@izkac/forgekit 0.1.7 → 0.3.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/forge.mjs +7 -1
- package/package.json +1 -1
- package/scripts/run-tests.mjs +7 -0
- package/src/brief-cli.mjs +62 -0
- package/src/brief.mjs +104 -0
- package/src/brief.test.mjs +186 -0
- package/src/cleanup-sessions.mjs +2 -0
- package/src/e2e.mjs +172 -0
- package/src/fleet.mjs +233 -0
- package/src/fleet.test.mjs +137 -0
- package/src/integrity-check.mjs +5 -3
- package/src/integrity.mjs +310 -20
- package/src/integrity.test.mjs +169 -17
- package/src/lib/fleet.mjs +212 -0
- package/src/lib.mjs +5 -0
- package/src/session-reminder.mjs +12 -1
- package/src/set-phase.mjs +28 -0
- package/src/set-phase.test.mjs +9 -1
- package/vendor/skills/forge/SKILL.md +1 -1
- package/vendor/skills/forge/docs/forge.md +53 -31
- package/vendor/skills/forge/phases/finish.md +1 -1
- package/vendor/skills/forge/phases/implement.md +1 -0
- package/vendor/skills/forge/phases/plan-openspec.md +23 -4
- package/vendor/skills/forge/phases/plan-specs.md +23 -4
- package/vendor/skills/forge/phases/verify.md +7 -3
- package/vendor/skills/forge/references/operator-brief.md +74 -0
- package/vendor/skills/forge/references/runtime-integrity.md +28 -10
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +9 -6
- package/vendor/skills/forge/subagents/task-reviewer-prompt.md +1 -0
package/src/fleet.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fleet control terminal — see and command every forge session on this
|
|
4
|
+
* machine, across projects and engines.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* forge fleet list [--json]
|
|
8
|
+
* forge fleet watch [--interval <sec>]
|
|
9
|
+
* forge fleet view <session> [--transcript [N]]
|
|
10
|
+
* forge fleet send <session>|--all <message...>
|
|
11
|
+
*
|
|
12
|
+
* <session> matches by sessionId, slug, or project name; must be unique.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import {
|
|
18
|
+
PHASE_ORDER,
|
|
19
|
+
listFleet,
|
|
20
|
+
newestTranscript,
|
|
21
|
+
peekInbox,
|
|
22
|
+
queueMessage,
|
|
23
|
+
sessionDirFor,
|
|
24
|
+
} from './lib/fleet.mjs';
|
|
25
|
+
|
|
26
|
+
function usage() {
|
|
27
|
+
process.stderr.write(
|
|
28
|
+
`Usage:
|
|
29
|
+
forge fleet list [--json]
|
|
30
|
+
forge fleet watch [--interval <sec>]
|
|
31
|
+
forge fleet view <session> [--transcript [N]]
|
|
32
|
+
forge fleet send <session>|--all <message...>
|
|
33
|
+
`,
|
|
34
|
+
);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function relTime(iso) {
|
|
39
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
40
|
+
if (Number.isNaN(ms)) return '?';
|
|
41
|
+
const min = Math.floor(ms / 60000);
|
|
42
|
+
if (min < 1) return 'now';
|
|
43
|
+
if (min < 60) return `${min}m`;
|
|
44
|
+
const h = Math.floor(min / 60);
|
|
45
|
+
if (h < 24) return `${h}h`;
|
|
46
|
+
return `${Math.floor(h / 24)}d`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function phaseBar(phase) {
|
|
50
|
+
const idx = PHASE_ORDER.indexOf(phase);
|
|
51
|
+
if (phase === 'skipped') return 'skipped';
|
|
52
|
+
if (idx < 0) return phase;
|
|
53
|
+
const total = PHASE_ORDER.length - 1; // done = full bar
|
|
54
|
+
return `${'█'.repeat(idx)}${'░'.repeat(total - idx)} ${phase}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tasksCell(entry) {
|
|
58
|
+
const total = Number(entry.tasksTotal) || 0;
|
|
59
|
+
if (total === 0) return '—';
|
|
60
|
+
const complete = Number(entry.tasksComplete) || 0;
|
|
61
|
+
const width = 8;
|
|
62
|
+
const filled = Math.round((complete / total) * width);
|
|
63
|
+
return `${'█'.repeat(filled)}${'░'.repeat(width - filled)} ${complete}/${total}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pad(str, len) {
|
|
67
|
+
const s = String(str ?? '');
|
|
68
|
+
return s.length >= len ? s.slice(0, len) : s + ' '.repeat(len - s.length);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderTable(entries) {
|
|
72
|
+
if (entries.length === 0) return 'No fleet sessions. Start one with `forge new <slug>`.\n';
|
|
73
|
+
const header = `${pad('PROJECT', 18)} ${pad('SESSION', 26)} ${pad('ENGINE', 7)} ${pad('PHASE', 18)} ${pad('TASKS', 13)} ${pad('PACE', 9)} ${pad('AGE', 4)} MSGS`;
|
|
74
|
+
const lines = [header, '-'.repeat(header.length)];
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
const pending = e.missing ? 0 : peekInbox(sessionDirFor(e)).length;
|
|
77
|
+
lines.push(
|
|
78
|
+
`${pad(e.projectName, 18)} ${pad(e.slug, 26)} ${pad(e.engine ?? '—', 7)} ${pad(
|
|
79
|
+
e.missing ? 'missing' : phaseBar(e.phase),
|
|
80
|
+
18,
|
|
81
|
+
)} ${pad(tasksCell(e), 13)} ${pad(e.pace ?? '—', 9)} ${pad(relTime(e.updatedAt), 4)} ${
|
|
82
|
+
pending > 0 ? `✉ ${pending}` : ''
|
|
83
|
+
}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return `${lines.join('\n')}\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve a user-supplied needle to exactly one fleet entry. */
|
|
90
|
+
function findEntry(needle) {
|
|
91
|
+
const entries = listFleet();
|
|
92
|
+
const matches = entries.filter(
|
|
93
|
+
(e) =>
|
|
94
|
+
e.sessionId === needle ||
|
|
95
|
+
e.sessionId.includes(needle) ||
|
|
96
|
+
e.slug === needle ||
|
|
97
|
+
e.projectName === needle,
|
|
98
|
+
);
|
|
99
|
+
if (matches.length === 1) return matches[0];
|
|
100
|
+
if (matches.length === 0) {
|
|
101
|
+
process.stderr.write(`No fleet session matches "${needle}". Try: forge fleet list\n`);
|
|
102
|
+
} else {
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
`Ambiguous "${needle}" — matches:\n${matches
|
|
105
|
+
.map((m) => ` ${m.sessionId} (${m.projectName})`)
|
|
106
|
+
.join('\n')}\n`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function cmdList(args) {
|
|
113
|
+
const entries = listFleet();
|
|
114
|
+
if (args.includes('--json')) {
|
|
115
|
+
process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
|
|
116
|
+
} else {
|
|
117
|
+
process.stdout.write(renderTable(entries));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function cmdWatch(args) {
|
|
122
|
+
const i = args.indexOf('--interval');
|
|
123
|
+
const interval = Math.max(1, Number(i >= 0 ? args[i + 1] : 3) || 3) * 1000;
|
|
124
|
+
const render = () => {
|
|
125
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
126
|
+
process.stdout.write(`forge fleet — ${new Date().toLocaleTimeString()} (Ctrl+C to exit)\n\n`);
|
|
127
|
+
process.stdout.write(renderTable(listFleet()));
|
|
128
|
+
};
|
|
129
|
+
render();
|
|
130
|
+
setInterval(render, interval);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* ponytail: transcript view is best-effort — newest jsonl in the project's
|
|
135
|
+
* Claude dir, not a verified session link. Upgrade path: SessionStart hook
|
|
136
|
+
* records claudeSessionId in the registry entry.
|
|
137
|
+
*/
|
|
138
|
+
function tailTranscript(entry, count) {
|
|
139
|
+
const file = newestTranscript(entry.project);
|
|
140
|
+
if (!file) {
|
|
141
|
+
process.stdout.write('No Claude Code transcript found for this project.\n');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write(`Transcript (newest in project): ${file}\n\n`);
|
|
145
|
+
const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
|
|
146
|
+
const turns = [];
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
let rec;
|
|
149
|
+
try {
|
|
150
|
+
rec = JSON.parse(line);
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (rec.type !== 'user' && rec.type !== 'assistant') continue;
|
|
155
|
+
const content = rec.message?.content;
|
|
156
|
+
let text = '';
|
|
157
|
+
if (typeof content === 'string') text = content;
|
|
158
|
+
else if (Array.isArray(content)) {
|
|
159
|
+
text = content
|
|
160
|
+
.map((c) => (c.type === 'text' ? c.text : c.type === 'tool_use' ? `[tool: ${c.name}]` : ''))
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.join(' ');
|
|
163
|
+
}
|
|
164
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
165
|
+
if (text) turns.push(`${rec.type === 'user' ? '>' : '<'} ${text.slice(0, 300)}`);
|
|
166
|
+
}
|
|
167
|
+
process.stdout.write(`${turns.slice(-count).join('\n\n')}\n`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cmdView(args) {
|
|
171
|
+
if (!args[0]) usage();
|
|
172
|
+
const entry = findEntry(args[0]);
|
|
173
|
+
const ti = args.indexOf('--transcript');
|
|
174
|
+
const sessionDir = sessionDirFor(entry);
|
|
175
|
+
|
|
176
|
+
process.stdout.write(`${entry.projectName} · ${entry.slug}\n`);
|
|
177
|
+
process.stdout.write(` Session: ${entry.sessionId}\n`);
|
|
178
|
+
process.stdout.write(` Project: ${entry.project}\n`);
|
|
179
|
+
process.stdout.write(` Engine: ${entry.engine ?? 'unknown'}\n`);
|
|
180
|
+
process.stdout.write(` Phase: ${phaseBar(entry.phase)}\n`);
|
|
181
|
+
process.stdout.write(` Tasks: ${tasksCell(entry)}\n`);
|
|
182
|
+
process.stdout.write(` Pace: ${entry.pace ?? '—'}\n`);
|
|
183
|
+
process.stdout.write(` Change: ${entry.openspecChange ?? '—'} (${entry.planType ?? 'pending'})\n`);
|
|
184
|
+
const rel = relTime(entry.updatedAt);
|
|
185
|
+
process.stdout.write(` Updated: ${entry.updatedAt}${rel === 'now' ? '' : ` (${rel} ago)`}\n`);
|
|
186
|
+
|
|
187
|
+
const pending = entry.missing ? [] : peekInbox(sessionDir);
|
|
188
|
+
if (pending.length > 0) {
|
|
189
|
+
process.stdout.write(`\n Pending fleet messages (${pending.length}):\n`);
|
|
190
|
+
for (const m of pending) process.stdout.write(` • ${m.text.split('\n')[0]}\n`);
|
|
191
|
+
}
|
|
192
|
+
const evidence = path.join(sessionDir, 'verify-evidence.md');
|
|
193
|
+
if (fs.existsSync(evidence)) process.stdout.write(` Evidence: ${evidence}\n`);
|
|
194
|
+
|
|
195
|
+
if (ti >= 0) {
|
|
196
|
+
const count = Number(args[ti + 1]) || 20;
|
|
197
|
+
process.stdout.write('\n');
|
|
198
|
+
tailTranscript(entry, count);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function cmdSend(args) {
|
|
203
|
+
const all = args[0] === '--all';
|
|
204
|
+
const message = (all ? args.slice(1) : args.slice(1)).join(' ').trim();
|
|
205
|
+
if (!args[0] || !message) usage();
|
|
206
|
+
|
|
207
|
+
const targets = all ? listFleet().filter((e) => !e.missing) : [findEntry(args[0])];
|
|
208
|
+
for (const entry of targets) {
|
|
209
|
+
const file = queueMessage(sessionDirFor(entry), message);
|
|
210
|
+
process.stdout.write(
|
|
211
|
+
`Queued for ${entry.projectName}/${entry.slug} → delivered on its next turn (${file})\n`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (targets.length === 0) process.stdout.write('No reachable fleet sessions.\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
218
|
+
switch (cmd) {
|
|
219
|
+
case 'list':
|
|
220
|
+
cmdList(rest);
|
|
221
|
+
break;
|
|
222
|
+
case 'watch':
|
|
223
|
+
cmdWatch(rest);
|
|
224
|
+
break;
|
|
225
|
+
case 'view':
|
|
226
|
+
cmdView(rest);
|
|
227
|
+
break;
|
|
228
|
+
case 'send':
|
|
229
|
+
cmdSend(rest);
|
|
230
|
+
break;
|
|
231
|
+
default:
|
|
232
|
+
usage();
|
|
233
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
drainInbox,
|
|
10
|
+
entryFile,
|
|
11
|
+
listFleet,
|
|
12
|
+
peekInbox,
|
|
13
|
+
queueMessage,
|
|
14
|
+
registerSession,
|
|
15
|
+
sanitizePath,
|
|
16
|
+
unregisterSession,
|
|
17
|
+
} from './lib/fleet.mjs';
|
|
18
|
+
import { saveSession } from './lib.mjs';
|
|
19
|
+
|
|
20
|
+
const FLEET_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fleet.mjs');
|
|
21
|
+
|
|
22
|
+
function tmp(prefix) {
|
|
23
|
+
return fs.mkdtempSync(path.join(tmpdir(), prefix));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeSession(id, extra = {}) {
|
|
27
|
+
const now = new Date().toISOString();
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
slug: 'fixture',
|
|
31
|
+
createdAt: now,
|
|
32
|
+
updatedAt: now,
|
|
33
|
+
phase: 'implement',
|
|
34
|
+
planType: 'specs',
|
|
35
|
+
openspecChange: 'my-change',
|
|
36
|
+
tasksTotal: 10,
|
|
37
|
+
tasksComplete: 4,
|
|
38
|
+
pace: 'auto',
|
|
39
|
+
resolvedPace: 'standard',
|
|
40
|
+
...extra,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Scratch project with a session dir so listFleet keeps the entry. */
|
|
45
|
+
function makeProject(root, sessionId) {
|
|
46
|
+
const sessionDir = path.join(root, '.forge', 'sessions', sessionId);
|
|
47
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
48
|
+
return sessionDir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('register / list / unregister roundtrip', () => {
|
|
52
|
+
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-reg-'), 'sessions');
|
|
53
|
+
const project = tmp('fleet-proj-');
|
|
54
|
+
makeProject(project, 's1');
|
|
55
|
+
|
|
56
|
+
registerSession(project, makeSession('s1'));
|
|
57
|
+
const entries = listFleet();
|
|
58
|
+
assert.equal(entries.length, 1);
|
|
59
|
+
assert.equal(entries[0].sessionId, 's1');
|
|
60
|
+
assert.equal(entries[0].project, project);
|
|
61
|
+
assert.equal(entries[0].projectName, path.basename(project));
|
|
62
|
+
assert.equal(entries[0].phase, 'implement');
|
|
63
|
+
assert.equal(entries[0].tasksTotal, 10);
|
|
64
|
+
assert.equal(entries[0].tasksComplete, 4);
|
|
65
|
+
assert.equal(entries[0].pace, 'standard');
|
|
66
|
+
assert.equal(entries[0].missing, false);
|
|
67
|
+
|
|
68
|
+
unregisterSession(project, 's1');
|
|
69
|
+
assert.equal(listFleet().length, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('listFleet self-heals entries whose session dir is gone', () => {
|
|
73
|
+
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-heal-'), 'sessions');
|
|
74
|
+
const project = tmp('fleet-proj-');
|
|
75
|
+
registerSession(project, makeSession('gone')); // no session dir created
|
|
76
|
+
assert.equal(listFleet().length, 0);
|
|
77
|
+
assert.equal(fs.existsSync(entryFile(project, 'gone')), false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('saveSession mirrors into the fleet registry', () => {
|
|
81
|
+
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-mirror-'), 'sessions');
|
|
82
|
+
const project = tmp('fleet-proj-');
|
|
83
|
+
const sessionDir = makeProject(project, 's2');
|
|
84
|
+
|
|
85
|
+
saveSession(sessionDir, makeSession('s2', { phase: 'review' }));
|
|
86
|
+
const entries = listFleet();
|
|
87
|
+
assert.equal(entries.length, 1);
|
|
88
|
+
assert.equal(entries[0].sessionId, 's2');
|
|
89
|
+
assert.equal(entries[0].phase, 'review');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('queue → peek → drain delivers each message exactly once', () => {
|
|
93
|
+
const sessionDir = makeProject(tmp('fleet-proj-'), 's3');
|
|
94
|
+
queueMessage(sessionDir, 'pause and report status');
|
|
95
|
+
assert.equal(peekInbox(sessionDir).length, 1);
|
|
96
|
+
|
|
97
|
+
const drained = drainInbox(sessionDir);
|
|
98
|
+
assert.equal(drained.length, 1);
|
|
99
|
+
assert.equal(drained[0].text, 'pause and report status');
|
|
100
|
+
assert.equal(drainInbox(sessionDir).length, 0);
|
|
101
|
+
assert.equal(peekInbox(sessionDir).length, 0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('fleet CLI: send queues, list --json reports pending-capable entries', () => {
|
|
105
|
+
const fleetDir = path.join(tmp('fleet-cli-'), 'sessions');
|
|
106
|
+
const project = tmp('fleet-proj-');
|
|
107
|
+
const sessionDir = makeProject(project, 's4');
|
|
108
|
+
const env = { ...process.env, FORGEKIT_FLEET_DIR: fleetDir };
|
|
109
|
+
|
|
110
|
+
registerSessionIn(fleetDir, project, makeSession('s4'));
|
|
111
|
+
|
|
112
|
+
const listOut = execFileSync(process.execPath, [FLEET_SCRIPT, 'list', '--json'], { env });
|
|
113
|
+
const parsed = JSON.parse(listOut.toString());
|
|
114
|
+
assert.equal(parsed.length, 1);
|
|
115
|
+
assert.equal(parsed[0].sessionId, 's4');
|
|
116
|
+
|
|
117
|
+
execFileSync(process.execPath, [FLEET_SCRIPT, 'send', 's4', 'ship', 'it'], { env });
|
|
118
|
+
const pending = peekInbox(sessionDir);
|
|
119
|
+
assert.equal(pending.length, 1);
|
|
120
|
+
assert.equal(pending[0].text, 'ship it');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** register via a temp override without clobbering this process's env-based dir */
|
|
124
|
+
function registerSessionIn(fleetDir, project, session) {
|
|
125
|
+
const prev = process.env.FORGEKIT_FLEET_DIR;
|
|
126
|
+
process.env.FORGEKIT_FLEET_DIR = fleetDir;
|
|
127
|
+
try {
|
|
128
|
+
registerSession(project, session);
|
|
129
|
+
} finally {
|
|
130
|
+
if (prev === undefined) delete process.env.FORGEKIT_FLEET_DIR;
|
|
131
|
+
else process.env.FORGEKIT_FLEET_DIR = prev;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test('sanitizePath matches Claude Code project-dir naming', () => {
|
|
136
|
+
assert.equal(sanitizePath('S:\\Projects\\forgekit'), 'S--Projects-forgekit');
|
|
137
|
+
});
|
package/src/integrity-check.mjs
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Fails (exit 1) when:
|
|
9
9
|
* - deferrals are unresolved
|
|
10
|
-
* - spine.json is
|
|
11
|
-
* - a spine with rows exists but
|
|
12
|
-
*
|
|
10
|
+
* - spine.json is missing or invalid
|
|
11
|
+
* - a spine with rows exists but the executable E2E acceptance is missing,
|
|
12
|
+
* failed, or stale (e2e.json + green, current e2e-results.json), or
|
|
13
|
+
* verify-evidence.md contains an explicit BLOCKED marker
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { loadSession, readActive } from './lib.mjs';
|
|
@@ -49,6 +50,7 @@ process.stdout.write(
|
|
|
49
50
|
problems: result.problems,
|
|
50
51
|
spineFile: result.spineFile,
|
|
51
52
|
spineExists: result.spineExists,
|
|
53
|
+
e2eFile: result.e2eFile,
|
|
52
54
|
},
|
|
53
55
|
null,
|
|
54
56
|
2,
|