@kynetic-ai/spec 0.4.0 → 0.5.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/dist/cli/commands/guard.d.ts +43 -0
- package/dist/cli/commands/guard.d.ts.map +1 -0
- package/dist/cli/commands/guard.js +200 -0
- package/dist/cli/commands/guard.js.map +1 -0
- package/dist/cli/commands/index.d.ts +1 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/item.d.ts.map +1 -1
- package/dist/cli/commands/item.js +18 -0
- package/dist/cli/commands/item.js.map +1 -1
- package/dist/cli/commands/plan-import.js +51 -12
- package/dist/cli/commands/plan-import.js.map +1 -1
- package/dist/cli/commands/ralph.d.ts.map +1 -1
- package/dist/cli/commands/ralph.js +131 -329
- package/dist/cli/commands/ralph.js.map +1 -1
- package/dist/cli/commands/session.d.ts +73 -1
- package/dist/cli/commands/session.d.ts.map +1 -1
- package/dist/cli/commands/session.js +603 -157
- package/dist/cli/commands/session.js.map +1 -1
- package/dist/cli/commands/setup.d.ts.map +1 -1
- package/dist/cli/commands/setup.js +69 -223
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/task.d.ts.map +1 -1
- package/dist/cli/commands/task.js +95 -37
- package/dist/cli/commands/task.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +14 -2
- package/dist/cli/output.js.map +1 -1
- package/dist/parser/file-lock.d.ts +14 -0
- package/dist/parser/file-lock.d.ts.map +1 -0
- package/dist/parser/file-lock.js +124 -0
- package/dist/parser/file-lock.js.map +1 -0
- package/dist/parser/index.d.ts +1 -0
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +1 -0
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/plan-document.d.ts +36 -0
- package/dist/parser/plan-document.d.ts.map +1 -1
- package/dist/parser/plan-document.js +75 -8
- package/dist/parser/plan-document.js.map +1 -1
- package/dist/parser/plans.d.ts.map +1 -1
- package/dist/parser/plans.js +28 -102
- package/dist/parser/plans.js.map +1 -1
- package/dist/parser/shadow.d.ts.map +1 -1
- package/dist/parser/shadow.js +11 -7
- package/dist/parser/shadow.js.map +1 -1
- package/dist/parser/yaml.d.ts.map +1 -1
- package/dist/parser/yaml.js +322 -297
- package/dist/parser/yaml.js.map +1 -1
- package/dist/schema/task.d.ts +22 -0
- package/dist/schema/task.d.ts.map +1 -1
- package/dist/schema/task.js +7 -0
- package/dist/schema/task.js.map +1 -1
- package/dist/sessions/store.d.ts +218 -1
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +493 -1
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/types.d.ts +51 -2
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/sessions/types.js +25 -0
- package/dist/sessions/types.js.map +1 -1
- package/dist/strings/labels.d.ts +2 -0
- package/dist/strings/labels.d.ts.map +1 -1
- package/dist/strings/labels.js +2 -0
- package/dist/strings/labels.js.map +1 -1
- package/dist/utils/git.d.ts +2 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +21 -5
- package/dist/utils/git.js.map +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/kspec/skills/review/SKILL.md +37 -0
- package/plugin/plugins/kspec/skills/task-work/SKILL.md +16 -0
- package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
- package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +14 -0
- package/templates/agents-sections/05-commit-convention.md +14 -0
- package/templates/skills/review/SKILL.md +37 -0
- package/templates/skills/task-work/SKILL.md +16 -0
- package/templates/skills/triage-inbox/SKILL.md +1 -1
- package/templates/skills/writing-specs/SKILL.md +14 -0
|
@@ -17,185 +17,12 @@ import { registerAdapter, resolveAdapter, } from "../../agents/index.js";
|
|
|
17
17
|
import { spawnAndInitialize } from "../../agents/spawner.js";
|
|
18
18
|
import { initContext, loadAllItems, loadAllTasks, ReferenceIndex, } from "../../parser/index.js";
|
|
19
19
|
import { buildWrapUpContext, createCliRenderer, createTranslator, DEFAULT_SUBAGENT_PREFIX, DEFAULT_WRAPUP_TIMEOUT, RALPH_PROMPT_TIMEOUT, runSubagent, runWrapUpAgent, WRAPUP_AGENT_PREFIX, } from "../../ralph/index.js";
|
|
20
|
-
import { appendEvent,
|
|
20
|
+
import { appendEvent, closeSession, createSessionWithBudget, getSessionBudgetPath, isEndLoopRequested, requestEndLoop, resetBudget, saveSessionContext, } from "../../sessions/index.js";
|
|
21
21
|
import { errors } from "../../strings/index.js";
|
|
22
22
|
import { getCurrentBranch } from "../../utils/git.js";
|
|
23
23
|
import { EXIT_CODES } from "../exit-codes.js";
|
|
24
24
|
import { error, info, success, warn } from "../output.js";
|
|
25
|
-
import { gatherSessionContext,
|
|
26
|
-
const TASK_LIMIT_MARKER_PATH = ".claude/ralph-task-limit.json";
|
|
27
|
-
const END_LOOP_MARKER_PATH = ".claude/ralph-end-loop.json";
|
|
28
|
-
const STALE_MARKER_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
|
|
29
|
-
/**
|
|
30
|
-
* Write task limit marker file.
|
|
31
|
-
* AC: @ralph-task-limit ac-wrapup, ac-marker-format
|
|
32
|
-
*/
|
|
33
|
-
async function writeTaskLimitMarker(rootDir, marker) {
|
|
34
|
-
const markerPath = path.join(rootDir, TASK_LIMIT_MARKER_PATH);
|
|
35
|
-
const dir = path.dirname(markerPath);
|
|
36
|
-
await fs.mkdir(dir, { recursive: true });
|
|
37
|
-
await fs.writeFile(markerPath, JSON.stringify(marker, null, 2));
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Read task limit marker file if it exists.
|
|
41
|
-
*/
|
|
42
|
-
async function readTaskLimitMarker(rootDir) {
|
|
43
|
-
const markerPath = path.join(rootDir, TASK_LIMIT_MARKER_PATH);
|
|
44
|
-
try {
|
|
45
|
-
const content = await fs.readFile(markerPath, "utf-8");
|
|
46
|
-
return JSON.parse(content);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Clear task limit marker file.
|
|
54
|
-
* AC: @ralph-task-limit ac-reset
|
|
55
|
-
*/
|
|
56
|
-
async function clearTaskLimitMarker(rootDir) {
|
|
57
|
-
const markerPath = path.join(rootDir, TASK_LIMIT_MARKER_PATH);
|
|
58
|
-
try {
|
|
59
|
-
await fs.unlink(markerPath);
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
// Ignore if file doesn't exist
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Clear stale marker files (older than 1 hour).
|
|
67
|
-
* AC: @ralph-task-limit ac-reset
|
|
68
|
-
*/
|
|
69
|
-
async function clearStaleMarker(rootDir) {
|
|
70
|
-
const marker = await readTaskLimitMarker(rootDir);
|
|
71
|
-
if (!marker)
|
|
72
|
-
return false;
|
|
73
|
-
const markerAge = Date.now() - new Date(marker.since).getTime();
|
|
74
|
-
if (markerAge > STALE_MARKER_THRESHOLD_MS) {
|
|
75
|
-
await clearTaskLimitMarker(rootDir);
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Write end-loop marker file.
|
|
82
|
-
* AC: @ralph-end-loop ac-cmd
|
|
83
|
-
*/
|
|
84
|
-
async function writeEndLoopMarker(rootDir, reason) {
|
|
85
|
-
const markerPath = path.join(rootDir, END_LOOP_MARKER_PATH);
|
|
86
|
-
const dir = path.dirname(markerPath);
|
|
87
|
-
await fs.mkdir(dir, { recursive: true });
|
|
88
|
-
const marker = {
|
|
89
|
-
requested: true,
|
|
90
|
-
timestamp: new Date().toISOString(),
|
|
91
|
-
reason,
|
|
92
|
-
};
|
|
93
|
-
await fs.writeFile(markerPath, JSON.stringify(marker, null, 2));
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Read end-loop marker file if it exists.
|
|
97
|
-
* AC: @ralph-end-loop ac-detect
|
|
98
|
-
*/
|
|
99
|
-
async function readEndLoopMarker(rootDir) {
|
|
100
|
-
const markerPath = path.join(rootDir, END_LOOP_MARKER_PATH);
|
|
101
|
-
try {
|
|
102
|
-
const content = await fs.readFile(markerPath, "utf-8");
|
|
103
|
-
return JSON.parse(content);
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Clear end-loop marker file.
|
|
111
|
-
* AC: @ralph-end-loop ac-cleanup
|
|
112
|
-
*/
|
|
113
|
-
async function clearEndLoopMarker(rootDir) {
|
|
114
|
-
const markerPath = path.join(rootDir, END_LOOP_MARKER_PATH);
|
|
115
|
-
try {
|
|
116
|
-
await fs.unlink(markerPath);
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// Ignore if file doesn't exist
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Clear stale end-loop markers (older than 1 hour).
|
|
124
|
-
* AC: @ralph-end-loop ac-cleanup
|
|
125
|
-
*/
|
|
126
|
-
async function clearStaleEndLoopMarker(rootDir) {
|
|
127
|
-
const marker = await readEndLoopMarker(rootDir);
|
|
128
|
-
if (!marker)
|
|
129
|
-
return false;
|
|
130
|
-
const markerAge = Date.now() - new Date(marker.timestamp).getTime();
|
|
131
|
-
if (markerAge > STALE_MARKER_THRESHOLD_MS) {
|
|
132
|
-
await clearEndLoopMarker(rootDir);
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* Detect if a Bash command is a task complete command.
|
|
139
|
-
* AC: @ralph-task-limit ac-detection
|
|
140
|
-
*/
|
|
141
|
-
function detectTaskCompleteCommand(command) {
|
|
142
|
-
// Match variations of "kspec task complete"
|
|
143
|
-
// Don't match "kspec task submit" - that's just status change to pending_review
|
|
144
|
-
return /\bkspec\s+task\s+complete\b/.test(command);
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Detect if a Bash command is an end-loop command.
|
|
148
|
-
* AC: @ralph-end-loop ac-detect
|
|
149
|
-
*/
|
|
150
|
-
function detectEndLoopCommand(command) {
|
|
151
|
-
// Match "kspec ralph end-loop" with any arguments
|
|
152
|
-
return /\bkspec\s+ralph\s+end-loop\b/.test(command);
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Extract Bash command from SessionUpdate if it's a tool_call or tool_call_update event.
|
|
156
|
-
* Returns null if not a Bash tool call.
|
|
157
|
-
*/
|
|
158
|
-
function extractBashCommand(update) {
|
|
159
|
-
const u = update;
|
|
160
|
-
// Check if this is a tool call event
|
|
161
|
-
if (u.sessionUpdate !== "tool_call" && u.sessionUpdate !== "tool_call_update") {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
// Extract tool name - check various locations Claude Code uses
|
|
165
|
-
let toolName;
|
|
166
|
-
// Try _meta.claudeCode.toolName first (Claude Code pattern)
|
|
167
|
-
const meta = u._meta;
|
|
168
|
-
if (meta) {
|
|
169
|
-
const claudeCode = meta.claudeCode;
|
|
170
|
-
if (claudeCode?.toolName) {
|
|
171
|
-
toolName = String(claudeCode.toolName);
|
|
172
|
-
}
|
|
173
|
-
else if (meta.toolName) {
|
|
174
|
-
toolName = String(meta.toolName);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
// Fall back to name or title field
|
|
178
|
-
if (!toolName && u.name) {
|
|
179
|
-
toolName = String(u.name);
|
|
180
|
-
}
|
|
181
|
-
if (!toolName && u.title) {
|
|
182
|
-
toolName = String(u.title);
|
|
183
|
-
}
|
|
184
|
-
// Check if it's a Bash tool (handle MCP prefix variations)
|
|
185
|
-
if (!toolName)
|
|
186
|
-
return null;
|
|
187
|
-
const isBash = toolName === "Bash" || toolName.endsWith("__Bash");
|
|
188
|
-
if (!isBash)
|
|
189
|
-
return null;
|
|
190
|
-
// Extract command from input
|
|
191
|
-
const input = (u.rawInput || u.input || u.params);
|
|
192
|
-
if (!input)
|
|
193
|
-
return null;
|
|
194
|
-
const command = input.command;
|
|
195
|
-
if (typeof command !== "string")
|
|
196
|
-
return null;
|
|
197
|
-
return command;
|
|
198
|
-
}
|
|
25
|
+
import { gatherSessionContext, } from "./session.js";
|
|
199
26
|
/**
|
|
200
27
|
* Parse and validate --tasks flag value.
|
|
201
28
|
* Returns resolved ULIDs for the specified task refs.
|
|
@@ -735,7 +562,7 @@ export function registerRalphCommand(program) {
|
|
|
735
562
|
.command("ralph")
|
|
736
563
|
.description("Ralph automated task loop and agent control");
|
|
737
564
|
// end-loop subcommand - allows agent to signal loop termination
|
|
738
|
-
// AC: @
|
|
565
|
+
// AC: @session-end-loop-signal ac-signal
|
|
739
566
|
ralph
|
|
740
567
|
.command("end-loop")
|
|
741
568
|
.description("End the ralph loop gracefully (stops all remaining iterations)")
|
|
@@ -743,26 +570,31 @@ export function registerRalphCommand(program) {
|
|
|
743
570
|
.action(async (options) => {
|
|
744
571
|
try {
|
|
745
572
|
const ctx = await initContext();
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
// AC: @ralph-end-loop ac-noop-outside
|
|
755
|
-
warn("No active ralph session detected. Marker written but may have no effect.");
|
|
756
|
-
info("This command is designed to be called by agents during a ralph loop.");
|
|
573
|
+
const sessionId = process.env.KSPEC_SESSION_ID;
|
|
574
|
+
if (!sessionId) {
|
|
575
|
+
// AC: @trait-error-guidance ac-1, ac-2
|
|
576
|
+
warn("No active ralph session detected (KSPEC_SESSION_ID not set).");
|
|
577
|
+
info("This command requires an active session. It is designed to be called by agents during a ralph loop.");
|
|
578
|
+
info("Suggestion: Ensure KSPEC_SESSION_ID is set, or start a session with: kspec session create --agent-type ralph");
|
|
579
|
+
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
580
|
+
return;
|
|
757
581
|
}
|
|
758
|
-
|
|
759
|
-
|
|
582
|
+
// AC: @session-end-loop-signal ac-signal - Write end-loop state to session
|
|
583
|
+
const updated = await requestEndLoop(ctx.specDir, sessionId, options.reason);
|
|
584
|
+
if (!updated) {
|
|
585
|
+
// AC: @trait-error-guidance ac-1, ac-2
|
|
586
|
+
error(`Session not found: ${sessionId}`);
|
|
587
|
+
info("Suggestion: Check session ID with: kspec session log list");
|
|
588
|
+
process.exit(EXIT_CODES.NOT_FOUND);
|
|
589
|
+
return;
|
|
760
590
|
}
|
|
591
|
+
success("Loop end signal sent");
|
|
761
592
|
if (options.reason) {
|
|
762
593
|
info(`Reason: ${options.reason}`);
|
|
763
594
|
}
|
|
764
595
|
}
|
|
765
596
|
catch (err) {
|
|
597
|
+
// AC: @trait-error-guidance ac-1
|
|
766
598
|
error("Failed to signal end-loop", err);
|
|
767
599
|
process.exit(EXIT_CODES.ERROR);
|
|
768
600
|
}
|
|
@@ -830,7 +662,7 @@ export function registerRalphCommand(program) {
|
|
|
830
662
|
error("--restart-every must be a non-negative integer");
|
|
831
663
|
process.exit(EXIT_CODES.ERROR);
|
|
832
664
|
}
|
|
833
|
-
// AC: @ralph-
|
|
665
|
+
// AC: @ralph-session-budget-integration ac-create-budget
|
|
834
666
|
const maxTasks = parseInt(options.maxTasks, 10);
|
|
835
667
|
if (Number.isNaN(maxTasks) || maxTasks < 0 || maxTasks > 999) {
|
|
836
668
|
error("--max-tasks must be 0 (unlimited) or a positive integer up to 999");
|
|
@@ -895,91 +727,96 @@ export function registerRalphCommand(program) {
|
|
|
895
727
|
const specDir = ctx.specDir;
|
|
896
728
|
// Create session for event tracking
|
|
897
729
|
const sessionId = ulid();
|
|
898
|
-
|
|
730
|
+
// Set session env vars on this process so all spawned agents
|
|
731
|
+
// (main worker, subagent, wrap-up) inherit them via process.env.
|
|
732
|
+
// KSPEC_RALPH_SESSION: Used by codex skill safety guard to detect ralph context.
|
|
733
|
+
// KSPEC_SESSION_ID: Used by kspec task start for budget enforcement.
|
|
734
|
+
// AC: @ralph-session-budget-integration ac-env-inject
|
|
735
|
+
process.env.KSPEC_RALPH_SESSION = sessionId;
|
|
736
|
+
process.env.KSPEC_SESSION_ID = sessionId;
|
|
737
|
+
// AC: @ralph-session-budget-integration ac-create-budget
|
|
738
|
+
// Create session with budget. When maxTasks=0 (unlimited), no budget.json is created.
|
|
739
|
+
await createSessionWithBudget(specDir, {
|
|
899
740
|
id: sessionId,
|
|
900
741
|
agent_type: options.adapter,
|
|
901
|
-
|
|
902
|
-
});
|
|
903
|
-
// Log session start
|
|
904
|
-
await appendEvent(specDir, {
|
|
905
|
-
session_id: sessionId,
|
|
906
|
-
type: "session.start",
|
|
907
|
-
data: {
|
|
908
|
-
adapter: options.adapter,
|
|
909
|
-
maxLoops,
|
|
910
|
-
maxRetries,
|
|
911
|
-
maxFailures,
|
|
912
|
-
maxTasks,
|
|
913
|
-
yolo: options.yolo,
|
|
914
|
-
focus: options.focus,
|
|
915
|
-
explicitTasks: explicitTaskScope?.refs,
|
|
916
|
-
},
|
|
742
|
+
budget: maxTasks,
|
|
917
743
|
});
|
|
744
|
+
// Everything after session creation is wrapped in try/finally to guarantee
|
|
745
|
+
// budget cleanup even if pre-loop setup (event logging, signal handlers) throws.
|
|
746
|
+
// AC: @ralph-session-budget-integration ac-session-close-all-paths
|
|
918
747
|
let consecutiveFailures = 0;
|
|
919
748
|
let agent = null;
|
|
920
749
|
let acpSessionId = null;
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
750
|
+
let exitReason = null;
|
|
751
|
+
let lastIterationCtx = null;
|
|
752
|
+
let lastErrorMessage;
|
|
753
|
+
const recentTaskRefs = [];
|
|
754
|
+
const sessionIterationMap = new Map();
|
|
755
|
+
// Signal handler refs — declared here so finally can remove them
|
|
756
|
+
// AC: @ralph-task-limit ac-signal-cleanup
|
|
925
757
|
const signalCleanup = (signal) => {
|
|
926
758
|
info(`Received ${signal}, cleaning up...`);
|
|
927
|
-
// Kill agent if running
|
|
928
759
|
if (agent) {
|
|
929
760
|
agent.kill();
|
|
930
761
|
}
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
762
|
+
// AC: @ralph-session-budget-integration ac-session-close-all-paths
|
|
763
|
+
// Must use async IIFE — signal handlers are called synchronously,
|
|
764
|
+
// but cleanup needs async I/O. The IIFE keeps the event loop alive
|
|
765
|
+
// until cleanup completes, then exits explicitly.
|
|
766
|
+
void (async () => {
|
|
767
|
+
try {
|
|
768
|
+
await Promise.all([
|
|
769
|
+
fs.unlink(getSessionBudgetPath(specDir, sessionId)).catch(() => { }),
|
|
770
|
+
closeSession(specDir, sessionId, "abandoned", `Received ${signal}`),
|
|
771
|
+
]);
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
// Best-effort cleanup — don't let errors prevent exit
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
process.exit(0);
|
|
778
|
+
}
|
|
779
|
+
})();
|
|
938
780
|
};
|
|
939
781
|
const sigintHandler = () => { signalCleanup("SIGINT"); };
|
|
940
782
|
const sigtermHandler = () => { signalCleanup("SIGTERM"); };
|
|
941
|
-
process.on("SIGINT", sigintHandler);
|
|
942
|
-
process.on("SIGTERM", sigtermHandler);
|
|
943
|
-
// Create translator and renderer for this session
|
|
944
|
-
const translator = createTranslator();
|
|
945
|
-
const renderer = createCliRenderer();
|
|
946
|
-
// Task limit state - tracks completions per iteration
|
|
947
|
-
// AC: @ralph-task-limit ac-reset, ac-wrapup
|
|
948
|
-
let taskLimitReached = false;
|
|
949
|
-
let tasksCompletedThisIteration = 0;
|
|
950
|
-
// End-loop signal state
|
|
951
|
-
// AC: @ralph-end-loop ac-detect, ac-graceful
|
|
952
|
-
let endLoopRequested = false;
|
|
953
|
-
// AC: @ralph-wrap-up-agent-on-loop-exit ac-1 - Track exit reason for wrap-up
|
|
954
|
-
let exitReason = null;
|
|
955
|
-
let lastIterationCtx = null;
|
|
956
|
-
let lastErrorMessage;
|
|
957
|
-
const recentTaskRefs = [];
|
|
958
|
-
// Map ACP session IDs to their iteration number.
|
|
959
|
-
// The agent's "update" handler persists across iterations and receives
|
|
960
|
-
// the ACP session ID (_sid) on each event. By looking up the iteration
|
|
961
|
-
// from this map, late updates from a previous ACP session are correctly
|
|
962
|
-
// attributed even after the loop has advanced to the next iteration.
|
|
963
|
-
const sessionIterationMap = new Map();
|
|
964
783
|
try {
|
|
784
|
+
// AC: @session-end-loop-signal ac-session-close-signal
|
|
785
|
+
// Install signal handlers FIRST, before any async work, so signals
|
|
786
|
+
// during startup (e.g. during appendEvent) still trigger cleanup.
|
|
787
|
+
// AC: @ralph-session-budget-integration ac-session-close-all-paths
|
|
788
|
+
process.on("SIGINT", sigintHandler);
|
|
789
|
+
process.on("SIGTERM", sigtermHandler);
|
|
790
|
+
// Log session start
|
|
791
|
+
await appendEvent(specDir, {
|
|
792
|
+
session_id: sessionId,
|
|
793
|
+
type: "session.start",
|
|
794
|
+
data: {
|
|
795
|
+
adapter: options.adapter,
|
|
796
|
+
maxLoops,
|
|
797
|
+
maxRetries,
|
|
798
|
+
maxFailures,
|
|
799
|
+
maxTasks,
|
|
800
|
+
yolo: options.yolo,
|
|
801
|
+
focus: options.focus,
|
|
802
|
+
explicitTasks: explicitTaskScope?.refs,
|
|
803
|
+
},
|
|
804
|
+
});
|
|
805
|
+
// Create translator and renderer for this session
|
|
806
|
+
const translator = createTranslator();
|
|
807
|
+
const renderer = createCliRenderer();
|
|
965
808
|
for (let iteration = 1; iteration <= maxLoops; iteration++) {
|
|
966
809
|
renderer.newSection?.(`Iteration ${iteration}/${maxLoops}`);
|
|
967
|
-
// AC: @ralph-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
// AC: @ralph-end-loop ac-cleanup - Reset end-loop state
|
|
977
|
-
endLoopRequested = false;
|
|
978
|
-
const wasStaleEndLoop = await clearStaleEndLoopMarker(ctx.rootDir);
|
|
979
|
-
if (wasStaleEndLoop) {
|
|
980
|
-
info("Cleared stale end-loop marker from previous session");
|
|
810
|
+
// AC: @ralph-session-budget-integration ac-reset-iteration
|
|
811
|
+
// Reset budget counter at iteration start (no-op when no budget exists)
|
|
812
|
+
await resetBudget(specDir, sessionId);
|
|
813
|
+
// AC: @session-end-loop-signal ac-detect - Check session state for end-loop
|
|
814
|
+
const endLoopState = await isEndLoopRequested(specDir, sessionId);
|
|
815
|
+
if (endLoopState?.requested) {
|
|
816
|
+
info(`End-loop already requested for this session. Exiting.`);
|
|
817
|
+
exitReason = "end_loop_signal";
|
|
818
|
+
break;
|
|
981
819
|
}
|
|
982
|
-
await clearEndLoopMarker(ctx.rootDir);
|
|
983
820
|
// Gather fresh context each iteration
|
|
984
821
|
// AC: @cli-ralph ac-16 - Only automation-eligible tasks (unless explicit scope)
|
|
985
822
|
// AC: @cli-ralph ac-21 - With explicit task scope, ignore automation eligibility
|
|
@@ -1055,7 +892,7 @@ export function registerRalphCommand(program) {
|
|
|
1055
892
|
// AC: @cli-ralph ac-21 - Include explicit task scope in prompt
|
|
1056
893
|
const taskWorkPrompt = buildTaskWorkPrompt(currentCtx, iteration, maxLoops, sessionId, ctx.config.ralph.skills.task_work, options.focus, explicitTaskScope);
|
|
1057
894
|
const reflectPrompt = buildReflectPrompt(iteration, maxLoops, sessionId, ctx.config.ralph.skills.reflect);
|
|
1058
|
-
// AC: @
|
|
895
|
+
// AC: @cli-ralph ac-21
|
|
1059
896
|
if (options.dryRun) {
|
|
1060
897
|
console.log(chalk.yellow("=== DRY RUN - Configuration ===\n"));
|
|
1061
898
|
console.log(` max-loops: ${maxLoops}`);
|
|
@@ -1098,8 +935,10 @@ export function registerRalphCommand(program) {
|
|
|
1098
935
|
// Spawn agent if not already running
|
|
1099
936
|
if (!agent) {
|
|
1100
937
|
info("Spawning ACP agent...");
|
|
938
|
+
// AC: @ralph-session-budget-integration ac-env-inject
|
|
1101
939
|
agent = await spawnAndInitialize(adapter, {
|
|
1102
940
|
cwd: process.cwd(),
|
|
941
|
+
env: { KSPEC_SESSION_ID: sessionId },
|
|
1103
942
|
clientOptions: {
|
|
1104
943
|
clientInfo: {
|
|
1105
944
|
name: "kspec-ralph",
|
|
@@ -1118,64 +957,6 @@ export function registerRalphCommand(program) {
|
|
|
1118
957
|
if (event) {
|
|
1119
958
|
renderer.render(event);
|
|
1120
959
|
}
|
|
1121
|
-
// AC: @ralph-task-limit ac-detection, ac-wrapup
|
|
1122
|
-
// Detect task completions for limit enforcement
|
|
1123
|
-
if (maxTasks > 0 && !taskLimitReached) {
|
|
1124
|
-
const bashCmd = extractBashCommand(update);
|
|
1125
|
-
if (bashCmd && detectTaskCompleteCommand(bashCmd)) {
|
|
1126
|
-
// Pattern matched - verify via kspec query
|
|
1127
|
-
getIterationStats(ctx, iterationStartTime)
|
|
1128
|
-
.then(async (stats) => {
|
|
1129
|
-
if (stats.tasks_completed >= maxTasks && !taskLimitReached) {
|
|
1130
|
-
taskLimitReached = true;
|
|
1131
|
-
tasksCompletedThisIteration = stats.tasks_completed;
|
|
1132
|
-
info(`Task limit reached (${stats.tasks_completed}/${maxTasks})`);
|
|
1133
|
-
// AC: @ralph-task-limit ac-marker-format, ac-wrapup
|
|
1134
|
-
// Write marker file for hook enforcement
|
|
1135
|
-
const marker = {
|
|
1136
|
-
active: true,
|
|
1137
|
-
since: iterationStartTime.toISOString(),
|
|
1138
|
-
max: maxTasks,
|
|
1139
|
-
completed: stats.tasks_completed,
|
|
1140
|
-
sessionId,
|
|
1141
|
-
};
|
|
1142
|
-
await writeTaskLimitMarker(ctx.rootDir, marker);
|
|
1143
|
-
// Inject wrap-up message to agent
|
|
1144
|
-
if (agent && acpSessionId) {
|
|
1145
|
-
const wrapUpMsg = `\n\n**TASK LIMIT REACHED** - ${stats.tasks_completed} task(s) completed this iteration (limit: ${maxTasks}).\n\nPlease wrap up your current work and exit cleanly. Do not start new tasks.\n\nCompleted tasks this iteration: ${stats.completed_refs.join(", ")}`;
|
|
1146
|
-
agent.client.prompt({
|
|
1147
|
-
sessionId: acpSessionId,
|
|
1148
|
-
prompt: [{ type: "text", text: wrapUpMsg }],
|
|
1149
|
-
}).catch(() => {
|
|
1150
|
-
// Ignore if message injection fails
|
|
1151
|
-
});
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
})
|
|
1155
|
-
.catch(() => {
|
|
1156
|
-
// Ignore query failures - detection is best-effort
|
|
1157
|
-
});
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
// AC: @ralph-end-loop ac-detect
|
|
1161
|
-
// Detect explicit end-loop command
|
|
1162
|
-
if (!endLoopRequested) {
|
|
1163
|
-
const bashCmd = extractBashCommand(update);
|
|
1164
|
-
if (bashCmd && detectEndLoopCommand(bashCmd)) {
|
|
1165
|
-
endLoopRequested = true;
|
|
1166
|
-
// Read marker to get reason if present
|
|
1167
|
-
readEndLoopMarker(ctx.rootDir)
|
|
1168
|
-
.then((marker) => {
|
|
1169
|
-
const reason = marker?.reason
|
|
1170
|
-
? ` (${marker.reason})`
|
|
1171
|
-
: "";
|
|
1172
|
-
info(`End-loop signal received${reason}`);
|
|
1173
|
-
})
|
|
1174
|
-
.catch(() => {
|
|
1175
|
-
info("End-loop signal received");
|
|
1176
|
-
});
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
960
|
// Log raw update event (async, non-blocking)
|
|
1180
961
|
// Look up iteration by ACP session ID so late updates from
|
|
1181
962
|
// a previous session are attributed to the correct iteration
|
|
@@ -1280,12 +1061,6 @@ export function registerRalphCommand(program) {
|
|
|
1280
1061
|
}
|
|
1281
1062
|
}
|
|
1282
1063
|
lastIterationCtx = sessionCtx;
|
|
1283
|
-
// AC: @ralph-end-loop ac-graceful - Check for end-loop signal
|
|
1284
|
-
if (endLoopRequested) {
|
|
1285
|
-
info("Agent requested end of loop. Exiting gracefully.");
|
|
1286
|
-
exitReason = "end_loop_signal";
|
|
1287
|
-
break;
|
|
1288
|
-
}
|
|
1289
1064
|
// Periodic agent restart to prevent OOM
|
|
1290
1065
|
// AC: @cli-ralph ac-restart-periodic
|
|
1291
1066
|
if (restartEvery > 0 &&
|
|
@@ -1323,6 +1098,13 @@ export function registerRalphCommand(program) {
|
|
|
1323
1098
|
exitReason = "max_iterations";
|
|
1324
1099
|
}
|
|
1325
1100
|
}
|
|
1101
|
+
catch (loopErr) {
|
|
1102
|
+
// AC: @session-end-loop-signal ac-session-close-error
|
|
1103
|
+
// Unrecoverable error during loop execution
|
|
1104
|
+
exitReason = exitReason ?? "error";
|
|
1105
|
+
lastErrorMessage = loopErr.message;
|
|
1106
|
+
error("Unrecoverable error in ralph loop", loopErr);
|
|
1107
|
+
}
|
|
1326
1108
|
finally {
|
|
1327
1109
|
// Remove signal handlers to avoid double cleanup
|
|
1328
1110
|
process.off("SIGINT", sigintHandler);
|
|
@@ -1332,10 +1114,12 @@ export function registerRalphCommand(program) {
|
|
|
1332
1114
|
agent.kill();
|
|
1333
1115
|
agent = null;
|
|
1334
1116
|
}
|
|
1335
|
-
// AC: @ralph-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1117
|
+
// AC: @ralph-session-budget-integration ac-session-close-all-paths
|
|
1118
|
+
// Clean up budget file when session ends
|
|
1119
|
+
await fs.unlink(getSessionBudgetPath(specDir, sessionId)).catch(() => { });
|
|
1120
|
+
// Clean up session env vars
|
|
1121
|
+
delete process.env.KSPEC_RALPH_SESSION;
|
|
1122
|
+
delete process.env.KSPEC_SESSION_ID;
|
|
1339
1123
|
// AC: @ralph-wrap-up-agent-on-loop-exit ac-1, ac-2, ac-3, ac-4, ac-5
|
|
1340
1124
|
// Spawn wrap-up agent if not dry-run and we have an exit reason
|
|
1341
1125
|
if (!options.dryRun && exitReason) {
|
|
@@ -1382,8 +1166,25 @@ export function registerRalphCommand(program) {
|
|
|
1382
1166
|
console.log(chalk.cyan(`${"═".repeat(60)}`));
|
|
1383
1167
|
console.log("");
|
|
1384
1168
|
}
|
|
1385
|
-
// Log session end
|
|
1386
|
-
|
|
1169
|
+
// Log session end and close session with appropriate status/reason
|
|
1170
|
+
// AC: @session-end-loop-signal ac-session-close-normal, ac-session-close-error
|
|
1171
|
+
const isErrorExit = consecutiveFailures >= maxFailures ||
|
|
1172
|
+
exitReason === "max_failures" ||
|
|
1173
|
+
exitReason === "error";
|
|
1174
|
+
const status = isErrorExit ? "abandoned" : "completed";
|
|
1175
|
+
const closeReason = exitReason === "max_failures"
|
|
1176
|
+
? `Max failures reached (${consecutiveFailures}/${maxFailures})${lastErrorMessage ? `: ${lastErrorMessage}` : ""}`
|
|
1177
|
+
: exitReason === "error"
|
|
1178
|
+
? `Unrecoverable error${lastErrorMessage ? `: ${lastErrorMessage}` : ""}`
|
|
1179
|
+
: exitReason === "end_loop_signal"
|
|
1180
|
+
? "Agent requested end of loop"
|
|
1181
|
+
: exitReason === "max_iterations"
|
|
1182
|
+
? `Completed all ${maxLoops} iterations`
|
|
1183
|
+
: exitReason === "no_tasks"
|
|
1184
|
+
? "No eligible tasks remaining"
|
|
1185
|
+
: exitReason === "explicit_tasks_done"
|
|
1186
|
+
? "All explicit tasks completed"
|
|
1187
|
+
: `Loop ended: ${exitReason}`;
|
|
1387
1188
|
await appendEvent(specDir, {
|
|
1388
1189
|
session_id: sessionId,
|
|
1389
1190
|
type: "session.end",
|
|
@@ -1391,9 +1192,10 @@ export function registerRalphCommand(program) {
|
|
|
1391
1192
|
status,
|
|
1392
1193
|
consecutiveFailures,
|
|
1393
1194
|
exitReason,
|
|
1195
|
+
closeReason,
|
|
1394
1196
|
},
|
|
1395
1197
|
});
|
|
1396
|
-
await
|
|
1198
|
+
await closeSession(specDir, sessionId, status, closeReason);
|
|
1397
1199
|
}
|
|
1398
1200
|
console.log(chalk.green(`\n${"─".repeat(60)}`));
|
|
1399
1201
|
success("Ralph loop completed");
|