team 0.1.2 → 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 (114) hide show
  1. package/README.md +22 -11
  2. package/dist/approve/approval.js +14 -9
  3. package/dist/approve/fingerprint.d.ts +14 -2
  4. package/dist/approve/fingerprint.js +21 -21
  5. package/dist/budgets/gate.js +7 -2
  6. package/dist/budgets/readings.d.ts +6 -2
  7. package/dist/budgets/readings.js +28 -2
  8. package/dist/budgets/run.js +8 -2
  9. package/dist/budgets/table.d.ts +2 -0
  10. package/dist/budgets/table.js +10 -5
  11. package/dist/cli.d.ts +2 -1
  12. package/dist/cli.js +8 -5
  13. package/dist/commands/add.d.ts +0 -1
  14. package/dist/commands/add.js +7 -5
  15. package/dist/commands/approve.js +17 -1
  16. package/dist/commands/doctor.d.ts +15 -1
  17. package/dist/commands/doctor.js +126 -8
  18. package/dist/commands/down.d.ts +4 -0
  19. package/dist/commands/down.js +32 -13
  20. package/dist/commands/init.d.ts +1 -1
  21. package/dist/commands/init.js +4 -2
  22. package/dist/commands/remove.js +5 -2
  23. package/dist/commands/status.d.ts +2 -0
  24. package/dist/commands/status.js +19 -1
  25. package/dist/commands/up.js +3 -1
  26. package/dist/commands/watch.d.ts +2 -0
  27. package/dist/commands/watch.js +28 -4
  28. package/dist/conformance/adapter.d.ts +5 -0
  29. package/dist/conformance/adapter.js +44 -0
  30. package/dist/file/check.d.ts +20 -0
  31. package/dist/file/check.js +112 -0
  32. package/dist/file/lines.js +13 -2
  33. package/dist/file/sections/budgets.d.ts +15 -0
  34. package/dist/file/sections/budgets.js +180 -0
  35. package/dist/file/sections/coordinator.d.ts +2 -0
  36. package/dist/file/sections/coordinator.js +11 -0
  37. package/dist/file/sections/format.d.ts +2 -0
  38. package/dist/file/sections/format.js +13 -0
  39. package/dist/file/sections/identity.d.ts +2 -0
  40. package/dist/file/sections/identity.js +103 -0
  41. package/dist/file/sections/index.d.ts +9 -0
  42. package/dist/file/sections/index.js +43 -0
  43. package/dist/file/sections/lead.d.ts +4 -0
  44. package/dist/file/sections/lead.js +18 -0
  45. package/dist/file/sections/limits.d.ts +2 -0
  46. package/dist/file/sections/limits.js +43 -0
  47. package/dist/file/sections/machine.d.ts +2 -0
  48. package/dist/file/sections/machine.js +38 -0
  49. package/dist/file/sections/operator.d.ts +2 -0
  50. package/dist/file/sections/operator.js +11 -0
  51. package/dist/file/sections/project.d.ts +2 -0
  52. package/dist/file/sections/project.js +13 -0
  53. package/dist/file/sections/rules.d.ts +2 -0
  54. package/dist/file/sections/rules.js +9 -0
  55. package/dist/file/sections/seats.d.ts +7 -0
  56. package/dist/file/sections/seats.js +161 -0
  57. package/dist/file/sections/section.d.ts +32 -0
  58. package/dist/file/sections/section.js +7 -0
  59. package/dist/file/sections/session.d.ts +2 -0
  60. package/dist/file/sections/session.js +16 -0
  61. package/dist/file/sections/tools.d.ts +2 -0
  62. package/dist/file/sections/tools.js +43 -0
  63. package/dist/file/sections/trust.d.ts +2 -0
  64. package/dist/file/sections/trust.js +20 -0
  65. package/dist/file/sections/units.d.ts +16 -0
  66. package/dist/file/sections/units.js +8 -0
  67. package/dist/file/sections/visibility.d.ts +2 -0
  68. package/dist/file/sections/visibility.js +12 -0
  69. package/dist/file/sections/watch-checks.d.ts +9 -0
  70. package/dist/file/sections/watch-checks.js +14 -0
  71. package/dist/file/sections/watch.d.ts +14 -0
  72. package/dist/file/sections/watch.js +111 -0
  73. package/dist/file/sections/workspace.d.ts +4 -0
  74. package/dist/file/sections/workspace.js +77 -0
  75. package/dist/file/types.d.ts +1 -0
  76. package/dist/file/validate.d.ts +4 -12
  77. package/dist/file/validate.js +45 -688
  78. package/dist/herdr.d.ts +1 -0
  79. package/dist/herdr.js +11 -1
  80. package/dist/launch/agent.js +4 -3
  81. package/dist/launch/deliver.d.ts +4 -0
  82. package/dist/launch/deliver.js +151 -8
  83. package/dist/launch/execute.d.ts +2 -0
  84. package/dist/launch/execute.js +21 -11
  85. package/dist/launch/plan.js +16 -2
  86. package/dist/profiles/antigravity.yaml +12 -2
  87. package/dist/profiles/claude-code.yaml +21 -6
  88. package/dist/profiles/codex.yaml +14 -1
  89. package/dist/profiles/cursor.yaml +45 -5
  90. package/dist/profiles/overrides.d.ts +67 -0
  91. package/dist/profiles/overrides.js +190 -0
  92. package/dist/profiles/profile.d.ts +3 -0
  93. package/dist/profiles/profile.js +5 -1
  94. package/dist/state.d.ts +2 -0
  95. package/dist/status/compare.js +7 -4
  96. package/dist/store/store.d.ts +5 -0
  97. package/dist/version.d.ts +1 -0
  98. package/dist/version.js +7 -0
  99. package/dist/watch/checks/budget.js +49 -53
  100. package/dist/watch/dialect.d.ts +6 -1
  101. package/dist/watch/dialect.js +68 -12
  102. package/dist/watch/pass.d.ts +8 -1
  103. package/dist/watch/pass.js +19 -9
  104. package/dist/watch/screen-core.d.ts +41 -4
  105. package/dist/watch/screen-core.js +458 -93
  106. package/dist/watch/screen-data.d.ts +25 -0
  107. package/dist/watch/screen-file.d.ts +8 -2
  108. package/dist/watch/screen-file.js +325 -38
  109. package/dist/watch/screen-profile.d.ts +19 -0
  110. package/dist/watch/screen-profile.js +1 -0
  111. package/dist/watch/screen.d.ts +22 -4
  112. package/dist/watch/screen.js +39 -5
  113. package/package.json +9 -3
  114. package/schema/team.schema.json +498 -0
