@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,353 @@
1
+ // src/core/workspaces.mjs
2
+ // Workspace registry: a small persistent list of named project sets (2+ onboarded
3
+ // git repos sharing one editable interconnection description). Persisted in SQLite
4
+ // (db.mjs) across two tables: workspaces (id, name, description, created/updated)
5
+ // and workspace_projects (the ordered member set). The member's absolute PATH is
6
+ // stored in the workspace_projects.project_key column (ordinal-ordered) — NOT a
7
+ // projectKey: a projectKey is a one-way sha1 hash, so the path could not be
8
+ // reconstructed from it. The real projectKey is recomputed on read via
9
+ // store.projectKey(path) in annotate().
10
+ //
11
+ // A workspace is a thin record plus a derived store namespace at
12
+ // store/workspaces/<workspaceKey>/. The key is derived ONCE at creation from the
13
+ // name slug + a sorted-canonical-roots hash, then frozen: rename never recomputes it
14
+ // (D1). projectKeys / exists[] are derived at read time and never persisted.
15
+ //
16
+ // Reads never throw: a missing row yields []/null. Writes run inside one tx() and
17
+ // keep their validation throws (err(message, code), mirrors pipeline-delete.mjs) so
18
+ // the server can map codes -> HTTP (BAD_REQUEST->400, DUPLICATE_*->409,
19
+ // NOT_FOUND->404). workspacesFile() is retained (vestigial) for import-compat.
20
+
21
+ import { statSync } from 'node:fs';
22
+ import { rm } from 'node:fs/promises';
23
+ import { execFileSync } from 'node:child_process';
24
+ import { join } from 'node:path';
25
+ import { createHash } from 'node:crypto';
26
+
27
+ import { worcaHome, normalizeProjectPath } from './projects.mjs';
28
+ import { canonicalProjectRoot, projectKey, workspaceStorePath } from './store.mjs';
29
+ import { slugify, retainedWorkFor } from './artifacts.mjs';
30
+ import { getDb, prepare, tx } from './db.mjs';
31
+
32
+ /** Object-shaped error carrying a machine code (mirrors pipeline-delete.mjs). */
33
+ function err(message, code) { return Object.assign(new Error(message), { code }); }
34
+
35
+ /**
36
+ * The workspace-key shape: "wks-<slug>-<sha1[:8]>". The server imports this as
37
+ * its single source of truth (M2 route validation), so core + route agree on one
38
+ * invariant. Validating an id against it also forecloses any path-traversal: a
39
+ * key matching this regex can never contain "/" or "..", so workspaceStorePath(id)
40
+ * cannot escape the store namespace even before a registry-membership check.
41
+ */
42
+ export const WORKSPACE_KEY_RE = /^wks-[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/;
43
+
44
+ /** Absolute path to the workspace registry file. Sibling of projects.json. */
45
+ export function workspacesFile() {
46
+ return join(worcaHome(), 'workspaces.json');
47
+ }
48
+
49
+ /** True when the path exists and is a directory. */
50
+ function isDir(p) {
51
+ try { return statSync(p).isDirectory(); } catch { return false; }
52
+ }
53
+
54
+ /**
55
+ * True when `p` is inside a git work tree (and a directory). Never throws.
56
+ * Exported so the server's run-target loop can reject a member that exists but is
57
+ * no longer a git repo (§2.6 step 3) using the SAME check createWorkspace applies.
58
+ */
59
+ export function isGitRepo(p) {
60
+ if (!isDir(p)) return false;
61
+ try {
62
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'],
63
+ { cwd: p, stdio: ['ignore', 'pipe', 'ignore'] });
64
+ return true;
65
+ } catch { return false; }
66
+ }
67
+
68
+ /**
69
+ * Roots-only dedupe hash (D1): sha1 of the sorted canonical roots, joined by "\n",
70
+ * sliced to 8 hex. Name-independent and order-independent, so it identifies a
71
+ * project SET regardless of the workspace's name or the input ordering.
72
+ * @param {string[]} projectPaths
73
+ * @returns {string} 8 hex chars
74
+ */
75
+ export function rootsHash(projectPaths) {
76
+ const roots = (Array.isArray(projectPaths) ? projectPaths : [])
77
+ .map((p) => canonicalProjectRoot(p))
78
+ .sort();
79
+ return createHash('sha1').update(roots.join('\n')).digest('hex').slice(0, 8);
80
+ }
81
+
82
+ /**
83
+ * Stable workspace key == id: "wks-" + slugify(name) + "-" + rootsHash(paths).
84
+ * The wks- prefix guarantees no collision with any projectKey in the same store.
85
+ * @param {{name:string, projectPaths:string[]}} ws
86
+ * @returns {string}
87
+ */
88
+ export function workspaceKey(ws) {
89
+ const name = ws && typeof ws.name === 'string' ? ws.name : '';
90
+ const paths = ws && Array.isArray(ws.projectPaths) ? ws.projectPaths : [];
91
+ return `wks-${slugify(name)}-${rootsHash(paths)}`;
92
+ }
93
+
94
+ /**
95
+ * Annotate a persisted entry with read-time derived fields:
96
+ * projectKeys (sorted ascending, index-aligned with the returned projectPaths)
97
+ * exists[] (per-path on-disk presence)
98
+ * Neither is ever persisted. projectPaths is re-ordered to align with the sorted
99
+ * projectKeys so callers get the canonical member ordering used everywhere.
100
+ */
101
+ function annotate(entry) {
102
+ const pairs = entry.projectPaths.map((p) => ({ path: p, key: projectKey(p) }));
103
+ pairs.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
104
+ return {
105
+ id: entry.id,
106
+ name: entry.name,
107
+ description: entry.description,
108
+ projectPaths: pairs.map((x) => x.path),
109
+ projectKeys: pairs.map((x) => x.key),
110
+ exists: pairs.map((x) => isDir(x.path)),
111
+ createdAt: entry.createdAt,
112
+ updatedAt: entry.updatedAt,
113
+ };
114
+ }
115
+
116
+ /** Load the ordered member PATHS for a workspace (stored in the project_key column). */
117
+ function memberPaths(id) {
118
+ return prepare(
119
+ 'SELECT project_key AS path FROM workspace_projects WHERE workspace_id = ? ORDER BY ordinal'
120
+ ).all(id).map((r) => r.path);
121
+ }
122
+
123
+ /** Map a workspaces row (+ its member rows) to the persisted entry shape. */
124
+ function rowToEntry(r) {
125
+ return {
126
+ id: r.id,
127
+ name: r.name,
128
+ description: typeof r.description === 'string' ? r.description : '',
129
+ projectPaths: memberPaths(r.id),
130
+ createdAt: typeof r.created_at === 'string' ? r.created_at : '',
131
+ updatedAt: typeof r.updated_at === 'string' ? r.updated_at : '',
132
+ };
133
+ }
134
+
135
+ /** Read one workspace entry by id (persisted shape, pre-annotate). null when absent. */
136
+ function readEntry(id) {
137
+ getDb();
138
+ const r = prepare(
139
+ 'SELECT id, name, description, created_at, updated_at FROM workspaces WHERE id = ?'
140
+ ).get(id);
141
+ return r ? rowToEntry(r) : null;
142
+ }
143
+
144
+ /**
145
+ * List saved workspaces, each annotated with derived projectKeys/exists.
146
+ * @returns {Promise<Array<{id,name,description,projectPaths,projectKeys,exists:boolean[],createdAt,updatedAt}>>}
147
+ */
148
+ export async function listWorkspaces() {
149
+ getDb();
150
+ const rows = prepare(
151
+ 'SELECT id, name, description, created_at, updated_at FROM workspaces ORDER BY created_at, name'
152
+ ).all();
153
+ return rows.map(rowToEntry).map(annotate);
154
+ }
155
+
156
+ /**
157
+ * Number of saved workspaces (matches listWorkspaces().length). Cheap COUNT(*).
158
+ * Uses the bare `prepare` already imported at workspaces.mjs:30.
159
+ * @returns {number}
160
+ */
161
+ export function countWorkspaces() {
162
+ getDb();
163
+ const row = prepare('SELECT COUNT(*) AS n FROM workspaces').get();
164
+ return row ? Number(row.n) : 0;
165
+ }
166
+
167
+ /**
168
+ * Read one workspace by id, annotated. Returns null when absent.
169
+ * @param {string} id
170
+ * @returns {Promise<object|null>}
171
+ */
172
+ export async function readWorkspace(id) {
173
+ if (!id || typeof id !== 'string') return null;
174
+ const entry = readEntry(id);
175
+ return entry ? annotate(entry) : null;
176
+ }
177
+
178
+ /**
179
+ * Normalize + de-dupe member paths by canonical root. Returns the normalized
180
+ * absolute paths in input order, with later paths that resolve to an
181
+ * already-seen canonical root dropped.
182
+ */
183
+ function normalizeMembers(projectPaths) {
184
+ const out = [];
185
+ const seenRoots = new Set();
186
+ for (const raw of Array.isArray(projectPaths) ? projectPaths : []) {
187
+ const norm = normalizeProjectPath(raw);
188
+ if (!norm) continue;
189
+ const root = canonicalProjectRoot(norm);
190
+ if (seenRoots.has(root)) continue;
191
+ seenRoots.add(root);
192
+ out.push(norm);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * Create a workspace. Validates name (non-empty + unique case-insensitive),
199
+ * a 2+ distinct-git-repo member set (de-duped by canonical root), and a unique
200
+ * project set (D1, by rootsHash). Persists the workspaces row + ordered
201
+ * workspace_projects member rows (member PATH stored in the project_key column)
202
+ * in ONE tx(). id is the frozen workspaceKey, computed once. Returns the
203
+ * annotated entry.
204
+ * @param {{name:string, projectPaths:string[], description?:string}} input
205
+ * @throws err(code: BAD_REQUEST | DUPLICATE_NAME | DUPLICATE_SET)
206
+ */
207
+ export async function createWorkspace(input = {}) {
208
+ const name = (input && typeof input.name === 'string' ? input.name : '').trim();
209
+ if (!name) throw err('workspace name is required', 'BAD_REQUEST');
210
+ const description = typeof input.description === 'string' ? input.description : '';
211
+
212
+ const members = normalizeMembers(input.projectPaths);
213
+ if (members.length < 2) {
214
+ throw err('a workspace needs at least 2 distinct member projects', 'BAD_REQUEST');
215
+ }
216
+ for (const p of members) {
217
+ if (!isDir(p)) throw err(`member path does not exist or is not a directory: ${p}`, 'BAD_REQUEST');
218
+ if (!isGitRepo(p)) throw err(`member path is not a git repository: ${p}`, 'BAD_REQUEST');
219
+ }
220
+
221
+ const id = workspaceKey({ name, projectPaths: members });
222
+ const hash = rootsHash(members);
223
+ const now = new Date().toISOString();
224
+
225
+ getDb();
226
+ tx(() => {
227
+ // Case-insensitive duplicate-name guard (matches the legacy check + NOCASE index).
228
+ if (prepare('SELECT 1 FROM workspaces WHERE name = ? COLLATE NOCASE').get(name)) {
229
+ throw err(`a workspace named "${name}" already exists`, 'DUPLICATE_NAME');
230
+ }
231
+ // D1 duplicate-SET guard: compare rootsHash over existing members.
232
+ for (const row of prepare('SELECT id FROM workspaces').all()) {
233
+ if (rootsHash(memberPaths(row.id)) === hash) {
234
+ throw err('a workspace over this exact project set already exists', 'DUPLICATE_SET');
235
+ }
236
+ }
237
+ prepare(
238
+ 'INSERT INTO workspaces (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
239
+ ).run(id, name, description, now, now);
240
+ const insMember = prepare(
241
+ 'INSERT INTO workspace_projects (workspace_id, project_key, ordinal) VALUES (?, ?, ?)'
242
+ );
243
+ // projectPaths persisted in input order (ordinal = index); annotate() re-sorts by key.
244
+ members.forEach((p, i) => insMember.run(id, p, i));
245
+ });
246
+
247
+ // Return the annotated entry (derived fields recomputed from the persisted paths).
248
+ return annotate({ id, name, description, projectPaths: members, createdAt: now, updatedAt: now });
249
+ }
250
+
251
+ /**
252
+ * Update a workspace's name and/or description. NEVER touches projectPaths (the
253
+ * project set is immutable) and NEVER recomputes the id (D1). Re-validates a new
254
+ * name for case-insensitive uniqueness. Stamps updatedAt.
255
+ * @param {string} id
256
+ * @param {{name?:string, description?:string}} patch
257
+ * @throws err(code: NOT_FOUND | BAD_REQUEST | DUPLICATE_NAME)
258
+ */
259
+ export async function updateWorkspace(id, patch = {}) {
260
+ getDb();
261
+ const entry = readEntry(id);
262
+ if (!entry) throw err(`workspace not found: ${id}`, 'NOT_FOUND');
263
+
264
+ let { name, description } = entry;
265
+ if (patch && typeof patch.name === 'string') {
266
+ const next = patch.name.trim();
267
+ if (!next) throw err('workspace name is required', 'BAD_REQUEST');
268
+ name = next;
269
+ }
270
+ if (patch && typeof patch.description === 'string') {
271
+ description = patch.description; // cap-on-freeze, not cap-on-store: persisted whole
272
+ }
273
+ const now = new Date().toISOString();
274
+
275
+ tx(() => {
276
+ // Re-check NOCASE name clash against OTHER rows (exclude self).
277
+ const clash = prepare(
278
+ 'SELECT 1 FROM workspaces WHERE name = ? COLLATE NOCASE AND id <> ?'
279
+ ).get(name, id);
280
+ if (clash) throw err(`a workspace named "${name}" already exists`, 'DUPLICATE_NAME');
281
+ prepare(
282
+ 'UPDATE workspaces SET name = ?, description = ?, updated_at = ? WHERE id = ?'
283
+ ).run(name, description, now, id);
284
+ });
285
+
286
+ return annotate({ ...entry, name, description, updatedAt: now });
287
+ }
288
+
289
+ /** Thin setter: edit only the description. */
290
+ export async function updateWorkspaceDescription(id, text) {
291
+ return updateWorkspace(id, { description: typeof text === 'string' ? text : '' });
292
+ }
293
+
294
+ /** Thin setter: rename only. Never recomputes the id (D1). */
295
+ export async function renameWorkspace(id, name) {
296
+ return updateWorkspace(id, { name: typeof name === 'string' ? name : '' });
297
+ }
298
+
299
+ /**
300
+ * Delete a workspace: remove the store/workspaces/<id>/ directory (best-effort) and
301
+ * the registry row. The workspace_projects children are removed by the FK
302
+ * ON DELETE CASCADE (foreign_keys=ON, set on open). The module has no runs map —
303
+ * the live-run 409 guard lives in the server route.
304
+ *
305
+ * Self-guarded: id MUST match the workspace-key shape AND the row MUST exist before
306
+ * anything is removed; a crafted id (e.g. "../..") never reaches the rm — it throws
307
+ * NOT_FOUND. (store_meta cleanup is owned by Phase 3 — see the cross-phase note.)
308
+ * @param {string} id
309
+ * @returns {Promise<{ok:true, warnings:string[]}>}
310
+ * @throws err(code: NOT_FOUND) for a malformed or unknown id
311
+ */
312
+ export async function deleteWorkspace(id) {
313
+ if (!id || typeof id !== 'string' || !WORKSPACE_KEY_RE.test(id)) {
314
+ throw err(`workspace not found: ${id}`, 'NOT_FOUND');
315
+ }
316
+ getDb();
317
+ // Membership-first: only act on an id actually present.
318
+ if (!prepare('SELECT 1 FROM workspaces WHERE id = ?').get(id)) {
319
+ throw err(`workspace not found: ${id}`, 'NOT_FOUND');
320
+ }
321
+
322
+ // Never orphan retained uncommitted work: deleting the store removes the
323
+ // pipeline dir the discard flow needs for its recovery patch, wedging the run.
324
+ const memberRows = prepare(
325
+ 'SELECT * FROM pipelines WHERE workspace_key = ? AND archived_at IS NULL',
326
+ ).all(id);
327
+ for (const memberRow of memberRows) {
328
+ if (retainedWorkFor(memberRow)) {
329
+ throw err(
330
+ `workspace has retained uncommitted work (pipeline ${memberRow.id}); recover or discard it first — ` +
331
+ 'and copy any retained-work*.patch out of the workspace store before deleting, deletion removes it',
332
+ 'RETAINED_WORKTREE',
333
+ );
334
+ }
335
+ }
336
+
337
+ const warnings = [];
338
+ try {
339
+ await rm(workspaceStorePath(id), { recursive: true, force: true });
340
+ } catch (e) {
341
+ warnings.push(`store cleanup failed: ${e && e.message ? e.message : 'error'}`);
342
+ }
343
+ try {
344
+ tx(() => {
345
+ // Children cascade via the workspace_projects FK (ON DELETE CASCADE).
346
+ prepare('DELETE FROM workspaces WHERE id = ?').run(id);
347
+ });
348
+ } catch (e) {
349
+ warnings.push(`registry write failed: ${e && e.message ? e.message : 'error'}`);
350
+ }
351
+
352
+ return { ok: true, warnings };
353
+ }