@kynetic-ai/spec 0.9.1 → 0.10.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 (262) hide show
  1. package/README.md +2 -1
  2. package/dist/acp/client.d.ts +6 -1
  3. package/dist/acp/client.d.ts.map +1 -1
  4. package/dist/acp/client.js +7 -2
  5. package/dist/acp/client.js.map +1 -1
  6. package/dist/acp/framing.d.ts +12 -1
  7. package/dist/acp/framing.d.ts.map +1 -1
  8. package/dist/acp/framing.js +27 -4
  9. package/dist/acp/framing.js.map +1 -1
  10. package/dist/agent-runtime/dispatch.d.ts +261 -0
  11. package/dist/agent-runtime/dispatch.d.ts.map +1 -0
  12. package/dist/agent-runtime/dispatch.js +791 -0
  13. package/dist/agent-runtime/dispatch.js.map +1 -0
  14. package/dist/agent-runtime/index.d.ts +11 -0
  15. package/dist/agent-runtime/index.d.ts.map +1 -0
  16. package/dist/agent-runtime/index.js +11 -0
  17. package/dist/agent-runtime/index.js.map +1 -0
  18. package/dist/agent-runtime/invocation.d.ts +86 -0
  19. package/dist/agent-runtime/invocation.d.ts.map +1 -0
  20. package/dist/agent-runtime/invocation.js +442 -0
  21. package/dist/agent-runtime/invocation.js.map +1 -0
  22. package/dist/agent-runtime/prompts.d.ts +50 -0
  23. package/dist/agent-runtime/prompts.d.ts.map +1 -0
  24. package/dist/agent-runtime/prompts.js +108 -0
  25. package/dist/agent-runtime/prompts.js.map +1 -0
  26. package/dist/agents/spawner.d.ts.map +1 -1
  27. package/dist/agents/spawner.js +60 -4
  28. package/dist/agents/spawner.js.map +1 -1
  29. package/dist/cli/batch-exec.d.ts.map +1 -1
  30. package/dist/cli/batch-exec.js +140 -62
  31. package/dist/cli/batch-exec.js.map +1 -1
  32. package/dist/cli/batch-write-buffer.d.ts +141 -0
  33. package/dist/cli/batch-write-buffer.d.ts.map +1 -0
  34. package/dist/cli/batch-write-buffer.js +400 -0
  35. package/dist/cli/batch-write-buffer.js.map +1 -0
  36. package/dist/cli/commands/agent.d.ts +20 -0
  37. package/dist/cli/commands/agent.d.ts.map +1 -0
  38. package/dist/cli/commands/agent.js +831 -0
  39. package/dist/cli/commands/agent.js.map +1 -0
  40. package/dist/cli/commands/inbox.d.ts.map +1 -1
  41. package/dist/cli/commands/inbox.js +46 -22
  42. package/dist/cli/commands/inbox.js.map +1 -1
  43. package/dist/cli/commands/index.d.ts +1 -0
  44. package/dist/cli/commands/index.d.ts.map +1 -1
  45. package/dist/cli/commands/index.js +1 -0
  46. package/dist/cli/commands/index.js.map +1 -1
  47. package/dist/cli/commands/item.d.ts.map +1 -1
  48. package/dist/cli/commands/item.js +22 -16
  49. package/dist/cli/commands/item.js.map +1 -1
  50. package/dist/cli/commands/log.js +1 -1
  51. package/dist/cli/commands/log.js.map +1 -1
  52. package/dist/cli/commands/meta.d.ts.map +1 -1
  53. package/dist/cli/commands/meta.js +159 -6
  54. package/dist/cli/commands/meta.js.map +1 -1
  55. package/dist/cli/commands/module.d.ts.map +1 -1
  56. package/dist/cli/commands/module.js +2 -1
  57. package/dist/cli/commands/module.js.map +1 -1
  58. package/dist/cli/commands/plan-import.js +19 -3
  59. package/dist/cli/commands/plan-import.js.map +1 -1
  60. package/dist/cli/commands/plan.d.ts.map +1 -1
  61. package/dist/cli/commands/plan.js +87 -43
  62. package/dist/cli/commands/plan.js.map +1 -1
  63. package/dist/cli/commands/ralph.d.ts +5 -56
  64. package/dist/cli/commands/ralph.d.ts.map +1 -1
  65. package/dist/cli/commands/ralph.js +52 -1502
  66. package/dist/cli/commands/ralph.js.map +1 -1
  67. package/dist/cli/commands/search.d.ts.map +1 -1
  68. package/dist/cli/commands/search.js +22 -13
  69. package/dist/cli/commands/search.js.map +1 -1
  70. package/dist/cli/commands/serve.d.ts.map +1 -1
  71. package/dist/cli/commands/serve.js +70 -11
  72. package/dist/cli/commands/serve.js.map +1 -1
  73. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
  74. package/dist/cli/commands/session/checkpoint.js +7 -2
  75. package/dist/cli/commands/session/checkpoint.js.map +1 -1
  76. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  77. package/dist/cli/commands/session/commands.js +15 -0
  78. package/dist/cli/commands/session/commands.js.map +1 -1
  79. package/dist/cli/commands/session/context.d.ts.map +1 -1
  80. package/dist/cli/commands/session/context.js +10 -5
  81. package/dist/cli/commands/session/context.js.map +1 -1
  82. package/dist/cli/commands/session/log.d.ts +1 -0
  83. package/dist/cli/commands/session/log.d.ts.map +1 -1
  84. package/dist/cli/commands/session/log.js +124 -8
  85. package/dist/cli/commands/session/log.js.map +1 -1
  86. package/dist/cli/commands/session/stale-close.d.ts +17 -0
  87. package/dist/cli/commands/session/stale-close.d.ts.map +1 -0
  88. package/dist/cli/commands/session/stale-close.js +378 -0
  89. package/dist/cli/commands/session/stale-close.js.map +1 -0
  90. package/dist/cli/commands/setup.d.ts.map +1 -1
  91. package/dist/cli/commands/setup.js +95 -0
  92. package/dist/cli/commands/setup.js.map +1 -1
  93. package/dist/cli/commands/skill-crud.d.ts.map +1 -1
  94. package/dist/cli/commands/skill-crud.js +4 -3
  95. package/dist/cli/commands/skill-crud.js.map +1 -1
  96. package/dist/cli/commands/skill-diff.d.ts.map +1 -1
  97. package/dist/cli/commands/skill-diff.js +15 -0
  98. package/dist/cli/commands/skill-diff.js.map +1 -1
  99. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  100. package/dist/cli/commands/skill-install.js +50 -18
  101. package/dist/cli/commands/skill-install.js.map +1 -1
  102. package/dist/cli/commands/task.d.ts.map +1 -1
  103. package/dist/cli/commands/task.js +536 -310
  104. package/dist/cli/commands/task.js.map +1 -1
  105. package/dist/cli/commands/tasks.js +1 -1
  106. package/dist/cli/commands/tasks.js.map +1 -1
  107. package/dist/cli/commands/triage.d.ts.map +1 -1
  108. package/dist/cli/commands/triage.js +37 -13
  109. package/dist/cli/commands/triage.js.map +1 -1
  110. package/dist/cli/commands/validate.d.ts.map +1 -1
  111. package/dist/cli/commands/validate.js +65 -25
  112. package/dist/cli/commands/validate.js.map +1 -1
  113. package/dist/cli/help/content.d.ts.map +1 -1
  114. package/dist/cli/help/content.js +5 -0
  115. package/dist/cli/help/content.js.map +1 -1
  116. package/dist/cli/index.d.ts.map +1 -1
  117. package/dist/cli/index.js +2 -1
  118. package/dist/cli/index.js.map +1 -1
  119. package/dist/cli/output.d.ts.map +1 -1
  120. package/dist/cli/output.js +5 -1
  121. package/dist/cli/output.js.map +1 -1
  122. package/dist/daemon/project-context.ts +22 -0
  123. package/dist/daemon/routes/agent-dispatch.ts +272 -0
  124. package/dist/daemon/server.ts +55 -20
  125. package/dist/daemon/websocket/handler.ts +67 -6
  126. package/dist/daemon/websocket/lifecycle.ts +19 -0
  127. package/dist/daemon/websocket/pubsub.ts +74 -3
  128. package/dist/export/html.d.ts.map +1 -1
  129. package/dist/export/html.js +5 -2
  130. package/dist/export/html.js.map +1 -1
  131. package/dist/export/triage.d.ts +1 -1
  132. package/dist/export/triage.d.ts.map +1 -1
  133. package/dist/export/triage.js +5 -3
  134. package/dist/export/triage.js.map +1 -1
  135. package/dist/parser/alignment.d.ts.map +1 -1
  136. package/dist/parser/alignment.js +6 -3
  137. package/dist/parser/alignment.js.map +1 -1
  138. package/dist/parser/assess.js +1 -1
  139. package/dist/parser/assess.js.map +1 -1
  140. package/dist/parser/config.d.ts +6 -6
  141. package/dist/parser/meta.d.ts.map +1 -1
  142. package/dist/parser/meta.js +9 -8
  143. package/dist/parser/meta.js.map +1 -1
  144. package/dist/parser/plan-document.d.ts +12 -12
  145. package/dist/parser/plans.d.ts +7 -0
  146. package/dist/parser/plans.d.ts.map +1 -1
  147. package/dist/parser/plans.js +100 -15
  148. package/dist/parser/plans.js.map +1 -1
  149. package/dist/parser/refs.d.ts +5 -0
  150. package/dist/parser/refs.d.ts.map +1 -1
  151. package/dist/parser/refs.js +17 -12
  152. package/dist/parser/refs.js.map +1 -1
  153. package/dist/parser/shadow.d.ts +1 -1
  154. package/dist/parser/shadow.d.ts.map +1 -1
  155. package/dist/parser/shadow.js +71 -4
  156. package/dist/parser/shadow.js.map +1 -1
  157. package/dist/parser/skill-render.d.ts.map +1 -1
  158. package/dist/parser/skill-render.js +6 -3
  159. package/dist/parser/skill-render.js.map +1 -1
  160. package/dist/parser/validate.d.ts.map +1 -1
  161. package/dist/parser/validate.js +35 -76
  162. package/dist/parser/validate.js.map +1 -1
  163. package/dist/parser/yaml.d.ts +24 -5
  164. package/dist/parser/yaml.d.ts.map +1 -1
  165. package/dist/parser/yaml.js +224 -64
  166. package/dist/parser/yaml.js.map +1 -1
  167. package/dist/schema/meta.d.ts +442 -119
  168. package/dist/schema/meta.d.ts.map +1 -1
  169. package/dist/schema/meta.js +55 -0
  170. package/dist/schema/meta.js.map +1 -1
  171. package/dist/schema/plan.d.ts +22 -22
  172. package/dist/schema/spec.d.ts +39 -39
  173. package/dist/schema/task.d.ts +43 -32
  174. package/dist/schema/task.d.ts.map +1 -1
  175. package/dist/schema/task.js +5 -0
  176. package/dist/schema/task.js.map +1 -1
  177. package/dist/sessions/store.d.ts +112 -0
  178. package/dist/sessions/store.d.ts.map +1 -1
  179. package/dist/sessions/store.js +414 -22
  180. package/dist/sessions/store.js.map +1 -1
  181. package/dist/sessions/types.d.ts +75 -17
  182. package/dist/sessions/types.d.ts.map +1 -1
  183. package/dist/sessions/types.js +51 -1
  184. package/dist/sessions/types.js.map +1 -1
  185. package/dist/triage/actions.d.ts +1 -0
  186. package/dist/triage/actions.d.ts.map +1 -1
  187. package/dist/triage/actions.js +34 -7
  188. package/dist/triage/actions.js.map +1 -1
  189. package/dist/utils/commit.js +1 -1
  190. package/dist/utils/commit.js.map +1 -1
  191. package/dist/web-ui/_app/env.js +1 -0
  192. package/dist/web-ui/_app/immutable/assets/0.BxCxvrZR.css +1 -0
  193. package/dist/web-ui/_app/immutable/assets/select-trigger.CV-KWLNP.css +1 -0
  194. package/dist/web-ui/_app/immutable/chunks/B-CZR0q8.js +1 -0
  195. package/dist/web-ui/_app/immutable/chunks/B1IR5Su5.js +1 -0
  196. package/dist/web-ui/_app/immutable/chunks/BCkp8Hs8.js +1 -0
  197. package/dist/web-ui/_app/immutable/chunks/B_Cvvtc4.js +1 -0
  198. package/dist/web-ui/_app/immutable/chunks/BtFaGGII.js +1 -0
  199. package/dist/web-ui/_app/immutable/chunks/Bu8JVsCH.js +1 -0
  200. package/dist/web-ui/_app/immutable/chunks/C87u-CNA.js +1 -0
  201. package/dist/web-ui/_app/immutable/chunks/CrFkBTYp.js +1 -0
  202. package/dist/web-ui/_app/immutable/chunks/D1ArdqNb.js +1 -0
  203. package/dist/web-ui/_app/immutable/chunks/D28BF5MJ.js +1 -0
  204. package/dist/web-ui/_app/immutable/chunks/D6RtLpzL.js +1 -0
  205. package/dist/web-ui/_app/immutable/chunks/D7FHSgx2.js +1 -0
  206. package/dist/web-ui/_app/immutable/chunks/DBXrsxZQ.js +2 -0
  207. package/dist/web-ui/_app/immutable/chunks/Da_hHMuA.js +1 -0
  208. package/dist/web-ui/_app/immutable/chunks/Do6LchSF.js +1 -0
  209. package/dist/web-ui/_app/immutable/chunks/DoNPtcAw.js +1 -0
  210. package/dist/web-ui/_app/immutable/chunks/DtUbXRZz.js +1 -0
  211. package/dist/web-ui/_app/immutable/chunks/DyFPRlLl.js +1 -0
  212. package/dist/web-ui/_app/immutable/chunks/DzAP8lRM.js +1 -0
  213. package/dist/web-ui/_app/immutable/chunks/DzVXElzN.js +2 -0
  214. package/dist/web-ui/_app/immutable/chunks/aoPBFken.js +1 -0
  215. package/dist/web-ui/_app/immutable/chunks/i-XnOIX0.js +1 -0
  216. package/dist/web-ui/_app/immutable/chunks/laxtrUO3.js +1 -0
  217. package/dist/web-ui/_app/immutable/chunks/q1nIWgqB.js +1 -0
  218. package/dist/web-ui/_app/immutable/chunks/sTLbk5Nm.js +1 -0
  219. package/dist/web-ui/_app/immutable/chunks/vwKgQu5P.js +5 -0
  220. package/dist/web-ui/_app/immutable/entry/app.BCwMcqnT.js +2 -0
  221. package/dist/web-ui/_app/immutable/entry/start.wKCQH-tt.js +1 -0
  222. package/dist/web-ui/_app/immutable/nodes/0.CjGVMG74.js +1 -0
  223. package/dist/web-ui/_app/immutable/nodes/1.B6_AIPan.js +1 -0
  224. package/dist/web-ui/_app/immutable/nodes/2.q4oCS7Ws.js +1 -0
  225. package/dist/web-ui/_app/immutable/nodes/3.rTKZf9o2.js +1 -0
  226. package/dist/web-ui/_app/immutable/nodes/4.DVIDRu1d.js +1 -0
  227. package/dist/web-ui/_app/immutable/nodes/5.8PtPXIOd.js +1 -0
  228. package/dist/web-ui/_app/immutable/nodes/6.ZZrTemy_.js +1 -0
  229. package/dist/web-ui/_app/immutable/nodes/7.IP-gxCxi.js +1 -0
  230. package/dist/web-ui/_app/version.json +1 -0
  231. package/dist/web-ui/index.html +36 -0
  232. package/dist/web-ui/robots.txt +3 -0
  233. package/package.json +3 -2
  234. package/plugin/.claude-plugin/marketplace.json +1 -1
  235. package/plugin/.claude-plugin/plugin.json +1 -1
  236. package/plugin/plugins/kspec/skills/task-work/SKILL.md +25 -2
  237. package/templates/agents-sections/06-ralph-loop.md +64 -11
  238. package/templates/skills/task-work/SKILL.md +25 -2
  239. package/dist/ralph/cli-renderer.d.ts +0 -27
  240. package/dist/ralph/cli-renderer.d.ts.map +0 -1
  241. package/dist/ralph/cli-renderer.js +0 -250
  242. package/dist/ralph/cli-renderer.js.map +0 -1
  243. package/dist/ralph/events.d.ts +0 -65
  244. package/dist/ralph/events.d.ts.map +0 -1
  245. package/dist/ralph/events.js +0 -600
  246. package/dist/ralph/events.js.map +0 -1
  247. package/dist/ralph/index.d.ts +0 -11
  248. package/dist/ralph/index.d.ts.map +0 -1
  249. package/dist/ralph/index.js +0 -16
  250. package/dist/ralph/index.js.map +0 -1
  251. package/dist/ralph/loop-errors.d.ts +0 -83
  252. package/dist/ralph/loop-errors.d.ts.map +0 -1
  253. package/dist/ralph/loop-errors.js +0 -150
  254. package/dist/ralph/loop-errors.js.map +0 -1
  255. package/dist/ralph/subagent.d.ts +0 -127
  256. package/dist/ralph/subagent.d.ts.map +0 -1
  257. package/dist/ralph/subagent.js +0 -268
  258. package/dist/ralph/subagent.js.map +0 -1
  259. package/dist/ralph/wrap-up.d.ts +0 -127
  260. package/dist/ralph/wrap-up.d.ts.map +0 -1
  261. package/dist/ralph/wrap-up.js +0 -271
  262. package/dist/ralph/wrap-up.js.map +0 -1
