@kynetic-ai/spec 0.5.0 → 0.7.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 (65) hide show
  1. package/dist/cli/commands/batch.d.ts.map +1 -1
  2. package/dist/cli/commands/batch.js +38 -6
  3. package/dist/cli/commands/batch.js.map +1 -1
  4. package/dist/cli/commands/item.d.ts.map +1 -1
  5. package/dist/cli/commands/item.js +42 -23
  6. package/dist/cli/commands/item.js.map +1 -1
  7. package/dist/cli/commands/ralph.d.ts.map +1 -1
  8. package/dist/cli/commands/ralph.js +15 -2
  9. package/dist/cli/commands/ralph.js.map +1 -1
  10. package/dist/cli/commands/session/checkpoint.d.ts +19 -0
  11. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -0
  12. package/dist/cli/commands/session/checkpoint.js +161 -0
  13. package/dist/cli/commands/session/checkpoint.js.map +1 -0
  14. package/dist/cli/commands/session/commands.d.ts +18 -0
  15. package/dist/cli/commands/session/commands.d.ts.map +1 -0
  16. package/dist/cli/commands/session/commands.js +259 -0
  17. package/dist/cli/commands/session/commands.js.map +1 -0
  18. package/dist/cli/commands/session/context.d.ts +17 -0
  19. package/dist/cli/commands/session/context.d.ts.map +1 -0
  20. package/dist/cli/commands/session/context.js +493 -0
  21. package/dist/cli/commands/session/context.js.map +1 -0
  22. package/dist/cli/commands/session/create.d.ts +29 -0
  23. package/dist/cli/commands/session/create.d.ts.map +1 -0
  24. package/dist/cli/commands/session/create.js +147 -0
  25. package/dist/cli/commands/session/create.js.map +1 -0
  26. package/dist/cli/commands/session/format.d.ts +27 -0
  27. package/dist/cli/commands/session/format.d.ts.map +1 -0
  28. package/dist/cli/commands/session/format.js +401 -0
  29. package/dist/cli/commands/session/format.js.map +1 -0
  30. package/dist/cli/commands/session/index.d.ts +13 -0
  31. package/dist/cli/commands/session/index.d.ts.map +1 -0
  32. package/dist/cli/commands/session/index.js +17 -0
  33. package/dist/cli/commands/session/index.js.map +1 -0
  34. package/dist/cli/commands/session/log.d.ts +52 -0
  35. package/dist/cli/commands/session/log.d.ts.map +1 -0
  36. package/dist/cli/commands/session/log.js +570 -0
  37. package/dist/cli/commands/session/log.js.map +1 -0
  38. package/dist/cli/commands/session/types.d.ts +230 -0
  39. package/dist/cli/commands/session/types.d.ts.map +1 -0
  40. package/dist/cli/commands/session/types.js +7 -0
  41. package/dist/cli/commands/session/types.js.map +1 -0
  42. package/dist/cli/commands/session.d.ts +4 -251
  43. package/dist/cli/commands/session.d.ts.map +1 -1
  44. package/dist/cli/commands/session.js +6 -1870
  45. package/dist/cli/commands/session.js.map +1 -1
  46. package/dist/cli/commands/validate.d.ts.map +1 -1
  47. package/dist/cli/commands/validate.js +23 -7
  48. package/dist/cli/commands/validate.js.map +1 -1
  49. package/dist/parser/shadow.d.ts +5 -1
  50. package/dist/parser/shadow.d.ts.map +1 -1
  51. package/dist/parser/shadow.js +18 -10
  52. package/dist/parser/shadow.js.map +1 -1
  53. package/dist/parser/validate.d.ts +4 -1
  54. package/dist/parser/validate.d.ts.map +1 -1
  55. package/dist/parser/validate.js +50 -35
  56. package/dist/parser/validate.js.map +1 -1
  57. package/dist/sessions/store.d.ts +37 -1
  58. package/dist/sessions/store.d.ts.map +1 -1
  59. package/dist/sessions/store.js +133 -5
  60. package/dist/sessions/store.js.map +1 -1
  61. package/package.json +4 -1
  62. package/plugin/.claude-plugin/marketplace.json +1 -1
  63. package/plugin/.claude-plugin/plugin.json +1 -1
  64. package/templates/agents-sections/03-task-lifecycle.md +4 -1
  65. package/templates/agents-sections/07-batch-usage.md +51 -0
