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
|
@@ -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
|
|
package/lib/member-report.js
CHANGED
|
@@ -2,18 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
// `atris member report [--since <time>] [--json]`: a short per-member summary
|
|
4
4
|
// of what the team did. Four groups, newest first, one line per piece of work:
|
|
5
|
-
// merged diary "Task completed" with action merged, plus the PR link
|
|
5
|
+
// merged diary "Task completed" with action merged, plus the PR link, and
|
|
6
|
+
// pull requests GitHub merged from a member's branch (codex/<member>-...)
|
|
6
7
|
// finished diary mission ticks whose check passed, accepted tasks, completed missions
|
|
7
8
|
// turned down tasks a person sent back (task db) and tasks set aside (diary), with the reason
|
|
8
9
|
// waiting tasks sitting in review for a person right now (task db)
|
|
9
10
|
// Diary headings are `## HH:MM · title` in local time and the file name holds
|
|
10
11
|
// the date, so every time here is local; nothing prints in UTC.
|
|
12
|
+
// Merged and finished lines also say how long the job took (first pickup to
|
|
13
|
+
// finish) and how many tries it needed (every run, failed or timed out too).
|
|
11
14
|
|
|
12
15
|
const fs = require('fs');
|
|
13
16
|
const path = require('path');
|
|
17
|
+
const { spawnSync } = require('child_process');
|
|
14
18
|
|
|
15
19
|
const DEFAULT_SINCE = '24h';
|
|
16
20
|
const PER_GROUP = 3;
|
|
21
|
+
// A job picked up before the window still gets its real start time.
|
|
22
|
+
const HISTORY_DAYS = 7;
|
|
17
23
|
const LOG_NAME_RE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
|
|
18
24
|
const HEADING_RE = /^##\s+(\d{1,2}):(\d{2})\s*(?:·|-|:)?\s*(.*)$/;
|
|
19
25
|
const FIELD_RE = /^-\s+([a-z_][a-z0-9_ ]*):\s*(.*)$/i;
|
|
@@ -174,6 +180,70 @@ function taskItems(tasks, sinceMs, members) {
|
|
|
174
180
|
return items;
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
function jobKey(text) {
|
|
184
|
+
return String(text || '').trim().toLowerCase();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Walks one member's diary oldest first and returns, for each entry that
|
|
188
|
+
// finishes a job, { took_minutes, tries }. A job opens at its first pickup and
|
|
189
|
+
// stays open through failed runs and re-pickups; a pickup after a passing run
|
|
190
|
+
// opens a new job. Finishes with no pickup in the diary get nothing.
|
|
191
|
+
function jobTimings(entries) {
|
|
192
|
+
const jobs = new Map();
|
|
193
|
+
const stamps = new Map();
|
|
194
|
+
for (const entry of [...entries].sort((a, b) => a.at - b.at)) {
|
|
195
|
+
const f = entry.fields;
|
|
196
|
+
const kind = entry.title.replace(/\s+\d+$/, '');
|
|
197
|
+
if (kind === 'Task claimed' || kind === 'Mission started') {
|
|
198
|
+
const key = jobKey(kind === 'Task claimed' ? f.title : f.mission);
|
|
199
|
+
const job = jobs.get(key);
|
|
200
|
+
if (key && (!job || job.done)) jobs.set(key, { startAt: entry.at.getTime(), tries: 0, done: false });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const finishKey = kind === 'Mission run tick' ? jobKey(f.mission)
|
|
204
|
+
: kind === 'Mission completed' ? jobKey(f.mission || f.title)
|
|
205
|
+
: kind === 'Task completed' || kind === 'Task accepted' ? jobKey(f.title) : null;
|
|
206
|
+
const job = finishKey && jobs.get(finishKey);
|
|
207
|
+
if (!job) continue;
|
|
208
|
+
if (kind === 'Mission run tick') {
|
|
209
|
+
if (job.done) continue;
|
|
210
|
+
job.tries += 1;
|
|
211
|
+
if (f.verifier !== 'passed') continue;
|
|
212
|
+
job.done = true;
|
|
213
|
+
}
|
|
214
|
+
const stamp = { took_minutes: Math.round((entry.at.getTime() - job.startAt) / 60e3) };
|
|
215
|
+
if (job.tries) stamp.tries = job.tries;
|
|
216
|
+
stamps.set(entry, stamp);
|
|
217
|
+
}
|
|
218
|
+
return stamps;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Builder branches are `<engine>/<member>-<slug>`; the longest member name that
|
|
222
|
+
// fits wins, so `codex-executor-x` is codex-executor, not a member named codex.
|
|
223
|
+
function memberFromBranch(branch, members) {
|
|
224
|
+
const tail = String(branch || '').split('/').slice(1).join('/');
|
|
225
|
+
if (!tail) return null;
|
|
226
|
+
let best = null;
|
|
227
|
+
for (const name of members) {
|
|
228
|
+
if ((tail === name || tail.startsWith(`${name}-`)) && (!best || name.length > best.length)) best = name;
|
|
229
|
+
}
|
|
230
|
+
return best;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Pull requests GitHub merged since the cutoff, credited to the member whose
|
|
234
|
+
// branch they came from. A merge nobody wrote down still counts.
|
|
235
|
+
function githubMergeItems(prs, sinceMs, members) {
|
|
236
|
+
const items = [];
|
|
237
|
+
for (const pr of prs || []) {
|
|
238
|
+
const at = Date.parse(pr && pr.mergedAt || '');
|
|
239
|
+
if (!Number.isFinite(at) || at < sinceMs) continue;
|
|
240
|
+
const member = memberFromBranch(pr.headRefName, members);
|
|
241
|
+
if (!member) continue;
|
|
242
|
+
items.push({ member, at, group: 'merged', title: pr.title, link: pr.url || null });
|
|
243
|
+
}
|
|
244
|
+
return items;
|
|
245
|
+
}
|
|
246
|
+
|
|
177
247
|
function sameTitle(a, b) {
|
|
178
248
|
return String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();
|
|
179
249
|
}
|
|
@@ -183,26 +253,35 @@ function summarize(items) {
|
|
|
183
253
|
const sorted = [...items].sort((a, b) => b.at - a.at);
|
|
184
254
|
for (const item of sorted) {
|
|
185
255
|
if (!item.title) continue;
|
|
186
|
-
// Newest word on a piece of work wins, and merged beats finished.
|
|
187
|
-
|
|
256
|
+
// Newest word on a piece of work wins, and merged beats finished. A pull
|
|
257
|
+
// request a diary already logged shares its link, so it counts once.
|
|
258
|
+
const seenBefore = (seen) => sameTitle(seen.title, item.title) || (item.link && seen.link === item.link);
|
|
259
|
+
if (Object.values(groups).some((list) => list.some(seenBefore))) continue;
|
|
188
260
|
const { member, at, group, ...rest } = item;
|
|
189
261
|
groups[group].push(rest);
|
|
190
262
|
}
|
|
191
263
|
return groups;
|
|
192
264
|
}
|
|
193
265
|
|
|
194
|
-
function memberReport(root, { since = DEFAULT_SINCE, now = new Date(), tasks = [] } = {}) {
|
|
266
|
+
function memberReport(root, { since = DEFAULT_SINCE, now = new Date(), tasks = [], merges = [] } = {}) {
|
|
195
267
|
const sinceAt = parseSince(since, now);
|
|
196
268
|
if (!sinceAt) throw new Error(`--since "${since}" is not a time; try 2h, yesterday, or 2026-10-14`);
|
|
197
269
|
const names = memberNames(root);
|
|
198
270
|
if (!names) throw new Error('no atris/team folder here; run this inside an atris workspace');
|
|
199
|
-
const fromTasks = taskItems(tasks, sinceAt.getTime(), names)
|
|
271
|
+
const fromTasks = taskItems(tasks, sinceAt.getTime(), names)
|
|
272
|
+
.concat(githubMergeItems(merges, sinceAt.getTime(), names));
|
|
273
|
+
const historyAt = new Date(sinceAt.getTime() - HISTORY_DAYS * 86400e3);
|
|
200
274
|
const members = names
|
|
201
275
|
.map((member) => {
|
|
202
|
-
const
|
|
276
|
+
const history = diaryEntriesSince(root, member, historyAt);
|
|
277
|
+
const timings = jobTimings(history);
|
|
278
|
+
const items = history
|
|
279
|
+
.filter((entry) => entry.at.getTime() >= sinceAt.getTime())
|
|
203
280
|
.map((entry) => {
|
|
204
281
|
const item = classifyDiaryEntry(entry);
|
|
205
|
-
|
|
282
|
+
if (!item) return null;
|
|
283
|
+
const timing = item.group === 'merged' || item.group === 'finished' ? timings.get(entry) : null;
|
|
284
|
+
return { member, at: entry.at.getTime(), ...item, ...timing };
|
|
206
285
|
})
|
|
207
286
|
.filter(Boolean)
|
|
208
287
|
.concat(fromTasks.filter((item) => item.member === member));
|
|
@@ -243,7 +322,26 @@ function firstSentence(text) {
|
|
|
243
322
|
return match ? match[0] : flat;
|
|
244
323
|
}
|
|
245
324
|
|
|
325
|
+
// "took 3h 30m, 2 tries"; empty when the diary never showed the pickup.
|
|
326
|
+
function timingText(item) {
|
|
327
|
+
if (!Number.isFinite(item.took_minutes)) return '';
|
|
328
|
+
const minutes = item.took_minutes;
|
|
329
|
+
const days = Math.floor(minutes / 1440);
|
|
330
|
+
const hours = Math.floor((minutes % 1440) / 60);
|
|
331
|
+
const mins = minutes % 60;
|
|
332
|
+
const took = minutes < 1 ? 'under a minute'
|
|
333
|
+
: days ? `${days}d${hours ? ` ${hours}h` : ''}`
|
|
334
|
+
: hours ? `${hours}h${mins ? ` ${mins}m` : ''}` : `${mins}m`;
|
|
335
|
+
const tries = item.tries ? `, ${item.tries} ${item.tries === 1 ? 'try' : 'tries'}` : '';
|
|
336
|
+
return ` took ${took}${tries}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
246
339
|
function itemLine(key, item) {
|
|
340
|
+
if (key === 'merged' || key === 'finished') return baseLine(key, item) + timingText(item);
|
|
341
|
+
return baseLine(key, item);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function baseLine(key, item) {
|
|
247
345
|
if (key === 'finished' && item.detail) return clip(firstSentence(item.detail), 90);
|
|
248
346
|
if (key === 'merged') return item.link ? `${clip(item.title, 70)} ${item.link}` : clip(item.title, 90);
|
|
249
347
|
if (key === 'turned_down') return item.reason ? `${clip(item.title, 50)}: ${clip(item.reason, 70)}` : clip(item.title, 90);
|
|
@@ -280,6 +378,23 @@ function loadTasks(root) {
|
|
|
280
378
|
}
|
|
281
379
|
}
|
|
282
380
|
|
|
381
|
+
// Merged pull requests from GitHub for this checkout. No gh, no remote, or no
|
|
382
|
+
// login means an empty list: the report still prints what the diaries say.
|
|
383
|
+
function loadGithubMerges(root, since) {
|
|
384
|
+
const day = new Date(since.getTime() - 86400e3).toISOString().slice(0, 10);
|
|
385
|
+
const result = spawnSync('gh', [
|
|
386
|
+
'pr', 'list', '--state', 'merged', '--search', `merged:>=${day}`, '--limit', '200',
|
|
387
|
+
'--json', 'title,headRefName,mergedAt,url',
|
|
388
|
+
], { cwd: root, encoding: 'utf8', timeout: 20000 });
|
|
389
|
+
if (result.status !== 0) return [];
|
|
390
|
+
try {
|
|
391
|
+
const prs = JSON.parse(result.stdout);
|
|
392
|
+
return Array.isArray(prs) ? prs : [];
|
|
393
|
+
} catch {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
283
398
|
function memberReportCommand(...args) {
|
|
284
399
|
let parsed;
|
|
285
400
|
try {
|
|
@@ -296,7 +411,9 @@ function memberReportCommand(...args) {
|
|
|
296
411
|
let report;
|
|
297
412
|
try {
|
|
298
413
|
const root = process.cwd();
|
|
299
|
-
|
|
414
|
+
const sinceAt = parseSince(parsed.since);
|
|
415
|
+
const merges = sinceAt ? loadGithubMerges(root, sinceAt) : [];
|
|
416
|
+
report = memberReport(root, { since: parsed.since, tasks: loadTasks(root), merges });
|
|
300
417
|
} catch (error) {
|
|
301
418
|
console.error(error.message);
|
|
302
419
|
process.exitCode = 1;
|
|
@@ -308,6 +425,8 @@ function memberReportCommand(...args) {
|
|
|
308
425
|
|
|
309
426
|
module.exports = {
|
|
310
427
|
formatReport,
|
|
428
|
+
githubMergeItems,
|
|
429
|
+
memberFromBranch,
|
|
311
430
|
memberReport,
|
|
312
431
|
memberReportCommand,
|
|
313
432
|
parseSince,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// When a member's job list is empty, it writes its own next job from its
|
|
4
|
+
// standing mission (atris/team/<member>/MISSION.md) and keeps working. The
|
|
5
|
+
// job is a normal open task: the North Star is the goal, and the mission's
|
|
6
|
+
// `## Check` section names the command that proves a run. A mission with no
|
|
7
|
+
// runnable check writes nothing, so the member stays idle as before. At most
|
|
8
|
+
// one self-written job is open per member, and at most MAX_PER_DAY are
|
|
9
|
+
// written per member per local day, so an idle team cannot spin forever.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { skipReason, taskCheck } = require('./member-task-pick');
|
|
14
|
+
|
|
15
|
+
const MAX_PER_DAY = 12;
|
|
16
|
+
const TITLE_MAX = 120;
|
|
17
|
+
|
|
18
|
+
function oneLine(text) {
|
|
19
|
+
return String(text || '').replace(/\s+/g, ' ').trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The body of one `## <name>` section, up to the next `## ` heading.
|
|
23
|
+
function section(text, name) {
|
|
24
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
25
|
+
const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${name}`);
|
|
26
|
+
if (start < 0) return '';
|
|
27
|
+
const end = lines.findIndex((line, i) => i > start && /^##\s/.test(line));
|
|
28
|
+
return lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The job a mission file asks for next: its North Star as the goal and its
|
|
32
|
+
// check command. null when either is missing.
|
|
33
|
+
function missionNextJob(text) {
|
|
34
|
+
const northStar = oneLine(section(text, 'north star').split(/\n\s*\n/)[0]);
|
|
35
|
+
const checkBlock = section(text, 'check');
|
|
36
|
+
const tick = checkBlock.match(/`([^`\n]+)`/);
|
|
37
|
+
const check = oneLine(tick ? tick[1] : checkBlock.split(/\r?\n/).find((line) => line.trim()));
|
|
38
|
+
if (!northStar || !check) return null;
|
|
39
|
+
const goal = northStar.length > TITLE_MAX ? `${northStar.slice(0, TITLE_MAX - 3).trim()}...` : northStar;
|
|
40
|
+
return { title: `next step toward: ${goal}`, objective: northStar, check };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function localDay(ms) {
|
|
44
|
+
const d = new Date(ms);
|
|
45
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Write the member's next job from its mission. Returns { task } with the
|
|
49
|
+
// new open task, or { skip } with a plain reason. dryRun returns the task it
|
|
50
|
+
// would write without writing it.
|
|
51
|
+
function writeSelfNextJob(taskDb, db, { root, workspaceRoot, member, job, now = Date.now(), dryRun = false }) {
|
|
52
|
+
let text;
|
|
53
|
+
try {
|
|
54
|
+
text = fs.readFileSync(path.join(root, 'atris', 'team', member, 'MISSION.md'), 'utf8');
|
|
55
|
+
} catch {
|
|
56
|
+
return { skip: 'no mission file' };
|
|
57
|
+
}
|
|
58
|
+
const next = missionNextJob(text);
|
|
59
|
+
if (!next) return { skip: 'mission names no north star or no check' };
|
|
60
|
+
const metadata = {
|
|
61
|
+
verify: next.check,
|
|
62
|
+
objective: next.objective,
|
|
63
|
+
written_by: member,
|
|
64
|
+
source: 'member mission',
|
|
65
|
+
};
|
|
66
|
+
const probe = { status: 'open', title: next.title, tag: job, metadata };
|
|
67
|
+
if (!taskCheck(probe).runnable) return { skip: 'mission check cannot be run here' };
|
|
68
|
+
const held = skipReason(probe, job, { requireCheck: true, member });
|
|
69
|
+
if (held) return { skip: `the job it would write is not one it can take (${held})` };
|
|
70
|
+
|
|
71
|
+
const prefix = `self-next:${member}:`;
|
|
72
|
+
const mine = db.prepare(
|
|
73
|
+
'SELECT id, status, source_key FROM tasks WHERE workspace_root = ? AND source_key LIKE ?'
|
|
74
|
+
).all(workspaceRoot, `${prefix}%`);
|
|
75
|
+
if (mine.some((row) => row.status === 'open' || row.status === 'claimed')) {
|
|
76
|
+
return { skip: 'its last self-written job is still open' };
|
|
77
|
+
}
|
|
78
|
+
const day = localDay(now);
|
|
79
|
+
const today = mine.filter((row) => String(row.source_key).startsWith(`${prefix}${day}:`)).length;
|
|
80
|
+
if (today >= MAX_PER_DAY) return { skip: `already wrote ${MAX_PER_DAY} jobs for itself today` };
|
|
81
|
+
|
|
82
|
+
if (dryRun) return { task: { id: null, ...probe } };
|
|
83
|
+
const added = taskDb.addTask(db, {
|
|
84
|
+
title: next.title,
|
|
85
|
+
tag: job,
|
|
86
|
+
workspaceRoot,
|
|
87
|
+
sourceKey: `${prefix}${day}:${today + 1}`,
|
|
88
|
+
metadata,
|
|
89
|
+
});
|
|
90
|
+
return { task: taskDb.getTask(db, added.id) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
MAX_PER_DAY,
|
|
95
|
+
missionNextJob,
|
|
96
|
+
writeSelfNextJob,
|
|
97
|
+
};
|
package/lib/member-task-pick.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// The next real job for a member with no idea given: the oldest open,
|
|
4
|
-
// unclaimed task
|
|
5
|
-
//
|
|
4
|
+
// unclaimed task assigned to them, then an unassigned task whose job (build,
|
|
5
|
+
// review, search) matches theirs, held neither for a human nor in a protected lane. Callers
|
|
6
6
|
// fall back to the member's own goal when this returns null, unless they
|
|
7
7
|
// ask for requireCheck: then only tasks with a runnable check count, and
|
|
8
8
|
// nothing found means the member stays idle.
|
|
@@ -66,32 +66,58 @@ function taskCheck(task) {
|
|
|
66
66
|
return { command: verify, runnable: isAutoCertifyVerifyCommandAllowed(verify) };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
function assigneeOf(task) {
|
|
70
|
+
const assigned = task?.metadata?.assigned_to;
|
|
71
|
+
return typeof assigned === 'string' ? assigned.trim() : '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Held for a person, the one test both the picker and the merge-line close
|
|
75
|
+
// use, so a merge never closes a job the picker would leave alone.
|
|
76
|
+
function heldForPerson(task) {
|
|
72
77
|
if (isDecisionTask(task) || metadataTags(task).some(isDecisionHoldTag)) return 'needs_human';
|
|
73
78
|
if (protectedTask(task)) return 'protected_lane';
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function skipReason(task, job, { requireCheck = false, member, recoverClaimed = false } = {}) {
|
|
83
|
+
const ownClaim = recoverClaimed && member && task?.status === 'claimed' && task.claimed_by === member;
|
|
84
|
+
if (!task || (task.status !== 'open' && !ownClaim)) return 'not_open';
|
|
85
|
+
if (task.claimed_by && !ownClaim) return 'claimed';
|
|
86
|
+
const assignee = assigneeOf(task);
|
|
87
|
+
if (assignee && assignee !== member) return 'other_member';
|
|
88
|
+
const held = heldForPerson(task);
|
|
89
|
+
if (held) return held;
|
|
74
90
|
const check = taskCheck(task);
|
|
75
91
|
if (!check.runnable) return 'check_not_runnable';
|
|
76
92
|
if (requireCheck && !check.command) return 'no_check';
|
|
77
|
-
|
|
93
|
+
// A named assignment is more specific than the inferred job type.
|
|
94
|
+
if (!assignee && jobForTask(task) !== job) return 'other_job';
|
|
78
95
|
return '';
|
|
79
96
|
}
|
|
80
97
|
|
|
81
|
-
function fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck = false }) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
98
|
+
function fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck = false, member, recoverClaimed = false }) {
|
|
99
|
+
const tasks = taskDb.listTasks(db, { workspaceRoot, status: 'open' });
|
|
100
|
+
// Only a caller that checked the member has no live run may recover its
|
|
101
|
+
// own claim. Keep the same human, lane, job, and check gates.
|
|
102
|
+
if (recoverClaimed && member) tasks.push(...taskDb.listTasks(db, { workspaceRoot, status: 'claimed' }));
|
|
103
|
+
return tasks
|
|
104
|
+
.filter((task) => !skipReason(task, job, { requireCheck, member, recoverClaimed }))
|
|
105
|
+
.sort((a, b) => Number(Boolean(assigneeOf(b))) - Number(Boolean(assigneeOf(a)))
|
|
106
|
+
|| (a.created_at - b.created_at) || String(a.id).localeCompare(String(b.id)));
|
|
85
107
|
}
|
|
86
108
|
|
|
87
|
-
function pickTaskForMember(taskDb, db,
|
|
88
|
-
return fittingTasks(taskDb, db,
|
|
109
|
+
function pickTaskForMember(taskDb, db, options) {
|
|
110
|
+
return fittingTasks(taskDb, db, options)[0] || null;
|
|
89
111
|
}
|
|
90
112
|
|
|
91
113
|
// Claim the oldest fitting task for the member. A lost race moves on to the
|
|
92
114
|
// next candidate; null when nothing fits or every claim lost.
|
|
93
|
-
function claimTaskForMember(taskDb, db, { workspaceRoot, member, job, requireCheck = false }) {
|
|
94
|
-
for (const task of fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck })) {
|
|
115
|
+
function claimTaskForMember(taskDb, db, { workspaceRoot, member, job, requireCheck = false, recoverClaimed = false }) {
|
|
116
|
+
for (const task of fittingTasks(taskDb, db, { workspaceRoot, member, job, requireCheck, recoverClaimed })) {
|
|
117
|
+
if (task.status === 'claimed') {
|
|
118
|
+
const released = taskDb.releaseTask(db, { id: task.id, actor: member, expectedUpdatedAt: task.updated_at });
|
|
119
|
+
if (!released.released) continue;
|
|
120
|
+
}
|
|
95
121
|
let result;
|
|
96
122
|
try {
|
|
97
123
|
result = taskDb.claimTask(db, { id: task.id, claimedBy: member });
|
|
@@ -109,6 +135,7 @@ module.exports = {
|
|
|
109
135
|
taskCheck,
|
|
110
136
|
claimTaskForMember,
|
|
111
137
|
fittingTasks,
|
|
138
|
+
heldForPerson,
|
|
112
139
|
jobForTask,
|
|
113
140
|
pickTaskForMember,
|
|
114
141
|
skipReason,
|