@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.
- package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +2 -0
- package/dist/commands/router.d.ts +6 -0
- package/dist/git/git-commands.d.ts +2 -0
- package/dist/git/git-diff.d.ts +6 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +5699 -3486
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5679 -3483
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +6 -0
- package/dist/mesh/mesh-delivery-policy.d.ts +5 -0
- package/dist/mesh/mesh-events-coordinator.d.ts +151 -0
- package/dist/mesh/mesh-events-pending.d.ts +33 -0
- package/dist/mesh/mesh-events-stale.d.ts +40 -0
- package/dist/mesh/mesh-events-utils.d.ts +14 -0
- package/dist/mesh/mesh-events.d.ts +5 -198
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
- package/dist/mesh/mesh-ledger.d.ts +19 -0
- package/dist/mesh/mesh-missions.d.ts +58 -0
- package/dist/mesh/mesh-review-inbox.d.ts +90 -0
- package/dist/mesh/mesh-runtime-store.d.ts +175 -0
- package/dist/mesh/mesh-task-stats.d.ts +49 -0
- package/dist/mesh/mesh-work-queue.d.ts +82 -0
- package/dist/mesh/refine-config.d.ts +24 -2
- package/dist/mesh/worktree-bootstrap-config.d.ts +22 -0
- package/dist/providers/acp-provider-instance.d.ts +2 -0
- package/dist/providers/spec/driver.d.ts +8 -0
- package/dist/providers/spec/evaluator.d.ts +4 -5
- package/dist/providers/spec/loader.d.ts +1 -0
- package/dist/providers/spec/schema.gen.d.ts +1409 -6
- package/dist/providers/spec/types.d.ts +188 -175
- package/dist/repo-mesh-types.d.ts +1 -0
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +3 -0
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +20 -7
- package/src/commands/router.ts +594 -66
- package/src/git/git-commands.ts +5 -5
- package/src/git/git-diff.ts +53 -0
- package/src/index.ts +11 -5
- package/src/mesh/coordinator-prompt.ts +14 -1
- package/src/mesh/mesh-delivery-policy.ts +17 -0
- package/src/mesh/mesh-events-coordinator.ts +1404 -0
- package/src/mesh/mesh-events-pending.ts +371 -0
- package/src/mesh/mesh-events-stale.ts +283 -0
- package/src/mesh/mesh-events-utils.ts +161 -0
- package/src/mesh/mesh-events.ts +27 -2143
- package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
- package/src/mesh/mesh-ledger.ts +134 -2
- package/src/mesh/mesh-missions.ts +151 -0
- package/src/mesh/mesh-review-inbox.ts +307 -0
- package/src/mesh/mesh-runtime-store.ts +539 -3
- package/src/mesh/mesh-task-stats.ts +154 -0
- package/src/mesh/mesh-work-queue.ts +233 -17
- package/src/mesh/refine-config.ts +42 -5
- package/src/mesh/worktree-bootstrap-config.ts +79 -0
- package/src/providers/acp-provider-instance.ts +15 -1
- package/src/providers/cli-provider-instance.ts +34 -13
- package/src/providers/spec/driver.ts +57 -29
- package/src/providers/spec/evaluator.ts +302 -112
- package/src/providers/spec/loader.ts +226 -37
- package/src/providers/spec/schema.gen.ts +450 -334
- package/src/providers/spec/schema.json +162 -75
- package/src/providers/spec/types.ts +234 -183
- 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,
|
|
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
|
-
|
|
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
|
|
92
|
+
for (const [id, sec] of sectionEntries) {
|
|
85
93
|
let from = 0;
|
|
86
94
|
let to = total;
|
|
87
|
-
|
|
95
|
+
|
|
96
|
+
if (sec.anchor !== undefined) {
|
|
88
97
|
try {
|
|
89
|
-
const re = new RegExp(sec.
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
138
|
+
|
|
139
|
+
anchored.set(id, { fromLine: from, toLine: to });
|
|
125
140
|
}
|
|
126
141
|
|
|
127
|
-
// Second pass: apply `until` references.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
153
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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:
|
|
280
|
+
state: SpecStateV3,
|
|
168
281
|
sections: ResolvedSection[],
|
|
169
282
|
fullScreen: string,
|
|
170
283
|
trace: TraceEntry[],
|
|
171
|
-
cursor
|
|
284
|
+
cursor: { row: number; col: number } | undefined,
|
|
285
|
+
prevLines: string[] | undefined,
|
|
172
286
|
): { matched: boolean; title: string | null } {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
301
|
+
trace.push({ kind: 'state_match', text: `state[${state.id}] matched` });
|
|
204
302
|
|
|
205
303
|
let title: string | null = null;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
216
|
-
rule:
|
|
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:
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
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: `
|
|
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: `
|
|
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).
|
|
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
|
-
|
|
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
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|