atris 3.69.0 → 3.70.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 +22 -1
- package/commands/member.js +78 -9
- package/commands/mission.js +83 -5
- package/commands/task.js +63 -1
- package/commands/worktree.js +42 -5
- package/lib/engine-ask.js +2 -1
- package/lib/engine-validate.js +189 -9
- package/lib/member-autoland.js +100 -8
- package/lib/member-board.js +80 -10
- package/lib/member-report.js +314 -0
- package/lib/member-scaffold.js +1 -2
- package/lib/member-switches.js +7 -0
- package/lib/task-db.js +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `atris member report [--since <time>] [--json]`: a short per-member summary
|
|
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
|
|
6
|
+
// finished diary mission ticks whose check passed, accepted tasks, completed missions
|
|
7
|
+
// turned down tasks a person sent back (task db) and tasks set aside (diary), with the reason
|
|
8
|
+
// waiting tasks sitting in review for a person right now (task db)
|
|
9
|
+
// Diary headings are `## HH:MM · title` in local time and the file name holds
|
|
10
|
+
// the date, so every time here is local; nothing prints in UTC.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_SINCE = '24h';
|
|
16
|
+
const PER_GROUP = 3;
|
|
17
|
+
const LOG_NAME_RE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
|
|
18
|
+
const HEADING_RE = /^##\s+(\d{1,2}):(\d{2})\s*(?:·|-|:)?\s*(.*)$/;
|
|
19
|
+
const FIELD_RE = /^-\s+([a-z_][a-z0-9_ ]*):\s*(.*)$/i;
|
|
20
|
+
const URL_RE = /https?:\/\/\S+/;
|
|
21
|
+
const GROUPS = [
|
|
22
|
+
['merged', 'merged'],
|
|
23
|
+
['finished', 'finished'],
|
|
24
|
+
['turned_down', 'turned down'],
|
|
25
|
+
['waiting', 'not merged yet'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function usage() {
|
|
29
|
+
return [
|
|
30
|
+
'usage:',
|
|
31
|
+
' atris member report [--since <time>] [--json]',
|
|
32
|
+
'',
|
|
33
|
+
'a short summary of what each member got merged, finished, had turned down,',
|
|
34
|
+
'and what is done but not merged yet.',
|
|
35
|
+
'',
|
|
36
|
+
'options:',
|
|
37
|
+
' --since <time> 2h, 30m, 3d, today, yesterday, a date, or an iso time (default: 24h)',
|
|
38
|
+
' --json print the report as json',
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function localMidnight(date) {
|
|
43
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Returns a Date, or null when the text is not a time we understand.
|
|
47
|
+
function parseSince(text, now = new Date()) {
|
|
48
|
+
const raw = String(text || '').trim().toLowerCase();
|
|
49
|
+
if (!raw) return null;
|
|
50
|
+
if (raw === 'today') return localMidnight(now);
|
|
51
|
+
if (raw === 'yesterday') {
|
|
52
|
+
const day = localMidnight(now);
|
|
53
|
+
day.setDate(day.getDate() - 1);
|
|
54
|
+
return day;
|
|
55
|
+
}
|
|
56
|
+
const relative = raw.match(/^(\d+)\s*(m|min|h|hr|d|day|days)$/);
|
|
57
|
+
if (relative) {
|
|
58
|
+
const unit = relative[2][0];
|
|
59
|
+
const ms = Number(relative[1]) * (unit === 'm' ? 60e3 : unit === 'h' ? 3600e3 : 86400e3);
|
|
60
|
+
return new Date(now.getTime() - ms);
|
|
61
|
+
}
|
|
62
|
+
const dateOnly = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
63
|
+
if (dateOnly) return new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]));
|
|
64
|
+
const ms = Date.parse(String(text).trim());
|
|
65
|
+
return Number.isFinite(ms) ? new Date(ms) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseReportArgs(args = []) {
|
|
69
|
+
let since = DEFAULT_SINCE;
|
|
70
|
+
let json = false;
|
|
71
|
+
let help = false;
|
|
72
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
73
|
+
const arg = String(args[index]);
|
|
74
|
+
if (arg === '--help' || arg === '-h') { help = true; continue; }
|
|
75
|
+
if (arg === '--json') { json = true; continue; }
|
|
76
|
+
if (arg === '--since') {
|
|
77
|
+
if (index + 1 >= args.length) throw new Error('--since needs a time, like 2h or yesterday');
|
|
78
|
+
since = String(args[index + 1]);
|
|
79
|
+
index += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg.startsWith('--since=')) { since = arg.slice('--since='.length); continue; }
|
|
83
|
+
throw new Error(`unknown option ${arg}\n${usage()}`);
|
|
84
|
+
}
|
|
85
|
+
return { help, since, json };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseDiary(text, year, month, day) {
|
|
89
|
+
const entries = [];
|
|
90
|
+
let current = null;
|
|
91
|
+
for (const line of String(text || '').split('\n')) {
|
|
92
|
+
const heading = line.match(HEADING_RE);
|
|
93
|
+
if (heading) {
|
|
94
|
+
const at = new Date(year, month - 1, day, Number(heading[1]), Number(heading[2]));
|
|
95
|
+
current = { at, title: heading[3].trim(), fields: {} };
|
|
96
|
+
entries.push(current);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const field = current && line.match(FIELD_RE);
|
|
100
|
+
if (field) {
|
|
101
|
+
const key = field[1].trim().toLowerCase();
|
|
102
|
+
if (!(key in current.fields)) current.fields[key] = field[2].trim();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function memberNames(root) {
|
|
109
|
+
const teamDir = path.join(root, 'atris', 'team');
|
|
110
|
+
if (!fs.existsSync(teamDir)) return null;
|
|
111
|
+
return fs.readdirSync(teamDir, { withFileTypes: true })
|
|
112
|
+
.filter((entry) => entry.isDirectory() && !/^[._]/.test(entry.name))
|
|
113
|
+
.map((entry) => entry.name)
|
|
114
|
+
.sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function diaryEntriesSince(root, member, since) {
|
|
118
|
+
const logsDir = path.join(root, 'atris', 'team', member, 'logs');
|
|
119
|
+
if (!fs.existsSync(logsDir)) return [];
|
|
120
|
+
const sinceDay = localMidnight(since).getTime();
|
|
121
|
+
const entries = [];
|
|
122
|
+
for (const name of fs.readdirSync(logsDir).sort()) {
|
|
123
|
+
const match = name.match(LOG_NAME_RE);
|
|
124
|
+
if (!match) continue;
|
|
125
|
+
const [year, month, day] = match.slice(1).map(Number);
|
|
126
|
+
if (new Date(year, month - 1, day).getTime() < sinceDay) continue;
|
|
127
|
+
const text = fs.readFileSync(path.join(logsDir, name), 'utf8');
|
|
128
|
+
for (const entry of parseDiary(text, year, month, day)) {
|
|
129
|
+
if (entry.at.getTime() >= since.getTime()) entries.push(entry);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return entries;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// One diary entry becomes at most one summary item, or null when it is
|
|
136
|
+
// bookkeeping (claimed, started, notes, ticks that did not pass their check).
|
|
137
|
+
function classifyDiaryEntry(entry) {
|
|
138
|
+
const f = entry.fields;
|
|
139
|
+
const kind = entry.title.replace(/\s+\d+$/, '');
|
|
140
|
+
if (kind === 'Task completed' && f.action === 'merged') {
|
|
141
|
+
const link = (String(f.proof || '').match(URL_RE) || [''])[0].replace(/[).,]+$/, '');
|
|
142
|
+
return { group: 'merged', title: f.title, link: link || null };
|
|
143
|
+
}
|
|
144
|
+
if (kind === 'Task completed' || kind === 'Task accepted') return { group: 'finished', title: f.title };
|
|
145
|
+
if (kind === 'Mission completed') return { group: 'finished', title: f.mission || f.title };
|
|
146
|
+
if (kind === 'Mission run tick' && f.verifier === 'passed') {
|
|
147
|
+
return { group: 'finished', title: f.mission, detail: f.summary };
|
|
148
|
+
}
|
|
149
|
+
if (kind === 'Task archived' || kind === 'Task failed') {
|
|
150
|
+
return { group: 'turned_down', title: f.title, reason: f.proof || f.reason || null };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Rows from the task db for this workspace: who sent work back and what is
|
|
156
|
+
// waiting on a person. Tasks with no team member behind them are skipped.
|
|
157
|
+
function taskItems(tasks, sinceMs, members) {
|
|
158
|
+
const known = new Set(members);
|
|
159
|
+
const items = [];
|
|
160
|
+
for (const task of tasks || []) {
|
|
161
|
+
const meta = task && task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
|
|
162
|
+
const member = [meta.built_by, meta.assigned_to, task.claimed_by].find((name) => name && known.has(name));
|
|
163
|
+
if (!member) continue;
|
|
164
|
+
if (task.status !== 'review') continue;
|
|
165
|
+
if (meta.approval_status === 'revise') {
|
|
166
|
+
const at = Date.parse(meta.human_revision_at || '');
|
|
167
|
+
if (Number.isFinite(at) && at >= sinceMs) {
|
|
168
|
+
items.push({ member, at, group: 'turned_down', title: task.title, reason: meta.human_revision_note || null });
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
items.push({ member, at: Number(task.updated_at) || 0, group: 'waiting', title: task.title });
|
|
173
|
+
}
|
|
174
|
+
return items;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sameTitle(a, b) {
|
|
178
|
+
return String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function summarize(items) {
|
|
182
|
+
const groups = { merged: [], finished: [], turned_down: [], waiting: [] };
|
|
183
|
+
const sorted = [...items].sort((a, b) => b.at - a.at);
|
|
184
|
+
for (const item of sorted) {
|
|
185
|
+
if (!item.title) continue;
|
|
186
|
+
// Newest word on a piece of work wins, and merged beats finished.
|
|
187
|
+
if (Object.values(groups).some((list) => list.some((seen) => sameTitle(seen.title, item.title)))) continue;
|
|
188
|
+
const { member, at, group, ...rest } = item;
|
|
189
|
+
groups[group].push(rest);
|
|
190
|
+
}
|
|
191
|
+
return groups;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function memberReport(root, { since = DEFAULT_SINCE, now = new Date(), tasks = [] } = {}) {
|
|
195
|
+
const sinceAt = parseSince(since, now);
|
|
196
|
+
if (!sinceAt) throw new Error(`--since "${since}" is not a time; try 2h, yesterday, or 2026-10-14`);
|
|
197
|
+
const names = memberNames(root);
|
|
198
|
+
if (!names) throw new Error('no atris/team folder here; run this inside an atris workspace');
|
|
199
|
+
const fromTasks = taskItems(tasks, sinceAt.getTime(), names);
|
|
200
|
+
const members = names
|
|
201
|
+
.map((member) => {
|
|
202
|
+
const items = diaryEntriesSince(root, member, sinceAt)
|
|
203
|
+
.map((entry) => {
|
|
204
|
+
const item = classifyDiaryEntry(entry);
|
|
205
|
+
return item ? { member, at: entry.at.getTime(), ...item } : null;
|
|
206
|
+
})
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.concat(fromTasks.filter((item) => item.member === member));
|
|
209
|
+
const groups = summarize(items);
|
|
210
|
+
const total = Object.values(groups).reduce((sum, list) => sum + list.length, 0);
|
|
211
|
+
return { member, total, ...groups };
|
|
212
|
+
})
|
|
213
|
+
.filter((row) => row.total > 0)
|
|
214
|
+
.sort((a, b) => b.total - a.total || a.member.localeCompare(b.member));
|
|
215
|
+
const totals = {};
|
|
216
|
+
for (const [key] of GROUPS) totals[key] = members.reduce((sum, row) => sum + row[key].length, 0);
|
|
217
|
+
return { ok: true, action: 'report', since: localStamp(sinceAt, now), totals, members };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function pad(n) {
|
|
221
|
+
return String(n).padStart(2, '0');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// "14:05" today, "yesterday 14:05", or "10-12 14:05"; always the local clock.
|
|
225
|
+
function localStamp(at, now) {
|
|
226
|
+
const time = `${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
|
227
|
+
const days = Math.round((localMidnight(now) - localMidnight(at)) / 86400e3);
|
|
228
|
+
if (days === 0) return time;
|
|
229
|
+
if (days === 1) return `yesterday ${time}`;
|
|
230
|
+
return `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${time}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function clip(text, max) {
|
|
234
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim();
|
|
235
|
+
return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The first sentence of a tick summary says what changed in plain words; the
|
|
239
|
+
// mission text is the job description, so it is only the fallback.
|
|
240
|
+
function firstSentence(text) {
|
|
241
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim();
|
|
242
|
+
const match = flat.match(/^.*?[.!?](?=\s|$)/);
|
|
243
|
+
return match ? match[0] : flat;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function itemLine(key, item) {
|
|
247
|
+
if (key === 'finished' && item.detail) return clip(firstSentence(item.detail), 90);
|
|
248
|
+
if (key === 'merged') return item.link ? `${clip(item.title, 70)} ${item.link}` : clip(item.title, 90);
|
|
249
|
+
if (key === 'turned_down') return item.reason ? `${clip(item.title, 50)}: ${clip(item.reason, 70)}` : clip(item.title, 90);
|
|
250
|
+
return clip(item.title, 90);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function formatReport(report) {
|
|
254
|
+
const lines = ['', `what the team did since ${report.since}`];
|
|
255
|
+
if (!report.members.length) {
|
|
256
|
+
lines.push('', ' nothing merged, finished, turned down, or done since then.', '');
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
259
|
+
lines.push(GROUPS.map(([key, label]) => `${report.totals[key]} ${label}`).join(', '));
|
|
260
|
+
for (const row of report.members) {
|
|
261
|
+
lines.push('', row.member);
|
|
262
|
+
for (const [key, label] of GROUPS) {
|
|
263
|
+
const list = row[key];
|
|
264
|
+
list.slice(0, PER_GROUP).forEach((item, index) => {
|
|
265
|
+
lines.push(` ${(index === 0 ? label : '').padEnd(15)}${itemLine(key, item)}`);
|
|
266
|
+
});
|
|
267
|
+
if (list.length > PER_GROUP) lines.push(` ${''.padEnd(15)}and ${list.length - PER_GROUP} more`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
lines.push('');
|
|
271
|
+
return lines.join('\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function loadTasks(root) {
|
|
275
|
+
try {
|
|
276
|
+
const taskDb = require('./task-db');
|
|
277
|
+
return taskDb.listTasks(taskDb.open(), { workspaceRoot: taskDb.workspaceRoot(root) });
|
|
278
|
+
} catch {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function memberReportCommand(...args) {
|
|
284
|
+
let parsed;
|
|
285
|
+
try {
|
|
286
|
+
parsed = parseReportArgs(args);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.error(error.message);
|
|
289
|
+
process.exitCode = 1;
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
if (parsed.help) {
|
|
293
|
+
console.log(usage());
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
let report;
|
|
297
|
+
try {
|
|
298
|
+
const root = process.cwd();
|
|
299
|
+
report = memberReport(root, { since: parsed.since, tasks: loadTasks(root) });
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.error(error.message);
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
console.log(parsed.json ? JSON.stringify(report, null, 2) : formatReport(report));
|
|
306
|
+
return report;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
formatReport,
|
|
311
|
+
memberReport,
|
|
312
|
+
memberReportCommand,
|
|
313
|
+
parseSince,
|
|
314
|
+
};
|
package/lib/member-scaffold.js
CHANGED
|
@@ -139,8 +139,7 @@ function initialMemberLog({ name, role, description, source, now }) {
|
|
|
139
139
|
description ? `- mission: ${description}` : '',
|
|
140
140
|
`- source: ${source}`,
|
|
141
141
|
'- status: ready_for_room',
|
|
142
|
-
|
|
143
|
-
].filter(Boolean).join('\n');
|
|
142
|
+
].filter(Boolean).join('\n') + '\n';
|
|
144
143
|
}
|
|
145
144
|
|
|
146
145
|
function ensureMemberBundle(memberDir, {
|
package/lib/member-switches.js
CHANGED
|
@@ -64,6 +64,12 @@ function isMemberAwake(name, root = process.cwd()) {
|
|
|
64
64
|
return getMemberSwitch(name, root).awake !== false;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Names of members whose switch is off, so boards can show them asleep.
|
|
68
|
+
function asleepMembers(root = process.cwd()) {
|
|
69
|
+
const { members } = loadMemberSwitches(root);
|
|
70
|
+
return Object.keys(members).filter((name) => normalizeMemberEntry(members[name]).awake === false).sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
function isMemberLoopAwake(name, loopId, root = process.cwd()) {
|
|
68
74
|
const entry = getMemberSwitch(name, root);
|
|
69
75
|
if (entry.awake === false) return false;
|
|
@@ -132,6 +138,7 @@ module.exports = {
|
|
|
132
138
|
isLocalMember,
|
|
133
139
|
getMemberSwitch,
|
|
134
140
|
isMemberAwake,
|
|
141
|
+
asleepMembers,
|
|
135
142
|
setMemberAwake,
|
|
136
143
|
setMemberLoopAwake,
|
|
137
144
|
readLoopFlag,
|
package/lib/task-db.js
CHANGED
|
@@ -454,6 +454,21 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
|
|
|
454
454
|
return { id, inserted: true };
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
// Same job = same title ignoring case and spacing. Only jobs not yet handed in
|
|
458
|
+
// (open or claimed) count, so a job in review, done, or failed can be filed again.
|
|
459
|
+
function normalizeTaskTitle(title) {
|
|
460
|
+
return String(title || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function findUnfinishedTaskByTitle(db, { workspaceRoot: ws, title }) {
|
|
464
|
+
const wanted = normalizeTaskTitle(title);
|
|
465
|
+
if (!wanted) return null;
|
|
466
|
+
const rows = db.prepare(
|
|
467
|
+
"SELECT id, title FROM tasks WHERE workspace_root = ? AND status IN ('open', 'claimed') ORDER BY created_at ASC"
|
|
468
|
+
).all(ws);
|
|
469
|
+
return rows.find((row) => normalizeTaskTitle(row.title) === wanted) || null;
|
|
470
|
+
}
|
|
471
|
+
|
|
457
472
|
// A main checkout's view also covers its linked git worktrees, whose tasks keep
|
|
458
473
|
// the worktree as workspace_root so checks run where the work lives. Only a
|
|
459
474
|
// main checkout with linked worktrees spawns git; any error means exact match.
|
|
@@ -2304,6 +2319,7 @@ module.exports = {
|
|
|
2304
2319
|
sourceKey,
|
|
2305
2320
|
normalizeTitle,
|
|
2306
2321
|
addTask,
|
|
2322
|
+
findUnfinishedTaskByTitle,
|
|
2307
2323
|
getTask,
|
|
2308
2324
|
listTasks,
|
|
2309
2325
|
listTasksCached,
|