atris 3.69.0 → 3.71.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 +40 -1
- package/atris/skills/README.md +1 -0
- package/atris/skills/campaigns/SKILL.md +81 -0
- package/atris/skills/meta/SKILL.md +52 -8
- package/ax +226 -42
- package/bin/atris.js +26 -6
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +759 -0
- package/commands/compile.js +19 -8
- package/commands/doctor.js +58 -0
- package/commands/dream.js +18 -3
- package/commands/engine.js +147 -5
- package/commands/improve.js +119 -9
- package/commands/init.js +18 -1
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +433 -99
- package/commands/mission.js +332 -41
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +132 -16
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +148 -18
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/auto-accept-certified.js +1 -0
- package/lib/autoland.js +13 -4
- package/lib/campaign-cadence.js +131 -0
- package/lib/campaign-core.js +330 -0
- package/lib/campaign-holdout.js +65 -0
- package/lib/campaign-loop.js +242 -0
- package/lib/campaign-missions.js +237 -0
- package/lib/campaign-new.js +251 -0
- package/lib/campaign-protect.js +75 -0
- package/lib/campaign-reading.js +147 -0
- package/lib/campaign-schema.js +196 -0
- package/lib/campaign-store.js +380 -0
- package/lib/campaign-sync.js +302 -0
- package/lib/campaign-trace.js +300 -0
- package/lib/conductor-artifacts.js +15 -1
- package/lib/daily-log.js +11 -1
- package/lib/dispatch-scout.js +18 -0
- package/lib/engine-ask.js +35 -4
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/engine-validate.js +189 -9
- package/lib/fleet.js +125 -16
- package/lib/init-commit.js +80 -0
- package/lib/intents.js +8 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +58 -7
- package/lib/landing-proof.js +352 -0
- package/lib/member-autoland.js +479 -10
- package/lib/member-board.js +86 -10
- package/lib/member-map-fold.js +126 -0
- package/lib/member-map-inbox.js +80 -0
- package/lib/member-progress.js +48 -1
- package/lib/member-report.js +433 -0
- package/lib/member-scaffold.js +1 -2
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-switches.js +7 -0
- package/lib/member-task-pick.js +41 -14
- package/lib/member-up.js +249 -16
- package/lib/roster-prep.js +25 -2
- package/lib/runner-command.js +5 -0
- package/lib/task-db.js +63 -11
- package/lib/task-explanation.js +7 -0
- package/lib/task-list-keeper.js +8 -1
- package/lib/task-receipt.js +6 -1
- package/mcp/atris-mcp/index.mjs +212 -10
- package/package.json +2 -1
- package/scripts/det/engine-reading.js +37 -0
- package/scripts/det/ytnotes +29 -5
package/commands/team.js
CHANGED
|
@@ -175,6 +175,19 @@ function memberIsActive({ awake, activity }) {
|
|
|
175
175
|
|
|
176
176
|
const ACTIVITY_WINDOW_MS = IDLE_AFTER_DAYS * 24 * 60 * 60 * 1000;
|
|
177
177
|
|
|
178
|
+
// A log whose only entry is the note setup writes ("Member initialized") is
|
|
179
|
+
// not work: a fresh project's members have never run.
|
|
180
|
+
const SEED_LOG_MAX_BYTES = 1024;
|
|
181
|
+
function isSeedOnlyLog(file) {
|
|
182
|
+
try {
|
|
183
|
+
if (fs.statSync(file).size > SEED_LOG_MAX_BYTES) return false;
|
|
184
|
+
const headings = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter((line) => line.startsWith('## '));
|
|
185
|
+
return headings.length > 0 && headings.every((line) => /·\s*Member initialized\s*$/.test(line));
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
178
191
|
// Dated file names under the member's logs/ folder and one level below it
|
|
179
192
|
// (logs/2026/2026-09-28.md). The date in the name, never the file time: a
|
|
180
193
|
// fresh checkout stamps every file with today.
|
|
@@ -191,12 +204,12 @@ function memberLogDays(member, root) {
|
|
|
191
204
|
try { inner = fs.readdirSync(path.join(dir, entry.name)); } catch { inner = []; }
|
|
192
205
|
for (const name of inner) {
|
|
193
206
|
const day = logFileDay(name);
|
|
194
|
-
if (day) days.push(day);
|
|
207
|
+
if (day && !isSeedOnlyLog(path.join(dir, entry.name, name))) days.push(day);
|
|
195
208
|
}
|
|
196
209
|
continue;
|
|
197
210
|
}
|
|
198
211
|
const day = logFileDay(entry.name);
|
|
199
|
-
if (day) days.push(day);
|
|
212
|
+
if (day && !isSeedOnlyLog(path.join(dir, entry.name))) days.push(day);
|
|
200
213
|
}
|
|
201
214
|
return days;
|
|
202
215
|
}
|
|
@@ -432,6 +445,63 @@ function liveRunJson(run) {
|
|
|
432
445
|
};
|
|
433
446
|
}
|
|
434
447
|
|
|
448
|
+
function realFolder(folder) {
|
|
449
|
+
try { return fs.realpathSync(folder); } catch { return path.resolve(folder); }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Each run's working folder in one lookup: /proc on Linux, lsof elsewhere.
|
|
453
|
+
// A folder that cannot be read stays unknown.
|
|
454
|
+
function liveRunFolders(pids, deps = {}) {
|
|
455
|
+
if (typeof deps.runFolders === 'function') return deps.runFolders(pids);
|
|
456
|
+
const folders = new Map();
|
|
457
|
+
if (!pids.length) return folders;
|
|
458
|
+
if (process.platform === 'linux') {
|
|
459
|
+
for (const pid of pids) {
|
|
460
|
+
try { folders.set(pid, fs.readlinkSync(`/proc/${pid}/cwd`)); } catch { /* unknown */ }
|
|
461
|
+
}
|
|
462
|
+
return folders;
|
|
463
|
+
}
|
|
464
|
+
let result;
|
|
465
|
+
try {
|
|
466
|
+
result = spawnSync('lsof', ['-a', '-p', pids.join(','), '-d', 'cwd', '-Fpn'], { encoding: 'utf8', timeout: 3000 });
|
|
467
|
+
} catch {
|
|
468
|
+
return folders;
|
|
469
|
+
}
|
|
470
|
+
let pid = null;
|
|
471
|
+
for (const line of String(result?.stdout || '').split(/\r?\n/)) {
|
|
472
|
+
if (line.startsWith('p')) pid = Number(line.slice(1));
|
|
473
|
+
else if (line.startsWith('n') && pid) folders.set(pid, line.slice(1));
|
|
474
|
+
}
|
|
475
|
+
return folders;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// This project's folders: the checkout and every work copy git knows for it.
|
|
479
|
+
function projectFolders(root, deps = {}) {
|
|
480
|
+
if (Array.isArray(deps.projectFolders)) return deps.projectFolders.map(realFolder);
|
|
481
|
+
const folders = [realFolder(root)];
|
|
482
|
+
const result = spawnSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, encoding: 'utf8', timeout: 3000 });
|
|
483
|
+
if (result && result.status === 0) {
|
|
484
|
+
for (const line of String(result.stdout || '').split(/\r?\n/)) {
|
|
485
|
+
if (line.startsWith('worktree ')) folders.push(realFolder(line.slice('worktree '.length)));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return [...new Set(folders)];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Live runs on this machine that work in this project. A run in another
|
|
492
|
+
// project is not this team working; a run whose folder cannot be read stays.
|
|
493
|
+
function liveRunsInProject(runs, root, deps = {}) {
|
|
494
|
+
if (!runs.length) return runs;
|
|
495
|
+
const folders = projectFolders(root, deps);
|
|
496
|
+
const where = liveRunFolders(runs.map((run) => run.pid), deps);
|
|
497
|
+
return runs.filter((run) => {
|
|
498
|
+
const folder = where.get(run.pid);
|
|
499
|
+
if (!folder) return true;
|
|
500
|
+
const real = realFolder(folder);
|
|
501
|
+
return folders.some((base) => real === base || real.startsWith(base + path.sep));
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
435
505
|
// The table's rows, in order, for text and json alike. Parked members are
|
|
436
506
|
// left out unless `all` (or unless they are working right now).
|
|
437
507
|
function teamTableRows(rosterRows, { lineup = null, liveRuns = [], all = false } = {}) {
|
|
@@ -537,6 +607,139 @@ function teamJsonRows(allRows, deps = {}) {
|
|
|
537
607
|
return [...members, ...others];
|
|
538
608
|
}
|
|
539
609
|
|
|
610
|
+
// The first paragraph under the title of a MEMBER.md, for files written
|
|
611
|
+
// without a header block. A leading "role:" label is dropped.
|
|
612
|
+
function memberFileOpening(member) {
|
|
613
|
+
let text = '';
|
|
614
|
+
try { text = fs.readFileSync(member.path, 'utf8'); } catch { return ''; }
|
|
615
|
+
const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
|
|
616
|
+
const paragraph = [];
|
|
617
|
+
for (const line of body.split(/\r?\n/)) {
|
|
618
|
+
const trimmed = line.trim();
|
|
619
|
+
if (trimmed.startsWith('#') || !trimmed) {
|
|
620
|
+
if (paragraph.length) break;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
paragraph.push(trimmed);
|
|
624
|
+
}
|
|
625
|
+
return paragraph.join(' ').replace(/^role:\s*/i, '');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const MEMBER_DOES_MAX = 140;
|
|
629
|
+
|
|
630
|
+
// What a member does, in its own words: the description, else the role,
|
|
631
|
+
// else the opening of its file. Only the first sentence, so the column
|
|
632
|
+
// stays one idea per member.
|
|
633
|
+
function memberDoes(member) {
|
|
634
|
+
const role = String(member?.role || '').trim();
|
|
635
|
+
const candidates = [member?.description, role === '(no role)' ? '' : role, memberFileOpening(member)];
|
|
636
|
+
const name = String(member?.name || '').trim().toLowerCase();
|
|
637
|
+
for (const candidate of candidates) {
|
|
638
|
+
const sentence = String(candidate || '').trim().split(/(?<=\.)\s/)[0]
|
|
639
|
+
.replace(/[\u2013\u2014]/g, '-')
|
|
640
|
+
.replace(/\s+/g, ' ')
|
|
641
|
+
.toLowerCase()
|
|
642
|
+
.replace(/[.\s]+$/, '');
|
|
643
|
+
const text = name && sentence.startsWith(name) ? sentence.slice(name.length).replace(/^[\s:,-]+/, '') : sentence;
|
|
644
|
+
if (!text) continue;
|
|
645
|
+
if (text.length <= MEMBER_DOES_MAX) return text;
|
|
646
|
+
const cut = text.slice(0, MEMBER_DOES_MAX);
|
|
647
|
+
return `${cut.slice(0, cut.lastIndexOf(' ')).replace(/[\s,;:-]+$/, '')}…`;
|
|
648
|
+
}
|
|
649
|
+
return 'no role written yet';
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// `atris member roster`: one line per member with the full name, working or
|
|
653
|
+
// idle, the engine it really runs on, and what it does. Same facts as the
|
|
654
|
+
// team table: a live run in this project means working, the lineup names
|
|
655
|
+
// the engine. Parked members leave the list unless `all`.
|
|
656
|
+
function memberRosterRows(members, { lineup = null, liveRuns = [], parked = new Set(), all = false } = {}) {
|
|
657
|
+
const runsByMember = new Map();
|
|
658
|
+
for (const run of Array.isArray(liveRuns) ? liveRuns : []) {
|
|
659
|
+
const key = String(run.member || '').trim().toLowerCase();
|
|
660
|
+
if (key && !runsByMember.has(key)) runsByMember.set(key, run);
|
|
661
|
+
}
|
|
662
|
+
const rows = members.map((member) => {
|
|
663
|
+
const key = String(member.name || '').trim().toLowerCase();
|
|
664
|
+
const run = runsByMember.get(key);
|
|
665
|
+
const place = memberLineup(lineup, key);
|
|
666
|
+
const isParked = parked.has(member.name) || isParkedFrontmatter(member.frontmatter);
|
|
667
|
+
return {
|
|
668
|
+
name: member.name,
|
|
669
|
+
state: run ? 'working' : isParked ? 'parked' : 'idle',
|
|
670
|
+
engine: run ? liveRunEngineText(run) : place && place.engine ? placedEngineText(place) : 'not set',
|
|
671
|
+
does: memberDoes(member),
|
|
672
|
+
parked: isParked,
|
|
673
|
+
};
|
|
674
|
+
});
|
|
675
|
+
const order = { working: 0, idle: 1, parked: 2 };
|
|
676
|
+
return rows
|
|
677
|
+
.filter((row) => all || !row.parked || row.state === 'working')
|
|
678
|
+
.sort((a, b) => order[a.state] - order[b.state] || a.name.localeCompare(b.name));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const MEMBER_ROSTER_COLUMNS = Object.freeze([
|
|
682
|
+
{ header: 'MEMBER' },
|
|
683
|
+
{ header: 'STATUS' },
|
|
684
|
+
{ header: 'ENGINE' },
|
|
685
|
+
{ header: 'WHAT IT DOES', wrap: true, min: 20 },
|
|
686
|
+
]);
|
|
687
|
+
|
|
688
|
+
function renderMemberRoster(members, deps = {}) {
|
|
689
|
+
if (!members.length) return 'no team members yet. create one with: atris member create <name> --role="..."';
|
|
690
|
+
const width = terminalWidth(deps.termWidth);
|
|
691
|
+
const rows = memberRosterRows(members, deps);
|
|
692
|
+
const lines = [renderTable(MEMBER_ROSTER_COLUMNS, rows.map((row) => [row.name, row.state, row.engine, row.does]), { width })];
|
|
693
|
+
const working = rows.filter((row) => row.state === 'working').length;
|
|
694
|
+
lines.push('', `${rows.length} ${rows.length === 1 ? 'member' : 'members'}, ${working} working now.`);
|
|
695
|
+
const shown = new Set(rows.map((row) => row.name));
|
|
696
|
+
const hidden = members.filter((member) => !shown.has(member.name)).map((member) => member.name);
|
|
697
|
+
if (hidden.length) {
|
|
698
|
+
const names = [...hidden];
|
|
699
|
+
names[0] = `parked (${hidden.length}): ${names[0]}`;
|
|
700
|
+
names[names.length - 1] = `${names[names.length - 1]} · atris member roster --all to show them`;
|
|
701
|
+
lines.push(wrapCommaNames(names, width));
|
|
702
|
+
}
|
|
703
|
+
return lines.join('\n');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function memberRosterCommand(args = [], deps = {}) {
|
|
707
|
+
const root = deps.root || process.cwd();
|
|
708
|
+
const members = (deps.members || collectMembers(root, deps)).filter((member) => !isTemplateMember(member));
|
|
709
|
+
const parked = new Set(members.filter((member) => isMemberParked(root, member.name)).map((member) => member.name));
|
|
710
|
+
const lineup = deps.lineup !== undefined ? deps.lineup : readLineupSafe(root, deps.lineupNow || new Date());
|
|
711
|
+
const liveRuns = Array.isArray(deps.liveRuns) ? deps.liveRuns : liveRunsInProject(listLiveEngineRuns(deps), root, deps);
|
|
712
|
+
const view = { lineup: lineup && lineup.ok ? lineup : null, liveRuns, parked, all: args.includes('--all') };
|
|
713
|
+
const write = deps.write || ((text) => process.stdout.write(text));
|
|
714
|
+
if (args.includes('--json')) {
|
|
715
|
+
const byName = new Map(memberRosterRows(members, { ...view, all: true }).map((row) => [row.name, row]));
|
|
716
|
+
write(`${JSON.stringify({
|
|
717
|
+
ok: true,
|
|
718
|
+
count: members.length,
|
|
719
|
+
members: members.map((member) => {
|
|
720
|
+
const row = byName.get(member.name);
|
|
721
|
+
return {
|
|
722
|
+
name: member.name,
|
|
723
|
+
...(row.parked ? { parked: true } : {}),
|
|
724
|
+
state: row.state,
|
|
725
|
+
engine: row.engine,
|
|
726
|
+
does: row.does,
|
|
727
|
+
role: member.role,
|
|
728
|
+
format: member.format,
|
|
729
|
+
skills: member.format === 'directory' ? member.skillCount : null,
|
|
730
|
+
context: member.format === 'directory' ? member.contextCount : null,
|
|
731
|
+
version: member.version || null,
|
|
732
|
+
};
|
|
733
|
+
}),
|
|
734
|
+
}, null, 2)}\n`);
|
|
735
|
+
return 0;
|
|
736
|
+
}
|
|
737
|
+
const output = renderMemberRoster(members, { ...view, termWidth: deps.termWidth });
|
|
738
|
+
if (deps.lineup === undefined && lineup && !lineup.ok) write(`${output}\n\n${LINEUP_UNREADABLE}\n`);
|
|
739
|
+
else write(`${output}\n`);
|
|
740
|
+
return 0;
|
|
741
|
+
}
|
|
742
|
+
|
|
540
743
|
function renderTeamRosterHtml(allRows, meta = {}) {
|
|
541
744
|
const rosterRows = withParkedLabels(allRows, Boolean(meta.all)).rows;
|
|
542
745
|
const activeRows = rosterRows.filter((entry) => entry.active);
|
|
@@ -907,8 +1110,10 @@ function teamCommand(args = [], deps = {}) {
|
|
|
907
1110
|
const lineup = deps.lineup !== undefined
|
|
908
1111
|
? deps.lineup
|
|
909
1112
|
: readLineupSafe(deps.root || repoRoot(deps.cwd || process.cwd()), deps.lineupNow || new Date());
|
|
910
|
-
// Who is working this minute: live engine runs
|
|
911
|
-
const liveRuns = Array.isArray(deps.liveRuns)
|
|
1113
|
+
// Who is working this minute: live engine runs in this project.
|
|
1114
|
+
const liveRuns = Array.isArray(deps.liveRuns)
|
|
1115
|
+
? deps.liveRuns
|
|
1116
|
+
: liveRunsInProject(listLiveEngineRuns(deps), deps.root || repoRoot(deps.cwd || process.cwd()), deps);
|
|
912
1117
|
const output = json
|
|
913
1118
|
? JSON.stringify(teamJsonRows(roster, { lineup, liveRuns }), null, 2)
|
|
914
1119
|
: renderTeamRoster(roster, { ...deps, lineup, liveRuns, all });
|
|
@@ -933,6 +1138,9 @@ module.exports = {
|
|
|
933
1138
|
collectTeamPrune,
|
|
934
1139
|
recordMemberActivity,
|
|
935
1140
|
collectTeamRoster,
|
|
1141
|
+
memberRosterCommand,
|
|
1142
|
+
memberRosterRows,
|
|
1143
|
+
renderMemberRoster,
|
|
936
1144
|
renderTeamPrune,
|
|
937
1145
|
renderTeamRoster,
|
|
938
1146
|
teamCommand,
|
package/commands/unknowns.js
CHANGED
|
@@ -261,6 +261,9 @@ function runRunnerPrompt(root, prompt) {
|
|
|
261
261
|
const cmd = buildRunnerCommand({ promptFile });
|
|
262
262
|
const env = { ...process.env };
|
|
263
263
|
delete env.CLAUDECODE;
|
|
264
|
+
// Machine memory and load at both ends of the launch, so one killed with
|
|
265
|
+
// SIGKILL says whether the machine was short on memory.
|
|
266
|
+
const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: 'runner', task: 'unknowns' });
|
|
264
267
|
const result = spawnSync(cmd, [], {
|
|
265
268
|
cwd: root,
|
|
266
269
|
encoding: 'utf8',
|
|
@@ -270,7 +273,17 @@ function runRunnerPrompt(root, prompt) {
|
|
|
270
273
|
maxBuffer: 10 * 1024 * 1024,
|
|
271
274
|
env,
|
|
272
275
|
});
|
|
273
|
-
|
|
276
|
+
// spawnSync does not throw, so a failure is passed in the thrown shape.
|
|
277
|
+
const failed = result.error || result.signal || result.status !== 0;
|
|
278
|
+
const end = endResource(failed ? {
|
|
279
|
+
code: result.error && result.error.code,
|
|
280
|
+
signal: result.signal,
|
|
281
|
+
stderr: result.stderr,
|
|
282
|
+
message: result.error ? result.error.message : `runner exited ${result.status}`,
|
|
283
|
+
} : null);
|
|
284
|
+
const cause = end && end.cause ? `; ${end.cause}` : '';
|
|
285
|
+
if (result.error) return { ok: false, reason: `${result.error.message || 'runner failed'}${cause}` };
|
|
286
|
+
if (result.signal) return { ok: false, reason: `runner stopped by ${result.signal}${cause}` };
|
|
274
287
|
if (result.status !== 0) {
|
|
275
288
|
return { ok: false, reason: clipLine(result.stderr || result.stdout || `runner exited ${result.status}`, 300) };
|
|
276
289
|
}
|
|
@@ -618,5 +631,6 @@ module.exports = {
|
|
|
618
631
|
unknownsCommand,
|
|
619
632
|
showHelp,
|
|
620
633
|
fallbackPayload,
|
|
634
|
+
runRunnerPrompt,
|
|
621
635
|
gatherTerritoryContext,
|
|
622
636
|
};
|
package/commands/verify.js
CHANGED
|
@@ -581,6 +581,45 @@ function checkMapForFiles(atrisDir, changes) {
|
|
|
581
581
|
return { documented: documented >= fileChanges.length / 2 };
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
const NPM_MUTATING_SUBCOMMANDS = new Set([
|
|
585
|
+
'i', 'install', 'add',
|
|
586
|
+
'un', 'uninstall', 'rm', 'r', 'remove', 'unlink',
|
|
587
|
+
'up', 'update', 'upgrade',
|
|
588
|
+
]);
|
|
589
|
+
const NPM_GLOBAL_FLAG_RE = /^(?:-g|--global(?:=true)?|--location=global)$/;
|
|
590
|
+
const NPM_GLOBAL_ENV_RE = /\bnpm_config_(?:global=(?:true|1)\b|location=global\b)/i;
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Return the first line of a rubric script that installs, removes, or links
|
|
594
|
+
* a package globally, or null. A rubric once ran `npm i -g atris@<old>` and
|
|
595
|
+
* swapped the CLI under every agent on the machine; proofs stay read-only.
|
|
596
|
+
*/
|
|
597
|
+
function findGlobalInstall(script) {
|
|
598
|
+
const text = String(script || '').replace(/\\\n/g, ' ');
|
|
599
|
+
// An exported or inline npm_config_global turns any later install global.
|
|
600
|
+
const globalEnv = NPM_GLOBAL_ENV_RE.test(text.replace(/(^|\s)#.*$/gm, ''));
|
|
601
|
+
for (const rawLine of text.split('\n')) {
|
|
602
|
+
const line = rawLine.replace(/(^|\s)#.*$/, '').trim();
|
|
603
|
+
if (!line) continue;
|
|
604
|
+
for (const segment of line.split(/&&|\|\||[;|&]/)) {
|
|
605
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
606
|
+
while (tokens.length && (tokens[0] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))) tokens.shift();
|
|
607
|
+
const tool = tokens[0];
|
|
608
|
+
if (tool === 'yarn' && tokens[1] === 'global' && tokens[2] !== 'list' && tokens[2] !== 'bin' && tokens[2] !== 'dir') {
|
|
609
|
+
return rawLine.trim();
|
|
610
|
+
}
|
|
611
|
+
if (tool !== 'npm' && tool !== 'pnpm') continue;
|
|
612
|
+
const args = tokens.slice(1);
|
|
613
|
+
const sub = args.find((token, i) => !token.startsWith('-') && args[i - 1] !== '--location');
|
|
614
|
+
if (sub === 'link' || sub === 'ln') return rawLine.trim();
|
|
615
|
+
const isGlobal = globalEnv || args.some((token, i) =>
|
|
616
|
+
NPM_GLOBAL_FLAG_RE.test(token) || (token === '--location' && args[i + 1] === 'global'));
|
|
617
|
+
if (isGlobal && NPM_MUTATING_SUBCOMMANDS.has(sub)) return rawLine.trim();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
584
623
|
/**
|
|
585
624
|
* atris verify <slug> --section <name>
|
|
586
625
|
*
|
|
@@ -617,6 +656,13 @@ function verifyRubric(slug, section, opts = {}) {
|
|
|
617
656
|
return 2;
|
|
618
657
|
}
|
|
619
658
|
const script = match[1];
|
|
659
|
+
const globalInstall = findGlobalInstall(script);
|
|
660
|
+
if (globalInstall) {
|
|
661
|
+
console.error(`✗ Refusing to run "## ${section}": it changes globally installed packages.`);
|
|
662
|
+
console.error(` ${globalInstall}`);
|
|
663
|
+
console.error(' Proofs must be read-only. Move install steps to prose or a non-bash fence.');
|
|
664
|
+
return 2;
|
|
665
|
+
}
|
|
620
666
|
const os = require('os');
|
|
621
667
|
const tmpFile = path.join(os.tmpdir(), `atris-verify-${Date.now()}-${Math.floor(Math.random() * 1e6)}.sh`);
|
|
622
668
|
fs.writeFileSync(tmpFile, `#!/usr/bin/env bash\nset -e\n${script}\n`);
|