@arik_shemesh/yoman 0.2.0 → 0.3.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/README.md +83 -6
- package/cli.js +91 -35
- package/package.json +3 -2
- package/src/amend.js +87 -0
- package/src/ask.js +18 -0
- package/src/config.js +1 -0
- package/src/git.js +31 -2
- package/src/llm.js +31 -1
- package/src/log.js +75 -8
- package/src/prompt.js +2 -1
- package/src/review.js +32 -0
package/README.md
CHANGED
|
@@ -1,10 +1,65 @@
|
|
|
1
1
|
# yoman
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@arik_shemesh/yoman)
|
|
4
|
+
[](LICENSE)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
**A development log your team will actually trust, because the "why" comes from you, not a guess.**
|
|
6
7
|
|
|
7
|
-
Every
|
|
8
|
+
Every "AI changelog" tool reads a diff and invents a reason for it. That reason is always a hallucination - a diff shows *what* changed, never *why*. yoman flips the pipeline: it asks you for your reasoning at the moment the milestone happens, captures it verbatim, and only then lets the model use the diff as supporting evidence to write up the rest.
|
|
9
|
+
|
|
10
|
+
Local-first CLI. Your repo, your `DEVELOPMENT_LOG.md`, the Gemini API doing the write-up - nothing else in the loop.
|
|
11
|
+
|
|
12
|
+
## See it work
|
|
13
|
+
|
|
14
|
+
This is a real, unedited entry from this project's own log, generated by running `yoman` after a milestone:
|
|
15
|
+
|
|
16
|
+
> **2026-07-19 - Flush pending queue before new runs; add README and CLAUDE.md**
|
|
17
|
+
>
|
|
18
|
+
> **Stated intent:** Asked what happens if a failed run is followed by plain yoman instead of retry, and found a real duplicate-entry and state-regression bug. Chose flush-first over post-run auto-flush so overlapping ranges can never form, plus a stale-item guard as cheap insurance.
|
|
19
|
+
>
|
|
20
|
+
> **Summary:** Implemented a flush-first mechanism in the main command flow that processes any queued items in `pending.json` before a new run is calculated. Added a stale-item check using a new git ancestry utility to drop queued entries that have already been journaled...
|
|
21
|
+
|
|
22
|
+
The first paragraph is what the human typed. Everything after it is the model, working only from that intent plus the diff. Full entry in [DEVELOPMENT_LOG.md](DEVELOPMENT_LOG.md).
|
|
23
|
+
|
|
24
|
+
## How
|
|
25
|
+
|
|
26
|
+
1. `yoman` shows you the commits since your last journal entry.
|
|
27
|
+
2. You pick a title, then type **why** - free text, mandatory, no skipping.
|
|
28
|
+
3. Your intent gets saved to disk *before* any network call, so a failed API request never loses what you typed.
|
|
29
|
+
4. Gemini receives your stated intent + commit messages + a secret-filtered diff, and is explicitly told your intent is the only source of motivation - the diff is evidence of *what* changed only.
|
|
30
|
+
5. You review the generated entry before it's written: accept, regenerate with a steering note, or abort.
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
npm install -g @arik_shemesh/yoman
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Add your Gemini key next to the install (`.env`, gitignored):
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
GEMINI_API_KEY=your-key-here
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Inside the repo you want to journal:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
yoman init # sets up .yoman/ + DEVELOPMENT_LOG.md
|
|
48
|
+
yoman # journal commits since the last entry
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
See [Install](#install) below for installing from source with `npm link`.
|
|
52
|
+
|
|
53
|
+
## Table of contents
|
|
54
|
+
|
|
55
|
+
- [Install](#install)
|
|
56
|
+
- [Updating](#updating)
|
|
57
|
+
- [Usage](#usage)
|
|
58
|
+
- [Nag hook](#nag-hook)
|
|
59
|
+
- [What gets sent to the LLM, and what doesn't](#what-gets-sent-to-the-llm-and-what-doesnt)
|
|
60
|
+
- [Config](#config)
|
|
61
|
+
- [Not yet included](#not-yet-included)
|
|
62
|
+
- [Project layout](#project-layout)
|
|
8
63
|
|
|
9
64
|
## Install
|
|
10
65
|
|
|
@@ -30,7 +85,7 @@ GEMINI_API_KEY=your-key-here
|
|
|
30
85
|
|
|
31
86
|
`.env` is gitignored - never committed.
|
|
32
87
|
|
|
33
|
-
|
|
88
|
+
### Global command
|
|
34
89
|
|
|
35
90
|
Run once inside the cloned yoman folder:
|
|
36
91
|
|
|
@@ -79,6 +134,24 @@ yoman retry
|
|
|
79
134
|
|
|
80
135
|
This flushes everything queued in `.yoman/pending.json`.
|
|
81
136
|
|
|
137
|
+
Before anything is written, yoman shows the generated entry and asks: accept, regenerate (optionally with a steering note like "shorter" or "focus on the refactor"), or abort. Abort keeps your input queued for `yoman retry`. Set `"preview": false` in `.yoman/config.json` to skip the preview and write immediately.
|
|
138
|
+
|
|
139
|
+
Got a weak entry into the log anyway? Fix the newest one:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
yoman amend
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Keep or replace the title and the stated intent, regenerate, review, accept - same SHA range, same date, older entries untouched.
|
|
146
|
+
|
|
147
|
+
Rebased or amended commits after journaling? `yoman` and `yoman status` will refuse with a clear message instead of guessing; run:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
yoman reset
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
to re-anchor the journal state at the current HEAD (confirms first; skipped commits stay unjournaled).
|
|
154
|
+
|
|
82
155
|
## Nag hook
|
|
83
156
|
|
|
84
157
|
Forgetting to journal is the default failure mode. Install a reminder per repo:
|
|
@@ -108,7 +181,8 @@ This adds a `post-commit` hook that prints one line once you have `nagThreshold`
|
|
|
108
181
|
"logFile": "DEVELOPMENT_LOG.md",
|
|
109
182
|
"minWhyWords": 15,
|
|
110
183
|
"diffCharLimit": 20000,
|
|
111
|
-
"nagThreshold": 5
|
|
184
|
+
"nagThreshold": 5,
|
|
185
|
+
"preview": true
|
|
112
186
|
}
|
|
113
187
|
```
|
|
114
188
|
|
|
@@ -127,7 +201,7 @@ Deliberately deferred:
|
|
|
127
201
|
## Project layout
|
|
128
202
|
|
|
129
203
|
```
|
|
130
|
-
cli.js entry point, command dispatch (init / run / retry / status / hook)
|
|
204
|
+
cli.js entry point, command dispatch (init / run / retry / status / hook / amend / reset)
|
|
131
205
|
src/
|
|
132
206
|
config.js .yoman/config.json load/validate + .env key
|
|
133
207
|
state.js .yoman/state.json (lastSha) read/write
|
|
@@ -137,4 +211,7 @@ src/
|
|
|
137
211
|
llm.js Gemini API call + friendly error mapping
|
|
138
212
|
log.js prepend entry to DEVELOPMENT_LOG.md with SHA-range anchor
|
|
139
213
|
pending.js save-before-call queue (.yoman/pending.json)
|
|
214
|
+
review.js entry preview loop (accept / regenerate / abort)
|
|
215
|
+
amend.js regenerate the latest entry (same range, same date)
|
|
216
|
+
ask.js shared why prompt with thin-context re-ask
|
|
140
217
|
```
|
package/cli.js
CHANGED
|
@@ -32,6 +32,18 @@ async function cmdInit() {
|
|
|
32
32
|
console.log(` log: ${logFile}`);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Rebase/amend can leave state.lastSha pointing at a commit that no longer
|
|
36
|
+
// exists on this branch; every range computation would then crash or lie.
|
|
37
|
+
function checkHistoryGuard(st, cwd) {
|
|
38
|
+
const git = require('./src/git');
|
|
39
|
+
if (git.shaExists(st.lastSha, cwd) && git.isAncestor(st.lastSha, git.currentHead(cwd), cwd)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(
|
|
43
|
+
'journal state points at a commit not in this branch\'s history (rebase or amend?). Run `yoman reset` to re-anchor at the current HEAD.'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
// Shared by run (fresh milestone) and retry/auto-flush (queued milestone):
|
|
36
48
|
// builds the filtered diff, calls the LLM, prepends the log entry, and
|
|
37
49
|
// advances state.lastSha to the item's range end (plan step 9 applies to
|
|
@@ -50,31 +62,39 @@ async function processPendingItem(item, cfg, cwd) {
|
|
|
50
62
|
// queued item finally flushed (may differ after a failed run + later retry).
|
|
51
63
|
const isoDate = item.timestamp.slice(0, 10);
|
|
52
64
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{
|
|
66
|
-
logFile: cfg.logFile,
|
|
65
|
+
const generateBlock = async (steeringNote) => {
|
|
66
|
+
const llmPrompt = prompt.buildPrompt({
|
|
67
|
+
date: isoDate,
|
|
68
|
+
title: item.title,
|
|
69
|
+
why: item.why,
|
|
70
|
+
commitLines,
|
|
71
|
+
diff,
|
|
72
|
+
truncated,
|
|
73
|
+
steeringNote,
|
|
74
|
+
});
|
|
75
|
+
const modelBody = await llm.generateEntry({ model: cfg.model, prompt: llmPrompt });
|
|
76
|
+
return log.buildEntryBlock({
|
|
67
77
|
fromSha: from,
|
|
68
78
|
toSha: to,
|
|
69
79
|
isoDate,
|
|
70
80
|
title: item.title,
|
|
71
81
|
why: item.why,
|
|
72
82
|
modelBody,
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
);
|
|
83
|
+
});
|
|
84
|
+
};
|
|
76
85
|
|
|
86
|
+
let block = await generateBlock(null);
|
|
87
|
+
|
|
88
|
+
if (cfg.preview) {
|
|
89
|
+
const review = require('./src/review');
|
|
90
|
+
const result = await review.reviewLoop({ entryBlock: block, regenerate: generateBlock });
|
|
91
|
+
if (result.action === 'abort') return { aborted: true };
|
|
92
|
+
block = result.entryBlock;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
log.prependEntry({ logFile: cfg.logFile, block }, cwd);
|
|
77
96
|
state.writeState({ lastSha: to }, cwd);
|
|
97
|
+
return { aborted: false };
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
// Flush the pending queue: journal each queued item, dropping items whose
|
|
@@ -88,7 +108,7 @@ async function flushPending(cfg, cwd) {
|
|
|
88
108
|
const state = require('./src/state');
|
|
89
109
|
|
|
90
110
|
const items = pending.loadPending(cwd);
|
|
91
|
-
const result = { total: items.length, recovered: 0, dropped: 0, failed: 0 };
|
|
111
|
+
const result = { total: items.length, recovered: 0, dropped: 0, aborted: 0, failed: 0 };
|
|
92
112
|
|
|
93
113
|
for (const item of items) {
|
|
94
114
|
const st = state.loadState(cwd);
|
|
@@ -99,7 +119,12 @@ async function flushPending(cfg, cwd) {
|
|
|
99
119
|
continue;
|
|
100
120
|
}
|
|
101
121
|
try {
|
|
102
|
-
await processPendingItem(item, cfg, cwd);
|
|
122
|
+
const { aborted } = await processPendingItem(item, cfg, cwd);
|
|
123
|
+
if (aborted) {
|
|
124
|
+
result.aborted++;
|
|
125
|
+
console.log(`kept in pending (aborted at preview): ${item.title}`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
103
128
|
pending.removePending(item.timestamp, cwd);
|
|
104
129
|
result.recovered++;
|
|
105
130
|
console.log(`recovered: ${item.title}`);
|
|
@@ -130,17 +155,18 @@ async function cmdRun() {
|
|
|
130
155
|
if (pending.loadPending(cwd).length > 0) {
|
|
131
156
|
console.log('pending entries from a failed run found - recovering them first...');
|
|
132
157
|
const flush = await flushPending(cfg, cwd);
|
|
133
|
-
if (flush.failed > 0) {
|
|
158
|
+
if (flush.failed > 0 || flush.aborted > 0) {
|
|
134
159
|
console.error(
|
|
135
|
-
'yoman: pending entries
|
|
160
|
+
'yoman: pending entries remain - a new run now would journal the same commits twice.'
|
|
136
161
|
);
|
|
137
|
-
console.error('fix the problem
|
|
162
|
+
console.error('fix the problem (or accept the previews), then run `yoman retry`.');
|
|
138
163
|
process.exitCode = 1;
|
|
139
164
|
return;
|
|
140
165
|
}
|
|
141
166
|
}
|
|
142
167
|
|
|
143
168
|
const st = state.loadState(cwd);
|
|
169
|
+
checkHistoryGuard(st, cwd);
|
|
144
170
|
const head = git.currentHead(cwd);
|
|
145
171
|
|
|
146
172
|
const count = git.commitCount(st.lastSha, head, cwd);
|
|
@@ -168,17 +194,8 @@ async function cmdRun() {
|
|
|
168
194
|
});
|
|
169
195
|
}
|
|
170
196
|
|
|
171
|
-
const askWhy = (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
let why = await askWhy('Why did you make this change? (context, mandatory):');
|
|
175
|
-
const wordCount = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
|
176
|
-
if (wordCount(why) < cfg.minWhyWords) {
|
|
177
|
-
console.log(
|
|
178
|
-
`That's pretty thin (aim for ${cfg.minWhyWords}+ words - more context makes a better entry). One more try:`
|
|
179
|
-
);
|
|
180
|
-
why = await askWhy('Why did you make this change?:');
|
|
181
|
-
}
|
|
197
|
+
const { askWhy } = require('./src/ask');
|
|
198
|
+
const why = await askWhy(cfg.minWhyWords);
|
|
182
199
|
|
|
183
200
|
const item = {
|
|
184
201
|
title,
|
|
@@ -189,7 +206,11 @@ async function cmdRun() {
|
|
|
189
206
|
pending.addPending(item, cwd);
|
|
190
207
|
|
|
191
208
|
try {
|
|
192
|
-
await processPendingItem(item, cfg, cwd);
|
|
209
|
+
const { aborted } = await processPendingItem(item, cfg, cwd);
|
|
210
|
+
if (aborted) {
|
|
211
|
+
console.log('kept in pending - run `yoman retry` when ready.');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
193
214
|
pending.removePending(item.timestamp, cwd);
|
|
194
215
|
console.log(`entry added to ${cfg.logFile}.`);
|
|
195
216
|
} catch (err) {
|
|
@@ -213,7 +234,7 @@ async function cmdRetry() {
|
|
|
213
234
|
|
|
214
235
|
const result = await flushPending(cfg, cwd);
|
|
215
236
|
console.log(
|
|
216
|
-
`${result.recovered}/${result.total} recovered, ${result.dropped} dropped as stale, ${result.failed} still pending.`
|
|
237
|
+
`${result.recovered}/${result.total} recovered, ${result.dropped} dropped as stale, ${result.aborted} aborted, ${result.failed} still pending.`
|
|
217
238
|
);
|
|
218
239
|
if (result.failed > 0) process.exitCode = 1;
|
|
219
240
|
}
|
|
@@ -227,6 +248,7 @@ async function cmdStatus() {
|
|
|
227
248
|
const cwd = process.cwd();
|
|
228
249
|
const cfg = config.loadConfig(cwd);
|
|
229
250
|
const st = state.loadState(cwd);
|
|
251
|
+
checkHistoryGuard(st, cwd);
|
|
230
252
|
const head = git.currentHead(cwd);
|
|
231
253
|
const count = git.commitCount(st.lastSha, head, cwd);
|
|
232
254
|
const pendingCount = pending.loadPending(cwd).length;
|
|
@@ -269,6 +291,38 @@ async function cmdNag() {
|
|
|
269
291
|
}
|
|
270
292
|
}
|
|
271
293
|
|
|
294
|
+
async function cmdReset() {
|
|
295
|
+
const { confirm } = require('@inquirer/prompts');
|
|
296
|
+
const config = require('./src/config');
|
|
297
|
+
const state = require('./src/state');
|
|
298
|
+
const git = require('./src/git');
|
|
299
|
+
|
|
300
|
+
const cwd = process.cwd();
|
|
301
|
+
config.loadConfig(cwd);
|
|
302
|
+
const st = state.loadState(cwd);
|
|
303
|
+
const head = git.currentHead(cwd);
|
|
304
|
+
|
|
305
|
+
if (st.lastSha === head) {
|
|
306
|
+
console.log('journal state already at HEAD - nothing to reset.');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const oldDesc = git.shaExists(st.lastSha, cwd)
|
|
311
|
+
? st.lastSha.slice(0, 7)
|
|
312
|
+
: `${st.lastSha.slice(0, 7)} (no longer exists)`;
|
|
313
|
+
console.log(`re-anchor journal state: ${oldDesc} -> ${head.slice(0, 7)}`);
|
|
314
|
+
const ok = await confirm({
|
|
315
|
+
message: 'Commits between these points will never be journaled automatically. Continue?',
|
|
316
|
+
default: false,
|
|
317
|
+
});
|
|
318
|
+
if (!ok) {
|
|
319
|
+
console.log('no changes.');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
state.writeState({ lastSha: head }, cwd);
|
|
323
|
+
console.log(`journal state re-anchored at ${head.slice(0, 7)}.`);
|
|
324
|
+
}
|
|
325
|
+
|
|
272
326
|
async function cmdHook() {
|
|
273
327
|
const fs = require('fs');
|
|
274
328
|
const path = require('path');
|
|
@@ -327,6 +381,8 @@ async function main() {
|
|
|
327
381
|
if (sub === 'status') return cmdStatus();
|
|
328
382
|
if (sub === 'nag') return cmdNag();
|
|
329
383
|
if (sub === 'hook') return cmdHook();
|
|
384
|
+
if (sub === 'reset') return cmdReset();
|
|
385
|
+
if (sub === 'amend') return require('./src/amend').runAmend();
|
|
330
386
|
if (sub && sub !== 'run') {
|
|
331
387
|
console.error(`yoman: unknown command "${sub}"`);
|
|
332
388
|
process.exitCode = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arik_shemesh/yoman",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Local-first CLI that generates engineering log entries from git history + human-provided intent, via Gemini API.",
|
|
5
5
|
"main": "cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"url": "git+https://github.com/ArikShemesh/yoman.git"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"start": "node cli.js"
|
|
20
|
+
"start": "node cli.js",
|
|
21
|
+
"test": "node test/smoke.js"
|
|
21
22
|
},
|
|
22
23
|
"license": "MIT",
|
|
23
24
|
"dependencies": {
|
package/src/amend.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const { select, input } = require('@inquirer/prompts');
|
|
2
|
+
|
|
3
|
+
// Amend the LATEST log entry only: same SHA range, same anchor, same date -
|
|
4
|
+
// rewording a milestone does not change when it happened. State untouched.
|
|
5
|
+
async function runAmend() {
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
const state = require('./state');
|
|
8
|
+
const git = require('./git');
|
|
9
|
+
const log = require('./log');
|
|
10
|
+
const prompt = require('./prompt');
|
|
11
|
+
const llm = require('./llm');
|
|
12
|
+
const review = require('./review');
|
|
13
|
+
const { askWhy } = require('./ask');
|
|
14
|
+
|
|
15
|
+
const cwd = process.cwd();
|
|
16
|
+
const cfg = config.loadConfig(cwd);
|
|
17
|
+
config.requireApiKey();
|
|
18
|
+
state.loadState(cwd);
|
|
19
|
+
|
|
20
|
+
const latest = log.readLatestEntry(cfg.logFile, cwd);
|
|
21
|
+
if (!latest) {
|
|
22
|
+
throw new Error(`nothing to amend (no anchored entries in ${cfg.logFile}).`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log(`amending latest entry: "${latest.title}" (${latest.fromSha.slice(0, 7)}..${latest.toSha.slice(0, 7)}, ${latest.isoDate})`);
|
|
26
|
+
|
|
27
|
+
const titleChoice = await select({
|
|
28
|
+
message: 'Title:',
|
|
29
|
+
choices: [
|
|
30
|
+
{ name: `Keep: "${latest.title}"`, value: 'keep' },
|
|
31
|
+
{ name: 'Enter new title', value: 'new' },
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
const title =
|
|
35
|
+
titleChoice === 'keep'
|
|
36
|
+
? latest.title
|
|
37
|
+
: await input({
|
|
38
|
+
message: 'New title:',
|
|
39
|
+
validate: (v) => v.trim().length > 0 || 'title is required',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const whyChoice = await select({
|
|
43
|
+
message: 'Stated intent (why):',
|
|
44
|
+
choices: [
|
|
45
|
+
{ name: 'Keep current', value: 'keep' },
|
|
46
|
+
{ name: 'Enter new why', value: 'new' },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
const why = whyChoice === 'keep' ? latest.why : await askWhy(cfg.minWhyWords);
|
|
50
|
+
|
|
51
|
+
const commitLines = git.commitOneliners(latest.fromSha, latest.toSha, cwd);
|
|
52
|
+
const { diff, truncated } = git.filteredDiff(latest.fromSha, latest.toSha, cfg.diffCharLimit, cwd, cfg.logFile);
|
|
53
|
+
|
|
54
|
+
const generateBlock = async (steeringNote) => {
|
|
55
|
+
const llmPrompt = prompt.buildPrompt({
|
|
56
|
+
date: latest.isoDate,
|
|
57
|
+
title,
|
|
58
|
+
why,
|
|
59
|
+
commitLines,
|
|
60
|
+
diff,
|
|
61
|
+
truncated,
|
|
62
|
+
steeringNote,
|
|
63
|
+
});
|
|
64
|
+
const modelBody = await llm.generateEntry({ model: cfg.model, prompt: llmPrompt });
|
|
65
|
+
return log.buildEntryBlock({
|
|
66
|
+
fromSha: latest.fromSha,
|
|
67
|
+
toSha: latest.toSha,
|
|
68
|
+
isoDate: latest.isoDate,
|
|
69
|
+
title,
|
|
70
|
+
why,
|
|
71
|
+
modelBody,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const first = await generateBlock(null);
|
|
76
|
+
// Amend always previews - reviewing IS the point of the command.
|
|
77
|
+
const result = await review.reviewLoop({ entryBlock: first, regenerate: generateBlock });
|
|
78
|
+
if (result.action === 'abort') {
|
|
79
|
+
console.log('amend cancelled - log unchanged.');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
log.replaceLatestEntry({ logFile: cfg.logFile, newBlock: result.entryBlock }, cwd);
|
|
84
|
+
console.log(`latest entry amended in ${cfg.logFile}.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { runAmend };
|
package/src/ask.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const { input } = require('@inquirer/prompts');
|
|
2
|
+
|
|
3
|
+
async function askWhy(minWhyWords) {
|
|
4
|
+
const ask = (message) =>
|
|
5
|
+
input({ message, validate: (v) => v.trim().length > 0 || 'why is required' });
|
|
6
|
+
|
|
7
|
+
let why = await ask('Why did you make this change? (context, mandatory):');
|
|
8
|
+
const wordCount = why.trim().split(/\s+/).filter(Boolean).length;
|
|
9
|
+
if (wordCount < minWhyWords) {
|
|
10
|
+
console.log(
|
|
11
|
+
`That's pretty thin (aim for ${minWhyWords}+ words - more context makes a better entry). One more try:`
|
|
12
|
+
);
|
|
13
|
+
why = await ask('Why did you make this change?:');
|
|
14
|
+
}
|
|
15
|
+
return why;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { askWhy };
|
package/src/config.js
CHANGED
package/src/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { execFileSync } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
2
3
|
|
|
3
4
|
const EXCLUDED_PATTERNS = [
|
|
4
5
|
'.env',
|
|
@@ -14,13 +15,28 @@ const EXCLUDED_PATTERNS = [
|
|
|
14
15
|
];
|
|
15
16
|
|
|
16
17
|
function git(args, cwd) {
|
|
17
|
-
return execFileSync('git', args, {
|
|
18
|
+
return execFileSync('git', args, {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
maxBuffer: 1024 * 1024 * 50,
|
|
22
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
23
|
+
});
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
function currentHead(cwd = process.cwd()) {
|
|
21
27
|
return git(['rev-parse', 'HEAD'], cwd).trim();
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
// true if sha resolves to a commit in this repo
|
|
31
|
+
function shaExists(sha, cwd = process.cwd()) {
|
|
32
|
+
try {
|
|
33
|
+
git(['cat-file', '-e', `${sha}^{commit}`], cwd);
|
|
34
|
+
return true;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
// true if maybeAncestor is an ancestor of sha (or the same commit)
|
|
25
41
|
function isAncestor(maybeAncestor, sha, cwd = process.cwd()) {
|
|
26
42
|
try {
|
|
@@ -46,8 +62,20 @@ function commitOneliners(fromSha, toSha, cwd = process.cwd()) {
|
|
|
46
62
|
return out ? out.split('\n') : [];
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
// git's pathspec exclude requires a path inside the repo tree. logFile can
|
|
66
|
+
// be configured as an absolute path outside the repo (e.g. an Obsidian
|
|
67
|
+
// vault) - resolve it and only add the exclude when it actually lands
|
|
68
|
+
// inside cwd; a path outside the repo can never appear in the diff anyway.
|
|
69
|
+
function logFileExcludePattern(logFile, cwd) {
|
|
70
|
+
if (!logFile) return null;
|
|
71
|
+
const rel = path.isAbsolute(logFile) ? path.relative(cwd, logFile) : logFile;
|
|
72
|
+
if (path.isAbsolute(rel) || rel.startsWith('..')) return null;
|
|
73
|
+
return rel;
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
function filteredDiff(fromSha, toSha, charLimit, cwd = process.cwd(), logFile) {
|
|
50
|
-
const
|
|
77
|
+
const logExclude = logFileExcludePattern(logFile, cwd);
|
|
78
|
+
const patterns = logExclude ? [...EXCLUDED_PATTERNS, logExclude] : EXCLUDED_PATTERNS;
|
|
51
79
|
const excludeArgs = patterns.map((p) => `:(exclude)${p}`);
|
|
52
80
|
const raw = git(['diff', fromSha, toSha, '--', '.', ...excludeArgs], cwd);
|
|
53
81
|
if (raw.length <= charLimit) {
|
|
@@ -65,6 +93,7 @@ function filteredDiff(fromSha, toSha, charLimit, cwd = process.cwd(), logFile) {
|
|
|
65
93
|
module.exports = {
|
|
66
94
|
currentHead,
|
|
67
95
|
isAncestor,
|
|
96
|
+
shaExists,
|
|
68
97
|
commitCount,
|
|
69
98
|
commitSubjects,
|
|
70
99
|
commitOneliners,
|
package/src/llm.js
CHANGED
|
@@ -1,11 +1,39 @@
|
|
|
1
1
|
const { GoogleGenAI } = require('@google/genai');
|
|
2
2
|
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 60000;
|
|
4
|
+
|
|
5
|
+
const SPINNER_FRAMES = ['|', '/', '-', '\\'];
|
|
6
|
+
|
|
7
|
+
function startSpinner(label) {
|
|
8
|
+
if (!process.stdout.isTTY) return null;
|
|
9
|
+
let i = 0;
|
|
10
|
+
process.stdout.write(`${label} `);
|
|
11
|
+
return setInterval(() => {
|
|
12
|
+
process.stdout.write(`\r${label} ${SPINNER_FRAMES[(i = (i + 1) % SPINNER_FRAMES.length)]}`);
|
|
13
|
+
}, 100);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stopSpinner(timer) {
|
|
17
|
+
if (!timer) return;
|
|
18
|
+
clearInterval(timer);
|
|
19
|
+
process.stdout.write('\r' + ' '.repeat(40) + '\r');
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
async function generateEntry({ model, prompt }) {
|
|
4
|
-
const ai = new GoogleGenAI({
|
|
23
|
+
const ai = new GoogleGenAI({
|
|
24
|
+
apiKey: process.env.GEMINI_API_KEY,
|
|
25
|
+
httpOptions: { timeout: REQUEST_TIMEOUT_MS },
|
|
26
|
+
});
|
|
27
|
+
const spinner = startSpinner('yoman: waiting for Gemini...');
|
|
5
28
|
try {
|
|
6
29
|
const response = await ai.models.generateContent({ model, contents: prompt });
|
|
7
30
|
return response.text;
|
|
8
31
|
} catch (err) {
|
|
32
|
+
if (err.name === 'AbortError') {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Gemini request timed out after ${REQUEST_TIMEOUT_MS / 1000}s - no response. Check your connection and retry.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
9
37
|
if (err.status === 404) {
|
|
10
38
|
throw new Error(
|
|
11
39
|
`model "${model}" not found or not available for this key. Update "model" in .yoman/config.json. (${err.message})`
|
|
@@ -15,6 +43,8 @@ async function generateEntry({ model, prompt }) {
|
|
|
15
43
|
throw new Error(`rate limited / quota exceeded for model "${model}". (${err.message})`);
|
|
16
44
|
}
|
|
17
45
|
throw new Error(`Gemini API call failed: ${err.message}`);
|
|
46
|
+
} finally {
|
|
47
|
+
stopSpinner(spinner);
|
|
18
48
|
}
|
|
19
49
|
}
|
|
20
50
|
|
package/src/log.js
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
)
|
|
8
|
-
|
|
4
|
+
const ANCHOR_LINE_RE = /^<!-- yoman: ([^.\s]+)\.\.(\S+) \| (\d{4}-\d{2}-\d{2}) -->$/;
|
|
5
|
+
// Heading separator: entries written today use " - "; v0.1 entries used an
|
|
6
|
+
// em-dash. Parse both, write only "-".
|
|
7
|
+
const HEADING_RE = /^## (\d{4}-\d{2}-\d{2}) [-—] (.*)$/;
|
|
8
|
+
const INTENT_RE = /^\*\*Stated intent:\*\* (.*)$/;
|
|
9
|
+
|
|
10
|
+
function resolveLogFile(logFile, cwd) {
|
|
11
|
+
return path.isAbsolute(logFile) ? logFile : path.join(cwd, logFile);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildEntryBlock({ fromSha, toSha, isoDate, title, why, modelBody }) {
|
|
9
15
|
const anchor = `<!-- yoman: ${fromSha}..${toSha} | ${isoDate} -->`;
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
return `${anchor}\n## ${isoDate} - ${title}\n**Stated intent:** ${why}\n\n${modelBody.trim()}\n`;
|
|
17
|
+
}
|
|
12
18
|
|
|
19
|
+
// Locate the topmost anchored block: [start, end) character offsets.
|
|
20
|
+
function findLatestBlock(content) {
|
|
21
|
+
const start = content.search(/^<!-- yoman: /m);
|
|
22
|
+
if (start === -1) return null;
|
|
23
|
+
const afterAnchorLine = content.indexOf('\n', start) + 1;
|
|
24
|
+
const nextRel = content.slice(afterAnchorLine).search(/^<!-- yoman: /m);
|
|
25
|
+
const end = nextRel === -1 ? content.length : afterAnchorLine + nextRel;
|
|
26
|
+
return { start, end };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function prependEntry({ logFile, block }, cwd = process.cwd()) {
|
|
30
|
+
const file = resolveLogFile(logFile, cwd);
|
|
13
31
|
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
14
32
|
const headerMatch = existing.match(/^(#[^\n]*\n)/);
|
|
15
33
|
|
|
@@ -22,7 +40,56 @@ function prependEntry(
|
|
|
22
40
|
updated = `${block}\n${existing}`;
|
|
23
41
|
}
|
|
24
42
|
|
|
43
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
25
44
|
fs.writeFileSync(file, updated);
|
|
26
45
|
}
|
|
27
46
|
|
|
28
|
-
|
|
47
|
+
function readLatestEntry(logFile, cwd = process.cwd()) {
|
|
48
|
+
const file = resolveLogFile(logFile, cwd);
|
|
49
|
+
if (!fs.existsSync(file)) return null;
|
|
50
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
51
|
+
const loc = findLatestBlock(content);
|
|
52
|
+
if (!loc) return null;
|
|
53
|
+
|
|
54
|
+
const raw = content.slice(loc.start, loc.end);
|
|
55
|
+
const lines = raw.split('\n');
|
|
56
|
+
const anchorMatch = lines[0].match(ANCHOR_LINE_RE);
|
|
57
|
+
if (!anchorMatch) return null;
|
|
58
|
+
|
|
59
|
+
let title = null;
|
|
60
|
+
let isoDate = anchorMatch[3];
|
|
61
|
+
let why = null;
|
|
62
|
+
let bodyStart = -1;
|
|
63
|
+
for (let i = 1; i < lines.length; i++) {
|
|
64
|
+
const heading = lines[i].match(HEADING_RE);
|
|
65
|
+
if (heading && title === null) {
|
|
66
|
+
isoDate = heading[1];
|
|
67
|
+
title = heading[2];
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const intent = lines[i].match(INTENT_RE);
|
|
71
|
+
if (intent && why === null) {
|
|
72
|
+
why = intent[1];
|
|
73
|
+
bodyStart = i + 1;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (title === null || why === null) return null;
|
|
78
|
+
|
|
79
|
+
const body = lines.slice(bodyStart).join('\n').replace(/^\n+/, '').replace(/\n+$/, '\n');
|
|
80
|
+
return { fromSha: anchorMatch[1], toSha: anchorMatch[2], isoDate, title, why, body, raw };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function replaceLatestEntry({ logFile, newBlock }, cwd = process.cwd()) {
|
|
84
|
+
const file = resolveLogFile(logFile, cwd);
|
|
85
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
86
|
+
const loc = findLatestBlock(content);
|
|
87
|
+
if (!loc) {
|
|
88
|
+
throw new Error(`no anchored entry found in ${logFile}`);
|
|
89
|
+
}
|
|
90
|
+
const withNewline = newBlock.endsWith('\n') ? newBlock : `${newBlock}\n`;
|
|
91
|
+
const sep = loc.end < content.length ? '\n' : '';
|
|
92
|
+
fs.writeFileSync(file, content.slice(0, loc.start) + withNewline + sep + content.slice(loc.end));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { buildEntryBlock, prependEntry, readLatestEntry, replaceLatestEntry };
|
package/src/prompt.js
CHANGED
|
@@ -7,7 +7,7 @@ const EXAMPLE_ENTRY = `**Summary:** Added a pending.json queue that saves the us
|
|
|
7
7
|
// The heading and "Stated intent" line are assembled deterministically by
|
|
8
8
|
// the caller (see log.js), never generated by the model - the model only
|
|
9
9
|
// ever sees the why as evidence, so it can't paraphrase or drop it.
|
|
10
|
-
function buildPrompt({ date, title, why, commitLines, diff, truncated }) {
|
|
10
|
+
function buildPrompt({ date, title, why, commitLines, diff, truncated, steeringNote }) {
|
|
11
11
|
return `You are a professional engineering log writer.
|
|
12
12
|
|
|
13
13
|
HARD RULES:
|
|
@@ -31,6 +31,7 @@ ${commitLines.join('\n')}
|
|
|
31
31
|
Diff (evidence only, filtered for secrets${truncated ? ', truncated' : ''}):
|
|
32
32
|
${diff}
|
|
33
33
|
|
|
34
|
+
${steeringNote ? `Reviewer note (style/emphasis guidance only, not new facts): ${steeringNote}\n` : ''}
|
|
34
35
|
OUTPUT FORMAT (return only markdown, nothing else, no code fences, no heading, no "Stated intent" line):
|
|
35
36
|
**Summary:** ...
|
|
36
37
|
|
package/src/review.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { select, input } = require('@inquirer/prompts');
|
|
2
|
+
|
|
3
|
+
// UI-only preview loop. Knows nothing about git, state, or pending - takes
|
|
4
|
+
// a ready-to-write entry block and a regenerate callback, returns a decision.
|
|
5
|
+
async function reviewLoop({ entryBlock, regenerate }) {
|
|
6
|
+
let current = entryBlock;
|
|
7
|
+
for (;;) {
|
|
8
|
+
console.log(`\n--- entry preview ${'-'.repeat(42)}`);
|
|
9
|
+
console.log(current.trim());
|
|
10
|
+
console.log('-'.repeat(60));
|
|
11
|
+
let action;
|
|
12
|
+
try {
|
|
13
|
+
action = await select({
|
|
14
|
+
message: 'Write this entry to the log?',
|
|
15
|
+
choices: [
|
|
16
|
+
{ name: 'Accept - write it', value: 'accept' },
|
|
17
|
+
{ name: 'Regenerate', value: 'regen' },
|
|
18
|
+
{ name: 'Abort - keep in pending', value: 'abort' },
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err.name === 'ExitPromptError') return { action: 'abort', entryBlock: current };
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
if (action !== 'regen') return { action, entryBlock: current };
|
|
26
|
+
const note = await input({ message: 'Steering note (optional, Enter to skip):' });
|
|
27
|
+
console.log('regenerating...');
|
|
28
|
+
current = await regenerate(note.trim() || null);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { reviewLoop };
|