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
@@ -1,6 +1,6 @@
1
- # Claude Code's screen. The questions are written out in both cases: the old
2
- # classifier ignored case on those two lines and not on the "1. Yes" choice, and
3
- # ignore_case would apply to the whole rule.
1
+ # Claude Code's screen. The trust question carries ignore_case on its own pattern:
2
+ # the old classifier ignored case on those lines and not on the "1. Yes" choice,
3
+ # and a rule-wide or stage-wide flag would have reached the choice too.
4
4
  format: 1
5
5
  cli: claude-code
6
6
  binary: claude
@@ -39,8 +39,12 @@ status_model:
39
39
  screen:
40
40
  trust:
41
41
  - all:
42
- - '(?:[Dd][Oo] [Yy][Oo][Uu] [Tt][Rr][Uu][Ss][Tt] (?:[Tt][Hh][Ii][Ss]|[Tt][Hh][Ee]) [Ff][Oo][Ll][Dd][Ee][Rr]\?|[Oo][Nn][Ee] [Yy][Oo][Uu] [Tt][Rr][Uu][Ss][Tt]\?)'
42
+ - match: '(?:Do you trust (?:this|the) folder\?|One you trust\?)'
43
+ ignore_case: true
43
44
  - '^\s*[❯›>]?\s*1\.\s+Yes\b'
45
+ - all:
46
+ - '^\s*[❯›>]?\s*Yes, I trust this folder\b'
47
+ footer: 'Enter to confirm · Esc to cancel'
44
48
  # A quoted question above the composer's rule is not a dialog. The footer is the
45
49
  # captured last line and needs the question. A Yes/No choice below the last rule
46
50
  # is the other shape; the mark is optional, as on an unmarked "2. No".
@@ -50,20 +54,61 @@ screen:
50
54
  footer: 'Esc to cancel · Tab to amend'
51
55
  below_last_rule: 'Esc to cancel · Tab to amend'
52
56
  - below_last_rule: '^\s*❯?\s*[0-9]\. (Yes|No)\b'
57
+ # A pane with no composer rule whose choice is the dialog, including the
58
+ # unmarked "1. Yes" while up is running. A choice quoted above an empty
59
+ # prompt is a transcript: the empty prompt comes after it.
60
+ - all:
61
+ - '^\s*❯?\s*[0-9]\. (Yes|No)\b'
62
+ without_rule: true
63
+ none_after:
64
+ anchor: '^\s*❯?\s*[0-9]\. (Yes|No)\b'
65
+ patterns:
66
+ - '^\s*[❯›>]\s*$'
53
67
  question:
68
+ # The phrase is the dialog's last line, as a permission footer is. Prose
69
+ # above the box is not a question.
70
+ - any:
71
+ - 'Enter to select'
72
+ - '↑/↓ to navigate'
73
+ - 'Enter to confirm'
74
+ - 'Esc to cancel'
75
+ on_footer: true
76
+ - below_last_rule: 'Enter to select|↑/↓ to navigate|Enter to confirm|Esc to cancel'
77
+ # The footer has a status line under it and the rule is out of the window.
78
+ # A phrase quoted above an empty prompt is a transcript.
54
79
  - any:
55
80
  - 'Enter to select'
56
81
  - '↑/↓ to navigate'
57
82
  - 'Enter to confirm'
58
83
  - 'Esc to cancel'
84
+ without_rule: true
85
+ none_after:
86
+ anchor: 'Enter to select|↑/↓ to navigate|Enter to confirm|Esc to cancel'
87
+ patterns:
88
+ - '^\s*[❯›>]\s*$'
59
89
  working:
60
90
  - any:
61
91
  - '\besc to interrupt\b'
62
92
  - '^[^\S\n]*[✢✳✶✻✼✽][^\n]*…\s*\(\s*\d'
63
93
  composer:
64
94
  mode: box-to-rule
