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
@@ -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,17 +65,21 @@ 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');
68
74
  return 2;
69
75
  }
70
- const state = readState(dir).sessions[session] ?? emptySession();
71
- const budgets = budgetTable(sources.budgetsInForce(team, root), recall(state.budgets), sources.now().getTime());
76
+ const whole = readState(dir);
77
+ const state = whole.sessions[session] ?? emptySession();
78
+ const budgets = budgetTable(sources.budgetsInForce(team, root), recall(whole.budgets), sources.now().getTime());
72
79
  const comparison = compare(team, session, state, live, sources.now(), sources.watchInForce(team, root));
73
80
  if (!live.running)
74
81
  comparison.notes.unshift(`the herdr session "${session}" is not running`);
75
- 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));
76
83
  if (args.flags.has('json')) {
77
84
  const doc = {
78
85
  format: 1,
@@ -91,6 +98,18 @@ export async function runStatus(argv, io, sources) {
91
98
  }
92
99
  return comparison.differences.length ? 1 : 0;
93
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
+ }
94
113
  // A file that was never approved, or was changed since, runs nothing until the owner approves it.
95
114
  export function approvalDrift(differences) {
96
115
  if (differences === null)
@@ -35,6 +35,8 @@ export type Launch = {
35
35
  typeText?(session: string, pane: string, text: string): boolean;
36
36
  pressEnter?(session: string, pane: string): boolean;
37
37
  agentStatus?(session: string, pane: string): string | null;
38
+ /** Foreground argv0 names, or null when the pane can't be read. */
39
+ foreground(session: string, pane: string): string[] | null;
38
40
  sleep(ms: number): Promise<void>;
39
41
  now(): Date;
40
42
  };
@@ -44,6 +46,6 @@ export declare const realSources: UpSources;
44
46
  export declare const USAGE = "Usage: team up [--dry-run] [--session <name>] [--file <path>]\n";
45
47
  export declare const up: Command;
46
48
  export default up;
47
- /** The rules one seat gets at launch, with its own signature lines. */
49
+ /** The rules one seat gets at launch, with its own signature lines, as its delivery carries them. */
48
50
  export declare function rulesOf(team: TeamFile, seat: Seat): string;
49
51
  export declare function runUp(argv: string[], io: Io, sources: UpSources): Promise<number>;
@@ -7,7 +7,7 @@ import { readArgs } from "../args.js";
7
7
  import { callerOf, describeCaller, isOwner } from "../caller.js";
8
8
  import { loadTeamFile } from "../file/load.js";
9
9
  import { renderSignature } from "../file/signature.js";
10
- import { agentList, agentStatus, agentRename, paneRead, paneRun, typeText, pressEnter, sessionRunning, sessionState, startServer, workspaceClose, workspaceCreate, workspaceList, } from "../herdr.js";
10
+ import { agentList, agentStatus, agentRename, paneForeground, paneRead, paneRun, typeText, pressEnter, sessionRunning, sessionState, startServer, workspaceClose, workspaceCreate, workspaceList, } from "../herdr.js";
11
11
  import { executePlan } from "../launch/execute.js";
12
12
  import { formatPlan, upPlan } from "../launch/plan.js";
13
13
  import { rulesText } from "../launch/rules.js";
@@ -49,6 +49,7 @@ const realLaunch = {
49
49
  typeText: (session, pane, text) => typeText(pane, text, aim(session)),
50
50
  pressEnter: (session, pane) => pressEnter(pane, aim(session)),
51
51
  agentStatus: (session, pane) => agentStatus(pane, aim(session)),
52
+ foreground: (session, pane) => paneForeground(pane, aim(session)),
52
53
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
53
54
  now: () => new Date(),
54
55
  };
@@ -77,9 +78,11 @@ export const realSources = {
77
78
  export const USAGE = 'Usage: team up [--dry-run] [--session <name>] [--file <path>]\n';
78
79
  export const up = (argv, io) => runUp(argv, io, realSources);
79
80
  export default up;
80
- /** The rules one seat gets at launch, with its own signature lines. */
81
+ /** The rules one seat gets at launch, with its own signature lines, as its delivery carries them. */
81
82
  export function rulesOf(team, seat) {
82
83
  const { commits, pullRequests } = team.identity.signature;
84
+ const profile = profileFor(seat.cli);
85
+ const delivery = profile && profile.rulesOption !== null ? 'option' : 'message';
83
86
  return rulesText({
84
87
  coordinator: team.coordinator,
85
88
  rules: team.rules,
@@ -89,7 +92,7 @@ export function rulesOf(team, seat) {
89
92
  commitPosition: commits.position,
90
93
  },
91
94
  workspace: { mode: seat.mode, protected: team.workspace.protected, branch: team.workspace.branch },
92
- });
95
+ }, delivery);
93
96
  }
94
97
  function resolveState(sources, session) {
95
98
  if (sources.sessionState)
@@ -208,8 +211,8 @@ export async function runUp(argv, io, sources) {
208
211
  }
209
212
  const recorded = readState(dir).sessions[session];
210
213
  const workspaces = sources.workspaces?.(session) ?? null;
211
- const readings = loadReadings(dir, session);
212
- const spend = loadSpendReadings(dir, session);
214
+ const readings = loadReadings(dir);
215
+ const spend = loadSpendReadings(dir);
213
216
  const seats = [];
214
217
  const refused = new Set();
215
218
  for (const seat of team.seats) {
@@ -305,6 +308,7 @@ export async function runUp(argv, io, sources) {
305
308
  status: () => launch.agentStatus?.(session, pane) ?? null,
306
309
  type: (value) => launch.typeText?.(session, pane, value) ?? false,
307
310
  enter: () => launch.pressEnter?.(session, pane) ?? false,
311
+ foreground: () => launch.foreground(session, pane),
308
312
  now: () => now().getTime(),
309
313
  sleep: sources.sleep ?? launch.sleep,
310
314
  }),
@@ -331,7 +335,9 @@ export async function runUp(argv, io, sources) {
331
335
  updateState(dir, (file) => {
332
336
  const current = (file.sessions[session] ??= emptySession());
333
337
  const prior = current.seats[name] ?? { stage: patch.stage };
334
- 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 } : {}) };
335
341
  });
