atris 3.14.0 → 3.15.11

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.
@@ -0,0 +1,263 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const NOW_PATH = path.join('atris', 'now.md');
5
+
6
+ function todayIso() {
7
+ return new Date().toISOString().split('T')[0];
8
+ }
9
+
10
+ function ensureAtrisDir(root = process.cwd()) {
11
+ const atrisDir = path.join(root, 'atris');
12
+ if (!fs.existsSync(atrisDir)) {
13
+ throw new Error('atris/ folder not found. Run "atris init" first.');
14
+ }
15
+ return atrisDir;
16
+ }
17
+
18
+ function hasWorkspaceMarkers(atrisDir) {
19
+ return fs.existsSync(path.join(atrisDir, 'MAP.md')) || fs.existsSync(path.join(atrisDir, 'TODO.md'));
20
+ }
21
+
22
+ function findChildWorkspaces(root = process.cwd()) {
23
+ if (!fs.existsSync(root)) return [];
24
+
25
+ return fs
26
+ .readdirSync(root, { withFileTypes: true })
27
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
28
+ .map((entry) => {
29
+ const workspaceRoot = path.join(root, entry.name);
30
+ const atrisDir = path.join(workspaceRoot, 'atris');
31
+ if (!fs.existsSync(atrisDir) || !hasWorkspaceMarkers(atrisDir)) return null;
32
+ const mapPath = path.join(atrisDir, 'MAP.md');
33
+ const todoPath = path.join(atrisDir, 'TODO.md');
34
+ return {
35
+ slug: entry.name,
36
+ root: workspaceRoot,
37
+ atrisDir,
38
+ mapPath,
39
+ todoPath,
40
+ nowPath: path.join(atrisDir, 'now.md'),
41
+ };
42
+ })
43
+ .filter(Boolean)
44
+ .sort((a, b) => a.slug.localeCompare(b.slug));
45
+ }
46
+
47
+ function readFirstHeading(filePath) {
48
+ if (!fs.existsSync(filePath)) return null;
49
+ const content = fs.readFileSync(filePath, 'utf8');
50
+ const line = content.split(/\r?\n/).find((l) => l.trim().startsWith('#'));
51
+ return line ? line.replace(/^#+\s*/, '').trim() : null;
52
+ }
53
+
54
+ function countMatches(filePath, pattern) {
55
+ if (!fs.existsSync(filePath)) return 0;
56
+ const content = fs.readFileSync(filePath, 'utf8');
57
+ return (content.match(pattern) || []).length;
58
+ }
59
+
60
+ function currentJournalPath(root = process.cwd()) {
61
+ const now = new Date();
62
+ const year = String(now.getFullYear());
63
+ const date = todayIso();
64
+ return path.join(root, 'atris', 'logs', year, `${date}.md`);
65
+ }
66
+
67
+ function renderDefaultNow(root = process.cwd()) {
68
+ const atrisDir = ensureAtrisDir(root);
69
+ const mapHeading = readFirstHeading(path.join(atrisDir, 'MAP.md')) || 'MAP not filled yet';
70
+ const todoPath = path.join(atrisDir, 'TODO.md');
71
+ const journalPath = currentJournalPath(root);
72
+ const backlogCount = countMatches(todoPath, /^-\s+\*\*.+?\*\*/gm);
73
+ const inboxCount = countMatches(journalPath, /^-\s+\*\*I\d+:/gm);
74
+ const completedCount = countMatches(journalPath, /^-\s+\*\*C\d+:/gm);
75
+ const generated = todayIso();
76
+
77
+ return `# now
78
+
79
+ > Current operating truth for this workspace.
80
+ > Read this first. Follow links only when needed.
81
+
82
+ Last updated: ${generated}
83
+
84
+ ## What Matters Now
85
+
86
+ - Decide the next useful move before opening more context.
87
+
88
+ ## Current Priority
89
+
90
+ - Keep the workspace coherent and useful for the next human or agent.
91
+
92
+ ## Signals
93
+
94
+ - Map: ${mapHeading}
95
+ - TODO items visible: ${backlogCount}
96
+ - Inbox items today: ${inboxCount}
97
+ - Completed receipts today: ${completedCount}
98
+
99
+ ## Watchouts
100
+
101
+ - Do not treat old logs as current truth unless this file links to them.
102
+ - Do not create motion for its own sake.
103
+ - If facts conflict, surface the conflict and cite the receipts.
104
+
105
+ ## Next Move
106
+
107
+ - Read \`atris/MAP.md\`, \`atris/TODO.md\`, and today's journal only as needed for the task in front of you.
108
+
109
+ ## Receipts
110
+
111
+ - \`atris/MAP.md\`
112
+ - \`atris/TODO.md\`
113
+ - \`${path.relative(root, journalPath)}\`
114
+ `;
115
+ }
116
+
117
+ function renderPortfolioNow(root = process.cwd()) {
118
+ const workspaces = findChildWorkspaces(root);
119
+ if (workspaces.length === 0) {
120
+ throw new Error('atris/ folder not found. Run "atris init" first.');
121
+ }
122
+
123
+ const generated = todayIso();
124
+ const lines = workspaces.map((workspace) => {
125
+ const heading = readFirstHeading(workspace.mapPath) || workspace.slug;
126
+ const todoCount = countMatches(workspace.todoPath, /^-\s+\*\*.+?\*\*/gm);
127
+ const nowState = fs.existsSync(workspace.nowPath) ? 'has now.md' : 'needs now.md';
128
+ return `- ${workspace.slug}: ${heading}; ${todoCount} visible TODO item${todoCount === 1 ? '' : 's'}; ${nowState}.`;
129
+ });
130
+
131
+ return `# now
132
+
133
+ > Current operating truth for this portfolio of Atris workspaces.
134
+ > Read this first. Then enter the specific workspace that matters.
135
+
136
+ Last updated: ${generated}
137
+
138
+ ## What Matters Now
139
+
140
+ - Keep the active business workspaces easy to scan, update, and hand off.
141
+
142
+ ## Current Priority
143
+
144
+ - Use the child workspace with the right slug; avoid creating duplicate business brains.
145
+
146
+ ## Workspace Signals
147
+
148
+ ${lines.join('\n')}
149
+
150
+ ## Watchouts
151
+
152
+ - Parent status is a map, not the source of truth for each business.
153
+ - Each active workspace should own its own \`atris/now.md\`.
154
+ - If slugs conflict, resolve the workspace identity before pushing or pulling.
155
+
156
+ ## Next Move
157
+
158
+ - Run \`atris now\` inside the workspace you are about to operate.
159
+
160
+ ## Receipts
161
+
162
+ ${workspaces.map((workspace) => `- \`${workspace.slug}/atris/MAP.md\``).join('\n')}
163
+ `;
164
+ }
165
+
166
+ function ensureNowFile(root = process.cwd()) {
167
+ let atrisDir = path.join(root, 'atris');
168
+ const isWorkspace = fs.existsSync(atrisDir) && hasWorkspaceMarkers(atrisDir);
169
+ const childWorkspaces = isWorkspace ? [] : findChildWorkspaces(root);
170
+ if (!isWorkspace && childWorkspaces.length === 0) {
171
+ ensureAtrisDir(root);
172
+ }
173
+ if (!isWorkspace && childWorkspaces.length > 0) {
174
+ fs.mkdirSync(atrisDir, { recursive: true });
175
+ }
176
+ const nowPath = path.join(atrisDir, 'now.md');
177
+ if (!fs.existsSync(nowPath)) {
178
+ const content = isWorkspace ? renderDefaultNow(root) : renderPortfolioNow(root);
179
+ fs.writeFileSync(nowPath, content, 'utf8');
180
+ return { created: true, path: nowPath };
181
+ }
182
+ return { created: false, path: nowPath };
183
+ }
184
+
185
+ function refreshNowFile(root = process.cwd()) {
186
+ const atrisDir = path.join(root, 'atris');
187
+ const isWorkspace = fs.existsSync(atrisDir) && hasWorkspaceMarkers(atrisDir);
188
+ const childWorkspaces = isWorkspace ? [] : findChildWorkspaces(root);
189
+ if (!isWorkspace && childWorkspaces.length === 0) {
190
+ ensureAtrisDir(root);
191
+ }
192
+ if (!isWorkspace && childWorkspaces.length > 0) {
193
+ fs.mkdirSync(atrisDir, { recursive: true });
194
+ }
195
+ const nowPath = path.join(atrisDir, 'now.md');
196
+ const content = isWorkspace ? renderDefaultNow(root) : renderPortfolioNow(root);
197
+ fs.writeFileSync(nowPath, content, 'utf8');
198
+ return { path: nowPath };
199
+ }
200
+
201
+ function nowAtris(args = process.argv.slice(3), root = process.cwd()) {
202
+ const help = args.includes('--help') || args.includes('-h');
203
+ if (help) {
204
+ console.log('Usage: atris now [--init|--refresh|--all|--path]');
205
+ console.log('');
206
+ console.log('Show the current operating truth for this workspace.');
207
+ console.log('');
208
+ console.log(' atris now Show atris/now.md');
209
+ console.log(' atris now --init Create atris/now.md if missing');
210
+ console.log(' atris now --refresh Regenerate a small local now.md');
211
+ console.log(' atris now --all Refresh this parent and every child Atris workspace');
212
+ console.log(' atris now --path Print the file path only');
213
+ return;
214
+ }
215
+
216
+ const init = args.includes('--init');
217
+ const refresh = args.includes('--refresh');
218
+ const all = args.includes('--all');
219
+ const pathOnly = args.includes('--path');
220
+
221
+ let result;
222
+ if (all) {
223
+ const workspaces = findChildWorkspaces(root);
224
+ for (const workspace of workspaces) {
225
+ refreshNowFile(workspace.root);
226
+ }
227
+ result = refreshNowFile(root);
228
+ if (!pathOnly) {
229
+ console.log(`Refreshed ${workspaces.length} child workspace${workspaces.length === 1 ? '' : 's'}.`);
230
+ console.log('');
231
+ }
232
+ } else if (refresh) {
233
+ result = refreshNowFile(root);
234
+ } else if (init) {
235
+ result = ensureNowFile(root);
236
+ } else {
237
+ result = ensureNowFile(root);
238
+ }
239
+
240
+ const rel = path.relative(root, result.path);
241
+ if (pathOnly) {
242
+ console.log(rel);
243
+ return;
244
+ }
245
+
246
+ if (result.created) {
247
+ console.log(`Created ${rel}`);
248
+ console.log('');
249
+ }
250
+
251
+ const content = fs.readFileSync(result.path, 'utf8').trimEnd();
252
+ console.log(content);
253
+ }
254
+
255
+ module.exports = {
256
+ NOW_PATH,
257
+ ensureNowFile,
258
+ findChildWorkspaces,
259
+ nowAtris,
260
+ refreshNowFile,
261
+ renderDefaultNow,
262
+ renderPortfolioNow,
263
+ };
package/commands/pull.js CHANGED
@@ -11,6 +11,13 @@ const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocal
11
11
  const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
12
12
  const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
13
13
  const { resolveSafeOutputDir } = require('../lib/workspace-safety');
14
+ const {
15
+ buildConflictReviewPacket,
16
+ readBaseContent,
17
+ removeBaseContents,
18
+ writeBaseContents,
19
+ writeConflictReviewPacket,
20
+ } = require('../lib/company-brain-sync');
14
21
 
15
22
  function pruneEmptyParentDirs(filePath, stopDir) {
16
23
  let current = path.dirname(filePath);
@@ -26,11 +33,50 @@ function pruneEmptyParentDirs(filePath, stopDir) {
26
33
  }
27
34
  }
28
35
 
36
+ function isBusinessWorkspaceRoot(dir) {
37
+ return fs.existsSync(path.join(dir, '.atris', 'business.json')) && fs.existsSync(path.join(dir, 'atris'));
38
+ }
39
+
40
+ function syncTimestamp() {
41
+ return new Date().toISOString().replace(/[:.]/g, '-');
42
+ }
43
+
44
+ function buildPullConflictReviewPacket(outputDir, conflictChanges, remoteContents = {}, timestamp = syncTimestamp()) {
45
+ const baseContents = {};
46
+ const localContents = {};
47
+ const normalizedRemoteContents = {};
48
+
49
+ for (const change of conflictChanges) {
50
+ const p = change.path;
51
+ const localPath = path.join(outputDir, p.replace(/^\//, ''));
52
+ try {
53
+ localContents[p] = fs.readFileSync(localPath, 'utf8');
54
+ } catch {
55
+ localContents[p] = '';
56
+ }
57
+
58
+ const baseContent = readBaseContent(outputDir, p);
59
+ if (baseContent !== null) baseContents[p] = baseContent;
60
+
61
+ normalizedRemoteContents[p] = Object.prototype.hasOwnProperty.call(remoteContents, p)
62
+ ? remoteContents[p]
63
+ : '';
64
+ }
65
+
66
+ return buildConflictReviewPacket({
67
+ plan: { changes: conflictChanges },
68
+ baseContents,
69
+ localContents,
70
+ remoteContents: normalizedRemoteContents,
71
+ timestamp,
72
+ });
73
+ }
74
+
29
75
  async function pullAtris() {
30
76
  let arg = process.argv[3];
31
77
 
32
78
  if (arg === '--help') {
33
- console.log('Usage: atris pull [business] [--into <path>] [--only <prefix>] [--keep-local] [--timeout <seconds>]');
79
+ console.log('Usage: atris pull [business] [--into <path>] [--only <prefix>] [--keep-local] [--timeout <seconds>] [--dry-run]');
34
80
  console.log('');
35
81
  console.log(' Pull is force-overwrite by default. Cloud is the source of truth.');
36
82
  console.log(' Local files that conflict with cloud are replaced by the cloud version.');
@@ -40,6 +86,7 @@ async function pullAtris() {
40
86
  console.log(' atris pull doordash --into /tmp/doordash');
41
87
  console.log(' atris pull doordash --only atris/wiki/');
42
88
  console.log(' atris pull --keep-local Preserve conflicting local edits as .remote files (legacy)');
89
+ console.log(' atris pull --dry-run Preview pull changes without writing local files');
43
90
  return;
44
91
  }
45
92
 
@@ -127,6 +174,8 @@ async function pullBusiness(slug) {
127
174
  // --keep-local opts back into the legacy three-way merge with .remote conflict files.
128
175
  // --force is still accepted as an alias for the default for muscle-memory.
129
176
  const force = !process.argv.includes('--keep-local');
177
+ const failOnConflict = process.argv.includes('--fail-on-conflict');
178
+ const dryRun = process.argv.includes('--dry-run');
130
179
 
131
180
  // Parse --only flag: comma-separated directory prefixes to filter
132
181
  // Supports both --only=team/,context/ and --only team/,context/
@@ -140,7 +189,7 @@ async function pullBusiness(slug) {
140
189
  onlyRaw = process.argv[onlyIdx + 1];
141
190
  }
142
191
  }
143
- const onlyPrefixes = onlyRaw
192
+ let onlyPrefixes = onlyRaw
144
193
  ? onlyRaw.split(',').map(p => {
145
194
  let norm = p.replace(/^\//, '');
146
195
  const wikiPrefix = normalizeWikiOnlyPrefix(norm);
@@ -202,6 +251,10 @@ async function pullBusiness(slug) {
202
251
  // for a stray cwd to cause atris to delete user files.
203
252
  ({ dir: outputDir } = resolveSafeOutputDir(outputDir, { slug, op: 'pull into' }));
204
253
 
254
+ if (!onlyPrefixes && isBusinessWorkspaceRoot(outputDir)) {
255
+ onlyPrefixes = ['atris/'];
256
+ }
257
+
205
258
  // Resolve business ID — always refresh from API to avoid stale workspace_id
206
259
  let businessId, workspaceId, businessName, resolvedSlug;
207
260
  let localSlug = slug;
@@ -283,6 +336,14 @@ async function pullBusiness(slug) {
283
336
  // Load manifest (last sync state)
284
337
  const manifest = loadManifest(resolvedSlug || slug);
285
338
  const timeSince = manifest ? _timeSince(manifest.last_sync) : null;
339
+ const localFilesBeforePull = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
340
+ const manifestRootMatchesOutput = !manifest || !manifest.workspace_root || (() => {
341
+ try {
342
+ return fs.realpathSync(manifest.workspace_root) === fs.realpathSync(outputDir);
343
+ } catch {
344
+ return path.resolve(manifest.workspace_root || '') === path.resolve(outputDir);
345
+ }
346
+ })();
286
347
 
287
348
  console.log('');
288
349
  console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
@@ -297,7 +358,12 @@ async function pullBusiness(slug) {
297
358
  }, 250);
298
359
 
299
360
  // Smart pull: if we have a manifest (not first sync), fetch hashes first, then only changed content
300
- const hasManifest = manifest && manifest.files && Object.keys(manifest.files).length > 0 && !force;
361
+ const hasManifest = manifest
362
+ && manifest.files
363
+ && Object.keys(manifest.files).length > 0
364
+ && Object.keys(localFilesBeforePull).length > 0
365
+ && manifestRootMatchesOutput
366
+ && !force;
301
367
  let result;
302
368
 
303
369
  const pathsParam = onlyPrefixes ? `&paths=${encodeURIComponent(onlyPrefixes.map(p => p.replace(/\/$/, '')).join(','))}` : '';
@@ -483,7 +549,7 @@ async function pullBusiness(slug) {
483
549
  }
484
550
 
485
551
  // Compute local file hashes
486
- const localFiles = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
552
+ const localFiles = localFilesBeforePull;
487
553
 
488
554
  // If output dir is empty (fresh clone) or --force, treat as first sync — pull everything
489
555
  const effectiveManifest = (Object.keys(localFiles).length === 0 || force) ? null : manifest;
@@ -491,11 +557,38 @@ async function pullBusiness(slug) {
491
557
  // Three-way compare
492
558
  const diff = threeWayCompare(localFiles, remoteFiles, effectiveManifest);
493
559
 
560
+ if (dryRun) {
561
+ console.log('');
562
+ for (const p of [...diff.toPull, ...diff.newRemote]) {
563
+ const label = diff.newRemote.includes(p) ? 'new on computer' : 'updated on computer';
564
+ const icon = diff.newRemote.includes(p) ? '+' : '\u2193';
565
+ console.log(` ${icon} ${p.replace(/^\//, '')} ${label} (dry run)`);
566
+ }
567
+ for (const p of diff.conflicts) {
568
+ console.log(` ! ${p.replace(/^\//, '')} conflict (dry run)`);
569
+ }
570
+ for (const p of diff.deletedRemote) {
571
+ console.log(` - ${p.replace(/^\//, '')} deleted on computer (dry run)`);
572
+ }
573
+
574
+ const parts = [];
575
+ const pullCount = diff.toPull.length + diff.newRemote.length;
576
+ if (pullCount > 0) parts.push(`${pullCount} would be pulled`);
577
+ if (diff.deletedRemote.length > 0) parts.push(`${diff.deletedRemote.length} local file${diff.deletedRemote.length === 1 ? '' : 's'} would be removed`);
578
+ if (diff.unchanged.length > 0) parts.push(`${diff.unchanged.length} unchanged`);
579
+ if (diff.conflicts.length > 0) parts.push(`${diff.conflicts.length} conflict${diff.conflicts.length === 1 ? '' : 's'}`);
580
+ if (parts.length === 0) parts.push('no changes');
581
+ console.log(`\n ${parts.join(', ')}. (--dry-run, nothing written)\n`);
582
+ return;
583
+ }
584
+
494
585
  // Apply changes
