parallel-codex-tui 0.1.4 → 0.1.6
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 +46 -0
- package/README.md +96 -19
- package/dist/cli-startup-preflight.js +18 -0
- package/dist/cli-startup-recovery.js +13 -1
- package/dist/cli.js +19 -7
- package/dist/core/clipboard.js +97 -0
- package/dist/core/collaboration-timeline.js +9 -2
- package/dist/core/config.js +161 -103
- package/dist/core/router.js +1 -1
- package/dist/core/session-index.js +234 -61
- package/dist/core/session-manager.js +25 -1
- package/dist/core/task-session-details.js +175 -0
- package/dist/core/task-state-machine.js +10 -9
- package/dist/doctor.js +58 -39
- package/dist/domain/schemas.js +15 -1
- package/dist/orchestrator/collaboration-channel.js +35 -3
- package/dist/orchestrator/final-acceptance.js +86 -0
- package/dist/orchestrator/orchestrator.js +405 -69
- package/dist/orchestrator/prompts.js +42 -3
- package/dist/orchestrator/workspace-sandbox.js +16 -0
- package/dist/tui/App.js +514 -56
- package/dist/tui/AppShell.js +9 -3
- package/dist/tui/FeatureBoardView.js +7 -2
- package/dist/tui/InputBar.js +87 -15
- package/dist/tui/StatusBar.js +1 -1
- package/dist/tui/StatusDetailView.js +164 -0
- package/dist/tui/TaskSessionDetailView.js +222 -0
- package/dist/tui/TaskSessionsView.js +5 -2
- package/dist/tui/WorkerOutputView.js +6 -1
- package/dist/tui/WorkerOverviewView.js +23 -4
- package/dist/tui/keyboard.js +6 -0
- package/dist/tui/status-line.js +42 -0
- package/dist/version.js +1 -1
- package/dist/workers/capabilities.js +4 -3
- package/dist/workers/live-probe.js +4 -3
- package/dist/workers/mock-adapter.js +37 -5
- package/dist/workers/native-attach.js +32 -17
- package/dist/workers/process-adapter.js +2 -0
- package/dist/workers/provider.js +26 -0
- package/dist/workers/registry.js +12 -22
- package/package.json +7 -1
package/dist/doctor.js
CHANGED
|
@@ -16,6 +16,7 @@ import { formatTuiThemePreview } from "./tui/theme-preview.js";
|
|
|
16
16
|
import { resolveTuiTheme } from "./tui/theme.js";
|
|
17
17
|
import { diagnoseAgentCapabilities } from "./workers/capabilities.js";
|
|
18
18
|
import { runLiveAgentProbes } from "./workers/live-probe.js";
|
|
19
|
+
import { workerProvider } from "./workers/provider.js";
|
|
19
20
|
export async function runDoctor(appRoot, workspaceRoot, env = process.env, options = {}) {
|
|
20
21
|
const lines = ["parallel-codex-tui doctor"];
|
|
21
22
|
let ok = true;
|
|
@@ -63,6 +64,51 @@ export async function runDoctor(appRoot, workspaceRoot, env = process.env, optio
|
|
|
63
64
|
lines.push(`router retry: ${config.router.codex.maxAttempts} attempts; transient only; ${config.router.codex.retryDelayMs}ms backoff (TUI routing; live probe runs once)`);
|
|
64
65
|
lines.push(`router budget: total ${config.router.codex.timeoutMs}ms; follow-up ${config.router.codex.followUpTimeoutMs}ms; first output ${config.router.codex.firstOutputTimeoutMs}ms; idle ${config.router.codex.idleTimeoutMs}ms`);
|
|
65
66
|
}
|
|
67
|
+
const preflight = await runRuntimePreflight(config, preparedWorkspace, env, {
|
|
68
|
+
includeRouter,
|
|
69
|
+
...(options.capabilityRunner ? { capabilityRunner: options.capabilityRunner } : {}),
|
|
70
|
+
...(options.capabilityTimeoutMs ? { capabilityTimeoutMs: options.capabilityTimeoutMs } : {})
|
|
71
|
+
});
|
|
72
|
+
lines.push(...preflight.lines);
|
|
73
|
+
ok = ok && preflight.ok;
|
|
74
|
+
if (options.probeAgents) {
|
|
75
|
+
if (ok) {
|
|
76
|
+
const liveAgents = await runLiveAgentProbes(config, preparedWorkspace, configuredWorkerEngines(config), options.liveAgentProbeOptions);
|
|
77
|
+
lines.push(...liveAgents.lines);
|
|
78
|
+
ok = ok && liveAgents.ok;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push("agent live probe: skipped (preflight failed)");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
lines.push("agent live probe: not run (add --probe-agents; may use model quota)");
|
|
86
|
+
}
|
|
87
|
+
if (options.probeRouter) {
|
|
88
|
+
const probe = await runRouterProbe(config, appRoot, options.routeRunner);
|
|
89
|
+
lines.push(probe.line);
|
|
90
|
+
ok = ok && probe.ok;
|
|
91
|
+
}
|
|
92
|
+
else if (config.router.defaultMode === "auto") {
|
|
93
|
+
lines.push("router live probe: not run (add --probe-router)");
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
ok,
|
|
97
|
+
text: `${lines.join("\n")}\n`
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export async function runRuntimePreflight(config, workspaceRoot, env = process.env, options = {}) {
|
|
101
|
+
const includeRouter = options.includeRouter ?? config.router.defaultMode === "auto";
|
|
102
|
+
const lines = [];
|
|
103
|
+
let ok = true;
|
|
104
|
+
try {
|
|
105
|
+
await access(workspaceRoot, constants.R_OK | constants.W_OK | constants.X_OK);
|
|
106
|
+
lines.push("workspace permissions: ok (read/write/search)");
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
ok = false;
|
|
110
|
+
lines.push(`workspace permissions: denied (${workspaceRoot}; need read/write/search)`);
|
|
111
|
+
}
|
|
66
112
|
const availableCommands = new Set();
|
|
67
113
|
for (const command of configuredCommands(config, includeRouter)) {
|
|
68
114
|
if (await commandExists(command, env)) {
|
|
@@ -92,45 +138,24 @@ export async function runDoctor(appRoot, workspaceRoot, env = process.env, optio
|
|
|
92
138
|
});
|
|
93
139
|
lines.push(...capabilityDiagnostics.lines);
|
|
94
140
|
ok = ok && capabilityDiagnostics.ok;
|
|
95
|
-
const
|
|
141
|
+
const systemProxy = options.systemProxy !== undefined
|
|
142
|
+
? options.systemProxy
|
|
143
|
+
: await detectMacSystemProxy();
|
|
144
|
+
const proxyDiagnostics = await diagnoseProxyEnvironment(config, env, systemProxy, options.proxyConnector ?? canConnectProxy, { includeRouter });
|
|
96
145
|
lines.push(...proxyDiagnostics.lines);
|
|
97
146
|
ok = ok && proxyDiagnostics.ok;
|
|
98
|
-
|
|
99
|
-
if (ok) {
|
|
100
|
-
const liveAgents = await runLiveAgentProbes(config, preparedWorkspace, configuredWorkerEngines(config), options.liveAgentProbeOptions);
|
|
101
|
-
lines.push(...liveAgents.lines);
|
|
102
|
-
ok = ok && liveAgents.ok;
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
lines.push("agent live probe: skipped (preflight failed)");
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
lines.push("agent live probe: not run (add --probe-agents; may use model quota)");
|
|
110
|
-
}
|
|
111
|
-
if (options.probeRouter) {
|
|
112
|
-
const probe = await runRouterProbe(config, appRoot, options.routeRunner);
|
|
113
|
-
lines.push(probe.line);
|
|
114
|
-
ok = ok && probe.ok;
|
|
115
|
-
}
|
|
116
|
-
else if (config.router.defaultMode === "auto") {
|
|
117
|
-
lines.push("router live probe: not run (add --probe-router)");
|
|
118
|
-
}
|
|
119
|
-
return {
|
|
120
|
-
ok,
|
|
121
|
-
text: `${lines.join("\n")}\n`
|
|
122
|
-
};
|
|
147
|
+
return { ok, lines };
|
|
123
148
|
}
|
|
124
149
|
export async function diagnoseProxyEnvironment(config, env, systemProxy, connect = canConnectProxy, options = {}) {
|
|
125
150
|
const contexts = [];
|
|
126
151
|
if (options.includeRouter ?? config.router.defaultMode === "auto") {
|
|
127
152
|
contexts.push({ label: "router proxy", env: config.router.codex.env, table: "router.codex.env" });
|
|
128
153
|
}
|
|
129
|
-
|
|
154
|
+
for (const engine of configuredWorkerEngines(config)) {
|
|
130
155
|
contexts.push({
|
|
131
|
-
label:
|
|
132
|
-
env: config.
|
|
133
|
-
table:
|
|
156
|
+
label: `workers.${engine} proxy`,
|
|
157
|
+
env: workerProvider(config, engine).config.model.env,
|
|
158
|
+
table: `workers.${engine}.model.env`
|
|
134
159
|
});
|
|
135
160
|
}
|
|
136
161
|
const connectionCache = new Map();
|
|
@@ -300,11 +325,8 @@ function themeSummary(effectiveTheme, configTheme, cliTheme) {
|
|
|
300
325
|
}
|
|
301
326
|
return effectiveTheme;
|
|
302
327
|
}
|
|
303
|
-
function
|
|
328
|
+
function configuredWorkerEngines(config) {
|
|
304
329
|
const engines = new Set();
|
|
305
|
-
if (options.includeRouter) {
|
|
306
|
-
engines.add("router-codex");
|
|
307
|
-
}
|
|
308
330
|
if (config.router.defaultMode === "auto") {
|
|
309
331
|
engines.add(config.pairing.main);
|
|
310
332
|
engines.add(config.pairing.judge);
|
|
@@ -319,10 +341,7 @@ function configuredEngines(config, options) {
|
|
|
319
341
|
engines.add(config.pairing.actor);
|
|
320
342
|
engines.add(config.pairing.critic);
|
|
321
343
|
}
|
|
322
|
-
return
|
|
323
|
-
}
|
|
324
|
-
function configuredWorkerEngines(config) {
|
|
325
|
-
return configuredEngines(config, { includeRouter: false }).filter((engine) => engine === "codex" || engine === "claude");
|
|
344
|
+
return [...engines].filter((engine) => engine !== "mock");
|
|
326
345
|
}
|
|
327
346
|
function configuredEnvironmentChecks(config, env, includeRouter) {
|
|
328
347
|
const checks = [];
|
|
@@ -338,7 +357,7 @@ function configuredEnvironmentChecks(config, env, includeRouter) {
|
|
|
338
357
|
}
|
|
339
358
|
}
|
|
340
359
|
for (const engine of configuredWorkerEngines(config)) {
|
|
341
|
-
const worker = engine
|
|
360
|
+
const worker = workerProvider(config, engine).config;
|
|
342
361
|
for (const [key, value] of Object.entries(worker.model.env)) {
|
|
343
362
|
for (const envName of referencedEnvNames(value)) {
|
|
344
363
|
checks.push({
|
package/dist/domain/schemas.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const RouteModeSchema = z.enum(["simple", "complex"]);
|
|
3
|
-
export const EngineNameSchema = z
|
|
3
|
+
export const EngineNameSchema = z
|
|
4
|
+
.string()
|
|
5
|
+
.min(1)
|
|
6
|
+
.max(48)
|
|
7
|
+
.regex(/^[a-z][a-z0-9_]*$/, "Worker id must start with a lowercase letter and contain only lowercase letters, digits or _");
|
|
4
8
|
export const WorkerRoleSchema = z.enum(["main", "judge", "actor", "critic"]);
|
|
5
9
|
export const RouterFailureStageSchema = z.enum([
|
|
6
10
|
"spawn",
|
|
@@ -49,6 +53,7 @@ export const TaskStateSchema = z.enum([
|
|
|
49
53
|
"revision_needed",
|
|
50
54
|
"integrating",
|
|
51
55
|
"verifying",
|
|
56
|
+
"paused",
|
|
52
57
|
"done",
|
|
53
58
|
"failed",
|
|
54
59
|
"cancelled"
|
|
@@ -80,6 +85,7 @@ export const FeatureStateSchema = z.enum([
|
|
|
80
85
|
"revision_needed",
|
|
81
86
|
"integrating",
|
|
82
87
|
"verifying",
|
|
88
|
+
"paused",
|
|
83
89
|
"approved",
|
|
84
90
|
"failed",
|
|
85
91
|
"cancelled"
|
|
@@ -141,6 +147,8 @@ export const WorkerStatusSchema = z.object({
|
|
|
141
147
|
feature_title: z.string().min(1).optional(),
|
|
142
148
|
role: WorkerRoleSchema,
|
|
143
149
|
engine: EngineNameSchema,
|
|
150
|
+
model_name: z.string().optional(),
|
|
151
|
+
model_provider: z.string().optional(),
|
|
144
152
|
state: WorkerStateSchema,
|
|
145
153
|
phase: z.string().min(1),
|
|
146
154
|
last_event_at: z.string().datetime(),
|
|
@@ -157,6 +165,12 @@ export const FeatureStatusSchema = z.object({
|
|
|
157
165
|
state: FeatureStateSchema,
|
|
158
166
|
updated_at: z.string().datetime()
|
|
159
167
|
});
|
|
168
|
+
export const FeatureAssignmentSchema = z.object({
|
|
169
|
+
version: z.literal(1),
|
|
170
|
+
actor_engine: EngineNameSchema,
|
|
171
|
+
critic_engine: EngineNameSchema,
|
|
172
|
+
updated_at: z.string().datetime()
|
|
173
|
+
});
|
|
160
174
|
export const TurnMetaSchema = z.object({
|
|
161
175
|
task_id: TaskIdSchema,
|
|
162
176
|
turn_id: z.string().regex(/^\d{4}$/),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { appendJsonLine, ensureDir, pathExists, readJson, readTextIfExists, writeJson, writeText } from "../core/file-store.js";
|
|
4
|
-
import { FeatureStatusSchema } from "../domain/schemas.js";
|
|
4
|
+
import { FeatureAssignmentSchema, FeatureStatusSchema } from "../domain/schemas.js";
|
|
5
5
|
const CriticFindingRecordSchema = z.object({
|
|
6
6
|
id: z.string().trim().min(1),
|
|
7
7
|
severity: z.string().trim().optional(),
|
|
@@ -52,7 +52,8 @@ export async function createFeatureChannel(input) {
|
|
|
52
52
|
actorRepliesPath: join(dir, "actor-replies.jsonl"),
|
|
53
53
|
criticFindingsPath: join(dir, "critic-findings.jsonl"),
|
|
54
54
|
findingResolutionPath: join(dir, "finding-resolution.json"),
|
|
55
|
-
decisionsPath: join(dir, "decisions.md")
|
|
55
|
+
decisionsPath: join(dir, "decisions.md"),
|
|
56
|
+
assignmentPath: join(dir, "assignment.json")
|
|
56
57
|
};
|
|
57
58
|
await ensureDir(dir);
|
|
58
59
|
await ensureDir(join(input.task.dir, "dialogue"));
|
|
@@ -68,6 +69,9 @@ export async function createFeatureChannel(input) {
|
|
|
68
69
|
await writeText(channel.actorWorklogPath, "");
|
|
69
70
|
await writeText(channel.actorRepliesPath, "");
|
|
70
71
|
await writeText(channel.criticFindingsPath, "");
|
|
72
|
+
if (input.actorEngine && input.criticEngine) {
|
|
73
|
+
await writeFeatureAssignment(channel, input.actorEngine, input.criticEngine);
|
|
74
|
+
}
|
|
71
75
|
await updateFeatureStatus(channel, "created");
|
|
72
76
|
await appendFeatureDialogue(channel, "feature.created", "actor", "Feature mailbox created for the current turn.");
|
|
73
77
|
return channel;
|
|
@@ -84,6 +88,32 @@ async function ensureFeatureChannelFiles(input, channel) {
|
|
|
84
88
|
await writeText(path, initialContent);
|
|
85
89
|
}
|
|
86
90
|
}
|
|
91
|
+
if (!(await pathExists(channel.assignmentPath)) && input.actorEngine && input.criticEngine) {
|
|
92
|
+
await writeFeatureAssignment(channel, input.actorEngine, input.criticEngine);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function readFeatureAssignment(channel, fallback) {
|
|
96
|
+
try {
|
|
97
|
+
return await readJson(channel.assignmentPath, FeatureAssignmentSchema);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return FeatureAssignmentSchema.parse({
|
|
101
|
+
version: 1,
|
|
102
|
+
actor_engine: fallback.actor,
|
|
103
|
+
critic_engine: fallback.critic,
|
|
104
|
+
updated_at: new Date(0).toISOString()
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export async function writeFeatureAssignment(channel, actorEngine, criticEngine) {
|
|
109
|
+
const assignment = FeatureAssignmentSchema.parse({
|
|
110
|
+
version: 1,
|
|
111
|
+
actor_engine: actorEngine,
|
|
112
|
+
critic_engine: criticEngine,
|
|
113
|
+
updated_at: new Date().toISOString()
|
|
114
|
+
});
|
|
115
|
+
await writeJson(channel.assignmentPath, assignment);
|
|
116
|
+
return assignment;
|
|
87
117
|
}
|
|
88
118
|
export function featurePromptContext(channel) {
|
|
89
119
|
return {
|
|
@@ -97,7 +127,8 @@ export function featurePromptContext(channel) {
|
|
|
97
127
|
actorWorklogPath: channel.actorWorklogPath,
|
|
98
128
|
actorRepliesPath: channel.actorRepliesPath,
|
|
99
129
|
criticFindingsPath: channel.criticFindingsPath,
|
|
100
|
-
decisionsPath: channel.decisionsPath
|
|
130
|
+
decisionsPath: channel.decisionsPath,
|
|
131
|
+
assignmentPath: channel.assignmentPath
|
|
101
132
|
};
|
|
102
133
|
}
|
|
103
134
|
export async function updateFeatureStatus(channel, state) {
|
|
@@ -339,6 +370,7 @@ function buildFeatureSpec(input, channel) {
|
|
|
339
370
|
'- Critic writes one JSON object per blocking issue to critic-findings.jsonl: {"id":"C-001","severity":"blocker","summary":"what must change"}.',
|
|
340
371
|
'- Actor replies to each fixed finding in actor-replies.jsonl: {"finding_id":"C-001","status":"fixed","notes":"what changed"}.',
|
|
341
372
|
"- Supervisor writes the final decision summary to decisions.md.",
|
|
373
|
+
"- assignment.json records the Actor and Critic engines selected for retries.",
|
|
342
374
|
""
|
|
343
375
|
].join("\n");
|
|
344
376
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const FinalAcceptanceItemSchema = z.object({
|
|
3
|
+
criterion_id: z.string().regex(/^A-\d{1,4}$/),
|
|
4
|
+
status: z.enum(["passed", "failed"]),
|
|
5
|
+
evidence: z.string().trim().min(1).max(4000)
|
|
6
|
+
}).strict();
|
|
7
|
+
export const FinalJudgeAcceptanceSchema = z.object({
|
|
8
|
+
version: z.literal(1),
|
|
9
|
+
decision: z.enum(["approved", "rejected"]),
|
|
10
|
+
summary: z.string().trim().min(1).max(4000),
|
|
11
|
+
acceptance: z.array(FinalAcceptanceItemSchema).min(1).max(100),
|
|
12
|
+
changed_paths: z.array(z.string().trim().min(1).max(1000)).max(10000)
|
|
13
|
+
}).strict();
|
|
14
|
+
export function validateFinalJudgeAcceptance(input, expectedCriterionIds, expectedChangedPaths) {
|
|
15
|
+
const parsed = FinalJudgeAcceptanceSchema.safeParse(input);
|
|
16
|
+
if (!parsed.success) {
|
|
17
|
+
return {
|
|
18
|
+
acceptance: null,
|
|
19
|
+
report: {
|
|
20
|
+
version: 1,
|
|
21
|
+
state: "invalid",
|
|
22
|
+
decision: "unknown",
|
|
23
|
+
issues: parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const acceptance = parsed.data;
|
|
28
|
+
const issues = [];
|
|
29
|
+
const actualIds = acceptance.acceptance.map((item) => item.criterion_id);
|
|
30
|
+
const duplicateIds = repeated(actualIds);
|
|
31
|
+
if (duplicateIds.length > 0) {
|
|
32
|
+
issues.push(`duplicate acceptance criteria: ${duplicateIds.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
const expectedIds = [...new Set(expectedCriterionIds)].sort();
|
|
35
|
+
const uniqueActualIds = [...new Set(actualIds)].sort();
|
|
36
|
+
const missingIds = expectedIds.filter((id) => !uniqueActualIds.includes(id));
|
|
37
|
+
const unknownIds = uniqueActualIds.filter((id) => !expectedIds.includes(id));
|
|
38
|
+
if (missingIds.length > 0) {
|
|
39
|
+
issues.push(`missing acceptance criteria: ${missingIds.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
if (unknownIds.length > 0) {
|
|
42
|
+
issues.push(`unknown acceptance criteria: ${unknownIds.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
const failedIds = acceptance.acceptance
|
|
45
|
+
.filter((item) => item.status === "failed")
|
|
46
|
+
.map((item) => item.criterion_id);
|
|
47
|
+
if (acceptance.decision === "approved" && failedIds.length > 0) {
|
|
48
|
+
issues.push(`approved decision contains failed criteria: ${failedIds.join(", ")}`);
|
|
49
|
+
}
|
|
50
|
+
if (acceptance.decision === "rejected" && failedIds.length === 0) {
|
|
51
|
+
issues.push("rejected decision must identify at least one failed criterion");
|
|
52
|
+
}
|
|
53
|
+
const expectedPaths = [...new Set(expectedChangedPaths)].sort();
|
|
54
|
+
const actualPaths = [...new Set(acceptance.changed_paths)].sort();
|
|
55
|
+
if (acceptance.changed_paths.length !== actualPaths.length) {
|
|
56
|
+
issues.push(`duplicate changed paths: ${repeated(acceptance.changed_paths).join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
const missingPaths = expectedPaths.filter((path) => !actualPaths.includes(path));
|
|
59
|
+
const unknownPaths = actualPaths.filter((path) => !expectedPaths.includes(path));
|
|
60
|
+
if (missingPaths.length > 0) {
|
|
61
|
+
issues.push(`missing changed paths: ${missingPaths.join(", ")}`);
|
|
62
|
+
}
|
|
63
|
+
if (unknownPaths.length > 0) {
|
|
64
|
+
issues.push(`unknown changed paths: ${unknownPaths.join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
acceptance,
|
|
68
|
+
report: {
|
|
69
|
+
version: 1,
|
|
70
|
+
state: issues.length === 0 ? "valid" : "invalid",
|
|
71
|
+
decision: acceptance.decision,
|
|
72
|
+
issues
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function repeated(values) {
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const duplicates = new Set();
|
|
79
|
+
for (const value of values) {
|
|
80
|
+
if (seen.has(value)) {
|
|
81
|
+
duplicates.add(value);
|
|
82
|
+
}
|
|
83
|
+
seen.add(value);
|
|
84
|
+
}
|
|
85
|
+
return [...duplicates].sort();
|
|
86
|
+
}
|