65
- prompt: '^\s*[❯›>]'
66
- rule: '^\s*[─━]{8,}'
95
+ # The prompt sits at the pane's first column, as unsent-typed-ansi.txt draws `❯ Fix the`;
96
+ # the pane draws continuation rows at the content column — two spaces — so a prompt glyph
97
+ # on one of those is content, never the input row. The reader takes the box's first row
98
+ # under its opening rule and anchors this pattern to the captured column: a row the box
99
+ # holds can never be mistaken for the input.
100
+ prompt: '^[❯›>]'
101
+ # The box's two rules, as unsent-typed-ansi.txt draws them: one unbroken run of ─ from the
102
+ # pane's first column, the closing rule the window's last rule row. A rule-looking row the
103
+ # box holds is drawn at the content column and is content, never the frame.
104
+ rule: '^[─━]{8,}$'
105
+ footers:
106
+ - '^\s{2,}.*·.*(?:Opus|Sonnet|Haiku|Fable)\s+[0-9]+.*$'
107
+ - '^\s{2,}(?:⏵⏵\s+)?bypass permissions\b.*$'
108
+ # Claude Code greys every suggestion it shows — "Try …", "Resume briefly.", any other —
109
+ # with faint styling, while typed text carries none. The list below stays the fallback
110
+ # for a source without styling.
111
+ placeholder_style: dim
67
112
  placeholders:
68
113
  - equals: ''
69
114
  - prefix: 'Try "'
@@ -44,6 +44,9 @@ status_model:
44
44
  model: 'GPT {2:title}'
45
45
  version: '{1}'
46
46
  # A later footer that is not one of those four names is unread, as the old id map was.
47
+ # The `{1}` names a group this pattern does not have, on purpose: a template that cannot
48
+ # fill reads the line as naming no model. There is no plainer spelling — an empty string
49
+ # is refused and a literal would read as a found model, not as unread.
47
50
  - match: '^\s+GPT-[0-9][\w.-]*\s[^·]*·'
48
51
  model: '{1}'
49
52
  version: '{1}'
@@ -71,7 +74,17 @@ screen:
71
74
  composer:
72
75
  mode: status-last
73
76
  status_line: '^\s+GPT-[0-9][\w.-]*\s+[^·]*·'
74
- prompt: '^\s*›(?:\s|$)'
77
+ # The input row carries the prompt at the pane's first column — unsent.txt draws `› Rules.`
78
+ # there, content on the rows below at the second, and exit-typed.txt keeps the old
79
+ # transcript and menu rows above it — and a prompt glyph on a content row is never the
80
+ # input row. The read scans up from the status line and anchors this pattern to the
81
+ # captured column.
82
+ prompt: '^›(?:\s|$)'
75
83
  placeholders:
76
84
  - equals: ''
77
85
  - equals: Ask Codex to do anything
86
+ # unsent.txt and exit-typed.txt show one empty row under the text, between it and the
87
+ # status line: the drop the pane draws of its own, inside the box's frame. The box read
88
+ # strips up to one trailing empty row as that frame; a row beyond it is one the typed
89
+ # text does not have.
90
+ frame_rows: 1
@@ -48,18 +48,61 @@ screen:
48
48
  - '^\s*→'
49
49
  - '^\s+Grok\s+[0-9]'
50
50
  working:
51
+ # The running turn is the braille spinner (whatever verb it paints), the
52
+ # prompt line ending in "ctrl+c to stop", or the follow-up queue: the box's
53
+ # `enter …` hint row with, below it, the composer's own row — empty, or its
54
+ # placeholder. The order is the frame's: what follows the hint row may be
55
+ # chrome or that unpainted composer row and nothing else, so a typed row
56
+ # under the box falls to the composer read and says unsent. A transcript
57
+ # that quotes the box paints its prose lines there, so the queue rule reads
58
+ # them as content and leaves the screen alone. Idle and rules-accepted
59
+ # captures paint none of these.
51
60
  - any:
