screenhand 0.3.2 → 0.3.3

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.
@@ -98,12 +98,19 @@ export class Planner {
98
98
  }
99
99
  learningEngine;
100
100
  toolRegistry = null;
101
+ appMap = null;
101
102
  /**
102
103
  * Set the tool registry for LLM plan generation.
103
104
  */
104
105
  setToolRegistry(registry) {
105
106
  this.toolRegistry = registry;
106
107
  }
108
+ /**
109
+ * Set the AppMap for navigation-aware planning and state-aware enrichment.
110
+ */
111
+ setAppMap(map) {
112
+ this.appMap = map;
113
+ }
107
114
  /**
108
115
  * Create a Goal from a description.
109
116
  * Decomposes complex goals into multiple subgoals when the description
@@ -139,17 +146,73 @@ export class Planner {
139
146
  // 1. Try playbook match
140
147
  const playbookPlan = this.findPlaybookPlan(subgoal.description);
141
148
  if (playbookPlan)
142
- return playbookPlan;
149
+ return this.annotateAndReturn(this.enrichWithStateContext(playbookPlan));
143
150
  // 2. Try strategy recall
144
151
  const strategyPlan = this.findStrategyPlan(subgoal.description);
145
152
  if (strategyPlan)
146
- return strategyPlan;
147
- // 3. Try reference flow
153
+ return this.annotateAndReturn(this.enrichWithStateContext(strategyPlan));
154
+ // 3. Try AppMap BFS navigation (Wire #6: L7→L4)
155
+ // State enrichment is already done inside findNavigationPlan
156
+ const navPlan = this.findNavigationPlan(subgoal.description);
157
+ if (navPlan)
158
+ return this.annotateAndReturn(navPlan);
159
+ // 4. Try reference flow
148
160
  const flowPlan = this.findFlowPlan(subgoal.description);
149
161
  if (flowPlan)
150
- return flowPlan;
151
- // 4. Fallback: LLM-generated plan (or stub if no API key)
152
- return this.createLLMPlan(subgoal.description);
162
+ return this.annotateAndReturn(this.enrichWithStateContext(flowPlan));
163
+ // 5. Fallback: LLM-generated plan (or stub if no API key)
164
+ return this.annotateAndReturn(await this.createLLMPlan(subgoal.description));
165
+ }
166
+ /**
167
+ * Wire #8: L7→L4 — Enrich any plan with state machine context.
168
+ * Prepends state-fix steps if needed (e.g. expand sidebar before clicking sidebar items).
169
+ */
170
+ enrichWithStateContext(plan) {
171
+ if (!this.appMap)
172
+ return plan;
173
+ const bundleId = this.getBundleId();
174
+ if (!bundleId)
175
+ return plan;
176
+ const enrichedSteps = this.enrichStepsWithStateContext(plan.steps, bundleId);
177
+ if (enrichedSteps === plan.steps)
178
+ return plan;
179
+ return {
180
+ ...plan,
181
+ steps: enrichedSteps,
182
+ };
183
+ }
184
+ /**
185
+ * Wire #13: L5→L4 — Annotate plan steps with known failure pattern warnings,
186
+ * then return the plan. Called as the final step of planSubgoal().
187
+ */
188
+ annotateAndReturn(plan) {
189
+ if (!this.learningEngine || typeof this.learningEngine.queryPatterns !== "function")
190
+ return plan;
191
+ const bundleId = this.getBundleId();
192
+ if (!bundleId)
193
+ return plan;
194
+ for (const step of plan.steps) {
195
+ const patterns = this.learningEngine.queryPatterns(bundleId, step.tool);
196
+ // Find patterns with strong evidence of failure
197
+ for (const pat of patterns) {
198
+ if (pat.score < 0.4 && pat.failCount >= 3) {
199
+ const target = step.params.target ?? step.params.selector ?? step.params.text ?? step.params.title ?? step.params.name ?? step.params.label ?? step.params.placeholder ?? "";
200
+ // Only warn if target matches the failing pattern — skip if step has no locator-like param
201
+ if (target && pat.locator === target) {
202
+ // Sanitize locator text: strip newlines/control chars, cap length to prevent prompt injection
203
+ const sanitizeLoc = (s) => s.replace(/[\n\r\t\x00-\x1f]/g, " ").slice(0, 100);
204
+ step._patternWarning = `⚠ ${step.tool}: "${sanitizeLoc(pat.locator)}" fails ${pat.failCount}x (score ${pat.score.toFixed(2)})`;
205
+ // Suggest best alternative if available
206
+ const best = patterns.find((p) => p.score > 0.6 && p.locator !== pat.locator);
207
+ if (best) {
208
+ step._patternWarning += ` — try "${sanitizeLoc(best.locator)}" instead (score ${best.score.toFixed(2)})`;
209
+ }
210
+ break; // One warning per step is enough
211
+ }
212
+ }
213
+ }
214
+ }
215
+ return plan;
153
216
  }
