atris 3.68.2 → 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.
- package/atris/skills/engines/SKILL.md +1 -1
- package/atris/team/customer-lead/MEMBER.md +1 -1
- package/atris/team/executor/MEMBER.md +19 -18
- package/atris/team/executor/skills/isolated-slice-builder/SKILL.md +21 -0
- package/atris/team/navigator/MEMBER.md +3 -1
- package/atris/team/navigator/skills/loophole-proof-planner/SKILL.md +20 -0
- package/atris/team/researcher/MEMBER.md +2 -1
- package/atris/team/researcher/skills/grounded-evidence-brief/SKILL.md +19 -0
- package/atris/team/validator/MEMBER.md +2 -7
- package/atris/team/validator/skills/cross-model-referee/SKILL.md +20 -0
- package/commands/autoland.js +2 -1
- package/commands/engine.js +2 -2
- package/commands/goal.js +61 -2
- package/commands/land.js +34 -7
- package/commands/member.js +1542 -41
- package/commands/mission.js +506 -18
- package/commands/task.js +26 -3
- package/commands/worktree.js +144 -34
- package/lib/auto-accept-certified.js +7 -6
- package/lib/codex-flight.js +6 -12
- package/lib/conductor-artifacts.js +3 -1
- package/lib/default-runner.js +42 -7
- package/lib/engine-ask.js +8 -1
- package/lib/engine-registry.js +17 -0
- package/lib/fleet.js +163 -88
- package/lib/member-alive.js +1 -1
- package/lib/member-approval.js +153 -0
- package/lib/mission-task-gate.js +170 -0
- package/lib/one-lap-validator.js +2 -1
- package/lib/roster-runs.js +28 -0
- package/lib/runner-command.js +32 -8
- package/lib/task-db.js +25 -1
- package/package.json +5 -1
- package/scripts/det/ytnotes +49 -10
package/lib/member-alive.js
CHANGED
|
@@ -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 };
|
package/lib/one-lap-validator.js
CHANGED
|
@@ -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
|
-
|
|
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:',
|
package/lib/roster-runs.js
CHANGED
|
@@ -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
|
};
|
package/lib/runner-command.js
CHANGED
|
@@ -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 ?
|
|
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
|
-
|
|
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) {
|
|
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.
|
|
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",
|
package/scripts/det/ytnotes
CHANGED
|
@@ -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
|
|
188
|
-
#
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|