@kynetic-ai/spec 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/cli/batch-exec.d.ts +0 -9
  2. package/dist/cli/batch-exec.d.ts.map +1 -1
  3. package/dist/cli/batch-exec.js +16 -4
  4. package/dist/cli/batch-exec.js.map +1 -1
  5. package/dist/cli/commands/derive.d.ts.map +1 -1
  6. package/dist/cli/commands/derive.js +2 -1
  7. package/dist/cli/commands/derive.js.map +1 -1
  8. package/dist/cli/commands/guard.d.ts +43 -0
  9. package/dist/cli/commands/guard.d.ts.map +1 -0
  10. package/dist/cli/commands/guard.js +200 -0
  11. package/dist/cli/commands/guard.js.map +1 -0
  12. package/dist/cli/commands/index.d.ts +1 -0
  13. package/dist/cli/commands/index.d.ts.map +1 -1
  14. package/dist/cli/commands/index.js +1 -0
  15. package/dist/cli/commands/index.js.map +1 -1
  16. package/dist/cli/commands/item.d.ts.map +1 -1
  17. package/dist/cli/commands/item.js +18 -0
  18. package/dist/cli/commands/item.js.map +1 -1
  19. package/dist/cli/commands/log.d.ts.map +1 -1
  20. package/dist/cli/commands/log.js +5 -4
  21. package/dist/cli/commands/log.js.map +1 -1
  22. package/dist/cli/commands/meta.d.ts.map +1 -1
  23. package/dist/cli/commands/meta.js +2 -1
  24. package/dist/cli/commands/meta.js.map +1 -1
  25. package/dist/cli/commands/plan-import.d.ts.map +1 -1
  26. package/dist/cli/commands/plan-import.js +100 -30
  27. package/dist/cli/commands/plan-import.js.map +1 -1
  28. package/dist/cli/commands/ralph.d.ts.map +1 -1
  29. package/dist/cli/commands/ralph.js +143 -330
  30. package/dist/cli/commands/ralph.js.map +1 -1
  31. package/dist/cli/commands/session.d.ts +73 -1
  32. package/dist/cli/commands/session.d.ts.map +1 -1
  33. package/dist/cli/commands/session.js +607 -162
  34. package/dist/cli/commands/session.js.map +1 -1
  35. package/dist/cli/commands/setup.d.ts.map +1 -1
  36. package/dist/cli/commands/setup.js +97 -217
  37. package/dist/cli/commands/setup.js.map +1 -1
  38. package/dist/cli/commands/skill-install.d.ts +4 -1
  39. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  40. package/dist/cli/commands/skill-install.js +62 -5
  41. package/dist/cli/commands/skill-install.js.map +1 -1
  42. package/dist/cli/commands/task.d.ts.map +1 -1
  43. package/dist/cli/commands/task.js +128 -59
  44. package/dist/cli/commands/task.js.map +1 -1
  45. package/dist/cli/commands/tasks.d.ts.map +1 -1
  46. package/dist/cli/commands/tasks.js +2 -4
  47. package/dist/cli/commands/tasks.js.map +1 -1
  48. package/dist/cli/commands/triage.d.ts.map +1 -1
  49. package/dist/cli/commands/triage.js +12 -98
  50. package/dist/cli/commands/triage.js.map +1 -1
  51. package/dist/cli/index.d.ts.map +1 -1
  52. package/dist/cli/index.js +2 -1
  53. package/dist/cli/index.js.map +1 -1
  54. package/dist/cli/output.d.ts.map +1 -1
  55. package/dist/cli/output.js +18 -4
  56. package/dist/cli/output.js.map +1 -1
  57. package/dist/daemon/routes/triage.ts +4 -70
  58. package/dist/parser/config.d.ts +106 -0
  59. package/dist/parser/config.d.ts.map +1 -1
  60. package/dist/parser/config.js +47 -0
  61. package/dist/parser/config.js.map +1 -1
  62. package/dist/parser/file-lock.d.ts +14 -0
  63. package/dist/parser/file-lock.d.ts.map +1 -0
  64. package/dist/parser/file-lock.js +124 -0
  65. package/dist/parser/file-lock.js.map +1 -0
  66. package/dist/parser/index.d.ts +1 -0
  67. package/dist/parser/index.d.ts.map +1 -1
  68. package/dist/parser/index.js +1 -0
  69. package/dist/parser/index.js.map +1 -1
  70. package/dist/parser/plan-document.d.ts +44 -0
  71. package/dist/parser/plan-document.d.ts.map +1 -1
  72. package/dist/parser/plan-document.js +76 -8
  73. package/dist/parser/plan-document.js.map +1 -1
  74. package/dist/parser/plans.d.ts.map +1 -1
  75. package/dist/parser/plans.js +28 -102
  76. package/dist/parser/plans.js.map +1 -1
  77. package/dist/parser/shadow.d.ts.map +1 -1
  78. package/dist/parser/shadow.js +11 -7
  79. package/dist/parser/shadow.js.map +1 -1
  80. package/dist/parser/yaml.d.ts.map +1 -1
  81. package/dist/parser/yaml.js +322 -297
  82. package/dist/parser/yaml.js.map +1 -1
  83. package/dist/ralph/events.d.ts.map +1 -1
  84. package/dist/ralph/events.js +24 -0
  85. package/dist/ralph/events.js.map +1 -1
  86. package/dist/ralph/index.d.ts +1 -1
  87. package/dist/ralph/index.d.ts.map +1 -1
  88. package/dist/ralph/index.js +1 -1
  89. package/dist/ralph/index.js.map +1 -1
  90. package/dist/ralph/subagent.d.ts +12 -1
  91. package/dist/ralph/subagent.d.ts.map +1 -1
  92. package/dist/ralph/subagent.js +22 -3
  93. package/dist/ralph/subagent.js.map +1 -1
  94. package/dist/schema/batch.d.ts +2 -0
  95. package/dist/schema/batch.d.ts.map +1 -1
  96. package/dist/schema/common.d.ts +6 -0
  97. package/dist/schema/common.d.ts.map +1 -1
  98. package/dist/schema/common.js +8 -0
  99. package/dist/schema/common.js.map +1 -1
  100. package/dist/schema/task.d.ts +22 -0
  101. package/dist/schema/task.d.ts.map +1 -1
  102. package/dist/schema/task.js +7 -0
  103. package/dist/schema/task.js.map +1 -1
  104. package/dist/sessions/store.d.ts +226 -1
  105. package/dist/sessions/store.d.ts.map +1 -1
  106. package/dist/sessions/store.js +712 -38
  107. package/dist/sessions/store.js.map +1 -1
  108. package/dist/sessions/types.d.ts +51 -2
  109. package/dist/sessions/types.d.ts.map +1 -1
  110. package/dist/sessions/types.js +25 -0
  111. package/dist/sessions/types.js.map +1 -1
  112. package/dist/strings/errors.d.ts +4 -0
  113. package/dist/strings/errors.d.ts.map +1 -1
  114. package/dist/strings/errors.js +2 -0
  115. package/dist/strings/errors.js.map +1 -1
  116. package/dist/strings/labels.d.ts +2 -0
  117. package/dist/strings/labels.d.ts.map +1 -1
  118. package/dist/strings/labels.js +2 -0
  119. package/dist/strings/labels.js.map +1 -1
  120. package/dist/triage/actions.d.ts +27 -0
  121. package/dist/triage/actions.d.ts.map +1 -0
  122. package/dist/triage/actions.js +95 -0
  123. package/dist/triage/actions.js.map +1 -0
  124. package/dist/triage/constants.d.ts +6 -0
  125. package/dist/triage/constants.d.ts.map +1 -0
  126. package/dist/triage/constants.js +7 -0
  127. package/dist/triage/constants.js.map +1 -0
  128. package/dist/triage/index.d.ts +3 -0
  129. package/dist/triage/index.d.ts.map +1 -0
  130. package/dist/triage/index.js +3 -0
  131. package/dist/triage/index.js.map +1 -0
  132. package/dist/utils/git.d.ts +2 -0
  133. package/dist/utils/git.d.ts.map +1 -1
  134. package/dist/utils/git.js +21 -5
  135. package/dist/utils/git.js.map +1 -1
  136. package/package.json +1 -1
  137. package/plugin/.claude-plugin/marketplace.json +1 -1
  138. package/plugin/.claude-plugin/plugin.json +1 -1
  139. package/plugin/plugins/kspec/skills/create-workflow/SKILL.md +235 -0
  140. package/plugin/plugins/kspec/skills/observations/SKILL.md +143 -0
  141. package/plugin/plugins/kspec/skills/plan/SKILL.md +343 -0
  142. package/plugin/plugins/kspec/skills/reflect/SKILL.md +161 -0
  143. package/plugin/plugins/kspec/skills/review/SKILL.md +230 -0
  144. package/plugin/plugins/kspec/skills/task-work/SKILL.md +319 -0
  145. package/plugin/plugins/kspec/skills/triage-automation/SKILL.md +140 -0
  146. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +232 -0
  147. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +354 -0
  148. package/templates/agents-sections/03-task-lifecycle.md +2 -2
  149. package/templates/agents-sections/04-pr-workflow.md +3 -3
  150. package/templates/agents-sections/05-commit-convention.md +14 -0
  151. package/templates/skills/create-workflow/SKILL.md +228 -0
  152. package/templates/skills/manifest.yaml +45 -0
  153. package/templates/skills/observations/SKILL.md +137 -0
  154. package/templates/skills/plan/SKILL.md +336 -0
  155. package/templates/skills/reflect/SKILL.md +155 -0
  156. package/templates/skills/review/SKILL.md +223 -0
  157. package/templates/skills/task-work/SKILL.md +312 -0
  158. package/templates/skills/triage-automation/SKILL.md +134 -0
  159. package/templates/skills/triage-inbox/SKILL.md +225 -0
  160. package/templates/skills/writing-specs/SKILL.md +347 -0
