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
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.1
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:
@@ -48,7 +49,10 @@ identity:
48
49
  position: trailer # last-line | trailer | anywhere
49
50
  exempt: [merge] # merge commits need no signature
50
51
 
51
- rules: # lines added to every seat's rules at launch
52
+ rules: # lines added to every seat's rules at launch. Rules delivered as a launch
53
+ # option (claude-code) close with "These are standing rules, not a task.";
54
+ # rules typed as a first message (codex, cursor, antigravity) close with
55
+ # "These are standing rules, not a task: reply ready and wait for your brief."
52
56
  - Run the tests your change touches, not the whole suite.
53
57
 
54
58
  workspace:
@@ -56,7 +60,7 @@ workspace:
56
60
 
57
61
  seats:
58
62
  - role: coordinator
59
- name: claude-coord
63
+ name: coordinator
60
64
  cli: claude-code # the launch profile
61
65
  vendor: anthropic # the model's maker
62
66
  model: Claude Opus # the model's name, without its version
@@ -64,9 +68,10 @@ seats:
64
68
  launch: claude --model claude-opus-5-5 # no approval flags: the profile adds them
65
69
 
66
70
  - role: implementer
67
- name: codex-hello
71
+ name: implementer
68
72
  cli: codex
69
73
  vendor: openai
74
+ account: openai-hello # the seat's account, when one vendor has two; absent, its vendor
70
75
  model: GPT Sol
71
76
  version: "6"
72
77
  display: GPT-6 Sol # the vendor's spelling, for the signature
@@ -74,23 +79,30 @@ seats:
74
79
  parked: true # running, and not reported while idle
75
80
 
76
81
  - role: implementer
77
- name: deepseek-hello
82
+ name: implementer-deepseek
78
83
  cli: claude-code # DeepSeek's model, run by Claude Code
79
84
  vendor: deepseek
80
85
  model: DeepSeek Flash
81
86
  version: "V4.1"
82
87
  display: DeepSeek V4.1 Flash
83
88
  launch: team-deepseek # a launcher on the PATH, holding the account's key and endpoint
84
- count: 2 # deepseek-hello and deepseek-hello-2
89
+ count: 2 # implementer-deepseek and implementer-deepseek-2
85
90
 
86
91
  - role: reviewer
87
- name: grok-hello
92
+ name: reviewer
88
93
  cli: grok
89
94
  vendor: xai
90
95
  model: Grok
91
96
  version: "4.7"
92
97
  launch: grok --model grok-4.7
93
98
  stopped: true # kept in the file; `up` doesn't start it
99
+
100
+ budgets: # the owner's: reserve or floor per account, marks, freshness
101
+ accounts:
102
+ openai-hello: # the account implementer spends
103
+ kind: subscription
104
+ reserve: 10% # refuse a launch on a figure inside it
105
+ sources: [status_line] # the figure comes off Codex's status line
94
106
  ```
95
107
 
96
108
  - `session` names the herdr session and defaults to `project`; `--session` overrides it.
@@ -104,6 +116,8 @@ seats:
104
116
  and session links are always refused.
105
117
  - `seats[*].cli` picks the launch profile; `claude-code`, `codex`, `cursor` and `antigravity` are available, and `team
106
118
  doctor` says what the others still need. `vendor`, `model` and `version` spell one seat's model.
119
+ `account` names the budget account the seat spends when one vendor has two; without it, the seat
120
+ spends its `vendor`, and changing either is an edit the owner re-approves.
107
121
  - `launch` is the plain command, without approval flags: the profile adds them. `count: 2` makes the
108
122
  numbered names; `parked` keeps a seat out of idle reports, `stopped` keeps it out of `up`.
109
123
  - `workspace.mode` is `shared` (every seat in the project) or `worktree` (each task in its own
@@ -112,6 +126,15 @@ seats:
112
126
  `trust` and outside every protected checkout — never in the project root; `up` and `add` refuse a
113
127
  seat whose folder, lobby included, would be protected or untrusted.
114
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
+
115
138
  The Codex profile is tested with CLI 0.157.0. Its status line is read for a weekly figure
116
139
  (`weekly N% left`) when the pane is wide enough to show the number; a cut line is not a figure.
117
140
  It adds `-a never -s danger-full-access`
@@ -134,7 +157,10 @@ under ~/.cursor/projects for that folder; team writes no trust (.workspace-trust
134
157
  config.
135
158
 
136
159
  `budgets` is the owner's: marks (percent used), how long a figure stays fresh, and each
137
- account's reserve or floor. A `check` command is resolved to a file and hashed when the
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`
162
+ when it doesn't, so one vendor's two accounts are two buckets; a pattern names the account it
163
+ measures, not the seat's. A `check` command is resolved to a file and hashed when the
138
164
  owner approves. A change to that file leaves that account's check unapproved: it is
