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.
- package/AGENTS.md +35 -0
- package/FOR_AGENTS.md +5 -3
- package/README.md +5 -3
- package/atris/GETTING_STARTED.md +1 -1
- package/atris/atris.md +3 -0
- package/atris/policies/day-loop-voice.md +102 -0
- package/atris/policies/outbound-artifact-gate.md +2 -0
- package/atris/skills/design/SKILL.md +56 -32
- package/atris/skills/endgame/SKILL.md +12 -6
- package/atris/skills/engines/SKILL.md +35 -8
- package/atris/skills/fable-method/SKILL.md +66 -0
- package/atris/skills/improve/SKILL.md +65 -45
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris/skills/youtube/SKILL.md +10 -1
- package/atris.md +6 -2
- package/ax +617 -31
- package/bin/atris.js +719 -266
- package/commands/activate.js +194 -88
- package/commands/aeo.js +52 -0
- package/commands/agents.js +166 -0
- package/commands/autoland.js +718 -72
- package/commands/autopilot-front.js +20 -2
- package/commands/autopilot.js +118 -2
- package/commands/avail.js +407 -0
- package/commands/bench.js +188 -0
- package/commands/brain.js +3 -0
- package/commands/brief.js +651 -0
- package/commands/business-sync.js +192 -6
- package/commands/business.js +91 -8
- package/commands/clean.js +50 -24
- package/commands/close.js +1083 -0
- package/commands/cloud.js +245 -0
- package/commands/codex-goal.js +26 -2
- package/commands/compile.js +292 -1
- package/commands/computer.js +150 -3
- package/commands/dream.js +365 -0
- package/commands/drill.js +371 -0
- package/commands/drive.js +187 -0
- package/commands/engine.js +1061 -29
- package/commands/experiments.js +28 -0
- package/commands/feed.js +202 -0
- package/commands/feedback.js +34 -12
- package/commands/fleet-report.js +206 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +285 -3
- package/commands/goal.js +247 -0
- package/commands/improve.js +642 -26
- package/commands/init.js +83 -43
- package/commands/integrations.js +39 -11
- package/commands/interview.js +209 -0
- package/commands/land.js +253 -52
- package/commands/lesson.js +112 -1
- package/commands/lifecycle.js +39 -3
- package/commands/linear.js +38 -0
- package/commands/log.js +84 -1
- package/commands/loops.js +220 -16
- package/commands/meet.js +220 -0
- package/commands/member.js +899 -66
- package/commands/mission.js +3512 -332
- package/commands/next.js +137 -0
- package/commands/now.js +240 -21
- package/commands/one-lap.js +776 -0
- package/commands/orb.js +314 -0
- package/commands/pack-craft.js +179 -0
- package/commands/pack.js +823 -0
- package/commands/play.js +3 -2
- package/commands/probe.js +30 -3
- package/commands/pulse.js +241 -46
- package/commands/push.js +260 -82
- package/commands/radar.js +259 -14
- package/commands/rainmaker.js +49 -0
- package/commands/report.js +415 -0
- package/commands/scout.js +147 -0
- package/commands/search.js +363 -0
- package/commands/serve.js +54 -0
- package/commands/skill.js +47 -3
- package/commands/slop.js +50 -2
- package/commands/soul.js +1 -1
- package/commands/status.js +50 -5
- package/commands/stream.js +861 -0
- package/commands/stripe.js +38 -0
- package/commands/study.js +693 -0
- package/commands/supabase.js +39 -0
- package/commands/sync.js +67 -54
- package/commands/task.js +2275 -182
- package/commands/team.js +73 -0
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/verify.js +96 -0
- package/commands/watch.js +303 -0
- package/commands/wish.js +500 -0
- package/commands/workflow.js +11 -5
- package/commands/worktree.js +299 -20
- package/commands/write.js +399 -0
- package/commands/xp.js +29 -11
- package/lib/auto-accept-certified.js +391 -43
- package/lib/autoland.js +353 -52
- package/lib/ax-auto-lane.js +79 -0
- package/lib/bench/context.js +147 -0
- package/lib/bench/engines.js +141 -0
- package/lib/bench/report.js +140 -0
- package/lib/bench/runner.js +512 -0
- package/lib/brief-ledger.js +350 -0
- package/lib/cloud-mission.js +259 -0
- package/lib/codex-flight.js +154 -0
- package/lib/default-runner.js +45 -0
- package/lib/default-verifier.js +70 -0
- package/lib/engine-registry.js +232 -0
- package/lib/experiments/daily.js +640 -0
- package/lib/fleet.js +2431 -38
- package/lib/improve-vitals-html.js +171 -0
- package/lib/known-commands.js +58 -0
- package/lib/loop-doctor.js +416 -0
- package/lib/member-switches.js +144 -0
- package/lib/memory-view.js +14 -5
- package/lib/mission-room.js +1 -0
- package/lib/mission-root.js +52 -0
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/next-moves.js +327 -10
- package/lib/official-cli-integration.js +174 -0
- package/lib/one-lap-validator.js +60 -0
- package/lib/orb-context.js +477 -0
- package/lib/orb-scorecard.js +224 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/policy-lessons.js +52 -1
- package/lib/pulse.js +277 -3
- package/lib/receipt-block.js +168 -0
- package/lib/receipt-evidence.js +65 -4
- package/lib/review-integrity.js +147 -0
- package/lib/router-brain.js +352 -0
- package/lib/runner-command.js +33 -0
- package/lib/self-drive.js +258 -0
- package/lib/short-name.js +103 -0
- package/lib/spawn-env.js +18 -0
- package/lib/state-detection.js +56 -1
- package/lib/sync-status.js +59 -0
- package/lib/task-db.js +319 -27
- package/lib/task-proof.js +43 -1
- package/lib/task-receipt.js +93 -0
- package/lib/team-presence.js +260 -0
- package/lib/tool-result-encode.js +7 -0
- package/lib/trust-tiers.js +90 -0
- package/lib/usage.js +107 -0
- package/lib/voice-gate.js +163 -0
- package/lib/wish-audit.js +1368 -0
- package/lib/wish-delegate.js +1840 -0
- package/lib/wish-design.js +110 -0
- package/lib/wish-stats.js +183 -0
- package/lib/wish-store.js +354 -0
- package/lib/zip.js +221 -0
- package/package.json +3 -1
- package/templates/loops/atris/loops/LOOPS.md +55 -0
- package/templates/loops/atris/loops/TICK.md +24 -0
- package/templates/loops/atris/loops/feedback.md +22 -0
- package/templates/loops/atris/loops/quality.md +22 -0
- package/templates/loops/atris/wiki/systems/loops.md +41 -0
- package/utils/api.js +5 -1
- package/utils/auth.js +57 -21
- package/utils/update-check.js +27 -6
- package/atris/learnings.jsonl +0 -1
package/commands/land.js
CHANGED
|
@@ -5,10 +5,12 @@ const { spawnSync } = require('child_process');
|
|
|
5
5
|
const { listWorktrees, statusCounts } = require('./worktree');
|
|
6
6
|
|
|
7
7
|
const DEFAULT_TTL_DAYS = 7;
|
|
8
|
+
const DEFAULT_STALE_HOURS = 48;
|
|
9
|
+
const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
|
|
8
10
|
const PROTECTED_BRANCHES = new Set(['main', 'master']);
|
|
9
11
|
|
|
10
|
-
function runGit(args, { cwd = process.cwd(), check = true } = {}) {
|
|
11
|
-
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
12
|
+
function runGit(args, { cwd = process.cwd(), check = true, maxBuffer } = {}) {
|
|
13
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', ...(maxBuffer ? { maxBuffer } : {}) });
|
|
12
14
|
if (check && result.status !== 0) {
|
|
13
15
|
throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
|
|
14
16
|
}
|
|
@@ -27,6 +29,8 @@ function refExists(root, ref) {
|
|
|
27
29
|
|
|
28
30
|
function baseBranch(root, override = '') {
|
|
29
31
|
if (override && refExists(root, override)) return override;
|
|
32
|
+
if (refExists(root, 'origin/master')) return 'origin/master';
|
|
33
|
+
if (refExists(root, 'origin/main')) return 'origin/main';
|
|
30
34
|
if (refExists(root, 'master')) return 'master';
|
|
31
35
|
if (refExists(root, 'main')) return 'main';
|
|
32
36
|
return '';
|
|
@@ -36,18 +40,46 @@ function ageDays(unixSeconds, now = Date.now()) {
|
|
|
36
40
|
return Math.floor((now / 1000 - Number(unixSeconds)) / 86400);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
function ageHours(unixSeconds, now = Date.now()) {
|
|
44
|
+
return Math.floor((now / 1000 - Number(unixSeconds)) / 3600);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function worktreeMtimeMs(worktreePath) {
|
|
48
|
+
try {
|
|
49
|
+
return fs.statSync(worktreePath).mtimeMs;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
|
|
56
|
+
const mtime = worktreeMtimeMs(worktreePath);
|
|
57
|
+
return typeof mtime === 'number' && now - mtime < WORKTREE_REAP_GRACE_MS;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function listBranches(root, base = '') {
|
|
61
|
+
// With a base, ask git for ahead counts in the same single spawn
|
|
62
|
+
// (%(ahead-behind:) needs git >= 2.41; on failure we retry without it and
|
|
63
|
+
// collectBoard falls back to per-branch aheadCount).
|
|
64
|
+
const format = base
|
|
65
|
+
? `%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:${base})`
|
|
66
|
+
: '%(refname:short)%09%(committerdate:unix)';
|
|
67
|
+
const result = runGit(['for-each-ref', 'refs/heads', `--format=${format}`], { cwd: root, check: false });
|
|
68
|
+
if (result.status !== 0) {
|
|
69
|
+
if (base) return listBranches(root);
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
45
72
|
return result.stdout
|
|
46
73
|
.split(/\r?\n/)
|
|
47
74
|
.filter(Boolean)
|
|
48
75
|
.map((line) => {
|
|
49
|
-
const [name, date] = line.split('\t');
|
|
50
|
-
|
|
76
|
+
const [name, date, aheadBehind] = line.split('\t');
|
|
77
|
+
const entry = { name, lastCommitUnix: Number(date) };
|
|
78
|
+
if (aheadBehind !== undefined) {
|
|
79
|
+
const ahead = Number(aheadBehind.split(' ')[0]);
|
|
80
|
+
if (Number.isFinite(ahead)) entry.ahead = ahead;
|
|
81
|
+
}
|
|
82
|
+
return entry;
|
|
51
83
|
});
|
|
52
84
|
}
|
|
53
85
|
|
|
@@ -57,30 +89,59 @@ function aheadCount(root, base, ref) {
|
|
|
57
89
|
return Number(result.stdout.trim()) || 0;
|
|
58
90
|
}
|
|
59
91
|
|
|
92
|
+
function cherryStats(root, base, ref) {
|
|
93
|
+
const result = runGit(['cherry', base, ref], { cwd: root, check: false });
|
|
94
|
+
if (result.status !== 0) return { landedElsewhere: 0, unique: 0 };
|
|
95
|
+
let landedElsewhere = 0;
|
|
96
|
+
let unique = 0;
|
|
97
|
+
for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
98
|
+
if (line.startsWith('- ')) landedElsewhere += 1;
|
|
99
|
+
else if (line.startsWith('+ ')) unique += 1;
|
|
100
|
+
}
|
|
101
|
+
return { landedElsewhere, unique };
|
|
102
|
+
}
|
|
103
|
+
|
|
60
104
|
// Board: every branch and worktree with unlanded state, classified.
|
|
61
105
|
// landed — no commits ahead of base; the branch pointer is residue
|
|
62
106
|
// active — has unlanded commits, younger than TTL
|
|
63
107
|
// due — has unlanded commits, older than TTL; --reap collects it
|
|
64
|
-
function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '' } = {}) {
|
|
108
|
+
function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', now = Date.now(), light = false } = {}) {
|
|
65
109
|
const base = baseBranch(root, baseOverride);
|
|
66
110
|
if (!base) throw new Error('no master/main branch found');
|
|
67
111
|
|
|
68
112
|
const branches = [];
|
|
69
|
-
|
|
113
|
+
const lastCommitMsByBranch = new Map();
|
|
114
|
+
for (const branch of listBranches(root, base)) {
|
|
70
115
|
if (PROTECTED_BRANCHES.has(branch.name)) continue;
|
|
71
|
-
const
|
|
72
|
-
|
|
116
|
+
const lastCommitMs = Number(branch.lastCommitUnix) * 1000;
|
|
117
|
+
if (Number.isFinite(lastCommitMs)) lastCommitMsByBranch.set(branch.name, lastCommitMs);
|
|
118
|
+
const ahead = branch.ahead !== undefined ? branch.ahead : aheadCount(root, base, branch.name);
|
|
119
|
+
const age = ageDays(branch.lastCommitUnix, now);
|
|
120
|
+
const hours = ageHours(branch.lastCommitUnix, now);
|
|
121
|
+
const cherry = ahead > 0 ? cherryStats(root, base, branch.name) : { landedElsewhere: 0, unique: 0 };
|
|
73
122
|
let state = 'active';
|
|
74
|
-
if (ahead === 0) state = 'landed';
|
|
123
|
+
if (ahead === 0 || (cherry.landedElsewhere > 0 && cherry.unique === 0)) state = 'landed';
|
|
75
124
|
else if (age > ttlDays) state = 'due';
|
|
76
|
-
branches.push({
|
|
125
|
+
branches.push({
|
|
126
|
+
name: branch.name,
|
|
127
|
+
ahead,
|
|
128
|
+
ageDays: age,
|
|
129
|
+
ageHours: hours,
|
|
130
|
+
activityHours: hours,
|
|
131
|
+
landedElsewhere: cherry.landedElsewhere,
|
|
132
|
+
uniqueChanges: cherry.unique,
|
|
133
|
+
stale: false,
|
|
134
|
+
state,
|
|
135
|
+
});
|
|
77
136
|
}
|
|
78
137
|
|
|
79
138
|
const worktrees = [];
|
|
80
139
|
const all = listWorktrees(root);
|
|
81
140
|
for (const wt of all.slice(1)) {
|
|
82
141
|
const branch = (wt.branch || '').replace(/^refs\/heads\//, '');
|
|
83
|
-
|
|
142
|
+
// light mode skips the full `git status` per worktree — the banner summary
|
|
143
|
+
// never reads dirty counts, only worktree mtimes for staleness.
|
|
144
|
+
const counts = (light ? null : statusCounts(wt.path)) || { staged: 0, unstaged: 0, untracked: 0 };
|
|
84
145
|
const dirty = counts.staged + counts.unstaged + counts.untracked;
|
|
85
146
|
const entry = branches.find((b) => b.name === branch) || null;
|
|
86
147
|
worktrees.push({
|
|
@@ -88,16 +149,35 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '
|
|
|
88
149
|
branch,
|
|
89
150
|
dirty,
|
|
90
151
|
ageDays: entry ? entry.ageDays : null,
|
|
152
|
+
ageHours: entry ? entry.ageHours : null,
|
|
91
153
|
state: entry ? entry.state : 'detached',
|
|
92
154
|
});
|
|
93
155
|
}
|
|
94
156
|
|
|
157
|
+
const activityByBranch = new Map(branches.map((b) => [b.name, lastCommitMsByBranch.get(b.name)]));
|
|
158
|
+
for (const wt of worktrees) {
|
|
159
|
+
if (!wt.branch || !activityByBranch.has(wt.branch)) continue;
|
|
160
|
+
const mtime = worktreeMtimeMs(wt.path);
|
|
161
|
+
if (typeof mtime === 'number' && mtime > activityByBranch.get(wt.branch)) {
|
|
162
|
+
activityByBranch.set(wt.branch, mtime);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const branch of branches) {
|
|
166
|
+
const lastActivity = activityByBranch.get(branch.name);
|
|
167
|
+
if (Number.isFinite(lastActivity)) {
|
|
168
|
+
branch.activityHours = Math.floor((now - lastActivity) / 3600000);
|
|
169
|
+
}
|
|
170
|
+
branch.stale = branch.state === 'active' && branch.activityHours >= staleHours;
|
|
171
|
+
}
|
|
172
|
+
|
|
95
173
|
const due = branches.filter((b) => b.state === 'due');
|
|
96
174
|
const landed = branches.filter((b) => b.state === 'landed');
|
|
97
175
|
const active = branches.filter((b) => b.state === 'active');
|
|
176
|
+
const stale = active.filter((b) => b.stale);
|
|
98
177
|
return {
|
|
99
178
|
base,
|
|
100
179
|
ttlDays,
|
|
180
|
+
staleHours,
|
|
101
181
|
branches,
|
|
102
182
|
worktrees,
|
|
103
183
|
summary: {
|
|
@@ -105,23 +185,25 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '
|
|
|
105
185
|
active: active.length,
|
|
106
186
|
due: due.length,
|
|
107
187
|
landed: landed.length,
|
|
188
|
+
stale: stale.length,
|
|
108
189
|
worktrees: worktrees.length,
|
|
109
190
|
},
|
|
110
191
|
};
|
|
111
192
|
}
|
|
112
193
|
|
|
113
|
-
//
|
|
194
|
+
// Counts for the boot banner and digest. This intentionally pays the same
|
|
195
|
+
// classification cost as the landing board so "in the air" never includes
|
|
196
|
+
// merged branch residue.
|
|
114
197
|
function landSummary(cwd = process.cwd(), ttlDays = DEFAULT_TTL_DAYS) {
|
|
115
198
|
const root = repoRoot(cwd);
|
|
116
199
|
if (!root) return null;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
return { branches, due, ttlDays };
|
|
200
|
+
const board = collectBoard(root, { ttlDays, light: true });
|
|
201
|
+
return {
|
|
202
|
+
branches: board.summary.active + board.summary.due,
|
|
203
|
+
due: board.summary.due,
|
|
204
|
+
stale: board.summary.stale,
|
|
205
|
+
ttlDays,
|
|
206
|
+
};
|
|
125
207
|
}
|
|
126
208
|
|
|
127
209
|
function salvageDir(root) {
|
|
@@ -146,16 +228,76 @@ function remoteHeads(root) {
|
|
|
146
228
|
return heads;
|
|
147
229
|
}
|
|
148
230
|
|
|
231
|
+
// Everything a force-remove would destroy: staged + unstaged tracked changes
|
|
232
|
+
// (git diff HEAD) and untracked files (copied verbatim). Returns false if any
|
|
233
|
+
// piece could not be saved — the caller then keeps the worktree.
|
|
234
|
+
function salvageWorktree(w, dir, receipt) {
|
|
235
|
+
try {
|
|
236
|
+
// git writes the patch file itself: routing the diff through spawnSync
|
|
237
|
+
// stdout hits Node's 1MB default maxBuffer, so any worktree more than
|
|
238
|
+
// ~1MB dirty read as "salvage failed" and was kept on every reap pass.
|
|
239
|
+
const patchPath = path.join(dir, `${path.basename(w.path)}.dirty.patch`);
|
|
240
|
+
const diff = runGit(['diff', 'HEAD', `--output=${patchPath}`], { cwd: w.path, check: false });
|
|
241
|
+
if (diff.status !== 0) {
|
|
242
|
+
fs.rmSync(patchPath, { force: true });
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
if (fs.statSync(patchPath).size > 0) receipt.patches.push(patchPath);
|
|
246
|
+
else fs.rmSync(patchPath, { force: true });
|
|
247
|
+
const untracked = runGit(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: w.path, check: false, maxBuffer: 64 * 1024 * 1024 });
|
|
248
|
+
if (untracked.status !== 0) return false;
|
|
249
|
+
const files = untracked.stdout.split('\0').filter(Boolean);
|
|
250
|
+
if (files.length > 0) {
|
|
251
|
+
const destRoot = path.join(dir, `${path.basename(w.path)}.untracked`);
|
|
252
|
+
for (const rel of files) {
|
|
253
|
+
const dest = path.join(destRoot, rel);
|
|
254
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
255
|
+
fs.copyFileSync(path.join(w.path, rel), dest);
|
|
256
|
+
}
|
|
257
|
+
receipt.untracked.push(destRoot);
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
149
265
|
// Reap: salvage then delete everything landed (residue) or past TTL.
|
|
150
266
|
// Salvage first, always: unlanded commits go into a git bundle, dirty
|
|
151
|
-
// worktrees into patch files, so a reap is never
|
|
152
|
-
|
|
153
|
-
|
|
267
|
+
// worktrees into patch files + untracked-file copies, so a reap is never
|
|
268
|
+
// a loss of work — and when a backup cannot be written, the work stays.
|
|
269
|
+
// includeDetached: detached-HEAD worktrees have no branch, so the salvage
|
|
270
|
+
// bundle cannot cover their commits — unattended reaps (autoland) pass false
|
|
271
|
+
// and leave them for a human reap.
|
|
272
|
+
function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true, protectCurrent = true, now = Date.now() } = {}) {
|
|
273
|
+
const board = collectBoard(root, { ttlDays, staleHours, base: baseOverride, now });
|
|
154
274
|
const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
|
|
155
275
|
const targetNames = new Set(targets.map((b) => b.name));
|
|
156
|
-
const
|
|
157
|
-
(w) => targetNames.has(w.branch) || w.state === 'detached' || (typeof w.ageDays === 'number' && w.ageDays > ttlDays)
|
|
276
|
+
const candidateWorktrees = board.worktrees.filter(
|
|
277
|
+
(w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached') || (typeof w.ageDays === 'number' && w.ageDays > ttlDays)
|
|
158
278
|
);
|
|
279
|
+
const protectedWorktrees = [];
|
|
280
|
+
const worktreeTargets = [];
|
|
281
|
+
const current = path.resolve(root);
|
|
282
|
+
for (const w of candidateWorktrees) {
|
|
283
|
+
// Only the fresh-worktree grace protects here. Dirty worktrees are NOT
|
|
284
|
+
// skipped: reap's salvage (bundle + patches + untracked copies below)
|
|
285
|
+
// exists precisely so dirty residue can be cleared loss-free. Blanket
|
|
286
|
+
// dirty protection lives in the janitor's cleanupWorktrees, which has
|
|
287
|
+
// no salvage machinery.
|
|
288
|
+
if (protectCurrent && path.resolve(w.path) === current) {
|
|
289
|
+
protectedWorktrees.push({ ...w, reason: 'current_checkout' });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (worktreeWithinReapGrace(w.path, now)) {
|
|
293
|
+
protectedWorktrees.push({ ...w, reason: 'fresh_worktree_grace' });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
worktreeTargets.push(w);
|
|
297
|
+
}
|
|
298
|
+
for (const w of protectedWorktrees) {
|
|
299
|
+
if (w.branch) targetNames.delete(w.branch);
|
|
300
|
+
}
|
|
159
301
|
for (const w of worktreeTargets) {
|
|
160
302
|
if (w.branch && !targetNames.has(w.branch)) targetNames.add(w.branch);
|
|
161
303
|
}
|
|
@@ -165,7 +307,11 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRu
|
|
|
165
307
|
ttlDays,
|
|
166
308
|
dryRun,
|
|
167
309
|
bundle: null,
|
|
310
|
+
bundleError: null,
|
|
168
311
|
patches: [],
|
|
312
|
+
untracked: [],
|
|
313
|
+
keptWorktrees: protectedWorktrees.map((w) => `${w.path} (${w.reason})`),
|
|
314
|
+
keptMovedBranches: [],
|
|
169
315
|
removedWorktrees: [],
|
|
170
316
|
deletedBranches: [],
|
|
171
317
|
deletedRemote: [],
|
|
@@ -190,24 +336,62 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRu
|
|
|
190
336
|
check: false,
|
|
191
337
|
});
|
|
192
338
|
if (result.status === 0) receipt.bundle = bundlePath;
|
|
339
|
+
else {
|
|
340
|
+
// Salvage-first is a hard promise: no bundle, no deletion of unlanded
|
|
341
|
+
// work. These branches (and their worktrees) survive until a reap can
|
|
342
|
+
// actually back them up.
|
|
343
|
+
receipt.bundleError = String(result.stderr || result.stdout || 'bundle failed').trim().slice(0, 200);
|
|
344
|
+
for (const name of withCommits) targetNames.delete(name);
|
|
345
|
+
}
|
|
193
346
|
}
|
|
194
347
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
348
|
+
const survivingWorktreeTargets = worktreeTargets.filter(
|
|
349
|
+
(w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached')
|
|
350
|
+
);
|
|
351
|
+
for (const w of survivingWorktreeTargets) {
|
|
352
|
+
// Salvage-then-remove, never keep-because-dirty: patches + untracked
|
|
353
|
+
// copies bank everything force-remove would destroy. The fresh-worktree
|
|
354
|
+
// grace was already applied when candidates were selected.
|
|
355
|
+
if (w.dirty > 0 && !salvageWorktree(w, dir, receipt)) {
|
|
356
|
+
// could not fully back up what force-remove would destroy — keep it,
|
|
357
|
+
// and say why: a bare path in the receipt reads as an unexplained
|
|
358
|
+
// no-op (the exact silence BCK-1232 was about).
|
|
359
|
+
receipt.keptWorktrees.push(`${w.path} (salvage incomplete: patch/copy failed, kept to avoid losing work)`);
|
|
360
|
+
if (w.branch) targetNames.delete(w.branch);
|
|
361
|
+
continue;
|
|
203
362
|
}
|
|
204
363
|
const removed = runGit(['worktree', 'remove', '--force', w.path], { cwd: root, check: false });
|
|
205
|
-
if (removed.status === 0)
|
|
364
|
+
if (removed.status === 0) {
|
|
365
|
+
receipt.removedWorktrees.push(w.path);
|
|
366
|
+
} else {
|
|
367
|
+
// A locked worktree fails here and, unreported, reap re-salvages the
|
|
368
|
+
// same patches every pass forever. Say what blocked it so a human can
|
|
369
|
+
// unlock (or a dead lock can be challenged) instead of looping.
|
|
370
|
+
const reason = String(removed.stderr || removed.stdout || 'remove failed').trim().slice(0, 160);
|
|
371
|
+
receipt.keptWorktrees.push(`${w.path} (${reason})`);
|
|
372
|
+
if (w.branch) targetNames.delete(w.branch);
|
|
373
|
+
}
|
|
206
374
|
}
|
|
207
375
|
|
|
208
376
|
for (const name of targetNames) {
|
|
377
|
+
const entry = board.branches.find((b) => b.name === name);
|
|
378
|
+
// the board is a snapshot; an agent may have committed since it was
|
|
379
|
+
// taken. A branch that moved is left alone — the next reap sees the
|
|
380
|
+
// new truth and bundles it before touching it.
|
|
381
|
+
if (!entry || aheadCount(root, board.base, name) !== entry.ahead) {
|
|
382
|
+
receipt.keptMovedBranches.push(name);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
209
385
|
const deleted = runGit(['branch', '-D', name], { cwd: root, check: false });
|
|
210
|
-
if (deleted.status === 0)
|
|
386
|
+
if (deleted.status === 0) {
|
|
387
|
+
receipt.deletedBranches.push(name);
|
|
388
|
+
} else {
|
|
389
|
+
// Same silence class as the worktree-remove path: a branch checked
|
|
390
|
+
// out in a side copy the board never saw makes -D refuse, and an
|
|
391
|
+
// unreported refusal reads as "cleanup done" with nothing cleared.
|
|
392
|
+
const reason = String(deleted.stderr || deleted.stdout || 'branch delete failed').trim().slice(0, 160);
|
|
393
|
+
receipt.keptWorktrees.push(`branch ${name} (${reason})`);
|
|
394
|
+
}
|
|
211
395
|
}
|
|
212
396
|
|
|
213
397
|
if (remote && receipt.deletedBranches.length > 0) {
|
|
@@ -308,7 +492,7 @@ function printStory(story) {
|
|
|
308
492
|
console.log(' some other way. nothing here would be lost by clearing it.');
|
|
309
493
|
} else if (story.landedElsewhere > 0) {
|
|
310
494
|
console.log(` bottom line: ${story.landedElsewhere} of ${story.changes.length} changes made it into ${story.base} another`);
|
|
311
|
-
console.log(` way. ${story.uniqueChanges} exist only here (marked "never made it")
|
|
495
|
+
console.log(` way. ${story.uniqueChanges} exist only here (marked "never made it") - they may have`);
|
|
312
496
|
console.log(' been redone differently, or they may be real lost work. clearing backs');
|
|
313
497
|
console.log(' them up first either way.');
|
|
314
498
|
} else {
|
|
@@ -322,19 +506,20 @@ function printStory(story) {
|
|
|
322
506
|
|
|
323
507
|
function printBoard(board) {
|
|
324
508
|
console.log('');
|
|
325
|
-
console.log('the landing
|
|
509
|
+
console.log('the landing: what is actually done vs still in the air');
|
|
326
510
|
console.log('');
|
|
327
511
|
if (board.branches.length === 0 && board.worktrees.length === 0) {
|
|
328
512
|
console.log(' everything has landed. nothing in the air, nothing stuck.');
|
|
329
513
|
console.log('');
|
|
330
514
|
return;
|
|
331
515
|
}
|
|
332
|
-
const order = { due: 0, detached: 0,
|
|
516
|
+
const order = { due: 0, detached: 0, active: 1, landed: 2 };
|
|
333
517
|
const rows = [...board.branches].sort((a, b) => (order[a.state] ?? 3) - (order[b.state] ?? 3) || b.ageDays - a.ageDays);
|
|
334
518
|
const labels = { due: 'overdue', landed: 'landed', active: 'in the air' };
|
|
335
519
|
for (const b of rows) {
|
|
336
520
|
const changes = b.ahead === 1 ? '1 change ' : `${b.ahead} changes`;
|
|
337
|
-
|
|
521
|
+
const label = b.stale ? 'stale' : (labels[b.state] || b.state);
|
|
522
|
+
console.log(` ${label.padEnd(11)} ${String(b.ageDays).padStart(3)}d ${changes} ${b.name}`);
|
|
338
523
|
}
|
|
339
524
|
for (const w of board.worktrees) {
|
|
340
525
|
const edits = w.dirty === 1 ? '1 unsaved edit' : `${w.dirty} unsaved edits`;
|
|
@@ -342,10 +527,14 @@ function printBoard(board) {
|
|
|
342
527
|
}
|
|
343
528
|
console.log('');
|
|
344
529
|
const s = board.summary;
|
|
345
|
-
|
|
530
|
+
const stillInAir = s.active + s.due;
|
|
531
|
+
console.log(` ${s.unlanded} ${s.unlanded === 1 ? 'piece' : 'pieces'} tracked: ${stillInAir} still in the air, ${s.stale} stale, ${s.due} overdue, ${s.landed} landed and safe to clear.`);
|
|
346
532
|
console.log(' work counts as done only when it lands in master.');
|
|
347
533
|
if (s.due + s.landed > 0) {
|
|
348
|
-
|
|
534
|
+
const clearable = s.due > 0 && s.landed > 0
|
|
535
|
+
? `${s.due} overdue and ${s.landed} landed`
|
|
536
|
+
: s.due > 0 ? `${s.due} overdue` : `${s.landed} landed`;
|
|
537
|
+
console.log(` back up + clear ${clearable}: atris land --reap`);
|
|
349
538
|
}
|
|
350
539
|
console.log('');
|
|
351
540
|
}
|
|
@@ -353,13 +542,22 @@ function printBoard(board) {
|
|
|
353
542
|
function printReceipt(receipt) {
|
|
354
543
|
console.log('');
|
|
355
544
|
if (receipt.dryRun) {
|
|
356
|
-
console.log(`landing cleanup preview
|
|
545
|
+
console.log(`landing cleanup preview: would clear ${receipt.deletedBranches.length} pieces of work, ${receipt.removedWorktrees.length} side copies:`);
|
|
357
546
|
for (const b of receipt.deletedBranches) console.log(` would clear ${b}`);
|
|
358
547
|
for (const w of receipt.removedWorktrees) console.log(` would remove ${w}`);
|
|
359
548
|
} else {
|
|
360
|
-
console.log(`landing cleanup done
|
|
549
|
+
console.log(`landing cleanup done: ${receipt.deletedBranches.length} pieces cleared, ${receipt.removedWorktrees.length} side copies removed`);
|
|
361
550
|
if (receipt.bundle) console.log(` backed up first, nothing lost: ${receipt.bundle}`);
|
|
551
|
+
if (receipt.bundleError) console.log(` backup failed: unlanded work left in place: ${receipt.bundleError}`);
|
|
362
552
|
for (const p of receipt.patches) console.log(` unsaved edits saved: ${p}`);
|
|
553
|
+
for (const u of receipt.untracked || []) console.log(` new files saved: ${u}`);
|
|
554
|
+
// fresh-grace keeps are healthy active work, not a call to action —
|
|
555
|
+
// "needs a human" on those trained readers to ignore the real ones.
|
|
556
|
+
for (const k of receipt.keptWorktrees || []) {
|
|
557
|
+
if (String(k).includes('(fresh_worktree_grace)')) console.log(` in use, left alone: ${k.replace(' (fresh_worktree_grace)', '')}`);
|
|
558
|
+
else console.log(` ✋ kept, needs a human: ${k}`);
|
|
559
|
+
}
|
|
560
|
+
for (const m of receipt.keptMovedBranches || []) console.log(` moved since scan, left alone: ${m}`);
|
|
363
561
|
if (receipt.deletedRemote.length > 0) console.log(` also cleared on github: ${receipt.deletedRemote.length}`);
|
|
364
562
|
}
|
|
365
563
|
console.log(` still flying (recent work, left alone): ${receipt.kept.length}`);
|
|
@@ -374,13 +572,15 @@ function readFlag(args, name, fallback = '') {
|
|
|
374
572
|
|
|
375
573
|
function showHelp() {
|
|
376
574
|
console.log('');
|
|
377
|
-
console.log('atris land
|
|
575
|
+
console.log('atris land: the landing, what is actually done vs still in the air');
|
|
378
576
|
console.log('');
|
|
379
577
|
console.log('work counts as done only when it lands in master. everything else is');
|
|
380
578
|
console.log('in the air, and this shows it so nothing quietly dies.');
|
|
381
579
|
console.log('');
|
|
382
580
|
console.log(' atris land show everything still in the air');
|
|
581
|
+
console.log(' atris land status show everything still in the air');
|
|
383
582
|
console.log(' atris land <name> the story of one piece: what it tried,');
|
|
583
|
+
console.log(' atris land status <name> same story lookup from status mode');
|
|
384
584
|
console.log(' whether it landed some other way, what');
|
|
385
585
|
console.log(' would be lost by clearing it');
|
|
386
586
|
console.log(' atris land --reap back up, then clear anything overdue');
|
|
@@ -389,7 +589,7 @@ function showHelp() {
|
|
|
389
589
|
console.log(' atris land --ttl <days> change the 7-day overdue line');
|
|
390
590
|
console.log(' atris land --json machine-readable output');
|
|
391
591
|
console.log('');
|
|
392
|
-
console.log('backups go to .atris/salvage/<date
|
|
592
|
+
console.log('backups go to .atris/salvage/<date>/; clearing never loses work.');
|
|
393
593
|
console.log('');
|
|
394
594
|
}
|
|
395
595
|
|
|
@@ -416,7 +616,8 @@ function landCommand(args = []) {
|
|
|
416
616
|
return 0;
|
|
417
617
|
}
|
|
418
618
|
|
|
419
|
-
const
|
|
619
|
+
const lookupArgs = args[0] === 'status' ? args.slice(1) : args;
|
|
620
|
+
const name = lookupArgs.find((a) => !a.startsWith('-') && a !== readFlag(lookupArgs, '--ttl') && a !== readFlag(lookupArgs, '--base'));
|
|
420
621
|
if (name) {
|
|
421
622
|
const story = collectStory(root, name, { base });
|
|
422
623
|
if (!story) {
|
package/commands/lesson.js
CHANGED
|
@@ -1,9 +1,112 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
writeLesson,
|
|
5
|
+
parseLessons,
|
|
6
|
+
loadLessonMetadata,
|
|
7
|
+
runLessonDetector,
|
|
8
|
+
} = require('./autopilot');
|
|
4
9
|
const { detectLessonContradictions } = require('../lib/lesson-contradiction');
|
|
5
10
|
const taskDb = require('../lib/task-db');
|
|
6
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Tag a lesson's line in atris/lessons.md with `[resolved]` (idempotent).
|
|
14
|
+
* Inserts the marker right after the verdict separator so both the autopilot
|
|
15
|
+
* parser (`resolvedTag`) and the memory view treat the lesson as retired.
|
|
16
|
+
* @returns {boolean} true if the file was changed.
|
|
17
|
+
*/
|
|
18
|
+
function tagLessonResolvedInMd(cwd, slug) {
|
|
19
|
+
const lessonsPath = path.join(cwd, 'atris', 'lessons.md');
|
|
20
|
+
if (!fs.existsSync(lessonsPath)) return false;
|
|
21
|
+
const lines = fs.readFileSync(lessonsPath, 'utf8').split('\n');
|
|
22
|
+
let changed = false;
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const m = lines[i].match(/\*\*\[\d{4}-\d{2}-\d{2}\]\s+([\w-]+)\*\*/);
|
|
25
|
+
if (!m || m[1] !== slug) continue;
|
|
26
|
+
if (/\[resolved\]/i.test(lines[i])) continue; // already tagged
|
|
27
|
+
lines[i] = lines[i].replace(
|
|
28
|
+
/(\*\*\[\d{4}-\d{2}-\d{2}\]\s+[\w-]+\*\*\s*[—-]\s*(?:pass|fail)?\s*[—-]?\s*)/,
|
|
29
|
+
'$1[resolved] '
|
|
30
|
+
);
|
|
31
|
+
changed = true;
|
|
32
|
+
}
|
|
33
|
+
if (changed) fs.writeFileSync(lessonsPath, lines.join('\n'));
|
|
34
|
+
return changed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Auto-resolve detector-backed fail lessons whose detector now passes.
|
|
39
|
+
*
|
|
40
|
+
* A `fail` lesson records a bug; its detector exits 0 once the bug is gone.
|
|
41
|
+
* When that happens we retire the lesson: stamp `status: resolved` +
|
|
42
|
+
* `resolved_at` in the atris/lessons.json sidecar and tag `[resolved]` in
|
|
43
|
+
* atris/lessons.md. Retired lessons stop being re-picked by the self-heal
|
|
44
|
+
* loop and drop out of the active memory view, keeping the lesson file small
|
|
45
|
+
* and trustworthy.
|
|
46
|
+
*
|
|
47
|
+
* Only detector-backed `fail` lessons self-retire. Prose-only lessons (no
|
|
48
|
+
* detector) and `observed` process rules are never auto-resolved — they have
|
|
49
|
+
* no falsifiable pass state.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} cwd
|
|
52
|
+
* @param {object} [options] - { dryRun, detectorTimeout }
|
|
53
|
+
* @returns {{ checked: string[], resolved: string[], dryRun: boolean }}
|
|
54
|
+
*/
|
|
55
|
+
function autoResolveLessons(cwd, options = {}) {
|
|
56
|
+
const dryRun = !!options.dryRun;
|
|
57
|
+
const lessons = parseLessons(cwd);
|
|
58
|
+
const metadata = loadLessonMetadata(cwd);
|
|
59
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
60
|
+
const checked = [];
|
|
61
|
+
const resolved = [];
|
|
62
|
+
|
|
63
|
+
for (const lesson of lessons) {
|
|
64
|
+
const meta = lesson.meta;
|
|
65
|
+
if (!meta || !meta.detector) continue; // only detector-backed lessons self-retire
|
|
66
|
+
if (lesson.verdict !== 'fail') continue; // only bug lessons carry a resolve semantic
|
|
67
|
+
if (meta.status === 'resolved') continue; // already retired
|
|
68
|
+
if (meta.status === 'observed') continue; // process rule, never auto-resolve
|
|
69
|
+
checked.push(lesson.id);
|
|
70
|
+
if (!runLessonDetector(meta.detector, cwd, options.detectorTimeout)) continue;
|
|
71
|
+
resolved.push(lesson.id);
|
|
72
|
+
if (!dryRun) {
|
|
73
|
+
metadata[lesson.id] = { ...meta, status: 'resolved', resolved_at: today };
|
|
74
|
+
tagLessonResolvedInMd(cwd, lesson.id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!dryRun && resolved.length) {
|
|
79
|
+
const metaPath = path.join(cwd, 'atris', 'lessons.json');
|
|
80
|
+
fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + '\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { checked, resolved, dryRun };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveLessons(args) {
|
|
87
|
+
const cwd = process.cwd();
|
|
88
|
+
const json = args.includes('--json');
|
|
89
|
+
const dryRun = args.includes('--dry-run');
|
|
90
|
+
const { checked, resolved } = autoResolveLessons(cwd, { dryRun });
|
|
91
|
+
|
|
92
|
+
if (json) {
|
|
93
|
+
console.log(JSON.stringify({
|
|
94
|
+
ok: true,
|
|
95
|
+
action: 'lesson_resolve',
|
|
96
|
+
dry_run: dryRun,
|
|
97
|
+
detectors_checked: checked.length,
|
|
98
|
+
resolved_count: resolved.length,
|
|
99
|
+
resolved,
|
|
100
|
+
}, null, 2));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const verb = dryRun ? 'would auto-resolve' : 'auto-resolved';
|
|
105
|
+
const tail = resolved.length ? `: ${resolved.join(', ')}` : '';
|
|
106
|
+
console.log(`ran ${checked.length} detector(s); ${verb} ${resolved.length} lesson(s)${tail}`);
|
|
107
|
+
if (dryRun) console.log('(dry-run: nothing written)');
|
|
108
|
+
}
|
|
109
|
+
|
|
7
110
|
function sweepLessons(args) {
|
|
8
111
|
const cwd = process.cwd();
|
|
9
112
|
const json = args.includes('--json');
|
|
@@ -127,11 +230,17 @@ function lessonAtris(subcommand, ...args) {
|
|
|
127
230
|
return;
|
|
128
231
|
}
|
|
129
232
|
|
|
233
|
+
if (subcommand === 'resolve') {
|
|
234
|
+
resolveLessons(args);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
130
238
|
if (subcommand !== 'add') {
|
|
131
239
|
console.log('');
|
|
132
240
|
console.log(' Usage: atris lesson add <slug> <pass|fail> "<text>"');
|
|
133
241
|
console.log(' atris lesson mine [--json] [--dry-run]');
|
|
134
242
|
console.log(' atris lesson sweep [--json] [--dry-run]');
|
|
243
|
+
console.log(' atris lesson resolve [--json] [--dry-run]');
|
|
135
244
|
console.log('');
|
|
136
245
|
process.exit(subcommand ? 1 : 0);
|
|
137
246
|
}
|
|
@@ -159,3 +268,5 @@ function lessonAtris(subcommand, ...args) {
|
|
|
159
268
|
}
|
|
160
269
|
|
|
161
270
|
module.exports = lessonAtris;
|
|
271
|
+
module.exports.autoResolveLessons = autoResolveLessons;
|
|
272
|
+
module.exports.tagLessonResolvedInMd = tagLessonResolvedInMd;
|