atris 3.82.23 → 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
@@ -153,7 +153,8 @@ const dryRunRequested = updateArgs.includes('--dry-run');
153
153
  const skipUpdateCheck = Boolean(process.env.ATRIS_SKIP_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER || helpRequested || jsonRequested);
154
154
  // Local-only commands make no registry call and start no background install.
155
155
  // sentinel prints its own one-line notice at the end of the run instead.
156
- if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99', 'sentinel', 'login'].includes(updateCommand)))) {
156
+ // listen is polled by scripts every minute and its stdout is read by them.
157
+ if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99', 'sentinel', 'login', 'listen'].includes(updateCommand)))) {
157
158
  updateCheckPromise = checkForUpdates()
158
159
  .then((updateInfo) => {
159
160
  if (updateInfo) {
@@ -722,7 +723,7 @@ function showHelpAll() {
722
723
  console.log(' pull - Pull journals + member data from cloud');
723
724
  console.log(' push - Push workspace files to cloud');
724
725
  console.log(' memory - Your Atris memory for any agent: show, search, remember, notebook; pull/push a laptop copy');
725
- 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)');
726
727
  console.log(' zen - make this repo calm and current, keeping everything (--check to only look)');
727
728
  console.log(' cloud - Delete cloud files not present locally (cloud clean --dry-run|--yes)');
728
729
  console.log(' live - Keep a business brain fresh (doctor, pull, watch, push)');
@@ -788,6 +789,7 @@ function showHelpAll() {
788
789
  console.log(' hires - Morning check-in: one line per hire (mail, spend, last run)');
789
790
  console.log(' mail - Your agents\' own inboxes: list, read, send (not Gmail)');
790
791
  console.log(' channel - The team\'s shared channels: read, post, claim, done (atris channel <name>)');
792
+ console.log(' listen - A team member\'s inbox: what was sent to it, then mark it handled (atris listen --as <member>)');
791
793
  console.log(' design - Extract a site design system, check brand adherence, search brands');
792
794
  console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
793
795
  console.log('');
@@ -2586,6 +2588,11 @@ if (command === 'guide') {
2586
2588
  Promise.resolve().then(() => require('../commands/channel').channelCommand(process.argv.slice(3)))
2587
2589
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2588
2590
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2591
+ } else if (command === 'listen') {
2592
+ // A team member's inbox on the team board. Exits 0 new, 1 nothing new, 2 error.
2593
+ Promise.resolve().then(() => require('../commands/listen').listenCommand(process.argv.slice(3)))
2594
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2595
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(2); });
2589
2596
  } else if (command === 'transactions') {
2590
2597
  Promise.resolve().then(() => require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
2591
2598
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
@@ -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.`,
@@ -5,12 +5,10 @@
5
5
  // the sign-in they already have. Backend: /api/swarlo/{business}/... (the
6
6
  // business may be its slug or its id; the server takes both).
7
7
 
8
- const fs = require('fs');
9
- const path = require('path');
10
8
  const { apiRequestJson } = require('../utils/api');
11
- const { NOT_LOGGED_IN, oneLine } = require('../lib/developer-api');
9
+ const { NOT_LOGGED_IN } = require('../lib/developer-api');
12
10
  const { parseArgs, loginToken, relativeTime, clip } = require('../lib/business-target');
13
- const { stateHome } = require('../lib/state-home');
11
+ const { resolveChannelBusiness, boardWhy, memberName, lookupMember } = require('../lib/team-board');
14
12
 
15
13
  // Words that are verbs here, so no channel can be called one of them.
16
14
  const REPORTS = ['done', 'failed', 'blocked'];
@@ -43,60 +41,6 @@ the team's channels on the hosted Atris server, shared by every agent on every
43
41
  computer. uses your existing sign-in (atris login).`);
44
42
  }
45
43
 
46
- function readJson(file) {
47
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
48
- }
49
-
50
- function registeredBusinesses(homeDir) {
51
- const raw = readJson(path.join(homeDir, '.atris', 'businesses.json'));
52
- if (!raw || typeof raw !== 'object') return [];
53
- return Object.entries(raw)
54
- .filter(([, b]) => b && typeof b === 'object' && b.business_id)
55
- .map(([key, b]) => ({ id: String(b.business_id), slug: String(b.slug || key), name: String(b.name || b.slug || key) }));
56
- }
57
-
58
- function boundBusiness(startDir) {
59
- let dir = path.resolve(startDir);
60
- for (;;) {
61
- const parsed = readJson(path.join(dir, '.atris', 'business.json'));
62
- if (parsed && parsed.business_id) {
63
- const slug = String(parsed.slug || parsed.name || parsed.business_id);
64
- return { id: String(parsed.business_id), slug, name: String(parsed.name || slug) };
65
- }
66
- const up = path.dirname(dir);
67
- if (up === dir) return null;
68
- dir = up;
69
- }
70
- }
71
-
72
- const PICK = 'add --business <slug>, set ATRIS_BUSINESS=<slug>, or run inside a business folder';
73
-
74
- // Which business, in order: --business, ATRIS_BUSINESS, the nearest
75
- // .atris/business.json, then the only one in ~/.atris/businesses.json.
76
- // A slug is looked up locally; one this computer does not know goes to the
77
- // server as is. Returns { id, slug, name } or { error }.
78
- function resolveChannelBusiness(flags = {}, deps = {}) {
79
- const env = deps.env || process.env;
80
- const known = registeredBusinesses(deps.homeDir || stateHome());
81
- const lookup = (value) => known.find((b) => b.slug === value || b.id === value)
82
- || { id: value, slug: value, name: value };
83
-
84
- if (Object.prototype.hasOwnProperty.call(flags, 'business')) {
85
- const given = typeof flags.business === 'string' ? flags.business.trim() : '';
86
- if (!given) return { error: '--business needs a business slug' };
87
- return lookup(given);
88
- }
89
- const fromEnv = String(env.ATRIS_BUSINESS || '').trim();
90
- if (fromEnv) return lookup(fromEnv);
91
- const bound = boundBusiness(deps.cwd || process.cwd());
92
- if (bound) return bound;
93
- if (known.length === 1) return known[0];
94
- if (!known.length) return { error: `no business picked: ${PICK}.` };
95
- const names = known.map((b) => b.slug);
96
- const shown = names.length > 8 ? `${names.slice(0, 8).join(', ')}, and ${names.length - 8} more` : names.join(', ');
97
- return { error: `more than one business on this computer (${shown}): ${PICK}.` };
98
- }
99
-
100
44
  function postLine(post, now, width = 400) {
101
45
  const who = clip(post.member_name || 'someone', 18);
102
46
  const when = relativeTime(post.created_at, now) || '';
@@ -144,14 +88,7 @@ async function channelCommand(args = [], deps = {}) {
144
88
  const request = deps.apiRequestJson || apiRequestJson;
145
89
  const base = `/swarlo/${encodeURIComponent(business.id)}`;
146
90
  const call = (route, options = {}) => request(`${base}${route}`, { method: 'GET', ...options, token });
147
- const why = (res) => {
148
- if (!res) return 'no answer from the server';
149
- const detail = res.data && (res.data.detail || res.data.error);
150
- if (res.status === 401) return 'your sign-in was refused. run: atris login';
151
- if (res.status === 403) return `you are not on ${business.slug}'s team`;
152
- if (res.status === 404 && detail === 'Business not found') return `no business called "${business.slug}"`;
153
- return oneLine(detail || res.error || `request failed (${res.status})`);
154
- };
91
+ const why = (res) => boardWhy(res, business);
155
92
  const say = (res) => log(JSON.stringify(res.data));
156
93
  const channelList = async () => {
157
94
  const res = await call('/channels');
@@ -200,14 +137,14 @@ async function channelCommand(args = [], deps = {}) {
200
137
  // member that cannot be found stops the write; it never falls back to you.
201
138
  const writes = (verb && verb !== 'list') || (name && text);
202
139
  const env = deps.env || process.env;
203
- const asWho = String((typeof flags.as === 'string' ? flags.as : env.ATRIS_AGENT) || '').trim().replace(/^@/, '').toLowerCase();
140
+ const asWho = memberName(flags, env);
204
141
  let member = null;
205
142
  if (writes && asWho) {
206
- const res = await request(`/business/${encodeURIComponent(business.id)}/team/${encodeURIComponent(asWho)}`, { method: 'GET', token });
207
- if (res && res.status === 404) { err(`no member called "${asWho}" in ${business.slug}, so nothing was sent. check the name, or leave off --as to post as yourself`); return 1; }
208
- if (!res || !res.ok) { err(`could not look up member "${asWho}", so nothing was sent: ${why(res)}`); return 1; }
209
- if (!res.data || !res.data.agent_id) { err(`member "${asWho}" in ${business.slug} has no agent id, so nothing was sent`); return 1; }
210
- member = { id: String(res.data.agent_id), name: String(res.data.name || asWho) };
143
+ const found = await lookupMember(request, business, asWho, token);
144
+ if (found.missing) { err(`no member called "${asWho}" in ${business.slug}, so nothing was sent. check the name, or leave off --as to post as yourself`); return 1; }
145
+ if (found.failed !== undefined) { err(`could not look up member "${asWho}", so nothing was sent: ${why(found.failed)}`); return 1; }
146
+ if (found.noId) { err(`member "${asWho}" in ${business.slug} has no agent id, so nothing was sent`); return 1; }
147
+ member = found.member;
211
148
  }
212
149
  const signed = (body) => (member ? { ...body, agent_id: member.id } : body);
213
150
  const byline = member ? ` as ${member.name}` : '';
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
  };
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ // atris listen: a team member's inbox on the team board. Shows what was sent
4
+ // to that member (mentions, replies, tasks) that it has not marked handled.
5
+ // Backend: GET /api/swarlo/{business}/inbox and POST .../inbox/ack.
6
+ //
7
+ // Exit codes are for scripts: 0 something new, 1 nothing new, 2 error.
8
+ // if atris listen --as walsh --json > new.json; then ...; fi
9
+
10
+ const { apiRequestJson } = require('../utils/api');
11
+ const { NOT_LOGGED_IN, oneLine } = require('../lib/developer-api');
12
+ const { parseArgs, relativeTime, clip } = require('../lib/business-target');
13
+ const { resolveChannelBusiness, boardWhy, memberName, lookupMember } = require('../lib/team-board');
14
+
15
+ const FLAGS = { switches: ['watch'], values: ['as', 'limit', 'ack'] };
16
+ const NEW = 0;
17
+ const NOTHING = 1;
18
+ const FAILED = 2;
19
+ const WATCH_MS = 30000;
20
+ const SERVER_MAX = 50;
21
+ // Scripts run this every minute: each request gives up after 10 seconds and
22
+ // is never retried, so one run cannot outlast the next.
23
+ const QUICK = { timeoutMs: 10000, retries: 0 };
24
+ const WHOLE = /^[1-9]\d*$/;
25
+
26
+ // The sign-in token, or { error }. A token from ATRIS_TOKEN or one that is
27
+ // not near expiry is used as is: the inbox request's own 401 is the check,
28
+ // which saves a round trip per run. Anything else (expired, unknown expiry,
29
+ // an agent client) goes through the full check. When that check fails for a
30
+ // reason other than "never signed in", say the real reason.
31
+ async function listenToken(deps, request) {
32
+ if (deps.token) return { token: deps.token };
33
+ if (!deps.ensureCredentials) {
34
+ const auth = require('../utils/auth');
35
+ const { isAgentClientCredential } = require('../utils/agent-client');
36
+ const creds = auth.readActiveCredentials();
37
+ const fresh = creds && creds.token && !isAgentClientCredential(creds)
38
+ && (creds.source === 'env' || (auth.getTokenExpiryEpochSeconds(creds.token) && !auth.shouldRefreshToken(creds.token)));
39
+ if (fresh) return { token: creds.token };
40
+ }
41
+ const ensure = deps.ensureCredentials
42
+ || (() => require('../utils/auth').ensureValidCredentials((p, o = {}) => request(p, { ...o, ...QUICK })));
43
+ let auth;
44
+ try { auth = await ensure(); } catch (e) { return { error: `could not check your sign-in: ${oneLine((e && e.message) || e)}` }; }
45
+ if (auth && !auth.error && auth.credentials && auth.credentials.token) return { token: auth.credentials.token };
46
+ if (!auth || auth.error === 'not_logged_in') return { error: NOT_LOGGED_IN };
47
+ return { error: `could not check your sign-in: ${oneLine(auth.detail || auth.error)}. if this keeps happening, run: atris login` };
48
+ }
49
+
50
+ function listenHelp(log = console.log) {
51
+ log(`usage: atris listen --as <member> what was sent to that member, oldest first
52
+ atris listen --as <member> --ack <number> mark everything up to that number handled
53
+ atris listen --as <member> --watch keep checking every 30 seconds (Ctrl-C to stop)
54
+
55
+ options:
56
+ --as <member> whose inbox (else ATRIS_AGENT)
57
+ --business <slug> which business (else ATRIS_BUSINESS, this folder's business, or your only one)
58
+ --limit <n> how many to show, 1 to 50 (default 20)
59
+ --json the server's reply as is
60
+
61
+ exits 0 when something is new, 1 when nothing is, 2 on an error, so a script
62
+ can run: if atris listen --as walsh --json > new.json; then ...; fi
63
+ --watch never marks anything handled. uses your existing sign-in (atris login).`);
64
+ }
65
+
66
+ // Oldest first, whatever order the server sent.
67
+ function bySeq(items) {
68
+ return items.slice().sort((a, b) => Number(a.seq) - Number(b.seq));
69
+ }
70
+
71
+ function itemLines(items, now) {
72
+ const rows = items.map((it) => ({
73
+ channel: `#${clip(it.channel || '?', 20)}`,
74
+ who: clip(it.author_name || it.author_id || 'someone', 18),
75
+ when: relativeTime(it.created_at, now) || '',
76
+ text: `[${it.reason || 'note'}] ${clip(it.content || '', 200)}`,
77
+ }));
78
+ const w = (key) => Math.max(...rows.map((r) => r[key].length));
79
+ const wc = w('channel'); const ww = w('who'); const wt = w('when');
80
+ return rows.map((r) => `${r.channel.padEnd(wc)} ${r.who.padEnd(ww)} ${r.when.padEnd(wt)} ${r.text}`);
81
+ }
82
+
83
+ async function listenCommand(args = [], deps = {}) {
84
+ let secret = '';
85
+ const hide = (line) => (secret ? String(line).split(secret).join('[hidden]') : String(line));
86
+ const log = (line) => (deps.log || console.log)(hide(line));
87
+ const err = (line) => (deps.err || console.error)(hide(line));
88
+ const clock = () => (deps.now ? deps.now() : Date.now());
89
+ const env = deps.env || process.env;
90
+
91
+ const { flags, pos, error } = parseArgs(args, FLAGS);
92
+ if (flags.help || pos[0] === 'help') { listenHelp(log); return 0; }
93
+ if (error) { err(`${error}. see: atris listen --help`); return FAILED; }
94
+ if (pos.length) { err(`atris listen takes no other words. to name the member: atris listen --as ${pos[0].replace(/^@/, '')}`); return FAILED; }
95
+
96
+ const who = memberName(flags, env);
97
+ if (!who) { err('say who is listening: atris listen --as <member>'); return FAILED; }
98
+ const limit = flags.limit == null ? 20 : (WHOLE.test(flags.limit) ? Number(flags.limit) : NaN);
99
+ if (!(limit >= 1 && limit <= SERVER_MAX)) { err('--limit takes a whole number from 1 to 50'); return FAILED; }
100
+ const ack = flags.ack == null ? null : (WHOLE.test(flags.ack) ? Number(flags.ack) : NaN);
101
+ if (ack !== null && !Number.isSafeInteger(ack)) { err(`--ack takes the number shown on the last line, like: atris listen --as ${who} --ack 42`); return FAILED; }
102
+ if (ack !== null && flags.watch) { err('--ack and --watch do not go together: --watch never marks anything handled'); return FAILED; }
103
+
104
+ const business = resolveChannelBusiness(flags, deps);
105
+ if (business.error) { err(business.error); return FAILED; }
106
+ const raw = deps.apiRequestJson || apiRequestJson;
107
+ const request = (p, o = {}) => raw(p, { ...o, ...QUICK });
108
+ const signIn = await listenToken(deps, raw);
109
+ if (signIn.error) { err(signIn.error); return FAILED; }
110
+ const token = signIn.token;
111
+ secret = token;
112
+
113
+ const why = (res) => boardWhy(res, business);
114
+ const found = await lookupMember(request, business, who, token);
115
+ if (found.missing) { err(`no member called "${who}" in ${business.slug}, so nothing was checked. check the spelling of the name`); return FAILED; }
116
+ if (found.failed !== undefined) { err(`could not look up member "${who}": ${why(found.failed)}`); return FAILED; }
117
+ if (found.noId) { err(`member "${who}" in ${business.slug} has no agent id, so it has no inbox`); return FAILED; }
118
+ const member = found.member;
119
+
120
+ const base = `/swarlo/${encodeURIComponent(business.id)}/inbox`;
121
+ const inboxWhy = (res) => (res && res.status === 404 && res.data && res.data.detail === 'Not Found'
122
+ ? 'this server has no inboxes yet; the update may still be rolling out'
123
+ : why(res));
124
+ // The same command again, so the hint is copyable as is.
125
+ const again = `atris listen --as ${who}${Object.prototype.hasOwnProperty.call(flags, 'business') ? ` --business ${business.slug}` : ''}`;
126
+
127
+ if (ack !== null) {
128
+ const res = await request(`${base}/ack`, { method: 'POST', token, body: { agent_id: member.id, seq: ack } });
129
+ if (!res || !res.ok) { err(`could not mark ${member.name}'s inbox handled: ${inboxWhy(res)}`); return FAILED; }
130
+ if (flags.json) { log(JSON.stringify(res.data)); return 0; }
131
+ const upTo = Number(res.data && res.data.read_up_to);
132
+ if (Number.isFinite(upTo) && upTo > ack) log(`${member.name}'s inbox was already handled up to ${upTo}; nothing changed.`);
133
+ else log(`marked handled for ${member.name}, up to ${Number.isFinite(upTo) ? upTo : ack}.`);
134
+ return 0;
135
+ }
136
+
137
+ const fetchInbox = async () => {
138
+ // --watch asks for the most the server gives, since it never marks
139
+ // anything handled and would otherwise keep seeing the same oldest few.
140
+ const n = flags.watch ? SERVER_MAX : limit;
141
+ const res = await request(`${base}?agent_id=${encodeURIComponent(member.id)}&limit=${n}`, { method: 'GET', token });
142
+ if (!res || !res.ok) return { failed: inboxWhy(res), res };
143
+ if (!res.data || !Array.isArray(res.data.items)) return { failed: 'the server answered without an items list' };
144
+ return { data: res.data, items: bySeq(res.data.items) };
145
+ };
146
+
147
+ if (!flags.watch) {
148
+ const got = await fetchInbox();
149
+ if (got.failed) { err(`could not read ${member.name}'s inbox: ${got.failed}`); return FAILED; }
150
+ if (flags.json) { log(JSON.stringify(got.data)); return got.items.length ? NEW : NOTHING; }
151
+ if (!got.items.length) { log(`nothing new for ${member.name}.`); return NOTHING; }
152
+ for (const line of itemLines(got.items, clock())) log(line);
153
+ if (got.items.length >= limit) log(`showing the oldest ${got.items.length}; more are waiting after these are handled.`);
154
+ log(`mark handled: ${again} --ack ${got.items[got.items.length - 1].seq}`);
155
+ return NEW;
156
+ }
157
+
158
+ // --watch: for a person at a terminal. Prints only items it has not shown
159
+ // yet and never marks anything handled. Errors that retrying cannot fix
160
+ // (sign-in, access, no such business) stop it; the rest wait for the next round.
161
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
162
+ const keepGoing = deps.keepGoing || (() => true);
163
+ // stderr, so --watch --json leaves nothing but items on stdout.
164
+ if (!deps.sleep) process.once('SIGINT', () => { (deps.err || console.error)('stopped listening.'); process.exit(0); });
165
+ if (!flags.json) log(`listening for ${member.name} in ${business.slug}. checks every 30 seconds; Ctrl-C to stop.`);
166
+ let shown = -Infinity;
167
+ let first = true;
168
+ let full = false;
169
+ for (;;) {
170
+ const got = await fetchInbox();
171
+ if (got.failed) {
172
+ err(`could not read ${member.name}'s inbox: ${got.failed}`);
173
+ if (got.res && [401, 403, 404].includes(got.res.status)) return FAILED;
174
+ } else {
175
+ const fresh = got.items.filter((it) => Number(it.seq) > shown);
176
+ if (fresh.length) {
177
+ if (flags.json) for (const it of fresh) log(JSON.stringify(it));
178
+ else for (const line of itemLines(fresh, clock())) log(line);
179
+ shown = Math.max(shown, ...fresh.map((it) => Number(it.seq)));
180
+ if (!flags.json) log(`mark handled: ${again} --ack ${shown}`);
181
+ } else if (first && !flags.json) {
182
+ log(`nothing new for ${member.name} yet.`);
183
+ }
184
+ // The server gives the oldest 50 unhandled; newer ones stay hidden
185
+ // until some are marked handled. Say so once, not every round.
186
+ const nowFull = got.items.length >= SERVER_MAX;
187
+ if (nowFull && !full) (flags.json ? err : log)(`${SERVER_MAX}+ waiting for ${member.name}; newer ones show after these are marked handled: ${again} --ack ${shown}`);
188
+ full = nowFull;
189
+ }
190
+ first = false;
191
+ if (!keepGoing()) return 0;
192
+ await sleep(deps.intervalMs || WATCH_MS);
193
+ }
194
+ }
195
+
196
+ module.exports = { listenCommand };
@@ -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');
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'sentinel', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'growth', 'scorecard', 'rating', 'sync-checkout', 'zen',
7
- 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'film', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'channel', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'film', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'channel', 'listen', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
@@ -174,11 +174,15 @@ function jobScope(state, key) {
174
174
  return state.machine && state.machine.path ? 'machine' : 'project';
175
175
  }
176
176
 
177
+ // A member belongs to one project, so a member no file names yet goes in that
178
+ // project's own atris/ROSTER.md when it has one; only a project without one
179
+ // falls back to the all-projects roster.
177
180
  function teamScope(state, member) {
178
181
  for (const scope of reg.ROSTER_LAYERS) {
179
182
  const team = state[scope] && Array.isArray(state[scope].team) ? state[scope].team : [];
180
183
  if (team.some((line) => line.member === member)) return scope;
181
184
  }
185
+ if (state.project && state.project.format === 'markdown') return 'project';
182
186
  return state.machine && state.machine.path ? 'machine' : 'project';
183
187
  }
184
188
 
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,
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ // Shared plumbing for the team board on the hosted server (`atris channel`,
4
+ // `atris listen`): which business, the plain reason a request failed, and the
5
+ // member name -> agent id lookup. HTTP client only; routes live in the backend.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { oneLine } = require('./developer-api');
10
+ const { stateHome } = require('./state-home');
11
+
12
+ function readJson(file) {
13
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
14
+ }
15
+
16
+ function registeredBusinesses(homeDir) {
17
+ const raw = readJson(path.join(homeDir, '.atris', 'businesses.json'));
18
+ if (!raw || typeof raw !== 'object') return [];
19
+ return Object.entries(raw)
20
+ .filter(([, b]) => b && typeof b === 'object' && b.business_id)
21
+ .map(([key, b]) => ({ id: String(b.business_id), slug: String(b.slug || key), name: String(b.name || b.slug || key) }));
22
+ }
23
+
24
+ function boundBusiness(startDir) {
25
+ let dir = path.resolve(startDir);
26
+ for (;;) {
27
+ const parsed = readJson(path.join(dir, '.atris', 'business.json'));
28
+ if (parsed && parsed.business_id) {
29
+ const slug = String(parsed.slug || parsed.name || parsed.business_id);
30
+ return { id: String(parsed.business_id), slug, name: String(parsed.name || slug) };
31
+ }
32
+ const up = path.dirname(dir);
33
+ if (up === dir) return null;
34
+ dir = up;
35
+ }
36
+ }
37
+
38
+ const PICK = 'add --business <slug>, set ATRIS_BUSINESS=<slug>, or run inside a business folder';
39
+
40
+ // Which business, in order: --business, ATRIS_BUSINESS, the nearest
41
+ // .atris/business.json, then the only one in ~/.atris/businesses.json.
42
+ // A slug is looked up locally; one this computer does not know goes to the
43
+ // server as is. Returns { id, slug, name } or { error }.
44
+ function resolveChannelBusiness(flags = {}, deps = {}) {
45
+ const env = deps.env || process.env;
46
+ const known = registeredBusinesses(deps.homeDir || stateHome());
47
+ const lookup = (value) => known.find((b) => b.slug === value || b.id === value)
48
+ || { id: value, slug: value, name: value };
49
+
50
+ if (Object.prototype.hasOwnProperty.call(flags, 'business')) {
51
+ const given = typeof flags.business === 'string' ? flags.business.trim() : '';
52
+ if (!given) return { error: '--business needs a business slug' };
53
+ return lookup(given);
54
+ }
55
+ const fromEnv = String(env.ATRIS_BUSINESS || '').trim();
56
+ if (fromEnv) return lookup(fromEnv);
57
+ const bound = boundBusiness(deps.cwd || process.cwd());
58
+ if (bound) return bound;
59
+ if (known.length === 1) return known[0];
60
+ if (!known.length) return { error: `no business picked: ${PICK}.` };
61
+ const names = known.map((b) => b.slug);
62
+ const shown = names.length > 8 ? `${names.slice(0, 8).join(', ')}, and ${names.length - 8} more` : names.join(', ');
63
+ return { error: `more than one business on this computer (${shown}): ${PICK}.` };
64
+ }
65
+
66
+ // One plain line on why a board request failed.
67
+ function boardWhy(res, business) {
68
+ if (!res) return 'no answer from the server';
69
+ const detail = res.data && (res.data.detail || res.data.error);
70
+ if (res.status === 401) return 'your sign-in was refused. run: atris login';
71
+ if (res.status === 403) return `you are not on ${business.slug}'s team`;
72
+ if (res.status === 404 && detail === 'Business not found') return `no business called "${business.slug}"`;
73
+ return oneLine(detail || res.error || `request failed (${res.status})`);
74
+ }
75
+
76
+ // The member named by --as, else ATRIS_AGENT: lowercased, no leading @.
77
+ function memberName(flags = {}, env = process.env) {
78
+ return String((typeof flags.as === 'string' ? flags.as : env.ATRIS_AGENT) || '').trim().replace(/^@/, '').toLowerCase();
79
+ }
80
+
81
+ // The server's words for "no such member" ("Agent not found: walsh"). Any
82
+ // other 404, like "Business not found", is a different problem.
83
+ const MEMBER_MISSING = /\b(agent|member) not found\b/i;
84
+
85
+ // Member name -> { member: { id, name } }, or { missing: true }, { failed: res },
86
+ // or { noId: true }. The caller words the error for its own command.
87
+ // `options` adds request settings, like a shorter timeout.
88
+ async function lookupMember(request, business, name, token, options = {}) {
89
+ const res = await request(`/business/${encodeURIComponent(business.id)}/team/${encodeURIComponent(name)}`, { method: 'GET', ...options, token });
90
+ const detail = res && res.data && (res.data.detail || res.data.error);
91
+ if (res && res.status === 404 && MEMBER_MISSING.test(String(detail || ''))) return { missing: true };
92
+ if (!res || !res.ok) return { failed: res };
93
+ if (!res.data || !res.data.agent_id) return { noId: true };
94
+ return { member: { id: String(res.data.agent_id), name: String(res.data.name || name) } };
95
+ }
96
+
97
+ module.exports = { resolveChannelBusiness, boardWhy, memberName, lookupMember };
@@ -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.23",
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",