atris 3.30.12 → 3.32.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 +16 -3
- package/FOR_AGENTS.md +81 -0
- package/README.md +6 -4
- package/atris/CLAUDE.md +8 -0
- package/atris/atris.md +51 -19
- package/atris/skills/README.md +1 -0
- package/atris/skills/blocks/SKILL.md +134 -0
- package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
- package/atris/skills/youtube/SKILL.md +31 -11
- package/atris.md +15 -0
- package/ax +189 -7
- package/bin/atris.js +273 -225
- package/commands/autoland.js +379 -0
- package/commands/autopilot-front.js +273 -0
- package/commands/autopilot.js +94 -4
- package/commands/business.js +1 -1
- package/commands/clean.js +72 -9
- package/commands/codex-goal.js +72 -22
- package/commands/computer.js +48 -3
- package/commands/gm.js +1 -1
- package/commands/harvest.js +179 -0
- package/commands/init.js +22 -1
- package/commands/land.js +442 -0
- package/commands/loop-front.js +122 -4
- package/commands/member.js +551 -19
- package/commands/mission.js +3674 -278
- package/commands/play.js +1 -1
- package/commands/pulse.js +65 -7
- package/commands/run-front.js +144 -0
- package/commands/run.js +10 -7
- package/commands/sign.js +90 -0
- package/commands/strings.js +301 -0
- package/commands/task.js +782 -108
- package/commands/truth.js +171 -0
- package/commands/xp.js +32 -8
- package/commands/youtube.js +72 -5
- package/decks/README.md +6 -12
- package/lib/auto-accept-certified.js +10 -0
- package/lib/autoland.js +391 -0
- package/lib/context-gatherer.js +0 -8
- package/lib/mission-artifact.js +504 -0
- package/lib/mission-room.js +846 -0
- package/lib/next-moves.js +212 -6
- package/lib/operator-next.js +7 -0
- package/lib/pulse.js +78 -4
- package/lib/runner-command.js +51 -20
- package/lib/runs-prune.js +242 -0
- package/lib/task-db.js +19 -2
- package/lib/task-proof.js +1 -1
- package/package.json +4 -4
- package/decks/atris-seed-pitch-v3.json +0 -118
- package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
- package/decks/atris-seed-pitch-v5.json +0 -109
- package/decks/atris-seed-pitch-v6.json +0 -137
- package/decks/atris-seed-pitch-v7.json +0 -133
- package/decks/mark-pincus-narrative.json +0 -102
- package/decks/mark-pincus-sourcery.json +0 -94
- package/decks/yash-applied-compute-detailed.json +0 -150
- package/decks/yash-applied-compute-generalist.json +0 -82
- package/decks/yash-applied-compute-narrative.json +0 -54
- package/lib/ax-chat-input.js +0 -164
- package/lib/ax-goal.js +0 -307
- package/lib/ax-prefs.js +0 -63
- package/lib/ax-shimmer.js +0 -63
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const {
|
|
4
|
+
isGenericInboxPlaceholder,
|
|
5
|
+
seedInboxFromMove,
|
|
6
|
+
} = require('../lib/next-moves');
|
|
7
|
+
|
|
8
|
+
function hasFlag(args, name) {
|
|
9
|
+
return args.includes(name);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function safeRead(file) {
|
|
13
|
+
try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function walkFiles(dir, predicate, limit = 200) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const visit = (current) => {
|
|
19
|
+
if (out.length >= limit) return;
|
|
20
|
+
let entries = [];
|
|
21
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (out.length >= limit) break;
|
|
24
|
+
const full = path.join(current, entry.name);
|
|
25
|
+
if (entry.isDirectory()) visit(full);
|
|
26
|
+
else if (!predicate || predicate(full)) out.push(full);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
visit(dir);
|
|
30
|
+
return out.sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clip(value, max = 140) {
|
|
34
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
if (text.length <= max) return text;
|
|
36
|
+
return `${text.slice(0, max - 3).trim()}...`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function addAction(actions, title, source, reason, evidence = null) {
|
|
40
|
+
const clean = clip(title, 180);
|
|
41
|
+
if (!clean || isGenericInboxPlaceholder(clean)) return;
|
|
42
|
+
const key = clean.toLowerCase();
|
|
43
|
+
if (actions.some((action) => action.key === key)) return;
|
|
44
|
+
actions.push({
|
|
45
|
+
key,
|
|
46
|
+
title: clean,
|
|
47
|
+
source,
|
|
48
|
+
reason: clip(reason, 220),
|
|
49
|
+
evidence,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function receiptActions(root, actions) {
|
|
54
|
+
const runsDir = path.join(root, 'atris', 'runs');
|
|
55
|
+
for (const file of walkFiles(runsDir, (full) => /\.json$/i.test(full), 300).slice(-80)) {
|
|
56
|
+
let receipt = null;
|
|
57
|
+
try { receipt = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { receipt = null; }
|
|
58
|
+
if (!receipt || receipt.schema !== 'atris.mission_receipt.v1') continue;
|
|
59
|
+
const rel = path.relative(root, file);
|
|
60
|
+
const landing = receipt.result?.landing || {};
|
|
61
|
+
const text = [
|
|
62
|
+
receipt.objective,
|
|
63
|
+
landing.changed,
|
|
64
|
+
landing.reason,
|
|
65
|
+
landing.checked,
|
|
66
|
+
landing.tested,
|
|
67
|
+
landing.next,
|
|
68
|
+
receipt.result?.reason,
|
|
69
|
+
].filter(Boolean).join(' ');
|
|
70
|
+
if (/no concrete follow-up|stop|placeholder|zero value score|near[- ]?miss/i.test(text)) {
|
|
71
|
+
addAction(actions, 'Improve the next-mission chooser so it explains stopped work and better candidates', 'receipt', text, rel);
|
|
72
|
+
}
|
|
73
|
+
if (receipt.result?.verifier_result?.passed === false || /\b(verifier|test|check).{0,40}\b(fail|failed|error)\b/i.test(text)) {
|
|
74
|
+
addAction(actions, `Fix failing verifier for ${receipt.objective || 'latest mission'}`, 'receipt', text, rel);
|
|
75
|
+
}
|
|
76
|
+
if (landing.reason && landing.proof && /receipt saved/i.test(landing.proof)) {
|
|
77
|
+
addAction(actions, 'Surface buried result landing reasoning in the normal mission view', 'receipt', landing.reason, rel);
|
|
78
|
+
}
|
|
79
|
+
if (landing.proof == null || /proof:\s*null/i.test(text)) {
|
|
80
|
+
addAction(actions, 'Repair proof mismatch where proof exists in metadata but renders as null', 'receipt', text, rel);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function runLogActions(root, actions) {
|
|
86
|
+
const logsDir = path.join(root, 'atris', 'logs', 'runs');
|
|
87
|
+
for (const file of walkFiles(logsDir, (full) => /\.(log|txt|md|json)$/i.test(full), 200).slice(-60)) {
|
|
88
|
+
const rel = path.relative(root, file);
|
|
89
|
+
const text = safeRead(file);
|
|
90
|
+
if (!text) continue;
|
|
91
|
+
if (/no concrete follow-up|placeholder|test idea|zero value score/i.test(text)) {
|
|
92
|
+
addAction(actions, 'Harvest weak chooser traces into a concrete next-mission fix', 'runlog', text, rel);
|
|
93
|
+
}
|
|
94
|
+
if (/\b(error|failed|exception|traceback|unhandled)\b/i.test(text)) {
|
|
95
|
+
addAction(actions, 'Turn recent run-log failures into a bounded fix task with proof', 'runlog', text, rel);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function thinkingActions(root, actions) {
|
|
101
|
+
const rel = 'atris/thinking.md';
|
|
102
|
+
const text = safeRead(path.join(root, rel));
|
|
103
|
+
if (!text) return;
|
|
104
|
+
if (/plain English|jargon|feynman|understand/i.test(text)) {
|
|
105
|
+
addAction(actions, 'Make proof and task previews plain enough to understand without opening logs', 'thinking', text, rel);
|
|
106
|
+
}
|
|
107
|
+
if (/proof over motion|receipt|verified|checked/i.test(text)) {
|
|
108
|
+
addAction(actions, 'Audit proof surfaces for null proof, hidden receipts, and vague verifier claims', 'thinking', text, rel);
|
|
109
|
+
}
|
|
110
|
+
if (/stop.*no concrete|no concrete.*stop|fake|busywork/i.test(text)) {
|
|
111
|
+
addAction(actions, 'Make autonomous loops stop or ask instead of inventing fake work', 'thinking', text, rel);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function harvestActions(root = process.cwd()) {
|
|
116
|
+
const actions = [];
|
|
117
|
+
receiptActions(root, actions);
|
|
118
|
+
runLogActions(root, actions);
|
|
119
|
+
thinkingActions(root, actions);
|
|
120
|
+
return actions.slice(0, 12).map(({ key, ...action }, index) => ({
|
|
121
|
+
id: `I${index + 1}`,
|
|
122
|
+
...action,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function harvestCommand(args = []) {
|
|
127
|
+
const root = process.cwd();
|
|
128
|
+
if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
|
|
129
|
+
console.log('Usage: atris harvest [--write] [--json]');
|
|
130
|
+
console.log('');
|
|
131
|
+
console.log('Find plain next actions from mission receipts, run logs, and thinking.md.');
|
|
132
|
+
console.log('Read-only by default. Use --write to append actions to today\'s Inbox.');
|
|
133
|
+
return { ok: true, action: 'help' };
|
|
134
|
+
}
|
|
135
|
+
const asJson = hasFlag(args, '--json');
|
|
136
|
+
const write = hasFlag(args, '--write');
|
|
137
|
+
const actions = harvestActions(root);
|
|
138
|
+
const writes = [];
|
|
139
|
+
if (write) {
|
|
140
|
+
for (const action of actions) {
|
|
141
|
+
const written = seedInboxFromMove(root, { title: action.title, source: action.source });
|
|
142
|
+
writes.push({
|
|
143
|
+
title: action.title,
|
|
144
|
+
file: written.file ? path.relative(root, written.file) : null,
|
|
145
|
+
line: written.line,
|
|
146
|
+
already_present: Boolean(written.alreadyPresent),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const payload = {
|
|
151
|
+
ok: true,
|
|
152
|
+
action: 'harvest',
|
|
153
|
+
write,
|
|
154
|
+
count: actions.length,
|
|
155
|
+
actions,
|
|
156
|
+
writes,
|
|
157
|
+
};
|
|
158
|
+
if (asJson) {
|
|
159
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
160
|
+
return payload;
|
|
161
|
+
}
|
|
162
|
+
if (!actions.length) {
|
|
163
|
+
console.log('No harvest actions found.');
|
|
164
|
+
return payload;
|
|
165
|
+
}
|
|
166
|
+
for (const action of actions) {
|
|
167
|
+
console.log(`- **${action.id}:** ${action.title}`);
|
|
168
|
+
console.log(` reason: ${action.reason}`);
|
|
169
|
+
if (action.evidence) console.log(` evidence: ${action.evidence}`);
|
|
170
|
+
}
|
|
171
|
+
if (write) console.log(`Wrote ${writes.filter((item) => item.line).length} inbox action(s).`);
|
|
172
|
+
else console.log('Read-only. Add --write to append these to today\'s Inbox.');
|
|
173
|
+
return payload;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
harvestActions,
|
|
178
|
+
harvestCommand,
|
|
179
|
+
};
|
package/commands/init.js
CHANGED
|
@@ -512,7 +512,7 @@ function initAtris() {
|
|
|
512
512
|
if (!fs.existsSync(featuresDir)) {
|
|
513
513
|
fs.mkdirSync(featuresDir, { recursive: true });
|
|
514
514
|
const featuresReadme = path.join(featuresDir, 'README.md');
|
|
515
|
-
fs.writeFileSync(featuresReadme, '# Features\n\nThis directory tracks all features built using the
|
|
515
|
+
fs.writeFileSync(featuresReadme, '# Features\n\nThis directory tracks all features built using the Atris protocol.\n\nEach feature has:\n- `[feature-name]/idea.md` - Problem, solution, diagrams, success criteria\n- `[feature-name]/build.md` - Implementation plan, files changed, testing\n- `[feature-name]/validate.md` - End-to-end simulation script\n\n---\n\n## Features Built\n\n*Features will appear here as you build them.*\n');
|
|
516
516
|
console.log('✓ Created features/ directory with README');
|
|
517
517
|
}
|
|
518
518
|
|
|
@@ -660,6 +660,20 @@ Do not rely on chat context. Put the task, file pointers, and proof on disk.
|
|
|
660
660
|
Do not write new operating doctrine here first; add it to Atris policy, skills,
|
|
661
661
|
wiki, or \`atris/atris.md\`, then regenerate this adapter if needed.
|
|
662
662
|
|
|
663
|
+
## How to Report
|
|
664
|
+
|
|
665
|
+
The human approves work by reading, so how you report IS the product. Rules:
|
|
666
|
+
|
|
667
|
+
- **Results are capabilities.** State what someone can do now that they
|
|
668
|
+
couldn't before, then what it means for them or the business. Tests are one
|
|
669
|
+
word ("verified"); the meaning is the sentence. Shape: "We did X, so you can
|
|
670
|
+
now Y." Detail stays under the hood; the reader asks if they want more.
|
|
671
|
+
- **Three results, air between them, rest on ask.** A report fits one screen
|
|
672
|
+
with no scrolling. Reading it is one glance.
|
|
673
|
+
- **Stake first, then the move.** "Agents burn tokens hand-rolling parsers:
|
|
674
|
+
add one shared view." Plain words; flags, ids, and identifiers belong in the
|
|
675
|
+
body, never the headline.
|
|
676
|
+
|
|
663
677
|
Native goals and task approval are separate gates:
|
|
664
678
|
|
|
665
679
|
\`\`\`text
|
|
@@ -980,6 +994,13 @@ Read atris/MAP.md. Begin iteration 1.`;
|
|
|
980
994
|
console.log('✓ Created .claude/settings.json (auto-loads Atris on startup)');
|
|
981
995
|
}
|
|
982
996
|
|
|
997
|
+
// Co-author trailer: commits in this workspace credit Atris, same as Claude/Cursor do
|
|
998
|
+
try {
|
|
999
|
+
const { installSignHook } = require('./sign');
|
|
1000
|
+
const { already } = installSignHook();
|
|
1001
|
+
if (!already) console.log('✓ Installed co-author hook (commits credit Atris — remove with `atris sign off`)');
|
|
1002
|
+
} catch {} // not a git repo — skip quietly
|
|
1003
|
+
|
|
983
1004
|
// Update root CLAUDE.md with Atris block (prepend with markers)
|
|
984
1005
|
const rootClaudeMd = path.join(process.cwd(), 'CLAUDE.md');
|
|
985
1006
|
const atrisBlock = `<!-- ATRIS:START - Auto-generated, do not edit -->
|
package/commands/land.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
const { listWorktrees, statusCounts } = require('./worktree');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TTL_DAYS = 7;
|
|
8
|
+
const PROTECTED_BRANCHES = new Set(['main', 'master']);
|
|
9
|
+
|
|
10
|
+
function runGit(args, { cwd = process.cwd(), check = true } = {}) {
|
|
11
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
12
|
+
if (check && result.status !== 0) {
|
|
13
|
+
throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function repoRoot(cwd = process.cwd()) {
|
|
19
|
+
const result = runGit(['rev-parse', '--show-toplevel'], { cwd, check: false });
|
|
20
|
+
if (result.status !== 0) return '';
|
|
21
|
+
return result.stdout.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function refExists(root, ref) {
|
|
25
|
+
return runGit(['rev-parse', '--verify', '--quiet', ref], { cwd: root, check: false }).status === 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function baseBranch(root, override = '') {
|
|
29
|
+
if (override && refExists(root, override)) return override;
|
|
30
|
+
if (refExists(root, 'master')) return 'master';
|
|
31
|
+
if (refExists(root, 'main')) return 'main';
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ageDays(unixSeconds, now = Date.now()) {
|
|
36
|
+
return Math.floor((now / 1000 - Number(unixSeconds)) / 86400);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function listBranches(root) {
|
|
40
|
+
const result = runGit(
|
|
41
|
+
['for-each-ref', 'refs/heads', '--format=%(refname:short)%09%(committerdate:unix)'],
|
|
42
|
+
{ cwd: root, check: false }
|
|
43
|
+
);
|
|
44
|
+
if (result.status !== 0) return [];
|
|
45
|
+
return result.stdout
|
|
46
|
+
.split(/\r?\n/)
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((line) => {
|
|
49
|
+
const [name, date] = line.split('\t');
|
|
50
|
+
return { name, lastCommitUnix: Number(date) };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function aheadCount(root, base, ref) {
|
|
55
|
+
const result = runGit(['rev-list', '--count', `${base}..${ref}`], { cwd: root, check: false });
|
|
56
|
+
if (result.status !== 0) return 0;
|
|
57
|
+
return Number(result.stdout.trim()) || 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Board: every branch and worktree with unlanded state, classified.
|
|
61
|
+
// landed — no commits ahead of base; the branch pointer is residue
|
|
62
|
+
// active — has unlanded commits, younger than TTL
|
|
63
|
+
// due — has unlanded commits, older than TTL; --reap collects it
|
|
64
|
+
function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '' } = {}) {
|
|
65
|
+
const base = baseBranch(root, baseOverride);
|
|
66
|
+
if (!base) throw new Error('no master/main branch found');
|
|
67
|
+
|
|
68
|
+
const branches = [];
|
|
69
|
+
for (const branch of listBranches(root)) {
|
|
70
|
+
if (PROTECTED_BRANCHES.has(branch.name)) continue;
|
|
71
|
+
const ahead = aheadCount(root, base, branch.name);
|
|
72
|
+
const age = ageDays(branch.lastCommitUnix);
|
|
73
|
+
let state = 'active';
|
|
74
|
+
if (ahead === 0) state = 'landed';
|
|
75
|
+
else if (age > ttlDays) state = 'due';
|
|
76
|
+
branches.push({ name: branch.name, ahead, ageDays: age, state });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const worktrees = [];
|
|
80
|
+
const all = listWorktrees(root);
|
|
81
|
+
for (const wt of all.slice(1)) {
|
|
82
|
+
const branch = (wt.branch || '').replace(/^refs\/heads\//, '');
|
|
83
|
+
const counts = statusCounts(wt.path) || { staged: 0, unstaged: 0, untracked: 0 };
|
|
84
|
+
const dirty = counts.staged + counts.unstaged + counts.untracked;
|
|
85
|
+
const entry = branches.find((b) => b.name === branch) || null;
|
|
86
|
+
worktrees.push({
|
|
87
|
+
path: wt.path,
|
|
88
|
+
branch,
|
|
89
|
+
dirty,
|
|
90
|
+
ageDays: entry ? entry.ageDays : null,
|
|
91
|
+
state: entry ? entry.state : 'detached',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const due = branches.filter((b) => b.state === 'due');
|
|
96
|
+
const landed = branches.filter((b) => b.state === 'landed');
|
|
97
|
+
const active = branches.filter((b) => b.state === 'active');
|
|
98
|
+
return {
|
|
99
|
+
base,
|
|
100
|
+
ttlDays,
|
|
101
|
+
branches,
|
|
102
|
+
worktrees,
|
|
103
|
+
summary: {
|
|
104
|
+
unlanded: branches.length,
|
|
105
|
+
active: active.length,
|
|
106
|
+
due: due.length,
|
|
107
|
+
landed: landed.length,
|
|
108
|
+
worktrees: worktrees.length,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fast counts for the boot banner: no per-branch ahead checks.
|
|
114
|
+
function landSummary(cwd = process.cwd(), ttlDays = DEFAULT_TTL_DAYS) {
|
|
115
|
+
const root = repoRoot(cwd);
|
|
116
|
+
if (!root) return null;
|
|
117
|
+
let branches = 0;
|
|
118
|
+
let due = 0;
|
|
119
|
+
for (const branch of listBranches(root)) {
|
|
120
|
+
if (PROTECTED_BRANCHES.has(branch.name)) continue;
|
|
121
|
+
branches += 1;
|
|
122
|
+
if (ageDays(branch.lastCommitUnix) > ttlDays) due += 1;
|
|
123
|
+
}
|
|
124
|
+
return { branches, due, ttlDays };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function salvageDir(root) {
|
|
128
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
129
|
+
const dir = path.join(root, '.atris', 'salvage', stamp);
|
|
130
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
131
|
+
return dir;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function timeStamp() {
|
|
135
|
+
return new Date().toISOString().slice(11, 19).replace(/:/g, '');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function remoteHeads(root) {
|
|
139
|
+
const result = runGit(['ls-remote', '--heads', 'origin'], { cwd: root, check: false });
|
|
140
|
+
if (result.status !== 0) return null;
|
|
141
|
+
const heads = new Set();
|
|
142
|
+
for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
143
|
+
const ref = line.split(/\s+/)[1] || '';
|
|
144
|
+
if (ref.startsWith('refs/heads/')) heads.add(ref.slice('refs/heads/'.length));
|
|
145
|
+
}
|
|
146
|
+
return heads;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Reap: salvage then delete everything landed (residue) or past TTL.
|
|
150
|
+
// Salvage first, always: unlanded commits go into a git bundle, dirty
|
|
151
|
+
// worktrees into patch files, so a reap is never a loss of work.
|
|
152
|
+
function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRun = false, remote = true } = {}) {
|
|
153
|
+
const board = collectBoard(root, { ttlDays, base: baseOverride });
|
|
154
|
+
const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
|
|
155
|
+
const targetNames = new Set(targets.map((b) => b.name));
|
|
156
|
+
const worktreeTargets = board.worktrees.filter(
|
|
157
|
+
(w) => targetNames.has(w.branch) || w.state === 'detached' || (typeof w.ageDays === 'number' && w.ageDays > ttlDays)
|
|
158
|
+
);
|
|
159
|
+
for (const w of worktreeTargets) {
|
|
160
|
+
if (w.branch && !targetNames.has(w.branch)) targetNames.add(w.branch);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const receipt = {
|
|
164
|
+
base: board.base,
|
|
165
|
+
ttlDays,
|
|
166
|
+
dryRun,
|
|
167
|
+
bundle: null,
|
|
168
|
+
patches: [],
|
|
169
|
+
removedWorktrees: [],
|
|
170
|
+
deletedBranches: [],
|
|
171
|
+
deletedRemote: [],
|
|
172
|
+
kept: board.branches.filter((b) => !targetNames.has(b.name)).map((b) => b.name),
|
|
173
|
+
};
|
|
174
|
+
if (targetNames.size === 0 && worktreeTargets.length === 0) return receipt;
|
|
175
|
+
if (dryRun) {
|
|
176
|
+
receipt.removedWorktrees = worktreeTargets.map((w) => w.path);
|
|
177
|
+
receipt.deletedBranches = [...targetNames];
|
|
178
|
+
return receipt;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const dir = salvageDir(root);
|
|
182
|
+
const withCommits = [...targetNames].filter((name) => {
|
|
183
|
+
const entry = board.branches.find((b) => b.name === name);
|
|
184
|
+
return entry && entry.ahead > 0;
|
|
185
|
+
});
|
|
186
|
+
if (withCommits.length > 0) {
|
|
187
|
+
const bundlePath = path.join(dir, `reap-${timeStamp()}.bundle`);
|
|
188
|
+
const result = runGit(['bundle', 'create', bundlePath, ...withCommits, '--not', board.base], {
|
|
189
|
+
cwd: root,
|
|
190
|
+
check: false,
|
|
191
|
+
});
|
|
192
|
+
if (result.status === 0) receipt.bundle = bundlePath;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const w of worktreeTargets) {
|
|
196
|
+
if (w.dirty > 0) {
|
|
197
|
+
const diff = runGit(['diff'], { cwd: w.path, check: false });
|
|
198
|
+
if (diff.status === 0 && diff.stdout.trim()) {
|
|
199
|
+
const patchPath = path.join(dir, `${path.basename(w.path)}.dirty.patch`);
|
|
200
|
+
fs.writeFileSync(patchPath, diff.stdout);
|
|
201
|
+
receipt.patches.push(patchPath);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const removed = runGit(['worktree', 'remove', '--force', w.path], { cwd: root, check: false });
|
|
205
|
+
if (removed.status === 0) receipt.removedWorktrees.push(w.path);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const name of targetNames) {
|
|
209
|
+
const deleted = runGit(['branch', '-D', name], { cwd: root, check: false });
|
|
210
|
+
if (deleted.status === 0) receipt.deletedBranches.push(name);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (remote && receipt.deletedBranches.length > 0) {
|
|
214
|
+
const heads = remoteHeads(root);
|
|
215
|
+
if (heads) {
|
|
216
|
+
const remoteTargets = receipt.deletedBranches.filter((name) => heads.has(name));
|
|
217
|
+
for (let i = 0; i < remoteTargets.length; i += 25) {
|
|
218
|
+
const batch = remoteTargets.slice(i, i + 25);
|
|
219
|
+
const pushed = runGit(['push', 'origin', '--delete', ...batch], { cwd: root, check: false });
|
|
220
|
+
if (pushed.status === 0) receipt.deletedRemote.push(...batch);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return receipt;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The story of one piece of work: what it tried to do, whether it made it
|
|
229
|
+
// into master some other way, and what would be lost if it were cleared.
|
|
230
|
+
function collectStory(root, name, { base: baseOverride = '' } = {}) {
|
|
231
|
+
const base = baseBranch(root, baseOverride);
|
|
232
|
+
if (!base) throw new Error('no master/main branch found');
|
|
233
|
+
if (!refExists(root, name)) return null;
|
|
234
|
+
|
|
235
|
+
const logResult = runGit(
|
|
236
|
+
['log', '--format=%h%x09%cs%x09%an%x09%s', `${base}..${name}`],
|
|
237
|
+
{ cwd: root, check: false }
|
|
238
|
+
);
|
|
239
|
+
const changes = logResult.stdout
|
|
240
|
+
.split(/\r?\n/)
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
.map((line) => {
|
|
243
|
+
const [hash, date, author, subject] = line.split('\t');
|
|
244
|
+
return { hash, date, author, subject };
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// git cherry marks a change '-' when an equivalent patch already exists
|
|
248
|
+
// in base — the honest "it landed some other way" signal.
|
|
249
|
+
const cherry = runGit(['cherry', base, name], { cwd: root, check: false });
|
|
250
|
+
const landedHashes = new Set();
|
|
251
|
+
for (const line of cherry.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
252
|
+
if (line.startsWith('- ')) landedHashes.add(line.slice(2).trim());
|
|
253
|
+
}
|
|
254
|
+
const fullHashes = runGit(['log', '--format=%H %h', `${base}..${name}`], { cwd: root, check: false });
|
|
255
|
+
const shortToLanded = new Set();
|
|
256
|
+
for (const line of fullHashes.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
257
|
+
const [full, short] = line.split(' ');
|
|
258
|
+
if (landedHashes.has(full)) shortToLanded.add(short);
|
|
259
|
+
}
|
|
260
|
+
for (const c of changes) c.landedElsewhere = shortToLanded.has(c.hash);
|
|
261
|
+
|
|
262
|
+
const stat = runGit(['diff', '--stat', `${base}...${name}`], { cwd: root, check: false });
|
|
263
|
+
const statLines = stat.stdout.split(/\r?\n/).filter(Boolean);
|
|
264
|
+
const files = statLines.slice(0, -1).map((l) => l.split('|')[0].trim());
|
|
265
|
+
|
|
266
|
+
const info = listBranches(root).find((b) => b.name === name);
|
|
267
|
+
return {
|
|
268
|
+
name,
|
|
269
|
+
base,
|
|
270
|
+
ageDays: info ? ageDays(info.lastCommitUnix) : null,
|
|
271
|
+
changes,
|
|
272
|
+
landedElsewhere: changes.filter((c) => c.landedElsewhere).length,
|
|
273
|
+
uniqueChanges: changes.filter((c) => !c.landedElsewhere).length,
|
|
274
|
+
files,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function printStory(story) {
|
|
279
|
+
console.log('');
|
|
280
|
+
console.log(`what happened in ${story.name}`);
|
|
281
|
+
console.log('');
|
|
282
|
+
if (story.changes.length === 0) {
|
|
283
|
+
console.log(` everything here is already in ${story.base}. this is an empty leftover,`);
|
|
284
|
+
console.log(" safe to clear. nothing would be lost.");
|
|
285
|
+
console.log('');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const who = [...new Set(story.changes.map((c) => c.author))].join(', ');
|
|
289
|
+
const when = story.changes[story.changes.length - 1].date;
|
|
290
|
+
const last = story.changes[0].date;
|
|
291
|
+
const age = story.ageDays === null ? '' : ` (${story.ageDays} days ago)`;
|
|
292
|
+
console.log(` started ${when}, last touched ${last}${age}, by ${who}.`);
|
|
293
|
+
console.log('');
|
|
294
|
+
console.log(' what it tried to do:');
|
|
295
|
+
for (const c of [...story.changes].reverse()) {
|
|
296
|
+
const mark = c.landedElsewhere ? 'made it in ' : 'never made it';
|
|
297
|
+
console.log(` ${mark} ${c.subject}`);
|
|
298
|
+
}
|
|
299
|
+
console.log('');
|
|
300
|
+
if (story.files.length > 0) {
|
|
301
|
+
const shown = story.files.slice(0, 6).join(', ');
|
|
302
|
+
const more = story.files.length > 6 ? ` and ${story.files.length - 6} more` : '';
|
|
303
|
+
console.log(` it touched: ${shown}${more}`);
|
|
304
|
+
console.log('');
|
|
305
|
+
}
|
|
306
|
+
if (story.uniqueChanges === 0) {
|
|
307
|
+
console.log(` bottom line: all ${story.changes.length} changes made it into ${story.base}`);
|
|
308
|
+
console.log(' some other way. nothing here would be lost by clearing it.');
|
|
309
|
+
} else if (story.landedElsewhere > 0) {
|
|
310
|
+
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") — they may have`);
|
|
312
|
+
console.log(' been redone differently, or they may be real lost work. clearing backs');
|
|
313
|
+
console.log(' them up first either way.');
|
|
314
|
+
} else {
|
|
315
|
+
console.log(` bottom line: none of this is in ${story.base}. this is real work that only`);
|
|
316
|
+
console.log(' exists here. land it or clear it knowing the backup keeps a copy.');
|
|
317
|
+
}
|
|
318
|
+
console.log('');
|
|
319
|
+
console.log(` see the actual changes: git diff ${story.base}...${story.name}`);
|
|
320
|
+
console.log('');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function printBoard(board) {
|
|
324
|
+
console.log('');
|
|
325
|
+
console.log('the landing — what is actually done vs still in the air');
|
|
326
|
+
console.log('');
|
|
327
|
+
if (board.branches.length === 0 && board.worktrees.length === 0) {
|
|
328
|
+
console.log(' everything has landed. nothing in the air, nothing stuck.');
|
|
329
|
+
console.log('');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const order = { due: 0, detached: 0, landed: 1, active: 2 };
|
|
333
|
+
const rows = [...board.branches].sort((a, b) => (order[a.state] ?? 3) - (order[b.state] ?? 3) || b.ageDays - a.ageDays);
|
|
334
|
+
const labels = { due: 'overdue', landed: 'landed', active: 'in the air' };
|
|
335
|
+
for (const b of rows) {
|
|
336
|
+
const changes = b.ahead === 1 ? '1 change ' : `${b.ahead} changes`;
|
|
337
|
+
console.log(` ${(labels[b.state] || b.state).padEnd(11)} ${String(b.ageDays).padStart(3)}d ${changes} ${b.name}`);
|
|
338
|
+
}
|
|
339
|
+
for (const w of board.worktrees) {
|
|
340
|
+
const edits = w.dirty === 1 ? '1 unsaved edit' : `${w.dirty} unsaved edits`;
|
|
341
|
+
console.log(` side copy ${w.path} (${edits})`);
|
|
342
|
+
}
|
|
343
|
+
console.log('');
|
|
344
|
+
const s = board.summary;
|
|
345
|
+
console.log(` ${s.unlanded} pieces of work in the air, ${s.due} overdue, ${s.landed} landed and safe to clear.`);
|
|
346
|
+
console.log(' work counts as done only when it lands in master.');
|
|
347
|
+
if (s.due + s.landed > 0) {
|
|
348
|
+
console.log(` back up + clear the ${s.due + s.landed} overdue/landed: atris land --reap`);
|
|
349
|
+
}
|
|
350
|
+
console.log('');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function printReceipt(receipt) {
|
|
354
|
+
console.log('');
|
|
355
|
+
if (receipt.dryRun) {
|
|
356
|
+
console.log(`landing cleanup preview — would clear ${receipt.deletedBranches.length} pieces of work, ${receipt.removedWorktrees.length} side copies:`);
|
|
357
|
+
for (const b of receipt.deletedBranches) console.log(` would clear ${b}`);
|
|
358
|
+
for (const w of receipt.removedWorktrees) console.log(` would remove ${w}`);
|
|
359
|
+
} else {
|
|
360
|
+
console.log(`landing cleanup done — ${receipt.deletedBranches.length} pieces cleared, ${receipt.removedWorktrees.length} side copies removed`);
|
|
361
|
+
if (receipt.bundle) console.log(` backed up first, nothing lost: ${receipt.bundle}`);
|
|
362
|
+
for (const p of receipt.patches) console.log(` unsaved edits saved: ${p}`);
|
|
363
|
+
if (receipt.deletedRemote.length > 0) console.log(` also cleared on github: ${receipt.deletedRemote.length}`);
|
|
364
|
+
}
|
|
365
|
+
console.log(` still flying (recent work, left alone): ${receipt.kept.length}`);
|
|
366
|
+
console.log('');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function readFlag(args, name, fallback = '') {
|
|
370
|
+
const idx = args.indexOf(name);
|
|
371
|
+
if (idx === -1) return fallback;
|
|
372
|
+
return args[idx + 1] || fallback;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function showHelp() {
|
|
376
|
+
console.log('');
|
|
377
|
+
console.log('atris land — the landing: what is actually done vs still in the air');
|
|
378
|
+
console.log('');
|
|
379
|
+
console.log('work counts as done only when it lands in master. everything else is');
|
|
380
|
+
console.log('in the air, and this shows it so nothing quietly dies.');
|
|
381
|
+
console.log('');
|
|
382
|
+
console.log(' atris land show everything still in the air');
|
|
383
|
+
console.log(' atris land <name> the story of one piece: what it tried,');
|
|
384
|
+
console.log(' whether it landed some other way, what');
|
|
385
|
+
console.log(' would be lost by clearing it');
|
|
386
|
+
console.log(' atris land --reap back up, then clear anything overdue');
|
|
387
|
+
console.log(' or already landed (here and on github)');
|
|
388
|
+
console.log(' atris land --reap --dry-run preview what would be cleared');
|
|
389
|
+
console.log(' atris land --ttl <days> change the 7-day overdue line');
|
|
390
|
+
console.log(' atris land --json machine-readable output');
|
|
391
|
+
console.log('');
|
|
392
|
+
console.log('backups go to .atris/salvage/<date>/ — clearing never loses work.');
|
|
393
|
+
console.log('');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function landCommand(args = []) {
|
|
397
|
+
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
398
|
+
showHelp();
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
const root = repoRoot();
|
|
402
|
+
if (!root) {
|
|
403
|
+
console.error('not a git repository');
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
406
|
+
const ttlRaw = readFlag(args, '--ttl', '');
|
|
407
|
+
const ttlParsed = Number(ttlRaw);
|
|
408
|
+
const ttlDays = ttlRaw !== '' && Number.isFinite(ttlParsed) && ttlParsed >= 0 ? ttlParsed : DEFAULT_TTL_DAYS;
|
|
409
|
+
const base = readFlag(args, '--base', '');
|
|
410
|
+
const json = args.includes('--json');
|
|
411
|
+
|
|
412
|
+
if (args.includes('--reap')) {
|
|
413
|
+
const receipt = reap(root, { ttlDays, base, dryRun: args.includes('--dry-run'), remote: !args.includes('--no-remote') });
|
|
414
|
+
if (json) console.log(JSON.stringify(receipt, null, 2));
|
|
415
|
+
else printReceipt(receipt);
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const name = args.find((a) => !a.startsWith('-') && a !== readFlag(args, '--ttl') && a !== readFlag(args, '--base'));
|
|
420
|
+
if (name) {
|
|
421
|
+
const story = collectStory(root, name, { base });
|
|
422
|
+
if (!story) {
|
|
423
|
+
console.error(`nothing called '${name}' is in the air right now`);
|
|
424
|
+
return 1;
|
|
425
|
+
}
|
|
426
|
+
if (json) console.log(JSON.stringify(story, null, 2));
|
|
427
|
+
else printStory(story);
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const board = collectBoard(root, { ttlDays, base });
|
|
432
|
+
if (json) console.log(JSON.stringify(board, null, 2));
|
|
433
|
+
else printBoard(board);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
module.exports = {
|
|
438
|
+
collectBoard,
|
|
439
|
+
landCommand,
|
|
440
|
+
landSummary,
|
|
441
|
+
reap,
|
|
442
|
+
};
|