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