@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
@@ -15,11 +15,12 @@ import * as fs from "node:fs";
15
15
  import * as fsPromises from "node:fs/promises";
16
16
  import * as path from "node:path";
17
17
  import * as YAML from "yaml";
18
- import { SessionEventSchema, SessionMetadataSchema, } from "./types.js";
18
+ import { SessionEventSchema, SessionMetadataSchema, TaskBudgetSchema, } from "./types.js";
19
19
  // ─── Constants ───────────────────────────────────────────────────────────────
20
20
  const SESSIONS_DIR = "sessions";
21
21
  const METADATA_FILE = "session.yaml";
22
22
  const EVENTS_FILE = "events.jsonl";
23
+ const BUDGET_FILE = "budget.json";
23
24
  // ─── Path Helpers ────────────────────────────────────────────────────────────
24
25
  /**
25
26
  * Get the sessions directory path within a spec directory.
@@ -51,6 +52,13 @@ export function getSessionEventsPath(specDir, sessionId) {
51
52
  export function getSessionContextPath(specDir, sessionId, iteration) {
52
53
  return path.join(getSessionDir(specDir, sessionId), `context-iter-${iteration}.json`);
53
54
  }
55
+ /**
56
+ * Get the path to a session's budget file.
57
+ * AC: @session-creation-and-env-injection ac-budget-local
58
+ */
59
+ export function getSessionBudgetPath(specDir, sessionId) {
60
+ return path.join(getSessionDir(specDir, sessionId), BUDGET_FILE);
61
+ }
54
62
  // ─── Session CRUD ────────────────────────────────────────────────────────────
55
63
  /**
56
64
  * Create a new session with metadata.
@@ -165,6 +173,97 @@ export async function sessionExists(specDir, sessionId) {
165
173
  return false;
166
174
  }
167
175
  }
176
+ // ─── End-Loop Signal ────────────────────────────────────────────────────────
177
+ /**
178
+ * Request end-loop for a session.
179
+ *
180
+ * Writes end_requested=true and optional end_reason to the session metadata.
181
+ * This is the session-scoped replacement for the marker file approach.
182
+ *
183
+ * AC: @session-end-loop-signal ac-signal
184
+ *
185
+ * @param specDir - The .kspec directory path
186
+ * @param sessionId - Session ID
187
+ * @param reason - Optional reason for ending the loop
188
+ * @returns Updated metadata or null if session not found
189
+ */
190
+ export async function requestEndLoop(specDir, sessionId, reason) {
191
+ const metadata = await getSession(specDir, sessionId);
192
+ if (!metadata) {
193
+ return null;
194
+ }
195
+ const updated = {
196
+ ...metadata,
197
+ end_requested: true,
198
+ end_reason: reason,
199
+ };
200
+ const metadataPath = getSessionMetadataPath(specDir, sessionId);
201
+ const content = YAML.stringify(updated, {
202
+ indent: 2,
203
+ lineWidth: 100,
204
+ sortMapEntries: false,
205
+ });
206
+ await fsPromises.writeFile(metadataPath, content, "utf-8");
207
+ return updated;
208
+ }
209
+ /**
210
+ * Check if end-loop has been requested for a session.
211
+ *
212
+ * Only returns requested=true for active sessions. If the session is
213
+ * completed or abandoned, the end-loop signal is no longer relevant
214
+ * (prevents stale KSPEC_SESSION_ID from blocking task starts).
215
+ *
216
+ * AC: @session-end-loop-signal ac-detect
217
+ *
218
+ * @param specDir - The .kspec directory path
219
+ * @param sessionId - Session ID
220
+ * @returns Object with requested flag and optional reason, or null if session not found
221
+ */
222
+ export async function isEndLoopRequested(specDir, sessionId) {
223
+ const metadata = await getSession(specDir, sessionId);
224
+ if (!metadata) {
225
+ return null;
226
+ }
227
+ return {
228
+ requested: metadata.end_requested === true && metadata.status === "active",
229
+ reason: metadata.end_reason,
230
+ };
231
+ }
232
+ /**
233
+ * Close a session with a specific status and reason.
234
+ *
235
+ * Used for all session close paths: normal exit, signal, error.
236
+ *
237
+ * AC: @session-end-loop-signal ac-session-close-normal
238
+ * AC: @session-end-loop-signal ac-session-close-signal
239
+ * AC: @session-end-loop-signal ac-session-close-error
240
+ *
241
+ * @param specDir - The .kspec directory path
242
+ * @param sessionId - Session ID
243
+ * @param status - New status (completed or abandoned)
244
+ * @param reason - Reason for closing
245
+ * @returns Updated metadata or null if session not found
246
+ */
247
+ export async function closeSession(specDir, sessionId, status, reason) {
248
+ const metadata = await getSession(specDir, sessionId);
249
+ if (!metadata) {
250
+ return null;
251
+ }
252
+ const updated = {
253
+ ...metadata,
254
+ status,
255
+ ended_at: new Date().toISOString(),
256
+ close_reason: reason,
257
+ };
258
+ const metadataPath = getSessionMetadataPath(specDir, sessionId);
259
+ const content = YAML.stringify(updated, {
260
+ indent: 2,
261
+ lineWidth: 100,
262
+ sortMapEntries: false,
263
+ });
264
+ await fsPromises.writeFile(metadataPath, content, "utf-8");
265
+ return updated;
266
+ }
168
267
  // ─── Event Storage ───────────────────────────────────────────────────────────
