screenhand 0.5.0 → 0.5.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.
Files changed (33) hide show
  1. package/dist/mcp-desktop.js +463 -39
  2. package/dist/src/community/publisher.js +4 -2
  3. package/dist/src/context-tracker.js +62 -6
  4. package/dist/src/ingestion/reference-merger.js +33 -0
  5. package/dist/src/memory/recall.js +65 -1
  6. package/dist/src/memory/research.js +1 -1
  7. package/dist/src/memory/service.js +26 -5
  8. package/dist/src/memory/store.js +42 -23
  9. package/dist/src/native/bridge-client.js +3 -3
  10. package/dist/src/perception/coordinator.js +94 -15
  11. package/dist/src/perception/manager.js +65 -1
  12. package/dist/src/planner/executor.js +6 -2
  13. package/dist/src/planner/plan-refiner.js +213 -0
  14. package/dist/src/playbook/engine.js +18 -3
  15. package/dist/src/playbook/recorder.js +24 -8
  16. package/dist/src/playbook/runner.js +9 -3
  17. package/dist/src/playbook/store.js +8 -0
  18. package/dist/src/recovery/engine.js +9 -3
  19. package/dist/src/state/app-map.js +212 -2
  20. package/dist/src/state/state-watcher.js +144 -0
  21. package/dist/src/state/visual-mapper.js +325 -0
  22. package/dist/src/state/world-model.js +30 -1
  23. package/dist/src/supervisor/supervisor.js +1 -1
  24. package/dist-app-maps/com.apple.Notes.json +2328 -2201
  25. package/dist-app-maps/com.apple.Terminal.json +331 -343
  26. package/dist-app-maps/com.apple.iCal.json +3 -3
  27. package/dist-app-maps/com.apple.iphonesimulator.json +714 -223
  28. package/dist-app-maps/com.apple.mail.json +3 -3
  29. package/dist-app-maps/com.apple.reminders.json +2 -2
  30. package/dist-app-maps/net.whatsapp.WhatsApp.json +27 -27
  31. package/dist-references/notes.json +53 -16
  32. package/dist-references/simulator.json +48 -2
  33. package/package.json +1 -1
@@ -150,7 +150,9 @@ export class PerceptionManager extends EventEmitter {
150
150
  windowId = (frontmost ?? matching[0])?.windowId;
151
151
  }
152
152
  }
