atris 3.58.5 → 3.58.7

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 (42) hide show
  1. package/README.md +8 -0
  2. package/atris/policies/engineering-principles.md +129 -0
  3. package/atris/policies/genesis.md +112 -0
  4. package/atris/policies/product-design-principles.md +100 -0
  5. package/atris/skills/design/SKILL.md +3 -1
  6. package/atris/skills/engines/SKILL.md +3 -3
  7. package/atris/skills/x-search/SKILL.md +2 -2
  8. package/atris/skills/youtube/SKILL.md +44 -28
  9. package/bin/atris.js +56 -3
  10. package/commands/auth.js +58 -24
  11. package/commands/brain.js +1 -0
  12. package/commands/design.js +362 -0
  13. package/commands/doc-health.js +329 -0
  14. package/commands/drive.js +32 -0
  15. package/commands/improve.js +67 -1
  16. package/commands/land.js +144 -4
  17. package/commands/learn.js +211 -40
  18. package/commands/member.js +65 -11
  19. package/commands/mission.js +37 -7
  20. package/commands/pulse.js +38 -0
  21. package/commands/rsi.js +156 -0
  22. package/commands/task.js +41 -1
  23. package/commands/workflow.js +15 -14
  24. package/commands/x-search.js +9 -10
  25. package/commands/youtube.js +518 -107
  26. package/lib/apply-gate.js +22 -4
  27. package/lib/daily-log.js +88 -0
  28. package/lib/design-api.js +130 -0
  29. package/lib/engine-ask.js +1 -1
  30. package/lib/first-minute.js +1 -6
  31. package/lib/known-commands.js +3 -3
  32. package/lib/member-context.js +42 -0
  33. package/lib/rsi-record.js +335 -0
  34. package/lib/state-detection.js +8 -8
  35. package/lib/task-db.js +71 -51
  36. package/lib/task-list-keeper.js +192 -0
  37. package/lib/todo-fallback.js +9 -3
  38. package/lib/todo.js +22 -10
  39. package/mcp/atris-mcp/index.mjs +174 -0
  40. package/package.json +8 -3
  41. package/scripts/det/ytnotes +122 -10
  42. package/utils/auth.js +109 -13
@@ -6,6 +6,7 @@ const crypto = require('crypto');
6
6
  const readline = require('readline');
7
7
  const { spawn, spawnSync } = require('child_process');
8
8
  const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
