@worca/app 0.0.1

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 (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,612 @@
1
+ // src/core/migrate-fs-to-db.mjs
2
+ // One-time importer: legacy JSON (the pre-SQLite filesystem state) -> the SQLite DB.
3
+ //
4
+ // Called once by db.mjs#getDb() after migrate(db) stamps the schema, before the
5
+ // singleton handle is published. SYNCHRONOUS (it runs inside the sync open
6
+ // sequence): all FS access is node:fs sync; all inserts run in ONE transaction on
7
+ // the passed `db`. Self-guarded + idempotent: a no-op unless the DB has no migrated
8
+ // data AND legacy JSON is present. Crash-safe: COMMIT first, then archive consumed
9
+ // JSON into <worcaHome>/backup-<ts>/ (mirroring the relative layout); an
10
+ // interrupted archive is harmless because the row-count guard makes a re-run a
11
+ // no-op. Markdown agent outputs + extras/ are NEVER moved (FS keeps them; spec §4/§5).
12
+ //
13
+ // DECOUPLED from the service layer: it does NOT call config/projects/workspaces/
14
+ // artifacts write paths (those become SQL in Phases 2/3 and would re-enter getDb()).
15
+ // It writes direct prepared INSERTs against the FINAL v1 DDL and uses ONLY pure
16
+ // path/key helpers (worcaHome, projectKey, store paths) that never touch the DB.
17
+
18
+ import {
19
+ existsSync, readFileSync, readdirSync, statSync,
20
+ mkdirSync, renameSync, cpSync, unlinkSync,
21
+ } from 'node:fs';
22
+ import { join, resolve, dirname, relative } from 'node:path';
23
+
24
+ import { worcaHome } from './projects.mjs';
25
+ import {
26
+ projectKey, storeRoot, workspacesStoreRoot,
27
+ } from './store.mjs';
28
+
29
+ // ── tiny fail-safe IO helpers ────────────────────────────────────────────────────
30
+
31
+ /** Parse a JSON file; missing/corrupt -> undefined (never throws). */
32
+ function readJsonSafe(file) {
33
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return undefined; }
34
+ }
35
+ /** Read a text file; missing -> undefined (never throws). */
36
+ function readTextSafe(file) {
37
+ try { return readFileSync(file, 'utf8'); } catch { return undefined; }
38
+ }
39
+ /** List a directory's dirents; missing -> [] (never throws). */
40
+ function readDirSafe(dir) {
41
+ try { return readdirSync(dir, { withFileTypes: true }); } catch { return []; }
42
+ }
43
+ /** True when the path is an existing directory (never throws). */
44
+ function isDir(p) { try { return statSync(p).isDirectory(); } catch { return false; } }
45
+
46
+ /** Coerce to a JSON TEXT column value: stringify when present, else null. */
47
+ function jsonCol(value) {
48
+ return value === undefined || value === null ? null : JSON.stringify(value);
49
+ }
50
+ /** Nullable boolean -> 1/0/null for an INTEGER column. */
51
+ function boolCol(v) { return typeof v === 'boolean' ? (v ? 1 : 0) : null; }
52
+
53
+ const nowIso = () => new Date().toISOString();
54
+
55
+ // ── the per-project config path (INLINED to stay fully decoupled from config.mjs) ──
56
+ function projectConfigFile(projectDir) {
57
+ return join(resolve(projectDir), '.worca-cc', 'config.json');
58
+ }
59
+
60
+ // ── the set of pipeline-dir JSON/md files we CONSUME (move to backup) ───────────────
61
+ // pipeline.md is consumed (fully captured as pipeline_events). prompt.md stays
62
+ // (kept markdown; its body is also copied into pipelines.prompt). Everything else
63
+ // that is markdown stays on disk.
64
+ const REVIEW_FILE_RE = /^(refine|impl|plan|ws|webui)-review-cycle(\d+)\.json$/;
65
+
66
+ // A14: ESM namespace exports are non-configurable, so the Phase-1 hook-call test
67
+ // (db.test.mjs) cannot mock.method() this function. It instead asserts an
68
+ // OBSERVABLE effect — this module-level call counter — to prove getDb() invokes the
69
+ // hook exactly once, after migrate(), on first open (and not on a cached re-open).
70
+ // The real importer keeps the increment: it is harmless (a single bump at the top of
71
+ // every call, before any early return) and preserves that frozen Phase-1 contract.
72
+ let _callCount = 0;
73
+
74
+ // M3: one-time completion latch. The old guard (projects>0 OR pipelines>0) was a
75
+ // row-count PROXY: a DB with projects but zero finished pipelines read as "done",
76
+ // so a legacy pipeline JSON reappearing under store/ would be skipped forever; and
77
+ // legacyPresent() can never go false (store/ keeps markdown). Instead we stamp a
78
+ // sentinel store_meta row INSIDE the import tx and gate re-runs on it. store_meta
79
+ // has no CHECK on `kind`, and every reader looks it up by an exact key (never scans
80
+ // the table), so this reserved key is invisible to the service layer.
81
+ const MIGRATION_MARKER_KEY = '__fs_migration__';
82
+
83
+ // ─────────────────────────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Migrate legacy JSON state into the DB on first run. Self-guarded + idempotent.
87
+ * @param {import('node:sqlite').DatabaseSync} db
88
+ * @returns {void}
89
+ */
90
+ export function maybeMigrateFromFs(db) {
91
+ _callCount += 1; // A14: observable hook-fired effect (see above)
92
+ const home = worcaHome();
93
+ if (!legacyPresent(home)) return; // nothing on disk to import
94
+ if (alreadyMigrated(db)) return; // DB already holds migrated data
95
+
96
+ const plan = collectLegacy(home); // pure reads -> in-memory rows + consumed-file list
97
+ if (!plan.hasRows) return; // corrupt-only tree: leave JSON, no-op
98
+
99
+ db.exec('BEGIN');
100
+ try {
101
+ insertAll(db, plan);
102
+ db.exec('COMMIT');
103
+ } catch (err) {
104
+ try { db.exec('ROLLBACK'); } catch { /* ignore secondary */ }
105
+ throw err; // DB left empty -> safe to retry on next open
106
+ }
107
+
108
+ // Commit succeeded -> the DB is authoritative. Archive consumed JSON best-effort;
109
+ // an interrupted archive is harmless (the row-count guard no-ops the re-run).
110
+ archive(home, plan.consumed);
111
+ }
112
+
113
+ /** Legacy data is "present" when ANY consumable source exists on disk. */
114
+ function legacyPresent(home) {
115
+ return (
116
+ existsSync(join(home, 'projects.json')) ||
117
+ existsSync(join(home, 'workspaces.json')) ||
118
+ isDir(join(home, 'workflows')) ||
119
+ isDir(join(home, 'store'))
120
+ );
121
+ }
122
+
123
+ /**
124
+ * SELF-GUARD (M3): the fs->db import already completed when the one-time marker row
125
+ * exists. This is a real latch (stamped in the same tx as the import), NOT the old
126
+ * row-count proxy — so a legacy JSON file reappearing after a partial-looking DB
127
+ * (e.g. projects>0 but pipelines==0) is still imported until the marker is present.
128
+ */
129
+ function alreadyMigrated(db) {
130
+ return db.prepare('SELECT 1 FROM store_meta WHERE key = ?').get(MIGRATION_MARKER_KEY) !== undefined;
131
+ }
132
+
133
+ // ── collection (pure reads) ────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Read the whole legacy tree into a plan: arrays of row tuples per table + the list
137
+ * of consumed files to archive. No DB access here. Never throws (fail-safe skips).
138
+ */
139
+ function collectLegacy(home) {
140
+ const plan = {
141
+ // m14: each workspaces entry is { row:[...], members:[[id,path,ordinal],...] }
142
+ // so insertAll can emit a workspace's member rows ONLY when its parent
143
+ // `workspaces` row actually inserted (changes===1). A hand-edited registry with
144
+ // two CI-name-colliding entries (distinct ids) makes `INSERT OR IGNORE` drop the
145
+ // 2nd `workspaces` row; emitting its `workspace_projects` children unconditionally
146
+ // would then FK-fail (parent absent) and — since INSERT OR IGNORE does NOT swallow
147
+ // a FOREIGN KEY violation — throw, rolling the WHOLE import back and re-attempting
148
+ // it on every getDb() open (an infinite re-migrate loop). Carrying members with
149
+ // their parent lets us gate them on the parent insert.
150
+ projects: [], workspaces: [], workflows: [],
151
+ projectConfig: [], configNodes: [], configFeedbacks: [],
152
+ storeMeta: [], pipelines: [], pipelineSteps: [], pipelineEvents: [],
153
+ clarify: [], reviews: [], artifacts: [],
154
+ consumed: [], // { src, rel } where rel is relative to `home` (or special)
155
+ hasRows: false,
156
+ };
157
+ const ts = nowIso();
158
+
159
+ // 1) projects.json
160
+ const projectsFile = join(home, 'projects.json');
161
+ const registry = readJsonSafe(projectsFile);
162
+ const registryEntries = Array.isArray(registry)
163
+ ? registry.filter((e) => e && typeof e.name === 'string' && typeof e.path === 'string')
164
+ : [];
165
+ const seenProjectKeys = new Set();
166
+ for (const e of registryEntries) {
167
+ const key = projectKey(e.path);
168
+ if (seenProjectKeys.has(key)) continue;
169
+ seenProjectKeys.add(key);
170
+ plan.projects.push([key, e.name, e.path, ts]);
171
+ }
172
+ if (Array.isArray(registry)) plan.consumed.push({ src: projectsFile, rel: 'projects.json' });
173
+
174
+ // 2) workspaces.json
175
+ const workspacesFile = join(home, 'workspaces.json');
176
+ const wsRegistry = readJsonSafe(workspacesFile);
177
+ if (Array.isArray(wsRegistry)) {
178
+ for (const w of wsRegistry) {
179
+ if (!isValidWorkspace(w)) continue;
180
+ // A1: workspace_projects.project_key stores the ABSOLUTE member PATH
181
+ // (ordinal-ordered); the real projectKey is recomputed on read via
182
+ // store.projectKey(path) in workspaces.mjs#annotate (a projectKey is a
183
+ // one-way hash, so the path could not be recovered from it).
184
+ // m14: members ride WITH their parent so insertAll only emits them when the
185
+ // parent `workspaces` row inserts (changes===1) — never as FK-orphans.
186
+ const members = w.projectPaths.map((p, i) => [w.id, p, i]);
187
+ plan.workspaces.push({
188
+ row: [
189
+ w.id, w.name, typeof w.description === 'string' ? w.description : '',
190
+ (typeof w.createdAt === 'string' && w.createdAt) || ts,
191
+ (typeof w.updatedAt === 'string' && w.updatedAt) || ts,
192
+ ],
193
+ members,
194
+ });
195
+ }
196
+ plan.consumed.push({ src: workspacesFile, rel: 'workspaces.json' });
197
+ }
198
+
199
+ // 3) workflows/*.json
200
+ const wfDir = join(home, 'workflows');
201
+ for (const ent of readDirSafe(wfDir)) {
202
+ if (!ent.isFile() || !ent.name.endsWith('.json')) continue;
203
+ const file = join(wfDir, ent.name);
204
+ const wf = readJsonSafe(file);
205
+ if (!wf || typeof wf !== 'object' || !Array.isArray(wf.steps)) continue;
206
+ const id = (typeof wf.id === 'string' && wf.id) || ent.name.slice(0, -'.json'.length);
207
+ if (id === 'wf_default') { // built-in; never a row, but still archive a stray file
208
+ plan.consumed.push({ src: file, rel: join('workflows', ent.name) });
209
+ continue;
210
+ }
211
+ plan.workflows.push([
212
+ id, (typeof wf.name === 'string' && wf.name) || 'Untitled',
213
+ Number(wf.version) || 1, JSON.stringify(wf.steps),
214
+ JSON.stringify(Array.isArray(wf.feedbacks) ? wf.feedbacks : []),
215
+ (typeof wf.createdAt === 'string' && wf.createdAt) || ts,
216
+ (typeof wf.updatedAt === 'string' && wf.updatedAt) || ts,
217
+ ]);
218
+ plan.consumed.push({ src: file, rel: join('workflows', ent.name) });
219
+ }
220
+
221
+ // 4) per-project .worca-cc/config.json (only for registered, still-present dirs)
222
+ for (const e of registryEntries) {
223
+ const dir = e.path;
224
+ if (!isDir(dir)) continue; // SKIP projects whose dir is gone
225
+ const file = projectConfigFile(dir);
226
+ const cfg = readJsonSafe(file);
227
+ if (!cfg || typeof cfg !== 'object') continue;
228
+ const key = projectKey(dir);
229
+ collectProjectConfig(plan, key, cfg);
230
+ // config.json lives OUTSIDE home -> namespaced archive path under project-config/<key>/
231
+ plan.consumed.push({ src: file, rel: null, dest: join('project-config', key, 'config.json') });
232
+ }
233
+
234
+ // 5) store tree
235
+ collectStore(plan, home);
236
+
237
+ plan.hasRows =
238
+ plan.projects.length || plan.workspaces.length || plan.workflows.length ||
239
+ plan.projectConfig.length || plan.storeMeta.length || plan.pipelines.length;
240
+ return plan;
241
+ }
242
+
243
+ function isValidWorkspace(e) {
244
+ return e && typeof e === 'object' && typeof e.id === 'string' &&
245
+ typeof e.name === 'string' && Array.isArray(e.projectPaths) &&
246
+ e.projectPaths.length >= 2 && e.projectPaths.every((p) => typeof p === 'string');
247
+ }
248
+
249
+ /** Decompose a per-project config into project_config + normalized node/feedback rows. */
250
+ function collectProjectConfig(plan, key, cfg) {
251
+ const steps = cfg.steps && typeof cfg.steps === 'object' ? cfg.steps : {};
252
+ const customModels = Array.isArray(cfg.customModels) ? cfg.customModels : [];
253
+ const activeWorkflowId =
254
+ typeof cfg.activeWorkflowId === 'string' && cfg.activeWorkflowId.trim()
255
+ ? cfg.activeWorkflowId.trim() : null;
256
+ // extra = every top-level key except the four modeled ones.
257
+ const extra = {};
258
+ for (const [k, v] of Object.entries(cfg)) {
259
+ if (k === 'steps' || k === 'customModels' || k === 'activeWorkflowId' || k === 'workflows') continue;
260
+ extra[k] = v;
261
+ }
262
+ plan.projectConfig.push([
263
+ key, JSON.stringify(steps), JSON.stringify(customModels), activeWorkflowId,
264
+ JSON.stringify(extra),
265
+ ]);
266
+
267
+ const workflows = cfg.workflows && typeof cfg.workflows === 'object' ? cfg.workflows : {};
268
+ for (const [wfId, wf] of Object.entries(workflows)) {
269
+ if (!wf || typeof wf !== 'object') continue;
270
+ const nodes = wf.nodes && typeof wf.nodes === 'object' ? wf.nodes : {};
271
+ for (const [nodeId, sel] of Object.entries(nodes)) {
272
+ if (!sel || typeof sel !== 'object') continue;
273
+ plan.configNodes.push([
274
+ key, wfId, nodeId,
275
+ typeof sel.model === 'string' ? sel.model : null,
276
+ typeof sel.effort === 'string' ? sel.effort : null,
277
+ boolCol(sel.fanOut),
278
+ ]);
279
+ }
280
+ const feedbacks = wf.feedbacks && typeof wf.feedbacks === 'object' ? wf.feedbacks : {};
281
+ for (const [fbId, fb] of Object.entries(feedbacks)) {
282
+ if (!fb || typeof fb !== 'object') continue;
283
+ const maxCycles = Math.max(1, Math.floor(Number(fb.maxCycles) || 0) || 1);
284
+ plan.configFeedbacks.push([key, wfId, fbId, maxCycles]);
285
+ }
286
+ }
287
+ }
288
+
289
+ /** Walk store/<key>/ (project keys) and store/workspaces/<wkey>/ (workspace keys). */
290
+ function collectStore(plan, home) {
291
+ const root = storeRoot();
292
+ for (const ent of readDirSafe(root)) {
293
+ if (!ent.isDirectory()) continue;
294
+ if (ent.name === 'workspaces') {
295
+ const wsRoot = workspacesStoreRoot();
296
+ for (const w of readDirSafe(wsRoot)) {
297
+ if (!w.isDirectory()) continue;
298
+ collectKeyDir(plan, home, join(wsRoot, w.name), w.name, 'workspace');
299
+ }
300
+ continue;
301
+ }
302
+ collectKeyDir(plan, home, join(root, ent.name), ent.name, 'project');
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Import one key dir (project or workspace): meta.json -> store_meta, each
308
+ * pipelines/<runId>/ -> a pipeline + children, shared plans/reviews -> artifacts.
309
+ * @param {string} dirName the store key dir name (bare workspace key for workspaces)
310
+ * @param {'project'|'workspace'} kind
311
+ */
312
+ function collectKeyDir(plan, home, keyDir, dirName, kind) {
313
+ // meta.json -> store_meta (key = meta.key when present, else the dir name)
314
+ const metaFile = join(keyDir, 'meta.json');
315
+ const meta = readJsonSafe(metaFile);
316
+ if (meta && typeof meta === 'object') {
317
+ const metaKey = typeof meta.key === 'string' && meta.key ? meta.key : dirName;
318
+ plan.storeMeta.push([metaKey, kind, JSON.stringify(meta)]);
319
+ plan.consumed.push({ src: metaFile, rel: relFromHome(home, metaFile) });
320
+ }
321
+
322
+ // pipelines/<runId>/
323
+ const pipelinesDir = join(keyDir, 'pipelines');
324
+ const runEntries = readDirSafe(pipelinesDir).filter((e) => e.isDirectory());
325
+ // remember each run's (id, datePrefix, baseCandidates) to attribute shared md later.
326
+ const runIndex = [];
327
+ for (const run of runEntries) {
328
+ const runDir = join(pipelinesDir, run.name);
329
+ const idAndBases = collectPipeline(plan, home, runDir, run.name, dirName, kind, meta);
330
+ if (idAndBases) runIndex.push(idAndBases);
331
+ }
332
+
333
+ // shared plans/*.md + reviews/*.md -> artifacts (attributed by datePrefix+base).
334
+ collectSharedMarkdown(plan, keyDir, runIndex);
335
+ }
336
+
337
+ /**
338
+ * Import one pipeline dir. Returns { id, datePrefix, bases:Set } for shared-md
339
+ * attribution, or null when state.json is unreadable (skip the whole pipeline).
340
+ */
341
+ function collectPipeline(plan, home, runDir, runName, dirName, kind, keyMeta) {
342
+ const state = readJsonSafe(join(runDir, 'state.json'));
343
+ if (!state || typeof state !== 'object') return null; // corrupt -> skip this pipeline
344
+
345
+ const id = (typeof state.id === 'string' && state.id) || runName;
346
+
347
+ // project_key
348
+ let projectKeyVal;
349
+ if (kind === 'workspace') {
350
+ projectKeyVal =
351
+ (Array.isArray(state.projectKeys) && state.projectKeys[0]) ||
352
+ (keyMeta && Array.isArray(keyMeta.projectKeys) && keyMeta.projectKeys[0]) || '';
353
+ } else {
354
+ projectKeyVal =
355
+ (keyMeta && typeof keyMeta.key === 'string' && keyMeta.key) ||
356
+ (typeof state.projectDir === 'string' ? projectKey(state.projectDir) : '') ||
357
+ dirName;
358
+ }
359
+ // workspace_key: composite tag for workspace runs, null for project runs.
360
+ const workspaceKeyVal = kind === 'workspace' ? `workspaces/${dirName}` : null;
361
+
362
+ const prompt = readTextSafe(join(runDir, 'prompt.md')) ?? null;
363
+
364
+ const workspaceMeta = state.target === 'workspace'
365
+ ? pruneUndefined({
366
+ workspaceId: state.workspaceId, workspaceKey: state.workspaceKey,
367
+ workspaceName: state.workspaceName, workspaceDescription: state.workspaceDescription,
368
+ projectKeys: state.projectKeys, projects: state.projects,
369
+ checkpointRefs: state.checkpointRefs, branches: state.branches,
370
+ })
371
+ : null;
372
+
373
+ plan.pipelines.push([
374
+ id, projectKeyVal, workspaceKeyVal,
375
+ typeof state.target === 'string' ? state.target : 'project',
376
+ state.title ?? null, state.baseName ?? null, state.datePrefix ?? null,
377
+ state.status || 'created', state.phase || 'created', Number(state.cycle) || 0,
378
+ state.startedAt ?? null, state.updatedAt ?? null,
379
+ Number(state.totalCostUsd) || 0, Number(state.totalActiveMs) || 0,
380
+ prompt,
381
+ jsonCol(state.branch), jsonCol(workspaceMeta), jsonCol(state.stepper), jsonCol(state.tools),
382
+ ]);
383
+ plan.consumed.push({ src: join(runDir, 'state.json'), rel: relFromHome(home, join(runDir, 'state.json')) });
384
+
385
+ // steps
386
+ const seenStepKeys = new Set();
387
+ for (const s of Array.isArray(state.steps) ? state.steps : []) {
388
+ if (!s || typeof s !== 'object' || typeof s.key !== 'string' || !s.key) continue;
389
+ if (seenStepKeys.has(s.key)) continue;
390
+ seenStepKeys.add(s.key);
391
+ plan.pipelineSteps.push([
392
+ id, s.key, s.nodeId ?? null, s.phase ?? null,
393
+ Number.isInteger(s.stepIndex) ? s.stepIndex : null,
394
+ Number.isFinite(s.cycle) ? s.cycle : null,
395
+ s.status ?? null, s.startedAt ?? null, s.updatedAt ?? null,
396
+ Number(s.activeMs) || 0,
397
+ s.runningSince == null ? null : String(s.runningSince),
398
+ Number(s.costUsd) || 0,
399
+ ]);
400
+ }
401
+
402
+ // pipeline.md -> events
403
+ const md = readTextSafe(join(runDir, 'pipeline.md'));
404
+ if (md !== undefined) {
405
+ for (const ev of parseTimeline(md)) plan.pipelineEvents.push([id, ev.ts, ev.text]);
406
+ plan.consumed.push({ src: join(runDir, 'pipeline.md'), rel: relFromHome(home, join(runDir, 'pipeline.md')) });
407
+ }
408
+
409
+ // clarify + clarify-answers -> one clarify row
410
+ const clarify = readJsonSafe(join(runDir, 'clarify.json'));
411
+ const answers = readJsonSafe(join(runDir, 'clarify-answers.json'));
412
+ if (clarify !== undefined || answers !== undefined) {
413
+ plan.clarify.push([id, jsonCol(clarify), jsonCol(answers)]);
414
+ if (clarify !== undefined) plan.consumed.push({ src: join(runDir, 'clarify.json'), rel: relFromHome(home, join(runDir, 'clarify.json')) });
415
+ if (answers !== undefined) plan.consumed.push({ src: join(runDir, 'clarify-answers.json'), rel: relFromHome(home, join(runDir, 'clarify-answers.json')) });
416
+ }
417
+
418
+ // *-review-cycleN.json -> reviews; markdown artifacts inside the run dir -> artifacts
419
+ for (const ent of readDirSafe(runDir)) {
420
+ if (!ent.isFile()) continue;
421
+ const m = REVIEW_FILE_RE.exec(ent.name);
422
+ if (m) {
423
+ const body = readJsonSafe(join(runDir, ent.name));
424
+ plan.reviews.push([id, m[1], Number(m[2]), jsonCol(body)]);
425
+ plan.consumed.push({ src: join(runDir, ent.name), rel: relFromHome(home, join(runDir, ent.name)) });
426
+ continue;
427
+ }
428
+ if (ent.name === 'manual-tests-checklist.md') {
429
+ plan.artifacts.push([id, 'manual-checklist', ent.name]);
430
+ } else if (/^webui-review-cycle\d+\.md$/.test(ent.name)) {
431
+ plan.artifacts.push([id, 'webui-review', ent.name]);
432
+ } else if (ent.name === 'workspace-description.md') {
433
+ plan.artifacts.push([id, 'workspace-description', ent.name]);
434
+ } else if (ent.name.endsWith('.md') && ent.name !== 'pipeline.md' && ent.name !== 'prompt.md') {
435
+ plan.artifacts.push([id, 'extra-md', ent.name]);
436
+ }
437
+ }
438
+ // extras/<file> -> artifacts (kept on disk, never moved)
439
+ for (const ex of readDirSafe(join(runDir, 'extras'))) {
440
+ if (ex.isFile()) plan.artifacts.push([id, 'extra', join('extras', ex.name)]);
441
+ }
442
+
443
+ return { id, datePrefix: state.datePrefix ?? null, bases: baseCandidates(state, runName) };
444
+ }
445
+
446
+ /** Candidate base names for shared-md linkage (mirrors pipeline-delete.mjs#deriveNames). */
447
+ function baseCandidates(state, runName) {
448
+ const bases = new Set();
449
+ if (state && state.baseName) bases.add(String(state.baseName));
450
+ // dir slug: drop the "DD-MM-YY-" prefix and the trailing "-<id>".
451
+ const m = /^(\d{2}-\d{2}-\d{2})-(.+)$/.exec(runName);
452
+ if (m && state && state.id) {
453
+ const inner = m[2];
454
+ const suffix = `-${String(state.id)}`;
455
+ if (inner.endsWith(suffix)) bases.add(inner.slice(0, -suffix.length));
456
+ }
457
+ return bases;
458
+ }
459
+
460
+ /** Attribute plans/*.md + reviews/*.md to a pipeline in this key by datePrefix+base. */
461
+ function collectSharedMarkdown(plan, keyDir, runIndex) {
462
+ const PLAN_RE = /^(\d{2}-\d{2}-\d{2})-(.+?)(?:-v\d+)?\.md$/;
463
+ const REVIEW_RE = /^(\d{2}-\d{2}-\d{2})-(.+?)-(impl-review|plan-review|ws-review)\.md$/;
464
+ const matchRun = (datePrefix, base) =>
465
+ runIndex.find((r) => r.datePrefix === datePrefix && r.bases.has(base));
466
+
467
+ for (const ent of readDirSafe(join(keyDir, 'plans'))) {
468
+ if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
469
+ const m = PLAN_RE.exec(ent.name);
470
+ if (!m) continue;
471
+ const run = matchRun(m[1], m[2]);
472
+ if (run) plan.artifacts.push([run.id, 'plan', join('plans', ent.name)]);
473
+ }
474
+ for (const ent of readDirSafe(join(keyDir, 'reviews'))) {
475
+ if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
476
+ const m = REVIEW_RE.exec(ent.name);
477
+ if (!m) continue;
478
+ const run = matchRun(m[1], m[2]);
479
+ if (run) plan.artifacts.push([run.id, 'review', join('reviews', ent.name)]);
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Parse the `## Timeline` section of a pipeline.md into { ts, text } events. ONLY
485
+ * lines after the first "## Timeline" header that match the strict ISO-ts pattern
486
+ * are events. When there is NO "## Timeline" header we return [] rather than scanning
487
+ * the whole file: a prompt body can legitimately contain "- `<ISO ts>` ..." lines,
488
+ * and treating those as events would fabricate spurious pipeline_events rows.
489
+ */
490
+ function parseTimeline(md) {
491
+ const lines = md.split(/\r?\n/);
492
+ const header = lines.findIndex((l) => l.trim() === '## Timeline');
493
+ if (header === -1) return []; // no Timeline section -> no events
494
+ const RE = /^- `(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)` (.*)$/;
495
+ const out = [];
496
+ for (let i = header + 1; i < lines.length; i++) {
497
+ const m = RE.exec(lines[i]);
498
+ if (m) out.push({ ts: m[1], text: m[2] });
499
+ }
500
+ return out;
501
+ }
502
+
503
+ /** Drop undefined-valued keys so JSON.stringify omits them (keeps the column compact). */
504
+ function pruneUndefined(obj) {
505
+ const out = {};
506
+ for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v;
507
+ return out;
508
+ }
509
+
510
+ /** Path of `file` relative to `home` (for mirroring into backup-<ts>/). */
511
+ function relFromHome(home, file) { return relative(home, file); }
512
+
513
+ // ── insertion (one transaction) ────────────────────────────────────────────────────
514
+
515
+ function insertAll(db, plan) {
516
+ const ins = {
517
+ project: db.prepare('INSERT OR IGNORE INTO projects (key,name,path,created_at) VALUES (?,?,?,?)'),
518
+ workspace: db.prepare('INSERT OR IGNORE INTO workspaces (id,name,description,created_at,updated_at) VALUES (?,?,?,?,?)'),
519
+ wsProj: db.prepare('INSERT OR IGNORE INTO workspace_projects (workspace_id,project_key,ordinal) VALUES (?,?,?)'),
520
+ workflow: db.prepare('INSERT OR IGNORE INTO workflows (id,name,version,steps,feedbacks,created_at,updated_at) VALUES (?,?,?,?,?,?,?)'),
521
+ projectConfig: db.prepare('INSERT OR IGNORE INTO project_config (project_key,steps,custom_models,active_workflow_id,extra) VALUES (?,?,?,?,?)'),
522
+ configNode: db.prepare('INSERT OR IGNORE INTO config_workflow_nodes (project_key,workflow_id,node_id,model,effort,fan_out) VALUES (?,?,?,?,?,?)'),
523
+ configFb: db.prepare('INSERT OR IGNORE INTO config_workflow_feedbacks (project_key,workflow_id,fb_id,max_cycles) VALUES (?,?,?,?)'),
524
+ storeMeta: db.prepare('INSERT OR IGNORE INTO store_meta (key,kind,data) VALUES (?,?,?)'),
525
+ pipeline: db.prepare(`INSERT OR IGNORE INTO pipelines
526
+ (id,project_key,workspace_key,target,title,base_name,date_prefix,status,phase,cycle,
527
+ started_at,updated_at,total_cost_usd,total_active_ms,prompt,branch,workspace_meta,stepper,tools)
528
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`),
529
+ step: db.prepare(`INSERT OR IGNORE INTO pipeline_steps
530
+ (pipeline_id,key,node_id,phase,step_index,cycle,status,started_at,updated_at,active_ms,running_since,cost_usd)
531
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`),
532
+ event: db.prepare('INSERT INTO pipeline_events (pipeline_id,ts,text) VALUES (?,?,?)'),
533
+ clarify: db.prepare('INSERT OR IGNORE INTO clarify (pipeline_id,questions,answers) VALUES (?,?,?)'),
534
+ review: db.prepare('INSERT OR IGNORE INTO reviews (pipeline_id,kind,cycle,verdict) VALUES (?,?,?,?)'),
535
+ artifact: db.prepare('INSERT OR IGNORE INTO artifacts (pipeline_id,kind,rel_path) VALUES (?,?,?)'),
536
+ marker: db.prepare('INSERT OR REPLACE INTO store_meta (key,kind,data) VALUES (?,?,?)'),
537
+ };
538
+ for (const r of plan.projects) ins.project.run(...r);
539
+ // m14: emit a workspace's member rows ONLY when its parent `workspaces` row
540
+ // actually inserted (changes===1). When `INSERT OR IGNORE` drops the row (a
541
+ // CI-name or id collision → changes===0), skipping its members avoids the FK
542
+ // violation that `INSERT OR IGNORE` does NOT swallow (it throws), which would
543
+ // otherwise roll the whole import back and re-trigger it on every open.
544
+ for (const w of plan.workspaces) {
545
+ const inserted = ins.workspace.run(...w.row).changes === 1;
546
+ if (!inserted) continue;
547
+ for (const m of w.members) ins.wsProj.run(...m);
548
+ }
549
+ for (const r of plan.workflows) ins.workflow.run(...r);
550
+ for (const r of plan.projectConfig) ins.projectConfig.run(...r);
551
+ for (const r of plan.configNodes) ins.configNode.run(...r);
552
+ for (const r of plan.configFeedbacks) ins.configFb.run(...r);
553
+ for (const r of plan.storeMeta) ins.storeMeta.run(...r);
554
+ for (const r of plan.pipelines) ins.pipeline.run(...r);
555
+ for (const r of plan.pipelineSteps) ins.step.run(...r);
556
+ for (const r of plan.pipelineEvents) ins.event.run(...r);
557
+ for (const r of plan.clarify) ins.clarify.run(...r);
558
+ for (const r of plan.reviews) ins.review.run(...r);
559
+ for (const r of plan.artifacts) ins.artifact.run(...r);
560
+ // M3: stamp the one-time completion marker LAST, in this same tx (committed by the
561
+ // caller's COMMIT). INSERT OR REPLACE so a marker-less pre-marker DB re-stamps clean.
562
+ ins.marker.run(MIGRATION_MARKER_KEY, '_meta', JSON.stringify({ migrated: true, at: nowIso() }));
563
+ }
564
+
565
+ // ── archive (after commit; best-effort) ──────────────────────────────────────────
566
+
567
+ /**
568
+ * Move every consumed file into <home>/backup-<ts>/ mirroring the relative layout.
569
+ * Files outside `home` (per-project config.json) carry an explicit `dest` rel path
570
+ * (namespaced by projectKey). Best-effort: a per-file failure is swallowed (the DB
571
+ * is already authoritative; a stuck file must not crash app open).
572
+ */
573
+ function archive(home, consumed) {
574
+ if (!consumed.length) return;
575
+ const ts = nowIso().replace(/[:.]/g, '-');
576
+ const backupRoot = join(home, `backup-${ts}`);
577
+ for (const item of consumed) {
578
+ const rel = item.dest || item.rel;
579
+ if (!rel) continue;
580
+ const dest = join(backupRoot, rel);
581
+ try {
582
+ mkdirSync(dirname(dest), { recursive: true });
583
+ try {
584
+ renameSync(item.src, dest);
585
+ } catch (e) {
586
+ if (e && e.code === 'EXDEV') { // cross-device (per-project config): copy+unlink
587
+ cpSync(item.src, dest);
588
+ unlinkSync(item.src);
589
+ } else {
590
+ throw e;
591
+ }
592
+ }
593
+ } catch (err) {
594
+ // Best-effort: the DB is already authoritative, so a stuck file must NOT crash
595
+ // app open. But LOG it (M-min3) so a leftover un-archived legacy file in the
596
+ // live tree is diagnosable instead of vanishing silently.
597
+ try { console.warn(`worca: fs->db archive failed for ${item.src} -> ${dest}: ${err && err.message ? err.message : err}`); } catch { /* never let logging itself crash open */ }
598
+ }
599
+ }
600
+ }
601
+
602
+ // ── TEST-ONLY observability (A14; consumed by db.test.mjs's hook-call test) ─────────
603
+
604
+ /** TEST-ONLY: how many times maybeMigrateFromFs() has been called. */
605
+ export function _migrateFromFsCallCount() {
606
+ return _callCount;
607
+ }
608
+
609
+ /** TEST-ONLY: reset the call counter so each test observes a fresh open. */
610
+ export function _resetMigrateFromFsCallCount() {
611
+ _callCount = 0;
612
+ }