parallel-codex-tui 0.1.0 → 0.1.4
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.
- package/.parallel-codex/config.example.toml +90 -3
- package/README.md +269 -12
- package/dist/bootstrap.js +50 -18
- package/dist/cli-args.js +96 -14
- package/dist/cli-help.js +20 -0
- package/dist/cli-startup-recovery.js +70 -0
- package/dist/cli-workspace-picker.js +330 -0
- package/dist/cli-workspace-transition.js +33 -0
- package/dist/cli-workspace.js +40 -0
- package/dist/cli.js +291 -35
- package/dist/core/app-root.js +8 -0
- package/dist/core/collaboration-timeline.js +261 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +191 -23
- package/dist/core/file-store.js +130 -6
- package/dist/core/lease-finalization.js +22 -0
- package/dist/core/paths.js +10 -0
- package/dist/core/process-identity.js +48 -0
- package/dist/core/process-mutation-turn.js +128 -0
- package/dist/core/process-ownership.js +276 -0
- package/dist/core/process-tree.js +90 -0
- package/dist/core/router-audit.js +155 -0
- package/dist/core/router-redaction.js +31 -0
- package/dist/core/router.js +473 -42
- package/dist/core/session-index.js +225 -30
- package/dist/core/session-manager.js +1182 -44
- package/dist/core/task-state-machine.js +17 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +126 -0
- package/dist/doctor.js +384 -30
- package/dist/domain/schemas.js +127 -6
- package/dist/orchestrator/collaboration-channel.js +255 -4
- package/dist/orchestrator/feature-plan.js +70 -0
- package/dist/orchestrator/judge-artifacts.js +236 -0
- package/dist/orchestrator/orchestrator.js +1777 -212
- package/dist/orchestrator/prompts.js +126 -2
- package/dist/orchestrator/supervisor-summary.js +56 -2
- package/dist/orchestrator/workspace-sandbox.js +911 -0
- package/dist/tui/App.js +2838 -159
- package/dist/tui/AppShell.js +188 -23
- package/dist/tui/CollaborationTimelineView.js +327 -0
- package/dist/tui/FeatureBoardView.js +227 -0
- package/dist/tui/InputBar.js +514 -57
- package/dist/tui/RouterDiagnosticsView.js +469 -0
- package/dist/tui/StatusBar.js +610 -57
- package/dist/tui/TaskSessionsView.js +207 -0
- package/dist/tui/TerminalOutput.js +53 -9
- package/dist/tui/WorkerOutputView.js +1403 -161
- package/dist/tui/WorkerOverviewView.js +250 -0
- package/dist/tui/chat-history.js +25 -0
- package/dist/tui/chat-input.js +67 -19
- package/dist/tui/chat-paste.js +76 -0
- package/dist/tui/display-width.js +41 -3
- package/dist/tui/incremental-text-file.js +101 -0
- package/dist/tui/keyboard.js +46 -0
- package/dist/tui/markdown-text.js +14 -0
- package/dist/tui/raw-input-decoder.js +3 -0
- package/dist/tui/scrolling.js +2 -1
- package/dist/tui/status-line.js +318 -11
- package/dist/tui/task-memory.js +15 -0
- package/dist/tui/task-result.js +105 -0
- package/dist/tui/terminal-screen.js +13 -1
- package/dist/tui/theme-contrast.js +144 -0
- package/dist/tui/theme-preview.js +109 -0
- package/dist/tui/theme.js +158 -0
- package/dist/version.js +1 -1
- package/dist/workers/capabilities.js +212 -0
- package/dist/workers/live-probe.js +176 -0
- package/dist/workers/mock-adapter.js +39 -6
- package/dist/workers/native-attach.js +147 -8
- package/dist/workers/native-session-detection.js +17 -0
- package/dist/workers/process-adapter.js +580 -81
- package/dist/workers/registry.js +4 -2
- package/package.json +17 -2
package/dist/cli.js
CHANGED
|
@@ -1,48 +1,304 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
import { render } from "ink";
|
|
4
|
-
import {
|
|
5
|
+
import { ZodError } from "zod";
|
|
6
|
+
import { parseCliArgs, validateCliArgs } from "./cli-args.js";
|
|
7
|
+
import { selectWorkspaceForCli } from "./cli-workspace.js";
|
|
8
|
+
import { commitWorkspaceTransition } from "./cli-workspace-transition.js";
|
|
9
|
+
import { WorkspaceSelectionCancelledError } from "./cli-workspace-picker.js";
|
|
10
|
+
import { startupRecoveryMessages } from "./cli-startup-recovery.js";
|
|
5
11
|
import { createRuntime } from "./bootstrap.js";
|
|
6
|
-
import {
|
|
12
|
+
import { prepareAppRoot } from "./core/app-root.js";
|
|
13
|
+
import { formatConfigErrorMessage } from "./core/config-errors.js";
|
|
14
|
+
import { configPath, loadConfig, withUiThemeOverride, writeDefaultConfig } from "./core/config.js";
|
|
7
15
|
import { pathExists } from "./core/file-store.js";
|
|
16
|
+
import { readRouterAudit } from "./core/router-audit.js";
|
|
17
|
+
import { listWorkspaceChoices } from "./core/workspace.js";
|
|
8
18
|
import { runDoctor } from "./doctor.js";
|
|
19
|
+
import { helpText } from "./cli-help.js";
|
|
9
20
|
import { App } from "./tui/App.js";
|
|
21
|
+
import { formatTuiThemeCatalog } from "./tui/theme-preview.js";
|
|
22
|
+
import { configureTuiTheme } from "./tui/theme.js";
|
|
23
|
+
import { routerDiagnosticsPolicy } from "./tui/RouterDiagnosticsView.js";
|
|
10
24
|
import { version } from "./version.js";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
25
|
+
main().catch((error) => {
|
|
26
|
+
if (error instanceof WorkspaceSelectionCancelledError) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
process.stderr.write(`${formatStartupError(error)}\n`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
32
|
+
async function main() {
|
|
33
|
+
const rawArgs = process.argv.slice(2);
|
|
34
|
+
const cliArgErrors = validateCliArgs(rawArgs);
|
|
35
|
+
if (cliArgErrors.length > 0) {
|
|
36
|
+
process.stderr.write(`${cliArgErrors.join("\n")}\n`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const cliArgs = parseCliArgs(rawArgs, process.cwd());
|
|
40
|
+
if (!cliArgs.help && !cliArgs.themes && !cliArgs.version) {
|
|
41
|
+
await prepareAppRoot(cliArgs.appRoot);
|
|
42
|
+
}
|
|
43
|
+
const localConfigPath = configPath(cliArgs.appRoot);
|
|
44
|
+
if (cliArgs.help) {
|
|
45
|
+
console.log(helpText);
|
|
46
|
+
}
|
|
47
|
+
else if (cliArgs.version) {
|
|
48
|
+
console.log(`parallel-codex-tui ${version}`);
|
|
49
|
+
}
|
|
50
|
+
else if (cliArgs.themes) {
|
|
51
|
+
console.log(formatTuiThemeCatalog(cliArgs.theme ? [cliArgs.theme] : undefined).join("\n"));
|
|
52
|
+
}
|
|
53
|
+
else if (cliArgs.doctor) {
|
|
54
|
+
const workspaceRoot = await selectWorkspaceForCli({
|
|
55
|
+
appRoot: cliArgs.appRoot,
|
|
56
|
+
cwd: process.cwd(),
|
|
57
|
+
explicitWorkspace: cliArgs.explicitWorkspace,
|
|
58
|
+
interactive: false
|
|
59
|
+
});
|
|
60
|
+
const result = await runDoctor(cliArgs.appRoot, workspaceRoot, process.env, {
|
|
61
|
+
probeAgents: cliArgs.probeAgents,
|
|
62
|
+
probeRouter: cliArgs.probeRouter,
|
|
63
|
+
theme: cliArgs.theme
|
|
64
|
+
});
|
|
65
|
+
process.stdout.write(result.text);
|
|
66
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
67
|
+
}
|
|
68
|
+
else if (cliArgs.init) {
|
|
69
|
+
if (await pathExists(localConfigPath)) {
|
|
70
|
+
console.log(`Config already exists: ${localConfigPath}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
await writeDefaultConfig(cliArgs.appRoot);
|
|
74
|
+
console.log(`Wrote ${localConfigPath}`);
|
|
75
|
+
}
|
|
37
76
|
}
|
|
38
77
|
else {
|
|
39
|
-
await
|
|
40
|
-
|
|
78
|
+
const startupConfig = await loadConfig(cliArgs.appRoot);
|
|
79
|
+
configureTuiTheme({
|
|
80
|
+
theme: cliArgs.theme ?? startupConfig.ui.theme,
|
|
81
|
+
colors: startupConfig.ui.colors
|
|
82
|
+
});
|
|
83
|
+
const workspaceRoot = await selectWorkspaceForCli({
|
|
84
|
+
appRoot: cliArgs.appRoot,
|
|
85
|
+
cwd: process.cwd(),
|
|
86
|
+
explicitWorkspace: cliArgs.explicitWorkspace
|
|
87
|
+
});
|
|
88
|
+
let current = await loadInteractiveWorkspace(cliArgs.appRoot, workspaceRoot, cliArgs.taskId);
|
|
89
|
+
if (!canRenderInteractiveTui()) {
|
|
90
|
+
current.runtime.index.close();
|
|
91
|
+
throw new Error("parallel-codex-tui requires an interactive terminal. Use --help, --version, --init, or --doctor for non-interactive command modes.");
|
|
92
|
+
}
|
|
93
|
+
let instance = null;
|
|
94
|
+
const shutdownController = new AbortController();
|
|
95
|
+
const deferredWorkspaceClosures = new Set();
|
|
96
|
+
const appElement = (state) => (_jsx(App, { config: withUiThemeOverride(state.runtime.config, cliArgs.theme), orchestrator: state.runtime.orchestrator, cwd: state.runtime.workspaceRoot, initialTaskId: state.initialTaskId, initialRoute: state.initialRoute, initialWorkers: state.initialWorkers, initialCanRetryTask: state.initialCanRetryTask, initialMessages: state.initialMessages, workspaceChoices: state.workspaceChoices, shutdownSignal: shutdownController.signal, loadRouterDiagnostics: async () => {
|
|
97
|
+
const [records, latestConfig] = await Promise.all([
|
|
98
|
+
readRouterAudit(join(state.runtime.routerCwd, "routes.jsonl"), 100),
|
|
99
|
+
loadConfig(cliArgs.appRoot)
|
|
100
|
+
]);
|
|
101
|
+
return {
|
|
102
|
+
records,
|
|
103
|
+
policy: routerDiagnosticsPolicy(latestConfig.router)
|
|
104
|
+
};
|
|
105
|
+
}, loadTaskSessions: (options) => state.runtime.index.listTasks(100, options), renameTaskSession: async (taskId, title) => {
|
|
106
|
+
await state.runtime.sessions.renameTask(taskId, title);
|
|
107
|
+
}, setTaskSessionArchived: async (taskId, archived) => {
|
|
108
|
+
await state.runtime.sessions.setTaskArchived(taskId, archived);
|
|
109
|
+
}, deleteTaskSession: async (taskId) => {
|
|
110
|
+
await state.runtime.sessions.deleteTask(taskId);
|
|
111
|
+
}, exportTaskSession: async (taskId) => (await state.runtime.sessions.exportTask(taskId)).path, loadCollaborationTimeline: (taskId) => state.runtime.sessions.readCollaborationTimeline(taskId), activateTaskSession: async (taskId) => {
|
|
112
|
+
if (!taskId) {
|
|
113
|
+
await state.runtime.index.setActiveTaskId(null);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (!(await state.runtime.sessions.hasTask(taskId))) {
|
|
117
|
+
throw new Error(`Task session not found in workspace ${state.runtime.workspaceRoot}: ${taskId}`);
|
|
118
|
+
}
|
|
119
|
+
const task = state.runtime.sessions.taskFromId(taskId);
|
|
120
|
+
const meta = await state.runtime.sessions.readMeta(task);
|
|
121
|
+
if (meta.archived_at) {
|
|
122
|
+
throw new Error(`Task session is archived: ${taskId}`);
|
|
123
|
+
}
|
|
124
|
+
const [route, workers, canRetry] = await Promise.all([
|
|
125
|
+
state.runtime.sessions.readLatestRoute(task),
|
|
126
|
+
state.runtime.orchestrator.listTaskWorkers(taskId),
|
|
127
|
+
state.runtime.orchestrator.canRetryTask(taskId)
|
|
128
|
+
]);
|
|
129
|
+
await state.runtime.index.setActiveTaskId(taskId);
|
|
130
|
+
return { taskId, route, workers, canRetry };
|
|
131
|
+
}, switchWorkspace: async (workspace) => {
|
|
132
|
+
if (workspace === current.runtime.workspaceRoot) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
retryDeferredWorkspaceClosures(deferredWorkspaceClosures);
|
|
136
|
+
const next = await loadInteractiveWorkspace(cliArgs.appRoot, workspace, null);
|
|
137
|
+
const previous = current;
|
|
138
|
+
current = commitWorkspaceTransition({
|
|
139
|
+
previous,
|
|
140
|
+
next,
|
|
141
|
+
render: (state) => {
|
|
142
|
+
if (!instance) {
|
|
143
|
+
throw new Error("Interactive TUI is not ready to switch workspaces.");
|
|
144
|
+
}
|
|
145
|
+
instance.rerender(appElement(state));
|
|
146
|
+
},
|
|
147
|
+
close: closeInteractiveWorkspace,
|
|
148
|
+
deferClose: (state) => deferredWorkspaceClosures.add(state)
|
|
149
|
+
});
|
|
150
|
+
}, persistChatMessage: (message, taskId) => state.runtime.sessions.appendChatMessage({
|
|
151
|
+
...message,
|
|
152
|
+
taskId
|
|
153
|
+
}) }, state.runtime.workspaceRoot));
|
|
154
|
+
const removeSigintHandler = installInteractiveSigintExitHandler(() => shutdownController.abort());
|
|
155
|
+
try {
|
|
156
|
+
instance = render(appElement(current), { exitOnCtrlC: false });
|
|
157
|
+
await instance.waitUntilExit();
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
removeSigintHandler();
|
|
161
|
+
retryDeferredWorkspaceClosures(deferredWorkspaceClosures);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function loadInteractiveWorkspace(appRoot, workspaceRoot, requestedTaskId) {
|
|
166
|
+
const runtime = await createRuntime(appRoot, workspaceRoot);
|
|
167
|
+
try {
|
|
168
|
+
if (requestedTaskId) {
|
|
169
|
+
if (!(await runtime.sessions.hasTask(requestedTaskId))) {
|
|
170
|
+
throw new Error(`Task session not found in workspace ${runtime.workspaceRoot}: ${requestedTaskId}`);
|
|
171
|
+
}
|
|
172
|
+
const requestedMeta = await runtime.sessions.readMeta(runtime.sessions.taskFromId(requestedTaskId));
|
|
173
|
+
if (requestedMeta.archived_at) {
|
|
174
|
+
throw new Error(`Task session is archived in workspace ${runtime.workspaceRoot}: ${requestedTaskId}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const [latestTask, rememberedTaskId] = await Promise.all([
|
|
178
|
+
runtime.sessions.latestTask(),
|
|
179
|
+
runtime.index.activeTaskId()
|
|
180
|
+
]);
|
|
181
|
+
const rememberedTaskIsRestorable = typeof rememberedTaskId === "string"
|
|
182
|
+
&& await runtime.sessions.hasTask(rememberedTaskId)
|
|
183
|
+
&& !(await runtime.sessions.readMeta(runtime.sessions.taskFromId(rememberedTaskId))).archived_at;
|
|
184
|
+
let initialTaskId;
|
|
185
|
+
if (requestedTaskId) {
|
|
186
|
+
initialTaskId = requestedTaskId;
|
|
187
|
+
}
|
|
188
|
+
else if (rememberedTaskId === null) {
|
|
189
|
+
initialTaskId = null;
|
|
190
|
+
}
|
|
191
|
+
else if (rememberedTaskId && rememberedTaskIsRestorable) {
|
|
192
|
+
initialTaskId = rememberedTaskId;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
initialTaskId = latestTask?.id ?? null;
|
|
196
|
+
}
|
|
197
|
+
if (initialTaskId && initialTaskId !== rememberedTaskId) {
|
|
198
|
+
await runtime.index.setActiveTaskId(initialTaskId);
|
|
199
|
+
}
|
|
200
|
+
else if (!initialTaskId && typeof rememberedTaskId === "string") {
|
|
201
|
+
await runtime.index.setActiveTaskId(null);
|
|
202
|
+
}
|
|
203
|
+
const [initialRoute, initialWorkers, initialCanRetryTask, initialHistory, workspaceChoices] = await Promise.all([
|
|
204
|
+
initialTaskId
|
|
205
|
+
? runtime.sessions.readLatestRoute(runtime.sessions.taskFromId(initialTaskId))
|
|
206
|
+
: null,
|
|
207
|
+
initialTaskId ? runtime.orchestrator.listTaskWorkers(initialTaskId) : [],
|
|
208
|
+
initialTaskId ? runtime.orchestrator.canRetryTask(initialTaskId) : false,
|
|
209
|
+
runtime.sessions.readChatHistory(),
|
|
210
|
+
listWorkspaceChoices(appRoot)
|
|
211
|
+
]);
|
|
212
|
+
const recoveryMessages = startupRecoveryMessages(runtime.recoveredTasks, initialTaskId, runtime.pendingTaskCreations);
|
|
213
|
+
return {
|
|
214
|
+
runtime,
|
|
215
|
+
initialTaskId,
|
|
216
|
+
initialRoute,
|
|
217
|
+
initialWorkers,
|
|
218
|
+
initialCanRetryTask,
|
|
219
|
+
initialMessages: [
|
|
220
|
+
...initialHistory.map(({ from, text, task_id }) => ({
|
|
221
|
+
from,
|
|
222
|
+
text,
|
|
223
|
+
...(task_id ? { taskId: task_id } : {})
|
|
224
|
+
})),
|
|
225
|
+
...recoveryMessages
|
|
226
|
+
],
|
|
227
|
+
workspaceChoices
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
runtime.index.close();
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function closeInteractiveWorkspace(state) {
|
|
236
|
+
state.runtime.index.close();
|
|
237
|
+
}
|
|
238
|
+
function retryDeferredWorkspaceClosures(states) {
|
|
239
|
+
for (const state of states) {
|
|
240
|
+
try {
|
|
241
|
+
closeInteractiveWorkspace(state);
|
|
242
|
+
states.delete(state);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Process exit remains the final cleanup boundary if an index cannot be closed yet.
|
|
246
|
+
}
|
|
41
247
|
}
|
|
42
248
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
249
|
+
function canRenderInteractiveTui() {
|
|
250
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
251
|
+
}
|
|
252
|
+
function installInteractiveSigintExitHandler(requestGracefulExit) {
|
|
253
|
+
let interrupted = false;
|
|
254
|
+
const onSigint = () => {
|
|
255
|
+
if (!interrupted) {
|
|
256
|
+
interrupted = true;
|
|
257
|
+
try {
|
|
258
|
+
requestGracefulExit();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Fall through to the force-exit path when graceful shutdown cannot start.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
restoreInteractiveTerminal();
|
|
266
|
+
process.exit(0);
|
|
267
|
+
};
|
|
268
|
+
process.on("SIGINT", onSigint);
|
|
269
|
+
return () => process.off("SIGINT", onSigint);
|
|
270
|
+
}
|
|
271
|
+
function restoreInteractiveTerminal() {
|
|
272
|
+
if (process.stdin.isTTY && process.stdin.isRaw && typeof process.stdin.setRawMode === "function") {
|
|
273
|
+
try {
|
|
274
|
+
process.stdin.setRawMode(false);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// The active view may already be releasing raw mode.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
process.stdin.pause();
|
|
281
|
+
if (process.stdout.isTTY) {
|
|
282
|
+
process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function formatStartupError(error) {
|
|
286
|
+
const message = isConfigStartupError(error)
|
|
287
|
+
? formatConfigErrorMessage(error)
|
|
288
|
+
: error instanceof Error ? error.message : String(error);
|
|
289
|
+
if (isConfigStartupError(error)) {
|
|
290
|
+
return `Config error: ${message}\nRun parallel-codex-tui --doctor for details.`;
|
|
291
|
+
}
|
|
292
|
+
return `Startup error: ${message}`;
|
|
293
|
+
}
|
|
294
|
+
function isConfigStartupError(error) {
|
|
295
|
+
if (error instanceof ZodError) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
if (!(error instanceof Error)) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
return (error.message.startsWith("Invalid config section ") ||
|
|
302
|
+
error.name.toLowerCase().includes("toml") ||
|
|
303
|
+
error.message.toLowerCase().includes("toml"));
|
|
48
304
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ensureDir, pathExists, pathIsDirectory } from "./file-store.js";
|
|
2
|
+
export async function prepareAppRoot(appRoot) {
|
|
3
|
+
if ((await pathExists(appRoot)) && !(await pathIsDirectory(appRoot))) {
|
|
4
|
+
throw new Error(`App root path exists but is not a directory: ${appRoot}`);
|
|
5
|
+
}
|
|
6
|
+
await ensureDir(appRoot);
|
|
7
|
+
return appRoot;
|
|
8
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { EventRecordSchema, FeatureStatusSchema } from "../domain/schemas.js";
|
|
5
|
+
import { readTextIfExists } from "./file-store.js";
|
|
6
|
+
const FeatureDialogueSchema = z.object({
|
|
7
|
+
time: z.string().datetime(),
|
|
8
|
+
feature_id: z.string().min(1),
|
|
9
|
+
turn_id: z.string().min(1),
|
|
10
|
+
type: z.string().min(1),
|
|
11
|
+
role: z.enum(["actor", "critic"]),
|
|
12
|
+
message: z.string().default(""),
|
|
13
|
+
paths: z.record(z.string()).default({})
|
|
14
|
+
});
|
|
15
|
+
const FindingResolutionSchema = z.object({
|
|
16
|
+
version: z.literal(1),
|
|
17
|
+
decision: z.enum(["pending", "approved", "inconsistent"]),
|
|
18
|
+
fixed_ids: z.array(z.string().min(1)),
|
|
19
|
+
unresolved_ids: z.array(z.string().min(1))
|
|
20
|
+
});
|
|
21
|
+
export async function loadCollaborationTimeline(taskId, taskDir) {
|
|
22
|
+
const [features, dialogueText, taskEventsText] = await Promise.all([
|
|
23
|
+
readCollaborationFeatures(taskDir),
|
|
24
|
+
readTextIfExists(join(taskDir, "dialogue", "actor-critic.jsonl")),
|
|
25
|
+
readTextIfExists(join(taskDir, "events.jsonl"))
|
|
26
|
+
]);
|
|
27
|
+
const featureById = new Map(features.map((feature) => [feature.id, feature]));
|
|
28
|
+
const dialogue = parseJsonLines(dialogueText, FeatureDialogueSchema);
|
|
29
|
+
const taskEvents = parseJsonLines(taskEventsText, EventRecordSchema)
|
|
30
|
+
.filter((event) => event.type.startsWith("feature.wave_"));
|
|
31
|
+
const events = [
|
|
32
|
+
...dialogue.map((event, index) => {
|
|
33
|
+
const feature = featureById.get(event.feature_id);
|
|
34
|
+
const artifactRefs = collaborationArtifactRefs(event.paths ?? {});
|
|
35
|
+
return {
|
|
36
|
+
id: `dialogue-${index}-${event.time}`,
|
|
37
|
+
time: event.time,
|
|
38
|
+
type: event.type,
|
|
39
|
+
role: event.role,
|
|
40
|
+
action: collaborationDialogueAction(event.type),
|
|
41
|
+
message: event.type === "critic.revision_requested" && feature?.latestFinding
|
|
42
|
+
? appendCollaborationEvidence(event.message ?? "", feature.latestFinding)
|
|
43
|
+
: event.message ?? "",
|
|
44
|
+
turnId: event.turn_id,
|
|
45
|
+
featureId: event.feature_id,
|
|
46
|
+
featureTitle: feature?.title ?? event.feature_id,
|
|
47
|
+
findings: feature?.findings ?? 0,
|
|
48
|
+
replies: feature?.replies ?? 0,
|
|
49
|
+
...(typeof feature?.resolvedFindings === "number"
|
|
50
|
+
? { resolvedFindings: feature.resolvedFindings }
|
|
51
|
+
: {}),
|
|
52
|
+
...(typeof feature?.unresolvedFindings === "number"
|
|
53
|
+
? { unresolvedFindings: feature.unresolvedFindings }
|
|
54
|
+
: {}),
|
|
55
|
+
artifacts: artifactRefs.map((artifact) => artifact.label),
|
|
56
|
+
artifactRefs
|
|
57
|
+
};
|
|
58
|
+
}),
|
|
59
|
+
...taskEvents.map((event, index) => ({
|
|
60
|
+
id: `task-${index}-${event.time}`,
|
|
61
|
+
time: event.time,
|
|
62
|
+
type: event.type,
|
|
63
|
+
role: "supervisor",
|
|
64
|
+
action: collaborationWaveAction(event.type),
|
|
65
|
+
message: event.message ?? "",
|
|
66
|
+
artifacts: ["task events"],
|
|
67
|
+
artifactRefs: [{ label: "task events", path: join(taskDir, "events.jsonl") }]
|
|
68
|
+
})),
|
|
69
|
+
...features.map((feature) => ({
|
|
70
|
+
id: `state-${feature.id}-${feature.updatedAt}`,
|
|
71
|
+
time: feature.updatedAt,
|
|
72
|
+
type: "feature.state",
|
|
73
|
+
role: "supervisor",
|
|
74
|
+
action: collaborationFeatureStateAction(feature.state),
|
|
75
|
+
message: [
|
|
76
|
+
`${feature.title} · ${humanizeFeatureState(feature.state)}`,
|
|
77
|
+
...(feature.latestFinding ? [`finding: ${feature.latestFinding}`] : []),
|
|
78
|
+
...(feature.latestReply ? [`reply: ${feature.latestReply}`] : [])
|
|
79
|
+
].join(" · "),
|
|
80
|
+
turnId: feature.turnId,
|
|
81
|
+
featureId: feature.id,
|
|
82
|
+
featureTitle: feature.title,
|
|
83
|
+
findings: feature.findings,
|
|
84
|
+
replies: feature.replies,
|
|
85
|
+
...(typeof feature.resolvedFindings === "number"
|
|
86
|
+
? { resolvedFindings: feature.resolvedFindings }
|
|
87
|
+
: {}),
|
|
88
|
+
...(typeof feature.unresolvedFindings === "number"
|
|
89
|
+
? { unresolvedFindings: feature.unresolvedFindings }
|
|
90
|
+
: {}),
|
|
91
|
+
artifacts: feature.artifactRefs.map((artifact) => artifact.label),
|
|
92
|
+
artifactRefs: feature.artifactRefs
|
|
93
|
+
}))
|
|
94
|
+
].sort((left, right) => left.time.localeCompare(right.time) || left.id.localeCompare(right.id));
|
|
95
|
+
return { taskId, features, events };
|
|
96
|
+
}
|
|
97
|
+
async function readCollaborationFeatures(taskDir) {
|
|
98
|
+
const root = join(taskDir, "features");
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const features = await Promise.all(entries
|
|
107
|
+
.filter((entry) => entry.isDirectory())
|
|
108
|
+
.map(async (entry) => {
|
|
109
|
+
const dir = join(root, entry.name);
|
|
110
|
+
const [statusText, spec, findings, replies, resolutionText] = await Promise.all([
|
|
111
|
+
readTextIfExists(join(dir, "status.json")),
|
|
112
|
+
readTextIfExists(join(dir, "spec.md")),
|
|
113
|
+
readTextIfExists(join(dir, "critic-findings.jsonl")),
|
|
114
|
+
readTextIfExists(join(dir, "actor-replies.jsonl")),
|
|
115
|
+
readTextIfExists(join(dir, "finding-resolution.json"))
|
|
116
|
+
]);
|
|
117
|
+
const status = parseJsonValue(statusText, FeatureStatusSchema);
|
|
118
|
+
if (!status) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const findingEvidence = readMailboxEvidence(findings);
|
|
122
|
+
const replyEvidence = readMailboxEvidence(replies);
|
|
123
|
+
const resolution = parseJsonValue(resolutionText, FindingResolutionSchema);
|
|
124
|
+
return {
|
|
125
|
+
id: status.feature_id,
|
|
126
|
+
title: status.title?.trim() || featureSpecTitle(spec) || status.feature_id,
|
|
127
|
+
description: status.description?.trim() ?? "",
|
|
128
|
+
dependsOn: status.depends_on ?? [],
|
|
129
|
+
turnId: status.turn_id,
|
|
130
|
+
state: status.state,
|
|
131
|
+
updatedAt: status.updated_at,
|
|
132
|
+
findings: findingEvidence.count,
|
|
133
|
+
replies: replyEvidence.count,
|
|
134
|
+
artifactRefs: [
|
|
135
|
+
{ label: "status", path: join(dir, "status.json") },
|
|
136
|
+
{ label: "spec", path: join(dir, "spec.md") },
|
|
137
|
+
{ label: "critic findings", path: join(dir, "critic-findings.jsonl") },
|
|
138
|
+
{ label: "actor replies", path: join(dir, "actor-replies.jsonl") },
|
|
139
|
+
...(resolution ? [{ label: "finding resolution", path: join(dir, "finding-resolution.json") }] : [])
|
|
140
|
+
],
|
|
141
|
+
...(resolution
|
|
142
|
+
? {
|
|
143
|
+
resolvedFindings: new Set(resolution.fixed_ids).size,
|
|
144
|
+
unresolvedFindings: new Set(resolution.unresolved_ids).size
|
|
145
|
+
}
|
|
146
|
+
: {}),
|
|
147
|
+
...(findingEvidence.latest ? { latestFinding: findingEvidence.latest } : {}),
|
|
148
|
+
...(replyEvidence.latest ? { latestReply: replyEvidence.latest } : {})
|
|
149
|
+
};
|
|
150
|
+
}));
|
|
151
|
+
return features
|
|
152
|
+
.filter((feature) => feature !== null)
|
|
153
|
+
.sort((left, right) => left.turnId.localeCompare(right.turnId) || left.id.localeCompare(right.id));
|
|
154
|
+
}
|
|
155
|
+
function collaborationArtifactRefs(paths) {
|
|
156
|
+
return Object.entries(paths)
|
|
157
|
+
.map(([label, path]) => ({ label: label.trim(), path: path.trim() }))
|
|
158
|
+
.filter((artifact) => artifact.label && artifact.path)
|
|
159
|
+
.sort((left, right) => left.label.localeCompare(right.label) || left.path.localeCompare(right.path));
|
|
160
|
+
}
|
|
161
|
+
function parseJsonLines(text, schema) {
|
|
162
|
+
const records = [];
|
|
163
|
+
for (const line of text.split(/\r?\n/)) {
|
|
164
|
+
if (!line.trim()) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const parsed = schema.safeParse(JSON.parse(line));
|
|
169
|
+
if (parsed.success) {
|
|
170
|
+
records.push(parsed.data);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// A partial mailbox write must not hide earlier collaboration evidence.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return records;
|
|
178
|
+
}
|
|
179
|
+
function parseJsonValue(text, schema) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = schema.safeParse(JSON.parse(text));
|
|
182
|
+
return parsed.success ? parsed.data : null;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function readMailboxEvidence(text) {
|
|
189
|
+
let count = 0;
|
|
190
|
+
let latest = null;
|
|
191
|
+
for (const line of text.split(/\r?\n/)) {
|
|
192
|
+
if (!line.trim()) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const value = JSON.parse(line);
|
|
197
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
198
|
+
count += 1;
|
|
199
|
+
latest = mailboxRecordSummary(value) || latest;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Ignore partial or malformed mailbox rows.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { count, latest };
|
|
207
|
+
}
|
|
208
|
+
function mailboxRecordSummary(value) {
|
|
209
|
+
for (const key of ["message", "summary", "notes", "resolution", "title", "description", "issue", "status"]) {
|
|
210
|
+
const field = value[key];
|
|
211
|
+
if (typeof field === "string" && field.trim()) {
|
|
212
|
+
return field.replace(/\s+/g, " ").trim();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return "";
|
|
216
|
+
}
|
|
217
|
+
function appendCollaborationEvidence(message, evidence) {
|
|
218
|
+
const base = message.trim();
|
|
219
|
+
if (!base || base.includes(evidence)) {
|
|
220
|
+
return base || evidence;
|
|
221
|
+
}
|
|
222
|
+
return `${base} · ${evidence}`;
|
|
223
|
+
}
|
|
224
|
+
function featureSpecTitle(spec) {
|
|
225
|
+
const title = spec.match(/^Title:\s*(.+)$/m)?.[1]?.trim();
|
|
226
|
+
return title || null;
|
|
227
|
+
}
|
|
228
|
+
function collaborationDialogueAction(type) {
|
|
229
|
+
if (type === "feature.created") {
|
|
230
|
+
return "mailbox created";
|
|
231
|
+
}
|
|
232
|
+
if (type === "actor.completed") {
|
|
233
|
+
return "implementation completed";
|
|
234
|
+
}
|
|
235
|
+
if (type === "critic.completed") {
|
|
236
|
+
return "review completed";
|
|
237
|
+
}
|
|
238
|
+
if (type === "critic.revision_requested") {
|
|
239
|
+
return "revision requested";
|
|
240
|
+
}
|
|
241
|
+
return humanizeCollaborationType(type);
|
|
242
|
+
}
|
|
243
|
+
function collaborationWaveAction(type) {
|
|
244
|
+
const action = type.replace(/^feature\.wave_/, "wave ");
|
|
245
|
+
return humanizeCollaborationType(action);
|
|
246
|
+
}
|
|
247
|
+
function collaborationFeatureStateAction(state) {
|
|
248
|
+
if (state === "approved") {
|
|
249
|
+
return "feature approved";
|
|
250
|
+
}
|
|
251
|
+
if (state === "revision_needed") {
|
|
252
|
+
return "revision pending";
|
|
253
|
+
}
|
|
254
|
+
return `feature ${humanizeFeatureState(state)}`;
|
|
255
|
+
}
|
|
256
|
+
function humanizeFeatureState(state) {
|
|
257
|
+
return state.replaceAll("_", " ");
|
|
258
|
+
}
|
|
259
|
+
function humanizeCollaborationType(type) {
|
|
260
|
+
return type.replace(/[._-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
261
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ZodError } from "zod";
|
|
2
|
+
export function formatConfigErrorMessage(error) {
|
|
3
|
+
if (error instanceof ZodError) {
|
|
4
|
+
return error.issues
|
|
5
|
+
.map((issue) => ({
|
|
6
|
+
message: issue.message,
|
|
7
|
+
path: issue.path.map(String).join(".") || "config"
|
|
8
|
+
}))
|
|
9
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
10
|
+
.map((issue) => `${issue.path}: ${issue.message}`)
|
|
11
|
+
.join("\n");
|
|
12
|
+
}
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|