atris 3.35.0 โ†’ 3.36.0

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 (133) hide show
  1. package/AGENTS.md +37 -0
  2. package/README.md +5 -3
  3. package/atris/GETTING_STARTED.md +1 -1
  4. package/atris/atris.md +3 -0
  5. package/atris/policies/day-loop-voice.md +102 -0
  6. package/atris/policies/outbound-artifact-gate.md +2 -0
  7. package/atris/skills/design/SKILL.md +56 -32
  8. package/atris/skills/endgame/SKILL.md +12 -6
  9. package/atris/skills/engines/SKILL.md +22 -4
  10. package/atris/skills/fable-method/SKILL.md +66 -0
  11. package/atris/skills/improve/SKILL.md +65 -45
  12. package/atris/skills/youtube/SKILL.md +10 -1
  13. package/atris.md +2 -0
  14. package/ax +147 -19
  15. package/bin/atris.js +565 -265
  16. package/commands/activate.js +194 -88
  17. package/commands/agents.js +166 -0
  18. package/commands/autoland.js +459 -107
  19. package/commands/autopilot-front.js +20 -2
  20. package/commands/autopilot.js +118 -2
  21. package/commands/avail.js +407 -0
  22. package/commands/bench.js +188 -0
  23. package/commands/brain.js +3 -0
  24. package/commands/brief.js +651 -0
  25. package/commands/business-sync.js +192 -6
  26. package/commands/clean.js +50 -24
  27. package/commands/close.js +1083 -0
  28. package/commands/cloud.js +245 -0
  29. package/commands/compile.js +292 -1
  30. package/commands/computer.js +150 -3
  31. package/commands/dream.js +365 -0
  32. package/commands/drill.js +371 -0
  33. package/commands/engine.js +993 -32
  34. package/commands/experiments.js +28 -0
  35. package/commands/feedback.js +34 -12
  36. package/commands/fleet-report.js +206 -0
  37. package/commands/gm.js +23 -0
  38. package/commands/goal.js +247 -0
  39. package/commands/improve.js +642 -26
  40. package/commands/init.js +72 -44
  41. package/commands/interview.js +67 -1
  42. package/commands/land.js +152 -52
  43. package/commands/lifecycle.js +39 -3
  44. package/commands/log.js +84 -1
  45. package/commands/loops.js +220 -16
  46. package/commands/meet.js +220 -0
  47. package/commands/member.js +511 -34
  48. package/commands/mission.js +3029 -339
  49. package/commands/next.js +137 -0
  50. package/commands/now.js +220 -25
  51. package/commands/one-lap.js +776 -0
  52. package/commands/orb.js +314 -0
  53. package/commands/pack-craft.js +179 -0
  54. package/commands/pack.js +823 -0
  55. package/commands/play.js +3 -2
  56. package/commands/probe.js +30 -3
  57. package/commands/pulse.js +241 -46
  58. package/commands/push.js +260 -82
  59. package/commands/rainmaker.js +49 -0
  60. package/commands/report.js +415 -0
  61. package/commands/scout.js +147 -0
  62. package/commands/search.js +363 -0
  63. package/commands/skill.js +47 -3
  64. package/commands/slop.js +50 -2
  65. package/commands/soul.js +1 -1
  66. package/commands/stream.js +861 -0
  67. package/commands/study.js +693 -0
  68. package/commands/sync.js +67 -54
  69. package/commands/task.js +1346 -117
  70. package/commands/team.js +73 -0
  71. package/commands/verify.js +96 -0
  72. package/commands/watch.js +303 -0
  73. package/commands/wish.js +500 -0
  74. package/commands/workflow.js +11 -5
  75. package/commands/worktree.js +234 -13
  76. package/commands/xp.js +29 -11
  77. package/lib/auto-accept-certified.js +331 -34
  78. package/lib/autoland.js +319 -54
  79. package/lib/ax-auto-lane.js +79 -0
  80. package/lib/bench/context.js +147 -0
  81. package/lib/bench/engines.js +141 -0
  82. package/lib/bench/report.js +140 -0
  83. package/lib/bench/runner.js +512 -0
  84. package/lib/brief-ledger.js +350 -0
  85. package/lib/cloud-mission.js +259 -0
  86. package/lib/codex-flight.js +154 -0
  87. package/lib/default-runner.js +45 -0
  88. package/lib/default-verifier.js +70 -0
  89. package/lib/engine-registry.js +232 -0
  90. package/lib/experiments/daily.js +640 -0
  91. package/lib/fleet.js +2219 -67
  92. package/lib/improve-vitals-html.js +171 -0
  93. package/lib/known-commands.js +58 -0
  94. package/lib/loop-doctor.js +416 -0
  95. package/lib/member-switches.js +144 -0
  96. package/lib/mission-room.js +1 -0
  97. package/lib/mission-root.js +52 -0
  98. package/lib/next-moves.js +327 -10
  99. package/lib/one-lap-validator.js +60 -0
  100. package/lib/orb-context.js +477 -0
  101. package/lib/orb-scorecard.js +224 -0
  102. package/lib/policy-lessons.js +52 -1
  103. package/lib/pulse.js +277 -3
  104. package/lib/receipt-block.js +168 -0
  105. package/lib/receipt-evidence.js +65 -4
  106. package/lib/router-brain.js +352 -0
  107. package/lib/runner-command.js +10 -0
  108. package/lib/self-drive.js +258 -0
  109. package/lib/short-name.js +103 -0
  110. package/lib/spawn-env.js +18 -0
  111. package/lib/state-detection.js +56 -1
  112. package/lib/sync-status.js +59 -0
  113. package/lib/task-db.js +108 -29
  114. package/lib/task-proof.js +23 -1
  115. package/lib/team-presence.js +260 -0
  116. package/lib/tool-result-encode.js +7 -0
  117. package/lib/trust-tiers.js +90 -0
  118. package/lib/usage.js +107 -0
  119. package/lib/voice-gate.js +163 -0
  120. package/lib/wish-audit.js +1368 -0
  121. package/lib/wish-delegate.js +1840 -0
  122. package/lib/wish-design.js +110 -0
  123. package/lib/wish-stats.js +183 -0
  124. package/lib/wish-store.js +354 -0
  125. package/lib/zip.js +221 -0
  126. package/package.json +3 -1
  127. package/templates/loops/atris/loops/LOOPS.md +55 -0
  128. package/templates/loops/atris/loops/TICK.md +24 -0
  129. package/templates/loops/atris/loops/feedback.md +22 -0
  130. package/templates/loops/atris/loops/quality.md +22 -0
  131. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  132. package/utils/api.js +5 -1
  133. package/utils/auth.js +57 -21
