claudenv 1.2.5 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -3
- package/bin/cli.js +294 -0
- package/package.json +1 -1
- package/scaffold/.claude/memories/README.md +44 -0
- package/scaffold/global/.claude/commands/add-source.md +31 -0
- package/scaffold/global/.claude/commands/canon.md +33 -0
- package/scaffold/global/.claude/commands/decisions.md +29 -0
- package/scaffold/global/.claude/commands/deeper.md +55 -0
- package/scaffold/global/.claude/commands/just-code.md +26 -0
- package/scaffold/global/.claude/commands/why.md +33 -0
- package/scaffold/global/.claude/skills/source-connector/SKILL.md +128 -0
- package/scaffold/global/.claude/skills/vibe-decisions/SKILL.md +127 -0
- package/scaffold/global/.claude/skills/vibe-decisions/deep-dive-template.md +41 -0
- package/scaffold/global-claudenv/config.yaml +16 -0
- package/scaffold/global-claudenv/memories/INDEX.md +25 -0
- package/scaffold/global-claudenv/memories/canon/index.yaml +21 -0
- package/scaffold/global-claudenv/memories/user/preferences.md.example +16 -0
- package/src/autonomy.js +6 -1
- package/src/canon.js +214 -0
- package/src/decisions.js +161 -0
- package/src/doctor.js +201 -0
- package/src/hooks/decisions-logger.js +183 -0
- package/src/hooks/dispatcher.js +91 -0
- package/src/hooks/regen-index.js +198 -0
- package/src/hooks-gen.js +43 -1
- package/src/installer.js +57 -14
- package/src/loop.js +26 -1
- package/src/memory-context.js +63 -0
- package/src/memory-paths.js +128 -0
- package/src/memory.js +111 -0
- package/src/sources.js +78 -0
- package/src/workspaces.js +129 -0
- package/templates/connector-mssql.ejs +62 -0
- package/templates/connector-rest.ejs +59 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* regen-index — SessionEnd hook handler and CLI command for INDEX.md generation.
|
|
3
|
+
*
|
|
4
|
+
* Scans ~/.claudenv/memories/decisions/ + <cwd>/.claude/memories/decisions/,
|
|
5
|
+
* picks the top-N most recent by `date:` frontmatter, and produces a compact
|
|
6
|
+
* INDEX.md briefing (~30-50 lines) that the next session reads via memory tool
|
|
7
|
+
* or via `--append-system-prompt` (loop).
|
|
8
|
+
*
|
|
9
|
+
* Idempotent. Hard-capped output size so it never bloats the cache.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile, writeFile, readdir, mkdir, unlink, stat } from 'node:fs/promises';
|
|
13
|
+
import { join, basename } from 'node:path';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_RECENT_LIMIT = 5;
|
|
17
|
+
const MAX_INDEX_LINES = 60;
|
|
18
|
+
|
|
19
|
+
// Lazy resolution so tests can override via process.env.CLAUDENV_HOME.
|
|
20
|
+
function claudenvHome() {
|
|
21
|
+
return process.env.CLAUDENV_HOME || join(homedir(), '.claudenv');
|
|
22
|
+
}
|
|
23
|
+
function memoriesDir() { return join(claudenvHome(), 'memories'); }
|
|
24
|
+
function globalDecisionsDir() { return join(memoriesDir(), 'decisions'); }
|
|
25
|
+
function userPrefsPath() { return join(memoriesDir(), 'user', 'preferences.md'); }
|
|
26
|
+
function indexPath() { return join(memoriesDir(), 'INDEX.md'); }
|
|
27
|
+
function dirtyFlagPath() { return join(claudenvHome(), '.index-dirty'); }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read YAML frontmatter into a flat object. Same minimal parser as decisions-logger.
|
|
31
|
+
*/
|
|
32
|
+
function parseFrontmatter(text) {
|
|
33
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
34
|
+
if (!match) return null;
|
|
35
|
+
const result = {};
|
|
36
|
+
for (const line of match[1].split('\n')) {
|
|
37
|
+
const m = /^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
|
|
38
|
+
if (!m) continue;
|
|
39
|
+
let value = m[2].trim().replace(/^['"]|['"]$/g, '');
|
|
40
|
+
result[m[1]] = value;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Collect decision entries from one directory.
|
|
47
|
+
* @returns {Promise<Array<{file: string, date: string|null, topic: string, chose: string, scope: string}>>}
|
|
48
|
+
*/
|
|
49
|
+
async function collectDecisions(dir, scopeHint) {
|
|
50
|
+
let names;
|
|
51
|
+
try {
|
|
52
|
+
names = await readdir(dir);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
if (!name.endsWith('.md')) continue;
|
|
59
|
+
const full = join(dir, name);
|
|
60
|
+
let text;
|
|
61
|
+
try {
|
|
62
|
+
text = await readFile(full, 'utf-8');
|
|
63
|
+
} catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const fm = parseFrontmatter(text) || {};
|
|
67
|
+
out.push({
|
|
68
|
+
file: full,
|
|
69
|
+
name,
|
|
70
|
+
date: fm.date || null,
|
|
71
|
+
topic: fm.topic || name.replace(/\.md$/, ''),
|
|
72
|
+
chose: fm.chose || '',
|
|
73
|
+
scope: fm.scope || scopeHint,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read user/preferences.md, return summary lines (up to N).
|
|
81
|
+
*/
|
|
82
|
+
async function summarizePrefs(maxLines = 8) {
|
|
83
|
+
try {
|
|
84
|
+
const text = await readFile(userPrefsPath(), 'utf-8');
|
|
85
|
+
return text
|
|
86
|
+
.split('\n')
|
|
87
|
+
.map((l) => l.trim())
|
|
88
|
+
.filter((l) => l.startsWith('- '))
|
|
89
|
+
.slice(0, maxLines);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate INDEX.md content.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} [options]
|
|
99
|
+
* @param {string} [options.cwd] - Project root to include project-scoped decisions from
|
|
100
|
+
* @param {number} [options.limit] - Max decisions to surface
|
|
101
|
+
*/
|
|
102
|
+
export async function regenIndex(options = {}) {
|
|
103
|
+
const cwd = options.cwd || process.cwd();
|
|
104
|
+
const limit = options.limit ?? DEFAULT_RECENT_LIMIT;
|
|
105
|
+
|
|
106
|
+
const global = await collectDecisions(globalDecisionsDir(), 'global');
|
|
107
|
+
const project = await collectDecisions(
|
|
108
|
+
join(cwd, '.claude', 'memories', 'decisions'),
|
|
109
|
+
'project'
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const all = [...global, ...project];
|
|
113
|
+
|
|
114
|
+
// Sort newest first by date (ISO sorts lexicographically). Missing date = oldest.
|
|
115
|
+
all.sort((a, b) => {
|
|
116
|
+
if (!a.date && !b.date) return 0;
|
|
117
|
+
if (!a.date) return 1;
|
|
118
|
+
if (!b.date) return -1;
|
|
119
|
+
return b.date.localeCompare(a.date);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const recent = all.slice(0, limit);
|
|
123
|
+
const prefs = await summarizePrefs();
|
|
124
|
+
|
|
125
|
+
const lines = [];
|
|
126
|
+
lines.push('# claudenv memory — INDEX');
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push(`> Auto-generated by \`claudenv hook regen-index\`. Last: ${new Date().toISOString()}`);
|
|
129
|
+
lines.push('');
|
|
130
|
+
|
|
131
|
+
lines.push('## Recent decisions');
|
|
132
|
+
lines.push('');
|
|
133
|
+
if (recent.length === 0) {
|
|
134
|
+
lines.push('_No decisions yet. Make a non-trivial technical choice; vibe-decisions will log it._');
|
|
135
|
+
} else {
|
|
136
|
+
for (const d of recent) {
|
|
137
|
+
const date = d.date ? d.date.slice(0, 10) : '?';
|
|
138
|
+
const scope = d.scope === 'project' ? '[project]' : '[global]';
|
|
139
|
+
const slug = basename(d.name, '.md');
|
|
140
|
+
lines.push(`- ${date} ${scope} **${d.topic}** → ${d.chose} (\`${slug}\`)`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
lines.push('');
|
|
144
|
+
|
|
145
|
+
if (prefs.length) {
|
|
146
|
+
lines.push('## Active preferences');
|
|
147
|
+
lines.push('');
|
|
148
|
+
for (const p of prefs) lines.push(p);
|
|
149
|
+
lines.push('');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
lines.push('---');
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(`Files: ${global.length} global, ${project.length} project decisions. Run \`claudenv decisions list\` for the full set.`);
|
|
155
|
+
|
|
156
|
+
// Hard cap to keep cache stable.
|
|
157
|
+
const capped = lines.slice(0, MAX_INDEX_LINES);
|
|
158
|
+
|
|
159
|
+
await mkdir(memoriesDir(), { recursive: true });
|
|
160
|
+
await writeFile(indexPath(), capped.join('\n') + '\n', 'utf-8');
|
|
161
|
+
|
|
162
|
+
// Clear dirty flag if present.
|
|
163
|
+
try {
|
|
164
|
+
await unlink(dirtyFlagPath());
|
|
165
|
+
} catch {
|
|
166
|
+
/* not present — fine */
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
indexPath: indexPath(),
|
|
171
|
+
decisionCount: all.length,
|
|
172
|
+
recentCount: recent.length,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read the dirty flag (returns true if INDEX.md needs regen).
|
|
178
|
+
*/
|
|
179
|
+
export async function isIndexDirty() {
|
|
180
|
+
try {
|
|
181
|
+
await stat(dirtyFlagPath());
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Hook entry point — called by dispatcher on SessionEnd.
|
|
190
|
+
*/
|
|
191
|
+
export async function handleRegenIndex({ event } = {}) {
|
|
192
|
+
const cwd = event?.cwd || process.cwd();
|
|
193
|
+
const result = await regenIndex({ cwd });
|
|
194
|
+
return {
|
|
195
|
+
exitCode: 0,
|
|
196
|
+
message: `INDEX.md regenerated (${result.recentCount} recent of ${result.decisionCount} total decisions)`,
|
|
197
|
+
};
|
|
198
|
+
}
|
package/src/hooks-gen.js
CHANGED
|
@@ -4,14 +4,36 @@
|
|
|
4
4
|
|
|
5
5
|
import { CREDENTIAL_PATHS } from './profiles.js';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Detect whether `claudenv` is on PATH globally. Used to decide whether
|
|
9
|
+
* settings.json hooks should call `claudenv hook ...` directly or via `npx`.
|
|
10
|
+
*
|
|
11
|
+
* Async — but settings generation is sync today. We call this from the
|
|
12
|
+
* installer flow where we await it, then pass the resolved value in.
|
|
13
|
+
*/
|
|
14
|
+
export async function isGlobalInstall() {
|
|
15
|
+
try {
|
|
16
|
+
const { execFile } = await import('node:child_process');
|
|
17
|
+
const { promisify } = await import('node:util');
|
|
18
|
+
const exec = promisify(execFile);
|
|
19
|
+
await exec('which', ['claudenv']);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
/**
|
|
8
27
|
* Generate .claude/settings.json content for a given profile.
|
|
9
28
|
* @param {object} profile - Autonomy profile
|
|
10
29
|
* @param {object} detected - Tech stack detection result
|
|
30
|
+
* @param {object} [opts]
|
|
31
|
+
* @param {string} [opts.claudenvCmd] - Command prefix for `claudenv hook ...` (e.g., "claudenv" or "npx claudenv")
|
|
11
32
|
* @returns {string} JSON string
|
|
12
33
|
*/
|
|
13
|
-
export function generateSettingsJson(profile, detected = {}) {
|
|
34
|
+
export function generateSettingsJson(profile, detected = {}, opts = {}) {
|
|
14
35
|
const settings = {};
|
|
36
|
+
const claudenvCmd = opts.claudenvCmd || 'claudenv';
|
|
15
37
|
|
|
16
38
|
if (profile.allowedTools && profile.allowedTools.length > 0) {
|
|
17
39
|
settings.permissions = settings.permissions || {};
|
|
@@ -46,6 +68,16 @@ export function generateSettingsJson(profile, detected = {}) {
|
|
|
46
68
|
command: 'bash .claude/hooks/audit-log.sh',
|
|
47
69
|
timeout: 10,
|
|
48
70
|
};
|
|
71
|
+
const decisionsLoggerHook = {
|
|
72
|
+
type: 'command',
|
|
73
|
+
command: `${claudenvCmd} hook decisions-logger`,
|
|
74
|
+
timeout: 5,
|
|
75
|
+
};
|
|
76
|
+
const regenIndexHook = {
|
|
77
|
+
type: 'command',
|
|
78
|
+
command: `${claudenvCmd} hook regen-index`,
|
|
79
|
+
timeout: 5,
|
|
80
|
+
};
|
|
49
81
|
|
|
50
82
|
settings.hooks = {
|
|
51
83
|
PreToolUse: [
|
|
@@ -71,6 +103,16 @@ export function generateSettingsJson(profile, detected = {}) {
|
|
|
71
103
|
matcher: '',
|
|
72
104
|
hooks: [auditLogHook],
|
|
73
105
|
},
|
|
106
|
+
{
|
|
107
|
+
matcher: 'Write',
|
|
108
|
+
hooks: [decisionsLoggerHook],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
SessionEnd: [
|
|
112
|
+
{
|
|
113
|
+
matcher: '.*',
|
|
114
|
+
hooks: [regenIndexHook],
|
|
115
|
+
},
|
|
74
116
|
],
|
|
75
117
|
};
|
|
76
118
|
|
package/src/installer.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const SCAFFOLD_GLOBAL = join(__dirname, '..', 'scaffold', 'global');
|
|
8
|
+
const SCAFFOLD_GLOBAL_CLAUDENV = join(__dirname, '..', 'scaffold', 'global-claudenv');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Get the path to ~/.claude/
|
|
@@ -13,6 +14,14 @@ function getClaudeHome() {
|
|
|
13
14
|
return join(homedir(), '.claude');
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Get the path to ~/.claudenv/ (separate from ~/.claude/ — holds cross-project
|
|
19
|
+
* memory, canon, user preferences, and the doctor config).
|
|
20
|
+
*/
|
|
21
|
+
function getClaudenvHome() {
|
|
22
|
+
return join(homedir(), '.claudenv');
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* Recursively list all files in a directory (relative paths).
|
|
18
27
|
*/
|
|
@@ -31,20 +40,18 @@ async function listFiles(dir, base = dir) {
|
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* @param {object} [options]
|
|
38
|
-
* @param {boolean} [options.force] - Overwrite existing files
|
|
39
|
-
* @param {string} [options.claudeHome] - Override ~/.claude/ path (for testing)
|
|
40
|
-
* @returns {Promise<{written: string[], skipped: string[]}>}
|
|
43
|
+
* Copy every file under `sourceBase` into `targetBase`, skipping existing files
|
|
44
|
+
* unless `force` is set. Returns relative paths of what was written/skipped.
|
|
41
45
|
*/
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
async function copyTree(sourceBase, targetBase, { force }) {
|
|
47
|
+
let files;
|
|
48
|
+
try {
|
|
49
|
+
files = await listFiles(sourceBase);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err.code === 'ENOENT') return { written: [], skipped: [] };
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
46
54
|
|
|
47
|
-
const files = await listFiles(sourceBase);
|
|
48
55
|
const written = [];
|
|
49
56
|
const skipped = [];
|
|
50
57
|
|
|
@@ -52,7 +59,6 @@ export async function installGlobal(options = {}) {
|
|
|
52
59
|
const src = join(sourceBase, relPath);
|
|
53
60
|
const dest = join(targetBase, relPath);
|
|
54
61
|
|
|
55
|
-
// Check if file exists and skip unless force
|
|
56
62
|
if (!force) {
|
|
57
63
|
try {
|
|
58
64
|
await stat(dest);
|
|
@@ -66,7 +72,6 @@ export async function installGlobal(options = {}) {
|
|
|
66
72
|
await mkdir(dirname(dest), { recursive: true });
|
|
67
73
|
await cp(src, dest);
|
|
68
74
|
|
|
69
|
-
// Make .sh files executable
|
|
70
75
|
if (relPath.endsWith('.sh')) {
|
|
71
76
|
const { chmod } = await import('node:fs/promises');
|
|
72
77
|
await chmod(dest, 0o755);
|
|
@@ -78,6 +83,36 @@ export async function installGlobal(options = {}) {
|
|
|
78
83
|
return { written, skipped };
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Install global Claude Code artifacts to ~/.claude/ AND ~/.claudenv/.
|
|
88
|
+
* Copies scaffold/global/.claude/ → ~/.claude/ and scaffold/global-claudenv/ → ~/.claudenv/.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} [options]
|
|
91
|
+
* @param {boolean} [options.force] - Overwrite existing files
|
|
92
|
+
* @param {string} [options.claudeHome] - Override ~/.claude/ path (for testing)
|
|
93
|
+
* @param {string} [options.claudenvHome] - Override ~/.claudenv/ path (for testing)
|
|
94
|
+
* @returns {Promise<{written: string[], skipped: string[], claudenvWritten: string[], claudenvSkipped: string[]}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function installGlobal(options = {}) {
|
|
97
|
+
const { force = false, claudeHome, claudenvHome } = options;
|
|
98
|
+
const claudeTarget = claudeHome || getClaudeHome();
|
|
99
|
+
const claudenvTarget = claudenvHome || getClaudenvHome();
|
|
100
|
+
|
|
101
|
+
const claude = await copyTree(join(SCAFFOLD_GLOBAL, '.claude'), claudeTarget, { force });
|
|
102
|
+
const claudenv = await copyTree(SCAFFOLD_GLOBAL_CLAUDENV, claudenvTarget, { force });
|
|
103
|
+
|
|
104
|
+
// Ensure baseline subdirectories exist even when scaffold doesn't carry .gitkeep
|
|
105
|
+
// (decisions/ is empty by design until vibe-decisions fills it).
|
|
106
|
+
await mkdir(join(claudenvTarget, 'memories', 'decisions'), { recursive: true });
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
written: claude.written,
|
|
110
|
+
skipped: claude.skipped,
|
|
111
|
+
claudenvWritten: claudenv.written,
|
|
112
|
+
claudenvSkipped: claudenv.skipped,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
81
116
|
/**
|
|
82
117
|
* Remove global Claude Code artifacts from ~/.claude/.
|
|
83
118
|
*
|
|
@@ -95,7 +130,15 @@ export async function uninstallGlobal(options = {}) {
|
|
|
95
130
|
join(targetBase, 'commands', 'autonomy.md'),
|
|
96
131
|
join(targetBase, 'commands', 'setup-mcp.md'),
|
|
97
132
|
join(targetBase, 'commands', 'improve.md'),
|
|
133
|
+
join(targetBase, 'commands', 'deeper.md'),
|
|
134
|
+
join(targetBase, 'commands', 'why.md'),
|
|
135
|
+
join(targetBase, 'commands', 'decisions.md'),
|
|
136
|
+
join(targetBase, 'commands', 'canon.md'),
|
|
137
|
+
join(targetBase, 'commands', 'just-code.md'),
|
|
138
|
+
join(targetBase, 'commands', 'add-source.md'),
|
|
98
139
|
join(targetBase, 'skills', 'claudenv'),
|
|
140
|
+
join(targetBase, 'skills', 'vibe-decisions'),
|
|
141
|
+
join(targetBase, 'skills', 'source-connector'),
|
|
99
142
|
];
|
|
100
143
|
|
|
101
144
|
for (const target of targets) {
|
package/src/loop.js
CHANGED
|
@@ -3,6 +3,8 @@ import { readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { select } from '@inquirer/prompts';
|
|
5
5
|
import { createWorktree, removeWorktree, mergeWorktree, getCurrentBranch } from './worktree.js';
|
|
6
|
+
import { buildSystemPromptWithMemory } from './memory-context.js';
|
|
7
|
+
import { regenIndex } from './hooks/regen-index.js';
|
|
6
8
|
|
|
7
9
|
// =============================================
|
|
8
10
|
// Work report helpers
|
|
@@ -51,6 +53,7 @@ export function checkClaudeCli() {
|
|
|
51
53
|
* @param {string} [options.model] - Model to use
|
|
52
54
|
* @param {number} [options.budget] - Budget cap in USD
|
|
53
55
|
* @param {string} [options.appendSystemPrompt] - Append to system prompt
|
|
56
|
+
* @param {object} [options.env] - Extra env vars to merge into the child process env
|
|
54
57
|
* @returns {Promise<{ result: string, sessionId: string|null, usage: object|null }>}
|
|
55
58
|
*/
|
|
56
59
|
export function spawnClaude(prompt, options = {}) {
|
|
@@ -81,6 +84,7 @@ export function spawnClaude(prompt, options = {}) {
|
|
|
81
84
|
|
|
82
85
|
const child = spawn('claude', args, {
|
|
83
86
|
cwd: options.cwd || process.cwd(),
|
|
87
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
84
88
|
// stdin=inherit avoids Node.js spawn hang bug, stdout=pipe to capture JSON, stderr=pipe for rate limit detection
|
|
85
89
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
86
90
|
});
|
|
@@ -520,6 +524,14 @@ export async function runLoop(options = {}) {
|
|
|
520
524
|
};
|
|
521
525
|
|
|
522
526
|
// --- Shared spawn options ---
|
|
527
|
+
// appendSystemPrompt now includes:
|
|
528
|
+
// - autonomy=law directive (from goal)
|
|
529
|
+
// - vibe-decisions loop-mode fragment (so the skill auto-logs without pause)
|
|
530
|
+
// - memory briefing (INDEX.md contents) if present
|
|
531
|
+
// The prompt is stable for the session; the prefix stays cache-friendly.
|
|
532
|
+
const appendedPrompt = await buildSystemPromptWithMemory(options.goal, {
|
|
533
|
+
autonomyBuilder: buildAutonomySystemPrompt,
|
|
534
|
+
});
|
|
523
535
|
const spawnOpts = {
|
|
524
536
|
cwd,
|
|
525
537
|
trust: options.trust || false,
|
|
@@ -527,7 +539,12 @@ export async function runLoop(options = {}) {
|
|
|
527
539
|
maxTurns: options.maxTurns || 30,
|
|
528
540
|
model: options.model,
|
|
529
541
|
budget: options.budget,
|
|
530
|
-
appendSystemPrompt:
|
|
542
|
+
appendSystemPrompt: appendedPrompt,
|
|
543
|
+
// Secondary marker for Python helpers (e.g. claudenv-memory 0.2.x) that
|
|
544
|
+
// want to detect they're being driven by `claudenv loop`. The PRIMARY
|
|
545
|
+
// mode signal stays the directive fragment in appendSystemPrompt above —
|
|
546
|
+
// env alone wouldn't be readable by the markdown skill.
|
|
547
|
+
env: { CLAUDENV_LOOP: '1' },
|
|
531
548
|
};
|
|
532
549
|
|
|
533
550
|
let sessionId = options.initialSessionId || null;
|
|
@@ -585,6 +602,10 @@ export async function runLoop(options = {}) {
|
|
|
585
602
|
tokens: planResult.usage ? { in: planResult.usage.input_tokens, out: planResult.usage.output_tokens } : null,
|
|
586
603
|
});
|
|
587
604
|
|
|
605
|
+
// Fallback for SessionEnd hook — regen INDEX.md so the next iteration's
|
|
606
|
+
// briefing reflects anything written during planning.
|
|
607
|
+
try { await regenIndex({ cwd }); } catch { /* best-effort */ }
|
|
608
|
+
|
|
588
609
|
if (shuttingDown) {
|
|
589
610
|
log.stopReason = 'interrupted';
|
|
590
611
|
log.completedAt = new Date().toISOString();
|
|
@@ -773,6 +794,10 @@ export async function runLoop(options = {}) {
|
|
|
773
794
|
tokens: iterResult.usage ? { in: iterResult.usage.input_tokens, out: iterResult.usage.output_tokens } : null,
|
|
774
795
|
});
|
|
775
796
|
|
|
797
|
+
// Fallback for SessionEnd hook — regen INDEX.md from accumulated decisions
|
|
798
|
+
// so the next iteration's briefing is current.
|
|
799
|
+
try { await regenIndex({ cwd }); } catch { /* best-effort */ }
|
|
800
|
+
|
|
776
801
|
// --- Convergence check ---
|
|
777
802
|
if (detectConvergence(iterResult.result)) {
|
|
778
803
|
console.log('\n All improvements completed.');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the `--append-system-prompt` payload for `claudenv loop`.
|
|
3
|
+
*
|
|
4
|
+
* Three composable fragments, joined with blank lines:
|
|
5
|
+
*
|
|
6
|
+
* 1. Autonomy=law directive from the goal (existing 1.2.x behavior)
|
|
7
|
+
* 2. Vibe-decisions loop-mode marker so the skill skips pause-and-ask
|
|
8
|
+
* 3. Memory briefing — current INDEX.md contents, capped for cache stability
|
|
9
|
+
*
|
|
10
|
+
* Cache discipline: the function is pure on its inputs (goal + file state at
|
|
11
|
+
* call time). Loop.js calls it once per iteration but with stable inputs
|
|
12
|
+
* within a session, so the appended prompt remains stable.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
import { indexMdPath } from './memory-paths.js';
|
|
17
|
+
|
|
18
|
+
const VIBE_LOOP_FRAGMENT = `## Vibe-decisions mode (loop)
|
|
19
|
+
|
|
20
|
+
You are running inside \`claudenv loop\` in autonomous mode. When the
|
|
21
|
+
vibe-decisions skill triggers: pick the approach, write the decision log
|
|
22
|
+
immediately (Write tool to /memories/decisions/<date>-<slug>.md with the
|
|
23
|
+
__VIBE_DECISION__ marker), continue with code. Do NOT pause for user
|
|
24
|
+
confirmation. Do NOT ask "делать так?". The goal is law.`;
|
|
25
|
+
|
|
26
|
+
const MAX_BRIEFING_CHARS = 4000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the system prompt suffix.
|
|
30
|
+
*
|
|
31
|
+
* @param {string|null} goal - The loop's --goal value (may be null)
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {(goal: string) => string} [opts.autonomyBuilder] - Caller's autonomy prompt builder
|
|
34
|
+
* @param {boolean} [opts.includeMemoryBriefing] - Default true; false skips INDEX.md read (for tests)
|
|
35
|
+
* @returns {Promise<string|undefined>} The appended prompt, or undefined if nothing to add
|
|
36
|
+
*/
|
|
37
|
+
export async function buildSystemPromptWithMemory(goal, opts = {}) {
|
|
38
|
+
const parts = [];
|
|
39
|
+
|
|
40
|
+
if (goal && opts.autonomyBuilder) {
|
|
41
|
+
parts.push(opts.autonomyBuilder(goal));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Vibe-decisions loop mode is always on inside `claudenv loop`.
|
|
45
|
+
parts.push(VIBE_LOOP_FRAGMENT);
|
|
46
|
+
|
|
47
|
+
// Memory briefing — INDEX.md if present.
|
|
48
|
+
if (opts.includeMemoryBriefing !== false) {
|
|
49
|
+
try {
|
|
50
|
+
const briefing = await readFile(indexMdPath(), 'utf-8');
|
|
51
|
+
const capped =
|
|
52
|
+
briefing.length > MAX_BRIEFING_CHARS
|
|
53
|
+
? briefing.slice(0, MAX_BRIEFING_CHARS) + '\n…(truncated)'
|
|
54
|
+
: briefing;
|
|
55
|
+
parts.push('## Memory briefing\n\n' + capped);
|
|
56
|
+
} catch {
|
|
57
|
+
/* INDEX.md absent — first session, fine */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (parts.length === 0) return undefined;
|
|
62
|
+
return parts.join('\n\n');
|
|
63
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared resolvers for the claudenv memory layout + a minimal YAML frontmatter
|
|
3
|
+
* parser used by hooks, CLI commands, and the loop integration.
|
|
4
|
+
*
|
|
5
|
+
* All path getters read process.env.CLAUDENV_HOME so tests can isolate without
|
|
6
|
+
* touching the real ~/.claudenv/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
export function claudenvHome() {
|
|
14
|
+
return process.env.CLAUDENV_HOME || join(homedir(), '.claudenv');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function memoriesDir() {
|
|
18
|
+
return join(claudenvHome(), 'memories');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function globalDecisionsDir() {
|
|
22
|
+
return join(memoriesDir(), 'decisions');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function canonIndexPath() {
|
|
26
|
+
return join(memoriesDir(), 'canon', 'index.yaml');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function userPrefsPath() {
|
|
30
|
+
return join(memoriesDir(), 'user', 'preferences.md');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function indexMdPath() {
|
|
34
|
+
return join(memoriesDir(), 'INDEX.md');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function dirtyFlagPath() {
|
|
38
|
+
return join(claudenvHome(), '.index-dirty');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function projectDecisionsDir(cwd) {
|
|
42
|
+
return join(cwd, '.claude', 'memories', 'decisions');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- Workspace layer (isolated per-company/context memory) ---
|
|
46
|
+
|
|
47
|
+
export function workspacesDir() {
|
|
48
|
+
return join(claudenvHome(), 'workspaces');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function activeWorkspaceFile() {
|
|
52
|
+
return join(claudenvHome(), 'active-workspace');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function workspaceDir(id) {
|
|
56
|
+
return join(workspacesDir(), id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function workspaceManifestPath(id) {
|
|
60
|
+
return join(workspaceDir(id), 'workspace.yaml');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function workspaceConnectorsDir(id) {
|
|
64
|
+
return join(workspaceDir(id), 'memories', 'connectors');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function workspaceContextDir(id) {
|
|
68
|
+
return join(workspaceDir(id), 'memories', 'context');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the active workspace id. Priority: env CLAUDENV_WORKSPACE, then the
|
|
73
|
+
* pointer file ~/.claudenv/active-workspace. Returns null if none set.
|
|
74
|
+
* Intentionally does NOT scan all workspaces - isolation barrier.
|
|
75
|
+
*/
|
|
76
|
+
export function activeWorkspaceId() {
|
|
77
|
+
if (process.env.CLAUDENV_WORKSPACE) return process.env.CLAUDENV_WORKSPACE.trim();
|
|
78
|
+
try {
|
|
79
|
+
const id = readFileSync(activeWorkspaceFile(), 'utf-8').trim();
|
|
80
|
+
return id || null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Minimal YAML frontmatter parser. Returns null when no frontmatter is present.
|
|
88
|
+
* Handles `key: value` and inline arrays `[a, b, c]`. Heavier structures fall
|
|
89
|
+
* through as raw strings — frontmatter here is intentionally flat.
|
|
90
|
+
*/
|
|
91
|
+
export function parseFrontmatter(text) {
|
|
92
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
93
|
+
if (!match) return null;
|
|
94
|
+
const result = {};
|
|
95
|
+
for (const line of match[1].split('\n')) {
|
|
96
|
+
const m = /^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
|
|
97
|
+
if (!m) continue;
|
|
98
|
+
let value = m[2].trim();
|
|
99
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
100
|
+
value = value
|
|
101
|
+
.slice(1, -1)
|
|
102
|
+
.split(',')
|
|
103
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
} else {
|
|
106
|
+
value = value.replace(/^['"]|['"]$/g, '');
|
|
107
|
+
}
|
|
108
|
+
result[m[1]] = value;
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Render a flat object back into a YAML frontmatter block.
|
|
115
|
+
* Preserves array fields as inline `[a, b]`.
|
|
116
|
+
*/
|
|
117
|
+
export function renderFrontmatter(obj) {
|
|
118
|
+
const lines = ['---'];
|
|
119
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
lines.push(`${key}: [${value.join(', ')}]`);
|
|
122
|
+
} else {
|
|
123
|
+
lines.push(`${key}: ${value}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
lines.push('---');
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|