@magnusekdahl/parallix 1.2.0 → 1.2.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 (51) hide show
  1. package/lib/agents/agents.js +737 -838
  2. package/lib/agents/agents.ts +930 -0
  3. package/lib/agents/claude-telemetry.js +149 -154
  4. package/lib/agents/claude-telemetry.ts +233 -0
  5. package/lib/agents/claude.js +121 -131
  6. package/lib/agents/claude.ts +156 -0
  7. package/lib/agents/codex-telemetry.js +176 -164
  8. package/lib/agents/codex-telemetry.ts +205 -0
  9. package/lib/agents/codex.js +168 -201
  10. package/lib/agents/codex.ts +236 -0
  11. package/lib/agents/limit-hit.js +216 -226
  12. package/lib/agents/limit-hit.ts +262 -0
  13. package/lib/agents/mistral-telemetry.js +7 -10
  14. package/lib/agents/mistral-telemetry.ts +44 -0
  15. package/lib/agents/mistral.js +40 -58
  16. package/lib/agents/mistral.ts +84 -0
  17. package/lib/agents/opencode-export.js +126 -127
  18. package/lib/agents/opencode-export.ts +160 -0
  19. package/lib/agents/opencode-telemetry.js +282 -261
  20. package/lib/agents/opencode-telemetry.ts +350 -0
  21. package/lib/agents/opencode.js +263 -305
  22. package/lib/agents/opencode.ts +370 -0
  23. package/lib/agents/stage-telemetry.js +23 -24
  24. package/lib/agents/stage-telemetry.ts +47 -0
  25. package/lib/commands/integrate.js +9 -3
  26. package/lib/core/fmt.js +140 -211
  27. package/lib/core/fmt.ts +189 -0
  28. package/lib/core/git.js +121 -102
  29. package/lib/core/git.ts +146 -0
  30. package/lib/core/gitignore.js +59 -106
  31. package/lib/core/gitignore.ts +103 -0
  32. package/lib/core/mission-utils.js +833 -875
  33. package/lib/core/mission-utils.ts +998 -0
  34. package/lib/core/persistent-data-migration.js +247 -205
  35. package/lib/core/persistent-data-migration.ts +255 -0
  36. package/lib/core/product-config.js +400 -476
  37. package/lib/core/product-config.ts +518 -0
  38. package/lib/core/runtime-matrix.js +41 -87
  39. package/lib/core/runtime-matrix.ts +84 -0
  40. package/lib/core/spawn-tee.js +152 -192
  41. package/lib/core/spawn-tee.ts +202 -0
  42. package/lib/core/state-map.js +92 -92
  43. package/lib/core/state-map.ts +115 -0
  44. package/lib/core/storage.js +101 -129
  45. package/lib/core/storage.ts +182 -0
  46. package/lib/core/verification.js +148 -138
  47. package/lib/core/verification.ts +200 -0
  48. package/package.json +4 -2
  49. package/prompts/draft.md +1 -0
  50. package/prompts/portfolio.md +1 -1
  51. package/px.js +0 -2
