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