create-claude-workspace 2.3.27 → 2.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/scheduler/agents/prompt-builder.mjs +67 -0
- package/dist/scheduler/agents/worker-pool.mjs +3 -2
- package/dist/scheduler/index.mjs +32 -1
- package/dist/scheduler/loop.mjs +93 -3
- package/dist/scheduler/tasks/issue-source.mjs +114 -3
- package/dist/scheduler/types.mjs +1 -0
- package/package.json +1 -1
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
// ─── Minimal prompt construction for each pipeline step ───
|
|
2
2
|
// No workflow instructions. Just task + context + expected output format.
|
|
3
|
+
// ─── Shared helpers ───
|
|
4
|
+
function descriptionBlock(task) {
|
|
5
|
+
if (!task.description)
|
|
6
|
+
return [];
|
|
7
|
+
return [``, `## Issue Description`, task.description];
|
|
8
|
+
}
|
|
9
|
+
// ─── Triage prompts ───
|
|
10
|
+
export function buildPOTriagePrompt(ctx) {
|
|
11
|
+
return [
|
|
12
|
+
`You are a product owner evaluating a feature request.`,
|
|
13
|
+
``,
|
|
14
|
+
`## Feature Request`,
|
|
15
|
+
`**${ctx.task.title}**`,
|
|
16
|
+
...descriptionBlock(ctx.task),
|
|
17
|
+
``,
|
|
18
|
+
`## Working Directory`,
|
|
19
|
+
ctx.worktreePath,
|
|
20
|
+
``,
|
|
21
|
+
`## Project Root`,
|
|
22
|
+
ctx.projectDir,
|
|
23
|
+
``,
|
|
24
|
+
`## Instructions`,
|
|
25
|
+
`Read PRODUCT.md (if available) and the codebase to understand the product context.`,
|
|
26
|
+
`Evaluate this feature request and respond with a structured analysis.`,
|
|
27
|
+
``,
|
|
28
|
+
`## Required Output Format`,
|
|
29
|
+
`Respond with JSON:`,
|
|
30
|
+
'```json',
|
|
31
|
+
`{`,
|
|
32
|
+
` "decision": "accept" | "reject",`,
|
|
33
|
+
` "risks": ["risk 1", "risk 2"],`,
|
|
34
|
+
` "scope": "Your refined scope description — what exactly should be implemented",`,
|
|
35
|
+
` "priority": "critical" | "high" | "medium" | "low",`,
|
|
36
|
+
` "reason": "Brief explanation of your decision",`,
|
|
37
|
+
` "suggestedType": "frontend" | "backend" | "fullstack",`,
|
|
38
|
+
` "suggestedComplexity": "S" | "M" | "L"`,
|
|
39
|
+
`}`,
|
|
40
|
+
'```',
|
|
41
|
+
``,
|
|
42
|
+
`## Decision Criteria`,
|
|
43
|
+
`- ACCEPT if: aligns with product vision, reasonable scope, clear value`,
|
|
44
|
+
`- REJECT if: out of scope, too risky, contradicts product direction, duplicate of existing functionality`,
|
|
45
|
+
`- When accepting, refine the scope to be specific and achievable`,
|
|
46
|
+
`- Estimate type and complexity based on what you see in the codebase`,
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
export function buildITAnalystTriagePrompt(ctx, poOutput) {
|
|
50
|
+
const kind = ctx.task.issueKind ?? 'feature';
|
|
51
|
+
const lines = [
|
|
52
|
+
`You are an IT analyst creating a detailed technical specification for a ${kind} issue.`,
|
|
53
|
+
``,
|
|
54
|
+
`## Issue`,
|
|
55
|
+
`**${ctx.task.title}**`,
|
|
56
|
+
...descriptionBlock(ctx.task),
|
|
57
|
+
];
|
|
58
|
+
if (poOutput) {
|
|
59
|
+
lines.push(``, `## Product Owner's Brief`, poOutput);
|
|
60
|
+
}
|
|
61
|
+
lines.push(``, `## Working Directory`, ctx.worktreePath, ``, `## Project Root`, ctx.projectDir, ``, `## Instructions`, `Read the codebase to understand existing patterns, architecture, and conventions.`, `Create a detailed specification for this ${kind}.`, ``, `## Required Output Format`, ``, `### Description`, `Clear explanation of what needs to be done and why.`, ``, `### Acceptance Criteria`, `- [ ] Testable criterion 1`, `- [ ] Testable criterion 2`, `- [ ] (at least 3 criteria)`, ``, `### Technical Notes`, `Architecture decisions, existing patterns to follow, reuse opportunities.`, ``, `### Edge Cases`, `- Boundary condition 1`, `- Boundary condition 2`, ``, `### Affected Components`, `List file paths and modules that will need changes.`, ``, `### Metadata`, `Respond with a JSON block at the end:`, '```json', `{`, ` "type": "frontend" | "backend" | "fullstack",`, ` "complexity": "S" | "M" | "L",`, ` "dependencies": ["#N", ...]`, `}`, '```');
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
3
64
|
// ─── Planning prompts ───
|
|
4
65
|
export function buildPlanPrompt(ctx) {
|
|
5
66
|
const lines = [
|
|
@@ -7,6 +68,7 @@ export function buildPlanPrompt(ctx) {
|
|
|
7
68
|
``,
|
|
8
69
|
`## Task`,
|
|
9
70
|
`**${ctx.task.title}** (${ctx.task.type}, Complexity: ${ctx.task.complexity})`,
|
|
71
|
+
...descriptionBlock(ctx.task),
|
|
10
72
|
``,
|
|
11
73
|
`## Working Directory`,
|
|
12
74
|
ctx.worktreePath,
|
|
@@ -30,6 +92,7 @@ export function buildImplementPrompt(ctx) {
|
|
|
30
92
|
``,
|
|
31
93
|
`## Task`,
|
|
32
94
|
`**${ctx.task.title}** (${ctx.task.type}, Complexity: ${ctx.task.complexity})`,
|
|
95
|
+
...descriptionBlock(ctx.task),
|
|
33
96
|
``,
|
|
34
97
|
`## Working Directory`,
|
|
35
98
|
ctx.worktreePath,
|
|
@@ -54,6 +117,7 @@ export function buildQAPrompt(ctx) {
|
|
|
54
117
|
``,
|
|
55
118
|
`## Task`,
|
|
56
119
|
`**${ctx.task.title}** (${ctx.task.type})`,
|
|
120
|
+
...descriptionBlock(ctx.task),
|
|
57
121
|
``,
|
|
58
122
|
`## Working Directory`,
|
|
59
123
|
ctx.worktreePath,
|
|
@@ -74,6 +138,7 @@ export function buildReviewPrompt(ctx) {
|
|
|
74
138
|
``,
|
|
75
139
|
`## Task`,
|
|
76
140
|
`**${ctx.task.title}** (${ctx.task.type})`,
|
|
141
|
+
...descriptionBlock(ctx.task),
|
|
77
142
|
``,
|
|
78
143
|
`## Working Directory`,
|
|
79
144
|
ctx.worktreePath,
|
|
@@ -111,6 +176,7 @@ export function buildDecomposePrompt(task) {
|
|
|
111
176
|
``,
|
|
112
177
|
`## Task`,
|
|
113
178
|
`**${task.title}** (${task.type}, Phase ${task.phase})`,
|
|
179
|
+
...descriptionBlock(task),
|
|
114
180
|
``,
|
|
115
181
|
`## Instructions`,
|
|
116
182
|
`Split this task into 2-4 smaller tasks (Complexity S or M).`,
|
|
@@ -136,6 +202,7 @@ export function buildRoutingPrompt(task, step, agents) {
|
|
|
136
202
|
``,
|
|
137
203
|
`## Task`,
|
|
138
204
|
`**${task.title}** (${task.type}, Complexity: ${task.complexity})`,
|
|
205
|
+
...descriptionBlock(task),
|
|
139
206
|
``,
|
|
140
207
|
`## Pipeline Step`,
|
|
141
208
|
step,
|
|
@@ -118,7 +118,7 @@ export class WorkerPool {
|
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
async killAll() {
|
|
121
|
-
|
|
121
|
+
const kills = [...this.queries.entries()].map(async ([slotId, q]) => {
|
|
122
122
|
try {
|
|
123
123
|
await q.return(undefined);
|
|
124
124
|
}
|
|
@@ -127,7 +127,8 @@ export class WorkerPool {
|
|
|
127
127
|
slot.status = 'idle';
|
|
128
128
|
slot.taskId = null;
|
|
129
129
|
slot.pid = null;
|
|
130
|
-
}
|
|
130
|
+
});
|
|
131
|
+
await Promise.allSettled(kills);
|
|
131
132
|
this.queries.clear();
|
|
132
133
|
}
|
|
133
134
|
reset() {
|
package/dist/scheduler/index.mjs
CHANGED
|
@@ -66,6 +66,10 @@ export function parseSchedulerArgs(argv) {
|
|
|
66
66
|
mutable.resume = true;
|
|
67
67
|
continue;
|
|
68
68
|
}
|
|
69
|
+
if (arg === '--reset-iterations') {
|
|
70
|
+
mutable.resetIterations = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
69
73
|
if (arg === '--interactive' || arg === '-i') {
|
|
70
74
|
mutable.interactive = true;
|
|
71
75
|
continue;
|
|
@@ -178,6 +182,7 @@ Options:
|
|
|
178
182
|
--project-dir <path> Project directory (default: cwd)
|
|
179
183
|
--skip-permissions Bypass all permission checks
|
|
180
184
|
--resume Resume from saved state
|
|
185
|
+
--reset-iterations Reset iteration counter (use with --resume)
|
|
181
186
|
--resume-session <id> Resume specific Claude session
|
|
182
187
|
--notify-command <cmd> Shell command on critical events
|
|
183
188
|
--log-file <path> Log file path
|
|
@@ -248,6 +253,12 @@ export async function runScheduler(opts) {
|
|
|
248
253
|
// Clear in-progress pipelines from previous run (can't resume mid-pipeline)
|
|
249
254
|
if (!opts.resume) {
|
|
250
255
|
state.pipelines = {};
|
|
256
|
+
// Reset iteration counter on fresh start (not --resume)
|
|
257
|
+
state.iteration = 0;
|
|
258
|
+
}
|
|
259
|
+
// Explicit --reset-iterations flag (works with or without --resume)
|
|
260
|
+
if (opts.resetIterations) {
|
|
261
|
+
state.iteration = 0;
|
|
251
262
|
}
|
|
252
263
|
// Always re-run recovery on fresh scheduler start
|
|
253
264
|
state._recoveryDone = false;
|
|
@@ -256,6 +267,12 @@ export async function runScheduler(opts) {
|
|
|
256
267
|
else {
|
|
257
268
|
state = emptyState(opts.concurrency);
|
|
258
269
|
}
|
|
270
|
+
// Warn if iteration limit already reached (e.g. --resume without --reset-iterations)
|
|
271
|
+
if (state.iteration >= opts.maxIterations) {
|
|
272
|
+
logger.warn(`Iteration limit reached (${state.iteration}/${opts.maxIterations}). Use --reset-iterations or --max-iterations ${state.iteration + 50} to continue.`);
|
|
273
|
+
tui.destroy();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
259
276
|
// Ensure platform CLI is available before task mode detection
|
|
260
277
|
const detectedPlatform = detectCIPlatform(opts.projectDir);
|
|
261
278
|
if (detectedPlatform !== 'none') {
|
|
@@ -326,12 +343,26 @@ export async function runScheduler(opts) {
|
|
|
326
343
|
let stopping = false;
|
|
327
344
|
const stoppingRef = { value: false };
|
|
328
345
|
const cleanup = () => {
|
|
346
|
+
if (stopping) {
|
|
347
|
+
// Second signal — force exit immediately
|
|
348
|
+
logger.warn('Force exit.');
|
|
349
|
+
tui.destroy();
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
329
352
|
stopping = true;
|
|
330
353
|
stoppingRef.value = true;
|
|
331
354
|
writeState(opts.projectDir, state);
|
|
332
355
|
appendEvent(opts.projectDir, createEvent('health_check', { detail: 'Interrupted — state saved' }));
|
|
333
|
-
logger.warn('Interrupted. State saved.');
|
|
356
|
+
logger.warn('Interrupted. State saved. Press Ctrl+C again to force exit.');
|
|
357
|
+
// Kill workers with 5s timeout — guarantee exit even if SDK hangs
|
|
358
|
+
const forceTimer = setTimeout(() => {
|
|
359
|
+
logger.warn('Kill timeout — force exit.');
|
|
360
|
+
tui.destroy();
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}, 5_000);
|
|
363
|
+
forceTimer.unref();
|
|
334
364
|
pool.killAll().finally(() => {
|
|
365
|
+
clearTimeout(forceTimer);
|
|
335
366
|
tui.destroy();
|
|
336
367
|
process.exit(0);
|
|
337
368
|
});
|
package/dist/scheduler/loop.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { resolve } from 'node:path';
|
|
6
6
|
import { parseTodoMd, updateTaskCheckbox } from './tasks/parser.mjs';
|
|
7
|
-
import { fetchOpenIssues, issueToTask, updateIssueStatus } from './tasks/issue-source.mjs';
|
|
7
|
+
import { fetchOpenIssues, issueToTask, updateIssueStatus, isTriaged, updateIssueBody, addIssueLabels, closeIssue } from './tasks/issue-source.mjs';
|
|
8
8
|
import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete } from './tasks/queue.mjs';
|
|
9
9
|
import { writeState, appendEvent, createEvent, rotateLog } from './state/state.mjs';
|
|
10
10
|
import { recordSession, getSession, clearSession } from './state/session.mjs';
|
|
@@ -14,7 +14,7 @@ import { scanAgents } from './agents/health-checker.mjs';
|
|
|
14
14
|
import { detectCIPlatform, fetchFailureLogs } from './git/ci-watcher.mjs';
|
|
15
15
|
import { createRelease } from './git/release.mjs';
|
|
16
16
|
import { processInbox, addTaskMessageToTask } from './tasks/inbox.mjs';
|
|
17
|
-
import { buildPlanPrompt, buildImplementPrompt, buildQAPrompt, buildReviewPrompt, buildReworkPrompt, buildCIFixPrompt, buildPRCommentPrompt, } from './agents/prompt-builder.mjs';
|
|
17
|
+
import { buildPlanPrompt, buildImplementPrompt, buildQAPrompt, buildReviewPrompt, buildReworkPrompt, buildCIFixPrompt, buildPRCommentPrompt, buildPOTriagePrompt, buildITAnalystTriagePrompt, } from './agents/prompt-builder.mjs';
|
|
18
18
|
import { discoveredIssueToTask } from './tools/report-issue.mjs';
|
|
19
19
|
import { createSchedulerToolServer } from './tools/scheduler-tools.mjs';
|
|
20
20
|
const MAX_REVIEW_CYCLES = 5;
|
|
@@ -405,15 +405,102 @@ async function runTaskPipeline(task, workerId, agents, deps) {
|
|
|
405
405
|
assignedAgent: null,
|
|
406
406
|
prState: null,
|
|
407
407
|
approvalWaitingSince: null,
|
|
408
|
+
triageOutput: null,
|
|
408
409
|
};
|
|
409
410
|
pipeline.workerId = workerId;
|
|
410
411
|
state.pipelines[task.id] = pipeline;
|
|
411
412
|
// Determine which steps to skip (for resumed pipelines)
|
|
412
413
|
const skipTo = resumeStep ?? 'plan';
|
|
413
|
-
const stepOrder = ['plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
|
|
414
|
+
const stepOrder = ['triage', 'plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
|
|
414
415
|
const skipToIndex = stepOrder.indexOf(skipTo);
|
|
415
416
|
const shouldSkip = (step) => stepOrder.indexOf(step) < skipToIndex;
|
|
416
417
|
try {
|
|
418
|
+
// STEP 0: Triage (platform issues only — features go through PO, bugs go to IT analyst)
|
|
419
|
+
if (!shouldSkip('triage')) {
|
|
420
|
+
if (task.source === 'platform' && !isTriaged(task)) {
|
|
421
|
+
pipeline.step = 'triage';
|
|
422
|
+
appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'triage' }));
|
|
423
|
+
const ciPlatformTriage = detectCIPlatform(projectDir);
|
|
424
|
+
const issueNum = extractIssueNumber(task.issueMarker);
|
|
425
|
+
const isBug = task.issueKind === 'bug';
|
|
426
|
+
let poOutput;
|
|
427
|
+
// Features: product owner evaluates first
|
|
428
|
+
if (!isBug) {
|
|
429
|
+
logger.info(`[${task.id}] Triage: feature request — consulting product owner`);
|
|
430
|
+
const poResult = await spawnAgent(pool, workerId, {
|
|
431
|
+
agent: 'product-owner',
|
|
432
|
+
cwd: worktreePath,
|
|
433
|
+
prompt: buildPOTriagePrompt({ task, worktreePath, projectDir }),
|
|
434
|
+
model: 'opus',
|
|
435
|
+
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
436
|
+
if (poResult.success) {
|
|
437
|
+
poOutput = poResult.output;
|
|
438
|
+
// Parse PO decision
|
|
439
|
+
const jsonMatch = poResult.output.match(/\{[\s\S]*"decision"[\s\S]*\}/);
|
|
440
|
+
if (jsonMatch) {
|
|
441
|
+
try {
|
|
442
|
+
const poDecision = JSON.parse(jsonMatch[0]);
|
|
443
|
+
if (poDecision.decision === 'reject') {
|
|
444
|
+
logger.info(`[${task.id}] Product owner rejected: ${poDecision.reason}`);
|
|
445
|
+
// Close the issue with explanation
|
|
446
|
+
if (ciPlatformTriage !== 'none' && issueNum) {
|
|
447
|
+
closeIssue(projectDir, ciPlatformTriage, issueNum, `**Rejected by product analysis:**\n\n${poDecision.reason}\n\nRisks: ${(poDecision.risks ?? []).join(', ')}`);
|
|
448
|
+
}
|
|
449
|
+
cleanupWorktree(projectDir, worktreePath, slug);
|
|
450
|
+
pipeline.step = 'done';
|
|
451
|
+
delete state.pipelines[task.id];
|
|
452
|
+
task.status = 'skipped';
|
|
453
|
+
state.skippedTasks.push(task.id);
|
|
454
|
+
appendEvent(projectDir, createEvent('task_skipped', { taskId: task.id, detail: `PO rejected: ${poDecision.reason}` }));
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch { /* parse error — continue with IT analyst */ }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// IT analyst creates detailed spec
|
|
463
|
+
logger.info(`[${task.id}] Triage: ${isBug ? 'bug' : 'accepted feature'} — creating spec via IT analyst`);
|
|
464
|
+
const analystResult = await spawnAgent(pool, workerId, {
|
|
465
|
+
agent: 'it-analyst',
|
|
466
|
+
cwd: worktreePath,
|
|
467
|
+
prompt: buildITAnalystTriagePrompt({ task, worktreePath, projectDir }, poOutput),
|
|
468
|
+
model: 'sonnet',
|
|
469
|
+
}, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
|
|
470
|
+
if (analystResult.success) {
|
|
471
|
+
pipeline.triageOutput = analystResult.output;
|
|
472
|
+
// Update task description with triage output
|
|
473
|
+
task.description = analystResult.output;
|
|
474
|
+
// Parse metadata from analyst output
|
|
475
|
+
const metaMatch = analystResult.output.match(/```json\s*(\{[\s\S]*?\})\s*```/);
|
|
476
|
+
if (metaMatch) {
|
|
477
|
+
try {
|
|
478
|
+
const meta = JSON.parse(metaMatch[1]);
|
|
479
|
+
if (meta.type)
|
|
480
|
+
task.type = meta.type;
|
|
481
|
+
if (meta.complexity)
|
|
482
|
+
task.complexity = meta.complexity;
|
|
483
|
+
}
|
|
484
|
+
catch { /* ignore parse errors */ }
|
|
485
|
+
}
|
|
486
|
+
// Update issue on platform with structured spec + labels
|
|
487
|
+
if (ciPlatformTriage !== 'none' && issueNum) {
|
|
488
|
+
try {
|
|
489
|
+
updateIssueBody(projectDir, ciPlatformTriage, issueNum, analystResult.output);
|
|
490
|
+
const newLabels = [`type::${task.type}`, `complexity::${task.complexity}`, 'status::todo'];
|
|
491
|
+
addIssueLabels(projectDir, ciPlatformTriage, issueNum, newLabels);
|
|
492
|
+
logger.info(`[${task.id}] Issue updated with spec + labels`);
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
logger.warn(`[${task.id}] Failed to update issue: ${err.message?.split('\n')[0]}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
logger.warn(`[${task.id}] IT analyst triage failed — proceeding with original description`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
417
504
|
// Route task to agent
|
|
418
505
|
const routing = await orchestrator.routeTask(task, shouldSkip('plan') ? skipTo : 'plan', agents);
|
|
419
506
|
pipeline.assignedAgent = routing.agent;
|
|
@@ -839,6 +926,7 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
|
|
|
839
926
|
assignedAgent: null,
|
|
840
927
|
prState: null,
|
|
841
928
|
approvalWaitingSince: null,
|
|
929
|
+
triageOutput: null,
|
|
842
930
|
};
|
|
843
931
|
}
|
|
844
932
|
catch (err) {
|
|
@@ -932,6 +1020,7 @@ async function analyzeStaleUnmergedBranches(projectDir, state, logger, deps) {
|
|
|
932
1020
|
assignedAgent: null,
|
|
933
1021
|
prState: null,
|
|
934
1022
|
approvalWaitingSince: null,
|
|
1023
|
+
triageOutput: null,
|
|
935
1024
|
};
|
|
936
1025
|
logger.info(`[recovery] Branch "${d.branch}" re-injected at step "${step}"`);
|
|
937
1026
|
}
|
|
@@ -1042,6 +1131,7 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
|
|
|
1042
1131
|
assignedAgent: null,
|
|
1043
1132
|
prState: { prNumber: mr.number, url: '', issueNumber: issueNum },
|
|
1044
1133
|
approvalWaitingSince: null,
|
|
1134
|
+
triageOutput: null,
|
|
1045
1135
|
};
|
|
1046
1136
|
}
|
|
1047
1137
|
catch (err) {
|
|
@@ -42,8 +42,14 @@ const PHASE_RE = /^Phase\s+(\d+)/i;
|
|
|
42
42
|
const DEPENDS_ON_RE = /depends[- ]on:\s*(.+)/im;
|
|
43
43
|
export function issueToTask(issue, fallbackPhase) {
|
|
44
44
|
const phase = extractPhase(issue.milestone) ?? fallbackPhase;
|
|
45
|
-
const
|
|
46
|
-
const
|
|
45
|
+
const hasTypeLabel = issue.labels.some(l => l.startsWith('type::'));
|
|
46
|
+
const hasComplexityLabel = issue.labels.some(l => l.startsWith('complexity::'));
|
|
47
|
+
const type = hasTypeLabel
|
|
48
|
+
? extractLabel(issue.labels, 'type::', 'fullstack')
|
|
49
|
+
: inferType(issue.title, issue.body);
|
|
50
|
+
const complexity = hasComplexityLabel
|
|
51
|
+
? extractLabel(issue.labels, 'complexity::', 'M')
|
|
52
|
+
: inferComplexity(issue.title, issue.body);
|
|
47
53
|
const status = extractStatus(issue.labels);
|
|
48
54
|
const dependsOn = extractDependencies(issue.body, issue.labels);
|
|
49
55
|
return {
|
|
@@ -55,8 +61,10 @@ export function issueToTask(issue, fallbackPhase) {
|
|
|
55
61
|
dependsOn,
|
|
56
62
|
issueMarker: `#${issue.issueNumber}`,
|
|
57
63
|
kitUpgrade: issue.labels.some(l => l.toLowerCase().includes('kit-upgrade')),
|
|
58
|
-
lineNumber: 0,
|
|
64
|
+
lineNumber: 0,
|
|
59
65
|
source: 'platform',
|
|
66
|
+
description: truncateBody(issue.body, 4000),
|
|
67
|
+
issueKind: classifyIssueKind(issue.title, issue.labels),
|
|
60
68
|
status,
|
|
61
69
|
changelog: inferChangelog(issue.title, issue.labels),
|
|
62
70
|
};
|
|
@@ -123,6 +131,109 @@ function inferChangelog(title, labels) {
|
|
|
123
131
|
return 'changed';
|
|
124
132
|
return 'added';
|
|
125
133
|
}
|
|
134
|
+
// ─── Body truncation ───
|
|
135
|
+
export function truncateBody(body, maxChars) {
|
|
136
|
+
if (!body || !body.trim())
|
|
137
|
+
return undefined;
|
|
138
|
+
if (body.length <= maxChars)
|
|
139
|
+
return body;
|
|
140
|
+
// Truncate at the last paragraph break before maxChars
|
|
141
|
+
const truncated = body.slice(0, maxChars);
|
|
142
|
+
const lastBreak = truncated.lastIndexOf('\n\n');
|
|
143
|
+
const cutPoint = lastBreak > maxChars * 0.5 ? lastBreak : maxChars;
|
|
144
|
+
return truncated.slice(0, cutPoint) + '\n\n[... truncated]';
|
|
145
|
+
}
|
|
146
|
+
// ─── Issue classification ───
|
|
147
|
+
const BUG_KEYWORDS = ['bug', 'fix', 'hotfix', 'broken', 'crash', 'error', 'regression', 'defect'];
|
|
148
|
+
export function classifyIssueKind(title, labels) {
|
|
149
|
+
// Labels take priority
|
|
150
|
+
if (labels.some(l => l === 'bug' || l === 'type::bug'))
|
|
151
|
+
return 'bug';
|
|
152
|
+
if (labels.some(l => l === 'feature' || l === 'enhancement' || l === 'type::feature'))
|
|
153
|
+
return 'feature';
|
|
154
|
+
// Keyword detection from title
|
|
155
|
+
const lower = title.toLowerCase();
|
|
156
|
+
if (BUG_KEYWORDS.some(kw => lower.includes(kw)))
|
|
157
|
+
return 'bug';
|
|
158
|
+
return 'feature';
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* An issue is considered "triaged" if it has both status:: and type:: and complexity:: labels.
|
|
162
|
+
* Triaged issues skip the triage step and go directly to planning.
|
|
163
|
+
*/
|
|
164
|
+
export function isTriaged(task) {
|
|
165
|
+
// Platform tasks that were created by the setup pipeline (it-analyst/technical-planner)
|
|
166
|
+
// already have proper labels. We check if the source data had explicit labels.
|
|
167
|
+
// The simplest heuristic: if the task has a non-default status label AND description
|
|
168
|
+
// contains structured sections (acceptance criteria), it's been triaged.
|
|
169
|
+
// But for now, we use a simpler approach: issues with all 3 metadata labels are triaged.
|
|
170
|
+
return task.source === 'platform' && task.status === 'todo'
|
|
171
|
+
&& task.type !== 'fullstack' // has explicit type (not fallback)
|
|
172
|
+
&& task.complexity !== 'M'; // has explicit complexity (not fallback)
|
|
173
|
+
}
|
|
174
|
+
// ─── Heuristic inference (fallback when labels missing) ───
|
|
175
|
+
const FRONTEND_SIGNALS = ['component', 'ui', 'css', 'scss', 'template', 'form', 'button', 'page', 'modal', 'dialog', 'layout', 'responsive', 'animation'];
|
|
176
|
+
const BACKEND_SIGNALS = ['api', 'endpoint', 'database', 'migration', 'auth', 'middleware', 'worker', 'cron', 'server', 'query', 'schema'];
|
|
177
|
+
export function inferType(title, body) {
|
|
178
|
+
const text = `${title} ${body}`.toLowerCase();
|
|
179
|
+
const fScore = FRONTEND_SIGNALS.filter(s => text.includes(s)).length;
|
|
180
|
+
const bScore = BACKEND_SIGNALS.filter(s => text.includes(s)).length;
|
|
181
|
+
if (fScore > 0 && bScore > 0)
|
|
182
|
+
return 'fullstack';
|
|
183
|
+
if (fScore > bScore)
|
|
184
|
+
return 'frontend';
|
|
185
|
+
if (bScore > fScore)
|
|
186
|
+
return 'backend';
|
|
187
|
+
return 'fullstack';
|
|
188
|
+
}
|
|
189
|
+
export function inferComplexity(title, body) {
|
|
190
|
+
const text = `${title} ${body}`;
|
|
191
|
+
const criteriaCount = (text.match(/^[-*]\s/gm) ?? []).length;
|
|
192
|
+
const wordCount = text.split(/\s+/).length;
|
|
193
|
+
if (criteriaCount >= 8 || wordCount >= 500)
|
|
194
|
+
return 'L';
|
|
195
|
+
if (criteriaCount >= 3 || wordCount >= 150)
|
|
196
|
+
return 'M';
|
|
197
|
+
return 'S';
|
|
198
|
+
}
|
|
199
|
+
// ─── Update issue on platform ───
|
|
200
|
+
export function updateIssueBody(cwd, platform, issueNumber, body) {
|
|
201
|
+
const num = String(issueNumber);
|
|
202
|
+
if (platform === 'github') {
|
|
203
|
+
run('gh', ['issue', 'edit', num, '--body', body], cwd);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
run('glab', ['issue', 'update', num, '--description', body], cwd);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
export function addIssueLabels(cwd, platform, issueNumber, labels) {
|
|
210
|
+
if (labels.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
const num = String(issueNumber);
|
|
213
|
+
if (platform === 'github') {
|
|
214
|
+
run('gh', ['issue', 'edit', num, '--add-label', labels.join(',')], cwd);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
run('glab', ['issue', 'update', num, '--label', labels.join(',')], cwd);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export function closeIssue(cwd, platform, issueNumber, comment) {
|
|
221
|
+
const num = String(issueNumber);
|
|
222
|
+
if (comment) {
|
|
223
|
+
if (platform === 'github') {
|
|
224
|
+
run('gh', ['issue', 'comment', num, '--body', comment], cwd);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
run('glab', ['issue', 'note', num, '--message', comment], cwd);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (platform === 'github') {
|
|
231
|
+
run('gh', ['issue', 'close', num], cwd);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
run('glab', ['issue', 'close', num], cwd);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
126
237
|
// ─── Update issue status on platform ───
|
|
127
238
|
export function updateIssueStatus(cwd, platform, issueNumber, status) {
|
|
128
239
|
const statusLabel = `status::${status}`;
|
package/dist/scheduler/types.mjs
CHANGED