@adhdev/daemon-core 0.9.82-rc.209 → 0.9.82-rc.210

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 (64) hide show
  1. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +2 -0
  2. package/dist/commands/router.d.ts +6 -0
  3. package/dist/git/git-commands.d.ts +2 -0
  4. package/dist/git/git-diff.d.ts +6 -0
  5. package/dist/index.d.ts +11 -5
  6. package/dist/index.js +5699 -3486
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +5679 -3483
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/coordinator-prompt.d.ts +6 -0
  11. package/dist/mesh/mesh-delivery-policy.d.ts +5 -0
  12. package/dist/mesh/mesh-events-coordinator.d.ts +151 -0
  13. package/dist/mesh/mesh-events-pending.d.ts +33 -0
  14. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  15. package/dist/mesh/mesh-events-utils.d.ts +14 -0
  16. package/dist/mesh/mesh-events.d.ts +5 -198
  17. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  18. package/dist/mesh/mesh-ledger.d.ts +19 -0
  19. package/dist/mesh/mesh-missions.d.ts +58 -0
  20. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +175 -0
  22. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  23. package/dist/mesh/mesh-work-queue.d.ts +82 -0
  24. package/dist/mesh/refine-config.d.ts +24 -2
  25. package/dist/mesh/worktree-bootstrap-config.d.ts +22 -0
  26. package/dist/providers/acp-provider-instance.d.ts +2 -0
  27. package/dist/providers/spec/driver.d.ts +8 -0
  28. package/dist/providers/spec/evaluator.d.ts +4 -5
  29. package/dist/providers/spec/loader.d.ts +1 -0
  30. package/dist/providers/spec/schema.gen.d.ts +1409 -6
  31. package/dist/providers/spec/types.d.ts +188 -175
  32. package/dist/repo-mesh-types.d.ts +1 -0
  33. package/package.json +1 -1
  34. package/src/boot/daemon-lifecycle.ts +3 -0
  35. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +20 -7
  36. package/src/commands/router.ts +594 -66
  37. package/src/git/git-commands.ts +5 -5
  38. package/src/git/git-diff.ts +53 -0
  39. package/src/index.ts +11 -5
  40. package/src/mesh/coordinator-prompt.ts +14 -1
  41. package/src/mesh/mesh-delivery-policy.ts +17 -0
  42. package/src/mesh/mesh-events-coordinator.ts +1404 -0
  43. package/src/mesh/mesh-events-pending.ts +371 -0
  44. package/src/mesh/mesh-events-stale.ts +283 -0
  45. package/src/mesh/mesh-events-utils.ts +161 -0
  46. package/src/mesh/mesh-events.ts +27 -2143
  47. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  48. package/src/mesh/mesh-ledger.ts +134 -2
  49. package/src/mesh/mesh-missions.ts +151 -0
  50. package/src/mesh/mesh-review-inbox.ts +307 -0
  51. package/src/mesh/mesh-runtime-store.ts +539 -3
  52. package/src/mesh/mesh-task-stats.ts +154 -0
  53. package/src/mesh/mesh-work-queue.ts +233 -17
  54. package/src/mesh/refine-config.ts +42 -5
  55. package/src/mesh/worktree-bootstrap-config.ts +79 -0
  56. package/src/providers/acp-provider-instance.ts +15 -1
  57. package/src/providers/cli-provider-instance.ts +34 -13
  58. package/src/providers/spec/driver.ts +57 -29
  59. package/src/providers/spec/evaluator.ts +302 -112
  60. package/src/providers/spec/loader.ts +226 -37
  61. package/src/providers/spec/schema.gen.ts +450 -334
  62. package/src/providers/spec/schema.json +162 -75
  63. package/src/providers/spec/types.ts +234 -183
  64. package/src/repo-mesh-types.ts +1 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Spec evaluator — pure function.
3
3
  *
4
- * Given a visible screen text and a CliSpec, returns:
4
+ * Given a visible screen text and a CliSpec (v3), returns:
5
5
  * - which sections cover which line ranges
6
6
  * - which state matched and why
7
7
  * - extracted modal title + buttons (if any)
