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.
- package/README.md +22 -11
- package/dist/approve/approval.js +14 -9
- package/dist/approve/fingerprint.d.ts +14 -2
- package/dist/approve/fingerprint.js +21 -21
- package/dist/budgets/gate.js +7 -2
- package/dist/budgets/readings.d.ts +6 -2
- package/dist/budgets/readings.js +28 -2
- package/dist/budgets/run.js +8 -2
- package/dist/budgets/table.d.ts +2 -0
- package/dist/budgets/table.js +10 -5
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +8 -5
- package/dist/commands/add.d.ts +0 -1
- package/dist/commands/add.js +7 -5
- package/dist/commands/approve.js +17 -1
- package/dist/commands/doctor.d.ts +15 -1
- package/dist/commands/doctor.js +126 -8
- package/dist/commands/down.d.ts +4 -0
- package/dist/commands/down.js +32 -13
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +4 -2
- package/dist/commands/remove.js +5 -2
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +19 -1
- package/dist/commands/up.js +3 -1
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +28 -4
- package/dist/conformance/adapter.d.ts +5 -0
- package/dist/conformance/adapter.js +44 -0
- package/dist/file/check.d.ts +20 -0
- package/dist/file/check.js +112 -0
- package/dist/file/lines.js +13 -2
- package/dist/file/sections/budgets.d.ts +15 -0
- package/dist/file/sections/budgets.js +180 -0
- package/dist/file/sections/coordinator.d.ts +2 -0
- package/dist/file/sections/coordinator.js +11 -0
- package/dist/file/sections/format.d.ts +2 -0
- package/dist/file/sections/format.js +13 -0
- package/dist/file/sections/identity.d.ts +2 -0
- package/dist/file/sections/identity.js +103 -0
- package/dist/file/sections/index.d.ts +9 -0
- package/dist/file/sections/index.js +43 -0
- package/dist/file/sections/lead.d.ts +4 -0
- package/dist/file/sections/lead.js +18 -0
- package/dist/file/sections/limits.d.ts +2 -0
- package/dist/file/sections/limits.js +43 -0
- package/dist/file/sections/machine.d.ts +2 -0
- package/dist/file/sections/machine.js +38 -0
- package/dist/file/sections/operator.d.ts +2 -0
- package/dist/file/sections/operator.js +11 -0
- package/dist/file/sections/project.d.ts +2 -0
- package/dist/file/sections/project.js +13 -0
- package/dist/file/sections/rules.d.ts +2 -0
- package/dist/file/sections/rules.js +9 -0
- package/dist/file/sections/seats.d.ts +7 -0
- package/dist/file/sections/seats.js +161 -0
- package/dist/file/sections/section.d.ts +32 -0
- package/dist/file/sections/section.js +7 -0
- package/dist/file/sections/session.d.ts +2 -0
- package/dist/file/sections/session.js +16 -0
- package/dist/file/sections/tools.d.ts +2 -0
- package/dist/file/sections/tools.js +43 -0
- package/dist/file/sections/trust.d.ts +2 -0
- package/dist/file/sections/trust.js +20 -0
- package/dist/file/sections/units.d.ts +16 -0
- package/dist/file/sections/units.js +8 -0
- package/dist/file/sections/visibility.d.ts +2 -0
- package/dist/file/sections/visibility.js +12 -0
- package/dist/file/sections/watch-checks.d.ts +9 -0
- package/dist/file/sections/watch-checks.js +14 -0
- package/dist/file/sections/watch.d.ts +14 -0
- package/dist/file/sections/watch.js +111 -0
- package/dist/file/sections/workspace.d.ts +4 -0
- package/dist/file/sections/workspace.js +77 -0
- package/dist/file/types.d.ts +1 -0
- package/dist/file/validate.d.ts +4 -12
- package/dist/file/validate.js +45 -688
- package/dist/herdr.d.ts +1 -0
- package/dist/herdr.js +11 -1
- package/dist/launch/agent.js +4 -3
- package/dist/launch/deliver.d.ts +4 -0
- package/dist/launch/deliver.js +151 -8
- package/dist/launch/execute.d.ts +2 -0
- package/dist/launch/execute.js +21 -11
- package/dist/launch/plan.js +16 -2
- package/dist/profiles/antigravity.yaml +12 -2
- package/dist/profiles/claude-code.yaml +21 -6
- package/dist/profiles/codex.yaml +14 -1
- package/dist/profiles/cursor.yaml +45 -5
- package/dist/profiles/overrides.d.ts +67 -0
- package/dist/profiles/overrides.js +190 -0
- package/dist/profiles/profile.d.ts +3 -0
- package/dist/profiles/profile.js +5 -1
- package/dist/state.d.ts +2 -0
- package/dist/status/compare.js +7 -4
- package/dist/store/store.d.ts +5 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/dist/watch/checks/budget.js +49 -53
- package/dist/watch/dialect.d.ts +6 -1
- package/dist/watch/dialect.js +68 -12
- package/dist/watch/pass.d.ts +8 -1
- package/dist/watch/pass.js +19 -9
- package/dist/watch/screen-core.d.ts +41 -4
- package/dist/watch/screen-core.js +458 -93
- package/dist/watch/screen-data.d.ts +25 -0
- package/dist/watch/screen-file.d.ts +8 -2
- package/dist/watch/screen-file.js +325 -38
- package/dist/watch/screen-profile.d.ts +19 -0
- package/dist/watch/screen-profile.js +1 -0
- package/dist/watch/screen.d.ts +22 -4
- package/dist/watch/screen.js +39 -5
- package/package.json +9 -3
- package/schema/team.schema.json +498 -0
package/dist/commands/doctor.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
83
|
-
|
|
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(...
|
|
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;
|
package/dist/commands/down.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/down.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
206
|
-
//
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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;
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/init.js
CHANGED
|
@@ -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 `#
|
|
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}
|
package/dist/commands/remove.js
CHANGED
|
@@ -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
|
-
|
|
217
|
-
if (!resting() || (
|
|
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;
|
package/dist/commands/status.js
CHANGED
|
@@ -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)
|
package/dist/commands/up.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/commands/watch.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/watch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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,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
|
+
}
|