claudenv 1.2.4 → 1.3.0
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 +243 -178
- package/bin/cli.js +265 -4
- package/package.json +1 -1
- package/scaffold/.claude/memories/README.md +44 -0
- package/scaffold/global/.claude/commands/autonomy.md +90 -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/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 +7 -1
- package/src/canon.js +214 -0
- package/src/decisions.js +161 -0
- package/src/doctor.js +175 -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 +60 -5
- package/src/installer.js +56 -14
- package/src/loop.js +148 -42
- package/src/memory-context.js +63 -0
- package/src/memory-paths.js +86 -0
- package/src/memory.js +111 -0
- package/src/profiles.js +4 -0
- package/src/report.js +160 -0
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
|
|
|
@@ -101,11 +143,21 @@ export function generatePreToolUseHook(profile, detected = {}) {
|
|
|
101
143
|
return `#!/usr/bin/env bash
|
|
102
144
|
# Pre-tool-use hook — generated by claudenv autonomy (profile: ${profile.name})
|
|
103
145
|
# Exit 0 = allow, Exit 2 = block
|
|
146
|
+
# Claude Code sends hook data via stdin as JSON: {"tool_name":"...","tool_input":{...}}
|
|
104
147
|
|
|
105
148
|
set -euo pipefail
|
|
106
149
|
|
|
107
|
-
|
|
108
|
-
|
|
150
|
+
# Read hook input from stdin (Claude Code sends JSON)
|
|
151
|
+
HOOK_INPUT=$(cat)
|
|
152
|
+
TOOL_NAME=$(echo "$HOOK_INPUT" | sed -n 's/.*"tool_name":"\\([^"]*\\)".*/\\1/p')
|
|
153
|
+
|
|
154
|
+
# Extract actionable string based on tool type
|
|
155
|
+
# Uses [^"]* to stop at the first unescaped quote — safely ignores other JSON fields
|
|
156
|
+
if [ "$TOOL_NAME" = "Bash" ]; then
|
|
157
|
+
TOOL_INPUT=$(echo "$HOOK_INPUT" | sed -n 's/.*"command":"\\([^"]*\\)".*/\\1/p')
|
|
158
|
+
else
|
|
159
|
+
TOOL_INPUT=$(echo "$HOOK_INPUT" | sed -n 's/.*"file_path":"\\([^"]*\\)".*/\\1/p')
|
|
160
|
+
fi
|
|
109
161
|
|
|
110
162
|
# === Hard blocks (all profiles) ===
|
|
111
163
|
|
|
@@ -157,11 +209,14 @@ export function generateAuditLogHook() {
|
|
|
157
209
|
return `#!/usr/bin/env bash
|
|
158
210
|
# Post-tool-use audit hook — generated by claudenv autonomy
|
|
159
211
|
# Logs every tool invocation to .claude/audit-log.jsonl
|
|
212
|
+
# Claude Code sends hook data via stdin as JSON: {"tool_name":"...","tool_input":{...}}
|
|
160
213
|
|
|
161
214
|
set -uo pipefail
|
|
162
215
|
|
|
163
|
-
|
|
164
|
-
|
|
216
|
+
# Read hook input from stdin (Claude Code sends JSON)
|
|
217
|
+
HOOK_INPUT=$(cat)
|
|
218
|
+
TOOL_NAME=$(echo "$HOOK_INPUT" | sed -n 's/.*"tool_name":"\\([^"]*\\)".*/\\1/p')
|
|
219
|
+
TOOL_INPUT=$(echo "$HOOK_INPUT" | sed -n 's/.*"tool_input":\\(.*\\)/\\1/p' | sed 's/}$//')
|
|
165
220
|
SESSION_ID="\${CLAUDE_SESSION_ID:-}"
|
|
166
221
|
|
|
167
222
|
# Truncate input for logging (max 500 chars)
|
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
|
*
|
|
@@ -92,9 +127,16 @@ export async function uninstallGlobal(options = {}) {
|
|
|
92
127
|
|
|
93
128
|
const targets = [
|
|
94
129
|
join(targetBase, 'commands', 'claudenv.md'),
|
|
130
|
+
join(targetBase, 'commands', 'autonomy.md'),
|
|
95
131
|
join(targetBase, 'commands', 'setup-mcp.md'),
|
|
96
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'),
|
|
97
138
|
join(targetBase, 'skills', 'claudenv'),
|
|
139
|
+
join(targetBase, 'skills', 'vibe-decisions'),
|
|
98
140
|
];
|
|
99
141
|
|
|
100
142
|
for (const target of targets) {
|
package/src/loop.js
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import { execSync, spawn } from 'node:child_process';
|
|
2
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
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';
|
|
8
|
+
|
|
9
|
+
// =============================================
|
|
10
|
+
// Work report helpers
|
|
11
|
+
// =============================================
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Append a report event to .claude/work-report.jsonl
|
|
15
|
+
*/
|
|
16
|
+
async function writeReportEvent(cwd, event) {
|
|
17
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
|
|
18
|
+
await mkdir(join(cwd, '.claude'), { recursive: true });
|
|
19
|
+
await appendFile(join(cwd, '.claude', 'work-report.jsonl'), line);
|
|
20
|
+
}
|
|
6
21
|
|
|
7
22
|
// =============================================
|
|
8
23
|
// Pre-flight: check Claude CLI
|
|
@@ -38,6 +53,7 @@ export function checkClaudeCli() {
|
|
|
38
53
|
* @param {string} [options.model] - Model to use
|
|
39
54
|
* @param {number} [options.budget] - Budget cap in USD
|
|
40
55
|
* @param {string} [options.appendSystemPrompt] - Append to system prompt
|
|
56
|
+
* @param {object} [options.env] - Extra env vars to merge into the child process env
|
|
41
57
|
* @returns {Promise<{ result: string, sessionId: string|null, usage: object|null }>}
|
|
42
58
|
*/
|
|
43
59
|
export function spawnClaude(prompt, options = {}) {
|
|
@@ -68,16 +84,23 @@ export function spawnClaude(prompt, options = {}) {
|
|
|
68
84
|
|
|
69
85
|
const child = spawn('claude', args, {
|
|
70
86
|
cwd: options.cwd || process.cwd(),
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
88
|
+
// stdin=inherit avoids Node.js spawn hang bug, stdout=pipe to capture JSON, stderr=pipe for rate limit detection
|
|
89
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
73
90
|
});
|
|
74
91
|
|
|
75
92
|
let stdout = '';
|
|
93
|
+
let stderrBuf = '';
|
|
76
94
|
|
|
77
95
|
child.stdout.on('data', (chunk) => {
|
|
78
96
|
stdout += chunk.toString();
|
|
79
97
|
});
|
|
80
98
|
|
|
99
|
+
child.stderr.on('data', (chunk) => {
|
|
100
|
+
process.stderr.write(chunk); // keep real-time output
|
|
101
|
+
stderrBuf += chunk.toString();
|
|
102
|
+
});
|
|
103
|
+
|
|
81
104
|
child.on('error', (err) => {
|
|
82
105
|
if (err.code === 'ENOENT') {
|
|
83
106
|
reject(new Error('Claude CLI not found. Install it from https://docs.anthropic.com/en/docs/claude-code'));
|
|
@@ -88,7 +111,10 @@ export function spawnClaude(prompt, options = {}) {
|
|
|
88
111
|
|
|
89
112
|
child.on('close', (code) => {
|
|
90
113
|
if (code !== 0) {
|
|
91
|
-
|
|
114
|
+
const isRateLimit = /rate.?limit|429|overloaded|too many requests/i.test(stderrBuf);
|
|
115
|
+
const err = new Error(`Claude exited with code ${code}`);
|
|
116
|
+
err.isRateLimit = isRateLimit;
|
|
117
|
+
reject(err);
|
|
92
118
|
return;
|
|
93
119
|
}
|
|
94
120
|
|
|
@@ -450,6 +476,8 @@ function printFinalSummary(log) {
|
|
|
450
476
|
* @param {string} [options.cwd] - Working directory
|
|
451
477
|
* @param {boolean} [options.allowDirty] - Allow dirty git state
|
|
452
478
|
* @param {boolean} [options.worktree] - Run each iteration in an isolated git worktree
|
|
479
|
+
* @param {number} [options.startIteration] - Resume from this iteration (skip planning)
|
|
480
|
+
* @param {string} [options.initialSessionId] - Session ID to resume from
|
|
453
481
|
*/
|
|
454
482
|
export async function runLoop(options = {}) {
|
|
455
483
|
const cwd = options.cwd || process.cwd();
|
|
@@ -480,21 +508,30 @@ export async function runLoop(options = {}) {
|
|
|
480
508
|
console.log(`\n Git safety tag: ${gitTag}`);
|
|
481
509
|
console.log(` Pre-loop commit: ${preLoopCommit}`);
|
|
482
510
|
|
|
483
|
-
// --- Initialize loop log ---
|
|
511
|
+
// --- Initialize loop log (carry over previous data on resume) ---
|
|
512
|
+
const prevLogData = startIteration > 0 ? await readLoopLog(cwd) : null;
|
|
484
513
|
const log = {
|
|
485
|
-
started: new Date().toISOString(),
|
|
514
|
+
started: prevLogData?.started || new Date().toISOString(),
|
|
486
515
|
goal: options.goal || 'General improvement',
|
|
487
|
-
model: options.model ||
|
|
488
|
-
gitTag,
|
|
489
|
-
preLoopCommit,
|
|
490
|
-
iterations: [],
|
|
516
|
+
model: options.model || null,
|
|
517
|
+
gitTag: prevLogData?.gitTag || gitTag,
|
|
518
|
+
preLoopCommit: prevLogData?.preLoopCommit || preLoopCommit,
|
|
519
|
+
iterations: prevLogData?.iterations || [],
|
|
491
520
|
completedAt: null,
|
|
492
521
|
stopReason: null,
|
|
493
|
-
totalIterations: 0,
|
|
494
|
-
hypotheses: [],
|
|
522
|
+
totalIterations: prevLogData?.totalIterations || 0,
|
|
523
|
+
hypotheses: prevLogData?.hypotheses || [],
|
|
495
524
|
};
|
|
496
525
|
|
|
497
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
|
+
});
|
|
498
535
|
const spawnOpts = {
|
|
499
536
|
cwd,
|
|
500
537
|
trust: options.trust || false,
|
|
@@ -502,11 +539,17 @@ export async function runLoop(options = {}) {
|
|
|
502
539
|
maxTurns: options.maxTurns || 30,
|
|
503
540
|
model: options.model,
|
|
504
541
|
budget: options.budget,
|
|
505
|
-
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' },
|
|
506
548
|
};
|
|
507
549
|
|
|
508
|
-
let sessionId = null;
|
|
550
|
+
let sessionId = options.initialSessionId || null;
|
|
509
551
|
let shuttingDown = false;
|
|
552
|
+
const startIteration = options.startIteration || 0;
|
|
510
553
|
|
|
511
554
|
// --- Ctrl+C handling ---
|
|
512
555
|
const sigintHandler = () => {
|
|
@@ -521,37 +564,63 @@ export async function runLoop(options = {}) {
|
|
|
521
564
|
process.on('SIGINT', sigintHandler);
|
|
522
565
|
|
|
523
566
|
try {
|
|
524
|
-
// ---
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
number: 0,
|
|
533
|
-
startedAt: log.started,
|
|
534
|
-
completedAt: new Date().toISOString(),
|
|
535
|
-
sessionId,
|
|
536
|
-
summary: 'Generated improvement plan',
|
|
537
|
-
commitHash: getCurrentCommit(cwd),
|
|
538
|
-
usage: planResult.usage,
|
|
539
|
-
};
|
|
540
|
-
log.iterations.push(planIteration);
|
|
541
|
-
printIterationSummary(planIteration);
|
|
542
|
-
await writeLoopLog(cwd, log);
|
|
567
|
+
// --- Report: loop start ---
|
|
568
|
+
await writeReportEvent(cwd, {
|
|
569
|
+
event: 'loop_start',
|
|
570
|
+
goal: options.goal || 'General improvement',
|
|
571
|
+
model: options.model || null,
|
|
572
|
+
gitTag,
|
|
573
|
+
resume: startIteration > 0,
|
|
574
|
+
});
|
|
543
575
|
|
|
544
|
-
if
|
|
545
|
-
|
|
546
|
-
log
|
|
547
|
-
|
|
576
|
+
// --- Iteration 0: Planning (skip if resuming) ---
|
|
577
|
+
if (startIteration === 0) {
|
|
578
|
+
console.log('\n Starting iteration 0 (planning)...\n');
|
|
579
|
+
await writeReportEvent(cwd, { event: 'iteration_start', iteration: 0, type: 'planning' });
|
|
580
|
+
|
|
581
|
+
const planPrompt = buildPlanningPrompt(options.goal);
|
|
582
|
+
const planResult = await spawnClaude(planPrompt, { ...spawnOpts, sessionId });
|
|
583
|
+
sessionId = planResult.sessionId || sessionId;
|
|
584
|
+
|
|
585
|
+
const planIteration = {
|
|
586
|
+
number: 0,
|
|
587
|
+
startedAt: log.started,
|
|
588
|
+
completedAt: new Date().toISOString(),
|
|
589
|
+
sessionId,
|
|
590
|
+
summary: 'Generated improvement plan',
|
|
591
|
+
commitHash: getCurrentCommit(cwd),
|
|
592
|
+
usage: planResult.usage,
|
|
593
|
+
};
|
|
594
|
+
log.iterations.push(planIteration);
|
|
595
|
+
printIterationSummary(planIteration);
|
|
548
596
|
await writeLoopLog(cwd, log);
|
|
549
|
-
|
|
550
|
-
|
|
597
|
+
await writeReportEvent(cwd, {
|
|
598
|
+
event: 'iteration_end',
|
|
599
|
+
iteration: 0,
|
|
600
|
+
summary: 'Generated improvement plan',
|
|
601
|
+
commitHash: planIteration.commitHash,
|
|
602
|
+
tokens: planResult.usage ? { in: planResult.usage.input_tokens, out: planResult.usage.output_tokens } : null,
|
|
603
|
+
});
|
|
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
|
+
|
|
609
|
+
if (shuttingDown) {
|
|
610
|
+
log.stopReason = 'interrupted';
|
|
611
|
+
log.completedAt = new Date().toISOString();
|
|
612
|
+
log.totalIterations = 0;
|
|
613
|
+
await writeLoopLog(cwd, log);
|
|
614
|
+
printFinalSummary(log);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
} else {
|
|
618
|
+
console.log(`\n Resuming from iteration ${startIteration}...\n`);
|
|
551
619
|
}
|
|
552
620
|
|
|
553
|
-
// --- Execution iterations 1-N ---
|
|
554
|
-
|
|
621
|
+
// --- Execution iterations 1-N (or startIteration-N if resuming) ---
|
|
622
|
+
const firstIter = startIteration > 0 ? startIteration : 1;
|
|
623
|
+
for (let i = firstIter; i <= maxIterations; i++) {
|
|
555
624
|
if (shuttingDown) break;
|
|
556
625
|
|
|
557
626
|
// --- Pause between iterations ---
|
|
@@ -607,11 +676,12 @@ export async function runLoop(options = {}) {
|
|
|
607
676
|
}
|
|
608
677
|
}
|
|
609
678
|
|
|
679
|
+
await writeReportEvent(cwd, { event: 'iteration_start', iteration: i, type: 'execution' });
|
|
680
|
+
|
|
610
681
|
let iterResult;
|
|
611
682
|
try {
|
|
612
683
|
iterResult = await spawnClaude(execPrompt, { ...spawnOpts, cwd: iterCwd, sessionId });
|
|
613
684
|
} catch (err) {
|
|
614
|
-
console.error(`\n Iteration ${i} failed: ${err.message}`);
|
|
615
685
|
// In worktree mode, clean up the worktree on failure
|
|
616
686
|
if (worktreeInfo) {
|
|
617
687
|
try {
|
|
@@ -624,6 +694,26 @@ export async function runLoop(options = {}) {
|
|
|
624
694
|
iteration: i,
|
|
625
695
|
});
|
|
626
696
|
}
|
|
697
|
+
|
|
698
|
+
// Rate limit detection — save state for resume
|
|
699
|
+
if (err.isRateLimit) {
|
|
700
|
+
log.stopReason = 'rate_limit';
|
|
701
|
+
log.pausedAt = { iteration: i, sessionId };
|
|
702
|
+
log.options = {
|
|
703
|
+
goal: options.goal,
|
|
704
|
+
model: options.model,
|
|
705
|
+
trust: options.trust,
|
|
706
|
+
maxTurns: options.maxTurns,
|
|
707
|
+
budget: options.budget,
|
|
708
|
+
worktree: options.worktree,
|
|
709
|
+
};
|
|
710
|
+
await writeLoopLog(cwd, log);
|
|
711
|
+
await writeReportEvent(cwd, { event: 'rate_limit', iteration: i, message: err.message });
|
|
712
|
+
console.log(`\n Rate limited at iteration ${i}. Resume with: claudenv loop --resume`);
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
console.error(`\n Iteration ${i} failed: ${err.message}`);
|
|
627
717
|
log.stopReason = 'error';
|
|
628
718
|
break;
|
|
629
719
|
}
|
|
@@ -696,6 +786,17 @@ export async function runLoop(options = {}) {
|
|
|
696
786
|
log.totalIterations = i;
|
|
697
787
|
printIterationSummary(iteration);
|
|
698
788
|
await writeLoopLog(cwd, log);
|
|
789
|
+
await writeReportEvent(cwd, {
|
|
790
|
+
event: 'iteration_end',
|
|
791
|
+
iteration: i,
|
|
792
|
+
summary: iteration.summary,
|
|
793
|
+
commitHash: iteration.commitHash,
|
|
794
|
+
tokens: iterResult.usage ? { in: iterResult.usage.input_tokens, out: iterResult.usage.output_tokens } : null,
|
|
795
|
+
});
|
|
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 */ }
|
|
699
800
|
|
|
700
801
|
// --- Convergence check ---
|
|
701
802
|
if (detectConvergence(iterResult.result)) {
|
|
@@ -740,6 +841,11 @@ export async function runLoop(options = {}) {
|
|
|
740
841
|
|
|
741
842
|
log.completedAt = new Date().toISOString();
|
|
742
843
|
await writeLoopLog(cwd, log);
|
|
844
|
+
await writeReportEvent(cwd, {
|
|
845
|
+
event: 'loop_end',
|
|
846
|
+
reason: log.stopReason,
|
|
847
|
+
totalIterations: log.totalIterations,
|
|
848
|
+
});
|
|
743
849
|
printFinalSummary(log);
|
|
744
850
|
}
|
|
745
851
|
|
|
@@ -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,86 @@
|
|
|
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
|
+
|
|
12
|
+
export function claudenvHome() {
|
|
13
|
+
return process.env.CLAUDENV_HOME || join(homedir(), '.claudenv');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function memoriesDir() {
|
|
17
|
+
return join(claudenvHome(), 'memories');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function globalDecisionsDir() {
|
|
21
|
+
return join(memoriesDir(), 'decisions');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function canonIndexPath() {
|
|
25
|
+
return join(memoriesDir(), 'canon', 'index.yaml');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function userPrefsPath() {
|
|
29
|
+
return join(memoriesDir(), 'user', 'preferences.md');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function indexMdPath() {
|
|
33
|
+
return join(memoriesDir(), 'INDEX.md');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function dirtyFlagPath() {
|
|
37
|
+
return join(claudenvHome(), '.index-dirty');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function projectDecisionsDir(cwd) {
|
|
41
|
+
return join(cwd, '.claude', 'memories', 'decisions');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Minimal YAML frontmatter parser. Returns null when no frontmatter is present.
|
|
46
|
+
* Handles `key: value` and inline arrays `[a, b, c]`. Heavier structures fall
|
|
47
|
+
* through as raw strings — frontmatter here is intentionally flat.
|
|
48
|
+
*/
|
|
49
|
+
export function parseFrontmatter(text) {
|
|
50
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
51
|
+
if (!match) return null;
|
|
52
|
+
const result = {};
|
|
53
|
+
for (const line of match[1].split('\n')) {
|
|
54
|
+
const m = /^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
|
|
55
|
+
if (!m) continue;
|
|
56
|
+
let value = m[2].trim();
|
|
57
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
58
|
+
value = value
|
|
59
|
+
.slice(1, -1)
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
} else {
|
|
64
|
+
value = value.replace(/^['"]|['"]$/g, '');
|
|
65
|
+
}
|
|
66
|
+
result[m[1]] = value;
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render a flat object back into a YAML frontmatter block.
|
|
73
|
+
* Preserves array fields as inline `[a, b]`.
|
|
74
|
+
*/
|
|
75
|
+
export function renderFrontmatter(obj) {
|
|
76
|
+
const lines = ['---'];
|
|
77
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
lines.push(`${key}: [${value.join(', ')}]`);
|
|
80
|
+
} else {
|
|
81
|
+
lines.push(`${key}: ${value}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
lines.push('---');
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|