atris 3.82.24 → 3.82.25

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/bin/atris.js CHANGED
@@ -723,7 +723,7 @@ function showHelpAll() {
723
723
  console.log(' pull - Pull journals + member data from cloud');
724
724
  console.log(' push - Push workspace files to cloud');
725
725
  console.log(' memory - Your Atris memory for any agent: show, search, remember, notebook; pull/push a laptop copy');
726
- console.log(' sync-checkout - fetch and fast-forward a clean current branch');
726
+ console.log(' sync-checkout - fetch and fast-forward the current branch (--backup saves edits first)');
727
727
  console.log(' zen - make this repo calm and current, keeping everything (--check to only look)');
728
728
  console.log(' cloud - Delete cloud files not present locally (cloud clean --dry-run|--yes)');
729
729
  console.log(' live - Keep a business brain fresh (doctor, pull, watch, push)');
@@ -190,7 +190,7 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false, problem
190
190
  ? { unhealable: [] }
191
191
  : healBrokenMapRefs(cwd, atrisDir, true); // dry-run
192
192
  if (unhealable.length > 0 && !skipped.has('fix-map-refs')) {
193
- const sample = unhealable.slice(0, 3).map(r => `${r.file}:${r.line}`).join(', ');
193
+ const sample = unhealable.slice(0, 3).map(r => (r.line == null ? r.file : `${r.file}:${r.line}`)).join(', ');
194
194
  suggestions.push({
195
195
  task: `Fix ${unhealable.length} broken reference${unhealable.length > 1 ? 's' : ''} in MAP.md`,
196
196
  why: `These file:line references point to code that moved or was deleted: ${sample}. MAP.md is the navigation, it needs to be accurate.`,
package/commands/clean.js CHANGED
@@ -46,8 +46,10 @@ function cleanAtris(options = {}) {
46
46
  results.staleTasks = staleTasks;
47
47
 
48
48
  // 2. Find and HEAL broken MAP.md references
49
- const { healed, unhealable, replacements } = healBrokenMapRefs(cwd, atrisDir, options.dryRun);
49
+ const { healed, unhealable, replacements, checked = 0, skipped = 0 } = healBrokenMapRefs(cwd, atrisDir, options.dryRun);
50
50
  results.healedRefs = healed;
51
+ results.checkedRefs = checked;
52
+ results.skippedRefs = skipped;
51
53
  results.healedRefDetails = replacements;
52
54
  results.unhealableRefs = unhealable;
53
55
 
@@ -108,9 +110,10 @@ function cleanAtris(options = {}) {
108
110
  }
109
111
 
110
112
  // Healed refs
113
+ const countText = refCountText(checked, skipped);
111
114
  if (healed > 0) {
112
- const verb = options.dryRun ? 'Would heal' : 'Healed';
113
- console.log(`✓ ${verb} ${healed} MAP.md ${healed === 1 ? 'reference' : 'references'}`);
115
+ const verb = options.dryRun ? 'would heal' : 'healed';
116
+ console.log(`✓ ${verb} ${healed} MAP.md ${healed === 1 ? 'reference' : 'references'} (${countText})`);
114
117
  (results.healedRefDetails || []).slice(0, 3).forEach(ref => {
115
118
  console.log(` • ${ref.old} -> ${ref.new}${ref.symbol ? ` (${ref.symbol})` : ''}`);
116
119
  });
@@ -121,16 +124,18 @@ function cleanAtris(options = {}) {
121
124
 
122
125
  // Unhealable refs
123
126
  if (unhealable.length > 0) {
124
- console.log(`⚠ ${unhealable.length} MAP.md ${unhealable.length === 1 ? 'ref' : 'refs'} couldn't be healed:`);
127
+ console.log(`⚠ ${unhealable.length} MAP.md ${unhealable.length === 1 ? 'ref' : 'refs'} couldn't be healed (${countText}):`);
125
128
  unhealable.slice(0, 3).forEach(ref => {
126
- console.log(` • ${ref.file}:${ref.line}: ${ref.reason}`);
129
+ console.log(` • ${unhealableLine(ref)}`);
127
130
  });
128
131
  if (unhealable.length > 3) {
129
132
  console.log(` ... and ${unhealable.length - 3} more`);
130
133
  }
131
134
  console.log('');
135
+ } else if (checked === 0) {
136
+ console.log(`○ ${countText}, nothing to validate`);
132
137
  } else if (healed === 0) {
133
- console.log('✓ All MAP.md refs valid');
138
+ console.log(`✓ all MAP.md refs valid (${countText})`);
134
139
  }
135
140
 
136
141
  // Repo-local map scripts
@@ -274,6 +279,8 @@ function cleanResultPayload(results, options = {}, cwd = process.cwd()) {
274
279
  items: results.staleTasks,
275
280
  },
276
281
  map_refs: {
282
+ checked_count: results.checkedRefs || 0,
283
+ skipped_count: results.skippedRefs || 0,
277
284
  healed_count: results.healedRefs,
278
285
  verb: options.dryRun ? 'would_heal' : 'healed',
279
286
  items: results.healedRefDetails || [],
@@ -375,14 +382,28 @@ function mapNoteFiles(atrisDir) {
375
382
  return files.filter((file) => { try { return fs.statSync(file).isFile(); } catch { return false; } });
376
383
  }
377
384
 
385
+ function refCountText(checked, skipped) {
386
+ const noun = checked === 1 ? 'ref' : 'refs';
387
+ const other = skipped > 0 ? `, skipped ${skipped} ${skipped === 1 ? 'path' : 'paths'} (other projects or git-ignored)` : '';
388
+ return `checked ${checked} ${noun}${other}`;
389
+ }
390
+
391
+ function unhealableLine(ref) {
392
+ // A plain path has no line of its own. Say where in the map it was written.
393
+ if (ref.plain) return `${ref.file}: ${ref.reason} (${ref.map}:${ref.mapLine})`;
394
+ return `${ref.file}:${ref.line}: ${ref.reason}`;
395
+ }
396
+
378
397
  function healBrokenMapRefs(cwd, atrisDir, dryRun = false, homeDir = os.homedir()) {
379
398
  const mapFile = path.join(atrisDir, 'MAP.md');
380
- if (!fs.existsSync(mapFile)) return { healed: 0, unhealable: [] };
399
+ if (!fs.existsSync(mapFile)) return { healed: 0, unhealable: [], checked: 0, skipped: 0 };
381
400
  const result = healMapFileRefs(cwd, mapFile, dryRun, homeDir);
382
401
  for (const notesFile of mapNoteFiles(atrisDir)) {
383
402
  const notes = healMapFileRefs(cwd, notesFile, dryRun, homeDir);
384
403
  const map = path.relative(cwd, notesFile).split(path.sep).join('/');
385
404
  result.healed += notes.healed;
405
+ result.checked += notes.checked;
406
+ result.skipped += notes.skipped;
386
407
  result.unhealable = [...result.unhealable, ...notes.unhealable.map((entry) => ({ ...entry, map }))];
387
408
  result.replacements = [...(result.replacements || []), ...notes.replacements.map((entry) => ({ ...entry, map }))];
388
409
  }
@@ -394,6 +415,7 @@ function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {
394
415
  let mapContent = fs.readFileSync(mapFile, 'utf8');
395
416
  const unhealable = [];
396
417
  let healed = 0;
418
+ let checked = 0;
397
419
 
398
420
  // Match both `file.js:123` and `file.js:123-456` with surrounding context
399
421
  // [^\S\n] = horizontal whitespace only (no newlines)
@@ -437,6 +459,7 @@ function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {
437
459
  const backtickAfter = match[6] || '';
438
460
  const contextPart = match[8] || '';
439
461
 
462
+ checked++;
440
463
  const file = readFileCached(filePath);
441
464
  if (!file) {
442
465
  unhealable.push({ file: filePath, line: startLine, reason: 'File not found' });
@@ -497,6 +520,11 @@ function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {
497
520
  }
498
521
  }
499
522
 
523
+ // Plain backticked paths with no line number. Report only, never rewrite.
524
+ const plain = checkPlainMapPaths(cwd, mapFile, mapContent);
525
+ checked += plain.checked;
526
+ unhealable.push(...plain.unhealable);
527
+
500
528
  // Apply replacements
501
529
  if (!dryRun && replacements.length > 0) {
502
530
  for (const r of replacements) {
@@ -505,7 +533,111 @@ function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {
505
533
  fs.writeFileSync(mapFile, mapContent);
506
534
  }
507
535
 
508
- return { healed, unhealable, replacements };
536
+ return { healed, unhealable, replacements, checked, skipped: plain.skipped };
537
+ }
538
+
539
+ // `folder/file.ext` with a slash and no `:line` suffix. The closing backtick must
540
+ // follow the extension. That keeps `file.js:12` and `file.jsonl` from matching here.
541
+ const PLAIN_PATH_PATTERN = /`((?:\.\.?\/)*[A-Za-z0-9_@.\-]+(?:\/[A-Za-z0-9_@.\-]+)+\.(?:js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml))`/g;
542
+
543
+ // A plain path can be written from the project root, the map's own folder, or any
544
+ // folder between them. Each of those is a base the path is resolved against.
545
+ function plainPathBases(root, mapFile) {
546
+ const bases = [root];
547
+ let dir = path.dirname(path.resolve(mapFile));
548
+ while (dir.startsWith(root + path.sep)) {
549
+ bases.push(dir);
550
+ dir = path.dirname(dir);
551
+ }
552
+ return bases;
553
+ }
554
+
555
+ function insideRoot(root, target) {
556
+ return target === root || target.startsWith(root + path.sep);
557
+ }
558
+
559
+ // Fenced code blocks hold examples, not navigation. Blank their lines, keeping
560
+ // every line number. The plain-path pass then never reads them.
561
+ function maskFencedBlocks(content) {
562
+ let fence = null;
563
+ return content.split('\n').map((line) => {
564
+ const opener = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
565
+ if (fence) {
566
+ if (opener && opener[1][0] === fence.char && opener[1].length >= fence.length && opener[2].trim() === '') fence = null;
567
+ return '';
568
+ }
569
+ if (opener && !(opener[1][0] === '`' && opener[2].includes('`'))) {
570
+ fence = { char: opener[1][0], length: opener[1].length };
571
+ return '';
572
+ }
573
+ return line;
574
+ }).join('\n');
575
+ }
576
+
577
+ function isDirectory(target) {
578
+ try { return fs.statSync(target).isDirectory(); } catch { return false; }
579
+ }
580
+
581
+ // Runtime files (state folders, logs) are written by the tools and are not in a
582
+ // fresh checkout. Paths the repo's own ignore rules cover are not dead refs.
583
+ function gitIgnoredPaths(cwd, paths) {
584
+ if (paths.length === 0) return new Set();
585
+ const out = spawnSync('git', ['check-ignore', '--no-index', '--stdin'], {
586
+ cwd, input: paths.join('\n'), encoding: 'utf8',
587
+ });
588
+ if (out.status !== 0 && out.status !== 1) return new Set();
589
+ return new Set(String(out.stdout).split('\n').filter(Boolean));
590
+ }
591
+
592
+ // The first folder of a path, checked in the place it was resolved against. A path
593
+ // that climbs out of its base is judged by its first folder under the project root.
594
+ function firstFolderExists(root, base, target) {
595
+ let anchor = base;
596
+ let from = path.relative(base, target);
597
+ if (from === '..' || from.startsWith('..' + path.sep)) {
598
+ anchor = root;
599
+ from = path.relative(root, target);
600
+ }
601
+ const parts = from.split(path.sep);
602
+ return parts.length > 1 && isDirectory(path.join(anchor, parts[0]));
603
+ }
604
+
605
+ function checkPlainMapPaths(cwd, mapFile, content) {
606
+ const root = path.resolve(cwd);
607
+ const map = path.relative(root, path.resolve(mapFile)).split(path.sep).join('/');
608
+ const bases = plainPathBases(root, mapFile);
609
+ const scanned = maskFencedBlocks(content);
610
+ const seen = new Set();
611
+ const missing = [];
612
+ let checked = 0;
613
+ let skipped = 0;
614
+ const pattern = new RegExp(PLAIN_PATH_PATTERN.source, 'g');
615
+ let match;
616
+ while ((match = pattern.exec(scanned)) !== null) {
617
+ const rel = match[1];
618
+ if (seen.has(rel)) continue;
619
+ seen.add(rel);
620
+ // Anything that resolves outside the project belongs to another project.
621
+ const candidates = bases
622
+ .map((base) => ({ base, target: path.resolve(base, rel) }))
623
+ .filter((candidate) => insideRoot(root, candidate.target));
624
+ if (candidates.some((candidate) => fs.existsSync(candidate.target))) { checked++; continue; }
625
+ // A path whose first folder is in none of its bases belongs to another project.
626
+ const here = candidates
627
+ .filter((candidate) => firstFolderExists(root, candidate.base, candidate.target))
628
+ .map((candidate) => path.relative(root, candidate.target).split(path.sep).join('/'));
629
+ if (here.length === 0) { skipped++; continue; }
630
+ const mapLine = scanned.slice(0, match.index).split('\n').length;
631
+ missing.push({ rel, mapLine, here });
632
+ }
633
+ const ignored = gitIgnoredPaths(root, missing.flatMap((entry) => entry.here));
634
+ const unhealable = [];
635
+ for (const { rel, mapLine, here } of missing) {
636
+ if (here.every((candidate) => ignored.has(candidate))) { skipped++; continue; }
637
+ checked++;
638
+ unhealable.push({ file: rel, line: null, mapLine, map, reason: 'missing file', plain: true });
639
+ }
640
+ return { checked, skipped, unhealable };
509
641
  }
510
642
 
511
643
  /**
@@ -851,6 +983,7 @@ module.exports = {
851
983
  cleanResultPayload,
852
984
  findStaleTasks,
853
985
  healBrokenMapRefs,
986
+ unhealableLine,
854
987
  findStalePages,
855
988
  checkPageStaleness
856
989
  };
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
3
7
  const {
4
8
  runGit,
5
9
  trackedTreeIsClean,
@@ -10,19 +14,80 @@ function refuse(message) {
10
14
  return 1;
11
15
  }
12
16
 
17
+ function pad(value) {
18
+ return String(value).padStart(2, '0');
19
+ }
20
+
21
+ // 2026-11-12-134655, local time, so a backup sorts and reads like a date.
22
+ function backupStamp(date = new Date()) {
23
+ return [
24
+ date.getFullYear(),
25
+ pad(date.getMonth() + 1),
26
+ pad(date.getDate()),
27
+ ].join('-') + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
28
+ }
29
+
30
+ // Saves the checkout's tracked edits as a patch under ~/.atris/backups/<repo>/.
31
+ // Untracked files are only listed (in a sibling .untracked.txt), never copied.
32
+ // Returns { patchPath, untrackedPath } or null when nothing could be saved.
33
+ function writeBackup(cwd) {
34
+ const topResult = runGit(['rev-parse', '--show-toplevel'], cwd);
35
+ const top = topResult.status === 0 ? topResult.stdout.trim() : cwd;
36
+ const repoName = path.basename(top) || 'checkout';
37
+
38
+ const dir = path.join(os.homedir(), '.atris', 'backups', repoName);
39
+ fs.mkdirSync(dir, { recursive: true });
40
+
41
+ const stamp = backupStamp();
42
+ let patchPath = null;
43
+ for (let attempt = 0; attempt < 100 && !patchPath; attempt += 1) {
44
+ const candidate = path.join(dir, `${stamp}${attempt === 0 ? '' : `-${attempt}`}.patch`);
45
+ if (!fs.existsSync(candidate)) patchPath = candidate;
46
+ }
47
+ if (!patchPath) return null;
48
+
49
+ // Diff against HEAD so staged and unstaged edits land in one patch. Git
50
+ // writes the file itself, so the bytes are exact even for odd encodings.
51
+ const diffResult = runGit(
52
+ ['diff', 'HEAD', '--binary', '--no-color', '--no-ext-diff', `--output=${patchPath}`],
53
+ cwd
54
+ );
55
+ const written = diffResult.status === 0 && fs.existsSync(patchPath) && fs.statSync(patchPath).size > 0;
56
+ if (!written) {
57
+ fs.rmSync(patchPath, { force: true });
58
+ return null;
59
+ }
60
+
61
+ const untrackedResult = runGit(['ls-files', '--others', '--exclude-standard'], top);
62
+ const untracked = untrackedResult.status === 0
63
+ ? untrackedResult.stdout.split('\n').filter(Boolean)
64
+ : [];
65
+ let untrackedPath = null;
66
+ if (untracked.length > 0) {
67
+ untrackedPath = patchPath.replace(/\.patch$/, '.untracked.txt');
68
+ fs.writeFileSync(untrackedPath, `${untracked.join('\n')}\n`);
69
+ }
70
+
71
+ return { patchPath, untrackedPath };
72
+ }
73
+
13
74
  function syncCheckoutCommand(args = [], cwd = process.cwd()) {
14
75
  if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
15
- console.log('usage: atris sync-checkout.');
76
+ console.log('usage: atris sync-checkout [--backup].');
16
77
  return 0;
17
78
  }
79
+ const backup = args.includes('--backup');
18
80
 
19
81
  const branchResult = runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd);
20
82
  if (branchResult.status !== 0) {
21
83
  return refuse('this checkout is not on a git branch; sync-checkout refused to touch it.');
22
84
  }
23
85
 
24
- if (!trackedTreeIsClean(cwd)) {
25
- return refuse('this checkout has tracked changes; sync-checkout refused to touch it.');
86
+ const dirty = !trackedTreeIsClean(cwd);
87
+ if (dirty && !backup) {
88
+ console.log('this checkout has tracked changes; sync-checkout refused to touch it.');
89
+ console.log('run: atris sync-checkout --backup (saves your edits as a patch, then syncs)');
90
+ return 1;
26
91
  }
27
92
 
28
93
  const upstreamResult = runGit(
@@ -39,6 +104,39 @@ function syncCheckoutCommand(args = [], cwd = process.cwd()) {
39
104
  return refuse('this checkout could not sync because git fetch failed.');
40
105
  }
41
106
 
107
+ if (dirty) {
108
+ // Check the sync can succeed before touching anything, so a sync that
109
+ // was never going to work does not cost anyone their edits.
110
+ const canFastForward = runGit(['merge-base', '--is-ancestor', 'HEAD', '@{upstream}'], cwd);
111
+ if (canFastForward.status !== 0) {
112
+ return refuse('this checkout could not fast-forward; sync-checkout refused to change it.');
113
+ }
114
+ const behindResult = runGit(['rev-list', '--count', 'HEAD..@{upstream}'], cwd);
115
+ if (behindResult.status === 0 && behindResult.stdout.trim() === '0') {
116
+ console.log(`this checkout is up to date with ${upstream}; your edits were left in place.`);
117
+ return 0;
118
+ }
119
+
120
+ let saved;
121
+ try {
122
+ saved = writeBackup(cwd);
123
+ } catch (error) {
124
+ saved = null;
125
+ }
126
+ if (!saved) {
127
+ return refuse('could not save your edits as a patch; sync-checkout refused to touch it.');
128
+ }
129
+ const resetResult = runGit(['reset', '--hard', '--quiet', 'HEAD'], cwd);
130
+ if (resetResult.status !== 0) {
131
+ return refuse(`could not set your edits aside; they are saved at ${saved.patchPath}`);
132
+ }
133
+ console.log(`saved your edits to ${saved.patchPath}`);
134
+ if (saved.untrackedPath) {
135
+ console.log(`untracked files were left in place; their names are in ${saved.untrackedPath}`);
136
+ }
137
+ console.log(`to restore them: git apply ${saved.patchPath}`);
138
+ }
139
+
42
140
  const mergeResult = runGit(['merge', '--ff-only', '@{upstream}'], cwd);
43
141
  if (mergeResult.status !== 0) {
44
142
  return refuse('this checkout could not fast-forward; sync-checkout refused to change it.');
package/commands/task.js CHANGED
@@ -9000,6 +9000,38 @@ function appendRelabelArchivedJournalReceipt(workspaceRoot, { actor, count, ids
9000
9000
  return logPath;
9001
9001
  }
9002
9002
 
9003
+ // --work-root <path>, or ATRIS_WORK_ROOT only when the flag is absent. Nothing named gives
9004
+ // undefined. The job then records the main folder's commit as before. A flag that is present
9005
+ // but empty is an error, never a quiet fall back to the env var. A relative path resolves
9006
+ // from the current folder. The folder must be a real checkout with a commit, because that
9007
+ // commit is what the job records.
9008
+ function readyWorkRoot(args) {
9009
+ const at = args.indexOf('--work-root');
9010
+ let named;
9011
+ if (at !== -1) {
9012
+ named = String(args[at + 1] === undefined ? '' : args[at + 1]).trim();
9013
+ if (!named) {
9014
+ console.error('work root is empty');
9015
+ process.exit(1);
9016
+ }
9017
+ } else {
9018
+ named = String(process.env.ATRIS_WORK_ROOT || '').trim();
9019
+ }
9020
+ if (!named) return undefined;
9021
+ const resolved = path.resolve(named);
9022
+ let isDir = false;
9023
+ try { isDir = fs.statSync(resolved).isDirectory(); } catch (_) { isDir = false; }
9024
+ if (!isDir) {
9025
+ console.error(`work root not found: ${resolved}`);
9026
+ process.exit(1);
9027
+ }
9028
+ if (!getTaskDb().workspaceHead(resolved)) {
9029
+ console.error(`work root has no git commit: ${resolved}`);
9030
+ process.exit(1);
9031
+ }
9032
+ return resolved;
9033
+ }
9034
+
9003
9035
  function cmdReady(args) {
9004
9036
  const pos = positional(args);
9005
9037
  const id = pos[0];
@@ -9007,9 +9039,12 @@ function cmdReady(args) {
9007
9039
  const verifyFlag = flag(args, '--verify');
9008
9040
  const resultFlag = textFlag(args, ['--result']);
9009
9041
  if (!id || (!proofFlag && !verifyFlag && !resultFlag)) {
9010
- console.log('Usage: atris task ready <id> --proof "..." --result "<sentence>"');
9042
+ console.log('Usage: atris task ready <id> --proof "..." --result "<sentence>" [--work-root <path>] (the copy you built in; ATRIS_WORK_ROOT is the fallback; a relative path resolves from the current folder)');
9011
9043
  process.exit(2);
9012
9044
  }
9045
+ // The copy the builder actually built in. Its commit is the one the job records.
9046
+ // Without it the job records the main folder's commit, as before.
9047
+ const workRoot = readyWorkRoot(args);
9013
9048
  // Two ways to prove: --proof "<note>" (claimed, pattern-checked) or
9014
9049
  // --verify "<command>" which actually RUNS the command and gates ready on exit 0.
9015
9050
  // If --proof names npm test, node --test, or git diff --check, this process
@@ -9041,12 +9076,54 @@ function cmdReady(args) {
9041
9076
  proof = proof.trim() ? `${proof.trim()} ${attested}` : attested;
9042
9077
  }
9043
9078
  const verifyAutoCertifyAllowed = !usedVerify || isAutoCertifyVerifyCommandAllowed(verifyFlag);
9079
+ const landing = landingFlags(args);
9080
+ // What this process can refuse from the flags and the task alone is refused before the verifier
9081
+ // runs. Wording or a name the gate would turn away never costs a test run. The verifier's own
9082
+ // wording, the actor, the task lookup and the landing sentence only need the flags and the task
9083
+ // row. Without --verify nothing slow comes first. The actor, task and landing checks then keep
9084
+ // their place after the proof checks below.
9085
+ let readyTarget = null;
9086
+ const resolveReadyTarget = () => {
9087
+ if (readyTarget) return readyTarget;
9088
+ guardExplicitActor('atris task ready', flag(args, '--as'));
9089
+ const taskDb = getTaskDb();
9090
+ const db = taskDb.open();
9091
+ const taskId = requireTaskId(taskDb, db, id, 'atris task ready');
9092
+ const beforeTask = taskDetail(taskDb, db, taskId);
9093
+ requireExplicitLandingDayOnePm('atris task ready', landing, beforeTask && beforeTask.title);
9094
+ readyTarget = { taskDb, db, taskId, beforeTask };
9095
+ return readyTarget;
9096
+ };
9097
+ // The start of the proof written once the verifier passes. It is built from the flags alone. A
9098
+ // proof that names a command the verifier did not run is refused before the verifier runs. The
9099
+ // command's output and the receipt path follow it. Then the same check runs again on the whole
9100
+ // proof.
9101
+ const verifiedLead = usedVerify
9102
+ ? `[verified] \`${verifyFlag}\` passed (exit 0)${proof.trim() ? `, ${proof.trim()}` : ''}`
9103
+ : '';
9104
+ if (usedVerify) {
9105
+ if (!hasFlag(args, '--no-falsify-check')) {
9106
+ const { classifyVerifier } = require('../lib/verifier-quality');
9107
+ const quality = classifyVerifier(verifyFlag);
9108
+ if (!quality.ok) {
9109
+ console.error(`atris task ready: weak verifier, ${quality.reason}`);
9110
+ console.error(` command: ${verifyFlag}`);
9111
+ console.error(' allowlisted examples: node --test, npm test, git diff --check, node --check, rg <symbol>');
9112
+ console.error(' or pass --no-falsify-check to accept anyway.');
9113
+ process.exit(1);
9114
+ }
9115
+ }
9116
+ requireRanNamedProofCommand('atris task ready', verifiedLead, usedVerify);
9117
+ resolveReadyTarget();
9118
+ }
9044
9119
  if (usedVerify) {
9045
9120
  // Run the verifier once and write a receipt (pass or fail) so the review
9046
9121
  // gate in lib/receipt-evidence.js can validate the exact path named in
9047
9122
  // the proof, not just trust the prose.
9048
9123
  const { writeTaskReceipt } = require('../lib/task-receipt');
9049
- const receipt = writeTaskReceipt({ taskId: id, command: verifyFlag, root: process.cwd() });
9124
+ // The check runs in the copy the work was built in. Its receipt still lands under the
9125
+ // folder ready ran from, because that is where a reviewer looks for the path in the proof.
9126
+ const receipt = writeTaskReceipt({ taskId: id, command: verifyFlag, root: workRoot || process.cwd(), receiptRoot: process.cwd() });
9050
9127
  if (!receipt.passed) {
9051
9128
  const detail = receipt.exit != null ? ` (exit ${receipt.exit})` : (receipt.signal ? ` (signal ${receipt.signal})` : '');
9052
9129
  console.error(`atris task ready: verifier failed${detail}: ${verifyFlag}`);
@@ -9060,15 +9137,6 @@ function cmdReady(args) {
9060
9137
  // this codebase fails there, and one that passes anywhere passes there too.
9061
9138
  if (!hasFlag(args, '--no-falsify-check')) {
9062
9139
  const { probeVerifierCanFail } = require('../lib/falsifier-probe');
9063
- const { classifyVerifier } = require('../lib/verifier-quality');
9064
- const quality = classifyVerifier(verifyFlag);
9065
- if (!quality.ok) {
9066
- console.error(`atris task ready: weak verifier, ${quality.reason}`);
9067
- console.error(` command: ${verifyFlag}`);
9068
- console.error(' allowlisted examples: node --test, npm test, git diff --check, node --check, rg <symbol>');
9069
- console.error(' or pass --no-falsify-check to accept anyway.');
9070
- process.exit(1);
9071
- }
9072
9140
  const probe = probeVerifierCanFail({ command: verifyFlag });
9073
9141
  if (probe.probed && probe.canFail === false) {
9074
9142
  console.error(`atris task ready: this check cannot fail, ${probe.reason}`);
@@ -9078,8 +9146,7 @@ function cmdReady(args) {
9078
9146
  }
9079
9147
  if (!probe.probed && !wantsJson(args)) console.log(` ${probe.reason}`);
9080
9148
  }
9081
- const base = proof.trim();
9082
- proof = `[verified] \`${verifyFlag}\` passed (exit 0)${base ? `, ${base}` : ''}${receipt.output ? `\n${receipt.output}` : ''}\nReceipt: ${receipt.receiptPath}`;
9149
+ proof = `${verifiedLead}${receipt.output ? `\n${receipt.output}` : ''}\nReceipt: ${receipt.receiptPath}`;
9083
9150
  if (!wantsJson(args)) console.log(`✓ verified: \`${verifyFlag}\` exited 0 (receipt ${receipt.receiptPath})`);
9084
9151
  }
9085
9152
  if (!proof) {
@@ -9090,9 +9157,7 @@ function cmdReady(args) {
9090
9157
  requireRanNamedProofCommand('atris task ready', proof, usedVerify);
9091
9158
  const lesson = flag(args, '--lesson') || '';
9092
9159
  const nextTaskInput = normalizeReviewNextTaskInput(typeof flag(args, '--next') === 'string' ? flag(args, '--next') : '');
9093
- const landing = landingFlags(args);
9094
9160
  const merge = readyMergeFlags(args);
9095
- guardExplicitActor('atris task ready', flag(args, '--as'));
9096
9161
  const actor = String(flag(args, '--as') || DEFAULT_OWNER);
9097
9162
  // The others who built the work with --as, by name, comma apart: a flight that ran for a member
9098
9163
  // hands in under the member's name and names the engines that ran here. Only a hand-in uses it.
@@ -9109,18 +9174,19 @@ function cmdReady(args) {
9109
9174
  files: textFlag(args, ['--files']),
9110
9175
  commands: textFlag(args, ['--commands', '--command']),
9111
9176
  };
9112
- const taskDb = getTaskDb();
9113
- const db = taskDb.open();
9114
- const taskId = requireTaskId(taskDb, db, id, 'atris task ready');
9115
- const beforeTask = taskDetail(taskDb, db, taskId);
9116
- requireExplicitLandingDayOnePm('atris task ready', landing, beforeTask && beforeTask.title);
9177
+ const { taskDb, db, taskId } = resolveReadyTarget();
9178
+ // The verifier may have run for a while. The task is read again after it. Without one, nothing
9179
+ // elapsed and the read from the checks above stands. The landing sentence is checked against the
9180
+ // refreshed title too, since a verifier can rename the task.
9181
+ const beforeTask = usedVerify ? taskDetail(taskDb, db, taskId) : readyTarget.beforeTask;
9182
+ if (usedVerify) requireExplicitLandingDayOnePm('atris task ready', landing, beforeTask && beforeTask.title);
9117
9183
  const missionXpIssue = missionXpEndToEndProofIssue(beforeTask, proof, taskDb.workspaceRoot());
9118
9184
  if (missionXpIssue) {
9119
9185
  failTask('atris task ready', MISSION_XP_END_TO_END_REASON, missionXpIssue);
9120
9186
  }
9121
9187
  const readyPolicyTask = {
9122
9188
  ...beforeTask,
9123
- workspace_root: process.cwd(),
9189
+ workspace_root: workRoot || process.cwd(),
9124
9190
  metadata: {
9125
9191
  ...(beforeTask && beforeTask.metadata || {}),
9126
9192
  ...(resultFields.files ? { changed_files: resultFields.files } : {}),
@@ -9145,6 +9211,7 @@ function cmdReady(args) {
9145
9211
  merge,
9146
9212
  result: resultSentence,
9147
9213
  builtWith,
9214
+ workRoot,
9148
9215
  });
9149
9216
  if (!result.ready) {
9150
9217
  console.error(`ready failed: ${result.reason}`);
@@ -12235,7 +12302,7 @@ async function run(args) {
12235
12302
  // `task ready --help` is a help request, not a run. Ready mutates
12236
12303
  // review state, so never list or ready just to show usage.
12237
12304
  if (sub === 'ready' && argsWantHelp(raw.slice(1))) {
12238
- console.log('Usage: atris task ready <id> --proof "..." --result "<sentence>"');
12305
+ console.log('Usage: atris task ready <id> --proof "..." --result "<sentence>" [--work-root <path>] (the copy you built in; ATRIS_WORK_ROOT is the fallback; a relative path resolves from the current folder)');
12239
12306
  return;
12240
12307
  }
12241
12308
  // `task step --help` is a help request, not a run. Step mutates
@@ -60,6 +60,7 @@ const {
60
60
  repoRoot,
61
61
  restoreRegeneratedAdapterChurn,
62
62
  runCommand,
63
+ runShipNets,
63
64
  runGit,
64
65
  runShipHealthCheck,
65
66
  scheduleShippedCopyRemoval,
@@ -186,13 +187,15 @@ function startWorktree(args) {
186
187
  }
187
188
 
188
189
  function shipHelp() {
189
- console.log('Usage: atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
190
+ console.log('Usage: atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local] [--skip-nets]');
190
191
  console.log('');
191
192
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
192
193
  console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
193
194
  console.log(' protected-lane tasks (billing, money, deploy, feedback, ...) can be pushed for review without --merge;');
194
195
  console.log(' landing one with --merge or --local needs --allow-denied-lane on written order');
195
196
  console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
197
+ console.log(' --skip-nets skip the repo hygiene tests that run before --verify (or set ATRIS_SHIP_SKIP_NETS=1)');
198
+ console.log(' nets run first: repo-hygiene, no-hardcoded-home-paths and map-refs-accurate, for repos that have them; a failure stops the ship before the push');
196
199
  console.log(' --keep-copy keep this build copy; by default it is removed once the work lands or is pushed');
197
200
  console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
198
201
  console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
@@ -293,6 +296,9 @@ function shipWorktree(args) {
293
296
  refreshMovedMapRefs(root, args);
294
297
  }
295
298
 
299
+ const nets = runShipNets(root, { verify, skip: hasFlag(args, '--skip-nets'), dryRun });
300
+ if (!nets.ok) return 1;
301
+
296
302
  if (verify) {
297
303
  const beforeVerify = dryRun ? [] : statusSnapshot(root);
298
304
  console.log(`verify: ${verify}`);
@@ -619,6 +625,7 @@ function guide() {
619
625
  console.log('4. Ship only from the isolated worktree:');
620
626
  console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" --merge [--target <ref>]');
621
627
  console.log(' --target <ref> overrides the default landing target (default: branch atris-base, else origin default branch)');
628
+ console.log(' ship runs the repo hygiene nets first (repo-hygiene, no-hardcoded-home-paths, map-refs-accurate); --skip-nets or ATRIS_SHIP_SKIP_NETS=1 skips them');
622
629
  console.log(' recommended verify: npm run test:fast && node --test <focused files>');
623
630
  console.log('');
624
631
  console.log('5. Cleanup is part of creation, shipping, and the sweep; run it directly any time:');
@@ -645,10 +652,11 @@ function help() {
645
652
  console.log(' --force start even when another recent flight shares a task word');
646
653
  console.log(' --former-task the task\'s older name; a branch left unfinished under it is named, never resumed');
647
654
  console.log(' --no-deps do not clone node_modules from a matching install');
648
- console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
655
+ console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local] [--skip-nets]');
649
656
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
650
657
  console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
651
658
  console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
659
+ console.log(' --skip-nets skip the repo hygiene tests that run before --verify; they run first by default');
652
660
  console.log(' --keep-copy keep this build copy after it ships');
653
661
  console.log(' recommended verify: npm run test:fast && node --test <focused files>');
654
662
  console.log(' atris worktree status');
package/lib/task-core.js CHANGED
@@ -125,8 +125,9 @@ atris task - durable local task state (SQLite, gitignored)
125
125
  atris task continue-work <id> Create/reuse a certified Review follow-up task
126
126
  atris task say <id> "<message>" Add context to a task
127
127
  atris task chat <id> "<message>" [--goal "..."] Refine a task chat + working goal
128
- atris task ready <id> --proof "..." --result "<sentence>"
128
+ atris task ready <id> --proof "..." --result "<sentence>" [--work-root <path>]
129
129
  Agent proof ready; native goal can complete
130
+ --work-root names the copy you built in and the job records that copy's commit; ATRIS_WORK_ROOT is the fallback and a relative path resolves from the current folder
130
131
  atris task ready <id> --verify "<cmd>" --result "<sentence>"
131
132
  Run <cmd>; only ready if it exits 0 (executed proof)
132
133
  Writes atris/runs/ receipt (pass or fail); that local pass is enough proof
package/lib/task-db.js CHANGED
@@ -3247,6 +3247,7 @@ function withBusyRetry(fn, attempts = 8) {
3247
3247
 
3248
3248
  module.exports = {
3249
3249
  open,
3250
+ workspaceHead,
3250
3251
  withBusyRetry,
3251
3252
  isBusyError,
3252
3253
  close,
@@ -54,7 +54,10 @@ function verifyTimeoutMs() {
54
54
  // Runs `command` for `taskId`, writes atris/runs/<date>-task-<id>-<time>.json,
55
55
  // and returns { ok, passed, receiptPath, exit, signal, output }. `ok` mirrors
56
56
  // `passed`, kept as a separate field so callers reading either name work.
57
- function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}) {
57
+ // The command runs in `root`. The receipt file goes under `receiptRoot`, which
58
+ // is `root` unless the caller built in another copy and wants the receipt left
59
+ // where a reviewer looks for it.
60
+ function writeTaskReceipt({ taskId, command, root = process.cwd(), receiptRoot = root, runner } = {}) {
58
61
  const cmd = String(command || '').trim();
59
62
  if (!taskId) return { ok: false, passed: false, reason: 'task_id_required' };
60
63
  if (!cmd) return { ok: false, passed: false, reason: 'verify_command_required' };
@@ -83,7 +86,7 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
83
86
  : '';
84
87
  const output = tailText(`${result.stdout || ''}${result.stderr || ''}${diagnostic}`);
85
88
  const passed = !result.error && result.status === 0;
86
- const dir = runsDir(root);
89
+ const dir = runsDir(receiptRoot);
87
90
  fs.mkdirSync(dir, { recursive: true });
88
91
  const now = new Date();
89
92
  // Local calendar day for the file name; toISOString() is the UTC day,
@@ -91,13 +94,15 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
91
94
  const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
92
95
  const safeTime = now.toISOString().replace(/[:.]/g, '-');
93
96
  const receiptFile = path.join(dir, `${date}-task-${slugifyTaskId(taskId)}-${safeTime}.json`);
94
- const receiptPath = path.relative(root, receiptFile);
97
+ const receiptPath = path.relative(receiptRoot, receiptFile);
95
98
  const receipt = {
96
99
  schema: 'atris.task_receipt.v1',
97
100
  task_id: String(taskId),
98
101
  command: cmd,
99
102
  at: now.toISOString(),
100
103
  commit: gitCommitHash(root),
104
+ work_root: path.resolve(root),
105
+ receipt_root: path.resolve(receiptRoot),
101
106
  result: {
102
107
  passed,
103
108
  exit: result.status,
@@ -906,6 +906,53 @@ function refreshMovedMapRefs(root, args = []) {
906
906
  }
907
907
  }
908
908
 
909
+ // The repo checks that failed CI for seven builders in three days. Ship runs
910
+ // them itself so the failure shows up before the push, not after a CI round trip.
911
+ const SHIP_NET_FILES = [
912
+ 'test/repo-hygiene.test.js',
913
+ 'test/no-hardcoded-home-paths.test.js',
914
+ 'test/map-refs-accurate.test.js',
915
+ ];
916
+
917
+ function shipNets(root) {
918
+ return SHIP_NET_FILES.filter((file) => fs.existsSync(path.join(root, file)));
919
+ }
920
+
921
+ // Runs the nets once, before the caller's --verify. A net the verify command
922
+ // already names by its exact path is dropped so it does not run twice. Returns { ok, ran, files }.
923
+ function runShipNets(root, { verify = '', skip = false, dryRun = false, env = process.env } = {}) {
924
+ if (skip || env.ATRIS_SHIP_SKIP_NETS === '1') {
925
+ console.log(`nets: skipped (${skip ? '--skip-nets' : 'ATRIS_SHIP_SKIP_NETS=1'})`);
926
+ return { ok: true, ran: false, files: [] };
927
+ }
928
+ const present = shipNets(root);
929
+ if (!present.length) {
930
+ console.log('nets: none in this repo');
931
+ return { ok: true, ran: false, files: [] };
932
+ }
933
+ // Whole whitespace-separated tokens only: the exact path, or the same path with ./ in front.
934
+ const named = new Set(String(verify || '').split(/\s+/).map((token) => token.replace(/^(['"])(.*)\1$/, '$2')));
935
+ const files = present.filter((file) => !named.has(file) && !named.has(`./${file}`));
936
+ if (!files.length) {
937
+ console.log('nets: covered by --verify');
938
+ return { ok: true, ran: false, files: [] };
939
+ }
940
+ console.log(`nets: node --test ${files.join(' ')}`);
941
+ if (dryRun) {
942
+ console.log('nets: skipped (dry-run)');
943
+ return { ok: true, ran: false, files };
944
+ }
945
+ // A ship started from inside a test run must still get a real runner.
946
+ const childEnv = { ...env };
947
+ delete childEnv.NODE_TEST_CONTEXT;
948
+ const result = spawnSync(process.execPath, ['--test', ...files], { cwd: root, stdio: 'inherit', env: childEnv });
949
+ if (result.error || result.status !== 0) {
950
+ console.error('nets: failed, fix before shipping');
951
+ return { ok: false, ran: true, files };
952
+ }
953
+ return { ok: true, ran: true, files };
954
+ }
955
+
909
956
  const SHIP_SETTLE_MS = 10 * 60 * 1000;
910
957
  const SHIP_REAP_POLL_MS = 60 * 1000;
911
958
  const SHIP_REAP_GIVE_UP_MS = 6 * 60 * 60 * 1000;
@@ -2014,9 +2061,11 @@ module.exports = {
2014
2061
  runCommand,
2015
2062
  runGit,
2016
2063
  runShipHealthCheck,
2064
+ runShipNets,
2017
2065
  scheduleShippedCopyRemoval,
2018
2066
  shellQuote,
2019
2067
  shipMergeRecord,
2068
+ shipNets,
2020
2069
  shippedCopyRemovalPlan,
2021
2070
  slugify,
2022
2071
  stamp,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.24",
3
+ "version": "3.82.25",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",