agentxchain 2.129.0 → 2.130.1
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/agentxchain.js +10 -0
- package/package.json +3 -2
- package/scripts/release-preflight.sh +18 -5
- package/src/commands/accept-turn.js +14 -0
- package/src/commands/checkpoint-turn.js +35 -0
- package/src/commands/connector.js +1 -0
- package/src/commands/doctor.js +39 -21
- package/src/commands/mission.js +661 -7
- package/src/commands/reject-turn.js +36 -2
- package/src/commands/restart.js +72 -8
- package/src/commands/run.js +13 -0
- package/src/commands/status.js +13 -1
- package/src/lib/connector-probe.js +42 -27
- package/src/lib/connector-validate.js +21 -0
- package/src/lib/continuous-run.js +8 -1
- package/src/lib/coordinator-dispatch.js +25 -0
- package/src/lib/governed-state.js +152 -2
- package/src/lib/intake.js +12 -0
- package/src/lib/mission-plans.js +510 -6
- package/src/lib/missions.js +9 -2
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/run-events.js +1 -0
- package/src/lib/run-loop.js +20 -0
- package/src/lib/runtime-spawn-context.js +163 -0
- package/src/lib/turn-checkpoint.js +221 -0
package/src/lib/run-loop.js
CHANGED
|
@@ -396,6 +396,16 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
|
|
|
396
396
|
|
|
397
397
|
acceptedCount++;
|
|
398
398
|
history.push({ role: roleId, turn_id: turn.turn_id, accepted: true });
|
|
399
|
+
if (callbacks.afterAccept) {
|
|
400
|
+
const afterAcceptResult = await callbacks.afterAccept({ turn, acceptResult });
|
|
401
|
+
if (afterAcceptResult?.ok === false) {
|
|
402
|
+
errors.push(`afterAccept(${roleId}): ${afterAcceptResult.error}`);
|
|
403
|
+
if (afterAcceptResult.state) {
|
|
404
|
+
emit({ type: 'blocked', state: afterAcceptResult.state, reason: 'after_accept_failed' });
|
|
405
|
+
}
|
|
406
|
+
return { terminal: true, ok: false, stop_reason: 'blocked', history, acceptedCount };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
399
409
|
emit({ type: 'turn_accepted', turn, role: roleId, state: acceptResult.state });
|
|
400
410
|
} else {
|
|
401
411
|
const validationResult = {
|
|
@@ -512,6 +522,16 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
|
|
|
512
522
|
}
|
|
513
523
|
|
|
514
524
|
history.push({ role: roleId, turn_id: turn.turn_id, accepted: true });
|
|
525
|
+
if (callbacks.afterAccept) {
|
|
526
|
+
const afterAcceptResult = await callbacks.afterAccept({ turn, acceptResult });
|
|
527
|
+
if (afterAcceptResult?.ok === false) {
|
|
528
|
+
errors.push(`afterAccept(${roleId}): ${afterAcceptResult.error}`);
|
|
529
|
+
if (afterAcceptResult.state) {
|
|
530
|
+
emit({ type: 'blocked', state: afterAcceptResult.state, reason: 'after_accept_failed' });
|
|
531
|
+
}
|
|
532
|
+
return { terminal: true, ok: false, stop_reason: 'blocked', history };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
515
535
|
emit({ type: 'turn_accepted', turn, role: roleId, state: acceptResult.state });
|
|
516
536
|
return { terminal: false, accepted: true, history };
|
|
517
537
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 500;
|
|
6
|
+
const PROMPT_PLACEHOLDER = 'AgentXchain spawn-context probe';
|
|
7
|
+
|
|
8
|
+
function resolveLocalCliPromptTransport(runtime) {
|
|
9
|
+
const valid = new Set(['argv', 'stdin', 'dispatch_bundle_only']);
|
|
10
|
+
if (valid.has(runtime?.prompt_transport)) {
|
|
11
|
+
return runtime.prompt_transport;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const parts = Array.isArray(runtime?.command)
|
|
15
|
+
? runtime.command
|
|
16
|
+
: [runtime?.command, ...(Array.isArray(runtime?.args) ? runtime.args : [])];
|
|
17
|
+
const hasPrompt = parts.some((part) => typeof part === 'string' && part.includes('{prompt}'));
|
|
18
|
+
return hasPrompt ? 'argv' : 'dispatch_bundle_only';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveLocalCliInvocation(runtime) {
|
|
22
|
+
if (!runtime?.command) {
|
|
23
|
+
return { command: null, args: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const transport = resolveLocalCliPromptTransport(runtime);
|
|
27
|
+
|
|
28
|
+
if (Array.isArray(runtime.command)) {
|
|
29
|
+
const first = runtime.command[0] || '';
|
|
30
|
+
const headParts = typeof first === 'string' && first.includes(' ') ? first.split(/\s+/) : [first];
|
|
31
|
+
const [command, ...headArgs] = headParts;
|
|
32
|
+
const rest = [...headArgs, ...runtime.command.slice(1)];
|
|
33
|
+
const args = transport === 'argv'
|
|
34
|
+
? rest.map((arg) => arg === '{prompt}' ? PROMPT_PLACEHOLDER : arg)
|
|
35
|
+
: rest.filter((arg) => arg !== '{prompt}');
|
|
36
|
+
return { command, args };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const args = transport === 'argv'
|
|
40
|
+
? (runtime.args || []).map((arg) => arg === '{prompt}' ? PROMPT_PLACEHOLDER : arg)
|
|
41
|
+
: (runtime.args || []).filter((arg) => arg !== '{prompt}');
|
|
42
|
+
return { command: runtime.command, args };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveMcpInvocation(runtime) {
|
|
46
|
+
if (!runtime?.command) {
|
|
47
|
+
return { command: null, args: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(runtime.command)) {
|
|
51
|
+
const [command, ...args] = runtime.command;
|
|
52
|
+
return { command, args };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
command: runtime.command,
|
|
57
|
+
args: Array.isArray(runtime.args) ? runtime.args : [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveInvocation(runtime) {
|
|
62
|
+
if (runtime?.type === 'local_cli') {
|
|
63
|
+
return resolveLocalCliInvocation(runtime);
|
|
64
|
+
}
|
|
65
|
+
return resolveMcpInvocation(runtime);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildResolutionFix(command) {
|
|
69
|
+
const commandValue = String(command || '');
|
|
70
|
+
const commandBase = basename(commandValue);
|
|
71
|
+
|
|
72
|
+
if (commandBase === 'codex' || commandBase === 'codex.exe') {
|
|
73
|
+
return 'Set "command" to the absolute path, e.g. "/Applications/Codex.app/Contents/Resources/codex", or add Codex to PATH in the dispatch spawn context.';
|
|
74
|
+
}
|
|
75
|
+
if (commandValue.includes('~')) {
|
|
76
|
+
return 'Expand "~" to an absolute path in "command". Shell expansion does not apply to governed dispatch.';
|
|
77
|
+
}
|
|
78
|
+
return 'Set "command" to an absolute path or add it to PATH in the dispatch spawn context.';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function probeRuntimeSpawnContext(root, runtime, options = {}) {
|
|
82
|
+
const runtimeId = options.runtimeId || null;
|
|
83
|
+
const cwd = runtime?.cwd ? join(root, runtime.cwd) : root;
|
|
84
|
+
const { command, args } = resolveInvocation(runtime);
|
|
85
|
+
|
|
86
|
+
if (!command) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
runtime_id: runtimeId,
|
|
90
|
+
command: null,
|
|
91
|
+
cwd,
|
|
92
|
+
detail: 'No command configured for the dispatch spawn context.',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!existsSync(cwd)) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
runtime_id: runtimeId,
|
|
100
|
+
command,
|
|
101
|
+
cwd,
|
|
102
|
+
detail: `Runtime cwd "${runtime.cwd}" does not exist in the dispatch spawn context.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const probe = spawnSync(command, args, {
|
|
107
|
+
cwd,
|
|
108
|
+
env: { ...process.env, AGENTXCHAIN_SPAWN_PROBE: '1' },
|
|
109
|
+
stdio: 'ignore',
|
|
110
|
+
timeout: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
|
|
111
|
+
windowsHide: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (probe.error) {
|
|
115
|
+
const errorCode = probe.error.code || 'spawn_error';
|
|
116
|
+
if (errorCode === 'ETIMEDOUT') {
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
runtime_id: runtimeId,
|
|
120
|
+
command,
|
|
121
|
+
cwd,
|
|
122
|
+
timed_out: true,
|
|
123
|
+
detail: `"${command}" launched in the dispatch spawn context but exceeded the short probe timeout. Treating this as resolvable.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (errorCode === 'ENOENT') {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
runtime_id: runtimeId,
|
|
130
|
+
command,
|
|
131
|
+
cwd,
|
|
132
|
+
error_code: errorCode,
|
|
133
|
+
detail: `"${command}" is not resolvable in the dispatch spawn context. ${buildResolutionFix(command)}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (errorCode === 'EACCES') {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
runtime_id: runtimeId,
|
|
140
|
+
command,
|
|
141
|
+
cwd,
|
|
142
|
+
error_code: errorCode,
|
|
143
|
+
detail: `"${command}" exists but is not executable in the dispatch spawn context. Mark it executable or point "command" at the real executable path.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
runtime_id: runtimeId,
|
|
149
|
+
command,
|
|
150
|
+
cwd,
|
|
151
|
+
error_code: errorCode,
|
|
152
|
+
detail: `Dispatch spawn probe failed for "${command}": ${probe.error.message}`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
runtime_id: runtimeId,
|
|
159
|
+
command,
|
|
160
|
+
cwd,
|
|
161
|
+
detail: `"${command}" is resolvable in the dispatch spawn context.`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { resolveAcceptedTurnHistoryReference } from './accepted-turn-history.js';
|
|
5
|
+
import { emitRunEvent } from './run-events.js';
|
|
6
|
+
import { safeWriteJson } from './safe-write.js';
|
|
7
|
+
|
|
8
|
+
const STATE_PATH = '.agentxchain/state.json';
|
|
9
|
+
const HISTORY_PATH = '.agentxchain/history.jsonl';
|
|
10
|
+
|
|
11
|
+
function readState(root) {
|
|
12
|
+
const filePath = join(root, STATE_PATH);
|
|
13
|
+
if (!existsSync(filePath)) return null;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeState(root, state) {
|
|
22
|
+
safeWriteJson(join(root, STATE_PATH), state);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readHistoryEntries(root) {
|
|
26
|
+
const filePath = join(root, HISTORY_PATH);
|
|
27
|
+
if (!existsSync(filePath)) return [];
|
|
28
|
+
const raw = readFileSync(filePath, 'utf8').trim();
|
|
29
|
+
if (!raw) return [];
|
|
30
|
+
return raw.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeHistoryEntries(root, entries) {
|
|
34
|
+
const filePath = join(root, HISTORY_PATH);
|
|
35
|
+
const content = entries.map((entry) => JSON.stringify(entry)).join('\n');
|
|
36
|
+
writeFileSync(filePath, content ? `${content}\n` : '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function git(root, args) {
|
|
40
|
+
return execFileSync('git', args, {
|
|
41
|
+
cwd: root,
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
44
|
+
}).trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isGitRepo(root) {
|
|
48
|
+
try {
|
|
49
|
+
return git(root, ['rev-parse', '--is-inside-work-tree']) === 'true';
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeFilesChanged(filesChanged) {
|
|
56
|
+
return [...new Set(
|
|
57
|
+
(Array.isArray(filesChanged) ? filesChanged : [])
|
|
58
|
+
.filter((value) => typeof value === 'string')
|
|
59
|
+
.map((value) => value.trim())
|
|
60
|
+
.filter(Boolean),
|
|
61
|
+
)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractGitError(err) {
|
|
65
|
+
const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
|
|
66
|
+
const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '';
|
|
67
|
+
return stderr || stdout || err?.message || 'git command failed';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildCheckpointCommit(entry) {
|
|
71
|
+
const subject = `checkpoint: ${entry.turn_id} (role=${entry.role}, phase=${entry.phase})`;
|
|
72
|
+
const bodyLines = [
|
|
73
|
+
`Summary: ${entry.summary || '(none)'}`,
|
|
74
|
+
`Turn-ID: ${entry.turn_id}`,
|
|
75
|
+
`Role: ${entry.role || '(unknown)'}`,
|
|
76
|
+
`Phase: ${entry.phase || '(unknown)'}`,
|
|
77
|
+
`Runtime: ${entry.runtime_id || '(unknown)'}`,
|
|
78
|
+
];
|
|
79
|
+
if (entry.intent_id) bodyLines.push(`Intent-ID: ${entry.intent_id}`);
|
|
80
|
+
if (entry.accepted_at) bodyLines.push(`Accepted-At: ${entry.accepted_at}`);
|
|
81
|
+
return { subject, body: bodyLines.join('\n') };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function detectPendingCheckpoint(root, dirtyFiles = []) {
|
|
85
|
+
const actorDirtyFiles = normalizeFilesChanged(dirtyFiles);
|
|
86
|
+
if (actorDirtyFiles.length === 0) return { required: false };
|
|
87
|
+
|
|
88
|
+
const resolved = resolveAcceptedTurnHistoryReference(root, null);
|
|
89
|
+
if (!resolved.ok || !resolved.entry) return { required: false };
|
|
90
|
+
|
|
91
|
+
const entry = resolved.entry;
|
|
92
|
+
if (entry.checkpoint_sha) return { required: false };
|
|
93
|
+
|
|
94
|
+
const turnFiles = normalizeFilesChanged(entry.files_changed);
|
|
95
|
+
if (turnFiles.length === 0) return { required: false };
|
|
96
|
+
|
|
97
|
+
const dirtyOutsideTurn = actorDirtyFiles.filter((file) => !turnFiles.includes(file));
|
|
98
|
+
if (dirtyOutsideTurn.length > 0) return { required: false };
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
required: true,
|
|
102
|
+
turn_id: entry.turn_id,
|
|
103
|
+
message: `Accepted turn ${entry.turn_id} is not checkpointed yet. Run agentxchain checkpoint-turn --turn ${entry.turn_id} before assigning the next code-writing turn.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function checkpointAcceptedTurn(root, opts = {}) {
|
|
108
|
+
if (!isGitRepo(root)) {
|
|
109
|
+
return { ok: false, error: 'checkpoint-turn requires a git repository.' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const resolved = resolveAcceptedTurnHistoryReference(root, opts.turnId || opts.turn || null);
|
|
113
|
+
if (!resolved.ok || !resolved.entry) {
|
|
114
|
+
return { ok: false, error: resolved.error || 'Accepted turn not found.' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const entry = resolved.entry;
|
|
118
|
+
if (entry.checkpoint_sha) {
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
already_checkpointed: true,
|
|
122
|
+
turn: entry,
|
|
123
|
+
checkpoint_sha: entry.checkpoint_sha,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const filesChanged = normalizeFilesChanged(entry.files_changed);
|
|
128
|
+
if (filesChanged.length === 0) {
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
skipped: true,
|
|
132
|
+
turn: entry,
|
|
133
|
+
reason: 'Accepted turn has no writable files_changed paths to checkpoint.',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
git(root, ['add', '-A', '--', ...filesChanged]);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
turn: entry,
|
|
143
|
+
error: `Failed to stage accepted files for checkpoint: ${extractGitError(err)}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let staged = [];
|
|
148
|
+
try {
|
|
149
|
+
staged = git(root, ['diff', '--cached', '--name-only', '--', ...filesChanged])
|
|
150
|
+
.split('\n')
|
|
151
|
+
.map((value) => value.trim())
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
turn: entry,
|
|
157
|
+
error: `Failed to inspect staged checkpoint diff: ${extractGitError(err)}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (staged.length === 0) {
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
skipped: true,
|
|
165
|
+
turn: entry,
|
|
166
|
+
reason: `Accepted turn ${entry.turn_id} has no staged repo changes to checkpoint.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const commit = buildCheckpointCommit(entry);
|
|
171
|
+
try {
|
|
172
|
+
git(root, ['commit', '-m', commit.subject, '-m', commit.body]);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
turn: entry,
|
|
177
|
+
error: `Checkpoint commit failed: ${extractGitError(err)}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const checkpointSha = git(root, ['rev-parse', 'HEAD']);
|
|
182
|
+
const checkpointedAt = new Date().toISOString();
|
|
183
|
+
|
|
184
|
+
const historyEntries = readHistoryEntries(root).map((historyEntry) => (
|
|
185
|
+
historyEntry.turn_id === entry.turn_id
|
|
186
|
+
? { ...historyEntry, checkpoint_sha: checkpointSha, checkpointed_at: checkpointedAt }
|
|
187
|
+
: historyEntry
|
|
188
|
+
));
|
|
189
|
+
writeHistoryEntries(root, historyEntries);
|
|
190
|
+
|
|
191
|
+
const state = readState(root);
|
|
192
|
+
if (state) {
|
|
193
|
+
writeState(root, {
|
|
194
|
+
...state,
|
|
195
|
+
last_completed_turn: {
|
|
196
|
+
turn_id: entry.turn_id,
|
|
197
|
+
role: entry.role || null,
|
|
198
|
+
phase: entry.phase || null,
|
|
199
|
+
checkpoint_sha: checkpointSha,
|
|
200
|
+
checkpointed_at: checkpointedAt,
|
|
201
|
+
intent_id: entry.intent_id || null,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
emitRunEvent(root, 'turn_checkpointed', {
|
|
206
|
+
run_id: state.run_id || null,
|
|
207
|
+
phase: state.phase || null,
|
|
208
|
+
status: state.status || null,
|
|
209
|
+
turn: { turn_id: entry.turn_id, role_id: entry.role || null },
|
|
210
|
+
intent_id: entry.intent_id || null,
|
|
211
|
+
payload: { checkpoint_sha: checkpointSha, checkpointed_at: checkpointedAt },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
ok: true,
|
|
217
|
+
turn: entry,
|
|
218
|
+
checkpoint_sha: checkpointSha,
|
|
219
|
+
checkpointed_at: checkpointedAt,
|
|
220
|
+
};
|
|
221
|
+
}
|