@su-record/vibe 4.2.19 → 4.2.21

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.
@@ -0,0 +1,145 @@
1
+ // Retest gate: a test command that already passed on exactly this code is not run again. PostToolUse(Bash)
2
+ // remembers a passing test command with the state of the git work tree after it; PreToolUse(Bash) stops the same
3
+ // command, from the same folder, while that state is unchanged. A failed run is never remembered, so a fix can
4
+ // always be tested. `vibe retest block|warn|off` is the person's setting (block by default); `VIBE_RETEST=1`
5
+ // in front of a command lets one run through when the user asked for it or the test depends on more than files.
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const { spawnSync } = require('node:child_process');
9
+ const { createHash } = require('node:crypto');
10
+ const { segments, runOutcome } = require('./knowledge-journal.cjs');
11
+ const { readPrivate, writePrivate } = require('./private-store.cjs');
12
+
13
+ const DAY = 86400000;
14
+ const MODES = ['block', 'warn', 'off'];
15
+ const KEEP_PASSED = 100;
16
+ const KEEP_EVENTS = 500;
17
+ const MAX_FILES = 2000;
18
+ const HASH_LIMIT = 16777216;
19
+
20
+ const settingsFile = home => path.join(home, '.config', 'vibe', 'retest.json');
21
+ function mode(home) {
22
+ try { const raw = JSON.parse(fs.readFileSync(settingsFile(home), 'utf8')); return MODES.includes(raw.mode) ? raw.mode : 'block'; } catch { return 'block'; }
23
+ }
24
+
25
+ // Test runners and the scripts that usually run them; `vibe check` is the harness's own verdict run.
26
+ const TEST = [
27
+ /^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check)(?::\S+)?(?:\s|$)/,
28
+ /^(?:npx\s+|bunx\s+|pnpm\s+(?:exec\s+|dlx\s+)?|yarn\s+)?(?:vitest|jest|mocha|ava|playwright\s+test|cypress\s+run)(?:\s|$)/,
29
+ /^(?:python3?\s+-m\s+)?(?:pytest|unittest)(?:\s|$)/, /^(?:uv|poetry)\s+run\s+pytest(?:\s|$)/,
30
+ /^go\s+test(?:\s|$)/, /^cargo\s+(?:test|nextest)(?:\s|$)/, /^dotnet\s+test(?:\s|$)/, /^deno\s+test(?:\s|$)/,
31
+ /^(?:\.\/)?(?:mvnw?|gradlew?)\s+(?:\S+\s+)*(?:test|check|verify)(?:\s|$)/, /^(?:bundle\s+exec\s+)?rspec(?:\s|$)/,
32
+ /^(?:\.\/vendor\/bin\/)?phpunit(?:\s|$)/, /^node\s+--test(?:\s|$)/, /^make\s+(?:test|check)(?:\s|$)/,
33
+ /^vibe\s+check(?:\s|$)/, /^node\s+\S*dist\/cli\.js\s+check(?:\s|$)/,
34
+ ];
35
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/;
36
+ const withoutAssignments = segment => { let s = segment; while (ASSIGNMENT.test(s)) s = s.replace(ASSIGNMENT, ''); return s; };
37
+
38
+ // A client may hand the command over wrapped in a shell, as `bash -lc "…"` or as an argument list.
39
+ function unwrapped(raw) {
40
+ if (Array.isArray(raw)) return /(?:^|\/)(?:ba|z)?sh$/.test(String(raw[0])) && /^-l?c$/.test(String(raw[1])) ? String(raw[2] ?? '') : raw.join(' ');
41
+ const text = String(raw ?? '');
42
+ const shell = /^\s*(?:\/\S*\/)?(?:ba|z)?sh\s+-l?c\s+(['"])([\s\S]*)\1\s*$/.exec(text);
43
+ return shell ? shell[2] : text;
44
+ }
45
+
46
+ /** The command as remembered (leading variable assignments dropped, spaces collapsed) when it runs a test, else null. */
47
+ function testCommand(command) {
48
+ const parts = segments(unwrapped(command)).map(withoutAssignments);
49
+ return parts.some(part => TEST.some(re => re.test(part))) ? parts.join(' && ').replace(/\s+/g, ' ') : null;
50
+ }
51
+ const bypassed = command => segments(unwrapped(command)).some(part => /^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*VIBE_RETEST=1(?:\s|$)/.test(part));
52
+
53
+ function git(cwd, args) {
54
+ const r = spawnSync('git', args, { cwd, encoding: 'buffer', timeout: 3000, windowsHide: true });
55
+ return r.status === 0 ? r.stdout : null;
56
+ }
57
+ function fileId(file) {
58
+ try {
59
+ const stat = fs.statSync(file);
60
+ if (!stat.isFile()) return 'other';
61
+ if (stat.size > HASH_LIMIT) return `${stat.size}:${stat.mtimeMs}`;
62
+ return createHash('sha1').update(fs.readFileSync(file)).digest('hex');
63
+ } catch { return 'deleted'; }
64
+ }
65
+
66
+ /** The git work tree a command runs in and a fingerprint of its content: HEAD plus every changed or untracked file, `.vibe/` aside. */
67
+ function workState(cwd) {
68
+ const top = git(cwd, ['rev-parse', '--show-toplevel', 'HEAD'])?.toString('utf8').trim().split('\n');
69
+ if (!top || top.length !== 2) return null;
70
+ const [root, head] = top;
71
+ const status = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames']);
72
+ if (status === null) return null;
73
+ const files = status.toString('utf8').split('\0').filter(Boolean).map(entry => entry.slice(3)).filter(file => !file.startsWith('.vibe/')).sort();
74
+ if (files.length > MAX_FILES) return null;
75
+ const hash = createHash('sha256').update(head);
76
+ for (const file of files) hash.update(`\n${file}:${fileId(path.join(root, file))}`);
77
+ return { root: fs.realpathSync(root), state: hash.digest('hex').slice(0, 16) };
78
+ }
79
+
80
+ const storeName = root => `retest-${createHash('sha256').update(root).digest('hex').slice(0, 32)}.json`;
81
+ function readStore(root, home) {
82
+ try {
83
+ const raw = JSON.parse(readPrivate(root, storeName(root), 1048576, home) ?? '{}');
84
+ return { passed: Array.isArray(raw.passed) ? raw.passed : [], events: Array.isArray(raw.events) ? raw.events : [] };
85
+ } catch { return { passed: [], events: [] }; }
86
+ }
87
+ const writeStore = (root, store, home) => writePrivate(root, storeName(root), JSON.stringify(store), home);
88
+
89
+ function where(payload) {
90
+ const input = payload.tool_input ?? {};
91
+ const cwd = input.cwd ?? input.workdir ?? payload.cwd ?? process.cwd();
92
+ return path.resolve(typeof cwd === 'string' ? cwd : process.cwd());
93
+ }
94
+ const folderOf = (root, cwd) => { try { return path.relative(root, fs.realpathSync(cwd)).split(path.sep).join('/'); } catch { return path.relative(root, cwd); } };
95
+ const clock = at => new Date(at).toISOString().slice(11, 16);
96
+
97
+ /** PreToolUse(Bash): null to let the command run, or the decision and the message for the model. */
98
+ function before(payload, home, now = Date.now()) {
99
+ const setting = mode(home);
100
+ const command = payload.tool_input?.command;
101
+ const key = setting === 'off' ? null : testCommand(command);
102
+ if (!key || bypassed(command)) return null;
103
+ const cwd = where(payload);
104
+ const work = workState(cwd);
105
+ if (!work) return null;
106
+ const store = readStore(work.root, home);
107
+ const folder = folderOf(work.root, cwd);
108
+ const hit = store.passed.find(p => p.cmd === key && p.cwd === folder && p.state === work.state);
109
+ if (!hit) return null;
110
+ store.events = [...store.events.filter(e => e.at >= now - 30 * DAY), { at: now, cmd: key, action: setting }].slice(-KEEP_EVENTS);
111
+ writeStore(work.root, store, home);
112
+ const said = `[vibe] \`${key}\` already passed on exactly this code at ${clock(hit.at)} UTC and no file has changed since`;
113
+ return setting === 'block'
114
+ ? { decision: 'block', message: `${said}: not run again. Use that result. Run it again only when the user asks or the test depends on something outside the files (a service, data, the clock): put VIBE_RETEST=1 in front of the command.` }
115
+ : { decision: 'warn', message: `${said}; running it again repeats a known result unless the test depends on something outside the files.` };
116
+ }
117
+
118
+ /** PostToolUse(Bash): remember a test command that passed, with the state it passed on. */
119
+ function after(payload, home, now = Date.now()) {
120
+ const key = mode(home) === 'off' ? null : testCommand(payload.tool_input?.command);
121
+ if (!key || runOutcome(payload) !== true) return;
122
+ const cwd = where(payload);
123
+ const work = workState(cwd);
124
+ if (!work) return;
125
+ const store = readStore(work.root, home);
126
+ const folder = folderOf(work.root, cwd);
127
+ store.passed = [...store.passed.filter(p => !(p.cmd === key && p.cwd === folder)), { cmd: key, cwd: folder, state: work.state, at: now }].slice(-KEEP_PASSED);
128
+ writeStore(work.root, store, home);
129
+ }
130
+
131
+ /** What the gate did in a work tree over 30 days, for `vibe knowledge journal` and `vibe retest`. */
132
+ function summary(cwd, home, now = Date.now()) {
133
+ let root;
134
+ try { root = fs.realpathSync(git(cwd, ['rev-parse', '--show-toplevel'])?.toString('utf8').trim() || cwd); } catch { root = path.resolve(cwd); }
135
+ const events = readStore(root, home).events.filter(e => e.at >= now - 30 * DAY);
136
+ const counts = new Map();
137
+ for (const e of events) counts.set(e.cmd, (counts.get(e.cmd) ?? 0) + 1);
138
+ return {
139
+ mode: mode(home), days: 30,
140
+ stopped: events.filter(e => e.action === 'block').length, warned: events.filter(e => e.action === 'warn').length,
141
+ commands: [...counts].map(([command, count]) => ({ command, count })).sort((a, b) => b.count - a.count).slice(0, 10),
142
+ };
143
+ }
144
+
145
+ module.exports = { MODES, settingsFile, mode, testCommand, bypassed, workState, before, after, summary };
@@ -40,7 +40,8 @@ updated: 2026-10-16
40
40
  - `summary` is what lists and search results show, so write the conclusion, not "notes about X"; put it in double quotes when it holds a colon.
41
41
  - `tags`: one to five, reusing tags other pages already have (search finds them); lowercase, no spaces.
42
42
  - `aliases`: other names the user or a model would search for: synonyms, abbreviations and the user's own words in their language. This is the only place a record holds words outside English, so search in the user's language finds the page.
43
- - `type`, one of: `decision` (follow it), `method` (reuse it), `problem` (avoid or solve it), `fact`, `project`, `research`, `skill`, `proposal`. `vibe knowledge lint` reports any other value.
43
+ - `type`, one of: `decision` (follow it), `method` (reuse it), `problem` (avoid or solve it), `fact`, `project`, `research`, `skill`, `proposal`, `source`. `vibe knowledge lint` reports any other value.
44
+ - `source`: raw material kept as evidence (a transcript, a web clipping, a video analysis; voin marks meeting transcripts so). Never rewrite or split it; an organised page holds the conclusions and links it for its claims. The lint skips its length, not its links.
44
45
  - `supersedes: "[[old-page]]"` on a page that replaces another, and `status: archived` on a finished project or knowledge no longer in force. Search ranks both lower and marks them, so keep the old page for its reasons instead of deleting it.
45
46
  - One subject per page; a page covering two subjects is two pages linked to each other. Keep `## Related` at the end for links that are not already in the text.
46
47
 
@@ -80,7 +81,11 @@ Keep `proposals.md` in the wiki (`type: proposal` pages or one line each): what,
80
81
 
81
82
  ## Keep the connections
82
83
 
83
- Link related pages with `[[page]]` where a reader would follow the connection, with the reason; do not link every noun. `vibe knowledge find` shows which pages a subject touches. When the wiki has grown or links were edited, run `vibe knowledge lint` and fix what it reports: create or redirect a broken link's target, link an orphan from `index.md` or a related page, merge duplicate pages, split long ones. An unlinked shared page needs nothing: the next session start lists it; link it from a related page where the connection means something.
84
+ Link related pages with `[[page]]` where a reader would follow the connection, with the reason; do not link every noun. `vibe knowledge find` shows which pages a subject touches. Every knowledge turn runs `vibe knowledge lint --fix` first: orphans join `index.md` on lines ending `<!-- vibe:auto -->` (removed when the page goes) and a project page `log.md` links to is created; unmarked lines are only reported. The turn names what is left: redirect or create a broken link's target, merge pages sharing a title, split long pages, fix unknown types. Give an auto line a real description when you can. An unlinked shared page needs nothing: the next session start lists it; link it from a related page where the connection means something.
85
+
86
+ ## Review
87
+
88
+ After enough changed pages (`--review`, `--review-days`), the knowledge turn is also a review turn listing the changed pages and the pages they link. Read those together, not the whole wiki, for what a script cannot see: two pages disagreeing on one subject, a dated claim later facts or the code no longer support, a replaced decision still reading as current. Correct it with a one-line reason, date and source; mark a replaced decision with `supersedes` or `status: archived`; when unsure which claim holds, ask the user in one line. Report it like any knowledge turn ("점검: 모순 1건 수정 · 대체된 결정 1건 표시"), or nothing when everything holds.
84
89
 
85
90
  ## Tell the user
86
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@su-record/vibe",
3
- "version": "4.2.19",
3
+ "version": "4.2.21",
4
4
  "description": "Your personal FDE in Claude and Codex. Understand the work, use the right tools, deliver and retain useful context. One entry: vibe.",
5
5
  "type": "module",
6
6
  "bin": {