atris 3.74.4 → 3.74.6

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 (124) hide show
  1. package/FOR_AGENTS.md +12 -12
  2. package/atris/AGENTS.md +7 -7
  3. package/atris/CLAUDE.md +10 -10
  4. package/atris/policies/atris-backend.md +1 -1
  5. package/atris/policies/atris-design.md +71 -71
  6. package/atris/policies/design-seed.md +28 -28
  7. package/atris/policies/writing.md +1 -1
  8. package/atris/skills/apps/SKILL.md +3 -3
  9. package/atris/skills/atris-feedback/SKILL.md +2 -2
  10. package/atris/skills/autopilot/SKILL.md +10 -10
  11. package/atris/skills/blocks/SKILL.md +16 -16
  12. package/atris/skills/calendar/SKILL.md +1 -1
  13. package/atris/skills/clawhub/atris/SKILL.md +4 -4
  14. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
  15. package/atris/skills/create-app/SKILL.md +18 -18
  16. package/atris/skills/create-member/SKILL.md +11 -11
  17. package/atris/skills/design/SKILL.md +1 -1
  18. package/atris/skills/drive/SKILL.md +25 -25
  19. package/atris/skills/email-agent/SKILL.md +11 -11
  20. package/atris/skills/endgame/SKILL.md +33 -33
  21. package/atris/skills/magic-inbox/SKILL.md +15 -15
  22. package/atris/skills/notion/SKILL.md +1 -1
  23. package/atris/skills/ramp/SKILL.md +7 -7
  24. package/atris/skills/research-search/SKILL.md +7 -7
  25. package/atris/skills/slides/SKILL.md +7 -7
  26. package/atris/skills/tidy/SKILL.md +11 -11
  27. package/atris/skills/wake/SKILL.md +11 -11
  28. package/atris/skills/writing/SKILL.md +5 -5
  29. package/atris/team/_template/SOUL.md +3 -3
  30. package/atris/team/brainstormer/MEMBER.md +2 -2
  31. package/atris/team/launcher/MEMBER.md +2 -2
  32. package/atris/team/mission-lead/MISSION.md +7 -7
  33. package/atris/team/opus-overnight/MISSION.md +16 -16
  34. package/atris/team/opus-overnight/STEERING.md +5 -5
  35. package/atris.md +9 -0
  36. package/bin/atris.js +16 -3
  37. package/commands/campaign.js +17 -2
  38. package/commands/card.js +19 -12
  39. package/commands/engine.js +92 -17
  40. package/commands/gm.js +30 -0
  41. package/commands/idea.js +263 -0
  42. package/commands/init.js +11 -2
  43. package/commands/land.js +39 -18
  44. package/commands/mission.js +4 -3
  45. package/commands/ninety-nine.js +288 -0
  46. package/commands/one-lap.js +46 -27
  47. package/commands/reel.js +12 -7
  48. package/commands/sync.js +10 -2
  49. package/commands/task.js +4 -2
  50. package/commands/xp.js +68 -15
  51. package/commands/zen.js +10 -3
  52. package/lib/campaign-keep.js +23 -8
  53. package/lib/campaign-loop.js +24 -5
  54. package/lib/career-xp-lock.js +61 -0
  55. package/lib/career-xp-root.js +61 -0
  56. package/lib/claude-boot-block.js +7 -4
  57. package/lib/engine-registry.js +139 -1
  58. package/lib/fleet.js +11 -2
  59. package/lib/gm-bet.js +734 -42
  60. package/lib/gm-card.js +431 -0
  61. package/lib/gm-phone-page.js +486 -717
  62. package/lib/gm-phone.js +33 -34
  63. package/lib/gm-season.js +14 -0
  64. package/lib/headless-chrome.js +39 -0
  65. package/lib/idea-gate.js +33 -0
  66. package/lib/idea-inbox.js +220 -0
  67. package/lib/idea-ledger.js +196 -0
  68. package/lib/idea-tick.js +384 -0
  69. package/lib/known-commands.js +1 -1
  70. package/lib/member-engine.js +1 -0
  71. package/lib/ninety-nine-sync.js +156 -0
  72. package/lib/ninety-nine.js +247 -0
  73. package/lib/opening-lines.js +94 -0
  74. package/lib/roster-markdown.js +39 -1
  75. package/lib/roster-models.js +1 -1
  76. package/lib/roster-swap.js +353 -0
  77. package/lib/spawn-env.js +29 -2
  78. package/lib/task-db.js +8 -4
  79. package/lib/wish-audit.js +14 -0
  80. package/lib/yes-lock.js +365 -256
  81. package/lib/zen-apply.js +6 -3
  82. package/lib/zen.js +28 -1
  83. package/package.json +1 -1
  84. package/templates/business-starter/CLAUDE.md +7 -7
  85. package/templates/business-starter/MAP.md +1 -1
  86. package/templates/business-starter/TODO.md +1 -1
  87. package/templates/business-starter/atris.md +2 -2
  88. package/templates/business-starter/context/README.md +6 -6
  89. package/templates/business-starter/context/live-workspace.md +1 -1
  90. package/templates/business-starter/goals.md +1 -1
  91. package/templates/business-starter/instructions.md +1 -1
  92. package/templates/business-starter/memory.md +4 -4
  93. package/templates/business-starter/persona.md +3 -3
  94. package/templates/business-starter/policies/LESSONS.md +1 -1
  95. package/templates/business-starter/reports/README.md +1 -1
  96. package/templates/business-starter/skills/README.md +1 -1
  97. package/templates/business-starter/team/README.md +1 -1
  98. package/templates/business-starter/team/START_HERE.md +1 -1
  99. package/templates/business-starter/team/_template/SOUL.md +3 -3
  100. package/templates/business-starter/team/comms/SOUL.md +2 -2
  101. package/templates/business-starter/team/ops/SOUL.md +4 -4
  102. package/templates/business-starter/team/research/SOUL.md +3 -3
  103. package/templates/business-starter/wiki/index.md +1 -1
  104. package/templates/research-canonical/CLAUDE.md +7 -7
  105. package/templates/research-canonical/MAP.md +1 -1
  106. package/templates/research-canonical/TODO.md +1 -1
  107. package/templates/research-canonical/atris.md +2 -2
  108. package/templates/research-canonical/context/README.md +8 -8
  109. package/templates/research-canonical/context/live-workspace.md +1 -1
  110. package/templates/research-canonical/goals.md +2 -2
  111. package/templates/research-canonical/instructions.md +1 -1
  112. package/templates/research-canonical/memory.md +4 -4
  113. package/templates/research-canonical/persona.md +1 -1
  114. package/templates/research-canonical/policies/LESSONS.md +1 -1
  115. package/templates/research-canonical/reports/README.md +1 -1
  116. package/templates/research-canonical/skills/README.md +1 -1
  117. package/templates/research-canonical/team/README.md +1 -1
  118. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  119. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  120. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  121. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  122. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  123. package/templates/research-canonical/wiki/index.md +1 -1
  124. package/utils/auth.js +1 -0