@@ -4,20 +4,46 @@
4
4
  * Provides context for starting/resuming work sessions.
5
5
  */
6
6
  import chalk from "chalk";
7
- import { getReadyTasks, initContext, loadAllItems, loadAllTasks, loadInboxItems, loadSessionContext, ReferenceIndex, } from "../../parser/index.js";
7
+ import { getReadyTasks, initContext, loadAllItems, loadAllTasks, loadInboxItems, loadSessionContext, loadTriageRecords, ReferenceIndex, } from "../../parser/index.js";
8
8
  import { ShadowError, shadowPull, } from "../../parser/shadow.js";
9
- import { getAllSessionLogSummaries, getSessionLogDetail, resolveSessionId, readEvents, readSessionContext, computeSessionLogStats, computeToolUsageStats, computeTimePeriodStats, searchSessionEvents, } from "../../sessions/store.js";
9
+ import { loadMetaContext } from "../../parser/meta.js";
10
+ import { ulid } from "ulid";
11
+ import { getAllSessionLogSummaries, getSessionLogDetail, resolveSessionId, readEvents, readSessionContext, computeSessionLogStats, computeToolUsageStats, computeTimePeriodStats, searchSessionEvents, deduplicatePhasedToolCalls, createSessionWithBudget, validateSessionId, injectClaudeCodeEnv, injectCodexEnv, injectGeminiEnv, injectOpenCodeEnv, getFallbackInjectionInstructions, } from "../../sessions/store.js";
10
12
  import { errors, hints, sessionHeaders, sessionPrompt, } from "../../strings/index.js";
