parallel-codex-tui 0.3.1 → 0.3.2

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.
@@ -0,0 +1,510 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir, readlink } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { RouteDecisionSchema, TurnMetaSchema } from "../domain/schemas.js";
5
+ import { loadCollaborationTimeline } from "./collaboration-timeline.js";
6
+ import { pathExists, readJson, readTextIfExists } from "./file-store.js";
7
+ import { loadTaskSessionDetails } from "./task-session-details.js";
8
+ export async function buildTaskReport(input) {
9
+ const taskSummary = {
10
+ ...input.task,
11
+ turnCount: 0,
12
+ workerCount: 0,
13
+ nativeSessionCount: 0
14
+ };
15
+ const [turns, timeline, details, integrations, reconciliation] = await Promise.all([
16
+ readTaskReportTurns(input.taskDir),
17
+ loadCollaborationTimeline(input.task.id, input.taskDir),
18
+ loadTaskSessionDetails({ task: taskSummary, taskDir: input.taskDir }),
19
+ readTaskReportIntegrations(input.taskDir),
20
+ reconcileTaskWorkspace(input.taskDir, input.workspaceRoot)
21
+ ]);
22
+ const report = {
23
+ format: "parallel-codex-task-report-v1",
24
+ generated_at: input.generatedAt,
25
+ task: input.task,
26
+ workspace: {
27
+ path: input.workspaceRoot,
28
+ reconciliation
29
+ },
30
+ turns,
31
+ features: timeline.features.map((feature) => ({
32
+ id: feature.id,
33
+ turn_id: feature.turnId,
34
+ title: feature.title,
35
+ description: feature.description,
36
+ depends_on: feature.dependsOn,
37
+ state: feature.state,
38
+ updated_at: feature.updatedAt,
39
+ ...(feature.actorEngine ? { actor_engine: feature.actorEngine } : {}),
40
+ ...(feature.actorModel ? { actor_model: feature.actorModel } : {}),
41
+ ...(feature.criticEngine ? { critic_engine: feature.criticEngine } : {}),
42
+ ...(feature.criticModel ? { critic_model: feature.criticModel } : {}),
43
+ findings: feature.findings,
44
+ replies: feature.replies,
45
+ ...(typeof feature.resolvedFindings === "number"
46
+ ? { resolved_findings: feature.resolvedFindings }
47
+ : {}),
48
+ ...(typeof feature.unresolvedFindings === "number"
49
+ ? { unresolved_findings: feature.unresolvedFindings }
50
+ : {}),
51
+ ...(feature.latestFinding ? { latest_finding: feature.latestFinding } : {}),
52
+ ...(feature.latestReply ? { latest_reply: feature.latestReply } : {})
53
+ })),
54
+ workers: details.workers.map((worker) => ({
55
+ id: worker.id,
56
+ turn_id: worker.turnId,
57
+ ...(worker.featureId ? { feature_id: worker.featureId } : {}),
58
+ ...(worker.featureTitle ? { feature_title: worker.featureTitle } : {}),
59
+ role: worker.role,
60
+ engine: worker.engine,
61
+ model: worker.model,
62
+ ...(worker.modelProvider ? { model_provider: worker.modelProvider } : {}),
63
+ state: worker.state,
64
+ phase: worker.phase,
65
+ summary: worker.summary,
66
+ last_activity_at: worker.lastActivityAt,
67
+ ...(worker.nativeSession ? {
68
+ native_session: {
69
+ id: worker.nativeSession.sessionId,
70
+ cwd: worker.nativeSession.cwd,
71
+ writable_dirs: worker.nativeSession.writableDirs,
72
+ created_at: worker.nativeSession.createdAt,
73
+ last_used_at: worker.nativeSession.lastUsedAt,
74
+ source: worker.nativeSession.source
75
+ }
76
+ } : {})
77
+ })),
78
+ integrations
79
+ };
80
+ return { report, markdown: renderTaskReportMarkdown(report) };
81
+ }
82
+ export async function reconcileTaskWorkspace(taskDir, workspaceRoot) {
83
+ const { waves, issues, sawIntegrationRecord } = await readIntegratedWaves(taskDir);
84
+ const authoritative = new Map();
85
+ for (const wave of waves) {
86
+ for (const changedPath of wave.changedPaths) {
87
+ authoritative.set(changedPath, wave);
88
+ }
89
+ }
90
+ const paths = await Promise.all([...authoritative.entries()]
91
+ .sort(([left], [right]) => left.localeCompare(right))
92
+ .map(async ([changedPath, wave]) => {
93
+ const expected = await inspectReportEntry(wave.integrationDir, changedPath);
94
+ const current = await inspectReportEntry(workspaceRoot, changedPath);
95
+ return {
96
+ path: changedPath,
97
+ state: compareTaskReportEntries(expected, current),
98
+ source: {
99
+ turn_id: wave.turnId,
100
+ wave: wave.wave,
101
+ feature_ids: wave.featureIds
102
+ },
103
+ expected,
104
+ current
105
+ };
106
+ }));
107
+ const counts = emptyComparisonCounts();
108
+ for (const path of paths) {
109
+ counts[path.state] += 1;
110
+ }
111
+ const state = paths.length === 0
112
+ ? sawIntegrationRecord || issues.length > 0 ? "unavailable" : "no-integrations"
113
+ : counts.unavailable > 0
114
+ ? "unavailable"
115
+ : counts.drift + counts.missing + counts.unexpected > 0
116
+ ? "drifted"
117
+ : "clean";
118
+ return {
119
+ state,
120
+ integrated_waves: waves.length,
121
+ changed_paths: paths.length,
122
+ counts,
123
+ paths,
124
+ issues
125
+ };
126
+ }
127
+ async function readTaskReportTurns(taskDir) {
128
+ const turnsRoot = join(taskDir, "turns");
129
+ let entries;
130
+ try {
131
+ entries = await readdir(turnsRoot, { withFileTypes: true });
132
+ }
133
+ catch {
134
+ return [];
135
+ }
136
+ const turns = await Promise.all(entries
137
+ .filter((entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name))
138
+ .map(async (entry) => {
139
+ const dir = join(turnsRoot, entry.name);
140
+ try {
141
+ const meta = await readJson(join(dir, "turn.json"), TurnMetaSchema);
142
+ if (meta.turn_id !== entry.name) {
143
+ return null;
144
+ }
145
+ const [request, route, requirements, plan, acceptanceCriteria, summary, featurePlan, judgeValidation, contract, acceptance, validation] = await Promise.all([
146
+ readTextIfExists(join(dir, "user.md")),
147
+ readRouteDecision(join(dir, "route.json")),
148
+ readTextIfExists(join(dir, "requirements.md")),
149
+ readTextIfExists(join(dir, "plan.md")),
150
+ readTextIfExists(join(dir, "acceptance.md")),
151
+ readTextIfExists(join(dir, "supervisor-summary.md")),
152
+ readOptionalJson(join(dir, "feature-plan.json")),
153
+ readOptionalJson(join(dir, "judge-validation.json")),
154
+ readOptionalJson(join(dir, "completion-contract.json")),
155
+ readOptionalJson(join(dir, "final-acceptance.json")),
156
+ readOptionalJson(join(dir, "final-acceptance-validation.json"))
157
+ ]);
158
+ return {
159
+ turn_id: meta.turn_id,
160
+ created_at: meta.created_at,
161
+ request: request.trimEnd(),
162
+ ...(route ? { route } : {}),
163
+ ...(requirements.trim() ? { requirements: requirements.trimEnd() } : {}),
164
+ ...(plan.trim() ? { plan: plan.trimEnd() } : {}),
165
+ ...(acceptanceCriteria.trim() ? { acceptance_criteria: acceptanceCriteria.trimEnd() } : {}),
166
+ ...(summary.trim() ? { supervisor_summary: summary.trimEnd() } : {}),
167
+ ...(featurePlan !== undefined ? { feature_plan: featurePlan } : {}),
168
+ ...(judgeValidation !== undefined ? { judge_validation: judgeValidation } : {}),
169
+ ...(contract !== undefined ? { completion_contract: contract } : {}),
170
+ ...(acceptance !== undefined ? { final_acceptance: acceptance } : {}),
171
+ ...(validation !== undefined ? { final_acceptance_validation: validation } : {})
172
+ };
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }));
178
+ return turns
179
+ .filter((turn) => turn !== null)
180
+ .sort((left, right) => left.turn_id.localeCompare(right.turn_id));
181
+ }
182
+ async function readTaskReportIntegrations(taskDir) {
183
+ const { waves } = await readIntegratedWaves(taskDir);
184
+ return Promise.all(waves.map(async (wave) => {
185
+ const [verification, verificationReview] = await Promise.all([
186
+ readOptionalJson(join(wave.rootDir, "verification.json")),
187
+ readTextIfExists(join(wave.rootDir, "verification-review.md"))
188
+ ]);
189
+ return {
190
+ turn_id: wave.turnId,
191
+ wave: wave.wave,
192
+ feature_ids: wave.featureIds,
193
+ changed_paths: wave.changedPaths,
194
+ ...(verification !== undefined ? { verification } : {}),
195
+ ...(verificationReview.trim() ? { verification_review: verificationReview.trimEnd() } : {})
196
+ };
197
+ }));
198
+ }
199
+ async function readRouteDecision(path) {
200
+ if (!(await pathExists(path))) {
201
+ return undefined;
202
+ }
203
+ try {
204
+ return await readJson(path, RouteDecisionSchema);
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ }
210
+ async function readOptionalJson(path) {
211
+ if (!(await pathExists(path))) {
212
+ return undefined;
213
+ }
214
+ try {
215
+ return JSON.parse(await readTextIfExists(path));
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ }
221
+ async function readIntegratedWaves(taskDir) {
222
+ const workspacesRoot = join(taskDir, "workspaces");
223
+ const waves = [];
224
+ const issues = [];
225
+ let sawIntegrationRecord = false;
226
+ let turnEntries;
227
+ try {
228
+ turnEntries = await readdir(workspacesRoot, { withFileTypes: true });
229
+ }
230
+ catch {
231
+ return { waves, issues, sawIntegrationRecord };
232
+ }
233
+ for (const turnEntry of turnEntries
234
+ .filter((entry) => entry.isDirectory() && /^turn-\d{4,}$/.test(entry.name))
235
+ .sort((left, right) => left.name.localeCompare(right.name))) {
236
+ const turnId = turnEntry.name.slice("turn-".length);
237
+ const turnRoot = join(workspacesRoot, turnEntry.name);
238
+ let waveEntries;
239
+ try {
240
+ waveEntries = await readdir(turnRoot, { withFileTypes: true });
241
+ }
242
+ catch (error) {
243
+ issues.push(`${turnEntry.name}: ${errorMessage(error)}`);
244
+ continue;
245
+ }
246
+ for (const waveEntry of waveEntries
247
+ .filter((entry) => entry.isDirectory() && /^wave-\d{4,}$/.test(entry.name))
248
+ .sort((left, right) => left.name.localeCompare(right.name))) {
249
+ const path = join(turnRoot, waveEntry.name, "integration.json");
250
+ if (!(await pathExists(path))) {
251
+ continue;
252
+ }
253
+ sawIntegrationRecord = true;
254
+ const value = await readOptionalJson(path);
255
+ if (!isRecord(value)) {
256
+ issues.push(`${turnEntry.name}/${waveEntry.name}: malformed integration.json`);
257
+ continue;
258
+ }
259
+ if (value.state !== "integrated") {
260
+ continue;
261
+ }
262
+ const wave = Number(value.wave);
263
+ const featureIds = stringArray(value.feature_ids);
264
+ const changedPaths = stringArray(value.changed_paths);
265
+ if (!Number.isInteger(wave) || wave < 1 || !featureIds || !changedPaths) {
266
+ issues.push(`${turnEntry.name}/${waveEntry.name}: invalid integrated checkpoint`);
267
+ continue;
268
+ }
269
+ waves.push({
270
+ turnId: typeof value.turn_id === "string" ? value.turn_id : turnId,
271
+ wave,
272
+ featureIds,
273
+ changedPaths,
274
+ rootDir: join(turnRoot, waveEntry.name),
275
+ integrationDir: join(turnRoot, waveEntry.name, "integration")
276
+ });
277
+ }
278
+ }
279
+ waves.sort((left, right) => (left.turnId.localeCompare(right.turnId)
280
+ || left.wave - right.wave));
281
+ return { waves, issues, sawIntegrationRecord };
282
+ }
283
+ async function inspectReportEntry(root, changedPath) {
284
+ const safe = safeWorkspacePath(root, changedPath);
285
+ if (!safe) {
286
+ return { type: "unavailable", detail: "unsafe relative path" };
287
+ }
288
+ const segments = relative(resolve(root), safe).split(sep);
289
+ let parent = resolve(root);
290
+ for (const segment of segments.slice(0, -1)) {
291
+ parent = join(parent, segment);
292
+ try {
293
+ const stat = await lstat(parent);
294
+ if (stat.isSymbolicLink()) {
295
+ return { type: "unavailable", detail: "parent path is a symbolic link" };
296
+ }
297
+ if (!stat.isDirectory()) {
298
+ return { type: "missing" };
299
+ }
300
+ }
301
+ catch (error) {
302
+ if (isMissingError(error)) {
303
+ return { type: "missing" };
304
+ }
305
+ return { type: "unavailable", detail: errorMessage(error) };
306
+ }
307
+ }
308
+ try {
309
+ const stat = await lstat(safe);
310
+ if (stat.isSymbolicLink()) {
311
+ return { type: "symlink", target: await readlink(safe) };
312
+ }
313
+ if (stat.isFile()) {
314
+ const content = await readFile(safe);
315
+ return {
316
+ type: "file",
317
+ sha256: createHash("sha256").update(content).digest("hex"),
318
+ mode: stat.mode & 0o777,
319
+ size: stat.size
320
+ };
321
+ }
322
+ return { type: "other", detail: stat.isDirectory() ? "directory" : "special entry" };
323
+ }
324
+ catch (error) {
325
+ return isMissingError(error)
326
+ ? { type: "missing" }
327
+ : { type: "unavailable", detail: errorMessage(error) };
328
+ }
329
+ }
330
+ function safeWorkspacePath(root, changedPath) {
331
+ if (!changedPath || changedPath.includes("\0") || isAbsolute(changedPath)) {
332
+ return null;
333
+ }
334
+ const base = resolve(root);
335
+ const candidate = resolve(base, changedPath);
336
+ const relativePath = relative(base, candidate);
337
+ return relativePath
338
+ && relativePath !== ".."
339
+ && !relativePath.startsWith(`..${sep}`)
340
+ && !isAbsolute(relativePath)
341
+ ? candidate
342
+ : null;
343
+ }
344
+ function compareTaskReportEntries(expected, current) {
345
+ if (expected.type === "unavailable" || current.type === "unavailable") {
346
+ return "unavailable";
347
+ }
348
+ if (expected.type === "missing") {
349
+ return current.type === "missing" ? "match" : "unexpected";
350
+ }
351
+ if (current.type === "missing") {
352
+ return "missing";
353
+ }
354
+ if (expected.type !== current.type) {
355
+ return "drift";
356
+ }
357
+ if (expected.type === "file" && current.type === "file") {
358
+ return expected.sha256 === current.sha256 && expected.mode === current.mode ? "match" : "drift";
359
+ }
360
+ if (expected.type === "symlink" && current.type === "symlink") {
361
+ return expected.target === current.target ? "match" : "drift";
362
+ }
363
+ return expected.detail === current.detail ? "match" : "drift";
364
+ }
365
+ function emptyComparisonCounts() {
366
+ return { match: 0, drift: 0, missing: 0, unexpected: 0, unavailable: 0 };
367
+ }
368
+ function renderTaskReportMarkdown(report) {
369
+ const lines = [
370
+ `# Task Report: ${escapeMarkdownText(report.task.title)}`,
371
+ "",
372
+ `- Task: \`${escapeCode(report.task.id)}\``,
373
+ `- Status: \`${report.task.status}\``,
374
+ `- Mode: \`${report.task.mode}\``,
375
+ `- Created: ${report.task.created_at}`,
376
+ `- Generated: ${report.generated_at}`,
377
+ `- Workspace: \`${escapeCode(report.workspace.path)}\``,
378
+ "",
379
+ "## Workspace Reconciliation",
380
+ "",
381
+ `State: **${report.workspace.reconciliation.state}**. `
382
+ + `${report.workspace.reconciliation.integrated_waves} integrated wave(s), `
383
+ + `${report.workspace.reconciliation.changed_paths} changed path(s).`,
384
+ ""
385
+ ];
386
+ if (report.workspace.reconciliation.paths.length > 0) {
387
+ lines.push("| State | Path | Source | Expected | Current |", "| --- | --- | --- | --- | --- |", ...report.workspace.reconciliation.paths.map((entry) => (`| ${entry.state} | \`${escapeCode(entry.path)}\` | turn ${entry.source.turn_id}, wave ${entry.source.wave} | `
388
+ + `${describeReportEntry(entry.expected)} | ${describeReportEntry(entry.current)} |`)), "");
389
+ }
390
+ if (report.workspace.reconciliation.issues.length > 0) {
391
+ lines.push("### Reconciliation Issues", "", ...report.workspace.reconciliation.issues.map((issue) => `- ${escapeMarkdownText(issue)}`), "");
392
+ }
393
+ lines.push("## Turns", "");
394
+ if (report.turns.length === 0) {
395
+ lines.push("No valid turn records were found.", "");
396
+ }
397
+ for (const turn of report.turns) {
398
+ lines.push(`### Turn ${turn.turn_id}`, "", `- Created: ${turn.created_at}`, ...(turn.route ? [
399
+ `- Route: \`${turn.route.mode}\` via \`${turn.route.source ?? "configured"}\``,
400
+ `- Reason: ${escapeMarkdownText(turn.route.reason)}`
401
+ ] : []), "", "**Request**", "", ...(turn.request ? quoteMarkdown(turn.request) : ["> (empty)"]), "");
402
+ if (turn.supervisor_summary) {
403
+ lines.push("**Supervisor summary**", "", turn.supervisor_summary, "");
404
+ }
405
+ for (const [label, value] of [
406
+ ["Requirements", turn.requirements],
407
+ ["Plan", turn.plan],
408
+ ["Acceptance criteria", turn.acceptance_criteria]
409
+ ]) {
410
+ if (value) {
411
+ lines.push(`**${label}**`, "", value, "");
412
+ }
413
+ }
414
+ for (const [label, value] of [
415
+ ["Feature plan", turn.feature_plan],
416
+ ["Judge validation", turn.judge_validation],
417
+ ["Completion contract", turn.completion_contract],
418
+ ["Final acceptance", turn.final_acceptance],
419
+ ["Final acceptance validation", turn.final_acceptance_validation]
420
+ ]) {
421
+ if (value !== undefined) {
422
+ lines.push(`**${label}**`, "", fencedJson(value), "");
423
+ }
424
+ }
425
+ }
426
+ lines.push("## Features", "");
427
+ if (report.features.length === 0) {
428
+ lines.push("No Feature records were found.", "");
429
+ }
430
+ else {
431
+ lines.push("| State | Turn | Feature | Actor | Critic | Findings | Replies | Latest review evidence |", "| --- | --- | --- | --- | --- | ---: | ---: | --- |", ...report.features.map((feature) => (`| ${feature.state} | ${feature.turn_id} | ${escapeTableText(feature.title)} (\`${escapeCode(feature.id)}\`) | `
432
+ + `${escapeTableText(workerAssignment(feature.actor_engine, feature.actor_model))} | `
433
+ + `${escapeTableText(workerAssignment(feature.critic_engine, feature.critic_model))} | `
434
+ + `${feature.findings} | ${feature.replies} | `
435
+ + `${escapeTableText(feature.latest_finding ?? feature.latest_reply ?? "-")} |`)), "");
436
+ }
437
+ lines.push("## Integration And Verification", "");
438
+ if (report.integrations.length === 0) {
439
+ lines.push("No integrated Wave records were found.", "");
440
+ }
441
+ for (const integration of report.integrations) {
442
+ lines.push(`### Turn ${integration.turn_id}, Wave ${integration.wave}`, "", `- Features: ${integration.feature_ids.map((id) => `\`${escapeCode(id)}\``).join(", ") || "none"}`, `- Changed paths: ${integration.changed_paths.length}`, ...integration.changed_paths.map((path) => ` - \`${escapeCode(path)}\``), "");
443
+ if (integration.verification_review) {
444
+ lines.push("**Verification review**", "", integration.verification_review, "");
445
+ }
446
+ if (integration.verification !== undefined) {
447
+ lines.push("**Verification evidence**", "", fencedJson(integration.verification), "");
448
+ }
449
+ }
450
+ lines.push("## Workers", "");
451
+ if (report.workers.length === 0) {
452
+ lines.push("No Worker records were found.", "");
453
+ }
454
+ else {
455
+ lines.push("| State | Turn | Role | Engine / model | Feature | Native session | Worker | Summary |", "| --- | --- | --- | --- | --- | --- | --- | --- |", ...report.workers.map((worker) => (`| ${worker.state} | ${worker.turn_id} | ${worker.role} | `
456
+ + `${escapeTableText(workerAssignment(worker.engine, worker.model, worker.model_provider))} | `
457
+ + `${escapeTableText(worker.feature_title ?? worker.feature_id ?? "-")} | `
458
+ + `${escapeTableText(worker.native_session?.id ?? "-")} | \`${escapeCode(worker.id)}\` | `
459
+ + `${escapeTableText(worker.summary || "-")} |`)), "");
460
+ }
461
+ return `${lines.join("\n").trimEnd()}\n`;
462
+ }
463
+ function quoteMarkdown(value) {
464
+ return value.split(/\r?\n/).map((line) => `> ${line}`);
465
+ }
466
+ function fencedJson(value) {
467
+ const json = JSON.stringify(value, null, 2);
468
+ const longestRun = Math.max(0, ...Array.from(json.matchAll(/`+/g), (match) => match[0].length));
469
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
470
+ return `${fence}json\n${json}\n${fence}`;
471
+ }
472
+ function describeReportEntry(entry) {
473
+ if (entry.type === "file") {
474
+ return `file ${entry.sha256?.slice(0, 12) ?? "?"} mode ${entry.mode?.toString(8) ?? "?"}`;
475
+ }
476
+ if (entry.type === "symlink") {
477
+ return `symlink \`${escapeCode(entry.target ?? "")}\``;
478
+ }
479
+ return entry.detail ? `${entry.type}: ${escapeTableText(entry.detail)}` : entry.type;
480
+ }
481
+ function workerAssignment(engine, model, provider) {
482
+ if (!engine) {
483
+ return "-";
484
+ }
485
+ const configured = [provider, model].filter(Boolean).join("/");
486
+ return configured ? `${engine} (${configured})` : engine;
487
+ }
488
+ function escapeMarkdownText(value) {
489
+ return value.replace(/[\\`*_{}[\]()#+.!|>-]/g, "\\$&").replace(/\r?\n/g, " ");
490
+ }
491
+ function escapeTableText(value) {
492
+ return escapeMarkdownText(value).replace(/\r?\n/g, " ");
493
+ }
494
+ function escapeCode(value) {
495
+ return value.replaceAll("`", "\\`").replace(/\r?\n/g, " ");
496
+ }
497
+ function isRecord(value) {
498
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
499
+ }
500
+ function stringArray(value) {
501
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
502
+ ? [...value]
503
+ : null;
504
+ }
505
+ function isMissingError(error) {
506
+ return ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
507
+ }
508
+ function errorMessage(error) {
509
+ return error instanceof Error ? error.message : String(error);
510
+ }
@@ -0,0 +1,142 @@
1
+ export const TASK_SEARCH_FIELDS = [
2
+ "task",
3
+ "turn",
4
+ "feature",
5
+ "role",
6
+ "provider",
7
+ "model",
8
+ "state"
9
+ ];
10
+ const TASK_SEARCH_FIELD_SET = new Set(TASK_SEARCH_FIELDS);
11
+ export function parseTaskSearchQuery(query) {
12
+ return tokenizeTaskSearchQuery(query)
13
+ .map((token) => {
14
+ const separator = token.indexOf(":");
15
+ if (separator > 0) {
16
+ const field = normalizeTaskSearchValue(token.slice(0, separator));
17
+ const value = normalizeTaskSearchValue(token.slice(separator + 1));
18
+ if (TASK_SEARCH_FIELD_SET.has(field) && value) {
19
+ return { field: field, value };
20
+ }
21
+ }
22
+ const value = normalizeTaskSearchValue(token);
23
+ return value ? { field: "any", value } : null;
24
+ })
25
+ .filter((term) => term !== null);
26
+ }
27
+ export function matchTaskSearchDocument(query, document) {
28
+ const terms = typeof query === "string" ? parseTaskSearchQuery(query) : [...query];
29
+ if (terms.length === 0) {
30
+ return { fields: [], summary: "" };
31
+ }
32
+ const candidates = taskSearchCandidates(document);
33
+ const matches = [];
34
+ for (const term of terms) {
35
+ const match = candidates.find((candidate) => ((term.field === "any" || candidate.field === term.field)
36
+ && normalizeTaskSearchValue(candidate.value).includes(term.value)));
37
+ if (!match) {
38
+ return null;
39
+ }
40
+ matches.push(match);
41
+ }
42
+ const fields = [...new Set(matches
43
+ .map((match) => match.field)
44
+ .filter((field) => field !== "any"))];
45
+ const summaries = [...new Set(matches.map((match) => match.summary).filter(Boolean))].slice(0, 2);
46
+ return {
47
+ fields,
48
+ summary: summaries.length > 0 ? `match · ${summaries.join(" · ")}` : "match"
49
+ };
50
+ }
51
+ function taskSearchCandidates(document) {
52
+ const candidates = [
53
+ candidate("task", document.task.id, `task ${compactSearchEvidence(document.task.id)}`),
54
+ candidate("task", document.task.title, `task ${compactSearchEvidence(document.task.title)}`),
55
+ candidate("task", document.task.cwd, `task ${compactSearchEvidence(document.task.cwd)}`),
56
+ candidate("task", document.task.mode, `task ${compactSearchEvidence(document.task.mode)}`),
57
+ candidate("state", document.task.state, `state ${compactSearchEvidence(document.task.state)}`)
58
+ ];
59
+ for (const turn of document.turns) {
60
+ const label = numericTurnLabel(turn.turnId);
61
+ candidates.push(candidate("turn", turn.turnId, label));
62
+ candidates.push(candidate("turn", String(Number(turn.turnId)), label));
63
+ if (turn.request.trim()) {
64
+ candidates.push(candidate("turn", turn.request, `${label} ${compactSearchEvidence(turn.request, 72)}`));
65
+ }
66
+ }
67
+ for (const worker of document.workers) {
68
+ if (worker.featureId) {
69
+ candidates.push(candidate("feature", worker.featureId, `feature ${compactSearchEvidence(worker.featureTitle || worker.featureId)}`));
70
+ }
71
+ if (worker.featureTitle) {
72
+ candidates.push(candidate("feature", worker.featureTitle, `feature ${compactSearchEvidence(worker.featureTitle)}`));
73
+ }
74
+ candidates.push(candidate("role", worker.role, `role ${compactSearchEvidence(worker.role)}`));
75
+ candidates.push(candidate("provider", worker.provider, `provider ${compactSearchEvidence(worker.provider)}`));
76
+ if (worker.modelProvider) {
77
+ candidates.push(candidate("provider", worker.modelProvider, `provider ${compactSearchEvidence(worker.modelProvider)}`));
78
+ }
79
+ if (worker.model) {
80
+ candidates.push(candidate("model", worker.model, `model ${compactSearchEvidence(worker.model)}`));
81
+ }
82
+ candidates.push(candidate("state", worker.state, `state ${compactSearchEvidence(worker.state)}`));
83
+ candidates.push(candidate("any", worker.id, `worker ${compactSearchEvidence(worker.id)}`));
84
+ candidates.push(candidate("any", worker.phase, `phase ${compactSearchEvidence(worker.phase)}`));
85
+ if (worker.summary) {
86
+ candidates.push(candidate("any", worker.summary, `worker ${compactSearchEvidence(worker.summary, 72)}`));
87
+ }
88
+ }
89
+ for (const session of document.nativeSessions) {
90
+ candidates.push(candidate("any", session.sessionId, `session ${compactSearchEvidence(session.sessionId)}`));
91
+ candidates.push(candidate("provider", session.provider, `provider ${compactSearchEvidence(session.provider)}`));
92
+ }
93
+ return candidates.filter((item) => item.value.trim());
94
+ }
95
+ function candidate(field, value, summary) {
96
+ return { field, value, summary };
97
+ }
98
+ function tokenizeTaskSearchQuery(query) {
99
+ const tokens = [];
100
+ let current = "";
101
+ let quoted = false;
102
+ for (const character of Array.from(query).slice(0, 1000)) {
103
+ if (character === '"') {
104
+ quoted = !quoted;
105
+ continue;
106
+ }
107
+ if (/\s/u.test(character) && !quoted) {
108
+ if (current) {
109
+ tokens.push(current);
110
+ current = "";
111
+ }
112
+ continue;
113
+ }
114
+ current += character;
115
+ }
116
+ if (current) {
117
+ tokens.push(current);
118
+ }
119
+ return tokens;
120
+ }
121
+ function normalizeTaskSearchValue(value) {
122
+ return value
123
+ .normalize("NFKC")
124
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
125
+ .replace(/\s+/g, " ")
126
+ .trim()
127
+ .toLowerCase();
128
+ }
129
+ function numericTurnLabel(turnId) {
130
+ const numeric = Number(turnId);
131
+ return `turn ${Number.isInteger(numeric) ? numeric : compactSearchEvidence(turnId)}`;
132
+ }
133
+ function compactSearchEvidence(value, maxLength = 48) {
134
+ const clean = value
135
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
136
+ .replace(/\s+/g, " ")
137
+ .trim();
138
+ const points = Array.from(clean);
139
+ return points.length > maxLength
140
+ ? `${points.slice(0, Math.max(1, maxLength - 3)).join("")}...`
141
+ : clean;
142
+ }