iriai-build 0.1.0
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/bin/iriai-build.js +78 -0
- package/bridge-v3.js +98 -0
- package/cli/bootstrap.js +83 -0
- package/cli/commands/implementation.js +64 -0
- package/cli/commands/index.js +46 -0
- package/cli/commands/launch.js +153 -0
- package/cli/commands/plan.js +117 -0
- package/cli/commands/setup.js +80 -0
- package/cli/commands/slack.js +97 -0
- package/cli/commands/transfer.js +111 -0
- package/cli/config.js +92 -0
- package/cli/display.js +121 -0
- package/cli/terminal-input.js +666 -0
- package/cli/wait.js +82 -0
- package/index.js +1488 -0
- package/lib/agent-process.js +170 -0
- package/lib/bridge-state.js +126 -0
- package/lib/constants.js +137 -0
- package/lib/health-monitor.js +113 -0
- package/lib/prompt-builder.js +565 -0
- package/lib/signal-watcher.js +215 -0
- package/lib/slack-helpers.js +224 -0
- package/lib/state-machines/feature-lead.js +408 -0
- package/lib/state-machines/operator-agent.js +173 -0
- package/lib/state-machines/planning-role.js +161 -0
- package/lib/state-machines/role-agent.js +186 -0
- package/lib/state-machines/team-orchestrator.js +160 -0
- package/package.json +31 -0
- package/v3/.handover-html-evidence.md +35 -0
- package/v3/KICKOFF-HTML-EVIDENCE.md +98 -0
- package/v3/PLAN-HTML-EVIDENCE-HARDENING.md +603 -0
- package/v3/adapters/desktop-adapter.js +78 -0
- package/v3/adapters/interface.js +146 -0
- package/v3/adapters/slack-adapter.js +608 -0
- package/v3/adapters/slack-helpers.js +179 -0
- package/v3/adapters/terminal-adapter.js +249 -0
- package/v3/agent-supervisor.js +320 -0
- package/v3/artifact-portal.js +1184 -0
- package/v3/bridge.db +0 -0
- package/v3/constants.js +170 -0
- package/v3/db.js +76 -0
- package/v3/file-io.js +216 -0
- package/v3/helpers.js +174 -0
- package/v3/operator.js +364 -0
- package/v3/orchestrator.js +2886 -0
- package/v3/plan-compiler.js +440 -0
- package/v3/prompt-builder.js +849 -0
- package/v3/queries.js +461 -0
- package/v3/recovery.js +508 -0
- package/v3/review-sessions.js +360 -0
- package/v3/roles/accessibility-auditor/CLAUDE.md +50 -0
- package/v3/roles/analytics-engineer/CLAUDE.md +40 -0
- package/v3/roles/architect/CLAUDE.md +809 -0
- package/v3/roles/backend-implementer/CLAUDE.md +97 -0
- package/v3/roles/code-reviewer/CLAUDE.md +89 -0
- package/v3/roles/database-implementer/CLAUDE.md +97 -0
- package/v3/roles/deployer/CLAUDE.md +42 -0
- package/v3/roles/designer/CLAUDE.md +386 -0
- package/v3/roles/documentation/CLAUDE.md +40 -0
- package/v3/roles/feature-lead/CLAUDE.md +233 -0
- package/v3/roles/frontend-implementer/CLAUDE.md +97 -0
- package/v3/roles/implementer/CLAUDE.md +97 -0
- package/v3/roles/integration-tester/CLAUDE.md +174 -0
- package/v3/roles/observability-engineer/CLAUDE.md +40 -0
- package/v3/roles/operator/CLAUDE.md +322 -0
- package/v3/roles/orchestrator/CLAUDE.md +288 -0
- package/v3/roles/package-implementer/CLAUDE.md +47 -0
- package/v3/roles/performance-analyst/CLAUDE.md +49 -0
- package/v3/roles/plan-compiler/CLAUDE.md +163 -0
- package/v3/roles/planning-lead/CLAUDE.md +41 -0
- package/v3/roles/pm/CLAUDE.md +806 -0
- package/v3/roles/regression-tester/CLAUDE.md +135 -0
- package/v3/roles/release-manager/CLAUDE.md +43 -0
- package/v3/roles/security-auditor/CLAUDE.md +90 -0
- package/v3/roles/smoke-tester/CLAUDE.md +97 -0
- package/v3/roles/test-author/CLAUDE.md +42 -0
- package/v3/roles/verifier/CLAUDE.md +90 -0
- package/v3/schema.sql +134 -0
- package/v3/slack-adapter.js +510 -0
- package/v3/slack-helpers.js +346 -0
package/v3/operator.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// operator.js — Ephemeral operator: context assembly from SQLite, spawn, parse response.
|
|
2
|
+
// Operator is a stateless function: (event, context) → formatted_response.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { getClaudeBin } from "../cli/config.js";
|
|
8
|
+
import * as queries from "./queries.js";
|
|
9
|
+
import { buildOperatorPrompt, buildOperatorRelayPrompt } from "./prompt-builder.js";
|
|
10
|
+
import {
|
|
11
|
+
HISTORY_CHAR_LIMIT, HISTORY_RECENT_EVENTS, HISTORY_FULL_BACKUP_EVENTS,
|
|
12
|
+
} from "./constants.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Invoke the Operator for a given event. Assembles context from SQLite,
|
|
16
|
+
* spawns a one-shot claude CLI, and lets the response flow through the
|
|
17
|
+
* normal file-based signal path (.agent-response → fileIO → slack).
|
|
18
|
+
*
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {object} opts.feature - Feature row from SQLite
|
|
21
|
+
* @param {string} opts.operatorDir - Operator signal directory
|
|
22
|
+
* @param {string} opts.flDir - Feature Lead signal directory
|
|
23
|
+
* @param {string} opts.featureDir - Feature signal tree root
|
|
24
|
+
* @param {string} opts.userMessage - The triggering event/message
|
|
25
|
+
* @param {AgentSupervisor} opts.supervisor - Agent supervisor instance
|
|
26
|
+
* @param {number} opts.agentId - Operator agent DB id
|
|
27
|
+
*/
|
|
28
|
+
export async function invokeOperator({ feature, operatorDir, flDir, featureDir, userMessage, supervisor, agentId, planDir, activePlanningRole }) {
|
|
29
|
+
// 1. Assemble context from SQLite (async — Haiku summarization is non-blocking)
|
|
30
|
+
const history = await assembleHistory(feature.id);
|
|
31
|
+
const activeAgents = assembleActiveAgents(feature.id);
|
|
32
|
+
const pendingDecision = assemblePendingDecision(feature.id);
|
|
33
|
+
|
|
34
|
+
const HOME = process.env.HOME;
|
|
35
|
+
const directoryMap = `${HOME}/src/iriai/DIRECTORY_MAP.MD`;
|
|
36
|
+
|
|
37
|
+
// 2. Build prompt
|
|
38
|
+
const prompt = buildOperatorPrompt({
|
|
39
|
+
featureName: feature.slug,
|
|
40
|
+
operatorDir,
|
|
41
|
+
flDir,
|
|
42
|
+
featureDir,
|
|
43
|
+
history,
|
|
44
|
+
userMessage,
|
|
45
|
+
activeAgents,
|
|
46
|
+
pendingDecision,
|
|
47
|
+
planDir,
|
|
48
|
+
activePlanningRole,
|
|
49
|
+
directoryMap,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// 3. Spawn claude via supervisor with --continue for session context
|
|
53
|
+
// The supervisor handles PID tracking, exit detection, .runner.log
|
|
54
|
+
// Response flows through .agent-response → fileIO → slack adapter
|
|
55
|
+
supervisor.spawn(agentId, prompt, { continue: true });
|
|
56
|
+
|
|
57
|
+
// Record the event
|
|
58
|
+
queries.insertEvent(feature.id, "user-message", "bridge", `Operator invoked for: ${userMessage.slice(0, 100)}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Invoke the Operator in relay mode — format another agent's output for Slack.
|
|
63
|
+
* Returns a promise that the caller manages (wait for operatorResponse or timeout).
|
|
64
|
+
*
|
|
65
|
+
* @param {object} opts
|
|
66
|
+
* @param {object} opts.feature - Feature row from SQLite
|
|
67
|
+
* @param {object} opts.queueEntry - Relay queue entry { id, source_agent, event_hint, raw_content }
|
|
68
|
+
* @param {AgentSupervisor} opts.supervisor - Agent supervisor instance
|
|
69
|
+
* @param {number} opts.agentId - Operator agent DB id
|
|
70
|
+
* @param {string} opts.operatorDir - Operator signal directory
|
|
71
|
+
* @param {string} opts.featureDir - Feature signal tree root
|
|
72
|
+
*/
|
|
73
|
+
export async function invokeOperatorRelay({ feature, queueEntry, supervisor, agentId, operatorDir, featureDir, continue: cont = false }) {
|
|
74
|
+
const history = await assembleHistory(feature.id);
|
|
75
|
+
const activeAgents = assembleActiveAgents(feature.id);
|
|
76
|
+
const pendingDecision = assemblePendingDecision(feature.id);
|
|
77
|
+
|
|
78
|
+
const prompt = buildOperatorRelayPrompt({
|
|
79
|
+
featureName: feature.slug,
|
|
80
|
+
operatorDir,
|
|
81
|
+
featureDir,
|
|
82
|
+
history,
|
|
83
|
+
activeAgents,
|
|
84
|
+
pendingDecision,
|
|
85
|
+
sourceAgent: queueEntry.source_agent,
|
|
86
|
+
eventHint: queueEntry.event_hint,
|
|
87
|
+
rawContent: queueEntry.raw_content,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
supervisor.spawn(agentId, prompt, { continue: cont });
|
|
91
|
+
queries.insertEvent(feature.id, "system", "bridge",
|
|
92
|
+
`Operator relay invoked for ${queueEntry.source_agent} (${queueEntry.event_hint})`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format a single event into a log line.
|
|
97
|
+
*/
|
|
98
|
+
function formatEvent(e) {
|
|
99
|
+
const time = e.created_at ? e.created_at.split(" ")[1] || "" : "";
|
|
100
|
+
const source = e.source || "unknown";
|
|
101
|
+
const type = e.event_type;
|
|
102
|
+
const content = (e.content || "").slice(0, 500);
|
|
103
|
+
|
|
104
|
+
switch (type) {
|
|
105
|
+
case "user-message":
|
|
106
|
+
return `[${time}] ${source}: ${content}`;
|
|
107
|
+
case "agent-response":
|
|
108
|
+
return `[${time}] ${source}: ${content}`;
|
|
109
|
+
case "operator-response":
|
|
110
|
+
return `[${time}] operator: ${content}`;
|
|
111
|
+
case "phase-transition":
|
|
112
|
+
return `[${time}] SYSTEM: Phase transition — ${content}`;
|
|
113
|
+
case "gate-approved":
|
|
114
|
+
return `[${time}] SYSTEM: Gate approved`;
|
|
115
|
+
case "gate-rejected":
|
|
116
|
+
return `[${time}] SYSTEM: Gate rejected — ${content}`;
|
|
117
|
+
case "agent-crashed":
|
|
118
|
+
return `[${time}] SYSTEM: Agent crashed — ${content}`;
|
|
119
|
+
case "question":
|
|
120
|
+
return `[${time}] ${source} QUESTION: ${content}`;
|
|
121
|
+
case "system":
|
|
122
|
+
return `[${time}] SYSTEM: ${content}`;
|
|
123
|
+
default:
|
|
124
|
+
return `[${time}] [${type}] ${source}: ${content}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Assemble conversation history from SQLite events.
|
|
130
|
+
*
|
|
131
|
+
* Strategy: keep recent events verbatim, summarize older events via Haiku
|
|
132
|
+
* if total history exceeds HISTORY_CHAR_LIMIT. Full history is always
|
|
133
|
+
* saved to disk as a backup.
|
|
134
|
+
*/
|
|
135
|
+
export async function assembleHistory(featureId) {
|
|
136
|
+
const allEvents = queries.getRecentEvents(featureId, HISTORY_FULL_BACKUP_EVENTS);
|
|
137
|
+
if (!allEvents.length) return "(no prior conversation)";
|
|
138
|
+
|
|
139
|
+
// Reverse to chronological order (DB returns DESC)
|
|
140
|
+
const chronological = allEvents.reverse();
|
|
141
|
+
const allLines = chronological.map(formatEvent);
|
|
142
|
+
const fullHistory = allLines.join("\n");
|
|
143
|
+
|
|
144
|
+
// Save full backup to disk
|
|
145
|
+
saveFullHistoryBackup(featureId, fullHistory);
|
|
146
|
+
|
|
147
|
+
// If within limit, return as-is
|
|
148
|
+
if (fullHistory.length <= HISTORY_CHAR_LIMIT) {
|
|
149
|
+
return fullHistory;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Split: recent events stay verbatim, older ones get summarized
|
|
153
|
+
const recentCount = Math.min(HISTORY_RECENT_EVENTS, chronological.length);
|
|
154
|
+
const olderLines = allLines.slice(0, -recentCount);
|
|
155
|
+
const recentLines = allLines.slice(-recentCount);
|
|
156
|
+
|
|
157
|
+
const recentText = recentLines.join("\n");
|
|
158
|
+
const olderText = olderLines.join("\n");
|
|
159
|
+
|
|
160
|
+
// Summarize older events via Haiku (async — doesn't block event loop)
|
|
161
|
+
const summary = await summarizeViaHaiku(olderText);
|
|
162
|
+
|
|
163
|
+
return `### Earlier Context (summarized)\n${summary}\n\n### Recent Events\n${recentText}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Save full history to disk as backup.
|
|
168
|
+
*/
|
|
169
|
+
function saveFullHistoryBackup(featureId, fullHistory) {
|
|
170
|
+
try {
|
|
171
|
+
const feature = queries.getFeatureById(featureId);
|
|
172
|
+
if (!feature) return;
|
|
173
|
+
const backupDir = path.join(
|
|
174
|
+
process.env.HOME, "src/iriai/.implementation/features", feature.slug, "operator"
|
|
175
|
+
);
|
|
176
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
177
|
+
fs.writeFileSync(path.join(backupDir, ".history-full.log"), fullHistory);
|
|
178
|
+
} catch { /* non-critical */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Summarize older history via a one-shot Haiku call (async).
|
|
183
|
+
* Falls back to simple truncation if Haiku fails.
|
|
184
|
+
*/
|
|
185
|
+
function summarizeViaHaiku(olderText) {
|
|
186
|
+
const prompt = `Summarize the following conversation history between a user and AI agents working on a software feature. Preserve:
|
|
187
|
+
- All decisions made (user choices, architectural decisions)
|
|
188
|
+
- Current phase and what's been completed
|
|
189
|
+
- Any pending questions or blockers
|
|
190
|
+
- Key context needed for continuity
|
|
191
|
+
|
|
192
|
+
Be concise — under 800 words. Use bullet points.
|
|
193
|
+
|
|
194
|
+
--- HISTORY ---
|
|
195
|
+
${olderText}
|
|
196
|
+
--- END HISTORY ---`;
|
|
197
|
+
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
const child = execFile(getClaudeBin(), [
|
|
200
|
+
"--dangerously-skip-permissions",
|
|
201
|
+
"--model", "haiku",
|
|
202
|
+
"-p", prompt,
|
|
203
|
+
], {
|
|
204
|
+
timeout: 30_000,
|
|
205
|
+
encoding: "utf-8",
|
|
206
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
207
|
+
}, (err, stdout) => {
|
|
208
|
+
if (!err && stdout.trim().length > 0) {
|
|
209
|
+
resolve(stdout.trim());
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (err) console.error("[operator] Haiku summarization failed:", err.message);
|
|
213
|
+
// Fallback: truncate older history to fit
|
|
214
|
+
const budget = HISTORY_CHAR_LIMIT - 2000;
|
|
215
|
+
resolve(olderText.slice(-budget) + "\n[...earlier history truncated]");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Assemble a summary of active agents.
|
|
222
|
+
*/
|
|
223
|
+
export function assembleActiveAgents(featureId) {
|
|
224
|
+
const agents = queries.getRunningAgents(featureId);
|
|
225
|
+
if (!agents.length) return "(no agents running)";
|
|
226
|
+
|
|
227
|
+
return agents.map(a => {
|
|
228
|
+
const elapsed = a.started_at
|
|
229
|
+
? Math.round((Date.now() - new Date(a.started_at + "Z").getTime()) / 60000)
|
|
230
|
+
: "?";
|
|
231
|
+
return `- ${a.agent_key} (${a.agent_type}, ${a.status}, ${elapsed}min)`;
|
|
232
|
+
}).join("\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Assemble pending decision info.
|
|
237
|
+
*/
|
|
238
|
+
export function assemblePendingDecision(featureId) {
|
|
239
|
+
const decision = queries.getPendingDecision(featureId);
|
|
240
|
+
if (!decision) return null;
|
|
241
|
+
|
|
242
|
+
const options = JSON.parse(decision.options || "[]");
|
|
243
|
+
const optionsList = options.map(o => ` - ${o.label}${o.description ? `: ${o.description}` : ""}`).join("\n");
|
|
244
|
+
|
|
245
|
+
return `PENDING DECISION: ${decision.title}
|
|
246
|
+
Type: ${decision.decision_type}
|
|
247
|
+
Context: ${decision.context_text || "(none)"}
|
|
248
|
+
Options:
|
|
249
|
+
${optionsList}
|
|
250
|
+
Permalink: ${decision.permalink || "(not yet posted)"}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parse Operator response for structured blocks.
|
|
255
|
+
* Extracts [DECISION], [ROUTE:agent_key], and [RESOLVE_DECISION] blocks.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} responseText - Raw operator response
|
|
258
|
+
* @returns {{ plainText: string, decisions: Array, routes: Array, resolutions: Array }}
|
|
259
|
+
*/
|
|
260
|
+
export function parseOperatorResponse(responseText) {
|
|
261
|
+
const decisions = [];
|
|
262
|
+
const routes = [];
|
|
263
|
+
const resolutions = [];
|
|
264
|
+
|
|
265
|
+
// Extract [RESOLVE_DECISION] blocks
|
|
266
|
+
let text = responseText.replace(
|
|
267
|
+
/\[RESOLVE_DECISION\]([\s\S]*?)\[\/RESOLVE_DECISION\]/gi,
|
|
268
|
+
(match, content) => {
|
|
269
|
+
const resolution = parseResolutionBlock(content.trim());
|
|
270
|
+
if (resolution) resolutions.push(resolution);
|
|
271
|
+
return "";
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// Extract [DECISION] blocks (also accepts legacy [SLACK:decision] for backward compatibility)
|
|
276
|
+
text = text.replace(
|
|
277
|
+
/\[(?:DECISION|SLACK:decision)\]([\s\S]*?)\[\/(?:DECISION|SLACK:decision)\]/gi,
|
|
278
|
+
(match, content) => {
|
|
279
|
+
const decision = parseDecisionBlock(content.trim());
|
|
280
|
+
if (decision) decisions.push(decision);
|
|
281
|
+
return "";
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Extract [ROUTE:agent_key] blocks
|
|
286
|
+
text = text.replace(
|
|
287
|
+
/\[ROUTE:([^\]]+)\]\s*([\s\S]*?)(?=\[ROUTE:|$)/gi,
|
|
288
|
+
(match, agentKey, content) => {
|
|
289
|
+
routes.push({ agentKey: agentKey.trim(), content: content.trim() });
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
plainText: text.trim(),
|
|
296
|
+
decisions,
|
|
297
|
+
routes,
|
|
298
|
+
resolutions,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Parse a [RESOLVE_DECISION] block.
|
|
304
|
+
* Format:
|
|
305
|
+
* id: <decision-id>
|
|
306
|
+
* option: <selected-option-id>
|
|
307
|
+
* feedback: <optional feedback text>
|
|
308
|
+
*/
|
|
309
|
+
function parseResolutionBlock(content) {
|
|
310
|
+
const resolution = {};
|
|
311
|
+
for (const line of content.split("\n")) {
|
|
312
|
+
const trimmed = line.trim();
|
|
313
|
+
if (!trimmed) continue;
|
|
314
|
+
if (trimmed.startsWith("id:")) {
|
|
315
|
+
resolution.id = trimmed.replace("id:", "").trim();
|
|
316
|
+
} else if (trimmed.startsWith("option:")) {
|
|
317
|
+
resolution.option = trimmed.replace("option:", "").trim();
|
|
318
|
+
} else if (trimmed.startsWith("feedback:")) {
|
|
319
|
+
resolution.feedback = trimmed.replace("feedback:", "").trim();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return resolution.id && resolution.option ? resolution : null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Parse a decision block's YAML-like content.
|
|
327
|
+
*/
|
|
328
|
+
function parseDecisionBlock(content) {
|
|
329
|
+
const lines = content.split("\n");
|
|
330
|
+
const decision = { options: [] };
|
|
331
|
+
|
|
332
|
+
let inOptions = false;
|
|
333
|
+
|
|
334
|
+
for (const line of lines) {
|
|
335
|
+
const trimmed = line.trim();
|
|
336
|
+
if (!trimmed) continue;
|
|
337
|
+
|
|
338
|
+
if (trimmed.startsWith("id:")) {
|
|
339
|
+
decision.id = trimmed.replace("id:", "").trim();
|
|
340
|
+
} else if (trimmed.startsWith("type:")) {
|
|
341
|
+
decision.type = trimmed.replace("type:", "").trim();
|
|
342
|
+
} else if (trimmed.startsWith("title:")) {
|
|
343
|
+
decision.title = trimmed.replace("title:", "").trim();
|
|
344
|
+
} else if (trimmed.startsWith("context:")) {
|
|
345
|
+
decision.context = trimmed.replace("context:", "").trim();
|
|
346
|
+
} else if (trimmed.startsWith("options:")) {
|
|
347
|
+
inOptions = true;
|
|
348
|
+
} else if (inOptions && trimmed.startsWith("-")) {
|
|
349
|
+
const optLine = trimmed.replace(/^-\s*/, "");
|
|
350
|
+
const opt = {};
|
|
351
|
+
for (const part of optLine.split(",")) {
|
|
352
|
+
const [key, ...val] = part.split(":");
|
|
353
|
+
if (key && val.length) {
|
|
354
|
+
opt[key.trim()] = val.join(":").trim();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (opt.id && opt.label) {
|
|
358
|
+
decision.options.push(opt);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return decision.id && decision.title ? decision : null;
|
|
364
|
+
}
|