atris 3.30.1 → 3.30.3

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 (67) hide show
  1. package/AGENTS.md +6 -0
  2. package/atris/AGENTS.md +11 -0
  3. package/atris/CLAUDE.md +5 -0
  4. package/atris/atris.md +19 -4
  5. package/atris/policies/atris-design.md +71 -0
  6. package/atris/policies/design-seed.md +187 -0
  7. package/atris/skills/atris/SKILL.md +26 -3
  8. package/atris/skills/design/SKILL.md +3 -2
  9. package/atris/skills/loop/SKILL.md +5 -3
  10. package/atris/team/_template/MEMBER.md +19 -0
  11. package/atris.md +63 -23
  12. package/ax +1434 -1698
  13. package/bin/atris.js +5 -1
  14. package/commands/agent-spawn.js +2 -2
  15. package/commands/brain.js +92 -7
  16. package/commands/brainstorm.js +62 -22
  17. package/commands/business-sync.js +92 -8
  18. package/commands/business.js +13 -7
  19. package/commands/chat-scan.js +102 -0
  20. package/commands/computer.js +758 -15
  21. package/commands/deck.js +505 -105
  22. package/commands/init.js +6 -1
  23. package/commands/launchpad.js +638 -0
  24. package/commands/log-sync.js +44 -13
  25. package/commands/loop-front.js +290 -0
  26. package/commands/member.js +124 -3
  27. package/commands/mission.js +717 -32
  28. package/commands/pull.js +37 -28
  29. package/commands/pulse.js +11 -8
  30. package/commands/run.js +79 -39
  31. package/commands/sync.js +36 -17
  32. package/commands/task.js +342 -66
  33. package/commands/xp.js +3 -0
  34. package/commands/youtube.js +221 -7
  35. package/decks/README.md +89 -0
  36. package/decks/archetype-catalog.json +180 -0
  37. package/decks/atris-antislop-pitch.json +48 -0
  38. package/decks/atris-archetypes-v2.json +83 -0
  39. package/decks/atris-one-loop-pitch.json +80 -0
  40. package/decks/atris-seed-pitch-v3.json +118 -0
  41. package/decks/atris-seed-pitch-v4-skeleton.json +106 -0
  42. package/decks/atris-seed-pitch-v5.json +109 -0
  43. package/decks/atris-seed-pitch-v6.json +137 -0
  44. package/decks/atris-seed-pitch-v7.json +133 -0
  45. package/decks/atris-single-shot-proof.json +74 -0
  46. package/decks/mark-pincus-narrative.json +102 -0
  47. package/decks/mark-pincus-sourcery.json +94 -0
  48. package/decks/yash-applied-compute-detailed.json +150 -0
  49. package/decks/yash-applied-compute-generalist.json +82 -0
  50. package/decks/yash-applied-compute-narrative.json +54 -0
  51. package/lib/ax-prefs.js +5 -12
  52. package/lib/chat-log-scan.js +377 -0
  53. package/lib/context-gatherer.js +35 -1
  54. package/lib/deck-compose.js +145 -0
  55. package/lib/deck-history.js +64 -0
  56. package/lib/deck-layout.js +169 -0
  57. package/lib/deck-review.js +431 -0
  58. package/lib/deck-schema.js +154 -0
  59. package/lib/file-ops.js +2 -2
  60. package/lib/functional-owner.js +189 -0
  61. package/lib/slides-deck.js +512 -58
  62. package/lib/task-db.js +109 -2
  63. package/package.json +2 -1
  64. package/templates/business-starter/team/START_HERE.md +12 -8
  65. package/utils/auth.js +4 -0
  66. package/utils/config.js +4 -0
  67. package/atris/atrisDev.md +0 -717
package/commands/pull.js CHANGED
@@ -100,6 +100,19 @@ function buildPullConflictReviewPacket(outputDir, conflictChanges, remoteContent
100
100
  });
101
101
  }
102
102
 
