atris 3.82.1 → 3.82.3

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.
package/README.md CHANGED
@@ -18,7 +18,7 @@ A computer can be personal or shared by a business. It can run a job like code,
18
18
 
19
19
  Atris gives Claude Code, Codex, Cursor and any other agent a memory that lasts across sessions. The memory is plain files in your repo: `atris/MAP.md` (where everything lives, with file and line numbers), `atris/wiki/` (what the team learned, as pages linked with `[[page-name]]`), `atris/logs/` (what was done and checked each day), and `atris task` (open work, who claimed it, and the proof). Every agent reads the same files before it acts, so several agents and people share one memory, reviewed in git like code.
20
20
 
21
- Mem0, Zep and Letta remember the users of a chat app. Atris remembers the project. Guide and comparison: https://atris.ai/blog/memory-for-coding-agents
21
+ Mem0, Zep and Letta remember the users of a chat app. Atris remembers the project. Patterns and setup: [docs/agent-memory.md](docs/agent-memory.md). Comparison: https://atris.ai/blog/memory-for-coding-agents
22
22
 
23
23
  ## Check what ChatGPT can read about your business
24
24
 
@@ -7,7 +7,7 @@ const crypto = require('crypto');
7
7
  const readline = require('readline');
8
8
  const { spawn, spawnSync } = require('child_process');
9
9
  const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
10
- const { memberProcessPrompt } = require('../lib/member-context');
10
+ const { memberProcessPrompt, currentWorkPrompt } = require('../lib/member-context');
11
11
  const { memberIdentityLine, missionObjectiveLine } = require('../lib/member-identity');
12
12
  const { formatToolStep, formatCodexEvent, appendProgress, reportProgress, startedLine, newRunToken, runMarker } = require('../lib/member-progress');
