passalong 0.20.0 → 1.0.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/src/store.js CHANGED
@@ -1,9 +1,9 @@
1
- // The local store: ~/.passalong (override with PASSALONG_HOME). Guides are one markdown file each so
1
+ // The local store: ~/.passalong (override with PASSALONG_HOME). Work are one markdown file each so
2
2
  // the whole thing is greppable, git-friendly, and there is nothing to export that isn't
3
3
  // already a file you own.
4
4
  //
5
5
  // ~/.passalong/config.json api url + token
6
- // ~/.passalong/guides/<id>.md
6
+ // ~/.passalong/work/<id>.md
7
7
  // ~/.passalong/drafts/*.md captured but not yet shared
8
8
  import {
9
9
  existsSync,
@@ -11,21 +11,20 @@ import {
11
11
  readdirSync,
12
12
  readFileSync,
13
13
  renameSync,
14
- statSync,
15
14
  unlinkSync,
16
15
  writeFileSync,
17
16
  } from "node:fs";
18
17
  import { homedir } from "node:os";
19
18
  import { join } from "node:path";
20
- import { parse, serialize } from "./guide.js";
19
+ import { parse, serialize } from "./work.js";
21
20
 
22
21
  export const HOME = process.env.PASSALONG_HOME || join(homedir(), ".passalong");
23
- export const GUIDES = join(HOME, "guides");
22
+ export const WORKS = join(HOME, "work");
24
23
  export const DRAFTS = join(HOME, "drafts");
25
24
  const CONFIG = join(HOME, "config.json");
26
25
 
27
26
  function ensure() {
28
- for (const d of [HOME, GUIDES, DRAFTS])
27
+ for (const d of [HOME, WORKS, DRAFTS])
29
28
  if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o700 });
30
29
  }
31
30
 
@@ -146,46 +145,37 @@ export function removeAccount(name) {
146
145
  return { removed: true, active: "" };
147
146
  }
148
147
 
