agileflow 2.89.3 → 2.90.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/CHANGELOG.md +5 -0
- package/README.md +3 -3
- package/lib/placeholder-registry.js +617 -0
- package/lib/smart-json-file.js +205 -1
- package/lib/table-formatter.js +504 -0
- package/lib/transient-status.js +374 -0
- package/lib/ui-manager.js +612 -0
- package/lib/validate-args.js +213 -0
- package/lib/validate-names.js +143 -0
- package/lib/validate-paths.js +434 -0
- package/lib/validate.js +37 -737
- package/package.json +4 -1
- package/scripts/check-update.js +16 -3
- package/scripts/lib/sessionRegistry.js +682 -0
- package/scripts/session-manager.js +77 -10
- package/scripts/tui/App.js +176 -0
- package/scripts/tui/index.js +75 -0
- package/scripts/tui/lib/crashRecovery.js +302 -0
- package/scripts/tui/lib/eventStream.js +316 -0
- package/scripts/tui/lib/keyboard.js +252 -0
- package/scripts/tui/lib/loopControl.js +371 -0
- package/scripts/tui/panels/OutputPanel.js +278 -0
- package/scripts/tui/panels/SessionPanel.js +178 -0
- package/scripts/tui/panels/TracePanel.js +333 -0
- package/src/core/commands/tui.md +91 -0
- package/tools/cli/commands/config.js +7 -30
- package/tools/cli/commands/doctor.js +18 -38
- package/tools/cli/commands/list.js +47 -35
- package/tools/cli/commands/status.js +13 -37
- package/tools/cli/commands/uninstall.js +9 -38
- package/tools/cli/installers/core/installer.js +13 -0
- package/tools/cli/lib/command-context.js +374 -0
- package/tools/cli/lib/config-manager.js +394 -0
- package/tools/cli/lib/ide-registry.js +186 -0
- package/tools/cli/lib/npm-utils.js +16 -3
- package/tools/cli/lib/self-update.js +148 -0
- package/tools/cli/lib/validation-middleware.js +491 -0
|
@@ -259,6 +259,23 @@ function unregisterSession(sessionId) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
// Get session by ID
|
|
263
|
+
function getSession(sessionId) {
|
|
264
|
+
const registry = loadRegistry();
|
|
265
|
+
const session = registry.sessions[sessionId];
|
|
266
|
+
if (!session) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
// Ensure thread_type exists (migration for legacy sessions)
|
|
270
|
+
const threadType = session.thread_type || (session.is_main ? 'base' : 'parallel');
|
|
271
|
+
return {
|
|
272
|
+
id: sessionId,
|
|
273
|
+
...session,
|
|
274
|
+
thread_type: threadType,
|
|
275
|
+
active: isSessionActive(sessionId),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
262
279
|
// Create new session with worktree
|
|
263
280
|
function createSession(options = {}) {
|
|
264
281
|
const registry = loadRegistry();
|
|
@@ -1006,6 +1023,22 @@ function main() {
|
|
|
1006
1023
|
break;
|
|
1007
1024
|
}
|
|
1008
1025
|
|
|
1026
|
+
case 'get': {
|
|
1027
|
+
const sessionId = args[1];
|
|
1028
|
+
if (!sessionId) {
|
|
1029
|
+
console.log(JSON.stringify({ success: false, error: 'Session ID required' }));
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
// Use the exported getSession function for consistency
|
|
1033
|
+
const session = getSession(sessionId);
|
|
1034
|
+
if (!session) {
|
|
1035
|
+
console.log(JSON.stringify({ success: false, error: `Session ${sessionId} not found` }));
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
console.log(JSON.stringify({ success: true, ...session }));
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1009
1042
|
// PERFORMANCE: Combined command for welcome script (saves ~200ms from 3 subprocess calls)
|
|
1010
1043
|
case 'full-status': {
|
|
1011
1044
|
const nickname = args[1] || null;
|
|
@@ -1248,6 +1281,7 @@ ${c.cyan}Commands:${c.reset}
|
|
|
1248
1281
|
count Count other active sessions
|
|
1249
1282
|
delete <id> [--remove-worktree] Delete session
|
|
1250
1283
|
status Get current session status
|
|
1284
|
+
get <id> Get specific session by ID
|
|
1251
1285
|
full-status Combined register+count+status (optimized)
|
|
1252
1286
|
switch <id|nickname> Switch active session context (for /add-dir)
|
|
1253
1287
|
active Get currently switched session (if any)
|
|
@@ -1667,14 +1701,47 @@ function resolveConflict(resolution) {
|
|
|
1667
1701
|
try {
|
|
1668
1702
|
switch (gitStrategy) {
|
|
1669
1703
|
case 'union':
|
|
1670
|
-
// Union merge -
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1704
|
+
// Union merge - concatenate both versions (works for additive files like docs/tests)
|
|
1705
|
+
// Use git merge-file with --union flag for true union merge
|
|
1706
|
+
// This keeps both ours and theirs changes, separated by markers only if truly conflicting
|
|
1707
|
+
try {
|
|
1708
|
+
// Get the base, ours, and theirs versions
|
|
1709
|
+
const base = spawnSync('git', ['show', `:1:${file}`], { cwd: ROOT, encoding: 'utf8' });
|
|
1710
|
+
const ours = spawnSync('git', ['show', `:2:${file}`], { cwd: ROOT, encoding: 'utf8' });
|
|
1711
|
+
const theirs = spawnSync('git', ['show', `:3:${file}`], { cwd: ROOT, encoding: 'utf8' });
|
|
1712
|
+
|
|
1713
|
+
// If we can get all three, use merge-file with union
|
|
1714
|
+
if (base.status === 0 && ours.status === 0 && theirs.status === 0) {
|
|
1715
|
+
// Write temp files for merge-file
|
|
1716
|
+
const tmpBase = path.join(ROOT, '.git', 'MERGE_BASE_TMP');
|
|
1717
|
+
const tmpOurs = path.join(ROOT, '.git', 'MERGE_OURS_TMP');
|
|
1718
|
+
const tmpTheirs = path.join(ROOT, '.git', 'MERGE_THEIRS_TMP');
|
|
1719
|
+
|
|
1720
|
+
fs.writeFileSync(tmpBase, base.stdout);
|
|
1721
|
+
fs.writeFileSync(tmpOurs, ours.stdout);
|
|
1722
|
+
fs.writeFileSync(tmpTheirs, theirs.stdout);
|
|
1723
|
+
|
|
1724
|
+
// Run merge-file with --union (keeps both sides for conflicts)
|
|
1725
|
+
spawnSync('git', ['merge-file', '--union', tmpOurs, tmpBase, tmpTheirs], {
|
|
1726
|
+
cwd: ROOT,
|
|
1727
|
+
encoding: 'utf8',
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
// Copy merged result to working tree
|
|
1731
|
+
fs.copyFileSync(tmpOurs, path.join(ROOT, file));
|
|
1732
|
+
|
|
1733
|
+
// Cleanup temp files
|
|
1734
|
+
fs.unlinkSync(tmpBase);
|
|
1735
|
+
fs.unlinkSync(tmpOurs);
|
|
1736
|
+
fs.unlinkSync(tmpTheirs);
|
|
1737
|
+
} else {
|
|
1738
|
+
// Fallback: accept theirs for docs/tests (session's additions are more important)
|
|
1739
|
+
execSync(`git checkout --theirs "${file}"`, { cwd: ROOT, encoding: 'utf8' });
|
|
1740
|
+
}
|
|
1741
|
+
} catch (unionError) {
|
|
1742
|
+
// Fallback to theirs on any error
|
|
1743
|
+
execSync(`git checkout --theirs "${file}"`, { cwd: ROOT, encoding: 'utf8' });
|
|
1744
|
+
}
|
|
1678
1745
|
break;
|
|
1679
1746
|
|
|
1680
1747
|
case 'theirs':
|
|
@@ -1689,8 +1756,7 @@ function resolveConflict(resolution) {
|
|
|
1689
1756
|
|
|
1690
1757
|
case 'recursive':
|
|
1691
1758
|
default:
|
|
1692
|
-
//
|
|
1693
|
-
// For conflicts, we'll favor theirs (the session's work)
|
|
1759
|
+
// For source code conflicts, favor theirs (the session's work)
|
|
1694
1760
|
execSync(`git checkout --theirs "${file}"`, { cwd: ROOT, encoding: 'utf8' });
|
|
1695
1761
|
break;
|
|
1696
1762
|
}
|
|
@@ -1936,6 +2002,7 @@ module.exports = {
|
|
|
1936
2002
|
saveRegistry,
|
|
1937
2003
|
registerSession,
|
|
1938
2004
|
unregisterSession,
|
|
2005
|
+
getSession,
|
|
1939
2006
|
createSession,
|
|
1940
2007
|
getSessions,
|
|
1941
2008
|
getActiveSessionCount,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { Box, Text, useInput, useApp } = require('ink');
|
|
5
|
+
const { KeyboardHandler, formatBindings, DEFAULT_BINDINGS } = require('./lib/keyboard');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Main TUI Application Component
|
|
9
|
+
*
|
|
10
|
+
* Provides the base layout and keyboard handling for AgileFlow TUI.
|
|
11
|
+
* Key bindings: Q=quit, S=start, P=pause, R=resume, T=trace, 1-9=sessions
|
|
12
|
+
*/
|
|
13
|
+
function App({
|
|
14
|
+
children,
|
|
15
|
+
title = 'AgileFlow TUI',
|
|
16
|
+
showFooter = true,
|
|
17
|
+
onAction = null,
|
|
18
|
+
bindings = DEFAULT_BINDINGS
|
|
19
|
+
}) {
|
|
20
|
+
const { exit } = useApp();
|
|
21
|
+
const [showHelp, setShowHelp] = React.useState(false);
|
|
22
|
+
const [lastAction, setLastAction] = React.useState(null);
|
|
23
|
+
|
|
24
|
+
// Create keyboard handler
|
|
25
|
+
const keyboardRef = React.useRef(null);
|
|
26
|
+
if (!keyboardRef.current) {
|
|
27
|
+
keyboardRef.current = new KeyboardHandler({ bindings });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Set up event listeners
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
const keyboard = keyboardRef.current;
|
|
33
|
+
|
|
34
|
+
keyboard.on('quit', () => exit());
|
|
35
|
+
keyboard.on('help', () => setShowHelp(prev => !prev));
|
|
36
|
+
|
|
37
|
+
// Forward all actions to parent
|
|
38
|
+
keyboard.on('action', (action) => {
|
|
39
|
+
setLastAction(action);
|
|
40
|
+
if (onAction) {
|
|
41
|
+
onAction(action);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
keyboard.removeAllListeners();
|
|
47
|
+
};
|
|
48
|
+
}, [exit, onAction]);
|
|
49
|
+
|
|
50
|
+
// Handle key input
|
|
51
|
+
useInput((input, key) => {
|
|
52
|
+
keyboardRef.current.processKey(input, key);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Format footer bindings
|
|
56
|
+
const footerBindings = formatBindings(bindings);
|
|
57
|
+
|
|
58
|
+
return React.createElement(
|
|
59
|
+
Box,
|
|
60
|
+
{
|
|
61
|
+
flexDirection: 'column',
|
|
62
|
+
width: '100%',
|
|
63
|
+
minHeight: 20
|
|
64
|
+
},
|
|
65
|
+
// Header
|
|
66
|
+
React.createElement(
|
|
67
|
+
Box,
|
|
68
|
+
{
|
|
69
|
+
borderStyle: 'round',
|
|
70
|
+
borderColor: 'cyan',
|
|
71
|
+
paddingX: 1,
|
|
72
|
+
justifyContent: 'center'
|
|
73
|
+
},
|
|
74
|
+
React.createElement(
|
|
75
|
+
Text,
|
|
76
|
+
{ bold: true, color: 'cyan' },
|
|
77
|
+
title
|
|
78
|
+
),
|
|
79
|
+
lastAction && React.createElement(
|
|
80
|
+
Text,
|
|
81
|
+
{ dimColor: true },
|
|
82
|
+
` [${lastAction.action}]`
|
|
83
|
+
)
|
|
84
|
+
),
|
|
85
|
+
// Main content area
|
|
86
|
+
React.createElement(
|
|
87
|
+
Box,
|
|
88
|
+
{
|
|
89
|
+
flexDirection: 'column',
|
|
90
|
+
flexGrow: 1,
|
|
91
|
+
paddingX: 1,
|
|
92
|
+
paddingY: 1
|
|
93
|
+
},
|
|
94
|
+
showHelp
|
|
95
|
+
? React.createElement(HelpPanel, { bindings })
|
|
96
|
+
: children
|
|
97
|
+
),
|
|
98
|
+
// Footer with key bindings
|
|
99
|
+
showFooter && React.createElement(
|
|
100
|
+
Box,
|
|
101
|
+
{
|
|
102
|
+
borderStyle: 'single',
|
|
103
|
+
borderColor: 'gray',
|
|
104
|
+
paddingX: 1,
|
|
105
|
+
justifyContent: 'space-between'
|
|
106
|
+
},
|
|
107
|
+
React.createElement(
|
|
108
|
+
Box,
|
|
109
|
+
{ flexDirection: 'row' },
|
|
110
|
+
footerBindings.map((binding, i) =>
|
|
111
|
+
React.createElement(
|
|
112
|
+
Text,
|
|
113
|
+
{ key: `binding-${i}`, dimColor: true },
|
|
114
|
+
i > 0 ? ' | ' : '',
|
|
115
|
+
binding
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
),
|
|
119
|
+
React.createElement(
|
|
120
|
+
Text,
|
|
121
|
+
{ dimColor: true },
|
|
122
|
+
'1-9:Sessions | AgileFlow v2.89.3'
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Help Panel component
|
|
130
|
+
*/
|
|
131
|
+
function HelpPanel({ bindings = DEFAULT_BINDINGS }) {
|
|
132
|
+
const groups = {
|
|
133
|
+
'Loop Control': ['start', 'pause', 'resume'],
|
|
134
|
+
'View': ['trace', 'help'],
|
|
135
|
+
'Navigation': ['quit'],
|
|
136
|
+
'Sessions': ['session1', 'session2', 'session3']
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return React.createElement(
|
|
140
|
+
Box,
|
|
141
|
+
{ flexDirection: 'column', padding: 1 },
|
|
142
|
+
React.createElement(
|
|
143
|
+
Text,
|
|
144
|
+
{ bold: true, color: 'cyan' },
|
|
145
|
+
'Key Bindings'
|
|
146
|
+
),
|
|
147
|
+
React.createElement(Box, { marginTop: 1 }),
|
|
148
|
+
Object.entries(groups).map(([groupName, actions]) =>
|
|
149
|
+
React.createElement(
|
|
150
|
+
Box,
|
|
151
|
+
{ key: groupName, flexDirection: 'column', marginBottom: 1 },
|
|
152
|
+
React.createElement(
|
|
153
|
+
Text,
|
|
154
|
+
{ bold: true },
|
|
155
|
+
groupName + ':'
|
|
156
|
+
),
|
|
157
|
+
actions.map(action => {
|
|
158
|
+
const binding = bindings[action];
|
|
159
|
+
if (!binding) return null;
|
|
160
|
+
return React.createElement(
|
|
161
|
+
Text,
|
|
162
|
+
{ key: action, dimColor: true },
|
|
163
|
+
` ${binding.key.toUpperCase()} - ${binding.description}`
|
|
164
|
+
);
|
|
165
|
+
})
|
|
166
|
+
)
|
|
167
|
+
),
|
|
168
|
+
React.createElement(
|
|
169
|
+
Text,
|
|
170
|
+
{ dimColor: true, marginTop: 1 },
|
|
171
|
+
'Press ? to close help'
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { App, HelpPanel };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AgileFlow TUI - Terminal User Interface
|
|
6
|
+
*
|
|
7
|
+
* Real-time visualization for session monitoring, multi-agent orchestration,
|
|
8
|
+
* and interactive loop control.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/tui/index.js
|
|
12
|
+
* npx agileflow tui
|
|
13
|
+
*
|
|
14
|
+
* Key bindings:
|
|
15
|
+
* q - Quit TUI
|
|
16
|
+
* s - Start loop on current story
|
|
17
|
+
* p - Pause active loop
|
|
18
|
+
* r - Resume paused loop
|
|
19
|
+
* t - Toggle trace panel
|
|
20
|
+
* 1-9 - Switch session focus
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const React = require('react');
|
|
24
|
+
const { render, Box, Text } = require('ink');
|
|
25
|
+
const { App } = require('./App');
|
|
26
|
+
const { SessionPanel } = require('./panels/SessionPanel');
|
|
27
|
+
const { OutputPanel } = require('./panels/OutputPanel');
|
|
28
|
+
|
|
29
|
+
// Main TUI Layout - split panel view
|
|
30
|
+
function MainLayout() {
|
|
31
|
+
return React.createElement(
|
|
32
|
+
Box,
|
|
33
|
+
{ flexDirection: 'row', width: '100%', minHeight: 15 },
|
|
34
|
+
// Left panel - Sessions (40% width)
|
|
35
|
+
React.createElement(
|
|
36
|
+
Box,
|
|
37
|
+
{ flexDirection: 'column', width: '40%', paddingRight: 1 },
|
|
38
|
+
React.createElement(SessionPanel, { refreshInterval: 5000 })
|
|
39
|
+
),
|
|
40
|
+
// Right panel - Agent Output (60% width)
|
|
41
|
+
React.createElement(
|
|
42
|
+
Box,
|
|
43
|
+
{ flexDirection: 'column', width: '60%' },
|
|
44
|
+
React.createElement(OutputPanel, {
|
|
45
|
+
maxMessages: 100,
|
|
46
|
+
showTimestamp: true,
|
|
47
|
+
title: 'AGENT OUTPUT'
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Main entry point
|
|
54
|
+
function main() {
|
|
55
|
+
const instance = render(
|
|
56
|
+
React.createElement(
|
|
57
|
+
App,
|
|
58
|
+
{ title: 'AgileFlow TUI' },
|
|
59
|
+
React.createElement(MainLayout)
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Handle clean exit
|
|
64
|
+
instance.waitUntilExit().then(() => {
|
|
65
|
+
console.log('AgileFlow TUI closed.');
|
|
66
|
+
process.exit(0);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Run if executed directly
|
|
71
|
+
if (require.main === module) {
|
|
72
|
+
main();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { main };
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Crash Recovery - Session state persistence and recovery
|
|
5
|
+
*
|
|
6
|
+
* Persists ralph-loop state to enable recovery after crashes.
|
|
7
|
+
* Stores checkpoints on each iteration and prompts for recovery
|
|
8
|
+
* when incomplete state is detected.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
// Get project root
|
|
15
|
+
let getProjectRoot;
|
|
16
|
+
try {
|
|
17
|
+
getProjectRoot = require('../../../lib/paths').getProjectRoot;
|
|
18
|
+
} catch (e) {
|
|
19
|
+
getProjectRoot = () => process.cwd();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Get safe JSON utilities
|
|
23
|
+
let safeReadJSON, safeWriteJSON;
|
|
24
|
+
try {
|
|
25
|
+
const errors = require('../../../lib/errors');
|
|
26
|
+
safeReadJSON = errors.safeReadJSON;
|
|
27
|
+
safeWriteJSON = errors.safeWriteJSON;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
safeReadJSON = (filePath, opts = {}) => {
|
|
30
|
+
try {
|
|
31
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
32
|
+
return { ok: true, data };
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { ok: false, error: e.message, data: opts.defaultValue };
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
safeWriteJSON = (filePath, data) => {
|
|
38
|
+
const dir = path.dirname(filePath);
|
|
39
|
+
if (!fs.existsSync(dir)) {
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
43
|
+
return { ok: true };
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get checkpoint file path
|
|
49
|
+
*/
|
|
50
|
+
function getCheckpointPath(sessionId = 'default') {
|
|
51
|
+
const rootDir = getProjectRoot();
|
|
52
|
+
return path.join(rootDir, '.agileflow', 'sessions', `${sessionId}.checkpoint`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get session state path
|
|
57
|
+
*/
|
|
58
|
+
function getSessionStatePath() {
|
|
59
|
+
const rootDir = getProjectRoot();
|
|
60
|
+
return path.join(rootDir, 'docs', '09-agents', 'session-state.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create checkpoint
|
|
65
|
+
* Called after each iteration to persist state
|
|
66
|
+
*/
|
|
67
|
+
function createCheckpoint(sessionId = 'default', loopState = null) {
|
|
68
|
+
const checkpointPath = getCheckpointPath(sessionId);
|
|
69
|
+
|
|
70
|
+
// Get current loop state if not provided
|
|
71
|
+
if (!loopState) {
|
|
72
|
+
const statePath = getSessionStatePath();
|
|
73
|
+
const result = safeReadJSON(statePath, { defaultValue: {} });
|
|
74
|
+
loopState = result.ok ? result.data.ralph_loop : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!loopState || !loopState.enabled) {
|
|
78
|
+
return { ok: false, error: 'No active loop state to checkpoint' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const checkpoint = {
|
|
82
|
+
version: 1,
|
|
83
|
+
session_id: sessionId,
|
|
84
|
+
created_at: new Date().toISOString(),
|
|
85
|
+
loop_state: {
|
|
86
|
+
epic: loopState.epic,
|
|
87
|
+
current_story: loopState.current_story,
|
|
88
|
+
iteration: loopState.iteration || 0,
|
|
89
|
+
max_iterations: loopState.max_iterations || 20,
|
|
90
|
+
visual_mode: loopState.visual_mode || false,
|
|
91
|
+
coverage_mode: loopState.coverage_mode || false,
|
|
92
|
+
coverage_threshold: loopState.coverage_threshold || 80,
|
|
93
|
+
coverage_current: loopState.coverage_current || 0,
|
|
94
|
+
started_at: loopState.started_at,
|
|
95
|
+
conditions: loopState.conditions || []
|
|
96
|
+
},
|
|
97
|
+
recovery_info: {
|
|
98
|
+
can_resume: true,
|
|
99
|
+
last_checkpoint: new Date().toISOString()
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const result = safeWriteJSON(checkpointPath, checkpoint);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
ok: result.ok,
|
|
107
|
+
checkpoint,
|
|
108
|
+
path: checkpointPath
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load checkpoint
|
|
114
|
+
*/
|
|
115
|
+
function loadCheckpoint(sessionId = 'default') {
|
|
116
|
+
const checkpointPath = getCheckpointPath(sessionId);
|
|
117
|
+
|
|
118
|
+
if (!fs.existsSync(checkpointPath)) {
|
|
119
|
+
return { ok: false, exists: false, error: 'No checkpoint found' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = safeReadJSON(checkpointPath, { defaultValue: null });
|
|
123
|
+
|
|
124
|
+
if (!result.ok || !result.data) {
|
|
125
|
+
return { ok: false, exists: true, error: 'Failed to read checkpoint' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
exists: true,
|
|
131
|
+
checkpoint: result.data
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if recovery is needed
|
|
137
|
+
* Detects incomplete loop state from crash
|
|
138
|
+
*/
|
|
139
|
+
function checkRecoveryNeeded(sessionId = 'default') {
|
|
140
|
+
// Check for checkpoint file
|
|
141
|
+
const checkpointResult = loadCheckpoint(sessionId);
|
|
142
|
+
|
|
143
|
+
if (!checkpointResult.exists) {
|
|
144
|
+
return { needed: false, reason: 'no_checkpoint' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!checkpointResult.ok) {
|
|
148
|
+
return { needed: false, reason: 'checkpoint_invalid' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const checkpoint = checkpointResult.checkpoint;
|
|
152
|
+
|
|
153
|
+
// Check if checkpoint is stale (older than 1 hour without update)
|
|
154
|
+
const lastCheckpoint = new Date(checkpoint.recovery_info?.last_checkpoint || checkpoint.created_at);
|
|
155
|
+
const now = new Date();
|
|
156
|
+
const hoursSinceCheckpoint = (now - lastCheckpoint) / (1000 * 60 * 60);
|
|
157
|
+
|
|
158
|
+
if (hoursSinceCheckpoint > 24) {
|
|
159
|
+
return { needed: false, reason: 'checkpoint_expired', checkpoint };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if loop was in progress
|
|
163
|
+
const loopState = checkpoint.loop_state;
|
|
164
|
+
if (!loopState || loopState.iteration === 0) {
|
|
165
|
+
return { needed: false, reason: 'loop_not_started', checkpoint };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check current session state
|
|
169
|
+
const statePath = getSessionStatePath();
|
|
170
|
+
const stateResult = safeReadJSON(statePath, { defaultValue: {} });
|
|
171
|
+
const currentState = stateResult.ok ? stateResult.data : {};
|
|
172
|
+
|
|
173
|
+
// If loop is still enabled and matches checkpoint, no recovery needed
|
|
174
|
+
if (currentState.ralph_loop && currentState.ralph_loop.enabled) {
|
|
175
|
+
if (currentState.ralph_loop.iteration === loopState.iteration) {
|
|
176
|
+
return { needed: false, reason: 'loop_still_active', checkpoint };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Loop was in progress but not active now - recovery needed
|
|
181
|
+
return {
|
|
182
|
+
needed: true,
|
|
183
|
+
reason: 'incomplete_loop',
|
|
184
|
+
checkpoint,
|
|
185
|
+
recovery_options: {
|
|
186
|
+
resume: {
|
|
187
|
+
iteration: loopState.iteration,
|
|
188
|
+
story: loopState.current_story,
|
|
189
|
+
epic: loopState.epic
|
|
190
|
+
},
|
|
191
|
+
fresh: {
|
|
192
|
+
message: 'Start fresh from beginning of epic'
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resume from checkpoint
|
|
200
|
+
*/
|
|
201
|
+
function resumeFromCheckpoint(sessionId = 'default') {
|
|
202
|
+
const checkpointResult = loadCheckpoint(sessionId);
|
|
203
|
+
|
|
204
|
+
if (!checkpointResult.ok || !checkpointResult.checkpoint) {
|
|
205
|
+
return { ok: false, error: 'No valid checkpoint to resume from' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const checkpoint = checkpointResult.checkpoint;
|
|
209
|
+
const loopState = checkpoint.loop_state;
|
|
210
|
+
|
|
211
|
+
// Restore loop state to session state
|
|
212
|
+
const statePath = getSessionStatePath();
|
|
213
|
+
const stateResult = safeReadJSON(statePath, { defaultValue: {} });
|
|
214
|
+
const state = stateResult.ok ? stateResult.data : {};
|
|
215
|
+
|
|
216
|
+
state.ralph_loop = {
|
|
217
|
+
enabled: true,
|
|
218
|
+
epic: loopState.epic,
|
|
219
|
+
current_story: loopState.current_story,
|
|
220
|
+
iteration: loopState.iteration,
|
|
221
|
+
max_iterations: loopState.max_iterations,
|
|
222
|
+
visual_mode: loopState.visual_mode,
|
|
223
|
+
coverage_mode: loopState.coverage_mode,
|
|
224
|
+
coverage_threshold: loopState.coverage_threshold,
|
|
225
|
+
coverage_current: loopState.coverage_current,
|
|
226
|
+
conditions: loopState.conditions,
|
|
227
|
+
started_at: loopState.started_at,
|
|
228
|
+
resumed_at: new Date().toISOString(),
|
|
229
|
+
resumed_from_checkpoint: true
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
safeWriteJSON(statePath, state);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
resumed: true,
|
|
237
|
+
iteration: loopState.iteration,
|
|
238
|
+
story: loopState.current_story,
|
|
239
|
+
epic: loopState.epic
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Clear checkpoint (on clean completion)
|
|
245
|
+
*/
|
|
246
|
+
function clearCheckpoint(sessionId = 'default') {
|
|
247
|
+
const checkpointPath = getCheckpointPath(sessionId);
|
|
248
|
+
|
|
249
|
+
if (fs.existsSync(checkpointPath)) {
|
|
250
|
+
fs.unlinkSync(checkpointPath);
|
|
251
|
+
return { ok: true, cleared: true };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { ok: true, cleared: false };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Start fresh (clear checkpoint and any existing loop state)
|
|
259
|
+
*/
|
|
260
|
+
function startFresh(sessionId = 'default') {
|
|
261
|
+
// Clear checkpoint
|
|
262
|
+
clearCheckpoint(sessionId);
|
|
263
|
+
|
|
264
|
+
// Clear loop state
|
|
265
|
+
const statePath = getSessionStatePath();
|
|
266
|
+
const stateResult = safeReadJSON(statePath, { defaultValue: {} });
|
|
267
|
+
const state = stateResult.ok ? stateResult.data : {};
|
|
268
|
+
|
|
269
|
+
if (state.ralph_loop) {
|
|
270
|
+
delete state.ralph_loop;
|
|
271
|
+
safeWriteJSON(statePath, state);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { ok: true, cleared: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get recovery status summary
|
|
279
|
+
*/
|
|
280
|
+
function getRecoveryStatus(sessionId = 'default') {
|
|
281
|
+
const recovery = checkRecoveryNeeded(sessionId);
|
|
282
|
+
const checkpoint = loadCheckpoint(sessionId);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
recoveryNeeded: recovery.needed,
|
|
286
|
+
reason: recovery.reason,
|
|
287
|
+
hasCheckpoint: checkpoint.exists,
|
|
288
|
+
checkpoint: checkpoint.checkpoint,
|
|
289
|
+
options: recovery.recovery_options
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
getCheckpointPath,
|
|
295
|
+
createCheckpoint,
|
|
296
|
+
loadCheckpoint,
|
|
297
|
+
checkRecoveryNeeded,
|
|
298
|
+
resumeFromCheckpoint,
|
|
299
|
+
clearCheckpoint,
|
|
300
|
+
startFresh,
|
|
301
|
+
getRecoveryStatus
|
|
302
|
+
};
|