@yemi33/minions 0.1.2370 → 0.1.2372

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 (71) hide show
  1. package/bin/minions.js +38 -11
  2. package/dashboard/js/render-other.js +2 -30
  3. package/dashboard/js/render-work-items.js +0 -34
  4. package/dashboard/js/settings.js +12 -27
  5. package/dashboard/pages/tools.html +1 -1
  6. package/dashboard.js +80 -257
  7. package/docs/README.md +2 -3
  8. package/docs/architecture.excalidraw +2 -2
  9. package/docs/auto-discovery.md +3 -3
  10. package/docs/completion-reports.md +0 -121
  11. package/docs/copilot-cli-schema.md +9 -17
  12. package/docs/deprecated.json +0 -51
  13. package/docs/design-state-storage.md +4 -4
  14. package/docs/harness-propagation.md +33 -263
  15. package/docs/human-vs-automated.md +1 -1
  16. package/docs/named-agents.md +0 -2
  17. package/docs/plan-lifecycle.md +5 -6
  18. package/docs/qa-runbook-lifecycle.md +10 -25
  19. package/docs/shared-lifecycle-module-map.md +2 -10
  20. package/engine/ado-comment.js +5 -11
  21. package/engine/ado.js +10 -5
  22. package/engine/cleanup.js +16 -36
  23. package/engine/cli.js +13 -175
  24. package/engine/comment-format.js +8 -182
  25. package/engine/db/index.js +60 -10
  26. package/engine/db/migrations/018-sql-only-cutover.js +56 -0
  27. package/engine/dispatch-store.js +0 -114
  28. package/engine/dispatch.js +1 -6
  29. package/engine/gh-comment.js +2 -10
  30. package/engine/github.js +8 -6
  31. package/engine/lifecycle.js +58 -173
  32. package/engine/llm.js +9 -11
  33. package/engine/managed-spawn.js +1 -6
  34. package/engine/memory-store.js +8 -6
  35. package/engine/metrics-store.js +4 -128
  36. package/engine/pipeline.js +4 -7
  37. package/engine/playbook.js +8 -196
  38. package/engine/prd-store.js +115 -141
  39. package/engine/preflight.js +8 -55
  40. package/engine/projects.js +34 -41
  41. package/engine/pull-requests-store.js +9 -234
  42. package/engine/qa-runs.js +4 -15
  43. package/engine/qa-sessions.js +5 -17
  44. package/engine/queries.js +23 -103
  45. package/engine/routing.js +1 -1
  46. package/engine/runtimes/claude.js +1 -2
  47. package/engine/runtimes/codex.js +0 -2
  48. package/engine/runtimes/copilot.js +1 -4
  49. package/engine/shared-branch-pr-reconcile.js +2 -5
  50. package/engine/shared.js +174 -533
  51. package/engine/small-state-store.js +12 -647
  52. package/engine/spawn-agent.js +5 -96
  53. package/engine/state-operations.js +166 -0
  54. package/engine/supervisor.js +0 -1
  55. package/engine/watch-actions.js +2 -3
  56. package/engine/watches-store.js +2 -128
  57. package/engine/work-items-store.js +3 -254
  58. package/engine.js +102 -334
  59. package/package.json +2 -2
  60. package/playbooks/build-fix-complex.md +0 -4
  61. package/playbooks/fix.md +0 -4
  62. package/playbooks/implement-shared.md +0 -4
  63. package/playbooks/implement.md +0 -4
  64. package/playbooks/plan-to-prd.md +16 -11
  65. package/playbooks/plan.md +0 -4
  66. package/playbooks/review.md +0 -6
  67. package/playbooks/shared-rules.md +0 -65
  68. package/docs/harness-transparency.md +0 -199
  69. package/docs/project-skills.md +0 -193
  70. package/engine/discover-project-skills.js +0 -673
  71. package/engine/playbook-intents.js +0 -76
@@ -22,7 +22,6 @@
22
22
  * --verbose / --no-verbose → opts.verbose
23
23
  * --stream <on|off> → opts.stream (Copilot)
24
24
  * --disable-builtin-mcps → opts.disableBuiltinMcps (Copilot)
25
- * --no-custom-instructions → opts.suppressAgentsMd (Copilot)
26
25
  * --enable-reasoning-summaries→ opts.reasoningSummaries (Copilot)
27
26
  *
28
27
  * Legacy --permission-mode <X> is dropped: the new Claude adapter emits
