atris 3.5.0 → 3.12.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.
package/bin/atris.js CHANGED
@@ -1263,13 +1263,17 @@ async function agentAtris() {
1263
1263
  process.exit(1);
1264
1264
  }
1265
1265
 
1266
- // Check if logged in
1267
- const credentials = loadCredentials();
1268
-
1269
- if (!credentials || !credentials.token) {
1266
+ // Check if logged in (with token refresh)
1267
+ const ensured = await ensureValidCredentials();
1268
+ if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
1270
1269
  console.error('✗ Error: Not logged in. Run "atris login" first.');
1271
1270
  process.exit(1);
1272
1271
  }
1272
+ if (ensured.error) {
1273
+ console.error(`✗ Error: Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
1274
+ process.exit(1);
1275
+ }
1276
+ const credentials = ensured.credentials;
1273
1277
 
1274
1278
  console.log('🔍 Fetching your agents...\n');
1275
1279
 
@@ -1356,12 +1360,17 @@ async function chatAtris() {
1356
1360
  process.exit(1);
1357
1361
  }
1358
1362
 
1359
- // Check credentials
1360
- const credentials = loadCredentials();
1361
- if (!credentials || !credentials.token) {
1363
+ // Check credentials (with token refresh)
1364
+ const ensured = await ensureValidCredentials();
1365
+ if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
1362
1366
  console.error('✗ Error: Not logged in. Run "atris login" first.');
1363
1367
  process.exit(1);
1364
1368
  }
1369
+ if (ensured.error) {
1370
+ console.error(`✗ Error: Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
1371
+ process.exit(1);
1372
+ }
1373
+ const credentials = ensured.credentials;
1365
1374
 
1366
1375
  // If message provided, one-shot mode
1367
1376
  if (message) {
package/commands/align.js CHANGED
@@ -28,6 +28,7 @@ const { loadBusinesses, saveBusinesses } = require('./business');
28
28
  const SKIP_DIRS = new Set([
29
29
  'node_modules', '__pycache__', '.git', 'venv', '.venv',
30
30
  'lost+found', '.cache', '.atris', '.claude', 'default',
31
+ 'Library', 'Applications', 'System',
31
32
  ]);
32
33
 
33
34
  const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db']);
@@ -73,7 +73,8 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false } = {})
73
73
  task: `Re-read sources and update ${pageName}`,
74
74
  why: `"${sp.staleSource}" changed on ${sp.sourceDate} but the page was last compiled ${sp.compiledDate}. The content may be wrong.`,
75
75
  kind: 'staleness',
76
- priority: 2
76
+ priority: 2,
77
+ skipKey: key
77
78
  });
78
79
  break;
79
80
  }
@@ -87,7 +88,8 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false } = {})
87
88
  task: `Finish or remove stale task: ${st.title}`,
88
89
  why: `Claimed ${st.daysSinceClaim} days ago and never completed. Either finish it or delete it — stale tasks add noise.`,
89
90
  kind: 'cleanup',
90
- priority: 3
91
+ priority: 3,
92
+ skipKey: key
91
93
  });
92
94
  }
93
95
 
@@ -103,6 +105,23 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false } = {})
103
105
  });
104
106
  }
105
107
 
108
+ // --- Self-healing: unresolved fail lessons (bug still present per grep) ---
109
+ if (!skipped.has('self-heal')) {
110
+ const failLesson = pickUnresolvedFailLesson(cwd);
111
+ if (failLesson && !skipped.has(`self-heal:${failLesson.slug}`)) {
112
+ suggestions.push({
113
+ task: `Fix unresolved fail lesson: ${failLesson.slug}`,
114
+ why: `Lesson from ${failLesson.date} tagged \`fail\` and grep confirms the bug pattern is still present in-repo. Self-heal before taking new work.`,
115
+ kind: 'self-heal',
116
+ priority: 4.5,
117
+ lessonLine: failLesson.line,
118
+ lessonSlug: failLesson.slug,
119
+ lessonDate: failLesson.date,
120
+ skipKey: `self-heal:${failLesson.slug}`
121
+ });
122
+ }
123
+ }
124
+
106
125
  // --- Backlog tasks ---
107
126
  for (const t of todo.backlog) {
108
127
  if (t.tags && t.tags.includes('unverified')) continue;
@@ -117,6 +136,14 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false } = {})
117
136
  break;
118
137
  }
119
138
 
