atris 3.31.0 → 3.33.3

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/commands/truth.js CHANGED
@@ -1,13 +1,15 @@
1
1
  // atris truth — one table of what is actually proven, blocked, or stale.
2
2
  // Rolls up sources that already exist; writes nothing. SQL/state is truth, this is the render.
3
3
  // 1. .atris/state/missions.jsonl — mission states (dedupe by id, latest wins)
4
- // 2. ~/.atris/tasks.db — task counts by status
4
+ // 2. ~/.atris/tasks.db — task counts by status, scoped to this workspace unless --all is passed
5
5
  // 3. atris/features/*/ — validate.md frontmatter + newest proof/ receipt age
6
6
  // 4. ~/.atris/heartbeat/{registry,state}.json — declared loops vs last_run / fails
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const os = require('os');
11
+ const { execFileSync } = require('child_process');
12
+ const taskDb = require('../lib/task-db');
11
13
 
12
14
  const STALE_DAYS = 7;
13
15
 
@@ -38,19 +40,71 @@ function loadMissions(cwd) {
38
40
  if (id) seen.set(id, rec);
39
41
  } catch { /* skip bad lines */ }
40
42
  }
41
- return [...seen.values()].filter((m) => m.status && m.status !== 'complete' && m.status !== 'archived');
43
+ // stopped = killed-on-purpose = a closed loop; only genuinely open states count
44
+ return [...seen.values()].filter((m) => m.status && !['complete', 'archived', 'stopped'].includes(m.status));
42
45
  }
43
46
 
44
- function loadTaskCounts() {
47
+ function gitCommonWorkspaceRoot(cwd) {
48
+ try {
49
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
50
+ cwd: cwd || process.cwd(),
51
+ encoding: 'utf8',
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ }).trim();
54
+ if (!common) return null;
55
+ const gitDir = path.resolve(cwd || process.cwd(), common);
56
+ return path.basename(gitDir) === '.git' ? path.dirname(gitDir) : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function workspaceRootCandidates(cwd) {
63
+ const primary = taskDb.workspaceRoot(cwd || process.cwd());
64
+ const candidates = [primary];
65
+ const commonRoot = gitCommonWorkspaceRoot(primary);
66
+ if (commonRoot && commonRoot !== primary) candidates.push(commonRoot);
67
+ return candidates;
68
+ }
69
+
70
+ function countsFromRows(rows) {
71
+ const counts = {};
72
+ for (const r of rows) counts[r.status] = Number(r.n || 0);
73
+ return counts;
74
+ }
75
+
76
+ function taskCountRows(db, workspaceRoot) {
77
+ return workspaceRoot
78
+ ? db.prepare('SELECT status, COUNT(*) n FROM tasks WHERE workspace_root = ? GROUP BY status').all(workspaceRoot)
79
+ : db.prepare('SELECT status, COUNT(*) n FROM tasks GROUP BY status').all();
80
+ }
81
+
82
+ function loadTaskCounts({ cwd, all = false } = {}) {
83
+ const candidates = all ? [null] : workspaceRootCandidates(cwd || process.cwd());
45
84
  try {
46
85
  const { DatabaseSync } = require('node:sqlite');
47
- const db = new DatabaseSync(path.join(os.homedir(), '.atris', 'tasks.db'), { readOnly: true });
48
- const rows = db.prepare('SELECT status, COUNT(*) n FROM tasks GROUP BY status').all();
86
+ const dbPath = taskDb.getDbPath();
87
+ if (!fs.existsSync(dbPath)) return { counts: null, workspaceRoot: candidates[0] || null };
88
+ const db = new DatabaseSync(dbPath, { readOnly: true });
89
+ for (const workspaceRoot of candidates) {
90
+ const rows = taskCountRows(db, workspaceRoot);
91
+ if (all || rows.length > 0) {
92
+ db.close();
93
+ return { counts: countsFromRows(rows), workspaceRoot };
94
+ }
95
+ }
49
96
  db.close();
50
- const counts = {};
51
- for (const r of rows) counts[r.status] = r.n;
52
- return counts;
53
- } catch { return null; }
97
+ return { counts: {}, workspaceRoot: candidates[0] || null };
98
+ } catch { return { counts: null, workspaceRoot: candidates[0] || null }; }
99
+ }
100
+
101
+ function taskScopeLabel(scope) {
102
+ return scope.kind === 'global' ? 'global' : 'workspace';
103
+ }
104
+
105
+ function taskScopeLine(scope) {
106
+ if (scope.kind === 'global') return 'global';
107
+ return `${taskScopeLabel(scope)} (${scope.workspace_root})`;
54
108
  }