@@ -55,15 +54,6 @@ function parseSpawnArgs(argv) {
55
54
  let runtimeName = 'claude';
56
55
  const opts = {};
57
56
  const passthrough = [];
58
- // P-08b62d49 — accumulating array of project-local-on-main harness dirs that
59
- // engine.js wants `computeAddDirs` to surface as additional `--add-dir`
60
- // entries. Multiple `--project-harness-dir <path>` flags accumulate in
61
- // declaration order; the dedup/exists filter happens inside `computeAddDirs`.
62
- const projectHarnessDirs = [];
63
- // P-49e1c8b7 — hermetic harness opt-out (boolean). Set by engine.js when
64
- // shared.resolveAgentHermeticHarness(agent, engine) is true; propagates to
65
- // computeAddDirs which then returns [minionsDir] only.
66
- let hermetic = false;
67
57
 
68
58
  for (let i = 2; i < args.length; i++) {
69
59
  const a = args[i];
@@ -105,7 +95,7 @@ function parseSpawnArgs(argv) {
105
95
  }
106
96
  }
107
97
 
108
- return { promptFile, sysPromptFile, runtimeName, opts, passthrough, projectHarnessDirs, hermetic };
98
+ return { promptFile, sysPromptFile, runtimeName, opts, passthrough };
109
99
  }
110
100
 
111
101
  /**
@@ -121,9 +111,8 @@ function parseSpawnArgs(argv) {
121
111
  * usingNodeShim, // true → runtime returned a non-native binary (Claude cli.js)
122
112
  * }
123
113
  */
