parallel-codex-tui 0.1.3 → 0.1.5

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