@kynetic-ai/spec 0.4.0 → 0.6.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 (125) hide show
  1. package/dist/cli/commands/guard.d.ts +43 -0
  2. package/dist/cli/commands/guard.d.ts.map +1 -0
  3. package/dist/cli/commands/guard.js +200 -0
  4. package/dist/cli/commands/guard.js.map +1 -0
  5. package/dist/cli/commands/index.d.ts +1 -0
  6. package/dist/cli/commands/index.d.ts.map +1 -1
  7. package/dist/cli/commands/index.js +1 -0
  8. package/dist/cli/commands/index.js.map +1 -1
  9. package/dist/cli/commands/item.d.ts.map +1 -1
  10. package/dist/cli/commands/item.js +60 -23
  11. package/dist/cli/commands/item.js.map +1 -1
  12. package/dist/cli/commands/plan-import.js +51 -12
  13. package/dist/cli/commands/plan-import.js.map +1 -1
  14. package/dist/cli/commands/ralph.d.ts.map +1 -1
  15. package/dist/cli/commands/ralph.js +144 -329
  16. package/dist/cli/commands/ralph.js.map +1 -1
  17. package/dist/cli/commands/session/checkpoint.d.ts +19 -0
  18. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -0
  19. package/dist/cli/commands/session/checkpoint.js +161 -0
  20. package/dist/cli/commands/session/checkpoint.js.map +1 -0
  21. package/dist/cli/commands/session/commands.d.ts +18 -0
  22. package/dist/cli/commands/session/commands.d.ts.map +1 -0
  23. package/dist/cli/commands/session/commands.js +259 -0
  24. package/dist/cli/commands/session/commands.js.map +1 -0
  25. package/dist/cli/commands/session/context.d.ts +17 -0
  26. package/dist/cli/commands/session/context.d.ts.map +1 -0
  27. package/dist/cli/commands/session/context.js +493 -0
  28. package/dist/cli/commands/session/context.js.map +1 -0
  29. package/dist/cli/commands/session/create.d.ts +29 -0
  30. package/dist/cli/commands/session/create.d.ts.map +1 -0
  31. package/dist/cli/commands/session/create.js +147 -0
  32. package/dist/cli/commands/session/create.js.map +1 -0
  33. package/dist/cli/commands/session/format.d.ts +27 -0
  34. package/dist/cli/commands/session/format.d.ts.map +1 -0
  35. package/dist/cli/commands/session/format.js +401 -0
  36. package/dist/cli/commands/session/format.js.map +1 -0
  37. package/dist/cli/commands/session/index.d.ts +13 -0
  38. package/dist/cli/commands/session/index.d.ts.map +1 -0
  39. package/dist/cli/commands/session/index.js +17 -0
  40. package/dist/cli/commands/session/index.js.map +1 -0
  41. package/dist/cli/commands/session/log.d.ts +52 -0
  42. package/dist/cli/commands/session/log.d.ts.map +1 -0
  43. package/dist/cli/commands/session/log.js +570 -0
  44. package/dist/cli/commands/session/log.js.map +1 -0
  45. package/dist/cli/commands/session/types.d.ts +230 -0
  46. package/dist/cli/commands/session/types.d.ts.map +1 -0
  47. package/dist/cli/commands/session/types.js +7 -0
  48. package/dist/cli/commands/session/types.js.map +1 -0
  49. package/dist/cli/commands/session.d.ts +4 -179
  50. package/dist/cli/commands/session.d.ts.map +1 -1
  51. package/dist/cli/commands/session.js +6 -1424
  52. package/dist/cli/commands/session.js.map +1 -1
  53. package/dist/cli/commands/setup.d.ts.map +1 -1
  54. package/dist/cli/commands/setup.js +69 -223
  55. package/dist/cli/commands/setup.js.map +1 -1
  56. package/dist/cli/commands/task.d.ts.map +1 -1
  57. package/dist/cli/commands/task.js +95 -37
  58. package/dist/cli/commands/task.js.map +1 -1
  59. package/dist/cli/commands/validate.d.ts.map +1 -1
  60. package/dist/cli/commands/validate.js +23 -7
  61. package/dist/cli/commands/validate.js.map +1 -1
  62. package/dist/cli/index.d.ts.map +1 -1
  63. package/dist/cli/index.js +2 -1
  64. package/dist/cli/index.js.map +1 -1
  65. package/dist/cli/output.d.ts.map +1 -1
  66. package/dist/cli/output.js +14 -2
  67. package/dist/cli/output.js.map +1 -1
  68. package/dist/parser/file-lock.d.ts +14 -0
  69. package/dist/parser/file-lock.d.ts.map +1 -0
  70. package/dist/parser/file-lock.js +124 -0
  71. package/dist/parser/file-lock.js.map +1 -0
  72. package/dist/parser/index.d.ts +1 -0
  73. package/dist/parser/index.d.ts.map +1 -1
  74. package/dist/parser/index.js +1 -0
  75. package/dist/parser/index.js.map +1 -1
  76. package/dist/parser/plan-document.d.ts +36 -0
  77. package/dist/parser/plan-document.d.ts.map +1 -1
  78. package/dist/parser/plan-document.js +75 -8
  79. package/dist/parser/plan-document.js.map +1 -1
  80. package/dist/parser/plans.d.ts.map +1 -1
  81. package/dist/parser/plans.js +28 -102
  82. package/dist/parser/plans.js.map +1 -1
  83. package/dist/parser/shadow.d.ts +5 -1
  84. package/dist/parser/shadow.d.ts.map +1 -1
  85. package/dist/parser/shadow.js +29 -17
  86. package/dist/parser/shadow.js.map +1 -1
  87. package/dist/parser/validate.d.ts +4 -1
  88. package/dist/parser/validate.d.ts.map +1 -1
  89. package/dist/parser/validate.js +50 -35
  90. package/dist/parser/validate.js.map +1 -1
  91. package/dist/parser/yaml.d.ts.map +1 -1
  92. package/dist/parser/yaml.js +322 -297
  93. package/dist/parser/yaml.js.map +1 -1
  94. package/dist/schema/task.d.ts +22 -0
  95. package/dist/schema/task.d.ts.map +1 -1
  96. package/dist/schema/task.js +7 -0
  97. package/dist/schema/task.js.map +1 -1
  98. package/dist/sessions/store.d.ts +254 -1
  99. package/dist/sessions/store.d.ts.map +1 -1
  100. package/dist/sessions/store.js +621 -1
  101. package/dist/sessions/store.js.map +1 -1
  102. package/dist/sessions/types.d.ts +51 -2
  103. package/dist/sessions/types.d.ts.map +1 -1
  104. package/dist/sessions/types.js +25 -0
  105. package/dist/sessions/types.js.map +1 -1
  106. package/dist/strings/labels.d.ts +2 -0
  107. package/dist/strings/labels.d.ts.map +1 -1
  108. package/dist/strings/labels.js +2 -0
  109. package/dist/strings/labels.js.map +1 -1
  110. package/dist/utils/git.d.ts +2 -0
  111. package/dist/utils/git.d.ts.map +1 -1
  112. package/dist/utils/git.js +21 -5
  113. package/dist/utils/git.js.map +1 -1
  114. package/package.json +4 -1
  115. package/plugin/.claude-plugin/marketplace.json +1 -1
  116. package/plugin/.claude-plugin/plugin.json +1 -1
  117. package/plugin/plugins/kspec/skills/review/SKILL.md +37 -0
  118. package/plugin/plugins/kspec/skills/task-work/SKILL.md +16 -0
  119. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
  120. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +14 -0
  121. package/templates/agents-sections/05-commit-convention.md +14 -0
  122. package/templates/skills/review/SKILL.md +37 -0
  123. package/templates/skills/task-work/SKILL.md +16 -0
  124. package/templates/skills/triage-inbox/SKILL.md +1 -1
  125. 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, createSession, saveSessionContext, updateSessionStatus, } from "../../sessions/index.js";
