@adhdev/daemon-core 0.9.82-rc.220 → 0.9.82-rc.221

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.
@@ -1,325 +0,0 @@
1
- /**
2
- * spec.json loader + strict validator.
3
- *
4
- * Supports both v1 (adhdev:cli/spec@1) and v3 (adhdev:cli/spec@3).
5
- * v1 specs are auto-migrated to v3 before validation.
6
- * Rejects spec files that don't conform to the schema.
7
- */
8
- 'use strict';
9
-
10
- import * as fs from 'node:fs';
11
- import * as path from 'node:path';
12
- import Ajv from 'ajv';
13
- import type { CliSpec, SectionDef, Condition, ExtractTitle, ExtractButtons } from './types.js';
14
- import { SCHEMA_V1, SCHEMA_V3 } from './schema.gen.js';
15
-
16
- const ajvV1 = new Ajv({ allErrors: true, strict: false });
17
- const validateV1 = ajvV1.compile(SCHEMA_V1);
18
-
19
- const ajvV3 = new Ajv({ allErrors: true, strict: false });
20
- const validateV3 = ajvV3.compile<CliSpec>(SCHEMA_V3);
21
-
22
- export interface SpecLoadResult {
23
- ok: true;
24
- spec: CliSpec;
25
- sourcePath: string;
26
- }
27
-
28
- export interface SpecLoadError {
29
- ok: false;
30
- errors: string[];
31
- sourcePath: string;
32
- }
33
-
34
- export function loadSpec(sourcePath: string): SpecLoadResult | SpecLoadError {
35
- let raw: unknown;
36
- try {
37
- const text = fs.readFileSync(sourcePath, 'utf8');
38
- raw = JSON.parse(text);
39
- } catch (err) {
40
- return { ok: false, errors: [`Failed to read spec: ${(err as Error).message}`], sourcePath };
41
- }
42
-
43
- // Auto-migrate v1 → v3
44
- const schemaStr = (raw as any)?.$schema;
45
- if (schemaStr === 'adhdev:cli/spec@1') {
46
- // Validate as v1 first
47
- if (!validateV1(raw)) {
48
- const errors = (validateV1.errors || []).map(e => `${e.instancePath || '(root)'} ${e.message ?? 'invalid'} (v1 parse)`);
49
- return { ok: false, errors, sourcePath };
50
- }
51
- raw = migrateV1toV3(raw as any);
52
- }
53
-
54
- if (!validateV3(raw)) {
55
- const errors = (validateV3.errors || []).map(e => `${e.instancePath || '(root)'} ${e.message ?? 'invalid'}`);
56
- return { ok: false, errors, sourcePath };
57
- }
58
-
59
- const spec = raw as CliSpec;
60
- const extra = validateRefs(spec);
61
- if (extra.length > 0) return { ok: false, errors: extra, sourcePath };
62
-
63
- // Populate legacy `debounce` alias from `timing` for backward compat
64
- attachDebounceAlias(spec);
65
-
66
- return { ok: true, spec, sourcePath };
67
- }
68
-
69
- /**
70
- * Populate spec.debounce as a backward-compat alias for spec.timing.
71
- * The driver and tests read debounce.{busy_hold_ms, idle_hold_ms, ...}.
72
- * After this call both fields point at equivalent data.
73
- */
74
- function attachDebounceAlias(spec: CliSpec): void {
75
- const t = spec.timing;
76
- if (!t) return;
77
- const cm = t.completion_marker;
78
- (spec as any).debounce = {
79
- ...(t.busy_hold_ms !== undefined ? { busy_hold_ms: t.busy_hold_ms } : {}),
80
- ...(t.idle_hold_ms !== undefined ? { idle_hold_ms: t.idle_hold_ms } : {}),
81
- ...(t.startup_grace_ms !== undefined ? { startup_grace_ms: t.startup_grace_ms } : {}),
82
- ...(t.screen_active_hold_ms !== undefined ? { screen_active_hold_ms: t.screen_active_hold_ms } : {}),
83
- ...(cm ? {
84
- completion_idle_after: {
85
- ...(cm.section ? { section: cm.section } : {}),
86
- regex: cm.matches,
87
- ...(cm.flags ? { flags: cm.flags } : {}),
88
- hold_ms: cm.hold_ms,
89
- ...(cm.force_after_ms !== undefined ? { force_after_ms: cm.force_after_ms } : {}),
90
- },
91
- } : {}),
92
- };
93
- }
94
-
95
- // ─────────────────────────────────────────────────────────────────────────────
96
- // Migration: v1 → v3
97
- // ─────────────────────────────────────────────────────────────────────────────
98
-
99
- export function migrateV1toV3(raw: any): any {
100
- // sections: array → object
101
- const sections: Record<string, SectionDef> = {};
102
- for (const sec of raw.layout?.sections ?? []) {
103
- const { id, anchor_regex, until_regex, until_regex_flags, ...rest } = sec;
104
- const sectionDef: any = { ...rest };
105
-
106
- // anchor_regex → anchor
107
- if (anchor_regex) sectionDef.anchor = anchor_regex;
108
-
109
- // until: { section: id } → until: id (section reference)
110
- if (rest.until?.section) {
111
- sectionDef.until = rest.until.section;
112
- } else {
113
- delete sectionDef.until;
114
- }
115
-
116
- // keep until_regex for anchor-based extension
117
- if (until_regex) sectionDef.until_regex = until_regex;
118
- if (until_regex_flags) sectionDef.until_regex_flags = until_regex_flags;
119
-
120
- sections[id] = sectionDef;
121
- }
122
-
123
- // states: convert when/extract_title/modal_buttons
124
- const states = (raw.states ?? []).map((s: any) => {
125
- // Convert when: SectionRegex → when: AllCondition | AnyCondition
126
- let when: any;
127
- if (s.when?.cursor_above_lines && s.when?.changed) {
128
- // v1 delta detection condition
129
- when = {
130
- any: [
131
- { cursor_above: s.when.cursor_above_lines, changed: true as const },
132
- ],
133
- };
134
- } else if (s.when?.regex) {
135
- const regexCond: any = {
136
- section: s.when.section,
137
- matches: s.when.regex,
138
- ...(s.when.flags ? { flags: s.when.flags } : {}),
139
- ...(s.when.cursor_row_min !== undefined ? { cursor_row_min: s.when.cursor_row_min } : {}),
140
- ...(s.when.cursor_row_max !== undefined ? { cursor_row_max: s.when.cursor_row_max } : {}),
141
- ...(s.when.cursor_col_min !== undefined ? { cursor_col_min: s.when.cursor_col_min } : {}),
142
- ...(s.when.cursor_col_max !== undefined ? { cursor_col_max: s.when.cursor_col_max } : {}),
143
- };
144
- if (!regexCond.section) delete regexCond.section;
145
- when = { all: [regexCond] };
146
- } else {
147
- // No regex — empty all (always matches)
148
- when = { all: [] };
149
- }
150
-
151
- const extract: any = {};
152
- if (s.extract_title) {
153
- if (s.extract_title.first_line) {
154
- extract.title = { section: s.extract_title.section, first_line: true as const };
155
- if (!extract.title.section) delete extract.title.section;
156
- } else if (s.extract_title.regex) {
157
- extract.title = {
158
- section: s.extract_title.section,
159
- regex: s.extract_title.regex,
160
- ...(s.extract_title.flags ? { flags: s.extract_title.flags } : {}),
161
- };
162
- if (!extract.title.section) delete extract.title.section;
163
- }
164
- }
165
- if (s.modal_buttons) {
166
- const mb = s.modal_buttons;
167
- // Use first pattern from patterns[] array, or single pattern
168
- const pat = mb.patterns?.length ? mb.patterns[0].pattern : mb.pattern;
169
- const flg = mb.patterns?.length ? mb.patterns[0].flags : mb.flags;
170
- if (pat) {
171
- extract.buttons = {
172
- ...(mb.section ? { section: mb.section } : {}),
173
- pattern: pat,
174
- ...(flg ? { flags: flg } : {}),
175
- key_for_index: mb.key_for_index,
176
- ...(mb.min_count !== undefined ? { min_count: mb.min_count } : {}),
177
- ...(mb.continuation_lines !== undefined ? { continuation_lines: mb.continuation_lines } : {}),
178
- };
179
- }
180
- }
181
-
182
- return {
183
- id: s.id,
184
- label: s.label,
185
- when,
186
- ...(Object.keys(extract).length > 0 ? { extract } : {}),
187
- };
188
- });
189
-
190
- // timing (from debounce)
191
- const d = raw.debounce ?? {};
192
- const timing: any = {};
193
- if (d.busy_hold_ms !== undefined) timing.busy_hold_ms = d.busy_hold_ms;
194
- if (d.idle_hold_ms !== undefined) timing.idle_hold_ms = d.idle_hold_ms;
195
- if (d.startup_grace_ms !== undefined) timing.startup_grace_ms = d.startup_grace_ms;
196
- if (d.completion_idle_after) {
197
- timing.completion_marker = {
198
- ...(d.completion_idle_after.section ? { section: d.completion_idle_after.section } : {}),
199
- matches: d.completion_idle_after.regex,
200
- ...(d.completion_idle_after.flags ? { flags: d.completion_idle_after.flags } : {}),
201
- hold_ms: d.completion_idle_after.hold_ms,
202
- ...(d.completion_idle_after.force_after_ms !== undefined
203
- ? { force_after_ms: d.completion_idle_after.force_after_ms } : {}),
204
- };
205
- }
206
-
207
- return {
208
- $schema: 'adhdev:cli/spec@3',
209
- id: raw.id,
210
- name: raw.name,
211
- binary: raw.binary,
212
- ...(raw.cli_version_range ? { cli_version_range: raw.cli_version_range } : {}),
213
- ...(raw.spawn_args ? { spawn_args: raw.spawn_args } : {}),
214
- ...(raw.env ? { env: raw.env } : {}),
215
- send_message: raw.send_message,
216
- sections,
217
- states,
218
- default_state: raw.default_state,
219
- ...(Object.keys(timing).length > 0 ? { timing } : {}),
220
- ...(raw.control_bar ? { control_bar: raw.control_bar } : {}),
221
- ...(raw.notifications ? { notifications: raw.notifications } : {}),
222
- ...(raw.delegate ? { delegate: raw.delegate } : {}),
223
- ...(raw.native_history ? { native_history: raw.native_history } : {}),
224
- ...(raw.requiresFinalAssistantBeforeIdle
225
- ? { requiresFinalAssistantBeforeIdle: raw.requiresFinalAssistantBeforeIdle } : {}),
226
- };
227
- }
228
-
229
- // ─────────────────────────────────────────────────────────────────────────────
230
- // Cross-field validation
231
- // ─────────────────────────────────────────────────────────────────────────────
232
-
233
- function validateRefs(spec: CliSpec): string[] {
234
- const errs: string[] = [];
235
- const sectionIds = new Set(Object.keys(spec.sections));
236
- const stateIds = new Set(spec.states.map(s => s.id));
237
-
238
- if (!stateIds.has(spec.default_state)) {
239
- errs.push(`default_state "${spec.default_state}" is not defined in states[]`);
240
- }
241
-
242
- for (const s of spec.states) {
243
- validateConditionRefs(s.when, `states[${s.id}].when`, sectionIds, errs);
244
- const ext = s.extract;
245
- if (ext?.title) {
246
- if (ext.title.section && !sectionIds.has(ext.title.section)) {
247
- errs.push(`states[${s.id}].extract.title.section "${ext.title.section}" unknown`);
248
- }
249
- if (ext.title.regex) {
250
- compileRegexCheck(ext.title.regex, ext.title.flags, `states[${s.id}].extract.title.regex`, errs);
251
- }
252
- }
253
- if (ext?.buttons) {
254
- if (ext.buttons.section && !sectionIds.has(ext.buttons.section)) {
255
- errs.push(`states[${s.id}].extract.buttons.section "${ext.buttons.section}" unknown`);
256
- }
257
- compileRegexCheck(ext.buttons.pattern, ext.buttons.flags ?? 'm', `states[${s.id}].extract.buttons.pattern`, errs);
258
- }
259
- }
260
-
261
- for (const c of spec.control_bar ?? []) {
262
- for (const stId of c.visible_when_state ?? []) {
263
- if (!stateIds.has(stId)) errs.push(`control_bar[${c.id}].visible_when_state references unknown state "${stId}"`);
264
- }
265
- if (c.action.type === 'open_picker') {
266
- if (c.action.wait_for.section && !sectionIds.has(c.action.wait_for.section ?? '')) {
267
- errs.push(`control_bar[${c.id}].action.wait_for.section "${c.action.wait_for.section}" unknown`);
268
- }
269
- if (c.action.wait_for.regex) {
270
- compileRegexCheck(c.action.wait_for.regex, c.action.wait_for.flags, `control_bar[${c.id}].action.wait_for.regex`, errs);
271
- }
272
- if (c.action.extract_choices.section && !sectionIds.has(c.action.extract_choices.section)) {
273
- errs.push(`control_bar[${c.id}].action.extract_choices.section "${c.action.extract_choices.section}" unknown`);
274
- }
275
- compileRegexCheck(c.action.extract_choices.pattern, c.action.extract_choices.flags ?? 'm', `control_bar[${c.id}].action.extract_choices.pattern`, errs);
276
- }
277
- }
278
-
279
- for (const n of spec.notifications ?? []) {
280
- if (!stateIds.has(n.when_state)) errs.push(`notifications[${n.id}].when_state "${n.when_state}" unknown`);
281
- }
282
- for (const d of spec.delegate ?? []) {
283
- if (!stateIds.has(d.when_state)) errs.push(`delegate[${d.id}].when_state "${d.when_state}" unknown`);
284
- }
285
-
286
- // native_history: exactly one of {reader, source, override_path}.
287
- const nh = spec.native_history;
288
- if (nh) {
289
- const modes = (['reader', 'source', 'override_path'] as const).filter(k => (nh as Record<string, unknown>)[k] !== undefined);
290
- if (modes.length === 0) {
291
- errs.push('native_history must set exactly one of {reader, source, override_path}');
292
- } else if (modes.length > 1) {
293
- errs.push(`native_history sets ${modes.length} modes (${modes.join(', ')}); pick exactly one`);
294
- }
295
- }
296
-
297
- return errs;
298
- }
299
-
300
- function validateConditionRefs(cond: any, where: string, sectionIds: Set<string>, errs: string[]): void {
301
- if (!cond) return;
302
- if ('all' in cond) {
303
- for (const c of cond.all ?? []) validateConditionRefs(c, where, sectionIds, errs);
304
- } else if ('any' in cond) {
305
- for (const c of cond.any ?? []) validateConditionRefs(c, where, sectionIds, errs);
306
- } else if ('matches' in cond) {
307
- if (cond.section && !sectionIds.has(cond.section)) {
308
- errs.push(`${where}.section "${cond.section}" unknown`);
309
- }
310
- compileRegexCheck(cond.matches, cond.flags, `${where}.matches`, errs);
311
- }
312
- // ChangedCondition has no section refs
313
- }
314
-
315
- function compileRegexCheck(source: string | undefined, flags: string | undefined, where: string, errs: string[]): void {
316
- if (!source) return;
317
- try { new RegExp(source, flags ?? ''); } catch (err) {
318
- errs.push(`${where} regex invalid: ${(err as Error).message}`);
319
- }
320
- }
321
-
322
- /** Convenience: look up a provider's spec.json next to its provider dir. */
323
- export function resolveSpecPath(providerDir: string): string {
324
- return path.join(providerDir, 'spec.json');
325
- }