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
@@ -0,0 +1,161 @@
1
+ import { CLIS } from "../../clis.js";
2
+ import { insideProject, insideTrust, normalize } from "../paths.js";
3
+ import { hasVersionToken } from "../signature.js";
4
+ import { valueOf } from "./section.js";
5
+ import { MODES } from "./workspace.js";
6
+ export const seats = {
7
+ name: 'seats',
8
+ owner: false,
9
+ after: ['trust', 'budgets'],
10
+ validate(entry, ctx) {
11
+ const trust = valueOf(ctx, 'trust');
12
+ const accounts = valueOf(ctx, 'budgets').declared;
13
+ return readSeats(entry, ctx.check, trust, ctx.root.line, ctx.broken, accounts);
14
+ },
15
+ schema: {
16
+ type: 'array',
17
+ minItems: 1,
18
+ items: {
19
+ type: 'object',
20
+ additionalProperties: false,
21
+ properties: {
22
+ role: { type: 'string', minLength: 1 },
23
+ name: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$' },
24
+ cli: { enum: CLIS },
25
+ vendor: { type: 'string', minLength: 1 },
26
+ account: { type: 'string' },
27
+ model: { type: 'string', minLength: 1 },
28
+ version: { type: 'string', $comment: 'quoted: unquoted, YAML reads 4.10 as the number 4.1' },
29
+ display: { type: 'string', $comment: 'defaults to "model version"; must contain the version as a token of its own' },
30
+ launch: { type: 'string', minLength: 1 },
31
+ cwd: { type: 'string', $comment: 'relative to the project, or a trust path' },
32
+ label: { type: 'string' },
33
+ mode: { enum: MODES },
34
+ parked: { type: 'boolean' },
35
+ stopped: { type: 'boolean' },
36
+ count: { type: 'integer', minimum: 1 },
37
+ },
38
+ required: ['role', 'name', 'cli', 'vendor', 'model', 'version', 'launch'],
39
+ $comment: 'account must name a key of budgets.accounts; label defaults to the model and version; a seat with count is neither lead',
40
+ },
41
+ },
42
+ };
43
+ // `broken` collects the names of seats left out for their own problems, so that a reference to
44
+ // one is not reported a second time. `accounts` is the names the file's budgets declare: a seat
45
+ // that names an `account:` must name one of these, or it would silently spend nothing budgeted.
46
+ function readSeats(entry, check, trust, topLine, broken, accounts) {
47
+ if (!entry) {
48
+ check.fail(topLine, 'seats is required: at least the coordinator\'s and the operator\'s seat');
49
+ return [];
50
+ }
51
+ if (entry.value.kind !== 'seq' || !entry.value.items.length) {
52
+ check.fail(entry.value.line, 'seats must be a list with at least one seat');
53
+ return [];
54
+ }
55
+ const seats = [];
56
+ for (const item of entry.value.items) {
57
+ const fields = check.fields(item, 'a seat', [
58
+ 'role', 'name', 'cli', 'vendor', 'account', 'model', 'version', 'display', 'launch', 'cwd', 'label', 'mode',
59
+ 'parked', 'stopped', 'count',
60
+ ]);
61
+ if (item.kind !== 'map')
62
+ continue;
63
+ const line = item.line;
64
+ const role = check.required(fields.get('role'), 'a seat\'s role', line);
65
+ const name = check.required(fields.get('name'), 'a seat\'s name', line);
66
+ const at = name ? `seat "${name}"` : 'a seat';
67
+ if (name && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
68
+ check.fail(fields.get('name')?.line ?? line, `${at}: the name takes letters, digits, ".", "_" and "-"`);
69
+ }
70
+ if (!fields.get('cli'))
71
+ check.fail(line, `${at}: cli is required`);
72
+ const cli = check.oneOf(fields.get('cli'), `${at}: cli`, CLIS);
73
+ const vendor = check.required(fields.get('vendor'), `${at}: vendor`, line);
74
+ // The account whose budget the seat spends, when the vendor's name is not it (§ 3b). Optional:
75
+ // absent, the seat spends its vendor, and the fingerprint of every file written so far is
76
+ // unchanged. Named, it must be one of the budgets' accounts: a typo would take the seat out of
77
+ // every budget with no warning at all.
78
+ const account = check.text(fields.get('account'), `${at}: account`);
79
+ if (account !== undefined && !accounts.has(account)) {
80
+ check.fail(fields.get('account')?.line ?? line, `${at}: account "${account}" is not in budgets.accounts`);
81
+ }
82
+ const model = check.required(fields.get('model'), `${at}: model`, line);
83
+ const version = readVersion(fields.get('version'), at, line, check);
84
+ const launch = check.required(fields.get('launch'), `${at}: launch`, line);
85
+ const display = check.text(fields.get('display'), `${at}: display`) ?? (model && version ? `${model} ${version}` : undefined);
86
+ if (display && version && fields.get('display') && !hasVersionToken(display, version)) {
87
+ check.fail(fields.get('display')?.line ?? line, `${at}: display must contain the version "${version}" as a token of its own`);
88
+ }
89
+ const cwd = readCwd(fields.get('cwd'), at, trust, check);
90
+ const mode = check.oneOf(fields.get('mode'), `${at}: mode`, MODES);
91
+ const parked = check.flag(fields.get('parked'), `${at}: parked`);
92
+ const stopped = check.flag(fields.get('stopped'), `${at}: stopped`);
93
+ const count = check.whole(fields.get('count'), `${at}: count`, 1) ?? 1;
94
+ const label = check.text(fields.get('label'), `${at}: label`);
95
+ // Left out, the herdr title is the model and version. A written label wins, so a file that
96
+ // already names one keeps the title, and the fingerprint, it had.
97
+ if (!role || !name || !cli || !vendor || !model || !version || !launch || !display) {
98
+ if (name)
99
+ broken.add(name);
100
+ continue;
101
+ }
102
+ for (let instance = 1; instance <= count; instance++) {
103
+ const suffix = instance === 1 ? '' : `-${instance}`;
104
+ seats.push({
105
+ role, cli, vendor, model, version, display, launch, cwd, parked, stopped, line,
106
+ name: name + suffix,
107
+ label: (label ?? defaultLabel(model, version)) + suffix,
108
+ declared: name,
109
+ count,
110
+ instance,
111
+ ...(account ? { account } : {}),
112
+ ...(mode ? { mode } : {}),
113
+ });
114
+ }
115
+ }
116
+ reportCollisions(seats, 'name', check);
117
+ return seats;
118
+ }
119
+ export function defaultLabel(model, version) {
120
+ return `${model} ${version}`.toLowerCase();
121
+ }
122
+ function readVersion(entry, at, line, check) {
123
+ if (!entry) {
124
+ check.fail(line, `${at}: version is required`);
125
+ return undefined;
126
+ }
127
+ const node = entry.value;
128
+ if (node.kind === 'scalar' && typeof node.value === 'string' && node.value !== '')
129
+ return node.value;
130
+ if (node.kind === 'scalar' && typeof node.value === 'number') {
131
+ check.fail(node.line, `${at}: version must be quoted ("${node.raw}"): unquoted, YAML reads it as a number and 4.10 becomes 4.1`);
132
+ }
133
+ else
134
+ check.fail(node.line, `${at}: version must be quoted text`);
135
+ return undefined;
136
+ }
137
+ function readCwd(entry, at, trust, check) {
138
+ const cwd = check.text(entry, `${at}: cwd`);
139
+ if (cwd === undefined || !entry)
140
+ return '.';
141
+ if (cwd.startsWith('/') || cwd.startsWith('~') || /^[A-Za-z]:[\\/]/.test(cwd)) {
142
+ check.fail(entry.value.line, `${at}: cwd must be relative to the project, never absolute and never "~"`);
143
+ return '.';
144
+ }
145
+ const path = normalize(cwd);
146
+ if (!insideProject(path) && !insideTrust(path, trust)) {
147
+ check.fail(entry.value.line, `${at}: cwd "${cwd}" is outside the project and matches no trust path`);
148
+ }
149
+ return path;
150
+ }
151
+ function reportCollisions(seats, field, check) {
152
+ const seen = new Map();
153
+ for (const seat of seats) {
154
+ const first = seen.get(seat[field]);
155
+ if (!first)
156
+ seen.set(seat[field], seat);
157
+ else if (first.line !== seat.line || first.instance === seat.instance) {
158
+ check.fail(seat.line, `two seats have the ${field} "${seat[field]}" (the other is on line ${first.line}${first.count > 1 || seat.count > 1 ? ', after count is expanded' : ''})`);
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,32 @@
1
+ import type { YamlEntry, YamlNode } from '../../yaml.ts';
2
+ import type { Check, Fields } from '../check.ts';
3
+ /** What a section reads and writes while the file is validated. */
4
+ export interface Ctx {
5
+ /** Every problem and warning, and the field helpers that report them. */
6
+ check: Check;
7
+ /** The whole file, for errors that belong to the file itself rather than one section. */
8
+ root: YamlNode;
9
+ /** The file's own fields, by top-level key. */
10
+ top: Fields;
11
+ /** The names of seats left out for their own problems, shared with the sections that name seats. */
12
+ broken: Set<string>;
13
+ /** Each section's value, set once the section has run. */
14
+ values: Map<string, unknown>;
15
+ }
16
+ /** A section's value, once every section it names in `after` has run. */
17
+ export declare function valueOf<T>(ctx: Ctx, name: string): T;
18
+ /** A JSON Schema fragment for one section, assembled into the file's schema by the generator. */
19
+ export type JsonSchema = Record<string, unknown>;
20
+ /** One top-level section of the team file, read and checked on its own. */
21
+ export interface Section {
22
+ /** The top-level key; `watch.checks` names a line inside `watch`, not a key of its own. */
23
+ name: string;
24
+ /** True when only the owner changes it: `team approve` fingerprints exactly these, in list order. */
25
+ owner: boolean;
26
+ /** The sections whose value this one reads; the validator runs it once those have run. */
27
+ after: readonly string[];
28
+ /** The code from `validate.ts`, moved as it was: reads its entry, reports, returns its value. */
29
+ validate(entry: YamlEntry | undefined, ctx: Ctx): unknown;
30
+ /** The JSON Schema fragment for this section. */
31
+ schema: JsonSchema;
32
+ }
@@ -0,0 +1,7 @@
1
+ /** A section's value, once every section it names in `after` has run. */
2
+ export function valueOf(ctx, name) {
3
+ const value = ctx.values.get(name);
4
+ if (value === undefined)
5
+ throw new Error(`section "${name}" has not run before its reader; name it in "after"`);
6
+ return value;
7
+ }
@@ -0,0 +1,2 @@
1
+ import type { Section } from './section.ts';
2
+ export declare const session: Section;
@@ -0,0 +1,16 @@
1
+ import { valueOf } from "./section.js";
2
+ export const session = {
3
+ name: 'session',
4
+ owner: true,
5
+ after: ['project'],
6
+ validate(entry, ctx) {
7
+ const project = valueOf(ctx, 'project');
8
+ const session = ctx.check.text(entry, 'session') ?? project;
9
+ if (session === 'default') {
10
+ ctx.check.fail(ctx.top.get('session')?.line ?? ctx.top.get('project')?.line ?? 1, 'session can\'t be "default", herdr\'s own session: give the team a session of its own');
11
+ }
12
+ return session;
13
+ },
14
+ // The team's own herdr session, defaulting to the project's name — never `default`, herdr's own.
15
+ schema: { type: 'string', not: { const: 'default' } },
16
+ };
@@ -0,0 +1,2 @@
1
+ import type { Section } from './section.ts';
2
+ export declare const tools: Section;
@@ -0,0 +1,43 @@
1
+ export const tools = {
2
+ name: 'tools',
3
+ owner: true,
4
+ after: [],
5
+ validate(entry, ctx) {
6
+ return readTools(entry, ctx.check);
7
+ },
8
+ schema: {
9
+ type: 'object',
10
+ additionalProperties: {
11
+ type: 'object',
12
+ properties: { kind: { type: 'string', minLength: 1 } },
13
+ required: ['kind'],
14
+ additionalProperties: { type: 'string' },
15
+ },
16
+ $comment: 'a tool is a name, then its kind and where it is; every field but the kind is text',
17
+ },
18
+ };
19
+ function readTools(entry, check) {
20
+ const tools = {};
21
+ if (!entry)
22
+ return tools;
23
+ if (entry.value.kind !== 'map') {
24
+ check.fail(entry.value.line, 'tools must be a map: a name, then its kind and where it is');
25
+ return tools;
26
+ }
27
+ for (const tool of entry.value.entries) {
28
+ const fields = {};
29
+ if (tool.value.kind !== 'map') {
30
+ check.fail(tool.value.line, `tools.${tool.key} must be a map with a kind`);
31
+ continue;
32
+ }
33
+ for (const field of tool.value.entries) {
34
+ const value = check.text(field, `tools.${tool.key}.${field.key}`);
35
+ if (value !== undefined)
36
+ fields[field.key] = value;
37
+ }
38
+ if (!('kind' in fields))
39
+ check.fail(tool.line, `tools.${tool.key} needs a kind`);
40
+ tools[tool.key] = fields;
41
+ }
42
+ return tools;
43
+ }
@@ -0,0 +1,2 @@
1
+ import type { Section } from './section.ts';
2
+ export declare const trust: Section;
@@ -0,0 +1,20 @@
1
+ import { trustProblem } from "../paths.js";
2
+ export const trust = {
3
+ name: 'trust',
4
+ owner: true,
5
+ after: [],
6
+ validate(entry, ctx) {
7
+ const trustItems = ctx.check.list(entry, 'trust');
8
+ for (const item of trustItems) {
9
+ const problem = trustProblem(item.value);
10
+ if (problem)
11
+ ctx.check.fail(item.line, `trust: "${item.value}" ${problem}`);
12
+ }
13
+ return trustItems.map((item) => item.value);
14
+ },
15
+ schema: {
16
+ type: 'array',
17
+ items: { type: 'string', minLength: 1 },
18
+ $comment: 'paths outside the project the seats may still work in, as the validator reads them',
19
+ },
20
+ };
@@ -0,0 +1,16 @@
1
+ import type { JsonSchema } from './section.ts';
2
+ export declare const DURATION: {
3
+ s: number;
4
+ m: number;
5
+ h: number;
6
+ };
7
+ export declare const PERCENT: {
8
+ '%': number;
9
+ };
10
+ export declare const SIZE: {
11
+ MB: number;
12
+ GB: number;
13
+ TB: number;
14
+ };
15
+ /** The schema of a measure, its unit tables deciding the pattern the text must fit. */
16
+ export declare function measureSchema(units: Record<string, number>): JsonSchema;
@@ -0,0 +1,8 @@
1
+ export const DURATION = { s: 1, m: 60, h: 3600 };
2
+ export const PERCENT = { '%': 1 };
3
+ export const SIZE = { MB: 1e6, GB: 1e9, TB: 1e12 };
4
+ /** The schema of a measure, its unit tables deciding the pattern the text must fit. */
5
+ export function measureSchema(units) {
6
+ const unit = Object.keys(units).join('|');
7
+ return { type: 'string', pattern: `^[0-9]+(?:\\.[0-9]+)?(${unit})$` };
8
+ }
@@ -0,0 +1,2 @@
1
+ import type { Section } from './section.ts';
2
+ export declare const visibility: Section;
@@ -0,0 +1,12 @@
1
+ import { valueOf } from "./section.js";
2
+ export const visibility = {
3
+ name: 'visibility',
4
+ owner: true,
5
+ after: ['identity'],
6
+ validate(entry, ctx) {
7
+ const identity = valueOf(ctx, 'identity');
8
+ return ctx.check.oneOf(entry, 'visibility', ['public', 'private'])
9
+ ?? (identity.forbiddenPublic.length ? 'public' : 'private');
10
+ },
11
+ schema: { enum: ['public', 'private'] },
12
+ };
@@ -0,0 +1,9 @@
1
+ import type { Section } from './section.ts';
2
+ /**
3
+ * Not a top-level key: the line inside `watch` that turns a check off. It stands in the list
4
+ * because the owner-only sections are generated from it, and its digest is its own — turning a
5
+ * check off reads as `watch.checks` changed, never as a threshold change too. The checks
6
+ * themselves are read by `watch`, so this section validates nothing: it is a name in the list,
7
+ * and a fragment that lives inside the watch one.
8
+ */
9
+ export declare const watchChecks: Section;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Not a top-level key: the line inside `watch` that turns a check off. It stands in the list
3
+ * because the owner-only sections are generated from it, and its digest is its own — turning a
4
+ * check off reads as `watch.checks` changed, never as a threshold change too. The checks
5
+ * themselves are read by `watch`, so this section validates nothing: it is a name in the list,
6
+ * and a fragment that lives inside the watch one.
7
+ */
8
+ export const watchChecks = {
9
+ name: 'watch.checks',
10
+ owner: true,
11
+ after: [],
12
+ validate: () => undefined,
13
+ schema: { $comment: 'inside watch: the checks property of the watch fragment' },
14
+ };
@@ -0,0 +1,14 @@
1
+ import type { TeamFile } from '../types.ts';
2
+ import type { Section } from './section.ts';
3
+ /** What the watch section hands the rest of the file: its value, and whether the old marks key was set. */
4
+ export interface Watched {
5
+ watch: TeamFile['watch'];
6
+ /** `watch.quota_marks` was written: `budgets.marks` warns that it replaces it. */
7
+ legacyMarks: boolean;
8
+ }
9
+ export declare const watch: Section;
10
+ /**
11
+ * The watch values a file that sets none runs with — and, since the section is the owner's, the
12
+ * values a file runs with until the owner approves what it sets instead.
13
+ */
14
+ export declare function defaultWatch(): TeamFile['watch'];
@@ -0,0 +1,111 @@
1
+ import { ALWAYS_ON, CHECK_NAMES } from "../../watch/check.js";
2
+ import { measureSchema, DURATION } from "./units.js";
3
+ export const watch = {
4
+ name: 'watch',
5
+ owner: true,
6
+ after: [],
7
+ validate(entry, ctx) {
8
+ return readWatch(entry, ctx.check);
9
+ },
10
+ schema: {
11
+ type: 'object',
12
+ additionalProperties: false,
13
+ properties: {
14
+ interval: measureSchema(DURATION),
15
+ idle_first: measureSchema(DURATION),
16
+ idle_repeat: measureSchema(DURATION),
17
+ team_idle: measureSchema(DURATION),
18
+ nudge_wait: measureSchema(DURATION),
19
+ unsent_after: measureSchema(DURATION),
20
+ quota_marks: {
21
+ type: 'array',
22
+ items: { type: 'number', exclusiveMinimum: 0, maximum: 100 },
23
+ deprecated: true,
24
+ $comment: 'now budgets.marks, and still read',
25
+ },
26
+ checks: {
27
+ type: 'object',
28
+ propertyNames: { enum: CHECK_NAMES.filter((name) => !ALWAYS_ON.includes(name)) },
29
+ additionalProperties: { const: 'off' },
30
+ $comment: 'the four that always run (attention, missing, model-drift, approval) are refused here too',
31
+ },
32
+ },
33
+ },
34
+ };
35
+ /**
36
+ * The watch values a file that sets none runs with — and, since the section is the owner's, the
37
+ * values a file runs with until the owner approves what it sets instead.
38
+ */
39
+ export function defaultWatch() {
40
+ return {
41
+ interval: 120,
42
+ idleFirst: 600,
43
+ idleRepeat: 1200,
44
+ teamIdle: 600,
45
+ nudgeWait: 600,
46
+ unsentAfter: 60,
47
+ quotaMarks: [50, 75, 90],
48
+ checks: [],
49
+ };
50
+ }
51
+ function readWatch(entry, check) {
52
+ const fields = check.fields(entry?.value, 'watch', [
53
+ 'interval', 'idle_first', 'idle_repeat', 'team_idle', 'nudge_wait', 'unsent_after', 'quota_marks', 'checks',
54
+ ]);
55
+ const time = (name, fallback) => check.measure(fields.get(name), `watch.${name}`, DURATION, '120s or 10m') ?? fallback;
56
+ const base = defaultWatch();
57
+ let quotaMarks = base.quotaMarks;
58
+ const marks = fields.get('quota_marks');
59
+ if (marks) {
60
+ const node = marks.value;
61
+ const numbers = node.kind === 'seq' ? node.items.map((item) => (item.kind === 'scalar' ? item.value : null)) : null;
62
+ if (numbers && numbers.every((n) => typeof n === 'number' && n > 0 && n <= 100))
63
+ quotaMarks = numbers;
64
+ else
65
+ check.fail(node.line, 'watch.quota_marks must be a list of percentages, such as [50, 75, 90]');
66
+ check.warnings.push({ line: node.line, message: 'watch.quota_marks is now budgets.marks, and is still read' });
67
+ }
68
+ return {
69
+ legacyMarks: Boolean(marks),
70
+ watch: {
71
+ interval: time('interval', base.interval),
72
+ idleFirst: time('idle_first', base.idleFirst),
73
+ idleRepeat: time('idle_repeat', base.idleRepeat),
74
+ teamIdle: time('team_idle', base.teamIdle),
75
+ nudgeWait: time('nudge_wait', base.nudgeWait),
76
+ unsentAfter: time('unsent_after', base.unsentAfter),
77
+ quotaMarks,
78
+ checks: readChecks(fields.get('checks'), check),
79
+ },
80
+ };
81
+ }
82
+ // The checks a file turns off: a map of check name to `off`. A name that doesn't exist is refused
83
+ // with its line, and so is one of the four that always run (RFC 0002 § 4.2) — the schema refuses
84
+ // them, and `team approve` validates before it fingerprints, so neither can approve them either.
85
+ function readChecks(entry, check) {
86
+ const off = [];
87
+ if (!entry)
88
+ return off;
89
+ const node = entry.value;
90
+ if (node.kind !== 'map') {
91
+ if (!(node.kind === 'scalar' && node.value === null)) {
92
+ check.fail(node.line, 'watch.checks must be a map: a check named, and set off');
93
+ }
94
+ return off;
95
+ }
96
+ for (const item of node.entries) {
97
+ if (!CHECK_NAMES.includes(item.key)) {
98
+ check.fail(item.line, `unknown check "${item.key}" in watch.checks: the checks are ${CHECK_NAMES.join(', ')}`);
99
+ continue;
100
+ }
101
+ if (ALWAYS_ON.includes(item.key)) {
102
+ check.fail(item.line, `watch.checks can't turn off ${item.key}: ${ALWAYS_ON.join(', ')} always run`);
103
+ continue;
104
+ }
105
+ if (item.value.kind === 'scalar' && item.value.value === 'off')
106
+ off.push(item.key);
107
+ else
108
+ check.fail(item.line, `watch.checks.${item.key} must be off: the only setting is off`);
109
+ }
110
+ return off;
111
+ }
@@ -0,0 +1,4 @@
1
+ import type { Mode } from '../types.ts';
2
+ import type { Section } from './section.ts';
3
+ export declare const MODES: Mode[];
4
+ export declare const workspace: Section;
@@ -0,0 +1,77 @@
1
+ import { insideTrust, normalize } from "../paths.js";
2
+ import { valueOf } from "./section.js";
3
+ export const MODES = ['worktree', 'shared'];
4
+ export const workspace = {
5
+ name: 'workspace',
6
+ owner: true,
7
+ after: ['seats', 'project', 'trust'],
8
+ validate(entry, ctx) {
9
+ const project = valueOf(ctx, 'project');
10
+ const trust = valueOf(ctx, 'trust');
11
+ const seats = valueOf(ctx, 'seats');
12
+ return readWorkspace(entry, ctx.check, project, trust, seats, ctx.root.line);
13
+ },
14
+ schema: {
15
+ type: 'object',
16
+ additionalProperties: false,
17
+ properties: {
18
+ mode: { enum: MODES },
19
+ path: { type: 'string', pattern: '(^|/)\\{task\\}$' },
20
+ branch: { type: 'string' },
21
+ base: { type: 'string' },
22
+ setup: { type: 'array', items: { type: 'string' } },
23
+ remove: { enum: ['on-merge', 'manual'] },
24
+ protected: { type: 'array', items: { type: 'string' } },
25
+ limit: { type: 'integer', minimum: 1 },
26
+ },
27
+ $comment: 'path ends with the segment {task} under a fixed folder of its own, inside a trust pattern; branch defaults to {task}, base and path are required when a seat works in worktrees — the validator checks what a pattern cannot say',
28
+ },
29
+ };
30
+ function readWorkspace(entry, check, project, trust, seats, topLine) {
31
+ const fields = check.fields(entry?.value, 'workspace', ['mode', 'path', 'branch', 'base', 'setup', 'remove', 'protected', 'limit']);
32
+ const mode = check.oneOf(fields.get('mode'), 'workspace.mode', MODES) ?? 'worktree';
33
+ const usesWorktrees = seats.some((seat) => (seat.mode ?? mode) === 'worktree');
34
+ const line = entry?.line ?? topLine;
35
+ const path = check.text(fields.get('path'), 'workspace.path') ?? null;
36
+ const base = check.text(fields.get('base'), 'workspace.base') ?? null;
37
+ if (usesWorktrees && !base)
38
+ check.fail(line, 'workspace.base is required when a seat works in worktrees');
39
+ if (usesWorktrees && !path)
40
+ check.fail(line, 'workspace.path is required when a seat works in worktrees');
41
+ if (path) {
42
+ const at = fields.get('path')?.value.line ?? line;
43
+ const problem = worktreePathProblem(path, project, trust);
44
+ if (problem)
45
+ check.fail(at, `workspace.path ${problem}`);
46
+ }
47
+ const protectedPaths = fields.get('protected') ? check.list(fields.get('protected'), 'workspace.protected').map((item) => item.value) : ['.'];
48
+ return {
49
+ mode,
50
+ path,
51
+ branch: check.text(fields.get('branch'), 'workspace.branch') ?? '{task}',
52
+ base,
53
+ setup: check.list(fields.get('setup'), 'workspace.setup').map((item) => item.value),
54
+ remove: check.oneOf(fields.get('remove'), 'workspace.remove', ['on-merge', 'manual']) ?? 'on-merge',
55
+ protected: protectedPaths,
56
+ limit: check.whole(fields.get('limit'), 'workspace.limit', 1) ?? 8,
57
+ };
58
+ }
59
+ // A worktree goes under a fixed folder of its own, and inside a trust path.
60
+ function worktreePathProblem(path, project, trust) {
61
+ if (path.startsWith('/') || path.startsWith('~'))
62
+ return 'must be relative to the project';
63
+ const segments = path.split('/').filter((segment) => segment && segment !== '.');
64
+ const last = segments[segments.length - 1];
65
+ if (last !== '{task}')
66
+ return 'must end with the segment {task}';
67
+ if (segments.slice(0, -1).some((segment) => segment.includes('{task}')))
68
+ return 'takes {task} only as its last segment';
69
+ const folder = segments.slice(0, -1).map((segment) => segment.replaceAll('{repo}', project || 'project'));
70
+ if (folder.every((segment) => segment === '..')) {
71
+ return 'needs a fixed folder of its own before {task}: this one puts worktrees straight into the project or one of its parents';
72
+ }
73
+ const sample = normalize([...folder, 'task'].join('/'));
74
+ if (!insideTrust(sample, trust))
75
+ return 'matches no trust pattern';
76
+ return null;
77
+ }
@@ -24,11 +24,17 @@ export type Seat = {
24
24
  name: string;
25
25
  cli: string;
26
26
  vendor: string;
27
+ /**
28
+ * The account whose budget this seat spends, when one vendor has two (RFC 0003 § 3b). Absent
29
+ * means the vendor is the account; a seat that names one spends it instead of the vendor.
30
+ */
31
+ account?: string;
27
32
  model: string;
28
33
  version: string;
29
34
  display: string;
30
35
  launch: string;
31
36
  cwd: string;
37
+ /** Herdr workspace title. Omitted in the file, it is `<model> <version>` in lowercase. */
32
38
  label: string;
33
39
  mode: Mode;
34
40
  parked: boolean;
@@ -1,13 +1,5 @@
1
- import type { TeamFile, ValidateResult } from './types.ts';
1
+ import type { ValidateResult } from './types.ts';
2
+ export { defaultLabel } from './sections/seats.ts';
3
+ export { defaultWatch } from './sections/watch.ts';
4
+ export { defaultBudgets } from './sections/budgets.ts';
2
5
  export declare function validateTeamFile(text: string): ValidateResult;
3
- /**
4
- * The watch values a file that sets none runs with — and, since the section is the owner's, the
5
- * values a file runs with until the owner approves what it sets instead.
6
- */
7
- export declare function defaultWatch(): TeamFile['watch'];
8
- /**
9
- * The budget values a file that sets none runs with — and, since the section is the owner's, the
10
- * values a file runs with until the owner approves what it sets instead. No accounts: nothing is
11
- * budgeted until the owner names one.
12
- */
13
- export declare function defaultBudgets(): TeamFile['budgets'];