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.
Files changed (44) hide show
  1. package/package.json +10 -7
  2. package/src/lib/operations/plans.mjs +85 -0
  3. package/src/lib/operations/skills.mjs +124 -0
  4. package/src/lib/operations/tickets.mjs +332 -0
  5. package/src/scripts/get-next-id.js +39 -165
  6. package/src/scripts/move-ticket.js +68 -225
  7. package/src/scripts/pick-next-task.js +93 -759
  8. package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/claude-sonnet/trial-1.md +4 -68
  9. package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/claude-sonnet/trial-2.md +53 -58
  10. package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/claude-sonnet/trial-3.md +48 -48
  11. package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/judge.json +15 -15
  12. package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-001/current/meta.json +16 -16
  13. package/src/skills/analyze-report/tests/cases/TC-ANALYZE-REPORT-002/current/claude-sonnet/trial-3.md +4 -76
  14. package/src/skills/coach/tests/cases/TC-COACH-001/current/meta.json +93 -93
  15. package/src/skills/coach/tests/cases/TC-COACH-002/current/meta.json +93 -93
  16. package/src/skills/decompose-plan/tests/cases/TC-DECOMPOSE-PLAN-005/current/meta.json +113 -113
  17. package/src/skills/execute-task/tests/cases/TC-EXECUTE-TASK-001/current/meta.json +87 -87
  18. package/src/skills/execute-task/tests/cases/TC-EXECUTE-TASK-005/current/meta.json +87 -87
  19. package/src/skills/review-result/SKILL.md +1 -0
  20. package/src/skills/review-result/knowledge/baseline-snapshot-validation.md +67 -0
  21. package/src/skills/review-result/knowledge/dod-patterns.md +1 -0
  22. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/claude-sonnet/trial-1.md +2 -2
  23. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/claude-sonnet/trial-2.md +2 -2
  24. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/claude-sonnet/trial-3.md +2 -14
  25. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/judge.json +18 -18
  26. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-001/current/meta.json +20 -20
  27. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-002/current/claude-sonnet/trial-2.md +2 -34
  28. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-002/current/judge.json +19 -19
  29. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-002/current/meta.json +21 -21
  30. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/claude-sonnet/trial-1.md +36 -3
  31. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/claude-sonnet/trial-2.md +11 -3
  32. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/claude-sonnet/trial-3.md +3 -3
  33. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/judge.json +18 -18
  34. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-003/current/meta.json +20 -20
  35. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/claude-sonnet/trial-1.md +5 -0
  36. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/claude-sonnet/trial-2.md +5 -0
  37. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/claude-sonnet/trial-3.md +6 -0
  38. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/judge.json +46 -0
  39. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004/current/meta.json +37 -0
  40. package/src/skills/review-result/tests/cases/TC-REVIEW-RESULT-004-baseline-snapshot.yaml +50 -0
  41. package/src/skills/review-result/tests/fixtures/QA-905-baseline-regex-instead-of-snapshot/QA-905.md +62 -0
  42. package/src/skills/review-result/tests/fixtures/QA-905-baseline-regex-instead-of-snapshot/baseline.test.mjs +124 -0
  43. package/src/skills/review-result/tests/index.yaml +5 -0
  44. 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.68",
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
- "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
- },
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
+ */