@kynetic-ai/spec 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/cli/commands/guard.d.ts +43 -0
  2. package/dist/cli/commands/guard.d.ts.map +1 -0
  3. package/dist/cli/commands/guard.js +200 -0
  4. package/dist/cli/commands/guard.js.map +1 -0
  5. package/dist/cli/commands/index.d.ts +1 -0
  6. package/dist/cli/commands/index.d.ts.map +1 -1
  7. package/dist/cli/commands/index.js +1 -0
  8. package/dist/cli/commands/index.js.map +1 -1
  9. package/dist/cli/commands/item.d.ts.map +1 -1
  10. package/dist/cli/commands/item.js +60 -23
  11. package/dist/cli/commands/item.js.map +1 -1
  12. package/dist/cli/commands/plan-import.js +51 -12
  13. package/dist/cli/commands/plan-import.js.map +1 -1
  14. package/dist/cli/commands/ralph.d.ts.map +1 -1
  15. package/dist/cli/commands/ralph.js +144 -329
  16. package/dist/cli/commands/ralph.js.map +1 -1
  17. package/dist/cli/commands/session/checkpoint.d.ts +19 -0
  18. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -0
  19. package/dist/cli/commands/session/checkpoint.js +161 -0
  20. package/dist/cli/commands/session/checkpoint.js.map +1 -0
  21. package/dist/cli/commands/session/commands.d.ts +18 -0
  22. package/dist/cli/commands/session/commands.d.ts.map +1 -0
  23. package/dist/cli/commands/session/commands.js +259 -0
  24. package/dist/cli/commands/session/commands.js.map +1 -0
  25. package/dist/cli/commands/session/context.d.ts +17 -0
  26. package/dist/cli/commands/session/context.d.ts.map +1 -0
  27. package/dist/cli/commands/session/context.js +493 -0
  28. package/dist/cli/commands/session/context.js.map +1 -0
  29. package/dist/cli/commands/session/create.d.ts +29 -0
  30. package/dist/cli/commands/session/create.d.ts.map +1 -0
  31. package/dist/cli/commands/session/create.js +147 -0
  32. package/dist/cli/commands/session/create.js.map +1 -0
  33. package/dist/cli/commands/session/format.d.ts +27 -0
  34. package/dist/cli/commands/session/format.d.ts.map +1 -0
  35. package/dist/cli/commands/session/format.js +401 -0
  36. package/dist/cli/commands/session/format.js.map +1 -0
  37. package/dist/cli/commands/session/index.d.ts +13 -0
  38. package/dist/cli/commands/session/index.d.ts.map +1 -0
  39. package/dist/cli/commands/session/index.js +17 -0
  40. package/dist/cli/commands/session/index.js.map +1 -0
  41. package/dist/cli/commands/session/log.d.ts +52 -0
  42. package/dist/cli/commands/session/log.d.ts.map +1 -0
  43. package/dist/cli/commands/session/log.js +570 -0
  44. package/dist/cli/commands/session/log.js.map +1 -0
  45. package/dist/cli/commands/session/types.d.ts +230 -0
  46. package/dist/cli/commands/session/types.d.ts.map +1 -0
  47. package/dist/cli/commands/session/types.js +7 -0
  48. package/dist/cli/commands/session/types.js.map +1 -0
  49. package/dist/cli/commands/session.d.ts +4 -179
  50. package/dist/cli/commands/session.d.ts.map +1 -1
  51. package/dist/cli/commands/session.js +6 -1424
  52. package/dist/cli/commands/session.js.map +1 -1
  53. package/dist/cli/commands/setup.d.ts.map +1 -1
  54. package/dist/cli/commands/setup.js +69 -223
  55. package/dist/cli/commands/setup.js.map +1 -1
  56. package/dist/cli/commands/task.d.ts.map +1 -1
  57. package/dist/cli/commands/task.js +95 -37
  58. package/dist/cli/commands/task.js.map +1 -1
  59. package/dist/cli/commands/validate.d.ts.map +1 -1
  60. package/dist/cli/commands/validate.js +23 -7
  61. package/dist/cli/commands/validate.js.map +1 -1
  62. package/dist/cli/index.d.ts.map +1 -1
  63. package/dist/cli/index.js +2 -1
  64. package/dist/cli/index.js.map +1 -1
  65. package/dist/cli/output.d.ts.map +1 -1
  66. package/dist/cli/output.js +14 -2
  67. package/dist/cli/output.js.map +1 -1
  68. package/dist/parser/file-lock.d.ts +14 -0
  69. package/dist/parser/file-lock.d.ts.map +1 -0
  70. package/dist/parser/file-lock.js +124 -0
  71. package/dist/parser/file-lock.js.map +1 -0
  72. package/dist/parser/index.d.ts +1 -0
  73. package/dist/parser/index.d.ts.map +1 -1
  74. package/dist/parser/index.js +1 -0
  75. package/dist/parser/index.js.map +1 -1
  76. package/dist/parser/plan-document.d.ts +36 -0
  77. package/dist/parser/plan-document.d.ts.map +1 -1
  78. package/dist/parser/plan-document.js +75 -8
  79. package/dist/parser/plan-document.js.map +1 -1
  80. package/dist/parser/plans.d.ts.map +1 -1
  81. package/dist/parser/plans.js +28 -102
  82. package/dist/parser/plans.js.map +1 -1
  83. package/dist/parser/shadow.d.ts +5 -1
  84. package/dist/parser/shadow.d.ts.map +1 -1
  85. package/dist/parser/shadow.js +29 -17
  86. package/dist/parser/shadow.js.map +1 -1
  87. package/dist/parser/validate.d.ts +4 -1
  88. package/dist/parser/validate.d.ts.map +1 -1
  89. package/dist/parser/validate.js +50 -35
  90. package/dist/parser/validate.js.map +1 -1
  91. package/dist/parser/yaml.d.ts.map +1 -1
  92. package/dist/parser/yaml.js +322 -297
  93. package/dist/parser/yaml.js.map +1 -1
  94. package/dist/schema/task.d.ts +22 -0
  95. package/dist/schema/task.d.ts.map +1 -1
  96. package/dist/schema/task.js +7 -0
  97. package/dist/schema/task.js.map +1 -1
  98. package/dist/sessions/store.d.ts +254 -1
  99. package/dist/sessions/store.d.ts.map +1 -1
  100. package/dist/sessions/store.js +621 -1
  101. package/dist/sessions/store.js.map +1 -1
  102. package/dist/sessions/types.d.ts +51 -2
  103. package/dist/sessions/types.d.ts.map +1 -1
  104. package/dist/sessions/types.js +25 -0
  105. package/dist/sessions/types.js.map +1 -1
  106. package/dist/strings/labels.d.ts +2 -0
  107. package/dist/strings/labels.d.ts.map +1 -1
  108. package/dist/strings/labels.js +2 -0
  109. package/dist/strings/labels.js.map +1 -1
  110. package/dist/utils/git.d.ts +2 -0
  111. package/dist/utils/git.d.ts.map +1 -1
  112. package/dist/utils/git.js +21 -5
  113. package/dist/utils/git.js.map +1 -1
  114. package/package.json +4 -1
  115. package/plugin/.claude-plugin/marketplace.json +1 -1
  116. package/plugin/.claude-plugin/plugin.json +1 -1
  117. package/plugin/plugins/kspec/skills/review/SKILL.md +37 -0
  118. package/plugin/plugins/kspec/skills/task-work/SKILL.md +16 -0
  119. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
  120. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +14 -0
  121. package/templates/agents-sections/05-commit-convention.md +14 -0
  122. package/templates/skills/review/SKILL.md +37 -0
  123. package/templates/skills/task-work/SKILL.md +16 -0
  124. package/templates/skills/triage-inbox/SKILL.md +1 -1
  125. package/templates/skills/writing-specs/SKILL.md +14 -0
@@ -1,1428 +1,10 @@
1
1
  /**
2
- * Session management commands
2
+ * Session management commands — barrel re-export.
3
3
  *
4
- * Provides context for starting/resuming work sessions.
4
+ * The implementation has been split into focused modules under ./session/.
5
+ * This file re-exports the public API for backward compatibility.
5
6
  */