13
13
  const {
@@ -8147,6 +8147,8 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8147
8147
  ...turnDownLines,
8148
8148
  ...(sharedProcess ? ['', sharedProcess] : []),
8149
8149
  ...preloadedContextLines,
8150
+ '',
8151
+ currentWorkPrompt(cwd, { ...mission, verifier: frozen.verifier }),
8150
8152
  ``,
8151
8153
  `## Your task`,
8152
8154
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
@@ -123,10 +123,10 @@ function applyBaselineOptions(root, rawFindings, opts) {
123
123
 
124
124
  function securityReviewCommand(argv = []) {
125
125
  const sub = argv[0];
126
+ if (sub === 'audit') return auditCommand(argv.slice(1));
126
127
  if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) return printHelp();
127
128
  if (sub === 'rules') return printRules();
128
129
  if (sub === 'hook' || sub === 'install-hook') return installHook();
129
- if (sub === 'audit') return auditCommand(argv.slice(1));
130
130
 
131
131
  const root = process.cwd();
132
132
  let opts;
@@ -402,7 +402,7 @@ function installHook() {
402
402
  }
403
403
 
404
404
  function parseAuditArgs(argv) {
405
- const opts = { profile: 'standard', scope: [], json: false, planOnly: false, budget: null, out: null, hunter: null, verifier: null, timeoutMs: null };
405
+ const opts = { profile: 'standard', scope: [], json: false, planOnly: false, budget: null, out: null, hunter: null, verifier: null, timeoutMs: null, engine: null, yes: false, upload: true, scheduled: false };
406
406
  const takes = (i, flag) => {
407
407
  if (!argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`${flag} needs a value`);
408
408
  return argv[i + 1];
@@ -420,6 +420,10 @@ function parseAuditArgs(argv) {
420
420
  else if (flag === '--hunter') opts.hunter = value();
421
421
  else if (flag === '--verifier') opts.verifier = value();
422
422
  else if (flag === '--timeout') opts.timeoutMs = Number(value()) * 1000;
423
+ else if (flag === '--engine') opts.engine = value();
424
+ else if (flag === '--yes' || flag === '-y') opts.yes = true;
425
+ else if (flag === '--no-upload') opts.upload = false;
426
+ else if (flag === '--scheduled') opts.scheduled = true;
423
427
  else if (arg.startsWith('-')) throw new Error(`unknown flag: ${arg}`);
424
428
  else opts.scope.push(arg);
425
429
  }
@@ -427,6 +431,7 @@ function parseAuditArgs(argv) {
427
431
  if (!PROFILES.includes(opts.profile)) throw new Error(`--profile must be one of ${PROFILES.join(', ')}`);
428
432
  if (opts.budget !== null && (!Number.isInteger(opts.budget) || opts.budget < 1 || opts.budget > 500)) throw new Error('--budget must be a whole number from 1 to 500');
429
433
  if (opts.timeoutMs !== null && (!Number.isFinite(opts.timeoutMs) || opts.timeoutMs < 10000)) throw new Error('--timeout is in seconds, at least 10');
434
+ if (opts.engine !== null && !['atris', 'local'].includes(opts.engine)) throw new Error('--engine must be atris or local');
430
435
  return opts;
431
436
  }
432
437
 
@@ -454,7 +459,7 @@ function renderAuditLanding({ dir, meta, records }) {
454
459
  } else {
455
460
  L.push(` ${confirmed.length} confirmed${sevText ? ` (${sevText})` : ''} · ${needs.length} need a fact from you · ${rejected.length} disproved`);
456
461
  L.push(` read ${cov.fresh} areas this run, ${cov.carried} carried unchanged, ${cov.deferred || 0} left for the next run`);
457
- L.push(` found by ${meta.engines.hunter}, checked by ${meta.engines.verifier}${meta.engines.hunter === meta.engines.verifier ? ' (same engine)' : ''} · ${meta.invocations.used} engine calls of ${meta.budget}`);
462
+ L.push(` found by ${meta.engines.hunter}, checked by ${meta.engines.verifier}${meta.engines.hunter === meta.engines.verifier ? ' (same engine)' : ''} · ${meta.invocations.used} engine calls of ${meta.budget}${meta.credits_used ? ` · ${meta.credits_used} credits used` : ''}`);
458
463
  for (const r of confirmed.slice(0, 6)) {
459
464
  const sink = r.trace[r.trace.length - 1];
460
465
  L.push(` ${r.severity.overall.padEnd(8)} ${r.title} (${sink.file}:${sink.line})`);
@@ -465,33 +470,204 @@ function renderAuditLanding({ dir, meta, records }) {
465
470
  return L.join('\n');
466
471
  }
467
472
 
468
- async function auditCommand(argv) {
473
+ // Ask once on a terminal. Anything but y or yes is a no.
474
+ function askYesNo(question, { input = process.stdin, output = process.stdout } = {}) {
475
+ return new Promise((resolve) => {
476
+ const rl = require('readline').createInterface({ input, output });
477
+ rl.question(question, (answer) => { rl.close(); resolve(/^\s*y(es)?\s*$/i.test(String(answer || ''))); });
478
+ });
479
+ }
480
+
481
+ async function uploadAfterRun(dir, { token, quiet, cloud }) {
482
+ const res = await cloud.uploadRun(dir, { token });
483
+ const say = quiet ? (m) => console.error(m) : (m) => console.log(m);
484
+ if (res.ok) say(' saved the summary to your atris account (no source code was sent)');
485
+ else say(` warning: could not save the summary to your atris account: ${res.reason}. the review itself is done`);
486
+ return res;
487
+ }
488
+
489
+ async function auditUploadCommand(argv, deps) {
490
+ const audit = require('../lib/security-audit');
491
+ const cloud = deps.cloud || require('../lib/sentinel-cloud');
492
+ const token = deps.token !== undefined ? deps.token : cloud.authToken();
493
+ if (!token) { console.error(' log in first with atris login. then run atris sentinel upload again'); return 2; }
494
+ const arg = argv.find((a) => !a.startsWith('-'));
495
+ let dir = arg ? path.resolve(arg) : null;
496
+ if (!dir) {
497
+ const last = audit.latestRun(deps.root || process.cwd());
498
+ if (!last) { console.error(' no sentinel review has run for this repo yet'); return 2; }
499
+ dir = last.dir;
500
+ }
501
+ if (!fs.existsSync(path.join(dir, 'run-metadata.json'))) { console.error(` ${tildePath(dir)} is not a sentinel run folder`); return 2; }
502
+ const res = await cloud.uploadRun(dir, { token });
503
+ if (!res.ok) { console.error(` could not save ${path.basename(dir)}: ${res.reason}`); return 1; }
504
+ console.log(` saved ${path.basename(dir)} to your atris account (no source code was sent)`);
505
+ return 0;
506
+ }
507
+
508
+ // Price one quick run on atris models for a folder without calling any
509
+ // model: the audit plans, the estimate is quoted, and the plan is dropped.
510
+ async function quoteQuickRun(target, { hunter, verifier, token, cloud }) {
511
+ const audit = require('../lib/security-audit');
512
+ let estimate = null;
513
+ await audit.runAudit({
514
+ root: target, profile: 'quick', hunter, verifier,
515
+ runJobs: async () => { throw new Error('no model call while pricing a schedule'); },
516
+ confirmSpend: async (est) => { estimate = est; return false; },
517
+ });
518
+ if (!estimate) throw new Error('could not plan a review for this folder');
519
+ return cloud.quoteEstimate(estimate, { token });
520
+ }
521
+
522
+ async function auditScheduleCommand(argv, deps) {
523
+ const cloud = deps.cloud || require('../lib/sentinel-cloud');
524
+ const { installKeep, removeKeep } = require('../lib/campaign-keep');
525
+ const uninstall = argv.includes('--uninstall');
526
+ let every = 'day';
527
+ let target = null;
528
+ let engine = null;
529
+ let maxCredits = null;
530
+ let yes = false;
531
+ for (let i = 0; i < argv.length; i++) {
532
+ const a = argv[i];
533
+ const [flag, inline] = a.startsWith('--') && a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, null];
534
+ const value = () => (inline !== null ? inline : argv[++i]);
535
+ if (flag === '--every') every = value();
536
+ else if (flag === '--engine') engine = value();
537
+ else if (flag === '--max-credits') maxCredits = Number(value());
538
+ else if (flag === '--yes' || flag === '-y') yes = true;
539
+ else if (flag === '--uninstall' || flag === '--install') continue;
540
+ else if (a.startsWith('-')) { console.error(` unknown flag: ${a}`); return 2; }
541
+ else target = a;
542
+ }
543
+ if (!cloud.SCHEDULE_EVERY[every]) { console.error(' use --every day or --every week'); return 2; }
544
+ if (engine !== null && engine !== 'atris' && engine !== 'local') { console.error(' --engine must be atris or local'); return 2; }
545
+ if (maxCredits !== null && (!Number.isInteger(maxCredits) || maxCredits < 1)) { console.error(' --max-credits must be a whole number of credits, at least 1'); return 2; }
546
+ target = path.resolve(target || deps.root || process.cwd());
547
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { console.error(` ${target} is not a folder`); return 2; }
548
+ const atrisBin = deps.atrisBin || path.resolve(__dirname, '..', 'bin', 'atris.js');
549
+ const plan = cloud.schedulePlan({ target, every, homeDir: deps.homeDir, platform: deps.platform, atrisBin, envPath: deps.envPath });
550
+ const keepDeps = deps.spawn ? { spawn: deps.spawn } : {};
551
+ if (uninstall) {
552
+ // Same folder makes the same timer name, so --every does not matter here.
553
+ const removed = removeKeep(plan, keepDeps);
554
+ if (!removed.ok) { console.error(` could not remove the timer: ${removed.error}`); return 1; }
555
+ cloud.removeApproval(target, deps.homeDir);
556
+ console.log(` scheduled reviews ${removed.removed === false ? 'were not on' : 'are off'} for ${tildePath(target)}`);
557
+ return 0;
558
+ }
559
+ const space = require('../lib/worktree-space');
560
+ if (!deps.atrisBin && (space.isAgentCopyPath(atrisBin) || space.isScratchPath(atrisBin))) {
561
+ console.error(` refusing: this atris runs from ${path.dirname(path.dirname(atrisBin))}, a folder that gets removed. install the timer from a lasting atris install`);
562
+ return 2;
563
+ }
564
+
565
+ // The engine is decided now, with the owner here, and written down. A
566
+ // scheduled run never picks again.
567
+ const token = deps.token !== undefined ? deps.token : cloud.authToken();
568
+ let engines;
569
+ try { engines = cloud.resolveEngines({ engine, token, env: deps.env || process.env }); } catch (e) { console.error(` ${e.message}`); return 2; }
570
+ let approval;
571
+ if (engines.mode === 'atris') {
572
+ let quote;
573
+ try { quote = await quoteQuickRun(target, { hunter: engines.hunter, verifier: engines.verifier, token, cloud }); } catch (e) { console.error(` no timer was set. ${e.message}`); return 2; }
574
+ const cap = maxCredits || quote.credits;
575
+ console.log(`\n each scheduled review runs on atris models (no codex or claude needed)`);
576
+ console.log(` ${cloud.describeQuote(quote)}`);
577
+ console.log(` each run may spend at most ${cap} credits, once a ${every}. a run that reaches that stops\n`);
578
+ if (!yes) {
579
+ const input = deps.input || process.stdin;
580
+ if (!input.isTTY && !deps.input) { console.error(' no timer was set. add --yes to approve this spend without the question'); return 1; }
581
+ const ok = await (deps.ask || askYesNo)(` approve up to ${cap} credits per scheduled run? (y/N) `, { input, output: process.stdout });
582
+ if (!ok) { console.log(' no timer was set'); return 1; }
583
+ }
584
+ approval = { engine: 'atris', hunter: engines.hunter, verifier: engines.verifier, max_credits: cap, quoted_credits: quote.credits, every, approved_at: new Date().toISOString() };
585
+ } else {
586
+ approval = { engine: 'local', every, approved_at: new Date().toISOString() };
587
+ }
588
+ cloud.writeApproval(target, approval, deps.homeDir);
589
+ const installed = installKeep(plan, keepDeps);
590
+ if (!installed.ok) { cloud.removeApproval(target, deps.homeDir); console.error(` could not install the timer: ${installed.error}`); return 1; }
591
+ console.log(` scheduled reviews are on for ${tildePath(target)}, once a ${every}`);
592
+ console.log(' each run uses the quick profile and saves its summary to your atris account');
593
+ if (approval.engine === 'local') console.log(' runs use only codex and claude on this machine. they never switch to atris models');
594
+ else console.log(` runs use atris models and stop at ${approval.max_credits} credits`);
595
+ console.log(` log: ${tildePath(plan.log_path)}`);
596
+ console.log(` stop with: atris sentinel schedule --uninstall ${tildePath(target)}`);
597
+ return 0;
598
+ }
599
+
600
+ async function auditCommand(argv, deps = {}) {
469
601
  if (argv[0] === 'help' || argv.includes('-h') || argv.includes('--help')) return printAuditHelp();
470
602
  const audit = require('../lib/security-audit');
603
+ const root = deps.root || process.cwd();
471
604
  if (argv[0] === 'status' || argv[0] === 'last') {
472
- const last = audit.latestRun(process.cwd());
605
+ const last = audit.latestRun(root);
473
606
  if (!last || !last.meta) { console.log('\n no sentinel audit has run for this repo yet\n'); return 0; }
474
607
  if (argv.includes('--json')) { console.log(JSON.stringify({ dir: last.dir, meta: last.meta, findings: last.records }, null, 2)); return 0; }
475
608
  console.log(renderAuditLanding({ dir: last.dir, meta: last.meta, records: last.records }));
476
609
  return 0;
477
610
  }
611
+ if (argv[0] === 'upload') return auditUploadCommand(argv.slice(1), deps);
612
+ if (argv[0] === 'schedule') return auditScheduleCommand(argv.slice(1), deps);
478
613
  let opts;
479
614
  try { opts = parseAuditArgs(argv); } catch (e) { console.error(`security-review audit: ${e.message}`); return 2; }
615
+ const cloud = deps.cloud || require('../lib/sentinel-cloud');
616
+ // A timer run does only what the owner approved at install.
617
+ let capOverride = null;
618
+ if (opts.scheduled) {
619
+ const approval = cloud.readApproval(root, deps.homeDir);
620
+ if (!approval) { console.error('security-review audit: this folder has no approved schedule. nothing ran. run atris sentinel schedule again'); return 2; }
621
+ opts.hunter = null;
622
+ opts.verifier = null;
623
+ opts.engine = approval.engine;
624
+ if (approval.engine === 'atris') { opts.yes = true; capOverride = Number(approval.max_credits); }
625
+ }
626
+ const token = deps.token !== undefined ? deps.token : cloud.authToken();
627
+ let engines;
628
+ try { engines = cloud.resolveEngines({ engine: opts.engine, hunter: opts.hunter, verifier: opts.verifier, token, env: deps.env || process.env }); } catch (e) { console.error(`security-review audit: ${e.message}`); return 2; }
629
+ const hunter = engines.hunter || undefined;
630
+ const verifier = engines.verifier || undefined;
631
+ const hosted = audit.isHostedEngine(hunter) || audit.isHostedEngine(verifier);
632
+ if (hosted && !token) { console.error('security-review audit: reviews on atris models need a login; run atris login first'); return 2; }
480
633
  const log = opts.json ? () => {} : (msg) => console.log(` · ${msg}`);
634
+ const tell = opts.json ? (m) => console.error(m) : (m) => console.log(m);
635
+ if (hosted && engines.chosen === 'no_local_engine' && !opts.planOnly) tell(' no codex or claude found on this machine. this review runs on atris models');
636
+ let runJobs = deps.runJobs;
637
+ const spend = hosted ? cloud.spendGuard({ quoteCall: deps.quoteCall }) : null;
638
+ if (!runJobs && hosted) runJobs = cloud.atrisJobRunner({ token, spend });
639
+ const confirmSpend = async (estimate) => {
640
+ const quote = await cloud.quoteEstimate(estimate, { token });
641
+ spend.max = capOverride || quote.credits;
642
+ spend.limits = quote.limits;
643
+ tell(`\n ${cloud.describeQuote(quote)}\n`);
644
+ if (capOverride) tell(` this scheduled run may spend at most ${capOverride} credits, as approved`);
645
+ if (opts.yes) return true;
646
+ const input = deps.input || process.stdin;
647
+ if (!input.isTTY && !deps.input) { console.error(' nothing was spent. add --yes to run without this question'); return false; }
648
+ const yes = await (deps.ask || askYesNo)(' spend these credits? (y/N) ', { input, output: opts.json ? process.stderr : process.stdout });
649
+ if (!yes) tell(' nothing was spent');
650
+ return yes;
651
+ };
481
652
  let result;
482
653
  try {
483
654
  result = await audit.runAudit({
484
- root: process.cwd(), profile: opts.profile, scope: opts.scope, budget: opts.budget ?? undefined, out: opts.out,
485
- hunter: opts.hunter || undefined, verifier: opts.verifier || undefined, planOnly: opts.planOnly,
655
+ root, profile: opts.profile, scope: opts.scope, budget: opts.budget ?? undefined, out: opts.out,
656
+ hunter, verifier, planOnly: opts.planOnly, runJobs, confirmSpend,
486
657
  timeoutMs: opts.timeoutMs || undefined, log,
487
658
  });
488
659
  } catch (e) {
489
660
  console.error(`security-review audit: ${e.message}`);
490
661
  return 2;
491
662
  }
663
+ if (result.declined) return 1;
492
664
  const fail = audit.shouldFailAudit(result.records);
493
665
  if (opts.json) console.log(JSON.stringify({ ok: !fail, dir: result.dir, meta: result.meta, findings: result.records }, null, 2));
494
666
  else console.log(renderAuditLanding(result));
667
+ if (spend && spend.blocked) tell(` stopped at the approved limit of ${spend.max} credits. ${spend.blocked} model call${spend.blocked === 1 ? '' : 's'} did not run and wait for the next run`);
668
+ if (opts.upload && token && !opts.planOnly) {
669
+ try { await uploadAfterRun(result.dir, { token, quiet: opts.json, cloud }); } catch (e) { tell(` warning: could not save the summary to your atris account: ${e.message}. the review itself is done`); }
670
+ }
495
671
  return fail ? 1 : 0;
496
672
  }
497
673
 
@@ -510,6 +686,37 @@ function printAuditHelp() {
510
686
  atris security-review audit --out DIR write the run here (outside the repo)
511
687
  atris security-review audit status the last run's landing
512
688
 
689
+ on atris models (no other AI subscription needed, paid in atris credits):
690
+
691
+ atris sentinel --engine atris atris:fast searches, atris:pro checks
692
+ atris sentinel --engine atris --yes skip the price question
693
+ atris sentinel --engine local only codex and claude on this machine
694
+
695
+ with no --engine, local engines run when codex or claude is installed.
696
+ atris models run only when neither is installed and you are logged in.
697
+ every run on atris models shows the most it can cost first and stops
698
+ before it could pass that. lines that look like secrets are hidden and
699
+ only tracked files inside the review are ever sent.
700
+
701
+ saving results to your atris account (on when you are logged in):
702
+
703
+ atris sentinel --no-upload keep this run on this machine only
704
+ atris sentinel upload [run-folder] save a past run (default: the last one)
705
+
706
+ only the summary is saved: plain sentences, file:line locations and counts.
707
+ source code and code lines never leave in an upload.
708
+
709
+ reviews on a timer:
710
+
711
+ atris sentinel schedule --every day [path] quick review once a day, saved to your account
712
+ atris sentinel schedule --every week [path] once a week
713
+ atris sentinel schedule --engine atris --max-credits 20
714
+ timed runs on atris models, at most 20 credits each
715
+ atris sentinel schedule --uninstall [path] stop the timer
716
+
717
+ the engine is picked when the timer goes on. on atris models you see the
718
+ price and say yes then. a timed run never switches to atris models later.
719
+
513
720
  How it works: every source file x attack class with a reason to be read
514
721
  becomes an area in a coverage ledger. Read-only engine hunters each own a
515
722
  few areas. A coverage critic names gaps. Every candidate goes to a different
@@ -553,4 +760,4 @@ function printHelp() {
553
760
  return 0;
554
761
  }
555
762
 
556
- module.exports = { securityReviewCommand };
763
+ module.exports = { securityReviewCommand, auditCommand };
package/commands/task.js CHANGED
@@ -10,6 +10,7 @@ const { execSync } = require('node:child_process');
10
10
  const path = require('path');
11
11
  const os = require('os');
12
12
  const { hasFlag } = require('../lib/arg-parser');
13
+ const { buildNextWorkContext, reconcileTaskRows } = require('../lib/next-work-context');
13
14
  const { argsWantHelp } = require('../lib/noninteractive');
14
15
  const {
15
16
  compactErrorPayload,
@@ -74,6 +75,7 @@ const {
74
75
  } = require('../lib/task-decision');
75
76
  const {
76
77
  buildFirstMinute,
78
+ loadLocalTaskState,
77
79
  deskNextCommand,
78
80
  firstTalkCommand,
79
81
  folderName,
@@ -5880,27 +5882,11 @@ function cmdNextTruth(args) {
5880
5882
  const taskDb = getTaskDb();
5881
5883
  const db = taskDb.open();
5882
5884
  const workspaceRoot = taskDb.workspaceRoot();
5883
- const rows = taskDb.listTasks(db, { workspaceRoot, limit: 500 });
5884
- const existingProj = readProjectionFile(workspaceRoot);
5885
- const dbHasActionable = rows.some((task) => {
5886
- const status = task && task.status;
5887
- return status === 'open' || status === 'claimed' || status === 'review';
5888
- });
5889
- const projHasActionable = Boolean(
5890
- existingProj
5891
- && Array.isArray(existingProj.tasks)
5892
- && existingProj.tasks.some((task) => {
5893
- const status = task && task.status;
5894
- return status === 'open' || status === 'claimed' || status === 'review';
5895
- }),
5896
- );
5885
+ const snapshot = loadLocalTaskState(workspaceRoot);
5897
5886
  let projection;
5898
5887
  let outPath;
5899
- if (!dbHasActionable && projHasActionable) {
5900
- projection = existingProj;
5901
- outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
5902
- } else if (rows.length === 0 && existingProj && Array.isArray(existingProj.tasks) && existingProj.tasks.length > 0) {
5903
- projection = existingProj;
5888
+ if (snapshot.source !== 'database') {
5889
+ projection = { tasks: snapshot.tasks };
5904
5890
  outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
5905
5891
  } else {
5906
5892
  const written = writeDefaultProjection(taskDb, db);
@@ -5928,6 +5914,7 @@ function cmdNextTruth(args) {
5928
5914
  scope: normalizeTaskQueueScope(scope),
5929
5915
  projection_path: outPath,
5930
5916
  task: task ? compactTaskFromProjection(projection, task.id) : null,
5917
+ context: buildNextWorkContext({ root: workspaceRoot, tasks: reconcileTaskRows(snapshot.tasks, projection.tasks || []), selected: task, owner: String(owner), taskSource: snapshot.source, generatedAt: snapshot.generated_at }),
5931
5918
  });
5932
5919
  return;
5933
5920
  }
@@ -12,10 +12,12 @@ const worktreeCore = require('../lib/worktree-core');
12
12
 
13
13
  const {
14
14
  ATRIS_BIN,
15
+ RESCUE_IDLE_MS,
16
+ finishShippedCopy,
17
+ formatKib,
15
18
  SWEEP_IDLE_MS,
16
19
  SWEEP_EVERY_HOURS,
17
20
  SWEEP_KEPT_LABELS,
18
- announceShippedCopyRemoval,
19
21
  assertOneLapProofTree,
20
22
  baseBranchName,
21
23
  branchName,
@@ -50,6 +52,7 @@ const {
50
52
  prMergeRef,
51
53
  protectedPushRefusal,
52
54
  readOneLapProofBinding,
55
+ reapPushedCopy,
53
56
  reapShippedCopy,
54
57
  recordWorktreeRemoval,
55
58
  refreshMovedMapRefs,
@@ -190,6 +193,7 @@ function shipHelp() {
190
193
  console.log(' protected-lane tasks (billing, money, deploy, feedback, ...) can be pushed for review without --merge;');
191
194
  console.log(' landing one with --merge or --local needs --allow-denied-lane on written order');
192
195
  console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
196
+ console.log(' --keep-copy keep this build copy; by default it is removed once the work lands or is pushed');
193
197
  console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
194
198
  console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
195
199
  }
@@ -204,6 +208,7 @@ function shipWorktree(args) {
204
208
  const noPr = hasFlag(args, '--no-pr');
205
209
  const localMode = hasFlag(args, '--local') || hasFlag(args, '--local-merge');
206
210
  const merge = hasFlag(args, '--merge');
211
+ const keepCopy = hasFlag(args, '--keep-copy');
207
212
  const message = readFlag(args, '--message') || readFlag(args, '-m');
208
213
  const verify = readFlag(args, '--verify');
209
214
  let branch = currentBranch(root);
@@ -351,7 +356,7 @@ function shipWorktree(args) {
351
356
  }
352
357
  console.log('pr: skipped (local mode)');
353
358
  if (merge && !dryRun) runShipHealthCheck(primary || root);
354
- if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
359
+ if (landedOn) finishShippedCopy({ root, primary, branch, landedOn, keep: keepCopy });
355
360
  console.log('done: worktree shipped');
356
361
  return 0;
357
362
  }
@@ -385,6 +390,7 @@ function shipWorktree(args) {
385
390
  }
386
391
  if (state.state === 'OPEN' && !state.mergedAt) {
387
392
  console.log('merge: pending; kept the source branch while required checks finish');
393
+ finishShippedCopy({ root, primary, branch, keep: keepCopy });
388
394
  return 0;
389
395
  }
390
396
  if (state.state !== 'MERGED' || !state.mergedAt) {
@@ -414,7 +420,7 @@ function shipWorktree(args) {
414
420
  }, { worktree: root });
415
421
  } catch {}
416
422
  if (merge && !dryRun) runShipHealthCheck(primary || root);
417
- if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
423
+ if (!dryRun) finishShippedCopy({ root, primary, branch, landedOn, keep: keepCopy });
418
424
  console.log('done: worktree shipped');
419
425
  return 0;
420
426
  }
@@ -425,7 +431,10 @@ function reapShipped() {
425
431
  console.error('reap-shipped: ATRIS_REAP_WORKTREE is not set');
426
432
  return 2;
427
433
  }
428
- const result = reapShippedCopy({ root: process.cwd(), target, base: process.env.ATRIS_REAP_BASE || '' });
434
+ const base = process.env.ATRIS_REAP_BASE || '';
435
+ const result = base
436
+ ? reapShippedCopy({ root: process.cwd(), target, base })
437
+ : reapPushedCopy({ primary: process.cwd(), target, branch: process.env.ATRIS_REAP_BRANCH || '' });
429
438
  console.log(`reap-shipped: ${result.removed ? 'removed' : 'kept'} ${target} (${result.reason})`);
430
439
  return result.removed ? 0 : 1;
431
440
  }
@@ -495,8 +504,14 @@ function sweep(args) {
495
504
  console.error('Usage: atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours <n>]');
496
505
  return 2;
497
506
  }
507
+ const rescueDays = Number(readFlag(args, '--rescue-after-days', process.env.ATRIS_WORKTREE_RESCUE_DAYS || String(RESCUE_IDLE_MS / 86400000)));
508
+ if (!Number.isFinite(rescueDays) || rescueDays <= 0) {
509
+ console.error('Usage: atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours <n>] [--rescue] [--rescue-after-days <n>]');
510
+ return 2;
511
+ }
498
512
  const apply = hasFlag(args, '--apply');
499
- const report = sweepWorkspace({ workspace, apply, idleMs: idleHours * 3600000 });
513
+ const rescue = hasFlag(args, '--rescue');
514
+ const report = sweepWorkspace({ workspace, apply, rescue, idleMs: idleHours * 3600000, rescueIdleMs: rescueDays * 86400000 });
500
515
  if (hasFlag(args, '--json')) {
501
516
  console.log(JSON.stringify({ ok: !report.error, ...report }, null, 2));
502
517
  return report.error ? 1 : 0;
@@ -514,9 +529,11 @@ function sweep(args) {
514
529
  console.log(`${repo.name}: skipped (${repo.error || repo.skipped})`);
515
530
  continue;
516
531
  }
517
- const gone = apply ? repo.removed : repo.candidates;
532
+ const saved = apply ? repo.rescued : repo.rescueCandidates;
533
+ const savedSet = new Set(saved);
534
+ const gone = (apply ? repo.removed : repo.candidates).filter((item) => !savedSet.has(item));
518
535
  const cleared = apply ? repo.cachePruned : repo.cacheCandidates.filter((item) => item.reason === 'generated_webpack_cache');
519
- removedTotal += gone.length;
536
+ removedTotal += gone.length + saved.length;
520
537
  clearedTotal += cleared.length;
521
538
  reviewTotal += repo.cacheCandidates.filter((item) => item.reason === 'manual_review_required').length;
522
539
  const counts = {};
@@ -526,7 +543,18 @@ function sweep(args) {
526
543
  }
527
544
  const keptText = Object.entries(counts).map(([label, count]) => `${count} ${label}`).join(', ');
528
545
  console.log(`${repo.name}: ${apply ? 'removed' : 'would remove'} ${plural(gone.length, 'finished copy', 'finished copies')}, ${apply ? 'cleared' : 'would clear'} ${plural(cleared.length, 'webpack cache', 'webpack caches')}; kept ${repo.kept.length}${keptText ? ` (${keptText})` : ''}`);
546
+ if (saved.length) console.log(` ${apply ? 'saved and removed' : 'would save and remove'} ${plural(saved.length, 'abandoned copy', 'abandoned copies')}`);
547
+ for (const item of saved) {
548
+ const what = item.reason === 'pushed_branch' ? 'already on origin' : `${plural(item.changed_files || 0, 'changed file', 'changed files')}, idle ${item.idle_days} days`;
549
+ console.log(` ${apply ? 'saved' : 'save'}: ${item.path} -> origin/${item.rescue_branch} (${what}, ${formatKib(item.size_kib)})`);
550
+ }
529
551
  for (const item of gone) console.log(` ${apply ? 'removed' : 'remove'}: ${item.path}`);
552
+ for (const item of repo.kept) {
553
+ if (item.reason === 'rescue_push_failed') console.log(` kept: ${item.path} (could not push its work to origin/${item.rescue_branch}: ${item.error})`);
554
+ if (item.reason === 'unprovable') console.log(` kept: ${item.path} (${item.why})`);
555
+ if (item.reason === 'stale_dirty' || item.reason === 'stale_unpushed') console.log(` stale: ${item.path} (idle ${item.idle_days} days; atris worktree sweep --rescue --apply saves and removes it)`);
556
+ if (item.reason === 'ignored_data' && item.files) console.log(` kept: ${item.path} (ignored files atris cannot rebuild: ${item.files.join(', ')})`);
557
+ }
530
558
  for (const item of cleared) console.log(` ${apply ? 'cleared' : 'clear'}: ${item.path}`);
531
559
  }
532
560
  if (reviewTotal) console.log(`review: ${plural(reviewTotal, 'other build folder waits', 'other build folders wait')} for a person; \`atris worktree cleanup\` in each repo lists them`);
@@ -603,7 +631,8 @@ function guide() {
603
631
  console.log('Start reaps completed checkouts first. Cleanup preserves branches, source changes, and files git ignores, skips live work, and prunes only verified webpack caches.');
604
632
  console.log('Start clones node_modules from a matching install when package-lock.json matches; skip npm ci when it prints "deps: cloned".');
605
633
  console.log('Below 25 GB free, start reaps finished copies in every repo; below 10 GB it refuses (ATRIS_WORKTREE_REAP_BELOW_GB, ATRIS_WORKTREE_MIN_FREE_GB).');
606
- console.log('A copy shipped with --merge is removed once nothing has run in it for 10 minutes (ATRIS_WORKTREE_KEEP_AFTER_SHIP=1 keeps it).');
634
+ console.log('A shipped copy is removed as soon as its work is merged or on origin and nothing else runs in it; if something does, a merged copy goes once it has been quiet for 10 minutes (--keep-copy or ATRIS_WORKTREE_KEEP_AFTER_SHIP=1 keeps it).');
635
+ console.log('The sweep reports abandoned copies with unsaved work (idle 7 days). With --rescue it saves one to commits on a new atris-rescue/ branch, pushes it, and removes the copy only after origin has it; it removes a copy only when it can prove every byte is saved, and anything unusual keeps the copy for a person. The timer never rescues. Rescues are logged in .atris/state/worktree_removals.jsonl.');
607
636
  console.log('Use `atris worktree status` to see all worktrees and dirty counts.');
608
637
  return 0;
609
638
  }
@@ -620,12 +649,14 @@ function help() {
620
649
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
621
650
  console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
622
651
  console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
652
+ console.log(' --keep-copy keep this build copy after it ships');
623
653
  console.log(' recommended verify: npm run test:fast && node --test <focused files>');
624
654
  console.log(' atris worktree status');
625
655
  console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
626
656
  console.log(' atris worktree prune [--apply]');
627
657
  console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and verified webpack caches');
628
- console.log(' atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours 2] reap merged, idle copies in every repo');
658
+ console.log(' atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours 2] [--rescue] [--rescue-after-days 7]');
659
+ console.log(' removes merged or pushed idle copies in every repo and reports abandoned ones; --rescue saves those to a new branch on origin, then removes them');
629
660
  console.log(' atris worktree sweep --install [--every 3] | --uninstall run the sweep on a timer');
630
661
  }
631
662
 
@@ -35,6 +35,7 @@ function cronSchedule(seconds) {
35
35
  if (minutes === 1) return '* * * * *';
36
36
  if (minutes < 60) return `*/${minutes} * * * *`;
37
37
  const hours = Math.round(minutes / 60);
38
+ if (hours >= 168) return '0 0 * * 0';
38
39
  return hours >= 24 ? '0 0 * * *' : `0 */${hours} * * *`;
39
40
  }
40
41
 
@@ -8,6 +8,7 @@ const { spawnSync } = require('child_process');
8
8
  const { isGenericScratchRoot } = require('./scratch-root');
9
9
  const { resolveDefaultVerifier } = require('./default-verifier');
10
10
  const { nonFalsifyingIssue } = require('./accept-verify-gate');
11
+ const { reconcileTaskRows } = require('./next-work-context');
11
12
 
12
13
  const FIRST_USE_COMMAND = 'atris "help me choose the first useful step for this project"';
13
14
  const FIRST_TALK_ASK = 'what do you want here';
@@ -176,8 +177,10 @@ function titlesFromTodoSection(root, section) {
176
177
  }
177
178
  }
178
179
 
179
- function loadLocalTasks(root = process.cwd()) {
180
+ function loadLocalTaskState(root = process.cwd(), { taskIds = [] } = {}) {
180
181
  let dbRows = [];
182
+ let lookup = () => null;
183
+ let databaseRead = false;
181
184
  try {
182
185
  const taskDb = require('./task-db');
183
186
  // Reading never creates the db, so a first look writes nothing.
@@ -185,20 +188,52 @@ function loadLocalTasks(root = process.cwd()) {
185
188
  const db = taskDb.open();
186
189
  const workspaceRoot = taskDb.workspaceRoot(root);
187
190
  const rows = taskDb.listTasks(db, { workspaceRoot, limit: 200 });
188
- if (Array.isArray(rows) && rows.length) dbRows = taskDb.withTaskDisplayRefs(rows);
191
+ // The visible window must not become a new numbering universe. Task
192
+ // commands resolve these references against the complete workspace.
193
+ const refRows = taskDb.listTasks(db, { workspaceRoot });
194
+ // Busy queues must not crowd the last outcomes out of the handoff.
195
+ const recent = ['done', 'failed'].flatMap((status) => taskDb.listTasks(db, { workspaceRoot, status, limit: 3, orderBy: 'updated_at' }));
196
+ const seen = new Set(rows.map((row) => row.id));
197
+ rows.push(...recent.filter((row) => !seen.has(row.id)));
198
+ if (Array.isArray(rows) && rows.length) dbRows = taskDb.withTaskDisplayRefs(rows, refRows);
199
+ databaseRead = true;
200
+ lookup = (id) => {
201
+ const row = taskDb.getTask(db, id);
202
+ // A foreign id in a damaged projection is not this workspace's work.
203
+ if (row && !taskDb.rowInWorkspace(row, workspaceRoot)) return false;
204
+ return row ? taskDb.withTaskDisplayRefs([row], refRows)[0] : null;
205
+ };
206
+ // A shortlist is an output budget, not authority to invent a bound job.
207
+ for (const id of taskIds.slice(0, 8)) {
208
+ if (dbRows.some((row) => row.id === id)) continue;
209
+ const row = lookup(id);
210
+ if (row) dbRows.push(row);
211
+ }
189
212
  } catch {
190
213
  // Fall through to the local projection. Tests and fresh folders often have no db.
191
214
  }
192
- if (dbRows.some(isActionableTask)) return dbRows;
215
+ if (dbRows.some(isActionableTask)) return { tasks: dbRows, source: 'database' };
193
216
  const projectionPath = path.join(root, '.atris', 'state', 'tasks.projection.json');
194
217
  try {
195
218
  const parsed = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
219
+ if (parsed.workspace_root && path.resolve(parsed.workspace_root) !== path.resolve(require('./task-db').workspaceRoot(root))) {
220
+ return { tasks: dbRows, source: databaseRead ? 'database' : 'none' };
221
+ }
196
222
  const projected = Array.isArray(parsed.tasks) ? parsed.tasks : [];
197
- if (projected.some(isActionableTask) || !dbRows.length) return projected;
223
+ if (projected.some(isActionableTask) || !dbRows.length) {
224
+ const tasks = reconcileTaskRows(dbRows, projected, lookup);
225
+ const known = new Set(dbRows.map((row) => row.id));
226
+ const unknown = tasks.some((row) => !row.id || (!known.has(row.id) && !lookup(row.id)));
227
+ return { tasks, source: unknown || !databaseRead ? (dbRows.length ? 'mixed' : 'projection') : 'database', generated_at: parsed.generated_at || null };
228
+ }
198
229
  } catch {
199
230
  // Keep any done-only db rows below.
200
231
  }
201
- return dbRows;
232
+ return { tasks: dbRows, source: databaseRead ? 'database' : 'none' };
233
+ }
234
+
235
+ function loadLocalTasks(root = process.cwd()) {
236
+ return loadLocalTaskState(root).tasks;
202
237
  }
203
238
 
204
239
  function loadLatestRecap(root = process.cwd()) {
@@ -1170,6 +1205,7 @@ module.exports = {
1170
1205
  spokenBuiltList,
1171
1206
  taskStartedAt,
1172
1207
  loadLocalTasks,
1208
+ loadLocalTaskState,
1173
1209
  firstTalkBuildCommand,
1174
1210
  renderWhatsNextAsk,
1175
1211
  isLeftoverVerbLook,
package/lib/intents.js CHANGED
@@ -8,6 +8,13 @@ const INTENTS = Object.freeze([
8
8
  plain: 'your agent picks the most useful next step and starts it.',
9
9
  offer: 'boot_empty',
10
10
  }),
11
+ Object.freeze({
12
+ id: 'prioritize_work',
13
+ say: Object.freeze(['whats the most important work to work on', 'what should we work on next', 'what is the highest priority work', 'help me choose what to work on']),
14
+ do: 'atris task next --json',
15
+ plain: 'your agent reads the current goal, existing parts and recent evidence before recommending the next work.',
16
+ offer: 'always',
17
+ }),
11
18
  Object.freeze({
12
19
  id: 'keep_going',
13
20
  say: Object.freeze(['keep going while i\'m away', 'run overnight', 'finish this', 'keep working without me']),