@@ -0,0 +1,384 @@
1
+ 'use strict';
2
+
3
+ // atris idea tick: build the oldest waiting idea, one per repo at a time.
4
+ //
5
+ // The tick adds no new build machinery. It hands the idea's sentence to the
6
+ // one-lap path, which already turns a sentence into one scoped Atris task,
7
+ // builds it in a sealed copy cut from the protected branch, runs the repo's
8
+ // check, and has a second engine review the change (the builder never
9
+ // certifies itself). The tick then checks the built change against the same
10
+ // safety rails the inbox uses and lands it with `atris worktree ship`, which
11
+ // reruns the check, pushes a plain branch, and merges through a pull request.
12
+ //
13
+ // Rails encoded here: one tick per repo (lock file), a time cap on the build,
14
+ // no force-push, no hook skipping, no stash (the tick never calls any of
15
+ // them), and refusal of money, sending, auth, deleting user data, and
16
+ // migrations at intake and again on the built diff.
17
+ //
18
+ // Every idea the tick picks up ends with one row in the outcome ledger
19
+ // (lib/idea-ledger.js). When the same failure comes back twice in a day, the
20
+ // tick files one idea aimed at the loop itself, tagged 'loop', under the same
21
+ // rails as any other idea. At most one such idea is open at a time.
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const crypto = require('crypto');
26
+ const { spawnSync } = require('child_process');
27
+ const fleet = require('./fleet');
28
+ const { assertSafeShipArgs, secondReviewerSignedOff } = require('./idea-gate');
29
+ const { readOutcomes, recordOutcome, repeatedFailure } = require('./idea-ledger');
30
+ const {
31
+ addIdea,
32
+ changeRefusal,
33
+ ideaRefusal,
34
+ nextWaitingIdea,
35
+ oneLine,
36
+ readIdeas,
37
+ updateIdea,
38
+ } = require('./idea-inbox');
39
+
40
+ const DEFAULT_BUILD_SECONDS = 30 * 60;
41
+ const MAX_ATTEMPTS = 2;
42
+ const LOCK_STALE_MS = 3 * 60 * 60 * 1000;
43
+ const SHIP_TIMEOUT_MS = 20 * 60 * 1000;
44
+ const DAY_MS = 24 * 60 * 60 * 1000;
45
+
46
+ const IDEA_BRIEF = [
47
+ 'House rules for this idea. The owner dropped it and walked away, so you decide close calls with these values:',
48
+ '- Acceleration: land one small working change for this idea, not a plan.',
49
+ '- Accuracy: a win counts only when checked. Add a test that fails before your change and passes after, and run the fast check bare.',
50
+ '- Ambition: if the bigger version of the same win fits in this one change, take it; otherwise leave a note.',
51
+ '- Agency: own the missing piece. If something blocks you, fix it inside this change or stop and say exactly what is missing.',
52
+ 'Hard rules:',
53
+ '- Never force-push, never skip hooks (no --no-verify), never stash, never rewrite history.',
54
+ '- Do not touch money, sending messages or email, auth, deleting user data, or database migrations. If the idea needs any of these, stop and say so.',
55
+ '- Do not edit the rules that judge you: the idea refuse list, the landing gates, or the idea score.',
56
+ '- Keep to the idea. One idea, one change.',
57
+ ].join('\n');
58
+
59
+ function lockFile(root) {
60
+ return path.join(path.resolve(root), '.atris', 'state', 'idea-tick.lock');
61
+ }
62
+
63
+ function processAlive(pid) {
64
+ if (!Number.isInteger(pid) || pid <= 0) return false;
65
+ try {
66
+ process.kill(pid, 0);
67
+ return true;
68
+ } catch (error) {
69
+ return Boolean(error && error.code === 'EPERM');
70
+ }
71
+ }
72
+
73
+ function readLock(file) {
74
+ try {
75
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // One tick per repo. A lock whose process is gone, or that is older than the
82
+ // longest a tick can take, is left over from a crash and gets taken.
83
+ function acquireTickLock(root, { pid = process.pid, now = Date.now(), staleMs = LOCK_STALE_MS } = {}) {
84
+ const file = lockFile(root);
85
+ fs.mkdirSync(path.dirname(file), { recursive: true });
86
+ for (let attempt = 0; attempt < 2; attempt += 1) {
87
+ const token = crypto.randomBytes(8).toString('hex');
88
+ try {
89
+ const fd = fs.openSync(file, 'wx', 0o600);
90
+ try {
91
+ fs.writeFileSync(fd, `${JSON.stringify({ pid, token, started_at: new Date(now).toISOString() })}\n`);
92
+ } finally {
93
+ fs.closeSync(fd);
94
+ }
95
+ return { ok: true, file, token };
96
+ } catch (error) {
97
+ if (!error || error.code !== 'EEXIST') throw error;
98
+ const holder = readLock(file);
99
+ let startedMs = holder ? Date.parse(holder.started_at || '') : NaN;
100
+ if (!Number.isFinite(startedMs)) {
101
+ try { startedMs = fs.statSync(file).mtimeMs; } catch { startedMs = now; }
102
+ }
103
+ const age = now - startedMs;
104
+ const live = holder ? processAlive(Number(holder.pid)) : age < staleMs;
105
+ if (live && age < staleMs) return { ok: false, file, holder };
106
+ try { fs.unlinkSync(file); } catch {}
107
+ }
108
+ }
109
+ return { ok: false, file, holder: readLock(file) };
110
+ }
111
+
112
+ function releaseTickLock(lock) {
113
+ if (!lock || !lock.ok) return;
114
+ const current = readLock(lock.file);
115
+ if (current && current.token === lock.token) {
116
+ try { fs.unlinkSync(lock.file); } catch {}
117
+ }
118
+ }
119
+
120
+ // The repo's own fast check: an idea's --verify, else test:fast, test:unit,
121
+ // the npm test script, check, or node --test when a test folder exists. A repo
122
+ // with none of these gets no guess; the idea is refused with how to fix it.
123
+ function fastCheck(root, idea = {}) {
124
+ if (idea.verify) return String(idea.verify).trim();
125
+ let scripts = {};
126
+ try {
127
+ scripts = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).scripts || {};
128
+ } catch {}
129
+ const has = (name) => String(scripts[name] || '').trim().length > 0;
130
+ if (has('test:fast')) return 'npm run test:fast';
131
+ if (has('test:unit')) return 'npm run test:unit';
132
+ if (has('test') && !/no test specified/i.test(scripts.test)) return 'npm test';
133
+ if (has('check')) return 'npm run check';
134
+ for (const dir of ['test', 'tests']) {
135
+ try {
136
+ if (fs.readdirSync(path.join(root, dir)).some((name) => /\.test\.(?:c|m)?js$/.test(name))) return 'node --test';
137
+ } catch {}
138
+ }
139
+ return '';
140
+ }
141
+
142
+ function git(cwd, args) {
143
+ return spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 60000 });
144
+ }
145
+
146
+ // What the built change touches: changed paths and added lines against the
147
+ // branch it will land on.
148
+ function inspectBuiltChange(worktree, target) {
149
+ const range = `${target}...HEAD`;
150
+ const names = git(worktree, ['diff', '--name-only', range]);
151
+ const diff = git(worktree, ['diff', '-U0', range]);
152
+ if (names.status !== 0 || diff.status !== 0) {
153
+ return { ok: false, detail: String(names.stderr || diff.stderr || 'could not read the change').trim() };
154
+ }
155
+ const paths = String(names.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean);
156
+ const addedLines = String(diff.stdout || '').split('\n')
157
+ .filter((line) => line.startsWith('+') && !line.startsWith('+++'))
158
+ .map((line) => line.slice(1));
159
+ return { ok: true, paths, addedLines };
160
+ }
161
+
162
+ function hasOrigin(cwd) {
163
+ return git(cwd, ['remote', 'get-url', 'origin']).status === 0;
164
+ }
165
+
166
+ // A repo with no origin lands on its own branch (`--local`); every other repo
167
+ // lands through a pull request.
168
+ function shipArgsFor({ title, taskId, engine, verify, target, local = false }) {
169
+ const args = fleet.fleetShipArgs({ task: { title, display_id: taskId }, engine }, verify, target);
170
+ if (local) args.push('--local');
171
+ return assertSafeShipArgs(args);
172
+ }
173
+
174
+ function defaultShip(worktree, args) {
175
+ const bin = path.resolve(__dirname, '..', 'bin', 'atris.js');
176
+ const result = spawnSync(process.execPath, [bin, ...args], {
177
+ cwd: worktree,
178
+ encoding: 'utf8',
179
+ env: process.env,
180
+ timeout: SHIP_TIMEOUT_MS,
181
+ });
182
+ return { status: result.status, stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
183
+ }
184
+
185
+ function defaultRunLap(text, options) {
186
+ return require('../commands/one-lap').runOneLap(text, options);
187
+ }
188
+
189
+ // Where a stopped lap stopped: its check failed, its check passed but the
190
+ // review did not, or it never got that far.
191
+ function lapStage(lap) {
192
+ const checked = String(lap && lap.checked || '');
193
+ if (/\bfailed\b/.test(checked)) return 'checks';
194
+ if (/\bpassed\b/.test(checked)) return 'review';
195
+ return 'build';
196
+ }
197
+
198
+ // Where a failed landing stopped: `worktree ship` prints `merge_check:` only
199
+ // after the fast check passed, so without it the check is what failed.
200
+ function shipStage(out) {
201
+ return /^verify: /m.test(out) && !/^merge_check: /m.test(out) ? 'checks' : 'push';
202
+ }
203
+
204
+ // A build that was mid-flight when the last tick died goes back in line once,
205
+ // then stops for the owner. Either way the ledger gets a failed build row.
206
+ function recoverInterrupted(root, ideas) {
207
+ const recovered = [];
208
+ for (const idea of ideas.filter((row) => row.status === 'building')) {
209
+ if (Number(idea.attempts || 0) >= MAX_ATTEMPTS) {
210
+ updateIdea(root, idea.id, { status: 'refused', reason: `the build stopped ${MAX_ATTEMPTS} times without finishing, needs owner` });
211
+ } else {
212
+ updateIdea(root, idea.id, { status: 'waiting' });
213
+ }
214
+ recordOutcome(root, { idea, outcome: 'failed', stage: 'build', cause: 'the build stopped without finishing', started: idea.started_at || null, taskId: idea.task_id || null });
215
+ recovered.push(idea.id);
216
+ }
217
+ return recovered;
218
+ }
219
+
220
+ // When the same failure came back twice in a day, file one idea aimed at the
221
+ // loop itself. Never more than one open loop idea, never the same cause twice
222
+ // in a day, and the idea goes through addIdea, so the usual refuse rules
223
+ // apply to it (a cause about logins, say, is saved as refused, needs owner).
224
+ function feedLoop({ repo, loopRepo, now = Date.now(), log = () => {} }) {
225
+ const repeat = repeatedFailure(readOutcomes(repo), { now });
226
+ if (!repeat) return null;
227
+ const home = path.resolve(loopRepo || repo);
228
+ const ideas = readIdeas(home);
229
+ const loopIdeas = ideas.filter((idea) => idea.tag === 'loop');
230
+ if (loopIdeas.some((idea) => idea.status === 'waiting' || idea.status === 'building')) return null;
231
+ if (loopIdeas.some((idea) => idea.cause_key === repeat.key && now - (Date.parse(idea.at || '') || 0) < DAY_MS)) return null;
232
+ const text = `make the idea loop handle "${repeat.cause}" at the ${repeat.stage} stage (seen ${repeat.count} times in 24h)`;
233
+ const idea = addIdea(home, text, { tag: 'loop', causeKey: repeat.key, now: new Date(now) });
234
+ const where = home === path.resolve(repo) ? '' : ` in ${home}`;
235
+ log(`the same ${repeat.stage} failure came back ${repeat.count} times today, so I filed loop idea ${idea.n}${where}: ${oneLine(text, 100)}`);
236
+ if (idea.status === 'refused') log(` loop idea ${idea.n} is refused too: ${idea.reason}`);
237
+ return idea;
238
+ }
239
+
240
+ async function runIdeaTick({
241
+ root = process.cwd(),
242
+ log = console.log,
243
+ runLap = defaultRunLap,
244
+ ship = defaultShip,
245
+ inspectChange = inspectBuiltChange,
246
+ landTarget = (worktree) => fleet.resolveDispatchLandTarget(worktree),
247
+ maxSeconds = DEFAULT_BUILD_SECONDS,
248
+ lockOptions = {},
249
+ loopRepo = null,
250
+ clock = () => Date.now(),
251
+ } = {}) {
252
+ const repo = path.resolve(root);
253
+ const lock = acquireTickLock(repo, lockOptions);
254
+ if (!lock.ok) {
255
+ log('one idea is already building here, so this tick waits.');
256
+ return { status: 'busy' };
257
+ }
258
+ const iso = () => new Date(clock()).toISOString();
259
+ let started = null;
260
+ let current = null;
261
+ // Every way out of a picked-up idea goes through here: the inbox row says
262
+ // refused with the reason, and the ledger says refused (a rail said no) or
263
+ // failed (a stage did not get through), at which stage, and why in short.
264
+ const stop = (idea, { outcome = 'failed', stage, cause, reason, taskId = null }) => {
265
+ updateIdea(repo, idea.id, { status: 'refused', reason, ...(taskId ? { task_id: taskId } : {}) });
266
+ recordOutcome(repo, { idea: current || idea, outcome, stage, cause: cause || reason, started, finished: iso(), taskId });
267
+ log(`idea ${idea.n} refused: ${reason}`);
268
+ const loopIdea = outcome === 'failed' ? feedLoop({ repo, loopRepo, now: clock(), log }) : null;
269
+ return { status: 'refused', outcome, stage, idea_id: idea.id, reason, ...(loopIdea ? { loop_idea: loopIdea.id } : {}) };
270
+ };
271
+ try {
272
+ if (recoverInterrupted(repo, readIdeas(repo)).length) feedLoop({ repo, loopRepo, now: clock(), log });
273
+ const idea = nextWaitingIdea(readIdeas(repo));
274
+ if (!idea) {
275
+ log('no ideas waiting.');
276
+ return { status: 'idle' };
277
+ }
278
+ const intake = ideaRefusal(idea.text);
279
+ if (intake) return stop(idea, { outcome: 'refused', stage: 'intake', cause: intake.split(' (')[0], reason: intake });
280
+ const verify = fastCheck(repo, idea);
281
+ if (!verify) {
282
+ return stop(idea, { stage: 'checks', cause: 'no fast check found', reason: `this repo has no fast check I can find; give one: atris idea retry ${idea.n} --verify "<cmd>"` });
283
+ }
284
+
285
+ started = iso();
286
+ current = updateIdea(repo, idea.id, { status: 'building', attempts: Number(idea.attempts || 0) + 1, started_at: started }) || idea;
287
+ log(`building idea ${idea.n}: ${oneLine(idea.text, 80)}`);
288
+
289
+ let lap = null;
290
+ try {
291
+ await runLap(idea.text, {
292
+ root: repo,
293
+ verifier: verify,
294
+ maxSeconds,
295
+ brief: IDEA_BRIEF,
296
+ asJson: true,
297
+ quiet: true,
298
+ onResult: (result) => { lap = result; },
299
+ progress: (line) => log(` ${String(line || '').trim()}`),
300
+ });
301
+ } catch (error) {
302
+ const why = oneLine(error && error.message || error, 120);
303
+ return stop(idea, { stage: 'build', cause: `the build crashed: ${why}`, reason: `the build crashed: ${why}` });
304
+ }
305
+ if (!lap) return stop(idea, { stage: 'build', reason: 'the build ended without a result' });
306
+ const taskId = lap.task_id || null;
307
+ if (taskId) updateIdea(repo, idea.id, { task_id: taskId });
308
+ if (lap.status === 'waiting_input') {
309
+ return stop(idea, {
310
+ outcome: 'refused',
311
+ stage: 'build',
312
+ cause: 'the build asked the owner a question',
313
+ reason: `needs your answer: ${oneLine(lap.question || 'the build asked a question', 120)}`,
314
+ taskId,
315
+ });
316
+ }
317
+ if (lap.status !== 'done') {
318
+ const why = oneLine(lap.reason || 'it stopped early', 120);
319
+ return stop(idea, { stage: lapStage(lap), cause: why, reason: `the build did not pass: ${why}`, taskId });
320
+ }
321
+ if (!secondReviewerSignedOff(lap)) {
322
+ return stop(idea, { stage: 'review', cause: 'no second engine signed off', reason: 'no second engine signed off on the change, needs owner', taskId });
323
+ }
324
+ if (!lap.worktree) return stop(idea, { stage: 'build', reason: 'the built change has no copy to land from', taskId });
325
+
326
+ const target = landTarget(lap.worktree);
327
+ const change = inspectChange(lap.worktree, target);
328
+ if (!change || change.ok === false) {
329
+ return stop(idea, {
330
+ stage: 'review',
331
+ cause: 'could not read the built change',
332
+ reason: `could not read the built change: ${oneLine(change && change.detail, 100)}`,
333
+ taskId,
334
+ });
335
+ }
336
+ const unsafe = changeRefusal(change);
337
+ if (unsafe) {
338
+ return stop(idea, {
339
+ outcome: 'refused',
340
+ stage: 'review',
341
+ cause: unsafe.split(' (')[0],
342
+ reason: `built and reviewed, but ${unsafe}; it waits in review as ${taskId}`,
343
+ taskId,
344
+ });
345
+ }
346
+
347
+ const args = shipArgsFor({
348
+ title: lap.changed || idea.text,
349
+ taskId: taskId || idea.id,
350
+ engine: lap.engine || 'engine',
351
+ verify,
352
+ target,
353
+ local: !hasOrigin(lap.worktree),
354
+ });
355
+ log(` landing idea ${idea.n}: the fast check runs once more first`);
356
+ const shipped = ship(lap.worktree, args) || {};
357
+ const out = `${shipped.stdout || ''}\n${shipped.stderr || ''}`;
358
+ if (shipped.status !== 0 || !/done: worktree shipped/.test(out)) {
359
+ const why = out.split('\n').map((line) => line.trim()).filter(Boolean).slice(-1)[0] || 'landing failed';
360
+ return stop(idea, { stage: shipStage(out), cause: oneLine(why, 120), reason: `the change did not land: ${oneLine(why, 120)}`, taskId });
361
+ }
362
+ const pr = (out.match(/^pr:\s*(https?:\/\/\S+)/m) || [])[1] || '';
363
+ const queued = /merge: pending/.test(out);
364
+ const link = pr || 'merged on the local branch';
365
+ updateIdea(repo, idea.id, { status: 'landed', link, task_id: taskId, ...(queued ? { note: 'merge queued' } : {}) });
366
+ recordOutcome(repo, { idea: current, outcome: 'landed', started, finished: iso(), taskId, link: pr || null });
367
+ log(`idea ${idea.n} landed${queued ? ' (merge queued)' : ''}: ${link}`);
368
+ return { status: 'landed', outcome: 'landed', idea_id: idea.id, link, task_id: taskId };
369
+ } finally {
370
+ releaseTickLock(lock);
371
+ }
372
+ }
373
+
374
+ module.exports = {
375
+ DEFAULT_BUILD_SECONDS,
376
+ IDEA_BRIEF,
377
+ MAX_ATTEMPTS,
378
+ acquireTickLock,
379
+ fastCheck,
380
+ lockFile,
381
+ releaseTickLock,
382
+ runIdeaTick,
383
+ shipArgsFor,
384
+ };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
3
+ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'idea', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', '99', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'sync-checkout', 'zen',
@@ -271,6 +271,7 @@ function teamRosterView(root = process.cwd(), options = {}) {
271
271
 
272
272
  module.exports = {
273
273
  autoJobForMember,
274
+ readMemberCard,
274
275
  resolveEngineForMember,
275
276
  memberRosterEngine,
276
277
  teamRosterView,
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ const { createHash } = require('node:crypto');
4
+ const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
5
+ const { decodeJwtClaims } = require('../utils/auth');
6
+ const { parseSessionLog, lineIdentity, uniqueLines } = require('./ninety-nine');
7
+
8
+ const WORKSPACE_DIRECTORY = 'atris/99';
9
+
10
+ function validateSync(sync) {
11
+ if (!sync || !['personal', 'workspace'].includes(sync.target)) {
12
+ throw new Error('choose sync target personal or workspace in the 99 config');
13
+ }
14
+ const appSlug = sync.app_slug ?? 'project-99';
15
+ if (sync.target === 'personal' && (typeof appSlug !== 'string' || !/^[a-z][a-z0-9-]{1,63}$/.test(appSlug))) {
16
+ throw new Error('sync app_slug must be 2 to 64 lowercase letters, digits, or hyphens, starting with a letter');
17
+ }
18
+ if (sync.target === 'workspace' && (typeof sync.business !== 'string' || !sync.business.trim())) {
19
+ throw new Error('set sync business to your business slug in the 99 config');
20
+ }
21
+ if (sync.workspace !== undefined && (typeof sync.workspace !== 'string' || !sync.workspace.trim())) {
22
+ throw new Error('sync workspace must be a workspace id');
23
+ }
24
+ }
25
+
26
+ function checkpointKey(sync, credentials) {
27
+ const account = credentials.user_id || decodeJwtClaims(credentials.token)?.sub || credentials.email
28
+ || createHash('sha256').update(credentials.token).digest('hex');
29
+ return createHash('sha256').update(JSON.stringify([
30
+ getApiBaseUrl(), account, sync.target, sync.app_slug ?? 'project-99', sync.business, sync.workspace,
31
+ ])).digest('hex');
32
+ }
33
+
34
+ async function requestData(request, endpoint, options) {
35
+ let response;
36
+ try { response = await request(endpoint, options); } catch {
37
+ throw new Error('could not reach sync; try again');
38
+ }
39
+ if (!response?.ok) {
40
+ if ([401, 403].includes(response?.status)) throw new Error('sync needs your signed-in user login; run atris login');
41
+ // Server errors can echo request headers. Keep credentials out of all output.
42
+ throw new Error(`sync request failed${response?.status ? ` (http ${response.status})` : ''}; try again`);
43
+ }
44
+ return response.data;
45
+ }
46
+
47
+ // Personal records are append-only. Retain acknowledged identities as well as
48
+ // the server's received-time cursor, so retries send only unacknowledged lines.
49
+ async function personalPlan(records, sync, checkpoint, request, token) {
50
+ const endpoint = `/me/apps/${sync.app_slug ?? 'project-99'}/records`;
51
+ const known = new Set(checkpoint.known || []);
52
+ let since = checkpoint.since || null;
53
+ const cursors = new Set(since ? [since] : []);
54
+ const remote = [];
55
+ for (;;) {
56
+ const query = new URLSearchParams({ limit: '1000' });
57
+ if (since) query.set('since', since);
58
+ const data = await requestData(request, `${endpoint}?${query}`, { method: 'GET', token });
59
+ if (!Array.isArray(data?.lines) || data.lines.length > 1000
60
+ || !(data.next_since === null || (typeof data.next_since === 'string' && Number.isFinite(Date.parse(data.next_since))))) {
61
+ throw new Error('sync returned invalid records or cursor; nothing was saved');
62
+ }
63
+ const lines = parseSessionLog(data.lines.map((line) => JSON.stringify(line)).join('\n'));
64
+ remote.push(...lines);
65
+ for (const line of lines) known.add(lineIdentity(line));
66
+ if (!data.next_since) break;
67
+ if (cursors.has(data.next_since) || (since && Date.parse(data.next_since) <= Date.parse(since))) {
68
+ if (lines.length) throw new Error('sync cursor did not advance; nothing was saved');
69
+ break;
70
+ }
71
+ since = data.next_since;
72
+ cursors.add(since);
73
+ if (!lines.length) break;
74
+ }
75
+ const send = records.filter((line) => !known.has(lineIdentity(line)));
76
+ if (send.some((line) => Buffer.byteLength(JSON.stringify(line), 'utf8') > 2048)) {
77
+ throw new Error('a personal sync line exceeds 2 kb; nothing was sent');
78
+ }
79
+ return { remote, send, known, since, endpoint };
80
+ }
81
+
82
+ async function workspacePlan(records, sync, deviceId, request, token) {
83
+ if (typeof deviceId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(deviceId)) {
84
+ throw new Error('check the 99 sync-state device id');
85
+ }
86
+ const deviceFile = `${WORKSPACE_DIRECTORY}/sessions.${deviceId}.jsonl`;
87
+ // Use the same business lookup, filtered snapshot, and file sync routes as
88
+ // push.js and pull.js. Never invoke those commands or sync unrelated files.
89
+ const { businessMatchesSlug } = require('../commands/business');
90
+ const businesses = await requestData(request, '/business/', { method: 'GET', token });
91
+ if (!Array.isArray(businesses)) throw new Error('sync returned an invalid business list');
92
+ const business = businesses.find((item) => businessMatchesSlug(item, sync.business, { includeName: true }));
93
+ if (!business?.id) throw new Error('sync business was not found; check the 99 config');
94
+ const workspace = sync.workspace || business.workspace_id;
95
+ if (!workspace) throw new Error('sync business has no default workspace; set sync workspace in the 99 config');
96
+ const endpoint = `/business/${encodeURIComponent(business.id)}/workspaces/${encodeURIComponent(workspace)}`;
97
+ const data = await requestData(request, `${endpoint}/snapshot?include_content=true&paths=${encodeURIComponent(WORKSPACE_DIRECTORY)}`, {
98
+ method: 'GET', token,
99
+ });
100
+ if (!Array.isArray(data?.files)) throw new Error('sync returned an invalid workspace snapshot');
101
+ const files = data.files.filter((item) => typeof item?.path === 'string'
102
+ && /^atris\/99\/sessions(?:\.[^/]+)?\.jsonl$/.test(item.path.replace(/^\/+/, '')));
103
+ if (files.some((file) => typeof file.content !== 'string')) {
104
+ throw new Error('sync could not read the workspace session log');
105
+ }
106
+ const text = files.find((file) => file.path.replace(/^\/+/, '') === deviceFile)?.content || '';
107
+ const remote = uniqueLines(files.flatMap((file) => parseSessionLog(file.content)));
108
+ const known = new Set(remote.map(lineIdentity));
109
+ const send = records.filter((line) => !known.has(lineIdentity(line)));
110
+ const separator = text && !text.endsWith('\n') ? '\n' : '';
111
+ const content = text + (send.length ? separator + send.map((line) => JSON.stringify(line)).join('\n') + '\n' : '');
112
+ return { remote, send, content, endpoint, deviceFile };
113
+ }
114
+
115
+ async function syncLines({ records, sync, checkpoint = {}, deviceId, credentials, dryRun = false, request = apiRequestJson }) {
116
+ validateSync(sync);
117
+ const local = uniqueLines(records);
118
+ const plan = sync.target === 'personal'
119
+ ? await personalPlan(local, sync, checkpoint, request, credentials.token)
120
+ : await workspacePlan(local, sync, deviceId, request, credentials.token);
121
+ const localKeys = new Set(local.map(lineIdentity));
122
+ const pull = uniqueLines(plan.remote).filter((line) => !localKeys.has(lineIdentity(line)));
123
+ if (!dryRun && plan.send.length) {
124
+ if (sync.target === 'personal') {
125
+ for (let offset = 0; offset < plan.send.length; offset += 500) {
126
+ const lines = plan.send.slice(offset, offset + 500);
127
+ const data = await requestData(request, plan.endpoint, {
128
+ method: 'POST', token: credentials.token, body: { lines },
129
+ });
130
+ if (!Number.isInteger(data?.stored) || !Number.isInteger(data?.skipped)
131
+ || data.stored < 0 || data.skipped < 0 || data.stored + data.skipped !== lines.length) {
132
+ throw new Error('sync did not acknowledge every line; try again');
133
+ }
134
+ }
135
+ } else {
136
+ const data = await requestData(request, `${plan.endpoint}/sync?timeout=120`, {
137
+ method: 'POST', token: credentials.token,
138
+ headers: { 'X-Atris-Actor-Source': 'cli' }, timeoutMs: 135000,
139
+ body: { files: [{ path: plan.deviceFile, content: plan.content }] },
140
+ });
141
+ const result = data?.results?.find((item) => item?.path?.replace(/^\/+/, '') === plan.deviceFile);
142
+ if (!result || !['written', 'unchanged'].includes(result.status)) {
143
+ throw new Error('sync did not confirm the workspace file was saved; try again');
144
+ }
145
+ }
146
+ }
147
+ return {
148
+ send: plan.send,
149
+ pull,
150
+ checkpoint: sync.target === 'personal'
151
+ ? { since: plan.since, known: [...new Set([...plan.known, ...plan.send.map(lineIdentity)])] }
152
+ : {},
153
+ };
154
+ }
155
+
156
+ module.exports = { validateSync, checkpointKey, syncLines };