9
+ const { memberProcessPrompt } = require('../lib/member-context');
9
10
  const {
10
11
  compactErrorPayload,
11
12
  compactSuccessPayload,
@@ -111,6 +112,15 @@ const CODEX_NATIVE_GOAL_CLOSED_STATUSES = new Set(['complete', 'completed', 'ach
111
112
  const DEFAULT_LONG_RUN_VERIFIER = 'git diff --check';
112
113
  const SLEEP_LENGTH_BUDGET_SECONDS = 3600;
113
114
  const HUMAN_BLOCKING_PAUSE_REASONS = new Set(['auth-required', 'model-unavailable', 'rate-limit-exceeded-wall']);
115
+ // Claude prints its logged-out message on stdout ("Invalid API key · Please
116
+ // run /login"), not stderr, so the check has to read the captured result text
117
+ // too or a dead login masquerades as a generic claude-error.
118
+ const MISSION_RUNNER_AUTH_EXPIRED_RE = /not authenticated|please log in|login required|auth(?:entication)? expired|invalid (?:api|connection) key|run \/login/i;
119
+
120
+ function missionRunnerAuthExpired(...texts) {
121
+ return texts.some((text) => Boolean(text) && MISSION_RUNNER_AUTH_EXPIRED_RE.test(String(text)));
122
+ }
123
+
114
124
  const MISSION_BUDGET_TIERS = Object.freeze({
115
125
  quick: Object.freeze({ max_ticks: 4, requested_seconds: 15 * 60 }),
116
126
  long: Object.freeze({ max_ticks: 12, requested_seconds: 60 * 60 }),
@@ -8406,8 +8416,12 @@ function probeClaudeBinary() {
8406
8416
  // Pull unread operator pings off the mission and mark them consumed, so the
8407
8417
  // next tick's prompt carries them exactly once. Pings are how a human talks to
8408
8418
  // an always-on member mid-run: atris member ping <name> "<msg>".
8419
+ function pendingMissionPings(mission) {
8420
+ return (Array.isArray(mission.pings) ? mission.pings : []).filter((p) => p && !p.consumed_at);
8421
+ }
8422
+
8409
8423
  function consumeMissionPings(mission, cwd) {
8410
- const pending = (Array.isArray(mission.pings) ? mission.pings : []).filter((p) => p && !p.consumed_at);
8424
+ const pending = pendingMissionPings(mission);
8411
8425
  if (!pending.length) return { mission, pings: [] };
8412
8426
  const consumedAt = stampIso();
8413
8427
  const pings = (mission.pings || []).map((p) => (p && !p.consumed_at ? { ...p, consumed_at: consumedAt } : p));
@@ -8415,7 +8429,8 @@ function consumeMissionPings(mission, cwd) {
8415
8429
  return { mission: saved, pings: pending };
8416
8430
  }
8417
8431
 
8418
- function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = []) {
8432
+ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd = process.cwd()) {
8433
+ const sharedProcess = memberProcessPrompt(cwd);
8419
8434
  const pingLines = pings.length
8420
8435
  ? [
8421
8436
  ``,
@@ -8447,6 +8462,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = []) {
8447
8462
  `**Last tick:** ${mission.last_tick_at || 'never'}`,
8448
8463
  ...missionBudgetPromptLines(mission),
8449
8464
  ...checkFeedbackLines,
8465
+ ...(sharedProcess ? ['', sharedProcess] : []),
8450
8466
  ``,
8451
8467
  `## Your task`,
8452
8468
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
@@ -8765,7 +8781,7 @@ function spawnGenericRunnerTick(mission, opts) {
8765
8781
  const finalText = String(stdout || '').trim();
8766
8782
  const errStr = String(stderr || '').slice(-2000);
8767
8783
  const ok = code === 0 && !timedOut && !aborted;
8768
- const authExpired = /not authenticated|please log in|login required|auth(?:entication)? expired/i.test(errStr);
8784
+ const authExpired = !ok && missionRunnerAuthExpired(errStr, finalText);
8769
8785
  resolve({
8770
8786
  ok,
8771
8787
  brief_id: briefId,
@@ -8940,7 +8956,7 @@ function spawnClaudeTick(mission, opts) {
8940
8956
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
8941
8957
  const ok = code === 0 && !isError && !timedOut && !aborted;
8942
8958
  const errStr = stderr.slice(-2000);
8943
- const authExpired = /not authenticated|please log in|login required|auth(?:entication)? expired/i.test(errStr);
8959
+ const authExpired = !ok && missionRunnerAuthExpired(errStr, finalText, stdoutBuf);
8944
8960
  resolve({
8945
8961
  ok,
8946
8962
  timedOut,
@@ -9521,6 +9537,9 @@ async function executeMissionRunTicksPhase(context) {
9521
9537
  drill: drillResult,
9522
9538
  };
9523
9539
  } else if (tickAtris2Runner) {
9540
+ // Build before consuming direction or allocating the guard: a shared
9541
+ // process read error must leave operator pings available for retry.
9542
+ const prompt = buildTickPrompt(tickRuntimeMission, tickIdx, effectiveMaxTicks, frozen, pendingMissionPings(mission), cwd);
9524
9543
  let atris2GitGuard = null;
9525
9544
  try {
9526
9545
  atris2GitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd) });
@@ -9541,7 +9560,6 @@ async function executeMissionRunTicksPhase(context) {
9541
9560
  const pingDrain = consumeMissionPings(mission, cwd);
9542
9561
  mission = pingDrain.mission;
9543
9562
  runtimeMission = runtimeView(mission);
9544
- const prompt = buildTickPrompt(tickRuntimeMission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
9545
9563
  const { runAtris2Turn } = require('./probe');
9546
9564
  const businessId = businessIdForAtris2Mission(tickRuntimeMission, cwd);
9547
9565
  const tickController = new AbortController();
@@ -9593,12 +9611,12 @@ async function executeMissionRunTicksPhase(context) {
9593
9611
  }
9594
9612
  }
9595
9613
  } else {
9614
+ const prompt = buildTickPrompt(tickRuntimeMission, tickIdx, effectiveMaxTicks, frozen, pendingMissionPings(mission), cwd);
9596
9615
  let sessionMode = sessionId ? 'resume' : 'set';
9597
9616
  let useId = sessionId || pendingSessionId;
9598
9617
  const pingDrain = consumeMissionPings(mission, cwd);
9599
9618
  mission = pingDrain.mission;
9600
9619
  runtimeMission = runtimeView(mission);
9601
- const prompt = buildTickPrompt(tickRuntimeMission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
9602
9620
  const restoreTickRunnerProfile = tickEngineId ? applyMissionRunnerProfile(tickEngineId) : () => {};
9603
9621
  let claudeResult;
9604
9622
  let sessionBusyRetried = false;
@@ -9813,6 +9831,14 @@ async function executeMissionRunTicksPhase(context) {
9813
9831
  const claudeRanTick = Boolean(result.claude) && result.status === 'ran';
9814
9832
  const claudeSessionTicks = claudeRanTick ? Number(mission.claude_session_ticks || 0) + 1 : Number(mission.claude_session_ticks || 0);
9815
9833
  const rotateSessionForContext = claudeRanTick && claudeSessionTicks >= CLAUDE_SESSION_CONTEXT_ROTATE_TICKS;
9834
+ // Heartbeat missions run one tick per invocation, so the in-run ticks
9835
+ // array alone can never see two identical errors. Persist the trailing
9836
+ // same-reason errored count so the breaker below trips across runs.
9837
+ const errorStreakCount = result.status === 'errored' && result.reason
9838
+ ? (latestOnDisk.last_tick_status === 'errored' && latestOnDisk.last_tick_reason === result.reason
9839
+ ? Number(latestOnDisk.error_streak_count || 0) + 1
9840
+ : 1)
9841
+ : 0;
9816
9842
  // Base on latestOnDisk so mid-tick complete proof/completed_at survive.
9817
9843
  mission = saveMission({
9818
9844
  ...latestOnDisk,
@@ -9825,6 +9851,7 @@ async function executeMissionRunTicksPhase(context) {
9825
9851
  last_tick_index: tickIdx,
9826
9852
  last_tick_layer: result.layer,
9827
9853
  last_tick_layer_source: result.layer_source,
9854
+ error_streak_count: errorStreakCount,
9828
9855
  verifier_result: verifierResult || (verifyEach && result.protected_lane_guard && result.protected_lane_guard.allowed === false ? null : latestOnDisk.verifier_result) || null,
9829
9856
  last_check_feedback: verifierResult
9830
9857
  ? extractCheckFeedback(verifierResult)
@@ -9902,9 +9929,12 @@ async function executeMissionRunTicksPhase(context) {
9902
9929
  // is the same trap one step less deterministic: keep retrying and the loop burns every
9903
9930
  // tick + cron firing on it. Halt at two-in-a-row and surface the reason for a human.
9904
9931
  const errStreak = consecutiveSameReasonErrors(ticks);
9932
+ // The in-run ticks array only sees this invocation; the persisted streak
9933
+ // carries identical errors across heartbeat runs of one tick each.
9934
+ const errStreakCount = Math.max(errStreak.count, errStreak.reason ? Number(mission.error_streak_count || 0) : 0);
9905
9935
  // Sleeping Atris2 backends are different: leave the mission running so the
9906
9936
  // next tick or heartbeat can catch the backend after it wakes.
9907
- if (errStreak.count >= 2 && !missionRunKeepsRetryingError(errStreak.reason)) { pauseReason = `repeated-error:${errStreak.reason}`; break; }
9937
+ if (errStreakCount >= 2 && !missionRunKeepsRetryingError(errStreak.reason)) { pauseReason = `repeated-error:${errStreak.reason}`; break; }
9908
9938
 
9909
9939
  // Sleep until next tick
9910
9940
  let sleepMs = 0;
package/commands/pulse.js CHANGED
@@ -18,6 +18,7 @@ const os = require('os');
18
18
  const path = require('path');
19
19
  const { spawnSync } = require('child_process');
20
20
  const pulse = require('../lib/pulse');
21
+ const rsi = require('../lib/rsi-record');
21
22
  const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('../lib/runner-command');
22
23
  const { hasFlag } = require('../lib/arg-parser');
23
24
 
@@ -267,6 +268,14 @@ function tickCommand(args, root = process.cwd()) {
267
268
  orbIngestError,
268
269
  }));
269
270
 
271
+ // Dream-RSI: record this tick as one attempt when the workspace has the
272
+ // recorder. Best-effort; a missing or failing recorder never changes the
273
+ // tick's outcome. The engine spawns claude-backed runners by default.
274
+ const rsiLog = (m) => { if (!asJson) process.stderr.write(`rsi: ${m}\n`); };
275
+ const rsiAttempt = rsi.beginAttempt(root, { lane: rsi.IMPROVE_LANE, engine: 'claude', log: rsiLog });
276
+ const rsiBefore = rsiAttempt ? rsi.gitSnapshot(root) : null;
277
+ let rsiOutcome = null;
278
+
270
279
  let engine;
271
280
  let verify = { passed: null, cmd: verifyCmd };
272
281
  try {
@@ -312,6 +321,14 @@ function tickCommand(args, root = process.cwd()) {
312
321
  ? 'no due mission; heartbeat alive (no-op)'
313
322
  : `mission ${engine.reason}${changedTail}`;
314
323
 
324
+ rsiOutcome = {
325
+ status: !engine.ok || verify.passed === false ? 'failed' : producedWork ? 'shipped' : 'nothing',
326
+ verify: verify.passed === true ? 'pass' : verify.passed === false ? 'fail' : 'skipped',
327
+ elapsed_s: Math.round(elapsedMs / 100) / 10,
328
+ engine_calls: 1,
329
+ reason: String(what || '').slice(0, 200),
330
+ };
331
+
315
332
  const receipt = pulse.buildPulseReceipt({
316
333
  tickIndex,
317
334
  phase: 'finished',
@@ -426,11 +443,32 @@ function tickCommand(args, root = process.cwd()) {
426
443
  orb_policy_lesson: orbPolicy,
427
444
  };
428
445
  if (orbIngestError) out.orb_ingest_error = orbIngestError;
446
+ rsiOutcome = {
447
+ status: 'failed',
448
+ verify: 'skipped',
449
+ elapsed_s: Math.round((Date.now() - startedAt) / 100) / 10,
450
+ engine_calls: 1,
451
+ reason: String(err && err.message ? err.message : err).slice(-200),
452
+ };
429
453
  if (!asJson) process.stdout.write(`pulse tick #${tickIndex} crashed: ${out.error}\n`);
430
454
  return emit(out, asJson);
431
455
  } finally {
432
456
  process.removeListener('SIGINT', finishInterrupted);
433
457
  process.removeListener('SIGTERM', finishInterrupted);
458
+ if (rsiAttempt) {
459
+ const delta = rsi.gitDelta(root, rsiBefore);
460
+ rsi.finishAttempt(root, rsiAttempt, {
461
+ commits: delta.commits,
462
+ files: delta.files,
463
+ ...(rsiOutcome || {
464
+ status: 'failed',
465
+ verify: 'skipped',
466
+ elapsed_s: Math.round((Date.now() - startedAt) / 100) / 10,
467
+ engine_calls: 1,
468
+ reason: 'tick ended without an outcome',
469
+ }),
470
+ }, { log: rsiLog });
471
+ }
434
472
  pulse.releaseLock(root);
435
473
  }
436
474
  }
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ // atris rsi - read the Dream-RSI attempt ledger in plain words.
4
+ //
5
+ // Prints trees and attempts by lane, the most recent day's attempts and
6
+ // outcomes, the live policy id, and the last dream receipt. Read-only: it
7
+ // never touches record.py, never writes state, and exits 0 even when the
8
+ // workspace has no ledger yet.
9
+
10
+ const fs = require('fs');
11
+ const rsi = require('../lib/rsi-record');
12
+
13
+ function readJsonl(file) {
14
+ try {
15
+ return fs.readFileSync(file, 'utf8')
16
+ .split(/\r?\n/)
17
+ .map((line) => line.trim())
18
+ .filter(Boolean)
19
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } })
20
+ .filter(Boolean);
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ function dayOf(node) {
27
+ return String((node && node.created_at) || '').slice(0, 10);
28
+ }
29
+
30
+ function collectStatus(root) {
31
+ const nodes = rsi.latestNodes(root);
32
+ const lanes = new Map();
33
+ for (const n of nodes) {
34
+ const lane = String(n.lane || 'unknown');
35
+ if (!lanes.has(lane)) {
36
+ lanes.set(lane, { trees: new Set(), nodes: 0, shipped: 0, failed: 0, nothing: 0, open: 0 });
37
+ }
38
+ const l = lanes.get(lane);
39
+ l.trees.add(String(n.tree_id || ''));
40
+ l.nodes += 1;
41
+ const st = n.outcome && n.outcome.status;
42
+ if (st === 'shipped') l.shipped += 1;
43
+ else if (st === 'failed') l.failed += 1;
44
+ else if (st === 'nothing') l.nothing += 1;
45
+ else l.open += 1; // running / waiting / unlabeled
46
+ }
47
+ const days = [...new Set(nodes.map(dayOf).filter(Boolean))].sort();
48
+ const lastDay = days.length ? days[days.length - 1] : null;
49
+ const lastAttempts = lastDay ? nodes.filter((n) => dayOf(n) === lastDay) : [];
50
+ const policyFile = `${root}/atris/rsi/policy.current`;
51
+ const policy = fs.existsSync(policyFile) ? rsi.currentPolicyId(root) : 'p-0001 (default)';
52
+ const dreams = readJsonl(rsi.dreamsPath(root));
53
+ return {
54
+ lanes,
55
+ lastDay,
56
+ lastAttempts,
57
+ policy,
58
+ lastDream: dreams.length ? dreams[dreams.length - 1] : null,
59
+ totalNodes: nodes.length,
60
+ ledger: rsi.attemptsPath(root),
61
+ };
62
+ }
63
+
64
+ function outcomeWord(status, verify) {
65
+ if (status === 'shipped') return verify === 'pass' ? 'shipped, verify passed' : 'shipped';
66
+ if (status === 'failed') return 'failed';
67
+ if (status === 'nothing') return 'found nothing to do';
68
+ return 'still open';
69
+ }
70
+
71
+ function formatStatus(s) {
72
+ const lines = ['rsi attempts ledger', ` ${s.ledger}`, ''];
73
+ if (!s.totalNodes) {
74
+ lines.push(' no attempts recorded yet.');
75
+ lines.push('');
76
+ lines.push(` policy: ${s.policy}`);
77
+ if (s.lastDream) lines.push(` last dream: ${dreamLine(s.lastDream)}`);
78
+ else lines.push(' no dreams yet.');
79
+ return lines.join('\n');
80
+ }
81
+ const totalTrees = [...s.lanes.values()].reduce((a, l) => a + l.trees.size, 0);
82
+ lines.push(` ${totalTrees} tree${totalTrees === 1 ? '' : 's'}, ${s.totalNodes} attempt${s.totalNodes === 1 ? '' : 's'} across ${s.lanes.size} lane${s.lanes.size === 1 ? '' : 's'}:`);
83
+ for (const [lane, l] of [...s.lanes.entries()].sort()) {
84
+ const parts = [];
85
+ if (l.shipped) parts.push(`${l.shipped} shipped`);
86
+ if (l.failed) parts.push(`${l.failed} failed`);
87
+ if (l.nothing) parts.push(`${l.nothing} nothing`);
88
+ if (l.open) parts.push(`${l.open} open`);
89
+ lines.push(` ${lane}: ${l.trees.size} tree${l.trees.size === 1 ? '' : 's'}, ${l.nodes} attempt${l.nodes === 1 ? '' : 's'}${parts.length ? ` (${parts.join(', ')})` : ''}`);
90
+ }
91
+ lines.push('');
92
+ lines.push(` last night (${s.lastDay}): ${s.lastAttempts.length} attempt${s.lastAttempts.length === 1 ? '' : 's'}`);
93
+ for (const n of s.lastAttempts.slice(0, 10)) {
94
+ const reason = String((n.outcome && n.outcome.reason) || '').replace(/\s+/g, ' ').trim().slice(0, 80);
95
+ lines.push(` ${n.id}: ${outcomeWord(n.outcome && n.outcome.status, n.outcome && n.outcome.verify)}${reason ? ` - ${reason}` : ''}`);
96
+ }
97
+ if (s.lastAttempts.length > 10) lines.push(` ...and ${s.lastAttempts.length - 10} more`);
98
+ lines.push('');
99
+ lines.push(` policy: ${s.policy}`);
100
+ lines.push(s.lastDream ? ` last dream: ${dreamLine(s.lastDream)}` : ' no dreams yet.');
101
+ return lines.join('\n');
102
+ }
103
+
104
+ function dreamLine(receipt) {
105
+ const deployed = receipt.deployed ? `deployed ${receipt.deployed}` : `kept ${receipt.current || 'current policy'}`;
106
+ const reason = String(receipt.reason || '').replace(/\s+/g, ' ').trim().slice(0, 120);
107
+ return `${receipt.at || '(no date)'} · ${deployed}${reason ? ` · ${reason}` : ''}`;
108
+ }
109
+
110
+ function showHelp() {
111
+ console.log(`atris rsi - read the Dream-RSI attempt ledger
112
+
113
+ Usage:
114
+ atris rsi same as status
115
+ atris rsi status trees + attempts by lane, last night's outcomes, policy, last dream
116
+ atris rsi status --json machine-readable status
117
+
118
+ Read-only. State lives in .atris/state/rsi/ (or $ATRIS_RSI_STATE).`);
119
+ }
120
+
121
+ function run(argv = []) {
122
+ const args = Array.isArray(argv) ? argv : [];
123
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
124
+ showHelp();
125
+ return 0;
126
+ }
127
+ const sub = args[0] && !args[0].startsWith('-') ? args[0] : 'status';
128
+ if (sub !== 'status') {
129
+ console.log(`unknown rsi subcommand: ${sub}`);
130
+ showHelp();
131
+ return 2;
132
+ }
133
+ const root = process.cwd();
134
+ const s = collectStatus(root);
135
+ if (args.includes('--json')) {
136
+ const lanes = {};
137
+ for (const [lane, l] of s.lanes.entries()) {
138
+ lanes[lane] = { trees: l.trees.size, nodes: l.nodes, shipped: l.shipped, failed: l.failed, nothing: l.nothing, open: l.open };
139
+ }
140
+ console.log(JSON.stringify({
141
+ ok: true,
142
+ ledger: s.ledger,
143
+ total_nodes: s.totalNodes,
144
+ lanes,
145
+ last_day: s.lastDay,
146
+ last_day_attempts: s.lastAttempts.length,
147
+ policy: s.policy,
148
+ last_dream: s.lastDream,
149
+ }));
150
+ return 0;
151
+ }
152
+ console.log(formatStatus(s));
153
+ return 0;
154
+ }
155
+
156
+ module.exports = { run, collectStatus, formatStatus };
package/commands/task.js CHANGED
@@ -253,6 +253,7 @@ atris task - durable local task state (SQLite, gitignored)
253
253
  Sweep off-roadmap/duplicate work as archived (not failed);
254
254
  --from-failed opts in to relabel a fail-closed row (never done)
255
255
  atris task clear-done [--before <days>] [--dry-run] [--json] Archive completed rows, oldest first
256
+ atris task keep [--json] Put away finished and untouched work, then refresh the list
256
257
  atris task reap-mission-blockers [--json] Close blocker rows whose missions are complete or stopped
257
258
  atris task relabel-archived [--dry-run|--apply]
258
259
  One-time OBL-1622 migration: relabel June-10 backlog-reset rows failed -> archived
@@ -6416,7 +6417,7 @@ function compactTechnicalDetails(task, formatTitle = value => value) {
6416
6417
  }
6417
6418
 
6418
6419
  function taskDayGroups(tasks, { now = Date.now() } = {}) {
6419
- const active = tasks.filter(task => task.status !== 'done');
6420
+ const active = tasks.filter(task => task.status !== 'done' && task.status !== 'archived');
6420
6421
  const staleFailed = [];
6421
6422
  const visible = [];
6422
6423
  for (const task of active) {
@@ -6446,7 +6447,43 @@ function taskDayGroups(tasks, { now = Date.now() } = {}) {
6446
6447
  return { groups: grouped, staleFailed };
6447
6448
  }
6448
6449
 
6450
+ function refreshKeptTaskList(cwd = process.cwd()) {
6451
+ const taskDb = getTaskDb();
6452
+ const db = taskDb.open();
6453
+ writeDefaultProjection(taskDb, db);
6454
+ autoRenderTodoFromDb(cwd);
6455
+ }
6456
+
6457
+ function keptCount(result) {
6458
+ return (result && result.put_away ? result.put_away.length : 0)
6459
+ + (result && result.reaped ? result.reaped.length : 0);
6460
+ }
6461
+
6462
+ function cmdKeep(args) {
6463
+ const result = require('../lib/task-list-keeper').keepWorkspaceTaskList(process.cwd());
6464
+ if (keptCount(result)) refreshKeptTaskList(process.cwd());
6465
+ const count = keptCount(result);
6466
+ if (wantsJson(args)) {
6467
+ printJson({
6468
+ ok: true,
6469
+ action: 'keep',
6470
+ count,
6471
+ put_away: result.put_away,
6472
+ reaped: result.reaped,
6473
+ });
6474
+ return;
6475
+ }
6476
+ if (!count) {
6477
+ console.log('task list is current. nothing to put away.');
6478
+ return;
6479
+ }
6480
+ const noun = count === 1 ? 'item' : 'items';
6481
+ console.log(`put away ${count} ${noun} that were finished or sitting still.`);
6482
+ }
6483
+
6449
6484
  function cmdDay(args) {
6485
+ const kept = require('../lib/task-list-keeper').keepWorkspaceTaskList(process.cwd());
6486
+ if (keptCount(kept)) autoRenderTodoFromDb(process.cwd());
6450
6487
  const all = hasFlag(args, '--all');
6451
6488
  const full = hasFlag(args, '--full');
6452
6489
  const everywhere = taskScopeEverywhere(args);
@@ -13321,6 +13358,7 @@ async function runTaskCommand(args) {
13321
13358
  case 'fail': return cmdDone([...rest, '--failed']);
13322
13359
  case 'archive': return cmdArchive(rest);
13323
13360
  case 'clear-done': return cmdClearDone(rest);
13361
+ case 'keep': return cmdKeep(rest);
13324
13362
  case 'reap-mission-blockers':
13325
13363
  case 'reap-blockers':
13326
13364
  return cmdReapMissionBlockers(rest);
@@ -13425,6 +13463,8 @@ module.exports = {
13425
13463
  delegateTask,
13426
13464
  AGENT_ENV_MARKERS,
13427
13465
  autoRenderTodoFromDb,
13466
+ refreshKeptTaskList,
13467
+ keptCount,
13428
13468
  projectionMissions,
13429
13469
  projectionWishes,
13430
13470
  taskBoardViewModel,
@@ -19,6 +19,16 @@ const { startFirstTalk } = require('../lib/context-gatherer');
19
19
  const { isNonInteractive } = require('../lib/noninteractive');
20
20
  const { loadContext } = require('../lib/state-detection');
21
21
  const { buildToolResultBody } = require('../lib/tool-result-encode');
22
+ const { commitReviewLearning } = require('./learn');
23
+
24
+ function appendReviewLearningToJournal(journalContent, learning) {
25
+ const text = String(journalContent || '').replace(/\r\n/g, '\n');
26
+ const note = String(learning || '');
27
+ if (text.includes('## Notes')) {
28
+ return text.replace(/## Notes\n/, `## Notes\n${note}\n`);
29
+ }
30
+ return `${text}\n## Notes\n${note}\n`;
31
+ }
22
32
 
23
33
  function wrapWorkflowText(text, width = 76) {
24
34
  const normalized = String(text || '').replace(/\s+/g, ' ').trim();
@@ -1774,27 +1784,17 @@ async function reviewAtris() {
1774
1784
  const timestamp = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
1775
1785
  const learning = `- ${timestamp} \u2014 ${answer.trim()}`;
1776
1786
 
1777
- // Find or create ## Notes section
1778
- if (journalContent.includes('## Notes')) {
1779
- journalContent = journalContent.replace(/## Notes\n/, `## Notes\n${learning}\n`);
1780
- } else {
1781
- journalContent += `\n## Notes\n${learning}\n`;
1782
- }
1787
+ journalContent = appendReviewLearningToJournal(journalContent, learning);
1783
1788
 
1784
1789
  fs.writeFileSync(logFile, journalContent);
1785
1790
  console.log('');
1786
1791
  console.log(`✓ Logged to journal: ${learning}`);
1787
1792
  }
1788
1793
 
1789
- // Also log to structured learnings (if learnings module exists)
1790
1794
  try {
1791
- const { addLearning } = require('../lib/learnings');
1792
- const insight = answer.trim();
1793
- // Auto-classify: starts with "don't" or "never" or "avoid" → pitfall, else pattern
1794
- const type = /^(don't|never|avoid|watch out|careful)/i.test(insight) ? 'pitfall' : 'pattern';
1795
- const key = insight.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).slice(0, 4).join('-');
1796
- addLearning({ type, key, insight, confidence: 7, source: 'review', files: [] });
1797
- console.log(`✓ Saved to learnings: [7/10] ${type}/${key}`);
1795
+ commitReviewLearning(answer.trim(), {
1796
+ cwd: process.cwd(),
1797
+ });
1798
1798
  } catch {
1799
1799
  // learnings module not available, so skip silently
1800
1800
  }
@@ -1897,6 +1897,7 @@ module.exports = {
1897
1897
  planAtris,
1898
1898
  doAtris,
1899
1899
  reviewAtris,
1900
+ appendReviewLearningToJournal,
1900
1901
  renderReviewMinute,
1901
1902
  executorAgentPrompt,
1902
1903
  executorDispatchForTask,
@@ -32,7 +32,7 @@ function showXSearchHelp(output = console.log, commandName = 'atris x-search') {
32
32
  output('Prints to stdout. Rich ephemeral prints one apply next-step, then hands off to atris youtube search (no files).');
33
33
  output('--save files a brief only when the result is rich.');
34
34
  output('unsave deletes the filed brief, apply stub, and matching experiment pack (no paid calls).');
35
- output('Empty or failed search refunds the credits.');
35
+ output('Empty or failed search prints credits refunded only when the server marks a refund.');
36
36
  output('');
37
37
  output('Options:');
38
38
  output(' --limit <n> Max results hint (search only)');
@@ -327,13 +327,6 @@ function xSearchCredits(data) {
327
327
  return { used, remaining, refunded };
328
328
  }
329
329
 
330
- function creditsWereRefunded(credits) {
331
- if (!credits) return false;
332
- if (credits.used === 0) return true;
333
- if (credits.refunded === true) return true;
334
- return typeof credits.refunded === 'number' && credits.refunded > 0;
335
- }
336
-
337
330
  function creditsRefundedExplicitly(credits) {
338
331
  if (!credits) return false;
339
332
  if (credits.refunded === true) return true;
@@ -345,7 +338,7 @@ function formatCreditsLines(credits) {
345
338
  if (credits.used !== undefined || credits.remaining !== undefined) {
346
339
  lines.push(`Credits: ${credits.used !== undefined ? credits.used : '?'} used, ${credits.remaining !== undefined ? credits.remaining : '?'} remaining`);
347
340
  }
348
- if (creditsWereRefunded(credits)) {
341
+ if (creditsRefundedExplicitly(credits)) {
349
342
  lines.push('credits refunded');
350
343
  }
351
344
  return lines;
@@ -360,7 +353,7 @@ function xSearchFailureError(result) {
360
353
  ? ' xAI is unavailable; retry in a few seconds.'
361
354
  : '';
362
355
  const credits = xSearchCredits(result.data);
363
- const refundHint = result.status === 502 && creditsWereRefunded(credits)
356
+ const refundHint = result.status === 502 && creditsRefundedExplicitly(credits)
364
357
  ? ' credits refunded.'
365
358
  : '';
366
359
  const lines = [`X search failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
@@ -398,6 +391,12 @@ async function runXSearch(options, deps = {}) {
398
391
  forceMint: true,
399
392
  });
400
393
  if (remint?.ok && remint.token) {
394
+ if (!options.json) {
395
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
396
+ for (const line of formatCreditsLines(xSearchCredits(result.data))) {
397
+ print(line);
398
+ }
399
+ }
401
400
  auth = remint;
402
401
  result = await call(auth.token);
403
402
  }