@@ -53,6 +53,13 @@ function positiveNumber(value) {
53
53
  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
54
54
  }
55
55
 
56
+ // `--iterations=N` or `--iterations N` โ€” the leg cap legacy callers pass.
57
+ function maxLegsFlag(args) {
58
+ const eq = args.find((a) => typeof a === 'string' && a.startsWith('--iterations='));
59
+ if (eq) return positiveNumber(eq.split('=')[1]);
60
+ return positiveNumber(readValueFlag(args, '--iterations'));
61
+ }
62
+
56
63
  // Most logical member: the one who owns the newest mission, else the first
57
64
  // member on the team. Null when the workspace has no members yet.
58
65
  function pickMember(root, preferredOwner = '') {
@@ -178,7 +185,12 @@ function showFrontHelp() {
178
185
  }
179
186
 
180
187
  async function autopilotFront(args = []) {
181
- const root = process.cwd();
188
+ // Anchor autopilot state (autopilot.json / autopilot.stop) to the shared
189
+ // workspace root, not the raw cwd. Otherwise `atris autopilot` from a subdir
190
+ // (e.g. backend/) wrote its loop state into a nested .atris, so a later
191
+ // `autopilot stop`/`status` from the root couldn't see the running loop.
192
+ // Same resolver the mission/task/usage stores use; falls back to cwd.
193
+ const root = require('../lib/mission-root').resolveWorkspaceRoot(process.cwd());
182
194
  if (args[0] === 'stop') return autopilotStop(root);
183
195
  if (args[0] === 'status') return autopilotStatus(root);
184
196
  if (args.includes('--help') || args.includes('-h') || args[0] === 'help') { showFrontHelp(); return 0; }
@@ -192,7 +204,11 @@ async function autopilotFront(args = []) {
192
204
  clearStop(root);
193
205
  const budgetSeconds = runBudgetSeconds(args);
194
206
  const legWallDefault = positiveNumber(readValueFlag(args, '--leg-wall')) || DEFAULT_LEG_WALL_SECONDS;
195
- const once = args.includes('--once');
207
+ // Honor the legacy contract callers still speak: `--iterations=N` caps the
208
+ // number of legs (the improve fallback spawns `--auto --iterations=1` and
209
+ // means exactly one tick, not an unbounded loop killed by timeout).
210
+ const maxLegs = maxLegsFlag(args);
211
+ const once = args.includes('--once') || maxLegs === 1;
196
212
  const startedAt = Date.now();
197
213
  const state = { pid: process.pid, started_at: new Date().toISOString(), legs: 0 };
198
214
  writeState(root, state);
@@ -234,6 +250,7 @@ async function autopilotFront(args = []) {
234
250
  writeState(root, state);
235
251
 
236
252
  if (once) { stopReason = 'single leg (--once)'; break; }
253
+ if (maxLegs && state.legs >= maxLegs) { stopReason = `leg cap reached (--iterations=${maxLegs})`; break; }
237
254
  if (stopRequested(root)) { stopReason = 'stop requested'; break; }
238
255
 
239
256
  // A leg that finishes in seconds means no real work happened โ€” a crash,
@@ -261,6 +278,7 @@ async function autopilotFront(args = []) {
261
278
 
262
279
  module.exports = {
263
280
  autopilotFront,
281
+ maxLegsFlag,
264
282
  autopilotStop,
265
283
  autopilotStatus,
266
284
  legPlan,
@@ -56,6 +56,34 @@ function shouldSkipAutoHumanGate(task) {
56
56
  function repoMapAuditReportsClean(cwd) {
57
57
  const auditPath = path.join(cwd, 'scripts', 'audit_map_refs.py');
58
58
  if (!fs.existsSync(auditPath)) return false;
59
+ const mapPath = path.join(cwd, 'atris', 'MAP.md');
60
+ const cachePath = path.join(cwd, '.atris', 'state', 'map-audit-cache.json');
61
+ let auditKey = null;
62
+
63
+ try {
64
+ auditKey = {
65
+ mapMtimeMs: fs.statSync(mapPath).mtimeMs,
66
+ scriptMtimeMs: fs.statSync(auditPath).mtimeMs,
67
+ };
68
+ } catch {
69
+ auditKey = null;
70
+ }
71
+
72
+ if (auditKey) {
73
+ try {
74
+ const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
75
+ if (
76
+ cached &&
77
+ cached.mapMtimeMs === auditKey.mapMtimeMs &&
78
+ cached.scriptMtimeMs === auditKey.scriptMtimeMs &&
79
+ typeof cached.result === 'boolean'
80
+ ) {
81
+ return cached.result;
82
+ }
83
+ } catch {
84
+ // Cache is a hot-path optimization only; fall through to the audit.
85
+ }
86
+ }
59
87
 
60
88
  const result = spawnSync('python3', [auditPath], {
61
89
  cwd,
@@ -67,7 +95,18 @@ function repoMapAuditReportsClean(cwd) {
67
95
 
68
96
  const output = `${result.stdout || ''}\n${result.stderr || ''}`;
69
97
  const match = output.match(/Total broken references:\s*(\d+)/i);
70
- return Boolean(match && Number(match[1]) === 0);
98
+ const clean = Boolean(match && Number(match[1]) === 0);
99
+
100
+ if (auditKey) {
101
+ try {
102
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
103
+ fs.writeFileSync(cachePath, JSON.stringify({ ...auditKey, result: clean }, null, 2) + '\n', 'utf8');
104
+ } catch {
105
+ // Failure to persist the cache should not change audit behavior.
106
+ }
107
+ }
108
+
109
+ return clean;
71
110
  }
72
111
 
73
112
  /**
@@ -604,6 +643,35 @@ function getContextFiles(phase, options = {}) {
604
643
  const SHARED_CHECKOUT_GIT_CONTRACT = `- Shared-checkout git safety (COORDINATION.md Rule 4): edits to any repo OTHER than this tick's cwd (../atrisos-backend-style sibling repos) go through a per-tick worktree โ€” start with \`atris worktree start --member <member> --task "<task>"\`, land with \`atris worktree ship --message "<msg>" --verify "<cmd>"\`. Never edit a sibling repo's shared checkout directly.
605
644
  - On a shared checkout, \`git reset\`, \`git checkout --\`, \`git clean\`, and stashing other agents' work are FORBIDDEN โ€” concurrent ticks' uncommitted work lives there.`;
606
645
 
646
+ /**
647
+ * Target files of experiment-queue entries that have not run yet. The gated
648
+ * keep/revert loop owns pending patches to these; a freelance autopilot edit
649
+ * to the same file blocks the loop with dirty targets (seen live 2026-07-07:
650
+ * a pulse tick hand-applied two queued prompt patches, verify failed, and the
651
+ * real experiment run was locked out until the tree was restored).
652
+ */
653
+ function pendingExperimentTargets(root = process.cwd()) {
654
+ try {
655
+ const queueFile = path.join(root, 'atris', 'experiments', 'daily', 'queue.jsonl');
656
+ const stateFile = path.join(root, '.atris', 'state', 'experiments-daily.json');
657
+ let history = [];
658
+ try { history = JSON.parse(fs.readFileSync(stateFile, 'utf8')).history || []; } catch { /* no state yet */ }
659
+ const done = new Set(history);
660
+ const targets = new Set();
661
+ for (const line of fs.readFileSync(queueFile, 'utf8').split('\n')) {
662
+ const trimmed = line.trim();
663
+ if (!trimmed) continue;
664
+ try {
665
+ const entry = JSON.parse(trimmed);
666
+ if (entry.id && !done.has(entry.id)) (entry.target_files || []).forEach((t) => targets.add(t));
667
+ } catch { /* skip malformed row */ }
668
+ }
669
+ return [...targets];
670
+ } catch {
671
+ return [];
672
+ }
673
+ }
674
+
607
675
  /**
608
676
  * Build the right prompt for each phase, adapting to the kind of work.
609
677
  */
@@ -817,6 +885,10 @@ Self-heal task: ${task}
817
885
  When done, reply: done.`;
818
886
  }
819
887
 
888
+ const pendingTargets = pendingExperimentTargets();
889
+ const experimentGuard = pendingTargets.length
890
+ ? `\n- Do NOT edit these files this tick โ€” the gated experiment loop owns pending patches to them: ${pendingTargets.join(', ')}. If the task requires changing them, stop and flag instead.`
891
+ : '';
820
892
  return `You are the executor. Read your MEMBER.md spec first if available.
821
893
 
822
894
  Rules:
@@ -824,9 +896,12 @@ Rules:
824
896
  - Execute ONE step at a time. Verify each step before moving on.
825
897
  - Check MAP.md for file locations before grepping.
826
898
  - If you hit two errors on the same step, stop and flag for re-scope.
827
- - Stay in scope. Don't touch files outside the task boundary.
899
+ - Stay in scope. Don't touch files outside the task boundary.${experimentGuard}
828
900
  ${SHARED_CHECKOUT_GIT_CONTRACT}
829
901
 
902
+ Before reading the file list, state the business stake this task protects or unlocks in one sentence.
903
+ Before marking work ready, confirm the task owner is still right and the work was not superseded.
904
+
830
905
  Read these files first:
831
906
  ${readFiles}
832
907
 
@@ -1584,6 +1659,21 @@ function isDoPhaseTimeoutMessage(message) {
1584
1659
  * Returns true if a bullet was marked.
1585
1660
  */
1586
1661
  function markTodoBulletDone(cwd, taskTitle) {
1662
+ try {
1663
+ // A rendered TODO.md carries the "Regenerated from durable Atris task
1664
+ // state" marker; those are projections and never hand-edited. Legacy
1665
+ // hand-written TODO.md files (no marker) keep the bullet-mark fallback.
1666
+ const renderedTodoPath = path.join(cwd, 'atris', 'TODO.md');
1667
+ const isRenderedView = fs.existsSync(renderedTodoPath)
1668
+ && fs.readFileSync(renderedTodoPath, 'utf8').includes('Regenerated from durable Atris task state');
1669
+ if (isRenderedView) {
1670
+ require('node:sqlite');
1671
+ const rendered = require('./task').autoRenderTodoFromDb(cwd);
1672
+ if (rendered) return true;
1673
+ }
1674
+ } catch {
1675
+ // Older Node runtimes and legacy workspaces retain the markdown fallback.
1676
+ }
1587
1677
  const needle = taskMatchNeedle(taskTitle);
1588
1678
  if (!needle) return false;
1589
1679
  for (const name of ['TODO.md', 'todo.md']) {
@@ -2628,6 +2718,30 @@ function findHotspot(cwd) {
2628
2718
  *
2629
2719
  * @returns {boolean} true on success, false on malformed sidecar or write error
2630
2720
  */
2721
+ function upsertLessonMeta(cwd, slug, patch = {}) {
2722
+ if (!slug || typeof slug !== 'string') return false;
2723
+ const metaPath = path.join(cwd, 'atris', 'lessons.json');
2724
+ let meta = {};
2725
+ if (fs.existsSync(metaPath)) {
2726
+ try {
2727
+ const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
2728
+ if (parsed && typeof parsed === 'object') meta = parsed;
2729
+ } catch { return false; }
2730
+ }
2731
+ if (!meta[slug] || typeof meta[slug] !== 'object') meta[slug] = {};
2732
+ for (const [key, value] of Object.entries(patch)) {
2733
+ if (value === undefined) continue;
2734
+ if (key === 'detector' && value === null) delete meta[slug].detector;
2735
+ else meta[slug][key] = value;
2736
+ }
2737
+ try {
2738
+ const atrisDir = path.join(cwd, 'atris');
2739
+ if (!fs.existsSync(atrisDir)) fs.mkdirSync(atrisDir, { recursive: true });
2740
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
2741
+ return true;
2742
+ } catch { return false; }
2743
+ }
2744
+
2631
2745
  function markLessonAttempted(cwd, slug, reason) {
2632
2746
  if (!slug || typeof slug !== 'string') return false;
2633
2747
  const metaPath = path.join(cwd, 'atris', 'lessons.json');
@@ -3724,10 +3838,12 @@ module.exports = {
3724
3838
  autopilotAtris,
3725
3839
  autopilotFromTodo,
3726
3840
  buildPrompt,
3841
+ pendingExperimentTargets,
3727
3842
  isLessonResolved,
3728
3843
  isLessonResolvedLegacy,
3729
3844
  loadLessonMetadata,
3730
3845
  markLessonAttempted,
3846
+ upsertLessonMeta,
3731
3847
  parseLessons,
3732
3848
  pickUnresolvedFailLesson,
3733
3849
  runLessonDetector,
@@ -0,0 +1,407 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Booking availability โ€” read/update the windows that power /book/{username}.
5
+ *
6
+ * atris avail # show current settings
7
+ * atris avail set --wake 2:30pm --sleep 9pm --where Ibiza --days all --apply
8
+ * atris avail slots [--days 7]
9
+ */
10
+
11
+ const { ensureValidCredentials } = require('../utils/auth');
12
+ const { apiRequestJson, getAppBaseUrl, httpRequest } = require('../utils/api');
13
+
14
+ const DAY_INDEX = { mon: 0, tue: 1, wed: 2, thu: 3, fri: 4, sat: 5, sun: 6 };
15
+ const DAY_LABEL = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
16
+
17
+ const WHERE_TZ = {
18
+ ibiza: 'Europe/Madrid',
19
+ madrid: 'Europe/Madrid',
20
+ spain: 'Europe/Madrid',
21
+ la: 'America/Los_Angeles',
22
+ sf: 'America/Los_Angeles',
23
+ pt: 'America/Los_Angeles',
24
+ pacific: 'America/Los_Angeles',
25
+ nyc: 'America/New_York',
26
+ ny: 'America/New_York',
27
+ london: 'Europe/London',
28
+ utc: 'UTC',
29
+ };
30
+
31
+ function backendBaseUrl() {
32
+ const raw = process.env.ATRIS_BACKEND_URL
33
+ ? process.env.ATRIS_BACKEND_URL.replace(/\/+$/, '')
34
+ : 'https://api.atris.ai';
35
+ return raw.replace(/\/api$/, '');
36
+ }
37
+
38
+ async function getToken() {
39
+ const ensured = await ensureValidCredentials(apiRequestJson);
40
+ const creds = ensured.error ? null : ensured.credentials;
41
+ if (!creds?.token) {
42
+ console.error(ensured.error && ensured.error !== 'not_logged_in'
43
+ ? `Authentication failed: ${ensured.detail || ensured.error}. Run: atris login`
44
+ : 'Not logged in. Run: atris login');
45
+ process.exit(1);
46
+ }
47
+ return creds.token;
48
+ }
49
+
50
+ /** Parse "2:30pm", "14:30", "9", "9pm" โ†’ decimal hours (e.g. 14.5). */
51
+ function parseTimeToDecimal(raw) {
52
+ if (raw == null || raw === '') throw new Error('time value required');
53
+ let s = String(raw).trim().toLowerCase().replace(/\s+/g, '');
54
+ const m12 = s.match(/^(\d{1,2})(?::(\d{2}))?(am|pm)$/);
55
+ if (m12) {
56
+ let h = parseInt(m12[1], 10);
57
+ const min = m12[2] ? parseInt(m12[2], 10) : 0;
58
+ const ap = m12[3];
59
+ if (h < 1 || h > 12 || min < 0 || min > 59) throw new Error(`invalid time: ${raw}`);
60
+ if (ap === 'am') h = h === 12 ? 0 : h;
61
+ else h = h === 12 ? 12 : h + 12;
62
+ return h + min / 60;
63
+ }
64
+ const m24 = s.match(/^(\d{1,2})(?::(\d{2}))?$/);
65
+ if (m24) {
66
+ const h = parseInt(m24[1], 10);
67
+ const min = m24[2] ? parseInt(m24[2], 10) : 0;
68
+ if (h < 0 || h > 23 || min < 0 || min > 59) throw new Error(`invalid time: ${raw}`);
69
+ return h + min / 60;
70
+ }
71
+ throw new Error(`invalid time: ${raw} (use 2:30pm, 14:30, 9am)`);
72
+ }
73
+
74
+ function formatDecimalHour(h) {
75
+ const hrs = Math.floor(h);
76
+ const mins = Math.round((h - hrs) * 60);
77
+ const ap = hrs >= 12 ? 'pm' : 'am';
78
+ const h12 = hrs % 12 === 0 ? 12 : hrs % 12;
79
+ return mins ? `${h12}:${String(mins).padStart(2, '0')}${ap}` : `${h12}${ap}`;
80
+ }
81
+
82
+ /** mon-fri | all | mon,wed,fri | weekdays | weekends */
83
+ function parseDaysArg(raw) {
84
+ if (!raw || raw === 'all' || raw === 'everyday' || raw === 'daily') {
85
+ return [0, 1, 2, 3, 4, 5, 6];
86
+ }
87
+ const key = raw.trim().toLowerCase();
88
+ if (key === 'weekdays' || key === 'mon-fri' || key === 'weekdays-mon-fri') {
89
+ return [0, 1, 2, 3, 4];
90
+ }
91
+ if (key === 'weekends' || key === 'sat-sun') return [5, 6];
92
+ const out = new Set();
93
+ for (const part of key.split(/[,+\s]+/).filter(Boolean)) {
94
+ if (part.includes('-')) {
95
+ const [a, b] = part.split('-');
96
+ const start = DAY_INDEX[a];
97
+ const end = DAY_INDEX[b];
98
+ if (start == null || end == null) throw new Error(`invalid day range: ${part}`);
99
+ for (let d = start; d <= end; d += 1) out.add(d);
100
+ continue;
101
+ }
102
+ const idx = DAY_INDEX[part.slice(0, 3)] ?? DAY_INDEX[part];
103
+ if (idx == null) throw new Error(`invalid day: ${part}`);
104
+ out.add(idx);
105
+ }
106
+ return [...out].sort((a, b) => a - b);
107
+ }
108
+
109
+ function resolveTimezone({ tz, where }) {
110
+ if (tz) return tz;
111
+ if (where) {
112
+ const key = where.trim().toLowerCase();
113
+ if (WHERE_TZ[key]) return WHERE_TZ[key];
114
+ throw new Error(`unknown --where "${where}"; use --tz IANA/Zone instead`);
115
+ }
116
+ return 'America/Los_Angeles';
117
+ }
118
+
119
+ function parseSetArgs(argv) {
120
+ const opts = {
121
+ wake: null,
122
+ sleep: null,
123
+ tz: null,
124
+ where: null,
125
+ days: 'all',
126
+ apply: false,
127
+ json: false,
128
+ slotMinutes: null,
129
+ windowDays: null,
130
+ };
131
+ for (let i = 0; i < argv.length; i += 1) {
132
+ const a = argv[i];
133
+ if (a === '--apply') opts.apply = true;
134
+ else if (a === '--json') opts.json = true;
135
+ else if (a === '--wake') opts.wake = argv[++i];
136
+ else if (a === '--sleep' || a === '--bed') opts.sleep = argv[++i];
137
+ else if (a === '--tz') opts.tz = argv[++i];
138
+ else if (a === '--where') opts.where = argv[++i];
139
+ else if (a === '--days') opts.days = argv[++i];
140
+ else if (a === '--slot-minutes') opts.slotMinutes = parseInt(argv[++i], 10);
141
+ else if (a === '--window-days') opts.windowDays = parseInt(argv[++i], 10);
142
+ else if (a === '--help' || a === '-h') return { help: true };
143
+ else throw new Error(`unknown arg: ${a}`);
144
+ }
145
+ if (opts.wake == null || opts.sleep == null) {
146
+ throw new Error('--wake and --sleep are required (e.g. --wake 2:30pm --sleep 9pm)');
147
+ }
148
+ return opts;
149
+ }
150
+
151
+ function buildAvailableHours(dayIndexes, wakeDec, sleepDec) {
152
+ const hours = {};
153
+ for (let d = 0; d <= 6; d += 1) hours[String(d)] = [];
154
+ const window = [[wakeDec, sleepDec]];
155
+ for (const d of dayIndexes) hours[String(d)] = window.map(([a, b]) => [a, b]);
156
+ return hours;
157
+ }
158
+
159
+ function summarizeHours(availableHours) {
160
+ const lines = [];
161
+ for (let d = 0; d <= 6; d += 1) {
162
+ const ranges = availableHours[String(d)] || availableHours[d] || [];
163
+ if (!ranges.length) {
164
+ lines.push(` ${DAY_LABEL[d]}: off`);
165
+ continue;
166
+ }
167
+ const pretty = ranges.map(([a, b]) => `${formatDecimalHour(a)}โ€“${formatDecimalHour(b)}`).join(', ');
168
+ lines.push(` ${DAY_LABEL[d]}: ${pretty}`);
169
+ }
170
+ return lines.join('\n');
171
+ }
172
+
173
+ // The booking link is the funnel: an operator shares /book/{username} so a
174
+ // stranger can pick a slot. With no claimed handle there is no link โ€” say so and
175
+ // name the fix instead of silently dropping the line.
176
+ function formatBookingLink(username, appBase) {
177
+ const handle = String(username || '').trim();
178
+ const base = String(appBase || '').replace(/\/+$/, '');
179
+ if (!handle) {
180
+ return ' link: none yet โ€” claim a handle with `atris signup` to get your /book link';
181
+ }
182
+ return ` link: ${base}/book/${handle}`;
183
+ }
184
+
185
+ async function showAvail({ json = false } = {}) {
186
+ const token = await getToken();
187
+ const result = await apiRequestJson('/profile/booking-settings', { method: 'GET', token });
188
+ if (!result.ok) {
189
+ console.error(`Error: ${result.error || result.status}`);
190
+ process.exit(1);
191
+ }
192
+ const data = result.data || {};
193
+ if (json) {
194
+ console.log(JSON.stringify(data, null, 2));
195
+ return 0;
196
+ }
197
+ const identity = await apiRequestJson('/identity/me', { method: 'GET', token });
198
+ const username = identity.ok ? identity.data?.username : null;
199
+ const appBase = getAppBaseUrl();
200
+ console.log('๐Ÿ“… Booking availability\n');
201
+ console.log(` timezone: ${data.timezone}`);
202
+ console.log(` slot: ${data.slot_duration_minutes} min ยท window: ${data.booking_window_days} days`);
203
+ console.log(formatBookingLink(username, appBase));
204
+ console.log('\n weekly windows (stored timezone):');
205
+ console.log(summarizeHours(data.available_hours || {}));
206
+ console.log('\n Google Calendar busy times are subtracted automatically.');
207
+ return 0;
208
+ }
209
+
210
+ async function setAvail(argv) {
211
+ let opts;
212
+ try {
213
+ opts = parseSetArgs(argv);
214
+ } catch (err) {
215
+ console.error(err.message);
216
+ return 1;
217
+ }
218
+ if (opts.help) {
219
+ printHelp();
220
+ return 0;
221
+ }
222
+
223
+ const wakeDec = parseTimeToDecimal(opts.wake);
224
+ const sleepDec = parseTimeToDecimal(opts.sleep);
225
+ if (!(wakeDec < sleepDec)) {
226
+ console.error('wake must be before sleep on the same calendar day');
227
+ return 1;
228
+ }
229
+
230
+ let dayIndexes;
231
+ try {
232
+ dayIndexes = parseDaysArg(opts.days);
233
+ } catch (err) {
234
+ console.error(err.message);
235
+ return 1;
236
+ }
237
+
238
+ let timezone;
239
+ try {
240
+ timezone = resolveTimezone(opts);
241
+ } catch (err) {
242
+ console.error(err.message);
243
+ return 1;
244
+ }
245
+
246
+ const payload = {
247
+ timezone,
248
+ available_hours: buildAvailableHours(dayIndexes, wakeDec, sleepDec),
249
+ };
250
+ if (opts.slotMinutes) payload.slot_duration_minutes = opts.slotMinutes;
251
+ if (opts.windowDays) payload.booking_window_days = opts.windowDays;
252
+
253
+ if (opts.json && !opts.apply) {
254
+ console.log(JSON.stringify({ preview: true, payload }, null, 2));
255
+ return 0;
256
+ }
257
+
258
+ console.log('๐Ÿ“… Availability preview\n');
259
+ console.log(` timezone: ${timezone}`);
260
+ console.log(` awake: ${formatDecimalHour(wakeDec)} โ†’ ${formatDecimalHour(sleepDec)} (${opts.days})`);
261
+ console.log('\n weekly windows:');
262
+ console.log(summarizeHours(payload.available_hours));
263
+
264
+ if (!opts.apply) {
265
+ console.log('\n Dry run โ€” add --apply to push to your profile.');
266
+ return 0;
267
+ }
268
+
269
+ const token = await getToken();
270
+ const result = await apiRequestJson('/profile/booking-settings', {
271
+ method: 'PUT',
272
+ token,
273
+ body: payload,
274
+ });
275
+ if (!result.ok) {
276
+ console.error(`Update failed: ${result.error || result.status}`);
277
+ return 1;
278
+ }
279
+
280
+ if (opts.json) {
281
+ console.log(JSON.stringify({ ok: true, settings: result.data }, null, 2));
282
+ return 0;
283
+ }
284
+
285
+ console.log('\nโœ“ Updated. Live on /book/{username} after the next slots fetch.');
286
+ return 0;
287
+ }
288
+
289
+ async function slotsAvail(argv) {
290
+ const opts = { days: 7, username: null, json: false };
291
+ for (let i = 0; i < argv.length; i += 1) {
292
+ const a = argv[i];
293
+ if (a === '--json') opts.json = true;
294
+ else if (a === '--days') opts.days = parseInt(argv[++i], 10);
295
+ else if (a === '--user' || a === '--username') opts.username = argv[++i];
296
+ else if (a === '--help' || a === '-h') {
297
+ printHelp();
298
+ return 0;
299
+ } else {
300
+ console.error(`unknown arg: ${a}`);
301
+ return 1;
302
+ }
303
+ }
304
+
305
+ const token = await getToken();
306
+ let username = opts.username;
307
+ if (!username) {
308
+ const identity = await apiRequestJson('/identity/me', { method: 'GET', token });
309
+ if (!identity.ok || !identity.data?.username) {
310
+ console.error('Could not resolve username โ€” pass --user keshav');
311
+ return 1;
312
+ }
313
+ username = identity.data.username;
314
+ }
315
+
316
+ const url = `${backendBaseUrl()}/book/${encodeURIComponent(username)}/slots?days=${opts.days}`;
317
+ const result = await httpRequest(url, { method: 'GET', timeoutMs: 30000 });
318
+ let data = null;
319
+ try {
320
+ data = JSON.parse(result.body.toString('utf8'));
321
+ } catch {
322
+ console.error('Invalid slots response');
323
+ return 1;
324
+ }
325
+ if (result.status < 200 || result.status >= 300) {
326
+ console.error(data?.detail || data?.error || `HTTP ${result.status}`);
327
+ return 1;
328
+ }
329
+
330
+ if (opts.json) {
331
+ console.log(JSON.stringify(data, null, 2));
332
+ return 0;
333
+ }
334
+
335
+ const byDay = data.slots_by_day || {};
336
+ const keys = Object.keys(byDay).sort();
337
+ console.log(`๐Ÿ“… Open slots for /book/${username} (${data.timezone || 'local'})\n`);
338
+ if (!keys.length) {
339
+ console.log(' No open slots in range.');
340
+ return 0;
341
+ }
342
+ for (const day of keys) {
343
+ const slots = byDay[day] || [];
344
+ const times = slots.map((s) => s.start_formatted?.split(' at ').pop() || s.start).join(', ');
345
+ console.log(` ${day}: ${times || '(none)'}`);
346
+ }
347
+ const total = keys.reduce((n, k) => n + (byDay[k]?.length || 0), 0);
348
+ console.log(`\n ${total} slot(s) ยท ${opts.days}-day window`);
349
+ return 0;
350
+ }
351
+
352
+ function printHelp() {
353
+ console.log(`Usage: atris avail [show | set | slots] [options]
354
+
355
+ Show or update the weekly windows behind your public booking page.
356
+ Busy Google Calendar events are subtracted when guests pick a slot.
357
+
358
+ Commands:
359
+ atris avail Same as show
360
+ atris avail show [--json]
361
+ atris avail set --wake TIME --sleep TIME [options]
362
+ atris avail slots [--days N] [--user USERNAME] [--json]
363
+
364
+ Set options:
365
+ --wake TIME Awake/start (2:30pm, 14:30, 9am)
366
+ --sleep TIME Sleep/end (9pm, 21:00)
367
+ --where CITY Shorthand timezone (Ibiza, LA, NYC, London, โ€ฆ)
368
+ --tz ZONE IANA timezone (overrides --where)
369
+ --days DAYS all | weekdays | mon-fri | mon,wed,fri (default: all)
370
+ --slot-minutes N 15, 30, 45, or 60
371
+ --window-days N How far ahead to offer slots (1-90)
372
+ --apply Push preview to your profile (default: dry run)
373
+ --json Machine-readable output
374
+
375
+ Examples:
376
+ atris avail
377
+ atris avail set --where Ibiza --wake 2:30pm --sleep 9pm --days all
378
+ atris avail set --wake 9am --sleep 5pm --days mon-fri --tz America/Los_Angeles --apply
379
+ atris avail slots --days 7
380
+ `);
381
+ }
382
+
383
+ async function availCommand(argv = []) {
384
+ if (!argv.length || argv[0]?.startsWith('-') || argv[0] === 'show' || argv[0] === 'get') {
385
+ return showAvail({ json: argv.includes('--json') });
386
+ }
387
+ const sub = argv[0].toLowerCase();
388
+ if (sub === '--help' || sub === '-h' || sub === 'help') {
389
+ printHelp();
390
+ return 0;
391
+ }
392
+ if (sub === 'set' || sub === 'update') return setAvail(argv.slice(1));
393
+ if (sub === 'slots') return slotsAvail(argv.slice(1));
394
+ console.error(`Unknown subcommand: ${sub}`);
395
+ printHelp();
396
+ return 1;
397
+ }
398
+
399
+ module.exports = {
400
+ availCommand,
401
+ formatBookingLink,
402
+ parseTimeToDecimal,
403
+ parseDaysArg,
404
+ buildAvailableHours,
405
+ resolveTimezone,
406
+ formatDecimalHour,
407
+ };