atris 3.16.0 → 3.17.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 (59) hide show
  1. package/README.md +33 -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 +446 -43
  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 +466 -20
  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 +574 -0
  23. package/commands/console.js +8 -3
  24. package/commands/deck.js +135 -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 +444 -0
  31. package/commands/pulse.js +504 -0
  32. package/commands/radar.js +1 -0
  33. package/commands/recap.js +233 -0
  34. package/commands/run.js +615 -22
  35. package/commands/skill.js +6 -2
  36. package/commands/slop.js +173 -0
  37. package/commands/spaceship.js +39 -0
  38. package/commands/sync.js +0 -2
  39. package/commands/task.js +458 -43
  40. package/commands/verify.js +7 -3
  41. package/lib/activity-stream.js +166 -0
  42. package/lib/auto-accept-certified.js +23 -1
  43. package/lib/context-gatherer.js +170 -0
  44. package/lib/escape-regexp.js +13 -0
  45. package/lib/file-ops.js +6 -3
  46. package/lib/journal.js +1 -1
  47. package/lib/lesson-contradiction.js +113 -0
  48. package/lib/policy-lessons.js +3 -2
  49. package/lib/pulse.js +401 -0
  50. package/lib/runner-command.js +156 -0
  51. package/lib/slides-deck.js +236 -0
  52. package/lib/state-detection.js +40 -3
  53. package/lib/task-db.js +101 -4
  54. package/lib/task-proof.js +1 -1
  55. package/lib/todo-fallback.js +2 -1
  56. package/lib/todo-sections.js +33 -0
  57. package/package.json +1 -2
  58. package/utils/api.js +14 -2
  59. 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,7 +398,53 @@ function askHuman(taskTitle) {
393
398
  }
394
399
 