139
+ // --- Proactive "surprise me" anomalies (didn't-ask-but-noticed signals) ---
140
+ try {
141
+ for (const anomaly of scanAnomalies(cwd)) {
142
+ if (anomaly.skipKey && skipped.has(anomaly.skipKey)) continue;
143
+ suggestions.push(anomaly);
144
+ }
145
+ } catch { /* anomaly scanner must never crash the tick */ }
146
+
120
147
  // --- Unprocessed inbox items ---
121
148
  const { logFile } = getLogPath();
122
149
  if (fs.existsSync(logFile)) {
@@ -492,6 +519,27 @@ Read today's journal completions and the git log from the past few days.
492
519
  Extract patterns worth remembering — things that surprised you, approaches that worked,
493
520
  mistakes that were caught. Append to atris/lessons.md. One line per lesson. Be specific.
494
521
 
522
+ When done, reply: done.`;
523
+ }
524
+
525
+ if (kind === 'self-heal') {
526
+ const { lessonLine = '', lessonSlug = '' } = context;
527
+ const lessonBlock = lessonLine ? `\nUnresolved fail lesson:\n${lessonLine}\n` : '';
528
+ return `${baseRules}${lessonBlock}
529
+ Self-heal task: ${task}
530
+
531
+ This is an unresolved \`fail\` lesson from atris/lessons.md. grep confirms the bug pattern
532
+ is still present in-repo — the fix has NOT been shipped yet.
533
+
534
+ Plan the smallest fix:
535
+ 1. Parse the lesson for file:line references and the described bug pattern.
536
+ 2. Read those files to confirm the bug is exactly as described (or has drifted).
537
+ 3. Write ONE task in atris/TODO.md with:
538
+ - **Exit:** the specific behavior that proves the fix
539
+ - **Verify:** a command that fails now and will pass after the fix${lessonSlug ? ` (include "${lessonSlug}" in the task title so the lesson auto-resolves)` : ''}
540
+ - **Rollback:** how to revert if the fix misses
541
+ 4. Do NOT fix it in this phase — planner only. The executor will do the work.
542
+
495
543
  When done, reply: done.`;
496
544
  }
497
545
 
@@ -527,6 +575,32 @@ Task: ${task}
527
575
  4. Update MAP.md only if file locations truly shifted because of your change.
528
576
  5. If updating wiki pages, set last_compiled in frontmatter to today's date.
529
577
 
