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.
- package/.parallel-codex/config.example.toml +90 -3
- package/README.md +240 -21
- package/dist/bootstrap.js +37 -17
- package/dist/cli-args.js +34 -3
- 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 +7 -71
- package/dist/cli.js +221 -23
- package/dist/core/collaboration-timeline.js +261 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +154 -24
- package/dist/core/file-store.js +119 -6
- package/dist/core/lease-finalization.js +22 -0
- package/dist/core/paths.js +7 -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 +462 -35
- package/dist/core/session-index.js +188 -37
- package/dist/core/session-manager.js +1086 -40
- package/dist/core/task-state-machine.js +17 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +19 -11
- package/dist/doctor.js +343 -23
- 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 +1749 -202
- 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 +2830 -153
- 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 +13 -1
- 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 +78 -3
- package/dist/workers/process-adapter.js +570 -77
- package/dist/workers/registry.js +4 -2
- package/package.json +5 -2
package/dist/domain/schemas.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|
|
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:
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
89
|
-
|
|
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
|
+
}
|