atris 3.16.1 → 3.22.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 (65) hide show
  1. package/README.md +32 -7
  2. package/atris/skills/atris/SKILL.md +15 -2
  3. package/atris/skills/atris-feedback/SKILL.md +7 -0
  4. package/atris/skills/design/SKILL.md +29 -2
  5. package/atris/skills/engines/SKILL.md +44 -0
  6. package/atris/skills/flow/SKILL.md +1 -1
  7. package/atris/skills/wake/SKILL.md +37 -0
  8. package/atris/skills/youtube/SKILL.md +13 -39
  9. package/atris/team/validator/MEMBER.md +1 -0
  10. package/atris/wiki/concepts/agent-activation-contract.md +3 -3
  11. package/atris/wiki/concepts/workspace-initialization-contract.md +3 -3
  12. package/atris/wiki/index.md +1 -0
  13. package/atris.md +43 -19
  14. package/bin/atris.js +413 -31
  15. package/commands/agent-spawn.js +480 -0
  16. package/commands/analytics.js +6 -3
  17. package/commands/apps.js +11 -0
  18. package/commands/autopilot.js +42 -18
  19. package/commands/brain.js +74 -7
  20. package/commands/brainstorm.js +9 -58
  21. package/commands/clean.js +1 -4
  22. package/commands/compile.js +9 -4
  23. package/commands/console.js +8 -3
  24. package/commands/deck.js +184 -0
  25. package/commands/init.js +22 -11
  26. package/commands/lesson.js +76 -0
  27. package/commands/member.js +252 -48
  28. package/commands/mission.js +405 -13
  29. package/commands/now.js +4 -2
  30. package/commands/probe.js +105 -27
  31. package/commands/pulse.js +504 -0
  32. package/commands/radar.js +1 -0
  33. package/commands/recap.js +71 -25
  34. package/commands/run.js +615 -22
  35. package/commands/site.js +48 -0
  36. package/commands/slop.js +307 -0
  37. package/commands/spaceship.js +39 -0
  38. package/commands/sync.js +0 -2
  39. package/commands/task.js +429 -37
  40. package/commands/theme.js +217 -0
  41. package/commands/verify.js +7 -3
  42. package/lib/activity-stream.js +166 -0
  43. package/lib/auto-accept-certified.js +23 -1
  44. package/lib/context-gatherer.js +170 -0
  45. package/lib/deck-from-md.js +110 -0
  46. package/lib/escape-regexp.js +13 -0
  47. package/lib/file-ops.js +6 -3
  48. package/lib/html-render.js +257 -0
  49. package/lib/journal.js +1 -1
  50. package/lib/lesson-contradiction.js +113 -0
  51. package/lib/memory-view.js +95 -0
  52. package/lib/policy-lessons.js +3 -2
  53. package/lib/pulse.js +401 -0
  54. package/lib/runner-command.js +156 -0
  55. package/lib/site.js +114 -0
  56. package/lib/slides-deck.js +237 -0
  57. package/lib/state-detection.js +1 -4
  58. package/lib/task-db.js +101 -4
  59. package/lib/task-proof.js +1 -1
  60. package/lib/theme.js +264 -0
  61. package/lib/todo-fallback.js +2 -1
  62. package/lib/todo-sections.js +33 -0
  63. package/package.json +1 -2
  64. package/utils/api.js +14 -2
  65. package/atris/atrisDev.md +0 -717
@@ -12,6 +12,11 @@ const { execSync, execFileSync, spawnSync } = require('child_process');
12
12
  const readline = require('readline');
13
13
  const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/journal');
14
14
  const { parseTodo } = require('../lib/todo');
15
+ const {
16
+ buildRunnerCommand,
17
+ buildRunnerAvailabilityCommand,
18
+ resolveClaudeRunnerBin,
19
+ } = require('../lib/runner-command');
15
20
  const { findStalePages, findStaleTasks, healBrokenMapRefs } = require('./clean');
16
21
  const {
17
22
  buildScorecardData,
@@ -194,7 +199,7 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false } = {})
194
199
  const { logFile } = getLogPath();