52
- - 'ctrl\+c to stop'
53
- - '^\s*[⠀-⣿]+\s+(Working|Thinking)\b'
61
+ - '^\s*→.*\s{2,}ctrl\+c to stop\s*$'
62
+ - '^\s*[⠀-⣿]+\s+\S'
63
+ - all:
64
+ - '^\s*│\s*enter (?:steer|interrupt and send) · ↑ select/edit · esc cancel\s*│\s*$'
65
+ - '^\s*→\s*(?:Add a follow-up|Plan, search, build anything)?\s*$'
66
+ none_after:
67
+ anchor: '^\s*│\s*enter (?:steer|interrupt and send) · ↑ select/edit · esc cancel\s*│\s*$'
68
+ patterns:
69
+ - match: '^\s*\S'
70
+ except:
71
+ - '^\s*[│┌└─┐┘├┤]'
72
+ - '^\s*[⠀-⣿]'
73
+ - '^\s*Tip:'
74
+ - '^\s*→\s*(?:Add a follow-up|Plan, search, build anything)?\s*$'
75
+ - '^\s+Grok\s'
76
+ - '^\s*<workspace>'
77
+ - '^\s*~/'
78
+ - '^\s*❯'
54
79
  composer:
55
80
  mode: status-then-one
56
81
  status_line: '^\s+Grok\s+[0-9]'
57
- prompt: '^\s*→'
82
+ # The input row carries the prompt at the pane's second column — unsent.txt and
83
+ # follow-up-queue-typed.txt draw ` → Plan, search, build anything` there, continuations at
84
+ # the fourth — and a prompt glyph on a content or popup row is never the input row. The
85
+ # read scans up from the status line and anchors this pattern to the captured column.
86
+ prompt: '^ {2}→'
58
87
  strip_suffix: '\s*ctrl\+c to stop\s*$'
59
88
  placeholders:
60
89
  - equals: ''
61
90
  - equals: Plan, search, build anything
62
91
  - equals: Add a follow-up
92
+ # A line wider than the pane's content column continues on the next row at the text
93
+ # column, broken at a space: unsent.txt is its sentence word-wrapped this way on a
94
+ # 51-column pane, and the box is compared with the typed text before any Enter. Only
95
+ # the whitespace the row break itself stands for is normalised — the run the break was
96
+ # made at, or a blank line the text itself has; inside a row, every character must
97
+ # match, runs of spaces included.
98
+ wrap:
99
+ continuation: text-column
100
+ kind: word
101
+ # unsent.txt and follow-up-queue-typed.txt show two empty rows under the text, between
102
+ # it and the status line: the drop the pane draws of its own, inside the box's frame.
103
+ # The box read strips up to two trailing empty rows as that frame; a row beyond them is
104
+ # one the typed text does not have.
105
+ frame_rows: 2
63
106
  fallback:
64
107
  - all:
65
108
  - match: '^\s*→\s+\S'
