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.
- package/AGENTS.md +37 -0
- package/README.md +5 -3
- package/atris/GETTING_STARTED.md +1 -1
- package/atris/atris.md +3 -0
- package/atris/policies/day-loop-voice.md +102 -0
- package/atris/policies/outbound-artifact-gate.md +2 -0
- package/atris/skills/design/SKILL.md +56 -32
- package/atris/skills/endgame/SKILL.md +12 -6
- package/atris/skills/engines/SKILL.md +22 -4
- package/atris/skills/fable-method/SKILL.md +66 -0
- package/atris/skills/improve/SKILL.md +65 -45
- package/atris/skills/youtube/SKILL.md +10 -1
- package/atris.md +2 -0
- package/ax +147 -19
- package/bin/atris.js +565 -265
- package/commands/activate.js +194 -88
- package/commands/agents.js +166 -0
- package/commands/autoland.js +459 -107
- package/commands/autopilot-front.js +20 -2
- package/commands/autopilot.js +118 -2
- package/commands/avail.js +407 -0
- package/commands/bench.js +188 -0
- package/commands/brain.js +3 -0
- package/commands/brief.js +651 -0
- package/commands/business-sync.js +192 -6
- package/commands/clean.js +50 -24
- package/commands/close.js +1083 -0
- package/commands/cloud.js +245 -0
- package/commands/compile.js +292 -1
- package/commands/computer.js +150 -3
- package/commands/dream.js +365 -0
- package/commands/drill.js +371 -0
- package/commands/engine.js +993 -32
- package/commands/experiments.js +28 -0
- package/commands/feedback.js +34 -12
- package/commands/fleet-report.js +206 -0
- package/commands/gm.js +23 -0
- package/commands/goal.js +247 -0
- package/commands/improve.js +642 -26
- package/commands/init.js +72 -44
- package/commands/interview.js +67 -1
- package/commands/land.js +152 -52
- package/commands/lifecycle.js +39 -3
- package/commands/log.js +84 -1
- package/commands/loops.js +220 -16
- package/commands/meet.js +220 -0
- package/commands/member.js +511 -34
- package/commands/mission.js +3029 -339
- package/commands/next.js +137 -0
- package/commands/now.js +220 -25
- package/commands/one-lap.js +776 -0
- package/commands/orb.js +314 -0
- package/commands/pack-craft.js +179 -0
- package/commands/pack.js +823 -0
- package/commands/play.js +3 -2
- package/commands/probe.js +30 -3
- package/commands/pulse.js +241 -46
- package/commands/push.js +260 -82
- package/commands/rainmaker.js +49 -0
- package/commands/report.js +415 -0
- package/commands/scout.js +147 -0
- package/commands/search.js +363 -0
- package/commands/skill.js +47 -3
- package/commands/slop.js +50 -2
- package/commands/soul.js +1 -1
- package/commands/stream.js +861 -0
- package/commands/study.js +693 -0
- package/commands/sync.js +67 -54
- package/commands/task.js +1346 -117
- package/commands/team.js +73 -0
- package/commands/verify.js +96 -0
- package/commands/watch.js +303 -0
- package/commands/wish.js +500 -0
- package/commands/workflow.js +11 -5
- package/commands/worktree.js +234 -13
- package/commands/xp.js +29 -11
- package/lib/auto-accept-certified.js +331 -34
- package/lib/autoland.js +319 -54
- package/lib/ax-auto-lane.js +79 -0
- package/lib/bench/context.js +147 -0
- package/lib/bench/engines.js +141 -0
- package/lib/bench/report.js +140 -0
- package/lib/bench/runner.js +512 -0
- package/lib/brief-ledger.js +350 -0
- package/lib/cloud-mission.js +259 -0
- package/lib/codex-flight.js +154 -0
- package/lib/default-runner.js +45 -0
- package/lib/default-verifier.js +70 -0
- package/lib/engine-registry.js +232 -0
- package/lib/experiments/daily.js +640 -0
- package/lib/fleet.js +2219 -67
- package/lib/improve-vitals-html.js +171 -0
- package/lib/known-commands.js +58 -0
- package/lib/loop-doctor.js +416 -0
- package/lib/member-switches.js +144 -0
- package/lib/mission-room.js +1 -0
- package/lib/mission-root.js +52 -0
- package/lib/next-moves.js +327 -10
- package/lib/one-lap-validator.js +60 -0
- package/lib/orb-context.js +477 -0
- package/lib/orb-scorecard.js +224 -0
- package/lib/policy-lessons.js +52 -1
- package/lib/pulse.js +277 -3
- package/lib/receipt-block.js +168 -0
- package/lib/receipt-evidence.js +65 -4
- package/lib/router-brain.js +352 -0
- package/lib/runner-command.js +10 -0
- package/lib/self-drive.js +258 -0
- package/lib/short-name.js +103 -0
- package/lib/spawn-env.js +18 -0
- package/lib/state-detection.js +56 -1
- package/lib/sync-status.js +59 -0
- package/lib/task-db.js +108 -29
- package/lib/task-proof.js +23 -1
- package/lib/team-presence.js +260 -0
- package/lib/tool-result-encode.js +7 -0
- package/lib/trust-tiers.js +90 -0
- package/lib/usage.js +107 -0
- package/lib/voice-gate.js +163 -0
- package/lib/wish-audit.js +1368 -0
- package/lib/wish-delegate.js +1840 -0
- package/lib/wish-design.js +110 -0
- package/lib/wish-stats.js +183 -0
- package/lib/wish-store.js +354 -0
- package/lib/zip.js +221 -0
- package/package.json +3 -1
- package/templates/loops/atris/loops/LOOPS.md +55 -0
- package/templates/loops/atris/loops/TICK.md +24 -0
- package/templates/loops/atris/loops/feedback.md +22 -0
- package/templates/loops/atris/loops/quality.md +22 -0
- package/templates/loops/atris/wiki/systems/loops.md +41 -0
- package/utils/api.js +5 -1
- 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
|
-
|
|
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
|
-
|
|
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,
|
package/commands/autopilot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|