11
13
  import { formatCommitGuidance, formatRelativeTime, getCurrentBranch, getRecentCommits, getWorkingTreeStatus, isGitRepo, parseTimeSpec, } from "../../utils/index.js";
14
+ import { markMutating } from "../command-annotations.js";
12
15
  import { EXIT_CODES } from "../exit-codes.js";
13
- import { error, isJsonMode, isNoteSuperseded, output } from "../output.js";
16
+ import { error, info, isJsonMode, isNoteSuperseded, output, success, warn } from "../output.js";
14
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
+ }
15
38
  function toActiveTaskSummary(task, index) {
16
39
  const lastNote = task.notes.length > 0 ? task.notes[task.notes.length - 1] : null;
17
40
  const incompleteTodos = task.todos.filter((t) => !t.done).length;
18
41
  return {
19
42
  ref: index.shortUlid(task._ulid),
43
+ slug: task.slugs.length > 0 ? task.slugs[0] : null,
20
44
  title: task.title,
45
+ description: task.description || null,
46
+ status: task.status,
21
47
  started_at: task.started_at || null,
22
48
  priority: task.priority,
23
49
  spec_ref: task.spec_ref || null,
@@ -27,16 +53,19 @@ function toActiveTaskSummary(task, index) {
27
53
  incomplete_todos: incompleteTodos,
28
54
  };
29
55
  }
30
- function toReadyTaskSummary(task, index) {
56
+ function toReadyTaskSummary(task, index, unlocksMap) {
31
57
  return {
32
58
  ref: index.shortUlid(task._ulid),
59
+ slug: task.slugs.length > 0 ? task.slugs[0] : null,
33
60
  title: task.title,
61
+ description: task.description || null,
34
62
  priority: task.priority,
35
63
  spec_ref: task.spec_ref || null,
36
64
  tags: task.tags,
65
+ unlocks: unlocksMap.get(task._ulid) || 0,
37
66
  };
38
67
  }
39
- function toBlockedTaskSummary(task, _allTasks, index) {
68
+ function toBlockedTaskSummary(task, _allTasks, index, unlocksMap) {
40
69
  // Find unmet dependencies
41
70
  const unmetDeps = [];
42
71
  for (const depRef of task.depends_on) {
@@ -50,14 +79,18 @@ function toBlockedTaskSummary(task, _allTasks, index) {
50
79
  }
51
80
  return {
52
81
  ref: index.shortUlid(task._ulid),
82
+ slug: task.slugs.length > 0 ? task.slugs[0] : null,
53
83
  title: task.title,
84
+ description: task.description || null,
54
85
  blocked_by: task.blocked_by,
55
86
  unmet_deps: unmetDeps,
87
+ unlocks: unlocksMap.get(task._ulid) || 0,
56
88
  };
57
89
  }
58
90
  function toCompletedTaskSummary(task, index) {
59
91
  return {
60
92
  ref: index.shortUlid(task._ulid),
93
+ slug: task.slugs.length > 0 ? task.slugs[0] : null,
61
94
  title: task.title,
62
95
  completed_at: task.completed_at || "",
63
96
  closed_reason: task.closed_reason || null,
@@ -120,6 +153,80 @@ function collectIncompleteTodos(tasks, index, options) {
120
153
  .sort((a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime())
121
154
  .slice(0, options.limit);
122
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
+ }
123
230
  /**
124
231
  * Gather session context data
125
232
  */
@@ -131,7 +238,14 @@ export async function gatherSessionContext(ctx, options) {
131
238
  const allTasks = await loadAllTasks(ctx);
132
239
  const items = await loadAllItems(ctx);
133
240
  const inboxItems = await loadInboxItems(ctx);
241
+ const triageRecords = await loadTriageRecords(ctx);
134
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
+ }
135
249
  // Compute stats
136
250
  const stats = {
137
251
  total_tasks: allTasks.length,
@@ -178,16 +292,21 @@ export async function gatherSessionContext(ctx, options) {
178
292
  .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
179
293
  // Get incomplete todos from active tasks
180
294
  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)
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);
182
301
  const readyTasks = getReadyTasks(allTasks)
183
302
  .filter((t) => !options.eligible || t.automation === "eligible")
184
- .slice(0, options.full ? undefined : limit)
185
- .map((t) => toReadyTaskSummary(t, index));
303
+ .slice(0, readyLimit)
304
+ .map((t) => toReadyTaskSummary(t, index, unlocksMap));
186
305
  // Get blocked tasks
187
306
  const blockedTasks = allTasks
188
307
  .filter((t) => t.status === "blocked")
189
308
  .slice(0, options.full ? undefined : limit)
190
- .map((t) => toBlockedTaskSummary(t, allTasks, index));
309
+ .map((t) => toBlockedTaskSummary(t, allTasks, index, unlocksMap));
191
310
  // Get recently completed tasks
192
311
  const recentlyCompleted = allTasks
193
312
  .filter((t) => {
@@ -223,22 +342,93 @@ export async function gatherSessionContext(ctx, options) {
223
342
  date: c.date.toISOString(),
224
343
  message: c.message,
225
344
  author: c.author,
345
+ task_refs: c.taskRefs,
226
346
  }));
227
347
  workingTree = getWorkingTreeStatus(ctx.rootDir);
228
348
  }
229
- // Get inbox items (oldest first to encourage triage)
230
- const inboxSummaries = inboxItems
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
231
352
  .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
- }));
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;
240
375
  // Load session context (focus, threads, questions)
241
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
+ };
242
432
  return {
243
433
  generated_at: new Date().toISOString(),
244
434
  branch,
@@ -251,9 +441,13 @@ export async function gatherSessionContext(ctx, options) {
251
441
  blocked_tasks: blockedTasks,
252
442
  recently_completed: recentlyCompleted,
253
443
  recent_commits: recentCommits,
444
+ activity_timeline: activityTimeline,
254
445
  working_tree: workingTree,
255
446
  inbox_items: inboxSummaries,
447
+ inbox_stats: inboxStats,
448
+ observations,
256
449
  stats,
450
+ computed,
257
451
  };
258
452
  }
259
453
  /**
@@ -262,10 +456,17 @@ export async function gatherSessionContext(ctx, options) {
262
456
  */
263
457
  export async function getIterationStats(ctx, since) {
264
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
265
462
  const completedSince = allTasks.filter((t) => {
266
- if (t.status !== "completed" || !t.completed_at)
267
- return false;
268
- return new Date(t.completed_at) >= since;
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;
269
470
  });
270
471
  const startedSince = allTasks.filter((t) => {
271
472
  if (!t.started_at)
@@ -427,7 +628,10 @@ function formatCheckpointResult(result) {
427
628
  }
428
629
  }
429
630
  function formatSessionContext(ctx, options) {
430
- const isBrief = !options.full;
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}`;
431
635
  // Header
432
636
  console.log(`\n${sessionHeaders.title}`);
433
637
  const age = formatRelativeTime(new Date(ctx.generated_at));
@@ -466,9 +670,15 @@ function formatSessionContext(ctx, options) {
466
670
  }
467
671
  }
468
672
  }
469
- // Active tasks section
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
470
678
  if (ctx.active_tasks.length > 0) {
471
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");
472
682
  for (const task of ctx.active_tasks) {
473
683
  const started = task.started_at
474
684
  ? chalk.gray(` (started ${formatRelativeTime(new Date(task.started_at))})`)
@@ -476,106 +686,88 @@ function formatSessionContext(ctx, options) {
476
686
  const priority = task.priority <= 2
477
687
  ? chalk.red(`P${task.priority}`)
478
688
  : chalk.gray(`P${task.priority}`);
479
- console.log(` ${chalk.blue("[in_progress]")} ${priority} ${task.ref} ${task.title}${started}`);
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
+ }
480
720
  }
481
721
  }
482
- else {
483
- console.log(`\n${sessionHeaders.noActiveWork}`);
484
- }
485
- // Awaiting review section
722
+ // ── Awaiting review section ──
723
+ // AC: @cmd-session-start ac-review-detail
486
724
  if (ctx.pending_review_tasks.length > 0) {
487
725
  console.log(`\n${sessionHeaders.awaitingReview}`);
726
+ const reviewNotes = ctx.recent_notes.filter((n) => n.task_status === "pending_review");
488
727
  for (const task of ctx.pending_review_tasks) {
489
728
  const priority = task.priority <= 2
490
729
  ? chalk.red(`P${task.priority}`)
491
730
  : 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);
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
+ }
513
753
  }
514
754
  }
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
755
  }
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);
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(", ")}`));
561
764
  }
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);
765
+ if (task.unmet_deps.length > 0) {
766
+ console.log(chalk.gray(` Waiting on: ${task.unmet_deps.join(", ")}`));
568
767
  }
