@nemus-cli/nemus 0.10.0 → 0.12.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,43 @@
1
+ # Reflect — retrospective on recent sessions
2
+
3
+ `nemus reflect` (alias `retro`) reads your recent workspaces' agent session
4
+ transcripts and asks *your own* configured agent (claude/pi/opencode — no API key
5
+ of Nemus's) to recommend concrete setup improvements: skills to add, missing
6
+ `AGENTS.md`/context rules, missing connectivity/smoke tests, and prompt/workflow
7
+ habits — each with a priority and an example.
8
+
9
+ ```bash
10
+ nemus reflect # analyze the most recent workspaces (default 10)
11
+ nemus reflect --limit 20 # widen the window
12
+ nemus reflect --workspace <name> # analyze a single workspace
13
+ ```
14
+
15
+ Output / sharing:
16
+ ```bash
17
+ nemus reflect --json # structured report ({ ok:false, error } on failure)
18
+ nemus reflect --markdown > reflection.md # paste into an issue/PR
19
+ nemus reflect --group-by kind # group recommendations by kind (default: priority)
20
+ ```
21
+
22
+ Review saved reports (each run is saved under `~/.nemus/reflect/` unless
23
+ `--no-save`):
24
+ ```bash
25
+ nemus reflect history # list saved reports
26
+ nemus reflect show [id] # show one (id or id-prefix; defaults to latest)
27
+ ```
28
+
29
+ ## Flags
30
+
31
+ | Flag | Description |
32
+ |---|---|
33
+ | `--limit <n>` / `-n` | How many recent workspaces to analyze (default 10) |
34
+ | `--workspace <name>` / `-w` | Analyze a single workspace (ignores `--limit`) |
35
+ | `--json` | Structured JSON report to stdout |
36
+ | `--markdown` | Markdown report to stdout |
37
+ | `--group-by <how>` | `priority` (default) or `kind` |
38
+ | `--no-save` | Don't save the report to `~/.nemus/reflect/` |
39
+ | `--model` / `--thinking` | Judge model / pi thinking-level overrides |
40
+ | `--dry-run` | Print the assembled corpus + judge prompt without calling the agent |
41
+
42
+ Read-only and safe — it analyzes transcripts and prints advice; it changes no
43
+ repos. Use it to coach setup, not to modify anything.
@@ -0,0 +1,25 @@
1
+ # Save Context
2
+
3
+ `nemus save-context` (alias `ctx`) writes a progress summary to the workspace's
4
+ `CONTEXT.md`, so work survives `/clear` or a new session. Read `CONTEXT.md` back
5
+ at the start of a session to resume. The workspace defaults to the current
6
+ directory; pass `-w` to target another.
7
+
8
+ ```bash
9
+ nemus save-context -m "…" # save to the current workspace
10
+ nemus save-context -w <name> -m "…" # target a specific workspace
11
+ nemus save-context # interactive: prompts for the summary
12
+ nemus save-context -f notes.md --append # read from a file, append (don't replace)
13
+ ```
14
+
15
+ Use it to capture: what was done, what's in progress, key decisions, and the
16
+ next steps — before a context reset or when handing off.
17
+
18
+ ## Flags
19
+
20
+ | Flag | Short | Description |
21
+ |---|---|---|
22
+ | `--workspace <name>` | `-w` | Workspace name (default: current directory) |
23
+ | `--message <text>` | `-m` | Summary text to save (skips the prompt) |
24
+ | `--file <path>` | `-f` | Read the summary from a file |
25
+ | `--append` | | Append to existing context instead of replacing |
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: prune-workspaces
3
+ description: Bulk-delete workspaces with no recent activity, safe by default (protects uncommitted/unpushed work)
4
+ ---
5
+
6
+ Preview first — never delete without showing the user the plan:
7
+ ```bash
8
+ nemus prune --days 30 --dry-run
9
+ ```
10
+
11
+ Then prune after confirming with the user:
12
+ ```bash
13
+ nemus prune --days 30 # prompts (default: No)
14
+ nemus prune --days 30 --yes # non-interactive — only when the user is sure
15
+ ```
16
+
17
+ - **Stale** = no agent session (or, failing that, no `createdAt`) in the last N
18
+ days (`--days`, default 30). Undatable workspaces are never selected.
19
+ - **Safe by default:** workspaces with uncommitted or unpushed changes are
20
+ **protected** and listed with the reason — not deleted. `--include-dirty`
21
+ overrides this (only with explicit user consent to lose that work).
22
+ - `--json` and `--dry-run` never delete.
23
+ - Deletion is permanent — repos must be re-cloned. Always confirm first.
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: reflect
3
+ description: Retrospective on recent agent sessions — get concrete tips to improve skills, context, and prompts
4
+ ---
5
+
6
+ Analyze recent workspaces' agent sessions and get setup improvement tips (read-only; changes no repos):
7
+ ```bash
8
+ nemus reflect # most recent workspaces (default 10)
9
+ nemus reflect --workspace <name> # a single workspace
10
+ nemus reflect --markdown > reflection.md # shareable report
11
+ ```
12
+
13
+ Review saved reports (saved under `~/.nemus/reflect/` unless `--no-save`):
14
+ ```bash
15
+ nemus reflect history # list past reports
16
+ nemus reflect show [id] # show one (defaults to latest)
17
+ ```
18
+
19
+ - Uses *your own* configured agent (claude/pi/opencode) as the judge — no API key of Nemus's.
20
+ - `--json` / `--markdown` for machine or shareable output; `--group-by kind|priority`.
21
+ - Safe: it reads transcripts and prints advice, nothing is modified.
@@ -0,0 +1,183 @@
1
+ import { Command } from 'commander';
2
+ import * as fs from 'fs/promises';
3
+ import { safeWorkspacePath } from '../utils/validation';
4
+ import { listWorkspaces } from '../utils/workspace-meta';
5
+ import { getWorkspaceSessions } from '../utils/claude-sessions';
6
+ import { getAllReposStatus } from '../utils/git-status';
7
+ import { logInfo, logSuccess, logError, logWarning, logStep } from '../utils/logger';
8
+ import { colorize } from '../utils/colors';
9
+ import { confirm } from '../utils/prompt';
10
+ import { getGlobalOpts } from '../utils/command-helpers';
11
+ import { outputJson, outputJsonError } from '../utils/output';
12
+ import {
13
+ toCandidate,
14
+ isStale,
15
+ planPrune,
16
+ type WorkspaceForPrune,
17
+ type PruneCandidate,
18
+ } from '../utils/prune';
19
+
20
+ const DEFAULT_DAYS = 30;
21
+
22
+ export function registerPruneCommand(parent: Command) {
23
+ parent
24
+ .command('prune')
25
+ .description('Delete workspaces with no recent activity (safe by default)')
26
+ .option('-d, --days <n>', `Consider a workspace stale after N days of inactivity (default ${DEFAULT_DAYS})`)
27
+ .option('--include-dirty', 'Also prune workspaces with uncommitted/unpushed changes (default: protected)')
28
+ .option('--dry-run', 'Show what would be pruned without deleting anything')
29
+ .option('-y, --yes', 'Skip the confirmation prompt')
30
+ .option('--json', 'Output the prune plan as JSON (never deletes)')
31
+ .action(async (opts, cmd) => {
32
+ const globalOpts = getGlobalOpts(cmd);
33
+ await handlePrune({ ...opts, ...globalOpts });
34
+ });
35
+ }
36
+
37
+ function parseDays(raw: unknown): number | null {
38
+ if (raw === undefined) return DEFAULT_DAYS;
39
+ const n = Number(raw);
40
+ if (!Number.isFinite(n) || n < 0) return null;
41
+ return Math.floor(n);
42
+ }
43
+
44
+ export async function handlePrune(opts: {
45
+ days?: string;
46
+ includeDirty?: boolean;
47
+ dryRun?: boolean;
48
+ yes?: boolean;
49
+ json?: boolean;
50
+ }) {
51
+ const json = !!opts.json;
52
+ const days = parseDays(opts.days);
53
+ if (days === null) {
54
+ if (json) outputJsonError('--days must be a non-negative number');
55
+ else logError('--days must be a non-negative number');
56
+ process.exit(1);
57
+ }
58
+
59
+ try {
60
+ const [workspaces, sessions] = await Promise.all([listWorkspaces(), getWorkspaceSessions()]);
61
+ const sessionMap = new Map(sessions.map((s) => [s.workspaceName, s]));
62
+ const now = Date.now();
63
+
64
+ const candidates: PruneCandidate[] = workspaces.map((ws) => {
65
+ const session = sessionMap.get(ws.name);
66
+ const createdRaw = ws.metadata?.createdAt ? Date.parse(ws.metadata.createdAt) : NaN;
67
+ const forPrune: WorkspaceForPrune = {
68
+ name: ws.name,
69
+ path: ws.path,
70
+ repoDirNames: (ws.metadata?.repositories ?? []).map((r) => r.directoryName),
71
+ lastActiveAt: session ? session.lastActiveAt.getTime() : 0,
72
+ createdAt: Number.isFinite(createdRaw) ? createdRaw : 0,
73
+ };
74
+ return toCandidate(forPrune, now);
75
+ });
76
+
77
+ const stale = candidates.filter((c) => isStale(c, days));
78
+
79
+ if (stale.length === 0) {
80
+ if (json) {
81
+ outputJson({ ok: true, days, prunable: [], protected: [], scanned: workspaces.length });
82
+ } else {
83
+ logInfo(`No workspaces inactive for ${days}+ days (scanned ${workspaces.length}).`);
84
+ }
85
+ return;
86
+ }
87
+
88
+ // Compute the plan. The git safety check only runs for stale workspaces.
89
+ const plan = await planPrune(
90
+ stale,
91
+ (c) => getAllReposStatus(c.path, c.repoDirNames, 3),
92
+ !!opts.includeDirty,
93
+ );
94
+
95
+ if (json) {
96
+ outputJson({
97
+ ok: true,
98
+ days,
99
+ scanned: workspaces.length,
100
+ prunable: plan.prunable.map((c) => ({ name: c.name, path: c.path, ageDays: c.ageDays, repos: c.repoDirNames.length })),
101
+ protected: plan.protected.map((p) => ({ name: p.candidate.name, ageDays: p.candidate.ageDays, reason: p.reason })),
102
+ });
103
+ return;
104
+ }
105
+
106
+ // Human report.
107
+ console.log('\n' + '='.repeat(60));
108
+ console.log(colorize(`Prune — workspaces inactive for ${days}+ days`, 'bright'));
109
+ console.log('='.repeat(60) + '\n');
110
+
111
+ if (plan.protected.length > 0) {
112
+ logWarning(`Protected (${plan.protected.length}) — skipped due to unsaved work:`);
113
+ for (const p of plan.protected) {
114
+ console.log(` ${colorize('•', 'yellow')} ${colorize(p.candidate.name, 'cyan')} — ${p.reason} ${colorize(`(${ageLabel(p.candidate)})`, 'gray')}`);
115
+ }
116
+ console.log('');
117
+ }
118
+
119
+ if (plan.prunable.length === 0) {
120
+ logInfo('Nothing safe to prune.');
121
+ if (plan.protected.length > 0) logInfo('Re-run with --include-dirty to include the protected ones (careful).');
122
+ return;
123
+ }
124
+
125
+ console.log(`${colorize('Prunable', 'bright')} (${plan.prunable.length}):`);
126
+ for (const c of plan.prunable) {
127
+ const repoLabel = c.repoDirNames.length === 1 ? '1 repo' : `${c.repoDirNames.length} repos`;
128
+ console.log(` ${colorize('✗', 'red')} ${colorize(c.name, 'cyan')} ${colorize(`(${ageLabel(c)}, ${repoLabel})`, 'gray')}`);
129
+ }
130
+ console.log('');
131
+
132
+ if (opts.dryRun) {
133
+ logInfo(`Dry run — nothing deleted. ${plan.prunable.length} workspace(s) would be pruned.`);
134
+ return;
135
+ }
136
+
137
+ logWarning('This permanently deletes the selected workspaces and every cloned repo inside them!');
138
+
139
+ if (!opts.yes) {
140
+ const confirmed = await confirm({
141
+ message: plan.prunable.length === 1
142
+ ? `Prune workspace ${plan.prunable[0].name}?`
143
+ : `Prune these ${plan.prunable.length} workspaces?`,
144
+ default: false,
145
+ });
146
+ if (!confirmed) {
147
+ logInfo('Prune cancelled');
148
+ return;
149
+ }
150
+ }
151
+
152
+ let deleted = 0;
153
+ for (const c of plan.prunable) {
154
+ let target: string;
155
+ try {
156
+ // Re-validate through the same choke point delete uses: enforces the
157
+ // name allowlist and pins the path inside WORKSPACES_DIR.
158
+ target = safeWorkspacePath(c.name);
159
+ } catch (error) {
160
+ logError(error instanceof Error ? error.message : `Invalid workspace name "${c.name}"`);
161
+ continue;
162
+ }
163
+ try {
164
+ await fs.rm(target, { recursive: true, force: true });
165
+ logSuccess(`Pruned "${colorize(c.name, 'cyan')}"`);
166
+ deleted++;
167
+ } catch (error) {
168
+ logError(`Failed to prune "${c.name}"`);
169
+ if (error instanceof Error) logError(error.message);
170
+ }
171
+ }
172
+ logStep(`Pruned ${deleted} of ${plan.prunable.length} workspace(s).`);
173
+ } catch (error) {
174
+ if (json) outputJsonError(error instanceof Error ? error.message : 'prune failed');
175
+ else logError(error instanceof Error ? error.message : 'prune failed');
176
+ process.exit(1);
177
+ }
178
+ }
179
+
180
+ function ageLabel(c: PruneCandidate): string {
181
+ const base = c.ageDays === 1 ? '1 day' : `${c.ageDays} days`;
182
+ return c.fromSession ? `${base} since last session` : `${base} since created, no sessions`;
183
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildVersionInfo } from './version';
3
+
4
+ describe('buildVersionInfo', () => {
5
+ it('combines the given version with the injected runtime fields', () => {
6
+ const info = buildVersionInfo('1.2.3', {
7
+ versions: { node: '22.13.0' } as NodeJS.ProcessVersions,
8
+ platform: 'linux',
9
+ arch: 'x64',
10
+ });
11
+ expect(info).toEqual({ version: '1.2.3', node: '22.13.0', platform: 'linux', arch: 'x64' });
12
+ });
13
+
14
+ it('defaults to the real process runtime', () => {
15
+ const info = buildVersionInfo('9.9.9');
16
+ expect(info.version).toBe('9.9.9');
17
+ expect(info.node).toBe(process.versions.node);
18
+ expect(info.platform).toBe(process.platform);
19
+ expect(info.arch).toBe(process.arch);
20
+ });
21
+ });
@@ -0,0 +1,45 @@
1
+ import { Command } from 'commander';
2
+ import { outputJson } from '../utils/output';
3
+
4
+ export interface VersionInfo {
5
+ version: string;
6
+ node: string;
7
+ platform: string;
8
+ arch: string;
9
+ }
10
+
11
+ /**
12
+ * Build the version payload. Pure and process-injectable so the JSON shape is
13
+ * unit-testable without reading the real runtime.
14
+ */
15
+ export function buildVersionInfo(
16
+ version: string,
17
+ proc: Pick<NodeJS.Process, 'versions' | 'platform' | 'arch'> = process,
18
+ ): VersionInfo {
19
+ return {
20
+ version,
21
+ node: proc.versions.node,
22
+ platform: proc.platform,
23
+ arch: proc.arch,
24
+ };
25
+ }
26
+
27
+ /**
28
+ * `nemus version` — a subcommand companion to the `-V/--version` flag, for
29
+ * people who type `nemus version`. `--json` also reports the Node/OS runtime
30
+ * (handy for bug reports), emitting a single JSON document to stdout.
31
+ */
32
+ export function registerVersionCommand(program: Command, version: string) {
33
+ program
34
+ .command('version')
35
+ .description('Print the Nemus version (with --json for version + runtime info)')
36
+ .option('--json', 'Output version + runtime info as JSON')
37
+ .action((opts: { json?: boolean }) => {
38
+ const info = buildVersionInfo(version);
39
+ if (opts.json) {
40
+ outputJson(info);
41
+ } else {
42
+ process.stdout.write(`nemus ${info.version}\n`);
43
+ }
44
+ });
45
+ }
package/src/program.ts CHANGED
@@ -4,6 +4,7 @@ import * as fs from 'fs';
4
4
  import { setColorEnabled } from './utils/colors';
5
5
  import { renderHelpBanner } from './utils/banner';
6
6
  import { applyGlobalFlags } from './utils/global-flags';
7
+ import { registerVersionCommand } from './commands/version';
7
8
 
8
9
  // Read version from package.json
9
10
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -35,10 +36,12 @@ program
35
36
  program.hook('preAction', () => applyGlobalFlags(program.opts()));
36
37
 
37
38
  // Register top-level commands
39
+ registerVersionCommand(program, pkg.version);
38
40
  import { registerCreateCommand } from './commands/create';
39
41
  import { registerListCommand } from './commands/list';
40
42
  import { registerUpdateCommand } from './commands/update';
41
43
  import { registerDeleteCommand } from './commands/delete';
44
+ import { registerPruneCommand } from './commands/prune';
42
45
  import { registerSyncCommand } from './commands/sync';
43
46
  import { registerStatusCommand } from './commands/status';
44
47
  import { registerDiffCommand } from './commands/diff';
@@ -66,6 +69,7 @@ registerCreateCommand(program);
66
69
  registerListCommand(program);
67
70
  registerUpdateCommand(program);
68
71
  registerDeleteCommand(program);
72
+ registerPruneCommand(program);
69
73
  registerSyncCommand(program);
70
74
  registerStatusCommand(program);
71
75
  registerDiffCommand(program);
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ toCandidate,
4
+ isStale,
5
+ protectionReason,
6
+ planPrune,
7
+ type WorkspaceForPrune,
8
+ type PruneCandidate,
9
+ } from './prune';
10
+ import type { GitStatus } from '../types';
11
+
12
+ const NOW = Date.parse('2026-09-01T00:00:00Z');
13
+ const daysAgo = (n: number) => NOW - n * 24 * 60 * 60 * 1000;
14
+
15
+ function ws(over: Partial<WorkspaceForPrune> = {}): WorkspaceForPrune {
16
+ return { name: 'w', path: '/w', repoDirNames: [], lastActiveAt: 0, createdAt: 0, ...over };
17
+ }
18
+ function status(over: Partial<GitStatus> = {}): GitStatus {
19
+ return {
20
+ repo: 'r', branch: 'main', clean: true, ahead: 0, behind: 0,
21
+ modifiedFiles: 0, untrackedFiles: 0, hasRemote: true, detachedHead: false, ...over,
22
+ };
23
+ }
24
+
25
+ describe('toCandidate', () => {
26
+ it('prefers lastActive over createdAt and marks fromSession', () => {
27
+ const c = toCandidate(ws({ lastActiveAt: daysAgo(5), createdAt: daysAgo(40) }), NOW);
28
+ expect(c.fromSession).toBe(true);
29
+ expect(c.ageDays).toBe(5);
30
+ expect(c.undatable).toBe(false);
31
+ });
32
+
33
+ it('falls back to createdAt when there is no session', () => {
34
+ const c = toCandidate(ws({ lastActiveAt: 0, createdAt: daysAgo(40) }), NOW);
35
+ expect(c.fromSession).toBe(false);
36
+ expect(c.ageDays).toBe(40);
37
+ });
38
+
39
+ it('is undatable when neither timestamp is present', () => {
40
+ const c = toCandidate(ws({ lastActiveAt: 0, createdAt: 0 }), NOW);
41
+ expect(c.undatable).toBe(true);
42
+ expect(c.ageDays).toBe(0);
43
+ });
44
+
45
+ it('floors a future reference to a negative age (clock skew) without marking undatable', () => {
46
+ const c = toCandidate(ws({ lastActiveAt: NOW + 60_000 }), NOW);
47
+ expect(c.undatable).toBe(false);
48
+ expect(c.ageDays).toBeLessThan(0);
49
+ });
50
+ });
51
+
52
+ describe('isStale', () => {
53
+ const c = (over: Partial<WorkspaceForPrune>) => toCandidate(ws(over), NOW);
54
+
55
+ it('is true at or beyond the threshold', () => {
56
+ expect(isStale(c({ lastActiveAt: daysAgo(30) }), 30)).toBe(true);
57
+ expect(isStale(c({ lastActiveAt: daysAgo(31) }), 30)).toBe(true);
58
+ });
59
+ it('is false below the threshold', () => {
60
+ expect(isStale(c({ lastActiveAt: daysAgo(29) }), 30)).toBe(false);
61
+ });
62
+ it('never selects an undatable workspace', () => {
63
+ expect(isStale(c({ lastActiveAt: 0, createdAt: 0 }), 0)).toBe(false);
64
+ });
65
+ it('never selects a future-dated (skewed) workspace', () => {
66
+ expect(isStale(c({ lastActiveAt: NOW + 86_400_000 }), 0)).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe('protectionReason', () => {
71
+ it('returns null for an all-clean workspace', () => {
72
+ expect(protectionReason([status(), status()], false)).toBeNull();
73
+ });
74
+ it('returns null for an empty workspace', () => {
75
+ expect(protectionReason([], false)).toBeNull();
76
+ });
77
+ it('flags uncommitted changes', () => {
78
+ expect(protectionReason([status({ clean: false })], false)).toBe('1 repo with uncommitted changes');
79
+ });
80
+ it('flags unpushed commits', () => {
81
+ expect(protectionReason([status({ ahead: 2 })], false)).toBe('1 repo with unpushed commits');
82
+ });
83
+ it('combines both reasons and pluralizes', () => {
84
+ expect(
85
+ protectionReason([status({ clean: false }), status({ clean: false }), status({ ahead: 1 })], false),
86
+ ).toBe('2 repos with uncommitted changes, 1 repo with unpushed commits');
87
+ });
88
+ it('returns null when includeDirty overrides protection', () => {
89
+ expect(protectionReason([status({ clean: false, ahead: 3 })], true)).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe('planPrune', () => {
94
+ const mk = (name: string, repos: string[]): PruneCandidate =>
95
+ toCandidate(ws({ name, path: `/w/${name}`, repoDirNames: repos, lastActiveAt: daysAgo(40) }), NOW);
96
+
97
+ it('partitions prunable vs protected and skips git calls for empty workspaces', async () => {
98
+ const empty = mk('empty', []);
99
+ const clean = mk('clean', ['a']);
100
+ const dirty = mk('dirty', ['b']);
101
+ let calls = 0;
102
+ const resolver = async (c: PruneCandidate): Promise<GitStatus[]> => {
103
+ calls++;
104
+ return c.name === 'dirty' ? [status({ clean: false })] : [status()];
105
+ };
106
+
107
+ const plan = await planPrune([empty, clean, dirty], resolver, false);
108
+
109
+ expect(plan.prunable.map((c) => c.name)).toEqual(['empty', 'clean']);
110
+ expect(plan.protected.map((p) => p.candidate.name)).toEqual(['dirty']);
111
+ expect(plan.protected[0].reason).toBe('1 repo with uncommitted changes');
112
+ expect(calls).toBe(2); // empty workspace incurred no status call
113
+ });
114
+
115
+ it('includeDirty moves everything to prunable', async () => {
116
+ const dirty = mk('dirty', ['b']);
117
+ const plan = await planPrune([dirty], async () => [status({ clean: false, ahead: 2 })], true);
118
+ expect(plan.prunable.map((c) => c.name)).toEqual(['dirty']);
119
+ expect(plan.protected).toHaveLength(0);
120
+ });
121
+ });
@@ -0,0 +1,109 @@
1
+ // Pure logic for `nemus prune` — deciding which workspaces are stale and which
2
+ // are unsafe to delete. Kept free of I/O so it can be unit-tested exhaustively;
3
+ // the command layer (src/commands/prune.ts) does the filesystem/git work and
4
+ // feeds the results in here.
5
+ import type { GitStatus } from '../types';
6
+
7
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
8
+
9
+ export interface WorkspaceForPrune {
10
+ name: string;
11
+ path: string;
12
+ /** Repo directory names inside the workspace (for the git safety check). */
13
+ repoDirNames: string[];
14
+ /** Epoch ms of the most recent agent session, or 0 if none. */
15
+ lastActiveAt: number;
16
+ /** Epoch ms parsed from metadata.createdAt, or 0 if absent/unparseable. */
17
+ createdAt: number;
18
+ }
19
+
20
+ export interface PruneCandidate {
21
+ name: string;
22
+ path: string;
23
+ repoDirNames: string[];
24
+ /** The timestamp staleness is measured from (lastActive, else createdAt). */
25
+ referenceAt: number;
26
+ /** Whether the reference came from a real session (vs. createdAt fallback). */
27
+ fromSession: boolean;
28
+ /** Whole days since referenceAt (floored). */
29
+ ageDays: number;
30
+ /** True when we have no date at all to judge age. */
31
+ undatable: boolean;
32
+ }
33
+
34
+ export interface ProtectedWorkspace {
35
+ candidate: PruneCandidate;
36
+ reason: string;
37
+ }
38
+
39
+ export interface PrunePlan {
40
+ /** Stale + safe to delete. */
41
+ prunable: PruneCandidate[];
42
+ /** Stale but held back (uncommitted/unpushed work), unless includeDirty. */
43
+ protected: ProtectedWorkspace[];
44
+ }
45
+
46
+ /** Build a dated candidate for a workspace. `now` is injected for testability. */
47
+ export function toCandidate(ws: WorkspaceForPrune, now: number): PruneCandidate {
48
+ const referenceAt = ws.lastActiveAt > 0 ? ws.lastActiveAt : ws.createdAt;
49
+ const undatable = !(referenceAt > 0);
50
+ const ageDays = undatable ? 0 : Math.floor((now - referenceAt) / MS_PER_DAY);
51
+ return {
52
+ name: ws.name,
53
+ path: ws.path,
54
+ repoDirNames: ws.repoDirNames,
55
+ referenceAt,
56
+ fromSession: ws.lastActiveAt > 0,
57
+ ageDays,
58
+ undatable,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * A workspace is a prune candidate when it is datable and its age meets the
64
+ * threshold. Undatable workspaces are never auto-selected — we won't delete
65
+ * something we can't put a date on. A future `referenceAt` (clock skew) yields
66
+ * a negative age and is therefore not stale.
67
+ */
68
+ export function isStale(c: PruneCandidate, days: number): boolean {
69
+ return !c.undatable && c.ageDays >= days;
70
+ }
71
+
72
+ /**
73
+ * Why a stale workspace should be held back from deletion, or null if it's safe.
74
+ * Unsafe = any repo has uncommitted changes (`!clean`) or unpushed commits
75
+ * (`ahead > 0`). With `includeDirty`, nothing is held back. An empty workspace
76
+ * (no repos) is always safe.
77
+ */
78
+ export function protectionReason(statuses: GitStatus[], includeDirty: boolean): string | null {
79
+ if (includeDirty) return null;
80
+ const dirty = statuses.filter((s) => !s.clean).length;
81
+ const unpushed = statuses.filter((s) => s.ahead > 0).length;
82
+ if (dirty === 0 && unpushed === 0) return null;
83
+ const parts: string[] = [];
84
+ if (dirty > 0) parts.push(`${dirty} repo${dirty === 1 ? '' : 's'} with uncommitted changes`);
85
+ if (unpushed > 0) parts.push(`${unpushed} repo${unpushed === 1 ? '' : 's'} with unpushed commits`);
86
+ return parts.join(', ');
87
+ }
88
+
89
+ /**
90
+ * Partition stale candidates into prunable vs. protected, given a resolver that
91
+ * returns each workspace's per-repo git status. The resolver is only invoked
92
+ * for workspaces that actually have repos, so empty stale workspaces cost no git
93
+ * calls. Injecting the resolver keeps this function pure and unit-testable.
94
+ */
95
+ export async function planPrune(
96
+ staleCandidates: PruneCandidate[],
97
+ getStatuses: (c: PruneCandidate) => Promise<GitStatus[]>,
98
+ includeDirty: boolean,
99
+ ): Promise<PrunePlan> {
100
+ const prunable: PruneCandidate[] = [];
101
+ const protectedList: ProtectedWorkspace[] = [];
102
+ for (const c of staleCandidates) {
103
+ const statuses = c.repoDirNames.length > 0 ? await getStatuses(c) : [];
104
+ const reason = protectionReason(statuses, includeDirty);
105
+ if (reason) protectedList.push({ candidate: c, reason });
106
+ else prunable.push(c);
107
+ }
108
+ return { prunable, protected: protectedList };
109
+ }
@@ -24,14 +24,41 @@ vi.mock('./config', () => ({
24
24
  getPackageVersion: () => '2.20.0',
25
25
  }));
26
26
 
27
- import { checkForUpdate } from './version-check';
27
+ import { checkForUpdate, updateCheckDisabled } from './version-check';
28
28
  import * as fs from 'fs/promises';
29
29
 
30
+ describe('updateCheckDisabled', () => {
31
+ it('is true when NEMUS_NO_UPDATE_CHECK is set to a truthy value', () => {
32
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: '1' })).toBe(true);
33
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: 'yes' })).toBe(true);
34
+ });
35
+ it('honors the de-facto NO_UPDATE_NOTIFIER', () => {
36
+ expect(updateCheckDisabled({ NO_UPDATE_NOTIFIER: 'true' })).toBe(true);
37
+ });
38
+ it('is false when unset, empty, or an explicit falsey token', () => {
39
+ expect(updateCheckDisabled({})).toBe(false);
40
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: '' })).toBe(false);
41
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: '0' })).toBe(false);
42
+ expect(updateCheckDisabled({ NEMUS_NO_UPDATE_CHECK: 'false' })).toBe(false);
43
+ });
44
+ });
45
+
30
46
  describe('checkForUpdate', () => {
31
47
  beforeEach(() => {
32
48
  vi.clearAllMocks();
33
49
  });
34
50
 
51
+ it('returns null immediately when opted out via env (no fetch)', async () => {
52
+ process.env.NEMUS_NO_UPDATE_CHECK = '1';
53
+ try {
54
+ const result = await checkForUpdate();
55
+ expect(result).toBeNull();
56
+ expect(mockExecFile).not.toHaveBeenCalled();
57
+ } finally {
58
+ delete process.env.NEMUS_NO_UPDATE_CHECK;
59
+ }
60
+ });
61
+
35
62
  it('returns null when current version matches latest', async () => {
36
63
  // No cached check
37
64
  vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));