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.
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/autofix.md +12 -0
- package/.claude/commands/tlc/build.md +35 -4
- package/.claude/commands/tlc/cleanup.md +13 -1
- package/.claude/commands/tlc/coverage.md +12 -0
- package/.claude/commands/tlc/discuss.md +12 -0
- package/.claude/commands/tlc/docs.md +13 -1
- package/.claude/commands/tlc/edge-cases.md +13 -1
- package/.claude/commands/tlc/plan.md +32 -6
- package/.claude/commands/tlc/preflight.md +12 -0
- package/.claude/commands/tlc/progress.md +41 -15
- package/.claude/commands/tlc/refactor.md +12 -0
- package/.claude/commands/tlc/review-pr.md +32 -11
- package/.claude/commands/tlc/review.md +12 -0
- package/.claude/commands/tlc/security.md +13 -1
- package/.claude/commands/tlc/status.md +42 -3
- package/.claude/commands/tlc/tlc.md +32 -16
- package/.claude/commands/tlc/verify.md +12 -0
- package/.claude/commands/tlc/watchci.md +12 -0
- package/package.json +1 -1
- package/scripts/renumber-phases.js +283 -0
- package/scripts/renumber-phases.test.js +305 -0
- package/server/lib/orchestration/completion-checker.js +52 -2
- package/server/lib/orchestration/completion-checker.test.js +64 -0
- package/server/lib/orchestration/session-status.js +28 -4
- package/server/lib/orchestration/session-status.test.js +44 -1
- package/server/lib/orchestration/skill-dispatcher.js +270 -0
- package/server/lib/orchestration/skill-dispatcher.test.js +449 -0
- package/server/lib/workspace-manifest.js +138 -0
- 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 (
|
|
83
|
+
if (status === 'completed') {
|
|
56
84
|
completions.push(session);
|
|
57
85
|
continue;
|
|
58
86
|
}
|
|
59
87
|
|
|
60
|
-
if (
|
|
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
|
|
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
|
|
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
|
};
|