578
+ When done, reply: done.`;
579
+ }
580
+
581
+ if (kind === 'self-heal') {
582
+ const { lessonLine = '', lessonSlug = '' } = context;
583
+ const lessonBlock = lessonLine ? `\nUnresolved fail lesson:\n${lessonLine}\n` : '';
584
+ return `You are the executor. Read your MEMBER.md spec first if available.
585
+
586
+ Rules:
587
+ - You CAN read and write code. You CANNOT plan or create new tasks.
588
+ - Execute ONE step at a time. Verify each step before moving on.
589
+ - Check MAP.md for file locations before grepping.
590
+ - Stay in scope. Only fix the bug described in the lesson — no side quests.
591
+
592
+ Read these files first:
593
+ ${readFiles}
594
+ ${lessonBlock}
595
+ Self-heal task: ${task}
596
+
597
+ 1. Find the self-heal task in TODO.md and claim it (Claimed by: Executor at ${new Date().toISOString()}).
598
+ 2. Parse the lesson above for file:line references. Open those files and locate the bug pattern.
599
+ 3. Make the smallest change that removes the bug pattern AND makes the lesson's Verify command pass.
600
+ 4. Run the Verify command yourself to confirm it passes.
601
+ 5. Update MAP.md only if file:line locations shifted because of your fix.
602
+ 6. Commit: git add <specific-files> && git commit -m "fix: ${lessonSlug || 'self-heal'}"
603
+
530
604
  When done, reply: done.`;
531
605
  }
532
606
 
@@ -708,14 +782,46 @@ function regressionCheck(cwd) {
708
782
  */
709
783
  function getVerifyCommand(cwd, taskTitle) {
710
784
  const todoPath = path.join(cwd, 'atris', 'TODO.md');
711
- if (!fs.existsSync(todoPath)) return { cmd: null, explicit: false };
712
-
713
- const todo = parseTodo(todoPath);
714
- const task = [...todo.inProgress, ...todo.backlog, ...todo.completed]
715
- .find(t => t.title === taskTitle);
785
+ if (fs.existsSync(todoPath)) {
786
+ const todo = parseTodo(todoPath);
787
+ const task = [...todo.inProgress, ...todo.backlog, ...todo.completed]
788
+ .find(t => t.title === taskTitle);
789
+ if (task && task.verify) return { cmd: task.verify, explicit: true };
790
+ }
791
+ // Fallback: detect repo shape and pick a sensible default.
792
+ // Reactive tasks (inbox/staleness/imagined) don't carry explicit verify fields,
793
+ // so without shape detection they get `npm test` even on Python/Rust/Go repos.
794
+ return { cmd: detectDefaultVerify(cwd), explicit: false };
795
+ }
716
796
 
717
- if (!task || !task.verify) return { cmd: null, explicit: false };
718
- return { cmd: task.verify, explicit: true };
797
+ /**
798
+ * Infer a default verify command from the repo shape. Order matters:
799
+ * package.json with a non-stub test script → `npm test`; then pytest/python;
800
+ * then rust/go; otherwise null (no default — skip verify).
801
+ */
802
+ function detectDefaultVerify(cwd) {
803
+ const pkg = path.join(cwd, 'package.json');
804
+ if (fs.existsSync(pkg)) {
805
+ try {
806
+ const parsed = JSON.parse(fs.readFileSync(pkg, 'utf8'));
807
+ const test = parsed.scripts && parsed.scripts.test;
808
+ if (test && test !== 'echo "Error: no test specified" && exit 1') {
809
+ return 'npm test';
810
+ }
811
+ } catch { /* fall through */ }
812
+ }
813
+ if (fs.existsSync(path.join(cwd, 'pytest.ini')) ||
814
+ fs.existsSync(path.join(cwd, 'pyproject.toml')) ||
815
+ fs.existsSync(path.join(cwd, 'setup.py'))) {
816
+ return 'pytest';
817
+ }
818
+ if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) {
819
+ return 'cargo test';
820
+ }
821
+ if (fs.existsSync(path.join(cwd, 'go.mod'))) {
822
+ return 'go test ./...';
823
+ }
824
+ return null;
719
825
  }
720
826
 
721
827
  /**
@@ -1759,17 +1865,295 @@ function scoreEndgameCandidates(cwd, candidates) {
1759
1865
  }
1760
1866
  }
1761
1867
 
1868
+ /**
1869
+ * Proactive "surprise me" scanner — surfaces things the user didn't ask about.
1870
+ * Returns an array of suggestion objects in the same shape as the reactive
1871
+ * signals in suggestNextTask. Three orthogonal checks, none requiring
1872
+ * cross-session state:
1873
+ * - orphan-todo: `// TODO` or `// FIXME` in source with no matching backlog item
1874
+ * - unverified-detector: typed lesson has a detector but no last_detected stamp
1875
+ * - hotspot: file with >5 git commits in last 24h (churn signal)
1876
+ *
1877
+ * Each suggestion includes a `skipKey` so dry-run / skip doesn't re-fire it.
1878
+ */
1879
+ function scanAnomalies(cwd) {
1880
+ const results = [];
1881
+ const atrisDir = path.join(cwd, 'atris');
1882
+
1883
+ // --- orphan-todo: code TODOs not tracked in TODO.md backlog ---
1884
+ try {
1885
+ const codeTodos = findCodeTodos(cwd);
1886
+ if (codeTodos.length > 0) {
1887
+ const todoFile = path.join(atrisDir, 'TODO.md');
1888
+ const backlogText = fs.existsSync(todoFile) ? fs.readFileSync(todoFile, 'utf8') : '';
1889
+ const untracked = codeTodos.filter(t => !isTodoTracked(t.text, backlogText));
1890
+ if (untracked.length > 0) {
1891
+ const first = untracked[0];
1892
+ const sample = untracked.slice(0, 3).map(t => `${t.file}:${t.line}`).join(', ');
1893
+ const firstText = first.text.slice(0, 60);
1894
+ results.push({
1895
+ task: `Track the ${untracked.length} orphan TODO${untracked.length > 1 ? 's' : ''} in source — first: "${firstText}"`,
1896
+ why: `Code has ${untracked.length} \`// TODO\`/\`// FIXME\` comment${untracked.length > 1 ? 's' : ''} never written to TODO.md. First: "${firstText}" (${sample}). Either convert to real tasks or delete if obsolete.`,
1897
+ kind: 'orphan-todo',
1898
+ priority: 6,
1899
+ skipKey: 'orphan-todo'
1900
+ });
1901
+ }
1902
+ }
1903
+ } catch { /* best-effort scan */ }
1904
+
1905
+ // --- unverified-detector: lesson has detector but last_detected missing/stale ---
1906
+ try {
1907
+ const meta = loadLessonMetadata(cwd);
1908
+ const unverified = [];
1909
+ for (const [slug, entry] of Object.entries(meta)) {
1910
+ if (slug === '_schema') continue;
1911
+ if (!entry || typeof entry !== 'object') continue;
1912
+ if (!entry.detector) continue;
1913
+ if (!entry.last_detected) unverified.push(slug);
1914
+ }
1915
+ if (unverified.length > 0) {
1916
+ results.push({
1917
+ task: `Run the ${unverified.length} unverified detector${unverified.length > 1 ? 's' : ''} in atris/lessons.json`,
1918
+ why: `These lessons claim they're resolved via a detector but the detector has never been run: ${unverified.slice(0, 3).join(', ')}${unverified.length > 3 ? ', …' : ''}. Until it runs and exits 0, the resolved claim is unverified.`,
1919
+ kind: 'unverified-detector',
1920
+ priority: 5.5,
1921
+ skipKey: 'unverified-detector'
1922
+ });
1923
+ }
1924
+ } catch { /* best-effort */ }
1925
+
1926
+ // --- hotspot: file with high churn in last 24h ---
1927
+ try {
1928
+ const hotspot = findHotspot(cwd);
1929
+ if (hotspot) {
1930
+ results.push({
1931
+ task: `Pause and review ${hotspot.file} — ${hotspot.commits} commits in the last 24h`,
1932
+ why: `That file has churned more than any other file today. Could be genuine progress or a sign the change isn't sticking. Worth reading the diff before continuing.`,
1933
+ kind: 'hotspot',
1934
+ priority: 6.5,
1935
+ skipKey: `hotspot:${hotspot.file}`
1936
+ });
1937
+ }
1938
+ } catch { /* best-effort */ }
1939
+
1940
+ return results;
1941
+ }
1942
+
1943
+ /**
1944
+ * Grep source code for TODO/FIXME comments. Skips test/, node_modules/,
1945
+ * atris/, and .md files. Returns [{file, line, text}].
1946
+ *
1947
+ * Uses a loose grep then filters to real comment prefixes in JS — git grep's
1948
+ * -E flag doesn't support `\s` on macOS, so we keep the pattern simple and
1949
+ * refine post-hoc.
1950
+ */
1951
+ function findCodeTodos(cwd) {
1952
+ try {
1953
+ const out = execFileSync('git', [
1954
+ 'grep', '-n', '-I', '-E', '(TODO|FIXME)',
1955
+ '--', ':!test/', ':!node_modules/', ':!atris/', ':!**/*.md'
1956
+ ], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
1957
+ const results = [];
1958
+ for (const raw of out.split('\n').filter(Boolean)) {
1959
+ const m = raw.match(/^([^:]+):(\d+):(.*)$/);
1960
+ if (!m) continue;
1961
+ const line = m[3];
1962
+ // A real TODO is a comment marker at the start of the line (allowing
1963
+ // leading indent) followed by TODO/FIXME and at least one word. This
1964
+ // rejects "TODO.md" string literals in templates (init.js:398 style).
1965
+ const commentMatch = line.match(/^\s*(?:\/\/|#|\/\*|\*)\s*(TODO|FIXME):?\s+(\S.*)/);
1966
+ if (!commentMatch) continue;
1967
+ const text = commentMatch[2].replace(/\*\/\s*$/, '').trim();
1968
+ if (!text) continue;
1969
+ results.push({ file: m[1], line: parseInt(m[2], 10), text });
1970
+ if (results.length >= 100) break;
1971
+ }
1972
+ return results;
1973
+ } catch {
1974
+ return [];
1975
+ }
1976
+ }
1977
+
1978
+ /**
1979
+ * Heuristic: is a code TODO text substring already mentioned in the backlog?
1980
+ * We check for significant words (>=4 chars) overlap. At least 2 must match.
1981
+ */
1982
+ function isTodoTracked(todoText, backlogText) {
1983
+ if (!todoText || !backlogText) return false;
1984
+ const significantWords = todoText
1985
+ .toLowerCase()
1986
+ .split(/\W+/)
1987
+ .filter(w => w.length >= 4 && !['todo', 'fixme', 'this', 'that', 'with', 'from', 'when', 'then'].includes(w));
1988
+ if (significantWords.length === 0) return false;
1989
+ const lowerBacklog = backlogText.toLowerCase();
1990
+ const matches = significantWords.filter(w => lowerBacklog.includes(w)).length;
1991
+ return matches >= Math.min(2, significantWords.length);
1992
+ }
1993
+
1994
+ /**
1995
+ * Find the file with the most commits in the last 24 hours. Returns null if
1996
+ * no file has more than 5 commits (below the "hotspot" threshold).
1997
+ */
1998
+ function findHotspot(cwd) {
1999
+ try {
2000
+ const out = execFileSync('git', [
2001
+ 'log', '--since=24.hours.ago', '--name-only', '--pretty=format:'
2002
+ ], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
2003
+ const counts = {};
2004
+ for (const f of out.split('\n').map(s => s.trim()).filter(Boolean)) {
2005
+ counts[f] = (counts[f] || 0) + 1;
2006
+ }
2007
+ let best = null;
2008
+ for (const [file, commits] of Object.entries(counts)) {
2009
+ if (commits < 6) continue;
2010
+ if (!best || commits > best.commits) best = { file, commits };
2011
+ }
2012
+ return best;
2013
+ } catch {
2014
+ return null;
2015
+ }
2016
+ }
2017
+
2018
+ /**
2019
+ * Write `status: attempted` back to the typed lesson sidecar for a slug when
2020
+ * a self-heal tick tried and failed. Increments `attempts`, stamps
2021
+ * `last_attempt` (YYYY-MM-DD) and `last_attempt_reason`. Creates the sidecar
2022
+ * (and the slug entry) if missing.
2023
+ *
2024
+ * This closes the survivorship-bias loop the oracle flagged: without this,
2025
+ * the ledger only records fixes that worked, never the ones that didn't.
2026
+ *
2027
+ * @returns {boolean} true on success, false on malformed sidecar or write error
2028
+ */
2029
+ function markLessonAttempted(cwd, slug, reason) {
2030
+ if (!slug || typeof slug !== 'string') return false;
2031
+ const metaPath = path.join(cwd, 'atris', 'lessons.json');
2032
+ let meta = {};
2033
+ if (fs.existsSync(metaPath)) {
2034
+ try {
2035
+ const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
2036
+ if (parsed && typeof parsed === 'object') meta = parsed;
2037
+ } catch { return false; }
2038
+ }
2039
+ if (!meta[slug] || typeof meta[slug] !== 'object') meta[slug] = {};
2040
+ meta[slug].status = 'attempted';
2041
+ meta[slug].attempts = (typeof meta[slug].attempts === 'number' ? meta[slug].attempts : 0) + 1;
2042
+ meta[slug].last_attempt = new Date().toISOString().slice(0, 10);
2043
+ if (reason) meta[slug].last_attempt_reason = String(reason);
2044
+ try {
2045
+ const atrisDir = path.join(cwd, 'atris');
2046
+ if (!fs.existsSync(atrisDir)) fs.mkdirSync(atrisDir, { recursive: true });
2047
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
2048
+ return true;
2049
+ } catch { return false; }
2050
+ }
2051
+
2052
+ /**
2053
+ * Load the typed lesson metadata sidecar (atris/lessons.json).
2054
+ * Keyed by slug. Each entry may carry: scope, applies_to, detector, status.
2055
+ * Missing file or parse errors → empty object (prose-only fallback).
2056
+ */
2057
+ function loadLessonMetadata(cwd) {
2058
+ const metaPath = path.join(cwd, 'atris', 'lessons.json');
2059
+ if (!fs.existsSync(metaPath)) return {};
2060
+ try {
2061
+ const raw = fs.readFileSync(metaPath, 'utf8');
2062
+ const parsed = JSON.parse(raw);
2063
+ return (parsed && typeof parsed === 'object') ? parsed : {};
2064
+ } catch {
2065
+ return {};
2066
+ }
2067
+ }
2068
+
2069
+ /**
2070
+ * Parse atris/lessons.md into structured lesson objects, joined with the
2071
+ * optional atris/lessons.json sidecar by slug. Returns an array of:
2072
+ * { id, date, verdict, body, line, resolvedTag, meta, legacy }
2073
+ * where `legacy` is true when no sidecar metadata exists for the slug.
2074
+ */
2075
+ function parseLessons(cwd) {
2076
+ const lessonsPath = path.join(cwd, 'atris', 'lessons.md');
2077
+ if (!fs.existsSync(lessonsPath)) return [];
2078
+ const content = fs.readFileSync(lessonsPath, 'utf8');
2079
+ const metadata = loadLessonMetadata(cwd);
2080
+
2081
+ const out = [];
2082
+ for (const rawLine of content.split('\n')) {
2083
+ const line = rawLine;
2084
+ if (!line.trim().startsWith('- **[')) continue;
2085
+ const m = line.match(/\*\*\[(\d{4}-\d{2}-\d{2})\]\s+([\w-]+)\*\*\s*[—-]\s*(pass|fail)?\s*[—-]?\s*(.*)$/);
2086
+ if (!m) continue;
2087
+ const [, date, id, verdict, rest] = m;
2088
+ const resolvedTag = /\[resolved\]/.test(rest);
2089
+ const body = rest.replace(/^\[resolved\]\s*/, '').trim();
2090
+ const meta = metadata[id] || null;
2091
+ out.push({
2092
+ id,
2093
+ date,
2094
+ verdict: verdict || null,
2095
+ body,
2096
+ line: line.trim(),
2097
+ resolvedTag,
2098
+ meta,
2099
+ legacy: !meta
2100
+ });
2101
+ }
2102
+ return out;
2103
+ }
2104
+
2105
+ /**
2106
+ * Run a lesson's detector command. Returns true if the detector exits 0,
2107
+ * false otherwise (non-zero exit, timeout, spawn error).
2108
+ * execFileSync is intentionally avoided for detectors because they may
2109
+ * legitimately shell out (e.g. `node --test path | grep X`).
2110
+ */
2111
+ function runLessonDetector(detector, cwd, timeoutMs = 60000) {
2112
+ if (!detector || typeof detector !== 'string') return false;
2113
+ try {
2114
+ execSync(detector, { cwd, stdio: 'pipe', timeout: timeoutMs });
2115
+ return true;
2116
+ } catch {
2117
+ return false;
2118
+ }
2119
+ }
2120
+
1762
2121
  /**
1763
2122
  * Check whether a lesson's bug pattern is still present in the named files.
1764
- * Parses the lesson line for file paths (e.g. `commands/autopilot.js:116`)
1765
- * and the slug (e.g. `inbox-parser-eats-hr-separator`). Greps the named
1766
- * files for slug keywords. If none match lesson is resolved.
2123
+ *
2124
+ * Detector-backed path (preferred): if sidecar metadata has `detector`, run it.
2125
+ * exit 0 resolved (true). non-zeronot resolved (false).
2126
+ *
2127
+ * Legacy path (fallback): parse the lesson line for file paths + slug keywords
2128
+ * and grep. If no keyword matches any named file → resolved (true).
1767
2129
  *
1768
2130
  * @param {string} lessonLine - A single line from lessons.md
1769
2131
  * @param {string} cwd - Current working directory
1770
- * @returns {boolean} true if the lesson's bug pattern is gone (resolved)
2132
+ * @param {object} [options] - Optional pre-loaded metadata ({ meta, detectorTimeout })
2133
+ * @returns {boolean} true if the bug pattern is gone (resolved)
1771
2134
  */
1772
- function isLessonResolved(lessonLine, cwd) {
2135
+ function isLessonResolved(lessonLine, cwd, options = {}) {
2136
+ const slugMatch = lessonLine.match(/\*\*\[\d{4}-\d{2}-\d{2}\]\s+([\w-]+)\*\*/);
2137
+ if (!slugMatch) return false;
2138
+ const slug = slugMatch[1];
2139
+
2140
+ // Detector-backed check (typed lesson sidecar)
2141
+ const meta = options.meta || loadLessonMetadata(cwd)[slug];
2142
+ if (meta && meta.detector) {
2143
+ return runLessonDetector(meta.detector, cwd, options.detectorTimeout);
2144
+ }
2145
+
2146
+ // Legacy fallback: keyword grep against referenced files.
2147
+ return isLessonResolvedLegacy(lessonLine, cwd);
2148
+ }
2149
+
2150
+ /**
2151
+ * The pre-v3.8 resolver — kept as an internal fallback for prose-only lessons
2152
+ * that don't have detector metadata yet. Never auto-promotes a prose lesson to
2153
+ * resolved in the typed system (callers can still use the `resolvedTag` field
2154
+ * from parseLessons for hand-tagged entries).
2155
+ */
2156
+ function isLessonResolvedLegacy(lessonLine, cwd) {
1773
2157
  // Extract slug: bold text after date, e.g. **[2026-04-08] inbox-parser-eats-hr-separator**
1774
2158
  const slugMatch = lessonLine.match(/\*\*\[\d{4}-\d{2}-\d{2}\]\s+([\w-]+)\*\*/);
1775
2159
  if (!slugMatch) return false;
@@ -1815,6 +2199,52 @@ function isLessonResolved(lessonLine, cwd) {
1815
2199
  return true;
1816
2200
  }
1817
2201
 
2202
+ /**
2203
+ * Pick the oldest unresolved `fail` lesson whose bug pattern is still present.
2204
+ * Returns { date, slug, line } for the top candidate, or null if none.
2205
+ *
2206
+ * Self-healing seed: instead of imagining new horizons via LLM, use what the
2207
+ * system already wrote down about itself. A `fail` lesson with `isLessonResolved
2208
+ * === false` means grep confirms the bug pattern is still present — actionable.
2209
+ */
2210
+ function pickUnresolvedFailLesson(cwd) {
2211
+ const lessons = parseLessons(cwd);
2212
+ if (lessons.length === 0) return null;
2213
+
2214
+ const MAX_ATTEMPTS = 3;
2215
+ const candidates = [];
2216
+ for (const lesson of lessons) {
2217
+ if (lesson.verdict !== 'fail') continue;
2218
+ if (lesson.resolvedTag) continue;
2219
+ // Typed lesson with explicit status wins — respect the sidecar.
2220
+ // `resolved` = done. `observed` = process rule, not a fixable code state.
2221
+ // `attempted` with attempts >= MAX_ATTEMPTS = needs human re-scoping, skip.
2222
+ // Only `open` and `attempted` (under the cap) flow to self-heal execution.
2223
+ if (lesson.meta && lesson.meta.status) {
2224
+ const s = lesson.meta.status;
2225
+ if (s === 'resolved' || s === 'observed') continue;
2226
+ if (s === 'attempted' && (lesson.meta.attempts || 0) >= MAX_ATTEMPTS) continue;
2227
+ }
2228
+ // Detector-backed or legacy grep check.
2229
+ if (isLessonResolved(lesson.line, cwd, { meta: lesson.meta })) continue;
2230
+
2231
+ candidates.push({
2232
+ date: lesson.date,
2233
+ slug: lesson.id,
2234
+ line: lesson.line,
2235
+ typed: !lesson.legacy,
2236
+ detector: lesson.meta ? lesson.meta.detector || null : null,
2237
+ attempts: lesson.meta ? (lesson.meta.attempts || 0) : 0
2238
+ });
2239
+ }
2240
+
2241
+ if (candidates.length === 0) return null;
2242
+
2243
+ // Oldest first — longest-standing fails get priority
2244
+ candidates.sort((a, b) => a.date.localeCompare(b.date));
2245
+ return candidates[0];
2246
+ }
2247
+
1818
2248
  /**
1819
2249
  * Propose 3 candidate next horizons for the autopilot loop. Combines
1820
2250
  * `getIdleTickCount` + `getRecentSignals` into a prompt asking the LLM
@@ -2105,6 +2535,7 @@ async function autopilotAtris(description, options = {}) {
2105
2535
  }
2106
2536
  // Track as skipped so dry-run shows variety
2107
2537
  skipped.add(suggestion.task);
2538
+ if (suggestion.skipKey) skipped.add(suggestion.skipKey);
2108
2539
  if (suggestion.kind === 'docs') skipped.add('fix-map-refs');
2109
2540
  if (suggestion.kind === 'review') skipped.add('review');
2110
2541
  if (suggestion.kind === 'lessons') skipped.add('lessons');
@@ -2135,7 +2566,7 @@ async function autopilotAtris(description, options = {}) {
2135
2566
 
2136
2567
  if (decision === 'skip') {
2137
2568
  skipped.add(suggestion.task);
2138
- if (suggestion.kind === 'staleness') skipped.add(`recompile:${suggestion.task}`);
2569
+ if (suggestion.skipKey) skipped.add(suggestion.skipKey);
2139
2570
  if (suggestion.kind === 'docs') skipped.add('fix-map-refs');
2140
2571
  if (suggestion.kind === 'review') skipped.add('review');
2141
2572
  if (suggestion.kind === 'lessons') skipped.add('lessons');
@@ -2155,7 +2586,13 @@ async function autopilotAtris(description, options = {}) {
2155
2586
 
2156
2587
  // Execute: plan → do → review
2157
2588
  lastTaskTitle = suggestion.task;
2158
- const context = { task: suggestion.task, kind: suggestion.kind };
2589
+ const context = {
2590
+ task: suggestion.task,
2591
+ kind: suggestion.kind,
2592
+ ...(suggestion.lessonLine ? { lessonLine: suggestion.lessonLine } : {}),
2593
+ ...(suggestion.lessonSlug ? { lessonSlug: suggestion.lessonSlug } : {}),
2594
+ ...(suggestion.lessonDate ? { lessonDate: suggestion.lessonDate } : {})
2595
+ };
2159
2596
  const startingEndgame = readEndgameState(cwd);
2160
2597
 
2161
2598
  try {
@@ -2178,6 +2615,9 @@ async function autopilotAtris(description, options = {}) {
2178
2615
  tickOutcome = 'halted';
2179
2616
  tickOutcomeText = `I halted before running "${lastTaskTitle}": ${execution.reason}.`;
2180
2617
  tickNextStep = 'stop until a human looks at the error';
2618
+ if (suggestion.kind === 'self-heal' && suggestion.lessonSlug) {
2619
+ markLessonAttempted(cwd, suggestion.lessonSlug, `halted:${execution.reason}`);
2620
+ }
2181
2621
  if (!verbose) {
2182
2622
  printPlainBlock([
2183
2623
  `I halted: ${execution.reason}.`,
@@ -2203,6 +2643,9 @@ async function autopilotAtris(description, options = {}) {
2203
2643
  tickOutcome = 'halted';
2204
2644
  tickOutcomeText = `I built "${lastTaskTitle}" but review flagged issues.`;
2205
2645
  tickNextStep = 'wait for a human to check the review output';
2646
+ if (suggestion.kind === 'self-heal' && suggestion.lessonSlug) {
2647
+ markLessonAttempted(cwd, suggestion.lessonSlug, 'review-rejected');
2648
+ }
2206
2649
  if (verbose) {
2207
2650
  console.log(` review flagged issues (${reviewTime}s). stopping for manual check.`);
2208
2651
  } else {
@@ -2222,6 +2665,9 @@ async function autopilotAtris(description, options = {}) {
2222
2665
  tickOutcomeText = `I planned, built, and reviewed "${lastTaskTitle}" but verify failed.`;
2223
2666
  tickNextStep = 'verify failed, halting';
2224
2667
  writeLesson(cwd, 'verify-failed', 'fail', `Task "${lastTaskTitle}" passed review but failed verify command.`);
2668
+ if (suggestion.kind === 'self-heal' && suggestion.lessonSlug) {
2669
+ markLessonAttempted(cwd, suggestion.lessonSlug, 'verify-failed');
2670
+ }
2225
2671
  if (verbose) {
2226
2672
  console.log(` verify failed. stopping for manual check.`);
2227
2673
  } else {
@@ -2512,6 +2958,12 @@ module.exports = {
2512
2958
  autopilotFromTodo,
2513
2959
  buildPrompt,
2514
2960
  isLessonResolved,
2961
+ isLessonResolvedLegacy,
2962
+ loadLessonMetadata,
2963
+ markLessonAttempted,
2964
+ parseLessons,
2965
+ pickUnresolvedFailLesson,
2966
+ runLessonDetector,
2515
2967
  isStillTrue,
2516
2968
  getTaskAgeDays,
2517
2969
  getIdleTickCount,
@@ -2519,6 +2971,11 @@ module.exports = {
2519
2971
  getTickStatus,
2520
2972
  getVerifyCommand,
2521
2973
  computeTickReward,
2974
+ detectDefaultVerify,
2975
+ findCodeTodos,
2976
+ findHotspot,
2977
+ isTodoTracked,
2978
+ scanAnomalies,
2522
2979
  verifyJudgeIntegrity,
2523
2980
  maybeWriteCompletedEndgameScorecard,
2524
2981
  renderHumanSuggestion,