team 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +22 -11
  2. package/dist/approve/approval.js +14 -9
  3. package/dist/approve/fingerprint.d.ts +14 -2
  4. package/dist/approve/fingerprint.js +21 -21
  5. package/dist/budgets/gate.js +7 -2
  6. package/dist/budgets/readings.d.ts +6 -2
  7. package/dist/budgets/readings.js +28 -2
  8. package/dist/budgets/run.js +8 -2
  9. package/dist/budgets/table.d.ts +2 -0
  10. package/dist/budgets/table.js +10 -5
  11. package/dist/cli.d.ts +2 -1
  12. package/dist/cli.js +8 -5
  13. package/dist/commands/add.d.ts +0 -1
  14. package/dist/commands/add.js +7 -5
  15. package/dist/commands/approve.js +17 -1
  16. package/dist/commands/doctor.d.ts +15 -1
  17. package/dist/commands/doctor.js +126 -8
  18. package/dist/commands/down.d.ts +4 -0
  19. package/dist/commands/down.js +32 -13
  20. package/dist/commands/init.d.ts +1 -1
  21. package/dist/commands/init.js +4 -2
  22. package/dist/commands/remove.js +5 -2
  23. package/dist/commands/status.d.ts +2 -0
  24. package/dist/commands/status.js +19 -1
  25. package/dist/commands/up.js +3 -1
  26. package/dist/commands/watch.d.ts +2 -0
  27. package/dist/commands/watch.js +28 -4
  28. package/dist/conformance/adapter.d.ts +5 -0
  29. package/dist/conformance/adapter.js +44 -0
  30. package/dist/file/check.d.ts +20 -0
  31. package/dist/file/check.js +112 -0
  32. package/dist/file/lines.js +13 -2
  33. package/dist/file/sections/budgets.d.ts +15 -0
  34. package/dist/file/sections/budgets.js +180 -0
  35. package/dist/file/sections/coordinator.d.ts +2 -0
  36. package/dist/file/sections/coordinator.js +11 -0
  37. package/dist/file/sections/format.d.ts +2 -0
  38. package/dist/file/sections/format.js +13 -0
  39. package/dist/file/sections/identity.d.ts +2 -0
  40. package/dist/file/sections/identity.js +103 -0
  41. package/dist/file/sections/index.d.ts +9 -0
  42. package/dist/file/sections/index.js +43 -0
  43. package/dist/file/sections/lead.d.ts +4 -0
  44. package/dist/file/sections/lead.js +18 -0
  45. package/dist/file/sections/limits.d.ts +2 -0
  46. package/dist/file/sections/limits.js +43 -0
  47. package/dist/file/sections/machine.d.ts +2 -0
  48. package/dist/file/sections/machine.js +38 -0
  49. package/dist/file/sections/operator.d.ts +2 -0
  50. package/dist/file/sections/operator.js +11 -0
  51. package/dist/file/sections/project.d.ts +2 -0
  52. package/dist/file/sections/project.js +13 -0
  53. package/dist/file/sections/rules.d.ts +2 -0
  54. package/dist/file/sections/rules.js +9 -0
  55. package/dist/file/sections/seats.d.ts +7 -0
  56. package/dist/file/sections/seats.js +161 -0
  57. package/dist/file/sections/section.d.ts +32 -0
  58. package/dist/file/sections/section.js +7 -0
  59. package/dist/file/sections/session.d.ts +2 -0
  60. package/dist/file/sections/session.js +16 -0
  61. package/dist/file/sections/tools.d.ts +2 -0
  62. package/dist/file/sections/tools.js +43 -0
  63. package/dist/file/sections/trust.d.ts +2 -0
  64. package/dist/file/sections/trust.js +20 -0
  65. package/dist/file/sections/units.d.ts +16 -0
  66. package/dist/file/sections/units.js +8 -0
  67. package/dist/file/sections/visibility.d.ts +2 -0
  68. package/dist/file/sections/visibility.js +12 -0
  69. package/dist/file/sections/watch-checks.d.ts +9 -0
  70. package/dist/file/sections/watch-checks.js +14 -0
  71. package/dist/file/sections/watch.d.ts +14 -0
  72. package/dist/file/sections/watch.js +111 -0
  73. package/dist/file/sections/workspace.d.ts +4 -0
  74. package/dist/file/sections/workspace.js +77 -0
  75. package/dist/file/types.d.ts +1 -0
  76. package/dist/file/validate.d.ts +4 -12
  77. package/dist/file/validate.js +45 -688
  78. package/dist/herdr.d.ts +1 -0
  79. package/dist/herdr.js +11 -1
  80. package/dist/launch/agent.js +4 -3
  81. package/dist/launch/deliver.d.ts +4 -0
  82. package/dist/launch/deliver.js +151 -8
  83. package/dist/launch/execute.d.ts +2 -0
  84. package/dist/launch/execute.js +21 -11
  85. package/dist/launch/plan.js +16 -2
  86. package/dist/profiles/antigravity.yaml +12 -2
  87. package/dist/profiles/claude-code.yaml +21 -6
  88. package/dist/profiles/codex.yaml +14 -1
  89. package/dist/profiles/cursor.yaml +45 -5
  90. package/dist/profiles/overrides.d.ts +67 -0
  91. package/dist/profiles/overrides.js +190 -0
  92. package/dist/profiles/profile.d.ts +3 -0
  93. package/dist/profiles/profile.js +5 -1
  94. package/dist/state.d.ts +2 -0
  95. package/dist/status/compare.js +7 -4
  96. package/dist/store/store.d.ts +5 -0
  97. package/dist/version.d.ts +1 -0
  98. package/dist/version.js +7 -0
  99. package/dist/watch/checks/budget.js +49 -53
  100. package/dist/watch/dialect.d.ts +6 -1
  101. package/dist/watch/dialect.js +68 -12
  102. package/dist/watch/pass.d.ts +8 -1
  103. package/dist/watch/pass.js +19 -9
  104. package/dist/watch/screen-core.d.ts +41 -4
  105. package/dist/watch/screen-core.js +458 -93
  106. package/dist/watch/screen-data.d.ts +25 -0
  107. package/dist/watch/screen-file.d.ts +8 -2
  108. package/dist/watch/screen-file.js +325 -38
  109. package/dist/watch/screen-profile.d.ts +19 -0
  110. package/dist/watch/screen-profile.js +1 -0
  111. package/dist/watch/screen.d.ts +22 -4
  112. package/dist/watch/screen.js +39 -5
  113. package/package.json +9 -3
  114. package/schema/team.schema.json +498 -0
