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,388 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
5
+ import { approvalDifferences } from "../approve/approval.js";
6
+ import { readArgs } from "../args.js";
7
+ import { callerOf, describeCaller, isOwner, mayChangeTeam } from "../caller.js";
8
+ import { insideTrust } from "../file/paths.js";
9
+ import { loadTeamFile } from "../file/load.js";
10
+ import { logLine } from "../log.js";
11
+ import { emptySession, readState, withLock, writeAtomic, STATE_FILE } from "../state.js";
12
+ import { fillPattern, publicNameHit, realLanding, taskProblem } from "../worktree/place.js";
13
+ export const realSources = {
14
+ home: homedir(),
15
+ now: () => new Date(),
16
+ };
17
+ export const USAGE = `Usage: team worktree new <task> [--kind <kind>] [--seat <name>] [--session <name>] [--file <path>]
18
+ team worktree remove <task> [--session <name>] [--file <path>]
19
+ Ignored files in the worktree are deleted with it.
20
+ `;
21
+ export const worktree = (argv, io) => runWorktree(argv, io, realSources);
22
+ export default worktree;
23
+ export async function runWorktree(argv, io, sources = realSources) {
24
+ const args = readArgs(argv, ['kind', 'seat', 'session', 'file'], []);
25
+ const sub = args.rest[0];
26
+ const task = args.rest[1];
27
+ if (args.error) {
28
+ io.stderr(`team worktree: ${args.error}\n${USAGE}`);
29
+ return 2;
30
+ }
31
+ if (sub !== 'new' && sub !== 'remove') {
32
+ io.stderr(`team worktree: ${sub ? `unknown subcommand "${sub}"` : 'a subcommand is required'}\n${USAGE}`);
33
+ return 2;
34
+ }
35
+ if (!task) {
36
+ io.stderr(`team worktree: a task name is required\n${USAGE}`);
37
+ return 2;
38
+ }
39
+ if (args.rest.length > 2) {
40
+ io.stderr(`team worktree: unexpected "${args.rest[2]}"\n${USAGE}`);
41
+ return 2;
42
+ }
43
+ if (sub === 'remove' && (args.values.kind || args.values.seat)) {
44
+ io.stderr(`team worktree: remove takes no --kind or --seat\n${USAGE}`);
45
+ return 2;
46
+ }
47
+ const loaded = loadTeamFile(io.cwd, args.values.file ? { file: args.values.file } : {});
48
+ if (!loaded.ok) {
49
+ for (const problem of loaded.errors) {
50
+ io.stderr(`team worktree: ${problem.line ? `line ${problem.line}: ` : ''}${problem.message}\n`);
51
+ }
52
+ return 2;
53
+ }
54
+ const { team, root } = loaded;
55
+ const caller = callerOf(io);
56
+ if (args.values.file && !isOwner(caller)) {
57
+ io.stderr(`team worktree: --file is the owner's, from a terminal outside herdr; this call is ${describeCaller(caller)}\n`);
58
+ return 1;
59
+ }
60
+ if (!mayChangeTeam(caller, team)) {
61
+ io.stderr(`team worktree: only the owner, the coordinator or the operator runs it; this call is ${describeCaller(caller)}\n`);
62
+ return 1;
63
+ }
64
+ const session = args.values.session ?? team.session;
65
+ if (session === 'default') {
66
+ io.stderr('team worktree: session can\'t be "default", herdr\'s own session\n');
67
+ return 1;
68
+ }
69
+ const differences = approvalDifferences(team, root, sources.home);
70
+ if (differences === null) {
71
+ io.stderr('team worktree: the file was never approved on this machine: run `team approve`\n');
72
+ return 1;
73
+ }
74
+ if (differences.length) {
75
+ io.stderr(`team worktree: the file is not the approved one (${differences.join('; ')}): run \`team approve\`\n`);
76
+ return 1;
77
+ }
78
+ const dir = dirname(loaded.path);
79
+ const who = describeCaller(caller);
80
+ if (sub === 'new')
81
+ return create(io, sources, team, root, dir, session, task, args.values.kind, args.values.seat, who);
82
+ return removeWorktree(io, sources, team, root, dir, session, task, who);
83
+ }
84
+ function create(io, sources, team, root, dir, session, task, kind, seatName, who) {
85
+ const named = taskProblem(task);
86
+ if (named) {
87
+ io.stderr(`team worktree: ${named}\n`);
88
+ return 1;
89
+ }
90
+ if (kind !== undefined && taskProblem(kind)) {
91
+ io.stderr(`team worktree: --kind: ${taskProblem(kind)}\n`);
92
+ return 1;
93
+ }
94
+ if (team.workspace.mode === 'shared') {
95
+ io.stderr('team worktree: workspace.mode is shared: this team keeps one checkout, so there is no task worktree to create\n');
96
+ return 1;
97
+ }
98
+ const pathPattern = team.workspace.path;
99
+ const base = team.workspace.base;
100
+ if (!pathPattern || !base) {
101
+ io.stderr('team worktree: workspace.path and workspace.base are required\n');
102
+ return 1;
103
+ }
104
+ if (pathPattern.includes('{kind}')) {
105
+ io.stderr('team worktree: workspace.path fills {repo} and {task} only\n');
106
+ return 1;
107
+ }
108
+ const branchPattern = team.workspace.branch;
109
+ if (branchPattern.includes('{kind}') && kind === undefined) {
110
+ io.stderr(`team worktree: --kind is required: the branch pattern is ${JSON.stringify(branchPattern)}\n`);
111
+ return 1;
112
+ }
113
+ if (!branchPattern.includes('{kind}') && kind !== undefined) {
114
+ io.stderr(`team worktree: --kind has nowhere to go: the branch pattern is ${JSON.stringify(branchPattern)}\n`);
115
+ return 1;
116
+ }
117
+ const branch = fillPattern(branchPattern, { kind, task });
118
+ const folder = fillPattern(pathPattern, { repo: team.project, task });
119
+ if (!branch || !folder) {
120
+ io.stderr('team worktree: the branch or path pattern has a placeholder other than {kind}, {task} or {repo}\n');
121
+ return 1;
122
+ }
123
+ const hit = publicNameHit(team, [task, kind, branch, basename(folder)].filter((name) => Boolean(name)));
124
+ if (hit) {
125
+ io.stderr(`team worktree: ${hit}; a public project refuses that name\n`);
126
+ return 1;
127
+ }
128
+ if (!insideTrust(folder, team.trust)) {
129
+ io.stderr(`team worktree: ${folder} is outside the approved trust paths\n`);
130
+ return 1;
131
+ }
132
+ const landing = realLanding(root, folder);
133
+ if (landing.real !== landing.logical) {
134
+ const rel = relative(realpathSync(root), landing.real).split(sep).join('/');
135
+ if (!insideTrust(rel, team.trust)) {
136
+ io.stderr(`team worktree: ${folder} follows a symlink to ${landing.real}, which is outside the approved trust paths\n`);
137
+ return 1;
138
+ }
139
+ }
140
+ if (seatName !== undefined && !team.seats.some((seat) => seat.name === seatName)) {
141
+ io.stderr(`team worktree: --seat ${JSON.stringify(seatName)} names no declared seat\n`);
142
+ return 1;
143
+ }
144
+ const absolute = landing.logical;
145
+ let refused = null;
146
+ const outcome = withLock(dir, () => {
147
+ const state = readState(dir);
148
+ refused = openRefusal(state, team.workspace.limit, task, session);
149
+ if (refused)
150
+ return null;
151
+ if (existsSync(absolute)) {
152
+ refused = `${folder} already exists`;
153
+ return null;
154
+ }
155
+ const branchTaken = git(root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
156
+ if (branchTaken.code === 0) {
157
+ refused = `branch ${branch} already exists`;
158
+ return null;
159
+ }
160
+ const published = publishedBranch(root, branch);
161
+ if (published) {
162
+ refused = `${published} already exists`;
163
+ return null;
164
+ }
165
+ const format = git(root, ['check-ref-format', '--branch', branch]);
166
+ if (format.code !== 0) {
167
+ refused = `${JSON.stringify(branch)} is not a branch name`;
168
+ return null;
169
+ }
170
+ const start = startPoint(root, base);
171
+ if ('error' in start) {
172
+ refused = start.error;
173
+ return null;
174
+ }
175
+ mkdirSync(dirname(absolute), { recursive: true });
176
+ const added = git(root, ['worktree', 'add', '-q', '-b', branch, absolute, start.ref]);
177
+ if (added.code !== 0) {
178
+ refused = `the worktree was not created: ${firstLine(added.stderr || added.stdout)}`;
179
+ return null;
180
+ }
181
+ const setup = runSetup(absolute, team.workspace.setup);
182
+ const record = {
183
+ path: folder,
184
+ branch,
185
+ own_commits: false,
186
+ ...(seatName ? { seat: seatName } : {}),
187
+ setup: setup.ok ? 'ok' : 'failed',
188
+ };
189
+ const sessionState = state.sessions[session] ?? emptySession();
190
+ state.sessions[session] = sessionState;
191
+ sessionState.worktrees[task] = record;
192
+ writeAtomic(join(dir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
193
+ return { start, setup };
194
+ });
195
+ if (refused || !outcome) {
196
+ io.stderr(`team worktree: ${refused ?? 'nothing was created'}\n`);
197
+ return 1;
198
+ }
199
+ if (!outcome.setup.ok) {
200
+ logLine(dir, 'worktree new', who, `created ${task} at ${folder} on ${branch}; setup failed on command ${outcome.setup.at}`, sources.now());
201
+ io.stderr(`team worktree: setup failed on command ${outcome.setup.at} of ${team.workspace.setup.length}; the worktree is kept and recorded as setup: failed\n`);
202
+ io.stdout(`${folder}\n`);
203
+ return 1;
204
+ }
205
+ const note = outcome.start.note ? `; ${outcome.start.note}` : '';
206
+ logLine(dir, 'worktree new', who, `created ${task} at ${folder} on ${branch}${note}`, sources.now());
207
+ io.stdout(`${folder}\n`);
208
+ return 0;
209
+ }
210
+ export function removeWorktree(io, sources, team, root, dir, session, task, who) {
211
+ const named = taskProblem(task);
212
+ if (named) {
213
+ io.stderr(`team worktree: ${named}\n`);
214
+ return 1;
215
+ }
216
+ let refused = null;
217
+ const removed = withLock(dir, () => {
218
+ const state = readState(dir);
219
+ const found = findWorktree(state, task, session);
220
+ if (!found) {
221
+ refused = `no worktree named ${JSON.stringify(task)} is recorded`;
222
+ return false;
223
+ }
224
+ if (!found.record) {
225
+ refused = `${JSON.stringify(task)} is recorded in session ${found.elsewhere.join(', ')}, not in ${session}; nothing was removed`;
226
+ return false;
227
+ }
228
+ const occupant = seatInTask(state, task);
229
+ if (occupant) {
230
+ refused = `seat ${occupant} is recorded in this worktree; team remove ${occupant} first`;
231
+ return false;
232
+ }
233
+ const record = found.record;
234
+ const absolute = resolve(root, record.path);
235
+ if (!existsSync(absolute)) {
236
+ git(root, ['worktree', 'prune']);
237
+ delete found.session.worktrees[task];
238
+ writeAtomic(join(dir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
239
+ return 'gone';
240
+ }
241
+ const porcelain = git(absolute, ['status', '--porcelain']);
242
+ if (porcelain.code !== 0) {
243
+ refused = `couldn't read the worktree: ${firstLine(porcelain.stderr)}`;
244
+ return false;
245
+ }
246
+ const dirty = porcelain.stdout.split('\n').filter((line) => line !== '');
247
+ if (dirty.length) {
248
+ refused = `uncommitted or untracked files:\n${list(dirty)}`;
249
+ return false;
250
+ }
251
+ const unpublished = commitsOnNoRemote(root, absolute, record.branch, team.workspace.base);
252
+ if (unpublished.length) {
253
+ refused = `commits on no remote branch:\n${list(unpublished)}`;
254
+ return false;
255
+ }
256
+ const gone = git(root, ['worktree', 'remove', absolute]);
257
+ if (gone.code !== 0) {
258
+ refused = `the worktree was not removed: ${firstLine(gone.stderr || gone.stdout)}`;
259
+ return false;
260
+ }
261
+ delete found.session.worktrees[task];
262
+ writeAtomic(join(dir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
263
+ return record.branch;
264
+ });
265
+ if (refused || removed === false) {
266
+ io.stderr(`team worktree: ${refused ?? 'nothing was removed'}\n`);
267
+ return 1;
268
+ }
269
+ if (removed === 'gone') {
270
+ logLine(dir, 'worktree remove', who, `removed the record of ${task}; its folder was already gone`, sources.now());
271
+ io.stdout(`removed the record of ${task}; its folder was already gone. No branch was deleted.\n`);
272
+ return 0;
273
+ }
274
+ logLine(dir, 'worktree remove', who, `removed ${task}; kept branch ${removed}`, sources.now());
275
+ io.stdout(`removed ${task}; the branch ${removed} is kept\n`);
276
+ return 0;
277
+ }
278
+ function openRefusal(state, limit, task, session) {
279
+ let open = 0;
280
+ for (const [name, recorded] of Object.entries(state.sessions)) {
281
+ open += Object.keys(recorded.worktrees).length;
282
+ if (recorded.worktrees[task]) {
283
+ return name === session
284
+ ? `${JSON.stringify(task)} is already recorded`
285
+ : `${JSON.stringify(task)} is already recorded in session ${name}`;
286
+ }
287
+ }
288
+ if (open >= limit)
289
+ return `the worktree limit is ${limit}, and ${open} are open`;
290
+ return null;
291
+ }
292
+ // The record in the session this call is for. A record that lives only in another session is
293
+ // reported, not removed: `--session` chooses, and one call never clears two.
294
+ function findWorktree(state, task, sessionName) {
295
+ const recorded = state.sessions[sessionName];
296
+ const record = recorded?.worktrees[task];
297
+ if (record && recorded)
298
+ return { elsewhere: [], session: recorded, record };
299
+ const elsewhere = Object.entries(state.sessions)
300
+ .filter(([name, session]) => name !== sessionName && session.worktrees[task])
301
+ .map(([name]) => name);
302
+ if (elsewhere.length)
303
+ return { elsewhere, session: recorded ?? emptySession(), record: undefined };
304
+ return null;
305
+ }
306
+ function seatInTask(state, task) {
307
+ for (const recorded of Object.values(state.sessions)) {
308
+ for (const [name, seat] of Object.entries(recorded.seats)) {
309
+ if (seat.temporary?.task === task)
310
+ return name;
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+ // The commit the new branch starts from: the base's upstream after a fetch, or the local base
316
+ // when the base has no upstream. A failed fetch creates nothing.
317
+ function startPoint(root, base) {
318
+ const local = git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${base}`]);
319
+ if (local.code !== 0)
320
+ return { error: `workspace.base ${JSON.stringify(base)} is not a branch here; nothing was created` };
321
+ const upstream = git(root, ['rev-parse', '--abbrev-ref', `${base}@{upstream}`]);
322
+ if (upstream.code !== 0) {
323
+ return { ref: base, note: `base ${base} has no upstream, so the worktree starts from the local branch` };
324
+ }
325
+ const remote = upstream.stdout.trim();
326
+ const slash = remote.indexOf('/');
327
+ if (slash <= 0)
328
+ return { error: `base ${base} tracks ${JSON.stringify(remote)}, which isn't a remote branch; nothing was created` };
329
+ const fetched = git(root, ['fetch', '--quiet', remote.slice(0, slash), remote.slice(slash + 1)]);
330
+ if (fetched.code !== 0) {
331
+ return { error: `couldn't fetch ${remote}: ${firstLine(fetched.stderr || fetched.stdout) || 'fetch failed'}. Nothing was created.` };
332
+ }
333
+ return { ref: remote };
334
+ }
335
+ // The recorded branch and whatever HEAD the worktree has checked out. A detached HEAD, or
336
+ // another branch, is not the recorded branch; removing the folder drops that reflog, so a commit
337
+ // that lives only there is named and the folder stays. With no remote-tracking refs, unpublished
338
+ // means not in the base.
339
+ function commitsOnNoRemote(root, worktree, branch, base) {
340
+ const remotes = git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes']);
341
+ const args = remotes.stdout.trim()
342
+ ? ['rev-list', '--oneline', 'HEAD', branch, '--not', '--remotes']
343
+ : ['rev-list', '--oneline', 'HEAD', branch, '--not', base ?? 'HEAD'];
344
+ const listed = git(worktree, args);
345
+ if (listed.code !== 0)
346
+ return [`couldn't list commits: ${firstLine(listed.stderr)}`];
347
+ return listed.stdout.split('\n').filter((line) => line !== '');
348
+ }
349
+ function runSetup(cwd, commands) {
350
+ for (let i = 0; i < commands.length; i++) {
351
+ const command = commands[i];
352
+ const result = spawnSync('sh', ['-c', command], {
353
+ cwd,
354
+ encoding: 'utf8',
355
+ stdio: ['ignore', 'pipe', 'pipe'],
356
+ });
357
+ if ((result.status ?? 1) !== 0)
358
+ return { ok: false, at: i + 1 };
359
+ }
360
+ return { ok: true };
361
+ }
362
+ function publishedBranch(root, branch) {
363
+ const listed = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes']);
364
+ for (const ref of listed.stdout.split('\n')) {
365
+ const slash = ref.indexOf('/');
366
+ if (slash > 0 && ref.slice(slash + 1) === branch)
367
+ return ref;
368
+ }
369
+ return null;
370
+ }
371
+ function git(cwd, args) {
372
+ const result = spawnSync('git', args, {
373
+ cwd,
374
+ encoding: 'utf8',
375
+ stdio: ['ignore', 'pipe', 'pipe'],
376
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
377
+ });
378
+ return { code: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
379
+ }
380
+ function firstLine(text) {
381
+ return text.trim().split('\n')[0] ?? '';
382
+ }
383
+ function list(lines) {
384
+ const shown = lines.slice(0, 20).map((line) => ` ${line}`);
385
+ if (lines.length > 20)
386
+ shown.push(` and ${lines.length - 20} more`);
387
+ return shown.join('\n');
388
+ }
@@ -0,0 +1,28 @@
1
+ export type MergeVerdict = {
2
+ verdict: 'merged';
3
+ detail: string;
4
+ } | {
5
+ verdict: 'open';
6
+ detail: string;
7
+ } | {
8
+ verdict: 'unproven';
9
+ detail: string;
10
+ };
11
+ export declare function judgeMerge(input: {
12
+ fetch: 'ok' | 'failed';
13
+ branch: 'present' | 'gone';
14
+ /** `git rev-list <branch> --not origin/<base>` after the fetch. Empty when the branch has nothing of its own. */
15
+ ownNow: readonly string[];
16
+ /** True once an earlier pass saw a non-empty list. A branch that never had one is never merged. */
17
+ ownBefore: boolean;
18
+ }): MergeVerdict;
19
+ /** The branch is a local branch or a remote-tracking one. Null when git can't be asked. */
20
+ export declare function branchPresent(root: string, branch: string): boolean | null;
21
+ export type MergeRead = MergeVerdict & {
22
+ ownNow: number;
23
+ };
24
+ /**
25
+ * Reads the branch after fetching its base. `ownBefore` is the state's record. A failed fetch
26
+ * or a missing branch does not say the work is merged.
27
+ */
28
+ export declare function readMerge(root: string, branch: string, base: string, ownBefore: boolean): MergeRead;
@@ -0,0 +1,77 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ export function judgeMerge(input) {
3
+ if (input.fetch === 'failed') {
4
+ return { verdict: 'unproven', detail: 'the fetch failed; nothing is removed' };
5
+ }
6
+ if (input.branch === 'gone') {
7
+ return {
8
+ verdict: 'unproven',
9
+ detail: 'the branch is gone; a squash or a rebase would look the same, so nothing is removed',
10
+ };
11
+ }
12
+ if (input.ownNow.length > 0) {
13
+ return { verdict: 'open', detail: 'the branch still has commits of its own' };
14
+ }
15
+ if (!input.ownBefore) {
16
+ return { verdict: 'open', detail: 'the branch never had a commit of its own' };
17
+ }
18
+ return { verdict: 'merged', detail: 'its own commits are in the base' };
19
+ }
20
+ function git(cwd, args) {
21
+ const result = spawnSync('git', args, {
22
+ cwd,
23
+ encoding: 'utf8',
24
+ stdio: ['ignore', 'pipe', 'pipe'],
25
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
26
+ });
27
+ return { code: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
28
+ }
29
+ /** The branch is a local branch or a remote-tracking one. Null when git can't be asked. */
30
+ export function branchPresent(root, branch) {
31
+ const listed = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes']);
32
+ if (listed.code !== 0)
33
+ return null;
34
+ return listed.stdout.split('\n').some((ref) => {
35
+ if (ref === branch)
36
+ return true;
37
+ const slash = ref.indexOf('/');
38
+ return slash > 0 && ref.slice(slash + 1) === branch;
39
+ });
40
+ }
41
+ /**
42
+ * Reads the branch after fetching its base. `ownBefore` is the state's record. A failed fetch
43
+ * or a missing branch does not say the work is merged.
44
+ */
45
+ export function readMerge(root, branch, base, ownBefore) {
46
+ const upstream = git(root, ['rev-parse', '--abbrev-ref', `${base}@{upstream}`]);
47
+ if (upstream.code !== 0) {
48
+ return {
49
+ verdict: 'unproven',
50
+ ownNow: 0,
51
+ detail: `base ${base} has no upstream, so the fetch can't be checked; nothing is removed`,
52
+ };
53
+ }
54
+ const remote = upstream.stdout.trim();
55
+ const slash = remote.indexOf('/');
56
+ if (slash <= 0)
57
+ return { verdict: 'unproven', ownNow: 0, detail: 'the fetch failed; nothing is removed' };
58
+ const fetched = git(root, ['fetch', '--quiet', remote.slice(0, slash), remote.slice(slash + 1)]);
59
+ if (fetched.code !== 0)
60
+ return { ...judgeMerge({ fetch: 'failed', branch: 'present', ownNow: [], ownBefore }), ownNow: 0 };
61
+ const present = branchPresent(root, branch);
62
+ if (present === null)
63
+ return { verdict: 'unproven', ownNow: 0, detail: 'the branch could not be read; nothing is removed' };
64
+ if (!present)
65
+ return { ...judgeMerge({ fetch: 'ok', branch: 'gone', ownNow: [], ownBefore }), ownNow: 0 };
66
+ const listed = git(root, ['rev-list', '--oneline', branch, '--not', remote]);
67
+ if (listed.code !== 0)
68
+ return { verdict: 'unproven', ownNow: 0, detail: 'the branch could not be read; nothing is removed' };
69
+ const ownNow = listed.stdout.split('\n').filter((line) => line !== '');
70
+ // A squash leaves the branch's commits in place while its tree matches the base. That is not a
71
+ // merge this version can prove, and it is not treated as one. A branch whose tree still differs
72
+ // is ordinary open work and stays quiet.
73
+ if (ownNow.length > 0 && git(root, ['diff', '--quiet', remote, branch]).code === 0) {
74
+ return { verdict: 'open', ownNow: ownNow.length, detail: 'merged? not provable' };
75
+ }
76
+ return { ...judgeMerge({ fetch: 'ok', branch: 'present', ownNow, ownBefore }), ownNow: ownNow.length };
77
+ }
@@ -0,0 +1,13 @@
1
+ import type { Problem, TeamFile } from './types.ts';
2
+ export type Current = {
3
+ ok: true;
4
+ team: TeamFile;
5
+ root: string;
6
+ dir: string;
7
+ warnings: Problem[];
8
+ notice?: string;
9
+ } | {
10
+ ok: false;
11
+ errors: Problem[];
12
+ };
13
+ export declare function currentTeam(cwd: string, file: string | undefined, now: Date): Current;
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { readState, updateState } from "../state.js";
4
+ import { loadTeamFile } from "./load.js";
5
+ import { validateTeamFile } from "./validate.js";
6
+ export function currentTeam(cwd, file, now) {
7
+ const loaded = loadTeamFile(cwd, file ? { file } : {});
8
+ if (loaded.ok) {
9
+ const dir = dirname(loaded.path);
10
+ remember(dir, loaded.path, now);
11
+ return { ok: true, team: loaded.team, root: loaded.root, dir, warnings: loaded.warnings };
12
+ }
13
+ if (!loaded.path || !existsSync(loaded.path))
14
+ return { ok: false, errors: loaded.errors };
15
+ const dir = dirname(loaded.path);
16
+ const saved = lastValid(dir);
17
+ if (!saved)
18
+ return { ok: false, errors: loaded.errors };
19
+ const first = loaded.errors[0];
20
+ return {
21
+ ok: true,
22
+ team: saved.team,
23
+ root: dir.endsWith('.agents') ? dirname(dir) : dir,
24
+ dir,
25
+ warnings: [],
26
+ notice: `team.yaml is invalid (${first && first.line ? `line ${first.line}: ` : ''}${first?.message ?? 'unreadable'}); using the copy of ${saved.readAt}`,
27
+ };
28
+ }
29
+ function remember(dir, path, now) {
30
+ const file = readFileSync(path, 'utf8');
31
+ try {
32
+ if (readState(dir).last_valid?.file === file)
33
+ return;
34
+ updateState(dir, (state) => {
35
+ state.last_valid = { read_at: now.toISOString(), file };
36
+ });
37
+ }
38
+ catch {
39
+ // A state that can't be written must not stop a command that only reads.
40
+ }
41
+ }
42
+ function lastValid(dir) {
43
+ try {
44
+ const saved = readState(dir).last_valid;
45
+ if (!saved)
46
+ return null;
47
+ const result = validateTeamFile(saved.file);
48
+ return result.ok ? { team: result.team, readAt: saved.read_at } : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
@@ -0,0 +1,25 @@
1
+ export type SeatBlock = {
2
+ start: number;
3
+ end: number;
4
+ name: string | null;
5
+ count: number;
6
+ };
7
+ /** The seat entries under `seats:`, in order. A file with no seats section has none. */
8
+ export declare function seatBlocks(text: string): SeatBlock[];
9
+ /** Whether `name` is a seat entry, including one that `count` expands to. */
10
+ export declare function hasSeat(text: string, name: string): boolean;
11
+ /** One `count` entry becomes one explicit seat per instance. Other lines are copied, not rebuilt. */
12
+ export declare function rewriteCount(text: string, declared: string): string | null;
13
+ /** True when this seat's own entry, or the count entry it comes from, says stopped. */
14
+ export declare function seatIsStopped(text: string, name: string): boolean;
15
+ /** Drops `stopped: true` from the entry whose name is `name`. A counted entry is split first. */
16
+ export declare function clearStopped(text: string, name: string): string;
17
+ /** Takes `name` out of the file. A counted entry is split first, so the other instances stay. */
18
+ export declare function takeOut(text: string, name: string): string;
19
+ /** Leaves `name` in the file with `stopped: true`. A counted entry is split first. */
20
+ export declare function markStopped(text: string, name: string): string;
21
+ /**
22
+ * Puts `name`'s entry back from `approved` when the current text lacks it.
23
+ * The entry is inserted after whichever approved neighbour is still present.
24
+ */
25
+ export declare function restoreSeat(current: string, approved: string, name: string): string;