atris 3.34.0 → 3.36.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 (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. 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,
@@ -236,22 +240,43 @@ function newId() {
236
240
  return head + tail;
237
241
  }
238
242
 
239
- // Walk up from `start` to find the canonical workspace root. We check for
240
- // .git, then atris/, then .atris/. If none found, fall back to `start`. This
241
- // makes `atris task add` from a subdirectory write the same workspace_root
242
- // as parseTodo() reads from the project's atris/TODO.md.
243
+ // Resolve the canonical workspace root so `atris task` from any subdirectory
244
+ // keys the SAME store as the repo root. Precedence (nearest ancestor wins per
245
+ // pass):
246
+ // 1. A deliberate workspace spine — .atris/business.json (a bound customer
247
+ // sub-workspace) or atris/atris.md (the framework protocol file). These are
248
+ // intentional nested workspaces INSIDE a repo and must stay isolated.
249
+ // 2. The git toplevel (.git). One git repo == one workspace; this rescues a
250
+ // subdirectory whose only markers are a bare atris/ or .atris/ dir.
251
+ // 3. Legacy fallback for non-git trees: a bare atris/ or .atris/ dir.
252
+ //
253
+ // Before this, a bare atris/ or .atris/ dir short-circuited ahead of the git
254
+ // root, so a subdir like backend/ (which has an atris/ folder plus a stray
255
+ // .atris) hijacked itself as a second workspace root and split task/usage state
256
+ // into backend/.atris (proven footgun 2026-07-16). Checking .git only at the
257
+ // same level meant the walk returned before ever reaching the real repo root.
243
258
  function findWorkspaceRoot(start) {
244
- let cur = path.resolve(start || process.cwd());
245
- // Cap the walk at 32 levels to avoid pathological symlink loops.
246
- for (let i = 0; i < 32; i++) {
247
- if (fs.existsSync(path.join(cur, '.git'))) return cur;
248
- if (fs.existsSync(path.join(cur, 'atris'))) return cur;
249
- if (fs.existsSync(path.join(cur, '.atris'))) return cur;
250
- const parent = path.dirname(cur);
251
- if (parent === cur) break;
252
- cur = parent;
253
- }
254
- return path.resolve(start || process.cwd());
259
+ const origin = path.resolve(start || process.cwd());
260
+ // Cap each walk at 32 levels to avoid pathological symlink loops.
261
+ const walkUp = (test) => {
262
+ let cur = origin;
263
+ for (let i = 0; i < 32; i++) {
264
+ if (test(cur)) return cur;
265
+ const parent = path.dirname(cur);
266
+ if (parent === cur) break;
267
+ cur = parent;
268
+ }
269
+ return null;
270
+ };
271
+ const spine = walkUp((dir) => fs.existsSync(path.join(dir, '.atris', 'business.json'))
272
+ || fs.existsSync(path.join(dir, 'atris', 'atris.md')));
273
+ if (spine) return spine;
274
+ const gitRoot = walkUp((dir) => fs.existsSync(path.join(dir, '.git')));
275
+ if (gitRoot) return gitRoot;
276
+ const legacy = walkUp((dir) => fs.existsSync(path.join(dir, 'atris'))
277
+ || fs.existsSync(path.join(dir, '.atris')));
278
+ if (legacy) return legacy;
279
+ return origin;
255
280
  }
256
281
 
257
282
  function workspaceRoot(cwd) {
@@ -413,7 +438,7 @@ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
413
438
  FROM tasks
414
439
  ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
415
440
  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,
441
+ 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
442
  CASE WHEN tag='endgame' THEN 0 ELSE 1 END,
418
443
  created_at DESC
419
444
  ${limit ? 'LIMIT ' + Number(limit) : ''}
@@ -506,10 +531,13 @@ function releaseTask(db, { id, actor }) {
506
531
  return { released: true, row: updated, event };
507
532
  }
508
533
 
509
- function doneTask(db, { id, status, actor, allowReview = false, action, proof } = {}) {
534
+ function doneTask(db, { id, status, actor, allowReview = false, action, proof, autoAccepted = false } = {}) {
510
535
  if (!id) throw new Error('id required');
511
536
  const final = status || 'done';
512
537
  if (!['done', 'failed'].includes(final)) throw new Error('status must be done|failed');
538
+ // 'archived' is a distinct terminal status written by archiveTask(), never
539
+ // by this function — a bulk sweep of duplicate/off-roadmap tasks must not
540
+ // read as a real failure. See archiveTask() below.
513
541
  const now = Date.now();
514
542
  const allowedStatuses = allowReview ? "'open', 'claimed', 'review'" : "'open', 'claimed'";
515
543
  const result = withBusyRetry(() => db.prepare(`
@@ -525,7 +553,11 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof }
525
553
  workspaceRoot: row.workspace_root,
526
554
  actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
527
555
  eventType: final === 'done' ? 'completed' : 'blocked',
528
- payload: { status: final, action: action || final },
556
+ payload: {
557
+ status: final,
558
+ action: action || final,
559
+ ...(autoAccepted ? { auto_accepted: true } : {}),
560
+ },
529
561
  });
530
562
  const logs = appendTaskCompletionLogs(db, row, {
531
563
  status: final,
@@ -538,12 +570,147 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof }
538
570
  return { updated: false };
539
571
  }
540
572
 
573
+ // Distinct terminal status for housekeeping sweeps (duplicate loop-ticks,
574
+ // off-roadmap backlog resets, synthetic-test cleanup). Never conflate this
575
+ // with `failed` — 'failed' means the work itself did not succeed, 'archived'
576
+ // means the work was swept off the board for reasons unrelated to whether it
577
+ // succeeded. Reward/RSI signal readers must treat the two differently.
578
+ // `fromFailed: true` is an explicit opt-in for sanctioned historical cleanup
579
+ // (e.g. the 109 duplicate "Loop tick:" orphans fail-closed before 'archived'
580
+ // existed — cluster 1 of failed-tasks-analysis-2026-07-03). It permits the
581
+ // failed→archived transition and records the prior status in
582
+ // metadata.archived_from. Individual archive commands never archive 'done'
583
+ // rows. The clear-done sweep opts in through fromDone after selecting only the
584
+ // same 'done' rows counted by `atris status`.
585
+ function archiveTask(db, { id, actor, reason, fromFailed = false, fromDone = false } = {}) {
586
+ if (!id) throw new Error('id required');
587
+ const reasonText = String(reason || '').trim();
588
+ if (!reasonText) throw new Error('reason required');
589
+ const row = getTask(db, id);
590
+ if (!row) return { archived: false, reason: 'not_found' };
591
+ const allowedStatuses = ['open', 'claimed', 'review'];
592
+ if (fromFailed) allowedStatuses.push('failed');
593
+ if (fromDone) allowedStatuses.push('done');
594
+ if (!allowedStatuses.includes(row.status)) {
595
+ return { archived: false, reason: `already_${row.status}` };
596
+ }
597
+ const now = Date.now();
598
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
599
+ metadata.archived_reason = reasonText;
600
+ metadata.archived_at = new Date(now).toISOString();
601
+ metadata.archived_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
602
+ metadata.approval_status = 'archived';
603
+ if (row.status === 'failed' || row.status === 'done') metadata.archived_from = row.status;
604
+ const terminalAt = row.status === 'done' && row.done_at ? row.done_at : now;
605
+ const statusPlaceholders = allowedStatuses.map(() => '?').join(', ');
606
+ const result = withBusyRetry(() => db.prepare(`
607
+ UPDATE tasks
608
+ SET status = 'archived',
609
+ done_at = ?,
610
+ updated_at = ?,
611
+ metadata = ?
612
+ WHERE id = ?
613
+ AND status IN (${statusPlaceholders})
614
+ `).run(terminalAt, now, JSON.stringify(metadata), id, ...allowedStatuses));
615
+ if (result.changes !== 1) return { archived: false, reason: 'stale_task_state' };
616
+ const updated = getTask(db, id);
617
+ const event = appendTaskEvent(db, {
618
+ taskId: id,
619
+ workspaceRoot: updated.workspace_root,
620
+ actor: metadata.archived_by,
621
+ eventType: 'archived',
622
+ payload: {
623
+ reason: reasonText,
624
+ ...(['failed', 'done'].includes(row.status) ? { previous_status: row.status } : {}),
625
+ },
626
+ });
627
+ const logs = appendTaskCompletionLogs(db, updated, {
628
+ status: 'archived',
629
+ actor: metadata.archived_by,
630
+ action: 'archived',
631
+ proof: reasonText,
632
+ });
633
+ return { archived: true, event, row: updated, logs };
634
+ }
635
+
636
+ // Marks metadata written by the 2026-06-10 "first-principles backlog reset"
637
+ // (see atris/logs/2026/2026-06-10.md) — a bulk pass that closed off-roadmap
638
+ // tasks, including certified/proof-backed done work, by writing status
639
+ // 'failed' because no distinct 'archived' status existed yet. This
640
+ // identifies exactly those rows so relabelArchivedTasks() can fix the label
641
+ // without touching any task that failed for a real reason.
642
+ function isJune10BacklogResetMarker(metadata) {
643
+ const m = metadata && typeof metadata === 'object' ? metadata : {};
644
+ if (!m.archived_at) return false;
645
+ return /first-principles backlog reset 2026-06-10/i.test(String(m.archive_reason || ''));
646
+ }
647
+
648
+ // One-time migration (OBL-1622): relabel the June-10 mislabeled rows from
649
+ // 'failed' to 'archived'. Dry-run only counts/samples; --apply performs the
650
+ // same status write path as archiveTask() (direct projection UPDATE +
651
+ // appendTaskEvent), never a raw projection-JSON edit.
652
+ function relabelArchivedTasks(db, { workspaceRoot: ws, apply = false, actor, limit = 5000 } = {}) {
653
+ const candidates = listTasks(db, { workspaceRoot: ws || null, status: 'failed', limit })
654
+ .filter(row => isJune10BacklogResetMarker(row.metadata));
655
+ const sample = candidates.slice(0, 10).map(row => ({ id: row.id, title: row.title }));
656
+ if (!apply) {
657
+ return { applied: false, count: candidates.length, sample };
658
+ }
659
+ const now = Date.now();
660
+ const actorText = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
661
+ const relabeledIds = [];
662
+ for (const row of candidates) {
663
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
664
+ metadata.relabeled_from_status = 'failed';
665
+ metadata.relabeled_at = new Date(now).toISOString();
666
+ metadata.relabeled_by = actorText;
667
+ metadata.relabel_reason = 'obl_1622_june_10_backlog_reset_relabel';
668
+ const result = withBusyRetry(() => db.prepare(`
669
+ UPDATE tasks
670
+ SET status = 'archived',
671
+ updated_at = ?,
672
+ metadata = ?
673
+ WHERE id = ?
674
+ AND status = 'failed'
675
+ `).run(now, JSON.stringify(metadata), row.id));
676
+ if (result.changes === 1) {
677
+ appendTaskEvent(db, {
678
+ taskId: row.id,
679
+ workspaceRoot: row.workspace_root,
680
+ actor: actorText,
681
+ eventType: 'relabeled_archived',
682
+ payload: { previous_status: 'failed', reason: 'obl_1622_june_10_backlog_reset_relabel' },
683
+ });
684
+ relabeledIds.push(row.id);
685
+ }
686
+ }
687
+ return { applied: true, count: relabeledIds.length, ids: relabeledIds, sample };
688
+ }
689
+
541
690
  function cleanLandingText(value) {
542
691
  const text = String(value || '').replace(/\s+/g, ' ').trim();
543
692
  return text || null;
544
693
  }
545
694
 
546
- function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resultTrace }) {
695
+ // Record who delivered this review pass and answer whether certification is
696
+ // allowed: at least one recorded pass actor must differ from the builder.
697
+ // The first pass actor stamps built_by; judge != worker from then on.
698
+ function recordReviewPassActor(metadata, claimedBy, rawActor) {
699
+ const actorNorm = reviewIntegrity.normalizeActor(
700
+ rawActor || process.env.ATRIS_AGENT_ID || process.env.USER || ''
701
+ );
702
+ if (!metadata.built_by && actorNorm) metadata.built_by = actorNorm;
703
+ const actors = Array.isArray(metadata.agent_review_actors) ? [...metadata.agent_review_actors] : [];
704
+ if (actorNorm && !actors.includes(actorNorm)) actors.push(actorNorm);
705
+ metadata.agent_review_actors = actors;
706
+ const builder = reviewIntegrity.normalizeActor(metadata.built_by || claimedBy || '');
707
+ const independent = builder
708
+ ? (actors.find((a) => a && a !== builder) || null)
709
+ : (actors.length >= 2 ? actors[1] : null);
710
+ return { builder: builder || null, independent };
711
+ }
712
+
713
+ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resultTrace, result: resultSentence, reason }) {
547
714
  if (!id) throw new Error('id required');
548
715
  const text = String(proof || '').trim();
549
716
  if (!text) throw new Error('proof required');
@@ -562,16 +729,22 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
562
729
  metadata.latest_agent_proof = text;
563
730
  metadata.latest_agent_lesson = String(lesson || '').trim() || null;
564
731
  metadata.latest_agent_next_task = String(nextTask || '').trim() || null;
732
+ const resultText = cleanLandingText(resultSentence);
733
+ if (resultText) metadata.result = resultText;
734
+ const reasonText = cleanLandingText(reason);
735
+ if (reasonText) metadata.result_reason = reasonText;
565
736
  const landingInput = landing && typeof landing === 'object' ? landing : {};
566
737
  for (const key of ['happened', 'checked', 'tested', 'decision']) {
567
738
  const cleaned = cleanLandingText(landingInput[key]);
568
739
  if (cleaned) metadata[`landing_${key}`] = cleaned;
569
740
  }
570
- if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES) {
741
+ const passRecord = recordReviewPassActor(metadata, row.claimed_by, actor);
742
+ if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
571
743
  metadata.agent_certified = true;
572
744
  metadata.agent_certified_at = new Date(now).toISOString();
573
745
  metadata.agent_certified_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
574
746
  metadata.agent_certification_policy = `${AGENT_CERTIFICATION_REVIEW_PASSES}_agent_review_passes`;
747
+ metadata.independent_review_by = passRecord.independent;
575
748
  }
576
749
  const result = withBusyRetry(() => db.prepare(`
577
750
  UPDATE tasks
@@ -592,6 +765,7 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
592
765
  review_pass_count: reviewPassCount,
593
766
  agent_certified: metadata.agent_certified === true,
594
767
  agent_certification_policy: metadata.agent_certification_policy || null,
768
+ result: metadata.result || null,
595
769
  };
596
770
  if (resultTrace && typeof resultTrace === 'object') {
597
771
  payload.result_trace = resultTrace;
@@ -613,13 +787,46 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
613
787
  return { ready: true, event, row: updated };
614
788
  }
615
789
 
616
- function reviseTask(db, { id, actor, note }) {
790
+ function setTaskResult(db, { id, actor, result }) {
791
+ if (!id) throw new Error('id required');
792
+ const text = cleanLandingText(result);
793
+ if (!text) throw new Error('result required');
794
+ const row = getTask(db, id);
795
+ if (!row) return { saved: false, reason: 'not_found' };
796
+ const now = Date.now();
797
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
798
+ metadata.result = text;
799
+ metadata.result_at = new Date(now).toISOString();
800
+ metadata.result_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
801
+ const updated = withBusyRetry(() => db.prepare(`
802
+ UPDATE tasks
803
+ SET metadata = ?,
804
+ updated_at = ?
805
+ WHERE id = ?
806
+ `).run(JSON.stringify(metadata), now, id));
807
+ if (updated.changes !== 1) return { saved: false, reason: 'stale_task_state' };
808
+ const latest = getTask(db, id);
809
+ const event = appendTaskEvent(db, {
810
+ taskId: id,
811
+ workspaceRoot: latest.workspace_root,
812
+ actor: metadata.result_by,
813
+ eventType: 'result_set',
814
+ payload: { result: text },
815
+ });
816
+ return { saved: true, event, row: latest };
817
+ }
818
+
819
+ function reviseTask(db, { id, actor, note, allowDone = false }) {
617
820
  if (!id) throw new Error('id required');
618
821
  const text = String(note || '').trim();
619
822
  if (!text) throw new Error('note required');
620
823
  const row = getTask(db, id);
621
824
  if (!row) return { revised: false, reason: 'not_found' };
622
- if (row.status !== 'review') {
825
+ const acceptedDone = allowDone
826
+ && row.status === 'done'
827
+ && row.metadata
828
+ && row.metadata.approval_status === 'accepted';
829
+ if (row.status !== 'review' && !acceptedDone) {
623
830
  return { revised: false, reason: `not_reviewable_${row.status}` };
624
831
  }
625
832
  const now = Date.now();
@@ -644,7 +851,19 @@ function reviseTask(db, { id, actor, note }) {
644
851
  metadata.human_revision_at = new Date(now).toISOString();
645
852
  metadata.human_revision_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
646
853
  metadata.human_revision_note = text;
854
+ if (acceptedDone) {
855
+ for (const key of [
856
+ 'accepted_at',
857
+ 'accepted_by',
858
+ 'auto_accepted_at',
859
+ 'auto_accepted_by',
860
+ 'auto_accept_policy',
861
+ ]) {
862
+ delete metadata[key];
863
+ }
864
+ }
647
865
  const revisedStatus = row.claimed_by ? 'claimed' : 'open';
866
+ const expectedStatus = acceptedDone ? 'done' : 'review';
648
867
  const result = withBusyRetry(() => db.prepare(`
649
868
  UPDATE tasks
650
869
  SET status = ?,
@@ -652,8 +871,8 @@ function reviseTask(db, { id, actor, note }) {
652
871
  updated_at = ?,
653
872
  metadata = ?
654
873
  WHERE id = ?
655
- AND status = 'review'
656
- `).run(revisedStatus, now, JSON.stringify(metadata), id));
874
+ AND status = ?
875
+ `).run(revisedStatus, now, JSON.stringify(metadata), id, expectedStatus));
657
876
  if (result.changes !== 1) return { revised: false, reason: 'not_updated' };
658
877
  const updated = getTask(db, id);
659
878
  const event = appendTaskEvent(db, {
@@ -665,6 +884,7 @@ function reviseTask(db, { id, actor, note }) {
665
884
  note: text,
666
885
  approval_status: 'revise',
667
886
  revision_count: revisionCount,
887
+ ...(acceptedDone ? { previous_status: row.status } : {}),
668
888
  },
669
889
  });
670
890
  const episode = taskEpisodeFromRevision(updated, event, event.payload);
@@ -1170,6 +1390,61 @@ function noteTask(db, { id, actor, content }) {
1170
1390
  return { noted: true, event };
1171
1391
  }
1172
1392
 
1393
+ function normalizeTagToken(value) {
1394
+ return String(value == null ? '' : value).trim().toLowerCase();
1395
+ }
1396
+
1397
+ // Update a task's flag list (`metadata.tags`) on an already-created task.
1398
+ // Tags are only settable at creation elsewhere; this is the after-the-fact
1399
+ // escape hatch so an open task that becomes an owner decision can be marked
1400
+ // `needs-human` — which sweep and fleet staffing both honor — instead of
1401
+ // being restaffed forever (CLI-879). Every change appends a
1402
+ // `task_tags_updated` event so the trail shows who flagged it and when.
1403
+ function tagTask(db, { id, actor, add = [], remove = [] } = {}) {
1404
+ if (!id) throw new Error('id required');
1405
+ const addTags = [...new Set((Array.isArray(add) ? add : [add]).map(normalizeTagToken).filter(Boolean))];
1406
+ const removeSet = new Set((Array.isArray(remove) ? remove : [remove]).map(normalizeTagToken).filter(Boolean));
1407
+ if (!addTags.length && !removeSet.size) return { tagged: false, reason: 'no_tags' };
1408
+ const row = getTask(db, id);
1409
+ if (!row) return { tagged: false, reason: 'not_found' };
1410
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1411
+ const before = [...new Set((Array.isArray(metadata.tags) ? metadata.tags : []).map(normalizeTagToken).filter(Boolean))];
1412
+ const removed = [];
1413
+ const next = before.filter((tag) => {
1414
+ if (removeSet.has(tag)) { removed.push(tag); return false; }
1415
+ return true;
1416
+ });
1417
+ const added = [];
1418
+ for (const tag of addTags) {
1419
+ // An explicit --remove of the same token wins over --add.
1420
+ if (removeSet.has(tag)) continue;
1421
+ if (!next.includes(tag)) { next.push(tag); added.push(tag); }
1422
+ }
1423
+ if (!added.length && !removed.length) {
1424
+ return { tagged: false, reason: 'no_changes', tags: before };
1425
+ }
1426
+ metadata.tags = next;
1427
+ const actorText = cleanStageText(actor) || null;
1428
+ const now = Math.max(Date.now(), Number(row.updated_at || 0) + 1);
1429
+ const result = withBusyRetry(() => db.prepare(`
1430
+ UPDATE tasks
1431
+ SET metadata = ?,
1432
+ updated_at = ?
1433
+ WHERE id = ?
1434
+ AND updated_at = ?
1435
+ `).run(JSON.stringify(metadata), now, id, row.updated_at));
1436
+ if (result.changes !== 1) return { tagged: false, reason: 'stale_task_state' };
1437
+ const updated = getTask(db, id);
1438
+ const event = appendTaskEvent(db, {
1439
+ taskId: id,
1440
+ workspaceRoot: updated.workspace_root,
1441
+ actor: actorText,
1442
+ eventType: 'task_tags_updated',
1443
+ payload: { added, removed, tags: next, previous_tags: before },
1444
+ });
1445
+ return { tagged: true, event, row: updated, tags: next, added, removed };
1446
+ }
1447
+
1173
1448
  function taskChatPacketFromPayload(payload) {
1174
1449
  const data = payload && typeof payload === 'object' ? payload : {};
1175
1450
  const lines = ['TASK_CHAT_UPDATE'];
@@ -1235,7 +1510,7 @@ function chatTask(db, { id, actor, content, goal, summary }) {
1235
1510
  };
1236
1511
  }
1237
1512
 
1238
- function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, careerXpEligible = false, clearedFields = [], landing = {} }) {
1513
+ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, careerXpEligible = false, clearedFields = [], landing = {}, autoAccepted = false }) {
1239
1514
  if (!id) throw new Error('id required');
1240
1515
  const row = getTask(db, id);
1241
1516
  if (!row) return { reviewed: false, reason: 'not_found' };
@@ -1243,6 +1518,14 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1243
1518
  const now = Date.now();
1244
1519
  const reviewer = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1245
1520
  const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1521
+ const reviewerNorm = reviewIntegrity.normalizeActor(reviewer);
1522
+ const builderNorm = reviewIntegrity.taskBuilder(row);
1523
+ // judge != worker applies pre-acceptance only: a builder cannot positive-
1524
+ // reward their own row while it still sits in review. Done rows are past
1525
+ // the human gate; finish and post-acceptance XP bookkeeping stay open.
1526
+ if (numericReward > 0 && row.status === 'review' && builderNorm && reviewerNorm && reviewerNorm === builderNorm) {
1527
+ return { reviewed: false, reason: 'judge_equals_worker', builder: builderNorm };
1528
+ }
1246
1529
  const proofText = String(proof || '').trim();
1247
1530
  const verifyText = cleanStageText(verify);
1248
1531
  const lessonText = String(lesson || '').trim();
@@ -1284,11 +1567,13 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1284
1567
  if (reviewingPendingProof) {
1285
1568
  reviewPassCount += 1;
1286
1569
  metadata.agent_review_pass_count = reviewPassCount;
1287
- if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES) {
1570
+ const passRecord = recordReviewPassActor(metadata, row.claimed_by, reviewer);
1571
+ if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
1288
1572
  metadata.agent_certified = true;
1289
1573
  metadata.agent_certified_at = new Date(now).toISOString();
1290
1574
  metadata.agent_certified_by = reviewer;
1291
1575
  metadata.agent_certification_policy = `${AGENT_CERTIFICATION_REVIEW_PASSES}_agent_review_passes`;
1576
+ metadata.independent_review_by = passRecord.independent;
1292
1577
  }
1293
1578
  }
1294
1579
  if (numericReward > 0 && row.status === 'done') {
@@ -1312,6 +1597,7 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1312
1597
  verify: verifyText || null,
1313
1598
  career_xp_eligible: Boolean(careerXpEligible),
1314
1599
  };
1600
+ if (autoAccepted) payload.auto_accepted = true;
1315
1601
  if (reviewingPendingProof) {
1316
1602
  payload.review_pass_count = reviewPassCount;
1317
1603
  payload.agent_certified = metadata.agent_certified === true;
@@ -1637,6 +1923,7 @@ function taskProjection(db, {
1637
1923
  id: row.id,
1638
1924
  ...(refById.get(row.id) || {}),
1639
1925
  title: row.title,
1926
+ result: row.metadata && row.metadata.result || null,
1640
1927
  status: row.status,
1641
1928
  tag: row.tag,
1642
1929
  workspace_root: row.workspace_root,
@@ -1762,11 +2049,16 @@ module.exports = {
1762
2049
  backlogTask,
1763
2050
  clearPlanTasks,
1764
2051
  doneTask,
2052
+ archiveTask,
2053
+ relabelArchivedTasks,
2054
+ isJune10BacklogResetMarker,
1765
2055
  readyTask,
2056
+ setTaskResult,
1766
2057
  reviseTask,
1767
2058
  stageTask,
1768
2059
  noteTask,
1769
2060
  chatTask,
2061
+ tagTask,
1770
2062
  reviewTask,
1771
2063
  appendTaskEvent,
1772
2064
  listTaskEvents,
package/lib/task-proof.js CHANGED
@@ -7,19 +7,43 @@ const FILE_PROOF_RE = /(?:^|[\s'"`])(?:\.{0,2}\/|~\/|\/Users\/|src\/|scripts\/|a
7
7
  const PATH_ONLY_PROOF_RE = /(?:^|[\s'"`])(?:\.{0,2}\/|~\/|\/Users\/|\/private\/|\/var\/|atris\/runs\/|\.atris\/state\/)[^\s'"`,;)]+(?:[.](?:json|jsonl|md|log|txt|png|jpg|jpeg|pdf))?/i;
8
8
  const RECEIPT_OR_ARTIFACT_RE = /\b(?:receipt|artifact|screenshot|log|trace|path=|file=|bytes=|model=|opened=|https?:\/\/)\b/i;
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
+ const SUITE_GREEN_CLAIM_RE = /\b(?:tests|test\s+suite|suite)\s+(?:is|are|was|were)?\s*(?:all\s+)?(?:green|pass(?:es|ed)?|ok)\b|\b(?:all|full|whole|entire)\s+(?:tests?|test\s+suite|suite)\s+(?:is|are|was|were)?\s*(?:green|pass(?:es|ed)?|ok)\b|\ball\s+green\b|\b(?:npm|pnpm|yarn)\s+(?:run\s+)?test\b\x60?(?:\s+(?:\d+\s*\/\s*\d+|all|suite))?\s+(?:is\s+)?(?:green|pass(?:es|ed)?|ok)\b/i;
11
+ const CI_RUN_CITATION_RE = /https?:\/\/github[.]com\/[^/\s]+\/[^/\s]+\/actions\/runs\/\d+\b|\brun_id\s*=\s*\d+\b|\brun(?:\s+id)?\s*(?:=|:)?\s*\d+\b/i;
12
+ const COMMIT_PINNED_LOCAL_VERIFY_RE = /\bcommit\s+[0-9a-f]{7,40}\b/i;
13
+ const EXIT_ZERO_RE = /\bexit(?:ed)?(?:\s+code)?\s*0\b/i;
14
+ const SUITE_GREEN_CITATION_REASON = 'cite the CI run URL, run id, or commit-pinned verify command with exit 0 before claiming the suite is green';
10
15
  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
16
  const FILE_ACTION_RE = /\b(?:changed|updated|edited|created|deleted|saved|wrote|patched|reviewed|verified|validated|opened|read)\b/i;
17
+ const VERIFIED_PROOF_RE = /^\[verified\]\s+`[^`]+`\s+passed\s+\(exit 0\)(?:\s|$)/i;
18
+ const SECOND_ACTOR_EXECUTED_PROOF_RE = /^Second-actor check:\s+`[^`]+`\s+re-run by [^,]+,\s+exited 0(?:[.\s]|$)/i;
12
19
 
13
20
  function compactWhitespace(text) {
14
21
  return String(text || '').replace(/\s+/g, ' ').trim();
15
22
  }
16
23
 
24
+ function hasSuiteGreenCitation(text) {
25
+ return CI_RUN_CITATION_RE.test(text)
26
+ || (COMMIT_PINNED_LOCAL_VERIFY_RE.test(text)
27
+ && COMMAND_PROOF_RE.test(text)
28
+ && EXIT_ZERO_RE.test(text));
29
+ }
30
+
17
31
  function taskProofState(proof) {
18
32
  const text = compactWhitespace(proof);
19
33
  if (!text) return { ok: false, reason: 'proof required' };
20
34
  if (GENERIC_COMPLETION_PROOF_RE.test(text)) {
21
35
  return { ok: false, reason: 'proof must name what was verified, changed, approved, or produced' };
22
36
  }
37
+ if (SUITE_GREEN_CLAIM_RE.test(text)) {
38
+ if (!hasSuiteGreenCitation(text)) {
39
+ return {
40
+ ok: false,
41
+ code: 'suite_green_citation_required',
42
+ reason: SUITE_GREEN_CITATION_REASON,
43
+ };
44
+ }
45
+ return { ok: true, reason: 'proof cites a CI run or commit-pinned local verify' };
46
+ }
23
47
  if (COMMAND_PROOF_RE.test(text)) return { ok: true, reason: 'proof names a command' };
24
48
  if (HUMAN_PROOF_RE.test(text)) return { ok: true, reason: 'proof names human/customer approval' };
25
49
  if (RESULT_PAIR_RE.test(text)) return { ok: true, reason: 'proof names a verifier result' };
@@ -37,6 +61,22 @@ function taskProofLooksMeaningful(proof) {
37
61
  return taskProofState(proof).ok;
38
62
  }
39
63
 
64
+ function taskProofExecutionState(proof) {
65
+ const text = compactWhitespace(proof);
66
+ if (!text) return { ok: false, reason: 'proof_not_executed', detail: 'executed proof required' };
67
+ if (VERIFIED_PROOF_RE.test(text)) return { ok: true, reason: 'verified_proof' };
68
+ if (SECOND_ACTOR_EXECUTED_PROOF_RE.test(text)) return { ok: true, reason: 'second_actor_executed_proof' };
69
+ return {
70
+ ok: false,
71
+ reason: 'proof_not_executed',
72
+ detail: 'proof must come from an executed verifier, not a free-text claim that tests passed',
73
+ };
74
+ }
75
+
76
+ function taskProofLooksExecuted(proof) {
77
+ return taskProofExecutionState(proof).ok;
78
+ }
79
+
40
80
  function tailText(text, max = 400) {
41
81
  const trimmed = String(text || '').trim();
42
82
  if (trimmed.length <= max) return trimmed;
@@ -48,7 +88,7 @@ function tailText(text, max = 400) {
48
88
  // happily pattern-matches even if nothing ran) and a VERIFIED proof: the command
49
89
  // actually executed and exited 0. On failure it returns ok:false so the caller
50
90
  // can refuse to mark the task ready. The returned proof string is prefixed
51
- // `[verified]` and names the command + exit, so it also passes taskProofState.
91
+ // It names the command and exit; suite-green claims still need a citation.
52
92
  function buildVerifiedProof(verifyCmd, baseProof = '', runner, options = {}) {
53
93
  const cmd = String(verifyCmd || '').trim();
54
94
  if (!cmd) return { ok: false, reason: 'verify_command_required' };
@@ -73,6 +113,8 @@ function buildVerifiedProof(verifyCmd, baseProof = '', runner, options = {}) {
73
113
 
74
114
  module.exports = {
75
115
  taskProofLooksMeaningful,
116
+ taskProofLooksExecuted,
76
117
  taskProofState,
118
+ taskProofExecutionState,
77
119
  buildVerifiedProof,
78
120
  };