atris 3.68.8 → 3.69.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 +32 -0
- package/atris/team/opus-overnight/MEMBER.md +1 -0
- package/commands/init.js +9 -0
- package/commands/member.js +359 -32
- package/commands/mission.js +77 -9
- package/commands/sync.js +54 -3
- package/lib/auto-accept-certified.js +1 -0
- package/lib/engine-registry.js +5 -4
- package/lib/gitignore.js +56 -0
- package/lib/member-autoland.js +350 -0
- package/lib/member-board.js +181 -23
- package/lib/member-progress.js +68 -4
- package/lib/member-task-pick.js +116 -0
- package/lib/member-tidy.js +235 -0
- package/lib/member-up.js +286 -0
- package/lib/member-verbs.js +84 -0
- package/lib/runner-command.js +29 -0
- package/lib/task-db.js +5 -1
- package/package.json +1 -1
package/commands/mission.js
CHANGED
|
@@ -8,7 +8,7 @@ const { spawn, spawnSync } = require('child_process');
|
|
|
8
8
|
const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
|
|
9
9
|
const { memberProcessPrompt } = require('../lib/member-context');
|
|
10
10
|
const { memberIdentityLine, missionObjectiveLine } = require('../lib/member-identity');
|
|
11
|
-
const { formatToolStep, appendProgress, reportProgress } = require('../lib/member-progress');
|
|
11
|
+
const { formatToolStep, formatCodexEvent, appendProgress, reportProgress, startedLine, newRunToken, runMarker } = require('../lib/member-progress');
|
|
12
12
|
const {
|
|
13
13
|
compactErrorPayload,
|
|
14
14
|
compactSuccessPayload,
|
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
resolveClaudeRunnerBin,
|
|
29
29
|
resolveClaudeRunnerCommandTemplate,
|
|
30
30
|
buildRunnerCommand,
|
|
31
|
+
codexJsonEventsCommand,
|
|
31
32
|
RUNNER_PROFILE_DEFS,
|
|
32
33
|
RUNNER_PROFILE_ALIASES,
|
|
33
34
|
RUNNER_PROFILE_NAMES,
|
|
@@ -6509,9 +6510,16 @@ function writeReceipt(mission, result, root = process.cwd()) {
|
|
|
6509
6510
|
const receiptPath = path.join(paths.runsDir, `mission-${mission.id}-${safeTime}.json`);
|
|
6510
6511
|
const relativeReceiptPath = path.relative(root, receiptPath);
|
|
6511
6512
|
const finalResult = normalizeMissionReceiptResult(mission, result, relativeReceiptPath);
|
|
6513
|
+
// tree_hash covers only doctrine files; git_tree pins the code the run's
|
|
6514
|
+
// checks saw, so a later reader can tell whether the code has moved since.
|
|
6515
|
+
// With unsaved changes the checks saw more than any commit holds, so no
|
|
6516
|
+
// code tree is recorded and clean is false.
|
|
6517
|
+
const codeState = require('../lib/member-autoland').gitCodeState(root);
|
|
6512
6518
|
const receipt = {
|
|
6513
6519
|
schema: 'atris.mission_receipt.v1',
|
|
6514
6520
|
tree_hash: treeHashFor(root),
|
|
6521
|
+
clean: codeState.clean,
|
|
6522
|
+
git_tree: codeState.clean ? codeState.tree : null,
|
|
6515
6523
|
mission_id: mission.id,
|
|
6516
6524
|
objective: mission.objective,
|
|
6517
6525
|
owner: mission.owner,
|
|
@@ -9181,11 +9189,20 @@ function spawnGenericRunnerTick(mission, opts) {
|
|
|
9181
9189
|
if (dispatchDepth.tooDeep) return Promise.resolve(tooDeepRunnerResult(dispatchDepth));
|
|
9182
9190
|
return new Promise((resolve) => {
|
|
9183
9191
|
let promptFile = null;
|
|
9192
|
+
let lastMessageFile = null;
|
|
9184
9193
|
let cmd = '';
|
|
9185
9194
|
let briefId = null;
|
|
9186
9195
|
try {
|
|
9187
9196
|
promptFile = writeRunnerPromptFile(cwd, mission.id, prompt);
|
|
9188
9197
|
cmd = buildRunnerCommand({ promptFile, model, ...(mission.roster_effort ? { effort: mission.roster_effort } : {}) });
|
|
9198
|
+
// A codex that prints JSONL events gets live step lines; its final
|
|
9199
|
+
// answer comes from the last-message file, the same text stdout held.
|
|
9200
|
+
const lastFile = promptFile.replace(/\.md$/, '.last.txt');
|
|
9201
|
+
const eventsCmd = codexJsonEventsCommand(cmd, lastFile);
|
|
9202
|
+
if (eventsCmd) {
|
|
9203
|
+
cmd = eventsCmd;
|
|
9204
|
+
lastMessageFile = lastFile;
|
|
9205
|
+
}
|
|
9189
9206
|
const engine = canonicalEngineName(mission.runner);
|
|
9190
9207
|
if (engine) {
|
|
9191
9208
|
const record = appendBriefRecord(cwd, {
|
|
@@ -9232,13 +9249,31 @@ function spawnGenericRunnerTick(mission, opts) {
|
|
|
9232
9249
|
env: { ...gitGuard.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
|
|
9233
9250
|
});
|
|
9234
9251
|
updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
|
|
9252
|
+
const progressMember = mission.owner || 'mission';
|
|
9253
|
+
const seenItemIds = new Set();
|
|
9254
|
+
if (lastMessageFile) appendProgress(progressMember, `started: ${mission.objective || ''}`, mission.id);
|
|
9235
9255
|
let stdout = '';
|
|
9256
|
+
let eventBuf = '';
|
|
9257
|
+
let lastAgentMessage = '';
|
|
9236
9258
|
let stderr = '';
|
|
9237
9259
|
let timedOut = false;
|
|
9238
9260
|
let aborted = false;
|
|
9239
9261
|
|
|
9240
9262
|
const cleanupPrompt = () => {
|
|
9241
9263
|
try { if (promptFile) fs.unlinkSync(promptFile); } catch {}
|
|
9264
|
+
try { if (lastMessageFile) fs.unlinkSync(lastMessageFile); } catch {}
|
|
9265
|
+
};
|
|
9266
|
+
const readCodexEvent = (line) => {
|
|
9267
|
+
let ev;
|
|
9268
|
+
try { ev = JSON.parse(line); } catch { return; }
|
|
9269
|
+
const item = ev && ev.item;
|
|
9270
|
+
if (ev.type === 'item.completed' && item && item.type === 'agent_message' && typeof item.text === 'string') {
|
|
9271
|
+
lastAgentMessage = item.text;
|
|
9272
|
+
}
|
|
9273
|
+
const step = formatCodexEvent(ev, cwd);
|
|
9274
|
+
if (!step || (item && item.id && seenItemIds.has(item.id))) return;
|
|
9275
|
+
if (item && item.id) seenItemIds.add(item.id);
|
|
9276
|
+
reportProgress(progressMember, step, mission.id);
|
|
9242
9277
|
};
|
|
9243
9278
|
const kill = () => {
|
|
9244
9279
|
const killGroup = (sig) => {
|
|
@@ -9254,18 +9289,36 @@ function spawnGenericRunnerTick(mission, opts) {
|
|
|
9254
9289
|
else signal.addEventListener('abort', onAbort, { once: true });
|
|
9255
9290
|
}
|
|
9256
9291
|
|
|
9257
|
-
proc.stdout.on('data', (chunk) => {
|
|
9292
|
+
proc.stdout.on('data', (chunk) => {
|
|
9293
|
+
const text = chunk.toString();
|
|
9294
|
+
stdout += text;
|
|
9295
|
+
if (!lastMessageFile) return;
|
|
9296
|
+
eventBuf += text;
|
|
9297
|
+
let nl;
|
|
9298
|
+
while ((nl = eventBuf.indexOf('\n')) !== -1) {
|
|
9299
|
+
const line = eventBuf.slice(0, nl).trim();
|
|
9300
|
+
eventBuf = eventBuf.slice(nl + 1);
|
|
9301
|
+
if (line) readCodexEvent(line);
|
|
9302
|
+
}
|
|
9303
|
+
});
|
|
9258
9304
|
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
9259
9305
|
|
|
9260
9306
|
proc.on('close', (code) => {
|
|
9261
9307
|
clearTimeout(timer);
|
|
9308
|
+
if (lastMessageFile && eventBuf.trim()) readCodexEvent(eventBuf.trim());
|
|
9309
|
+
let finalText = String(stdout || '').trim();
|
|
9310
|
+
if (lastMessageFile) {
|
|
9311
|
+
let lastMessage = '';
|
|
9312
|
+
try { lastMessage = fs.readFileSync(lastMessageFile, 'utf8'); } catch {}
|
|
9313
|
+
finalText = String(lastMessage || lastAgentMessage || '').trim();
|
|
9314
|
+
}
|
|
9262
9315
|
cleanupPrompt();
|
|
9263
9316
|
gitGuard.cleanup();
|
|
9264
9317
|
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
9265
9318
|
updateMissionLockOwner(missionLock, missionLock?.driverPid);
|
|
9266
|
-
const finalText = String(stdout || '').trim();
|
|
9267
9319
|
const errStr = String(stderr || '').slice(-2000);
|
|
9268
9320
|
const ok = code === 0 && !timedOut && !aborted;
|
|
9321
|
+
if (lastMessageFile) appendProgress(progressMember, `finished: ${ok ? 'ok' : 'failed'}`, mission.id);
|
|
9269
9322
|
const authExpired = !ok && missionRunnerAuthExpired(errStr, finalText);
|
|
9270
9323
|
resolve({
|
|
9271
9324
|
ok,
|
|
@@ -9355,8 +9408,10 @@ function spawnClaudeTick(mission, opts) {
|
|
|
9355
9408
|
});
|
|
9356
9409
|
return;
|
|
9357
9410
|
}
|
|
9411
|
+
const runToken = newRunToken();
|
|
9412
|
+
const runOwner = mission.owner || 'mission';
|
|
9358
9413
|
const args = [
|
|
9359
|
-
'-p', prompt
|
|
9414
|
+
'-p', `${runMarker(runOwner, runToken)}\n${prompt}`,
|
|
9360
9415
|
'--output-format', 'stream-json',
|
|
9361
9416
|
'--verbose',
|
|
9362
9417
|
'--permission-mode', 'bypassPermissions',
|
|
@@ -9380,7 +9435,7 @@ function spawnClaudeTick(mission, opts) {
|
|
|
9380
9435
|
updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
|
|
9381
9436
|
const progressMember = mission.owner || 'mission';
|
|
9382
9437
|
const seenToolIds = new Set();
|
|
9383
|
-
appendProgress(progressMember,
|
|
9438
|
+
appendProgress(progressMember, startedLine(mission.objective, { pid: proc.pid, engine: 'claude', run: runToken }), mission.id);
|
|
9384
9439
|
|
|
9385
9440
|
let stdoutBuf = '';
|
|
9386
9441
|
let observedSessionIds = new Set();
|
|
@@ -9532,10 +9587,13 @@ function engineVerifierResultFromRun(engineResult, engine = 'codex') {
|
|
|
9532
9587
|
}
|
|
9533
9588
|
|
|
9534
9589
|
function pickCrossEngineVerifier(mission, cwd = process.cwd(), preferredEngine = '') {
|
|
9535
|
-
|
|
9536
|
-
|
|
9590
|
+
// The reviewer must not be the builder: a builder grading itself is no review.
|
|
9591
|
+
// A named reviewer is used unless it is the builder's own engine.
|
|
9537
9592
|
const workerEngine = canonicalEngineName(mission?.runner) || 'claude';
|
|
9538
|
-
const
|
|
9593
|
+
const explicit = canonicalEngineName(preferredEngine || mission?.referee_engine || '');
|
|
9594
|
+
if (explicit && explicit !== workerEngine) return explicit;
|
|
9595
|
+
const candidates = (workerEngine === 'codex' ? ['claude', 'fable'] : ['codex', 'claude'])
|
|
9596
|
+
.filter((candidate) => candidate !== workerEngine);
|
|
9539
9597
|
try {
|
|
9540
9598
|
const engines = engineRegistryView(cwd);
|
|
9541
9599
|
for (const candidate of candidates) {
|
|
@@ -10704,12 +10762,22 @@ async function verifyMissionRunTickPhase(context) {
|
|
|
10704
10762
|
|| (!currentTick.storedVerifier && !process.env.NODE_TEST_CONTEXT)
|
|
10705
10763
|
);
|
|
10706
10764
|
if (shouldRunReferee) {
|
|
10707
|
-
|
|
10765
|
+
// Pin the code before the referee looks; a referee that changes the
|
|
10766
|
+
// code or its unsaved state has not reviewed what would merge.
|
|
10767
|
+
const { gitCodeState, codeStatus } = require('../lib/member-autoland');
|
|
10768
|
+
const beforeReview = gitCodeState(cwd);
|
|
10769
|
+
const reviewed = await runEngineVerifier(currentTick.tickRuntimeMission, {
|
|
10708
10770
|
cwd,
|
|
10709
10771
|
signal: controller.signal,
|
|
10710
10772
|
tickIndex: currentTick.tickIdx,
|
|
10711
10773
|
refereeEngine: context.refereeEngine,
|
|
10712
10774
|
});
|
|
10775
|
+
const afterReview = gitCodeState(cwd);
|
|
10776
|
+
const codeMoved = beforeReview.tree !== afterReview.tree
|
|
10777
|
+
|| codeStatus(beforeReview) !== codeStatus(afterReview)
|
|
10778
|
+
|| beforeReview.hidden !== afterReview.hidden;
|
|
10779
|
+
const refereeResult = codeMoved ? { ...reviewed, passed: false, reason: 'code changed during review' } : reviewed;
|
|
10780
|
+
result.reviewed_git_tree = beforeReview.clean ? beforeReview.tree : null;
|
|
10713
10781
|
result.referee_result = refereeResult;
|
|
10714
10782
|
if (!verifierResult || !refereeResult.passed) {
|
|
10715
10783
|
verifierResult = verifierResult
|
package/commands/sync.js
CHANGED
|
@@ -3,6 +3,7 @@ const path = require('path');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const { ensureWikiScaffold } = require('../lib/wiki');
|
|
5
5
|
const { upsertAtrisClaudeBootBlock } = require('../lib/claude-boot-block');
|
|
6
|
+
const { ensureRunIgnore } = require('../lib/gitignore');
|
|
6
7
|
const {
|
|
7
8
|
upsertAgentVoiceCard,
|
|
8
9
|
upsertClaudeVoiceHook,
|
|
@@ -83,6 +84,23 @@ function ensureRealDirectory(dir) {
|
|
|
83
84
|
fs.mkdirSync(dir, { recursive: true });
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// A hook script the project already has is never replaced. Projects harden
|
|
88
|
+
// their hooks (nonce checks, queues, proof before pop); a sync must not
|
|
89
|
+
// downgrade them to the shipped copy.
|
|
90
|
+
function isProtectedHook(destPath) {
|
|
91
|
+
return path.basename(path.dirname(destPath)) === 'hooks' && fs.existsSync(destPath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A member retired to team/_archived/<name>-<date>/ must never come back.
|
|
95
|
+
function isArchivedMember(teamDir, name) {
|
|
96
|
+
const archiveDir = path.join(teamDir, '_archived');
|
|
97
|
+
try {
|
|
98
|
+
return fs.readdirSync(archiveDir).some(entry => entry === name || (entry.startsWith(`${name}-`) && /^\d{4}-\d{2}-\d{2}$/.test(entry.slice(name.length + 1))));
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
/**
|
|
87
105
|
* Sync the canonical skill set from atris-cli/atris/skills/* into a
|
|
88
106
|
* workspace's atris/skills/* (plus ensure .claude/skills/ symlinks).
|
|
@@ -128,7 +146,9 @@ function syncPackageSkills(targetAtrisDir, opts = {}) {
|
|
|
128
146
|
} else {
|
|
129
147
|
const srcContent = fs.readFileSync(srcPath, 'utf8');
|
|
130
148
|
const destContent = fs.existsSync(destPath) ? fs.readFileSync(destPath, 'utf8') : '';
|
|
131
|
-
if (srcContent !== destContent) {
|
|
149
|
+
if (srcContent !== destContent && isProtectedHook(destPath)) {
|
|
150
|
+
if (verbose) console.log(`- Kept atris/skills/${skillName}/${relPath} (differs from shipped hook, not replaced)`);
|
|
151
|
+
} else if (srcContent !== destContent) {
|
|
132
152
|
if (!dryRun) {
|
|
133
153
|
fs.writeFileSync(destPath, srcContent);
|
|
134
154
|
if (entry.endsWith('.sh')) fs.chmodSync(destPath, 0o755);
|
|
@@ -348,6 +368,12 @@ function syncWorkspaceTemplate(targetRoot, bizMeta, options = {}) {
|
|
|
348
368
|
try { templateContent = fs.readFileSync(templatePath, 'utf-8'); } catch { continue; }
|
|
349
369
|
const finalContent = _substituteParams(templateContent, params);
|
|
350
370
|
|
|
371
|
+
const memberMatch = targetRelPath.split(path.sep).join('/').match(/^team\/([^/_][^/]*)\//);
|
|
372
|
+
if (memberMatch && isArchivedMember(path.join(targetAtrisDir, 'team'), memberMatch[1])) {
|
|
373
|
+
skipped++;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
351
377
|
if (!fs.existsSync(targetPath)) {
|
|
352
378
|
addedList.push(targetRelPath); added++;
|
|
353
379
|
if (!dryRun) {
|
|
@@ -358,7 +384,7 @@ function syncWorkspaceTemplate(targetRoot, bizMeta, options = {}) {
|
|
|
358
384
|
const existing = fs.readFileSync(targetPath, 'utf-8');
|
|
359
385
|
if (existing === finalContent) {
|
|
360
386
|
skipped++;
|
|
361
|
-
} else if (force) {
|
|
387
|
+
} else if (force && !memberMatch) {
|
|
362
388
|
updatedList.push(targetRelPath); updated++;
|
|
363
389
|
if (!dryRun) fs.writeFileSync(targetPath, finalContent);
|
|
364
390
|
} else {
|
|
@@ -467,6 +493,10 @@ function syncAtris(options = {}) {
|
|
|
467
493
|
dryRun,
|
|
468
494
|
force,
|
|
469
495
|
});
|
|
496
|
+
const bizRunIgnore = ensureRunIgnore(process.cwd(), { dryRun });
|
|
497
|
+
if (bizRunIgnore.changed) {
|
|
498
|
+
console.log(`${dryRun ? 'Would update' : '✓ Updated'} .gitignore (atris run output ignored)`);
|
|
499
|
+
}
|
|
470
500
|
exportTeamAfterUpdate(process.cwd(), { dryRun });
|
|
471
501
|
if (!yes) {
|
|
472
502
|
console.log('\nPlan only. Re-run with --yes to write these files.');
|
|
@@ -565,6 +595,17 @@ function syncAtris(options = {}) {
|
|
|
565
595
|
return;
|
|
566
596
|
}
|
|
567
597
|
|
|
598
|
+
// Team members belong to the project. Update never adds a member (init
|
|
599
|
+
// does that, and a missing member may have been retired to
|
|
600
|
+
// team/_archived/) and never replaces one the project has changed.
|
|
601
|
+
if (target.startsWith('team/')) {
|
|
602
|
+
if (fs.existsSync(targetFile)) {
|
|
603
|
+
console.log(`- Kept ${target} (newer template exists in the package, yours not replaced)`);
|
|
604
|
+
}
|
|
605
|
+
skipped++;
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
568
609
|
if (!dryRun) {
|
|
569
610
|
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
|
|
570
611
|
fs.copyFileSync(sourceFile, targetFile);
|
|
@@ -694,6 +735,14 @@ Key behaviors:
|
|
|
694
735
|
applyClaudeUpdate(content, 'create CLAUDE.md with atris block', 'created CLAUDE.md with atris block');
|
|
695
736
|
}
|
|
696
737
|
|
|
738
|
+
// Member run output must stay out of git or member self-merge never fires
|
|
739
|
+
// on a dirty tree. Append-only; plan-only mode prints instead of writing.
|
|
740
|
+
const runIgnore = ensureRunIgnore(process.cwd(), { dryRun });
|
|
741
|
+
if (runIgnore.changed) {
|
|
742
|
+
console.log(`${dryRun ? 'Would update' : '✓ Updated'} .gitignore (atris run output ignored)`);
|
|
743
|
+
updated++;
|
|
744
|
+
}
|
|
745
|
+
|
|
697
746
|
if (dryRun) {
|
|
698
747
|
console.log(`\nDry run: ${updated} file(s) would update, ${skipped} unchanged; no changes made.`);
|
|
699
748
|
if (!yes) {
|
|
@@ -728,7 +777,9 @@ function syncRecursiveCount(src, dest, label, silent) {
|
|
|
728
777
|
} else {
|
|
729
778
|
const srcBuf = fs.readFileSync(srcPath);
|
|
730
779
|
const destBuf = fs.existsSync(destPath) ? fs.readFileSync(destPath) : Buffer.alloc(0);
|
|
731
|
-
if (!srcBuf.equals(destBuf)) {
|
|
780
|
+
if (!srcBuf.equals(destBuf) && isProtectedHook(destPath)) {
|
|
781
|
+
if (!silent) console.log(`- Kept ${label}/${entry} (differs from shipped hook, not replaced)`);
|
|
782
|
+
} else if (!srcBuf.equals(destBuf)) {
|
|
732
783
|
fs.writeFileSync(destPath, srcBuf);
|
|
733
784
|
if (entry.endsWith('.sh')) {
|
|
734
785
|
fs.chmodSync(destPath, 0o755);
|
package/lib/engine-registry.js
CHANGED
|
@@ -182,11 +182,12 @@ const ENGINE_SEED_META = Object.freeze({
|
|
|
182
182
|
// bake the effort into the model.
|
|
183
183
|
models: Object.freeze([
|
|
184
184
|
'gemini-3.8-flash',
|
|
185
|
+
// Matches `agy models` on agy 1.3.4, verified 2026-10-13.
|
|
186
|
+
'gemini-4-argon-high',
|
|
187
|
+
'gemini-4-argon-medium',
|
|
185
188
|
'gemini-3.8-flash-high',
|
|
186
|
-
'
|
|
187
|
-
'
|
|
188
|
-
'claude-sonnet-4-6',
|
|
189
|
-
'claude-opus-4-6-thinking',
|
|
189
|
+
'claude-opus-5-5-high',
|
|
190
|
+
'claude-sonnet-5-5-high',
|
|
190
191
|
'gpt-oss-120b-medium',
|
|
191
192
|
]),
|
|
192
193
|
duty: 'errands',
|
package/lib/gitignore.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Member runs drop receipts and status files under atris/runs/ and
|
|
4
|
+
// atris/status/. A project that never ignores them stays dirty forever and
|
|
5
|
+
// member self-merge never fires, so init and update pin these entries in the
|
|
6
|
+
// project .gitignore. Git repos only: a plain folder gets no new file.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const RUN_IGNORE_COMMENT = '# atris run output';
|
|
12
|
+
const RUN_IGNORE_ENTRIES = ['atris/runs/', 'atris/status/'];
|
|
13
|
+
|
|
14
|
+
// .git can be a directory or a worktree pointer file; either counts.
|
|
15
|
+
function isGitRepo(root) {
|
|
16
|
+
return fs.existsSync(path.join(root, '.git'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// What the project .gitignore still needs, or null when it needs nothing
|
|
20
|
+
// (not a git repo, or every entry already present). Pure read; callers decide
|
|
21
|
+
// whether to write (update's plan-only mode prints the change instead).
|
|
22
|
+
function planRunIgnore(root) {
|
|
23
|
+
if (!isGitRepo(root)) return null;
|
|
24
|
+
const file = path.join(root, '.gitignore');
|
|
25
|
+
const exists = fs.existsSync(file);
|
|
26
|
+
const content = exists ? fs.readFileSync(file, 'utf8') : '';
|
|
27
|
+
const lines = content.split(/\r?\n/).map((line) => line.trim());
|
|
28
|
+
const missing = RUN_IGNORE_ENTRIES.filter((entry) => !lines.includes(entry));
|
|
29
|
+
if (missing.length === 0) return null;
|
|
30
|
+
const additions = lines.includes(RUN_IGNORE_COMMENT) ? missing : [RUN_IGNORE_COMMENT, ...missing];
|
|
31
|
+
const gap = content === '' ? '' : content.endsWith('\n') ? '\n' : '\n\n';
|
|
32
|
+
return { file, created: !exists, missing, block: `${gap}${additions.join('\n')}\n` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Append-only write: existing lines, their order, and the rest of the file
|
|
36
|
+
// are never touched. Best-effort: an unreadable or unwritable .gitignore must
|
|
37
|
+
// not fail init or update, it just reports nothing changed.
|
|
38
|
+
function ensureRunIgnore(root, { dryRun = false } = {}) {
|
|
39
|
+
let plan = null;
|
|
40
|
+
try {
|
|
41
|
+
plan = planRunIgnore(root);
|
|
42
|
+
} catch {
|
|
43
|
+
return { changed: false };
|
|
44
|
+
}
|
|
45
|
+
if (!plan) return { changed: false };
|
|
46
|
+
if (!dryRun) {
|
|
47
|
+
try {
|
|
48
|
+
fs.appendFileSync(plan.file, plan.block);
|
|
49
|
+
} catch {
|
|
50
|
+
return { changed: false };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { changed: true, ...plan };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { ensureRunIgnore };
|