atris 3.71.2 → 3.71.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.
@@ -32,7 +32,9 @@ const STATES = Object.freeze([
32
32
 
33
33
  const STOP_REASONS = Object.freeze(['judge_passed', 'stopped', 'done_by_human', 'budget_spent']);
34
34
 
35
- const MISSION_OUTCOMES = Object.freeze(['kept', 'reverted', 'failed']);
35
+ // A fault is a job that never ran (engine missing, crash before the first
36
+ // step): the setup broke, not the work.
37
+ const MISSION_OUTCOMES = Object.freeze(['kept', 'reverted', 'failed', 'fault']);
36
38
 
37
39
  // What a person or agent writes. Everything else is folded from events.
38
40
  const CAMPAIGN_FIELDS = Object.freeze({
@@ -7,7 +7,7 @@
7
7
  'use strict';
8
8
 
9
9
  const crypto = require('crypto');
10
- const { compareEvents, fold } = require('./campaign-core');
10
+ const { compareEvents, fold, foldWithPrefixes } = require('./campaign-core');
11
11
  const { judgeReading } = require('./campaign-reading');
12
12
 
13
13
  const SCHEMA = 'atris.campaign.trace';
@@ -18,7 +18,7 @@ const PRIVACY = {
18
18
  full: 'Full: includes titles, objectives, verify commands, the judge command, judge summaries and output tails, and ask text. Export this only for your own data, or with the written consent of everyone in the campaign.',
19
19
  };
20
20
 
21
- const REWARD = 'reward = reading_after - reading_before, where a reading is the judge score in [0, 1] from the judge runs on either side of the mission. Missions that ended between the same two judge runs form a batch and split the change equally.';
21
+ const REWARD = 'reward = reading_after - reading_before, where a reading is the judge score in [0, 1] from the judge runs on either side of the mission. An accepted judge reset between those runs makes the comparison unknown. Missions that ended between the same two judge runs form a batch; eligible missions split the change equally. Setup faults never ran, so they have no work reward and take no share of that change.';
22
22
 
23
23
  function round(value, places = 6) {
24
24
  const scale = 10 ** places;
@@ -84,14 +84,20 @@ function buildTrace(events, options = {}) {
84
84
 
85
85
  const missions = new Map();
86
86
  const judgeRuns = [];
87
+ const judgeChanges = [];
88
+ // Use the fold's authorization decision rather than a second owner rule.
89
+ const ignoredJudgeChanges = new Set(final.warnings
90
+ .filter(warning => warning.code === 'judge_set_not_owner')
91
+ .map(warning => warning.event_id));
87
92
  const asks = new Map();
88
93
  const info = (id) => {
89
- if (!missions.has(id)) missions.set(id, { id, firstPos: null, endPos: null, objective: null, verify: null, member: null, engine: null, kind: 'mission', estimate: null, ticks: 0, uncostedTicks: 0, endData: null, asks: [] });
94
+ if (!missions.has(id)) missions.set(id, { id, firstPos: null, endPos: null, objective: null, verify: null, member: null, engine: null, executionEngines: [], kind: 'mission', estimate: null, ticks: 0, uncostedTicks: 0, endData: null, asks: [] });
90
95
  return missions.get(id);
91
96
  };
92
97
 
93
98
  sequence.forEach((event, pos) => {
94
99
  const d = event.data;
100
+ if (event.type === 'judge_set' && !ignoredJudgeChanges.has(event.id)) judgeChanges.push(pos);
95
101
  if (event.type === 'judge_ran') {
96
102
  judgeRuns.push({ pos, at: event.at, passed: d.passed, reading: d.reading, summary: d.summary || null, outputTail: d.output_tail || null });
97
103
  return;
@@ -117,8 +123,10 @@ function buildTrace(events, options = {}) {
117
123
  if (typeof d.engine === 'string' && event.type === 'mission_started' && d.kind === 'plan') m.engine = d.engine;
118
124
  if (typeof d.estimate_usd === 'number' && m.estimate === null) m.estimate = d.estimate_usd;
119
125
  if (event.type === 'mission_started' && d.kind === 'plan') m.kind = 'plan';
126
+ if (event.type === 'mission_started' && !m.startedAt) m.startedAt = event.at;
120
127
  if (event.type === 'step') {
121
128
  m.ticks += 1;
129
+ if (d.kind !== 'plan') m.executionEngines.push(typeof d.engine === 'string' && d.engine ? d.engine : null);
122
130
  if (!(typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) && d.cost_usd >= 0)) m.uncostedTicks += 1;
123
131
  }
124
132
  if (event.type === 'mission_ended' && m.endPos === null) {
@@ -129,12 +137,13 @@ function buildTrace(events, options = {}) {
129
137
  });
130
138
  for (const ask of asks.values()) if (ask.mission_id && missions.has(ask.mission_id)) missions.get(ask.mission_id).asks.push(ask);
131
139
 
132
- // The view as it stood just before a position, folded once per position.
133
- const prefixViews = new Map();
134
- const viewBefore = (pos) => {
135
- if (!prefixViews.has(pos)) prefixViews.set(pos, fold(sequence.slice(0, Math.max(1, pos))));
136
- return prefixViews.get(pos);
137
- };
140
+ const ended = [...missions.values()].filter((m) => m.endPos !== null).sort((a, b) => a.endPos - b.endPos);
141
+ // Reuse one event traversal and retain only the compact state needed for
142
+ // ended missions. Reading lookup below still uses the original position.
143
+ const prefixStates = ended.length
144
+ ? foldWithPrefixes(sequence, ended.map((m) => Math.max(1, m.firstPos === null ? m.endPos : m.firstPos)),
145
+ (view) => compactState(view, null)).prefixes
146
+ : new Map();
138
147
  const lastRunBefore = (pos) => {
139
148
  let found = null;
140
149
  for (const run of judgeRuns) if (run.pos < pos) found = run;
@@ -156,7 +165,7 @@ function buildTrace(events, options = {}) {
156
165
  }
157
166
 
158
167
  const finalMissions = new Map(final.missions.map((m) => [m.id, m]));
159
- const ended = [...missions.values()].filter((m) => m.endPos !== null).sort((a, b) => a.endPos - b.endPos);
168
+ const outcomeOf = (m) => (finalMissions.get(m.id) || {}).outcome || m.endData.outcome;
160
169
 
161
170
  // A batch: missions that ended between the same two judge runs.
162
171
  const batches = new Map();
@@ -164,8 +173,10 @@ function buildTrace(events, options = {}) {
164
173
  const before = lastRunBefore(m.endPos);
165
174
  const after = firstRunAfter(m.endPos);
166
175
  const key = `${before ? before.pos : -1}:${after ? after.pos : 'open'}`;
167
- if (!batches.has(key)) batches.set(key, { index: batches.size + 1, before, after, missions: [] });
168
- batches.get(key).missions.push(m);
176
+ if (!batches.has(key)) batches.set(key, { index: batches.size + 1, before, after, missions: [], eligible: 0 });
177
+ const batch = batches.get(key);
178
+ batch.missions.push(m);
179
+ if (outcomeOf(m) !== 'fault') batch.eligible += 1;
169
180
  }
170
181
 
171
182
  const memberFor = (member) => (redact && typeof member === 'string' && member.startsWith('human:') ? 'human' : member);
@@ -178,23 +189,31 @@ function buildTrace(events, options = {}) {
178
189
  const after = firstRunAfter(m.endPos);
179
190
  const batch = batches.get(`${before ? before.pos : -1}:${after ? after.pos : 'open'}`);
180
191
  const size = batch.missions.length;
192
+ const outcome = outcomeOf(m);
193
+ const judgeChanged = before && after && judgeChanges.some(pos => pos > before.pos && pos < after.pos);
181
194
  const readBefore = readingOf(before);
182
- const readAfter = readingOf(after);
195
+ // A replacement baseline is real campaign state, but cannot score earlier
196
+ // work. Null also prevents a replacement pass becoming fallback RSI proof.
197
+ const readAfter = readingOf(judgeChanged ? null : after);
183
198
  const delta = readBefore.value !== null && readAfter.value !== null ? readAfter.value - readBefore.value : null;
184
199
  const startPos = m.firstPos === null ? m.endPos : m.firstPos;
185
- const view = viewBefore(startPos);
186
200
  const folded = finalMissions.get(m.id) || {};
187
201
  const uncosted = typeof m.endData.uncosted_runs === 'number' ? m.endData.uncosted_runs : m.uncostedTicks;
188
202
  let creditNote = null;
189
- if (!after) creditNote = 'no judge run after this mission yet, so it has no reward';
203
+ if (outcome === 'fault') creditNote = 'this mission never ran (setup fault), so it has no work reward and takes no share of the reading change';
204
+ else if (!after) creditNote = 'no judge run after this mission yet, so it has no reward';
190
205
  else if (!before) creditNote = 'no judge run before this mission, so it has no reward';
206
+ else if (judgeChanged) creditNote = 'the judge changed between readings, so this mission has no comparable reward';
207
+ else if (batch.eligible < size) creditNote = `one of ${batch.eligible} eligible missions in this batch; setup faults are excluded and the reading change is split equally`;
191
208
  else if (size > 1) creditNote = `one of ${size} missions that ended between the same two judge runs; the reading change is split equally`;
192
209
 
193
210
  const action = {
194
211
  mission_id: idFor(m.id),
195
212
  kind: m.kind,
196
213
  member: memberFor(folded.member || m.member),
197
- engine: m.engine || (typeof m.endData.engine === 'string' ? m.endData.engine : null),
214
+ engine: m.executionEngines.length
215
+ ? new Set(m.executionEngines).size === 1 ? m.executionEngines[0] : null
216
+ : m.engine || (typeof m.endData.engine === 'string' ? m.endData.engine : null),
198
217
  estimate_usd: m.estimate,
199
218
  };
200
219
  if (!redact) {
@@ -207,9 +226,9 @@ function buildTrace(events, options = {}) {
207
226
  step: i + 1,
208
227
  at: m.endAt,
209
228
  batch: { index: batch.index, size },
210
- state_before: compactState(view, lastRunBefore(startPos)),
229
+ state_before: { ...prefixStates.get(Math.max(1, startPos)), reading: readingOf(lastRunBefore(startPos)).value },
211
230
  action,
212
- outcome: folded.outcome || m.endData.outcome,
231
+ outcome,
213
232
  cost_usd: folded.cost_usd === undefined || folded.cost_usd === null ? m.endData.cost_usd : folded.cost_usd,
214
233
  ticks: m.ticks,
215
234
  uncosted_ticks: uncosted,
@@ -217,12 +236,15 @@ function buildTrace(events, options = {}) {
217
236
  reading_before: readBefore.value,
218
237
  reading_after: readAfter.value,
219
238
  reading_source: readAfter.source,
220
- reward: delta === null ? null : round(delta / size),
239
+ reward: outcome === 'fault' || delta === null ? null : round(delta / batch.eligible),
221
240
  credit_note: creditNote,
222
241
  done: finishPos !== null && finishPos <= (after ? after.pos : lastPos),
223
- judge_passed: after ? after.passed : null,
242
+ judge_passed: after && !judgeChanged ? after.passed : null,
224
243
  };
225
244
  if (!redact) {
245
+ row.started_at = m.startedAt || null;
246
+ row.receipt_path = typeof m.endData.receipt_path === 'string' ? m.endData.receipt_path : null;
247
+ row.execution_engines = m.executionEngines;
226
248
  row.judge_summary = after ? after.summary : null;
227
249
  row.judge_output_tail = after ? after.outputTail : null;
228
250
  row.asks = m.asks.map((ask) => ({ question: ask.question, answer: ask.answer }));
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { devNull } = require('os');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const BASELINE_SCHEMA = 'atris.mission_code_baseline.v1';
8
+ const PROVENANCE_SCHEMA = 'atris.mission_code_provenance.v1';
9
+ const BOOKKEEPING = ['.atris/', 'atris/runs/', 'atris/status/'];
10
+
11
+ function fail(reason) {
12
+ throw new Error(reason);
13
+ }
14
+
15
+ function git(root, args, reason = 'git_command_failed') {
16
+ // Disabling replacements alone still permits info/grafts and inherited graft
17
+ // files to rewrite ancestry. Evidence needs the actual commit relationships.
18
+ const env = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_GRAFT_FILE: devNull };
19
+ // Evidence belongs to this on-disk repository, even when the caller was
20
+ // launched by a Git hook with another index or repository in its environment.
21
+ for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_SHALLOW_FILE']) delete env[key];
22
+ const result = spawnSync('git', ['-c', 'core.fsmonitor=false', ...args], {
23
+ cwd: root, env, timeout: 15000, maxBuffer: Infinity,
24
+ });
25
+ if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) fail(reason);
26
+ const text = result.stdout.toString('utf8');
27
+ // A lossy filename decode cannot stand in for a complete path list.
28
+ if (!Buffer.from(text, 'utf8').equals(result.stdout)) fail('git_output_unreadable');
29
+ return text;
30
+ }
31
+
32
+ function repositoryRoot(root) {
33
+ const physical = fs.realpathSync(root);
34
+ const top = git(physical, ['rev-parse', '--show-toplevel'], 'not_git_repository').replace(/\n$/, '');
35
+ if (fs.realpathSync(top) !== physical) fail('root_not_git_top_level');
36
+ return physical;
37
+ }
38
+
39
+ function nulEntries(text) {
40
+ if (!text) return [];
41
+ if (!text.endsWith('\0')) fail('git_output_incomplete');
42
+ return text.slice(0, -1).split('\0');
43
+ }
44
+
45
+ function knownCommit(root, commit) {
46
+ if (typeof commit !== 'string' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) fail('commit_unavailable');
47
+ if (git(root, ['cat-file', '-t', commit], 'commit_unavailable').trim() !== 'commit') fail('commit_unavailable');
48
+ return commit;
49
+ }
50
+
51
+ function cleanHead(root) {
52
+ const head = knownCommit(root, git(root, ['rev-parse', '--verify', 'HEAD'], 'head_unavailable').trim());
53
+ // Disabling rename detection exposes both paths, including a product file
54
+ // moved into a bookkeeping directory. Every untracked file is inspected.
55
+ const entries = nulEntries(git(root, [
56
+ 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames', '--ignore-submodules=none',
57
+ ], 'git_status_unavailable'));
58
+ for (const entry of entries) {
59
+ if (entry.length < 4 || entry[2] !== ' ') fail('git_status_unreadable');
60
+ if (!BOOKKEEPING.some((prefix) => entry.slice(3).startsWith(prefix))) fail('product_dirty');
61
+ }
62
+ if (git(root, ['rev-parse', '--verify', 'HEAD'], 'head_unavailable').trim() !== head) fail('head_changed_during_capture');
63
+ return head;
64
+ }
65
+
66
+ function committedFiles(root, base, head) {
67
+ knownCommit(root, base);
68
+ knownCommit(root, head);
69
+ git(root, ['merge-base', '--is-ancestor', base, head], 'base_not_ancestor');
70
+ // No sample, path filter, or rename folding: deleted and renamed source
71
+ // paths matter as much as additions, including committed bookkeeping.
72
+ return nulEntries(git(root, [
73
+ 'diff', '--name-only', '--no-renames', '--no-ext-diff', '--no-textconv', '-z', base, head, '--',
74
+ ], 'git_diff_unavailable'));
75
+ }
76
+
77
+ function unavailable(schema, error) {
78
+ return { schema, status: 'unavailable', reason: error?.message || 'git_evidence_unavailable' };
79
+ }
80
+
81
+ function captureCodeBaseline(root) {
82
+ try {
83
+ const repo_root = repositoryRoot(root);
84
+ return { schema: BASELINE_SCHEMA, status: 'complete', repo_root, base_commit: cleanHead(repo_root) };
85
+ } catch (error) {
86
+ return unavailable(BASELINE_SCHEMA, error);
87
+ }
88
+ }
89
+
90
+ function collectCodeProvenance(root, baseline) {
91
+ try {
92
+ if (baseline?.schema !== BASELINE_SCHEMA || baseline.status !== 'complete') fail('baseline_unavailable');
93
+ const repo_root = repositoryRoot(root);
94
+ if (baseline.repo_root !== repo_root) fail('baseline_repository_mismatch');
95
+ const head_commit = cleanHead(repo_root);
96
+ const base_commit = baseline.base_commit;
97
+ const files = committedFiles(repo_root, base_commit, head_commit);
98
+ return { schema: PROVENANCE_SCHEMA, status: 'complete', repo_root, base_commit, head_commit, files };
99
+ } catch (error) {
100
+ return unavailable(PROVENANCE_SCHEMA, error);
101
+ }
102
+ }
103
+
104
+ function verifyCodeProvenance(root, provenance) {
105
+ try {
106
+ if (provenance?.schema !== PROVENANCE_SCHEMA || provenance.status !== 'complete' || !Array.isArray(provenance.files)) fail('provenance_unavailable');
107
+ const repo_root = repositoryRoot(root);
108
+ if (provenance.repo_root !== repo_root) fail('provenance_repository_mismatch');
109
+ // Historical evidence stays valid when the current checkout moves on.
110
+ const actual = committedFiles(repo_root, provenance.base_commit, provenance.head_commit);
111
+ if (actual.length !== provenance.files.length || actual.some((file, index) => file !== provenance.files[index])) fail('provenance_files_mismatch');
112
+ return { ok: true };
113
+ } catch (error) {
114
+ return { ok: false, reason: error?.message || 'git_evidence_unavailable' };
115
+ }
116
+ }
117
+
118
+ module.exports = { captureCodeBaseline, collectCodeProvenance, verifyCodeProvenance };
package/lib/pulse.js CHANGED
@@ -603,6 +603,7 @@ function buildTickScript(opts = {}) {
603
603
  pathDirs = [],
604
604
  launchLabel = '',
605
605
  launchPlistPath = '',
606
+ tickFlags = [],
606
607
  } = opts;
607
608
  if (!root) throw new Error('buildTickScript: root is required');
608
609
  if (!stateHome) throw new Error('buildTickScript: stateHome is required');
@@ -690,7 +691,7 @@ ${runnerBinExport}
690
691
  ${runnerCommandTemplateExport}
691
692
  export ATRIS_SKIP_UPDATE_CHECK=1
692
693
 
693
- "$ATRIS" pulse tick --json${safeVerify == null ? '' : ` --verify '${safeVerify}'`} >> "$log" 2>&1
694
+ "$ATRIS" pulse tick --json${tickFlags.filter((f) => /^--[a-z-]+$/.test(f)).map((f) => ` ${f}`).join('')}${safeVerify == null ? '' : ` --verify '${safeVerify}'`} >> "$log" 2>&1
694
695
  tick_status=$?
695
696
 
696
697
  # runDaily no-ops via last_run_date after the first daily run.
package/lib/rsi-record.js CHANGED
@@ -320,6 +320,93 @@ function finishAttempt(root, attempt, outcome = {}, { log = () => {} } = {}) {
320
320
  }
321
321
  }
322
322
 
323
+ // Observed history did not run under today's policy. Use the schema directly:
324
+ // open/finish would invent current action and completion times for old work.
325
+ const OBSERVED_APPEND_PY = `import json, sys
326
+ from dataclasses import asdict
327
+ from datetime import datetime
328
+ sys.path.insert(0, sys.argv[1])
329
+ from backend.scripts.rsi.schema import Action, Context, Node, Outcome, append_node, tree_split
330
+ from backend.scripts.rsi.record import get_node
331
+ data = json.loads(sys.argv[2])
332
+ def instant(value): return datetime.fromisoformat(value.replace('Z', '+00:00'))
333
+ if data.get('nodeId'):
334
+ node = get_node(data['nodeId'])
335
+ if (node is None or node.lane != data['lane'] or
336
+ node.context.ground != data['ground'] or node.action.ground != data['ground'] or
337
+ node.action.engine != data['engine'] or node.action.policy_id != 'p-0000' or
338
+ node.context.base_commit != data['baseCommit'] or node.outcome.status == 'running' or
339
+ instant(node.opened_at) != instant(data['openedAt']) or
340
+ instant(node.created_at) != instant(data['finishedAt'])):
341
+ raise ValueError('observed attempt identity, policy, or provenance does not match')
342
+ outcome = asdict(node.outcome)
343
+ outcome.update(data['outcome'])
344
+ node.outcome = Outcome(**outcome)
345
+ node.recorded_at = None
346
+ else:
347
+ node = Node(
348
+ tree_id=data['tree'], lane=data['lane'],
349
+ context=Context(base_commit=data['baseCommit'], ground=data['ground']),
350
+ action=Action(policy_id='p-0000', kind='open', ground=data['ground'], engine=data['engine'], model=None, cap_s=0),
351
+ outcome=Outcome(**data['outcome']), split=tree_split(data['tree']),
352
+ created_at=data['finishedAt'], opened_at=data['openedAt'])
353
+ append_node(node)
354
+ print(node.id)
355
+ `;
356
+
357
+ function observedTime(value) {
358
+ if (typeof value !== 'string') return NaN;
359
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/);
360
+ if (!match) return NaN;
361
+ const year = Number(match[1]);
362
+ const month = Number(match[2]);
363
+ const day = Number(match[3]);
364
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
365
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
366
+ if (month < 1 || month > 12 || day < 1 || day > days[month - 1]) return NaN;
367
+ return Date.parse(value);
368
+ }
369
+
370
+ /** Append measured completed work, preserving its historical provenance.
371
+ * Missing metadata or an unavailable backend schema defers recording. This
372
+ * never chooses a policy and never changes the active-attempt environment.
373
+ */
374
+ function recordObservedAttempt(root, options = {}, { log = () => {} } = {}) {
375
+ const report = (message) => { try { log(message); } catch {} };
376
+ try {
377
+ const { lane = 'campaign', ground, engine, baseCommit, openedAt, finishedAt, outcome, nodeId } = options;
378
+ const engines = new Set(['codex', 'devin', 'cursor', 'agy', 'claude']);
379
+ const opened = observedTime(openedAt);
380
+ const finished = observedTime(finishedAt);
381
+ if (!root || !recorderPath(root) || typeof lane !== 'string' || !lane.trim() ||
382
+ typeof ground !== 'string' || !ground.trim() || !engines.has(engine) ||
383
+ typeof baseCommit !== 'string' || !/^[a-fA-F0-9]{7,64}$/.test(baseCommit) ||
384
+ !Number.isFinite(opened) || !Number.isFinite(finished) || opened > finished || finished > Date.now() ||
385
+ !outcome || typeof outcome !== 'object' || Array.isArray(outcome) ||
386
+ !['shipped', 'failed', 'nothing', 'waiting'].includes(outcome.status) ||
387
+ !['pass', 'fail', 'skipped'].includes(outcome.verify) ||
388
+ !Array.isArray(outcome.files) || !outcome.files.length ||
389
+ !Array.from(outcome.files).every((file) => typeof file === 'string' && file.trim()) ||
390
+ (nodeId !== undefined && (typeof nodeId !== 'string' || !nodeId.trim()))) return null;
391
+ const tree = nodeId ? null : startTree(root, lane);
392
+ if (!nodeId && !tree) {
393
+ report('rsi: observed attempt skipped (could not select a tree)');
394
+ return null;
395
+ }
396
+ const result = spawnSync(pythonBin(root), ['-c', OBSERVED_APPEND_PY, root,
397
+ JSON.stringify({ lane, ground, engine, baseCommit, openedAt, finishedAt, outcome, nodeId, tree })], {
398
+ cwd: root, encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, env: process.env,
399
+ });
400
+ const id = result.status === 0 ? String(result.stdout || '').trim().split('\n').pop() : '';
401
+ if (id) return id;
402
+ report(`rsi: observed attempt skipped${result.stderr ? ` (${String(result.stderr).trim().split('\n').pop()})` : ''}`);
403
+ return null;
404
+ } catch (error) {
405
+ report(`rsi: observed attempt skipped (${error && error.message ? error.message : error})`);
406
+ return null;
407
+ }
408
+ }
409
+
323
410
  module.exports = {
324
411
  IMPROVE_LANE,
325
412
  GUARD_ENV,
@@ -332,4 +419,5 @@ module.exports = {
332
419
  engineFromModel,
333
420
  beginAttempt,
334
421
  finishAttempt,
422
+ recordObservedAttempt,
335
423
  };
@@ -43,6 +43,14 @@ function slugifyTaskId(taskId) {
43
43
  .slice(0, 40) || 'task';
44
44
  }
45
45
 
46
+ function verifyTimeoutMs() {
47
+ // Match the audit and certification budget, including its one-hour ceiling.
48
+ const raw = Number(process.env.ATRIS_VERIFY_TIMEOUT_MS);
49
+ return Number.isFinite(raw) && raw > 0
50
+ ? Math.min(Math.ceil(raw), 60 * 60 * 1000)
51
+ : 10 * 60 * 1000;
52
+ }
53
+
46
54
  // Runs `command` for `taskId`, writes atris/runs/<date>-task-<id>-<time>.json,
47
55
  // and returns { ok, passed, receiptPath, exit, signal, output }. `ok` mirrors
48
56
  // `passed`, kept as a separate field so callers reading either name work.
@@ -56,16 +64,24 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
56
64
  // support. Put the node running atris first, after the profile has loaded.
57
65
  const nodeDir = path.dirname(process.execPath).replace(/'/g, `'\\''`);
58
66
  const script = `export PATH='${nodeDir}':"$PATH"\n${cmd}`;
67
+ const timeoutMs = verifyTimeoutMs();
59
68
  const result = run('bash', ['-lc', script], {
60
69
  cwd: root,
61
70
  encoding: 'utf8',
62
- timeout: 120000,
71
+ timeout: timeoutMs,
63
72
  });
64
- if (result.error) {
65
- return { ok: false, passed: false, reason: 'verifier_spawn_failed', error: result.error.message, cmd };
66
- }
67
- const output = tailText(`${result.stdout || ''}${result.stderr || ''}`);
68
- const passed = result.status === 0;
73
+ const failure = result.error ? {
74
+ error: result.error.message,
75
+ error_code: result.error.code || null,
76
+ timeout_ms: timeoutMs,
77
+ } : {};
78
+ const diagnostic = result.error
79
+ ? (result.error.code === 'ETIMEDOUT'
80
+ ? `\nverification timed out after ${timeoutMs / 1000} seconds.`
81
+ : '\nverification did not complete.')
82
+ : '';
83
+ const output = tailText(`${result.stdout || ''}${result.stderr || ''}${diagnostic}`);
84
+ const passed = !result.error && result.status === 0;
69
85
  const dir = runsDir(root);
70
86
  fs.mkdirSync(dir, { recursive: true });
71
87
  const now = new Date();
@@ -84,6 +100,7 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
84
100
  exit: result.status,
85
101
  signal: result.signal || null,
86
102
  output,
103
+ ...failure,
87
104
  },
88
105
  };
89
106
  fs.writeFileSync(receiptFile, JSON.stringify(receipt, null, 2) + '\n', 'utf8');
@@ -96,6 +113,7 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
96
113
  signal: result.signal || null,
97
114
  output,
98
115
  cmd,
116
+ ...(result.error ? { reason: 'verifier_spawn_failed', ...failure } : {}),
99
117
  };
100
118
  }
101
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.71.2",
3
+ "version": "3.71.3",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {