@yemi33/minions 0.1.2379 → 0.1.2380

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 (96) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +2 -3
  3. package/dashboard/js/render-prd.js +1 -1
  4. package/dashboard/js/render-work-items.js +13 -6
  5. package/dashboard.js +393 -721
  6. package/docs/architecture-review-2026-07-09.md +2 -4
  7. package/docs/auto-discovery.md +36 -49
  8. package/docs/blog-first-successful-dispatch.md +1 -1
  9. package/docs/branch-derivation.md +2 -2
  10. package/docs/command-center.md +1 -1
  11. package/docs/completion-reports.md +14 -4
  12. package/docs/constellation-bridge.md +59 -10
  13. package/docs/constellation-style-telemetry.md +6 -6
  14. package/docs/cooldown-merge-semantics.md +4 -0
  15. package/docs/copilot-cli-schema.md +3 -3
  16. package/docs/cross-repo-plans.md +17 -17
  17. package/docs/deprecated.json +2 -2
  18. package/docs/design-state-storage.md +1 -1
  19. package/docs/documentation-audit-2026-07-09.md +2 -2
  20. package/docs/engine-restart.md +20 -8
  21. package/docs/harness-mode.md +1 -1
  22. package/docs/managed-spawn.md +4 -4
  23. package/docs/onboarding.md +1 -2
  24. package/docs/pr-comment-followup.md +3 -3
  25. package/docs/pr-review-fix-loop.md +1 -1
  26. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  27. package/docs/qa-runbook-lifecycle.md +4 -4
  28. package/docs/qa-runbooks.md +2 -2
  29. package/docs/rfc-completion-json.md +4 -1
  30. package/docs/runtime-adapters.md +1 -1
  31. package/docs/self-improvement.md +4 -5
  32. package/docs/shared-lifecycle-module-map.md +3 -1
  33. package/docs/slim-ux/architecture-suggestions.md +5 -6
  34. package/docs/slim-ux/concepts.md +23 -25
  35. package/docs/watches.md +7 -7
  36. package/docs/workspace-manifests.md +1 -1
  37. package/docs/worktree-lifecycle.md +1 -1
  38. package/engine/abandoned-pr-reconciliation.js +4 -5
  39. package/engine/ado-status.js +5 -5
  40. package/engine/ado.js +20 -25
  41. package/engine/agent-worker-pool.js +58 -1
  42. package/engine/bridge.js +260 -5
  43. package/engine/cleanup.js +48 -131
  44. package/engine/cli.js +125 -83
  45. package/engine/cooldown.js +9 -16
  46. package/engine/db/index.js +22 -9
  47. package/engine/db/migrations/009-qa.js +1 -1
  48. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  49. package/engine/db/migrations/021-archived-work-items.js +72 -0
  50. package/engine/db/migrations/022-global-cc-session.js +31 -0
  51. package/engine/db/migrations/023-engine-state.js +30 -0
  52. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  53. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  54. package/engine/dispatch-store.js +2 -7
  55. package/engine/dispatch.js +34 -41
  56. package/engine/github.js +20 -27
  57. package/engine/lifecycle.js +221 -283
  58. package/engine/llm.js +1 -1
  59. package/engine/logs-store.js +2 -2
  60. package/engine/managed-spawn.js +2 -23
  61. package/engine/meeting.js +6 -6
  62. package/engine/metrics-store.js +2 -2
  63. package/engine/note-link-backfill.js +6 -11
  64. package/engine/pipeline.js +18 -36
  65. package/engine/playbook.js +1 -1
  66. package/engine/prd-store.js +73 -54
  67. package/engine/preflight.js +2 -5
  68. package/engine/projects.js +15 -62
  69. package/engine/pull-requests-store.js +0 -17
  70. package/engine/qa-runbooks.js +2 -2
  71. package/engine/qa-runs.js +1 -8
  72. package/engine/qa-sessions.js +41 -64
  73. package/engine/queries.js +120 -219
  74. package/engine/routing.js +3 -5
  75. package/engine/scheduler.js +0 -4
  76. package/engine/shared-branch-pr-reconcile.js +2 -3
  77. package/engine/shared.js +132 -637
  78. package/engine/small-state-store.js +89 -10
  79. package/engine/state-operations.js +16 -4
  80. package/engine/stdio-timestamps.js +1 -1
  81. package/engine/timeout.js +5 -12
  82. package/engine/watch-actions.js +20 -22
  83. package/engine/watches-store.js +1 -1
  84. package/engine/watches.js +6 -10
  85. package/engine/work-item-validation.js +52 -0
  86. package/engine/work-items-store.js +127 -29
  87. package/engine/worktree-gc.js +2 -2
  88. package/engine/worktree-pool.js +8 -18
  89. package/engine.js +147 -343
  90. package/minions.js +2 -2
  91. package/package.json +1 -1
  92. package/playbooks/plan-to-prd.md +2 -2
  93. package/playbooks/shared-rules.md +3 -3
  94. package/playbooks/templates/followup-dispatch.md +1 -1
  95. package/playbooks/verify.md +1 -1
  96. package/prompts/cc-system.md +2 -2
