@arik_shemesh/yoman 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arik Shemesh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # yoman
2
+
3
+ Local-first CLI that generates engineering log entries (`DEVELOPMENT_LOG.md`) from git history + your own stated intent, via the Gemini API.
4
+
5
+ ## Why
6
+
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
+
9
+ ## Install
10
+
11
+ From npm:
12
+
13
+ ```
14
+ npm install -g @arik_shemesh/yoman
15
+ ```
16
+
17
+ Or from source:
18
+
19
+ ```
20
+ git clone https://github.com/ArikShemesh/yoman.git
21
+ cd yoman
22
+ npm install
23
+ ```
24
+
25
+ Create a `.env` file next to `cli.js` with your Gemini API key:
26
+
27
+ ```
28
+ GEMINI_API_KEY=your-key-here
29
+ ```
30
+
31
+ `.env` is gitignored - never committed.
32
+
33
+ ## Global command
34
+
35
+ Run once inside the cloned yoman folder:
36
+
37
+ ```
38
+ npm link
39
+ ```
40
+
41
+ This puts a `yoman` command on your PATH, so every command below is just `yoman`, `yoman init`, `yoman status`, etc.
42
+
43
+ ## Updating
44
+
45
+ ```
46
+ cd /path/to/yoman
47
+ git pull
48
+ npm install # only if dependencies changed
49
+ ```
50
+
51
+ Nothing else: `npm link` is a symlink to the clone, so pulled code is live everywhere immediately. New config defaults reach existing repos automatically - a `.yoman/config.json` without a newly added key just gets that key's default; add the key to the file only to override it. Installed hooks keep working untouched (they only call `yoman nag`; all logic lives in the clone).
52
+
53
+ ## Usage
54
+
55
+ Run these from inside the git repo you want to journal (not from the yoman folder itself, unless you're journaling yoman's own history):
56
+
57
+ ```
58
+ yoman init
59
+ ```
60
+
61
+ Creates `.yoman/config.json` and `.yoman/state.json` in the current repo, and `DEVELOPMENT_LOG.md` if it doesn't exist yet. Safe to re-run - refuses to overwrite and prints the current state instead.
62
+
63
+ ```
64
+ yoman
65
+ ```
66
+
67
+ The main command. Run it after finishing a milestone (one commit or several):
68
+
69
+ 1. Shows the commits since the last journal entry.
70
+ 2. Asks you to pick a title (derived from commit messages) or type your own.
71
+ 3. Asks **why** you made the change - free text, mandatory. Under ~15 words, it asks once more for more context, then accepts whatever you give it.
72
+ 4. Sends your stated intent + the commit messages + a secret-filtered diff to Gemini, and prepends a new entry to `DEVELOPMENT_LOG.md` (newest entry first).
73
+
74
+ If the API call fails (bad model name, rate limit, network), your title/why/commit-range is saved to `.yoman/pending.json` - nothing is lost. Fix whatever broke and run:
75
+
76
+ ```
77
+ yoman retry
78
+ ```
79
+
80
+ This flushes everything queued in `.yoman/pending.json`.
81
+
82
+ ## Nag hook
83
+
84
+ Forgetting to journal is the default failure mode. Install a reminder per repo:
85
+
86
+ ```
87
+ yoman hook
88
+ ```
89
+
90
+ This adds a `post-commit` hook that prints one line once you have `nagThreshold` (default 5) unjournaled commits - and stays silent otherwise. It never blocks or breaks a commit: any yoman error is swallowed. If your repo manages hooks via `core.hooksPath` (husky etc.), yoman refuses to touch the managed directory and prints the line to add manually.
91
+
92
+ `yoman status` shows where you stand any time: model, unjournaled commits, pending queue.
93
+
94
+ ## What gets sent to the LLM, and what doesn't
95
+
96
+ - Sent: commit subjects, a filtered diff, your stated intent, the entry date (the day the milestone was recorded).
97
+ - Never sent: anything matching `.env`, `.env.*`, `*.pem`, `*.key`, `*credentials*`, `*secret*`, lockfiles (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`), the `.yoman/` directory, or the log file itself.
98
+ - The prompt explicitly instructs the model that your stated intent is the *only* source of truth for motivation - the diff is evidence of *what* changed, never *why*. If no rationale was stated, the entry says "Rationale not recorded" instead of guessing.
99
+ - The "Stated intent" line in every log entry is your verbatim text, assembled by the code - never something the model is trusted to reproduce or paraphrase.
100
+
101
+ ## Config
102
+
103
+ `.yoman/config.json` (created by `init`, per-repo):
104
+
105
+ ```json
106
+ {
107
+ "model": "gemini-flash-latest",
108
+ "logFile": "DEVELOPMENT_LOG.md",
109
+ "minWhyWords": 15,
110
+ "diffCharLimit": 20000,
111
+ "nagThreshold": 5
112
+ }
113
+ ```
114
+
115
+ Edit freely - e.g. swap `model` if Gemini retires/renames one (this happens; the tool maps 404s to a clear "update your config" message instead of a stack trace).
116
+
117
+ `.yoman/state.json` tracks `lastSha` - the last commit journaled. Commit both `.yoman/` and your log file to your repo so state travels with it.
118
+
119
+ ## Not yet included
120
+
121
+ Deliberately deferred:
122
+
123
+ - No npm registry publish (global command is via `npm link` only)
124
+ - No other LLM providers (Gemini only)
125
+ - No log splitting or cross-entry narrative continuity
126
+
127
+ ## Project layout
128
+
129
+ ```
130
+ cli.js entry point, command dispatch (init / run / retry / status / hook)
131
+ src/
132
+ config.js .yoman/config.json load/validate + .env key
133
+ state.js .yoman/state.json (lastSha) read/write
134
+ git.js commit list, filtered diff, secret-path exclusion
135
+ titles.js local (non-AI) title candidates from commit subjects
136
+ prompt.js hallucination-guarded prompt construction
137
+ llm.js Gemini API call + friendly error mapping
138
+ log.js prepend entry to DEVELOPMENT_LOG.md with SHA-range anchor
139
+ pending.js save-before-call queue (.yoman/pending.json)
140
+ ```
package/cli.js ADDED
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ async function cmdInit() {
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const config = require('./src/config');
6
+ const state = require('./src/state');
7
+ const git = require('./src/git');
8
+
9
+ const cwd = process.cwd();
10
+
11
+ if (config.isInitialized(cwd)) {
12
+ const existingConfig = JSON.parse(fs.readFileSync(config.configPath(cwd), 'utf8'));
13
+ const existingState = state.loadState(cwd);
14
+ console.log('yoman already initialized in this directory.');
15
+ console.log(` config: ${JSON.stringify(existingConfig)}`);
16
+ console.log(` state: ${JSON.stringify(existingState)}`);
17
+ return;
18
+ }
19
+
20
+ const head = git.currentHead(cwd);
21
+ config.writeConfig(config.DEFAULT_CONFIG, cwd);
22
+ state.writeState({ lastSha: head }, cwd);
23
+
24
+ const logFile = path.join(cwd, config.DEFAULT_CONFIG.logFile);
25
+ if (!fs.existsSync(logFile)) {
26
+ fs.writeFileSync(logFile, '# Development Log\n');
27
+ }
28
+
29
+ console.log('yoman initialized.');
30
+ console.log(` config: ${config.configPath(cwd)}`);
31
+ console.log(` state: lastSha=${head}`);
32
+ console.log(` log: ${logFile}`);
33
+ }
34
+
35
+ // Shared by run (fresh milestone) and retry/auto-flush (queued milestone):
36
+ // builds the filtered diff, calls the LLM, prepends the log entry, and
37
+ // advances state.lastSha to the item's range end (plan step 9 applies to
38
+ // both call sites).
39
+ async function processPendingItem(item, cfg, cwd) {
40
+ const git = require('./src/git');
41
+ const prompt = require('./src/prompt');
42
+ const llm = require('./src/llm');
43
+ const log = require('./src/log');
44
+ const state = require('./src/state');
45
+
46
+ const { from, to } = item.shaRange;
47
+ const commitLines = git.commitOneliners(from, to, cwd);
48
+ const { diff, truncated } = git.filteredDiff(from, to, cfg.diffCharLimit, cwd, cfg.logFile);
49
+ // Entry date = the day the human recorded the milestone, not the day a
50
+ // queued item finally flushed (may differ after a failed run + later retry).
51
+ const isoDate = item.timestamp.slice(0, 10);
52
+
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,
67
+ fromSha: from,
68
+ toSha: to,
69
+ isoDate,
70
+ title: item.title,
71
+ why: item.why,
72
+ modelBody,
73
+ },
74
+ cwd
75
+ );
76
+
77
+ state.writeState({ lastSha: to }, cwd);
78
+ }
79
+
80
+ // Flush the pending queue: journal each queued item, dropping items whose
81
+ // range was already journaled (item.to is an ancestor of current lastSha).
82
+ // Stale items happen when a run covering the same commits succeeded after
83
+ // the item was queued; processing them would duplicate log entries and
84
+ // regress state.lastSha.
85
+ async function flushPending(cfg, cwd) {
86
+ const pending = require('./src/pending');
87
+ const git = require('./src/git');
88
+ const state = require('./src/state');
89
+
90
+ const items = pending.loadPending(cwd);
91
+ const result = { total: items.length, recovered: 0, dropped: 0, failed: 0 };
92
+
93
+ for (const item of items) {
94
+ const st = state.loadState(cwd);
95
+ if (git.isAncestor(item.shaRange.to, st.lastSha, cwd)) {
96
+ pending.removePending(item.timestamp, cwd);
97
+ result.dropped++;
98
+ console.log(`dropped stale pending entry (commits already journaled): ${item.title}`);
99
+ continue;
100
+ }
101
+ try {
102
+ await processPendingItem(item, cfg, cwd);
103
+ pending.removePending(item.timestamp, cwd);
104
+ result.recovered++;
105
+ console.log(`recovered: ${item.title}`);
106
+ } catch (err) {
107
+ result.failed++;
108
+ console.error(`yoman: still failing for "${item.title}": ${err.message}`);
109
+ }
110
+ }
111
+
112
+ return result;
113
+ }
114
+
115
+ async function cmdRun() {
116
+ const { select, input } = require('@inquirer/prompts');
117
+ const config = require('./src/config');
118
+ const state = require('./src/state');
119
+ const git = require('./src/git');
120
+ const titles = require('./src/titles');
121
+ const pending = require('./src/pending');
122
+
123
+ const cwd = process.cwd();
124
+ const cfg = config.loadConfig(cwd);
125
+ config.requireApiKey();
126
+
127
+ // Flush leftovers from failed runs BEFORE computing the new range.
128
+ // Otherwise the new range overlaps the queued one and the same commits
129
+ // get journaled twice with state.lastSha regressing afterward.
130
+ if (pending.loadPending(cwd).length > 0) {
131
+ console.log('pending entries from a failed run found - recovering them first...');
132
+ const flush = await flushPending(cfg, cwd);
133
+ if (flush.failed > 0) {
134
+ console.error(
135
+ 'yoman: pending entries are still failing - a new run now would journal the same commits twice.'
136
+ );
137
+ console.error('fix the problem above (model name / quota / network), then run `yoman retry`.');
138
+ process.exitCode = 1;
139
+ return;
140
+ }
141
+ }
142
+
143
+ const st = state.loadState(cwd);
144
+ const head = git.currentHead(cwd);
145
+
146
+ const count = git.commitCount(st.lastSha, head, cwd);
147
+ if (count === 0) {
148
+ console.log('nothing to journal (no commits since last entry).');
149
+ return;
150
+ }
151
+
152
+ const oneliners = git.commitOneliners(st.lastSha, head, cwd);
153
+ console.log(`${count} commit(s) since last entry:`);
154
+ for (const line of oneliners.slice(0, 20)) console.log(` ${line}`);
155
+ if (oneliners.length > 20) console.log(` +${oneliners.length - 20} more`);
156
+
157
+ const subjects = git.commitSubjects(st.lastSha, head, cwd);
158
+ const OTHER = '__other__';
159
+ const choices = [
160
+ ...titles.deriveTitles(subjects).map((t) => ({ name: t, value: t })),
161
+ { name: 'Other (type manually)', value: OTHER },
162
+ ];
163
+ let title = await select({ message: 'Title for this entry:', choices });
164
+ if (title === OTHER) {
165
+ title = await input({
166
+ message: 'Enter a title:',
167
+ validate: (v) => v.trim().length > 0 || 'title is required',
168
+ });
169
+ }
170
+
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
+ }
182
+
183
+ const item = {
184
+ title,
185
+ why,
186
+ shaRange: { from: st.lastSha, to: head },
187
+ timestamp: new Date().toISOString(),
188
+ };
189
+ pending.addPending(item, cwd);
190
+
191
+ try {
192
+ await processPendingItem(item, cfg, cwd);
193
+ pending.removePending(item.timestamp, cwd);
194
+ console.log(`entry added to ${cfg.logFile}.`);
195
+ } catch (err) {
196
+ console.error(`yoman: ${err.message}`);
197
+ console.error('your input is saved - run `yoman retry` later.');
198
+ process.exitCode = 1;
199
+ }
200
+ }
201
+
202
+ async function cmdRetry() {
203
+ const config = require('./src/config');
204
+ const pending = require('./src/pending');
205
+ const cwd = process.cwd();
206
+ const cfg = config.loadConfig(cwd);
207
+ config.requireApiKey();
208
+
209
+ if (pending.loadPending(cwd).length === 0) {
210
+ console.log('nothing pending.');
211
+ return;
212
+ }
213
+
214
+ const result = await flushPending(cfg, cwd);
215
+ console.log(
216
+ `${result.recovered}/${result.total} recovered, ${result.dropped} dropped as stale, ${result.failed} still pending.`
217
+ );
218
+ if (result.failed > 0) process.exitCode = 1;
219
+ }
220
+
221
+ async function cmdStatus() {
222
+ const config = require('./src/config');
223
+ const state = require('./src/state');
224
+ const git = require('./src/git');
225
+ const pending = require('./src/pending');
226
+
227
+ const cwd = process.cwd();
228
+ const cfg = config.loadConfig(cwd);
229
+ const st = state.loadState(cwd);
230
+ const head = git.currentHead(cwd);
231
+ const count = git.commitCount(st.lastSha, head, cwd);
232
+ const pendingCount = pending.loadPending(cwd).length;
233
+
234
+ console.log(`model: ${cfg.model}`);
235
+ console.log(`log file: ${cfg.logFile}`);
236
+ console.log(`nag threshold: ${cfg.nagThreshold}`);
237
+ console.log(`last journaled sha: ${st.lastSha.slice(0, 7)}`);
238
+ console.log(
239
+ `pending queue: ${pendingCount === 0 ? 'none' : `${pendingCount} item(s) - run \`yoman retry\``}`
240
+ );
241
+ if (count === 0) {
242
+ console.log('commits since last entry: none');
243
+ return;
244
+ }
245
+ console.log(`commits since last entry: ${count}`);
246
+ const oneliners = git.commitOneliners(st.lastSha, head, cwd);
247
+ for (const line of oneliners.slice(0, 5)) console.log(` ${line}`);
248
+ if (oneliners.length > 5) console.log(` +${oneliners.length - 5} more`);
249
+ }
250
+
251
+ // Called from the post-commit hook. Must NEVER break or pollute a commit:
252
+ // prints nothing below threshold, prints nothing on ANY error, always exits 0.
253
+ async function cmdNag() {
254
+ try {
255
+ const config = require('./src/config');
256
+ const state = require('./src/state');
257
+ const git = require('./src/git');
258
+
259
+ const cwd = process.cwd();
260
+ const cfg = config.loadConfig(cwd);
261
+ const st = state.loadState(cwd);
262
+ const head = git.currentHead(cwd);
263
+ const count = git.commitCount(st.lastSha, head, cwd);
264
+ if (count >= cfg.nagThreshold) {
265
+ console.log(`yoman: ${count} commits unjournaled - consider a milestone entry`);
266
+ }
267
+ } catch (err) {
268
+ // silent by contract
269
+ }
270
+ }
271
+
272
+ async function cmdHook() {
273
+ const fs = require('fs');
274
+ const path = require('path');
275
+ const { execFileSync } = require('child_process');
276
+ const cwd = process.cwd();
277
+
278
+ const MARKER = '# yoman-nag';
279
+ const CALL_LINE = 'yoman nag 2>/dev/null || true';
280
+
281
+ let hooksPath = '';
282
+ try {
283
+ hooksPath = execFileSync('git', ['config', 'core.hooksPath'], { cwd, encoding: 'utf8' }).trim();
284
+ } catch (err) {
285
+ // exit 1 = unset; we manage .git/hooks ourselves
286
+ }
287
+
288
+ if (hooksPath) {
289
+ console.log(`core.hooksPath is set (${hooksPath}) - another tool manages hooks in this repo.`);
290
+ console.log('yoman will not modify a managed hooks directory.');
291
+ console.log('Add these two lines to that post-commit hook yourself:');
292
+ console.log(` ${MARKER}`);
293
+ console.log(` ${CALL_LINE}`);
294
+ return;
295
+ }
296
+
297
+ const gitDir = execFileSync('git', ['rev-parse', '--git-dir'], { cwd, encoding: 'utf8' }).trim();
298
+ const hookFile = path.resolve(cwd, gitDir, 'hooks', 'post-commit');
299
+
300
+ if (fs.existsSync(hookFile)) {
301
+ const content = fs.readFileSync(hookFile, 'utf8');
302
+ if (content.includes(MARKER)) {
303
+ console.log('yoman nag hook already installed.');
304
+ return;
305
+ }
306
+ const updated = content.replace(/\n*$/, '\n') + `\n${MARKER}\n${CALL_LINE}\n`;
307
+ fs.writeFileSync(hookFile, updated);
308
+ console.log(`appended yoman nag to existing post-commit hook: ${hookFile}`);
309
+ } else {
310
+ fs.mkdirSync(path.dirname(hookFile), { recursive: true });
311
+ fs.writeFileSync(hookFile, `#!/bin/sh\n\n${MARKER}\n${CALL_LINE}\n`);
312
+ console.log(`created post-commit hook: ${hookFile}`);
313
+ }
314
+
315
+ try {
316
+ fs.chmodSync(hookFile, 0o755);
317
+ } catch (err) {
318
+ // best-effort; git-bash on Windows executes hooks regardless
319
+ }
320
+ }
321
+
322
+ async function main() {
323
+ const [, , sub] = process.argv;
324
+
325
+ if (sub === 'init') return cmdInit();
326
+ if (sub === 'retry') return cmdRetry();
327
+ if (sub === 'status') return cmdStatus();
328
+ if (sub === 'nag') return cmdNag();
329
+ if (sub === 'hook') return cmdHook();
330
+ if (sub && sub !== 'run') {
331
+ console.error(`yoman: unknown command "${sub}"`);
332
+ process.exitCode = 1;
333
+ return;
334
+ }
335
+ return cmdRun();
336
+ }
337
+
338
+ main().catch((err) => {
339
+ console.error(`yoman: ${err.message}`);
340
+ process.exitCode = 1;
341
+ });
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@arik_shemesh/yoman",
3
+ "version": "0.2.0",
4
+ "description": "Local-first CLI that generates engineering log entries from git history + human-provided intent, via Gemini API.",
5
+ "main": "cli.js",
6
+ "bin": {
7
+ "yoman": "cli.js"
8
+ },
9
+ "files": [
10
+ "cli.js",
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ArikShemesh/yoman.git"
18
+ },
19
+ "scripts": {
20
+ "start": "node cli.js"
21
+ },
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "@google/genai": "^1.0.0",
25
+ "@inquirer/prompts": "^7.0.0",
26
+ "dotenv": "^16.4.5"
27
+ }
28
+ }
package/src/config.js ADDED
@@ -0,0 +1,45 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
5
+
6
+ const YOMAN_DIR = '.yoman';
7
+ const CONFIG_FILE = 'config.json';
8
+
9
+ const DEFAULT_CONFIG = {
10
+ model: 'gemini-flash-latest',
11
+ logFile: 'DEVELOPMENT_LOG.md',
12
+ minWhyWords: 15,
13
+ diffCharLimit: 20000,
14
+ nagThreshold: 5,
15
+ };
16
+
17
+ function configPath(cwd = process.cwd()) {
18
+ return path.join(cwd, YOMAN_DIR, CONFIG_FILE);
19
+ }
20
+
21
+ function isInitialized(cwd = process.cwd()) {
22
+ return fs.existsSync(configPath(cwd));
23
+ }
24
+
25
+ function loadConfig(cwd = process.cwd()) {
26
+ if (!isInitialized(cwd)) {
27
+ throw new Error('not initialized in this directory. Run `yoman init` first.');
28
+ }
29
+ const raw = fs.readFileSync(configPath(cwd), 'utf8');
30
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
31
+ }
32
+
33
+ function requireApiKey() {
34
+ if (!process.env.GEMINI_API_KEY) {
35
+ throw new Error('GEMINI_API_KEY not set. Add it to the .env file next to cli.js.');
36
+ }
37
+ }
38
+
39
+ function writeConfig(config, cwd = process.cwd()) {
40
+ const file = configPath(cwd);
41
+ fs.mkdirSync(path.dirname(file), { recursive: true });
42
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
43
+ }
44
+
45
+ module.exports = { DEFAULT_CONFIG, configPath, isInitialized, loadConfig, writeConfig, requireApiKey };
package/src/git.js ADDED
@@ -0,0 +1,72 @@
1
+ const { execFileSync } = require('child_process');
2
+
3
+ const EXCLUDED_PATTERNS = [
4
+ '.env',
5
+ '.env.*',
6
+ '*.pem',
7
+ '*.key',
8
+ '*credentials*',
9
+ '*secret*',
10
+ 'package-lock.json',
11
+ 'yarn.lock',
12
+ 'pnpm-lock.yaml',
13
+ '.yoman/**',
14
+ ];
15
+
16
+ function git(args, cwd) {
17
+ return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 * 50 });
18
+ }
19
+
20
+ function currentHead(cwd = process.cwd()) {
21
+ return git(['rev-parse', 'HEAD'], cwd).trim();
22
+ }
23
+
24
+ // true if maybeAncestor is an ancestor of sha (or the same commit)
25
+ function isAncestor(maybeAncestor, sha, cwd = process.cwd()) {
26
+ try {
27
+ git(['merge-base', '--is-ancestor', maybeAncestor, sha], cwd);
28
+ return true;
29
+ } catch (err) {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function commitCount(fromSha, toSha, cwd = process.cwd()) {
35
+ const out = git(['rev-list', `${fromSha}..${toSha}`], cwd).trim();
36
+ return out ? out.split('\n').length : 0;
37
+ }
38
+
39
+ function commitSubjects(fromSha, toSha, cwd = process.cwd()) {
40
+ const out = git(['log', '--format=%s', `${fromSha}..${toSha}`], cwd).trim();
41
+ return out ? out.split('\n') : [];
42
+ }
43
+
44
+ function commitOneliners(fromSha, toSha, cwd = process.cwd()) {
45
+ const out = git(['log', '--format=%h %s', `${fromSha}..${toSha}`], cwd).trim();
46
+ return out ? out.split('\n') : [];
47
+ }
48
+
49
+ function filteredDiff(fromSha, toSha, charLimit, cwd = process.cwd(), logFile) {
50
+ const patterns = logFile ? [...EXCLUDED_PATTERNS, logFile] : EXCLUDED_PATTERNS;
51
+ const excludeArgs = patterns.map((p) => `:(exclude)${p}`);
52
+ const raw = git(['diff', fromSha, toSha, '--', '.', ...excludeArgs], cwd);
53
+ if (raw.length <= charLimit) {
54
+ return { diff: raw, truncated: false };
55
+ }
56
+ const truncated = raw.slice(0, charLimit);
57
+ const lastHunk = truncated.lastIndexOf('\ndiff --git ');
58
+ const cut = lastHunk > 0 ? truncated.slice(0, lastHunk) : truncated;
59
+ return {
60
+ diff: `${cut}\n\n[... diff truncated at ${charLimit} chars ...]\n`,
61
+ truncated: true,
62
+ };
63
+ }
64
+
65
+ module.exports = {
66
+ currentHead,
67
+ isAncestor,
68
+ commitCount,
69
+ commitSubjects,
70
+ commitOneliners,
71
+ filteredDiff,
72
+ };
package/src/llm.js ADDED
@@ -0,0 +1,21 @@
1
+ const { GoogleGenAI } = require('@google/genai');
2
+
3
+ async function generateEntry({ model, prompt }) {
4
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
5
+ try {
6
+ const response = await ai.models.generateContent({ model, contents: prompt });
7
+ return response.text;
8
+ } catch (err) {
9
+ if (err.status === 404) {
10
+ throw new Error(
11
+ `model "${model}" not found or not available for this key. Update "model" in .yoman/config.json. (${err.message})`
12
+ );
13
+ }
14
+ if (err.status === 429) {
15
+ throw new Error(`rate limited / quota exceeded for model "${model}". (${err.message})`);
16
+ }
17
+ throw new Error(`Gemini API call failed: ${err.message}`);
18
+ }
19
+ }
20
+
21
+ module.exports = { generateEntry };
package/src/log.js ADDED
@@ -0,0 +1,28 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
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);
9
+ 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`;
12
+
13
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
14
+ const headerMatch = existing.match(/^(#[^\n]*\n)/);
15
+
16
+ let updated;
17
+ if (headerMatch) {
18
+ const header = headerMatch[1];
19
+ const rest = existing.slice(header.length).replace(/^\n+/, '');
20
+ updated = `${header}\n${block}\n${rest}`;
21
+ } else {
22
+ updated = `${block}\n${existing}`;
23
+ }
24
+
25
+ fs.writeFileSync(file, updated);
26
+ }
27
+
28
+ module.exports = { prependEntry };
package/src/pending.js ADDED
@@ -0,0 +1,43 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const YOMAN_DIR = '.yoman';
5
+ const PENDING_FILE = 'pending.json';
6
+
7
+ function pendingPath(cwd = process.cwd()) {
8
+ return path.join(cwd, YOMAN_DIR, PENDING_FILE);
9
+ }
10
+
11
+ function loadPending(cwd = process.cwd()) {
12
+ const file = pendingPath(cwd);
13
+ if (!fs.existsSync(file)) return [];
14
+ const raw = fs.readFileSync(file, 'utf8').trim();
15
+ return raw ? JSON.parse(raw) : [];
16
+ }
17
+
18
+ function writePending(items, cwd = process.cwd()) {
19
+ const file = pendingPath(cwd);
20
+ if (items.length === 0) {
21
+ if (fs.existsSync(file)) fs.unlinkSync(file);
22
+ return;
23
+ }
24
+ fs.mkdirSync(path.dirname(file), { recursive: true });
25
+ fs.writeFileSync(file, JSON.stringify(items, null, 2) + '\n');
26
+ }
27
+
28
+ // item: { title, why, shaRange: { from, to }, timestamp }
29
+ function addPending(item, cwd = process.cwd()) {
30
+ const items = loadPending(cwd);
31
+ items.push(item);
32
+ writePending(items, cwd);
33
+ return item;
34
+ }
35
+
36
+ function removePending(timestamp, cwd = process.cwd()) {
37
+ const items = loadPending(cwd);
38
+ const remaining = items.filter((item) => item.timestamp !== timestamp);
39
+ writePending(remaining, cwd);
40
+ return remaining;
41
+ }
42
+
43
+ module.exports = { pendingPath, loadPending, writePending, addPending, removePending };
package/src/prompt.js ADDED
@@ -0,0 +1,43 @@
1
+ const EXAMPLE_ENTRY = `**Summary:** Added a pending.json queue that saves the user's input before the API call, so a failed call doesn't lose it. Added \`yoman retry\` to flush the queue.
2
+
3
+ **Key technical decisions:** Save-before-call ordering - the write happens before the network request, not after a catch block, so a crash mid-request still leaves the data on disk.
4
+
5
+ **Impact:** API failures no longer lose the user's context; retry is a manual re-run away.`;
6
+
7
+ // The heading and "Stated intent" line are assembled deterministically by
8
+ // the caller (see log.js), never generated by the model - the model only
9
+ // ever sees the why as evidence, so it can't paraphrase or drop it.
10
+ function buildPrompt({ date, title, why, commitLines, diff, truncated }) {
11
+ return `You are a professional engineering log writer.
12
+
13
+ HARD RULES:
14
+ - The human context below is the ONLY source of truth for intent and motivation.
15
+ - The diff and commit messages are evidence of WHAT changed only.
16
+ - Never invent motivations, alternatives, or reasoning not stated in the context.
17
+ - If rationale for a change is not stated, write "Rationale not recorded."
18
+ - The entry date is ${date}. Do not infer or invent any other date.
19
+
20
+ EXAMPLE OF A GOOD RESPONSE BODY (format reference only, not this task's content):
21
+ ${EXAMPLE_ENTRY}
22
+
23
+ INPUTS:
24
+ Entry date: ${date}
25
+ Title: ${title}
26
+ Stated intent (context only - do not repeat it verbatim, the caller already displays it separately): ${why}
27
+
28
+ Commit messages in range:
29
+ ${commitLines.join('\n')}
30
+
31
+ Diff (evidence only, filtered for secrets${truncated ? ', truncated' : ''}):
32
+ ${diff}
33
+
34
+ OUTPUT FORMAT (return only markdown, nothing else, no code fences, no heading, no "Stated intent" line):
35
+ **Summary:** ...
36
+
37
+ **Key technical decisions:** ...
38
+
39
+ **Impact:** ...
40
+ `;
41
+ }
42
+
43
+ module.exports = { buildPrompt };
package/src/state.js ADDED
@@ -0,0 +1,25 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const YOMAN_DIR = '.yoman';
5
+ const STATE_FILE = 'state.json';
6
+
7
+ function statePath(cwd = process.cwd()) {
8
+ return path.join(cwd, YOMAN_DIR, STATE_FILE);
9
+ }
10
+
11
+ function loadState(cwd = process.cwd()) {
12
+ const file = statePath(cwd);
13
+ if (!fs.existsSync(file)) {
14
+ throw new Error('not initialized in this directory. Run `yoman init` first.');
15
+ }
16
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
17
+ }
18
+
19
+ function writeState(state, cwd = process.cwd()) {
20
+ const file = statePath(cwd);
21
+ fs.mkdirSync(path.dirname(file), { recursive: true });
22
+ fs.writeFileSync(file, JSON.stringify(state, null, 2) + '\n');
23
+ }
24
+
25
+ module.exports = { statePath, loadState, writeState };
package/src/titles.js ADDED
@@ -0,0 +1,25 @@
1
+ function clean(subject) {
2
+ return subject.trim().replace(/\s+/g, ' ');
3
+ }
4
+
5
+ // Local heuristic (no API call): most-recent subject, oldest subject, and a
6
+ // count summary, deduplicated. Good enough to seed a menu, not meant to be smart.
7
+ function deriveTitles(commitSubjects) {
8
+ const subjects = commitSubjects.map(clean).filter(Boolean);
9
+ if (subjects.length === 0) return [];
10
+
11
+ const options = [];
12
+ const add = (title) => {
13
+ if (title && !options.includes(title)) options.push(title);
14
+ };
15
+
16
+ add(subjects[0]);
17
+ if (subjects.length > 1) {
18
+ add(subjects[subjects.length - 1]);
19
+ add(`${subjects.length} changes: ${subjects[0]}`);
20
+ }
21
+
22
+ return options.slice(0, 3);
23
+ }
24
+
25
+ module.exports = { deriveTitles };