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
package/README.md CHANGED
@@ -18,7 +18,7 @@ Node 22 or later runs the built command.
18
18
 
19
19
  ```sh
20
20
  npm install -g team # or run it without installing: npx team
21
- team --version # 0.1.2
21
+ team --version # 0.2.0
22
22
  ```
23
23
 
24
24
  ## The file is private to each clone
@@ -34,13 +34,14 @@ yourself.
34
34
  A documented subset of YAML, read by the library's own parser: maps, lists, one-line `{ }` and
35
35
  `[ ]`, plain and quoted values, comments. Anchors, aliases, tags, block scalars, several documents
36
36
  in one file and duplicate keys are refused, with the line number. The file starts with `format: 1`.
37
+ The package ships the JSON Schema at `schema/team.schema.json`, and `team init` writes a `# yaml-language-server: $schema=…` line at the top of the file so editors validate it.
37
38
  By example:
38
39
 
39
40
  ```yaml
40
41
  format: 1 # the only format this version reads
41
42
  project: hello
42
- coordinator: claude-coord # the seat that dispatches work
43
- operator: claude-coord # the seat the watch reports to
43
+ coordinator: coordinator # the seat that dispatches work
44
+ operator: coordinator # the seat the watch reports to
44
45
 
45
46
  identity:
46
47
  signature:
@@ -59,7 +60,7 @@ workspace:
59
60
 
60
61
  seats:
61
62
  - role: coordinator
62
- name: claude-coord
63
+ name: coordinator
63
64
  cli: claude-code # the launch profile
64
65
  vendor: anthropic # the model's maker
65
66
  model: Claude Opus # the model's name, without its version
@@ -67,7 +68,7 @@ seats:
67
68
  launch: claude --model claude-opus-5-5 # no approval flags: the profile adds them
68
69
 
69
70
  - role: implementer
70
- name: codex-hello
71
+ name: implementer
71
72
  cli: codex
72
73
  vendor: openai
73
74
  account: openai-hello # the seat's account, when one vendor has two; absent, its vendor
@@ -78,17 +79,17 @@ seats:
78
79
  parked: true # running, and not reported while idle
79
80
 
80
81
  - role: implementer
81
- name: deepseek-hello
82
+ name: implementer-deepseek
82
83
  cli: claude-code # DeepSeek's model, run by Claude Code
83
84
  vendor: deepseek
84
85
  model: DeepSeek Flash
85
86
  version: "V4.1"
86
87
  display: DeepSeek V4.1 Flash
87
88
  launch: team-deepseek # a launcher on the PATH, holding the account's key and endpoint
88
- count: 2 # deepseek-hello and deepseek-hello-2
89
+ count: 2 # implementer-deepseek and implementer-deepseek-2
89
90
 
90
91
  - role: reviewer
91
- name: grok-hello
92
+ name: reviewer
92
93
  cli: grok
93
94
  vendor: xai
94
95
  model: Grok
@@ -98,7 +99,7 @@ seats:
98
99
 
99
100
  budgets: # the owner's: reserve or floor per account, marks, freshness
100
101
  accounts:
101
- openai-hello: # the account codex-hello spends
102
+ openai-hello: # the account implementer spends
102
103
  kind: subscription
103
104
  reserve: 10% # refuse a launch on a figure inside it
104
105
  sources: [status_line] # the figure comes off Codex's status line
@@ -125,6 +126,15 @@ budgets: # the owner's: reserve or floor per account, marks
125
126
  `trust` and outside every protected checkout — never in the project root; `up` and `add` refuse a
126
127
  seat whose folder, lobby included, would be protected or untrusted.
127
128
 
129
+ ### Naming seats
130
+
131
+ The herdr session carries the project, so a seat's name is its role: `coordinator`, `implementer`,
132
+ `reviewer`. When a role is used twice, the model is added: `implementer-deepseek`. The label is the
133
+ herdr workspace title. Left out of the file, it is the model and version in lowercase
134
+ (`claude opus 5.5`), taken from that seat's own fields, so a model change retitles the pane. A
135
+ label written in the file is kept. `team doctor` warns, and does not refuse the file, when a name
136
+ or a label repeats the project or the session.
137
+
128
138
  The Codex profile is tested with CLI 0.157.0. Its status line is read for a weekly figure
129
139
  (`weekly N% left`) when the pane is wide enough to show the number; a cut line is not a figure.
130
140
  It adds `-a never -s danger-full-access`
@@ -147,7 +157,8 @@ under ~/.cursor/projects for that folder; team writes no trust (.workspace-trust
147
157
  config.
148
158
 
149
159
  `budgets` is the owner's: marks (percent used), how long a figure stays fresh, and each
150
- account's reserve or floor. A seat spends its own `account:` when the file names one, its `vendor`
160
+ account's reserve or floor. An account's `shared` key is informational; `team` does not act
161
+ on it. A seat spends its own `account:` when the file names one, its `vendor`
151
162
  when it doesn't, so one vendor's two accounts are two buckets; a pattern names the account it
152
163
  measures, not the seat's. A `check` command is resolved to a file and hashed when the
153
164
  owner approves. A change to that file leaves that account's check unapproved: it is
@@ -264,7 +275,7 @@ cd team
264
275
  bun install
265
276
  bun run build
266
277
  npm install -g . # puts `team` on the PATH
267
- team --version # 0.1.2
278
+ team --version # 0.2.0
268
279
  ```
