@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,257 @@
1
+ // src/core/protocol.mjs
2
+ // JSON contracts + validators shared by agents and the orchestrator.
3
+ //
4
+ // All readers are tolerant: a missing or malformed file yields a safe empty
5
+ // shape rather than throwing. Writers serialize canonical JSON shapes that the
6
+ // agent prompts (agents/*.md) are instructed to produce.
7
+
8
+ import { readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+
11
+ /**
12
+ * Severity ranking used throughout the pipeline. Order is significant:
13
+ * earlier entries are more severe. "critical" and "major" are *blocking*.
14
+ */
15
+ export const SEVERITIES = ['critical', 'major', 'minor', 'suggestion'];
16
+
17
+ const BLOCKING = new Set(['critical', 'major']);
18
+
19
+ /** Normalize an arbitrary value to one of SEVERITIES (default "minor"). */
20
+ function normalizeSeverity(value) {
21
+ if (typeof value !== 'string') return 'minor';
22
+ const v = value.trim().toLowerCase();
23
+ return SEVERITIES.includes(v) ? v : 'minor';
24
+ }
25
+
26
+ /** Coerce any value to a trimmed string (empty string for null/undefined). */
27
+ function asString(value) {
28
+ if (value === null || value === undefined) return '';
29
+ return String(value);
30
+ }
31
+
32
+ /**
33
+ * Tolerant JSON parser.
34
+ * - Accepts plain JSON.
35
+ * - Strips ```json ... ``` (or bare ``` ... ```) fences.
36
+ * - Falls back to extracting the first balanced {...} object found in the text
37
+ * (handles models that wrap JSON in prose).
38
+ * Returns the parsed object/array, or null if nothing parseable is found.
39
+ *
40
+ * @param {string} text
41
+ * @returns {object|Array|null}
42
+ */
43
+ export function safeParseJson(text) {
44
+ if (text === null || text === undefined) return null;
45
+ let raw = String(text).trim();
46
+ if (!raw) return null;
47
+
48
+ // 1) Direct parse.
49
+ const direct = tryParse(raw);
50
+ if (direct !== undefined) return direct;
51
+
52
+ // 2) Strip code fences (```json\n...\n``` or ```\n...\n```).
53
+ const fence = raw.match(/```(?:json|JSON)?\s*([\s\S]*?)```/);
54
+ if (fence && fence[1]) {
55
+ const inner = tryParse(fence[1].trim());
56
+ if (inner !== undefined) return inner;
57
+ raw = fence[1].trim();
58
+ }
59
+
60
+ // 3) Find the first balanced JSON object or array in the remaining text.
61
+ const balanced = extractFirstBalanced(raw);
62
+ if (balanced !== null) {
63
+ const parsed = tryParse(balanced);
64
+ if (parsed !== undefined) return parsed;
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ function tryParse(s) {
71
+ try {
72
+ return JSON.parse(s);
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Scan `text` for the first balanced {...} or [...] region, respecting strings
80
+ * and escapes so braces inside string literals do not confuse the matcher.
81
+ * Returns the substring (inclusive of the delimiters) or null.
82
+ */
83
+ function extractFirstBalanced(text) {
84
+ const startIdx = firstIndexOfEither(text, '{', '[');
85
+ if (startIdx === -1) return null;
86
+
87
+ const open = text[startIdx];
88
+ const close = open === '{' ? '}' : ']';
89
+ let depth = 0;
90
+ let inString = false;
91
+ let escaped = false;
92
+
93
+ for (let i = startIdx; i < text.length; i++) {
94
+ const ch = text[i];
95
+ if (inString) {
96
+ if (escaped) {
97
+ escaped = false;
98
+ } else if (ch === '\\') {
99
+ escaped = true;
100
+ } else if (ch === '"') {
101
+ inString = false;
102
+ }
103
+ continue;
104
+ }
105
+ if (ch === '"') {
106
+ inString = true;
107
+ continue;
108
+ }
109
+ if (ch === open) {
110
+ depth++;
111
+ } else if (ch === close) {
112
+ depth--;
113
+ if (depth === 0) {
114
+ return text.slice(startIdx, i + 1);
115
+ }
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ function firstIndexOfEither(text, a, b) {
122
+ const ia = text.indexOf(a);
123
+ const ib = text.indexOf(b);
124
+ if (ia === -1) return ib;
125
+ if (ib === -1) return ia;
126
+ return Math.min(ia, ib);
127
+ }
128
+
129
+ // Hard caps so a single clarify round can never overwhelm the user, regardless of
130
+ // what the clarify agent emits. Tunable; pairs with the prompt guidance in
131
+ // agents/worca-cc-clarify.md (up to 8 questions, 2–4 options each).
132
+ const MAX_CLARIFY_QUESTIONS = 8;
133
+ const MAX_CLARIFY_OPTIONS = 4;
134
+
135
+ /**
136
+ * Coerce arbitrary parsed data into the canonical clarify shape:
137
+ * { questions: [ { id, question, options:[2–4 strings], allowFreeText:true } ] }
138
+ * Always returns at least { questions: [] }. Caps questions at MAX_CLARIFY_QUESTIONS
139
+ * and options at MAX_CLARIFY_OPTIONS; blank options are trimmed away (the UI/CLI also
140
+ * filter them), so binary/assumption questions keep exactly their real choices —
141
+ * nothing is padded.
142
+ */
143
+ export function normalizeClarify(data) {
144
+ if (!data || typeof data !== 'object') return { questions: [] };
145
+ const list = Array.isArray(data.questions) ? data.questions : [];
146
+ const questions = [];
147
+ for (let i = 0; i < list.length; i++) {
148
+ const q = list[i];
149
+ if (!q || typeof q !== 'object') continue;
150
+ const id = asString(q.id).trim() || `q${i + 1}`;
151
+ const question = asString(q.question).trim();
152
+ if (!question) continue;
153
+ // Allow 2–4 options: trim, drop blanks, cap at MAX_CLARIFY_OPTIONS. No padding.
154
+ // asString does not trim, so trim explicitly here before dropping empties.
155
+ const options = (Array.isArray(q.options) ? q.options.map(asString) : [])
156
+ .map((o) => o.trim())
157
+ .filter(Boolean)
158
+ .slice(0, MAX_CLARIFY_OPTIONS);
159
+ questions.push({ id, question, options, allowFreeText: true });
160
+ }
161
+ return { questions: questions.slice(0, MAX_CLARIFY_QUESTIONS) };
162
+ }
163
+
164
+ /**
165
+ * Read clarify.json from a pipeline directory. Missing/invalid => { questions: [] }.
166
+ * @param {string} pipelineDir
167
+ * @returns {Promise<{questions: Array}>}
168
+ */
169
+ export async function readClarify(pipelineDir) {
170
+ const file = join(pipelineDir, 'clarify.json');
171
+ let text;
172
+ try {
173
+ text = await readFile(file, 'utf8');
174
+ } catch {
175
+ return { questions: [] };
176
+ }
177
+ const parsed = safeParseJson(text);
178
+ return normalizeClarify(parsed);
179
+ }
180
+
181
+ /**
182
+ * Read one ask-then-resume questions file (per-step user questions, spec
183
+ * 2026-07-11) from an ABSOLUTE path. Same schema, caps, and tolerance as
184
+ * clarify.json. Missing file => { questions: [], malformed: false } (the agent
185
+ * chose not to ask); present-but-unparseable => malformed: true so the caller
186
+ * can audit-warn while proceeding.
187
+ * @param {string} absPath
188
+ * @returns {Promise<{questions: Array, malformed: boolean}>}
189
+ */
190
+ export async function readQuestionsFile(absPath) {
191
+ let text;
192
+ try {
193
+ text = await readFile(absPath, 'utf8');
194
+ } catch {
195
+ return { questions: [], malformed: false };
196
+ }
197
+ const parsed = safeParseJson(text);
198
+ if (parsed === null) return { questions: [], malformed: true };
199
+ return { ...normalizeClarify(parsed), malformed: false };
200
+ }
201
+
202
+ /**
203
+ * Coerce arbitrary parsed data into the canonical review shape:
204
+ * { issues: [ { severity, title, detail, location } ], summary }
205
+ * Always returns { issues: [], summary: '' } on bad input.
206
+ */
207
+ function normalizeReview(data) {
208
+ if (!data || typeof data !== 'object') return { issues: [], summary: '' };
209
+ const list = Array.isArray(data.issues) ? data.issues : [];
210
+ const issues = [];
211
+ for (const it of list) {
212
+ if (!it || typeof it !== 'object') continue;
213
+ issues.push({
214
+ severity: normalizeSeverity(it.severity),
215
+ title: asString(it.title).trim(),
216
+ detail: asString(it.detail).trim(),
217
+ location: asString(it.location).trim(),
218
+ });
219
+ }
220
+ return { issues, summary: asString(data.summary).trim() };
221
+ }
222
+
223
+ /**
224
+ * Read a review JSON file from an absolute path.
225
+ * Missing/invalid => { issues: [], summary: '' }.
226
+ * @param {string} jsonPath
227
+ * @returns {Promise<{issues: Array, summary: string}>}
228
+ */
229
+ export async function readReview(jsonPath) {
230
+ let text;
231
+ try {
232
+ text = await readFile(jsonPath, 'utf8');
233
+ } catch {
234
+ return { issues: [], summary: '' };
235
+ }
236
+ return normalizeReview(safeParseJson(text));
237
+ }
238
+
239
+ /**
240
+ * True if a review contains any critical or major issue.
241
+ * @param {{issues: Array}} review
242
+ * @returns {boolean}
243
+ */
244
+ export function hasBlocking(review) {
245
+ if (!review || !Array.isArray(review.issues)) return false;
246
+ return review.issues.some((i) => BLOCKING.has(normalizeSeverity(i?.severity)));
247
+ }
248
+
249
+ /**
250
+ * The subset of issues that are critical or major.
251
+ * @param {{issues: Array}} review
252
+ * @returns {Array}
253
+ */
254
+ export function blockingIssues(review) {
255
+ if (!review || !Array.isArray(review.issues)) return [];
256
+ return review.issues.filter((i) => BLOCKING.has(normalizeSeverity(i?.severity)));
257
+ }
@@ -0,0 +1,51 @@
1
+ // src/core/recoverable-error.mjs
2
+ // Single source of truth for "is this pipeline error recoverable, and which class".
3
+ // Recoverable errors are user/transient-fixable (re-auth, wait, top up, retry),
4
+ // NOT bugs. The orchestrator uses the class to drive a retry gate; a null result
5
+ // means "fail as today". Classification reads the thrown message because the real
6
+ // runner (src/core/claude-runner.mjs) folds the underlying headless cause — incl.
7
+ // the 401 auth string captured from the terminal result(is_error:true) event —
8
+ // into its reject text: `claude exited with code N: <cause>` (claude-runner.mjs:298).
9
+ //
10
+ // CAVEAT (accepted, see YAGNI): detection is message-based unless the producer
11
+ // stamped `errorClass` (claude-runner does, on the non-zero-exit path only —
12
+ // spawn-failure errors stay unstamped and keep message-sniff classification), so
13
+ // a genuine bug whose message happens to contain a recoverable keyword (e.g. an
14
+ // app error literally mentioning "network" or "quota") will be classed
15
+ // recoverable and retried. Structured error-code detection is out of scope.
16
+ //
17
+ // @param {Error|string|unknown} err
18
+ // @returns {'auth'|'usage_limit'|'rate_limit'|'quota'|'network'|null}
19
+ export function classifyError(err) {
20
+ // A producer that saw MORE evidence than the message carries stamps the
21
+ // verdict directly: claude-runner classifies the FULL stderr stream line-by-
22
+ // line, then tail-caps the message. Re-sniffing the capped message here could
23
+ // only lose an early marker (or mint a fake one at the slice boundary), so a
24
+ // stamp — including an explicit null — is authoritative.
25
+ if (err && typeof err === 'object' && err.errorClass !== undefined) return err.errorClass;
26
+ const msg = String((err && err.message) || err || '');
27
+ if (/\b401\b|invalid authentication|authentication_error|please run .*login|not logged in/i.test(msg)) return 'auth';
28
+ // Session/usage caps that only clear after a multi-hour reset (the CLI prints
29
+ // "You've hit your session limit · resets 6pm"). Distinct from rate_limit (a
30
+ // few-second 429/overloaded burst) because retrying is futile — the orchestrator
31
+ // PAUSES on this class instead of burning the retry budget. Kept narrow enough
32
+ // not to swallow the generic "usage limit reached" billing case (-> quota).
33
+ if (/\bsession limit\b|hit your[^.]*\blimit\b|reached your[^.]*\blimit\b|\blimit\b[^.]*\bresets?\b/i.test(msg)) return 'usage_limit';
34
+ if (/\b429\b|\b529\b|rate.?limit|overloaded/i.test(msg)) return 'rate_limit';
35
+ if (/credit balance|usage limit|quota|insufficient_quota|billing/i.test(msg)) return 'quota';
36
+ if (/ECONNRESET|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|EPIPE|socket hang up|fetch failed|network|connection (refused|reset|closed|error)|closed mid-response|response above may be incomplete/i.test(msg)) return 'network';
37
+ return null;
38
+ }
39
+
40
+ // Precedence for folding per-line classes into the one whole-text class — the
41
+ // SAME order as the regex chain above. First-match-wins there equals
42
+ // strongest-class-wins here, because every per-line match (the patterns are
43
+ // unanchored) is also a whole-text match.
44
+ const CLASS_ORDER = ['auth', 'usage_limit', 'rate_limit', 'quota', 'network'];
45
+
46
+ /** Fold two classification results, keeping the higher-precedence class. */
47
+ export function strongestClass(a, b) {
48
+ if (!a) return b ?? null;
49
+ if (!b) return a;
50
+ return CLASS_ORDER.indexOf(a) <= CLASS_ORDER.indexOf(b) ? a : b;
51
+ }
@@ -0,0 +1,188 @@
1
+ // src/core/results.mjs
2
+ // Layer 1 (mechanical) results view: pure deterministic assembler + git-backed
3
+ // builder + persistence/read accessors. No model call in this path — rebuilding
4
+ // assembleResults on the same patch + reviews yields byte-identical JSON.
5
+ import { writeFile, readFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { recordArtifact, resolvePipelineId, readPipelineExtras } from './artifacts.mjs';
8
+
9
+ const NEW_STATUS = new Set(['A', 'C']);
10
+
11
+ export const RESULTS_FILE = 'results.json';
12
+ export const DIFF_PATCH_FILE = 'diff-patch.patch';
13
+ export const OVERVIEW_FILE = 'overview.json';
14
+
15
+ /** Canonical retained-work patch filename; a null/empty key yields the bare name. */
16
+ export function retainedWorkPatchName(projectKey = null) {
17
+ const suffix = projectKey ? `-${String(projectKey).replace(/[^a-zA-Z0-9._-]+/g, '-')}` : '';
18
+ return `retained-work${suffix}.patch`;
19
+ }
20
+
21
+ /** Bucket name-status rows into new/changed and sum line counts from numstat. */
22
+ export function bucketFiles(nameStatus, numstat) {
23
+ const newFiles = [];
24
+ const changedFiles = [];
25
+ let linesAdded = 0, linesRemoved = 0, filesDeleted = 0;
26
+ for (const row of nameStatus) {
27
+ const n = numstat.get(row.path) || { added: 0, removed: 0, binary: false };
28
+ linesAdded += n.added; linesRemoved += n.removed;
29
+ if (row.status === 'D') filesDeleted += 1;
30
+ const base = { path: row.path, status: row.status };
31
+ if (!n.binary) { base.added = n.added; base.removed = n.removed; } else base.binary = true;
32
+ if (NEW_STATUS.has(row.status)) {
33
+ newFiles.push(base);
34
+ } else {
35
+ if (row.from) base.from = row.from;
36
+ base.issues = [];
37
+ changedFiles.push(base);
38
+ }
39
+ }
40
+ newFiles.sort((a, b) => a.path.localeCompare(b.path));
41
+ changedFiles.sort((a, b) => a.path.localeCompare(b.path));
42
+ return {
43
+ newFiles,
44
+ changedFiles,
45
+ counts: {
46
+ filesNew: newFiles.length,
47
+ filesChanged: changedFiles.length,
48
+ filesDeleted,
49
+ linesAdded,
50
+ linesRemoved,
51
+ },
52
+ };
53
+ }
54
+
55
+ /** Keep only the highest-cycle review row per kind. */
56
+ function latestPerKind(reviews) {
57
+ const byKind = new Map();
58
+ for (const r of reviews) {
59
+ const cur = byKind.get(r.kind);
60
+ if (!cur || r.cycle > cur.cycle) byKind.set(r.kind, r);
61
+ }
62
+ return [...byKind.values()];
63
+ }
64
+
65
+ const SEV_RANK = { critical: 0, major: 1 };
66
+
67
+ /** Critical+major issues, latest cycle per kind, deduped by severity|title, sorted. */
68
+ export function selectKeyChecks(reviews) {
69
+ const latest = latestPerKind(reviews);
70
+ const seen = new Map(); // key -> check
71
+ let seq = 0;
72
+ for (const row of latest) {
73
+ for (const iss of row.issues || []) {
74
+ if (iss.severity !== 'critical' && iss.severity !== 'major') continue;
75
+ const key = `${iss.severity}|${(iss.title || '').toLowerCase()}`;
76
+ const existing = seen.get(key);
77
+ if (existing) {
78
+ if (!existing.kind.split(',').includes(row.kind)) existing.kind += `,${row.kind}`;
79
+ continue;
80
+ }
81
+ seen.set(key, {
82
+ id: `check-${seq++}`,
83
+ severity: iss.severity,
84
+ title: iss.title,
85
+ detail: iss.detail,
86
+ location: iss.location,
87
+ kind: row.kind,
88
+ cycle: row.cycle,
89
+ });
90
+ }
91
+ }
92
+ return [...seen.values()].sort((a, b) =>
93
+ (SEV_RANK[a.severity] - SEV_RANK[b.severity]) ||
94
+ (Number(a.id.slice(6)) - Number(b.id.slice(6))));
95
+ }
96
+
97
+ /** Minor+suggestion issues from the latest cycle per kind. */
98
+ export function splitNitpicks(reviews) {
99
+ const out = [];
100
+ for (const row of latestPerKind(reviews)) {
101
+ for (const iss of row.issues || []) {
102
+ if (iss.severity === 'minor' || iss.severity === 'suggestion') {
103
+ out.push({ severity: iss.severity, title: iss.title, kind: row.kind });
104
+ }
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function basename(p) { const i = p.lastIndexOf('/'); return i < 0 ? p : p.slice(i + 1); }
111
+
112
+ /** Best-effort substring link of checks to changed/new files. Mutates both. */
113
+ export function linkIssues(checks, files) {
114
+ const all = [...files.changedFiles, ...files.newFiles];
115
+ for (const c of checks) {
116
+ if (!c.location) continue;
117
+ const hit = all.find((f) => c.location.includes(f.path) || c.location.includes(basename(f.path)));
118
+ if (hit) {
119
+ c.file = hit.path;
120
+ if (Array.isArray(hit.issues)) hit.issues.push(c.id);
121
+ }
122
+ }
123
+ }
124
+
125
+ /** Build the full single-project results object. Pure + deterministic. */
126
+ export function assembleResults({ nameStatus, numstat, reviews }) {
127
+ const files = bucketFiles(nameStatus, numstat);
128
+ const keyThingsToCheck = selectKeyChecks(reviews);
129
+ const nitpicks = splitNitpicks(reviews);
130
+ linkIssues(keyThingsToCheck, files);
131
+ return {
132
+ summary: {
133
+ ...files.counts,
134
+ blockingIssues: keyThingsToCheck.length,
135
+ nitpicks: nitpicks.length,
136
+ },
137
+ newFiles: files.newFiles,
138
+ changedFiles: files.changedFiles,
139
+ keyThingsToCheck,
140
+ nitpicks,
141
+ };
142
+ }
143
+
144
+ /** Map [{projectKey, results}] -> { <projectKey>: results }. */
145
+ export function buildPerProject(members) {
146
+ const out = {};
147
+ for (const m of members) out[m.projectKey] = m.results;
148
+ return out;
149
+ }
150
+
151
+ /** Sum member summaries into one workspace-level summary. */
152
+ export function rollupSummary(perProject) {
153
+ const keys = ['filesNew', 'filesChanged', 'filesDeleted', 'linesAdded', 'linesRemoved', 'blockingIssues', 'nitpicks'];
154
+ const s = Object.fromEntries(keys.map((k) => [k, 0]));
155
+ for (const r of Object.values(perProject)) for (const k of keys) s[k] += (r.summary?.[k] || 0);
156
+ return s;
157
+ }
158
+
159
+ /** Write results.json into the pipeline dir and index it (best-effort index). */
160
+ export async function persistResults(pipelineDir, results) {
161
+ if (!pipelineDir || !results) return;
162
+ await writeFile(join(pipelineDir, RESULTS_FILE), JSON.stringify(results, null, 2));
163
+ const id = resolvePipelineId(pipelineDir);
164
+ if (id) recordArtifact(id, 'results', RESULTS_FILE);
165
+ }
166
+
167
+ /** Write the unified diff patch into the pipeline dir and index it. */
168
+ export async function persistDiffPatch(pipelineDir, patch) {
169
+ if (!pipelineDir || patch == null) return;
170
+ await writeFile(join(pipelineDir, DIFF_PATCH_FILE), String(patch));
171
+ const id = resolvePipelineId(pipelineDir);
172
+ if (id) recordArtifact(id, 'diff-patch', DIFF_PATCH_FILE);
173
+ }
174
+
175
+ /**
176
+ * Spec §6 shared context bundle for a finished run — the substrate for the
177
+ * overview agent and a future Q&A agent. `dir` is the absolute pipeline dir.
178
+ */
179
+ export async function readRunContextBundle(dir, pipelineId) {
180
+ const read = async (f) => { try { return await readFile(join(dir, f), 'utf8'); } catch { return null; } };
181
+ const resultsTxt = await read(RESULTS_FILE);
182
+ return {
183
+ diffPatch: await read(DIFF_PATCH_FILE),
184
+ results: resultsTxt ? JSON.parse(resultsTxt) : null,
185
+ reviews: readPipelineExtras(pipelineId).reviews || [],
186
+ audit: null, // audit markdown is rebuilt by buildAuditMarkdown at the API layer
187
+ };
188
+ }