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 +16 -7
- package/commands/align.js +1 -0
- package/commands/autopilot.js +473 -16
- package/commands/brainstorm.js +7 -5
- package/commands/clean.js +19 -3
- package/commands/computer.js +43 -0
- package/commands/integrations.js +14 -9
- package/commands/pull.js +46 -10
- package/commands/push.js +110 -10
- package/commands/workflow.js +24 -9
- package/lib/manifest.js +3 -0
- package/lib/workspace-safety.js +87 -0
- package/package.json +1 -1
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
|
|
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
|
|
1361
|
-
if (
|
|
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']);
|
package/commands/autopilot.js
CHANGED
|
@@ -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 (
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
718
|
-
|
|
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
|
-
*
|
|
1765
|
-
*
|
|
1766
|
-
*
|
|
2123
|
+
*
|
|
2124
|
+
* Detector-backed path (preferred): if sidecar metadata has `detector`, run it.
|
|
2125
|
+
* exit 0 → resolved (true). non-zero → not 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
|
-
* @
|
|
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.
|
|
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 = {
|
|
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,
|