atris 3.34.0 → 3.36.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.
Files changed (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. package/atris/learnings.jsonl +0 -1
package/commands/play.js CHANGED
@@ -328,6 +328,7 @@ function playWorkspaceRoot(taskDb, workspaceArg) {
328
328
 
329
329
  function nextCommands(task, player) {
330
330
  const helper = player || 'player';
331
+ const readyResult = 'Players can now claim AgentXP proof faster because the verifier names the finished artifact.';
331
332
  if (!task) {
332
333
  return [
333
334
  `atris task delegate "AgentXP first rep: one proof-backed mission" --to ${player} --tag agent-xp`,
@@ -339,7 +340,7 @@ function nextCommands(task, player) {
339
340
  if (task.status === 'open') {
340
341
  return [
341
342
  `atris task claim ${ref} --as ${helper}`,
342
- `atris task ready ${ref} --as ${helper} --proof "AGENTXP_PROOF.md + test -s AGENTXP_PROOF.md passed"`,
343
+ `atris task ready ${ref} --as ${helper} --proof "AGENTXP_PROOF.md + test -s AGENTXP_PROOF.md passed" --result "${readyResult}"`,
343
344
  `atris task accept ${ref} --as ${player} --proof "<human review>"`,
344
345
  'atris xp card --local',
345
346
  ...globalSyncCommands(player),
@@ -349,7 +350,7 @@ function nextCommands(task, player) {
349
350
  if (task.status === 'claimed') {
350
351
  const actor = task.claimed_by || helper;
351
352
  return [
352
- `atris task ready ${ref} --as ${actor} --proof "AGENTXP_PROOF.md + test -s AGENTXP_PROOF.md passed"`,
353
+ `atris task ready ${ref} --as ${actor} --proof "AGENTXP_PROOF.md + test -s AGENTXP_PROOF.md passed" --result "${readyResult}"`,
353
354
  `atris task accept ${ref} --as ${player} --proof "<human review>"`,
354
355
  'atris xp card --local',
355
356
  ...globalSyncCommands(player),
package/commands/probe.js CHANGED
@@ -28,6 +28,7 @@ const https = require('https');
28
28
  const http = require('http');
29
29
  const { getApiBaseUrl } = require('../utils/api');
30
30
  const { loadCredentials } = require('../utils/auth');
31
+ const { encodeToolResult } = require('../lib/tool-result-encode');
31
32
 
32
33
  // G2's honest blocked message (tool_policy_bench.MAX_TURNS_EXHAUSTED_MESSAGE)
33
34
  // starts with this — an answer that is only this marker is a dead-end.
@@ -182,6 +183,26 @@ function runTerminal(base, token, command, businessId) {
182
183
  return postJson(`${base}/ai-computer/terminal`, token, body, 80000);
183
184
  }
184
185
 
186
+ // CLI-231: local executor for atris2 mission ticks. Same result shape as the
187
+ // /ai-computer/terminal endpoint ({ stdout, stderr, exit_code }), but the
188
+ // command runs in the mission's OWN workspace directory instead of the hosted
189
+ // ai-computer. Without this, a mission tick in a local workspace read the
190
+ // remote computer's files and reported local members (e.g. maze) as missing.
191
+ function runLocalTerminal(command, cwd) {
192
+ const { exec } = require('child_process');
193
+ const { envWithNodeDir } = require('../lib/spawn-env');
194
+ const env = envWithNodeDir(process.env);
195
+ return new Promise((resolve) => {
196
+ exec(command, { cwd, env, timeout: 60000, maxBuffer: 4 * 1024 * 1024, shell: '/bin/bash' }, (error, stdout, stderr) => {
197
+ resolve({
198
+ stdout: String(stdout || ''),
199
+ stderr: String(stderr || (error && error.message) || ''),
200
+ exit_code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
201
+ });
202
+ });
203
+ });
204
+ }
205
+
185
206
  function parseArgs(argv) {
186
207
  const a = { business: null, model: 'atris:fast', memberId: null, memberSlug: null, calendar: false, message: null };
187
208
  for (let i = 0; i < argv.length; i++) {
@@ -215,6 +236,7 @@ async function runAtris2Turn(opts = {}) {
215
236
  idleMs = 180000,
216
237
  connectTimeoutMs = 60000,
217
238
  signal = null,
239
+ localCwd = null,
218
240
  } = opts;
219
241
  const t0 = Date.now();
220
242
  const out = { ok: false, text: '', engine: null, tools_run: 0, cli_ops: [], unsupported: [], error: null, duration_ms: 0 };
@@ -299,7 +321,9 @@ async function runAtris2Turn(opts = {}) {
299
321
  out = { status: 'error', error: 'unsupported op or unsafe path' };
300
322
  unsupported.push(`file_op:${op}`);
301
323
  } else {
302
- const term = await runTerminal(base, token, cmd, business);
324
+ const term = localCwd
325
+ ? await runLocalTerminal(cmd, localCwd)
326
+ : await runTerminal(base, token, cmd, business);
303
327
  const ok = term.exit_code === 0;
304
328
  out = ok
305
329
  ? { status: 'ok', stdout: String(term.stdout || '').slice(0, 12000) }
@@ -316,7 +340,9 @@ async function runAtris2Turn(opts = {}) {
316
340
  out = { status: 'error', error: `unsupported atris cli op: ${op || '?'}` };
317
341
  unsupported.push(`cli_op:${op || '?'}`);
318
342
  } else {
319
- const term = await runTerminal(base, token, cmd, business);
343
+ const term = localCwd
344
+ ? await runLocalTerminal(cmd, localCwd)
345
+ : await runTerminal(base, token, cmd, business);
320
346
  out = atrisCliResult(cmd, term);
321
347
  cliOps.push(op || '?');
322
348
  }
@@ -325,7 +351,7 @@ async function runAtris2Turn(opts = {}) {
325
351
  unsupported.push(`tool:${name || '?'}`);
326
352
  }
327
353
  await postJson(`${base}/atris2/turn/tool-result`, token,
328
- { call_id: ev.call_id || ev.id, result: out }, 30000);
354
+ { call_id: ev.call_id || ev.id, result_base64: encodeToolResult(out) }, 30000);
329
355
  resetIdle();
330
356
  }).catch((e) => reject(e));
331
357
  } else if (et === 'result') {
@@ -441,4 +467,5 @@ module.exports = {
441
467
  fileOpCommand,
442
468
  atrisCliCommand,
443
469
  atrisCliResult,
470
+ runLocalTerminal,
444
471
  };
package/commands/pulse.js CHANGED
@@ -49,7 +49,7 @@ Options:
49
49
  --json Print machine-readable output
50
50
  --no-claude Do not spawn Claude-backed mission work
51
51
  --no-verify Skip verifier command
52
- --verify "<cmd>" Verifier for changed-work ticks (default: npm test)
52
+ --verify "<cmd>" Verifier for changed-work ticks (default: npm test if package.json exists, else none)
53
53
  --cadence "<cron>" Cron cadence for install
54
54
  --days <n> Auto-expire installed heartbeat after n days
55
55
  --hours <n> Auto-expire installed heartbeat after n hours
@@ -76,8 +76,6 @@ function emit(obj, asJson) {
76
76
  return obj;
77
77
  }
78
78
 
79
- const STATE_HOME = path.join(os.homedir(), '.atris', 'overnight', 'atris-cli-self-improve');
80
-
81
79
  // --- engine: run one mission-run-due tick via the real CLI ---
82
80
 
83
81
  function runMissionEngine(root, { noClaude = false, timeoutMs = 600000 } = {}) {
@@ -166,25 +164,51 @@ function gitSnapshot(root) {
166
164
  return { head, dirty: new Set(gitChangedFiles(root)) };
167
165
  }
168
166
 
169
- function runVerify(root, verifyCmd, timeoutMs = 600000) {
167
+ // 30 min: the full suite takes ~8 min alone but 15+ under concurrent mission
168
+ // load; a 10-min cap turned green suites into false verify FAILs (tick 159).
169
+ function runVerify(root, verifyCmd, timeoutMs = 1800000) {
170
170
  if (!verifyCmd) return { passed: null, cmd: null };
171
+ const { envWithNodeDir } = require('../lib/spawn-env');
171
172
  const r = spawnSync(verifyCmd, {
172
173
  cwd: root,
173
174
  shell: true,
174
175
  encoding: 'utf8',
175
176
  timeout: timeoutMs,
176
- env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_AGENT_PROOF_ONLY: '0' },
177
+ env: envWithNodeDir({ ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_AGENT_PROOF_ONLY: '0' }),
177
178
  });
178
179
  return { passed: r.status === 0, cmd: verifyCmd, status: r.status };
179
180
  }
180
181
 
182
+ function refreshOrbPolicyLessons(root, ingestion, now = new Date()) {
183
+ if (!ingestion || ingestion.historical_failures < 1) return null;
184
+ const {
185
+ loadHistory,
186
+ mineProofPolicy,
187
+ writePolicyLessons,
188
+ syncLessonsMd,
189
+ } = require('../lib/policy-lessons');
190
+ const mined = mineProofPolicy(loadHistory(root), {
191
+ now,
192
+ orbWindowDays: ingestion.summary && ingestion.summary.days,
193
+ });
194
+ const statePath = writePolicyLessons(root, mined);
195
+ const lessonsMd = syncLessonsMd(root, mined);
196
+ const orbLesson = mined.lessons.find((lesson) => lesson.id === 'orb-job-failures') || null;
197
+ return {
198
+ status: orbLesson ? orbLesson.status : null,
199
+ state_path: statePath,
200
+ lessons_md_path: lessonsMd.path,
201
+ lessons_md_written: lessonsMd.written,
202
+ };
203
+ }
204
+
181
205
  // --- atris pulse tick ---
182
206
 
183
207
  function tickCommand(args, root = process.cwd()) {
184
208
  const asJson = wantsJson(args);
185
209
  const noClaude = hasFlag(args, '--no-claude');
186
210
  const noVerify = hasFlag(args, '--no-verify');
187
- const verifyCmd = noVerify ? null : readFlag(args, '--verify', 'npm test');
211
+ const verifyCmd = noVerify ? null : readFlag(args, '--verify', pulse.defaultVerifyCmd(root));
188
212
  const startedAt = Date.now();
189
213
 
190
214
  // Detect a previous tick that died mid-run before we take the lock.
@@ -199,6 +223,20 @@ function tickCommand(args, root = process.cwd()) {
199
223
  }
200
224
 
201
225
  const tickIndex = pulse.nextTickIndex(root);
226
+ let orbIngestion = {
227
+ summary: null,
228
+ improvement_candidates: [],
229
+ scorecards_written: 0,
230
+ historical_failures: 0,
231
+ };
232
+ let orbPolicy = null;
233
+ let orbIngestError = null;
234
+ try {
235
+ orbIngestion = pulse.ingestOrbScorecard(root);
236
+ orbPolicy = refreshOrbPolicyLessons(root, orbIngestion);
237
+ } catch (error) {
238
+ orbIngestError = String(error && error.message ? error.message : error);
239
+ }
202
240
  const signalExitCode = { SIGINT: 130, SIGTERM: 143 };
203
241
  let interrupted = false;
204
242
  const finishInterrupted = (signal) => {
@@ -225,18 +263,31 @@ function tickCommand(args, root = process.cwd()) {
225
263
  tickIndex,
226
264
  phase: 'started',
227
265
  prevTickStale: priorStale.stale,
266
+ orbScorecard: orbIngestion.summary,
267
+ improvementCandidates: orbIngestion.improvement_candidates,
268
+ orbIngestError,
228
269
  }));
229
270
 
230
271
  let engine;
231
272
  let verify = { passed: null, cmd: verifyCmd };
232
273
  try {
233
- const before = gitSnapshot(root);
274
+ // Non-git workspaces made producedWork permanently false (empty git delta),
275
+ // so every tick scored 0 no matter what the engine wrote. Fall back to a
276
+ // filesystem snapshot when git is absent.
277
+ const isRepo = fs.existsSync(path.join(root, '.git'));
278
+ const before = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
234
279
  engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot') });
235
- const after = gitSnapshot(root);
280
+ const after = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
236
281
  // This tick's ACTUAL contribution: files it newly dirtied, or a new commit.
237
282
  // Pre-existing dirt is excluded so reward isn't re-credited every tick.
238
- const changedFiles = [...after.dirty].filter((f) => !before.dirty.has(f));
239
- const committed = Boolean(before.head && after.head && before.head !== after.head);
283
+ let changedFiles;
284
+ let committed = false;
285
+ if (isRepo) {
286
+ changedFiles = [...after.dirty].filter((f) => !before.dirty.has(f));
287
+ committed = Boolean(before.head && after.head && before.head !== after.head);
288
+ } else {
289
+ changedFiles = pulse.diffFsSnapshots(before, after);
290
+ }
240
291
  const producedWork = committed || changedFiles.length > 0;
241
292
 
242
293
  // Verify only matters when the tick produced work; a no-op tick skips it.
@@ -270,6 +321,9 @@ function tickCommand(args, root = process.cwd()) {
270
321
  elapsedMs,
271
322
  prevTickStale: priorStale.stale,
272
323
  reward,
324
+ orbScorecard: orbIngestion.summary,
325
+ improvementCandidates: orbIngestion.improvement_candidates,
326
+ orbIngestError,
273
327
  });
274
328
  pulse.appendPulseReceipt(root, receipt);
275
329
 
@@ -283,10 +337,33 @@ function tickCommand(args, root = process.cwd()) {
283
337
  changedFiles,
284
338
  elapsedMs,
285
339
  model: process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || DEFAULT_CLAUDE_RUNNER_MODEL,
340
+ orbScorecard: orbIngestion.summary,
341
+ improvementCandidates: orbIngestion.improvement_candidates,
286
342
  }));
287
343
  scorecardWritten = true;
288
344
  }
289
345
 
346
+ // Closure sweep rides the heartbeat: escalate overdue open loops once a
347
+ // day without a human invoking atris close sweep. Never fails the tick.
348
+ let closure = null;
349
+ try {
350
+ const { scanState, sweepState } = require('./close');
351
+ const scanned = scanState(root);
352
+ const swept = sweepState(root);
353
+ closure = {
354
+ open: swept.open,
355
+ overdue: swept.overdue.length,
356
+ escalated_today: swept.escalated_today,
357
+ scanned: {
358
+ opened: scanned.opened.length,
359
+ auto_closed: scanned.auto_closed.length,
360
+ },
361
+ };
362
+ } catch { /* closure ledger optional */ }
363
+
364
+ // Rewrite the live vitals page so an open browser tab shows this tick.
365
+ try { require('../lib/improve-vitals-html').writeVitalsHtml(root); } catch { /* page is a bonus */ }
366
+
290
367
  const out = {
291
368
  ok: true,
292
369
  action: 'pulse_tick',
@@ -300,11 +377,23 @@ function tickCommand(args, root = process.cwd()) {
300
377
  prev_tick_stale: priorStale.stale,
301
378
  elapsed_ms: elapsedMs,
302
379
  receipts_path: pulse.pulseReceiptsPath(root),
380
+ orb_scorecard: orbIngestion.summary,
381
+ orb_improvement_candidates: orbIngestion.improvement_candidates,
382
+ orb_scorecards_written: orbIngestion.scorecards_written,
383
+ orb_policy_lesson: orbPolicy,
303
384
  };
385
+ if (orbIngestError) out.orb_ingest_error = orbIngestError;
386
+ if (closure) out.closure = closure;
304
387
  if (!asJson) {
305
388
  const ghost = priorStale.stale ? ` (recovered ghost tick #${priorStale.tick_index || '?'})` : '';
306
389
  const r = reward > 0 ? `+${reward}` : String(reward);
307
390
  process.stdout.write(`pulse tick #${tickIndex}: ${what} - verify ${verify.passed === null ? 'n/a' : verify.passed ? 'pass' : 'FAIL'} - reward ${r}${ghost}\n`);
391
+ if (orbIngestion.summary && orbIngestion.summary.fail > 0) {
392
+ process.stdout.write(`orb: ${orbIngestion.summary.fail} failed job${orbIngestion.summary.fail === 1 ? '' : 's'} in the ${orbIngestion.summary.days}-day window; ${orbIngestion.scorecards_written} new improvement candidate${orbIngestion.scorecards_written === 1 ? '' : 's'} recorded.\n`);
393
+ }
394
+ if (closure && closure.overdue > 0) {
395
+ process.stdout.write(`closure: ${closure.overdue} open loop${closure.overdue === 1 ? '' : 's'} overdue, run: atris close sweep\n`);
396
+ }
308
397
  }
309
398
  return emit(out, asJson);
310
399
  } catch (err) {
@@ -318,8 +407,21 @@ function tickCommand(args, root = process.cwd()) {
318
407
  what: `tick crashed: ${err && err.message ? err.message : String(err)}`,
319
408
  elapsedMs: Date.now() - startedAt,
320
409
  reward: -1,
410
+ orbScorecard: orbIngestion.summary,
411
+ improvementCandidates: orbIngestion.improvement_candidates,
412
+ orbIngestError,
321
413
  }));
322
- const out = { ok: false, action: 'pulse_tick', tick_index: tickIndex, error: err && err.message ? err.message : String(err) };
414
+ const out = {
415
+ ok: false,
416
+ action: 'pulse_tick',
417
+ tick_index: tickIndex,
418
+ error: err && err.message ? err.message : String(err),
419
+ orb_scorecard: orbIngestion.summary,
420
+ orb_improvement_candidates: orbIngestion.improvement_candidates,
421
+ orb_scorecards_written: orbIngestion.scorecards_written,
422
+ orb_policy_lesson: orbPolicy,
423
+ };
424
+ if (orbIngestError) out.orb_ingest_error = orbIngestError;
323
425
  if (!asJson) process.stdout.write(`pulse tick #${tickIndex} crashed: ${out.error}\n`);
324
426
  return emit(out, asJson);
325
427
  } finally {
@@ -331,34 +433,89 @@ function tickCommand(args, root = process.cwd()) {
331
433
 
332
434
  // --- atris pulse status ---
333
435
 
334
- function cronInstalled(marker = pulse.PULSE_MARKER) {
436
+ function crontabHasActiveMarker(crontab, markers = pulse.PULSE_MARKER) {
437
+ const markerList = (Array.isArray(markers) ? markers : [markers]).filter(Boolean);
438
+ return String(crontab || '')
439
+ .split('\n')
440
+ .map((line) => line.trim())
441
+ .filter((line) => line && !line.startsWith('#'))
442
+ .some((line) => markerList.some((marker) => line.includes(marker)));
443
+ }
444
+
445
+ function cronInstalled(markers = pulse.PULSE_MARKER, commandSpawn = spawnSync) {
335
446
  try {
336
- const r = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
447
+ const r = commandSpawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
337
448
  if (r.status !== 0) return false;
338
- return String(r.stdout || '').includes(marker);
449
+ return crontabHasActiveMarker(r.stdout, markers);
339
450
  } catch {
340
451
  return false;
341
452
  }
342
453
  }
343
454
 
344
- function statusCommand(args, root = process.cwd()) {
455
+ function pulseExpiryBreadcrumbPath(root) {
456
+ return path.join(root, '.atris', 'state', 'pulse-expired.json');
457
+ }
458
+
459
+ function readPulseExpiryBreadcrumb(root) {
460
+ try {
461
+ const raw = fs.readFileSync(pulseExpiryBreadcrumbPath(root), 'utf8').trim();
462
+ const candidates = [];
463
+ if (raw) candidates.push(raw, ...raw.split('\n').reverse());
464
+ for (const candidate of candidates) {
465
+ try {
466
+ const parsed = JSON.parse(candidate);
467
+ if (parsed
468
+ && typeof parsed.expired_at === 'string'
469
+ && typeof parsed.state_home === 'string'
470
+ && typeof parsed.renew_command === 'string') {
471
+ return parsed;
472
+ }
473
+ } catch {}
474
+ }
475
+ } catch {}
476
+ return null;
477
+ }
478
+
479
+ function clearPulseExpiryBreadcrumb(root) {
480
+ const file = pulseExpiryBreadcrumbPath(root);
481
+ try {
482
+ if (!fs.existsSync(file)) return false;
483
+ fs.rmSync(file, { force: true });
484
+ return true;
485
+ } catch {
486
+ return false;
487
+ }
488
+ }
489
+
490
+ function statusCommand(args, root = process.cwd(), options = {}) {
345
491
  const asJson = wantsJson(args);
492
+ const slot = pulse.resolvePulseSlot(root, options.homeDir ? { homeDir: options.homeDir } : {});
346
493
  const receipts = pulse.readPulseReceipts(root);
347
494
  const summary = pulse.summarizePulse(receipts);
348
- const installed = cronInstalled();
495
+ const installed = cronInstalled(slot.markers, options.spawnSync || spawnSync);
496
+ const expiryBreadcrumb = installed ? null : readPulseExpiryBreadcrumb(root);
497
+ const writeOutput = options.writeOutput || ((text) => process.stdout.write(text));
349
498
  const out = {
350
499
  ok: true,
351
500
  action: 'pulse_status',
352
501
  cron_installed: installed,
502
+ expiry_breadcrumb: expiryBreadcrumb,
503
+ state_home: slot.activeStateHome,
504
+ marker: slot.activeMarker,
505
+ install_state_home: slot.stateHome,
506
+ install_marker: slot.marker,
507
+ legacy_slot: slot.legacyMatches,
353
508
  ...summary,
354
509
  };
355
510
  if (!asJson) {
356
- process.stdout.write([
357
- `pulse: ${installed ? 'cron INSTALLED' : 'cron NOT installed (run: atris pulse install)'}`,
511
+ writeOutput([
512
+ `pulse: ${installed ? 'cron installed' : 'cron not installed (run: atris pulse install)'}`,
513
+ expiryBreadcrumb ? `heartbeat expired on ${expiryBreadcrumb.expired_at}; renew with: ${expiryBreadcrumb.renew_command}` : '',
514
+ `state home: ${slot.activeStateHome} marker: ${slot.activeMarker}`,
358
515
  `ticks: ${summary.total_ticks} | reward: ${summary.reward_sum} | verify pass/fail: ${summary.verify_pass}/${summary.verify_fail}`,
359
- `last tick: ${summary.last_tick_ts || 'never'} (verify ${summary.last_verify_passed === null ? 'n/a' : summary.last_verify_passed ? 'pass' : 'FAIL'})`,
360
- summary.stale.stale ? `⚠ STALE: ${summary.stale.reason}${summary.stale.tick_index ? ` (ghost tick #${summary.stale.tick_index})` : ''}` : 'liveness: fresh',
361
- summary.orphan_ticks.length ? `⚠ orphan (crashed) ticks: ${summary.orphan_ticks.join(', ')}` : '',
516
+ `last tick: ${summary.last_tick_ts || 'never'} (verify ${summary.last_verify_passed === null ? 'n/a' : summary.last_verify_passed ? 'pass' : 'fail'})`,
517
+ summary.stale.stale ? `stale: ${summary.stale.reason}${summary.stale.tick_index ? ` (ghost tick #${summary.stale.tick_index})` : ''}` : 'liveness: fresh',
518
+ summary.orphan_ticks.length ? `orphan (crashed) ticks: ${summary.orphan_ticks.join(', ')}` : '',
362
519
  ].filter(Boolean).join('\n') + '\n');
363
520
  }
364
521
  return emit(out, asJson);
@@ -366,9 +523,9 @@ function statusCommand(args, root = process.cwd()) {
366
523
 
367
524
  // --- atris pulse install / uninstall ---
368
525
 
369
- function resolveAtrisBin() {
526
+ function resolveAtrisBin(commandSpawn = spawnSync) {
370
527
  // Prefer a globally linked `atris`; fall back to this checkout's bin.
371
- const which = spawnSync('which', ['atris'], { encoding: 'utf8', timeout: 8000 });
528
+ const which = commandSpawn('which', ['atris'], { encoding: 'utf8', timeout: 8000 });
372
529
  if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
373
530
  return `${process.execPath} ${path.join(__dirname, '..', 'bin', 'atris.js')}`;
374
531
  }
@@ -376,17 +533,17 @@ function resolveAtrisBin() {
376
533
  // The dirs holding the binaries the engine spawns by bare name (claude, node,
377
534
  // git, atris). Baked into the cron script's PATH so a minimal cron environment
378
535
  // can still find them. Missing tools just contribute nothing.
379
- function resolveEngineBinDirs(extraBins = []) {
536
+ function resolveEngineBinDirs(extraBins = [], commandSpawn = spawnSync) {
380
537
  const dirs = new Set([path.dirname(process.execPath)]); // node
381
538
  for (const tool of ['ax', 'claude', 'git', 'atris']) {
382
- const r = spawnSync('which', [tool], { encoding: 'utf8', timeout: 8000 });
539
+ const r = commandSpawn('which', [tool], { encoding: 'utf8', timeout: 8000 });
383
540
  if (r.status === 0 && r.stdout.trim()) dirs.add(path.dirname(r.stdout.trim()));
384
541
  }
385
542
  for (const bin of [process.env.ATRIS_RUNNER_BIN, process.env.ATRIS_CLAUDE_BIN, ...extraBins]) {
386
543
  const configured = String(bin || '').trim();
387
544
  if (configured && configured.includes(path.sep)) dirs.add(path.dirname(configured));
388
545
  if (configured && !configured.includes(path.sep)) {
389
- const r = spawnSync('which', [configured], { encoding: 'utf8', timeout: 8000 });
546
+ const r = commandSpawn('which', [configured], { encoding: 'utf8', timeout: 8000 });
390
547
  if (r.status === 0 && r.stdout.trim()) dirs.add(path.dirname(r.stdout.trim()));
391
548
  }
392
549
  }
@@ -394,8 +551,19 @@ function resolveEngineBinDirs(extraBins = []) {
394
551
  return Array.from(dirs);
395
552
  }
396
553
 
397
- function installCommand(args, root = process.cwd()) {
554
+ function stripMarkedCrontabLines(crontab, markers) {
555
+ const markerList = Array.isArray(markers) ? markers : [markers];
556
+ return String(crontab || '')
557
+ .split('\n')
558
+ .filter((line) => line && !markerList.some((marker) => marker && line.includes(marker)))
559
+ .join('\n');
560
+ }
561
+
562
+ function installCommand(args, root = process.cwd(), options = {}) {
398
563
  const asJson = wantsJson(args);
564
+ const commandSpawn = options.spawnSync || spawnSync;
565
+ const writeOutput = options.writeOutput || ((text) => process.stdout.write(text));
566
+ const slot = pulse.resolvePulseSlot(root, options.homeDir ? { homeDir: options.homeDir } : {});
399
567
  const cadenceInput = readFlag(args, '--cadence', pulse.DEFAULT_CADENCE_CRON);
400
568
  let cron;
401
569
  try {
@@ -429,23 +597,24 @@ function installCommand(args, root = process.cwd()) {
429
597
  if (!asJson) process.stdout.write(`pulse install failed: ${out.detail}\n`);
430
598
  return emit(out, asJson);
431
599
  }
432
- const verifyCmd = readFlag(args, '--verify', 'npm test');
600
+ const verifyCmd = readFlag(args, '--verify', pulse.defaultVerifyCmd(root));
433
601
  const model = readFlag(args, '--model', process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || DEFAULT_CLAUDE_RUNNER_MODEL);
434
602
  const runnerProfile = readFlag(args, '--runner-profile', process.env.ATRIS_RUNNER_PROFILE || '');
435
603
  const runnerBin = readFlag(args, '--runner-bin', process.env.ATRIS_RUNNER_BIN || process.env.ATRIS_CLAUDE_BIN || '');
436
604
  const runnerCommandTemplate = readFlag(args, '--runner-template', process.env.ATRIS_RUNNER_COMMAND_TEMPLATE || process.env.ATRIS_CLAUDE_COMMAND_TEMPLATE || '');
437
605
  const deadlineEpoch = Math.floor(Date.now() / 1000) + expiry.seconds;
438
606
 
439
- fs.mkdirSync(STATE_HOME, { recursive: true });
440
- const scriptPath = path.join(STATE_HOME, 'tick.sh');
607
+ fs.mkdirSync(slot.stateHome, { recursive: true });
608
+ const scriptPath = path.join(slot.stateHome, 'tick.sh');
441
609
  // Resolve the real bin dirs the engine spawns by bare name, so cron's minimal
442
610
  // PATH doesn't silently break the worker spawn (claude lives in ~/.local/bin).
443
- const pathDirs = resolveEngineBinDirs([runnerBin]);
611
+ const pathDirs = resolveEngineBinDirs([runnerBin], commandSpawn);
444
612
  const script = pulse.buildTickScript({
445
613
  root,
446
- atrisBin: resolveAtrisBin(),
447
- stateHome: STATE_HOME,
614
+ atrisBin: resolveAtrisBin(commandSpawn),
615
+ stateHome: slot.stateHome,
448
616
  deadlineEpoch,
617
+ marker: slot.marker,
449
618
  model,
450
619
  runnerProfile,
451
620
  runnerBin,
@@ -455,18 +624,23 @@ function installCommand(args, root = process.cwd()) {
455
624
  });
456
625
  fs.writeFileSync(scriptPath, script, { mode: 0o755 });
457
626
 
458
- const line = pulse.buildCrontabLine({ cron, scriptPath });
459
- // Append our line to the existing crontab (idempotent: strip any prior marker first).
460
- const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
627
+ const line = pulse.buildCrontabLine({ cron, scriptPath, marker: slot.marker });
628
+ // Append our line to the existing crontab. Strip this repo's new marker and,
629
+ // when the legacy tick.sh belongs to this root, strip the old single-slot marker too.
630
+ const existing = commandSpawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
461
631
  const prior = existing.status === 0 ? String(existing.stdout || '') : '';
462
- const cleaned = prior.split('\n').filter((l) => l && !l.includes(pulse.PULSE_MARKER)).join('\n');
632
+ const cleaned = stripMarkedCrontabLines(prior, slot.markers);
463
633
  const next = `${cleaned ? cleaned + '\n' : ''}${line}\n`;
464
- const apply = spawnSync('crontab', ['-'], { input: next, encoding: 'utf8', timeout: 10000 });
634
+ const apply = commandSpawn('crontab', ['-'], { input: next, encoding: 'utf8', timeout: 10000 });
635
+ const expiryBreadcrumbCleared = apply.status === 0 && clearPulseExpiryBreadcrumb(root);
465
636
 
466
637
  const out = {
467
638
  ok: apply.status === 0,
468
639
  action: 'pulse_install',
469
640
  script_path: scriptPath,
641
+ state_home: slot.stateHome,
642
+ marker: slot.marker,
643
+ legacy_migrated: slot.legacyMatches,
470
644
  crontab_line: line,
471
645
  cadence: cron,
472
646
  cadence_input: cadenceInput,
@@ -477,34 +651,50 @@ function installCommand(args, root = process.cwd()) {
477
651
  runner_profile: runnerProfile || null,
478
652
  runner_bin: runnerBin || null,
479
653
  runner_template_configured: Boolean(runnerCommandTemplate),
654
+ expiry_breadcrumb_cleared: expiryBreadcrumbCleared,
480
655
  };
481
656
  if (!asJson) {
482
657
  if (apply.status === 0) {
483
- process.stdout.write([
658
+ writeOutput([
484
659
  `pulse installed. heartbeat fires '${cron}' against ${root}.`,
485
660
  `script: ${scriptPath}`,
486
661
  `auto-expires in ${expiry.hours ? `${expiry.hours} hours` : `${expiry.days} days`}. stop early: atris pulse uninstall`,
487
662
  ].join('\n') + '\n');
488
663
  } else {
489
- process.stdout.write(`pulse install failed to write crontab: ${apply.stderr || apply.status}\nscript written to ${scriptPath}; add this line to your crontab manually:\n${line}\n`);
664
+ writeOutput(`pulse install failed to write crontab: ${apply.stderr || apply.status}\nscript written to ${scriptPath}; add this line to your crontab manually:\n${line}\n`);
490
665
  }
491
666
  }
492
667
  return emit(out, asJson);
493
668
  }
494
669
 
495
- function uninstallCommand(args) {
670
+ function uninstallCommand(args, root = process.cwd()) {
496
671
  const asJson = wantsJson(args);
672
+ const slot = pulse.resolvePulseSlot(root);
497
673
  const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
498
674
  if (existing.status !== 0) {
499
- const out = { ok: true, action: 'pulse_uninstall', removed: false, reason: 'no_crontab' };
675
+ const out = {
676
+ ok: true,
677
+ action: 'pulse_uninstall',
678
+ removed: false,
679
+ reason: 'no_crontab',
680
+ state_home: slot.activeStateHome,
681
+ marker: slot.activeMarker,
682
+ };
500
683
  if (!asJson) process.stdout.write('pulse: no crontab to clean.\n');
501
684
  return emit(out, asJson);
502
685
  }
503
686
  const prior = String(existing.stdout || '');
504
- const had = prior.includes(pulse.PULSE_MARKER);
505
- const cleaned = prior.split('\n').filter((l) => l && !l.includes(pulse.PULSE_MARKER)).join('\n');
687
+ const had = slot.markers.some((marker) => prior.includes(marker));
688
+ const cleaned = stripMarkedCrontabLines(prior, slot.markers);
506
689
  const apply = spawnSync('crontab', ['-'], { input: cleaned ? cleaned + '\n' : '', encoding: 'utf8', timeout: 10000 });
507
- const out = { ok: apply.status === 0, action: 'pulse_uninstall', removed: had };
690
+ const out = {
691
+ ok: apply.status === 0,
692
+ action: 'pulse_uninstall',
693
+ removed: had,
694
+ state_home: slot.activeStateHome,
695
+ marker: slot.activeMarker,
696
+ legacy_slot: slot.legacyMatches,
697
+ };
508
698
  if (!asJson) process.stdout.write(had ? 'pulse uninstalled (crontab line removed).\n' : 'pulse: no heartbeat line found.\n');
509
699
  return emit(out, asJson);
510
700
  }
@@ -553,6 +743,10 @@ module.exports = {
553
743
  tickCommand,
554
744
  statusCommand,
555
745
  cronInstalled,
746
+ crontabHasActiveMarker,
747
+ pulseExpiryBreadcrumbPath,
748
+ readPulseExpiryBreadcrumb,
749
+ clearPulseExpiryBreadcrumb,
556
750
  installCommand,
557
751
  uninstallCommand,
558
752
  runCommand,
@@ -561,5 +755,6 @@ module.exports = {
561
755
  runEngine,
562
756
  gitChangedFiles,
563
757
  runVerify,
564
- STATE_HOME,
758
+ refreshOrbPolicyLessons,
759
+ stripMarkedCrontabLines,
565
760
  };