124
- function buildSpawnInvocation({ runtime, resolved, promptText, sysPromptText, opts, passthrough, addDirs }) {
114
+ function buildSpawnInvocation({ runtime, resolved, promptText, sysPromptText, opts, passthrough }) {
125
115
  const adapterOpts = { ...opts };
126
- if (Array.isArray(addDirs) && addDirs.length) adapterOpts.addDirs = addDirs;
127
116
  const finalPrompt = runtime.buildPrompt(promptText, sysPromptText, adapterOpts);
128
117
  const deliveryMode = typeof runtime.getPromptDeliveryMode === 'function'
129
118
  ? runtime.getPromptDeliveryMode(adapterOpts)
@@ -317,67 +306,6 @@ function createParentPipeForwarder(stream, outputPath, prefix = '') {
317
306
  };
318
307
  }
319
308
 
320
- /**
321
- * Build the `--add-dir` list passed to the runtime CLI. Pure: takes
322
- * `{ runtime, minionsDir, homeDir, projectHarnessDirs, hermetic, exists }`
323
- * and returns an ordered, deduped array of dirs the agent should be able to
324
- * read from outside its worktree.
325
- *
326
- * Order:
327
- * 1. minionsDir (always first, so playbooks/system-prompt are reachable).
328
- * 2. Every existing dir from `runtime.getUserAssetDirs({ homeDir })` —
329
- * user-scope skill/command/MCP roots (`~/.claude`, `~/.copilot`,
330
- * `~/.agents`, …) so worktree-bound agents inherit user-installed
331
- * harnesses.
332
- * 3. P-08b62d49 — Every existing dir in `projectHarnessDirs` (engine.js
333
- * passes the union of `queries.getProjectHarnesses(project).{skills,
334
- * commands}` dirs that live in `project.localPath` but NOT under the
335
- * worktree, so uncommitted `<repo>/.claude/skills/…` etc. on the main
336
- * checkout are reachable from worktree dispatches). See
337
- * `docs/harness-propagation.md` → "The worktree-uncommitted footgun".
338
- *
339
- * Non-existent dirs are dropped at every layer — Claude CLI rejects unknown
340
- * `--add-dir` entries. The dedup compares resolved paths so we never emit a
341
- * dir twice (e.g. when a runtime asset dir IS the minions repo in unusual
342
- * setups, or when a project-harness dir duplicates a user-asset dir).
343
- *
344
- * P-49e1c8b7 — When `hermetic === true`, the result is exactly `[minionsDir]`:
345
- * both the runtime adapter's user-asset dirs AND `projectHarnessDirs` are
346
- * dropped so the spawned agent runs with a known-empty harness surface.
347
- * Engine.js sets this via `shared.resolveAgentHermeticHarness(agent, engine)`
348
- * and forwards `--hermetic-harness` to spawn-agent so the flag survives the
349
- * subprocess hop.
350
- *
351
- * `exists` is injectable for tests; defaults to `fs.existsSync`.
352
- */
353
- function computeAddDirs({ runtime, minionsDir, homeDir, projectHarnessDirs, hermetic, exists = fs.existsSync } = {}) {
354
- if (hermetic) return [minionsDir];
355
- const out = [minionsDir];
356
- const seen = new Set([path.resolve(minionsDir)]);
357
- const assetDirs = typeof runtime?.getUserAssetDirs === 'function'
358
- ? runtime.getUserAssetDirs({ homeDir })
359
- : [];
360
- for (const d of assetDirs) {
361
- if (!d) continue;
362
- const resolved = path.resolve(d);
363
- if (seen.has(resolved)) continue;
364
- if (!exists(d)) continue;
365
- out.push(d);
366
- seen.add(resolved);
367
- }
368
- if (Array.isArray(projectHarnessDirs)) {
369
- for (const d of projectHarnessDirs) {
370
- if (!d) continue;
371
- const resolved = path.resolve(d);
372
- if (seen.has(resolved)) continue;
373
- if (!exists(d)) continue;
374
- out.push(d);
375
- seen.add(resolved);
376
- }
377
- }
378
- return out;
379
- }
380
-
381
309
  /**
382
310
  * P-7d31a06b — Pre-approve a worktree's `.mcp.json` servers in `~/.claude.json`
383
311
  * so Claude Code's first invocation in a fresh worktree cwd doesn't show the
@@ -498,7 +426,7 @@ function main() {
498
426
  console.error('Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [--runtime <name>] [args...]');
499
427
  process.exit(1);
500
428
  }
501
- const { promptFile, sysPromptFile, runtimeName, opts, passthrough, projectHarnessDirs, hermetic } = parsed;
429
+ const { promptFile, sysPromptFile, runtimeName, opts, passthrough } = parsed;
502
430
 
503
431
  const env = cleanChildEnv();
504
432
  injectAdoTokenEnvForRepoHost(env);
@@ -532,25 +460,6 @@ function main() {
532
460
  opts.sysPromptFile = sysTmpPath;
533
461
  }
534
462
 
535
- // Skill discovery dirs — agents run with CWD set to an external repo
536
- // worktree, so runtime-native global assets would otherwise be invisible.
537
- // The adapter owns both where those assets live and how to surface them.
538
- // P-08b62d49: engine.js also forwards uncommitted project-local harness
539
- // dirs from the operator's main checkout via `--project-harness-dir <path>`
540
- // (one per dir, accumulated in `projectHarnessDirs`) so worktree-bound
541
- // agents inherit them too. See docs/harness-propagation.md.
542
- // P-49e1c8b7: when engine.js passes `--hermetic-harness`, computeAddDirs
543
- // returns [minionsDir] only — both user-asset dirs and project harness
544
- // dirs are dropped for a known-empty harness surface.
545
- const minionsDir = path.resolve(__dirname, '..');
546
- const addDirs = computeAddDirs({
547
- runtime,
548
- minionsDir,
549
- homeDir: os.homedir(),
550
- projectHarnessDirs,
551
- hermetic,
552
- });
553
-
554
463
  let resolved;
555
464
  try { resolved = runtime.resolveBinary({ env }); }
556
465
  catch (err) {
@@ -565,7 +474,7 @@ function main() {
565
474
  const invocation = buildSpawnInvocation({
566
475
  runtime, resolved,
567
476
  promptText, sysPromptText,
568
- opts, passthrough, addDirs,
477
+ opts, passthrough,
569
478
  });
570
479
 
571
480
  // Debug log (async — not on critical path). Lives inside the per-dispatch
@@ -805,6 +714,6 @@ function main() {
805
714
  });
806
715
  }
807
716
 
808
- module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, preApproveWorkspaceMcps, createParentPipeForwarder, assertStaleHeadOk };
717
+ module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, preApproveWorkspaceMcps, createParentPipeForwarder, assertStaleHeadOk };
809
718
 
810
719
  if (require.main === module) main();
@@ -0,0 +1,166 @@
1
+ // Explicit operational tooling for the SQL-only runtime state store.
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getDb } = require('./db');
6
+
7
+ function _quoteSqlString(value) {
8
+ return `'${String(value).replace(/'/g, "''")}'`;
9
+ }
10
+
11
+ function checkState() {
12
+ const db = getDb();
13
+ const schemaVersion = Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_version').get().version) || 0;
14
+ const quickCheck = db.prepare('PRAGMA quick_check').all().map(row => Object.values(row)[0]);
15
+ const foreignKeyViolations = db.prepare('PRAGMA foreign_key_check').all();
16
+ const invariants = {
17
+ orphanPrdItems: Number(db.prepare('SELECT COUNT(*) AS n FROM prd_items pi LEFT JOIN prds p ON p.id=pi.prd_id WHERE p.id IS NULL').get().n) || 0,
18
+ orphanVerifyPrs: Number(db.prepare('SELECT COUNT(*) AS n FROM prd_verify_prs vp LEFT JOIN prds p ON p.id=vp.prd_id WHERE p.id IS NULL').get().n) || 0,
19
+ brokenWorkItemPrdLinks: Number(db.prepare('SELECT COUNT(*) AS n FROM work_items wi LEFT JOIN prd_items pi ON pi.id=wi.prd_item_id WHERE wi.prd_item_id IS NOT NULL AND pi.id IS NULL').get().n) || 0,
20
+ };
21
+ const ok = quickCheck.length === 1
22
+ && quickCheck[0] === 'ok'
23
+ && foreignKeyViolations.length === 0
24
+ && Object.values(invariants).every(count => count === 0);
25
+ return { ok, schemaVersion, quickCheck, foreignKeyViolations, invariants };
26
+ }
27
+
28
+ function backupState(targetPath) {
29
+ if (!targetPath) throw new Error('state backup requires an output path');
30
+ const resolved = path.resolve(targetPath);
31
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
32
+ if (fs.existsSync(resolved)) throw new Error(`backup target already exists: ${resolved}`);
33
+ const db = getDb();
34
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
35
+ db.exec(`VACUUM INTO ${_quoteSqlString(resolved)}`);
36
+ return { ok: true, path: resolved, bytes: fs.statSync(resolved).size };
37
+ }
38
+
39
+ function exportState(targetPath) {
40
+ if (!targetPath) throw new Error('state export requires an output path');
41
+ const resolved = path.resolve(targetPath);
42
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
43
+ const db = getDb();
44
+ const tables = db.prepare(`
45
+ SELECT name FROM sqlite_master
46
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
47
+ ORDER BY name
48
+ `).all().map(row => row.name);
49
+ const data = {
50
+ format: 'minions-sql-diagnostic-export-v1',
51
+ exportedAt: new Date().toISOString(),
52
+ integrity: checkState(),
53
+ tables: {},
54
+ };
55
+ for (const table of tables) {
56
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`unsafe table name: ${table}`);
57
+ data.tables[table] = db.prepare(`SELECT * FROM "${table}"`).all();
58
+ }
59
+ const tmp = `${resolved}.tmp.${process.pid}`;
60
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
61
+ fs.renameSync(tmp, resolved);
62
+ return { ok: true, path: resolved, tables: tables.length, bytes: fs.statSync(resolved).size };
63
+ }
64
+
65
+ function _readJson(filePath, expected) {
66
+ if (!fs.existsSync(filePath)) return null;
67
+ const value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
68
+ if (expected === 'array' && !Array.isArray(value)) throw new Error(`expected array in ${filePath}`);
69
+ if (expected === 'object' && (!value || typeof value !== 'object' || Array.isArray(value))) {
70
+ throw new Error(`expected object in ${filePath}`);
71
+ }
72
+ return value;
73
+ }
74
+
75
+ function importLegacyJson(sourceRoot) {
76
+ if (!sourceRoot) throw new Error('state import-legacy-json requires a source directory');
77
+ const root = path.resolve(sourceRoot);
78
+ if (!fs.statSync(root).isDirectory()) throw new Error(`legacy source is not a directory: ${root}`);
79
+ const imported = {};
80
+ const replace = (modulePath, fn, value) => {
81
+ if (value == null) return;
82
+ require(modulePath)[fn](() => value);
83
+ imported[fn] = (imported[fn] || 0) + 1;
84
+ };
85
+
86
+ replace('./dispatch-store', 'applyDispatchMutation', _readJson(path.join(root, 'engine', 'dispatch.json'), 'object'));
87
+ replace('./metrics-store', 'applyMetricsMutation', _readJson(path.join(root, 'engine', 'metrics.json'), 'object'));
88
+ replace('./watches-store', 'applyWatchesMutation', _readJson(path.join(root, 'engine', 'watches.json'), 'array'));
89
+
90
+ const small = [
91
+ ['schedule-runs.json', 'applyScheduleRunsMutation', 'object'],
92
+ ['pipeline-runs.json', 'applyPipelineRunsMutation', 'object'],
93
+ ['managed-processes.json', 'applyManagedProcessesMutation', 'object'],
94
+ ['worktree-pool.json', 'applyWorktreePoolMutation', 'object'],
95
+ ['qa-runs.json', 'applyQaRunsMutation', 'array'],
96
+ ['qa-sessions.json', 'applyQaSessionsMutation', 'array'],
97
+ ['pr-links.json', 'applyPrLinksMutation', 'object'],
98
+ ['cooldowns.json', 'applyCooldownsMutation', 'object'],
99
+ ['pending-rebases.json', 'applyPendingRebasesMutation', 'array'],
100
+ ['cc-sessions.json', 'applyCcSessionsMutation', 'array'],
101
+ ['doc-sessions.json', 'applyDocSessionsMutation', 'object'],
102
+ ];
103
+ for (const [filename, fn, shape] of small) {
104
+ replace('./small-state-store', fn, _readJson(path.join(root, 'engine', filename), shape));
105
+ }
106
+
107
+ const workItemsStore = require('./work-items-store');
108
+ const pullRequestsStore = require('./pull-requests-store');
109
+ const importScope = (scope, dir) => {
110
+ const workItems = _readJson(path.join(dir, 'work-items.json'), 'array');
111
+ if (workItems) {
112
+ workItemsStore.applyWorkItemsMutation(scope, () => workItems);
113
+ imported.workItemScopes = (imported.workItemScopes || 0) + 1;
114
+ }
115
+ const pullRequests = _readJson(path.join(dir, 'pull-requests.json'), 'array');
116
+ if (pullRequests) {
117
+ pullRequestsStore.applyPullRequestsMutation(scope, () => pullRequests);
118
+ imported.pullRequestScopes = (imported.pullRequestScopes || 0) + 1;
119
+ }
120
+ };
121
+ importScope('central', root);
122
+ try {
123
+ for (const entry of fs.readdirSync(path.join(root, 'projects'), { withFileTypes: true })) {
124
+ if (entry.isDirectory() && entry.name !== '.archived') {
125
+ importScope(entry.name, path.join(root, 'projects', entry.name));
126
+ }
127
+ }
128
+ } catch { /* projects are optional */ }
129
+
130
+ const prdStore = require('./prd-store');
131
+ for (const [dir, archived] of [[path.join(root, 'prd'), false], [path.join(root, 'prd', 'archive'), true]]) {
132
+ let files = [];
133
+ try { files = fs.readdirSync(dir).filter(name => name.endsWith('.json')); } catch { continue; }
134
+ for (const filename of files) {
135
+ const prd = _readJson(path.join(dir, filename), 'object');
136
+ const target = archived
137
+ ? path.join(require('./shared').MINIONS_DIR, 'prd', 'archive', filename)
138
+ : path.join(require('./shared').MINIONS_DIR, 'prd', filename);
139
+ prdStore.writePrd(target, prd);
140
+ imported.prds = (imported.prds || 0) + 1;
141
+ }
142
+ }
143
+
144
+ const logs = _readJson(path.join(root, 'engine', 'log.json'), 'array');
145
+ if (logs) {
146
+ const db = getDb();
147
+ const { withTransaction } = require('./db');
148
+ withTransaction(db, () => {
149
+ db.exec('DELETE FROM logs');
150
+ const insert = db.prepare('INSERT INTO logs (ts_ms, level, message, meta) VALUES (?, ?, ?, ?)');
151
+ for (const entry of logs) {
152
+ const timestamp = Date.parse(entry.timestamp);
153
+ const meta = { ...entry };
154
+ delete meta.timestamp;
155
+ delete meta.level;
156
+ delete meta.message;
157
+ const { level, message } = entry;
158
+ insert.run(Number.isFinite(timestamp) ? timestamp : Date.now(), level || 'info', String(message || ''), Object.keys(meta).length ? JSON.stringify(meta) : null);
159
+ }
160
+ });
161
+ imported.logs = logs.length;
162
+ }
163
+ return { ok: true, source: root, imported, integrity: checkState() };
164
+ }
165
+
166
+ module.exports = { checkState, backupState, exportState, importLegacyJson };
@@ -339,7 +339,6 @@ function openAppendFd(name) {
339
339
  }
