@diffin/cli 0.0.0-stage → 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 +22 -0
- package/README.md +82 -2
- package/bin/diffin.mjs +69 -0
- package/lib/agents.mjs +56 -0
- package/lib/backfill.mjs +316 -0
- package/lib/capture.mjs +443 -0
- package/lib/command.mjs +44 -0
- package/lib/creds.mjs +35 -0
- package/lib/folders.mjs +76 -0
- package/lib/ignore.mjs +208 -0
- package/lib/init.mjs +114 -0
- package/lib/journey.mjs +269 -0
- package/lib/login.mjs +25 -0
- package/lib/machine.mjs +45 -0
- package/lib/net.mjs +87 -0
- package/lib/onboard.mjs +74 -0
- package/lib/scrub.mjs +513 -0
- package/lib/settings.mjs +46 -0
- package/lib/sources/antigravity.mjs +178 -0
- package/lib/sources/claude-code.mjs +107 -0
- package/lib/sources/codex.mjs +230 -0
- package/lib/sources/hermes.mjs +170 -0
- package/lib/sources/index.mjs +29 -0
- package/lib/sources/project-dir.mjs +13 -0
- package/lib/state.mjs +113 -0
- package/lib/status.mjs +71 -0
- package/lib/stream.mjs +67 -0
- package/lib/ui.mjs +220 -0
- package/lib/watch.mjs +164 -0
- package/package.json +21 -4
- package/templates/skills/capture/SKILL.md +107 -0
package/lib/ui.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Every line the CLI prints goes through here, so it speaks with one voice: a section
|
|
2
|
+
// per step, one aligned row per fact, a short closing line, and errors that say what
|
|
3
|
+
// happened and what to do — never a JSON dump. Colour only on a TTY and never when
|
|
4
|
+
// NO_COLOR is set; everything goes to stderr so stdout stays clean for pipes.
|
|
5
|
+
//
|
|
6
|
+
// The accent is the product's own (the graph view's `hsl(258 88% 66%)`), written as a
|
|
7
|
+
// 24-bit escape where the terminal says it can show one and as plain magenta where it
|
|
8
|
+
// does not. Everything else is ink on paper: bold, dim, and a verdict colour.
|
|
9
|
+
const tty = Boolean(process.stderr.isTTY) && !process.env.NO_COLOR;
|
|
10
|
+
const paint = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
11
|
+
export const bold = paint(1), dim = paint(2), green = paint(32), yellow = paint(33), red = paint(31);
|
|
12
|
+
const truecolor = /truecolor|24bit/i.test(process.env.COLORTERM ?? '');
|
|
13
|
+
export const accent = paint(truecolor ? '38;2;143;92;246' : '35');
|
|
14
|
+
|
|
15
|
+
export const out = (s = '') => console.error(s);
|
|
16
|
+
|
|
17
|
+
/** the wordmark, once, at the top of a command that has several steps */
|
|
18
|
+
export const brand = () => out(`${accent('◆')} ${bold('diffin')}`);
|
|
19
|
+
|
|
20
|
+
/** a step header: `diffin 로그인` with an optional dimmed note */
|
|
21
|
+
export function section(title, note) {
|
|
22
|
+
out(`${accent('diffin')} ${bold(title)}${note ? dim(` ${note}`) : ''}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Korean glyphs are two columns wide; padEnd counts code units. Width-aware pad so
|
|
26
|
+
// columns line up whether a cell says "Claude Code" or "최신". Escape sequences are
|
|
27
|
+
// zero-width: a painted cell must still pad to what the eye sees, or every row after
|
|
28
|
+
// the first coloured one is out by the length of an escape.
|
|
29
|
+
const BARE = /\x1b\[[0-9;]*m/g;
|
|
30
|
+
export const width = (s) => [...String(s).replace(BARE, '')]
|
|
31
|
+
.reduce((w, ch) => w + (/[ᄀ-ᇿ -鿿가--]/.test(ch) ? 2 : 1), 0);
|
|
32
|
+
export const pad = (s, n) => String(s) + ' '.repeat(Math.max(0, n - width(s)));
|
|
33
|
+
|
|
34
|
+
/** one aligned fact: label · value · trailing status */
|
|
35
|
+
export function row(label, value = '', status = '') {
|
|
36
|
+
out(` ${pad(label, 14)}${pad(value, 40)}${status}`.replace(/\s+$/, ''));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** rewrite the current line (progress). Off a TTY there is no line to rewrite, and a progress bar written once per
|
|
40
|
+
* file was 638 lines in a log for one upload — so it says nothing there, and the result line that follows is the record */
|
|
41
|
+
export function live(text) { if (tty) process.stderr.write(`\r\x1b[2K${text}`); }
|
|
42
|
+
/** take the progress line away, so what is printed next stands where it was */
|
|
43
|
+
export const liveClear = () => { if (tty) process.stderr.write('\r\x1b[2K'); };
|
|
44
|
+
|
|
45
|
+
// A bar is a shape, not a number: it says "this is moving and roughly how far in".
|
|
46
|
+
// The exact count rides beside it, because a bar cannot be read aloud or grepped.
|
|
47
|
+
const BLOCK = '█', TRACK = '░';
|
|
48
|
+
export function bar(done, total, cells = 16) {
|
|
49
|
+
if (!(total > 0)) return dim(TRACK.repeat(cells));
|
|
50
|
+
const filled = Math.max(0, Math.min(cells, Math.round((done / total) * cells)));
|
|
51
|
+
return accent(BLOCK.repeat(filled)) + dim(TRACK.repeat(cells - filled));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A spinner is the only thing that says "the wait is ours, not yours" while a request
|
|
55
|
+
// is open. Off a TTY there is nothing to rewrite, so the label is printed once and the
|
|
56
|
+
// ticker never starts — a log file must not collect 600 copies of one line.
|
|
57
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
58
|
+
export function spin(render, ms = 90) {
|
|
59
|
+
const text = typeof render === 'function' ? render : () => String(render);
|
|
60
|
+
if (!tty) { process.stderr.write(`${text('·')}\n`); return () => {}; }
|
|
61
|
+
let i = 0;
|
|
62
|
+
const tick = () => live(text(accent(FRAMES[i++ % FRAMES.length])));
|
|
63
|
+
tick();
|
|
64
|
+
const timer = setInterval(tick, ms);
|
|
65
|
+
timer.unref?.();
|
|
66
|
+
// Stopping ERASES the line rather than ending it: a spinner is a statement about
|
|
67
|
+
// right now, and the result that follows belongs where the waiting was, not under a
|
|
68
|
+
// frozen frame of it.
|
|
69
|
+
return () => { clearInterval(timer); process.stderr.write('\r\x1b[2K'); };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** a short duration a person reads without converting: 48초 · 3분 12초 */
|
|
73
|
+
export function since(ms) {
|
|
74
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
75
|
+
return s < 60 ? `${s}초` : `${Math.floor(s / 60)}분 ${String(s % 60).padStart(2, '0')}초`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// One clock for everything a person reads: THEIR time. A transcript's stamp and the server's are UTC strings, and
|
|
79
|
+
// slicing one printed a preview nine hours off the "next record" line under it, on the same screen. A date is
|
|
80
|
+
// 「10월 4일」, a moment 「10월 4일 03:12」, a time inside a listed day 「03:12」.
|
|
81
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
82
|
+
export const clock = (d) => { const t = new Date(d); return Number.isFinite(t.getTime()) ? `${two(t.getHours())}:${two(t.getMinutes())}` : ''; };
|
|
83
|
+
export const dayOf = (d) => { const t = new Date(d); return Number.isFinite(t.getTime()) ? `${t.getMonth() + 1}월 ${t.getDate()}일` : ''; };
|
|
84
|
+
export const when = (d) => `${dayOf(d)} ${clock(d)}`.trim();
|
|
85
|
+
|
|
86
|
+
// ---- steps and the card ----------------------------------------------------------
|
|
87
|
+
// A command with several stages reads as a short list: one line a stage, a mark in front that says where it stands
|
|
88
|
+
// (done, running, could not finish), the one fact worth knowing beside it, anything else dimmed at the end. The first
|
|
89
|
+
// onboarding printed sections of rows — a path, a version, a file name per line, all the same weight — and nothing in
|
|
90
|
+
// it said which line was the result. A step line is what the eye runs down; the card under it is what it stops on.
|
|
91
|
+
const STEP_TITLE = 18, STEP_VALUE = 44;
|
|
92
|
+
// a value wider than its column still keeps two spaces before what follows it
|
|
93
|
+
const stepLine = (mark, title, value = '', tail = '') => ` ${mark} ${pad(title, STEP_TITLE)}${tail ? pad(`${value} `, STEP_VALUE) : value}${tail}`.replace(/\s+$/, '');
|
|
94
|
+
export const stepDone = (title, value, tail) => out(stepLine(green('✓'), title, value, tail));
|
|
95
|
+
export const stepSkip = (title, value, tail) => out(stepLine(dim('○'), title, value, tail));
|
|
96
|
+
export const stepFail = (title, value, tail) => out(stepLine(red('✗'), title, value, tail));
|
|
97
|
+
export const stepWarn = (title, value, tail) => out(stepLine(yellow('!'), title, value, tail));
|
|
98
|
+
/** a fact with no verdict: no mark, the same columns */
|
|
99
|
+
export const stepInfo = (title, value, tail) => out(stepLine(' ', title, value, tail));
|
|
100
|
+
/** a sentence that is the whole answer of a command, with what follows from it dimmed underneath */
|
|
101
|
+
export function say(mark, text, ...more) { out(` ${mark} ${text}`); for (const m of more) out(dim(` ${m}`)); }
|
|
102
|
+
export const marks = { done: () => green('✓'), wait: () => dim('○'), warn: () => yellow('!'), now: () => accent('●') };
|
|
103
|
+
/** the running form, for `live()` / `spin()`: the mark is the caller's (a spinner frame, or a dot) */
|
|
104
|
+
export const stepLive = (mark, title, value) => stepLine(mark, title, value);
|
|
105
|
+
export const dot = () => accent('●');
|
|
106
|
+
|
|
107
|
+
/** the wordmark and the version, once, at the top of the onboarding */
|
|
108
|
+
export const banner = (version) => { out(); out(` ${accent('◆')} ${bold('diffin')}${version ? dim(` v${version}`) : ''}`); out(); };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A boxed card: the one place a person's eye should land when a command with several steps is over. `lines` are
|
|
112
|
+
* strings ('' is a blank line); the first is the title. Width follows the widest line, counted the way the eye
|
|
113
|
+
* counts (a Korean glyph is two columns, an escape none).
|
|
114
|
+
*/
|
|
115
|
+
export function card(lines) {
|
|
116
|
+
const inner = Math.max(...lines.map((l) => width(l))) + 4;
|
|
117
|
+
out(` ${dim('╭' + '─'.repeat(inner) + '╮')}`);
|
|
118
|
+
lines.forEach((l, i) => out(` ${dim('│')} ${pad(i === 0 ? bold(l) : l, inner - 2)}${dim('│')}`));
|
|
119
|
+
out(` ${dim('╰' + '─'.repeat(inner) + '╯')}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- a list to choose from ---------------------------------------------------------
|
|
123
|
+
// A tree, two levels deep on screen: a top folder and, when opened, every folder under it. Everything starts selected.
|
|
124
|
+
// ↑↓ move · space switches the line under the cursor · →/← open and close a folder · a switches all · enter confirms ·
|
|
125
|
+
// q or Ctrl-C cancels. Switching a folder off switches off everything under it — that is what excluding a folder
|
|
126
|
+
// means — and switching one on switches its parents on, since a folder cannot be recorded inside one that is not.
|
|
127
|
+
//
|
|
128
|
+
// `items` are in display order: { label, tail, parent } with `parent` the index of the item it sits under (null for a
|
|
129
|
+
// top folder). The state and the frame are plain functions (tested); `selectMany` only moves keys in and lines out.
|
|
130
|
+
const kids = (items, i) => items.map((it, k) => k).filter((k) => { for (let p = items[k].parent; p != null; p = items[p].parent) if (p === i) return true; return false; });
|
|
131
|
+
const topOf = (items, i) => { let t = i; while (items[t].parent != null) t = items[t].parent; return t; };
|
|
132
|
+
export const selectVisible = (items, st) => items.map((_, i) => i).filter((i) => items[i].parent == null || st.open.includes(topOf(items, i)));
|
|
133
|
+
export function selectStep(items, st, key) {
|
|
134
|
+
const vis = selectVisible(items, st);
|
|
135
|
+
const pos = Math.max(0, vis.indexOf(st.at));
|
|
136
|
+
const here = vis[pos];
|
|
137
|
+
if (key === 'up') return { ...st, at: vis[(pos - 1 + vis.length) % vis.length] };
|
|
138
|
+
if (key === 'down') return { ...st, at: vis[(pos + 1) % vis.length] };
|
|
139
|
+
if (key === 'open') { const t = topOf(items, here); return kids(items, t).length && !st.open.includes(t) ? { ...st, open: [...st.open, t] } : st; }
|
|
140
|
+
if (key === 'close') { const t = topOf(items, here); return st.open.includes(t) ? { ...st, open: st.open.filter((x) => x !== t), at: t } : st; }
|
|
141
|
+
if (key === 'space') {
|
|
142
|
+
const v = !st.on[here];
|
|
143
|
+
const on = [...st.on];
|
|
144
|
+
on[here] = v; for (const k of kids(items, here)) on[k] = v;
|
|
145
|
+
if (v) for (let p = items[here].parent; p != null; p = items[p].parent) on[p] = true;
|
|
146
|
+
return { ...st, on };
|
|
147
|
+
}
|
|
148
|
+
if (key === 'all') { const every = st.on.every(Boolean); return { ...st, on: st.on.map(() => !every) }; }
|
|
149
|
+
return st;
|
|
150
|
+
}
|
|
151
|
+
/** the lines that tie a folder to the one it sits under: ├─ / └─ for the item, │ carried down past its parents' later siblings */
|
|
152
|
+
function branch(items, i) {
|
|
153
|
+
if (items[i].parent == null) return '';
|
|
154
|
+
const lastOf = (k) => !items.some((it, j) => j > k && it.parent === items[k].parent);
|
|
155
|
+
let lead = lastOf(i) ? '└─ ' : '├─ ';
|
|
156
|
+
for (let p = items[i].parent; items[p].parent != null; p = items[p].parent) lead = (lastOf(p) ? ' ' : '│ ') + lead;
|
|
157
|
+
return dim(lead);
|
|
158
|
+
}
|
|
159
|
+
export function selectFrame({ title, note, items, unit }, st) {
|
|
160
|
+
const vis = selectVisible(items, st);
|
|
161
|
+
const depth = (i) => { let d = 0; for (let p = items[i].parent; p != null; p = items[p].parent) d++; return d; };
|
|
162
|
+
const w = Math.max(...vis.map((i) => width(items[i].label) + depth(i) * 3)) + 3;
|
|
163
|
+
const lines = ['', ` ${bold(title)}${note ? dim(` ${note}`) : ''}`, ''];
|
|
164
|
+
for (const i of vis) {
|
|
165
|
+
const under = kids(items, i);
|
|
166
|
+
const part = st.on[i] && under.some((k) => !st.on[k]);
|
|
167
|
+
// a checkbox, not a dot: three states have to read apart at a glance, in any font — a filled, half and empty
|
|
168
|
+
// circle did not (the owner's screenshot, 2026-10-03)
|
|
169
|
+
const box = !st.on[i] ? dim('[ ]') : part ? yellow('[-]') : green('[✓]');
|
|
170
|
+
const name = `${branch(items, i)}${st.on[i] ? items[i].label : dim(items[i].label)}`;
|
|
171
|
+
const fold = items[i].parent == null && under.length ? dim(st.open.includes(i) ? ' ← 접기' : ` → 하위 폴더 ${under.length}곳`) : '';
|
|
172
|
+
const state = !st.on[i] ? dim(' 올리지 않음') : part ? dim(' 일부만') : '';
|
|
173
|
+
lines.push(` ${i === st.at ? accent('❯') : ' '} ${box} ${pad(name, w)}${dim(items[i].tail ?? '')}${state}${fold}`);
|
|
174
|
+
}
|
|
175
|
+
const any = st.on.some(Boolean);
|
|
176
|
+
lines.push('', ` ${dim('↑↓ 이동 · space 선택/해제 · → 하위 폴더 · a 전체 ·')} ${any ? bold(`enter ${unit ? unit(st.on) : '확인'}`) : dim('enter (선택한 폴더 없음)')} ${dim('· q 취소')}`);
|
|
177
|
+
return lines;
|
|
178
|
+
}
|
|
179
|
+
const KEYS = { '\x1b[A': 'up', '\x1b[B': 'down', '\x1b[C': 'open', '\x1b[D': 'close', k: 'up', j: 'down', l: 'open', h: 'close', ' ': 'space', a: 'all', '\r': 'enter', '\n': 'enter', q: 'cancel', '\x03': 'cancel', '\x1b': 'cancel' };
|
|
180
|
+
/** resolves to the selection (an array of booleans, one per item), or null when the person cancelled */
|
|
181
|
+
export function selectMany(spec) {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
const input = process.stdin;
|
|
184
|
+
let st = { at: 0, on: spec.initial ? spec.items.map((_, i) => spec.initial[i] !== false) : spec.items.map(() => true), open: [] };
|
|
185
|
+
let drawn = 0;
|
|
186
|
+
const draw = () => {
|
|
187
|
+
const lines = selectFrame(spec, st);
|
|
188
|
+
// a frame may be shorter than the one before it (a folder closed): the lines left over are cleared
|
|
189
|
+
const extra = Math.max(0, drawn - lines.length);
|
|
190
|
+
process.stderr.write((drawn ? `\x1b[${drawn}A` : '') + lines.map((l) => `\r\x1b[2K${l}`).join('\n') + '\n' + '\r\x1b[2K\n'.repeat(extra) + (extra ? `\x1b[${extra}A` : ''));
|
|
191
|
+
drawn = lines.length;
|
|
192
|
+
};
|
|
193
|
+
const done = (value) => {
|
|
194
|
+
input.off('data', onKey); try { input.setRawMode(false); } catch { /* not a tty any more */ } input.pause();
|
|
195
|
+
// the list leaves the screen: what was chosen is said by the lines that follow
|
|
196
|
+
process.stderr.write(`\x1b[${drawn}A` + '\r\x1b[2K\n'.repeat(drawn) + `\x1b[${drawn}A`);
|
|
197
|
+
resolve(value);
|
|
198
|
+
};
|
|
199
|
+
const onKey = (buf) => {
|
|
200
|
+
const key = KEYS[String(buf)];
|
|
201
|
+
if (key === 'cancel') return done(null);
|
|
202
|
+
if (key === 'enter') return done(st.on);
|
|
203
|
+
if (key) { st = selectStep(spec.items, st, key); draw(); }
|
|
204
|
+
};
|
|
205
|
+
try { input.setRawMode(true); } catch { return resolve(st.on); }
|
|
206
|
+
input.resume(); input.on('data', onKey);
|
|
207
|
+
draw();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export const ok = (s) => out(` ${green('✓')} ${s}`);
|
|
212
|
+
export const warn = (s) => out(` ${yellow('!')} ${s}`);
|
|
213
|
+
export const fail = (s, hint) => { out(` ${red('✗')} ${s}`); if (hint) out(` ${dim(hint)}`); };
|
|
214
|
+
|
|
215
|
+
/** the server's own words, or a status when it had none */
|
|
216
|
+
export function serverMessage(status, bodyText) {
|
|
217
|
+
try { const b = JSON.parse(bodyText); if (b?.message) return b.message; } catch { /* not JSON */ }
|
|
218
|
+
return { 401: '로그인이 필요합니다.', 403: '권한이 없습니다.', 404: '찾을 수 없습니다.', 413: '요청이 너무 큽니다.', 429: '요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.', 503: '서버가 바쁩니다. 잠시 후 다시 시도해주세요.' }[status]
|
|
219
|
+
?? `서버 오류 (${status})`;
|
|
220
|
+
}
|
package/lib/watch.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// What happens after `complete` used to be invisible: the CLI said "서버가 기록을
|
|
2
|
+
// 정리하는 데 몇 분 걸립니다" and exited, so the first thing a person ever saw of this
|
|
3
|
+
// product was a promise. The worker already writes its progress to the run record at
|
|
4
|
+
// every stage boundary — the lamp the app polls — so the CLI can read the same thing
|
|
5
|
+
// and stay until there is a result to show.
|
|
6
|
+
//
|
|
7
|
+
// Bounded and escapable: past WAIT_MAX the wait ends with the app's address, because
|
|
8
|
+
// the run is the server's from `complete` onwards and nothing here can hurry it.
|
|
9
|
+
import { SERVER, fetchRetry, runAuth } from './net.mjs';
|
|
10
|
+
import { bar, dim, fail, pad, serverMessage, since, spin, stepDone, stepLive, stepSkip } from './ui.mjs';
|
|
11
|
+
import { serverPlace } from './journey.mjs';
|
|
12
|
+
|
|
13
|
+
/** how often a run is asked for its progress, and how long the CLI waits before saying it is still being processed;
|
|
14
|
+
* a test passes shorter ones through `watchAll`'s options. An hour, not fifteen minutes: measured on the owner's own
|
|
15
|
+
* backfills, eleven or twelve days of work took 12–20 minutes (13m34s, 14m53s, 19m54s) with nobody else in the queue,
|
|
16
|
+
* so the old ceiling ended the wait on an ordinary first run and showed 「아직 정리 중」 where a result was minutes away.
|
|
17
|
+
* The wait costs nothing — it is a poll every two seconds — and the person can close the terminal at any time. */
|
|
18
|
+
const POLL_MS = 2000;
|
|
19
|
+
const WAIT_MAX_MS = 60 * 60_000;
|
|
20
|
+
const n = (x) => Number(x ?? 0).toLocaleString('ko-KR');
|
|
21
|
+
/** the step this file draws: the server turning what was uploaded into records */
|
|
22
|
+
const STEP = '기록 정리';
|
|
23
|
+
|
|
24
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One poll. A transport failure is not a verdict — the network dropping a packet reads
|
|
28
|
+
* as "no news yet" and the next poll asks again. A REFUSAL is a verdict: a credential
|
|
29
|
+
* that stopped working, or a run this account cannot see, never becomes readable by
|
|
30
|
+
* being asked 450 more times, and waiting out fifteen minutes on it would look exactly
|
|
31
|
+
* like patience. `{ gone }` ends the wait; `null` continues it.
|
|
32
|
+
*/
|
|
33
|
+
async function look(token, runId) {
|
|
34
|
+
const res = await fetchRetry(`${SERVER}/backfill/runs/${runId}`, () => ({ headers: runAuth(token) })).catch(() => null);
|
|
35
|
+
if (!res) return null;
|
|
36
|
+
if ([401, 403, 404].includes(res.status)) return { gone: res.status };
|
|
37
|
+
if (!res.ok) return null;
|
|
38
|
+
const body = await res.json().catch(() => null);
|
|
39
|
+
return body ? { view: body } : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Watch one run to its end. Returns the run's last known view, or null if the wait ran
|
|
44
|
+
* out — the caller says what to do, because that differs between onboarding and a
|
|
45
|
+
* plain `diffin backfill`.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* The bar of a stage that counts something. A stage whose count is ONE has no bar: 0/1 for the whole of it and then
|
|
49
|
+
* gone is an empty bar that reads as a stall — the stitch stage of a backfill sat at 「0/1」 for minutes — and the
|
|
50
|
+
* clock beside it is what says the run is alive. Exported for the test.
|
|
51
|
+
*/
|
|
52
|
+
export function scaleOf(p) {
|
|
53
|
+
return p && p.total > 1 ? `${bar(p.done, p.total, 12)} ${dim(`${n(p.done)}/${n(p.total)}`)}` : '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function watchRun(token, runId, label, { pollMs = POLL_MS, maxMs = WAIT_MAX_MS } = {}) {
|
|
57
|
+
const t0 = Date.now();
|
|
58
|
+
let view = null;
|
|
59
|
+
const stop = spin((frame) => {
|
|
60
|
+
const p = view?.progress ?? null;
|
|
61
|
+
// Before the first answer, and while the run sits unclaimed, the honest word is
|
|
62
|
+
// that it is queued — not a stage name we have not been told yet.
|
|
63
|
+
const lamp = p?.lamp ?? (view?.status === 'processing' ? '시작하는 중' : '차례 기다리는 중');
|
|
64
|
+
// A stage with a count gets a bar; one without (scan, relevance, project) does not,
|
|
65
|
+
// so the field is padded — otherwise the cost and the clock jump left and right as
|
|
66
|
+
// the run moves between the two kinds and the eye has to find them again.
|
|
67
|
+
const scale = scaleOf(p);
|
|
68
|
+
// What the run costs us to process is ours to know, not a line for the person watching — it used to be printed
|
|
69
|
+
// here, a dollar figure with nothing to do with what they pay. `DIFFIN_DEBUG` still shows it.
|
|
70
|
+
const cost = process.env.DIFFIN_DEBUG && p?.usd > 0 ? dim(` $${Number(p.usd).toFixed(4)}`) : '';
|
|
71
|
+
// said on the line itself, where the waiting is: the run is the server's now, and closing the window costs nothing
|
|
72
|
+
return stepLive(frame, STEP, `${pad(`${label} · ${lamp}`, 30)}${pad(scale, 22)}${dim(`${since(Date.now() - t0)} · 창을 닫아도 계속됩니다`)}${cost}`);
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
for (;;) {
|
|
76
|
+
const r = await look(token, runId);
|
|
77
|
+
if (r?.gone) return { status: 'failed', note: serverMessage(r.gone, ''), code: `http-${r.gone}` };
|
|
78
|
+
if (r?.view) view = r.view;
|
|
79
|
+
if (view && ['done', 'failed', 'empty'].includes(view.status)) return view;
|
|
80
|
+
if (Date.now() - t0 > maxMs) return null;
|
|
81
|
+
await sleep(pollMs);
|
|
82
|
+
}
|
|
83
|
+
} finally { stop(); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** the one line a finished run is worth, in the person's units */
|
|
87
|
+
export function reportRun(label, view, ms) {
|
|
88
|
+
if (!view) {
|
|
89
|
+
stepSkip(STEP, `${label} · 서버가 아직 정리하고 있습니다`, dim('앱에서 이어서 볼 수 있습니다'));
|
|
90
|
+
return 'waiting';
|
|
91
|
+
}
|
|
92
|
+
// A refusal is not the run failing: the worker may be processing it happily and we
|
|
93
|
+
// simply cannot read it any more. Saying "처리에 실패했습니다" there would send a
|
|
94
|
+
// person to re-upload work that is already being written.
|
|
95
|
+
if (String(view.code ?? '').startsWith('http-')) {
|
|
96
|
+
const how = { 'http-401': '앱의 연결 화면에서 받은 토큰으로 diffin login 을 다시 하세요.', 'http-403': '이 계정의 런이 아닙니다.', 'http-404': '앱에서 진행 상황을 확인하세요.' }[view.code];
|
|
97
|
+
fail(`${label} 진행 상황을 읽지 못했습니다 — ${view.note}`, `${how ?? '앱에서 진행 상황을 확인하세요.'} 올린 작업은 서버에서 계속 정리됩니다.`);
|
|
98
|
+
return 'failed';
|
|
99
|
+
}
|
|
100
|
+
if (view.status === 'failed') {
|
|
101
|
+
fail(`${label} 처리에 실패했습니다 — ${view.note ?? '서버가 이유를 남기지 않았습니다'}`,
|
|
102
|
+
view.code === 'machine-gone' ? 'npx @diffin/cli onboard 를 다시 실행하면 같은 구간을 다시 올립니다.' : 'npx @diffin/cli onboard 를 다시 실행하세요.');
|
|
103
|
+
return 'failed';
|
|
104
|
+
}
|
|
105
|
+
const p = view.progress ?? {};
|
|
106
|
+
const spent = process.env.DIFFIN_DEBUG && p.usd > 0 ? dim(` $${Number(p.usd).toFixed(4)}`) : '';
|
|
107
|
+
const took = dim(since((p.secs ?? 0) * 1000 || ms));
|
|
108
|
+
if (view.status === 'empty' || !(p.atoms > 0)) {
|
|
109
|
+
stepDone(STEP, `${label} · 새로 기록할 작업이 없었습니다`, took);
|
|
110
|
+
return 'empty';
|
|
111
|
+
}
|
|
112
|
+
stepDone(STEP, `${label} · 기록 ${n(p.atoms)}개 · 작업 ${n(p.goals)}개`, `${took}${spent}`);
|
|
113
|
+
return 'done';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** watch every run this invocation handed over, in the order they were made */
|
|
117
|
+
export async function watchAll(token, runs, opts = {}) {
|
|
118
|
+
if (!runs.length) return [];
|
|
119
|
+
const results = [];
|
|
120
|
+
for (const r of runs) {
|
|
121
|
+
const t0 = Date.now();
|
|
122
|
+
const view = await watchRun(token, r.runId, r.label, opts);
|
|
123
|
+
results.push({ ...r, view, outcome: reportRun(r.label, view, Date.now() - t0) });
|
|
124
|
+
}
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** what a finished run came to, without a line printed — the journey draws it (`watchJourney`) */
|
|
129
|
+
function outcomeOf(view) {
|
|
130
|
+
if (!view) return 'waiting';
|
|
131
|
+
if (String(view.code ?? '').startsWith('http-') || view.status === 'failed') return 'failed';
|
|
132
|
+
return view.status === 'empty' || !(view.progress?.atoms > 0) ? 'empty' : 'done';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every run this invocation handed over, watched TOGETHER and drawn as one journey (`journey.mjs`): the worker takes a
|
|
137
|
+
* person's runs one at a time, so the journey reads them as a queue, each by its size (`weight`, `journey.mjs` `queue`). Ends the journey — done, stopped (a run
|
|
138
|
+
* failed or could no longer be read) or later (the wait ran out with the server still at it) — and returns what
|
|
139
|
+
* `watchAll` returns, so the caller reads one shape either way.
|
|
140
|
+
*/
|
|
141
|
+
export async function watchJourney(token, runs, J, { pollMs = POLL_MS, maxMs = WAIT_MAX_MS } = {}) {
|
|
142
|
+
const t0 = Date.now();
|
|
143
|
+
const views = runs.map(() => null);
|
|
144
|
+
const finished = (v) => v && (v.gone || ['done', 'failed', 'empty'].includes(v.status));
|
|
145
|
+
for (;;) {
|
|
146
|
+
await Promise.all(runs.map(async (r, i) => {
|
|
147
|
+
if (finished(views[i])) return;
|
|
148
|
+
const got = await look(token, r.runId);
|
|
149
|
+
if (got?.gone) views[i] = { status: 'failed', note: serverMessage(got.gone, ''), code: `http-${got.gone}`, gone: got.gone };
|
|
150
|
+
else if (got?.view) views[i] = got.view;
|
|
151
|
+
}));
|
|
152
|
+
J.server(views.map((v, i) => ({ ...serverPlace(v), w: runs[i].weight ?? 1 })));
|
|
153
|
+
if (views.every(finished) || Date.now() - t0 > maxMs) break;
|
|
154
|
+
await sleep(pollMs);
|
|
155
|
+
}
|
|
156
|
+
const results = runs.map((r, i) => ({ ...r, view: finished(views[i]) ? views[i] : null, outcome: finished(views[i]) ? outcomeOf(views[i]) : 'waiting' }));
|
|
157
|
+
const sum = (k) => results.reduce((t, r) => t + Number(r.view?.progress?.[k] ?? 0), 0);
|
|
158
|
+
if (results.some((r) => r.outcome === 'failed')) {
|
|
159
|
+
J.end('stopped');
|
|
160
|
+
for (const r of results.filter((x) => x.outcome === 'failed')) fail(`${r.label} 처리에 실패했습니다 — ${r.view?.note ?? '서버가 이유를 남기지 않았습니다'}`, 'npx @diffin/cli onboard 를 다시 실행하세요.');
|
|
161
|
+
} else if (results.some((r) => r.outcome === 'waiting')) J.end('later');
|
|
162
|
+
else J.end('done', { result: { atoms: sum('atoms'), goals: sum('goals') } });
|
|
163
|
+
return results;
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diffin/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Capture your AI work history into diffin",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"homepage": "https://diffin.work",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"diffin": "./bin/diffin.mjs"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": { "access": "public" },
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"lib",
|
|
18
|
+
"templates"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "for f in bin/*.mjs lib/*.mjs lib/sources/*.mjs; do node --check $f || exit 1; done && node test/no-shadowed-import.mjs && node test/ui.mjs && node test/scrub-self.mjs && node test/scrub-rules.mjs && node test/run-pin.mjs && node test/capture.mjs && node test/capture-all.mjs && node test/ignore.mjs && node test/allow.mjs && node test/machine.mjs && node test/launch.mjs && node test/command.mjs && node test/init.mjs && node test/onboard.mjs && node test/watch.mjs && node test/journey.mjs && node test/net-https.mjs && node test/net-429.mjs && node test/state.mjs && node test/hermes.mjs && node test/codex.mjs && node test/antigravity.mjs && node test/status.mjs"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: capture
|
|
3
|
+
description: Records the work of every agent session on this machine to diffin (the AI-work ledger). Run `diffin capture` at the end of ANY turn in which a step of work got done — in any kind of work, not only code. A step is done when (1) something left the person's hands: sent, submitted, published, shared, handed over, exported, committed, merged, deployed; (2) a version was accepted: a draft, design, wording, plan or fix the person said yes to after revising; (3) a question got its answer: a research, comparison, calculation or diagnosis the person will act on; (4) a direction was decided; (5) the person wrapped up, moved to something else, or a long turn came to a stop. Examples outside code: an email ready to send, a report section finished, a slide deck shared, a spreadsheet analysis answered, a contract clause agreed, a lesson plan done, an application submitted. Always when the user says "기록해", "캡처해", "record this", "capture". The bar is low on purpose: run it after each finished step, not only at milestones, and when unsure, run it — the command itself ends a call that comes too soon, without sending, and says when the next may go up, so an extra call costs nothing and a missed one loses the record. Runs alone, with no confirmation.
|
|
4
|
+
license: Proprietary. Part of the diffin CLI.
|
|
5
|
+
compatibility: Requires the diffin CLI on PATH (`npx @diffin/cli onboard` installs it; or npm i -g @diffin/cli), signed in once with `diffin login`, and network access to the diffin server. Reads the agent session transcripts on this machine — every session that moved since it was last recorded. Capture supports Claude Code, Codex, Antigravity and Hermes sessions; under other agents the command says so.
|
|
6
|
+
allowed-tools: Bash(diffin capture:*)
|
|
7
|
+
metadata:
|
|
8
|
+
author: diffin
|
|
9
|
+
version: "3.13"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Capture (diffin)
|
|
13
|
+
|
|
14
|
+
You decide **when** to record. You never describe the work: the server extracts it
|
|
15
|
+
from the transcript, so nothing is recorded twice or in a different shape. The person
|
|
16
|
+
consented to recording when they installed diffin and chose which folders to exclude,
|
|
17
|
+
so the send needs no confirmation and asks nothing — it is one command at the end of
|
|
18
|
+
your turn, like a commit.
|
|
19
|
+
|
|
20
|
+
## When — whenever something just landed
|
|
21
|
+
|
|
22
|
+
One record carries **everything since the last one**, from every session on this
|
|
23
|
+
machine, and the server arranges the whole day again each time. Spacing is not your
|
|
24
|
+
job: the command ends a call that comes too soon on this machine, without sending
|
|
25
|
+
anything, and the server refuses one that slips through. A call too many costs nothing;
|
|
26
|
+
a call missed leaves the person's work unrecorded until someone remembers.
|
|
27
|
+
|
|
28
|
+
Run it at the end of the turn in which a step got done. Work of any kind ends in one
|
|
29
|
+
of five ways; the developer's commit, merge and deploy are only the first one's
|
|
30
|
+
examples:
|
|
31
|
+
|
|
32
|
+
- **something left the person's hands** — sent, submitted, published, shared, handed
|
|
33
|
+
over, exported, saved and closed. Code: committed, merged, pushed, deployed,
|
|
34
|
+
released. Elsewhere: an email sent, a report section handed over, a slide deck
|
|
35
|
+
shared, an application submitted, a post published, a file exported
|
|
36
|
+
- **a version was accepted** — a revise-and-review loop ended in "좋아 / 이걸로 가자 /
|
|
37
|
+
확정". Code: a bug fixed and verified, a test made to pass, a screen or feature
|
|
38
|
+
finished. Elsewhere: a draft, a wording, a design, a plan, a contract clause, a
|
|
39
|
+
lesson plan the person said yes to
|
|
40
|
+
- **a question got its answer** — an analysis, comparison, investigation, calculation
|
|
41
|
+
or diagnosis reached what the person will act on. Code: the cause of a failure found.
|
|
42
|
+
Elsewhere: a research conclusion, a candidate chosen, a figure computed
|
|
43
|
+
- **a direction was decided** — the person confirmed a choice or a plan that changes
|
|
44
|
+
what gets made or sent next
|
|
45
|
+
- **it stopped** — the person is wrapping up, turning to something unrelated, stepping
|
|
46
|
+
away, or a long turn (many files, many commands, many drafts) came to a stop with no
|
|
47
|
+
milestone behind it
|
|
48
|
+
|
|
49
|
+
A step of a larger job counts; the next step is another record. And always when the
|
|
50
|
+
person says **기록해 / 캡처해 / record this / capture**.
|
|
51
|
+
|
|
52
|
+
The bar is low on purpose. A record is for a finished step, not a finished project: a
|
|
53
|
+
working session of a few hours should produce several, not one at the end. Hold off
|
|
54
|
+
only in the middle of something still moving — a test still failing, a change
|
|
55
|
+
half-made, a question you just asked them. Otherwise, **when you are unsure, run it.**
|
|
56
|
+
|
|
57
|
+
## You do not need to know when the last record was
|
|
58
|
+
|
|
59
|
+
You usually cannot know — it may have gone up from another window, or this
|
|
60
|
+
conversation may have been cleared or compacted since. That is fine: **the command
|
|
61
|
+
knows**. It keeps, on this machine, the time before which the server will refuse a
|
|
62
|
+
record, and answers an early call at once without sending anything.
|
|
63
|
+
|
|
64
|
+
So the rule is about what you have *seen in this conversation*:
|
|
65
|
+
|
|
66
|
+
- If a reply in this conversation said `다음 기록은 HH:MM 이후에 가능합니다`, do not
|
|
67
|
+
run the command again before that time. Check the time with `date` if you are unsure.
|
|
68
|
+
- If you have seen no such reply (a new or cleared conversation), running it once at a
|
|
69
|
+
resting point is right. If it is early you get that line back, and now you know.
|
|
70
|
+
- Never run it twice in a row, and never retry a refusal.
|
|
71
|
+
|
|
72
|
+
## How — one command, at the end of the turn
|
|
73
|
+
|
|
74
|
+
Finish the person's work first. Then run, as a lone command:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
diffin capture
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It sends the new turns of **every** agent session on this machine, each from where it
|
|
81
|
+
was last recorded, already scrubbed; tool output, images and thinking never leave the
|
|
82
|
+
machine. `diffin capture --this` sends only this session. Do not bundle it with other
|
|
83
|
+
commands, do not ask whether to send, and do not tell the person to run it.
|
|
84
|
+
|
|
85
|
+
Then one short line at most, and stop:
|
|
86
|
+
|
|
87
|
+
| reply | what to say |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `올렸습니다` | `diffin 에 기록했습니다.` — and remember the time it gives for the next record |
|
|
90
|
+
| `다음 기록은 … 이후에 가능합니다` | nothing if you ran it on your own judgment; if the person asked for a record, tell them that time and that this work goes up then |
|
|
91
|
+
| `새 작업이 없습니다` / `최근 움직인 세션이 없습니다` | nothing — it was already recorded |
|
|
92
|
+
| `새 폴더 N곳은 아직 기록하지 않습니다 · …` | relay that line once, as it is — it names the folders and the two commands. Do not run `diffin allow` or `diffin folders` yourself: they refuse without a terminal, and allowing a folder is the person's decision |
|
|
93
|
+
| `일시정지 중` | nothing — the person paused recording; what is said while paused is not recorded, and `diffin resume` turns it back on |
|
|
94
|
+
| `샌드박스 안이라 올릴 수 없습니다` | tell the person once, in one line, what it says to do (run `diffin init` in a terminal, then restart the agent); do not retry |
|
|
95
|
+
| `로그인이 필요합니다` | tell the person to run the connect command from the diffin app once |
|
|
96
|
+
| a daily limit | nothing — tomorrow |
|
|
97
|
+
| anything else | show the message once; do not retry |
|
|
98
|
+
|
|
99
|
+
## Never
|
|
100
|
+
|
|
101
|
+
- Write, edit or summarise the record yourself — the server extracts it.
|
|
102
|
+
- Send anything by another path (no curl, no reading transcripts into a message).
|
|
103
|
+
- Record from a folder the person excluded (`--exclude`, `.diffin-ignore`) or a
|
|
104
|
+
conversation they asked you not to record — say `diffin pause` exists if they want
|
|
105
|
+
a quiet stretch.
|
|
106
|
+
- Allow a folder on the person's behalf. A folder they never allowed is held by the
|
|
107
|
+
command itself; only they widen it, on a terminal (`diffin allow`, `diffin folders`).
|