@pageai/ralph-loop 1.20.0 → 1.21.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/.agent/PROMPT.md +12 -1
- package/.claude/hooks/one-task-guard.js +92 -0
- package/.claude/hooks/{pre-tool-use.js → post-tool-use.js} +1 -1
- package/.claude/settings.json +6 -7
- package/AGENTS.md +4 -0
- package/bin/cli.js +17 -1
- package/bin/lib/settings.js +98 -0
- package/package.json +1 -1
- package/ralph.sh +6 -6
- package/scripts/lib/cleanup.sh +27 -1
- package/scripts/lib/output.sh +38 -12
- package/scripts/lib/spinner.sh +2 -2
- package/scripts/lib/timing.sh +10 -0
- /package/.claude/{commands → hooks}/aw.md +0 -0
package/.agent/PROMPT.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
> ⛔ **ONE TASK PER INVOCATION** — Complete one task from @.agent/tasks.json, commit, output `<promise>TASK-{ID}:DONE</promise>`, and STOP. Do NOT start the next task. Do NOT use parallel agents for multiple tasks.
|
|
2
|
+
|
|
1
3
|
## Overview
|
|
2
4
|
|
|
3
5
|
You are implementing the project described in @.agent/prd/SUMMARY.md
|
|
@@ -28,7 +30,7 @@ Tasks are listed in @.agent/tasks.json
|
|
|
28
30
|
7. Run `tsc` and unit tests project-wide
|
|
29
31
|
8. All tests must pass. Broke unrelated test? Fix it before proceeding.
|
|
30
32
|
9. When tests pass, set `passes: true` in `tasks.json` for the task you completed.
|
|
31
|
-
10. Log entry → `.agent/logs/LOG.md` (date, brief summary, screenshot path)
|
|
33
|
+
10. Log entry → `.agent/logs/LOG.md` (date, brief summary, screenshot path, newest at the top)
|
|
32
34
|
11. Update `.agent/STRUCTURE.md` if dirs changed. Exclude dotfiles, tests and config.
|
|
33
35
|
12. Commit changes, using the Conventional Commit format.
|
|
34
36
|
|
|
@@ -51,6 +53,15 @@ When stuck after all possible solutions exhausted, output one of the following t
|
|
|
51
53
|
<promise>BLOCKED:brief description</promise>
|
|
52
54
|
```
|
|
53
55
|
|
|
56
|
+
**Exit immediately (no workarounds) for environment constraints you cannot fix from inside the sandbox:**
|
|
57
|
+
|
|
58
|
+
- `Blocked by network policy` → firewall, only user can change from host
|
|
59
|
+
- Missing/invalid credentials or API keys
|
|
60
|
+
- Required system service unavailable
|
|
61
|
+
- Hardware/arch incompatibility with no known fix
|
|
62
|
+
|
|
63
|
+
These are not bugs. No amount of retries, alternative downloads, or package managers will help. Output BLOCKED on first failure.
|
|
64
|
+
|
|
54
65
|
2. **DECIDE** — need human input: lib choices, architecture, unclear requirements, breaking changes. Output:
|
|
55
66
|
|
|
56
67
|
```
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PostToolUse hook that enforces the one-task-per-invocation rule.
|
|
5
|
+
*
|
|
6
|
+
* Reads .agent/tasks.json, counts tasks with passes: true,
|
|
7
|
+
* and warns if more than one task was completed since the session started.
|
|
8
|
+
* The baseline is stored in .agent/.task-count (ephemeral per session).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
// Find project root by looking for .agent directory
|
|
15
|
+
function findProjectRoot() {
|
|
16
|
+
let dir = __dirname;
|
|
17
|
+
// Walk up from .claude/hooks
|
|
18
|
+
while (dir !== path.dirname(dir)) {
|
|
19
|
+
if (fs.existsSync(path.join(dir, '.agent', 'tasks.json'))) {
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
dir = path.dirname(dir);
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Read tool input from stdin to check if this was a git commit
|
|
28
|
+
function readStdin() {
|
|
29
|
+
try {
|
|
30
|
+
return fs.readFileSync('/dev/stdin', 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const input = readStdin();
|
|
38
|
+
|
|
39
|
+
// Only check after git commit commands
|
|
40
|
+
let parsed = {};
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(input);
|
|
43
|
+
} catch {
|
|
44
|
+
// Not JSON input, skip
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const toolInput = parsed.tool_input || {};
|
|
49
|
+
const command = toolInput.command || '';
|
|
50
|
+
|
|
51
|
+
// Only trigger on git commit commands
|
|
52
|
+
if (!command.includes('git commit')) {
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const root = findProjectRoot();
|
|
57
|
+
if (!root) {
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const agentDir = path.join(root, '.agent');
|
|
62
|
+
const tasksFile = path.join(agentDir, 'tasks.json');
|
|
63
|
+
const countFile = path.join(agentDir, '.task-count');
|
|
64
|
+
|
|
65
|
+
if (!fs.existsSync(tasksFile)) {
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8'));
|
|
70
|
+
const passCount = tasks.filter((t) => t.passes === true).length;
|
|
71
|
+
|
|
72
|
+
if (!fs.existsSync(countFile)) {
|
|
73
|
+
// First commit this session — store baseline
|
|
74
|
+
fs.writeFileSync(countFile, String(passCount));
|
|
75
|
+
} else {
|
|
76
|
+
const baseline = parseInt(fs.readFileSync(countFile, 'utf8'), 10);
|
|
77
|
+
const completed = passCount - baseline;
|
|
78
|
+
|
|
79
|
+
if (completed > 1) {
|
|
80
|
+
console.log(
|
|
81
|
+
`⚠️ VIOLATION: ${completed} tasks completed in this invocation. ` +
|
|
82
|
+
'PROMPT.md requires ONE task per invocation. ' +
|
|
83
|
+
'Stop now and output <promise>TASK-{ID}:DONE</promise>.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// Fail silently — don't block work if the guard has a bug
|
|
89
|
+
if (process.env.DEBUG) {
|
|
90
|
+
console.error(`one-task-guard error: ${error.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -26,7 +26,7 @@ function findClaudeDir() {
|
|
|
26
26
|
|
|
27
27
|
try {
|
|
28
28
|
const claudeDir = findClaudeDir();
|
|
29
|
-
const awFilePath = path.join(claudeDir, '
|
|
29
|
+
const awFilePath = path.join(claudeDir, 'hooks', 'aw.md');
|
|
30
30
|
|
|
31
31
|
if (fs.existsSync(awFilePath)) {
|
|
32
32
|
const content = fs.readFileSync(awFilePath, 'utf8');
|
package/.claude/settings.json
CHANGED
|
@@ -7,23 +7,22 @@
|
|
|
7
7
|
"enableAllProjectMcpServers": true,
|
|
8
8
|
"enabledMcpjsonServers": ["playwright", "context7", "sequential-thinking"],
|
|
9
9
|
"hooks": {
|
|
10
|
-
"
|
|
10
|
+
"PostToolUse": [
|
|
11
11
|
{
|
|
12
|
+
"matcher": "Edit|Write|Bash",
|
|
12
13
|
"hooks": [
|
|
13
14
|
{
|
|
14
15
|
"type": "command",
|
|
15
|
-
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/
|
|
16
|
+
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use.js"
|
|
16
17
|
}
|
|
17
18
|
]
|
|
18
|
-
}
|
|
19
|
-
],
|
|
20
|
-
"PreToolUse": [
|
|
19
|
+
},
|
|
21
20
|
{
|
|
22
|
-
"matcher": "
|
|
21
|
+
"matcher": "Bash",
|
|
23
22
|
"hooks": [
|
|
24
23
|
{
|
|
25
24
|
"type": "command",
|
|
26
|
-
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/
|
|
25
|
+
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/one-task-guard.js"
|
|
27
26
|
}
|
|
28
27
|
]
|
|
29
28
|
}
|
package/AGENTS.md
CHANGED
|
@@ -25,6 +25,10 @@ Signs a file needs splitting:
|
|
|
25
25
|
- "Utils" file becoming a junk drawer
|
|
26
26
|
- Component doing data fetching + transformation + rendering
|
|
27
27
|
|
|
28
|
+
## Task Execution
|
|
29
|
+
|
|
30
|
+
- **One task per invocation.** When working from `.agent/tasks.json`, complete exactly one task, commit, and stop. Never batch multiple tasks.
|
|
31
|
+
|
|
28
32
|
## Code Style
|
|
29
33
|
|
|
30
34
|
1. Prefer writing clear code and use inline comments sparingly
|
package/bin/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ const fs = require('fs');
|
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const display = require('./lib/display');
|
|
12
12
|
const { copyFile, copyDir, mergeDir, exists, ensureDir } = require('./lib/copy');
|
|
13
|
+
const { mergeSettings } = require('./lib/settings');
|
|
13
14
|
const { isGitRepo, initGitRepo } = require('./lib/git');
|
|
14
15
|
const { isShadcnProject, installAllComponents } = require('./lib/shadcn');
|
|
15
16
|
const { setupPlaywright } = require('./lib/playwright');
|
|
@@ -124,7 +125,7 @@ const DIRS_TO_MERGE = [
|
|
|
124
125
|
|
|
125
126
|
// Other files in these directories to copy (with exclusions)
|
|
126
127
|
const EXTRA_DIR_FILES = [
|
|
127
|
-
{ dir: '.claude', exclude: ['settings.local.json', 'skills', 'agents', 'commands', 'hooks'] },
|
|
128
|
+
{ dir: '.claude', exclude: ['settings.local.json', 'settings.json', 'skills', 'agents', 'commands', 'hooks'] },
|
|
128
129
|
];
|
|
129
130
|
|
|
130
131
|
async function main() {
|
|
@@ -319,6 +320,21 @@ async function main() {
|
|
|
319
320
|
}
|
|
320
321
|
}
|
|
321
322
|
|
|
323
|
+
// Merge .claude/settings.json (preserve user settings, add hooks)
|
|
324
|
+
console.log();
|
|
325
|
+
display.printStep('🔧', 'Settings');
|
|
326
|
+
const settingsResult = mergeSettings(
|
|
327
|
+
path.join(PACKAGE_ROOT, '.claude/settings.json'),
|
|
328
|
+
path.join(TARGET_DIR, '.claude/settings.json')
|
|
329
|
+
);
|
|
330
|
+
if (settingsResult.created) {
|
|
331
|
+
display.printSuccess('.claude/settings.json (created)');
|
|
332
|
+
} else if (settingsResult.merged) {
|
|
333
|
+
display.printSuccess('.claude/settings.json (merged hooks)');
|
|
334
|
+
} else if (settingsResult.backedUp) {
|
|
335
|
+
display.printWarning('.claude/settings.json (backed up malformed, replaced)');
|
|
336
|
+
}
|
|
337
|
+
|
|
322
338
|
// Replace dev server URL in copied files if different from default
|
|
323
339
|
if (devServerUrl !== 'http://localhost:3000') {
|
|
324
340
|
const filesToPatch = [
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings merge module for Ralph Loop CLI
|
|
3
|
+
* Merges hook configurations into existing .claude/settings.json
|
|
4
|
+
* without overwriting user preferences
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { ensureDir, exists } = require('./copy');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Merges hook entries by event type, deduplicating by command string.
|
|
13
|
+
* Source hooks are only added if their commands aren't already present.
|
|
14
|
+
* @param {Object} existing - Existing hooks object (e.g. { PostToolUse: [...], Notification: [...] })
|
|
15
|
+
* @param {Object} source - Source hooks to merge in
|
|
16
|
+
* @returns {Object} Merged hooks
|
|
17
|
+
*/
|
|
18
|
+
function mergeHooks(existing = {}, source = {}) {
|
|
19
|
+
const result = JSON.parse(JSON.stringify(existing));
|
|
20
|
+
|
|
21
|
+
for (const [eventType, sourceEntries] of Object.entries(source)) {
|
|
22
|
+
if (!Array.isArray(sourceEntries)) continue;
|
|
23
|
+
|
|
24
|
+
if (!result[eventType]) {
|
|
25
|
+
result[eventType] = sourceEntries;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const existingCommands = new Set();
|
|
30
|
+
for (const entry of result[eventType]) {
|
|
31
|
+
for (const hook of (entry.hooks || [])) {
|
|
32
|
+
if (hook.command) existingCommands.add(hook.command);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const entry of sourceEntries) {
|
|
37
|
+
const commands = (entry.hooks || []).map(h => h.command).filter(Boolean);
|
|
38
|
+
const alreadyExists = commands.some(cmd => existingCommands.has(cmd));
|
|
39
|
+
if (!alreadyExists) {
|
|
40
|
+
result[eventType].push(entry);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Merges ralph-loop's settings.json into an existing one.
|
|
50
|
+
* - If dest doesn't exist: copies source as-is
|
|
51
|
+
* - If dest has malformed JSON: backs up and replaces
|
|
52
|
+
* - If dest is valid: merges hooks additively, sets missing non-hooks keys
|
|
53
|
+
* @param {string} srcPath - Path to ralph-loop's settings.json
|
|
54
|
+
* @param {string} destPath - Path to target project's settings.json
|
|
55
|
+
* @returns {{ created: boolean, merged: boolean, backedUp: boolean }}
|
|
56
|
+
*/
|
|
57
|
+
function mergeSettings(srcPath, destPath) {
|
|
58
|
+
if (!exists(srcPath)) {
|
|
59
|
+
return { created: false, merged: false, backedUp: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sourceSettings = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
|
|
63
|
+
ensureDir(path.dirname(destPath));
|
|
64
|
+
|
|
65
|
+
if (!exists(destPath)) {
|
|
66
|
+
fs.writeFileSync(destPath, JSON.stringify(sourceSettings, null, 2) + '\n', 'utf8');
|
|
67
|
+
return { created: true, merged: false, backedUp: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const raw = fs.readFileSync(destPath, 'utf8');
|
|
71
|
+
let existingSettings;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
existingSettings = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
fs.copyFileSync(destPath, destPath + '.bak');
|
|
77
|
+
fs.writeFileSync(destPath, JSON.stringify(sourceSettings, null, 2) + '\n', 'utf8');
|
|
78
|
+
return { created: false, merged: false, backedUp: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const merged = { ...existingSettings };
|
|
82
|
+
for (const [key, value] of Object.entries(sourceSettings)) {
|
|
83
|
+
if (key === 'hooks') continue;
|
|
84
|
+
if (!(key in merged)) {
|
|
85
|
+
merged[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
merged.hooks = mergeHooks(existingSettings.hooks, sourceSettings.hooks);
|
|
90
|
+
|
|
91
|
+
fs.writeFileSync(destPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
92
|
+
return { created: false, merged: true, backedUp: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
mergeHooks,
|
|
97
|
+
mergeSettings,
|
|
98
|
+
};
|
package/package.json
CHANGED
package/ralph.sh
CHANGED
|
@@ -113,7 +113,7 @@ $(cat $SCRIPT_DIR/.agent/PROMPT.md)"
|
|
|
113
113
|
# Read new content if file has grown
|
|
114
114
|
if [ "$CURRENT_SIZE" -gt "$LAST_POS" ]; then
|
|
115
115
|
# Read new lines
|
|
116
|
-
|
|
116
|
+
while IFS= read -r line; do
|
|
117
117
|
if [ -n "$line" ]; then
|
|
118
118
|
# Parse JSON and extract text content
|
|
119
119
|
parsed=$(parse_json_content "$line")
|
|
@@ -126,11 +126,11 @@ $(cat $SCRIPT_DIR/.agent/PROMPT.md)"
|
|
|
126
126
|
update_preview_line "$parsed"
|
|
127
127
|
fi
|
|
128
128
|
fi
|
|
129
|
-
done
|
|
129
|
+
done < <(tail -c +$((LAST_POS + 1)) "$OUTPUT_FILE" 2>/dev/null)
|
|
130
130
|
LAST_POS=$CURRENT_SIZE
|
|
131
131
|
fi
|
|
132
132
|
fi
|
|
133
|
-
sleep 0.2
|
|
133
|
+
sleep 0.2 || true
|
|
134
134
|
done
|
|
135
135
|
|
|
136
136
|
wait "$AGENT_PID" || true
|
|
@@ -140,14 +140,14 @@ $(cat $SCRIPT_DIR/.agent/PROMPT.md)"
|
|
|
140
140
|
if [ -f "$OUTPUT_FILE" ]; then
|
|
141
141
|
CURRENT_SIZE=$(stat -f%z "$OUTPUT_FILE" 2>/dev/null || stat -c%s "$OUTPUT_FILE" 2>/dev/null || echo "0")
|
|
142
142
|
if [ "$CURRENT_SIZE" -gt "$LAST_POS" ]; then
|
|
143
|
-
|
|
143
|
+
while IFS= read -r line; do
|
|
144
144
|
if [ -n "$line" ]; then
|
|
145
145
|
parsed=$(parse_json_content "$line")
|
|
146
146
|
if [ -n "$parsed" ]; then
|
|
147
147
|
echo "$parsed" >> "$FULL_OUTPUT_FILE"
|
|
148
148
|
fi
|
|
149
149
|
fi
|
|
150
|
-
done
|
|
150
|
+
done < <(tail -c +$((LAST_POS + 1)) "$OUTPUT_FILE" 2>/dev/null)
|
|
151
151
|
fi
|
|
152
152
|
fi
|
|
153
153
|
|
|
@@ -307,7 +307,7 @@ $(cat $SCRIPT_DIR/.agent/PROMPT.md)"
|
|
|
307
307
|
if [ -n "$STEP_TIMES_OUTPUT" ]; then
|
|
308
308
|
echo -e "${G} └──${R} $STEP_TIMES_OUTPUT"
|
|
309
309
|
fi
|
|
310
|
-
sleep 2
|
|
310
|
+
sleep 2 || true
|
|
311
311
|
done
|
|
312
312
|
|
|
313
313
|
# Calculate final elapsed time
|
package/scripts/lib/cleanup.sh
CHANGED
|
@@ -16,13 +16,39 @@ cleanup() {
|
|
|
16
16
|
fi
|
|
17
17
|
SPINNER_PID=""
|
|
18
18
|
|
|
19
|
-
# Kill Agent process if still running
|
|
19
|
+
# Kill Agent process tree if still running
|
|
20
|
+
# The process tree is: script -> bash -> docker sandbox run
|
|
21
|
+
# Killing just the script PID leaves the docker sandbox running
|
|
20
22
|
if [ -n "$AGENT_PID" ] && kill -0 "$AGENT_PID" 2>/dev/null; then
|
|
23
|
+
# Kill child processes first (bash -> docker), then the script process
|
|
24
|
+
pkill -TERM -P "$AGENT_PID" 2>/dev/null || true
|
|
21
25
|
kill "$AGENT_PID" 2>/dev/null || true
|
|
22
26
|
wait "$AGENT_PID" 2>/dev/null || true
|
|
23
27
|
fi
|
|
24
28
|
AGENT_PID=""
|
|
25
29
|
|
|
30
|
+
# Stop all running Docker sandboxes for this workspace
|
|
31
|
+
# Finds sandboxes by matching the workspace path, regardless of agent prefix
|
|
32
|
+
if command -v docker &>/dev/null && command -v jq &>/dev/null; then
|
|
33
|
+
local sandboxes
|
|
34
|
+
sandboxes=$(docker sandbox ls --json 2>/dev/null \
|
|
35
|
+
| jq -r --arg ws "$SCRIPT_DIR" '(.vms // .sandboxes // [])[] | select(.status == "running" and (.workspaces[] == $ws)) | .name' 2>/dev/null)
|
|
36
|
+
|
|
37
|
+
if [ -n "$sandboxes" ]; then
|
|
38
|
+
echo -e " ${Y}📦 Stopping sandbox...${R}"
|
|
39
|
+
# Stop all matching sandboxes (pass all names at once)
|
|
40
|
+
docker sandbox stop $sandboxes 2>/dev/null &
|
|
41
|
+
local stop_pid=$!
|
|
42
|
+
# Timeout after 5 seconds to avoid hanging on exit
|
|
43
|
+
( sleep 5 && kill "$stop_pid" 2>/dev/null ) &>/dev/null &
|
|
44
|
+
local timeout_pid=$!
|
|
45
|
+
if wait "$stop_pid" 2>/dev/null; then
|
|
46
|
+
echo -e " ${GR}✅ Sandbox stopped${R}"
|
|
47
|
+
fi
|
|
48
|
+
kill "$timeout_pid" 2>/dev/null || true
|
|
49
|
+
fi
|
|
50
|
+
fi
|
|
51
|
+
|
|
26
52
|
# Remove temporary files
|
|
27
53
|
[[ -n "$STEP_FILE" ]] && rm -f "$STEP_FILE" 2>/dev/null || true
|
|
28
54
|
[[ -n "$PREVIEW_LINE_FILE" ]] && rm -f "$PREVIEW_LINE_FILE" 2>/dev/null || true
|
package/scripts/lib/output.sh
CHANGED
|
@@ -11,16 +11,42 @@ parse_json_content() {
|
|
|
11
11
|
# For example, Claude stream-json outputs {"type":"content_block_delta","delta":{"text":"..."}}
|
|
12
12
|
# or {"type":"text","text":"..."} etc.
|
|
13
13
|
|
|
14
|
-
# Extract text from delta
|
|
15
|
-
local text
|
|
14
|
+
# Extract text from delta (use jq if available for proper escaped quote handling)
|
|
15
|
+
local text=""
|
|
16
|
+
if command -v jq &>/dev/null; then
|
|
17
|
+
text=$(echo "$json_line" | jq -r '.delta.text // .text // empty' 2>/dev/null)
|
|
18
|
+
else
|
|
19
|
+
text=$(echo "$json_line" | grep -o '"text":"[^"]*"' | head -1 | LC_ALL=C sed 's/"text":"//;s/"$//' 2>/dev/null)
|
|
20
|
+
fi
|
|
16
21
|
if [ -n "$text" ]; then
|
|
17
|
-
# Unescape common JSON escapes
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
# Unescape common JSON escapes (only needed for grep fallback, jq handles this)
|
|
23
|
+
if ! command -v jq &>/dev/null; then
|
|
24
|
+
text=$(echo "$text" | LC_ALL=C sed 's/\\n/\'$'\n''/g; s/\\t/\'$'\t''/g; s/\\"/"/g; s/\\\\/\\/g' 2>/dev/null)
|
|
25
|
+
fi
|
|
20
26
|
echo "$text"
|
|
21
27
|
return
|
|
22
28
|
fi
|
|
23
29
|
|
|
30
|
+
# Extract tool_use events (have "name" but no "text")
|
|
31
|
+
# Produces strings like "Read file_path=/src/main.ts" or "Bash command=npm test"
|
|
32
|
+
# that detect_step() patterns can match
|
|
33
|
+
local tool_name=$(echo "$json_line" | grep -o '"name":"[^"]*"' | head -1 | LC_ALL=C sed 's/"name":"//;s/"$//' 2>/dev/null)
|
|
34
|
+
if [ -n "$tool_name" ]; then
|
|
35
|
+
local file_path=$(echo "$json_line" | grep -o '"file_path":"[^"]*"' | head -1 | LC_ALL=C sed 's/"file_path":"//;s/"$//' 2>/dev/null)
|
|
36
|
+
local pattern=$(echo "$json_line" | grep -o '"pattern":"[^"]*"' | head -1 | LC_ALL=C sed 's/"pattern":"//;s/"$//' 2>/dev/null)
|
|
37
|
+
local cmd=$(echo "$json_line" | grep -o '"command":"[^"]*"' | head -1 | LC_ALL=C sed 's/"command":"//;s/"$//' 2>/dev/null)
|
|
38
|
+
if [ -n "$file_path" ]; then
|
|
39
|
+
echo "$tool_name file_path=$file_path"
|
|
40
|
+
elif [ -n "$pattern" ]; then
|
|
41
|
+
echo "$tool_name pattern=$pattern"
|
|
42
|
+
elif [ -n "$cmd" ]; then
|
|
43
|
+
echo "$tool_name command=$cmd"
|
|
44
|
+
else
|
|
45
|
+
echo "$tool_name"
|
|
46
|
+
fi
|
|
47
|
+
return
|
|
48
|
+
fi
|
|
49
|
+
|
|
24
50
|
# If it doesn't look like JSON, return as-is
|
|
25
51
|
if ! echo "$json_line" | grep -q '^{'; then
|
|
26
52
|
echo "$json_line"
|
|
@@ -44,7 +70,7 @@ strip_ansi() {
|
|
|
44
70
|
# 0x1C-0x1F (FS through US) - excludes ESC (0x1B) for sed to process
|
|
45
71
|
# sed handles: ESC sequences, OSC-like patterns, caret notation
|
|
46
72
|
# Note: OSC pattern uses ^ anchor but also handles after caret removal via second pass
|
|
47
|
-
echo "$input" | sed \
|
|
73
|
+
echo "$input" | LC_ALL=C sed \
|
|
48
74
|
-e 's/\x1b\[[0-9;?]*[A-Za-z]//g' \
|
|
49
75
|
-e 's/\x1b\][^\x07]*\x07//g' \
|
|
50
76
|
-e 's/\x1b\][^\x1b]*\x1b\\//g' \
|
|
@@ -53,9 +79,9 @@ strip_ansi() {
|
|
|
53
79
|
-e 's/\x1b.//g' \
|
|
54
80
|
-e 's/\^[][A-Z@\\^_]//g' \
|
|
55
81
|
-e 's/^0;[^]]*]//g' \
|
|
56
|
-
-e 's/<u0;//g' \
|
|
57
|
-
| tr -d '\000-\010\013\014\016-\032\034-\037' \
|
|
58
|
-
| sed -e 's/^0;[^]]*]//g'
|
|
82
|
+
-e 's/<u0;//g' 2>/dev/null \
|
|
83
|
+
| LC_ALL=C tr -d '\000-\010\013\014\016-\032\034-\037' 2>/dev/null \
|
|
84
|
+
| LC_ALL=C sed -e 's/^0;[^]]*]//g' 2>/dev/null
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
# Strip ANSI from a file and write to output file
|
|
@@ -68,7 +94,7 @@ strip_ansi_file() {
|
|
|
68
94
|
# Then tr removes remaining control characters (excluding newline 0x0A, CR 0x0D)
|
|
69
95
|
# tr range excludes ESC (0x1B = octal 033) which sed already handled
|
|
70
96
|
# Final sed pass cleans up OSC patterns that were hidden by control chars
|
|
71
|
-
sed \
|
|
97
|
+
LC_ALL=C sed \
|
|
72
98
|
-e 's/\x1b\[[0-9;?]*[A-Za-z]//g' \
|
|
73
99
|
-e 's/\x1b\][^\x07]*\x07//g' \
|
|
74
100
|
-e 's/\x1b\][^\x1b]*\x1b\\//g' \
|
|
@@ -78,8 +104,8 @@ strip_ansi_file() {
|
|
|
78
104
|
-e 's/\^[][A-Z@\\^_]//g' \
|
|
79
105
|
-e 's/^0;[^]]*]//g' \
|
|
80
106
|
-e 's/<u0;//g' \
|
|
81
|
-
"$input_file" | tr -d '\000-\010\013\014\016-\032\034-\037' \
|
|
82
|
-
| sed -e 's/^0;[^]]*]//g' \
|
|
107
|
+
"$input_file" 2>/dev/null | LC_ALL=C tr -d '\000-\010\013\014\016-\032\034-\037' 2>/dev/null \
|
|
108
|
+
| LC_ALL=C sed -e 's/^0;[^]]*]//g' 2>/dev/null \
|
|
83
109
|
> "$output_file"
|
|
84
110
|
}
|
|
85
111
|
|
package/scripts/lib/spinner.sh
CHANGED
|
@@ -60,7 +60,7 @@ update_spinner_step() {
|
|
|
60
60
|
echo "$detected" > "$STEP_FILE"
|
|
61
61
|
|
|
62
62
|
# Clean step name (remove trailing spaces) for timing tracking
|
|
63
|
-
local clean_step=$(echo "$detected" | sed 's/ *$//')
|
|
63
|
+
local clean_step=$(echo "$detected" | LC_ALL=C sed 's/ *$//' 2>/dev/null)
|
|
64
64
|
|
|
65
65
|
# Record timing if step changed
|
|
66
66
|
if [ "$clean_step" != "$CURRENT_STEP_NAME" ]; then
|
|
@@ -77,7 +77,7 @@ update_preview_line() {
|
|
|
77
77
|
[[ "$line" =~ ^[[:space:]]*$ ]] && return
|
|
78
78
|
# Sanitize: replace newlines/tabs with spaces, collapse multiple spaces
|
|
79
79
|
# This prevents multi-line content from breaking cursor positioning
|
|
80
|
-
line=$(echo "$line" | tr '\n\t\r' ' ' | sed 's/ */ /g; s/^ *//; s/ *$//')
|
|
80
|
+
line=$(echo "$line" | LC_ALL=C tr '\n\t\r' ' ' 2>/dev/null | LC_ALL=C sed 's/ */ /g; s/^ *//; s/ *$//' 2>/dev/null | head -1)
|
|
81
81
|
# Skip if sanitization resulted in empty string
|
|
82
82
|
[ -z "$line" ] && return
|
|
83
83
|
# Write to preview file (spinner reads this)
|
package/scripts/lib/timing.sh
CHANGED
|
@@ -224,6 +224,8 @@ detect_step() {
|
|
|
224
224
|
echo "Implementing"
|
|
225
225
|
elif echo "$line" | grep -qiE "(compiling|bundling|transpiling)"; then
|
|
226
226
|
echo "Implementing"
|
|
227
|
+
elif echo "$line" | grep -qiE "Bash.*command=.*(build|compile|bundle)"; then
|
|
228
|
+
echo "Implementing"
|
|
227
229
|
|
|
228
230
|
# COMMITTING - git operations
|
|
229
231
|
elif echo "$line" | grep -qiE "(git commit|git add|committing|staged for commit)"; then
|
|
@@ -238,6 +240,8 @@ detect_step() {
|
|
|
238
240
|
echo "Testing"
|
|
239
241
|
elif echo "$line" | grep -qiE "(running|executing) (tests|test suite)"; then
|
|
240
242
|
echo "Testing"
|
|
243
|
+
elif echo "$line" | grep -qiE "Bash.*command=.*(test|e2e|spec|jest|vitest|playwright|pytest)"; then
|
|
244
|
+
echo "Testing"
|
|
241
245
|
|
|
242
246
|
# DEBUGGING - investigating errors, fixing issues
|
|
243
247
|
elif echo "$line" | grep -qiE "(the|this) (error|issue|problem|bug) (is|seems|appears|was)"; then
|
|
@@ -254,10 +258,14 @@ detect_step() {
|
|
|
254
258
|
# LINTING - code style and formatting
|
|
255
259
|
elif echo "$line" | grep -qiE "(eslint|biome|lint|prettier|formatting|stylelint)"; then
|
|
256
260
|
echo "Linting"
|
|
261
|
+
elif echo "$line" | grep -qiE "Bash.*command=.*(lint|eslint|biome|prettier|stylelint)"; then
|
|
262
|
+
echo "Linting"
|
|
257
263
|
|
|
258
264
|
# TYPECHECKING - type validation
|
|
259
265
|
elif echo "$line" | grep -qiE "(npm run typecheck|tsc|typescript|type.?check|mypy|pyright)"; then
|
|
260
266
|
echo "Typechecking"
|
|
267
|
+
elif echo "$line" | grep -qiE "Bash.*command=.*(typecheck|tsc|mypy|pyright)"; then
|
|
268
|
+
echo "Typechecking"
|
|
261
269
|
|
|
262
270
|
# WRITING TESTS - creating test files
|
|
263
271
|
elif echo "$line" | grep -qiE "(\.test\.|\.spec\.|test file|writing test|adding test)"; then
|
|
@@ -272,6 +280,8 @@ detect_step() {
|
|
|
272
280
|
echo "Installing"
|
|
273
281
|
elif echo "$line" | grep -qiE "(installing|adding|updating) (dependency|dependencies|package)"; then
|
|
274
282
|
echo "Installing"
|
|
283
|
+
elif echo "$line" | grep -qiE "Bash.*command=.*(npm install|yarn add|pnpm add|pip install|brew install)"; then
|
|
284
|
+
echo "Installing"
|
|
275
285
|
|
|
276
286
|
# WEB RESEARCH - fetching docs, searching web
|
|
277
287
|
elif echo "$line" | grep -qiE "(WebFetch|WebSearch)"; then
|
|
File without changes
|