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/lib/member-board.js
CHANGED
|
@@ -97,6 +97,27 @@ function currentRun(text) {
|
|
|
97
97
|
return { run, startedLine, finished, stopped, meta: parseStarted(startedLine ? startedLine.text : '') };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// How the member's most recent finished run ended: ok, failed, or stopped.
|
|
101
|
+
// Looks past the current run, so a member busy again still shows how its
|
|
102
|
+
// last job went. A stop wins over a "finished: failed" in the same run.
|
|
103
|
+
function lastResult(text) {
|
|
104
|
+
const lines = text.split('\n').map(parseLine).filter(Boolean);
|
|
105
|
+
let end = -1;
|
|
106
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
107
|
+
if (lines[i].text.startsWith('finished:')) { end = i; break; }
|
|
108
|
+
}
|
|
109
|
+
if (end < 0) return null;
|
|
110
|
+
let start = 0;
|
|
111
|
+
for (let i = end; i >= 0; i--) {
|
|
112
|
+
if (lines[i].text.startsWith('started:')) { start = i; break; }
|
|
113
|
+
}
|
|
114
|
+
const finishes = lines.slice(start, end + 1).filter((l) => l.text.startsWith('finished:'));
|
|
115
|
+
const stop = finishes.find((l) => /^finished:\s*stopped\b/.test(l.text));
|
|
116
|
+
const line = stop || lines[end];
|
|
117
|
+
const word = /^finished:\s*(\S*)/.exec(line.text)[1];
|
|
118
|
+
return { result: word || 'done', at: line.at };
|
|
119
|
+
}
|
|
120
|
+
|
|
100
121
|
function memberRow(name, text, now) {
|
|
101
122
|
const current = currentRun(text);
|
|
102
123
|
if (!current) return null;
|
|
@@ -112,9 +133,12 @@ function memberRow(name, text, now) {
|
|
|
112
133
|
else if (finished) state = /^finished:\s*ok\b/.test(finished.text) ? 'done ok' : 'done failed';
|
|
113
134
|
else if (alive === false) state = 'stopped';
|
|
114
135
|
else state = now - last.at < QUIET_AFTER_MS ? 'working' : 'quiet';
|
|
136
|
+
const result = lastResult(text);
|
|
115
137
|
return {
|
|
116
138
|
member: name,
|
|
117
139
|
state,
|
|
140
|
+
last_result: result ? result.result : '',
|
|
141
|
+
last_result_ago: result ? ago(now - result.at) : '',
|
|
118
142
|
pid: meta.pid,
|
|
119
143
|
engine: meta.engine,
|
|
120
144
|
// null when the started line has no pid; false once the run is over.
|
|
@@ -129,25 +153,62 @@ function memberRow(name, text, now) {
|
|
|
129
153
|
};
|
|
130
154
|
}
|
|
131
155
|
|
|
132
|
-
|
|
156
|
+
// A member put to sleep says so on the board, even with no progress log.
|
|
157
|
+
// Sleep does not end a run already going, so that run reads as finishing.
|
|
158
|
+
function markAsleep(row) {
|
|
159
|
+
const live = (row.state === 'working' || row.state === 'quiet') && row.alive !== false;
|
|
160
|
+
return { ...row, state: live ? 'asleep after this run' : 'asleep', asleep: true, idle: false };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function asleepRow(member) {
|
|
164
|
+
return {
|
|
165
|
+
member, state: 'asleep', last_result: '', last_result_ago: '', asleep: true, pid: null, engine: '', alive: null,
|
|
166
|
+
running_for: '', job: '', step: '', ago: '-', at: 0, idle: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The progress folder is shared by every project on this computer. `only`
|
|
171
|
+
// names the members to show; null shows every log.
|
|
172
|
+
function readBoard({ dir = progressDir(), now = Date.now(), all = false, asleep = [], only = null } = {}) {
|
|
133
173
|
let files = [];
|
|
134
174
|
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.log')); } catch {}
|
|
175
|
+
if (only) files = files.filter((f) => only.includes(f.slice(0, -'.log'.length)));
|
|
176
|
+
const sleeping = new Set(asleep);
|
|
135
177
|
const rows = [];
|
|
136
178
|
for (const f of files) {
|
|
137
179
|
let text = '';
|
|
138
180
|
try { text = readTail(path.join(dir, f)); } catch { continue; }
|
|
139
181
|
const row = memberRow(f.slice(0, -'.log'.length), text, now);
|
|
140
|
-
if (row) rows.push(row);
|
|
182
|
+
if (row) rows.push(sleeping.has(row.member) ? markAsleep(row) : { ...row, asleep: false });
|
|
183
|
+
}
|
|
184
|
+
for (const name of sleeping) {
|
|
185
|
+
if (!rows.some((r) => r.member === name)) rows.push(asleepRow(name));
|
|
141
186
|
}
|
|
142
187
|
return rows.filter((r) => all || !r.idle).sort((a, b) => b.at - a.at);
|
|
143
188
|
}
|
|
144
189
|
|
|
190
|
+
// Members of this project: one folder each under atris/team. Outside an
|
|
191
|
+
// Atris project there is no team, so the board shows every log (null).
|
|
192
|
+
function teamHere(root = process.cwd()) {
|
|
193
|
+
try {
|
|
194
|
+
return fs.readdirSync(path.join(root, 'atris', 'team'), { withFileTypes: true })
|
|
195
|
+
.filter((e) => e.isDirectory() && !/^[._]/.test(e.name))
|
|
196
|
+
.map((e) => e.name);
|
|
197
|
+
} catch { return null; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Members the sleep switch has off in this workspace.
|
|
201
|
+
function sleepingHere() {
|
|
202
|
+
try { return require('./member-switches').asleepMembers(process.cwd()); } catch { return []; }
|
|
203
|
+
}
|
|
204
|
+
|
|
145
205
|
function renderBoard(rows, dir = progressDir(), hidden = 0) {
|
|
146
206
|
const note = hidden ? `${hidden} finished over an hour ago, hidden. --all shows them` : '';
|
|
147
207
|
if (!rows.length) return note ? `nobody is working right now. ${note}` : `no member progress yet in ${dir}`;
|
|
148
208
|
const cells = rows.map((r) => [
|
|
149
209
|
r.member,
|
|
150
210
|
r.state,
|
|
211
|
+
r.last_result ? `${r.last_result} ${r.last_result_ago}` : '-',
|
|
151
212
|
r.alive === null ? '-' : (r.alive ? 'yes' : 'no'),
|
|
152
213
|
r.engine || '-',
|
|
153
214
|
r.running_for || '-',
|
|
@@ -155,7 +216,7 @@ function renderBoard(rows, dir = progressDir(), hidden = 0) {
|
|
|
155
216
|
r.job || '-',
|
|
156
217
|
r.step || '-',
|
|
157
218
|
]);
|
|
158
|
-
const head = ['member', 'state', 'alive', 'engine', 'running for', 'ago', 'job', 'last step'];
|
|
219
|
+
const head = ['member', 'state', 'last result', 'alive', 'engine', 'running for', 'ago', 'job', 'last step'];
|
|
159
220
|
const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
|
|
160
221
|
const line = (c) => c.map((v, i) => (i === c.length - 1 ? v : v.padEnd(widths[i]))).join(' ').trimEnd();
|
|
161
222
|
const out = [line(head)];
|
|
@@ -295,6 +356,8 @@ const ENGINE_PROGRAMS = Object.freeze({
|
|
|
295
356
|
cursor: ['cursor-agent'], composer: ['cursor-agent'], grok: ['grok'],
|
|
296
357
|
devin: ['devin'], agy: ['agy'], opencode: ['opencode'],
|
|
297
358
|
});
|
|
359
|
+
// Engines whose member run may be led by the login shell that launched them.
|
|
360
|
+
const SHELL_LAUNCHED = Object.freeze(['codex']);
|
|
298
361
|
|
|
299
362
|
function readProcess(pid) {
|
|
300
363
|
const run = (fmt) => require('child_process').spawnSync('ps', ['-o', fmt, '-p', String(pid)], {
|
|
@@ -326,7 +389,10 @@ function pidRunsEngine(pid, engine, deps = {}, startedLineAt = null, member = ''
|
|
|
326
389
|
const ownGroup = (deps.readProcess || readProcess)(process.pid);
|
|
327
390
|
if (ownGroup && ownGroup.pgid === pid) return false;
|
|
328
391
|
const program = path.basename(String(info.command).trim().split(/\s+/)[0] || '');
|
|
329
|
-
|
|
392
|
+
// The codex mission runner leads its group with a login shell whose script
|
|
393
|
+
// opens with the marker as a no-op: sh -lc ': atris-run <member> <run>; ...'.
|
|
394
|
+
const viaShell = SHELL_LAUNCHED.includes(key) && program === 'sh';
|
|
395
|
+
if (!programs.includes(program) && !viaShell) return false;
|
|
330
396
|
// The run token proves the process is this member's run: it is on the first
|
|
331
397
|
// line of the engine's prompt and in no other member's log.
|
|
332
398
|
if (!/^[0-9a-f]{16}$/.test(String(run || '')) || !member) return false;
|
|
@@ -335,7 +401,10 @@ function pidRunsEngine(pid, engine, deps = {}, startedLineAt = null, member = ''
|
|
|
335
401
|
const escaped = runMarker(member, run).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
336
402
|
// The program, then -p, then the marker: nothing earlier can carry it. ps on
|
|
337
403
|
// macOS prints the prompt's line break as the four characters \\012.
|
|
338
|
-
|
|
404
|
+
const shape = viaShell
|
|
405
|
+
? `^\\S+\\s+-lc\\s+:\\s+${escaped};`
|
|
406
|
+
: `^\\S+\\s+-p\\s+${escaped}(?=\\s|\\\\012|\\?|$)`;
|
|
407
|
+
if (!new RegExp(shape).test(String(info.command))) return false;
|
|
339
408
|
return { startedAt: info.startedAt, command: info.command };
|
|
340
409
|
}
|
|
341
410
|
|
|
@@ -381,11 +450,18 @@ async function memberStopCommand(name) {
|
|
|
381
450
|
function memberBoardCommand(...args) {
|
|
382
451
|
const asJson = args.includes('--json');
|
|
383
452
|
const all = args.includes('--all');
|
|
453
|
+
const only = teamHere();
|
|
384
454
|
if (args.includes('--follow')) {
|
|
385
455
|
const dir = progressDir();
|
|
386
456
|
const write = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
387
|
-
|
|
388
|
-
|
|
457
|
+
// Mark where every log ends before reading the snapshot, so a line written
|
|
458
|
+
// while the snapshot prints is streamed after it instead of lost.
|
|
459
|
+
const early = [];
|
|
460
|
+
let send = (event) => early.push(event);
|
|
461
|
+
const follower = followBoard({ dir, emit: (event) => { if (!only || only.includes(event.member)) send(event); } });
|
|
462
|
+
write({ kind: 'snapshot', at: new Date().toISOString(), dir, members: readBoard({ dir, all, asleep: sleepingHere(), only }) });
|
|
463
|
+
send = write;
|
|
464
|
+
early.forEach(write);
|
|
389
465
|
// Runs until ctrl-c or the reader closes the pipe.
|
|
390
466
|
return new Promise((resolve) => {
|
|
391
467
|
const done = () => { follower.stop(); resolve(); };
|
|
@@ -394,11 +470,11 @@ function memberBoardCommand(...args) {
|
|
|
394
470
|
});
|
|
395
471
|
}
|
|
396
472
|
if (asJson) {
|
|
397
|
-
console.log(JSON.stringify({ ok: true, dir: progressDir(), members: readBoard({ all }) }, null, 2));
|
|
473
|
+
console.log(JSON.stringify({ ok: true, dir: progressDir(), members: readBoard({ all, asleep: sleepingHere(), only }) }, null, 2));
|
|
398
474
|
return;
|
|
399
475
|
}
|
|
400
476
|
const draw = () => {
|
|
401
|
-
const every = readBoard({ all: true });
|
|
477
|
+
const every = readBoard({ all: true, asleep: sleepingHere(), only });
|
|
402
478
|
const rows = all ? every : every.filter((r) => !r.idle);
|
|
403
479
|
return renderBoard(rows, progressDir(), every.length - rows.length);
|
|
404
480
|
};
|
|
@@ -417,5 +493,5 @@ function memberBoardCommand(...args) {
|
|
|
417
493
|
|
|
418
494
|
module.exports = {
|
|
419
495
|
readBoard, renderBoard, memberRow, ago, progressEvent, followBoard, memberBoardCommand,
|
|
420
|
-
parseStarted, isAlive, stopMember, memberStopCommand,
|
|
496
|
+
parseStarted, teamHere, isAlive, stopMember, memberStopCommand,
|
|
421
497
|
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Member changes save map notes as their own small files under
|
|
4
|
+
// atris/refs/map-notes.d/ instead of editing atris/refs/MAP-NOTES.md, so two
|
|
5
|
+
// changes never clash on the shared map. Once an hour the autoland tick folds
|
|
6
|
+
// those files into MAP-NOTES.md on the main branch and removes them, in one
|
|
7
|
+
// commit built without touching any checkout. A push that loses a race with
|
|
8
|
+
// another landing is dropped; the next hour tries again from the new main.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { spawnSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const NOTES_FILE = 'atris/refs/MAP-NOTES.md';
|
|
16
|
+
const DROP_DIR = 'atris/refs/map-notes.d';
|
|
17
|
+
const FOLD_EVERY_MS = 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
function git(args, cwd, extraEnv = {}) {
|
|
20
|
+
const res = spawnSync('git', args, {
|
|
21
|
+
cwd,
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
timeout: 120000,
|
|
24
|
+
env: { ...process.env, ...extraEnv, GIT_NO_REPLACE_OBJECTS: '1' },
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
ok: res.status === 0,
|
|
28
|
+
stdout: String(res.stdout || ''),
|
|
29
|
+
stderr: String(res.stderr || res.error?.message || '').trim(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function foldDue(lastAt, now = Date.now()) {
|
|
34
|
+
const last = Date.parse(lastAt || '');
|
|
35
|
+
return !Number.isFinite(last) || now - last >= FOLD_EVERY_MS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isHeading = (line) => /^## \S/.test(line);
|
|
39
|
+
const trimEnd = (text) => String(text || '').replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
|
40
|
+
|
|
41
|
+
// Fold drop texts into the notes text. A drop that opens with a "## " heading
|
|
42
|
+
// already in the notes adds its new lines at the end of that section; any
|
|
43
|
+
// other drop is added at the end of the file as written. A drop whose lines
|
|
44
|
+
// are all already there adds nothing.
|
|
45
|
+
function foldNotesText(notes, drops) {
|
|
46
|
+
const lines = trimEnd(notes) ? trimEnd(notes).split('\n') : [];
|
|
47
|
+
let added = 0;
|
|
48
|
+
for (const drop of drops) {
|
|
49
|
+
const body = trimEnd(drop.text).split('\n');
|
|
50
|
+
while (body.length && !body[0].trim()) body.shift();
|
|
51
|
+
if (!body.length) continue;
|
|
52
|
+
const at = isHeading(body[0]) ? lines.findIndex((l) => l.trim() === body[0].trim()) : -1;
|
|
53
|
+
if (at >= 0) {
|
|
54
|
+
let end = lines.findIndex((l, i) => i > at && isHeading(l));
|
|
55
|
+
if (end < 0) end = lines.length;
|
|
56
|
+
const seen = new Set(lines.slice(at, end).map((l) => l.trim()));
|
|
57
|
+
const fresh = body.slice(1).filter((l) => l.trim() && !seen.has(l.trim()));
|
|
58
|
+
if (!fresh.length) continue;
|
|
59
|
+
while (end > at + 1 && !lines[end - 1].trim()) end--;
|
|
60
|
+
lines.splice(end, 0, ...fresh);
|
|
61
|
+
added += fresh.length;
|
|
62
|
+
} else {
|
|
63
|
+
const seen = new Set(lines.map((l) => l.trim()));
|
|
64
|
+
const content = body.filter((l) => l.trim());
|
|
65
|
+
if (content.every((l) => seen.has(l.trim()))) continue;
|
|
66
|
+
if (lines.length) lines.push('');
|
|
67
|
+
lines.push(...body);
|
|
68
|
+
added += content.length;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { text: lines.length ? `${lines.join('\n')}\n` : '', added };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mainBranch(root, remote) {
|
|
75
|
+
const head = git(['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], root).stdout.trim();
|
|
76
|
+
return head.startsWith(`${remote}/`) ? head.slice(remote.length + 1) : 'master';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fold every drop file on the main branch into the shared map notes and push
|
|
80
|
+
// the result. Returns what happened; never throws for an ordinary miss.
|
|
81
|
+
function foldMapNotesOnMain(root, { base = '', remote = 'origin', push = true } = {}) {
|
|
82
|
+
base = base || mainBranch(root, remote);
|
|
83
|
+
const remoteRef = `refs/remotes/${remote}/${base}`;
|
|
84
|
+
const fetched = git(['fetch', '-q', remote, `+refs/heads/${base}:${remoteRef}`], root);
|
|
85
|
+
if (!fetched.ok) return { folded: 0, reason: 'main_unreadable', detail: fetched.stderr.split('\n')[0] };
|
|
86
|
+
const baseSha = git(['rev-parse', remoteRef], root).stdout.trim();
|
|
87
|
+
if (!/^[0-9a-f]{40,64}$/.test(baseSha)) return { folded: 0, reason: 'main_unreadable' };
|
|
88
|
+
|
|
89
|
+
const listed = git(['ls-tree', '-r', '--name-only', baseSha, '--', `${DROP_DIR}/`], root);
|
|
90
|
+
const files = listed.stdout.split('\n').filter((f) => f.endsWith('.md')).sort();
|
|
91
|
+
if (!files.length) return { folded: 0, reason: 'nothing_to_fold', base: baseSha };
|
|
92
|
+
|
|
93
|
+
const drops = [];
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
const blob = git(['cat-file', 'blob', `${baseSha}:${file}`], root);
|
|
96
|
+
if (!blob.ok) return { folded: 0, reason: 'drop_unreadable', detail: file };
|
|
97
|
+
drops.push({ file, text: blob.stdout });
|
|
98
|
+
}
|
|
99
|
+
const current = git(['cat-file', 'blob', `${baseSha}:${NOTES_FILE}`], root);
|
|
100
|
+
const { text, added } = foldNotesText(current.ok ? current.stdout : '', drops);
|
|
101
|
+
|
|
102
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-map-fold-'));
|
|
103
|
+
try {
|
|
104
|
+
const index = { GIT_INDEX_FILE: path.join(tmp, 'index') };
|
|
105
|
+
const notesPath = path.join(tmp, 'notes');
|
|
106
|
+
fs.writeFileSync(notesPath, text);
|
|
107
|
+
const blob = git(['hash-object', '-w', notesPath], root).stdout.trim();
|
|
108
|
+
const built = git(['read-tree', baseSha], root, index).ok
|
|
109
|
+
&& /^[0-9a-f]{40,64}$/.test(blob)
|
|
110
|
+
&& git(['update-index', '--add', '--cacheinfo', `100644,${blob},${NOTES_FILE}`], root, index).ok
|
|
111
|
+
&& git(['update-index', '--force-remove', '--', ...files], root, index).ok;
|
|
112
|
+
const tree = built ? git(['write-tree'], root, index).stdout.trim() : '';
|
|
113
|
+
const message = `Fold ${files.length} map ${files.length === 1 ? 'note' : 'notes'} from member changes into the shared map`;
|
|
114
|
+
const commit = tree ? git(['commit-tree', tree, '-p', baseSha, '-m', message], root).stdout.trim() : '';
|
|
115
|
+
if (!/^[0-9a-f]{40,64}$/.test(commit)) return { folded: 0, reason: 'commit_failed' };
|
|
116
|
+
if (!push) return { folded: files.length, added, sha: commit, pushed: false, files };
|
|
117
|
+
const pushed = git(['push', '-q', remote, `${commit}:refs/heads/${base}`], root);
|
|
118
|
+
if (!pushed.ok) return { folded: 0, reason: 'push_rejected', detail: pushed.stderr.split('\n')[0], sha: commit };
|
|
119
|
+
git(['update-ref', remoteRef, commit], root);
|
|
120
|
+
return { folded: files.length, added, sha: commit, pushed: true, files };
|
|
121
|
+
} finally {
|
|
122
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { NOTES_FILE, DROP_DIR, FOLD_EVERY_MS, foldDue, foldNotesText, foldMapNotesOnMain };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Member branches stop editing the shared map files. Before a branch goes to
|
|
4
|
+
// GitHub, its edits to any MAP.md or MAP-NOTES.md that already exist on the
|
|
5
|
+
// main branch are taken back out and saved as a patch in the map inbox, one
|
|
6
|
+
// file per branch. The shared map files land untouched, so two members never
|
|
7
|
+
// clash in them; the inbox patches are folded into the maps later on the main
|
|
8
|
+
// branch by one actor.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { spawnSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const INBOX_DIR = 'atris/refs/map-inbox';
|
|
16
|
+
const SHARED_MAP = /(?:^|\/)MAP(?:-NOTES)?\.md$/;
|
|
17
|
+
|
|
18
|
+
function run(args, cwd, extraEnv = {}, { raw = false } = {}) {
|
|
19
|
+
const env = { ...process.env, ...extraEnv, GIT_NO_REPLACE_OBJECTS: '1' };
|
|
20
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120000, env });
|
|
21
|
+
const stdout = String(res.stdout || '');
|
|
22
|
+
return { ok: res.status === 0, stdout: raw ? stdout : stdout.trim(), stderr: String(res.stderr || '').trim() };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function inboxPath(branch) {
|
|
26
|
+
const slug = String(branch || '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '') || 'branch';
|
|
27
|
+
return `${INBOX_DIR}/${slug}.patch`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SHA = /^[0-9a-f]{40,64}$/;
|
|
31
|
+
|
|
32
|
+
// Returns { sha } unchanged when the branch left the shared maps alone, or
|
|
33
|
+
// { sha, moved, inbox } for a new commit on top of headSha whose shared map
|
|
34
|
+
// files match the starting point and whose map edits sit in the inbox patch.
|
|
35
|
+
// Never touches the checkout. Any failure returns { hold }.
|
|
36
|
+
function inboxMapEdits({ headSha, baseSha, branch, cwd }) {
|
|
37
|
+
const bases = run(['merge-base', '--all', headSha, baseSha], cwd);
|
|
38
|
+
const mergeBase = bases.ok ? bases.stdout.split('\n').filter(Boolean) : [];
|
|
39
|
+
if (mergeBase.length !== 1) return { sha: headSha, moved: [] };
|
|
40
|
+
const changed = run(['diff', '--no-renames', '--name-status', mergeBase[0], headSha], cwd);
|
|
41
|
+
if (!changed.ok) return { hold: 'the branch\'s map edits could not be read' };
|
|
42
|
+
const moved = changed.stdout.split('\n')
|
|
43
|
+
.map((line) => line.split('\t'))
|
|
44
|
+
.filter(([status, file]) => status === 'M' && SHARED_MAP.test(file || ''))
|
|
45
|
+
.map(([, file]) => file);
|
|
46
|
+
if (!moved.length) return { sha: headSha, moved: [] };
|
|
47
|
+
|
|
48
|
+
const patch = run(['diff', '--no-color', '--no-ext-diff', '--no-renames', mergeBase[0], headSha, '--', ...moved], cwd, {}, { raw: true });
|
|
49
|
+
if (!patch.ok || !patch.stdout) return { hold: 'the branch\'s map edits could not be saved' };
|
|
50
|
+
const inbox = inboxPath(branch);
|
|
51
|
+
const earlier = run(['cat-file', 'blob', `${headSha}:${inbox}`], cwd, {}, { raw: true });
|
|
52
|
+
|
|
53
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-map-inbox-'));
|
|
54
|
+
try {
|
|
55
|
+
const index = { GIT_INDEX_FILE: path.join(tmp, 'index') };
|
|
56
|
+
if (!run(['read-tree', headSha], cwd, index).ok) return { hold: 'the map inbox could not be built' };
|
|
57
|
+
for (const file of moved) {
|
|
58
|
+
const entry = run(['ls-tree', mergeBase[0], '--', file], cwd).stdout.match(/^(\d{6}) blob ([0-9a-f]{40,64})\t/);
|
|
59
|
+
if (!entry || !run(['update-index', '--cacheinfo', `${entry[1]},${entry[2]},${file}`], cwd, index).ok) {
|
|
60
|
+
return { hold: `the map file ${file} could not be put back` };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const patchFile = path.join(tmp, 'patch');
|
|
64
|
+
fs.writeFileSync(patchFile, (earlier.ok ? earlier.stdout : '') + patch.stdout);
|
|
65
|
+
const blob = run(['hash-object', '-w', patchFile], cwd);
|
|
66
|
+
if (!blob.ok || !SHA.test(blob.stdout)) return { hold: 'the map inbox could not be saved' };
|
|
67
|
+
if (!run(['update-index', '--add', '--cacheinfo', `100644,${blob.stdout},${inbox}`], cwd, index).ok) {
|
|
68
|
+
return { hold: 'the map inbox could not be built' };
|
|
69
|
+
}
|
|
70
|
+
const tree = run(['write-tree'], cwd, index);
|
|
71
|
+
if (!tree.ok) return { hold: 'the map inbox could not be built' };
|
|
72
|
+
const commit = run(['commit-tree', tree.stdout, '-p', headSha, '-m', 'Move map edits to the map inbox'], cwd);
|
|
73
|
+
if (!commit.ok || !SHA.test(commit.stdout)) return { hold: 'the map inbox commit could not be saved' };
|
|
74
|
+
return { sha: commit.stdout, moved, inbox };
|
|
75
|
+
} finally {
|
|
76
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { inboxMapEdits, inboxPath, INBOX_DIR };
|
package/lib/member-progress.js
CHANGED
|
@@ -7,6 +7,51 @@ const fs = require('fs');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
|
|
10
|
+
const MAX_PROGRESS_BYTES = 256 * 1024;
|
|
11
|
+
const KEEP_PROGRESS_BYTES = 128 * 1024;
|
|
12
|
+
|
|
13
|
+
// Progress is a live view, not the durable mission receipt. Keep whole recent
|
|
14
|
+
// lines and the latest start marker so the board still knows the job and pid.
|
|
15
|
+
// Scan backwards in bounded chunks even when upgrading a very large old log.
|
|
16
|
+
function trimProgress(file) {
|
|
17
|
+
const fd = fs.openSync(file, 'r');
|
|
18
|
+
let recent;
|
|
19
|
+
let started = '';
|
|
20
|
+
try {
|
|
21
|
+
const size = fs.fstatSync(fd).size;
|
|
22
|
+
if (size <= MAX_PROGRESS_BYTES) return;
|
|
23
|
+
const from = Math.max(0, size - KEEP_PROGRESS_BYTES);
|
|
24
|
+
const tail = Buffer.alloc(size - from);
|
|
25
|
+
fs.readSync(fd, tail, 0, tail.length, from);
|
|
26
|
+
recent = tail.subarray(from ? tail.indexOf(0x0a) + 1 : 0).toString('utf8');
|
|
27
|
+
let end = size;
|
|
28
|
+
let partial = Buffer.alloc(0);
|
|
29
|
+
while (end > 0 && !started) {
|
|
30
|
+
const begin = Math.max(0, end - KEEP_PROGRESS_BYTES);
|
|
31
|
+
const chunk = Buffer.alloc(end - begin);
|
|
32
|
+
fs.readSync(fd, chunk, 0, chunk.length, begin);
|
|
33
|
+
const combined = Buffer.concat([chunk, partial]);
|
|
34
|
+
const boundary = begin ? combined.indexOf(0x0a) : -1;
|
|
35
|
+
partial = begin ? combined.subarray(0, boundary < 0 ? combined.length : boundary) : Buffer.alloc(0);
|
|
36
|
+
const lines = combined.subarray(begin ? boundary < 0 ? combined.length : boundary + 1 : 0).toString('utf8').split('\n');
|
|
37
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
38
|
+
if (/^\S+ \S+ started:/.test(lines[i])) { started = lines[i].slice(0, 500); break; }
|
|
39
|
+
}
|
|
40
|
+
// Malformed legacy lines must not make the backwards scan unbounded.
|
|
41
|
+
if (partial.length > KEEP_PROGRESS_BYTES) partial = Buffer.alloc(0);
|
|
42
|
+
end = begin;
|
|
43
|
+
}
|
|
44
|
+
} finally { fs.closeSync(fd); }
|
|
45
|
+
const prefix = started && !recent.includes(`${started}\n`) ? `${started}\n` : '';
|
|
46
|
+
const tmp = `${file}.${process.pid}.${newRunToken()}.tmp`;
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(tmp, prefix + recent, 'utf8');
|
|
49
|
+
fs.renameSync(tmp, file);
|
|
50
|
+
} finally {
|
|
51
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
10
55
|
function realish(p) {
|
|
11
56
|
try { return fs.realpathSync(p); } catch {}
|
|
12
57
|
try { return path.join(fs.realpathSync(path.dirname(p)), path.basename(p)); } catch {}
|
|
@@ -144,7 +189,9 @@ function appendProgress(member, line, missionId) {
|
|
|
144
189
|
const dir = progressDir();
|
|
145
190
|
fs.mkdirSync(dir, { recursive: true });
|
|
146
191
|
const entry = `${new Date().toISOString()} ${missionId || '-'} ${shortenLine(line)}\n`;
|
|
147
|
-
|
|
192
|
+
const file = path.join(dir, `${safeMember(member)}.log`);
|
|
193
|
+
fs.appendFileSync(file, entry, 'utf8');
|
|
194
|
+
trimProgress(file);
|
|
148
195
|
} catch {}
|
|
149
196
|
}
|
|
150
197
|
|