@engineeros/connector 0.4.1 → 0.4.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/README.md +11 -0
- package/bin/engineeros-connector.mjs +33 -9
- package/package.json +1 -1
- package/src/runner.mjs +252 -21
package/README.md
CHANGED
|
@@ -36,3 +36,14 @@ Credentials are stored per workspace under `~/.engineeros/connectors` with owner
|
|
|
36
36
|
Keep the connector online to receive Goals assigned from EngineerOS. Each Goal runs in an isolated worktree below `~/.engineeros/runs`. Cancellation stops Codex. The connector returns changed paths, a bounded diff, and the exact repository ZIP; a human still performs independent attestation.
|
|
37
37
|
|
|
38
38
|
Requirements: Node.js 22 or newer, Git, and an authenticated Codex CLI (`codex login`).
|
|
39
|
+
|
|
40
|
+
## Codex CLI compatibility
|
|
41
|
+
|
|
42
|
+
The connector prints the exact Codex CLI version it will use before connecting. If the
|
|
43
|
+
configured model requires a newer CLI, update Codex and restart the connector:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npm install -g @openai/codex@latest
|
|
47
|
+
codex --version
|
|
48
|
+
npx @engineeros/connector start --workspace .
|
|
49
|
+
```
|
|
@@ -10,9 +10,11 @@ import {
|
|
|
10
10
|
workspaceUrl,
|
|
11
11
|
} from "../src/config.mjs";
|
|
12
12
|
import {
|
|
13
|
+
assessmentProgressMessage,
|
|
13
14
|
executeAssignment,
|
|
14
15
|
executeConnectedPrompt,
|
|
15
16
|
executeWorkspaceAssessment,
|
|
17
|
+
inspectCodexCli,
|
|
16
18
|
stopProcess,
|
|
17
19
|
workspaceSnapshot,
|
|
18
20
|
} from "../src/runner.mjs";
|
|
@@ -73,6 +75,17 @@ if (command === "pair") {
|
|
|
73
75
|
fail("Use `engineeros-connector pair`, `start`, or `status`.");
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
let codexCli;
|
|
79
|
+
try {
|
|
80
|
+
codexCli = await inspectCodexCli(config.workspace);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
83
|
+
}
|
|
84
|
+
console.log(`Using ${codexCli.version}.`);
|
|
85
|
+
if (firstMessage.type === "pair") {
|
|
86
|
+
firstMessage.capabilities.codex_cli_version = codexCli.version;
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
let stopped = false;
|
|
77
90
|
let active = null;
|
|
78
91
|
const available = [];
|
|
@@ -134,7 +147,9 @@ async function connect() {
|
|
|
134
147
|
if (message.type === "workspace.assessment") {
|
|
135
148
|
if (
|
|
136
149
|
active?.runId !== message.assessment_id &&
|
|
137
|
-
!assessments.some(
|
|
150
|
+
!assessments.some(
|
|
151
|
+
(candidate) => candidate.assessment_id === message.assessment_id,
|
|
152
|
+
)
|
|
138
153
|
) {
|
|
139
154
|
assessments.push(message);
|
|
140
155
|
}
|
|
@@ -314,7 +329,9 @@ function pump() {
|
|
|
314
329
|
|
|
315
330
|
async function executePrompt(assignment) {
|
|
316
331
|
const promptId = assignment.prompt_id;
|
|
317
|
-
console.log(
|
|
332
|
+
console.log(
|
|
333
|
+
`Answering ${assignment.purpose || "project"} prompt with Codex CLI.`,
|
|
334
|
+
);
|
|
318
335
|
try {
|
|
319
336
|
const result = await executeConnectedPrompt(assignment, config, {
|
|
320
337
|
onProcess: (child) => {
|
|
@@ -353,8 +370,12 @@ async function executeAssessment(assignment) {
|
|
|
353
370
|
const assessmentId = assignment.assessment_id;
|
|
354
371
|
console.log(`Assessing workspace with Codex CLI (${assessmentId}).`);
|
|
355
372
|
let progress = 10;
|
|
356
|
-
const
|
|
357
|
-
|
|
373
|
+
const reportedMilestones = new Set();
|
|
374
|
+
const reportProgress = (message, { milestone = true } = {}) => {
|
|
375
|
+
if (socket.readyState !== WebSocket.OPEN || active?.runId !== assessmentId)
|
|
376
|
+
return;
|
|
377
|
+
if (milestone && reportedMilestones.has(message)) return;
|
|
378
|
+
if (milestone) reportedMilestones.add(message);
|
|
358
379
|
socket.send(
|
|
359
380
|
JSON.stringify({
|
|
360
381
|
type: "workspace.assessment.progress",
|
|
@@ -364,20 +385,23 @@ async function executeAssessment(assignment) {
|
|
|
364
385
|
}),
|
|
365
386
|
);
|
|
366
387
|
};
|
|
367
|
-
reportProgress("
|
|
388
|
+
reportProgress("Starting read-only repository assessment");
|
|
368
389
|
const heartbeat = setInterval(() => {
|
|
369
390
|
progress = Math.min(90, progress + 5);
|
|
370
|
-
reportProgress("
|
|
391
|
+
reportProgress("Assessment in progress", { milestone: false });
|
|
371
392
|
}, 15_000);
|
|
372
393
|
try {
|
|
394
|
+
if (assignment.assessment_mode === "incremental") {
|
|
395
|
+
progress = 15;
|
|
396
|
+
reportProgress("Calculating changed files and affected evidence");
|
|
397
|
+
}
|
|
373
398
|
const result = await executeWorkspaceAssessment(assignment, config, {
|
|
374
399
|
onProcess: (child) => {
|
|
375
400
|
if (active?.runId === assessmentId) active.child = child;
|
|
376
401
|
},
|
|
377
402
|
onEvent: (event) => {
|
|
378
|
-
const message =
|
|
379
|
-
|
|
380
|
-
reportProgress(message);
|
|
403
|
+
const message = assessmentProgressMessage(event);
|
|
404
|
+
if (message) reportProgress(message);
|
|
381
405
|
},
|
|
382
406
|
});
|
|
383
407
|
const response = await fetch(
|
package/package.json
CHANGED
package/src/runner.mjs
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
stat,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
4
11
|
import os from "node:os";
|
|
5
12
|
import path from "node:path";
|
|
6
13
|
import { promisify } from "node:util";
|
|
@@ -114,17 +121,47 @@ export async function executeAssignment(assignment, config, callbacks) {
|
|
|
114
121
|
};
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
export async function executeWorkspaceAssessment(
|
|
124
|
+
export async function executeWorkspaceAssessment(
|
|
125
|
+
assignment,
|
|
126
|
+
config,
|
|
127
|
+
callbacks,
|
|
128
|
+
) {
|
|
118
129
|
const execution = connectorExecution(assignment);
|
|
119
|
-
const
|
|
130
|
+
const startingRevision = await run(
|
|
120
131
|
"git",
|
|
121
132
|
["rev-parse", "HEAD"],
|
|
122
133
|
config.workspace,
|
|
123
134
|
{ allowFailure: true },
|
|
124
135
|
);
|
|
136
|
+
const startingHead =
|
|
137
|
+
startingRevision.code === 0
|
|
138
|
+
? startingRevision.stdout.trim().slice(0, 128)
|
|
139
|
+
: null;
|
|
140
|
+
if (
|
|
141
|
+
assignment.target_head_revision &&
|
|
142
|
+
startingHead !== assignment.target_head_revision
|
|
143
|
+
) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
"This workspace is no longer at the commit inventoried by EngineerOS. Refresh the workspace inventory before assessing it.",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const changeImpact =
|
|
149
|
+
assignment.assessment_mode === "incremental"
|
|
150
|
+
? await workspaceChangeImpact(
|
|
151
|
+
config.workspace,
|
|
152
|
+
assignment.base_head_revision,
|
|
153
|
+
assignment.target_head_revision,
|
|
154
|
+
)
|
|
155
|
+
: { changedFiles: [], markdown: null };
|
|
156
|
+
const prompt = changeImpact.markdown
|
|
157
|
+
? execution.prompt.replace(
|
|
158
|
+
"<!-- ENGINEEROS_CHANGE_IMPACT -->",
|
|
159
|
+
changeImpact.markdown,
|
|
160
|
+
)
|
|
161
|
+
: execution.prompt;
|
|
125
162
|
const controller = launchCodexProcess(
|
|
126
163
|
config.workspace,
|
|
127
|
-
|
|
164
|
+
prompt,
|
|
128
165
|
execution.sandboxMode,
|
|
129
166
|
callbacks,
|
|
130
167
|
);
|
|
@@ -132,13 +169,115 @@ export async function executeWorkspaceAssessment(assignment, config, callbacks)
|
|
|
132
169
|
const completed = await controller.completed;
|
|
133
170
|
const report = completed.finalMessage.trim();
|
|
134
171
|
if (!report) throw new Error("Codex completed without returning a response.");
|
|
172
|
+
const endingRevision = await run(
|
|
173
|
+
"git",
|
|
174
|
+
["rev-parse", "HEAD"],
|
|
175
|
+
config.workspace,
|
|
176
|
+
{ allowFailure: true },
|
|
177
|
+
);
|
|
178
|
+
const endingHead =
|
|
179
|
+
endingRevision.code === 0
|
|
180
|
+
? endingRevision.stdout.trim().slice(0, 128)
|
|
181
|
+
: null;
|
|
182
|
+
if (startingHead !== endingHead) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
"The Git commit changed during assessment. Refresh the workspace inventory and assess the new commit.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
135
187
|
return {
|
|
136
188
|
report_markdown: report,
|
|
137
|
-
observed_head_revision:
|
|
138
|
-
|
|
189
|
+
observed_head_revision: endingHead,
|
|
190
|
+
changed_files: changeImpact.changedFiles,
|
|
191
|
+
change_impact_markdown: changeImpact.markdown,
|
|
139
192
|
};
|
|
140
193
|
}
|
|
141
194
|
|
|
195
|
+
export async function workspaceChangeImpact(workspace, baseRevision, targetRevision) {
|
|
196
|
+
if (!baseRevision || !targetRevision) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
"Incremental assessment requires both the previously assessed and current Git commits. Run a full assessment instead.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const range = `${baseRevision}..${targetRevision}`;
|
|
202
|
+
const names = await run(
|
|
203
|
+
"git",
|
|
204
|
+
["diff", "--name-status", "--find-renames", range],
|
|
205
|
+
workspace,
|
|
206
|
+
{ allowFailure: true },
|
|
207
|
+
);
|
|
208
|
+
if (names.code !== 0) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"Codex could not compare the assessed and current commits. Fetch the missing Git history or run a full assessment.",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const committedFiles = await run(
|
|
214
|
+
"git",
|
|
215
|
+
["-c", "core.quotepath=false", "diff", "--name-only", range],
|
|
216
|
+
workspace,
|
|
217
|
+
{ allowFailure: true },
|
|
218
|
+
);
|
|
219
|
+
const workingFiles = await run(
|
|
220
|
+
"git",
|
|
221
|
+
["-c", "core.quotepath=false", "ls-files", "--others", "--modified", "--deleted", "--exclude-standard"],
|
|
222
|
+
workspace,
|
|
223
|
+
{ allowFailure: true },
|
|
224
|
+
);
|
|
225
|
+
const status = await run("git", ["status", "--short"], workspace, {
|
|
226
|
+
allowFailure: true,
|
|
227
|
+
});
|
|
228
|
+
const stat = await run(
|
|
229
|
+
"git",
|
|
230
|
+
["diff", "--stat", "--compact-summary", range],
|
|
231
|
+
workspace,
|
|
232
|
+
{ allowFailure: true },
|
|
233
|
+
);
|
|
234
|
+
const allChangedFiles = [...new Set([
|
|
235
|
+
...pathLines(committedFiles.stdout),
|
|
236
|
+
...pathLines(workingFiles.stdout),
|
|
237
|
+
])];
|
|
238
|
+
if (allChangedFiles.length > 500) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`This change affects ${allChangedFiles.length} paths, above the 500-path incremental limit. Run a full reassessment instead.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const changedFiles = allChangedFiles;
|
|
244
|
+
if (!changedFiles.length) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
"The inventoried repository changed but Git reports no assessable paths. Refresh inventory or run a full assessment.",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const lines = [
|
|
250
|
+
`Comparison: \`${range}\``,
|
|
251
|
+
`Changed paths: ${changedFiles.length}`,
|
|
252
|
+
"",
|
|
253
|
+
"### Name status",
|
|
254
|
+
"",
|
|
255
|
+
"```text",
|
|
256
|
+
boundedText(names.stdout, 12_000),
|
|
257
|
+
"```",
|
|
258
|
+
];
|
|
259
|
+
if (status.stdout.trim()) {
|
|
260
|
+
lines.push("", "### Working tree", "", "```text", boundedText(status.stdout, 6_000), "```");
|
|
261
|
+
}
|
|
262
|
+
if (stat.stdout.trim()) {
|
|
263
|
+
lines.push("", "### Diff summary", "", "```text", boundedText(stat.stdout, 6_000), "```");
|
|
264
|
+
}
|
|
265
|
+
return { changedFiles, markdown: lines.join("\n") };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function pathLines(output) {
|
|
269
|
+
return String(output || "")
|
|
270
|
+
.split(/\r?\n/)
|
|
271
|
+
.map((line) => line.trim())
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.map((line) => line.replace(/^"|"$/g, ""));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function boundedText(value, maximum) {
|
|
277
|
+
const text = String(value || "").trim();
|
|
278
|
+
return text.length <= maximum ? text : `${text.slice(0, maximum)}\n... truncated by EngineerOS`;
|
|
279
|
+
}
|
|
280
|
+
|
|
142
281
|
export async function executeConnectedPrompt(assignment, config, callbacks) {
|
|
143
282
|
const execution = connectorExecution(assignment);
|
|
144
283
|
const controller = launchCodexProcess(
|
|
@@ -156,6 +295,83 @@ export async function executeConnectedPrompt(assignment, config, callbacks) {
|
|
|
156
295
|
return { content, model: "codex-cli" };
|
|
157
296
|
}
|
|
158
297
|
|
|
298
|
+
export async function inspectCodexCli(workspace = process.cwd()) {
|
|
299
|
+
const command =
|
|
300
|
+
process.env.CODEX_BIN ||
|
|
301
|
+
(process.platform === "win32" ? "codex.cmd" : "codex");
|
|
302
|
+
const result = await runCodexCommand(command, ["--version"], workspace);
|
|
303
|
+
if (result.code !== 0) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
"Codex CLI is unavailable. Install it with `npm install -g @openai/codex@latest`, run `codex login`, then restart this connector.",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const version = result.stdout.trim().slice(0, 100);
|
|
309
|
+
if (!version) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"Codex CLI returned no version. Reinstall @openai/codex, then restart this connector.",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return { command, version };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function codexFailureMessage(output, code) {
|
|
318
|
+
if (/requires a newer version of Codex/i.test(output)) {
|
|
319
|
+
return (
|
|
320
|
+
"The configured model requires a newer Codex CLI. " +
|
|
321
|
+
"Run `npm install -g @openai/codex@latest`, verify with `codex --version`, " +
|
|
322
|
+
"then restart the EngineerOS connector and retry the assessment."
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (/not logged in|login required|authentication required/i.test(output)) {
|
|
326
|
+
return "Codex CLI is not authenticated. Run `codex login`, then restart the EngineerOS connector.";
|
|
327
|
+
}
|
|
328
|
+
return `Codex exited with code ${code}. ${output.slice(-1_000)}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function assessmentProgressMessage(event) {
|
|
332
|
+
if (!event || typeof event !== "object") return null;
|
|
333
|
+
if (event.type === "turn.started")
|
|
334
|
+
return "Reviewing repository structure and current Git state";
|
|
335
|
+
if (event.type === "item.started") {
|
|
336
|
+
if (event.item?.type === "command_execution") {
|
|
337
|
+
return assessmentCommandMilestone(event.item.command);
|
|
338
|
+
}
|
|
339
|
+
if (event.item?.type === "mcp_tool_call")
|
|
340
|
+
return "Tracing architecture and code relationships";
|
|
341
|
+
if (event.item?.type === "web_search")
|
|
342
|
+
return "Checking an external technical reference";
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message") {
|
|
346
|
+
return "Synthesizing findings and highest-return actions";
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function assessmentCommandMilestone(command) {
|
|
352
|
+
const value = Array.isArray(command)
|
|
353
|
+
? command.join(" ")
|
|
354
|
+
: String(command || "");
|
|
355
|
+
const normalized = value.replace(/\s+/g, " ").trim().toLowerCase();
|
|
356
|
+
if (!normalized) return "Inspecting repository evidence";
|
|
357
|
+
if (/\bgit\s+(status|log|diff|show|rev-parse)\b/.test(normalized)) {
|
|
358
|
+
return "Comparing Git history and workspace changes";
|
|
359
|
+
}
|
|
360
|
+
if (
|
|
361
|
+
/\b(test|pytest|vitest|jest|ruff|eslint|tsc|build|lint)\b/.test(normalized)
|
|
362
|
+
) {
|
|
363
|
+
return "Checking verification and delivery signals";
|
|
364
|
+
}
|
|
365
|
+
if (
|
|
366
|
+
/\b(audit|dependency|dependencies|lockfile|package-lock|pnpm-lock|requirements)\b/.test(
|
|
367
|
+
normalized,
|
|
368
|
+
)
|
|
369
|
+
) {
|
|
370
|
+
return "Reviewing dependencies and security signals";
|
|
371
|
+
}
|
|
372
|
+
return "Tracing architecture and code relationships";
|
|
373
|
+
}
|
|
374
|
+
|
|
159
375
|
export function connectorExecution(assignment) {
|
|
160
376
|
const prompt = assignment?.prompt_markdown;
|
|
161
377
|
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
@@ -292,15 +508,7 @@ function launchCodexProcess(workspace, prompt, sandbox, callbacks) {
|
|
|
292
508
|
const command =
|
|
293
509
|
process.env.CODEX_BIN ||
|
|
294
510
|
(process.platform === "win32" ? "codex.cmd" : "codex");
|
|
295
|
-
const args = [
|
|
296
|
-
"exec",
|
|
297
|
-
"--json",
|
|
298
|
-
"--sandbox",
|
|
299
|
-
sandbox,
|
|
300
|
-
"-C",
|
|
301
|
-
workspace,
|
|
302
|
-
"-",
|
|
303
|
-
];
|
|
511
|
+
const args = ["exec", "--json", "--sandbox", sandbox, "-C", workspace, "-"];
|
|
304
512
|
const child = spawn(command, args, {
|
|
305
513
|
cwd: workspace,
|
|
306
514
|
env: process.env,
|
|
@@ -349,15 +557,31 @@ function launchCodexProcess(workspace, prompt, sandbox, callbacks) {
|
|
|
349
557
|
child.once("error", reject);
|
|
350
558
|
child.once("close", (code) => {
|
|
351
559
|
if (code === 0) resolve({ output: output.slice(-20_000), finalMessage });
|
|
352
|
-
else
|
|
353
|
-
reject(
|
|
354
|
-
new Error(`Codex exited with code ${code}. ${output.slice(-1_000)}`),
|
|
355
|
-
);
|
|
560
|
+
else reject(new Error(codexFailureMessage(output, code)));
|
|
356
561
|
});
|
|
357
562
|
});
|
|
358
563
|
return { child, completed };
|
|
359
564
|
}
|
|
360
565
|
|
|
566
|
+
function runCodexCommand(command, args, cwd) {
|
|
567
|
+
return new Promise((resolve, reject) => {
|
|
568
|
+
const child = spawn(command, args, {
|
|
569
|
+
cwd,
|
|
570
|
+
env: process.env,
|
|
571
|
+
shell: process.platform === "win32",
|
|
572
|
+
windowsHide: true,
|
|
573
|
+
});
|
|
574
|
+
let stdout = "";
|
|
575
|
+
let stderr = "";
|
|
576
|
+
child.stdout.setEncoding("utf8");
|
|
577
|
+
child.stderr.setEncoding("utf8");
|
|
578
|
+
child.stdout.on("data", (chunk) => (stdout += chunk));
|
|
579
|
+
child.stderr.on("data", (chunk) => (stderr += chunk));
|
|
580
|
+
child.once("error", reject);
|
|
581
|
+
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
361
585
|
async function changedFilePaths(workspace) {
|
|
362
586
|
const tracked = await run(
|
|
363
587
|
"git",
|
|
@@ -452,7 +676,12 @@ async function workspaceFiles(root, current = root) {
|
|
|
452
676
|
|
|
453
677
|
function isShareablePath(relative) {
|
|
454
678
|
const normalized = normalizePath(relative);
|
|
455
|
-
if (
|
|
679
|
+
if (
|
|
680
|
+
!normalized ||
|
|
681
|
+
normalized === ".." ||
|
|
682
|
+
normalized.startsWith("../") ||
|
|
683
|
+
path.isAbsolute(normalized)
|
|
684
|
+
) {
|
|
456
685
|
return false;
|
|
457
686
|
}
|
|
458
687
|
const parts = normalized.split("/");
|
|
@@ -482,7 +711,9 @@ function isShareablePath(relative) {
|
|
|
482
711
|
|
|
483
712
|
function isCodeBearing(relative) {
|
|
484
713
|
const name = path.posix.basename(relative).toLowerCase();
|
|
485
|
-
return
|
|
714
|
+
return (
|
|
715
|
+
CODE_MARKERS.has(name) || CODE_EXTENSIONS.has(path.posix.extname(name))
|
|
716
|
+
);
|
|
486
717
|
}
|
|
487
718
|
|
|
488
719
|
async function worktreeBase(workspace, requested) {
|