340
340
 
341
341
  function _sqliteSpawnFlags() {
342
- if (process.env.MINIONS_FORCE_JSON === '1') return [];
343
342
  const major = parseInt(String(process.versions.node).split('.')[0], 10);
344
343
  if (!Number.isFinite(major) || major < 22 || major >= 24) return [];
345
344
  const nodeOpts = String(process.env.NODE_OPTIONS || '');
@@ -687,7 +687,6 @@ registerActionType(WATCH_ACTION_TYPE.CANCEL_WORK_ITEM, {
687
687
 
688
688
  let cancelled = false;
689
689
  for (const wiPath of candidates) {
690
- if (!fs.existsSync(wiPath)) continue;
691
690
  mutateWorkItems(wiPath, (items) => {
692
691
  const it = items.find(i => i && i.id === targetId);
693
692
  if (!it) return items;
@@ -743,7 +742,7 @@ registerActionType(WATCH_ACTION_TYPE.ARCHIVE_PLAN, {
743
742
  const planFile = String(p.planFile || ctx.planFile || '').trim();
744
743
  if (!planFile) return { ok: false, summary: 'archive-plan: planFile is required' };
745
744
  const prdPath = path.join(shared.MINIONS_DIR, 'prd', planFile);
746
- if (!fs.existsSync(prdPath)) return { ok: false, summary: `archive-plan: ${planFile} not found` };
745
+ if (!shared.safeJsonNoRestore(prdPath)) return { ok: false, summary: `archive-plan: ${planFile} not found` };
747
746
  let updated = false;
748
747
  mutateJsonFileLocked(prdPath, (data) => {
749
748
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
@@ -770,7 +769,7 @@ registerActionType(WATCH_ACTION_TYPE.RESUME_PLAN, {
770
769
  const planFile = String(p.planFile || ctx.planFile || '').trim();
771
770
  if (!planFile) return { ok: false, summary: 'resume-plan: planFile is required' };
772
771
  const prdPath = path.join(shared.MINIONS_DIR, 'prd', planFile);
773
- if (!fs.existsSync(prdPath)) return { ok: false, summary: `resume-plan: ${planFile} not found` };
772
+ if (!shared.safeJsonNoRestore(prdPath)) return { ok: false, summary: `resume-plan: ${planFile} not found` };
774
773
  let updated = false;
775
774
  mutateJsonFileLocked(prdPath, (data) => {
776
775
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
@@ -1,16 +1,10 @@
1
1
  // engine/watches-store.js — SQL-backed implementation of engine/watches.json.
2
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.
3
+ // A single SQL table (no per-scope split), with a diff-then-apply mutator.
7
4
  //
8
5
  // readWatches() -> [watch, watch, ...]
9
6
  // applyWatchesMutation(fn) -> diff-then-apply, returns { wrote, result }
10
7
 
11
- const fs = require('fs');
12
- const path = require('path');
13
-
14
8
  function _toMs(v) {
15
9
  if (v == null) return null;
16
10
  if (typeof v === 'number') return Number.isFinite(v) ? v : null;
@@ -18,99 +12,12 @@ function _toMs(v) {
18
12
  return Number.isFinite(parsed) ? parsed : null;
19
13
  }
20
14
 
21
- function _watchesFilePath() {
22
- const shared = require('./shared');
23
- return path.join(shared.MINIONS_DIR, 'engine', 'watches.json');
24
- }
25
-
26
15
  function _parseRow(row) {
27
16
  if (!row || !row.data) return null;
28
17
  try { return JSON.parse(row.data); }
29
18
  catch { return null; }
30
19
  }
31
20
 
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
21
  function _readWatchesFromSqlOnly(db) {
115
22
  const rows = db.prepare('SELECT data FROM watches ORDER BY rowid').all();
116
23
  const out = [];
@@ -123,22 +30,7 @@ function _readWatchesFromSqlOnly(db) {
123
30
 
124
31
  function readWatches() {
125
32
  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;
33
+ return _readWatchesFromSqlOnly(getDb());
142
34
  }
143
35
 
144
36
  function _indexById(arr) {
@@ -216,7 +108,6 @@ function applyWatchesMutation(mutator) {
216
108
  }
217
109
 
218
110
  return withTransaction(db, () => {
219
- _resyncIfJsonDiverged(db);
220
111
  const before = readWatches();
221
112
  const beforeSnapshot = JSON.parse(JSON.stringify(before));
222
113
  const next = mutator(before);
@@ -229,24 +120,7 @@ function applyWatchesMutation(mutator) {
229
120
  });
230
121
  }
231
122
 
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
123
  module.exports = {
249
124
  readWatches,
250
125
  applyWatchesMutation,
251
- _mirrorJsonFromSql,
252
126
  };