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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -1
  3. package/atris/atris.md +14 -0
  4. package/atris/features/README.md +15 -0
  5. package/atris/policies/README.md +24 -0
  6. package/atris/skills/README.md +61 -45
  7. package/atris/skills/design/SKILL.md +6 -5
  8. package/atris/skills/taste/SKILL.md +39 -0
  9. package/atris/skills/taste/notes.md +29 -0
  10. package/atris/skills/taste/principles.md +23 -0
  11. package/atris/skills/wiki/SKILL.md +14 -2
  12. package/atris/skills/wiki/index-lines.md +30 -0
  13. package/atris/skills/wiki/measure.md +20 -0
  14. package/atris/skills/wiki/note-shape.md +16 -0
  15. package/atris/skills/wiki/upkeep.md +16 -0
  16. package/atris/team/README.md +38 -0
  17. package/atris.md +14 -0
  18. package/bin/atris.js +1 -1
  19. package/commands/activate.js +1 -1
  20. package/commands/autoland.js +25 -26
  21. package/commands/autopilot-front.js +46 -1
  22. package/commands/autopilot.js +55 -25
  23. package/commands/clean.js +22 -11
  24. package/commands/computer.js +1 -1
  25. package/commands/doc-health.js +48 -1
  26. package/commands/improve.js +16 -3
  27. package/commands/init.js +4 -0
  28. package/commands/integrations.js +69 -1616
  29. package/commands/mission.js +112 -14
  30. package/commands/release.js +41 -10
  31. package/commands/status.js +1 -1
  32. package/commands/sync.js +12 -8
  33. package/commands/task.js +150 -137
  34. package/commands/unknowns.js +9 -5
  35. package/commands/visualize.js +1 -12
  36. package/commands/workflow.js +4 -3
  37. package/commands/worktree.js +107 -1610
  38. package/commands/xp.js +23 -2113
  39. package/commands/youtube.js +192 -3110
  40. package/lib/business-core.js +3 -4
  41. package/lib/campaign-run-lock.js +12 -1
  42. package/lib/engine-ask.js +1 -1
  43. package/lib/engine-receipt-sweep.js +64 -0
  44. package/lib/file-lock.js +175 -0
  45. package/lib/first-minute.js +37 -4
  46. package/lib/fleet.js +1 -1
  47. package/lib/integrations-core.js +1695 -0
  48. package/lib/intents.js +7 -0
  49. package/lib/json-file.js +22 -1
  50. package/lib/loop-doctor.js +36 -2
  51. package/lib/map-refs.js +68 -8
  52. package/lib/member-context.js +20 -1
  53. package/lib/member-engine.js +1 -1
  54. package/lib/member-map-fold.js +49 -11
  55. package/lib/member-map-inbox.js +2 -1
  56. package/lib/mission-core.js +9 -2
  57. package/lib/next-work-context.js +190 -0
  58. package/lib/recall-exam.js +171 -0
  59. package/lib/roster-markdown.js +2 -2
  60. package/lib/runner-command.js +5 -5
  61. package/lib/task-db.js +107 -38
  62. package/lib/taste-notes.js +35 -0
  63. package/lib/team-lineup.js +3 -3
  64. package/lib/todo.js +2 -0
  65. package/lib/wiki.js +11 -3
  66. package/lib/worktree-core.js +2032 -0
  67. package/lib/xp-core.js +2172 -0
  68. package/lib/youtube-core.js +3178 -0
  69. package/lib/zen-apply.js +3 -1
  70. 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 };
@@ -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 5.5
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 5.5, backup codex
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.';
@@ -95,7 +95,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
95
95
  }),
96
96
  haiku: Object.freeze({
97
97
  bin: 'claude',
98
- model: 'claude-haiku-5-5',
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-4.6 went stale when the grok CLI moved its
113
- // own default to grok-4.7-build-fast, so an unpinned run rides the CLI.
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 3.8 flash (the 2026-09-04
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 like "opus 5.5" only becomes an id for the claude cli (or
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 result = withBusyRetry(() => db.prepare(`
1143
- UPDATE tasks
1144
- SET status = ?, done_at = ?, updated_at = ?
1145
- WHERE id = ?
1146
- AND status IN (${allowedStatuses})
1147
- `).run(final, now, now, id));
1148
- if (result.changes === 1) {
1149
- const row = getTask(db, id);
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: row.workspace_root,
1153
- actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
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
- const logs = appendTaskCompletionLogs(db, row, {
1162
- status: final,
1163
- actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
1164
- action: action || final,
1165
- proof,
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 metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1836
- metadata.result = text;
1837
- metadata.result_at = new Date(now).toISOString();
1838
- metadata.result_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1839
- const updated = withBusyRetry(() => db.prepare(`
1840
- UPDATE tasks
1841
- SET metadata = ?,
1842
- updated_at = ?
1843
- WHERE id = ?
1844
- `).run(JSON.stringify(metadata), now, id));
1845
- if (updated.changes !== 1) return { saved: false, reason: 'stale_task_state' };
1846
- const latest = getTask(db, id);
1847
- const event = appendTaskEvent(db, {
1848
- taskId: id,
1849
- workspaceRoot: latest.workspace_root,
1850
- actor: metadata.result_by,
1851
- eventType: 'result_set',
1852
- payload: { result: text },
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 };
@@ -19,15 +19,15 @@ function orderJobs(rows) {
19
19
  return [...builtIn, ...list.filter((row) => !builtIn.includes(row))];
20
20
  }
21
21
 
22
- // "opus 5.5" for a claude model, "codex gpt-6-astra" for anything else, and
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; "grok 4.7 fast" on grok
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-line descriptions of touched pages
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 = {