atris 3.57.4 → 3.58.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.
- package/README.md +12 -1
- package/atris/skills/clawhub/member-runtime/SKILL.md +9 -9
- package/atris/skills/create-member/SKILL.md +16 -13
- package/atris/skills/design/SKILL.md +27 -4
- package/atris/skills/engines/SKILL.md +55 -12
- package/atris/skills/x-search/SKILL.md +8 -6
- package/atris/skills/youtube/SKILL.md +66 -21
- package/atris/team/brainstormer/MEMBER.md +2 -2
- package/atris/team/executor/MEMBER.md +3 -3
- package/atris/team/launcher/MEMBER.md +4 -4
- package/atris/team/navigator/MEMBER.md +3 -3
- package/atris/team/researcher/MEMBER.md +2 -2
- package/atris/team/validator/MEMBER.md +1 -1
- package/atris.md +14 -0
- package/ax +153 -23
- package/bin/atris.js +78 -30
- package/commands/aeo.js +4 -4
- package/commands/agents.js +1 -1
- package/commands/align.js +12 -12
- package/commands/analytics.js +3 -3
- package/commands/app.js +5 -5
- package/commands/ask.js +175 -0
- package/commands/auth.js +59 -31
- package/commands/autoland.js +29 -5
- package/commands/autopilot-front.js +6 -6
- package/commands/autopilot.js +90 -90
- package/commands/avail.js +5 -5
- package/commands/bench.js +63 -1
- package/commands/browse.js +2 -2
- package/commands/business.js +12 -12
- package/commands/card.js +2 -2
- package/commands/clean.js +13 -13
- package/commands/close.js +3 -3
- package/commands/compile.js +13 -13
- package/commands/computer.js +94 -27
- package/commands/console.js +5 -5
- package/commands/decide.js +1 -1
- package/commands/deck.js +9 -9
- package/commands/drive.js +4 -4
- package/commands/engine.js +173 -19
- package/commands/errors.js +3 -3
- package/commands/experiments.js +18 -1
- package/commands/feed.js +2 -2
- package/commands/feedback.js +4 -4
- package/commands/fleet.js +12 -12
- package/commands/game.js +1 -1
- package/commands/guide.js +49 -0
- package/commands/improve.js +19 -17
- package/commands/init.js +67 -19
- package/commands/interview.js +7 -7
- package/commands/land.js +40 -15
- package/commands/learn.js +216 -50
- package/commands/lesson.js +2 -2
- package/commands/log.js +1 -1
- package/commands/loops.js +4 -4
- package/commands/member.js +75 -50
- package/commands/mission.js +90 -82
- package/commands/one-lap.js +1 -1
- package/commands/pack.js +74 -8
- package/commands/plugin.js +6 -6
- package/commands/probe.js +9 -9
- package/commands/pull.js +35 -35
- package/commands/pulse.js +1 -1
- package/commands/push.js +21 -21
- package/commands/rainmaker.js +1 -1
- package/commands/recap.js +7 -7
- package/commands/reel.js +2 -2
- package/commands/release.js +3 -3
- package/commands/report.js +1 -1
- package/commands/review.js +5 -5
- package/commands/revisions.js +1 -1
- package/commands/run.js +1 -1
- package/commands/scout.js +3 -3
- package/commands/security-review.js +7 -7
- package/commands/serve.js +9 -9
- package/commands/setup.js +1 -1
- package/commands/sign.js +4 -4
- package/commands/signup.js +4 -4
- package/commands/site.js +8 -3
- package/commands/skill.js +6 -6
- package/commands/slop.js +18 -18
- package/commands/social.js +3 -3
- package/commands/soul.js +14 -14
- package/commands/spaceship.js +1 -1
- package/commands/status.js +2 -2
- package/commands/strings.js +13 -13
- package/commands/study.js +4 -4
- package/commands/sync.js +8 -8
- package/commands/task.js +50 -25
- package/commands/teach.js +2 -2
- package/commands/team.js +1 -1
- package/commands/theme.js +2 -2
- package/commands/tree.js +34 -0
- package/commands/truth.js +7 -7
- package/commands/verify.js +12 -12
- package/commands/visualize.js +2 -2
- package/commands/workflow.js +101 -48
- package/commands/worktree.js +1 -1
- package/commands/write.js +9 -9
- package/commands/x-search.js +93 -22
- package/commands/xp.js +6 -3
- package/commands/youtube.js +1019 -123
- package/lib/accept-verify-gate.js +3 -3
- package/lib/activity-stream.js +1 -1
- package/lib/auto-accept-certified.js +28 -12
- package/lib/autoland.js +6 -6
- package/lib/bench/engines.js +59 -8
- package/lib/bench/report.js +1 -1
- package/lib/bench/runner.js +204 -5
- package/lib/bench/tree-render.js +76 -0
- package/lib/card.js +1 -1
- package/lib/chat-log-scan.js +1 -1
- package/lib/claude-boot-block.js +2 -0
- package/lib/deck-compose.js +1 -1
- package/lib/deck-history.js +2 -2
- package/lib/deck-layout.js +1 -1
- package/lib/deck-review.js +5 -5
- package/lib/deck-schema.js +1 -1
- package/lib/default-verifier.js +1 -1
- package/lib/developer-api.js +3 -0
- package/lib/engine-ask.js +45 -7
- package/lib/engine-registry.js +15 -2
- package/lib/file-ops.js +1 -1
- package/lib/fleet.js +71 -35
- package/lib/html-render.js +1 -1
- package/lib/intents.js +172 -0
- package/lib/journal.js +1 -1
- package/lib/known-commands.js +2 -2
- package/lib/land-green.js +364 -0
- package/lib/lesson-contradiction.js +2 -2
- package/lib/lesson-preflight.js +1 -1
- package/lib/manifest.js +5 -5
- package/lib/member-alive.js +9 -5
- package/lib/memory-view.js +3 -3
- package/lib/mission-protected-lane.js +1 -1
- package/lib/mission-root.js +2 -2
- package/lib/orb-context.js +10 -10
- package/lib/pack-capabilities.js +861 -3
- package/lib/permission-grants.js +2 -2
- package/lib/policy-lessons.js +6 -6
- package/lib/pulse.js +8 -8
- package/lib/receipt-evidence.js +1 -1
- package/lib/reel.js +1 -1
- package/lib/review-integrity.js +1 -1
- package/lib/runner-command.js +35 -5
- package/lib/scorecard.js +4 -4
- package/lib/security-scan.js +5 -5
- package/lib/self-drive.js +1 -1
- package/lib/site-publish.js +372 -0
- package/lib/site.js +1 -1
- package/lib/slides-deck.js +2 -2
- package/lib/state-detection.js +2 -2
- package/lib/sync-telemetry.js +2 -2
- package/lib/task-db.js +17 -10
- package/lib/task-explanation.js +1 -1
- package/lib/task-proof.js +2 -2
- package/lib/task-receipt.js +3 -3
- package/lib/theme.js +12 -12
- package/lib/todo-fallback.js +3 -3
- package/lib/todo-sections.js +1 -1
- package/lib/tree-hash.js +79 -0
- package/lib/usage.js +1 -1
- package/lib/wish-delegate.js +1 -1
- package/lib/workspace-safety.js +4 -4
- package/lib/workspace-scaffold.js +3 -3
- package/package.json +1 -1
- package/scripts/det/concierge-bakeoff.sh +26 -0
- package/scripts/det/ytnotes +82 -5
- package/scripts/det/ytquote-repair.js +0 -1
- package/scripts/det/ytrail-race.js +0 -5
- package/scripts/member-operate.mjs +52 -14
- package/scripts/outbound-artifact-gate.js +0 -3
- package/utils/auth.js +47 -9
- package/utils/config.js +0 -1
- package/scripts/det/checklist-score.js +0 -191
|
@@ -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
|
|
26
|
-
const lessonRegex = /^-\s+\*\*\[(\d{4}-\d{2}-\d{2})\]\s+([a-z0-9-]+)\*\*\s
|
|
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 }]
|
package/lib/lesson-preflight.js
CHANGED
|
@@ -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*[
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
package/lib/member-alive.js
CHANGED
|
@@ -6,7 +6,7 @@ const { spawnSync } = require('child_process');
|
|
|
6
6
|
|
|
7
7
|
const MEMBER_OPERATE_PROGRESS_PREFIX = 'ATRIS_MEMBER_OPERATE_PROGRESS\t';
|
|
8
8
|
|
|
9
|
-
// Wake decisions where the member should NOT run operate work this tick
|
|
9
|
+
// Wake decisions where the member should NOT run operate work this tick: it is waiting on a
|
|
10
10
|
// human (ask), on a pending review or clean scope (wait), or has no active goal yet (stop).
|
|
11
11
|
const NON_OPERATE_DECISIONS = new Set(['ask', 'wait', 'stop']);
|
|
12
12
|
|
|
@@ -65,6 +65,7 @@ function resolveMemberOperateScript(cwd) {
|
|
|
65
65
|
const candidates = [
|
|
66
66
|
path.join(cwd, 'scripts', 'member-operate.mjs'),
|
|
67
67
|
path.join(cwd, 'scripts', 'member-operate.js'),
|
|
68
|
+
path.join(__dirname, '..', 'scripts', 'member-operate.mjs'),
|
|
68
69
|
];
|
|
69
70
|
for (const candidate of candidates) {
|
|
70
71
|
if (fs.existsSync(candidate)) return candidate;
|
|
@@ -87,6 +88,7 @@ function runMemberOperateScript(scriptPath, member, opts) {
|
|
|
87
88
|
if (opts.agent) args.push('--agent', String(opts.agent));
|
|
88
89
|
if (opts.model) args.push('--model', String(opts.model));
|
|
89
90
|
if (opts.noPrime) args.push('--no-prime');
|
|
91
|
+
if (opts.sharedCheckout) args.push('--shared-checkout');
|
|
90
92
|
// Inherit stderr so member-operate progress lines reach the parent alive/loop process live.
|
|
91
93
|
const result = spawnSync(process.execPath, args, {
|
|
92
94
|
cwd: opts.cwd || process.cwd(),
|
|
@@ -188,7 +190,7 @@ function runAliveTick(name, opts = {}) {
|
|
|
188
190
|
return tick;
|
|
189
191
|
}
|
|
190
192
|
|
|
191
|
-
emitAliveProgress({ kind: 'phase', text: `Running ${name} tick
|
|
193
|
+
emitAliveProgress({ kind: 'phase', text: `Running ${name} tick, streaming live below.` });
|
|
192
194
|
tick.operate = runMemberOperateScript(operateScript, name, {
|
|
193
195
|
cwd,
|
|
194
196
|
execute: true,
|
|
@@ -197,6 +199,7 @@ function runAliveTick(name, opts = {}) {
|
|
|
197
199
|
agent: opts.agent || null,
|
|
198
200
|
model: opts.model || null,
|
|
199
201
|
noPrime: true,
|
|
202
|
+
sharedCheckout: opts.sharedCheckout,
|
|
200
203
|
});
|
|
201
204
|
tick.needs_user = tick.needs_user || tick.operate.needs_user === true;
|
|
202
205
|
|
|
@@ -210,17 +213,18 @@ function runAliveTick(name, opts = {}) {
|
|
|
210
213
|
const operateOk = tick.operate.ok !== false;
|
|
211
214
|
const acceptOk = tick.auto_accept.ok !== false;
|
|
212
215
|
tick.ok = operateOk && acceptOk;
|
|
216
|
+
const planned = tick.ok && tick.operate.payload?.status === 'planned';
|
|
213
217
|
tick.status = tick.needs_user
|
|
214
218
|
? 'needs_user'
|
|
215
219
|
: tick.ok
|
|
216
|
-
? 'completed'
|
|
220
|
+
? planned ? 'planned' : 'completed'
|
|
217
221
|
: 'failed';
|
|
218
222
|
tick.reason = tick.ok
|
|
219
|
-
? 'alive_tick_complete'
|
|
223
|
+
? planned ? tick.operate.reason : 'alive_tick_complete'
|
|
220
224
|
: tick.operate.reason || tick.auto_accept.json?.summary
|
|
221
225
|
? 'partial_failure'
|
|
222
226
|
: 'alive_tick_failed';
|
|
223
|
-
tick.receipt_path = tick.operate.receipt_path ||
|
|
227
|
+
tick.receipt_path = tick.operate.receipt_path || null;
|
|
224
228
|
tick.finished_at = stampIso();
|
|
225
229
|
return tick;
|
|
226
230
|
}
|
package/lib/memory-view.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Memory view
|
|
1
|
+
// Memory view: another way to look at memory updates. Reads the workspace's
|
|
2
2
|
// compounding memory (lessons.md, learnings.jsonl, task projection) and builds a
|
|
3
3
|
// content spec rendered as a beautiful HTML page by lib/html-render.
|
|
4
4
|
//
|
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
|
|
12
|
-
// "- **[2026-06-18] some-id
|
|
12
|
+
// "- **[2026-06-18] some-id**, pass, text..." -> { date, id, status, text, resolved }
|
|
13
13
|
// A leading "[resolved]" marker (written when a lesson's detector passes) is
|
|
14
14
|
// stripped from the text and surfaced as `resolved` so the view can retire it.
|
|
15
15
|
function parseLessons(text) {
|
|
16
16
|
const out = [];
|
|
17
17
|
for (const line of String(text || '').split('\n')) {
|
|
18
|
-
const m = line.match(/^-\s*\*\*\[([^\]]+)\]\s*([^*]+?)\*\*\s*[
|
|
18
|
+
const m = line.match(/^-\s*\*\*\[([^\]]+)\]\s*([^*]+?)\*\*\s*[\u2014:-]*\s*(pass|fail)?\s*[\u2014:-]*\s*(.*)$/i);
|
|
19
19
|
if (!m) continue;
|
|
20
20
|
let body = m[4].trim();
|
|
21
21
|
const resolved = /\[resolved\]/i.test(body);
|
|
@@ -51,7 +51,7 @@ function changedPathsFromDiff(diff) {
|
|
|
51
51
|
|
|
52
52
|
// Prose is not a protected surface. Docs, judge cards, wikis, journals and run
|
|
53
53
|
// receipts routinely *describe* CSP or force-dynamic without changing any
|
|
54
|
-
// behaviour
|
|
54
|
+
// behaviour: replaying this guard over 60 real commits, 5 of 6 hits were
|
|
55
55
|
// markdown that merely mentioned the words. A guard that pauses on every judge
|
|
56
56
|
// card gets switched off, which is worse than no guard. Content signals
|
|
57
57
|
// therefore apply to code only; PATH signals still apply to every file.
|
package/lib/mission-root.js
CHANGED
|
@@ -6,7 +6,7 @@ const { spawnSync } = require('child_process');
|
|
|
6
6
|
// The mission store lives at <workspace>/.atris/state. Historically every
|
|
7
7
|
// mission entry point defaulted its root to process.cwd(), so running any
|
|
8
8
|
// `atris mission ...` verb from a subdirectory (e.g. atris/features/food-ordering)
|
|
9
|
-
// silently created a NESTED .atris there
|
|
9
|
+
// silently created a NESTED .atris there: a second, split-brain mission store
|
|
10
10
|
// the fleet never reads. Resolve to the workspace root so mission state always
|
|
11
11
|
// lands there, whichever subdir the caller happened to be in.
|
|
12
12
|
//
|
|
@@ -14,7 +14,7 @@ const { spawnSync } = require('child_process');
|
|
|
14
14
|
// (lib/task-db findWorkspaceRoot): a bound customer sub-workspace spine
|
|
15
15
|
// (.atris/business.json | atris/atris.md) wins, else the git toplevel, else the
|
|
16
16
|
// caller's cwd. Sharing one resolver keeps mission state and task state in the
|
|
17
|
-
// same root
|
|
17
|
+
// same root. previously mission used only the git toplevel, so a mission
|
|
18
18
|
// started inside a bound sub-workspace split away from that workspace's task
|
|
19
19
|
// state. A git worktree reports its own toplevel (its .git marker), which is
|
|
20
20
|
// the correct per-worktree store, so --worktree missions are unaffected. Walk-up
|
package/lib/orb-context.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Orb context
|
|
1
|
+
// Orb context: deterministic greeting + suggestion synthesis for the orb surface.
|
|
2
2
|
// Pure synthesis lives in buildOrbContext (unit-testable, no fs); collectOrbContext
|
|
3
3
|
// does the read-only fs aggregation from a project root. No model calls anywhere:
|
|
4
4
|
// 0ms-to-greeting is the contract (atris/features/orb-desktop/PLAN.md).
|
|
@@ -65,7 +65,7 @@ function normalizeTasks(tasks) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// Known suggestion action prefixes. Stripped repeatedly (front-anchored) so that
|
|
68
|
-
// "review: review: X" or "pick up pick up X" both collapse to the bare title
|
|
68
|
+
// "review: review: X" or "pick up pick up X" both collapse to the bare title.
|
|
69
69
|
// used both to keep labels single-prefixed and to compare titles across sources.
|
|
70
70
|
// "atrisbox" is included because Atrisbox threads are titled "Atrisbox: <label>",
|
|
71
71
|
// which otherwise shields inner accumulated prefixes from stripping (seen live
|
|
@@ -85,12 +85,12 @@ function stripActionPrefixes(value) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
// Lowercase, prefix-stripped, whitespace-collapsed form used only for equality/
|
|
88
|
-
// prefix comparison across sources (tasks vs threads)
|
|
88
|
+
// prefix comparison across sources (tasks vs threads), never shown to the user.
|
|
89
89
|
function normalizeForMatch(value) {
|
|
90
90
|
return stripActionPrefixes(String(value || '').toLowerCase().replace(/\s+/g, ' ').trim());
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// True when two titles are "the same work"
|
|
93
|
+
// True when two titles are "the same work": exact match, or one is a truncated
|
|
94
94
|
// prefix of the other (titles get cut at different lengths per source, so a
|
|
95
95
|
// straight equality check would miss the common case). Guarded by a minimum
|
|
96
96
|
// needle length so short titles ("fix it") don't false-positive against unrelated
|
|
@@ -106,7 +106,7 @@ function titlesOverlap(a, b) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
// Background Atrisbox taps write a launch-receipt thread (desk_<project>_
|
|
109
|
-
// atrisbox_bg_<slug>)
|
|
109
|
+
// atrisbox_bg_<slug>), proof a turn ran, not a resumable conversation.
|
|
110
110
|
// They self-archive on delivery (App.tsx sendAtrisboxMessage) and main.js
|
|
111
111
|
// sweeps stragglers, but excluding them from warm eligibility here too is
|
|
112
112
|
// cheap (a string check, no I/O) and keeps the count honest even for threads
|
|
@@ -127,7 +127,7 @@ function normalizeThreads(threads) {
|
|
|
127
127
|
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
// Dedupe by "same work" (titlesOverlap) keeping the newest of each cluster
|
|
130
|
+
// Dedupe by "same work" (titlesOverlap) keeping the newest of each cluster,
|
|
131
131
|
// used before counting warm threads so the greeting number reflects distinct
|
|
132
132
|
// resumable work, not the same thread title repeated under different ids.
|
|
133
133
|
function dedupeByTitle(threads) {
|
|
@@ -218,7 +218,7 @@ function buildOrbContext({ nowMd = '', tasks = [], threads = [], memberLoops = [
|
|
|
218
218
|
const suggestions = [];
|
|
219
219
|
// Titles already spoken for by a suggestion, used to dedupe across sources
|
|
220
220
|
// (review/resume/continue are built independently but can point at the same
|
|
221
|
-
// underlying work
|
|
221
|
+
// underlying work, e.g. a task's warm thread title vs the task's own title).
|
|
222
222
|
const usedTitles = [];
|
|
223
223
|
|
|
224
224
|
function pushSuggestion(suggestion, title = '') {
|
|
@@ -268,7 +268,7 @@ function buildOrbContext({ nowMd = '', tasks = [], threads = [], memberLoops = [
|
|
|
268
268
|
});
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
// Explicitly blocked/stalled statuses are their own lane
|
|
271
|
+
// Explicitly blocked/stalled statuses are their own lane, distinct from the
|
|
272
272
|
// stale-doing "finish" lane below, which infers staleness from the clock.
|
|
273
273
|
const blockedTask = cleanTasks
|
|
274
274
|
.filter((t) => t.status === 'blocked' || t.status === 'stalled')
|
|
@@ -310,7 +310,7 @@ function buildOrbContext({ nowMd = '', tasks = [], threads = [], memberLoops = [
|
|
|
310
310
|
for (const thread of warmThreads) {
|
|
311
311
|
if (usedTitles.some((title) => titlesOverlap(title, thread.title))) continue;
|
|
312
312
|
// Thread titles inherit message text, so a picked-up thread can arrive as
|
|
313
|
-
// "pick up X: pick up X..."
|
|
313
|
+
// "pick up X: pick up X...", strip the accumulated prefixes before reusing.
|
|
314
314
|
const threadTitle = cleanTitle(stripActionPrefixes(String(thread.title || '')), 40);
|
|
315
315
|
if (threadTitle.length < 3) continue;
|
|
316
316
|
resumeThread = thread;
|
|
@@ -369,7 +369,7 @@ function buildOrbContext({ nowMd = '', tasks = [], threads = [], memberLoops = [
|
|
|
369
369
|
|
|
370
370
|
// These stay at the tail even when the backlog is full, so orientation and
|
|
371
371
|
// planning never disappear behind the deeper task-fed pool.
|
|
372
|
-
// 'status' is answered locally by the view from gmSnapshot
|
|
372
|
+
// 'status' is answered locally by the view from gmSnapshot, no model call.
|
|
373
373
|
const staticSuggestions = [{
|
|
374
374
|
id: 'status',
|
|
375
375
|
label: "what's moving",
|