atris 3.68.9 → 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.
@@ -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
+ };
@@ -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, {
@@ -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,
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ // The next real job for a member with no idea given: the oldest open,
4
+ // unclaimed task in this workspace whose job (build, review, search) matches
5
+ // the member's, held neither for a human nor in a protected lane. Callers
6
+ // fall back to the member's own goal when this returns null, unless they
7
+ // ask for requireCheck: then only tasks with a runnable check count, and
8
+ // nothing found means the member stays idle.
9
+
10
+ const { autoJobForMember } = require('./member-engine');
11
+ const { deniedTagOf, declaredProtectedLane, sniffedProtectedLane, isAutoCertifyVerifyCommandAllowed } = require('./auto-accept-certified');
12
+ const { isDecisionHoldTag, isDecisionTask, taskTagTokens } = require('./task-decision');
13
+
14
+ // A task's job uses the member rule with its tags in the name slot and its
15
+ // title in the role slot: tags decide first, then the title, else build.
16
+ function jobForTask(task) {
17
+ return autoJobForMember(taskTagTokens(task).join(' '), task && task.title);
18
+ }
19
+
20
+ function taskBody(task) {
21
+ const meta = (task && task.metadata) || {};
22
+ return ['objective', 'body', 'description', 'details']
23
+ .map((key) => (typeof meta[key] === 'string' ? meta[key].trim() : ''))
24
+ .find(Boolean) || '';
25
+ }
26
+
27
+ // The objective a member run gets: the title, then the body when there is one,
28
+ // on one line. Mission start refuses objectives with line breaks.
29
+ function taskObjective(task) {
30
+ const oneLine = (text) => String(text || '').replace(/\s+/g, ' ').trim();
31
+ const title = oneLine(task && task.title);
32
+ const body = oneLine(taskBody(task));
33
+ if (!body) return title;
34
+ return /[.!?]$/.test(title) ? `${title} ${body}` : `${title}. ${body}`;
35
+ }
36
+
37
+ // Held for a person: a denied tag on the row or in its metadata, a protected
38
+ // lane declared anywhere in its text, or lane terms in its title or body.
39
+ // Tags written in metadata, which the shared tag parser does not read.
40
+ function metadataTags(task) {
41
+ const meta = (task && task.metadata) || {};
42
+ return [meta.tag, ...(Array.isArray(meta.tags) ? meta.tags : [])]
43
+ .filter((t) => typeof t === 'string' && t.trim())
44
+ .map((t) => t.trim().toLowerCase().replace(/^#/, ''));
45
+ }
46
+
47
+ function protectedTask(task) {
48
+ const meta = (task && task.metadata) || {};
49
+ const tags = [...metadataTags(task), ...taskTagTokens(task)];
50
+ if (deniedTagOf(task) || tags.some((tag) => deniedTagOf({ tag }))) return true;
51
+ // Every text field, not just the one the objective uses.
52
+ const allText = ['objective', 'body', 'description', 'details']
53
+ .map((key) => (typeof meta[key] === 'string' ? meta[key] : ''))
54
+ .filter(Boolean)
55
+ .join('\n');
56
+ const probe = { title: task.title, objective: allText, metadata: meta };
57
+ return Boolean(declaredProtectedLane(probe) || sniffedProtectedLane(probe));
58
+ }
59
+
60
+ // The check a task requires, when it names one. A check written as prose
61
+ // that cannot be run means the task cannot be proven here.
62
+ function taskCheck(task) {
63
+ const verify = task && task.metadata && typeof task.metadata.verify === 'string'
64
+ ? task.metadata.verify.trim() : '';
65
+ if (!verify) return { command: '', runnable: true };
66
+ return { command: verify, runnable: isAutoCertifyVerifyCommandAllowed(verify) };
67
+ }
68
+
69
+ function skipReason(task, job, { requireCheck = false } = {}) {
70
+ if (!task || task.status !== 'open') return 'not_open';
71
+ if (task.claimed_by) return 'claimed';
72
+ if (isDecisionTask(task) || metadataTags(task).some(isDecisionHoldTag)) return 'needs_human';
73
+ if (protectedTask(task)) return 'protected_lane';
74
+ const check = taskCheck(task);
75
+ if (!check.runnable) return 'check_not_runnable';
76
+ if (requireCheck && !check.command) return 'no_check';
77
+ if (jobForTask(task) !== job) return 'other_job';
78
+ return '';
79
+ }
80
+
81
+ function fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck = false }) {
82
+ return taskDb.listTasks(db, { workspaceRoot, status: 'open' })
83
+ .filter((task) => !skipReason(task, job, { requireCheck }))
84
+ .sort((a, b) => (a.created_at - b.created_at) || String(a.id).localeCompare(String(b.id)));
85
+ }
86
+
87
+ function pickTaskForMember(taskDb, db, { workspaceRoot, job, requireCheck = false }) {
88
+ return fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck })[0] || null;
89
+ }
90
+
91
+ // Claim the oldest fitting task for the member. A lost race moves on to the
92
+ // 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 })) {
95
+ let result;
96
+ try {
97
+ result = taskDb.claimTask(db, { id: task.id, claimedBy: member });
98
+ } catch (error) {
99
+ // A claim that half-wrote must not strand the task.
100
+ try { taskDb.releaseTask(db, { id: task.id, actor: member }); } catch {}
101
+ throw error;
102
+ }
103
+ if (result.claimed) return { task: result.row, objective: taskObjective(result.row) };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ module.exports = {
109
+ taskCheck,
110
+ claimTaskForMember,
111
+ fittingTasks,
112
+ jobForTask,
113
+ pickTaskForMember,
114
+ skipReason,
115
+ taskObjective,
116
+ };