atris 3.68.1 → 3.68.3

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 (41) hide show
  1. package/atris/skills/engines/SKILL.md +1 -1
  2. package/atris/team/customer-lead/MEMBER.md +1 -1
  3. package/atris/team/executor/MEMBER.md +19 -18
  4. package/atris/team/executor/skills/isolated-slice-builder/SKILL.md +21 -0
  5. package/atris/team/navigator/MEMBER.md +3 -1
  6. package/atris/team/navigator/skills/loophole-proof-planner/SKILL.md +20 -0
  7. package/atris/team/researcher/MEMBER.md +2 -1
  8. package/atris/team/researcher/skills/grounded-evidence-brief/SKILL.md +19 -0
  9. package/atris/team/validator/MEMBER.md +2 -7
  10. package/atris/team/validator/skills/cross-model-referee/SKILL.md +20 -0
  11. package/bin/atris.js +40 -1
  12. package/commands/autoland.js +2 -1
  13. package/commands/done.js +374 -0
  14. package/commands/engine.js +2 -2
  15. package/commands/goal.js +61 -2
  16. package/commands/land.js +34 -7
  17. package/commands/member.js +1542 -41
  18. package/commands/mission.js +506 -18
  19. package/commands/task.js +26 -3
  20. package/commands/workflow.js +1 -1
  21. package/commands/worktree.js +144 -34
  22. package/lib/auto-accept-certified.js +7 -6
  23. package/lib/codex-flight.js +6 -12
  24. package/lib/conductor-artifacts.js +3 -1
  25. package/lib/default-runner.js +42 -7
  26. package/lib/engine-ask.js +8 -1
  27. package/lib/engine-registry.js +17 -0
  28. package/lib/falsifier-probe.js +6 -0
  29. package/lib/first-minute.js +257 -6
  30. package/lib/fleet.js +163 -88
  31. package/lib/known-commands.js +1 -1
  32. package/lib/member-alive.js +1 -1
  33. package/lib/member-approval.js +153 -0
  34. package/lib/mission-task-gate.js +170 -0
  35. package/lib/one-lap-validator.js +2 -1
  36. package/lib/roster-runs.js +28 -0
  37. package/lib/runner-command.js +32 -8
  38. package/lib/task-db.js +25 -1
  39. package/package.json +9 -2
  40. package/scripts/det/ytnotes +49 -10
  41. package/utils/backend-root.js +4 -0
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
3
+ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
@@ -130,7 +130,7 @@ function runAliveTick(name, opts = {}) {
130
130
  }
131
131
 
132
132
  const wake = runCli(atrisBin, [
133
- 'member', 'wake', name, '--json',
133
+ 'member', 'wake', name, '--json', '--plan',
134
134
  ...(opts.force ? ['--force'] : []),
135
135
  ], { cwd, timeoutMs: 120000 });
