@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,210 @@
1
+ // src/core/skills.mjs
2
+ // Declared-skill resolution, validation, and worktree injection.
3
+ //
4
+ // Worca CC agents may declare `requiresSkills: string[]` in their meta sidecar.
5
+ // A skill is only reachable by the headless `claude -p` child when it sits on a
6
+ // scan path: `<cwd>/.claude/skills/<name>/` or `~/.claude/skills/<name>/`.
7
+ // Worca CC's own repo `skills/` dir is on NEITHER, so a bundled skill must be
8
+ // COPIED into the run's worktree before any node runs. This module:
9
+ // - resolveSkill() pure: where does a skill live? (bundle|global|project|none)
10
+ // - collectRequiredSkills() pure: union of requiresSkills across the plan's agents
11
+ // - validateSkills() gate: throw a structured abort if any are unresolvable
12
+ // - injectSkills() side-effect: copy bundle/plugin skills into given target(s)
13
+
14
+ import { existsSync } from 'node:fs';
15
+ import { cp } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs'; // plugin skill roots (Task 2)
19
+
20
+ /**
21
+ * Ordered plugin skill roots for resolveSkill's ctx.pluginDirs: every ENABLED
22
+ * plugin's current/skills dir that exists, lexicographic by plugin name (the
23
+ * same determinism as pluginAgentLayers). try/catch mirrors userAgentsDir():
24
+ * no resolvable home / no lock => [] — zero plugins is byte-identical to today.
25
+ * @returns {Array<{plugin: string, dir: string}>}
26
+ */
27
+ export function pluginSkillDirs() {
28
+ try {
29
+ const lock = readPluginsLock();
30
+ return Object.keys(lock)
31
+ .sort()
32
+ .filter((name) => lock[name] && lock[name].enabled !== false)
33
+ .map((name) => ({ plugin: name, dir: join(pluginCurrentDir(name), 'skills') }))
34
+ .filter(({ dir }) => existsSync(dir));
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /**
41
+ * S2 (path-segment guard). A skill name is used as a PATH SEGMENT at both mount
42
+ * sites — `injectSkills`' `join(t, '.claude', 'skills', skill)` and
43
+ * `assembleSkills`' `join(target, effective)` (run-context.mjs) — and it arrives
44
+ * from author-controlled agent frontmatter (`requiresSkills`) or, on resume, from a
45
+ * disk-read `run.json.skillResolutions`. Unvalidated, `../../x` turns either mount
46
+ * into a write-anywhere primitive.
47
+ *
48
+ * The standard is `createWorktree`'s, verbatim (worktree.mjs S2/checkoutName): the
49
+ * conservative `[A-Za-z0-9._-]+` class plus the two dot cases the class alone still
50
+ * admits. Callers differ only in POSTURE — a declared skill is rejected LOUDLY at
51
+ * preflight (validateSkills), a manifest entry is SKIPPED with a warning (§8.20:
52
+ * a corrupt run.json must not make a paused run unresumable).
53
+ * @param {unknown} name
54
+ * @returns {boolean}
55
+ */
56
+ export function isValidSkillName(name) {
57
+ return typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test(name) && name !== '.' && name !== '..';
58
+ }
59
+
60
+ /**
61
+ * Resolve a skill name to its source, in priority order:
62
+ * 0. plugin (OWNER) — <plugins>/<name>/current/skills/<skill>/SKILL.md, only for
63
+ * the plugin named by ctx.origin ('plugin:<name>' — the
64
+ * requesting agent's registry origin)
65
+ * 1. bundle — <repoRoot>/skills/<name>/SKILL.md (injectSkills copies this into the worktree)
66
+ * 2. global — <homeDir>/.claude/skills/<name>/SKILL.md (already on the scan path)
67
+ * 3. project — <projectDir>/.claude/skills/<name>/SKILL.md (committed; already on the scan path)
68
+ * 4. plugin (others) — every remaining ctx.pluginDirs entry, in given order
69
+ * 5. none — unresolvable
70
+ * pluginDirs entries are { plugin, dir } (pluginSkillDirs()); a plain-string dir is
71
+ * tolerated (never owner, source 'plugin'). pluginDirs=[] + origin=null keeps the
72
+ * legacy 3-path chain and 3-entry `searched` byte-identical. Pure: existsSync only.
73
+ * @param {string} name
74
+ * @param {{repoRoot:string, projectDir:string, homeDir?:string,
75
+ * pluginDirs?:Array<{plugin:string,dir:string}|string>, origin?:string|null}} ctx
76
+ * @returns {{source:string|null, path:string|null, searched:string[]}}
77
+ */
78
+ export function resolveSkill(name, { repoRoot, projectDir, homeDir = homedir(), pluginDirs = [], origin = null }) {
79
+ const dirs = (Array.isArray(pluginDirs) ? pluginDirs : [])
80
+ .map((p) => (typeof p === 'string' ? { plugin: null, dir: p } : p))
81
+ .filter((p) => p && typeof p.dir === 'string' && p.dir);
82
+ const ownerName = typeof origin === 'string' && origin.startsWith('plugin:')
83
+ ? origin.slice('plugin:'.length)
84
+ : null;
85
+ const asHit = (p) => ({ source: p.plugin ? `plugin:${p.plugin}` : 'plugin', dir: join(p.dir, name) });
86
+ const chain = [
87
+ ...dirs.filter((p) => p.plugin !== null && p.plugin === ownerName).map(asHit), // owner FIRST
88
+ { source: 'bundle', dir: join(repoRoot, 'skills', name) },
89
+ { source: 'global', dir: join(homeDir, '.claude', 'skills', name) },
90
+ { source: 'project', dir: join(projectDir, '.claude', 'skills', name) },
91
+ ...dirs.filter((p) => p.plugin === null || p.plugin !== ownerName).map(asHit), // others LAST
92
+ ];
93
+ const searched = chain.map((c) => join(c.dir, 'SKILL.md'));
94
+ for (let i = 0; i < chain.length; i++) {
95
+ if (existsSync(searched[i])) return { source: chain[i].source, path: chain[i].dir, searched };
96
+ }
97
+ return { source: null, path: null, searched };
98
+ }
99
+
100
+ /**
101
+ * Union of `requiresSkills` across the agents that appear in the resolved plan.
102
+ * Returns one entry per distinct skill with the agent keys that require it (for
103
+ * error attribution), sorted by skill name.
104
+ *
105
+ * `registry` is the plain object returned by loadAgentRegistry (keyed by agent
106
+ * key); `plan.steps` is Array<Array<node>> and each node has `.key`.
107
+ * @param {Record<string, {requiresSkills?: string[]}>} registry
108
+ * @param {{steps?: Array<Array<{key:string}>>}} plan
109
+ * @returns {Array<{skill:string, requiredBy:string[]}>}
110
+ */
111
+ export function collectRequiredSkills(registry, plan) {
112
+ const nodeKeys = new Set();
113
+ for (const group of plan?.steps || []) for (const node of group) nodeKeys.add(node.key);
114
+ /** @type {Map<string, Set<string>>} */
115
+ const bySkill = new Map();
116
+ for (const key of nodeKeys) {
117
+ for (const skill of registry?.[key]?.requiresSkills || []) {
118
+ if (!bySkill.has(skill)) bySkill.set(skill, new Set());
119
+ bySkill.get(skill).add(key);
120
+ }
121
+ }
122
+ return [...bySkill.keys()].sort().map((skill) => {
123
+ const requiredBy = [...bySkill.get(skill)].sort();
124
+ // Owner attribution for plugin-first search: the FIRST (sorted) requiring
125
+ // agent whose registry meta carries a plugin origin. The key is OMITTED when
126
+ // none does, so legacy fixtures/deepEqual call sites are byte-identical.
127
+ const origin = requiredBy
128
+ .map((k) => registry?.[k]?.origin)
129
+ .find((o) => typeof o === 'string' && o.startsWith('plugin:'));
130
+ return origin ? { skill, requiredBy, origin } : { skill, requiredBy };
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Preflight gate. Resolve every required skill; if ANY is unresolvable, throw a
136
+ * single aggregated error naming each (requiring agent(s), skill, searched paths)
137
+ * so the user fixes them all at once. On success, return the resolutions keyed by
138
+ * skill (consumed by injectSkills). Throws BEFORE any node runs; the thrown plain
139
+ * Error lands in run()'s error branch → run ends with status 'error', message
140
+ * surfaced.
141
+ * @param {Array<{skill:string, requiredBy:string[]}>} required
142
+ * @param {{repoRoot:string, projectDir:string, homeDir?:string}} ctx
143
+ * @returns {Map<string, {source:string, path:string, requiredBy:string[]}>}
144
+ */
145
+ export function validateSkills(required, ctx) {
146
+ const resolved = new Map();
147
+ const missing = [];
148
+ const invalid = [];
149
+ for (const { skill, requiredBy, origin } of required) {
150
+ // S2 FIRST: a name that cannot be a path segment is never searched, so a
151
+ // traversal is always reported as a traversal instead of being masked by a
152
+ // "not found" listing of three joined-and-normalized paths.
153
+ if (!isValidSkillName(skill)) { invalid.push({ skill, requiredBy }); continue; }
154
+ const r = resolveSkill(skill, { ...ctx, origin: origin ?? null });
155
+ if (r.source === null) missing.push({ skill, requiredBy, searched: r.searched });
156
+ else resolved.set(skill, { source: r.source, path: r.path, requiredBy });
157
+ }
158
+ if (invalid.length) {
159
+ throw new Error(
160
+ `Preflight failed: ${invalid.length} required skill(s) have an invalid skill name. ` +
161
+ 'A skill name becomes a directory name under `.claude/skills/`, so it may contain only ' +
162
+ 'letters, digits, `.`, `_` and `-`, and may not be `.` or `..`:\n' +
163
+ invalid
164
+ .map((m) => ` - ${JSON.stringify(m.skill)} (required by ${m.requiredBy.join(', ')})`)
165
+ .join('\n'),
166
+ );
167
+ }
168
+ if (missing.length) {
169
+ const lines = missing.map(
170
+ (m) =>
171
+ ` - skill "${m.skill}" (required by ${m.requiredBy.join(', ')}) not found. Searched:\n` +
172
+ m.searched.map((p) => ` ${p}`).join('\n'),
173
+ );
174
+ throw new Error(
175
+ `Preflight failed: ${missing.length} required skill(s) unavailable. ` +
176
+ `Bundle them in the worca-cc repo (skills/<name>/) or install under ~/.claude/skills/:\n` +
177
+ lines.join('\n'),
178
+ );
179
+ }
180
+ return resolved;
181
+ }
182
+
183
+ /**
184
+ * Copy each BUNDLE- or PLUGIN-sourced skill into every given target's
185
+ * .claude/skills/ (global/project skills are already on the scan path — nothing to
186
+ * copy). Runs ONLY after validateSkills passes. A copy failure rejects, aborting
187
+ * the run before any node starts (no half-injected skill dir reaches a node).
188
+ *
189
+ * `targets` MUST be dirs worca-cc owns — real isolated checkouts (never the main
190
+ * projectDir) or a run root. The orchestrator guards this before calling (see §5)
191
+ * so injection cannot pollute the user's working tree. The parameter is generic
192
+ * because a detached WORKSPACE run mounts once at the run root instead of N
193
+ * worktrees (§5.6 entry class 3); that call site lives in run-context.mjs, so under
194
+ * `detached` this function is not the delivery path at all — it stays the byte-
195
+ * identical LEGACY one (§10 rollback contract).
196
+ * @param {Map<string, {source:string, path:string}>} resolutions
197
+ * @param {{targets:string[]}} ctx
198
+ * @returns {Promise<string[]>} skill names actually injected
199
+ */
200
+ export async function injectSkills(resolutions, { targets }) {
201
+ const injected = [];
202
+ for (const [skill, r] of resolutions) {
203
+ if (r.source !== 'bundle' && !String(r.source || '').startsWith('plugin:')) continue;
204
+ for (const t of targets || []) {
205
+ await cp(r.path, join(t, '.claude', 'skills', skill), { recursive: true });
206
+ }
207
+ injected.push(skill);
208
+ }
209
+ return injected;
210
+ }
@@ -0,0 +1,232 @@
1
+ // src/core/sources.mjs
2
+ // The task-source seam (spec §7.3): every way a pipeline acquires its task —
3
+ // inline prompt, markdown file/text, or a plugin task-source connector — resolves
4
+ // through ONE path yielding { promptText, promptFile, sourceMeta }.
5
+ // Feature-off bar: with zero plugins installed, 'prompt'/'markdown' resolution is
6
+ // byte-identical to the old inline prompt||promptFile branching in createPipeline.
7
+
8
+ import { readFile } from 'node:fs/promises';
9
+ import { readFileSync } from 'node:fs';
10
+ import { isAbsolute, join, resolve } from 'node:path';
11
+ import { callSource } from './plugin-shim.mjs';
12
+ import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs';
13
+ import { normalizeManifest } from './plugin-manifest.mjs';
14
+ import { getDb } from './db.mjs';
15
+ import { runDirForRow, readStoreMeta } from './artifacts.mjs';
16
+ import { RESULTS_FILE } from './results.mjs';
17
+ import { hasGh, findPrForBranch } from './git-info.mjs';
18
+
19
+ /** Same resolve-against-projectDir semantics as artifacts.mjs#resolveAgainst. */
20
+ function resolveAgainst(base, p) {
21
+ return isAbsolute(p) ? p : resolve(base, p);
22
+ }
23
+
24
+ /**
25
+ * Every selectable task source: the two built-ins plus one entry per task source
26
+ * of every ENABLED installed plugin (lexicographic plugin order, manifest order
27
+ * within a plugin). Broken/missing manifests are skipped — the pane must render.
28
+ * @returns {Array<{type:string, displayName:string, plugin?:string, sourceId?:string, inputs?:Array}>}
29
+ */
30
+ export function listTaskSources() {
31
+ const sources = [
32
+ { type: 'prompt', displayName: 'Prompt' },
33
+ { type: 'markdown', displayName: 'Markdown' },
34
+ ];
35
+ let lock = {};
36
+ try { lock = readPluginsLock(); } catch { lock = {}; }
37
+ for (const name of Object.keys(lock).sort()) {
38
+ if (lock[name]?.enabled === false) continue;
39
+ let manifest = null;
40
+ try {
41
+ const dir = pluginCurrentDir(name);
42
+ const norm = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
43
+ manifest = norm.ok ? norm.manifest : null;
44
+ } catch { manifest = null; }
45
+ if (!manifest) continue;
46
+ for (const ts of manifest.taskSources || []) {
47
+ sources.push({
48
+ type: 'plugin',
49
+ plugin: name,
50
+ sourceId: ts.id,
51
+ displayName: ts.displayName || `${name}/${ts.id}`,
52
+ inputs: ts.inputs || [],
53
+ });
54
+ }
55
+ }
56
+ return sources;
57
+ }
58
+
59
+ /** `# title\n\nbody` + a fenced json meta block when the provider bag is non-empty. */
60
+ function taskPromptText(task) {
61
+ let text = `# ${task.title || task.id}\n\n${task.body || ''}`;
62
+ if (task.meta && typeof task.meta === 'object' && Object.keys(task.meta).length > 0) {
63
+ text += `\n\n\`\`\`json meta\n${JSON.stringify(task.meta, null, 2)}\n\`\`\``;
64
+ }
65
+ return text;
66
+ }
67
+
68
+ /**
69
+ * Resolve a source descriptor to the pipeline's task input.
70
+ * { type:'prompt', prompt } | { type:'markdown', promptText?, promptFile? }
71
+ * | { type:'plugin', plugin, sourceId, taskId, inputs? }
72
+ * @returns {Promise<{promptText:string, promptFile:string|null,
73
+ * sourceMeta:{plugin,sourceId,taskId,url,title}|null}>}
74
+ */
75
+ export async function resolveTaskInput(source, { projectDir } = {}) {
76
+ const src = source && typeof source === 'object' ? source : { type: 'prompt', prompt: '' };
77
+ const type = src.type || 'prompt';
78
+
79
+ if (type === 'prompt') {
80
+ return { promptText: typeof src.prompt === 'string' ? src.prompt : '', promptFile: null, sourceMeta: null };
81
+ }
82
+
83
+ if (type === 'markdown') {
84
+ if (src.promptFile) {
85
+ let promptText = '';
86
+ try {
87
+ promptText = await readFile(resolveAgainst(projectDir, src.promptFile), 'utf8');
88
+ } catch {
89
+ promptText = ''; // exactly the legacy createPipeline catch{} degradation
90
+ }
91
+ return { promptText, promptFile: src.promptFile, sourceMeta: null };
92
+ }
93
+ return { promptText: typeof src.promptText === 'string' ? src.promptText : '', promptFile: null, sourceMeta: null };
94
+ }
95
+
96
+ if (type === 'plugin') {
97
+ const task = await callSource({ plugin: src.plugin, sourceId: src.sourceId, op: 'getTask', args: { id: src.taskId } });
98
+ if (!task) {
99
+ throw new Error(`task-source ${src.plugin}/${src.sourceId}: task "${src.taskId}" not found`);
100
+ }
101
+ return {
102
+ promptText: taskPromptText(task),
103
+ promptFile: null,
104
+ sourceMeta: {
105
+ plugin: src.plugin,
106
+ sourceId: src.sourceId,
107
+ taskId: src.taskId,
108
+ url: task.url ?? null,
109
+ title: task.title ?? null,
110
+ },
111
+ };
112
+ }
113
+
114
+ throw new Error(`unknown task source type "${type}"`);
115
+ }
116
+
117
+ // ── result write-back (spec §7.5) ──────────────────────────────────────────────
118
+
119
+ /** Map a pipeline row status onto the connector reportResult vocabulary (§7.1). */
120
+ function statusToResult(status) {
121
+ if (status === 'done') return 'completed';
122
+ if (status === 'error' || status === 'stopped') return 'failed';
123
+ return 'needs-human'; // paused | interrupted | anything non-terminal (manual retry path)
124
+ }
125
+
126
+ /** Markdown summary assembled from the persisted results view (results.mjs#assembleResults shape). */
127
+ function buildResultSummary(row, bundle) {
128
+ const lines = [`### Worca CC run \`${row.id}\` — ${row.status}`];
129
+ if (row.title) lines.push('', `**${row.title}**`);
130
+ const s = bundle?.results?.summary; // { filesNew, filesChanged, filesDeleted, linesAdded, linesRemoved, blockingIssues, nitpicks }
131
+ if (s) {
132
+ lines.push('', `- Diffstat: ${s.filesChanged ?? 0} changed, ${s.filesNew ?? 0} new, ${s.filesDeleted ?? 0} deleted, +${s.linesAdded ?? 0} / -${s.linesRemoved ?? 0}`);
133
+ lines.push(`- Review: ${s.blockingIssues ?? 0} blocking, ${s.nitpicks ?? 0} nitpicks`);
134
+ }
135
+ if (bundle?.branch) lines.push(`- Branch: \`${bundle.branch}\``); // local branches have no URL — named here, linked below only as a PR
136
+ const checks = Array.isArray(bundle?.results?.keyThingsToCheck) ? bundle.results.keyThingsToCheck : [];
137
+ if (checks.length) {
138
+ lines.push('', 'Key things to check:');
139
+ for (const c of checks.slice(0, 5)) lines.push(`- [${c.severity}] ${c.title}${c.file ? ` (\`${c.file}\`)` : ''}`);
140
+ }
141
+ return lines.join('\n');
142
+ }
143
+
144
+ /** Tracker-comment links: the PR when the bundle knows one. */
145
+ function buildResultLinks(bundle) {
146
+ const links = [];
147
+ if (bundle?.prUrl) links.push({ title: 'Pull request', url: bundle.prUrl });
148
+ return links;
149
+ }
150
+
151
+ /**
152
+ * Report a finished pipeline back to its plugin task source. NEVER throws and
153
+ * NEVER blocks completion semantics: every failure collapses to { ok:false,
154
+ * error } for the caller to log/surface. Silent skip ({ ok:true, skipped:true })
155
+ * for prompt/markdown rows and refs that cannot be parsed.
156
+ * @param {object} pipelineRow raw pipelines row (source_type/source_ref/status/title)
157
+ * @param {{results:object|null, branch:string|null, prUrl:string|null}} resultsBundle
158
+ * @returns {Promise<{ok:true, skipped?:true} | {ok:false, error:string}>}
159
+ */
160
+ export async function reportResultForPipeline(pipelineRow, resultsBundle) {
161
+ try {
162
+ if ((pipelineRow?.source_type || 'prompt') !== 'plugin') return { ok: true, skipped: true };
163
+ let ref = null;
164
+ try { ref = pipelineRow.source_ref ? JSON.parse(pipelineRow.source_ref) : null; } catch { ref = null; }
165
+ if (!ref?.plugin || !ref?.sourceId || !ref?.taskId) return { ok: true, skipped: true };
166
+
167
+ // Capability probe with a TOLERANT DEFAULT. capabilities() is optional in the
168
+ // connector contract (§7.1: "defaults: writeBack true"): a connector without
169
+ // the op makes the child answer { ok:false, error:{ kind:'plugin', message:
170
+ // 'connector does not implement op "capabilities"' } } -> callSource throws
171
+ // PluginOpError('plugin') -> we default to writeBack:true. Transport errors
172
+ // (auth/network/timeout/protocol) ALSO default to true — the reportResult
173
+ // call below is the one that surfaces the real failure to the caller.
174
+ let writeBack = true;
175
+ try {
176
+ const caps = await callSource({ plugin: ref.plugin, sourceId: ref.sourceId, op: 'capabilities', args: {} });
177
+ if (caps && caps.writeBack === false) writeBack = false;
178
+ } catch {
179
+ writeBack = true;
180
+ }
181
+ if (!writeBack) return { ok: true, skipped: true };
182
+
183
+ await callSource({
184
+ plugin: ref.plugin,
185
+ sourceId: ref.sourceId,
186
+ op: 'reportResult',
187
+ args: {
188
+ id: ref.taskId,
189
+ status: statusToResult(pipelineRow.status),
190
+ summary: buildResultSummary(pipelineRow, resultsBundle),
191
+ links: buildResultLinks(resultsBundle),
192
+ },
193
+ });
194
+ return { ok: true };
195
+ } catch (err) {
196
+ return { ok: false, error: err?.message || String(err) };
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Load everything write-back needs for one pipeline and report it. Used by the
202
+ * orchestrator's terminal hook AND (Task 15) the results-view "Report result"
203
+ * retry endpoint. Skips BEFORE any bundle work for prompt/markdown rows, so
204
+ * feature-off runs never touch git/gh/results here. NEVER throws.
205
+ * @param {string} pipelineId
206
+ */
207
+ export async function retryWriteback(pipelineId) {
208
+ try {
209
+ const row = getDb().prepare('SELECT * FROM pipelines WHERE id = ?').get(pipelineId);
210
+ if (!row) return { ok: false, error: `unknown pipeline "${pipelineId}"` };
211
+ if ((row.source_type || 'prompt') !== 'plugin') return { ok: true, skipped: true };
212
+
213
+ const dir = await runDirForRow(row);
214
+ let results = null;
215
+ try { results = JSON.parse(await readFile(join(dir, RESULTS_FILE), 'utf8')); } catch { results = null; }
216
+ let branch = null;
217
+ try { branch = row.branch ? (JSON.parse(row.branch)?.feature ?? null) : null; } catch { branch = null; }
218
+ // PR link, best-effort (same hasGh-gated pattern as artifacts.mjs#rowToHistoryEntry).
219
+ let prUrl = null;
220
+ if (branch) {
221
+ try {
222
+ const meta = readStoreMeta(row.project_key);
223
+ if (meta?.path && (await hasGh())) {
224
+ prUrl = (await findPrForBranch({ projectDir: meta.path, head: branch }))?.url || null;
225
+ }
226
+ } catch { prUrl = null; }
227
+ }
228
+ return await reportResultForPipeline(row, { results, branch, prUrl });
229
+ } catch (err) {
230
+ return { ok: false, error: err?.message || String(err) };
231
+ }
232
+ }
@@ -0,0 +1,182 @@
1
+ // src/core/stats.mjs
2
+ // Statistics over ALL pipeline records — archived included, deliberately: the
3
+ // archive exists so these numbers survive history cleanup (spec §6.9). Pure
4
+ // synchronous DB reads; no gh, no git, offline-correct.
5
+ //
6
+ // Attribution: money = cost_ledger by event timestamp (exact); runs, time,
7
+ // and PRs = cohort by started_at (a run belongs to the bucket its start falls
8
+ // in, with its CURRENT status). 'today' differs: its totals count runs whose
9
+ // lifespan [started_at, updated_at] overlaps the day ("active today"), and its
10
+ // hourly bars attribute terminal outcomes to the hour of the terminal write
11
+ // (updated_at). 'all' money/time use the fallback-aware pipelines sums so
12
+ // pre-ledger history still counts.
13
+
14
+ import { prepare } from './db.mjs';
15
+ import {
16
+ budgetStatus, costWindowStart, costWindowEnd, allTimeTotals, roundUsd,
17
+ } from './cost-budget.mjs';
18
+
19
+ const RANGES = ['today', 'week', 'month', 'all'];
20
+
21
+ function hourStart(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()); }
22
+ function dayStart(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
23
+ function monthStart(d) { return new Date(d.getFullYear(), d.getMonth(), 1); }
24
+
25
+ const TOTALS_SELECT = `
26
+ SELECT COUNT(*) AS runs,
27
+ COALESCE(SUM(status = 'done'), 0) AS finished,
28
+ COALESCE(SUM(status = 'stopped'), 0) AS stopped,
29
+ COALESCE(SUM(status = 'error'), 0) AS failed,
30
+ COALESCE(SUM(status IN ('paused','interrupted')), 0) AS paused,
31
+ COALESCE(SUM(status IN ('created','starting','running','pausing')), 0) AS running,
32
+ COALESCE(SUM(pr_url IS NOT NULL), 0) AS prsOpened,
33
+ COALESCE(SUM(pr_state = 'MERGED'), 0) AS prsMerged,
34
+ COALESCE(SUM(CASE WHEN p.total_cost_usd > 0 THEN p.total_cost_usd ELSE COALESCE(s.sc, 0) END), 0) AS spend,
35
+ COALESCE(SUM(CASE WHEN p.total_active_ms > 0 THEN p.total_active_ms ELSE COALESCE(s.sa, 0) END), 0) AS active
36
+ FROM pipelines p
37
+ LEFT JOIN (SELECT pipeline_id, SUM(cost_usd) sc, SUM(active_ms) sa
38
+ FROM pipeline_steps GROUP BY pipeline_id) s ON s.pipeline_id = p.id`;
39
+
40
+ function totalsRow(sql, isoA, isoB) {
41
+ const row = prepare(sql).get(isoA, isoB);
42
+ return {
43
+ runs: row.runs, finished: row.finished, stopped: row.stopped, failed: row.failed,
44
+ paused: row.paused, running: row.running,
45
+ prsOpened: row.prsOpened, prsMerged: row.prsMerged,
46
+ workedMs: Number(row.active || 0),
47
+ cohortSpendUsd: roundUsd(row.spend || 0),
48
+ };
49
+ }
50
+
51
+ /** Cohort totals over runs started in [fromMs, toMs). Legacy fs->db imports have
52
+ * no started_at; they fall back to updated_at so their money (which always
53
+ * lands in the all-time sums) is never counted without its run. */
54
+ function cohortTotals(fromMs, toMs) {
55
+ return totalsRow(`${TOTALS_SELECT}
56
+ WHERE COALESCE(p.started_at, p.updated_at) >= ?
57
+ AND COALESCE(p.started_at, p.updated_at) < ?`,
58
+ new Date(fromMs).toISOString(), new Date(toMs).toISOString());
59
+ }
60
+
61
+ /** Runs whose lifespan [started_at, updated_at] overlaps [fromMs, toMs) —
62
+ * "active in the window": started, updated, or finished inside it. There is
63
+ * no finished_at column; updated_at is the terminal-write proxy. */
64
+ function activeTotals(fromMs, toMs) {
65
+ return totalsRow(`${TOTALS_SELECT}
66
+ WHERE COALESCE(p.started_at, p.updated_at) < ?
67
+ AND COALESCE(p.updated_at, p.started_at) >= ?`,
68
+ new Date(toMs).toISOString(), new Date(fromMs).toISOString());
69
+ }
70
+
71
+ /** Runs whose LAST write (updated_at, ≈ the terminal write for finished runs)
72
+ * falls in [fromMs, toMs) — hourly outcome attribution for the today range. */
73
+ function updatedTotals(fromMs, toMs) {
74
+ return totalsRow(`${TOTALS_SELECT}
75
+ WHERE COALESCE(p.updated_at, p.started_at) >= ?
76
+ AND COALESCE(p.updated_at, p.started_at) < ?`,
77
+ new Date(fromMs).toISOString(), new Date(toMs).toISOString());
78
+ }
79
+
80
+ /** Ledger spend in [fromMs, toMs). */
81
+ function ledgerSpend(fromMs, toMs) {
82
+ const row = prepare(
83
+ 'SELECT SUM(amount_usd) AS s FROM cost_ledger WHERE ts >= ? AND ts < ?').get(fromMs, toMs);
84
+ return roundUsd(row?.s || 0);
85
+ }
86
+
87
+ /** Build zero-filled buckets [{startMs, endMs}] from windowStart through `now`. */
88
+ function buildBuckets(windowStart, now, bucket) {
89
+ const out = [];
90
+ if (bucket === 'hour') {
91
+ for (let h = hourStart(windowStart); h <= now;
92
+ h = new Date(h.getFullYear(), h.getMonth(), h.getDate(), h.getHours() + 1)) {
93
+ out.push({ startMs: h.getTime(),
94
+ endMs: new Date(h.getFullYear(), h.getMonth(), h.getDate(), h.getHours() + 1).getTime() });
95
+ }
96
+ } else if (bucket === 'day') {
97
+ for (let d = dayStart(windowStart); d <= now; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1)) {
98
+ out.push({ startMs: d.getTime(), endMs: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() });
99
+ }
100
+ } else {
101
+ for (let m = monthStart(windowStart); m <= now; m = new Date(m.getFullYear(), m.getMonth() + 1, 1)) {
102
+ out.push({ startMs: m.getTime(), endMs: new Date(m.getFullYear(), m.getMonth() + 1, 1).getTime() });
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** GET /api/stats backing (spec §6.9). @throws {RangeError} on unknown range. */
109
+ export function getStats({ range = 'month', now = new Date() } = {}) {
110
+ if (!RANGES.includes(range)) throw new RangeError(`unknown stats range: ${range}`);
111
+ const budget = budgetStatus(now);
112
+
113
+ let windowStart, windowEnd, prevStart, prevEnd, bucket;
114
+ if (range === 'today') {
115
+ windowStart = dayStart(now);
116
+ windowEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
117
+ prevStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
118
+ prevEnd = windowStart; bucket = 'hour';
119
+ } else if (range === 'week') {
120
+ windowStart = costWindowStart(now, 'weekly'); windowEnd = costWindowEnd(now, 'weekly');
121
+ prevStart = new Date(windowStart.getFullYear(), windowStart.getMonth(), windowStart.getDate() - 7);
122
+ prevEnd = windowStart; bucket = 'day';
123
+ } else if (range === 'month') {
124
+ windowStart = costWindowStart(now, 'monthly'); windowEnd = costWindowEnd(now, 'monthly');
125
+ prevStart = new Date(windowStart.getFullYear(), windowStart.getMonth() - 1, 1);
126
+ prevEnd = windowStart; bucket = 'day';
127
+ } else {
128
+ // all time: series over the last 12 months; totals over everything
129
+ windowStart = new Date(now.getFullYear(), now.getMonth() - 11, 1);
130
+ windowEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
131
+ prevStart = null; prevEnd = null; bucket = 'month';
132
+ }
133
+
134
+ // 'today' counts runs ACTIVE in the window (Q&A: started, updated, or
135
+ // finished today); every other range keeps cohort-by-start. prev flows
136
+ // through the same closure, so the yesterday delta compares like with like.
137
+ const windowTotalsFn = range === 'today' ? activeTotals : cohortTotals;
138
+ const shapeTotals = (fromMs, toMs) => {
139
+ const c = windowTotalsFn(fromMs, toMs);
140
+ return {
141
+ spentUsd: ledgerSpend(fromMs, toMs),
142
+ workedMs: c.workedMs,
143
+ runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
144
+ paused: c.paused, running: c.running,
145
+ prsOpened: c.prsOpened, prsMerged: c.prsMerged,
146
+ };
147
+ };
148
+
149
+ let totals;
150
+ if (range === 'all') {
151
+ const at = allTimeTotals();
152
+ const c = cohortTotals(0, windowEnd.getTime());
153
+ totals = {
154
+ spentUsd: at.spendUsd, workedMs: at.activeMs,
155
+ runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
156
+ paused: c.paused, running: c.running,
157
+ prsOpened: c.prsOpened, prsMerged: c.prsMerged,
158
+ };
159
+ } else {
160
+ totals = shapeTotals(windowStart.getTime(), windowEnd.getTime());
161
+ }
162
+
163
+ const prev = prevStart ? shapeTotals(prevStart.getTime(), prevEnd.getTime()) : null;
164
+
165
+ // Hourly bars pin terminal outcomes to the hour of the terminal write; the
166
+ // day/month bars keep cohort-by-start. Money is ledger-exact either way.
167
+ const bucketTotalsFn = range === 'today' ? updatedTotals : cohortTotals;
168
+ const series = buildBuckets(windowStart, now, bucket).map(({ startMs, endMs }) => {
169
+ const c = bucketTotalsFn(startMs, endMs);
170
+ return {
171
+ bucketStartMs: startMs,
172
+ spentUsd: ledgerSpend(startMs, endMs),
173
+ finished: c.finished, stopped: c.stopped, failed: c.failed,
174
+ };
175
+ });
176
+
177
+ return {
178
+ range, bucket,
179
+ windowStartMs: windowStart.getTime(), windowEndMs: windowEnd.getTime(),
180
+ totals, prev, budget, series,
181
+ };
182
+ }