395
400
  /**
396
- * Run a phase via claude -p subprocess.
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.
407
+ */
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) {
418
+ return Boolean(err && (err.killed || err.code === 'ETIMEDOUT' || err.signal));
419
+ }
420
+
421
+ /**
422
+ * execSync with the phase-timeout orphan fix. Node's sync-exec timeout signals
423
+ * only the direct child pid — the `/bin/sh -c` wrapper — so the `claude` it
424
+ * spawned kept committing 160–296s past the 600s wall (lesson:
425
+ * etimedout-error-shape, 2026-06-10). `detached: true` makes the wrapper a
426
+ * process-group leader; on timeout we sweep the whole group via
427
+ * `process.kill(-pid, 'SIGKILL')`. ESRCH on the sweep means the group already
428
+ * died — fine. The original error is rethrown untouched so every call site
429
+ * keeps its existing catch contract (err.stdout passthrough included).
430
+ */
431
+ function execPhaseCommandSync(cmd, opts = {}) {
432
+ try {
433
+ return execSync(cmd, { ...opts, detached: true });
434
+ } catch (err) {
435
+ if (isPhaseKillError(err) && err.pid) {
436
+ try {
437
+ process.kill(-err.pid, 'SIGKILL');
438
+ } catch (sweepErr) {
439
+ if (sweepErr.code !== 'ESRCH') throw sweepErr;
440
+ }
441
+ }
442
+ throw err;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Run a phase via the configured runner subprocess.
397
448
  */
398
449
  function executePhaseDetailed(phase, context, options = {}) {
399
450
  const { verbose = false, timeout = PHASE_TIMEOUT } = options;
@@ -403,10 +454,11 @@ function executePhaseDetailed(phase, context, options = {}) {
403
454
  fs.writeFileSync(tmpFile, prompt);
404
455
 
405
456
  try {
406
- const cmd = `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')" --allowedTools "Bash,Read,Write,Edit,Glob,Grep"`;
457
+ const cmd = options.cmdOverride
458
+ || buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Bash,Read,Write,Edit,Glob,Grep' });
407
459
  const env = { ...process.env };
408
460
  delete env.CLAUDECODE;
409
- const output = execSync(cmd, {
461
+ const output = execPhaseCommandSync(cmd, {
410
462
  cwd: process.cwd(),
411
463
  encoding: 'utf8',
412
464
  timeout,
@@ -419,7 +471,12 @@ function executePhaseDetailed(phase, context, options = {}) {
419
471
  return { prompt, output: output || '' };
420
472
  } catch (err) {
421
473
  try { fs.unlinkSync(tmpFile); } catch {}
422
- if (err.killed) throw new Error(`${phase} timed out after ${timeout / 1000}s`);
474
+ if (isPhaseTimeoutError(err)) {
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`);
479
+ }
423
480
  if (err.stdout) {
424
481
  return { prompt, output: err.stdout };
425
482
  }
@@ -452,6 +509,16 @@ function getContextFiles(phase, options = {}) {
452
509
  return [...new Set(files.filter(Boolean))].map((f) => `- ${f}`).join('\n');
453
510
  }
454
511
 
512
+ // T35a (endgame loop-self-repair): shared-checkout git-safety contract.
513
+ // Lesson 39: a concurrent tick's `git reset` destroyed a sibling repo's
514
+ // uncommitted work. Sibling-repo edits ride per-tick worktrees (the same
515
+ // ../repo siblings snapshotRepoHeads tracks); destructive git on the shared
516
+ // checkout is forbidden (COORDINATION.md Rule 4). Interpolated into the
517
+ // default and self-heal do prompts — never the benchmark prompt (it never
518
+ // commits).
519
+ const SHARED_CHECKOUT_GIT_CONTRACT = `- Shared-checkout git safety (COORDINATION.md Rule 4): edits to any repo OTHER than this tick's cwd (../atrisos-backend-style sibling repos) go through a per-tick worktree — start with \`atris worktree start --member <member> --task "<task>"\`, land with \`atris worktree ship --message "<msg>" --verify "<cmd>"\`. Never edit a sibling repo's shared checkout directly.
520
+ - On a shared checkout, \`git reset\`, \`git checkout --\`, \`git clean\`, and stashing other agents' work are FORBIDDEN — concurrent ticks' uncommitted work lives there.`;
521
+
455
522
  /**
456
523
  * Build the right prompt for each phase, adapting to the kind of work.
457
524
  */
@@ -648,6 +715,7 @@ Rules:
648
715
  - Execute ONE step at a time. Verify each step before moving on.
649
716
  - Check MAP.md for file locations before grepping.
650
717
  - Stay in scope. Only fix the bug described in the lesson — no side quests.
718
+ ${SHARED_CHECKOUT_GIT_CONTRACT}
651
719
 
652
720
  Read these files first:
653
721
  ${readFiles}
@@ -672,6 +740,7 @@ Rules:
672
740
  - Check MAP.md for file locations before grepping.
673
741
  - If you hit two errors on the same step, stop and flag for re-scope.
674
742
  - Stay in scope. Don't touch files outside the task boundary.
743
+ ${SHARED_CHECKOUT_GIT_CONTRACT}
675
744
 
676
745
  Read these files first:
677
746
  ${readFiles}
@@ -742,6 +811,27 @@ If broken beyond quick fix, reply: failed — [reason].`;
742
811
  return '';
743
812
  }
744
813
 
814
+ /**
815
+ * Build a clean kebab-case lesson slug from free text. Strips non-alphanumerics
816
+ * (em-dashes were leaking into slugs verbatim) and truncates at a word boundary
817
+ * instead of mid-word (e.g. the old `.slice(0, 40)` produced
818
+ * `verify-fail-per-member-model-selection-—-the-member-`).
819
+ */
820
+ function lessonSlug(text, maxLen = 40) {
821
+ const base = String(text || 'unknown')
822
+ .toLowerCase()
823
+ .replace(/[^a-z0-9]+/g, '-')
824
+ .replace(/^-+|-+$/g, '');
825
+ if (!base) return 'unknown';
826
+ if (base.length <= maxLen) return base;
827
+ const cut = base.slice(0, maxLen);
828
+ const lastDash = cut.lastIndexOf('-');
829
+ // base[maxLen] continues a word — back up to the last full word.
830
+ const atBoundary = base[maxLen] === '-';
831
+ const trimmed = atBoundary ? cut : (lastDash > 0 ? cut.slice(0, lastDash) : cut);
832
+ return trimmed.replace(/-+$/g, '') || 'unknown';
833
+ }
834
+
745
835
  /**
746
836
  * Write a lesson to atris/lessons.md
747
837
  * Appends a line in format: - **[YYYY-MM-DD] slug** — pass/fail — explanation
@@ -884,6 +974,38 @@ function shouldAdoptPlannedVerify(kind) {
884
974
  return ['staleness', 'docs', 'review', 'inbox', 'cleanup', 'feature', 'lessons', 'imagined'].includes(kind);
885
975
  }
886
976
 
977
+ // Task-plane status vocabulary lint. `atris task list/queue/current --status <s>`
978
+ // only matches raw stored statuses (commands/task.js); `ready` is a TRANSITION
979
+ // (`atris task ready` moves a task to review), so `--status ready` always
980
+ // returns "(no tasks)" — a verify built on it is an unreachable gate; the
981
+ // matching listable form is --status review (lessons.md
982
+ // verify-status-vocabulary, 3rd occurrence 2026-06-10).
983
+ const LISTABLE_TASK_STATUSES = ['open', 'claimed', 'review', 'done', 'failed'];
984
+ const STATUS_CORRECTIONS = { ready: 'review' };
985
+
986
+ function lintVerifyTaskStatusVocabulary(text) {
987
+ // Scan every `atris task list|queue|current` segment (compound verifies
988
+ // chain with && / || / ;), then pull its --status value if present.
989
+ const segmentRe = /\batris\s+task\s+(?:list|queue|current)\b([^|&;]*)/g;
990
+ let segment;
991
+ while ((segment = segmentRe.exec(text)) !== null) {
992
+ const statusMatch = /--status[=\s]+["']?([A-Za-z0-9_-]+)["']?/.exec(segment[1]);
993
+ if (!statusMatch) continue;
994
+ const status = statusMatch[1];
995
+ if (LISTABLE_TASK_STATUSES.includes(status)) continue;
996
+ const vocabulary = LISTABLE_TASK_STATUSES.join('|');
997
+ const corrected = STATUS_CORRECTIONS[status];
998
+ const suggestion = corrected
999
+ ? `use --status ${corrected} instead (atris task ${status} is a transition that lands tasks in ${corrected}, so --status ${status} never matches)`
1000
+ : `use one of --status ${vocabulary}`;
1001
+ return {
1002
+ ok: false,
1003
+ reason: `Verify uses unlistable task status "--status ${status}" — the listable vocabulary is ${vocabulary}; ${suggestion}`,
1004
+ };
1005
+ }
1006
+ return null;
1007
+ }
1008
+
887
1009
  function validateVerifyCommandShape(cmd) {
888
1010
  const text = String(cmd || '').trim();
889
1011
  if (!text) return { ok: true };
@@ -893,6 +1015,8 @@ function validateVerifyCommandShape(cmd) {
893
1015
  if (/\b(returns?|shows?|equals?|should|must)\b/i.test(text)) {
894
1016
  return { ok: false, reason: 'Verify contains prose expectations instead of shell operators/assertions' };
895
1017
  }
1018
+ const statusLint = lintVerifyTaskStatusVocabulary(text);
1019
+ if (statusLint) return statusLint;
896
1020
  return { ok: true };
897
1021
  }
898
1022
 
@@ -1073,17 +1197,17 @@ function parseProposedBlock(lines) {
1073
1197
  }
1074
1198
 
1075
1199
  /**
1076
- * Default executor for plan-review: spawn a fresh claude -p call.
1200
+ * Default executor for plan-review: spawn a fresh configured runner call.
1077
1201
  * Kept thin so tests can inject a stub via options.planReviewExec.
1078
1202
  */
1079
1203
  function defaultPlanReviewExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1080
1204
  const tmpFile = path.join(cwd, '.autopilot-plan-review.tmp');
1081
1205
  fs.writeFileSync(tmpFile, prompt);
1082
1206
  try {
1083
- const cmd = `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')" --allowedTools "Bash,Read,Grep,Glob"`;
1207
+ const cmd = buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Bash,Read,Grep,Glob' });
1084
1208
  const env = { ...process.env };
1085
1209
  delete env.CLAUDECODE;
1086
- const output = execSync(cmd, {
1210
+ const output = execPhaseCommandSync(cmd, {
1087
1211
  cwd,
1088
1212
  encoding: 'utf8',
1089
1213
  timeout,
@@ -1112,7 +1236,18 @@ function defaultCodexExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1112
1236
  timeout,
1113
1237
  stdio: 'pipe',
1114
1238
  maxBuffer: 10 * 1024 * 1024,
1239
+ detached: true,
1115
1240
  });
1241
+ // No sh wrapper here, but codex spawns its own children — sweep the group
1242
+ // on timeout so they cannot outlive the wall (same orphan class as the
1243
+ // claude sites; ESRCH means the tree is already dead).
1244
+ if (proc.pid && ((proc.error && proc.error.code === 'ETIMEDOUT') || proc.signal)) {
1245
+ try {
1246
+ process.kill(-proc.pid, 'SIGKILL');
1247
+ } catch (sweepErr) {
1248
+ if (sweepErr.code !== 'ESRCH') throw sweepErr;
1249
+ }
1250
+ }
1116
1251
  if (proc.status !== 0 && !proc.stdout) {
1117
1252
  throw new Error(`codex exited with status ${proc.status}: ${proc.stderr || 'no output'}`);
1118
1253
  }
@@ -1258,6 +1393,216 @@ function appendPlanRejection(cwd, context, review) {
1258
1393
  }
1259
1394
  }
1260
1395
 
1396
+ // ── Timeout reconciliation (T33, endgame loop-self-repair) ─────────────────
1397
+ // A do-phase wall-clock timeout kills the reporter, not the work: 12 of 13
1398
+ // ETIMEDOUT halts in the 2026-06-10 RSI audit had real commits landed with no
1399
+ // receipt, no checked bullet, and a human halt (lessons: executor-timeout-wall,
1400
+ // tick-must-mark-own-bullet). These helpers let the tick reconcile from
1401
+ // pre-tick HEADs instead of halting when work provably landed.
1402
+
1403
+ function todayJournalPath(cwd) {
1404
+ const now = new Date();
1405
+ const yyyy = now.getFullYear();
1406
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
1407
+ const dd = String(now.getDate()).padStart(2, '0');
1408
+ return {
1409
+ logFile: path.join(cwd, 'atris', 'logs', String(yyyy), `${yyyy}-${mm}-${dd}.md`),
1410
+ dateFormatted: `${yyyy}-${mm}-${dd}`,
1411
+ };
1412
+ }
1413
+
1414
+ /**
1415
+ * Normalize text for fuzzy task-title matching: lowercase, strip code spans,
1416
+ * tags, and markdown punctuation down to single-spaced words.
1417
+ */
1418
+ function normalizeForMatch(text) {
1419
+ return String(text || '')
1420
+ .toLowerCase()
1421
+ .replace(/`[^`]*`/g, ' ')
1422
+ .replace(/\[[\w-]+\]/g, ' ')
1423
+ .replace(/[^a-z0-9]+/g, ' ')
1424
+ .trim()
1425
+ .replace(/\s+/g, ' ');
1426
+ }
1427
+
1428
+ /**
1429
+ * A word-boundary-truncated normalized prefix of the task title, used to find
1430
+ * the task's TODO bullet and journal receipts without exact-string fragility.
1431
+ */
1432
+ function taskMatchNeedle(taskTitle, maxLen = 60) {
1433
+ const norm = normalizeForMatch(taskTitle);
1434
+ if (!norm) return '';
1435
+ if (norm.length <= maxLen) return norm;
1436
+ return norm.slice(0, maxLen).replace(/\s+\S*$/, '');
1437
+ }
1438
+
1439
+ function gitHeadAt(dir) {
1440
+ try {
1441
+ return execSync('git rev-parse HEAD', { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }).trim();
1442
+ } catch {
1443
+ return null;
1444
+ }
1445
+ }
1446
+
1447
+ /**
1448
+ * Snapshot HEAD of the workspace repo plus any sibling repos named in the
1449
+ * task text — both explicit `../atris-cli`-style refs (the journal convention)
1450
+ * and bare sibling-directory names like `atris-cli` that resolve to a git
1451
+ * repo next to cwd. Returns [{ label, dir, head }].
1452
+ */
1453
+ function snapshotRepoHeads(cwd, taskText = '') {
1454
+ const root = path.resolve(cwd);
1455
+ const repos = new Map([[root, '.']]);
1456
+ const text = String(taskText || '');
1457
+ for (const ref of text.match(/\.\.\/[A-Za-z0-9._-]+/g) || []) {
1458
+ const dir = path.resolve(cwd, ref);
1459
+ if (dir !== root && fs.existsSync(path.join(dir, '.git'))) repos.set(dir, ref);
1460
+ }
1461
+ for (const tok of text.match(/[A-Za-z][A-Za-z0-9._-]{2,}/g) || []) {
1462
+ const dir = path.resolve(cwd, '..', tok);
1463
+ if (dir !== root && !repos.has(dir) && fs.existsSync(path.join(dir, '.git'))) {
1464
+ repos.set(dir, `../${tok}`);
1465
+ }
1466
+ }
1467
+ return [...repos].map(([dir, label]) => ({ label, dir, head: gitHeadAt(dir) }));
1468
+ }
1469
+
1470
+ /**
1471
+ * Re-read HEADs for a prior snapshot; return the repos whose HEAD advanced
1472
+ * as [{ label, dir, before, after }].
1473
+ */
1474
+ function diffAdvancedRepoHeads(snapshot) {
1475
+ const advanced = [];
1476
+ for (const repo of snapshot || []) {
1477
+ if (!repo || !repo.head) continue;
1478
+ const after = gitHeadAt(repo.dir);
1479
+ if (after && after !== repo.head) {
1480
+ advanced.push({ label: repo.label, dir: repo.dir, before: repo.head, after });
1481
+ }
1482
+ }
1483
+ return advanced;
1484
+ }
1485
+
1486
+ /**
1487
+ * The T31-typed do-phase timeout message thrown by executePhaseDetailed.
1488
+ * Plan/review timeouts stay human halts — only the do phase commits work
1489
+ * worth reconciling.
1490
+ */
1491
+ function isDoPhaseTimeoutMessage(message) {
1492
+ return /\bdo phase timed out after\b/.test(String(message || ''));
1493
+ }
1494
+
1495
+ /**
1496
+ * Mark the task's TODO bullet `[x]`. Matches the first un-checked,
1497
+ * un-struck bullet whose normalized text contains the normalized title
1498
+ * prefix; `- **T33:** …` becomes `- [x] **T33:** …`, `- [ ]` becomes `- [x]`.
1499
+ * Returns true if a bullet was marked.
1500
+ */
1501
+ function markTodoBulletDone(cwd, taskTitle) {
1502
+ const needle = taskMatchNeedle(taskTitle);
1503
+ if (!needle) return false;
1504
+ for (const name of ['TODO.md', 'todo.md']) {
1505
+ const todoPath = path.join(cwd, 'atris', name);
1506
+ if (!fs.existsSync(todoPath)) continue;
1507
+ const lines = fs.readFileSync(todoPath, 'utf8').split('\n');
1508
+ for (let i = 0; i < lines.length; i++) {
1509
+ const bullet = lines[i].match(/^(\s*)- (?:\[( |x)\]\s+)?(.*)$/);
1510
+ if (!bullet) continue;
1511
+ if (bullet[2] === 'x') continue;
1512
+ if (bullet[3].startsWith('~~')) continue;
1513
+ if (!normalizeForMatch(lines[i]).includes(needle)) continue;
1514
+ lines[i] = `${bullet[1]}- [x] ${bullet[3]}`;
1515
+ fs.writeFileSync(todoPath, lines.join('\n'));
1516
+ return true;
1517
+ }
1518
+ return false;
1519
+ }
1520
+ return false;
1521
+ }
1522
+
1523
+ /**
1524
+ * Append a block under today's journal `## Notes`, creating the journal file
1525
+ * if the tick dies before any other writer got to it. Never throws.
1526
+ */
1527
+ function appendUnderNotes(cwd, block) {
1528
+ try {
1529
+ const { logFile, dateFormatted } = todayJournalPath(cwd);
1530
+ if (!fs.existsSync(logFile)) {
1531
+ const dir = path.dirname(logFile);
1532
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1533
+ createLogFile(logFile, dateFormatted);
1534
+ }
1535
+ let content = fs.readFileSync(logFile, 'utf8');
1536
+ const notesIdx = content.indexOf('## Notes');
1537
+ if (notesIdx === -1) {
1538
+ content = content.replace(/\s*$/, '') + `\n\n## Notes\n${block}\n`;
1539
+ } else {
1540
+ const eol = content.indexOf('\n', notesIdx);
1541
+ content = content.slice(0, eol + 1) + block + content.slice(eol + 1);
1542
+ }
1543
+ fs.writeFileSync(logFile, content);
1544
+ return true;
1545
+ } catch {
1546
+ return false;
1547
+ }
1548
+ }
1549
+
1550
+ function appendTimeoutReconciliation(cwd, { task, advanced }) {
1551
+ const now = new Date().toISOString().slice(0, 16).replace('T', ' ');
1552
+ const repoLines = (advanced || [])
1553
+ .map((r) => `- ${r.label}: ${String(r.before).slice(0, 7)} → ${String(r.after).slice(0, 7)}`)
1554
+ .join('\n');
1555
+ const block =
1556
+ `\n### Timeout reconciliation — ${now} — work-landed-receipt-died\n\n` +
1557
+ `**Task:** ${task}\n` +
1558
+ `**What happened:** the do-phase wall killed the reporter, but commits landed:\n` +
1559
+ `${repoLines}\n` +
1560
+ `Receipt auto-written and the TODO bullet marked; no human halt required.\n`;
1561
+ return appendUnderNotes(cwd, block);
1562
+ }
1563
+
1564
+ function appendCheckAndAdvance(cwd, task, receiptLine) {
1565
+ const now = new Date().toISOString().slice(0, 16).replace('T', ' ');
1566
+ const block =
1567
+ `\n### Check-and-advance — ${now} — advanced-already-done\n\n` +
1568
+ `**Task:** ${task}\n` +
1569
+ `**What happened:** verify passed before work started AND today's journal already carries a completion receipt — the work shipped on a prior tick whose reporter died before bookkeeping. Bullet marked, picker advanced.\n` +
1570
+ `**Receipt:** ${receiptLine}\n`;
1571
+ return appendUnderNotes(cwd, block);
1572
+ }
1573
+
1574
+ /**
1575
+ * Scan today's journal for a completion receipt naming the task: a `C#`
1576
+ * completed line, a timeout-reconciliation entry, or a `**Task:**` line.
1577
+ * Returns the matching line, or null.
1578
+ */
1579
+ function findCompletionReceipt(cwd, taskTitle) {
1580
+ const { logFile } = todayJournalPath(cwd);
1581
+ if (!fs.existsSync(logFile)) return null;
1582
+ const needle = taskMatchNeedle(taskTitle);
1583
+ if (!needle) return null;
1584
+ for (const line of fs.readFileSync(logFile, 'utf8').split('\n')) {
1585
+ const receiptShaped =
1586
+ /\*\*C\d+:\*\*/.test(line) || /\*\*Task:\*\*/.test(line) || /reconciliation/i.test(line);
1587
+ if (receiptShaped && normalizeForMatch(line).includes(needle)) return line.trim();
1588
+ }
1589
+ return null;
1590
+ }
1591
+
1592
+ /**
1593
+ * After a do-phase timeout: diff the pre-tick HEAD snapshot. If commits
1594
+ * landed, write the journal reconciliation receipt, mark the TODO bullet, and
1595
+ * report outcome `work-landed-receipt-died`. If nothing landed, the caller
1596
+ * halts exactly as before.
1597
+ */
1598
+ function reconcileTimedOutTick(cwd, snapshot, taskTitle) {
1599
+ const advanced = diffAdvancedRepoHeads(snapshot);
1600
+ if (advanced.length === 0) return { reconciled: false, advanced: [] };
1601
+ appendTimeoutReconciliation(cwd, { task: taskTitle, advanced });
1602
+ const bulletMarked = markTodoBulletDone(cwd, taskTitle);
1603
+ return { reconciled: true, outcome: 'work-landed-receipt-died', advanced, bulletMarked };
1604
+ }
1605
+
1261
1606
  function runTaskOnce(context, options = {}) {
1262
1607
  const { verbose = false, cwd = process.cwd() } = options;
1263
1608
 
@@ -1318,6 +1663,25 @@ function runTaskOnce(context, options = {}) {
1318
1663
  if (!skipFalsifiability && verifyResult.explicit && context.kind === 'endgame' && verifyCmd) {
1319
1664
  try {
1320
1665
  execSync(verifyCmd, { cwd, stdio: 'pipe', timeout: 300000 });
1666
+ // T33b (lesson: tick-must-mark-own-bullet): a pre-work verify pass WITH
1667
+ // a completion receipt already in today's journal means the work shipped
1668
+ // but the reporter died before bookkeeping. Check the bullet and advance
1669
+ // instead of wedging the picker on verify-not-falsifiable.
1670
+ const receipt = findCompletionReceipt(cwd, context.task);
1671
+ if (receipt) {
1672
+ const bulletMarked = markTodoBulletDone(cwd, context.task);
1673
+ appendCheckAndAdvance(cwd, context.task, receipt);
1674
+ return {
1675
+ outcome: 'advanced-already-done',
1676
+ reason: 'advanced-already-done',
1677
+ receipt,
1678
+ bulletMarked,
1679
+ phaseResults: {},
1680
+ elapsedSeconds: 0,
1681
+ verifyRan: true,
1682
+ verifyPass: true,
1683
+ };
1684
+ }
1321
1685
  writeLesson(cwd, 'verify-not-falsifiable', 'fail',
1322
1686
  `Verify \`${verifyCmd}\` passed before work started on "${context.task}". Either the rubric is trivial or the task is already done. Tick halted.`);
1323
1687
  return {
@@ -1436,7 +1800,7 @@ function runTaskOnce(context, options = {}) {
1436
1800
  elapsedSeconds: verifyTime,
1437
1801
  };
1438
1802
  try {
1439
- const slug = (context.task || 'unknown').replace(/\s+/g, '-').toLowerCase().slice(0, 40);
1803
+ const slug = lessonSlug(context.task);
1440
1804
  writeLesson(cwd, `verify-fail-${slug}`, 'fail', `Verify command \`${verifyCmd}\` failed: ${e.message.split('\n')[0]}`);
1441
1805
  } catch { /* lesson write must not crash the tick */ }
1442
1806
  }
@@ -2482,7 +2846,7 @@ function getLessonVerdict(lessonLine) {
2482
2846
  /**
2483
2847
  * Propose 3 candidate next horizons for the autopilot loop. Combines
2484
2848
  * `getIdleTickCount` + `getRecentSignals` into a prompt asking the LLM
2485
- * 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
2486
2850
  * JSON response into `[{ title, confidence, rationale }]`.
2487
2851
  *
2488
2852
  * Filters out candidates derived from resolved lessons (bug pattern no
@@ -2537,10 +2901,10 @@ Reply with the JSON array and nothing else.`;
2537
2901
 
2538
2902
  let output = '';
2539
2903
  try {
2540
- const cmd = `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')"`;
2904
+ const cmd = buildRunnerCommand({ promptFile: tmpFile });
2541
2905
  const env = { ...process.env };
2542
2906
  delete env.CLAUDECODE;
2543
- output = execSync(cmd, {
2907
+ output = execPhaseCommandSync(cmd, {
2544
2908
  cwd,
2545
2909
  encoding: 'utf8',
2546
2910
  timeout: PHASE_TIMEOUT,
@@ -2548,6 +2912,14 @@ Reply with the JSON array and nothing else.`;
2548
2912
  maxBuffer: 10 * 1024 * 1024,
2549
2913
  env
2550
2914
  }).toString();
2915
+ } catch (err) {
2916
+ if (isPhaseTimeoutError(err)) {
2917
+ throw new Error(`horizon-proposal phase timed out after ${PHASE_TIMEOUT / 1000}s`);
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
+ }
2922
+ throw err;
2551
2923
  } finally {
2552
2924
  try { fs.unlinkSync(tmpFile); } catch {}
2553
2925
  }
@@ -2555,7 +2927,7 @@ Reply with the JSON array and nothing else.`;
2555
2927
  const start = output.indexOf('[');
2556
2928
  const end = output.lastIndexOf(']');
2557
2929
  if (start === -1 || end === -1 || end <= start) {
2558
- throw new Error('proposeCandidateHorizons: claude -p returned no JSON array');
2930
+ throw new Error('proposeCandidateHorizons: configured runner returned no JSON array');
2559
2931
  }
2560
2932
  const jsonText = output.slice(start, end + 1);
2561
2933
 
@@ -2650,8 +3022,8 @@ async function autopilotAtris(description, options = {}) {
2650
3022
  process.exit(1);
2651
3023
  }
2652
3024
 
2653
- try { execSync('which claude', { stdio: 'pipe' }); } catch {
2654
- 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.`);
2655
3027
  process.exit(1);
2656
3028
  }
2657
3029
 
@@ -2835,6 +3207,17 @@ async function autopilotAtris(description, options = {}) {
2835
3207
  };
2836
3208
  const startingEndgame = readEndgameState(cwd);
2837
3209
 
3210
+ // T33a: snapshot pre-tick HEADs (cwd + sibling repos named in the task)
3211
+ // so a do-phase timeout can be reconciled against what actually landed.
3212
+ let preTickHeads = null;
3213
+ try {
3214
+ const verifyHint = getVerifyCommand(cwd, suggestion.task).cmd || '';
3215
+ preTickHeads = snapshotRepoHeads(
3216
+ cwd,
3217
+ [suggestion.task, ...(suggestion.files || []), verifyHint].join(' ')
3218
+ );
3219
+ } catch { /* snapshot failure must not block the tick */ }
3220
+
2838
3221
  try {
2839
3222
  if (verbose) {
2840
3223
  console.log('');
@@ -2868,6 +3251,26 @@ async function autopilotAtris(description, options = {}) {
2868
3251
  break;
2869
3252
  }
2870
3253
 
3254
+ // T33b: the falsifiability gate found a completion receipt — the work
3255
+ // already shipped, the bullet is checked, move straight to the next pick.
3256
+ if (execution.outcome === 'advanced-already-done') {
3257
+ completed++;
3258
+ tickOutcome = 'built';
3259
+ tickOutcomeText = `"${lastTaskTitle}" was already done — verify passed pre-work and today's journal carries its completion receipt, so I checked the bullet and advanced.`;
3260
+ tickNextStep = 'pick the next endgame task';
3261
+ if (verbose) {
3262
+ console.log(' already done (journal receipt found). bullet checked, advancing.');
3263
+ } else {
3264
+ printPlainBlock([
3265
+ 'That task was already done — verify passed before work and a completion receipt exists in today\'s journal.',
3266
+ 'I checked the bullet and advanced.',
3267
+ '',
3268
+ 'Next I will look for the next task.'
3269
+ ].join('\n'));
3270
+ }
3271
+ continue;
3272
+ }
3273
+
2871
3274
  const planTime = execution.phaseResults.plan.elapsedSeconds;
2872
3275
  if (verbose) console.log(` planned (${planTime}s)`);
2873
3276
 
@@ -2929,7 +3332,7 @@ async function autopilotAtris(description, options = {}) {
2929
3332
  // Record commit hash + verify command for retroactive regression checks
2930
3333
  try {
2931
3334
  const commitHash = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8' }).trim();
2932
- const taskSlug = (suggestion.task || 'unknown').replace(/\s+/g, '-').toLowerCase().slice(0, 40);
3335
+ const taskSlug = lessonSlug(suggestion.task);
2933
3336
  recordTickCommit(cwd, commitHash, execution.verifyCmd || '', taskSlug);
2934
3337
 
2935
3338
  // Every 10th tick, run retroactive regression check
@@ -2976,6 +3379,36 @@ async function autopilotAtris(description, options = {}) {
2976
3379
  }
2977
3380
 
2978
3381
  } catch (err) {
3382
+ // T33a: a do-phase timeout with commits landed is a dead reporter, not
3383
+ // dead work — write the reconciliation receipt, mark the bullet, and
3384
+ // record work-landed-receipt-died instead of halting for a human.
3385
+ let reconciliation = null;
3386
+ if (isDoPhaseTimeoutMessage(err.message)) {
3387
+ try {
3388
+ reconciliation = reconcileTimedOutTick(cwd, preTickHeads, lastTaskTitle || suggestion.task);
3389
+ } catch { reconciliation = null; }
3390
+ }
3391
+ if (reconciliation && reconciliation.reconciled) {
3392
+ completed++;
3393
+ const landed = reconciliation.advanced
3394
+ .map((r) => `${r.label} ${String(r.before).slice(0, 7)} → ${String(r.after).slice(0, 7)}`)
3395
+ .join(', ');
3396
+ tickOutcome = 'work-landed-receipt-died';
3397
+ tickOutcomeText = `"${lastTaskTitle}" hit the do-phase wall but commits landed (${landed}). I wrote the reconciliation receipt and marked the bullet — work-landed-receipt-died, no human halt.`;
3398
+ tickNextStep = 'pick the next task';
3399
+ if (verbose) {
3400
+ console.log(` do phase timed out, but work landed (${landed}). reconciled — no human halt.`);
3401
+ } else {
3402
+ printPlainBlock([
3403
+ 'The do phase timed out, but commits landed before the wall.',
3404
+ `Landed: ${landed}.`,
3405
+ 'I wrote the reconciliation receipt and marked the task bullet.',
3406
+ '',
3407
+ 'Next tick will pick the next task.'
3408
+ ].join('\n'));
3409
+ }
3410
+ break;
3411
+ }
2979
3412
  tickOutcome = 'halted';
2980
3413
  tickOutcomeText = `I hit an error while running "${lastTaskTitle || 'a task'}": ${err.message}`;
2981
3414
  tickNextStep = 'stop until a human looks at the error';
@@ -3135,7 +3568,7 @@ function isStillTrue(fact, cwd) {
3135
3568
  /**
3136
3569
  * Ask a local model whether a task/fact is still relevant.
3137
3570
  * Called when isStillTrue returns 'unverified' — the mechanical check
3138
- * 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.
3139
3572
  *
3140
3573
  * @param {{ title: string, age: number, source?: string }} fact
3141
3574
  * @param {string} cwd - workspace root
@@ -3158,8 +3591,8 @@ Search the codebase to verify. Reply: YES <reason> or NO <reason>`;
3158
3591
  try {
3159
3592
  const env = { ...process.env };
3160
3593
  delete env.CLAUDECODE;
3161
- const cmd = `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')" --allowedTools "Bash,Read,Glob,Grep"`;
3162
- const output = execSync(cmd, {
3594
+ const cmd = buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Bash,Read,Glob,Grep' });
3595
+ const output = execPhaseCommandSync(cmd, {
3163
3596
  cwd,
3164
3597
  encoding: 'utf8',
3165
3598
  timeout: 60000,
@@ -3192,6 +3625,13 @@ async function autopilotFromTodo(options = {}) {
3192
3625
 
3193
3626
  module.exports = {
3194
3627
  appendTickSummary,
3628
+ snapshotRepoHeads,
3629
+ diffAdvancedRepoHeads,
3630
+ reconcileTimedOutTick,
3631
+ markTodoBulletDone,
3632
+ findCompletionReceipt,
3633
+ isDoPhaseTimeoutMessage,
3634
+ validateVerifyCommandShape,
3195
3635
  askHuman,
3196
3636
  askModel,
3197
3637
  autopilotAtris,
@@ -3218,6 +3658,7 @@ module.exports = {
3218
3658
  scanAnomalies,
3219
3659
  verifyJudgeIntegrity,
3220
3660
  maybeWriteCompletedEndgameScorecard,
3661
+ readEndgameState,
3221
3662
  renderHumanSuggestion,
3222
3663
  renderHumanTickIntro,
3223
3664
  proposeCandidateHorizons,
@@ -3233,5 +3674,10 @@ module.exports = {
3233
3674
  scoreEndgameCandidates,
3234
3675
  suggestNextTask,
3235
3676
  shouldSkipAutoHumanGate,
3236
- writeLesson
3677
+ writeLesson,
3678
+ isPhaseTimeoutError,
3679
+ isPhaseKillError,
3680
+ execPhaseCommandSync,
3681
+ executePhaseDetailed,
3682
+ lessonSlug
3237
3683
  };