atris 3.15.56 → 3.16.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 (44) hide show
  1. package/AGENTS.md +2 -2
  2. package/GETTING_STARTED.md +1 -1
  3. package/PERSONA.md +4 -4
  4. package/README.md +11 -11
  5. package/atris/skills/copy-editor/SKILL.md +30 -4
  6. package/atris/skills/improve/SKILL.md +18 -20
  7. package/atris/wiki/concepts/agent-activation-contract.md +5 -3
  8. package/atris/wiki/concepts/workspace-initialization-contract.md +4 -4
  9. package/atris/wiki/index.md +1 -0
  10. package/ax +522 -73
  11. package/bin/atris.js +32 -31
  12. package/commands/align.js +0 -14
  13. package/commands/apps.js +102 -1
  14. package/commands/autopilot.js +197 -22
  15. package/commands/brain.js +219 -34
  16. package/commands/brainstorm.js +0 -829
  17. package/commands/computer.js +45 -83
  18. package/commands/improve.js +501 -0
  19. package/commands/integrations.js +228 -0
  20. package/commands/lesson.js +44 -0
  21. package/commands/member.js +4498 -226
  22. package/commands/mission.js +302 -27
  23. package/commands/now.js +89 -1
  24. package/commands/radar.js +181 -56
  25. package/commands/skill.js +37 -6
  26. package/commands/soul.js +0 -4
  27. package/commands/task.js +5582 -517
  28. package/commands/terminal.js +14 -10
  29. package/commands/wiki.js +87 -1
  30. package/commands/workflow.js +288 -73
  31. package/commands/worktree.js +52 -15
  32. package/commands/xp.js +41 -65
  33. package/lib/auto-accept-certified.js +294 -0
  34. package/lib/file-ops.js +0 -184
  35. package/lib/member-alive.js +232 -0
  36. package/lib/policy-lessons.js +280 -0
  37. package/lib/receipt-evidence.js +64 -0
  38. package/lib/state-detection.js +34 -0
  39. package/lib/task-db.js +568 -16
  40. package/lib/task-proof.js +43 -0
  41. package/package.json +1 -1
  42. package/utils/auth.js +13 -4
  43. package/commands/research.js +0 -52
  44. package/lib/section-merge.js +0 -196
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const MEMBER_OPERATE_PROGRESS_PREFIX = 'ATRIS_MEMBER_OPERATE_PROGRESS\t';
8
+
9
+ // Wake decisions where the member should NOT run operate work this tick — it is waiting on a
10
+ // human (ask), on a pending review or clean scope (wait), or has no active goal yet (stop).
11
+ const NON_OPERATE_DECISIONS = new Set(['ask', 'wait', 'stop']);
12
+
13
+ function emitAliveProgress(payload) {
14
+ process.stderr.write(`${MEMBER_OPERATE_PROGRESS_PREFIX}${JSON.stringify(payload)}\n`);
15
+ }
16
+
17
+ function stampIso() {
18
+ return new Date().toISOString();
19
+ }
20
+
21
+ function parseJsonMaybe(text) {
22
+ const trimmed = String(text || '').trim();
23
+ if (!trimmed) return null;
24
+ try {
25
+ return JSON.parse(trimmed);
26
+ } catch {
27
+ const lastLine = trimmed.split(/\r?\n/).filter(Boolean).at(-1);
28
+ if (!lastLine) return null;
29
+ try {
30
+ return JSON.parse(lastLine);
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+ }
36
+
37
+ function resolveAtrisBin(explicit) {
38
+ if (explicit) return explicit;
39
+ if (process.env.ATRIS_BIN) return process.env.ATRIS_BIN;
40
+ const local = path.join(__dirname, '..', 'bin', 'atris.js');
41
+ if (fs.existsSync(local)) return local;
42
+ return 'atris';
43
+ }
44
+
45
+ function runCli(bin, args, opts = {}) {
46
+ const cwd = opts.cwd || process.cwd();
47
+ const command = bin.endsWith('.js') ? process.execPath : bin;
48
+ const argv = bin.endsWith('.js') ? [bin, ...args] : args;
49
+ const result = spawnSync(command, argv, {
50
+ cwd,
51
+ encoding: 'utf8',
52
+ env: process.env,
53
+ timeout: opts.timeoutMs || 120000,
54
+ });
55
+ return {
56
+ ok: result.status === 0,
57
+ status: result.status,
58
+ stdout: result.stdout || '',
59
+ stderr: result.stderr || '',
60
+ json: parseJsonMaybe(result.stdout) || parseJsonMaybe(result.stderr),
61
+ };
62
+ }
63
+
64
+ function resolveMemberOperateScript(cwd) {
65
+ const candidates = [
66
+ path.join(cwd, 'scripts', 'member-operate.mjs'),
67
+ path.join(cwd, 'scripts', 'member-operate.js'),
68
+ ];
69
+ for (const candidate of candidates) {
70
+ if (fs.existsSync(candidate)) return candidate;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function runMemberOperateScript(scriptPath, member, opts) {
76
+ const args = [
77
+ scriptPath,
78
+ member,
79
+ '--json',
80
+ '--max-wall',
81
+ String(Math.max(60, Math.min(1800, Number(opts.maxWallSeconds) || 900))),
82
+ ];
83
+ if (opts.execute) {
84
+ args.push('--execute');
85
+ if (opts.confirmed) args.push('--confirm-autonomy-policy');
86
+ }
87
+ if (opts.agent === 'claude') args.push('--agent', 'claude');
88
+ if (opts.model) args.push('--model', String(opts.model));
89
+ if (opts.noPrime) args.push('--no-prime');
90
+ // Inherit stderr so member-operate progress lines reach the parent alive/loop process live.
91
+ const result = spawnSync(process.execPath, args, {
92
+ cwd: opts.cwd || process.cwd(),
93
+ encoding: 'utf8',
94
+ env: process.env,
95
+ timeout: (Number(opts.maxWallSeconds) || 900) * 1000 + 15000,
96
+ stdio: ['ignore', 'pipe', 'inherit'],
97
+ });
98
+ const json = parseJsonMaybe(result.stdout) || parseJsonMaybe(result.stderr);
99
+ return {
100
+ ok: result.status === 0 && json?.ok !== false,
101
+ status: result.status,
102
+ payload: json,
103
+ reason: json?.reason || json?.error || (result.status === 0 ? 'operate_complete' : 'operate_failed'),
104
+ needs_user: Boolean(json?.needs_user),
105
+ receipt_path: json?.receipt_path || null,
106
+ stdout: result.stdout,
107
+ stderr: result.stderr,
108
+ };
109
+ }
110
+
111
+ function runAliveTick(name, opts = {}) {
112
+ const cwd = opts.cwd || process.cwd();
113
+ const atrisBin = resolveAtrisBin(opts.atrisBin);
114
+ const startedAt = stampIso();
115
+ const tick = {
116
+ schema: 'atris.member_alive_tick.v1',
117
+ member: name,
118
+ mode: opts.execute ? 'execute' : 'dry_run',
119
+ started_at: startedAt,
120
+ };
121
+
122
+ if (opts.execute && opts.confirmed) {
123
+ emitAliveProgress({ kind: 'phase', text: 'Reading mission and goal…' });
124
+ }
125
+
126
+ if (!opts.noPrime) {
127
+ tick.prime = runCli(atrisBin, ['member', 'goal-from-mission', name, '--json'], { cwd, timeoutMs: 120000 });
128
+ }
129
+
130
+ const wake = runCli(atrisBin, [
131
+ 'member', 'wake', name, '--json',
132
+ ...(opts.force ? ['--force'] : []),
133
+ ], { cwd, timeoutMs: 120000 });
134
+ tick.wake = wake;
135
+ const wakeJson = wake.json || {};
136
+ tick.has_mission = wakeJson.checks?.has_mission === true;
137
+ tick.has_goal = wakeJson.checks?.has_goal === true;
138
+ tick.needs_user = Boolean(wakeJson.needs_user);
139
+ tick.next_command = wakeJson.next_command || null;
140
+ tick.decision = wakeJson.decision || null;
141
+ tick.blocked_on_human = false;
142
+
143
+ if (!opts.execute || !opts.confirmed) {
144
+ tick.operate = { ok: true, skipped: true, reason: 'dry_run' };
145
+ tick.auto_accept = { ok: true, skipped: true, reason: 'dry_run' };
146
+ tick.status = tick.needs_user ? 'needs_user' : 'planned';
147
+ tick.reason = 'alive_dry_run';
148
+ tick.ok = true;
149
+ tick.finished_at = stampIso();
150
+ return tick;
151
+ }
152
+
153
+ // Honor the member's own wake judgment. ask/wait/stop all mean "do not force operate work":
154
+ // ask = blocked on a human, wait = blocked on a pending review or a dirty scope, stop = no goal yet.
155
+ // Forcing an operate tick in these states is exactly the no-op churn that surfaces as
156
+ // failed:partial_failure across a whole loop while the member is actually just waiting on you.
157
+ const wakeDecision = wakeJson.decision || null;
158
+ if (NON_OPERATE_DECISIONS.has(wakeDecision)) {
159
+ const reason = wakeJson.reason || `wake_${wakeDecision}`;
160
+ const blockedOnHuman = wakeDecision === 'ask'
161
+ || /^open_experiment_/.test(reason)
162
+ || /^blocked_/.test(reason);
163
+ tick.operate = { ok: true, skipped: true, reason: `wake_${wakeDecision}` };
164
+ tick.auto_accept = runCli(atrisBin, [
165
+ 'task', 'auto-accept-certified',
166
+ '--dry-run',
167
+ '--limit', String(Math.max(1, Number(opts.autoAcceptLimit) || 8)),
168
+ '--json',
169
+ ], { cwd, timeoutMs: 180000 });
170
+ tick.decision = wakeDecision;
171
+ tick.needs_user = wakeDecision === 'ask' || tick.needs_user;
172
+ tick.blocked_on_human = blockedOnHuman;
173
+ tick.status = wakeDecision === 'ask' ? 'needs_user' : 'waiting';
174
+ tick.reason = reason;
175
+ tick.next_command = wakeJson.next_command || tick.next_command;
176
+ tick.ok = tick.auto_accept.ok !== false;
177
+ tick.finished_at = stampIso();
178
+ return tick;
179
+ }
180
+
181
+ const operateScript = resolveMemberOperateScript(cwd);
182
+ if (!operateScript) {
183
+ tick.operate = { ok: false, reason: 'member_operate_script_missing' };
184
+ tick.status = 'blocked';
185
+ tick.reason = 'missing_member_operate_script';
186
+ tick.ok = false;
187
+ tick.finished_at = stampIso();
188
+ return tick;
189
+ }
190
+
191
+ emitAliveProgress({ kind: 'phase', text: `Running ${name} tick — streaming live below.` });
192
+ tick.operate = runMemberOperateScript(operateScript, name, {
193
+ cwd,
194
+ execute: true,
195
+ confirmed: true,
196
+ maxWallSeconds: opts.maxWallSeconds,
197
+ agent: opts.agent || 'codex',
198
+ model: opts.model || null,
199
+ noPrime: true,
200
+ });
201
+ tick.needs_user = tick.needs_user || tick.operate.needs_user === true;
202
+
203
+ tick.auto_accept = runCli(atrisBin, [
204
+ 'task', 'auto-accept-certified',
205
+ '--dry-run',
206
+ '--limit', String(Math.max(1, Number(opts.autoAcceptLimit) || 8)),
207
+ '--json',
208
+ ], { cwd, timeoutMs: 180000 });
209
+
210
+ const operateOk = tick.operate.ok !== false;
211
+ const acceptOk = tick.auto_accept.ok !== false;
212
+ tick.ok = operateOk && acceptOk;
213
+ tick.status = tick.needs_user
214
+ ? 'needs_user'
215
+ : tick.ok
216
+ ? 'completed'
217
+ : 'failed';
218
+ tick.reason = tick.ok
219
+ ? 'alive_tick_complete'
220
+ : tick.operate.reason || tick.auto_accept.json?.summary
221
+ ? 'partial_failure'
222
+ : 'alive_tick_failed';
223
+ tick.receipt_path = tick.operate.receipt_path || wakeJson.receipt_path || null;
224
+ tick.finished_at = stampIso();
225
+ return tick;
226
+ }
227
+
228
+ module.exports = {
229
+ runAliveTick,
230
+ resolveMemberOperateScript,
231
+ resolveAtrisBin,
232
+ };
@@ -0,0 +1,280 @@
1
+ // Policy lesson mining for the RSI loop (CLI-215).
2
+ //
3
+ // The workspace already records its own history: career XP receipts (human
4
+ // accepts), task episodes (every review turn with an RL label), and
5
+ // scorecards (improve/brain ticks). This module mines that history into a
6
+ // small set of deterministic policy lessons — each one carries the evidence
7
+ // counts it was computed from — and surfaces them at the moment an agent
8
+ // submits proof (`atris task ready`), so the next submission behaves
9
+ // differently than the last. No LLM calls; re-mining refreshes the numbers.
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { RECEIPT_PATH_PATTERN, extractReceiptEvidence } = require('./receipt-evidence');
13
+
14
+ const POLICY_LESSONS_FILE = path.join('.atris', 'state', 'policy_lessons.json');
15
+ const CAREER_XP_RECEIPTS_FILE = path.join('.atris', 'state', 'career_xp_receipts.jsonl');
16
+ const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
17
+ const SCORECARDS_FILE = path.join('.atris', 'state', 'scorecards.jsonl');
18
+
19
+ // Review actors that are agents, not the human gate. Mining must split the
20
+ // two: agent self-review churn and human accept/bounce are different signals.
21
+ const AGENT_ACTOR_PATTERN = /review|validator|certifier|auto|codex|claude|devin|droid|improver|second-pass|agent|bot/i;
22
+ // "Names a runnable verify command" — the check a reviewer could replay.
23
+ const VERIFY_COMMAND_PATTERN = /\b(npm (run )?test|node --test|node --check|node bin\/|pytest|cargo test|go test|make test|atris verify|grep -q|git diff --check|diff -u|test -[fs])\b|--verify\b|\bverify:\s/;
24
+ const COMMIT_REF_PATTERN = /\bcommit\s+[0-9a-f]{7,40}\b/i;
25
+
26
+ function readJsonlFile(filePath) {
27
+ let raw;
28
+ try {
29
+ raw = fs.readFileSync(filePath, 'utf8');
30
+ } catch {
31
+ return [];
32
+ }
33
+ return raw.split('\n').map((line) => {
34
+ const text = line.trim();
35
+ if (!text) return null;
36
+ try { return JSON.parse(text); } catch { return null; }
37
+ }).filter(Boolean);
38
+ }
39
+
40
+ function loadHistory(root) {
41
+ return {
42
+ receipts: readJsonlFile(path.join(root, CAREER_XP_RECEIPTS_FILE)),
43
+ episodes: readJsonlFile(path.join(root, TASK_EPISODES_FILE)),
44
+ scorecards: readJsonlFile(path.join(root, SCORECARDS_FILE)),
45
+ };
46
+ }
47
+
48
+ function isAgentActor(actor) {
49
+ return AGENT_ACTOR_PATTERN.test(String(actor || ''));
50
+ }
51
+
52
+ function hasReceiptPath(text) {
53
+ return new RegExp(RECEIPT_PATH_PATTERN.source).test(String(text || ''));
54
+ }
55
+
56
+ function hasVerifyCommand(text) {
57
+ return VERIFY_COMMAND_PATTERN.test(String(text || ''));
58
+ }
59
+
60
+ function bounceCause(episode) {
61
+ const proof = String(episode.proof || '').trim();
62
+ if (!proof) return 'empty_proof';
63
+ if (/supersed/i.test(proof)) return 'superseded';
64
+ if (/wrong owner|route .*to|owner:/i.test(proof)) return 'wrong_owner';
65
+ return 'other';
66
+ }
67
+
68
+ function featureGateCounts(episodes, predicate) {
69
+ const counts = { accepted_with: 0, accepted_without: 0, revised_with: 0, revised_without: 0 };
70
+ for (const episode of episodes) {
71
+ const withFeature = predicate(String(episode.proof || ''));
72
+ const key = `${episode.rl.label}_${withFeature ? 'with' : 'without'}`;
73
+ if (key in counts) counts[key] += 1;
74
+ }
75
+ return counts;
76
+ }
77
+
78
+ function acceptRate(withCount, againstCount) {
79
+ const total = withCount + againstCount;
80
+ return total > 0 ? withCount / total : null;
81
+ }
82
+
83
+ // True when proofs carrying the feature get accepted at a higher rate than
84
+ // proofs without it — the bar for emitting a feature lesson at all.
85
+ function featureHelps(counts) {
86
+ const withRate = acceptRate(counts.accepted_with, counts.revised_with);
87
+ const withoutRate = acceptRate(counts.accepted_without, counts.revised_without);
88
+ return withRate !== null && withoutRate !== null && withRate > withoutRate;
89
+ }
90
+
91
+ function mineProofPolicy(history, opts = {}) {
92
+ const { receipts = [], episodes = [], scorecards = [] } = history || {};
93
+ const minHumanReviewed = Number.isFinite(opts.minHumanReviewed) ? opts.minHumanReviewed : 10;
94
+ const now = opts.now instanceof Date ? opts.now : new Date();
95
+
96
+ const reviewed = episodes.filter((e) => e && e.rl && e.action && e.action.actor);
97
+ const humanReviewed = reviewed.filter((e) => !isAgentActor(e.action.actor));
98
+ const agentReviewed = reviewed.filter((e) => isAgentActor(e.action.actor));
99
+ const humanGateEpisodes = humanReviewed.filter((e) => e.rl.label === 'accepted' || e.rl.label === 'revised');
100
+
101
+ const accepted = humanGateEpisodes.filter((e) => e.rl.label === 'accepted').length;
102
+ const revised = humanGateEpisodes.filter((e) => e.rl.label === 'revised').length;
103
+ const verifyCounts = featureGateCounts(humanGateEpisodes, hasVerifyCommand);
104
+ const receiptCounts = featureGateCounts(humanGateEpisodes, hasReceiptPath);
105
+ const commitCounts = featureGateCounts(humanGateEpisodes, (p) => COMMIT_REF_PATTERN.test(p));
106
+
107
+ const bounceCauses = {};
108
+ for (const episode of humanGateEpisodes) {
109
+ if (episode.rl.label !== 'revised') continue;
110
+ const cause = bounceCause(episode);
111
+ bounceCauses[cause] = (bounceCauses[cause] || 0) + 1;
112
+ }
113
+
114
+ const agentReviseTurns = agentReviewed.filter((e) => e.rl.label === 'revised').length;
115
+ const autoCertifiedReceipts = receipts.filter((r) => isAgentActor(r.actor)).length;
116
+
117
+ const improveTicks = scorecards.filter((s) => s && s.schema === 'atris.improve_tick.v1');
118
+ const brainScorecards = scorecards.filter((s) => s && s.schema === 'atris.brain.scorecard.v1');
119
+ const avgReward = (rows) => (rows.length
120
+ ? Math.round((rows.reduce((sum, r) => sum + (Number(r.reward) || 0), 0) / rows.length) * 100) / 100
121
+ : null);
122
+
123
+ const stats = {
124
+ human_gate: {
125
+ accepted,
126
+ revised,
127
+ verify_command: verifyCounts,
128
+ receipt_path: receiptCounts,
129
+ commit_ref: commitCounts,
130
+ bounce_causes: bounceCauses,
131
+ },
132
+ agent_lane: {
133
+ review_turns: agentReviewed.length,
134
+ revise_turns: agentReviseTurns,
135
+ auto_certified_receipts: autoCertifiedReceipts,
136
+ },
137
+ scorecards: {
138
+ total: scorecards.length,
139
+ improve_ticks: { count: improveTicks.length, avg_reward: avgReward(improveTicks) },
140
+ brain: { count: brainScorecards.length, avg_reward: avgReward(brainScorecards) },
141
+ },
142
+ };
143
+
144
+ const lessons = [];
145
+ const enoughHumanData = humanGateEpisodes.length >= minHumanReviewed;
146
+
147
+ if (enoughHumanData && verifyCounts.accepted_with >= 5 && featureHelps(verifyCounts)) {
148
+ lessons.push({
149
+ id: 'proof-verify-command',
150
+ hint_when: 'proof_missing_verify_command',
151
+ lesson: `Name a runnable verify command in every proof: ${verifyCounts.accepted_with}/${verifyCounts.accepted_with + verifyCounts.revised_with} proofs with one were accepted at the human gate, while ${verifyCounts.revised_without}/${revised} human bounces named none.`,
152
+ evidence: { source: 'task_episodes.human_gate', ...verifyCounts },
153
+ });
154
+ }
155
+
156
+ if (enoughHumanData && receiptCounts.accepted_with >= 1 && featureHelps(receiptCounts)) {
157
+ lessons.push({
158
+ id: 'proof-live-receipt',
159
+ hint_when: 'proof_missing_receipt_path',
160
+ lesson: `Cite a live receipt path (atris/runs/...) whose verifier passed: receipt-backed proofs are ${receiptCounts.accepted_with}/${receiptCounts.accepted_with + receiptCounts.revised_with} at the human gate and auto-review certifies them agent-side with zero human turns, while evidence-less proofs stall in the review lane (${agentReviseTurns} agent revise turns recorded).`,
161
+ evidence: { source: 'task_episodes + review lane', ...receiptCounts, agent_revise_turns: agentReviseTurns, auto_certified_receipts: autoCertifiedReceipts },
162
+ });
163
+ }
164
+
165
+ if (enoughHumanData && revised > 0) {
166
+ const causeSummary = Object.entries(bounceCauses).map(([cause, count]) => `${count} ${cause}`).join(', ');
167
+ lessons.push({
168
+ id: 'bounce-causes-routing',
169
+ hint_when: null,
170
+ lesson: `Human bounces are routing/staleness problems, not prose problems (${revised} bounces: ${causeSummary}). Before ready: confirm the task owner is right and the work was not superseded.`,
171
+ evidence: { source: 'task_episodes.human_gate', revised, bounce_causes: bounceCauses },
172
+ });
173
+ }
174
+
175
+ return {
176
+ schema: 'atris.policy_lessons.v1',
177
+ mined_at: now.toISOString(),
178
+ sources: {
179
+ career_xp_receipts: receipts.length,
180
+ task_episodes: episodes.length,
181
+ scorecards: scorecards.length,
182
+ human_reviewed_episodes: humanGateEpisodes.length,
183
+ },
184
+ stats,
185
+ lessons,
186
+ };
187
+ }
188
+
189
+ // Mirrors the review lane's auto-review bar (autoReviewableEvidence): every
190
+ // named receipt must exist on disk and show a passing verifier. A proof that
191
+ // names a receipt the lane would reject still earns the hint.
192
+ function hasLaneCertifiableReceipt(proof, root) {
193
+ const evidence = extractReceiptEvidence(proof, root);
194
+ return Boolean(
195
+ evidence
196
+ && !evidence.missing.length
197
+ && evidence.receipts.length
198
+ && evidence.receipts.every((entry) => entry.verifier_passed === true),
199
+ );
200
+ }
201
+
202
+ function policyHintsForProof(proofText, mined, root = process.cwd()) {
203
+ const lessons = mined && Array.isArray(mined.lessons) ? mined.lessons : [];
204
+ if (!lessons.length) return [];
205
+ const proof = String(proofText || '');
206
+ const hints = [];
207
+ for (const lesson of lessons) {
208
+ if (lesson.hint_when === 'proof_missing_verify_command' && !hasVerifyCommand(proof)) {
209
+ hints.push({ id: lesson.id, hint: lesson.lesson });
210
+ } else if (lesson.hint_when === 'proof_missing_receipt_path' && !hasLaneCertifiableReceipt(proof, root)) {
211
+ hints.push({ id: lesson.id, hint: lesson.lesson });
212
+ }
213
+ }
214
+ return hints;
215
+ }
216
+
217
+ function policyLessonsPath(root) {
218
+ return path.join(root, POLICY_LESSONS_FILE);
219
+ }
220
+
221
+ function readPolicyLessons(root) {
222
+ try {
223
+ const parsed = JSON.parse(fs.readFileSync(policyLessonsPath(root), 'utf8'));
224
+ return parsed && parsed.schema === 'atris.policy_lessons.v1' ? parsed : null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ function writePolicyLessons(root, mined) {
231
+ const filePath = policyLessonsPath(root);
232
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
233
+ fs.writeFileSync(filePath, JSON.stringify(mined, null, 2) + '\n', 'utf8');
234
+ return filePath;
235
+ }
236
+
237
+ // Sync mined lessons into atris/lessons.md (the surface agents already read).
238
+ // One line per lesson id; re-mining replaces the line so counts stay fresh
239
+ // instead of stacking stale duplicates.
240
+ function syncLessonsMd(root, mined) {
241
+ const lessonsPath = path.join(root, 'atris', 'lessons.md');
242
+ const today = (mined.mined_at || new Date().toISOString()).split('T')[0];
243
+ const lines = (mined.lessons || []).map((lesson) => ({
244
+ id: lesson.id,
245
+ line: `- **[${today}] policy-${lesson.id}** — pass — ${lesson.lesson} (mined from ${mined.sources.career_xp_receipts} receipts / ${mined.sources.task_episodes} episodes / ${mined.sources.scorecards} scorecards)`,
246
+ }));
247
+ if (!lines.length) return { path: lessonsPath, written: [] };
248
+
249
+ let content;
250
+ try {
251
+ content = fs.readFileSync(lessonsPath, 'utf8');
252
+ } catch {
253
+ content = '# lessons.md — What We Learned\n\n> Append-only. One line per lesson.\n\n---\n';
254
+ }
255
+ const written = [];
256
+ for (const { id, line } of lines) {
257
+ const marker = new RegExp(`^- \\*\\*\\[\\d{4}-\\d{2}-\\d{2}\\] policy-${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\*\\*.*$`, 'm');
258
+ if (marker.test(content)) {
259
+ content = content.replace(marker, line);
260
+ } else {
261
+ content = content.replace(/\n*$/, '\n') + line + '\n';
262
+ }
263
+ written.push(id);
264
+ }
265
+ fs.mkdirSync(path.dirname(lessonsPath), { recursive: true });
266
+ fs.writeFileSync(lessonsPath, content, 'utf8');
267
+ return { path: lessonsPath, written };
268
+ }
269
+
270
+ module.exports = {
271
+ AGENT_ACTOR_PATTERN,
272
+ VERIFY_COMMAND_PATTERN,
273
+ loadHistory,
274
+ mineProofPolicy,
275
+ policyHintsForProof,
276
+ policyLessonsPath,
277
+ readPolicyLessons,
278
+ writePolicyLessons,
279
+ syncLessonsMd,
280
+ };
@@ -0,0 +1,64 @@
1
+ // Receipt evidence extraction for the human accept gate.
2
+ //
3
+ // Agent proofs are free text, but they usually name receipt files under
4
+ // atris/runs/. Validating those paths on disk turns "trust the prose" into
5
+ // "read the verifier state": the queue and accept surfaces show whether the
6
+ // receipts named in a proof exist and whether their verifiers passed. This
7
+ // informs the human gate — it never blocks it.
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const RECEIPT_PATH_PATTERN = /(?:^|[\s"'`(])((?:atris\/runs|\.atris\/state)\/[^\s"'`),;]+\.json)/g;
12
+ const MAX_RECEIPTS = 5;
13
+
14
+ // Mirrors mission receipt shapes: result.passed (legacy verifier-only),
15
+ // result.verifier_result.passed (run/tick envelope), result.tick.verifier_passed.
16
+ function receiptVerifierPassed(receipt) {
17
+ const result = receipt?.result;
18
+ if (!result || typeof result !== 'object') return null;
19
+ if (result.passed === true || result.verifier_result?.passed === true || result.tick?.verifier_passed === true) return true;
20
+ if (result.passed === false || result.verifier_result?.passed === false || result.tick?.verifier_passed === false) return false;
21
+ return null;
22
+ }
23
+
24
+ function extractReceiptEvidence(proofText, root = process.cwd()) {
25
+ const text = String(proofText || '');
26
+ const found = [];
27
+ const seen = new Set();
28
+ const pattern = new RegExp(RECEIPT_PATH_PATTERN.source, 'g');
29
+ let match;
30
+ while ((match = pattern.exec(text)) && found.length < MAX_RECEIPTS) {
31
+ const candidate = match[1];
32
+ if (candidate.includes('*') || seen.has(candidate)) continue; // glob shorthand or duplicate
33
+ seen.add(candidate);
34
+ found.push(candidate);
35
+ }
36
+ if (!found.length) return null;
37
+
38
+ const receipts = [];
39
+ const missing = [];
40
+ for (const rel of found) {
41
+ let raw;
42
+ try {
43
+ raw = fs.readFileSync(path.resolve(root, rel), 'utf8');
44
+ } catch {
45
+ missing.push(rel);
46
+ continue;
47
+ }
48
+ let parsed = null;
49
+ try { parsed = JSON.parse(raw); } catch {}
50
+ const entry = { path: rel, exists: true, schema: parsed?.schema || null };
51
+ const passed = receiptVerifierPassed(parsed);
52
+ if (passed !== null) entry.verifier_passed = passed;
53
+ if (parsed?.mission_id) entry.mission_id = parsed.mission_id;
54
+ receipts.push(entry);
55
+ }
56
+ return {
57
+ receipts,
58
+ missing,
59
+ // True only when every named receipt exists and none shows a failing verifier.
60
+ all_passing: receipts.length > 0 && !missing.length && receipts.every((entry) => entry.verifier_passed !== false),
61
+ };
62
+ }
63
+
64
+ module.exports = { extractReceiptEvidence, receiptVerifierPassed, RECEIPT_PATH_PATTERN };
@@ -1,6 +1,12 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
+ const TASK_STATUS_BUCKETS = {
5
+ backlog: new Set(['open']),
6
+ inProgress: new Set(['claimed']),
7
+ completed: new Set(['done', 'failed'])
8
+ };
9
+
4
10
  function isFeatureInProgress(ideaContent) {
5
11
  if (!ideaContent || typeof ideaContent !== 'string') return false;
6
12
  return /\*\*Status:\*\*\s*in-progress\b/i.test(ideaContent) || /\bstatus:\s*in-progress\b/i.test(ideaContent);
@@ -36,6 +42,9 @@ function getInProgressFeatures(featuresDir) {
36
42
  }
37
43
 
38
44
  function getBacklogTasks(atrisDir) {
45
+ const dbTasks = getTasksFromDbBucket(atrisDir, 'backlog');
46
+ if (dbTasks) return dbTasks;
47
+
39
48
  const todoFile = path.join(atrisDir, 'TODO.md');
40
49
  const legacyTaskContextFile = path.join(atrisDir, 'TASK_CONTEXTS.md');
41
50
  const taskFilePath = fs.existsSync(todoFile)
@@ -60,6 +69,9 @@ function getBacklogTasks(atrisDir) {
60
69
  }
61
70
 
62
71
  function getInProgressTasks(atrisDir) {
72
+ const dbTasks = getTasksFromDbBucket(atrisDir, 'inProgress');
73
+ if (dbTasks) return dbTasks;
74
+
63
75
  const todoFile = path.join(atrisDir, 'TODO.md');
64
76
  const legacyTaskContextFile = path.join(atrisDir, 'TASK_CONTEXTS.md');
65
77
  const taskFilePath = fs.existsSync(todoFile)
@@ -73,6 +85,9 @@ function getInProgressTasks(atrisDir) {
73
85
  }
74
86
 
75
87
  function getCompletedTasks(atrisDir) {
88
+ const dbTasks = getTasksFromDbBucket(atrisDir, 'completed');
89
+ if (dbTasks) return dbTasks;
90
+
76
91
  const todoFile = path.join(atrisDir, 'TODO.md');
77
92
  const legacyTaskContextFile = path.join(atrisDir, 'TASK_CONTEXTS.md');
78
93
  const taskFilePath = fs.existsSync(todoFile)
@@ -111,6 +126,25 @@ function getTasksFromTodoSection(content, sectionName) {
111
126
  .map((line) => line.replace(/^-+\s*/, '').trim());
112
127
  }
113
128
 
129
+ function getTasksFromDbBucket(atrisDir, bucketName) {
130
+ const statuses = TASK_STATUS_BUCKETS[bucketName];
131
+ if (!statuses) return null;
132
+ try {
133
+ const taskDb = require('./task-db');
134
+ if (!fs.existsSync(taskDb.getDbPath())) return null;
135
+ const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
136
+ const rows = taskDb.listTasks(taskDb.open(), { workspaceRoot, limit: 500 });
137
+ if (!rows.length) return null;
138
+ return rows
139
+ .filter(row => statuses.has(String(row.status || '').toLowerCase()))
140
+ .map(row => row.title)
141
+ .filter(Boolean);
142
+ } catch (e) {
143
+ if (process.env.ATRIS_DEBUG) console.error('[state-detection] db task read failed:', e.message);
144
+ return null;
145
+ }
146
+ }
147
+
114
148
  function escapeRegExp(value) {
115
149
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
150
  }