149
- export function guidePath(id) {
150
- return join(GUIDES, `${id}.md`);
148
+ export function workPath(id) {
149
+ return join(WORKS, `${id}.md`);
151
150
  }
152
151
 
153
152
  export function get(id) {
154
- const p = guidePath(id);
153
+ const p = workPath(id);
155
154
  if (!existsSync(p)) return null;
156
155
  return parse(readFileSync(p, "utf8"));
157
156
  }
158
157
 
159
- export function save(guide) {
158
+ export function save(work) {
160
159
  ensure();
161
- writeFileSync(guidePath(guide.meta.id), serialize(guide));
162
- return guidePath(guide.meta.id);
160
+ writeFileSync(workPath(work.meta.id), serialize(work));
161
+ return workPath(work.meta.id);
163
162
  }
164
163
 
165
164
  export function remove(id) {
166
- const p = guidePath(id);
165
+ const p = workPath(id);
167
166
  if (existsSync(p)) unlinkSync(p);
168
167
  }
169
168
 
170
- /** All local guides, newest first. */
169
+ /** All local work, newest first. */
171
170
  export function list() {
172
171
  ensure();
173
- return readdirSync(GUIDES)
172
+ return readdirSync(WORKS)
174
173
  .filter((f) => f.endsWith(".md"))
175
- .map((f) => parse(readFileSync(join(GUIDES, f), "utf8")))
174
+ .map((f) => parse(readFileSync(join(WORKS, f), "utf8")))
176
175
  .filter((g) => g.meta.id)
177
176
  .sort((a, b) => (b.meta.created || "").localeCompare(a.meta.created || ""));
178
177
  }
179
178
 
180
- /** Draft files, newest first. */
181
- export function drafts() {
182
- ensure();
183
- return readdirSync(DRAFTS)
184
- .filter((f) => f.endsWith(".md"))
185
- .map((f) => join(DRAFTS, f))
186
- .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
187
- }
188
-
189
179
  export function draftPath(name) {
190
180
  ensure();
191
181
  return join(DRAFTS, `${name}.md`);
@@ -196,11 +186,11 @@ export function removeDraft(path) {
196
186
  }
197
187
 
198
188
  /** Case-insensitive match on title, tags, source context, and body. */
199
- export function search(query, guides = list()) {
189
+ export function search(query, work = list()) {
200
190
  const q = query.trim().toLowerCase();
201
- if (!q) return guides;
191
+ if (!q) return work;
202
192
  const terms = q.split(/\s+/);
203
- return guides.filter((g) => {
193
+ return work.filter((g) => {
204
194
  const hay = [
205
195
  g.meta.title,
206
196
  g.meta.source_context,
package/src/work.js ADDED
@@ -0,0 +1,416 @@
1
+ // The work format: plain markdown with a small YAML-ish frontmatter block.
2
+ //
3
+ // No proprietary format and no YAML library. The frontmatter only ever holds strings and
4
+ // lists of strings, so a deliberately tiny parser covers it and stays readable when hand-edited.
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ /**
8
+ * Work is Draft, Ready or Done. Done is the shelf — off the board, out of the free tier's count,
9
+ * reversible. Mirrors STATUSES in apps/api/src/work.ts.
10
+ */
11
+ export const STATUSES = ["draft", "ready", "done"];
12
+ /**
13
+ * What work says to a person, in a sentence or two, and the longest that may be.
14
+ *
15
+ * Everything an agent writes is written for another agent: exact, dense, full of ids and paths. A
16
+ * person scanning their work wants "what is this, and do I need to do anything", so every piece of work
17
+ * carries both — the document is the agent's form, and `summary:` is the human one, shown by default
18
+ * in the hub with the document behind "Show details".
19
+ *
20
+ * Said by the author and refused when absent: a summary written afterwards by something that was
21
+ * not there is a second voice that can say what nobody claimed.
22
+ */
23
+ export const SUMMARY_MAX = 400;
24
+
25
+ // Fields that hold a list of strings. Everything else is a plain string.
26
+ /**
27
+ * One tag, in the one style tags are written in: lowercase, words joined by a hyphen.
28
+ *
29
+ * Nothing used to normalise them, so `custom-fields` and `additional_information` could sit on the
30
+ * same work and the same idea exist twice under two spellings. A tag is a controlled vocabulary,
31
+ * not prose — its whole value is that two people who mean the same thing write the same string.
32
+ * Mirrors `tag()` in apps/api/src/work.ts.
33
+ */
34
+ export function tag(raw) {
35
+ return String(raw ?? "")
36
+ .trim()
37
+ .toLowerCase()
38
+ .replace(/[\s_]+/g, "-")
39
+ .replace(/[^a-z0-9-]/g, "")
40
+ .replace(/-{2,}/g, "-")
41
+ .slice(0, 32)
42
+ .replace(/^-+|-+$/g, "");
43
+ }
44
+
45
+ /** Work's tags: normalised, emptied of blanks, deduped once two spellings become one. */
46
+ export function tagList(raw) {
47
+ const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
48
+ return [...new Set(list.map(tag).filter(Boolean))];
49
+ }
50
+
51
+ const LIST_FIELDS = new Set(["stack_assumptions", "tags", "blocked_by"]);
52
+
53
+ // IDs are short enough to type and say out loud. 8 chars from a 31-letter alphabet with the
54
+ // look-alikes removed (0/o, 1/l/i) is ~40 bits: plenty for addressing, not a secret.
55
+ const ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
56
+ export function newId(length = 8) {
57
+ const bytes = randomBytes(length);
58
+ let out = "";
59
+ for (let i = 0; i < length; i++) out += ID_ALPHABET[bytes[i] % ID_ALPHABET.length];
60
+ return out;
61
+ }
62
+ export const ID_RE = /^[a-z0-9]{6,12}$/;
63
+
64
+ function scalar(raw) {
65
+ const v = raw.trim();
66
+ if (v === "") return "";
67
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
68
+ return v.slice(1, -1).replace(/\\"/g, '"');
69
+ }
70
+ return v;
71
+ }
72
+
73
+ /**
74
+ * Split a flow sequence's inner text on the commas *between* items, not the ones inside them.
75
+ * `quote()` wraps any value containing a comma, so a plain `split(",")` tears those items apart —
76
+ * and the pieces keep their stray quotes, because `scalar()` only unwraps a value quoted at both
77
+ * ends. Serialising a list and parsing it back has to return the same list.
78
+ */
79
+ function items(inner) {
80
+ const out = [];
81
+ let buf = "";
82
+ let quoted = null;
83
+ for (let i = 0; i < inner.length; i++) {
84
+ const c = inner[i];
85
+ if (quoted) {
86
+ // `quote()` escapes an inner double quote as \", which does not close the value.
87
+ if (c === "\\" && quoted === '"' && inner[i + 1] === '"') {
88
+ buf += c + inner[i + 1];
89
+ i++;
90
+ continue;
91
+ }
92
+ buf += c;
93
+ if (c === quoted) quoted = null;
94
+ continue;
95
+ }
96
+ if (c === '"' || c === "'") quoted = c;
97
+ if (c === ",") {
98
+ out.push(buf);
99
+ buf = "";
100
+ continue;
101
+ }
102
+ buf += c;
103
+ }
104
+ out.push(buf);
105
+ return out;
106
+ }
107
+
108
+ function inlineList(raw) {
109
+ const inner = raw.trim().slice(1, -1).trim();
110
+ if (inner === "") return [];
111
+ return items(inner)
112
+ .map(scalar)
113
+ .filter((s) => s !== "");
114
+ }
115
+
116
+ /** Parse a frontmatter block (the text between the --- fences) into an object. */
117
+ export function parseFrontmatter(text) {
118
+ const meta = {};
119
+ const lines = text.split(/\r?\n/);
120
+ let listKey = null;
121
+ for (const line of lines) {
122
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
123
+ // YAML lets a block sequence sit flush with its key, so the indent is optional. Requiring it
124
+ // dropped `- item` lines silently: they match no key either, and the field stayed empty.
125
+ const item = /^\s*-\s*(.*)$/.exec(line);
126
+ if (item && listKey) {
127
+ meta[listKey].push(scalar(item[1]));
128
+ continue;
129
+ }
130
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
131
+ if (!kv) continue;
132
+ const [, key, rest] = kv;
133
+ if (rest.trim() === "" && LIST_FIELDS.has(key)) {
134
+ meta[key] = [];
135
+ listKey = key;
136
+ } else if (rest.trim().startsWith("[") && rest.trim().endsWith("]")) {
137
+ meta[key] = inlineList(rest);
138
+ listKey = null;
139
+ } else {
140
+ meta[key] = LIST_FIELDS.has(key) ? [scalar(rest)] : scalar(rest);
141
+ listKey = null;
142
+ }
143
+ }
144
+ // Only the lists every piece of work has. `blocked_by` is optional, and defaulting it would write
145
+ // an empty one into every document anybody re-shares.
146
+ for (const k of ["stack_assumptions", "tags"]) if (meta[k] === undefined) meta[k] = [];
147
+ // Both ends of this module: what it reads and what it writes are in one style, so work
148
+ // written before there was a rule comes back normalised and goes out normalised.
149
+ meta.tags = tagList(meta.tags);
150
+ return meta;
151
+ }
152
+
153
+ /**
154
+ * Frontmatter fields holding more than this format can: a nested map, a block scalar (`|`), a
155
+ * sequence under a field that is not a list. `parseFrontmatter` keeps only the key's own line, so
156
+ * each of these would come back as `""` with its content gone — a design.md's `colors:` block did
157
+ * exactly that, and a publish would have stored the document with every token wiped. The format
158
+ * does not grow to hold them (strings and string lists only); the publish refuses them by name
159
+ * instead, so nothing is lost without its author being told. Mirrors `unheldFields` in
160
+ * apps/api/src/work.ts, which is the refusal every client meets.
161
+ */
162
+ export function unheldFields(markdown) {
163
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
164
+ if (!m) return [];
165
+ const out = new Set();
166
+ let key = null;
167
+ let listKey = null;
168
+ for (const line of m[1].split(/\r?\n/)) {
169
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
170
+ if (listKey && /^\s*-\s*/.test(line)) continue;
171
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
172
+ if (kv) {
173
+ key = kv[1];
174
+ listKey = kv[2].trim() === "" && LIST_FIELDS.has(key) ? key : null;
175
+ continue;
176
+ }
177
+ // Anything else is a line the parser skips: an indented child, or an item under a field
178
+ // that is not a list. It belongs to the key above it, and that is the key to name.
179
+ out.add(key ?? line.trim());
180
+ }
181
+ return [...out];
182
+ }
183
+
184
+ /**
185
+ * Fields this format no longer has, refused by name rather than carried. `kind` split work into
186
+ * kinds, each with its own sections, and every rule that turned on it is gone
187
+ * (docs/WORK.md): a document still saying it is one of them is an old export or an old client, and
188
+ * carrying the line would put back an answer nothing reads. `retiredFields` in apps/api/src/work.ts
189
+ * is the refusal every client meets.
190
+ */
191
+ export const RETIRED = ["kind"];
192
+
193
+ function quote(v) {
194
+ const s = String(v ?? "");
195
+ // Quote anything YAML would misread: colons, leading symbols, or empty.
196
+ return /[:#[\]{}"'|>&*!%@`,]|^\s|\s$|^$/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
197
+ }
198
+
199
+ const META_ORDER = [
200
+ "id",
201
+ "title",
202
+ // Right under the title: it is what a person reads first about it, so it is what the document
203
+ // opens with.
204
+ "summary",
205
+ "created",
206
+ "author",
207
+ "source_context",
208
+ // The repo the work is for. Optional — work without one is not tied to a repo.
209
+ "target_context",
210
+ // The work this waits for, by id: it is not handed to an agent until each is Done. See
211
+ // migrations/0022_blocks.sql.
212
+ "blocked_by",
213
+ // Where it came from, in the two senses work has one: `source_context` is the repo and branch it
214
+ // was written in, `parent` is the work it came *out of*.
215
+ "parent",
216
+ "status",
217
+ "team",
218
+ "to",
219
+ "stack_assumptions",
220
+ "tags",
221
+ ];
222
+
223
+ export function serializeFrontmatter(meta) {
224
+ const keys = [
225
+ ...META_ORDER.filter((k) => k in meta),
226
+ ...Object.keys(meta).filter((k) => !META_ORDER.includes(k)),
227
+ ];
228
+ const out = [];
229
+ for (const key of keys) {
230
+ const v = key === "tags" ? tagList(meta[key]) : meta[key];
231
+ if (Array.isArray(v)) {
232
+ out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
233
+ } else {
234
+ out.push(`${key}: ${quote(v)}`);
235
+ }
236
+ }
237
+ return out.join("\n");
238
+ }
239
+
240
+ /** Split a markdown document into { meta, body }. A document with no frontmatter has empty meta. */
241
+ export function parse(markdown) {
242
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(markdown);
243
+ if (!m) return { meta: parseFrontmatter(""), body: markdown.trim() };
244
+ return { meta: parseFrontmatter(m[1]), body: m[2].trim() };
245
+ }
246
+
247
+ export function serialize({ meta, body }) {
248
+ return `---\n${serializeFrontmatter(meta)}\n---\n\n${body.trim()}\n`;
249
+ }
250
+
251
+ /**
252
+ * Image targets no reader will ever load. Mirrors `unreachableImages` in apps/api/src/work.ts,
253
+ * which is the authority and refuses the publish; this lets a local share fail before the network.
254
+ *
255
+ * Only a foreign scheme counts — `attachment://`, `blob:`, `file:`, `data:`. `https:` is left alone
256
+ * because embedding an image you host is legitimate, and a relative path is left alone because it
257
+ * has always been accepted.
258
+ */
259
+ export function unreachableImages(markdown) {
260
+ const bad = [...String(markdown).matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)]
261
+ .map((m) => m[1].trim())
262
+ .filter((target) => {
263
+ const scheme = /^([a-z][a-z0-9+.\-]*):/i.exec(target);
264
+ return Boolean(scheme) && !/^https?$/i.test(scheme[1]);
265
+ });
266
+ return [...new Set(bad)];
267
+ }
268
+
269
+ /** Problems that make work unfit to publish. Empty array means it is fine. */
270
+ export function validate({ meta, body }) {
271
+ const errors = [];
272
+ if (!meta.title) errors.push("frontmatter needs a title");
273
+ if (meta.id && !ID_RE.test(meta.id)) errors.push(`id "${meta.id}" is not a valid passalong id`);
274
+ // A parent is optional, and a self-parent is always a mistake worth refusing.
275
+ //
276
+ // A malformed one is not refused, though, and that is deliberate. `parent:` is a field name this
277
+ // format did not reserve until now, so work written months ago may already carry one meaning
278
+ // something else entirely — and work shared then has to still re-share today. Rejecting the
279
+ // value would make that document unpublishable by its own author, which is the failure the
280
+ // STATUSES comment above describes. The server drops a parent it cannot resolve, so the worst
281
+ // case is lineage that silently does not apply rather than work nobody can hand on.
282
+ if (meta.parent && meta.id && meta.parent === meta.id)
283
+ errors.push("work cannot come out of itself");
284
+ if (meta.status && !STATUSES.includes(meta.status)) {
285
+ errors.push(`status must be one of ${STATUSES.join(", ")}`);
286
+ }
287
+ const said = String(meta.summary ?? "").trim();
288
+ if (!said)
289
+ errors.push(
290
+ "say it to a person: add `summary:` — one or two plain sentences, no ids or paths, saying what " +
291
+ `this is and whether anybody needs to act (${SUMMARY_MAX} characters at most)`,
292
+ );
293
+ else if (said.length > SUMMARY_MAX)
294
+ errors.push(`summary is ${said.length} characters; ${SUMMARY_MAX} at most`);
295
+ for (const field of RETIRED)
296
+ if (meta[field] !== undefined)
297
+ errors.push(`\`${field}:\` is gone — work has no kinds; remove the line`);
298
+ // An agent that has a file id but never called add_file writes the id as if it were a link. It
299
+ // publishes, and stores work whose screenshot was never uploaded anywhere.
300
+ for (const target of unreachableImages(body))
301
+ errors.push(
302
+ `${target} is not a URL a reader can load — attach the image first and use the URL it returns`,
303
+ );
304
+ return errors;
305
+ }
306
+
307
+ /** Fill in what a fresh work needs before it can be stored. Returns a new object. */
308
+ export function stamp(work, defaults = {}) {
309
+ const meta = { ...work.meta };
310
+ if (!meta.id) meta.id = newId();
311
+ if (!meta.created) meta.created = new Date().toISOString();
312
+ // Ready unless whoever wrote it said draft: work sent to someone is meant to reach them, and
313
+ // drafts were where addressed work sat forgotten (every draft in production on 2026-10-15).
314
+ if (!meta.status) meta.status = "ready";
315
+ for (const [k, v] of Object.entries(defaults))
316
+ if (meta[k] === undefined || meta[k] === "") meta[k] = v;
317
+ return { meta, body: work.body };
318
+ }
319
+
320
+ // ---- reading work in parts, and how big one may be -----------------------------------------
321
+ // Mirrors apps/api/src/sections.ts — apps/api/test/sections.test.mjs holds the two to the same
322
+ // answers. Most of what is stored is a few very large work (six of 123 held 72% of the text), and
323
+ // an agent that wants only the Acceptance of a long task should not read the rest first.
324
+
325
+ /** Over this work is large: its readers are offered the outline first, and its author a warning. */
326
+ export const WORK_WARN = 20_000;
327
+ /** Over this a new work is refused by the server. Work stored before it existed may keep its size. */
328
+ export const WORK_MAX = 60_000;
329
+
330
+ /** Where each heading starts, outside the frontmatter and outside code fences. */
331
+ function headingLines(markdown) {
332
+ const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(markdown);
333
+ const bodyStart = fm ? fm[0].length : 0;
334
+ const lines = [];
335
+ let fence = "";
336
+ let at = bodyStart;
337
+ for (const raw of markdown.slice(bodyStart).split("\n")) {
338
+ const mark = /^\s*(```|~~~)/.exec(raw)?.[1];
339
+ if (mark) fence = fence ? (fence === mark ? "" : fence) : mark;
340
+ const m = !fence && !mark ? /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw) : null;
341
+ if (m) lines.push({ heading: m[2].trim(), level: m[1].length, start: at });
342
+ at += raw.length + 1;
343
+ }
344
+ return lines;
345
+ }
346
+
347
+ /** The end of the section that begins at lines[i]: the next heading at its level or above. */
348
+ function sectionEnd(lines, i, total) {
349
+ for (let j = i + 1; j < lines.length; j++)
350
+ if (lines[j].level <= lines[i].level) return lines[j].start;
351
+ return total;
352
+ }
353
+
354
+ /** Every heading, with the characters in its section (nested ones included). */
355
+ export function outline(markdown) {
356
+ const lines = headingLines(markdown);
357
+ return lines.map((l, i) => ({
358
+ heading: l.heading,
359
+ level: l.level,
360
+ chars: sectionEnd(lines, i, markdown.length) - l.start,
361
+ }));
362
+ }
363
+
364
+ /** One section by its heading — exact, then by its start, then by what it contains. */
365
+ export function section(markdown, name) {
366
+ const lines = headingLines(markdown);
367
+ const want = String(name ?? "")
368
+ .trim()
369
+ .toLowerCase();
370
+ if (!want) return null;
371
+ const at = (test) => lines.findIndex((l) => test(l.heading.toLowerCase()));
372
+ const i = [
373
+ at((h) => h === want),
374
+ at((h) => h.startsWith(want)),
375
+ at((h) => h.includes(want)),
376
+ ].find((n) => n >= 0);
377
+ if (i === undefined) return null;
378
+ return {
379
+ heading: lines[i].heading,
380
+ text: markdown.slice(lines[i].start, sectionEnd(lines, i, markdown.length)).trimEnd(),
381
+ };
382
+ }
383
+
384
+ /** What the author of a piece of work is told when it is on the large side. */
385
+ export function sizeWarning(chars) {
386
+ return chars > WORK_WARN
387
+ ? `this is ${chars.toLocaleString("en")} characters, and over ${WORK_WARN.toLocaleString("en")} ` +
388
+ "it is read by outline first. Put logs, dumps and long output in a file (add_file) and " +
389
+ "link it; keep the text to what the next agent has to act on."
390
+ : null;
391
+ }
392
+
393
+ /**
394
+ * What an agent is shown instead of a large work: who it is, what it is, and where its parts are.
395
+ * `path` is where the whole of it was written, for a search rather than a read.
396
+ */
397
+ export function outlineText(markdown, { id = "", path = "" } = {}) {
398
+ const { meta } = parse(markdown);
399
+ const rows = outline(markdown)
400
+ .filter((h) => h.level <= 3)
401
+ .map(
402
+ (h) => `${" ".repeat(h.level - 1)}- ${h.heading} (${h.chars.toLocaleString("en")} chars)`,
403
+ );
404
+ return [
405
+ `${meta.title || id} — ${markdown.length.toLocaleString("en")} characters`,
406
+ meta.summary ? `Summary: ${meta.summary}` : "",
407
+ "",
408
+ "Sections:",
409
+ ...rows,
410
+ "",
411
+ `Read one with find_work id=${id} section="<heading>", or the whole thing with full=true.` +
412
+ (path ? ` The full text is also on disk at ${path}, if you only need to search it.` : ""),
413
+ ]
414
+ .filter((l, i, a) => l !== "" || a[i - 1] !== "")
415
+ .join("\n");
416
+ }