atris 3.71.2 → 3.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/atris/skills/campaigns/SKILL.md +1 -1
  2. package/bin/atris.js +8 -2
  3. package/commands/autoland.js +102 -2
  4. package/commands/campaign.js +263 -11
  5. package/commands/clean.js +60 -4
  6. package/commands/gm.js +1273 -7
  7. package/commands/improve.js +3 -2
  8. package/commands/member.js +28 -3
  9. package/commands/mission.js +82 -27
  10. package/commands/pulse.js +89 -5
  11. package/commands/sync.js +2 -2
  12. package/commands/task.js +49 -13
  13. package/commands/worktree.js +24 -2
  14. package/commands/zen.js +183 -0
  15. package/lib/auto-accept-certified.js +9 -4
  16. package/lib/campaign-cadence.js +53 -14
  17. package/lib/campaign-core.js +98 -17
  18. package/lib/campaign-keep.js +91 -0
  19. package/lib/campaign-lanes.js +11 -6
  20. package/lib/campaign-loop.js +173 -32
  21. package/lib/campaign-reading.js +14 -1
  22. package/lib/campaign-rsi.js +697 -0
  23. package/lib/campaign-run-lock.js +9 -1
  24. package/lib/campaign-schedule.js +124 -0
  25. package/lib/campaign-schema.js +3 -1
  26. package/lib/campaign-store.js +34 -2
  27. package/lib/campaign-trace.js +77 -29
  28. package/lib/engine-resource.js +5 -1
  29. package/lib/gm-bet.js +2219 -0
  30. package/lib/gm-phone-page.js +1102 -0
  31. package/lib/gm-phone.js +682 -0
  32. package/lib/gm-season.js +1162 -0
  33. package/lib/gm-target.js +292 -0
  34. package/lib/job-code-verify.js +169 -0
  35. package/lib/known-commands.js +1 -1
  36. package/lib/member-autoland.js +105 -14
  37. package/lib/member-progress.js +2 -2
  38. package/lib/member-report.js +11 -1
  39. package/lib/member-self-next-job.js +6 -1
  40. package/lib/member-up.js +17 -8
  41. package/lib/merged-review-close.js +206 -0
  42. package/lib/mission-code-provenance.js +118 -0
  43. package/lib/mission-protected-lane.js +52 -4
  44. package/lib/pulse.js +2 -1
  45. package/lib/qr.js +338 -0
  46. package/lib/rsi-record.js +88 -0
  47. package/lib/task-receipt.js +24 -6
  48. package/lib/yes-lock.js +530 -0
  49. package/lib/zen-apply.js +511 -0
  50. package/lib/zen-workspace.js +121 -0
  51. package/lib/zen.js +395 -0
  52. package/package.json +1 -1