20
+ import { appendEvent, closeSession, createSessionWithBudget, getSessionBudgetPath, injectEnvForAdapter, isEndLoopRequested, removeEnvForAdapter, 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.
@@ -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: @ralph-end-loop ac-cmd, ac-reason, ac-noop-outside
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
- // Check if we're in a ralph session by looking for any ralph marker
747
- const taskLimitMarker = await readTaskLimitMarker(ctx.rootDir);
748
- const endLoopMarker = await readEndLoopMarker(ctx.rootDir);
749
- // Write the marker with reason if provided
750
- await writeEndLoopMarker(ctx.rootDir, options.reason);
751
- // Determine if we're likely in a ralph session
752
- const inRalphSession = taskLimitMarker !== null || endLoopMarker !== null;
753
- if (!inRalphSession) {
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
- else {
759
- 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;
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-task-limit ac-flag
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,108 @@ export function registerRalphCommand(program) {
895
727
  const specDir = ctx.specDir;
896
728
  // Create session for event tracking
897
729
  const sessionId = ulid();
898
- 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, {
899
740
  id: sessionId,
900
741
  agent_type: options.adapter,
901
- task_id: undefined, // Will be determined per iteration
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
+ // Adapter ID for harness-specific env injection/cleanup.
745
+ // Declared before try/finally so signal handlers and finally block can access it.
746
+ const adapterId = options.adapter || "claude-agent-acp";
747
+ // Everything after session creation is wrapped in try/finally to guarantee
748
+ // budget cleanup even if pre-loop setup (event logging, signal handlers) throws.
749
+ // AC: @ralph-session-budget-integration ac-session-close-all-paths
918
750
  let consecutiveFailures = 0;
919
751
  let agent = null;
920
752
  let acpSessionId = null;
921
- // AC: @ralph-end-loop ac-signal-cleanup, @ralph-task-limit ac-signal-cleanup
922
- // Signal handlers for cleanup on Ctrl+C or kill
923
- // Note: Signal handlers must be synchronous, so we use Promise.finally()
924
- // to ensure cleanup completes before exit
753
+ let exitReason = null;
754
+ let lastIterationCtx = null;
755
+ let lastErrorMessage;
756
+ let previousEnvValue; // For restoring pre-existing KSPEC_SESSION_ID
757
+ const recentTaskRefs = [];
758
+ const sessionIterationMap = new Map();
759
+ // Signal handler refs — declared here so finally can remove them
760
+ // AC: @ralph-task-limit ac-signal-cleanup
925
761
  const signalCleanup = (signal) => {
926
762
  info(`Received ${signal}, cleaning up...`);
927
- // Kill agent if running
928
763
  if (agent) {
929
764
  agent.kill();
930
765
  }
931
- // Clean up marker files, then exit after cleanup completes
932
- Promise.all([
933
- clearTaskLimitMarker(ctx.rootDir),
934
- clearEndLoopMarker(ctx.rootDir),
935
- ]).finally(() => {
936
- process.exit(0);
937
- });
766
+ // AC: @ralph-session-budget-integration ac-session-close-all-paths
767
+ // Must use async IIFE — signal handlers are called synchronously,
768
+ // but cleanup needs async I/O. The IIFE keeps the event loop alive
769
+ // until cleanup completes, then exits explicitly.
770
+ void (async () => {
771
+ try {
772
+ await Promise.all([
773
+ fs.unlink(getSessionBudgetPath(specDir, sessionId)).catch(() => { }),
774
+ closeSession(specDir, sessionId, "abandoned", `Received ${signal}`),
775
+ removeEnvForAdapter(adapterId, previousEnvValue),
776
+ ]);
777
+ }
778
+ catch {
779
+ // Best-effort cleanup — don't let errors prevent exit
780
+ }
781
+ finally {
782
+ process.exit(0);
783
+ }
784
+ })();
938
785
  };
939
786
  const sigintHandler = () => { signalCleanup("SIGINT"); };
940
787
  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
788
  try {
789
+ // AC: @session-end-loop-signal ac-session-close-signal
790
+ // Install signal handlers FIRST, before any async work, so signals
791
+ // during startup (e.g. during appendEvent) still trigger cleanup.
792
+ // AC: @ralph-session-budget-integration ac-session-close-all-paths
793
+ process.on("SIGINT", sigintHandler);
794
+ process.on("SIGTERM", sigtermHandler);
795
+ // Inject KSPEC_SESSION_ID into agent harness config so it reaches child
796
+ // processes. Inside try/finally so cleanup runs even if injection fails.
797
+ // Process env alone is insufficient — some harnesses (e.g., Claude Code)
798
+ // sandbox child processes and don't forward arbitrary parent env vars.
799
+ // AC: @ralph-session-budget-integration ac-env-inject
800
+ const injectionResult = await injectEnvForAdapter(adapterId, sessionId);
801
+ previousEnvValue = injectionResult?.previousValue;
802
+ // Log session start
803
+ await appendEvent(specDir, {
804
+ session_id: sessionId,
805
+ type: "session.start",
806
+ data: {
807
+ adapter: options.adapter,
808
+ maxLoops,
809
+ maxRetries,
810
+ maxFailures,
811
+ maxTasks,
812
+ yolo: options.yolo,
813
+ focus: options.focus,
814
+ explicitTasks: explicitTaskScope?.refs,
815
+ },
816
+ });
817
+ // Create translator and renderer for this session
818
+ const translator = createTranslator();
819
+ const renderer = createCliRenderer();
965
820
  for (let iteration = 1; iteration <= maxLoops; iteration++) {
966
821
  renderer.newSection?.(`Iteration ${iteration}/${maxLoops}`);
967
- // AC: @ralph-task-limit ac-reset - Reset counter and clear stale markers at iteration start
968
- taskLimitReached = false;
969
- tasksCompletedThisIteration = 0;
970
- const wasStale = await clearStaleMarker(ctx.rootDir);
971
- if (wasStale) {
972
- info("Cleared stale task limit marker from previous session");
973
- }
974
- // Also clear any marker from previous iteration of this session
975
- await clearTaskLimitMarker(ctx.rootDir);
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");
822
+ // AC: @ralph-session-budget-integration ac-reset-iteration
823
+ // Reset budget counter at iteration start (no-op when no budget exists)
824
+ await resetBudget(specDir, sessionId);
825
+ // AC: @session-end-loop-signal ac-detect - Check session state for end-loop
826
+ const endLoopState = await isEndLoopRequested(specDir, sessionId);
827
+ if (endLoopState?.requested) {
828
+ info(`End-loop already requested for this session. Exiting.`);
829
+ exitReason = "end_loop_signal";
830
+ break;
981
831
  }
982
- await clearEndLoopMarker(ctx.rootDir);
983
832
  // Gather fresh context each iteration
984
833
  // AC: @cli-ralph ac-16 - Only automation-eligible tasks (unless explicit scope)
985
834
  // AC: @cli-ralph ac-21 - With explicit task scope, ignore automation eligibility
@@ -1055,7 +904,7 @@ export function registerRalphCommand(program) {
1055
904
  // AC: @cli-ralph ac-21 - Include explicit task scope in prompt
1056
905
  const taskWorkPrompt = buildTaskWorkPrompt(currentCtx, iteration, maxLoops, sessionId, ctx.config.ralph.skills.task_work, options.focus, explicitTaskScope);
1057
906
  const reflectPrompt = buildReflectPrompt(iteration, maxLoops, sessionId, ctx.config.ralph.skills.reflect);
1058
- // AC: @ralph-task-limit ac-dryrun, @cli-ralph ac-21
907
+ // AC: @cli-ralph ac-21
1059
908
  if (options.dryRun) {
1060
909
  console.log(chalk.yellow("=== DRY RUN - Configuration ===\n"));
1061
910
  console.log(` max-loops: ${maxLoops}`);
@@ -1098,8 +947,10 @@ export function registerRalphCommand(program) {
1098
947
  // Spawn agent if not already running
1099
948
  if (!agent) {
1100
949
  info("Spawning ACP agent...");
950
+ // AC: @ralph-session-budget-integration ac-env-inject
1101
951
  agent = await spawnAndInitialize(adapter, {
1102
952
  cwd: process.cwd(),
953
+ env: { KSPEC_SESSION_ID: sessionId },
1103
954
  clientOptions: {
1104
955
  clientInfo: {
1105
956
  name: "kspec-ralph",
@@ -1118,64 +969,6 @@ export function registerRalphCommand(program) {
1118
969
  if (event) {
1119
970
  renderer.render(event);
1120
971
  }
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
972
  // Log raw update event (async, non-blocking)
1180
973
  // Look up iteration by ACP session ID so late updates from
1181
974
  // a previous session are attributed to the correct iteration
@@ -1280,12 +1073,6 @@ export function registerRalphCommand(program) {
1280
1073
  }
1281
1074
  }
1282
1075
  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
1076
  // Periodic agent restart to prevent OOM
1290
1077
  // AC: @cli-ralph ac-restart-periodic
1291
1078
  if (restartEvery > 0 &&
@@ -1323,6 +1110,13 @@ export function registerRalphCommand(program) {
1323
1110
  exitReason = "max_iterations";
1324
1111
  }
1325
1112
  }
1113
+ catch (loopErr) {
1114
+ // AC: @session-end-loop-signal ac-session-close-error
1115
+ // Unrecoverable error during loop execution
1116
+ exitReason = exitReason ?? "error";
1117
+ lastErrorMessage = loopErr.message;
1118
+ error("Unrecoverable error in ralph loop", loopErr);
1119
+ }
1326
1120
  finally {
1327
1121
  // Remove signal handlers to avoid double cleanup
1328
1122
  process.off("SIGINT", sigintHandler);
@@ -1332,10 +1126,13 @@ export function registerRalphCommand(program) {
1332
1126
  agent.kill();
1333
1127
  agent = null;
1334
1128
  }
1335
- // AC: @ralph-task-limit ac-reset - Clear marker file when session ends
1336
- await clearTaskLimitMarker(ctx.rootDir);
1337
- // AC: @ralph-end-loop ac-cleanup - Clear end-loop marker when session ends
1338
- await clearEndLoopMarker(ctx.rootDir);
1129
+ // AC: @ralph-session-budget-integration ac-session-close-all-paths
1130
+ // Clean up budget file and harness env injection when session ends
1131
+ await fs.unlink(getSessionBudgetPath(specDir, sessionId)).catch(() => { });
1132
+ await removeEnvForAdapter(adapterId, previousEnvValue);
1133
+ // Clean up session env vars
1134
+ delete process.env.KSPEC_RALPH_SESSION;
1135
+ delete process.env.KSPEC_SESSION_ID;
1339
1136
  // AC: @ralph-wrap-up-agent-on-loop-exit ac-1, ac-2, ac-3, ac-4, ac-5
1340
1137
  // Spawn wrap-up agent if not dry-run and we have an exit reason
1341
1138
  if (!options.dryRun && exitReason) {
@@ -1382,8 +1179,25 @@ export function registerRalphCommand(program) {
1382
1179
  console.log(chalk.cyan(`${"═".repeat(60)}`));
1383
1180
  console.log("");
1384
1181
  }
1385
- // Log session end
1386
- const status = consecutiveFailures >= maxFailures ? "abandoned" : "completed";
1182
+ // Log session end and close session with appropriate status/reason
1183
+ // AC: @session-end-loop-signal ac-session-close-normal, ac-session-close-error
1184
+ const isErrorExit = consecutiveFailures >= maxFailures ||
1185
+ exitReason === "max_failures" ||
1186
+ exitReason === "error";
1187
+ const status = isErrorExit ? "abandoned" : "completed";
1188
+ const closeReason = exitReason === "max_failures"
1189
+ ? `Max failures reached (${consecutiveFailures}/${maxFailures})${lastErrorMessage ? `: ${lastErrorMessage}` : ""}`
1190
+ : exitReason === "error"
1191
+ ? `Unrecoverable error${lastErrorMessage ? `: ${lastErrorMessage}` : ""}`
1192
+ : exitReason === "end_loop_signal"
1193
+ ? "Agent requested end of loop"
1194
+ : exitReason === "max_iterations"
1195
+ ? `Completed all ${maxLoops} iterations`
1196
+ : exitReason === "no_tasks"
1197
+ ? "No eligible tasks remaining"
1198
+ : exitReason === "explicit_tasks_done"
1199
+ ? "All explicit tasks completed"
1200
+ : `Loop ended: ${exitReason}`;
1387
1201
  await appendEvent(specDir, {
1388
1202
  session_id: sessionId,
1389
1203
  type: "session.end",
@@ -1391,9 +1205,10 @@ export function registerRalphCommand(program) {
1391
1205
  status,
1392
1206
  consecutiveFailures,
1393
1207
  exitReason,
1208
+ closeReason,
1394
1209
  },
1395
1210
  });
1396
- await updateSessionStatus(specDir, sessionId, status);
1211
+ await closeSession(specDir, sessionId, status, closeReason);
1397
1212
  }
1398
1213
  console.log(chalk.green(`\n${"─".repeat(60)}`));
1399
1214
  success("Ralph loop completed");