495
586
  let pulled = 0;
496
587
  let deleted = 0;
497
588
  let conflictCount = 0;
498
589
  let unchangedCount = diff.unchanged.length;
590
+ const conflictChanges = [];
591
+ const conflictRemoteContents = {};
499
592
 
500
593
  console.log('');
501
594
 
@@ -526,6 +619,8 @@ async function pullBusiness(slug) {
526
619
  } else {
527
620
  // Save remote version alongside local
528
621
  const content = remoteContent[p];
622
+ conflictRemoteContents[p] = content || '';
623
+ conflictChanges.push({ path: p, status: 'conflict_updated', action: 'review' });
529
624
  if (content || content === '') {
530
625
  const localPath = path.join(outputDir, p.replace(/^\//, '') + '.remote');
531
626
  fs.mkdirSync(path.dirname(localPath), { recursive: true });
@@ -553,6 +648,8 @@ async function pullBusiness(slug) {
553
648
  deleted++;
554
649
  } else {
555
650
  console.log(` \u26A0 ${p.replace(/^\//, '')} deleted on computer, but you changed it locally`);
651
+ conflictRemoteContents[p] = '';
652
+ conflictChanges.push({ path: p, status: 'conflict_remote_deleted_local_updated', action: 'review' });
556
653
  conflictCount++;
557
654
  }
558
655
  }
@@ -640,6 +737,22 @@ async function pullBusiness(slug) {
640
737
  if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
641
738
  if (conflictCount > 0) parts.push(`${conflictCount} conflict${conflictCount > 1 ? 's' : ''}`);
642
739
  if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
740
+ if (failOnConflict && conflictCount > 0) {
741
+ const timestamp = syncTimestamp();
742
+ const packet = buildPullConflictReviewPacket(outputDir, conflictChanges, conflictRemoteContents, timestamp);
743
+ writeConflictReviewPacket(outputDir, packet);
744
+ console.log('');
745
+ console.log(` Sync paused: ${conflictCount} conflict${conflictCount === 1 ? '' : 's'} need review before publishing.`);
746
+ console.log(` Review packet: .atris/sync/conflicts/${timestamp}/summary.md`);
747
+ console.log(' Resolve the conflict, then run sync again.');
748
+ process.exit(2);
749
+ }
750
+ if (remoteFiles['atris/now.md'] || remoteFiles['/atris/now.md']) {
751
+ const nowLocal = path.join(outputDir, 'atris', 'now.md');
752
+ if (fs.existsSync(nowLocal)) {
753
+ console.log(' now: atris/now.md is current.');
754
+ }
755
+ }
643
756
 
644
757
  // Get current commit hash from remote (for manifest)
645
758
  let commitHash = null;
@@ -693,8 +806,10 @@ async function pullBusiness(slug) {
693
806
  }
694
807
  manifestFiles = merged;
695
808
  }
696
- const newManifest = buildManifest(manifestFiles, commitHash);
809
+ const newManifest = buildManifest(manifestFiles, commitHash, { workspaceRoot: outputDir });
697
810
  saveManifest(resolvedSlug || slug, newManifest);
811
+ writeBaseContents(outputDir, remoteContent);
812
+ removeBaseContents(outputDir, diff.deletedRemote);
698
813
 
699
814
  // Save business config in the output dir so push/status work without args
700
815
  const atrisDir = path.join(outputDir, '.atris');
@@ -901,4 +1016,4 @@ async function pullMemberJournal(token, agentId, memberName, memberDir) {
901
1016
  return synced;
902
1017
  }
903
1018
 
904
- module.exports = { pullAtris };
1019
+ module.exports = { buildPullConflictReviewPacket, pullAtris };