@su-record/vibe 4.2.35 → 4.2.37

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.
Files changed (222) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/dist/cli/common.js +4 -1
  5. package/dist/cli/common.js.map +1 -1
  6. package/dist/cli/heldout.js +10 -4
  7. package/dist/cli/heldout.js.map +1 -1
  8. package/dist/cli/human.js +8 -4
  9. package/dist/cli/human.js.map +1 -1
  10. package/dist/cli/internal.js +37 -24
  11. package/dist/cli/internal.js.map +1 -1
  12. package/dist/cli/ledger.js +67 -0
  13. package/dist/cli/ledger.js.map +1 -0
  14. package/dist/cli/map.js +5 -3
  15. package/dist/cli/map.js.map +1 -1
  16. package/dist/cli/memory.js +104 -74
  17. package/dist/cli/memory.js.map +1 -1
  18. package/dist/cli/mutate.js +20 -0
  19. package/dist/cli/mutate.js.map +1 -0
  20. package/dist/cli/risks.js +20 -3
  21. package/dist/cli/risks.js.map +1 -1
  22. package/dist/cli/session.js +4 -2
  23. package/dist/cli/session.js.map +1 -1
  24. package/dist/cli/setup.js +44 -13
  25. package/dist/cli/setup.js.map +1 -1
  26. package/dist/cli/work.js +73 -21
  27. package/dist/cli/work.js.map +1 -1
  28. package/dist/cli.js +19 -6
  29. package/dist/cli.js.map +1 -1
  30. package/dist/core/analyze.js +21 -7
  31. package/dist/core/analyze.js.map +1 -1
  32. package/dist/core/artifact-paths.js +14 -3
  33. package/dist/core/artifact-paths.js.map +1 -1
  34. package/dist/core/capabilities.js +69 -15
  35. package/dist/core/capabilities.js.map +1 -1
  36. package/dist/core/check-authorization.js +37 -0
  37. package/dist/core/check-authorization.js.map +1 -0
  38. package/dist/core/check-process.js +12 -4
  39. package/dist/core/check-process.js.map +1 -1
  40. package/dist/core/check-report.js +66 -0
  41. package/dist/core/check-report.js.map +1 -0
  42. package/dist/core/check.js +59 -71
  43. package/dist/core/check.js.map +1 -1
  44. package/dist/core/checks/changed.js +20 -6
  45. package/dist/core/checks/changed.js.map +1 -1
  46. package/dist/core/checks/eval.js +35 -10
  47. package/dist/core/checks/eval.js.map +1 -1
  48. package/dist/core/checks/file.js +26 -23
  49. package/dist/core/checks/file.js.map +1 -1
  50. package/dist/core/checks/fileRules.js +42 -16
  51. package/dist/core/checks/fileRules.js.map +1 -1
  52. package/dist/core/checks/http.js +35 -34
  53. package/dist/core/checks/http.js.map +1 -1
  54. package/dist/core/checks/packs.js +1 -1
  55. package/dist/core/checks/packs.js.map +1 -1
  56. package/dist/core/checks/review.js +75 -40
  57. package/dist/core/checks/review.js.map +1 -1
  58. package/dist/core/checks/run.js +5 -2
  59. package/dist/core/checks/run.js.map +1 -1
  60. package/dist/core/checks/schema.js +25 -0
  61. package/dist/core/checks/schema.js.map +1 -0
  62. package/dist/core/checks/source.js +11 -6
  63. package/dist/core/checks/source.js.map +1 -1
  64. package/dist/core/config.js +9 -2
  65. package/dist/core/config.js.map +1 -1
  66. package/dist/core/consent.js +24 -16
  67. package/dist/core/consent.js.map +1 -1
  68. package/dist/core/context-notes.js +15 -7
  69. package/dist/core/context-notes.js.map +1 -1
  70. package/dist/core/context.js +24 -12
  71. package/dist/core/context.js.map +1 -1
  72. package/dist/core/conventions.js +22 -9
  73. package/dist/core/conventions.js.map +1 -1
  74. package/dist/core/docs/cfb.js +81 -45
  75. package/dist/core/docs/cfb.js.map +1 -1
  76. package/dist/core/docs/html.js +14 -3
  77. package/dist/core/docs/html.js.map +1 -1
  78. package/dist/core/docs/hwp.js +44 -22
  79. package/dist/core/docs/hwp.js.map +1 -1
  80. package/dist/core/docs/office.js +20 -10
  81. package/dist/core/docs/office.js.map +1 -1
  82. package/dist/core/docs/pdf.js +10 -4
  83. package/dist/core/docs/pdf.js.map +1 -1
  84. package/dist/core/docs/read.js +32 -24
  85. package/dist/core/docs/read.js.map +1 -1
  86. package/dist/core/docs/zip.js +32 -15
  87. package/dist/core/docs/zip.js.map +1 -1
  88. package/dist/core/evidence.js +17 -7
  89. package/dist/core/evidence.js.map +1 -1
  90. package/dist/core/failure-message.js +35 -13
  91. package/dist/core/failure-message.js.map +1 -1
  92. package/dist/core/failure.js +49 -14
  93. package/dist/core/failure.js.map +1 -1
  94. package/dist/core/github.js +3 -1
  95. package/dist/core/github.js.map +1 -1
  96. package/dist/core/handoff.js +20 -7
  97. package/dist/core/handoff.js.map +1 -1
  98. package/dist/core/heldout-run.js +6 -3
  99. package/dist/core/heldout-run.js.map +1 -1
  100. package/dist/core/heldout.js +24 -13
  101. package/dist/core/heldout.js.map +1 -1
  102. package/dist/core/inbox.js +3 -1
  103. package/dist/core/inbox.js.map +1 -1
  104. package/dist/core/inputs.js +6 -3
  105. package/dist/core/inputs.js.map +1 -1
  106. package/dist/core/inspect.js +61 -20
  107. package/dist/core/inspect.js.map +1 -1
  108. package/dist/core/intent.js +35 -13
  109. package/dist/core/intent.js.map +1 -1
  110. package/dist/core/knowledge-find.js +31 -12
  111. package/dist/core/knowledge-find.js.map +1 -1
  112. package/dist/core/knowledge.js +22 -12
  113. package/dist/core/knowledge.js.map +1 -1
  114. package/dist/core/lang.js +5 -2
  115. package/dist/core/lang.js.map +1 -1
  116. package/dist/core/ledger.js +44 -20
  117. package/dist/core/ledger.js.map +1 -1
  118. package/dist/core/locations.js +12 -0
  119. package/dist/core/locations.js.map +1 -0
  120. package/dist/core/map/imports.js +20 -9
  121. package/dist/core/map/imports.js.map +1 -1
  122. package/dist/core/map/index.js +28 -13
  123. package/dist/core/map/index.js.map +1 -1
  124. package/dist/core/map/symbols.js +27 -12
  125. package/dist/core/map/symbols.js.map +1 -1
  126. package/dist/core/mutate.js +107 -0
  127. package/dist/core/mutate.js.map +1 -0
  128. package/dist/core/output-capture.js +7 -4
  129. package/dist/core/output-capture.js.map +1 -1
  130. package/dist/core/paths.js +2 -1
  131. package/dist/core/paths.js.map +1 -1
  132. package/dist/core/performance.js +43 -17
  133. package/dist/core/performance.js.map +1 -1
  134. package/dist/core/profile.js +6 -3
  135. package/dist/core/profile.js.map +1 -1
  136. package/dist/core/reader.js +32 -16
  137. package/dist/core/reader.js.map +1 -1
  138. package/dist/core/readerSession.js +34 -14
  139. package/dist/core/readerSession.js.map +1 -1
  140. package/dist/core/recent.js +20 -6
  141. package/dist/core/recent.js.map +1 -1
  142. package/dist/core/regress.js +21 -12
  143. package/dist/core/regress.js.map +1 -1
  144. package/dist/core/repair.js +22 -4
  145. package/dist/core/repair.js.map +1 -1
  146. package/dist/core/research-queries.js +85 -0
  147. package/dist/core/research-queries.js.map +1 -0
  148. package/dist/core/research.js +89 -88
  149. package/dist/core/research.js.map +1 -1
  150. package/dist/core/results.js +15 -6
  151. package/dist/core/results.js.map +1 -1
  152. package/dist/core/risk-signals.js +53 -15
  153. package/dist/core/risk-signals.js.map +1 -1
  154. package/dist/core/run-id.js +10 -6
  155. package/dist/core/run-id.js.map +1 -1
  156. package/dist/core/scenarios.js +91 -60
  157. package/dist/core/scenarios.js.map +1 -1
  158. package/dist/core/session.js +3 -2
  159. package/dist/core/session.js.map +1 -1
  160. package/dist/core/size.js +28 -8
  161. package/dist/core/size.js.map +1 -1
  162. package/dist/core/skills.js +66 -28
  163. package/dist/core/skills.js.map +1 -1
  164. package/dist/core/skillsmp.js +26 -12
  165. package/dist/core/skillsmp.js.map +1 -1
  166. package/dist/core/source-basis.js +21 -9
  167. package/dist/core/source-basis.js.map +1 -1
  168. package/dist/core/status.js +26 -13
  169. package/dist/core/status.js.map +1 -1
  170. package/dist/core/table.js +8 -1
  171. package/dist/core/table.js.map +1 -1
  172. package/dist/core/tokens.js +20 -10
  173. package/dist/core/tokens.js.map +1 -1
  174. package/dist/core/tree.js +26 -16
  175. package/dist/core/tree.js.map +1 -1
  176. package/dist/core/verification-plan.js +24 -8
  177. package/dist/core/verification-plan.js.map +1 -1
  178. package/dist/core/verification.js +14 -7
  179. package/dist/core/verification.js.map +1 -1
  180. package/dist/core/view.js +93 -44
  181. package/dist/core/view.js.map +1 -1
  182. package/dist/install/global.js +37 -18
  183. package/dist/install/global.js.map +1 -1
  184. package/dist/install/hooks.js +17 -8
  185. package/dist/install/hooks.js.map +1 -1
  186. package/dist/install/manifest.js +11 -0
  187. package/dist/install/manifest.js.map +1 -0
  188. package/dist/install/mcpb.js +52 -23
  189. package/dist/install/mcpb.js.map +1 -1
  190. package/dist/install/plugin.js +73 -33
  191. package/dist/install/plugin.js.map +1 -1
  192. package/dist/install/project.js +8 -7
  193. package/dist/install/project.js.map +1 -1
  194. package/dist/install/register.js +83 -48
  195. package/dist/install/register.js.map +1 -1
  196. package/dist/install/tree.js +27 -11
  197. package/dist/install/tree.js.map +1 -1
  198. package/dist/install/update.js +46 -20
  199. package/dist/install/update.js.map +1 -1
  200. package/hooks/code-map.cjs +22 -9
  201. package/hooks/journal.cjs +18 -16
  202. package/hooks/knowledge-auto.cjs +55 -21
  203. package/hooks/knowledge-journal.cjs +47 -20
  204. package/hooks/knowledge-projects.cjs +50 -21
  205. package/hooks/knowledge-recall.cjs +16 -6
  206. package/hooks/knowledge-upkeep.cjs +22 -5
  207. package/hooks/locations.cjs +35 -0
  208. package/hooks/notify.js +52 -24
  209. package/hooks/obsidian.cjs +17 -7
  210. package/hooks/private-store.cjs +25 -10
  211. package/hooks/retest.cjs +39 -16
  212. package/hooks/session-context.cjs +30 -13
  213. package/hooks/session-files.cjs +44 -21
  214. package/hooks/session-repair.cjs +26 -11
  215. package/hooks/session-state.cjs +78 -38
  216. package/hooks/session.js +19 -8
  217. package/hooks/slice-command.cjs +38 -17
  218. package/hooks/slice-guard.cjs +20 -6
  219. package/hooks/slice-store.cjs +25 -12
  220. package/internal/guides/code.md +2 -0
  221. package/mcpb/server/index.js +40 -12
  222. package/package.json +1 -1