103
+ function mergePulledManifestFiles(remoteFiles = {}, onlyPrefixes = null, manifest = null) {
104
+ if (!onlyPrefixes || !manifest || !manifest.files) return remoteFiles;
105
+ const merged = {};
106
+ for (const [p, info] of Object.entries(manifest.files)) {
107
+ const inScope = onlyPrefixes.some((pref) => p.replace(/^\//, '').startsWith(pref));
108
+ if (!inScope) merged[p] = info;
109
+ }
110
+ for (const [p, info] of Object.entries(remoteFiles)) {
111
+ merged[p] = info;
112
+ }
113
+ return merged;
114
+ }
115
+
103
116
  async function pullAtris() {
104
117
  let arg = process.argv[3];
105
118
 
@@ -819,9 +832,32 @@ async function pullBusiness(slug) {
819
832
  if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
820
833
  if (conflictCount > 0) parts.push(`${conflictCount} conflict${conflictCount > 1 ? 's' : ''}`);
821
834
  if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
835
+
836
+ let commitHash = null;
837
+ try {
838
+ const headResult = await apiRequestJson(
839
+ `/business/${businessId}/workspaces/${workspaceId}/git/head`,
840
+ { method: 'GET', token: creds.token }
841
+ );
842
+ if (headResult.ok && headResult.data && headResult.data.commit) {
843
+ commitHash = headResult.data.commit;
844
+ }
845
+ } catch {
846
+ // Git might not be initialized yet — that's fine
847
+ }
848
+
822
849
  if (failOnConflict && conflictCount > 0) {
823
850
  const timestamp = syncTimestamp();
824
851
  const packet = buildPullConflictReviewPacket(outputDir, conflictChanges, conflictRemoteContents, timestamp);
852
+ if (!noManifest) {
853
+ const manifestFiles = mergePulledManifestFiles(remoteFiles, onlyPrefixes, manifest);
854
+ packet.files[`.atris/sync/conflicts/${timestamp}/manifest.json`] = `${JSON.stringify({
855
+ slug: resolvedSlug || slug,
856
+ manifest: buildManifest(manifestFiles, commitHash, { workspaceRoot: outputDir }),
857
+ baseContents: remoteContent,
858
+ deletedRemote: diff.deletedRemote,
859
+ }, null, 2)}\n`;
860
+ }
825
861
  writeConflictReviewPacket(outputDir, packet);
826
862
  console.log('');
827
863
  console.log(` Sync paused: ${conflictCount} conflict${conflictCount === 1 ? '' : 's'} need review before publishing.`);
@@ -836,20 +872,6 @@ async function pullBusiness(slug) {
836
872
  }
837
873
  }
838
874
 
839
- // Get current commit hash from remote (for manifest)
840
- let commitHash = null;
841
- try {
842
- const headResult = await apiRequestJson(
843
- `/business/${businessId}/workspaces/${workspaceId}/git/head`,
844
- { method: 'GET', token: creds.token }
845
- );
846
- if (headResult.ok && headResult.data && headResult.data.commit) {
847
- commitHash = headResult.data.commit;
848
- }
849
- } catch {
850
- // Git might not be initialized yet — that's fine
851
- }
852
-
853
875
  // ANTI-WIPE GUARD: if cloud reported zero in-scope files but local still
854
876
  // has in-scope content (i.e. the sweep refused), don't overwrite the
855
877
  // manifest with empty data for the scoped subtree. The manifest is the
@@ -874,20 +896,7 @@ async function pullBusiness(slug) {
874
896
  // since the last sync get evicted from the manifest. Without this, the
875
897
  // push freshness check would forever flag those paths as "deleted on
876
898
  // cloud" drift, blocking pushes for no reason.
877
- let manifestFiles = remoteFiles;
878
- if (onlyPrefixes && manifest && manifest.files) {
879
- const merged = {};
880
- // 1. Keep paths from old manifest that are OUTSIDE the scoped prefix.
881
- for (const [p, info] of Object.entries(manifest.files)) {
882
- const inScope = onlyPrefixes.some((pref) => p.replace(/^\//, '').startsWith(pref));
883
- if (!inScope) merged[p] = info;
884
- }
885
- // 2. Overwrite the in-scope subtree with what we just pulled (cloud truth).
886
- for (const [p, info] of Object.entries(remoteFiles)) {
887
- merged[p] = info;
888
- }
889
- manifestFiles = merged;
890
- }
899
+ const manifestFiles = mergePulledManifestFiles(remoteFiles, onlyPrefixes, manifest);
891
900
  if (!noManifest) {
892
901
  const newManifest = buildManifest(manifestFiles, commitHash, { workspaceRoot: outputDir });
893
902
  saveManifest(resolvedSlug || slug, newManifest);
package/commands/pulse.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // `atris pulse` the durable overnight self-improvement heartbeat for atris-cli.
3
+ // `atris pulse` - the durable overnight self-improvement heartbeat for atris-cli.
4
4
  //
5
5
  // atris pulse tick run ONE self-improvement tick (what the cron calls)
6
6
  // atris pulse status liveness, reward sum, ghost-tick detection
@@ -57,6 +57,8 @@ Options:
57
57
  --runner-template <s> Runner command template for installed heartbeat
58
58
  --max-ticks <n> Number of foreground ticks for run
59
59
  --help, -h Show this help
60
+
61
+ Tip: \`atris loop start --overnight\` is the one front door to this.
60
62
  `.trim();
61
63
  }
62
64
 
@@ -105,7 +107,7 @@ function runMissionEngine(root, { noClaude = false, timeoutMs = 600000 } = {}) {
105
107
  }
106
108
 
107
109
  // Fallback worker: one headless autopilot tick. This is the path that reaches
108
- // proposeCandidateHorizons i.e. where the member AUTHORS a new goal when no
110
+ // proposeCandidateHorizons - i.e. where the member AUTHORS a new goal when no
109
111
  // mission is due, instead of idling.
110
112
  function runAutopilotTick(root, { timeoutMs = 600000 } = {}) {
111
113
  const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
@@ -152,7 +154,7 @@ function gitChangedFiles(root) {
152
154
 
153
155
  // A cheap snapshot of the working tree: current HEAD + the set of dirty files.
154
156
  // Comparing two snapshots gives a tick's ACTUAL contribution (a new commit, or
155
- // files it newly dirtied) never the whole pre-existing dirty tree.
157
+ // files it newly dirtied) - never the whole pre-existing dirty tree.
156
158
  function gitSnapshot(root) {
157
159
  let head = null;
158
160
  try {
@@ -196,7 +198,7 @@ function tickCommand(args, root = process.cwd()) {
196
198
 
197
199
  const tickIndex = pulse.nextTickIndex(root);
198
200
 
199
- // 'started' receipt if the tick dies after this, the orphan surfaces as a ghost.
201
+ // 'started' receipt - if the tick dies after this, the orphan surfaces as a ghost.
200
202
  pulse.appendPulseReceipt(root, pulse.buildPulseReceipt({
201
203
  tickIndex,
202
204
  phase: 'started',
@@ -225,8 +227,8 @@ function tickCommand(args, root = process.cwd()) {
225
227
  const elapsedMs = Date.now() - startedAt;
226
228
  const reward = pulse.scoreTick({ verifyPassed: verify.passed, producedWork });
227
229
  const changedTail = committed
228
- ? ' committed'
229
- : (changedFiles.length ? ` ${changedFiles.length} file(s) changed` : '');
230
+ ? ' - committed'
231
+ : (changedFiles.length ? ` - ${changedFiles.length} file(s) changed` : '');
230
232
  const what = engine.actor === 'autopilot'
231
233
  ? `autopilot authored/advanced a goal${changedTail}`
232
234
  : engine.reason === 'no_due_mission'
@@ -249,7 +251,7 @@ function tickCommand(args, root = process.cwd()) {
249
251
  });
250
252
  pulse.appendPulseReceipt(root, receipt);
251
253
 
252
- // Revive the reward channel but only when there is signal (gate noise).
254
+ // Revive the reward channel - but only when there is signal (gate noise).
253
255
  let scorecardWritten = false;
254
256
  if (pulse.shouldWriteScorecard({ reward })) {
255
257
  pulse.appendScorecard(root, pulse.buildPulseScorecardRow({
@@ -280,7 +282,7 @@ function tickCommand(args, root = process.cwd()) {
280
282
  if (!asJson) {
281
283
  const ghost = priorStale.stale ? ` (recovered ghost tick #${priorStale.tick_index || '?'})` : '';
282
284
  const r = reward > 0 ? `+${reward}` : String(reward);
283
- process.stdout.write(`pulse tick #${tickIndex}: ${what} verify ${verify.passed === null ? 'n/a' : verify.passed ? 'pass' : 'FAIL'} reward ${r}${ghost}\n`);
285
+ process.stdout.write(`pulse tick #${tickIndex}: ${what} - verify ${verify.passed === null ? 'n/a' : verify.passed ? 'pass' : 'FAIL'} - reward ${r}${ghost}\n`);
284
286
  }
285
287
  return emit(out, asJson);
286
288
  } catch (err) {
@@ -492,6 +494,7 @@ module.exports = {
492
494
  pulseCommand,
493
495
  tickCommand,
494
496
  statusCommand,
497
+ cronInstalled,
495
498
  installCommand,
496
499
  uninstallCommand,
497
500
  runCommand,
package/commands/run.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Atris Run Auto-chain plan → do → review cycles
2
+ * Atris Run - Auto-chain plan → do → review cycles
3
3
  *
4
4
  * The ignition switch. Reads inbox/backlog, loops autonomously
5
5
  * until work is done or max cycles reached.
@@ -52,7 +52,7 @@ function getRunLogPath(runStamp, cycle) {
52
52
  */
53
53
  function writePhaseToRunLog(runLogPath, cycle, phase, output, durationMs) {
54
54
  const now = new Date().toISOString();
55
- const header = `# Run Log Cycle ${cycle}\n\n> Generated: ${now}\n\n`;
55
+ const header = `# Run Log - Cycle ${cycle}\n\n> Generated: ${now}\n\n`;
56
56
  const phaseSection = `## ${phase.toUpperCase()} (${Math.round(durationMs / 1000)}s)\n\n${output || '(no output)'}\n\n---\n\n`;
57
57
 
58
58
  if (!fs.existsSync(runLogPath)) {
@@ -115,7 +115,7 @@ function execPhaseCommandSync(cmd, opts = {}) {
115
115
  * Build prompt for each phase with full context.
116
116
  * If priorCycleReview is provided (from the previous cycle's review phase),
117
117
  * it is injected into the plan prompt so the navigator can adjust based on
118
- * what the validator found closing the try → notice → adjust loop.
118
+ * what the validator found - closing the try → notice → adjust loop.
119
119
  */
120
120
  function buildRunPrompt(phase, context, priorCycleReview) {
121
121
  const { mapPath, todoPath, personaPath, lessonsPath, journalPath } = context;
@@ -153,12 +153,12 @@ Workflow:
153
153
  1. Read the journal's ## Inbox section for ideas/tasks
154
154
  2. Read MAP.md for codebase navigation (file:line references)
155
155
  3. Read lessons.md for past learnings (if it exists)
156
- ${(priorCycleReview && priorCycleReview.trim()) ? "4. Read the Previous Cycle's Review above adjust planning based on what the validator found\n" : ""}5. For each inbox item, create a task in TODO.md under ## Backlog
156
+ ${(priorCycleReview && priorCycleReview.trim()) ? "4. Read the Previous Cycle's Review above - adjust planning based on what the validator found\n" : ""}5. For each inbox item, create a task in TODO.md under ## Backlog
157
157
  Format: - **T#:** Description [execute]
158
158
  6. Keep tasks small and specific (one function, one file, one fix)
159
159
  7. Do NOT write code. Planning only.
160
160
 
161
- If inbox is empty but TODO.md has backlog tasks, skip planning tasks already exist.
161
+ If inbox is empty but TODO.md has backlog tasks, skip planning - tasks already exist.
162
162
  If both inbox and backlog are empty, reply: [NOTHING_TO_DO]
163
163
 
164
164
  Reply [PLAN_COMPLETE] when done.`;
@@ -171,7 +171,7 @@ Read these files first:
171
171
  ${readFiles}
172
172
 
173
173
  Workflow:
174
- 1. Read TODO.md pick the first task from ## Backlog
174
+ 1. Read TODO.md - pick the first task from ## Backlog
175
175
  2. Move it to ## In Progress with: **Claimed by:** Executor at ${new Date().toISOString()}
176
176
  3. Read MAP.md to find exact file:line locations
177
177
  4. Implement the task step by step
@@ -191,7 +191,7 @@ Read these files first:
191
191
  ${readFiles}
192
192
 
193
193
  Workflow:
194
- 1. Read TODO.md find the task in ## In Progress
194
+ 1. Read TODO.md - find the task in ## In Progress
195
195
  2. Review the implementation:
196
196
  - Does it actually work? Test it if possible.
197
197
  - Does it follow existing patterns? (check MAP.md)
@@ -257,29 +257,47 @@ function executePhase(phase, context, options = {}) {
257
257
  /**
258
258
  * Check if there's work to do (inbox items or backlog tasks)
259
259
  */
260
- function hasWork(atrisDir) {
260
+ function hasInboxOrBacklogWork(atrisDir) {
261
261
  // Check backlog tasks
262
262
  const todoPath = path.join(atrisDir, 'TODO.md');
263
263
  const todo = parseTodo(todoPath);
264
264
  if (todo.backlog.length > 0 || todo.inProgress.length > 0) return true;
265
265
 
266
- // Check inbox
267
- const { logFile } = getLogPath();
268
- if (fs.existsSync(logFile)) {
269
- const content = fs.readFileSync(logFile, 'utf8');
270
- const inboxMatch = content.match(/## Inbox\r?\n([\s\S]*?)(?=\r?\n##|$)/);
271
- if (inboxMatch && inboxMatch[1].trim()) {
272
- const items = inboxMatch[1].trim().split('\n').filter(l => {
273
- const t = l.trim();
274
- return t.startsWith('- ') && t.length > 2;
275
- });
276
- if (items.length > 0) return true;
277
- }
278
- }
266
+ // Check today's inbox through the ONE shared parser, so the gate, the seed
267
+ // picker, and the seeder all read the same file with the same section bounds.
268
+ try {
269
+ if (require('../lib/next-moves').todayInboxItems(process.cwd()).length > 0) return true;
270
+ } catch { /* best-effort */ }
279
271
 
280
272
  return false;
281
273
  }
282
274
 
275
+ // Raw count of unchecked ROADMAP open items. Utility for status/reporting; the
276
+ // work gate below does NOT use it (a raw count and the seed picker can disagree
277
+ // once items are handled/killed), it uses pickRoadmapSeed directly.
278
+ function roadmapOpenCount(atrisDir) {
279
+ try {
280
+ return require('../lib/next-moves').readRoadmapOpenItems(path.dirname(atrisDir)).length;
281
+ } catch {
282
+ return 0;
283
+ }
284
+ }
285
+
286
+ // A seedable ROADMAP item (the exact thing the idle loop would pull) counts as
287
+ // work. Using pickRoadmapSeed here means the work gate and the seed path are the
288
+ // SAME signal, so the loop never spins on an empty inbox with nothing to seed.
289
+ function hasSeedableRoadmapItem(atrisDir) {
290
+ try {
291
+ return require('../lib/next-moves').pickRoadmapSeed(path.dirname(atrisDir)) != null;
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ function hasWork(atrisDir) {
298
+ return hasInboxOrBacklogWork(atrisDir) || hasSeedableRoadmapItem(atrisDir);
299
+ }
300
+
283
301
  /**
284
302
  * Append a durable Master-loop receipt for one cycle's review verdict.
285
303
  *
@@ -331,7 +349,7 @@ function logRunCompletion(cycles, startTime, cycleTimings = []) {
331
349
  timingLines = '\n' + timingLines;
332
350
  }
333
351
 
334
- const entry = `\n### Atris Run ${new Date().toLocaleTimeString()}\n- Cycles: ${cycles}\n- Duration: ${duration}s${timingLines}\n`;
352
+ const entry = `\n### Atris Run - ${new Date().toLocaleTimeString()}\n- Cycles: ${cycles}\n- Duration: ${duration}s${timingLines}\n`;
335
353
 
336
354
  if (content.includes('## Notes')) {
337
355
  content = content.replace(/(## Notes[^\n]*\n)/, `$1${entry}\n`);
@@ -343,7 +361,7 @@ function logRunCompletion(cycles, startTime, cycleTimings = []) {
343
361
  }
344
362
 
345
363
  /**
346
- * Main run function the ignition switch
364
+ * Main run function - the ignition switch
347
365
  */
348
366
  async function runAtris(options = {}) {
349
367
  const {
@@ -374,7 +392,7 @@ async function runAtris(options = {}) {
374
392
  console.log('');
375
393
  if (verbose) {
376
394
  console.log('┌─────────────────────────────────────────────────────────────┐');
377
- console.log(`│ Atris Run v${pkg.version} autonomous plan → do → review │`);
395
+ console.log(`│ Atris Run v${pkg.version} - autonomous plan → do → review │`);
378
396
  console.log('└─────────────────────────────────────────────────────────────┘');
379
397
  console.log('');
380
398
  console.log(`Max cycles: ${cycles}`);
@@ -383,9 +401,9 @@ async function runAtris(options = {}) {
383
401
  console.log(`Run logs: atris/logs/runs/`);
384
402
  console.log('');
385
403
  } else {
386
- console.log(`atris run v${pkg.version} plan, do, review, repeat.`);
404
+ console.log(`atris run v${pkg.version} - plan, do, review, repeat.`);
387
405
  console.log(`i'll run up to ${cycles} cycle${cycles === 1 ? '' : 's'}, ${timeout / 1000}s per phase. next i'll check the backlog.`);
388
- console.log(`phase reasoning will be saved to atris/logs/runs/ you can read what i thought after.`);
406
+ console.log(`phase reasoning will be saved to atris/logs/runs/ - you can read what i thought after.`);
389
407
  console.log('');
390
408
  }
391
409
 
@@ -410,7 +428,7 @@ async function runAtris(options = {}) {
410
428
  const cycleTimings = [];
411
429
  const writtenRunLogs = [];
412
430
  let completedCycles = 0;
413
- let lastReviewOutput = null; // Carried to next cycle's plan closes the loop
431
+ let lastReviewOutput = null; // Carried to next cycle's plan - closes the loop
414
432
 
415
433
  for (let cycle = 1; cycle <= cycles; cycle++) {
416
434
  if (verbose) {
@@ -421,6 +439,28 @@ async function runAtris(options = {}) {
421
439
  console.log(`\ncycle ${cycle} of ${cycles}.`);
422
440
  }
423
441
 
442
+ // If there's no inbox/backlog work, pull the top open ROADMAP item into the
443
+ // inbox so this cycle pursues the goal. This is how the loop reads ROADMAP.
444
+ if (!hasInboxOrBacklogWork(atrisDir)) {
445
+ try {
446
+ const { pickRoadmapSeed, seedInboxFromMove, claimRoadmapItem } = require('../lib/next-moves');
447
+ const pick = pickRoadmapSeed(process.cwd());
448
+ if (pick) {
449
+ // Seed THEN claim. A crash after the seed leaves the item in the inbox
450
+ // (carried as recoverable work) rather than claimed-but-lost; the seed
451
+ // is idempotent by title so a retry will not duplicate it. The claim
452
+ // ('- [~]' in the Open loop items section) then drops it from the open
453
+ // set so the next idle cycle advances. ROADMAP state is the one source
454
+ // of truth the work gate and the seed picker both read.
455
+ seedInboxFromMove(process.cwd(), { title: pick.title, source: 'roadmap' });
456
+ claimRoadmapItem(process.cwd(), pick.title);
457
+ console.log(verbose
458
+ ? `Seeded from ROADMAP: ${pick.title}`
459
+ : `no inbox or backlog work, so i pulled the top ROADMAP item: ${pick.title}`);
460
+ }
461
+ } catch { /* roadmap seeding is best-effort */ }
462
+ }
463
+
424
464
  // Check if there's work
425
465
  if (!hasWork(atrisDir)) {
426
466
  console.log(verbose
@@ -435,7 +475,7 @@ async function runAtris(options = {}) {
435
475
  try {
436
476
  // PLAN
437
477
  console.log(verbose
438
- ? '\n[1/3] PLAN reading inbox, creating tasks...'
478
+ ? '\n[1/3] PLAN - reading inbox, creating tasks...'
439
479
  : 'planning… reading inbox, turning ideas into tasks.');
440
480
  if (lastReviewOutput && verbose) {
441
481
  console.log(' [loop] carrying previous review into plan prompt');
@@ -462,7 +502,7 @@ async function runAtris(options = {}) {
462
502
  }
463
503
 
464
504
  // DO
465
- console.log(verbose ? '\n[2/3] DO building task...' : 'building the top task now.');
505
+ console.log(verbose ? '\n[2/3] DO - building task...' : 'building the top task now.');
466
506
  phaseStart = Date.now();
467
507
  const doOutput = executePhase('do', context, { verbose, timeout });
468
508
  timing.do = Date.now() - phaseStart;
@@ -473,13 +513,13 @@ async function runAtris(options = {}) {
473
513
  : `built in ${Math.round(timing.do / 1000)}s. next i'll review it.`);
474
514
 
475
515
  // REVIEW
476
- console.log(verbose ? '\n[3/3] REVIEW validating...' : 'reviewing the change against tests and validate.md.');
516
+ console.log(verbose ? '\n[3/3] REVIEW - validating...' : 'reviewing the change against tests and validate.md.');
477
517
  phaseStart = Date.now();
478
518
  const reviewOutput = executePhase('review', context, { verbose, timeout });
479
519
  timing.review = Date.now() - phaseStart;
480
520
  writePhaseToRunLog(runLogPath, cycle, 'review', reviewOutput, timing.review);
481
521
 
482
- // Carry the review output into the next cycle's plan closes the loop
522
+ // Carry the review output into the next cycle's plan - closes the loop
483
523
  lastReviewOutput = reviewOutput;
484
524
 
485
525
  // Persist the verdict durably so the brain can see the review actually ran.
@@ -503,7 +543,7 @@ async function runAtris(options = {}) {
503
543
 
504
544
  // Self-heal MAP.md refs after each cycle
505
545
  console.log(verbose
506
- ? '\n[+] CLEAN healing MAP.md refs...'
546
+ ? '\n[+] CLEAN - healing MAP.md refs...'
507
547
  : 'cleaning up drifted MAP.md refs.');
508
548
  try {
509
549
  cleanAtris({ dryRun: false });
@@ -513,7 +553,7 @@ async function runAtris(options = {}) {
513
553
 
514
554
  // Auto-push if not disabled
515
555
  if (push) {
516
- console.log(verbose ? '\n[+] PUSH pushing to remote...' : 'pushing to remote.');
556
+ console.log(verbose ? '\n[+] PUSH - pushing to remote...' : 'pushing to remote.');
517
557
  try {
518
558
  execSync('git push', { cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe' });
519
559
  console.log(verbose ? '✓ Pushed to remote' : 'pushed.');
@@ -653,7 +693,7 @@ function listRunLogs(args = []) {
653
693
  const filePath = path.join(runsDir, file);
654
694
  const content = fs.readFileSync(filePath, 'utf8');
655
695
  const lines = content.split('\n');
656
- const cycleMatch = content.match(/# Run Log Cycle (\d+)/);
696
+ const cycleMatch = content.match(/# Run Log - Cycle (\d+)/);
657
697
  const phases = [...content.matchAll(/## (\w+)/g)].map(m => m[1]);
658
698
  return {
659
699
  file,
@@ -819,7 +859,7 @@ function searchRunLogs(args = []) {
819
859
  }
820
860
 
821
861
  console.log('');
822
- console.log(`Search: "${keyword}" ${results.length} match${results.length === 1 ? '' : 'es'} in ${files.length} run log${files.length === 1 ? '' : 's'}:`);
862
+ console.log(`Search: "${keyword}" - ${results.length} match${results.length === 1 ? '' : 'es'} in ${files.length} run log${files.length === 1 ? '' : 's'}:`);
823
863
  console.log('');
824
864
 
825
865
  const shown = results.slice(0, limit);
@@ -860,7 +900,7 @@ function statsRunLogs() {
860
900
  const filePath = path.join(runsDir, file);
861
901
  const content = fs.readFileSync(filePath, 'utf8');
862
902
 
863
- const cycleMatch = content.match(/# Run Log Cycle (\d+)/);
903
+ const cycleMatch = content.match(/# Run Log - Cycle (\d+)/);
864
904
  if (cycleMatch) totalCycles++;
865
905
 
866
906
  // Extract phase headers with durations: ## PLAN (3s)
@@ -885,7 +925,7 @@ function statsRunLogs() {
885
925
  const count = phaseCounts[phase];
886
926
  const durs = phaseDurations[phase] || [];
887
927
  const avg = durs.length > 0 ? Math.round(durs.reduce((a, b) => a + b, 0) / durs.length) : 0;
888
- console.log(` ${phase.padEnd(7)} │ ${String(count).padStart(5)} │ ${avg > 0 ? avg + 's' : ''}`);
928
+ console.log(` ${phase.padEnd(7)} │ ${String(count).padStart(5)} │ ${avg > 0 ? avg + 's' : '-'}`);
889
929
  }
890
930
  console.log('');
891
931
  }
@@ -922,7 +962,7 @@ function exportRunLogs(args = []) {
922
962
  logs: files.map(file => {
923
963
  const filePath = path.join(runsDir, file);
924
964
  const content = fs.readFileSync(filePath, 'utf8');
925
- const cycleMatch = content.match(/# Run Log Cycle (\d+)/);
965
+ const cycleMatch = content.match(/# Run Log - Cycle (\d+)/);
926
966
  const phases = [...content.matchAll(/## (\w+)\s*\((\d+)s\)/g)].map(m => ({
927
967
  name: m[1],
928
968
  duration_s: parseInt(m[2]),
@@ -1022,4 +1062,4 @@ function diffRunLogs(args = []) {
1022
1062
  }
1023
1063
  }
1024
1064
 
1025
- module.exports = { runAtris, getRunLogDir, getRunLogPath, writePhaseToRunLog, appendMasterLoopReceipt, listRunLogs, pruneRunLogs, searchRunLogs, statsRunLogs, exportRunLogs, diffRunLogs, buildRunPrompt };
1065
+ module.exports = { runAtris, hasWork, hasInboxOrBacklogWork, roadmapOpenCount, getRunLogDir, getRunLogPath, writePhaseToRunLog, appendMasterLoopReceipt, listRunLogs, pruneRunLogs, searchRunLogs, statsRunLogs, exportRunLogs, diffRunLogs, buildRunPrompt };
package/commands/sync.js CHANGED
@@ -52,6 +52,29 @@ function _templateTargetRelPath(relPath) {
52
52
  return relPath === 'persona.md' ? 'PERSONA.md' : relPath;
53
53
  }
54
54
 
55
+ function ensureRealDirectory(dir) {
56
+ let stat = null;
57
+ try {
58
+ stat = fs.lstatSync(dir);
59
+ } catch (error) {
60
+ if (!error || error.code !== 'ENOENT') throw error;
61
+ }
62
+ if (stat) {
63
+ if (stat.isDirectory()) return;
64
+ if (stat.isSymbolicLink()) {
65
+ try {
66
+ if (fs.statSync(dir).isDirectory()) return;
67
+ fs.unlinkSync(dir);
68
+ } catch (_) {
69
+ fs.unlinkSync(dir);
70
+ }
71
+ } else {
72
+ throw new Error(`${dir} exists and is not a directory`);
73
+ }
74
+ }
75
+ fs.mkdirSync(dir, { recursive: true });
76
+ }
77
+
55
78
  /**
56
79
  * Sync the canonical skill set from atris-cli/atris/skills/* into a
57
80
  * workspace's atris/skills/* (plus ensure .claude/skills/ symlinks).
@@ -70,8 +93,8 @@ function syncPackageSkills(targetAtrisDir, opts = {}) {
70
93
 
71
94
  if (!fs.existsSync(packageSkillsDir)) return 0;
72
95
 
73
- if (!fs.existsSync(userSkillsDir)) fs.mkdirSync(userSkillsDir, { recursive: true });
74
- if (!fs.existsSync(claudeSkillsBaseDir)) fs.mkdirSync(claudeSkillsBaseDir, { recursive: true });
96
+ ensureRealDirectory(userSkillsDir);
97
+ ensureRealDirectory(claudeSkillsBaseDir);
75
98
 
76
99
  const skillFolders = fs.readdirSync(packageSkillsDir).filter(f => {
77
100
  try { return fs.statSync(path.join(packageSkillsDir, f)).isDirectory(); }
@@ -84,7 +107,7 @@ function syncPackageSkills(targetAtrisDir, opts = {}) {
84
107
  const symlinkPath = path.join(claudeSkillsBaseDir, skill);
85
108
 
86
109
  const syncRecursive = (src, dest, skillName, basePath = '') => {
87
- if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
110
+ ensureRealDirectory(dest);
88
111
  for (const entry of fs.readdirSync(src)) {
89
112
  const srcPath = path.join(src, entry);
90
113
  const destPath = path.join(dest, entry);
@@ -112,7 +135,7 @@ function syncPackageSkills(targetAtrisDir, opts = {}) {
112
135
  fs.symlinkSync(relativePath, symlinkPath);
113
136
  if (verbose) console.log(`✓ Linked .claude/skills/${skill}`);
114
137
  } catch (e) {
115
- fs.mkdirSync(symlinkPath, { recursive: true });
138
+ ensureRealDirectory(symlinkPath);
116
139
  const skillFile = path.join(destSkillDir, 'SKILL.md');
117
140
  if (fs.existsSync(skillFile)) {
118
141
  fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md'));
@@ -227,9 +250,13 @@ function renderBusinessAgentAdapter(bizMeta = {}, targetRoot = '.') {
227
250
  '## Proof Loop',
228
251
  '',
229
252
  '```bash',
253
+ 'atris sync --dry-run',
230
254
  'atris business check',
231
255
  'atris business record atris/reports/<recap>.md --outcome mixed --metric "operator speed"',
232
256
  'atris business share --write',
257
+ 'atris sync',
258
+ '# Optional during active collaboration:',
259
+ 'atris sync --watch',
233
260
  '```',
234
261
  '',
235
262
  `Workspace root at creation: ${rootHint}`,
@@ -370,7 +397,7 @@ function syncWorkspaceTemplate(targetRoot, bizMeta, options = {}) {
370
397
  console.log(' ✓ Already up to date');
371
398
  } else {
372
399
  ensureWikiScaffold(targetRoot);
373
- console.log(` ✓ Local workspace updated. Run \`atris align ${params.slug} --fix\` to push to EC2.`);
400
+ console.log(' ✓ Local workspace updated. Run `atris sync` to push and pull with safety checks.');
374
401
  }
375
402
 
376
403
  return {
@@ -463,7 +490,6 @@ function syncAtris() {
463
490
 
464
491
  const filesToSync = [
465
492
  { source: 'atris.md', target: 'atris.md' },
466
- { source: 'atris/atrisDev.md', target: 'atrisDev.md' },
467
493
  { source: 'PERSONA.md', target: 'PERSONA.md' },
468
494
  { source: 'GETTING_STARTED.md', target: 'GETTING_STARTED.md' },
469
495
  { source: 'atris/CLAUDE.md', target: 'CLAUDE.md' },
@@ -647,9 +673,7 @@ After displaying the boot output, respond to the user naturally.
647
673
  */
648
674
  function syncRecursiveCount(src, dest, label, silent) {
649
675
  let count = 0;
650
- if (!fs.existsSync(dest)) {
651
- fs.mkdirSync(dest, { recursive: true });
652
- }
676
+ ensureRealDirectory(dest);
653
677
  const entries = fs.readdirSync(src);
654
678
  for (const entry of entries) {
655
679
  const srcPath = path.join(src, entry);
@@ -732,12 +756,8 @@ function syncSkills({ silent = false } = {}) {
732
756
  const userSkillsDir = path.join(targetDir, 'skills');
733
757
  const claudeSkillsBaseDir = path.join(process.cwd(), '.claude', 'skills');
734
758
 
735
- if (!fs.existsSync(userSkillsDir)) {
736
- fs.mkdirSync(userSkillsDir, { recursive: true });
737
- }
738
- if (!fs.existsSync(claudeSkillsBaseDir)) {
739
- fs.mkdirSync(claudeSkillsBaseDir, { recursive: true });
740
- }
759
+ ensureRealDirectory(userSkillsDir);
760
+ ensureRealDirectory(claudeSkillsBaseDir);
741
761
 
742
762
  for (const skill of skillFolders) {
743
763
  const srcSkillDir = path.join(packageSkillsDir, skill);
@@ -756,7 +776,7 @@ function syncSkills({ silent = false } = {}) {
756
776
  }
757
777
  } catch (e) {
758
778
  // Fallback: copy instead of symlink
759
- fs.mkdirSync(symlinkPath, { recursive: true });
779
+ ensureRealDirectory(symlinkPath);
760
780
  const skillFile = path.join(destSkillDir, 'SKILL.md');
761
781
  if (fs.existsSync(skillFile)) {
762
782
  fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md'));
@@ -808,7 +828,6 @@ function _findAtrisProjects(rootDir, maxDepth = 8) {
808
828
  // Canonical files shipped from the package root. Must match syncAtris's filesToSync.
809
829
  const SYNC_ALL_FILES = [
810
830
  { source: 'atris.md', target: 'atris.md' },
811
- { source: 'atris/atrisDev.md', target: 'atrisDev.md' },
812
831
  { source: 'PERSONA.md', target: 'PERSONA.md' },
813
832
  { source: 'GETTING_STARTED.md', target: 'GETTING_STARTED.md' },
814
833
  { source: 'atris/CLAUDE.md', target: 'CLAUDE.md' },