@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.
Files changed (85) 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 +18 -0
  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 +131 -329
  16. package/dist/cli/commands/ralph.js.map +1 -1
  17. package/dist/cli/commands/session.d.ts +73 -1
  18. package/dist/cli/commands/session.d.ts.map +1 -1
  19. package/dist/cli/commands/session.js +603 -157
  20. package/dist/cli/commands/session.js.map +1 -1
  21. package/dist/cli/commands/setup.d.ts.map +1 -1
  22. package/dist/cli/commands/setup.js +69 -223
  23. package/dist/cli/commands/setup.js.map +1 -1
  24. package/dist/cli/commands/task.d.ts.map +1 -1
  25. package/dist/cli/commands/task.js +95 -37
  26. package/dist/cli/commands/task.js.map +1 -1
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +2 -1
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/cli/output.d.ts.map +1 -1
  31. package/dist/cli/output.js +14 -2
  32. package/dist/cli/output.js.map +1 -1
  33. package/dist/parser/file-lock.d.ts +14 -0
  34. package/dist/parser/file-lock.d.ts.map +1 -0
  35. package/dist/parser/file-lock.js +124 -0
  36. package/dist/parser/file-lock.js.map +1 -0
  37. package/dist/parser/index.d.ts +1 -0
  38. package/dist/parser/index.d.ts.map +1 -1
  39. package/dist/parser/index.js +1 -0
  40. package/dist/parser/index.js.map +1 -1
  41. package/dist/parser/plan-document.d.ts +36 -0
  42. package/dist/parser/plan-document.d.ts.map +1 -1
  43. package/dist/parser/plan-document.js +75 -8
  44. package/dist/parser/plan-document.js.map +1 -1
  45. package/dist/parser/plans.d.ts.map +1 -1
  46. package/dist/parser/plans.js +28 -102
  47. package/dist/parser/plans.js.map +1 -1
  48. package/dist/parser/shadow.d.ts.map +1 -1
  49. package/dist/parser/shadow.js +11 -7
  50. package/dist/parser/shadow.js.map +1 -1
  51. package/dist/parser/yaml.d.ts.map +1 -1
  52. package/dist/parser/yaml.js +322 -297
  53. package/dist/parser/yaml.js.map +1 -1
  54. package/dist/schema/task.d.ts +22 -0
  55. package/dist/schema/task.d.ts.map +1 -1
  56. package/dist/schema/task.js +7 -0
  57. package/dist/schema/task.js.map +1 -1
  58. package/dist/sessions/store.d.ts +218 -1
  59. package/dist/sessions/store.d.ts.map +1 -1
  60. package/dist/sessions/store.js +493 -1
  61. package/dist/sessions/store.js.map +1 -1
  62. package/dist/sessions/types.d.ts +51 -2
  63. package/dist/sessions/types.d.ts.map +1 -1
  64. package/dist/sessions/types.js +25 -0
  65. package/dist/sessions/types.js.map +1 -1
  66. package/dist/strings/labels.d.ts +2 -0
  67. package/dist/strings/labels.d.ts.map +1 -1
  68. package/dist/strings/labels.js +2 -0
  69. package/dist/strings/labels.js.map +1 -1
  70. package/dist/utils/git.d.ts +2 -0
  71. package/dist/utils/git.d.ts.map +1 -1
  72. package/dist/utils/git.js +21 -5
  73. package/dist/utils/git.js.map +1 -1
  74. package/package.json +1 -1
  75. package/plugin/.claude-plugin/marketplace.json +1 -1
  76. package/plugin/.claude-plugin/plugin.json +1 -1
  77. package/plugin/plugins/kspec/skills/review/SKILL.md +37 -0
  78. package/plugin/plugins/kspec/skills/task-work/SKILL.md +16 -0
  79. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
  80. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +14 -0
  81. package/templates/agents-sections/05-commit-convention.md +14 -0
  82. package/templates/skills/review/SKILL.md +37 -0
  83. package/templates/skills/task-work/SKILL.md +16 -0
  84. package/templates/skills/triage-inbox/SKILL.md +1 -1
  85. 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, 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.
@@ -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,96 @@ 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
+ // 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
- // 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
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
- // 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
- });
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-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");
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: @ralph-task-limit ac-dryrun, @cli-ralph ac-21
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-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);
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
- 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}`;
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 updateSessionStatus(specDir, sessionId, status);
1198
+ await closeSession(specDir, sessionId, status, closeReason);
1397
1199
  }
1398
1200
  console.log(chalk.green(`\n${"─".repeat(60)}`));
1399
1201
  success("Ralph loop completed");