explorbot 0.1.15 → 0.1.17

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.
@@ -3,7 +3,7 @@ import { getStyles } from '../ai/planner/styles.js';
3
3
  import { outputPath } from '../config.js';
4
4
  import { normalizeUrl } from '../state-manager.js';
5
5
  import { Stats } from '../stats.js';
6
- import type { Plan } from '../test-plan.js';
6
+ import { type Plan, type Test, TestResult } from '../test-plan.js';
7
7
  import { getCliName } from '../utils/cli-name.ts';
8
8
  import { ErrorPageError } from '../utils/error-page.ts';
9
9
  import { tag } from '../utils/logger.js';
@@ -13,6 +13,7 @@ import { safeFilename } from '../utils/strings.ts';
13
13
  import { BaseCommand, type Suggestion } from './base-command.js';
14
14
 
15
15
  const MAX_SUB_PAGE_ATTEMPTS = 30;
16
+ const PRIORITY_ORDER: Record<string, number> = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
16
17
 
17
18
  export class ExploreCommand extends BaseCommand {
18
19
  name = 'explore';
@@ -20,6 +21,8 @@ export class ExploreCommand extends BaseCommand {
20
21
  options = [
21
22
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
22
23
  { flags: '--focus <feature>', description: 'Focus area for exploration' },
24
+ { flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
25
+ { flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
23
26
  ];
24
27
  suggestions: Suggestion[] = [
25
28
  { command: 'navigate <page>', hint: 'go to another page' },
@@ -28,9 +31,12 @@ export class ExploreCommand extends BaseCommand {
28
31
  ];
29
32
 
30
33
  maxTests?: number;
34
+ dryRun = false;
31
35
  private testsRun = 0;
32
36
  private completedPlans: Plan[] = [];
33
37
  private failedSubPages = new Set<string>();
38
+ private oldTestRefs = new Set<Test>();
39
+ private priorityFilter?: Set<string>;
34
40
 
35
41
  async execute(args: string): Promise<void> {
36
42
  const { opts, args: remaining } = this.parseArgs(args);
@@ -39,86 +45,359 @@ export class ExploreCommand extends BaseCommand {
39
45
  }
40
46
 
41
47
  const feature = (opts.focus as string) || remaining.join(' ') || undefined;
48
+ const cfg = this.parseConfigure(opts.configure as string | undefined);
49
+ if (cfg.priorities) this.priorityFilter = new Set(cfg.priorities);
50
+ if (opts.dryRun) this.dryRun = true;
51
+ if (this.dryRun) tag('info').log('Dry-run mode: planner runs to discover new tests; test execution is skipped');
42
52
  Stats.mode ??= 'explore';
43
53
  Stats.focus ??= feature;
44
54
  const mainUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
45
55
 
46
- await this.runAllStyles(mainUrl, feature);
56
+ if (cfg.enabled) {
57
+ await this.runReuseMode(mainUrl, feature, cfg);
58
+ } else {
59
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
60
+ }
61
+
62
+ const mainPlan = this.completedPlans[0];
63
+ if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
64
+ if (this.dryRun) {
65
+ this.printResults();
66
+ return;
67
+ }
68
+ if (mainUrl) await this.explorBot.visit(mainUrl);
69
+ const savedPath = this.explorBot.savePlans(this.completedPlans);
70
+ this.printResults();
71
+ await this.explorBot.printSessionAnalysis();
72
+ this.printNextSteps(savedPath);
73
+ }
74
+
75
+ private originLabel(test: Test): string {
76
+ return this.oldTestRefs.has(test) ? 'OLD' : 'NEW';
77
+ }
78
+
79
+ private printPreview(label: string, tests: Test[]): void {
80
+ if (tests.length === 0) return;
81
+ const lines = [label];
82
+ for (let i = 0; i < tests.length; i++) {
83
+ const t = tests[i];
84
+ lines.push(` ${String(i + 1).padStart(2)}. [${this.originLabel(t)}] [${t.priority.padEnd(9)}] ${t.scenario}`);
85
+ }
86
+ tag('multiline').log(lines.join('\n'));
87
+ }
88
+
89
+ private async runFreshMode(mainUrl: string | undefined, feature: string | undefined, styles?: string[]): Promise<void> {
90
+ await this.runAllStyles(mainUrl, feature, undefined, undefined, styles);
47
91
  const mainPlan = this.explorBot.getCurrentPlan();
48
92
  if (!mainPlan) return;
49
93
  this.completedPlans.push(mainPlan);
50
94
 
51
- if (!feature && !this.isLimitReached()) {
52
- const planner = this.explorBot.agentPlanner();
53
- let attempts = 0;
54
- while (attempts < MAX_SUB_PAGE_ATTEMPTS) {
55
- attempts++;
56
- if (this.isLimitReached()) break;
95
+ if (feature || this.isLimitReached()) return;
96
+
97
+ await this.discoverNewSubPages(mainPlan, mainUrl, styles, new Set());
98
+ }
99
+
100
+ private async runReuseMode(mainUrl: string | undefined, feature: string | undefined, cfg: ConfigureSpec): Promise<void> {
101
+ const filename = cfg.fromPath || this.explorBot.generatePlanFilename(feature);
102
+
103
+ let loadedPlans: Plan[] = [];
104
+ try {
105
+ loadedPlans = this.explorBot.loadPlans(filename);
106
+ } catch (err) {
107
+ tag('warning').log(`Reuse plan not found (${err instanceof Error ? err.message : err}); falling back to fresh planning`);
108
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
109
+ return;
110
+ }
57
111
 
58
- const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/').filter((c) => !this.failedSubPages.has(normalizeUrl(c.url)));
59
- if (candidates.length === 0) break;
112
+ if (loadedPlans.length === 0) {
113
+ tag('warning').log('Reuse plan empty; falling back to fresh planning');
114
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
115
+ return;
116
+ }
117
+
118
+ const mainPlan = loadedPlans[0];
119
+ const subPlans = loadedPlans.slice(1);
60
120
 
61
- const pick = await planner.pickNextSubPage(candidates);
62
- if (!pick) break;
121
+ const totalCap = this.maxTests ?? Number.POSITIVE_INFINITY;
122
+ let newQuota = Number.POSITIVE_INFINITY;
123
+ let oldQuota = Number.POSITIVE_INFINITY;
124
+ if (Number.isFinite(totalCap)) {
125
+ newQuota = Math.round(totalCap * cfg.newRatio);
126
+ oldQuota = Math.max(0, totalCap - newQuota);
127
+ }
63
128
 
64
- tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
129
+ for (const p of loadedPlans) {
130
+ for (const t of p.tests) this.oldTestRefs.add(t);
131
+ }
132
+
133
+ const allOldTests = loadedPlans.flatMap((p) => p.tests.filter((t) => t.status === 'pending'));
134
+ let matchingOldTests: Test[] = allOldTests;
135
+ if (cfg.styles) {
136
+ matchingOldTests = matchingOldTests.filter((t) => !t.style || cfg.styles!.includes(t.style));
137
+ }
138
+ if (this.priorityFilter) {
139
+ matchingOldTests = matchingOldTests.filter((t) => this.priorityFilter!.has(t.priority));
140
+ }
141
+ const pickBy = cfg.pickBy ?? 'priority';
142
+ const orderedOldTests = matchingOldTests.slice();
143
+ if (pickBy === 'priority') {
144
+ orderedOldTests.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99));
145
+ } else if (pickBy === 'random') {
146
+ for (let i = orderedOldTests.length - 1; i > 0; i--) {
147
+ const j = Math.floor(Math.random() * (i + 1));
148
+ [orderedOldTests[i], orderedOldTests[j]] = [orderedOldTests[j], orderedOldTests[i]];
149
+ }
150
+ }
151
+
152
+ let pickCount = orderedOldTests.length;
153
+ if (Number.isFinite(oldQuota)) pickCount = Math.min(oldQuota, orderedOldTests.length);
154
+ const picked = orderedOldTests.slice(0, pickCount);
155
+ const pickedSet = new Set(picked);
156
+
157
+ for (const t of allOldTests) {
158
+ if (!pickedSet.has(t)) t.enabled = false;
159
+ }
160
+
161
+ let newQuotaLabel = 'unlimited';
162
+ if (Number.isFinite(newQuota)) newQuotaLabel = String(newQuota);
163
+ let priorityNote = '';
164
+ if (this.priorityFilter) priorityNote = `, priority=[${[...this.priorityFilter].join(',')}]`;
165
+ tag('info').log(`Reuse: loaded ${allOldTests.length} old test(s), running ${picked.length} (pick_by=${pickBy}${priorityNote}), reserving ${newQuotaLabel} for new`);
166
+
167
+ const planner = this.explorBot.agentPlanner();
168
+ for (const p of loadedPlans) planner.registerPlanInSession(p);
169
+
170
+ this.completedPlans.push(...loadedPlans);
171
+
172
+ this.printPreview(`Picked old tests (${picked.length}):`, picked);
173
+
174
+ let currentPlanRef: Plan | undefined;
175
+ for (const test of picked) {
176
+ if (this.isLimitReached()) break;
177
+ const owningPlan = test.plan;
178
+ if (owningPlan && owningPlan !== currentPlanRef) {
179
+ this.explorBot.setCurrentPlan(owningPlan);
180
+ if (owningPlan.url && !this.dryRun) await this.explorBot.visit(owningPlan.url);
181
+ currentPlanRef = owningPlan;
182
+ }
183
+ await this.runOneTest(test);
184
+ }
185
+
186
+ if (this.isLimitReached() || newQuota <= 0) return;
187
+
188
+ const subpagesMode = cfg.subpages || 'both';
189
+
190
+ if (mainUrl && !this.dryRun) await this.explorBot.visit(mainUrl);
191
+ await this.replanAndRun(mainUrl, feature, mainPlan, cfg.styles);
192
+
193
+ if (this.isLimitReached()) return;
194
+
195
+ if (subpagesMode === 'same' || subpagesMode === 'both') {
196
+ for (const subPlan of subPlans) {
197
+ if (this.isLimitReached()) break;
198
+ if (!subPlan.url) continue;
65
199
  try {
66
- await this.explorBot.visit(pick.url);
67
- await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans);
68
- const subPlan = this.explorBot.getCurrentPlan();
69
- if (subPlan) {
70
- this.completedPlans.push(subPlan);
71
- }
200
+ if (!this.dryRun) await this.explorBot.visit(subPlan.url);
201
+ await this.replanAndRun(subPlan.url, undefined, subPlan, cfg.styles);
72
202
  } catch (err) {
73
- this.failedSubPages.add(normalizeUrl(pick.url));
74
- tag('warning').log(`Sub-page exploration failed: ${err instanceof Error ? err.message : err}`);
203
+ this.failedSubPages.add(normalizeUrl(subPlan.url));
204
+ tag('warning').log(`Sub-page re-planning failed: ${err instanceof Error ? err.message : err}`);
75
205
  }
76
206
  }
77
207
  }
78
208
 
79
- this.explorBot.setCurrentPlan(mainPlan);
80
- if (mainUrl) await this.explorBot.visit(mainUrl);
81
- const savedPath = this.explorBot.savePlans(this.completedPlans);
82
- this.printResults();
83
- await this.explorBot.printSessionAnalysis();
84
- this.printNextSteps(savedPath);
209
+ if (this.isLimitReached()) return;
210
+
211
+ if (subpagesMode === 'new' || subpagesMode === 'both') {
212
+ const knownUrls = new Set<string>();
213
+ for (const p of loadedPlans) {
214
+ if (p.url) knownUrls.add(normalizeUrl(p.url));
215
+ }
216
+ await this.discoverNewSubPages(mainPlan, mainUrl, cfg.styles, knownUrls);
217
+ }
218
+ }
219
+
220
+ private async discoverNewSubPages(mainPlan: Plan, mainUrl: string | undefined, styles: string[] | undefined, knownUrls: Set<string>): Promise<void> {
221
+ const planner = this.explorBot.agentPlanner();
222
+ let attempts = 0;
223
+ while (attempts < MAX_SUB_PAGE_ATTEMPTS) {
224
+ attempts++;
225
+ if (this.isLimitReached()) break;
226
+
227
+ const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/').filter((c) => {
228
+ const norm = normalizeUrl(c.url);
229
+ return !this.failedSubPages.has(norm) && !knownUrls.has(norm);
230
+ });
231
+ if (candidates.length === 0) break;
232
+
233
+ const pick = await planner.pickNextSubPage(candidates);
234
+ if (!pick) break;
235
+
236
+ tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
237
+ try {
238
+ await this.explorBot.visit(pick.url);
239
+ await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
240
+ const subPlan = this.explorBot.getCurrentPlan();
241
+ if (subPlan && !this.completedPlans.includes(subPlan)) {
242
+ this.completedPlans.push(subPlan);
243
+ }
244
+ knownUrls.add(normalizeUrl(pick.url));
245
+ } catch (err) {
246
+ this.failedSubPages.add(normalizeUrl(pick.url));
247
+ tag('warning').log(`Sub-page exploration failed: ${err instanceof Error ? err.message : err}`);
248
+ }
249
+ }
85
250
  }
86
251
 
87
- private async runAllStyles(pageUrl?: string, feature?: string, parentPlan?: Plan, completedPlans?: Plan[]): Promise<void> {
252
+ private async replanAndRun(pageUrl: string | undefined, feature: string | undefined, existingPlan: Plan, styles?: string[]): Promise<void> {
253
+ const styleList = styles ?? Object.keys(getStyles());
254
+ for (const style of styleList) {
255
+ if (this.isLimitReached()) break;
256
+ this.explorBot.setCurrentPlan(existingPlan);
257
+ const opts: { fresh: boolean; style: string; completedPlans?: Plan[]; noSave?: boolean } = { fresh: false, style, completedPlans: this.completedPlans };
258
+ if (this.dryRun) opts.noSave = true;
259
+ await this.planWithRetry(feature, opts, pageUrl);
260
+ await this.runPendingTests();
261
+ }
262
+ }
263
+
264
+ private async runAllStyles(pageUrl?: string, feature?: string, parentPlan?: Plan, completedPlans?: Plan[], styles?: string[]): Promise<void> {
265
+ const styleList = styles ?? Object.keys(getStyles());
88
266
  let fresh = true;
89
- for (const style of Object.keys(getStyles())) {
90
- if (!fresh && pageUrl) {
267
+ for (const style of styleList) {
268
+ if (!fresh && pageUrl && !this.dryRun) {
91
269
  await this.explorBot.visit(pageUrl);
92
270
  }
93
- const opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[] } = { fresh, style, completedPlans };
271
+ const opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean } = { fresh, style, completedPlans };
94
272
  if (fresh && parentPlan) opts.extend = parentPlan;
273
+ if (this.dryRun) opts.noSave = true;
95
274
  await this.planWithRetry(feature, opts, pageUrl);
96
275
  await this.runPendingTests();
97
276
  fresh = false;
98
277
  }
99
278
  }
100
279
 
101
- private async planWithRetry(feature: string | undefined, opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[] }, pageUrl?: string): Promise<void> {
102
- await this.explorBot.plan(feature, opts);
103
- if (!this.explorBot.lastPlanError) return;
104
- if (this.explorBot.lastPlanError instanceof ErrorPageError) {
105
- throw this.explorBot.lastPlanError;
106
- }
280
+ private async planWithRetry(feature: string | undefined, opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean }, pageUrl?: string): Promise<void> {
281
+ const before = new Set(this.explorBot.getCurrentPlan()?.tests ?? []);
107
282
 
108
- tag('info').log(`Retrying planning style '${opts.style}'...`);
109
- if (pageUrl) await this.explorBot.visit(pageUrl);
110
283
  await this.explorBot.plan(feature, opts);
111
284
  if (this.explorBot.lastPlanError) {
112
- tag('warning').log(`Planning style '${opts.style}' failed after retry, skipping`);
285
+ if (this.explorBot.lastPlanError instanceof ErrorPageError) {
286
+ throw this.explorBot.lastPlanError;
287
+ }
288
+ tag('info').log(`Retrying planning style '${opts.style}'...`);
289
+ if (pageUrl && !this.dryRun) await this.explorBot.visit(pageUrl);
290
+ await this.explorBot.plan(feature, opts);
291
+ if (this.explorBot.lastPlanError) {
292
+ tag('warning').log(`Planning style '${opts.style}' failed after retry, skipping`);
293
+ return;
294
+ }
295
+ }
296
+
297
+ const planAfter = this.explorBot.getCurrentPlan();
298
+ if (!planAfter) return;
299
+ const added = planAfter.tests.filter((t) => !before.has(t));
300
+ if (added.length === 0) return;
301
+ const urlNote = pageUrl ? ` for ${pageUrl}` : '';
302
+ this.printPreview(`Planner added ${added.length} new test(s) [style=${opts.style}]${urlNote}:`, added);
303
+ }
304
+
305
+ private parseConfigure(raw: string | undefined): ConfigureSpec {
306
+ const cfg: ConfigureSpec = { enabled: false, newRatio: 1.0 };
307
+ if (!raw) return cfg;
308
+
309
+ const allStyles = Object.keys(getStyles());
310
+ const validSubpages = new Set(['none', 'same', 'new', 'both']);
311
+ let hasReuseSignal = false;
312
+
313
+ for (const pair of raw.split(';')) {
314
+ const trimmed = pair.trim();
315
+ if (!trimmed) continue;
316
+ const sepMatch = trimmed.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
317
+ if (!sepMatch) {
318
+ tag('warning').log(`Ignoring malformed configure pair: ${trimmed}`);
319
+ continue;
320
+ }
321
+ const key = sepMatch[1].trim().toLowerCase();
322
+ const value = sepMatch[2].trim();
323
+
324
+ if (key === 'new') {
325
+ const ratio = parseRatio(value);
326
+ if (ratio == null) {
327
+ tag('warning').log(`Ignoring invalid 'new' value: ${value}`);
328
+ continue;
329
+ }
330
+ cfg.newRatio = ratio;
331
+ hasReuseSignal = true;
332
+ continue;
333
+ }
334
+ if (key === 'from') {
335
+ cfg.fromPath = value;
336
+ hasReuseSignal = true;
337
+ continue;
338
+ }
339
+ if (key === 'style' || key === 'styles') {
340
+ const requested = value
341
+ .split(',')
342
+ .map((s) => s.trim())
343
+ .filter(Boolean);
344
+ const valid: string[] = [];
345
+ for (const s of requested) {
346
+ if (allStyles.includes(s)) {
347
+ valid.push(s);
348
+ continue;
349
+ }
350
+ tag('warning').log(`Unknown planning style: ${s}`);
351
+ }
352
+ if (valid.length) cfg.styles = valid;
353
+ continue;
354
+ }
355
+ if (key === 'subpages') {
356
+ if (!validSubpages.has(value)) {
357
+ tag('warning').log(`Ignoring invalid 'subpages' value: ${value}`);
358
+ continue;
359
+ }
360
+ cfg.subpages = value as ConfigureSpec['subpages'];
361
+ continue;
362
+ }
363
+ if (key === 'pick_by' || key === 'pickby' || key === 'pick-by') {
364
+ if (value === 'priority' || value === 'random' || value === 'index') {
365
+ cfg.pickBy = value;
366
+ continue;
367
+ }
368
+ tag('warning').log(`Ignoring invalid 'pick_by' value: ${value} (use priority|random|index)`);
369
+ continue;
370
+ }
371
+ if (key === 'priority' || key === 'priorities') {
372
+ const requested = value
373
+ .split(',')
374
+ .map((s) => s.trim().toLowerCase())
375
+ .filter(Boolean);
376
+ const valid: string[] = [];
377
+ for (const p of requested) {
378
+ if (p in PRIORITY_ORDER) {
379
+ valid.push(p);
380
+ continue;
381
+ }
382
+ tag('warning').log(`Unknown priority: ${p} (use ${Object.keys(PRIORITY_ORDER).join('|')})`);
383
+ }
384
+ if (valid.length) cfg.priorities = valid;
385
+ continue;
386
+ }
387
+ tag('warning').log(`Unknown configure key: ${key}`);
113
388
  }
389
+
390
+ cfg.enabled = hasReuseSignal;
391
+ return cfg;
114
392
  }
115
393
 
116
394
  private printResults(): void {
117
- const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title })));
395
+ const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title }))).sort((a, b) => (a.test.startTime ?? 0) - (b.test.startTime ?? 0));
118
396
 
