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.
Files changed (78) hide show
  1. package/AGENTS.md +25 -6
  2. package/atris/PERSONA.md +8 -4
  3. package/atris.md +7 -0
  4. package/ax +2 -1
  5. package/bin/atris.js +31 -6
  6. package/commands/agent-spawn.js +13 -11
  7. package/commands/autoland.js +28 -77
  8. package/commands/bench.js +10 -12
  9. package/commands/business.js +345 -0
  10. package/commands/chat-scan.js +5 -7
  11. package/commands/codex-goal.js +8 -10
  12. package/commands/computer.js +20 -0
  13. package/commands/console.js +19 -3
  14. package/commands/decide.js +166 -0
  15. package/commands/deck.js +1 -4
  16. package/commands/drill.js +14 -24
  17. package/commands/engine.js +196 -8
  18. package/commands/gm.js +8 -6
  19. package/commands/harvest.js +1 -4
  20. package/commands/init.js +23 -3
  21. package/commands/land.js +8 -14
  22. package/commands/launchpad.js +1 -14
  23. package/commands/lifecycle.js +5 -5
  24. package/commands/log.js +55 -5
  25. package/commands/member.js +558 -574
  26. package/commands/mission.js +456 -237
  27. package/commands/pack.js +2746 -164
  28. package/commands/play.js +6 -4
  29. package/commands/probe.js +2 -2
  30. package/commands/pulse.js +15 -16
  31. package/commands/release.js +10 -9
  32. package/commands/router.js +5 -4
  33. package/commands/site-deploy.js +885 -0
  34. package/commands/site.js +11 -2
  35. package/commands/slop.js +14 -2
  36. package/commands/stream.js +4 -18
  37. package/commands/task.js +880 -558
  38. package/commands/taste.js +101 -0
  39. package/commands/team.js +176 -3
  40. package/commands/vercel.js +4 -2
  41. package/commands/voice.js +195 -0
  42. package/commands/watch.js +1 -22
  43. package/commands/wiki.js +1 -4
  44. package/commands/workflow.js +2 -2
  45. package/commands/worktree.js +1 -14
  46. package/commands/xp.js +27 -24
  47. package/lib/accept-verify-gate.js +5 -1
  48. package/lib/arg-parser.js +41 -0
  49. package/lib/auto-accept-certified.js +116 -1
  50. package/lib/autoland.js +66 -0
  51. package/lib/bench/runner.js +19 -1
  52. package/lib/context-gatherer.js +7 -1
  53. package/lib/engine-registry.js +141 -20
  54. package/lib/falsifier-probe.js +84 -0
  55. package/lib/fleet.js +65 -15
  56. package/lib/git-spawn.js +15 -0
  57. package/lib/json-file.js +37 -0
  58. package/lib/known-commands.js +2 -2
  59. package/lib/lesson-preflight.js +146 -0
  60. package/lib/loop-doctor.js +0 -2
  61. package/lib/mission-human-asks.js +28 -0
  62. package/lib/mission-protected-lane.js +4 -1
  63. package/lib/official-cli-integration.js +47 -2
  64. package/lib/orb-context.js +8 -1
  65. package/lib/pack-capabilities.js +685 -0
  66. package/lib/router-brain.js +51 -1
  67. package/lib/runner-command.js +0 -6
  68. package/lib/self-drive.js +44 -13
  69. package/lib/task-db.js +137 -3
  70. package/lib/task-decision.js +50 -0
  71. package/lib/taste-lessons.js +153 -0
  72. package/lib/tool-result-encode.js +17 -1
  73. package/lib/voice-gate.js +66 -0
  74. package/lib/wish-audit.js +1 -1
  75. package/lib/wish-delegate.js +1 -1
  76. package/lib/zip.js +95 -7
  77. package/package.json +2 -1
  78. package/templates/business-starter/persona.md +9 -0
@@ -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: ranked,
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
  };
@@ -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) || (Array.isArray(mission.human_asks) && mission.human_asks.length)) {
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}:${matching.length}`,
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
- metadata ? JSON.stringify(metadata) : null,
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: metadata || null,
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
- module.exports = { encodeToolResult };
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 };