@@ -2,11 +2,25 @@
2
2
  // knowledge shown inside the wiki. The session hook and the CLI both use this, so they name a project alike.
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { vibeConfig, SETTINGS } = require('./locations.cjs');
5
6
  const { createHash, randomBytes } = require('node:crypto');
6
7
  const { inside } = require('./private-store.cjs');
7
8
  const { syncedBy, realFolder } = require('./obsidian.cjs');
8
9
 
9
- const namesFile = home => path.join(home, '.config', 'vibe', 'knowledge-projects.json');
10
+ const PROJECT_DIR = '.vibe';
11
+ // Where the wiki keeps one page (or linked folder) per project.
12
+ const PROJECTS_FOLDER = 'projects';
13
+ const TEMP_SUFFIX_BYTES = 6;
14
+ const NAME_MARK_CHARS = 6;
15
+ const MAX_LISTED = 200;
16
+ const START = '<!-- vibe:shared-knowledge -->';
17
+ const END = '<!-- /vibe:shared-knowledge -->';
18
+
19
+ /** The project's shared knowledge folder, which travels with it. */
20
+ const sharedFolder = root => path.join(root, PROJECT_DIR, 'knowledge');
21
+ const hasProjectDir = root => fs.statSync(path.join(root, PROJECT_DIR), { throwIfNoEntry: false })?.isDirectory() === true;
22
+
23
+ const namesFile = home => vibeConfig(home, SETTINGS.knowledgeProjects);
10
24
 