569
768
  }
570
769
  }
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
770
+ // ── Ready tasks section ──
579
771
  if (ctx.ready_tasks.length > 0) {
580
772
  console.log(`\n${sessionHeaders.readyTasks}`);
581
773
  for (const task of ctx.ready_tasks) {
@@ -583,48 +775,159 @@ function formatSessionContext(ctx, options) {
583
775
  ? chalk.red(`P${task.priority}`)
584
776
  : chalk.gray(`P${task.priority}`);
585
777
  const tags = task.tags.length > 0 ? chalk.cyan(` #${task.tags.join(" #")}`) : "";
586
- console.log(` ${priority} ${task.ref} ${task.title}${tags}`);
778
+ const unlocks = task.unlocks > 0 ? chalk.green(` unlocks ${task.unlocks}`) : "";
779
+ console.log(` ${priority} ${displayRef(task)} ${task.title}${unlocks}${tags}`);
587
780
  }
588
781
  }
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(", ")}`));
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
+ }
596
802
  }
597
- if (task.unmet_deps.length > 0) {
598
- console.log(chalk.gray(` Waiting on: ${task.unmet_deps.join(", ")}`));
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 });
599
808
  }
600
809
  }
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})`)}`);
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`));
608
874
  }
609
875
  }
610
- // Inbox section (oldest first to encourage triage)
611
- if (ctx.inbox_items.length > 0) {
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) {
612
879
  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()}...`;
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}`);
621
903
  }
