atris 3.58.6 → 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.
@@ -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,
@@ -21,6 +21,15 @@ const { loadContext } = require('../lib/state-detection');
21
21
  const { buildToolResultBody } = require('../lib/tool-result-encode');
22
22
  const { commitReviewLearning } = require('./learn');
23
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
+ }
32
+
24
33
  function wrapWorkflowText(text, width = 76) {
25
34
  const normalized = String(text || '').replace(/\s+/g, ' ').trim();
26
35
  if (!normalized) return [''];
@@ -1775,12 +1784,7 @@ async function reviewAtris() {
1775
1784
  const timestamp = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
1776
1785
  const learning = `- ${timestamp} \u2014 ${answer.trim()}`;
1777
1786
 
1778
- // Find or create ## Notes section
1779
- if (journalContent.includes('## Notes')) {
1780
- journalContent = journalContent.replace(/## Notes\n/, `## Notes\n${learning}\n`);
1781
- } else {
1782
- journalContent += `\n## Notes\n${learning}\n`;
1783
- }
1787
+ journalContent = appendReviewLearningToJournal(journalContent, learning);
1784
1788
 
1785
1789
  fs.writeFileSync(logFile, journalContent);
1786
1790
  console.log('');
@@ -1893,6 +1897,7 @@ module.exports = {
1893
1897
  planAtris,
1894
1898
  doAtris,
1895
1899
  reviewAtris,
1900
+ appendReviewLearningToJournal,
1896
1901
  renderReviewMinute,
1897
1902
  executorAgentPrompt,
1898
1903
  executorDispatchForTask,
@@ -283,6 +283,51 @@ function parseVttTimestampMs(value) {
283
283
  return ((hours * 3600) + (minutes * 60) + seconds) * 1000 + millis;
284
284
  }
285
285
 
286
+ function parseCleanTimestampMs(value) {
287
+ const match = String(value || '').trim().match(/^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/);
288
+ if (!match) return null;
289
+ const hours = Number(match[1] || 0);
290
+ const minutes = Number(match[2] || 0);
291
+ const seconds = Number(match[3] || 0);
292
+ if (![hours, minutes, seconds].every(Number.isFinite)) return null;
293
+ if (minutes > 59 || seconds > 59) return null;
294
+ return ((hours * 3600) + (minutes * 60) + seconds) * 1000;
295
+ }
296
+
297
+ function parseCleanTranscriptCues(raw) {
298
+ const cues = [];
299
+ let startMs = 0;
300
+ let sawStamp = false;
301
+ const pending = [];
302
+ const flush = () => {
303
+ const text = pending.join(' ').replace(/\s+/g, ' ').trim();
304
+ pending.length = 0;
305
+ if (!text) return;
306
+ const cue = { startMs, text };
307
+ if (cues.length && cues[cues.length - 1].text === cue.text && cues[cues.length - 1].startMs === cue.startMs) {
308
+ return;
309
+ }
310
+ cues.push(cue);
311
+ };
312
+ for (const line of String(raw).split(/\r?\n/)) {
313
+ const stripped = line.trim();
314
+ if (!stripped) continue;
315
+ const stamp = stripped.match(/^\[(\d{1,2}:\d{2}(?::\d{2})?)\]$/);
316
+ if (stamp) {
317
+ flush();
318
+ const parsed = parseCleanTimestampMs(stamp[1]);
319
+ if (parsed != null) {
320
+ startMs = parsed;
321
+ sawStamp = true;
322
+ }
323
+ continue;
324
+ }
325
+ pending.push(stripped.replace(/<[^>]+>/g, ''));
326
+ }
327
+ flush();
328
+ return sawStamp && cues.length ? cues : [];
329
+ }
330
+
286
331
  function fetchCaptionText(urlString, redirects = 0) {
287
332
  if (!captionHostAllowed(urlString)) {
288
333
  return Promise.resolve(null);
@@ -380,7 +425,7 @@ function parseCaptionCues(raw) {
380
425
  return cues;
381
426
  }
382
427
 
383
- return [];
428
+ return parseCleanTranscriptCues(raw);
384
429
  }
385
430
 
386
431
  function parseCaptionText(raw) {
@@ -415,7 +460,14 @@ function parseCaptionText(raw) {
415
460
  }
416
461
 
417
462
  function parseYtDlpInfoJson(result) {
418
- const raw = String((result && result.stdout) || '').trim();
463
+ const kept = [];
464
+ for (const line of String((result && result.stdout) || '').split(/\r?\n/)) {
465
+ const trimmed = line.trim();
466
+ if (!trimmed) continue;
467
+ if (/^(WARNING|ERROR|INFO)\b/i.test(trimmed)) continue;
468
+ kept.push(trimmed);
469
+ }
470
+ const raw = kept.join('\n').trim();
419
471
  if (!raw) return null;
420
472
  try {
421
473
  const info = JSON.parse(raw);
@@ -426,7 +478,7 @@ function parseYtDlpInfoJson(result) {
426
478
  }
427
479
 
428
480
  function localCaptionNames(id) {
429
- // scripts/det/ytnotes keeps the same VTT names (not clean.txt).
481
+ // scripts/det/ytnotes keeps these VTT names plus leftover yt_<id>.clean.txt.
430
482
  return [
431
483
  `yt_${id}.en.vtt`,
432
484
  `yt_${id}.en-orig.vtt`,
@@ -765,6 +817,10 @@ function applySidecarRel(id) {
765
817
  return applyGate.applySidecarRel('youtube', id);
766
818
  }
767
819
 
820
+ function notesApplyRel(id) {
821
+ return applyGate.applySidecarRel('notes', experimentIdToken(id));
822
+ }
823
+
768
824
  function notesExperimentSlug(id) {
769
825
  return `notes-${experimentIdToken(id)}`;
770
826
  }
@@ -814,7 +870,7 @@ function saveRichNotes(url, deps = {}) {
814
870
  url,
815
871
  lesson,
816
872
  slug: id ? notesExperimentSlug(id) : null,
817
- applyRel: id ? applySidecarRel(id) : null,
873
+ applyRel: id ? notesApplyRel(id) : null,
818
874
  });
819
875
  return { thin: false, brief, packRel, lesson };
820
876
  }
@@ -826,7 +882,7 @@ function ensureNotesApply({ cwd, url, packRel, now, output } = {}) {
826
882
  return applyGate.ensureApply({
827
883
  cwd,
828
884
  source: url,
829
- rel: id ? applySidecarRel(id) : null,
885
+ rel: id ? notesApplyRel(id) : null,
830
886
  now,
831
887
  output,
832
888
  incompleteMessage: slug
@@ -907,6 +963,7 @@ function unsaveYoutubeNotes(target, deps = {}) {
907
963
  };
908
964
  add(briefRel);
909
965
  add(applyRel);
966
+ add(notesApplyRel(id));
910
967
  for (const section of sections) {
911
968
  add(teachBriefRel(id, section));
912
969
  add(applySidecarRel(`${id}-s${section}`));
@@ -959,6 +1016,7 @@ function ensureProcessApply({ cwd, url, now, output } = {}) {
959
1016
  output,
960
1017
  incompleteMessage: PROCESS_APPLY_MESSAGE,
961
1018
  required: true,
1019
+ human: true,
962
1020
  });
963
1021
  }
964
1022
 
@@ -1455,15 +1513,22 @@ function channelVideosUrl(channel) {
1455
1513
  return `${base}/videos`;
1456
1514
  }
1457
1515
 
1516
+ function looksLikeFlatVideoId(id) {
1517
+ const text = String(id || '');
1518
+ if (/^(NA|None)$/i.test(text)) return false;
1519
+ return /^[A-Za-z0-9_-]+$/.test(text);
1520
+ }
1521
+
1458
1522
  function parseFlatPlaylist(stdout) {
1459
1523
  const videos = [];
1460
1524
  for (const line of String(stdout || '').split(/\r?\n/)) {
1461
1525
  const trimmed = line.trim();
1462
1526
  if (!trimmed || !trimmed.includes('|')) continue;
1527
+ if (/^(WARNING|ERROR|INFO)\b/i.test(trimmed)) continue;
1463
1528
  const idx = trimmed.indexOf('|');
1464
1529
  const id = trimmed.slice(0, idx).trim();
1465
1530
  const title = trimmed.slice(idx + 1).trim();
1466
- if (id && id !== 'NA') videos.push({ id, title });
1531
+ if (looksLikeFlatVideoId(id)) videos.push({ id, title });
1467
1532
  }
1468
1533
  return videos;
1469
1534
  }
@@ -1744,6 +1809,7 @@ function defaultPlaylistExpander(playlistUrl, deps = {}) {
1744
1809
  const result = spawn('yt-dlp', [
1745
1810
  '--no-update',
1746
1811
  '--flat-playlist',
1812
+ '--no-warnings',
1747
1813
  '--print',
1748
1814
  '%(id)s|%(title)s',
1749
1815
  playlistUrl,
@@ -1938,7 +2004,7 @@ function runYoutubeNotesBatch({ urls, engine, save, json } = {}, deps = {}) {
1938
2004
  }));
1939
2005
  const baseline = proveSavedLearnerBaseline({
1940
2006
  cwd: deps.cwd || process.cwd(),
1941
- applyRel: id ? applySidecarRel(id) : null,
2007
+ applyRel: id ? notesApplyRel(id) : null,
1942
2008
  lesson,
1943
2009
  output,
1944
2010
  json: asJson,
@@ -1989,7 +2055,7 @@ function runSingleYoutubeNotes(url, engine, deps = {}) {
1989
2055
  const id = videoIdFromUrl(url);
1990
2056
  const baseline = proveSavedLearnerBaseline({
1991
2057
  cwd,
1992
- applyRel: id ? applySidecarRel(id) : null,
2058
+ applyRel: id ? notesApplyRel(id) : null,
1993
2059
  lesson: saved.lesson,
1994
2060
  output,
1995
2061
  json: deps.json === true,
@@ -2129,10 +2195,12 @@ function parseSearchStdout(stdout = '') {
2129
2195
  for (const line of String(stdout || '').split(/\r?\n/)) {
2130
2196
  const trimmed = line.trim();
2131
2197
  if (!trimmed || !trimmed.includes('|')) continue;
2198
+ if (/^(WARNING|ERROR|INFO)\b/i.test(trimmed)) continue;
2132
2199
  const parts = trimmed.split(/\s*\|\s*/).map((part) => part.trim());
2133
2200
  if (parts.length < 5) continue;
2134
2201
  const url = parts[parts.length - 1];
2135
2202
  if (!/^https?:\/\/(?:www\.)?(?:youtube\.com\/|youtu\.be\/)/i.test(url)) continue;
2203
+ if (!looksLikeFlatVideoId(videoIdFromUrl(url))) continue;
2136
2204
  const row = {
2137
2205
  title: parts[0] || '',
2138
2206
  channel: parts[1] || '',