atris 3.57.3 → 3.58.5

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 (175) hide show
  1. package/README.md +12 -1
  2. package/atris/skills/clawhub/member-runtime/SKILL.md +9 -9
  3. package/atris/skills/create-member/SKILL.md +16 -13
  4. package/atris/skills/design/SKILL.md +27 -4
  5. package/atris/skills/engines/SKILL.md +57 -13
  6. package/atris/skills/x-search/SKILL.md +13 -5
  7. package/atris/skills/youtube/SKILL.md +39 -8
  8. package/atris/team/brainstormer/MEMBER.md +2 -2
  9. package/atris/team/executor/MEMBER.md +3 -3
  10. package/atris/team/launcher/MEMBER.md +4 -4
  11. package/atris/team/navigator/MEMBER.md +3 -3
  12. package/atris/team/researcher/MEMBER.md +2 -2
  13. package/atris/team/validator/MEMBER.md +1 -1
  14. package/atris.md +14 -0
  15. package/ax +157 -24
  16. package/bin/atris.js +79 -31
  17. package/commands/aeo.js +4 -4
  18. package/commands/agents.js +1 -1
  19. package/commands/align.js +12 -12
  20. package/commands/analytics.js +3 -3
  21. package/commands/app.js +5 -5
  22. package/commands/ask.js +175 -0
  23. package/commands/auth.js +7 -7
  24. package/commands/autoland.js +29 -5
  25. package/commands/autopilot-front.js +6 -6
  26. package/commands/autopilot.js +90 -90
  27. package/commands/avail.js +5 -5
  28. package/commands/bench.js +63 -1
  29. package/commands/browse.js +2 -2
  30. package/commands/business.js +12 -12
  31. package/commands/card.js +2 -2
  32. package/commands/clean.js +13 -13
  33. package/commands/close.js +297 -15
  34. package/commands/compile.js +13 -13
  35. package/commands/computer.js +94 -27
  36. package/commands/console.js +5 -5
  37. package/commands/decide.js +1 -1
  38. package/commands/deck.js +9 -9
  39. package/commands/drive.js +4 -4
  40. package/commands/engine.js +173 -19
  41. package/commands/errors.js +3 -3
  42. package/commands/experiments.js +18 -1
  43. package/commands/feed.js +2 -2
  44. package/commands/feedback.js +4 -4
  45. package/commands/fleet.js +12 -12
  46. package/commands/game.js +1 -1
  47. package/commands/guide.js +49 -0
  48. package/commands/improve.js +19 -17
  49. package/commands/init.js +67 -19
  50. package/commands/interview.js +7 -7
  51. package/commands/land.js +40 -15
  52. package/commands/learn.js +12 -12
  53. package/commands/lesson.js +2 -2
  54. package/commands/log.js +1 -1
  55. package/commands/loops.js +4 -4
  56. package/commands/member.js +75 -50
  57. package/commands/mission.js +97 -98
  58. package/commands/one-lap.js +1 -1
  59. package/commands/pack.js +74 -8
  60. package/commands/plugin.js +6 -6
  61. package/commands/probe.js +9 -9
  62. package/commands/pull.js +35 -35
  63. package/commands/pulse.js +1 -1
  64. package/commands/push.js +21 -21
  65. package/commands/rainmaker.js +1 -1
  66. package/commands/recap.js +7 -7
  67. package/commands/reel.js +2 -2
  68. package/commands/release.js +3 -3
  69. package/commands/report.js +1 -1
  70. package/commands/review.js +5 -5
  71. package/commands/revisions.js +1 -1
  72. package/commands/run.js +1 -1
  73. package/commands/scout.js +3 -3
  74. package/commands/security-review.js +7 -7
  75. package/commands/serve.js +9 -9
  76. package/commands/setup.js +1 -1
  77. package/commands/sign.js +4 -4
  78. package/commands/signup.js +4 -4
  79. package/commands/site.js +8 -3
  80. package/commands/skill.js +6 -6
  81. package/commands/slop.js +18 -18
  82. package/commands/social.js +3 -3
  83. package/commands/soul.js +14 -14
  84. package/commands/spaceship.js +1 -1
  85. package/commands/status.js +2 -2
  86. package/commands/strings.js +13 -13
  87. package/commands/study.js +4 -4
  88. package/commands/sync.js +8 -8
  89. package/commands/task.js +50 -25
  90. package/commands/teach.js +2 -2
  91. package/commands/team.js +1 -1
  92. package/commands/theme.js +2 -2
  93. package/commands/tree.js +34 -0
  94. package/commands/truth.js +7 -7
  95. package/commands/verify.js +12 -12
  96. package/commands/visualize.js +2 -2
  97. package/commands/workflow.js +97 -40
  98. package/commands/worktree.js +57 -1
  99. package/commands/write.js +9 -9
  100. package/commands/x-search.js +304 -15
  101. package/commands/xp.js +6 -3
  102. package/commands/youtube.js +916 -65
  103. package/lib/accept-verify-gate.js +3 -3
  104. package/lib/activity-stream.js +1 -1
  105. package/lib/apply-gate.js +13 -0
  106. package/lib/auto-accept-certified.js +28 -12
  107. package/lib/autoland.js +6 -6
  108. package/lib/bench/engines.js +59 -8
  109. package/lib/bench/report.js +1 -1
  110. package/lib/bench/runner.js +204 -5
  111. package/lib/bench/tree-render.js +76 -0
  112. package/lib/card.js +1 -1
  113. package/lib/chat-log-scan.js +1 -1
  114. package/lib/claude-boot-block.js +2 -0
  115. package/lib/deck-compose.js +1 -1
  116. package/lib/deck-history.js +2 -2
  117. package/lib/deck-layout.js +1 -1
  118. package/lib/deck-review.js +5 -5
  119. package/lib/deck-schema.js +1 -1
  120. package/lib/default-verifier.js +1 -1
  121. package/lib/developer-api.js +3 -0
  122. package/lib/engine-ask.js +66 -9
  123. package/lib/engine-registry.js +15 -2
  124. package/lib/file-ops.js +1 -1
  125. package/lib/fleet.js +71 -35
  126. package/lib/html-render.js +1 -1
  127. package/lib/intents.js +172 -0
  128. package/lib/journal.js +1 -1
  129. package/lib/known-commands.js +2 -2
  130. package/lib/land-green.js +364 -0
  131. package/lib/lesson-contradiction.js +2 -2
  132. package/lib/lesson-preflight.js +1 -1
  133. package/lib/manifest.js +5 -5
  134. package/lib/member-alive.js +9 -5
  135. package/lib/memory-view.js +3 -3
  136. package/lib/mission-protected-lane.js +1 -1
  137. package/lib/mission-root.js +2 -2
  138. package/lib/orb-context.js +10 -10
  139. package/lib/pack-capabilities.js +861 -3
  140. package/lib/permission-grants.js +2 -2
  141. package/lib/policy-lessons.js +6 -6
  142. package/lib/pulse.js +8 -8
  143. package/lib/receipt-evidence.js +1 -1
  144. package/lib/reel.js +1 -1
  145. package/lib/review-integrity.js +1 -1
  146. package/lib/runner-command.js +35 -5
  147. package/lib/scorecard.js +4 -4
  148. package/lib/security-scan.js +5 -5
  149. package/lib/self-drive.js +1 -1
  150. package/lib/site-publish.js +372 -0
  151. package/lib/site.js +1 -1
  152. package/lib/slides-deck.js +2 -2
  153. package/lib/state-detection.js +2 -2
  154. package/lib/sync-telemetry.js +2 -2
  155. package/lib/task-db.js +17 -10
  156. package/lib/task-explanation.js +1 -1
  157. package/lib/task-proof.js +2 -2
  158. package/lib/task-receipt.js +3 -3
  159. package/lib/theme.js +12 -12
  160. package/lib/todo-fallback.js +3 -3
  161. package/lib/todo-sections.js +1 -1
  162. package/lib/tree-hash.js +79 -0
  163. package/lib/usage.js +1 -1
  164. package/lib/wish-delegate.js +1 -1
  165. package/lib/workspace-safety.js +4 -4
  166. package/lib/workspace-scaffold.js +3 -3
  167. package/package.json +1 -1
  168. package/scripts/det/concierge-bakeoff.sh +26 -0
  169. package/scripts/det/ytquote-repair.js +0 -1
  170. package/scripts/det/ytrail-race.js +0 -5
  171. package/scripts/member-operate.mjs +52 -14
  172. package/scripts/outbound-artifact-gate.js +0 -3
  173. package/utils/auth.js +0 -3
  174. package/utils/config.js +0 -1
  175. package/scripts/det/checklist-score.js +0 -191
