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
package/lib/intents.js CHANGED
@@ -8,6 +8,13 @@ const INTENTS = Object.freeze([
8
8
  plain: 'your agent picks the most useful next step and starts it.',
9
9
  offer: 'boot_empty',
10
10
  }),
11
+ Object.freeze({
12
+ id: 'prioritize_work',
13
+ say: Object.freeze(['whats the most important work to work on', 'what should we work on next', 'what is the highest priority work', 'help me choose what to work on']),
14
+ do: 'atris task next --json',
15
+ plain: 'your agent reads the current goal, existing parts and recent evidence before recommending the next work.',
16
+ offer: 'always',
17
+ }),
11
18
  Object.freeze({
12
19
  id: 'keep_going',
13
20
  say: Object.freeze(['keep going while i\'m away', 'run overnight', 'finish this', 'keep working without me']),
package/lib/json-file.js CHANGED
@@ -34,4 +34,25 @@ function writeJson(filePath, value, { indent = 2 } = {}) {
34
34
  return filePath;
35
35
  }
36
36
 
37
- module.exports = { readJson, writeJson };
37
+ /**
38
+ * Write text to a file by way of a temp file in the same folder and a rename, so a
39
+ * reader never sees half a file and two writers never interleave: the last rename wins
40
+ * whole. Creates parent dirs. The temp file is removed if the write fails.
41
+ * @param {string} filePath
42
+ * @param {string} text
43
+ * @returns {string} the path written
44
+ */
45
+ function writeFileAtomic(filePath, text) {
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ const tmp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);
48
+ try {
49
+ fs.writeFileSync(tmp, text, 'utf8');
50
+ fs.renameSync(tmp, filePath);
51
+ } catch (err) {
52
+ try { fs.rmSync(tmp, { force: true }); } catch { /* nothing left to clean */ }
53
+ throw err;
54
+ }
55
+ return filePath;
56
+ }
57
+
58
+ module.exports = { readJson, writeJson, writeFileAtomic };
@@ -127,6 +127,20 @@ function walk(value, visit, trail = []) {
127
127
  });
128
128
  }
129
129
 