@@ -0,0 +1,67 @@
1
+ import type { QuotaPattern } from './quota.ts';
2
+ import type { ScreenData, Stage } from '../watch/screen-data.ts';
3
+ import { type Screen } from '../watch/screen.ts';
4
+ export declare const DIALOG_STAGES: readonly ["unknown", "trust", "permission", "question"];
5
+ export type DialogStage = (typeof DIALOG_STAGES)[number];
6
+ export type ProfileOverride = {
7
+ cli: string;
8
+ screen: Partial<Record<DialogStage, Stage>>;
9
+ quota: QuotaPattern[];
10
+ };
11
+ export type OverrideProblem = {
12
+ line: number;
13
+ message: string;
14
+ };
15
+ export type OverrideParse = {
16
+ ok: true;
17
+ profiles: ProfileOverride[];
18
+ } | {
19
+ ok: false;
20
+ errors: OverrideProblem[];
21
+ };
22
+ /** `<store>/overrides.yaml`, the same folder as the approval for this root. */
23
+ export declare function overridesPath(project: string, root: string, home: string): string;
24
+ /** The file's added patterns, or the line that refuses it. One error, the first. */
25
+ export declare function parseOverrides(text: string): OverrideParse;
26
+ /**
27
+ * Shipped rules stay first, and the added ones follow. The composer and the
28
+ * chrome are the shipped profile's own objects, so an override cannot retune
29
+ * them by merging.
30
+ */
31
+ export declare function mergeScreen(base: ScreenData, added: ProfileOverride): ScreenData;
32
+ /**
33
+ * An added pattern may move a reading toward a dialog. It may not make a screen
34
+ * idle or unsent when the shipped profile does not, and it may not stop a
35
+ * shipped permission, trust or question pattern from matching.
36
+ */
37
+ export declare function guardReading(shipped: Screen, merged: Screen): Screen;
38
+ /** The shipped reading, then the same window with this profile's added patterns, guarded. */
39
+ export declare function classifyWith(cli: string, screen: string | undefined, profiles: readonly ProfileOverride[]): Screen;
40
+ /** Shipped quota patterns, then the override's. The shipped ones are never dropped. */
41
+ export declare function quotaWith(cli: string, profiles: readonly ProfileOverride[]): readonly QuotaPattern[];
42
+ /** What `approve` records when the override file itself is the change. */
43
+ export declare const OVERRIDE_CHANGED = "`overrides` changed";
44
+ /** A stored copy that does not parse. The shipped profiles stay in force. */
45
+ export declare const OVERRIDE_UNREADABLE = "the approved overrides can't be read";
46
+ export type OverrideForce = {
47
+ /** The approved patterns. Empty when none were approved, or the copy cannot be read. */
48
+ profiles: ProfileOverride[];
49
+ /** Drift from the approved copy. Empty when the file is the approved one, or there is none. */
50
+ differences: string[];
51
+ /** The live file, when it cannot be read or parsed. Already carrying the path and the line. */
52
+ problems: string[];
53
+ };
54
+ /** The override file's text, and why it was refused. A missing file is an empty text and no problem. */
55
+ export declare function overrideFile(project: string, root: string, home: string): {
56
+ path: string;
57
+ text: string | null;
58
+ problems: string[];
59
+ };
60
+ /**
61
+ * The patterns in force for this project. Keyed on the project root, the same
62
+ * store as the team-file approval: another checkout of the same name has its
63
+ * own. No file, or a stored copy that cannot be read, leaves the shipped profiles.
64
+ * The live file is used only when it is the text `approve` recorded.
65
+ */
66
+ export declare function overridesInForce(project: string, root: string, home: string): OverrideForce;
67
+ export declare function overrideProblem(path: string, problem: OverrideProblem): string;
@@ -0,0 +1,190 @@
1
+ // The owner's override file. It sits beside the approval and may only add dialog
2
+ // patterns and quota patterns to a profile this version ships. It cannot take a
3
+ // shipped pattern out, and it cannot change a composer, a prompt, a footer, a
4
+ // launch line or the order of the stages: anything else in the file is refused
5
+ // with its line. An edit takes effect only once `approve` records the text: until
6
+ // then the approved copy stays in force, and a copy that cannot be read leaves
7
+ // the shipped profiles.
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { profileFor, quotaFor, quotaList } from "./profile.js";
11
+ import { readApproval, storePath } from "../store/store.js";
12
+ import { addedRules } from "../watch/screen-file.js";
13
+ import { classifyData, screenData } from "../watch/screen.js";
14
+ import { YamlError, parseYaml } from "../yaml.js";
15
+ export const DIALOG_STAGES = ['unknown', 'trust', 'permission', 'question'];
16
+ /** `<store>/overrides.yaml`, the same folder as the approval for this root. */
17
+ export function overridesPath(project, root, home) {
18
+ return join(storePath(project, root, home), 'overrides.yaml');
19
+ }
20
+ /** The file's added patterns, or the line that refuses it. One error, the first. */
21
+ export function parseOverrides(text) {
22
+ try {
23
+ return { ok: true, profiles: profilesOf(parseYaml(text)) };
24
+ }
25
+ catch (error) {
26
+ if (error instanceof YamlError)
27
+ return { ok: false, errors: [{ line: error.line, message: error.message }] };
28
+ throw error;
29
+ }
30
+ }
31
+ /**
32
+ * Shipped rules stay first, and the added ones follow. The composer and the
33
+ * chrome are the shipped profile's own objects, so an override cannot retune
34
+ * them by merging.
35
+ */
36
+ export function mergeScreen(base, added) {
37
+ const next = { ...base };
38
+ for (const name of DIALOG_STAGES) {
39
+ const extra = added.screen[name];
40
+ if (!extra)
41
+ continue;
42
+ const existing = base[name];
43
+ next[name] = { rules: [...(existing?.rules ?? []), ...extra.rules] };
44
+ }
45
+ return next;
46
+ }
47
+ const KEPT = new Set(['permission', 'trust', 'question']);
48
+ const DIALOG = new Set(['unknown', 'trust', 'permission', 'question']);
49
+ /**
50
+ * An added pattern may move a reading toward a dialog. It may not make a screen
51
+ * idle or unsent when the shipped profile does not, and it may not stop a
52
+ * shipped permission, trust or question pattern from matching.
53
+ */
54
+ export function guardReading(shipped, merged) {
55
+ if (KEPT.has(shipped.kind))
56
+ return shipped;
57
+ if ((merged.kind === 'idle' || merged.kind === 'unsent') && merged.kind !== shipped.kind)
58
+ return shipped;
59
+ if (!DIALOG.has(merged.kind))
60
+ return shipped;
61
+ return merged;
62
+ }
63
+ /** The shipped reading, then the same window with this profile's added patterns, guarded. */
64
+ export function classifyWith(cli, screen, profiles) {
65
+ if (screen === undefined)
66
+ return { kind: 'unknown' };
67
+ const base = screenData(cli);
68
+ if (!base)
69
+ return { kind: 'unknown' };
70
+ const lines = screen.split('\n');
71
+ const shipped = classifyData(base, lines);
72
+ const added = profiles.find((profile) => profile.cli === cli);
73
+ if (!added || DIALOG_STAGES.every((stage) => added.screen[stage] === undefined))
74
+ return shipped;
75
+ return guardReading(shipped, classifyData(mergeScreen(base, added), lines));
76
+ }
77
+ /** Shipped quota patterns, then the override's. The shipped ones are never dropped. */
78
+ export function quotaWith(cli, profiles) {
79
+ const added = profiles.find((profile) => profile.cli === cli)?.quota ?? [];
80
+ return [...quotaFor(cli), ...added];
81
+ }
82
+ /** What `approve` records when the override file itself is the change. */
83
+ export const OVERRIDE_CHANGED = '`overrides` changed';
84
+ /** A stored copy that does not parse. The shipped profiles stay in force. */
85
+ export const OVERRIDE_UNREADABLE = "the approved overrides can't be read";
86
+ /** The override file's text, and why it was refused. A missing file is an empty text and no problem. */
87
+ export function overrideFile(project, root, home) {
88
+ const path = overridesPath(project, root, home);
89
+ try {
90
+ const text = readFileSync(path, 'utf8');
91
+ const parsed = parseOverrides(text);
92
+ return { path, text, problems: parsed.ok ? [] : parsed.errors.map((problem) => overrideProblem(path, problem)) };
93
+ }
94
+ catch (error) {
95
+ if (error.code === 'ENOENT')
96
+ return { path, text: null, problems: [] };
97
+ return { path, text: null, problems: [overrideProblem(path, { line: 0, message: "can't be read" })] };
98
+ }
99
+ }
100
+ /**
101
+ * The patterns in force for this project. Keyed on the project root, the same
102
+ * store as the team-file approval: another checkout of the same name has its
103
+ * own. No file, or a stored copy that cannot be read, leaves the shipped profiles.
104
+ * The live file is used only when it is the text `approve` recorded.
105
+ */
106
+ export function overridesInForce(project, root, home) {
107
+ const record = readApproval(storePath(project, root, home));
108
+ const recorded = record !== null && Object.hasOwn(record.approval, 'overrides');
109
+ const stored = recorded ? record?.approval.overrides : undefined;
110
+ const approvedText = typeof stored === 'string' ? stored : null;
111
+ const live = overrideFile(project, root, home);
112
+ const differences = [];
113
+ const profiles = [];
114
+ if (approvedText !== null) {
115
+ const parsed = parseOverrides(approvedText);
116
+ if (parsed.ok)
117
+ profiles.push(...parsed.profiles);
118
+ else
119
+ differences.push(OVERRIDE_UNREADABLE);
120
+ }
121
+ else if (recorded && stored !== null && stored !== undefined) {
122
+ differences.push(OVERRIDE_UNREADABLE);
123
+ }
124
+ if (live.text !== approvedText) {
125
+ if (record === null) {
126
+ if (live.text !== null)
127
+ differences.push('the overrides were never approved');
128
+ }
129
+ else
130
+ differences.push(OVERRIDE_CHANGED);
131
+ }
132
+ return { profiles, differences, problems: live.problems };
133
+ }
134
+ export function overrideProblem(path, problem) {
135
+ return problem.line ? `${path}: line ${problem.line}: ${problem.message}` : `${path}: ${problem.message}`;
136
+ }
137
+ function profilesOf(root) {
138
+ const entries = mapping(root, 'an override file');
139
+ const format = required(entries, 'format', root.line);
140
+ if (format.value.kind !== 'scalar' || format.value.value !== 1)
141
+ fail(format.line, '"format" must be 1');
142
+ only(entries, ['format', 'profiles']);
143
+ const profiles = required(entries, 'profiles', root.line);
144
+ const named = mapping(profiles.value, '"profiles"');
145
+ if (named.length === 0)
146
+ fail(profiles.line, '"profiles" must name a profile');
147
+ return named.map(profileOf);
148
+ }
149
+ function profileOf(entry) {
150
+ if (!profileFor(entry.key))
151
+ fail(entry.line, `unknown profile "${entry.key}"`);
152
+ const entries = mapping(entry.value, 'a profile override');
153
+ only(entries, ['screen', 'quota']);
154
+ const screen = optional(entries, 'screen');
155
+ const quota = optional(entries, 'quota');
156
+ if (!screen && !quota)
157
+ fail(entry.line, 'a profile override adds dialog patterns or quota patterns');
158
+ const stages = screen ? screenOf(screen.value) : {};
159
+ if (screen && Object.keys(stages).length === 0 && !quota) {
160
+ fail(screen.line, 'a profile override adds dialog patterns or quota patterns');
161
+ }
162
+ return { cli: entry.key, screen: stages, quota: quota ? quotaList(quota.value) : [] };
163
+ }
164
+ function screenOf(node) {
165
+ const entries = mapping(node, 'screen');
166
+ only(entries, DIALOG_STAGES);
167
+ const screen = {};
168
+ for (const entry of entries)
169
+ screen[entry.key] = { rules: addedRules(entry.value) };
170
+ return screen;
171
+ }
172
+ function mapping(node, what) {
173
+ if (node.kind !== 'map')
174
+ fail(node.line, `${what} must be a map`);
175
+ return node.entries;
176
+ }
177
+ function only(entries, allowed) {
178
+ for (const entry of entries)
179
+ if (!allowed.includes(entry.key))
180
+ fail(entry.line, `unknown key "${entry.key}"`);
181
+ }
182
+ function required(entries, key, line) {
183
+ return optional(entries, key) ?? fail(line, `missing "${key}"`);
184
+ }
185
+ function optional(entries, key) {
186
+ return entries.find((entry) => entry.key === key);
187
+ }
188
+ function fail(line, message) {
189
+ throw new YamlError(line, message);
190
+ }
@@ -1,4 +1,5 @@
1
1
  import { type QuotaPattern } from './quota.ts';
