@kynetic-ai/spec 0.3.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.
Files changed (160) hide show
  1. package/dist/cli/batch-exec.d.ts +0 -9
  2. package/dist/cli/batch-exec.d.ts.map +1 -1
  3. package/dist/cli/batch-exec.js +16 -4
  4. package/dist/cli/batch-exec.js.map +1 -1
  5. package/dist/cli/commands/derive.d.ts.map +1 -1
  6. package/dist/cli/commands/derive.js +2 -1
  7. package/dist/cli/commands/derive.js.map +1 -1
  8. package/dist/cli/commands/guard.d.ts +43 -0
  9. package/dist/cli/commands/guard.d.ts.map +1 -0
  10. package/dist/cli/commands/guard.js +200 -0
  11. package/dist/cli/commands/guard.js.map +1 -0
  12. package/dist/cli/commands/index.d.ts +1 -0
  13. package/dist/cli/commands/index.d.ts.map +1 -1
  14. package/dist/cli/commands/index.js +1 -0
  15. package/dist/cli/commands/index.js.map +1 -1
  16. package/dist/cli/commands/item.d.ts.map +1 -1
  17. package/dist/cli/commands/item.js +18 -0
  18. package/dist/cli/commands/item.js.map +1 -1
  19. package/dist/cli/commands/log.d.ts.map +1 -1
  20. package/dist/cli/commands/log.js +5 -4
  21. package/dist/cli/commands/log.js.map +1 -1
  22. package/dist/cli/commands/meta.d.ts.map +1 -1
  23. package/dist/cli/commands/meta.js +2 -1
  24. package/dist/cli/commands/meta.js.map +1 -1
  25. package/dist/cli/commands/plan-import.d.ts.map +1 -1
  26. package/dist/cli/commands/plan-import.js +100 -30
  27. package/dist/cli/commands/plan-import.js.map +1 -1
  28. package/dist/cli/commands/ralph.d.ts.map +1 -1
  29. package/dist/cli/commands/ralph.js +143 -330
  30. package/dist/cli/commands/ralph.js.map +1 -1
  31. package/dist/cli/commands/session.d.ts +73 -1
  32. package/dist/cli/commands/session.d.ts.map +1 -1
  33. package/dist/cli/commands/session.js +607 -162
  34. package/dist/cli/commands/session.js.map +1 -1
  35. package/dist/cli/commands/setup.d.ts.map +1 -1
  36. package/dist/cli/commands/setup.js +97 -217
  37. package/dist/cli/commands/setup.js.map +1 -1
  38. package/dist/cli/commands/skill-install.d.ts +4 -1
  39. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  40. package/dist/cli/commands/skill-install.js +62 -5
  41. package/dist/cli/commands/skill-install.js.map +1 -1
  42. package/dist/cli/commands/task.d.ts.map +1 -1
  43. package/dist/cli/commands/task.js +128 -59
  44. package/dist/cli/commands/task.js.map +1 -1
  45. package/dist/cli/commands/tasks.d.ts.map +1 -1
  46. package/dist/cli/commands/tasks.js +2 -4
  47. package/dist/cli/commands/tasks.js.map +1 -1
  48. package/dist/cli/commands/triage.d.ts.map +1 -1
  49. package/dist/cli/commands/triage.js +12 -98
  50. package/dist/cli/commands/triage.js.map +1 -1
  51. package/dist/cli/index.d.ts.map +1 -1
  52. package/dist/cli/index.js +2 -1
  53. package/dist/cli/index.js.map +1 -1
  54. package/dist/cli/output.d.ts.map +1 -1
  55. package/dist/cli/output.js +18 -4
  56. package/dist/cli/output.js.map +1 -1
  57. package/dist/daemon/routes/triage.ts +4 -70
  58. package/dist/parser/config.d.ts +106 -0
  59. package/dist/parser/config.d.ts.map +1 -1
  60. package/dist/parser/config.js +47 -0
  61. package/dist/parser/config.js.map +1 -1
  62. package/dist/parser/file-lock.d.ts +14 -0
  63. package/dist/parser/file-lock.d.ts.map +1 -0
  64. package/dist/parser/file-lock.js +124 -0
  65. package/dist/parser/file-lock.js.map +1 -0
  66. package/dist/parser/index.d.ts +1 -0
  67. package/dist/parser/index.d.ts.map +1 -1
  68. package/dist/parser/index.js +1 -0
  69. package/dist/parser/index.js.map +1 -1
  70. package/dist/parser/plan-document.d.ts +44 -0
  71. package/dist/parser/plan-document.d.ts.map +1 -1
  72. package/dist/parser/plan-document.js +76 -8
  73. package/dist/parser/plan-document.js.map +1 -1
  74. package/dist/parser/plans.d.ts.map +1 -1
  75. package/dist/parser/plans.js +28 -102
  76. package/dist/parser/plans.js.map +1 -1
  77. package/dist/parser/shadow.d.ts.map +1 -1
  78. package/dist/parser/shadow.js +11 -7
  79. package/dist/parser/shadow.js.map +1 -1
  80. package/dist/parser/yaml.d.ts.map +1 -1
  81. package/dist/parser/yaml.js +322 -297
  82. package/dist/parser/yaml.js.map +1 -1
  83. package/dist/ralph/events.d.ts.map +1 -1
  84. package/dist/ralph/events.js +24 -0
  85. package/dist/ralph/events.js.map +1 -1
  86. package/dist/ralph/index.d.ts +1 -1
  87. package/dist/ralph/index.d.ts.map +1 -1
  88. package/dist/ralph/index.js +1 -1
  89. package/dist/ralph/index.js.map +1 -1
  90. package/dist/ralph/subagent.d.ts +12 -1
  91. package/dist/ralph/subagent.d.ts.map +1 -1
  92. package/dist/ralph/subagent.js +22 -3
  93. package/dist/ralph/subagent.js.map +1 -1
  94. package/dist/schema/batch.d.ts +2 -0
  95. package/dist/schema/batch.d.ts.map +1 -1
  96. package/dist/schema/common.d.ts +6 -0
  97. package/dist/schema/common.d.ts.map +1 -1
  98. package/dist/schema/common.js +8 -0
  99. package/dist/schema/common.js.map +1 -1
  100. package/dist/schema/task.d.ts +22 -0
  101. package/dist/schema/task.d.ts.map +1 -1
  102. package/dist/schema/task.js +7 -0
  103. package/dist/schema/task.js.map +1 -1
  104. package/dist/sessions/store.d.ts +226 -1
  105. package/dist/sessions/store.d.ts.map +1 -1
  106. package/dist/sessions/store.js +712 -38
  107. package/dist/sessions/store.js.map +1 -1
  108. package/dist/sessions/types.d.ts +51 -2
  109. package/dist/sessions/types.d.ts.map +1 -1
  110. package/dist/sessions/types.js +25 -0
  111. package/dist/sessions/types.js.map +1 -1
  112. package/dist/strings/errors.d.ts +4 -0
  113. package/dist/strings/errors.d.ts.map +1 -1
  114. package/dist/strings/errors.js +2 -0
  115. package/dist/strings/errors.js.map +1 -1
  116. package/dist/strings/labels.d.ts +2 -0
  117. package/dist/strings/labels.d.ts.map +1 -1
  118. package/dist/strings/labels.js +2 -0
  119. package/dist/strings/labels.js.map +1 -1
  120. package/dist/triage/actions.d.ts +27 -0
  121. package/dist/triage/actions.d.ts.map +1 -0
  122. package/dist/triage/actions.js +95 -0
  123. package/dist/triage/actions.js.map +1 -0
  124. package/dist/triage/constants.d.ts +6 -0
  125. package/dist/triage/constants.d.ts.map +1 -0
  126. package/dist/triage/constants.js +7 -0
  127. package/dist/triage/constants.js.map +1 -0
  128. package/dist/triage/index.d.ts +3 -0
  129. package/dist/triage/index.d.ts.map +1 -0
  130. package/dist/triage/index.js +3 -0
  131. package/dist/triage/index.js.map +1 -0
  132. package/dist/utils/git.d.ts +2 -0
  133. package/dist/utils/git.d.ts.map +1 -1
  134. package/dist/utils/git.js +21 -5
  135. package/dist/utils/git.js.map +1 -1
  136. package/package.json +1 -1
  137. package/plugin/.claude-plugin/marketplace.json +1 -1
  138. package/plugin/.claude-plugin/plugin.json +1 -1
  139. package/plugin/plugins/kspec/skills/create-workflow/SKILL.md +235 -0
  140. package/plugin/plugins/kspec/skills/observations/SKILL.md +143 -0
  141. package/plugin/plugins/kspec/skills/plan/SKILL.md +343 -0
  142. package/plugin/plugins/kspec/skills/reflect/SKILL.md +161 -0
  143. package/plugin/plugins/kspec/skills/review/SKILL.md +230 -0
  144. package/plugin/plugins/kspec/skills/task-work/SKILL.md +319 -0
  145. package/plugin/plugins/kspec/skills/triage-automation/SKILL.md +140 -0
  146. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +232 -0
  147. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +354 -0
  148. package/templates/agents-sections/03-task-lifecycle.md +2 -2
  149. package/templates/agents-sections/04-pr-workflow.md +3 -3
  150. package/templates/agents-sections/05-commit-convention.md +14 -0
  151. package/templates/skills/create-workflow/SKILL.md +228 -0
  152. package/templates/skills/manifest.yaml +45 -0
  153. package/templates/skills/observations/SKILL.md +137 -0
  154. package/templates/skills/plan/SKILL.md +336 -0
  155. package/templates/skills/reflect/SKILL.md +155 -0
  156. package/templates/skills/review/SKILL.md +223 -0
  157. package/templates/skills/task-work/SKILL.md +312 -0
  158. package/templates/skills/triage-automation/SKILL.md +134 -0
  159. package/templates/skills/triage-inbox/SKILL.md +225 -0
  160. package/templates/skills/writing-specs/SKILL.md +347 -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, createSession, saveSessionContext, updateSessionStatus, } from "../../sessions/index.js";
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, getIterationStats, } from "./session.js";
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.
@@ -266,7 +93,7 @@ async function allExplicitTasksDone(ctx, scope) {
266
93
  }
