@mohshomis/ckpt 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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,259 @@
1
+ # ckpt
2
+
3
+ Automatic checkpoints for AI coding sessions. Per-step undo, branching, and restore — on top of git.
4
+
5
+ ```bash
6
+ ckpt watch # start watching — auto-snapshots every AI change
7
+ # ... let Kiro / Cursor / Claude Code / Codex do its thing ...
8
+ ckpt steps # see what happened, step by step
9
+ ckpt restore 3 # go back to step 3
10
+ ckpt end # squash into one clean git commit
11
+ ```
12
+
13
+ ## The problem
14
+
15
+ AI agents edit your code in rapid bursts — 5, 10, 20 files at once. When something breaks:
16
+
17
+ - **Undo everything** (Kiro/Cursor revert) — lose all the good changes too
18
+ - **Manually figure out** which change broke things — painful and slow
19
+
20
+ No per-step undo. No timeline. No way to try a different approach without losing the first one.
21
+
22
+ ### How AI agents handle errors today (and why it's wasteful)
23
+
24
+ When an AI agent breaks something, here's what actually happens:
25
+
26
+ 1. The agent notices the error (or you tell it)
27
+ 2. It re-reads the files it already wrote to understand the current state
28
+ 3. It reasons about what went wrong — burning tokens on analysis
29
+ 4. It rewrites the files — often re-generating code it already had right
30
+ 5. If the fix doesn't work, repeat steps 2-4 again. And again.
31
+
32
+ Every cycle costs tokens, time, and context window. A simple revert that should take milliseconds instead takes 30-60 seconds and hundreds of tokens as the agent manually reconstructs what it already had.
33
+
34
+ With ckpt: `ckpt restore 3` — instant rollback to the last good state. Zero tokens. Zero re-reading. Zero re-writing.
35
+
36
+ ## "But my IDE already has checkpoints"
37
+
38
+ Yes. Cursor has timeline. Kiro has revert. Windsurf has checkpoints. Here's what ckpt does differently:
39
+
40
+ ### 1. The AI agent can operate it — not just you
41
+
42
+ IDE checkpoints are buttons in a UI. The human clicks "revert." The AI agent can't.
43
+
44
+ With ckpt, the agent itself runs `ckpt restore 3`. It becomes self-correcting — it can checkpoint its own work, detect when something broke, roll back, and try a different approach. No human in the loop. No IDE UI needed.
45
+
46
+ This is the core difference. IDE checkpoints are a human safety net. ckpt is an AI capability.
47
+
48
+ ### 2. Terminal agents have nothing
49
+
50
+ Claude Code, Codex, Aider, and any agent running in a terminal have zero checkpoint support. No UI, no revert button, nothing. When they break something, they brute-force fix it by re-reading and rewriting — slow, expensive, and often makes things worse.
51
+
52
+ ckpt is the only checkpoint system that works for terminal-based agents.
53
+
54
+ ### 3. Branching — try multiple approaches
55
+
56
+ No IDE has this. `ckpt try approach-a -r 2` saves the current work, goes back to step 2, and lets the agent try a completely different approach. Then `ckpt trydiff approach-a` compares the two. The agent (or you) picks the better one.
57
+
58
+ IDEs give you undo. ckpt gives you branching exploration.
59
+
60
+ ### 4. Persistent history
61
+
62
+ IDE checkpoints disappear when you close the session or the IDE. `ckpt log` keeps every session permanently. Weeks later you can see exactly what the agent did, step by step.
63
+
64
+ ### 5. Works everywhere
65
+
66
+ IDE checkpoints only work inside that IDE. ckpt works in any terminal, any CI pipeline, any environment. It's just git.
67
+
68
+ | Feature | IDE checkpoints | ckpt |
69
+ |---------|----------------|------|
70
+ | AI agent can use it | ✗ | ✓ |
71
+ | Terminal agents (Codex, Aider, Claude Code) | ✗ | ✓ |
72
+ | Branch & compare approaches | ✗ | ✓ |
73
+ | Persistent history | ✗ | ✓ |
74
+ | Works outside IDE | ✗ | ✓ |
75
+ | Step tagging | ✗ | ✓ |
76
+ | Auto-snapshot | ✓ | ✓ |
77
+ | Per-step restore | ✓ | ✓ |
78
+
79
+ ## What happens when AI agents use ckpt
80
+
81
+ ckpt is a CLI. Every AI coding agent can already run shell commands. No plugins, no integrations, no MCP servers. Just tell the agent to use it.
82
+
83
+ Add this to your prompt or system instructions:
84
+
85
+ > Run `ckpt watch` in the background before starting work. It auto-snapshots every change you make. If something breaks, run `ckpt restore <step>` instead of manually rewriting. To try a different approach, run `ckpt try <name> -r <step>`. When done, run `ckpt end`.
86
+ >
87
+ > For richer snapshots with reasoning, use `ckpt snap "why you made this change"` after each logical step instead of relying on auto-snapshots.
88
+
89
+ Here's what changes:
90
+
91
+ ### Faster error recovery
92
+
93
+ Without ckpt: agent breaks something → re-reads files → reasons about the error → rewrites the fix → maybe breaks it again → repeat. 30-60 seconds per cycle.
94
+
95
+ With ckpt: `ckpt restore 3` → back to the last good state in milliseconds. Try a different approach immediately.
96
+
97
+ ### Cheaper sessions
98
+
99
+ Every time an agent re-reads and rewrites files to fix a mistake, that's tokens you're paying for. A typical error-fix cycle costs 500-2000 tokens just to get back to where you were. ckpt eliminates that entire cost — restore is free.
100
+
101
+ ### Better results through exploration
102
+
103
+ Without ckpt, agents commit to one approach and push forward. If it doesn't work, they patch on top of patches until the code is a mess.
104
+
105
+ With ckpt, an agent can:
106
+ 1. Try approach A → `ckpt snap "approach A: class-based"`
107
+ 2. Hit a wall → `ckpt try approach-a -r 1` (save A, go back)
108
+ 3. Try approach B → `ckpt snap "approach B: hooks-based"`
109
+ 4. Compare → `ckpt trydiff approach-a`
110
+ 5. Pick the better one
111
+
112
+ This is how good developers work. ckpt gives AI agents the same workflow.
113
+
114
+ ### Cleaner context windows
115
+
116
+ When an agent manually reverts by rewriting, it fills the context window with "oops, let me fix that" back-and-forth. With ckpt, a failed approach is `ckpt restore 3` — one line instead of 20 messages of debugging. The context stays clean for actual work.
117
+
118
+ ## Works with any AI agent
119
+
120
+ | Agent | Works? | How |
121
+ |-------|--------|-----|
122
+ | Kiro | ✓ | Runs shell commands natively |
123
+ | Cursor | ✓ | Runs shell commands natively |
124
+ | Claude Code | ✓ | Runs shell commands natively |
125
+ | OpenAI Codex | ✓ | Runs shell commands natively |
126
+ | GitHub Copilot | ✓ | Via terminal |
127
+ | Windsurf | ✓ | Runs shell commands natively |
128
+ | Aider | ✓ | Runs shell commands natively |
129
+ | Any human | ✓ | It's a CLI |
130
+
131
+ ## What ckpt does
132
+
133
+ ckpt watches your project while an AI agent works. Every time the agent pauses, ckpt takes a snapshot. You get a timeline of every step, and you can restore to any point.
134
+
135
+ It's just git under the hood — hidden branch, real commits, squash when done.
136
+
137
+ ## Install
138
+
139
+ ```bash
140
+ npm install -g @mohshomis/ckpt
141
+ ```
142
+
143
+ ## Usage
144
+
145
+ ### Auto mode (recommended)
146
+
147
+ ```bash
148
+ ckpt watch
149
+ ```
150
+
151
+ That's it. Let your AI agent work. ckpt snapshots automatically.
152
+
153
+ ### Manual mode
154
+
155
+ ```bash
156
+ ckpt start
157
+ ckpt snap "chose HS256 over RS256 because this is a monolith"
158
+ ckpt snap "updated tests — old ones used session cookies"
159
+ ckpt end -m "refactored auth to JWT"
160
+ ```
161
+
162
+ ### Restore
163
+
164
+ ```bash
165
+ ckpt restore 5 # go back to step 5
166
+ ckpt restore --last-good # go back to last step tagged "good"
167
+ ckpt restore --last working # go back to last step tagged "working"
168
+ ```
169
+
170
+ ### Tag steps
171
+
172
+ ```bash
173
+ ckpt tag 3 good # mark step 3 as good
174
+ ckpt tag 5 broken # mark step 5 as broken
175
+ ckpt tag 2 experiment # or any custom tag
176
+ ```
177
+
178
+ ### Try multiple approaches
179
+
180
+ ```bash
181
+ # AI tries approach A, gets to step 5
182
+ ckpt try approach-a -r 2 # save as branch, go back to step 2
183
+
184
+ # AI tries approach B from step 2
185
+ ckpt snap "approach B: used hooks instead of HOCs"
186
+
187
+ # Compare the two approaches
188
+ ckpt trydiff approach-a
189
+
190
+ # List all experiments
191
+ ckpt tries
192
+ ```
193
+
194
+ ### Range diff
195
+
196
+ ```bash
197
+ ckpt diff 3 # what changed at step 3
198
+ ckpt diff 2 7 # everything that changed between step 2 and step 7
199
+ ```
200
+
201
+ ### Session history
202
+
203
+ ```bash
204
+ ckpt log # list all past sessions
205
+ ckpt log --detail abc123 # see full step history for a past session
206
+ ```
207
+
208
+ ### End a session
209
+
210
+ ```bash
211
+ ckpt end -m "built auth system" # squash into one clean commit
212
+ ckpt end --discard # throw away everything
213
+ ```
214
+
215
+ ## All commands
216
+
217
+ | Command | What it does |
218
+ |---------|-------------|
219
+ | `ckpt watch` | Auto-snapshot file changes |
220
+ | `ckpt start` | Start a session manually |
221
+ | `ckpt snap <msg>` | Manual snapshot with a reason |
222
+ | `ckpt steps` | List all snapshots |
223
+ | `ckpt diff <step> [step]` | Diff for one step or between two |
224
+ | `ckpt why <step>` | Show why a step was made |
225
+ | `ckpt tag <step> <tag>` | Tag a step (good, broken, etc.) |
226
+ | `ckpt restore [step]` | Go back to a step |
227
+ | `ckpt restore --last-good` | Go back to last "good" step |
228
+ | `ckpt try <name> [-r step]` | Branch to try a different approach |
229
+ | `ckpt trydiff <name>` | Compare with an experiment branch |
230
+ | `ckpt tries` | List experiment branches |
231
+ | `ckpt end` | Squash into one commit |
232
+ | `ckpt log` | Show all past sessions |
233
+ | `ckpt status` | Current session info |
234
+
235
+ ## How it works
236
+
237
+ 1. `ckpt start` creates a hidden branch: `ckpt/session/<id>`
238
+ 2. Each snapshot = a real git commit on that branch
239
+ 3. `ckpt restore` = `git reset --hard` to that commit
240
+ 4. `ckpt try` = `git branch` at current HEAD
241
+ 5. `ckpt end` = `git merge --squash` back to your branch
242
+ 6. Session history saved to `.ckpt/history/`
243
+
244
+ No database. No new format. Just git.
245
+
246
+ ## Smart auto-labels
247
+
248
+ In watch mode, ckpt auto-categorizes changes:
249
+
250
+ ```
251
+ ✓ Step 1 [components] created Button.tsx, Modal.tsx
252
+ ✓ Step 2 [tests] modified Button.test.tsx
253
+ ✓ Step 3 [config] modified package.json
254
+ ✓ Step 4 [styles] modified globals.css
255
+ ```
256
+
257
+ ## License
258
+
259
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import path from 'path';
4
+ import * as core from './core.js';
5
+ import { startWatch } from './watch.js';
6
+ const program = new Command();
7
+ program
8
+ .name('ckpt')
9
+ .description('Automatic checkpoints for AI coding sessions. Per-step undo on top of git.')
10
+ .version('0.1.0')
11
+ .option('-C, --path <dir>', 'Run as if ckpt was started in <dir>');
12
+ function getCwd() {
13
+ const opts = program.opts();
14
+ return opts.path ? path.resolve(opts.path) : process.cwd();
15
+ }
16
+ // ── watch ─────────────────────────────────────────────────────────────────
17
+ program
18
+ .command('watch')
19
+ .description('Auto-snapshot file changes — just run this and let the AI work')
20
+ .option('-d, --debounce <ms>', 'Quiet period before snapshotting (ms)', '2000')
21
+ .action((opts) => {
22
+ startWatch(getCwd(), parseInt(opts.debounce));
23
+ });
24
+ // ── start ─────────────────────────────────────────────────────────────────
25
+ program
26
+ .command('start')
27
+ .description('Start a session manually (watch does this automatically)')
28
+ .action(() => {
29
+ try {
30
+ const session = core.startSession(getCwd());
31
+ console.log(`\n⚡ Session started: ${session.id}`);
32
+ console.log(` Branch: ${session.branch}\n`);
33
+ }
34
+ catch (e) {
35
+ console.error(`✗ ${e.message}`);
36
+ process.exit(1);
37
+ }
38
+ });
39
+ // ── snap ──────────────────────────────────────────────────────────────────
40
+ program
41
+ .command('snap <message>')
42
+ .description('Manual snapshot with a reason')
43
+ .action((message) => {
44
+ try {
45
+ const snapshot = core.snap(getCwd(), message);
46
+ if (snapshot) {
47
+ console.log(`\n✓ Step ${snapshot.id}: ${snapshot.message}`);
48
+ console.log(` ${snapshot.hash} | ${snapshot.filesChanged.length} files | +${snapshot.additions} -${snapshot.deletions}\n`);
49
+ }
50
+ else {
51
+ console.log('Nothing to snapshot — no changes.');
52
+ }
53
+ }
54
+ catch (e) {
55
+ console.error(`✗ ${e.message}`);
56
+ process.exit(1);
57
+ }
58
+ });
59
+ // ── steps ─────────────────────────────────────────────────────────────────
60
+ program
61
+ .command('steps')
62
+ .description('List all snapshots in the current session')
63
+ .action(() => {
64
+ try {
65
+ const snapshots = core.steps(getCwd());
66
+ if (snapshots.length === 0) {
67
+ console.log('\nNo snapshots yet.\n');
68
+ return;
69
+ }
70
+ console.log(`\n Steps (${snapshots.length}):\n`);
71
+ for (const s of snapshots) {
72
+ const time = new Date(s.timestamp).toLocaleTimeString();
73
+ const tag = s.tag ? ` [${s.tag}]` : '';
74
+ console.log(` ${s.id} ${s.hash} ${time} +${s.additions} -${s.deletions} ${s.message}${tag}`);
75
+ }
76
+ console.log();
77
+ }
78
+ catch (e) {
79
+ console.error(`✗ ${e.message}`);
80
+ process.exit(1);
81
+ }
82
+ });
83
+ // ── diff ──────────────────────────────────────────────────────────────────
84
+ program
85
+ .command('diff <from> [to]')
86
+ .description('Show diff for a step, or between two steps (ckpt diff 2 7)')
87
+ .action((from, to) => {
88
+ try {
89
+ console.log(core.diff(getCwd(), parseInt(from), to ? parseInt(to) : undefined));
90
+ }
91
+ catch (e) {
92
+ console.error(`✗ ${e.message}`);
93
+ process.exit(1);
94
+ }
95
+ });
96
+ // ── why ───────────────────────────────────────────────────────────────────
97
+ program
98
+ .command('why <step>')
99
+ .description('Show why a step was made')
100
+ .action((step) => {
101
+ try {
102
+ const info = core.why(getCwd(), parseInt(step));
103
+ const tag = info.tag ? ` [${info.tag}]` : '';
104
+ console.log(`\n Step ${step}: "${info.message}"${tag}\n`);
105
+ console.log(` Files: ${info.files.join(', ')}\n`);
106
+ console.log(info.diff);
107
+ }
108
+ catch (e) {
109
+ console.error(`✗ ${e.message}`);
110
+ process.exit(1);
111
+ }
112
+ });
113
+ // ── tag ───────────────────────────────────────────────────────────────────
114
+ program
115
+ .command('tag <step> <tag>')
116
+ .description('Tag a step (good, broken, experiment, or any custom tag)')
117
+ .action((step, tag) => {
118
+ try {
119
+ core.tagStep(getCwd(), parseInt(step), tag);
120
+ console.log(`\n✓ Step ${step} tagged as "${tag}".\n`);
121
+ }
122
+ catch (e) {
123
+ console.error(`✗ ${e.message}`);
124
+ process.exit(1);
125
+ }
126
+ });
127
+ // ── restore ───────────────────────────────────────────────────────────────
128
+ program
129
+ .command('restore [step]')
130
+ .description('Go back to a step, or --last-good to restore last "good" tagged step')
131
+ .option('--last-good', 'Restore to the last step tagged "good"')
132
+ .option('--last <tag>', 'Restore to the last step with this tag')
133
+ .action((step, opts) => {
134
+ try {
135
+ if (opts.lastGood) {
136
+ const id = core.restoreToTag(getCwd(), 'good');
137
+ console.log(`\n✓ Restored to step ${id} (last "good").\n`);
138
+ }
139
+ else if (opts.last) {
140
+ const id = core.restoreToTag(getCwd(), opts.last);
141
+ console.log(`\n✓ Restored to step ${id} (last "${opts.last}").\n`);
142
+ }
143
+ else if (step) {
144
+ core.restore(getCwd(), parseInt(step));
145
+ console.log(`\n✓ Restored to step ${step}.\n`);
146
+ }
147
+ else {
148
+ console.error('✗ Provide a step number, --last-good, or --last <tag>.');
149
+ process.exit(1);
150
+ }
151
+ }
152
+ catch (e) {
153
+ console.error(`✗ ${e.message}`);
154
+ process.exit(1);
155
+ }
156
+ });
157
+ // ── try ───────────────────────────────────────────────────────────────────
158
+ program
159
+ .command('try <name>')
160
+ .description('Save current progress as a named branch, optionally go back to try another approach')
161
+ .option('-r, --restore <step>', 'After branching, restore to this step to try a different approach')
162
+ .action((name, opts) => {
163
+ try {
164
+ const restoreTo = opts.restore ? parseInt(opts.restore) : undefined;
165
+ const branch = core.branchSession(getCwd(), name, restoreTo);
166
+ console.log(`\n✓ Saved as ${branch}`);
167
+ if (restoreTo)
168
+ console.log(` Restored to step ${restoreTo} — try a different approach.`);
169
+ console.log(` Compare later with: ckpt trydiff ${name}\n`);
170
+ }
171
+ catch (e) {
172
+ console.error(`✗ ${e.message}`);
173
+ process.exit(1);
174
+ }
175
+ });
176
+ // ── trydiff ───────────────────────────────────────────────────────────────
177
+ program
178
+ .command('trydiff <name>')
179
+ .description('Compare current state with a named experiment branch')
180
+ .action((name) => {
181
+ try {
182
+ console.log(core.branchDiff(getCwd(), name));
183
+ }
184
+ catch (e) {
185
+ console.error(`✗ ${e.message}`);
186
+ process.exit(1);
187
+ }
188
+ });
189
+ // ── tries ─────────────────────────────────────────────────────────────────
190
+ program
191
+ .command('tries')
192
+ .description('List all named experiment branches')
193
+ .action(() => {
194
+ try {
195
+ const branches = core.listBranches(getCwd());
196
+ if (branches.length === 0) {
197
+ console.log('\nNo experiment branches. Use "ckpt try <name>" to create one.\n');
198
+ return;
199
+ }
200
+ console.log(`\n Experiments (${branches.length}):\n`);
201
+ for (const b of branches)
202
+ console.log(` ${b}`);
203
+ console.log();
204
+ }
205
+ catch (e) {
206
+ console.error(`✗ ${e.message}`);
207
+ process.exit(1);
208
+ }
209
+ });
210
+ // ── end ───────────────────────────────────────────────────────────────────
211
+ program
212
+ .command('end')
213
+ .description('End session — squash all steps into one clean commit')
214
+ .option('-m, --message <msg>', 'Commit message')
215
+ .option('--discard', 'Throw away all changes instead of committing')
216
+ .action((opts) => {
217
+ try {
218
+ const result = core.endSession(getCwd(), opts.message, opts.discard);
219
+ console.log(`\n✓ ${result}\n`);
220
+ }
221
+ catch (e) {
222
+ console.error(`✗ ${e.message}`);
223
+ process.exit(1);
224
+ }
225
+ });
226
+ // ── status ────────────────────────────────────────────────────────────────
227
+ program
228
+ .command('status')
229
+ .description('Show current session info')
230
+ .action(() => {
231
+ const session = core.status(getCwd());
232
+ if (!session) {
233
+ console.log('\nNo active session. Run "ckpt watch" to start.\n');
234
+ return;
235
+ }
236
+ console.log(`\n Session: ${session.id}`);
237
+ console.log(` Branch: ${session.branch}`);
238
+ console.log(` Base: ${session.originalBranch}`);
239
+ console.log(` Steps: ${session.snapshots.length}`);
240
+ console.log(` Started: ${new Date(session.startedAt).toLocaleString()}\n`);
241
+ });
242
+ // ── log ───────────────────────────────────────────────────────────────────
243
+ program
244
+ .command('log')
245
+ .description('Show history of all past sessions')
246
+ .option('--detail <id>', 'Show full detail for a specific past session')
247
+ .action((opts) => {
248
+ try {
249
+ if (opts.detail) {
250
+ const s = core.logDetail(getCwd(), opts.detail);
251
+ const st = s.discarded ? '(discarded)' : `→ ${s.commitHash}`;
252
+ console.log(`\n Session ${s.id} ${st}`);
253
+ console.log(` Branch: ${s.originalBranch}`);
254
+ console.log(` Started: ${new Date(s.startedAt).toLocaleString()}`);
255
+ console.log(` Ended: ${new Date(s.endedAt).toLocaleString()}`);
256
+ console.log(` Steps: ${s.snapshots.length}\n`);
257
+ for (const snap of s.snapshots) {
258
+ const time = new Date(snap.timestamp).toLocaleTimeString();
259
+ const tag = snap.tag ? ` [${snap.tag}]` : '';
260
+ console.log(` ${snap.id} ${snap.hash} ${time} +${snap.additions} -${snap.deletions} ${snap.message}${tag}`);
261
+ }
262
+ console.log();
263
+ }
264
+ else {
265
+ const sessions = core.log(getCwd());
266
+ if (sessions.length === 0) {
267
+ console.log('\nNo session history yet.\n');
268
+ return;
269
+ }
270
+ console.log(`\n Session history (${sessions.length}):\n`);
271
+ for (const s of sessions) {
272
+ const date = new Date(s.startedAt).toLocaleDateString();
273
+ const time = new Date(s.startedAt).toLocaleTimeString();
274
+ const st = s.discarded ? 'discarded' : `→ ${s.commitHash}`;
275
+ console.log(` ${s.id} ${date} ${time} ${s.snapshots.length} steps ${st}`);
276
+ }
277
+ console.log(`\n Run "ckpt log --detail <id>" for full step history.\n`);
278
+ }
279
+ }
280
+ catch (e) {
281
+ console.error(`✗ ${e.message}`);
282
+ process.exit(1);
283
+ }
284
+ });
285
+ program.parse();
package/dist/core.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * ckpt core — automatic checkpoints for AI coding sessions, on top of git.
3
+ *
4
+ * Uses a hidden branch (ckpt/session/<id>) to store lightweight snapshots.
5
+ * Each snapshot is a real git commit on that branch, so all git tooling works.
6
+ * When you're done, squash into a single commit on your real branch.
7
+ */
8
+ export type StepTag = 'good' | 'broken' | 'experiment' | string;
9
+ export interface Snapshot {
10
+ id: number;
11
+ hash: string;
12
+ timestamp: string;
13
+ message: string;
14
+ filesChanged: string[];
15
+ additions: number;
16
+ deletions: number;
17
+ tag: StepTag | null;
18
+ }
19
+ export interface Session {
20
+ id: string;
21
+ branch: string;
22
+ originalBranch: string;
23
+ startedAt: string;
24
+ snapshots: Snapshot[];
25
+ }
26
+ export interface ArchivedSession {
27
+ id: string;
28
+ originalBranch: string;
29
+ startedAt: string;
30
+ endedAt: string;
31
+ commitHash: string | null;
32
+ discarded: boolean;
33
+ snapshots: Snapshot[];
34
+ }
35
+ export declare function startSession(cwd: string): Session;
36
+ export declare function snap(cwd: string, message: string): Snapshot | null;
37
+ export declare function steps(cwd: string): Snapshot[];
38
+ export declare function diff(cwd: string, fromStep: number, toStep?: number): string;
39
+ export declare function why(cwd: string, stepId: number): {
40
+ message: string;
41
+ diff: string;
42
+ files: string[];
43
+ tag: StepTag | null;
44
+ };
45
+ export declare function restore(cwd: string, stepId: number): void;
46
+ export declare function restoreToTag(cwd: string, tag?: StepTag): number;
47
+ export declare function tagStep(cwd: string, stepId: number, tag: StepTag): void;
48
+ export declare function branchSession(cwd: string, name: string, restoreToStep?: number): string;
49
+ export declare function branchDiff(cwd: string, name: string): string;
50
+ export declare function listBranches(cwd: string): string[];
51
+ export declare function endSession(cwd: string, commitMessage?: string, discard?: boolean): string;
52
+ export declare function status(cwd: string): Session | null;
53
+ export declare function log(cwd: string): ArchivedSession[];
54
+ export declare function logDetail(cwd: string, sessionId: string): ArchivedSession;
package/dist/core.js ADDED
@@ -0,0 +1,272 @@
1
+ /**
2
+ * ckpt core — automatic checkpoints for AI coding sessions, on top of git.
3
+ *
4
+ * Uses a hidden branch (ckpt/session/<id>) to store lightweight snapshots.
5
+ * Each snapshot is a real git commit on that branch, so all git tooling works.
6
+ * When you're done, squash into a single commit on your real branch.
7
+ */
8
+ import { execSync } from 'child_process';
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { randomUUID } from 'crypto';
12
+ const CKPT_DIR = '.ckpt';
13
+ const SESSION_FILE = 'session.json';
14
+ const HISTORY_DIR = 'history';
15
+ function git(cmd, cwd) {
16
+ return execSync(`git ${cmd}`, {
17
+ cwd,
18
+ encoding: 'utf8',
19
+ stdio: ['pipe', 'pipe', 'pipe'],
20
+ }).trim();
21
+ }
22
+ function gitSafe(cmd, cwd) {
23
+ try {
24
+ return git(cmd, cwd);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function ensureGitRepo(cwd) {
31
+ if (!gitSafe('rev-parse --git-dir', cwd)) {
32
+ throw new Error('Not a git repository. Run "git init" first.');
33
+ }
34
+ }
35
+ function sessionPath(cwd) {
36
+ return path.join(cwd, CKPT_DIR, SESSION_FILE);
37
+ }
38
+ function historyDir(cwd) {
39
+ return path.join(cwd, CKPT_DIR, HISTORY_DIR);
40
+ }
41
+ function loadSession(cwd) {
42
+ const p = sessionPath(cwd);
43
+ if (!fs.existsSync(p))
44
+ return null;
45
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
46
+ }
47
+ function saveSession(cwd, session) {
48
+ const dir = path.join(cwd, CKPT_DIR);
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ fs.writeFileSync(sessionPath(cwd), JSON.stringify(session, null, 2), 'utf8');
51
+ }
52
+ function ensureCkptIgnored(cwd) {
53
+ const gitignorePath = path.join(cwd, '.gitignore');
54
+ const gitignore = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
55
+ if (!gitignore.includes('.ckpt')) {
56
+ fs.appendFileSync(gitignorePath, '\n.ckpt/\n');
57
+ }
58
+ }
59
+ function archiveSession(cwd, session, commitHash, discarded) {
60
+ const dir = historyDir(cwd);
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ const archived = {
63
+ id: session.id,
64
+ originalBranch: session.originalBranch,
65
+ startedAt: session.startedAt,
66
+ endedAt: new Date().toISOString(),
67
+ commitHash,
68
+ discarded,
69
+ snapshots: session.snapshots,
70
+ };
71
+ fs.writeFileSync(path.join(dir, `${session.id}.json`), JSON.stringify(archived, null, 2), 'utf8');
72
+ }
73
+ // ── Public API ────────────────────────────────────────────────────────────
74
+ export function startSession(cwd) {
75
+ ensureGitRepo(cwd);
76
+ const existing = loadSession(cwd);
77
+ if (existing) {
78
+ throw new Error(`Session already active (${existing.id}). Run "ckpt end" or "ckpt end --discard" first.`);
79
+ }
80
+ const status = git('status --porcelain', cwd);
81
+ if (status) {
82
+ git('stash push -m "ckpt: auto-stash before session"', cwd);
83
+ }
84
+ const originalBranch = git('rev-parse --abbrev-ref HEAD', cwd);
85
+ const sessionId = randomUUID().slice(0, 8);
86
+ const branchName = `ckpt/session/${sessionId}`;
87
+ git(`checkout -b ${branchName}`, cwd);
88
+ if (status) {
89
+ gitSafe('stash pop', cwd);
90
+ }
91
+ const session = {
92
+ id: sessionId,
93
+ branch: branchName,
94
+ originalBranch,
95
+ startedAt: new Date().toISOString(),
96
+ snapshots: [],
97
+ };
98
+ ensureCkptIgnored(cwd);
99
+ saveSession(cwd, session);
100
+ return session;
101
+ }
102
+ export function snap(cwd, message) {
103
+ ensureGitRepo(cwd);
104
+ const session = loadSession(cwd);
105
+ if (!session) {
106
+ throw new Error('No active session. Run "ckpt start" first.');
107
+ }
108
+ git('add -A', cwd);
109
+ const status = git('status --porcelain', cwd);
110
+ if (!status)
111
+ return null;
112
+ const diffFiles = gitSafe('diff --cached --name-only', cwd) ?? '';
113
+ const filesChanged = diffFiles.split('\n').filter(Boolean);
114
+ const numstat = gitSafe('diff --cached --numstat', cwd) ?? '';
115
+ let additions = 0;
116
+ let deletions = 0;
117
+ for (const line of numstat.split('\n').filter(Boolean)) {
118
+ const [add, del] = line.split('\t');
119
+ additions += parseInt(add) || 0;
120
+ deletions += parseInt(del) || 0;
121
+ }
122
+ const stepNum = session.snapshots.length + 1;
123
+ const commitMsg = `ckpt[${stepNum}]: ${message}`;
124
+ git(`commit -m "${commitMsg.replace(/"/g, '\\"')}"`, cwd);
125
+ const hash = git('rev-parse --short HEAD', cwd);
126
+ const snapshot = {
127
+ id: stepNum,
128
+ hash,
129
+ timestamp: new Date().toISOString(),
130
+ message,
131
+ filesChanged,
132
+ additions,
133
+ deletions,
134
+ tag: null,
135
+ };
136
+ session.snapshots.push(snapshot);
137
+ saveSession(cwd, session);
138
+ return snapshot;
139
+ }
140
+ export function steps(cwd) {
141
+ const session = loadSession(cwd);
142
+ if (!session)
143
+ throw new Error('No active session. Run "ckpt start" first.');
144
+ return session.snapshots;
145
+ }
146
+ export function diff(cwd, fromStep, toStep) {
147
+ const session = loadSession(cwd);
148
+ if (!session)
149
+ throw new Error('No active session.');
150
+ if (toStep !== undefined) {
151
+ const from = session.snapshots.find((s) => s.id === fromStep);
152
+ const to = session.snapshots.find((s) => s.id === toStep);
153
+ if (!from)
154
+ throw new Error(`Step ${fromStep} not found.`);
155
+ if (!to)
156
+ throw new Error(`Step ${toStep} not found.`);
157
+ return git(`diff ${from.hash} ${to.hash}`, cwd);
158
+ }
159
+ const snapshot = session.snapshots.find((s) => s.id === fromStep);
160
+ if (!snapshot)
161
+ throw new Error(`Step ${fromStep} not found.`);
162
+ return git(`diff ${snapshot.hash}~1 ${snapshot.hash}`, cwd);
163
+ }
164
+ export function why(cwd, stepId) {
165
+ const session = loadSession(cwd);
166
+ if (!session)
167
+ throw new Error('No active session.');
168
+ const snapshot = session.snapshots.find((s) => s.id === stepId);
169
+ if (!snapshot)
170
+ throw new Error(`Step ${stepId} not found.`);
171
+ const d = git(`diff ${snapshot.hash}~1 ${snapshot.hash} --stat`, cwd);
172
+ return { message: snapshot.message, diff: d, files: snapshot.filesChanged, tag: snapshot.tag };
173
+ }
174
+ export function restore(cwd, stepId) {
175
+ const session = loadSession(cwd);
176
+ if (!session)
177
+ throw new Error('No active session.');
178
+ const snapshot = session.snapshots.find((s) => s.id === stepId);
179
+ if (!snapshot)
180
+ throw new Error(`Step ${stepId} not found.`);
181
+ git(`reset --hard ${snapshot.hash}`, cwd);
182
+ session.snapshots = session.snapshots.filter((s) => s.id <= stepId);
183
+ saveSession(cwd, session);
184
+ }
185
+ export function restoreToTag(cwd, tag = 'good') {
186
+ const session = loadSession(cwd);
187
+ if (!session)
188
+ throw new Error('No active session.');
189
+ const tagged = session.snapshots.filter((s) => s.tag === tag);
190
+ if (tagged.length === 0)
191
+ throw new Error(`No steps tagged "${tag}".`);
192
+ const last = tagged[tagged.length - 1];
193
+ restore(cwd, last.id);
194
+ return last.id;
195
+ }
196
+ export function tagStep(cwd, stepId, tag) {
197
+ const session = loadSession(cwd);
198
+ if (!session)
199
+ throw new Error('No active session.');
200
+ const snapshot = session.snapshots.find((s) => s.id === stepId);
201
+ if (!snapshot)
202
+ throw new Error(`Step ${stepId} not found.`);
203
+ snapshot.tag = tag;
204
+ saveSession(cwd, session);
205
+ }
206
+ export function branchSession(cwd, name, restoreToStep) {
207
+ const session = loadSession(cwd);
208
+ if (!session)
209
+ throw new Error('No active session.');
210
+ const branchName = `ckpt/try/${name}`;
211
+ git(`branch ${branchName}`, cwd);
212
+ if (restoreToStep !== undefined) {
213
+ restore(cwd, restoreToStep);
214
+ }
215
+ return branchName;
216
+ }
217
+ export function branchDiff(cwd, name) {
218
+ return git(`diff HEAD ckpt/try/${name}`, cwd);
219
+ }
220
+ export function listBranches(cwd) {
221
+ const raw = gitSafe('branch --list "ckpt/try/*"', cwd) ?? '';
222
+ return raw.split('\n').map((b) => b.trim()).filter(Boolean);
223
+ }
224
+ export function endSession(cwd, commitMessage, discard = false) {
225
+ const session = loadSession(cwd);
226
+ if (!session)
227
+ throw new Error('No active session.');
228
+ const snapshotCount = session.snapshots.length;
229
+ if (discard || snapshotCount === 0) {
230
+ git(`checkout ${session.originalBranch}`, cwd);
231
+ gitSafe(`branch -D ${session.branch}`, cwd);
232
+ archiveSession(cwd, session, null, true);
233
+ cleanup(cwd);
234
+ return discard ? 'Session discarded. All changes dropped.' : 'Session ended with no changes.';
235
+ }
236
+ const msg = commitMessage ?? session.snapshots.map((s) => `- ${s.message}`).join('\n');
237
+ git(`checkout ${session.originalBranch}`, cwd);
238
+ git(`merge --squash ${session.branch}`, cwd);
239
+ git(`commit -m "${msg.replace(/"/g, '\\"')}"`, cwd);
240
+ const commitHash = git('rev-parse --short HEAD', cwd);
241
+ gitSafe(`branch -D ${session.branch}`, cwd);
242
+ for (const b of listBranches(cwd)) {
243
+ gitSafe(`branch -D ${b}`, cwd);
244
+ }
245
+ archiveSession(cwd, session, commitHash, false);
246
+ cleanup(cwd);
247
+ return `Committed ${snapshotCount} steps as one commit on ${session.originalBranch}.`;
248
+ }
249
+ export function status(cwd) {
250
+ return loadSession(cwd);
251
+ }
252
+ // ── History ───────────────────────────────────────────────────────────────
253
+ export function log(cwd) {
254
+ const dir = historyDir(cwd);
255
+ if (!fs.existsSync(dir))
256
+ return [];
257
+ return fs.readdirSync(dir)
258
+ .filter((f) => f.endsWith('.json'))
259
+ .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')))
260
+ .sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
261
+ }
262
+ export function logDetail(cwd, sessionId) {
263
+ const filePath = path.join(historyDir(cwd), `${sessionId}.json`);
264
+ if (!fs.existsSync(filePath))
265
+ throw new Error(`Session "${sessionId}" not found in history.`);
266
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
267
+ }
268
+ function cleanup(cwd) {
269
+ const p = sessionPath(cwd);
270
+ if (fs.existsSync(p))
271
+ fs.unlinkSync(p);
272
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * ckpt watch — auto-snapshot mode.
3
+ *
4
+ * Watches the project for file changes. After a 2-second quiet period
5
+ * (no new changes), it auto-snapshots with a smart description.
6
+ * Zero friction — just run "ckpt watch" and forget about it.
7
+ */
8
+ export declare function startWatch(cwd: string, debounceMs?: number): void;
package/dist/watch.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ckpt watch — auto-snapshot mode.
3
+ *
4
+ * Watches the project for file changes. After a 2-second quiet period
5
+ * (no new changes), it auto-snapshots with a smart description.
6
+ * Zero friction — just run "ckpt watch" and forget about it.
7
+ */
8
+ import chokidar from 'chokidar';
9
+ import path from 'path';
10
+ import * as core from './core.js';
11
+ export function startWatch(cwd, debounceMs = 2000) {
12
+ let session = core.status(cwd);
13
+ if (!session) {
14
+ session = core.startSession(cwd);
15
+ console.log(`⚡ Session started: ${session.id}`);
16
+ }
17
+ else {
18
+ console.log(`⚡ Resuming session: ${session.id}`);
19
+ }
20
+ console.log(`👀 Watching ${cwd}`);
21
+ console.log(` Auto-snapshot after ${debounceMs / 1000}s of quiet.`);
22
+ console.log(` Press Ctrl+C to stop.\n`);
23
+ let pending = [];
24
+ let timer = null;
25
+ const watcher = chokidar.watch(cwd, {
26
+ ignored: [
27
+ /(^|[/\\])\./,
28
+ /node_modules/,
29
+ /dist\//,
30
+ /build\//,
31
+ ],
32
+ persistent: true,
33
+ ignoreInitial: true,
34
+ });
35
+ const flush = () => {
36
+ if (pending.length === 0)
37
+ return;
38
+ const events = [...pending];
39
+ pending = [];
40
+ const label = buildSmartLabel(events);
41
+ try {
42
+ const snapshot = core.snap(cwd, label);
43
+ if (snapshot) {
44
+ const time = new Date().toLocaleTimeString();
45
+ console.log(` ✓ Step ${snapshot.id} ${time} +${snapshot.additions} -${snapshot.deletions} ${label}`);
46
+ }
47
+ }
48
+ catch (e) {
49
+ if (!e.message.includes('Nothing to snapshot')) {
50
+ console.error(` ✗ ${e.message}`);
51
+ }
52
+ }
53
+ };
54
+ const onEvent = (type) => (filePath) => {
55
+ pending.push({ type, file: path.relative(cwd, filePath) });
56
+ if (timer)
57
+ clearTimeout(timer);
58
+ timer = setTimeout(flush, debounceMs);
59
+ };
60
+ watcher
61
+ .on('add', onEvent('add'))
62
+ .on('change', onEvent('change'))
63
+ .on('unlink', onEvent('unlink'));
64
+ const shutdown = () => {
65
+ console.log('\n⏹ Stopping watch...');
66
+ if (timer)
67
+ clearTimeout(timer);
68
+ if (pending.length > 0)
69
+ flush();
70
+ watcher.close().then(() => {
71
+ const s = core.status(cwd);
72
+ const count = s?.snapshots.length ?? 0;
73
+ console.log(`\n Session ${session.id}: ${count} steps recorded.`);
74
+ console.log(` Run "ckpt steps" to review.`);
75
+ console.log(` Run "ckpt restore <step>" to go back.`);
76
+ console.log(` Run "ckpt end" to commit everything.\n`);
77
+ process.exit(0);
78
+ });
79
+ };
80
+ process.on('SIGINT', shutdown);
81
+ process.on('SIGTERM', shutdown);
82
+ }
83
+ // ── Smart labeling ────────────────────────────────────────────────────────
84
+ const CATEGORY_MAP = {
85
+ 'tests': ['.test.', '.spec.', '__tests__', 'test/', 'tests/'],
86
+ 'styles': ['.css', '.scss', '.less', '.styled.'],
87
+ 'config': ['package.json', 'tsconfig', '.eslint', '.prettier', 'vite.config', 'webpack.config', '.env'],
88
+ 'components': ['/components/', '.tsx', '.jsx'],
89
+ 'api': ['/api/', '/routes/', '/controllers/', '/handlers/'],
90
+ 'types': ['.d.ts', '/types/', '/interfaces/'],
91
+ 'docs': ['.md', 'README', 'CHANGELOG', 'LICENSE'],
92
+ 'deps': ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'],
93
+ };
94
+ function categorizeFile(file) {
95
+ const lower = file.toLowerCase();
96
+ for (const [category, patterns] of Object.entries(CATEGORY_MAP)) {
97
+ if (patterns.some((p) => lower.includes(p)))
98
+ return category;
99
+ }
100
+ return null;
101
+ }
102
+ function buildSmartLabel(events) {
103
+ const added = events.filter((e) => e.type === 'add');
104
+ const changed = events.filter((e) => e.type === 'change');
105
+ const deleted = events.filter((e) => e.type === 'unlink');
106
+ const categories = new Set(events.map((e) => categorizeFile(e.file)).filter(Boolean));
107
+ let prefix = '';
108
+ if (categories.size === 1)
109
+ prefix = `[${[...categories][0]}] `;
110
+ else if (categories.size > 1)
111
+ prefix = `[${[...categories].join(', ')}] `;
112
+ const parts = [];
113
+ if (added.length > 0)
114
+ parts.push(added.length <= 2 ? `created ${added.map((e) => path.basename(e.file)).join(', ')}` : `created ${added.length} files`);
115
+ if (changed.length > 0)
116
+ parts.push(changed.length <= 2 ? `modified ${changed.map((e) => path.basename(e.file)).join(', ')}` : `modified ${changed.length} files`);
117
+ if (deleted.length > 0)
118
+ parts.push(deleted.length <= 2 ? `deleted ${deleted.map((e) => path.basename(e.file)).join(', ')}` : `deleted ${deleted.length} files`);
119
+ return prefix + (parts.join(', ') || 'file changes');
120
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@mohshomis/ckpt",
3
+ "version": "0.1.0",
4
+ "description": "Automatic checkpoints for AI coding sessions. Per-step undo, branching, and restore — on top of git.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ckpt": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsc --watch"
12
+ },
13
+ "keywords": [
14
+ "git", "ai", "checkpoint", "undo", "version-control",
15
+ "cursor", "kiro", "claude", "copilot", "coding-agent",
16
+ "snapshot", "restore", "diff", "developer-tools"
17
+ ],
18
+ "author": "mohshomis",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/mohshomis/ckpt"
23
+ },
24
+ "homepage": "https://github.com/mohshomis/ckpt#readme",
25
+ "dependencies": {
26
+ "chokidar": "^3.6.0",
27
+ "commander": "^12.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^5.5.0",
31
+ "@types/node": "^20.0.0"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ]
38
+ }