@@ -17,6 +17,7 @@ import * as path from "node:path";
17
17
  import { createHash, randomUUID } from "node:crypto";
18
18
  import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
19
19
  import * as YAML from "yaml";
20
+ import { shadowAutoCommit } from "../parser/shadow.js";
20
21
  import { SessionEventSchema, SessionMetadataSchema, TaskBudgetSchema, } from "./types.js";
21
22
  // ─── Constants ───────────────────────────────────────────────────────────────
22
23
  const SESSIONS_DIR = "sessions";
@@ -29,6 +30,7 @@ const BLOBS_DIR = "blobs";
29
30
  const EVENT_LINE_MAX_BYTES = 256 * 1024;
30
31
  const EVENT_FIELD_EXTERNALIZE_BYTES = 16 * 1024;
31
32
  const EVENT_PREVIEW_MAX_BYTES = 512;
33
+ const EVENT_SEQ_TAIL_READ_BYTES = EVENT_LINE_MAX_BYTES + 1024;
32
34
  // ─── Path Helpers ────────────────────────────────────────────────────────────
33
35
  /**
34
36
  * Get the sessions directory path within a spec directory.
@@ -90,10 +92,13 @@ export async function createSession(specDir, input) {
90
92
  // Create session directory
91
93
  await fsPromises.mkdir(sessionDir, { recursive: true });
92
94
  // Build full metadata
95
+ // AC: @session-model-evolution ac-1 — include trigger and agent_id when provided
93
96
  const metadata = {
94
97
  id: input.id,
95
98
  task_id: input.task_id,
96
99
  agent_type: input.agent_type,
100
+ agent_id: input.agent_id,
101
+ trigger: input.trigger,
97
102
  status: input.status ?? "active",
98
103
  started_at: input.started_at ?? new Date().toISOString(),
99
104
  ended_at: undefined,
@@ -120,7 +125,13 @@ export async function getSession(specDir, sessionId) {
120
125
  try {
121
126
  const content = await fsPromises.readFile(metadataPath, "utf-8");
122
127
  const raw = YAML.parse(content);
123
- return SessionMetadataSchema.parse(raw);
128
+ const parsed = SessionMetadataSchema.parse(raw);
129
+ // AC: @session-model-evolution ac-2 — materialize defaults for legacy sessions
130
+ return {
131
+ ...parsed,
132
+ trigger: parsed.trigger ?? "legacy",
133
+ agent_id: parsed.agent_id ?? parsed.agent_type,
134
+ };
124
135
  }
125
136
  catch {
126
137
  return null;
@@ -443,22 +454,70 @@ export async function resolveSessionBlobPointers(specDir, sessionId, value) {
443
454
  }
444
455
  return value;
445
456
  }
457
+ function extractLastEventSeq(content) {
458
+ const lines = content.split("\n");
459
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
460
+ const line = lines[i].trim();
461
+ if (line.length === 0) {
462
+ continue;
463
+ }
464
+ try {
465
+ const parsed = JSON.parse(line);
466
+ if (isRecord(parsed) &&
467
+ typeof parsed.seq === "number" &&
468
+ Number.isInteger(parsed.seq) &&
469
+ parsed.seq >= 0) {
470
+ return parsed.seq;
471
+ }
472
+ }
473
+ catch {
474
+ // Ignore malformed lines and continue scanning backward.
475
+ }
476
+ }
477
+ return null;
478
+ }
446
479
  /**
447
- * Get the current event count for a session (for seq assignment).
480
+ * Get next sequence number from the last stored event.
448
481
  *
449
- * @param specDir - The .kspec directory path
450
- * @param sessionId - Session ID
451
- * @returns Number of events in the session
482
+ * Reads a bounded tail slice for O(1) seq lookup; falls back to full scan only
483
+ * if the tail slice cannot be parsed (for example, partial line boundary).
452
484
  */
