atris 3.80.1 → 3.82.2
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/LICENSE +21 -0
- package/README.md +11 -1
- package/atris/atris.md +14 -0
- package/atris/features/README.md +15 -0
- package/atris/policies/README.md +24 -0
- package/atris/skills/README.md +61 -45
- package/atris/skills/design/SKILL.md +6 -5
- package/atris/skills/taste/SKILL.md +39 -0
- package/atris/skills/taste/notes.md +29 -0
- package/atris/skills/taste/principles.md +23 -0
- package/atris/skills/wiki/SKILL.md +14 -2
- package/atris/skills/wiki/index-lines.md +30 -0
- package/atris/skills/wiki/measure.md +20 -0
- package/atris/skills/wiki/note-shape.md +16 -0
- package/atris/skills/wiki/upkeep.md +16 -0
- package/atris/team/README.md +38 -0
- package/atris.md +14 -0
- package/bin/atris.js +1 -1
- package/commands/activate.js +1 -1
- package/commands/autoland.js +25 -26
- package/commands/autopilot-front.js +46 -1
- package/commands/autopilot.js +55 -25
- package/commands/clean.js +22 -11
- package/commands/computer.js +1 -1
- package/commands/doc-health.js +48 -1
- package/commands/improve.js +16 -3
- package/commands/init.js +4 -0
- package/commands/integrations.js +69 -1616
- package/commands/mission.js +112 -14
- package/commands/release.js +41 -10
- package/commands/status.js +1 -1
- package/commands/sync.js +12 -8
- package/commands/task.js +150 -137
- package/commands/unknowns.js +9 -5
- package/commands/visualize.js +1 -12
- package/commands/workflow.js +4 -3
- package/commands/worktree.js +107 -1610
- package/commands/xp.js +23 -2113
- package/commands/youtube.js +192 -3110
- package/lib/business-core.js +3 -4
- package/lib/campaign-run-lock.js +12 -1
- package/lib/engine-ask.js +1 -1
- package/lib/engine-receipt-sweep.js +64 -0
- package/lib/file-lock.js +175 -0
- package/lib/first-minute.js +37 -4
- package/lib/fleet.js +1 -1
- package/lib/integrations-core.js +1695 -0
- package/lib/intents.js +7 -0
- package/lib/json-file.js +22 -1
- package/lib/loop-doctor.js +36 -2
- package/lib/map-refs.js +68 -8
- package/lib/member-context.js +20 -1
- package/lib/member-engine.js +1 -1
- package/lib/member-map-fold.js +49 -11
- package/lib/member-map-inbox.js +2 -1
- package/lib/mission-core.js +9 -2
- package/lib/next-work-context.js +190 -0
- package/lib/recall-exam.js +171 -0
- package/lib/roster-markdown.js +2 -2
- package/lib/runner-command.js +5 -5
- package/lib/task-db.js +107 -38
- package/lib/taste-notes.js +35 -0
- package/lib/team-lineup.js +3 -3
- package/lib/todo.js +2 -0
- package/lib/wiki.js +11 -3
- package/lib/worktree-core.js +2032 -0
- package/lib/xp-core.js +2172 -0
- package/lib/youtube-core.js +3178 -0
- package/lib/zen-apply.js +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Recall exam: how fast can an agent find the right note by following links?
|
|
4
|
+
//
|
|
5
|
+
// The reader starts with the boot files, then keeps opening the linked
|
|
6
|
+
// markdown file whose link text and line best match the question, the way an
|
|
7
|
+
// agent scans an index and follows the line that says when to open it. It never
|
|
8
|
+
// greps the whole tree. A question is found when an opened file contains the
|
|
9
|
+
// question's needle, an exact answer phrase. Needles survive a file move, so the
|
|
10
|
+
// same exam scores a layout before and after notes are split or merged.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const STOP_WORDS = new Set(('the and for are was were where what which who how does did can could should would this that these those '
|
|
16
|
+
+ 'with from into about find have has had there here when why our you your its it is be do to of in on at or an a i im me my we us '
|
|
17
|
+
+ 'if not no any all one get got make need needs want am will just then than so but by as up out over use using used new before after '
|
|
18
|
+
+ 'first next way thing things something someone mid work working about while also only very much more most some same each every').split(' '));
|
|
19
|
+
const DEFAULT_MAX_OPENS = 6;
|
|
20
|
+
// A real file reader stops after this many lines unless asked for more.
|
|
21
|
+
const READ_LINE_CAP = 2000;
|
|
22
|
+
|
|
23
|
+
function stem(word) {
|
|
24
|
+
return word.replace(/(?:ing|ed|es|s)$/u, '') || word;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function keywords(text) {
|
|
28
|
+
return [...new Set((String(text).toLowerCase().match(/[\p{L}\p{N}]+/gu) || [])
|
|
29
|
+
.filter(word => word.length >= 3 && !STOP_WORDS.has(word)).map(stem))];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalize(text) {
|
|
33
|
+
return String(text).toLowerCase().replace(/\s+/g, ' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readCapped(file) {
|
|
37
|
+
let text;
|
|
38
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
39
|
+
const lines = text.split('\n');
|
|
40
|
+
return lines.length > READ_LINE_CAP ? lines.slice(0, READ_LINE_CAP).join('\n') : text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Every markdown file a line points at, with the words an agent sees beside it.
|
|
44
|
+
function linksIn(text, fromFile, root) {
|
|
45
|
+
const links = [];
|
|
46
|
+
const fromDir = path.dirname(fromFile);
|
|
47
|
+
let heading = '';
|
|
48
|
+
for (const line of text.split('\n')) {
|
|
49
|
+
const head = line.match(/^#{1,6}\s+(.*)/);
|
|
50
|
+
if (head) heading = head[1];
|
|
51
|
+
const targets = [];
|
|
52
|
+
for (const match of line.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)) targets.push({ label: match[1], target: match[2] });
|
|
53
|
+
for (const match of line.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)) targets.push({ label: '', target: match[1] });
|
|
54
|
+
for (const match of line.matchAll(/`([^`\s]+\.md)(?:#[^`]*)?`/g)) targets.push({ label: '', target: match[1] });
|
|
55
|
+
for (const { label, target } of targets) {
|
|
56
|
+
const clean = target.replace(/#.*$/, '').replace(/:\d+(?:-\d+)?$/, '');
|
|
57
|
+
if (!/\.md$/i.test(clean) || clean.includes('://')) continue;
|
|
58
|
+
const candidates = [path.resolve(fromDir, clean), path.resolve(root, clean)];
|
|
59
|
+
const resolved = candidates.find(file => file.startsWith(root + path.sep) && fs.existsSync(file));
|
|
60
|
+
if (!resolved) continue;
|
|
61
|
+
links.push({ file: resolved, context: `${label} ${line} ${heading} ${path.basename(clean, '.md')}` });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return links;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scoreContext(context, words) {
|
|
68
|
+
const have = new Set(keywords(context));
|
|
69
|
+
return words.filter(word => have.has(word)).length;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function examineQuestion(root, question, { bootFiles, maxOpens = DEFAULT_MAX_OPENS }) {
|
|
73
|
+
const words = keywords(question.q);
|
|
74
|
+
const needle = normalize(question.needle);
|
|
75
|
+
const opened = [];
|
|
76
|
+
let charsRead = 0;
|
|
77
|
+
let foundIn = null;
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const frontier = new Map();
|
|
80
|
+
// The file whose link led to each opened file, so the path to the answer is known.
|
|
81
|
+
const via = new Map();
|
|
82
|
+
const absorb = (file, text) => {
|
|
83
|
+
seen.add(file);
|
|
84
|
+
for (const link of linksIn(text, file, root)) {
|
|
85
|
+
if (seen.has(link.file)) continue;
|
|
86
|
+
const score = scoreContext(link.context, words);
|
|
87
|
+
const prior = frontier.get(link.file);
|
|
88
|
+
if (!prior || score > prior.score) frontier.set(link.file, { score, order: prior ? prior.order : frontier.size, from: file });
|
|
89
|
+
}
|
|
90
|
+
if (!foundIn && normalize(text).includes(needle)) foundIn = path.relative(root, file);
|
|
91
|
+
};
|
|
92
|
+
for (const rel of bootFiles) {
|
|
93
|
+
const file = path.resolve(root, rel);
|
|
94
|
+
const text = readCapped(file);
|
|
95
|
+
if (text === null) continue;
|
|
96
|
+
absorb(file, text);
|
|
97
|
+
}
|
|
98
|
+
while (!foundIn && opened.length < maxOpens) {
|
|
99
|
+
let best = null;
|
|
100
|
+
for (const [file, entry] of frontier) {
|
|
101
|
+
if (seen.has(file) || entry.score === 0) continue;
|
|
102
|
+
if (!best || entry.score > best.score || (entry.score === best.score && entry.order < best.order)) best = { file, ...entry };
|
|
103
|
+
}
|
|
104
|
+
if (!best) break;
|
|
105
|
+
frontier.delete(best.file);
|
|
106
|
+
via.set(best.file, best.from);
|
|
107
|
+
const text = readCapped(best.file) || '';
|
|
108
|
+
opened.push(path.relative(root, best.file));
|
|
109
|
+
charsRead += text.length;
|
|
110
|
+
absorb(best.file, text);
|
|
111
|
+
}
|
|
112
|
+
// An index opened on the way to the answer was needed; every other open was not.
|
|
113
|
+
const needed = new Set();
|
|
114
|
+
for (let at = foundIn && path.resolve(root, foundIn); at && via.has(at); at = via.get(at)) needed.add(path.relative(root, at));
|
|
115
|
+
return {
|
|
116
|
+
id: question.id, area: question.area || null, found: Boolean(foundIn), found_in: foundIn,
|
|
117
|
+
files_opened: opened.length, opened, tokens_opened: Math.round(charsRead / 4),
|
|
118
|
+
wrong_opens: opened.filter(rel => !needed.has(rel)).length,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readQuestions(file) {
|
|
123
|
+
const questions = [];
|
|
124
|
+
const invalid = [];
|
|
125
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
126
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
127
|
+
if (!line.trim()) return;
|
|
128
|
+
let row;
|
|
129
|
+
try { row = JSON.parse(line); } catch { row = null; }
|
|
130
|
+
if (!row || typeof row.q !== 'string' || typeof row.needle !== 'string' || !row.needle.trim()) invalid.push(index + 1);
|
|
131
|
+
else questions.push(row);
|
|
132
|
+
});
|
|
133
|
+
return { questions, invalid };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function median(values) {
|
|
137
|
+
if (!values.length) return null;
|
|
138
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
139
|
+
const mid = Math.floor(sorted.length / 2);
|
|
140
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function runRecallExam(root, questionsFile, { bootFiles, maxOpens = DEFAULT_MAX_OPENS } = {}) {
|
|
144
|
+
const { questions, invalid } = readQuestions(questionsFile);
|
|
145
|
+
const bootChars = bootFiles.reduce((sum, rel) => sum + (readCapped(path.resolve(root, rel)) || '').length, 0);
|
|
146
|
+
const results = questions.map(question => examineQuestion(root, question, { bootFiles, maxOpens }));
|
|
147
|
+
const found = results.filter(result => result.found);
|
|
148
|
+
const opens = results.map(result => result.files_opened);
|
|
149
|
+
const tokens = results.map(result => result.tokens_opened);
|
|
150
|
+
const wrong = results.map(result => result.wrong_opens);
|
|
151
|
+
const byArea = {};
|
|
152
|
+
for (const result of results) {
|
|
153
|
+
const area = result.area || 'none';
|
|
154
|
+
byArea[area] = byArea[area] || { total: 0, found: 0 };
|
|
155
|
+
byArea[area].total++;
|
|
156
|
+
if (result.found) byArea[area].found++;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
questions: results.length, invalid_lines: invalid, max_opens: maxOpens,
|
|
160
|
+
boot_tokens: Math.round(bootChars / 4),
|
|
161
|
+
found: found.length, found_share: results.length ? found.length / results.length : null,
|
|
162
|
+
files_opened: { total: opens.reduce((a, b) => a + b, 0), median: median(opens), mean: results.length ? opens.reduce((a, b) => a + b, 0) / results.length : null },
|
|
163
|
+
wrong_opens: { total: wrong.reduce((a, b) => a + b, 0), median: median(wrong), mean: results.length ? wrong.reduce((a, b) => a + b, 0) / results.length : null },
|
|
164
|
+
tokens_opened: { total: tokens.reduce((a, b) => a + b, 0), median: median(tokens), mean: results.length ? Math.round(tokens.reduce((a, b) => a + b, 0) / results.length) : null },
|
|
165
|
+
tokens_opened_when_found: { median: median(found.map(result => result.tokens_opened)) },
|
|
166
|
+
by_area: byArea,
|
|
167
|
+
results,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { runRecallExam, keywords };
|
package/lib/roster-markdown.js
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Two shapes read side by side. The sectioned shape, which assign writes:
|
|
10
10
|
// ## build
|
|
11
|
-
// - claude code, model: opus
|
|
11
|
+
// - claude code, model: opus
|
|
12
12
|
// - codex, effort: medium
|
|
13
13
|
// and the older one-line shape, still read as before:
|
|
14
|
-
// build: claude opus
|
|
14
|
+
// build: claude opus, backup codex
|
|
15
15
|
|
|
16
16
|
const OLD_ROSTER_NOTE = '> edit any time. each line is job: engine and model, an optional effort (low, medium, high, xhigh, max), then optional "backup <engine and model>", "max 20 min", and "until 2026-10-24" (the year is required).';
|
|
17
17
|
const ROSTER_NOTE = '> edit any time. each "## job" lists its workers in order, one per line: "- tool, model: name", then optional "effort: medium", "max: 20 min", "prep: search" (that job reads the task first and hands this worker a short brief), and "until 2026-10-24" (the year is required). the first ready worker leads and the rest back it up.';
|
package/lib/runner-command.js
CHANGED
|
@@ -95,7 +95,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
95
95
|
}),
|
|
96
96
|
haiku: Object.freeze({
|
|
97
97
|
bin: 'claude',
|
|
98
|
-
model:
|
|
98
|
+
model: DEFAULT_CLAUDE_RUNNER_MODEL,
|
|
99
99
|
commandTemplate: '',
|
|
100
100
|
effortFlag: '--effort',
|
|
101
101
|
efforts: CLAUDE_EFFORTS,
|
|
@@ -109,8 +109,8 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
109
109
|
model: '',
|
|
110
110
|
commandTemplate: '{bin} -p {pinnedModelFlag} -- {prompt}',
|
|
111
111
|
}),
|
|
112
|
-
// No hard pin: a pinned grok
|
|
113
|
-
// own default to
|
|
112
|
+
// No hard pin: a pinned grok version went stale when the grok CLI moved its
|
|
113
|
+
// own default to a newer build, so an unpinned run rides the CLI.
|
|
114
114
|
// Grok runs only xAI models, so a claude model name never reaches it.
|
|
115
115
|
grok: Object.freeze({
|
|
116
116
|
bin: 'grok',
|
|
@@ -123,7 +123,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
123
123
|
agy: Object.freeze({
|
|
124
124
|
bin: 'agy',
|
|
125
125
|
// No hard pin: the agy CLI keeps its own session model, so an unpinned
|
|
126
|
-
// run rides that and a roster line names gemini
|
|
126
|
+
// run rides that and a roster line names its own gemini model (the 2026-09-04
|
|
127
127
|
// bake-off pick) when it wants it. --add-dir or agy edits its own scratch
|
|
128
128
|
// folder; the headless note or gemini stops to ask "may I edit?" and
|
|
129
129
|
// exits 0 having changed nothing.
|
|
@@ -319,7 +319,7 @@ function runnerModelFor(engineId, model) {
|
|
|
319
319
|
// Required here, not at the top: engine-registry requires this file.
|
|
320
320
|
const { isClaudeFamilyModel } = require('./engine-registry');
|
|
321
321
|
const name = String(engineId || '').trim().toLowerCase();
|
|
322
|
-
// A display name
|
|
322
|
+
// A spaced display name only becomes an id for the claude cli (or
|
|
323
323
|
// an unnamed engine). Any other engine's model name is handed over as is.
|
|
324
324
|
const canonical = RUNNER_PROFILE_ALIASES[name] || name;
|
|
325
325
|
const claudeCli = !canonical || canonical === 'claude' || canonical === 'fable' || canonical === 'haiku';
|
package/lib/task-db.js
CHANGED
|
@@ -545,7 +545,7 @@ function rowInWorkspace(row, ws) {
|
|
|
545
545
|
return Boolean(rowRoot) && workspaceViewRoots(ws).includes(rowRoot);
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
-
function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
|
|
548
|
+
function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit, orderBy }) {
|
|
549
549
|
const where = [];
|
|
550
550
|
const args = [];
|
|
551
551
|
if (ws) {
|
|
@@ -560,6 +560,7 @@ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
|
|
|
560
560
|
FROM tasks
|
|
561
561
|
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
562
562
|
ORDER BY
|
|
563
|
+
${orderBy === 'updated_at' ? 'updated_at DESC,' : ''}
|
|
563
564
|
CASE status WHEN 'open' THEN 0 WHEN 'claimed' THEN 1 WHEN 'review' THEN 2 WHEN 'failed' THEN 3 WHEN 'done' THEN 4 WHEN 'archived' THEN 5 ELSE 6 END,
|
|
564
565
|
CASE WHEN tag='endgame' THEN 0 ELSE 1 END,
|
|
565
566
|
created_at DESC
|
|
@@ -826,6 +827,13 @@ function pidAlive(pid) {
|
|
|
826
827
|
try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
|
|
827
828
|
}
|
|
828
829
|
|
|
830
|
+
// A claim made by hand (`atris task claim` with no --lease-minutes) has no lease, so before this nothing ever let
|
|
831
|
+
// it go: a session that died holding one kept the job off the board for good. Such a claim is let go once its job
|
|
832
|
+
// has seen no activity (no event of any kind) for this many days, whatever --stale-days says. It is measured by
|
|
833
|
+
// activity, not by a clock set at claim time, so a claim someone is still working on is never taken away, and it
|
|
834
|
+
// is long enough that the narrower --stale-days rule (fleet claims, or one named claimer) still decides first.
|
|
835
|
+
const UNLEASED_CLAIM_STALE_DAYS = 30;
|
|
836
|
+
|
|
829
837
|
function expireLeases(db, { now = Date.now(), staleDays, claimer, dryRun = false, isAlive = pidAlive } = {}) {
|
|
830
838
|
const timestamp = now instanceof Date ? now.getTime() : typeof now === 'string' ? Date.parse(now) : Number(now);
|
|
831
839
|
if (!Number.isFinite(timestamp)) throw new Error('invalid now');
|
|
@@ -846,6 +854,9 @@ function expireLeases(db, { now = Date.now(), staleDays, claimer, dryRun = false
|
|
|
846
854
|
} else if (!lease && staleDays != null && row.last_seen_at != null && row.last_seen_at < timestamp - Number(staleDays) * 86400000 && (claimer ? row.claimed_by === claimer : String(row.claimed_by || '').startsWith('fleet-'))) {
|
|
847
855
|
reason = `stranded with no activity for over ${staleDays} days`;
|
|
848
856
|
leaseUntil = new Date(row.last_seen_at + 3600000).toISOString();
|
|
857
|
+
} else if (!lease && row.last_seen_at != null && row.last_seen_at < timestamp - UNLEASED_CLAIM_STALE_DAYS * 86400000) {
|
|
858
|
+
reason = `stranded with no activity for over ${UNLEASED_CLAIM_STALE_DAYS} days`;
|
|
859
|
+
leaseUntil = new Date(row.last_seen_at + 3600000).toISOString();
|
|
849
860
|
} else continue;
|
|
850
861
|
const lastSeen = lease && Number.isFinite(until) && Number.isFinite(Number(lease.minutes))
|
|
851
862
|
? until - Number(lease.minutes) * 60000 : row.last_seen_at;
|
|
@@ -1130,7 +1141,7 @@ function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview
|
|
|
1130
1141
|
return { reopened: true, event, row: updated };
|
|
1131
1142
|
}
|
|
1132
1143
|
|
|
1133
|
-
function doneTask(db, { id, status, actor, allowReview = false, action, proof, autoAccepted = false } = {}) {
|
|
1144
|
+
function doneTask(db, { id, status, actor, allowReview = false, action, proof, autoAccepted = false, deferLogs = false } = {}) {
|
|
1134
1145
|
if (!id) throw new Error('id required');
|
|
1135
1146
|
const final = status || 'done';
|
|
1136
1147
|
if (!['done', 'failed'].includes(final)) throw new Error('status must be done|failed');
|
|
@@ -1139,18 +1150,22 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof, a
|
|
|
1139
1150
|
// read as a real failure. See archiveTask() below.
|
|
1140
1151
|
const now = Date.now();
|
|
1141
1152
|
const allowedStatuses = allowReview ? "'open', 'claimed', 'review'" : "'open', 'claimed'";
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1153
|
+
const eventActor = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
|
|
1154
|
+
// The status change and its event are one write: a crash between them used to leave a done job with no
|
|
1155
|
+
// completed event. The daily log is a file, written only after the write is saved.
|
|
1156
|
+
const row = inWriteTransaction(db, () => {
|
|
1157
|
+
const result = withBusyRetry(() => db.prepare(`
|
|
1158
|
+
UPDATE tasks
|
|
1159
|
+
SET status = ?, done_at = ?, updated_at = ?
|
|
1160
|
+
WHERE id = ?
|
|
1161
|
+
AND status IN (${allowedStatuses})
|
|
1162
|
+
`).run(final, now, now, id));
|
|
1163
|
+
if (result.changes !== 1) return null;
|
|
1164
|
+
const updated = getTask(db, id);
|
|
1150
1165
|
appendTaskEvent(db, {
|
|
1151
1166
|
taskId: id,
|
|
1152
|
-
workspaceRoot:
|
|
1153
|
-
actor:
|
|
1167
|
+
workspaceRoot: updated.workspace_root,
|
|
1168
|
+
actor: eventActor,
|
|
1154
1169
|
eventType: final === 'done' ? 'completed' : 'blocked',
|
|
1155
1170
|
payload: {
|
|
1156
1171
|
status: final,
|
|
@@ -1158,17 +1173,40 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof, a
|
|
|
1158
1173
|
...(autoAccepted ? { auto_accepted: true } : {}),
|
|
1159
1174
|
},
|
|
1160
1175
|
});
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
}
|
|
1176
|
+
return updated;
|
|
1177
|
+
});
|
|
1178
|
+
if (row) {
|
|
1179
|
+
const logInput = { status: final, actor: eventActor, action: action || final, proof };
|
|
1180
|
+
// A caller that holds its own transaction (acceptTask) writes the log once that is saved.
|
|
1181
|
+
if (deferLogs) return { updated: true, row, logInput };
|
|
1182
|
+
const logs = appendTaskCompletionLogs(db, row, logInput);
|
|
1167
1183
|
return { updated: true, logs };
|
|
1168
1184
|
}
|
|
1169
1185
|
return { updated: false };
|
|
1170
1186
|
}
|
|
1171
1187
|
|
|
1188
|
+
// Accepting a job is two writes, the job marked done and the review that records it as accepted with its
|
|
1189
|
+
// reward. They are one transaction: a crash between them used to leave a done job with approval still
|
|
1190
|
+
// pending, and nothing scans done jobs to finish the accept (autoland reads only review). The daily log
|
|
1191
|
+
// is a file and is written once the transaction is saved.
|
|
1192
|
+
function acceptTask(db, { id, actor, proof, reward, lesson = '', nextTask = '', autoAccepted = false, careerXpEligible = true, clearedFields = [] } = {}) {
|
|
1193
|
+
if (!id) throw new Error('id required');
|
|
1194
|
+
const out = inWriteTransaction(db, () => {
|
|
1195
|
+
const done = doneTask(db, { id, status: 'done', actor, allowReview: true, action: 'accepted', proof, autoAccepted, deferLogs: true });
|
|
1196
|
+
if (!done.updated) return { updated: false };
|
|
1197
|
+
const reviewed = reviewTask(db, { id, actor, reward, lesson, nextTask, proof, careerXpEligible, clearedFields, autoAccepted });
|
|
1198
|
+
return { updated: true, reviewed, row: done.row, logInput: done.logInput };
|
|
1199
|
+
});
|
|
1200
|
+
if (!out.updated) return { updated: false };
|
|
1201
|
+
let logs = {};
|
|
1202
|
+
try {
|
|
1203
|
+
logs = appendTaskCompletionLogs(db, out.row, out.logInput);
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
if (process.env.ATRIS_DEBUG) process.stderr.write(`accept log failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`);
|
|
1206
|
+
}
|
|
1207
|
+
return { updated: true, reviewed: out.reviewed, logs };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1172
1210
|
function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor, rows = null, skipLogs = false } = {}) {
|
|
1173
1211
|
const missionById = new Map((Array.isArray(missions) ? missions : [])
|
|
1174
1212
|
.filter(mission => mission && mission.id)
|
|
@@ -1825,32 +1863,55 @@ function releaseClosedPullRequest(db, { id, actor, workspaceRoot: ws, expected,
|
|
|
1825
1863
|
return { released: true, row: out.row, event: out.event };
|
|
1826
1864
|
}
|
|
1827
1865
|
|
|
1866
|
+
// Read a job's metadata, change it, and write it back with the write lock held, so two writers that each
|
|
1867
|
+
// read the whole JSON cannot drop each other's keys. The mutator gets a copy and changes it in place or
|
|
1868
|
+
// returns a new object; returning false writes nothing. Keep the mutator fast and free of I/O: it runs
|
|
1869
|
+
// inside BEGIN IMMEDIATE. touch: false leaves updated_at as it was.
|
|
1870
|
+
// Returns null when the job does not exist, else { row, metadata, changed }.
|
|
1871
|
+
function updateTaskMetadata(db, id, mutator, { touch = true, now = Date.now() } = {}) {
|
|
1872
|
+
if (!id) throw new Error('id required');
|
|
1873
|
+
if (typeof mutator !== 'function') throw new Error('mutator required');
|
|
1874
|
+
return inWriteTransaction(db, () => {
|
|
1875
|
+
const row = getTask(db, id);
|
|
1876
|
+
if (!row) return null;
|
|
1877
|
+
const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
|
|
1878
|
+
const out = mutator(metadata, row);
|
|
1879
|
+
if (out === false) return { row, metadata, changed: false };
|
|
1880
|
+
const next = out && typeof out === 'object' ? out : metadata;
|
|
1881
|
+
const text = JSON.stringify(next);
|
|
1882
|
+
withBusyRetry(() => (touch
|
|
1883
|
+
? db.prepare('UPDATE tasks SET metadata = ?, updated_at = ? WHERE id = ?').run(text, now, id)
|
|
1884
|
+
: db.prepare('UPDATE tasks SET metadata = ? WHERE id = ?').run(text, id)));
|
|
1885
|
+
return { row, metadata: next, changed: true };
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1828
1889
|
function setTaskResult(db, { id, actor, result }) {
|
|
1829
1890
|
if (!id) throw new Error('id required');
|
|
1830
1891
|
const text = cleanLandingText(result);
|
|
1831
1892
|
if (!text) throw new Error('result required');
|
|
1832
|
-
const row = getTask(db, id);
|
|
1833
|
-
if (!row) return { saved: false, reason: 'not_found' };
|
|
1834
1893
|
const now = Date.now();
|
|
1835
|
-
const
|
|
1836
|
-
metadata
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1894
|
+
const resultBy = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
|
|
1895
|
+
// The metadata write and its event are one transaction, and the metadata is read under the lock.
|
|
1896
|
+
const out = inWriteTransaction(db, () => {
|
|
1897
|
+
const saved = updateTaskMetadata(db, id, (metadata) => {
|
|
1898
|
+
metadata.result = text;
|
|
1899
|
+
metadata.result_at = new Date(now).toISOString();
|
|
1900
|
+
metadata.result_by = resultBy;
|
|
1901
|
+
}, { now });
|
|
1902
|
+
if (!saved) return null;
|
|
1903
|
+
const row = getTask(db, id);
|
|
1904
|
+
const written = appendTaskEvent(db, {
|
|
1905
|
+
taskId: id,
|
|
1906
|
+
workspaceRoot: row.workspace_root,
|
|
1907
|
+
actor: resultBy,
|
|
1908
|
+
eventType: 'result_set',
|
|
1909
|
+
payload: { result: text },
|
|
1910
|
+
});
|
|
1911
|
+
return { latest: row, event: written };
|
|
1853
1912
|
});
|
|
1913
|
+
if (!out) return { saved: false, reason: 'not_found' };
|
|
1914
|
+
const { latest, event } = out;
|
|
1854
1915
|
return { saved: true, event, row: latest };
|
|
1855
1916
|
}
|
|
1856
1917
|
|
|
@@ -3053,6 +3114,11 @@ function taskProjection(db, {
|
|
|
3053
3114
|
};
|
|
3054
3115
|
}
|
|
3055
3116
|
|
|
3117
|
+
// Status names: the database keeps `failed` (doneTask writes it, its event is `blocked`), and
|
|
3118
|
+
// TODO.md shows those rows under "## Blocked". The words differ on purpose and the stored value
|
|
3119
|
+
// is not renamed: every reader, filter, and old database already says `failed`, and a person
|
|
3120
|
+
// reads "blocked" as "needs someone", which is what a failed job is. `atris task list --status
|
|
3121
|
+
// failed` is how to list them.
|
|
3056
3122
|
function renderTodoMarkdown(rows, { title = 'TODO.md', doneLimit = TODO_RENDER_DONE_LIMIT, failedLimit = TODO_RENDER_FAILED_LIMIT, refRows = rows, preservedSections = [] } = {}) {
|
|
3057
3123
|
const displayRows = withTaskDisplayRefs(rows, refRows);
|
|
3058
3124
|
const buckets = {
|
|
@@ -3192,6 +3258,7 @@ module.exports = {
|
|
|
3192
3258
|
renewLease,
|
|
3193
3259
|
clearLease,
|
|
3194
3260
|
expireLeases,
|
|
3261
|
+
UNLEASED_CLAIM_STALE_DAYS,
|
|
3195
3262
|
previewExpiredLeases,
|
|
3196
3263
|
sweepExpiredLeases,
|
|
3197
3264
|
releaseTask,
|
|
@@ -3201,6 +3268,7 @@ module.exports = {
|
|
|
3201
3268
|
backlogTask,
|
|
3202
3269
|
clearPlanTasks,
|
|
3203
3270
|
doneTask,
|
|
3271
|
+
acceptTask,
|
|
3204
3272
|
reapMissionBlockerTasks,
|
|
3205
3273
|
archiveTask,
|
|
3206
3274
|
archiveNotNeeded,
|
|
@@ -3212,6 +3280,7 @@ module.exports = {
|
|
|
3212
3280
|
landHeldMerge,
|
|
3213
3281
|
releaseClosedPullRequest,
|
|
3214
3282
|
setTaskResult,
|
|
3283
|
+
updateTaskMetadata,
|
|
3215
3284
|
reviseTask,
|
|
3216
3285
|
stageTask,
|
|
3217
3286
|
noteTask,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Taste is personal, so it lives outside the shipped skill: the project's
|
|
4
|
+
// atris/taste/TASTE.md and the person's ~/.atris/taste/TASTE.md. Sync replaces
|
|
5
|
+
// atris/skills/ on every update and never touches either file. Init and update
|
|
6
|
+
// only create the project starter when it is missing.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const PROJECT_TASTE_FILE = path.join('taste', 'TASTE.md');
|
|
12
|
+
|
|
13
|
+
const STARTER = `# Taste
|
|
14
|
+
|
|
15
|
+
How this project should look and read: its brand and its voice. Agents read this before making anything a person will see. The person's own taste in \`~/.atris/taste/TASTE.md\` comes second.
|
|
16
|
+
|
|
17
|
+
Each line gives the rule in plain words and links to a short note with the reason and real examples. \`atris/skills/taste/notes.md\` shows the shape.
|
|
18
|
+
|
|
19
|
+
## The list
|
|
20
|
+
|
|
21
|
+
- (Nothing recorded yet. When someone corrects how something looks or reads, add the rule here.)
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
// Returns true when the starter was written (or would be, in a dry run).
|
|
25
|
+
function ensureProjectTasteFile(atrisDir, { dryRun = false } = {}) {
|
|
26
|
+
const file = path.join(atrisDir, PROJECT_TASTE_FILE);
|
|
27
|
+
if (fs.existsSync(file)) return false;
|
|
28
|
+
if (!dryRun) {
|
|
29
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
30
|
+
fs.writeFileSync(file, STARTER, { flag: 'wx' });
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { ensureProjectTasteFile };
|
package/lib/team-lineup.js
CHANGED
|
@@ -19,15 +19,15 @@ function orderJobs(rows) {
|
|
|
19
19
|
return [...builtIn, ...list.filter((row) => !builtIn.includes(row))];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
//
|
|
22
|
+
// Friendly label for a claude model, "<tool> <model>" for anything else, and
|
|
23
23
|
// just the tool when it runs its own default.
|
|
24
24
|
function shortRuns(view) {
|
|
25
25
|
if (!view || !view.engine) return '';
|
|
26
26
|
const tool = rosterToolLabel(view.engine);
|
|
27
27
|
if (!view.model) return tool;
|
|
28
28
|
const label = modelLabel(view.model);
|
|
29
|
-
// "atris:fast" on atris fast says the tool twice;
|
|
30
|
-
// already names it.
|
|
29
|
+
// "atris:fast" on atris fast says the tool twice; a label starting with the
|
|
30
|
+
// tool name already names it.
|
|
31
31
|
const plain = (text) => String(text).toLowerCase().replace(/[^a-z0-9.]+/g, ' ').trim();
|
|
32
32
|
if (plain(label) === plain(tool)) return tool;
|
|
33
33
|
if (CLAUDE_FAMILY.includes(view.engine) || plain(label).startsWith(`${plain(tool)} `)) return label;
|
package/lib/todo.js
CHANGED
|
@@ -61,6 +61,8 @@ function dbBuckets(workspaceRoot) {
|
|
|
61
61
|
if (r.status === 'open') backlog.push(shaped);
|
|
62
62
|
else if (r.status === 'claimed') inProgress.push(shaped);
|
|
63
63
|
else if (r.status === 'review') review.push(shaped);
|
|
64
|
+
// `failed` is the stored name for a job TODO.md shows under "Blocked" (see renderTodoMarkdown
|
|
65
|
+
// in lib/task-db.js); this shim has no blocked bucket, so it files them with the finished rows.
|
|
64
66
|
else if (r.status === 'done' || r.status === 'failed') completed.push(shaped);
|
|
65
67
|
}
|
|
66
68
|
return { backlog, inProgress, review, completed, sourceKeys, sourceRefs };
|
package/lib/wiki.js
CHANGED
|
@@ -71,12 +71,17 @@ Turn raw project context into a living memory the next agent can pick up cold.
|
|
|
71
71
|
- Add cross-references with \`[[${wikiLinkRoot}/...]]\` links.
|
|
72
72
|
- Keep \`index.md\`, \`log.md\`, and \`STATUS.md\` in sync with page changes.
|
|
73
73
|
- If something is unclear or contradictory, say so directly.
|
|
74
|
+
- One idea per page, under about 80 lines, with real examples right under the rule they prove.
|
|
75
|
+
- Each \`index.md\` line gives the rule or answer and says when to open the page. Never a bare topic name.
|
|
76
|
+
- Keep \`index.md\` to one screen. Move text instead of rewriting it, and list anything deleted in \`log.md\`.
|
|
74
77
|
`;
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
function indexMarkdown() {
|
|
78
81
|
return `# Atris Wiki Index
|
|
79
82
|
|
|
83
|
+
Each line gives the rule or answer and says when to open the page behind it.
|
|
84
|
+
|
|
80
85
|
## People
|
|
81
86
|
|
|
82
87
|
## Systems
|
|
@@ -874,7 +879,9 @@ Rules:
|
|
|
874
879
|
- Merge new info into existing pages instead of replacing them
|
|
875
880
|
- Keep index.md, log.md, and STATUS.md current
|
|
876
881
|
- Flag contradictions directly instead of smoothing them over
|
|
877
|
-
- Never modify the raw source documents you ingested
|
|
882
|
+
- Never modify the raw source documents you ingested
|
|
883
|
+
- One idea per page, under about 80 lines, with examples under the rule they prove
|
|
884
|
+
- An index line gives the rule or answer, then says when to open the page`;
|
|
878
885
|
}
|
|
879
886
|
|
|
880
887
|
function buildIngestPrompt(sourceValue, mode = 'public') {
|
|
@@ -889,7 +896,7 @@ Workflow:
|
|
|
889
896
|
3. Extract people, systems, and concepts worth preserving
|
|
890
897
|
4. Create or update pages under ${wikiRoot}/, merging with existing facts instead of replacing them
|
|
891
898
|
5. Add cross-references using [[${wikiLinkRoot}/...]] links
|
|
892
|
-
6. Update ${wikiRoot}/index.md with one
|
|
899
|
+
6. Update ${wikiRoot}/index.md with one line per touched page: the rule or answer, then when to open it
|
|
893
900
|
7. Append an INGEST entry to ${wikiRoot}/log.md under today's date
|
|
894
901
|
8. Refresh ${wikiRoot}/STATUS.md in plain English for a non-technical reader
|
|
895
902
|
|
|
@@ -945,7 +952,8 @@ Keep rules:
|
|
|
945
952
|
- Entries marked as user-stated conventions or explicit remember-this survive.
|
|
946
953
|
- Every kept entry keeps its provenance as a source link or date.
|
|
947
954
|
|
|
948
|
-
Consolidate overlapping entries, preserve the strongest source-backed version, and leave index.md and page links consistent
|
|
955
|
+
Consolidate overlapping entries, preserve the strongest source-backed version, and leave index.md and page links consistent.
|
|
956
|
+
Split any page over about 80 lines or holding two ideas. Rewrite bare topic lines in index.md so each says when to open the page.`;
|
|
949
957
|
}
|
|
950
958
|
|
|
951
959
|
module.exports = {
|