atris 3.70.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 +18 -0
- 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 +359 -94
- package/commands/mission.js +249 -36
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +71 -17
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +108 -15
- 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 +33 -3
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- 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 +395 -18
- package/lib/member-board.js +7 -1
- 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 +127 -8
- package/lib/member-self-next-job.js +97 -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 +47 -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`);
|
package/commands/worktree.js
CHANGED
|
@@ -5,12 +5,16 @@ const path = require('path');
|
|
|
5
5
|
const { spawnSync } = require('child_process');
|
|
6
6
|
const { hasFlag, readFlag } = require('../lib/arg-parser');
|
|
7
7
|
const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
|
|
8
|
-
const { isConductorArtifact } = require('../lib/conductor-artifacts');
|
|
8
|
+
const { isConductorArtifact, isConductorUntracked } = require('../lib/conductor-artifacts');
|
|
9
9
|
const { fixMapDocs } = require('../lib/map-refs');
|
|
10
10
|
const { sleepSync } = require('../lib/sleep-sync');
|
|
11
11
|
const close = require('./close');
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Files atris regenerates on its own. Unstaged churn in them is restored
|
|
14
|
+
// before ship commits, unless the commit message names the file. The task
|
|
15
|
+
// projection is rewritten by any task command run in a work folder, and two
|
|
16
|
+
// parallel landings that each carry a copy always clash.
|
|
17
|
+
const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
|
|
14
18
|
const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
15
19
|
const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
|
|
16
20
|
const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
|
|
@@ -179,6 +183,14 @@ function lastRunBranch(root, owner, task, base, skip) {
|
|
|
179
183
|
return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
|
|
180
184
|
}
|
|
181
185
|
|
|
186
|
+
// True only when git proves the branch no longer merges cleanly with the base.
|
|
187
|
+
// merge-tree --write-tree exits 1 on conflict; an error or old git keeps the
|
|
188
|
+
// resume as before.
|
|
189
|
+
function conflictsWithBase(root, base, branch) {
|
|
190
|
+
const result = runGit(['merge-tree', '--write-tree', '--name-only', base, branch], { cwd: root, check: false });
|
|
191
|
+
return result.status === 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
182
194
|
function defaultWorktreePath(root, member, task, now = new Date()) {
|
|
183
195
|
const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
184
196
|
return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
|
|
@@ -466,10 +478,30 @@ function statusCounts(root, {
|
|
|
466
478
|
|
|
467
479
|
// Remove conductor plumbing from the index after a broad `git add -A`, so the commit
|
|
468
480
|
// records engine output only. Paths that were never staged are simply not listed.
|
|
481
|
+
// GitHub refuses a merge with "Base branch was modified" when another PR
|
|
482
|
+
// merged into the same base a moment earlier; the same merge succeeds on a
|
|
483
|
+
// second try. On 2026-10-15 five parallel dispatches lost three landings to
|
|
484
|
+
// it. Retry only that message, a few times, with a short wait.
|
|
485
|
+
const BASE_MODIFIED_PATTERN = /Base branch was modified/i;
|
|
486
|
+
function mergePrWithRetry(mergeArgs, root, { run = spawnSync, wait = sleepSync, attempts = 4 } = {}) {
|
|
487
|
+
let merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
488
|
+
for (let attempt = 1; attempt < attempts && merged.status !== 0
|
|
489
|
+
&& BASE_MODIFIED_PATTERN.test(`${merged.stderr || ''}${merged.stdout || ''}`); attempt += 1) {
|
|
490
|
+
console.log(`merge: base branch moved, retrying (${attempt + 1}/${attempts})`);
|
|
491
|
+
wait(1500 * attempt);
|
|
492
|
+
merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
493
|
+
}
|
|
494
|
+
return merged;
|
|
495
|
+
}
|
|
496
|
+
|
|
469
497
|
function unstageConductorArtifacts(root) {
|
|
470
498
|
const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
|
|
471
499
|
if (staged.status !== 0) return [];
|
|
472
|
-
const
|
|
500
|
+
const added = runGit(['diff', '--cached', '--name-only', '--diff-filter=A'], { cwd: root, check: false });
|
|
501
|
+
const newFiles = new Set(String(added.stdout || '').split(/\r?\n/).filter(Boolean));
|
|
502
|
+
// A file this commit would add under .atris/ is the tool's own state, never work.
|
|
503
|
+
const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean)
|
|
504
|
+
.filter((file) => isConductorArtifact(file) || (newFiles.has(file) && isConductorUntracked(file)));
|
|
473
505
|
if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
|
|
474
506
|
return drop;
|
|
475
507
|
}
|
|
@@ -572,9 +604,18 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
572
604
|
const staleBase = refreshRemoteRef(root, checkoutBase);
|
|
573
605
|
// A retry of the same job starts from the last run's unlanded commits; it
|
|
574
606
|
// still ships to the mainline.
|
|
575
|
-
|
|
607
|
+
let resumedFrom = (!pathOverride && !branchOverride)
|
|
576
608
|
? lastRunBranch(root, owner, task, checkoutBase, branch)
|
|
577
609
|
: null;
|
|
610
|
+
// A last run that now conflicts with the mainline would hand its conflict
|
|
611
|
+
// to the retry, which then cannot land on the first try. Start fresh; the
|
|
612
|
+
// old branch keeps its commits.
|
|
613
|
+
let skippedResume = null;
|
|
614
|
+
if (resumedFrom && conflictsWithBase(root, checkoutBase, resumedFrom)) {
|
|
615
|
+
skippedResume = resumedFrom;
|
|
616
|
+
resumedFrom = null;
|
|
617
|
+
console.error(`note: the last run's branch ${skippedResume} no longer merges cleanly with ${checkoutBase}, so this run starts fresh; that branch keeps its commits`);
|
|
618
|
+
}
|
|
578
619
|
const startPoint = resumedFrom || checkoutBase;
|
|
579
620
|
const ref = `refs/heads/${branch}`;
|
|
580
621
|
const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
|
|
@@ -590,7 +631,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
590
631
|
if (registered) {
|
|
591
632
|
if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
|
|
592
633
|
|| registered.head !== startCommit) return;
|
|
593
|
-
const dirt = statusCounts(target, { ignoreUntracked:
|
|
634
|
+
const dirt = statusCounts(target, { ignoreUntracked: isConductorUntracked });
|
|
594
635
|
if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
|
|
595
636
|
// No engine has started. Normal removal still refuses unexpected dirt.
|
|
596
637
|
runGit(['worktree', 'remove', target], { cwd: root });
|
|
@@ -608,6 +649,15 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
608
649
|
}
|
|
609
650
|
sleepSync(100 * (attempt + 1));
|
|
610
651
|
}
|
|
652
|
+
// A resumed branch takes in what the mainline gained since, or a job that
|
|
653
|
+
// keeps resuming drifts further behind every run until it clashes
|
|
654
|
+
// (2026-10-15: five long jobs sat 103 to 129 commits behind). The clash
|
|
655
|
+
// check above proved this merge is clean; if git still refuses, the run
|
|
656
|
+
// keeps the old start instead of failing.
|
|
657
|
+
if (resumedFrom) {
|
|
658
|
+
const caughtUp = runGit(['merge', '--no-edit', '-m', `Merge ${checkoutBase} into ${branch}`, checkoutBase], { cwd: target, check: false });
|
|
659
|
+
if (caughtUp.status !== 0) runGit(['merge', '--abort'], { cwd: target, check: false });
|
|
660
|
+
}
|
|
611
661
|
try {
|
|
612
662
|
runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
|
|
613
663
|
runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
|
|
@@ -624,6 +674,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
624
674
|
base: shipBase,
|
|
625
675
|
checkout_base: checkoutBase,
|
|
626
676
|
resumed_from: resumedFrom,
|
|
677
|
+
skipped_resume: skippedResume,
|
|
627
678
|
workspace_root: root,
|
|
628
679
|
created_at: now.toISOString(),
|
|
629
680
|
}, null, 2) + '\n',
|
|
@@ -633,7 +684,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
633
684
|
discardFailedStart();
|
|
634
685
|
throw error;
|
|
635
686
|
}
|
|
636
|
-
return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, owner, reapedBeforeStart, cachePrunedBeforeStart };
|
|
687
|
+
return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart };
|
|
637
688
|
}
|
|
638
689
|
|
|
639
690
|
function startWorktree(args) {
|
|
@@ -949,7 +1000,7 @@ function shipWorktree(args) {
|
|
|
949
1000
|
const ignoredUnstagedFiles = dryRun ? new Set(skippedAdapters) : new Set();
|
|
950
1001
|
// Conductor plumbing must not make the checkout look dirty, or a flight where the
|
|
951
1002
|
// engine wrote nothing commits the fleet's own prompt file and calls it work.
|
|
952
|
-
const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked:
|
|
1003
|
+
const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorUntracked })
|
|
953
1004
|
|| { staged: 0, unstaged: 0, untracked: 0 };
|
|
954
1005
|
const dirty = counts.staged || counts.unstaged || counts.untracked;
|
|
955
1006
|
if (dirty && !message) {
|
|
@@ -989,7 +1040,7 @@ function shipWorktree(args) {
|
|
|
989
1040
|
// already present before the verifier ran, so counting it here blocks a clean ship.
|
|
990
1041
|
const afterVerify = dryRun
|
|
991
1042
|
? { staged: 0, unstaged: 0, untracked: 0 }
|
|
992
|
-
: statusCounts(root, { ignoreUntracked:
|
|
1043
|
+
: statusCounts(root, { ignoreUntracked: isConductorUntracked })
|
|
993
1044
|
|| { staged: 0, unstaged: 0, untracked: 0 };
|
|
994
1045
|
if (!dryRun && (afterVerify.staged || afterVerify.unstaged || afterVerify.untracked)) {
|
|
995
1046
|
console.error(
|
|
@@ -1029,7 +1080,7 @@ function shipWorktree(args) {
|
|
|
1029
1080
|
throw new Error((switched.stderr || switched.stdout || `git switch ${targetBranch} failed`).trim());
|
|
1030
1081
|
}
|
|
1031
1082
|
}
|
|
1032
|
-
const primaryCounts = statusCounts(primary, { ignoreUntracked:
|
|
1083
|
+
const primaryCounts = statusCounts(primary, { ignoreUntracked: isConductorUntracked }) || { staged: 0, unstaged: 0, untracked: 0 };
|
|
1033
1084
|
if (primaryCounts.staged || primaryCounts.unstaged || primaryCounts.untracked) {
|
|
1034
1085
|
throw new Error(
|
|
1035
1086
|
`primary checkout dirty staged=${primaryCounts.staged} ` +
|
|
@@ -1060,7 +1111,7 @@ function shipWorktree(args) {
|
|
|
1060
1111
|
const mergeArgs = ['pr', 'merge'];
|
|
1061
1112
|
if (mergeRef) mergeArgs.push(mergeRef);
|
|
1062
1113
|
mergeArgs.push('--merge');
|
|
1063
|
-
const merged =
|
|
1114
|
+
const merged = mergePrWithRetry(mergeArgs, root);
|
|
1064
1115
|
if (merged.status !== 0) throw new Error((merged.stderr || merged.stdout || 'gh pr merge failed').trim());
|
|
1065
1116
|
// `gh pr merge` on an already-merged PR exits 0 with a warning
|
|
1066
1117
|
// instead of failing, which would otherwise report a false landing.
|
|
@@ -1104,7 +1155,7 @@ function guard(args) {
|
|
|
1104
1155
|
// Conductor plumbing (worktree metadata, the fleet prompt written for this very
|
|
1105
1156
|
// engine, runtime scratch, the brief ledger) must not count as dirt, otherwise the
|
|
1106
1157
|
// harness blocks the engine with the harness's own files.
|
|
1107
|
-
const counts = statusCounts(root, { ignoreUntracked:
|
|
1158
|
+
const counts = statusCounts(root, { ignoreUntracked: isConductorUntracked });
|
|
1108
1159
|
if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
|
|
1109
1160
|
console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
|
|
1110
1161
|
return 3;
|
|
@@ -1162,12 +1213,14 @@ function canonicalPath(value) {
|
|
|
1162
1213
|
}
|
|
1163
1214
|
}
|
|
1164
1215
|
|
|
1216
|
+
// null means the scan failed: we cannot tell who is inside, so callers keep
|
|
1217
|
+
// every folder. An empty list would read as "nobody is here" and delete.
|
|
1165
1218
|
function listActiveProcessCwds() {
|
|
1166
1219
|
const result = spawnSync('lsof', ['-n', '-a', '-d', 'cwd', '-F', 'n'], {
|
|
1167
1220
|
encoding: 'utf8',
|
|
1168
1221
|
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1169
1222
|
});
|
|
1170
|
-
if (result.status !== 0) return
|
|
1223
|
+
if (result.error || result.status !== 0) return null;
|
|
1171
1224
|
return String(result.stdout || '')
|
|
1172
1225
|
.split(/\r?\n/)
|
|
1173
1226
|
.filter((line) => line.startsWith('n'))
|
|
@@ -1175,7 +1228,45 @@ function listActiveProcessCwds() {
|
|
|
1175
1228
|
.filter(Boolean);
|
|
1176
1229
|
}
|
|
1177
1230
|
|
|
1231
|
+
// Absolute paths named on each program's command line, including flag values
|
|
1232
|
+
// like --require=/x.js. A program can run a script from a work folder while
|
|
1233
|
+
// its current folder is elsewhere; the folder scan alone misses it. The list
|
|
1234
|
+
// prints arguments unquoted, so a path with a space is also kept as the rest
|
|
1235
|
+
// of the line from where it starts; the folder match is a prefix check.
|
|
1236
|
+
function parseProcessArgPaths(psOutput) {
|
|
1237
|
+
const paths = new Set();
|
|
1238
|
+
const starts = /(?:^|\s)(?:[^\s=]*=)?(?=\/)/g;
|
|
1239
|
+
for (const raw of String(psOutput || '').split(/\r?\n/)) {
|
|
1240
|
+
const line = raw.trim();
|
|
1241
|
+
for (const match of line.matchAll(starts)) {
|
|
1242
|
+
const rest = line.slice(match.index + match[0].length);
|
|
1243
|
+
const token = rest.split(/\s/, 1)[0];
|
|
1244
|
+
if (token.length > 1) paths.add(token);
|
|
1245
|
+
if (rest.length > token.length) paths.add(rest);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return [...paths];
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function listActiveProcessArgPaths() {
|
|
1252
|
+
const result = spawnSync('ps', ['-axww', '-o', 'args='], {
|
|
1253
|
+
encoding: 'utf8',
|
|
1254
|
+
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1255
|
+
});
|
|
1256
|
+
if (result.error || result.status !== 0) return [];
|
|
1257
|
+
return parseProcessArgPaths(result.stdout);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Everything a running program is anchored to: its current folder plus any
|
|
1261
|
+
// script path on its command line.
|
|
1262
|
+
function listActiveProcessPaths() {
|
|
1263
|
+
const cwds = listActiveProcessCwds();
|
|
1264
|
+
if (!Array.isArray(cwds)) return cwds;
|
|
1265
|
+
return cwds.concat(listActiveProcessArgPaths());
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1178
1268
|
function worktreeHasActiveProcess(worktreePath, activeCwds) {
|
|
1269
|
+
if (!Array.isArray(activeCwds)) return true;
|
|
1179
1270
|
const resolved = canonicalPath(worktreePath);
|
|
1180
1271
|
const prefix = `${resolved}${path.sep}`;
|
|
1181
1272
|
return activeCwds.some((cwd) => {
|
|
@@ -1223,7 +1314,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1223
1314
|
const worktrees = listWorktrees(root);
|
|
1224
1315
|
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1225
1316
|
const current = path.resolve(root);
|
|
1226
|
-
const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) :
|
|
1317
|
+
const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
|
|
1227
1318
|
const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
|
|
1228
1319
|
refreshRemoteRef(root, base);
|
|
1229
1320
|
const candidates = [];
|
|
@@ -1258,7 +1349,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1258
1349
|
continue;
|
|
1259
1350
|
}
|
|
1260
1351
|
if (worktreeHasActiveProcess(wtPath, activeCwds)) {
|
|
1261
|
-
kept.push({ ...item, reason: 'active_process' });
|
|
1352
|
+
kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
|
|
1262
1353
|
continue;
|
|
1263
1354
|
}
|
|
1264
1355
|
const rawCounts = statusCounts(wt.path);
|
|
@@ -1438,6 +1529,7 @@ function worktreeCommand(args = []) {
|
|
|
1438
1529
|
module.exports = {
|
|
1439
1530
|
branchName,
|
|
1440
1531
|
collidingFlights,
|
|
1532
|
+
mergePrWithRetry,
|
|
1441
1533
|
createAgentWorktree,
|
|
1442
1534
|
deniedLaneForShip,
|
|
1443
1535
|
createOrFindPr,
|
|
@@ -1448,9 +1540,10 @@ module.exports = {
|
|
|
1448
1540
|
gitNetworkEnvironment,
|
|
1449
1541
|
describeFlightAge,
|
|
1450
1542
|
flightStampMs,
|
|
1451
|
-
|
|
1543
|
+
listActiveProcessPaths,
|
|
1452
1544
|
listWorktrees,
|
|
1453
1545
|
parseAgentFlightName,
|
|
1546
|
+
parseProcessArgPaths,
|
|
1454
1547
|
parseWorktrees,
|
|
1455
1548
|
normalizeTargetRef,
|
|
1456
1549
|
recordWorktreeRemoval,
|
package/commands/write.js
CHANGED
|
@@ -341,7 +341,17 @@ function coach(argv, root = process.cwd()) {
|
|
|
341
341
|
|
|
342
342
|
if (!offline && claudeAvailable()) {
|
|
343
343
|
const prompt = coachPrompt({ topic, beats, counts, findings, planText, draftText, currentBeat: current.title });
|
|
344
|
+
// Machine memory and load at both ends of the launch, so one killed with
|
|
345
|
+
// SIGKILL says whether the machine was short on memory.
|
|
346
|
+
const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: 'claude', task: 'write coach' });
|
|
344
347
|
const res = spawnSync('claude', ['-p', prompt], { encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 });
|
|
348
|
+
// spawnSync does not throw, so a failure is passed in the thrown shape.
|
|
349
|
+
const end = endResource(res.error || res.signal || res.status !== 0 ? {
|
|
350
|
+
code: res.error && res.error.code,
|
|
351
|
+
signal: res.signal,
|
|
352
|
+
stderr: res.stderr,
|
|
353
|
+
message: res.error ? res.error.message : `claude exited ${res.status}`,
|
|
354
|
+
} : null);
|
|
345
355
|
const out = (res.stdout || '').trim();
|
|
346
356
|
if (res.status === 0 && /QUESTION:/.test(out)) {
|
|
347
357
|
console.log(out.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
@@ -350,7 +360,8 @@ function coach(argv, root = process.cwd()) {
|
|
|
350
360
|
console.log(`\n your move: answer under "## ${current.title}" in draft.md, then \`atris write status\`\n`);
|
|
351
361
|
return 0;
|
|
352
362
|
}
|
|
353
|
-
|
|
363
|
+
const stopped = end && end.cause ? `claude coach stopped by ${res.signal}; ${end.cause}` : 'claude coach unavailable';
|
|
364
|
+
console.log(` (${stopped}, falling back to the offline coach)\n`);
|
|
354
365
|
}
|
|
355
366
|
|
|
356
367
|
// Offline coach: still proactive, still gets you going. Seeds + one question.
|