atris 3.45.1 → 3.46.1

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,396 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const https = require('node:https');
7
+ const { spawn, spawnSync } = require('node:child_process');
8
+
9
+ const RUNNER_VERSION = '2.336.0';
10
+ const GITHUB_API_VERSION = '2026-03-10';
11
+
12
+ function usageFilePath(home = os.homedir()) {
13
+ return path.join(home, '.atris', 'ci', 'usage.jsonl');
14
+ }
15
+
16
+ function appendUsageLine(file, line) {
17
+ fs.mkdirSync(path.dirname(file), { recursive: true });
18
+ fs.appendFileSync(file, line, 'utf8');
19
+ }
20
+
21
+ function clockDate(clock) {
22
+ const value = clock();
23
+ const date = value instanceof Date ? value : new Date(value);
24
+ if (!Number.isFinite(date.getTime())) throw new Error('ci usage clock returned an invalid time');
25
+ return date;
26
+ }
27
+
28
+ function runnerAssetName(platform, arch, version = RUNNER_VERSION) {
29
+ const platformNames = { darwin: 'osx', linux: 'linux' };
30
+ const archNames = { x64: 'x64', arm64: 'arm64' };
31
+ const runnerPlatform = platformNames[platform];
32
+ const runnerArch = archNames[arch];
33
+ if (!runnerPlatform || !runnerArch) {
34
+ throw new Error(`unsupported runner platform: ${platform}/${arch}`);
35
+ }
36
+ return `actions-runner-${runnerPlatform}-${runnerArch}-${version}.tar.gz`;
37
+ }
38
+
39
+ function parseRepo(value) {
40
+ if (typeof value !== 'string') throw new Error('--repo owner/name is required');
41
+ const parts = value.trim().split('/');
42
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
43
+ throw new Error('--repo must use owner/name');
44
+ }
45
+ const [owner, repo] = parts;
46
+ const validOwner = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/i;
47
+ const validRepo = /^[a-z0-9_.-]{1,100}$/i;
48
+ if (!validOwner.test(owner) || !validRepo.test(repo) || repo === '.' || repo === '..') {
49
+ throw new Error('--repo must use a valid owner/name');
50
+ }
51
+ return { owner, repo, slug: `${owner}/${repo}` };
52
+ }
53
+
54
+ function parseRunnerArgs(argv) {
55
+ const options = { label: null, once: false };
56
+ let repoValue = null;
57
+
58
+ for (let index = 0; index < argv.length; index += 1) {
59
+ const arg = argv[index];
60
+ if (arg === '--repo') {
61
+ if (repoValue !== null) throw new Error('--repo may only be set once');
62
+ repoValue = argv[index + 1];
63
+ if (!repoValue || repoValue.startsWith('--')) throw new Error('--repo owner/name is required');
64
+ index += 1;
65
+ } else if (arg === '--label') {
66
+ if (options.label !== null) throw new Error('--label may only be set once');
67
+ options.label = argv[index + 1];
68
+ if (!options.label || options.label.startsWith('--')) throw new Error('--label name is required');
69
+ if (!/^[a-z0-9_.-]+$/i.test(options.label)) {
70
+ throw new Error('--label must contain only letters, numbers, dots, underscores, or hyphens');
71
+ }
72
+ index += 1;
73
+ } else if (arg === '--once') {
74
+ if (options.once) throw new Error('--once may only be set once');
75
+ options.once = true;
76
+ } else {
77
+ throw new Error(`unknown ci runner option: ${arg}`);
78
+ }
79
+ }
80
+
81
+ return { repo: parseRepo(repoValue), label: options.label, once: options.once };
82
+ }
83
+
84
+ function parseUsageArgs(argv) {
85
+ let repo = null;
86
+
87
+ for (let index = 0; index < argv.length; index += 1) {
88
+ const arg = argv[index];
89
+ if (arg !== '--repo') throw new Error(`unknown ci usage option: ${arg}`);
90
+ if (repo !== null) throw new Error('--repo may only be set once');
91
+ const value = argv[index + 1];
92
+ if (!value || value.startsWith('--')) throw new Error('--repo owner/name is required');
93
+ repo = parseRepo(value).slug;
94
+ index += 1;
95
+ }
96
+
97
+ return { repo };
98
+ }
99
+
100
+ function readUsageRecords(file = usageFilePath(), readFile = fs.readFileSync) {
101
+ let content;
102
+ try {
103
+ content = readFile(file, 'utf8');
104
+ } catch (error) {
105
+ if (error && error.code === 'ENOENT') return [];
106
+ throw error;
107
+ }
108
+
109
+ return String(content)
110
+ .split('\n')
111
+ .filter((line) => line.trim())
112
+ .map((line) => {
113
+ try {
114
+ return JSON.parse(line);
115
+ } catch {
116
+ return null;
117
+ }
118
+ })
119
+ .filter(Boolean);
120
+ }
121
+
122
+ function summarizeUsage(records, options = {}) {
123
+ const now = options.now instanceof Date ? options.now : new Date(options.now || Date.now());
124
+ if (!Number.isFinite(now.getTime())) throw new Error('ci usage clock returned an invalid time');
125
+ const month = now.toISOString().slice(0, 7);
126
+ const selected = records.filter((record) => (
127
+ record
128
+ && typeof record.repo === 'string'
129
+ && record.repo.length > 0
130
+ && (!options.repo || record.repo === options.repo)
131
+ && Number.isFinite(Date.parse(record.started_at))
132
+ && Number.isFinite(Number(record.duration_seconds))
133
+ && Number(record.duration_seconds) >= 0
134
+ ));
135
+ const repos = new Map();
136
+ let totalMinutes = 0;
137
+ let monthMinutes = 0;
138
+
139
+ for (const record of selected) {
140
+ const minutes = Math.ceil(Number(record.duration_seconds) / 60);
141
+ totalMinutes += minutes;
142
+ if (new Date(record.started_at).toISOString().slice(0, 7) === month) monthMinutes += minutes;
143
+ const repo = repos.get(record.repo) || { repo: record.repo, jobs: 0, minutes: 0 };
144
+ repo.jobs += 1;
145
+ repo.minutes += minutes;
146
+ repos.set(record.repo, repo);
147
+ }
148
+
149
+ return {
150
+ totalJobs: selected.length,
151
+ totalMinutes,
152
+ monthMinutes,
153
+ repos: [...repos.values()].sort((left, right) => left.repo.localeCompare(right.repo)),
154
+ };
155
+ }
156
+
157
+ function formatUsageSummary(summary) {
158
+ if (summary.totalJobs === 0) return 'no build minutes recorded yet';
159
+ return [
160
+ `total jobs: ${summary.totalJobs}`,
161
+ `total minutes: ${summary.totalMinutes}`,
162
+ `minutes this month: ${summary.monthMinutes}`,
163
+ 'per repo:',
164
+ ...summary.repos.map((repo) => (
165
+ `${repo.repo}: ${repo.jobs} ${repo.jobs === 1 ? 'job' : 'jobs'}, `
166
+ + `${repo.minutes} ${repo.minutes === 1 ? 'minute' : 'minutes'}`
167
+ )),
168
+ ].join('\n');
169
+ }
170
+
171
+ function buildJitConfigRequest(repo, label, runnerName) {
172
+ const labels = label && label !== 'atris' ? ['atris', label] : ['atris'];
173
+ return {
174
+ path: `/repos/${repo.owner}/${repo.repo}/actions/runners/generate-jitconfig`,
175
+ body: {
176
+ name: runnerName,
177
+ runner_group_id: 1,
178
+ labels,
179
+ work_folder: '_work',
180
+ },
181
+ };
182
+ }
183
+
184
+ function resolveGithubToken(env = process.env, run = spawnSync) {
185
+ const envToken = String(env.GITHUB_TOKEN || '').trim();
186
+ if (envToken) return envToken;
187
+ const result = run('gh', ['auth', 'token'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
188
+ const ghToken = result && result.status === 0 ? String(result.stdout || '').trim() : '';
189
+ if (ghToken) return ghToken;
190
+ throw new Error('github token required: set GITHUB_TOKEN or run gh auth login');
191
+ }
192
+
193
+ function requestJson(options, body, request = https.request) {
194
+ return new Promise((resolve, reject) => {
195
+ const req = request(options, (response) => {
196
+ const chunks = [];
197
+ response.on('data', (chunk) => chunks.push(chunk));
198
+ response.on('end', () => {
199
+ const raw = Buffer.concat(chunks).toString('utf8');
200
+ let parsed = {};
201
+ try {
202
+ parsed = raw ? JSON.parse(raw) : {};
203
+ } catch {
204
+ reject(new Error('github returned an unreadable response'));
205
+ return;
206
+ }
207
+ if (response.statusCode < 200 || response.statusCode >= 300) {
208
+ const message = String(parsed.message || `github request failed with status ${response.statusCode}`)
209
+ .replace(/\s+/g, ' ')
210
+ .trim();
211
+ reject(new Error(message.toLowerCase()));
212
+ return;
213
+ }
214
+ resolve(parsed);
215
+ });
216
+ });
217
+ req.on('error', reject);
218
+ req.end(body);
219
+ });
220
+ }
221
+
222
+ async function generateJitConfig(token, repo, label, runnerName, request = https.request) {
223
+ const spec = buildJitConfigRequest(repo, label, runnerName);
224
+ const body = JSON.stringify(spec.body);
225
+ const response = await requestJson({
226
+ hostname: 'api.github.com',
227
+ path: spec.path,
228
+ method: 'POST',
229
+ headers: {
230
+ Accept: 'application/vnd.github+json',
231
+ Authorization: `Bearer ${token}`,
232
+ 'Content-Type': 'application/json',
233
+ 'Content-Length': Buffer.byteLength(body),
234
+ 'User-Agent': 'atris-ci-runner',
235
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
236
+ },
237
+ }, body, request);
238
+ if (!response.encoded_jit_config) throw new Error('github did not return a runner configuration');
239
+ return response.encoded_jit_config;
240
+ }
241
+
242
+ function downloadFile(url, destination, get = https.get, redirectsLeft = 5) {
243
+ return new Promise((resolve, reject) => {
244
+ const request = get(url, { headers: { 'User-Agent': 'atris-ci-runner' } }, (response) => {
245
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
246
+ response.resume();
247
+ if (redirectsLeft === 0) {
248
+ reject(new Error('runner download followed too many redirects'));
249
+ return;
250
+ }
251
+ const nextUrl = new URL(response.headers.location, url);
252
+ if (nextUrl.protocol !== 'https:') {
253
+ reject(new Error('runner download redirected outside https'));
254
+ return;
255
+ }
256
+ downloadFile(nextUrl.toString(), destination, get, redirectsLeft - 1).then(resolve, reject);
257
+ return;
258
+ }
259
+ if (response.statusCode !== 200) {
260
+ response.resume();
261
+ reject(new Error(`runner download failed with status ${response.statusCode}`));
262
+ return;
263
+ }
264
+ const output = fs.createWriteStream(destination);
265
+ output.on('error', reject);
266
+ response.on('error', reject);
267
+ output.on('finish', () => output.close(resolve));
268
+ response.pipe(output);
269
+ });
270
+ request.on('error', reject);
271
+ });
272
+ }
273
+
274
+ async function ensureRunner(options = {}) {
275
+ const version = options.version || RUNNER_VERSION;
276
+ const platform = options.platform || process.platform;
277
+ const arch = options.arch || process.arch;
278
+ const home = options.home || os.homedir();
279
+ const log = options.log || console.log;
280
+ const asset = runnerAssetName(platform, arch, version);
281
+ const runnerPlatform = platform === 'darwin' ? 'osx' : platform;
282
+ const runnerDir = path.join(home, '.atris', 'ci', version, `${runnerPlatform}-${arch}`);
283
+ const runScript = path.join(runnerDir, 'run.sh');
284
+ if (fs.existsSync(runScript)) return runnerDir;
285
+
286
+ const cacheDir = path.join(home, '.atris', 'ci');
287
+ const archive = path.join(cacheDir, asset);
288
+ fs.mkdirSync(runnerDir, { recursive: true });
289
+ if (!fs.existsSync(archive)) {
290
+ const partial = `${archive}.download-${process.pid}`;
291
+ log(`downloading github actions runner ${version}`);
292
+ try {
293
+ await (options.download || downloadFile)(
294
+ `https://github.com/actions/runner/releases/download/v${version}/${asset}`,
295
+ partial,
296
+ );
297
+ fs.renameSync(partial, archive);
298
+ } catch (error) {
299
+ fs.rmSync(partial, { force: true });
300
+ throw error;
301
+ }
302
+ }
303
+
304
+ const extract = (options.spawnSync || spawnSync)('tar', ['-xzf', archive, '-C', runnerDir], {
305
+ encoding: 'utf8',
306
+ stdio: ['ignore', 'pipe', 'pipe'],
307
+ });
308
+ if (extract.error || extract.status !== 0) {
309
+ const detail = String(extract.stderr || extract.error?.message || 'tar failed').replace(/\s+/g, ' ').trim();
310
+ throw new Error(`could not extract github actions runner: ${detail.toLowerCase()}`);
311
+ }
312
+ if (!fs.existsSync(runScript)) throw new Error('github actions runner archive did not contain run.sh');
313
+ fs.chmodSync(runScript, 0o755);
314
+ return runnerDir;
315
+ }
316
+
317
+ function runWorker(runnerDir, jitConfig, start = spawn) {
318
+ return new Promise((resolve, reject) => {
319
+ const child = start('./run.sh', ['--jitconfig', jitConfig], {
320
+ cwd: runnerDir,
321
+ env: process.env,
322
+ stdio: 'inherit',
323
+ });
324
+ child.on('error', reject);
325
+ child.on('exit', (code, signal) => {
326
+ if (code === 0) {
327
+ resolve();
328
+ } else if (signal) {
329
+ reject(new Error(`github actions runner stopped with signal ${signal}`));
330
+ } else {
331
+ reject(new Error(`github actions runner exited with code ${code}`));
332
+ }
333
+ });
334
+ });
335
+ }
336
+
337
+ async function runJobLoop(options, dependencies = {}) {
338
+ const mint = dependencies.generateJitConfig || generateJitConfig;
339
+ const startWorker = dependencies.runWorker || runWorker;
340
+ const shouldContinue = dependencies.shouldContinue || ((state) => !state.once);
341
+ const log = dependencies.log || console.log;
342
+ const clock = dependencies.clock || (() => new Date());
343
+ const file = dependencies.usagePath || usageFilePath();
344
+ const appendUsage = dependencies.appendUsage || appendUsageLine;
345
+ let completedJobs = 0;
346
+
347
+ while (true) {
348
+ const runnerName = `${options.runnerName}-${completedJobs + 1}`;
349
+ const jitConfig = await mint(options.token, options.repo, options.label, runnerName);
350
+ log(`worker ready, waiting for jobs on ${options.repo.slug}`);
351
+ const startedAt = clockDate(clock);
352
+ let workerError = null;
353
+ try {
354
+ await startWorker(options.runnerDir, jitConfig);
355
+ } catch (error) {
356
+ workerError = error;
357
+ } finally {
358
+ const finishedAt = clockDate(clock);
359
+ const durationSeconds = Math.max(0, Math.ceil((finishedAt.getTime() - startedAt.getTime()) / 1000));
360
+ const line = `${JSON.stringify({
361
+ repo: options.repo.slug,
362
+ started_at: startedAt.toISOString(),
363
+ duration_seconds: durationSeconds,
364
+ })}\n`;
365
+ await appendUsage(file, line);
366
+ }
367
+ if (workerError) throw workerError;
368
+ completedJobs += 1;
369
+ if (!shouldContinue({ once: options.once, completedJobs })) return completedJobs;
370
+ }
371
+ }
372
+
373
+ async function runCiRunner(options, dependencies = {}) {
374
+ const token = (dependencies.resolveGithubToken || resolveGithubToken)();
375
+ const runnerDir = await (dependencies.ensureRunner || ensureRunner)({ log: dependencies.log });
376
+ const host = String((dependencies.hostname || os.hostname)()).toLowerCase().replace(/[^a-z0-9-]+/g, '-');
377
+ return runJobLoop({
378
+ ...options,
379
+ token,
380
+ runnerDir,
381
+ runnerName: `atris-${host || 'worker'}-${process.pid}`,
382
+ }, dependencies);
383
+ }
384
+
385
+ module.exports = {
386
+ buildJitConfigRequest,
387
+ formatUsageSummary,
388
+ parseRunnerArgs,
389
+ parseUsageArgs,
390
+ readUsageRecords,
391
+ runCiRunner,
392
+ runJobLoop,
393
+ runnerAssetName,
394
+ summarizeUsage,
395
+ usageFilePath,
396
+ };
@@ -14,6 +14,8 @@ const DEFAULT_REFEREE_ENGINE = 'haiku';
14
14
  const VALIDATION_SCHEMA = 'atris.engine_validate_verdict.v1';
15
15
  const VALID_VERDICTS = new Set(['pass', 'fail', 'unsure']);
16
16
  const MODEL_SELECTING_REFEREES = new Set(['claude', 'fable', 'haiku', 'codex', 'cursor', 'devin', 'grok']);
17
+ // Three receipt checks per hourly tick keep the cheap referee spend negligible.
18
+ const MAX_AUTOMATIC_VALIDATIONS_PER_TICK = 3;
17
19
 
18
20
  function validateUsage() {
19
21
  return [
@@ -254,6 +256,127 @@ function relativePath(root, filePath) {
254
256
  return path.relative(root, filePath) || path.basename(filePath);
255
257
  }
256
258
 
259
+ function validationSourceKey(root, source) {
260
+ const sourcePath = path.isAbsolute(source) ? source : path.resolve(root, source);
261
+ return relativePath(root, sourcePath).split(path.sep).join('/');
262
+ }
263
+
264
+ function automaticValidationCandidates(root, fsModule = fs) {
265
+ const covered = new Set(readValidationRows(root, fsModule)
266
+ .filter((row) => row && row.source_receipt && VALID_VERDICTS.has(row.verdict))
267
+ .map((row) => validationSourceKey(root, row.source_receipt)));
268
+ const summary = {
269
+ scanned: 0,
270
+ skipped_covered: 0,
271
+ skipped_status: 0,
272
+ skipped_no_answers: 0,
273
+ };
274
+ const runsDir = path.join(root, 'atris', 'runs');
275
+ let names;
276
+ try {
277
+ names = fsModule.readdirSync(runsDir)
278
+ .filter((name) => /^engine-ask-.+[.]json$/.test(name));
279
+ } catch {
280
+ return { candidates: [], summary };
281
+ }
282
+
283
+ const candidates = [];
284
+ for (const name of names) {
285
+ const receiptPath = path.join(runsDir, name);
286
+ let receipt;
287
+ let stat;
288
+ try {
289
+ receipt = readAskReceipt(receiptPath, fsModule);
290
+ stat = fsModule.statSync(receiptPath);
291
+ } catch {
292
+ continue;
293
+ }
294
+ summary.scanned += 1;
295
+ if (receipt.status !== 'completed') {
296
+ summary.skipped_status += 1;
297
+ continue;
298
+ }
299
+ const answered = receipt.answers.filter((answer) => answer.status === 'answered');
300
+ if (!answered.length) {
301
+ summary.skipped_no_answers += 1;
302
+ continue;
303
+ }
304
+ const sourceReceipt = validationSourceKey(root, receiptPath);
305
+ if (covered.has(sourceReceipt)) {
306
+ summary.skipped_covered += 1;
307
+ continue;
308
+ }
309
+ const gradeable = answered.filter((answer) => {
310
+ const worker = canonicalEngineName(answer.engine) || String(answer.engine || '').trim();
311
+ return worker !== DEFAULT_REFEREE_ENGINE;
312
+ });
313
+ const skipped = answered.filter((answer) => !gradeable.includes(answer));
314
+ candidates.push({
315
+ receiptPath,
316
+ sourceReceipt,
317
+ timeMs: receiptTimeMs(receipt, stat),
318
+ gradeable,
319
+ skipped,
320
+ });
321
+ }
322
+ candidates.sort((left, right) => right.timeMs - left.timeMs
323
+ || right.receiptPath.localeCompare(left.receiptPath));
324
+ return { candidates, summary };
325
+ }
326
+
327
+ async function validateRecentAskReceipts(root, deps = {}) {
328
+ const fsModule = deps.fs || fs;
329
+ const selected = automaticValidationCandidates(root, fsModule);
330
+ const summary = {
331
+ ...selected.summary,
332
+ attempted_receipts: 0,
333
+ graded_receipts: 0,
334
+ graded_answers: 0,
335
+ skipped_self_grade: 0,
336
+ failures: 0,
337
+ notes: [],
338
+ };
339
+ const candidates = [];
340
+ for (const candidate of selected.candidates) {
341
+ if (candidate.skipped.length) {
342
+ summary.skipped_self_grade += candidate.skipped.length;
343
+ for (const answer of candidate.skipped) {
344
+ const label = String(answer.label || answer.engine || 'haiku answer').trim();
345
+ summary.notes.push(`skipped ${label} from ${candidate.sourceReceipt}: judge never equals worker.`);
346
+ }
347
+ }
348
+ if (candidate.gradeable.length) candidates.push(candidate);
349
+ }
350
+
351
+ const validateReceipt = deps.validateReceipt || runEngineValidateCommand;
352
+ for (const candidate of candidates.slice(0, MAX_AUTOMATIC_VALIDATIONS_PER_TICK)) {
353
+ summary.attempted_receipts += 1;
354
+ let code;
355
+ try {
356
+ code = await validateReceipt([candidate.receiptPath], root, {
357
+ fs: fsModule,
358
+ now: deps.now,
359
+ concurrency: deps.concurrency,
360
+ timeoutMs: deps.timeoutMs,
361
+ executeAskJob: deps.executeAskJob,
362
+ skipMatchingWorker: true,
363
+ log: deps.validationLog || (() => {}),
364
+ error: deps.validationError || (() => {}),
365
+ });
366
+ } catch (error) {
367
+ code = 1;
368
+ summary.notes.push(`validation failed for ${candidate.sourceReceipt}: ${String(error && error.message || error)}`);
369
+ }
370
+ if (code === 0) {
371
+ summary.graded_receipts += 1;
372
+ summary.graded_answers += candidate.gradeable.length;
373
+ } else {
374
+ summary.failures += 1;
375
+ }
376
+ }
377
+ return summary;
378
+ }
379
+
257
380
  async function runEngineValidateCommand(args = [], root = process.cwd(), deps = {}) {
258
381
  const log = deps.log || console.log;
259
382
  const errorLog = deps.error || console.error;
@@ -286,11 +409,25 @@ async function runEngineValidateCommand(args = [], root = process.cwd(), deps =
286
409
  errorLog(`engine validate: ${error.message}`);
287
410
  return 2;
288
411
  }
289
- const answered = source.receipt.answers.filter((answer) => answer.status === 'answered');
412
+ let answered = source.receipt.answers.filter((answer) => answer.status === 'answered');
290
413
  if (!answered.length) {
291
414
  errorLog('engine validate: the ask receipt has no answered entries');
292
415
  return 2;
293
416
  }
417
+ const skipped = [];
418
+ if (deps.skipMatchingWorker) {
419
+ answered = answered.filter((answer) => {
420
+ const workerEngine = canonicalEngineName(answer.engine) || String(answer.engine || '').trim();
421
+ if (workerEngine !== parsed.refereeEngine) return true;
422
+ skipped.push({
423
+ answer_label: String(answer.label || ''),
424
+ worker_engine: String(answer.engine || ''),
425
+ reason: 'judge never equals worker',
426
+ });
427
+ return false;
428
+ });
429
+ if (!answered.length) return 0;
430
+ }
294
431
  for (const answer of answered) {
295
432
  const workerEngine = canonicalEngineName(answer.engine) || String(answer.engine || '').trim();
296
433
  if (!workerEngine) {
@@ -348,6 +485,7 @@ async function runEngineValidateCommand(args = [], root = process.cwd(), deps =
348
485
  referee_engine: parsed.refereeEngine,
349
486
  referee_model: model,
350
487
  verdicts,
488
+ ...(skipped.length ? { skipped } : {}),
351
489
  duration_ms: verdicts.reduce((sum, verdict) => sum + verdict.duration_ms, 0),
352
490
  at,
353
491
  };
@@ -375,8 +513,10 @@ async function runEngineValidateCommand(args = [], root = process.cwd(), deps =
375
513
  }
376
514
 
377
515
  module.exports = {
516
+ MAX_AUTOMATIC_VALIDATIONS_PER_TICK,
378
517
  VALIDATION_SCHEMA,
379
518
  parseRefereeOutput,
380
519
  resolveAskReceipt,
381
520
  runEngineValidateCommand,
521
+ validateRecentAskReceipts,
382
522
  };
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'log', 'logs', 'wish', 'ask', 'approve', 'stop',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
7
- 'github', 'vercel', 'supabase', 'linear', 'stripe', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'feed', 'brief'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
package/lib/task-db.js CHANGED
@@ -30,6 +30,7 @@ const { DatabaseSync } = require('node:sqlite');
30
30
  const reviewIntegrity = require('./review-integrity');
31
31
  const { isDecisionTask } = require('./task-decision');
32
32
  const { parseVerifyCommand } = require('./auto-accept-certified');
33
+ const { taskExplanation } = require('./task-explanation');
33
34
 
34
35
  const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
35
36
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
@@ -384,13 +385,21 @@ function taskDisplayRefMap(rows) {
384
385
  return map;
385
386
  }
386
387
 
387
- function taskCreationMetadata(metadata) {
388
+ // One chokepoint every production addTask caller passes through, so a task
389
+ // created by mission, play, business, gm, lesson, the context gatherer, the
390
+ // self-drive lane, the CLI, or the board API all carry the same plain-language
391
+ // first layer. Explicit fields from the caller are kept verbatim; the rest get
392
+ // an honest derived default recorded as such.
393
+ function taskCreationMetadata(metadata, { title, tag } = {}) {
388
394
  const next = metadata && typeof metadata === 'object' ? { ...metadata } : {};
389
395
  const verify = typeof next.verify === 'string' ? next.verify.trim() : '';
390
396
  if (!verify || verify.toLowerCase() === 'git diff --check') {
391
397
  next.verification_status = 'degraded';
392
398
  next.verification_degraded_reason = verify ? 'diff_only_verify' : 'missing_verify';
393
399
  }
400
+ if (!next.explanation || typeof next.explanation !== 'object') {
401
+ next.explanation = taskExplanation({ title, tag, metadata: next });
402
+ }
394
403
  return next;
395
404
  }
396
405
 
@@ -408,7 +417,7 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
408
417
  ).get(ws, sk);
409
418
  if (existing) return { id: existing.id, inserted: false };
410
419
  }
411
- const taskMetadata = taskCreationMetadata(metadata);
420
+ const taskMetadata = taskCreationMetadata(metadata, { title: String(title).trim(), tag: tag || null });
412
421
  withBusyRetry(() => db.prepare(`
413
422
  INSERT INTO tasks (id, title, status, tag, workspace_root, source_key,
414
423
  claimed_by, claimed_at, created_at, updated_at, metadata)
@@ -2052,6 +2061,9 @@ function taskProjection(db, {
2052
2061
  id: row.id,
2053
2062
  ...(refById.get(row.id) || {}),
2054
2063
  title: row.title,
2064
+ // First layer, ahead of the detail. Legacy rows with no stored
2065
+ // explanation get the same three fields derived here.
2066
+ explanation: taskExplanation(row),
2055
2067
  result: row.metadata && row.metadata.result || null,
2056
2068
  status: row.status,
2057
2069
  tag: row.tag,
@@ -2130,7 +2142,14 @@ function appendSection(lines, name, rows) {
2130
2142
  // so a human judgment row never blends into ordinary work on the board.
2131
2143
  const decision = isDecisionTask(row) ? ' [decision]' : '';
2132
2144
  const displayRef = meta.todo_id || row.display_id || row.id;
2133
- lines.push(`- **[${displayRef}]** ${row.title}${tag}${decision}`);
2145
+ const explanation = taskExplanation(row);
2146
+ // The plain face leads. The exact original title remains immediately below
2147
+ // it so old TODO-only projects and deep inspection keep full fidelity.
2148
+ lines.push(`- **[${displayRef}]** ${explanation.what_changes}${tag}${decision}`);
2149
+ lines.push(` **Why it matters:** ${explanation.why_it_matters}`);
2150
+ lines.push(` **Done looks like:** ${explanation.done_looks_like}`);
2151
+ lines.push(` **Approve or change:** \`atris task show ${displayRef}\` shows the actions allowed by the current plan and proof checks.`);
2152
+ lines.push(` **Technical details:** ${row.title}`);
2134
2153
  if (row.claimed_by && row.status === 'claimed') lines.push(` **Claimed by:** ${row.claimed_by}`);
2135
2154
  if (meta.verify) lines.push(` **Verify:** ${meta.verify}`);
2136
2155
  }