@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/dist/cli/common.js +4 -1
- package/dist/cli/common.js.map +1 -1
- package/dist/cli/heldout.js +10 -4
- package/dist/cli/heldout.js.map +1 -1
- package/dist/cli/human.js +8 -4
- package/dist/cli/human.js.map +1 -1
- package/dist/cli/internal.js +37 -24
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/ledger.js +67 -0
- package/dist/cli/ledger.js.map +1 -0
- package/dist/cli/map.js +5 -3
- package/dist/cli/map.js.map +1 -1
- package/dist/cli/memory.js +104 -74
- package/dist/cli/memory.js.map +1 -1
- package/dist/cli/mutate.js +20 -0
- package/dist/cli/mutate.js.map +1 -0
- package/dist/cli/risks.js +20 -3
- package/dist/cli/risks.js.map +1 -1
- package/dist/cli/session.js +4 -2
- package/dist/cli/session.js.map +1 -1
- package/dist/cli/setup.js +44 -13
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/work.js +73 -21
- package/dist/cli/work.js.map +1 -1
- package/dist/cli.js +19 -6
- package/dist/cli.js.map +1 -1
- package/dist/core/analyze.js +21 -7
- package/dist/core/analyze.js.map +1 -1
- package/dist/core/artifact-paths.js +14 -3
- package/dist/core/artifact-paths.js.map +1 -1
- package/dist/core/capabilities.js +69 -15
- package/dist/core/capabilities.js.map +1 -1
- package/dist/core/check-authorization.js +37 -0
- package/dist/core/check-authorization.js.map +1 -0
- package/dist/core/check-process.js +12 -4
- package/dist/core/check-process.js.map +1 -1
- package/dist/core/check-report.js +66 -0
- package/dist/core/check-report.js.map +1 -0
- package/dist/core/check.js +59 -71
- package/dist/core/check.js.map +1 -1
- package/dist/core/checks/changed.js +20 -6
- package/dist/core/checks/changed.js.map +1 -1
- package/dist/core/checks/eval.js +35 -10
- package/dist/core/checks/eval.js.map +1 -1
- package/dist/core/checks/file.js +26 -23
- package/dist/core/checks/file.js.map +1 -1
- package/dist/core/checks/fileRules.js +42 -16
- package/dist/core/checks/fileRules.js.map +1 -1
- package/dist/core/checks/http.js +35 -34
- package/dist/core/checks/http.js.map +1 -1
- package/dist/core/checks/packs.js +1 -1
- package/dist/core/checks/packs.js.map +1 -1
- package/dist/core/checks/review.js +75 -40
- package/dist/core/checks/review.js.map +1 -1
- package/dist/core/checks/run.js +5 -2
- package/dist/core/checks/run.js.map +1 -1
- package/dist/core/checks/schema.js +25 -0
- package/dist/core/checks/schema.js.map +1 -0
- package/dist/core/checks/source.js +11 -6
- package/dist/core/checks/source.js.map +1 -1
- package/dist/core/config.js +9 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/consent.js +24 -16
- package/dist/core/consent.js.map +1 -1
- package/dist/core/context-notes.js +15 -7
- package/dist/core/context-notes.js.map +1 -1
- package/dist/core/context.js +24 -12
- package/dist/core/context.js.map +1 -1
- package/dist/core/conventions.js +22 -9
- package/dist/core/conventions.js.map +1 -1
- package/dist/core/docs/cfb.js +81 -45
- package/dist/core/docs/cfb.js.map +1 -1
- package/dist/core/docs/html.js +14 -3
- package/dist/core/docs/html.js.map +1 -1
- package/dist/core/docs/hwp.js +44 -22
- package/dist/core/docs/hwp.js.map +1 -1
- package/dist/core/docs/office.js +20 -10
- package/dist/core/docs/office.js.map +1 -1
- package/dist/core/docs/pdf.js +10 -4
- package/dist/core/docs/pdf.js.map +1 -1
- package/dist/core/docs/read.js +32 -24
- package/dist/core/docs/read.js.map +1 -1
- package/dist/core/docs/zip.js +32 -15
- package/dist/core/docs/zip.js.map +1 -1
- package/dist/core/evidence.js +17 -7
- package/dist/core/evidence.js.map +1 -1
- package/dist/core/failure-message.js +35 -13
- package/dist/core/failure-message.js.map +1 -1
- package/dist/core/failure.js +49 -14
- package/dist/core/failure.js.map +1 -1
- package/dist/core/github.js +3 -1
- package/dist/core/github.js.map +1 -1
- package/dist/core/handoff.js +20 -7
- package/dist/core/handoff.js.map +1 -1
- package/dist/core/heldout-run.js +6 -3
- package/dist/core/heldout-run.js.map +1 -1
- package/dist/core/heldout.js +24 -13
- package/dist/core/heldout.js.map +1 -1
- package/dist/core/inbox.js +3 -1
- package/dist/core/inbox.js.map +1 -1
- package/dist/core/inputs.js +6 -3
- package/dist/core/inputs.js.map +1 -1
- package/dist/core/inspect.js +61 -20
- package/dist/core/inspect.js.map +1 -1
- package/dist/core/intent.js +35 -13
- package/dist/core/intent.js.map +1 -1
- package/dist/core/knowledge-find.js +31 -12
- package/dist/core/knowledge-find.js.map +1 -1
- package/dist/core/knowledge.js +22 -12
- package/dist/core/knowledge.js.map +1 -1
- package/dist/core/lang.js +5 -2
- package/dist/core/lang.js.map +1 -1
- package/dist/core/ledger.js +44 -20
- package/dist/core/ledger.js.map +1 -1
- package/dist/core/locations.js +12 -0
- package/dist/core/locations.js.map +1 -0
- package/dist/core/map/imports.js +20 -9
- package/dist/core/map/imports.js.map +1 -1
- package/dist/core/map/index.js +28 -13
- package/dist/core/map/index.js.map +1 -1
- package/dist/core/map/symbols.js +27 -12
- package/dist/core/map/symbols.js.map +1 -1
- package/dist/core/mutate.js +107 -0
- package/dist/core/mutate.js.map +1 -0
- package/dist/core/output-capture.js +7 -4
- package/dist/core/output-capture.js.map +1 -1
- package/dist/core/paths.js +2 -1
- package/dist/core/paths.js.map +1 -1
- package/dist/core/performance.js +43 -17
- package/dist/core/performance.js.map +1 -1
- package/dist/core/profile.js +6 -3
- package/dist/core/profile.js.map +1 -1
- package/dist/core/reader.js +32 -16
- package/dist/core/reader.js.map +1 -1
- package/dist/core/readerSession.js +34 -14
- package/dist/core/readerSession.js.map +1 -1
- package/dist/core/recent.js +20 -6
- package/dist/core/recent.js.map +1 -1
- package/dist/core/regress.js +21 -12
- package/dist/core/regress.js.map +1 -1
- package/dist/core/repair.js +22 -4
- package/dist/core/repair.js.map +1 -1
- package/dist/core/research-queries.js +85 -0
- package/dist/core/research-queries.js.map +1 -0
- package/dist/core/research.js +89 -88
- package/dist/core/research.js.map +1 -1
- package/dist/core/results.js +15 -6
- package/dist/core/results.js.map +1 -1
- package/dist/core/risk-signals.js +53 -15
- package/dist/core/risk-signals.js.map +1 -1
- package/dist/core/run-id.js +10 -6
- package/dist/core/run-id.js.map +1 -1
- package/dist/core/scenarios.js +91 -60
- package/dist/core/scenarios.js.map +1 -1
- package/dist/core/session.js +3 -2
- package/dist/core/session.js.map +1 -1
- package/dist/core/size.js +28 -8
- package/dist/core/size.js.map +1 -1
- package/dist/core/skills.js +66 -28
- package/dist/core/skills.js.map +1 -1
- package/dist/core/skillsmp.js +26 -12
- package/dist/core/skillsmp.js.map +1 -1
- package/dist/core/source-basis.js +21 -9
- package/dist/core/source-basis.js.map +1 -1
- package/dist/core/status.js +26 -13
- package/dist/core/status.js.map +1 -1
- package/dist/core/table.js +8 -1
- package/dist/core/table.js.map +1 -1
- package/dist/core/tokens.js +20 -10
- package/dist/core/tokens.js.map +1 -1
- package/dist/core/tree.js +26 -16
- package/dist/core/tree.js.map +1 -1
- package/dist/core/verification-plan.js +24 -8
- package/dist/core/verification-plan.js.map +1 -1
- package/dist/core/verification.js +14 -7
- package/dist/core/verification.js.map +1 -1
- package/dist/core/view.js +93 -44
- package/dist/core/view.js.map +1 -1
- package/dist/install/global.js +37 -18
- package/dist/install/global.js.map +1 -1
- package/dist/install/hooks.js +17 -8
- package/dist/install/hooks.js.map +1 -1
- package/dist/install/manifest.js +11 -0
- package/dist/install/manifest.js.map +1 -0
- package/dist/install/mcpb.js +52 -23
- package/dist/install/mcpb.js.map +1 -1
- package/dist/install/plugin.js +73 -33
- package/dist/install/plugin.js.map +1 -1
- package/dist/install/project.js +8 -7
- package/dist/install/project.js.map +1 -1
- package/dist/install/register.js +83 -48
- package/dist/install/register.js.map +1 -1
- package/dist/install/tree.js +27 -11
- package/dist/install/tree.js.map +1 -1
- package/dist/install/update.js +46 -20
- package/dist/install/update.js.map +1 -1
- package/hooks/code-map.cjs +22 -9
- package/hooks/journal.cjs +18 -16
- package/hooks/knowledge-auto.cjs +55 -21
- package/hooks/knowledge-journal.cjs +47 -20
- package/hooks/knowledge-projects.cjs +50 -21
- package/hooks/knowledge-recall.cjs +16 -6
- package/hooks/knowledge-upkeep.cjs +22 -5
- package/hooks/locations.cjs +35 -0
- package/hooks/notify.js +52 -24
- package/hooks/obsidian.cjs +17 -7
- package/hooks/private-store.cjs +25 -10
- package/hooks/retest.cjs +39 -16
- package/hooks/session-context.cjs +30 -13
- package/hooks/session-files.cjs +44 -21
- package/hooks/session-repair.cjs +26 -11
- package/hooks/session-state.cjs +78 -38
- package/hooks/session.js +19 -8
- package/hooks/slice-command.cjs +38 -17
- package/hooks/slice-guard.cjs +20 -6
- package/hooks/slice-store.cjs +25 -12
- package/internal/guides/code.md +2 -0
- package/mcpb/server/index.js +40 -12
- 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
|
|
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,
|
|
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(
|
|
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 (!
|
|
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 =
|
|
65
|
+
const target = sharedFolder(root);
|
|
52
66
|
fs.mkdirSync(target, { recursive: true });
|
|
53
|
-
const link = path.join(wiki,
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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,
|
|
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
|
-
//
|
|
27
|
-
const finder = path.join(__dirname, '..',
|
|
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 =
|
|
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),
|
|
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(-
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 [
|
|
57
|
+
for (const file of [claudeSettings(home)]) {
|
|
42
58
|
try {
|
|
43
|
-
const settings = JSON.parse(sessionFiles.optional(file,
|
|
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(
|
|
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
|
-
|
|
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
|
|
72
|
-
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName
|
|
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,
|
|
97
|
-
const cutoff = Date.now() -
|
|
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 (!
|
|
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,
|
|
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 ||
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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 (!
|
|
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(
|
|
194
|
-
if (blocking) process.exit(
|
|
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:
|
|
204
|
-
: spawnSync('vibe', ['state', '--json'], { cwd: root, encoding: 'utf-8', timeout:
|
|
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);
|
package/hooks/obsidian.cjs
CHANGED
|
@@ -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',
|
|
14
|
-
if (platform === 'win32') return [path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'obsidian',
|
|
15
|
-
return [
|
|
16
|
-
path.join(
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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';
|
package/hooks/private-store.cjs
CHANGED
|
@@ -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
|
-
|
|
12
|
-
if (
|
|
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,
|
|
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:
|
|
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 (
|
|
41
|
+
if (!ENTRY_NAME.test(name)) throw new Error('invalid local store entry name');
|
|
29
42
|
}
|
|
30
|
-
function readPrivate(root, name, maxBytes =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
54
|
-
fs.writeFileSync(temporary, text, { flag: 'wx', mode:
|
|
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;
|