119
397
  if (allTests.length === 0) return;
120
398
 
121
399
  const hasSubPages = this.completedPlans.length > 1;
400
+ const hasOrigin = this.oldTestRefs.size > 0;
122
401
  const rows = allTests.map(({ test, planTitle }, index) => {
123
402
  const durationMs = test.getDurationMs();
124
403
  const duration = durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '-';
@@ -133,12 +412,16 @@ export class ExploreCommand extends BaseCommand {
133
412
  Time: duration,
134
413
  Steps: String(Object.keys(test.notes).length),
135
414
  };
415
+ if (hasOrigin) {
416
+ row.Origin = this.originLabel(test);
417
+ }
136
418
  if (hasSubPages) {
137
419
  row.Plan = planTitle;
138
420
  }
139
421
  return row;
140
422
  });
141
423
  const columns = ['#', 'Status', 'Title', 'Priority', 'Time', 'Steps'];
424
+ if (hasOrigin) columns.push('Origin');
142
425
  if (hasSubPages) columns.push('Plan');
143
426
  tag('multiline').log(jsonToTable(rows, columns));
144
427
  tag('info').log(`${figureSet.tick} ${allTests.length} tests completed`);
@@ -197,10 +480,47 @@ export class ExploreCommand extends BaseCommand {
197
480
  private async runPendingTests(): Promise<void> {
198
481
  const plan = this.explorBot.getCurrentPlan();
199
482
  if (!plan) return;
483
+ if (this.priorityFilter) {
484
+ for (const t of plan.getPendingTests()) {
485
+ if (!this.priorityFilter.has(t.priority)) t.enabled = false;
486
+ }
487
+ }
200
488
  for (const test of plan.getPendingTests()) {
201
489
  if (this.isLimitReached()) break;
490
+ await this.runOneTest(test);
491
+ }
492
+ }
493
+
494
+ private async runOneTest(test: Test): Promise<void> {
495
+ if (this.dryRun) {
496
+ test.start();
497
+ test.finish(TestResult.SKIPPED);
498
+ } else {
202
499
  await this.explorBot.agentTester().test(test);
203
- this.testsRun++;
204
500
  }
501
+ this.testsRun++;
502
+ }
503
+ }
504
+
505
+ interface ConfigureSpec {
506
+ enabled: boolean;
507
+ newRatio: number;
508
+ fromPath?: string;
509
+ styles?: string[];
510
+ subpages?: 'none' | 'same' | 'new' | 'both';
511
+ pickBy?: 'priority' | 'random' | 'index';
512
+ priorities?: string[];
513
+ }
514
+
515
+ function parseRatio(s: string): number | null {
516
+ const trimmed = s.trim();
517
+ if (!trimmed) return null;
518
+ if (trimmed.endsWith('%')) {
519
+ const n = Number.parseFloat(trimmed.slice(0, -1));
520
+ if (Number.isNaN(n) || n < 0 || n > 100) return null;
521
+ return n / 100;
205
522
  }
523
+ const n = Number.parseFloat(trimmed);
524
+ if (Number.isNaN(n) || n < 0 || n > 1) return null;
525
+ return n;
206
526
  }
