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,3 +1,4 @@
1
+ import type { ScreenProfile } from './screen-profile.ts';
1
2
  import type { Screen } from './screen.ts';
2
3
  export type LinePattern = {
3
4
  match: RegExp;
@@ -7,7 +8,11 @@ export type Rule = {
7
8
  any?: LinePattern[];
8
9
  all?: LinePattern[];
9
10
  footer?: string;
11
+ /** `any` and `all` are read on the dialog's last line, not on every line. */
12
+ onFooter?: boolean;
10
13
  belowLastRule?: RegExp;
14
+ /** The window has no composer rule: the dialog is the pane. */
15
+ withoutRule?: boolean;
11
16
  noneAfter?: {
12
17
  anchor: LinePattern;
13
18
  patterns: LinePattern[];
@@ -21,36 +26,64 @@ export type Placeholder = {
21
26
  } | {
22
27
  prefix: string;
23
28
  };
29
+ /** How a composer continues a line onto its next row, from a capture that shows it: the
30
+ * continuation starts at the text column (the box's own indent), and the break folds the space
31
+ * away at a word boundary or falls mid-word (hard). */
32
+ export type Wrap = {
33
+ continuation: 'text-column';
34
+ kind: 'word' | 'hard';
35
+ };
24
36
  export type FallbackRule = {
25
37
  all: LinePattern[];
26
38
  kind: Screen['kind'];
27
39
  };
40
+ export type PlaceholderStyle = 'dim';
28
41
  type Box = {
29
42
  mode: 'box-to-rule';
30
43
  prompt: RegExp;
31
44
  rule: RegExp;
45
+ /**
46
+ * For a scrolled-out box, the non-blank lines under the closing rule must match
47
+ * every pattern, in order, and the counts must be equal.
48
+ */
49
+ footers: RegExp[];
32
50
  placeholders: Placeholder[];
51
+ placeholderStyle?: PlaceholderStyle;
52
+ wrap?: Wrap;
53
+ frameRows: number;
33
54
  };
34
55
  type StatusLast = {
35
56
  mode: 'status-last';
36
57
  statusLine: RegExp;
37
58
  prompt: RegExp;
38
59
  placeholders: Placeholder[];
60
+ placeholderStyle?: PlaceholderStyle;
61
+ wrap?: Wrap;
62
+ frameRows: number;
39
63
  };
40
64
  type StatusThenOne = {
41
65
  mode: 'status-then-one';
42
66
  statusLine: RegExp;
43
67
  prompt: RegExp;
44
68
  placeholders: Placeholder[];
69
+ placeholderStyle?: PlaceholderStyle;
45
70
  stripSuffix: RegExp | null;
46
71
  fallback: FallbackRule[];
72
+ wrap?: Wrap;
73
+ frameRows: number;
47
74
  };
48
75
  type TwoRules = {
49
76
  mode: 'two-rules-footer-below';
50
77
  prompt: RegExp;
51
78
  rule: RegExp;
79
+ /** Any line below the closing rule matches any pattern in the list. */
52
80
  footers: RegExp[];
53
81
  placeholders: Placeholder[];
82
+ /** A row that stands for hidden rows of a folded paste; capture 1 is the hidden count. */
83
+ fold: RegExp | null;
84
+ placeholderStyle?: PlaceholderStyle;
85
+ wrap?: Wrap;
86
+ frameRows: number;
54
87
  };
55
88
  export type Composer = Box | StatusLast | StatusThenOne | TwoRules;
56
89
  export type ScreenData = {
@@ -61,5 +94,6 @@ export type ScreenData = {
61
94
  question?: Stage;
62
95
  working?: Stage;
63
96
  composer: Composer;
97
+ profile?: ScreenProfile;
64
98
  };
65
99
  export {};
@@ -1,2 +1,8 @@
1
- import type { ScreenData } from './screen-data.ts';
2
- export declare function loadScreen(text: string): ScreenData;
1
+ import type { Rule, ScreenData } from './screen-data.ts';
2
+ import { type YamlNode } from '../yaml.ts';
3
+ export declare function loadScreen(text: string, baseDir?: string, profileFile?: string): ScreenData;
4
+ /**
5
+ * Rules an override adds to one dialog stage. The stage is a list of patterns:
6
+ * a map would be a case flag or a renamed stage, and neither is an added pattern.
7
+ */
8
+ export declare function addedRules(node: YamlNode): Rule[];
@@ -1,34 +1,240 @@
1
1
  // A profile file, in team's YAML subset, checked as it is loaded. The JSON Schema
2
2
  // next to the profiles describes the same shape; this is what actually refuses a file,
3
3
  // because the package does not carry a schema validator.
4
+ import { existsSync, realpathSync, statSync } from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import { isAbsolute, resolve, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
4
8
  import { DialectError, compilePattern } from "./dialect.js";
5
9
  import { YamlError, parseYaml } from "../yaml.js";
10
+ const require = createRequire(import.meta.url);
6
11
  const STAGES = ['unknown', 'trust', 'permission', 'question', 'working'];
7
12
  const KINDS = ['idle', 'working', 'unsent', 'permission', 'trust', 'question', 'unknown'];
8
- const CHOICE_SAMPLES = ['❯ 1. Yes', '› 1. Yes', '> 1. Yes', '❯ 1.', '› 1.', '> 1.'];
9
- export function loadScreen(text) {
13
+ // The lines a dialog draws for its choices, built from their parts: the mark on the choice
14
+ // the cursor is on (claude-code ❯, codex ›, antigravity >) or the indent of the others, the
15
+ // number, and the labels the profiles' rules and fixtures show, run-on forms included — the
16
+ // trust dialog's "Yes, I trust this folder" and "No, exit", the permission dialog's "No, and
17
+ // tell Claude what to do differently" (escape hint and all), codex's "Yes, proceed (y)" and
18
+ // its own long No. The safety floor reads the first two numbered lines (screen-core's
19
+ // choiceLine and twoLine), so chrome must match none of them.
20
+ const CHOICE_MARKS = ['❯ ', '› ', '> ', ' '];
21
+ const CHOICE_TAILS = [
22
+ '',
23
+ ' Yes',
24
+ ' No',
25
+ ' Yes, I trust this folder',
26
+ ' No, exit',
27
+ ' No, and tell Claude what to do differently',
28
+ ' No, and tell Claude what to do differently (esc)',
29
+ ' Yes, proceed (y)',
30
+ ' No, and tell Codex what to do differently (esc)',
31
+ ];
32
+ const CHOICE_SAMPLES = CHOICE_MARKS.flatMap((mark) => ['1', '2'].flatMap((number) => CHOICE_TAILS.map((tail) => `${mark}${number}.${tail}`)));
33
+ const DEFAULT_COMPOSER = {
34
+ mode: 'box-to-rule',
35
+ prompt: /(?!)/,
36
+ rule: /(?!)/,
37
+ footers: [],
38
+ placeholders: [],
39
+ frameRows: 0,
40
+ };
41
+ export function loadScreen(text, baseDir, profileFile) {
10
42
  const root = parseYaml(text);
11
43
  const entries = mapping(root, 'a profile');
12
44
  // Launch keys are read by profile.ts. A screen-only snippet, as in the tests, omits them.
13
- only(entries, ['format', 'cli', 'screen', 'quota', 'binary', 'process_names', 'tested', 'unattended', 'rules', 'login', 'exit', 'timeouts', 'models', 'status_model']);
45
+ only(entries, ['format', 'cli', 'screen', 'screen_module', 'quota', 'binary', 'process_names', 'tested', 'unattended', 'rules', 'login', 'exit', 'timeouts', 'models', 'status_model']);
14
46
  const format = required(entries, 'format', root.line);
15
47
  if (format.value.kind !== 'scalar' || format.value.value !== 1)
16
48
  fail(format.line, '"format" must be 1');
17
49
  const cli = required(entries, 'cli', root.line);
18
50
  if (!stringOf(cli.value))
19
51
  fail(cli.line, '"cli" must be a string');
52
+ const topScreenModule = optional(entries, 'screen_module');
20
53
  const screen = required(entries, 'screen', root.line);
21
- return screenOf(screen.value);
54
+ return deepFreeze(screenOf(screen.value, topScreenModule, baseDir, profileFile));
22
55
  }
23
- function screenOf(node) {
56
+ function deepFreeze(obj) {
57
+ if (obj === null || typeof obj !== 'object' || obj instanceof RegExp)
58
+ return obj;
59
+ if (Object.isFrozen(obj))
60
+ return obj;
61
+ Object.freeze(obj);
62
+ for (const key of Object.getOwnPropertyNames(obj)) {
63
+ const val = obj[key];
64
+ if (val !== null && typeof val === 'object' && !(val instanceof RegExp)) {
65
+ deepFreeze(val);
66
+ }
67
+ }
68
+ return obj;
69
+ }
70
+ function loadScreenModule(specifier, baseDir, line, profileFile) {
71
+ const label = profileFile ? `profile "${profileFile}"` : 'profile';
72
+ // 1. Refuse empty string
73
+ if (!specifier || specifier.trim() === '') {
74
+ fail(line, `${label}: "screen_module" cannot be an empty string`);
75
+ }
76
+ // 2. Refuse current directory "."
77
+ if (specifier === '.' || specifier === './' || specifier === '.\\') {
78
+ fail(line, `${label}: "screen_module" cannot be current directory "."`);
79
+ }
80
+ // 3. Refuse absolute path
81
+ if (isAbsolute(specifier) || specifier.startsWith('/') || specifier.startsWith('\\')) {
82
+ fail(line, `${label}: "screen_module" cannot be an absolute path: "${specifier}"`);
83
+ }
84
+ // 4. Refuse home directory path
85
+ if (specifier.startsWith('~')) {
86
+ fail(line, `${label}: "screen_module" cannot be a home directory path: "${specifier}"`);
87
+ }
88
+ // 5. Refuse URL or other scheme
89
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(specifier)) {
90
+ fail(line, `${label}: "screen_module" cannot be a URL or scheme: "${specifier}"`);
91
+ }
92
+ // 6. Refuse ".." segment
93
+ if (specifier.split(/[/\\]/).includes('..') || /%2[eE]%2[eE]/.test(specifier)) {
94
+ fail(line, `${label}: "screen_module" cannot contain ".." segments: "${specifier}"`);
95
+ }
96
+ const defaultDir = fileURLToPath(new URL('../profiles', import.meta.url));
97
+ const dir = baseDir ? resolve(baseDir) : defaultDir;
98
+ const resolvedDir = resolve(dir);
99
+ // 7. Refuse paths in the project outside profiles
100
+ const projectSegments = ['test', 'src', 'dist', 'scripts', 'examples', 'node_modules', 'worktrees', '.github'];
101
+ const stripped = specifier.replace(/^(?:\.[/\\])+/, '');
102
+ const firstSegment = stripped.split(/[/\\]/)[0] ?? '';
103
+ if (projectSegments.includes(firstSegment) ||
104
+ (existsSync(resolve(process.cwd(), specifier)) && !resolve(process.cwd(), specifier).startsWith(resolvedDir + sep))) {
105
+ fail(line, `${label}: "screen_module" cannot be a path in the project: "${specifier}"`);
106
+ }
107
+ // 8. Must resolve inside profiles directory
108
+ let target = resolve(resolvedDir, specifier);
109
+ if (!target.startsWith(resolvedDir + sep) && target !== resolvedDir) {
110
+ fail(line, `${label}: "screen_module" must resolve inside profiles directory: "${specifier}"`);
111
+ }
112
+ // Check if target exists and if it is a directory or symlink loop
113
+ let st;
114
+ try {
115
+ st = statSync(target);
116
+ }
117
+ catch (err) {
118
+ if (err && err.code === 'ELOOP') {
119
+ fail(line, `${label}: "screen_module" contains a symlink loop: "${specifier}"`);
120
+ }
121
+ }
122
+ if (st && st.isDirectory()) {
123
+ fail(line, `${label}: "screen_module" cannot be a directory: "${specifier}"`);
124
+ }
125
+ // 9. Refuse bare names or paths without a script extension (.ts, .js, .cjs, .mjs)
126
+ if (!/\.(ts|js|cjs|mjs)$/.test(specifier)) {
127
+ if (!specifier.includes('/') && !specifier.includes('\\') && !/\.[^/\\]+$/.test(specifier)) {
128
+ fail(line, `${label}: "screen_module" cannot be a bare name: "${specifier}"`);
129
+ }
130
+ fail(line, `${label}: "screen_module" must have a script extension (.ts, .js, .cjs, .mjs): "${specifier}"`);
131
+ }
132
+ let fileTarget = target;
133
+ if (!existsSync(fileTarget)) {
134
+ if (fileTarget.endsWith('.ts') && existsSync(fileTarget.slice(0, -3) + '.js')) {
135
+ fileTarget = fileTarget.slice(0, -3) + '.js';
136
+ }
137
+ else if (fileTarget.endsWith('.js') && existsSync(fileTarget.slice(0, -3) + '.ts')) {
138
+ fileTarget = fileTarget.slice(0, -3) + '.ts';
139
+ }
140
+ }
141
+ let fileStat;
142
+ try {
143
+ fileStat = statSync(fileTarget);
144
+ }
145
+ catch (err) {
146
+ if (err && err.code === 'ELOOP') {
147
+ fail(line, `${label}: "screen_module" contains a symlink loop: "${specifier}"`);
148
+ }
149
+ fail(line, `${label}: cannot load "screen_module": Cannot find module "${specifier}"`);
150
+ }
151
+ if (!fileStat.isFile()) {
152
+ if (fileStat.isDirectory()) {
153
+ fail(line, `${label}: "screen_module" cannot be a directory: "${specifier}"`);
154
+ }
155
+ fail(line, `${label}: cannot load "screen_module": Cannot find module "${specifier}"`);
156
+ }
157
+ // Symlink check: realpath must be inside profiles directory
158
+ let realTarget;
159
+ let realDir;
160
+ try {
161
+ realTarget = realpathSync(fileTarget);
162
+ realDir = realpathSync(resolvedDir);
163
+ }
164
+ catch (err) {
165
+ if (err && err.code === 'ELOOP') {
166
+ fail(line, `${label}: "screen_module" contains a symlink loop: "${specifier}"`);
167
+ }
168
+ fail(line, `${label}: cannot load "screen_module": ${err instanceof Error ? err.message : String(err)}`);
169
+ }
170
+ if (!realTarget.startsWith(realDir + sep) && realTarget !== realDir) {
171
+ fail(line, `${label}: "screen_module" symlink leads outside profiles directory: "${specifier}"`);
172
+ }
173
+ let mod;
174
+ try {
175
+ mod = require(realTarget);
176
+ }
177
+ catch (error) {
178
+ fail(line, `${label}: cannot load "screen_module": ${error instanceof Error ? error.message : String(error)}`);
179
+ }
180
+ try {
181
+ const defaultExport = (typeof mod === 'object' && mod !== null) || typeof mod === 'function'
182
+ ? mod.default
183
+ : undefined;
184
+ const candidate = defaultExport && (typeof defaultExport === 'object' || typeof defaultExport === 'function')
185
+ ? defaultExport
186
+ : mod;
187
+ const readExport = (key) => {
188
+ let val = candidate !== null && (typeof candidate === 'object' || typeof candidate === 'function')
189
+ ? candidate[key]
190
+ : undefined;
191
+ if (val === undefined && mod !== candidate && mod !== null && (typeof mod === 'object' || typeof mod === 'function')) {
192
+ val = mod[key];
193
+ }
194
+ return typeof val === 'function' ? val : undefined;
195
+ };
196
+ const snapshot = Object.freeze({
197
+ unknown: readExport('unknown'),
198
+ trust: readExport('trust'),
199
+ permission: readExport('permission'),
200
+ question: readExport('question'),
201
+ working: readExport('working'),
202
+ composer: readExport('composer'),
203
+ });
204
+ return snapshot;
205
+ }
206
+ catch (error) {
207
+ fail(line, `${label}: cannot load "screen_module": ${error instanceof Error ? error.message : String(error)}`);
208
+ }
209
+ }
210
+ function screenOf(node, topScreenModule, baseDir, profileFile) {
24
211
  const entries = mapping(node, 'screen');
25
- only(entries, ['chrome', 'composer', ...STAGES]);
212
+ only(entries, ['chrome', 'composer', 'screen_module', ...STAGES]);
26
213
  const chrome = optional(entries, 'chrome');
27
- const composer = required(entries, 'composer', node.line);
214
+ const screenScreenModule = optional(entries, 'screen_module');
215
+ const moduleEntry = screenScreenModule ?? topScreenModule;
216
+ const composer = optional(entries, 'composer');
217
+ const label = profileFile ? `profile "${profileFile}"` : 'profile';
218
+ let profile;
219
+ if (moduleEntry) {
220
+ const specifier = stringOf(moduleEntry.value);
221
+ if (specifier === null)
222
+ fail(moduleEntry.line, '"screen_module" must be a string');
223
+ profile = loadScreenModule(specifier, baseDir, moduleEntry.line, profileFile);
224
+ }
225
+ // Rule (b): A composer comes from data or from the hatch, never both; the load refuses both.
226
+ if (composer && typeof profile?.composer === 'function') {
227
+ fail(moduleEntry.line, `${label}: profile has a data composer and screen_module exports a composer: a composer comes from data or from the hatch, never both`);
228
+ }
229
+ if (!composer && typeof profile?.composer !== 'function') {
230
+ fail(node.line, `${label}: missing "composer"`);
231
+ }
28
232
  const data = {
29
233
  chrome: chrome ? chromeOf(chrome.value) : [],
30
- composer: composerOf(composer.value),
234
+ composer: composer ? composerOf(composer.value) : DEFAULT_COMPOSER,
31
235
  };
236
+ if (profile)
237
+ data.profile = profile;
32
238
  for (const name of STAGES) {
33
239
  const entry = optional(entries, name);
34
240
  if (entry)
@@ -36,6 +242,17 @@ function screenOf(node) {
36
242
  }
37
243
  return data;
38
244
  }
245
+ /**
246
+ * Rules an override adds to one dialog stage. The stage is a list of patterns:
247
+ * a map would be a case flag or a renamed stage, and neither is an added pattern.
248
+ */
249
+ export function addedRules(node) {
250
+ if (node.kind !== 'seq' || node.items.length === 0)
251
+ fail(node.line, 'an override adds a non-empty list of patterns');
252
+ // A shipped profile may set a pattern's own case. An override may not: the flag would
253
+ // loosen a match the shipped patterns made case-sensitively.
254
+ return node.items.map((item) => ruleOf(item, false, false));
255
+ }
39
256
  function stageOf(node) {
40
257
  if (node.kind === 'seq')
41
258
  return { rules: node.items.map((item) => ruleOf(item, false)) };
@@ -48,45 +265,55 @@ function stageOf(node) {
48
265
  fail(rules.line, '"rules" must be a list');
49
266
  return { rules: rules.value.items.map((item) => ruleOf(item, ignoreCase)) };
50
267
  }
51
- function ruleOf(node, ignoreCase) {
268
+ function ruleOf(node, ignoreCase, allowCase = true) {
52
269
  const entries = mapping(node, 'a rule');
53
- only(entries, ['any', 'all', 'footer', 'below_last_rule', 'none_after']);
270
+ only(entries, ['any', 'all', 'footer', 'on_footer', 'below_last_rule', 'without_rule', 'none_after']);
54
271
  if (entries.length === 0)
55
272
  fail(node.line, 'a rule has no primitive');
56
273
  const rule = {};
57
274
  const any = optional(entries, 'any');
58
275
  const all = optional(entries, 'all');
59
276
  const footer = optional(entries, 'footer');
277
+ const onFooter = optional(entries, 'on_footer');
60
278
  const below = optional(entries, 'below_last_rule');
279
+ const withoutRule = optional(entries, 'without_rule');
61
280
  const noneAfter = optional(entries, 'none_after');
62
281
  if (any)
63
- rule.any = patternsOf(any.value, 'any', ignoreCase);
282
+ rule.any = patternsOf(any.value, 'any', ignoreCase, allowCase);
64
283
  if (all)
65
- rule.all = patternsOf(all.value, 'all', ignoreCase);
284
+ rule.all = patternsOf(all.value, 'all', ignoreCase, allowCase);
66
285
  if (footer)
67
286
  rule.footer = stringOf(footer.value) ?? fail(footer.line, '"footer" must be a string');
287
+ if (onFooter)
288
+ rule.onFooter = boolOf(onFooter.value, 'on_footer');
68
289
  if (below)
69
290
  rule.belowLastRule = patternOf(stringOf(below.value) ?? fail(below.line, '"below_last_rule" must be a string'), ignoreCase, below.line);
291
+ if (withoutRule)
292
+ rule.withoutRule = boolOf(withoutRule.value, 'without_rule');
70
293
  if (noneAfter)
71
- rule.noneAfter = noneAfterOf(noneAfter.value, ignoreCase);
294
+ rule.noneAfter = noneAfterOf(noneAfter.value, ignoreCase, allowCase);
72
295
  return rule;
73
296
  }
74
- function noneAfterOf(node, ignoreCase) {
297
+ function noneAfterOf(node, ignoreCase, allowCase = true) {
75
298
  const entries = mapping(node, 'none_after');
76
299
  only(entries, ['anchor', 'patterns']);
77
300
  const anchor = required(entries, 'anchor', node.line);
78
301
  const patterns = required(entries, 'patterns', node.line);
79
302
  return {
80
- anchor: linePattern(anchor.value, ignoreCase),
81
- patterns: patternsOf(patterns.value, 'patterns', ignoreCase),
303
+ anchor: linePattern(anchor.value, ignoreCase, allowCase),
304
+ patterns: patternsOf(patterns.value, 'patterns', ignoreCase, allowCase),
82
305
  };
83
306
  }
84
- function patternsOf(node, key, ignoreCase) {
307
+ function patternsOf(node, key, ignoreCase, allowCase = true) {
85
308
  if (node.kind !== 'seq' || node.items.length === 0)
86
309
  fail(node.line, `"${key}" must be a non-empty list`);
87
- return node.items.map((item) => linePattern(item, ignoreCase));
310
+ return node.items.map((item) => linePattern(item, ignoreCase, allowCase));
88
311
  }
89
- function linePattern(node, ignoreCase) {
312
+ // A pattern is a string, or a mapping with `match`, its optional `except` list and its
313
+ // own `ignore_case`. The pattern's flag is read with the stage's: a screen whose own
314
+ // case varies carries the flag beside the pattern, where a stage-wide flag would reach
315
+ // every rule. The flag is the entry's, so `except` is read on the same lines `match` is.
316
+ function linePattern(node, ignoreCase, allowCase = true) {
90
317
  if (node.kind === 'scalar') {
91
318
  const text = stringOf(node);
92
319
  if (text === null)
@@ -94,13 +321,17 @@ function linePattern(node, ignoreCase) {
94
321
  return { match: patternOf(text, ignoreCase, node.line), except: [] };
95
322
  }
96
323
  const entries = mapping(node, 'a pattern');
97
- only(entries, ['match', 'except']);
324
+ only(entries, ['match', 'except', 'ignore_case']);
98
325
  const match = required(entries, 'match', node.line);
99
326
  const text = stringOf(match.value);
100
327
  if (text === null)
101
328
  fail(match.line, '"match" must be a string');
329
+ const flag = optional(entries, 'ignore_case');
330
+ if (flag && !allowCase)
331
+ fail(flag.line, 'unknown key "ignore_case"');
332
+ const own = flag ? boolOf(flag.value, 'ignore_case') : false;
102
333
  const except = optional(entries, 'except');
103
- return { match: patternOf(text, ignoreCase, match.line), except: except ? exceptOf(except.value, ignoreCase) : [] };
334
+ return { match: patternOf(text, ignoreCase || own, match.line), except: except ? exceptOf(except.value, ignoreCase || own) : [] };
104
335
  }
105
336
  function exceptOf(node, ignoreCase) {
106
337
  if (node.kind !== 'seq' || node.items.length === 0)
@@ -116,16 +347,42 @@ function composerOf(node) {
116
347
  const entries = mapping(node, 'composer');
117
348
  const mode = required(entries, 'mode', node.line);
118
349
  const name = stringOf(mode.value);
350
+ // How the CLI renders its greyed suggestions, read off the input line's styling. Optional:
351
+ // a composer whose suggestions plain text already names needs none of it.
352
+ const style = optional(entries, 'placeholder_style');
353
+ const placeholderStyle = style ? placeholderStyleOf(style) : undefined;
354
+ // How the box continues a line onto its next row, declared only where a capture showed the
355
+ // wrap. A composer without one is read by tiling the typed text's own runs.
356
+ const wrapEntry = optional(entries, 'wrap');
357
+ const wrap = wrapEntry ? wrapOf(wrapEntry) : undefined;
358
+ // The empty rows a capture shows the pane drawing under the text, inside the box's frame —
359
+ // the drop before the status line, or the closing rule. Counted from the captures; a
360
+ // composer whose captures show none declares none, and the box read then keeps every
361
+ // trailing empty row and refuses one the typed text does not have. Don't guess.
362
+ const frameEntry = optional(entries, 'frame_rows');
363
+ const frameRows = frameEntry ? frameRowsOf(frameEntry) : 0;
119
364
  if (name === 'box-to-rule') {
120
- only(entries, ['mode', 'prompt', 'rule', 'placeholders']);
121
- return { mode: name, prompt: regexField(entries, 'prompt', node.line), rule: regexField(entries, 'rule', node.line), placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value) };
365
+ only(entries, ['mode', 'prompt', 'rule', 'footers', 'placeholders', 'placeholder_style', 'wrap', 'frame_rows']);
366
+ // For a scrolled-out box, the non-blank lines under the closing rule must match
367
+ // every pattern, in order, and the counts must be equal.
368
+ const footers = optional(entries, 'footers');
369
+ return {
370
+ mode: name,
371
+ prompt: regexField(entries, 'prompt', node.line),
372
+ rule: regexField(entries, 'rule', node.line),
373
+ footers: footers ? footersOf(footers, false) : [],
374
+ placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value),
375
+ placeholderStyle,
376
+ wrap,
377
+ frameRows,
378
+ };
122
379
  }
123
380
  if (name === 'status-last') {
124
- only(entries, ['mode', 'status_line', 'prompt', 'placeholders']);
125
- return { mode: name, statusLine: regexField(entries, 'status_line', node.line), prompt: regexField(entries, 'prompt', node.line), placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value) };
381
+ only(entries, ['mode', 'status_line', 'prompt', 'placeholders', 'placeholder_style', 'wrap', 'frame_rows']);
382
+ return { mode: name, statusLine: regexField(entries, 'status_line', node.line), prompt: regexField(entries, 'prompt', node.line), placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value), placeholderStyle, wrap, frameRows };
126
383
  }
127
384
  if (name === 'status-then-one') {
128
- only(entries, ['mode', 'status_line', 'prompt', 'placeholders', 'strip_suffix', 'fallback']);
385
+ only(entries, ['mode', 'status_line', 'prompt', 'placeholders', 'placeholder_style', 'strip_suffix', 'fallback', 'wrap', 'frame_rows']);
129
386
  const suffix = optional(entries, 'strip_suffix');
130
387
  const fallback = required(entries, 'fallback', node.line);
131
388
  return {
@@ -133,32 +390,71 @@ function composerOf(node) {
133
390
  statusLine: regexField(entries, 'status_line', node.line),
134
391
  prompt: regexField(entries, 'prompt', node.line),
135
392
  placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value),
136
- stripSuffix: suffix ? patternOf(stringOf(suffix.value) ?? fail(suffix.line, '"strip_suffix" must be a string'), false, suffix.line) : null,
393
+ placeholderStyle,
394
+ stripSuffix: suffix ? composerString(suffix.value, 'strip_suffix', suffix.line) : null,
137
395
  fallback: fallbackOf(fallback.value),
396
+ wrap,
397
+ frameRows,
138
398
  };
139
399
  }
140
400
  if (name === 'two-rules-footer-below') {
141
- only(entries, ['mode', 'ignore_case', 'prompt', 'rule', 'footers', 'placeholders']);
401
+ only(entries, ['mode', 'ignore_case', 'prompt', 'rule', 'footers', 'placeholders', 'fold', 'placeholder_style', 'wrap', 'frame_rows']);
142
402
  const flag = optional(entries, 'ignore_case');
143
403
  const ignoreCase = flag ? boolOf(flag.value, 'ignore_case') : false;
404
+ // Any line below the closing rule matches any pattern in the list.
144
405
  const footers = required(entries, 'footers', node.line);
145
- if (footers.value.kind !== 'seq' || footers.value.items.length === 0)
146
- fail(footers.line, '"footers" must be a non-empty list');
406
+ const fold = optional(entries, 'fold');
147
407
  return {
148
408
  mode: name,
149
409
  prompt: regexField(entries, 'prompt', node.line, ignoreCase),
150
410
  rule: regexField(entries, 'rule', node.line, ignoreCase),
151
- footers: footers.value.items.map((item) => {
152
- const text = stringOf(item);
153
- if (text === null)
154
- fail(item.line, '"footers" entries must be strings');
155
- return patternOf(text, ignoreCase, item.line);
156
- }),
411
+ footers: footersOf(footers, ignoreCase),
157
412
  placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value),
413
+ fold: fold ? patternOf(stringOf(fold.value) ?? fail(fold.line, '"fold" must be a string'), ignoreCase, fold.line) : null,
414
+ placeholderStyle,
415
+ wrap,
416
+ frameRows,
158
417
  };
159
418
  }
160
419
  fail(mode.line, `"mode" must be box-to-rule, status-last, status-then-one or two-rules-footer-below`);
161
420
  }
421
+ // The composer's wrap rule: the continuation starts at the text column and the break is at a
422
+ // word boundary or hard. Either alone does not join a wrapped box — where it starts and where
423
+ // it breaks are both needed — so both keys are required and nothing else is read.
424
+ function wrapOf(entry) {
425
+ const entries = mapping(entry.value, 'wrap');
426
+ only(entries, ['continuation', 'kind']);
427
+ const continuation = required(entries, 'continuation', entry.line);
428
+ const where = stringOf(continuation.value);
429
+ if (where !== 'text-column')
430
+ fail(continuation.line, '"continuation" must be text-column');
431
+ const kind = required(entries, 'kind', entry.line);
432
+ const shape = stringOf(kind.value);
433
+ if (shape !== 'word' && shape !== 'hard')
434
+ fail(kind.line, '"kind" must be word or hard');
435
+ return { continuation: where, kind: shape };
436
+ }
437
+ // The pane's own empty rows inside a box's frame, as a capture shows them: a whole number,
438
+ // zero or more. Omitting the key is how a composer whose captures show no such row declares
439
+ // zero — the box read then keeps every trailing empty row.
440
+ function frameRowsOf(entry) {
441
+ const value = entry.value;
442
+ if (value.kind !== 'scalar' || typeof value.value !== 'number' || !Number.isInteger(value.value) || value.value < 0) {
443
+ fail(entry.line, '"frame_rows" must be a whole number of rows, zero or more');
444
+ }
445
+ return value.value;
446
+ }
447
+ function footersOf(entry, ignoreCase) {
448
+ if (entry.value.kind !== 'seq' || entry.value.items.length === 0)
449
+ fail(entry.line, '"footers" must be a non-empty list');
450
+ return entry.value.items.map((item) => composerString(item, 'footers', item.line, ignoreCase));
451
+ }
452
+ function placeholderStyleOf(entry) {
453
+ const text = stringOf(entry.value);
454
+ if (text !== 'dim')
455
+ fail(entry.line, '"placeholder_style" must be dim');
456
+ return text;
457
+ }
162
458
  function fallbackOf(node) {
163
459
  if (node.kind !== 'seq')
164
460
  fail(node.line, '"fallback" must be a list');
@@ -215,10 +511,19 @@ function chromeOf(node) {
215
511
  }
216
512
  function regexField(entries, key, line, ignoreCase = false) {
217
513
  const entry = required(entries, key, line);
218
- const text = stringOf(entry.value);
514
+ return composerString(entry.value, key, entry.line, ignoreCase);
515
+ }
516
+ // A composer key that reads a pattern string. The dialog shape aims its flag at the one
517
+ // line it sits on; a composer's own patterns — its prompt, rule, footers, status line,
518
+ // suffix — are strings, and the flag among them is refused in words that name the key.
519
+ function composerString(node, key, line, ignoreCase = false) {
520
+ if (node.kind === 'map' && node.entries.some((item) => item.key === 'ignore_case')) {
521
+ fail(line, `"${key}" cannot ignore case: only a dialog pattern may`);
522
+ }
523
+ const text = stringOf(node);
219
524
  if (text === null)
220
- fail(entry.line, `"${key}" must be a string`);
221
- return patternOf(text, ignoreCase, entry.line);
525
+ fail(line, `"${key}" must be a string`);
526
+ return patternOf(text, ignoreCase, line);
222
527
  }
223
528
  function patternOf(text, ignoreCase, line) {
224
529
  try {
@@ -0,0 +1,19 @@
1
+ import type { Screen } from './screen.ts';
2
+ export type ComposerReading = {
3
+ kind: Screen['kind'];
4
+ from?: number;
5
+ input?: number;
6
+ };
7
+ /**
8
+ * Every hatch predicate is monotone toward caution, running alongside the data stage for unknown, trust, permission, question, and working so a hatch can only add caution, never remove it.
9
+ * A composer comes from data or from the hatch, never both, and a profile that has a data composer while its screen_module exports a composer is refused at load.
10
+ * The guarantees cover what a hatch returns and what load accepts; a hatch is trusted package code, not a sandbox.
11
+ */
12
+ export interface ScreenProfile {
13
+ unknown?: (lines: string[]) => boolean;
14
+ trust?: (lines: string[]) => boolean;
15
+ permission?: (lines: string[]) => boolean;
16
+ question?: (lines: string[]) => boolean;
17
+ working?: (lines: string[]) => boolean;
18
+ composer?: (lines: string[]) => ComposerReading;
19
+ }
@@ -0,0 +1 @@
1
+ export {};