154
217
  /**
155
218
  * Plan all subgoals in a goal.
@@ -178,19 +241,42 @@ export class Planner {
178
241
  return null;
179
242
  }
180
243
  subgoal.status = "pending";
244
+ // Wire F2: Ask LearningEngine which recovery strategy works best (L5→L4)
245
+ if (this.learningEngine && subgoal.plan) {
246
+ const bundleId = this.getBundleId();
247
+ if (bundleId) {
248
+ const blockerMap = {
249
+ unexpected_dialog: "unexpected_dialog",
250
+ element_not_found: "element_gone",
251
+ timeout: "loading_stuck",
252
+ };
253
+ const blockerType = blockerMap[reason];
254
+ if (blockerType) {
255
+ const ranked = this.learningEngine.rankRecoveryStrategies(blockerType, bundleId);
256
+ if (ranked.length > 0 && ranked[0].score > 0.6) {
257
+ const strategy = ranked[0];
258
+ const recoveryStep = this.strategyToStep(strategy.strategyId, bundleId);
259
+ if (recoveryStep) {
260
+ subgoal.plan.steps.unshift(recoveryStep);
261
+ subgoal.plan.currentStepIndex = 0;
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
181
267
  // On replan, try alternative sources or adjust params
182
268
  const currentSource = subgoal.plan?.source;
183
269
  // If playbook failed, try strategy
184
270
  if (currentSource === "playbook") {
185
271
  const strategyPlan = this.findStrategyPlan(subgoal.description);
186
272
  if (strategyPlan)
187
- return strategyPlan;
273
+ return this.annotateAndReturn(this.enrichWithStateContext(strategyPlan));
188
274
  }
189
275
  // If strategy failed, try reference flow
190
276
  if (currentSource === "playbook" || currentSource === "strategy") {
191
277
  const flowPlan = this.findFlowPlan(subgoal.description);
192
278
  if (flowPlan)
193
- return flowPlan;
279
+ return this.annotateAndReturn(this.enrichWithStateContext(flowPlan));
194
280
  }
195
281
  // Don't downgrade deterministic plans to LLM stubs.
196
282
  // A 9-step reference_flow failing on step 1 (bridge crash) shouldn't
@@ -205,11 +291,11 @@ export class Planner {
205
291
  if (step.status === "failed")
206
292
  step.status = "pending";
207
293
  }
208
- return subgoal.plan;
294
+ return this.annotateAndReturn(subgoal.plan);
209
295
  }
210
296
  }
211
297
  // Only fall back to LLM when no deterministic plan existed
212
- return this.createLLMPlan(subgoal.description);
298
+ return this.annotateAndReturn(await this.createLLMPlan(subgoal.description));
213
299
  }
214
300
  /**
215
301
  * Check if a goal is complete (all subgoals done or failed).
@@ -244,6 +330,407 @@ export class Planner {
244
330
  getBundleId() {
245
331
  return this.worldModel.getState().focusedApp?.bundleId ?? "";
246
332
  }
333
+ /**
334
+ * Wire F2: Map a recovery strategy ID to a concrete plan step.
335
+ */
336
+ strategyToStep(strategyId, _bundleId) {
337
+ const stepBase = {
338
+ expectedPostcondition: null,
339
+ timeout: 5000,
340
+ fallbackTool: null,
341
+ requiresLLM: false,
342
+ status: "pending",
343
+ };
344
+ if (strategyId === "dismiss_dialog" || strategyId.startsWith("undo_")) {
345
+ return { ...stepBase, tool: "key", params: { key: "Escape" }, description: `Recovery: ${strategyId}` };
346
+ }
347
+ if (strategyId === "refocus") {
348
+ return { ...stepBase, tool: "focus", params: {}, description: "Recovery: refocus app" };
349
+ }
350
+ if (strategyId === "restart_app") {
351
+ return { ...stepBase, tool: "launch", params: {}, timeout: 10000, description: "Recovery: restart app" };
352
+ }
353
+ return null;
354
+ }
355
+ /**
356
+ * Wire #6: L7→L4 — BFS navigation plan from AppMap.
357
+ *
358
+ * Extracts navigation intent from the goal description (e.g. "navigate to Settings",
359
+ * "go from Edit to Deliver", "open the Color page") and uses AppMap's BFS pathfinding
360
+ * to generate a concrete plan without LLM.
361
+ */
362
+ findNavigationPlan(description) {
363
+ if (!this.appMap)
364
+ return null;
365
+ const bundleId = this.getBundleId();
366
+ if (!bundleId)
367
+ return null;
368
+ const nav = this.parseNavigationIntent(description);
369
+ if (!nav)
370
+ return null;
371
+ const path = this.appMap.findPath(bundleId, nav.from, nav.to);
372
+ if (!path || path.length === 0)
373
+ return null;
374
+ // Convert NavEdge[] → PlanStep[]
375
+ const steps = this.navEdgesToSteps(path, bundleId);
376
+ if (steps.length === 0)
377
+ return null;
378
+ // Wire #8: Enrich with state context (prepend state-fix steps if needed)
379
+ const enrichedSteps = this.enrichStepsWithStateContext(steps, bundleId);
380
+ return {
381
+ steps: enrichedSteps,
382
+ currentStepIndex: 0,
383
+ confidence: this.computeNavConfidence(path),
384
+ source: "learned",
385
+ sourceId: `bfs:${nav.from}→${nav.to}`,
386
+ };
387
+ }
388
+ /**
389
+ * Parse navigation intent from a goal description.
390
+ * Patterns:
391
+ * "navigate to Settings" → from = current page, to = "Settings"
392
+ * "go from Edit to Deliver" → from = "Edit", to = "Deliver"
393
+ * "open the Color page" → from = current page, to = "Color"
394
+ * "switch to the Deliver tab" → from = current page, to = "Deliver"
395
+ */
396
+ parseNavigationIntent(description) {
397
+ const desc = description.trim();
398
+ // Pattern 1: "from X to Y" / "from X page to Y page"
399
+ const fromToMatch = desc.match(/\bfrom\s+(?:the\s+)?["']?(\w[\w\s]*?)["']?\s+(?:page\s+|tab\s+)?to\s+(?:the\s+)?["']?(\w[\w\s]*?)["']?(?:\s+(?:page|tab|panel|view))?\s*$/i);
400
+ if (fromToMatch) {
401
+ return { from: fromToMatch[1].trim(), to: fromToMatch[2].trim() };
402
+ }
403
+ // Pattern 2: "navigate/go/switch to X" / "open the X page/tab"
404
+ const toMatch = desc.match(/\b(?:navigate|go|switch|move)\s+to\s+(?:the\s+)?["']?(\w[\w\s]*?)["']?(?:\s+(?:page|tab|panel|view))?\s*$/i);
405
+ if (toMatch) {
406
+ const to = toMatch[1].trim();
407
+ const from = this.getCurrentNavNode();
408
+ return from ? { from, to } : null;
409
+ }
410
+ // Pattern 3: "open the X page/tab/panel"
411
+ const openMatch = desc.match(/\bopen\s+(?:the\s+)?["']?(\w[\w\s]*?)["']?\s+(?:page|tab|panel|view)\s*$/i);
412
+ if (openMatch) {
413
+ const to = openMatch[1].trim();
414
+ const from = this.getCurrentNavNode();
415
+ return from ? { from, to } : null;
416
+ }
417
+ return null;
418
+ }
419
+ /**
420
+ * Get the current navigation node from world model state.
421
+ * Matches the window title against known AppMap nav nodes to find the current page.
422
+ * Falls back to null if no match found (BFS cannot start without a valid from-node).
423
+ */
424
+ getCurrentNavNode() {
425
+ if (!this.appMap)
426
+ return null;
427
+ const bundleId = this.getBundleId();
428
+ if (!bundleId)
429
+ return null;
430
+ const state = this.worldModel.getState();
431
+ if (!state.focusedWindowId)
432
+ return null;
433
+ const win = state.windows.get(state.focusedWindowId);
434
+ if (!win?.title?.value)
435
+ return null;
436
+ const titleLower = win.title.value.toLowerCase();
437
+ // Load the AppMap to access navigation graph node names
438
+ const mapData = this.appMap.load(bundleId);
439
+ if (!mapData)
440
+ return null;
441
+ const nodeKeys = Object.keys(mapData.navigationGraph.nodes);
442
+ if (nodeKeys.length === 0)
443
+ return null;
444
+ // Try exact match first, then substring match against window title
445
+ // Window titles are typically "AppName — PageName" or "PageName - AppName"
446
+ for (const nodeKey of nodeKeys) {
447
+ if (nodeKey.toLowerCase() === titleLower)
448
+ return nodeKey;
449
+ }
450
+ for (const nodeKey of nodeKeys) {
451
+ if (nodeKey.length >= 3 && titleLower.includes(nodeKey.toLowerCase()))
452
+ return nodeKey;
453
+ }
454
+ return null;
455
+ }
456
+ /**
457
+ * Convert a BFS path (NavEdge[]) into executable PlanStep[].
458
+ * Parses the edge.action string to extract tool + params.
459
+ */
460
+ navEdgesToSteps(path, bundleId) {
461
+ const ctx = this.getRuntimeContext();
462
+ const steps = [];
463
+ for (const edge of path) {
464
+ const step = this.parseEdgeAction(edge, bundleId, ctx);
465
+ if (step) {
466
+ steps.push(step);
467
+ }
468
+ else {
469
+ // Can't parse this edge action — create an LLM-required step
470
+ steps.push({
471
+ tool: "",
472
+ params: {},
473
+ expectedPostcondition: { type: "text_visible", target: edge.to },
474
+ timeout: this.config.defaultStepTimeout,
475
+ fallbackTool: null,
476
+ requiresLLM: true,
477
+ status: "pending",
478
+ description: `${edge.action} (navigate from ${edge.from} to ${edge.to})`,
479
+ });
480
+ }
481
+ }
482
+ return steps;
483
+ }
484
+ /**
485
+ * Parse an edge action string into a PlanStep.
486
+ * Edge actions are recorded as "click Submit", "navigate /settings",
487
+ * "key cmd+,", "menu_click File > Preferences", etc.
488
+ */
489
+ parseEdgeAction(edge, _bundleId, ctx) {
490
+ const action = edge.action.trim();
491
+ // "click X" or "click_text X"
492
+ const clickMatch = action.match(/^(?:click_text|click)\s+(.+)$/i);
493
+ if (clickMatch) {
494
+ const target = clickMatch[1].replace(/^["']|["']$/g, "");
495
+ return {
496
+ tool: "click_text",
497
+ params: { text: target, ...(ctx.windowId != null ? { windowId: ctx.windowId } : {}) },
498
+ expectedPostcondition: { type: "text_visible", target: edge.to },
499
+ timeout: this.config.defaultStepTimeout,
500
+ fallbackTool: "click_with_fallback",
501
+ requiresLLM: false,
502
+ status: "pending",
503
+ description: `Click "${target}" to navigate to ${edge.to}`,
504
+ };
505
+ }
506
+ // "ui_press X" or "press X"
507
+ const pressMatch = action.match(/^(?:ui_press|press)\s+(.+)$/i);
508
+ if (pressMatch) {
509
+ const target = pressMatch[1].replace(/^["']|["']$/g, "");
510
+ return {
511
+ tool: "ui_press",
512
+ params: { title: target, ...(ctx.pid != null ? { pid: ctx.pid } : {}) },
513
+ expectedPostcondition: { type: "text_visible", target: edge.to },
514
+ timeout: this.config.defaultStepTimeout,
515
+ fallbackTool: "click_text",
516
+ requiresLLM: false,
517
+ status: "pending",
518
+ description: `Press "${target}" to navigate to ${edge.to}`,
519
+ };
520
+ }
521
+ // "key cmd+," or "key Return"
522
+ const keyMatch = action.match(/^key\s+(.+)$/i);
523
+ if (keyMatch) {
524
+ return {
525
+ tool: "key",
526
+ params: { key: keyMatch[1].trim() },
527
+ expectedPostcondition: { type: "text_visible", target: edge.to },
528
+ timeout: this.config.defaultStepTimeout,
529
+ fallbackTool: null,
530
+ requiresLLM: false,
531
+ status: "pending",
532
+ description: `Press ${keyMatch[1].trim()} to navigate to ${edge.to}`,
533
+ };
534
+ }
535
+ // "navigate URL" or "browser_navigate URL"
536
+ const navMatch = action.match(/^(?:navigate|browser_navigate)\s+(.+)$/i);
537
+ if (navMatch) {
538
+ return {
539
+ tool: "browser_navigate",
540
+ params: { url: navMatch[1].trim() },
541
+ expectedPostcondition: { type: "text_visible", target: edge.to },
542
+ timeout: this.config.defaultStepTimeout,
543
+ fallbackTool: null,
544
+ requiresLLM: false,
545
+ status: "pending",
546
+ description: `Navigate to ${navMatch[1].trim()}`,
547
+ };
548
+ }
549
+ // "menu_click File > Preferences"
550
+ const menuMatch = action.match(/^menu_click\s+(.+)$/i);
551
+ if (menuMatch) {
552
+ const menuPath = menuMatch[1].trim();
553
+ const parts = menuPath.split(/\s*>\s*/);
554
+ return {
555
+ tool: "menu_click",
556
+ params: {
557
+ menu: parts[0],
558
+ ...(parts.length > 1 ? { item: parts.slice(1).join(" > ") } : {}),
559
+ },
560
+ expectedPostcondition: { type: "text_visible", target: edge.to },
561
+ timeout: this.config.defaultStepTimeout,
562
+ fallbackTool: null,
563
+ requiresLLM: false,
564
+ status: "pending",
565
+ description: `Menu: ${menuPath} to navigate to ${edge.to}`,
566
+ };
567
+ }
568
+ return null;
569
+ }
570
+ /**
571
+ * Compute plan confidence from BFS path edges.
572
+ * Higher when all edges are verified with good success rates.
573
+ */
574
+ computeNavConfidence(path) {
575
+ if (path.length === 0)
576
+ return 0.5;
577
+ let totalScore = 0;
578
+ for (const edge of path) {
579
+ const total = edge.successCount + edge.failCount;
580
+ if (total === 0) {
581
+ // Use L5 Bayesian score if available, otherwise default
582
+ totalScore += edge.topologyScore ?? 0.3;
583
+ }
584
+ else {
585
+ const rate = edge.successCount / total;
586
+ const baseScore = edge.verified ? Math.max(0.5, rate) : rate * 0.8;
587
+ // Blend raw rate with L5 TopologyPolicy score when available
588
+ totalScore += edge.topologyScore != null
589
+ ? (baseScore + edge.topologyScore) / 2
590
+ : baseScore;
591
+ }
592
+ }
593
+ return Math.min(0.95, totalScore / path.length);
594
+ }
595
+ /**
596
+ * Wire #8: L7→L4 — Enrich plan steps with state machine context.
597
+ *
598
+ * Checks AppMap state dimensions. If a step's target element is only visible
599
+ * in a specific state (e.g. sidebar must be expanded), and the current state
600
+ * is wrong, prepends state-change steps using known transitions.
601
+ */
602
+ enrichStepsWithStateContext(steps, bundleId) {
603
+ if (!this.appMap)
604
+ return steps;
605
+ const currentState = this.appMap.getCurrentState(bundleId);
606
+ if (Object.keys(currentState).length === 0)
607
+ return steps;
608
+ const visConditions = this.appMap.getConditionalElements(bundleId);
609
+ if (visConditions.length === 0)
610
+ return steps;
611
+ const prependSteps = [];
612
+ const handledDimensions = new Set();
613
+ for (const step of steps) {
614
+ const target = (step.params.text ?? step.params.title ?? step.params.name);
615
+ if (!target)
616
+ continue;
617
+ // Check if this target has visibility conditions tied to state
618
+ const targetLower = target.toLowerCase();
619
+ for (const vc of visConditions) {
620
+ if (vc.conditionType !== "state")
621
+ continue;
622
+ // Require minimum label length to avoid false positives ("Add" matching everything)
623
+ if (vc.elementLabel.length < 4)
624
+ continue;
625
+ if (!vc.elementLabel.toLowerCase().includes(targetLower) &&
626
+ !targetLower.includes(vc.elementLabel.toLowerCase()))
627
+ continue;
628
+ // This element is state-conditional. Check if we need to change state.
629
+ // Look for state dimensions that might affect visibility
630
+ const dimensions = this.appMap.getStateDimensions(bundleId);
631
+ for (const dim of dimensions) {
632
+ if (handledDimensions.has(dim.key))
633
+ continue;
634
+ // Get transitions that might reveal this element
635
+ const transitions = this.appMap.getStateTransitions(bundleId, dim.key);
636
+ // Only fire when transition is unambiguous (single toggle) or leads
637
+ // to a clearly "revealing" state (open/show/visible/expanded)
638
+ const REVEAL_PATTERNS = /\b(open|show|visible|expanded|enabled|on|active)\b/i;
639
+ for (const t of transitions) {
640
+ if (t.fromValue === currentState[dim.key] && t.toValue !== currentState[dim.key]) {
641
+ // Filter: only use this transition if it's unambiguous or leads to a reveal state
642
+ if (transitions.length > 2 && !REVEAL_PATTERNS.test(t.toValue))
643
+ continue;
644
+ const fixStep = this.parseTransitionTrigger(t, bundleId);
645
+ if (fixStep) {
646
+ prependSteps.push(fixStep);
647
+ handledDimensions.add(dim.key);
648
+ break; // one fix step per dimension
649
+ }
650
+ }
651
+ }
652
+ }
653
+ }
654
+ }
655
+ // Wire F3: Zone spatial scroll prepend (L7→L4)
656
+ // If a target element lives in a zone near the bottom of the window, prepend a scroll step.
657
+ if (!handledDimensions.has("__scroll__")) {
658
+ for (const step of steps) {
659
+ const target = (step.params.text ?? step.params.title ?? step.params.name);
660
+ if (!target)
661
+ continue;
662
+ const zone = this.findElementZone(bundleId, target);
663
+ if (!zone)
664
+ continue;
665
+ const pos = zone.relativePosition;
666
+ if (pos.top > 0.85) {
667
+ prependSteps.push({
668
+ tool: "scroll", params: { direction: "down", amount: 300 },
669
+ expectedPostcondition: null, timeout: 3000, fallbackTool: null,
670
+ requiresLLM: false, status: "pending",
671
+ description: "Scroll down to reveal off-screen element",
672
+ });
673
+ handledDimensions.add("__scroll__");
674
+ break;
675
+ }
676
+ else if (pos.top < 0.05 && pos.height < 0.1) {
677
+ prependSteps.push({
678
+ tool: "scroll", params: { direction: "up", amount: 300 },
679
+ expectedPostcondition: null, timeout: 3000, fallbackTool: null,
680
+ requiresLLM: false, status: "pending",
681
+ description: "Scroll up to reveal off-screen element",
682
+ });
683
+ handledDimensions.add("__scroll__");
684
+ break;
685
+ }
686
+ }
687
+ }
688
+ return prependSteps.length > 0 ? [...prependSteps, ...steps] : steps;
689
+ }
690
+ /**
691
+ * Wire F3: Find the zone containing a target element in AppMap.
692
+ */
693
+ findElementZone(bundleId, label) {
694
+ if (!this.appMap)
695
+ return null;
696
+ const data = this.appMap.load(bundleId);
697
+ if (!data)
698
+ return null;
699
+ const labelLower = label.toLowerCase();
700
+ for (const zone of Object.values(data.zones)) {
701
+ for (const el of zone.elements) {
702
+ if (el.label.toLowerCase() === labelLower) {
703
+ return zone;
704
+ }
705
+ }
706
+ }
707
+ return null;
708
+ }
709
+ /**
710
+ * Parse a state transition trigger into a PlanStep.
711
+ */
712
+ parseTransitionTrigger(transition, _bundleId) {
713
+ const trigger = transition.trigger.trim();
714
+ if (!trigger)
715
+ return null;
716
+ // Try parsing as "click X", "key X", "press X", etc. — reuse edge action parsing
717
+ const ctx = this.getRuntimeContext();
718
+ const fakeEdge = {
719
+ from: transition.fromValue,
720
+ action: trigger,
721
+ to: transition.toValue,
722
+ verified: transition.observedCount >= 2,
723
+ successCount: transition.observedCount,
724
+ failCount: 0,
725
+ lastUsed: transition.lastSeen,
726
+ };
727
+ const step = this.parseEdgeAction(fakeEdge, _bundleId, ctx);
728
+ if (step) {
729
+ step.description = `[L7→L4 state fix] ${trigger} (${transition.dimensionKey}: ${transition.fromValue} → ${transition.toValue})`;
730
+ step.expectedPostcondition = null; // state change is the postcondition itself
731
+ }
732
+ return step;
733
+ }
247
734
  findPlaybookPlan(description) {
248
735
  // Try task-based match only — don't unconditionally use the active playbook
249
736
  // here, because that would shadow findFlowPlan() which also uses the active
@@ -429,6 +916,18 @@ export class Planner {
429
916
  if (active) {
430
917
  lines.push(`Platform reference loaded: ${active.platform ?? active.id}`);
431
918
  }
919
+ // Wire #8: Include AppMap state dimensions for state-aware LLM planning
920
+ if (this.appMap) {
921
+ const bundleId = this.getBundleId();
922
+ if (bundleId) {
923
+ const currentState = this.appMap.getCurrentState(bundleId);
924
+ const stateEntries = Object.entries(currentState);
925
+ if (stateEntries.length > 0) {
926
+ const stateStr = stateEntries.map(([k, v]) => `${k}=${v}`).join(", ");
927
+ lines.push(`App state: ${stateStr}`);
928
+ }
929
+ }
930
+ }
432
931
  return lines.length > 0 ? `\nRuntime context:\n${lines.join("\n")}` : "";
433
932
  }
434
933
  async createLLMPlan(description) {
@@ -108,7 +108,17 @@ export class PlaybookEngine {
108
108
  /**
109
109
  * Execute a single playbook step.
110
110
  */
111
+ /** Tools that the PlaybookEngine refuses to execute (defense in depth). */
112
+ static BLOCKED_ACTIONS = new Set([
113
+ "applescript", "browser_stealth",
114
+ "memory_save", "memory_clear", "memory_snapshot",
115
+ "supervisor_start", "supervisor_stop", "supervisor_install", "supervisor_uninstall",
116
+ "job_create", "worker_start",
117
+ ]);
111
118
  async executeStep(sessionId, step, cdpPort) {
119
+ if (PlaybookEngine.BLOCKED_ACTIONS.has(step.action)) {
120
+ throw new Error(`Blocked: playbook step uses restricted action "${step.action}"`);
121
+ }
112
122
  const target = this.resolveTarget(step.target);
113
123
  switch (step.action) {
114
124
  case "navigate": {