2
+ import { type YamlNode } from '../yaml.ts';
2
3
  /**
3
4
  * A launch profile: what `team` knows about one CLI, so a seat can't start
4
5
  * blocked. Keyed by the seat's `cli`.
@@ -53,6 +54,8 @@ export declare function profileFor(cli: string): Profile | null;
53
54
  export declare function quotaFor(cli: string): readonly QuotaPattern[];
54
55
  /** Patterns from a YAML list, for a profile snippet. A bad pattern throws. */
55
56
  export declare function quotaPatterns(text: string): QuotaPattern[];
57
+ /** Quota patterns from a list already parsed. A bad pattern throws. */
58
+ export declare function quotaList(node: YamlNode): QuotaPattern[];
56
59
  /** The model a status line names. `unreadable` is a line the rules claim that does not name one. */
57
60
  export declare function statusOnLine(cli: string, line: string): {
58
61
  model: string;
@@ -61,6 +61,10 @@ export function quotaFor(cli) {
61
61
  export function quotaPatterns(text) {
62
62
  return quotaOf(parseYaml(text));
63
63
  }
64
+ /** Quota patterns from a list already parsed. A bad pattern throws. */
65
+ export function quotaList(node) {
66
+ return quotaOf(node);
67
+ }
64
68
  /** The model a status line names. `unreadable` is a line the rules claim that does not name one. */
65
69
  export function statusOnLine(cli, line) {
66
70
  const rules = SHIPPED[cli]?.status;
@@ -84,7 +88,7 @@ function launchOf(root) {
84
88
  const format = required(entries, 'format', root.line);
85
89
  if (format.value.kind !== 'scalar' || format.value.value !== 1)
86
90
  fail(format.line, '"format" must be 1');
87
- only(entries, ['format', 'cli', 'screen', 'quota', ...LAUNCH_KEYS]);
91
+ only(entries, ['format', 'cli', 'screen', 'screen_module', 'quota', ...LAUNCH_KEYS]);
88
92
  const cli = text(required(entries, 'cli', root.line), 'cli');
89
93
  required(entries, 'screen', root.line);
90
94
  const quotaEntry = optional(entries, 'quota');
package/dist/state.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { StoredReading, StoredSpend } from './budgets/readings.ts';
2
2
  export type SeatState = {
3
3
  stage: 'launched' | 'named' | 'ready';
4
+ /** The CLI the seat was launched with: `down` stops it under it when the file renamed it. */
5
+ cli?: string;
4
6
  pane?: string;
5
7
  /** The herdr workspace, so a later command can close it without listing agents. */
6
8
  workspace?: string;
@@ -35,10 +37,6 @@ export type SessionState = {
35
37
  nudge?: {
36
38
  pending_since: string | null;
37
39
  };
38
- /** The last screen readings for this project, keyed by account, window and seat. */
39
- budgets?: Record<string, StoredReading>;
40
- /** The last spend check readings for this project, keyed by account. */
41
- spend?: Record<string, StoredSpend>;
42
40
  };
43
41
  export type State = {
44
42
  format: 1;
@@ -46,6 +44,13 @@ export type State = {
46
44
  read_at: string;
47
45
  file: string;
48
46
  };
47
+ /**
48
+ * The project's last readings, whatever session saw them: a screen reading keyed by account,
49
+ * window and seat, a check reading by account and window (§ 4.4).
50
+ */
51
+ budgets?: Record<string, StoredReading>;
52
+ /** The project's last spend check readings, keyed by account. */
53
+ spend?: Record<string, StoredSpend>;
49
54
  sessions: Record<string, SessionState>;
50
55
  };
51
56
  export declare const STATE_FILE = "team.state.json";
package/dist/state.js CHANGED
@@ -26,9 +26,46 @@ export function readState(dir) {
26
26
  for (const session of Object.values(state.sessions)) {
27
27
  session.seats ??= {};
28
28
  session.worktrees ??= {};
29
+ // Read old, write new: readings used to be held under a session. They are the project's
30
+ // (§ 4.4), so every session's records are merged to the top level here — no reader loses one,
31
+ // and the next write persists the new shape.
32
+ const held = session;
33
+ if (held.budgets) {
34
+ state.budgets = mergeReadings(state.budgets, held.budgets);
35
+ delete held.budgets;
36
+ }
37
+ if (held.spend) {
38
+ state.spend = mergeSpend(state.spend, held.spend);
39
+ delete held.spend;
40
+ }
29
41
  }
30
42
  return state;
31
43
  }
44
+ /**
45
+ * One record per slot when two sessions hold the same account, window and seat: the reading that
46
+ * counts wins — a confirmed one, and among those the newest change (§ 4.3).
47
+ */
48
+ function mergeReadings(top, held) {
49
+ const merged = top ?? {};
50
+ for (const [key, reading] of Object.entries(held)) {
51
+ const current = merged[key];
52
+ merged[key] = !current
53
+ || (reading.confirmed && !current.confirmed)
54
+ || (reading.confirmed === current.confirmed && Date.parse(reading.changedAt) > Date.parse(current.changedAt))
55
+ ? reading
56
+ : current;
57
+ }
58
+ return merged;
59
+ }
60
+ /** The same for spend: one record per account, the reading whose check ran last winning. */
61
+ function mergeSpend(top, held) {
62
+ const merged = top ?? {};
63
+ for (const [key, reading] of Object.entries(held)) {
64
+ const current = merged[key];
65
+ merged[key] = !current || Date.parse(reading.at) > Date.parse(current.at) ? reading : current;
66
+ }
67
+ return merged;
68
+ }
32
69
  // Reads the state, lets `change` edit it, and writes it back, all under the lock.
33
70
  export function updateState(dir, change) {
34
71
  return withLock(dir, () => {
@@ -35,14 +35,17 @@ export function compare(team, session, state, live, now = new Date(), watch = te
35
35
  rows.push({ name: seat.name, state: 'stopped', model: seat.display, pane: '-' });
36
36
  continue;
37
37
  }
38
- // An agent sits in the seat's workspace under another name, or under none.
39
- const stray = live.agents.find((candidate) => !claimed.has(candidate.pane) && labels.get(candidate.workspace) === seat.label
40
- && !team.seats.some((other) => other.name === candidate.name));
38
+ // An agent sits in the workspace recorded for this name, under another name or under none.
39
+ // The display label is not a key: two seats may share it.
40
+ const stray = recorded?.workspace
41
+ ? live.agents.find((candidate) => !claimed.has(candidate.pane) && candidate.workspace === recorded.workspace
42
+ && !team.seats.some((other) => other.name === candidate.name))
43
+ : undefined;
41
44
  if (stray) {
42
45
  claimed.add(stray.pane);
43
46
  rows.push({ name: seat.name, state: 'wrong name', model: seat.display, pane: stray.pane });
44
47
  differences.push({
45
- what: `${seat.name}: the agent in its workspace "${seat.label}" is ${stray.name ? `named "${stray.name}"` : 'unnamed'}`,
48
+ what: `${seat.name}: the agent in ${stray.pane} is ${stray.name ? `named "${stray.name}"` : 'unnamed'}`,
46
49
  repair: herdrCommand(session, 'agent', 'rename', stray.pane, seat.name),
47
50
  });
48
51
  continue;
@@ -2,10 +2,12 @@
2
2
  // visible text into the file's two fields, `model` and `version`, or null when the text doesn't
3
3
  // show them: "unread" is never a mismatch. The last six lines are the window. A line the rules
4
4
  // claim but cannot name clears an earlier match; that is how an unknown Codex footer stays unread.
5
+ import { stripSgr } from "../ansi.js";
5
6
  import { statusOnLine } from "../profiles/profile.js";
6
7
  export function runningModel(cli, screen) {
7
8
  let found = null;
8
- for (const line of screen.split('\n').slice(-6)) {
9
+ // The pane's text may keep its ANSI styling; the model is read from the plain form.
10
+ for (const line of stripSgr(screen).split('\n').slice(-6)) {
9
11
  const hit = statusOnLine(cli, line);
10
12
  if (hit === 'unreadable')
11
13
  found = null;
@@ -23,6 +23,11 @@ export interface Approval {
23
23
  path: string;
24
24
  hash: string;
25
25
  }>;
26
+ /**
27
+ * The override file as approved, or null when the owner approved there being
28
+ * none. Absent on a record written before the file existed.
29
+ */
30
+ overrides?: string | null;
26
31
  }
27
32
  export interface LedgerEntry {
28
33
  display: string;
@@ -0,0 +1 @@
1
+ export declare function version(): string;
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from 'node:fs';
2
+ // The one place the CLI's version is read. `--version` prints it and `team init` pins the
3
+ // schema URL's tag to it, so the URL can never name a release the CLI doesn't report.
4
+ export function version() {
5
+ const text = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
6
+ return JSON.parse(text).version;
7
+ }
@@ -29,6 +29,7 @@ export type SeatObservation = {
29
29
  screen: Screen;
30
30
  cli: string;
31
31
  vendor: string;
32
+ account: string;
32
33
  quota: QuotaFigure[];
33
34
  running: boolean;
34
35
  quiet: boolean;