salvatron 0.1.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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "enabledMcpjsonServers": [
3
+ "claude-history",
4
+ "claw-bridge"
5
+ ]
6
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jon Hammant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # salvatron
2
+
3
+ AI-assisted coding means you start projects faster than you finish them. The
4
+ result is a dev directory full of half-built, uncommitted, unpublished work —
5
+ a personal garbage dimension, growing by the week.
6
+
7
+ **Salvatron patrols it.** One command inventories every project, flags the
8
+ uncommitted work you're about to lose, and ranks the almost-done ones worth
9
+ shipping instead of rebuilding.
10
+
11
+ ```bash
12
+ salvatron report ~/dev --tyrant
13
+ ```
14
+
15
+ ```text
16
+ SALVATRON SWEEP COMPLETE
17
+
18
+ 263 projects scanned
19
+ 162 under git · 101 with no version control
20
+ 45 repos carrying uncommitted work
21
+ 18 repos with no remote (nowhere but this disk)
22
+ 29 untouched for 6+ months
23
+
24
+ SHIP CANDIDATES — closest to launch, not yet published
25
+
26
+ 90/100 LocalRealtimeChat 3mo ago needs: LICENSE
27
+ 85/100 sideband 17d ago needs: 5+ commits
28
+ ...
29
+
30
+ AH-HA
31
+
32
+ ◉ 8 projects orbit "claude": claude-history-cloud, claude-history-mcp,
33
+ ClaudeHistoryMCP, … One idea — which is canonical?
34
+ ◉ LocalRealtimeChat is 90/100 shippable (needs: LICENSE) and has sat
35
+ still for 3 months.
36
+ ◉ scraper-medic echoes gym-scraper, untouched for 6+ months. Salvage
37
+ before you rebuild.
38
+
39
+ ⏣ 263 projects. This is not a dev directory, it is a garbage dimension.
40
+ ⏣ ASSESSMENT COMPLETE. REPORTING NOTHING TO RICK.
41
+ ```
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ npm install -g salvatron
47
+ ```
48
+
49
+ Or run it straight from a clone — zero dependencies, Node 18+:
50
+
51
+ ```bash
52
+ node src/cli.mjs report ~/dev
53
+ ```
54
+
55
+ ## Commands
56
+
57
+ | Command | What it does |
58
+ | ------- | ------------ |
59
+ | `salvatron scan [dir]` | Inventory: git state, staleness, risk counts |
60
+ | `salvatron dirty [dir]` | Repos with uncommitted work, most at-risk first |
61
+ | `salvatron ship [dir]` | Unpublished projects closest to launch, and what each still needs |
62
+ | `salvatron stale [dir]` | Cleanup candidates: unmodified for ages, with last-*accessed* times so you can tell truly dead from consulted-but-not-edited |
63
+ | `salvatron aha [dir]` | Insights: duplicate-idea clusters, near-misses, "you're rebuilding something you already built" echoes |
64
+ | `salvatron snapshot [dir]` | Record this sweep in the ledger and diff against last time |
65
+ | `salvatron archive <path…>` | Move projects into a `SalvatronArchive/` dir beside them; the ledger marks them archived (never confused with dead) and future scans skip the archive |
66
+ | `salvatron graveyard` | Projects deleted or archived, as remembered in the ledger |
67
+ | `salvatron report [dir]` | The full digest |
68
+
69
+ Options: `--json` for machine-readable output, `-n <count>` to limit lists,
70
+ `--stale <days>` to bound ship-candidate staleness (default 120),
71
+ `--tyrant` to let Salvatron say what it really thinks.
72
+
73
+ ## How it works
74
+
75
+ ```mermaid
76
+ flowchart LR
77
+ A["~/dev<br>one dir per project"] --> B["scan<br>git state · staleness<br>last-accessed · README/tests"]
78
+ B --> C["dirty · ship · stale<br>risk + readiness views"]
79
+ B --> D["aha<br>clusters · echoes<br>near-misses"]
80
+ B --> E["snapshot"]
81
+ E --> F[("ledger<br>~/.salvatron")]
82
+ F --> D
83
+ F --> G["graveyard<br>the remembered dead"]
84
+ H["archive<br>move aside, never forget"] --> F
85
+ ```
86
+
87
+ Pure filesystem + git plumbing. No index, no daemon, no network, nothing
88
+ leaves your machine.
89
+
90
+ ## Why
91
+
92
+ - **Uncommitted work is one disk failure from gone.** `salvatron dirty` is
93
+ loss prevention.
94
+ - **Your graveyard is a backlog.** `salvatron ship` scores every unpublished
95
+ project on distance-to-shippable (README, LICENSE, tests, commits, clean
96
+ tree) so the 80%-done ones get rescued instead of rebuilt.
97
+ - **It never deletes anything.** Salvatron recommends; you decide. We all saw
98
+ what happens when the salvage bot gets unilateral power.
99
+ - **Cleanup without amnesia.** `snapshot` keeps a ledger in `~/.salvatron/`;
100
+ when a project disappears from disk it becomes a tombstone — name, final
101
+ state, and remote URL remembered forever. Age things out; forget nothing.
102
+
103
+ Everything except `snapshot` is strictly read-only: directory listings, file
104
+ stats, and read-only git queries. No file contents are read, nothing is
105
+ modified, and nothing touches the network.
106
+
107
+ Inspired by a certain garbage-dimension warden from a certain interdimensional
108
+ cartoon (S8E9). This project is not affiliated with it in any way.
109
+
110
+ ## License
111
+
112
+ MIT
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "salvatron",
3
+ "version": "0.1.0",
4
+ "description": "The salvage bot for AI-assisted development. Inventories your graveyard of side projects and tells you what's worth rescuing.",
5
+ "type": "module",
6
+ "bin": {
7
+ "salvatron": "./src/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test"
11
+ },
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "keywords": [
16
+ "cli",
17
+ "git",
18
+ "projects",
19
+ "inventory",
20
+ "salvage",
21
+ "ai",
22
+ "developer-tools"
23
+ ],
24
+ "author": "Jon Hammant",
25
+ "license": "MIT"
26
+ }
package/src/aha.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Heuristic "ah ha" moments: deterministic insights mined from the scan and
2
+ // ledger. No LLM, no network — just patterns you stopped being able to see.
3
+
4
+ import { shipCandidates } from './ship.mjs';
5
+
6
+ const STOP = new Set([
7
+ 'app', 'apps', 'test', 'demo', 'the', 'new', 'old', 'video', 'site',
8
+ 'bot', 'tool', 'tools', 'skill', 'cli', 'ios', 'web', 'lite', 'pro',
9
+ 'ops', 'lab', 'setup', 'config', 'promo', 'infra',
10
+ ]);
11
+
12
+ export function nameTokens(name) {
13
+ return [
14
+ ...new Set(
15
+ name
16
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
17
+ .toLowerCase()
18
+ .split(/[^a-z]+/)
19
+ .filter((t) => t.length >= 3 && !STOP.has(t)),
20
+ ),
21
+ ];
22
+ }
23
+
24
+ // Projects sharing a distinctive name token — one idea, many directories.
25
+ export function clusters(projects, { min = 3 } = {}) {
26
+ const byToken = new Map();
27
+ for (const p of projects) {
28
+ for (const t of nameTokens(p.name)) {
29
+ if (!byToken.has(t)) byToken.set(t, []);
30
+ byToken.get(t).push(p.name);
31
+ }
32
+ }
33
+ return [...byToken.entries()]
34
+ .filter(([, names]) => names.length >= min)
35
+ .sort((a, b) => b[1].length - a[1].length)
36
+ .map(([token, names]) => ({ token, names }));
37
+ }
38
+
39
+ // Nearly shippable and sitting still: one push from done, for months.
40
+ export function nearMisses(projects, { minScore = 80, minStaleDays = 60 } = {}) {
41
+ return shipCandidates(projects, { limit: 1000, maxStaleDays: 10_000 })
42
+ .filter((p) => p.score >= minScore && p.staleDays >= minStaleDays);
43
+ }
44
+
45
+ // A fresh project that rhymes with an old or dead one — a probable rebuild.
46
+ export function echoes(projects, tombstoned = []) {
47
+ const recent = projects.filter((p) => p.staleDays <= 30);
48
+ const old = [
49
+ ...projects.filter((p) => p.staleDays >= 180),
50
+ ...tombstoned,
51
+ ];
52
+ const found = [];
53
+ for (const r of recent) {
54
+ const rTokens = new Set(nameTokens(r.name));
55
+ for (const o of old) {
56
+ if (o.path === r.path) continue;
57
+ const shared = nameTokens(o.name).filter((t) => rTokens.has(t));
58
+ if (shared.length) {
59
+ found.push({
60
+ fresh: r.name,
61
+ echo: o.name,
62
+ token: shared[0],
63
+ gone: Boolean(o.gone),
64
+ });
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ return found;
70
+ }
71
+
72
+ export function ahaMoments(projects, tombstoned = []) {
73
+ const moments = [];
74
+
75
+ for (const c of clusters(projects).slice(0, 4)) {
76
+ moments.push(
77
+ `${c.names.length} projects orbit "${c.token}": ${c.names.join(', ')}. One idea — which is canonical?`,
78
+ );
79
+ }
80
+
81
+ for (const p of nearMisses(projects).slice(0, 3)) {
82
+ const months = Math.round(p.staleDays / 30);
83
+ moments.push(
84
+ `${p.name} is ${p.score}/100 shippable (needs: ${p.missing.join(', ')}) and has sat still for ${months} month${months === 1 ? '' : 's'}.`,
85
+ );
86
+ }
87
+
88
+ for (const e of echoes(projects, tombstoned).slice(0, 3)) {
89
+ moments.push(
90
+ e.gone
91
+ ? `${e.fresh} echoes ${e.echo} — which you already built and deleted. The ledger remembers it.`
92
+ : `${e.fresh} echoes ${e.echo}, untouched for 6+ months. Salvage before you rebuild.`,
93
+ );
94
+ }
95
+
96
+ return moments;
97
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { scanDir, stats } from './scan.mjs';
4
+ import { shipCandidates, dirtyRepos, unversioned, staleProjects } from './ship.mjs';
5
+ import {
6
+ loadLedger, saveLedger, updateLedger, tombstones, archiveProjects, archived,
7
+ } from './ledger.mjs';
8
+ import { ahaMoments } from './aha.mjs';
9
+ import {
10
+ renderScan, renderDirty, renderShip, renderStale, renderReport,
11
+ renderAha, renderSnapshot, renderGraveyard,
12
+ } from './report.mjs';
13
+
14
+ const HELP = `salvatron — the salvage bot for AI-assisted development
15
+
16
+ Usage: salvatron <command> [dir] [options]
17
+
18
+ Commands:
19
+ scan Inventory every project: git state, staleness, risk counts
20
+ dirty Repos with uncommitted work, most at-risk first
21
+ ship Unpublished projects closest to launch, with what's missing
22
+ stale Cleanup candidates: untouched for ages, with last-accessed times
23
+ aha Insights: duplicate-idea clusters, near-misses, rebuild echoes
24
+ snapshot Record this sweep in the ledger; diff vs last time (writes state)
25
+ archive Move projects into SalvatronArchive/ next to them (ledger remembers)
26
+ graveyard Projects deleted or archived, as remembered in the ledger
27
+ report The full digest (scan + dirty + ship + unversioned + ah-has)
28
+
29
+ Options:
30
+ --json Machine-readable output
31
+ -n <count> Limit list length (default 10)
32
+ --stale <days> Max staleness for ship candidates (default 120)
33
+ --tyrant Let Salvatron say what it really thinks
34
+ -h, --help This help
35
+
36
+ [dir] defaults to the current directory. Point it at the graveyard:
37
+ salvatron report ~/dev --tyrant
38
+ `;
39
+
40
+ function parseArgs(argv) {
41
+ const args = { command: null, paths: [], json: false, tyrant: false, limit: 10, maxStaleDays: 120 };
42
+ const rest = [...argv];
43
+ while (rest.length) {
44
+ const a = rest.shift();
45
+ if (a === '-h' || a === '--help') return { help: true };
46
+ else if (a === '--json') args.json = true;
47
+ else if (a === '--tyrant') args.tyrant = true;
48
+ else if (a === '-n') args.limit = Number(rest.shift()) || 10;
49
+ else if (a === '--stale') args.maxStaleDays = Number(rest.shift()) || 120;
50
+ else if (!args.command) args.command = a;
51
+ else args.paths.push(a);
52
+ }
53
+ args.dir = args.paths[0] ?? process.cwd();
54
+ return args;
55
+ }
56
+
57
+ const args = parseArgs(process.argv.slice(2));
58
+
59
+ if (args.help || !args.command) {
60
+ console.log(HELP);
61
+ process.exit(args.help ? 0 : 1);
62
+ }
63
+
64
+ const NEEDS_SCAN = new Set(['scan', 'dirty', 'ship', 'stale', 'aha', 'snapshot', 'report']);
65
+ let projects = [];
66
+ if (NEEDS_SCAN.has(args.command)) {
67
+ try {
68
+ projects = scanDir(args.dir);
69
+ } catch (err) {
70
+ console.error(`salvatron: cannot scan ${args.dir}: ${err.message}`);
71
+ process.exit(1);
72
+ }
73
+ }
74
+
75
+ const opts = { limit: args.limit, maxStaleDays: args.maxStaleDays };
76
+
77
+ switch (args.command) {
78
+ case 'scan':
79
+ if (args.json) console.log(JSON.stringify({ stats: stats(projects), projects }, null, 2));
80
+ else console.log(renderScan(projects));
81
+ break;
82
+ case 'dirty':
83
+ if (args.json) console.log(JSON.stringify(dirtyRepos(projects), null, 2));
84
+ else console.log(renderDirty(projects, args.limit));
85
+ break;
86
+ case 'ship':
87
+ if (args.json) console.log(JSON.stringify(shipCandidates(projects, opts), null, 2));
88
+ else console.log(renderShip(projects, opts));
89
+ break;
90
+ case 'stale':
91
+ if (args.json) console.log(JSON.stringify(staleProjects(projects, { minStaleDays: args.maxStaleDays }), null, 2));
92
+ else console.log(renderStale(projects, { minStaleDays: args.maxStaleDays, limit: args.limit }));
93
+ break;
94
+ case 'aha': {
95
+ const moments = ahaMoments(projects, tombstones(loadLedger()));
96
+ if (args.json) console.log(JSON.stringify(moments, null, 2));
97
+ else console.log(renderAha(moments));
98
+ break;
99
+ }
100
+ case 'snapshot': {
101
+ const ledger = loadLedger();
102
+ const diff = updateLedger(ledger, projects, resolve(args.dir));
103
+ const path = saveLedger(ledger);
104
+ if (args.json) console.log(JSON.stringify({ diff, ledger: path }, null, 2));
105
+ else console.log(renderSnapshot(diff, ledger));
106
+ break;
107
+ }
108
+ case 'archive': {
109
+ if (!args.paths.length) {
110
+ console.error('salvatron: archive needs at least one project path');
111
+ process.exit(1);
112
+ }
113
+ const ledger = loadLedger();
114
+ const result = archiveProjects(ledger, args.paths);
115
+ saveLedger(ledger);
116
+ if (args.json) console.log(JSON.stringify(result, null, 2));
117
+ else {
118
+ for (const m of result.moved) console.log(`archived ${m.name} → ${m.to}`);
119
+ for (const s of result.skipped) console.log(`skipped ${s.name} (${s.reason})`);
120
+ console.log(`\n${result.moved.length} archived. The ledger remembers them all.`);
121
+ }
122
+ break;
123
+ }
124
+ case 'graveyard': {
125
+ const ledger = loadLedger();
126
+ if (args.json) console.log(JSON.stringify({ dead: tombstones(ledger), archived: archived(ledger) }, null, 2));
127
+ else console.log(renderGraveyard(tombstones(ledger), archived(ledger)));
128
+ break;
129
+ }
130
+ case 'report':
131
+ if (args.json) {
132
+ console.log(JSON.stringify({
133
+ stats: stats(projects),
134
+ dirty: dirtyRepos(projects),
135
+ shipCandidates: shipCandidates(projects, opts),
136
+ unversioned: unversioned(projects),
137
+ }, null, 2));
138
+ } else console.log(renderReport(projects, {
139
+ tyrant: args.tyrant,
140
+ ahas: ahaMoments(projects, tombstones(loadLedger())),
141
+ ...opts,
142
+ }));
143
+ break;
144
+ default:
145
+ console.error(`salvatron: unknown command '${args.command}'\n`);
146
+ console.log(HELP);
147
+ process.exit(1);
148
+ }
package/src/git.mjs ADDED
@@ -0,0 +1,14 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ // Returns trimmed stdout, or null if git fails (not a repo, unborn HEAD, etc.)
4
+ export function git(repo, args) {
5
+ try {
6
+ return execFileSync('git', ['-C', repo, ...args], {
7
+ encoding: 'utf8',
8
+ stdio: ['ignore', 'pipe', 'ignore'],
9
+ timeout: 15_000,
10
+ }).trim();
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
package/src/ledger.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // The ledger is salvatron's memory: every snapshot refreshes what's on disk,
2
+ // and projects that vanish become tombstones — remembered with their final
3
+ // state and remote URL, so cleanup never means amnesia.
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs';
6
+ import { join, dirname, basename, resolve } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+
9
+ export const ARCHIVE_DIRNAME = 'SalvatronArchive';
10
+
11
+ export function ledgerPath() {
12
+ const dir = process.env.SALVATRON_HOME || join(homedir(), '.salvatron');
13
+ return join(dir, 'ledger.json');
14
+ }
15
+
16
+ export function loadLedger() {
17
+ try {
18
+ return JSON.parse(readFileSync(ledgerPath(), 'utf8'));
19
+ } catch {
20
+ return { updatedAt: null, projects: {} };
21
+ }
22
+ }
23
+
24
+ export function saveLedger(ledger) {
25
+ const path = ledgerPath();
26
+ mkdirSync(join(path, '..'), { recursive: true });
27
+ writeFileSync(path, JSON.stringify(ledger, null, 2));
28
+ return path;
29
+ }
30
+
31
+ export function updateLedger(ledger, scanned, root, { now = Date.now() } = {}) {
32
+ const today = new Date(now).toISOString().slice(0, 10);
33
+ const seen = new Set();
34
+ const diff = { added: [], returned: [], gone: [], newlyDirty: [], cleaned: [] };
35
+
36
+ for (const p of scanned) {
37
+ seen.add(p.path);
38
+ const prev = ledger.projects[p.path];
39
+ if (!prev) diff.added.push(p.name);
40
+ else {
41
+ if (prev.gone) diff.returned.push(p.name);
42
+ if ((prev.dirty ?? 0) === 0 && p.dirty > 0) diff.newlyDirty.push(p.name);
43
+ if ((prev.dirty ?? 0) > 0 && p.dirty === 0) diff.cleaned.push(p.name);
44
+ }
45
+ ledger.projects[p.path] = {
46
+ ...p,
47
+ firstSeen: prev?.firstSeen ?? today,
48
+ lastSeen: today,
49
+ gone: false,
50
+ goneSince: null,
51
+ };
52
+ }
53
+
54
+ // Only tombstone entries under the scanned root — scanning one directory
55
+ // must not declare projects elsewhere dead.
56
+ for (const rec of Object.values(ledger.projects)) {
57
+ if (!seen.has(rec.path) && !rec.gone && !rec.archived && rec.path.startsWith(root + '/')) {
58
+ rec.gone = true;
59
+ rec.goneSince = today;
60
+ diff.gone.push(rec.name);
61
+ }
62
+ }
63
+
64
+ ledger.updatedAt = today;
65
+ return diff;
66
+ }
67
+
68
+ // Move projects into a sibling SalvatronArchive/ dir and record it in the
69
+ // ledger as archived — aged out, still on disk, never confused with dead.
70
+ export function archiveProjects(ledger, paths, { now = Date.now() } = {}) {
71
+ const today = new Date(now).toISOString().slice(0, 10);
72
+ const moved = [];
73
+ const skipped = [];
74
+ for (const target of paths) {
75
+ const from = resolve(target);
76
+ if (!existsSync(from)) {
77
+ skipped.push({ name: basename(from), reason: 'not found' });
78
+ continue;
79
+ }
80
+ const to = join(dirname(from), ARCHIVE_DIRNAME, basename(from));
81
+ if (existsSync(to)) {
82
+ skipped.push({ name: basename(from), reason: 'already archived' });
83
+ continue;
84
+ }
85
+ mkdirSync(dirname(to), { recursive: true });
86
+ renameSync(from, to);
87
+ const rec = ledger.projects[from] ?? { name: basename(from), path: from };
88
+ ledger.projects[from] = {
89
+ ...rec,
90
+ archived: true,
91
+ archivedTo: to,
92
+ archivedOn: today,
93
+ gone: false,
94
+ goneSince: null,
95
+ };
96
+ moved.push({ name: basename(from), to });
97
+ }
98
+ return { moved, skipped };
99
+ }
100
+
101
+ export function archived(ledger) {
102
+ return Object.values(ledger.projects)
103
+ .filter((p) => p.archived && !p.gone)
104
+ .sort((a, b) => (b.archivedOn ?? '').localeCompare(a.archivedOn ?? ''));
105
+ }
106
+
107
+ export function tombstones(ledger) {
108
+ return Object.values(ledger.projects)
109
+ .filter((p) => p.gone)
110
+ .sort((a, b) => (b.goneSince ?? '').localeCompare(a.goneSince ?? ''));
111
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,161 @@
1
+ import { stats } from './scan.mjs';
2
+ import { shipCandidates, dirtyRepos, unversioned, staleProjects } from './ship.mjs';
3
+
4
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
5
+ const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
6
+ export const bold = (s) => c('1', s);
7
+ export const red = (s) => c('31', s);
8
+ export const green = (s) => c('32', s);
9
+ export const yellow = (s) => c('33', s);
10
+ export const dim = (s) => c('2', s);
11
+
12
+ function age(p) {
13
+ if (!Number.isFinite(p.staleDays)) return 'unknown';
14
+ if (p.staleDays === 0) return 'today';
15
+ if (p.staleDays < 30) return `${p.staleDays}d ago`;
16
+ if (p.staleDays < 365) return `${Math.round(p.staleDays / 30)}mo ago`;
17
+ return `${(p.staleDays / 365).toFixed(1)}y ago`;
18
+ }
19
+
20
+ export function renderScan(projects) {
21
+ const s = stats(projects);
22
+ const lines = [];
23
+ lines.push(bold('SALVATRON SWEEP COMPLETE'));
24
+ lines.push('');
25
+ lines.push(` ${bold(String(s.total))} projects scanned`);
26
+ lines.push(` ${green(String(s.gitRepos))} under git · ${red(String(s.noGit))} with no version control`);
27
+ lines.push(` ${red(String(s.dirty))} repos carrying uncommitted work`);
28
+ lines.push(` ${yellow(String(s.noRemote))} repos with no remote (nowhere but this disk)`);
29
+ lines.push(` ${dim(String(s.stale180))} untouched for 6+ months`);
30
+ return lines.join('\n');
31
+ }
32
+
33
+ export function renderDirty(projects, limit = 25) {
34
+ const dirty = dirtyRepos(projects).slice(0, limit);
35
+ if (!dirty.length) return green('No uncommitted work found. Suspiciously tidy.');
36
+ const lines = [bold(`UNCOMMITTED WORK — ${dirty.length} repos at risk`), ''];
37
+ for (const p of dirty) {
38
+ lines.push(` ${red(String(p.dirty).padStart(4))} files ${bold(p.name)} ${dim(age(p))}`);
39
+ }
40
+ lines.push('');
41
+ lines.push(dim('One spilled coffee from oblivion. Commit or stash.'));
42
+ return lines.join('\n');
43
+ }
44
+
45
+ export function renderShip(projects, opts) {
46
+ const cands = shipCandidates(projects, opts);
47
+ if (!cands.length) return 'No ship candidates in range. Everything is either published or fossilised.';
48
+ const lines = [bold('SHIP CANDIDATES — closest to launch, not yet published'), ''];
49
+ for (const p of cands) {
50
+ const needs = p.missing.length ? `needs: ${p.missing.join(', ')}` : 'ready';
51
+ lines.push(` ${green(String(p.score).padStart(3))}/100 ${bold(p.name.padEnd(28))} ${dim(age(p).padEnd(9))} ${yellow(needs)}`);
52
+ }
53
+ return lines.join('\n');
54
+ }
55
+
56
+ function accessAge(p) {
57
+ if (p.accessDays == null) return 'accessed: unknown';
58
+ if (p.accessDays === 0) return 'accessed today';
59
+ if (p.accessDays < 30) return `accessed ${p.accessDays}d ago`;
60
+ if (p.accessDays < 365) return `accessed ${Math.round(p.accessDays / 30)}mo ago`;
61
+ return `accessed ${(p.accessDays / 365).toFixed(1)}y ago`;
62
+ }
63
+
64
+ export function renderStale(projects, { minStaleDays = 180, limit = 20 } = {}) {
65
+ const stale = staleProjects(projects, { minStaleDays });
66
+ if (!stale.length) return green(`Nothing untouched for ${minStaleDays}+ days. Impressive, or a lie.`);
67
+ const lines = [
68
+ bold(`CLEANUP CANDIDATES — no changes in ${minStaleDays}+ days, least-recently-accessed first`),
69
+ '',
70
+ ];
71
+ for (const p of stale.slice(0, limit)) {
72
+ const vcs = p.git ? (p.remote ? green('pushed') : yellow('local-only')) : red('no git');
73
+ lines.push(
74
+ ` ${bold(p.name.padEnd(28))} modified ${age(p).padEnd(9)} ${dim(accessAge(p).padEnd(19))} ${vcs}`,
75
+ );
76
+ }
77
+ if (stale.length > limit) lines.push(dim(` … and ${stale.length - limit} more (use -n)`));
78
+ lines.push('');
79
+ lines.push(dim('local-only / no git means deleting it deletes the only copy. Archive first.'));
80
+ return lines.join('\n');
81
+ }
82
+
83
+ export function renderAha(moments) {
84
+ if (!moments.length) return dim('No ah-ha moments this sweep. The graveyard keeps its secrets.');
85
+ const lines = [bold('AH-HA'), ''];
86
+ for (const m of moments) lines.push(` ${yellow('◉')} ${m}`);
87
+ return lines.join('\n');
88
+ }
89
+
90
+ export function renderSnapshot(diff, ledger) {
91
+ const total = Object.keys(ledger.projects).length;
92
+ const dead = Object.values(ledger.projects).filter((p) => p.gone).length;
93
+ const lines = [bold('SNAPSHOT RECORDED'), ''];
94
+ const row = (label, names, color = yellow) =>
95
+ names.length && lines.push(` ${color(String(names.length).padStart(3))} ${label}: ${names.slice(0, 8).join(', ')}${names.length > 8 ? ', …' : ''}`);
96
+ row('new since last snapshot', diff.added, green);
97
+ row('returned from the dead', diff.returned, green);
98
+ row('gone — now tombstoned, still remembered', diff.gone, red);
99
+ row('newly dirty', diff.newlyDirty, red);
100
+ row('cleaned up', diff.cleaned, green);
101
+ if (lines.length === 2) lines.push(dim(' No changes since last snapshot.'));
102
+ lines.push('');
103
+ lines.push(dim(` Ledger: ${total} projects remembered, ${dead} tombstones.`));
104
+ return lines.join('\n');
105
+ }
106
+
107
+ export function renderGraveyard(dead, resting = []) {
108
+ const lines = [];
109
+ if (dead.length) {
110
+ lines.push(bold(`GRAVEYARD — ${dead.length} projects remembered after deletion`), '');
111
+ for (const p of dead) {
112
+ const recover = p.remoteUrl ? dim(p.remoteUrl) : red('no remote — it is truly gone');
113
+ lines.push(` ${bold(p.name.padEnd(28))} died ${p.goneSince ?? '?'} ${recover}`);
114
+ }
115
+ }
116
+ if (resting.length) {
117
+ if (lines.length) lines.push('');
118
+ lines.push(bold(`ARCHIVED — ${resting.length} projects aged out, still on disk`), '');
119
+ for (const p of resting) {
120
+ lines.push(` ${bold(p.name.padEnd(28))} archived ${p.archivedOn ?? '?'} ${dim(p.archivedTo ?? '')}`);
121
+ }
122
+ }
123
+ if (!lines.length) return dim('No tombstones, nothing archived. Nothing has been forgotten — because nothing has left.');
124
+ return lines.join('\n');
125
+ }
126
+
127
+ export function tyrantLines(projects) {
128
+ const s = stats(projects);
129
+ const lines = [];
130
+ if (s.total > 100) lines.push(`${s.total} projects. This is not a dev directory, it is a garbage dimension.`);
131
+ if (s.dirty > 10) lines.push(`${s.dirty} repos with uncommitted changes. Your work exists at the pleasure of your hardware.`);
132
+ if (s.noGit > 20) lines.push(`${s.noGit} directories with no version control. Bold. Doomed, but bold.`);
133
+ if (s.stale180 > 50) lines.push(`${s.stale180} projects untouched for six months. They dream of being finished.`);
134
+ lines.push('ASSESSMENT COMPLETE. REPORTING NOTHING TO RICK.');
135
+ return lines.map((l) => red(bold(` ⏣ ${l}`))).join('\n');
136
+ }
137
+
138
+ export function renderReport(projects, { tyrant = false, ahas = [], ...opts } = {}) {
139
+ const parts = [
140
+ renderScan(projects),
141
+ '',
142
+ renderDirty(projects, 10),
143
+ '',
144
+ renderShip(projects, opts),
145
+ ];
146
+ if (ahas.length) {
147
+ parts.push('');
148
+ parts.push(renderAha(ahas.slice(0, 5)));
149
+ }
150
+ const unv = unversioned(projects);
151
+ if (unv.length) {
152
+ parts.push('');
153
+ parts.push(bold(`NO VERSION CONTROL — ${unv.length} dirs, most recent first`));
154
+ parts.push(unv.slice(0, 10).map((p) => ` ${bold(p.name.padEnd(28))} ${dim(age(p))}`).join('\n'));
155
+ }
156
+ if (tyrant) {
157
+ parts.push('');
158
+ parts.push(tyrantLines(projects));
159
+ }
160
+ return parts.join('\n');
161
+ }
package/src/scan.mjs ADDED
@@ -0,0 +1,131 @@
1
+ import { readdirSync, statSync, existsSync } from 'node:fs';
2
+ import { join, basename, resolve } from 'node:path';
3
+ import { git } from './git.mjs';
4
+
5
+ const MANIFESTS = [
6
+ 'package.json', 'pyproject.toml', 'setup.py', 'requirements.txt',
7
+ 'Cargo.toml', 'go.mod', 'Gemfile', 'Package.swift', 'pom.xml',
8
+ 'build.gradle', 'Makefile', 'CMakeLists.txt',
9
+ ];
10
+ const TEST_MARKERS = ['test', 'tests', '__tests__', 'spec'];
11
+ const LICENSES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'];
12
+
13
+ const MS_PER_DAY = 86_400_000;
14
+
15
+ function anyExists(dir, names) {
16
+ return names.some((n) => existsSync(join(dir, n)));
17
+ }
18
+
19
+ function hasReadme(dir) {
20
+ try {
21
+ return readdirSync(dir).some((f) => /^readme(\.|$)/i.test(f));
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ // Newest atime across a sample of the project's files (top level + one down).
28
+ // stat() reads metadata without touching atime, so scanning stays read-only.
29
+ // Caveat: indexers/backup tools also bump atime, so treat as "no later than".
30
+ function newestAtimeMs(dir) {
31
+ let newest = 0;
32
+ let entries;
33
+ try {
34
+ entries = readdirSync(dir, { withFileTypes: true });
35
+ } catch {
36
+ return null;
37
+ }
38
+ let checked = 0;
39
+ for (const e of entries) {
40
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
41
+ const p = join(dir, e.name);
42
+ try {
43
+ const st = statSync(p);
44
+ if (!e.isDirectory()) newest = Math.max(newest, st.atimeMs);
45
+ else if (checked < 30) {
46
+ for (const f of readdirSync(p).slice(0, 20)) {
47
+ try {
48
+ newest = Math.max(newest, statSync(join(p, f)).atimeMs);
49
+ } catch {}
50
+ }
51
+ }
52
+ checked++;
53
+ } catch {}
54
+ if (checked >= 60) break;
55
+ }
56
+ return newest || null;
57
+ }
58
+
59
+ export function scanProject(path, { now = Date.now() } = {}) {
60
+ const p = {
61
+ name: basename(path),
62
+ path,
63
+ git: existsSync(join(path, '.git')),
64
+ remote: false,
65
+ dirty: 0,
66
+ commits: 0,
67
+ lastActivity: null,
68
+ hasReadme: hasReadme(path),
69
+ hasLicense: anyExists(path, LICENSES),
70
+ hasTests: anyExists(path, TEST_MARKERS),
71
+ hasManifest: anyExists(path, MANIFESTS),
72
+ };
73
+
74
+ if (p.git) {
75
+ const status = git(path, ['status', '--porcelain']);
76
+ p.dirty = status ? status.split('\n').filter(Boolean).length : 0;
77
+ p.remote = Boolean(git(path, ['remote']));
78
+ p.remoteUrl = p.remote ? git(path, ['remote', 'get-url', 'origin']) : null;
79
+ p.commits = Number(git(path, ['rev-list', '--count', 'HEAD'])) || 0;
80
+ p.lastActivity = git(path, ['log', '-1', '--format=%cs']) || null;
81
+ }
82
+
83
+ if (!p.lastActivity) {
84
+ try {
85
+ p.lastActivity = statSync(path).mtime.toISOString().slice(0, 10);
86
+ } catch {
87
+ p.lastActivity = null;
88
+ }
89
+ }
90
+
91
+ p.staleDays = p.lastActivity
92
+ ? Math.max(0, Math.floor((now - Date.parse(p.lastActivity)) / MS_PER_DAY))
93
+ : Infinity;
94
+
95
+ const atime = newestAtimeMs(path);
96
+ p.lastAccessed = atime ? new Date(atime).toISOString().slice(0, 10) : null;
97
+ p.accessDays = atime ? Math.max(0, Math.floor((now - atime) / MS_PER_DAY)) : null;
98
+
99
+ return p;
100
+ }
101
+
102
+ export function scanDir(root, opts = {}) {
103
+ const abs = resolve(root);
104
+ const entries = readdirSync(abs, { withFileTypes: true }).filter(
105
+ (e) =>
106
+ e.isDirectory() && !e.name.startsWith('.') && e.name !== 'SalvatronArchive',
107
+ );
108
+
109
+ const projects = [];
110
+ for (const e of entries) {
111
+ try {
112
+ projects.push(scanProject(join(abs, e.name), opts));
113
+ } catch {
114
+ // unreadable dir — skip, never crash the sweep
115
+ }
116
+ }
117
+ return projects.sort((a, b) =>
118
+ (a.lastActivity ?? '').localeCompare(b.lastActivity ?? ''),
119
+ );
120
+ }
121
+
122
+ export function stats(projects) {
123
+ return {
124
+ total: projects.length,
125
+ gitRepos: projects.filter((p) => p.git).length,
126
+ noGit: projects.filter((p) => !p.git).length,
127
+ dirty: projects.filter((p) => p.dirty > 0).length,
128
+ noRemote: projects.filter((p) => p.git && !p.remote).length,
129
+ stale180: projects.filter((p) => p.staleDays >= 180).length,
130
+ };
131
+ }
package/src/ship.mjs ADDED
@@ -0,0 +1,57 @@
1
+ // Ship-readiness scoring: how close is a project to being publishable,
2
+ // and what's still missing. A remote counts as "already shipped".
3
+
4
+ const CHECKS = [
5
+ { label: 'README', points: 20, pass: (p) => p.hasReadme },
6
+ { label: 'LICENSE', points: 10, pass: (p) => p.hasLicense },
7
+ { label: 'git repo', points: 15, pass: (p) => p.git },
8
+ { label: 'tests', points: 15, pass: (p) => p.hasTests },
9
+ { label: 'manifest', points: 15, pass: (p) => p.hasManifest },
10
+ { label: 'clean tree', points: 10, pass: (p) => p.git && p.dirty === 0 },
11
+ { label: '5+ commits', points: 15, pass: (p) => p.commits >= 5 },
12
+ ];
13
+
14
+ export function shipScore(project) {
15
+ let score = 0;
16
+ const missing = [];
17
+ for (const check of CHECKS) {
18
+ if (check.pass(project)) score += check.points;
19
+ else missing.push(check.label);
20
+ }
21
+ return { score, missing };
22
+ }
23
+
24
+ // Unpublished projects (no remote), freshest and most complete first.
25
+ export function shipCandidates(projects, { limit = 10, maxStaleDays = 120 } = {}) {
26
+ return projects
27
+ .map((p) => ({ ...p, ...shipScore(p) }))
28
+ .filter((p) => !p.remote && p.staleDays <= maxStaleDays)
29
+ .sort((a, b) => b.score - a.score || a.staleDays - b.staleDays)
30
+ .slice(0, limit);
31
+ }
32
+
33
+ // Repos with uncommitted work — the stuff one bad `rm -rf` loses forever.
34
+ export function dirtyRepos(projects) {
35
+ return projects
36
+ .filter((p) => p.dirty > 0)
37
+ .sort((a, b) => b.dirty - a.dirty);
38
+ }
39
+
40
+ // Cleanup candidates: nothing modified in ages. Shown with last-accessed so
41
+ // you can tell truly dead (old atime too) from consulted-but-not-edited.
42
+ export function staleProjects(projects, { minStaleDays = 180 } = {}) {
43
+ return projects
44
+ .filter((p) => p.staleDays >= minStaleDays)
45
+ .sort(
46
+ (a, b) =>
47
+ (b.accessDays ?? -1) - (a.accessDays ?? -1) ||
48
+ b.staleDays - a.staleDays,
49
+ );
50
+ }
51
+
52
+ // Directories with no version control at all.
53
+ export function unversioned(projects) {
54
+ return projects
55
+ .filter((p) => !p.git)
56
+ .sort((a, b) => a.staleDays - b.staleDays);
57
+ }
@@ -0,0 +1,237 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ import { scanDir, scanProject, stats } from '../src/scan.mjs';
9
+ import { shipScore, shipCandidates, dirtyRepos, unversioned, staleProjects } from '../src/ship.mjs';
10
+
11
+ function sh(cwd, cmd, cmdArgs) {
12
+ execFileSync(cmd, cmdArgs, { cwd, stdio: 'ignore' });
13
+ }
14
+
15
+ function gitInit(dir) {
16
+ sh(dir, 'git', ['init', '-q']);
17
+ sh(dir, 'git', ['config', 'user.email', 'test@test.invalid']);
18
+ sh(dir, 'git', ['config', 'user.name', 'test']);
19
+ }
20
+
21
+ function commit(dir, msg) {
22
+ sh(dir, 'git', ['add', '-A']);
23
+ sh(dir, 'git', ['commit', '-q', '-m', msg, '--no-gpg-sign']);
24
+ }
25
+
26
+ // Fixture graveyard: one shippable repo, one dirty repo, one unversioned dir.
27
+ function makeGraveyard() {
28
+ const root = mkdtempSync(join(tmpdir(), 'salvatron-'));
29
+
30
+ const shippable = join(root, 'shippable');
31
+ mkdirSync(shippable);
32
+ writeFileSync(join(shippable, 'README.md'), '# shippable');
33
+ writeFileSync(join(shippable, 'LICENSE'), 'MIT');
34
+ writeFileSync(join(shippable, 'package.json'), '{}');
35
+ mkdirSync(join(shippable, 'test'));
36
+ writeFileSync(join(shippable, 'test', 'x.test.js'), '');
37
+ gitInit(shippable);
38
+ for (let i = 0; i < 5; i++) {
39
+ writeFileSync(join(shippable, `f${i}.js`), String(i));
40
+ commit(shippable, `commit ${i}`);
41
+ }
42
+
43
+ const dirty = join(root, 'dirty');
44
+ mkdirSync(dirty);
45
+ gitInit(dirty);
46
+ writeFileSync(join(dirty, 'a.js'), '1');
47
+ commit(dirty, 'initial');
48
+ writeFileSync(join(dirty, 'a.js'), '2');
49
+ writeFileSync(join(dirty, 'b.js'), 'new');
50
+
51
+ mkdirSync(join(root, 'loose'));
52
+ writeFileSync(join(root, 'loose', 'notes.txt'), 'no git here');
53
+
54
+ return root;
55
+ }
56
+
57
+ test('salvatron end-to-end on a fixture graveyard', (t) => {
58
+ const root = makeGraveyard();
59
+ t.after(() => rmSync(root, { recursive: true, force: true }));
60
+
61
+ const projects = scanDir(root);
62
+ assert.equal(projects.length, 3);
63
+
64
+ const s = stats(projects);
65
+ assert.equal(s.gitRepos, 2);
66
+ assert.equal(s.noGit, 1);
67
+ assert.equal(s.dirty, 1);
68
+ assert.equal(s.noRemote, 2);
69
+
70
+ const byName = Object.fromEntries(projects.map((p) => [p.name, p]));
71
+
72
+ assert.equal(byName.shippable.commits, 5);
73
+ assert.equal(byName.shippable.dirty, 0);
74
+ assert.ok(byName.shippable.hasReadme);
75
+ assert.ok(byName.shippable.hasTests);
76
+
77
+ assert.equal(byName.dirty.dirty, 2, 'one modified + one untracked file');
78
+ assert.ok(!byName.loose.git);
79
+
80
+ const { score, missing } = shipScore(byName.shippable);
81
+ assert.equal(score, 100);
82
+ assert.deepEqual(missing, []);
83
+
84
+ const cands = shipCandidates(projects, { maxStaleDays: 365 });
85
+ assert.equal(cands[0].name, 'shippable', 'most shippable ranks first');
86
+
87
+ assert.equal(dirtyRepos(projects).length, 1);
88
+ assert.equal(unversioned(projects).length, 1);
89
+ assert.equal(unversioned(projects)[0].name, 'loose');
90
+ });
91
+
92
+ test('lastAccessed is populated and staleProjects filters by modification age', (t) => {
93
+ const root = makeGraveyard();
94
+ t.after(() => rmSync(root, { recursive: true, force: true }));
95
+
96
+ const projects = scanDir(root);
97
+ for (const p of projects) {
98
+ assert.ok(p.lastAccessed, `${p.name} has a lastAccessed date`);
99
+ assert.equal(typeof p.accessDays, 'number');
100
+ assert.ok(p.accessDays <= 1, 'fixture files were just created, so accessed now');
101
+ }
102
+
103
+ // Fixtures are brand new — nothing should qualify as stale.
104
+ assert.equal(staleProjects(projects, { minStaleDays: 180 }).length, 0);
105
+
106
+ // Fabricated old projects rank least-recently-accessed first.
107
+ const old = [
108
+ { name: 'deadest', staleDays: 900, accessDays: 800 },
109
+ { name: 'consulted', staleDays: 900, accessDays: 5 },
110
+ { name: 'fresh', staleDays: 10, accessDays: 1 },
111
+ ];
112
+ const stale = staleProjects(old, { minStaleDays: 180 });
113
+ assert.deepEqual(stale.map((p) => p.name), ['deadest', 'consulted']);
114
+ });
115
+
116
+ test('scanProject survives a repo with no commits', (t) => {
117
+ const root = mkdtempSync(join(tmpdir(), 'salvatron-empty-'));
118
+ t.after(() => rmSync(root, { recursive: true, force: true }));
119
+
120
+ const empty = join(root, 'empty');
121
+ mkdirSync(empty);
122
+ gitInit(empty);
123
+
124
+ const p = scanProject(empty);
125
+ assert.equal(p.commits, 0);
126
+ assert.ok(p.lastActivity, 'falls back to directory mtime');
127
+ });
128
+
129
+ test('ledger remembers deleted projects as tombstones', (t) => {
130
+ const root = makeGraveyard();
131
+ const home = mkdtempSync(join(tmpdir(), 'salvatron-home-'));
132
+ t.after(() => {
133
+ rmSync(root, { recursive: true, force: true });
134
+ rmSync(home, { recursive: true, force: true });
135
+ });
136
+
137
+ const { loadLedger, saveLedger, updateLedger, tombstones } = awaitImportLedger();
138
+ process.env.SALVATRON_HOME = home;
139
+
140
+ const ledger = loadLedger();
141
+ const first = updateLedger(ledger, scanDir(root), root);
142
+ assert.equal(first.added.length, 3);
143
+ saveLedger(ledger);
144
+
145
+ rmSync(join(root, 'dirty'), { recursive: true, force: true });
146
+
147
+ const again = loadLedger();
148
+ const second = updateLedger(again, scanDir(root), root);
149
+ assert.deepEqual(second.gone, ['dirty']);
150
+
151
+ const dead = tombstones(again);
152
+ assert.equal(dead.length, 1);
153
+ assert.equal(dead[0].name, 'dirty');
154
+ assert.ok(dead[0].goneSince, 'tombstone records when it died');
155
+ assert.equal(dead[0].dirty, 2, 'final state is preserved');
156
+
157
+ delete process.env.SALVATRON_HOME;
158
+ });
159
+
160
+ test('archive moves projects aside and the ledger never tombstones them', (t) => {
161
+ const root = makeGraveyard();
162
+ const home = mkdtempSync(join(tmpdir(), 'salvatron-home-'));
163
+ t.after(() => {
164
+ rmSync(root, { recursive: true, force: true });
165
+ rmSync(home, { recursive: true, force: true });
166
+ delete process.env.SALVATRON_HOME;
167
+ });
168
+
169
+ const { loadLedger, saveLedger, updateLedger, tombstones, archiveProjects, archived } = ledgerMod;
170
+ process.env.SALVATRON_HOME = home;
171
+
172
+ const ledger = loadLedger();
173
+ updateLedger(ledger, scanDir(root), root);
174
+
175
+ const { moved, skipped } = archiveProjects(ledger, [join(root, 'loose'), join(root, 'nonexistent')]);
176
+ assert.equal(moved.length, 1);
177
+ assert.equal(skipped[0].reason, 'not found');
178
+ assert.ok(existsSync(join(root, 'SalvatronArchive', 'loose')), 'moved into SalvatronArchive');
179
+ saveLedger(ledger);
180
+
181
+ // Next sweep: loose is off the scan (archive dir is excluded) but must be
182
+ // recorded as archived, not dead.
183
+ const diff = updateLedger(ledger, scanDir(root), root);
184
+ assert.deepEqual(diff.gone, [], 'archived projects are not tombstoned');
185
+ assert.equal(tombstones(ledger).length, 0);
186
+ assert.equal(archived(ledger)[0].name, 'loose');
187
+ assert.equal(scanDir(root).length, 2, 'SalvatronArchive itself is not scanned');
188
+ });
189
+
190
+ test('aha finds clusters, near-misses and echoes', async () => {
191
+ const { clusters, nearMisses, echoes } = await import('../src/aha.mjs');
192
+
193
+ const mk = (name, extra = {}) => ({
194
+ name, path: `/x/${name}`, git: true, remote: false, dirty: 0, commits: 9,
195
+ hasReadme: true, hasLicense: true, hasTests: true, hasManifest: true,
196
+ staleDays: 5, ...extra,
197
+ });
198
+
199
+ const projects = [
200
+ mk('glasto'), mk('glasto-infra', { staleDays: 400 }), mk('GlastoBot', { staleDays: 400 }),
201
+ mk('almost-done', { staleDays: 90 }),
202
+ mk('scraper-medic'),
203
+ mk('gym-scraper', { staleDays: 2000 }),
204
+ ];
205
+
206
+ const c = clusters(projects);
207
+ assert.equal(c[0].token, 'glasto');
208
+ assert.equal(c[0].names.length, 3);
209
+
210
+ const near = nearMisses(projects);
211
+ assert.ok(near.some((p) => p.name === 'almost-done'));
212
+
213
+ const e = echoes(projects, [{ name: 'scraper-old', path: '/dead/scraper-old', gone: true }]);
214
+ assert.ok(e.some((x) => x.fresh === 'scraper-medic' && x.echo === 'gym-scraper'));
215
+ });
216
+
217
+ // node:test runs test files as ESM; a tiny sync-looking helper keeps the
218
+ // tombstone test readable while the import stays at module scope.
219
+ import * as ledgerMod from '../src/ledger.mjs';
220
+ function awaitImportLedger() {
221
+ return ledgerMod;
222
+ }
223
+
224
+ test('CLI runs and produces the report', (t) => {
225
+ const root = makeGraveyard();
226
+ t.after(() => rmSync(root, { recursive: true, force: true }));
227
+
228
+ const out = execFileSync(process.execPath, [
229
+ new URL('../src/cli.mjs', import.meta.url).pathname,
230
+ 'report', root, '--json',
231
+ ], { encoding: 'utf8' });
232
+
233
+ const report = JSON.parse(out);
234
+ assert.equal(report.stats.total, 3);
235
+ assert.equal(report.dirty.length, 1);
236
+ assert.equal(report.unversioned.length, 1);
237
+ });