@nemus-cli/nemus 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +23 -5
  3. package/bin/workspace.js +12 -0
  4. package/dist/commands/analyze-deps.js +5 -12
  5. package/dist/commands/archive.js +13 -18
  6. package/dist/commands/branch/create.js +5 -12
  7. package/dist/commands/branch/switch.js +14 -25
  8. package/dist/commands/cache/manager.js +13 -24
  9. package/dist/commands/cleanup.js +14 -26
  10. package/dist/commands/configure-claude.js +4 -8
  11. package/dist/commands/configure.js +38 -32
  12. package/dist/commands/dashboard/session-picker.js +19 -26
  13. package/dist/commands/dashboard/workspace-picker.js +19 -26
  14. package/dist/commands/delete.js +15 -30
  15. package/dist/commands/ghq-status.js +7 -12
  16. package/dist/commands/go.js +18 -27
  17. package/dist/commands/history.js +6 -13
  18. package/dist/commands/list.js +20 -29
  19. package/dist/commands/prune.js +196 -0
  20. package/dist/commands/remove-repo.js +21 -29
  21. package/dist/commands/save-context.js +5 -10
  22. package/dist/commands/sessions.js +19 -25
  23. package/dist/commands/suite/create.js +27 -53
  24. package/dist/commands/suite/delete.js +13 -25
  25. package/dist/commands/suite/export.js +20 -36
  26. package/dist/commands/suite/import.js +9 -20
  27. package/dist/commands/suite/use.js +9 -17
  28. package/dist/program.js +2 -0
  29. package/dist/utils/prompt.js +26 -0
  30. package/dist/utils/prompts.js +86 -118
  31. package/dist/utils/prune.js +70 -0
  32. package/package.json +3 -6
  33. package/scripts/release-notes.mjs +54 -0
  34. package/skills/config.md +17 -0
  35. package/skills/nemus/SKILL.md +7 -2
  36. package/skills/nemus/references/completion.md +22 -0
  37. package/skills/nemus/references/config.md +32 -0
  38. package/skills/nemus/references/prune.md +44 -0
  39. package/skills/nemus/references/reflect.md +43 -0
  40. package/skills/nemus/references/save-context.md +25 -0
  41. package/skills/prune-workspaces.md +23 -0
  42. package/skills/reflect.md +21 -0
  43. package/src/commands/analyze-deps.ts +5 -9
  44. package/src/commands/archive.ts +5 -7
  45. package/src/commands/branch/create.ts +5 -9
  46. package/src/commands/branch/switch.ts +14 -22
  47. package/src/commands/cache/manager.ts +13 -21
  48. package/src/commands/cleanup.ts +14 -23
  49. package/src/commands/configure-claude.ts +4 -5
  50. package/src/commands/configure.ts +35 -29
  51. package/src/commands/dashboard/session-picker.ts +6 -11
  52. package/src/commands/dashboard/workspace-picker.ts +6 -11
  53. package/src/commands/delete.test.ts +36 -42
  54. package/src/commands/delete.ts +15 -27
  55. package/src/commands/ghq-status.ts +5 -7
  56. package/src/commands/go.ts +18 -25
  57. package/src/commands/history.ts +6 -10
  58. package/src/commands/list.test.ts +19 -26
  59. package/src/commands/list.ts +24 -31
  60. package/src/commands/prune.ts +183 -0
  61. package/src/commands/remove-repo.ts +11 -17
  62. package/src/commands/save-context.ts +3 -5
  63. package/src/commands/sessions.ts +6 -10
  64. package/src/commands/suite/create.ts +28 -50
  65. package/src/commands/suite/delete.ts +14 -23
  66. package/src/commands/suite/export.ts +20 -33
  67. package/src/commands/suite/import.ts +9 -17
  68. package/src/commands/suite/use.ts +9 -14
  69. package/src/program.ts +2 -0
  70. package/src/utils/prompt.ts +16 -0
  71. package/src/utils/prompts.test.ts +70 -41
  72. package/src/utils/prompts.ts +98 -128
  73. package/src/utils/prune.test.ts +121 -0
  74. package/src/utils/prune.ts +109 -0
@@ -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
+ }