622
- console.log(` ${chalk.magenta(item.ref)} ${chalk.gray(`(${age}${author})`)}${tags}`);
623
- console.log(` ${text}`);
624
904
  }
625
- console.log(` ${hints.inboxPromote}`);
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))}`));
626
928
  }
627
- // Working tree section
929
+ // ── Working tree section ──
930
+ // AC: @cmd-session-start ac-dirty-tree-only — only shown when dirty
628
931
  if (ctx.working_tree && !ctx.working_tree.clean) {
629
932
  console.log(`\n${sessionHeaders.workingTree}`);
630
933
  if (ctx.working_tree.staged.length > 0) {
@@ -641,32 +944,30 @@ function formatSessionContext(ctx, options) {
641
944
  }
642
945
  if (ctx.working_tree.untracked.length > 0) {
643
946
  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}`);
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}`);
647
952
  }
648
- if (isBrief && ctx.working_tree.untracked.length > limit) {
649
- console.log(chalk.gray(` ... and ${ctx.working_tree.untracked.length - limit} more`));
953
+ if (!isFull && ctx.working_tree.untracked.length > untrackedLimit) {
954
+ console.log(chalk.gray(` ... and ${ctx.working_tree.untracked.length - untrackedLimit} more`));
650
955
  }
651
956
  }
652
957
  }
653
- else if (ctx.working_tree?.clean) {
654
- console.log(`\n${sessionHeaders.workingTreeClean}`);
655
- }
656
- // Quick Commands section - contextual hints based on state
958
+ // ── Quick Commands section ──
657
959
  const quickCommands = [];
658
960
  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")}`);
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")}`);
662
964
  }
