atris 3.58.8 → 3.59.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.
Files changed (40) hide show
  1. package/AGENTS.md +3 -2
  2. package/atris/skills/engines/SKILL.md +28 -1
  3. package/bin/atris.js +3 -0
  4. package/commands/autopilot.js +75 -5
  5. package/commands/clean.js +17 -0
  6. package/commands/doc-health.js +11 -6
  7. package/commands/engine.js +384 -4
  8. package/commands/host.js +53 -0
  9. package/commands/member.js +57 -6
  10. package/commands/mission.js +72 -8
  11. package/commands/one-lap.js +58 -3
  12. package/commands/run.js +4 -1
  13. package/commands/scout.js +1 -1
  14. package/commands/search.js +5 -3
  15. package/commands/unknowns.js +5 -0
  16. package/commands/verify.js +12 -4
  17. package/commands/visualize.js +20 -3
  18. package/commands/workflow.js +17 -6
  19. package/lib/dispatch-scout.js +31 -22
  20. package/lib/engine-registry.js +1482 -13
  21. package/lib/feedback-triage.js +2 -0
  22. package/lib/fleet.js +107 -11
  23. package/lib/host.js +336 -0
  24. package/lib/known-commands.js +1 -1
  25. package/lib/map-refs.js +44 -1
  26. package/lib/member-engine.js +253 -0
  27. package/lib/roster-markdown.js +206 -0
  28. package/lib/roster-models.js +179 -0
  29. package/lib/runner-command.js +94 -14
  30. package/lib/self-drive.js +9 -1
  31. package/lib/wish-audit.js +10 -6
  32. package/lib/wish-delegate.js +10 -0
  33. package/package.json +1 -1
  34. package/templates/members/host/MEMBER.md +23 -0
  35. package/templates/members/host/MISSION.md +11 -0
  36. package/templates/members/host/SOUL.md +7 -0
  37. package/templates/members/host/context/why.md +3 -0
  38. package/templates/members/host/goals.json +5 -0
  39. package/templates/members/host/goals.md +3 -0
  40. package/templates/members/host/skills/host/SKILL.md +13 -0
package/lib/map-refs.js CHANGED
@@ -148,6 +148,10 @@ function checkMapRefs(root, mapText) {
148
148
  const file = linesOf(ref.path);
149
149
  return { ...ref, ...(file ? classify(ref, file) : { status: 'missing-file' }) };
150
150
  });
