parallel-codex-tui 0.1.3 → 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.
Files changed (72) hide show
  1. package/.parallel-codex/config.example.toml +90 -3
  2. package/README.md +240 -21
  3. package/dist/bootstrap.js +37 -17
  4. package/dist/cli-args.js +34 -3
  5. package/dist/cli-help.js +20 -0
  6. package/dist/cli-startup-recovery.js +70 -0
  7. package/dist/cli-workspace-picker.js +330 -0
  8. package/dist/cli-workspace-transition.js +33 -0
  9. package/dist/cli-workspace.js +7 -71
  10. package/dist/cli.js +221 -23
  11. package/dist/core/collaboration-timeline.js +261 -0
  12. package/dist/core/config-errors.js +14 -0
  13. package/dist/core/config.js +154 -24
  14. package/dist/core/file-store.js +119 -6
  15. package/dist/core/lease-finalization.js +22 -0
  16. package/dist/core/paths.js +7 -0
  17. package/dist/core/process-identity.js +48 -0
  18. package/dist/core/process-mutation-turn.js +128 -0
  19. package/dist/core/process-ownership.js +276 -0
  20. package/dist/core/process-tree.js +90 -0
  21. package/dist/core/router-audit.js +155 -0
  22. package/dist/core/router-redaction.js +31 -0
  23. package/dist/core/router.js +462 -35
  24. package/dist/core/session-index.js +188 -37
  25. package/dist/core/session-manager.js +1086 -40
  26. package/dist/core/task-state-machine.js +17 -0
  27. package/dist/core/workspace-commit-recovery.js +118 -0
  28. package/dist/core/workspace.js +19 -11
  29. package/dist/doctor.js +343 -23
  30. package/dist/domain/schemas.js +127 -6
  31. package/dist/orchestrator/collaboration-channel.js +255 -4
  32. package/dist/orchestrator/feature-plan.js +70 -0
  33. package/dist/orchestrator/judge-artifacts.js +236 -0
  34. package/dist/orchestrator/orchestrator.js +1749 -202
  35. package/dist/orchestrator/prompts.js +126 -2
  36. package/dist/orchestrator/supervisor-summary.js +56 -2
  37. package/dist/orchestrator/workspace-sandbox.js +911 -0
  38. package/dist/tui/App.js +2830 -153
  39. package/dist/tui/AppShell.js +188 -23
  40. package/dist/tui/CollaborationTimelineView.js +327 -0
  41. package/dist/tui/FeatureBoardView.js +227 -0
  42. package/dist/tui/InputBar.js +514 -57
  43. package/dist/tui/RouterDiagnosticsView.js +469 -0
  44. package/dist/tui/StatusBar.js +610 -57
  45. package/dist/tui/TaskSessionsView.js +207 -0
  46. package/dist/tui/TerminalOutput.js +53 -9
  47. package/dist/tui/WorkerOutputView.js +1403 -161
  48. package/dist/tui/WorkerOverviewView.js +250 -0
  49. package/dist/tui/chat-history.js +25 -0
  50. package/dist/tui/chat-input.js +67 -19
  51. package/dist/tui/chat-paste.js +76 -0
  52. package/dist/tui/display-width.js +41 -3
  53. package/dist/tui/incremental-text-file.js +101 -0
  54. package/dist/tui/keyboard.js +46 -0
  55. package/dist/tui/markdown-text.js +14 -0
  56. package/dist/tui/raw-input-decoder.js +3 -0
  57. package/dist/tui/scrolling.js +2 -1
  58. package/dist/tui/status-line.js +318 -11
  59. package/dist/tui/task-memory.js +13 -1
  60. package/dist/tui/task-result.js +105 -0
  61. package/dist/tui/terminal-screen.js +13 -1
  62. package/dist/tui/theme-contrast.js +144 -0
  63. package/dist/tui/theme-preview.js +109 -0
  64. package/dist/tui/theme.js +158 -0
  65. package/dist/version.js +1 -1
  66. package/dist/workers/capabilities.js +212 -0
  67. package/dist/workers/live-probe.js +176 -0
  68. package/dist/workers/mock-adapter.js +39 -6
  69. package/dist/workers/native-attach.js +78 -3
  70. package/dist/workers/process-adapter.js +570 -77
  71. package/dist/workers/registry.js +4 -2
  72. package/package.json +5 -2