6
- import chalk from "chalk";
7
- import { getReadyTasks, initContext, loadAllItems, loadAllTasks, loadInboxItems, loadSessionContext, ReferenceIndex, } from "../../parser/index.js";
8
- import { ShadowError, shadowPull, } from "../../parser/shadow.js";
9
- import { getAllSessionLogSummaries, getSessionLogDetail, resolveSessionId, readEvents, readSessionContext, computeSessionLogStats, computeToolUsageStats, computeTimePeriodStats, searchSessionEvents, deduplicatePhasedToolCalls, } from "../../sessions/store.js";
10
- import { errors, hints, sessionHeaders, sessionPrompt, } from "../../strings/index.js";
11
- import { formatCommitGuidance, formatRelativeTime, getCurrentBranch, getRecentCommits, getWorkingTreeStatus, isGitRepo, parseTimeSpec, } from "../../utils/index.js";
12
- import { EXIT_CODES } from "../exit-codes.js";
13
- import { error, info, isJsonMode, isNoteSuperseded, output, warn } from "../output.js";
14
- // ─── Data Gathering ──────────────────────────────────────────────────────────
15
- function toActiveTaskSummary(task, index) {
16
- const lastNote = task.notes.length > 0 ? task.notes[task.notes.length - 1] : null;
17
- const incompleteTodos = task.todos.filter((t) => !t.done).length;
18
- return {
19
- ref: index.shortUlid(task._ulid),
20
- title: task.title,
21
- started_at: task.started_at || null,
22
- priority: task.priority,
23
- spec_ref: task.spec_ref || null,
24
- note_count: task.notes.length,
25
- last_note_at: lastNote ? lastNote.created_at : null,
26
- todo_count: task.todos.length,
27
- incomplete_todos: incompleteTodos,
28
- };
29
- }
30
- function toReadyTaskSummary(task, index) {
31
- return {
32
- ref: index.shortUlid(task._ulid),
33
- title: task.title,
34
- priority: task.priority,
35
- spec_ref: task.spec_ref || null,
36
- tags: task.tags,
37
- };
38
- }
39
- function toBlockedTaskSummary(task, _allTasks, index) {
40
- // Find unmet dependencies
41
- const unmetDeps = [];
42
- for (const depRef of task.depends_on) {
43
- const result = index.resolve(depRef);
44
- if (result.ok) {
45
- const depItem = result.item;
46
- if ("status" in depItem && depItem.status !== "completed") {
47
- unmetDeps.push(depRef);
48
- }
49
- }
50
- }
51
- return {
52
- ref: index.shortUlid(task._ulid),
53
- title: task.title,
54
- blocked_by: task.blocked_by,
55
- unmet_deps: unmetDeps,
56
- };
57
- }
58
- function toCompletedTaskSummary(task, index) {
59
- return {
60
- ref: index.shortUlid(task._ulid),
61
- title: task.title,
62
- completed_at: task.completed_at || "",
63
- closed_reason: task.closed_reason || null,
64
- origin: task.origin,
65
- };
66
- }
67
- function collectRecentNotes(tasks, index, options) {
68
- const allNotes = [];
69
- for (const task of tasks) {
70
- // Only include notes from in_progress, pending_review, or completed tasks
71
- const taskStatus = task.status;
72
- if (!["in_progress", "pending_review", "needs_work", "completed"].includes(taskStatus)) {
73
- continue;
74
- }
75
- for (const note of task.notes) {
76
- const noteDate = new Date(note.created_at);
77
- // Filter by since date if provided
78
- if (options.since && noteDate < options.since) {
79
- continue;
80
- }
81
- // Filter out superseded notes
82
- if (isNoteSuperseded(note, task.notes)) {
83
- continue;
84
- }
85
- allNotes.push({
86
- task_ref: index.shortUlid(task._ulid),
87
- task_title: task.title,
88
- task_status: taskStatus,
89
- note_ulid: note._ulid.slice(0, 8),
90
- created_at: note.created_at,
91
- author: note.author || null,
92
- content: note.content,
93
- });
94
- }
95
- }
96
- // Sort by date descending, take limit
97
- return allNotes
98
- .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
99
- .slice(0, options.limit);
100
- }
101
- function collectIncompleteTodos(tasks, index, options) {
102
- const allTodos = [];
103
- for (const task of tasks) {
104
- for (const todo of task.todos) {
105
- // Only include incomplete todos
106
- if (todo.done)
107
- continue;
108
- allTodos.push({
109
- task_ref: index.shortUlid(task._ulid),
110
- task_title: task.title,
111
- id: todo.id,
112
- text: todo.text,
113
- added_at: todo.added_at,
114
- added_by: todo.added_by || null,
115
- });
116
- }
117
- }
118
- // Sort by added_at descending (most recent first), take limit
119
- return allTodos
120
- .sort((a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime())
121
- .slice(0, options.limit);
122
- }
123
- /**
124
- * Gather session context data
125
- */
126
- export async function gatherSessionContext(ctx, options) {
127
- const limit = parseInt(options.limit || "10", 10);
128
- const sinceDate = options.since ? parseTimeSpec(options.since) : null;
129
- const showGit = options.git !== false; // default true
130
- // Load all data
131
- const allTasks = await loadAllTasks(ctx);
132
- const items = await loadAllItems(ctx);
133
- const inboxItems = await loadInboxItems(ctx);
134
- const index = new ReferenceIndex(allTasks, items);
135
- // Compute stats
136
- const stats = {
137
- total_tasks: allTasks.length,
138
- in_progress: allTasks.filter((t) => t.status === "in_progress").length,
139
- needs_work: allTasks.filter((t) => t.status === "needs_work").length,
140
- pending_review: allTasks.filter((t) => t.status === "pending_review")
141
- .length,
142
- ready: getReadyTasks(allTasks).length,
143
- blocked: allTasks.filter((t) => t.status === "blocked").length,
144
- completed: allTasks.filter((t) => t.status === "completed").length,
145
- inbox_items: inboxItems.length,
146
- };
147
- // Get active tasks (in_progress + needs_work, optionally filtered to automation-eligible only)
148
- // AC: @cli-ralph ac-16
149
- const activeTasks = allTasks
150
- .filter((t) => t.status === "in_progress" || t.status === "needs_work")
151
- .filter((t) => !options.eligible || t.automation === "eligible")
152
- .sort((a, b) => a.priority - b.priority)
153
- .slice(0, options.full ? undefined : limit)
154
- .map((t) => toActiveTaskSummary(t, index));
155
- // Get pending review tasks
156
- const pendingReviewTasks = allTasks
157
- .filter((t) => t.status === "pending_review")
158
- .sort((a, b) => a.priority - b.priority)
159
- .slice(0, options.full ? undefined : limit)
160
- .map((t) => toActiveTaskSummary(t, index));
161
- // Get recent notes from active, pending_review, and recently completed tasks
162
- // AC: @cmd-session-start ac-1, ac-2
163
- // Collect notes per-status first to prevent one status from starving others
164
- const noteLimitPerStatus = options.full ? limit : Math.ceil(limit / 3);
165
- const inProgressNotes = collectRecentNotes(allTasks.filter((t) => t.status === "in_progress" || t.status === "needs_work"), index, { limit: noteLimitPerStatus, since: sinceDate });
166
- const pendingReviewNotes = collectRecentNotes(allTasks.filter((t) => t.status === "pending_review"), index, { limit: noteLimitPerStatus, since: sinceDate });
167
- const recentlyCompletedForNotes = allTasks
168
- .filter((t) => t.status === "completed" && t.completed_at)
169
- .sort((a, b) => {
170
- const aDate = new Date(a.completed_at || 0);
171
- const bDate = new Date(b.completed_at || 0);
172
- return bDate.getTime() - aDate.getTime();
173
- })
174
- .slice(0, 5); // Last 3-5 completed tasks per AC-2
175
- const completedNotes = collectRecentNotes(recentlyCompletedForNotes, index, { limit: noteLimitPerStatus, since: sinceDate });
176
- // Combine notes from all statuses, preserving representation from each
177
- const recentNotes = [...inProgressNotes, ...pendingReviewNotes, ...completedNotes]
178
- .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
179
- // Get incomplete todos from active tasks
180
- const activeTodos = collectIncompleteTodos(allTasks.filter((t) => t.status === "in_progress" || t.status === "needs_work"), index, { limit: options.full ? limit * 2 : limit });
181
- // Get ready tasks (optionally filtered to automation-eligible only)
182
- const readyTasks = getReadyTasks(allTasks)
183
- .filter((t) => !options.eligible || t.automation === "eligible")
184
- .slice(0, options.full ? undefined : limit)
185
- .map((t) => toReadyTaskSummary(t, index));
186
- // Get blocked tasks
187
- const blockedTasks = allTasks
188
- .filter((t) => t.status === "blocked")
189
- .slice(0, options.full ? undefined : limit)
190
- .map((t) => toBlockedTaskSummary(t, allTasks, index));
191
- // Get recently completed tasks
192
- const recentlyCompleted = allTasks
193
- .filter((t) => {
194
- if (t.status !== "completed" || !t.completed_at)
195
- return false;
196
- const completedDate = new Date(t.completed_at);
197
- if (sinceDate && completedDate < sinceDate)
198
- return false;
199
- return true;
200
- })
201
- .sort((a, b) => {
202
- // Sort by completed_at descending (most recent first)
203
- const aDate = new Date(a.completed_at || 0);
204
- const bDate = new Date(b.completed_at || 0);
205
- return bDate.getTime() - aDate.getTime();
206
- })
207
- .slice(0, options.full ? undefined : limit)
208
- .map((t) => toCompletedTaskSummary(t, index));
209
- // Get git info
210
- let branch = null;
211
- let recentCommits = [];
212
- let workingTree = null;
213
- if (showGit && isGitRepo(ctx.rootDir)) {
214
- branch = getCurrentBranch(ctx.rootDir);
215
- const commits = getRecentCommits({
216
- limit: options.full ? limit * 2 : limit,
217
- since: sinceDate || undefined,
218
- cwd: ctx.rootDir,
219
- });
220
- recentCommits = commits.map((c) => ({
221
- hash: c.hash,
222
- full_hash: c.fullHash,
223
- date: c.date.toISOString(),
224
- message: c.message,
225
- author: c.author,
226
- }));
227
- workingTree = getWorkingTreeStatus(ctx.rootDir);
228
- }
229
- // Get inbox items (oldest first to encourage triage)
230
- const inboxSummaries = inboxItems
231
- .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
232
- .slice(0, options.full ? undefined : limit)
233
- .map((item) => ({
234
- ref: item._ulid.slice(0, 8),
235
- text: item.text,
236
- created_at: item.created_at,
237
- tags: item.tags,
238
- added_by: item.added_by || null,
239
- }));
240
- // Load session context (focus, threads, questions)
241
- const sessionContext = await loadSessionContext(ctx);
242
- return {
243
- generated_at: new Date().toISOString(),
244
- branch,
245
- context: sessionContext,
246
- active_tasks: activeTasks,
247
- pending_review_tasks: pendingReviewTasks,
248
- recent_notes: recentNotes,
249
- active_todos: activeTodos,
250
- ready_tasks: readyTasks,
251
- blocked_tasks: blockedTasks,
252
- recently_completed: recentlyCompleted,
253
- recent_commits: recentCommits,
254
- working_tree: workingTree,
255
- inbox_items: inboxSummaries,
256
- stats,
257
- };
258
- }
259
- /**
260
- * Get iteration stats - tasks completed/started since a given time.
261
- * AC: @ralph-task-limit ac-detection
262
- */
263
- export async function getIterationStats(ctx, since) {
264
- const allTasks = await loadAllTasks(ctx);
265
- const completedSince = allTasks.filter((t) => {
266
- if (t.status !== "completed" || !t.completed_at)
267
- return false;
268
- return new Date(t.completed_at) >= since;
269
- });
270
- const startedSince = allTasks.filter((t) => {
271
- if (!t.started_at)
272
- return false;
273
- return new Date(t.started_at) >= since;
274
- });
275
- // Use first slug or ULID prefix as ref
276
- const getRef = (t) => t.slugs.length > 0 ? `@${t.slugs[0]}` : `@${t._ulid.slice(0, 8)}`;
277
- return {
278
- tasks_completed: completedSince.length,
279
- tasks_started: startedSince.length,
280
- completed_refs: completedSince.map(getRef),
281
- };
282
- }
283
- /**
284
- * Perform session checkpoint - check for uncommitted work before ending session.
285
- *
286
- * This is designed for use as a Claude Code stop hook. It checks for:
287
- * - Uncommitted git changes (staged, unstaged, untracked)
288
- * - Tasks in in_progress status
289
- * - Incomplete todos on active tasks
290
- *
291
- * Returns a structured result indicating whether the session can end cleanly.
292
- */
293
- export async function performCheckpoint(ctx, options) {
294
- const issues = [];
295
- const instructions = [];
296
- // Load tasks
297
- const allTasks = await loadAllTasks(ctx);
298
- // Check for in-progress tasks
299
- const inProgressTasks = allTasks.filter((t) => t.status === "in_progress");
300
- for (const task of inProgressTasks) {
301
- const ref = task.slugs[0]
302
- ? `@${task.slugs[0]}`
303
- : `@${task._ulid.slice(0, 8)}`;
304
- issues.push({
305
- type: "in_progress_task",
306
- description: `Task ${ref} is still in progress: ${task.title}`,
307
- details: {
308
- ref,
309
- title: task.title,
310
- started_at: task.started_at,
311
- },
312
- });
313
- // Check for incomplete todos on this task
314
- const incompleteTodos = task.todos.filter((t) => !t.done);
315
- for (const todo of incompleteTodos) {
316
- issues.push({
317
- type: "incomplete_todo",
318
- description: `Incomplete todo on ${ref}: ${todo.text}`,
319
- details: {
320
- task_ref: ref,
321
- todo_id: todo.id,
322
- text: todo.text,
323
- },
324
- });
325
- }
326
- }
327
- // Check for uncommitted git changes
328
- if (isGitRepo(ctx.rootDir)) {
329
- const workingTree = getWorkingTreeStatus(ctx.rootDir);
330
- if (!workingTree.clean) {
331
- const changeCount = workingTree.staged.length +
332
- workingTree.unstaged.length +
333
- workingTree.untracked.length;
334
- issues.push({
335
- type: "uncommitted_changes",
336
- description: `${changeCount} uncommitted changes in working tree`,
337
- details: {
338
- staged: workingTree.staged.length,
339
- unstaged: workingTree.unstaged.length,
340
- untracked: workingTree.untracked.length,
341
- },
342
- });
343
- }
344
- }
345
- // Build instructions based on issues
346
- if (issues.length > 0 && !options.force) {
347
- instructions.push("If you've been instructed to ignore this message or are working on");
348
- instructions.push("unrelated things to any in progress tasks then disregard this message,");
349
- instructions.push("otherwise before ending this session, please:");
350
- const hasInProgress = issues.some((i) => i.type === "in_progress_task");
351
- const hasUncommitted = issues.some((i) => i.type === "uncommitted_changes");
352
- const hasIncompleteTodos = issues.some((i) => i.type === "incomplete_todo");
353
- let step = 1;
354
- if (hasInProgress) {
355
- instructions.push(`${step++}. Read in-progress task notes to get full context of the current task status`);
356
- instructions.push(`${step++}. Add notes documenting current state if any context is missing from this session`);
357
- instructions.push(`${step++}. Complete the task if you've completed the objectives and no AC are left uncovered\notherwise leave it in progress for a future session`);
358
- }
359
- if (hasIncompleteTodos) {
360
- instructions.push(`${step++}. Complete or acknowledge incomplete todos on active tasks`);
361
- }
362
- if (hasUncommitted) {
363
- instructions.push(`${step++}. Commit your changes with a descriptive message`);
364
- // Add WIP commit guidance if there are in-progress tasks
365
- if (inProgressTasks.length > 0) {
366
- const task = inProgressTasks[0];
367
- const guidance = formatCommitGuidance(task, { wip: true });
368
- instructions.push("");
369
- instructions.push("Suggested WIP commit:");
370
- instructions.push(` ${guidance.message}`);
371
- instructions.push("");
372
- for (const trailer of guidance.trailers) {
373
- instructions.push(` ${trailer}`);
374
- }
375
- }
376
- }
377
- instructions.push("");
378
- instructions.push("Use: kspec task @task to get current task state");
379
- instructions.push('Use: kspec task note @task "Progress notes..." to document state');
380
- instructions.push('Use: kspec task complete @task --reason "Summary" if task is done');
381
- }
382
- // Allow stop if:
383
- // - No issues found
384
- // - --force flag passed
385
- // - This is a retry (stop_hook_active = true from previous block)
386
- const isRetry = options.stopHookActive === true;
387
- const ok = issues.length === 0 || options.force === true || isRetry;
388
- let message;
389
- if (isRetry && issues.length > 0) {
390
- message = `[kspec] Session checkpoint: ${issues.length} issue(s) acknowledged - allowing stop`;
391
- }
392
- else if (ok) {
393
- message = "[kspec] Session checkpoint passed - ready to end session";
394
- }
395
- else {
396
- message = `[kspec] Session checkpoint: ${issues.length} issue(s) need attention`;
397
- }
398
- return {
399
- ok,
400
- message,
401
- issues,
402
- instructions,
403
- };
404
- }
405
- // ─── Output Formatting ───────────────────────────────────────────────────────
406
- function formatCheckpointResult(result) {
407
- if (result.ok) {
408
- console.log(chalk.green(result.message));
409
- }
410
- else {
411
- console.log(chalk.yellow(result.message));
412
- console.log("");
413
- for (const issue of result.issues) {
414
- const icon = issue.type === "uncommitted_changes"
415
- ? chalk.yellow("⚠")
416
- : issue.type === "in_progress_task"
417
- ? chalk.blue("●")
418
- : chalk.gray("○");
419
- console.log(` ${icon} ${issue.description}`);
420
- }
421
- if (result.instructions.length > 0) {
422
- console.log("");
423
- for (const instruction of result.instructions) {
424
- console.log(chalk.gray(instruction));
425
- }
426
- }
427
- }
428
- }
429
- function formatSessionContext(ctx, options) {
430
- const isBrief = !options.full;
431
- // Header
432
- console.log(`\n${sessionHeaders.title}`);
433
- const age = formatRelativeTime(new Date(ctx.generated_at));
434
- if (ctx.branch) {
435
- console.log(chalk.gray(`Branch: ${ctx.branch} | Generated: ${age}`));
436
- }
437
- else {
438
- console.log(chalk.gray(`Generated: ${age}`));
439
- }
440
- // Stats summary
441
- const pendingReviewNote = ctx.stats.pending_review > 0
442
- ? `${ctx.stats.pending_review} awaiting review, `
443
- : "";
444
- const inboxNote = ctx.stats.inbox_items > 0 ? ` | Inbox: ${ctx.stats.inbox_items}` : "";
445
- console.log(chalk.gray(`Tasks: ${ctx.stats.in_progress} active, ${pendingReviewNote}${ctx.stats.ready} ready, ` +
446
- `${ctx.stats.blocked} blocked, ${ctx.stats.completed}/${ctx.stats.total_tasks} completed${inboxNote}`));
447
- // Session context section (focus, threads, questions)
448
- if (ctx.context &&
449
- (ctx.context.focus ||
450
- ctx.context.threads.length > 0 ||
451
- ctx.context.open_questions.length > 0)) {
452
- console.log("\n--- Session Context ---");
453
- if (ctx.context.focus) {
454
- console.log(` ${chalk.cyan("Focus:")} ${ctx.context.focus}`);
455
- }
456
- if (ctx.context.threads.length > 0) {
457
- console.log(` ${chalk.cyan("Active Threads:")}`);
458
- for (const thread of ctx.context.threads) {
459
- console.log(` - ${thread}`);
460
- }
461
- }
462
- if (ctx.context.open_questions.length > 0) {
463
- console.log(` ${chalk.cyan("Open Questions:")}`);
464
- for (const question of ctx.context.open_questions) {
465
- console.log(` - ${question}`);
466
- }
467
- }
468
- }
469
- // Active tasks section
470
- if (ctx.active_tasks.length > 0) {
471
- console.log(`\n${sessionHeaders.activeWork}`);
472
- for (const task of ctx.active_tasks) {
473
- const started = task.started_at
474
- ? chalk.gray(` (started ${formatRelativeTime(new Date(task.started_at))})`)
475
- : "";
476
- const priority = task.priority <= 2
477
- ? chalk.red(`P${task.priority}`)
478
- : chalk.gray(`P${task.priority}`);
479
- console.log(` ${chalk.blue("[in_progress]")} ${priority} ${task.ref} ${task.title}${started}`);
480
- }
481
- }
482
- else {
483
- console.log(`\n${sessionHeaders.noActiveWork}`);
484
- }
485
- // Awaiting review section
486
- if (ctx.pending_review_tasks.length > 0) {
487
- console.log(`\n${sessionHeaders.awaitingReview}`);
488
- for (const task of ctx.pending_review_tasks) {
489
- const priority = task.priority <= 2
490
- ? chalk.red(`P${task.priority}`)
491
- : chalk.gray(`P${task.priority}`);
492
- console.log(` ${chalk.yellow("[pending_review]")} ${priority} ${task.ref} ${task.title}`);
493
- }
494
- }
495
- // Recently completed section
496
- if (ctx.recently_completed.length > 0) {
497
- console.log(`\n${sessionHeaders.recentlyCompleted}`);
498
- const observationPromotedTasks = [];
499
- for (const task of ctx.recently_completed) {
500
- const completedAge = formatRelativeTime(new Date(task.completed_at));
501
- let reason = "";
502
- if (task.closed_reason) {
503
- const maxLen = isBrief ? 60 : 120;
504
- const truncated = task.closed_reason.length > maxLen
505
- ? `${task.closed_reason.slice(0, maxLen).trim()}...`
506
- : task.closed_reason;
507
- reason = chalk.gray(` - ${truncated}`);
508
- }
509
- console.log(` ${chalk.green("[completed]")} ${task.ref} ${task.title} ${chalk.gray(`(${completedAge})`)}${reason}`);
510
- // Track tasks that came from observations
511
- if (task.origin === "observation_promotion") {
512
- observationPromotedTasks.push(task.ref);
513
- }
514
- }
515
- // Show reminder about resolving observations
516
- if (observationPromotedTasks.length > 0) {
517
- console.log(chalk.yellow(`\n ℹ Consider resolving linked observations: ${observationPromotedTasks.join(", ")}`));
518
- console.log(chalk.gray(` Run: kspec meta observations --pending-resolution`));
519
- }
520
- }
521
- // Recent notes section - grouped by task status
522
- // AC: @cmd-session-start ac-1, ac-2
523
- if (ctx.recent_notes.length > 0) {
524
- console.log(`\n${sessionHeaders.recentNotes}`);
525
- // Group notes by task status
526
- const inProgressNotes = ctx.recent_notes.filter((n) => n.task_status === "in_progress");
527
- const pendingReviewNotes = ctx.recent_notes.filter((n) => n.task_status === "pending_review");
528
- const completedNotes = ctx.recent_notes.filter((n) => n.task_status === "completed");
529
- // Helper to format a single note
530
- const formatNote = (note) => {
531
- const age = formatRelativeTime(new Date(note.created_at));
532
- const author = note.author ? chalk.gray(` by ${note.author}`) : "";
533
- console.log(` ${chalk.yellow(age)} on ${note.task_ref}${author}:`);
534
- // Truncate content in brief mode
535
- let content = note.content.trim();
536
- if (isBrief && content.length > 200) {
537
- content = `${content.slice(0, 200).trim()}...`;
538
- }
539
- // Indent content, limit lines in brief mode
540
- const lines = content.split("\n");
541
- const maxLines = isBrief ? 3 : lines.length;
542
- for (const line of lines.slice(0, maxLines)) {
543
- console.log(` ${chalk.white(line)}`);
544
- }
545
- if (isBrief && lines.length > maxLines) {
546
- console.log(chalk.gray(` ... (${lines.length - maxLines} more lines)`));
547
- }
548
- };
549
- // AC: @cmd-session-start ac-1 - In Progress notes
550
- if (inProgressNotes.length > 0) {
551
- console.log(` ${chalk.blue("In Progress:")}`);
552
- for (const note of inProgressNotes) {
553
- formatNote(note);
554
- }
555
- }
556
- // AC: @cmd-session-start ac-1 - Pending Review notes (grouped separately)
557
- if (pendingReviewNotes.length > 0) {
558
- console.log(` ${chalk.yellow("Pending Review:")}`);
559
- for (const note of pendingReviewNotes) {
560
- formatNote(note);
561
- }
562
- }
563
- // AC: @cmd-session-start ac-2 - Recently Completed notes
564
- if (completedNotes.length > 0) {
565
- console.log(` ${chalk.green("Recently Completed:")}`);
566
- for (const note of completedNotes) {
567
- formatNote(note);
568
- }
569
- }
570
- }
571
- // Incomplete todos section
572
- if (ctx.active_todos.length > 0) {
573
- console.log(`\n${sessionHeaders.incompleteTodos}`);
574
- for (const todo of ctx.active_todos) {
575
- console.log(` ${chalk.yellow("[ ]")} ${todo.task_ref}#${todo.id}: ${todo.text}`);
576
- }
577
- }
578
- // Ready tasks section
579
- if (ctx.ready_tasks.length > 0) {
580
- console.log(`\n${sessionHeaders.readyTasks}`);
581
- for (const task of ctx.ready_tasks) {
582
- const priority = task.priority <= 2
583
- ? chalk.red(`P${task.priority}`)
584
- : chalk.gray(`P${task.priority}`);
585
- const tags = task.tags.length > 0 ? chalk.cyan(` #${task.tags.join(" #")}`) : "";
586
- console.log(` ${priority} ${task.ref} ${task.title}${tags}`);
587
- }
588
- }
589
- // Blocked tasks section
590
- if (ctx.blocked_tasks.length > 0) {
591
- console.log(`\n${sessionHeaders.blocked}`);
592
- for (const task of ctx.blocked_tasks) {
593
- console.log(` ${chalk.red("[blocked]")} ${task.ref} ${task.title}`);
594
- if (task.blocked_by.length > 0) {
595
- console.log(chalk.gray(` Blockers: ${task.blocked_by.join(", ")}`));
596
- }
597
- if (task.unmet_deps.length > 0) {
598
- console.log(chalk.gray(` Waiting on: ${task.unmet_deps.join(", ")}`));
599
- }
600
- }
601
- }
602
- // Git commits section
603
- if (ctx.recent_commits.length > 0) {
604
- console.log(`\n${sessionHeaders.recentCommits}`);
605
- for (const commit of ctx.recent_commits) {
606
- const age = formatRelativeTime(new Date(commit.date));
607
- console.log(` ${chalk.yellow(commit.hash)} ${commit.message} ${chalk.gray(`(${age}, ${commit.author})`)}`);
608
- }
609
- }
610
- // Inbox section (oldest first to encourage triage)
611
- if (ctx.inbox_items.length > 0) {
612
- console.log(`\n${sessionHeaders.inbox}`);
613
- for (const item of ctx.inbox_items) {
614
- const age = formatRelativeTime(new Date(item.created_at));
615
- const author = item.added_by ? ` by ${item.added_by}` : "";
616
- const tags = item.tags.length > 0 ? chalk.cyan(` [${item.tags.join(", ")}]`) : "";
617
- // Truncate text in brief mode
618
- let text = item.text;
619
- if (isBrief && text.length > 60) {
620
- text = `${text.slice(0, 60).trim()}...`;
621
- }
622
- console.log(` ${chalk.magenta(item.ref)} ${chalk.gray(`(${age}${author})`)}${tags}`);
623
- console.log(` ${text}`);
624
- }
625
- console.log(` ${hints.inboxPromote}`);
626
- }
627
- // Working tree section
628
- if (ctx.working_tree && !ctx.working_tree.clean) {
629
- console.log(`\n${sessionHeaders.workingTree}`);
630
- if (ctx.working_tree.staged.length > 0) {
631
- console.log(chalk.green(" Staged:"));
632
- for (const file of ctx.working_tree.staged) {
633
- console.log(` ${chalk.green(file.status[0].toUpperCase())} ${file.path}`);
634
- }
635
- }
636
- if (ctx.working_tree.unstaged.length > 0) {
637
- console.log(chalk.red(" Modified:"));
638
- for (const file of ctx.working_tree.unstaged) {
639
- console.log(` ${chalk.red(file.status[0].toUpperCase())} ${file.path}`);
640
- }
641
- }
642
- if (ctx.working_tree.untracked.length > 0) {
643
- console.log(chalk.gray(" Untracked:"));
644
- const limit = isBrief ? 5 : ctx.working_tree.untracked.length;
645
- for (const path of ctx.working_tree.untracked.slice(0, limit)) {
646
- console.log(` ${chalk.gray("?")} ${path}`);
647
- }
648
- if (isBrief && ctx.working_tree.untracked.length > limit) {
649
- console.log(chalk.gray(` ... and ${ctx.working_tree.untracked.length - limit} more`));
650
- }
651
- }
652
- }
653
- else if (ctx.working_tree?.clean) {
654
- console.log(`\n${sessionHeaders.workingTreeClean}`);
655
- }
656
- // Quick Commands section - contextual hints based on state
657
- const quickCommands = [];
658
- if (ctx.active_tasks.length > 0) {
659
- const ref = ctx.active_tasks[0].ref;
660
- quickCommands.push(`kspec task note @${ref} "Progress..." ${chalk.gray("# document work")}`);
661
- quickCommands.push(`kspec task complete @${ref} --reason "..." ${chalk.gray("# finish task")}`);
662
- }
663
- else if (ctx.ready_tasks.length > 0) {
664
- const ref = ctx.ready_tasks[0].ref;
665
- quickCommands.push(`kspec task start @${ref} ${chalk.gray("# begin work")}`);
666
- }
667
- if (ctx.inbox_items.length > 0) {
668
- const ref = ctx.inbox_items[0].ref;
669
- quickCommands.push(`kspec inbox promote @${ref} --title "..." ${chalk.gray("# convert to task")}`);
670
- }
671
- if (ctx.working_tree && !ctx.working_tree.clean) {
672
- quickCommands.push(`git add . && git commit -m "..." ${chalk.gray("# commit changes")}`);
673
- }
674
- if (quickCommands.length > 0) {
675
- console.log(`\n${sessionHeaders.quickCommands}`);
676
- for (const hint of quickCommands) {
677
- console.log(` ${hint}`);
678
- }
679
- }
680
- console.log(""); // Final newline
681
- }
682
- // ─── Command Registration ────────────────────────────────────────────────────
683
- async function sessionStartAction(options) {
684
- try {
685
- const ctx = await initContext();
686
- // AC: @shadow-sync ac-2 - Pull remote changes before showing session context
687
- let syncResult = null;
688
- if (ctx.shadow?.enabled) {
689
- syncResult = await shadowPull(ctx.shadow.worktreeDir);
690
- // AC: @shadow-sync ac-3 - Warn about conflicts but continue with local state
691
- if (syncResult.hadConflict) {
692
- warn("Shadow sync conflict detected. Run `kspec shadow resolve` to fix.");
693
- info("Continuing with local state...");
694
- }
695
- else if (syncResult.pulled) {
696
- info("Synced shadow branch from remote");
697
- }
698
- }
699
- const sessionCtx = await gatherSessionContext(ctx, options);
700
- output(sessionCtx, () => formatSessionContext(sessionCtx, options));
701
- }
702
- catch (err) {
703
- error(errors.failures.gatherSessionContext, err);
704
- process.exit(EXIT_CODES.ERROR);
705
- }
706
- }
707
- /**
708
- * Read stdin if available (non-blocking check for hook input)
709
- */
710
- async function readStdinIfAvailable() {
711
- // Check if stdin is a TTY (interactive) - if so, don't try to read
712
- if (process.stdin.isTTY) {
713
- return null;
714
- }
715
- return new Promise((resolve) => {
716
- let data = "";
717
- const timeout = setTimeout(() => {
718
- process.stdin.removeAllListeners();
719
- resolve(data || null);
720
- }, 100); // 100ms timeout for stdin
721
- process.stdin.setEncoding("utf8");
722
- process.stdin.on("data", (chunk) => {
723
- data += chunk;
724
- });
725
- process.stdin.on("end", () => {
726
- clearTimeout(timeout);
727
- resolve(data || null);
728
- });
729
- process.stdin.on("error", () => {
730
- clearTimeout(timeout);
731
- resolve(null);
732
- });
733
- process.stdin.resume();
734
- });
735
- }
736
- /**
737
- * Parse Claude Code hook input from stdin
738
- */
739
- function parseHookInput(stdin) {
740
- if (!stdin)
741
- return null;
742
- try {
743
- return JSON.parse(stdin.trim());
744
- }
745
- catch {
746
- return null;
747
- }
748
- }
749
- // ─── Prompt Check (UserPromptSubmit Hook) ────────────────────────────────────
750
- /**
751
- * Output spec-first reminder for UserPromptSubmit hook.
752
- *
753
- * This is a simple context injection - always outputs the reminder,
754
- * and Claude (Opus) is smart enough to apply it when relevant.
755
- */
756
- async function sessionPromptCheckAction() {
757
- // Lean, instructive reminder with kspec prefix
758
- console.log(sessionPrompt.specCheck);
759
- }
760
- async function sessionCheckpointAction(options) {
761
- try {
762
- // Read stdin for Claude Code hook input
763
- const stdin = await readStdinIfAvailable();
764
- const hookInput = parseHookInput(stdin);
765
- // Check if this is a retry (stop hook already active)
766
- if (hookInput?.stop_hook_active) {
767
- options.stopHookActive = true;
768
- }
769
- const ctx = await initContext();
770
- const result = await performCheckpoint(ctx, options);
771
- // Output format depends on mode:
772
- // - JSON mode (--json): Output Claude Code hook format {"decision": "block", "reason": "..."}
773
- // - Human mode: Output formatted checkpoint result
774
- if (isJsonMode()) {
775
- if (!result.ok) {
776
- // Build reason message with issues and instructions
777
- const issueLines = result.issues
778
- .map((i) => `- ${i.description}`)
779
- .join("\n");
780
- const instructionLines = result.instructions
781
- .filter((i) => i.trim())
782
- .join("\n");
783
- const reason = `${result.message}\n\nIssues:\n${issueLines}\n\n${instructionLines}`;
784
- console.log(JSON.stringify({ decision: "block", reason }));
785
- }
786
- // If ok, exit silently (Claude Code expects no output when allowing stop)
787
- }
788
- else {
789
- formatCheckpointResult(result);
790
- if (!result.ok) {
791
- process.exit(EXIT_CODES.ERROR);
792
- }
793
- }
794
- }
795
- catch (err) {
796
- // Handle RUNNING_FROM_SHADOW gracefully - skip with warning instead of erroring
797
- // This happens when the stop hook runs while cwd is inside .kspec/ directory
798
- if (err instanceof ShadowError && err.code === "RUNNING_FROM_SHADOW") {
799
- if (!isJsonMode()) {
800
- console.log(chalk.yellow("[kspec] Session checkpoint skipped - running from inside .kspec/ directory"));
801
- }
802
- // Allow stop to proceed (exit successfully, no JSON output blocks the stop)
803
- return;
804
- }
805
- error(errors.failures.runCheckpoint, err);
806
- process.exit(EXIT_CODES.ERROR);
807
- }
808
- }
809
- const VALID_SORT_FIELDS = [
810
- "started_at",
811
- "duration",
812
- "events",
813
- "iterations",
814
- "tasks_completed",
815
- ];
816
- /**
817
- * Format a duration in milliseconds to a human-readable string.
818
- */
819
- function formatDuration(ms) {
820
- if (ms < 0)
821
- return "—";
822
- const totalSec = Math.floor(ms / 1000);
823
- const hours = Math.floor(totalSec / 3600);
824
- const minutes = Math.floor((totalSec % 3600) / 60);
825
- if (hours > 0) {
826
- return `${hours}h ${minutes}m`;
827
- }
828
- if (minutes > 0) {
829
- return `${minutes}m`;
830
- }
831
- return `${totalSec}s`;
832
- }
833
- /**
834
- * Sort session summaries by the specified field.
835
- * Default: started_at descending.
836
- *
837
- * AC: @session-log-list ac-5
838
- */
839
- function sortSessions(sessions, sortField) {
840
- return [...sessions].sort((a, b) => {
841
- switch (sortField) {
842
- case "started_at":
843
- return (new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
844
- case "duration":
845
- return b.duration_ms - a.duration_ms;
846
- case "events":
847
- return b.event_count - a.event_count;
848
- case "iterations":
849
- return b.iteration_count - a.iteration_count;
850
- case "tasks_completed":
851
- return b.tasks_completed - a.tasks_completed;
852
- default:
853
- return (new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
854
- }
855
- });
856
- }
857
- /**
858
- * Format the session log list as a table.
859
- *
860
- * AC: @session-log-list ac-1
861
- */
862
- function formatSessionLogList(sessions) {
863
- if (sessions.length === 0) {
864
- // AC: @session-log-list ac-6
865
- console.log("No sessions found.");
866
- return;
867
- }
868
- // Table header
869
- console.log(chalk.gray(`${"ID".padEnd(10)} ${"Status".padEnd(11)} ${"Agent".padEnd(20)} ${"Started".padEnd(16)} ${"Duration".padEnd(10)} ${"Events".padEnd(8)} ${"Iters".padEnd(7)} Tasks`));
870
- console.log(chalk.gray("─".repeat(95)));
871
- for (const s of sessions) {
872
- const id = s.id.slice(0, 8);
873
- const statusColor = s.status === "completed"
874
- ? chalk.green
875
- : s.status === "active"
876
- ? chalk.blue
877
- : chalk.yellow;
878
- const status = statusColor(s.status.padEnd(11));
879
- const agent = s.agent_type.slice(0, 20).padEnd(20);
880
- const started = formatRelativeTime(new Date(s.started_at)).padEnd(16);
881
- const duration = formatDuration(s.duration_ms).padEnd(10);
882
- const events = String(s.event_count).padEnd(8);
883
- const iters = String(s.iteration_count).padEnd(7);
884
- const tasks = String(s.tasks_completed);
885
- console.log(`${chalk.yellow(id)} ${status} ${chalk.gray(agent)} ${chalk.gray(started)} ${duration} ${events} ${iters} ${tasks}`);
886
- }
887
- console.log(chalk.gray(`\n${sessions.length} session(s)`));
888
- }
889
- /**
890
- * Session log list action handler.
891
- */
892
- async function sessionLogListAction(options) {
893
- try {
894
- const ctx = await initContext();
895
- let sessions = await getAllSessionLogSummaries(ctx.specDir);
896
- // AC: @session-log-list ac-2 - Filter by status
897
- if (options.status) {
898
- const statusFilter = options.status;
899
- sessions = sessions.filter((s) => s.status === statusFilter);
900
- }
901
- // AC: @session-log-list ac-4 - Filter by agent type
902
- if (options.agent) {
903
- const agentFilter = options.agent;
904
- sessions = sessions.filter((s) => s.agent_type === agentFilter);
905
- }
906
- // AC: @session-log-list ac-3 - Filter by since date
907
- if (options.since) {
908
- const sinceDate = parseTimeSpec(options.since);
909
- if (sinceDate) {
910
- sessions = sessions.filter((s) => new Date(s.started_at) >= sinceDate);
911
- }
912
- }
913
- // AC: @session-log-list ac-5 - Sort
914
- const sortField = options.sort && VALID_SORT_FIELDS.includes(options.sort)
915
- ? options.sort
916
- : "started_at";
917
- sessions = sortSessions(sessions, sortField);
918
- // AC: @session-log-list ac-7 - Limit output count
919
- if (options.count) {
920
- // AC: @trait-filterable-list ac-8
921
- output({ count: sessions.length }, () => {
922
- console.log(sessions.length);
923
- });
924
- return;
925
- }
926
- // Apply --limit (after filtering/sorting, before display)
927
- if (options.limit) {
928
- const limit = parseInt(options.limit, 10);
929
- if (!Number.isNaN(limit) && limit > 0) {
930
- sessions = sessions.slice(0, limit);
931
- }
932
- }
933
- output(sessions, () => formatSessionLogList(sessions));
934
- }
935
- catch (err) {
936
- error("Failed to list session logs", err);
937
- process.exit(EXIT_CODES.ERROR);
938
- }
939
- }
940
- /**
941
- * Format an event timestamp as relative time from session start.
942
- */
943
- function formatEventTimestamp(eventTs, sessionStartTs) {
944
- const relativeMs = eventTs - sessionStartTs;
945
- const totalSec = Math.floor(relativeMs / 1000);
946
- const minutes = Math.floor(totalSec / 60);
947
- const seconds = totalSec % 60;
948
- if (minutes > 0) {
949
- return `+${minutes}m${seconds}s`;
950
- }
951
- return `+${seconds}s`;
952
- }
953
- /**
954
- * Summarize event data for display.
955
- * Returns a short string describing the event payload.
956
- */
957
- function summarizeEventData(event) {
958
- const data = event.data;
959
- if (!data)
960
- return "";
961
- // Handle tool_call events
962
- if (event.type === "session.update") {
963
- const update = data.update;
964
- if (update?.sessionUpdate === "tool_call") {
965
- const toolName = update._meta?.claudeCode?.toolName || "unknown";
966
- const command = update.rawInput?.command;
967
- if (command) {
968
- const truncated = command.length > 60 ? command.slice(0, 57) + "..." : command;
969
- return `${toolName}: ${truncated}`;
970
- }
971
- return toolName;
972
- }
973
- }
974
- // Handle prompt.sent events
975
- if (event.type === "prompt.sent") {
976
- const prompt = data.prompt;
977
- if (prompt) {
978
- const truncated = prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt;
979
- return truncated;
980
- }
981
- }
982
- // Handle session.start/end
983
- if (event.type === "session.start") {
984
- return "Session started";
985
- }
986
- if (event.type === "session.end") {
987
- const reason = data.reason;
988
- return reason ? `Session ended: ${reason}` : "Session ended";
989
- }
990
- // Default: show first key
991
- const keys = Object.keys(data);
992
- if (keys.length > 0) {
993
- return `{${keys.slice(0, 3).join(", ")}${keys.length > 3 ? ", ..." : ""}}`;
994
- }
995
- return "";
996
- }
997
- /**
998
- * Format the session log show output.
999
- *
1000
- * AC: @session-log-show ac-1
1001
- */
1002
- function formatSessionLogShow(detail, events, contextSnapshot, sessionStartTs) {
1003
- // AC: @session-log-show ac-1 - Session metadata
1004
- console.log(chalk.bold(`Session ${detail.id.slice(0, 8)}`));
1005
- console.log(chalk.gray("─".repeat(60)));
1006
- console.log(` ID: ${detail.id}`);
1007
- const statusColor = detail.status === "completed"
1008
- ? chalk.green
1009
- : detail.status === "active"
1010
- ? chalk.blue
1011
- : chalk.yellow;
1012
- console.log(` Status: ${statusColor(detail.status)}`);
1013
- console.log(` Agent: ${detail.agent_type}`);
1014
- if (detail.task_id) {
1015
- console.log(` Task: ${detail.task_id}`);
1016
- }
1017
- console.log(` Started: ${detail.started_at}`);
1018
- if (detail.ended_at) {
1019
- console.log(` Ended: ${detail.ended_at}`);
1020
- }
1021
- console.log(` Duration: ${formatDuration(detail.duration_ms)}`);
1022
- console.log(` Events: ${detail.event_count}`);
1023
- console.log(` Iterations: ${detail.iteration_count}`);
1024
- // AC: @session-log-show ac-2 - Per-iteration summary
1025
- if (detail.iterations.length > 0) {
1026
- console.log("\n" + chalk.bold("Iterations"));
1027
- console.log(chalk.gray("─".repeat(60)));
1028
- for (const iter of detail.iterations) {
1029
- const taskInfo = [];
1030
- if (iter.tasks_started.length > 0) {
1031
- taskInfo.push(`started: ${iter.tasks_started.join(", ")}`);
1032
- }
1033
- if (iter.tasks_completed.length > 0) {
1034
- taskInfo.push(`completed: ${iter.tasks_completed.join(", ")}`);
1035
- }
1036
- const taskStr = taskInfo.length > 0 ? ` | ${taskInfo.join(" | ")}` : "";
1037
- console.log(` ${chalk.cyan(`[${iter.iteration}]`)} ${iter.event_count} events${taskStr}`);
1038
- }
1039
- }
1040
- // AC: @session-log-show ac-3 - Event timeline
1041
- if (events !== null) {
1042
- console.log("\n" + chalk.bold("Events"));
1043
- console.log(chalk.gray("─".repeat(60)));
1044
- if (events.length === 0) {
1045
- console.log(chalk.gray(" No events to display."));
1046
- }
1047
- else {
1048
- for (const event of events) {
1049
- const timestamp = formatEventTimestamp(event.ts, sessionStartTs);
1050
- const summary = summarizeEventData(event);
1051
- const typeColor = event.type === "session.start" || event.type === "session.end"
1052
- ? chalk.green
1053
- : event.type === "session.update"
1054
- ? chalk.blue
1055
- : chalk.gray;
1056
- console.log(` ${chalk.yellow(timestamp.padEnd(10))} ${typeColor(event.type.padEnd(16))} ${chalk.gray(summary)}`);
1057
- }
1058
- }
1059
- }
1060
- // AC: @session-log-show ac-6 - Context snapshot
1061
- if (contextSnapshot !== null) {
1062
- console.log("\n" + chalk.bold("Context Snapshot"));
1063
- console.log(chalk.gray("─".repeat(60)));
1064
- console.log(JSON.stringify(contextSnapshot, null, 2));
1065
- }
1066
- }
1067
- /**
1068
- * Session log show action handler.
1069
- */
1070
- async function sessionLogShowAction(sessionRef, options) {
1071
- try {
1072
- const ctx = await initContext();
1073
- // AC: @session-log-show ac-7, ac-8, ac-9 - Resolve session ID
1074
- const resolution = await resolveSessionId(ctx.specDir, sessionRef);
1075
- if (!resolution.ok) {
1076
- if (resolution.error === "not_found") {
1077
- // AC: @session-log-show ac-9
1078
- error(`Session not found: ${sessionRef}`);
1079
- process.exit(EXIT_CODES.NOT_FOUND);
1080
- }
1081
- else {
1082
- // AC: @session-log-show ac-8
1083
- error(`Ambiguous session ID prefix. Matches:\n ${resolution.matches.join("\n ")}\nPlease provide a more specific prefix.`);
1084
- process.exit(EXIT_CODES.VALIDATION_FAILED);
1085
- }
1086
- }
1087
- const sessionId = resolution.id;
1088
- // Get session detail
1089
- const detail = await getSessionLogDetail(ctx.specDir, sessionId);
1090
- if (!detail) {
1091
- error(`Session not found: ${sessionId}`);
1092
- process.exit(EXIT_CODES.NOT_FOUND);
1093
- }
1094
- // AC: @session-log-show ac-3, ac-4, ac-5 - Event timeline
1095
- let events = null;
1096
- if (options.events) {
1097
- let allEvents = deduplicatePhasedToolCalls(await readEvents(ctx.specDir, sessionId));
1098
- // AC: @session-log-show ac-4 - Filter by type
1099
- if (options.type) {
1100
- const typeFilter = options.type;
1101
- allEvents = allEvents.filter((e) => e.type === typeFilter);
1102
- }
1103
- // AC: @session-log-show ac-5 - Limit to last N events
1104
- if (options.limit) {
1105
- const limit = parseInt(options.limit, 10);
1106
- if (!Number.isNaN(limit) && limit > 0) {
1107
- allEvents = allEvents.slice(-limit);
1108
- }
1109
- }
1110
- events = allEvents;
1111
- }
1112
- // AC: @session-log-show ac-6 - Context snapshot
1113
- let contextSnapshot = null;
1114
- if (options.context) {
1115
- const iterNum = parseInt(options.context, 10);
1116
- if (!Number.isNaN(iterNum) && iterNum > 0) {
1117
- contextSnapshot = await readSessionContext(ctx.specDir, sessionId, iterNum);
1118
- if (contextSnapshot === null) {
1119
- error(`No context snapshot found for iteration ${iterNum}`);
1120
- process.exit(EXIT_CODES.NOT_FOUND);
1121
- }
1122
- }
1123
- else {
1124
- error(`Invalid iteration number: ${options.context}`);
1125
- process.exit(EXIT_CODES.USAGE_ERROR);
1126
- }
1127
- }
1128
- const sessionStartTs = new Date(detail.started_at).getTime();
1129
- // Build JSON output structure
1130
- const jsonOutput = {
1131
- ...detail,
1132
- ...(events !== null ? { events } : {}),
1133
- ...(contextSnapshot !== null ? { context: contextSnapshot } : {}),
1134
- };
1135
- output(jsonOutput, () => formatSessionLogShow(detail, events, contextSnapshot, sessionStartTs));
1136
- }
1137
- catch (err) {
1138
- error("Failed to show session log", err);
1139
- process.exit(EXIT_CODES.ERROR);
1140
- }
1141
- }
1142
- /**
1143
- * Format a duration in milliseconds to human-readable format.
1144
- * Reuses formatDuration from session log list but handles hours/minutes/seconds.
1145
- */
1146
- function formatDurationLong(ms) {
1147
- if (ms < 0)
1148
- return "—";
1149
- const totalSec = Math.floor(ms / 1000);
1150
- const hours = Math.floor(totalSec / 3600);
1151
- const minutes = Math.floor((totalSec % 3600) / 60);
1152
- const seconds = totalSec % 60;
1153
- if (hours > 0 && minutes > 0) {
1154
- return `${hours}h ${minutes}m`;
1155
- }
1156
- if (hours > 0) {
1157
- return `${hours}h`;
1158
- }
1159
- if (minutes > 0) {
1160
- return `${minutes}m ${seconds}s`;
1161
- }
1162
- return `${seconds}s`;
1163
- }
1164
- /**
1165
- * Format the session log stats output.
1166
- *
1167
- * AC: @session-log-stats ac-1, ac-2, ac-3
1168
- */
1169
- function formatSessionLogStats(stats, toolUsage, timePeriods, groupBy) {
1170
- // AC: @session-log-stats ac-1 - Totals
1171
- console.log(chalk.bold("Session Statistics"));
1172
- console.log(chalk.gray("─".repeat(50)));
1173
- console.log(` Total Sessions: ${stats.total_sessions}`);
1174
- console.log(` Total Events: ${stats.total_events}`);
1175
- console.log(` Total Iterations: ${stats.total_iterations}`);
1176
- console.log(` Tasks Completed: ${stats.total_tasks_completed}`);
1177
- console.log(` Total Duration: ${formatDurationLong(stats.total_duration_ms)}`);
1178
- // AC: @session-log-stats ac-2 - Averages
1179
- console.log("\n" + chalk.bold("Averages"));
1180
- console.log(chalk.gray("─".repeat(50)));
1181
- console.log(` Avg Duration/Session: ${formatDurationLong(stats.avg_duration_ms)}`);
1182
- console.log(` Avg Iterations/Session: ${stats.avg_iterations_per_session}`);
1183
- console.log(` Avg Tasks/Session: ${stats.avg_tasks_per_session}`);
1184
- // AC: @session-log-stats ac-3 - Status breakdown
1185
- if (stats.status_breakdown.length > 0) {
1186
- console.log("\n" + chalk.bold("Status Breakdown"));
1187
- console.log(chalk.gray("─".repeat(50)));
1188
- for (const item of stats.status_breakdown) {
1189
- const statusColor = item.status === "completed"
1190
- ? chalk.green
1191
- : item.status === "active"
1192
- ? chalk.blue
1193
- : chalk.yellow;
1194
- console.log(` ${statusColor(item.status.padEnd(12))} ${String(item.count).padEnd(6)} ${item.percentage}%`);
1195
- }
1196
- }
1197
- // AC: @session-log-stats ac-6 - Tool usage
1198
- if (toolUsage !== null && toolUsage.length > 0) {
1199
- console.log("\n" + chalk.bold("Top Tool Usage"));
1200
- console.log(chalk.gray("─".repeat(50)));
1201
- for (const tool of toolUsage) {
1202
- console.log(` ${tool.tool_name.padEnd(20)} ${String(tool.count).padEnd(8)} ${tool.percentage}%`);
1203
- }
1204
- }
1205
- // AC: @session-log-stats ac-7 - Time periods
1206
- if (timePeriods !== null && timePeriods.length > 0) {
1207
- const label = groupBy === "week" ? "By Week" : "By Day";
1208
- console.log("\n" + chalk.bold(label));
1209
- console.log(chalk.gray("─".repeat(50)));
1210
- console.log(chalk.gray(` ${"Period".padEnd(14)} ${"Sessions".padEnd(10)} ${"Tasks".padEnd(8)} Duration`));
1211
- for (const period of timePeriods) {
1212
- console.log(` ${period.period.padEnd(14)} ${String(period.sessions_count).padEnd(10)} ${String(period.tasks_completed).padEnd(8)} ${formatDurationLong(period.total_duration_ms)}`);
1213
- }
1214
- }
1215
- }
1216
- /**
1217
- * Session log stats action handler.
1218
- */
1219
- async function sessionLogStatsAction(options) {
1220
- try {
1221
- const ctx = await initContext();
1222
- let sessions = await getAllSessionLogSummaries(ctx.specDir);
1223
- // AC: @session-log-stats ac-4 - Filter by since
1224
- if (options.since) {
1225
- const sinceDate = parseTimeSpec(options.since);
1226
- if (sinceDate) {
1227
- sessions = sessions.filter((s) => new Date(s.started_at) >= sinceDate);
1228
- }
1229
- }
1230
- // AC: @session-log-stats ac-5 - Filter by agent type
1231
- if (options.agent) {
1232
- const agentFilter = options.agent;
1233
- sessions = sessions.filter((s) => s.agent_type === agentFilter);
1234
- }
1235
- // AC: @session-log-stats ac-8 - No sessions match criteria
1236
- if (sessions.length === 0) {
1237
- output({ message: "No sessions match criteria" }, () => {
1238
- console.log("No sessions match criteria.");
1239
- });
1240
- return;
1241
- }
1242
- // Compute base stats
1243
- const stats = computeSessionLogStats(sessions);
1244
- // AC: @session-log-stats ac-6 - Tool usage (optional)
1245
- let toolUsage = null;
1246
- if (options.toolUsage) {
1247
- const sessionIds = sessions.map((s) => s.id);
1248
- toolUsage = await computeToolUsageStats(ctx.specDir, sessionIds);
1249
- }
1250
- // AC: @session-log-stats ac-7 - Time periods (optional)
1251
- let timePeriods = null;
1252
- let groupBy = null;
1253
- if (options.byDay) {
1254
- groupBy = "day";
1255
- timePeriods = computeTimePeriodStats(sessions, "day");
1256
- }
1257
- else if (options.byWeek) {
1258
- groupBy = "week";
1259
- timePeriods = computeTimePeriodStats(sessions, "week");
1260
- }
1261
- // Build output structure
1262
- const jsonOutput = { stats };
1263
- if (toolUsage !== null) {
1264
- jsonOutput.tool_usage = toolUsage;
1265
- }
1266
- if (timePeriods !== null) {
1267
- jsonOutput.time_periods = timePeriods;
1268
- }
1269
- output(jsonOutput, () => formatSessionLogStats(stats, toolUsage, timePeriods, groupBy));
1270
- }
1271
- catch (err) {
1272
- error("Failed to compute session log stats", err);
1273
- process.exit(EXIT_CODES.ERROR);
1274
- }
1275
- }
1276
- /**
1277
- * Format relative timestamp from event timestamp (Unix ms) to session start.
1278
- */
1279
- function formatSearchTimestamp(eventTs) {
1280
- return new Date(eventTs).toISOString();
1281
- }
1282
- /**
1283
- * Format the session log search output.
1284
- *
1285
- * AC: @session-log-search ac-1, ac-4
1286
- */
1287
- function formatSessionLogSearch(results) {
1288
- if (results.length === 0) {
1289
- // AC: @session-log-search ac-6
1290
- console.log("No matches found.");
1291
- return;
1292
- }
1293
- let totalMatches = 0;
1294
- for (const session of results) {
1295
- totalMatches += session.matches.length;
1296
- }
1297
- console.log(chalk.bold(`Found ${totalMatches} match(es) in ${results.length} session(s)`));
1298
- console.log(chalk.gray("─".repeat(60)));
1299
- for (const session of results) {
1300
- // Session header
1301
- console.log(`\n${chalk.cyan(`Session ${session.session_id.slice(0, 8)}`)} ` +
1302
- `${chalk.gray(`(${session.agent_type}, started ${formatRelativeTime(new Date(session.started_at))})`)}`);
1303
- // AC: @session-log-search ac-4 - Show matches with session ID, timestamp, type, excerpt
1304
- for (const match of session.matches) {
1305
- const ts = formatSearchTimestamp(match.timestamp);
1306
- const typeColor = match.event_type === "session.start" || match.event_type === "session.end"
1307
- ? chalk.green
1308
- : match.event_type === "session.update"
1309
- ? chalk.blue
1310
- : chalk.gray;
1311
- console.log(` ${chalk.yellow(ts)} ${typeColor(match.event_type.padEnd(16))}`);
1312
- // Content excerpt on next line, indented
1313
- console.log(` ${chalk.gray(match.content_excerpt)}`);
1314
- }
1315
- }
1316
- }
1317
- /**
1318
- * Session log search action handler.
1319
- *
1320
- * AC: @session-log-search ac-1 through ac-7
1321
- */
1322
- async function sessionLogSearchAction(pattern, options) {
1323
- try {
1324
- const ctx = await initContext();
1325
- // Parse options - validate limit as positive integer
1326
- let limit = 50;
1327
- if (options.limit) {
1328
- const parsed = parseInt(options.limit, 10);
1329
- if (Number.isNaN(parsed) || parsed <= 0) {
1330
- error(`Invalid limit: ${options.limit}. Must be a positive integer.`);
1331
- process.exit(EXIT_CODES.USAGE_ERROR);
1332
- }
1333
- limit = parsed;
1334
- }
1335
- const sinceDate = options.since ? parseTimeSpec(options.since) : undefined;
1336
- // AC: @session-log-search ac-1, ac-2, ac-3, ac-5, ac-7
1337
- const results = await searchSessionEvents(ctx.specDir, pattern, {
1338
- eventType: options.type,
1339
- sinceDate: sinceDate || undefined,
1340
- agentType: options.agent,
1341
- limit,
1342
- });
1343
- // AC: @session-log-search ac-6 - No matches found message
1344
- // exit code 0 regardless (per @trait-semantic-exit-codes ac-5)
1345
- output(results, () => formatSessionLogSearch(results));
1346
- }
1347
- catch (err) {
1348
- error("Failed to search session logs", err);
1349
- process.exit(EXIT_CODES.ERROR);
1350
- }
1351
- }
1352
- /**
1353
- * Register the 'session' command group and aliases
1354
- */
1355
- export function registerSessionCommands(program) {
1356
- const session = program
1357
- .command("session")
1358
- .description("Session management and context");
1359
- session
1360
- .command("start")
1361
- .alias("resume")
1362
- .description("Surface relevant context for starting a new working session")
1363
- .option("--brief", "Compact summary (default)")
1364
- .option("--full", "Comprehensive context dump")
1365
- .option("--since <time>", "Filter by recency (ISO8601 or relative: 1h, 2d, 1w)")
1366
- .option("--no-git", "Skip git commit information")
1367
- .option("-n, --limit <n>", "Limit items per section", "10")
1368
- .action(sessionStartAction);
1369
- // Session log subcommand group
1370
- const log = session
1371
- .command("log")
1372
- .description("Session log analysis commands");
1373
- log
1374
- .command("list")
1375
- .description("List session logs with summary statistics")
1376
- .option("-s, --status <status>", "Filter by status (active, completed, abandoned)")
1377
- .option("--agent <type>", "Filter by agent type")
1378
- .option("--since <time>", "Only show sessions started after this time (ISO8601 or relative: 1h, 2d, 1w)")
1379
- .option("--sort <field>", "Sort by field (started_at, duration, events, iterations, tasks_completed)", "started_at")
1380
- .option("--count", "Show only the count of matching sessions")
1381
- .option("-n, --limit <n>", "Limit number of sessions shown")
1382
- .action(sessionLogListAction);
1383
- log
1384
- .command("show <session-id>")
1385
- .description("Show detailed view of a single session")
1386
- .option("-e, --events", "Include chronological event timeline")
1387
- .option("-t, --type <type>", "Filter events by type (e.g., tool.call)")
1388
- .option("-n, --limit <n>", "Show only the last N events")
1389
- .option("-c, --context <n>", "Show context snapshot for iteration N")
1390
- .action(sessionLogShowAction);
1391
- log
1392
- .command("stats")
1393
- .description("Aggregate analytics across sessions")
1394
- .option("--since <time>", "Only include sessions started after this time (ISO8601 or relative: 1h, 2d, 1w)")
1395
- .option("--agent <type>", "Only include sessions with this agent type")
1396
- .option("--tool-usage", "Display top 10 tool calls by frequency")
1397
- .option("--by-day", "Group stats by day")
1398
- .option("--by-week", "Group stats by week")
1399
- .action(sessionLogStatsAction);
1400
- log
1401
- .command("search <pattern>")
1402
- .description("Search across session events by content")
1403
- .option("-t, --type <type>", "Only search events of this type (e.g., session.update)")
1404
- .option("--since <time>", "Only search sessions started after this time (ISO8601 or relative: 1h, 2d, 1w)")
1405
- .option("--agent <type>", "Only search sessions with this agent type")
1406
- .option("-n, --limit <n>", "Maximum matches to return (default: 50)")
1407
- .action(sessionLogSearchAction);
1408
- session
1409
- .command("checkpoint")
1410
- .description("Pre-stop hook: check for uncommitted work before ending session")
1411
- .option("--force", "Allow session end regardless of issues")
1412
- .action(sessionCheckpointAction);
1413
- session
1414
- .command("prompt-check")
1415
- .description("UserPromptSubmit hook: inject spec-first reminder")
1416
- .action(sessionPromptCheckAction);
1417
- // Top-level alias: kspec context
1418
- program
1419
- .command("context")
1420
- .description("Alias for session start - surface session context")
1421
- .option("--brief", "Compact summary (default)")
1422
- .option("--full", "Comprehensive context dump")
1423
- .option("--since <time>", "Filter by recency (ISO8601 or relative: 1h, 2d, 1w)")
1424
- .option("--no-git", "Skip git commit information")
1425
- .option("-n, --limit <n>", "Limit items per section", "10")
1426
- .action(sessionStartAction);
1427
- }
7
+ export {
8
+ // Functions
9
+ gatherSessionContext, getIterationStats, performCheckpoint, getDisplayRef, formatPriority, statusColor, registerSessionCommands, } from "./session/index.js";
1428
10
  //# sourceMappingURL=session.js.map