139
165
  not run, and the account reads unknown, until the owner approves again. The rest of
140
166
  the file still runs. `watch.quota_marks` is still read, with a warning, until you move it to
@@ -197,7 +223,7 @@ the commands below read.
197
223
  | `team doctor` | checks this machine for what the file needs: herdr, each CLI, login, launcher, model, watch heartbeat; `--login` checks only CLI sign-ins | anyone; read only |
198
224
  | `team status` | prints the file's seats against the running session, each difference with its repair; `--json` outputs a stable JSON document (`format: 1`) for scripts; exit 1 when they differ | anyone; read only |
199
225
  | `team up` / `team down` | starts / stops the session and its seats | `up`: the owner; `down`: the owner, the coordinator or the operator seat |
200
- | `team watch` | watches the session, reports idle seats and nudges the operator; `--no-nudge` and `--no-notify` turn those off | anyone, one per session; it types only its fixed nudge, into an empty idle prompt |
226
+ | `team watch` | watches the session, reports idle seats and nudges the operator; `--no-nudge` and `--no-notify` are the owner's and do not silence a report addressed to the owner | anyone, one per session; it types only its fixed nudge, into an empty idle prompt |
201
227
  | `team add <name>` | starts one declared seat, or puts one back from the approved copy; `--temporary --like <seat> --until <end>` starts a seat the file does not hold | the owner, the coordinator or the operator |
202
228
  | `team remove <name>` | stops one seat, then takes it out of the file; `--keep` leaves it stopped; `--abandon` is the owner's, and types nothing | the owner, the coordinator or the operator; only the owner removes the coordinator or the operator |
203
229
  | `team worktree new <task>` / `team worktree remove <task>` | creates a task worktree from an up-to-date base, or removes its folder; a failed setup is kept and recorded; the branch is never deleted; ignored files in the worktree are deleted with it | the owner, the coordinator or the operator |
@@ -249,7 +275,7 @@ cd team
249
275
  bun install
250
276
  bun run build
251
277
  npm install -g . # puts `team` on the PATH