130
+ function tickSignalIdentity(receipt, value) {
131
+ if (!value || typeof value !== 'object') return null;
132
+ const missionId = String((receipt && receipt.mission_id) || value.mission_id || '').trim();
133
+ const briefId = String((value.claude && value.claude.brief_id) || value.brief_id || '').trim();
134
+ if (briefId) return `brief:${missionId}:${briefId}`;
135
+ const hasTickIndex = value.tick_index != null && value.tick_index !== '';
136
+ const startedAt = typeof value.started_at === 'string' ? value.started_at.trim() : '';
137
+ const finishedAt = typeof value.finished_at === 'string' ? value.finished_at.trim() : '';
138
+ if (hasTickIndex && (startedAt || finishedAt)) {
139
+ return `tick:${missionId}:${value.tick_index}:${startedAt}:${finishedAt}`;
140
+ }
141
+ return null;
142
+ }
143
+
130
144
  function receiptFailureSignals(entry) {
131
145
  const signals = [];
132
146
  walk(entry.receipt, (value, trail) => {
@@ -137,7 +151,7 @@ function receiptFailureSignals(entry) {
137
151
  // node carries none.
138
152
  const signalTs = objectTimestamp(value) || entry.ts;
139
153
  if (typeof value.pause_reason === 'string' && value.pause_reason.trim()) {
140
- signals.push({ type: 'pause_reason', value: value.pause_reason.trim(), trail, ts: signalTs });
154
+ signals.push({ type: 'pause_reason', value: value.pause_reason.trim(), trail, ts: signalTs, signalId: null });
141
155
  }
142
156
  const status = String(value.status || '').toLowerCase();
143
157
  const tickContext = trail.some((part) => part === 'tick' || part === 'ticks');
@@ -148,7 +162,15 @@ function receiptFailureSignals(entry) {
148
162
  if (!errored) return;
149
163
  const detail = value.error || value.error_message || value.reason || value.message || status;
150
164
  const text = typeof detail === 'string' ? detail.trim() : JSON.stringify(detail);
151
- if (text) signals.push({ type: 'tick_error', value: text, trail, ts: signalTs });
165
+ if (text) {
166
+ signals.push({
167
+ type: 'tick_error',
168
+ value: text,
169
+ trail,
170
+ ts: signalTs,
171
+ signalId: tickSignalIdentity(entry.receipt, value),
172
+ });
173
+ }
152
174
  });
153
175
  const unique = new Map();
154
176
  for (const signal of signals) unique.set(`${signal.type}\0${signal.value}`, signal);
@@ -157,10 +179,16 @@ function receiptFailureSignals(entry) {
157
179
 
158
180
  function repeatedFailureFindings(receipts, nowIso, freshnessMs) {
159
181
  const groups = new Map();
182
+ const seenSignalIds = new Map();
160
183
  for (const entry of receipts) {
161
184
  for (const signal of receiptFailureSignals(entry)) {
162
185
  const key = `${signal.type}\0${signal.value}`;
163
186
  if (!groups.has(key)) groups.set(key, []);
187
+ if (signal.signalId) {
188
+ const dedupKey = `${key}\0${signal.signalId}`;
189
+ if (seenSignalIds.has(dedupKey)) continue;
190
+ seenSignalIds.set(dedupKey, true);
191
+ }
164
192
  groups.get(key).push({ ts: signal.ts || entry.ts, receipt: path.basename(entry.file), trail: signal.trail });
165
193
  }
166
194
  }
@@ -267,10 +295,16 @@ function isOpenMission(mission) {
267
295
  return mission && !TERMINAL_MISSION_STATUSES.has(String(mission.status || 'planning').toLowerCase());
268
296
  }
269
297
 
298
+ function isLoopDoctorRepairMission(mission) {
299
+ const objective = String(mission && mission.objective || '').trim();
300
+ return /^\[loop-doctor:[^\]]+\]/i.test(objective);
301
+ }
302
+
270
303
  function isDueCapableMission(mission) {
271
304
  const cadence = String(mission && mission.cadence || 'manual').trim().toLowerCase();
272
305
  const runner = String(mission && mission.runner || 'manual').trim().toLowerCase();
273
306
  return isOpenMission(mission)
307
+ && !isLoopDoctorRepairMission(mission)
274
308
  && cadence !== 'manual'
275
309
  && cadence !== ''
276
310
  && mission.always_on === true
package/lib/map-refs.js CHANGED
@@ -194,16 +194,32 @@ function fixMapRefs(root, mapFile = 'atris/MAP.md') {
194
194
  }
195
195
 
196
196
  // The short routing map loads at boot; exact line refs live in the notes file.
197
- // Both are checked and fixed, so moving a ref out of the boot map never hides it.
197
+ // A workspace may split the notes into one file per area under atris/refs/map/,
198
+ // with MAP-NOTES.md left as their index. Every one is checked and fixed, so
199
+ // moving a ref out of the boot map or into an area note never hides it.
198
200
  const MAP_REF_DOCS = ['atris/MAP.md', 'atris/refs/MAP-NOTES.md'];
201
+ const MAP_NOTES_DIR = 'atris/refs/map';
202
+
203
+ function isFile(file) {
204
+ try { return fs.statSync(file).isFile(); } catch (error) {
205
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ function areaNoteDocs(root) {
211
+ let names;
212
+ try { names = fs.readdirSync(path.resolve(root, MAP_NOTES_DIR)); } catch (error) {
213
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return [];
214
+ throw error;
215
+ }
216
+ return names.filter(name => name.endsWith('.md')).sort()
217
+ .map(name => `${MAP_NOTES_DIR}/${name}`)
218
+ .filter(doc => isFile(path.resolve(root, doc)));
219
+ }
199
220
 
200
221
  function existingMapDocs(root) {
201
- return MAP_REF_DOCS.filter(doc => {
202
- try { return fs.statSync(path.resolve(root, doc)).isFile(); } catch (error) {
203
- if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
204
- throw error;
205
- }
206
- });
222
+ return [...MAP_REF_DOCS.filter(doc => isFile(path.resolve(root, doc))), ...areaNoteDocs(root)];
207
223
  }
208
224
 
209
225
  // Boot scores the docs every session, and checking every ref means reading
@@ -284,9 +300,53 @@ function fixMapDocs(root) {
284
300
  return { changes, left };
285
301
  }
286
302
 
303
+ // Lines from the deep map notes that share words with a query, for prompts that
304
+ // cannot open files themselves. With area notes, the index only describes them,
305
+ // so the notes are read instead. Notes are ranked by how many query words they
306
+ // match, and the budget fills from the best note first, its best lines first.
307
+ function mapNotesExcerpt(root, query, maxChars) {
308
+ const words = [...new Set(String(query || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length >= 4))];
309
+ if (!words.length || !(maxChars > 0)) return '';
310
+ // The notes file is read too while it still holds real notes, so a project
311
+ // that has moved only some of them into area notes keeps the rest.
312
+ const notes = existingMapDocs(root).filter(doc => doc !== MAP_REF_DOCS[0])
313
+ .filter(doc => doc !== MAP_REF_DOCS[1] || !areaNoteDocs(root).length || holdsRealNotes(fs.readFileSync(path.resolve(root, doc), 'utf8')));
314
+ const ranked = notes.map((doc, order) => {
315
+ const lines = fs.readFileSync(path.resolve(root, doc), 'utf8').split(/\r?\n/)
316
+ .map((line, index) => ({ line, index, hits: words.filter(w => line.toLowerCase().includes(w)).length }))
317
+ .filter(row => row.hits > 0)
318
+ .sort((a, b) => b.hits - a.hits || a.index - b.index);
319
+ const matched = new Set(words.filter(w => lines.some(row => row.line.toLowerCase().includes(w))));
320
+ return { doc, order, lines, matched: matched.size, total: lines.reduce((sum, row) => sum + row.hits, 0) };
321
+ }).filter(note => note.lines.length)
322
+ .sort((a, b) => b.matched - a.matched || b.total - a.total || a.order - b.order);
323
+ const out = [];
324
+ let used = 0;
325
+ for (const note of ranked) {
326
+ for (const row of note.lines) {
327
+ // Lines are joined by one newline, so only lines after the first pay for it.
328
+ const room = maxChars - used - (out.length ? 1 : 0);
329
+ if (room <= 0) return out.join('\n');
330
+ if (row.line.length > room) {
331
+ out.push(row.line.slice(0, room));
332
+ return out.join('\n');
333
+ }
334
+ out.push(row.line);
335
+ used += row.line.length + (out.length > 1 ? 1 : 0);
336
+ }
337
+ }
338
+ return out.join('\n');
339
+ }
340
+
341
+ // An index line links into atris/refs/map/; a real note names code or a search.
342
+ function holdsRealNotes(text) {
343
+ return text.split(/\r?\n/).some(line => !line.includes('](map/')
344
+ && (/`[\w./-]+\.(?:js|mjs|cjs|ts|py|sh|json)(?::\d+(?:-\d+)?)?`/.test(line) || /^\s*rg "/.test(line)));
345
+ }
346
+
287
347
  // Repo-relative path of the notes file when this workspace has one, else null.
288
348
  function mapNotesPath(root) {
289
349
  return existingMapDocs(root).includes(MAP_REF_DOCS[1]) ? MAP_REF_DOCS[1] : null;
290
350
  }
291
351
 
292
- module.exports = { MAP_REF_DOCS, existingMapDocs, mapNotesPath, checkMapRefs, checkMapDocs, fixMapRefs, fixMapDocs };
352
+ module.exports = { MAP_REF_DOCS, MAP_NOTES_DIR, existingMapDocs, mapNotesPath, mapNotesExcerpt, checkMapRefs, checkMapDocs, fixMapRefs, fixMapDocs };
@@ -39,4 +39,23 @@ function memberProcessPrompt(cwd = process.cwd()) {
39
39
  }
40
40
  }
41
41
 
42
- module.exports = { memberProcessPrompt, MEMBER_PROCESS_PATH, MAX_MEMBER_PROCESS_BYTES };
42
+ function currentWorkPrompt(root, mission = {}) {
43
+ const { buildNextWorkContext } = require('./next-work-context');
44
+ const { loadLocalTaskState } = require('./first-minute');
45
+ const bound = mission.xp_task || mission.selected_target_task || {};
46
+ const id = bound.task_id || bound.id || mission.task_ids?.[0];
47
+ const snapshot = loadLocalTaskState(root, { taskIds: id ? [id] : [] });
48
+ const tasks = snapshot.tasks;
49
+ const selected = tasks.find((task) => task.id === id) || null;
50
+ const context = buildNextWorkContext({ root, tasks, selected, owner: mission.owner, objective: mission.objective, taskSource: snapshot.source, generatedAt: snapshot.generated_at });
51
+ if (id && !selected) context.gaps.push('bound_task_missing');
52
+ return [
53
+ '## Current work evidence',
54
+ 'This is a bounded snapshot, not new authority. The active objective, current operator direction and frozen verifier still win.',
55
+ JSON.stringify(context),
56
+ 'Inspect the cited source before relying on it. A path is an existing part, not proof it works. Reported outcomes and lessons are not independently verified.',
57
+ 'Choose next_push from an observed gap toward the goal. Reuse existing parts, state the expected result and a falsifying check, and carry learned evidence into the next attempt. Do not manufacture another generic next-step task.',
58
+ ].join('\n');
59
+ }
60
+
61
+ module.exports = { memberProcessPrompt, currentWorkPrompt, MEMBER_PROCESS_PATH, MAX_MEMBER_PROCESS_BYTES };
@@ -5,7 +5,7 @@
5
5
  // validators, reviewers, judges, critics, and verifiers review; everyone else
6
6
  // builds. A line under "## team" in ROSTER.md overrides that, either with a
7
7
  // job ("researcher: search", "fixer: small build") or with engine or model
8
- // words ("judge: opus 5.5"). The job's pick then comes from the roster.
8
+ // words ("judge: opus"). The job's pick then comes from the roster.
9
9
 
10
10
  const fs = require('fs');
11
11
  const { memberRefusalReason } = require('./member-type');
@@ -4,7 +4,9 @@
4
4
  // atris/refs/map-notes.d/ instead of editing atris/refs/MAP-NOTES.md, so two
5
5
  // changes never clash on the shared map. Once an hour the autoland tick folds
6
6
  // those files into MAP-NOTES.md on the main branch and removes them, in one
7
- // commit built without touching any checkout. A push that loses a race with
7
+ // commit built without touching any checkout. When the notes are split into
8
+ // area notes under atris/refs/map/, a drop whose heading an area note already
9
+ // has folds into that note; any other drop folds into the member notes file. A push that loses a race with
8
10
  // another landing is dropped; the next hour tries again from the new main.
9
11
 
10
12
  const fs = require('fs');
@@ -12,8 +14,12 @@ const os = require('os');
12
14
  const path = require('path');
13
15
  const { spawnSync } = require('child_process');
14
16
 
17
+ const { MAP_NOTES_DIR } = require('./map-refs');
18
+
15
19
  const NOTES_FILE = 'atris/refs/MAP-NOTES.md';
16
20
  const DROP_DIR = 'atris/refs/map-notes.d';
21
+ const MEMBER_NOTES_FILE = `${MAP_NOTES_DIR}/member-notes.md`;
22
+ const MEMBER_NOTES_HEAD = '# Member notes\n\nMap notes that member changes added and no area has claimed yet. Move each one into the area note that owns its code. Back to [MAP-NOTES.md](../MAP-NOTES.md).\n';
17
23
  const FOLD_EVERY_MS = 60 * 60 * 1000;
18
24
 
19
25
  function git(args, cwd, extraEnv = {}) {
@@ -71,6 +77,34 @@ function foldNotesText(notes, drops) {
71
77
  return { text: lines.length ? `${lines.join('\n')}\n` : '', added };
72
78
  }
73
79
 
80
+ // Which notes file each drop folds into. Without area notes every drop goes to
81
+ // MAP-NOTES.md as before.
82
+ function foldTargets(root, baseSha, drops) {
83
+ const read = (file) => {
84
+ const blob = git(['cat-file', 'blob', `${baseSha}:${file}`], root);
85
+ return blob.ok ? blob.stdout : null;
86
+ };
87
+ const listed = git(['ls-tree', '--name-only', baseSha, '--', `${MAP_NOTES_DIR}/`], root).stdout
88
+ .split('\n').filter((f) => f.endsWith('.md')).sort();
89
+ const targets = new Map();
90
+ const put = (file, text, drop) => {
91
+ if (!targets.has(file)) targets.set(file, { text, drops: [] });
92
+ targets.get(file).drops.push(drop);
93
+ };
94
+ if (!listed.length) {
95
+ for (const drop of drops) put(NOTES_FILE, read(NOTES_FILE) || '', drop);
96
+ return targets;
97
+ }
98
+ const notes = listed.map((file) => ({ file, text: read(file) || '' }));
99
+ for (const drop of drops) {
100
+ const first = trimEnd(drop.text).split('\n').find((l) => l.trim()) || '';
101
+ const owner = isHeading(first) && notes.find((note) => note.text.split('\n').some((l) => l.trim() === first.trim()));
102
+ if (owner) put(owner.file, owner.text, drop);
103
+ else put(MEMBER_NOTES_FILE, read(MEMBER_NOTES_FILE) || MEMBER_NOTES_HEAD, drop);
104
+ }
105
+ return targets;
106
+ }
107
+
74
108
  function mainBranch(root, remote) {
75
109
  const head = git(['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], root).stdout.trim();
76
110
  return head.startsWith(`${remote}/`) ? head.slice(remote.length + 1) : 'master';
@@ -96,19 +130,23 @@ function foldMapNotesOnMain(root, { base = '', remote = 'origin', push = true }
96
130
  if (!blob.ok) return { folded: 0, reason: 'drop_unreadable', detail: file };
97
131
  drops.push({ file, text: blob.stdout });
98
132
  }
99
- const current = git(['cat-file', 'blob', `${baseSha}:${NOTES_FILE}`], root);
100
- const { text, added } = foldNotesText(current.ok ? current.stdout : '', drops);
101
-
133
+ const targets = foldTargets(root, baseSha, drops);
134
+ let added = 0;
102
135
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-map-fold-'));
103
136
  try {
104
137
  const index = { GIT_INDEX_FILE: path.join(tmp, 'index') };
105
- const notesPath = path.join(tmp, 'notes');
106
- fs.writeFileSync(notesPath, text);
107
- const blob = git(['hash-object', '-w', notesPath], root).stdout.trim();
108
- const built = git(['read-tree', baseSha], root, index).ok
109
- && /^[0-9a-f]{40,64}$/.test(blob)
110
- && git(['update-index', '--add', '--cacheinfo', `100644,${blob},${NOTES_FILE}`], root, index).ok
111
- && git(['update-index', '--force-remove', '--', ...files], root, index).ok;
138
+ let built = git(['read-tree', baseSha], root, index).ok;
139
+ for (const [target, { text: currentText, drops: targetDrops }] of targets) {
140
+ if (!built) break;
141
+ const folded = foldNotesText(currentText, targetDrops);
142
+ added += folded.added;
143
+ const notesPath = path.join(tmp, 'notes');
144
+ fs.writeFileSync(notesPath, folded.text);
145
+ const blob = git(['hash-object', '-w', notesPath], root).stdout.trim();
146
+ built = /^[0-9a-f]{40,64}$/.test(blob)
147
+ && git(['update-index', '--add', '--cacheinfo', `100644,${blob},${target}`], root, index).ok;
148
+ }
149
+ built = built && git(['update-index', '--force-remove', '--', ...files], root, index).ok;
112
150
  const tree = built ? git(['write-tree'], root, index).stdout.trim() : '';
113
151
  const message = `Fold ${files.length} map ${files.length === 1 ? 'note' : 'notes'} from member changes into the shared map`;
114
152
  const commit = tree ? git(['commit-tree', tree, '-p', baseSha, '-m', message], root).stdout.trim() : '';
@@ -13,7 +13,8 @@ const path = require('path');
13
13
  const { spawnSync } = require('child_process');
14
14
 
15
15
  const INBOX_DIR = 'atris/refs/map-inbox';
16
- const SHARED_MAP = /(?:^|\/)MAP(?:-NOTES)?\.md$/;
16
+ // The area notes that MAP-NOTES.md may index are shared map files too.
17
+ const SHARED_MAP = /(?:^|\/)MAP(?:-NOTES)?\.md$|^atris\/refs\/map\/[^/]+\.md$/;
17
18
 
18
19
  function run(args, cwd, extraEnv = {}, { raw = false } = {}) {
19
20
  const env = { ...process.env, ...extraEnv, GIT_NO_REPLACE_OBJECTS: '1' };
@@ -38,7 +38,7 @@ const {
38
38
  shortRecordLabel,
39
39
  shortRecordRef,
40
40
  } = require('./short-name');
41
- const { readJson, writeJson } = require('./json-file');
41
+ const { readJson, writeJson, writeFileAtomic } = require('./json-file');
42
42
 
43
43
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
44
44
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
@@ -827,10 +827,17 @@ function loadTaskDb(asJson = false) {
827
827
  }
828
828
  }
829
829
 
830
+ // The projection has one writer and one shape: `atris task`'s writeDefaultProjection, which
831
+ // enriches the rows and writes through a temp file and a rename. Writing the bare rows here
832
+ // left readers a different shape depending on which command ran last. The fallback (only if
833
+ // the task command cannot load) still writes atomically.
830
834
  function writeMissionTaskProjection(taskDb, db, workspaceRoot) {
835
+ let writer = null;
836
+ try { writer = require('../commands/task').writeDefaultProjection; } catch { writer = null; }
837
+ if (typeof writer === 'function') return writer(taskDb, db, { root: workspaceRoot });
831
838
  const projection = taskDb.taskProjection(db, { workspaceRoot, limit: 500 });
832
839
  const outPath = path.join(workspaceRoot, '.atris', 'state', 'tasks.projection.json');
833
- writeJson(outPath, projection);
840
+ writeFileAtomic(outPath, JSON.stringify(projection, null, 2) + '\n');
834
841
  return { projection, outPath };
835
842
  }
836
843
 
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ // A bounded, read-only handoff of facts for the next decision. Ranking and
4
+ // execution stay with the existing picker, member and verification gates.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { meaningfulWords } = require('./short-name');
8
+
9
+ function clip(value, max = 360) {
10
+ const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
11
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
12
+ }
13
+
14
+ function localFile(root, name) {
15
+ try {
16
+ const base = fs.realpathSync(root);
17
+ const file = fs.realpathSync(path.resolve(base, name));
18
+ const rel = path.relative(base, file);
19
+ if (rel.startsWith(`..${path.sep}`) || rel === '..' || path.isAbsolute(rel)) return null;
20
+ return fs.statSync(file).isFile() ? file : null;
21
+ } catch { return null; }
22
+ }
23
+
24
+ function readSource(root, name, maxBytes) {
25
+ const file = localFile(root, name);
26
+ if (!file) return { path: name, text: '', unavailable: true };
27
+ let fd;
28
+ try {
29
+ fd = fs.openSync(file, 'r');
30
+ const stat = fs.fstatSync(fd);
31
+ const buffer = Buffer.alloc(Math.min(stat.size, maxBytes));
32
+ const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
33
+ return { path: name, text: buffer.subarray(0, count).toString('utf8'), truncated: stat.size > maxBytes, modified_at: stat.mtime.toISOString() };
34
+ } catch { return { path: name, text: '', unavailable: true }; }
35
+ finally { if (fd !== undefined) fs.closeSync(fd); }
36
+ }
37
+
38
+ // Keep the offline projection fallback, but a known row's current database
39
+ // status always wins. Include finished rows so their outcomes are not lost.
40
+ function reconcileTaskRows(rows, projected, lookup = () => null) {
41
+ const known = new Map(rows.map((row) => [row.id, row]));
42
+ const seen = new Set();
43
+ const merged = [];
44
+ for (const row of projected) {
45
+ if (!row || typeof row !== 'object') continue;
46
+ // Older offline snapshots have only a display reference. Preserve that
47
+ // evidence without pretending it has a database identity or authority.
48
+ if (!row.id) { merged.push(row); continue; }
49
+ if (seen.has(row.id)) continue;
50
+ seen.add(row.id);
51
+ const current = known.get(row.id) || lookup(row.id);
52
+ if (current !== false) merged.push(current || row);
53
+ }
54
+ return [...merged, ...rows.filter((row) => !seen.has(row.id))];
55
+ }
56
+
57
+ function matchingParts(root, map, query) {
58
+ const wanted = new Set(meaningfulWords(query, 100));
59
+ const found = new Map();
60
+ map.text.split(/\r?\n/).forEach((line, index) => {
61
+ if (!line.startsWith('|')) return;
62
+ const score = meaningfulWords(line, 100).filter((word) => wanted.has(word)).length;
63
+ if (!score) return;
64
+ for (const match of line.matchAll(/`([^`]+)`/g)) {
65
+ const name = match[1].replace(/:\d+$/, '');
66
+ if (path.isAbsolute(name) || !localFile(root, name) || found.has(name)) continue;
67
+ found.set(name, { path: name, source: `${map.path}:${index + 1}`, description: clip(line.replace(/`/g, ''), 180), score });
68
+ }
69
+ });
70
+ return [...found.values()].sort((a, b) => b.score - a.score).slice(0, 5)
71
+ .map(({ score, ...part }) => part);
72
+ }
73
+
74
+ function recentOutcomes(tasks, selected) {
75
+ return tasks.filter((task) => ['done', 'failed', 'review'].includes(task.status))
76
+ .sort((a, b) => Number(b.id === selected?.id) - Number(a.id === selected?.id)
77
+ || Number(b.updated_at || 0) - Number(a.updated_at || 0))
78
+ .slice(0, 3).map((task) => {
79
+ const meta = task.metadata || {};
80
+ return {
81
+ task_id: clip(task.id, 120),
82
+ title: clip(task.title, 160),
83
+ status: task.status,
84
+ result: clip(task.result || meta.result),
85
+ lesson: clip(task.review?.lesson || meta.latest_agent_lesson),
86
+ next: clip(task.review?.next_task || meta.latest_agent_next_task),
87
+ proof: clip(task.review?.proof || meta.latest_agent_proof),
88
+ validation: task.status === 'failed' ? 'failed' : task.status === 'review' ? 'review_pending'
89
+ : meta.accepted_by && meta.accepted_at ? 'accepted' : 'reported',
90
+ };
91
+ });
92
+ }
93
+
94
+ function otherOwner(task, owner) {
95
+ const assigned = typeof task.metadata?.assigned_to === 'string' ? task.metadata.assigned_to.trim() : '';
96
+ return Boolean((task.claimed_by && task.claimed_by !== owner) || (assigned && assigned !== owner));
97
+ }
98
+
99
+ function candidateEvidence(tasks, selected, goal, owner) {
100
+ const words = new Set(meaningfulWords(goal?.text, 100));
101
+ const { heldForPerson } = require('./member-task-pick');
102
+ return tasks.filter((task) => task.id !== selected?.id && ['open', 'claimed', 'review'].includes(task.status))
103
+ .map((task) => {
104
+ const meta = task.metadata || {};
105
+ const why = clip(meta.why_it_matters || task.explanation?.why_it_matters, 240);
106
+ const overlap = new Set(meaningfulWords(`${task.title} ${meta.objective || ''} ${why}`, 100).filter((word) => words.has(word))).size;
107
+ return {
108
+ task_id: clip(task.id, 120), title: clip(task.title, 160), status: task.status,
109
+ owner: clip(task.claimed_by || meta.assigned_to, 80) || null,
110
+ why: why || null,
111
+ held: heldForPerson(task) || (otherOwner(task, owner) ? 'other_owner' : null),
112
+ goal_word_overlap: overlap,
113
+ };
114
+ }).sort((a, b) => b.goal_word_overlap - a.goal_word_overlap).slice(0, 3);
115
+ }
116
+
117
+ function buildNextWorkContext({ root, tasks = [], selected = null, owner = '', taskSource = 'projection', objective = '', generatedAt = null }) {
118
+ const scoreboard = readSource(root, '.atris/state/scoreboard.json', 16384);
119
+ const map = readSource(root, 'atris/MAP.md', 65536);
120
+ let goal = null;
121
+ try {
122
+ const value = scoreboard.truncated ? null : JSON.parse(scoreboard.text);
123
+ if (clip(value?.goal)) goal = { text: clip(value.goal, 600), source: scoreboard.path, updated_at: typeof value.updated_at === 'string' ? clip(value.updated_at, 80) : null };
124
+ } catch { /* Missing or unreadable knowledge stays unknown. */ }
125
+ const meta = selected?.metadata || {};
126
+ const rawCheck = typeof (meta.verify || meta.verifier) === 'string' ? (meta.verify || meta.verifier).trim() : '';
127
+ // Never offer an ellipsis as an executable command.
128
+ const check = rawCheck.length <= 600 ? rawCheck : '';
129
+ const { taskCheck, heldForPerson } = require('./member-task-pick');
130
+ const runnable = check && taskCheck({ metadata: { verify: check } }).runnable;
131
+ const parts = matchingParts(root, map, [objective, selected?.title, meta.objective, goal?.text].filter(Boolean).join(' '));
132
+ const gaps = [];
133
+ if (!goal) gaps.push('current_goal_missing');
134
+ if (taskSource === 'none') gaps.push('task_state_missing');
135
+ if (scoreboard.truncated) gaps.push('goal_source_truncated');
136
+ if (map.unavailable) gaps.push('parts_map_missing');
137
+ else if (!parts.length) gaps.push('matching_parts_missing');
138
+ if (map.truncated) gaps.push('parts_map_truncated');
139
+ if (selected && !runnable) gaps.push('runnable_check_missing');
140
+ if (rawCheck.length > 600) gaps.push('check_too_large_for_snapshot');
141
+ if (taskSource === 'projection' || taskSource === 'mixed') gaps.push('task_snapshot_not_authoritative');
142
+ if (generatedAt && Date.now() - Date.parse(generatedAt) > 3 * 86400000) gaps.push('task_snapshot_stale');
143
+ const why = clip(meta.why_it_matters || selected?.explanation?.why_it_matters);
144
+ if (selected && !why) gaps.push('reason_missing');
145
+ let kind = selected && runnable ? 'execute' : 'discover';
146
+ if (gaps.includes('task_snapshot_not_authoritative') || gaps.includes('task_snapshot_stale')) kind = 'discover';
147
+ let reason = selected ? 'Inspect the relevant source and establish the missing evidence before changing it.'
148
+ : 'No selectable job is recorded. Find one observed gap toward the current goal, with a falsifying check.';
149
+ if (kind === 'execute') reason = why || 'Run the recorded check against the relevant source before claiming progress.';
150
+ if (selected && !['open', 'claimed', 'review'].includes(selected.status)) {
151
+ kind = 'discover'; reason = 'The bound task is no longer active. Inspect its recorded result before proposing another gap.';
152
+ gaps.push(selected.status === 'done' ? 'bound_task_already_finished' : 'bound_task_not_active');
153
+ } else if (selected && heldForPerson(selected)) { kind = 'human_decision'; reason = 'This work is held for a person by the existing task policy.'; }
154
+ else if (selected?.status === 'review') {
155
+ const certified = selected.review?.agent_certified || meta.agent_certified || Number(meta.agent_review_pass_count) >= 2;
156
+ kind = certified ? 'human_decision' : selected.claimed_by === owner ? 'wait' : 'review';
157
+ reason = certified ? 'The recorded work needs human acceptance.' : 'A different actor must check the finished diff before more work is chosen.';
158
+ } else if (selected && otherOwner(selected, owner)) {
159
+ kind = 'wait'; reason = 'Another owner holds this job. Do not duplicate their work.';
160
+ }
161
+ const context = {
162
+ schema: 'atris.next_work_context.v1',
163
+ workspace_root: path.resolve(root),
164
+ captured_at: new Date().toISOString(),
165
+ task_source: taskSource,
166
+ goal,
167
+ parts,
168
+ candidates: candidateEvidence(tasks, selected, goal, owner),
169
+ recent_outcomes: recentOutcomes(tasks, selected),
170
+ gaps,
171
+ next_step: { kind, reason, check: check || null },
172
+ sources: [scoreboard, map].map(({ text, ...source }) => source),
173
+ selection_basis: 'existing queue policy; candidate keyword overlap is a discovery hint, not measured business value',
174
+ };
175
+ // Keep the packet bounded even with long unicode text or unusual paths.
176
+ while (Buffer.byteLength(JSON.stringify(context)) > 10000) {
177
+ if (!gaps.includes('context_budget_limited')) gaps.push('context_budget_limited');
178
+ if (context.parts.length) context.parts.pop();
179
+ else if (context.candidates.length) context.candidates.pop();
180
+ else if (context.recent_outcomes.length) context.recent_outcomes.pop();
181
+ else {
182
+ context.next_step.check = null;
183
+ if (context.next_step.kind === 'execute') context.next_step.kind = 'discover';
184
+ break;
185
+ }
186
+ }
187
+ return context;
188
+ }
189
+
190
+ module.exports = { buildNextWorkContext, reconcileTaskRows };