atris 3.34.0 → 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 (53) 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 +4 -2
  6. package/ax +475 -17
  7. package/bin/atris.js +197 -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/member.js +398 -42
  24. package/commands/mission.js +554 -64
  25. package/commands/now.js +25 -1
  26. package/commands/radar.js +259 -14
  27. package/commands/serve.js +54 -0
  28. package/commands/status.js +50 -5
  29. package/commands/stripe.js +38 -0
  30. package/commands/supabase.js +39 -0
  31. package/commands/task.js +935 -71
  32. package/commands/truth.js +29 -3
  33. package/commands/unknowns.js +627 -0
  34. package/commands/update.js +44 -0
  35. package/commands/vercel.js +38 -0
  36. package/commands/worktree.js +68 -10
  37. package/commands/write.js +399 -0
  38. package/lib/auto-accept-certified.js +70 -19
  39. package/lib/autoland.js +39 -3
  40. package/lib/fleet.js +250 -9
  41. package/lib/memory-view.js +14 -5
  42. package/lib/mission-runtime-loop.js +7 -0
  43. package/lib/official-cli-integration.js +174 -0
  44. package/lib/outbound-send-gate.js +165 -0
  45. package/lib/permission-grants.js +293 -0
  46. package/lib/review-integrity.js +147 -0
  47. package/lib/runner-command.js +23 -0
  48. package/lib/task-db.js +220 -7
  49. package/lib/task-proof.js +20 -0
  50. package/lib/task-receipt.js +93 -0
  51. package/package.json +1 -1
  52. package/utils/update-check.js +27 -6
  53. package/atris/learnings.jsonl +0 -1
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ // judge != worker: the builder of a task cannot be the actor whose review
4
+ // pass certifies or lands it. This module is the single home for actor
5
+ // identity rules; task-db, auto-accept, and the task CLI all lean on it.
6
+ // Level 1 trust: normalized string identity + builder exclusion + reserved
7
+ // system names + roster validation (warn by default). It stops accidental
8
+ // and lazy self-judging and makes deliberate spoofing auditable; signed
9
+ // events (level 2) are a follow-up, not this file.
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const functionalOwner = require('./functional-owner');
15
+
16
+ // System actors written by the machinery itself; a human or agent may never
17
+ // claim/ready/review under these names.
18
+ const RESERVED_ACTORS = new Set(['autoland-verifier', 'auto-accept-certified']);
19
+
20
+ function normalizeActor(value) {
21
+ return String(value || '')
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-+|-+$/g, '');
26
+ }
27
+
28
+ function envActor() {
29
+ return normalizeActor(process.env.ATRIS_AGENT_ID || process.env.USER || '');
30
+ }
31
+
32
+ // Builder identity, oldest signal wins: explicit stamp, then who claimed it,
33
+ // then whoever sent the first proof.
34
+ function taskBuilder(task) {
35
+ if (!task || typeof task !== 'object') return null;
36
+ const metadata = task.metadata || {};
37
+ const stamped = normalizeActor(metadata.built_by);
38
+ if (stamped) return stamped;
39
+ const claimed = normalizeActor(task.claimed_by);
40
+ if (claimed) return claimed;
41
+ for (const event of task.events || []) {
42
+ if (event && event.event_type === 'proof_ready') {
43
+ const actor = normalizeActor(event.actor || (event.payload && event.payload.actor));
44
+ if (actor) return actor;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function reviewEventActors(task) {
51
+ const actors = new Set();
52
+ for (const event of (task && task.events) || []) {
53
+ if (!event || !['proof_ready', 'reviewed'].includes(event.event_type)) continue;
54
+ const actor = normalizeActor(event.actor || (event.payload && event.payload.actor));
55
+ if (actor) actors.add(actor);
56
+ }
57
+ return actors;
58
+ }
59
+
60
+ // Actors who reviewed the task and are not its builder.
61
+ function independentReviewActors(task) {
62
+ const builder = taskBuilder(task);
63
+ const actors = reviewEventActors(task);
64
+ if (builder) actors.delete(builder);
65
+ return actors;
66
+ }
67
+
68
+ // True when at least one review pass came from someone other than the
69
+ // builder. When the builder cannot be resolved at all, fall back to
70
+ // requiring two distinct actors — independence cannot be proven from one.
71
+ function hasIndependentReview(task) {
72
+ const builder = taskBuilder(task);
73
+ if (!builder) return reviewEventActors(task).size >= 2;
74
+ return independentReviewActors(task).size >= 1;
75
+ }
76
+
77
+ function isReservedActor(actor) {
78
+ return RESERVED_ACTORS.has(normalizeActor(actor));
79
+ }
80
+
81
+ function rosterActors(root) {
82
+ const actors = new Set(functionalOwner.listWorkspaceMemberSlugs(root));
83
+ for (const id of ['atris-2-fast', 'atris2', 'atris2-fast', 'claude', 'codex', 'codex-executor', 'cursor', 'devin', 'executor', 'openclaw', 'windsurf']) {
84
+ actors.add(id);
85
+ }
86
+ try {
87
+ const user = normalizeActor(os.userInfo().username);
88
+ if (user) actors.add(user);
89
+ } catch (_) { /* identity lookup can fail in stripped containers */ }
90
+ const envUser = normalizeActor(process.env.USER);
91
+ if (envUser) actors.add(envUser);
92
+ const agentId = normalizeActor(process.env.ATRIS_AGENT_ID);
93
+ if (agentId) actors.add(agentId);
94
+ try {
95
+ const policyPath = path.join(root || process.cwd(), '.atris', 'policy', 'autoland.json');
96
+ const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
97
+ const enabledBy = normalizeActor(policy.enabled_by);
98
+ if (enabledBy) actors.add(enabledBy);
99
+ } catch (_) { /* no policy is fine */ }
100
+ return actors;
101
+ }
102
+
103
+ // off | warn | enforce. Env wins, then .atris/review-policy.json, then off.
104
+ // Off by default on purpose: ad-hoc --as names are everywhere in scripts and
105
+ // fixtures, and a warning that fires on legitimate flows teaches everyone to
106
+ // ignore warnings (the cry-wolf lesson). Reserved-actor rejection and
107
+ // builder-exclusion do not depend on this mode and are always enforced.
108
+ function actorValidationMode(root) {
109
+ const env = String(process.env.ATRIS_ACTOR_VALIDATION || '').toLowerCase();
110
+ if (['off', 'warn', 'enforce'].includes(env)) return env;
111
+ try {
112
+ const policyPath = path.join(root || process.cwd(), '.atris', 'review-policy.json');
113
+ const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
114
+ const mode = String(policy.actor_validation || '').toLowerCase();
115
+ if (['off', 'warn', 'enforce'].includes(mode)) return mode;
116
+ } catch (_) { /* no policy is fine */ }
117
+ return 'off';
118
+ }
119
+
120
+ // Reserved names are rejected in every mode; unknown names pass in warn mode
121
+ // (with ok true + reason so callers can print) and fail in enforce mode.
122
+ function validateActor(actor, { root } = {}) {
123
+ const normalized = normalizeActor(actor);
124
+ if (!normalized) return { ok: true, mode: 'off', actor: normalized };
125
+ if (isReservedActor(normalized)) {
126
+ return { ok: false, mode: 'enforce', actor: normalized, reason: 'reserved_actor' };
127
+ }
128
+ const mode = actorValidationMode(root);
129
+ if (mode === 'off') return { ok: true, mode, actor: normalized };
130
+ if (rosterActors(root).has(normalized)) return { ok: true, mode, actor: normalized };
131
+ if (mode === 'enforce') return { ok: false, mode, actor: normalized, reason: 'actor_not_on_roster' };
132
+ return { ok: true, mode, actor: normalized, reason: 'actor_not_on_roster' };
133
+ }
134
+
135
+ module.exports = {
136
+ RESERVED_ACTORS,
137
+ normalizeActor,
138
+ envActor,
139
+ taskBuilder,
140
+ reviewEventActors,
141
+ independentReviewActors,
142
+ hasIndependentReview,
143
+ isReservedActor,
144
+ rosterActors,
145
+ actorValidationMode,
146
+ validateActor,
147
+ };
@@ -40,11 +40,33 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
40
40
  model: '',
41
41
  commandTemplate: '{bin} --trust -p {prompt}',
42
42
  }),
43
+ // Compatibility names for fleet rosters. These map to real installed
44
+ // runners instead of retired or UI-only model names.
45
+ fable: Object.freeze({
46
+ bin: 'claude',
47
+ model: '',
48
+ commandTemplate: '',
49
+ }),
50
+ composer: Object.freeze({
51
+ bin: 'ax',
52
+ model: 'composer-2-5-fast',
53
+ commandTemplate: '{bin} --fast {prompt}',
54
+ }),
55
+ haiku: Object.freeze({
56
+ bin: 'claude',
57
+ model: 'claude-haiku-4-5',
58
+ commandTemplate: '',
59
+ }),
43
60
  devin: Object.freeze({
44
61
  bin: 'devin',
45
62
  model: '',
46
63
  commandTemplate: '{bin} -p -- {prompt}',
47
64
  }),
65
+ hermes: Object.freeze({
66
+ bin: 'hermes',
67
+ model: '',
68
+ commandTemplate: '{bin} -p -- {prompt}',
69
+ }),
48
70
  });
49
71
 
50
72
  // Alias -> canonical profile name. Every alias resolves to the same config as
@@ -53,6 +75,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
53
75
  const RUNNER_PROFILE_ALIASES = Object.freeze({
54
76
  'atris2-fast': 'atris-fast',
55
77
  'atris-2-fast': 'atris-fast',
78
+ 'hermes-agent': 'hermes',
56
79
  });
57
80
 
58
81
  // Back-compat surface: RUNNER_PROFILES still resolves every accepted name
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.34.0",
3
+ "version": "3.35.0",
4
4
  "main": "bin/atris.js",
5
5
  "bin": {
6
6
  "atris": "bin/atris.js",