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.
Files changed (34) hide show
  1. package/README.md +93 -3
  2. package/bin/cli.js +294 -0
  3. package/package.json +1 -1
  4. package/scaffold/.claude/memories/README.md +44 -0
  5. package/scaffold/global/.claude/commands/add-source.md +31 -0
  6. package/scaffold/global/.claude/commands/canon.md +33 -0
  7. package/scaffold/global/.claude/commands/decisions.md +29 -0
  8. package/scaffold/global/.claude/commands/deeper.md +55 -0
  9. package/scaffold/global/.claude/commands/just-code.md +26 -0
  10. package/scaffold/global/.claude/commands/why.md +33 -0
  11. package/scaffold/global/.claude/skills/source-connector/SKILL.md +128 -0
  12. package/scaffold/global/.claude/skills/vibe-decisions/SKILL.md +127 -0
  13. package/scaffold/global/.claude/skills/vibe-decisions/deep-dive-template.md +41 -0
  14. package/scaffold/global-claudenv/config.yaml +16 -0
  15. package/scaffold/global-claudenv/memories/INDEX.md +25 -0
  16. package/scaffold/global-claudenv/memories/canon/index.yaml +21 -0
  17. package/scaffold/global-claudenv/memories/user/preferences.md.example +16 -0
  18. package/src/autonomy.js +6 -1
  19. package/src/canon.js +214 -0
  20. package/src/decisions.js +161 -0
  21. package/src/doctor.js +201 -0
  22. package/src/hooks/decisions-logger.js +183 -0
  23. package/src/hooks/dispatcher.js +91 -0
  24. package/src/hooks/regen-index.js +198 -0
  25. package/src/hooks-gen.js +43 -1
  26. package/src/installer.js +57 -14
  27. package/src/loop.js +26 -1
  28. package/src/memory-context.js +63 -0
  29. package/src/memory-paths.js +128 -0
  30. package/src/memory.js +111 -0
  31. package/src/sources.js +78 -0
  32. package/src/workspaces.js +129 -0
  33. package/templates/connector-mssql.ejs +62 -0
  34. 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
- * Install global Claude Code artifacts to ~/.claude/.
35
- * Copies scaffold/global/.claude/ contents into ~/.claude/.
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
- export async function installGlobal(options = {}) {
43
- const { force = false, claudeHome } = options;
44
- const targetBase = claudeHome || getClaudeHome();
45
- const sourceBase = join(SCAFFOLD_GLOBAL, '.claude');
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: options.goal ? buildAutonomySystemPrompt(options.goal) : undefined,
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
+ }