atris 3.33.3 → 3.35.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 (54) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +19 -0
  6. package/ax +475 -17
  7. package/bin/atris.js +206 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/loops.js +212 -0
  24. package/commands/member.js +398 -42
  25. package/commands/mission.js +598 -64
  26. package/commands/now.js +25 -1
  27. package/commands/radar.js +259 -14
  28. package/commands/serve.js +54 -0
  29. package/commands/status.js +50 -5
  30. package/commands/stripe.js +38 -0
  31. package/commands/supabase.js +39 -0
  32. package/commands/task.js +935 -71
  33. package/commands/truth.js +29 -3
  34. package/commands/unknowns.js +627 -0
  35. package/commands/update.js +44 -0
  36. package/commands/vercel.js +38 -0
  37. package/commands/worktree.js +68 -10
  38. package/commands/write.js +399 -0
  39. package/lib/auto-accept-certified.js +70 -19
  40. package/lib/autoland.js +39 -3
  41. package/lib/fleet.js +256 -13
  42. package/lib/memory-view.js +14 -5
  43. package/lib/mission-runtime-loop.js +7 -0
  44. package/lib/official-cli-integration.js +174 -0
  45. package/lib/outbound-send-gate.js +165 -0
  46. package/lib/permission-grants.js +293 -0
  47. package/lib/review-integrity.js +147 -0
  48. package/lib/runner-command.js +23 -0
  49. package/lib/task-db.js +220 -7
  50. package/lib/task-proof.js +20 -0
  51. package/lib/task-receipt.js +93 -0
  52. package/package.json +1 -1
  53. package/utils/update-check.js +27 -6
  54. package/atris/learnings.jsonl +0 -1
package/lib/task-db.js CHANGED
@@ -27,6 +27,7 @@ const path = require('path');
27
27
  const os = require('os');
28
28
  const crypto = require('crypto');
29
29
  const { DatabaseSync } = require('node:sqlite');
30
+ const reviewIntegrity = require('./review-integrity');
30
31
 
31
32
  const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
