atris 3.15.13 → 3.15.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/AGENTS.md +84 -8
  2. package/README.md +5 -1
  3. package/atris/AGENTS.md +46 -1
  4. package/atris/CLAUDE.md +36 -1
  5. package/atris/GEMINI.md +14 -1
  6. package/atris/atris.md +12 -1
  7. package/atris/atrisDev.md +3 -2
  8. package/atris/context/README.md +11 -0
  9. package/atris/features/company-brain-sync/validate.md +5 -5
  10. package/atris/learnings.jsonl +1 -0
  11. package/atris/policies/atris-design.md +2 -0
  12. package/atris/skills/aeo/SKILL.md +2 -2
  13. package/atris/skills/atris/SKILL.md +15 -62
  14. package/atris/skills/design/SKILL.md +2 -0
  15. package/atris/skills/imessage/SKILL.md +19 -2
  16. package/atris/skills/loop/SKILL.md +6 -5
  17. package/atris/skills/magic-inbox/SKILL.md +1 -1
  18. package/atris/team/_template/MEMBER.md +23 -1
  19. package/atris/team/brainstormer/START_HERE.md +6 -0
  20. package/atris/team/executor/MEMBER.md +13 -0
  21. package/atris/team/executor/START_HERE.md +6 -0
  22. package/atris/team/launcher/START_HERE.md +6 -0
  23. package/atris/team/mission-lead/MEMBER.md +39 -0
  24. package/atris/team/mission-lead/MISSION.md +33 -0
  25. package/atris/team/mission-lead/START_HERE.md +6 -0
  26. package/atris/team/navigator/MEMBER.md +11 -0
  27. package/atris/team/navigator/START_HERE.md +6 -0
  28. package/atris/team/opus-overnight/MEMBER.md +39 -0
  29. package/atris/team/opus-overnight/MISSION.md +61 -0
  30. package/atris/team/opus-overnight/START_HERE.md +6 -0
  31. package/atris/team/opus-overnight/STEERING.md +35 -0
  32. package/atris/team/researcher/START_HERE.md +6 -0
  33. package/atris/team/validator/MEMBER.md +26 -6
  34. package/atris/team/validator/START_HERE.md +6 -0
  35. package/atris/wiki/concepts/agent-activation-contract.md +79 -0
  36. package/atris/wiki/concepts/workspace-initialization-contract.md +73 -0
  37. package/atris/wiki/index.md +27 -0
  38. package/atris/wiki/sources/atris-labs-2026-05-10.txt +17 -0
  39. package/atris/wiki/sources/atris-labs-goals-2026-05-10.txt +15 -0
  40. package/atris/wiki/sources/atrisos-generative-ui-product-surface-2026-05-10.txt +10 -0
  41. package/atris/wiki/sources/jack-dorsey-2026-05-10.txt +12 -0
  42. package/atris.md +49 -13
  43. package/bin/atris.js +660 -22
  44. package/commands/activate.js +12 -3
  45. package/commands/aeo.js +1 -1
  46. package/commands/align.js +10 -10
  47. package/commands/analytics.js +9 -4
  48. package/commands/app.js +2 -0
  49. package/commands/apps.js +276 -0
  50. package/commands/auth.js +1 -1
  51. package/commands/autopilot.js +74 -5
  52. package/commands/brain.js +536 -61
  53. package/commands/brainstorm.js +12 -12
  54. package/commands/business-sync.js +142 -24
  55. package/commands/clean.js +9 -6
  56. package/commands/codex-goal.js +311 -0
  57. package/commands/errors.js +11 -1
  58. package/commands/feedback.js +55 -17
  59. package/commands/fork.js +2 -2
  60. package/commands/gm.js +376 -0
  61. package/commands/init.js +80 -3
  62. package/commands/integrations.js +524 -0
  63. package/commands/learn.js +25 -16
  64. package/commands/lesson.js +41 -0
  65. package/commands/lifecycle.js +2 -2
  66. package/commands/member.js +2416 -9
  67. package/commands/mission.js +1776 -0
  68. package/commands/now.js +48 -7
  69. package/commands/play.js +425 -0
  70. package/commands/publish.js +2 -1
  71. package/commands/pull.js +72 -29
  72. package/commands/push.js +199 -17
  73. package/commands/review.js +51 -13
  74. package/commands/skill.js +2 -2
  75. package/commands/soul.js +19 -13
  76. package/commands/status.js +6 -1
  77. package/commands/sync.js +5 -4
  78. package/commands/task.js +1041 -147
  79. package/commands/terminal.js +5 -5
  80. package/commands/verify.js +7 -5
  81. package/commands/visualize.js +7 -0
  82. package/commands/wiki.js +53 -16
  83. package/commands/workflow.js +298 -54
  84. package/commands/workspace-clean.js +1 -1
  85. package/commands/worktree.js +468 -0
  86. package/commands/xp.js +1608 -0
  87. package/lib/manifest.js +34 -4
  88. package/lib/scorecard.js +3 -2
  89. package/lib/task-db.js +408 -27
  90. package/lib/todo-fallback.js +28 -2
  91. package/lib/todo.js +5 -3
  92. package/package.json +23 -2
  93. package/utils/update-check.js +51 -1
