@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 CHANGED
@@ -1,10 +1,65 @@
1
1
  # yoman
2
2
 
3
- Local-first CLI that generates engineering log entries (`DEVELOPMENT_LOG.md`) from git history + your own stated intent, via the Gemini API.
3
+ [![npm](https://img.shields.io/npm/v/@arik_shemesh/yoman)](https://www.npmjs.com/package/@arik_shemesh/yoman)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
5
 
5
- ## Why
6
+ **A development log your team will actually trust, because the "why" comes from you, not a guess.**
6
7
 
7
- Every existing "AI changelog" tool infers narrative from the diff alone - which means it hallucinates rationale. A diff shows *what* changed, never *why*. yoman fixes this by capturing your reasoning verbatim at milestone time, the one piece of information that was never in the diff to begin with. The diff is only used as supporting evidence.
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
- ## Global command
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 llmPrompt = prompt.buildPrompt({
54
- date: isoDate,
55
- title: item.title,
56
- why: item.why,
57
- commitLines,
58
- diff,
59
- truncated,
60
- });
61
-
62
- const modelBody = await llm.generateEntry({ model: cfg.model, prompt: llmPrompt });
63
-
64
- log.prependEntry(
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
- cwd
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 are still failing - a new run now would journal the same commits twice.'
160
+ 'yoman: pending entries remain - a new run now would journal the same commits twice.'
136
161
  );
137
- console.error('fix the problem above (model name / quota / network), then run `yoman retry`.');
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 = (message) =>
172
- input({ message, validate: (v) => v.trim().length > 0 || 'why is required' });
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.2.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
@@ -12,6 +12,7 @@ const DEFAULT_CONFIG = {
12
12
  minWhyWords: 15,
13
13
  diffCharLimit: 20000,
14
14
  nagThreshold: 5,
15
+ preview: true,
15
16
  };
16
17
 
17
18
  function configPath(cwd = process.cwd()) {
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, { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 * 50 });
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 patterns = logFile ? [...EXCLUDED_PATTERNS, logFile] : EXCLUDED_PATTERNS;
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({ apiKey: process.env.GEMINI_API_KEY });
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
- function prependEntry(
5
- { logFile, fromSha, toSha, isoDate, title, why, modelBody },
6
- cwd = process.cwd()
7
- ) {
8
- const file = path.isAbsolute(logFile) ? logFile : path.join(cwd, logFile);
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
- const entryMarkdown = `## ${isoDate} - ${title}\n**Stated intent:** ${why}\n\n${modelBody.trim()}`;
11
- const block = `${anchor}\n${entryMarkdown.trim()}\n`;
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
- module.exports = { prependEntry };
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 };