32
33
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
@@ -108,7 +109,10 @@ function appendTaskCompletionLogs(db, row, { status, actor, action, proof } = {}
108
109
  const metadata = row.metadata && typeof row.metadata === 'object' ? row.metadata : {};
109
110
  const proofText = compactLogText(proof || metadata.latest_agent_proof || metadata.verify || '', 500);
110
111
  const member = existingMemberSlug(row.workspace_root, row, actor);
111
- const title = status === 'failed' ? 'Task failed' : action === 'accepted' ? 'Task accepted' : 'Task completed';
112
+ const title = status === 'archived' ? 'Task archived'
113
+ : status === 'failed' ? 'Task failed'
114
+ : action === 'accepted' ? 'Task accepted'
115
+ : 'Task completed';
112
116
  const fields = {
113
117
  task: ref,
114
118
  title: row.title,
@@ -413,7 +417,7 @@ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
413
417
  FROM tasks
414
418
  ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
415
419
  ORDER BY
416
- CASE status WHEN 'open' THEN 0 WHEN 'claimed' THEN 1 WHEN 'review' THEN 2 WHEN 'failed' THEN 3 WHEN 'done' THEN 4 ELSE 5 END,
420
+ CASE status WHEN 'open' THEN 0 WHEN 'claimed' THEN 1 WHEN 'review' THEN 2 WHEN 'failed' THEN 3 WHEN 'done' THEN 4 WHEN 'archived' THEN 5 ELSE 6 END,
417
421
  CASE WHEN tag='endgame' THEN 0 ELSE 1 END,
418
422
  created_at DESC
419
423
  ${limit ? 'LIMIT ' + Number(limit) : ''}
@@ -506,10 +510,13 @@ function releaseTask(db, { id, actor }) {
506
510
  return { released: true, row: updated, event };
507
511
  }
508
512
 
509
- function doneTask(db, { id, status, actor, allowReview = false, action, proof } = {}) {
513
+ function doneTask(db, { id, status, actor, allowReview = false, action, proof, autoAccepted = false } = {}) {
510
514
  if (!id) throw new Error('id required');
511
515
  const final = status || 'done';
512
516
  if (!['done', 'failed'].includes(final)) throw new Error('status must be done|failed');
517
+ // 'archived' is a distinct terminal status written by archiveTask(), never
518
+ // by this function — a bulk sweep of duplicate/off-roadmap tasks must not
519
+ // read as a real failure. See archiveTask() below.
513
520
  const now = Date.now();
514
521
  const allowedStatuses = allowReview ? "'open', 'claimed', 'review'" : "'open', 'claimed'";
515
522
  const result = withBusyRetry(() => db.prepare(`
@@ -525,7 +532,11 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof }
525
532
  workspaceRoot: row.workspace_root,
526
533
  actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
527
534
  eventType: final === 'done' ? 'completed' : 'blocked',
528
- payload: { status: final, action: action || final },
535
+ payload: {
536
+ status: final,
537
+ action: action || final,
538
+ ...(autoAccepted ? { auto_accepted: true } : {}),
539
+ },
529
540
  });
530
541
  const logs = appendTaskCompletionLogs(db, row, {
531
542
  status: final,
@@ -538,11 +549,141 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof }
538
549
  return { updated: false };
539
550
  }
540
551
 
552
+ // Distinct terminal status for housekeeping sweeps (duplicate loop-ticks,
553
+ // off-roadmap backlog resets, synthetic-test cleanup). Never conflate this
554
+ // with `failed` — 'failed' means the work itself did not succeed, 'archived'
555
+ // means the work was swept off the board for reasons unrelated to whether it
556
+ // succeeded. Reward/RSI signal readers must treat the two differently.
557
+ // `fromFailed: true` is an explicit opt-in for sanctioned historical cleanup
558
+ // (e.g. the 109 duplicate "Loop tick:" orphans fail-closed before 'archived'
559
+ // existed — cluster 1 of failed-tasks-analysis-2026-07-03). It permits the
560
+ // failed→archived transition and records the prior status in
561
+ // metadata.archived_from. 'done' rows are never archivable: accepted work
562
+ // stays done, with or without the flag.
563
+ function archiveTask(db, { id, actor, reason, fromFailed = false } = {}) {
564
+ if (!id) throw new Error('id required');
565
+ const reasonText = String(reason || '').trim();
566
+ if (!reasonText) throw new Error('reason required');
567
+ const row = getTask(db, id);
568
+ if (!row) return { archived: false, reason: 'not_found' };
569
+ const allowedStatuses = fromFailed
570
+ ? ['open', 'claimed', 'review', 'failed']
571
+ : ['open', 'claimed', 'review'];
572
+ if (!allowedStatuses.includes(row.status)) {
573
+ return { archived: false, reason: `already_${row.status}` };
574
+ }
575
+ const now = Date.now();
576
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
577
+ metadata.archived_reason = reasonText;
578
+ metadata.archived_at = new Date(now).toISOString();
579
+ metadata.archived_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
580
+ metadata.approval_status = 'archived';
581
+ if (row.status === 'failed') metadata.archived_from = 'failed';
582
+ const statusPlaceholders = allowedStatuses.map(() => '?').join(', ');
583
+ const result = withBusyRetry(() => db.prepare(`
584
+ UPDATE tasks
585
+ SET status = 'archived',
586
+ done_at = ?,
587
+ updated_at = ?,
588
+ metadata = ?
589
+ WHERE id = ?
590
+ AND status IN (${statusPlaceholders})
591
+ `).run(now, now, JSON.stringify(metadata), id, ...allowedStatuses));
592
+ if (result.changes !== 1) return { archived: false, reason: 'stale_task_state' };
593
+ const updated = getTask(db, id);
594
+ const event = appendTaskEvent(db, {
595
+ taskId: id,
596
+ workspaceRoot: updated.workspace_root,
597
+ actor: metadata.archived_by,
598
+ eventType: 'archived',
599
+ payload: { reason: reasonText, ...(row.status === 'failed' ? { previous_status: 'failed' } : {}) },
600
+ });
601
+ const logs = appendTaskCompletionLogs(db, updated, {
602
+ status: 'archived',
603
+ actor: metadata.archived_by,
604
+ action: 'archived',
605
+ proof: reasonText,
606
+ });
607
+ return { archived: true, event, row: updated, logs };
608
+ }
609
+
610
+ // Marks metadata written by the 2026-06-10 "first-principles backlog reset"
611
+ // (see atris/logs/2026/2026-06-10.md) — a bulk pass that closed off-roadmap
612
+ // tasks, including certified/proof-backed done work, by writing status
613
+ // 'failed' because no distinct 'archived' status existed yet. This
614
+ // identifies exactly those rows so relabelArchivedTasks() can fix the label
615
+ // without touching any task that failed for a real reason.
616
+ function isJune10BacklogResetMarker(metadata) {
617
+ const m = metadata && typeof metadata === 'object' ? metadata : {};
618
+ if (!m.archived_at) return false;
619
+ return /first-principles backlog reset 2026-06-10/i.test(String(m.archive_reason || ''));
620
+ }
621
+
622
+ // One-time migration (OBL-1622): relabel the June-10 mislabeled rows from
623
+ // 'failed' to 'archived'. Dry-run only counts/samples; --apply performs the
624
+ // same status write path as archiveTask() (direct projection UPDATE +
625
+ // appendTaskEvent), never a raw projection-JSON edit.
626
+ function relabelArchivedTasks(db, { workspaceRoot: ws, apply = false, actor, limit = 5000 } = {}) {
627
+ const candidates = listTasks(db, { workspaceRoot: ws || null, status: 'failed', limit })
628
+ .filter(row => isJune10BacklogResetMarker(row.metadata));
629
+ const sample = candidates.slice(0, 10).map(row => ({ id: row.id, title: row.title }));
630
+ if (!apply) {
631
+ return { applied: false, count: candidates.length, sample };
632
+ }
633
+ const now = Date.now();
634
+ const actorText = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
635
+ const relabeledIds = [];
636
+ for (const row of candidates) {
637
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
638
+ metadata.relabeled_from_status = 'failed';
639
+ metadata.relabeled_at = new Date(now).toISOString();
640
+ metadata.relabeled_by = actorText;
641
+ metadata.relabel_reason = 'obl_1622_june_10_backlog_reset_relabel';
642
+ const result = withBusyRetry(() => db.prepare(`
643
+ UPDATE tasks
644
+ SET status = 'archived',
645
+ updated_at = ?,
646
+ metadata = ?
647
+ WHERE id = ?
648
+ AND status = 'failed'
649
+ `).run(now, JSON.stringify(metadata), row.id));
650
+ if (result.changes === 1) {
651
+ appendTaskEvent(db, {
652
+ taskId: row.id,
653
+ workspaceRoot: row.workspace_root,
654
+ actor: actorText,
655
+ eventType: 'relabeled_archived',
656
+ payload: { previous_status: 'failed', reason: 'obl_1622_june_10_backlog_reset_relabel' },
657
+ });
658
+ relabeledIds.push(row.id);
659
+ }
660
+ }
661
+ return { applied: true, count: relabeledIds.length, ids: relabeledIds, sample };
662
+ }
663
+
541
664
  function cleanLandingText(value) {
542
665
  const text = String(value || '').replace(/\s+/g, ' ').trim();
543
666
  return text || null;
544
667
  }
545
668
 
669
+ // Record who delivered this review pass and answer whether certification is
670
+ // allowed: at least one recorded pass actor must differ from the builder.
671
+ // The first pass actor stamps built_by; judge != worker from then on.
672
+ function recordReviewPassActor(metadata, claimedBy, rawActor) {
673
+ const actorNorm = reviewIntegrity.normalizeActor(
674
+ rawActor || process.env.ATRIS_AGENT_ID || process.env.USER || ''
675
+ );
676
+ if (!metadata.built_by && actorNorm) metadata.built_by = actorNorm;
677
+ const actors = Array.isArray(metadata.agent_review_actors) ? [...metadata.agent_review_actors] : [];
678
+ if (actorNorm && !actors.includes(actorNorm)) actors.push(actorNorm);
679
+ metadata.agent_review_actors = actors;
680
+ const builder = reviewIntegrity.normalizeActor(metadata.built_by || claimedBy || '');
681
+ const independent = builder
682
+ ? (actors.find((a) => a && a !== builder) || null)
683
+ : (actors.length >= 2 ? actors[1] : null);
684
+ return { builder: builder || null, independent };
685
+ }
686
+
546
687
  function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resultTrace }) {
547
688
  if (!id) throw new Error('id required');
548
689
  const text = String(proof || '').trim();
@@ -567,11 +708,13 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
567
708
  const cleaned = cleanLandingText(landingInput[key]);
568
709
  if (cleaned) metadata[`landing_${key}`] = cleaned;
569
710
  }
570
- if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES) {
711
+ const passRecord = recordReviewPassActor(metadata, row.claimed_by, actor);
712
+ if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
571
713
  metadata.agent_certified = true;
572
714
  metadata.agent_certified_at = new Date(now).toISOString();
573
715
  metadata.agent_certified_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
574
716
  metadata.agent_certification_policy = `${AGENT_CERTIFICATION_REVIEW_PASSES}_agent_review_passes`;
717
+ metadata.independent_review_by = passRecord.independent;
575
718
  }
576
719
  const result = withBusyRetry(() => db.prepare(`
577
720
  UPDATE tasks
@@ -1170,6 +1313,61 @@ function noteTask(db, { id, actor, content }) {
1170
1313
  return { noted: true, event };
1171
1314
  }
1172
1315
 
1316
+ function normalizeTagToken(value) {
1317
+ return String(value == null ? '' : value).trim().toLowerCase();
1318
+ }
1319
+
1320
+ // Update a task's flag list (`metadata.tags`) on an already-created task.
1321
+ // Tags are only settable at creation elsewhere; this is the after-the-fact
1322
+ // escape hatch so an open task that becomes an owner decision can be marked
1323
+ // `needs-human` — which sweep and fleet staffing both honor — instead of
1324
+ // being restaffed forever (CLI-879). Every change appends a
1325
+ // `task_tags_updated` event so the trail shows who flagged it and when.
1326
+ function tagTask(db, { id, actor, add = [], remove = [] } = {}) {
1327
+ if (!id) throw new Error('id required');
1328
+ const addTags = [...new Set((Array.isArray(add) ? add : [add]).map(normalizeTagToken).filter(Boolean))];
1329
+ const removeSet = new Set((Array.isArray(remove) ? remove : [remove]).map(normalizeTagToken).filter(Boolean));
1330
+ if (!addTags.length && !removeSet.size) return { tagged: false, reason: 'no_tags' };
1331
+ const row = getTask(db, id);
1332
+ if (!row) return { tagged: false, reason: 'not_found' };
1333
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1334
+ const before = [...new Set((Array.isArray(metadata.tags) ? metadata.tags : []).map(normalizeTagToken).filter(Boolean))];
1335
+ const removed = [];
1336
+ const next = before.filter((tag) => {
1337
+ if (removeSet.has(tag)) { removed.push(tag); return false; }
1338
+ return true;
1339
+ });
1340
+ const added = [];
1341
+ for (const tag of addTags) {
1342
+ // An explicit --remove of the same token wins over --add.
1343
+ if (removeSet.has(tag)) continue;
1344
+ if (!next.includes(tag)) { next.push(tag); added.push(tag); }
1345
+ }
1346
+ if (!added.length && !removed.length) {
1347
+ return { tagged: false, reason: 'no_changes', tags: before };
1348
+ }
1349
+ metadata.tags = next;
1350
+ const actorText = cleanStageText(actor) || null;
1351
+ const now = Math.max(Date.now(), Number(row.updated_at || 0) + 1);
1352
+ const result = withBusyRetry(() => db.prepare(`
1353
+ UPDATE tasks
1354
+ SET metadata = ?,
1355
+ updated_at = ?
1356
+ WHERE id = ?
1357
+ AND updated_at = ?
1358
+ `).run(JSON.stringify(metadata), now, id, row.updated_at));
1359
+ if (result.changes !== 1) return { tagged: false, reason: 'stale_task_state' };
1360
+ const updated = getTask(db, id);
1361
+ const event = appendTaskEvent(db, {
1362
+ taskId: id,
1363
+ workspaceRoot: updated.workspace_root,
1364
+ actor: actorText,
1365
+ eventType: 'task_tags_updated',
1366
+ payload: { added, removed, tags: next, previous_tags: before },
1367
+ });
1368
+ return { tagged: true, event, row: updated, tags: next, added, removed };
1369
+ }
1370
+
1173
1371
  function taskChatPacketFromPayload(payload) {
1174
1372
  const data = payload && typeof payload === 'object' ? payload : {};
1175
1373
  const lines = ['TASK_CHAT_UPDATE'];
@@ -1235,7 +1433,7 @@ function chatTask(db, { id, actor, content, goal, summary }) {
1235
1433
  };
1236
1434
  }
1237
1435
 
1238
- function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, careerXpEligible = false, clearedFields = [], landing = {} }) {
1436
+ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, careerXpEligible = false, clearedFields = [], landing = {}, autoAccepted = false }) {
1239
1437
  if (!id) throw new Error('id required');
1240
1438
  const row = getTask(db, id);
1241
1439
  if (!row) return { reviewed: false, reason: 'not_found' };
@@ -1243,6 +1441,14 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1243
1441
  const now = Date.now();
1244
1442
  const reviewer = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1245
1443
  const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1444
+ const reviewerNorm = reviewIntegrity.normalizeActor(reviewer);
1445
+ const builderNorm = reviewIntegrity.taskBuilder(row);
1446
+ // judge != worker applies pre-acceptance only: a builder cannot positive-
1447
+ // reward their own row while it still sits in review. Done rows are past
1448
+ // the human gate; finish and post-acceptance XP bookkeeping stay open.
1449
+ if (numericReward > 0 && row.status === 'review' && builderNorm && reviewerNorm && reviewerNorm === builderNorm) {
1450
+ return { reviewed: false, reason: 'judge_equals_worker', builder: builderNorm };
1451
+ }
1246
1452
  const proofText = String(proof || '').trim();
1247
1453
  const verifyText = cleanStageText(verify);
1248
1454
  const lessonText = String(lesson || '').trim();
@@ -1284,11 +1490,13 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1284
1490
  if (reviewingPendingProof) {
1285
1491
  reviewPassCount += 1;
1286
1492
  metadata.agent_review_pass_count = reviewPassCount;
1287
- if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES) {
1493
+ const passRecord = recordReviewPassActor(metadata, row.claimed_by, reviewer);
1494
+ if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
1288
1495
  metadata.agent_certified = true;
1289
1496
  metadata.agent_certified_at = new Date(now).toISOString();
1290
1497
  metadata.agent_certified_by = reviewer;
1291
1498
  metadata.agent_certification_policy = `${AGENT_CERTIFICATION_REVIEW_PASSES}_agent_review_passes`;
1499
+ metadata.independent_review_by = passRecord.independent;
1292
1500
  }
1293
1501
  }
1294
1502
  if (numericReward > 0 && row.status === 'done') {
@@ -1312,6 +1520,7 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1312
1520
  verify: verifyText || null,
1313
1521
  career_xp_eligible: Boolean(careerXpEligible),
1314
1522
  };
1523
+ if (autoAccepted) payload.auto_accepted = true;
1315
1524
  if (reviewingPendingProof) {
1316
1525
  payload.review_pass_count = reviewPassCount;
1317
1526
  payload.agent_certified = metadata.agent_certified === true;
@@ -1762,11 +1971,15 @@ module.exports = {
1762
1971
  backlogTask,
1763
1972
  clearPlanTasks,
1764
1973
  doneTask,
1974
+ archiveTask,
1975
+ relabelArchivedTasks,
1976
+ isJune10BacklogResetMarker,
1765
1977
  readyTask,
1766
1978
  reviseTask,
1767
1979
  stageTask,
1768
1980
  noteTask,
1769
1981
  chatTask,
1982
+ tagTask,
1770
1983
  reviewTask,
1771
1984
  appendTaskEvent,
1772
1985
  listTaskEvents,
package/lib/task-proof.js CHANGED
@@ -9,6 +9,8 @@ const RECEIPT_OR_ARTIFACT_RE = /\b(?:receipt|artifact|screenshot|log|trace|path=
9
9
  const RESULT_PAIR_RE = /\b(?:typecheck|build|smoke|test|pytest|verifier|validation|validated|verified|render|diff|sync|lineage|projection)\b.{0,80}\b(?:pass|passed|failed|green|ok|exit\s*0|reviewed)\b|\b(?:pass|passed|failed|green|ok|exit\s*0|reviewed)\b.{0,80}\b(?:typecheck|build|smoke|test|pytest|verifier|validation|validated|verified|render|diff|sync|lineage|projection)\b/i;
10
10
  const HUMAN_PROOF_RE = /\b(?:team human approved|human approved|human approval|approved by|accepted by|reviewed by|customer replied|customer approved|customer accepted|replied)\b/i;
11
11
  const FILE_ACTION_RE = /\b(?:changed|updated|edited|created|deleted|saved|wrote|patched|reviewed|verified|validated|opened|read)\b/i;
12
+ const VERIFIED_PROOF_RE = /^\[verified\]\s+`[^`]+`\s+passed\s+\(exit 0\)(?:\s|$)/i;
13
+ const SECOND_ACTOR_EXECUTED_PROOF_RE = /^Second-actor check:\s+`[^`]+`\s+re-run by [^,]+,\s+exited 0(?:[.\s]|$)/i;
12
14
 
13
15
  function compactWhitespace(text) {
14
16
  return String(text || '').replace(/\s+/g, ' ').trim();
@@ -37,6 +39,22 @@ function taskProofLooksMeaningful(proof) {
37
39
  return taskProofState(proof).ok;
38
40
  }
39
41
 
42
+ function taskProofExecutionState(proof) {
43
+ const text = compactWhitespace(proof);
44
+ if (!text) return { ok: false, reason: 'proof_not_executed', detail: 'executed proof required' };
45
+ if (VERIFIED_PROOF_RE.test(text)) return { ok: true, reason: 'verified_proof' };
46
+ if (SECOND_ACTOR_EXECUTED_PROOF_RE.test(text)) return { ok: true, reason: 'second_actor_executed_proof' };
47
+ return {
48
+ ok: false,
49
+ reason: 'proof_not_executed',
50
+ detail: 'proof must come from an executed verifier, not a free-text claim that tests passed',
51
+ };
52
+ }
53
+
54
+ function taskProofLooksExecuted(proof) {
55
+ return taskProofExecutionState(proof).ok;
56
+ }
57
+
40
58
  function tailText(text, max = 400) {
41
59
  const trimmed = String(text || '').trim();
42
60
  if (trimmed.length <= max) return trimmed;
@@ -73,6 +91,8 @@ function buildVerifiedProof(verifyCmd, baseProof = '', runner, options = {}) {
73
91
 
74
92
  module.exports = {
75
93
  taskProofLooksMeaningful,
94
+ taskProofLooksExecuted,
76
95
  taskProofState,
96
+ taskProofExecutionState,
77
97
  buildVerifiedProof,
78
98
  };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ // Receipt writer for task proofs. Mirrors the mission receipt shape
4
+ // (commands/mission.js writeReceipt) so lib/receipt-evidence.js can validate
5
+ // paths named in a proof at the review gate: schema tag, `at` timestamp,
6
+ // result.passed. Every call writes a receipt file, pass or fail — a failing
7
+ // verifier still leaves a record, it just can't be cited to move a task to
8
+ // ready.
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { spawnSync } = require('child_process');
13
+
14
+ function tailText(text, max = 400) {
15
+ const trimmed = String(text || '').trim();
16
+ if (trimmed.length <= max) return trimmed;
17
+ return `...${trimmed.slice(-max)}`;
18
+ }
19
+
20
+ function gitCommitHash(root) {
21
+ try {
22
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' });
23
+ if (result.status === 0) return String(result.stdout || '').trim() || null;
24
+ } catch {
25
+ // ignore — not every workspace is a git repo
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function runsDir(root) {
31
+ return path.join(root, 'atris', 'runs');
32
+ }
33
+
34
+ function slugifyTaskId(taskId) {
35
+ return String(taskId || 'task')
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, '-')
38
+ .replace(/^-+|-+$/g, '')
39
+ .slice(0, 40) || 'task';
40
+ }
41
+
42
+ // Runs `command` for `taskId`, writes atris/runs/<date>-task-<id>-<time>.json,
43
+ // and returns { ok, passed, receiptPath, exit, signal, output }. `ok` mirrors
44
+ // `passed` — kept as a separate field so callers reading either name work.
45
+ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}) {
46
+ const cmd = String(command || '').trim();
47
+ if (!taskId) return { ok: false, passed: false, reason: 'task_id_required' };
48
+ if (!cmd) return { ok: false, passed: false, reason: 'verify_command_required' };
49
+ const run = runner || spawnSync;
50
+ const result = run('bash', ['-lc', cmd], {
51
+ cwd: root,
52
+ encoding: 'utf8',
53
+ timeout: 120000,
54
+ });
55
+ if (result.error) {
56
+ return { ok: false, passed: false, reason: 'verifier_spawn_failed', error: result.error.message, cmd };
57
+ }
58
+ const output = tailText(`${result.stdout || ''}${result.stderr || ''}`);
59
+ const passed = result.status === 0;
60
+ const dir = runsDir(root);
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ const now = new Date();
63
+ const date = now.toISOString().slice(0, 10);
64
+ const safeTime = now.toISOString().replace(/[:.]/g, '-');
65
+ const receiptFile = path.join(dir, `${date}-task-${slugifyTaskId(taskId)}-${safeTime}.json`);
66
+ const receiptPath = path.relative(root, receiptFile);
67
+ const receipt = {
68
+ schema: 'atris.task_receipt.v1',
69
+ task_id: String(taskId),
70
+ command: cmd,
71
+ at: now.toISOString(),
72
+ commit: gitCommitHash(root),
73
+ result: {
74
+ passed,
75
+ exit: result.status,
76
+ signal: result.signal || null,
77
+ output,
78
+ },
79
+ };
80
+ fs.writeFileSync(receiptFile, JSON.stringify(receipt, null, 2) + '\n', 'utf8');
81
+ return {
82
+ ok: passed,
83
+ passed,
84
+ receiptPath,
85
+ receiptFile,
86
+ exit: result.status,
87
+ signal: result.signal || null,
88
+ output,
89
+ cmd,
90
+ };
91
+ }
92
+
93
+ module.exports = { writeTaskReceipt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.33.3",
3
+ "version": "3.35.0",
4
4
  "main": "bin/atris.js",
5
5
  "bin": {
6
6
  "atris": "bin/atris.js",
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const { spawn, spawnSync } = require('child_process');
6
6
 
7
7
  const PACKAGE_NAME = 'atris';
8
+ const NPM_SELF_UPDATE_COMMAND = `npm install -g ${PACKAGE_NAME}@latest`;
8
9
  const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
9
10
  const ATRIS_DIR = path.join(os.homedir(), '.atris');
10
11
  const CACHE_FILE = path.join(ATRIS_DIR, '.update-check');
@@ -217,13 +218,31 @@ async function checkForUpdates(force = false) {
217
218
  return null;
218
219
  }
219
220
 
220
- function showUpdateNotification(updateInfo) {
221
+ function isGitCheckout(packageRoot = path.join(__dirname, '..')) {
222
+ return fs.existsSync(path.join(path.resolve(packageRoot), '.git'));
223
+ }
224
+
225
+ function getNpmSelfUpdateCommand() {
226
+ return NPM_SELF_UPDATE_COMMAND;
227
+ }
228
+
229
+ function getNpmSelfUpdateSpawnArgs() {
230
+ return {
231
+ command: 'npm',
232
+ args: ['install', '-g', `${PACKAGE_NAME}@latest`],
233
+ };
234
+ }
235
+
236
+ function showUpdateNotification(updateInfo, options = {}) {
221
237
  if (!updateInfo || !updateInfo.needsUpdate) return;
222
238
 
223
- // Single yellow warning line non-intrusive
239
+ const packageRoot = options.packageRoot || path.join(__dirname, '..');
224
240
  const yellow = '\x1b[33m';
225
241
  const reset = '\x1b[0m';
226
- console.log(`${yellow}Update available: ${updateInfo.installed} → ${updateInfo.latest}. Run: atris upgrade${reset}`);
242
+ const installHint = isGitCheckout(packageRoot)
243
+ ? 'run: atris upgrade'
244
+ : `run: ${NPM_SELF_UPDATE_COMMAND}`;
245
+ console.log(`${yellow}update available: ${updateInfo.installed} -> ${updateInfo.latest}. ${installHint}${reset}`);
227
246
  }
228
247
 
229
248
  function inspectInstallGitState(packageRoot = path.join(__dirname, '..')) {
@@ -288,9 +307,8 @@ function shouldAutoUpdate(updateInfo, state, env = process.env) {
288
307
  if (mode === 'off') return false;
289
308
  if (mode === 'force') return true;
290
309
 
291
- // Packaged npm installs are not git repositories. Git checkouts may be linked
292
- // dev installs, where a global npm install would not update the code on PATH.
293
- return !(state && state.isGitRepo);
310
+ const packageRoot = state && state.root ? state.root : path.join(__dirname, '..');
311
+ return !isGitCheckout(packageRoot);
294
312
  }
295
313
 
296
314
  function autoUpdate(updateInfo, options = {}) {
@@ -331,4 +349,7 @@ module.exports = {
331
349
  shouldAutoUpdate,
332
350
  inspectInstallGitState,
333
351
  formatInstallGitWarning,
352
+ isGitCheckout,
353
+ getNpmSelfUpdateCommand,
354
+ getNpmSelfUpdateSpawnArgs,
334
355
  };
@@ -1 +0,0 @@
1
- {"ts":"2026-05-08T02:32:38.257Z","type":"pitfall","key":"narrow-grep-hides-matches","insight":"When operator says X exists in our code and a single-pattern grep returns 0 hits, do NOT conclude it does not exist. Run wider grep -rn first (whole backend/, not one subdir) before doubting. Tonight 2026-05-08: grepped \"gpt-5\\.5|gpt5\\.5|gpt-5p5\" in clients/ only, missed 20+ matches in routers/atris2_router.py + tests/, told operator gpt-5.5 was not wired. Operator was right.","confidence":5,"source":"observed","files":[]}