package/commands/pull.js CHANGED
@@ -7,7 +7,7 @@ const { loadConfig } = require('../utils/config');
7
7
  const { getLogPath } = require('../lib/file-ops');
8
8
  const { parseJournalSections, mergeSections, reconstructJournal } = require('../lib/journal');
9
9
  const { loadBusinesses, businessMatchesSlug } = require('./business');
10
- const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
10
+ const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, isIgnoredSyncPath } = require('../lib/manifest');
11
11
  const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
12
12
  const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
13
13
  const { resolveSafeOutputDir } = require('../lib/workspace-safety');
@@ -37,6 +37,34 @@ function isBusinessWorkspaceRoot(dir) {
37
37
  return fs.existsSync(path.join(dir, '.atris', 'business.json')) && fs.existsSync(path.join(dir, 'atris'));
38
38
  }
39
39
 
40
+ function normalizePullFilePath(filePath) {
41
+ if (!filePath) return null;
42
+ return `/${String(filePath).replace(/^\/+/, '')}`;
43
+ }
44
+
45
+ function mergeSmartPullFiles(hashFiles = [], batchFiles = [], changedPaths = []) {
46
+ const contentMap = {};
47
+ for (const f of batchFiles) {
48
+ const normalizedPath = normalizePullFilePath(f && f.path);
49
+ if (normalizedPath) contentMap[normalizedPath] = { ...f, path: normalizedPath };
50
+ }
51
+
52
+ const missingContent = changedPaths.filter((p) => {
53
+ const item = contentMap[p];
54
+ return !item || typeof item.content !== 'string';
55
+ });
56
+ if (missingContent.length > 0) {
57
+ return { ok: false, missingContent };
58
+ }
59
+
60
+ const files = hashFiles.map((f) => {
61
+ const normalizedPath = normalizePullFilePath(f && f.path);
62
+ const normalizedHashOnly = normalizedPath ? { ...f, path: normalizedPath } : f;
63
+ return contentMap[normalizedPath] || normalizedHashOnly;
64
+ });
65
+ return { ok: true, files };
66
+ }
67
+
40
68
  function syncTimestamp() {
41
69
  return new Date().toISOString().replace(/[:.]/g, '-');
42
70
  }
