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.
- 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/bin/atris.js +40 -1
- package/commands/autoland.js +2 -1
- package/commands/done.js +374 -0
- 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/workflow.js +1 -1
- 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/falsifier-probe.js +6 -0
- package/lib/first-minute.js +257 -6
- package/lib/fleet.js +163 -88
- package/lib/known-commands.js +1 -1
- 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 +9 -2
- package/scripts/det/ytnotes +49 -10
- package/utils/backend-root.js +4 -0
package/commands/done.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `atris done`: the one command a person runs after their coding agent built
|
|
4
|
+
// the thing. It names what was built, lands it through the real task path
|
|
5
|
+
// (`task ready --verify` then `task accept`, same gates agents face), and ends
|
|
6
|
+
// with a short credits roll and the file to open.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
const { argsWantHelp } = require('../lib/noninteractive');
|
|
12
|
+
const {
|
|
13
|
+
buildFirstMinute,
|
|
14
|
+
firstTalkBuildCommand,
|
|
15
|
+
firstTalkBuilder,
|
|
16
|
+
folderName,
|
|
17
|
+
humanStarterTask,
|
|
18
|
+
isFreshWorkspace,
|
|
19
|
+
listBuiltSince,
|
|
20
|
+
loadLocalTasks,
|
|
21
|
+
mainBuiltFile,
|
|
22
|
+
orderBuilt,
|
|
23
|
+
personName,
|
|
24
|
+
speakFirstMinute,
|
|
25
|
+
spokenBuiltList,
|
|
26
|
+
taskStartedAt,
|
|
27
|
+
} = require('../lib/first-minute');
|
|
28
|
+
|
|
29
|
+
const ATRIS_BIN = path.join(__dirname, '..', 'bin', 'atris.js');
|
|
30
|
+
const DONE_SCHEMA = 'atris.one_lap.v1';
|
|
31
|
+
|
|
32
|
+
// What a page has, in plain words, for the first tag found.
|
|
33
|
+
const HTML_MARKS = [
|
|
34
|
+
['<form', 'the form'],
|
|
35
|
+
['<h1', 'the heading'],
|
|
36
|
+
['<title', 'the page title'],
|
|
37
|
+
['<main', 'the main section'],
|
|
38
|
+
['<body', 'the page body'],
|
|
39
|
+
['<html', 'the page'],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function doneUsage() {
|
|
43
|
+
return 'Usage: atris done [<task>] [--json]';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function shQuote(value) {
|
|
47
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function spokenTitle(task) {
|
|
51
|
+
return String((task && task.title) || 'this').replace(/\s+/g, ' ').trim().replace(/[.,;:!?]+$/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readHead(root, file) {
|
|
55
|
+
try {
|
|
56
|
+
const buf = fs.readFileSync(path.join(root, file));
|
|
57
|
+
return buf.subarray(0, 65536);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A check that fails when the built file is gone or no longer holds what was
|
|
64
|
+
// built, so it clears the weak-verifier and empty-folder gates honestly.
|
|
65
|
+
function verifierFor(root, files) {
|
|
66
|
+
const ordered = orderBuilt(files);
|
|
67
|
+
for (const file of ordered) {
|
|
68
|
+
const head = readHead(root, file);
|
|
69
|
+
if (!head || !head.length || head.includes(0)) continue;
|
|
70
|
+
const text = head.toString('utf8');
|
|
71
|
+
if (/\.html?$/i.test(file)) {
|
|
72
|
+
const lower = text.toLowerCase();
|
|
73
|
+
const mark = HTML_MARKS.find(([tag]) => lower.includes(tag));
|
|
74
|
+
if (mark) {
|
|
75
|
+
return {
|
|
76
|
+
command: `grep -qi ${shQuote(mark[0])} ${shQuote(file)}`,
|
|
77
|
+
plain: `${file} has ${mark[1]}`,
|
|
78
|
+
file,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const word = (text.match(/[A-Za-z][A-Za-z0-9_-]{3,}/) || [])[0];
|
|
83
|
+
if (word) {
|
|
84
|
+
return {
|
|
85
|
+
command: `grep -qF ${shQuote(word)} ${shQuote(file)}`,
|
|
86
|
+
plain: `${file} says "${word}"`,
|
|
87
|
+
file,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const file = ordered.find((name) => {
|
|
92
|
+
const head = readHead(root, name);
|
|
93
|
+
return head && head.length;
|
|
94
|
+
});
|
|
95
|
+
if (!file) return null;
|
|
96
|
+
return {
|
|
97
|
+
command: `grep -qa . ${shQuote(file)}`,
|
|
98
|
+
plain: `${file} is there and not empty`,
|
|
99
|
+
file,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One sentence the ready gate accepts: what the person can do now and why.
|
|
104
|
+
function resultSentence(task) {
|
|
105
|
+
const { explainResult } = require('../lib/autoland');
|
|
106
|
+
const title = spokenTitle(task).replace(/[.!?]/g, '').slice(0, 100).trim();
|
|
107
|
+
const candidates = [
|
|
108
|
+
`You can now open ${title} and use it yourself, so it is real instead of only a plan.`,
|
|
109
|
+
'You can now open what you asked for here and use it yourself, so it is real instead of only a plan.',
|
|
110
|
+
];
|
|
111
|
+
return candidates.find((line) => explainResult(line, { allowCommandMention: true }).ok) || candidates[1];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function openCommand(file) {
|
|
115
|
+
const target = /^[\w./-]+$/.test(file) ? file : shQuote(file);
|
|
116
|
+
if (process.platform === 'darwin') return `open ${target}`;
|
|
117
|
+
if (process.platform === 'win32') return `start ${target}`;
|
|
118
|
+
return `xdg-open ${target}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildCommandFor(task, builder) {
|
|
122
|
+
const pick = builder !== undefined ? builder : firstTalkBuilder();
|
|
123
|
+
// Test runners skip the machine probe; name the default builder so the line stays runnable.
|
|
124
|
+
return firstTalkBuildCommand(pick === undefined ? 'claude' : pick, spokenTitle(task));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function taskRefOf(task) {
|
|
128
|
+
return String((task && (task.display_id || task.legacy_ref || task.id)) || '').trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findTask(tasks, ref) {
|
|
132
|
+
const want = String(ref || '').trim().toLowerCase();
|
|
133
|
+
if (!want) return null;
|
|
134
|
+
return tasks.find((task) => [task.display_id, task.legacy_ref, task.id]
|
|
135
|
+
.some((value) => value && String(value).toLowerCase() === want))
|
|
136
|
+
|| tasks.find((task) => task.id && String(task.id).toLowerCase().startsWith(want))
|
|
137
|
+
|| null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function pickTask(tasks, person) {
|
|
141
|
+
const starter = humanStarterTask(tasks, person);
|
|
142
|
+
if (starter) return starter;
|
|
143
|
+
const who = String(person || '').trim();
|
|
144
|
+
const mine = tasks
|
|
145
|
+
.filter((task) => (task.status === 'claimed' || task.status === 'review')
|
|
146
|
+
&& (!who || String(task.claimed_by || '') === who))
|
|
147
|
+
.sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0));
|
|
148
|
+
return mine[0] || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function runAtris(root, args) {
|
|
152
|
+
const result = spawnSync(process.execPath, [ATRIS_BIN, ...args], {
|
|
153
|
+
cwd: root,
|
|
154
|
+
env: process.env,
|
|
155
|
+
encoding: 'utf8',
|
|
156
|
+
timeout: 240000,
|
|
157
|
+
});
|
|
158
|
+
let json = null;
|
|
159
|
+
try {
|
|
160
|
+
json = JSON.parse(String(result.stdout || '').trim());
|
|
161
|
+
} catch {
|
|
162
|
+
json = null;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
ok: result.status === 0,
|
|
166
|
+
status: result.status,
|
|
167
|
+
stdout: String(result.stdout || ''),
|
|
168
|
+
stderr: String(result.stderr || ''),
|
|
169
|
+
json,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function failureLine(step) {
|
|
174
|
+
const text = `${step.stderr}\n${step.stdout}`;
|
|
175
|
+
if (step.json && (step.json.detail || step.json.reason)) {
|
|
176
|
+
return String(step.json.detail || step.json.reason).split('\n')[0];
|
|
177
|
+
}
|
|
178
|
+
const line = text.split('\n').map((s) => s.trim()).find(Boolean);
|
|
179
|
+
return line || `exit ${step.status}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function liveTask(root, id) {
|
|
183
|
+
try {
|
|
184
|
+
const taskDb = require('../lib/task-db');
|
|
185
|
+
const db = taskDb.open();
|
|
186
|
+
return taskDb.getTask(db, id);
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function receiptFromProof(proof) {
|
|
193
|
+
const match = String(proof || '').match(/Receipt:\s*(\S+\.json)/);
|
|
194
|
+
return match ? match[1] : '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The receipt is the record. Say on it that the person who owned this work
|
|
198
|
+
// accepted it themselves, through `atris done`.
|
|
199
|
+
function stampOwnerAccept(root, receiptPath, { owner, actor }) {
|
|
200
|
+
if (!receiptPath) return false;
|
|
201
|
+
const file = path.resolve(root, receiptPath);
|
|
202
|
+
try {
|
|
203
|
+
const receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
204
|
+
receipt.accepted = {
|
|
205
|
+
by: actor || owner || null,
|
|
206
|
+
owner: owner || null,
|
|
207
|
+
self_accepted: true,
|
|
208
|
+
via: 'atris done',
|
|
209
|
+
note: 'the human owner accepted their own work',
|
|
210
|
+
at: new Date().toISOString(),
|
|
211
|
+
};
|
|
212
|
+
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
|
|
213
|
+
return true;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderCredits({ title, built, checked, receipt, next }) {
|
|
220
|
+
return [
|
|
221
|
+
'',
|
|
222
|
+
` ${title}`,
|
|
223
|
+
'',
|
|
224
|
+
` built ${spokenBuiltList(built)}`,
|
|
225
|
+
` checked ${checked}, passed`,
|
|
226
|
+
` signed ${receipt || 'the task record'}`,
|
|
227
|
+
'',
|
|
228
|
+
`next: ${next}`,
|
|
229
|
+
].join('\n');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function emit(asJson, log, payload, text) {
|
|
233
|
+
if (asJson) log(JSON.stringify(payload, null, 2));
|
|
234
|
+
else log(text);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function doneAtris(args = process.argv.slice(3), {
|
|
238
|
+
root = process.cwd(),
|
|
239
|
+
log = console.log,
|
|
240
|
+
builder,
|
|
241
|
+
} = {}) {
|
|
242
|
+
const list = Array.isArray(args) ? args : [];
|
|
243
|
+
if (argsWantHelp(list)) {
|
|
244
|
+
log(doneUsage());
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
const asJson = list.includes('--json');
|
|
248
|
+
const ref = list.find((arg) => !String(arg).startsWith('-')) || '';
|
|
249
|
+
|
|
250
|
+
if (isFreshWorkspace(root)) {
|
|
251
|
+
return speakFirstMinute({ root, fresh: true, asJson, log });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const person = personName();
|
|
255
|
+
const tasks = loadLocalTasks(root);
|
|
256
|
+
const task = ref ? findTask(tasks, ref) : pickTask(tasks, person);
|
|
257
|
+
|
|
258
|
+
if (!task) {
|
|
259
|
+
const minute = buildFirstMinute({ root, builder });
|
|
260
|
+
const reason = ref ? `nothing here is called ${ref}` : 'nothing of yours is in progress here';
|
|
261
|
+
// Already shipped: say so, the same way bare atris does.
|
|
262
|
+
const text = !ref && minute.shipped ? `\n${minute.text}` : `\n${reason}.\n\nnext: ${minute.nextCommand}`;
|
|
263
|
+
emit(asJson, log, {
|
|
264
|
+
schema: DONE_SCHEMA,
|
|
265
|
+
ok: false,
|
|
266
|
+
status: 'stuck',
|
|
267
|
+
reason,
|
|
268
|
+
next_action: minute.nextCommand,
|
|
269
|
+
}, text);
|
|
270
|
+
return asJson ? 2 : 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const title = spokenTitle(task);
|
|
274
|
+
if (task.status === 'done') {
|
|
275
|
+
emit(asJson, log, {
|
|
276
|
+
schema: DONE_SCHEMA,
|
|
277
|
+
ok: true,
|
|
278
|
+
status: 'already_done',
|
|
279
|
+
reason: `${title} is already done`,
|
|
280
|
+
next_action: buildFirstMinute({ root, builder }).nextCommand,
|
|
281
|
+
}, `\n${title} is already done.\n\nnext: ${buildFirstMinute({ root, builder }).nextCommand}`);
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
if (!['open', 'claimed', 'review'].includes(task.status)) {
|
|
285
|
+
emit(asJson, log, {
|
|
286
|
+
schema: DONE_SCHEMA,
|
|
287
|
+
ok: false,
|
|
288
|
+
status: 'stuck',
|
|
289
|
+
reason: `${title} is ${task.status}`,
|
|
290
|
+
next_action: `atris task show ${taskRefOf(task)}`,
|
|
291
|
+
}, `\n${title} is ${task.status}.\n\nnext: atris task show ${taskRefOf(task)}`);
|
|
292
|
+
return asJson ? 2 : 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const built = orderBuilt(listBuiltSince(root, taskStartedAt(task)));
|
|
296
|
+
if (!built.length) {
|
|
297
|
+
const next = buildCommandFor(task, builder);
|
|
298
|
+
emit(asJson, log, {
|
|
299
|
+
schema: DONE_SCHEMA,
|
|
300
|
+
ok: true,
|
|
301
|
+
status: 'nothing_new',
|
|
302
|
+
reason: `nothing new here since you started ${title}`,
|
|
303
|
+
task: { display_id: task.display_id || null, title: task.title },
|
|
304
|
+
built: [],
|
|
305
|
+
next_action: next,
|
|
306
|
+
}, `\nnothing new here since you started ${title}.\n\nnext: ${next}`);
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const verify = verifierFor(root, built);
|
|
311
|
+
const fail = (reason, next) => {
|
|
312
|
+
emit(asJson, log, {
|
|
313
|
+
schema: DONE_SCHEMA,
|
|
314
|
+
ok: false,
|
|
315
|
+
status: 'stuck',
|
|
316
|
+
reason,
|
|
317
|
+
task: { display_id: task.display_id || null, title: task.title },
|
|
318
|
+
built,
|
|
319
|
+
next_action: next,
|
|
320
|
+
}, `\n${title} has new work, but ${reason}.\n\nnext: ${next}`);
|
|
321
|
+
return 1;
|
|
322
|
+
};
|
|
323
|
+
if (!verify) return fail('nothing built has content to check yet', buildCommandFor(task, builder));
|
|
324
|
+
|
|
325
|
+
let current = liveTask(root, task.id) || task;
|
|
326
|
+
if (current.status !== 'review') {
|
|
327
|
+
const ready = runAtris(root, [
|
|
328
|
+
'task', 'ready', task.id,
|
|
329
|
+
'--verify', verify.command,
|
|
330
|
+
'--result', resultSentence(task),
|
|
331
|
+
'--json',
|
|
332
|
+
]);
|
|
333
|
+
if (!ready.ok) {
|
|
334
|
+
return fail(`the check did not pass (${failureLine(ready)})`, `atris task show ${taskRefOf(task)}`);
|
|
335
|
+
}
|
|
336
|
+
current = liveTask(root, task.id) || current;
|
|
337
|
+
}
|
|
338
|
+
const receipt = receiptFromProof(current && current.metadata && current.metadata.latest_agent_proof);
|
|
339
|
+
|
|
340
|
+
const accept = runAtris(root, ['task', 'accept', task.id, '--json']);
|
|
341
|
+
if (!accept.ok) {
|
|
342
|
+
const why = failureLine(accept);
|
|
343
|
+
const waiting = /agent_proof_only|cannot accept/i.test(`${why} ${accept.stderr} ${accept.stdout}`);
|
|
344
|
+
return fail(
|
|
345
|
+
waiting ? 'it is checked and waiting on a person to accept it' : `accept did not go through (${why})`,
|
|
346
|
+
`atris task accept ${taskRefOf(task)}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const actor = (accept.json && accept.json.episode && (accept.json.episode.actor || accept.json.episode.reviewer)) || '';
|
|
350
|
+
const stamped = stampOwnerAccept(root, receipt, { owner: task.claimed_by || person, actor });
|
|
351
|
+
|
|
352
|
+
const main = mainBuiltFile(built);
|
|
353
|
+
const next = openCommand(main);
|
|
354
|
+
emit(asJson, log, {
|
|
355
|
+
schema: DONE_SCHEMA,
|
|
356
|
+
ok: true,
|
|
357
|
+
status: 'done',
|
|
358
|
+
reason: `${title} is done`,
|
|
359
|
+
task: { display_id: task.display_id || null, title: task.title },
|
|
360
|
+
built,
|
|
361
|
+
checked: { command: verify.command, plain: verify.plain, passed: true },
|
|
362
|
+
receipt: receipt || null,
|
|
363
|
+
owner_accepted: stamped,
|
|
364
|
+
next_action: next,
|
|
365
|
+
}, renderCredits({ title, built, checked: verify.plain, receipt, next }));
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
module.exports = {
|
|
370
|
+
doneAtris,
|
|
371
|
+
resultSentence,
|
|
372
|
+
verifierFor,
|
|
373
|
+
openCommand,
|
|
374
|
+
};
|
package/commands/engine.js
CHANGED
|
@@ -38,7 +38,7 @@ const { teamRosterView } = require('../lib/member-engine');
|
|
|
38
38
|
const { engineModelText, engineRunsView, availableModels, modelLabel } = require('../lib/roster-models');
|
|
39
39
|
const { renderTable } = require('../lib/text-table');
|
|
40
40
|
const { TEAM_FOOTER } = require('../lib/team-lineup');
|
|
41
|
-
const { readRosterRuns, workerRuns, summarizeRuns, recentRuns, renderRunLine, DEFAULT_DAYS: RUN_DAYS } = require('../lib/roster-runs');
|
|
41
|
+
const { readRosterRuns, workerRuns, summarizeRuns, recentRuns, renderRunLine, runView, DEFAULT_DAYS: RUN_DAYS } = require('../lib/roster-runs');
|
|
42
42
|
const { attachSuggestions } = require('../lib/roster-suggest');
|
|
43
43
|
const { PIN_MAX_AGE_DAYS: PIN_STALE_DAYS, attachStaleModels, modelCatalog } = require('../lib/roster-stale');
|
|
44
44
|
const {
|
|
@@ -1507,7 +1507,7 @@ const RUNS_LIST_LIMIT = 20;
|
|
|
1507
1507
|
function runRosterRunsCommand(job, json, root, now) {
|
|
1508
1508
|
const runs = recentRuns(readRosterRuns(root, { now, days: RUNS_LIST_DAYS }), { job, limit: RUNS_LIST_LIMIT });
|
|
1509
1509
|
if (json) {
|
|
1510
|
-
console.log(JSON.stringify({ job: job || null, days: RUNS_LIST_DAYS, runs }, null, 2));
|
|
1510
|
+
console.log(JSON.stringify({ job: job || null, days: RUNS_LIST_DAYS, runs: runs.map(runView) }, null, 2));
|
|
1511
1511
|
return 0;
|
|
1512
1512
|
}
|
|
1513
1513
|
if (!runs.length) {
|
package/commands/goal.js
CHANGED
|
@@ -168,9 +168,65 @@ function renderGoalView(view) {
|
|
|
168
168
|
return lines.join('\n');
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// A goal that never becomes a mission run is a wish. `goal set` therefore also
|
|
172
|
+
// files the sentence through the normal wish capture path (capture only: no
|
|
173
|
+
// mission, no driver), so the goal -> wish -> mission -> run chain starts here.
|
|
174
|
+
// Wish capture is best-effort: it must never fail or undo the goal itself.
|
|
175
|
+
function wishNextStep(wish, goal) {
|
|
176
|
+
return wish.status === 'needs_input'
|
|
177
|
+
? 'atris wish answer "your words"'
|
|
178
|
+
: `atris wish again ${wish.id} "${goal}"`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function captureGoalWish(sentence, root) {
|
|
182
|
+
const goal = String(sentence || '').replace(/\s+/g, ' ').trim();
|
|
183
|
+
const { isFreshWorkspace } = require('../lib/first-minute');
|
|
184
|
+
if (isFreshWorkspace(root)) {
|
|
185
|
+
console.error('note: wish not filed, no atris/ workspace here. run: atris init');
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const previous = process.cwd();
|
|
189
|
+
let wish = null;
|
|
190
|
+
try {
|
|
191
|
+
if (previous !== root) process.chdir(root);
|
|
192
|
+
// Re-setting the same goal must not stack duplicate wishes.
|
|
193
|
+
const { readWishes } = require('../lib/wish-store');
|
|
194
|
+
const open = readWishes(root).find((w) => (
|
|
195
|
+
normalizeText(w.text) === normalizeText(goal)
|
|
196
|
+
&& ['captured', 'captured_no_mission', 'needs_input'].includes(w.status)
|
|
197
|
+
));
|
|
198
|
+
if (open) {
|
|
199
|
+
console.log(`wish already filed: ${open.id}`);
|
|
200
|
+
console.log(`next ${wishNextStep(open, goal)}`);
|
|
201
|
+
return open;
|
|
202
|
+
}
|
|
203
|
+
const { runCapturedWish } = require('../lib/wish-delegate');
|
|
204
|
+
runCapturedWish(goal, root, {
|
|
205
|
+
noMission: true,
|
|
206
|
+
afterCapture: (captured) => { wish = captured; },
|
|
207
|
+
});
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error(`warning: goal set, but wish capture failed: ${normalizeText(error?.message || error)}`);
|
|
210
|
+
return null;
|
|
211
|
+
} finally {
|
|
212
|
+
if (process.cwd() !== previous) process.chdir(previous);
|
|
213
|
+
}
|
|
214
|
+
if (!wish) return null;
|
|
215
|
+
let latest = wish;
|
|
216
|
+
try {
|
|
217
|
+
const { readWishes } = require('../lib/wish-store');
|
|
218
|
+
latest = readWishes(root).find((w) => w.id === wish.id) || wish;
|
|
219
|
+
} catch {
|
|
220
|
+
latest = wish;
|
|
221
|
+
}
|
|
222
|
+
console.log(`wish id ${wish.id}`);
|
|
223
|
+
console.log(`next ${wishNextStep(latest, goal)}`);
|
|
224
|
+
return latest;
|
|
225
|
+
}
|
|
226
|
+
|
|
171
227
|
function printHelp() {
|
|
172
228
|
console.log('usage: atris goal [--json]');
|
|
173
|
-
console.log(' atris goal set "<sentence>"');
|
|
229
|
+
console.log(' atris goal set "<sentence>" [--no-wish]');
|
|
174
230
|
console.log(' atris goal metric <name> <value>');
|
|
175
231
|
console.log(' atris goal metric <name> --rm');
|
|
176
232
|
}
|
|
@@ -187,7 +243,9 @@ function run(args = [], context = {}) {
|
|
|
187
243
|
}
|
|
188
244
|
|
|
189
245
|
if (command === 'set') {
|
|
190
|
-
const
|
|
246
|
+
const noWish = args.includes('--no-wish');
|
|
247
|
+
const sentence = args.slice(1).filter((arg) => arg !== '--no-wish').join(' ');
|
|
248
|
+
const goal = normalizeText(sentence);
|
|
191
249
|
if (!goal) {
|
|
192
250
|
console.error('error: goal sentence is required');
|
|
193
251
|
return 2;
|
|
@@ -195,6 +253,7 @@ function run(args = [], context = {}) {
|
|
|
195
253
|
const scoreboard = readScoreboard(root);
|
|
196
254
|
writeScoreboard({ ...scoreboard, goal }, root);
|
|
197
255
|
console.log('goal set.');
|
|
256
|
+
if (!noWish) captureGoalWish(sentence, root);
|
|
198
257
|
return 0;
|
|
199
258
|
}
|
|
200
259
|
|
package/commands/land.js
CHANGED
|
@@ -2,7 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const gitSpawn = require('../lib/git-spawn');
|
|
4
4
|
const { compactErrorPayload, printCliJson } = require('../lib/cli-json');
|
|
5
|
-
const { listWorktrees, statusCounts } = require('./worktree');
|
|
5
|
+
const { listWorktrees, statusCounts, defaultMainlineBase, listActiveProcessCwds, worktreeHasActiveProcess, recordWorktreeRemoval } = require('./worktree');
|
|
6
6
|
|
|
7
7
|
const DEFAULT_TTL_DAYS = 7;
|
|
8
8
|
const DEFAULT_STALE_HOURS = 48;
|
|
@@ -38,10 +38,11 @@ function refExists(root, ref) {
|
|
|
38
38
|
|
|
39
39
|
function baseBranch(root, override = '') {
|
|
40
40
|
if (override && refExists(root, override)) return override;
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
const mainline = defaultMainlineBase(root);
|
|
42
|
+
if (mainline !== 'HEAD') return mainline;
|
|
43
|
+
for (const candidate of ['main', 'master']) {
|
|
44
|
+
if (refExists(root, candidate)) return candidate;
|
|
45
|
+
}
|
|
45
46
|
return '';
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -309,7 +310,7 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
|
|
|
309
310
|
const cherryCache = loadCherryCache(root);
|
|
310
311
|
const baseSha = resolveSha(root, base);
|
|
311
312
|
for (const branch of listBranches(root, base)) {
|
|
312
|
-
if (PROTECTED_BRANCHES.has(branch.name)) continue;
|
|
313
|
+
if (PROTECTED_BRANCHES.has(branch.name) || branch.name === base.replace(/^origin\//, '')) continue;
|
|
313
314
|
const lastCommitMs = Number(branch.lastCommitUnix) * 1000;
|
|
314
315
|
if (Number.isFinite(lastCommitMs)) lastCommitMsByBranch.set(branch.name, lastCommitMs);
|
|
315
316
|
const ahead = branch.ahead !== undefined ? branch.ahead : aheadCount(root, base, branch.name);
|
|
@@ -527,6 +528,15 @@ function salvageWorktree(w, dir, receipt) {
|
|
|
527
528
|
}
|
|
528
529
|
}
|
|
529
530
|
|
|
531
|
+
function reapReason(w, board, ttlDays, force) {
|
|
532
|
+
const state = (board.branches.find((b) => b.name === w.branch) || {}).state;
|
|
533
|
+
if (state === 'landed') return 'branch_landed';
|
|
534
|
+
if (state === 'due') return 'branch_past_ttl';
|
|
535
|
+
if (w.state === 'detached') return 'detached_head';
|
|
536
|
+
if (typeof w.ageDays === 'number' && w.ageDays > ttlDays) return 'worktree_past_ttl';
|
|
537
|
+
return force ? 'forced' : 'unlanded_commits';
|
|
538
|
+
}
|
|
539
|
+
|
|
530
540
|
// Reap: salvage then delete everything landed (residue) or past TTL.
|
|
531
541
|
// Salvage first, always: unlanded commits go into a git bundle, dirty
|
|
532
542
|
// worktrees into patch files + untracked-file copies, so a reap is never
|
|
@@ -534,7 +544,9 @@ function salvageWorktree(w, dir, receipt) {
|
|
|
534
544
|
// includeDetached: detached-HEAD worktrees have no branch, so the salvage
|
|
535
545
|
// bundle cannot cover their commits, unattended reaps (autoland) pass false
|
|
536
546
|
// and leave them for a human reap.
|
|
537
|
-
function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true, protectCurrent = true, force = false, now = Date.now() } = {}) {
|
|
547
|
+
function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true, protectCurrent = true, force = false, now = Date.now(), activeCwds: activeCwdsOverride, by = 'atris land --reap' } = {}) {
|
|
548
|
+
const readActiveCwds = () => (Array.isArray(activeCwdsOverride) ? activeCwdsOverride : listActiveProcessCwds());
|
|
549
|
+
let activeCwds = null;
|
|
538
550
|
const board = collectBoard(root, { ttlDays, staleHours, base: baseOverride, now });
|
|
539
551
|
const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
|
|
540
552
|
const targetNames = new Set(targets.map((b) => b.name));
|
|
@@ -554,6 +566,13 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
|
|
|
554
566
|
protectedWorktrees.push({ ...w, reason: 'current_checkout' });
|
|
555
567
|
continue;
|
|
556
568
|
}
|
|
569
|
+
// A run working inside the folder outranks --force: removing it pulls
|
|
570
|
+
// the floor out from under a live engine.
|
|
571
|
+
if (!activeCwds) activeCwds = readActiveCwds();
|
|
572
|
+
if (worktreeHasActiveProcess(w.path, activeCwds)) {
|
|
573
|
+
protectedWorktrees.push({ ...w, reason: 'active_process' });
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
557
576
|
if (worktreeWithinReapGrace(w.path, now)) {
|
|
558
577
|
protectedWorktrees.push({ ...w, reason: 'fresh_worktree_grace' });
|
|
559
578
|
continue;
|
|
@@ -642,7 +661,14 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
|
|
|
642
661
|
const survivingWorktreeTargets = worktreeTargets.filter(
|
|
643
662
|
(w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached')
|
|
644
663
|
);
|
|
664
|
+
const activeAtRemoval = survivingWorktreeTargets.length ? readActiveCwds() : [];
|
|
645
665
|
for (const w of survivingWorktreeTargets) {
|
|
666
|
+
// A run can start in the folder while the bundle is built; look again.
|
|
667
|
+
if (worktreeHasActiveProcess(w.path, activeAtRemoval)) {
|
|
668
|
+
receipt.keptWorktrees.push(`${w.path} (active_process)`);
|
|
669
|
+
if (w.branch) targetNames.delete(w.branch);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
646
672
|
// The grace check ran at scan time, and a bundle build can sit between it
|
|
647
673
|
// and removal — an engine that booted mid-sweep refreshes the directory
|
|
648
674
|
// mtime in that gap. Stat once more at removal time, the cheapest place
|
|
@@ -665,6 +691,7 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
|
|
|
665
691
|
const removed = runGit(['worktree', 'remove', '--force', w.path], { cwd: root, check: false });
|
|
666
692
|
if (removed.status === 0) {
|
|
667
693
|
receipt.removedWorktrees.push(w.path);
|
|
694
|
+
recordWorktreeRemoval(root, { path: w.path, branch: w.branch, by, reason: reapReason(w, board, ttlDays, force) });
|
|
668
695
|
} else {
|
|
669
696
|
// A locked worktree fails here and, unreported, reap re-salvages the
|
|
670
697
|
// same patches every pass forever. Say what blocked it so a human can
|