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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Lexer } from "marked";
|
|
2
|
+
export const JUDGE_REQUIRED_ARTIFACTS = [
|
|
3
|
+
"requirements.md",
|
|
4
|
+
"plan.md",
|
|
5
|
+
"acceptance.md",
|
|
6
|
+
"actor-brief.md",
|
|
7
|
+
"critic-brief.md"
|
|
8
|
+
];
|
|
9
|
+
export const JUDGE_VALIDATION_FILE = "judge-validation.json";
|
|
10
|
+
const CONTRACT_ARTIFACTS = {
|
|
11
|
+
"requirements.md": { prefix: "R", label: "requirement" },
|
|
12
|
+
"plan.md": { prefix: "P", label: "plan step" },
|
|
13
|
+
"acceptance.md": { prefix: "A", label: "acceptance criterion" }
|
|
14
|
+
};
|
|
15
|
+
const PLACEHOLDER_PATTERN = /^(?:todo|tbd|pending|placeholder|to be determined|coming soon|n\/?a|none|待定|稍后(?:补充|处理)?|后续(?:补充|再说)?|暂无|未定|占位(?:内容)?)(?:\s*[::-].*)?$/iu;
|
|
16
|
+
const ARTIFACT_NAME_PATTERN = /^(?:requirements|plan|acceptance|actor-brief|critic-brief)\.md$/iu;
|
|
17
|
+
export function validateJudgeArtifacts(input) {
|
|
18
|
+
const requirements = validateContractArtifact("requirements.md", input["requirements.md"] ?? "");
|
|
19
|
+
const plan = validateContractArtifact("plan.md", input["plan.md"] ?? "");
|
|
20
|
+
const acceptance = validateContractArtifact("acceptance.md", input["acceptance.md"] ?? "");
|
|
21
|
+
const actorBrief = validateBriefArtifact("actor-brief.md", input["actor-brief.md"] ?? "");
|
|
22
|
+
const criticBrief = validateBriefArtifact("critic-brief.md", input["critic-brief.md"] ?? "");
|
|
23
|
+
const requirementIds = new Set(requirements.items.map((item) => item.id));
|
|
24
|
+
for (const item of acceptance.items) {
|
|
25
|
+
const unknown = item.references.filter((reference) => !requirementIds.has(reference));
|
|
26
|
+
if (unknown.length > 0) {
|
|
27
|
+
acceptance.validation.issues.push(issue("acceptance.md", "unknown_requirement_reference", `${item.id} references unknown requirement${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}.`));
|
|
28
|
+
acceptance.validation.state = "invalid";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const artifacts = {
|
|
32
|
+
"requirements.md": requirements.validation,
|
|
33
|
+
"plan.md": plan.validation,
|
|
34
|
+
"acceptance.md": acceptance.validation,
|
|
35
|
+
"actor-brief.md": actorBrief.validation,
|
|
36
|
+
"critic-brief.md": criticBrief.validation
|
|
37
|
+
};
|
|
38
|
+
const issues = JUDGE_REQUIRED_ARTIFACTS.flatMap((file) => artifacts[file].issues);
|
|
39
|
+
return {
|
|
40
|
+
version: 1,
|
|
41
|
+
state: issues.length === 0 ? "valid" : "invalid",
|
|
42
|
+
artifacts,
|
|
43
|
+
contract: {
|
|
44
|
+
requirements: requirements.items,
|
|
45
|
+
plan: plan.items,
|
|
46
|
+
acceptance: acceptance.items
|
|
47
|
+
},
|
|
48
|
+
briefs: {
|
|
49
|
+
actor: actorBrief.content,
|
|
50
|
+
critic: criticBrief.content
|
|
51
|
+
},
|
|
52
|
+
issues
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function validateContractArtifact(file, markdown) {
|
|
56
|
+
const parsed = parseMarkdown(markdown);
|
|
57
|
+
const issues = [];
|
|
58
|
+
const spec = CONTRACT_ARTIFACTS[file];
|
|
59
|
+
if (!markdown.trim()) {
|
|
60
|
+
issues.push(issue(file, "missing", `${file} is missing or empty.`));
|
|
61
|
+
}
|
|
62
|
+
else if (parsed.parseError) {
|
|
63
|
+
issues.push(issue(file, "parse_error", `${file} is not valid Markdown: ${parsed.parseError}`));
|
|
64
|
+
}
|
|
65
|
+
const normalized = parsed.listItems.map((text, index) => normalizeContractItem(text, spec.prefix, index));
|
|
66
|
+
const meaningful = normalized.filter((item) => isMeaningful(item.text));
|
|
67
|
+
const placeholders = normalized.filter((item) => !isMeaningful(item.text));
|
|
68
|
+
if (markdown.trim() && !parsed.parseError && parsed.listItems.length === 0) {
|
|
69
|
+
issues.push(issue(file, "missing_list_items", `${file} must contain at least one Markdown list ${spec.label}.`));
|
|
70
|
+
}
|
|
71
|
+
else if (parsed.listItems.length > 0 && meaningful.length === 0) {
|
|
72
|
+
issues.push(issue(file, "placeholder_only", `${file} contains only placeholder ${spec.label}s.`));
|
|
73
|
+
}
|
|
74
|
+
else if (placeholders.length > 0) {
|
|
75
|
+
issues.push(issue(file, "placeholder_items", `${file} still contains ${placeholders.length} placeholder ${spec.label}${placeholders.length === 1 ? "" : "s"}.`));
|
|
76
|
+
}
|
|
77
|
+
const duplicateIds = repeatedIds(meaningful);
|
|
78
|
+
if (duplicateIds.length > 0) {
|
|
79
|
+
issues.push(issue(file, "duplicate_item_id", `${file} repeats item id${duplicateIds.length === 1 ? "" : "s"}: ${duplicateIds.join(", ")}.`));
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
validation: {
|
|
83
|
+
state: issues.length === 0 ? "valid" : "invalid",
|
|
84
|
+
item_count: parsed.listItems.length,
|
|
85
|
+
content_count: parsed.content.length,
|
|
86
|
+
issues
|
|
87
|
+
},
|
|
88
|
+
items: meaningful
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function validateBriefArtifact(file, markdown) {
|
|
92
|
+
const parsed = parseMarkdown(markdown);
|
|
93
|
+
const issues = [];
|
|
94
|
+
const meaningful = parsed.content.filter(isMeaningful);
|
|
95
|
+
const placeholders = parsed.content.filter((content) => !isMeaningful(content));
|
|
96
|
+
if (!markdown.trim()) {
|
|
97
|
+
issues.push(issue(file, "missing", `${file} is missing or empty.`));
|
|
98
|
+
}
|
|
99
|
+
else if (parsed.parseError) {
|
|
100
|
+
issues.push(issue(file, "parse_error", `${file} is not valid Markdown: ${parsed.parseError}`));
|
|
101
|
+
}
|
|
102
|
+
else if (parsed.content.length === 0) {
|
|
103
|
+
issues.push(issue(file, "missing_content", `${file} must contain implementation guidance below its heading.`));
|
|
104
|
+
}
|
|
105
|
+
else if (meaningful.length === 0) {
|
|
106
|
+
issues.push(issue(file, "placeholder_only", `${file} contains only placeholder guidance.`));
|
|
107
|
+
}
|
|
108
|
+
else if (placeholders.length > 0) {
|
|
109
|
+
issues.push(issue(file, "placeholder_items", `${file} still contains placeholder guidance.`));
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
validation: {
|
|
113
|
+
state: issues.length === 0 ? "valid" : "invalid",
|
|
114
|
+
item_count: parsed.listItems.length,
|
|
115
|
+
content_count: parsed.content.length,
|
|
116
|
+
issues
|
|
117
|
+
},
|
|
118
|
+
content: meaningful
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function parseMarkdown(markdown) {
|
|
122
|
+
if (!markdown.trim()) {
|
|
123
|
+
return { listItems: [], content: [] };
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const tokens = Lexer.lex(markdown);
|
|
127
|
+
return {
|
|
128
|
+
listItems: collectListItems(tokens),
|
|
129
|
+
content: collectContent(tokens)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
listItems: [],
|
|
135
|
+
content: [],
|
|
136
|
+
parseError: error instanceof Error ? error.message : String(error)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function collectListItems(tokens) {
|
|
141
|
+
const items = [];
|
|
142
|
+
for (const token of tokens) {
|
|
143
|
+
if (isListToken(token)) {
|
|
144
|
+
for (const item of token.items) {
|
|
145
|
+
const text = normalizeWhitespace(item.tokens
|
|
146
|
+
.filter((itemToken) => itemToken.type !== "list")
|
|
147
|
+
.map(tokenPlainText)
|
|
148
|
+
.join(" "));
|
|
149
|
+
items.push(text);
|
|
150
|
+
items.push(...collectListItems(item.tokens.filter(isListToken)));
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const nested = tokenTokens(token);
|
|
155
|
+
if (nested.length > 0) {
|
|
156
|
+
items.push(...collectListItems(nested));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return items;
|
|
160
|
+
}
|
|
161
|
+
function collectContent(tokens) {
|
|
162
|
+
const content = [];
|
|
163
|
+
for (const token of tokens) {
|
|
164
|
+
if (token.type === "heading" || token.type === "space" || token.type === "hr" || token.type === "def") {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (isListToken(token)) {
|
|
168
|
+
content.push(...collectListItems([token]));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (token.type === "blockquote") {
|
|
172
|
+
content.push(...collectContent(tokenTokens(token)));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const text = normalizeWhitespace(tokenPlainText(token));
|
|
176
|
+
if (text) {
|
|
177
|
+
content.push(text);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return content;
|
|
181
|
+
}
|
|
182
|
+
function normalizeContractItem(text, prefix, index) {
|
|
183
|
+
const idPattern = new RegExp(`^\\[?(${prefix}-\\d{1,4})\\]?\\s*(?:[::-]\\s*)?`, "iu");
|
|
184
|
+
const match = text.match(idPattern);
|
|
185
|
+
const id = match?.[1]?.toUpperCase() ?? `${prefix}-${String(index + 1).padStart(3, "0")}`;
|
|
186
|
+
const normalizedText = normalizeWhitespace(match ? text.slice(match[0].length) : text);
|
|
187
|
+
const references = Array.from(normalizedText.matchAll(/\bR-\d{1,4}\b/giu), (reference) => reference[0].toUpperCase());
|
|
188
|
+
return {
|
|
189
|
+
id,
|
|
190
|
+
text: normalizedText,
|
|
191
|
+
references: [...new Set(references)]
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function tokenPlainText(token) {
|
|
195
|
+
if (token.type === "checkbox" || token.type === "space" || token.type === "def") {
|
|
196
|
+
return "";
|
|
197
|
+
}
|
|
198
|
+
const nested = tokenTokens(token);
|
|
199
|
+
if (nested.length > 0) {
|
|
200
|
+
return nested.map(tokenPlainText).join(" ");
|
|
201
|
+
}
|
|
202
|
+
if ("text" in token && typeof token.text === "string") {
|
|
203
|
+
return token.text;
|
|
204
|
+
}
|
|
205
|
+
return typeof token.raw === "string" ? token.raw : "";
|
|
206
|
+
}
|
|
207
|
+
function tokenTokens(token) {
|
|
208
|
+
return "tokens" in token && Array.isArray(token.tokens) ? token.tokens : [];
|
|
209
|
+
}
|
|
210
|
+
function isListToken(token) {
|
|
211
|
+
return token.type === "list" && "items" in token && Array.isArray(token.items);
|
|
212
|
+
}
|
|
213
|
+
function normalizeWhitespace(value) {
|
|
214
|
+
return value.replace(/\s+/gu, " ").trim();
|
|
215
|
+
}
|
|
216
|
+
function isMeaningful(value) {
|
|
217
|
+
const normalized = normalizeWhitespace(value).replace(/[.。,,;;::!!??]+$/gu, "").trim();
|
|
218
|
+
return Boolean(normalized)
|
|
219
|
+
&& /[\p{L}\p{N}]/u.test(normalized)
|
|
220
|
+
&& !PLACEHOLDER_PATTERN.test(normalized)
|
|
221
|
+
&& !ARTIFACT_NAME_PATTERN.test(normalized);
|
|
222
|
+
}
|
|
223
|
+
function repeatedIds(items) {
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
const duplicates = new Set();
|
|
226
|
+
for (const item of items) {
|
|
227
|
+
if (seen.has(item.id)) {
|
|
228
|
+
duplicates.add(item.id);
|
|
229
|
+
}
|
|
230
|
+
seen.add(item.id);
|
|
231
|
+
}
|
|
232
|
+
return [...duplicates];
|
|
233
|
+
}
|
|
234
|
+
function issue(file, code, message) {
|
|
235
|
+
return { file, code, message };
|
|
236
|
+
}
|