package/src/explorbot.ts CHANGED
@@ -27,6 +27,7 @@ import { KnowledgeTracker } from './knowledge-tracker.ts';
27
27
  import { WebPageState } from './state-manager.ts';
28
28
  import type { Suite } from './suite.ts';
29
29
  import { Plan, type Test } from './test-plan.ts';
30
+ import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
30
31
  import { setVerboseMode, tag } from './utils/logger.ts';
31
32
  import { relativeToCwd } from './utils/next-steps.ts';
32
33
  import { sanitizeFilename } from './utils/strings.ts';
@@ -349,7 +350,7 @@ export class ExplorBot {
349
350
  this.agents.planner = undefined;
350
351
  }
351
352
 
352
- async plan(feature?: string, opts: { fresh?: boolean; style?: string; extend?: Plan; completedPlans?: Plan[] } = {}) {
353
+ async plan(feature?: string, opts: { fresh?: boolean; style?: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean } = {}) {
353
354
  this.planFeature = feature;
354
355
 
355
356
  if (opts.fresh) {
@@ -379,7 +380,7 @@ export class ExplorBot {
379
380
  return this.currentPlan;
380
381
  }
381
382
 
382
- this.savePlan();
383
+ if (!opts.noSave) this.savePlan();
383
384
 
384
385
  return this.currentPlan;
385
386
  }
@@ -409,19 +410,20 @@ export class ExplorBot {
409
410
  return planPath;
410
411
  }
411
412
 
412
- generatePlanFilename(): string {
413
+ generatePlanFilename(feature?: string): string {
413
414
  const state = this.explorer?.getStateManager().getCurrentState();
414
415
  const urlPath = state?.url || '/';
415
416
  const urlPart = sanitizeFilename(urlPath) || 'root';
416
417
  const suffix = '.md';
417
- if (!this.planFeature) return urlPart.slice(0, 256 - suffix.length) + suffix;
418
- const featurePart = `_${sanitizeFilename(this.planFeature)}`;
418
+ const f = feature ?? this.planFeature;
419
+ if (!f) return urlPart.slice(0, 256 - suffix.length) + suffix;
420
+ const featurePart = `_${sanitizeFilename(f)}`;
419
421
  const maxFeatureLen = 256 - suffix.length - urlPart.length;
420
422
  if (maxFeatureLen <= 1) return urlPart.slice(0, 256 - suffix.length) + suffix;
421
423
  return urlPart + featurePart.slice(0, maxFeatureLen) + suffix;
422
424
  }
423
425
 
424
- loadPlan(filename: string): Plan {
426
+ resolvePlanPath(filename: string): string {
425
427
  let planPath = filename;
426
428
 
427
429
  if (path.isAbsolute(filename)) {
@@ -438,14 +440,26 @@ export class ExplorBot {
438
440
  }
439
441
  }
440
442
 
443
+ return planPath;
444
+ }
445
+
446
+ loadPlan(filename: string): Plan {
447
+ const planPath = this.resolvePlanPath(filename);
441
448
  if (!existsSync(planPath)) {
442
449
  throw new Error(`Plan file not found: ${planPath}`);
443
450
  }
444
-
445
451
  this.setCurrentPlan(Plan.fromMarkdown(planPath));
446
452
  return this.currentPlan!;
447
453
  }
448
454
 
455
+ loadPlans(filename: string): Plan[] {
456
+ const planPath = this.resolvePlanPath(filename);
457
+ if (!existsSync(planPath)) {
458
+ throw new Error(`Plan file not found: ${planPath}`);
459
+ }
460
+ return parsePlansFromMarkdown(planPath);
461
+ }
462
+
449
463
  setCurrentPlan(plan?: Plan): void {
450
464
  this.currentPlan = plan;
451
465
  if (plan && !this.sessionPlans.includes(plan)) {
@@ -149,8 +149,15 @@ export function parsePlansFromMarkdown(filePath: string): Plan[] {
149
149
 
150
150
  if (line.startsWith('<!-- test')) {
151
151
  currentTest = null;
152
- const priorityMatch = line.match(/priority:\s*(\w+)/);
152
+ let block = line;
153
+ let j = i;
154
+ while (!block.includes('-->') && j + 1 < lines.length) {
155
+ j++;
156
+ block += `\n${lines[j].trim()}`;
157
+ }
158
+ const priorityMatch = block.match(/priority:\s*(\w+)/);
153
159
  priority = (priorityMatch?.[1] as 'critical' | 'important' | 'high' | 'normal' | 'low') || 'normal';
160
+ i = j;
154
161
  continue;
155
162
  }
156
163