team 0.1.1 → 0.2.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 (127) hide show
  1. package/README.md +38 -12
  2. package/dist/ansi.d.ts +13 -0
  3. package/dist/ansi.js +75 -0
  4. package/dist/approve/approval.d.ts +11 -0
  5. package/dist/approve/approval.js +80 -8
  6. package/dist/approve/fingerprint.d.ts +29 -6
  7. package/dist/approve/fingerprint.js +57 -32
  8. package/dist/budgets/gate.d.ts +5 -4
  9. package/dist/budgets/gate.js +23 -14
  10. package/dist/budgets/readings.d.ts +57 -9
  11. package/dist/budgets/readings.js +134 -18
  12. package/dist/budgets/run.js +8 -2
  13. package/dist/budgets/table.d.ts +4 -2
  14. package/dist/budgets/table.js +29 -11
  15. package/dist/cli.d.ts +2 -1
  16. package/dist/cli.js +8 -5
  17. package/dist/commands/add.d.ts +0 -1
  18. package/dist/commands/add.js +18 -9
  19. package/dist/commands/approve.js +17 -1
  20. package/dist/commands/doctor.d.ts +17 -1
  21. package/dist/commands/doctor.js +128 -8
  22. package/dist/commands/down.d.ts +6 -2
  23. package/dist/commands/down.js +40 -14
  24. package/dist/commands/init.d.ts +1 -1
  25. package/dist/commands/init.js +4 -2
  26. package/dist/commands/remove.d.ts +2 -0
  27. package/dist/commands/remove.js +26 -4
  28. package/dist/commands/status.d.ts +2 -0
  29. package/dist/commands/status.js +22 -3
  30. package/dist/commands/up.d.ts +3 -1
  31. package/dist/commands/up.js +12 -6
  32. package/dist/commands/watch.d.ts +4 -0
  33. package/dist/commands/watch.js +99 -19
  34. package/dist/conformance/adapter.d.ts +5 -0
  35. package/dist/conformance/adapter.js +44 -0
  36. package/dist/file/check.d.ts +20 -0
  37. package/dist/file/check.js +112 -0
  38. package/dist/file/lines.js +13 -2
  39. package/dist/file/sections/budgets.d.ts +15 -0
  40. package/dist/file/sections/budgets.js +180 -0
  41. package/dist/file/sections/coordinator.d.ts +2 -0
  42. package/dist/file/sections/coordinator.js +11 -0
  43. package/dist/file/sections/format.d.ts +2 -0
  44. package/dist/file/sections/format.js +13 -0
  45. package/dist/file/sections/identity.d.ts +2 -0
  46. package/dist/file/sections/identity.js +103 -0
  47. package/dist/file/sections/index.d.ts +9 -0
  48. package/dist/file/sections/index.js +43 -0
  49. package/dist/file/sections/lead.d.ts +4 -0
  50. package/dist/file/sections/lead.js +18 -0
  51. package/dist/file/sections/limits.d.ts +2 -0
  52. package/dist/file/sections/limits.js +43 -0
  53. package/dist/file/sections/machine.d.ts +2 -0
  54. package/dist/file/sections/machine.js +38 -0
  55. package/dist/file/sections/operator.d.ts +2 -0
  56. package/dist/file/sections/operator.js +11 -0
  57. package/dist/file/sections/project.d.ts +2 -0
  58. package/dist/file/sections/project.js +13 -0
  59. package/dist/file/sections/rules.d.ts +2 -0
  60. package/dist/file/sections/rules.js +9 -0
  61. package/dist/file/sections/seats.d.ts +7 -0
  62. package/dist/file/sections/seats.js +161 -0
  63. package/dist/file/sections/section.d.ts +32 -0
  64. package/dist/file/sections/section.js +7 -0
  65. package/dist/file/sections/session.d.ts +2 -0
  66. package/dist/file/sections/session.js +16 -0
  67. package/dist/file/sections/tools.d.ts +2 -0
  68. package/dist/file/sections/tools.js +43 -0
  69. package/dist/file/sections/trust.d.ts +2 -0
  70. package/dist/file/sections/trust.js +20 -0
  71. package/dist/file/sections/units.d.ts +16 -0
  72. package/dist/file/sections/units.js +8 -0
  73. package/dist/file/sections/visibility.d.ts +2 -0
  74. package/dist/file/sections/visibility.js +12 -0
  75. package/dist/file/sections/watch-checks.d.ts +9 -0
  76. package/dist/file/sections/watch-checks.js +14 -0
  77. package/dist/file/sections/watch.d.ts +14 -0
  78. package/dist/file/sections/watch.js +111 -0
  79. package/dist/file/sections/workspace.d.ts +4 -0
  80. package/dist/file/sections/workspace.js +77 -0
  81. package/dist/file/types.d.ts +6 -0
  82. package/dist/file/validate.d.ts +4 -12
  83. package/dist/file/validate.js +45 -668
  84. package/dist/herdr.d.ts +5 -0
  85. package/dist/herdr.js +51 -12
  86. package/dist/launch/agent.d.ts +1 -0
  87. package/dist/launch/agent.js +13 -0
  88. package/dist/launch/deliver.d.ts +7 -1
  89. package/dist/launch/deliver.js +159 -7
  90. package/dist/launch/execute.d.ts +4 -2
  91. package/dist/launch/execute.js +36 -13
  92. package/dist/launch/plan.js +16 -2
  93. package/dist/launch/rules.d.ts +4 -2
  94. package/dist/launch/rules.js +17 -3
  95. package/dist/profiles/antigravity.yaml +25 -2
  96. package/dist/profiles/claude-code.yaml +51 -6
  97. package/dist/profiles/codex.yaml +14 -1
  98. package/dist/profiles/cursor.yaml +46 -3
  99. package/dist/profiles/overrides.d.ts +67 -0
  100. package/dist/profiles/overrides.js +190 -0
  101. package/dist/profiles/profile.d.ts +3 -0
  102. package/dist/profiles/profile.js +5 -1
  103. package/dist/state.d.ts +9 -4
  104. package/dist/state.js +37 -0
  105. package/dist/status/compare.js +7 -4
  106. package/dist/status/statusline.js +3 -1
  107. package/dist/store/store.d.ts +5 -0
  108. package/dist/version.d.ts +1 -0
  109. package/dist/version.js +7 -0
  110. package/dist/watch/check.d.ts +1 -0
  111. package/dist/watch/checks/budget.js +50 -51
  112. package/dist/watch/dialect.d.ts +6 -1
  113. package/dist/watch/dialect.js +68 -12
  114. package/dist/watch/pass.d.ts +10 -2
  115. package/dist/watch/pass.js +70 -29
  116. package/dist/watch/screen-core.d.ts +41 -4
  117. package/dist/watch/screen-core.js +510 -89
  118. package/dist/watch/screen-data.d.ts +34 -0
  119. package/dist/watch/screen-file.d.ts +8 -2
  120. package/dist/watch/screen-file.js +344 -39
  121. package/dist/watch/screen-profile.d.ts +19 -0
  122. package/dist/watch/screen-profile.js +1 -0
  123. package/dist/watch/screen.d.ts +25 -6
  124. package/dist/watch/screen.js +48 -9
  125. package/examples/team.yaml +8 -0
  126. package/package.json +9 -3
  127. package/schema/team.schema.json +498 -0
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, resolve } from 'node:path';
4
- import { approvalDifferences, budgetsInForce } from "../approve/approval.js";
4
+ import { approvalDifferences, budgetsInForce, recordSeatDigest } from "../approve/approval.js";
5
5
  import { readArgs } from "../args.js";