@@ -1,1874 +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, loadTriageRecords, ReferenceIndex, } from "../../parser/index.js";
8
- import { ShadowError, shadowPull, } from "../../parser/shadow.js";
9
- import { loadMetaContext } from "../../parser/meta.js";
10
- import { ulid } from "ulid";
11
- import { getAllSessionLogSummaries, getSessionLogDetail, resolveSessionId, readEvents, readSessionContext, computeSessionLogStats, computeToolUsageStats, computeTimePeriodStats, searchSessionEvents, deduplicatePhasedToolCalls, createSessionWithBudget, validateSessionId, injectClaudeCodeEnv, injectCodexEnv, injectGeminiEnv, injectOpenCodeEnv, getFallbackInjectionInstructions, } from "../../sessions/store.js";
12
- import { errors, hints, sessionHeaders, sessionPrompt, } from "../../strings/index.js";
13
- import { formatCommitGuidance, formatRelativeTime, getCurrentBranch, getRecentCommits, getWorkingTreeStatus, isGitRepo, parseTimeSpec, } from "../../utils/index.js";
14
- import { markMutating } from "../command-annotations.js";
15
- import { EXIT_CODES } from "../exit-codes.js";
16
- import { error, info, isJsonMode, isNoteSuperseded, output, success, warn } from "../output.js";
17
- // ─── Data Gathering ──────────────────────────────────────────────────────────
18
- /**
19
- * Build a reverse dependency map: for each task ULID, count how many
20
- * pending tasks depend on it. Unresolvable refs are silently skipped.
21
- */
22
- function computeUnlocksMap(allTasks, index) {
23
- const counts = new Map();
24
- for (const task of allTasks) {
25
- // Only count pending tasks as "unlockable" downstream work per spec
26
- if (task.status !== "pending")
27
- continue;
28
- for (const depRef of task.depends_on) {
29
- const result = index.resolve(depRef);
30
- if (!result.ok)
31
- continue; // AC: unresolvable refs silently skipped
32
- const depUlid = result.item._ulid;
33
- counts.set(depUlid, (counts.get(depUlid) || 0) + 1);
34
- }
35
- }
36
- return counts;
37
- }
38
- function toActiveTaskSummary(task, index) {
39
- const lastNote = task.notes.length > 0 ? task.notes[task.notes.length - 1] : null;
40
- const incompleteTodos = task.todos.filter((t) => !t.done).length;
41
- return {
42
- ref: index.shortUlid(task._ulid),
43
- slug: task.slugs.length > 0 ? task.slugs[0] : null,
44
- title: task.title,
45
- description: task.description || null,
46
- status: task.status,
47
- started_at: task.started_at || null,
48
- priority: task.priority,
49
- spec_ref: task.spec_ref || null,
50
- note_count: task.notes.length,
51
- last_note_at: lastNote ? lastNote.created_at : null,
52
- todo_count: task.todos.length,
53
- incomplete_todos: incompleteTodos,
54
- };
55
- }
56
- function toReadyTaskSummary(task, index, unlocksMap) {
57
- return {
58
- ref: index.shortUlid(task._ulid),
59
- slug: task.slugs.length > 0 ? task.slugs[0] : null,
60
- title: task.title,
61
- description: task.description || null,
62
- priority: task.priority,
63
- spec_ref: task.spec_ref || null,
64
- tags: task.tags,
65
- unlocks: unlocksMap.get(task._ulid) || 0,
66
- };
67
- }
68
- function toBlockedTaskSummary(task, _allTasks, index, unlocksMap) {
69
- // Find unmet dependencies
70
- const unmetDeps = [];
71
- for (const depRef of task.depends_on) {
72
- const result = index.resolve(depRef);
73
- if (result.ok) {
74
- const depItem = result.item;
75
- if ("status" in depItem && depItem.status !== "completed") {
76
- unmetDeps.push(depRef);
77
- }
78
- }
79
- }
80
- return {
81
- ref: index.shortUlid(task._ulid),
82
- slug: task.slugs.length > 0 ? task.slugs[0] : null,
83
- title: task.title,
84
- description: task.description || null,
85
- blocked_by: task.blocked_by,
86
- unmet_deps: unmetDeps,
87
- unlocks: unlocksMap.get(task._ulid) || 0,
88
- };
89
- }
90
- function toCompletedTaskSummary(task, index) {
91
- return {
92
- ref: index.shortUlid(task._ulid),
93
- slug: task.slugs.length > 0 ? task.slugs[0] : null,
94
- title: task.title,
95
- completed_at: task.completed_at || "",
96
- closed_reason: task.closed_reason || null,
97
- origin: task.origin,
98
- };
99
- }
100
- function collectRecentNotes(tasks, index, options) {
101
- const allNotes = [];
102
- for (const task of tasks) {
103
- // Only include notes from in_progress, pending_review, or completed tasks
104
- const taskStatus = task.status;
105
- if (!["in_progress", "pending_review", "needs_work", "completed"].includes(taskStatus)) {
106
- continue;
107
- }
108
- for (const note of task.notes) {
109
- const noteDate = new Date(note.created_at);
110
- // Filter by since date if provided
111
- if (options.since && noteDate < options.since) {
112
- continue;
113
- }
114
- // Filter out superseded notes
115
- if (isNoteSuperseded(note, task.notes)) {
116
- continue;
117
- }
118
- allNotes.push({
119
- task_ref: index.shortUlid(task._ulid),
120
- task_title: task.title,
121
- task_status: taskStatus,
122
- note_ulid: note._ulid.slice(0, 8),
123
- created_at: note.created_at,
124
- author: note.author || null,
125
- content: note.content,
126
- });
127
- }
128
- }
129
- // Sort by date descending, take limit
130
- return allNotes
131
- .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
132
- .slice(0, options.limit);
133
- }
134
- function collectIncompleteTodos(tasks, index, options) {
135
- const allTodos = [];
136
- for (const task of tasks) {
137
- for (const todo of task.todos) {
138
- // Only include incomplete todos
139
- if (todo.done)
140
- continue;
141
- allTodos.push({
142
- task_ref: index.shortUlid(task._ulid),
143
- task_title: task.title,
144
- id: todo.id,
145
- text: todo.text,
146
- added_at: todo.added_at,
147
- added_by: todo.added_by || null,
148
- });
149
- }
150
- }
151
- // Sort by added_at descending (most recent first), take limit
152
- return allTodos
153
- .sort((a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime())
154
- .slice(0, options.limit);
155
- }
156
- /**
157
- * Build a unified activity timeline from completed tasks and git commits.
158
- *
159
- * - Commits with Task: @slug trailers are matched to completed tasks and shown
160
- * as combined "linked_commit" entries (AC: ac-activity-trailer-link, ac-activity-dedup)
161
- * - Unlinked commits appear as standalone "commit" entries
162
- * - Tasks not linked to any commit appear as standalone "task_completion" entries
163
- * - All items sorted most recent first (AC: ac-activity-sort)
164
- *
165
- * @param completedTasks - Completed task summaries
166
- * @param commits - Recent commit summaries (with task_refs parsed from trailers)
167
- * @param taskRefResolver - Maps a trailer ref (slug or ULID prefix) to a completed task's ref (short ULID)
168
- */
169
- function buildActivityTimeline(completedTasks, commits, taskRefResolver) {
170
- const items = [];
171
- // Build lookup from short ULID ref to CompletedTaskSummary
172
- const taskByRef = new Map();
173
- for (const task of completedTasks) {
174
- taskByRef.set(task.ref, task);
175
- }
176
- // Track which tasks have been linked to a commit (for dedup)
177
- const linkedTaskRefs = new Set();
178
- for (const commit of commits) {
179
- if (commit.task_refs.length > 0) {
180
- let linkedTask;
181
- for (const trailerRef of commit.task_refs) {
182
- // Resolve the trailer ref (slug or ULID) to the short ULID ref
183
- const resolvedRef = taskRefResolver.get(trailerRef);
184
- if (resolvedRef) {
185
- linkedTask = taskByRef.get(resolvedRef);
186
- }
187
- // Also try direct match on short ULID ref
188
- if (!linkedTask) {
189
- linkedTask = taskByRef.get(trailerRef);
190
- }
191
- if (linkedTask) {
192
- linkedTaskRefs.add(linkedTask.ref);
193
- // Use the later of commit date and task completion date for sort accuracy
194
- const commitTime = new Date(commit.date).getTime();
195
- const taskTime = new Date(linkedTask.completed_at).getTime();
196
- const laterDate = taskTime > commitTime ? linkedTask.completed_at : commit.date;
197
- items.push({
198
- type: "linked_commit",
199
- date: laterDate,
200
- commit,
201
- task: linkedTask,
202
- });
203
- break; // One linked entry per commit
204
- }
205
- }
206
- if (!linkedTask) {
207
- // Task ref in trailer but no matching completed task found
208
- items.push({ type: "commit", date: commit.date, commit });
209
- }
210
- }
211
- else {
212
- items.push({ type: "commit", date: commit.date, commit });
213
- }
214
- }
215
- // Add task completions not already linked to a commit
216
- for (const task of completedTasks) {
217
- if (!linkedTaskRefs.has(task.ref)) {
218
- items.push({
219
- type: "task_completion",
220
- date: task.completed_at,
221
- task,
222
- });
223
- }
224
- }
225
- // AC: @session-start-activity-timeline ac-activity-sort
226
- // Sort most recent first
227
- items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
228
- return items;
229
- }
230
- /**
231
- * Gather session context data
232
- */
233
- export async function gatherSessionContext(ctx, options) {
234
- const limit = parseInt(options.limit || "10", 10);
235
- const sinceDate = options.since ? parseTimeSpec(options.since) : null;
236
- const showGit = options.git !== false; // default true
237
- // Load all data
238
- const allTasks = await loadAllTasks(ctx);
239
- const items = await loadAllItems(ctx);
240
- const inboxItems = await loadInboxItems(ctx);
241
- const triageRecords = await loadTriageRecords(ctx);
242
- const index = new ReferenceIndex(allTasks, items);
243
- // AC: @session-start-inbox-triage ac-inbox-untriaged-def
244
- // Build lookup: inbox ULID → triage record (most recent if multiple)
245
- const triageByInboxRef = new Map();
246
- for (const record of triageRecords) {
247
- triageByInboxRef.set(record.inbox_ref, { action: record.action });
248
- }
249
- // Compute stats
250
- const stats = {
251
- total_tasks: allTasks.length,
252
- in_progress: allTasks.filter((t) => t.status === "in_progress").length,
253
- needs_work: allTasks.filter((t) => t.status === "needs_work").length,
254
- pending_review: allTasks.filter((t) => t.status === "pending_review")
255
- .length,
256
- ready: getReadyTasks(allTasks).length,
257
- blocked: allTasks.filter((t) => t.status === "blocked").length,
258
- completed: allTasks.filter((t) => t.status === "completed").length,
259
- inbox_items: inboxItems.length,
260
- };
261
- // Get active tasks (in_progress + needs_work, optionally filtered to automation-eligible only)
262
- // AC: @cli-ralph ac-16
263
- const activeTasks = allTasks
264
- .filter((t) => t.status === "in_progress" || t.status === "needs_work")
265
- .filter((t) => !options.eligible || t.automation === "eligible")
266
- .sort((a, b) => a.priority - b.priority)
267
- .slice(0, options.full ? undefined : limit)
268
- .map((t) => toActiveTaskSummary(t, index));
269
- // Get pending review tasks
270
- const pendingReviewTasks = allTasks
271
- .filter((t) => t.status === "pending_review")
272
- .sort((a, b) => a.priority - b.priority)
273
- .slice(0, options.full ? undefined : limit)
274
- .map((t) => toActiveTaskSummary(t, index));
275
- // Get recent notes from active, pending_review, and recently completed tasks
276
- // AC: @cmd-session-start ac-1, ac-2
277
- // Collect notes per-status first to prevent one status from starving others
278
- const noteLimitPerStatus = options.full ? limit : Math.ceil(limit / 3);
279
- const inProgressNotes = collectRecentNotes(allTasks.filter((t) => t.status === "in_progress" || t.status === "needs_work"), index, { limit: noteLimitPerStatus, since: sinceDate });
280
- const pendingReviewNotes = collectRecentNotes(allTasks.filter((t) => t.status === "pending_review"), index, { limit: noteLimitPerStatus, since: sinceDate });
281
- const recentlyCompletedForNotes = allTasks
282
- .filter((t) => t.status === "completed" && t.completed_at)
283
- .sort((a, b) => {
284
- const aDate = new Date(a.completed_at || 0);
285
- const bDate = new Date(b.completed_at || 0);
286
- return bDate.getTime() - aDate.getTime();
287
- })
288
- .slice(0, 5); // Last 3-5 completed tasks per AC-2
289
- const completedNotes = collectRecentNotes(recentlyCompletedForNotes, index, { limit: noteLimitPerStatus, since: sinceDate });
290
- // Combine notes from all statuses, preserving representation from each
291
- const recentNotes = [...inProgressNotes, ...pendingReviewNotes, ...completedNotes]
292
- .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
293
- // Get incomplete todos from active tasks
294
- const activeTodos = collectIncompleteTodos(allTasks.filter((t) => t.status === "in_progress" || t.status === "needs_work"), index, { limit: options.full ? limit * 2 : limit });
295
- // Compute reverse dependency map for "unlocks N" annotations
296
- const unlocksMap = computeUnlocksMap(allTasks, index);
297
- // AC: @cmd-session-start ac-primer-default, ac-full-sections
298
- // Primer: top 5 ready tasks; Full: all ready tasks
299
- // Respect --limit as upper bound when provided
300
- const readyLimit = options.full ? undefined : Math.min(limit, 5);
301
- const readyTasks = getReadyTasks(allTasks)
302
- .filter((t) => !options.eligible || t.automation === "eligible")
303
- .slice(0, readyLimit)
304
- .map((t) => toReadyTaskSummary(t, index, unlocksMap));
305
- // Get blocked tasks
306
- const blockedTasks = allTasks
307
- .filter((t) => t.status === "blocked")
308
- .slice(0, options.full ? undefined : limit)
309
- .map((t) => toBlockedTaskSummary(t, allTasks, index, unlocksMap));
310
- // Get recently completed tasks
311
- const recentlyCompleted = allTasks
312
- .filter((t) => {
313
- if (t.status !== "completed" || !t.completed_at)
314
- return false;
315
- const completedDate = new Date(t.completed_at);
316
- if (sinceDate && completedDate < sinceDate)
317
- return false;
318
- return true;
319
- })
320
- .sort((a, b) => {
321
- // Sort by completed_at descending (most recent first)
322
- const aDate = new Date(a.completed_at || 0);
323
- const bDate = new Date(b.completed_at || 0);
324
- return bDate.getTime() - aDate.getTime();
325
- })
326
- .slice(0, options.full ? undefined : limit)
327
- .map((t) => toCompletedTaskSummary(t, index));
328
- // Get git info
329
- let branch = null;
330
- let recentCommits = [];
331
- let workingTree = null;
332
- if (showGit && isGitRepo(ctx.rootDir)) {
333
- branch = getCurrentBranch(ctx.rootDir);
334
- const commits = getRecentCommits({
335
- limit: options.full ? limit * 2 : limit,
336
- since: sinceDate || undefined,
337
- cwd: ctx.rootDir,
338
- });
339
- recentCommits = commits.map((c) => ({
340
- hash: c.hash,
341
- full_hash: c.fullHash,
342
- date: c.date.toISOString(),
343
- message: c.message,
344
- author: c.author,
345
- task_refs: c.taskRefs,
346
- }));
347
- workingTree = getWorkingTreeStatus(ctx.rootDir);
348
- }
349
- // Get inbox items with triage status (oldest first to encourage triage)
350
- // AC: @session-start-inbox-triage ac-inbox-untriaged-def
351
- const allInboxSummaries = inboxItems
352
- .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
353
- .map((item) => {
354
- const triageInfo = triageByInboxRef.get(item._ulid);
355
- return {
356
- ref: item._ulid.slice(0, 8),
357
- text: item.text,
358
- created_at: item.created_at,
359
- tags: item.tags,
360
- added_by: item.added_by || null,
361
- triaged: !!triageInfo,
362
- triage_action: triageInfo?.action ?? null,
363
- };
364
- });
365
- // AC: @session-start-inbox-triage ac-inbox-stat-line, ac-inbox-all-triaged
366
- const inboxStats = {
367
- total: allInboxSummaries.length,
368
- untriaged: allInboxSummaries.filter((i) => !i.triaged).length,
369
- deferred: allInboxSummaries.filter((i) => i.triage_action === "defer")
370
- .length,
371
- triaged: allInboxSummaries.filter((i) => i.triaged).length,
372
- };
373
- // JSON always gets full list with triage status; human display filters in formatSessionContext
374
- const inboxSummaries = allInboxSummaries;
375
- // Load session context (focus, threads, questions)
376
- const sessionContext = await loadSessionContext(ctx);
377
- // Build task ref resolver for activity timeline: maps slug/ULID to short ref
378
- // This allows commits with Task: @task-slug trailers to match completed tasks
379
- const taskRefResolver = new Map();
380
- for (const task of allTasks) {
381
- if (task.status !== "completed")
382
- continue;
383
- const shortRef = index.shortUlid(task._ulid);
384
- // Map each slug to the short ref
385
- for (const slug of task.slugs) {
386
- taskRefResolver.set(slug, shortRef);
387
- }
388
- // Also map the full ULID and short ULID to itself
389
- taskRefResolver.set(task._ulid, shortRef);
390
- taskRefResolver.set(shortRef, shortRef);
391
- }
392
- // Build unified activity timeline
393
- // AC: @session-start-activity-timeline ac-activity-merge
394
- // AC: @cmd-session-start ac-primer-default, ac-full-sections
395
- // Primer: 10 items; Full: 20 items
396
- // Respect --limit as upper bound when provided
397
- const activityLimit = options.full ? Math.min(limit * 2, 20) : Math.min(limit, 10);
398
- const activityTimeline = buildActivityTimeline(recentlyCompleted, recentCommits, taskRefResolver).slice(0, activityLimit);
399
- // AC: @cmd-session-start ac-full-sections — observations section (full mode only)
400
- let observations = [];
401
- if (options.full) {
402
- const metaCtx = await loadMetaContext(ctx);
403
- observations = metaCtx.observations
404
- .filter((o) => !o.resolved)
405
- .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
406
- .map((o) => ({
407
- ref: o._ulid.slice(0, 8),
408
- type: o.type,
409
- content: o.content,
410
- created_at: o.created_at,
411
- author: o.author || null,
412
- resolved: o.resolved,
413
- workflow_ref: o.workflow_ref || null,
414
- }));
415
- }
416
- // AC: @session-start-computed-json ac-computed-unlocks
417
- // Build task_unlocks map: short ULID ref → count of pending dependents
418
- const taskUnlocks = {};
419
- for (const [taskUlid, count] of unlocksMap) {
420
- if (count > 0) {
421
- taskUnlocks[index.shortUlid(taskUlid)] = count;
422
- }
423
- }
424
- // AC: @session-start-computed-json ac-computed-inbox, ac-computed-unlocks, ac-computed-activity
425
- const computed = {
426
- inbox_untriaged_count: inboxStats.untriaged,
427
- inbox_deferred_count: inboxStats.deferred,
428
- inbox_total: inboxStats.total,
429
- task_unlocks: taskUnlocks,
430
- recent_activity: activityTimeline,
431
- };
432
- return {
433
- generated_at: new Date().toISOString(),
434
- branch,
435
- context: sessionContext,
436
- active_tasks: activeTasks,
437
- pending_review_tasks: pendingReviewTasks,
438
- recent_notes: recentNotes,
439
- active_todos: activeTodos,
440
- ready_tasks: readyTasks,
441
- blocked_tasks: blockedTasks,
442
- recently_completed: recentlyCompleted,
443
- recent_commits: recentCommits,
444
- activity_timeline: activityTimeline,
445
- working_tree: workingTree,
446
- inbox_items: inboxSummaries,
447
- inbox_stats: inboxStats,
448
- observations,
449
- stats,
450
- computed,
451
- };
452
- }
453
- /**
454
- * Get iteration stats - tasks completed/started since a given time.
455
- * AC: @ralph-task-limit ac-detection
456
- */
457
- export async function getIterationStats(ctx, since) {
458
- const allTasks = await loadAllTasks(ctx);
459
- // Count both completed and pending_review (submitted) tasks toward the limit.
460
- // Submit means the agent's work is done — it should count the same as complete.
461
- // AC: @ralph-task-limit ac-detection
462
- const completedSince = allTasks.filter((t) => {
463
- if (t.status === "completed" && t.completed_at) {
464
- return new Date(t.completed_at) >= since;
465
- }
466
- if (t.status === "pending_review" && t.submitted_at) {
467
- return new Date(t.submitted_at) >= since;
468
- }
469
- return false;
470
- });
471
- const startedSince = allTasks.filter((t) => {
472
- if (!t.started_at)
473
- return false;
474
- return new Date(t.started_at) >= since;
475
- });
476
- // Use first slug or ULID prefix as ref
477
- const getRef = (t) => t.slugs.length > 0 ? `@${t.slugs[0]}` : `@${t._ulid.slice(0, 8)}`;
478
- return {
479
- tasks_completed: completedSince.length,
480
- tasks_started: startedSince.length,
481
- completed_refs: completedSince.map(getRef),
482
- };
483
- }
484
- /**
485
- * Perform session checkpoint - check for uncommitted work before ending session.
486
- *
487
- * This is designed for use as a Claude Code stop hook. It checks for:
488
- * - Uncommitted git changes (staged, unstaged, untracked)
489
- * - Tasks in in_progress status
490
- * - Incomplete todos on active tasks
491
- *
492
- * Returns a structured result indicating whether the session can end cleanly.
493
- */
494
- export async function performCheckpoint(ctx, options) {
495
- const issues = [];
496
- const instructions = [];
497
- // Load tasks
498
- const allTasks = await loadAllTasks(ctx);
499
- // Check for in-progress tasks
500
- const inProgressTasks = allTasks.filter((t) => t.status === "in_progress");
501
- for (const task of inProgressTasks) {
502
- const ref = task.slugs[0]
503
- ? `@${task.slugs[0]}`
504
- : `@${task._ulid.slice(0, 8)}`;
505
- issues.push({
506
- type: "in_progress_task",
507
- description: `Task ${ref} is still in progress: ${task.title}`,
508
- details: {
509
- ref,
510
- title: task.title,
511
- started_at: task.started_at,
512
- },
513
- });
514
- // Check for incomplete todos on this task
515
- const incompleteTodos = task.todos.filter((t) => !t.done);
516
- for (const todo of incompleteTodos) {
517
- issues.push({
518
- type: "incomplete_todo",
519
- description: `Incomplete todo on ${ref}: ${todo.text}`,
520
- details: {
521
- task_ref: ref,
522
- todo_id: todo.id,
523
- text: todo.text,
524
- },
525
- });
526
- }
527
- }
528
- // Check for uncommitted git changes
529
- if (isGitRepo(ctx.rootDir)) {
530
- const workingTree = getWorkingTreeStatus(ctx.rootDir);
531
- if (!workingTree.clean) {
532
- const changeCount = workingTree.staged.length +
533
- workingTree.unstaged.length +
534
- workingTree.untracked.length;
535
- issues.push({
536
- type: "uncommitted_changes",
537
- description: `${changeCount} uncommitted changes in working tree`,
538
- details: {
539
- staged: workingTree.staged.length,
540
- unstaged: workingTree.unstaged.length,
541
- untracked: workingTree.untracked.length,
542
- },
543
- });
544
- }
545
- }
546
- // Build instructions based on issues
547
- if (issues.length > 0 && !options.force) {
548
- instructions.push("If you've been instructed to ignore this message or are working on");
549
- instructions.push("unrelated things to any in progress tasks then disregard this message,");
550
- instructions.push("otherwise before ending this session, please:");
551
- const hasInProgress = issues.some((i) => i.type === "in_progress_task");
552
- const hasUncommitted = issues.some((i) => i.type === "uncommitted_changes");
553
- const hasIncompleteTodos = issues.some((i) => i.type === "incomplete_todo");
554
- let step = 1;
555
- if (hasInProgress) {
556
- instructions.push(`${step++}. Read in-progress task notes to get full context of the current task status`);
557
- instructions.push(`${step++}. Add notes documenting current state if any context is missing from this session`);
558
- 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`);
559
- }
560
- if (hasIncompleteTodos) {
561
- instructions.push(`${step++}. Complete or acknowledge incomplete todos on active tasks`);
562
- }
563
- if (hasUncommitted) {
564
- instructions.push(`${step++}. Commit your changes with a descriptive message`);
565
- // Add WIP commit guidance if there are in-progress tasks
566
- if (inProgressTasks.length > 0) {
567
- const task = inProgressTasks[0];
568
- const guidance = formatCommitGuidance(task, { wip: true });
569
- instructions.push("");
570
- instructions.push("Suggested WIP commit:");
571
- instructions.push(` ${guidance.message}`);
572
- instructions.push("");
573
- for (const trailer of guidance.trailers) {
574
- instructions.push(` ${trailer}`);
575
- }
576
- }
577
- }
578
- instructions.push("");
579
- instructions.push("Use: kspec task @task to get current task state");
580
- instructions.push('Use: kspec task note @task "Progress notes..." to document state');
581
- instructions.push('Use: kspec task complete @task --reason "Summary" if task is done');
582
- }
583
- // Allow stop if:
584
- // - No issues found
585
- // - --force flag passed
586
- // - This is a retry (stop_hook_active = true from previous block)
587
- const isRetry = options.stopHookActive === true;
588
- const ok = issues.length === 0 || options.force === true || isRetry;
589
- let message;
590
- if (isRetry && issues.length > 0) {
591
- message = `[kspec] Session checkpoint: ${issues.length} issue(s) acknowledged - allowing stop`;
592
- }
593
- else if (ok) {
594
- message = "[kspec] Session checkpoint passed - ready to end session";
595
- }
596
- else {
597
- message = `[kspec] Session checkpoint: ${issues.length} issue(s) need attention`;
598
- }
599
- return {
600
- ok,
601
- message,
602
- issues,
603
- instructions,
604
- };
605
- }
606
- // ─── Output Formatting ───────────────────────────────────────────────────────
607
- function formatCheckpointResult(result) {
608
- if (result.ok) {
609
- console.log(chalk.green(result.message));
610
- }
611
- else {
612
- console.log(chalk.yellow(result.message));
613
- console.log("");
614
- for (const issue of result.issues) {
615
- const icon = issue.type === "uncommitted_changes"
616
- ? chalk.yellow("⚠")
617
- : issue.type === "in_progress_task"
618
- ? chalk.blue("●")
619
- : chalk.gray("○");
620
- console.log(` ${icon} ${issue.description}`);
621
- }
622
- if (result.instructions.length > 0) {
623
- console.log("");
624
- for (const instruction of result.instructions) {
625
- console.log(chalk.gray(instruction));
626
- }
627
- }
628
- }
629
- }
630
- function formatSessionContext(ctx, options) {
631
- const isFull = !!options.full;
632
- // AC: @cmd-session-start ac-slug-display, ac-slug-fallback
633
- // Display ref as @slug when available, @short-ulid when not
634
- const displayRef = (task) => task.slug ? `@${task.slug}` : `@${task.ref}`;
635
- // Header
636
- console.log(`\n${sessionHeaders.title}`);
637
- const age = formatRelativeTime(new Date(ctx.generated_at));
638
- if (ctx.branch) {
639
- console.log(chalk.gray(`Branch: ${ctx.branch} | Generated: ${age}`));
640
- }
641
- else {
642
- console.log(chalk.gray(`Generated: ${age}`));
643
- }
644
- // Stats summary
645
- const pendingReviewNote = ctx.stats.pending_review > 0
646
- ? `${ctx.stats.pending_review} awaiting review, `
647
- : "";
648
- const inboxNote = ctx.stats.inbox_items > 0 ? ` | Inbox: ${ctx.stats.inbox_items}` : "";
649
- console.log(chalk.gray(`Tasks: ${ctx.stats.in_progress} active, ${pendingReviewNote}${ctx.stats.ready} ready, ` +
650
- `${ctx.stats.blocked} blocked, ${ctx.stats.completed}/${ctx.stats.total_tasks} completed${inboxNote}`));
651
- // Session context section (focus, threads, questions)
652
- if (ctx.context &&
653
- (ctx.context.focus ||
654
- ctx.context.threads.length > 0 ||
655
- ctx.context.open_questions.length > 0)) {
656
- console.log("\n--- Session Context ---");
657
- if (ctx.context.focus) {
658
- console.log(` ${chalk.cyan("Focus:")} ${ctx.context.focus}`);
659
- }
660
- if (ctx.context.threads.length > 0) {
661
- console.log(` ${chalk.cyan("Active Threads:")}`);
662
- for (const thread of ctx.context.threads) {
663
- console.log(` - ${thread}`);
664
- }
665
- }
666
- if (ctx.context.open_questions.length > 0) {
667
- console.log(` ${chalk.cyan("Open Questions:")}`);
668
- for (const question of ctx.context.open_questions) {
669
- console.log(` - ${question}`);
670
- }
671
- }
672
- }
673
- // ── Section ordering per AC: @cmd-session-start ac-section-order ──
674
- // active tasks → pending review → blocked → ready → recent activity → inbox → working tree → quick commands
675
- // AC: @cmd-session-start ac-empty-skip — empty sections omitted entirely
676
- // ── Active tasks section ──
677
- // AC: @cmd-session-start ac-active-detail, ac-needs-work-indicator
678
- if (ctx.active_tasks.length > 0) {
679
- console.log(`\n${sessionHeaders.activeWork}`);
680
- // Collect notes relevant to active tasks for inline display
681
- const activeTaskNotes = ctx.recent_notes.filter((n) => n.task_status === "in_progress" || n.task_status === "needs_work");
682
- for (const task of ctx.active_tasks) {
683
- const started = task.started_at
684
- ? chalk.gray(` (started ${formatRelativeTime(new Date(task.started_at))})`)
685
- : "";
686
- const priority = task.priority <= 2
687
- ? chalk.red(`P${task.priority}`)
688
- : chalk.gray(`P${task.priority}`);
689
- // AC: @cmd-session-start ac-needs-work-indicator
690
- const statusLabel = task.status === "needs_work"
691
- ? chalk.red("[needs_work]")
692
- : chalk.blue("[in_progress]");
693
- console.log(` ${statusLabel} ${priority} ${displayRef(task)} ${task.title}${started}`);
694
- // AC: @cmd-session-start ac-active-detail — show description
695
- if (task.description) {
696
- console.log(chalk.gray(` ${task.description}`));
697
- }
698
- // AC: @cmd-session-start ac-active-detail — show recent notes inline
699
- const taskNotes = activeTaskNotes.filter((n) => n.task_ref === task.ref);
700
- if (taskNotes.length > 0) {
701
- const latestNote = taskNotes[0]; // already sorted most recent first
702
- const noteAge = formatRelativeTime(new Date(latestNote.created_at));
703
- const author = latestNote.author
704
- ? chalk.gray(` by ${latestNote.author}`)
705
- : "";
706
- console.log(` ${chalk.yellow("Note")} ${chalk.gray(`(${noteAge}${author})`)}`);
707
- let content = latestNote.content.trim();
708
- if (!isFull && content.length > 200) {
709
- content = `${content.slice(0, 200).trim()}...`;
710
- }
711
- const lines = content.split("\n");
712
- const maxLines = isFull ? lines.length : 3;
713
- for (const line of lines.slice(0, maxLines)) {
714
- console.log(` ${chalk.white(line)}`);
715
- }
716
- if (!isFull && lines.length > maxLines) {
717
- console.log(chalk.gray(` ... (${lines.length - maxLines} more lines)`));
718
- }
719
- }
720
- }
721
- }
722
- // ── Awaiting review section ──
723
- // AC: @cmd-session-start ac-review-detail
724
- if (ctx.pending_review_tasks.length > 0) {
725
- console.log(`\n${sessionHeaders.awaitingReview}`);
726
- const reviewNotes = ctx.recent_notes.filter((n) => n.task_status === "pending_review");
727
- for (const task of ctx.pending_review_tasks) {
728
- const priority = task.priority <= 2
729
- ? chalk.red(`P${task.priority}`)
730
- : chalk.gray(`P${task.priority}`);
731
- console.log(` ${chalk.yellow("[pending_review]")} ${priority} ${displayRef(task)} ${task.title}`);
732
- // AC: @cmd-session-start ac-review-detail — show recent notes
733
- const taskNotes = reviewNotes.filter((n) => n.task_ref === task.ref);
734
- if (taskNotes.length > 0) {
735
- const latestNote = taskNotes[0];
736
- const noteAge = formatRelativeTime(new Date(latestNote.created_at));
737
- const author = latestNote.author
738
- ? chalk.gray(` by ${latestNote.author}`)
739
- : "";
740
- console.log(` ${chalk.yellow("Note")} ${chalk.gray(`(${noteAge}${author})`)}`);
741
- let content = latestNote.content.trim();
742
- if (!isFull && content.length > 200) {
743
- content = `${content.slice(0, 200).trim()}...`;
744
- }
745
- const lines = content.split("\n");
746
- const maxLines = isFull ? lines.length : 3;
747
- for (const line of lines.slice(0, maxLines)) {
748
- console.log(` ${chalk.white(line)}`);
749
- }
750
- if (!isFull && lines.length > maxLines) {
751
- console.log(chalk.gray(` ... (${lines.length - maxLines} more lines)`));
752
- }
753
- }
754
- }
755
- }
756
- // ── Blocked tasks section ──
757
- if (ctx.blocked_tasks.length > 0) {
758
- console.log(`\n${sessionHeaders.blocked}`);
759
- for (const task of ctx.blocked_tasks) {
760
- const unlocks = task.unlocks > 0 ? chalk.green(` unlocks ${task.unlocks}`) : "";
761
- console.log(` ${chalk.red("[blocked]")} ${displayRef(task)} ${task.title}${unlocks}`);
762
- if (task.blocked_by.length > 0) {
763
- console.log(chalk.gray(` Blockers: ${task.blocked_by.join(", ")}`));
764
- }
765
- if (task.unmet_deps.length > 0) {
766
- console.log(chalk.gray(` Waiting on: ${task.unmet_deps.join(", ")}`));
767
- }
768
- }
769
- }
770
- // ── Ready tasks section ──
771
- if (ctx.ready_tasks.length > 0) {
772
- console.log(`\n${sessionHeaders.readyTasks}`);
773
- for (const task of ctx.ready_tasks) {
774
- const priority = task.priority <= 2
775
- ? chalk.red(`P${task.priority}`)
776
- : chalk.gray(`P${task.priority}`);
777
- const tags = task.tags.length > 0 ? chalk.cyan(` #${task.tags.join(" #")}`) : "";
778
- const unlocks = task.unlocks > 0 ? chalk.green(` unlocks ${task.unlocks}`) : "";
779
- console.log(` ${priority} ${displayRef(task)} ${task.title}${unlocks}${tags}`);
780
- }
781
- }
782
- // ── Recent Activity timeline ──
783
- // AC: @session-start-activity-timeline ac-activity-merge
784
- if (ctx.activity_timeline.length > 0) {
785
- console.log(`\n${sessionHeaders.recentActivity}`);
786
- const observationPromotedTasks = [];
787
- const taskGroups = new Map();
788
- const groups = [];
789
- for (const item of ctx.activity_timeline) {
790
- if (item.type === "linked_commit") {
791
- const key = item.task.ref;
792
- let group = taskGroups.get(key);
793
- if (!group) {
794
- group = { task: item.task, commits: [], sortDate: item.date };
795
- taskGroups.set(key, group);
796
- }
797
- group.commits.push({ commit: item.commit, date: item.commit.date });
798
- // Update sortDate to the most recent event in the group
799
- if (new Date(item.date).getTime() > new Date(group.sortDate).getTime()) {
800
- group.sortDate = item.date;
801
- }
802
- }
803
- else if (item.type === "task_completion") {
804
- groups.push({ kind: "task_completion", task: item.task, date: item.date });
805
- }
806
- else if (item.type === "commit") {
807
- groups.push({ kind: "orphan_commit", commit: item.commit, date: item.date });
808
- }
809
- }
810
- // Add task groups to the groups array
811
- for (const group of taskGroups.values()) {
812
- // AC: @session-start-activity-timeline ac-activity-sort
813
- // Sort commits within a group chronologically (oldest first)
814
- group.commits.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
815
- groups.push({ kind: "task_group", ...group });
816
- }
817
- // AC: @session-start-activity-timeline ac-activity-sort
818
- // Sort groups by most recent event, most recent first
819
- groups.sort((a, b) => new Date(b.kind === "task_group" ? b.sortDate : b.date).getTime() -
820
- new Date(a.kind === "task_group" ? a.sortDate : a.date).getTime());
821
- for (const group of groups) {
822
- if (group.kind === "task_completion") {
823
- // Standalone completed task (not linked to any commit)
824
- let reason = "";
825
- if (group.task.closed_reason) {
826
- const maxLen = isFull ? 120 : 60;
827
- const truncated = group.task.closed_reason.length > maxLen
828
- ? `${group.task.closed_reason.slice(0, maxLen).trim()}...`
829
- : group.task.closed_reason;
830
- reason = chalk.gray(` - ${truncated}`);
831
- }
832
- // AC: @cmd-session-start ac-slug-display
833
- const taskDisplay = group.task.slug
834
- ? `@${group.task.slug}`
835
- : `@${group.task.ref}`;
836
- // AC: @cmd-session-start ac-relative-time-human
837
- const itemAge = formatRelativeTime(new Date(group.date));
838
- console.log(` ${chalk.green("✓")} ${taskDisplay} ${group.task.title} ${chalk.gray(`(${itemAge})`)}${reason}`);
839
- if (group.task.origin === "observation_promotion") {
840
- observationPromotedTasks.push(taskDisplay);
841
- }
842
- }
843
- else if (group.kind === "task_group") {
844
- // AC: @session-start-activity-timeline ac-activity-hierarchy, ac-activity-trailer-link
845
- // Task as top-level entry with linked commits nested beneath
846
- const taskDisplay = group.task.slug
847
- ? `@${group.task.slug}`
848
- : `@${group.task.ref}`;
849
- const groupAge = formatRelativeTime(new Date(group.sortDate));
850
- console.log(` ${chalk.green("✓")} ${taskDisplay} ${group.task.title} ${chalk.gray(`(${groupAge})`)}`);
851
- if (group.task.origin === "observation_promotion") {
852
- observationPromotedTasks.push(taskDisplay);
853
- }
854
- // Render nested commits with visual connectors
855
- for (let i = 0; i < group.commits.length; i++) {
856
- const { commit, date } = group.commits[i];
857
- const isLast = i === group.commits.length - 1;
858
- const connector = isLast ? "└─" : "├─";
859
- const commitAge = formatRelativeTime(new Date(date));
860
- console.log(` ${chalk.gray(connector)} ${chalk.yellow(commit.hash)} ${commit.message} ${chalk.gray(`(${commitAge}, ${commit.author})`)}`);
861
- }
862
- }
863
- else if (group.kind === "orphan_commit") {
864
- // AC: @session-start-activity-timeline ac-activity-orphan
865
- // Orphan commit: visually distinct from task entries
866
- const commitAge = formatRelativeTime(new Date(group.date));
867
- console.log(` ${chalk.gray("○")} ${chalk.yellow(group.commit.hash)} ${group.commit.message} ${chalk.gray(`(${commitAge}, ${group.commit.author})`)}`);
868
- }
869
- }
870
- // Show reminder about resolving observations
871
- if (observationPromotedTasks.length > 0) {
872
- console.log(chalk.yellow(`\n ℹ Consider resolving linked observations: ${observationPromotedTasks.join(", ")}`));
873
- console.log(chalk.gray(` Run: kspec meta observations --pending-resolution`));
874
- }
875
- }
876
- // ── Inbox section ──
877
- // AC: @session-start-inbox-triage ac-inbox-stat-line, ac-inbox-full-list, ac-inbox-all-triaged
878
- if (ctx.inbox_stats.total > 0) {
879
- console.log(`\n${sessionHeaders.inbox}`);
880
- // Stat line always shown (primer and full)
881
- const statParts = [
882
- `${ctx.inbox_stats.untriaged} untriaged`,
883
- `${ctx.inbox_stats.deferred} deferred`,
884
- `${ctx.inbox_stats.total} total`,
885
- ];
886
- console.log(` ${statParts.join(" | ")}`);
887
- // AC: @cmd-session-start ac-full-sections, @session-start-inbox-triage ac-inbox-full-list
888
- // Full mode: list untriaged items (up to 20)
889
- // AC: @cmd-session-start ac-slug-fallback — inbox uses @short-ulid
890
- const untriagedItems = ctx.inbox_items
891
- .filter((i) => !i.triaged)
892
- .slice(0, 20);
893
- if (isFull && untriagedItems.length > 0) {
894
- console.log("");
895
- for (const item of untriagedItems) {
896
- const itemAge = formatRelativeTime(new Date(item.created_at));
897
- const author = item.added_by ? ` by ${item.added_by}` : "";
898
- const tags = item.tags.length > 0
899
- ? chalk.cyan(` [${item.tags.join(", ")}]`)
900
- : "";
901
- console.log(` ${chalk.magenta(`@${item.ref}`)} ${chalk.gray(`(${itemAge}${author})`)}${tags}`);
902
- console.log(` ${item.text}`);
903
- }
904
- }
905
- if (ctx.inbox_stats.untriaged > 0) {
906
- console.log(` ${hints.inboxTriage}`);
907
- }
908
- }
909
- // ── Observations section (full mode only) ──
910
- // AC: @cmd-session-start ac-full-sections
911
- if (isFull && ctx.observations.length > 0) {
912
- console.log(`\n${chalk.yellow.bold("--- Observations (unresolved) ---")}`);
913
- for (const obs of ctx.observations) {
914
- const obsAge = formatRelativeTime(new Date(obs.created_at));
915
- const author = obs.author ? ` by ${obs.author}` : "";
916
- const typeLabel = chalk.cyan(`[${obs.type}]`);
917
- console.log(` ${typeLabel} ${chalk.gray(`@${obs.ref}`)} ${chalk.gray(`(${obsAge}${author})`)}`);
918
- console.log(` ${obs.content}`);
919
- }
920
- }
921
- // ── Session metadata section (full mode only) ──
922
- // AC: @cmd-session-start ac-full-sections
923
- if (isFull &&
924
- ctx.context &&
925
- ctx.context.updated_at) {
926
- console.log(`\n${chalk.gray.bold("--- Session Metadata ---")}`);
927
- console.log(chalk.gray(` Last updated: ${formatRelativeTime(new Date(ctx.context.updated_at))}`));
928
- }
929
- // ── Working tree section ──
930
- // AC: @cmd-session-start ac-dirty-tree-only — only shown when dirty
931
- if (ctx.working_tree && !ctx.working_tree.clean) {
932
- console.log(`\n${sessionHeaders.workingTree}`);
933
- if (ctx.working_tree.staged.length > 0) {
934
- console.log(chalk.green(" Staged:"));
935
- for (const file of ctx.working_tree.staged) {
936
- console.log(` ${chalk.green(file.status[0].toUpperCase())} ${file.path}`);
937
- }
938
- }
939
- if (ctx.working_tree.unstaged.length > 0) {
940
- console.log(chalk.red(" Modified:"));
941
- for (const file of ctx.working_tree.unstaged) {
942
- console.log(` ${chalk.red(file.status[0].toUpperCase())} ${file.path}`);
943
- }
944
- }
945
- if (ctx.working_tree.untracked.length > 0) {
946
- console.log(chalk.gray(" Untracked:"));
947
- const untrackedLimit = isFull
948
- ? ctx.working_tree.untracked.length
949
- : 5;
950
- for (const filePath of ctx.working_tree.untracked.slice(0, untrackedLimit)) {
951
- console.log(` ${chalk.gray("?")} ${filePath}`);
952
- }
953
- if (!isFull && ctx.working_tree.untracked.length > untrackedLimit) {
954
- console.log(chalk.gray(` ... and ${ctx.working_tree.untracked.length - untrackedLimit} more`));
955
- }
956
- }
957
- }
958
- // ── Quick Commands section ──
959
- const quickCommands = [];
960
- if (ctx.active_tasks.length > 0) {
961
- const ref = displayRef(ctx.active_tasks[0]);
962
- quickCommands.push(`kspec task note ${ref} "Progress..." ${chalk.gray("# document work")}`);
963
- quickCommands.push(`kspec task complete ${ref} --reason "..." ${chalk.gray("# finish task")}`);
964
- }
965
- else if (ctx.ready_tasks.length > 0) {
966
- const ref = displayRef(ctx.ready_tasks[0]);
967
- quickCommands.push(`kspec task start ${ref} ${chalk.gray("# begin work")}`);
968
- }
969
- if (ctx.inbox_stats.untriaged > 0) {
970
- quickCommands.push(`kspec triage inbox ${chalk.gray("# triage untriaged inbox items")}`);
971
- }
972
- if (ctx.working_tree && !ctx.working_tree.clean) {
973
- quickCommands.push(`git add . && git commit -m "..." ${chalk.gray("# commit changes")}`);
974
- }
975
- if (quickCommands.length > 0) {
976
- console.log(`\n${sessionHeaders.quickCommands}`);
977
- for (const hint of quickCommands) {
978
- console.log(` ${hint}`);
979
- }
980
- }
981
- console.log(""); // Final newline
982
- }
983
- // ─── Command Registration ────────────────────────────────────────────────────
984
- async function sessionStartAction(options) {
985
- try {
986
- const ctx = await initContext();
987
- // AC: @shadow-sync ac-2 - Pull remote changes before showing session context
988
- let syncResult = null;
989
- if (ctx.shadow?.enabled) {
990
- syncResult = await shadowPull(ctx.shadow.worktreeDir);
991
- // AC: @shadow-sync ac-3 - Warn about conflicts but continue with local state
992
- if (syncResult.hadConflict) {
993
- warn("Shadow sync conflict detected. Run `kspec shadow resolve` to fix.");
994
- info("Continuing with local state...");
995
- }
996
- else if (syncResult.pulled) {
997
- info("Synced shadow branch from remote");
998
- }
999
- }
1000
- const sessionCtx = await gatherSessionContext(ctx, options);
1001
- output(sessionCtx, () => formatSessionContext(sessionCtx, options));
1002
- }
1003
- catch (err) {
1004
- error(errors.failures.gatherSessionContext, err);
1005
- process.exit(EXIT_CODES.ERROR);
1006
- }
1007
- }
1008
- /**
1009
- * Read stdin if available (non-blocking check for hook input)
1010
- */
1011
- async function readStdinIfAvailable() {
1012
- // Check if stdin is a TTY (interactive) - if so, don't try to read
1013
- if (process.stdin.isTTY) {
1014
- return null;
1015
- }
1016
- return new Promise((resolve) => {
1017
- let data = "";
1018
- const timeout = setTimeout(() => {
1019
- process.stdin.removeAllListeners();
1020
- resolve(data || null);
1021
- }, 100); // 100ms timeout for stdin
1022
- process.stdin.setEncoding("utf8");
1023
- process.stdin.on("data", (chunk) => {
1024
- data += chunk;
1025
- });
1026
- process.stdin.on("end", () => {
1027
- clearTimeout(timeout);
1028
- resolve(data || null);
1029
- });
1030
- process.stdin.on("error", () => {
1031
- clearTimeout(timeout);
1032
- resolve(null);
1033
- });
1034
- process.stdin.resume();
1035
- });
1036
- }
1037
- /**
1038
- * Parse Claude Code hook input from stdin
1039
- */
1040
- function parseHookInput(stdin) {
1041
- if (!stdin)
1042
- return null;
1043
- try {
1044
- return JSON.parse(stdin.trim());
1045
- }
1046
- catch {
1047
- return null;
1048
- }
1049
- }
1050
- // ─── Prompt Check (UserPromptSubmit Hook) ────────────────────────────────────
1051
- /**
1052
- * Output spec-first reminder for UserPromptSubmit hook.
1053
- *
1054
- * This is a simple context injection - always outputs the reminder,
1055
- * and Claude (Opus) is smart enough to apply it when relevant.
1056
- */
1057
- async function sessionPromptCheckAction() {
1058
- // Lean, instructive reminder with kspec prefix
1059
- console.log(sessionPrompt.specCheck);
1060
- }
1061
- async function sessionCheckpointAction(options) {
1062
- try {
1063
- // Read stdin for Claude Code hook input
1064
- const stdin = await readStdinIfAvailable();
1065
- const hookInput = parseHookInput(stdin);
1066
- // Check if this is a retry (stop hook already active)
1067
- if (hookInput?.stop_hook_active) {
1068
- options.stopHookActive = true;
1069
- }
1070
- const ctx = await initContext();
1071
- const result = await performCheckpoint(ctx, options);
1072
- // Output format depends on mode:
1073
- // - JSON mode (--json): Output Claude Code hook format {"decision": "block", "reason": "..."}
1074
- // - Human mode: Output formatted checkpoint result
1075
- if (isJsonMode()) {
1076
- if (!result.ok) {
1077
- // Build reason message with issues and instructions
1078
- const issueLines = result.issues
1079
- .map((i) => `- ${i.description}`)
1080
- .join("\n");
1081
- const instructionLines = result.instructions
1082
- .filter((i) => i.trim())
1083
- .join("\n");
1084
- const reason = `${result.message}\n\nIssues:\n${issueLines}\n\n${instructionLines}`;
1085
- console.log(JSON.stringify({ decision: "block", reason }));
1086
- }
1087
- // If ok, exit silently (Claude Code expects no output when allowing stop)
1088
- }
1089
- else {
1090
- formatCheckpointResult(result);
1091
- if (!result.ok) {
1092
- process.exit(EXIT_CODES.ERROR);
1093
- }
1094
- }
1095
- }
1096
- catch (err) {
1097
- // Handle RUNNING_FROM_SHADOW gracefully - skip with warning instead of erroring
1098
- // This happens when the stop hook runs while cwd is inside .kspec/ directory
1099
- if (err instanceof ShadowError && err.code === "RUNNING_FROM_SHADOW") {
1100
- if (!isJsonMode()) {
1101
- console.log(chalk.yellow("[kspec] Session checkpoint skipped - running from inside .kspec/ directory"));
1102
- }
1103
- // Allow stop to proceed (exit successfully, no JSON output blocks the stop)
1104
- return;
1105
- }
1106
- error(errors.failures.runCheckpoint, err);
1107
- process.exit(EXIT_CODES.ERROR);
1108
- }
1109
- }
1110
- const VALID_SORT_FIELDS = [
1111
- "started_at",
1112
- "duration",
1113
- "events",
1114
- "iterations",
1115
- "tasks_completed",
1116
- ];
1117
- /**
1118
- * Format a duration in milliseconds to a human-readable string.
1119
- */
1120
- function formatDuration(ms) {
1121
- if (ms < 0)
1122
- return "—";
1123
- const totalSec = Math.floor(ms / 1000);
1124
- const hours = Math.floor(totalSec / 3600);
1125
- const minutes = Math.floor((totalSec % 3600) / 60);
1126
- if (hours > 0) {
1127
- return `${hours}h ${minutes}m`;
1128
- }
1129
- if (minutes > 0) {
1130
- return `${minutes}m`;
1131
- }
1132
- return `${totalSec}s`;
1133
- }
1134
- /**
1135
- * Sort session summaries by the specified field.
1136
- * Default: started_at descending.
1137
- *
1138
- * AC: @session-log-list ac-5
1139
- */
1140
- function sortSessions(sessions, sortField) {
1141
- return [...sessions].sort((a, b) => {
1142
- switch (sortField) {
1143
- case "started_at":
1144
- return (new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
1145
- case "duration":
1146
- return b.duration_ms - a.duration_ms;
1147
- case "events":
1148
- return b.event_count - a.event_count;
1149
- case "iterations":
1150
- return b.iteration_count - a.iteration_count;
1151
- case "tasks_completed":
1152
- return b.tasks_completed - a.tasks_completed;
1153
- default:
1154
- return (new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
1155
- }
1156
- });
1157
- }
1158
- /**
1159
- * Format the session log list as a table.
1160
- *
1161
- * AC: @session-log-list ac-1
1162
- */
1163
- function formatSessionLogList(sessions) {
1164
- if (sessions.length === 0) {
1165
- // AC: @session-log-list ac-6
1166
- console.log("No sessions found.");
1167
- return;
1168
- }
1169
- // Table header
1170
- 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`));
1171
- console.log(chalk.gray("─".repeat(95)));
1172
- for (const s of sessions) {
1173
- const id = s.id.slice(0, 8);
1174
- const statusColor = s.status === "completed"
1175
- ? chalk.green
1176
- : s.status === "active"
1177
- ? chalk.blue
1178
- : chalk.yellow;
1179
- const status = statusColor(s.status.padEnd(11));
1180
- const agent = s.agent_type.slice(0, 20).padEnd(20);
1181
- const started = formatRelativeTime(new Date(s.started_at)).padEnd(16);
1182
- const duration = formatDuration(s.duration_ms).padEnd(10);
1183
- const events = String(s.event_count).padEnd(8);
1184
- const iters = String(s.iteration_count).padEnd(7);
1185
- const tasks = String(s.tasks_completed);
1186
- console.log(`${chalk.yellow(id)} ${status} ${chalk.gray(agent)} ${chalk.gray(started)} ${duration} ${events} ${iters} ${tasks}`);
1187
- }
1188
- console.log(chalk.gray(`\n${sessions.length} session(s)`));
1189
- }
1190
- /**
1191
- * Session log list action handler.
1192
- */
1193
- async function sessionLogListAction(options) {
1194
- try {
1195
- const ctx = await initContext();
1196
- let sessions = await getAllSessionLogSummaries(ctx.specDir);
1197
- // AC: @session-log-list ac-2 - Filter by status
1198
- if (options.status) {
1199
- const statusFilter = options.status;
1200
- sessions = sessions.filter((s) => s.status === statusFilter);
1201
- }
1202
- // AC: @session-log-list ac-4 - Filter by agent type
1203
- if (options.agent) {
1204
- const agentFilter = options.agent;
1205
- sessions = sessions.filter((s) => s.agent_type === agentFilter);
1206
- }
1207
- // AC: @session-log-list ac-3 - Filter by since date
1208
- if (options.since) {
1209
- const sinceDate = parseTimeSpec(options.since);
1210
- if (sinceDate) {
1211
- sessions = sessions.filter((s) => new Date(s.started_at) >= sinceDate);
1212
- }
1213
- }
1214
- // AC: @session-log-list ac-5 - Sort
1215
- const sortField = options.sort && VALID_SORT_FIELDS.includes(options.sort)
1216
- ? options.sort
1217
- : "started_at";
1218
- sessions = sortSessions(sessions, sortField);
1219
- // AC: @session-log-list ac-7 - Limit output count
1220
- if (options.count) {
1221
- // AC: @trait-filterable-list ac-8
1222
- output({ count: sessions.length }, () => {
1223
- console.log(sessions.length);
1224
- });
1225
- return;
1226
- }
1227
- // Apply --limit (after filtering/sorting, before display)
1228
- if (options.limit) {
1229
- const limit = parseInt(options.limit, 10);
1230
- if (!Number.isNaN(limit) && limit > 0) {
1231
- sessions = sessions.slice(0, limit);
1232
- }
1233
- }
1234
- output(sessions, () => formatSessionLogList(sessions));
1235
- }
1236
- catch (err) {
1237
- error("Failed to list session logs", err);
1238
- process.exit(EXIT_CODES.ERROR);
1239
- }
1240
- }
1241
- /**
1242
- * Format an event timestamp as relative time from session start.
1243
- */
1244
- function formatEventTimestamp(eventTs, sessionStartTs) {
1245
- const relativeMs = eventTs - sessionStartTs;
1246
- const totalSec = Math.floor(relativeMs / 1000);
1247
- const minutes = Math.floor(totalSec / 60);
1248
- const seconds = totalSec % 60;
1249
- if (minutes > 0) {
1250
- return `+${minutes}m${seconds}s`;
1251
- }
1252
- return `+${seconds}s`;
1253
- }
1254
- /**
1255
- * Summarize event data for display.
1256
- * Returns a short string describing the event payload.
1257
- */
1258
- function summarizeEventData(event) {
1259
- const data = event.data;
1260
- if (!data)
1261
- return "";
1262
- // Handle tool_call events
1263
- if (event.type === "session.update") {
1264
- const update = data.update;
1265
- if (update?.sessionUpdate === "tool_call") {
1266
- const toolName = update._meta?.claudeCode?.toolName || "unknown";
1267
- const command = update.rawInput?.command;
1268
- if (command) {
1269
- const truncated = command.length > 60 ? command.slice(0, 57) + "..." : command;
1270
- return `${toolName}: ${truncated}`;
1271
- }
1272
- return toolName;
1273
- }
1274
- }
1275
- // Handle prompt.sent events
1276
- if (event.type === "prompt.sent") {
1277
- const prompt = data.prompt;
1278
- if (prompt) {
1279
- const truncated = prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt;
1280
- return truncated;
1281
- }
1282
- }
1283
- // Handle session.start/end
1284
- if (event.type === "session.start") {
1285
- return "Session started";
1286
- }
1287
- if (event.type === "session.end") {
1288
- const reason = data.reason;
1289
- return reason ? `Session ended: ${reason}` : "Session ended";
1290
- }
1291
- // Default: show first key
1292
- const keys = Object.keys(data);
1293
- if (keys.length > 0) {
1294
- return `{${keys.slice(0, 3).join(", ")}${keys.length > 3 ? ", ..." : ""}}`;
1295
- }
1296
- return "";
1297
- }
1298
- /**
1299
- * Format the session log show output.
1300
- *
1301
- * AC: @session-log-show ac-1
1302
- */
1303
- function formatSessionLogShow(detail, events, contextSnapshot, sessionStartTs) {
1304
- // AC: @session-log-show ac-1 - Session metadata
1305
- console.log(chalk.bold(`Session ${detail.id.slice(0, 8)}`));
1306
- console.log(chalk.gray("─".repeat(60)));
1307
- console.log(` ID: ${detail.id}`);
1308
- const statusColor = detail.status === "completed"
1309
- ? chalk.green
1310
- : detail.status === "active"
1311
- ? chalk.blue
1312
- : chalk.yellow;
1313
- console.log(` Status: ${statusColor(detail.status)}`);
1314
- console.log(` Agent: ${detail.agent_type}`);
1315
- if (detail.task_id) {
1316
- console.log(` Task: ${detail.task_id}`);
1317
- }
1318
- console.log(` Started: ${detail.started_at}`);
1319
- if (detail.ended_at) {
1320
- console.log(` Ended: ${detail.ended_at}`);
1321
- }
1322
- console.log(` Duration: ${formatDuration(detail.duration_ms)}`);
1323
- console.log(` Events: ${detail.event_count}`);
1324
- console.log(` Iterations: ${detail.iteration_count}`);
1325
- // AC: @session-log-show ac-2 - Per-iteration summary
1326
- if (detail.iterations.length > 0) {
1327
- console.log("\n" + chalk.bold("Iterations"));
1328
- console.log(chalk.gray("─".repeat(60)));
1329
- for (const iter of detail.iterations) {
1330
- const taskInfo = [];
1331
- if (iter.tasks_started.length > 0) {
1332
- taskInfo.push(`started: ${iter.tasks_started.join(", ")}`);
1333
- }
1334
- if (iter.tasks_completed.length > 0) {
1335
- taskInfo.push(`completed: ${iter.tasks_completed.join(", ")}`);
1336
- }
1337
- const taskStr = taskInfo.length > 0 ? ` | ${taskInfo.join(" | ")}` : "";
1338
- console.log(` ${chalk.cyan(`[${iter.iteration}]`)} ${iter.event_count} events${taskStr}`);
1339
- }
1340
- }
1341
- // AC: @session-log-show ac-3 - Event timeline
1342
- if (events !== null) {
1343
- console.log("\n" + chalk.bold("Events"));
1344
- console.log(chalk.gray("─".repeat(60)));
1345
- if (events.length === 0) {
1346
- console.log(chalk.gray(" No events to display."));
1347
- }
1348
- else {
1349
- for (const event of events) {
1350
- const timestamp = formatEventTimestamp(event.ts, sessionStartTs);
1351
- const summary = summarizeEventData(event);
1352
- const typeColor = event.type === "session.start" || event.type === "session.end"
1353
- ? chalk.green
1354
- : event.type === "session.update"
1355
- ? chalk.blue
1356
- : chalk.gray;
1357
- console.log(` ${chalk.yellow(timestamp.padEnd(10))} ${typeColor(event.type.padEnd(16))} ${chalk.gray(summary)}`);
1358
- }
1359
- }
1360
- }
1361
- // AC: @session-log-show ac-6 - Context snapshot
1362
- if (contextSnapshot !== null) {
1363
- console.log("\n" + chalk.bold("Context Snapshot"));
1364
- console.log(chalk.gray("─".repeat(60)));
1365
- console.log(JSON.stringify(contextSnapshot, null, 2));
1366
- }
1367
- }
1368
- /**
1369
- * Session log show action handler.
1370
- */
1371
- async function sessionLogShowAction(sessionRef, options) {
1372
- try {
1373
- const ctx = await initContext();
1374
- // AC: @session-log-show ac-7, ac-8, ac-9 - Resolve session ID
1375
- const resolution = await resolveSessionId(ctx.specDir, sessionRef);
1376
- if (!resolution.ok) {
1377
- if (resolution.error === "not_found") {
1378
- // AC: @session-log-show ac-9
1379
- error(`Session not found: ${sessionRef}`);
1380
- process.exit(EXIT_CODES.NOT_FOUND);
1381
- }
1382
- else {
1383
- // AC: @session-log-show ac-8
1384
- error(`Ambiguous session ID prefix. Matches:\n ${resolution.matches.join("\n ")}\nPlease provide a more specific prefix.`);
1385
- process.exit(EXIT_CODES.VALIDATION_FAILED);
1386
- }
1387
- }
1388
- const sessionId = resolution.id;
1389
- // Get session detail
1390
- const detail = await getSessionLogDetail(ctx.specDir, sessionId);
1391
- if (!detail) {
1392
- error(`Session not found: ${sessionId}`);
1393
- process.exit(EXIT_CODES.NOT_FOUND);
1394
- }
1395
- // AC: @session-log-show ac-3, ac-4, ac-5 - Event timeline
1396
- let events = null;
1397
- if (options.events) {
1398
- let allEvents = deduplicatePhasedToolCalls(await readEvents(ctx.specDir, sessionId));
1399
- // AC: @session-log-show ac-4 - Filter by type
1400
- if (options.type) {
1401
- const typeFilter = options.type;
1402
- allEvents = allEvents.filter((e) => e.type === typeFilter);
1403
- }
1404
- // AC: @session-log-show ac-5 - Limit to last N events
1405
- if (options.limit) {
1406
- const limit = parseInt(options.limit, 10);
1407
- if (!Number.isNaN(limit) && limit > 0) {
1408
- allEvents = allEvents.slice(-limit);
1409
- }
1410
- }
1411
- events = allEvents;
1412
- }
1413
- // AC: @session-log-show ac-6 - Context snapshot
1414
- let contextSnapshot = null;
1415
- if (options.context) {
1416
- const iterNum = parseInt(options.context, 10);
1417
- if (!Number.isNaN(iterNum) && iterNum > 0) {
1418
- contextSnapshot = await readSessionContext(ctx.specDir, sessionId, iterNum);
1419
- if (contextSnapshot === null) {
1420
- error(`No context snapshot found for iteration ${iterNum}`);
1421
- process.exit(EXIT_CODES.NOT_FOUND);
1422
- }
1423
- }
1424
- else {
1425
- error(`Invalid iteration number: ${options.context}`);
1426
- process.exit(EXIT_CODES.USAGE_ERROR);
1427
- }
1428
- }
1429
- const sessionStartTs = new Date(detail.started_at).getTime();
1430
- // Build JSON output structure
1431
- const jsonOutput = {
1432
- ...detail,
1433
- ...(events !== null ? { events } : {}),
1434
- ...(contextSnapshot !== null ? { context: contextSnapshot } : {}),
1435
- };
1436
- output(jsonOutput, () => formatSessionLogShow(detail, events, contextSnapshot, sessionStartTs));
1437
- }
1438
- catch (err) {
1439
- error("Failed to show session log", err);
1440
- process.exit(EXIT_CODES.ERROR);
1441
- }
1442
- }
1443
- /**
1444
- * Format a duration in milliseconds to human-readable format.
1445
- * Reuses formatDuration from session log list but handles hours/minutes/seconds.
1446
- */
1447
- function formatDurationLong(ms) {
1448
- if (ms < 0)
1449
- return "—";
1450
- const totalSec = Math.floor(ms / 1000);
1451
- const hours = Math.floor(totalSec / 3600);
1452
- const minutes = Math.floor((totalSec % 3600) / 60);
1453
- const seconds = totalSec % 60;
1454
- if (hours > 0 && minutes > 0) {
1455
- return `${hours}h ${minutes}m`;
1456
- }
1457
- if (hours > 0) {
1458
- return `${hours}h`;
1459
- }
1460
- if (minutes > 0) {
1461
- return `${minutes}m ${seconds}s`;
1462
- }
1463
- return `${seconds}s`;
1464
- }
1465
- /**
1466
- * Format the session log stats output.
1467
- *
1468
- * AC: @session-log-stats ac-1, ac-2, ac-3
1469
- */
1470
- function formatSessionLogStats(stats, toolUsage, timePeriods, groupBy) {
1471
- // AC: @session-log-stats ac-1 - Totals
1472
- console.log(chalk.bold("Session Statistics"));
1473
- console.log(chalk.gray("─".repeat(50)));
1474
- console.log(` Total Sessions: ${stats.total_sessions}`);
1475
- console.log(` Total Events: ${stats.total_events}`);
1476
- console.log(` Total Iterations: ${stats.total_iterations}`);
1477
- console.log(` Tasks Completed: ${stats.total_tasks_completed}`);
1478
- console.log(` Total Duration: ${formatDurationLong(stats.total_duration_ms)}`);
1479
- // AC: @session-log-stats ac-2 - Averages
1480
- console.log("\n" + chalk.bold("Averages"));
1481
- console.log(chalk.gray("─".repeat(50)));
1482
- console.log(` Avg Duration/Session: ${formatDurationLong(stats.avg_duration_ms)}`);
1483
- console.log(` Avg Iterations/Session: ${stats.avg_iterations_per_session}`);
1484
- console.log(` Avg Tasks/Session: ${stats.avg_tasks_per_session}`);
1485
- // AC: @session-log-stats ac-3 - Status breakdown
1486
- if (stats.status_breakdown.length > 0) {
1487
- console.log("\n" + chalk.bold("Status Breakdown"));
1488
- console.log(chalk.gray("─".repeat(50)));
1489
- for (const item of stats.status_breakdown) {
1490
- const statusColor = item.status === "completed"
1491
- ? chalk.green
1492
- : item.status === "active"
1493
- ? chalk.blue
1494
- : chalk.yellow;
1495
- console.log(` ${statusColor(item.status.padEnd(12))} ${String(item.count).padEnd(6)} ${item.percentage}%`);
1496
- }
1497
- }
1498
- // AC: @session-log-stats ac-6 - Tool usage
1499
- if (toolUsage !== null && toolUsage.length > 0) {
1500
- console.log("\n" + chalk.bold("Top Tool Usage"));
1501
- console.log(chalk.gray("─".repeat(50)));
1502
- for (const tool of toolUsage) {
1503
- console.log(` ${tool.tool_name.padEnd(20)} ${String(tool.count).padEnd(8)} ${tool.percentage}%`);
1504
- }
1505
- }
1506
- // AC: @session-log-stats ac-7 - Time periods
1507
- if (timePeriods !== null && timePeriods.length > 0) {
1508
- const label = groupBy === "week" ? "By Week" : "By Day";
1509
- console.log("\n" + chalk.bold(label));
1510
- console.log(chalk.gray("─".repeat(50)));
1511
- console.log(chalk.gray(` ${"Period".padEnd(14)} ${"Sessions".padEnd(10)} ${"Tasks".padEnd(8)} Duration`));
1512
- for (const period of timePeriods) {
1513
- console.log(` ${period.period.padEnd(14)} ${String(period.sessions_count).padEnd(10)} ${String(period.tasks_completed).padEnd(8)} ${formatDurationLong(period.total_duration_ms)}`);
1514
- }
1515
- }
1516
- }
1517
- /**
1518
- * Session log stats action handler.
1519
- */
1520
- async function sessionLogStatsAction(options) {
1521
- try {
1522
- const ctx = await initContext();
1523
- let sessions = await getAllSessionLogSummaries(ctx.specDir);
1524
- // AC: @session-log-stats ac-4 - Filter by since
1525
- if (options.since) {
1526
- const sinceDate = parseTimeSpec(options.since);
1527
- if (sinceDate) {
1528
- sessions = sessions.filter((s) => new Date(s.started_at) >= sinceDate);
1529
- }
1530
- }
1531
- // AC: @session-log-stats ac-5 - Filter by agent type
1532
- if (options.agent) {
1533
- const agentFilter = options.agent;
1534
- sessions = sessions.filter((s) => s.agent_type === agentFilter);
1535
- }
1536
- // AC: @session-log-stats ac-8 - No sessions match criteria
1537
- if (sessions.length === 0) {
1538
- output({ message: "No sessions match criteria" }, () => {
1539
- console.log("No sessions match criteria.");
1540
- });
1541
- return;
1542
- }
1543
- // Compute base stats
1544
- const stats = computeSessionLogStats(sessions);
1545
- // AC: @session-log-stats ac-6 - Tool usage (optional)
1546
- let toolUsage = null;
1547
- if (options.toolUsage) {
1548
- const sessionIds = sessions.map((s) => s.id);
1549
- toolUsage = await computeToolUsageStats(ctx.specDir, sessionIds);
1550
- }
1551
- // AC: @session-log-stats ac-7 - Time periods (optional)
1552
- let timePeriods = null;
1553
- let groupBy = null;
1554
- if (options.byDay) {
1555
- groupBy = "day";
1556
- timePeriods = computeTimePeriodStats(sessions, "day");
1557
- }
1558
- else if (options.byWeek) {
1559
- groupBy = "week";
1560
- timePeriods = computeTimePeriodStats(sessions, "week");
1561
- }
1562
- // Build output structure
1563
- const jsonOutput = { stats };
1564
- if (toolUsage !== null) {
1565
- jsonOutput.tool_usage = toolUsage;
1566
- }
1567
- if (timePeriods !== null) {
1568
- jsonOutput.time_periods = timePeriods;
1569
- }
1570
- output(jsonOutput, () => formatSessionLogStats(stats, toolUsage, timePeriods, groupBy));
1571
- }
1572
- catch (err) {
1573
- error("Failed to compute session log stats", err);
1574
- process.exit(EXIT_CODES.ERROR);
1575
- }
1576
- }
1577
- /**
1578
- * Format relative timestamp from event timestamp (Unix ms) to session start.
1579
- */
1580
- function formatSearchTimestamp(eventTs) {
1581
- return new Date(eventTs).toISOString();
1582
- }
1583
- /**
1584
- * Format the session log search output.
1585
- *
1586
- * AC: @session-log-search ac-1, ac-4
1587
- */
1588
- function formatSessionLogSearch(results) {
1589
- if (results.length === 0) {
1590
- // AC: @session-log-search ac-6
1591
- console.log("No matches found.");
1592
- return;
1593
- }
1594
- let totalMatches = 0;
1595
- for (const session of results) {
1596
- totalMatches += session.matches.length;
1597
- }
1598
- console.log(chalk.bold(`Found ${totalMatches} match(es) in ${results.length} session(s)`));
1599
- console.log(chalk.gray("─".repeat(60)));
1600
- for (const session of results) {
1601
- // Session header
1602
- console.log(`\n${chalk.cyan(`Session ${session.session_id.slice(0, 8)}`)} ` +
1603
- `${chalk.gray(`(${session.agent_type}, started ${formatRelativeTime(new Date(session.started_at))})`)}`);
1604
- // AC: @session-log-search ac-4 - Show matches with session ID, timestamp, type, excerpt
1605
- for (const match of session.matches) {
1606
- const ts = formatSearchTimestamp(match.timestamp);
1607
- const typeColor = match.event_type === "session.start" || match.event_type === "session.end"
1608
- ? chalk.green
1609
- : match.event_type === "session.update"
1610
- ? chalk.blue
1611
- : chalk.gray;
1612
- console.log(` ${chalk.yellow(ts)} ${typeColor(match.event_type.padEnd(16))}`);
1613
- // Content excerpt on next line, indented
1614
- console.log(` ${chalk.gray(match.content_excerpt)}`);
1615
- }
1616
- }
1617
- }
1618
- /**
1619
- * Session log search action handler.
1620
- *
1621
- * AC: @session-log-search ac-1 through ac-7
1622
- */
1623
- async function sessionLogSearchAction(pattern, options) {
1624
- try {
1625
- const ctx = await initContext();
1626
- // Parse options - validate limit as positive integer
1627
- let limit = 50;
1628
- if (options.limit) {
1629
- const parsed = parseInt(options.limit, 10);
1630
- if (Number.isNaN(parsed) || parsed <= 0) {
1631
- error(`Invalid limit: ${options.limit}. Must be a positive integer.`);
1632
- process.exit(EXIT_CODES.USAGE_ERROR);
1633
- }
1634
- limit = parsed;
1635
- }
1636
- const sinceDate = options.since ? parseTimeSpec(options.since) : undefined;
1637
- // AC: @session-log-search ac-1, ac-2, ac-3, ac-5, ac-7
1638
- const results = await searchSessionEvents(ctx.specDir, pattern, {
1639
- eventType: options.type,
1640
- sinceDate: sinceDate || undefined,
1641
- agentType: options.agent,
1642
- limit,
1643
- });
1644
- // AC: @session-log-search ac-6 - No matches found message
1645
- // exit code 0 regardless (per @trait-semantic-exit-codes ac-5)
1646
- output(results, () => formatSessionLogSearch(results));
1647
- }
1648
- catch (err) {
1649
- error("Failed to search session logs", err);
1650
- process.exit(EXIT_CODES.ERROR);
1651
- }
1652
- }
1653
- // ─── Session Create Action ─────────────────────────────────────────────────
1654
- /**
1655
- * Action handler for `kspec session create`.
1656
- *
1657
- * Creates a new session with optional budget and environment injection.
1658
- *
1659
- * AC: @session-creation-and-env-injection ac-create
1660
- * AC: @session-creation-and-env-injection ac-budget
1661
- * AC: @session-creation-and-env-injection ac-budget-local
1662
- * AC: @session-creation-and-env-injection ac-inject-claude
1663
- * AC: @session-creation-and-env-injection ac-inject-codex
1664
- * AC: @session-creation-and-env-injection ac-inject-fallback
1665
- *
1666
- * Exit codes documented per @trait-semantic-exit-codes ac-8:
1667
- * - 0: Session created successfully
1668
- * - 1: Validation error (invalid budget value)
1669
- * - 3: Runtime error (filesystem failure)
1670
- */
1671
- async function sessionCreateAction(options) {
1672
- try {
1673
- const ctx = await initContext();
1674
- // AC: @session-creation-and-env-injection ac-invalid-session
1675
- // Validate existing KSPEC_SESSION_ID if set — warn user if it's stale/corrupt
1676
- const existingSessionId = process.env.KSPEC_SESSION_ID;
1677
- if (existingSessionId) {
1678
- const validation = await validateSessionId(ctx.specDir, existingSessionId);
1679
- if (!validation.valid) {
1680
- warn(`Current KSPEC_SESSION_ID (${existingSessionId}) is invalid: ${validation.error}`);
1681
- info(validation.suggestion || "Creating a new session will generate a fresh ID.");
1682
- }
1683
- }
1684
- // Validate budget if provided
1685
- // AC: @trait-error-guidance ac-5 - indicate which field/value failed
1686
- let budgetNum;
1687
- if (options.budget !== undefined) {
1688
- // Use Number() instead of parseInt to reject "3.5", "3abc", "1e2" etc.
1689
- budgetNum = Number(options.budget);
1690
- if (isNaN(budgetNum) || budgetNum <= 0 || !Number.isInteger(budgetNum) || !/^\d+$/.test(options.budget)) {
1691
- // AC: @trait-error-guidance ac-2, ac-5 - include suggested action and field info
1692
- // AC: @trait-error-guidance ac-6 - guidance included in structured error
1693
- error(`Invalid budget value: "${options.budget}". Must be a positive integer.`, { suggestion: "Usage: kspec session create --budget <positive-integer>" });
1694
- process.exit(EXIT_CODES.USAGE_ERROR);
1695
- }
1696
- }
1697
- // Generate session ID
1698
- const sessionId = ulid();
1699
- // AC: @session-creation-and-env-injection ac-create, ac-budget, ac-budget-local
1700
- const result = await createSessionWithBudget(ctx.specDir, {
1701
- id: sessionId,
1702
- agent_type: options.agentType,
1703
- task_id: options.taskId,
1704
- budget: budgetNum,
1705
- });
1706
- // Handle environment injection if requested
1707
- let injection = null;
1708
- if (options.inject) {
1709
- injection = await performEnvInjection(sessionId);
1710
- }
1711
- // Build output data
1712
- const outputData = {
1713
- session_id: result.session_id,
1714
- agent_type: result.session.agent_type,
1715
- status: result.session.status,
1716
- started_at: result.session.started_at,
1717
- };
1718
- if (result.session.task_id) {
1719
- outputData.task_id = result.session.task_id;
1720
- }
1721
- if (result.budget) {
1722
- outputData.budget = {
1723
- max_per_cycle: result.budget.max_per_cycle,
1724
- started_this_cycle: result.budget.started_this_cycle,
1725
- };
1726
- }
1727
- if (injection) {
1728
- outputData.env_injection = {
1729
- method: injection.method,
1730
- injected: injection.injected,
1731
- description: injection.description,
1732
- ...(injection.path ? { path: injection.path } : {}),
1733
- };
1734
- }
1735
- // AC: @trait-json-output ac-1, ac-2, ac-5 - JSON with all data, ISO timestamps
1736
- output(outputData, () => {
1737
- // AC: @session-creation-and-env-injection ac-create - print session ID to stdout
1738
- success(`Created session: ${sessionId}`, { session_id: sessionId });
1739
- info(`Agent type: ${options.agentType}`);
1740
- if (result.budget) {
1741
- info(`Budget: ${result.budget.max_per_cycle} tasks per cycle`);
1742
- }
1743
- if (injection) {
1744
- if (injection.injected) {
1745
- info(injection.description);
1746
- }
1747
- else {
1748
- // AC: @session-creation-and-env-injection ac-inject-fallback
1749
- console.log(injection.description);
1750
- }
1751
- }
1752
- });
1753
- }
1754
- catch (err) {
1755
- // AC: @trait-error-guidance ac-1 - describe what went wrong
1756
- // AC: @trait-json-output ac-3 - error as JSON object
1757
- error("Failed to create session", err);
1758
- process.exit(EXIT_CODES.ERROR);
1759
- }
1760
- }
1761
- /**
1762
- * Detect agent harness and perform environment injection.
1763
- *
1764
- * AC: @session-creation-and-env-injection ac-inject-claude
1765
- * AC: @session-creation-and-env-injection ac-inject-codex
1766
- * AC: @session-creation-and-env-injection ac-inject-fallback
1767
- */
1768
- async function performEnvInjection(sessionId) {
1769
- // Detect Claude Code
1770
- if (process.env.CLAUDECODE === "1" ||
1771
- process.env.CLAUDE_CODE_ENTRYPOINT ||
1772
- process.env.CLAUDE_PROJECT_DIR) {
1773
- return injectClaudeCodeEnv(sessionId);
1774
- }
1775
- // Detect Codex CLI
1776
- if (process.env.CODEX_SANDBOX) {
1777
- return injectCodexEnv(sessionId);
1778
- }
1779
- // Detect Gemini CLI
1780
- if (process.env.GEMINI_CLI === "1") {
1781
- return injectGeminiEnv(sessionId);
1782
- }
1783
- // Detect OpenCode
1784
- if (process.env.OPENCODE_CONFIG_DIR || process.env.OPENCODE_CONFIG) {
1785
- return injectOpenCodeEnv(sessionId);
1786
- }
1787
- // Fallback for unknown harnesses
1788
- return getFallbackInjectionInstructions(sessionId);
1789
- }
1790
- /**
1791
- * Register the 'session' command group and aliases
1792
- */
1793
- export function registerSessionCommands(program) {
1794
- const session = program
1795
- .command("session")
1796
- .description("Session management and context");
1797
- // Session create subcommand
1798
- markMutating(session.command("create"))
1799
- .description("Create a new kspec session with optional budget")
1800
- .option("--agent-type <type>", "Agent type (e.g., claude-code, codex-cli)", "claude-code")
1801
- .option("--budget <n>", "Maximum tasks per cycle (positive integer)")
1802
- .option("--inject", "Inject KSPEC_SESSION_ID into agent environment")
1803
- .option("--task-id <id>", "Optional task ID being worked on")
1804
- .action(sessionCreateAction);
1805
- session
1806
- .command("start")
1807
- .alias("resume")
1808
- .description("Surface relevant context for starting a new working session")
1809
- .option("--brief", "Compact summary (default)")
1810
- .option("--full", "Comprehensive context dump")
1811
- .option("--since <time>", "Filter by recency (ISO8601 or relative: 1h, 2d, 1w)")
1812
- .option("--no-git", "Skip git commit information")
1813
- .option("-n, --limit <n>", "Limit items per section", "10")
1814
- .action(sessionStartAction);
1815
- // Session log subcommand group
1816
- const log = session
1817
- .command("log")
1818
- .description("Session log analysis commands");
1819
- log
1820
- .command("list")
1821
- .description("List session logs with summary statistics")
1822
- .option("-s, --status <status>", "Filter by status (active, completed, abandoned)")
1823
- .option("--agent <type>", "Filter by agent type")
1824
- .option("--since <time>", "Only show sessions started after this time (ISO8601 or relative: 1h, 2d, 1w)")
1825
- .option("--sort <field>", "Sort by field (started_at, duration, events, iterations, tasks_completed)", "started_at")
1826
- .option("--count", "Show only the count of matching sessions")
1827
- .option("-n, --limit <n>", "Limit number of sessions shown")
1828
- .action(sessionLogListAction);
1829
- log
1830
- .command("show <session-id>")
1831
- .description("Show detailed view of a single session")
1832
- .option("-e, --events", "Include chronological event timeline")
1833
- .option("-t, --type <type>", "Filter events by type (e.g., tool.call)")
1834
- .option("-n, --limit <n>", "Show only the last N events")
1835
- .option("-c, --context <n>", "Show context snapshot for iteration N")
1836
- .action(sessionLogShowAction);
1837
- log
1838
- .command("stats")
1839
- .description("Aggregate analytics across sessions")
1840
- .option("--since <time>", "Only include sessions started after this time (ISO8601 or relative: 1h, 2d, 1w)")
1841
- .option("--agent <type>", "Only include sessions with this agent type")
1842
- .option("--tool-usage", "Display top 10 tool calls by frequency")
1843
- .option("--by-day", "Group stats by day")
1844
- .option("--by-week", "Group stats by week")
1845
- .action(sessionLogStatsAction);
1846
- log
1847
- .command("search <pattern>")
1848
- .description("Search across session events by content")
1849
- .option("-t, --type <type>", "Only search events of this type (e.g., session.update)")
1850
- .option("--since <time>", "Only search sessions started after this time (ISO8601 or relative: 1h, 2d, 1w)")
1851
- .option("--agent <type>", "Only search sessions with this agent type")
1852
- .option("-n, --limit <n>", "Maximum matches to return (default: 50)")
1853
- .action(sessionLogSearchAction);
1854
- session
1855
- .command("checkpoint")
1856
- .description("Pre-stop hook: check for uncommitted work before ending session")
1857
- .option("--force", "Allow session end regardless of issues")
1858
- .action(sessionCheckpointAction);
1859
- session
1860
- .command("prompt-check")
1861
- .description("UserPromptSubmit hook: inject spec-first reminder")
1862
- .action(sessionPromptCheckAction);
1863
- // Top-level alias: kspec context
1864
- program
1865
- .command("context")
1866
- .description("Alias for session start - surface session context")
1867
- .option("--brief", "Compact summary (default)")
1868
- .option("--full", "Comprehensive context dump")
1869
- .option("--since <time>", "Filter by recency (ISO8601 or relative: 1h, 2d, 1w)")
1870
- .option("--no-git", "Skip git commit information")
1871
- .option("-n, --limit <n>", "Limit items per section", "10")
1872
- .action(sessionStartAction);
1873
- }
7
+ export {
8
+ // Functions
9
+ gatherSessionContext, getIterationStats, performCheckpoint, getDisplayRef, formatPriority, statusColor, registerSessionCommands, } from "./session/index.js";
1874
10
  //# sourceMappingURL=session.js.map