153
- catch { /* best-effort */ }
153
+ catch (e) {
154
+ process.stderr.write(`[perception-mgr] window ID lookup failed: ${e instanceof Error ? e.message : String(e)}\n`);
155
+ }
154
156
  const ctx = {
155
157
  bundleId: focusedApp.bundleId,
156
158
  appName: focusedApp.bundleId,
@@ -189,9 +191,44 @@ export class PerceptionManager extends EventEmitter {
189
191
  notifyToolCall() {
190
192
  this.coordinator?.notifyToolCall();
191
193
  }
194
+ // ── Focus/Crash Tracking ──
195
+ expectedBundleId = null;
196
+ lastUIChangeTs = Date.now();
197
+ stallCheckInterval = null;
198
+ /** Set the expected focused app — enables focus_lost and app_crash detection. */
199
+ setExpectedApp(bundleId) {
200
+ this.expectedBundleId = bundleId;
201
+ this.lastUIChangeTs = Date.now();
202
+ }
203
+ /** Start stall detection (fires stall_detected if no UI changes for stallMs). */
204
+ startStallDetection(stallMs = 30_000) {
205
+ this.stopStallDetection();
206
+ this.lastUIChangeTs = Date.now();
207
+ this.stallCheckInterval = setInterval(() => {
208
+ if (!this.expectedBundleId)
209
+ return;
210
+ const elapsed = Date.now() - this.lastUIChangeTs;
211
+ if (elapsed >= stallMs) {
212
+ this.emit("stall_detected", {
213
+ bundleId: this.expectedBundleId,
214
+ stallMs: elapsed,
215
+ });
216
+ // Reset so we don't fire every interval tick
217
+ this.lastUIChangeTs = Date.now();
218
+ }
219
+ }, 5_000);
220
+ }
221
+ stopStallDetection() {
222
+ if (this.stallCheckInterval) {
223
+ clearInterval(this.stallCheckInterval);
224
+ this.stallCheckInterval = null;
225
+ }
226
+ }
192
227
  handleReactiveEvent(event) {
193
228
  if (event.data?.type === "ax_events" && Array.isArray(event.data.events)) {
194
229
  for (const uiEvent of event.data.events) {
230
+ // Track any UI change for stall detection
231
+ this.lastUIChangeTs = Date.now();
195
232
  if (uiEvent.type === "dialog_appeared") {
196
233
  this.emit("dialog_detected", {
197
234
  title: uiEvent.windowTitle ?? "",
@@ -205,6 +242,33 @@ export class PerceptionManager extends EventEmitter {
205
242
  bundleId: uiEvent.bundleId,
206
243
  pid: uiEvent.pid,
207
244
  });
245
+ // Focus loss detection: if we expected a specific app and a different one took focus
246
+ if (this.expectedBundleId && uiEvent.bundleId !== this.expectedBundleId) {
247
+ this.emit("focus_lost", {
248
+ expectedBundleId: this.expectedBundleId,
249
+ actualBundleId: uiEvent.bundleId,
250
+ pid: uiEvent.pid,
251
+ });
252
+ }
253
+ }
254
+ // App crash detection: when expected app deactivates, check if PID is still alive.
255
+ // Only emit app_crash if the process is actually gone (not just lost focus).
256
+ if (uiEvent.type === "app_deactivated" &&
257
+ uiEvent.bundleId &&
258
+ uiEvent.bundleId === this.expectedBundleId &&
259
+ uiEvent.pid) {
260
+ try {
261
+ // process.kill(pid, 0) throws if PID doesn't exist
262
+ process.kill(uiEvent.pid, 0);
263
+ // PID alive — just a focus switch, handled by focus_lost above
264
+ }
265
+ catch {
266
+ // PID dead — app crashed
267
+ this.emit("app_crash", {
268
+ bundleId: uiEvent.bundleId,
269
+ pid: uiEvent.pid,
270
+ });
271
+ }
208
272
  }
209
273
  }
210
274
  }
@@ -429,7 +429,9 @@ export class PlanExecutor {
429
429
  }
430
430
  }
431
431
  }
432
- catch { /* best-effort contract check */ }
432
+ catch (e) {
433
+ process.stderr.write(`[planner] contract precondition check failed: ${e instanceof Error ? e.message : String(e)}\n`);
434
+ }
433
435
  }
434
436
  }
435
437
  // 4. Focus validation: for type_text, verify a text field is focused
@@ -785,7 +787,9 @@ export class PlanExecutor {
785
787
  }
786
788
  }
787
789
  }
788
- catch { /* best-effort */ }
790
+ catch (e) {
791
+ process.stderr.write(`[planner] contract outcome inference failed: ${e instanceof Error ? e.message : String(e)}\n`);
792
+ }
789
793
  }
790
794
  }
791
795
  // Navigation or state-changing click → next step's target should be visible