6
6
  import { callerOf, describeCaller, isOwner, mayChangeTeam } from "../caller.js";
7
7
  import { blocksLaunch, doctorFindings, realSources as doctorSources } from "../commands/doctor.js";
@@ -13,7 +13,7 @@ import { clearStopped, hasSeat, restoreSeat, seatIsStopped } from "../file/lines
13
13
  import { loadTeamFile, placedProblems } from "../file/load.js";
14
14
  import { validateTeamFile } from "../file/validate.js";
15
15
  import { writeTeamFile } from "../file/write.js";
16
- import { agentList, agentRename, paneRead, paneRun, sessionRunning, sessionState, startServer, workspaceClose, workspaceCreate, workspaceList, } from "../herdr.js";
16
+ import { agentList, agentRename, paneForeground, paneRead, paneRun, sessionRunning, sessionState, startServer, workspaceClose, workspaceCreate, workspaceList, } from "../herdr.js";
17
17
  import { executePlan } from "../launch/execute.js";
18
18
  import { formatPlan, upPlan } from "../launch/plan.js";
19
19
  import { logLine } from "../log.js";
@@ -36,6 +36,7 @@ const realLaunch = {
36
36
  return agents === null ? null : agents.map((agent) => agent.pane);
37
37
  },
38
38
  paneText: (session, pane) => paneRead(pane, 200, aim(session)),
39
+ foreground: (session, pane) => paneForeground(pane, aim(session)),
39
40
  sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
40
41
  now: () => new Date(),
41
42
  };
