177 lines
5.1 KiB
JavaScript
177 lines
5.1 KiB
JavaScript
/**
|
|
* Plan-mode state machine (Grok-shaped). No Bare imports — unit-testable on Node.
|
|
*
|
|
* States: inactive | pending | active | exitPending
|
|
* While active, write/shell tools are blocked except the session plan file.
|
|
*/
|
|
|
|
const STATES = ['inactive', 'pending', 'active', 'exitPending'];
|
|
const WRITE_TOOLS = ['write_file', 'search_replace', 'run_terminal_cmd'];
|
|
const PLAN_FILE_NAMES = ['plan.md'];
|
|
|
|
function create(snapshot) {
|
|
const s = snapshot && typeof snapshot === 'object' ? snapshot : {};
|
|
let state = String(s.state || 'inactive');
|
|
if (STATES.indexOf(state) < 0) state = 'inactive';
|
|
if (state === 'pending') state = 'inactive';
|
|
if (state === 'exitPending') state = s.awaitingApproval ? 'active' : 'inactive';
|
|
return {
|
|
state,
|
|
reminderCount: Number(s.reminderCount) || 0,
|
|
planPath: String(s.planPath || 'plan.md'),
|
|
awaitingApproval: !!s.awaitingApproval,
|
|
wasActive: !!s.wasActive,
|
|
pendingExitReminder: !!s.pendingExitReminder,
|
|
};
|
|
}
|
|
|
|
function isActive(pm) {
|
|
if (!pm) return false;
|
|
if (pm === true) return true;
|
|
return pm.state === 'active' || pm.state === 'exitPending';
|
|
}
|
|
|
|
function enterPending(pm) {
|
|
if (!pm) return false;
|
|
if (pm.state === 'inactive') {
|
|
pm.state = 'pending';
|
|
pm.pendingExitReminder = false;
|
|
return true;
|
|
}
|
|
if (pm.state === 'exitPending') {
|
|
pm.state = 'active';
|
|
pm.pendingExitReminder = false;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function activate(pm) {
|
|
if (!pm) return false;
|
|
if (pm.state !== 'pending' && pm.state !== 'inactive') return false;
|
|
pm.state = 'active';
|
|
pm.wasActive = true;
|
|
pm.reminderCount = 0;
|
|
pm.awaitingApproval = false;
|
|
pm.pendingExitReminder = false;
|
|
return true;
|
|
}
|
|
|
|
function requestExit(pm) {
|
|
if (!pm || pm.state !== 'active') return false;
|
|
pm.state = 'exitPending';
|
|
pm.awaitingApproval = true;
|
|
return true;
|
|
}
|
|
|
|
function approveExit(pm) {
|
|
if (!pm) return false;
|
|
if (pm.state !== 'active' && pm.state !== 'exitPending') return false;
|
|
pm.state = 'inactive';
|
|
pm.awaitingApproval = false;
|
|
pm.reminderCount = 0;
|
|
pm.pendingExitReminder = false;
|
|
return true;
|
|
}
|
|
|
|
function rejectExit(pm) {
|
|
if (!pm) return false;
|
|
pm.state = 'active';
|
|
pm.awaitingApproval = false;
|
|
return true;
|
|
}
|
|
|
|
function reminder(pm, opts) {
|
|
opts = opts || {};
|
|
const path = (pm && pm.planPath) || 'plan.md';
|
|
const has = !!opts.hasContent;
|
|
const full = has
|
|
? 'Plan mode is active. Do not make any edits or writes to the system.\n\n' +
|
|
'A plan file exists at ' +
|
|
path +
|
|
'. You can read it and make edits using write_file or search_replace.\n' +
|
|
'This is the only file you are allowed to edit.\n\n' +
|
|
'Your turn should only end with either ask_user_question to clarify requirements or exit_plan_mode to present your plan to the user.'
|
|
: 'Plan mode is active. Do not make any edits or writes to the system.\n\n' +
|
|
'No plan written yet. Write your plan to ' +
|
|
path +
|
|
' using write_file.\n' +
|
|
'This is the only file you are allowed to edit.\n\n' +
|
|
'Your turn should only end with either ask_user_question to clarify requirements or exit_plan_mode to present your plan to the user.';
|
|
const sparse = 'Plan mode is still active. Do not make any edits or writes to the system except for the plan file.';
|
|
const useFull = !pm || pm.reminderCount % 2 === 0;
|
|
if (pm) pm.reminderCount += 1;
|
|
return useFull ? full : sparse;
|
|
}
|
|
|
|
function exitReminder() {
|
|
return 'You have exited plan mode. You can now make edits, run tools, and take actions. Implement the approved plan.';
|
|
}
|
|
|
|
function isWriteTool(name) {
|
|
return WRITE_TOOLS.indexOf(String(name || '')) >= 0;
|
|
}
|
|
|
|
function basename(p) {
|
|
const s = String(p || '').replace(/\\/g, '/');
|
|
const i = s.lastIndexOf('/');
|
|
return i >= 0 ? s.slice(i + 1) : s;
|
|
}
|
|
|
|
function isPlanFilePath(filePath, planPath) {
|
|
const want = basename(planPath || 'plan.md').toLowerCase();
|
|
const got = basename(filePath).toLowerCase();
|
|
if (!got) return false;
|
|
if (got === want) return true;
|
|
return PLAN_FILE_NAMES.indexOf(got) >= 0;
|
|
}
|
|
|
|
function gateWrite(pm, name, args) {
|
|
if (!isActive(pm)) return null;
|
|
if (!isWriteTool(name)) return null;
|
|
if (name === 'run_terminal_cmd') {
|
|
return (
|
|
'Plan mode is active. Shell is blocked. Write only ' +
|
|
((pm && pm.planPath) || 'plan.md') +
|
|
', or call exit_plan_mode.'
|
|
);
|
|
}
|
|
const file = args && (args.path || args.file);
|
|
if (isPlanFilePath(file, pm && pm.planPath)) return null;
|
|
return (
|
|
'Rejected: file edits are not allowed in plan mode - the only editable file is the plan file (' +
|
|
((pm && pm.planPath) || 'plan.md') +
|
|
').'
|
|
);
|
|
}
|
|
|
|
function snapshot(pm) {
|
|
if (!pm) return { state: 'inactive', reminderCount: 0, planPath: 'plan.md', awaitingApproval: false };
|
|
return {
|
|
state: pm.state,
|
|
reminderCount: pm.reminderCount,
|
|
planPath: pm.planPath || 'plan.md',
|
|
awaitingApproval: !!pm.awaitingApproval,
|
|
wasActive: !!pm.wasActive,
|
|
pendingExitReminder: !!pm.pendingExitReminder,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
STATES,
|
|
WRITE_TOOLS,
|
|
create,
|
|
isActive,
|
|
enterPending,
|
|
activate,
|
|
requestExit,
|
|
approveExit,
|
|
rejectExit,
|
|
reminder,
|
|
exitReminder,
|
|
isWriteTool,
|
|
isPlanFilePath,
|
|
gateWrite,
|
|
snapshot,
|
|
};
|