151
+ return summarize(refs);
152
+ }
153
+
154
+ function summarize(refs) {
151
155
  const count = status => refs.filter(ref => ref.status === status).length;
152
156
  const summary = {
153
157
  total: refs.length, ok: count('ok'), moved: count('moved'), ambiguous: count('ambiguous'),
@@ -180,4 +184,43 @@ function fixMapRefs(root, mapFile = 'atris/MAP.md') {
180
184
  return { changes, left };
181
185
  }
182
186
 
183
- module.exports = { checkMapRefs, fixMapRefs };
187
+ // The short routing map loads at boot; exact line refs live in the notes file.
188
+ // Both are checked and fixed, so moving a ref out of the boot map never hides it.
189
+ const MAP_REF_DOCS = ['atris/MAP.md', 'atris/refs/MAP-NOTES.md'];
190
+
191
+ function existingMapDocs(root) {
192
+ return MAP_REF_DOCS.filter(doc => {
193
+ try { return fs.statSync(path.resolve(root, doc)).isFile(); } catch (error) {
194
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
195
+ throw error;
196
+ }
197
+ });
198
+ }
199
+
200
+ // mapText is the already-read atris/MAP.md, so boot does not read it twice.
201
+ function checkMapDocs(root, mapText) {
202
+ const refs = existingMapDocs(root).flatMap(doc => {
203
+ const text = doc === MAP_REF_DOCS[0] && typeof mapText === 'string'
204
+ ? mapText : fs.readFileSync(path.resolve(root, doc), 'utf8');
205
+ return checkMapRefs(root, text).refs.map(ref => ({ doc, ...ref }));
206
+ });
207
+ return summarize(refs);
208
+ }
209
+
210
+ function fixMapDocs(root) {
211
+ const changes = [];
212
+ const left = [];
213
+ for (const doc of existingMapDocs(root)) {
214
+ const result = fixMapRefs(root, doc);
215
+ changes.push(...result.changes.map(change => ({ doc, ...change })));
216
+ left.push(...result.left.map(ref => ({ doc, ...ref })));
217
+ }
218
+ return { changes, left };
219
+ }
220
+
221
+ // Repo-relative path of the notes file when this workspace has one, else null.
222
+ function mapNotesPath(root) {
223
+ return existingMapDocs(root).includes(MAP_REF_DOCS[1]) ? MAP_REF_DOCS[1] : null;
224
+ }
225
+
226
+ module.exports = { MAP_REF_DOCS, existingMapDocs, mapNotesPath, checkMapRefs, checkMapDocs, fixMapRefs, fixMapDocs };
@@ -0,0 +1,253 @@
1
+ 'use strict';
2
+
3
+ // Which engine a team member runs on. Every member in atris/team/*/MEMBER.md
4
+ // gets a job on its own: navigators, researchers, and scouts search;
5
+ // validators, reviewers, judges, critics, and verifiers review; everyone else
6
+ // builds. A line under "## team" in ROSTER.md overrides that, either with a
7
+ // job ("researcher: search", "fixer: small build") or with engine or model
8
+ // words ("judge: opus 5.5"). The job's pick then comes from the roster.
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const {
13
+ ENGINE_JOBS,
14
+ ENGINE_ROLES,
15
+ inferJobKind,
16
+ mainCheckoutRoot,
17
+ readEngineRegistry,
18
+ readRosterState,
19
+ resolveEngineForRoleRanked,
20
+ rosterChoiceForPick,
21
+ rosterDecided,
22
+ ROSTER_LAYERS,
23
+ rosterJobKey,
24
+ rosterJobLabel,
25
+ rosterJobRole,
26
+ rosterModelLabel,
27
+ rosterPickFromValue,
28
+ } = require('./engine-registry');
29
+
30
+ const SEARCH_WORDS = Object.freeze(['navigator', 'researcher', 'research', 'search', 'scout']);
31
+ const REVIEW_WORDS = Object.freeze(['validator', 'reviewer', 'review', 'judge', 'critic', 'verifier']);
32
+
33
+ function memberCardFile(root, name) {
34
+ return path.join(root, 'atris', 'team', name, 'MEMBER.md');
35
+ }
36
+
37
+ // This folder's card, else the main checkout's when this is a git worktree:
38
+ // the same fallback the roster file uses, so a card not yet committed still
39
+ // counts from a worktree. '' when neither has one.
40
+ function findMemberCard(root, name) {
41
+ const own = memberCardFile(root, name);
42
+ if (fs.existsSync(own)) return own;
43
+ const main = mainCheckoutRoot(root);
44
+ const shared = main ? memberCardFile(main, name) : '';
45
+ return shared && fs.existsSync(shared) ? shared : '';
46
+ }
47
+
48
+ // name and role from the MEMBER.md frontmatter; found is false when there is
49
+ // no card for this name.
50
+ function readMemberCard(root, name) {
51
+ let text = '';
52
+ try {
53
+ text = fs.readFileSync(findMemberCard(root, name), 'utf8');
54
+ } catch {
55
+ return { name, role: '', found: false };
56
+ }
57
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
58
+ const field = (key) => {
59
+ const match = block ? new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(block[1]) : null;
60
+ return match ? match[1].trim().replace(/^["']|["']$/g, '') : '';
61
+ };
62
+ return { name: field('name') || name, role: field('role'), found: true };
63
+ }
64
+
65
+ function listTeamMembers(root = process.cwd()) {
66
+ const dir = path.join(root, 'atris', 'team');
67
+ let names = [];
68
+ try {
69
+ names = fs.readdirSync(dir, { withFileTypes: true })
70
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && !entry.name.startsWith('_'))
71
+ .map((entry) => entry.name)
72
+ .filter((name) => fs.existsSync(memberCardFile(root, name)));
73
+ } catch {
74
+ return [];
75
+ }
76
+ return names.sort();
77
+ }
78
+
79
+ function words(text) {
80
+ return String(text || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
81
+ }
82
+
83
+ function kindFromWords(list) {
84
+ if (list.some((word) => SEARCH_WORDS.includes(word))) return 'search';
85
+ if (list.some((word) => REVIEW_WORDS.includes(word))) return 'review';
86
+ return '';
87
+ }
88
+
89
+ // The job a member gets with no team line: its name decides first, then its
90
+ // role title, else build.
91
+ function autoJobForMember(name, role = '') {
92
+ return kindFromWords(words(name)) || kindFromWords(words(role)) || 'build';
93
+ }
94
+
95
+ // This session's team line wins, then this project's, then the
96
+ // all-projects one.
97
+ function teamLineFor(state, member) {
98
+ const key = String(member || '').trim().toLowerCase();
99
+ const find = (layer) => (layer && Array.isArray(layer.team) ? layer.team.filter((line) => line.member === key)[0] : null);
100
+ for (const scope of ROSTER_LAYERS) {
101
+ const line = find(state[scope]);
102
+ if (line) return { ...line, scope };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ // What a team line value means: a job (built-in name, a job the roster
108
+ // already has, or a name that says its kind) or a direct engine pick for the
109
+ // member's own kind of work. error is a plain sentence for a line that means
110
+ // neither.
111
+ function interpretTeamValue(value, state, memberKind, options = {}) {
112
+ const text = String(value || '').trim();
113
+ if (!text) return { error: 'names no job or engine' };
114
+ const key = rosterJobKey(text);
115
+ if (key && ENGINE_ROLES.includes(key)) return { type: 'job', key };
116
+ const saved = (layer) => Boolean(key && layer && layer.picks && layer.picks[key]);
117
+ if (saved(state.session) || saved(state.project) || saved(state.machine)) return { type: 'job', key };
118
+ try {
119
+ return { type: 'pick', pick: rosterPickFromValue(text, ENGINE_JOBS[memberKind], options) };
120
+ } catch (error) {
121
+ if (key && inferJobKind(text)) return { type: 'job', key };
122
+ return { error: error.message };
123
+ }
124
+ }
125
+
126
+ function engineText(engine) {
127
+ if (!engine) return 'no ready engine';
128
+ return `${engine.id}${engine.roster_model ? ` ${rosterModelLabel(engine.roster_model)}` : ''}`;
129
+ }
130
+
131
+ // The engine one member runs on, with a plain reason ("researcher does
132
+ // search: claude haiku"). roster_backed is true when a roster line decided
133
+ // (a job pick or a team line); when false the router decided, and callers
134
+ // keep whatever they did before the roster existed.
135
+ function resolveEngineForMember(memberName, root = process.cwd(), options = {}) {
136
+ const name = String(memberName || '').trim();
137
+ const card = readMemberCard(root, name);
138
+ const state = options.rosterState || readRosterState(root, options);
139
+ const autoKind = autoJobForMember(name, card.role);
140
+ const line = teamLineFor(state, name);
141
+ const read = line ? interpretTeamValue(line.value, state, autoKind, options) : null;
142
+ const override = read && !read.error ? read : null;
143
+ const yearless = override && override.type === 'pick' && override.pick.until_needs_year;
144
+ const pickOptions = {
145
+ ...options,
146
+ sessionRosterPicks: state.session ? state.session.picks : {},
147
+ projectRosterPicks: state.project.picks,
148
+ machineRosterPicks: state.machine.picks,
149
+ };
150
+ const base = {
151
+ member: name,
152
+ title: card.role || '',
153
+ found: card.found || Boolean(line),
154
+ source: override ? 'file' : 'auto',
155
+ file: override ? line.file : null,
156
+ line: override ? line.line : null,
157
+ ...(read && read.error ? { warning: read.error } : {}),
158
+ ...(yearless ? { until_warning: `has no year in its until date, so it counts as expired; write until ${yearless}` } : {}),
159
+ };
160
+
161
+ if (override && override.type === 'pick') {
162
+ const role = ENGINE_JOBS[autoKind];
163
+ const registry = readEngineRegistry(root, { persist: false });
164
+ const choice = rosterChoiceForPick(override.pick, role, registry, { label: autoKind, source: line.scope, now: options.now });
165
+ if (choice) {
166
+ return {
167
+ ...base,
168
+ job: autoKind,
169
+ job_key: role,
170
+ role,
171
+ engine: choice.engine,
172
+ model: choice.engine.roster_model || '',
173
+ pick_source: 'team',
174
+ roster_backed: true,
175
+ reason: `${name} does ${autoKind}: ${engineText(choice.engine)}`,
176
+ };
177
+ }
178
+ }
179
+
180
+ const jobKey = override && override.type === 'job' ? override.key : ENGINE_JOBS[autoKind];
181
+ const role = rosterJobRole(jobKey, root, pickOptions) || ENGINE_JOBS[autoKind];
182
+ const custom = !ENGINE_ROLES.includes(jobKey);
183
+ const resolved = resolveEngineForRoleRanked(role, root, { ...pickOptions, ...(custom ? { job: jobKey } : {}) });
184
+ const job = rosterJobLabel(jobKey);
185
+ const fromRoster = rosterDecided(resolved.source);
186
+ return {
187
+ ...base,
188
+ job,
189
+ job_key: jobKey,
190
+ role,
191
+ engine: resolved.engine || null,
192
+ model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : '',
193
+ pick_source: resolved.source,
194
+ roster_backed: fromRoster || Boolean(override),
195
+ reason: `${name} does ${job}: ${engineText(resolved.engine)}`,
196
+ };
197
+ }
198
+
199
+ // The member's engine only when the roster decided it and the member is on
200
+ // the team (a MEMBER.md or a team line). null means keep the old routing.
201
+ // Never throws: a broken roster must not stop a run.
202
+ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
203
+ const name = String(memberName || '').trim();
204
+ if (!name || name.includes('/') || name.includes('\\')) return null;
205
+ try {
206
+ const picked = resolveEngineForMember(name, root, options);
207
+ if (!picked.found && options.requireMember !== false) return null;
208
+ return picked.roster_backed && picked.engine ? picked : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ // One row per member for `atris engine roster`, plus a warning for every
215
+ // team line that could not be used.
216
+ function teamRosterView(root = process.cwd(), options = {}) {
217
+ const state = options.rosterState || readRosterState(root, options);
218
+ const names = new Set(listTeamMembers(root));
219
+ const warnings = [];
220
+ for (const layer of [state.session, state.project, state.machine]) {
221
+ for (const line of (layer && layer.team) || []) {
222
+ if (findMemberCard(root, line.member)) names.add(line.member);
223
+ else if (layer.scope === 'project') warnings.push({ file: line.file, line: line.line, text: line.text, message: `there is no team member named ${line.member}, so the line does nothing` });
224
+ }
225
+ }
226
+ const rows = [...names].sort().map((name) => {
227
+ const picked = resolveEngineForMember(name, root, { ...options, rosterState: state });
228
+ const line = teamLineFor(state, name);
229
+ if (picked.warning && line) {
230
+ warnings.push({ file: line.file, line: line.line, text: line.text, message: `${picked.warning}, so ${name} picks automatically` });
231
+ }
232
+ if (picked.until_warning && line) warnings.push({ file: line.file, line: line.line, text: line.text, message: picked.until_warning });
233
+ return {
234
+ member: name,
235
+ job: picked.job,
236
+ engine: picked.engine ? picked.engine.id : null,
237
+ model: picked.model || null,
238
+ effort: picked.engine && picked.engine.roster_effort ? picked.engine.roster_effort : null,
239
+ max_seconds: picked.engine && picked.engine.roster_max_seconds ? picked.engine.roster_max_seconds : null,
240
+ source: picked.source,
241
+ file: picked.file,
242
+ reason: picked.reason,
243
+ };
244
+ });
245
+ return { rows, warnings };
246
+ }
247
+
248
+ module.exports = {
249
+ autoJobForMember,
250
+ resolveEngineForMember,
251
+ memberRosterEngine,
252
+ teamRosterView,
253
+ };
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ // ROSTER.md as text: which lines are job lines, worker lines, and team
4
+ // lines, and edits that touch one job while keeping everything else the
5
+ // person wrote (comments, order, blank lines, notes). What a line means
6
+ // (which engine, which job) lives in engine-registry; this file only knows
7
+ // the shape.
8
+ //
9
+ // Two shapes read side by side. The sectioned shape, which assign writes:
10
+ // ## build
11
+ // - claude code, model: opus 5.5
12
+ // - codex, effort: medium
13
+ // and the older one-line shape, still read as before:
14
+ // build: claude opus 5.5, backup codex
15
+
16
+ const OLD_ROSTER_NOTE = '> edit any time. each line is job: engine and model, an optional effort (low, medium, high, xhigh, max), then optional "backup <engine and model>", "max 20 min", and "until 2026-10-24" (the year is required).';
17
+ const ROSTER_NOTE = '> edit any time. each "## job" lists its workers in order, one per line: "- tool, model: name", then optional "effort: medium", "max: 20 min", and "until 2026-10-24" (the year is required). the first ready worker leads and the rest back it up.';
18
+
19
+ const ROSTER_MARKDOWN_TEMPLATE = [
20
+ '# roster',
21
+ '',
22
+ ROSTER_NOTE,
23
+ '',
24
+ ];
25
+
26
+ function stripInlineComments(text) {
27
+ return String(text || '').replace(/<!--.*?-->/g, '').trim();
28
+ }
29
+
30
+ // Sections: anything under a top heading, "## jobs", or "## roster" holds
31
+ // one-line job lines; "## team" holds member lines; any other heading is a
32
+ // job whose "- " lines are its workers. Other lines under a job heading are
33
+ // free notes that are never read as picks.
34
+ function sectionForHeading(level, title) {
35
+ const name = String(title || '').trim().toLowerCase();
36
+ if (level === 1) return 'jobs';
37
+ if (name === 'team') return 'team';
38
+ if (name === 'jobs' || name === 'roster') return 'jobs';
39
+ return 'job';
40
+ }
41
+
42
+ function scanRosterMarkdown(text) {
43
+ const source = String(text || '');
44
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
45
+ const lines = source.split(/\r?\n/);
46
+ const entries = [];
47
+ const headings = [];
48
+ let section = 'jobs';
49
+ let heading = null;
50
+ let inComment = false;
51
+ lines.forEach((raw, index) => {
52
+ const line = raw.trim();
53
+ if (inComment) {
54
+ if (line.includes('-->')) inComment = false;
55
+ return;
56
+ }
57
+ if (!line) return;
58
+ if (line.startsWith('<!--')) {
59
+ if (!line.includes('-->')) inComment = true;
60
+ return;
61
+ }
62
+ if (line.startsWith('>') || line.startsWith('```')) return;
63
+ const headingMatch = /^(#{1,6})\s*(.*)$/.exec(line);
64
+ if (headingMatch) {
65
+ section = sectionForHeading(headingMatch[1].length, headingMatch[2]);
66
+ heading = { index, lineNumber: index + 1, level: headingMatch[1].length, title: stripInlineComments(headingMatch[2]), raw: line, section };
67
+ headings.push(heading);
68
+ return;
69
+ }
70
+ const dashed = /^-\s+/.test(line);
71
+ if (line.startsWith('-') && !dashed) return;
72
+ const body = stripInlineComments(dashed ? line.replace(/^-\s+/, '') : line);
73
+ if (!body) return;
74
+ if (section === 'job') {
75
+ // Only "- " lines under a job heading are workers.
76
+ if (!dashed) return;
77
+ entries.push({ index, lineNumber: index + 1, section, raw: line, name: '', value: body, heading: heading.title, headingIndex: heading.index, dashed, malformed: false });
78
+ return;
79
+ }
80
+ // A "- " bullet under the jobs heading is a note; under team it is a
81
+ // member line.
82
+ if (dashed && section !== 'team') return;
83
+ const colon = body.indexOf(':');
84
+ if (dashed && colon <= 0) return;
85
+ entries.push({
86
+ index,
87
+ lineNumber: index + 1,
88
+ section,
89
+ raw: line,
90
+ name: colon > 0 ? body.slice(0, colon).trim() : '',
91
+ value: colon > 0 ? body.slice(colon + 1).trim() : '',
92
+ dashed,
93
+ malformed: colon <= 0,
94
+ });
95
+ });
96
+ return { lines, entries, headings, newline };
97
+ }
98
+
99
+ function joinLines(lines, newline) {
100
+ const text = lines.join(newline);
101
+ return text.endsWith(newline) ? text : `${text}${newline}`;
102
+ }
103
+
104
+ function trimTrailingBlank(lines) {
105
+ const out = [...lines];
106
+ while (out.length && !out[out.length - 1].trim()) out.pop();
107
+ return out;
108
+ }
109
+
110
+ function isBlank(line) {
111
+ return !String(line || '').trim();
112
+ }
113
+
114
+ // One job's section: its heading, the index where it ends (the next heading
115
+ // or the end of the file), and its worker lines in order.
116
+ function jobSections(scan) {
117
+ return scan.headings
118
+ .map((heading, i) => ({ heading, next: scan.headings[i + 1] }))
119
+ .filter(({ heading }) => heading.section === 'job')
120
+ .map(({ heading, next }) => ({
121
+ heading,
122
+ end: next ? next.index : scan.lines.length,
123
+ workers: scan.entries.filter((entry) => entry.section === 'job' && entry.headingIndex === heading.index),
124
+ }));
125
+ }
126
+
127
+ // Two headings say the same thing when they differ only in case or spacing.
128
+ function sameHeading(a, b) {
129
+ const plain = (text) => stripInlineComments(String(text || '').replace(/^#+\s*/, '')).replace(/\s+/g, ' ').toLowerCase();
130
+ return plain(a) === plain(b);
131
+ }
132
+
133
+ // Replace one job's worker lines with lines (in order), add the section when
134
+ // it is missing, or remove every section for the job when lines is null. matches(title)
135
+ // names the section by its heading; heading is the text for a new one, and
136
+ // when given it also replaces an existing heading that says something else
137
+ // (a note at the end of the old heading stays). Every other line outside that
138
+ // job's workers stays as written.
139
+ function replaceJobSection(text, { matches, heading, lines: workerLines }) {
140
+ const base = String(text || '').trim() ? String(text) : ROSTER_MARKDOWN_TEMPLATE.join('\n');
141
+ const scan = scanRosterMarkdown(base);
142
+ const lines = [...scan.lines];
143
+ const sections = jobSections(scan).filter((entry) => matches(entry.heading.title));
144
+ const section = sections[0];
145
+ if (section && workerLines === null) {
146
+ // Every section for the job goes, last first so earlier indexes hold; a
147
+ // second section that was ignored must not become the job.
148
+ for (const entry of [...sections].reverse()) {
149
+ let start = entry.heading.index;
150
+ let end = Math.min(entry.end, lines.length);
151
+ // Take the blank lines after the section with it, so no double gap stays.
152
+ while (end > start && isBlank(lines[end - 1])) end -= 1;
153
+ while (end < lines.length && isBlank(lines[end])) end += 1;
154
+ if (end >= lines.length) while (start > 0 && isBlank(lines[start - 1])) start -= 1;
155
+ lines.splice(start, end - start);
156
+ }
157
+ return joinLines(trimTrailingBlank(lines), scan.newline);
158
+ }
159
+ if (section) {
160
+ const at = section.workers.length ? section.workers[0].index : section.heading.index + 1;
161
+ for (const worker of [...section.workers].reverse()) lines.splice(worker.index, 1);
162
+ lines.splice(at, 0, ...workerLines);
163
+ if (heading && !sameHeading(section.heading.raw, heading)) {
164
+ const note = /\s*(<!--.*?-->)\s*$/.exec(section.heading.raw);
165
+ lines[section.heading.index] = note ? `${heading} ${note[1]}` : heading;
166
+ }
167
+ return joinLines(trimTrailingBlank(lines), scan.newline);
168
+ }
169
+ if (workerLines === null) return joinLines(trimTrailingBlank(lines), scan.newline);
170
+ // A new job goes right before the team, else at the end.
171
+ const team = scan.headings.find((entry) => entry.section === 'team');
172
+ const block = [heading, ...workerLines];
173
+ if (team) {
174
+ let at = team.index;
175
+ while (at > 0 && isBlank(lines[at - 1])) at -= 1;
176
+ lines.splice(at, 0, ...(at > 0 ? [''] : []), ...block);
177
+ return joinLines(trimTrailingBlank(lines), scan.newline);
178
+ }
179
+ const trimmed = trimTrailingBlank(lines);
180
+ return joinLines([...trimmed, ...(trimmed.length ? [''] : []), ...block], scan.newline);
181
+ }
182
+
183
+ // Replace every line for which rewrite(entry) returns a string, leaving the
184
+ // rest of the file byte for byte.
185
+ function rewriteRosterValues(text, rewrite) {
186
+ const scan = scanRosterMarkdown(text);
187
+ const lines = [...scan.lines];
188
+ for (const entry of scan.entries) {
189
+ const next = rewrite(entry);
190
+ if (typeof next !== 'string') continue;
191
+ const indent = /^\s*/.exec(lines[entry.index])[0];
192
+ lines[entry.index] = `${indent}${next}`;
193
+ }
194
+ return joinLines(trimTrailingBlank(lines), scan.newline);
195
+ }
196
+
197
+ module.exports = {
198
+ OLD_ROSTER_NOTE,
199
+ ROSTER_NOTE,
200
+ ROSTER_MARKDOWN_TEMPLATE,
201
+ scanRosterMarkdown,
202
+ jobSections,
203
+ replaceJobSection,
204
+ rewriteRosterValues,
205
+ joinLines,
206
+ };