atris 3.38.0 → 3.41.0
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/AGENTS.md +25 -6
- package/atris/PERSONA.md +8 -4
- package/atris.md +7 -0
- package/ax +2 -1
- package/bin/atris.js +31 -6
- package/commands/agent-spawn.js +13 -11
- package/commands/autoland.js +28 -77
- package/commands/bench.js +10 -12
- package/commands/business.js +345 -0
- package/commands/chat-scan.js +5 -7
- package/commands/codex-goal.js +8 -10
- package/commands/computer.js +20 -0
- package/commands/console.js +19 -3
- package/commands/decide.js +166 -0
- package/commands/deck.js +1 -4
- package/commands/drill.js +14 -24
- package/commands/engine.js +196 -8
- package/commands/gm.js +8 -6
- package/commands/harvest.js +1 -4
- package/commands/init.js +23 -3
- package/commands/land.js +8 -14
- package/commands/launchpad.js +1 -14
- package/commands/lifecycle.js +5 -5
- package/commands/log.js +55 -5
- package/commands/member.js +558 -574
- package/commands/mission.js +456 -237
- package/commands/pack.js +2746 -164
- package/commands/play.js +6 -4
- package/commands/probe.js +2 -2
- package/commands/pulse.js +15 -16
- package/commands/release.js +10 -9
- package/commands/router.js +5 -4
- package/commands/site-deploy.js +885 -0
- package/commands/site.js +11 -2
- package/commands/slop.js +14 -2
- package/commands/stream.js +4 -18
- package/commands/task.js +880 -558
- package/commands/taste.js +101 -0
- package/commands/team.js +176 -3
- package/commands/vercel.js +4 -2
- package/commands/voice.js +195 -0
- package/commands/watch.js +1 -22
- package/commands/wiki.js +1 -4
- package/commands/workflow.js +2 -2
- package/commands/worktree.js +1 -14
- package/commands/xp.js +27 -24
- package/lib/accept-verify-gate.js +5 -1
- package/lib/arg-parser.js +41 -0
- package/lib/auto-accept-certified.js +116 -1
- package/lib/autoland.js +66 -0
- package/lib/bench/runner.js +19 -1
- package/lib/context-gatherer.js +7 -1
- package/lib/engine-registry.js +141 -20
- package/lib/falsifier-probe.js +84 -0
- package/lib/fleet.js +65 -15
- package/lib/git-spawn.js +15 -0
- package/lib/json-file.js +37 -0
- package/lib/known-commands.js +2 -2
- package/lib/lesson-preflight.js +146 -0
- package/lib/loop-doctor.js +0 -2
- package/lib/mission-human-asks.js +28 -0
- package/lib/mission-protected-lane.js +4 -1
- package/lib/official-cli-integration.js +47 -2
- package/lib/orb-context.js +8 -1
- package/lib/pack-capabilities.js +685 -0
- package/lib/router-brain.js +51 -1
- package/lib/runner-command.js +0 -6
- package/lib/self-drive.js +44 -13
- package/lib/task-db.js +137 -3
- package/lib/task-decision.js +50 -0
- package/lib/taste-lessons.js +153 -0
- package/lib/tool-result-encode.js +17 -1
- package/lib/voice-gate.js +66 -0
- package/lib/wish-audit.js +1 -1
- package/lib/wish-delegate.js +1 -1
- package/lib/zip.js +95 -7
- package/package.json +2 -1
- package/templates/business-starter/persona.md +9 -0
package/lib/router-brain.js
CHANGED
|
@@ -6,6 +6,8 @@ const path = require('node:path');
|
|
|
6
6
|
const DEFAULT_MIN_RECEIPTS = 3;
|
|
7
7
|
const DEFAULT_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
8
8
|
const DURATION_SCALE_MS = 60 * 1000;
|
|
9
|
+
const STRETCH_ZONE_MIN = 0.6;
|
|
10
|
+
const STRETCH_ZONE_MAX = 0.85;
|
|
9
11
|
|
|
10
12
|
function objectValue(value) {
|
|
11
13
|
return value && typeof value === 'object' ? value : null;
|
|
@@ -313,15 +315,56 @@ function rankEnginesDetailed(candidates, options = {}) {
|
|
|
313
315
|
|| fallbackOrder(left.candidate, left.index) - fallbackOrder(right.candidate, right.index)
|
|
314
316
|
|| candidateId(left.candidate).localeCompare(candidateId(right.candidate)))
|
|
315
317
|
.map((entry) => entry.candidate);
|
|
318
|
+
|
|
319
|
+
const stretchPick = stretchZonePick(ranked.map((candidate) => ({
|
|
320
|
+
candidate,
|
|
321
|
+
predicted: statsByEngine.get(candidateId(candidate))?.verified_pass_rate ?? null,
|
|
322
|
+
})), options);
|
|
323
|
+
const finalRanked = stretchPick
|
|
324
|
+
? [stretchPick, ...ranked.filter((candidate) => candidate !== stretchPick)]
|
|
325
|
+
: ranked;
|
|
316
326
|
return {
|
|
317
|
-
candidates:
|
|
327
|
+
candidates: finalRanked,
|
|
318
328
|
stats,
|
|
319
329
|
task_type: taskType,
|
|
320
330
|
used_track_record: true,
|
|
321
331
|
thin_engines: [],
|
|
332
|
+
stretch_zone_pick: stretchPick ? candidateId(stretchPick) : null,
|
|
322
333
|
};
|
|
323
334
|
}
|
|
324
335
|
|
|
336
|
+
function engineCost(candidate) {
|
|
337
|
+
const value = Number(candidate && typeof candidate === 'object' ? candidate.cost : NaN);
|
|
338
|
+
return Number.isFinite(value) && value >= 0 ? value : Number.POSITIVE_INFINITY;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// stretch zone rule for low-stakes lanes: land the cheapest engine whose
|
|
342
|
+
// predicted success rate sits in the learnable band. entries are
|
|
343
|
+
// { candidate, predicted } rows where predicted is a 0..1 rate or null.
|
|
344
|
+
// returns the winning candidate, or null when nobody is in the band; the
|
|
345
|
+
// caller then keeps the normal score order, whose top is the strongest
|
|
346
|
+
// engine, which is the escalation path when every engine sits below the
|
|
347
|
+
// band floor.
|
|
348
|
+
function stretchZonePick(entries, options = {}) {
|
|
349
|
+
const lowStakes = options.lowStakes === true || normalizedLabel(options.stakes) === 'low';
|
|
350
|
+
if (!lowStakes) return null;
|
|
351
|
+
const rows = (Array.isArray(entries) ? entries : [])
|
|
352
|
+
.map((entry, index) => ({
|
|
353
|
+
candidate: entry && typeof entry === 'object' && 'candidate' in entry ? entry.candidate : entry,
|
|
354
|
+
predicted: entry && typeof entry === 'object' ? finiteNonnegative(entry.predicted) : null,
|
|
355
|
+
index,
|
|
356
|
+
}))
|
|
357
|
+
.filter((row) => row.candidate);
|
|
358
|
+
const inBand = rows
|
|
359
|
+
.filter((row) => row.predicted !== null && row.predicted <= 1
|
|
360
|
+
&& row.predicted >= STRETCH_ZONE_MIN && row.predicted <= STRETCH_ZONE_MAX)
|
|
361
|
+
.sort((left, right) => engineCost(left.candidate) - engineCost(right.candidate)
|
|
362
|
+
|| right.predicted - left.predicted
|
|
363
|
+
|| fallbackOrder(left.candidate, left.index) - fallbackOrder(right.candidate, right.index)
|
|
364
|
+
|| candidateId(left.candidate).localeCompare(candidateId(right.candidate)));
|
|
365
|
+
return inBand.length ? inBand[0].candidate : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
325
368
|
function rankEngines(candidates, options = {}) {
|
|
326
369
|
return rankEnginesDetailed(candidates, options).candidates;
|
|
327
370
|
}
|
|
@@ -335,6 +378,10 @@ function routerPickExplanation(decision) {
|
|
|
335
378
|
return `router picked ${engine} because ${taskType} track records are thin, so fallback order applies.`;
|
|
336
379
|
}
|
|
337
380
|
const stats = decision.stats.find((row) => row.engine === engine && row.task_type === taskType);
|
|
381
|
+
if (decision.stretch_zone_pick === engine) {
|
|
382
|
+
const predicted = stats ? stats.verified_pass_rate.toFixed(2) : 'unknown';
|
|
383
|
+
return `router picked ${engine} because the lane is low stakes and its predicted pass rate ${predicted} sits in the ${STRETCH_ZONE_MIN.toFixed(2)} to ${STRETCH_ZONE_MAX.toFixed(2)} stretch zone at the lowest cost.`;
|
|
384
|
+
}
|
|
338
385
|
if (!stats) return `router picked ${engine} because it has the strongest ${taskType} track record.`;
|
|
339
386
|
const passRate = (stats.verified_pass_rate * 100).toFixed(1);
|
|
340
387
|
const duration = stats.median_duration_ms === null ? 'no duration' : `${Math.round(stats.median_duration_ms)} ms median`;
|
|
@@ -342,9 +389,12 @@ function routerPickExplanation(decision) {
|
|
|
342
389
|
}
|
|
343
390
|
|
|
344
391
|
module.exports = {
|
|
392
|
+
STRETCH_ZONE_MIN,
|
|
393
|
+
STRETCH_ZONE_MAX,
|
|
345
394
|
loadRouterHistory,
|
|
346
395
|
computeEngineTaskStats,
|
|
347
396
|
rankEngines,
|
|
348
397
|
rankEnginesDetailed,
|
|
349
398
|
routerPickExplanation,
|
|
399
|
+
stretchZonePick,
|
|
350
400
|
};
|
package/lib/runner-command.js
CHANGED
|
@@ -67,11 +67,6 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
67
67
|
model: 'grok-4.5',
|
|
68
68
|
commandTemplate: '{bin} --always-approve -p {prompt}',
|
|
69
69
|
}),
|
|
70
|
-
hermes: Object.freeze({
|
|
71
|
-
bin: 'hermes',
|
|
72
|
-
model: '',
|
|
73
|
-
commandTemplate: '{bin} -p -- {prompt}',
|
|
74
|
-
}),
|
|
75
70
|
droid: Object.freeze({
|
|
76
71
|
bin: 'droid',
|
|
77
72
|
model: '',
|
|
@@ -85,7 +80,6 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
85
80
|
const RUNNER_PROFILE_ALIASES = Object.freeze({
|
|
86
81
|
'atris2-fast': 'atris-fast',
|
|
87
82
|
'atris-2-fast': 'atris-fast',
|
|
88
|
-
'hermes-agent': 'hermes',
|
|
89
83
|
});
|
|
90
84
|
|
|
91
85
|
// Back-compat surface: RUNNER_PROFILES still resolves every accepted name
|
package/lib/self-drive.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { openHumanAsks } = require('./mission-human-asks');
|
|
5
6
|
|
|
6
7
|
const HUMAN_BLOCKING_REASONS = new Set(['auth-required', 'model-unavailable', 'rate-limit-exceeded-wall']);
|
|
7
8
|
const OPEN_TASK_STATUSES = new Set(['open', 'claimed', 'review']);
|
|
@@ -154,10 +155,10 @@ function projectTasks(taskDb, db, workspaceRoot) {
|
|
|
154
155
|
function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent }, injected = {}) {
|
|
155
156
|
if (!mission || !mission.id) return { taskId: null, dispatched: false, engine: null, reason: 'missing mission' };
|
|
156
157
|
const blockerClass = reasonClass(stopReason || mission.stop_reason || mission.last_tick_reason);
|
|
157
|
-
if (HUMAN_BLOCKING_REASONS.has(blockerClass) || (
|
|
158
|
+
if (HUMAN_BLOCKING_REASONS.has(blockerClass) || openHumanAsks(mission.human_asks).length) {
|
|
158
159
|
return { taskId: null, dispatched: false, engine: null, reason: 'human-blocking' };
|
|
159
160
|
}
|
|
160
|
-
if (['ready', 'complete'].includes(String(mission.status || '').toLowerCase())) {
|
|
161
|
+
if (['ready', 'complete', 'stopped'].includes(String(mission.status || '').toLowerCase())) {
|
|
161
162
|
return { taskId: null, dispatched: false, engine: null, reason: 'stop condition met' };
|
|
162
163
|
}
|
|
163
164
|
|
|
@@ -175,23 +176,53 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
|
|
|
175
176
|
return { taskId: taskRef(task), dispatched: false, engine: null, reason: 'existing blocker task' };
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
const evidence = String(mission.receipt_path || mission.last_tick_reason || stopReason || blockerClass).trim();
|
|
180
|
+
const objective = String(mission.objective || mission.id).trim();
|
|
181
|
+
const fullStopReason = String(stopReason || blockerClass).trim();
|
|
182
|
+
const blockerMetadata = {
|
|
183
|
+
mission_id: mission.id,
|
|
184
|
+
mission_objective: objective,
|
|
185
|
+
mission_blocker_class: blockerClass,
|
|
186
|
+
stop_reason: fullStopReason,
|
|
187
|
+
evidence,
|
|
188
|
+
};
|
|
189
|
+
const latestClosed = matching
|
|
190
|
+
.filter(row => !OPEN_TASK_STATUSES.has(row.status))
|
|
191
|
+
.sort((a, b) => Number(b.updated_at || b.created_at || 0) - Number(a.updated_at || a.created_at || 0))[0];
|
|
192
|
+
if (latestClosed && typeof deps.taskDb.reopenTask === 'function') {
|
|
193
|
+
const reopened = deps.taskDb.reopenTask(db, {
|
|
194
|
+
id: latestClosed.id,
|
|
195
|
+
actor: 'self-drive',
|
|
196
|
+
reason: `mission blocker fired again: ${blockerClass}`,
|
|
197
|
+
metadata: blockerMetadata,
|
|
198
|
+
});
|
|
199
|
+
if (reopened.reopened) {
|
|
200
|
+
const refreshed = deps.taskDb.withTaskDisplayRefs(deps.taskDb.listTasks(db, { workspaceRoot: scopedRoot }));
|
|
201
|
+
task = refreshed.find(row => row.id === latestClosed.id) || reopened.row;
|
|
202
|
+
if (task && typeof deps.taskDb.noteTask === 'function') {
|
|
203
|
+
deps.taskDb.noteTask(db, {
|
|
204
|
+
id: task.id,
|
|
205
|
+
actor: 'self-drive',
|
|
206
|
+
content: `Mission blocker reopened.\nMission objective: ${objective}\nStop reason: ${fullStopReason}\nEvidence: ${evidence}`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
projectTasks(deps.taskDb, db, scopedRoot);
|
|
210
|
+
appendEvent?.('mission_blocker_task_reopened', {
|
|
211
|
+
task_id: taskRef(task),
|
|
212
|
+
stop_reason: fullStopReason,
|
|
213
|
+
blocker_class: blockerClass,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
178
218
|
if (!task) {
|
|
179
|
-
const evidence = String(mission.receipt_path || mission.last_tick_reason || stopReason || blockerClass).trim();
|
|
180
|
-
const objective = String(mission.objective || mission.id).trim();
|
|
181
|
-
const fullStopReason = String(stopReason || blockerClass).trim();
|
|
182
219
|
const title = missionBlockerTitle(objective, fullStopReason);
|
|
183
220
|
const created = deps.taskDb.addTask(db, {
|
|
184
221
|
title,
|
|
185
222
|
tag: 'mission-blocker',
|
|
186
223
|
workspaceRoot: scopedRoot,
|
|
187
|
-
sourceKey: `mission-blocker:${mission.id}:${blockerClass}
|
|
188
|
-
metadata:
|
|
189
|
-
mission_id: mission.id,
|
|
190
|
-
mission_objective: objective,
|
|
191
|
-
mission_blocker_class: blockerClass,
|
|
192
|
-
stop_reason: fullStopReason,
|
|
193
|
-
evidence,
|
|
194
|
-
},
|
|
224
|
+
sourceKey: `mission-blocker:${mission.id}:${blockerClass}`,
|
|
225
|
+
metadata: blockerMetadata,
|
|
195
226
|
});
|
|
196
227
|
const refreshed = deps.taskDb.withTaskDisplayRefs(deps.taskDb.listTasks(db, { workspaceRoot: scopedRoot }));
|
|
197
228
|
task = refreshed.find((row) => row.id === created.id) || deps.taskDb.getTask(db, created.id);
|
package/lib/task-db.js
CHANGED
|
@@ -28,6 +28,8 @@ const os = require('os');
|
|
|
28
28
|
const crypto = require('crypto');
|
|
29
29
|
const { DatabaseSync } = require('node:sqlite');
|
|
30
30
|
const reviewIntegrity = require('./review-integrity');
|
|
31
|
+
const { isDecisionTask } = require('./task-decision');
|
|
32
|
+
const { parseVerifyCommand } = require('./auto-accept-certified');
|
|
31
33
|
|
|
32
34
|
const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
|
|
33
35
|
const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
|
|
@@ -38,6 +40,8 @@ const PROJECTION_EVENT_LIMIT = 8;
|
|
|
38
40
|
const PROJECTION_MESSAGE_LIMIT = 6;
|
|
39
41
|
const PROJECTION_PAYLOAD_TEXT_LIMIT = 1000;
|
|
40
42
|
const AGENT_CERTIFICATION_REVIEW_PASSES = 2;
|
|
43
|
+
const OPEN_TASK_STATUSES = new Set(['open', 'claimed', 'review']);
|
|
44
|
+
const TERMINAL_MISSION_STATUSES = new Set(['complete', 'stopped']);
|
|
41
45
|
const TASK_REF_GENERIC_TOKENS = new Set(['app', 'atris', 'atrisos', 'project', 'repo', 'workspace']);
|
|
42
46
|
const TASK_PLAN_TAGS = new Set([
|
|
43
47
|
'agent',
|
|
@@ -380,6 +384,16 @@ function taskDisplayRefMap(rows) {
|
|
|
380
384
|
return map;
|
|
381
385
|
}
|
|
382
386
|
|
|
387
|
+
function taskCreationMetadata(metadata) {
|
|
388
|
+
const next = metadata && typeof metadata === 'object' ? { ...metadata } : {};
|
|
389
|
+
const verify = typeof next.verify === 'string' ? next.verify.trim() : '';
|
|
390
|
+
if (!verify || verify.toLowerCase() === 'git diff --check') {
|
|
391
|
+
next.verification_status = 'degraded';
|
|
392
|
+
next.verification_degraded_reason = verify ? 'diff_only_verify' : 'missing_verify';
|
|
393
|
+
}
|
|
394
|
+
return next;
|
|
395
|
+
}
|
|
396
|
+
|
|
383
397
|
function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, status, claimedBy }) {
|
|
384
398
|
if (!title || !String(title).trim()) throw new Error('title required');
|
|
385
399
|
const now = Date.now();
|
|
@@ -394,6 +408,7 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
|
|
|
394
408
|
).get(ws, sk);
|
|
395
409
|
if (existing) return { id: existing.id, inserted: false };
|
|
396
410
|
}
|
|
411
|
+
const taskMetadata = taskCreationMetadata(metadata);
|
|
397
412
|
withBusyRetry(() => db.prepare(`
|
|
398
413
|
INSERT INTO tasks (id, title, status, tag, workspace_root, source_key,
|
|
399
414
|
claimed_by, claimed_at, created_at, updated_at, metadata)
|
|
@@ -409,7 +424,7 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
|
|
|
409
424
|
claimedAt,
|
|
410
425
|
now,
|
|
411
426
|
now,
|
|
412
|
-
|
|
427
|
+
JSON.stringify(taskMetadata),
|
|
413
428
|
));
|
|
414
429
|
appendTaskEvent(db, {
|
|
415
430
|
taskId: id,
|
|
@@ -421,7 +436,7 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
|
|
|
421
436
|
tag: tag || null,
|
|
422
437
|
status: taskStatus,
|
|
423
438
|
source_key: sk || null,
|
|
424
|
-
metadata:
|
|
439
|
+
metadata: taskMetadata,
|
|
425
440
|
},
|
|
426
441
|
});
|
|
427
442
|
return { id, inserted: true };
|
|
@@ -531,6 +546,72 @@ function releaseTask(db, { id, actor }) {
|
|
|
531
546
|
return { released: true, row: updated, event };
|
|
532
547
|
}
|
|
533
548
|
|
|
549
|
+
function reopenTask(db, { id, actor, reason, metadata: metadataPatch } = {}) {
|
|
550
|
+
if (!id) throw new Error('id required');
|
|
551
|
+
const row = getTask(db, id);
|
|
552
|
+
if (!row) return { reopened: false, reason: 'not_found' };
|
|
553
|
+
if (OPEN_TASK_STATUSES.has(row.status)) return { reopened: false, reason: `already_${row.status}`, row };
|
|
554
|
+
if (!['done', 'failed', 'archived'].includes(row.status)) {
|
|
555
|
+
return { reopened: false, reason: `not_reopenable_${row.status}` };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const now = Math.max(Date.now(), Number(row.updated_at || 0) + 1);
|
|
559
|
+
const actorText = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
|
|
560
|
+
const reasonText = String(reason || 'reopened').trim();
|
|
561
|
+
const metadata = {
|
|
562
|
+
...(row.metadata && typeof row.metadata === 'object' ? row.metadata : {}),
|
|
563
|
+
...(metadataPatch && typeof metadataPatch === 'object' ? metadataPatch : {}),
|
|
564
|
+
reopened_at: new Date(now).toISOString(),
|
|
565
|
+
reopened_by: actorText,
|
|
566
|
+
reopened_from: row.status,
|
|
567
|
+
reopen_reason: reasonText,
|
|
568
|
+
};
|
|
569
|
+
for (const key of [
|
|
570
|
+
'accepted_at',
|
|
571
|
+
'accepted_by',
|
|
572
|
+
'agent_certification_policy',
|
|
573
|
+
'agent_certified',
|
|
574
|
+
'agent_certified_at',
|
|
575
|
+
'agent_certified_by',
|
|
576
|
+
'agent_review_actors',
|
|
577
|
+
'agent_review_pass_count',
|
|
578
|
+
'agent_reviewed_at',
|
|
579
|
+
'agent_reviewed_by',
|
|
580
|
+
'approval_status',
|
|
581
|
+
'archived_at',
|
|
582
|
+
'archived_by',
|
|
583
|
+
'archived_from',
|
|
584
|
+
'archived_reason',
|
|
585
|
+
'independent_review_by',
|
|
586
|
+
'latest_agent_lesson',
|
|
587
|
+
'latest_agent_next_task',
|
|
588
|
+
'latest_agent_proof',
|
|
589
|
+
]) delete metadata[key];
|
|
590
|
+
|
|
591
|
+
const result = withBusyRetry(() => db.prepare(`
|
|
592
|
+
UPDATE tasks
|
|
593
|
+
SET status = 'open',
|
|
594
|
+
claimed_by = NULL,
|
|
595
|
+
claimed_at = NULL,
|
|
596
|
+
done_at = NULL,
|
|
597
|
+
updated_at = ?,
|
|
598
|
+
metadata = ?
|
|
599
|
+
WHERE id = ?
|
|
600
|
+
AND status = ?
|
|
601
|
+
AND updated_at = ?
|
|
602
|
+
`).run(now, JSON.stringify(metadata), id, row.status, row.updated_at));
|
|
603
|
+
if (result.changes !== 1) return { reopened: false, reason: 'stale_task_state' };
|
|
604
|
+
const updated = getTask(db, id);
|
|
605
|
+
const event = appendTaskEvent(db, {
|
|
606
|
+
taskId: id,
|
|
607
|
+
workspaceRoot: updated.workspace_root,
|
|
608
|
+
actor: actorText,
|
|
609
|
+
eventType: 'reopened',
|
|
610
|
+
payload: { previous_status: row.status, reason: reasonText },
|
|
611
|
+
});
|
|
612
|
+
return { reopened: true, event, row: updated };
|
|
613
|
+
}
|
|
614
|
+
|
|
534
615
|
function doneTask(db, { id, status, actor, allowReview = false, action, proof, autoAccepted = false } = {}) {
|
|
535
616
|
if (!id) throw new Error('id required');
|
|
536
617
|
const final = status || 'done';
|
|
@@ -570,6 +651,39 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof, a
|
|
|
570
651
|
return { updated: false };
|
|
571
652
|
}
|
|
572
653
|
|
|
654
|
+
function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor } = {}) {
|
|
655
|
+
const missionById = new Map((Array.isArray(missions) ? missions : [])
|
|
656
|
+
.filter(mission => mission && mission.id)
|
|
657
|
+
.map(mission => [String(mission.id), mission]));
|
|
658
|
+
const candidates = listTasks(db, { workspaceRoot: ws || null, limit: null })
|
|
659
|
+
.filter(row => OPEN_TASK_STATUSES.has(row.status))
|
|
660
|
+
.filter(row => row.tag === 'mission-blocker')
|
|
661
|
+
.filter(row => row.metadata && row.metadata.mission_id && row.metadata.mission_blocker_class)
|
|
662
|
+
.map(row => ({ row, mission: missionById.get(String(row.metadata.mission_id)) }))
|
|
663
|
+
.filter(({ mission }) => mission && TERMINAL_MISSION_STATUSES.has(String(mission.status || '').toLowerCase()));
|
|
664
|
+
const closed = [];
|
|
665
|
+
for (const { row, mission } of candidates) {
|
|
666
|
+
const missionStatus = String(mission.status).toLowerCase();
|
|
667
|
+
const reason = `mission ${mission.id} is ${missionStatus}`;
|
|
668
|
+
const result = archiveTask(db, {
|
|
669
|
+
id: row.id,
|
|
670
|
+
actor,
|
|
671
|
+
reason,
|
|
672
|
+
});
|
|
673
|
+
if (!result.archived) continue;
|
|
674
|
+
closed.push({
|
|
675
|
+
task_id: row.id,
|
|
676
|
+
title: row.title,
|
|
677
|
+
previous_status: row.status,
|
|
678
|
+
mission_id: mission.id,
|
|
679
|
+
mission_status: missionStatus,
|
|
680
|
+
blocker_class: row.metadata.mission_blocker_class,
|
|
681
|
+
reason,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
return { closed, count: closed.length };
|
|
685
|
+
}
|
|
686
|
+
|
|
573
687
|
// Distinct terminal status for housekeeping sweeps (duplicate loop-ticks,
|
|
574
688
|
// off-roadmap backlog resets, synthetic-test cleanup). Never conflate this
|
|
575
689
|
// with `failed` — 'failed' means the work itself did not succeed, 'archived'
|
|
@@ -1556,6 +1670,21 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
|
|
|
1556
1670
|
metadata.agent_reviewed_by = reviewer;
|
|
1557
1671
|
if (proofText) metadata.latest_agent_proof = proofText;
|
|
1558
1672
|
if (verifyText) {
|
|
1673
|
+
// Gate at the write path, not just cmdReview: a verify the strict parser
|
|
1674
|
+
// rejects can never be run by the hourly autoland recheck, so storing it
|
|
1675
|
+
// silently parks the task on a human forever (observed 2026-07-31: five
|
|
1676
|
+
// certified tasks stalled for days on unrunnable verifies stored by
|
|
1677
|
+
// fleet lanes that bypass the command-layer gate).
|
|
1678
|
+
const parsedVerify = parseVerifyCommand(verifyText);
|
|
1679
|
+
if (!parsedVerify.ok) {
|
|
1680
|
+
const error = new Error(
|
|
1681
|
+
`verify command is not runnable by the hourly recheck (${parsedVerify.reason || 'verify_command_not_allowed'}); `
|
|
1682
|
+
+ 'use an allowlisted shape: cd backend && ../venv/bin/python -m pytest <file> -q, '
|
|
1683
|
+
+ 'test -s <artifact>, npm test, node --test <file>, tsc, or git diff --check',
|
|
1684
|
+
);
|
|
1685
|
+
error.reason = parsedVerify.reason || 'verify_command_not_allowed';
|
|
1686
|
+
throw error;
|
|
1687
|
+
}
|
|
1559
1688
|
metadata.verify = verifyText;
|
|
1560
1689
|
metadata.latest_agent_verify = verifyText;
|
|
1561
1690
|
}
|
|
@@ -1997,8 +2126,11 @@ function appendSection(lines, name, rows) {
|
|
|
1997
2126
|
const tag = [...new Set(tags.filter(Boolean).map(value => String(value).trim()).filter(Boolean))]
|
|
1998
2127
|
.map(value => ` [${value}]`)
|
|
1999
2128
|
.join('');
|
|
2129
|
+
// Decision holds live in metadata.tags (or the primary tag). Surface them
|
|
2130
|
+
// so a human judgment row never blends into ordinary work on the board.
|
|
2131
|
+
const decision = isDecisionTask(row) ? ' [decision]' : '';
|
|
2000
2132
|
const displayRef = meta.todo_id || row.display_id || row.id;
|
|
2001
|
-
lines.push(`- **[${displayRef}]** ${row.title}${tag}`);
|
|
2133
|
+
lines.push(`- **[${displayRef}]** ${row.title}${tag}${decision}`);
|
|
2002
2134
|
if (row.claimed_by && row.status === 'claimed') lines.push(` **Claimed by:** ${row.claimed_by}`);
|
|
2003
2135
|
if (meta.verify) lines.push(` **Verify:** ${meta.verify}`);
|
|
2004
2136
|
}
|
|
@@ -2046,9 +2178,11 @@ module.exports = {
|
|
|
2046
2178
|
listTasks,
|
|
2047
2179
|
claimTask,
|
|
2048
2180
|
releaseTask,
|
|
2181
|
+
reopenTask,
|
|
2049
2182
|
backlogTask,
|
|
2050
2183
|
clearPlanTasks,
|
|
2051
2184
|
doneTask,
|
|
2185
|
+
reapMissionBlockerTasks,
|
|
2052
2186
|
archiveTask,
|
|
2053
2187
|
relabelArchivedTasks,
|
|
2054
2188
|
readyTask,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Decision rows are human judgment calls, not agent work. Detect them from
|
|
4
|
+
// tags already in the data (primary tag, tags[], metadata.tags). The live
|
|
5
|
+
// marker is needs-human (CLI-879); decision is the same hold under a clearer
|
|
6
|
+
// name. Autonomous pickers must refuse these; list renderers must show them.
|
|
7
|
+
|
|
8
|
+
const DECISION_HOLD_TAGS = new Set([
|
|
9
|
+
'needs-human',
|
|
10
|
+
'needshuman',
|
|
11
|
+
'decision',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const DECISION_MARKER = '[decision]';
|
|
15
|
+
const DECISION_REFUSE_REASON = 'decision row: human judgment required';
|
|
16
|
+
|
|
17
|
+
function normalizeDecisionTag(value) {
|
|
18
|
+
return String(value == null ? '' : value).trim().toLowerCase().replace(/_/g, '-');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function taskTagTokens(task) {
|
|
22
|
+
if (!task || typeof task !== 'object') return [];
|
|
23
|
+
const fromTags = Array.isArray(task.tags) ? task.tags : [];
|
|
24
|
+
const fromTag = task.tag ? [task.tag] : [];
|
|
25
|
+
const fromMeta = task.metadata && Array.isArray(task.metadata.tags) ? task.metadata.tags : [];
|
|
26
|
+
const fromTitle = (String(task.title || '').match(/#([a-z0-9-]+)/gi) || []).map((t) => t.slice(1));
|
|
27
|
+
return [...fromTag, ...fromTags, ...fromMeta, ...fromTitle]
|
|
28
|
+
.map(normalizeDecisionTag)
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isDecisionHoldTag(tag) {
|
|
33
|
+
return DECISION_HOLD_TAGS.has(normalizeDecisionTag(tag));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isDecisionTask(task) {
|
|
37
|
+
return taskTagTokens(task).some(isDecisionHoldTag);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function decisionMarkerFor(task) {
|
|
41
|
+
return isDecisionTask(task) ? DECISION_MARKER : '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
DECISION_REFUSE_REASON,
|
|
46
|
+
taskTagTokens,
|
|
47
|
+
isDecisionHoldTag,
|
|
48
|
+
isDecisionTask,
|
|
49
|
+
decisionMarkerFor,
|
|
50
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const VERDICTS = new Set(['keep', 'kill', 'more']);
|
|
7
|
+
const SCOPES = new Set(['writing', 'design', 'code', 'any']);
|
|
8
|
+
const STOP_WORDS = new Set([
|
|
9
|
+
'and', 'are', 'because', 'for', 'from', 'has', 'have', 'into', 'its', 'not', 'of', 'on', 'or',
|
|
10
|
+
'that', 'the', 'their', 'this', 'to', 'was', 'were', 'with', 'without', 'you', 'your',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function tastePath(root) {
|
|
14
|
+
return path.join(root, 'atris', 'taste.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readTaste(root) {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(fs.readFileSync(tastePath(root), 'utf8'));
|
|
20
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function slugify(subject) {
|
|
27
|
+
return String(subject || '')
|
|
28
|
+
.normalize('NFKD')
|
|
29
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
32
|
+
.replace(/^-+|-+$/g, '')
|
|
33
|
+
.slice(0, 80)
|
|
34
|
+
.replace(/-+$/g, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validEntry(entry) {
|
|
38
|
+
return entry
|
|
39
|
+
&& typeof entry === 'object'
|
|
40
|
+
&& VERDICTS.has(entry.verdict)
|
|
41
|
+
&& typeof entry.subject === 'string'
|
|
42
|
+
&& entry.subject.trim()
|
|
43
|
+
&& typeof entry.why === 'string'
|
|
44
|
+
&& entry.why.trim()
|
|
45
|
+
&& SCOPES.has(entry.scope)
|
|
46
|
+
&& typeof entry.added === 'string';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function entriesFrom(store) {
|
|
50
|
+
return Object.entries(store)
|
|
51
|
+
.filter(([, entry]) => validEntry(entry))
|
|
52
|
+
.map(([slug, entry]) => ({ slug, ...entry }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function assertChoice(value, allowed, field) {
|
|
56
|
+
if (!allowed.has(value)) throw new Error(`${field} must be one of: ${[...allowed].join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function addTaste({
|
|
60
|
+
verdict,
|
|
61
|
+
subject,
|
|
62
|
+
why,
|
|
63
|
+
scope = 'any',
|
|
64
|
+
example,
|
|
65
|
+
added,
|
|
66
|
+
root = process.cwd(),
|
|
67
|
+
} = {}) {
|
|
68
|
+
const cleanVerdict = String(verdict || '').trim().toLowerCase();
|
|
69
|
+
const cleanSubject = String(subject || '').trim();
|
|
70
|
+
const cleanWhy = String(why || '').trim();
|
|
71
|
+
const cleanScope = String(scope || 'any').trim().toLowerCase();
|
|
72
|
+
const cleanExample = example === undefined ? '' : String(example).trim();
|
|
73
|
+
const cleanAdded = String(added || '').trim();
|
|
74
|
+
|
|
75
|
+
assertChoice(cleanVerdict, VERDICTS, 'verdict');
|
|
76
|
+
assertChoice(cleanScope, SCOPES, 'scope');
|
|
77
|
+
if (!cleanSubject) throw new Error('subject is required');
|
|
78
|
+
if (!cleanWhy) throw new Error('why is required');
|
|
79
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(cleanAdded)) throw new Error('added must be an ISO date');
|
|
80
|
+
|
|
81
|
+
const baseSlug = slugify(cleanSubject);
|
|
82
|
+
if (!baseSlug) throw new Error('subject must contain letters or numbers');
|
|
83
|
+
|
|
84
|
+
const store = readTaste(root);
|
|
85
|
+
let slug = baseSlug;
|
|
86
|
+
let suffix = 2;
|
|
87
|
+
while (store[slug] && store[slug].subject !== cleanSubject) {
|
|
88
|
+
slug = `${baseSlug}-${suffix}`;
|
|
89
|
+
suffix += 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const entry = {
|
|
93
|
+
verdict: cleanVerdict,
|
|
94
|
+
subject: cleanSubject,
|
|
95
|
+
why: cleanWhy,
|
|
96
|
+
scope: cleanScope,
|
|
97
|
+
...(cleanExample ? { example: cleanExample } : {}),
|
|
98
|
+
added: cleanAdded,
|
|
99
|
+
};
|
|
100
|
+
store[slug] = entry;
|
|
101
|
+
|
|
102
|
+
const file = tastePath(root);
|
|
103
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
104
|
+
fs.writeFileSync(file, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
|
105
|
+
return { slug, ...entry };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function listTaste({ root = process.cwd(), scope } = {}) {
|
|
109
|
+
const cleanScope = scope === undefined ? null : String(scope).trim().toLowerCase();
|
|
110
|
+
if (cleanScope) assertChoice(cleanScope, SCOPES, 'scope');
|
|
111
|
+
return entriesFrom(readTaste(root))
|
|
112
|
+
.filter((entry) => !cleanScope || cleanScope === 'any' || entry.scope === 'any' || entry.scope === cleanScope)
|
|
113
|
+
.sort((left, right) => right.added.localeCompare(left.added) || left.slug.localeCompare(right.slug));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function keywords(text) {
|
|
117
|
+
return [...new Set(String(text || '').toLowerCase().split(/[^a-z0-9]+/)
|
|
118
|
+
.filter((word) => word.length >= 3 && !STOP_WORDS.has(word)))];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function matchTaste({ briefText = '', scope = 'any', root = process.cwd() } = {}) {
|
|
122
|
+
const cleanScope = SCOPES.has(String(scope || '').toLowerCase())
|
|
123
|
+
? String(scope).toLowerCase()
|
|
124
|
+
: 'any';
|
|
125
|
+
const briefWords = new Set(keywords(briefText));
|
|
126
|
+
if (!briefWords.size) return [];
|
|
127
|
+
|
|
128
|
+
const matches = [];
|
|
129
|
+
for (const entry of entriesFrom(readTaste(root))) {
|
|
130
|
+
if (cleanScope !== 'any' && entry.scope !== 'any' && entry.scope !== cleanScope) continue;
|
|
131
|
+
|
|
132
|
+
const subjectMatches = keywords(`${entry.slug} ${entry.subject}`).filter((word) => briefWords.has(word));
|
|
133
|
+
const whyMatches = keywords(entry.why).filter((word) => briefWords.has(word));
|
|
134
|
+
const exampleMatches = keywords(entry.example || '').filter((word) => briefWords.has(word));
|
|
135
|
+
const overlap = new Set([...subjectMatches, ...whyMatches, ...exampleMatches]);
|
|
136
|
+
if (!overlap.size) continue;
|
|
137
|
+
|
|
138
|
+
const scopeScore = entry.scope === cleanScope ? 10_000 : entry.scope === 'any' ? 5_000 : 0;
|
|
139
|
+
const score = scopeScore
|
|
140
|
+
+ (subjectMatches.length * 300)
|
|
141
|
+
+ (exampleMatches.length * 200)
|
|
142
|
+
+ (whyMatches.length * 100)
|
|
143
|
+
+ [...overlap].reduce((total, word) => total + word.length, 0);
|
|
144
|
+
matches.push({ ...entry, why_matched: `brief mentions ${[...overlap].join(', ')}`, score });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return matches
|
|
148
|
+
.sort((left, right) => right.score - left.score || left.slug.localeCompare(right.slug))
|
|
149
|
+
.slice(0, 5)
|
|
150
|
+
.map(({ score, ...entry }) => entry);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { addTaste, listTaste, matchTaste };
|
|
@@ -4,4 +4,20 @@ function encodeToolResult(result) {
|
|
|
4
4
|
return Buffer.from(JSON.stringify(result), 'utf8').toString('base64');
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
function toolResultEncodingEnabled(env = process.env) {
|
|
8
|
+
return env.ATRIS_TOOL_RESULT_B64 !== '0';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildToolResultBody(callId, result, env = process.env) {
|
|
12
|
+
if (!toolResultEncodingEnabled(env)) {
|
|
13
|
+
return { call_id: callId, result };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
call_id: callId,
|
|
18
|
+
result: encodeToolResult(result),
|
|
19
|
+
output_encoding: 'base64',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { buildToolResultBody, encodeToolResult, toolResultEncodingEnabled };
|