@@ -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,14 +242,40 @@ function watchFindings(watch, dir, session, running, now) {
150
242
  }
151
243
  return [{ level: 'ok', text: 'the watch is running' }];
152
244
  }
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
+ }
153
268
  export function doctorFindings(team, root, dir, session, sources, warnings,
154
269
  /** The file as approved. `add` hands the team about to run, whose `stopped` mark is already cleared. */
155
- approved = team) {
270
+ approved = team,
271
+ /** The budget-check lines, which only the report runs: `up` and `add` scan, they never run a check. */
272
+ budgetChecks = []) {
156
273
  const findings = warnings.map((warning) => ({
157
274
  level: 'warn',
158
275
  text: `the file, line ${warning.line}: ${warning.message}`,
159
276
  }));
160
- findings.push(...approvalFindings(approved, 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);
161
279
  const herdr = sources.herdrVersion();
162
280
  const running = herdr === null ? null : sources.sessionRunning(session);
163
281
  if (herdr === null)
@@ -222,7 +340,7 @@ export async function runDoctor(argv, io, sources) {
222
340
  const session = args.values.session ?? team.session;
223
341
  const findings = args.flags.has('login')
224
342
  ? doctorLoginFindings(team, sources)
225
- : 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)));
226
344
  const label = { ok: 'ok ', warn: 'warn', miss: 'MISS', note: '-- ' };
227
345
  io.stdout(findings.map((finding) => `${label[finding.level]} ${finding.text}\n`).join(''));
228
346
  const missing = findings.filter((finding) => finding.level === 'miss').length;
@@ -8,6 +8,8 @@ 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
15
  /** Foreground argv0 names, or null when the pane can't be read. */
@@ -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,10 +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
6
  import { reportedLiveAgent } from "../launch/agent.js";
7
7
  import { downPlan, formatPlan } from "../launch/plan.js";
8
+ import { boxHoldsText } from "../launch/deliver.js";
8
9
  import { profileFor } from "../profiles/index.js";
9
10
  import { logLine } from "../log.js";
10
11
  import { emptySession, readState, updateState } from "../state.js";
@@ -21,6 +22,7 @@ const realLaunch = {
21
22
  },