663
965
  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")}`);
966
+ const ref = displayRef(ctx.ready_tasks[0]);
967
+ quickCommands.push(`kspec task start ${ref} ${chalk.gray("# begin work")}`);
666
968
  }
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")}`);
969
+ if (ctx.inbox_stats.untriaged > 0) {
970
+ quickCommands.push(`kspec triage inbox ${chalk.gray("# triage untriaged inbox items")}`);
670
971
  }
671
972
  if (ctx.working_tree && !ctx.working_tree.clean) {
672
973
  quickCommands.push(`git add . && git commit -m "..." ${chalk.gray("# commit changes")}`);
@@ -689,12 +990,11 @@ async function sessionStartAction(options) {
689
990
  syncResult = await shadowPull(ctx.shadow.worktreeDir);
690
991
  // AC: @shadow-sync ac-3 - Warn about conflicts but continue with local state
691
992
  if (syncResult.hadConflict) {
692
- console.log(chalk.yellow("Shadow sync conflict detected. Run `kspec shadow resolve` to fix."));
693
- console.log(chalk.gray(" Continuing with local state..."));
694
- console.log("");
993
+ warn("Shadow sync conflict detected. Run `kspec shadow resolve` to fix.");
994
+ info("Continuing with local state...");
695
995
  }
696
996
  else if (syncResult.pulled) {
697
- console.log(chalk.gray("Synced shadow branch from remote"));
997
+ info("Synced shadow branch from remote");
698
998
  }
699
999
  }
700
1000
  const sessionCtx = await gatherSessionContext(ctx, options);
@@ -1095,7 +1395,7 @@ async function sessionLogShowAction(sessionRef, options) {
1095
1395
  // AC: @session-log-show ac-3, ac-4, ac-5 - Event timeline
1096
1396
  let events = null;
1097
1397
  if (options.events) {
1098
- let allEvents = await readEvents(ctx.specDir, sessionId);
1398
+ let allEvents = deduplicatePhasedToolCalls(await readEvents(ctx.specDir, sessionId));
1099
1399
  // AC: @session-log-show ac-4 - Filter by type
1100
1400
  if (options.type) {
1101
1401
  const typeFilter = options.type;
@@ -1350,6 +1650,143 @@ async function sessionLogSearchAction(pattern, options) {
1350
1650
  process.exit(EXIT_CODES.ERROR);
1351
1651
  }
1352
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
+ }
1353
1790
  /**
1354
1791
  * Register the 'session' command group and aliases
1355
1792
  */
@@ -1357,6 +1794,14 @@ export function registerSessionCommands(program) {
1357
1794
  const session = program
1358
1795
  .command("session")
1359
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);
1360
1805
  session
1361
1806
  .command("start")
1362
1807
  .alias("resume")