267
94
  // ─── Prompt Template ─────────────────────────────────────────────────────────
268
95
  // AC: @ralph-skill-delegation ac-1, ac-2, ac-3
269
- function buildTaskWorkPrompt(sessionCtx, iteration, maxLoops, sessionId, focus, explicitTaskScope) {
96
+ function buildTaskWorkPrompt(sessionCtx, iteration, maxLoops, sessionId, skillTaskWork, focus, explicitTaskScope) {
270
97
  const focusSection = focus
271
98
  ? `
272
99
  ## Session Focus (applies to ALL iterations)
@@ -306,7 +133,7 @@ ${JSON.stringify(sessionCtx, null, 2)}
306
133
  Run the task-work skill in loop mode:
307
134
 
308
135
  \`\`\`
309
- /task-work loop
136
+ ${skillTaskWork} loop
310
137
  \`\`\`
311
138
 
312
139
  ${modeDescription}
@@ -322,7 +149,7 @@ it checks for remaining eligible tasks at the start of each iteration and exits
322
149
  * Build the reflect prompt sent after task-work completes.
323
150
  * Ralph sends this as a separate prompt to ensure reflection always happens.
324
151
  */
325
- function buildReflectPrompt(iteration, maxLoops, sessionId) {
152
+ function buildReflectPrompt(iteration, maxLoops, sessionId, skillReflect) {
326
153
  const isFinal = iteration === maxLoops;
327
154
  return `# Kspec Automation Session - Reflection
328
155
 
@@ -335,7 +162,7 @@ function buildReflectPrompt(iteration, maxLoops, sessionId) {
335
162
  Run the reflect skill in loop mode:
336
163
 
337
164
  \`\`\`
338
- /reflect loop
165
+ ${skillReflect} loop
339
166
  \`\`\`
340
167
 
341
168
  Loop mode means: high-confidence captures only, must search existing before capturing, no user prompts.
@@ -661,6 +488,7 @@ async function processPendingReviewTasks(ctx, adapter, pendingReviewTasks, optio
661
488
  const result = await runSubagent(adapter, subagentCtx, {
662
489
  timeout: options.subagentTimeout,
663
490
  outputPrefix: DEFAULT_SUBAGENT_PREFIX,
491
+ skillName: ctx.config.ralph.skills.pr_review,
664
492
  }, {
665
493
  yolo: options.yolo,
666
494
  cwd: options.cwd,
@@ -734,7 +562,7 @@ export function registerRalphCommand(program) {
734
562
  .command("ralph")
735
563
  .description("Ralph automated task loop and agent control");
736
564
  // end-loop subcommand - allows agent to signal loop termination
737
- // AC: @ralph-end-loop ac-cmd, ac-reason, ac-noop-outside
565
+ // AC: @session-end-loop-signal ac-signal
738
566
  ralph
739
567
  .command("end-loop")
740
568
  .description("End the ralph loop gracefully (stops all remaining iterations)")
@@ -742,26 +570,31 @@ export function registerRalphCommand(program) {
742
570
  .action(async (options) => {
743
571
  try {
744
572
  const ctx = await initContext();
745
- // Check if we're in a ralph session by looking for any ralph marker
746
- const taskLimitMarker = await readTaskLimitMarker(ctx.rootDir);
747
- const endLoopMarker = await readEndLoopMarker(ctx.rootDir);
748
- // Write the marker with reason if provided
749
- await writeEndLoopMarker(ctx.rootDir, options.reason);
750
- // Determine if we're likely in a ralph session
751
- const inRalphSession = taskLimitMarker !== null || endLoopMarker !== null;
752
- if (!inRalphSession) {
753
- // AC: @ralph-end-loop ac-noop-outside
754
- warn("No active ralph session detected. Marker written but may have no effect.");
755
- 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;
756
581
  }
757
- else {
758
- success("Loop end signal sent");
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;
759
590
  }
591
+ success("Loop end signal sent");
760
592
  if (options.reason) {
761
593
  info(`Reason: ${options.reason}`);
762
594
  }
763
595
  }
764
596
  catch (err) {
597
+ // AC: @trait-error-guidance ac-1
765
598
  error("Failed to signal end-loop", err);
766
599
  process.exit(EXIT_CODES.ERROR);
767
600
  }
@@ -829,7 +662,7 @@ export function registerRalphCommand(program) {
829
662
  error("--restart-every must be a non-negative integer");
830
663
  process.exit(EXIT_CODES.ERROR);
831
664
  }
832
- // AC: @ralph-task-limit ac-flag
665
+ // AC: @ralph-session-budget-integration ac-create-budget
833
666
  const maxTasks = parseInt(options.maxTasks, 10);
834
667
  if (Number.isNaN(maxTasks) || maxTasks < 0 || maxTasks > 999) {
835
668
  error("--max-tasks must be 0 (unlimited) or a positive integer up to 999");
@@ -894,85 +727,96 @@ export function registerRalphCommand(program) {
894
727
  const specDir = ctx.specDir;
895
728
  // Create session for event tracking
896
729
  const sessionId = ulid();
897
- await createSession(specDir, {
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, {
898
740
  id: sessionId,
899
741
  agent_type: options.adapter,
900
- task_id: undefined, // Will be determined per iteration
901
- });
902
- // Log session start
903
- await appendEvent(specDir, {
904
- session_id: sessionId,
905
- type: "session.start",
906
- data: {
907
- adapter: options.adapter,
908
- maxLoops,
909
- maxRetries,
910
- maxFailures,
911
- maxTasks,
912
- yolo: options.yolo,
913
- focus: options.focus,
914
- explicitTasks: explicitTaskScope?.refs,
915
- },
742
+ budget: maxTasks,
916
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
917
747
  let consecutiveFailures = 0;
918
748
  let agent = null;
919
749
  let acpSessionId = null;
920
- // AC: @ralph-end-loop ac-signal-cleanup, @ralph-task-limit ac-signal-cleanup
921
- // Signal handlers for cleanup on Ctrl+C or kill
922
- // Note: Signal handlers must be synchronous, so we use Promise.finally()
923
- // to ensure cleanup completes before exit
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
924
757
  const signalCleanup = (signal) => {
925
758
  info(`Received ${signal}, cleaning up...`);
926
- // Kill agent if running
927
759
  if (agent) {
928
760
  agent.kill();
929
761
  }
930
- // Clean up marker files, then exit after cleanup completes
931
- Promise.all([
932
- clearTaskLimitMarker(ctx.rootDir),
933
- clearEndLoopMarker(ctx.rootDir),
934
- ]).finally(() => {
935
- process.exit(0);
936
- });
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
+ })();
937
780
  };
938
781
  const sigintHandler = () => { signalCleanup("SIGINT"); };
939
782
  const sigtermHandler = () => { signalCleanup("SIGTERM"); };
940
- process.on("SIGINT", sigintHandler);
941
- process.on("SIGTERM", sigtermHandler);
942
- // Create translator and renderer for this session
943
- const translator = createTranslator();
944
- const renderer = createCliRenderer();
945
- // Task limit state - tracks completions per iteration
946
- // AC: @ralph-task-limit ac-reset, ac-wrapup
947
- let taskLimitReached = false;
948
- let tasksCompletedThisIteration = 0;
949
- // End-loop signal state
950
- // AC: @ralph-end-loop ac-detect, ac-graceful
951
- let endLoopRequested = false;
952
- // AC: @ralph-wrap-up-agent-on-loop-exit ac-1 - Track exit reason for wrap-up
953
- let exitReason = null;
954
- let lastIterationCtx = null;
955
- let lastErrorMessage;
956
- const recentTaskRefs = [];
957
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();
958
808
  for (let iteration = 1; iteration <= maxLoops; iteration++) {
959
809
  renderer.newSection?.(`Iteration ${iteration}/${maxLoops}`);
960
- // AC: @ralph-task-limit ac-reset - Reset counter and clear stale markers at iteration start
961
- taskLimitReached = false;
962
- tasksCompletedThisIteration = 0;
963
- const wasStale = await clearStaleMarker(ctx.rootDir);
964
- if (wasStale) {
965
- info("Cleared stale task limit marker from previous session");
966
- }
967
- // Also clear any marker from previous iteration of this session
968
- await clearTaskLimitMarker(ctx.rootDir);
969
- // AC: @ralph-end-loop ac-cleanup - Reset end-loop state
970
- endLoopRequested = false;
971
- const wasStaleEndLoop = await clearStaleEndLoopMarker(ctx.rootDir);
972
- if (wasStaleEndLoop) {
973
- 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;
974
819
  }
975
- await clearEndLoopMarker(ctx.rootDir);
976
820
  // Gather fresh context each iteration
977
821
  // AC: @cli-ralph ac-16 - Only automation-eligible tasks (unless explicit scope)
978
822
  // AC: @cli-ralph ac-21 - With explicit task scope, ignore automation eligibility
@@ -1046,9 +890,9 @@ export function registerRalphCommand(program) {
1046
890
  const iterationStartTime = new Date();
1047
891
  // Build prompts - task-work first, then reflect
1048
892
  // AC: @cli-ralph ac-21 - Include explicit task scope in prompt
1049
- const taskWorkPrompt = buildTaskWorkPrompt(currentCtx, iteration, maxLoops, sessionId, options.focus, explicitTaskScope);
1050
- const reflectPrompt = buildReflectPrompt(iteration, maxLoops, sessionId);
1051
- // AC: @ralph-task-limit ac-dryrun, @cli-ralph ac-21
893
+ const taskWorkPrompt = buildTaskWorkPrompt(currentCtx, iteration, maxLoops, sessionId, ctx.config.ralph.skills.task_work, options.focus, explicitTaskScope);
894
+ const reflectPrompt = buildReflectPrompt(iteration, maxLoops, sessionId, ctx.config.ralph.skills.reflect);
895
+ // AC: @cli-ralph ac-21
1052
896
  if (options.dryRun) {
1053
897
  console.log(chalk.yellow("=== DRY RUN - Configuration ===\n"));
1054
898
  console.log(` max-loops: ${maxLoops}`);
@@ -1091,8 +935,10 @@ export function registerRalphCommand(program) {
1091
935
  // Spawn agent if not already running
1092
936
  if (!agent) {
1093
937
  info("Spawning ACP agent...");
938
+ // AC: @ralph-session-budget-integration ac-env-inject
1094
939
  agent = await spawnAndInitialize(adapter, {
1095
940
  cwd: process.cwd(),
941
+ env: { KSPEC_SESSION_ID: sessionId },
1096
942
  clientOptions: {
1097
943
  clientInfo: {
1098
944
  name: "kspec-ralph",
@@ -1111,69 +957,14 @@ export function registerRalphCommand(program) {
1111
957
  if (event) {
1112
958
  renderer.render(event);
1113
959
  }
1114
- // AC: @ralph-task-limit ac-detection, ac-wrapup
1115
- // Detect task completions for limit enforcement
1116
- if (maxTasks > 0 && !taskLimitReached) {
1117
- const bashCmd = extractBashCommand(update);
1118
- if (bashCmd && detectTaskCompleteCommand(bashCmd)) {
1119
- // Pattern matched - verify via kspec query
1120
- getIterationStats(ctx, iterationStartTime)
1121
- .then(async (stats) => {
1122
- if (stats.tasks_completed >= maxTasks && !taskLimitReached) {
1123
- taskLimitReached = true;
1124
- tasksCompletedThisIteration = stats.tasks_completed;
1125
- info(`Task limit reached (${stats.tasks_completed}/${maxTasks})`);
1126
- // AC: @ralph-task-limit ac-marker-format, ac-wrapup
1127
- // Write marker file for hook enforcement
1128
- const marker = {
1129
- active: true,
1130
- since: iterationStartTime.toISOString(),
1131
- max: maxTasks,
1132
- completed: stats.tasks_completed,
1133
- sessionId,
1134
- };
1135
- await writeTaskLimitMarker(ctx.rootDir, marker);
1136
- // Inject wrap-up message to agent
1137
- if (agent && acpSessionId) {
1138
- 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(", ")}`;
1139
- agent.client.prompt({
1140
- sessionId: acpSessionId,
1141
- prompt: [{ type: "text", text: wrapUpMsg }],
1142
- }).catch(() => {
1143
- // Ignore if message injection fails
1144
- });
1145
- }
1146
- }
1147
- })
1148
- .catch(() => {
1149
- // Ignore query failures - detection is best-effort
1150
- });
1151
- }
1152
- }
1153
- // AC: @ralph-end-loop ac-detect
1154
- // Detect explicit end-loop command
1155
- if (!endLoopRequested) {
1156
- const bashCmd = extractBashCommand(update);
1157
- if (bashCmd && detectEndLoopCommand(bashCmd)) {
1158
- endLoopRequested = true;
1159
- // Read marker to get reason if present
1160
- readEndLoopMarker(ctx.rootDir)
1161
- .then((marker) => {
1162
- const reason = marker?.reason
1163
- ? ` (${marker.reason})`
1164
- : "";
1165
- info(`End-loop signal received${reason}`);
1166
- })
1167
- .catch(() => {
1168
- info("End-loop signal received");
1169
- });
1170
- }
1171
- }
1172
960
  // Log raw update event (async, non-blocking)