@@ -48,7 +49,7 @@ export const realSources = {
48
49
  agents: (session) => agentList(aim(session)),
49
50
  workspaces(session) {
50
51
  const listed = workspaceList(aim(session));
51
- return listed === null ? null : listed.map((workspace) => ({ id: workspace.id, label: workspace.label }));
52
+ return listed === null ? null : listed.map((workspace) => ({ id: workspace.id }));
52
53
  },
53
54
  doctor: doctorSources,
54
55
  machine: readMachine,
@@ -122,6 +123,7 @@ export async function runAdd(argv, io, sources = realSources) {
122
123
  return 1;
123
124
  }
124
125
  const agents = live === 'running' ? sources.agents(session) : [];
126
+ // Read so a session whose workspace list can't be read is refused. The title is not a key.
125
127
  const workspaces = live === 'running' ? sources.workspaces(session) : [];
126
128
  if (agents === null || workspaces === null) {
127
129
  io.stderr(`team add: session ${session} runs, and its agents can't be read\n`);
@@ -168,7 +170,10 @@ export async function runAdd(argv, io, sources = realSources) {
168
170
  const doctorTeam = built.temporary
169
171
  ? { ...prepared.team, seats: prepared.team.seats.map((item) => item.name === built.temporary?.like ? { ...item, stopped: false } : item) }
170
172
  : prepared.team;
171
- for (const finding of doctorFindings(doctorTeam, root, dir, session, sources.doctor, prepared.warnings)) {
173
+ // `team` is the file on disk, already the approved one. `doctorTeam` is the
174
+ // seat about to run, so a stopped seat's CLI is still checked. The digest is
175
+ // recorded with the write, after a refusal has left the file alone.
176
+ for (const finding of doctorFindings(doctorTeam, root, dir, session, sources.doctor, prepared.warnings, team)) {
172
177
  if (blocksLaunch(finding)) {
173
178
  io.stderr(`team add: ${finding.text}\n`);
174
179
  return 1;
@@ -198,8 +203,8 @@ export async function runAdd(argv, io, sources = realSources) {
198
203
  // The gate reads the approved budgets, never the edited file's: the seat this `add` inserts
199
204
  // changes no section of its own, and no unapproved reserve may unblock a launch (#50).
200
205
  const budgets = budgetsInForce(prepared.team, root, sources.home);
201
- const decision = seatBudget(budgets, loadReadings(dir, session), built.seat, sources.now().getTime(), loadSpendReadings(dir, session));
202
- const stray = unnamedIn(built.seat.label, agents, workspaces);
206
+ const decision = seatBudget(budgets, loadReadings(dir), built.seat, sources.now().getTime(), loadSpendReadings(dir));
207
+ const stray = unnamedIn(recorded.seats[built.name]?.workspace, agents);
203
208
  const starting = seatPlan(prepared.team, built.seat, start);
204
209
  const planned = stray
205
210
  ? { ...starting, stage: 'launched', pane: stray.pane, workspace: stray.workspace, agentLive: true }
@@ -246,6 +251,9 @@ export async function runAdd(argv, io, sources = realSources) {
246
251
  io.stderr(`team add: ${where(problem)}${problem.message}\n`);
247
252
  return 2;
248
253
  }
254
+ const parsed = validateTeamFile(built.edited);
255
+ if (parsed.ok)
256
+ recordSeatDigest(parsed.team, root, built.name, sources.home);
249
257
  }
250
258
  const plan = upPlan({ root, session, sessionRunning: live === 'running', seats: [seatForPlan], watchAlive: true });
251
259
  const who = describeCaller(caller);
@@ -347,9 +355,10 @@ function seatPlan(team, seat, start) {
347
355
  ...(start.lobby ? { lobby: true } : {}),
348
356
  };
349
357
  }
350
- function unnamedIn(label, agents, workspaces) {
351
- const ids = new Set(workspaces.filter((workspace) => workspace.label === label).map((workspace) => workspace.id));
352
- return agents.find((agent) => !agent.name && ids.has(agent.workspace));
358
+ function unnamedIn(workspace, agents) {
359
+ if (!workspace)
360
+ return undefined;
361
+ return agents.find((agent) => !agent.name && agent.workspace === workspace);
353
362
  }
354
363
  function runningOf(agents, team, state) {
355
364
  const seats = new Map(team.seats.map((seat) => [seat.name, seat]));
@@ -11,6 +11,7 @@ import { callerOf, describeCaller, isOwner } from "../caller.js";
11
11
  import { loadTeamFile, placedProblems } from "../file/load.js";
12
12
  import { validateTeamFile } from "../file/validate.js";
13
13
  import { logLine } from "../log.js";
14
+ import { OVERRIDE_CHANGED, overrideFile } from "../profiles/overrides.js";
14
15
  import { readApproval, storePath, writeApproval } from "../store/store.js";
15
16
  export const realSources = {
16
17
  async ask(question) {
@@ -86,6 +87,12 @@ export async function runApprove(argv, io, sources) {
86
87
  for (const warning of checked.warnings)
87
88
  io.stderr(`team approve: warning, line ${warning.line}: ${warning.message}\n`);
88
89
  const store = storePath(team.project, root, sources.home);
90
+ const live = overrideFile(team.project, root, sources.home);
91
+ if (live.problems.length) {
92
+ for (const problem of live.problems)
93
+ io.stderr(`team approve: ${problem}\n`);
94
+ return 2;
95
+ }
89
96
  const problem = storeProblem(store, root, team);
90
97
  if (problem) {
91
98
  io.stderr(`team approve: ${problem}\n`);
@@ -119,6 +126,15 @@ export async function runApprove(argv, io, sources) {
119
126
  io.stdout(`${path}: against the copy approved on ${previous.approval.approvedAt}:\n\n`);
120
127
  io.stdout(`${lines.map((line) => ` ${line}`).join('\n')}\n\n`);
121
128
  }
129
+ const recorded = Object.hasOwn(previous.approval, 'overrides') ? previous.approval.overrides ?? null : null;
130
+ if (recorded !== live.text) {
131
+ changes.push(OVERRIDE_CHANGED);
132
+ const overrideLines = formatDiff(recorded ?? '', live.text ?? '');
133
+ if (overrideLines.length) {
134
+ io.stdout(`overrides.yaml: against the copy approved on ${previous.approval.approvedAt}:\n\n`);
135
+ io.stdout(`${overrideLines.map((line) => ` ${line}`).join('\n')}\n\n`);
136
+ }
137
+ }
122
138
  io.stdout(changes.length ? `Needs a new approval: ${changes.join('; ')}.\n` : 'Nothing in it needs a new approval.\n');
123
139
  if (ceilingsLine(previous.approval.ceilings) !== ceilingsLine(ceilings)) {
124
140
  io.stdout(`Ceilings approved: ${ceilingsLine(previous.approval.ceilings)}.\n`);
@@ -139,7 +155,7 @@ export async function runApprove(argv, io, sources) {
139
155
  return 1;
140
156
  }
141
157
  const now = sources.now();
142
- writeApproval(store, { approval: approvalOf(team, root, now, resolved.checks), file: text }, team.seats);
158
+ writeApproval(store, { approval: { ...approvalOf(team, root, now, resolved.checks), overrides: live.text }, file: text }, team.seats);
143
159
  logLine(dirname(path), 'approve', describeCaller(caller), `approved ${seats} seats; ceilings: ${ceilingsLine(ceilings)}`, now);
144
160
  io.stdout(`Approved. The record is in ${store}; check the rest with \`team doctor\`.\n`);
145
161
  return 0;
@@ -1,3 +1,4 @@
1
+ import { type Caller } from '../caller.ts';
1
2
  import type { TeamFile } from '../file/types.ts';
2
3
  import type { Command, Io } from '../io.ts';
3
4
  import { type Profile } from '../profiles/profile.ts';
@@ -9,6 +10,7 @@ export type DoctorSources = {
9
10
  sessionRunning(session: string): boolean | null;
10
11
  now(): Date;
11
12
  home: string;
13
+ runCheck?(path: string): string | null;
12
14
  };
13
15
  export type CommandRunner = (binary: string, args: string[]) => {
14
16
  status: number | null;
@@ -30,9 +32,23 @@ export type Finding = {
30
32
  export declare function blocksLaunch(finding: Finding): boolean;
31
33
  export declare const doctor: Command;
32
34
  export default doctor;
35
+ /**
36
+ * The budget checks, one line per account, plus the accounts nothing can read. The checks run
37
+ * the way the watch runs them (RFC 0003 § 5): the approved command's file, hashed again before
38
+ * it runs, in a clean environment with the watch's timeout. The raw output is parsed and
39
+ * dropped — never printed, never logged, never written anywhere. Only the owner's `doctor` runs
40
+ * a check, for the same reason the watch is the owner's: a check reads a vendor home. Any other
41
+ * caller gets the same report without the readings, and the one line that says so.
42
+ */
43
+ export declare function budgetCheckFindings(team: TeamFile, root: string, sources: DoctorSources, caller: Caller): Finding[];
44
+ export declare function seatNameFindings(team: TeamFile, session: string): Finding[];
33
45
  export declare function doctorFindings(team: TeamFile, root: string, dir: string, session: string, sources: DoctorSources, warnings: {
34
46
  line: number;
35
47
  message: string;
36
- }[]): Finding[];
48
+ }[],
49
+ /** The file as approved. `add` hands the team about to run, whose `stopped` mark is already cleared. */
50
+ approved?: TeamFile,
51
+ /** The budget-check lines, which only the report runs: `up` and `add` scan, they never run a check. */
52
+ budgetChecks?: Finding[]): Finding[];
37
53
  export declare function doctorLoginFindings(team: TeamFile, sources: DoctorSources): Finding[];
38
54
  export declare function runDoctor(argv: string[], io: Io, sources: DoctorSources): Promise<number>;
@@ -1,13 +1,17 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { accessSync, constants } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { accessSync, constants, readFileSync, statSync } from 'node:fs';
3
4
  import { homedir } from 'node:os';
4
5
  import { delimiter, dirname, isAbsolute, join } from 'node:path';
5
6
  import { approvalDifferences, budgetsInForce, watchInForce } from "../approve/approval.js";
6
7
  import { readArgs } from "../args.js";
7
- import { checkReadings } from "../budgets/checks.js";
8
+ import { callerOf, isOwner } from "../caller.js";
9
+ import { checkCommands } from "../budgets/checks.js";
10
+ import { parseOutput, runCommand } from "../budgets/run.js";
8
11
  import { loadTeamFile } from "../file/load.js";
9
12
  import { HERDR_TESTED, herdrVersion, sessionRunning } from "../herdr.js";
10
13
  import { profileFor } from "../profiles/index.js";
14
+ import { overridesInForce, quotaWith } from "../profiles/overrides.js";
11
15
  import { versionVerdict } from "../profiles/profile.js";
12
16
  import { readState } from "../state.js";
13
17
  import { readApproval, storePath } from "../store/store.js";
@@ -47,6 +51,7 @@ export const realSources = {
47
51
  sessionRunning,
48
52
  now: () => new Date(),
49
53
  home: homedir(),
54
+ runCheck: (path) => runCommand(path),
50
55
  };
51
56
  export const USAGE = 'Usage: team doctor [--session <name>] [--file <path>] [--login]\n';
52
57
  // A missing or stale watch is not a reason for `up` to refuse: `up` starts the watch itself.
@@ -75,13 +80,100 @@ function launchBinary(launch) {
75
80
  .split(/\s+/)
76
81
  .find((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) ?? null);
77
82
  }
83
+ // The approved bytes rule, as the watch applies it (budgets/checks.ts): a check whose file no
84
+ // longer hashes to the approval is not the approved check, and is not run.
85
+ function changedSinceApproval(check) {
86
+ try {
87
+ if (!statSync(check.path).isFile())
88
+ return true;
89
+ return createHash('sha256').update(readFileSync(check.path)).digest('hex') !== check.hash;
90
+ }
91
+ catch {
92
+ return true;
93
+ }
94
+ }
78
95
  function checkFindings(team, root, home) {
79
96
  const record = readApproval(storePath(team.project, root, home));
80
97
  if (!record)
81
98
  return [];
82
- return checkReadings(budgetsInForce(team, root, home), record.approval.checks).flatMap((reading) => reading.state === 'unknown'
83
- ? [{ level: 'warn', text: `the check for ${reading.account} is unapproved; that account reads unknown` }]
84
- : []);
99
+ const approved = record.approval.checks;
100
+ return checkCommands(budgetsInForce(team, root, home)).flatMap(({ account }) => {
101
+ const known = approved?.[account];
102
+ if (!known) {
103
+ return [{ level: 'warn', text: `the check for ${account} is unapproved; that account reads unknown` }];
104
+ }
105
+ if (changedSinceApproval(known)) {
106
+ return [{ level: 'warn', text: `the check for ${account} changed after approval and was not run; that account reads unknown` }];
107
+ }
108
+ return [];
109
+ });
110
+ }
111
+ // The CLIs of the seats that spend an account: its own `account:` when the file names one, its
112
+ // vendor otherwise — the gate's resolution.
113
+ function clisOf(team, account) {
114
+ return team.seats.filter((seat) => (seat.account ?? seat.vendor) === account).map((seat) => seat.cli);
115
+ }
116
+ function figureOf(reading) {
117
+ if (reading.kind === 'spend')
118
+ return `${reading.amount.toFixed(2)} ${reading.currency}`;
119
+ return reading.windows.map((one) => `${one.window} ${one.used}% used`).join(', ');
120
+ }
121
+ /**
122
+ * The budget checks, one line per account, plus the accounts nothing can read. The checks run
123
+ * the way the watch runs them (RFC 0003 § 5): the approved command's file, hashed again before
124
+ * it runs, in a clean environment with the watch's timeout. The raw output is parsed and
125
+ * dropped — never printed, never logged, never written anywhere. Only the owner's `doctor` runs
126
+ * a check, for the same reason the watch is the owner's: a check reads a vendor home. Any other
127
+ * caller gets the same report without the readings, and the one line that says so.
128
+ */
129
+ export function budgetCheckFindings(team, root, sources, caller) {
130
+ const budgets = budgetsInForce(team, root, sources.home);
131
+ const findings = [];
132
+ // An account nothing can read: its sources name the status line but no seat of its CLIs
133
+ // ships a quota pattern for it, or they name a check with no command. A file that names a
134
+ // `check` source without a command is refused on read; the guard is for the type, not the file.
135
+ for (const [name, account] of Object.entries(budgets.accounts)) {
136
+ const patterns = overridesInForce(team.project, root, sources.home).profiles;
137
+ const noPattern = account.sources.includes('status_line') && !clisOf(team, name).some((cli) => quotaWith(cli, patterns).some((one) => one.account === name));
138
+ const noCommand = account.sources.includes('check') && account.check === null;
139
+ if (noPattern || noCommand)
140
+ findings.push({ level: 'warn', text: `${name}: no pattern can read this account` });
141
+ }
142
+ const commands = checkCommands(budgets);
143
+ if (!isOwner(caller)) {
144
+ if (commands.length)
145
+ findings.push({ level: 'note', text: 'the budget checks were not run: only the owner runs them' });
146
+ return findings;
147
+ }
148
+ const record = readApproval(storePath(team.project, root, sources.home));
149
+ if (!record)
150
+ return findings;
151
+ const run = sources.runCheck ?? runCommand;
152
+ const now = sources.now().getTime();
153
+ for (const { account } of commands) {
154
+ const known = record.approval.checks?.[account];
155
+ const entry = budgets.accounts[account];
156
+ // Unapproved and changed checks are said above, and never run.
157
+ if (!known || !entry || changedSinceApproval(known))
158
+ continue;
159
+ const text = run(known.path);
160
+ if (text === null) {
161
+ findings.push({ level: 'warn', text: `the check for ${account} failed or timed out; that account reads unknown` });
162
+ continue;
163
+ }
164
+ const reading = parseOutput(entry, text, now);
165
+ findings.push(reading
166
+ ? { level: 'ok', text: `the check for ${account} reads ${figureOf(reading)}` }
167
+ : { level: 'warn', text: `the check for ${account} broke its output contract; that account reads unknown` });
168
+ }
169
+ return findings;
170
+ }
171
+ function overrideFindings(team, root, home) {
172
+ const report = overridesInForce(team.project, root, home);
173
+ return [
174
+ ...report.differences.map((line) => ({ level: 'miss', text: `run \`team approve\`: ${line}` })),
175
+ ...report.problems.map((line) => ({ level: 'miss', text: line })),
176
+ ];
85
177
  }
86
178
  function approvalFindings(team, root, home) {
87
179
  const differences = approvalDifferences(team, root, home);
@@ -150,12 +242,40 @@ function watchFindings(watch, dir, session, running, now) {
150
242
  }
151
243
  return [{ level: 'ok', text: 'the watch is running' }];
152
244
  }
153
- export function doctorFindings(team, root, dir, session, sources, warnings) {
245
+ // The session already carries the project, so a seat whose name or label repeats either is a
246
+ // warning, never a refusal. One line per seat, naming the field.
247
+ export function seatNameFindings(team, session) {
248
+ const needles = [...new Set([team.project, session].filter((value) => value !== ''))];
249
+ const findings = [];
250
+ for (const seat of team.seats) {
251
+ const hits = ['name', 'label'].map((field) => ({
252
+ field,
253
+ found: needles.filter((needle) => seat[field].toLowerCase().includes(needle.toLowerCase())),
254
+ })).filter((hit) => hit.found.length > 0);
255
+ if (hits.length === 0)
256
+ continue;
257
+ const same = hits.length === 2 && hits[0]?.found.join('\0') === hits[1]?.found.join('\0');
258
+ const which = same
259
+ ? `its name and its label repeat ${quoted(hits[0]?.found ?? [])}`
260
+ : hits.map((hit) => `its ${hit.field} repeats ${quoted(hit.found)}`).join(' and ');
261
+ findings.push({ level: 'warn', text: `${seat.name}: ${which}; the session already carries it` });
262
+ }
263
+ return findings;
264
+ }
265
+ function quoted(values) {
266
+ return values.map((value) => `"${value}"`).join(' and ');
267
+ }
268
+ export function doctorFindings(team, root, dir, session, sources, warnings,
269
+ /** The file as approved. `add` hands the team about to run, whose `stopped` mark is already cleared. */
270
+ approved = team,
271
+ /** The budget-check lines, which only the report runs: `up` and `add` scan, they never run a check. */
272
+ budgetChecks = []) {
154
273
  const findings = warnings.map((warning) => ({
155
274
  level: 'warn',
156
275
  text: `the file, line ${warning.line}: ${warning.message}`,
157
276
  }));
158
- findings.push(...approvalFindings(team, root, sources.home), ...checkFindings(team, root, sources.home));
277
+ findings.push(...seatNameFindings(team, session));
278
+ findings.push(...approvalFindings(approved, root, sources.home), ...overrideFindings(team, root, sources.home), ...checkFindings(team, root, sources.home), ...budgetChecks);
159
279
  const herdr = sources.herdrVersion();
160
280
  const running = herdr === null ? null : sources.sessionRunning(session);
161
281
  if (herdr === null)
@@ -220,7 +340,7 @@ export async function runDoctor(argv, io, sources) {
220
340
  const session = args.values.session ?? team.session;
221
341
  const findings = args.flags.has('login')
222
342
  ? doctorLoginFindings(team, sources)
223
- : doctorFindings(team, root, dirname(loaded.path), session, sources, loaded.warnings);
343
+ : doctorFindings(team, root, dirname(loaded.path), session, sources, loaded.warnings, undefined, budgetCheckFindings(team, root, sources, callerOf(io)));
224
344
  const label = { ok: 'ok ', warn: 'warn', miss: 'MISS', note: '-- ' };
225
345
  io.stdout(findings.map((finding) => `${label[finding.level]} ${finding.text}\n`).join(''));
226
346
  const missing = findings.filter((finding) => finding.level === 'miss').length;
@@ -8,10 +8,12 @@ export type DownSources = {
8
8
  alive(pid: number): boolean;
9
9
  /** Herdr's status is not enough: a permission prompt is reported as idle. */
10
10
  screen(session: string, pane: string, cli: string): Screen;
11
+ /** The pane's raw text, for reading the input box back before any Enter. */
12
+ screenText(session: string, pane: string, cli: string): string | undefined;
11
13
  /** Herdr's own status for the pane. The Enter waits for idle or done. */
12
14
  status(session: string, pane: string): string | null;
13
- /** Foreground process names in the pane, or null when the pane can't be read. */
14
- foreground?(session: string, pane: string): string[] | null;
15
+ /** Foreground argv0 names, or null when the pane can't be read. */
16
+ foreground(session: string, pane: string): string[] | null;
15
17
  now(): Date;
16
18
  sleep?(ms: number): Promise<void>;
17
19
  launch?: DownLaunch;
@@ -22,6 +24,8 @@ export type DownLaunch = {
22
24
  agentPanes(session: string): string[] | null;
23
25
  closeWorkspace(session: string, workspace: string): boolean;
24
26
  stopSession(session: string): boolean;
27
+ /** Clears the session this run stopped, so a later `up` starts from the beginning. */
28
+ deleteSession(session: string): boolean;
25
29
  kill(pid: number): boolean;
26
30
  sleep(ms: number): Promise<void>;
27
31
  now(): Date;
@@ -1,9 +1,11 @@
1
1
  import { readArgs } from "../args.js";
2
2
  import { callerOf, describeCaller, mayChangeTeam } from "../caller.js";
3
3
  import { currentTeam } from "../file/current.js";
4
- import { agentList, agentStatus, paneForeground, paneRead, sessionRunning, pressEnter, sessionStop, typeText, workspaceClose, } from "../herdr.js";
4
+ import { agentList, agentStatus, paneForeground, paneRead, sessionDelete, sessionRunning, pressEnter, sessionStop, typeText, workspaceClose, } from "../herdr.js";
5
5
  import { executePlan } from "../launch/execute.js";
6
+ import { reportedLiveAgent } from "../launch/agent.js";
6
7
  import { downPlan, formatPlan } from "../launch/plan.js";
8
+ import { boxHoldsText } from "../launch/deliver.js";
7
9
  import { profileFor } from "../profiles/index.js";
8
10
  import { logLine } from "../log.js";
9
11
  import { emptySession, readState, updateState } from "../state.js";
@@ -20,6 +22,7 @@ const realLaunch = {
20
22
  },
21
23
  closeWorkspace: (session, workspace) => workspaceClose(workspace, aim(session)),
22
24
  stopSession: sessionStop,
25
+ deleteSession: sessionDelete,
23
26
  kill(pid) {
24
27
  try {
25
28
  process.kill(pid, 'SIGTERM');
@@ -47,6 +50,7 @@ export const realSources = {
47
50
  screen(session, pane, cli) {
48
51
  return readScreen(cli, paneRead(pane, 200, aim(session)) ?? undefined);
49
52
  },
53
+ screenText: (session, pane) => paneRead(pane, 200, aim(session)) ?? undefined,
50
54
  status: (session, pane) => agentStatus(pane, aim(session)),
51
55
  foreground: (session, pane) => paneForeground(pane, aim(session)),
52
56
  now: () => new Date(),
@@ -125,6 +129,18 @@ export async function runDown(argv, io, sources) {
125
129
  if (abandon && !callerOwns(caller)) {
126
130
  refusals.push('only the owner abandons a team, from a terminal outside herdr');
127
131
  }
132
+ const state = readState(dir).sessions[session];
133
+ const known = new Set([...team.seats.map((seat) => seat.name), ...Object.keys(state?.seats ?? {})]);
134
+ const cliOf = new Map(team.seats.map((seat) => [seat.name, seat.cli]));
135
+ // The CLI a running seat was launched with: the file's for the seats it still names, the
136
+ // state's for the rest, so a seat the file renamed is still stopped. A temporary seat falls
137
+ // back to the seat it is like. A state too old to say leaves the seat to its owner, named
138
+ // in the plan.
139
+ const cliFor = (name) => {
140
+ const recorded = state?.seats[name];
141
+ const like = recorded?.temporary?.like;
142
+ return cliOf.get(name) ?? recorded?.cli ?? (like ? cliOf.get(like) ?? state?.seats[like]?.cli : undefined) ?? 'unknown';
143
+ };
128
144
  if (!dry && args.flags.has('wait') && refusals.length === 0) {
129
145
  const deadline = sources.now().getTime() + WAIT_SECONDS * 1000;
130
146
  const sleep = sources.sleep ?? sources.launch?.sleep;
@@ -132,8 +148,7 @@ export async function runDown(argv, io, sources) {
132
148
  const waiting = agents.some((agent) => {
133
149
  if (!agent.name)
134
150
  return false;
135
- const cli = team.seats.find((seat) => seat.name === agent.name)?.cli ?? 'claude-code';
136
- return stateOf(agent.status, sources.screen(session, agent.pane, cli)) === 'working';
151
+ return stateOf(agent.status, sources.screen(session, agent.pane, cliFor(agent.name))) === 'working';
137
152
  });
138
153
  if (!waiting)
139
154
  break;
@@ -147,9 +162,6 @@ export async function runDown(argv, io, sources) {
147
162
  agents = again;
148
163
  }
149
164
  }
150
- const state = readState(dir).sessions[session];
151
- const known = new Set([...team.seats.map((seat) => seat.name), ...Object.keys(state?.seats ?? {})]);
152
- const cliOf = new Map(team.seats.map((seat) => [seat.name, seat.cli]));
153
165
  const seats = [];
154
166
  let extra = 0;
155
167
  for (const agent of agents) {
@@ -157,8 +169,7 @@ export async function runDown(argv, io, sources) {
157
169
  extra++;
158
170
  continue;
159
171
  }
160
- const like = state?.seats[agent.name]?.temporary?.like;
161
- const cli = cliOf.get(agent.name) ?? (like ? cliOf.get(like) : undefined) ?? 'unknown';
172
+ const cli = cliFor(agent.name);
162
173
  seats.push({
163
174
  name: agent.name,
164
175
  cli,
@@ -201,9 +212,13 @@ export async function runDown(argv, io, sources) {
201
212
  typeLine(sessionName, pane, text) {
202
213
  // Free was decided when the plan was built. Look again, and once more between the text and
203
214
  // the Enter: a prompt that appeared would take the key, as the watch's nudge does. Herdr's
204
- // status is asked both times; Enter waits until it is idle or done and the screen is idle
205
- // or holding unsent text.
215
+ // status is asked both times; the Enter waits until it is idle or done and the box reads
216
+ // back as exactly the typed text.
206
217
  const cli = seats.find((seat) => seat.pane === pane)?.cli ?? '';
218
+ const names = profileFor(cli)?.processNames ?? [];
219
+ const live = () => reportedLiveAgent(sources.foreground(sessionName, pane), names);
220
+ if (!live())
221
+ return 'no-agent';
207
222
  const look = () => sources.screen(sessionName, pane, cli).kind;
208
223
  const resting = () => {
209
224
  const status = sources.status(sessionName, pane);
@@ -213,26 +228,37 @@ export async function runDown(argv, io, sources) {
213
228
  return false;
214
229
  if (!launch.typeText(sessionName, pane, text))
215
230
  return false;
216
- const after = look();
217
- if (!resting() || (after !== 'idle' && after !== 'unsent'))
231
+ if (!live())
232
+ return 'no-agent';
233
+ // An idle screen after the typing is the text not rendered, and unsent text alone is not
234
+ // this exit's: only a box that reads back as the typed text gets the Enter.
235
+ if (!resting() || !boxHoldsText(cli, text, sources.screenText(sessionName, pane, cli)))
218
236
  return false;
219
237
  return launch.pressEnter(sessionName, pane);
220
238
  },
221
239
  renameAgent: () => false,
222
240
  closeWorkspace: launch.closeWorkspace,
223
241
  stopSession: launch.stopSession,
242
+ // Only the session this run has itself just stopped, and only once herdr agrees it is no
243
+ // longer running: anything else — still running, or herdr silent — is left to its owner, with
244
+ // the command the line in `executePlan` names. `up` never deletes a session at all.
245
+ deleteSession(name) {
246
+ return sources.sessionRunning(name) === false && launch.deleteSession(name);
247
+ },
224
248
  kill: launch.kill,
225
249
  agentPanes(sessionName) {
226
250
  const listed = launch.agentPanes(sessionName);
227
251
  if (!listed)
228
252
  return null;
229
- // A pane back at its shell is no longer the seat. `gone` then finishes and the workspace closes.
253
+ // A pane back at its shell is no longer the seat. `gone` then finishes and the workspace
254
+ // closes. The wait keeps an unreadable list (`paneStillRunning`); the watch's quota read
255
+ // asks the other way — `reportedLiveAgent`, a figure only where the CLI was seen.
230
256
  return listed.filter((pane) => {
231
257
  const cli = seats.find((seat) => seat.pane === pane)?.cli;
232
258
  const names = cli ? profileFor(cli)?.processNames : undefined;
233
259
  if (!names)
234
260
  return true;
235
- return paneStillRunning(sources.foreground?.(sessionName, pane) ?? paneForeground(pane, aim(sessionName)), names);
261
+ return paneStillRunning(sources.foreground(sessionName, pane), names);
236
262
  });
237
263
  },
238
264
  classify: () => 'unknown',
@@ -1,7 +1,7 @@
1
1
  import type { Command, Io } from '../io.ts';
2
2
  export declare const EXCLUDED: string[];
3
3
  export declare const PRIVACY = "The team file is private to this clone: it is listed in .git/info/exclude, never in .gitignore,\nso git doesn't see it and a fresh clone doesn't carry it. A collaborator writes their own, or\nreceives one and approves it themselves.";
4
- export declare function skeleton(project: string, head: string | null): string;
4
+ export declare function skeleton(project: string, head: string | null, teamVersion?: string): string;
5
5
  export declare function exclude(root: string): string[];
6
6
  export declare const init: Command;
7
7
  export default init;
@@ -8,6 +8,7 @@ import { validateTeamFile } from "../file/validate.js";
8
8
  import { logLine } from "../log.js";
9
9
  import { LOCK_FILE, LOG_FILE, STATE_FILE, writeAtomic } from "../state.js";
10
10
  import { approvedCopy } from "../store/store.js";
11
+ import { version } from "../version.js";
11
12
  // What git must never pick up: the file and the runtime files beside it.
12
13
  export const EXCLUDED = [TEAM_FILE, `.agents/${STATE_FILE}`, `.agents/${LOG_FILE}*`, `.agents/${LOCK_FILE}`];
13
14
  export const PRIVACY = `The team file is private to this clone: it is listed in .git/info/exclude, never in .gitignore,
@@ -21,8 +22,9 @@ function git(root, ...args) {
21
22
  return null;
22
23
  }
23
24
  }
24
- export function skeleton(project, head) {
25
- return `# The team of ${project}. Private to this clone: see .git/info/exclude.
25
+ export function skeleton(project, head, teamVersion = version()) {
26
+ return `# yaml-language-server: $schema=https://raw.githubusercontent.com/floor/team/v${teamVersion}/schema/team.schema.json
27
+ # The team of ${project}. Private to this clone: see .git/info/exclude.
26
28
  # Nothing here runs until the owner has read it and run \`team approve\`.
27
29
  format: 1
28
30
  project: ${project}
@@ -4,6 +4,8 @@ import { type DownSources } from './down.ts';
4
4
  export type RemoveSources = DownSources & {
5
5
  /** Foreground process names in the pane, or null when the pane can't be read. */
6
6
  foreground(session: string, pane: string): string[] | null;
7
+ /** Approval store home. The real command uses the owner's home. */
8
+ home?: string;
7
9
  };
8
10
  export declare const realSources: RemoveSources;
9
11
  export declare const USAGE = "Usage: team remove <name> [--keep] [--abandon] [--session <name>] [--file <path>]\n";
@@ -1,5 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
2
3
  import { dirname } from 'node:path';
4
+ import { recordSeatDigest } from "../approve/approval.js";
3
5
  import { readArgs } from "../args.js";
4
6
  import { callerOf, describeCaller, isOwner, mayChangeTeam } from "../caller.js";
5
7
  import { loadTeamFile } from "../file/load.js";
@@ -7,12 +9,14 @@ import { markStopped, takeOut } from "../file/lines.js";
7
9
  import { validateTeamFile } from "../file/validate.js";
8
10
  import { writeTeamFile } from "../file/write.js";
9
11
  import { paneForeground } from "../herdr.js";
12
+ import { reportedLiveAgent } from "../launch/agent.js";
10
13
  import { executePlan } from "../launch/execute.js";
11
14
  import { downPlan } from "../launch/plan.js";
12
15
  import { logLine } from "../log.js";
13
16
  import { profileFor } from "../profiles/index.js";
14
17
  import { emptySession, readState, updateState, withLock } from "../state.js";
15
18
  import { paneStillRunning, realSources as downSources, stateOf } from "./down.js";
19
+ import { boxHoldsText } from "../launch/deliver.js";
16
20
  function aim(session) {
17
21
  return session === 'default' ? undefined : session;
18
22
  }
@@ -60,7 +64,7 @@ export async function runRemove(argv, io, sources = realSources) {
60
64
  io.stderr(`team remove: only the owner, the coordinator or the operator runs it; this call is ${describeCaller(caller)}\n`);
61
65
  return 1;
62
66
  }
63
- const { team, path } = loaded;
67
+ const { team, path, root } = loaded;
64
68
  const session = args.values.session ?? team.session;
65
69
  if (session === 'default') {
66
70
  io.stderr('team remove: session can\'t be "default", herdr\'s own session\n');
@@ -133,6 +137,7 @@ export async function runRemove(argv, io, sources = realSources) {
133
137
  if (!stopped)
134
138
  return 1;
135
139
  }
140
+ let kept = null;
136
141
  if (!temporary) {
137
142
  const refused = withLock(dir, () => {
138
143
  const text = readFileSync(path, 'utf8');
@@ -140,7 +145,11 @@ export async function runRemove(argv, io, sources = realSources) {
140
145
  if (next === text)
141
146
  return null;
142
147
  const wrote = writeTeamFile(path, next);
143
- return wrote.ok ? null : wrote.errors;
148
+ if (!wrote.ok)
149
+ return wrote.errors;
150
+ if (args.flags.has('keep'))
151
+ kept = next;
152
+ return null;
144
153
  });
145
154
  if (refused) {
146
155
  for (const problem of refused) {
@@ -149,6 +158,11 @@ export async function runRemove(argv, io, sources = realSources) {
149
158
  return 2;
150
159
  }
151
160
  }
161
+ if (kept !== null) {
162
+ const parsed = validateTeamFile(kept);
163
+ if (parsed.ok)
164
+ recordSeatDigest(parsed.team, root, name, sources.home ?? homedir());
165
+ }
152
166
  if (!agent && recorded) {
153
167
  updateState(dir, (file) => {
154
168
  const seats = file.sessions[session]?.seats;
@@ -185,6 +199,10 @@ export async function stopRunning(input) {
185
199
  createWorkspace: () => null,
186
200
  paneRun: () => false,
187
201
  typeLine(sessionName, pane, text) {
202
+ const names = profileFor(seat.cli)?.processNames ?? [];
203
+ const live = () => reportedLiveAgent(sources.foreground(sessionName, pane), names);
204
+ if (!live())
205
+ return 'no-agent';
188
206
  const look = () => sources.screen(sessionName, pane, seat.cli).kind;
189
207
  const resting = () => {
190
208
  const status = sources.status(sessionName, pane);
@@ -194,8 +212,10 @@ export async function stopRunning(input) {
194
212
  return false;
195
213
  if (!launch.typeText(sessionName, pane, text))
196
214
  return false;
197
- const after = look();
198
- if (!resting() || (after !== 'idle' && after !== 'unsent'))
215
+ if (!live())
216
+ return 'no-agent';
217
+ // As in `down`: only a box that reads back as exactly the typed text gets the Enter.
218
+ if (!resting() || !boxHoldsText(seat.cli, text, sources.screenText(sessionName, pane, seat.cli)))
199
219
  return false;
200
220
  return launch.pressEnter(sessionName, pane);
201
221
  },
@@ -207,6 +227,8 @@ export async function stopRunning(input) {
207
227
  const listed = launch.agentPanes(sessionName);
208
228
  if (!listed)
209
229
  return null;
230
+ // A pane back at its shell is no longer the seat. The wait keeps an unreadable list
231
+ // (`paneStillRunning`); the watch's quota read asks the other way (`reportedLiveAgent`).
210
232
  const names = profileFor(seat.cli)?.processNames;
211
233
  if (!names)
212
234
  return listed;