55
109
 
56
110
  function loadFeatures(cwd) {
@@ -110,9 +164,14 @@ function truthCommand(args = []) {
110
164
  const cwd = process.cwd();
111
165
  const json = args.includes('--json');
112
166
  const summary = args.includes('--summary');
167
+ const all = args.includes('--all');
113
168
 
114
169
  const missions = loadMissions(cwd);
115
- const tasks = loadTaskCounts();
170
+ const taskCounts = loadTaskCounts({ cwd, all });
171
+ const scope = all
172
+ ? { kind: 'global' }
173
+ : { kind: 'workspace', workspace_root: taskCounts.workspaceRoot };
174
+ const tasks = taskCounts.counts;
116
175
  const features = loadFeatures(cwd);
117
176
  const heartbeats = loadHeartbeats();
118
177
 
@@ -124,6 +183,7 @@ function truthCommand(args = []) {
124
183
  const result = {
125
184
  ok: true,
126
185
  generated_at: new Date().toISOString(),
186
+ scope,
127
187
  missions_active: missions.map((m) => ({ id: m.id || m.mission_id, status: m.status, owner: m.owner })),
128
188
  tasks,
129
189
  features: featureTally,
@@ -139,11 +199,12 @@ function truthCommand(args = []) {
139
199
 
140
200
  const line = (s) => console.log(s);
141
201
  if (summary) {
142
- line(`truth: features ${featureTally.proven || 0} proven / ${featureTally.stale || 0} stale / ${featureTally.blocked || 0} blocked / ${featureTally.unproven || 0} unproven · loops ${loopTally.proven || 0} live / ${(loopTally.stale || 0) + (loopTally['never-ran'] || 0)} stale / ${loopTally.blocked || 0} failing · missions ${missions.length} active`);
202
+ line(`truth [${taskScopeLabel(scope)}]: features ${featureTally.proven || 0} proven / ${featureTally.stale || 0} stale / ${featureTally.blocked || 0} blocked / ${featureTally.unproven || 0} unproven · loops ${loopTally.proven || 0} live / ${(loopTally.stale || 0) + (loopTally['never-ran'] || 0)} stale / ${loopTally.blocked || 0} failing · missions ${missions.length} active`);
143
203
  return 0;
144
204
  }
145
205
 
146
206
  line('ATRIS TRUTH — live state, not belief\n');
207
+ line(`Scope: ${taskScopeLine(scope)}`);
147
208
 
148
209
  line(`Missions active: ${missions.length}`);
149
210
  for (const m of missions) line(` ${m.status.padEnd(8)} ${m.owner || '?'} ${m.id || m.mission_id}`);
package/lib/autoland.js CHANGED
@@ -12,6 +12,8 @@ const { spawnSync } = require('child_process');
12
12
 
13
13
  const DEFAULT_DIGEST_HOUR = 9;
14
14
  const DEFAULT_ALARM_HOURS = 24;
15
+ const OPERATOR_WHY = /\b(sav(?:e|es|ing)|burn(?:s|ing)?|wast(?:e|es|ing)|cost(?:s|ing)?|prevent\w*|stop(?:s|ping)?|break(?:s|ing)?|fail(?:s|ing)?|slow(?:s|er)?|faster|cheaper|easier|clearer|safer|simpler|trust|revenue|users?|customers?|so that|because)\b/i;
16
+ const AGENT_JARGON = /[a-z0-9]_[a-z0-9]|\b[a-z]+[A-Z]\w*|--[a-z]|\bCLI-\d+/;
15
17
 
16
18
  function policyPath(root) {
17
19
  return path.join(root, '.atris', 'policy', 'autoland.json');
@@ -131,7 +133,7 @@ function waitingOnHuman(tasks, now = Date.now()) {
131
133
  .filter((t) => t.review?.agent_certified === true || t.metadata?.agent_certified === true)
132
134
  .map((t) => ({
133
135
  ref: t.display_id || t.legacy_ref || t.id,
134
- title: String(t.title || '').slice(0, 90),
136
+ title: String(t.title || '').slice(0, 200),
135
137
  tag: String(t.tag || ''),
136
138
  hours: waitingHours(t, now),
137
139
  }))
@@ -168,16 +170,54 @@ function cutAtWord(text, max) {
168
170
  return `${cut.slice(0, lastSpace > max * 0.6 ? lastSpace : max)}...`;
169
171
  }
170
172
 
173
+ // A line ends where a thought ends. Long lines close at their last complete
174
+ // clause inside the budget and read whole: no ellipsis, nothing dangling.
175
+ // Only when a line has no clause boundary at all does a word cut remain.
176
+ function finishThought(text, max = 160) {
177
+ const clean = String(text || '').replace(/\s+/g, ' ').trim().replace(/[,;:\s]+$/, '');
178
+ if (clean.length <= max) return clean;
179
+ const cut = clean.slice(0, max);
180
+ const sentence = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
181
+ if (sentence > max * 0.3) return cut.slice(0, sentence + 1);
182
+ const clause = Math.max(cut.lastIndexOf(', '), cut.lastIndexOf('; '), cut.lastIndexOf(' ('), cut.lastIndexOf(': '));
183
+ if (clause > max * 0.45) return cut.slice(0, clause);
184
+ const space = cut.lastIndexOf(' ');
185
+ return `${cut.slice(0, space > max * 0.6 ? space : max)}...`;
186
+ }
187
+
188
+ function operatorReady(text) {
189
+ const value = String(text || '');
190
+ return OPERATOR_WHY.test(value) && !AGENT_JARGON.test(value);
191
+ }
192
+
193
+ function hasAgentJargon(text) {
194
+ return AGENT_JARGON.test(String(text || ''));
195
+ }
196
+
171
197
  // One line per piece, in the work's own words: the landing sentence beats
172
198
  // the task title, because titles are written for the queue, not for a text.
199
+ // The cut is generous on purpose: a specific sentence the reader trusts
200
+ // (what happened + how big + how we know) beats a short one they must chase.
173
201
  function digestLine(item) {
174
- const happened = String(item.happened || '').split(/(?<=[.!?])\s+/)[0] || '';
202
+ const happened = String(item.happened || '').replace(/^completed:\s*/i, '').split(/(?<=[.!?])\s+/)[0] || '';
175
203
  const title = String(item.title || '').replace(/^Mission XP:\s*/i, '');
176
204
  const source = happened.length >= 12 ? happened : title;
177
- const line = cutAtWord(source.replace(/[.\s]+$/, ''), 72);
205
+ const line = finishThought(source.replace(/[.\s]+$/, ''), 160);
178
206
  return line.charAt(0).toLowerCase() + line.slice(1);
179
207
  }
180
208
 
209
+ // A rough sentence still beats a count: when a landing sentence carries agent
210
+ // vocabulary, strip what a reader can't use (flag dashes, ids, underscores)
211
+ // and show it anyway. Content always ships; the gate only changes how it reads.
212
+ function dejargon(line) {
213
+ return String(line || '')
214
+ .replace(/--([a-z][a-z-]*)/g, '$1')
215
+ .replace(/\bCLI-\d+\b/g, '')
216
+ .replace(/([a-z0-9])_([a-z0-9])/gi, '$1 $2')
217
+ .replace(/\s+/g, ' ')
218
+ .trim();
219
+ }
220
+
181
221
  // The mission loop files a check-off every time it stops instead of
182
222
  // inventing work; those all say the same thing, so they collapse into one line.
183
223
  function isCleanStop(item) {
@@ -185,40 +225,105 @@ function isCleanStop(item) {
185
225
  || /^(Mission XP:\s*)?Decide and start the next useful mission/i.test(String(item.title || ''));
186
226
  }
187
227
 
188
- // Short enough for one iMessage, plain enough to read over coffee and it
189
- // names the work, because "7 things landed" tells the score, not the story.
190
- function composeDigest({ accepted, waiting, landed, project }) {
228
+ // Short enough for one iMessage, plain enough to read walking to the car.
229
+ // Every line answers: what happened, how big, how we know. It names the work
230
+ // because "7 things landed" tells the score, not the story — and it always
231
+ // ends with what's waiting and what to do next, because those are the only
232
+ // lines that ask anything of the reader.
233
+ function composeDigest({ accepted, waiting, landed, project, nextMoves = [] }) {
191
234
  const lines = [];
192
- lines.push(`atris ${project} daily`);
235
+ lines.push(`atris ${project}: yesterday`);
193
236
  const autoCount = accepted.auto.length;
194
237
  const humanCount = accepted.human.length;
195
238
  if (autoCount > 0) {
196
- lines.push('got their final sign-off on their own (checked twice, proof on file):');
239
+ // Three results, air between them, the rest on ask: the whole message
240
+ // fits a laptop screen with no scrolling. Reading it is one glance.
197
241
  const stops = accepted.auto.filter(isCleanStop);
198
- const work = accepted.auto.filter((item) => !isCleanStop(item));
199
- for (const item of work.slice(0, 4)) lines.push(`- ${digestLine(item) || item.ref}`);
200
- if (work.length > 4) lines.push(`- and ${work.length - 4} more`);
201
- if (stops.length > 0) lines.push(`- ${plur(stops.length, 'check')} that the loop stops cleanly instead of inventing work`);
242
+ const work = accepted.auto
243
+ .filter((item) => !isCleanStop(item))
244
+ .map((item) => ({ item, line: digestLine(item) }));
245
+ // Operator-ready sentences lead; rough ones get de-jargoned and still
246
+ // shown. An empty report is the one thing a report must never be.
247
+ const ready = work.filter(({ line }) => operatorReady(line));
248
+ const rough = work.filter(({ line }) => !operatorReady(line))
249
+ .map(({ item, line }) => ({ item, line: dejargon(line) }));
250
+ const visibleWork = [...ready, ...rough].slice(0, 3);
251
+ const held = Math.max(0, work.length - visibleWork.length) + (stops.length > 0 ? 1 : 0);
252
+ if (visibleWork.length > 0) {
253
+ lines.push('landed on their own (verified twice, proof on file):');
254
+ for (const { item, line } of visibleWork) {
255
+ lines.push('');
256
+ lines.push(`- ${line || item.ref}${item.member ? ` (${item.member})` : ''}`);
257
+ }
258
+ lines.push('');
259
+ if (held > 0) lines.push(`${plur(held, 'more result')} when you want them: atris autoland digest`);
260
+ }
202
261
  }
203
- if (humanCount > 0) lines.push(`you approved ${plur(humanCount, 'piece')} yourself`);
204
262
  if (autoCount + humanCount === 0) lines.push('nothing finished in the last day');
205
- const byMember = new Map();
206
- for (const item of [...accepted.auto, ...accepted.human]) {
207
- if (!item.member || isCleanStop(item)) continue;
208
- byMember.set(item.member, (byMember.get(item.member) || 0) + 1);
209
- }
210
- if (byMember.size > 0) {
211
- lines.push(`workers: ${Array.from(byMember, ([who, n]) => `${who} landed ${n}`).join(', ')}`);
212
- }
213
263
  if (waiting.length > 0) {
214
- lines.push(`waiting on you: ${waiting.length} (oldest ${waiting[0].hours}h) atris task reviews`);
264
+ // The only lines that ask the reader to act, so they name what's asking.
265
+ lines.push(`waiting on you (approve or bounce: atris task reviews):`);
266
+ for (const item of waiting.slice(0, 2)) {
267
+ lines.push(`- ${finishThought(dejargon(item.title), 140)}${item.hours >= 12 ? ` (${item.hours}h)` : ''}`);
268
+ }
269
+ if (waiting.length > 2) lines.push(`- and ${waiting.length - 2} more`);
215
270
  } else {
216
- lines.push('nothing is waiting on you');
271
+ lines.push('waiting on you: nothing');
217
272
  }
218
273
  if (landed && landed.branches > 0) {
219
- lines.push(`${plur(landed.branches, 'piece')} of work still in the air${landed.due > 0 ? `, ${landed.due} overdue` : ''} atris land`);
274
+ lines.push(`in the air: ${plur(landed.branches, 'piece')}${landed.due > 0 ? `, ${landed.due} overdue` : ', all fresh'} (atris land)`);
275
+ }
276
+ // nextMoves: array of {title, owner}, or {moves, unexplained} where
277
+ // unexplained counts queue items whose sentence carries no operator why.
278
+ // Those are counted, not shown — a raw title the reader can't act on is
279
+ // noise here and a writing bug at its source.
280
+ const nextInfo = Array.isArray(nextMoves)
281
+ ? { moves: nextMoves, unexplained: 0 }
282
+ : (nextMoves || { moves: [], unexplained: 0 });
283
+ const moves = (nextInfo.moves || []).filter((move) => move && move.title).slice(0, 3);
284
+ const unexplained = Number(nextInfo.unexplained || 0);
285
+ if (moves.length > 0) {
286
+ lines.push('next, if you agree:');
287
+ for (const move of moves) {
288
+ lines.push(`- ${finishThought(move.title, 140)}${move.owner ? ` (best fit: ${move.owner})` : ''}`);
289
+ }
290
+ if (unexplained > 0) lines.push(`- ${plur(unexplained, 'more idea')} that can't explain themselves yet (atris now)`);
291
+ } else if (unexplained > 0) {
292
+ lines.push(`${plur(unexplained, 'idea')} in the queue can't explain themselves yet (atris now)`);
293
+ }
294
+ if (!lines.some((line) => line.includes('atris autoland digest'))) {
295
+ lines.push('the full story: atris autoland digest');
296
+ }
297
+ return lines.join('\n');
298
+ }
299
+
300
+ // The moment something lands, the operator hears it in one glance: the
301
+ // landing sentence each piece wrote for a day-one PM at finish time, its
302
+ // author, nothing else. Rough sentences get de-jargoned, never hidden.
303
+ function composeLiveUpdate({ landedRefs, tasks, project }) {
304
+ const byRef = new Map((tasks || []).map((t) => [String(t.display_id || t.legacy_ref || t.id), t]));
305
+ const items = (landedRefs || []).map((ref) => {
306
+ const t = byRef.get(String(ref));
307
+ if (!t) return { title: String(ref) };
308
+ return {
309
+ ref: String(ref),
310
+ title: String(t.title || '').slice(0, 200),
311
+ happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
312
+ member: String(t.claimed_by || t.assigned_to || '').trim(),
313
+ };
314
+ }).filter((item) => !isCleanStop(item));
315
+ if (!items.length) return '';
316
+ const lines = [`atris ${project}: just landed`];
317
+ for (const item of items.slice(0, 3)) {
318
+ const raw = digestLine(item);
319
+ const line = operatorReady(raw) ? raw : dejargon(raw);
320
+ lines.push('');
321
+ lines.push(`- ${finishThought(line, 160)}${item.member ? ` (${item.member})` : ''}`);
322
+ }
323
+ if (items.length > 3) {
324
+ lines.push('');
325
+ lines.push(`and ${plur(items.length - 3, 'more piece')}: atris autoland digest`);
220
326
  }
221
- lines.push('the full story: atris autoland digest');
222
327
  return lines.join('\n');
223
328
  }
224
329
 
@@ -264,12 +369,15 @@ module.exports = {
264
369
  buildCronLine,
265
370
  composeAlarm,
266
371
  composeDigest,
372
+ composeLiveUpdate,
267
373
  cronInstalled,
268
374
  cronMarker,
269
375
  dueForAlarm,
270
376
  installCron,
271
377
  liveAcceptAuthorization,
272
378
  markAlerted,
379
+ operatorReady,
380
+ hasAgentJargon,
273
381
  policyPath,
274
382
  readPolicy,
275
383
  readState,