@@ -0,0 +1,255 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import * as storage from './storage.js';
4
+
5
+ /** Cached stats module — loaded lazily to break circular dependency with `../commands/stats.js`. */
6
+ let _stats: any = null;
7
+
8
+ /** Lazily load the stats module to break the circular dependency. */
9
+ function getStats(): any {
10
+ if (!_stats) {_stats = require('../commands/stats.js');}
11
+ return _stats;
12
+ }
13
+
14
+ /** Resolve the canonical stats CSV headers. */
15
+ function getStatsHeaders(): string[] {
16
+ return getStats().STATS_HEADERS;
17
+ }
18
+
19
+ const ESSENTIAL_STATS_COLUMNS = ['date', 'mission', 'classification', 'implementer', 'pr_fix_rounds'];
20
+
21
+ /**
22
+ * Validate that a stats row has all essential columns populated.
23
+ * Throws on the first missing column found.
24
+ * @param row - Row object to validate
25
+ * @param rowIndex - Zero-based row index (for error messages)
26
+ * @param filePath - Source file path (for error messages)
27
+ */
28
+ function validateStatsRow(row: Record<string, unknown>, rowIndex: number, filePath: string): void {
29
+ const missing = ESSENTIAL_STATS_COLUMNS.filter(col => !(col in row) || row[col] === '');
30
+ if (missing.length > 0) {
31
+ throw new Error(
32
+ `Malformed telemetry row ${rowIndex + 1} in ${path.resolve(filePath)}: ` +
33
+ `missing essential columns: ${missing.join(', ')}`
34
+ );
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Parse a single CSV line, respecting quoted fields and escaped quotes.
40
+ * @param line - Raw CSV line string
41
+ * @returns Array of parsed field values
42
+ */
43
+ function parseCsvLine(line: string): string[] {
44
+ const values: string[] = [];
45
+ let value = '';
46
+ let quoted = false;
47
+ for (let index = 0; index < line.length; index += 1) {
48
+ const char = line[index];
49
+ if (char === '"') {
50
+ if (quoted && line[index + 1] === '"') {
51
+ value += '"';
52
+ index += 1;
53
+ } else {
54
+ quoted = !quoted;
55
+ }
56
+ } else if (char === ',' && !quoted) {
57
+ values.push(value);
58
+ value = '';
59
+ } else {
60
+ value += char;
61
+ }
62
+ }
63
+ values.push(value);
64
+ return values;
65
+ }
66
+
67
+ /**
68
+ * Escape a single CSV value — wraps in quotes if it contains commas, double-quotes, or newlines.
69
+ * @param value - Value to escape
70
+ * @returns Escaped CSV-safe string
71
+ */
72
+ function escapeCsvValue(value: unknown): string {
73
+ const text = String(value ?? '');
74
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
75
+ }
76
+
77
+ /**
78
+ * Minimal CSV loader — avoids circular dependency with stats module (which
79
+ * imports this module at the top). Defined locally so `migrateStats()` can
80
+ * read any schema without triggering a require-cycle.
81
+ */
82
+ function loadCsv(filePath: string): { headers: string[]; rows: Record<string, unknown>[] } {
83
+ if (!fs.existsSync(filePath)) {
84
+ return { headers: [], rows: [] };
85
+ }
86
+ const content = fs.readFileSync(filePath, 'utf8');
87
+ const lines = content.split('\n').filter(line => line.trim());
88
+ if (lines.length === 0) {
89
+ return { headers: [], rows: [] };
90
+ }
91
+ const headers = parseCsvLine(lines[0]);
92
+ const rows: Record<string, unknown>[] = [];
93
+ for (let i = 1; i < lines.length; i += 1) {
94
+ const values = parseCsvLine(lines[i]);
95
+ const row: Record<string, unknown> = {};
96
+ headers.forEach((header, idx) => {
97
+ row[header] = values[idx] || '';
98
+ });
99
+ rows.push(row);
100
+ }
101
+ return { headers, rows };
102
+ }
103
+
104
+ /**
105
+ * Read a stats CSV file with any schema (legacy 5-col or extended 21-col).
106
+ * Returns rows as arrays keyed by the 21-column STATS_HEADERS, with missing
107
+ * columns filled by normalizeStatsRow defaults.
108
+ */
109
+ function readStatsRows(filePath: string, options: { defaultRepo?: string } = {}): string[][] {
110
+ if (!filePath || !fs.existsSync(filePath)) {return [];}
111
+ const data = loadCsv(filePath);
112
+ if (data.headers.length === 0) {return [];}
113
+ const headers = getStatsHeaders();
114
+ const opts = options;
115
+ const validated = data.rows.map((row, idx) => {
116
+ validateStatsRow(row, idx, filePath);
117
+ return row;
118
+ });
119
+ return validated
120
+ .map(row => getStats().normalizeStatsRow(row, { repo: opts.defaultRepo }))
121
+ .map((row: Record<string, unknown>) => headers.map((h: string) => String(row[h] || '')));
122
+ }
123
+
124
+ /**
125
+ * Serialize an array of stats rows back to CSV format with canonical headers.
126
+ * @param rows - Array of row arrays (each row is an array of string values)
127
+ * @returns CSV string with headers and data rows
128
+ */
129
+ function serializeStatsRows(rows: string[][]): string {
130
+ const headers = getStatsHeaders();
131
+ return `${[
132
+ headers.join(','),
133
+ ...rows.map(row => row.map(escapeCsvValue).join(','))
134
+ ].join('\n')}\n`;
135
+ }
136
+
137
+ /**
138
+ * Merge stats from one or more source CSV files into the destination file.
139
+ * Deduplicates full rows. Creates the destination directory if needed.
140
+ * When no source data is available, returns early without writing a header-only file.
141
+ * @param options - Migration configuration (sourcePaths, sourcePath, destinationPath)
142
+ * @returns Result with destination path, import counts, and optional warning
143
+ */
144
+ function migrateStats(options: { sourcePaths?: string[]; sourcePath?: string; destinationPath?: string } = {}): { destinationPath: string; imported: number; rows: number; warn?: string } {
145
+ const opts = options;
146
+ const sourcePaths = (opts.sourcePaths || [opts.sourcePath].filter(Boolean)) as string[];
147
+ const destinationPath = opts.destinationPath || storage.resolveStatsPath({ ensureDir: true });
148
+ const sourceRows = sourcePaths.flatMap((sourcePath: string) => readStatsRows(sourcePath, {}));
149
+
150
+ // Fresh-install guard: when no source file exists or all sources are empty,
151
+ // there is no telemetry to import. Returning early prevents writing a
152
+ // header-only destination file that would masquerade as valid stats.
153
+ if (sourceRows.length === 0) {
154
+ return { destinationPath, imported: 0, rows: 0, warn: 'no source data available' };
155
+ }
156
+
157
+ const destinationRows = readStatsRows(destinationPath, {});
158
+ const rows: string[][] = [];
159
+ const seen = new Set<string>();
160
+
161
+ for (const row of [...destinationRows, ...sourceRows]) {
162
+ const key = JSON.stringify(row);
163
+ if (!seen.has(key)) {
164
+ seen.add(key);
165
+ rows.push(row);
166
+ }
167
+ }
168
+
169
+ const content = serializeStatsRows(rows);
170
+ const current = fs.existsSync(destinationPath) ? fs.readFileSync(destinationPath, 'utf8') : null;
171
+ if (current !== content) {storage.writeFileAtomic(destinationPath, content);}
172
+ return { destinationPath, imported: rows.length - destinationRows.length, rows: rows.length };
173
+ }
174
+
175
+ interface BlocklistSource { filePath: string; payload: Record<string, unknown>; blocklist: Record<string, unknown>; }
176
+
177
+ function readBlocklistSource(filePath: string, warn: (...args: unknown[]) => void, hardFailure = false): BlocklistSource | null {
178
+ if (!filePath || !fs.existsSync(filePath)) {return null;}
179
+ try {
180
+ const payload = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>;
181
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
182
+ throw new Error('expected a JSON object at the file root');
183
+ }
184
+ if (
185
+ payload.blocklist !== undefined &&
186
+ (!payload.blocklist || typeof payload.blocklist !== 'object' || Array.isArray(payload.blocklist))
187
+ ) {
188
+ throw new Error('expected blocklist to be a JSON object');
189
+ }
190
+ return { filePath, payload, blocklist: (payload.blocklist as Record<string, unknown>) || {} };
191
+ } catch (error) {
192
+ if (hardFailure) {throw error;}
193
+ warn(`Skipping malformed legacy agent blocklist ${path.resolve(filePath)}: ${(error as Error).message}`);
194
+ return null;
195
+ }
196
+ }
197
+
198
+ function sameValue(left: unknown, right: unknown): boolean {
199
+ return JSON.stringify(left) === JSON.stringify(right);
200
+ }
201
+
202
+ /**
203
+ * Merge agent blocklists from legacy sources into the destination file.
204
+ * Reports conflicts via `warn` when the same agent appears in multiple sources
205
+ * with different values. Existing destination entries take lowest precedence.
206
+ * @param options - Migration configuration (warn callback, destinationPath, sourcePaths)
207
+ * @returns Result with destination path, merged blocklist, and conflict details
208
+ */
209
+ function migrateAgentBlocklists(options: { warn?: (...args: unknown[]) => void; destinationPath?: string; sourcePaths?: string[] } = {}): { destinationPath: string; blocklist: Record<string, unknown>; conflicts: unknown[] } {
210
+ const opts = options;
211
+ const warn = opts.warn || (() => {});
212
+ const destinationPath = opts.destinationPath || storage.resolveAgentsLocalPath({ ensureDir: true });
213
+ const sources = (opts.sourcePaths || [])
214
+ .map(filePath => readBlocklistSource(filePath, warn))
215
+ .filter(Boolean) as BlocklistSource[];
216
+ const destination = readBlocklistSource(destinationPath, warn, true);
217
+ const selected: Record<string, unknown> = {};
218
+ const selectedFrom: Record<string, string> = {};
219
+ const conflicts: unknown[] = [];
220
+
221
+ for (const s of [...sources, ...(destination ? [destination] : [])]) {
222
+ if (!s) {continue;}
223
+ for (const [agent, value] of Object.entries(s.blocklist)) {
224
+ if (Object.prototype.hasOwnProperty.call(selected, agent) && !sameValue(selected[agent], value)) {
225
+ const conflict = {
226
+ agent,
227
+ previousSource: selectedFrom[agent],
228
+ previousValue: selected[agent],
229
+ selectedSource: s.filePath,
230
+ selectedValue: value
231
+ };
232
+ conflicts.push(conflict);
233
+ warn(
234
+ `Agent blocklist conflict for "${agent}": ${path.resolve(s.filePath)} takes precedence over ` +
235
+ `${path.resolve(selectedFrom[agent])}; selected=${JSON.stringify(value)} previous=${JSON.stringify(selected[agent])}`
236
+ );
237
+ }
238
+ selected[agent] = value;
239
+ selectedFrom[agent] = s.filePath;
240
+ }
241
+ }
242
+
243
+ const payload: Record<string, unknown> = destination ? { ...destination.payload } : {};
244
+ payload.blocklist = selected;
245
+ const content = `${JSON.stringify(payload, null, 2)}\n`;
246
+ const current = fs.existsSync(destinationPath) ? fs.readFileSync(destinationPath, 'utf8') : null;
247
+ if (current !== content) {storage.writeFileAtomic(destinationPath, content);}
248
+ return { destinationPath, blocklist: selected, conflicts };
249
+ }
250
+
251
+ export {
252
+ migrateStats,
253
+ migrateAgentBlocklists
254
+ };
255
+ export const _internals = { parseCsvLine, readStatsRows, serializeStatsRows };