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.
- package/AGENTS.md +24 -4
- package/README.md +4 -3
- package/atris/atris.md +38 -13
- package/atris/features/company-brain-sync/build.md +140 -0
- package/atris/features/company-brain-sync/idea.md +52 -0
- package/atris/features/company-brain-sync/validate.md +229 -0
- package/atris/skills/imessage/SKILL.md +44 -0
- package/bin/atris.js +56 -6
- package/commands/aeo.js +197 -0
- package/commands/align.js +1 -1
- package/commands/brain.js +840 -0
- package/commands/business-sync.js +716 -0
- package/commands/init.js +15 -3
- package/commands/integrations.js +128 -0
- package/commands/live.js +311 -0
- package/commands/now.js +263 -0
- package/commands/pull.js +121 -6
- package/commands/push.js +146 -48
- package/commands/task.js +1658 -18
- package/lib/company-brain-sync.js +178 -0
- package/lib/manifest.js +2 -1
- package/lib/task-db.js +271 -4
- package/package.json +12 -2
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { loadManifest } = require('../lib/manifest');
|
|
5
|
+
|
|
6
|
+
const WATCH_IGNORED_DIRS = new Set(['.git', '.atris', '.claude', 'node_modules', '__pycache__']);
|
|
7
|
+
const WATCH_IGNORED_FILES = new Set(['.DS_Store']);
|
|
8
|
+
|
|
9
|
+
function commandLine(args) {
|
|
10
|
+
return ['atris', ...args].join(' ');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseFlagValue(args, name, fallback) {
|
|
14
|
+
const eq = args.find((arg) => arg.startsWith(`${name}=`));
|
|
15
|
+
if (eq) return eq.slice(name.length + 1);
|
|
16
|
+
const idx = args.indexOf(name);
|
|
17
|
+
if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('-')) return args[idx + 1];
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseBusinessSyncArgs(args = []) {
|
|
22
|
+
const positional = args.filter((arg) => arg && !arg.startsWith('-'));
|
|
23
|
+
const status = args.includes('--status') || positional[0] === 'status' || positional[0] === 'doctor';
|
|
24
|
+
const review = args.includes('--review') || positional[0] === 'review';
|
|
25
|
+
const resolveIdx = positional.indexOf('resolve');
|
|
26
|
+
const resolveFlag = parseFlagValue(args, '--resolve', null);
|
|
27
|
+
const resolve = resolveFlag || (resolveIdx !== -1 ? positional[resolveIdx + 1] : null);
|
|
28
|
+
const commandWords = new Set(['status', 'doctor', 'review', 'resolve', 'local', 'cloud', 'both', 'merge']);
|
|
29
|
+
const slug = positional.find((arg) => !commandWords.has(arg)) || null;
|
|
30
|
+
const dryRun = args.includes('--dry-run');
|
|
31
|
+
const timeout = parseFlagValue(args, '--timeout', '120');
|
|
32
|
+
const allowDelete = args.includes('--delete');
|
|
33
|
+
const watch = args.includes('--watch');
|
|
34
|
+
const intervalSec = Number.parseInt(parseFlagValue(args, '--interval', '60'), 10);
|
|
35
|
+
const debounceSec = Number.parseInt(parseFlagValue(args, '--debounce', '5'), 10);
|
|
36
|
+
|
|
37
|
+
return { slug, dryRun, timeout, allowDelete, watch, intervalSec, debounceSec, status, review, resolve };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readBusinessSlug(cwd = process.cwd()) {
|
|
41
|
+
const bizFile = path.join(cwd, '.atris', 'business.json');
|
|
42
|
+
if (!fs.existsSync(bizFile)) return null;
|
|
43
|
+
try {
|
|
44
|
+
const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
|
|
45
|
+
return biz.slug || biz.name || null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveBusinessSyncOptions(args = [], cwd = process.cwd()) {
|
|
52
|
+
const options = parseBusinessSyncArgs(args);
|
|
53
|
+
if (!options.slug) options.slug = readBusinessSlug(cwd);
|
|
54
|
+
return options;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildBusinessSyncPlan(options) {
|
|
58
|
+
if (!options.slug) return null;
|
|
59
|
+
|
|
60
|
+
const pullArgs = ['pull', options.slug, '--keep-local', '--fail-on-conflict', '--timeout', String(options.timeout)];
|
|
61
|
+
const pushArgs = ['push', options.slug];
|
|
62
|
+
if (options.dryRun) {
|
|
63
|
+
pullArgs.push('--dry-run');
|
|
64
|
+
pushArgs.push('--dry-run');
|
|
65
|
+
}
|
|
66
|
+
if (options.allowDelete) pushArgs.push('--delete');
|
|
67
|
+
|
|
68
|
+
return { pullArgs, pushArgs };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function shouldIgnoreWatchPath(relativePath) {
|
|
72
|
+
if (!relativePath) return true;
|
|
73
|
+
const parts = relativePath.split(path.sep);
|
|
74
|
+
if (parts.some((part) => WATCH_IGNORED_DIRS.has(part))) return true;
|
|
75
|
+
return WATCH_IGNORED_FILES.has(path.basename(relativePath));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function collectBrainSnapshot(root) {
|
|
79
|
+
const brainDir = path.join(root, 'atris');
|
|
80
|
+
const snapshot = new Map();
|
|
81
|
+
|
|
82
|
+
function walk(dir) {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const full = path.join(dir, entry.name);
|
|
92
|
+
const rel = path.relative(brainDir, full);
|
|
93
|
+
if (shouldIgnoreWatchPath(rel)) continue;
|
|
94
|
+
if (entry.isDirectory()) {
|
|
95
|
+
walk(full);
|
|
96
|
+
} else if (entry.isFile()) {
|
|
97
|
+
try {
|
|
98
|
+
const stat = fs.statSync(full);
|
|
99
|
+
snapshot.set(rel, `${stat.size}:${Math.floor(stat.mtimeMs)}`);
|
|
100
|
+
} catch {
|
|
101
|
+
// Files can move while the editor is saving.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
walk(brainDir);
|
|
108
|
+
return snapshot;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function snapshotsDiffer(before, after) {
|
|
112
|
+
if (before.size !== after.size) return true;
|
|
113
|
+
for (const [key, value] of before.entries()) {
|
|
114
|
+
if (after.get(key) !== value) return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sameRealPath(a, b) {
|
|
120
|
+
try {
|
|
121
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
122
|
+
} catch {
|
|
123
|
+
return path.resolve(a || '') === path.resolve(b || '');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function canPreviewPush(cwd, slug) {
|
|
128
|
+
const manifest = loadManifest(slug);
|
|
129
|
+
if (!manifest || !manifest.workspace_root) return true;
|
|
130
|
+
return sameRealPath(manifest.workspace_root, cwd);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function syncStatusPath(cwd) {
|
|
134
|
+
return path.join(cwd, '.atris', 'sync', 'status.json');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readJsonFile(filePath) {
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function writeSyncStatus(cwd, payload = {}) {
|
|
146
|
+
const statusPath = syncStatusPath(cwd);
|
|
147
|
+
fs.mkdirSync(path.dirname(statusPath), { recursive: true });
|
|
148
|
+
fs.writeFileSync(statusPath, JSON.stringify({
|
|
149
|
+
schema: 'atris.company_brain_sync.status.v1',
|
|
150
|
+
updated_at: new Date().toISOString(),
|
|
151
|
+
...payload,
|
|
152
|
+
}, null, 2) + '\n', 'utf8');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function countBrainFiles(cwd) {
|
|
156
|
+
const brainDir = path.join(cwd, 'atris');
|
|
157
|
+
let count = 0;
|
|
158
|
+
|
|
159
|
+
function walk(dir) {
|
|
160
|
+
let entries;
|
|
161
|
+
try {
|
|
162
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
const full = path.join(dir, entry.name);
|
|
168
|
+
const rel = path.relative(brainDir, full);
|
|
169
|
+
if (shouldIgnoreWatchPath(rel)) continue;
|
|
170
|
+
if (entry.isDirectory()) walk(full);
|
|
171
|
+
else if (entry.isFile()) count += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
walk(brainDir);
|
|
176
|
+
return count;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function listConflictSummaries(cwd) {
|
|
180
|
+
const conflictsDir = path.join(cwd, '.atris', 'sync', 'conflicts');
|
|
181
|
+
const summaries = [];
|
|
182
|
+
|
|
183
|
+
function walk(dir) {
|
|
184
|
+
let entries;
|
|
185
|
+
try {
|
|
186
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
187
|
+
} catch {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
const full = path.join(dir, entry.name);
|
|
192
|
+
if (entry.isDirectory()) walk(full);
|
|
193
|
+
else if (entry.isFile() && entry.name === 'summary.md') {
|
|
194
|
+
summaries.push(path.relative(cwd, full).replace(/\\/g, '/'));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
walk(conflictsDir);
|
|
200
|
+
return summaries.sort();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collectLocalSyncStatus(cwd = process.cwd(), options = {}) {
|
|
204
|
+
const slug = options.slug || readBusinessSlug(cwd);
|
|
205
|
+
const manifest = slug ? loadManifest(slug) : null;
|
|
206
|
+
const heartbeat = readJsonFile(syncStatusPath(cwd));
|
|
207
|
+
const conflictSummaries = listConflictSummaries(cwd);
|
|
208
|
+
const brainDir = path.join(cwd, 'atris');
|
|
209
|
+
const manifestRootMatches = !manifest || !manifest.workspace_root || sameRealPath(manifest.workspace_root, cwd);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
slug,
|
|
213
|
+
cwd,
|
|
214
|
+
brainDir,
|
|
215
|
+
brainExists: fs.existsSync(brainDir),
|
|
216
|
+
brainFileCount: countBrainFiles(cwd),
|
|
217
|
+
conflictCount: conflictSummaries.length,
|
|
218
|
+
latestConflict: conflictSummaries[conflictSummaries.length - 1] || null,
|
|
219
|
+
lastSync: manifest && manifest.last_sync ? manifest.last_sync : null,
|
|
220
|
+
manifestRoot: manifest && manifest.workspace_root ? manifest.workspace_root : null,
|
|
221
|
+
manifestRootMatches,
|
|
222
|
+
heartbeat,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderLocalSyncStatus(status) {
|
|
227
|
+
const lines = [];
|
|
228
|
+
const fileLabel = status.brainFileCount === 1 ? 'file' : 'files';
|
|
229
|
+
lines.push('Company brain status');
|
|
230
|
+
lines.push(` business: ${status.slug || 'not detected'}`);
|
|
231
|
+
lines.push(` folder: ${status.cwd}`);
|
|
232
|
+
lines.push(` brain: ${status.brainExists ? `atris/ (${status.brainFileCount} ${fileLabel})` : 'missing atris/'}`);
|
|
233
|
+
lines.push(` last cloud sync: ${status.lastSync || 'never on this machine'}`);
|
|
234
|
+
if (status.manifestRoot && !status.manifestRootMatches) {
|
|
235
|
+
lines.push(` manifest: from another folder (${status.manifestRoot})`);
|
|
236
|
+
} else {
|
|
237
|
+
lines.push(` manifest: ${status.manifestRoot ? 'matches this folder' : 'not created yet'}`);
|
|
238
|
+
}
|
|
239
|
+
if (status.conflictCount > 0) {
|
|
240
|
+
lines.push(` conflicts: ${status.conflictCount} review packet${status.conflictCount === 1 ? '' : 's'}`);
|
|
241
|
+
lines.push(` latest: ${status.latestConflict}`);
|
|
242
|
+
} else {
|
|
243
|
+
lines.push(' conflicts: none');
|
|
244
|
+
}
|
|
245
|
+
if (status.heartbeat && status.heartbeat.updated_at) {
|
|
246
|
+
lines.push(` watcher: last heartbeat ${status.heartbeat.updated_at} (${status.heartbeat.state || 'unknown'})`);
|
|
247
|
+
} else {
|
|
248
|
+
lines.push(' watcher: no heartbeat yet');
|
|
249
|
+
}
|
|
250
|
+
lines.push('');
|
|
251
|
+
lines.push('Next: run `atris sync --dry-run` to preview, or `atris sync --watch` to keep this brain live.');
|
|
252
|
+
return `${lines.join('\n')}\n`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderLatestConflictReview(cwd = process.cwd()) {
|
|
256
|
+
const summaries = listConflictSummaries(cwd);
|
|
257
|
+
if (summaries.length === 0) {
|
|
258
|
+
return 'No sync conflicts need review.\n';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const latest = summaries[summaries.length - 1];
|
|
262
|
+
const fullPath = path.join(cwd, latest);
|
|
263
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
264
|
+
return [
|
|
265
|
+
`Latest sync conflict review: ${latest}`,
|
|
266
|
+
'',
|
|
267
|
+
content.trimEnd(),
|
|
268
|
+
'',
|
|
269
|
+
'Resolve the local file, then run `atris sync --dry-run` before publishing.',
|
|
270
|
+
'',
|
|
271
|
+
].join('\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function latestConflictDir(cwd = process.cwd()) {
|
|
275
|
+
const summaries = listConflictSummaries(cwd);
|
|
276
|
+
if (summaries.length === 0) return null;
|
|
277
|
+
return path.dirname(path.join(cwd, summaries[summaries.length - 1]));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function collectConflictResolutionEntries(cwd = process.cwd()) {
|
|
281
|
+
const dir = latestConflictDir(cwd);
|
|
282
|
+
if (!dir) return [];
|
|
283
|
+
const entries = [];
|
|
284
|
+
|
|
285
|
+
function walk(current) {
|
|
286
|
+
let items;
|
|
287
|
+
try {
|
|
288
|
+
items = fs.readdirSync(current, { withFileTypes: true });
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
for (const item of items) {
|
|
293
|
+
const full = path.join(current, item.name);
|
|
294
|
+
if (item.isDirectory()) {
|
|
295
|
+
walk(full);
|
|
296
|
+
} else if (item.isFile() && item.name.endsWith('.local')) {
|
|
297
|
+
const localPath = full;
|
|
298
|
+
const remotePath = full.replace(/\.local$/, '.remote');
|
|
299
|
+
const targetRel = path.relative(dir, full).replace(/\\/g, '/').replace(/\.local$/, '');
|
|
300
|
+
if (!targetRel.startsWith('atris/')) continue;
|
|
301
|
+
entries.push({
|
|
302
|
+
targetRel,
|
|
303
|
+
basePath: full.replace(/\.local$/, '.base'),
|
|
304
|
+
localPath,
|
|
305
|
+
remotePath,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
walk(dir);
|
|
312
|
+
return entries.sort((a, b) => a.targetRel.localeCompare(b.targetRel));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function assertWorkspaceTarget(cwd, targetRel) {
|
|
316
|
+
const targetPath = path.resolve(cwd, targetRel);
|
|
317
|
+
const root = path.resolve(cwd);
|
|
318
|
+
if (!targetPath.startsWith(`${root}${path.sep}`)) {
|
|
319
|
+
throw new Error(`Refusing to resolve outside workspace: ${targetRel}`);
|
|
320
|
+
}
|
|
321
|
+
return targetPath;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function changedRange(baseLines, changedLines) {
|
|
325
|
+
let start = 0;
|
|
326
|
+
while (start < baseLines.length && start < changedLines.length && baseLines[start] === changedLines[start]) {
|
|
327
|
+
start += 1;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let baseEnd = baseLines.length;
|
|
331
|
+
let changedEnd = changedLines.length;
|
|
332
|
+
while (
|
|
333
|
+
baseEnd > start
|
|
334
|
+
&& changedEnd > start
|
|
335
|
+
&& baseLines[baseEnd - 1] === changedLines[changedEnd - 1]
|
|
336
|
+
) {
|
|
337
|
+
baseEnd -= 1;
|
|
338
|
+
changedEnd -= 1;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
start,
|
|
343
|
+
end: baseEnd,
|
|
344
|
+
replacement: changedLines.slice(start, changedEnd),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function safeLineMerge(baseContent, localContent, remoteContent) {
|
|
349
|
+
if (localContent === remoteContent) return { ok: true, content: localContent };
|
|
350
|
+
if (localContent === baseContent) return { ok: true, content: remoteContent };
|
|
351
|
+
if (remoteContent === baseContent) return { ok: true, content: localContent };
|
|
352
|
+
|
|
353
|
+
const baseLines = baseContent.split('\n');
|
|
354
|
+
const localRange = changedRange(baseLines, localContent.split('\n'));
|
|
355
|
+
const remoteRange = changedRange(baseLines, remoteContent.split('\n'));
|
|
356
|
+
const sameRange = localRange.start === remoteRange.start && localRange.end === remoteRange.end;
|
|
357
|
+
const sameReplacement = localRange.replacement.join('\n') === remoteRange.replacement.join('\n');
|
|
358
|
+
if (sameRange && sameReplacement) return { ok: true, content: localContent };
|
|
359
|
+
|
|
360
|
+
const overlaps = localRange.start < remoteRange.end && remoteRange.start < localRange.end;
|
|
361
|
+
const sameInsertionPoint = localRange.start === localRange.end
|
|
362
|
+
&& remoteRange.start === remoteRange.end
|
|
363
|
+
&& localRange.start === remoteRange.start;
|
|
364
|
+
if (overlaps || sameInsertionPoint) {
|
|
365
|
+
return { ok: false, reason: 'local and cloud edits overlap' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const merged = baseLines.slice();
|
|
369
|
+
for (const range of [localRange, remoteRange].sort((a, b) => b.start - a.start)) {
|
|
370
|
+
merged.splice(range.start, range.end - range.start, ...range.replacement);
|
|
371
|
+
}
|
|
372
|
+
return { ok: true, content: merged.join('\n') };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function markdownSectionRanges(content) {
|
|
376
|
+
const lines = content.split('\n');
|
|
377
|
+
const ranges = [];
|
|
378
|
+
const headingPattern = /^(#{1,6})\s+(.+?)\s*$/;
|
|
379
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
380
|
+
const match = lines[i].match(headingPattern);
|
|
381
|
+
if (!match) continue;
|
|
382
|
+
const level = match[1].length;
|
|
383
|
+
let end = lines.length;
|
|
384
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
385
|
+
const next = lines[j].match(headingPattern);
|
|
386
|
+
if (next && next[1].length <= level) {
|
|
387
|
+
end = j;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
ranges.push({
|
|
392
|
+
key: lines[i].trim().toLowerCase(),
|
|
393
|
+
level,
|
|
394
|
+
start: i,
|
|
395
|
+
end,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return ranges;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function changedMarkdownSections(baseContent, changedContent) {
|
|
402
|
+
const baseLines = baseContent.split('\n');
|
|
403
|
+
const changedLines = changedContent.split('\n');
|
|
404
|
+
const range = changedRange(baseLines, changedLines);
|
|
405
|
+
if (range.start === range.end && range.replacement.length === 0) return new Set();
|
|
406
|
+
const touchedStart = range.start;
|
|
407
|
+
const touchedEnd = Math.max(range.end, range.start + 1);
|
|
408
|
+
const touched = markdownSectionRanges(baseContent)
|
|
409
|
+
.filter((section) => section.start < touchedEnd && touchedStart < section.end);
|
|
410
|
+
const deepestLevel = touched.reduce((max, section) => Math.max(max, section.level), 0);
|
|
411
|
+
const sections = touched
|
|
412
|
+
.filter((section) => section.level === deepestLevel)
|
|
413
|
+
.map((section) => section.key);
|
|
414
|
+
return new Set(sections.length ? sections : ['__preamble__']);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function replaceMarkdownSection(content, sectionKey, replacementContent) {
|
|
418
|
+
const lines = content.split('\n');
|
|
419
|
+
const ranges = markdownSectionRanges(content);
|
|
420
|
+
const range = ranges.find((section) => section.key === sectionKey);
|
|
421
|
+
if (!range) return null;
|
|
422
|
+
const replacementRange = markdownSectionRanges(replacementContent)
|
|
423
|
+
.find((section) => section.key === sectionKey);
|
|
424
|
+
if (!replacementRange) return null;
|
|
425
|
+
const replacementLines = replacementContent.split('\n').slice(replacementRange.start, replacementRange.end);
|
|
426
|
+
const merged = lines.slice();
|
|
427
|
+
merged.splice(range.start, range.end - range.start, ...replacementLines);
|
|
428
|
+
return merged.join('\n');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function safeMarkdownMerge(baseContent, localContent, remoteContent) {
|
|
432
|
+
const lineMerge = safeLineMerge(baseContent, localContent, remoteContent);
|
|
433
|
+
if (lineMerge.ok) return { ...lineMerge, mode: 'line' };
|
|
434
|
+
|
|
435
|
+
const localSections = changedMarkdownSections(baseContent, localContent);
|
|
436
|
+
const remoteSections = changedMarkdownSections(baseContent, remoteContent);
|
|
437
|
+
if (localSections.has('__preamble__') || remoteSections.has('__preamble__')) {
|
|
438
|
+
return { ok: false, reason: 'local and cloud edits overlap outside markdown sections' };
|
|
439
|
+
}
|
|
440
|
+
for (const section of localSections) {
|
|
441
|
+
if (remoteSections.has(section)) {
|
|
442
|
+
return { ok: false, reason: `local and cloud both edited ${section}` };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let merged = baseContent;
|
|
447
|
+
for (const section of localSections) {
|
|
448
|
+
const next = replaceMarkdownSection(merged, section, localContent);
|
|
449
|
+
if (next === null) return { ok: false, reason: `could not apply local section ${section}` };
|
|
450
|
+
merged = next;
|
|
451
|
+
}
|
|
452
|
+
for (const section of remoteSections) {
|
|
453
|
+
const next = replaceMarkdownSection(merged, section, remoteContent);
|
|
454
|
+
if (next === null) return { ok: false, reason: `could not apply cloud section ${section}` };
|
|
455
|
+
merged = next;
|
|
456
|
+
}
|
|
457
|
+
return { ok: true, content: merged, mode: 'markdown' };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function resolveLatestConflict(cwd = process.cwd(), strategy = 'local') {
|
|
461
|
+
if (!['local', 'cloud', 'both', 'merge'].includes(strategy)) {
|
|
462
|
+
throw new Error('Use `atris sync --resolve local`, `atris sync --resolve cloud`, `atris sync --resolve both`, or `atris sync --resolve merge`.');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const entries = collectConflictResolutionEntries(cwd);
|
|
466
|
+
if (entries.length === 0) {
|
|
467
|
+
return {
|
|
468
|
+
resolved: [],
|
|
469
|
+
message: 'No sync conflicts need resolution.\n',
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const resolved = [];
|
|
474
|
+
const unresolved = [];
|
|
475
|
+
for (const entry of entries) {
|
|
476
|
+
const targetPath = assertWorkspaceTarget(cwd, entry.targetRel);
|
|
477
|
+
if (strategy === 'both') {
|
|
478
|
+
if (fs.existsSync(entry.localPath)) {
|
|
479
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
480
|
+
fs.copyFileSync(entry.localPath, targetPath);
|
|
481
|
+
}
|
|
482
|
+
if (fs.existsSync(entry.remotePath)) {
|
|
483
|
+
const remoteCopyRel = `${entry.targetRel}.cloud`;
|
|
484
|
+
const remoteCopyPath = assertWorkspaceTarget(cwd, remoteCopyRel);
|
|
485
|
+
fs.mkdirSync(path.dirname(remoteCopyPath), { recursive: true });
|
|
486
|
+
fs.copyFileSync(entry.remotePath, remoteCopyPath);
|
|
487
|
+
}
|
|
488
|
+
resolved.push(entry.targetRel);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (strategy === 'merge') {
|
|
493
|
+
if (!fs.existsSync(entry.basePath) || !fs.existsSync(entry.localPath) || !fs.existsSync(entry.remotePath)) {
|
|
494
|
+
unresolved.push(`${entry.targetRel} (missing base/local/cloud artifact)`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const merged = safeMarkdownMerge(
|
|
498
|
+
fs.readFileSync(entry.basePath, 'utf8'),
|
|
499
|
+
fs.readFileSync(entry.localPath, 'utf8'),
|
|
500
|
+
fs.readFileSync(entry.remotePath, 'utf8')
|
|
501
|
+
);
|
|
502
|
+
if (!merged.ok) {
|
|
503
|
+
unresolved.push(`${entry.targetRel} (${merged.reason})`);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
507
|
+
fs.writeFileSync(targetPath, merged.content, 'utf8');
|
|
508
|
+
resolved.push(entry.targetRel);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const sourcePath = strategy === 'local' ? entry.localPath : entry.remotePath;
|
|
513
|
+
if (!fs.existsSync(sourcePath)) continue;
|
|
514
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
515
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
516
|
+
resolved.push(entry.targetRel);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
resolved,
|
|
521
|
+
unresolved,
|
|
522
|
+
message: [
|
|
523
|
+
`Resolved ${resolved.length} conflict${resolved.length === 1 ? '' : 's'} using ${strategy === 'both' ? 'both versions' : `${strategy === 'merge' ? 'safe merge' : `${strategy === 'local' ? 'local' : 'cloud'} version`}`}.`,
|
|
524
|
+
...resolved.map((rel) => ` - ${rel}`),
|
|
525
|
+
...(unresolved.length ? ['', 'Still needs review:', ...unresolved.map((rel) => ` - ${rel}`)] : []),
|
|
526
|
+
'',
|
|
527
|
+
'Next: run `atris sync --dry-run` before publishing.',
|
|
528
|
+
'',
|
|
529
|
+
].join('\n'),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function describeWatchFailure(err) {
|
|
534
|
+
const isConflict = err && err.status === 2;
|
|
535
|
+
return {
|
|
536
|
+
state: isConflict ? 'conflict' : 'retrying',
|
|
537
|
+
headline: `Sync ${isConflict ? 'paused for review' : 'will retry'}: ${err && err.message ? err.message : err}`,
|
|
538
|
+
detail: isConflict
|
|
539
|
+
? 'Resolve the review packet, then the watcher will pick up the next clean cycle.'
|
|
540
|
+
: 'The watcher is still running and will check again.',
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function runCli(args, cwd = process.cwd()) {
|
|
545
|
+
return new Promise((resolve, reject) => {
|
|
546
|
+
const child = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'atris.js'), ...args], {
|
|
547
|
+
cwd,
|
|
548
|
+
stdio: 'inherit',
|
|
549
|
+
env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
|
|
550
|
+
});
|
|
551
|
+
child.on('error', reject);
|
|
552
|
+
child.on('exit', (status) => {
|
|
553
|
+
if (status === 0) resolve({ status });
|
|
554
|
+
else {
|
|
555
|
+
const err = new Error(`${commandLine(args)} exited ${status}`);
|
|
556
|
+
err.status = status;
|
|
557
|
+
err.args = args;
|
|
558
|
+
reject(err);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function runSyncCycle(plan, cwd, options = {}) {
|
|
565
|
+
await runCli(plan.pullArgs, cwd);
|
|
566
|
+
if (options.dryRun) {
|
|
567
|
+
if (!canPreviewPush(cwd, options.slug)) {
|
|
568
|
+
console.log('');
|
|
569
|
+
console.log('Publish preview skipped until the pull preview is applied.');
|
|
570
|
+
console.log(' This folder is not the source of the current sync manifest yet.');
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
await runCli(plan.pushArgs, cwd);
|
|
575
|
+
} catch (err) {
|
|
576
|
+
console.log('');
|
|
577
|
+
console.log('Publish preview skipped until the pull preview is applied.');
|
|
578
|
+
console.log(` ${err.message || err}`);
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
await runCli(plan.pushArgs, cwd);
|
|
583
|
+
if (options.writeStatus) {
|
|
584
|
+
writeSyncStatus(cwd, {
|
|
585
|
+
slug: options.slug,
|
|
586
|
+
state: 'current',
|
|
587
|
+
mode: options.watch ? 'watch' : 'sync',
|
|
588
|
+
last_error: null,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function businessSync(args = process.argv.slice(3), cwd = process.cwd()) {
|
|
594
|
+
const options = resolveBusinessSyncOptions(args, cwd);
|
|
595
|
+
|
|
596
|
+
if (options.status) {
|
|
597
|
+
process.stdout.write(renderLocalSyncStatus(collectLocalSyncStatus(cwd, options)));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (options.review) {
|
|
602
|
+
process.stdout.write(renderLatestConflictReview(cwd));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (options.resolve) {
|
|
607
|
+
process.stdout.write(resolveLatestConflict(cwd, options.resolve).message);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const plan = buildBusinessSyncPlan(options);
|
|
612
|
+
|
|
613
|
+
if (!plan) {
|
|
614
|
+
console.error('Usage: atris sync [business] [--dry-run] [--watch] [--status] [--review] [--resolve local|cloud|both|merge] [--timeout 120]');
|
|
615
|
+
console.error('Run inside a business workspace or pass a business slug.');
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
console.log('');
|
|
620
|
+
console.log(`Syncing ${options.slug} knowledge wiki...`);
|
|
621
|
+
console.log(' scope: atris/');
|
|
622
|
+
if (options.watch) {
|
|
623
|
+
console.log(` watch: on (${options.intervalSec}s interval, ${options.debounceSec}s debounce)`);
|
|
624
|
+
}
|
|
625
|
+
console.log('');
|
|
626
|
+
|
|
627
|
+
await runSyncCycle(plan, cwd, {
|
|
628
|
+
dryRun: options.dryRun,
|
|
629
|
+
slug: options.slug,
|
|
630
|
+
writeStatus: !options.dryRun,
|
|
631
|
+
watch: options.watch,
|
|
632
|
+
});
|
|
633
|
+
if (!options.watch) return;
|
|
634
|
+
|
|
635
|
+
let lastSnapshot = collectBrainSnapshot(cwd);
|
|
636
|
+
let running = false;
|
|
637
|
+
let quietTicks = 0;
|
|
638
|
+
let pendingLocal = false;
|
|
639
|
+
|
|
640
|
+
console.log('');
|
|
641
|
+
console.log('Company brain sync is watching atris/. Press Ctrl+C to stop.');
|
|
642
|
+
|
|
643
|
+
const tickMs = 1000;
|
|
644
|
+
setInterval(async () => {
|
|
645
|
+
if (running) return;
|
|
646
|
+
const current = collectBrainSnapshot(cwd);
|
|
647
|
+
if (snapshotsDiffer(lastSnapshot, current)) {
|
|
648
|
+
pendingLocal = true;
|
|
649
|
+
quietTicks = 0;
|
|
650
|
+
lastSnapshot = current;
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (pendingLocal) {
|
|
655
|
+
quietTicks += 1;
|
|
656
|
+
if (quietTicks < options.debounceSec) return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const shouldPeriodicSync = !pendingLocal && quietTicks >= options.intervalSec;
|
|
660
|
+
const shouldLocalSync = pendingLocal && quietTicks >= options.debounceSec;
|
|
661
|
+
quietTicks += 1;
|
|
662
|
+
if (!shouldLocalSync && !shouldPeriodicSync) return;
|
|
663
|
+
|
|
664
|
+
running = true;
|
|
665
|
+
try {
|
|
666
|
+
console.log('');
|
|
667
|
+
console.log(shouldLocalSync ? 'Local brain changed. Syncing...' : 'Checking cloud brain...');
|
|
668
|
+
await runSyncCycle(plan, cwd, {
|
|
669
|
+
dryRun: options.dryRun,
|
|
670
|
+
slug: options.slug,
|
|
671
|
+
writeStatus: !options.dryRun,
|
|
672
|
+
watch: true,
|
|
673
|
+
});
|
|
674
|
+
lastSnapshot = collectBrainSnapshot(cwd);
|
|
675
|
+
pendingLocal = false;
|
|
676
|
+
quietTicks = 0;
|
|
677
|
+
} catch (err) {
|
|
678
|
+
const failure = describeWatchFailure(err);
|
|
679
|
+
console.error(`\n${failure.headline}`);
|
|
680
|
+
console.error(failure.detail);
|
|
681
|
+
if (!options.dryRun) {
|
|
682
|
+
writeSyncStatus(cwd, {
|
|
683
|
+
slug: options.slug,
|
|
684
|
+
state: failure.state,
|
|
685
|
+
mode: 'watch',
|
|
686
|
+
last_error: err.message || String(err),
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
pendingLocal = false;
|
|
690
|
+
quietTicks = 0;
|
|
691
|
+
} finally {
|
|
692
|
+
running = false;
|
|
693
|
+
}
|
|
694
|
+
}, tickMs);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
module.exports = {
|
|
698
|
+
businessSync,
|
|
699
|
+
buildBusinessSyncPlan,
|
|
700
|
+
canPreviewPush,
|
|
701
|
+
collectBrainSnapshot,
|
|
702
|
+
collectLocalSyncStatus,
|
|
703
|
+
collectConflictResolutionEntries,
|
|
704
|
+
describeWatchFailure,
|
|
705
|
+
parseBusinessSyncArgs,
|
|
706
|
+
readBusinessSlug,
|
|
707
|
+
renderLatestConflictReview,
|
|
708
|
+
renderLocalSyncStatus,
|
|
709
|
+
resolveLatestConflict,
|
|
710
|
+
resolveBusinessSyncOptions,
|
|
711
|
+
safeLineMerge,
|
|
712
|
+
safeMarkdownMerge,
|
|
713
|
+
shouldIgnoreWatchPath,
|
|
714
|
+
snapshotsDiffer,
|
|
715
|
+
writeSyncStatus,
|
|
716
|
+
};
|