11
25
  function readNames(home) {
12
26
  try {
@@ -24,11 +38,11 @@ function projectName(root, home, record = true) {
24
38
  const owned = Object.keys(names).find(name => names[name] === root);
25
39
  if (owned) return owned;
26
40
  const base = path.basename(root).replace(/[[\]|#^\r\n]/g, '-') || 'project';
27
- const name = names[base] === undefined ? base : `${base}-${createHash('sha256').update(root).digest('hex').slice(0, 6)}`;
41
+ const name = names[base] === undefined ? base : `${base}-${createHash('sha256').update(root).digest('hex').slice(0, NAME_MARK_CHARS)}`;
28
42
  if (record) {
29
43
  const file = namesFile(home);
30
44
  fs.mkdirSync(path.dirname(file), { recursive: true });
31
- const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`;
45
+ const temporary = `${file}.${randomBytes(TEMP_SUFFIX_BYTES).toString('hex')}.tmp`;
32
46
  fs.writeFileSync(temporary, JSON.stringify({ names: { ...names, [name]: root } }, null, 2));
33
47
  fs.renameSync(temporary, file);
34
48
  }
@@ -45,12 +59,12 @@ function sameFolder(a, b) {
45
59
  * wiki, where the link could carry a client's knowledge off the computer, nor into a wiki inside the project.
46
60
  */
47
61
  function linkProject(root, wiki, name) {
48
- if (!fs.statSync(path.join(root, '.vibe'), { throwIfNoEntry: false })?.isDirectory()) return 'none';
62
+ if (!hasProjectDir(root)) return 'none';
49
63
  if (syncedBy(wiki)) return 'synced';
50
64
  if (inside(root, realFolder(wiki))) return 'inside';
51
- const target = path.join(root, '.vibe', 'knowledge');
65
+ const target = sharedFolder(root);
52
66
  fs.mkdirSync(target, { recursive: true });
53
- const link = path.join(wiki, 'projects', name);
67
+ const link = path.join(wiki, PROJECTS_FOLDER, name);
54
68
  const existing = fs.lstatSync(link, { throwIfNoEntry: false });
55
69
  if (existing) return existing.isSymbolicLink() && sameFolder(link, target) ? 'linked' : 'taken';
56
70
  fs.mkdirSync(path.dirname(link), { recursive: true });
@@ -59,10 +73,6 @@ function linkProject(root, wiki, name) {
59
73
  return 'linked';
60
74
  }
61
75
 
62
- const MAX_LISTED = 200;
63
- const START = '<!-- vibe:shared-knowledge -->';
64
- const END = '<!-- /vibe:shared-knowledge -->';
65
-
66
76
  /** The shared folder's pages by subfolder, without links or dot folders; names that cannot be a [[link]] are left out. */
67
77
  function sharedPages(folder) {
68
78
  const groups = new Map();
@@ -84,7 +94,7 @@ function sharedPages(folder) {
84
94
  function writeIfChanged(file, text) {
85
95
  if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') === text) return;
86
96
  fs.mkdirSync(path.dirname(file), { recursive: true });
87
- const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`;
97
+ const temporary = `${file}.${randomBytes(TEMP_SUFFIX_BYTES).toString('hex')}.tmp`;
88
98
  fs.writeFileSync(temporary, text);
89
99
  fs.renameSync(temporary, file);
90
100
  }
@@ -95,30 +105,49 @@ function writeIfChanged(file, text) {
95
105
  * Everything outside the block, and every other line of the index, is the model's and the person's.
96
106
  */
97
107
  function indexProject(root, wiki, name) {
98
- const { groups, more } = sharedPages(path.join(root, '.vibe', 'knowledge'));
99
- const lines = [...groups].map(([folder, pages]) => `- ${folder || 'pages'}: ${pages.map(p => `[[projects/${name}/${p}]]`).join(' · ')}`);
100
- const block = [START, `Shared knowledge of this project (\`.vibe/knowledge\`), listed by vibe at each session start; edit outside this block.`,
101
- ...(lines.length ? lines : ['- none yet']), ...(more ? [`- and ${more} more pages`] : []), END].join('\n');
102
- const page = path.join(wiki, 'projects', `${name}.md`);
108
+ const page = path.join(wiki, PROJECTS_FOLDER, `${name}.md`);
103
109
  const current = fs.existsSync(page) ? fs.readFileSync(page, 'utf8') : `# ${name}\n`;
104
- const at = current.indexOf(START), end = current.indexOf(END, at);
105
- writeIfChanged(page, at >= 0 && end > at ? `${current.slice(0, at)}${block}${current.slice(end + END.length)}` : `${current.trimEnd()}\n\n${block}\n`);
110
+ writeIfChanged(page, withBlock(current, sharedBlock(root, name)));
106
111
  const index = path.join(wiki, 'index.md');
107
112
  const listed = fs.existsSync(index) ? fs.readFileSync(index, 'utf8') : '# Index\n';
108
- if (!new RegExp(`\\[\\[projects/${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\.md)?[\\]|#]`).test(listed)) {
113
+ if (!linksProject(listed, name)) {
109
114
  writeIfChanged(index, `${listed.trimEnd()}\n- [[projects/${name}]] — this project's page and its shared knowledge\n`);
110
115
  }
111
116
  }
112
117
 
118
+ /** The block listing the project's shared pages, one line per subfolder. */
119
+ function sharedBlock(root, name) {
120
+ const { groups, more } = sharedPages(sharedFolder(root));
121
+ const lines = [...groups].map(([folder, pages]) => `- ${folder || 'pages'}: ${pages.map(p => `[[projects/${name}/${p}]]`).join(' · ')}`);
122
+ return [
123
+ START,
124
+ 'Shared knowledge of this project (`.vibe/knowledge`), listed by vibe at each session start; edit outside this block.',
125
+ ...(lines.length ? lines : ['- none yet']),
126
+ ...(more ? [`- and ${more} more pages`] : []),
127
+ END,
128
+ ].join('\n');
129
+ }
130
+
131
+ /** The page with its marked block replaced, or the block appended when the page has none. */
132
+ function withBlock(current, block) {
133
+ const at = current.indexOf(START), end = current.indexOf(END, at);
134
+ if (at >= 0 && end > at) return `${current.slice(0, at)}${block}${current.slice(end + END.length)}`;
135
+ return `${current.trimEnd()}\n\n${block}\n`;
136
+ }
137
+
138
+ const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
139
+ /** Whether the index already links the project's page. */
140
+ const linksProject = (index, name) => new RegExp(`\\[\\[projects/${escapeRegExp(name)}(?:\\.md)?[\\]|#]`).test(index);
141
+
113
142
  /**
114
143
  * Create the project's page when it is missing, so a log line's `[[projects/<name>]]` always resolves. Returns
115
144
  * whether it created one; an existing page is left as it is.
116
145
  */
117
146
  function projectPage(wiki, name, root) {
118
- const page = path.join(wiki, 'projects', `${name}.md`);
147
+ const page = path.join(wiki, PROJECTS_FOLDER, `${name}.md`);
119
148
  if (fs.existsSync(page)) return false;
120
149
  writeIfChanged(page, `---\ntype: project\n---\n# ${name}\n${typeof root === 'string' ? `\nFolder: \`${root}\`\n` : ''}`);
121
150
  return true;
122
151
  }
123
152
 
124
- module.exports = { readNames, projectName, linkProject, indexProject, projectPage, writeIfChanged };
153
+ module.exports = { PROJECT_DIR, sharedFolder, hasProjectDir, readNames, projectName, linkProject, indexProject, projectPage, writeIfChanged };
@@ -7,6 +7,7 @@ const path = require('node:path');
7
7
  const { pathToFileURL } = require('node:url');
8
8
  const { readPrivate, writePrivate } = require('./private-store.cjs');
9
9
  const { digest } = require('./session-files.cjs');
10
+ const { sharedFolder } = require('./knowledge-projects.cjs');
10
11
 
11
12
  const SHOWN = 3;
12
13
  // A page scoring 6 matches a short prompt's words in its title or tags; a long prompt also touches big pages in passing,
@@ -14,7 +15,16 @@ const SHOWN = 3;
14
15
  // (2026-10-29): the hook, link and Stop questions found the vibe pages; "배포해" and "진행 상황 알려줘" found none.
15
16
  const ENOUGH = 6;
16
17
  const PROMPT_CHARS = 600;
18
+ const SHOWN_MAX_BYTES = 64 * 1024;
19
+ // Pages remembered as already shown, so a long session's list stays small.
20
+ const SHOWN_KEPT = 200;
21
+ // The finder is compiled TypeScript under the package's dist.
22
+ const FINDER = path.join('dist', 'core', 'knowledge-find.js');
17
23
 
24
+ const HEADING = [
25
+ '[vibe] Recorded knowledge matching this prompt, found before you chose;',
26
+ 'read a page that bears on the work, then follow its decision, reuse its method or avoid its problem:',
27
+ ].join(' ');
18
28
  const shownEntry = (root, id) => `recall-${digest(`${root}\0${id}`)}.json`;
19
29
 
20
30
  /**
@@ -23,19 +33,19 @@ const shownEntry = (root, id) => `recall-${digest(`${root}\0${id}`)}.json`;
23
33
  */
24
34
  async function recall(text, { root, id }, dir, home) {
25
35
  const prompt = typeof text === 'string' ? text.trim().slice(0, PROMPT_CHARS) : '';
26
- // The finder is compiled TypeScript; a hooks copy without the package's dist (Codex's plugin tree) skips the lookup.
27
- const finder = path.join(__dirname, '..', 'dist', 'core', 'knowledge-find.js');
36
+ // A hooks copy without the package's dist (Codex's plugin tree) skips the lookup.
37
+ const finder = path.join(__dirname, '..', FINDER);
28
38
  if (!prompt || !fs.existsSync(finder)) return null;
29
39
  const { findKnowledge } = await import(pathToFileURL(finder).href);
30
- const shared = path.join(root, '.vibe', 'knowledge');
40
+ const shared = sharedFolder(root);
31
41
  const { found } = findKnowledge(dir, prompt, fs.existsSync(shared) ? [shared] : []);
32
- const shown = JSON.parse(readPrivate(root, shownEntry(root, id), 65536, home) ?? '[]');
42
+ const shown = JSON.parse(readPrivate(root, shownEntry(root, id), SHOWN_MAX_BYTES, home) ?? '[]');
33
43
  const bar = Math.max(ENOUGH, (found[0]?.score ?? 0) / 2);
34
44
  const fresh = found.filter(page => page.state === 'current' && page.score >= bar && !shown.includes(page.file)).slice(0, SHOWN);
35
45
  if (!fresh.length) return null;
36
- writePrivate(root, shownEntry(root, id), JSON.stringify([...shown, ...fresh.map(page => page.file)].slice(-200)), home);
46
+ writePrivate(root, shownEntry(root, id), JSON.stringify([...shown, ...fresh.map(page => page.file)].slice(-SHOWN_KEPT)), home);
37
47
  const lines = fresh.map(page => `- ${page.title}${page.type ? ` [${page.type}]` : ''}: ${page.summary} (${page.file})`);
38
- return ['[vibe] Recorded knowledge matching this prompt, found before you chose; read a page that bears on the work, then follow its decision, reuse its method or avoid its problem:', ...lines].join('\n');
48
+ return [HEADING, ...lines].join('\n');
39
49
  }
40
50
 
41
51
  module.exports = { recall, ENOUGH };
@@ -2,12 +2,14 @@
2
2
  // not settle; after enough changed pages or days, the turn also reviews those pages and the pages they link.
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { vibeConfig, SETTINGS } = require('./locations.cjs');
5
6
  const { fixWiki, reviewScope } = require('./knowledge-lint.cjs');
6
7
  const { writeIfChanged } = require('./knowledge-projects.cjs');
8
+ const { DAY } = require('./knowledge-journal.cjs');
7
9
 
8
- const DAY = 86400000;
9
10
  const SHOWN = 3;
10
11
  const LISTED = 20;
12
+ const DATE_CHARS = 10;
11
13
 
12
14
  const few = (items, show) => `${items.slice(0, SHOWN).map(show).join(', ')}${items.length > SHOWN ? ` and ${items.length - SHOWN} more` : ''}`;
13
15
 
@@ -21,11 +23,27 @@ function tidy(dir, home) {
21
23
  lint.badType.length ? `unknown types ${few(lint.badType, b => `${b.page} "${b.type}"`)}` : '',
22
24
  lint.duplicates.length ? `pages sharing a title ${few(lint.duplicates, pages => pages.join(' = '))}` : '',
23
25
  ].filter(Boolean);
24
- return left.length ? ` The wiki lint fixed what needs no judgement; left for you: ${left.join('; ')}. Settle them as the guide's "Keep the connections" says (\`vibe knowledge lint\` lists all).` : '';
26
+ if (!left.length) return '';
27
+ return [
28
+ ` The wiki lint fixed what needs no judgement; left for you: ${left.join('; ')}.`,
29
+ ' Settle them as the guide\'s "Keep the connections" says (`vibe knowledge lint` lists all).',
30
+ ].join('');
25
31
  } catch { return ''; }
26
32
  }
27
33
 
28
- const stateFile = home => path.join(home, '.config', 'vibe', 'knowledge-review.json');
34
+ const pageList = items => `${items.slice(0, LISTED).map(p => `[[${p}]]`).join(', ')}${items.length > LISTED ? ` and ${items.length - LISTED} more` : ''}`;
35
+
36
+ function reviewClause(scope, since) {
37
+ const date = new Date(since).toISOString().slice(0, DATE_CHARS);
38
+ const linked = scope.linked.length ? ` and the pages they link, ${pageList(scope.linked)},` : '';
39
+ return [
40
+ ` This is also a review turn (${scope.changed.length} pages changed since ${date}): read ${pageList(scope.changed)}${linked}`,
41
+ ' for claims that contradict each other, dated claims likely out of date and decisions a later one replaced,',
42
+ ' and correct them as the guide\'s "Review" says.',
43
+ ].join('');
44
+ }
45
+
46
+ const stateFile = home => vibeConfig(home, SETTINGS.knowledgeReview);
29
47
 
30
48
  /**
31
49
  * A review clause when `pages` pages changed since the last review, or one did and `days` days passed; '' otherwise.
@@ -41,8 +59,7 @@ function review(dir, home, pages, days, now = Date.now()) {
41
59
  const due = scope.changed.length >= pages || (scope.changed.length > 0 && days > 0 && now - since >= days * DAY);
42
60
  if (!due) return '';
43
61
  writeIfChanged(stateFile(home), JSON.stringify({ at: now }));
44
- const list = items => `${items.slice(0, LISTED).map(p => `[[${p}]]`).join(', ')}${items.length > LISTED ? ` and ${items.length - LISTED} more` : ''}`;
45
- return ` This is also a review turn (${scope.changed.length} pages changed since ${new Date(since).toISOString().slice(0, 10)}): read ${list(scope.changed)}${scope.linked.length ? ` and the pages they link, ${list(scope.linked)},` : ''} for claims that contradict each other, dated claims likely out of date and decisions a later one replaced, and correct them as the guide's "Review" says.`;
62
+ return reviewClause(scope, since);
46
63
  } catch { return ''; }
47
64
  }
48
65
 
@@ -0,0 +1,35 @@
1
+ // Where vibe and the clients it serves keep their files under the home folder, spelled once. The hooks require this
2
+ // module and the CLI reads it through src/core/locations.ts, so a folder name never drifts between the two.
3
+ const path = require('node:path');
4
+
5
+ const CLAUDE_DIR = '.claude';
6
+ const CODEX_DIR = '.codex';
7
+ const AGENTS_DIR = '.agents';
8
+ const VIBE_CONFIG_DIR = path.join('.config', 'vibe');
9
+
10
+ /** A file or folder under ~/.config/vibe: the person's vibe settings, wiki, reader sessions and plugin tree. */
11
+ const vibeConfig = (home, ...parts) => path.join(home, VIBE_CONFIG_DIR, ...parts);
12
+ /** A file or folder under ~/.claude. */
13
+ const claudeHome = (home, ...parts) => path.join(home, CLAUDE_DIR, ...parts);
14
+ /** A file or folder under ~/.codex. */
15
+ const codexHome = (home, ...parts) => path.join(home, CODEX_DIR, ...parts);
16
+
17
+ /** The client files vibe reads or writes. */
18
+ const claudeSettings = (home) => claudeHome(home, 'settings.json');
19
+ const codexConfig = (home) => codexHome(home, 'config.toml');
20
+ const codexHooks = (home) => codexHome(home, 'hooks.json');
21
+
22
+ /** The person's vibe settings files under ~/.config/vibe. */
23
+ const SETTINGS = {
24
+ knowledge: 'knowledge.json',
25
+ knowledgeProjects: 'knowledge-projects.json',
26
+ knowledgeReview: 'knowledge-review.json',
27
+ retest: 'retest.json',
28
+ };
29
+ /** Folders under ~/.config/vibe. */
30
+ const FOLDERS = { knowledge: 'knowledge', reader: 'reader', plugin: 'plugin' };
31
+
32
+ module.exports = {
33
+ CLAUDE_DIR, CODEX_DIR, AGENTS_DIR, VIBE_CONFIG_DIR, SETTINGS, FOLDERS,
34
+ vibeConfig, claudeHome, codexHome, claudeSettings, codexConfig, codexHooks,
35
+ };
package/hooks/notify.js CHANGED
@@ -19,12 +19,28 @@ import path from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import codeMap from './code-map.cjs';
21
21
  import knowledgeAuto from './knowledge-auto.cjs';
22
+ import locations from './locations.cjs';
22
23
  import knowledgeJournal from './knowledge-journal.cjs';
23
24
  import retest from './retest.cjs';
24
25
  import sessionRuntime from './session-state.cjs';
25
26
  import sessionFiles from './session-files.cjs';
26
27
  import sliceRuntime from './slice-guard.cjs';
27
28
 
29
+ const { claudeSettings } = locations;
30
+
31
+ const NOTIFY_HOOK = 'hooks/notify.js';
32
+ const SETTINGS_MAX_BYTES = 64 * 1024;
33
+ const PROJECT_DIR = '.vibe';
34
+ const STATE_TIMEOUT_MS = 15000;
35
+ // An authorize record counts for this long.
36
+ const MS_PER_MINUTE = 60 * 1000;
37
+ const AUTHORIZE_WINDOW_MS = 10 * MS_PER_MINUTE;
38
+ const DEFAULT_READ_ADVISE_LINES = sliceRuntime.READ_ADVISE_LINES;
39
+ // Exit code that stops a tool call in Claude Code.
40
+ const BLOCK_EXIT = 2;
41
+ // Hook modes that run in a project with or without a .vibe folder.
42
+ const ANYWHERE_MODES = ['stop', 'session', 'pre'];
43
+
28
44
  const mode = process.argv[2] || 'post';
29
45
  const asPlugin = process.argv.includes('--plugin');
30
46
  const personal = process.argv.includes('--personal');
@@ -38,11 +54,11 @@ if (asPlugin) {
38
54
  // The reader refuses a link anywhere in a path; a home under one (macOS /var → /private/var) is the person's own.
39
55
  let home = process.env.VIBE_HOME_DIR || os.homedir();
40
56
  try { home = fs.realpathSync(home); } catch { /* a missing home has no settings to read */ }
41
- for (const file of [path.join(home, '.claude', 'settings.json')]) {
57
+ for (const file of [claudeSettings(home)]) {
42
58
  try {
43
- const settings = JSON.parse(sessionFiles.optional(file, 65536) ?? '{}');
59
+ const settings = JSON.parse(sessionFiles.optional(file, SETTINGS_MAX_BYTES) ?? '{}');
44
60
  const owned = Object.values(settings.hooks ?? {}).some(entries => entries.some(entry =>
45
- entry.hooks?.some(hook => hook.command?.replaceAll('\\', '/').includes('hooks/notify.js'))));
61
+ entry.hooks?.some(hook => hook.command?.replaceAll('\\', '/').includes(NOTIFY_HOOK))));
46
62
  if (owned) process.exit(0);
47
63
  } catch {
48
64
  /* no such file — keep going */
@@ -58,19 +74,17 @@ function readPayload() {
58
74
  catch { return { session_id: 'invalid payload' }; }
59
75
  }
60
76
 
61
- function structuralSession(payload) {
62
- const view = sessionRuntime.sessionStatus(payload);
63
- return sessionRuntime.message(view, view.status === 'bound' ? 'session status only; run vibe state for the procedure' : `${view.status}; use explicit vibe session bind in the intended worktree`);
64
- }
77
+ const structuralSession = payload => sessionRuntime.structuralNote(sessionRuntime.sessionStatus(payload));
65
78
  function sliceClient() {
66
79
  const declared = process.env.VIBE_CLIENT;
67
80
  if (declared) return ['codex', 'chatgpt'].includes(declared) ? 'codex' : 'claude';
68
81
  return process.env.CODEX_THREAD_ID && !process.env.CLAUDE_SESSION_ID ? 'codex' : 'claude';
69
82
  }
70
83
 
71
- function emitContext(text) {
72
- process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: mode === 'post' ? 'PostToolUse' : 'PreToolUse', additionalContext: text } })}\n`);
84
+ function emit(hookEventName, additionalContext) {
85
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } })}\n`);
73
86
  }
87
+ const emitContext = text => emit(mode === 'post' ? 'PostToolUse' : 'PreToolUse', text);
74
88
 
75
89
  // The same actions the check gate names (src/core/checks/mutation.ts), so a blocked tool call and a blocked
76
90
  // check ask for the same `vibe authorize --action`; `send` is the hook's own. A command that only reads —
@@ -91,10 +105,12 @@ const IRREVERSIBLE = [
91
105
  ];
92
106
  const READS_ONLY = /^\s*(?:grep|rg|cat|head|tail|less|ls|find|wc|echo|printf|sed\s+-n|git\s+(?:log|diff|show|status|grep|blame|branch|ls-files))\b/;
93
107
 
108
+ const hasProjectDir = () => fs.existsSync(path.join(root, PROJECT_DIR));
109
+
94
110
  function recentAuthorize(action) {
95
111
  try {
96
- const lines = fs.readFileSync(path.join(root, '.vibe', 'ledger.jsonl'), 'utf-8').trim().split('\n');
97
- const cutoff = Date.now() - 10 * 60 * 1000;
112
+ const lines = fs.readFileSync(path.join(root, PROJECT_DIR, 'ledger.jsonl'), 'utf-8').trim().split('\n');
113
+ const cutoff = Date.now() - AUTHORIZE_WINDOW_MS;
98
114
  return lines.some((line) => {
99
115
  try {
100
116
  const e = JSON.parse(line);
@@ -108,16 +124,25 @@ function recentAuthorize(action) {
108
124
  }
109
125
  }
110
126
 
111
- if (!['stop', 'session', 'pre'].includes(mode) && !fs.existsSync(path.join(root, '.vibe'))) process.exit(0);
127
+ if (!ANYWHERE_MODES.includes(mode) && !hasProjectDir()) process.exit(0);
128
+
129
+ function irreversibleNotice(action, blocking) {
130
+ return [
131
+ `[vibe] "${action}" is irreversible and no authorize record exists in the last ${AUTHORIZE_WINDOW_MS / MS_PER_MINUTE} minutes —`,
132
+ `${blocking ? 'blocked: ' : ''}get a human token with \`vibe ask --needs authorize:${action}\``,
133
+ 'and run `vibe authorize` first, as its own command: the gate reads the ledger before this command runs,',
134
+ 'so an authorize chained in front of the action is not seen',
135
+ ].join(' ');
136
+ }
112
137
 
113
138
  function tokenPolicy() {
114
139
  try {
115
- return JSON.parse(fs.readFileSync(path.join(root, '.vibe', 'config.json'), 'utf-8')).tokens || 'off';
140
+ return JSON.parse(fs.readFileSync(path.join(root, PROJECT_DIR, 'config.json'), 'utf-8')).tokens || 'off';
116
141
  } catch {
117
142
  return 'off';
118
143
  }
119
144
  }
120
- const READ_ADVISE_LINES = Number(process.env.VIBE_READ_ADVISE_LINES || 400);
145
+ const READ_ADVISE_LINES = Number(process.env.VIBE_READ_ADVISE_LINES || DEFAULT_READ_ADVISE_LINES);
121
146
 
122
147
  function countLines(file) {
123
148
  try {
@@ -134,7 +159,10 @@ function adviseRead(payload) {
134
159
  const lines = countLines(path.resolve(root, file));
135
160
  if (lines <= READ_ADVISE_LINES) return;
136
161
  const shown = (path.isAbsolute(file) ? path.relative(root, file) || file : file).split(path.sep).join('/');
137
- emitContext(`[vibe] ${shown} is ${lines} lines — when it only has to be understood, not edited or debugged, \`vibe read ${shown} --ask "<question>"\` lets a low-reasoning model read it and returns the answer with line numbers`);
162
+ emitContext([
163
+ `[vibe] ${shown} is ${lines} lines — when it only has to be understood, not edited or debugged,`,
164
+ `\`vibe read ${shown} --ask "<question>"\` lets a low-reasoning model read it and returns the answer with line numbers`,
165
+ ].join(' '));
138
166
  }
139
167
 
140
168
  const cli = path.join(here, '..', 'dist', 'cli.js');
@@ -158,14 +186,14 @@ if (mode === 'stop') {
158
186
  if (mode === 'session') {
159
187
  const payload = readPayload();
160
188
  const recall = knowledgeAuto.start(payload);
161
- process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: [structuralSession(payload), recall, codeMap.codeMap(root)].filter(Boolean).join('\n') } })}\n`);
189
+ emit('SessionStart', [structuralSession(payload), recall, codeMap.codeMap(root)].filter(Boolean).join('\n'));
162
190
  process.exit(0);
163
191
  }
164
192
 
165
193
  if (mode === 'pre') {
166
194
  const payload = readPayload();
167
195
  if (payload.tool_name === 'Read') {
168
- if (fs.existsSync(path.join(root, '.vibe'))) adviseRead(payload);
196
+ if (hasProjectDir()) adviseRead(payload);
169
197
  process.exit(0);
170
198
  }
171
199
  const raw = payload.tool_input && payload.tool_input.command;
@@ -176,13 +204,13 @@ if (mode === 'pre') {
176
204
  const leave = (code) => { if (notes.length) emitContext(notes.join('\n')); process.exit(code); };
177
205
  let gate = null;
178
206
  try { gate = retest.before(payload, process.env.VIBE_HOME_DIR || os.homedir()); } catch { /* the command runs */ }
179
- if (gate?.decision === 'block') { process.stderr.write(`${gate.message}\n`); process.exit(2); }
207
+ if (gate?.decision === 'block') { process.stderr.write(`${gate.message}\n`); process.exit(BLOCK_EXIT); }
180
208
  if (gate?.decision === 'warn') notes.push(gate.message);
181
209
  if (personal && tokenPolicy() === 'off') leave(0);
182
210
  const slice = personal ? { decision: 'allow' } : sliceRuntime.sliceGuard(root, command, sliceClient(), cwd);
183
- if (slice.decision === 'block') { process.stderr.write(`${slice.message}\n`); process.exit(2); }
211
+ if (slice.decision === 'block') { process.stderr.write(`${slice.message}\n`); process.exit(BLOCK_EXIT); }
184
212
  if (slice.decision === 'warn') notes.push(slice.message);
185
- if (!fs.existsSync(path.join(root, '.vibe'))) leave(0);
213
+ if (!hasProjectDir()) leave(0);
186
214
  // Every segment is judged — `echo x && git push`, `ls; rm -rf build`, `cat x | git push` carry the action in a later one
187
215
  // Vibe gates its own execution; its approval and inbox commands must remain reachable.
188
216
  const segments = command.split(/&&|\|\||;|\||\n/).map((s) => s.trim()).filter((s) => s && !READS_ONLY.test(s) && !/^vibe(?:\s|$)/.test(s));
@@ -190,8 +218,8 @@ if (mode === 'pre') {
190
218
  for (const [action, re] of IRREVERSIBLE) {
191
219
  if (segments.some((s) => re.test(s)) && !recentAuthorize(action)) {
192
220
  const blocking = tokenPolicy() !== 'off';
193
- process.stderr.write(`[vibe] "${action}" is irreversible and no authorize record exists in the last 10 minutes — ${blocking ? 'blocked: ' : ''}get a human token with \`vibe ask --needs authorize:${action}\` and run \`vibe authorize\` first, as its own command: the gate reads the ledger before this command runs, so an authorize chained in front of the action is not seen\n`);
194
- if (blocking) process.exit(2);
221
+ process.stderr.write(`${irreversibleNotice(action, blocking)}\n`);
222
+ if (blocking) process.exit(BLOCK_EXIT);
195
223
  leave(0);
196
224
  }
197
225
  }
@@ -200,8 +228,8 @@ if (mode === 'pre') {
200
228
 
201
229
  // Inside the npm package dist/cli.js sits next to us; inside a plugin tree it does not, so fall back to `vibe` on PATH.
202
230
  const result = fs.existsSync(cli)
203
- ? spawnSync(process.execPath, [cli, 'state', '--json'], { cwd: root, encoding: 'utf-8', timeout: 15000 })
204
- : spawnSync('vibe', ['state', '--json'], { cwd: root, encoding: 'utf-8', timeout: 15000, shell: process.platform === 'win32' });
231
+ ? spawnSync(process.execPath, [cli, 'state', '--json'], { cwd: root, encoding: 'utf-8', timeout: STATE_TIMEOUT_MS })
232
+ : spawnSync('vibe', ['state', '--json'], { cwd: root, encoding: 'utf-8', timeout: STATE_TIMEOUT_MS, shell: process.platform === 'win32' });
205
233
  if (result.status !== 0 || !result.stdout) process.exit(0);
206
234
  try {
207
235
  const view = JSON.parse(result.stdout);
@@ -4,16 +4,22 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { createHash, randomBytes } = require('node:crypto');
6
6
 
7
+ const REGISTRY_FILE = 'obsidian.json';
8
+ const VAULT_ID_CHARS = 16;
9
+ const TEMP_SUFFIX_BYTES = 6;
10
+
7
11
  function readJson(file) {
8
12
  try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
9
13
  }
10
14
 
11
15
  /** Where Obsidian keeps its vault list on each platform, including the Flatpak build on Linux. */
12
16
  function obsidianRegistries(home, platform = process.platform, env = process.env) {
13
- if (platform === 'darwin') return [path.join(home, 'Library', 'Application Support', 'obsidian', 'obsidian.json')];
14
- if (platform === 'win32') return [path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'obsidian', 'obsidian.json')];
15
- return [path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'obsidian', 'obsidian.json'),
16
- path.join(home, '.var', 'app', 'md.obsidian.Obsidian', 'config', 'obsidian', 'obsidian.json')];
17
+ if (platform === 'darwin') return [path.join(home, 'Library', 'Application Support', 'obsidian', REGISTRY_FILE)];
18
+ if (platform === 'win32') return [path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'obsidian', REGISTRY_FILE)];
19
+ return [
20
+ path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'obsidian', REGISTRY_FILE),
21
+ path.join(home, '.var', 'app', 'md.obsidian.Obsidian', 'config', 'obsidian', REGISTRY_FILE),
22
+ ];
17
23
  }
18
24
 
19
25
  /** Registered vaults that still exist, by real path: the open one first, then the most recently used. */
@@ -29,7 +35,11 @@ function obsidianVaults(home, platform, env) {
29
35
  return [...found.values()].sort((a, b) => Number(b.open) - Number(a.open) || b.ts - a.ts);
30
36
  }
31
37
 
32
- const CLOUD = [['Mobile Documents', 'iCloud'], ['iCloud', 'iCloud'], ['Dropbox', 'Dropbox'], ['OneDrive', 'OneDrive'], ['Google Drive', 'Google Drive'], ['CloudStorage', 'a cloud drive']];
38
+ // A folder name in a path, and the service whose sync folder carries it.
39
+ const CLOUD = [
40
+ ['Mobile Documents', 'iCloud'], ['iCloud', 'iCloud'], ['Dropbox', 'Dropbox'], ['OneDrive', 'OneDrive'],
41
+ ['Google Drive', 'Google Drive'], ['CloudStorage', 'a cloud drive'],
42
+ ];
33
43
 
34
44
  /** The service that copies this folder off the computer, when its path or Obsidian's own Sync plugin shows one. */
35
45
  function syncedBy(folder) {
@@ -76,9 +86,9 @@ function connectVault(folder, home, platform, env) {
76
86
  if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return 'waiting';
77
87
  const real = fs.realpathSync(folder);
78
88
  if (obsidianVaults(home, platform, env).some(v => real === v.path || real.startsWith(`${v.path}${path.sep}`))) return 'in-vault';
79
- const id = createHash('sha256').update(real).digest('hex').slice(0, 16);
89
+ const id = createHash('sha256').update(real).digest('hex').slice(0, VAULT_ID_CHARS);
80
90
  const next = { ...doc, vaults: { ...(doc.vaults ?? {}), [id]: { path: real, ts: Date.now() } } };
81
- const temporary = `${registry}.${randomBytes(6).toString('hex')}.tmp`;
91
+ const temporary = `${registry}.${randomBytes(TEMP_SUFFIX_BYTES).toString('hex')}.tmp`;
82
92
  fs.writeFileSync(temporary, JSON.stringify(next));
83
93
  fs.renameSync(temporary, registry);
84
94
  return 'connected';
@@ -3,31 +3,44 @@ const os = require('node:os');
3
3
  const path = require('node:path');
4
4
  const { randomBytes } = require('node:crypto');
5
5
 
6
+ const STORE_DIR = '.vibe-runtime';
7
+ const DIRECTORY_MODE = 0o700;
8
+ const FILE_MODE = 0o600;
9
+ // Any group or other permission bit.
10
+ const OTHERS_MASK = 0o077;
11
+ const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
12
+ const TEMP_SUFFIX_BYTES = 12;
13
+ const ENTRY_NAME = /^[a-z0-9.-]+$/i;
14
+ const LINKED_ENTRY = 'local store entry must be a real private directory or regular file, never linked';
15
+ const PERMISSIONS = 'local store permissions must be owner-private (0700 directory, 0600 file)';
16
+ const CHANGED_ENTRY = 'local store entry changed or exceeds its read limit';
17
+
6
18
  function inside(parent, file) {
7
19
  const relative = path.relative(parent, file);
8
20
  return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
9
21
  }
10
22
  function privateEntry(stat, directory) {
11
- if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (!directory && stat.nlink !== 1)) throw new Error('local store entry must be a real private directory or regular file, never linked');
12
- if (process.platform !== 'win32' && (stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0)) throw new Error('local store permissions must be owner-private (0700 directory, 0600 file)');
23
+ const wrongKind = directory ? !stat.isDirectory() : !stat.isFile();
24
+ if (stat.isSymbolicLink() || wrongKind || (!directory && stat.nlink !== 1)) throw new Error(LINKED_ENTRY);
25
+ if (process.platform !== 'win32' && (stat.uid !== process.getuid?.() || (stat.mode & OTHERS_MASK) !== 0)) throw new Error(PERMISSIONS);
13
26
  return stat;
14
27
  }
15
28
  function privateStat(file, directory) { return privateEntry(fs.lstatSync(file), directory); }
16
29
  function privateDirectory(root, create, homeDirectory = os.homedir()) {
17
30
  const home = fs.realpathSync(homeDirectory);
18
- const dir = path.join(home, '.vibe-runtime');
31
+ const dir = path.join(home, STORE_DIR);
19
32
  if (inside(fs.realpathSync(root), dir)) throw new Error('local execution consent storage must be outside the project');
20
33
  if (!fs.lstatSync(dir, { throwIfNoEntry: false })) {
21
34
  if (!create) return dir;
22
- fs.mkdirSync(dir, { mode: 0o700 });
35
+ fs.mkdirSync(dir, { mode: DIRECTORY_MODE });
23
36
  }
24
37
  privateStat(dir, true);
25
38
  return dir;
26
39
  }
27
40
  function entryName(name) {
28
- if (!/^[a-z0-9.-]+$/i.test(name)) throw new Error('invalid local store entry name');
41
+ if (!ENTRY_NAME.test(name)) throw new Error('invalid local store entry name');
29
42
  }
30
- function readPrivate(root, name, maxBytes = 2097152, homeDirectory) {
43
+ function readPrivate(root, name, maxBytes = DEFAULT_MAX_BYTES, homeDirectory) {
31
44
  entryName(name);
32
45
  const dir = privateDirectory(root, false, homeDirectory);
33
46
  if (!fs.existsSync(dir)) return null;
@@ -37,11 +50,13 @@ function readPrivate(root, name, maxBytes = 2097152, homeDirectory) {
37
50
  const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
38
51
  try {
39
52
  const actual = privateEntry(fs.fstatSync(fd), false);
40
- if (actual.dev !== before.dev || actual.ino !== before.ino || actual.size > maxBytes) throw new Error('local store entry changed or exceeds its read limit');
53
+ if (actual.dev !== before.dev || actual.ino !== before.ino || actual.size > maxBytes) throw new Error(CHANGED_ENTRY);
41
54
  const buffer = Buffer.alloc(maxBytes + 1);
42
55
  const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
43
56
  const after = privateStat(file, false);
44
- if (length > maxBytes || length !== actual.size || after.ino !== actual.ino || after.dev !== actual.dev || after.size !== actual.size || after.mtimeMs !== actual.mtimeMs || after.ctimeMs !== actual.ctimeMs) throw new Error('local store entry changed or exceeds its read limit');
57
+ const same = after.ino === actual.ino && after.dev === actual.dev && after.size === actual.size
58
+ && after.mtimeMs === actual.mtimeMs && after.ctimeMs === actual.ctimeMs;
59
+ if (length > maxBytes || length !== actual.size || !same) throw new Error(CHANGED_ENTRY);
45
60
  return buffer.subarray(0, length).toString('utf8');
46
61
  } finally { fs.closeSync(fd); }
47
62
  }
@@ -50,8 +65,8 @@ function writePrivate(root, name, text, homeDirectory) {
50
65
  const dir = privateDirectory(root, true, homeDirectory);
51
66
  const file = path.join(dir, name);
52
67
  if (fs.lstatSync(file, { throwIfNoEntry: false })) privateStat(file, false);
53
- const temporary = path.join(dir, `${name}.${randomBytes(12).toString('hex')}.tmp`);
54
- fs.writeFileSync(temporary, text, { flag: 'wx', mode: 0o600 });
68
+ const temporary = path.join(dir, `${name}.${randomBytes(TEMP_SUFFIX_BYTES).toString('hex')}.tmp`);
69
+ fs.writeFileSync(temporary, text, { flag: 'wx', mode: FILE_MODE });
55
70
  try { privateStat(dir, true); fs.renameSync(temporary, file); }
56
71
  finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
57
72
  return file;