workflow-ai 1.0.68 → 1.1.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/package.json +10 -7
- package/src/lib/operations/plans.mjs +85 -0
- package/src/lib/operations/skills.mjs +124 -0
- package/src/lib/operations/tickets.mjs +332 -0
- package/src/scripts/get-next-id.js +39 -165
- package/src/scripts/move-ticket.js +68 -225
- package/src/scripts/pick-next-task.js +93 -759
- package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/claude-sonnet/trial-1.md +4 -68
- package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/claude-sonnet/trial-2.md +53 -58
- package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/claude-sonnet/trial-3.md +48 -48
- package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/judge.json +15 -15
- package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/meta.json +16 -16
- package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-002/current/claude-sonnet/trial-3.md +4 -76
- package/src/skills/coach/tests/cases/TC-COACH-001/current/meta.json +93 -93
- package/src/skills/coach/tests/cases/TC-COACH-002/current/meta.json +93 -93
- package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/meta.json +113 -113
- package/src/skills/execute-task/tests/cases/TC-EXECUTE-TASK-001/current/meta.json +87 -87
- package/src/skills/execute-task/tests/cases/TC-EXECUTE-TASK-005/current/meta.json +87 -87
- package/src/skills/review-result/SKILL.md +1 -0
- package/src/skills/review-result/knowledge/baseline-snapshot-validation.md +67 -0
- package/src/skills/review-result/knowledge/dod-patterns.md +1 -0
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/claude-sonnet/trial-1.md +2 -2
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/claude-sonnet/trial-2.md +2 -2
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/claude-sonnet/trial-3.md +2 -14
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/judge.json +18 -18
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/meta.json +20 -20
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-002/current/claude-sonnet/trial-2.md +2 -34
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-002/current/judge.json +19 -19
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-002/current/meta.json +21 -21
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/claude-sonnet/trial-1.md +36 -3
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/claude-sonnet/trial-2.md +11 -3
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/claude-sonnet/trial-3.md +3 -3
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/judge.json +18 -18
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/meta.json +20 -20
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/claude-sonnet/trial-1.md +5 -0
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/claude-sonnet/trial-2.md +5 -0
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/claude-sonnet/trial-3.md +6 -0
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/judge.json +46 -0
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/meta.json +37 -0
- package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004-baseline-snapshot.yaml +50 -0
- package/src/skills/review-result/tests/fixtures/QA-905-baseline-regex-instead-of-snapshot/QA-905.md +62 -0
- package/src/skills/review-result/tests/fixtures/QA-905-baseline-regex-instead-of-snapshot/baseline.test.mjs +124 -0
- package/src/skills/review-result/tests/index.yaml +5 -0
- package/src/skills/review-result/tests/rubrics/baseline-snapshot.md +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workflow-ai",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,12 +21,15 @@
|
|
|
21
21
|
"configs/",
|
|
22
22
|
"agent-templates/"
|
|
23
23
|
],
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
"exports": {
|
|
25
|
+
"./lib/find-root.mjs": "./src/lib/find-root.mjs",
|
|
26
|
+
"./lib/utils.mjs": "./src/lib/utils.mjs",
|
|
27
|
+
"./lib/logger.mjs": "./src/lib/logger.mjs",
|
|
28
|
+
"./lib/js-yaml.mjs": "./src/lib/js-yaml.mjs",
|
|
29
|
+
"./lib/operations/tickets.mjs": "./src/lib/operations/tickets.mjs",
|
|
30
|
+
"./lib/operations/plans.mjs": "./src/lib/operations/plans.mjs",
|
|
31
|
+
"./lib/operations/skills.mjs": "./src/lib/operations/skills.mjs"
|
|
32
|
+
},
|
|
30
33
|
"engines": {
|
|
31
34
|
"node": ">=18.0.0"
|
|
32
35
|
},
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { parseFrontmatter } from '../utils.mjs';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs/promises';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* List all plans in plans/current and plans/archive directories.
|
|
8
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
9
|
+
* @param {{ status?: string }} [options] - Filter options
|
|
10
|
+
* @returns {Promise<Array<{id: string, title: string, status: string, path: string}>>}
|
|
11
|
+
*/
|
|
12
|
+
export async function listPlans(projectRoot, { status } = {}) {
|
|
13
|
+
const plansDirs = [
|
|
14
|
+
path.join(projectRoot, 'plans', 'current'),
|
|
15
|
+
path.join(projectRoot, 'plans', 'archive')
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const allPlans = [];
|
|
19
|
+
|
|
20
|
+
for (const plansDir of plansDirs) {
|
|
21
|
+
try {
|
|
22
|
+
const files = await fs.readdir(plansDir);
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
if (!file.endsWith('.md')) continue;
|
|
25
|
+
const filePath = path.join(plansDir, file);
|
|
26
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
27
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
28
|
+
if (status && frontmatter.status !== status) continue;
|
|
29
|
+
allPlans.push({
|
|
30
|
+
id: frontmatter.id || file.replace('.md', ''),
|
|
31
|
+
title: frontmatter.title || '',
|
|
32
|
+
status: frontmatter.status || 'unknown',
|
|
33
|
+
path: filePath
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// If directory doesn't exist, just skip it
|
|
38
|
+
if (err.code !== 'ENOENT') throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return allPlans;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get a specific plan by ID.
|
|
47
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
48
|
+
* @param {string} planId - Plan ID (e.g., 'PLAN-001')
|
|
49
|
+
* @returns {Promise<{frontmatter: object, body: string, path: string}>}
|
|
50
|
+
* @throws {Error} With code 'PLAN_NOT_FOUND' if plan not found
|
|
51
|
+
*/
|
|
52
|
+
export async function getPlan(projectRoot, planId) {
|
|
53
|
+
const plansDirs = [
|
|
54
|
+
path.join(projectRoot, 'plans', 'current'),
|
|
55
|
+
path.join(projectRoot, 'plans', 'archive')
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
for (const plansDir of plansDirs) {
|
|
59
|
+
try {
|
|
60
|
+
const files = await fs.readdir(plansDir);
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
if (!file.endsWith('.md')) continue;
|
|
63
|
+
// Normalize file name to plan ID format for comparison
|
|
64
|
+
const fileId = file.replace('.md', '');
|
|
65
|
+
if (fileId === planId || fileId.toUpperCase() === planId.toUpperCase()) {
|
|
66
|
+
const filePath = path.join(plansDir, file);
|
|
67
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
68
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
69
|
+
return {
|
|
70
|
+
frontmatter,
|
|
71
|
+
body,
|
|
72
|
+
path: filePath
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err.code !== 'ENOENT') throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const error = new Error(`Plan not found: ${planId}`);
|
|
82
|
+
error.code = 'PLAN_NOT_FOUND';
|
|
83
|
+
error.planId = planId;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { findProjectRoot } from '../find-root.mjs';
|
|
2
|
+
import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lists all available skills, distinguishing between shared and ejected ones.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} [projectRoot] - Project root directory. If not provided, will be auto-detected.
|
|
9
|
+
* @returns {Promise<Array<{name: string, path: string, source: 'shared' | 'ejected'}>>}
|
|
10
|
+
*/
|
|
11
|
+
export async function listSkills(projectRoot) {
|
|
12
|
+
// Auto-detect project root if not provided
|
|
13
|
+
if (!projectRoot) {
|
|
14
|
+
projectRoot = findProjectRoot();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Get global skills directory (from where this module is located)
|
|
18
|
+
// Assuming this file is in src/lib/operations/skills.mjs
|
|
19
|
+
// Global root is two levels up from src (since src is in project root)
|
|
20
|
+
const globalRoot = join(projectRoot, '..');
|
|
21
|
+
const globalSkillsDir = join(globalRoot, 'src', 'skills');
|
|
22
|
+
|
|
23
|
+
// Project skills directory
|
|
24
|
+
const projectSkillsDir = join(projectRoot, '.workflow', 'src', 'skills');
|
|
25
|
+
|
|
26
|
+
const result = [];
|
|
27
|
+
|
|
28
|
+
// Check if global skills directory exists
|
|
29
|
+
if (!existsSync(globalSkillsDir)) {
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Read global skills
|
|
34
|
+
const globalSkills = readdirSync(globalSkillsDir, { withFileTypes: true })
|
|
35
|
+
.filter(entry => entry.isDirectory())
|
|
36
|
+
.map(entry => entry.name);
|
|
37
|
+
|
|
38
|
+
// Check if project skills directory exists
|
|
39
|
+
if (existsSync(projectSkillsDir)) {
|
|
40
|
+
// Check if it's a junction/symlink
|
|
41
|
+
let isJunctionLink = false;
|
|
42
|
+
try {
|
|
43
|
+
const stats = lstatSync(projectSkillsDir);
|
|
44
|
+
isJunctionLink = stats.isSymbolicLink();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// If lstat fails, treat as not a junction
|
|
47
|
+
isJunctionLink = false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isJunctionLink) {
|
|
51
|
+
// If it's a junction, all skills in it are considered shared
|
|
52
|
+
const projectSkills = readdirSync(projectSkillsDir, { withFileTypes: true })
|
|
53
|
+
.filter(entry => entry.isDirectory())
|
|
54
|
+
.map(entry => entry.name);
|
|
55
|
+
|
|
56
|
+
// Add all skills as shared (from junction)
|
|
57
|
+
for (const skillName of [...new Set([...globalSkills, ...projectSkills])]) {
|
|
58
|
+
result.push({
|
|
59
|
+
name: skillName,
|
|
60
|
+
path: join(globalSkillsDir, skillName),
|
|
61
|
+
source: 'shared'
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// Not a junction - handle ejected skills
|
|
66
|
+
const projectSkills = readdirSync(projectSkillsDir, { withFileTypes: true })
|
|
67
|
+
.filter(entry => entry.isDirectory())
|
|
68
|
+
.map(entry => entry.name);
|
|
69
|
+
|
|
70
|
+
// First, add all global skills as shared
|
|
71
|
+
for (const skillName of globalSkills) {
|
|
72
|
+
result.push({
|
|
73
|
+
name: skillName,
|
|
74
|
+
path: join(globalSkillsDir, skillName),
|
|
75
|
+
source: 'shared'
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Then, override with ejected skills where applicable
|
|
80
|
+
const ejectedSkillsMap = new Map();
|
|
81
|
+
for (const skillName of projectSkills) {
|
|
82
|
+
ejectedSkillsMap.set(skillName, join(projectSkillsDir, skillName));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build final result: ejected overrides shared
|
|
86
|
+
const finalResult = [];
|
|
87
|
+
const processedSkills = new Set();
|
|
88
|
+
|
|
89
|
+
// Add ejected skills first
|
|
90
|
+
for (const [skillName, skillPath] of ejectedSkillsMap) {
|
|
91
|
+
finalResult.push({
|
|
92
|
+
name: skillName,
|
|
93
|
+
path: skillPath,
|
|
94
|
+
source: 'ejected'
|
|
95
|
+
});
|
|
96
|
+
processedSkills.add(skillName);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Add global skills that weren't overridden
|
|
100
|
+
for (const skillName of globalSkills) {
|
|
101
|
+
if (!processedSkills.has(skillName)) {
|
|
102
|
+
finalResult.push({
|
|
103
|
+
name: skillName,
|
|
104
|
+
path: join(globalSkillsDir, skillName),
|
|
105
|
+
source: 'shared'
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return finalResult;
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
// No project skills directory - return all global skills as shared
|
|
114
|
+
for (const skillName of globalSkills) {
|
|
115
|
+
result.push({
|
|
116
|
+
name: skillName,
|
|
117
|
+
path: join(globalSkillsDir, skillName),
|
|
118
|
+
source: 'shared'
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { findProjectRoot } from '../find-root.mjs';
|
|
2
|
+
import { parseFrontmatter, serializeFrontmatter, getLastReviewStatus, normalizePlanId } from '../utils.mjs';
|
|
3
|
+
import { existsSync, readdirSync, promises as fs } from 'node:fs';
|
|
4
|
+
import { resolve, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const VALID_STATUSES = [
|
|
7
|
+
"backlog", "ready", "in-progress", "blocked", "review", "done", "archive",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const VALID_TRANSITIONS = {
|
|
11
|
+
backlog: ["ready", "blocked", "done"],
|
|
12
|
+
ready: ["in-progress", "review", "backlog"],
|
|
13
|
+
"in-progress": ["done", "blocked", "review"],
|
|
14
|
+
blocked: ["ready"],
|
|
15
|
+
review: ["done", "ready", "in-progress", "blocked"],
|
|
16
|
+
done: ["ready", "blocked", "archive"],
|
|
17
|
+
archive: ["backlog"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function formatNumber(num) {
|
|
21
|
+
return String(num).padStart(3, '0');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getNextId(projectRoot, type) {
|
|
25
|
+
const root = projectRoot || findProjectRoot();
|
|
26
|
+
const ticketsDir = join(root, '.workflow', 'tickets');
|
|
27
|
+
if (!existsSync(ticketsDir)) return `${type}-001`;
|
|
28
|
+
const maxNum = findMaxNumber(ticketsDir, type);
|
|
29
|
+
return `${type}-${formatNumber(maxNum + 1)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findMaxNumber(targetDir, prefix) {
|
|
33
|
+
let maxNum = 0;
|
|
34
|
+
const regex = new RegExp(`^${prefix}-(\\d+)\\.md$`, "i");
|
|
35
|
+
function scanDirectory(dir) {
|
|
36
|
+
if (!existsSync(dir)) return;
|
|
37
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
const fullPath = join(dir, entry.name);
|
|
40
|
+
if (entry.isDirectory()) scanDirectory(fullPath);
|
|
41
|
+
else if (entry.isFile()) {
|
|
42
|
+
const match = entry.name.match(regex);
|
|
43
|
+
if (match) maxNum = Math.max(maxNum, parseInt(match[1], 10));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
scanDirectory(targetDir);
|
|
48
|
+
return maxNum;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getStatusFromPath(ticketId, ticketsDir) {
|
|
52
|
+
for (const status of VALID_STATUSES) {
|
|
53
|
+
if (existsSync(join(ticketsDir, status, `${ticketId}.md`))) return status;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assertValidTransition(from, to) {
|
|
59
|
+
if (!VALID_STATUSES.includes(from) || !VALID_STATUSES.includes(to)) {
|
|
60
|
+
throw { code: 'INVALID_TRANSITION', from, to, id: null };
|
|
61
|
+
}
|
|
62
|
+
if (!VALID_TRANSITIONS[from]?.includes(to)) {
|
|
63
|
+
throw { code: 'INVALID_TRANSITION', from, to, id: null };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function moveTicket(projectRoot, id, target) {
|
|
68
|
+
const root = projectRoot || findProjectRoot();
|
|
69
|
+
const ticketsDir = join(root, '.workflow', 'tickets');
|
|
70
|
+
const from = getStatusFromPath(id, ticketsDir);
|
|
71
|
+
if (!from) throw { code: 'INVALID_TRANSITION', from: null, to: target, id };
|
|
72
|
+
assertValidTransition(from, target);
|
|
73
|
+
|
|
74
|
+
const sourcePath = join(ticketsDir, from, `${id}.md`);
|
|
75
|
+
const targetDir = join(ticketsDir, target);
|
|
76
|
+
const targetPath = join(targetDir, `${id}.md`);
|
|
77
|
+
|
|
78
|
+
let content = await fs.readFile(sourcePath, 'utf8');
|
|
79
|
+
let { frontmatter, body } = parseFrontmatter(content);
|
|
80
|
+
const now = new Date().toISOString();
|
|
81
|
+
frontmatter.updated_at = now;
|
|
82
|
+
if (target === "done" && from !== "done") frontmatter.completed_at = now;
|
|
83
|
+
if (target === "done" && from === "review" && getLastReviewStatus(content) === null) {
|
|
84
|
+
const date = now.slice(0, 16).replace("T", " ");
|
|
85
|
+
body = body.trimEnd() + `\n## Ревью\n\n| Дата | Статус | Самари |\n|------|--------|--------|\n| ${date} | ✅ passed | Pipeline fallback |\n`;
|
|
86
|
+
}
|
|
87
|
+
if (from === "blocked" && frontmatter.blocked_reason) delete frontmatter.blocked_reason;
|
|
88
|
+
|
|
89
|
+
const newContent = serializeFrontmatter(frontmatter) + body;
|
|
90
|
+
if (!existsSync(targetDir)) await fs.mkdir(targetDir, { recursive: true });
|
|
91
|
+
await fs.rename(sourcePath, targetPath);
|
|
92
|
+
await fs.writeFile(targetPath, newContent, 'utf8');
|
|
93
|
+
return { from, to: target, path: targetPath };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function checkCondition(projectRoot, condition) {
|
|
97
|
+
const { type, value } = condition;
|
|
98
|
+
switch (type) {
|
|
99
|
+
case 'file_exists': return existsSync(join(projectRoot, value));
|
|
100
|
+
case 'file_not_exists': return !existsSync(join(projectRoot, value));
|
|
101
|
+
case 'tasks_completed':
|
|
102
|
+
if (!value || (Array.isArray(value) && value.length === 0)) return true;
|
|
103
|
+
const ids = Array.isArray(value) ? value : [value];
|
|
104
|
+
const doneDir = join(projectRoot, '.workflow', 'tickets', 'done');
|
|
105
|
+
const archiveDir = join(projectRoot, '.workflow', 'tickets', 'archive');
|
|
106
|
+
return ids.every(tid => existsSync(join(doneDir, `${tid}.md`)) || existsSync(join(archiveDir, `${tid}.md`)));
|
|
107
|
+
case 'date_after': return new Date() > new Date(value);
|
|
108
|
+
case 'date_before': return new Date() < new Date(value);
|
|
109
|
+
case 'manual_approval': return false;
|
|
110
|
+
default: return true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkDependencies(projectRoot, dependencies) {
|
|
115
|
+
if (!dependencies || dependencies.length === 0) return true;
|
|
116
|
+
const doneDir = join(projectRoot, '.workflow', 'tickets', 'done');
|
|
117
|
+
const archiveDir = join(projectRoot, '.workflow', 'tickets', 'archive');
|
|
118
|
+
return dependencies.every(depId =>
|
|
119
|
+
existsSync(join(doneDir, `${depId}.md`)) || existsSync(join(archiveDir, `${depId}.md`))
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function createTicket(projectRoot, data) {
|
|
124
|
+
const root = projectRoot || findProjectRoot();
|
|
125
|
+
const ticketsDir = join(root, '.workflow', 'tickets');
|
|
126
|
+
const backlogDir = join(ticketsDir, 'backlog');
|
|
127
|
+
const type = data.type ?? 'impl';
|
|
128
|
+
const id = await getNextId(root, type);
|
|
129
|
+
const frontmatter = {
|
|
130
|
+
id, title: data.title ?? '', priority: data.priority ?? 3, type,
|
|
131
|
+
required_capabilities: data.required_capabilities ?? [],
|
|
132
|
+
created_at: new Date().toISOString(), updated_at: new Date().toISOString(), completed_at: '',
|
|
133
|
+
parent_plan: data.parent_plan ?? '', parent_task: data.parent_task ?? '',
|
|
134
|
+
dependencies: data.dependencies ?? [], conditions: data.conditions ?? [],
|
|
135
|
+
context: data.context ?? { files: [], references: [], notes: '' },
|
|
136
|
+
complexity: data.complexity ?? 'medium', tags: data.tags ?? [],
|
|
137
|
+
};
|
|
138
|
+
if (data.type === 'human') frontmatter.executor_type = 'human';
|
|
139
|
+
if (!existsSync(backlogDir)) await fs.mkdir(backlogDir, { recursive: true });
|
|
140
|
+
const path = join(backlogDir, `${id}.md`);
|
|
141
|
+
const content = serializeFrontmatter(frontmatter) + '\n## Описание\n\n\n## Критерии готовности (Definition of Done)\n\n- [ ] \n';
|
|
142
|
+
await fs.writeFile(path, content, 'utf8');
|
|
143
|
+
return { id, path };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Проверяет, заполнен ли раздел результатов (Summary) в тикете
|
|
148
|
+
*/
|
|
149
|
+
function hasFilledResult(body) {
|
|
150
|
+
const resultSectionRegex = /^##\s*(Результат выполнения|Result)\s*$/m;
|
|
151
|
+
const sectionStart = body.search(resultSectionRegex);
|
|
152
|
+
if (sectionStart === -1) return false;
|
|
153
|
+
const nextSectionRegex = /^##\s+/gm;
|
|
154
|
+
nextSectionRegex.lastIndex = sectionStart + 1;
|
|
155
|
+
const nextSectionMatch = nextSectionRegex.exec(body);
|
|
156
|
+
const sectionEnd = nextSectionMatch ? nextSectionMatch.index : body.length;
|
|
157
|
+
const sectionContent = body.substring(sectionStart, sectionEnd);
|
|
158
|
+
const summaryRegex = /^###\s*(Summary|Что сделано)\s*$/m;
|
|
159
|
+
const summaryStart = sectionContent.search(summaryRegex);
|
|
160
|
+
if (summaryStart === -1) return false;
|
|
161
|
+
const nextSubsectionRegex = /^###\s+/gm;
|
|
162
|
+
nextSubsectionRegex.lastIndex = summaryStart + 1;
|
|
163
|
+
const nextSubsectionMatch = nextSubsectionRegex.exec(sectionContent);
|
|
164
|
+
const summaryEnd = nextSubsectionMatch ? nextSubsectionMatch.index : sectionContent.length;
|
|
165
|
+
const summaryContent = sectionContent.substring(summaryStart, summaryEnd);
|
|
166
|
+
const withoutComments = summaryContent.replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
167
|
+
return withoutComments.length > 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function filterByPlan(tickets, planId) {
|
|
171
|
+
if (!planId) return tickets;
|
|
172
|
+
return tickets.filter(t => normalizePlanId(t.frontmatter.parent_plan) === planId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sortTickets(tickets) {
|
|
176
|
+
return tickets.sort((a, b) => {
|
|
177
|
+
const pa = a.frontmatter.priority || 999, pb = b.frontmatter.priority || 999;
|
|
178
|
+
if (pa !== pb) return pa - pb;
|
|
179
|
+
const da = new Date(a.frontmatter.created_at || '9999-12-31');
|
|
180
|
+
const db = new Date(b.frontmatter.created_at || '9999-12-31');
|
|
181
|
+
return da - db;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isTicketEligible(projectRoot, ticket, checkDuplicates = [], currentDir = null) {
|
|
186
|
+
const { frontmatter, id } = ticket;
|
|
187
|
+
if (frontmatter.type === 'human') return false;
|
|
188
|
+
if (!checkDependencies(projectRoot, frontmatter.dependencies || [])) return false;
|
|
189
|
+
const conditions = frontmatter.conditions || [];
|
|
190
|
+
if (!conditions.every(c => checkCondition(projectRoot, c))) return false;
|
|
191
|
+
|
|
192
|
+
// Проверка дубликатов - исключаем тикет если он находится в других директориях
|
|
193
|
+
// checkDuplicates может быть true (исключить все кроме currentDir) или false/[] (не проверять)
|
|
194
|
+
if (checkDuplicates === true || (Array.isArray(checkDuplicates) && checkDuplicates.length > 0)) {
|
|
195
|
+
const allDirs = ['ready', 'done', 'in-progress', 'review', 'blocked'].filter(d => d !== currentDir).map(d =>
|
|
196
|
+
join(projectRoot, '.workflow', 'tickets', d));
|
|
197
|
+
if (allDirs.some(dir => existsSync(join(dir, `${id}.md`)))) return false;
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Выбирает следующий доступный тикет (все статусы: found, in_review, completed_in_progress, in_progress, empty)
|
|
204
|
+
* @param {string} projectRoot - Корень проекта
|
|
205
|
+
* @param {string} planId - ID плана для фильтрации
|
|
206
|
+
* @returns {Promise<object>} Результат выбора
|
|
207
|
+
*/
|
|
208
|
+
export async function pickNext(projectRoot) {
|
|
209
|
+
const root = projectRoot || findProjectRoot();
|
|
210
|
+
const ticketsDir = join(root, '.workflow', 'tickets');
|
|
211
|
+
const readyDir = join(ticketsDir, 'ready');
|
|
212
|
+
const reviewDir = join(ticketsDir, 'review');
|
|
213
|
+
const inProgressDir = join(ticketsDir, 'in-progress');
|
|
214
|
+
|
|
215
|
+
// 1. ready/ тикеты
|
|
216
|
+
const readyFiles = existsSync(readyDir) ? readdirSync(readyDir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md') : [];
|
|
217
|
+
const readyTickets = [];
|
|
218
|
+
for (const file of readyFiles) {
|
|
219
|
+
try {
|
|
220
|
+
const content = await fs.readFile(join(readyDir, file), 'utf8');
|
|
221
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
222
|
+
readyTickets.push({ id: frontmatter.id || file.replace('.md', ''), frontmatter, filePath: join(readyDir, file) });
|
|
223
|
+
} catch (e) {}
|
|
224
|
+
}
|
|
225
|
+
if (readyTickets.length === 0) {
|
|
226
|
+
return { empty: true, reason: 'no_ready_tickets' };
|
|
227
|
+
}
|
|
228
|
+
const eligibleReady = readyTickets.filter(t => isTicketEligible(root, t, true, 'ready'));
|
|
229
|
+
if (eligibleReady.length > 0) {
|
|
230
|
+
const selected = sortTickets(eligibleReady)[0];
|
|
231
|
+
return { ticket: { id: selected.id, ...selected.frontmatter, path: selected.filePath } };
|
|
232
|
+
}
|
|
233
|
+
return { empty: true, reason: 'no_eligible_tickets' };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Оригинальная функция pickNext (только ready/) - для совместимости
|
|
238
|
+
*/
|
|
239
|
+
export async function pickNextEffective(projectRoot, planId) {
|
|
240
|
+
const root = projectRoot || findProjectRoot();
|
|
241
|
+
const ticketsDir = join(root, '.workflow', 'tickets');
|
|
242
|
+
const readyDir = join(ticketsDir, 'ready');
|
|
243
|
+
const reviewDir = join(ticketsDir, 'review');
|
|
244
|
+
const inProgressDir = join(ticketsDir, 'in-progress');
|
|
245
|
+
|
|
246
|
+
// 1. ready/
|
|
247
|
+
const readyFiles = existsSync(readyDir) ? readdirSync(readyDir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md') : [];
|
|
248
|
+
const readyTickets = [];
|
|
249
|
+
for (const file of readyFiles) {
|
|
250
|
+
try {
|
|
251
|
+
const content = await fs.readFile(join(readyDir, file), 'utf8');
|
|
252
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
253
|
+
readyTickets.push({ id: frontmatter.id || file.replace('.md', ''), frontmatter, filePath: join(readyDir, file) });
|
|
254
|
+
} catch (e) {}
|
|
255
|
+
}
|
|
256
|
+
const eligibleReady = readyTickets.filter(t => isTicketEligible(root, t, true, 'ready'));
|
|
257
|
+
if (eligibleReady.length > 0) {
|
|
258
|
+
const selected = sortTickets(eligibleReady)[0];
|
|
259
|
+
return { status: 'found', ticket_id: selected.id, priority: selected.frontmatter.priority,
|
|
260
|
+
title: selected.frontmatter.title, type: selected.frontmatter.type,
|
|
261
|
+
required_capabilities: JSON.stringify(selected.frontmatter.required_capabilities || []) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 2. review/
|
|
265
|
+
const reviewFiles = existsSync(reviewDir) ? readdirSync(reviewDir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md') : [];
|
|
266
|
+
const reviewTickets = [];
|
|
267
|
+
for (const file of reviewFiles) {
|
|
268
|
+
try {
|
|
269
|
+
const content = await fs.readFile(join(reviewDir, file), 'utf8');
|
|
270
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
271
|
+
reviewTickets.push({ id: frontmatter.id || file.replace('.md', ''), frontmatter, filePath: join(reviewDir, file) });
|
|
272
|
+
} catch (e) {}
|
|
273
|
+
}
|
|
274
|
+
if (reviewTickets.length > 0) {
|
|
275
|
+
const eligibleReview = reviewTickets.filter(t => isTicketEligible(root, t, true, 'review'));
|
|
276
|
+
if (eligibleReview.length > 0) {
|
|
277
|
+
const selected = sortTickets(eligibleReview)[0];
|
|
278
|
+
return { status: 'in_review', ticket_id: selected.id, priority: selected.frontmatter.priority,
|
|
279
|
+
title: selected.frontmatter.title, type: selected.frontmatter.type,
|
|
280
|
+
required_capabilities: JSON.stringify(selected.frontmatter.required_capabilities || []) };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 3. in-progress/ завершённые
|
|
285
|
+
const inProgressFiles = existsSync(inProgressDir) ? readdirSync(inProgressDir).filter(f => f.endsWith('.md') && f !== '.gitkeep.md') : [];
|
|
286
|
+
const completedInProgress = [];
|
|
287
|
+
for (const file of inProgressFiles) {
|
|
288
|
+
try {
|
|
289
|
+
const content = await fs.readFile(join(inProgressDir, file), 'utf8');
|
|
290
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
291
|
+
if (hasFilledResult(body)) {
|
|
292
|
+
completedInProgress.push({ id: frontmatter.id || file.replace('.md', ''), frontmatter, filePath: join(inProgressDir, file) });
|
|
293
|
+
}
|
|
294
|
+
} catch (e) {}
|
|
295
|
+
}
|
|
296
|
+
if (completedInProgress.length > 0) {
|
|
297
|
+
const eligibleCompleted = completedInProgress.filter(t => isTicketEligible(root, t, true, 'in-progress'));
|
|
298
|
+
if (eligibleCompleted.length > 0) {
|
|
299
|
+
const selected = sortTickets(eligibleCompleted)[0];
|
|
300
|
+
return { status: 'completed_in_progress', ticket_id: selected.id,
|
|
301
|
+
required_capabilities: JSON.stringify(selected.frontmatter.required_capabilities || []) };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 4. in-progress/ незавершённые
|
|
306
|
+
const allInProgress = [];
|
|
307
|
+
for (const file of inProgressFiles) {
|
|
308
|
+
try {
|
|
309
|
+
const content = await fs.readFile(join(inProgressDir, file), 'utf8');
|
|
310
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
311
|
+
if (!hasFilledResult(body)) {
|
|
312
|
+
allInProgress.push({ id: frontmatter.id || file.replace('.md', ''), frontmatter, filePath: join(inProgressDir, file) });
|
|
313
|
+
}
|
|
314
|
+
} catch (e) {}
|
|
315
|
+
}
|
|
316
|
+
if (allInProgress.length > 0) {
|
|
317
|
+
const eligibleInProgress = allInProgress.filter(t => isTicketEligible(root, t, true, 'in-progress'));
|
|
318
|
+
if (eligibleInProgress.length > 0) {
|
|
319
|
+
const selected = sortTickets(eligibleInProgress)[0];
|
|
320
|
+
return { status: 'in_progress', ticket_id: selected.id, priority: selected.frontmatter.priority,
|
|
321
|
+
title: selected.frontmatter.title, type: selected.frontmatter.type,
|
|
322
|
+
required_capabilities: JSON.stringify(selected.frontmatter.required_capabilities || []) };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 5. пусто
|
|
327
|
+
return { status: 'empty', reason: 'No tickets in ready/' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Оригинальная функция pickNext (только ready/) - для совместимости со старыми test
|
|
332
|
+
*/
|