@yemi33/minions 0.1.2066 → 0.1.2068

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/README.md CHANGED
@@ -11,11 +11,11 @@ Inspired by and initially scaffolded from [Brady Gaster's Squad](https://bradyga
11
11
  ## Prerequisites
12
12
 
13
13
  - **Node.js** 18+ (LTS recommended)
14
- - **A supported runtime CLI** — Minions defaults to GitHub Copilot CLI (`npm install -g @github/copilot`). Claude Code CLI (`npm install -g @anthropic-ai/claude-code`) is also supported; switch with `minions config set-cli claude` or per-agent `cli` overrides.
15
- - **Auth for your runtime** — GitHub Copilot subscription (Copilot CLI handles its own auth) or an Anthropic API key / Claude Max subscription
14
+ - **A supported runtime CLI** — Minions defaults to GitHub Copilot CLI (`npm install -g @github/copilot`). Claude Code CLI (`npm install -g @anthropic-ai/claude-code`) and OpenAI Codex CLI (`npm install -g @openai/codex` or `brew install --cask codex`) are also supported; switch with `minions config set-cli claude`, `minions config set-cli codex`, or per-agent `cli` overrides.
15
+ - **Auth for your runtime** — GitHub Copilot subscription (Copilot CLI handles its own auth), OpenAI Codex login / API key, or an Anthropic API key / Claude Max subscription
16
16
  - **Git** — agents create worktrees for all code changes
17
17
 
18
- > **Note:** you do **not** need to configure your CLI for "autopilot" / "bypass permissions" / "dangerous mode". Minions passes the right bypass flag per spawn (`--dangerously-skip-permissions` for Claude; `--autopilot --allow-all --no-ask-user` for Copilot), independent of your global CLI config. Run `minions doctor` to verify your installed CLI accepts those flags.
18
+ > **Note:** you do **not** need to configure your CLI for "autopilot" / "bypass permissions" / "dangerous mode". Minions passes the right runtime-owned flag per spawn (`--dangerously-skip-permissions` for Claude; `--autopilot --allow-all --no-ask-user` for Copilot; `codex exec --sandbox workspace-write --json -` for Codex), independent of your global CLI config. Run `minions doctor` to verify your installed CLI accepts those flags.
19
19
 
20
20
  ## Installation
21
21
 
@@ -11,6 +11,7 @@ behavior is hidden behind an adapter object resolved through `resolveRuntime()`.
11
11
  | `engine/runtimes/index.js` | Adapter registry. `resolveRuntime(name)`, `listRuntimes()`, `registerRuntime()`. Engine code MUST go through these. |
12
12
  | `engine/runtimes/claude.js` | Claude Code adapter. Owns binary probe, `--system-prompt-file`, JSONL parser, model shorthands, budget cap, bare mode. |
13
13
  | `engine/runtimes/copilot.js` | GitHub Copilot CLI adapter. Owns standalone-vs-`gh-copilot` resolution, stdin-only prompt delivery, `https://api.githubcopilot.com/models` discovery, effort `'max' → 'xhigh'` mapping. |
14
+ | `engine/runtimes/codex.js` | OpenAI Codex CLI adapter. Owns `@openai/codex`/native binary resolution, `codex exec --json -` prompt delivery, `codex debug models --bundled` discovery, `.agents/skills` roots, and Codex JSONL parsing. |
14
15
 
15
16
  `resolveRuntime(name)` throws when `name` is unknown so misconfigurations surface
16
17
  at dispatch time instead of producing silent fallbacks deep inside spawn logic.
@@ -62,24 +63,24 @@ this table is the human-readable mirror.
62
63
  Engine code branches on flags, never on runtime names. Add a flag whenever a
63
64
  real behavioral split appears between adapters.
64
65
 
65
- | Flag | Claude | Copilot | Gates |
66
- |------|--------|---------|-------|
67
- | `streaming` | ✓ | ✓ | JSONL events on stdout. |
68
- | `sessionResume` | ✓ | ✓ | `--resume <id>`. |
69
- | `midRunSessionId` | ✓ | ✗ | Resumable session ID emitted before the terminal `result` event; when false, steering waits for a checkpoint. |
70
- | `systemPromptFile` | ✓ | ✗ | sysprompt via `--system-prompt-file` (else inlined into stdin by `buildPrompt`). |
71
- | `effortLevels` | ✓ | ✓ | `--effort low\|medium\|high\|xhigh`. Copilot maps `'max' 'xhigh'` inside `resolveModel`/`buildArgs`; Claude leaves `'max'` alone. |
72
- | `costTracking` | ✓ | ✗ | USD + token counts in the result event. Copilot only emits `premiumRequests`. |
73
- | `modelShorthands` | ✓ | ✗ | Bare `sonnet`/`opus`/`haiku` accepted. Copilot expects full model IDs (`claude-sonnet-4.5`, `gpt-5.4`). |
74
- | `modelDiscovery` | ✗ | ✓ | `listModels()` returns a real catalog (Copilot reads `https://api.githubcopilot.com/models`). |
75
- | `promptViaArg` | ✗ | ✗ | If true, prompt goes via `--prompt <text>` instead of stdin. False on both because Windows ARG_MAX (~32 KB) breaks `-p "<long-prompt>"` outright. |
76
- | `budgetCap` | ✓ | ✗ | `--max-budget-usd <n>`. |
77
- | `bareMode` | ✓ | ✗ | `--bare` (suppresses CLAUDE.md auto-discovery). Closest Copilot equivalent is `--no-custom-instructions`, gated separately. |
78
- | `fallbackModel` | ✓ | ✗ | `--fallback-model <id>` on rate-limit. |
79
- | `sessionPersistenceControl` | ✓ | ✗ | Engine writes `session.json`. Copilot manages session state internally in `~/.copilot/session-state/`. |
80
- | `resumePromptCarryover` | ✗ | ✓ | CC resume turns prepend recent visible Q&A in stdin because Copilot's session store is opaque to Minions. |
81
- | `resumeBookkeepingTurn` | ✓ | — | Claude CLI injects a synthetic "Continue from where you left off." meta turn on `--resume`; CC prompts must tell the model not to treat it as user intent. |
82
- | `streamConsumer` | ✓ | ✓ | Adapter implements `createStreamConsumer(ctx)` — required by `engine/llm.js` accumulator. |
66
+ | Flag | Claude | Copilot | Codex | Gates |
67
+ |------|--------|---------|-------|-------|
68
+ | `streaming` | ✓ | ✓ | ✓ | JSONL events on stdout. |
69
+ | `sessionResume` | ✓ | ✓ | ✓ | Runtime-specific resume args. |
70
+ | `midRunSessionId` | ✓ | ✗ | ✗ | Resumable session ID emitted before the terminal `result` event; when false, steering waits for a checkpoint. |
71
+ | `systemPromptFile` | ✓ | ✗ | ✗ | sysprompt via `--system-prompt-file` (else inlined into stdin by `buildPrompt`). |
72
+ | `effortLevels` | ✓ | ✓ | ✓ | Runtime reasoning-effort controls. Codex maps through `--config model_reasoning_effort=...`; Copilot maps `'max'` to `'xhigh'`; Claude leaves `'max'` alone. |
73
+ | `costTracking` | ✓ | ✗ | ✗ | USD + token counts in the result event. Copilot only emits `premiumRequests`; Codex accounting is not assumed. |
74
+ | `modelShorthands` | ✓ | ✗ | ✗ | Bare `sonnet`/`opus`/`haiku` accepted by Claude only. |
75
+ | `modelDiscovery` | ✗ | ✓ | ✓ | `listModels()` returns a real catalog when supported (Copilot API, Codex `debug models --bundled`). |
76
+ | `promptViaArg` | ✗ | ✗ | ✗ | If true, prompt goes via `--prompt <text>` instead of stdin. False for all current runtimes. |
77
+ | `budgetCap` | ✓ | ✗ | ✗ | `--max-budget-usd <n>`. |
78
+ | `bareMode` | ✓ | ✗ | ✗ | `--bare` (suppresses CLAUDE.md auto-discovery). Other runtimes use their own config/suppression knobs. |
79
+ | `fallbackModel` | ✓ | ✗ | ✗ | `--fallback-model <id>` on rate-limit. |
80
+ | `sessionPersistenceControl` | ✓ | ✗ | ✗ | Engine writes `session.json`; Copilot and Codex own their runtime stores, while Minions records IDs only for safe resume. |
81
+ | `resumePromptCarryover` | ✗ | ✓ | ✓ | CC resume turns prepend recent visible Q&A in stdin when the runtime session store is opaque to Minions. |
82
+ | `resumeBookkeepingTurn` | ✓ | — | — | Claude CLI injects a synthetic "Continue from where you left off." meta turn on `--resume`; CC prompts must tell the model not to treat it as user intent. |
83
+ | `streamConsumer` | ✓ | ✓ | ✓ | Adapter implements `createStreamConsumer(ctx)` — required by `engine/llm.js` accumulator. |
83
84
 
84
85
  When a behavior is genuinely uniform across all current adapters, it still gets
85
86
  a flag if a future runtime might disagree — flags are cheap.
@@ -165,6 +166,7 @@ contracts.
165
166
  |---------|---------------------|---------------------|-------------------------------|
166
167
  | Claude | `~/.claude/skills` | `<repo>/.claude/skills` | personal: `~/.claude/skills`; project: `<repo>/.claude/skills` |
167
168
  | Copilot | `~/.copilot/skills` (+ `~/.agents/skills`) | `<repo>/.github/skills`, `<repo>/.agents/skills` | personal: `~/.copilot/skills`; project: `<repo>/.github/skills` |
169
+ | Codex | `~/.agents/skills` (+ `/etc/codex/skills`) | `<repo>/.agents/skills` | personal: `~/.agents/skills`; project: `<repo>/.agents/skills` |
168
170
 
169
171
  `getUserAssetDirs()` returns the union of runtime-native global roots and is
170
172
  passed to spawn as `--add-dir` so spawned agents read the same global assets the
@@ -18,6 +18,7 @@ const path = require('path');
18
18
  const fs = require('fs');
19
19
 
20
20
  let _db = null;
21
+ let _dbPath = null;
21
22
  let _dbInitError = null;
22
23
 
23
24
  function _resolveDbPath() {
@@ -53,13 +54,34 @@ function _installExperimentalWarningFilter() {
53
54
  }
54
55
 
55
56
  function getDb() {
56
- if (_db) return _db;
57
+ // Re-resolve the DB path on every call so tests that swap MINIONS_TEST_DIR
58
+ // (or production-side configuration that changes MINIONS_HOME between
59
+ // operations) get a fresh connection to the new location instead of
60
+ // a stale handle pointing at a now-deleted tmpdir. The resolve itself
61
+ // is just env-var reads + path.join — sub-microsecond.
62
+ let dbPath;
63
+ try { dbPath = _resolveDbPath(); }
64
+ catch (e) {
65
+ _dbInitError = new Error(`engine/db: cannot resolve DB path — ${e.message}`);
66
+ throw _dbInitError;
67
+ }
68
+
69
+ if (_db) {
70
+ if (_dbPath === dbPath) return _db;
71
+ // Path changed (test isolation swap, or operator reconfigured
72
+ // MINIONS_HOME). Close the old handle and reopen against the new path.
73
+ try { _db.close(); } catch { /* already closed */ }
74
+ _db = null;
75
+ _dbPath = null;
76
+ _dbInitError = null;
77
+ }
78
+
57
79
  if (_dbInitError) throw _dbInitError;
58
80
  try {
59
81
  _installExperimentalWarningFilter();
60
82
  const { DatabaseSync } = require('node:sqlite');
61
- const dbPath = _resolveDbPath();
62
83
  _db = new DatabaseSync(dbPath);
84
+ _dbPath = dbPath;
63
85
  // WAL mode lets the engine writer and dashboard reader hit the same
64
86
  // file from two processes without lock contention. NORMAL synchronous
65
87
  // is the recommended WAL pairing (durable enough; one OS-level fsync
@@ -72,11 +94,6 @@ function getDb() {
72
94
  runMigrations(_db);
73
95
  return _db;
74
96
  } catch (e) {
75
- // Cache the failure so we don't repeatedly retry a broken setup. Most
76
- // callers wrap getDb() in try/catch and treat the cache-version probe
77
- // as best-effort, so a missing-Node-module install still boots the
78
- // dashboard fine — it just won't benefit from cross-process cache
79
- // invalidation until the install is fixed.
80
97
  _dbInitError = new Error(
81
98
  `engine/db: failed to open SQLite — ${e.message}. ` +
82
99
  `Node 22.5+ required for built-in 'node:sqlite' support.`
@@ -88,7 +105,37 @@ function getDb() {
88
105
  // Force-close — used by tests + graceful shutdown. Safe to call when not open.
89
106
  function closeDb() {
90
107
  if (_db) { try { _db.close(); } catch { /* already closed */ } _db = null; }
108
+ _dbPath = null;
91
109
  _dbInitError = null;
92
110
  }
93
111
 
94
- module.exports = { getDb, closeDb };
112
+ // Run `fn(db)` inside a transaction. Nestable: only the OUTERMOST call
113
+ // issues BEGIN/COMMIT; inner calls run inside the enclosing tx so multiple
114
+ // stores (dispatches + work_items + ...) can compose into one atomic
115
+ // mutation. Inner rollback propagates outward, which is the correct
116
+ // semantics for cross-store invariants — if a nested mutation fails, the
117
+ // enclosing one must also fail.
118
+ //
119
+ // We track depth on the db instance itself rather than module-level so
120
+ // reopening the singleton (test isolation, path changes) doesn't leak
121
+ // stale depth into a fresh connection.
122
+ function withTransaction(db, fn) {
123
+ if (!db) throw new Error('engine/db.withTransaction: db is required');
124
+ const isOuter = !db._minionsTxDepth;
125
+ db._minionsTxDepth = (db._minionsTxDepth || 0) + 1;
126
+ if (isOuter) db.exec('BEGIN IMMEDIATE');
127
+ try {
128
+ const result = fn(db);
129
+ db._minionsTxDepth -= 1;
130
+ if (db._minionsTxDepth === 0) db.exec('COMMIT');
131
+ return result;
132
+ } catch (e) {
133
+ db._minionsTxDepth -= 1;
134
+ if (db._minionsTxDepth === 0) {
135
+ try { db.exec('ROLLBACK'); } catch { /* best-effort */ }
136
+ }
137
+ throw e;
138
+ }
139
+ }
140
+
141
+ module.exports = { getDb, closeDb, withTransaction };
@@ -0,0 +1,113 @@
1
+ // engine/db/migrations/002-dispatches.js
2
+ //
3
+ // Phase 1: move engine/dispatch.json into a `dispatches` table.
4
+ //
5
+ // Schema is intentionally hybrid: a small set of indexed projection columns
6
+ // (status, agent, type, timestamps) for hot filters/sorts, plus a single
7
+ // `data` TEXT column holding the full record JSON. This way the
8
+ // `mutateDispatch(fn)` API contract — "mutator receives the section-shaped
9
+ // object, mutates in place, returns" — round-trips every field cleanly
10
+ // without us needing to enumerate every possible column ahead of time.
11
+ // New fields the engine adds in a future commit land in `data`
12
+ // automatically.
13
+ //
14
+ // Backfill: read engine/dispatch.json's four sections (pending / active /
15
+ // completed / review), INSERT one row per dispatch with `status` derived
16
+ // from the section name. Timestamps are parsed from ISO strings to
17
+ // milliseconds since epoch (matches the rest of the codebase). The JSON
18
+ // file is renamed to engine/dispatch.json.pre-sql-<ts> for rollback —
19
+ // NOT deleted. A separate `minions db rollback-to-json` command (next
20
+ // phase) will read the table and reconstruct the JSON if the user pins
21
+ // to a pre-migration version.
22
+
23
+ const path = require('path');
24
+ const fs = require('fs');
25
+
26
+ function _resolveMinionsDir() {
27
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
28
+ if (envHome) return envHome;
29
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
30
+ }
31
+
32
+ function _toMs(v) {
33
+ if (v == null) return null;
34
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
35
+ const parsed = Date.parse(v);
36
+ return Number.isFinite(parsed) ? parsed : null;
37
+ }
38
+
39
+ module.exports = {
40
+ version: 2,
41
+ description: 'dispatches: schema + dispatch.json backfill',
42
+ up(db) {
43
+ db.exec(`
44
+ CREATE TABLE dispatches (
45
+ id TEXT PRIMARY KEY,
46
+ status TEXT NOT NULL,
47
+ agent TEXT,
48
+ type TEXT,
49
+ created_at INTEGER,
50
+ started_at INTEGER,
51
+ completed_at INTEGER,
52
+ data TEXT NOT NULL,
53
+ updated_at INTEGER NOT NULL
54
+ );
55
+ CREATE INDEX idx_dispatches_status ON dispatches(status);
56
+ CREATE INDEX idx_dispatches_agent ON dispatches(agent);
57
+ CREATE INDEX idx_dispatches_status_completed ON dispatches(status, completed_at DESC);
58
+ CREATE INDEX idx_dispatches_status_created ON dispatches(status, created_at DESC);
59
+ `);
60
+
61
+ const minionsDir = _resolveMinionsDir();
62
+ if (!minionsDir) return;
63
+ const dispatchPath = path.join(minionsDir, 'engine', 'dispatch.json');
64
+ if (!fs.existsSync(dispatchPath)) return;
65
+
66
+ let raw;
67
+ try { raw = JSON.parse(fs.readFileSync(dispatchPath, 'utf8')); }
68
+ catch (e) {
69
+ // Corrupt JSON — abort the migration, surfacing the error. The user
70
+ // can fix the file (or restore from the .backup sidecar safeWrite
71
+ // keeps) and re-run the engine; the migration will retry.
72
+ throw new Error(`engine/db/002-dispatches: cannot parse dispatch.json: ${e.message}`);
73
+ }
74
+
75
+ const now = Date.now();
76
+ const insert = db.prepare(`
77
+ INSERT INTO dispatches (id, status, agent, type, created_at, started_at, completed_at, data, updated_at)
78
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
79
+ `);
80
+
81
+ let inserted = 0;
82
+ for (const status of ['pending', 'active', 'completed', 'review']) {
83
+ const rows = raw[status];
84
+ if (!Array.isArray(rows)) continue;
85
+ for (const d of rows) {
86
+ if (!d || typeof d !== 'object' || !d.id) continue;
87
+ insert.run(
88
+ String(d.id),
89
+ status,
90
+ d.agent || null,
91
+ d.type || null,
92
+ _toMs(d.created_at),
93
+ _toMs(d.started_at),
94
+ _toMs(d.completed_at),
95
+ JSON.stringify(d),
96
+ now,
97
+ );
98
+ inserted += 1;
99
+ }
100
+ }
101
+
102
+ // dispatch.json stays on disk as a dual-write mirror — every successful
103
+ // mutateDispatch refreshes it from SQL via dispatch-store._mirrorJsonFromSql.
104
+ // SQL is the source of truth; the file is a read-only derivative kept
105
+ // for backward compatibility with legacy direct-readers (engine/routing.js,
106
+ // engine/queries.js fallback, test infrastructure). Rollback path: if a
107
+ // user pins back to a pre-Phase-1 release, dispatch.json already contains
108
+ // the latest committed state (last mirror write), and the SQL table can
109
+ // be discarded.
110
+ // eslint-disable-next-line no-console
111
+ console.log(`[db-migrate] v2: backfilled ${inserted} dispatches; dispatch.json kept as dual-write mirror`);
112
+ },
113
+ };
@@ -0,0 +1,128 @@
1
+ // engine/db/migrations/003-work-items.js
2
+ //
3
+ // Phase 2: move work-items.json (central + per-project) into a `work_items`
4
+ // table.
5
+ //
6
+ // Same hybrid schema as `dispatches`: typed projection columns for hot
7
+ // filters (scope/status/agent/parent), plus a `data` TEXT column holding
8
+ // the full record JSON so future fields land transparently.
9
+ //
10
+ // `scope` keys the file the record came from: 'central' for
11
+ // <MINIONS_DIR>/work-items.json, otherwise the project name (matches the
12
+ // directory under projects/<name>/). Per-scope writes diff against the
13
+ // scope's rows so a mutation on one project's file never touches another's.
14
+ //
15
+ // Backfill: walk central + every projects/<name>/work-items.json. The
16
+ // JSON files stay on disk as a dual-write mirror so legacy direct-readers
17
+ // continue to function while we migrate them in Phase 2.5.
18
+
19
+ const path = require('path');
20
+ const fs = require('fs');
21
+
22
+ function _resolveMinionsDir() {
23
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
24
+ if (envHome) return envHome;
25
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
26
+ }
27
+
28
+ function _toMs(v) {
29
+ if (v == null) return null;
30
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
31
+ const parsed = Date.parse(v);
32
+ return Number.isFinite(parsed) ? parsed : null;
33
+ }
34
+
35
+ function _readJsonArray(filePath) {
36
+ try {
37
+ const raw = fs.readFileSync(filePath, 'utf8');
38
+ const parsed = JSON.parse(raw);
39
+ return Array.isArray(parsed) ? parsed : [];
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ function _listProjectDirs(minionsDir) {
46
+ const projectsRoot = path.join(minionsDir, 'projects');
47
+ let entries;
48
+ try { entries = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
49
+ catch { return []; }
50
+ const out = [];
51
+ for (const e of entries) {
52
+ if (!e.isDirectory()) continue;
53
+ if (e.name === '.archived') continue;
54
+ out.push(e.name);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ module.exports = {
60
+ version: 3,
61
+ description: 'work_items: schema + work-items.json backfill (central + per-project)',
62
+ up(db) {
63
+ db.exec(`
64
+ CREATE TABLE work_items (
65
+ id TEXT NOT NULL,
66
+ scope TEXT NOT NULL,
67
+ status TEXT NOT NULL,
68
+ type TEXT,
69
+ agent TEXT,
70
+ parent_id TEXT,
71
+ created_at INTEGER,
72
+ completed_at INTEGER,
73
+ data TEXT NOT NULL,
74
+ updated_at INTEGER NOT NULL,
75
+ PRIMARY KEY (scope, id)
76
+ );
77
+ CREATE INDEX idx_wi_status ON work_items(status);
78
+ CREATE INDEX idx_wi_scope_status ON work_items(scope, status);
79
+ CREATE INDEX idx_wi_agent ON work_items(agent);
80
+ CREATE INDEX idx_wi_parent ON work_items(parent_id);
81
+ `);
82
+
83
+ const minionsDir = _resolveMinionsDir();
84
+ if (!minionsDir) return;
85
+
86
+ const insert = db.prepare(`
87
+ INSERT INTO work_items (id, scope, status, type, agent, parent_id, created_at, completed_at, data, updated_at)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
89
+ `);
90
+
91
+ const now = Date.now();
92
+ let inserted = 0;
93
+
94
+ const backfillFile = (scope, filePath) => {
95
+ const rows = _readJsonArray(filePath);
96
+ for (const wi of rows) {
97
+ if (!wi || typeof wi !== 'object' || !wi.id) continue;
98
+ try {
99
+ insert.run(
100
+ String(wi.id),
101
+ scope,
102
+ String(wi.status || 'pending'),
103
+ wi.type || null,
104
+ wi.dispatched_to || wi.agent || null,
105
+ wi.parent_id || null,
106
+ _toMs(wi.created_at),
107
+ _toMs(wi.completed_at || wi.completedAt),
108
+ JSON.stringify(wi),
109
+ now,
110
+ );
111
+ inserted += 1;
112
+ } catch {
113
+ // Duplicate (scope, id) within the same file → skip. Real-world
114
+ // work-items.json doesn't carry dupes, but defensive: a corrupted
115
+ // file shouldn't abort the whole migration.
116
+ }
117
+ }
118
+ };
119
+
120
+ backfillFile('central', path.join(minionsDir, 'work-items.json'));
121
+ for (const projectName of _listProjectDirs(minionsDir)) {
122
+ backfillFile(projectName, path.join(minionsDir, 'projects', projectName, 'work-items.json'));
123
+ }
124
+
125
+ // eslint-disable-next-line no-console
126
+ console.log(`[db-migrate] v3: backfilled ${inserted} work items; work-items.json files kept as dual-write mirrors`);
127
+ },
128
+ };