tlc-claude-code 2.7.0 → 2.9.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 (30) hide show
  1. package/.claude/commands/tlc/audit.md +12 -0
  2. package/.claude/commands/tlc/autofix.md +12 -0
  3. package/.claude/commands/tlc/build.md +35 -4
  4. package/.claude/commands/tlc/cleanup.md +13 -1
  5. package/.claude/commands/tlc/coverage.md +12 -0
  6. package/.claude/commands/tlc/discuss.md +12 -0
  7. package/.claude/commands/tlc/docs.md +13 -1
  8. package/.claude/commands/tlc/edge-cases.md +13 -1
  9. package/.claude/commands/tlc/plan.md +32 -6
  10. package/.claude/commands/tlc/preflight.md +12 -0
  11. package/.claude/commands/tlc/progress.md +41 -15
  12. package/.claude/commands/tlc/refactor.md +12 -0
  13. package/.claude/commands/tlc/review-pr.md +32 -11
  14. package/.claude/commands/tlc/review.md +12 -0
  15. package/.claude/commands/tlc/security.md +13 -1
  16. package/.claude/commands/tlc/status.md +42 -3
  17. package/.claude/commands/tlc/tlc.md +32 -16
  18. package/.claude/commands/tlc/verify.md +12 -0
  19. package/.claude/commands/tlc/watchci.md +12 -0
  20. package/package.json +1 -1
  21. package/scripts/renumber-phases.js +283 -0
  22. package/scripts/renumber-phases.test.js +305 -0
  23. package/server/lib/orchestration/completion-checker.js +52 -2
  24. package/server/lib/orchestration/completion-checker.test.js +64 -0
  25. package/server/lib/orchestration/session-status.js +28 -4
  26. package/server/lib/orchestration/session-status.test.js +44 -1
  27. package/server/lib/orchestration/skill-dispatcher.js +270 -0
  28. package/server/lib/orchestration/skill-dispatcher.test.js +449 -0
  29. package/server/lib/workspace-manifest.js +138 -0
  30. package/server/lib/workspace-manifest.test.js +179 -0