195
200
  if (fs.existsSync(logFile)) {
196
201
  const content = fs.readFileSync(logFile, 'utf8');
197
- const inboxMatch = content.match(/## Inbox\n([\s\S]*?)(?=\n##|$)/);
202
+ const inboxMatch = content.match(/## Inbox\r?\n([\s\S]*?)(?=\r?\n##|$)/);
198
203
  if (inboxMatch && inboxMatch[1].trim()) {
199
204
  const items = inboxMatch[1].trim().split('\n').filter(l => {
200
205
  const t = l.trim();
@@ -393,12 +398,23 @@ function askHuman(taskTitle) {
393
398
  }
394
399
 
395
400
  /**
396
- * Type-check a child_process error as a timeout/kill. Node's execSync attaches
397
- * `code: 'ETIMEDOUT'` and `signal` on timeout — it does NOT set `killed`, so a
398
- * `killed`-only guard is dead code on the exact error it was written for
399
- * (lesson: etimedout-error-shape, 2026-06-10).
401
+ * Type-check a child_process error as a real wall-clock timeout. Node's
402
+ * execSync attaches `code: 'ETIMEDOUT'` (plus `signal`) on timeout — it does
403
+ * NOT set `killed`, so a `killed`-only guard is dead code on the exact error
404
+ * it was written for (lesson: etimedout-error-shape, 2026-06-10). A bare
405
+ * `signal` without ETIMEDOUT is NOT a timeout: it's an OOM SIGKILL or an
406
+ * external SIGTERM, and calling it a timeout misdiagnoses the cause.
400
407
  */
401
408
  function isPhaseTimeoutError(err) {
409
+ return Boolean(err && err.code === 'ETIMEDOUT');
410
+ }
411
+
412
+ /**
413
+ * Any abnormal child death — timeout or signal kill. The group sweep in
414
+ * execPhaseCommandSync uses this wider net (orphans need sweeping either
415
+ * way); the thrown message uses the narrow predicate to name the cause.
416
+ */
417
+ function isPhaseKillError(err) {
402
418
  return Boolean(err && (err.killed || err.code === 'ETIMEDOUT' || err.signal));
403
419
  }
404
420
 
@@ -416,7 +432,7 @@ function execPhaseCommandSync(cmd, opts = {}) {
416
432
  try {
417
433
  return execSync(cmd, { ...opts, detached: true });
418
434
  } catch (err) {
419
- if (isPhaseTimeoutError(err) && err.pid) {
435
+ if (isPhaseKillError(err) && err.pid) {
420
436
  try {
421
437
  process.kill(-err.pid, 'SIGKILL');
422
438
  } catch (sweepErr) {
@@ -428,7 +444,7 @@ function execPhaseCommandSync(cmd, opts = {}) {
428
444
  }
429
445
 
430
446
  /**
431
- * Run a phase via claude -p subprocess.
447
+ * Run a phase via the configured runner subprocess.
432
448
  */
433
449
  function executePhaseDetailed(phase, context, options = {}) {
434
450
  const { verbose = false, timeout = PHASE_TIMEOUT } = options;
@@ -439,7 +455,7 @@ function executePhaseDetailed(phase, context, options = {}) {
439
455
 
440
456
  try {
441
457
  const cmd = options.cmdOverride
442
- || `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')" --allowedTools "Bash,Read,Write,Edit,Glob,Grep"`;
458
+ || buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Bash,Read,Write,Edit,Glob,Grep' });
443
459
  const env = { ...process.env };
444
460
  delete env.CLAUDECODE;
445
461
  const output = execPhaseCommandSync(cmd, {
@@ -456,7 +472,10 @@ function executePhaseDetailed(phase, context, options = {}) {
456
472
  } catch (err) {
457
473
  try { fs.unlinkSync(tmpFile); } catch {}
458
474
  if (isPhaseTimeoutError(err)) {
459
- throw new Error(`${phase} phase timed out after ${timeout / 1000}s (claude -p hit the wall; any work it committed survives — reconcile from pre-tick HEADs)`);
475
+ throw new Error(`${phase} phase timed out after ${timeout / 1000}s (configured runner hit the wall; any work it committed survives — reconcile from pre-tick HEADs)`);
476
+ }
477
+ if (isPhaseKillError(err)) {
478
+ throw new Error(`${phase} phase killed by ${err.signal || 'a signal'} before the ${timeout / 1000}s wall — not a timeout; check memory pressure or an external supervisor`);
460
479
  }
461
480
  if (err.stdout) {
462
481
  return { prompt, output: err.stdout };
@@ -1178,14 +1197,14 @@ function parseProposedBlock(lines) {
1178
1197
  }
1179
1198
 
1180
1199
  /**
1181
- * Default executor for plan-review: spawn a fresh claude -p call.
1200
+ * Default executor for plan-review: spawn a fresh configured runner call.
1182
1201
  * Kept thin so tests can inject a stub via options.planReviewExec.
1183
1202
  */
1184
1203
  function defaultPlanReviewExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1185
1204
  const tmpFile = path.join(cwd, '.autopilot-plan-review.tmp');
1186
1205
  fs.writeFileSync(tmpFile, prompt);
1187
1206
  try {
1188
- const cmd = `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')" --allowedTools "Bash,Read,Grep,Glob"`;
1207
+ const cmd = buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Bash,Read,Grep,Glob' });
1189
1208
  const env = { ...process.env };
1190
1209
  delete env.CLAUDECODE;
1191
1210
  const output = execPhaseCommandSync(cmd, {
@@ -2827,7 +2846,7 @@ function getLessonVerdict(lessonLine) {
2827
2846
  /**
2828
2847
  * Propose 3 candidate next horizons for the autopilot loop. Combines
2829
2848
  * `getIdleTickCount` + `getRecentSignals` into a prompt asking the LLM
2830
- * to imagine what to work on next, spawns `claude -p`, and parses the
2849
+ * to imagine what to work on next, uses the shared runner command, and parses the
2831
2850
  * JSON response into `[{ title, confidence, rationale }]`.
2832
2851
  *
2833
2852
  * Filters out candidates derived from resolved lessons (bug pattern no
@@ -2882,7 +2901,7 @@ Reply with the JSON array and nothing else.`;
2882
2901
 
2883
2902
  let output = '';
2884
2903
  try {
2885
- const cmd = `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')"`;
2904
+ const cmd = buildRunnerCommand({ promptFile: tmpFile });
2886
2905
  const env = { ...process.env };
2887
2906
  delete env.CLAUDECODE;
2888
2907
  output = execPhaseCommandSync(cmd, {
@@ -2897,6 +2916,9 @@ Reply with the JSON array and nothing else.`;
2897
2916
  if (isPhaseTimeoutError(err)) {
2898
2917
  throw new Error(`horizon-proposal phase timed out after ${PHASE_TIMEOUT / 1000}s`);
2899
2918
  }
2919
+ if (isPhaseKillError(err)) {
2920
+ throw new Error(`horizon-proposal phase killed by ${err.signal || 'a signal'} before the ${PHASE_TIMEOUT / 1000}s wall — not a timeout`);
2921
+ }
2900
2922
  throw err;
2901
2923
  } finally {
2902
2924
  try { fs.unlinkSync(tmpFile); } catch {}
@@ -2905,7 +2927,7 @@ Reply with the JSON array and nothing else.`;
2905
2927
  const start = output.indexOf('[');
2906
2928
  const end = output.lastIndexOf(']');
2907
2929
  if (start === -1 || end === -1 || end <= start) {
2908
- throw new Error('proposeCandidateHorizons: claude -p returned no JSON array');
2930
+ throw new Error('proposeCandidateHorizons: configured runner returned no JSON array');
2909
2931
  }
2910
2932
  const jsonText = output.slice(start, end + 1);
2911
2933
 
@@ -3000,8 +3022,8 @@ async function autopilotAtris(description, options = {}) {
3000
3022
  process.exit(1);
3001
3023
  }
3002
3024
 
3003
- try { execSync('which claude', { stdio: 'pipe' }); } catch {
3004
- console.error('claude CLI not found. Install Claude Code first.');
3025
+ try { execSync(buildRunnerAvailabilityCommand(), { stdio: 'pipe' }); } catch {
3026
+ console.error(`${resolveClaudeRunnerBin()} CLI not found. Set ATRIS_RUNNER_BIN (or legacy ATRIS_CLAUDE_BIN), or install the configured runner first.`);
3005
3027
  process.exit(1);
3006
3028
  }
3007
3029
 
@@ -3546,7 +3568,7 @@ function isStillTrue(fact, cwd) {
3546
3568
  /**
3547
3569
  * Ask a local model whether a task/fact is still relevant.
3548
3570
  * Called when isStillTrue returns 'unverified' — the mechanical check
3549
- * couldn't confirm or deny, so we ask claude -p to inspect the codebase.
3571
+ * couldn't confirm or deny, so we ask the configured runner to inspect the codebase.
3550
3572
  *
3551
3573
  * @param {{ title: string, age: number, source?: string }} fact
3552
3574
  * @param {string} cwd - workspace root
@@ -3569,7 +3591,7 @@ Search the codebase to verify. Reply: YES <reason> or NO <reason>`;
3569
3591
  try {
3570
3592
  const env = { ...process.env };
3571
3593
  delete env.CLAUDECODE;
3572
- const cmd = `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')" --allowedTools "Bash,Read,Glob,Grep"`;
3594
+ const cmd = buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Bash,Read,Glob,Grep' });
3573
3595
  const output = execPhaseCommandSync(cmd, {
3574
3596
  cwd,
3575
3597
  encoding: 'utf8',
@@ -3636,6 +3658,7 @@ module.exports = {
3636
3658
  scanAnomalies,
3637
3659
  verifyJudgeIntegrity,
3638
3660
  maybeWriteCompletedEndgameScorecard,
3661
+ readEndgameState,
3639
3662
  renderHumanSuggestion,
3640
3663
  renderHumanTickIntro,
3641
3664
  proposeCandidateHorizons,
@@ -3653,6 +3676,7 @@ module.exports = {
3653
3676
  shouldSkipAutoHumanGate,
3654
3677
  writeLesson,
3655
3678
  isPhaseTimeoutError,
3679
+ isPhaseKillError,
3656
3680
  execPhaseCommandSync,
3657
3681
  executePhaseDetailed,
3658
3682
  lessonSlug
package/commands/brain.js CHANGED
@@ -3,6 +3,8 @@ const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const { spawnSync } = require('child_process');
5
5
  const { refreshNowFile } = require('./now');
6
+ const escapeRegExp = require('../lib/escape-regexp');
7
+ const { hasRenderedSections, isOpenSection, isDoneSection } = require('../lib/todo-sections');
6
8
 
7
9
  const GENERATED_START = '<!-- ATRIS_BRAIN_COMPILE:START -->';
8
10
  const GENERATED_END = '<!-- ATRIS_BRAIN_COMPILE:END -->';
@@ -296,7 +298,8 @@ function resolveStateRoot(root) {
296
298
 
297
299
  function countTodoItems(todoText) {
298
300
  const text = String(todoText || '');
299
- const hasRenderedSections = /^##\s+(Backlog|In Progress|Blocked|Completed)\s*$/m.test(text);
301
+ // Section classification (incl. emoji-decorated headings) lives in lib/todo-sections.
302
+ const rendered = hasRenderedSections(text);
300
303
  let section = null;
301
304
  let unchecked = 0;
302
305
  let checked = 0;
@@ -317,22 +320,46 @@ function countTodoItems(todoText) {
317
320
  const isTitled = /^\s*-\s+(?:\[[ xX]\]\s+)?\*\*[^*]+:?\*\*/.test(line);
318
321
  if (isUnchecked) unchecked += 1;
319
322
  if (isChecked) checked += 1;
320
- if (!hasRenderedSections && (isUnchecked || (isTitled && !isChecked))) legacyOpen += 1;
323
+ if (!rendered && (isUnchecked || (isTitled && !isChecked))) legacyOpen += 1;
321
324
  if (!isTitled) continue;
322
325
 
323
326
  titled += 1;
324
- if (hasRenderedSections && ['Backlog', 'In Progress'].includes(section)) renderedOpen += 1;
325
- if (hasRenderedSections && section === 'Completed') renderedDone += 1;
327
+ if (rendered && isOpenSection(section)) renderedOpen += 1;
328
+ if (rendered && isDoneSection(section)) renderedDone += 1;
326
329
  }
327
330
 
328
331
  return {
329
- open: hasRenderedSections ? renderedOpen : legacyOpen,
332
+ open: rendered ? renderedOpen : legacyOpen,
330
333
  checked,
331
334
  titled,
332
- done: hasRenderedSections ? renderedDone : checked + (text.match(/~~|DONE|✅/g) || []).length,
335
+ done: rendered ? renderedDone : checked + (text.match(/~~|DONE|✅/g) || []).length,
333
336
  };
334
337
  }
335
338
 
339
+ function parseTodoEndgame(todoText) {
340
+ const text = String(todoText || '');
341
+ const fields = {};
342
+ let inEndgame = false;
343
+
344
+ for (const line of text.split(/\r?\n/)) {
345
+ const heading = line.match(/^##\s+(.+?)\s*$/);
346
+ if (heading) {
347
+ inEndgame = heading[1].trim().toLowerCase() === 'endgame';
348
+ continue;
349
+ }
350
+ if (!inEndgame) continue;
351
+
352
+ const field = line.match(/^\*\*(Slug|Horizon|Source):\*\*\s*(.*?)\s*$/i);
353
+ if (field) fields[field[1].toLowerCase()] = field[2].trim();
354
+ }
355
+
356
+ const slug = fields.slug || null;
357
+ const horizon = fields.horizon || null;
358
+ const source = fields.source || null;
359
+ if (!slug && !horizon && !source) return null;
360
+ return { slug, horizon, source };
361
+ }
362
+
336
363
  const EXECUTABLE_TASK_STATUSES = new Set(['open', 'claimed']);
337
364
  const COMPLETED_TASK_STATUSES = new Set(['done', 'completed', 'accepted']);
338
365
 
@@ -350,15 +377,33 @@ function isCertifiedReviewTask(task) {
350
377
  return Boolean(metadata.agent_certified || review.agent_certified || passCount >= 2);
351
378
  }
352
379
 
380
+ function isAgentNeededReviewTask(task) {
381
+ if (String(task?.status || '').toLowerCase() !== 'review') return false;
382
+ if (isCertifiedReviewTask(task)) return false;
383
+ const metadata = task.metadata || {};
384
+ const review = task.review || {};
385
+ const approvalStatus = String(metadata.approval_status || review.approval_status || 'pending').toLowerCase();
386
+ if (approvalStatus && approvalStatus !== 'pending') return false;
387
+ const passCount = Number(metadata.agent_review_pass_count || review.agent_review_pass_count || 0);
388
+ return passCount < 2;
389
+ }
390
+
353
391
  function summarizeTaskProjection(root) {
354
392
  const tasks = readTaskProjectionTasks(root);
355
393
  if (!tasks) return null;
356
394
 
357
395
  const counts = {};
358
396
  const certifiedReviewTasks = [];
397
+ const agentNeededReviewTasks = [];
359
398
  for (const task of tasks) {
360
399
  const status = String(task?.status || '').toLowerCase();
361
400
  counts[status] = (counts[status] || 0) + 1;
401
+ if (isAgentNeededReviewTask(task)) {
402
+ agentNeededReviewTasks.push({
403
+ ref: task.display_id || task.legacy_ref || task.id,
404
+ title: task.title || 'Untitled task',
405
+ });
406
+ }
362
407
  if (isCertifiedReviewTask(task)) {
363
408
  certifiedReviewTasks.push({
364
409
  ref: task.display_id || task.legacy_ref || task.id,
@@ -370,6 +415,7 @@ function summarizeTaskProjection(root) {
370
415
  return {
371
416
  tasks,
372
417
  counts,
418
+ agentNeededReviewTasks,
373
419
  certifiedReviewTasks,
374
420
  };
375
421
  }
@@ -466,6 +512,7 @@ function collectState(root) {
466
512
  slug: business.slug || path.basename(root),
467
513
  business,
468
514
  todo: countWorkItems(root, todoText),
515
+ endgame: parseTodoEndgame(todoText),
469
516
  taskProjection: summarizeTaskProjection(root),
470
517
  hasNow: nowText.length > 0,
471
518
  nowHeading: firstHeading(nowText, null),
@@ -628,7 +675,7 @@ function parseContributionCard(text, member) {
628
675
  if (!text || !member) return null;
629
676
  const firstName = String(member.name || member.slug || '').split(/\s+/)[0].toLowerCase();
630
677
  const sections = String(text).split(/\n(?=##\s+)/);
631
- const memberSections = sections.filter(section => new RegExp(`^##\\s+${firstName}\\b`, 'i').test(section.trim()));
678
+ const memberSections = sections.filter(section => new RegExp(`^##\\s+${escapeRegExp(firstName)}\\b`, 'i').test(section.trim()));
632
679
  const section = (
633
680
  memberSections.find(candidate => /current_score_signal\s*:/i.test(candidate))
634
681
  || memberSections[0]
@@ -708,12 +755,25 @@ function memberNextMove(member, state = null) {
708
755
  const name = member.name || member.slug;
709
756
  const context = `${member.startHere}\n${member.goals}`;
710
757
  const identity = `${member.slug}\n${member.name}`;
758
+ const agentNeededReview = state?.taskProjection?.agentNeededReviewTasks?.[0] || null;
759
+ const agentNeededReviewMove = agentNeededReview
760
+ ? `${name}: run the agent-safe review lane for ${agentNeededReview.ref}: ` +
761
+ `\`atris task review-chat ${agentNeededReview.ref} --as codex-review\`, then run the verifier named in the review packet and certify or revise without accepting XP.`
762
+ : null;
711
763
  const certifiedReview = state?.taskProjection?.certifiedReviewTasks?.[0] || null;
764
+ const certifiedReviewRefs = (state?.taskProjection?.certifiedReviewTasks || [])
765
+ .map(task => task.ref)
766
+ .filter(Boolean);
712
767
  const certifiedReviewMove = certifiedReview
713
768
  ? `${name}: hand off certified review ${certifiedReview.ref} to the operator: run ` +
714
769
  `\`atris task accept ${certifiedReview.ref}\` if approved or ` +
715
770
  `\`atris task revise ${certifiedReview.ref} --note "<what must change>"\` if not; do not create new work until this checkpoint is clear.`
716
771
  : null;
772
+ const codexEndgameMove = certifiedReviewRefs.length > 0 && member.slug === 'codex-executor'
773
+ ? `${name}: certified reviews ${certifiedReviewRefs.slice(0, 3).join(', ')} are human-only; do not accept XP. ` +
774
+ `Create the next bounded Codex task from Endgame ${state?.endgame?.slug || 'current-horizon'}: ` +
775
+ `${state?.endgame?.horizon || 'the highest-leverage system gap'}; include files, verifier, and stop rule before editing.`
776
+ : null;
717
777
  if (member.slug === 'justin' || /justin/i.test(member.name || '')) {
718
778
  return `${name}: run one customer-moving GTM rep, update the relevant workspace state within 10 minutes, and leave a scorecard.`;
719
779
  }
@@ -728,6 +788,7 @@ function memberNextMove(member, state = null) {
728
788
  return `${name}: choose or create one bounded mission step, run its verifier, and close it with proof, a scorecard, and the next move.`;
729
789
  }
730
790
  if (/validator|reviewer/i.test(identity)) {
791
+ if (agentNeededReviewMove) return agentNeededReviewMove;
731
792
  if (certifiedReviewMove) return certifiedReviewMove;
732
793
  if ((state?.todo?.open || 0) === 0 && (state?.todo?.done || 0) === 0) {
733
794
  return `${name}: wait for one concrete artifact or ask Navigator to create a reviewable task with verifier, proof target, and residual-risk checklist.`;
@@ -736,6 +797,8 @@ function memberNextMove(member, state = null) {
736
797
  }
737
798
  if (/executor|builder/i.test(identity)) {
738
799
  if ((state?.todo?.open || 0) === 0) {
800
+ if (agentNeededReviewMove) return agentNeededReviewMove;
801
+ if (codexEndgameMove) return codexEndgameMove;
739
802
  if (certifiedReviewMove) return certifiedReviewMove;
740
803
  return `${name}: ask Navigator to create one bounded task with files, verifier, and stop rule before making a patch.`;
741
804
  }
@@ -745,6 +808,7 @@ function memberNextMove(member, state = null) {
745
808
  return `${name}: turn one messy or unclaimed intent into a MAP-backed plan with ASCII visualization, exact files, verifier, rollback, and a review-ready task.`;
746
809
  }
747
810
  if (/launcher|closer/i.test(identity)) {
811
+ if (agentNeededReviewMove) return agentNeededReviewMove;
748
812
  if (certifiedReviewMove) return certifiedReviewMove;
749
813
  if ((state?.todo?.done || 0) === 0) {
750
814
  return `${name}: wait for one validated task receipt before closeout, or ask Validator to produce a review decision with proof.`;
@@ -1571,4 +1635,7 @@ module.exports = {
1571
1635
  verifyActivationCard,
1572
1636
  verifyActivationGallery,
1573
1637
  verifyBrain,
1638
+ parseContributionCard,
1639
+ escapeRegExp,
1640
+ countTodoItems,
1574
1641
  };
@@ -2,6 +2,15 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const readline = require('readline');
4
4
  const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/journal');
5
+ // Inbox helpers live canonically (and CRLF-tolerant) in lib/file-ops; brainstorm
6
+ // used to carry byte-identical local copies that silently missed CRLF journals.
7
+ const {
8
+ parseInboxItems,
9
+ replaceInboxSection,
10
+ addInboxItemToContent,
11
+ getNextInboxId,
12
+ addInboxIdea,
13
+ } = require('../lib/file-ops');
5
14
  const { loadConfig } = require('../utils/config');
6
15
  const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
7
16
  const { apiRequestJson } = require('../utils/api');
@@ -351,69 +360,11 @@ function brainstormAbortError() {
351
360
  return error;
352
361
  }
353
362
 
354
- function addInboxIdea(logFile, summary) {
355
- const content = fs.readFileSync(logFile, 'utf8');
356
- const nextId = getNextInboxId(content);
357
- const updated = addInboxItemToContent(content, nextId, summary);
358
- fs.writeFileSync(logFile, updated);
359
- return nextId;
360
- }
361
-
362
- function parseInboxItems(content) {
363
- const match = content.match(/## Inbox\n([\s\S]*?)(?=\n##|\n---|$)/);
364
- if (!match) {
365
- return [];
366
- }
367
- const body = match[1];
368
- const lines = body.split('\n');
369
- const items = [];
370
- lines.forEach((line) => {
371
- const trimmed = line.trim();
372
- if (!trimmed) return;
373
- if (trimmed.startsWith('(Empty')) return;
374
- const parsed = trimmed.match(/^- \*\*I(\d+):\*\*\s*(.+)$|^- \*\*I(\d+):\s+(.+)$/);
375
- if (parsed) {
376
- const id = parseInt(parsed[1] || parsed[3], 10);
377
- const text = parsed[2] || parsed[4];
378
- items.push({ id, text, line: trimmed });
379
- }
380
- });
381
- return items;
382
- }
383
-
384
- function replaceInboxSection(content, items) {
385
- const regex = /(## Inbox\n)([\s\S]*?)(\n---|\n##|$)/;
386
- if (!regex.test(content)) {
387
- const lines = items.length ? items.map((item) => item.line).join('\n') : '(Empty - inbox zero achieved)';
388
- return `${content}\n\n## Inbox\n\n${lines}\n`;
389
- }
390
-
391
- return content.replace(regex, (match, header, body, suffix) => {
392
- const inner = items.length
393
- ? `\n${items.map((item) => item.line).join('\n')}\n`
394
- : '\n(Empty - inbox zero achieved)\n';
395
- return `${header}${inner}${suffix}`;
396
- });
397
- }
398
-
399
- function addInboxItemToContent(content, id, summary) {
400
- const items = parseInboxItems(content).filter((item) => item.id !== id);
401
- const newItem = { id, text: summary, line: `- **I${id}:** ${summary}` };
402
- const updatedItems = [newItem, ...items];
403
- return replaceInboxSection(content, updatedItems);
404
- }
405
-
406
363
  function removeInboxItemFromContent(content, id) {
407
364
  const items = parseInboxItems(content).filter((item) => item.id !== id);
408
365
  return replaceInboxSection(content, items);
409
366
  }
410
367
 
411
- function getNextInboxId(content) {
412
- const items = parseInboxItems(content);
413
- if (items.length === 0) return 1;
414
- return items.reduce((max, item) => (item.id > max ? item.id : max), 0) + 1;
415
- }
416
-
417
368
  function insertIntoNotesSection(content, block) {
418
369
  const regex = /(## Notes\n)([\s\S]*?)(\n---|\n##|$)/;
419
370
  const match = content.match(regex);
package/commands/clean.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const escapeRegExp = require('../lib/escape-regexp');
3
4
 
4
5
  /**
5
6
  * atris clean - Workspace housekeeping with auto-heal
@@ -396,10 +397,6 @@ function findSymbolLine(fileContent, symbol) {
396
397
  return null;
397
398
  }
398
399
 
399
- function escapeRegExp(string) {
400
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
401
- }
402
-
403
400
  /**
404
401
  * Find wiki pages whose sources have been modified after last_compiled.
405
402
  * Scans all .md files under atris/ for frontmatter with last_compiled + sources.
@@ -6,7 +6,7 @@
6
6
  * the promoted artifact runs token-free.
7
7
  *
8
8
  * atris compile record <name> --input <json|@file> --output <json|@file>
9
- * atris compile build <name> (uses claude -p, like atris run)
9
+ * atris compile build <name> (uses the shared runner command)
10
10
  * atris compile backtest <name>
11
11
  * atris compile promote <name>
12
12
  * atris compile exec <name> --input <json|@file> [--record]
@@ -18,6 +18,11 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const { execSync } = require('child_process');
21
+ const {
22
+ buildRunnerAvailabilityCommand,
23
+ buildRunnerCommand,
24
+ resolveClaudeRunnerBin,
25
+ } = require('../lib/runner-command');
21
26
 
22
27
  const DEFAULT_THRESHOLD = 0.99;
23
28
  const SAMPLE_RECORDS_FOR_BUILD = 25;
@@ -275,9 +280,9 @@ function executeBuild(root, name, options = {}) {
275
280
 
276
281
  if (!cmdOverride) {
277
282
  try {
278
- execSync('which claude', { stdio: 'pipe' });
283
+ execSync(buildRunnerAvailabilityCommand(), { stdio: 'pipe' });
279
284
  } catch {
280
- throw new Error('claude CLI not found. Install Claude Code first.');
285
+ throw new Error(`${resolveClaudeRunnerBin()} CLI not found. Set ATRIS_RUNNER_BIN (or legacy ATRIS_CLAUDE_BIN), or install the configured runner first.`);
281
286
  }
282
287
  }
283
288
 
@@ -287,7 +292,7 @@ function executeBuild(root, name, options = {}) {
287
292
  fs.writeFileSync(tmpFile, prompt);
288
293
 
289
294
  try {
290
- const cmd = cmdOverride || `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')" --allowedTools "Read,Write,Edit,Glob,Grep"`;
295
+ const cmd = cmdOverride || buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Read,Write,Edit,Glob,Grep' });
291
296
  const env = { ...process.env };
292
297
  delete env.CLAUDECODE;
293
298
  execSync(cmd, {
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const os = require('os');
4
4
  const { spawn, spawnSync } = require('child_process');
5
5
  const readline = require('readline');
6
+ const { resolveClaudeRunnerBin } = require('../lib/runner-command');
6
7
 
7
8
  // ── Context Gathering ──────────────────────────────────────────────
8
9
 
@@ -206,7 +207,10 @@ function renderSkillsBar(ctx) {
206
207
  // ── Backend Detection & Auth ───────────────────────────────────────
207
208
 
208
209
  function detectBackend(requested) {
209
- const hasClaude = spawnSync('which', ['claude'], { stdio: 'pipe' }).status === 0;
210
+ const claudeBin = resolveClaudeRunnerBin();
211
+ const hasClaude = claudeBin.includes(path.sep)
212
+ ? fs.existsSync(claudeBin)
213
+ : spawnSync('which', [claudeBin], { stdio: 'pipe' }).status === 0;
210
214
  const hasCodex = spawnSync('which', ['codex'], { stdio: 'pipe' }).status === 0;
211
215
 
212
216
  if (requested) {
@@ -277,20 +281,21 @@ function checkAuth(backend) {
277
281
  // ── Launch ──────────────────────────────────────────────────────────
278
282
 
279
283
  function launchClaude(systemPrompt, extraArgs) {
284
+ const runnerBin = resolveClaudeRunnerBin();
280
285
  const args = [
281
286
  '--dangerously-skip-permissions',
282
287
  '--append-system-prompt', systemPrompt,
283
288
  ...extraArgs,
284
289
  ];
285
290
 
286
- const child = spawnSync('claude', args, {
291
+ const child = spawnSync(runnerBin, args, {
287
292
  cwd: process.cwd(),
288
293
  stdio: 'inherit',
289
294
  env: { ...process.env, CLAUDECODE: undefined },
290
295
  });
291
296
 
292
297
  if (child.error) {
293
- console.error(`✗ Failed to start claude: ${child.error.message}`);
298
+ console.error(`✗ Failed to start ${runnerBin}: ${child.error.message}`);
294
299
  process.exit(1);
295
300
  }
296
301
  process.exit(child.status ?? 0);