@@ -0,0 +1,213 @@
1
+ // Copyright (C) 2025 Clazro Technology Private Limited
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ //
4
+ // This file is part of ScreenHand.
5
+ //
6
+ // ScreenHand is free software: you can redistribute it and/or modify
7
+ // it under the terms of the GNU Affero General Public License as
8
+ // published by the Free Software Foundation, version 3.
9
+ //
10
+ // ScreenHand is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
17
+ /**
18
+ * PlanRefiner — Self-improving plans.
19
+ *
20
+ * After a successful plan execution, diffs the original plan vs what actually
21
+ * happened (steps skipped, fallbacks used, extra retries). Produces a refined
22
+ * plan stripped of dead steps. After 3 successful refinements of the same goal
23
+ * pattern, graduates the plan to a playbook.
24
+ */
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { writeFileAtomicSync } from "../util/atomic-write.js";
28
+ const GRADUATION_THRESHOLD = 3;
29
+ const MAX_REFINED_PLANS = 200;
30
+ export class PlanRefiner {
31
+ stateDir;
32
+ cache = new Map();
33
+ filePath;
34
+ constructor(stateDir) {
35
+ this.stateDir = stateDir;
36
+ this.filePath = path.join(stateDir, "refined-plans.json");
37
+ this.load();
38
+ }
39
+ load() {
40
+ try {
41
+ if (fs.existsSync(this.filePath)) {
42
+ const data = JSON.parse(fs.readFileSync(this.filePath, "utf-8"));
43
+ if (Array.isArray(data)) {
44
+ for (const entry of data) {
45
+ if (entry.goalKey && typeof entry.goalKey === "string") {
46
+ this.cache.set(entry.goalKey, entry);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ catch {
53
+ // Corrupted file — start fresh
54
+ this.cache.clear();
55
+ }
56
+ }
57
+ save() {
58
+ try {
59
+ // Evict oldest graduated plans if over limit
60
+ if (this.cache.size > MAX_REFINED_PLANS) {
61
+ const sorted = [...this.cache.entries()]
62
+ .sort((a, b) => new Date(a[1].lastRefinedAt).getTime() - new Date(b[1].lastRefinedAt).getTime());
63
+ while (this.cache.size > MAX_REFINED_PLANS && sorted.length > 0) {
64
+ const oldest = sorted.shift();
65
+ this.cache.delete(oldest[0]);
66
+ }
67
+ }
68
+ const data = [...this.cache.values()];
69
+ writeFileAtomicSync(this.filePath, JSON.stringify(data, null, 2));
70
+ }
71
+ catch (err) {
72
+ process.stderr.write(`[plan-refiner] Save failed: ${err instanceof Error ? err.message : String(err)}\n`);
73
+ }
74
+ }
75
+ normalizeGoal(description) {
76
+ return description.toLowerCase().trim().replace(/\s+/g, " ");
77
+ }
78
+ /**
79
+ * Refine a plan after successful execution.
80
+ * Extracts only the steps that actually completed (strips skipped/failed).
81
+ * Returns the refinement count and whether the plan was graduated.
82
+ */
83
+ refine(goal, result) {
84
+ if (!result.success)
85
+ return { refinementCount: 0, graduated: false };
86
+ const goalKey = this.normalizeGoal(goal.description);
87
+ // Extract completed steps from all subgoals
88
+ const completedSteps = [];
89
+ for (const sg of goal.subgoals) {
90
+ if (sg.plan) {
91
+ for (const step of sg.plan.steps) {
92
+ if (step.status === "completed") {
93
+ completedSteps.push({ tool: step.tool, params: step.params });
94
+ }
95
+ }
96
+ }
97
+ }
98
+ if (completedSteps.length === 0)
99
+ return { refinementCount: 0, graduated: false };
100
+ const existing = this.cache.get(goalKey);
101
+ const refinementCount = (existing?.refinementCount ?? 0) + 1;
102
+ const prevAvg = existing?.avgDurationMs ?? result.durationMs;
103
+ const avgDurationMs = Math.round((prevAvg * (refinementCount - 1) + result.durationMs) / refinementCount);
104
+ const refined = {
105
+ goalKey,
106
+ goalDescription: goal.description,
107
+ steps: completedSteps,
108
+ refinementCount,
109
+ avgDurationMs,
110
+ lastRefinedAt: new Date().toISOString(),
111
+ graduated: existing?.graduated ?? false,
112
+ };
113
+ this.cache.set(goalKey, refined);
114
+ this.save();
115
+ return { refinementCount, graduated: refined.graduated };
116
+ }
117
+ /**
118
+ * Check if a goal should graduate to a playbook.
119
+ * Returns the playbook if ready, null otherwise.
120
+ */
121
+ checkGraduation(goalDescription, bundleId, appName) {
122
+ const goalKey = this.normalizeGoal(goalDescription);
123
+ const refined = this.cache.get(goalKey);
124
+ if (!refined)
125
+ return null;
126
+ if (refined.graduated)
127
+ return null;
128
+ if (refined.refinementCount < GRADUATION_THRESHOLD)
129
+ return null;
130
+ // Graduate: create a playbook from the refined steps
131
+ const playbookSteps = [];
132
+ for (let i = 0; i < refined.steps.length; i++) {
133
+ const s = refined.steps[i];
134
+ const mapped = mapToolToPlaybookStep(s.tool, s.params, i);
135
+ if (mapped)
136
+ playbookSteps.push(mapped);
137
+ }
138
+ const playbook = {
139
+ id: `graduated_${goalKey.replace(/[^a-z0-9]/g, "_").slice(0, 40)}_${Date.now().toString(36)}`,
140
+ name: refined.goalDescription,
141
+ description: `Auto-graduated from ${refined.refinementCount} successful plan executions (avg ${refined.avgDurationMs}ms)`,
142
+ platform: appName.toLowerCase(),
143
+ bundleId,
144
+ version: "1.0.0",
145
+ steps: playbookSteps,
146
+ tags: [appName.toLowerCase(), "auto-graduated", "refined"],
147
+ successCount: refined.refinementCount,
148
+ failCount: 0,
149
+ selectors: {},
150
+ errors: [],
151
+ };
152
+ // Mark as graduated
153
+ refined.graduated = true;
154
+ this.save();
155
+ return playbook;
156
+ }
157
+ /** Get all refined plans (for debugging/status). */
158
+ getAll() {
159
+ return [...this.cache.values()];
160
+ }
161
+ /** Look up a refined plan for a goal. */
162
+ lookup(goalDescription) {
163
+ return this.cache.get(this.normalizeGoal(goalDescription)) ?? null;
164
+ }
165
+ }
166
+ /** Map MCP tool name + params to a PlaybookStep. Returns null if unmappable. */
167
+ function mapToolToPlaybookStep(tool, params, index) {
168
+ const TOOL_TO_ACTION = {
169
+ ui_press: "press",
170
+ click_text: "press",
171
+ click: "press",
172
+ click_with_fallback: "press",
173
+ type_text: "type_into",
174
+ type_with_fallback: "type_into",
175
+ key: "key",
176
+ browser_navigate: "navigate",
177
+ browser_click: "browser_click",
178
+ browser_type: "browser_type",
179
+ browser_human_click: "browser_human_click",
180
+ browser_js: "browser_js",
181
+ menu_click: "menu_click",
182
+ scroll: "scroll",
183
+ scroll_with_fallback: "scroll",
184
+ screenshot: "screenshot",
185
+ applescript: "applescript",
186
+ };
187
+ const action = TOOL_TO_ACTION[tool];
188
+ if (!action)
189
+ return null; // focus, launch, etc. are not playbook-able
190
+ const step = {
191
+ action,
192
+ description: `Step ${index + 1}: ${tool}`,
193
+ };
194
+ // Map params to playbook step fields
195
+ const target = params.title ?? params.text ?? params.selector ?? params.target;
196
+ if (typeof target === "string")
197
+ step.target = target;
198
+ if (typeof params.text === "string")
199
+ step.text = params.text;
200
+ if (typeof params.url === "string")
201
+ step.url = params.url;
202
+ if (typeof params.combo === "string")
203
+ step.keys = [params.combo];
204
+ if (typeof params.key === "string")
205
+ step.keys = [params.key];
206
+ if (Array.isArray(params.menuPath))
207
+ step.menuPath = params.menuPath;
208
+ if (typeof params.direction === "string")
209
+ step.direction = params.direction;
210
+ if (typeof params.amount === "number")
211
+ step.amount = params.amount;
212
+ return step;
213
+ }
@@ -23,9 +23,15 @@ export class PlaybookEngine {
23
23
  appleScriptRunner;
24
24
  /** Enable observer-based popup checks before each step */
25
25
  popupCheckEnabled = false;
26
+ /** Optional callback invoked after each step with outcome — wires into learning/memory. */
27
+ onOutcome;
26
28
  constructor(runtime) {
27
29
  this.runtime = runtime;
28
30
  }
31
+ /** Set callback for step outcomes so PlaybookEngine feeds learning. */
32
+ setOutcomeCallback(cb) {
33
+ this.onOutcome = cb;
34
+ }
29
35
  /** Enable/disable pre-step popup detection via observer daemon */
30
36
  setPopupCheck(enabled) {
31
37
  this.popupCheckEnabled = enabled;
@@ -61,6 +67,9 @@ export class PlaybookEngine {
61
67
  }
62
68
  const result = await this.executeStep(sessionId, step, playbook.cdpPort);
63
69
  stepsCompleted++;
70
+ // Report success to learning pipeline
71
+ if (this.onOutcome)
72
+ this.onOutcome(step, true, null);
64
73
  if (options.onStep) {
65
74
  options.onStep(i, step, result);
66
75
  }
@@ -68,6 +77,8 @@ export class PlaybookEngine {
68
77
  if (step.verify) {
69
78
  const verified = await this.verifyStep(sessionId, step);
70
79
  if (!verified && !step.optional) {
80
+ if (this.onOutcome)
81
+ this.onOutcome(step, false, `Verification failed at step ${i}`);
71
82
  return {
72
83
  playbook: playbook.id,
73
84
  success: false,
@@ -83,10 +94,14 @@ export class PlaybookEngine {
83
94
  await sleep(STEP_DELAY_MS);
84
95
  }
85
96
  catch (err) {
97
+ const errMsg = err instanceof Error ? err.message : String(err);
98
+ // Report failure to learning pipeline
99
+ if (this.onOutcome)
100
+ this.onOutcome(step, false, errMsg);
86
101
  if (step.optional) {
87
102
  stepsCompleted++;
88
103
  if (options.onStep) {
89
- options.onStep(i, step, `Skipped (optional): ${err instanceof Error ? err.message : String(err)}`);
104
+ options.onStep(i, step, `Skipped (optional): ${errMsg}`);
90
105
  }
91
106
  continue;
92
107
  }
@@ -96,7 +111,7 @@ export class PlaybookEngine {
96
111
  stepsCompleted,
97
112
  totalSteps: playbook.steps.length,
98
113
  failedAtStep: i,
99
- error: err instanceof Error ? err.message : String(err),
114
+ error: errMsg,
100
115
  durationMs: Date.now() - start,
101
116
  };
102
117
  }
@@ -115,7 +130,7 @@ export class PlaybookEngine {
115
130
  */
116
131
  /** Tools that the PlaybookEngine refuses to execute (defense in depth). */
117
132
  static BLOCKED_ACTIONS = new Set([
118
- "applescript", "browser_stealth",
133
+ "browser_stealth",
119
134
  "memory_save", "memory_clear", "memory_snapshot",
120
135
  "supervisor_start", "supervisor_stop", "supervisor_install", "supervisor_uninstall",
121
136
  "job_create", "worker_start",
@@ -98,7 +98,9 @@ export class PlaybookRecorder {
98
98
  try {
99
99
  await this.pollAXState();
100
100
  }
101
- catch { /* non-fatal */ }
101
+ catch (e) {
102
+ process.stderr.write(`[recorder] AX poll failed: ${e instanceof Error ? e.message : String(e)}\n`);
103
+ }
102
104
  }, AX_POLL_INTERVAL_MS);
103
105
  // Start screenshot capture (slower — every 2.5s)
104
106
  if (this.captureScreenshots) {
@@ -108,7 +110,9 @@ export class PlaybookRecorder {
108
110
  try {
109
111
  await this.takeScreenshot();
110
112
  }
111
- catch { /* non-fatal */ }
113
+ catch (e) {
114
+ process.stderr.write(`[recorder] screenshot capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
115
+ }
112
116
  }, SCREENSHOT_INTERVAL_MS);
113
117
  }
114
118
  }
@@ -123,7 +127,9 @@ export class PlaybookRecorder {
123
127
  try {
124
128
  await this.takeScreenshot();
125
129
  }
126
- catch { /* ignore */ }
130
+ catch (e) {
131
+ process.stderr.write(`[recorder] final screenshot failed: ${e instanceof Error ? e.message : String(e)}\n`);
132
+ }
127
133
  }
128
134
  this.log(`Recording stopped. ${this.events.length} events, ${this.screenshots.length} screenshots captured.`);
129
135
  // Convert raw events + screenshots to playbook steps via AI
@@ -190,7 +196,9 @@ export class PlaybookRecorder {
190
196
  }
191
197
  }
192
198
  }
193
- catch { /* ignore */ }
199
+ catch (e) {
200
+ process.stderr.write(`[recorder] app list poll failed: ${e instanceof Error ? e.message : String(e)}\n`);
201
+ }
194
202
  // 2. Get accessibility tree — find focused element and text field values
195
203
  try {
196
204
  const tree = await this.runtime.elementTree({ sessionId: this.sessionId, maxDepth: 4 });
@@ -238,7 +246,9 @@ export class PlaybookRecorder {
238
246
  this.prevWindowTitle = title;
239
247
  }
240
248
  }
241
- catch { /* ignore */ }
249
+ catch (e) {
250
+ process.stderr.write(`[recorder] window title poll failed: ${e instanceof Error ? e.message : String(e)}\n`);
251
+ }
242
252
  }
243
253
  // ── Screenshot Capture ──
244
254
  async takeScreenshot() {
@@ -253,7 +263,9 @@ export class PlaybookRecorder {
253
263
  this.screenshots.push(record);
254
264
  }
255
265
  }
256
- catch { /* non-fatal */ }
266
+ catch (e) {
267
+ process.stderr.write(`[recorder] takeScreenshot failed: ${e instanceof Error ? e.message : String(e)}\n`);
268
+ }
257
269
  }
258
270
  // ── State Capture ──
259
271
  async captureState(label) {
@@ -271,7 +283,9 @@ export class PlaybookRecorder {
271
283
  }
272
284
  }
273
285
  }
274
- catch { /* ignore */ }
286
+ catch (e) {
287
+ process.stderr.write(`[recorder] initial app state capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
288
+ }
275
289
  // Capture initial tree state
276
290
  try {
277
291
  const tree = await this.runtime.elementTree({ sessionId: this.sessionId, maxDepth: 4 });
@@ -281,7 +295,9 @@ export class PlaybookRecorder {
281
295
  this.prevTextFields = collectTextFields(tree.data);
282
296
  }
283
297
  }
284
- catch { /* ignore */ }
298
+ catch (e) {
299
+ process.stderr.write(`[recorder] initial tree state capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
300
+ }
285
301
  // Take initial screenshot
286
302
  if (this.captureScreenshots) {
287
303
  await this.takeScreenshot();
@@ -97,7 +97,9 @@ export class PlaybookRunner {
97
97
  if (shot.ok)
98
98
  screenshotInfo = `Screenshot saved to: ${shot.data.path}`;
99
99
  }
100
- catch { /* ignore */ }
100
+ catch (e) {
101
+ process.stderr.write(`[playbook-runner] screenshot for recovery failed: ${e instanceof Error ? e.message : String(e)}\n`);
102
+ }
101
103
  // Get current page state
102
104
  let pageState = "";
103
105
  try {
@@ -106,7 +108,9 @@ export class PlaybookRunner {
106
108
  pageState = JSON.stringify(tree.data).slice(0, 3000);
107
109
  }
108
110
  }
109
- catch { /* ignore */ }
111
+ catch (e) {
112
+ process.stderr.write(`[playbook-runner] elementTree for recovery failed: ${e instanceof Error ? e.message : String(e)}\n`);
113
+ }
110
114
  // Build rich context from playbook metadata
111
115
  const playbookContext = buildPlaybookContext(playbook);
112
116
  const prompt = `A playbook automation failed. Help me recover.
@@ -221,7 +225,9 @@ Or if unrecoverable, respond with: { "unrecoverable": true, "reason": "..." }`;
221
225
  if (tree.ok)
222
226
  pageState = JSON.stringify(tree.data).slice(0, 4000);
223
227
  }
224
- catch { /* ignore */ }
228
+ catch (e) {
229
+ process.stderr.write(`[playbook-runner] elementTree in AI loop failed: ${e instanceof Error ? e.message : String(e)}\n`);
230
+ }
225
231
  const prompt = `Task: ${task}
226
232
 
227
233
  Steps taken so far:
@@ -63,6 +63,10 @@ export class PlaybookStore {
63
63
  }
64
64
  }
65
65
  }
66
+ /** Reload all playbooks from disk (call after ingestion writes new data). */
67
+ reload() {
68
+ this.load();
69
+ }
66
70
  /** Get all loaded playbooks. */
67
71
  getAll() {
68
72
  return [...this.playbooks.values()];
@@ -274,6 +278,10 @@ export class PlaybookStore {
274
278
  if (raw.flows && typeof raw.flows === "object") {
275
279
  return this.convertLegacy(raw, filename);
276
280
  }
281
+ // Reference format: has selectors but no steps/flows (e.g. *-explore.json from platform_explore)
282
+ if (raw.selectors && typeof raw.selectors === "object") {
283
+ return this.convertLegacy(raw, filename);
284
+ }
277
285
  return null;
278
286
  }
279
287
  /**
@@ -109,7 +109,9 @@ export class RecoveryEngine {
109
109
  if (undoStrategy)
110
110
  candidates.push(undoStrategy);
111
111
  }
112
- catch { /* best-effort */ }
112
+ catch (e) {
113
+ process.stderr.write(`[recovery] buildUndoStrategy failed: ${e instanceof Error ? e.message : String(e)}\n`);
114
+ }
113
115
  }
114
116
  // Reference strategies second (app-specific)
115
117
  if (blocker.bundleId) {
@@ -355,7 +357,9 @@ export class RecoveryEngine {
355
357
  break;
356
358
  }
357
359
  }
358
- catch { /* skip malformed */ }
360
+ catch (e) {
361
+ process.stderr.write(`[recovery] malformed reference file: ${e instanceof Error ? e.message : String(e)}\n`);
362
+ }
359
363
  }
360
364
  }
361
365
  catch { /* dir doesn't exist */ }
@@ -366,7 +370,9 @@ export class RecoveryEngine {
366
370
  try {
367
371
  this.memory.recordError(`recovery:${event.strategyId}`, event.error ?? "", event.success ? event.strategyLabel : null, event.blocker.bundleId ?? undefined);
368
372
  }
369
- catch { /* best-effort */ }
373
+ catch (e) {
374
+ process.stderr.write(`[recovery] recordEvent failed: ${e instanceof Error ? e.message : String(e)}\n`);
375
+ }
370
376
  }
371
377
  }
372
378
  function sleep(ms) {