169
268
  /**
170
269
  * Get the current event count for a session (for seq assignment).
@@ -264,6 +363,52 @@ export async function readEvents(specDir, sessionId) {
264
363
  return [];
265
364
  }
266
365
  }
366
+ /**
367
+ * Deduplicate phased tool_call events.
368
+ *
369
+ * ACP SDK 0.14+ sends tool calls in two phases: first with empty rawInput,
370
+ * then with populated rawInput. This merges them by keeping only the version
371
+ * with populated rawInput per toolCallId.
372
+ */
373
+ export function deduplicatePhasedToolCalls(events) {
374
+ // First pass: find toolCallIds that have a populated rawInput version
375
+ const populatedToolCalls = new Map(); // toolCallId → index
376
+ for (let i = 0; i < events.length; i++) {
377
+ const event = events[i];
378
+ if (event.type !== "session.update")
379
+ continue;
380
+ const data = event.data;
381
+ const update = data?.update;
382
+ if (update?.sessionUpdate !== "tool_call")
383
+ continue;
384
+ const toolCallId = update.toolCallId || update.tool_call_id || update.id;
385
+ if (!toolCallId)
386
+ continue;
387
+ const rawInput = update.rawInput;
388
+ const hasContent = rawInput && Object.keys(rawInput).length > 0;
389
+ if (hasContent) {
390
+ populatedToolCalls.set(toolCallId, i);
391
+ }
392
+ else if (!populatedToolCalls.has(toolCallId)) {
393
+ // First (empty) version - track it in case no populated version exists
394
+ populatedToolCalls.set(toolCallId, i);
395
+ }
396
+ }
397
+ // Second pass: keep only the best version per toolCallId
398
+ return events.filter((event, i) => {
399
+ if (event.type !== "session.update")
400
+ return true;
401
+ const data = event.data;
402
+ const update = data?.update;
403
+ if (update?.sessionUpdate !== "tool_call")
404
+ return true;
405
+ const toolCallId = update.toolCallId || update.tool_call_id || update.id;
406
+ if (!toolCallId)
407
+ return true;
408
+ // Keep this event only if it's the best version (populated or only version)
409
+ return populatedToolCalls.get(toolCallId) === i;
410
+ });
411
+ }
267
412
  /**
268
413
  * Read events within a time range.
269
414
  *
@@ -381,7 +526,7 @@ export async function getSessionLogSummary(specDir, sessionId) {
381
526
  return null;
382
527
  const [eventCount, iterationCount, tasksCompleted] = await Promise.all([
383
528
  countEventLines(specDir, sessionId),
384
- countIterations(specDir, sessionId),
529
+ countIterationsBoundaryAware(specDir, sessionId),
385
530
  countTaskCompletions(specDir, sessionId),
386
531
  ]);
387
532
  const startMs = new Date(metadata.started_at).getTime();
@@ -506,17 +651,73 @@ function extractTaskRef(command) {
506
651
  return match ? match[0] : null;
507
652
  }
508
653
  /**
509
- * Compute per-iteration summaries from events.
654
+ * Find iteration boundaries from prompt.sent events with phase "task-work".
655
+ *
656
+ * Ralph emits these synchronously at the start of each iteration, so their
657
+ * array positions are reliable even when concurrent fire-and-forget events
658
+ * produce duplicate seq numbers.
659
+ *
660
+ * Returns validated, monotonically increasing boundaries.
661
+ */
662
+ function findIterationBoundaries(events) {
663
+ const raw = [];
664
+ for (let i = 0; i < events.length; i++) {
665
+ const event = events[i];
666
+ if (event.type !== "prompt.sent")
667
+ continue;
668
+ const data = event.data;
669
+ if (data?.phase !== "task-work" ||
670
+ typeof data?.iteration !== "number") {
671
+ continue;
672
+ }
673
+ raw.push({ index: i, iteration: data.iteration });
674
+ }
675
+ // Validate: filter to monotonically increasing iteration numbers, deduplicate
676
+ const validated = [];
677
+ let lastIter = -Infinity;
678
+ for (const b of raw) {
679
+ if (b.iteration > lastIter) {
680
+ validated.push(b);
681
+ lastIter = b.iteration;
682
+ }
683
+ }
684
+ return validated;
685
+ }
686
+ /**
687
+ * Extract task start/complete refs from a slice of events.
688
+ */
689
+ function extractTaskTransitions(events) {
690
+ const tasksStarted = [];
691
+ const tasksCompleted = [];
692
+ for (const event of events) {
693
+ if (event.type === "session.update") {
694
+ const data = event.data;
695
+ const command = data?.update?.rawInput?.command;
696
+ if (typeof command === "string") {
697
+ if (/\btask start\b/.test(command)) {
698
+ const ref = extractTaskRef(command);
699
+ if (ref)
700
+ tasksStarted.push(ref);
701
+ }
702
+ else if (/\btask complete\b/.test(command)) {
703
+ const ref = extractTaskRef(command);
704
+ if (ref)
705
+ tasksCompleted.push(ref);
706
+ }
707
+ }
708
+ }
709
+ }
710
+ return { tasksStarted, tasksCompleted };
711
+ }
712
+ /**
713
+ * Legacy iteration grouping: groups events by their data.iteration field.
510
714
  *
511
- * Dynamically creates iteration buckets based on both context snapshot files
512
- * AND event data to handle cases where events are logged before context
513
- * snapshots exist (e.g., active sessions).
715
+ * Used as fallback for sessions that don't have prompt.sent boundary events
716
+ * with phase "task-work" (pre-boundary sessions or non-ralph sessions).
514
717
  *
515
718
  * AC: @session-log-show ac-2
516
719
  */