961
+ // Look up iteration by ACP session ID so late updates from
962
+ // a previous session are attributed to the correct iteration
963
+ const eventIteration = sessionIterationMap.get(_sid) ?? 0;
1173
964
  appendEvent(specDir, {
1174
965
  session_id: sessionId,
1175
966
  type: "session.update",
1176
- data: { iteration, update },
967
+ data: { iteration: eventIteration, update },
1177
968
  }).catch(() => {
1178
969
  // Ignore logging errors during streaming
1179
970
  });
@@ -1193,6 +984,7 @@ export function registerRalphCommand(program) {
1193
984
  cwd: process.cwd(),
1194
985
  mcpServers: [], // No MCP servers for now
1195
986
  });
987
+ sessionIterationMap.set(acpSessionId, iteration);
1196
988
  // Phase 1: Task Work
1197
989
  info("Sending task-work prompt to agent...");
1198
990
  const taskWorkResponse = await agent.client.prompt({
@@ -1269,12 +1061,6 @@ export function registerRalphCommand(program) {
1269
1061
  }
1270
1062
  }
1271
1063
  lastIterationCtx = sessionCtx;
1272
- // AC: @ralph-end-loop ac-graceful - Check for end-loop signal
1273
- if (endLoopRequested) {
1274
- info("Agent requested end of loop. Exiting gracefully.");
1275
- exitReason = "end_loop_signal";
1276
- break;
1277
- }
1278
1064
  // Periodic agent restart to prevent OOM
1279
1065
  // AC: @cli-ralph ac-restart-periodic
1280
1066
  if (restartEvery > 0 &&
@@ -1312,6 +1098,13 @@ export function registerRalphCommand(program) {
1312
1098
  exitReason = "max_iterations";
1313
1099
  }
1314
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
+ }
1315
1108
  finally {
1316
1109
  // Remove signal handlers to avoid double cleanup
1317
1110
  process.off("SIGINT", sigintHandler);
@@ -1321,10 +1114,12 @@ export function registerRalphCommand(program) {
1321
1114
  agent.kill();
1322
1115
  agent = null;
1323
1116
  }
1324
- // AC: @ralph-task-limit ac-reset - Clear marker file when session ends
1325
- await clearTaskLimitMarker(ctx.rootDir);
1326
- // AC: @ralph-end-loop ac-cleanup - Clear end-loop marker when session ends
1327
- await clearEndLoopMarker(ctx.rootDir);
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;
1328
1123
  // AC: @ralph-wrap-up-agent-on-loop-exit ac-1, ac-2, ac-3, ac-4, ac-5
1329
1124
  // Spawn wrap-up agent if not dry-run and we have an exit reason
1330
1125
  if (!options.dryRun && exitReason) {
@@ -1371,8 +1166,25 @@ export function registerRalphCommand(program) {
1371
1166
  console.log(chalk.cyan(`${"═".repeat(60)}`));
1372
1167
  console.log("");
1373
1168
  }
1374
- // Log session end
1375
- const status = consecutiveFailures >= maxFailures ? "abandoned" : "completed";
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}`;
1376
1188
  await appendEvent(specDir, {
1377
1189
  session_id: sessionId,
1378
1190
  type: "session.end",
@@ -1380,9 +1192,10 @@ export function registerRalphCommand(program) {
1380
1192
  status,
1381
1193
  consecutiveFailures,
1382
1194
  exitReason,
1195
+ closeReason,
1383
1196
  },
1384
1197
  });
1385
- await updateSessionStatus(specDir, sessionId, status);
1198
+ await closeSession(specDir, sessionId, status, closeReason);
1386
1199
  }
1387
1200
  console.log(chalk.green(`\n${"─".repeat(60)}`));
1388
1201
  success("Ralph loop completed");