@@ -0,0 +1,511 @@
1
+ 'use strict';
2
+
3
+ // Bring a repo to zen state. Every step is recoverable: stashes become refs
4
+ // under refs/archive/, stray code and unpushed commits go to zen-backup/
5
+ // branches, side copies are removed only when clean (their branches stay),
6
+ // and only merged branches are deleted. Anything touched recently is left
7
+ // alone, because someone may be working on it.
8
+
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawnSync } = require('node:child_process');
13
+ const { runGit } = require('./checkout-sync');
14
+ const {
15
+ checkZen,
16
+ defaultBranch,
17
+ dirtyEntries,
18
+ FRESH_MINUTES,
19
+ gitOut,
20
+ headMovedSince,
21
+ isTeamState,
22
+ listWorktrees,
23
+ readTeamState,
24
+ mtime,
25
+ idleWithOnlyNotes,
26
+ lastTouched,
27
+ sideCopyLines,
28
+ scheduledCopies,
29
+ untrackedFiles,
30
+ ABANDONED_DAYS,
31
+ abandonedDaysFromEnv,
32
+ } = require('./zen');
33
+ const { workspaceZen } = require('./zen-workspace');
34
+
35
+ function plural(n, one, many) {
36
+ return `${n} ${n === 1 ? one : many}`;
37
+ }
38
+
39
+ function stamp(now) {
40
+ const p = (n) => String(n).padStart(2, '0');
41
+ return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}-`
42
+ + `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
43
+ }
44
+
45
+ function firstLine(text) {
46
+ return String(text || '').trim().split('\n')[0] || 'git refused';
47
+ }
48
+
49
+ function chunks(list, size = 200) {
50
+ const out = [];
51
+ for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
52
+ return out;
53
+ }
54
+
55
+ function gitEnv(args, cwd, env) {
56
+ return spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60000, env: { ...process.env, ...env } });
57
+ }
58
+
59
+ function commonDir(root) {
60
+ const dir = gitOut(['rev-parse', '--git-common-dir'], root);
61
+ return path.resolve(root, dir || '.git');
62
+ }
63
+
64
+ // A checkout whose HEAD moved recently is in use: someone just committed,
65
+ // switched, or created it.
66
+ function identityEnv(root) {
67
+ const env = {};
68
+ if (!gitOut(['config', 'user.name'], root)) {
69
+ env.GIT_AUTHOR_NAME = 'atris zen';
70
+ env.GIT_COMMITTER_NAME = 'atris zen';
71
+ }
72
+ if (!gitOut(['config', 'user.email'], root)) {
73
+ env.GIT_AUTHOR_EMAIL = 'zen@atris.local';
74
+ env.GIT_COMMITTER_EMAIL = 'zen@atris.local';
75
+ }
76
+ return env;
77
+ }
78
+
79
+ function archiveStashes(root, tag) {
80
+ const shas = (gitOut(['stash', 'list', '--format=%H'], root) || '').split('\n').filter(Boolean);
81
+ if (!shas.length) return null;
82
+ const prefix = `refs/archive/zen-${tag}`;
83
+ shas.forEach((sha, i) => {
84
+ runGit(['update-ref', `${prefix}/stash-${String(i).padStart(3, '0')}`, sha], root);
85
+ });
86
+ const archived = (gitOut(['for-each-ref', '--format=%(objectname)', prefix], root) || '')
87
+ .split('\n').filter(Boolean).length;
88
+ if (archived !== shas.length || runGit(['stash', 'clear'], root).status !== 0) {
89
+ return { left: 'could not archive the old stashes, so they stay where they are' };
90
+ }
91
+ return { done: `saved ${plural(shas.length, 'old stash', 'old stashes')} away`, saved: prefix };
92
+ }
93
+
94
+ // When the newest unsaved change in a side copy was made.
95
+ // Save every unsaved change in a side copy onto a branch. Returns the branch
96
+ // only when the branch provably holds all of it.
97
+ function saveSideCopy(root, checkout, tag) {
98
+ const index = path.join(os.tmpdir(), `atris-zen-side-${process.pid}-${Date.now()}`);
99
+ const env = { GIT_INDEX_FILE: index, ...identityEnv(checkout) };
100
+ try {
101
+ if (gitEnv(['read-tree', 'HEAD'], checkout, env).status !== 0) return null;
102
+ if (gitEnv(['add', '-A', '.'], checkout, env).status !== 0) return null;
103
+ const unsaved = gitEnv(['ls-files', '--others', '--exclude-standard'], checkout, env).stdout.trim();
104
+ if (unsaved || gitEnv(['diff-files', '--quiet'], checkout, env).status !== 0) return null;
105
+ const tree = gitEnv(['write-tree'], checkout, env).stdout.trim();
106
+ const name = path.basename(checkout).replace(/[^A-Za-z0-9._-]/g, '-');
107
+ const commit = gitEnv(['commit-tree', tree, '-p', 'HEAD', '-m', `atris zen: unsaved work from ${name}`], checkout, env)
108
+ .stdout.trim();
109
+ const branch = `zen-backup/${tag}-${name}`;
110
+ if (!commit || runGit(['update-ref', `refs/heads/${branch}`, commit], root).status !== 0) return null;
111
+ return branch;
112
+ } finally {
113
+ fs.rmSync(index, { force: true });
114
+ }
115
+ }
116
+
117
+ function putAwaySideCopies(root, cutoff, staleCutoff, tag, teamState) {
118
+ let removed = 0;
119
+ const busy = [];
120
+ const notesKept = [];
121
+ let inUse = 0;
122
+ let prune = false;
123
+ let scheduledCount = 0;
124
+ let locked = 0;
125
+ const kept = [];
126
+ const worktrees = listWorktrees(root);
127
+ const scheduled = scheduledCopies(worktrees);
128
+ for (const wt of worktrees) {
129
+ if (path.resolve(wt.path) === path.resolve(root)) continue;
130
+ if (scheduled.has(path.resolve(wt.path))) {
131
+ scheduledCount += 1;
132
+ continue;
133
+ }
134
+ if (wt.locked) {
135
+ locked += 1;
136
+ continue;
137
+ }
138
+ if (wt.prunable || !fs.existsSync(wt.path)) {
139
+ prune = true;
140
+ removed += 1;
141
+ continue;
142
+ }
143
+ if (headMovedSince(wt.path, cutoff)) {
144
+ inUse += 1;
145
+ continue;
146
+ }
147
+ const status = runGit(['status', '--porcelain'], wt.path);
148
+ if (status.status !== 0) {
149
+ busy.push(wt.path);
150
+ continue;
151
+ }
152
+ if (status.stdout.trim()) {
153
+ // Unsaved work nobody has touched in days goes to a branch, then away.
154
+ // So does an idle copy holding only team notes: nothing in it is work.
155
+ const notesOnly = idleWithOnlyNotes(wt.path, teamState, cutoff);
156
+ const abandoned = !headMovedSince(wt.path, staleCutoff) && lastTouched(wt.path) <= staleCutoff;
157
+ const branch = notesOnly || abandoned ? saveSideCopy(root, wt.path, tag) : null;
158
+ if (branch && runGit(['worktree', 'remove', '--force', wt.path], root).status === 0) {
159
+ (notesOnly && !abandoned ? notesKept : kept).push(branch);
160
+ } else busy.push(wt.path);
161
+ continue;
162
+ }
163
+ if (runGit(['worktree', 'remove', wt.path], root).status === 0) removed += 1;
164
+ else busy.push(wt.path);
165
+ }
166
+ if (prune) runGit(['worktree', 'prune'], root);
167
+ return { removed, busy, inUse, kept, notesKept, scheduled: scheduledCount, locked };
168
+ }
169
+
170
+ function clearMergedBranches(root, upstream, main) {
171
+ const checkedOut = new Set(listWorktrees(root).map((wt) => wt.branch).filter(Boolean));
172
+ const current = gitOut(['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
173
+ const merged = (gitOut(['branch', '--merged', upstream, '--format=%(refname:short)'], root) || '')
174
+ .split('\n')
175
+ .filter((name) => name && name !== main && name !== current && !checkedOut.has(name));
176
+ let cleared = 0;
177
+ for (const name of merged) {
178
+ if (runGit(['branch', '-q', '-d', name], root).status === 0) cleared += 1;
179
+ }
180
+ return cleared;
181
+ }
182
+
183
+ // Save stray code edits on a zen-backup branch, then return those files to
184
+ // HEAD. Edits from the last few minutes are left alone.
185
+ function saveStrayEdits(root, teamState, tag, cutoff) {
186
+ const fresh = new Set();
187
+ const files = new Set();
188
+ for (const entry of dirtyEntries(root)) {
189
+ if (isTeamState(entry.path, teamState)) continue;
190
+ const paths = entry.xy === '??' && entry.path.endsWith('/')
191
+ ? untrackedFiles(root, entry.path)
192
+ : [entry.path, entry.orig].filter(Boolean);
193
+ const isFresh = paths.some((p) => mtime(path.join(root, p)) > cutoff);
194
+ for (const p of paths) (isFresh ? fresh : files).add(p);
195
+ }
196
+ const result = { saved: 0, fresh: fresh.size, branch: null, left: null };
197
+ const list = [...files];
198
+ if (!list.length) return result;
199
+ if (runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], root).status !== 0) {
200
+ result.left = 'stray edits stay, because this repo has no commits yet';
201
+ return result;
202
+ }
203
+
204
+ const index = path.join(os.tmpdir(), `atris-zen-index-${process.pid}-${Date.now()}`);
205
+ const env = { GIT_INDEX_FILE: index, ...identityEnv(root) };
206
+ try {
207
+ gitEnv(['read-tree', 'HEAD'], root, env);
208
+ for (const part of chunks(list)) gitEnv(['add', '-A', '--', ...part], root, env);
209
+ const tree = gitEnv(['write-tree'], root, env).stdout.trim();
210
+ const commit = gitEnv(['commit-tree', tree, '-p', 'HEAD', '-m', `atris zen: stray edits saved ${tag}`], root, env)
211
+ .stdout.trim();
212
+ const branch = `zen-backup/${tag}`;
213
+ if (!commit || runGit(['update-ref', `refs/heads/${branch}`, commit], root).status !== 0) {
214
+ result.left = 'could not save the stray edits, so they stay in the tree';
215
+ return result;
216
+ }
217
+
218
+ // Only touch files the backup really holds.
219
+ const held = new Set();
220
+ for (const part of chunks(list)) {
221
+ (gitOut(['ls-tree', '-r', '--name-only', commit, '--', ...part], root) || '')
222
+ .split('\n').filter(Boolean).forEach((p) => held.add(p));
223
+ }
224
+ const inHead = new Set();
225
+ for (const part of chunks(list)) {
226
+ (gitOut(['ls-tree', '-r', '--name-only', 'HEAD', '--', ...part], root) || '')
227
+ .split('\n').filter(Boolean).forEach((p) => inHead.add(p));
228
+ }
229
+ const restore = list.filter((p) => inHead.has(p));
230
+ const remove = list.filter((p) => !inHead.has(p) && held.has(p));
231
+ for (const part of chunks(restore)) runGit(['checkout', '-q', 'HEAD', '--', ...part], root);
232
+ for (const part of chunks(remove)) runGit(['rm', '-q', '--cached', '--ignore-unmatch', '--', ...part], root);
233
+ for (const p of remove) {
234
+ fs.rmSync(path.join(root, p), { force: true });
235
+ let dir = path.dirname(path.join(root, p));
236
+ while (dir.startsWith(root) && dir !== root) {
237
+ try {
238
+ fs.rmdirSync(dir);
239
+ } catch {
240
+ break;
241
+ }
242
+ dir = path.dirname(dir);
243
+ }
244
+ }
245
+ result.saved = restore.length + remove.length;
246
+ result.branch = branch;
247
+ return result;
248
+ } finally {
249
+ fs.rmSync(index, { force: true });
250
+ }
251
+ }
252
+
253
+ // Files a 3-way apply left with conflict marks. Each goes back to the new
254
+ // version, so a team note never holds <<<<<<< lines.
255
+ function settleClashes(root, files) {
256
+ const clashed = (gitOut(['diff', '--name-only', '--diff-filter=U', '--', ...files], root) || '')
257
+ .split('\n').filter(Boolean);
258
+ runGit(['reset', '-q', '--', ...files], root);
259
+ for (const p of clashed) runGit(['checkout', '-q', 'HEAD', '--', p], root);
260
+ return clashed;
261
+ }
262
+
263
+ // Lay a saved patch of team notes back down. One file that cannot go back
264
+ // (say its note was deleted upstream) makes git refuse the whole patch, so on
265
+ // that refusal each file goes back on its own and only the misfits stay out.
266
+ function applyNotes(root, patchFile, files) {
267
+ const whole = runGit(['apply', '-3', '--binary', patchFile], root);
268
+ const clashed = settleClashes(root, files);
269
+ if (whole.status === 0 || clashed.length) return { clashed, missed: [] };
270
+ const missed = [];
271
+ for (const p of files) {
272
+ const one = runGit(['apply', '-3', '--binary', `--include=${p.replace(/[*?[\\]/g, '\\$&')}`, patchFile], root);
273
+ if (settleClashes(root, [p]).length) clashed.push(p);
274
+ else if (one.status !== 0) missed.push(p);
275
+ }
276
+ return { clashed, missed };
277
+ }
278
+
279
+ function sameBytes(a, b) {
280
+ try {
281
+ return fs.readFileSync(a).equals(fs.readFileSync(b));
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
287
+ // Run a fast-forward or reset. If uncommitted team notes are in the way, save
288
+ // them, move, then lay them back on top.
289
+ function moveKeepingNotes(root, args, target, tag) {
290
+ const first = runGit(args, root);
291
+ if (first.status === 0) return { ok: true, notes: [] };
292
+
293
+ const changed = new Set((gitOut(['diff', '--name-only', 'HEAD', target], root) || '').split('\n').filter(Boolean));
294
+ const dirty = dirtyEntries(root);
295
+ const tracked = dirty.filter((e) => e.xy !== '??' && changed.has(e.path)).map((e) => e.path);
296
+ const untracked = dirty
297
+ .filter((e) => e.xy === '??')
298
+ .flatMap((e) => (e.path.endsWith('/') ? untrackedFiles(root, e.path) : [e.path]))
299
+ .filter((p) => changed.has(p));
300
+ if (!tracked.length && !untracked.length) return { ok: false, error: firstLine(first.stderr) };
301
+
302
+ const dir = path.join(commonDir(root), 'atris-zen', tag);
303
+ fs.mkdirSync(dir, { recursive: true });
304
+ const patchFile = path.join(dir, 'team-notes.patch');
305
+ if (tracked.length) {
306
+ const diff = runGit(['diff', '--binary', 'HEAD', '--', ...tracked], root);
307
+ fs.writeFileSync(patchFile, diff.stdout);
308
+ for (const p of tracked) {
309
+ if (!fs.existsSync(path.join(root, p))) continue;
310
+ const mine = path.join(dir, 'mine', p);
311
+ fs.mkdirSync(path.dirname(mine), { recursive: true });
312
+ fs.copyFileSync(path.join(root, p), mine);
313
+ }
314
+ runGit(['checkout', '-q', 'HEAD', '--', ...tracked], root);
315
+ }
316
+ for (const p of untracked) {
317
+ const dest = path.join(dir, 'untracked', p);
318
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
319
+ fs.copyFileSync(path.join(root, p), dest);
320
+ fs.rmSync(path.join(root, p), { force: true });
321
+ }
322
+
323
+ const second = runGit(args, root);
324
+ const notes = [];
325
+ if (tracked.length) {
326
+ const { clashed, missed } = applyNotes(root, patchFile, tracked);
327
+ for (const p of clashed) notes.push(`kept the new ${p}; your copy is in ${path.join(dir, 'mine', p)}`);
328
+ for (const p of missed) {
329
+ const mine = path.join(dir, 'mine', p);
330
+ notes.push(`${p} no longer fits the new changes; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
331
+ }
332
+ }
333
+ for (const p of untracked) {
334
+ const saved = path.join(dir, 'untracked', p);
335
+ if (fs.existsSync(path.join(root, p))) {
336
+ // A copy that matches the incoming file loses nothing, so say nothing.
337
+ if (sameBytes(saved, path.join(root, p))) fs.rmSync(saved, { force: true });
338
+ else notes.push(`kept the new ${p}; your copy is in ${saved}`);
339
+ } else {
340
+ fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
341
+ fs.copyFileSync(saved, path.join(root, p));
342
+ }
343
+ }
344
+ if (second.status !== 0) return { ok: false, error: firstLine(second.stderr), notes };
345
+ return { ok: true, notes };
346
+ }
347
+
348
+ // Unpushed commits can carry team-note edits. Their commits move to a backup
349
+ // branch, but the notes stay in the tree, uncommitted, like any team note.
350
+ function carryTeamNotes(root, base, commit, teamState, tag) {
351
+ const files = (gitOut(['diff', '--name-only', base, commit], root) || '')
352
+ .split('\n').filter((p) => p && isTeamState(p, teamState));
353
+ if (!files.length) return { carried: 0, notes: [] };
354
+ const diff = runGit(['diff', '--binary', base, commit, '--', ...files], root);
355
+ if (diff.status !== 0 || !diff.stdout) return { carried: 0, notes: [] };
356
+ const dir = path.join(commonDir(root), 'atris-zen', tag);
357
+ fs.mkdirSync(dir, { recursive: true });
358
+ const patchFile = path.join(dir, 'unpushed-team-notes.patch');
359
+ fs.writeFileSync(patchFile, diff.stdout);
360
+ const { clashed, missed } = applyNotes(root, patchFile, files);
361
+ const notes = clashed.map((p) => `kept the new ${p}; yours is on ${commit}`);
362
+ for (const p of missed) notes.push(`${p} no longer fits the new changes; yours is on ${commit}`);
363
+ return { carried: files.length - clashed.length - missed.length, notes };
364
+ }
365
+
366
+ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDays, now = new Date() } = {}) {
367
+ const minutes = Number.isFinite(freshMinutes)
368
+ ? freshMinutes
369
+ : Number.parseFloat(process.env.ATRIS_ZEN_FRESH_MINUTES || FRESH_MINUTES);
370
+ const staleDays = Number.isFinite(abandonedDays) ? abandonedDays : abandonedDaysFromEnv();
371
+ const before = checkZen({ cwd, fetch, freshMinutes: minutes, now: now.getTime() });
372
+ if (!before.ok) return before;
373
+
374
+ const root = before.root;
375
+ const tag = stamp(now);
376
+ const cutoff = now.getTime() - minutes * 60000;
377
+ const window = `the last ${plural(minutes, 'minute', 'minutes')}`;
378
+ const done = [];
379
+ const left = [];
380
+ const saved = [];
381
+ const notes = [];
382
+ const teamState = readTeamState(root);
383
+ const main = before.upstream ? defaultBranch(root, 'origin') : null;
384
+ const homeInUse = headMovedSince(root, cutoff);
385
+
386
+ const stashes = archiveStashes(root, tag);
387
+ if (stashes?.done) {
388
+ done.push(stashes.done);
389
+ saved.push(stashes.saved);
390
+ } else if (stashes?.left) {
391
+ left.push(stashes.left);
392
+ }
393
+
394
+ const copies = putAwaySideCopies(root, cutoff, now.getTime() - staleDays * 86400000, tag, teamState);
395
+ if (copies.removed) done.push(`put away ${plural(copies.removed, 'idle side copy', 'idle side copies')}`);
396
+ if (copies.notesKept.length) {
397
+ const n = copies.notesKept.length;
398
+ done.push(`saved the team notes from ${plural(n, 'idle side copy', 'idle side copies')} to zen-backup/ and put ${n === 1 ? 'it' : 'them'} away`);
399
+ saved.push(...copies.notesKept.map((b) => `refs/heads/${b}`));
400
+ }
401
+ if (copies.kept.length) {
402
+ done.push(`saved ${plural(copies.kept.length, 'side copy', 'side copies')} untouched for ${plural(staleDays, 'day', 'days')} to zen-backup/ and put ${copies.kept.length === 1 ? 'it' : 'them'} away`);
403
+ saved.push(...copies.kept.map((b) => `refs/heads/${b}`));
404
+ }
405
+ if (copies.busy.length) left.push(...sideCopyLines(copies.busy, staleDays));
406
+ if (copies.scheduled) left.push(`${plural(copies.scheduled, 'side copy is', 'side copies are')} used by a scheduled job, so ${copies.scheduled === 1 ? 'it stays' : 'they stay'}`);
407
+ if (copies.locked) left.push(`${plural(copies.locked, 'side copy is', 'side copies are')} locked by ${copies.locked === 1 ? 'its owner, so it stays' : 'their owners, so they stay'}`);
408
+ if (copies.inUse) left.push(`${plural(copies.inUse, 'side copy was', 'side copies were')} used in ${window}`);
409
+
410
+ const stray = saveStrayEdits(root, teamState, tag, cutoff);
411
+ if (stray.saved) {
412
+ done.push(`saved ${plural(stray.saved, 'stray edit', 'stray edits')} to ${stray.branch}`);
413
+ saved.push(`refs/heads/${stray.branch}`);
414
+ }
415
+ if (stray.fresh) left.push(`${plural(stray.fresh, 'edit was', 'edits were')} made in ${window}, so ${stray.fresh === 1 ? 'it stays' : 'they stay'}`);
416
+ if (stray.left) left.push(stray.left);
417
+
418
+ let branch = gitOut(['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
419
+ if (main && branch !== main) {
420
+ const stillStray = dirtyEntries(root).some((e) => !isTeamState(e.path, teamState));
421
+ if (stillStray || homeInUse) {
422
+ left.push(`stayed on ${branch || 'a detached commit'}, because it was used in ${window}`);
423
+ } else {
424
+ if (!branch) {
425
+ const keep = `zen-backup/${tag}-detached`;
426
+ runGit(['update-ref', `refs/heads/${keep}`, 'HEAD'], root);
427
+ saved.push(`refs/heads/${keep}`);
428
+ }
429
+ const moved = moveKeepingNotes(root, ['checkout', '-q', main], main, tag);
430
+ notes.push(...(moved.notes || []));
431
+ if (moved.ok) {
432
+ done.push(`moved back to ${main}${branch ? ` (${branch} is kept)` : ''}`);
433
+ branch = main;
434
+ } else {
435
+ left.push(`could not move to ${main}: ${moved.error}`);
436
+ }
437
+ }
438
+ }
439
+
440
+ if (main && branch === main) {
441
+ const counts = gitOut(['rev-list', '--left-right', '--count', `HEAD...${before.upstream}`], root) || '0 0';
442
+ const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
443
+ if (ahead && homeInUse) {
444
+ left.push(`${plural(ahead, 'unpushed commit stays', 'unpushed commits stay')}, made in ${window}`);
445
+ } else if (ahead) {
446
+ const keep = `zen-backup/${tag}-unpushed`;
447
+ runGit(['update-ref', `refs/heads/${keep}`, 'HEAD'], root);
448
+ const moved = moveKeepingNotes(root, ['reset', '-q', '--keep', before.upstream], before.upstream, tag);
449
+ notes.push(...(moved.notes || []));
450
+ if (moved.ok) {
451
+ done.push(`moved ${plural(ahead, 'unpushed commit', 'unpushed commits')} to ${keep}`);
452
+ const base = gitOut(['merge-base', keep, before.upstream], root);
453
+ const carried = base ? carryTeamNotes(root, base, keep, teamState, tag) : { carried: 0, notes: [] };
454
+ notes.push(...carried.notes);
455
+ if (carried.carried) {
456
+ done.push(`kept ${plural(carried.carried, 'team note', 'team notes')} from ${ahead === 1 ? 'it' : 'them'} in the tree`);
457
+ }
458
+ if (behind) done.push(`caught up ${plural(behind, 'commit', 'commits')} from ${before.upstream}`);
459
+ saved.push(`refs/heads/${keep}`);
460
+ } else {
461
+ runGit(['update-ref', '-d', `refs/heads/${keep}`], root);
462
+ left.push(`could not line up with ${before.upstream}: ${moved.error}`);
463
+ }
464
+ } else if (behind) {
465
+ const moved = moveKeepingNotes(root, ['merge', '-q', '--ff-only', before.upstream], before.upstream, tag);
466
+ notes.push(...(moved.notes || []));
467
+ if (moved.ok) done.push(`caught up ${plural(behind, 'commit', 'commits')} from ${before.upstream}`);
468
+ else left.push(`could not catch up with ${before.upstream}: ${moved.error}`);
469
+ }
470
+ }
471
+
472
+ if (before.upstream) {
473
+ const cleared = clearMergedBranches(root, before.upstream, main);
474
+ if (cleared) done.push(`cleared ${plural(cleared, 'finished branch', 'finished branches')}`);
475
+ }
476
+
477
+ const workspace = workspaceZen({ root });
478
+
479
+ const after = checkZen({ cwd: root, fetch: false, freshMinutes: minutes, now: now.getTime() });
480
+ const result = {
481
+ ok: true,
482
+ zen: after.zen,
483
+ root,
484
+ upstream: before.upstream,
485
+ already: before.zen && !done.length && !workspace.done.length,
486
+ workspace,
487
+ done,
488
+ left,
489
+ notes,
490
+ saved,
491
+ team_files: after.team_files,
492
+ remaining: after.problems.map((p) => p.text),
493
+ };
494
+ try {
495
+ fs.appendFileSync(path.join(commonDir(root), 'atris-zen.jsonl'), `${JSON.stringify({
496
+ at: now.toISOString(),
497
+ before: before.problems.map((p) => ({ kind: p.kind, count: p.count || 1 })),
498
+ after: after.problems.map((p) => ({ kind: p.kind, count: p.count || 1 })),
499
+ done,
500
+ left,
501
+ saved,
502
+ workspace: workspace.done,
503
+ yours: workspace.yours.map((item) => item.text),
504
+ })}\n`);
505
+ } catch {
506
+ // The receipt is a nicety; zen itself already happened.
507
+ }
508
+ return result;
509
+ }
510
+
511
+ module.exports = { applyZen, FRESH_MINUTES, ABANDONED_DAYS };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ // The workspace and understanding layers of atris zen. Zen runs the safe,
4
+ // existing cleanups (`atris clean`), then names the few things only a person
5
+ // can do, each with one command. That short list is the shared picture: zen
6
+ // did everything it could, and here is exactly what is left for you.
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const { spawnSync } = require('node:child_process');
11
+
12
+ const CLI = path.join(__dirname, '..', 'bin', 'atris.js');
13
+
14
+ function plural(n, one, many) {
15
+ return `${n} ${n === 1 ? one : many}`;
16
+ }
17
+
18
+ function runCliJson(args, root) {
19
+ const result = spawnSync(process.execPath, [CLI, ...args], {
20
+ cwd: root,
21
+ encoding: 'utf8',
22
+ timeout: 180000,
23
+ env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_NONINTERACTIVE: '1' },
24
+ });
25
+ const text = String(result.stdout || '');
26
+ const start = text.indexOf('{');
27
+ if (start < 0) return null;
28
+ try {
29
+ return JSON.parse(text.slice(start));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ // The map as it stands on disk: MAP.md plus any split files under atris/map/.
36
+ function mapSnapshot(root) {
37
+ const files = [path.join(root, 'atris', 'MAP.md')];
38
+ const dir = path.join(root, 'atris', 'map');
39
+ try {
40
+ for (const name of fs.readdirSync(dir).sort()) files.push(path.join(dir, name));
41
+ } catch {
42
+ // No split map.
43
+ }
44
+ return files.map((f) => {
45
+ try {
46
+ return `${f}\0${fs.readFileSync(f, 'utf8')}`;
47
+ } catch {
48
+ return `${f}\0`;
49
+ }
50
+ }).join('\0\0');
51
+ }
52
+
53
+ function workspaceZen({ root, dryRun = false } = {}) {
54
+ const out = { ran: false, done: [], todo: [], yours: [] };
55
+ if (!root || !fs.existsSync(path.join(root, 'atris'))) return out;
56
+ out.ran = true;
57
+
58
+ const mapBefore = dryRun ? null : mapSnapshot(root);
59
+ const clean = runCliJson(dryRun ? ['clean', '--dry-run', '--json'] : ['clean', '--json'], root);
60
+ const r = clean?.results || {};
61
+ const lines = [];
62
+ const journals = r.journals?.archived_count || 0;
63
+ if (journals) lines.push(`${dryRun ? '' : 'archived '}${plural(journals, 'old journal', 'old journals')}${dryRun ? ' to archive' : ''}`);
64
+ const stale = r.stale_tasks?.count || 0;
65
+ if (stale) lines.push(`${dryRun ? '' : 'put away '}${plural(stale, 'stale task', 'stale tasks')}${dryRun ? ' to put away' : ''}`);
66
+ const healed = r.map_refs?.healed_count || 0;
67
+ if (healed) lines.push(`${dryRun ? '' : 'fixed '}${plural(healed, 'broken map link', 'broken map links')}${dryRun ? ' to fix' : ''}`);
68
+ const sections = r.sections?.cleaned_count || 0;
69
+ if (sections) lines.push(`${dryRun ? '' : 'cleared '}${plural(sections, 'empty task section', 'empty task sections')}${dryRun ? ' to clear' : ''}`);
70
+ if (!dryRun && mapSnapshot(root) !== mapBefore) lines.push('refreshed the map');
71
+ if (dryRun) out.todo.push(...lines);
72
+ else out.done.push(...lines);
73
+
74
+ const unhealable = r.map_refs?.unhealable_count || 0;
75
+ if (unhealable) out.yours.push({ text: `${plural(unhealable, 'map link points', 'map links point')} nowhere`, cmd: 'atris clean' });
76
+ const pages = r.stale_pages?.items || [];
77
+ if (pages.length) {
78
+ out.yours.push({
79
+ text: `${plural(pages.length, 'wiki page is', 'wiki pages are')} behind ${pages.length === 1 ? 'its source' : 'their sources'}`,
80
+ cmd: 'atris clean --dry-run',
81
+ });
82
+ }
83
+ const dead = r.dead_code?.count || 0;
84
+ if (dead) out.yours.push({ text: `${plural(dead, 'file looks', 'files look')} unused`, cmd: 'atris clean --dry-run' });
85
+
86
+ // Every wiki surface that needs pruning shares one command, so they share
87
+ // one line too: a person sees one job, not three.
88
+ try {
89
+ const seen = new Set();
90
+ const parts = [];
91
+ for (const f of require('./wiki').findWikiMetabolismFindings(root)) {
92
+ if (seen.has(f.surface)) continue;
93
+ seen.add(f.surface);
94
+ const unit = f.surface.endsWith('/') ? 'pages' : 'entries';
95
+ parts.push(f.code === 'over-budget'
96
+ ? { one: `wiki ${f.surface} has ${f.count} ${unit}, its budget is ${f.budget}`, short: `${f.surface} ${f.count} of ${f.budget}` }
97
+ : { one: `wiki ${f.surface} grew by ${f.growth} ${unit} since its last pruning`, short: `${f.surface} grew by ${f.growth}` });
98
+ }
99
+ if (parts.length === 1) out.yours.push({ text: parts[0].one, cmd: 'atris wiki consolidate' });
100
+ if (parts.length > 1) {
101
+ out.yours.push({ text: `wiki needs pruning in ${parts.length} places: ${parts.map((x) => x.short).join(', ')}`, cmd: 'atris wiki consolidate' });
102
+ }
103
+ } catch {
104
+ // No wiki, nothing to prune.
105
+ }
106
+
107
+ // Only ask where tasks already live; asking would otherwise create task state.
108
+ const state = path.join(root, '.atris', 'state');
109
+ const hasTasks = ['tasks.projection.json', 'atris.db'].some((f) => fs.existsSync(path.join(state, f)));
110
+ const reviews = hasTasks ? runCliJson(['task', 'reviews', '--json'], root) : null;
111
+ const ready = reviews?.queue?.counts?.certified || 0;
112
+ if (ready) {
113
+ out.yours.push({
114
+ text: `${plural(ready, 'finished job is', 'finished jobs are')} waiting for your ok`,
115
+ cmd: 'atris task reviews',
116
+ });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ module.exports = { workspaceZen };