336
342
  },
337
343
  running(name) {
@@ -14,6 +14,8 @@ export type WatchSources = {
14
14
  readChecks(team: TeamFile, root: string, now: number): CheckOutcome[];
15
15
  screen(pane: string, session: string): string | null;
16
16
  status(pane: string, session: string): string | null;
17
+ /** Foreground argv0 names, or null when the pane can't be read. */
18
+ foreground(pane: string, session: string): string[] | null;
17
19
  typeText(pane: string, text: string, session: string): boolean;
18
20
  pressEnter(pane: string, session: string): boolean;
19
21
  notify(text: string): void;
@@ -27,6 +29,8 @@ export type WatchSources = {
27
29
  stopSeat?(session: string, seat: DownSeat): Promise<boolean>;
28
30
  /** Removes one merged worktree. The real watch calls `worktree remove`. */
29
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;
30
34
  };
31
35
  export declare const realWatchSources: WatchSources;
32
36
  export declare const watch: Command;
@@ -1,10 +1,11 @@
1
1
  import { readArgs } from "../args.js";
2
2
  import { budgetsInForce, watchInForce } from "../approve/approval.js";
3
- import { saveReadings, saveSpendReadings } from "../budgets/readings.js";
3
+ import { callerOf, describeCaller, isOwner } from "../caller.js";
4
+ import { loadReadings, saveSpendReadings, updateReadings } from "../budgets/readings.js";
4
5
  import { runChecks } from "../budgets/run.js";
5
6
  import { currentTeam } from "../file/current.js";
6
7
  import { homedir } from 'node:os';
7
- import { agentStatus, paneRead, pressEnter, typeText } from "../herdr.js";
8
+ import { agentStatus, paneForeground, paneRead, pressEnter, typeText } from "../herdr.js";
8
9
  import { logLine } from "../log.js";
9
10
  import { emptySession, readState, updateState } from "../state.js";
10
11
  import { readMachine } from "../watch/machine.js";
@@ -13,9 +14,13 @@ import { newMemory, pass } from "../watch/pass.js";
13
14
  import { readScreen } from "../watch/screen.js";
14
15
  import { judgeTemporary, judgeWorktree } from "../watch/close.js";
15
16
  import { readEnd } from "../watch/end.js";
17
+ import { reportedLiveAgent } from "../launch/agent.js";
18
+ import { boxHoldsText } from "../launch/deliver.js";
16
19
  import { stopRunning, realSources as removeSources } from "./remove.js";
17
20
  import { removeWorktree } from "./worktree.js";
18
21
  import { stateOf } from "./down.js";
22
+ import { profileFor } from "../profiles/index.js";
23
+ import { classifyWith, overridesInForce, quotaWith } from "../profiles/overrides.js";
19
24
  import { realSources } from "./status.js";
20
25
  function waitOrStop(seconds) {
21
26
  return new Promise((done) => {
@@ -41,6 +46,7 @@ export const realWatchSources = {
41
46
  readChecks: (team, root, now) => runChecks(team, root, now),
42
47
  screen: (pane, session) => paneRead(pane, 14, session),
43
48
  status: agentStatus,
49
+ foreground: (pane, session) => paneForeground(pane, session),
44
50
  typeText,
45
51
  pressEnter,
46
52
  notify,
@@ -56,6 +62,7 @@ export const realWatchSources = {
56
62
  }
57
63
  },
58
64
  pid: process.pid,
65
+ home: homedir(),
59
66
  };
60
67
  export const watch = (argv, io) => runWatch(argv, io, realWatchSources);
61
68
  export default watch;
@@ -74,17 +81,30 @@ export async function runWatch(argv, io, sources) {
74
81
  }
75
82
  const session = args.values.session ?? first.team.session;
76
83
  const { dir } = first;
84
+ // A seat that starts the session's only watch must not be able to drop the operator's nudge
85
+ // or the operator's desktop notices. The owner's own `--no-notify` still cannot silence a
86
+ // report addressed to the owner: those are routed below.
87
+ if (args.flags.has('no-nudge') || args.flags.has('no-notify')) {
88
+ const caller = callerOf(io, session);
89
+ if (!isOwner(caller)) {
90
+ io.stderr(`team watch: --no-nudge and --no-notify are the owner's, from a terminal outside herdr; this call is ${describeCaller(caller)}\n`);
91
+ return 1;
92
+ }
93
+ }
77
94
  const other = readState(dir).sessions[session]?.watch;
78
95
  if (other && other.pid !== sources.pid && sources.alive(other.pid)) {
79
96
  io.stderr(`team watch: a watch already runs for the session "${session}" (pid ${other.pid})\n`);
80
97
  return 1;
81
98
  }
82
- const say = (text, desktop) => {
99
+ // The log line is written for every report. `--no-notify` never removes it, and it never
100
+ // removes a desktop notice addressed to the owner. It does silence every other notice.
101
+ const tell = (text, notify) => {
83
102
  io.stdout(`${sources.now().toISOString()} ${text}\n`);
84
103
  logLine(dir, 'watch', 'watch', text, sources.now());
85
- if (desktop && !args.flags.has('no-notify'))
104
+ if (notify)
86
105
  sources.notify(text);
87
106
  };
107
+ const say = (text, desktop) => tell(text, desktop && !args.flags.has('no-notify'));
88
108
  const beat = () => updateState(dir, (state) => {
89
109
  const record = (state.sessions[session] ??= emptySession());
90
110
  record.watch = { pid: sources.pid, heartbeat: sources.now().toISOString() };
@@ -102,11 +122,32 @@ export async function runWatch(argv, io, sources) {
102
122
  const current = currentTeam(io.cwd, args.values.file, sources.now());
103
123
  const team = current.ok ? current.team : first.team;
104
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
+ }
105
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`;
106
138
  if (problem !== notice) {
107
139
  notice = problem;
108
140
  if (problem)
109
- say(problem, true);
141
+ tell(problem, true);
142
+ }
143
+ // Only the watch of the file's own session saves readings. A watch on another session —
144
+ // anyone may run one, from any terminal — reads and reports, but the figures it sees are
145
+ // not the project's cache that `up` and `add` count (§ 4.4): it says so once and writes
146
+ // none, budget or spend.
147
+ const foreign = session !== team.session;
148
+ if (foreign && !told.has('foreign-session')) {
149
+ told.add('foreign-session');
150
+ say(`the session "${session}" is not this file's "${team.session}": its readings are not saved`, false);
110
151
  }
111
152
  // The values in force, read with the file: until the owner approves an edit to `watch`,
112
153
  // the watch keeps running with what was approved, or with the defaults.
@@ -117,7 +158,7 @@ export async function runWatch(argv, io, sources) {
117
158
  const live = sources.live(session, team);
118
159
  if (!live) {
119
160
  if (!silent)
120
- say('herdr doesn\'t answer; the watch keeps trying', true);
161
+ tell('herdr doesn\'t answer; the watch keeps trying', true);
121
162
  silent = true;
122
163
  }
123
164
  else {
@@ -134,7 +175,8 @@ export async function runWatch(argv, io, sources) {
134
175
  // The money a spend check counted is kept, like the pass's screen readings: the launch
135
176
  // gate of `up` and `add` reads it later, and one that is stale by then reads unknown
136
177
  // (§ 5). An account whose check did not run keeps its stored reading.
137
- saveSpendReadings(dir, session, spendOf(outcomes));
178
+ if (!foreign)
179
+ saveSpendReadings(dir, spendOf(outcomes));
138
180
  for (const outcome of outcomes) {
139
181
  const key = `check:${outcome.account}`;
140
182
  if (outcome.state === 'read') {
@@ -149,21 +191,38 @@ export async function runWatch(argv, io, sources) {
149
191
  : `the check for ${outcome.account} is unapproved; that account reads unknown`, false);
150
192
  }
151
193
  }
152
- const result = pass({
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);
200
+ const run = (stored) => pass({
153
201
  team, state, live, machine: sources.machine(root), now, memory,
154
202
  approval: sources.approval(team, root), watch: inForce, outcomes, budgets: budget,
203
+ quotaFor: (cli) => quotaWith(cli, overrides.profiles),
204
+ readScreen: (cli, pane) => classifyWith(cli, pane, overrides.profiles),
205
+ readings: stored, foreground,
155
206
  });
207
+ // The pass folds its figures where the state is held: two watches of the project fold one
208
+ // after the other, not over each other. A watch on a foreign session still folds, for its
209
+ // reports, and saves nothing.
210
+ const result = foreign
211
+ ? run(loadReadings(dir))
212
+ : updateReadings(dir, now, (stored) => {
213
+ const folded = run(stored);
214
+ return { readings: folded.readings, value: folded };
215
+ });
156
216
  for (const report of result.reports)
157
- say(report.text, true);
158
- saveReadings(dir, session, result.readings, now);
217
+ tell(report.text, report.to === 'owner' || !args.flags.has('no-notify'));
159
218
  if (result.nudge) {
160
219
  if (args.flags.has('no-nudge'))
161
220
  say(`nudge not typed (--no-nudge): ${result.nudge.text}`, false);
162
221
  else
163
- deliver(result.nudge, team, session, sources, memory, say);
222
+ deliver(result.nudge, team, session, sources, memory, say, tell, told);
164
223
  }
165
224
  if (result.fallback)
166
- say(result.fallback, true);
225
+ tell(result.fallback, true);
167
226
  await closeEnded({ team, root, dir, session, live, sources, say, io, told });
168
227
  }
169
228
  beat();
@@ -177,7 +236,7 @@ export async function runWatch(argv, io, sources) {
177
236
  if (record?.watch?.pid === sources.pid)
178
237
  delete record.watch;
179
238
  });
180
- say(`the watch of "${session}" stopped`, true);
239
+ tell(`the watch of "${session}" stopped`, true);
181
240
  }
182
241
  return 0;
183
242
  }
@@ -198,7 +257,7 @@ function spendOf(outcomes) {
198
257
  }
199
258
  // Types the nudge, after reading the operator's screen once more: the pass saw it free, and a
200
259
  // prompt may have appeared since. Anything but an empty idle prompt keeps the nudge pending.
201
- function deliver(nudge, team, session, sources, memory, say) {
260
+ function deliver(nudge, team, session, sources, memory, say, tell, told) {
202
261
  const cli = team.seats.find((seat) => seat.name === team.operator)?.cli ?? '';
203
262
  const look = () => readScreen(cli, sources.screen(nudge.pane, session) ?? undefined).kind;
204
263
  const status = sources.status(nudge.pane, session);
@@ -208,15 +267,30 @@ function deliver(nudge, team, session, sources, memory, say) {
208
267
  memory.pending.push(...nudge.pending);
209
268
  memory.pendingSince ??= sources.now().getTime();
210
269
  };
270
+ const names = profileFor(cli)?.processNames ?? [];
271
+ const live = () => reportedLiveAgent(sources.foreground(nudge.pane, session), names);
272
+ const noAgent = 'nudge:no-agent';
273
+ if (!live()) {
274
+ tellOnce(told, noAgent, 'a nudge was not typed: no live agent in the operator\'s pane', tell);
275
+ keep();
276
+ return;
277
+ }
278
+ told.delete(noAgent);
211
279
  if ((status !== 'idle' && status !== 'done') || look() !== 'idle' || !sources.typeText(nudge.pane, nudge.text, session)) {
212
280
  keep();
213
281
  return;
214
282
  }
215
- // The text is in the box. A dialog that opened meanwhile must not get the Enter: the text
216
- // then stays unsent, which the next passes report, and the nudge is kept.
217
- const after = look();
218
- if (after !== 'idle' && after !== 'unsent') {
219
- say('a nudge was typed and not sent: the operator\'s screen changed before the Enter', true);
283
+ // The text is in the box. The agent is read again before Enter: it may have exited
284
+ // since the text was typed, and an unframed line is not a box to send.
285
+ if (!live()) {
286
+ tellOnce(told, noAgent, 'a nudge was not typed: no live agent in the operator\'s pane', tell);
287
+ keep();
288
+ return;
289
+ }
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);
220
294
  keep();
221
295
  return;
222
296
  }
@@ -225,6 +299,12 @@ function deliver(nudge, team, session, sources, memory, say) {
225
299
  else
226
300
  keep();
227
301
  }
302
+ function tellOnce(told, key, text, tell) {
303
+ if (told.has(key))
304
+ return;
305
+ told.add(key);
306
+ tell(text, true);
307
+ }
228
308
  function noteWorked(dir, session, live) {
229
309
  const recorded = readState(dir).sessions[session];
230
310
  if (!recorded)
@@ -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
+ }
@@ -0,0 +1,112 @@
1
+ export class Check {
2
+ problems = [];
3
+ warnings = [];
4
+ fail(line, message) {
5
+ this.problems.push({ line, message });
6
+ }
7
+ // The fields of a map, with every field outside `known` reported.
8
+ fields(node, where, known) {
9
+ const fields = new Map();
10
+ if (!node)
11
+ return fields;
12
+ if (node.kind !== 'map') {
13
+ if (!(node.kind === 'scalar' && node.value === null))
14
+ this.fail(node.line, `${where} must be a map of fields`);
15
+ return fields;
16
+ }
17
+ for (const entry of node.entries) {
18
+ if (known.includes(entry.key))
19
+ fields.set(entry.key, entry);
20
+ else
21
+ this.fail(entry.line, `unknown field "${entry.key}" in ${where}`);
22
+ }
23
+ return fields;
24
+ }
25
+ text(entry, name) {
26
+ if (!entry)
27
+ return undefined;
28
+ const node = entry.value;
29
+ if (node.kind === 'scalar' && typeof node.value === 'string' && node.value !== '')
30
+ return node.value;
31
+ this.fail(node.line, `${name} must be text${node.kind === 'scalar' && node.value !== null && node.value !== '' ? ': quote it' : ''}`);
32
+ return undefined;
33
+ }
34
+ required(entry, name, line) {
35
+ if (!entry)
36
+ this.fail(line, `${name} is required`);
37
+ return this.text(entry, name);
38
+ }
39
+ oneOf(entry, name, values) {
40
+ if (!entry)
41
+ return undefined;
42
+ const node = entry.value;
43
+ if (node.kind === 'scalar' && typeof node.value === 'string' && values.includes(node.value)) {
44
+ return node.value;
45
+ }
46
+ this.fail(node.line, `${name} must be one of: ${values.join(', ')}`);
47
+ return undefined;
48
+ }
49
+ flag(entry, name) {
50
+ if (!entry)
51
+ return false;
52
+ const node = entry.value;
53
+ if (node.kind === 'scalar' && typeof node.value === 'boolean' && !node.quoted)
54
+ return node.value;
55
+ this.fail(node.line, `${name} must be true or false`);
56
+ return false;
57
+ }
58
+ whole(entry, name, least) {
59
+ if (!entry)
60
+ return undefined;
61
+ const node = entry.value;
62
+ if (node.kind === 'scalar' && typeof node.value === 'number' && Number.isInteger(node.value) && node.value >= least) {
63
+ return node.value;
64
+ }
65
+ this.fail(node.line, `${name} must be a whole number, ${least} or more`);
66
+ return undefined;
67
+ }
68
+ number(entry, name) {
69
+ if (!entry)
70
+ return undefined;
71
+ const node = entry.value;
72
+ if (node.kind === 'scalar' && typeof node.value === 'number' && Number.isFinite(node.value) && node.value > 0) {
73
+ return node.value;
74
+ }
75
+ this.fail(node.line, `${name} must be a number above 0`);
76
+ return undefined;
77
+ }
78
+ // A value with its unit, such as 120s, 25% or 10GB.
79
+ measure(entry, name, units, example) {
80
+ if (!entry)
81
+ return undefined;
82
+ const node = entry.value;
83
+ const raw = node.kind === 'scalar' ? node.raw : '';
84
+ const match = /^([0-9]+(?:\.[0-9]+)?)([A-Za-z%]+)$/.exec(raw);
85
+ const unit = match?.[2];
86
+ if (match && unit !== undefined && unit in units && node.kind === 'scalar' && !node.quoted) {
87
+ return Number(match[1]) * units[unit];
88
+ }
89
+ this.fail(node.line, `${name} needs a value with its unit (${Object.keys(units).join(', ')}), such as ${example}`);
90
+ return undefined;
91
+ }
92
+ list(entry, name) {
93
+ if (!entry)
94
+ return [];
95
+ const node = entry.value;
96
+ if (node.kind !== 'seq') {
97
+ if (!(node.kind === 'scalar' && node.value === null))
98
+ this.fail(node.line, `${name} must be a list`);
99
+ return [];
100
+ }
101
+ const out = [];
102
+ for (const item of node.items) {
103
+ if (item.kind === 'scalar' && typeof item.value === 'string' && item.value !== '') {
104
+ out.push({ value: item.value, line: item.line });
105
+ }
106
+ else {
107
+ this.fail(item.line, `each item of ${name} must be text${item.kind === 'map' ? ': quote a line that contains ": "' : ''}`);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+ }
@@ -1,5 +1,6 @@
1
1
  // Edits the team file by the line. Comments, order and every other seat stay as written.
2
2
  // Nothing here re-serialises the file.
3
+ import { defaultLabel } from "./validate.js";
3
4
  function indent(line) {
4
5
  let n = 0;
5
6
  while (line[n] === ' ')
@@ -100,6 +101,11 @@ export function rewriteCount(text, declared) {
100
101
  return null;
101
102
  const lines = body(text, block);
102
103
  const label = field(lines, 'label');
104
+ const model = field(lines, 'model');
105
+ const version = field(lines, 'version');
106
+ // A counted seat with no label is titled with the model. Later instances need that title
107
+ // written out, or they collide with the first on the same default.
108
+ const implied = label === null && model !== null && version !== null ? defaultLabel(model, version) : null;
103
109
  const copies = [];
104
110
  for (let instance = 1; instance <= block.count; instance++) {
105
111
  const suffix = instance === 1 ? '' : `-${instance}`;
@@ -108,8 +114,13 @@ export function rewriteCount(text, declared) {
108
114
  return [];
109
115
  if (instance === 1)
110
116
  return [line];
111
- if (/^\s*(?:-\s+)?name:/.test(line))
112
- return [setValue(line, `${declared}${suffix}`)];
117
+ if (/^\s*(?:-\s+)?name:/.test(line)) {
118
+ const renamed = [setValue(line, `${declared}${suffix}`)];
119
+ if (implied === null)
120
+ return renamed;
121
+ const pad = /^(\s*)/.exec(line)?.[1] ?? '';
122
+ return [...renamed, `${pad}label: ${implied}${suffix}`];
123
+ }
113
124
  if (label !== null && /^\s*label:/.test(line))
114
125
  return [setValue(line, `${label}${suffix}`)];
115
126
  return [line];
@@ -0,0 +1,15 @@
1
+ import type { TeamFile } from '../types.ts';
2
+ import type { Section } from './section.ts';
3
+ /** What the budgets section hands the rest of the file: its value, and the account names it declares. */
4
+ export interface Budgeted {
5
+ budgets: TeamFile['budgets'];
6
+ /** Every name the accounts map writes, whether or not the account itself reads cleanly. */
7
+ declared: Set<string>;
8
+ }
9
+ export declare const budgets: Section;
10
+ /**
11
+ * The budget values a file that sets none runs with — and, since the section is the owner's, the
12
+ * values a file runs with until the owner approves what it sets instead. No accounts: nothing is
13
+ * budgeted until the owner names one.
14
+ */
15
+ export declare function defaultBudgets(): TeamFile['budgets'];