@yemi33/minions 0.1.2071 → 0.1.2073

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/engine/shared.js CHANGED
@@ -467,6 +467,22 @@ function safeReadDir(dir) {
467
467
  try { return fs.readdirSync(dir); } catch { return []; }
468
468
  }
469
469
 
470
+ /**
471
+ * Read a JSON file with **automatic restore from `.backup` sidecar** on
472
+ * missing/corrupt primary. Intended for live, mutable state files
473
+ * (work-items.json, dispatch.json, pull-requests.json, etc.) that are paired
474
+ * with a `.backup` sidecar written by `safeWrite`. Returns the parsed JSON,
475
+ * or null when both primary and backup are missing/unparseable.
476
+ *
477
+ * **Restore semantics:** If the primary is missing or unparseable but a valid
478
+ * `.backup` exists, the backup is parsed, returned, AND atomically rewritten
479
+ * to the primary path (best-effort). This protects live state from torn
480
+ * writes / interrupted saves.
481
+ *
482
+ * Counterpart: `safeJsonNoRestore` for terminal artifacts and "missing == gone"
483
+ * reads (cooldowns, archived PRDs, ephemeral session state) where reviving a
484
+ * stale `.backup` is actively harmful. See its JSDoc for selection guidance.
485
+ */
470
486
  function safeJson(p) {
471
487
  // Split the read from the parse so we can distinguish "file missing" (normal
472
488
  // pre-create state — silent) from "file present but corrupt JSON" (real
@@ -524,22 +540,42 @@ function safeJsonObj(p) { return safeJson(p) || {}; }
524
540
  function safeJsonArr(p) { return safeJson(p) || []; }
525
541
 
526
542
  /**
527
- * Sibling of safeJson for terminal-artifact reads (PRDs in `prd/`, archived
528
- * plans, anything where a missing primary should NOT auto-restore from a
529
- * stale `.backup` sidecar). Returns the parsed JSON on success, or null when
530
- * the primary is missing or unparseable.
543
+ * Sibling of safeJson for terminal-artifact and "missing == gone" reads
544
+ * (PRDs in `prd/`, archived plans, cooldowns, ephemeral session state
545
+ * anything where a missing primary should NOT auto-restore from a stale
546
+ * `.backup` sidecar). Returns the parsed JSON on success, or `defaultValue`
547
+ * (default `null`) on **any** failure: missing file, unparseable JSON, or
548
+ * IO error. The `.backup` sidecar is never consulted.
531
549
  *
532
550
  * Why a separate primitive: safeJson's restore-on-miss is correct for live
533
551
  * state files (work-items.json, dispatch.json, pull-requests.json, etc.) but
534
- * actively harmful for terminal artifacts. Archived PRDs leave a `.backup`
535
- * sidecar in `prd/`; if any caller reads the active path with safeJson, the
536
- * .backup is silently restored and the dashboard sees a phantom "active" PRD
537
- * (W-mouptdh1000h9f39). PRDs are end-state no automatic resurrection.
552
+ * actively harmful for terminal artifacts. Examples of misuse and the bugs
553
+ * they hide:
554
+ * - Archived PRDs leave a `.backup` sidecar in `prd/`; reading the active
555
+ * path with safeJson silently restores it and the dashboard sees a
556
+ * phantom "active" PRD (W-mouptdh1000h9f39). PRDs are end-state — no
557
+ * resurrection.
558
+ * - Cooldowns are time-bounded ephemeral state (24h TTL). Restoring a
559
+ * stale `cooldowns.json.backup` could resurrect expired entries that
560
+ * should already have been pruned, suppressing legitimate dispatches.
561
+ * - Restoring corrupt-primary scenarios from `.backup` masks the underlying
562
+ * write integrity failure and breaks State Integrity tests.
563
+ *
564
+ * **When to use which:**
565
+ * - `safeJson(p)` — live mutable state paired with safeWrite-managed `.backup`.
566
+ * Restore-on-miss is protective against torn writes.
567
+ * - `safeJsonNoRestore(p, defaultValue)` — terminal artifacts, time-bounded
568
+ * ephemeral state, or any read where "missing/corrupt" should mean "gone".
538
569
  *
539
570
  * Parse errors are logged so silent corruption still surfaces (mirrors
540
571
  * safeJson's contract). Read errors other than ENOENT are also logged.
572
+ *
573
+ * @param {string} p - Absolute path to the JSON file.
574
+ * @param {*} [defaultValue=null] - Value returned on any failure (missing,
575
+ * parse error, IO error). Pass `{}` / `[]` to mirror safeJsonObj/safeJsonArr.
576
+ * @returns {*} Parsed JSON on success, otherwise `defaultValue`.
541
577
  */
542
- function safeJsonNoRestore(p) {
578
+ function safeJsonNoRestore(p, defaultValue = null) {
543
579
  let raw;
544
580
  try {
545
581
  raw = fs.readFileSync(p, 'utf8');
@@ -547,13 +583,13 @@ function safeJsonNoRestore(p) {
547
583
  if (e && e.code !== 'ENOENT') {
548
584
  console.warn(`[safeJsonNoRestore] read failed for ${path.basename(p)}: ${e.message}`);
549
585
  }
550
- return null;
586
+ return defaultValue;
551
587
  }
552
588
  try {
553
589
  return JSON.parse(raw);
554
590
  } catch (parseErr) {
555
591
  console.error(`[safeJsonNoRestore] parse failure for ${path.basename(p)}: ${parseErr.message}`);
556
- return null;
592
+ return defaultValue;
557
593
  }
558
594
  }
559
595
 
@@ -1144,10 +1180,20 @@ function mutateJsonFileLocked(filePath, mutateFn, {
1144
1180
  let data = safeJson(filePath);
1145
1181
  const parsedInvalid = fileExists && data === null;
1146
1182
  if (data === null || typeof data !== 'object') data = Array.isArray(defaultValue) ? [...defaultValue] : { ...defaultValue };
1147
- const beforeSerialized = skipWriteIfUnchanged ? JSON.stringify(data) : null;
1183
+ // Normalize BEFORE taking the baseline snapshot so that both `beforeSerialized`
1184
+ // and the post-mutator snapshot reflect post-normalize state. Capturing the
1185
+ // baseline before normalize breaks the `skipWriteIfUnchanged` optimization for
1186
+ // pull-requests.json files: a no-op mutator on a denormalized file would
1187
+ // always trip the write path because normalization itself shifted serialized
1188
+ // bytes between the two snapshots (P-bfa1c-skipwrite-timing). The trade-off
1189
+ // is intentional: when normalization is the ONLY change, we deliberately
1190
+ // leave the on-disk file denormalized — readers re-run normalizePrRecords on
1191
+ // load (see getPrLinks, engine/queries.js:670-674), so the in-memory contract
1192
+ // is preserved without the per-poll mtime bump.
1148
1193
  if (path.basename(filePath) === 'pull-requests.json' && Array.isArray(data)) {
1149
1194
  normalizePrRecords(data, resolveProjectForPrPath(filePath));
1150
1195
  }
1196
+ const beforeSerialized = skipWriteIfUnchanged ? JSON.stringify(data) : null;
1151
1197
  const next = mutateFn(data);
1152
1198
  const finalData = next === undefined ? data : next;
1153
1199
  const shouldWrite = !skipWriteIfUnchanged || parsedInvalid || JSON.stringify(finalData) !== beforeSerialized;
@@ -1767,6 +1813,14 @@ function parseStreamJsonOutput(raw, runtimeName, opts) {
1767
1813
 
1768
1814
  const KB_CATEGORIES = ['architecture', 'conventions', 'project-notes', 'build-reports', 'reviews'];
1769
1815
 
1816
+ // P-bfa2b-kb-path-traversal — read-side whitelist for /api/knowledge/:category/:file.
1817
+ // Superset of KB_CATEGORIES: adds 'agents' because per-agent personal memory is
1818
+ // served from knowledge/agents/<id>.md (see engine/consolidation.js +
1819
+ // engine/playbook.js) but is NOT a destination for inbox classification, so
1820
+ // KB_CATEGORIES intentionally excludes it. Frozen so handlers can rely on the
1821
+ // list being immutable across the process lifetime.
1822
+ const KB_READABLE_CATEGORIES = Object.freeze([...KB_CATEGORIES, 'agents']);
1823
+
1770
1824
  /**
1771
1825
  * Classify an inbox item into a knowledge base category.
1772
1826
  * Single source of truth — used by consolidation.js (both LLM and regex paths).
@@ -2690,6 +2744,43 @@ const WATCH_ACTION_TYPE = {
2690
2744
  RESUME_PLAN: 'resume-plan',
2691
2745
  };
2692
2746
 
2747
+ /**
2748
+ * Route a watches mutation through the SQL store. Same shape as
2749
+ * mutateWorkItems / mutatePullRequests: mutator receives the watches
2750
+ * array, mutates in place or returns a replacement, and the store
2751
+ * diffs by id. Falls back to the legacy mutateJsonFileLocked path on
2752
+ * SQLite failure.
2753
+ */
2754
+ function mutateWatches(mutator) {
2755
+ const watchesPath = path.join(MINIONS_DIR, 'engine', 'watches.json');
2756
+ try {
2757
+ const store = require('./watches-store');
2758
+ const { wrote, result } = store.applyWatchesMutation((arr) => {
2759
+ if (!Array.isArray(arr)) arr = [];
2760
+ return mutator(arr) || arr;
2761
+ });
2762
+ if (wrote) {
2763
+ try { store._mirrorJsonFromSql(watchesPath); } catch { /* mirror best-effort */ }
2764
+ try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
2765
+ }
2766
+ return result;
2767
+ } catch (e) {
2768
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
2769
+ throw e;
2770
+ }
2771
+ // SQLite unavailable — fall through to legacy JSON path.
2772
+ }
2773
+ return mutateJsonFileLocked(watchesPath, (data) => {
2774
+ if (!Array.isArray(data)) data = [];
2775
+ return mutator(data) || data;
2776
+ }, {
2777
+ defaultValue: [],
2778
+ onWrote: () => {
2779
+ try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
2780
+ },
2781
+ });
2782
+ }
2783
+
2693
2784
  /**
2694
2785
  * Route a metrics mutation through the SQL store with a JSON dual-write
2695
2786
  * mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
@@ -4763,6 +4854,10 @@ function mutatePullRequests(filePath, mutator) {
4763
4854
  return mutator(data) || data;
4764
4855
  }, {
4765
4856
  defaultValue: [],
4857
+ skipWriteIfUnchanged: true,
4858
+ // Emit only when an actual write happened. skipWriteIfUnchanged can
4859
+ // short-circuit no-op mutations; suppress the event in that case so the
4860
+ // dashboard cache-version doesn't bump for nothing.
4766
4861
  onWrote: () => {
4767
4862
  try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
4768
4863
  },
@@ -5158,6 +5253,7 @@ module.exports = {
5158
5253
  gitEnv,
5159
5254
  parseStreamJsonOutput,
5160
5255
  KB_CATEGORIES,
5256
+ KB_READABLE_CATEGORIES,
5161
5257
  classifyInboxItem,
5162
5258
  ENGINE_DEFAULTS,
5163
5259
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
@@ -5166,7 +5262,7 @@ module.exports = {
5166
5262
  runtimeConfigWarnings,
5167
5263
  projectWorkSourceWarnings,
5168
5264
  backfillProjectWorkSourceDefaults,
5169
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, mutateMetrics, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
5265
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, mutateMetrics, mutateWatches, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
5170
5266
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
5171
5267
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
5172
5268
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
@@ -0,0 +1,259 @@
1
+ // engine/watches-store.js — SQL-backed implementation of engine/watches.json.
2
+ //
3
+ // Mirrors the Phase 2/3 work_items / pull_requests stores: a single
4
+ // table (no per-scope split — watches.json is a flat array, not
5
+ // per-project), diff-then-apply mutator, JSON dual-write mirror with
6
+ // (mtime, size) fingerprint for external-edit detection.
7
+ //
8
+ // readWatches() -> [watch, watch, ...]
9
+ // applyWatchesMutation(fn) -> diff-then-apply, returns { wrote, result }
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ function _toMs(v) {
15
+ if (v == null) return null;
16
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
17
+ const parsed = Date.parse(v);
18
+ return Number.isFinite(parsed) ? parsed : null;
19
+ }
20
+
21
+ function _watchesFilePath() {
22
+ const shared = require('./shared');
23
+ return path.join(shared.MINIONS_DIR, 'engine', 'watches.json');
24
+ }
25
+
26
+ function _parseRow(row) {
27
+ if (!row || !row.data) return null;
28
+ try { return JSON.parse(row.data); }
29
+ catch { return null; }
30
+ }
31
+
32
+ function _readJsonArrayFallback() {
33
+ const fp = _watchesFilePath();
34
+ let raw;
35
+ try { raw = fs.readFileSync(fp, 'utf8'); }
36
+ catch { return []; }
37
+ try {
38
+ const parsed = JSON.parse(raw);
39
+ return Array.isArray(parsed) ? parsed : [];
40
+ } catch (e) {
41
+ try {
42
+ // eslint-disable-next-line no-console
43
+ console.warn(`[watches-store] corrupt JSON in ${fp}: ${e.message}`);
44
+ } catch { /* console may be wrapped */ }
45
+ return [];
46
+ }
47
+ }
48
+
49
+ // Content-hash fingerprint — (mtime, size) is too coarse: same-size
50
+ // content swaps (e.g. last_checked timestamps that always have the same
51
+ // byte length) inside the same ms tick collide on both axes. Hashing the
52
+ // bytes is the unambiguous signal. SHA-1 on a few-KB file is sub-ms.
53
+ let _lastMirrorHash = null;
54
+
55
+ const crypto = require('crypto');
56
+
57
+ function _fileContentHash(filePath) {
58
+ try {
59
+ const buf = fs.readFileSync(filePath);
60
+ return crypto.createHash('sha1').update(buf).digest('hex');
61
+ }
62
+ catch { return null; }
63
+ }
64
+
65
+ function _hydrateFromJson(db) {
66
+ const items = _readJsonArrayFallback();
67
+ db.prepare('DELETE FROM watches').run();
68
+ if (items.length === 0) return;
69
+ const now = Date.now();
70
+ const ins = db.prepare(`
71
+ INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
72
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
73
+ ON CONFLICT(id) DO NOTHING
74
+ `);
75
+ for (const w of items) {
76
+ if (!w || !w.id) continue;
77
+ ins.run(
78
+ String(w.id),
79
+ typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
80
+ w.targetType || null,
81
+ w.condition || null,
82
+ String(w.status || 'active'),
83
+ w.owner || null,
84
+ _toMs(w.created_at),
85
+ _toMs(w.last_checked),
86
+ _toMs(w.last_triggered),
87
+ JSON.stringify(w),
88
+ now,
89
+ );
90
+ }
91
+ }
92
+
93
+ function _resyncIfJsonDiverged(db) {
94
+ const jsonPath = _watchesFilePath();
95
+ const currentHash = _fileContentHash(jsonPath);
96
+ if (currentHash == null) return;
97
+ if (_lastMirrorHash != null && currentHash === _lastMirrorHash) return;
98
+ if (_lastMirrorHash == null) {
99
+ const sqlHas = db.prepare('SELECT 1 FROM watches LIMIT 1').get();
100
+ if (sqlHas) {
101
+ _lastMirrorHash = currentHash;
102
+ return;
103
+ }
104
+ }
105
+ _hydrateFromJson(db);
106
+ _lastMirrorHash = currentHash;
107
+ }
108
+
109
+ // Read all watch rows from SQL only. No JSON fallback — used by the
110
+ // mirror writer to avoid resurrecting deleted rows: after the mutation
111
+ // deletes a watch from SQL but BEFORE the mirror lands, the JSON still
112
+ // holds the pre-mutation content, so a fallback read would round-trip
113
+ // the deleted row right back into JSON.
114
+ function _readWatchesFromSqlOnly(db) {
115
+ const rows = db.prepare('SELECT data FROM watches ORDER BY rowid').all();
116
+ const out = [];
117
+ for (const row of rows) {
118
+ const w = _parseRow(row);
119
+ if (w) out.push(w);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function readWatches() {
125
+ const { getDb } = require('./db');
126
+ let db;
127
+ try { db = getDb(); }
128
+ catch { return _readJsonArrayFallback(); }
129
+
130
+ _resyncIfJsonDiverged(db);
131
+
132
+ const out = _readWatchesFromSqlOnly(db);
133
+ if (out.length === 0) {
134
+ // SQL is empty for first-time hydrate: trust the JSON if it has
135
+ // content. After hydrate ran, SQL is empty iff the user really has
136
+ // no watches.
137
+ const fallback = _readJsonArrayFallback();
138
+ if (fallback.length > 0) return fallback;
139
+ return [];
140
+ }
141
+ return out;
142
+ }
143
+
144
+ function _indexById(arr) {
145
+ const out = new Map();
146
+ for (const w of arr) {
147
+ if (!w || !w.id) continue;
148
+ out.set(String(w.id), w);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ function _computeWatchesDiff(before, after) {
154
+ const beforeMap = _indexById(before);
155
+ const afterMap = _indexById(after);
156
+ const toUpsert = [];
157
+ const toDelete = [];
158
+ for (const [id, w] of afterMap) {
159
+ const prev = beforeMap.get(id);
160
+ if (!prev) { toUpsert.push(w); continue; }
161
+ if (JSON.stringify(prev) !== JSON.stringify(w)) toUpsert.push(w);
162
+ }
163
+ for (const [id] of beforeMap) {
164
+ if (!afterMap.has(id)) toDelete.push(id);
165
+ }
166
+ return { toUpsert, toDelete };
167
+ }
168
+
169
+ function _applyWatchesDiff(db, diff) {
170
+ if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
171
+ const now = Date.now();
172
+ const upsertStmt = db.prepare(`
173
+ INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
174
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
175
+ ON CONFLICT(id) DO UPDATE SET
176
+ target = excluded.target,
177
+ target_type = excluded.target_type,
178
+ condition = excluded.condition,
179
+ status = excluded.status,
180
+ owner = excluded.owner,
181
+ created_at = excluded.created_at,
182
+ last_checked = excluded.last_checked,
183
+ last_triggered = excluded.last_triggered,
184
+ data = excluded.data,
185
+ updated_at = excluded.updated_at
186
+ `);
187
+ const deleteStmt = db.prepare('DELETE FROM watches WHERE id = ?');
188
+
189
+ for (const w of diff.toUpsert) {
190
+ upsertStmt.run(
191
+ String(w.id),
192
+ typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
193
+ w.targetType || null,
194
+ w.condition || null,
195
+ String(w.status || 'active'),
196
+ w.owner || null,
197
+ _toMs(w.created_at),
198
+ _toMs(w.last_checked),
199
+ _toMs(w.last_triggered),
200
+ JSON.stringify(w),
201
+ now,
202
+ );
203
+ }
204
+ for (const id of diff.toDelete) {
205
+ deleteStmt.run(id);
206
+ }
207
+ return true;
208
+ }
209
+
210
+ function applyWatchesMutation(mutator) {
211
+ const { getDb, withTransaction } = require('./db');
212
+ let db;
213
+ try { db = getDb(); }
214
+ catch (e) {
215
+ throw new Error(`engine/watches-store: SQLite unavailable (${e.message}); cannot mutate watches`);
216
+ }
217
+
218
+ return withTransaction(db, () => {
219
+ _resyncIfJsonDiverged(db);
220
+ const before = readWatches();
221
+ const beforeSnapshot = JSON.parse(JSON.stringify(before));
222
+ const next = mutator(before);
223
+ const after = (next === undefined || next === null)
224
+ ? before
225
+ : (Array.isArray(next) ? next : before);
226
+ const diff = _computeWatchesDiff(beforeSnapshot, after);
227
+ const wrote = _applyWatchesDiff(db, diff);
228
+ return { wrote, result: after };
229
+ });
230
+ }
231
+
232
+ function _mirrorJsonFromSql(filePath) {
233
+ try {
234
+ const shared = require('./shared');
235
+ const { getDb } = require('./db');
236
+ // Read SQL directly — bypass the JSON fallback so a deletion that
237
+ // leaves SQL empty doesn't resurrect from the stale JSON content.
238
+ const items = _readWatchesFromSqlOnly(getDb());
239
+ const target = filePath || _watchesFilePath();
240
+ shared.safeWrite(target, items);
241
+ const h = _fileContentHash(target);
242
+ if (h != null) _lastMirrorHash = h;
243
+ } catch {
244
+ // Mirror failures are non-fatal — SQL has already committed.
245
+ }
246
+ }
247
+
248
+ function _resetWatchesTableForTest() {
249
+ const { getDb } = require('./db');
250
+ try { getDb().exec('DELETE FROM watches'); } catch { /* not initialized */ }
251
+ _lastMirrorHash = null;
252
+ }
253
+
254
+ module.exports = {
255
+ readWatches,
256
+ applyWatchesMutation,
257
+ _mirrorJsonFromSql,
258
+ _resetWatchesTableForTest,
259
+ };
package/engine/watches.js CHANGED
@@ -67,7 +67,7 @@
67
67
  const fs = require('fs');
68
68
  const path = require('path');
69
69
  const shared = require('./shared');
70
- const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
70
+ const { safeJsonArr, mutateJsonFileLocked, mutateWatches, ts, uid, log, writeToInbox,
71
71
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
72
72
  const watchActions = require('./watch-actions');
73
73
 
@@ -230,11 +230,10 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
230
230
  _history: [],
231
231
  };
232
232
 
233
- mutateJsonFileLocked(_watchesPath(), (watches) => {
234
- if (!Array.isArray(watches)) watches = [];
233
+ mutateWatches((watches) => {
235
234
  watches.push(watch);
236
235
  return watches;
237
- }, { defaultValue: [] });
236
+ });
238
237
 
239
238
  log('info', `Watch created: ${watch.id} → ${watch.target} (${watch.condition})`);
240
239
  return watch;
@@ -264,8 +263,7 @@ function updateWatch(id, updates) {
264
263
  if (reqErr) throw new Error(reqErr);
265
264
  }
266
265
  let found = null;
267
- mutateJsonFileLocked(_watchesPath(), (watches) => {
268
- if (!Array.isArray(watches)) return watches;
266
+ mutateWatches((watches) => {
269
267
  const watch = watches.find(w => w.id === id);
270
268
  if (!watch) return watches;
271
269
  // Only allow safe field updates
@@ -275,7 +273,7 @@ function updateWatch(id, updates) {
275
273
  }
276
274
  found = { ...watch };
277
275
  return watches;
278
- }, { defaultValue: [] });
276
+ });
279
277
  return found;
280
278
  }
281
279
 
@@ -287,15 +285,14 @@ function updateWatch(id, updates) {
287
285
  function deleteWatch(id) {
288
286
  if (!id) return false;
289
287
  let deleted = false;
290
- mutateJsonFileLocked(_watchesPath(), (watches) => {
291
- if (!Array.isArray(watches)) return watches;
288
+ mutateWatches((watches) => {
292
289
  const idx = watches.findIndex(w => w.id === id);
293
290
  if (idx >= 0) {
294
291
  watches.splice(idx, 1);
295
292
  deleted = true;
296
293
  }
297
294
  return watches;
298
- }, { defaultValue: [] });
295
+ });
299
296
  if (deleted) log('info', `Watch deleted: ${id}`);
300
297
  return deleted;
301
298
  }
@@ -413,8 +410,8 @@ function checkWatches(config, state) {
413
410
  const notifications = [];
414
411
  const actionsToRun = [];
415
412
 
416
- mutateJsonFileLocked(_watchesPath(), (watches) => {
417
- if (!Array.isArray(watches) || watches.length === 0) return watches;
413
+ mutateWatches((watches) => {
414
+ if (watches.length === 0) return watches;
418
415
 
419
416
  for (const watch of watches) {
420
417
  try {
@@ -539,7 +536,7 @@ function checkWatches(config, state) {
539
536
  }
540
537
 
541
538
  return watches;
542
- }, { defaultValue: [] });
539
+ });
543
540
 
544
541
  // Fire notifications outside the lock — writeToInbox does disk I/O
545
542
  for (const n of notifications) {
@@ -591,8 +588,7 @@ async function _runActionTask(task) {
591
588
  // can inspect each step's result. Single-action watches keep the legacy
592
589
  // `dispatchedItemId` shortcut for back-compat.
593
590
  try {
594
- mutateJsonFileLocked(_watchesPath(), (watches) => {
595
- if (!Array.isArray(watches)) return watches;
591
+ mutateWatches((watches) => {
596
592
  const w = watches.find(x => x.id === task.watchId);
597
593
  if (w) {
598
594
  if (isChain) {
@@ -639,7 +635,7 @@ async function _runActionTask(task) {
639
635
  }
640
636
  }
641
637
  return watches;
642
- }, { defaultValue: [] });
638
+ });
643
639
  } catch (err) {
644
640
  log('warn', `Watch ${task.watchId} action result persist failed: ${err.message}`);
645
641
  }
@@ -116,28 +116,19 @@ function readWorkItemsForScope(scope) {
116
116
  // scope is bounded by the JSON's row count.
117
117
  function _resyncScopeIfJsonDiverged(db, scope) {
118
118
  const jsonPath = _filePathForScope(scope);
119
- const current = _statFingerprint(jsonPath);
120
- const lastMirror = _lastMirrorByScope.get(scope);
121
- if (current == null) return; // JSON absent → nothing to resync
122
- // In-sync iff BOTH mtime AND size match. Either differing signals an
123
- // external write including the same-ms-tick safeWrite pattern that
124
- // mtime-only checks miss on Windows (ms-rounded mtimeMs collides).
125
- // Resync is idempotent (DELETE + bulk re-INSERT from JSON).
126
- if (lastMirror != null
127
- && current.mtime === lastMirror.mtime
128
- && current.size === lastMirror.size) return;
129
- // No mirror record but JSON exists: first-time hydrate iff SQL is empty
130
- // for this scope (avoid trampling a freshly-backfilled migration state
131
- // on the very first read).
132
- if (lastMirror == null) {
119
+ const currentHash = _fileContentHash(jsonPath);
120
+ const lastHash = _lastMirrorHashByScope.get(scope);
121
+ if (currentHash == null) return;
122
+ if (lastHash != null && currentHash === lastHash) return;
123
+ if (lastHash == null) {
133
124
  const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
134
125
  if (sqlHas) {
135
- _lastMirrorByScope.set(scope, current);
126
+ _lastMirrorHashByScope.set(scope, currentHash);
136
127
  return;
137
128
  }
138
129
  }
139
130
  _hydrateScopeFromJson(db, scope);
140
- _lastMirrorByScope.set(scope, current);
131
+ _lastMirrorHashByScope.set(scope, currentHash);
141
132
  }
142
133
 
143
134
  // Read all rows across all scopes — used by queries.getWorkItems which
@@ -243,18 +234,18 @@ function _applyWorkItemsDiff(db, diff) {
243
234
  // wrote — true iff at least one INSERT/UPDATE/DELETE landed
244
235
  // result — the post-mutation array (legacy return shape of
245
236
  // mutateJsonFileLocked)
246
- // In-process record of (mtime, size) of the JSON mirror we last wrote,
247
- // keyed by scope. Two-field fingerprint catches the same-ms-tick write
248
- // pattern that mtime-only checks miss on Windows: NTFS reports
249
- // ms-rounded mtimeMs, so back-to-back writes inside the same tick look
250
- // identical to "no change" by mtime alone. File size catches the rest
251
- // — content swaps almost always change byte length.
252
- const _lastMirrorByScope = new Map();
237
+ // Content-hash fingerprint of the JSON mirror per scope, so external
238
+ // edits are detected unambiguously. (mtime, size) is too coarse: same-
239
+ // length timestamp swaps (last_checked / last_triggered / completed_at)
240
+ // preserve both axes. SHA-1 is sub-ms on a few-hundred-KB file.
241
+ const _lastMirrorHashByScope = new Map();
253
242
 
254
- function _statFingerprint(filePath) {
243
+ const crypto = require('crypto');
244
+
245
+ function _fileContentHash(filePath) {
255
246
  try {
256
- const st = fs.statSync(filePath);
257
- return { mtime: st.mtimeMs, size: st.size };
247
+ const buf = fs.readFileSync(filePath);
248
+ return crypto.createHash('sha1').update(buf).digest('hex');
258
249
  }
259
250
  catch { return null; }
260
251
  }
@@ -325,17 +316,24 @@ function applyWorkItemsMutation(scope, mutator) {
325
316
  function _mirrorJsonFromSql(scope, filePath) {
326
317
  try {
327
318
  const shared = require('./shared');
328
- const items = readWorkItemsForScope(scope);
329
- // Strip _source if presentonly added by readAllWorkItems, but
330
- // belt-and-suspenders since the mirror is observable.
331
- for (const wi of items) { if (wi && wi._source) delete wi._source; }
319
+ const { getDb } = require('./db');
320
+ // Read SQL directly for this scope bypass the JSON fallback so a
321
+ // mutation that empties SQL for the scope doesn't resurrect stale
322
+ // JSON content. (See pull-requests-store and watches-store for the
323
+ // same defense.)
324
+ const db = getDb();
325
+ const rows = db.prepare('SELECT data FROM work_items WHERE scope = ? ORDER BY rowid').all(scope);
326
+ const items = [];
327
+ for (const row of rows) {
328
+ const wi = _parseRow(row);
329
+ if (!wi) continue;
330
+ if (wi._source) delete wi._source;
331
+ items.push(wi);
332
+ }
332
333
  const target = filePath || _filePathForScope(scope);
333
334
  shared.safeWrite(target, items);
334
- // Record (mtime, size) we just wrote so the next mutation can detect
335
- // an external edit. mtime alone misses same-ms-tick writes; size
336
- // catches them.
337
- const fp = _statFingerprint(target);
338
- if (fp != null) _lastMirrorByScope.set(scope, fp);
335
+ const h = _fileContentHash(target);
336
+ if (h != null) _lastMirrorHashByScope.set(scope, h);
339
337
  } catch {
340
338
  // Mirror failures are non-fatal: SQL has already committed.
341
339
  }