@@ -2,6 +2,43 @@ import { z } from "zod";
2
2
  export const RouteModeSchema = z.enum(["simple", "complex"]);
3
3
  export const EngineNameSchema = z.enum(["codex", "claude", "mock"]);
4
4
  export const WorkerRoleSchema = z.enum(["main", "judge", "actor", "critic"]);
5
+ export const RouterFailureStageSchema = z.enum([
6
+ "spawn",
7
+ "input",
8
+ "waiting-output",
9
+ "streaming",
10
+ "exit",
11
+ "response"
12
+ ]);
13
+ export const RouterFallbackResolutionSchema = z.enum([
14
+ "main",
15
+ "parallel",
16
+ "retry",
17
+ "auto-retry",
18
+ "cancelled",
19
+ "configured"
20
+ ]);
21
+ export const RouterFailureKindSchema = z.enum([
22
+ "timeout",
23
+ "auth",
24
+ "rate-limit",
25
+ "proxy",
26
+ "network",
27
+ "unavailable",
28
+ "invalid-output",
29
+ "exit",
30
+ "input",
31
+ "unknown"
32
+ ]);
33
+ export const RouterProxySourceSchema = z.enum(["router-config", "environment"]);
34
+ export const RouterTimeoutKindSchema = z.enum(["first-output", "idle", "total"]);
35
+ export const RouterRecoveryTriggerSchema = z.enum(["retry", "auto-retry"]);
36
+ export const TaskIdSchema = z
37
+ .string()
38
+ .min(1)
39
+ .max(255)
40
+ .regex(/^task-[A-Za-z0-9][A-Za-z0-9._-]*$/);
41
+ export const TaskSessionIdSchema = z.union([z.literal("main"), TaskIdSchema]);
5
42
  export const TaskStateSchema = z.enum([
6
43
  "created",
7
44
  "routed",
@@ -10,11 +47,20 @@ export const TaskStateSchema = z.enum([
10
47
  "actor_running",
11
48
  "critic_running",
12
49
  "revision_needed",
50
+ "integrating",
13
51
  "verifying",
14
52
  "done",
15
53
  "failed",
16
54
  "cancelled"
17
55
  ]);
56
+ export const TaskStatusTransitionSchema = z.object({
57
+ id: z.string().min(1),
58
+ from: TaskStateSchema,
59
+ to: TaskStateSchema,
60
+ at: z.string().datetime()
61
+ }).refine((transition) => transition.from !== transition.to, {
62
+ message: "Task status transition must change state"
63
+ });
18
64
  export const WorkerStateSchema = z.enum([
19
65
  "idle",
20
66
  "starting",
@@ -24,24 +70,75 @@ export const WorkerStateSchema = z.enum([
24
70
  "failed",
25
71
  "cancelled"
26
72
  ]);
73
+ export const FeatureStateSchema = z.enum([
74
+ "created",
75
+ "queued",
76
+ "actor_running",
77
+ "actor_done",
78
+ "critic_running",
79
+ "critic_done",
80
+ "revision_needed",
81
+ "integrating",
82
+ "verifying",
83
+ "approved",
84
+ "failed",
85
+ "cancelled"
86
+ ]);
27
87
  export const RouteDecisionSchema = z.object({
28
88
  mode: RouteModeSchema,
29
89
  reason: z.string().min(1),
90
+ source: z.enum(["codex", "forced", "fallback"]).optional(),
91
+ duration_ms: z.number().nonnegative().optional(),
92
+ router_dispatch_ms: z.number().nonnegative().optional(),
93
+ router_spawn_ms: z.number().nonnegative().optional(),
94
+ router_first_output_ms: z.number().nonnegative().optional(),
95
+ router_first_stdout_ms: z.number().nonnegative().optional(),
96
+ router_first_stderr_ms: z.number().nonnegative().optional(),
97
+ router_process_ms: z.number().nonnegative().optional(),
98
+ router_parse_ms: z.number().nonnegative().optional(),
99
+ router_stdout_bytes: z.number().int().nonnegative().optional(),
100
+ router_stderr_bytes: z.number().int().nonnegative().optional(),
101
+ router_command: z.string().min(1).max(80).optional(),
102
+ router_failure_stage: RouterFailureStageSchema.optional(),
103
+ router_failure_kind: RouterFailureKindSchema.optional(),
104
+ router_attempt: z.number().int().positive().optional(),
105
+ router_total_duration_ms: z.number().nonnegative().optional(),
106
+ router_fallback_resolution: RouterFallbackResolutionSchema.optional(),
107
+ router_timeout_kind: RouterTimeoutKindSchema.optional(),
108
+ router_recovered_from: RouterFailureKindSchema.optional(),
109
+ router_recovered_via: RouterRecoveryTriggerSchema.optional(),
110
+ router_recovered_timeout_kind: RouterTimeoutKindSchema.optional(),
111
+ router_recovered_failure_stage: RouterFailureStageSchema.optional(),
112
+ proxy_configured: z.boolean().optional(),
113
+ proxy_source: RouterProxySourceSchema.optional(),
114
+ proxy_variable: z.string().regex(/^(?:HTTP|HTTPS|ALL)_PROXY$/i).optional(),
115
+ proxy_endpoint: z.string().min(1).max(255).optional(),
30
116
  suggested_roles: z.array(WorkerRoleSchema).default([]),
31
117
  judge_engine: EngineNameSchema.default("codex"),
32
118
  actor_engine: EngineNameSchema.default("codex"),
33
119
  critic_engine: EngineNameSchema.default("claude")
34
120
  });
35
121
  export const TaskMetaSchema = z.object({
36
- id: z.string().min(1),
37
- title: z.string().min(1),
122
+ id: TaskIdSchema,
123
+ title: z.string().min(1).max(160),
38
124
  created_at: z.string().datetime(),
39
125
  cwd: z.string().min(1),
40
126
  mode: RouteModeSchema,
41
- status: TaskStateSchema
127
+ status: TaskStateSchema,
128
+ archived_at: z.string().datetime().optional(),
129
+ status_transition: TaskStatusTransitionSchema.optional().catch(undefined)
130
+ }).transform((meta) => {
131
+ if (meta.status_transition && meta.status_transition.to !== meta.status) {
132
+ const nextMeta = { ...meta };
133
+ delete nextMeta.status_transition;
134
+ return nextMeta;
135
+ }
136
+ return meta;
42
137
  });
43
138
  export const WorkerStatusSchema = z.object({
44
139
  worker_id: z.string().min(1),
140
+ feature_id: z.string().min(1).optional(),
141
+ feature_title: z.string().min(1).optional(),
45
142
  role: WorkerRoleSchema,
46
143
  engine: EngineNameSchema,
47
144
  state: WorkerStateSchema,
@@ -50,8 +147,18 @@ export const WorkerStatusSchema = z.object({
50
147
  summary: z.string(),
51
148
  native_session_id: z.string().min(1).optional()
52
149
  });
150
+ export const FeatureStatusSchema = z.object({
151
+ feature_id: z.string().min(1),
152
+ task_id: TaskIdSchema,
153
+ turn_id: z.string().min(1),
154
+ title: z.string().min(1).optional(),
155
+ description: z.string().default(""),
156
+ depends_on: z.array(z.string().min(1)).default([]),
157
+ state: FeatureStateSchema,
158
+ updated_at: z.string().datetime()
159
+ });
53
160
  export const TurnMetaSchema = z.object({
54
- task_id: z.string().min(1),
161
+ task_id: TaskIdSchema,
55
162
  turn_id: z.string().regex(/^\d{4}$/),
56
163
  created_at: z.string().datetime(),
57
164
  request_path: z.string().min(1)
@@ -62,17 +169,31 @@ export const NativeSessionSchema = z.object({
62
169
  role: WorkerRoleSchema,
63
170
  worker_id: z.string().min(1),
64
171
  session_id: z.string().min(1),
65
- scope: z.literal("task"),
172
+ scope: z.enum(["main", "task"]),
66
173
  cwd: z.string().min(1),
174
+ writable_dirs: z.array(z.string().min(1)).optional(),
67
175
  created_at: z.string().datetime(),
68
176
  last_used_at: z.string().datetime(),
69
177
  source: NativeSessionSourceSchema
70
178
  });
179
+ export const RetiredNativeSessionSchema = NativeSessionSchema.extend({
180
+ retired_at: z.string().datetime(),
181
+ retired_reason: z.string().min(1)
182
+ });
183
+ export const ChatRecordSchema = z.object({
184
+ time: z.string().datetime(),
185
+ from: z.enum(["user", "system"]),
186
+ text: z.string(),
187
+ task_id: TaskIdSchema.optional()
188
+ });
71
189
  export const EventRecordSchema = z.object({
72
190
  time: z.string().datetime(),
73
191
  type: z.string().min(1),
74
192
  message: z.string().optional(),
75
193
  worker: z.string().optional(),
76
194
  engine: EngineNameSchema.optional(),
77
- task_id: z.string().optional()
195
+ task_id: TaskSessionIdSchema.optional(),
196
+ transition_id: z.string().min(1).optional(),
197
+ from_state: TaskStateSchema.optional(),
198
+ to_state: TaskStateSchema.optional()
78
199
  });
@@ -1,11 +1,47 @@
1
1
  import { join } from "node:path";
2
- import { appendJsonLine, ensureDir, writeJson, writeText } from "../core/file-store.js";
2
+ import { z } from "zod";
3
+ import { appendJsonLine, ensureDir, pathExists, readJson, readTextIfExists, writeJson, writeText } from "../core/file-store.js";
4
+ import { FeatureStatusSchema } from "../domain/schemas.js";
5
+ const CriticFindingRecordSchema = z.object({
6
+ id: z.string().trim().min(1),
7
+ severity: z.string().trim().optional(),
8
+ summary: z.string().trim().min(1).optional(),
9
+ message: z.string().trim().min(1).optional()
10
+ }).passthrough().superRefine((record, context) => {
11
+ if (!record.summary && !record.message) {
12
+ context.addIssue({
13
+ code: z.ZodIssueCode.custom,
14
+ path: ["summary"],
15
+ message: "summary or legacy message is required"
16
+ });
17
+ }
18
+ }).transform((record) => ({
19
+ ...record,
20
+ summary: record.summary ?? record.message ?? ""
21
+ }));
22
+ const ActorFindingReplyRecordSchema = z.object({
23
+ finding_id: z.string().trim().min(1),
24
+ status: z.string().trim().toLowerCase().pipe(z.enum(["fixed", "not_fixed", "deferred"])).default("fixed"),
25
+ notes: z.string().trim().optional()
26
+ }).passthrough();
27
+ const ApprovedFindingResolutionSchema = z.object({
28
+ version: z.literal(1),
29
+ decision: z.literal("approved"),
30
+ finding_ids: z.array(z.string().min(1)),
31
+ fixed_ids: z.array(z.string().min(1)),
32
+ unresolved_ids: z.array(z.string().min(1)).length(0)
33
+ });
3
34
  export async function createFeatureChannel(input) {
4
- const id = featureIdForTurn(input.turn, input.request);
35
+ const id = input.feature
36
+ ? `${input.turn.turnId}-${input.feature.id}`
37
+ : featureIdForTurn(input.turn, input.request);
5
38
  const dir = join(input.task.dir, "features", id);
6
39
  const dialoguePath = join(input.task.dir, "dialogue", "actor-critic.jsonl");
7
40
  const channel = {
8
41
  id,
42
+ title: input.feature?.title ?? input.request.trim(),
43
+ description: input.feature?.description ?? input.request.trim(),
44
+ dependsOn: input.feature?.depends_on ?? [],
9
45
  taskId: input.task.id,
10
46
  turnId: input.turn.turnId,
11
47
  dir,
@@ -15,10 +51,19 @@ export async function createFeatureChannel(input) {
15
51
  actorWorklogPath: join(dir, "actor-worklog.md"),
16
52
  actorRepliesPath: join(dir, "actor-replies.jsonl"),
17
53
  criticFindingsPath: join(dir, "critic-findings.jsonl"),
54
+ findingResolutionPath: join(dir, "finding-resolution.json"),
18
55
  decisionsPath: join(dir, "decisions.md")
19
56
  };
20
57
  await ensureDir(dir);
21
58
  await ensureDir(join(input.task.dir, "dialogue"));
59
+ if (input.resume) {
60
+ await ensureFeatureChannelFiles(input, channel);
61
+ const repairedState = await repairFeatureStatus(channel);
62
+ if (repairedState) {
63
+ await appendFeatureDialogue(channel, "feature.status_recovered", "actor", `Recovered Feature status as ${repairedState} while preserving collaboration evidence.`);
64
+ }
65
+ return channel;
66
+ }
22
67
  await writeText(channel.specPath, buildFeatureSpec(input, channel));
23
68
  await writeText(channel.actorWorklogPath, "");
24
69
  await writeText(channel.actorRepliesPath, "");
@@ -27,9 +72,26 @@ export async function createFeatureChannel(input) {
27
72
  await appendFeatureDialogue(channel, "feature.created", "actor", "Feature mailbox created for the current turn.");
28
73
  return channel;
29
74
  }
75
+ async function ensureFeatureChannelFiles(input, channel) {
76
+ const files = [
77
+ [channel.specPath, buildFeatureSpec(input, channel)],
78
+ [channel.actorWorklogPath, ""],
79
+ [channel.actorRepliesPath, ""],
80
+ [channel.criticFindingsPath, ""]
81
+ ];
82
+ for (const [path, initialContent] of files) {
83
+ if (!(await pathExists(path))) {
84
+ await writeText(path, initialContent);
85
+ }
86
+ }
87
+ }
30
88
  export function featurePromptContext(channel) {
31
89
  return {
32
90
  featureId: channel.id,
91
+ featureTitle: channel.title,
92
+ featureDescription: channel.description,
93
+ featureSpecPath: channel.specPath,
94
+ featureDependencies: channel.dependsOn,
33
95
  featureDir: channel.dir,
34
96
  dialoguePath: channel.dialoguePath,
35
97
  actorWorklogPath: channel.actorWorklogPath,
@@ -39,14 +101,59 @@ export function featurePromptContext(channel) {
39
101
  };
40
102
  }
41
103
  export async function updateFeatureStatus(channel, state) {
104
+ if (await pathExists(channel.statusPath)) {
105
+ try {
106
+ const current = await readJson(channel.statusPath, FeatureStatusSchema);
107
+ if (current.state === state && featureStatusMatchesChannel(current, channel)) {
108
+ return;
109
+ }
110
+ }
111
+ catch {
112
+ // A corrupt status is rebuilt from the authoritative feature channel.
113
+ }
114
+ }
42
115
  await writeJson(channel.statusPath, {
43
116
  feature_id: channel.id,
44
117
  task_id: channel.taskId,
45
118
  turn_id: channel.turnId,
119
+ title: channel.title,
120
+ description: channel.description,
121
+ depends_on: channel.dependsOn,
46
122
  state,
47
123
  updated_at: new Date().toISOString()
48
124
  });
49
125
  }
126
+ async function repairFeatureStatus(channel) {
127
+ let current = null;
128
+ if (await pathExists(channel.statusPath)) {
129
+ try {
130
+ current = await readJson(channel.statusPath, FeatureStatusSchema);
131
+ }
132
+ catch {
133
+ // Invalid status is rebuilt without replacing the collaboration mailbox.
134
+ }
135
+ }
136
+ if (current && featureStatusMatchesChannel(current, channel)) {
137
+ return null;
138
+ }
139
+ const state = current && featureStatusIdentityMatches(current, channel)
140
+ ? current.state
141
+ : "created";
142
+ await updateFeatureStatus(channel, state);
143
+ return state;
144
+ }
145
+ function featureStatusMatchesChannel(status, channel) {
146
+ return featureStatusIdentityMatches(status, channel)
147
+ && status.title === channel.title
148
+ && status.description === channel.description
149
+ && status.depends_on.length === channel.dependsOn.length
150
+ && status.depends_on.every((dependency, index) => dependency === channel.dependsOn[index]);
151
+ }
152
+ function featureStatusIdentityMatches(status, channel) {
153
+ return status.feature_id === channel.id
154
+ && status.task_id === channel.taskId
155
+ && status.turn_id === channel.turnId;
156
+ }
50
157
  export async function appendFeatureDialogue(channel, type, role, message, paths = {}) {
51
158
  await appendJsonLine(channel.dialoguePath, {
52
159
  time: new Date().toISOString(),
@@ -70,11 +177,155 @@ export async function writeFeatureDecision(channel, summary) {
70
177
  ""
71
178
  ].join("\n"));
72
179
  }
180
+ export async function requireFeatureRevisionFindings(channel) {
181
+ const findings = await readFeatureCriticFindings(channel);
182
+ if (findings.length === 0) {
183
+ throw new Error(`Critic requested revision without valid critic findings for ${channel.id}.`);
184
+ }
185
+ const findingIds = findings.map((finding) => finding.id);
186
+ const replies = await readFeatureActorReplies(channel);
187
+ await writeFindingResolutionSnapshot(channel, "pending", findingIds, replies);
188
+ return findingIds;
189
+ }
190
+ export async function requireActorFindingReplies(channel, findingIds) {
191
+ const replies = await readFeatureActorReplies(channel);
192
+ const resolution = await writeFindingResolutionSnapshot(channel, "pending", findingIds, replies);
193
+ const unknown = resolution.unknownReplyIds;
194
+ if (unknown.length > 0) {
195
+ throw new Error(`Actor replies reference unknown Critic findings: ${unknown.join(", ")}.`);
196
+ }
197
+ const unresolved = resolution.unresolvedIds;
198
+ if (unresolved.length > 0) {
199
+ throw new Error(`Actor revision did not mark every Critic finding fixed: ${unresolved.join(", ")}.`);
200
+ }
201
+ }
202
+ export async function recordApprovedFindingResolution(channel, revisionFindingIds = [], options = {}) {
203
+ const findings = await readFeatureCriticFindings(channel);
204
+ const currentIds = [...new Set(findings.map((finding) => finding.id))];
205
+ const replies = await readFeatureActorReplies(channel);
206
+ const latestReplies = new Map(replies.map((reply) => [reply.finding_id, reply]));
207
+ const expectedIds = revisionFindingIds.length > 0
208
+ ? [...new Set(revisionFindingIds)]
209
+ : options.allowLegacyResolvedFindings
210
+ ? currentIds.filter((id) => latestReplies.get(id)?.status === "fixed")
211
+ : [];
212
+ const unexpected = currentIds.filter((id) => !expectedIds.includes(id));
213
+ if (unexpected.length > 0) {
214
+ await writeFindingResolutionRecord(channel, {
215
+ decision: "inconsistent",
216
+ findingIds: currentIds,
217
+ fixedIds: [],
218
+ unresolvedIds: currentIds,
219
+ unknownReplyIds: [...new Set(replies.map((reply) => reply.finding_id))]
220
+ .filter((id) => !currentIds.includes(id)),
221
+ replyCount: replies.length
222
+ });
223
+ throw new Error(`Critic approved with unresolved blocking findings: ${unexpected.join(", ")}.`);
224
+ }
225
+ await requireActorFindingReplies(channel, expectedIds);
226
+ await writeFindingResolutionRecord(channel, {
227
+ decision: "approved",
228
+ findingIds: expectedIds,
229
+ fixedIds: expectedIds,
230
+ unresolvedIds: [],
231
+ unknownReplyIds: [],
232
+ replyCount: replies.length
233
+ });
234
+ }
235
+ export async function featureCriticCheckpointIsReusable(channel, decision) {
236
+ try {
237
+ if (decision === "revision") {
238
+ return (await readFeatureCriticFindings(channel)).length > 0;
239
+ }
240
+ const resolutionText = await readTextIfExists(channel.findingResolutionPath);
241
+ if (resolutionText.trim()) {
242
+ return ApprovedFindingResolutionSchema.safeParse(JSON.parse(resolutionText)).success;
243
+ }
244
+ const findings = await readFeatureCriticFindings(channel);
245
+ const replies = await readFeatureActorReplies(channel);
246
+ if (findings.length === 0) {
247
+ return replies.length === 0;
248
+ }
249
+ const findingIds = new Set(findings.map((finding) => finding.id));
250
+ const latestReplies = new Map(replies.map((reply) => [reply.finding_id, reply]));
251
+ return [...latestReplies.keys()].every((id) => findingIds.has(id))
252
+ && [...findingIds].every((id) => latestReplies.get(id)?.status === "fixed");
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ }
258
+ async function readFeatureCriticFindings(channel) {
259
+ return readMailboxJsonLines(channel.criticFindingsPath, CriticFindingRecordSchema, "Critic finding");
260
+ }
261
+ async function readFeatureActorReplies(channel) {
262
+ return readMailboxJsonLines(channel.actorRepliesPath, ActorFindingReplyRecordSchema, "Actor finding reply");
263
+ }
264
+ async function writeFindingResolutionSnapshot(channel, decision, findingIds, replies) {
265
+ const ids = [...new Set(findingIds)];
266
+ const expected = new Set(ids);
267
+ const latest = new Map(replies.map((reply) => [reply.finding_id, reply]));
268
+ const fixedIds = ids.filter((id) => latest.get(id)?.status === "fixed");
269
+ const unresolvedIds = ids.filter((id) => latest.get(id)?.status !== "fixed");
270
+ const unknownReplyIds = [...latest.keys()].filter((id) => !expected.has(id));
271
+ await writeFindingResolutionRecord(channel, {
272
+ decision,
273
+ findingIds: ids,
274
+ fixedIds,
275
+ unresolvedIds,
276
+ unknownReplyIds,
277
+ replyCount: replies.length
278
+ });
279
+ return { fixedIds, unresolvedIds, unknownReplyIds };
280
+ }
281
+ async function writeFindingResolutionRecord(channel, input) {
282
+ await writeJson(channel.findingResolutionPath, {
283
+ version: 1,
284
+ decision: input.decision,
285
+ finding_ids: input.findingIds,
286
+ fixed_ids: input.fixedIds,
287
+ unresolved_ids: [
288
+ ...input.unresolvedIds,
289
+ ...input.unknownReplyIds.map((id) => `unknown:${id}`)
290
+ ],
291
+ unknown_reply_ids: input.unknownReplyIds,
292
+ reply_count: input.replyCount,
293
+ updated_at: new Date().toISOString()
294
+ });
295
+ }
296
+ async function readMailboxJsonLines(path, schema, label) {
297
+ const records = [];
298
+ const lines = (await readTextIfExists(path)).split(/\r?\n/);
299
+ for (let index = 0; index < lines.length; index += 1) {
300
+ const line = lines[index]?.trim() ?? "";
301
+ if (!line) {
302
+ continue;
303
+ }
304
+ let parsed;
305
+ try {
306
+ parsed = JSON.parse(line);
307
+ }
308
+ catch {
309
+ throw new Error(`${label} JSONL is invalid at line ${index + 1}: ${path}.`);
310
+ }
311
+ const result = schema.safeParse(parsed);
312
+ if (!result.success) {
313
+ const issue = result.error.issues[0];
314
+ const field = issue?.path.length ? ` field ${issue.path.join(".")}` : "";
315
+ throw new Error(`${label}${field} is invalid at line ${index + 1}: ${path}.`);
316
+ }
317
+ records.push(result.data);
318
+ }
319
+ return records;
320
+ }
73
321
  function buildFeatureSpec(input, channel) {
74
322
  return [
75
323
  "# Feature Mailbox",
76
324
  "",
77
325
  `Feature: ${channel.id}`,
326
+ `Title: ${channel.title}`,
327
+ `Description: ${channel.description}`,
328
+ `Depends on: ${channel.dependsOn.length > 0 ? channel.dependsOn.join(", ") : "(none)"}`,
78
329
  `Task: ${input.task.id}`,
79
330
  `Turn: ${input.turn.turnId}`,
80
331
  `Turn directory: ${input.turn.dir}`,
@@ -85,8 +336,8 @@ function buildFeatureSpec(input, channel) {
85
336
  "",
86
337
  "Protocol:",
87
338
  "- Actor writes implementation notes to actor-worklog.md.",
88
- "- Critic writes one JSON object per blocking issue to critic-findings.jsonl.",
89
- "- Actor replies to each critic finding in actor-replies.jsonl.",
339
+ '- Critic writes one JSON object per blocking issue to critic-findings.jsonl: {"id":"C-001","severity":"blocker","summary":"what must change"}.',
340
+ '- Actor replies to each fixed finding in actor-replies.jsonl: {"finding_id":"C-001","status":"fixed","notes":"what changed"}.',
90
341
  "- Supervisor writes the final decision summary to decisions.md.",
91
342
  ""
92
343
  ].join("\n");
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ const FeatureIdSchema = z.string()
3
+ .trim()
4
+ .regex(/^[a-z0-9][a-z0-9-]{0,31}$/, "Feature ids must use lowercase letters, numbers, and hyphens only");
5
+ const FeatureDefinitionSchema = z.object({
6
+ id: FeatureIdSchema,
7
+ title: z.string().trim().min(1),
8
+ description: z.string().trim().min(1),
9
+ depends_on: z.array(FeatureIdSchema).default([])
10
+ });
11
+ const FeaturePlanSchema = z.object({
12
+ version: z.literal(1),
13
+ features: z.array(FeatureDefinitionSchema).min(1).max(8)
14
+ });
15
+ export function parseFeaturePlan(input) {
16
+ const plan = FeaturePlanSchema.parse(input);
17
+ const ids = new Set();
18
+ for (const feature of plan.features) {
19
+ if (ids.has(feature.id)) {
20
+ throw new Error(`Duplicate feature id: ${feature.id}`);
21
+ }
22
+ ids.add(feature.id);
23
+ }
24
+ for (const feature of plan.features) {
25
+ const dependencies = new Set();
26
+ for (const dependency of feature.depends_on) {
27
+ if (dependency === feature.id) {
28
+ throw new Error(`Feature ${feature.id} cannot depend on itself`);
29
+ }
30
+ if (!ids.has(dependency)) {
31
+ throw new Error(`Feature ${feature.id} depends on unknown feature: ${dependency}`);
32
+ }
33
+ if (dependencies.has(dependency)) {
34
+ throw new Error(`Feature ${feature.id} has duplicate dependency: ${dependency}`);
35
+ }
36
+ dependencies.add(dependency);
37
+ }
38
+ }
39
+ const waves = buildWaves(plan);
40
+ if (waves.flat().length !== plan.features.length) {
41
+ const scheduled = new Set(waves.flat().map((feature) => feature.id));
42
+ const cycle = plan.features
43
+ .filter((feature) => !scheduled.has(feature.id))
44
+ .map((feature) => feature.id)
45
+ .sort();
46
+ throw new Error(`Feature dependency cycle: ${cycle.join(", ")}`);
47
+ }
48
+ return plan;
49
+ }
50
+ export function featureExecutionWaves(plan) {
51
+ return buildWaves(plan);
52
+ }
53
+ function buildWaves(plan) {
54
+ const completed = new Set();
55
+ const remaining = new Set(plan.features.map((feature) => feature.id));
56
+ const waves = [];
57
+ while (remaining.size > 0) {
58
+ const wave = plan.features.filter((feature) => (remaining.has(feature.id)
59
+ && feature.depends_on.every((dependency) => completed.has(dependency))));
60
+ if (wave.length === 0) {
61
+ break;
62
+ }
63
+ waves.push(wave);
64
+ for (const feature of wave) {
65
+ remaining.delete(feature.id);
66
+ completed.add(feature.id);
67
+ }
68
+ }
69
+ return waves;
70
+ }