252
- team --version # 0.1.1
278
+ team --version # 0.2.0
253
279
  ```
254
280
 
255
281
  Bun builds and tests the sources:
package/dist/ansi.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export declare function sgrDim(sequence: string, faint: boolean): boolean;
2
+ /** The text of a styled pane read, without its escape sequences. */
3
+ export declare function stripSgr(text: string): string;
4
+ /** Whether the text carries any SGR styling at all — a colour, faint, bold, anything. */
5
+ export declare function hasSgr(text: string): boolean;
6
+ /**
7
+ * Whether every visible character after the first `skip` ones is faint, so the line holds a
8
+ * greyed suggestion rather than text. Whitespace is not read, mirroring the typed text the
9
+ * caller trims; an empty remainder is dim, as an empty box is. A character without any
10
+ * styling is plain text, so a plain source answers false and the placeholder list alone
11
+ * decides — the fallback for an herdr without `--format ansi`.
12
+ */
13
+ export declare function allDimAfter(styled: string, skip: number): boolean;
package/dist/ansi.js ADDED
@@ -0,0 +1,75 @@
1
+ // ANSI styling as herdr's `pane read --format ansi` reads it off a pane: the escape sequences
2
+ // a CLI wrapped around its text. Matching always runs on the plain form; the styling answers
3
+ // one question, the composer's — is the input line's text all dim, a greyed suggestion rather
4
+ // than something typed. Nothing here moves the cursor or writes to a pane.
5
+ //
6
+ // Observed on Claude Code 2.1.289 (test/fixtures/claude-code/2.1.289/README.md): a greyed
7
+ // suggestion is `ESC[0m ESC[2m` … `ESC[0m` — faint — while typed text carries no styling at
8
+ // all. Faint is the only placeholder style: a colour, however grey it renders, is a colour,
9
+ // and reading one as a placeholder was a guess this module no longer makes.
10
+ // A CSI sequence (an SGR ends in `m`), or an OSC title string. Visible output holds little
11
+ // else, and what it does hold is not text.
12
+ const ESCAPES = /\x1b\[[0-9;:]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
13
+ // The faint state one SGR sequence leaves the pen in. `0` and `22` lift it; `2` sets it. The
14
+ // extended colours — foreground, background (48), underline (58) — carry their payload in
15
+ // sub-parameters (5;n or 2;r;g;b) whose numbers are never the faint switch.
16
+ export function sgrDim(sequence, faint) {
17
+ const params = sequence.slice(2, -1).split(';').map((part) => (part === '' ? '0' : part));
18
+ let faintNow = faint;
19
+ for (let i = 0; i < params.length; i++) {
20
+ const n = Number(params[i]);
21
+ if (!Number.isInteger(n))
22
+ continue;
23
+ if (n === 0 || n === 22)
24
+ faintNow = false;
25
+ if (n === 2)
26
+ faintNow = true;
27
+ if (n === 38 || n === 48 || n === 58) {
28
+ const mode = Number(params[i + 1]);
29
+ if (mode === 5)
30
+ i += 2;
31
+ else if (mode === 2)
32
+ i += 4;
33
+ }
34
+ }
35
+ return faintNow;
36
+ }
37
+ /** The text of a styled pane read, without its escape sequences. */
38
+ export function stripSgr(text) {
39
+ return text.replace(ESCAPES, '');
40
+ }
41
+ /** Whether the text carries any SGR styling at all — a colour, faint, bold, anything. */
42
+ export function hasSgr(text) {
43
+ return /\x1b\[[0-9;:]*m/.test(text);
44
+ }
45
+ /**
46
+ * Whether every visible character after the first `skip` ones is faint, so the line holds a
47
+ * greyed suggestion rather than text. Whitespace is not read, mirroring the typed text the
48
+ * caller trims; an empty remainder is dim, as an empty box is. A character without any
49
+ * styling is plain text, so a plain source answers false and the placeholder list alone
50
+ * decides — the fallback for an herdr without `--format ansi`.
51
+ */
52
+ export function allDimAfter(styled, skip) {
53
+ let passed = 0;
54
+ let faint = false;
55
+ let cursor = 0;
56
+ // The characters before an escape, then the escape; then the rest after the last one.
57
+ const read = (chunk) => {
58
+ for (const ch of chunk) {
59
+ if (/\s/.test(ch))
60
+ continue;
61
+ passed++;
62
+ if (passed > skip && !faint)
63
+ return false;
64
+ }
65
+ return true;
66
+ };
67
+ for (const match of styled.matchAll(ESCAPES)) {
68
+ if (!read(styled.slice(cursor, match.index)))
69
+ return false;
70
+ if (match[0].endsWith('m'))
71
+ faint = sgrDim(match[0], faint);
72
+ cursor = match.index + match[0].length;
73
+ }
74
+ return read(styled.slice(cursor));
75
+ }
@@ -14,6 +14,13 @@ export declare function approvalOf(team: TeamFile, root: string, now?: Date, che
14
14
  * it is, and the section reads as a difference.
15
15
  */
16
16
  export declare function approvedFingerprints(record: ApprovalRecord): Fingerprints;
17
+ /**
18
+ * `remove --keep` and `add` write `stopped` and nothing else. The new digest is
19
+ * recorded only when putting `stopped` back to its approved value makes the
20
+ * seat match the approval. A launch line or a `parked` flag edited beside the
21
+ * mark stays drift. A stored copy that can't be read records nothing.
22
+ */
23
+ export declare function recordSeatDigest(team: TeamFile, root: string, name: string, home?: string): void;
17
24
  /**
18
25
  * What in the file the owner has not approved on this machine, one line per
19
26
  * difference. Empty when the file is the approved one; null when nothing was
@@ -25,6 +32,10 @@ export declare function approvalDifferences(team: TeamFile, root: string, home?:
25
32
  * only once the owner has approved it: a file never approved runs with the defaults, and a file
26
33
  * whose `watch` section differs from the approved one runs with the values of the approved copy.
27
34
  * An edit to a threshold changes nothing until `approve`.
35
+ *
36
+ * `watch.checks` is the finer line inside that section. Whenever its digest differs, the checks
37
+ * in force are the approved copy's — or none, when that copy can't be read — even when the
38
+ * timings themselves are unchanged and the rest of the section is the file's.
28
39
  */
29
40
  export declare function watchInForce(team: TeamFile, root: string, home?: string): TeamFile['watch'];
30
41
  /**
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { defaultBudgets, defaultWatch, validateTeamFile } from "../file/validate.js";
3
- import { readApproval, storePath } from "../store/store.js";
4
- import { compare, describe, fingerprints, OWNER_SECTIONS } from "./fingerprint.js";
3
+ import { readApproval, storePath, writeApproval } from "../store/store.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 } };
@@ -26,12 +26,74 @@ export function approvalOf(team, root, now = new Date(), checks = {}) {
26
26
  */
27
27
  export function approvedFingerprints(record) {
28
28
  const stored = record.approval.fingerprints;
29
- if (OWNER_SECTIONS.every((name) => stored.sections[name] !== undefined))
30
- return stored;
31
29
  const checked = validateTeamFile(record.file);
32
- if (!checked.ok)
30
+ let sections = stored.sections;
31
+ if (!OWNER_SECTIONS.every((name) => stored.sections[name] !== undefined) && checked.ok) {
32
+ sections = { ...fingerprints(checked.team).sections, ...stored.sections };
33
+ }
34
+ const seats = checked.ok ? adoptLegacyDigests(stored.seats, checked.team) : stored.seats;
35
+ if (sections === stored.sections && seats === stored.seats)
33
36
  return stored;
34
- return { sections: { ...fingerprints(checked.team).sections, ...stored.sections }, seats: stored.seats };
37
+ return { sections, seats };
38
+ }
39
+ /**
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.
45
+ */
46
+ function adoptLegacyDigests(stored, team) {
47
+ const current = fingerprints(team).seats;
48
+ const legacyFlags = legacySeatDigests(team);
49
+ const legacyLabels = legacyLabelDigests(team);
50
+ let changed = false;
51
+ const next = { ...stored };
52
+ for (const [name, previous] of Object.entries(stored)) {
53
+ const adopted = current[name];
54
+ const old = previous === legacyFlags[name] ||
55
+ previous === legacyLabels.named[name] ||
56
+ previous === legacyLabels.namedWithoutFlags[name];
57
+ if (adopted !== undefined && old && previous !== adopted) {
58
+ next[name] = adopted;
59
+ changed = true;
60
+ }
61
+ }
62
+ return changed ? next : stored;
63
+ }
64
+ /**
65
+ * `remove --keep` and `add` write `stopped` and nothing else. The new digest is
66
+ * recorded only when putting `stopped` back to its approved value makes the
67
+ * seat match the approval. A launch line or a `parked` flag edited beside the
68
+ * mark stays drift. A stored copy that can't be read records nothing.
69
+ */
70
+ export function recordSeatDigest(team, root, name, home = homedir()) {
71
+ const store = storePath(team.project, root, home);
72
+ const record = readApproval(store);
73
+ if (record === null)
74
+ return;
75
+ if (!validateTeamFile(record.file).ok)
76
+ return;
77
+ const approved = approvedFingerprints(record).seats[name];
78
+ const digest = fingerprints(team).seats[name];
79
+ if (approved === undefined || digest === undefined || record.approval.fingerprints.seats[name] === digest)
80
+ return;
81
+ const withStopped = (stopped) => fingerprints({
82
+ ...team,
83
+ seats: team.seats.map((seat) => (seat.name === name ? { ...seat, stopped } : seat)),
84
+ }).seats[name];
85
+ if (withStopped(true) !== approved && withStopped(false) !== approved)
86
+ return;
87
+ writeApproval(store, {
88
+ approval: {
89
+ ...record.approval,
90
+ fingerprints: {
91
+ ...record.approval.fingerprints,
92
+ seats: { ...record.approval.fingerprints.seats, [name]: digest },
93
+ },
94
+ },
95
+ file: record.file,
96
+ }, []);
35
97
  }
36
98
  /**
37
99
  * What in the file the owner has not approved on this machine, one line per
@@ -49,15 +111,25 @@ export function approvalDifferences(team, root, home = homedir()) {
49
111
  * only once the owner has approved it: a file never approved runs with the defaults, and a file
50
112
  * whose `watch` section differs from the approved one runs with the values of the approved copy.
51
113
  * An edit to a threshold changes nothing until `approve`.
114
+ *
115
+ * `watch.checks` is the finer line inside that section. Whenever its digest differs, the checks
116
+ * in force are the approved copy's — or none, when that copy can't be read — even when the
117
+ * timings themselves are unchanged and the rest of the section is the file's.
52
118
  */
53
119
  export function watchInForce(team, root, home = homedir()) {
54
120
  const record = readApproval(storePath(team.project, root, home));
55
121
  if (record === null)
56
122
  return defaultWatch();
57
- if (approvedFingerprints(record).sections['watch'] === fingerprints(team).sections['watch'])
123
+ const differences = compare(approvedFingerprints(record), fingerprints(team));
124
+ const timingsDiffer = differences.some((difference) => difference.kind === 'section' && difference.name === 'watch');
125
+ const checksDiffer = differences.some((difference) => difference.kind === 'section' && difference.name === 'watch.checks');
126
+ if (!timingsDiffer && !checksDiffer)
58
127
  return team.watch;
59
128
  const copy = validateTeamFile(record.file);
60
- return copy.ok ? copy.team.watch : defaultWatch();
129
+ const approved = copy.ok ? copy.team.watch : defaultWatch();
130
+ if (!timingsDiffer)
131
+ return { ...team.watch, checks: approved.checks };
132
+ return approved;
61
133
  }
62
134
  /**
63
135
  * The budget values in force. The `budgets` section is the owner's like the watch's, so what a
@@ -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> & {
@@ -18,6 +20,21 @@ export interface Fingerprints {
18
20
  export declare function canonical(value: unknown): string;
19
21
  /** A fingerprint of each owner-only section and of each seat. */
20
22
  export declare function fingerprints(team: Approvable): Fingerprints;
23
+ /**
24
+ * Seat digests as a record written before `parked` and `stopped` were part of
25
+ * them. An approval from then still matches a file that has not changed.
26
+ */
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
+ };
21
38
  export type Difference = {
22
39
  kind: 'section';
23
40
  name: string;
@@ -31,14 +48,20 @@ export type Difference = {
31
48
  /**
32
49
  * What in the file the owner has not approved. The file passes when every
33
50
  * section matches and every seat in it matches an approved seat: a seat taken
34
- * out, parked or stopped needs no new approval.
51
+ * out needs no new approval. Parking or stopping one does.
35
52
  */
36
53
  export declare function compare(approved: Fingerprints, current: Fingerprints): Difference[];
37
54
  /**
38
- * The line `describe` prints when `watch.checks` itself is the difference. `pass` reads it to
39
- * keep the checks running until the owner approves an edit that would turn one off: nothing is
40
- * turned off until the owner approves (RFC 0002 § 4.2).
55
+ * The line `describe` prints when `watch.checks` itself is the difference. `pass` does not read
56
+ * it. The list in force is what turns checks off: while the edit is unapproved, the approved
57
+ * list stays in force, so nothing new is turned off and an approved-off check stays off.
41
58
  */
42
59
  export declare const WATCH_CHECKS_CHANGED = "`watch.checks` changed";
43
60
  /** One line per difference, as `status`, `doctor` and the refusals print it. */
44
61
  export declare function describe(difference: Difference): string;
62
+ /**
63
+ * The seat a difference line names, when it names one — the inverse of `describe`, kept beside it
64
+ * so the two can't drift. `pass` reads it to keep a seat the owner has not approved out of the
65
+ * readings fold; `null` for a section difference.
66
+ */
67
+ export declare function seatNamed(line: string): string | null;
@@ -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')
@@ -38,11 +22,15 @@ function sectionOf(team, name) {
38
22
  return team[name];
39
23
  }
40
24
  /**
41
- * Seat fields that change without a new approval: what `remove --keep` and
42
- * `add` set, where the seat sits in the file, and how its entry is written
43
- * (`count: 3` becoming `count: 2`, or explicit seats, when one is taken out).
25
+ * Seat fields that change without a new approval: where the seat sits in the
26
+ * file, and how its entry is written (`count: 3` becoming `count: 2`, or
27
+ * explicit seats, when one is taken out). `parked` and `stopped` stay in the
28
+ * digest: a seat that can edit the file must not silence itself. `remove --keep`
29
+ * and `add` record the new digest with the edit.
44
30
  */
45
- const SEAT_FREE_FIELDS = new Set(['parked', 'stopped', 'line', 'declared', 'count', 'instance']);
31
+ const SEAT_FREE_FIELDS = new Set(['line', 'declared', 'count', 'instance']);
32
+ /** The free set from before `parked` and `stopped` joined the digest. */
33
+ const LEGACY_SEAT_FREE_FIELDS = new Set(['parked', 'stopped', 'line', 'declared', 'count', 'instance']);
46
34
  /** JSON with every object's keys in order, so equal values give equal text. */
47
35
  export function canonical(value) {
48
36
  if (Array.isArray(value))
@@ -64,16 +52,44 @@ export function fingerprints(team) {
64
52
  for (const section of OWNER_SECTIONS)
65
53
  sections[section] = digest(sectionOf(team, section));
66
54
  const seats = {};
55
+ for (const seat of team.seats)
56
+ seats[seat.name] = seatDigest(seat, SEAT_FREE_FIELDS);
57
+ return { sections, seats };
58
+ }
59
+ /**
60
+ * Seat digests as a record written before `parked` and `stopped` were part of
61
+ * them. An approval from then still matches a file that has not changed.
62
+ */
63
+ export function legacySeatDigests(team) {
64
+ const seats = {};
65
+ for (const seat of team.seats)
66
+ seats[seat.name] = seatDigest(seat, LEGACY_SEAT_FREE_FIELDS);
67
+ return seats;
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 = {};
67
78
  for (const seat of team.seats) {
68
- const fields = Object.fromEntries(Object.entries(seat).filter(([key]) => !SEAT_FREE_FIELDS.has(key)));
69
- seats[seat.name] = digest(fields);
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);
70
82
  }
71
- return { sections, seats };
83
+ return { named, namedWithoutFlags };
84
+ }
85
+ function seatDigest(seat, free) {
86
+ const fields = Object.fromEntries(Object.entries(seat).filter(([key]) => !free.has(key)));
87
+ return digest(fields);
72
88
  }
73
89
  /**
74
90
  * What in the file the owner has not approved. The file passes when every
75
91
  * section matches and every seat in it matches an approved seat: a seat taken
76
- * out, parked or stopped needs no new approval.
92
+ * out needs no new approval. Parking or stopping one does.
77
93
  */
78
94
  export function compare(approved, current) {
79
95
  const differences = [];
@@ -94,9 +110,9 @@ export function compare(approved, current) {
94
110
  return differences;
95
111
  }
96
112
  /**
97
- * The line `describe` prints when `watch.checks` itself is the difference. `pass` reads it to
98
- * keep the checks running until the owner approves an edit that would turn one off: nothing is
99
- * turned off until the owner approves (RFC 0002 § 4.2).
113
+ * The line `describe` prints when `watch.checks` itself is the difference. `pass` does not read
114
+ * it. The list in force is what turns checks off: while the edit is unapproved, the approved
115
+ * list stays in force, so nothing new is turned off and an approved-off check stays off.
100
116
  */
101
117
  export const WATCH_CHECKS_CHANGED = '`watch.checks` changed';
102
118
  /** One line per difference, as `status`, `doctor` and the refusals print it. */
@@ -107,3 +123,12 @@ export function describe(difference) {
107
123
  return `seat ${difference.name} is not in the approved file`;
108
124
  return `seat ${difference.name} changed`;
109
125
  }
126
+ /**
127
+ * The seat a difference line names, when it names one — the inverse of `describe`, kept beside it
128
+ * so the two can't drift. `pass` reads it to keep a seat the owner has not approved out of the
129
+ * readings fold; `null` for a section difference.
130
+ */
131
+ export function seatNamed(line) {
132
+ const match = /^seat (.+) (?:changed|is not in the approved file)$/.exec(line);
133
+ return match?.[1] ?? null;
134
+ }
@@ -11,10 +11,11 @@ export type LaunchDecision = {
11
11
  why: string;
12
12
  };
13
13
  /**
14
- * The seat's account is its vendor. There is no separate account field on a seat. `budgets` is
15
- * the section in force: an unapproved edit to a reserve refuses no one until it is approved,
16
- * and an account only the unapproved edit names is not an account at all. `spend` holds the
17
- * stored spend check readings; a subscription account never looks at them.
14
+ * The seat's account is its own `account:` when the file names one — one vendor with two accounts
15
+ * is two buckets (§ 3b) — and its vendor otherwise. `budgets` is the section in force: an
16
+ * unapproved edit to a reserve refuses no one until it is approved, and an account only the
17
+ * unapproved edit names is not an account at all. `spend` holds the stored spend check readings;
18
+ * a subscription account never looks at them.
18
19
  */
19
20
  export declare function seatBudget(budgets: TeamFile['budgets'], readings: readonly Seen[], seat: Seat, now: number, spend?: readonly SpendReading[]): LaunchDecision;
20
21
  /**