@@ -0,0 +1,305 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { spawnSync } from 'child_process';
5
+
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ import renumberModule from './renumber-phases.js';
9
+
10
+ const { main, parseArgs, renumberPhases, updatePhaseReferences } = renumberModule;
11
+
12
+ const SCRIPT_PATH = path.join(process.cwd(), 'scripts', 'renumber-phases.js');
13
+ const tempDirs = [];
14
+
15
+ function createTempRepo(structure = {}) {
16
+ const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'renumber-phases-'));
17
+ tempDirs.push(repoPath);
18
+
19
+ for (const [relativePath, content] of Object.entries(structure)) {
20
+ const filePath = path.join(repoPath, relativePath);
21
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
22
+ fs.writeFileSync(filePath, content);
23
+ }
24
+
25
+ return repoPath;
26
+ }
27
+
28
+ function readFile(repoPath, relativePath) {
29
+ return fs.readFileSync(path.join(repoPath, relativePath), 'utf8');
30
+ }
31
+
32
+ function fileExists(repoPath, relativePath) {
33
+ return fs.existsSync(path.join(repoPath, relativePath));
34
+ }
35
+
36
+ function createPlanningRepo() {
37
+ return createTempRepo({
38
+ '.planning/ROADMAP.md': [
39
+ '# Roadmap',
40
+ '### Phase 108: Build the tool',
41
+ 'Depends on Phase 107 and Phase 101b.',
42
+ 'Already migrated: Phase TLC-999.',
43
+ '',
44
+ ].join('\n'),
45
+ '.planning/phases/108-PLAN.md': [
46
+ '# Phase 108',
47
+ 'Depends on Phase 107.',
48
+ 'Review Phase 101b before release.',
49
+ 'Already references Phase TLC-105 correctly.',
50
+ '',
51
+ ].join('\n'),
52
+ '.planning/phases/108-DISCUSSION.md': 'Phase 108 requires context from Phase 107.\n',
53
+ '.planning/phases/101b-RESEARCH.md': 'Research for Phase 101b.\n',
54
+ '.planning/phases/108-PLAN.md.superseded': 'Superseded Phase 108 draft.\n',
55
+ '.planning/phases/TLC-105-PLAN.md': 'Already migrated.\n',
56
+ '.planning/phases/77-OldPLAN.md': 'Should be ignored.\n',
57
+ '.planning/phases/77-PLAN.md.md': 'Should be ignored.\n',
58
+ '.planning/phases/notes.txt': 'Should be ignored.\n',
59
+ });
60
+ }
61
+
62
+ afterEach(() => {
63
+ vi.restoreAllMocks();
64
+
65
+ while (tempDirs.length > 0) {
66
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ describe('updatePhaseReferences', () => {
71
+ it('adds the prefix to bare phase references and leaves prefixed ones unchanged', () => {
72
+ const source = 'Phase 108 follows Phase 101b. Phase TLC-42 stays as-is.';
73
+
74
+ const result = updatePhaseReferences(source, 'TLC');
75
+
76
+ expect(result.content).toBe(
77
+ 'Phase TLC-108 follows Phase TLC-101b. Phase TLC-42 stays as-is.'
78
+ );
79
+ expect(result.replacements).toBe(2);
80
+ });
81
+ });
82
+
83
+ describe('parseArgs', () => {
84
+ it('parses repo path, prefix, and dry-run', () => {
85
+ expect(
86
+ parseArgs(['--repo-path', '/tmp/repo', '--prefix', 'TLC', '--dry-run'])
87
+ ).toEqual({
88
+ dryRun: true,
89
+ repoPath: '/tmp/repo',
90
+ prefix: 'TLC',
91
+ });
92
+ });
93
+
94
+ it('throws when required arguments are missing', () => {
95
+ expect(() => parseArgs(['--prefix', 'TLC'])).toThrow('Missing required arguments');
96
+ expect(() => parseArgs(['--repo-path', '/tmp/repo'])).toThrow('Missing required arguments');
97
+ });
98
+ });
99
+
100
+ describe('renumberPhases', () => {
101
+ it('renames matching files, updates roadmap references, and preserves ignored files', () => {
102
+ const repoPath = createPlanningRepo();
103
+ const stdout = vi.spyOn(console, 'log').mockImplementation(() => {});
104
+ const stderr = vi.spyOn(console, 'error').mockImplementation(() => {});
105
+
106
+ const summary = renumberPhases({
107
+ repoPath,
108
+ prefix: 'TLC',
109
+ });
110
+
111
+ expect(summary).toEqual({
112
+ renamed: 4,
113
+ updated: 10,
114
+ errors: 0,
115
+ });
116
+
117
+ expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md')).toBe(true);
118
+ expect(fileExists(repoPath, '.planning/phases/TLC-108-DISCUSSION.md')).toBe(true);
119
+ expect(fileExists(repoPath, '.planning/phases/TLC-101b-RESEARCH.md')).toBe(true);
120
+ expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md.superseded')).toBe(true);
121
+
122
+ expect(fileExists(repoPath, '.planning/phases/108-PLAN.md')).toBe(false);
123
+ expect(fileExists(repoPath, '.planning/phases/108-DISCUSSION.md')).toBe(false);
124
+ expect(fileExists(repoPath, '.planning/phases/101b-RESEARCH.md')).toBe(false);
125
+ expect(fileExists(repoPath, '.planning/phases/108-PLAN.md.superseded')).toBe(false);
126
+
127
+ expect(fileExists(repoPath, '.planning/phases/TLC-105-PLAN.md')).toBe(true);
128
+ expect(fileExists(repoPath, '.planning/phases/77-OldPLAN.md')).toBe(true);
129
+ expect(fileExists(repoPath, '.planning/phases/77-PLAN.md.md')).toBe(true);
130
+
131
+ expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('### Phase TLC-108: Build the tool');
132
+ expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('Depends on Phase TLC-107 and Phase TLC-101b.');
133
+ expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('Already migrated: Phase TLC-999.');
134
+
135
+ expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md')).toContain('Depends on Phase TLC-107.');
136
+ expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md')).toContain(
137
+ 'Review Phase TLC-101b before release.'
138
+ );
139
+ expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md')).toContain(
140
+ 'Already references Phase TLC-105 correctly.'
141
+ );
142
+ expect(readFile(repoPath, '.planning/phases/TLC-108-DISCUSSION.md')).toContain(
143
+ 'Phase TLC-108 requires context from Phase TLC-107.'
144
+ );
145
+ expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md.superseded')).toContain(
146
+ 'Superseded Phase TLC-108 draft.'
147
+ );
148
+
149
+ expect(stdout).toHaveBeenLastCalledWith('Renamed: 4 files, Updated: 10 references, Errors: 0');
150
+ expect(stderr).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('supports dry-run without modifying files', () => {
154
+ const repoPath = createPlanningRepo();
155
+ const originalRoadmap = readFile(repoPath, '.planning/ROADMAP.md');
156
+ const originalPlan = readFile(repoPath, '.planning/phases/108-PLAN.md');
157
+ const stdout = vi.spyOn(console, 'log').mockImplementation(() => {});
158
+
159
+ const summary = renumberPhases({
160
+ repoPath,
161
+ prefix: 'TLC',
162
+ dryRun: true,
163
+ });
164
+
165
+ expect(summary).toEqual({
166
+ renamed: 4,
167
+ updated: 10,
168
+ errors: 0,
169
+ });
170
+
171
+ expect(fileExists(repoPath, '.planning/phases/108-PLAN.md')).toBe(true);
172
+ expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md')).toBe(false);
173
+ expect(readFile(repoPath, '.planning/ROADMAP.md')).toBe(originalRoadmap);
174
+ expect(readFile(repoPath, '.planning/phases/108-PLAN.md')).toBe(originalPlan);
175
+
176
+ expect(stdout.mock.calls).toEqual(
177
+ expect.arrayContaining([
178
+ [expect.stringContaining('DRY-RUN rename')],
179
+ [expect.stringContaining('DRY-RUN update')],
180
+ ['Renamed: 4 files, Updated: 10 references, Errors: 0'],
181
+ ])
182
+ );
183
+ });
184
+
185
+ it('is idempotent when run again on already-prefixed files', () => {
186
+ const repoPath = createPlanningRepo();
187
+ vi.spyOn(console, 'log').mockImplementation(() => {});
188
+ vi.spyOn(console, 'error').mockImplementation(() => {});
189
+
190
+ const firstSummary = renumberPhases({
191
+ repoPath,
192
+ prefix: 'TLC',
193
+ });
194
+
195
+ const secondSummary = renumberPhases({
196
+ repoPath,
197
+ prefix: 'TLC',
198
+ });
199
+
200
+ expect(firstSummary).toEqual({
201
+ renamed: 4,
202
+ updated: 10,
203
+ errors: 0,
204
+ });
205
+ expect(secondSummary).toEqual({
206
+ renamed: 0,
207
+ updated: 0,
208
+ errors: 0,
209
+ });
210
+
211
+ expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md')).toBe(true);
212
+ expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('### Phase TLC-108: Build the tool');
213
+ });
214
+
215
+ it('reports rename collisions as errors without overwriting files', () => {
216
+ const repoPath = createTempRepo({
217
+ '.planning/ROADMAP.md': '### Phase 108: Collision case\nPhase 108 only.\n',
218
+ '.planning/phases/108-PLAN.md': 'Phase 108.\n',
219
+ '.planning/phases/TLC-108-PLAN.md': 'Existing target.\n',
220
+ });
221
+ const stdout = vi.spyOn(console, 'log').mockImplementation(() => {});
222
+ const stderr = vi.spyOn(console, 'error').mockImplementation(() => {});
223
+
224
+ const summary = renumberPhases({
225
+ repoPath,
226
+ prefix: 'TLC',
227
+ dryRun: true,
228
+ });
229
+
230
+ expect(summary).toEqual({
231
+ renamed: 1,
232
+ updated: 3,
233
+ errors: 1,
234
+ });
235
+ expect(stderr).toHaveBeenCalledWith(
236
+ 'Cannot rename 108-PLAN.md because TLC-108-PLAN.md already exists'
237
+ );
238
+ expect(stdout).toHaveBeenLastCalledWith('Renamed: 1 files, Updated: 3 references, Errors: 1');
239
+ });
240
+
241
+ it('throws when the phases directory is missing', () => {
242
+ const repoPath = createTempRepo({
243
+ '.planning/ROADMAP.md': '### Phase 1: Missing phases dir\n',
244
+ });
245
+
246
+ expect(() =>
247
+ renumberPhases({
248
+ repoPath,
249
+ prefix: 'TLC',
250
+ })
251
+ ).toThrow(`Missing phases directory: ${path.join(repoPath, '.planning', 'phases')}`);
252
+ });
253
+ });
254
+
255
+ describe('main', () => {
256
+ it('returns 1 and prints usage for missing repo-path', () => {
257
+ const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
258
+ const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
259
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
260
+
261
+ const exitCode = main(['--prefix', 'TLC']);
262
+
263
+ expect(exitCode).toBe(1);
264
+ expect(stdout).not.toHaveBeenCalled();
265
+ expect(stderr).toHaveBeenCalledWith(
266
+ 'Usage: node scripts/renumber-phases.js --repo-path <path> --prefix <PREFIX> [--dry-run]\n'
267
+ );
268
+ expect(consoleError).toHaveBeenCalledWith('Missing required arguments');
269
+ });
270
+
271
+ it('returns 1 when the phases directory does not exist', () => {
272
+ const repoPath = createTempRepo({
273
+ '.planning/ROADMAP.md': '### Phase 1: Missing phases dir\n',
274
+ });
275
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
276
+
277
+ const exitCode = main(['--repo-path', repoPath, '--prefix', 'TLC']);
278
+
279
+ expect(exitCode).toBe(1);
280
+ expect(consoleError).toHaveBeenCalledWith(
281
+ `Missing phases directory: ${path.join(repoPath, '.planning', 'phases')}`
282
+ );
283
+ });
284
+
285
+ it('works from the real CLI entry point', () => {
286
+ const repoPath = createTempRepo({
287
+ '.planning/ROADMAP.md': '### Phase 12: CLI check\nPhase 12 is ready.\n',
288
+ '.planning/phases/12-PLAN.md': 'Phase 12 is ready.\n',
289
+ });
290
+
291
+ const result = spawnSync(
292
+ process.execPath,
293
+ [SCRIPT_PATH, '--repo-path', repoPath, '--prefix', 'TLC'],
294
+ {
295
+ encoding: 'utf8',
296
+ }
297
+ );
298
+
299
+ expect(result.status).toBe(0);
300
+ expect(result.stderr).toBe('');
301
+ expect(result.stdout).toContain('Renamed: 1 files, Updated: 3 references, Errors: 0');
302
+ expect(fileExists(repoPath, '.planning/phases/TLC-12-PLAN.md')).toBe(true);
303
+ expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('### Phase TLC-12: CLI check');
304
+ });
305
+ });
@@ -1,5 +1,17 @@
1
1
  const fs = require('fs/promises');
2
2
 
3
+ const ACTIONABLE_MONITOR_STATES = new Map([
4
+ ['stuck', { suggestedAction: 'retry' }],
5
+ ['budget_exhausted', { suggestedAction: 'retry' }],
6
+ ]);
7
+
8
+ const FAILURE_MONITOR_STATES = new Map([
9
+ ['crashed', 'session crashed and needs investigation'],
10
+ ['errored', 'session errored and needs investigation'],
11
+ ['loop_detected', 'session entered a loop and needs investigation'],
12
+ ['timed_out', 'session timed out and needs investigation'],
13
+ ]);
14
+
3
15
  async function readActiveSessions(activeSessionsPath) {
4
16
  try {
5
17
  const raw = await fs.readFile(activeSessionsPath, 'utf8');
@@ -18,6 +30,19 @@ async function writeActiveSessions(activeSessionsPath, sessions) {
18
30
  await fs.writeFile(activeSessionsPath, JSON.stringify(sessions, null, 2));
19
31
  }
20
32
 
33
+ function getNormalizedStatus(payload) {
34
+ if (!payload || typeof payload !== 'object') {
35
+ return null;
36
+ }
37
+
38
+ const monitorState = payload.monitorState
39
+ || payload.monitor?.state
40
+ || payload.metadata?.monitorState
41
+ || null;
42
+
43
+ return String(monitorState || payload.status || '').trim().toLowerCase() || null;
44
+ }
45
+
21
46
  async function checkCompletions({
22
47
  activeSessionsPath,
23
48
  orchestratorUrl,
@@ -28,12 +53,14 @@ async function checkCompletions({
28
53
  return {
29
54
  completions: [],
30
55
  failures: [],
56
+ actionable: [],
31
57
  stillRunning: 0,
32
58
  };
33
59
  }
34
60
 
35
61
  const completions = [];
36
62
  const failures = [];
63
+ const actionable = [];
37
64
  const remainingSessions = [];
38
65
 
39
66
  let orchestratorReachable = true;
@@ -51,13 +78,14 @@ async function checkCompletions({
51
78
  }
52
79
 
53
80
  const payload = await response.json();
81
+ const status = getNormalizedStatus(payload);
54
82
 
55
- if (payload.status === 'completed') {
83
+ if (status === 'completed') {
56
84
  completions.push(session);
57
85
  continue;
58
86
  }
59
87
 
60
- if (payload.status === 'failed') {
88
+ if (status === 'failed') {
61
89
  failures.push({
62
90
  ...session,
63
91
  ...(payload.reason ? { reason: payload.reason } : {}),
@@ -65,6 +93,26 @@ async function checkCompletions({
65
93
  continue;
66
94
  }
67
95
 
96
+ if (ACTIONABLE_MONITOR_STATES.has(status)) {
97
+ actionable.push({
98
+ ...session,
99
+ status,
100
+ actionable: true,
101
+ suggestedAction: ACTIONABLE_MONITOR_STATES.get(status).suggestedAction,
102
+ });
103
+ continue;
104
+ }
105
+
106
+ if (FAILURE_MONITOR_STATES.has(status)) {
107
+ failures.push({
108
+ ...session,
109
+ status,
110
+ actionable: false,
111
+ failureReason: FAILURE_MONITOR_STATES.get(status),
112
+ });
113
+ continue;
114
+ }
115
+
68
116
  remainingSessions.push(session);
69
117
  } catch (error) {
70
118
  if (!orchestratorReachable) {
@@ -82,6 +130,7 @@ async function checkCompletions({
82
130
  return {
83
131
  completions: [],
84
132
  failures: [],
133
+ actionable: [],
85
134
  stillRunning: -1,
86
135
  error: 'orchestrator unreachable',
87
136
  };
@@ -92,6 +141,7 @@ async function checkCompletions({
92
141
  return {
93
142
  completions,
94
143
  failures,
144
+ actionable,
95
145
  stillRunning: remainingSessions.length,
96
146
  };
97
147
  }
@@ -49,6 +49,7 @@ describe('completion-checker', () => {
49
49
  expect(result).toEqual({
50
50
  completions: [],
51
51
  failures: [],
52
+ actionable: [],
52
53
  stillRunning: 0,
53
54
  });
54
55
  expect(fetch).not.toHaveBeenCalled();
@@ -74,6 +75,7 @@ describe('completion-checker', () => {
74
75
  expect(result).toEqual({
75
76
  completions: [makeSession()],
76
77
  failures: [],
78
+ actionable: [],
77
79
  stillRunning: 0,
78
80
  });
79
81
  expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
@@ -97,6 +99,7 @@ describe('completion-checker', () => {
97
99
  expect(result).toEqual({
98
100
  completions: [],
99
101
  failures: [{ ...makeSession(), reason: 'Tests failed' }],
102
+ actionable: [],
100
103
  stillRunning: 0,
101
104
  });
102
105
  expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
@@ -131,11 +134,70 @@ describe('completion-checker', () => {
131
134
  expect(result).toEqual({
132
135
  completions: [completed],
133
136
  failures: [{ ...failed, reason: 'Rejected' }],
137
+ actionable: [],
134
138
  stillRunning: 1,
135
139
  });
136
140
  expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([running]);
137
141
  });
138
142
 
143
+ it.each([
144
+ ['stuck', { status: 'stuck', actionable: true, suggestedAction: 'retry' }],
145
+ ['budget_exhausted', { status: 'budget_exhausted', actionable: true, suggestedAction: 'retry' }],
146
+ ])('reports actionable monitor state %s', async (state, expected) => {
147
+ const activeSessionsPath = makeActiveSessionsPath();
148
+ const session = makeSession();
149
+ writeSessionsFile(activeSessionsPath, [session]);
150
+
151
+ const fetch = vi.fn().mockResolvedValue({
152
+ ok: true,
153
+ json: async () => ({ status: state }),
154
+ });
155
+
156
+ const result = await checkCompletions({
157
+ activeSessionsPath,
158
+ orchestratorUrl: 'http://orchestrator.test',
159
+ fetch,
160
+ });
161
+
162
+ expect(result).toEqual({
163
+ completions: [],
164
+ failures: [],
165
+ actionable: [{ ...session, ...expected }],
166
+ stillRunning: 0,
167
+ });
168
+ expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
169
+ });
170
+
171
+ it.each([
172
+ ['crashed', 'session crashed and needs investigation'],
173
+ ['errored', 'session errored and needs investigation'],
174
+ ['loop_detected', 'session entered a loop and needs investigation'],
175
+ ['timed_out', 'session timed out and needs investigation'],
176
+ ])('reports failure monitor state %s', async (state, failureReason) => {
177
+ const activeSessionsPath = makeActiveSessionsPath();
178
+ const session = makeSession();
179
+ writeSessionsFile(activeSessionsPath, [session]);
180
+
181
+ const fetch = vi.fn().mockResolvedValue({
182
+ ok: true,
183
+ json: async () => ({ status: state }),
184
+ });
185
+
186
+ const result = await checkCompletions({
187
+ activeSessionsPath,
188
+ orchestratorUrl: 'http://orchestrator.test',
189
+ fetch,
190
+ });
191
+
192
+ expect(result).toEqual({
193
+ completions: [],
194
+ failures: [{ ...session, status: state, actionable: false, failureReason }],
195
+ actionable: [],
196
+ stillRunning: 0,
197
+ });
198
+ expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
199
+ });
200
+
139
201
  it('returns an error result when the orchestrator is unreachable', async () => {
140
202
  const activeSessionsPath = makeActiveSessionsPath();
141
203
  const session = makeSession();
@@ -152,6 +214,7 @@ describe('completion-checker', () => {
152
214
  expect(result).toEqual({
153
215
  completions: [],
154
216
  failures: [],
217
+ actionable: [],
155
218
  stillRunning: -1,
156
219
  error: 'orchestrator unreachable',
157
220
  });
@@ -170,6 +233,7 @@ describe('completion-checker', () => {
170
233
  expect(result).toEqual({
171
234
  completions: [],
172
235
  failures: [],
236
+ actionable: [],
173
237
  stillRunning: 0,
174
238
  });
175
239
  expect(fs.existsSync(activeSessionsPath)).toBe(false);
@@ -49,6 +49,24 @@ function buildSummary(session) {
49
49
  return titleCaseStatus(session.status);
50
50
  }
51
51
 
52
+ function getSessionSkill(session) {
53
+ return session.metadata?.skill || session.skill || '-';
54
+ }
55
+
56
+ function getMonitorState(session) {
57
+ return session.monitorState || session.monitor?.state || session.metadata?.monitorState || '-';
58
+ }
59
+
60
+ function hasAlert(session) {
61
+ const monitorState = String(getMonitorState(session)).toLowerCase();
62
+ return ['stuck', 'errored', 'crashed'].includes(monitorState);
63
+ }
64
+
65
+ function formatSessionId(session) {
66
+ const id = session.id || '-';
67
+ return hasAlert(session) ? `[!] ${id}` : id;
68
+ }
69
+
52
70
  /**
53
71
  * Format rows into an aligned CLI table.
54
72
  * @param {string[]} headers
@@ -108,11 +126,13 @@ function formatSessionStatus({ sessions } = {}) {
108
126
  sections.push([
109
127
  'Running Sessions',
110
128
  formatTable(
111
- ['ID', 'Project', 'Status', 'Elapsed', 'Task'],
129
+ ['ID', 'Skill', 'Project', 'Status', 'Monitor', 'Elapsed', 'Task'],
112
130
  running.map((session) => [
113
- session.id || '-',
131
+ formatSessionId(session),
132
+ getSessionSkill(session),
114
133
  session.project || '-',
115
134
  session.status || '-',
135
+ getMonitorState(session),
116
136
  formatElapsed(session.created_at),
117
137
  session.command || '-',
118
138
  ])
@@ -125,11 +145,13 @@ function formatSessionStatus({ sessions } = {}) {
125
145
  sections.push([
126
146
  'Completed Sessions',
127
147
  formatTable(
128
- ['ID', 'Project', 'Status', 'Summary'],
148
+ ['ID', 'Skill', 'Project', 'Status', 'Monitor', 'Summary'],
129
149
  completed.map((session) => [
130
- session.id || '-',
150
+ formatSessionId(session),
151
+ getSessionSkill(session),
131
152
  session.project || '-',
132
153
  session.status || '-',
154
+ getMonitorState(session),
133
155
  buildSummary(session),
134
156
  ])
135
157
  ),
@@ -144,4 +166,6 @@ module.exports = {
144
166
  formatSessionStatus,
145
167
  formatElapsed,
146
168
  buildSummary,
169
+ getSessionSkill,
170
+ getMonitorState,
147
171
  };