453
- async function getEventCount(specDir, sessionId) {
485
+ async function getNextEventSeq(specDir, sessionId) {
454
486
  const eventsPath = getSessionEventsPath(specDir, sessionId);
487
+ let fileHandle = null;
455
488
  try {
456
- const content = await fsPromises.readFile(eventsPath, "utf-8");
457
- const lines = content
458
- .trim()
459
- .split("\n")
460
- .filter((line) => line.length > 0);
461
- return lines.length;
489
+ fileHandle = await fsPromises.open(eventsPath, "r");
490
+ const stats = await fileHandle.stat();
491
+ if (stats.size === 0) {
492
+ return 0;
493
+ }
494
+ const readBytes = Math.min(stats.size, EVENT_SEQ_TAIL_READ_BYTES);
495
+ const startOffset = stats.size - readBytes;
496
+ const buffer = Buffer.alloc(readBytes);
497
+ await fileHandle.read(buffer, 0, readBytes, startOffset);
498
+ let tailContent = buffer.toString("utf-8");
499
+ if (startOffset > 0) {
500
+ // Drop potential partial first line from tail slice.
501
+ const firstNewline = tailContent.indexOf("\n");
502
+ tailContent = firstNewline === -1 ? "" : tailContent.slice(firstNewline + 1);
503
+ }
504
+ const tailSeq = extractLastEventSeq(tailContent);
505
+ if (tailSeq !== null) {
506
+ return tailSeq + 1;
507
+ }
508
+ }
509
+ catch (error) {
510
+ if (error.code === "ENOENT") {
511
+ return 0;
512
+ }
513
+ }
514
+ finally {
515
+ await fileHandle?.close().catch(() => undefined);
516
+ }
517
+ try {
518
+ const fullContent = await fsPromises.readFile(eventsPath, "utf-8");
519
+ const fullSeq = extractLastEventSeq(fullContent);
520
+ return fullSeq === null ? 0 : fullSeq + 1;
462
521
  }
463
522
  catch {
464
523
  return 0;
@@ -487,8 +546,8 @@ export async function appendEvent(specDir, input) {
487
546
  const eventsPath = getSessionEventsPath(specDir, input.session_id);
488
547
  // Ensure session directory exists (lazy creation)
489
548
  await fsPromises.mkdir(sessionDir, { recursive: true });
490
- // Get current event count for seq assignment
491
- const seq = input.seq ?? (await getEventCount(specDir, input.session_id));
549
+ // Derive next sequence number from the last stored event.
550
+ const seq = input.seq ?? (await getNextEventSeq(specDir, input.session_id));
492
551
  // Build full event
493
552
  const event = {
494
553
  ts: input.ts ?? Date.now(),
@@ -765,6 +824,321 @@ export async function getLastEvent(specDir, sessionId) {
765
824
  }
766
825
  return events[events.length - 1];
767
826
  }
827
+ // ─── Stale Session Candidate Selection ──────────────────────────────────────
828
+ const RELATIVE_DURATION_PATTERN = /^(\d+)([hdwm])$/i;
829
+ const STALE_DEFAULTS = {
830
+ olderThan: "24h",
831
+ inactiveFor: "6h",
832
+ livenessGuard: "5m",
833
+ };
834
+ function durationGuidance(flag) {
835
+ return `--${flag} accepts relative durations only (h, d, w, m), for example 6h, 7d, 2w, 1m`;
836
+ }
837
+ function parseRelativeDurationMs(rawValue) {
838
+ const match = rawValue.match(RELATIVE_DURATION_PATTERN);
839
+ if (!match)
840
+ return null;
841
+ const amount = parseInt(match[1], 10);
842
+ const unit = match[2].toLowerCase();
843
+ if (Number.isNaN(amount))
844
+ return null;
845
+ switch (unit) {
846
+ case "h":
847
+ return amount * 60 * 60 * 1000;
848
+ case "d":
849
+ return amount * 24 * 60 * 60 * 1000;
850
+ case "w":
851
+ return amount * 7 * 24 * 60 * 60 * 1000;
852
+ case "m":
853
+ return amount * 60 * 1000;
854
+ default:
855
+ return null;
856
+ }
857
+ }
858
+ function parseCriteriaDuration(field, value) {
859
+ const parsed = parseRelativeDurationMs(value);
860
+ if (parsed !== null) {
861
+ return { ok: true, ms: parsed };
862
+ }
863
+ const parsedDate = new Date(value);
864
+ const appearsAbsolute = !Number.isNaN(parsedDate.getTime());
865
+ return {
866
+ ok: false,
867
+ field,
868
+ value,
869
+ message: appearsAbsolute
870
+ ? `Invalid value for --${field}: "${value}" (absolute timestamps are not supported)`
871
+ : `Invalid value for --${field}: "${value}"`,
872
+ guidance: durationGuidance(field),
873
+ };
874
+ }
875
+ export function resolveStaleSessionCriteria(input) {
876
+ const olderThan = input.olderThan ?? STALE_DEFAULTS.olderThan;
877
+ const inactiveFor = input.inactiveFor ?? STALE_DEFAULTS.inactiveFor;
878
+ const livenessGuard = input.livenessGuard ?? STALE_DEFAULTS.livenessGuard;
879
+ const olderThanParsed = parseCriteriaDuration("older-than", olderThan);
880
+ if (!olderThanParsed.ok)
881
+ return olderThanParsed;
882
+ const olderThanMs = olderThanParsed.ms;
883
+ const inactiveForParsed = parseCriteriaDuration("inactive-for", inactiveFor);
884
+ if (!inactiveForParsed.ok)
885
+ return inactiveForParsed;
886
+ const inactiveForMs = inactiveForParsed.ms;
887
+ const livenessGuardParsed = parseCriteriaDuration("liveness-guard", livenessGuard);
888
+ if (!livenessGuardParsed.ok)
889
+ return livenessGuardParsed;
890
+ const livenessGuardMs = livenessGuardParsed.ms;
891
+ return {
892
+ ok: true,
893
+ criteria: {
894
+ olderThan,
895
+ olderThanMs,
896
+ inactiveFor,
897
+ inactiveForMs,
898
+ livenessGuard,
899
+ livenessGuardMs,
900
+ },
901
+ };
902
+ }
903
+ /**
904
+ * Resolve most recent activity timestamp for stale-session candidate checks.
905
+ *
906
+ * Unlike readEvents(), this is strict: corrupt events are surfaced as failures
907
+ * so stale auto-close can skip unsafe sessions.
908
+ */
909
+ export async function getSessionActivityForStaleCheck(specDir, sessionId) {
910
+ const metadata = await getSession(specDir, sessionId);
911
+ if (!metadata) {
912
+ return {
913
+ ok: false,
914
+ reason: "invalid_started_at",
915
+ detail: `Session metadata missing or unreadable for ${sessionId}`,
916
+ };
917
+ }
918
+ const startedAtTs = new Date(metadata.started_at).getTime();
919
+ if (Number.isNaN(startedAtTs)) {
920
+ return {
921
+ ok: false,
922
+ reason: "invalid_started_at",
923
+ detail: `Session ${sessionId} has invalid started_at timestamp`,
924
+ };
925
+ }
926
+ const eventsPath = getSessionEventsPath(specDir, sessionId);
927
+ let content;
928
+ try {
929
+ content = await fsPromises.readFile(eventsPath, "utf-8");
930
+ }
931
+ catch (err) {
932
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
933
+ return {
934
+ ok: true,
935
+ activity: {
936
+ lastActivityAt: metadata.started_at,
937
+ lastActivityTs: startedAtTs,
938
+ source: "started_at",
939
+ },
940
+ };
941
+ }
942
+ const message = err instanceof Error ? err.message : String(err);
943
+ return {
944
+ ok: false,
945
+ reason: "events_unreadable",
946
+ detail: `Unable to read events.jsonl for ${sessionId}: ${message}`,
947
+ };
948
+ }
949
+ const lines = content
950
+ .split("\n")
951
+ .map((line) => line.trim())
952
+ .filter((line) => line.length > 0);
953
+ if (lines.length === 0) {
954
+ return {
955
+ ok: true,
956
+ activity: {
957
+ lastActivityAt: metadata.started_at,
958
+ lastActivityTs: startedAtTs,
959
+ source: "started_at",
960
+ },
961
+ };
962
+ }
963
+ let latestTs = null;
964
+ for (let i = 0; i < lines.length; i++) {
965
+ const line = lines[i];
966
+ try {
967
+ const parsed = JSON.parse(line);
968
+ const event = SessionEventSchema.parse(parsed);
969
+ if (latestTs === null || event.ts > latestTs) {
970
+ latestTs = event.ts;
971
+ }
972
+ }
973
+ catch {
974
+ return {
975
+ ok: false,
976
+ reason: "events_corrupt",
977
+ detail: `Corrupt events.jsonl for ${sessionId}: invalid line ${i + 1}`,
978
+ };
979
+ }
980
+ }
981
+ if (latestTs === null) {
982
+ return {
983
+ ok: true,
984
+ activity: {
985
+ lastActivityAt: metadata.started_at,
986
+ lastActivityTs: startedAtTs,
987
+ source: "started_at",
988
+ },
989
+ };
990
+ }
991
+ return {
992
+ ok: true,
993
+ activity: {
994
+ lastActivityAt: new Date(latestTs).toISOString(),
995
+ lastActivityTs: latestTs,
996
+ source: "event",
997
+ },
998
+ };
999
+ }
1000
+ export function evaluateStaleSession(startedAt, activity, criteria, nowMs = Date.now()) {
1001
+ const startedAtMs = new Date(startedAt).getTime();
1002
+ const ageMs = nowMs - startedAtMs;
1003
+ const inactivityMs = nowMs - activity.lastActivityTs;
1004
+ const meetsAgeThreshold = ageMs >= criteria.olderThanMs;
1005
+ const meetsInactivityThreshold = inactivityMs >= criteria.inactiveForMs;
1006
+ const blockedByLivenessGuard = inactivityMs < criteria.livenessGuardMs;
1007
+ const eligible = meetsAgeThreshold &&
1008
+ meetsInactivityThreshold &&
1009
+ !blockedByLivenessGuard;
1010
+ return {
1011
+ startedAt,
1012
+ lastActivityAt: activity.lastActivityAt,
1013
+ lastActivitySource: activity.source,
1014
+ ageMs,
1015
+ inactivityMs,
1016
+ meetsAgeThreshold,
1017
+ meetsInactivityThreshold,
1018
+ blockedByLivenessGuard,
1019
+ eligible,
1020
+ };
1021
+ }
1022
+ export async function selectStaleActiveSessions(specDir, criteriaInput = {}, nowMs = Date.now()) {
1023
+ const criteriaResolved = resolveStaleSessionCriteria(criteriaInput);
1024
+ if (!criteriaResolved.ok) {
1025
+ throw new Error(`${criteriaResolved.message}. ${criteriaResolved.guidance}`);
1026
+ }
1027
+ const criteria = criteriaResolved.criteria;
1028
+ const sessionIds = await listSessions(specDir);
1029
+ const evaluations = [];
1030
+ const candidates = [];
1031
+ const skipped = [];
1032
+ for (const sessionId of sessionIds) {
1033
+ const metadata = await getSession(specDir, sessionId);
1034
+ if (!metadata || metadata.status !== "active")
1035
+ continue;
1036
+ const activityResult = await getSessionActivityForStaleCheck(specDir, sessionId);
1037
+ if (!activityResult.ok) {
1038
+ skipped.push({
1039
+ sessionId,
1040
+ reason: activityResult.reason,
1041
+ detail: activityResult.detail,
1042
+ });
1043
+ continue;
1044
+ }
1045
+ const evaluation = evaluateStaleSession(metadata.started_at, activityResult.activity, criteria, nowMs);
1046
+ const withSessionId = {
1047
+ sessionId,
1048
+ ...evaluation,
1049
+ };
1050
+ evaluations.push(withSessionId);
1051
+ if (withSessionId.eligible) {
1052
+ candidates.push(withSessionId);
1053
+ }
1054
+ }
1055
+ return {
1056
+ criteria,
1057
+ totalActiveSessions: evaluations.length + skipped.length,
1058
+ evaluations,
1059
+ candidates,
1060
+ skipped,
1061
+ skippedCount: skipped.length,
1062
+ failureCount: skipped.length,
1063
+ };
1064
+ }
1065
+ /**
1066
+ * Build canonical close_reason for stale auto-abandon updates.
1067
+ *
1068
+ * Canonical format:
1069
+ * auto-abandoned:older-than=<duration>,inactive-for=<duration>,liveness-guard=<duration>,last-activity=<iso>
1070
+ */
1071
+ export function buildAutoAbandonedCloseReason(criteria, evaluation) {
1072
+ const segments = [
1073
+ `older-than=${criteria.olderThan}`,
1074
+ `inactive-for=${criteria.inactiveFor}`,
1075
+ `liveness-guard=${criteria.livenessGuard}`,
1076
+ `last-activity=${evaluation.lastActivityAt}`,
1077
+ ];
1078
+ return `auto-abandoned:${segments.join(",")}`;
1079
+ }
1080
+ /**
1081
+ * Apply abandoned metadata to stale session candidates.
1082
+ *
1083
+ * All updates in a single invocation share one ended_at timestamp, which lets
1084
+ * the caller persist and commit the batch atomically with one shadow commit.
1085
+ */
1086
+ export async function applyAutoAbandonMetadata(specDir, selection, options) {
1087
+ const dryRun = options?.dryRun === true;
1088
+ const endedAt = new Date(options?.nowMs ?? Date.now()).toISOString();
1089
+ const updates = [];
1090
+ for (const candidate of selection.candidates) {
1091
+ const closeReason = buildAutoAbandonedCloseReason(selection.criteria, candidate);
1092
+ updates.push({
1093
+ sessionId: candidate.sessionId,
1094
+ status: "abandoned",
1095
+ endedAt,
1096
+ closeReason,
1097
+ });
1098
+ if (dryRun)
1099
+ continue;
1100
+ const metadata = await getSession(specDir, candidate.sessionId);
1101
+ if (!metadata)
1102
+ continue;
1103
+ const updated = {
1104
+ ...metadata,
1105
+ status: "abandoned",
1106
+ ended_at: endedAt,
1107
+ close_reason: closeReason,
1108
+ };
1109
+ const metadataPath = getSessionMetadataPath(specDir, candidate.sessionId);
1110
+ const content = YAML.stringify(updated, {
1111
+ indent: 2,
1112
+ lineWidth: 100,
1113
+ sortMapEntries: false,
1114
+ });
1115
+ await fsPromises.writeFile(metadataPath, content, "utf-8");
1116
+ }
1117
+ let shadowCommitted;
1118
+ if (!dryRun && updates.length > 0 && options?.shadowCommitMessage) {
1119
+ shadowCommitted = await shadowAutoCommit(specDir, options.shadowCommitMessage);
1120
+ }
1121
+ return {
1122
+ dryRun,
1123
+ updatedCount: updates.length,
1124
+ updates,
1125
+ shadowCommitted,
1126
+ };
1127
+ }
1128
+ // ─── Session Log Summaries ───────────────────────────────────────────────────
1129
+ /**
1130
+ * Determine session type from metadata for display.
1131
+ * - "invocation": New agent runtime session (has trigger != "legacy" or agent_id)
1132
+ * - "loop": Legacy ralph loop session (no trigger, or trigger === "legacy")
1133
+ *
1134
+ * AC: @session-model-evolution ac-6
1135
+ */
1136
+ function resolveSessionType(metadata) {
1137
+ if (metadata.trigger && metadata.trigger !== "legacy") {
1138
+ return "invocation";
1139
+ }
1140
+ return "loop";
1141
+ }
768
1142
  /**
769
1143
  * Count lines in events.jsonl without parsing JSON.
770
1144
  * Much faster than readEvents() for large files.
@@ -798,8 +1172,13 @@ async function countIterations(specDir, sessionId) {
798
1172
  * Count task completions by scanning events for tool calls that invoke
799
1173
  * `kspec task complete` or `npm run dev -- task complete`.
800
1174
  *
801
- * Real sessions record task completions as session.update events with
802
- * sessionUpdate: "tool_call" and rawInput.command containing the complete command.
1175
+ * Handles multiple adapter formats:
1176
+ * - claude-code-acp: rawInput.command is a string in tool_call_update events
1177
+ * - claude-agent-acp: rawInput.command is a string in tool_call_update events
1178
+ * (initial tool_call event has empty rawInput {})
1179
+ * - codex-acp: rawInput.command is an array ['/usr/bin/bash', '-lc', 'kspec task complete @ref']
1180
+ * in tool_call events; actual command is at index 2
1181
+ *
803
1182
  * We use a fast substring check before JSON parsing for performance.
804
1183
  */
805
1184
  async function countTaskCompletions(specDir, sessionId) {
@@ -814,12 +1193,15 @@ async function countTaskCompletions(specDir, sessionId) {
814
1193
  // Quick substring pre-filter: only parse lines that might contain task complete commands
815
1194
  if (!line.includes("task complete"))
816
1195
  continue;
817
- // Must be a tool_call event (session.update with sessionUpdate: "tool_call")
818
- if (!line.includes('"tool_call"'))
819
- continue;
820
1196
  try {
821
1197
  const event = JSON.parse(line);
822
- const command = event?.data?.update?.rawInput?.command;
1198
+ const rawCommand = event?.data?.update?.rawInput?.command;
1199
+ // Normalize: string (claude-*-acp) or array like [bash, -lc, cmd] (codex-acp)
1200
+ const command = typeof rawCommand === "string"
1201
+ ? rawCommand
1202
+ : Array.isArray(rawCommand) && rawCommand.length > 0
1203
+ ? rawCommand[rawCommand.length - 1]
1204
+ : undefined;
823
1205
  if (typeof command === "string" && /\btask complete\b/.test(command)) {
824
1206
  count++;
825
1207
  }
@@ -861,6 +1243,7 @@ export async function getSessionLogSummary(specDir, sessionId) {
861
1243
  id: metadata.id,
862
1244
  status: metadata.status,
863
1245
  agent_type: metadata.agent_type,
1246
+ session_type: resolveSessionType(metadata),
864
1247
  started_at: metadata.started_at,
865
1248
  ended_at: metadata.ended_at,
866
1249
  duration_ms: durationMs,
@@ -1015,7 +1398,13 @@ function extractTaskTransitions(events) {
1015
1398
  for (const event of events) {
1016
1399
  if (event.type === "session.update") {
1017
1400
  const data = event.data;
1018
- const command = data?.update?.rawInput?.command;
1401
+ const rawCommand = data?.update?.rawInput?.command;
1402
+ // Normalize: string (claude-*-acp) or array like [bash, -lc, cmd] (codex-acp)
1403
+ const command = typeof rawCommand === "string"
1404
+ ? rawCommand
1405
+ : Array.isArray(rawCommand) && rawCommand.length > 0
1406
+ ? rawCommand[rawCommand.length - 1]
1407
+ : undefined;
1019
1408
  if (typeof command === "string") {
1020
1409
  if (/\btask start\b/.test(command)) {
1021
1410
  const ref = extractTaskRef(command);
@@ -1194,6 +1583,7 @@ export async function getSessionLogDetail(specDir, sessionId) {
1194
1583
  id: metadata.id,
1195
1584
  status: metadata.status,
1196
1585
  agent_type: metadata.agent_type,
1586
+ session_type: resolveSessionType(metadata),
1197
1587
  task_id: metadata.task_id,
1198
1588
  started_at: metadata.started_at,
1199
1589
  ended_at: metadata.ended_at,
@@ -1232,6 +1622,8 @@ export function computeSessionLogStats(summaries) {
1232
1622
  active: 0,
1233
1623
  completed: 0,
1234
1624
  abandoned: 0,
1625
+ timed_out: 0,
1626
+ failed: 0,
1235
1627
  };
1236
1628
  for (const s of summaries) {
1237
1629
  totalEvents += s.event_count;
@@ -1243,7 +1635,7 @@ export function computeSessionLogStats(summaries) {
1243
1635
  const n = summaries.length;
1244
1636
  // Build status breakdown
1245
1637
  const statusBreakdown = [];
1246
- for (const status of ["completed", "active", "abandoned"]) {
1638
+ for (const status of ["completed", "active", "abandoned", "timed_out", "failed"]) {
1247
1639
  const count = statusCounts[status] || 0;
1248
1640
  if (count > 0) {
1249
1641
  statusBreakdown.push({