136
136
  tick.wake = wake;
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // MEMBER.md frontmatter can carry `approval-required: [merge, delete]` (the
7
+ // scaffold nests it under `permissions:`). Nothing in the CLI ever read it.
8
+ // This reader is deliberately tolerant: inline list, block list, nested or
9
+ // top-level, quotes optional. It returns [] for anything it cannot read, so a
10
+ // malformed file never blocks a mission, it just seeds no ask.
11
+
12
+ const MEMBER_SLUG = /^[a-zA-Z0-9._-]+$/;
13
+
14
+ function frontmatterOf(text) {
15
+ const match = String(text || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
16
+ return match ? match[1].split(/\r?\n/) : null;
17
+ }
18
+
19
+ function cleanItem(value) {
20
+ return String(value || '').trim().replace(/^["']|["']$/g, '').trim();
21
+ }
22
+
23
+ function parseApprovalRequired(text) {
24
+ const lines = frontmatterOf(text);
25
+ if (!lines) return [];
26
+ const index = lines.findIndex((line) => /^\s*approval-required\s*:/.test(line));
27
+ if (index < 0) return [];
28
+ const rest = lines[index].replace(/^\s*approval-required\s*:/, '').replace(/\s+#.*$/, '').trim();
29
+ let items = [];
30
+ if (rest.startsWith('[')) {
31
+ const end = rest.lastIndexOf(']');
32
+ items = rest.slice(1, end > 0 ? end : undefined).split(',');
33
+ } else if (rest) {
34
+ items = [rest];
35
+ } else {
36
+ const indent = lines[index].match(/^\s*/)[0].length;
37
+ for (let i = index + 1; i < lines.length; i += 1) {
38
+ const item = lines[i].match(/^(\s*)-\s+(.+)$/);
39
+ if (!item || item[1].length < indent) break;
40
+ items.push(item[2].replace(/\s+#.*$/, ''));
41
+ }
42
+ }
43
+ return Array.from(new Set(items.map(cleanItem).filter(Boolean)));
44
+ }
45
+
46
+ function memberApprovalRequired(owner, root = process.cwd()) {
47
+ const slug = String(owner || '').trim();
48
+ if (!slug || !MEMBER_SLUG.test(slug)) return [];
49
+ try {
50
+ const file = path.join(root, 'atris', 'team', slug, 'MEMBER.md');
51
+ if (!fs.existsSync(file)) return [];
52
+ return parseApprovalRequired(fs.readFileSync(file, 'utf8'));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ // Categories are action classes ("delete", "publish"), not mission-level
59
+ // gates. Seeding an ask for every listed category would stop every mission of
60
+ // a member whose template lists any (the shipped executor lists delete), so a
61
+ // category only seeds an ask when the mission objective names it. The head
62
+ // word of the category is matched as a stem: "delete" catches deleting/deletion,
63
+ // "destructive-file-action" catches destructive, "spend" catches spending.
64
+ function wordStem(word) {
65
+ const head = String(word || '').toLowerCase();
66
+ if (head.length < 3) return '';
67
+ return head.length > 4 ? head.replace(/(e|es|s|ing|ed)$/, '') : head;
68
+ }
69
+
70
+ function categoryStem(category) {
71
+ return wordStem(String(category || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)[0] || '');
72
+ }
73
+
74
+ // A mention under a negation ("do not edit files", "without deleting") forbids
75
+ // the action instead of asking for it, so it seeds no ask. Any plain mention
76
+ // elsewhere in the objective still does.
77
+ const NEGATION_BEFORE = /\b(?:not|never|no|don'?t|do\s+not|without|avoid|nor)\s+(?:\w+\s+){0,2}$/i;
78
+
79
+ // Categories are written in policy words ("destructive-file-action",
80
+ // "external-send"), objectives in plain ones ("delete the old logs", "email
81
+ // the client"). Each head word below also matches the plain words that mean
82
+ // it. Kept short on purpose: every word added is a chance to hold a mission
83
+ // that did nothing risky, so only words that name the action itself go here
84
+ // (not "message", "post", "clean", "update").
85
+ const CATEGORY_SYNONYMS = Object.freeze({
86
+ destructive: ['delete', 'remove', 'erase', 'wipe', 'purge', 'destroy'],
87
+ external: ['send', 'email', 'publish'],
88
+ billing: ['charge', 'refund', 'spend', 'invoice'],
89
+ spend: ['charge', 'refund', 'purchase'],
90
+ send: ['email'],
91
+ delete: ['erase', 'purge'],
92
+ });
93
+
94
+ // A category's own head word matches as a prefix (existing behavior); a
95
+ // synonym matches only as that word with an ordinary ending, so "send" does
96
+ // not fire on "sender" or "remove" on "removable".
97
+ const SYNONYM_ENDINGS = '(?:e|es|s|ed|ing|al|ion|ions)?';
98
+
99
+ // True when the pattern matches somewhere that is not under a negation.
100
+ function mentionedWithoutNegation(text, pattern) {
101
+ const re = new RegExp(pattern.source, 'gi');
102
+ let match;
103
+ while ((match = re.exec(text))) {
104
+ if (!NEGATION_BEFORE.test(text.slice(0, match.index))) return true;
105
+ if (match[0] === '') re.lastIndex += 1;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ function objectiveNamesCategory(objective, category) {
111
+ const text = String(objective || '');
112
+ const head = String(category || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)[0] || '';
113
+ const stem = categoryStem(category).replace(/[^a-z0-9]/g, '');
114
+ if (stem && mentionedWithoutNegation(text, new RegExp(`\\b${stem}`))) return true;
115
+ // "wipe" reads as wip(ing), "delete" as delet(ion): drop the trailing e.
116
+ const words = (CATEGORY_SYNONYMS[head] || []).map((w) => w.replace(/e$/, '')).filter(Boolean);
117
+ if (!words.length) return false;
118
+ return mentionedWithoutNegation(text, new RegExp(`\\b(?:${words.join('|')})${SYNONYM_ENDINGS}\\b`));
119
+ }
120
+
121
+ // One open human ask per mission covering every implicated category, so a
122
+ // single yes answers it. Tagged so a "no" can be told apart from a plain
123
+ // answered ask.
124
+ const APPROVAL_ASK_SOURCE = 'member_approval_required';
125
+
126
+ function memberApprovalAsks(owner, root = process.cwd(), objective = '') {
127
+ const categories = memberApprovalRequired(owner, root)
128
+ .filter((category) => objectiveNamesCategory(objective, category));
129
+ if (!categories.length) return [];
130
+ return [{
131
+ text: `Approve ${owner} acting on: ${categories.join(', ')} (MEMBER.md approval-required, named in the objective)`,
132
+ answered_at: null,
133
+ source: APPROVAL_ASK_SOURCE,
134
+ categories,
135
+ }];
136
+ }
137
+
138
+ // Asks that stop an explicit `mission run`: every open ask, plus a seeded
139
+ // approval ask that was answered "no" (a denial must not read as clearance).
140
+ function runBlockingAsks(asks) {
141
+ const list = Array.isArray(asks) ? asks : [];
142
+ return list
143
+ .map((ask) => (typeof ask === 'string' ? { text: ask, answered_at: null } : ask))
144
+ .filter((ask) => ask && String(ask.text || '').trim())
145
+ .filter((ask) => !ask.answered_at || (ask.source === APPROVAL_ASK_SOURCE && ask.answer !== 'yes'));
146
+ }
147
+
148
+ module.exports = {
149
+ memberApprovalAsks,
150
+ objectiveNamesCategory,
151
+ parseApprovalRequired,
152
+ runBlockingAsks,
153
+ };
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ // Done means an accepted task plus a receipt. A mission that is linked to
4
+ // task(s) may not complete just because its verifier passed: every linked task
5
+ // must be done + approval_status accepted, with proof on file. Missions with no
6
+ // linked task are not touched by this gate.
7
+ //
8
+ // Archived tasks: archiving a task wipes its approval (approval_status becomes
9
+ // 'archived'), so an archived task is never counted as accepted. The narrowest
10
+ // safe rule: an archived task does not block, so an abandoned side task cannot
11
+ // jam a mission whose real work was accepted, but a mission whose linked tasks
12
+ // are ALL archived has nothing accepted and is refused. Accepting at least one
13
+ // live linked task (or --force) is what lets it complete. One exception: a
14
+ // task that was done and accepted before the clear-done sweep archived it
15
+ // (archived_from 'done' plus an accepting review event) still counts as accepted.
16
+
17
+ function linkedTaskRefs(mission) {
18
+ const refs = [
19
+ ...(Array.isArray(mission?.task_ids) ? mission.task_ids : []),
20
+ mission?.xp_task?.task_id,
21
+ mission?.current_task_id,
22
+ mission?.task_id,
23
+ ];
24
+ return Array.from(new Set(refs.map((ref) => String(ref || '').trim()).filter(Boolean)));
25
+ }
26
+
27
+ function normalizeRef(taskDb, value) {
28
+ return taskDb.normalizeTaskRef
29
+ ? taskDb.normalizeTaskRef(value)
30
+ : String(value || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
31
+ }
32
+
33
+ function resolveLinkedTask(taskDb, db, ref, root, cache) {
34
+ const exact = taskDb.getTask(db, ref);
35
+ if (exact) return exact;
36
+ if (!cache.rows) {
37
+ const workspaceRoot = taskDb.workspaceRoot(root);
38
+ cache.rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot }));
39
+ }
40
+ const wanted = normalizeRef(taskDb, ref);
41
+ if (!wanted) return null;
42
+ const hit = cache.rows.find((row) => (
43
+ normalizeRef(taskDb, row.display_id) === wanted
44
+ || normalizeRef(taskDb, row.legacy_ref) === wanted
45
+ ));
46
+ return hit ? taskDb.getTask(db, hit.id) : null;
47
+ }
48
+
49
+ function taskReceipt(taskDb, db, task) {
50
+ const meta = task.metadata || {};
51
+ const stored = String(meta.latest_agent_proof || '').trim();
52
+ if (stored) return stored;
53
+ const events = taskDb.listTaskEvents(db, { taskId: task.id, order: 'desc', limit: 50 });
54
+ for (const event of events) {
55
+ const proof = String(event?.payload?.proof || '').trim();
56
+ if (proof) return proof;
57
+ }
58
+ return '';
59
+ }
60
+
61
+ // The short id the user types and `atris task` prints (T6V-2), not the long
62
+ // internal id. Falls back to the long id if the board cannot be read.
63
+ function shortTaskRef(taskDb, db, task, root) {
64
+ try {
65
+ // `atris task accept` looks a short id up in the current workspace, so a
66
+ // task that lives in another project keeps its full id, which works anywhere.
67
+ if (task.workspace_root && taskDb.workspaceRoot(root) !== task.workspace_root) return task.id;
68
+ const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot: task.workspace_root || taskDb.workspaceRoot(root) }));
69
+ const hit = rows.find((row) => row.id === task.id);
70
+ if (hit && hit.display_id) return hit.display_id;
71
+ } catch {
72
+ // fall through to the long id
73
+ }
74
+ return task.display_id || task.id;
75
+ }
76
+
77
+ // The clear-done sweep archives finished tasks, accepted ones included, and
78
+ // archiving overwrites the approval. A task that was done when archived and
79
+ // has an accepting review on record (reward above zero) still counts as
80
+ // accepted; anything else archived does not.
81
+ function wasAcceptedBeforeArchive(taskDb, db, task) {
82
+ const meta = task.metadata || {};
83
+ if (meta.archived_from !== 'done') return false;
84
+ try {
85
+ return taskDb.listTaskEvents(db, { taskId: task.id, order: 'desc', limit: 200 })
86
+ .some((event) => (event.event_type === 'reviewed' || event.eventType === 'reviewed') && Number(event?.payload?.reward) > 0);
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ function acceptCommand(ref, hasStoredProof) {
93
+ return hasStoredProof
94
+ ? `atris task accept ${ref}`
95
+ : `atris task accept ${ref} --proof "<receipt: what was checked>"`;
96
+ }
97
+
98
+ // Returns { ok: true, source, tasks } or { ok: false, source, task_id, reason,
99
+ // accept_command, tasks }. Infrastructure trouble (no task db) and linked ids
100
+ // that no longer resolve are skipped, never blocking: the gate only refuses on
101
+ // a task it can actually see.
102
+ function missionLinkedTaskGate(mission, root = process.cwd(), deps = {}) {
103
+ const refs = linkedTaskRefs(mission);
104
+ if (!refs.length) return { ok: true, source: 'no_linked_task', tasks: [] };
105
+ let taskDb;
106
+ let db;
107
+ try {
108
+ taskDb = deps.taskDb || require('./task-db');
109
+ db = taskDb.open();
110
+ } catch {
111
+ return { ok: true, source: 'task_db_unavailable', tasks: [] };
112
+ }
113
+ const cache = {};
114
+ const checked = [];
115
+ for (const ref of refs) {
116
+ let task = null;
117
+ try {
118
+ task = resolveLinkedTask(taskDb, db, ref, root, cache);
119
+ } catch {
120
+ task = null;
121
+ }
122
+ if (!task) continue;
123
+ const meta = task.metadata || {};
124
+ const archived = task.status === 'archived' || meta.approval_status === 'archived';
125
+ const acceptedBeforeArchive = archived && wasAcceptedBeforeArchive(taskDb, db, task);
126
+ if (archived && !acceptedBeforeArchive) {
127
+ checked.push({ task_id: task.id, task_ref: shortTaskRef(taskDb, db, task, root), state: 'archived', skipped: true });
128
+ continue;
129
+ }
130
+ const shortRef = shortTaskRef(taskDb, db, task, root);
131
+ const approval = meta.approval_status || 'none';
132
+ const receipt = taskReceipt(taskDb, db, task);
133
+ const hasStoredProof = Boolean(String(meta.latest_agent_proof || '').trim());
134
+ const accepted = acceptedBeforeArchive || (task.status === 'done' && approval === 'accepted');
135
+ checked.push({ task_id: task.id, task_ref: shortRef, state: accepted ? 'accepted' : task.status, approval_status: approval, has_receipt: Boolean(receipt) });
136
+ if (accepted && receipt) continue;
137
+ const reason = accepted
138
+ ? `linked task ${shortRef} is accepted but has no receipt on file`
139
+ : `linked task ${shortRef} is ${task.status} (approval: ${approval}), not accepted`;
140
+ return {
141
+ ok: false,
142
+ source: 'linked_task',
143
+ task_id: task.id,
144
+ task_ref: shortRef,
145
+ reason,
146
+ // An accepted task cannot be accepted again; only the operator override
147
+ // (--force) can wave through one that carries no receipt at all.
148
+ accept_command: accepted ? null : acceptCommand(shortRef, hasStoredProof),
149
+ tasks: checked,
150
+ };
151
+ }
152
+ // Every linked task we could see was archived: none was ever accepted.
153
+ if (checked.length && checked.every((entry) => entry.state === 'archived')) {
154
+ const names = checked.map((entry) => entry.task_ref).join(', ');
155
+ return {
156
+ ok: false,
157
+ source: 'linked_task',
158
+ task_id: checked[0].task_id,
159
+ task_ref: checked[0].task_ref,
160
+ reason: `every linked task (${names}) was archived and none was accepted`,
161
+ // An archived task cannot be accepted; link a live task and accept it.
162
+ accept_command: null,
163
+ archived_only: true,
164
+ tasks: checked,
165
+ };
166
+ }
167
+ return { ok: true, source: 'linked_tasks_accepted', tasks: checked };
168
+ }
169
+
170
+ module.exports = { missionLinkedTaskGate, linkedTaskRefs };
@@ -10,6 +10,7 @@ function taskTitle(task) {
10
10
 
11
11
  function buildOneLapValidatorPrompt(task, options = {}) {
12
12
  const verifier = String(options.verifierCommand || '').trim();
13
+ const baseRef = String(options.baseRef || 'HEAD').trim();
13
14
  const executor = String(options.executorEngine || 'executor').trim();
14
15
  return [
15
16
  'You are the independent validator for a completed one-lap build.',
@@ -22,7 +23,7 @@ function buildOneLapValidatorPrompt(task, options = {}) {
22
23
  `Required verifier: ${verifier}`,
23
24
  '',
24
25
  'Validate in a fresh context:',
25
- '1. Read atris/MAP.md when present; inspect git diff origin/master...HEAD, git diff HEAD, and git status --short.',
26
+ `1. Read atris/MAP.md when present; inspect git diff ${baseRef}...HEAD, git diff HEAD, and git status --short.`,
26
27
  '2. Decide whether the committed diff implements the task without unrelated or unsafe changes.',
27
28
  '3. Run the required verifier exactly as written and bare. A nonzero exit is a rejection.',
28
29
  '4. End with exactly one verdict line as the final nonblank line:',
@@ -120,6 +120,20 @@ function parseRunUsage({ usage = null, cost_usd = null, stdout = '', stderr = ''
120
120
  return found;
121
121
  }
122
122
 
123
+ // A run that did not land always says why in one plain line when it is shown
124
+ // with --json. Writers that know the cause pass it as `detail`; this is the
125
+ // fallback for a failed, stalled or credit-out run that has none. It is not
126
+ // stored, so the log can still tell a real reason from the filler. A landed
127
+ // run needs no reason.
128
+ function runReason(run) {
129
+ if (!run || run.outcome === 'landed') return '';
130
+ const given = clean(run.detail);
131
+ if (given) return given.slice(0, 240);
132
+ if (run.outcome === 'stalled') return 'it did not finish in time';
133
+ if (run.outcome === 'credit out') return 'the engine hit its usage limit';
134
+ return 'it failed and the tool gave no reason';
135
+ }
136
+
123
137
  // One normalized line. Empty fields stay out so a line only says what we know.
124
138
  function normalizeRun(input = {}, { now } = {}) {
125
139
  const outcome = OUTCOMES.includes(input.outcome) ? input.outcome : 'failed';
@@ -371,6 +385,19 @@ function renderRunLine(run) {
371
385
  return `${clockText(run.at)} ${who}: ${worker}${facts ? ` (${facts})` : ''}, ${outcomeText(run)}`;
372
386
  }
373
387
 
388
+ // The run as `--json` shows it: always a `model` key (null when the run rode
389
+ // the tool's own default, which atris could not read, with a note saying so)
390
+ // and, for a run that did not land, a one-line reason.
391
+ function runView(run) {
392
+ const reason = runReason(run);
393
+ return {
394
+ ...run,
395
+ model: run.model || null,
396
+ ...(run.model ? {} : { model_note: 'the tool picked its own model; it was not recorded' }),
397
+ ...(reason ? { reason } : {}),
398
+ };
399
+ }
400
+
374
401
  // Newest first, at most `limit`, optionally one job only.
375
402
  function recentRuns(runs, { job = '', limit = 20 } = {}) {
376
403
  const wantJob = job ? jobName(job) : '';
@@ -397,4 +424,5 @@ module.exports = {
397
424
  summarizeRuns,
398
425
  renderRunLine,
399
426
  recentRuns,
427
+ runView,
400
428
  };
@@ -9,13 +9,28 @@
9
9
  // first in precedence. Precedence: explicit model -> ATRIS_RUNNER_MODEL env ->
10
10
  // ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> pinned default.
11
11
  const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-5-5';
12
- const DEFAULT_FABLE_RUNNER_MODEL = 'claude-fable-5';
12
+ const DEFAULT_FABLE_RUNNER_MODEL = 'claude-fable-5-1';
13
13
  const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
14
14
  // Effort words a roster line may carry after the model. Each engine lists the
15
15
  // ones its CLI takes; an engine with no list takes none.
16
16
  const EFFORT_WORDS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
17
17
  const CLAUDE_EFFORTS = EFFORT_WORDS;
18
18
 
19
+ const FRIENDLY_CLAUDE_MODEL_ALIASES = Object.freeze({
20
+ 'opus 5.5': 'claude-opus-5-5',
21
+ 'opus-5.5': 'claude-opus-5-5',
22
+ 'opus 5': 'claude-opus-5',
23
+ 'opus-5': 'claude-opus-5',
24
+ 'opus 4.8': 'claude-opus-4-8',
25
+ 'opus-4.8': 'claude-opus-4-8',
26
+ });
27
+
28
+ function normalizeClaudeModelName(value) {
29
+ const trimmed = String(value || '').trim();
30
+ if (!trimmed) return '';
31
+ return FRIENDLY_CLAUDE_MODEL_ALIASES[trimmed.toLowerCase()] || trimmed;
32
+ }
33
+
19
34
  // Canonical runner profiles. Each entry is the config an operator would pick
20
35
  // on purpose. Aliases (spelling variants of the same profile) resolve to the
21
36
  // canonical config but are kept OUT of operator-facing lists so config errors
@@ -195,13 +210,13 @@ function runnerProfileValue(key) {
195
210
  }
196
211
 
197
212
  function resolveClaudeRunnerModel(mission) {
198
- const explicit = mission && mission.model != null ? String(mission.model).trim() : '';
213
+ const explicit = mission && mission.model != null ? normalizeClaudeModelName(mission.model) : '';
199
214
  if (explicit) return explicit;
200
- const env = firstConfiguredEnv(['ATRIS_RUNNER_MODEL']);
215
+ const env = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_RUNNER_MODEL']));
201
216
  if (env) return env;
202
- const profileModel = runnerProfileValue('model');
217
+ const profileModel = normalizeClaudeModelName(runnerProfileValue('model'));
203
218
  if (profileModel) return profileModel;
204
- const legacyEnv = firstConfiguredEnv(['ATRIS_CLAUDE_MODEL']);
219
+ const legacyEnv = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_CLAUDE_MODEL']));
205
220
  if (legacyEnv) return legacyEnv;
206
221
  return DEFAULT_CLAUDE_RUNNER_MODEL;
207
222
  }
@@ -231,9 +246,13 @@ function ownModelsOnlyEngineForBin(bin) {
231
246
  // claude env pin) and rides its own default instead.
232
247
  function runnerModelFor(engineId, model) {
233
248
  // Required here, not at the top: engine-registry requires this file.
234
- const { isClaudeFamilyModel, currentClaudeModel } = require('./engine-registry');
235
- const value = currentClaudeModel(String(model || '').trim());
249
+ const { isClaudeFamilyModel, currentClaudeModel, claudeModelId } = require('./engine-registry');
236
250
  const name = String(engineId || '').trim().toLowerCase();
251
+ // A display name like "opus 5.5" only becomes an id for the claude cli (or
252
+ // an unnamed engine). Any other engine's model name is handed over as is.
253
+ const canonical = RUNNER_PROFILE_ALIASES[name] || name;
254
+ const claudeCli = !canonical || canonical === 'claude' || canonical === 'fable' || canonical === 'haiku';
255
+ const value = claudeCli ? claudeModelId(String(model || '').trim()) : currentClaudeModel(String(model || '').trim());
237
256
  const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name];
238
257
  if (!value || !def || !def.ownModelsOnly) return value;
239
258
  return isClaudeFamilyModel(value) ? '' : value;
@@ -339,7 +358,12 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
339
358
  // profile, with or without a template.
340
359
  const profile = resolveRunnerProfileName();
341
360
  const binEngine = ownModelsOnlyEngineForBin(resolveClaudeRunnerBin());
342
- const modelFor = (value) => runnerModelFor(binEngine, runnerModelFor(profile, value));
361
+ // The second pass only matters for an own-models-only program; skipping it
362
+ // otherwise keeps a name that is not the claude cli's from being read as one.
363
+ const modelFor = (value) => {
364
+ const forProfile = runnerModelFor(profile, value);
365
+ return binEngine ? runnerModelFor(binEngine, forProfile) : forProfile;
366
+ };
343
367
  if (template) {
344
368
  return renderRunnerCommandTemplate(template, {
345
369
  promptFile,
package/lib/task-db.js CHANGED
@@ -454,10 +454,34 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
454
454
  return { id, inserted: true };
455
455
  }
456
456
 
457
+ // A main checkout's view also covers its linked git worktrees, whose tasks keep
458
+ // the worktree as workspace_root so checks run where the work lives. Only a
459
+ // main checkout with linked worktrees spawns git; any error means exact match.
460
+ const linkedWorktreeCache = new Map();
461
+ function linkedWorktreeRoots(ws) {
462
+ if (linkedWorktreeCache.has(ws)) return linkedWorktreeCache.get(ws);
463
+ let roots = [];
464
+ try {
465
+ if (fs.existsSync(path.join(ws, '.git', 'worktrees'))) {
466
+ const out = require('child_process').execFileSync('git', ['worktree', 'list', '--porcelain'],
467
+ { cwd: ws, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
468
+ roots = out.split('\n').filter(l => l.startsWith('worktree '))
469
+ .map(l => { const p = l.slice(9); try { return fs.realpathSync(p); } catch { return p; } })
470
+ .filter(p => p !== ws);
471
+ }
472
+ } catch { roots = []; }
473
+ linkedWorktreeCache.set(ws, roots);
474
+ return roots;
475
+ }
476
+
457
477
  function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
458
478
  const where = [];
459
479
  const args = [];
460
- if (ws) { where.push('workspace_root = ?'); args.push(ws); }
480
+ if (ws) {
481
+ const roots = [ws, ...linkedWorktreeRoots(ws)];
482
+ where.push(`workspace_root IN (${roots.map(() => '?').join(', ')})`);
483
+ args.push(...roots);
484
+ }
461
485
  if (status) { where.push('status = ?'); args.push(status); }
462
486
  if (claimedBy) { where.push('claimed_by = ?'); args.push(claimedBy); }
463
487
  const sql = `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.68.1",
3
+ "version": "3.68.3",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -53,6 +53,10 @@
53
53
  "atris/team/customer-lead/SOUL.md",
54
54
  "atris/team/customer-lead/START_HERE.md",
55
55
  "atris/team/customer-lead/skills/customer-commitments/SKILL.md",
56
+ "atris/team/navigator/skills/loophole-proof-planner/SKILL.md",
57
+ "atris/team/executor/skills/isolated-slice-builder/SKILL.md",
58
+ "atris/team/validator/skills/cross-model-referee/SKILL.md",
59
+ "atris/team/researcher/skills/grounded-evidence-brief/SKILL.md",
56
60
  "atris/team/opus-overnight/MEMBER.md",
57
61
  "atris/team/opus-overnight/MISSION.md",
58
62
  "atris/team/opus-overnight/START_HERE.md",
@@ -101,5 +105,8 @@
101
105
  "bugs": {
102
106
  "url": "https://github.com/atrislabs/atris/issues"
103
107
  },
104
- "homepage": "https://github.com/atrislabs/atris#readme"
108
+ "homepage": "https://github.com/atrislabs/atris#readme",
109
+ "engines": {
110
+ "node": ">=22.13"
111
+ }
105
112
  }
@@ -184,18 +184,41 @@ transcribe_locally() {
184
184
  fi
185
185
  echo "No captions on this video. Transcribing the audio on this computer (free, about 1 minute per 15 minutes of video)." >&2
186
186
  rm -f "yt_${ID}".audio.*
187
- # YouTube refuses some media requests at random (HTTP 403) and allows the
188
- # next one, so try the download three times before giving up.
189
- local attempt downloaded=0
190
- for attempt in 1 2 3; do
191
- if yt-dlp --no-update --quiet --no-warnings --no-playlist \
192
- -f "bestaudio[ext=m4a]/bestaudio/best" -o "yt_${ID}.audio.%(ext)s" "$URL" 2>"yt_${ID}.audio.err"; then
193
- downloaded=1; break
194
- fi
195
- [ "$attempt" = 3 ] || sleep "$((attempt * ${ATRIS_YTNOTES_RETRY_SECONDS:-3}))"
187
+ # When YouTube flags a connection it answers "Sign in to confirm you're not
188
+ # a bot" to the default player, but the embedded-player client still gets
189
+ # audio. Try each client in ATRIS_YTNOTES_AUDIO_CLIENTS in order ("default"
190
+ # = yt-dlp's own pick). A bot answer moves to the next client at once.
191
+ # YouTube also refuses some media requests at random (HTTP 403) and allows
192
+ # the next one, so each client gets three tries for any other error.
193
+ # Browser cookies are opt-in only: ATRIS_YTNOTES_COOKIES_FROM_BROWSER=safari.
194
+ local cookie_args=()
195
+ if [ -n "${ATRIS_YTNOTES_COOKIES_FROM_BROWSER:-}" ]; then
196
+ cookie_args=(--cookies-from-browser "$ATRIS_YTNOTES_COOKIES_FROM_BROWSER")
197
+ fi
198
+ local client attempt downloaded=0 blocked=1
199
+ for client in ${ATRIS_YTNOTES_AUDIO_CLIENTS:-web_embedded default}; do
200
+ local client_args=()
201
+ [ "$client" = default ] || client_args=(--extractor-args "youtube:player_client=$client")
202
+ for attempt in 1 2 3; do
203
+ # ${a[@]+...} keeps empty arrays safe under set -u in macOS bash 3.2.
204
+ if yt-dlp --no-update --quiet --no-warnings --no-playlist \
205
+ ${client_args[@]+"${client_args[@]}"} ${cookie_args[@]+"${cookie_args[@]}"} \
206
+ -f "bestaudio[ext=m4a]/bestaudio/best" -o "yt_${ID}.audio.%(ext)s" "$URL" 2>"yt_${ID}.audio.err"; then
207
+ downloaded=1; break 2
208
+ fi
209
+ if grep -qiE "not a bot|sign in to confirm" "yt_${ID}.audio.err" 2>/dev/null; then
210
+ break
211
+ fi
212
+ blocked=0
213
+ [ "$attempt" = 3 ] || sleep "$((attempt * ${ATRIS_YTNOTES_RETRY_SECONDS:-3}))"
214
+ done
196
215
  done
197
216
  if [ "$downloaded" != 1 ]; then
198
- LOCAL_FAIL="download: $(grep -m1 ERROR "yt_${ID}.audio.err" 2>/dev/null || echo "yt-dlp failed")"
217
+ if [ "$blocked" = 1 ] && grep -qiE "not a bot|sign in to confirm" "yt_${ID}.audio.err" 2>/dev/null; then
218
+ LOCAL_FAIL="blocked"
219
+ else
220
+ LOCAL_FAIL="download: $(grep -m1 ERROR "yt_${ID}.audio.err" 2>/dev/null || echo "yt-dlp failed")"
221
+ fi
199
222
  rm -f "yt_${ID}".audio.*
200
223
  return 1
201
224
  fi
@@ -229,6 +252,7 @@ if [ -z "$VTT" ]; then
229
252
  echo "No English captions found for $URL." >&2
230
253
  case "$LOCAL_FAIL" in
231
254
  no-model) echo "This computer has no local speech model to transcribe it." >&2 ;;
255
+ blocked) echo "YouTube is blocking audio downloads from this internet connection right now (it asks to sign in). Try again in a few hours or on another network, or opt in to your browser login: ATRIS_YTNOTES_COOKIES_FROM_BROWSER=safari" >&2 ;;
232
256
  download:*|transcribe:*) echo "Local transcription failed (${LOCAL_FAIL})." >&2 ;;
233
257
  esac
234
258
  echo "Paid notes, 5 credits, refunded if it fails: atris youtube process \"$URL\"" >&2
@@ -388,9 +412,20 @@ agy_is_cli() {
388
412
  ! head -c 4096 "$bin" 2>/dev/null | grep -q 'VSCODE_IPC_HOOK_CLI'
389
413
  }
390
414
 
415
+ # An agy that is not signed in opens a Google login and waits up to 60s, which
416
+ # also pops the Antigravity app. `agy models` fails fast with "Please sign in"
417
+ # and opens nothing, so probe with it. Only that message counts as signed out:
418
+ # any other result (including a slow or odd probe) lets the writer run.
419
+ agy_signed_in() {
420
+ local out
421
+ out=$(run_with_timeout 10 agy models </dev/null 2>&1) || true
422
+ ! printf '%s' "$out" | grep -qi 'sign in'
423
+ }
424
+
391
425
  run_gemini() {
392
426
  command -v agy >/dev/null 2>&1 || { echo "agy is not installed" >&2; return 127; }
393
427
  agy_is_cli || { echo "agy on PATH is the Antigravity editor launcher, which has no -p mode. Install the Antigravity CLI for Gemini notes." >&2; return 127; }
428
+ agy_signed_in || { echo "agy is installed but not signed in. Run: agy (sign in once) for Gemini notes." >&2; return 127; }
394
429
  run_with_timeout 120 agy -p "$PROMPT" --model "${ATRIS_YTNOTES_GEMINI_MODEL:-${1:-gemini-3.8-flash}}" \
395
430
  --effort "${2:-low}" --print-timeout 110s </dev/null
396
431
  }
@@ -459,6 +494,10 @@ run_auto() {
459
494
  IFS='|' read -r cmd model effort label <<< "$entry"
460
495
  command -v "$cmd" >/dev/null 2>&1 || continue
461
496
  if [ "$cmd" = agy ] && ! agy_is_cli; then continue; fi
497
+ if [ "$cmd" = agy ] && ! agy_signed_in; then
498
+ echo "agy is installed but not signed in; skipping Gemini. Run: agy (sign in once)." >&2
499
+ continue
500
+ fi
462
501
  if [ -n "$tried" ]; then
463
502
  echo "$tried did not answer; writing the notes with $label instead." >&2
464
503
  fi
@@ -24,6 +24,10 @@ function resolveBackendRoot(cwd = process.cwd()) {
24
24
 
25
25
  const sibling = path.resolve(workspace, '..', 'atrisos-backend');
26
26
  if (fs.existsSync(sibling) && fs.statSync(sibling).isDirectory()) return sibling;
27
+ // Test children inherit NODE_TEST_CONTEXT: do not discover the developer's
28
+ // backend beside this CLI install unless a test explicitly opts in.
29
+ const installSibling = process.env.ATRIS_BACKEND_INSTALL_SIBLING;
30
+ if (installSibling === '0' || (process.env.NODE_TEST_CONTEXT && installSibling !== '1')) return null;
27
31
  const installedSibling = path.resolve(__dirname, '..', '..', 'atrisos-backend');
28
32
  return fs.existsSync(installedSibling) && fs.statSync(installedSibling).isDirectory() ? installedSibling : null;
29
33
  }