package/lib/intents.js ADDED
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ const INTENTS = Object.freeze([
4
+ Object.freeze({
5
+ id: 'next_work',
6
+ say: Object.freeze(['what should i do next', 'what is next', 'pick the next thing', 'what do we do now']),
7
+ do: 'atris next',
8
+ plain: 'your agent picks the most useful next step and starts it.',
9
+ offer: 'boot_empty',
10
+ }),
11
+ Object.freeze({
12
+ id: 'keep_going',
13
+ say: Object.freeze(['keep going while i\'m away', 'run overnight', 'finish this', 'keep working without me']),
14
+ do: 'atris spaceship',
15
+ plain: 'your agent keeps working for a few hours and sends updates.',
16
+ offer: 'boot_empty',
17
+ confirm: true,
18
+ }),
19
+ Object.freeze({
20
+ id: 'improve_one_thing',
21
+ say: Object.freeze(['make it better', 'make it faster', 'ship one thing', 'improve this', 'find one useful improvement']),
22
+ do: 'atris improve tick',
23
+ plain: 'your agent finds one useful improvement, makes it, and checks the result.',
24
+ offer: 'after_landing',
25
+ }),
26
+ Object.freeze({
27
+ id: 'clean_up',
28
+ say: Object.freeze(['clean this up', 'it is messy', 'organize this project', 'tidy the loose ends']),
29
+ do: 'atris clean',
30
+ plain: 'your agent finds stale or messy project files and cleans up the safe ones.',
31
+ offer: 'after_landing',
32
+ }),
33
+ Object.freeze({
34
+ id: 'find_or_explain',
35
+ say: Object.freeze(['where is x', 'how does x work', 'where does this live', 'show me where this lives', 'explain this code']),
36
+ do: 'read atris/MAP.md',
37
+ plain: 'your agent reads the project map and points you to the exact files.',
38
+ offer: 'always',
39
+ file: true,
40
+ }),
41
+ Object.freeze({
42
+ id: 'remember_this',
43
+ say: Object.freeze(['remember this', 'save this', 'do not lose this', 'keep this for later']),
44
+ do: 'atris later "<their words>"',
45
+ plain: 'your agent saves this as a note for later.',
46
+ offer: 'always',
47
+ }),
48
+ Object.freeze({
49
+ id: 'schedule_work',
50
+ say: Object.freeze(['every day', 'every morning', 'on a schedule', 'run this regularly']),
51
+ do: 'atris loop',
52
+ plain: 'your agent sets up repeated work on the schedule you describe.',
53
+ offer: 'always',
54
+ confirm: true,
55
+ }),
56
+ Object.freeze({
57
+ id: 'build_an_idea',
58
+ say: Object.freeze(['i want to build x', 'here is an idea', 'can we make x', 'turn this idea into something']),
59
+ do: 'atris wish "<their words>"',
60
+ plain: 'your agent turns the idea into a clear job and starts once any gaps are answered.',
61
+ offer: 'always',
62
+ }),
63
+ Object.freeze({
64
+ id: 'catch_up',
65
+ say: Object.freeze(['catch me up', 'what happened', 'what landed', 'show me recent progress']),
66
+ do: 'atris recap',
67
+ plain: 'your agent summarizes what changed and what still needs attention.',
68
+ offer: 'always',
69
+ }),
70
+ Object.freeze({
71
+ id: 'check_the_work',
72
+ say: Object.freeze(['is this done', 'is it safe', 'check it', 'review this work', 'can i trust this']),
73
+ do: 'atris review',
74
+ plain: 'your agent checks the work, names any risks, and reports whether it is ready.',
75
+ offer: 'after_landing',
76
+ }),
77
+ Object.freeze({
78
+ id: 'use_fewer_credits',
79
+ say: Object.freeze(['use a free model', 'use a cheaper model', 'do not burn credits', 'save credits', 'switch to the fast model']),
80
+ do: 'atris engine atris-fast',
81
+ plain: 'your agent switches this project to the included fast model to spend fewer credits.',
82
+ offer: 'always',
83
+ }),
84
+ Object.freeze({
85
+ id: 'hand_off_work',
86
+ say: Object.freeze(['hand this off', 'have someone else do it', 'delegate this', 'give this to a teammate']),
87
+ do: 'atris task delegate "<their words>" --to <member>',
88
+ plain: 'your agent gives the work to the best available teammate and keeps the handoff visible.',
89
+ offer: 'always',
90
+ confirm: true,
91
+ }),
92
+ Object.freeze({
93
+ id: 'show_or_set_up_team',
94
+ say: Object.freeze(['who owns this', 'set up a team', 'who is responsible', 'show me the team']),
95
+ do: 'atris team',
96
+ plain: 'your agent shows who is responsible and helps fill any missing role.',
97
+ offer: 'always',
98
+ }),
99
+ Object.freeze({
100
+ id: 'speak_plainly',
101
+ say: Object.freeze(['talk plainer', 'too much jargon', 'use plain english', 'keep it simple']),
102
+ do: 'atris clarity --set voice=plain',
103
+ plain: 'your agent saves your preference for short, plain replies.',
104
+ offer: 'always',
105
+ }),
106
+ Object.freeze({
107
+ id: 'set_goal',
108
+ say: Object.freeze(['our goal is x', 'we are trying to x', 'set the goal', 'keep everyone focused on x']),
109
+ do: 'atris goal set "<their words>"',
110
+ plain: 'your agent saves the goal so future work stays pointed at it.',
111
+ offer: 'always',
112
+ }),
113
+ Object.freeze({
114
+ id: 'show_capabilities',
115
+ say: Object.freeze(['what can you do', 'how can you help', 'show me my options', 'what should i ask for']),
116
+ do: 'atris guide',
117
+ plain: 'your agent shows the things it can handle in words you already use.',
118
+ offer: 'always',
119
+ }),
120
+ ]);
121
+
122
+ const STOP_WORDS = new Set([
123
+ 'a', 'an', 'and', 'can', 'could', 'for', 'i', 'im', 'is', 'it', 'me', 'my',
124
+ 'of', 'on', 'please', 'the', 'this', 'to', 'we', 'you', 'your',
125
+ ]);
126
+
127
+ function normalizedTokens(text) {
128
+ const words = String(text || '')
129
+ .toLowerCase()
130
+ .replace(/[\u2018\u2019']/g, '')
131
+ .match(/[a-z0-9]+/g) || [];
132
+ return new Set(words.filter((word) => !STOP_WORDS.has(word)));
133
+ }
134
+
135
+ function tokenOverlapScore(left, right) {
136
+ if (!left.size || !right.size) return 0;
137
+ let overlap = 0;
138
+ for (const token of left) {
139
+ if (right.has(token)) overlap += 1;
140
+ }
141
+ if (!overlap) return 0;
142
+ return (2 * overlap) / (left.size + right.size);
143
+ }
144
+
145
+ function intentScore(tokens, intent) {
146
+ return intent.say.reduce((best, phrase) => (
147
+ Math.max(best, tokenOverlapScore(tokens, normalizedTokens(phrase)))
148
+ ), 0);
149
+ }
150
+
151
+ function matchIntent(text) {
152
+ const tokens = normalizedTokens(text);
153
+ const ranked = INTENTS
154
+ .map((intent, index) => ({ intent, score: intentScore(tokens, intent), index }))
155
+ .sort((left, right) => right.score - left.score || left.index - right.index);
156
+
157
+ // A 0.46 F1 threshold needs about half of the meaningful words to overlap.
158
+ // It catches natural additions such as "please make this faster" while
159
+ // refusing requests that share only one generic word with a phrase.
160
+ const threshold = 0.46;
161
+ if (!ranked.length || ranked[0].score < threshold) {
162
+ return { intent: null, alternatives: ranked.slice(0, 3).map((item) => item.intent) };
163
+ }
164
+
165
+ return {
166
+ intent: ranked[0].intent,
167
+ score: Number(ranked[0].score.toFixed(3)),
168
+ alternatives: ranked.slice(1, 3).map((item) => item.intent),
169
+ };
170
+ }
171
+
172
+ module.exports = { INTENTS, matchIntent };
package/lib/journal.js CHANGED
@@ -84,7 +84,7 @@ function mergeSections(localSections, remoteSections, knownRemoteHash) {
84
84
 
85
85
  // Check if the full remote document matches the known state.
86
86
  // knownRemoteHash is a hash of the entire document at last sync.
87
- // If it matches, remote hasn't changed prefer all local edits.
87
+ // If it matches, remote hasn't changed, prefer all local edits.
88
88
  const remoteReconstructed = reconstructJournal(remoteSections);
89
89
  const currentRemoteHash = computeContentHash(remoteReconstructed);
90
90
  const remoteUnchanged = knownRemoteHash && currentRemoteHash === knownRemoteHash;
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const knownCommands = ['init', 'doctor', 'install', 'workspace', 'log', 'logs', 'later', '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', 'install', 'workspace', 'log', 'logs', 'later', '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',
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
- 'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
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', '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', 'sync-checkout',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'playbook', 'feed', 'brief', 'social', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join'];
@@ -0,0 +1,364 @@
1
+ 'use strict';
2
+
3
+ // Green landing for stale branches.
4
+ //
5
+ // The landing board already classifies every branch: landed, active, due.
6
+ // Before this module the loop had exactly two moves for finished work that
7
+ // nobody merged: carry it in the digest, then salvage-reap it after the TTL.
8
+ // Both of the branches that sat "in the air" on 2026-09-01 were green and
9
+ // merged cleanly; they waited four days for a human to run the suite by hand.
10
+ //
11
+ // This is the third move. A stale active branch that merges without conflict,
12
+ // touches no protected surface, and passes the full verify command in its own
13
+ // scratch checkout is fast-forwarded onto the base branch and pushed. A branch
14
+ // that fails opens one close flag naming the failing checks so the owner sees
15
+ // it the same day instead of on reap day. Everything else is left alone with
16
+ // a named reason. One branch per tick: the suite is minutes, not seconds.
17
+
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const { spawnSync } = require('child_process');
22
+ const { runGit } = require('./git-spawn');
23
+
24
+ const DEFAULT_VERIFY = 'npm test';
25
+ const DEFAULT_VERIFY_TIMEOUT_MS = 20 * 60 * 1000;
26
+ const FAILING_LINE = /^(?:✖|not ok)\s+(.+?)(?:\s+\(\d+(?:\.\d+)?ms\))?\s*$/;
27
+
28
+ function gitOut(args, cwd) {
29
+ const result = runGit(args, { cwd, check: false });
30
+ return { ok: result.status === 0, out: String(result.stdout || '').trim(), err: String(result.stderr || '').trim() };
31
+ }
32
+
33
+ function branchAuthor(root, branch) {
34
+ const result = gitOut(['log', '-1', '--format=%an', branch], root);
35
+ return result.ok && result.out ? result.out : 'operator';
36
+ }
37
+
38
+ function mergeConflicts(root, base, branch) {
39
+ // --write-tree exits 1 on conflict, 0 on clean, >1 on error (git >= 2.38).
40
+ const result = runGit(['merge-tree', '--write-tree', '--name-only', base, branch], { cwd: root, check: false });
41
+ if (result.status === 0) return { ok: true, conflicts: [] };
42
+ if (result.status === 1) {
43
+ // Output: tree id, conflicted paths, a blank line, then messages.
44
+ const block = String(result.stdout || '').split(/\r?\n\r?\n/)[0] || '';
45
+ const lines = block.split(/\r?\n/).filter(Boolean);
46
+ return { ok: true, conflicts: lines.slice(1) };
47
+ }
48
+ return { ok: false, error: String(result.stderr || result.stdout || 'merge-tree failed').trim() };
49
+ }
50
+
51
+ function protectedSurfaces(root, base, branch) {
52
+ let matcher = null;
53
+ try {
54
+ ({ matchProtectedMissionDiff: matcher } = require('./mission-protected-lane'));
55
+ } catch {
56
+ return { protected: false, surfaces: [] };
57
+ }
58
+ const diff = runGit(['diff', `${base}...${branch}`], { cwd: root, check: false, maxBuffer: 64 * 1024 * 1024 });
59
+ if (diff.status !== 0) return { protected: true, surfaces: ['unreadable diff'] };
60
+ const verdict = matcher(String(diff.stdout || ''));
61
+ return { protected: Boolean(verdict.protected), surfaces: verdict.surfaces || [] };
62
+ }
63
+
64
+ function dirtyCheckouts(root, branch) {
65
+ let listWorktrees;
66
+ let statusCounts;
67
+ try {
68
+ ({ listWorktrees, statusCounts } = require('../commands/worktree'));
69
+ } catch {
70
+ return [];
71
+ }
72
+ const dirty = [];
73
+ for (const wt of listWorktrees(root)) {
74
+ const name = String(wt.branch || '').replace(/^refs\/heads\//, '');
75
+ if (name !== branch) continue;
76
+ const counts = statusCounts(wt.path) || { staged: 0, unstaged: 0, untracked: 0 };
77
+ if (counts.staged + counts.unstaged + counts.untracked > 0) dirty.push(wt.path);
78
+ }
79
+ return dirty;
80
+ }
81
+
82
+ function failingLines(text, limit = 8) {
83
+ const seen = [];
84
+ for (const raw of String(text || '').split(/\r?\n/)) {
85
+ const match = raw.trim().match(FAILING_LINE);
86
+ if (!match) continue;
87
+ const name = match[1].trim();
88
+ if (/^failing tests:?$/i.test(name)) continue;
89
+ if (!seen.includes(name)) seen.push(name);
90
+ if (seen.length >= limit) break;
91
+ }
92
+ return seen;
93
+ }
94
+
95
+ function runVerify(command, cwd, { timeoutMs = DEFAULT_VERIFY_TIMEOUT_MS, env = process.env } = {}) {
96
+ const started = Date.now();
97
+ const result = spawnSync(command, {
98
+ cwd,
99
+ shell: true,
100
+ encoding: 'utf8',
101
+ timeout: timeoutMs,
102
+ maxBuffer: 64 * 1024 * 1024,
103
+ env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' },
104
+ });
105
+ const output = `${result.stdout || ''}\n${result.stderr || ''}`;
106
+ return {
107
+ command,
108
+ status: result.status,
109
+ timed_out: Boolean(result.error && result.error.code === 'ETIMEDOUT'),
110
+ duration_ms: Date.now() - started,
111
+ failing: failingLines(output),
112
+ tail: output.trim().split(/\r?\n/).slice(-12).join('\n'),
113
+ };
114
+ }
115
+
116
+ function baseCheckoutState(root, base) {
117
+ const head = gitOut(['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
118
+ const onBase = head.ok && head.out === base;
119
+ if (!onBase) return { onBase: false, dirty: false };
120
+ const status = gitOut(['status', '--porcelain', '--untracked-files=no'], root);
121
+ return { onBase: true, dirty: status.ok && status.out.length > 0 };
122
+ }
123
+
124
+ // Move the base ref to `sha`, respecting a checked-out base worktree: when
125
+ // the root has base checked out and is clean, a fast-forward merge updates
126
+ // both the ref and the files; otherwise the ref moves alone.
127
+ function advanceBase(root, base, sha) {
128
+ const hasLocal = gitOut(['rev-parse', '--verify', '--quiet', `refs/heads/${base}`], root);
129
+ if (!hasLocal.ok) return { ok: true, skipped: 'no_local_branch' };
130
+ const state = baseCheckoutState(root, base);
131
+ if (state.onBase && state.dirty) return { ok: false, reason: 'base_checkout_dirty' };
132
+ if (state.onBase) {
133
+ const merged = gitOut(['merge', '--ff-only', sha], root);
134
+ if (!merged.ok) return { ok: false, reason: 'fast_forward_failed', detail: merged.err || merged.out };
135
+ return { ok: true };
136
+ }
137
+ const before = gitOut(['rev-parse', base], root);
138
+ const moved = gitOut(['update-ref', `refs/heads/${base}`, sha, before.ok ? before.out : ''], root);
139
+ if (!moved.ok) return { ok: false, reason: 'update_ref_failed', detail: moved.err || moved.out };
140
+ return { ok: true };
141
+ }
142
+
143
+ function pushBase(root, base, sha) {
144
+ const remote = gitOut(['remote', 'get-url', 'origin'], root);
145
+ if (!remote.ok) return { attempted: false, ok: false, reason: 'no_remote' };
146
+ const pushed = runGit(['push', 'origin', `${sha}:refs/heads/${base}`], { cwd: root, check: false });
147
+ return { attempted: true, ok: pushed.status === 0, detail: String(pushed.stderr || pushed.stdout || '').trim().slice(0, 300) };
148
+ }
149
+
150
+ // The board names the base as origin/master when a remote exists, so the
151
+ // merge target is the remote tip by construction. Refresh it first, and hold
152
+ // when the local branch carries commits origin lacks: pushing would fail and
153
+ // fast-forwarding the local branch would rewrite someone's unpushed work.
154
+ function localBaseName(base) {
155
+ return String(base).replace(/^origin\//, '');
156
+ }
157
+
158
+ function resolveMergeTarget(root, base) {
159
+ const local = localBaseName(base);
160
+ const remote = gitOut(['remote', 'get-url', 'origin'], root);
161
+ if (!remote.ok) return { ok: true, target: local, refreshed: false };
162
+ spawnSync('git', ['fetch', 'origin', local], { cwd: root, encoding: 'utf8', timeout: 30000 });
163
+ const remoteRef = `refs/remotes/origin/${local}`;
164
+ const exists = gitOut(['rev-parse', '--verify', '--quiet', remoteRef], root);
165
+ if (!exists.ok) return { ok: true, target: local, refreshed: true };
166
+ const hasLocal = gitOut(['rev-parse', '--verify', '--quiet', `refs/heads/${local}`], root);
167
+ if (hasLocal.ok) {
168
+ const counts = gitOut(['rev-list', '--left-right', '--count', `refs/heads/${local}...${remoteRef}`], root);
169
+ const [localOnly, remoteOnly] = counts.ok ? counts.out.split(/\s+/).map((n) => Number(n) || 0) : [0, 0];
170
+ if (localOnly > 0) {
171
+ return { ok: false, reason: 'base_diverged', detail: `local ${local} has ${localOnly} commit(s) origin lacks${remoteOnly ? ` and origin has ${remoteOnly} local lacks` : ''}` };
172
+ }
173
+ }
174
+ return { ok: true, target: remoteRef, refreshed: true };
175
+ }
176
+
177
+ function scratchCheckout(root, branch) {
178
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-land-green-'));
179
+ const added = gitOut(['worktree', 'add', '--detach', dir, branch], root);
180
+ if (!added.ok) {
181
+ fs.rmSync(dir, { recursive: true, force: true });
182
+ return { ok: false, error: added.err || added.out };
183
+ }
184
+ return { ok: true, dir };
185
+ }
186
+
187
+ function dropScratch(root, dir) {
188
+ if (!dir) return;
189
+ runGit(['worktree', 'remove', '--force', dir], { cwd: root, check: false });
190
+ fs.rmSync(dir, { recursive: true, force: true });
191
+ runGit(['worktree', 'prune'], { cwd: root, check: false });
192
+ }
193
+
194
+ function installDeps(dir) {
195
+ // Zero-dependency repos verify without install; only run it when a lockfile
196
+ // exists so the scratch checkout can resolve dev tooling.
197
+ if (!fs.existsSync(path.join(dir, 'package-lock.json'))) return { ran: false, ok: true };
198
+ const result = spawnSync('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], {
199
+ cwd: dir, encoding: 'utf8', timeout: 5 * 60 * 1000, maxBuffer: 16 * 1024 * 1024,
200
+ });
201
+ return { ran: true, ok: result.status === 0, detail: String(result.stderr || '').trim().slice(0, 300) };
202
+ }
203
+
204
+ function verdictFor(branch, base, extra) {
205
+ return { branch, base, ...extra };
206
+ }
207
+
208
+ // Try to land exactly one branch. Returns a verdict with `action` in:
209
+ // landed, red, skipped, error
210
+ function landOneBranch(root, base, branch, {
211
+ verifyCommand = DEFAULT_VERIFY,
212
+ verifyTimeoutMs = DEFAULT_VERIFY_TIMEOUT_MS,
213
+ dryRun = false,
214
+ push = true,
215
+ runVerifyFn = runVerify,
216
+ env = process.env,
217
+ } = {}) {
218
+ const dirty = dirtyCheckouts(root, branch);
219
+ if (dirty.length) {
220
+ return verdictFor(branch, base, { action: 'skipped', reason: 'checkout_dirty', detail: dirty.join(', ') });
221
+ }
222
+ const conflicts = mergeConflicts(root, base, branch);
223
+ if (!conflicts.ok) return verdictFor(branch, base, { action: 'error', reason: 'merge_check_failed', detail: conflicts.error });
224
+ if (conflicts.conflicts.length) {
225
+ return verdictFor(branch, base, { action: 'skipped', reason: 'merge_conflict', detail: conflicts.conflicts.slice(0, 6).join(', ') });
226
+ }
227
+ const lane = protectedSurfaces(root, base, branch);
228
+ if (lane.protected) {
229
+ return verdictFor(branch, base, { action: 'skipped', reason: 'protected_surface', detail: lane.surfaces.join(', ') });
230
+ }
231
+ const target = resolveMergeTarget(root, base);
232
+ if (!target.ok) return verdictFor(branch, base, { action: 'skipped', reason: target.reason, detail: target.detail });
233
+ if (dryRun) return verdictFor(branch, base, { action: 'skipped', reason: 'dry_run', target: target.target });
234
+
235
+ const scratch = scratchCheckout(root, branch);
236
+ if (!scratch.ok) return verdictFor(branch, base, { action: 'error', reason: 'scratch_checkout_failed', detail: scratch.error });
237
+ try {
238
+ // Verify the merged state, not the branch tip: base may have moved since.
239
+ const merged = gitOut(['merge', '--no-edit', '--no-ff', target.target, '-m', `land: ${branch} onto ${base} after a green verify`], scratch.dir);
240
+ if (!merged.ok) return verdictFor(branch, base, { action: 'error', reason: 'merge_failed', detail: merged.err || merged.out });
241
+ const deps = installDeps(scratch.dir);
242
+ if (!deps.ok) return verdictFor(branch, base, { action: 'error', reason: 'install_failed', detail: deps.detail });
243
+ const verify = runVerifyFn(verifyCommand, scratch.dir, { timeoutMs: verifyTimeoutMs, env });
244
+ if (verify.status !== 0) {
245
+ return verdictFor(branch, base, {
246
+ action: 'red',
247
+ reason: verify.timed_out ? 'verify_timed_out' : 'verify_failed',
248
+ verify,
249
+ owner: branchAuthor(root, branch),
250
+ });
251
+ }
252
+ const sha = gitOut(['rev-parse', 'HEAD'], scratch.dir);
253
+ if (!sha.ok) return verdictFor(branch, base, { action: 'error', reason: 'merged_sha_unreadable' });
254
+ const local = localBaseName(base);
255
+ const pushed = push ? pushBase(root, local, sha.out) : { attempted: false, ok: false, reason: 'push_disabled' };
256
+ if (pushed.attempted && !pushed.ok) {
257
+ // A race with another landing: origin moved after the fetch. Leave the
258
+ // local branch alone so the next tick retries onto the new tip.
259
+ return verdictFor(branch, base, { action: 'skipped', reason: 'push_rejected', detail: pushed.detail, verify });
260
+ }
261
+ const advanced = advanceBase(root, local, sha.out);
262
+ if (!advanced.ok) return verdictFor(branch, base, { action: 'landed', sha: sha.out, verify, push: pushed, local_base: advanced.reason });
263
+ return verdictFor(branch, base, { action: 'landed', sha: sha.out, verify, push: pushed });
264
+ } finally {
265
+ dropScratch(root, scratch.dir);
266
+ }
267
+ }
268
+
269
+ function flagWhat(verdict) {
270
+ const names = (verdict.verify && verdict.verify.failing) || [];
271
+ const head = names.length ? names.slice(0, 3).join('; ') : (verdict.reason === 'verify_timed_out' ? 'the verify timed out' : 'the verify command failed');
272
+ return `branch ${verdict.branch} fails its checks and cannot land: ${head}`;
273
+ }
274
+
275
+ function openRedFlag(root, verdict, { runCli } = {}) {
276
+ const what = flagWhat(verdict);
277
+ const args = ['close', 'add', what, '--owner', verdict.owner || 'operator', '--lane', 'code', '--ttl', '3',
278
+ '--when', `${verdict.branch} passes ${verdict.verify ? verdict.verify.command : 'its checks'} and lands on ${verdict.base}`,
279
+ '--source', 'land-green'];
280
+ if (typeof runCli === 'function') return runCli(args);
281
+ const bin = path.join(__dirname, '..', 'bin', 'atris.js');
282
+ const result = spawnSync(process.execPath, [bin, ...args], { cwd: root, encoding: 'utf8', timeout: 30000 });
283
+ return { status: result.status, stdout: String(result.stdout || '').trim(), stderr: String(result.stderr || '').trim() };
284
+ }
285
+
286
+ // The tick entry point: pick the stale active branches the board already
287
+ // knows about, oldest activity first, and land at most `limit` of them.
288
+ function landStaleGreenBranches(root, {
289
+ board = null,
290
+ staleHours,
291
+ ttlDays,
292
+ base: baseOverride = '',
293
+ limit = 1,
294
+ now = Date.now(),
295
+ verifyCommand = DEFAULT_VERIFY,
296
+ verifyTimeoutMs = DEFAULT_VERIFY_TIMEOUT_MS,
297
+ dryRun = false,
298
+ push = true,
299
+ openFlags = true,
300
+ runVerifyFn = runVerify,
301
+ runCli,
302
+ env = process.env,
303
+ } = {}) {
304
+ const { collectBoard } = require('../commands/land');
305
+ const live = board || collectBoard(root, { staleHours, ttlDays, base: baseOverride, now, light: true });
306
+ const candidates = live.branches
307
+ .filter((b) => b.state === 'active' && b.stale)
308
+ .sort((a, b) => b.activityHours - a.activityHours);
309
+ const verdicts = [];
310
+ let attempts = 0;
311
+ for (const candidate of candidates) {
312
+ if (attempts >= limit) {
313
+ verdicts.push(verdictFor(candidate.name, live.base, { action: 'skipped', reason: 'per_tick_limit' }));
314
+ continue;
315
+ }
316
+ attempts += 1;
317
+ let verdict;
318
+ try {
319
+ verdict = landOneBranch(root, live.base, candidate.name, { verifyCommand, verifyTimeoutMs, dryRun, push, runVerifyFn, env });
320
+ } catch (err) {
321
+ verdict = verdictFor(candidate.name, live.base, { action: 'error', reason: 'exception', detail: String((err && err.message) || err).slice(0, 300) });
322
+ }
323
+ if (verdict.action === 'red' && openFlags) {
324
+ try {
325
+ const flagged = openRedFlag(root, verdict, { runCli });
326
+ verdict.flag = { status: flagged.status, out: String(flagged.stdout || '').slice(0, 200) };
327
+ } catch (err) {
328
+ verdict.flag = { status: 1, out: String((err && err.message) || err).slice(0, 200) };
329
+ }
330
+ }
331
+ verdicts.push(verdict);
332
+ }
333
+ return {
334
+ at: new Date(now).toISOString(),
335
+ base: live.base,
336
+ candidates: candidates.map((b) => b.name),
337
+ landed: verdicts.filter((v) => v.action === 'landed').map((v) => v.branch),
338
+ red: verdicts.filter((v) => v.action === 'red').map((v) => v.branch),
339
+ verdicts,
340
+ };
341
+ }
342
+
343
+ function summaryLine(result) {
344
+ if (!result || !result.candidates || result.candidates.length === 0) return 'no stale branches to land';
345
+ const parts = [];
346
+ if (result.landed.length) parts.push(`landed ${result.landed.join(', ')}`);
347
+ if (result.red.length) parts.push(`red: ${result.red.join(', ')}`);
348
+ for (const v of result.verdicts) {
349
+ if (v.action === 'skipped' && v.reason !== 'per_tick_limit') parts.push(`${v.branch} held (${v.reason.replace(/_/g, ' ')}${v.detail ? `: ${v.detail}` : ''})`);
350
+ if (v.action === 'error') parts.push(`${v.branch} error (${v.reason.replace(/_/g, ' ')})`);
351
+ }
352
+ const waiting = result.verdicts.filter((v) => v.reason === 'per_tick_limit').length;
353
+ if (waiting) parts.push(`${waiting} more waiting for the next tick`);
354
+ return parts.join('; ') || 'nothing landed';
355
+ }
356
+
357
+ module.exports = {
358
+ DEFAULT_VERIFY,
359
+ landStaleGreenBranches,
360
+
361
+ failingLines,
362
+ flagWhat,
363
+ summaryLine,
364
+ };
@@ -22,8 +22,8 @@ function detectLessonContradictions(cwd) {
22
22
  ? fs.readFileSync(lessonsMdPath, 'utf8')
23
23
  : '';
24
24
 
25
- // Format: - **[YYYY-MM-DD] slug** pass|fail prose
26
- const lessonRegex = /^-\s+\*\*\[(\d{4}-\d{2}-\d{2})\]\s+([a-z0-9-]+)\*\*\s*—\s*(pass|fail)\s*—\s*(\[resolved\])?\s*(.+)$/m;
25
+ // Format: - **[YYYY-MM-DD] slug**, pass|fail, prose
26
+ const lessonRegex = /^-\s+\*\*\[(\d{4}-\d{2}-\d{2})\]\s+([a-z0-9-]+)\*\*\s*\u2014\s*(pass|fail)\s*\u2014\s*(\[resolved\])?\s*(.+)$/m;
27
27
  const lines = lessonsMdContent.split('\n');
28
28
 
29
29
  const lessonsBySlug = new Map(); // slug -> [{ date, outcome, lineNum, resolved }]
@@ -28,7 +28,7 @@ function readLessonText(filePath) {
28
28
 
29
29
  const bySlug = new Map();
30
30
  for (const line of content.split(/\r?\n/)) {
31
- const match = line.match(/^\s*-\s*\*\*\[([^\]]+)\]\s*([a-z0-9][a-z0-9-]*)\*\*\s*[—:-]+\s*(?:pass|fail)?\s*[—:-]*\s*(.*)$/i);
31
+ const match = line.match(/^\s*-\s*\*\*\[([^\]]+)\]\s*([a-z0-9][a-z0-9-]*)\*\*\s*[\u2014:-]+\s*(?:pass|fail)?\s*[\u2014:-]*\s*(.*)$/i);
32
32
  if (!match || bySlug.has(match[2])) continue;
33
33
  const text = match[3].replace(/^\[resolved\]\s*/i, '').trim();
34
34
  if (text) bySlug.set(match[2], text);
package/lib/manifest.js CHANGED
@@ -152,14 +152,14 @@ function threeWayCompare(localFiles, remoteFiles, manifest) {
152
152
  newRemote: [],
153
153
  };
154
154
 
155
- // First sync no manifest
155
+ // First sync, no manifest
156
156
  if (!manifest || !manifest.files) {
157
157
  // Everything remote is "new from remote"
158
158
  for (const p of Object.keys(remoteFiles)) {
159
159
  if (localFiles[p] && localFiles[p].hash === remoteFiles[p].hash) {
160
160
  result.unchanged.push(p);
161
161
  } else if (localFiles[p]) {
162
- // Both exist with different hashes, no baseline treat as conflict
162
+ // Both exist with different hashes, no baseline, treat as conflict
163
163
  result.conflicts.push(p);
164
164
  } else {
165
165
  result.newRemote.push(p);
@@ -191,7 +191,7 @@ function threeWayCompare(localFiles, remoteFiles, manifest) {
191
191
  const baseHash = inBase ? base[p].hash : null;
192
192
 
193
193
  if (inLocal && inRemote && inBase) {
194
- // All three exist standard three-way
194
+ // All three exist, standard three-way
195
195
  const localChanged = localHash !== baseHash;
196
196
  const remoteChanged = remoteHash !== baseHash;
197
197
 
@@ -223,10 +223,10 @@ function threeWayCompare(localFiles, remoteFiles, manifest) {
223
223
  // Was in base, deleted locally, still remote
224
224
  result.deletedLocal.push(p);
225
225
  } else if (inBase && !inLocal && !inRemote) {
226
- // Deleted on both sides nothing to do
226
+ // Deleted on both sides, nothing to do
227
227
  // (don't add to any list)
228
228
  } else if (inLocal && inRemote && !inBase) {
229
- // Both sides have it but no base new on both
229
+ // Both sides have it but no base, new on both
230
230
  if (localHash === remoteHash) {
231
231
  result.unchanged.push(p);
232
232
  } else {