269
280
 
270
281
  Bun builds and tests the sources:
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { defaultBudgets, defaultWatch, validateTeamFile } from "../file/validate.js";
3
3
  import { readApproval, storePath, writeApproval } from "../store/store.js";
4
- import { compare, describe, fingerprints, legacySeatDigests, OWNER_SECTIONS } from "./fingerprint.js";
4
+ import { compare, describe, fingerprints, legacyLabelDigests, legacySeatDigests, OWNER_SECTIONS } from "./fingerprint.js";
5
5
  /** The ceilings an approval fixes: `up` and `add` read them from the record, never from the file. */
6
6
  export function ceilingsOf(team) {
7
7
  return { seats: team.limits.seats, temporary: team.limits.temporary, vendors: { ...team.limits.vendors } };
@@ -31,25 +31,30 @@ export function approvedFingerprints(record) {
31
31
  if (!OWNER_SECTIONS.every((name) => stored.sections[name] !== undefined) && checked.ok) {
32
32
  sections = { ...fingerprints(checked.team).sections, ...stored.sections };
33
33
  }
34
- const seats = checked.ok ? adoptFlagDigests(stored.seats, checked.team) : stored.seats;
34
+ const seats = checked.ok ? adoptLegacyDigests(stored.seats, checked.team) : stored.seats;
35
35
  if (sections === stored.sections && seats === stored.seats)
36
36
  return stored;
37
37
  return { sections, seats };
38
38
  }
39
39
  /**
40
- * A record from before `parked` and `stopped` were in the digest still names
41
- * the seat as it was approved, flags included, read from the stored copy. A
42
- * digest already in the new shape is left alone, so a later edit of the file
43
- * is not adopted from a stale copy. A copy that can't be read is left alone.
40
+ * A record from before `parked` and `stopped` were in the digest, or from when
41
+ * an omitted label was the seat's name, still names the seat as it was
42
+ * approved, read from the stored copy. A digest already in the new shape is
43
+ * left alone, so a later edit of the file is not adopted from a stale copy.
44
+ * A copy that can't be read is left alone.
44
45
  */
45
- function adoptFlagDigests(stored, team) {
46
+ function adoptLegacyDigests(stored, team) {
46
47
  const current = fingerprints(team).seats;
47
- const legacy = legacySeatDigests(team);
48
+ const legacyFlags = legacySeatDigests(team);
49
+ const legacyLabels = legacyLabelDigests(team);
48
50
  let changed = false;
49
51
  const next = { ...stored };
50
52
  for (const [name, previous] of Object.entries(stored)) {
51
53
  const adopted = current[name];
52
- if (adopted !== undefined && previous === legacy[name] && previous !== adopted) {
54
+ const old = previous === legacyFlags[name] ||
55
+ previous === legacyLabels.named[name] ||
56
+ previous === legacyLabels.namedWithoutFlags[name];
57
+ if (adopted !== undefined && old && previous !== adopted) {
53
58
  next[name] = adopted;
54
59
  changed = true;
55
60
  }
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * The sections only the owner changes. An edit to any of them needs a new
3
- * approval before the file runs.
3
+ * approval before the file runs. Generated from the section modules, in their
4
+ * list's order — the order feeds the approval digest, so the list keeps it:
5
+ * `watch` last with `watch.checks`, the line inside it, after it.
4
6
  */
5
- export declare const OWNER_SECTIONS: readonly ["trust", "limits", "machine", "rules", "identity", "workspace", "coordinator", "operator", "session", "visibility", "tools", "budgets", "watch", "watch.checks"];
7
+ export declare const OWNER_SECTIONS: readonly string[];
6
8
  /** A validated team file, as far as an approval reads it. */
7
9
  export type Approvable = Record<string, unknown> & {
8
10
  seats: readonly (Record<string, unknown> & {
@@ -23,6 +25,16 @@ export declare function fingerprints(team: Approvable): Fingerprints;
23
25
  * them. An approval from then still matches a file that has not changed.
24
26
  */
25
27
  export declare function legacySeatDigests(team: Approvable): Record<string, string>;
28
+ /**
29
+ * Seat digests as a record written when an omitted label was the seat's name.
30
+ * `v0.1.2` hashed `parked` and `stopped`; `v0.1.1` left them out. A written
31
+ * label hashes the same title in every shape, so these equal a stored digest
32
+ * only where the title was the name.
33
+ */
34
+ export declare function legacyLabelDigests(team: Approvable): {
35
+ named: Record<string, string>;
36
+ namedWithoutFlags: Record<string, string>;
37
+ };
26
38
  export type Difference = {
27
39
  kind: 'section';
28
40
  name: string;
@@ -1,28 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { SECTIONS } from "../file/sections/index.js";
2
3
  /**
3
4
  * The sections only the owner changes. An edit to any of them needs a new
4
- * approval before the file runs.
5
+ * approval before the file runs. Generated from the section modules, in their
6
+ * list's order — the order feeds the approval digest, so the list keeps it:
7
+ * `watch` last with `watch.checks`, the line inside it, after it.
5
8
  */
6
- export const OWNER_SECTIONS = [
7
- 'trust',
8
- 'limits',
9
- 'machine',
10
- 'rules',
11
- 'identity',
12
- 'workspace',
13
- 'coordinator',
14
- 'operator',
15
- 'session',
16
- 'visibility',
17
- 'tools',
18
- 'budgets',
19
- // The watch's own timings, thresholds included: a seat allowed to stretch `unsent_after` or
20
- // `idle_first` could silence the watch itself, so the section is the owner's like the rest.
21
- 'watch',
22
- // And turning a check off is the finer line inside it: the digest above leaves the checks out,
23
- // so turning one off reads as `watch.checks` alone, never as a threshold change too.
24
- 'watch.checks',
25
- ];
9
+ export const OWNER_SECTIONS = SECTIONS.filter((section) => section.owner).map((section) => section.name);
26
10
  /** One owner section, read from the file; the two watch sections sit inside `watch`, not at the top. */
27
11
  function sectionOf(team, name) {
28
12
  if (name === 'watch.checks')
@@ -82,6 +66,22 @@ export function legacySeatDigests(team) {
82
66
  seats[seat.name] = seatDigest(seat, LEGACY_SEAT_FREE_FIELDS);
83
67
  return seats;
84
68
  }
69
+ /**
70
+ * Seat digests as a record written when an omitted label was the seat's name.
71
+ * `v0.1.2` hashed `parked` and `stopped`; `v0.1.1` left them out. A written
72
+ * label hashes the same title in every shape, so these equal a stored digest
73
+ * only where the title was the name.
74
+ */
75
+ export function legacyLabelDigests(team) {
76
+ const named = {};
77
+ const namedWithoutFlags = {};
78
+ for (const seat of team.seats) {
79
+ const titled = { ...seat, label: seat.name };
80
+ named[seat.name] = seatDigest(titled, SEAT_FREE_FIELDS);
81
+ namedWithoutFlags[seat.name] = seatDigest(titled, LEGACY_SEAT_FREE_FIELDS);
82
+ }
83
+ return { named, namedWithoutFlags };
84
+ }
85
85
  function seatDigest(seat, free) {
86
86
  const fields = Object.fromEntries(Object.entries(seat).filter(([key]) => !free.has(key)));
87
87
  return digest(fields);
@@ -125,7 +125,12 @@ function age(ms) {
125
125
  return `${hours}h`;
126
126
  return `${minutes}m`;
127
127
  }
128
- /** A money figure in a refusal: the cents are shown even when the file wrote none. */
128
+ /**
129
+ * A money figure in a refusal, as read: up to four decimals — the most a check or a floor may
130
+ * carry — with the trailing zeros beyond the cents dropped, and never fewer than two decimals.
131
+ * The figure the words judge is the figure the eyes see.
132
+ */
129
133
  function money(amount) {
130
- return amount.toFixed(2);
134
+ const written = amount.toFixed(4).replace(/0+$/, '');
135
+ return written.padEnd(written.indexOf('.') + 3, '0');
131
136
  }
@@ -27,7 +27,7 @@ export type StoredReading = {
27
27
  confirmed: boolean;
28
28
  };
29
29
  export type Verdict = {
30
- kind: 'fresh' | 'unconfirmed' | 'stale' | 'refusing';
30
+ kind: 'fresh' | 'unconfirmed' | 'stale' | 'refusing' | 'last-seen';
31
31
  reading: Seen;
32
32
  } | {
33
33
  kind: 'unknown';
@@ -62,7 +62,9 @@ export declare function checkOf(list: readonly Seen[]): Seen[];
62
62
  * Among confirmed live readings, the newest change wins. A first sight counts
63
63
  * only when no confirmed reading is still live. Staleness is measured from
64
64
  * `changedAt`. A stale reading inside the reserve refuses until its reset;
65
- * with no reset time it is unknown.
65
+ * with no reset time it is unknown when it could still matter — inside the
66
+ * reserve or within the reserve again outside it — and last seen when it sits
67
+ * further out, room the account may still hold. It never refuses.
66
68
  */
67
69
  export declare function verdict(list: readonly Seen[], now: number, staleAfterMs: number, reserve: number | null): Verdict;
68
70
  export type CountedReading = {
@@ -81,6 +83,8 @@ export type CountedReading = {
81
83
  * through when it is unknown, and a bare first sight falls through too, so a fresh check below it
82
84
  * still counts (§ 4.3 rule 5). A stale reading inside its reserve with its reset ahead is kept as
83
85
  * the fallback, whichever source it came from, so rule 4 still refuses when nothing below counts.
86
+ * A stale figure with no known reset counts as the room last seen only when it is well outside
87
+ * the reserve, and it sits below any inside-reserve figure: a last sight never clears a refusal.
84
88
  */
85
89
  export declare function countedFor(sources: readonly ReadingSource[], screen: readonly Seen[], checks: readonly Seen[], now: number, staleAfterMs: number, reserve: number | null): CountedReading;
86
90
  /** Readings whose reset has passed are left out. One with no reset time is kept. */
@@ -62,12 +62,22 @@ export function screenOf(list) {
62
62
  export function checkOf(list) {
63
63
  return list.filter((item) => item.source === 'check');
64
64
  }
65
+ /**
66
+ * The distance rule for a stale figure with no known reset (§ 5), in one place: more than the
67
+ * reserve again outside it, the figure is the room last seen rather than unknown. `verdict` and
68
+ * the check branch of `countedFor` both ask this, so the two cannot be edited apart.
69
+ */
70
+ function pastTheReserveAgain(left, reserve) {
71
+ return left > reserve * 2;
72
+ }
65
73
  /**
66
74
  * The reading that counts. A figure from before a known reset is dropped.
67
75
  * Among confirmed live readings, the newest change wins. A first sight counts
68
76
  * only when no confirmed reading is still live. Staleness is measured from
69
77
  * `changedAt`. A stale reading inside the reserve refuses until its reset;
70
- * with no reset time it is unknown.
78
+ * with no reset time it is unknown when it could still matter — inside the
79
+ * reserve or within the reserve again outside it — and last seen when it sits
80
+ * further out, room the account may still hold. It never refuses.
71
81
  */
72
82
  export function verdict(list, now, staleAfterMs, reserve) {
73
83
  const live = list.filter((item) => item.resetsAt === null || item.resetsAt > now);
@@ -87,8 +97,11 @@ export function verdict(list, now, staleAfterMs, reserve) {
87
97
  const inside = reserve !== null && reading.left <= reserve;
88
98
  if (inside && reading.resetsAt !== null)
89
99
  return { kind: 'refusing', reading };
90
- if (reading.resetsAt === null)
100
+ if (reading.resetsAt === null) {
101
+ if (reserve !== null && pastTheReserveAgain(reading.left, reserve))
102
+ return { kind: 'last-seen', reading };
91
103
  return { kind: 'unknown' };
104
+ }
92
105
  return { kind: 'stale', reading };
93
106
  }
94
107
  /**
@@ -98,9 +111,12 @@ export function verdict(list, now, staleAfterMs, reserve) {
98
111
  * through when it is unknown, and a bare first sight falls through too, so a fresh check below it
99
112
  * still counts (§ 4.3 rule 5). A stale reading inside its reserve with its reset ahead is kept as
100
113
  * the fallback, whichever source it came from, so rule 4 still refuses when nothing below counts.
114
+ * A stale figure with no known reset counts as the room last seen only when it is well outside
115
+ * the reserve, and it sits below any inside-reserve figure: a last sight never clears a refusal.
101
116
  */
102
117
  export function countedFor(sources, screen, checks, now, staleAfterMs, reserve) {
103
118
  let fallback = null;
119
+ let lastSeen = null;
104
120
  let unconfirmed = null;
105
121
  for (const source of sources) {
106
122
  if (source === 'check') {
@@ -112,6 +128,10 @@ export function countedFor(sources, screen, checks, now, staleAfterMs, reserve)
112
128
  const inside = checks.find((item) => item.resetsAt !== null && item.resetsAt > now && reserve !== null && item.left <= reserve);
113
129
  if (inside)
114
130
  fallback ??= inside;
131
+ // A stale check figure with no reset, past the reserve again, is the room last seen.
132
+ const sight = checks.find((item) => item.resetsAt === null && reserve !== null && pastTheReserveAgain(item.left, reserve));
133
+ if (sight)
134
+ lastSeen ??= sight;
115
135
  continue;
116
136
  }
117
137
  const result = verdict(screen, now, staleAfterMs, reserve);
@@ -123,10 +143,16 @@ export function countedFor(sources, screen, checks, now, staleAfterMs, reserve)
123
143
  }
124
144
  if (result.kind === 'fresh')
125
145
  return { kind: 'counted', reading: result.reading };
146
+ if (result.kind === 'last-seen') {
147
+ lastSeen ??= result.reading;
148
+ continue;
149
+ }
126
150
  fallback ??= result.reading;
127
151
  }
128
152
  if (fallback)
129
153
  return { kind: 'counted', reading: fallback };
154
+ if (lastSeen)
155
+ return { kind: 'counted', reading: lastSeen };
130
156
  if (unconfirmed)
131
157
  return { kind: 'unconfirmed', reading: unconfirmed };
132
158
  return { kind: 'unknown' };
@@ -12,10 +12,16 @@ const SPEND = /^([0-9]+(?:\.[0-9]{1,4})?) ([A-Z]{3})$/;
12
12
  // § 5: one to three lines, each a window, each figure whole and at most 100. The
13
13
  // printed reset is a duration; the printed measurement time is unix seconds.
14
14
  const LINE = /^(session|daily|weekly) (100|[0-9]{1,2})% (used|left)(?: resets ([0-9]+h(?:[0-9]+m)?|[0-9]+m))?(?: at ([0-9]{10}))?$/;
15
- /** An output's lines: one trailing newline allowed, and nothing else. */
15
+ /**
16
+ * An output's lines: one trailing newline allowed, and nothing else. A "\r\n" ending reads
17
+ * as its line — the CR goes with the newline — and a CR anywhere else stays a character of
18
+ * the line, off the contract.
19
+ */
16
20
  function linesOf(text) {
17
21
  const body = text.endsWith('\n') ? text.slice(0, -1) : text;
18
- return body === '' ? null : body.split('\n');
22
+ if (body === '')
23
+ return null;
24
+ return body.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
19
25
  }
20
26
  /**
21
27
  * A subscription output against § 5. Any line off the contract, a figure over
@@ -11,6 +11,8 @@ export type BudgetRow = {
11
11
  seat: string | null;
12
12
  age: string | null;
13
13
  source: ReadingSource | null;
14
+ /** True when the counted figure did not come from the first source the account names (§ 3). */
15
+ fallback: boolean;
14
16
  state: BudgetState;
15
17
  /** True when a subscription's left figure is at or inside its reserve. */
16
18
  inside: boolean;
@@ -18,10 +18,11 @@ export function budgetTable(budgets, list, now) {
18
18
  named.add(account);
19
19
  const reserve = reserveOf(budgets, account);
20
20
  // The window's reading from the sources the account names, in order (§ 3, § 5).
21
- const result = countedFor(sourcesOf(budgets, account), screenOf(group), checkOf(group), now, staleAfterMs, reserve);
21
+ const sources = sourcesOf(budgets, account);
22
+ const result = countedFor(sources, screenOf(group), checkOf(group), now, staleAfterMs, reserve);
22
23
  rows.push(result.kind === 'unknown'
23
24
  ? { ...blank(account, reserve), window }
24
- : rowOf(result, now, staleAfterMs, reserve));
25
+ : rowOf(result, now, staleAfterMs, reserve, result.reading.source !== sources[0]));
25
26
  }
26
27
  for (const account of Object.keys(budgets.accounts)) {
27
28
  if (!named.has(account))
@@ -40,15 +41,17 @@ export function budgetLine(row) {
40
41
  }
41
42
  const reset = row.resetsIn === null ? 'resets unknown' : `resets in ${row.resetsIn}`;
42
43
  const from = row.source === 'status_line' ? 'status line' : row.source === 'check' ? 'check' : 'unknown source';
43
- const when = row.source === 'check' ? 'read' : 'changed';
44
+ const source = row.fallback ? `${from} (fallback)` : from;
45
+ // A stale row with no known reset is the room last seen, not a reading aged out.
46
+ const when = row.state === 'stale' && row.resetsIn === null ? 'last seen' : row.source === 'check' ? 'read' : 'changed';
44
47
  const state = row.inside && row.reserve !== null ? `${row.state}, inside reserve ${row.reserve}%` : row.state;
45
- return `${row.account} ${row.window} left ${row.left}% used ${row.used}% ${reset} ${row.seat ?? '-'} ${when} ${row.age} ago ${from} ${state}`;
48
+ return `${row.account} ${row.window} left ${row.left}% used ${row.used}% ${reset} ${row.seat ?? '-'} ${when} ${row.age} ago ${source} ${state}`;
46
49
  }
47
50
  /** The sources the account's figures are read from, in order: a status line when the file is silent. */
48
51
  function sourcesOf(budgets, account) {
49
52
  return budgets.accounts[account]?.sources ?? ['status_line'];
50
53
  }
51
- function rowOf(result, now, staleAfterMs, reserve) {
54
+ function rowOf(result, now, staleAfterMs, reserve, fallback) {
52
55
  const reading = result.reading;
53
56
  const inside = reserve !== null && reading.left <= reserve;
54
57
  const state = result.kind === 'unconfirmed'
@@ -65,6 +68,7 @@ function rowOf(result, now, staleAfterMs, reserve) {
65
68
  seat: reading.seat,
66
69
  age: span(now - reading.changedAt),
67
70
  source: reading.source,
71
+ fallback,
68
72
  state,
69
73
  inside,
70
74
  reserve,
@@ -80,6 +84,7 @@ function blank(account, reserve) {
80
84
  seat: null,
81
85
  age: null,
82
86
  source: null,
87
+ fallback: false,
83
88
  state: 'unknown',
84
89
  inside: false,
85
90
  reserve,
package/dist/cli.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Command, Io } from './io.ts';
3
+ import { version } from './version.ts';
3
4
  export declare const commands: Record<string, () => Promise<{
4
5
  default: Command;
5
6
  USAGE: string;
6
7
  }>>;
7
- export declare function version(): string;
8
+ export { version };
8
9
  export declare function main(argv: string[], io: Io): Promise<number>;
9
10
  export declare function processIo(): Io;
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, realpathSync } from 'node:fs';
2
+ import { realpathSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { version } from "./version.js";
4
5
  // Each command is loaded only when it is called. A slice adds its line here. The table is exported
5
6
  // so a test can walk it: every command, `--help`, `-h` and a usage line.
6
7
  export const commands = {
@@ -29,10 +30,7 @@ Options:
29
30
 
30
31
  The team is declared in <project>/.agents/team.yaml.
31
32
  `;
32
- export function version() {
33
- const text = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
34
- return JSON.parse(text).version;
35
- }
33
+ export { version };
36
34
  export async function main(argv, io) {
37
35
  const [name, ...rest] = argv;
38
36
  const names = Object.keys(commands);
@@ -45,6 +43,11 @@ export async function main(argv, io) {
45
43
  io.stdout(`${version()}\n`);
46
44
  return 0;
47
45
  }
46
+ // Hidden: a port speaks this protocol. It is not a command in the help text.
47
+ if (name === 'conformance-adapter') {
48
+ const { default: run } = await import("./conformance/adapter.js");
49
+ return run(rest, io);
50
+ }
48
51
  const load = commands[name];
49
52
  if (!load) {
50
53
  io.stderr(`team: unknown command "${name}"\n\n${usage}`);
@@ -9,7 +9,6 @@ export type AddSources = {
9
9
  agents(session: string): HerdrAgent[] | null;
10
10
  workspaces(session: string): {
11
11
  id: string;
12
- label: string;
13
12
  }[] | null;
14
13
  doctor: DoctorSources;
15
14
  machine?: (root: string) => Machine;
@@ -49,7 +49,7 @@ export const realSources = {
49
49
  agents: (session) => agentList(aim(session)),
50
50
  workspaces(session) {
51
51
  const listed = workspaceList(aim(session));
52
- return listed === null ? null : listed.map((workspace) => ({ id: workspace.id, label: workspace.label }));
52
+ return listed === null ? null : listed.map((workspace) => ({ id: workspace.id }));
53
53
  },
54
54
  doctor: doctorSources,
55
55
  machine: readMachine,
@@ -123,6 +123,7 @@ export async function runAdd(argv, io, sources = realSources) {
123
123
  return 1;
124
124
  }
125
125
  const agents = live === 'running' ? sources.agents(session) : [];
126
+ // Read so a session whose workspace list can't be read is refused. The title is not a key.
126
127
  const workspaces = live === 'running' ? sources.workspaces(session) : [];
127
128
  if (agents === null || workspaces === null) {
128
129
  io.stderr(`team add: session ${session} runs, and its agents can't be read\n`);
@@ -203,7 +204,7 @@ export async function runAdd(argv, io, sources = realSources) {
203
204
  // changes no section of its own, and no unapproved reserve may unblock a launch (#50).
204
205
  const budgets = budgetsInForce(prepared.team, root, sources.home);
205
206
  const decision = seatBudget(budgets, loadReadings(dir), built.seat, sources.now().getTime(), loadSpendReadings(dir));
206
- const stray = unnamedIn(built.seat.label, agents, workspaces);
207
+ const stray = unnamedIn(recorded.seats[built.name]?.workspace, agents);
207
208
  const starting = seatPlan(prepared.team, built.seat, start);
208
209
  const planned = stray
209
210
  ? { ...starting, stage: 'launched', pane: stray.pane, workspace: stray.workspace, agentLive: true }
@@ -354,9 +355,10 @@ function seatPlan(team, seat, start) {
354
355
  ...(start.lobby ? { lobby: true } : {}),
355
356
  };
356
357
  }
357
- function unnamedIn(label, agents, workspaces) {
358
- const ids = new Set(workspaces.filter((workspace) => workspace.label === label).map((workspace) => workspace.id));
359
- return agents.find((agent) => !agent.name && ids.has(agent.workspace));
358
+ function unnamedIn(workspace, agents) {
359
+ if (!workspace)
360
+ return undefined;
361
+ return agents.find((agent) => !agent.name && agent.workspace === workspace);
360
362
  }
361
363
  function runningOf(agents, team, state) {
362
364
  const seats = new Map(team.seats.map((seat) => [seat.name, seat]));
@@ -11,6 +11,7 @@ import { callerOf, describeCaller, isOwner } from "../caller.js";
11
11
  import { loadTeamFile, placedProblems } from "../file/load.js";
12
12
  import { validateTeamFile } from "../file/validate.js";
13
13
  import { logLine } from "../log.js";
14
+ import { OVERRIDE_CHANGED, overrideFile } from "../profiles/overrides.js";
14
15
  import { readApproval, storePath, writeApproval } from "../store/store.js";
15
16
  export const realSources = {
16
17
  async ask(question) {
@@ -86,6 +87,12 @@ export async function runApprove(argv, io, sources) {
86
87
  for (const warning of checked.warnings)
87
88
  io.stderr(`team approve: warning, line ${warning.line}: ${warning.message}\n`);
88
89
  const store = storePath(team.project, root, sources.home);
90
+ const live = overrideFile(team.project, root, sources.home);
91
+ if (live.problems.length) {
92
+ for (const problem of live.problems)
93
+ io.stderr(`team approve: ${problem}\n`);
94
+ return 2;
95
+ }
89
96
  const problem = storeProblem(store, root, team);
90
97
  if (problem) {
91
98
  io.stderr(`team approve: ${problem}\n`);
@@ -119,6 +126,15 @@ export async function runApprove(argv, io, sources) {
119
126
  io.stdout(`${path}: against the copy approved on ${previous.approval.approvedAt}:\n\n`);
120
127
  io.stdout(`${lines.map((line) => ` ${line}`).join('\n')}\n\n`);
121
128
  }
129
+ const recorded = Object.hasOwn(previous.approval, 'overrides') ? previous.approval.overrides ?? null : null;
130
+ if (recorded !== live.text) {
131
+ changes.push(OVERRIDE_CHANGED);
132
+ const overrideLines = formatDiff(recorded ?? '', live.text ?? '');
133
+ if (overrideLines.length) {
134
+ io.stdout(`overrides.yaml: against the copy approved on ${previous.approval.approvedAt}:\n\n`);
135
+ io.stdout(`${overrideLines.map((line) => ` ${line}`).join('\n')}\n\n`);
136
+ }
137
+ }
122
138
  io.stdout(changes.length ? `Needs a new approval: ${changes.join('; ')}.\n` : 'Nothing in it needs a new approval.\n');
123
139
  if (ceilingsLine(previous.approval.ceilings) !== ceilingsLine(ceilings)) {
124
140
  io.stdout(`Ceilings approved: ${ceilingsLine(previous.approval.ceilings)}.\n`);
@@ -139,7 +155,7 @@ export async function runApprove(argv, io, sources) {
139
155
  return 1;
140
156
  }
141
157
  const now = sources.now();
142
- writeApproval(store, { approval: approvalOf(team, root, now, resolved.checks), file: text }, team.seats);
158
+ writeApproval(store, { approval: { ...approvalOf(team, root, now, resolved.checks), overrides: live.text }, file: text }, team.seats);
143
159
  logLine(dirname(path), 'approve', describeCaller(caller), `approved ${seats} seats; ceilings: ${ceilingsLine(ceilings)}`, now);
144
160
  io.stdout(`Approved. The record is in ${store}; check the rest with \`team doctor\`.\n`);
145
161
  return 0;
@@ -1,3 +1,4 @@
1
+ import { type Caller } from '../caller.ts';
1
2
  import type { TeamFile } from '../file/types.ts';
2
3
  import type { Command, Io } from '../io.ts';
3
4
  import { type Profile } from '../profiles/profile.ts';
@@ -9,6 +10,7 @@ export type DoctorSources = {
9
10
  sessionRunning(session: string): boolean | null;
10
11
  now(): Date;
11
12
  home: string;
13
+ runCheck?(path: string): string | null;
12
14
  };
13
15
  export type CommandRunner = (binary: string, args: string[]) => {
14
16
  status: number | null;
@@ -30,11 +32,23 @@ export type Finding = {
30
32
  export declare function blocksLaunch(finding: Finding): boolean;
31
33
  export declare const doctor: Command;
32
34
  export default doctor;
35
+ /**
36
+ * The budget checks, one line per account, plus the accounts nothing can read. The checks run
37
+ * the way the watch runs them (RFC 0003 § 5): the approved command's file, hashed again before
38
+ * it runs, in a clean environment with the watch's timeout. The raw output is parsed and
39
+ * dropped — never printed, never logged, never written anywhere. Only the owner's `doctor` runs
40
+ * a check, for the same reason the watch is the owner's: a check reads a vendor home. Any other
41
+ * caller gets the same report without the readings, and the one line that says so.
42
+ */
43
+ export declare function budgetCheckFindings(team: TeamFile, root: string, sources: DoctorSources, caller: Caller): Finding[];
44
+ export declare function seatNameFindings(team: TeamFile, session: string): Finding[];
33
45
  export declare function doctorFindings(team: TeamFile, root: string, dir: string, session: string, sources: DoctorSources, warnings: {
34
46
  line: number;
35
47
  message: string;
36
48
  }[],
37
49
  /** The file as approved. `add` hands the team about to run, whose `stopped` mark is already cleared. */
38
- approved?: TeamFile): Finding[];
50
+ approved?: TeamFile,
51
+ /** The budget-check lines, which only the report runs: `up` and `add` scan, they never run a check. */
52
+ budgetChecks?: Finding[]): Finding[];
39
53
  export declare function doctorLoginFindings(team: TeamFile, sources: DoctorSources): Finding[];
40
54
  export declare function runDoctor(argv: string[], io: Io, sources: DoctorSources): Promise<number>;