atris 3.34.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 (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. package/atris/learnings.jsonl +0 -1
@@ -0,0 +1,477 @@
1
+ // Orb context — deterministic greeting + suggestion synthesis for the orb surface.
2
+ // Pure synthesis lives in buildOrbContext (unit-testable, no fs); collectOrbContext
3
+ // does the read-only fs aggregation from a project root. No model calls anywhere:
4
+ // 0ms-to-greeting is the contract (atris/features/orb-desktop/PLAN.md).
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const MAX_SUGGESTIONS = 12;
9
+ const WARM_THREAD_WINDOW_MS = 48 * 60 * 60 * 1000;
10
+ const STALLED_TASK_WINDOW_MS = 24 * 60 * 60 * 1000;
11
+ const BACKLOG_STATUSES = new Set(['open', 'backlog', 'inbox', 'planned', 'plan', 'todo', 'ready']);
12
+
13
+ function cleanTitle(value, max = 64) {
14
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
15
+ if (!text) return '';
16
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
17
+ }
18
+
19
+ function asRecord(value) {
20
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
21
+ }
22
+
23
+ function normalizeTime(value) {
24
+ if (value === null || value === undefined || value === '') return 0;
25
+ const numeric = Number(value);
26
+ if (Number.isFinite(numeric) && numeric > 0) return numeric;
27
+ const parsed = Date.parse(String(value));
28
+ return Number.isFinite(parsed) ? parsed : 0;
29
+ }
30
+
31
+ function todayKey(now) {
32
+ const stamp = normalizeTime(now);
33
+ if (!stamp) return '';
34
+ return new Date(stamp).toISOString().slice(0, 10);
35
+ }
36
+
37
+ function normalizeStatus(value) {
38
+ return String(value || '').toLowerCase().replace(/_/g, '-').trim();
39
+ }
40
+
41
+ function normalizeTasks(tasks) {
42
+ if (!Array.isArray(tasks)) return [];
43
+ return tasks
44
+ .filter((t) => t && typeof t === 'object')
45
+ .map((t) => {
46
+ const metadata = asRecord(t.metadata);
47
+ const review = asRecord(t.review);
48
+ return {
49
+ id: String(t.display_id || t.id || ''),
50
+ title: cleanTitle(t.title),
51
+ status: normalizeStatus(t.status),
52
+ lane: cleanTitle(t.lane || t.task_lane || metadata.lane || t.tag || t.status || 'backlog', 24),
53
+ updatedAt: normalizeTime(t.updated_at || t.updatedAt),
54
+ metadata,
55
+ review,
56
+ };
57
+ })
58
+ .filter((t) => t.title);
59
+ }
60
+
61
+ // Known suggestion action prefixes. Stripped repeatedly (front-anchored) so that
62
+ // "review: review: X" or "pick up pick up X" both collapse to the bare title —
63
+ // used both to keep labels single-prefixed and to compare titles across sources.
64
+ // "atrisbox" is included because Atrisbox threads are titled "Atrisbox: <label>",
65
+ // which otherwise shields inner accumulated prefixes from stripping (seen live
66
+ // in the burst-tap rig: "pick up Atrisbox: pick up Atrisbox: plan today").
67
+ // The atrisbox marker requires its colon so a thread genuinely titled
68
+ // "Atrisbox" keeps its name.
69
+ const ACTION_PREFIX_RE = /^(?:(?:review|resume|pick up|keep going on)\s*:?|atrisbox\s*:)\s*/i;
70
+
71
+ function stripActionPrefixes(value) {
72
+ let text = String(value || '').trim();
73
+ let prev;
74
+ do {
75
+ prev = text;
76
+ text = text.replace(ACTION_PREFIX_RE, '').trim();
77
+ } while (text !== prev);
78
+ return text;
79
+ }
80
+
81
+ // Lowercase, prefix-stripped, whitespace-collapsed form used only for equality/
82
+ // prefix comparison across sources (tasks vs threads) — never shown to the user.
83
+ function normalizeForMatch(value) {
84
+ return stripActionPrefixes(String(value || '').toLowerCase().replace(/\s+/g, ' ').trim());
85
+ }
86
+
87
+ // True when two titles are "the same work" — exact match, or one is a truncated
88
+ // prefix of the other (titles get cut at different lengths per source, so a
89
+ // straight equality check would miss the common case). Guarded by a minimum
90
+ // needle length so short titles ("fix it") don't false-positive against unrelated
91
+ // longer ones.
92
+ function titlesOverlap(a, b) {
93
+ const na = normalizeForMatch(a);
94
+ const nb = normalizeForMatch(b);
95
+ if (!na || !nb) return false;
96
+ if (na === nb) return true;
97
+ const [shorter, longer] = na.length <= nb.length ? [na, nb] : [nb, na];
98
+ if (shorter.length < 12) return false;
99
+ return longer.startsWith(shorter);
100
+ }
101
+
102
+ // Background Atrisbox taps write a launch-receipt thread (desk_<project>_
103
+ // atrisbox_bg_<slug>) — it's proof a turn ran, not a resumable conversation.
104
+ // They self-archive on delivery (App.tsx sendAtrisboxMessage) and main.js
105
+ // sweeps stragglers, but excluding them from warm eligibility here too is
106
+ // cheap (a string check, no I/O) and keeps the count honest even for threads
107
+ // that haven't been swept or archived yet (OBL-1674).
108
+ function isAtrisboxBgThread(id) {
109
+ return /_atrisbox_bg_/.test(String(id || ''));
110
+ }
111
+
112
+ function normalizeThreads(threads) {
113
+ if (!Array.isArray(threads)) return [];
114
+ return threads
115
+ .filter((t) => t && typeof t === 'object' && !t.archivedAt && !isAtrisboxBgThread(t.id))
116
+ .map((t) => ({
117
+ id: String(t.id || ''),
118
+ title: cleanTitle(t.title || 'Untitled'),
119
+ updatedAt: Number(t.updatedAt || 0) || 0,
120
+ }))
121
+ .sort((a, b) => b.updatedAt - a.updatedAt);
122
+ }
123
+
124
+ // Dedupe by "same work" (titlesOverlap) keeping the newest of each cluster —
125
+ // used before counting warm threads so the greeting number reflects distinct
126
+ // resumable work, not the same thread title repeated under different ids.
127
+ function dedupeByTitle(threads) {
128
+ const kept = [];
129
+ for (const t of threads) {
130
+ if (kept.some((k) => titlesOverlap(k.title, t.title))) continue;
131
+ kept.push(t);
132
+ }
133
+ return kept;
134
+ }
135
+
136
+ function reviewApprovalStatus(task) {
137
+ const metadata = asRecord(task && task.metadata);
138
+ const review = asRecord(task && task.review);
139
+ return normalizeStatus(review.approval_status || metadata.approval_status || metadata.review_status);
140
+ }
141
+
142
+ function reviewPassCount(task) {
143
+ const metadata = asRecord(task && task.metadata);
144
+ const review = asRecord(task && task.review);
145
+ const value = Number(review.agent_review_pass_count ?? metadata.agent_review_pass_count ?? 0);
146
+ return Number.isFinite(value) ? value : 0;
147
+ }
148
+
149
+ function isAgentCertifiedReview(task) {
150
+ const metadata = asRecord(task && task.metadata);
151
+ const review = asRecord(task && task.review);
152
+ const handoff = asRecord(review.handoff);
153
+ return metadata.agent_certified === true
154
+ || metadata.agent_certified === 'true'
155
+ || review.agent_certified === true
156
+ || review.agent_certified === 'true'
157
+ || handoff.native_goal_status === 'agent_certified'
158
+ || reviewPassCount(task) >= 2;
159
+ }
160
+
161
+ function isCertifiedReviewWaiting(task) {
162
+ if (!task || task.status !== 'review' || !isAgentCertifiedReview(task)) return false;
163
+ const approval = reviewApprovalStatus(task);
164
+ return !['accept', 'accepted', 'done', 'complete', 'completed', 'revise', 'rejected'].includes(approval);
165
+ }
166
+
167
+ function isStalledInProgress(task, now) {
168
+ if (!task || !['claimed', 'doing'].includes(task.status) || !task.updatedAt) return false;
169
+ return now - task.updatedAt >= STALLED_TASK_WINDOW_MS;
170
+ }
171
+
172
+ function pluralize(count, one, many = `${one}s`) {
173
+ return count === 1 ? one : many;
174
+ }
175
+
176
+ // Pure. Input: plain data already read from disk. Output: greeting facts, greeting
177
+ // beats (typed out by the surface), and ranked suggestions (each launches a real turn).
178
+ function buildOrbContext({ nowMd = '', tasks = [], threads = [], memberLoops = [], now = Date.now() } = {}) {
179
+ const cleanTasks = normalizeTasks(tasks);
180
+ const cleanThreads = normalizeThreads(threads);
181
+ const reviewTasks = cleanTasks.filter((t) => t.status === 'review').sort((a, b) => b.updatedAt - a.updatedAt);
182
+ const certifiedReviewTasks = reviewTasks.filter(isCertifiedReviewWaiting).sort((a, b) => b.updatedAt - a.updatedAt);
183
+ const doingTasks = cleanTasks.filter((t) => t.status === 'claimed' || t.status === 'doing').sort((a, b) => b.updatedAt - a.updatedAt);
184
+ const stalledTasks = doingTasks.filter((t) => isStalledInProgress(t, now)).sort((a, b) => a.updatedAt - b.updatedAt);
185
+ const failedTasks = cleanTasks.filter((t) => t.status === 'failed').sort((a, b) => b.updatedAt - a.updatedAt);
186
+ const backlogTasks = cleanTasks.filter((t) => BACKLOG_STATUSES.has(t.status)).sort((a, b) => b.updatedAt - a.updatedAt);
187
+ const warmThreadsRaw = cleanThreads.filter((t) => now - t.updatedAt < WARM_THREAD_WINDOW_MS);
188
+ // Dedupe BEFORE counting so the greeting number ("N threads are warm") and
189
+ // the resume-suggestion loop below both see the same deduped set.
190
+ const warmThreads = dedupeByTitle(warmThreadsRaw);
191
+ const loops = (Array.isArray(memberLoops) ? memberLoops : [])
192
+ .filter((l) => l && typeof l === 'object' && l.member)
193
+ .map((l) => ({ member: String(l.member), mode: String(l.mode || '') }));
194
+ const hasNow = Boolean(String(nowMd || '').trim());
195
+ const day = todayKey(now);
196
+ const hasJournalToday = Boolean(day && String(nowMd || '').includes(day));
197
+ const journalEmptyToday = Boolean(day && !hasJournalToday);
198
+
199
+ const greetingFacts = {
200
+ threadCount: cleanThreads.length,
201
+ warmThreadCount: warmThreads.length,
202
+ reviewCount: reviewTasks.length,
203
+ doingCount: doingTasks.length,
204
+ certifiedReviewCount: certifiedReviewTasks.length,
205
+ stalledDoingCount: stalledTasks.length,
206
+ failedCount: failedTasks.length,
207
+ loopCount: loops.length,
208
+ hasNow,
209
+ hasJournalToday,
210
+ };
211
+
212
+ const suggestions = [];
213
+ // Titles already spoken for by a suggestion, used to dedupe across sources
214
+ // (review/resume/continue are built independently but can point at the same
215
+ // underlying work — e.g. a task's warm thread title vs the task's own title).
216
+ const usedTitles = [];
217
+
218
+ function pushSuggestion(suggestion, title = '') {
219
+ const key = normalizeForMatch(suggestion.label);
220
+ if (suggestions.some((s) => s.id === suggestion.id || normalizeForMatch(s.label) === key)) return false;
221
+ suggestions.push(suggestion);
222
+ if (title) usedTitles.push(title);
223
+ return true;
224
+ }
225
+
226
+ if (certifiedReviewTasks.length) {
227
+ const count = certifiedReviewTasks.length;
228
+ pushSuggestion({
229
+ id: 'review:certified',
230
+ label: `accept ${count} ${pluralize(count, 'review')}`,
231
+ kind: 'review',
232
+ reason: 'Certified proof is waiting on human acceptance.',
233
+ side: count === 1 ? certifiedReviewTasks[0].id || 'certified' : `${count} ready`,
234
+ prompt: `Review the ${count} agent-certified ${pluralize(count, 'task')} waiting on human acceptance. Read the proof, accept only clear wins, and call out anything that needs revision.`,
235
+ });
236
+ }
237
+
238
+ const reviewTask = certifiedReviewTasks.length
239
+ ? reviewTasks.find((t) => !isCertifiedReviewWaiting(t))
240
+ : reviewTasks[0];
241
+ if (reviewTask) {
242
+ const alreadyLabeled = /^review\s*:?/i.test(reviewTask.title);
243
+ pushSuggestion({
244
+ id: `review:${reviewTask.id}`,
245
+ label: alreadyLabeled ? reviewTask.title : `review: ${reviewTask.title}`,
246
+ kind: 'review',
247
+ reason: 'A task is waiting for judgment.',
248
+ side: reviewTask.id || 'in review',
249
+ prompt: `Task ${reviewTask.id} ("${reviewTask.title}") is waiting in review. Read its proof and diff, then tell me plainly: accept, or what exactly to fix.`,
250
+ }, reviewTask.title);
251
+ }
252
+
253
+ if (failedTasks.length) {
254
+ const count = failedTasks.length;
255
+ pushSuggestion({
256
+ id: 'unblock:failed-tasks',
257
+ label: `unblock ${count} failed ${pluralize(count, 'task')}`,
258
+ kind: 'unblock',
259
+ reason: 'Failed tasks are blocking the queue.',
260
+ side: count === 1 ? failedTasks[0].id || 'failed' : `${count} failed`,
261
+ prompt: `There ${count === 1 ? 'is' : 'are'} ${count} failed ${pluralize(count, 'task')} in this project. Find the failures, group the blockers, and recommend the fastest unblock path before changing anything.`,
262
+ });
263
+ }
264
+
265
+ // Explicitly blocked/stalled statuses are their own lane — distinct from the
266
+ // stale-doing "finish" lane below, which infers staleness from the clock.
267
+ const blockedTask = cleanTasks
268
+ .filter((t) => t.status === 'blocked' || t.status === 'stalled')
269
+ .sort((a, b) => b.updatedAt - a.updatedAt)[0];
270
+ if (blockedTask) {
271
+ pushSuggestion({
272
+ id: `unstick:${blockedTask.id}`,
273
+ label: `unstick: ${cleanTitle(stripActionPrefixes(blockedTask.title), 48)}`,
274
+ kind: 'continue',
275
+ reason: 'This task is marked blocked.',
276
+ side: blockedTask.id || blockedTask.lane || 'stalled',
277
+ prompt: `Run "atris task show ${blockedTask.id}" first. Then unblock one bounded slice of "${blockedTask.title}" and return proof with the files changed and exact verification command plus exit code.`,
278
+ }, blockedTask.title);
279
+ }
280
+
281
+ // The claimed/doing task will always be offered if present, so its title counts
282
+ // as "already included" for warm-thread dedupe purposes even though the
283
+ // suggestion itself is pushed after resume (to preserve review > resume > continue
284
+ // ranking).
285
+ const doingTask = doingTasks[0];
286
+ if (doingTask) usedTitles.push(doingTask.title);
287
+ const stalledTask = stalledTasks[0];
288
+ if (stalledTask) usedTitles.push(stalledTask.title);
289
+
290
+ if (stalledTask) {
291
+ const alreadyLabeled = /^finish\s*/i.test(stalledTask.title);
292
+ pushSuggestion({
293
+ id: `finish:${stalledTask.id}`,
294
+ label: alreadyLabeled ? stalledTask.title : `finish ${stalledTask.title}`,
295
+ kind: 'finish',
296
+ reason: 'This in-progress task has gone stale.',
297
+ side: stalledTask.id || 'stalled',
298
+ prompt: `Task ${stalledTask.id} ("${stalledTask.title}") is in progress and stale. Inspect its latest state, finish the smallest safe slice, and leave proof of what changed.`,
299
+ }, stalledTask.title);
300
+ }
301
+
302
+ let resumeThread = null;
303
+ let resumeLabelTitle = '';
304
+ for (const thread of warmThreads) {
305
+ if (usedTitles.some((title) => titlesOverlap(title, thread.title))) continue;
306
+ // Thread titles inherit message text, so a picked-up thread can arrive as
307
+ // "pick up X: pick up X..." — strip the accumulated prefixes before reusing.
308
+ const threadTitle = cleanTitle(stripActionPrefixes(String(thread.title || '')), 40);
309
+ if (threadTitle.length < 3) continue;
310
+ resumeThread = thread;
311
+ resumeLabelTitle = threadTitle;
312
+ break;
313
+ }
314
+ if (resumeThread) {
315
+ pushSuggestion({
316
+ id: `resume:${resumeThread.id}`,
317
+ label: `pick up ${resumeLabelTitle}`,
318
+ kind: 'resume',
319
+ reason: 'This thread was active recently.',
320
+ side: 'warm thread',
321
+ prompt: `Pick the thread "${resumeThread.title}" back up: read where it left off in this project and continue the work from there.`,
322
+ }, resumeThread.title);
323
+ }
324
+
325
+ if (doingTask && (!stalledTask || !titlesOverlap(doingTask.title, stalledTask.title))) {
326
+ const alreadyLabeled = /^keep going on\s*/i.test(doingTask.title);
327
+ pushSuggestion({
328
+ id: `continue:${doingTask.id}`,
329
+ label: alreadyLabeled ? doingTask.title : `keep going on ${doingTask.title}`,
330
+ kind: 'continue',
331
+ reason: 'This task is already claimed.',
332
+ side: doingTask.id || 'claimed',
333
+ prompt: `Task ${doingTask.id} ("${doingTask.title}") is claimed but not done. Check its current state and take the next concrete step.`,
334
+ }, doingTask.title);
335
+ }
336
+ if (journalEmptyToday) {
337
+ pushSuggestion({
338
+ id: 'log-today',
339
+ label: 'log what shipped',
340
+ kind: 'log',
341
+ reason: 'Today has no journal entry yet.',
342
+ side: day || 'today',
343
+ prompt: `Write a short ${day || 'today'} journal entry for this project: what shipped, what is still open, and the next concrete move. Keep it factual and brief.`,
344
+ });
345
+ }
346
+ // Backlog tasks deepen the well without another read: collectOrbContext already
347
+ // loaded the full task projection. Keep them behind urgent/resumable work and
348
+ // ahead of the evergreen tail so the menu survives many in-flight dispatches.
349
+ for (const task of backlogTasks) {
350
+ if (suggestions.length >= MAX_SUGGESTIONS) break;
351
+ if (usedTitles.some((title) => titlesOverlap(title, task.title))) continue;
352
+ const shortTitle = cleanTitle(stripActionPrefixes(task.title), 48);
353
+ if (!shortTitle) continue;
354
+ pushSuggestion({
355
+ id: `start:${task.id}`,
356
+ label: `start: ${shortTitle}`,
357
+ kind: task.status === 'planned' || task.status === 'plan' || task.status === 'ready' ? 'continue' : 'plan',
358
+ reason: 'Backlog work ready to begin.',
359
+ side: task.lane || 'backlog',
360
+ prompt: `Run "atris task show ${task.id}" first. Then execute one bounded slice of "${task.title}" and return proof with the files changed and exact verification command plus exit code.`,
361
+ }, task.title);
362
+ }
363
+
364
+ // These stay at the tail even when the backlog is full, so orientation and
365
+ // planning never disappear behind the deeper task-fed pool.
366
+ // 'status' is answered locally by the view from gmSnapshot — no model call.
367
+ const staticSuggestions = [{
368
+ id: 'status',
369
+ label: "what's moving",
370
+ kind: 'digest',
371
+ reason: 'Get the live team snapshot first.',
372
+ side: 'instant',
373
+ lane: 'instant',
374
+ prompt: 'Give me a team status: who is working, the latest result, and what needs me.',
375
+ }, {
376
+ id: 'plan-today',
377
+ label: 'plan today',
378
+ kind: 'plan',
379
+ reason: hasNow ? "Use today's notes to pick the move." : 'Start from project context.',
380
+ side: hasNow ? 'from now.md' : 'fresh',
381
+ prompt: hasNow
382
+ ? 'Read now.md and atris/TODO.md in this project, then give me a short plan for today: the one move that matters most, and two backups.'
383
+ : 'Look at this project (README, atris/MAP.md if present) and propose a short plan for today: the one move that matters most, and two backups.',
384
+ }, {
385
+ id: 'look-around',
386
+ label: 'show me around',
387
+ kind: 'plan',
388
+ reason: 'Orientation is always one tap away.',
389
+ side: 'orientation',
390
+ prompt: 'Read this project and tell me in plain words what it is, what state it is in, and the top three next moves.',
391
+ }];
392
+
393
+ const workSuggestions = suggestions.slice(0, MAX_SUGGESTIONS - staticSuggestions.length);
394
+ const finalSuggestions = [...workSuggestions, ...staticSuggestions];
395
+
396
+ const beats = [];
397
+ const factBits = [];
398
+ if (warmThreads.length) factBits.push(warmThreads.length === 1 ? 'One thread is warm.' : `${warmThreads.length} threads are warm.`);
399
+ if (reviewTasks.length) factBits.push(reviewTasks.length === 1 ? 'One task waits for review.' : `${reviewTasks.length} tasks wait for review.`);
400
+ if (doingTasks.length && factBits.length < 2) factBits.push(doingTasks.length === 1 ? 'One task is mid-flight.' : `${doingTasks.length} tasks are mid-flight.`);
401
+ if (loops.length && factBits.length < 2) factBits.push(loops.length === 1 ? 'One member loop ran recently.' : `${loops.length} member loops ran recently.`);
402
+ if (factBits.length) {
403
+ beats.push(factBits.slice(0, 2).join(' '));
404
+ beats.push('Tap the next move.');
405
+ } else {
406
+ beats.push('Quiet workspace. Nothing is mid-flight.');
407
+ beats.push('Tap a starting move, or say it your way.');
408
+ }
409
+
410
+ return {
411
+ schema: 'obelisk.orb_context.v1',
412
+ greeting_facts: greetingFacts,
413
+ beats,
414
+ suggestions: finalSuggestions,
415
+ };
416
+ }
417
+
418
+ async function readTextSafe(filePath, maxBytes = 24000) {
419
+ try {
420
+ const raw = await fs.promises.readFile(filePath, 'utf8');
421
+ return raw.length > maxBytes ? raw.slice(0, maxBytes) : raw;
422
+ } catch {
423
+ return '';
424
+ }
425
+ }
426
+
427
+ async function readJsonSafe(filePath) {
428
+ try {
429
+ return JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
430
+ } catch {
431
+ return null;
432
+ }
433
+ }
434
+
435
+ // Read-only aggregation from a project root. Creates nothing; absent files = empty.
436
+ // `threads` come from the caller (main.js owns ~/.obelisk/threads listing).
437
+ async function collectOrbContext(root, { threads = [], now = Date.now() } = {}) {
438
+ const safeRoot = typeof root === 'string' && root ? path.resolve(root) : null;
439
+ if (!safeRoot) return buildOrbContext({ threads, now });
440
+ const nowMd = await readTextSafe(path.join(safeRoot, 'now.md'), 8000);
441
+ const projection = await readJsonSafe(path.join(safeRoot, '.atris', 'state', 'tasks.projection.json'));
442
+ const tasks = projection && projection.schema === 'atris.task_projection.v1' && Array.isArray(projection.tasks)
443
+ ? projection.tasks
444
+ : [];
445
+ const memberLoops = [];
446
+ try {
447
+ const loopDir = path.join(safeRoot, '.atris', 'state', 'member-loops');
448
+ const names = (await fs.promises.readdir(loopDir)).filter((n) => n.endsWith('.latest.json')).slice(0, 12);
449
+ for (const name of names) {
450
+ const payload = await readJsonSafe(path.join(loopDir, name));
451
+ if (payload) memberLoops.push(payload);
452
+ }
453
+ } catch {}
454
+ return buildOrbContext({ nowMd, tasks, threads, memberLoops, now });
455
+ }
456
+
457
+ // The accumulate-context flywheel: each chosen option appends one visible line to the
458
+ // project's now.md. Append-only; creates now.md on first choice.
459
+ async function appendOrbChoice(root, label, when = new Date()) {
460
+ const safeRoot = typeof root === 'string' && root ? path.resolve(root) : null;
461
+ const cleanLabel = cleanTitle(label, 120);
462
+ if (!safeRoot || !cleanLabel) return { ok: false, error: 'invalid root or label' };
463
+ const day = when.toISOString().slice(0, 10);
464
+ const line = `orb: ${cleanLabel} · ${day}\n`;
465
+ try {
466
+ await fs.promises.appendFile(path.join(safeRoot, 'now.md'), line, 'utf8');
467
+ return { ok: true, line: line.trim() };
468
+ } catch (error) {
469
+ return { ok: false, error: String((error && error.message) || error) };
470
+ }
471
+ }
472
+
473
+ module.exports = {
474
+ buildOrbContext,
475
+ collectOrbContext,
476
+ appendOrbChoice,
477
+ };