@@ -1,12 +1,4 @@
1
- // engine/small-state-store.js SQL-backed implementations for the
2
- // four Phase 7 small state files:
3
- //
4
- // schedule-runs.json <-> schedule_runs (object → row-per-id)
5
- // pipeline-runs.json <-> pipeline_runs ({id:[runs]} → row-per-run)
6
- // managed-processes.json <-> managed_processes ({specs:[]} → row-per-name)
7
- // worktree-pool.json <-> worktree_pool ({entries:[]} → row-per-entry)
8
- //
9
- // They share transaction and JSON-shape transformation infrastructure.
1
+ // SQL stores for low-traffic runtime state.
10
2
 
11
3
  function _toMs(v) {
12
4
  if (v == null) return null;
@@ -15,6 +7,47 @@ function _toMs(v) {
15
7
  return Number.isFinite(parsed) ? parsed : null;
16
8
  }
17
9
 
10
+ const ENGINE_STATE_MARKER = 'engine_state';
11
+
12
+ function _readEngineStateFromSql(db) {
13
+ const row = db.prepare('SELECT value FROM state_markers WHERE key = ?').get(ENGINE_STATE_MARKER);
14
+ if (!row) return {};
15
+ try {
16
+ const state = JSON.parse(row.value);
17
+ return state && typeof state === 'object' && !Array.isArray(state) ? state : {};
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ function readEngineState() {
24
+ const { getDb } = require('./db');
25
+ return _readEngineStateFromSql(getDb());
26
+ }
27
+
28
+ function applyEngineStateMutation(mutator) {
29
+ const { getDb, withTransaction } = require('./db');
30
+ let db;
31
+ try { db = getDb(); }
32
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
33
+
34
+ return withTransaction(db, () => {
35
+ const before = _readEngineStateFromSql(db);
36
+ const next = mutator(JSON.parse(JSON.stringify(before)));
37
+ const after = next === undefined ? before : next;
38
+ if (!after || typeof after !== 'object' || Array.isArray(after)) {
39
+ return { wrote: false, result: before };
40
+ }
41
+ if (JSON.stringify(before) === JSON.stringify(after)) return { wrote: false, result: after };
42
+ db.prepare(`
43
+ INSERT INTO state_markers (key, value, updated_at)
44
+ VALUES (?, ?, ?)
45
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
46
+ `).run(ENGINE_STATE_MARKER, JSON.stringify(after), Date.now());
47
+ return { wrote: true, result: after };
48
+ });
49
+ }
50
+
18
51
  // ─── schedule_runs ──────────────────────────────────────────────────────────
19
52
  // Shape: { [scheduleId]: { lastRun, lastWorkItemId, lastResult, lastCompletedAt } }
20
53
  // SQL: row per scheduleId, data = the value blob.
@@ -618,8 +651,10 @@ function applyPendingRebasesMutation(mutator) {
618
651
  // Shape: [ { id, sessionId, title, _promptHash, lastActiveAt, ... }, ... ]
619
652
  // SQL: row per id (each entry has a unique `id` tab key).
620
653
 
654
+ const CC_GLOBAL_SESSION_ID = '__global__';
655
+
621
656
  function _readCcSessionsFromSql(db) {
622
- const rows = db.prepare('SELECT data FROM cc_sessions ORDER BY updated_at, id').all();
657
+ const rows = db.prepare('SELECT data FROM cc_sessions WHERE id <> ? ORDER BY updated_at, id').all(CC_GLOBAL_SESSION_ID);
623
658
  const out = [];
624
659
  for (const row of rows) {
625
660
  try { out.push(JSON.parse(row.data)); }
@@ -671,6 +706,46 @@ function applyCcSessionsMutation(mutator) {
671
706
  });
672
707
  }
673
708
 
709
+ function readCcGlobalSession() {
710
+ const { getDb } = require('./db');
711
+ const row = getDb().prepare('SELECT data FROM cc_sessions WHERE id = ?').get(CC_GLOBAL_SESSION_ID);
712
+ if (!row) return null;
713
+ try {
714
+ const session = JSON.parse(row.data);
715
+ return session && typeof session === 'object' && !Array.isArray(session) ? session : null;
716
+ } catch {
717
+ return null;
718
+ }
719
+ }
720
+
721
+ function applyCcGlobalSessionMutation(mutator) {
722
+ const { getDb, withTransaction } = require('./db');
723
+ let db;
724
+ try { db = getDb(); }
725
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
726
+
727
+ return withTransaction(db, () => {
728
+ const row = db.prepare('SELECT data FROM cc_sessions WHERE id = ?').get(CC_GLOBAL_SESSION_ID);
729
+ let before = {};
730
+ try {
731
+ const parsed = row ? JSON.parse(row.data) : {};
732
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) before = parsed;
733
+ } catch { /* replace malformed state */ }
734
+ const next = mutator(JSON.parse(JSON.stringify(before)));
735
+ const after = next === undefined ? before : next;
736
+ if (!after || typeof after !== 'object' || Array.isArray(after)) {
737
+ return { wrote: false, result: before };
738
+ }
739
+ if (JSON.stringify(before) === JSON.stringify(after)) return { wrote: false, result: after };
740
+ db.prepare(`
741
+ INSERT INTO cc_sessions (id, data, updated_at)
742
+ VALUES (?, ?, ?)
743
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
744
+ `).run(CC_GLOBAL_SESSION_ID, JSON.stringify(after), Date.now());
745
+ return { wrote: true, result: after };
746
+ });
747
+ }
748
+
674
749
  // ─── doc_sessions ──────────────────────────────────────────────────────────
675
750
  // Shape: { [filePath]: { sessionId, lastActiveAt, turnCount, ... } }
676
751
  // SQL: row per filePath key.
@@ -728,6 +803,8 @@ function applyDocSessionsMutation(mutator) {
728
803
  }
729
804
 
730
805
  module.exports = {
806
+ readEngineState,
807
+ applyEngineStateMutation,
731
808
  // schedule_runs
732
809
  readScheduleRuns,
733
810
  applyScheduleRunsMutation,
@@ -758,6 +835,8 @@ module.exports = {
758
835
  // cc_sessions
759
836
  readCcSessions,
760
837
  applyCcSessionsMutation,
838
+ readCcGlobalSession,
839
+ applyCcGlobalSessionMutation,
761
840
  // doc_sessions
762
841
  readDocSessions,
763
842
  applyDocSessionsMutation,
@@ -83,6 +83,11 @@ function importLegacyJson(sourceRoot) {
83
83
  imported[fn] = (imported[fn] || 0) + 1;
84
84
  };
85
85
 
86
+ replace(
87
+ './small-state-store',
88
+ 'applyEngineStateMutation',
89
+ _readJson(path.join(root, 'engine', 'state.json'), 'object'),
90
+ );
86
91
  replace('./dispatch-store', 'applyDispatchMutation', _readJson(path.join(root, 'engine', 'dispatch.json'), 'object'));
87
92
  replace('./metrics-store', 'applyMetricsMutation', _readJson(path.join(root, 'engine', 'metrics.json'), 'object'));
88
93
  replace('./watches-store', 'applyWatchesMutation', _readJson(path.join(root, 'engine', 'watches.json'), 'array'));
@@ -103,6 +108,11 @@ function importLegacyJson(sourceRoot) {
103
108
  for (const [filename, fn, shape] of small) {
104
109
  replace('./small-state-store', fn, _readJson(path.join(root, 'engine', filename), shape));
105
110
  }
111
+ replace(
112
+ './small-state-store',
113
+ 'applyCcGlobalSessionMutation',
114
+ _readJson(path.join(root, 'engine', 'cc-session.json'), 'object'),
115
+ );
106
116
 
107
117
  const workItemsStore = require('./work-items-store');
108
118
  const pullRequestsStore = require('./pull-requests-store');
@@ -112,6 +122,11 @@ function importLegacyJson(sourceRoot) {
112
122
  workItemsStore.applyWorkItemsMutation(scope, () => workItems);
113
123
  imported.workItemScopes = (imported.workItemScopes || 0) + 1;
114
124
  }
125
+ const archivedWorkItems = _readJson(path.join(dir, 'work-items-archive.json'), 'array');
126
+ if (archivedWorkItems) {
127
+ workItemsStore.replaceArchivedWorkItems(scope, archivedWorkItems);
128
+ imported.archivedWorkItemScopes = (imported.archivedWorkItemScopes || 0) + 1;
129
+ }
115
130
  const pullRequests = _readJson(path.join(dir, 'pull-requests.json'), 'array');
116
131
  if (pullRequests) {
117
132
  pullRequestsStore.applyPullRequestsMutation(scope, () => pullRequests);
@@ -133,10 +148,7 @@ function importLegacyJson(sourceRoot) {
133
148
  try { files = fs.readdirSync(dir).filter(name => name.endsWith('.json')); } catch { continue; }
134
149
  for (const filename of files) {
135
150
  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);
151
+ prdStore.writePrd(filename, prd, { archived });
140
152
  imported.prds = (imported.prds || 0) + 1;
141
153
  }
142
154
  }
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Required at the very top of dashboard.js and engine.js (and supervisor.js).
7
7
  * Without this, "what was the dashboard doing at 01:01:56?" requires fishing
8
- * through log.json's adjacent engine entries and hoping timing lines up.
8
+ * through adjacent engine-log entries and hoping timing lines up.
9
9
  *
10
10
  * The wrap is a no-op when MINIONS_NO_STDIO_TIMESTAMPS is truthy (used by
11
11
  * tests that compare console output verbatim).
package/engine/timeout.js CHANGED
@@ -13,7 +13,7 @@ const shared = require('./shared');
13
13
  const queries = require('./queries');
14
14
  const steering = require('./steering');
15
15
 
16
- const { safeRead, safeWrite, safeJson, mutateJsonFileLocked, getProjects, projectWorkItemsPath, log, ts,
16
+ const { safeRead, safeWrite, safeJson, mutateWorkItems, getProjects, log, ts,
17
17
  ENGINE_DEFAULTS, ENGINE_DIR, WI_STATUS, WORK_TYPE, DISPATCH_RESULT, AGENT_STATUS, FAILURE_CLASS } = shared;
18
18
  const { getDispatch, getAgentStatus } = queries;
19
19
  const AGENTS_DIR = queries.AGENTS_DIR;
@@ -984,21 +984,14 @@ function checkTimeouts(config) {
984
984
  });
985
985
  }
986
986
 
987
- // Agent status is now derived from dispatch.json at read time (getAgentStatus).
987
+ // Agent status is derived from dispatch state at read time (getAgentStatus).
988
988
  // No reconcile sweep needed — dispatch IS the source of truth.
989
989
 
990
990
  // Reconcile: find work items stuck in "dispatched" with no matching active dispatch
991
991
  const activeKeys = new Set((dispatchData.active || []).map(d => d.meta?.dispatchKey).filter(Boolean));
992
- const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
993
- for (const project of getProjects(config)) {
994
- allWiPaths.push(projectWorkItemsPath(project));
995
- }
996
- for (const wiPath of allWiPaths) {
997
- // skipWriteIfUnchanged is critical — checkTimeouts runs every tick, and
998
- // without this gate the file's mtime updates on every call (no real
999
- // change), tripping the cli.js work-items.json watcher → triggering tick
1000
- // → calling checkTimeouts → ... a 5-6s loop of "File change detected".
1001
- mutateJsonFileLocked(wiPath, (items) => {
992
+ const workItemScopes = ['central', ...getProjects(config)];
993
+ for (const scope of workItemScopes) {
994
+ mutateWorkItems(scope, (items) => {
1002
995
  if (!items || !Array.isArray(items)) return items;
1003
996
  let changed = false;
1004
997
  for (const item of items) {
@@ -32,7 +32,7 @@ const { URL } = require('url');
32
32
  const shared = require('./shared');
33
33
  const {
34
34
  WATCH_ACTION_TYPE, WI_STATUS, WORK_TYPE, DONE_STATUSES, PLAN_STATUS,
35
- log, ts, uid, mutateWorkItems, mutateJsonFileLocked, projectWorkItemsPath,
35
+ log, ts, uid, mutateWorkItems,
36
36
  } = shared;
37
37
 
38
38
  // BUG-H10 (P-h10-watch-flood): statuses that mean the dispatched WI is still
@@ -387,7 +387,7 @@ registerActionType(WATCH_ACTION_TYPE.NOTIFY, {
387
387
  },
388
388
  });
389
389
 
390
- // dispatch-work-item — append a new WI to the project (or central) work-items.json.
390
+ // dispatch-work-item — append a new WI to the project or central scope.
391
391
  registerActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM, {
392
392
  label: 'Dispatch Work Item',
393
393
  description: 'Create and queue a new work item when the watch fires. Templated params support {{target}}, {{condition}}, {{prNumber}}, {{workItemId}}, etc.',
@@ -405,9 +405,7 @@ registerActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM, {
405
405
  if (!title) return { ok: false, summary: 'dispatch-work-item: title is required' };
406
406
 
407
407
  const project = p.project || watch.project || null;
408
- const wiPath = project
409
- ? projectWorkItemsPath({ name: project })
410
- : path.join(shared.MINIONS_DIR, 'work-items.json');
408
+ const scope = project || 'central';
411
409
 
412
410
  const id = 'W-' + uid();
413
411
  const item = {
@@ -437,7 +435,7 @@ registerActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM, {
437
435
 
438
436
  let appended = false;
439
437
  let dedupedAgainstId = null;
440
- mutateWorkItems(wiPath, (items) => {
438
+ mutateWorkItems(scope, (items) => {
441
439
  const existing = items.find(it =>
442
440
  it
443
441
  && it.meta
@@ -663,10 +661,10 @@ registerActionType(WATCH_ACTION_TYPE.MINIONS_API, {
663
661
  },
664
662
  });
665
663
 
666
- // cancel-work-item — flip a WI to CANCELLED across project + central files.
664
+ // cancel-work-item — flip a WI to CANCELLED across project + central scopes.
667
665
  registerActionType(WATCH_ACTION_TYPE.CANCEL_WORK_ITEM, {
668
666
  label: 'Cancel Work Item',
669
- description: 'Cancel a work item by ID. Searches the central work-items.json plus all project work-items.json files.',
667
+ description: 'Cancel a work item by ID. Searches central and project scopes.',
670
668
  params: [
671
669
  { key: 'id', required: true, description: 'Work item ID to cancel.' },
672
670
  { key: 'reason', required: false, description: 'Reason recorded on the item (defaults to "watch:<id>").' },
@@ -677,17 +675,17 @@ registerActionType(WATCH_ACTION_TYPE.CANCEL_WORK_ITEM, {
677
675
  if (!targetId) return { ok: false, summary: 'cancel-work-item: id is required' };
678
676
 
679
677
  const reason = p.reason || `watch:${watch.id}`;
680
- const candidates = [path.join(shared.MINIONS_DIR, 'work-items.json')];
678
+ const candidates = ['central'];
681
679
  try {
682
680
  const config = shared.safeJson(path.join(shared.MINIONS_DIR, 'config.json')) || {};
683
681
  for (const proj of (config.projects || [])) {
684
- if (proj && proj.name) candidates.push(projectWorkItemsPath(proj));
682
+ if (proj && proj.name) candidates.push(proj);
685
683
  }
686
684
  } catch { /* config optional */ }
687
685
 
688
686
  let cancelled = false;
689
- for (const wiPath of candidates) {
690
- mutateWorkItems(wiPath, (items) => {
687
+ for (const scope of candidates) {
688
+ mutateWorkItems(scope, (items) => {
691
689
  const it = items.find(i => i && i.id === targetId);
692
690
  if (!it) return items;
693
691
  if (DONE_STATUSES.has(it.status) || it.status === WI_STATUS.CANCELLED) return items;
@@ -730,10 +728,10 @@ registerActionType(WATCH_ACTION_TYPE.TRIGGER_PIPELINE, {
730
728
  },
731
729
  });
732
730
 
733
- // archive-plan — mark the PRD JSON file as archived (status=archived, archivedAt=now).
731
+ // archive-plan — mark the PRD as archived.
734
732
  registerActionType(WATCH_ACTION_TYPE.ARCHIVE_PLAN, {
735
733
  label: 'Archive Plan',
736
- description: 'Mark a PRD/plan as archived. Sets status="archived" and archivedAt on the PRD JSON.',
734
+ description: 'Mark a PRD/plan as archived. Sets status="archived" and archivedAt.',
737
735
  params: [
738
736
  { key: 'planFile', required: false, description: 'PRD .json filename (defaults to {{planFile}} from trigger context).' },
739
737
  ],
@@ -741,16 +739,16 @@ registerActionType(WATCH_ACTION_TYPE.ARCHIVE_PLAN, {
741
739
  const p = ctx.params || {};
742
740
  const planFile = String(p.planFile || ctx.planFile || '').trim();
743
741
  if (!planFile) return { ok: false, summary: 'archive-plan: planFile is required' };
744
- const prdPath = path.join(shared.MINIONS_DIR, 'prd', planFile);
745
- if (!shared.safeJsonNoRestore(prdPath)) return { ok: false, summary: `archive-plan: ${planFile} not found` };
742
+ const store = require('./prd-store');
743
+ if (!store.readPrd(planFile)) return { ok: false, summary: `archive-plan: ${planFile} not found` };
746
744
  let updated = false;
747
- mutateJsonFileLocked(prdPath, (data) => {
745
+ store.mutatePrd(planFile, (data) => {
748
746
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
749
747
  data.status = 'archived';
750
748
  data.archivedAt = ts();
751
749
  updated = true;
752
750
  return data;
753
- }, { defaultValue: {} });
751
+ });
754
752
  return updated
755
753
  ? { ok: true, summary: `archived plan ${planFile}` }
756
754
  : { ok: false, summary: `archive-plan: ${planFile} write failed` };
@@ -768,16 +766,16 @@ registerActionType(WATCH_ACTION_TYPE.RESUME_PLAN, {
768
766
  const p = ctx.params || {};
769
767
  const planFile = String(p.planFile || ctx.planFile || '').trim();
770
768
  if (!planFile) return { ok: false, summary: 'resume-plan: planFile is required' };
771
- const prdPath = path.join(shared.MINIONS_DIR, 'prd', planFile);
772
- if (!shared.safeJsonNoRestore(prdPath)) return { ok: false, summary: `resume-plan: ${planFile} not found` };
769
+ const store = require('./prd-store');
770
+ if (!store.readPrd(planFile)) return { ok: false, summary: `resume-plan: ${planFile} not found` };
773
771
  let updated = false;
774
- mutateJsonFileLocked(prdPath, (data) => {
772
+ store.mutatePrd(planFile, (data) => {
775
773
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
776
774
  data.status = PLAN_STATUS.ACTIVE;
777
775
  delete data.planStale;
778
776
  updated = true;
779
777
  return data;
780
- }, { defaultValue: {} });
778
+ });
781
779
  return updated
782
780
  ? { ok: true, summary: `resumed plan ${planFile}` }
783
781
  : { ok: false, summary: `resume-plan: ${planFile} write failed` };
@@ -1,4 +1,4 @@
1
- // engine/watches-store.js — SQL-backed implementation of engine/watches.json.
1
+ // engine/watches-store.js — SQL-backed watches.
2
2
  //
3
3
  // A single SQL table (no per-scope split), with a diff-then-apply mutator.
4
4
  //
package/engine/watches.js CHANGED
@@ -61,26 +61,23 @@
61
61
  * `pipelines/`); no sandboxing. Hot-reload is not supported — restart the
62
62
  * engine to pick up new plugin files (matches how playbooks already work).
63
63
  *
64
- * State stored in engine/watches.json — concurrency-safe via mutateJsonFileLocked.
64
+ * State is stored transactionally in SQLite.
65
65
  */
66
66
 
67
67
  const fs = require('fs');
68
68
  const path = require('path');
69
69
  const shared = require('./shared');
70
- const { safeJsonArr, mutateJsonFileLocked, mutateWatches, ts, uid, log, writeToInbox,
70
+ const { 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
 
74
- // Dynamic path — respects MINIONS_TEST_DIR for test isolation
75
- function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watches.json'); }
76
-
77
74
  // Default check interval: 5 minutes (300000ms). Engine tick runs every 10s
78
75
  // (W-mpxpckey000laa4f), so watches are checked every N ticks where
79
76
  // N = ceil(interval / tickInterval).
80
77
  const DEFAULT_WATCH_INTERVAL = 300000;
81
78
 
82
79
  // P-w12d8f3a — Phase 6.2: per-watch evaluation history is capped at 25
83
- // entries (oldest-first). Bounded to keep watches.json compact:
80
+ // entries (oldest-first). Bounded to keep watch state compact:
84
81
  // 25 entries × ~200 bytes × 100 watches ≈ 500 KB ceiling. History entries
85
82
  // never contain full state snapshots — only the trigger reason — per the
86
83
  // "history is small and bounded" design principle.
@@ -177,11 +174,11 @@ function listTargetTypes() {
177
174
  }
178
175
 
179
176
  /**
180
- * Read all watches from disk.
177
+ * Read all watches from SQLite.
181
178
  * @returns {Array<object>}
182
179
  */
183
180
  function getWatches() {
184
- return safeJsonArr(_watchesPath());
181
+ return require('./watches-store').readWatches();
185
182
  }
186
183
 
187
184
  /**
@@ -1366,7 +1363,7 @@ registerTargetType(WATCH_TARGET_TYPE.AGENT, {
1366
1363
  // P-w2c8d1e7 — Phase 1.2: surface currentDispatchId so predicates can
1367
1364
  // distinguish "agent now working on a different dispatch" from a plain
1368
1365
  // status flip. Sourced from queries.getAgents() which derives it from
1369
- // dispatch.json (active list) via getAgentStatus.dispatch_id.
1366
+ // active dispatch state via getAgentStatus.dispatch_id.
1370
1367
  captureState: (a) => ({
1371
1368
  status: a.status || null,
1372
1369
  currentDispatchId: a.currentDispatchId || null,
@@ -1459,7 +1456,6 @@ module.exports = {
1459
1456
  runWatchActionChain: watchActions.runWatchActionChain,
1460
1457
  validateAction: watchActions.validateAction,
1461
1458
  _captureState, // exported for testing
1462
- _watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR
1463
1459
  _runActionTask, // exported for testing — invoked by checkWatches per fired action
1464
1460
  _TARGET_TYPES: TARGET_TYPES, // exported for testing — direct registry access
1465
1461
  _loadPluginTargetTypes, // exported for testing — re-scans watches.d/
@@ -0,0 +1,52 @@
1
+ function _hasText(value) {
2
+ return typeof value === 'string' && value.trim().length > 0;
3
+ }
4
+
5
+ function _hasObjectValue(value) {
6
+ return value && typeof value === 'object' && !Array.isArray(value)
7
+ && Object.keys(value).length > 0;
8
+ }
9
+
10
+ function hasWorkItemSemantics(item) {
11
+ return _hasText(item?.title)
12
+ || _hasText(item?.description)
13
+ || _hasText(item?.type)
14
+ || _hasText(item?.itemType);
15
+ }
16
+
17
+ function hasWorkItemProvenance(item) {
18
+ return [
19
+ 'created', 'created_at', 'createdAt', 'createdBy', 'sourcePlan', 'planFile',
20
+ 'project', 'parent_id', 'prd_item_id', '_originWi', '_originAgent',
21
+ '_pipelineRun', '_pipelineStage',
22
+ ].some(key => item?.[key] !== undefined && item[key] !== null && item[key] !== '')
23
+ || _hasObjectValue(item?.meta)
24
+ || (Array.isArray(item?.references) && item.references.length > 0);
25
+ }
26
+
27
+ function validateNewWorkItem(item) {
28
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
29
+ throw new Error('malformed work item: record must be an object');
30
+ }
31
+ if (!_hasText(String(item.id || ''))) {
32
+ throw new Error('malformed work item: id is required');
33
+ }
34
+ if (!hasWorkItemSemantics(item) && !hasWorkItemProvenance(item)) {
35
+ throw new Error(`malformed work item ${item.id}: semantic content or provenance is required`);
36
+ }
37
+ }
38
+
39
+ function isMalformedPhantomWorkItem(item) {
40
+ return !!item
41
+ && typeof item === 'object'
42
+ && item.status === 'failed'
43
+ && !hasWorkItemSemantics(item)
44
+ && !hasWorkItemProvenance(item);
45
+ }
46
+
47
+ module.exports = {
48
+ hasWorkItemSemantics,
49
+ hasWorkItemProvenance,
50
+ validateNewWorkItem,
51
+ isMalformedPhantomWorkItem,
52
+ };