@@ -9,12 +9,14 @@
9
9
  * - which notifications/delegates this evaluation activates
10
10
  * - a trace object that explains every decision (for the inspector)
11
11
  *
12
- * No I/O, no state.
12
+ * No I/O, no state. Pass prevLines for delta (changed) condition support.
13
13
  */
14
14
  'use strict';
15
15
 
16
16
  import type {
17
- CliSpec, Section, SectionRegex, SectionPattern, SpecState,
17
+ CliSpec, SectionDef, SpecStateV3, Condition, RegexCondition,
18
+ ChangedCondition, AllCondition, AnyCondition,
19
+ ExtractTitle, ExtractButtons,
18
20
  ControlAction, NotificationRule, DelegateTrigger,
19
21
  } from './types.js';
20
22
 
@@ -63,7 +65,7 @@ export interface SpecEvaluation {
63
65
  }
64
66
 
65
67
  // ────────────────────────────────────────────────────────────────────────────
66
- // Layout
68
+ // Layout — v3 sections{} object
67
69
  // ────────────────────────────────────────────────────────────────────────────
68
70
 
69
71
  function resolveSize(size: number | string | undefined, total: number): number {
@@ -75,23 +77,31 @@ function resolveSize(size: number | string | undefined, total: number): number {
75
77
  return Math.max(0, Math.min(total, Math.round((total * pct) / 100)));
76
78
  }
77
79
 
78
- function resolveSections(spec: CliSpec, lines: string[]): ResolvedSection[] {
80
+ /**
81
+ * Resolve v3 sections{} object into an ordered array of ResolvedSection.
82
+ * Two-pass: first anchor/positional, then apply `until` cross-references.
83
+ */
84
+ function resolveSections(
85
+ sectionsObj: Record<string, SectionDef>,
86
+ lines: string[],
87
+ ): ResolvedSection[] {
79
88
  const total = lines.length;
80
- const resolved: ResolvedSection[] = [];
81
- // Two-pass: first resolve from_top / from_bottom without `until`.
82
89
  const anchored = new Map<string, { fromLine: number; toLine: number }>();
90
+ const sectionEntries = Object.entries(sectionsObj);
83
91
 
84
- for (const sec of spec.layout.sections) {
92
+ for (const [id, sec] of sectionEntries) {
85
93
  let from = 0;
86
94
  let to = total;
87
- if (sec.anchor_regex !== undefined) {
95
+
96
+ if (sec.anchor !== undefined) {
88
97
  try {
89
- const re = new RegExp(sec.anchor_regex, sec.anchor_flags ?? '');
98
+ const re = new RegExp(sec.anchor, sec.anchor_flags ?? '');
90
99
  const prevRe = sec.anchor_context?.prev !== undefined
91
100
  ? new RegExp(sec.anchor_context.prev, sec.anchor_context.prev_flags ?? '') : null;
92
101
  const nextRe = sec.anchor_context?.next !== undefined
93
102
  ? new RegExp(sec.anchor_context.next, sec.anchor_context.next_flags ?? '') : null;
94
- const matches = (i: number) => re.test(lines[i])
103
+ const matches = (i: number) =>
104
+ re.test(lines[i])
95
105
  && (prevRe === null || (i > 0 && prevRe.test(lines[i - 1])))
96
106
  && (nextRe === null || (i < total - 1 && nextRe.test(lines[i + 1])));
97
107
  let idx = -1;
@@ -104,36 +114,54 @@ function resolveSections(spec: CliSpec, lines: string[]): ResolvedSection[] {
104
114
  from = idx;
105
115
  to = total;
106
116
  if (sec.until_regex !== undefined) {
117
+ // until_regex on anchor-based sections (extension field)
107
118
  try {
108
119
  const ure = new RegExp(sec.until_regex, sec.until_regex_flags ?? '');
109
120
  const end = lines.findIndex((l, i) => i > idx && ure.test(l));
110
121
  if (end !== -1) to = end;
111
- } catch { /* bad until_regex — extend to end */ }
122
+ } catch { /* bad until_regex */ }
112
123
  } else if (sec.lines !== undefined) {
113
124
  to = Math.min(total, from + sec.lines);
114
125
  }
126
+ // `until` regex check on anchor sections (starts with ^)
127
+ // handled in second pass
115
128
  }
116
- } catch { /* bad anchor_regex fall through to defaults */ }
129
+ } catch { /* bad anchor regex */ }
117
130
  } else if (sec.from_top !== undefined) {
118
131
  from = resolveSize(sec.from_top, total);
132
+ to = total;
119
133
  } else if (sec.from_bottom !== undefined) {
120
134
  const sz = resolveSize(sec.from_bottom, total);
121
135
  from = total - sz;
122
136
  to = total;
123
137
  }
124
- anchored.set(sec.id, { fromLine: from, toLine: to });
138
+
139
+ anchored.set(id, { fromLine: from, toLine: to });
125
140
  }
126
141
 
127
- // Second pass: apply `until` references.
128
- for (const sec of spec.layout.sections) {
129
- let { fromLine, toLine } = anchored.get(sec.id)!;
130
- if (sec.until) {
131
- const target = anchored.get(sec.until.section);
132
- if (target) toLine = target.fromLine;
142
+ // Second pass: apply `until` references or `until` regex strings.
143
+ const resolved: ResolvedSection[] = [];
144
+ for (const [id, sec] of sectionEntries) {
145
+ let { fromLine, toLine } = anchored.get(id)!;
146
+
147
+ if (sec.until !== undefined) {
148
+ if (sec.until.startsWith('^')) {
149
+ // `until` is a regex: stop at the first matching line after fromLine
150
+ try {
151
+ const ure = new RegExp(sec.until);
152
+ const end = lines.findIndex((l, i) => i > fromLine && ure.test(l));
153
+ if (end !== -1) toLine = end;
154
+ } catch { /* bad until regex */ }
155
+ } else {
156
+ // `until` is a section id reference
157
+ const target = anchored.get(sec.until);
158
+ if (target) toLine = target.fromLine;
159
+ }
133
160
  }
161
+
134
162
  if (toLine < fromLine) toLine = fromLine;
135
163
  const text = lines.slice(fromLine, toLine).join('\n');
136
- resolved.push({ id: sec.id, fromLine, toLine, text });
164
+ resolved.push({ id, fromLine, toLine, text });
137
165
  }
138
166
 
139
167
  return resolved;
@@ -145,18 +173,103 @@ function sectionText(sections: ResolvedSection[], sectionId: string | undefined,
145
173
  return found ? found.text : '';
146
174
  }
147
175
 
148
- function compileRegex(ref: { regex: string; flags?: string }): RegExp {
149
- return new RegExp(ref.regex, ref.flags ?? 'i');
176
+ // ────────────────────────────────────────────────────────────────────────────
177
+ // Condition evaluation (v3)
178
+ // ────────────────────────────────────────────────────────────────────────────
179
+
180
+ function isRegexCondition(c: Condition): c is RegexCondition {
181
+ return 'matches' in c;
150
182
  }
151
183
 
152
- function compilePattern(ref: { pattern: string; flags?: string }): RegExp {
153
- const flags = ref.flags ?? 'gm';
154
- return new RegExp(ref.pattern, flags.includes('g') ? flags : flags + 'g');
184
+ function isChangedCondition(c: Condition): c is ChangedCondition {
185
+ return 'cursor_above' in c && 'changed' in c;
155
186
  }
156
187
 
157
- function compileLinePattern(ref: { pattern: string; flags?: string }): RegExp {
158
- const flags = (ref.flags ?? 'm').replace(/g/g, '');
159
- return new RegExp(ref.pattern, flags);
188
+ function isAllCondition(c: Condition): c is AllCondition {
189
+ return 'all' in c;
190
+ }
191
+
192
+ function isAnyCondition(c: Condition): c is AnyCondition {
193
+ return 'any' in c;
194
+ }
195
+
196
+ function evaluateCondition(
197
+ cond: Condition,
198
+ sections: ResolvedSection[],
199
+ fullScreen: string,
200
+ cursor: { row: number; col: number } | undefined,
201
+ prevLines: string[] | undefined,
202
+ trace: TraceEntry[],
203
+ stateId: string,
204
+ ): boolean {
205
+ if (isAllCondition(cond)) {
206
+ for (const child of cond.all) {
207
+ if (!evaluateCondition(child, sections, fullScreen, cursor, prevLines, trace, stateId)) {
208
+ return false;
209
+ }
210
+ }
211
+ return true;
212
+ }
213
+
214
+ if (isAnyCondition(cond)) {
215
+ for (const child of cond.any) {
216
+ if (evaluateCondition(child, sections, fullScreen, cursor, prevLines, trace, stateId)) {
217
+ return true;
218
+ }
219
+ }
220
+ return false;
221
+ }
222
+
223
+ if (isChangedCondition(cond)) {
224
+ if (!cursor || !prevLines || prevLines.length === 0) return false;
225
+ const curLines = fullScreen.split('\n');
226
+ const startRow = Math.max(0, cursor.row - cond.cursor_above);
227
+ const endRow = cursor.row; // exclusive
228
+ const currentSlice = curLines.slice(startRow, endRow).join('\n');
229
+ const prevSlice = prevLines.slice(startRow, endRow).join('\n');
230
+ const result = currentSlice !== prevSlice;
231
+ trace.push({ kind: 'section', text: `state[${stateId}] changed cond cursor_above=${cond.cursor_above} rows[${startRow},${endRow}) changed=${result}` });
232
+ return result;
233
+ }
234
+
235
+ if (isRegexCondition(cond)) {
236
+ const haystack = sectionText(sections, cond.section, fullScreen);
237
+ let matched = false;
238
+ try {
239
+ const re = new RegExp(cond.matches, cond.flags ?? 'i');
240
+ matched = re.test(haystack);
241
+ } catch { matched = false; }
242
+
243
+ if (!matched) {
244
+ trace.push({ kind: 'state_skip', text: `state[${stateId}] regex cond ${cond.section ?? '*'}~/${cond.matches}/ no match` });
245
+ return false;
246
+ }
247
+
248
+ // Cursor-position guards
249
+ if (cursor !== undefined) {
250
+ if (cond.cursor_row_min !== undefined && cursor.row < cond.cursor_row_min) {
251
+ trace.push({ kind: 'state_skip', text: `state[${stateId}] cursor row ${cursor.row} < cursor_row_min ${cond.cursor_row_min}` });
252
+ return false;
253
+ }
254
+ if (cond.cursor_row_max !== undefined && cursor.row > cond.cursor_row_max) {
255
+ trace.push({ kind: 'state_skip', text: `state[${stateId}] cursor row ${cursor.row} > cursor_row_max ${cond.cursor_row_max}` });
256
+ return false;
257
+ }
258
+ if (cond.cursor_col_min !== undefined && cursor.col < cond.cursor_col_min) {
259
+ trace.push({ kind: 'state_skip', text: `state[${stateId}] cursor col ${cursor.col} < cursor_col_min ${cond.cursor_col_min}` });
260
+ return false;
261
+ }
262
+ if (cond.cursor_col_max !== undefined && cursor.col > cond.cursor_col_max) {
263
+ trace.push({ kind: 'state_skip', text: `state[${stateId}] cursor col ${cursor.col} > cursor_col_max ${cond.cursor_col_max}` });
264
+ return false;
265
+ }
266
+ }
267
+
268
+ trace.push({ kind: 'state_match', text: `state[${stateId}] regex cond ${cond.section ?? '*'}~/${cond.matches}/ matched${cursor !== undefined ? ` cursor=(${cursor.row},${cursor.col})` : ''}` });
269
+ return true;
270
+ }
271
+
272
+ return false;
160
273
  }
161
274
 
162
275
  // ────────────────────────────────────────────────────────────────────────────
@@ -164,61 +277,92 @@ function compileLinePattern(ref: { pattern: string; flags?: string }): RegExp {
164
277
  // ────────────────────────────────────────────────────────────────────────────
165
278
 
166
279
  function matchState(
167
- state: SpecState,
280
+ state: SpecStateV3,
168
281
  sections: ResolvedSection[],
169
282
  fullScreen: string,
170
283
  trace: TraceEntry[],
171
- cursor?: { row: number; col: number },
284
+ cursor: { row: number; col: number } | undefined,
285
+ prevLines: string[] | undefined,
172
286
  ): { matched: boolean; title: string | null } {
173
- const haystack = sectionText(sections, state.when.section, fullScreen);
174
- const re = compileRegex(state.when);
175
- if (!re.test(haystack)) {
176
- trace.push({ kind: 'state_skip', text: `state[${state.id}] when ${state.when.section ?? '*'}~/${state.when.regex}/ no match` });
177
- return { matched: false, title: null };
287
+ // Support v1-shaped state.when ({ regex, section }) for backward compat
288
+ // with test objects that don't go through the loader.
289
+ let effectiveWhen = state.when;
290
+ const stateAny = state as any;
291
+ if (stateAny.when && 'regex' in stateAny.when && !('all' in stateAny.when) && !('any' in stateAny.when)) {
292
+ effectiveWhen = normalizeV1When(stateAny.when);
178
293
  }
294
+ const condMatched = evaluateCondition(effectiveWhen, sections, fullScreen, cursor, prevLines, trace, state.id);
179
295
 
180
- // Cursor-position guards: check row/col bounds when the state declares them.
181
- // Guards are skipped entirely when the caller did not supply a cursor position
182
- // (cursor === undefined) so existing pure-text evaluation is unaffected.
183
- if (cursor !== undefined) {
184
- const w = state.when;
185
- if (w.cursor_row_min !== undefined && cursor.row < w.cursor_row_min) {
186
- trace.push({ kind: 'state_skip', text: `state[${state.id}] cursor row ${cursor.row} < cursor_row_min ${w.cursor_row_min}` });
187
- return { matched: false, title: null };
188
- }
189
- if (w.cursor_row_max !== undefined && cursor.row > w.cursor_row_max) {
190
- trace.push({ kind: 'state_skip', text: `state[${state.id}] cursor row ${cursor.row} > cursor_row_max ${w.cursor_row_max}` });
191
- return { matched: false, title: null };
192
- }
193
- if (w.cursor_col_min !== undefined && cursor.col < w.cursor_col_min) {
194
- trace.push({ kind: 'state_skip', text: `state[${state.id}] cursor col ${cursor.col} < cursor_col_min ${w.cursor_col_min}` });
195
- return { matched: false, title: null };
196
- }
197
- if (w.cursor_col_max !== undefined && cursor.col > w.cursor_col_max) {
198
- trace.push({ kind: 'state_skip', text: `state[${state.id}] cursor col ${cursor.col} > cursor_col_max ${w.cursor_col_max}` });
199
- return { matched: false, title: null };
200
- }
296
+ if (!condMatched) {
297
+ trace.push({ kind: 'state_skip', text: `state[${state.id}] when condition not met` });
298
+ return { matched: false, title: null };
201
299
  }
202
300
 
203
- trace.push({ kind: 'state_match', text: `state[${state.id}] matched via ${state.when.section ?? '*'}~/${state.when.regex}/${cursor !== undefined ? ` cursor=(${cursor.row},${cursor.col})` : ''}` });
301
+ trace.push({ kind: 'state_match', text: `state[${state.id}] matched` });
204
302
 
205
303
  let title: string | null = null;
206
- if (state.extract_title) {
207
- const titleHay = sectionText(sections, state.extract_title.section, fullScreen);
208
- const m = compileRegex(state.extract_title).exec(titleHay);
209
- if (m) title = (m[1] ?? m[0]).trim();
210
- trace.push({ kind: 'state_match', text: `state[${state.id}] extract_title ${title ?? '(none)'}` });
304
+ const stateAny2 = state as any;
305
+ const extract = state.extract;
306
+ // v1 compat: extract_title
307
+ const titleRule: ExtractTitle | undefined = extract?.title
308
+ ?? (stateAny2.extract_title ? v1ExtractTitleToV3(stateAny2.extract_title) : undefined);
309
+ if (titleRule) {
310
+ title = extractTitle(titleRule, sections, fullScreen);
311
+ trace.push({ kind: 'state_match', text: `state[${state.id}] extract.title → ${title ?? '(none)'}` });
211
312
  }
313
+
212
314
  return { matched: true, title };
213
315
  }
214
316
 
215
- function extractButtonsWithPattern(
216
- rule: { pattern: string; flags?: string },
317
+ function extractTitle(
318
+ rule: ExtractTitle,
319
+ sections: ResolvedSection[],
320
+ fullScreen: string,
321
+ ): string | null {
322
+ const hay = sectionText(sections, rule.section, fullScreen);
323
+ if (!hay) return null;
324
+
325
+ if (rule.first_line) {
326
+ // Take the first non-separator, non-empty line
327
+ const lines = hay.split('\n');
328
+ for (const line of lines) {
329
+ const stripped = line.trim();
330
+ if (stripped && !/^[─╌═─\s]+$/.test(stripped)) {
331
+ return stripped;
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+
337
+ if (rule.regex) {
338
+ try {
339
+ const re = new RegExp(rule.regex, rule.flags ?? 'i');
340
+ const m = re.exec(hay);
341
+ if (m) return (m[1] ?? m[0]).trim();
342
+ } catch { /* bad regex */ }
343
+ }
344
+
345
+ return null;
346
+ }
347
+
348
+ function compilePattern(ref: { pattern: string; flags?: string }): RegExp {
349
+ const flags = ref.flags ?? 'gm';
350
+ return new RegExp(ref.pattern, flags.includes('g') ? flags : flags + 'g');
351
+ }
352
+
353
+ function compileLinePattern(ref: { pattern: string; flags?: string }): RegExp {
354
+ const flags = (ref.flags ?? 'm').replace(/g/g, '');
355
+ return new RegExp(ref.pattern, flags);
356
+ }
357
+
358
+ function extractButtonsFromRule(
359
+ rule: ExtractButtons,
217
360
  hay: string,
218
- keyTemplate: string,
219
- continuationLines: boolean,
220
361
  ): { index: number; label: string; key: string }[] {
362
+ const keyTemplate = rule.key_for_index;
363
+ const continuationLines = rule.continuation_lines ?? false;
221
364
  const buttons: { index: number; label: string; key: string }[] = [];
365
+
222
366
  if (continuationLines) {
223
367
  const re = compileLinePattern(rule);
224
368
  const lines = hay.split('\n');
@@ -254,49 +398,96 @@ function extractButtonsWithPattern(
254
398
  buttons.push({ index: idx, label, key });
255
399
  }
256
400
  }
401
+
257
402
  buttons.sort((a, b) => a.index - b.index);
258
403
  return buttons;
259
404
  }
260
405
 
261
406
  function extractModal(
262
- state: SpecState,
407
+ state: SpecStateV3,
263
408
  sections: ResolvedSection[],
264
409
  fullScreen: string,
265
410
  title: string | null,
266
411
  trace: TraceEntry[],
267
412
  ): ModalSnapshot | null {
268
- if (!state.modal_buttons) return null;
269
- const hay = sectionText(sections, state.modal_buttons.section, fullScreen);
270
- const minCount = state.modal_buttons.min_count ?? 2;
271
- const keyTemplate = state.modal_buttons.key_for_index;
272
- const continuationLines = state.modal_buttons.continuation_lines ?? false;
273
-
274
- // Build ordered list of pattern candidates: `patterns` array takes precedence,
275
- // falling back to the single `pattern` field.
276
- const candidates: Array<{ pattern: string; flags?: string }> =
277
- state.modal_buttons.patterns?.length
278
- ? state.modal_buttons.patterns
279
- : state.modal_buttons.pattern
280
- ? [{ pattern: state.modal_buttons.pattern, flags: state.modal_buttons.flags }]
281
- : [];
282
-
283
- let buttons: { index: number; label: string; key: string }[] = [];
284
- for (const candidate of candidates) {
285
- const result = extractButtonsWithPattern(candidate, hay, keyTemplate, continuationLines);
286
- if (result.length >= minCount) {
287
- buttons = result;
288
- break;
289
- }
290
- }
413
+ const stateAny = state as any;
414
+ // v1 compat: modal_buttons
415
+ const buttonsRule: ExtractButtons | undefined = state.extract?.buttons
416
+ ?? (stateAny.modal_buttons ? v1ModalButtonsToV3(stateAny.modal_buttons) : undefined);
417
+ if (!buttonsRule) return null;
418
+
419
+ const hay = sectionText(sections, buttonsRule.section, fullScreen);
420
+ const minCount = buttonsRule.min_count ?? 2;
421
+ const buttons = extractButtonsFromRule(buttonsRule, hay);
291
422
 
292
423
  if (buttons.length < minCount) {
293
- trace.push({ kind: 'modal', text: `modal_buttons matched ${buttons.length}/${minCount} required — discarded` });
424
+ trace.push({ kind: 'modal', text: `extract.buttons matched ${buttons.length}/${minCount} required — discarded` });
294
425
  return null;
295
426
  }
296
- trace.push({ kind: 'modal', text: `modal_buttons matched ${buttons.length} choices` });
427
+ trace.push({ kind: 'modal', text: `extract.buttons matched ${buttons.length} choices` });
297
428
  return { title, buttons };
298
429
  }
299
430
 
431
+ // ────────────────────────────────────────────────────────────────────────────
432
+ // v1 backward-compat helpers
433
+ // ────────────────────────────────────────────────────────────────────────────
434
+
435
+ /**
436
+ * Convert a v1 sections array to a v3 sections object (for inline test specs
437
+ * that bypass the loader and thus the migration path).
438
+ */
439
+ function buildSectionsMapFromV1(v1Sections: any[]): Record<string, SectionDef> {
440
+ const map: Record<string, SectionDef> = {};
441
+ for (const sec of v1Sections) {
442
+ const { id, anchor_regex, ...rest } = sec;
443
+ const def: SectionDef = { ...rest };
444
+ if (anchor_regex) def.anchor = anchor_regex;
445
+ // v1 until: { section: id } → v3 until: id
446
+ if (rest.until?.section) def.until = rest.until.section;
447
+ else if (rest.until && typeof rest.until === 'string') def.until = rest.until;
448
+ else delete (def as any).until;
449
+ map[id] = def;
450
+ }
451
+ return map;
452
+ }
453
+
454
+ /** Convert v1 SectionRegex (when.regex) to v3 AllCondition. */
455
+ function normalizeV1When(v1When: any): AllCondition {
456
+ if (v1When?.cursor_above_lines && v1When?.changed) {
457
+ return { all: [{ cursor_above: v1When.cursor_above_lines, changed: true as const }] };
458
+ }
459
+ const cond: any = { matches: v1When.regex };
460
+ if (v1When.section) cond.section = v1When.section;
461
+ if (v1When.flags) cond.flags = v1When.flags;
462
+ if (v1When.cursor_row_min !== undefined) cond.cursor_row_min = v1When.cursor_row_min;
463
+ if (v1When.cursor_row_max !== undefined) cond.cursor_row_max = v1When.cursor_row_max;
464
+ if (v1When.cursor_col_min !== undefined) cond.cursor_col_min = v1When.cursor_col_min;
465
+ if (v1When.cursor_col_max !== undefined) cond.cursor_col_max = v1When.cursor_col_max;
466
+ return { all: [cond] };
467
+ }
468
+
469
+ /** Convert v1 extract_title to v3 ExtractTitle. */
470
+ function v1ExtractTitleToV3(v1: any): ExtractTitle {
471
+ if (v1.first_line) return { section: v1.section, first_line: true };
472
+ return { section: v1.section, regex: v1.regex, flags: v1.flags };
473
+ }
474
+
475
+ /** Convert v1 modal_buttons to v3 ExtractButtons. */
476
+ function v1ModalButtonsToV3(v1: any): ExtractButtons | undefined {
477
+ // Use first pattern from patterns[] or single pattern
478
+ const pat = v1.patterns?.length ? v1.patterns[0].pattern : v1.pattern;
479
+ if (!pat) return undefined;
480
+ const flg = v1.patterns?.length ? v1.patterns[0].flags : v1.flags;
481
+ return {
482
+ section: v1.section,
483
+ pattern: pat,
484
+ flags: flg,
485
+ key_for_index: v1.key_for_index,
486
+ min_count: v1.min_count,
487
+ continuation_lines: v1.continuation_lines,
488
+ };
489
+ }
490
+
300
491
  // ────────────────────────────────────────────────────────────────────────────
301
492
  // Public evaluator
302
493
  // ────────────────────────────────────────────────────────────────────────────
@@ -304,19 +495,23 @@ function extractModal(
304
495
  export function evaluate(
305
496
  spec: CliSpec,
306
497
  screenText: string,
307
- /** Optional cursor position (0-based row and col). When supplied, states
308
- * with cursor_row_min/max or cursor_col_min/max predicates are filtered.
309
- * When omitted, cursor predicates are ignored and evaluation is text-only
310
- * (backward-compatible with all existing specs and call sites). */
498
+ /** Optional cursor position (0-based row and col). */
311
499
  cursor?: { row: number; col: number },
500
+ /** Optional previous screen lines for `changed` condition detection. */
501
+ prevLines?: string[],
312
502
  ): SpecEvaluation {
313
503
  const trace: TraceEntry[] = [];
314
504
  const lines = screenText.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
315
- // Use \r-stripped text for all regex matching so that anchor patterns
316
- // like ^[─╌]+$ and full-screen when-regex work correctly against PTY
317
- // output where lines end with \r\n rather than plain \n.
318
505
  const cleanScreen = lines.join('\n');
319
- const sections = resolveSections(spec, lines);
506
+
507
+ // Support both v3 (sections{}) and v1-shaped objects (layout.sections[])
508
+ // The v1 path exists for tests that build raw spec objects without going
509
+ // through the loader (which would normally migrate v1 → v3).
510
+ const specAny = spec as any;
511
+ const effectiveSectionsMap: Record<string, SectionDef> = spec.sections
512
+ ?? buildSectionsMapFromV1(specAny.layout?.sections ?? []);
513
+ const sections = resolveSections(effectiveSectionsMap, lines);
514
+
320
515
  for (const s of sections) {
321
516
  trace.push({ kind: 'section', text: `section[${s.id}] lines [${s.fromLine}, ${s.toLine}) (${s.toLine - s.fromLine} lines)` });
322
517
  }
@@ -328,16 +523,14 @@ export function evaluate(
328
523
  let modal: ModalSnapshot | null = null;
329
524
 
330
525
  for (const st of spec.states) {
331
- const { matched, title } = matchState(st, sections, cleanScreen, trace, cursor);
526
+ const { matched, title } = matchState(st, sections, cleanScreen, trace, cursor, prevLines);
332
527
  if (!matched) continue;
333
528
  const extractedModal = extractModal(st, sections, cleanScreen, title, trace);
334
- // If the state declares modal_buttons but extraction failed (button
335
- // count below min_count, or text-was-mistaken-for-modal), do not
336
- // promote the state. Otherwise we would surface a phantom approval
337
- // built from arbitrary screen text — see claude-cli numbered-list
338
- // false-positive on 2026-06-07.
339
- if (st.modal_buttons && !extractedModal) {
340
- trace.push({ kind: 'state_skip', text: `state[${st.id}] matched but modal_buttons extraction failed — not promoting` });
529
+ // If the state declares extract.buttons (or v1 modal_buttons) but
530
+ // extraction failed, don't promote the state (avoids phantom approvals).
531
+ const stAny = st as any;
532
+ if ((st.extract?.buttons || stAny.modal_buttons) && !extractedModal) {
533
+ trace.push({ kind: 'state_skip', text: `state[${st.id}] matched but extract.buttons failed — not promoting` });
341
534
  continue;
342
535
  }
343
536
  activeState = { id: st.id, label: st.label, title };
@@ -374,9 +567,6 @@ export function evaluate(
374
567
  const delegates: FiredDelegate[] = [];
375
568
  for (const d of spec.delegate ?? []) {
376
569
  if (d.when_state !== activeState.id) continue;
377
- // after_duration_ms is enforced by the driver, not the evaluator
378
- // (the evaluator is pure / stateless). The driver bundles a
379
- // timer per delegate trigger and emits when it fires.
380
570
  delegates.push({ id: d.id, task: interpolate(d.task_template, activeState, sections) });
381
571
  }
382
572