517
- async function computeIterationSummaries(specDir, sessionId) {
518
- const events = await readEvents(specDir, sessionId);
519
- const snapshotIterations = await getIterationNumbers(specDir, sessionId);
720
+ function legacyIterationGrouping(events, snapshotIterations) {
520
721
  // Collect all iteration numbers from both snapshots and events
521
722
  const allIterations = new Set(snapshotIterations);
522
723
  for (const event of events) {
@@ -525,14 +726,18 @@ async function computeIterationSummaries(specDir, sessionId) {
525
726
  allIterations.add(data.iteration);
526
727
  }
527
728
  }
528
- // If no iterations found anywhere, create a single iteration-0 summary
729
+ // If no iterations found anywhere, synthesize iteration-0 only if events exist
529
730
  if (allIterations.size === 0) {
731
+ if (events.length === 0) {
732
+ return [];
733
+ }
734
+ const { tasksStarted, tasksCompleted } = extractTaskTransitions(events);
530
735
  return [
531
736
  {
532
737
  iteration: 0,
533
738
  event_count: events.length,
534
- tasks_started: [],
535
- tasks_completed: [],
739
+ tasks_started: tasksStarted,
740
+ tasks_completed: tasksCompleted,
536
741
  },
537
742
  ];
538
743
  }
@@ -543,41 +748,19 @@ async function computeIterationSummaries(specDir, sessionId) {
543
748
  iterationMap.set(n, []);
544
749
  }
545
750
  for (const event of events) {
546
- // Try to get iteration from event data
547
751
  const data = event.data;
548
752
  const iter = data?.iteration;
549
753
  if (typeof iter === "number" && iterationMap.has(iter)) {
550
754
  iterationMap.get(iter).push(event);
551
755
  }
552
756
  else {
553
- // Events without iteration info (lifecycle events) go to iteration 0
554
- // or the first known iteration if 0 doesn't exist
555
757
  const fallbackIter = iterationMap.has(0) ? 0 : iterations[0];
556
758
  iterationMap.get(fallbackIter).push(event);
557
759
  }
558
760
  }
559
761
  const summaries = [];
560
762
  for (const [iterNum, iterEvents] of iterationMap) {
561
- const tasksStarted = [];
562
- const tasksCompleted = [];
563
- for (const event of iterEvents) {
564
- if (event.type === "session.update") {
565
- const data = event.data;
566
- const command = data?.update?.rawInput?.command;
567
- if (typeof command === "string") {
568
- if (/\btask start\b/.test(command)) {
569
- const ref = extractTaskRef(command);
570
- if (ref)
571
- tasksStarted.push(ref);
572
- }
573
- else if (/\btask complete\b/.test(command)) {
574
- const ref = extractTaskRef(command);
575
- if (ref)
576
- tasksCompleted.push(ref);
577
- }
578
- }
579
- }
580
- }
763
+ const { tasksStarted, tasksCompleted } = extractTaskTransitions(iterEvents);
581
764
  summaries.push({
582
765
  iteration: iterNum,
583
766
  event_count: iterEvents.length,
@@ -587,6 +770,83 @@ async function computeIterationSummaries(specDir, sessionId) {
587
770
  }
588
771
  return summaries.sort((a, b) => a.iteration - b.iteration);
589
772
  }
773
+ /**
774
+ * Boundary-based iteration grouping: splits events by prompt.sent boundary
775
+ * positions (array indices) instead of trusting data.iteration fields.
776
+ *
777
+ * This is resilient to producer-side bugs where concurrent fire-and-forget
778
+ * event logging captures the wrong iteration number.
779
+ *
780
+ * AC: @session-log-show ac-10
781
+ */
782
+ function boundaryIterationGrouping(events, boundaries) {
783
+ const summaries = [];
784
+ for (let b = 0; b < boundaries.length; b++) {
785
+ const startIdx = boundaries[b].index;
786
+ const endIdx = b + 1 < boundaries.length ? boundaries[b + 1].index : events.length;
787
+ const iterEvents = events.slice(startIdx, endIdx);
788
+ const { tasksStarted, tasksCompleted } = extractTaskTransitions(iterEvents);
789
+ summaries.push({
790
+ iteration: boundaries[b].iteration,
791
+ event_count: iterEvents.length,
792
+ tasks_started: tasksStarted,
793
+ tasks_completed: tasksCompleted,
794
+ });
795
+ }
796
+ // Pre-boundary events (before the first prompt.sent) merge into first iteration
797
+ if (boundaries.length > 0 && boundaries[0].index > 0) {
798
+ const preBoundaryEvents = events.slice(0, boundaries[0].index);
799
+ const { tasksStarted, tasksCompleted } = extractTaskTransitions(preBoundaryEvents);
800
+ summaries[0].event_count += preBoundaryEvents.length;
801
+ summaries[0].tasks_started = [...tasksStarted, ...summaries[0].tasks_started];
802
+ summaries[0].tasks_completed = [...tasksCompleted, ...summaries[0].tasks_completed];
803
+ }
804
+ return summaries;
805
+ }
806
+ /**
807
+ * Compute per-iteration summaries from events.
808
+ *
809
+ * Uses prompt.sent boundary events (phase: "task-work") when available for
810
+ * accurate index-based grouping. Falls back to legacy data.iteration grouping
811
+ * for sessions without boundaries.
812
+ *
813
+ * AC: @session-log-show ac-2, ac-10
814
+ */
815
+ async function computeIterationSummaries(specDir, sessionId) {
816
+ const events = await readEvents(specDir, sessionId);
817
+ const boundaries = findIterationBoundaries(events);
818
+ if (boundaries.length > 0) {
819
+ return boundaryIterationGrouping(events, boundaries);
820
+ }
821
+ // Legacy fallback: no prompt.sent boundaries with phase "task-work"
822
+ const snapshotIterations = await getIterationNumbers(specDir, sessionId);
823
+ return legacyIterationGrouping(events, snapshotIterations);
824
+ }
825
+ /**
826
+ * Count iterations using boundary-aware logic, without computing full summaries.
827
+ *
828
+ * For use in getSessionLogSummary() (session log list, session log stats) to
829
+ * ensure iteration_count agrees with session log show.
830
+ *
831
+ * Falls back to counting context-iter-*.json files when no boundaries exist.
832
+ */
833
+ async function countIterationsBoundaryAware(specDir, sessionId) {
834
+ const events = await readEvents(specDir, sessionId);
835
+ const boundaries = findIterationBoundaries(events);
836
+ if (boundaries.length > 0) {
837
+ return boundaries.length;
838
+ }
839
+ // Legacy fallback: count from context snapshots and event data
840
+ const snapshotIterations = await getIterationNumbers(specDir, sessionId);
841
+ const allIterations = new Set(snapshotIterations);
842
+ for (const event of events) {
843
+ const data = event.data;
844
+ if (typeof data?.iteration === "number") {
845
+ allIterations.add(data.iteration);
846
+ }
847
+ }
848
+ return allIterations.size || (events.length > 0 ? 1 : 0);
849
+ }
590
850
  /**
591
851
  * Get full session detail for session log show.
592
852
  *
@@ -598,9 +858,8 @@ export async function getSessionLogDetail(specDir, sessionId) {
598
858
  const metadata = await getSession(specDir, sessionId);
599
859
  if (!metadata)
600
860
  return null;
601
- const [eventCount, iterationCount, iterations] = await Promise.all([
861
+ const [eventCount, iterations] = await Promise.all([
602
862
  countEventLines(specDir, sessionId),
603
- countIterations(specDir, sessionId),
604
863
  computeIterationSummaries(specDir, sessionId),
605
864
  ]);
606
865
  const startMs = new Date(metadata.started_at).getTime();
@@ -617,7 +876,7 @@ export async function getSessionLogDetail(specDir, sessionId) {
617
876
  ended_at: metadata.ended_at,
618
877
  duration_ms: durationMs,
619
878
  event_count: eventCount,
620
- iteration_count: iterationCount,
879
+ iteration_count: iterations.length,
621
880
  iterations,
622
881
  };
623
882
  }
@@ -701,6 +960,8 @@ export async function computeToolUsageStats(specDir, sessionIds, limit = 10) {
701
960
  if (!content.trim())
702
961
  continue;
703
962
  const lines = content.trim().split("\n");
963
+ // Track seen toolCallIds to deduplicate phased events
964
+ const seenToolCallIds = new Set();
704
965
  for (const line of lines) {
705
966
  // Quick pre-filter: only parse lines that might be tool_call events
706
967
  if (!line.includes('"tool_call"'))
@@ -710,6 +971,12 @@ export async function computeToolUsageStats(specDir, sessionIds, limit = 10) {
710
971
  if (event?.type === "session.update") {
711
972
  const update = event?.data?.update;
712
973
  if (update?.sessionUpdate === "tool_call") {
974
+ // Deduplicate phased tool_call events by toolCallId
975
+ const toolCallId = update?.toolCallId || update?.tool_call_id || update?.id;
976
+ if (toolCallId && seenToolCallIds.has(toolCallId))
977
+ continue;
978
+ if (toolCallId)
979
+ seenToolCallIds.add(toolCallId);
713
980
  const toolName = update?._meta?.claudeCode?.toolName || "unknown";
714
981
  toolCounts[toolName] = (toolCounts[toolName] || 0) + 1;
715
982
  totalToolCalls++;
@@ -880,6 +1147,8 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
880
1147
  continue;
881
1148
  const matches = [];
882
1149
  const lines = content.trim().split("\n");
1150
+ // Track seen tool_call IDs to skip phased duplicates
1151
+ const seenToolCallIds = new Set();
883
1152
  for (const line of lines) {
884
1153
  if (totalMatches >= limit)
885
1154
  break;
@@ -891,6 +1160,18 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
891
1160
  // AC: @session-log-search ac-2 - Filter by event type
892
1161
  if (options.eventType && event.type !== options.eventType)
893
1162
  continue;
1163
+ // Deduplicate phased tool_call events
1164
+ if (event?.type === "session.update") {
1165
+ const update = event?.data?.update;
1166
+ if (update?.sessionUpdate === "tool_call") {
1167
+ const toolCallId = update?.toolCallId || update?.tool_call_id || update?.id;
1168
+ if (toolCallId) {
1169
+ if (seenToolCallIds.has(toolCallId))
1170
+ continue;
1171
+ seenToolCallIds.add(toolCallId);
1172
+ }
1173
+ }
1174
+ }
894
1175
  // Verify match in stringified data (not just line, in case pattern appears in metadata)
895
1176
  const dataStr = JSON.stringify(event.data);
896
1177
  if (!dataStr.toLowerCase().includes(lowerPattern))
@@ -919,4 +1200,397 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
919
1200
  }
920
1201
  return results;
921
1202
  }
1203
+ // ─── Session Creation with Budget ─────────────────────────────────────────────
1204
+ /**
1205
+ * Create a session with an optional task budget in one call.
1206
+ *
1207
+ * This is the library-level entry point for session creation. It creates
1208
+ * the session directory, writes session.yaml, and optionally writes budget.json.
1209
+ * Returns metadata without any console output.
1210
+ *
1211
+ * AC: @session-creation-and-env-injection ac-create
1212
+ * AC: @session-creation-and-env-injection ac-budget
1213
+ * AC: @session-creation-and-env-injection ac-budget-local
1214
+ * AC: @session-creation-and-env-injection ac-library
1215
+ *
1216
+ * @param specDir - The .kspec directory path
1217
+ * @param input - Session creation parameters
1218
+ * @returns Session metadata and optional budget (no console output)
1219
+ */
1220
+ export async function createSessionWithBudget(specDir, input) {
1221
+ // Create session
1222
+ const session = await createSession(specDir, {
1223
+ id: input.id,
1224
+ agent_type: input.agent_type,
1225
+ task_id: input.task_id,
1226
+ });
1227
+ // Optionally create budget
1228
+ let budget = null;
1229
+ if (input.budget !== undefined && input.budget > 0) {
1230
+ budget = await createBudget(specDir, input.id, input.budget);
1231
+ }
1232
+ return {
1233
+ session_id: input.id,
1234
+ session,
1235
+ budget,
1236
+ };
1237
+ }
1238
+ /**
1239
+ * Write or update KSPEC_SESSION_ID in a dotenv-style file.
1240
+ * Replaces an existing KSPEC_SESSION_ID line or appends a new one.
1241
+ */
1242
+ async function upsertDotenvSessionId(filePath, sessionId) {
1243
+ let content = "";
1244
+ try {
1245
+ content = await fsPromises.readFile(filePath, "utf-8");
1246
+ }
1247
+ catch (err) {
1248
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
1249
+ // File doesn't exist yet, start fresh
1250
+ }
1251
+ else {
1252
+ throw err;
1253
+ }
1254
+ }
1255
+ const lines = content.split("\n");
1256
+ const existingIdx = lines.findIndex((l) => l.startsWith("KSPEC_SESSION_ID="));
1257
+ if (existingIdx >= 0) {
1258
+ lines[existingIdx] = `KSPEC_SESSION_ID=${sessionId}`;
1259
+ }
1260
+ else {
1261
+ // Append before final empty line if present
1262
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
1263
+ lines.splice(lines.length - 1, 0, `KSPEC_SESSION_ID=${sessionId}`);
1264
+ }
1265
+ else {
1266
+ lines.push(`KSPEC_SESSION_ID=${sessionId}`);
1267
+ }
1268
+ }
1269
+ await fsPromises.writeFile(filePath, lines.join("\n"), "utf-8");
1270
+ }
1271
+ /**
1272
+ * Inject KSPEC_SESSION_ID into Claude Code environment.
1273
+ *
1274
+ * Strategy:
1275
+ * 1. If CLAUDE_ENV_FILE is set, write to that file
1276
+ * 2. Otherwise, append to project .claude/settings.json env section
1277
+ *
1278
+ * AC: @session-creation-and-env-injection ac-inject-claude
1279
+ */
1280
+ export async function injectClaudeCodeEnv(sessionId) {
1281
+ const envFile = process.env.CLAUDE_ENV_FILE;
1282
+ if (envFile) {
1283
+ await upsertDotenvSessionId(envFile, sessionId);
1284
+ return {
1285
+ injected: true,
1286
+ method: "claude_env_file",
1287
+ description: `Wrote KSPEC_SESSION_ID=${sessionId} to CLAUDE_ENV_FILE`,
1288
+ path: envFile,
1289
+ };
1290
+ }
1291
+ // Fallback: write to project .claude/settings.json
1292
+ const settingsDir = path.join(process.cwd(), ".claude");
1293
+ const settingsPath = path.join(settingsDir, "settings.json");
1294
+ await fsPromises.mkdir(settingsDir, { recursive: true });
1295
+ let settings = {};
1296
+ try {
1297
+ const content = await fsPromises.readFile(settingsPath, "utf-8");
1298
+ settings = JSON.parse(content);
1299
+ }
1300
+ catch (err) {
1301
+ // Only start fresh for ENOENT; throw on parse errors to avoid overwriting
1302
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
1303
+ // File doesn't exist, start fresh
1304
+ }
1305
+ else {
1306
+ throw new Error(`Cannot inject env: .claude/settings.json exists but is not valid JSON. ` +
1307
+ `Fix the file manually or remove it, then retry.`);
1308
+ }
1309
+ }
1310
+ // Ensure env section exists
1311
+ if (!settings.env || typeof settings.env !== "object") {
1312
+ settings.env = {};
1313
+ }
1314
+ settings.env.KSPEC_SESSION_ID = sessionId;
1315
+ await fsPromises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
1316
+ return {
1317
+ injected: true,
1318
+ method: "claude_settings",
1319
+ description: `Added KSPEC_SESSION_ID to .claude/settings.json env section`,
1320
+ path: settingsPath,
1321
+ };
1322
+ }
1323
+ /**
1324
+ * Inject KSPEC_SESSION_ID into Codex CLI environment.
1325
+ *
1326
+ * Adds to shell_environment_policy.set in codex config.
1327
+ *
1328
+ * AC: @session-creation-and-env-injection ac-inject-codex
1329
+ */
1330
+ export async function injectCodexEnv(sessionId) {
1331
+ const configDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".codex");
1332
+ const configPath = path.join(configDir, "config.json");
1333
+ await fsPromises.mkdir(configDir, { recursive: true });
1334
+ let config = {};
1335
+ try {
1336
+ const content = await fsPromises.readFile(configPath, "utf-8");
1337
+ config = JSON.parse(content);
1338
+ }
1339
+ catch (err) {
1340
+ // Only start fresh for ENOENT; throw on parse errors to avoid overwriting
1341
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
1342
+ // File doesn't exist, start fresh
1343
+ }
1344
+ else {
1345
+ throw new Error(`Cannot inject env: ~/.codex/config.json exists but is not valid JSON. ` +
1346
+ `Fix the file manually or remove it, then retry.`);
1347
+ }
1348
+ }
1349
+ // Ensure shell_environment_policy.set exists
1350
+ if (!config.shell_environment_policy ||
1351
+ typeof config.shell_environment_policy !== "object") {
1352
+ config.shell_environment_policy = {};
1353
+ }
1354
+ const policy = config.shell_environment_policy;
1355
+ if (!policy.set || typeof policy.set !== "object") {
1356
+ policy.set = {};
1357
+ }
1358
+ policy.set.KSPEC_SESSION_ID = sessionId;
1359
+ await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1360
+ return {
1361
+ injected: true,
1362
+ method: "codex_config",
1363
+ description: `Added KSPEC_SESSION_ID to Codex config shell_environment_policy.set`,
1364
+ path: configPath,
1365
+ };
1366
+ }
1367
+ /**
1368
+ * Inject KSPEC_SESSION_ID into Gemini CLI environment.
1369
+ *
1370
+ * Writes to .gemini/.env in project root (auto-loaded by Gemini CLI).
1371
+ */
1372
+ export async function injectGeminiEnv(sessionId) {
1373
+ const dotenvDir = path.join(process.cwd(), ".gemini");
1374
+ const dotenvPath = path.join(dotenvDir, ".env");
1375
+ await fsPromises.mkdir(dotenvDir, { recursive: true });
1376
+ await upsertDotenvSessionId(dotenvPath, sessionId);
1377
+ return {
1378
+ injected: true,
1379
+ method: "gemini_dotenv",
1380
+ description: `Wrote KSPEC_SESSION_ID=${sessionId} to .gemini/.env`,
1381
+ path: dotenvPath,
1382
+ };
1383
+ }
1384
+ /**
1385
+ * Inject KSPEC_SESSION_ID into OpenCode environment.
1386
+ *
1387
+ * Writes to project root .env file (auto-loaded by OpenCode via Bun runtime).
1388
+ * Uses the same dotenv append/replace pattern as other injectors.
1389
+ */
1390
+ export async function injectOpenCodeEnv(sessionId) {
1391
+ const dotenvPath = path.join(process.cwd(), ".env");
1392
+ await upsertDotenvSessionId(dotenvPath, sessionId);
1393
+ return {
1394
+ injected: true,
1395
+ method: "opencode_dotenv",
1396
+ description: `Wrote KSPEC_SESSION_ID=${sessionId} to .env`,
1397
+ path: dotenvPath,
1398
+ };
1399
+ }
1400
+ /**
1401
+ * Get fallback injection instructions for unknown agent harnesses.
1402
+ *
1403
+ * AC: @session-creation-and-env-injection ac-inject-fallback
1404
+ */
1405
+ export function getFallbackInjectionInstructions(sessionId) {
1406
+ return {
1407
+ injected: false,
1408
+ method: "fallback",
1409
+ description: `export KSPEC_SESSION_ID=${sessionId}`,
1410
+ };
1411
+ }
1412
+ // ─── Session Validation ───────────────────────────────────────────────────────
1413
+ /**
1414
+ * Validate that the current KSPEC_SESSION_ID points to a valid session.
1415
+ *
1416
+ * AC: @session-creation-and-env-injection ac-invalid-session
1417
+ *
1418
+ * @param specDir - The .kspec directory path
1419
+ * @param sessionId - The session ID to validate
1420
+ * @returns Validation result with error details if invalid
1421
+ */
1422
+ export async function validateSessionId(specDir, sessionId) {
1423
+ // Check if session directory exists
1424
+ const exists = await sessionExists(specDir, sessionId);
1425
+ if (!exists) {
1426
+ return {
1427
+ valid: false,
1428
+ error: `Session not found: ${sessionId}`,
1429
+ suggestion: `Unset KSPEC_SESSION_ID or create a new session with: kspec session create --agent-type <type>`,
1430
+ };
1431
+ }
1432
+ // Try to read and validate session metadata
1433
+ const session = await getSession(specDir, sessionId);
1434
+ if (!session) {
1435
+ return {
1436
+ valid: false,
1437
+ error: `Session metadata is corrupt or unreadable: ${sessionId}`,
1438
+ suggestion: `Unset KSPEC_SESSION_ID or create a new session with: kspec session create --agent-type <type>`,
1439
+ };
1440
+ }
1441
+ return { valid: true, session };
1442
+ }
1443
+ // ─── Task Budget ──────────────────────────────────────────────────────────────
1444
+ /**
1445
+ * Atomic JSON write — write to temp file then rename in same directory.
1446
+ * Prevents corruption on crash.
1447
+ * AC: @task-budget-enforcement ac-atomic-write
1448
+ */
1449
+ async function writeBudgetAtomic(filePath, budget) {
1450
+ const dir = path.dirname(filePath);
1451
+ await fsPromises.mkdir(dir, { recursive: true });
1452
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
1453
+ const content = JSON.stringify(budget, null, 2) + "\n";
1454
+ await fsPromises.writeFile(tmpPath, content, "utf-8");
1455
+ await fsPromises.rename(tmpPath, filePath);
1456
+ }
1457
+ /**
1458
+ * Create a budget for a session.
1459
+ *
1460
+ * Writes budget.json to .kspec/sessions/{id}/ on the local filesystem
1461
+ * (NOT committed to shadow branch).
1462
+ *
1463
+ * AC: @session-creation-and-env-injection ac-budget
1464
+ * AC: @session-creation-and-env-injection ac-budget-local
1465
+ *
1466
+ * @param specDir - The .kspec directory path
1467
+ * @param sessionId - Session ID
1468
+ * @param maxPerCycle - Maximum tasks allowed per cycle
1469
+ * @returns The created budget
1470
+ */
1471
+ export async function createBudget(specDir, sessionId, maxPerCycle) {
1472
+ const budget = {
1473
+ max_per_cycle: maxPerCycle,
1474
+ started_this_cycle: 0,
1475
+ };
1476
+ const validated = TaskBudgetSchema.parse(budget);
1477
+ const budgetPath = getSessionBudgetPath(specDir, sessionId);
1478
+ await writeBudgetAtomic(budgetPath, validated);
1479
+ return validated;
1480
+ }
1481
+ /**
1482
+ * Read budget for a session.
1483
+ *
1484
+ * AC: @task-budget-enforcement ac-no-budget
1485
+ *
1486
+ * @param specDir - The .kspec directory path
1487
+ * @param sessionId - Session ID
1488
+ * @returns Budget or null if no budget configured (opt-in)
1489
+ */
1490
+ export async function getBudget(specDir, sessionId) {
1491
+ const budgetPath = getSessionBudgetPath(specDir, sessionId);
1492
+ let content;
1493
+ try {
1494
+ content = await fsPromises.readFile(budgetPath, "utf-8");
1495
+ }
1496
+ catch (err) {
1497
+ // File doesn't exist = no budget configured (opt-in)
1498
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
1499
+ return null;
1500
+ }
1501
+ throw err;
1502
+ }
1503
+ // File exists — parse errors are real failures, not "no budget"
1504
+ const raw = JSON.parse(content);
1505
+ return TaskBudgetSchema.parse(raw);
1506
+ }
1507
+ /**
1508
+ * Check whether the budget allows starting a new task.
1509
+ *
1510
+ * Returns an object with `allowed` boolean and context about the budget.
1511
+ * When no budget is configured, always allows (opt-in behavior).
1512
+ *
1513
+ * AC: @task-budget-enforcement ac-block-start
1514
+ * AC: @task-budget-enforcement ac-no-budget
1515
+ * AC: @task-budget-enforcement ac-no-session
1516
+ *
1517
+ * @param specDir - The .kspec directory path
1518
+ * @param sessionId - Session ID, or undefined if KSPEC_SESSION_ID not set
1519
+ * @returns Budget check result
1520
+ */
1521
+ export async function checkBudget(specDir, sessionId) {
1522
+ // AC: @task-budget-enforcement ac-no-session — no session means no check
1523
+ if (!sessionId) {
1524
+ return { allowed: true };
1525
+ }
1526
+ const budget = await getBudget(specDir, sessionId);
1527
+ // AC: @task-budget-enforcement ac-no-budget — no budget means no check
1528
+ if (!budget) {
1529
+ return { allowed: true };
1530
+ }
1531
+ if (budget.started_this_cycle >= budget.max_per_cycle) {
1532
+ return {
1533
+ allowed: false,
1534
+ reason: `Task budget exhausted: ${budget.started_this_cycle}/${budget.max_per_cycle} tasks started this cycle. Wrap up current work and let the iteration end naturally without starting new tasks.`,
1535
+ budget,
1536
+ };
1537
+ }
1538
+ return { allowed: true, budget };
1539
+ }
1540
+ /**
1541
+ * Increment the budget counter after a task is successfully started.
1542
+ *
1543
+ * IMPORTANT: Callers must NOT call this for resume cases (task already
1544
+ * in_progress). The budget should only be incremented when a new task
1545
+ * transitions to in_progress, not when resuming an existing one.
1546
+ * See AC: @task-budget-enforcement ac-resume-no-increment
1547
+ *
1548
+ * AC: @task-budget-enforcement ac-increment
1549
+ * AC: @task-budget-enforcement ac-atomic-write
1550
+ *
1551
+ * @param specDir - The .kspec directory path
1552
+ * @param sessionId - Session ID
1553
+ * @returns Updated budget, or null if no budget configured
1554
+ */
1555
+ export async function incrementBudget(specDir, sessionId) {
1556
+ const budget = await getBudget(specDir, sessionId);
1557
+ if (!budget) {
1558
+ return null;
1559
+ }
1560
+ const updated = {
1561
+ ...budget,
1562
+ started_this_cycle: budget.started_this_cycle + 1,
1563
+ };
1564
+ const validated = TaskBudgetSchema.parse(updated);
1565
+ const budgetPath = getSessionBudgetPath(specDir, sessionId);
1566
+ await writeBudgetAtomic(budgetPath, validated);
1567
+ return validated;
1568
+ }
1569
+ /**
1570
+ * Reset the budget counter to 0 for a new cycle/iteration.
1571
+ *
1572
+ * Called by ralph at iteration boundaries. Single-writer guarantee:
1573
+ * ralph only resets between iterations when the agent is not running.
1574
+ *
1575
+ * AC: @task-budget-enforcement ac-reset
1576
+ * AC: @task-budget-enforcement ac-atomic-write
1577
+ *
1578
+ * @param specDir - The .kspec directory path
1579
+ * @param sessionId - Session ID
1580
+ * @returns Updated budget, or null if no budget configured
1581
+ */
1582
+ export async function resetBudget(specDir, sessionId) {
1583
+ const budget = await getBudget(specDir, sessionId);
1584
+ if (!budget) {
1585
+ return null;
1586
+ }
1587
+ const updated = {
1588
+ ...budget,
1589
+ started_this_cycle: 0,
1590
+ };
1591
+ const validated = TaskBudgetSchema.parse(updated);
1592
+ const budgetPath = getSessionBudgetPath(specDir, sessionId);
1593
+ await writeBudgetAtomic(budgetPath, validated);
1594
+ return validated;
1595
+ }
922
1596
  //# sourceMappingURL=store.js.map