faberun 0.10.0 → 0.12.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/README.md +6 -0
- package/integrations/claude-code/statusline.sh +31 -2
- package/package.json +2 -2
- package/skills/faberun/references/spec-format.md +9 -0
- package/src/campaign/chain.mjs +2 -1
- package/src/campaign/index.mjs +102 -1
- package/src/campaign/metrics.mjs +2 -2
- package/src/cli/brand.mjs +2 -0
- package/src/cli/campaign-contract.mjs +55 -0
- package/src/cli/campaign.mjs +48 -13
- package/src/cli/init.mjs +8 -2
- package/src/cli/launch.mjs +2 -1
- package/src/cli/plan.mjs +3 -2
- package/src/cli/project.mjs +150 -0
- package/src/cli/skills.mjs +2 -3
- package/src/cli/update.mjs +7 -10
- package/src/cli.mjs +30 -4
- package/src/contract/index.mjs +44 -0
- package/src/contract/task-packet.mjs +12 -3
- package/src/engine/cancel.mjs +13 -0
- package/src/engine/dispatch.mjs +20 -2
- package/src/engine/lifecycle.mjs +38 -61
- package/src/engine/live-preflight.mjs +2 -1
- package/src/engine/notify-queue.mjs +9 -1
- package/src/engine/pricing-seed.json +543 -0
- package/src/engine/pricing-seed.mjs +54 -0
- package/src/engine/process.mjs +8 -1
- package/src/engine/result-file.mjs +2 -1
- package/src/engine/resume.mjs +6 -2
- package/src/engine/run-identity.mjs +9 -2
- package/src/engine/scheduler.mjs +40 -6
- package/src/engine/settle-judge.mjs +141 -0
- package/src/engine/settle.mjs +1 -1
- package/src/harnesses/protocol.mjs +46 -10
- package/src/harnesses/replay/bin.mjs +10 -1
- package/src/host/home.mjs +10 -3
- package/src/host/platform.mjs +111 -0
- package/src/host/preflight.mjs +43 -21
- package/src/host/projects.mjs +155 -0
- package/src/notify/index.mjs +88 -10
- package/src/plan/pipeline.mjs +32 -9
- package/src/plan/repo-facts.mjs +80 -2
- package/src/plan/spec.mjs +2 -1
- package/src/repo/declared-paths.mjs +7 -1
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +2 -1
- package/src/repo/signal.mjs +24 -7
- package/src/repo/source-identity.mjs +7 -0
- package/src/repo/workspace.mjs +12 -2
- package/src/repo/worktree.mjs +89 -22
- package/src/report/next.mjs +66 -6
- package/src/report/progress.mjs +724 -0
- package/src/report/render.mjs +3 -3
- package/src/run/migrate.mjs +278 -0
- package/src/run/paths.mjs +189 -0
- package/src/seat/index.mjs +2 -1
- package/src/web/app.css +170 -0
- package/src/web/app.mjs +690 -0
- package/src/web/index.html +38 -283
- package/src/web/server.mjs +197 -182
package/src/repo/workspace.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { errorCode } from "../util.mjs";
|
|
|
22
22
|
import { execFileSync } from "node:child_process";
|
|
23
23
|
import { fail, isContained } from "../util.mjs";
|
|
24
24
|
import { normalizeManagedSignalBlock } from "./signal-block.mjs";
|
|
25
|
+
import { RUNS_DIR_NAME } from "../run/paths.mjs";
|
|
25
26
|
|
|
26
27
|
/** A `node_modules` directory at any depth, matched as a whole path segment. */
|
|
27
28
|
const NODE_MODULES_SEGMENT = /(?:^|\/)node_modules(?:\/|$)/u;
|
|
@@ -311,7 +312,12 @@ function captureIgnoreSources(root) {
|
|
|
311
312
|
throw fail("snapshot_read_error", `cannot inspect workspace directory ${relativeWorkspacePath(root, directory)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
312
313
|
}
|
|
313
314
|
for (const entry of entries) {
|
|
314
|
-
|
|
315
|
+
// The runner's own state and git's own metadata carry no ignore rule a
|
|
316
|
+
// fingerprint here needs to see: `.runs/` is the runner's scratch tree,
|
|
317
|
+
// walked elsewhere for evidence and never for gitignore files, and
|
|
318
|
+
// `.git` is git's own directory, not the workspace git ignores things
|
|
319
|
+
// in.
|
|
320
|
+
if (entry.name === ".git" || entry.name === RUNS_DIR_NAME) continue;
|
|
315
321
|
// Same exclusions as the entries snapshot (see the task-packet
|
|
316
322
|
// reference): `node_modules` at any depth, and the agent runtimes'
|
|
317
323
|
// scratch state at the root (`.claude`, `.codex`). Ignore files inside
|
|
@@ -466,7 +472,11 @@ function relevantWorkspacePaths(cwd) {
|
|
|
466
472
|
const paths = new Set();
|
|
467
473
|
for (const value of output.toString("utf8").split("\0")) {
|
|
468
474
|
if (!value) continue;
|
|
469
|
-
|
|
475
|
+
// The runner's own scratch tree, excluded for the same reason as in the
|
|
476
|
+
// ignore-source walk above: it is machine-owned state, not evidence of
|
|
477
|
+
// what the worker changed, and letting it into the snapshot would fail a
|
|
478
|
+
// node on the runner's own bookkeeping rather than on the worker's work.
|
|
479
|
+
if (value === RUNS_DIR_NAME || value.startsWith(`${RUNS_DIR_NAME}/`)) continue;
|
|
470
480
|
// Same exclusion as the ignore-source walk above: a repository's
|
|
471
481
|
// gitignore conventionally excludes `node_modules/` as a directory
|
|
472
482
|
// pattern, which does not match the symlink an attempt worktree links it
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { RUNS_DIR_NAME, attemptWorktreePath, candidateWorktreePath } from "../run/paths.mjs";
|
|
4
5
|
|
|
5
6
|
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
6
7
|
|
|
@@ -103,26 +104,11 @@ export function candidateRefName(runId) {
|
|
|
103
104
|
return `refs/faberun/${runId}/candidate`;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
/** @param {string} runDir @param {string} runId @returns {string} */
|
|
107
|
-
function worktreeRoot(runDir, runId) {
|
|
108
|
-
return join(dirname(runDir), "worktrees", runId);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** @param {string} runDir @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
|
|
112
|
-
export function attemptWorktreePath(runDir, runId, nodeId, attempt) {
|
|
113
|
-
return join(worktreeRoot(runDir, runId), `${nodeId}.${attempt}`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
107
|
/** @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
|
|
117
108
|
function attemptBranchName(runId, nodeId, attempt) {
|
|
118
109
|
return `faberun/${runId}/${nodeId}/${attempt}`;
|
|
119
110
|
}
|
|
120
111
|
|
|
121
|
-
/** @param {string} runDir @param {string} runId @returns {string} */
|
|
122
|
-
export function candidateWorktreePath(runDir, runId) {
|
|
123
|
-
return join(worktreeRoot(runDir, runId), ".candidate");
|
|
124
|
-
}
|
|
125
|
-
|
|
126
112
|
/** @param {unknown} error @returns {string} */
|
|
127
113
|
function gitFailureReason(error) {
|
|
128
114
|
const streams = /** @type {{stderr?: unknown, stdout?: unknown}} */ (error ?? {});
|
|
@@ -183,14 +169,16 @@ export function createRunRef(repo, runId, head) {
|
|
|
183
169
|
}
|
|
184
170
|
|
|
185
171
|
/**
|
|
186
|
-
* @param {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, base?: string}} args
|
|
172
|
+
* @param {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, base?: string, declaredReads?: string[]}} args
|
|
187
173
|
* `base` cuts the new branch from a sealed sha instead of the run ref tip —
|
|
188
174
|
* the previous attempt's sealed work, when it left one (TECH-SPEC lean
|
|
189
175
|
* v0.3 section 3 rule 4). Omitted, it falls back to the run ref tip as
|
|
190
|
-
* before.
|
|
176
|
+
* before. `declaredReads` names the packet's readFiles: entries git does
|
|
177
|
+
* not track are carried into the fresh worktree, since a checkout carries
|
|
178
|
+
* only tracked content and the worker reads the packet's reads from here.
|
|
191
179
|
* @returns {AttemptWorktree}
|
|
192
180
|
*/
|
|
193
|
-
export function createAttemptWorktree({ repo, runDir, runId, nodeId, attempt, base }) {
|
|
181
|
+
export function createAttemptWorktree({ repo, runDir, runId, nodeId, attempt, base, declaredReads }) {
|
|
194
182
|
const runRefSha = gitHead(repo, runRefName(runId));
|
|
195
183
|
if (!runRefSha) throw Object.assign(new Error(`integration ref is unavailable for ${runId}`), { code: "run_ref_missing" });
|
|
196
184
|
const path = attemptWorktreePath(runDir, runId, nodeId, attempt);
|
|
@@ -206,6 +194,7 @@ export function createAttemptWorktree({ repo, runDir, runId, nodeId, attempt, ba
|
|
|
206
194
|
runGit(["-C", repo, "worktree", "add", path, "-b", branch, base ?? runRefName(runId)]);
|
|
207
195
|
}
|
|
208
196
|
prepareWorktreeEnvironment(repo, path);
|
|
197
|
+
carryDeclaredReads(repo, path, declaredReads ?? []);
|
|
209
198
|
return { status: "ready", path, branch, commit: gitHead(path), baseSha: base ?? runRefSha };
|
|
210
199
|
}
|
|
211
200
|
|
|
@@ -242,6 +231,67 @@ function isSymlink(path) {
|
|
|
242
231
|
}
|
|
243
232
|
}
|
|
244
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Carry a packet's declared reads that git does not track from the repository
|
|
236
|
+
* checkout into a freshly created attempt worktree. Validation checks
|
|
237
|
+
* readFiles against the repository, but the worker reads them from the
|
|
238
|
+
* worktree, and a worktree carries only tracked content: an ignored or
|
|
239
|
+
* untracked declared read — the plan pipeline's gitignored repo-facts relay is
|
|
240
|
+
* the live case — would be absent from the very packet that names it.
|
|
241
|
+
*
|
|
242
|
+
* A tracked file is never copied, so a dirty working copy cannot leak into a
|
|
243
|
+
* clean attempt: the worktree's own checkout is the only source for those. A
|
|
244
|
+
* path already present in the worktree is left alone, so recreating one never
|
|
245
|
+
* clobbers what an attempt left there.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} repo @param {string} worktree @param {string[]} readFiles @returns {void}
|
|
248
|
+
*/
|
|
249
|
+
function carryDeclaredReads(repo, worktree, readFiles) {
|
|
250
|
+
const repoRoot = resolve(repo);
|
|
251
|
+
const realRepoRoot = realpathSync(repoRoot);
|
|
252
|
+
for (const read of readFiles) {
|
|
253
|
+
if (typeof read !== "string" || !read || isAbsolute(read)) continue;
|
|
254
|
+
const source = resolve(repoRoot, read);
|
|
255
|
+
let real;
|
|
256
|
+
try {
|
|
257
|
+
if (!statSync(source).isFile()) continue;
|
|
258
|
+
real = realpathSync(source);
|
|
259
|
+
} catch {
|
|
260
|
+
// Absent or unreadable — including a broken symlink inside the path:
|
|
261
|
+
// nothing exists to carry, and a declared read may legitimately be
|
|
262
|
+
// produced later or removed since authoring.
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Containment inline: task-packet.mjs owns pathInside privately, and a
|
|
266
|
+
// second top-level body would fail the duplicate-body gate.
|
|
267
|
+
const rel = relative(realRepoRoot, real);
|
|
268
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue;
|
|
269
|
+
if (gitTracks(repo, read)) continue;
|
|
270
|
+
const target = join(worktree, read);
|
|
271
|
+
if (existsSync(target) || isSymlink(target)) continue;
|
|
272
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
273
|
+
// A copy, never a symlink back at the checkout: the attempt's grounding
|
|
274
|
+
// input is frozen at creation, exactly like the tracked content around it.
|
|
275
|
+
writeFileSync(target, readFileSync(source));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Whether git lists the path in the index, i.e. a fresh worktree's checkout
|
|
281
|
+
* already brings it on its own.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} repo @param {string} read @returns {boolean}
|
|
284
|
+
*/
|
|
285
|
+
function gitTracks(repo, read) {
|
|
286
|
+
try {
|
|
287
|
+
return git(repo, ["ls-files", "--", read]).length > 0;
|
|
288
|
+
} catch {
|
|
289
|
+
// git could not answer (locked index, unusable repository): fail toward
|
|
290
|
+
// "tracked", because never copying a tracked file outranks carrying one.
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
245
295
|
/**
|
|
246
296
|
* @param {{repo: string, path: string, baseSha: string|null, runId: string, nodeId: string, attempt: number}} args
|
|
247
297
|
* @returns {SealedAttempt}
|
|
@@ -260,12 +310,12 @@ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
|
|
|
260
310
|
// entry is that link would look dirty, stage it, unstage it, and commit an
|
|
261
311
|
// empty change set — which exits 1 and turns every retry of an
|
|
262
312
|
// already-sealed attempt into a hard failure.
|
|
263
|
-
const dirty = git(path, ["status", "--porcelain=v1", "--", ".",
|
|
313
|
+
const dirty = git(path, ["status", "--porcelain=v1", "--", ".", `:(exclude)${RUNS_DIR_NAME}`, ":(exclude)node_modules"]);
|
|
264
314
|
if (dirty) {
|
|
265
315
|
runGit(["-C", path, "add", "-A", "--", "."]);
|
|
266
316
|
// node_modules is linked into the worktree as a symlink, which `node_modules/`
|
|
267
317
|
// in .gitignore does not match; never let the link into the attempt commit.
|
|
268
|
-
runGit(["-C", path, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--",
|
|
318
|
+
runGit(["-C", path, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", RUNS_DIR_NAME, "node_modules"]);
|
|
269
319
|
runGit([
|
|
270
320
|
"-C", path,
|
|
271
321
|
"-c", "user.email=runner@example.test",
|
|
@@ -339,6 +389,23 @@ export function removeWorktree(repo, path) {
|
|
|
339
389
|
}
|
|
340
390
|
}
|
|
341
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Release one node's attempt worktree and the branch name it was checked out
|
|
394
|
+
* on. Git refuses to delete a branch that is still checked out anywhere, so
|
|
395
|
+
* the worktree goes first; both halves already tolerate an artefact that is
|
|
396
|
+
* already gone, which is what makes calling this on an accepted (worktree
|
|
397
|
+
* already `removed`) or an already-released attempt harmless.
|
|
398
|
+
*
|
|
399
|
+
* @param {string} repo
|
|
400
|
+
* @param {string|null|undefined} path
|
|
401
|
+
* @param {string} branch
|
|
402
|
+
* @returns {void}
|
|
403
|
+
*/
|
|
404
|
+
export function releaseAttemptWorktree(repo, path, branch) {
|
|
405
|
+
removeWorktree(repo, path);
|
|
406
|
+
deleteRef(repo, `refs/heads/${branch}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
342
409
|
/** @param {string} repo @param {string} runDir @param {string} runId @returns {void} */
|
|
343
410
|
export function cleanupCandidate(repo, runDir, runId) {
|
|
344
411
|
removeWorktree(repo, candidateWorktreePath(runDir, runId));
|
package/src/report/next.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* never through `loadRun`/`validateNodeSnapshot`: a torn snapshot is exactly
|
|
15
15
|
* the case this command must survive and report, not crash on.
|
|
16
16
|
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
import { discoverCampaigns } from "../campaign/index.mjs";
|
|
19
20
|
import { readJournalForDedupe } from "../campaign/journal.mjs";
|
|
@@ -23,7 +24,17 @@ import { errorMessage } from "../util.mjs";
|
|
|
23
24
|
|
|
24
25
|
/** @typedef {Record<string, unknown>} JsonObject */
|
|
25
26
|
/** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* `message`, when present, is the newest already-rendered progress line for
|
|
29
|
+
* the campaign (read back from a linked run's own `notify.jsonl`, never
|
|
30
|
+
* re-rendered here). Every per-node/per-run candidate constructor below
|
|
31
|
+
* leaves it unset; `campaignItem` attaches it once, to whichever candidate
|
|
32
|
+
* wins, so there is exactly one place that decides it rather than every
|
|
33
|
+
* constructor repeating the read. It is for the human text only --
|
|
34
|
+
* `renderNextJson` keeps carrying structure, not prose, so it omits this
|
|
35
|
+
* field.
|
|
36
|
+
* @typedef {{campaign: string, rank: number, reason: string, command: string, runnable: boolean, message?: string|null}} NextItem
|
|
37
|
+
*/
|
|
27
38
|
|
|
28
39
|
/** Node states that are still in flight, versus every settled terminal state. */
|
|
29
40
|
const IN_PROGRESS = new Set(["pending", "running"]);
|
|
@@ -49,6 +60,7 @@ export function computeNextItems(runsDir, cwd) {
|
|
|
49
60
|
reason: `campaign ${entry.id} unreadable: ${errorMessage(entry.error)} (${entry.path})`,
|
|
50
61
|
command: "",
|
|
51
62
|
runnable: false,
|
|
63
|
+
message: null,
|
|
52
64
|
});
|
|
53
65
|
}
|
|
54
66
|
for (const entry of campaigns) {
|
|
@@ -139,21 +151,60 @@ function campaignItem(entry, runsDir, cwd) {
|
|
|
139
151
|
}
|
|
140
152
|
}
|
|
141
153
|
|
|
154
|
+
const message = newestCampaignMessage(runsDir, campaign);
|
|
142
155
|
const best = candidates.reduce(
|
|
143
156
|
(found, candidate) => (!found || candidate.rank < found.rank ? candidate : found),
|
|
144
157
|
/** @type {NextItem|null} */ (null),
|
|
145
158
|
);
|
|
146
|
-
if (best) return best;
|
|
147
|
-
if (!inProgress) return closureItem(campaign, entry.path, cwd);
|
|
159
|
+
if (best) return { ...best, message };
|
|
160
|
+
if (!inProgress) return { ...closureItem(campaign, entry.path, cwd), message };
|
|
148
161
|
return {
|
|
149
162
|
campaign: campaign.id,
|
|
150
163
|
rank: 6,
|
|
151
164
|
reason: `run ${liveRunId ?? campaign.linkedRunIds[0] ?? ""} live; nothing to do`,
|
|
152
165
|
command: "",
|
|
153
166
|
runnable: false,
|
|
167
|
+
message,
|
|
154
168
|
};
|
|
155
169
|
}
|
|
156
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The newest already-rendered notification text across every run this
|
|
173
|
+
* campaign links, read straight from each run's own `notify.jsonl` receipts
|
|
174
|
+
* -- the durable, already-rendered text `NotifyQueue.enqueue` wrote, not a
|
|
175
|
+
* fresh render. `next` is a second audience for that same string, the way a
|
|
176
|
+
* bound transport is: reading it back here can never disagree with what the
|
|
177
|
+
* transport already received, because nothing here re-renders it.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} runsDir
|
|
180
|
+
* @param {Campaign} campaign
|
|
181
|
+
* @returns {string|null}
|
|
182
|
+
*/
|
|
183
|
+
function newestCampaignMessage(runsDir, campaign) {
|
|
184
|
+
/** @type {{at: string, summary: string}[]} */
|
|
185
|
+
const receipts = [];
|
|
186
|
+
for (const runId of campaign.linkedRunIds) {
|
|
187
|
+
let text;
|
|
188
|
+
try {
|
|
189
|
+
text = readFileSync(join(runsDir, runId, "notify.jsonl"), "utf8");
|
|
190
|
+
} catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
for (const line of text.split("\n")) {
|
|
194
|
+
if (!line.trim()) continue;
|
|
195
|
+
let entry;
|
|
196
|
+
try {
|
|
197
|
+
entry = JSON.parse(line);
|
|
198
|
+
} catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (entry && typeof entry.summary === "string" && typeof entry.at === "string") receipts.push({ at: entry.at, summary: entry.summary });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const newest = receipts.sort((left, right) => left.at.localeCompare(right.at)).at(-1);
|
|
205
|
+
return newest ? newest.summary : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
157
208
|
/**
|
|
158
209
|
* A live controller is the exact inverse of the stale test: a lock that is
|
|
159
210
|
* absent, `{invalid: true}` (unparsable), stale, or unreadable is not live.
|
|
@@ -363,11 +414,20 @@ function closureItem(campaign, campaignPath, cwd) {
|
|
|
363
414
|
};
|
|
364
415
|
}
|
|
365
416
|
|
|
366
|
-
/**
|
|
417
|
+
/**
|
|
418
|
+
* The item's own line, plus its newest progress message indented on the line
|
|
419
|
+
* after it when one exists -- the same string `next --json` deliberately
|
|
420
|
+
* omits, since prose has no place in that structured payload.
|
|
421
|
+
*
|
|
422
|
+
* @param {NextItem} item
|
|
423
|
+
* @returns {string}
|
|
424
|
+
*/
|
|
367
425
|
function renderLine(item) {
|
|
368
426
|
const prefix = `${item.campaign}: ${item.reason}`;
|
|
369
|
-
|
|
370
|
-
|
|
427
|
+
const line = item.command ? `${prefix} · ${item.command}${item.runnable ? "" : " [template]"}` : prefix;
|
|
428
|
+
if (!item.message) return line;
|
|
429
|
+
const indented = item.message.split("\n").map((part) => ` ${part}`).join("\n");
|
|
430
|
+
return `${line}\n${indented}`;
|
|
371
431
|
}
|
|
372
432
|
|
|
373
433
|
/**
|