22
23
  closeWorkspace: (session, workspace) => workspaceClose(workspace, aim(session)),
23
24
  stopSession: sessionStop,
25
+ deleteSession: sessionDelete,
24
26
  kill(pid) {
25
27
  try {
26
28
  process.kill(pid, 'SIGTERM');
@@ -48,6 +50,7 @@ export const realSources = {
48
50
  screen(session, pane, cli) {
49
51
  return readScreen(cli, paneRead(pane, 200, aim(session)) ?? undefined);
50
52
  },
53
+ screenText: (session, pane) => paneRead(pane, 200, aim(session)) ?? undefined,
51
54
  status: (session, pane) => agentStatus(pane, aim(session)),
52
55
  foreground: (session, pane) => paneForeground(pane, aim(session)),
53
56
  now: () => new Date(),
@@ -126,6 +129,18 @@ export async function runDown(argv, io, sources) {
126
129
  if (abandon && !callerOwns(caller)) {
127
130
  refusals.push('only the owner abandons a team, from a terminal outside herdr');
128
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
+ };
129
144
  if (!dry && args.flags.has('wait') && refusals.length === 0) {
130
145
  const deadline = sources.now().getTime() + WAIT_SECONDS * 1000;
131
146
  const sleep = sources.sleep ?? sources.launch?.sleep;
@@ -133,8 +148,7 @@ export async function runDown(argv, io, sources) {
133
148
  const waiting = agents.some((agent) => {
134
149
  if (!agent.name)
135
150
  return false;
136
- const cli = team.seats.find((seat) => seat.name === agent.name)?.cli ?? 'claude-code';
137
- 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';
138
152
  });
139
153
  if (!waiting)
140
154
  break;
@@ -148,9 +162,6 @@ export async function runDown(argv, io, sources) {
148
162
  agents = again;
149
163
  }
150
164
  }
151
- const state = readState(dir).sessions[session];
152
- const known = new Set([...team.seats.map((seat) => seat.name), ...Object.keys(state?.seats ?? {})]);
153
- const cliOf = new Map(team.seats.map((seat) => [seat.name, seat.cli]));
154
165
  const seats = [];
155
166
  let extra = 0;
156
167
  for (const agent of agents) {
@@ -158,8 +169,7 @@ export async function runDown(argv, io, sources) {
158
169
  extra++;
159
170
  continue;
160
171
  }
161
- const like = state?.seats[agent.name]?.temporary?.like;
162
- const cli = cliOf.get(agent.name) ?? (like ? cliOf.get(like) : undefined) ?? 'unknown';
172
+ const cli = cliFor(agent.name);
163
173
  seats.push({
164
174
  name: agent.name,
165
175
  cli,
@@ -202,8 +212,8 @@ export async function runDown(argv, io, sources) {
202
212
  typeLine(sessionName, pane, text) {
203
213
  // Free was decided when the plan was built. Look again, and once more between the text and
204
214
  // the Enter: a prompt that appeared would take the key, as the watch's nudge does. Herdr's
205
- // status is asked both times; Enter waits until it is idle or done and the screen is idle
206
- // 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.
207
217
  const cli = seats.find((seat) => seat.pane === pane)?.cli ?? '';
208
218
  const names = profileFor(cli)?.processNames ?? [];
209
219
  const live = () => reportedLiveAgent(sources.foreground(sessionName, pane), names);
@@ -220,20 +230,29 @@ export async function runDown(argv, io, sources) {
220
230
  return false;
221
231
  if (!live())
222
232
  return 'no-agent';
223
- const after = look();
224
- if (!resting() || (after !== 'idle' && after !== 'unsent'))
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)))
225
236
  return false;
226
237
  return launch.pressEnter(sessionName, pane);
227
238
  },
228
239
  renameAgent: () => false,
229
240
  closeWorkspace: launch.closeWorkspace,
230
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
+ },
231
248
  kill: launch.kill,
232
249
  agentPanes(sessionName) {
233
250
  const listed = launch.agentPanes(sessionName);
234
251
  if (!listed)
235
252
  return null;
236
- // 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.
237
256
  return listed.filter((pane) => {
238
257
  const cli = seats.find((seat) => seat.pane === pane)?.cli;
239
258
  const names = cli ? profileFor(cli)?.processNames : undefined;
@@ -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}
@@ -16,6 +16,7 @@ import { logLine } from "../log.js";
16
16
  import { profileFor } from "../profiles/index.js";
17
17
  import { emptySession, readState, updateState, withLock } from "../state.js";
18
18
  import { paneStillRunning, realSources as downSources, stateOf } from "./down.js";
19
+ import { boxHoldsText } from "../launch/deliver.js";
19
20
  function aim(session) {
20
21
  return session === 'default' ? undefined : session;
21
22
  }
@@ -213,8 +214,8 @@ export async function stopRunning(input) {
213
214
  return false;
214
215
  if (!live())
215
216
  return 'no-agent';
216
- const after = look();
217
- if (!resting() || (after !== 'idle' && after !== 'unsent'))
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)))
218
219
  return false;
219
220
  return launch.pressEnter(sessionName, pane);
220
221
  },
@@ -226,6 +227,8 @@ export async function stopRunning(input) {
226
227
  const listed = launch.agentPanes(sessionName);
227
228
  if (!listed)
228
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`).
229
232
  const names = profileFor(seat.cli)?.processNames;
230
233
  if (!names)
231
234
  return listed;
@@ -9,6 +9,8 @@ export type StatusSources = {
9
9
  watchInForce(team: TeamFile, root: string): TeamFile['watch'];
10
10
  budgetsInForce(team: TeamFile, root: string): TeamFile['budgets'];
11
11
  now(): Date;
12
+ /** The home whose store holds the override file. Absent in a test that does not set one. */
13
+ home?: string;
12
14
  };
13
15
  export declare const realSources: StatusSources;
14
16
  export declare const status: Command;
@@ -1,6 +1,8 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
2
3
  import { relative, resolve } from 'node:path';
3
4
  import { approvalDifferences, budgetsInForce, watchInForce } from "../approve/approval.js";
5
+ import { overridesInForce } from "../profiles/overrides.js";
4
6
  import { readArgs } from "../args.js";
5
7
  import { budgetLine, budgetTable } from "../budgets/table.js";
6
8
  import { recall } from "../budgets/readings.js";
@@ -41,6 +43,7 @@ export const realSources = {
41
43
  watchInForce: (team, root) => watchInForce(team, root),
42
44
  budgetsInForce: (team, root) => budgetsInForce(team, root),
43
45
  now: () => new Date(),
46
+ home: homedir(),
44
47
  };
45
48
  export const status = (argv, io) => runStatus(argv, io, realSources);
46
49
  export default status;
@@ -62,6 +65,9 @@ export async function runStatus(argv, io, sources) {
62
65
  for (const warning of current.warnings)
63
66
  io.stderr(`team status: warning, line ${warning.line}: ${warning.message}\n`);
64
67
  const session = args.values.session ?? team.session;
68
+ const overrides = sources.home ? overridesInForce(team.project, root, sources.home) : emptyOverrides();
69
+ for (const problem of overrides.problems)
70
+ io.stderr(`team status: ${problem}\n`);
65
71
  const live = sources.live(session, team);
66
72
  if (!live) {
67
73
  io.stderr('team status: herdr doesn\'t answer; is it installed and running?\n');
@@ -73,7 +79,7 @@ export async function runStatus(argv, io, sources) {
73
79
  const comparison = compare(team, session, state, live, sources.now(), sources.watchInForce(team, root));
74
80
  if (!live.running)
75
81
  comparison.notes.unshift(`the herdr session "${session}" is not running`);
76
- comparison.differences.push(...protectedCheckouts(team, root, sources), ...approvalDrift(sources.approval(team, root)));
82
+ comparison.differences.push(...protectedCheckouts(team, root, sources), ...approvalDrift(sources.approval(team, root)), ...overrideDrift(overrides));
77
83
  if (args.flags.has('json')) {
78
84
  const doc = {
79
85
  format: 1,
@@ -92,6 +98,18 @@ export async function runStatus(argv, io, sources) {
92
98
  }
93
99
  return comparison.differences.length ? 1 : 0;
94
100
  }
101
+ function emptyOverrides() {
102
+ return { profiles: [], differences: [], problems: [] };
103
+ }
104
+ function overrideDrift(report) {
105
+ return [
106
+ ...report.differences.map((line) => ({
107
+ what: `the overrides differ from the approved copy: ${line}`,
108
+ repair: 'the owner runs team approve',
109
+ })),
110
+ ...report.problems.map((problem) => ({ what: problem, repair: 'fix the overrides file' })),
111
+ ];
112
+ }
95
113
  // A file that was never approved, or was changed since, runs nothing until the owner approves it.
96
114
  export function approvalDrift(differences) {
97
115
  if (differences === null)
@@ -335,7 +335,9 @@ export async function runUp(argv, io, sources) {
335
335
  updateState(dir, (file) => {
336
336
  const current = (file.sessions[session] ??= emptySession());
337
337
  const prior = current.seats[name] ?? { stage: patch.stage };
338
- current.seats[name] = { ...prior, ...patch };
338
+ // The CLI the seat was launched with: `down` needs it when the file no longer names the seat.
339
+ const cli = team.seats.find((seat) => seat.name === name)?.cli;
340
+ current.seats[name] = { ...prior, ...patch, ...(cli ? { cli } : {}) };
339
341
  });
340
342
  },
341
343
  running(name) {
@@ -29,6 +29,8 @@ export type WatchSources = {
29
29
  stopSeat?(session: string, seat: DownSeat): Promise<boolean>;
30
30
  /** Removes one merged worktree. The real watch calls `worktree remove`. */
31
31
  removeWorktree?(task: string): number;
32
+ /** The home whose store holds the override file. Absent in a test that does not set one. */
33
+ home?: string;
32
34
  };
33
35
  export declare const realWatchSources: WatchSources;
34
36
  export declare const watch: Command;
@@ -15,10 +15,12 @@ import { readScreen } from "../watch/screen.js";
15
15
  import { judgeTemporary, judgeWorktree } from "../watch/close.js";
16
16
  import { readEnd } from "../watch/end.js";
17
17
  import { reportedLiveAgent } from "../launch/agent.js";
18
+ import { boxHoldsText } from "../launch/deliver.js";
18
19
  import { stopRunning, realSources as removeSources } from "./remove.js";
19
20
  import { removeWorktree } from "./worktree.js";
20
21
  import { stateOf } from "./down.js";
21
22
  import { profileFor } from "../profiles/index.js";
23
+ import { classifyWith, overridesInForce, quotaWith } from "../profiles/overrides.js";
22
24
  import { realSources } from "./status.js";
23
25
  function waitOrStop(seconds) {
24
26
  return new Promise((done) => {
@@ -60,6 +62,7 @@ export const realWatchSources = {
60
62
  }
61
63
  },
62
64
  pid: process.pid,
65
+ home: homedir(),
63
66
  };
64
67
  export const watch = (argv, io) => runWatch(argv, io, realWatchSources);
65
68
  export default watch;
@@ -119,6 +122,18 @@ export async function runWatch(argv, io, sources) {
119
122
  const current = currentTeam(io.cwd, args.values.file, sources.now());
120
123
  const team = current.ok ? current.team : first.team;
121
124
  const root = current.ok ? current.root : first.root;
125
+ const overrides = sources.home
126
+ ? overridesInForce(team.project, root, sources.home)
127
+ : { profiles: [], differences: [], problems: [] };
128
+ for (const line of [
129
+ ...overrides.problems,
130
+ ...overrides.differences.map((difference) => `the overrides differ from the approved copy: ${difference}`),
131
+ ]) {
132
+ if (told.has(line))
133
+ continue;
134
+ told.add(line);
135
+ say(line, false);
136
+ }
122
137
  const problem = current.ok ? current.notice : `team.yaml can't be read (${current.errors[0]?.message ?? 'unknown'}); watching with the team as it was`;
123
138
  if (problem !== notice) {
124
139
  notice = problem;
@@ -176,10 +191,18 @@ export async function runWatch(argv, io, sources) {
176
191
  : `the check for ${outcome.account} is unapproved; that account reads unknown`, false);
177
192
  }
178
193
  }
194
+ // Herdr's process list per pane, read with the screen: a figure is only read off a pane
195
+ // where herdr reports the seat's CLI. A pane it can't read is null, and a null is not a
196
+ // CLI: no figure.
197
+ const foreground = {};
198
+ for (const agent of live.agents)
199
+ foreground[agent.pane] = sources.foreground(agent.pane, session);
179
200
  const run = (stored) => pass({
180
201
  team, state, live, machine: sources.machine(root), now, memory,
181
202
  approval: sources.approval(team, root), watch: inForce, outcomes, budgets: budget,
182
- readings: stored,
203
+ quotaFor: (cli) => quotaWith(cli, overrides.profiles),
204
+ readScreen: (cli, pane) => classifyWith(cli, pane, overrides.profiles),
205
+ readings: stored, foreground,
183
206
  });
184
207
  // The pass folds its figures where the state is held: two watches of the project fold one
185
208
  // after the other, not over each other. A watch on a foreign session still folds, for its
@@ -264,9 +287,10 @@ function deliver(nudge, team, session, sources, memory, say, tell, told) {
264
287
  keep();
265
288
  return;
266
289
  }
267
- const after = look();
268
- if (after !== 'idle' && after !== 'unsent') {
269
- tell('a nudge was typed and not sent: the operator\'s screen changed before the Enter', true);
290
+ // The box is read back before the Enter: it must hold exactly the nudge's own text. An idle
291
+ // or changed screen here is the text not rendered, and unsent text alone is not this nudge.
292
+ if (!boxHoldsText(cli, nudge.text, sources.screen(nudge.pane, session) ?? undefined)) {
293
+ tell('a nudge was typed and not sent: the operator\'s box does not hold it', true);
270
294
  keep();
271
295
  return;
272
296
  }
@@ -0,0 +1,5 @@
1
+ import type { Command } from '../io.ts';
2
+ /** The answer for one request line. A line that is not a request is an error object. */
3
+ export declare function respond(line: string): string;
4
+ declare const adapter: Command;
5
+ export default adapter;
@@ -0,0 +1,44 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { classify, classifyComposer } from "../watch/screen.js";
3
+ import { parseYaml } from "../yaml.js";
4
+ /** The answer for one request line. A line that is not a request is an error object. */
5
+ export function respond(line) {
6
+ let request;
7
+ try {
8
+ request = JSON.parse(line);
9
+ }
10
+ catch {
11
+ return JSON.stringify({ error: 'the line is not JSON' });
12
+ }
13
+ if (request === null || typeof request !== 'object')
14
+ return JSON.stringify({ error: 'a request needs an op' });
15
+ if (request.op === 'classify' || request.op === 'composer') {
16
+ if (typeof request.cli !== 'string' || !Array.isArray(request.lines) || request.lines.some((item) => typeof item !== 'string')) {
17
+ return JSON.stringify({ error: 'classify needs a cli and lines of text' });
18
+ }
19
+ const screen = request.op === 'classify' ? classify(request.cli, request.lines) : classifyComposer(request.cli, request.lines);
20
+ return JSON.stringify({ kind: screen.kind });
21
+ }
22
+ if (request.op === 'yaml') {
23
+ if (typeof request.text !== 'string')
24
+ return JSON.stringify({ error: 'yaml needs text' });
25
+ try {
26
+ parseYaml(request.text);
27
+ return JSON.stringify({ ok: true });
28
+ }
29
+ catch {
30
+ return JSON.stringify({ ok: false });
31
+ }
32
+ }
33
+ return JSON.stringify({ error: 'unknown op' });
34
+ }
35
+ const adapter = async (_argv, io) => {
36
+ const lines = createInterface({ input: process.stdin });
37
+ for await (const line of lines) {
38
+ if (line.length === 0)
39
+ continue;
40
+ io.stdout(`${respond(line)}\n`);
41
+ }
42
+ return 0;
43
+ };
44
+ export default adapter;
@@ -0,0 +1,20 @@
1
+ import type { YamlEntry, YamlNode } from '../yaml.ts';
2
+ import type { Problem } from './types.ts';
3
+ export type Fields = Map<string, YamlEntry>;
4
+ export declare class Check {
5
+ problems: Problem[];
6
+ warnings: Problem[];
7
+ fail(line: number, message: string): void;
8
+ fields(node: YamlNode | undefined, where: string, known: string[]): Fields;
9
+ text(entry: YamlEntry | undefined, name: string): string | undefined;
10
+ required(entry: YamlEntry | undefined, name: string, line: number): string | undefined;
11
+ oneOf<T extends string>(entry: YamlEntry | undefined, name: string, values: T[]): T | undefined;
12
+ flag(entry: YamlEntry | undefined, name: string): boolean;
13
+ whole(entry: YamlEntry | undefined, name: string, least: number): number | undefined;
14
+ number(entry: YamlEntry | undefined, name: string): number | undefined;
15
+ measure(entry: YamlEntry | undefined, name: string, units: Record<string, number>, example: string): number | undefined;
16
+ list(entry: YamlEntry | undefined, name: string): {
17
+ value: string;
18
+ line: number;
19
+ }[];
20
+ }