@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.
- package/dist/cli/commands/batch.d.ts.map +1 -1
- package/dist/cli/commands/batch.js +38 -6
- package/dist/cli/commands/batch.js.map +1 -1
- package/dist/cli/commands/item.d.ts.map +1 -1
- package/dist/cli/commands/item.js +42 -23
- package/dist/cli/commands/item.js.map +1 -1
- package/dist/cli/commands/ralph.d.ts.map +1 -1
- package/dist/cli/commands/ralph.js +15 -2
- package/dist/cli/commands/ralph.js.map +1 -1
- package/dist/cli/commands/session/checkpoint.d.ts +19 -0
- package/dist/cli/commands/session/checkpoint.d.ts.map +1 -0
- package/dist/cli/commands/session/checkpoint.js +161 -0
- package/dist/cli/commands/session/checkpoint.js.map +1 -0
- package/dist/cli/commands/session/commands.d.ts +18 -0
- package/dist/cli/commands/session/commands.d.ts.map +1 -0
- package/dist/cli/commands/session/commands.js +259 -0
- package/dist/cli/commands/session/commands.js.map +1 -0
- package/dist/cli/commands/session/context.d.ts +17 -0
- package/dist/cli/commands/session/context.d.ts.map +1 -0
- package/dist/cli/commands/session/context.js +493 -0
- package/dist/cli/commands/session/context.js.map +1 -0
- package/dist/cli/commands/session/create.d.ts +29 -0
- package/dist/cli/commands/session/create.d.ts.map +1 -0
- package/dist/cli/commands/session/create.js +147 -0
- package/dist/cli/commands/session/create.js.map +1 -0
- package/dist/cli/commands/session/format.d.ts +27 -0
- package/dist/cli/commands/session/format.d.ts.map +1 -0
- package/dist/cli/commands/session/format.js +401 -0
- package/dist/cli/commands/session/format.js.map +1 -0
- package/dist/cli/commands/session/index.d.ts +13 -0
- package/dist/cli/commands/session/index.d.ts.map +1 -0
- package/dist/cli/commands/session/index.js +17 -0
- package/dist/cli/commands/session/index.js.map +1 -0
- package/dist/cli/commands/session/log.d.ts +52 -0
- package/dist/cli/commands/session/log.d.ts.map +1 -0
- package/dist/cli/commands/session/log.js +570 -0
- package/dist/cli/commands/session/log.js.map +1 -0
- package/dist/cli/commands/session/types.d.ts +230 -0
- package/dist/cli/commands/session/types.d.ts.map +1 -0
- package/dist/cli/commands/session/types.js +7 -0
- package/dist/cli/commands/session/types.js.map +1 -0
- package/dist/cli/commands/session.d.ts +4 -251
- package/dist/cli/commands/session.d.ts.map +1 -1
- package/dist/cli/commands/session.js +6 -1870
- package/dist/cli/commands/session.js.map +1 -1
- package/dist/cli/commands/validate.d.ts.map +1 -1
- package/dist/cli/commands/validate.js +23 -7
- package/dist/cli/commands/validate.js.map +1 -1
- package/dist/parser/shadow.d.ts +5 -1
- package/dist/parser/shadow.d.ts.map +1 -1
- package/dist/parser/shadow.js +18 -10
- package/dist/parser/shadow.js.map +1 -1
- package/dist/parser/validate.d.ts +4 -1
- package/dist/parser/validate.d.ts.map +1 -1
- package/dist/parser/validate.js +50 -35
- package/dist/parser/validate.js.map +1 -1
- package/dist/sessions/store.d.ts +37 -1
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +133 -5
- package/dist/sessions/store.js.map +1 -1
- package/package.json +4 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/templates/agents-sections/03-task-lifecycle.md +4 -1
- 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
|
-
*
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|