@@ -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;
@@ -25,6 +26,13 @@ export type Placeholder = {
25
26
  } | {
26
27
  prefix: string;
27
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
+ };
28
36
  export type FallbackRule = {
29
37
  all: LinePattern[];
30
38
  kind: Screen['kind'];
@@ -34,8 +42,15 @@ type Box = {
34
42
  mode: 'box-to-rule';
35
43
  prompt: RegExp;
36
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[];
37
50
  placeholders: Placeholder[];
38
51
  placeholderStyle?: PlaceholderStyle;
52
+ wrap?: Wrap;
53
+ frameRows: number;
39
54
  };
40
55
  type StatusLast = {
41
56
  mode: 'status-last';
@@ -43,6 +58,8 @@ type StatusLast = {
43
58
  prompt: RegExp;
44
59
  placeholders: Placeholder[];
45
60
  placeholderStyle?: PlaceholderStyle;
61
+ wrap?: Wrap;
62
+ frameRows: number;
46
63
  };
47
64
  type StatusThenOne = {
48
65
  mode: 'status-then-one';
@@ -52,14 +69,21 @@ type StatusThenOne = {
52
69
  placeholderStyle?: PlaceholderStyle;
53
70
  stripSuffix: RegExp | null;
54
71
  fallback: FallbackRule[];
72
+ wrap?: Wrap;
73
+ frameRows: number;
55
74
  };
56
75
  type TwoRules = {
57
76
  mode: 'two-rules-footer-below';
58
77
  prompt: RegExp;
59
78
  rule: RegExp;
79
+ /** Any line below the closing rule matches any pattern in the list. */
60
80
  footers: RegExp[];
61
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;
62
84
  placeholderStyle?: PlaceholderStyle;
85
+ wrap?: Wrap;
86
+ frameRows: number;
63
87
  };
64
88
  export type Composer = Box | StatusLast | StatusThenOne | TwoRules;
65
89
  export type ScreenData = {
@@ -70,5 +94,6 @@ export type ScreenData = {
70
94
  question?: Stage;
71
95
  working?: Stage;
72
96
  composer: Composer;
97
+ profile?: ScreenProfile;
73
98
  };
74
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,7 +265,7 @@ 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
270
  only(entries, ['any', 'all', 'footer', 'on_footer', 'below_last_rule', 'without_rule', 'none_after']);
54
271
  if (entries.length === 0)
@@ -62,9 +279,9 @@ function ruleOf(node, ignoreCase) {
62
279
  const withoutRule = optional(entries, 'without_rule');
63
280
  const noneAfter = optional(entries, 'none_after');
64
281
  if (any)
65
- rule.any = patternsOf(any.value, 'any', ignoreCase);
282
+ rule.any = patternsOf(any.value, 'any', ignoreCase, allowCase);
66
283
  if (all)
67
- rule.all = patternsOf(all.value, 'all', ignoreCase);
284
+ rule.all = patternsOf(all.value, 'all', ignoreCase, allowCase);
68
285
  if (footer)
69
286
  rule.footer = stringOf(footer.value) ?? fail(footer.line, '"footer" must be a string');
70
287
  if (onFooter)
@@ -74,25 +291,29 @@ function ruleOf(node, ignoreCase) {
74
291
  if (withoutRule)
75
292
  rule.withoutRule = boolOf(withoutRule.value, 'without_rule');
76
293
  if (noneAfter)
77
- rule.noneAfter = noneAfterOf(noneAfter.value, ignoreCase);
294
+ rule.noneAfter = noneAfterOf(noneAfter.value, ignoreCase, allowCase);
78
295
  return rule;
79
296
  }
80
- function noneAfterOf(node, ignoreCase) {
297
+ function noneAfterOf(node, ignoreCase, allowCase = true) {
81
298
  const entries = mapping(node, 'none_after');
82
299
  only(entries, ['anchor', 'patterns']);
83
300
  const anchor = required(entries, 'anchor', node.line);
84
301
  const patterns = required(entries, 'patterns', node.line);
85
302
  return {
86
- anchor: linePattern(anchor.value, ignoreCase),
87
- patterns: patternsOf(patterns.value, 'patterns', ignoreCase),
303
+ anchor: linePattern(anchor.value, ignoreCase, allowCase),
304
+ patterns: patternsOf(patterns.value, 'patterns', ignoreCase, allowCase),
88
305
  };
89
306
  }
90
- function patternsOf(node, key, ignoreCase) {
307
+ function patternsOf(node, key, ignoreCase, allowCase = true) {
91
308
  if (node.kind !== 'seq' || node.items.length === 0)
92
309
  fail(node.line, `"${key}" must be a non-empty list`);
93
- return node.items.map((item) => linePattern(item, ignoreCase));
310
+ return node.items.map((item) => linePattern(item, ignoreCase, allowCase));
94
311
  }
95
- 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) {
96
317
  if (node.kind === 'scalar') {
97
318
  const text = stringOf(node);
98
319
  if (text === null)
@@ -100,13 +321,17 @@ function linePattern(node, ignoreCase) {
100
321
  return { match: patternOf(text, ignoreCase, node.line), except: [] };
101
322
  }
102
323
  const entries = mapping(node, 'a pattern');
103
- only(entries, ['match', 'except']);
324
+ only(entries, ['match', 'except', 'ignore_case']);
104
325
  const match = required(entries, 'match', node.line);
105
326
  const text = stringOf(match.value);
106
327
  if (text === null)
107
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;
108
333
  const except = optional(entries, 'except');
109
- 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) : [] };
110
335
  }
111
336
  function exceptOf(node, ignoreCase) {
112
337
  if (node.kind !== 'seq' || node.items.length === 0)
@@ -126,16 +351,38 @@ function composerOf(node) {
126
351
  // a composer whose suggestions plain text already names needs none of it.
127
352
  const style = optional(entries, 'placeholder_style');
128
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;
129
364
  if (name === 'box-to-rule') {
130
- only(entries, ['mode', 'prompt', 'rule', 'placeholders', 'placeholder_style']);
131
- return { mode: name, prompt: regexField(entries, 'prompt', node.line), rule: regexField(entries, 'rule', node.line), placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value), placeholderStyle };
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
+ };
132
379
  }
133
380
  if (name === 'status-last') {
134
- only(entries, ['mode', 'status_line', 'prompt', 'placeholders', 'placeholder_style']);
135
- 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 };
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 };
136
383
  }
137
384
  if (name === 'status-then-one') {
138
- only(entries, ['mode', 'status_line', 'prompt', 'placeholders', 'placeholder_style', 'strip_suffix', 'fallback']);
385
+ only(entries, ['mode', 'status_line', 'prompt', 'placeholders', 'placeholder_style', 'strip_suffix', 'fallback', 'wrap', 'frame_rows']);
139
386
  const suffix = optional(entries, 'strip_suffix');
140
387
  const fallback = required(entries, 'fallback', node.line);
141
388
  return {
@@ -144,33 +391,64 @@ function composerOf(node) {
144
391
  prompt: regexField(entries, 'prompt', node.line),
145
392
  placeholders: placeholdersOf(required(entries, 'placeholders', node.line).value),
146
393
  placeholderStyle,
147
- stripSuffix: suffix ? patternOf(stringOf(suffix.value) ?? fail(suffix.line, '"strip_suffix" must be a string'), false, suffix.line) : null,
394
+ stripSuffix: suffix ? composerString(suffix.value, 'strip_suffix', suffix.line) : null,
148
395
  fallback: fallbackOf(fallback.value),
396
+ wrap,
397
+ frameRows,
149
398
  };
150
399
  }
151
400
  if (name === 'two-rules-footer-below') {
152
- only(entries, ['mode', 'ignore_case', 'prompt', 'rule', 'footers', 'placeholders', 'placeholder_style']);
401
+ only(entries, ['mode', 'ignore_case', 'prompt', 'rule', 'footers', 'placeholders', 'fold', 'placeholder_style', 'wrap', 'frame_rows']);
153
402
  const flag = optional(entries, 'ignore_case');
154
403
  const ignoreCase = flag ? boolOf(flag.value, 'ignore_case') : false;
404
+ // Any line below the closing rule matches any pattern in the list.
155
405
  const footers = required(entries, 'footers', node.line);
156
- if (footers.value.kind !== 'seq' || footers.value.items.length === 0)
157
- fail(footers.line, '"footers" must be a non-empty list');
406
+ const fold = optional(entries, 'fold');
158
407
  return {
159
408
  mode: name,
160
409
  prompt: regexField(entries, 'prompt', node.line, ignoreCase),
161
410
  rule: regexField(entries, 'rule', node.line, ignoreCase),
162
- footers: footers.value.items.map((item) => {
163
- const text = stringOf(item);
164
- if (text === null)
165
- fail(item.line, '"footers" entries must be strings');
166
- return patternOf(text, ignoreCase, item.line);
167
- }),
411
+ footers: footersOf(footers, ignoreCase),
168
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,
169
414
  placeholderStyle,
415
+ wrap,
416
+ frameRows,
170
417
  };
171
418
  }
172
419
  fail(mode.line, `"mode" must be box-to-rule, status-last, status-then-one or two-rules-footer-below`);
173
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
+ }
174
452
  function placeholderStyleOf(entry) {
175
453
  const text = stringOf(entry.value);
176
454
  if (text !== 'dim')
@@ -233,10 +511,19 @@ function chromeOf(node) {
233
511
  }
234
512
  function regexField(entries, key, line, ignoreCase = false) {
235
513
  const entry = required(entries, key, line);
236
- 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);
237
524
  if (text === null)
238
- fail(entry.line, `"${key}" must be a string`);
239
- return patternOf(text, ignoreCase, entry.line);
525
+ fail(line, `"${key}" must be a string`);
526
+ return patternOf(text, ignoreCase, line);
240
527
  }
241
528
  function patternOf(text, ignoreCase, line) {
242
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 {};
@@ -1,3 +1,6 @@
1
+ import { type Box, type Fold } from './screen-core.ts';
2
+ import type { ScreenData } from './screen-data.ts';
3
+ export type { Box, Fold };
1
4
  export type Screen = {
2
5
  kind: 'idle';
3
6
  } | {
@@ -13,13 +16,28 @@ export type Screen = {
13
16
  } | {
14
17
  kind: 'unknown';
15
18
  };
19
+ /** The shipped screen for a CLI, or null when this version has none. */
20
+ export declare function screenData(cli: string): ScreenData | null;
21
+ /** The same classification `classify` runs, against a screen that may carry added patterns. */
22
+ export declare function classifyData(data: ScreenData, lines: string[]): Screen;
16
23
  /** Every stage, in the core's order. The first one that matches wins. */
17
- export declare function classify(cli: string, lines: string[]): Screen;
24
+ export declare function classify(cli: string | ScreenData, lines: string[]): Screen;
18
25
  /** The composer alone. A running turn would otherwise hide an empty input box. */
19
- export declare function classifyComposer(cli: string, lines: string[]): Screen;
20
- export declare function readScreen(cli: string, screen: string | undefined): Screen;
26
+ export declare function classifyComposer(cli: string | ScreenData, lines: string[]): Screen;
27
+ export declare function readScreen(cli: string | ScreenData, screen: string | undefined): Screen;
28
+ /** The folded form of a paste the pane's composer shows, or null. The shape only; the caller
29
+ * verifies it holds the text it typed before trusting it. */
30
+ export declare function readFold(cli: string | ScreenData, screen: string | undefined): Fold | null;
31
+ /** Whether the pane shows a fold marker at all, whatever count it names. The case `readFold`
32
+ * cannot report — a marker claiming zero hidden rows — must still keep an Enter from taking the
33
+ * box for an ordinary unsent one. */
34
+ export declare function readFoldMark(cli: string, screen: string | undefined): boolean;
35
+ /** The input box the composer draws for its current text, or null when it does not read `unsent`.
36
+ * The box's rows are the rows the pane drew for the text; the caller compares them to the text
37
+ * it typed before any Enter. */
38
+ export declare function readBox(cli: string, screen: string | undefined): Box | null;
21
39
  /** The one line a quota figure may come from: the composer's own status row, in the pane's last
22
40
  * 20 lines, read plain — a figure is never styled. Null for a CLI with no status line, and for
23
41
  * a window that shows no status row — a dialog, a question, a trust screen, a shell or an
24
42
  * unknown one reads no figures. */
25
- export declare function statusRow(cli: string, screen: string | undefined): string | null;
43
+ export declare function statusRow(cli: string | ScreenData, screen: string | undefined): string | null;