@@ -75,7 +103,7 @@ function buildPullConflictReviewPacket(outputDir, conflictChanges, remoteContent
75
103
  async function pullAtris() {
76
104
  let arg = process.argv[3];
77
105
 
78
- if (arg === '--help') {
106
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
79
107
  console.log('Usage: atris pull [business] [--into <path>] [--only <prefix>] [--keep-local] [--timeout <seconds>] [--dry-run] [--no-manifest]');
80
108
  console.log('');
81
109
  console.log(' Pull is force-overwrite by default. Cloud is the source of truth.');
@@ -221,8 +249,8 @@ async function pullBusiness(slug) {
221
249
  // correct workspace for THIS business — i.e. it has a `.atris/business.json`
222
250
  // whose slug matches `slug`. Any other signal (a stray `atris/` folder, a
223
251
  // business.json for a different business, etc.) is NOT enough: pulling
224
- // atris-labs-1 on top of a pallet workspace would mix two businesses into
225
- // one directory and write pallet's manifest over atris-labs-1's (or vice
252
+ // atris-labs on top of another business workspace would mix two businesses into
253
+ // one directory and write one manifest over the other (or vice
226
254
  // versa), causing the next sync to do strange things.
227
255
  //
228
256
  // Fallback: create a fresh ./{slug}/ subdir. Always safe — even if cwd is
@@ -253,9 +281,9 @@ async function pullBusiness(slug) {
253
281
  // for a stray cwd to cause atris to delete user files.
254
282
  ({ dir: outputDir } = resolveSafeOutputDir(outputDir, { slug, op: 'pull into' }));
255
283
 
256
- if (!onlyPrefixes && isBusinessWorkspaceRoot(outputDir)) {
257
- onlyPrefixes = ['atris/'];
258
- }
284
+ // Pull the whole business workspace by default. Older versions silently
285
+ // narrowed bound workspace pulls to atris/, which made staging snapshots and
286
+ // the real workspace diverge while both reported "synced".
259
287
 
260
288
  // Resolve business ID — always refresh from API to avoid stale workspace_id
261
289
  let businessId, workspaceId, businessName, resolvedSlug;
@@ -379,13 +407,16 @@ async function pullBusiness(slug) {
379
407
  // Diff against manifest to find changed files
380
408
  const remoteHashes = {};
381
409
  for (const f of hashResult.data.files) {
382
- if (f.path && f.hash) remoteHashes[f.path] = f.hash;
410
+ const normalizedPath = normalizePullFilePath(f.path);
411
+ if (isIgnoredSyncPath(normalizedPath)) continue;
412
+ if (normalizedPath && f.hash) remoteHashes[normalizedPath] = f.hash;
383
413
  }
384
414
  const changedPaths = [];
385
415
  const manifestFiles = manifest.files || {};
386
416
  for (const [p, hash] of Object.entries(remoteHashes)) {
387
417
  const prev = manifestFiles[p];
388
- if (!prev || prev.hash !== hash) changedPaths.push(p);
418
+ const missingLocally = !localFilesBeforePull[p];
419
+ if (!prev || prev.hash !== hash || missingLocally) changedPaths.push(p);
389
420
  }
390
421
 
391
422
  if (changedPaths.length === 0) {
@@ -419,17 +450,16 @@ async function pullBusiness(slug) {
419
450
 
420
451
  if (batchResult.ok && batchResult.data && batchResult.data.files) {
421
452
  process.stdout.write(`\r Fetched ${batchResult.data.files.length} files in ${phase2Sec}s.${' '.repeat(10)}\n`);
422
- // Merge: hash-only results + content for changed files
423
- const contentMap = {};
424
- for (const f of batchResult.data.files) {
425
- if (f.path) contentMap[f.path] = f;
453
+ const merged = mergeSmartPullFiles(hashResult.data.files, batchResult.data.files, changedPaths);
454
+ if (merged.ok) {
455
+ result = { ok: true, data: { files: merged.files } };
456
+ } else {
457
+ process.stdout.write(`\r Batch missing ${merged.missingContent.length} file(s), fetching full snapshot...${' '.repeat(10)}\n`);
458
+ const contentUrl = `/business/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true${pathsParam}`;
459
+ result = await apiRequestJson(contentUrl, { method: 'GET', token: creds.token, timeoutMs });
460
+ const fullSec = Math.floor((Date.now() - startPhase2) / 1000);
461
+ process.stdout.write(`\r Fetched in ${fullSec}s.${' '.repeat(20)}\n`);
426
462
  }
427
- // Build merged file list: all hash-only entries + inject content for changed ones
428
- const mergedFiles = hashResult.data.files.map(f => {
429
- const withContent = contentMap[f.path];
430
- return withContent || f;
431
- });
432
- result = { ok: true, data: { files: mergedFiles } };
433
463
  } else {
434
464
  // Batch not available — fall back to full snapshot
435
465
  process.stdout.write(`\r Batch unavailable, fetching full snapshot...${' '.repeat(10)}\n`);
@@ -544,6 +574,9 @@ async function pullBusiness(slug) {
544
574
  const crypto = require('crypto');
545
575
  for (const file of files) {
546
576
  if (!file.path || file.binary) continue;
577
+ const normalizedPath = normalizePullFilePath(file.path);
578
+ if (!normalizedPath) continue;
579
+ if (isIgnoredSyncPath(normalizedPath)) continue;
547
580
  // An empty string IS valid content (a real, zero-byte file). The earlier
548
581
  // version excluded `content === ''` from the hasContent path, which made
549
582
  // empty files masquerade as hash-only entries; they'd then be recorded in
@@ -555,11 +588,11 @@ async function pullBusiness(slug) {
555
588
  if (hasContent) {
556
589
  // Full content available — hash from raw bytes (matches computeLocalHashes)
557
590
  const rawBytes = Buffer.from(file.content, 'utf-8');
558
- remoteFiles[file.path] = { hash: crypto.createHash('sha256').update(rawBytes).digest('hex'), size: rawBytes.length };
559
- remoteContent[file.path] = file.content;
591
+ remoteFiles[normalizedPath] = { hash: crypto.createHash('sha256').update(rawBytes).digest('hex'), size: rawBytes.length };
592
+ remoteContent[normalizedPath] = file.content;
560
593
  } else if (file.hash) {
561
594
  // Hash-only entry from smart pull — trust the cloud-reported hash
562
- remoteFiles[file.path] = { hash: file.hash, size: file.size || 0 };
595
+ remoteFiles[normalizedPath] = { hash: file.hash, size: file.size || 0 };
563
596
  }
564
597
  }
565
598
 
@@ -598,8 +631,12 @@ async function pullBusiness(slug) {
598
631
 
599
632
  if (dryRun) {
600
633
  console.log('');
601
- for (const p of [...diff.toPull, ...diff.newRemote]) {
602
- const label = diff.newRemote.includes(p) ? 'new on computer' : 'updated on computer';
634
+ for (const p of [...diff.toPull, ...diff.newRemote, ...diff.deletedLocal]) {
635
+ const label = diff.newRemote.includes(p)
636
+ ? 'new on computer'
637
+ : diff.deletedLocal.includes(p)
638
+ ? 'missing locally, restored from computer'
639
+ : 'updated on computer';
603
640
  const icon = diff.newRemote.includes(p) ? '+' : '\u2193';
604
641
  console.log(` ${icon} ${p.replace(/^\//, '')} ${label} (dry run)`);
605
642
  }
@@ -611,7 +648,7 @@ async function pullBusiness(slug) {
611
648
  }
612
649
 
613
650
  const parts = [];
614
- const pullCount = diff.toPull.length + diff.newRemote.length;
651
+ const pullCount = diff.toPull.length + diff.newRemote.length + diff.deletedLocal.length;
615
652
  if (pullCount > 0) parts.push(`${pullCount} would be pulled`);
616
653
  if (diff.deletedRemote.length > 0) parts.push(`${diff.deletedRemote.length} local file${diff.deletedRemote.length === 1 ? '' : 's'} would be removed`);
617
654
  if (diff.unchanged.length > 0) parts.push(`${diff.unchanged.length} unchanged`);
@@ -632,13 +669,17 @@ async function pullBusiness(slug) {
632
669
  console.log('');
633
670
 
634
671
  // Pull files that changed remotely (and we didn't change locally)
635
- for (const p of [...diff.toPull, ...diff.newRemote]) {
672
+ for (const p of [...diff.toPull, ...diff.newRemote, ...diff.deletedLocal]) {
636
673
  const content = remoteContent[p];
637
674
  if (!content && content !== '') continue;
638
675
  const localPath = path.join(outputDir, p.replace(/^\//, ''));
639
676
  fs.mkdirSync(path.dirname(localPath), { recursive: true });
640
677
  fs.writeFileSync(localPath, content);
641
- const label = diff.newRemote.includes(p) ? 'new on computer' : 'updated on computer';
678
+ const label = diff.newRemote.includes(p)
679
+ ? 'new on computer'
680
+ : diff.deletedLocal.includes(p)
681
+ ? 'missing locally, restored from computer'
682
+ : 'updated on computer';
642
683
  const icon = diff.newRemote.includes(p) ? '+' : '\u2193';
643
684
  console.log(` ${icon} ${p.replace(/^\//, '')} ${label}`);
644
685
  pulled++;
@@ -736,7 +777,9 @@ async function pullBusiness(slug) {
736
777
  // If there's no prior manifest (first pull), there's nothing to sweep —
737
778
  // threeWayCompare already handled newRemote/conflicts/newLocal, and we
738
779
  // have no basis for claiming ownership of any local-only file.
739
- const managedPaths = manifest && manifest.files ? Object.keys(manifest.files) : [];
780
+ const managedPaths = manifest && manifest.files
781
+ ? Object.keys(manifest.files).filter((p) => !isIgnoredSyncPath(p))
782
+ : [];
740
783
  const sweepCandidates = managedPaths.filter(isInScope).filter((p) => localFiles[p]);
741
784
  const inScopeLocal = Object.keys(localFiles).filter(isInScope);
742
785
 
@@ -1059,4 +1102,4 @@ async function pullMemberJournal(token, agentId, memberName, memberDir) {
1059
1102
  return synced;
1060
1103
  }
1061
1104
 
1062
- module.exports = { buildPullConflictReviewPacket, pullAtris };
1105
+ module.exports = { buildPullConflictReviewPacket, mergeSmartPullFiles, normalizePullFilePath, pullAtris };
package/commands/push.js CHANGED
@@ -4,7 +4,7 @@ const crypto = require('crypto');
4
4
  const { loadCredentials } = require('../utils/auth');
5
5
  const { apiRequestJson } = require('../utils/api');
6
6
  const { loadBusinesses, saveBusinesses, businessMatchesSlug } = require('./business');
7
- const { loadManifest, saveManifest, buildManifest, computeLocalHashes } = require('../lib/manifest');
7
+ const { loadManifest, saveManifest, buildManifest, computeLocalHashes, isIgnoredSyncPath, filterSyncFiles } = require('../lib/manifest');
8
8
  const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
9
9
  const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
10
10
  const { assertSafeWorkspaceRoot } = require('../lib/workspace-safety');
@@ -59,6 +59,7 @@ function buildPushChangePlan({
59
59
  baseFiles = {},
60
60
  onlyPrefixes = null,
61
61
  readFileContent,
62
+ isLocalFilePresent,
62
63
  } = {}) {
63
64
  const filesToPush = [];
64
65
  const deletedPaths = [];
@@ -79,7 +80,9 @@ function buildPushChangePlan({
79
80
  for (const filePath of Object.keys(baseFiles)) {
80
81
  if (!pathInScope(filePath, onlyPrefixes)) continue;
81
82
  if (basenameOfManifestPath(filePath).startsWith('.')) continue;
83
+ if (isIgnoredSyncPath(filePath)) continue;
82
84
  if (!localFiles[filePath]) {
85
+ if (isLocalFilePresent && isLocalFilePresent(filePath)) continue;
83
86
  deletedPaths.push(filePath);
84
87
  }
85
88
  }
@@ -104,11 +107,153 @@ function isMassDeletePlan({ deletedPaths = [], filesToPush = [], unchangedCount
104
107
  return deleteCount >= 10 && deleteCount > survivingCount;
105
108
  }
106
109
 
110
+ function cleanManifestPath(filePath) {
111
+ const s = String(filePath || '').replace(/\\/g, '/');
112
+ return s.startsWith('/') ? s : `/${s}`;
113
+ }
114
+
115
+ function buildCloudHashMap(files = []) {
116
+ const cloudHashes = {};
117
+ for (const f of files) {
118
+ const normalizedPath = f && f.path ? cleanManifestPath(f.path) : null;
119
+ if (normalizedPath && f.hash) cloudHashes[normalizedPath] = f.hash;
120
+ }
121
+ return cloudHashes;
122
+ }
123
+
124
+ function isSyncReviewArtifactPath(filePath) {
125
+ const cleaned = cleanManifestPath(filePath);
126
+ return ['.remote', '.local', '.base', '.cloud'].some((suffix) => cleaned.endsWith(suffix));
127
+ }
128
+
129
+ function isNestedWorkspacePollutionPath(filePath, slug) {
130
+ const cleaned = cleanManifestPath(filePath);
131
+ const cleanSlug = String(slug || '').trim().replace(/^\/+|\/+$/g, '');
132
+ if (!cleanSlug) return false;
133
+ return cleaned === `/${cleanSlug}` || cleaned.startsWith(`/${cleanSlug}/`);
134
+ }
135
+
136
+ function analyzePushSafety({
137
+ filesToPush = [],
138
+ deletedPaths = [],
139
+ unchangedCount = 0,
140
+ onlyPrefixes = null,
141
+ slug = null,
142
+ allowBroadWorkspace = false,
143
+ allowNestedWorkspace = false,
144
+ allowSyncArtifacts = false,
145
+ } = {}) {
146
+ const changedPaths = [
147
+ ...filesToPush.map((file) => cleanManifestPath(file.path)),
148
+ ...deletedPaths.map((filePath) => cleanManifestPath(filePath)),
149
+ ];
150
+ const changeCount = changedPaths.length;
151
+ const rootChanged = changedPaths.filter((filePath) => filePath.split('/').filter(Boolean).length === 1);
152
+ const topLevels = new Set(changedPaths.map((filePath) => filePath.split('/').filter(Boolean)[0]).filter(Boolean));
153
+ const nestedWorkspacePaths = changedPaths.filter((filePath) => isNestedWorkspacePollutionPath(filePath, slug));
154
+ const syncArtifactPaths = changedPaths.filter(isSyncReviewArtifactPath);
155
+ const reasons = [];
156
+
157
+ if (nestedWorkspacePaths.length > 0 && !allowNestedWorkspace) {
158
+ reasons.push(`nested workspace folder detected (${nestedWorkspacePaths.length} path${nestedWorkspacePaths.length === 1 ? '' : 's'})`);
159
+ }
160
+ if (syncArtifactPaths.length > 0 && !allowSyncArtifacts) {
161
+ reasons.push(`sync review artifacts detected (${syncArtifactPaths.length} path${syncArtifactPaths.length === 1 ? '' : 's'})`);
162
+ }
163
+ if (!onlyPrefixes && !allowBroadWorkspace) {
164
+ if (changeCount >= 25) {
165
+ reasons.push(`large unscoped workspace change (${changeCount} paths)`);
166
+ } else if (rootChanged.length >= 5) {
167
+ reasons.push(`many root files changed (${rootChanged.length} paths)`);
168
+ } else if (topLevels.size >= 8 && changeCount >= 10) {
169
+ reasons.push(`many top-level areas changed (${topLevels.size} areas)`);
170
+ }
171
+ }
172
+
173
+ return {
174
+ ok: reasons.length === 0,
175
+ reasons,
176
+ changedPaths,
177
+ changeCount,
178
+ unchangedCount,
179
+ rootChanged,
180
+ topLevelCount: topLevels.size,
181
+ nestedWorkspacePaths,
182
+ syncArtifactPaths,
183
+ };
184
+ }
185
+
186
+ function renderPushSafetyBlock(report, slug) {
187
+ const lines = [];
188
+ lines.push('');
189
+ lines.push(' Quest gate: review before publish');
190
+ lines.push(' ✗ Refusing unsafe workspace push.');
191
+ lines.push('');
192
+ lines.push(` Planned change: ${report.changeCount} path${report.changeCount === 1 ? '' : 's'}, ${report.unchangedCount} unchanged.`);
193
+ for (const reason of report.reasons) {
194
+ lines.push(` Reason: ${reason}.`);
195
+ }
196
+ const examples = [
197
+ ...report.nestedWorkspacePaths,
198
+ ...report.syncArtifactPaths,
199
+ ...report.changedPaths,
200
+ ];
201
+ const uniqueExamples = [...new Set(examples)].slice(0, 8);
202
+ if (uniqueExamples.length > 0) {
203
+ lines.push('');
204
+ lines.push(' First paths to review:');
205
+ uniqueExamples.forEach((filePath) => lines.push(` - ${filePath.replace(/^\//, '')}`));
206
+ }
207
+ lines.push('');
208
+ lines.push(' Safe moves:');
209
+ lines.push(` atris push ${slug || ''} --dry-run --only atris/now.md`.trimEnd());
210
+ lines.push(' atris sync --review');
211
+ lines.push('');
212
+ lines.push(' If this broad publish is intentional, rerun with:');
213
+ lines.push(' --allow-broad-workspace');
214
+ if (report.nestedWorkspacePaths.length > 0) lines.push(' --allow-nested-workspace');
215
+ if (report.syncArtifactPaths.length > 0) lines.push(' --allow-sync-artifacts');
216
+ lines.push('');
217
+ return `${lines.join('\n')}\n`;
218
+ }
219
+
220
+ function parsePushTimeoutSec(argv = process.argv, defaultSec = 120) {
221
+ let raw = null;
222
+ const eqArg = argv.find(a => a.startsWith('--timeout='));
223
+ if (eqArg) raw = eqArg.slice('--timeout='.length);
224
+ else {
225
+ const idx = argv.indexOf('--timeout');
226
+ if (idx !== -1 && argv[idx + 1]) raw = argv[idx + 1];
227
+ }
228
+
229
+ const parsed = raw == null ? defaultSec : Number.parseInt(raw, 10);
230
+ if (!Number.isFinite(parsed)) return defaultSec;
231
+ return Math.max(5, Math.min(300, parsed));
232
+ }
233
+
107
234
  async function pushAtris() {
108
235
  const elapsedMs = startTimer();
109
236
  let slug = process.argv[3];
110
237
  let _coldWake = false;
111
238
 
239
+ if (process.argv.includes('--help') || process.argv.includes('-h') || slug === 'help') {
240
+ console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force] [--delete] [--delete-all]');
241
+ console.log('');
242
+ console.log(' Push requires a fresh pull. If cloud has changed since your last pull,');
243
+ console.log(' the push will be blocked until you run `atris pull`. Use --force to override.');
244
+ console.log('');
245
+ console.log(' atris push Push from current folder (auto-detect business)');
246
+ console.log(' atris push example-co Push example-co/ or atris/example-co/');
247
+ console.log(' atris push example-co --only team/nate Only push files in team/nate/');
248
+ console.log(' atris push --force Bypass freshness check (force-push, may overwrite cloud changes)');
249
+ console.log(' atris push --delete Allow small cloud deletes shown by --dry-run');
250
+ console.log(' atris push --delete-all Extra confirmation for mass-delete recovery');
251
+ console.log(' --allow-broad-workspace Publish a large unscoped workspace plan after review');
252
+ console.log(' --allow-nested-workspace Explicitly allow nested <slug>/ paths');
253
+ console.log(' --allow-sync-artifacts Explicitly allow *.remote/*.local/*.base/*.cloud files');
254
+ process.exit(0);
255
+ }
256
+
112
257
  // Auto-detect business from .atris/business.json in current dir
113
258
  if (!slug || slug.startsWith('-')) {
114
259
  const bizFile = path.join(process.cwd(), '.atris', 'business.json');
@@ -128,11 +273,14 @@ async function pushAtris() {
128
273
  console.log(' the push will be blocked until you run `atris pull`. Use --force to override.');
129
274
  console.log('');
130
275
  console.log(' atris push Push from current folder (auto-detect business)');
131
- console.log(' atris push pallet Push pallet/ or atris/pallet/');
132
- console.log(' atris push pallet --only team/nate Only push files in team/nate/');
276
+ console.log(' atris push example-co Push example-co/ or atris/example-co/');
277
+ console.log(' atris push example-co --only team/nate Only push files in team/nate/');
133
278
  console.log(' atris push --force Bypass freshness check (force-push, may overwrite cloud changes)');
134
279
  console.log(' atris push --delete Allow small cloud deletes shown by --dry-run');
135
280
  console.log(' atris push --delete-all Extra confirmation for mass-delete recovery');
281
+ console.log(' --allow-broad-workspace Publish a large unscoped workspace plan after review');
282
+ console.log(' --allow-nested-workspace Explicitly allow nested <slug>/ paths');
283
+ console.log(' --allow-sync-artifacts Explicitly allow *.remote/*.local/*.base/*.cloud files');
136
284
  process.exit(0);
137
285
  }
138
286
 
@@ -140,7 +288,11 @@ async function pushAtris() {
140
288
  const dryRun = process.argv.includes('--dry-run');
141
289
  const allowDelete = process.argv.includes('--delete');
142
290
  const allowMassDelete = process.argv.includes('--delete-all');
291
+ const allowBroadWorkspace = process.argv.includes('--allow-broad-workspace');
292
+ const allowNestedWorkspace = process.argv.includes('--allow-nested-workspace');
293
+ const allowSyncArtifacts = process.argv.includes('--allow-sync-artifacts');
143
294
  const allowCrossRootManifest = process.argv.includes('--allow-cross-root-manifest');
295
+ const timeoutSec = parsePushTimeoutSec(process.argv);
144
296
 
145
297
  // Parse --only
146
298
  let onlyRaw = null;
@@ -167,7 +319,7 @@ async function pushAtris() {
167
319
  const sourceDir = resolvePushSourceDir({ slug });
168
320
  if (!sourceDir) {
169
321
  console.error(`No local folder found for "${slug}".`);
170
- console.error('Run from inside a pulled folder, or: atris push pallet --from ./path');
322
+ console.error('Run from inside a pulled folder, or: atris push example-co --from ./path');
171
323
  process.exit(1);
172
324
  }
173
325
 
@@ -176,9 +328,8 @@ async function pushAtris() {
176
328
  // Refuse to walk/upload dangerous paths ($HOME, /, /Users, system dirs).
177
329
  assertSafeWorkspaceRoot(sourceDir, { slug, op: 'push from' });
178
330
 
179
- if (!onlyPrefixes && isBusinessWorkspaceRoot(sourceDir)) {
180
- onlyPrefixes = ['/atris/'];
181
- }
331
+ // Push the whole business workspace by default. Brain-only sync must be
332
+ // explicit with --only atris/ so root workspace files cannot be missed.
182
333
 
183
334
  // Resolve business — always refresh from API
184
335
  let businessId, workspaceId, businessName, resolvedSlug;
@@ -280,11 +431,8 @@ async function pushAtris() {
280
431
  { method: 'GET', token: creds.token, timeoutMs: 60000 }
281
432
  );
282
433
  if (snapshotResult.ok && snapshotResult.data && Array.isArray(snapshotResult.data.files)) {
283
- const cloudHashes = {};
284
- for (const f of snapshotResult.data.files) {
285
- if (f.path && f.hash) cloudHashes[f.path] = f.hash;
286
- }
287
- const manifestFiles = (manifest && manifest.files) || {};
434
+ const cloudHashes = filterSyncFiles(buildCloudHashMap(snapshotResult.data.files));
435
+ const manifestFiles = filterSyncFiles((manifest && manifest.files) || {});
288
436
  const driftFiles = [];
289
437
  // Direction 1: cloud has files the manifest doesn't know about, OR
290
438
  // cloud's hash differs from what we last pulled (someone changed it).
@@ -350,12 +498,13 @@ async function pushAtris() {
350
498
 
351
499
  // Compare local hashes to manifest — NO server call needed
352
500
  // Files where local hash differs from manifest = changed locally
353
- const baseFiles = (manifest && manifest.files) ? manifest.files : {};
501
+ const baseFiles = filterSyncFiles((manifest && manifest.files) ? manifest.files : {});
354
502
  const { filesToPush, deletedPaths, unchangedCount } = buildPushChangePlan({
355
503
  localFiles,
356
504
  baseFiles,
357
505
  onlyPrefixes,
358
506
  readFileContent: (filePath) => fs.readFileSync(path.join(sourceDir, filePath.replace(/^\//, '')), 'utf8'),
507
+ isLocalFilePresent: (filePath) => fs.existsSync(path.join(sourceDir, filePath.replace(/^\//, ''))),
359
508
  });
360
509
 
361
510
  if (filesToPush.length === 0 && deletedPaths.length === 0) {
@@ -364,6 +513,23 @@ async function pushAtris() {
364
513
  return;
365
514
  }
366
515
 
516
+ const safetyReport = analyzePushSafety({
517
+ filesToPush,
518
+ deletedPaths,
519
+ unchangedCount,
520
+ onlyPrefixes,
521
+ slug: resolvedSlug || slug,
522
+ allowBroadWorkspace,
523
+ allowNestedWorkspace,
524
+ allowSyncArtifacts,
525
+ });
526
+
527
+ if (!safetyReport.ok && !dryRun) {
528
+ process.stdout.write(renderPushSafetyBlock(safetyReport, resolvedSlug || slug));
529
+ await emit('blocked', { error_detail: safetyReport.reasons.join('; ') });
530
+ process.exit(1);
531
+ }
532
+
367
533
  // Dry run — show what would be pushed without pushing
368
534
  if (dryRun) {
369
535
  console.log('');
@@ -383,6 +549,10 @@ async function pushAtris() {
383
549
  console.log(' Deletes require an explicit real push with --delete.');
384
550
  console.log(' If these deletes are unexpected, run `atris pull --keep-local` first.\n');
385
551
  }
552
+ if (!safetyReport.ok) {
553
+ process.stdout.write(renderPushSafetyBlock(safetyReport, resolvedSlug || slug));
554
+ console.log(' Dry run only: nothing was sent.\n');
555
+ }
386
556
  return;
387
557
  }
388
558
 
@@ -447,8 +617,14 @@ async function pushAtris() {
447
617
  };
448
618
  const wireFiles = (files) => files.map((f) => ({ path: toWirePath(f.path), content: f.content }));
449
619
  const syncFiles = (files) => apiRequestJson(
450
- `/business/${businessId}/workspaces/${workspaceId}/sync`,
451
- { method: 'POST', token: creds.token, body: { files: wireFiles(files) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
620
+ `/business/${businessId}/workspaces/${workspaceId}/sync?timeout=${timeoutSec}`,
621
+ {
622
+ method: 'POST',
623
+ token: creds.token,
624
+ body: { files: wireFiles(files) },
625
+ headers: { 'X-Atris-Actor-Source': 'cli' },
626
+ timeoutMs: (timeoutSec + 15) * 1000,
627
+ }
452
628
  );
453
629
 
454
630
  // Inspect per-file results from a /sync response. Treat "written" and
@@ -596,7 +772,7 @@ async function pushAtris() {
596
772
  }
597
773
  if (deleteFailed.length > 0) {
598
774
  console.log('');
599
- console.log(` ⚠ ${deleteFailed.length} delete(s) failed (NOT marked as deleted in manifest):`);
775
+ console.log(` ⚠ ${deleteFailed.length} ${deleteFailed.length === 1 ? 'delete' : 'deletes'} failed (NOT marked as deleted in manifest):`);
600
776
  deleteFailed.slice(0, 10).forEach((f) => console.log(` ${f.status} ${f.path.replace(/^\//, '')}`));
601
777
  if (deleteFailed.length > 10) console.log(` ... +${deleteFailed.length - 10} more`);
602
778
  }
@@ -625,7 +801,7 @@ async function pushAtris() {
625
801
  // These did NOT land on cloud even though the HTTP call returned 200.
626
802
  if (failedToLand.length > 0) {
627
803
  console.log('');
628
- console.log(` ⚠ ${failedToLand.length} file(s) did NOT land on cloud (server returned 200 but`);
804
+ console.log(` ⚠ ${failedToLand.length} ${failedToLand.length === 1 ? 'file' : 'files'} did NOT land on cloud (server returned 200 but`);
629
805
  console.log(` dropped or rejected these files):`);
630
806
  const shown = failedToLand.slice(0, 15);
631
807
  for (const f of shown) {
@@ -710,6 +886,12 @@ module.exports = {
710
886
  resolvePushSourceDir,
711
887
  canonicalWorkspaceRoot,
712
888
  basenameOfManifestPath,
889
+ buildCloudHashMap,
713
890
  isBusinessWorkspaceRoot,
714
891
  isMassDeletePlan,
892
+ parsePushTimeoutSec,
893
+ analyzePushSafety,
894
+ isNestedWorkspacePollutionPath,
895
+ isSyncReviewArtifactPath,
896
+ renderPushSafetyBlock,
715
897
  };
@@ -29,9 +29,16 @@ function findReviewEngine() {
29
29
  return null;
30
30
  }
31
31
 
32
- function runReview(args) {
33
- const enginePath = findReviewEngine();
34
- if (!enginePath) {
32
+ function printMissingReviewEngine(jsonMode) {
33
+ if (jsonMode) {
34
+ console.log(JSON.stringify({
35
+ ok: false,
36
+ error: 'Review engine not found.',
37
+ expected: 'atris/business/claude-code-review/workspace/review_engine.py',
38
+ specialists: ['Security', 'Testing', 'Performance', 'Maintainability', 'Database', 'Async'],
39
+ install: 'copy review_engine.py to your project',
40
+ }, null, 2));
41
+ } else {
35
42
  console.error('Review engine not found.');
36
43
  console.error('Expected at: atris/business/claude-code-review/workspace/review_engine.py');
37
44
  console.error('');
@@ -39,9 +46,20 @@ function runReview(args) {
39
46
  console.error(' Security, Testing, Performance, Maintainability, Database, Async');
40
47
  console.error('');
41
48
  console.error('Install: copy review_engine.py to your project');
42
- process.exit(1);
43
49
  }
50
+ process.exit(1);
51
+ }
52
+
53
+ function exitReviewError(message, jsonMode, extra = {}) {
54
+ if (jsonMode) {
55
+ console.log(JSON.stringify({ ok: false, error: message, ...extra }, null, 2));
56
+ } else {
57
+ console.error(message);
58
+ }
59
+ process.exit(1);
60
+ }
44
61
 
62
+ function runReview(args) {
45
63
  // Parse args
46
64
  let file = null;
47
65
  let diffRef = null;
@@ -61,6 +79,11 @@ function runReview(args) {
61
79
  }
62
80
  }
63
81
 
82
+ const enginePath = findReviewEngine();
83
+ if (!enginePath) {
84
+ printMissingReviewEngine(jsonMode);
85
+ }
86
+
64
87
  // Build command
65
88
  const cmdArgs = ['python3', enginePath];
66
89
  if (file) {
@@ -74,11 +97,12 @@ function runReview(args) {
74
97
 
75
98
  if (allMode) {
76
99
  // Audit all Python services
77
- console.log('Auditing all Python services...\n');
100
+ if (!jsonMode) console.log('Auditing all Python services...\n');
78
101
  const servicesDir = path.join(process.cwd(), 'backend', 'services');
79
102
  if (!fs.existsSync(servicesDir)) {
80
- console.error('No backend/services/ directory found.');
81
- process.exit(1);
103
+ exitReviewError('No backend/services/ directory found.', jsonMode, {
104
+ expected: 'backend/services/',
105
+ });
82
106
  }
83
107
 
84
108
  const files = fs.readdirSync(servicesDir).filter(f => f.endsWith('.py'));
@@ -107,6 +131,19 @@ function runReview(args) {
107
131
 
108
132
  issues.sort((a, b) => a.score - b.score);
109
133
 
134
+ if (jsonMode) {
135
+ console.log(JSON.stringify({
136
+ ok: true,
137
+ action: 'code_review_all',
138
+ services: files.length,
139
+ clean: cleanCount,
140
+ with_findings: issues.length,
141
+ total_findings: totalFindings,
142
+ issues,
143
+ }, null, 2));
144
+ return;
145
+ }
146
+
110
147
  console.log(`AUDIT: ${files.length} services | ${cleanCount} clean | ${issues.length} with findings\n`);
111
148
  if (issues.length > 0) {
112
149
  console.log(`${'Service'.padEnd(40)} ${'Score'.padStart(6)} ${'Findings'.padStart(8)} Top Issue`);
@@ -131,15 +168,16 @@ function runReview(args) {
131
168
 
132
169
  async function reviewCommand(...args) {
133
170
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
134
- console.log('Usage: atris review [file] [options]');
171
+ console.log('Usage: atris code-review [file] [options] (alias: atris cr)');
135
172
  console.log('');
136
- console.log(' atris review Review staged changes');
137
- console.log(' atris review <file.py> Review a specific file');
138
- console.log(' atris review --diff HEAD~1 Review last commit');
139
- console.log(' atris review --all Audit all backend services');
140
- console.log(' atris review --json Machine-readable output');
173
+ console.log(' atris code-review Review staged changes');
174
+ console.log(' atris code-review <file.py> Review a specific file');
175
+ console.log(' atris code-review --diff HEAD~1 Review last commit');
176
+ console.log(' atris code-review --all Audit all backend services');
177
+ console.log(' atris code-review --json Machine-readable output');
141
178
  console.log('');
142
179
  console.log('6 specialists: Security, Testing, Performance, Maintainability, Database, Async');
180
+ console.log('Note: this is distinct from `atris review` (the workflow validator step).');
143
181
  return;
144
182
  }
145
183