workflow-ai 1.0.57 → 1.0.59
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/README.md +20 -9
- package/agent-templates/CLAUDE.md.tpl +8 -0
- package/agent-templates/QWEN.md.tpl +8 -0
- package/agent-templates/kilocodemodes.tpl +13 -0
- package/configs/pipeline.yaml +13 -0
- package/package.json +1 -1
- package/src/cli.mjs +42 -11
- package/src/global-dir.mjs +9 -4
- package/src/init.mjs +26 -35
- package/src/junction-manager.mjs +46 -11
- package/src/runner.mjs +60 -26
- package/src/scripts/check-conditions.js +9 -2
- package/src/scripts/check-relevance.js +308 -0
- package/src/scripts/get-next-id.js +110 -0
- package/src/scripts/move-to-review.js +9 -0
- package/src/scripts/pick-next-task.js +19 -0
package/README.md
CHANGED
|
@@ -26,8 +26,10 @@ workflow run
|
|
|
26
26
|
|---------|-------------|
|
|
27
27
|
| `workflow init [path] [--force]` | Initialize `.workflow/` directory with kanban board structure |
|
|
28
28
|
| `workflow run [options]` | Execute the AI pipeline |
|
|
29
|
-
| `workflow update [path]` | Update global dir and recreate junctions
|
|
29
|
+
| `workflow update [path]` | Update global dir and recreate junctions |
|
|
30
30
|
| `workflow eject <skill> [path]` | Eject a skill (copy from global to project) |
|
|
31
|
+
| `workflow eject-scripts [path]` | Eject scripts (copy from global to project) |
|
|
32
|
+
| `workflow eject-configs [path]` | Eject configs (copy from global to project) |
|
|
31
33
|
| `workflow list [path]` | List skills with status (shared/ejected/project-only) |
|
|
32
34
|
| `workflow help` | Show help |
|
|
33
35
|
| `workflow version` | Show version |
|
|
@@ -46,10 +48,7 @@ The `workflow init` command creates the `.workflow/` directory structure:
|
|
|
46
48
|
|
|
47
49
|
```
|
|
48
50
|
.workflow/
|
|
49
|
-
├── config/
|
|
50
|
-
│ ├── config.yaml # Workflow configuration
|
|
51
|
-
│ ├── pipeline.yaml # Pipeline stages and agents
|
|
52
|
-
│ └── ticket-movement-rules.yaml
|
|
51
|
+
├── config/ # → junction to ~/.workflow/configs/ (eject to customize)
|
|
53
52
|
├── plans/
|
|
54
53
|
│ ├── current/ # Current development plans
|
|
55
54
|
│ ├── templates/ # Plan templates with triggers (recurring plans)
|
|
@@ -66,8 +65,8 @@ The `workflow init` command creates the `.workflow/` directory structure:
|
|
|
66
65
|
├── metrics/ # Performance metrics
|
|
67
66
|
├── templates/ # Ticket/plan/report templates
|
|
68
67
|
└── src/
|
|
69
|
-
├── skills/ # Skill instructions (
|
|
70
|
-
└── scripts/ # Automation scripts (
|
|
68
|
+
├── skills/ # Skill instructions (junctions to global, per-skill)
|
|
69
|
+
└── scripts/ # Automation scripts (junction to global)
|
|
71
70
|
```
|
|
72
71
|
|
|
73
72
|
## Pipeline
|
|
@@ -119,10 +118,22 @@ Built-in skills for different task types:
|
|
|
119
118
|
| `execute-task` | Task execution |
|
|
120
119
|
| `review-result` | Result review against DoD |
|
|
121
120
|
|
|
122
|
-
Skills are stored globally in `~/.workflow/skills/` and
|
|
121
|
+
Skills are stored globally in `~/.workflow/skills/` and linked into projects via junctions.
|
|
123
122
|
|
|
124
123
|
Use `workflow eject <skill>` to copy a skill into the project for customization.
|
|
125
124
|
|
|
125
|
+
## Scripts
|
|
126
|
+
|
|
127
|
+
Scripts are stored globally in `~/.workflow/scripts/` and linked as a single junction into `.workflow/src/scripts/`.
|
|
128
|
+
|
|
129
|
+
Use `workflow eject-scripts` to copy scripts into the project for customization.
|
|
130
|
+
|
|
131
|
+
## Configs
|
|
132
|
+
|
|
133
|
+
Configs are stored globally in `~/.workflow/configs/` and linked as a single junction into `.workflow/config/`.
|
|
134
|
+
|
|
135
|
+
Use `workflow eject-configs` to copy configs into the project for customization.
|
|
136
|
+
|
|
126
137
|
## Plan Templates
|
|
127
138
|
|
|
128
139
|
Plan templates allow recurring plans to be created automatically. Templates live in `.workflow/plans/templates/` and contain trigger conditions in their frontmatter.
|
|
@@ -186,7 +197,7 @@ workflow-ai/
|
|
|
186
197
|
│ ├── runner.mjs # Core pipeline orchestrator
|
|
187
198
|
│ ├── init.mjs # Project initialization
|
|
188
199
|
│ ├── global-dir.mjs # Global ~/.workflow/ management
|
|
189
|
-
│ ├── junction-manager.mjs #
|
|
200
|
+
│ ├── junction-manager.mjs # Junction/symlink management
|
|
190
201
|
│ ├── wf-loader.mjs # Config loader
|
|
191
202
|
│ ├── lib/ # Utility libraries
|
|
192
203
|
│ └── tests/ # Test suite
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
| Выбор следующей задачи | `node .workflow/src/scripts/pick-next-task.js` |
|
|
22
22
|
| Перемещение готовых в ready | `node .workflow/src/scripts/move-to-ready.js` |
|
|
23
23
|
|
|
24
|
+
### Кастомизация (eject)
|
|
25
|
+
|
|
26
|
+
| Действие | Команда |
|
|
27
|
+
|----------|---------|
|
|
28
|
+
| Eject скила | `workflow eject <skill-name>` |
|
|
29
|
+
| Eject скриптов | `workflow eject-scripts` |
|
|
30
|
+
| Eject конфигов | `workflow eject-configs` |
|
|
31
|
+
|
|
24
32
|
## Workflow
|
|
25
33
|
|
|
26
34
|
1. **Планирование**: Создай план в `.workflow/plans/current/`
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
| Выбор следующей задачи | `node .workflow/src/scripts/pick-next-task.js` |
|
|
22
22
|
| Перемещение готовых в ready | `node .workflow/src/scripts/move-to-ready.js` |
|
|
23
23
|
|
|
24
|
+
### Кастомизация (eject)
|
|
25
|
+
|
|
26
|
+
| Действие | Команда |
|
|
27
|
+
|----------|---------|
|
|
28
|
+
| Eject скила | `workflow eject <skill-name>` |
|
|
29
|
+
| Eject скриптов | `workflow eject-scripts` |
|
|
30
|
+
| Eject конфигов | `workflow eject-configs` |
|
|
31
|
+
|
|
24
32
|
## Workflow
|
|
25
33
|
|
|
26
34
|
1. **Планирование**: Создай план в `.workflow/plans/current/`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
customModes:
|
|
2
|
+
- slug: manual-testing
|
|
3
|
+
name: manual-testing
|
|
4
|
+
roleDefinition: QA-инженер (тестировщик). Находит дефекты, проверяет качество реализации через браузер и desktop-инструменты. Составляет тест-планы и тест-кейсы, выполняет smoke/regression/exploratory/acceptance тестирование, фиксирует баги с evidence. НЕ пишет автотесты в коде, НЕ исправляет баги, НЕ нагружает систему.
|
|
5
|
+
whenToUse: Тикеты QA-* или с тегом тестирования. Запросы на smoke/regression/exploratory/acceptance тестирование. Необходимость проверки через реальный UI (браузер/desktop). Составление тест-планов и тест-кейсов. Баг-репорты.
|
|
6
|
+
groups:
|
|
7
|
+
- read
|
|
8
|
+
- edit
|
|
9
|
+
- browser
|
|
10
|
+
- command
|
|
11
|
+
- mcp
|
|
12
|
+
source: project
|
|
13
|
+
customInstructions: Используй скил manual-testing.
|
package/configs/pipeline.yaml
CHANGED
|
@@ -552,6 +552,8 @@ pipeline:
|
|
|
552
552
|
protected_files:
|
|
553
553
|
- ".workflow/plans/**" # Планы — только для чтения
|
|
554
554
|
- ".workflow/config/**" # Конфигурация
|
|
555
|
+
- pattern: ".workflow/tickets/**"
|
|
556
|
+
mode: structure # Защита только структуры (создание/удаление файлов)
|
|
555
557
|
|
|
556
558
|
# ===========================================================================
|
|
557
559
|
# Доверенные агенты (trusted_agents)
|
|
@@ -561,6 +563,17 @@ pipeline:
|
|
|
561
563
|
# ===========================================================================
|
|
562
564
|
trusted_agents:
|
|
563
565
|
- "script-check-templates" # Создаёт планы в plans/current/
|
|
566
|
+
- "script-*" # Все скрипты
|
|
567
|
+
|
|
568
|
+
# ===========================================================================
|
|
569
|
+
# Доверенные стейджи (trusted_stages)
|
|
570
|
+
# ===========================================================================
|
|
571
|
+
# Стейджи, которые могут создавать/удалять файлы в protected_files.
|
|
572
|
+
# Например, decompose-plan и decompose-gaps создают тикеты.
|
|
573
|
+
# ===========================================================================
|
|
574
|
+
trusted_stages:
|
|
575
|
+
- "decompose-plan"
|
|
576
|
+
- "decompose-gaps"
|
|
564
577
|
|
|
565
578
|
# ===========================================================================
|
|
566
579
|
# Настройки выполнения (execution)
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs';
|
|
|
6
6
|
import { join, dirname, resolve } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { getGlobalDir, refreshGlobalDir, ensureGlobalDir } from './global-dir.mjs';
|
|
9
|
-
import { createSkillJunctions,
|
|
9
|
+
import { createSkillJunctions, createScriptJunction, createConfigJunction, ejectSkill, ejectScripts, ejectConfigs, listSkillsWithStatus } from './junction-manager.mjs';
|
|
10
10
|
|
|
11
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const pkgPath = join(__dirname, '..', 'package.json');
|
|
@@ -14,13 +14,15 @@ const pkgPath = join(__dirname, '..', 'package.json');
|
|
|
14
14
|
const HELP_TEXT = `workflow-ai v1.0.0
|
|
15
15
|
|
|
16
16
|
Usage:
|
|
17
|
-
workflow init [path] [--force]
|
|
18
|
-
workflow run [options]
|
|
19
|
-
workflow update [path]
|
|
20
|
-
workflow eject <skill> [path]
|
|
21
|
-
workflow
|
|
22
|
-
workflow
|
|
23
|
-
workflow
|
|
17
|
+
workflow init [path] [--force] Initialize .workflow/ in target directory
|
|
18
|
+
workflow run [options] Run the AI pipeline
|
|
19
|
+
workflow update [path] Update global dir and recreate junctions
|
|
20
|
+
workflow eject <skill> [path] Eject a skill (copy from global to project)
|
|
21
|
+
workflow eject-scripts [path] Eject scripts (copy from global to project)
|
|
22
|
+
workflow eject-configs [path] Eject configs (copy from global to project)
|
|
23
|
+
workflow list [path] List skills with status (shared/ejected/project-only)
|
|
24
|
+
workflow help Show this help
|
|
25
|
+
workflow version Show version
|
|
24
26
|
|
|
25
27
|
Run options:
|
|
26
28
|
--plan <plan> Plan ID to execute
|
|
@@ -94,7 +96,6 @@ function runUpdate(args) {
|
|
|
94
96
|
const workflowRoot = getWorkflowRoot(projectRoot);
|
|
95
97
|
const packageRoot = getPackageRoot();
|
|
96
98
|
const globalDir = getGlobalDir();
|
|
97
|
-
|
|
98
99
|
refreshGlobalDir(packageRoot);
|
|
99
100
|
console.log('✅ Global dir updated (~/.workflow/)');
|
|
100
101
|
|
|
@@ -103,8 +104,12 @@ function runUpdate(args) {
|
|
|
103
104
|
console.log('✅ Skill junctions recreated');
|
|
104
105
|
|
|
105
106
|
const scriptsDir = join(workflowRoot, 'src', 'scripts');
|
|
106
|
-
|
|
107
|
-
console.log('✅ Script
|
|
107
|
+
createScriptJunction(globalDir, scriptsDir);
|
|
108
|
+
console.log('✅ Script junction recreated');
|
|
109
|
+
|
|
110
|
+
const configDir = join(workflowRoot, 'config');
|
|
111
|
+
createConfigJunction(globalDir, configDir);
|
|
112
|
+
console.log('✅ Config junction recreated');
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
function runEject(args) {
|
|
@@ -122,6 +127,26 @@ function runEject(args) {
|
|
|
122
127
|
console.log(`✅ Skill "${skillName}" ejected (copied to project)`);
|
|
123
128
|
}
|
|
124
129
|
|
|
130
|
+
function runEjectScripts(args) {
|
|
131
|
+
const projectRoot = resolve(args._[0] || process.cwd());
|
|
132
|
+
const workflowRoot = getWorkflowRoot(projectRoot);
|
|
133
|
+
const globalDir = getGlobalDir();
|
|
134
|
+
const scriptsDir = join(workflowRoot, 'src', 'scripts');
|
|
135
|
+
|
|
136
|
+
ejectScripts(globalDir, scriptsDir);
|
|
137
|
+
console.log('✅ Scripts ejected (copied to project)');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function runEjectConfigs(args) {
|
|
141
|
+
const projectRoot = resolve(args._[0] || process.cwd());
|
|
142
|
+
const workflowRoot = getWorkflowRoot(projectRoot);
|
|
143
|
+
const globalDir = getGlobalDir();
|
|
144
|
+
const configDir = join(workflowRoot, 'config');
|
|
145
|
+
|
|
146
|
+
ejectConfigs(globalDir, configDir);
|
|
147
|
+
console.log('✅ Configs ejected (copied to project)');
|
|
148
|
+
}
|
|
149
|
+
|
|
125
150
|
function runList(args) {
|
|
126
151
|
const projectRoot = resolve(args._[0] || process.cwd());
|
|
127
152
|
const workflowRoot = getWorkflowRoot(projectRoot);
|
|
@@ -184,6 +209,12 @@ export function run(argv) {
|
|
|
184
209
|
case 'eject':
|
|
185
210
|
runEject(args);
|
|
186
211
|
break;
|
|
212
|
+
case 'eject-scripts':
|
|
213
|
+
runEjectScripts(args);
|
|
214
|
+
break;
|
|
215
|
+
case 'eject-configs':
|
|
216
|
+
runEjectConfigs(args);
|
|
217
|
+
break;
|
|
187
218
|
case 'list':
|
|
188
219
|
runList(args);
|
|
189
220
|
break;
|
package/src/global-dir.mjs
CHANGED
|
@@ -35,12 +35,14 @@ function copyDirectory(src, dest) {
|
|
|
35
35
|
cpSync(src, dest, { recursive: true });
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
function
|
|
38
|
+
function copySkillsScriptsAndConfigs(packageRoot) {
|
|
39
39
|
const globalDir = getGlobalDir();
|
|
40
40
|
const srcSkills = join(packageRoot, 'src', 'skills');
|
|
41
41
|
const srcScripts = join(packageRoot, 'src', 'scripts');
|
|
42
|
+
const srcConfigs = join(packageRoot, 'configs');
|
|
42
43
|
const destSkills = join(globalDir, 'skills');
|
|
43
44
|
const destScripts = join(globalDir, 'scripts');
|
|
45
|
+
const destConfigs = join(globalDir, 'configs');
|
|
44
46
|
|
|
45
47
|
if (existsSync(srcSkills)) {
|
|
46
48
|
copyDirectory(srcSkills, destSkills);
|
|
@@ -48,6 +50,9 @@ function copySkillsAndScripts(packageRoot) {
|
|
|
48
50
|
if (existsSync(srcScripts)) {
|
|
49
51
|
copyDirectory(srcScripts, destScripts);
|
|
50
52
|
}
|
|
53
|
+
if (existsSync(srcConfigs)) {
|
|
54
|
+
copyDirectory(srcConfigs, destConfigs);
|
|
55
|
+
}
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
export function isGlobalDirStale(packageRoot) {
|
|
@@ -67,7 +72,7 @@ export function ensureGlobalDir(packageRoot) {
|
|
|
67
72
|
const globalDir = getGlobalDir();
|
|
68
73
|
if (!existsSync(globalDir)) {
|
|
69
74
|
mkdirSync(globalDir, { recursive: true });
|
|
70
|
-
|
|
75
|
+
copySkillsScriptsAndConfigs(packageRoot);
|
|
71
76
|
const version = getPackageVersion(packageRoot);
|
|
72
77
|
writeFileSync(join(globalDir, '.version'), version);
|
|
73
78
|
return;
|
|
@@ -75,7 +80,7 @@ export function ensureGlobalDir(packageRoot) {
|
|
|
75
80
|
if (isGlobalDirStale(packageRoot)) {
|
|
76
81
|
const version = getPackageVersion(packageRoot);
|
|
77
82
|
writeFileSync(join(globalDir, '.version'), version);
|
|
78
|
-
|
|
83
|
+
copySkillsScriptsAndConfigs(packageRoot);
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
|
|
@@ -86,5 +91,5 @@ export function refreshGlobalDir(packageRoot) {
|
|
|
86
91
|
}
|
|
87
92
|
const version = getPackageVersion(packageRoot);
|
|
88
93
|
writeFileSync(join(globalDir, '.version'), version);
|
|
89
|
-
|
|
94
|
+
copySkillsScriptsAndConfigs(packageRoot);
|
|
90
95
|
}
|
package/src/init.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { join, resolve, dirname, basename } from 'node:path';
|
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { getGlobalDir, ensureGlobalDir } from './global-dir.mjs';
|
|
6
|
-
import { createSkillJunctions,
|
|
6
|
+
import { createSkillJunctions, createScriptJunction, createConfigJunction } from './junction-manager.mjs';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Возвращает абсолютный путь к корню npm-пакета через import.meta.url.
|
|
@@ -230,6 +230,21 @@ function generateQwenMd(workflowRoot, projectRoot, packageRoot) {
|
|
|
230
230
|
writeFileSync(destPath, content, 'utf-8');
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Генерирует .kilocodemodes из шаблона agent-templates/kilocodemodes.tpl.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} projectRoot - Путь к корню проекта
|
|
237
|
+
* @param {string} packageRoot - Путь к корню пакета
|
|
238
|
+
*/
|
|
239
|
+
function generateKilocodemodes(projectRoot, packageRoot) {
|
|
240
|
+
const templatePath = join(packageRoot, 'agent-templates', 'kilocodemodes.tpl');
|
|
241
|
+
const destPath = join(projectRoot, '.kilocodemodes');
|
|
242
|
+
|
|
243
|
+
if (existsSync(templatePath)) {
|
|
244
|
+
copyFileSync(templatePath, destPath);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
233
248
|
/**
|
|
234
249
|
* Обновляет .gitignore, добавляя указанные строки.
|
|
235
250
|
*
|
|
@@ -248,6 +263,7 @@ function updateGitignore(projectRoot) {
|
|
|
248
263
|
'QWEN.md',
|
|
249
264
|
'CLAUDE.md',
|
|
250
265
|
'.kilocode/',
|
|
266
|
+
'.kilocodemodes',
|
|
251
267
|
];
|
|
252
268
|
|
|
253
269
|
let currentContent = '';
|
|
@@ -355,7 +371,6 @@ export function initProject(targetPath = process.cwd(), options = {}) {
|
|
|
355
371
|
'reports',
|
|
356
372
|
'logs',
|
|
357
373
|
'templates',
|
|
358
|
-
'config',
|
|
359
374
|
'src/skills'
|
|
360
375
|
];
|
|
361
376
|
|
|
@@ -371,10 +386,10 @@ export function initProject(targetPath = process.cwd(), options = {}) {
|
|
|
371
386
|
createSkillJunctions(globalDir, srcSkillsDest);
|
|
372
387
|
result.steps.push('Created skill junctions from global dir → .workflow/src/skills/');
|
|
373
388
|
|
|
374
|
-
// Step 3: Create script
|
|
389
|
+
// Step 3: Create script junction
|
|
375
390
|
const srcScriptsDest = join(workflowRoot, 'src', 'scripts');
|
|
376
|
-
|
|
377
|
-
result.steps.push('Created script
|
|
391
|
+
createScriptJunction(globalDir, srcScriptsDest);
|
|
392
|
+
result.steps.push('Created script junction from global dir → .workflow/src/scripts/');
|
|
378
393
|
|
|
379
394
|
// Step 5: Copy templates (3 templates)
|
|
380
395
|
const templatesSrc = join(packageRoot, 'templates');
|
|
@@ -391,35 +406,10 @@ export function initProject(targetPath = process.cwd(), options = {}) {
|
|
|
391
406
|
}
|
|
392
407
|
result.steps.push('Copied 3 templates → .workflow/templates/');
|
|
393
408
|
|
|
394
|
-
// Step 6:
|
|
409
|
+
// Step 6: Create config junction
|
|
395
410
|
const configDest = join(workflowRoot, 'config');
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
// config.yaml — only if not exists (fresh only)
|
|
399
|
-
const configYamlDest = join(configDest, 'config.yaml');
|
|
400
|
-
if (!existsSync(configYamlDest) || force) {
|
|
401
|
-
const configYamlSrc = join(packageRoot, 'configs', 'config.yaml');
|
|
402
|
-
if (existsSync(configYamlSrc)) {
|
|
403
|
-
copyFile(configYamlSrc, configYamlDest);
|
|
404
|
-
result.steps.push('Generated config.yaml (fresh)');
|
|
405
|
-
}
|
|
406
|
-
} else {
|
|
407
|
-
result.warnings.push('config.yaml already exists, skipped (use --force to overwrite)');
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// pipeline.yaml — always
|
|
411
|
-
const pipelineSrc = join(packageRoot, 'configs', 'pipeline.yaml');
|
|
412
|
-
if (existsSync(pipelineSrc)) {
|
|
413
|
-
copyFile(pipelineSrc, join(configDest, 'pipeline.yaml'));
|
|
414
|
-
result.steps.push('Generated pipeline.yaml (overwritten)');
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// ticket-movement-rules.yaml — always
|
|
418
|
-
const movementRulesSrc = join(packageRoot, 'configs', 'ticket-movement-rules.yaml');
|
|
419
|
-
if (existsSync(movementRulesSrc)) {
|
|
420
|
-
copyFile(movementRulesSrc, join(configDest, 'ticket-movement-rules.yaml'));
|
|
421
|
-
result.steps.push('Generated ticket-movement-rules.yaml (overwritten)');
|
|
422
|
-
}
|
|
411
|
+
createConfigJunction(globalDir, configDest);
|
|
412
|
+
result.steps.push('Created config junction from global dir → .workflow/config/');
|
|
423
413
|
|
|
424
414
|
// Step 7: Create .kilocode symlinks
|
|
425
415
|
const symlinkResult = createKilocodeSymlinks(projectRoot, force);
|
|
@@ -432,10 +422,11 @@ export function initProject(targetPath = process.cwd(), options = {}) {
|
|
|
432
422
|
result.errors.push(symlinkResult.warning || 'Failed to create .kilocode symlinks');
|
|
433
423
|
}
|
|
434
424
|
|
|
435
|
-
// Step 8: Generate CLAUDE.md and
|
|
425
|
+
// Step 8: Generate CLAUDE.md, QWEN.md and .kilocodemodes
|
|
436
426
|
generateClaudeMd(workflowRoot, projectRoot, packageRoot);
|
|
437
427
|
generateQwenMd(workflowRoot, projectRoot, packageRoot);
|
|
438
|
-
|
|
428
|
+
generateKilocodemodes(projectRoot, packageRoot);
|
|
429
|
+
result.steps.push('Generated CLAUDE.md, QWEN.md and .kilocodemodes from agent-templates');
|
|
439
430
|
|
|
440
431
|
// Step 9: Update .gitignore
|
|
441
432
|
updateGitignore(projectRoot);
|
package/src/junction-manager.mjs
CHANGED
|
@@ -109,24 +109,59 @@ export function createSkillJunctions(globalDir, projectSkillsDir) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
export function
|
|
112
|
+
export function createScriptJunction(globalDir, projectScriptsDir) {
|
|
113
113
|
const globalScriptsDir = join(globalDir, 'scripts');
|
|
114
114
|
if (!existsSync(globalScriptsDir)) {
|
|
115
115
|
return;
|
|
116
116
|
}
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
|
|
118
|
+
// If local (ejected) scripts dir exists, don't overwrite
|
|
119
|
+
if (existsSync(projectScriptsDir) && !isJunction(projectScriptsDir)) {
|
|
120
|
+
return;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
createJunction(globalScriptsDir, projectScriptsDir);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createConfigJunction(globalDir, projectConfigDir) {
|
|
127
|
+
const globalConfigDir = join(globalDir, 'configs');
|
|
128
|
+
if (!existsSync(globalConfigDir)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// If local (ejected) config dir exists, don't overwrite
|
|
133
|
+
if (existsSync(projectConfigDir) && !isJunction(projectConfigDir)) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
createJunction(globalConfigDir, projectConfigDir);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function ejectConfigs(globalDir, projectConfigDir) {
|
|
141
|
+
const globalConfigDir = join(globalDir, 'configs');
|
|
142
|
+
|
|
143
|
+
if (!existsSync(globalConfigDir)) {
|
|
144
|
+
throw new Error('Configs do not exist in global dir');
|
|
129
145
|
}
|
|
146
|
+
|
|
147
|
+
removeJunction(projectConfigDir);
|
|
148
|
+
cpSync(globalConfigDir, projectConfigDir, { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function ejectScripts(globalDir, projectScriptsDir) {
|
|
152
|
+
const globalScriptsDir = join(globalDir, 'scripts');
|
|
153
|
+
|
|
154
|
+
if (!existsSync(globalScriptsDir)) {
|
|
155
|
+
throw new Error('Scripts do not exist in global dir');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
removeJunction(projectScriptsDir);
|
|
159
|
+
cpSync(globalScriptsDir, projectScriptsDir, { recursive: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @deprecated Use createScriptJunction instead */
|
|
163
|
+
export function createScriptHardlinks(globalDir, projectScriptsDir) {
|
|
164
|
+
createScriptJunction(globalDir, projectScriptsDir);
|
|
130
165
|
}
|
|
131
166
|
|
|
132
167
|
export function ejectSkill(skillName, globalDir, projectSkillsDir) {
|
package/src/runner.mjs
CHANGED
|
@@ -543,30 +543,46 @@ class ResultParser {
|
|
|
543
543
|
// FileGuard — защита файлов от несанкционированного изменения агентами
|
|
544
544
|
// ============================================================================
|
|
545
545
|
class FileGuard {
|
|
546
|
-
constructor(patterns, projectRoot = process.cwd(), trustedAgents = []) {
|
|
547
|
-
|
|
548
|
-
this.patterns = (patterns || []).map(p => p.replace(/\\/g, '/'));
|
|
549
|
-
this.enabled = this.patterns.length > 0;
|
|
546
|
+
constructor(patterns, projectRoot = process.cwd(), trustedAgents = [], trustedStages = []) {
|
|
547
|
+
this.enabled = patterns && patterns.length > 0;
|
|
550
548
|
this.snapshots = new Map();
|
|
549
|
+
this.patterns = (patterns || []).map(p => {
|
|
550
|
+
if (typeof p === 'string') {
|
|
551
|
+
return { pattern: p.replace(/\\/g, '/'), mode: 'full' };
|
|
552
|
+
}
|
|
553
|
+
return { pattern: p.pattern.replace(/\\/g, '/'), mode: p.mode || 'full' };
|
|
554
|
+
});
|
|
551
555
|
// projectRoot — корневая директория проекта, относительно которой указаны паттерны
|
|
552
556
|
this.projectRoot = projectRoot;
|
|
553
557
|
// Доверенные агенты — для них FileGuard не откатывает изменения
|
|
554
558
|
this.trustedAgents = trustedAgents;
|
|
559
|
+
// Доверенные стейджи — для них FileGuard не откатывает изменения
|
|
560
|
+
this.trustedStages = trustedStages;
|
|
555
561
|
}
|
|
556
562
|
|
|
557
563
|
/**
|
|
558
|
-
* Проверяет, является ли агент доверенным (пропускает FileGuard)
|
|
564
|
+
* Проверяет, является ли агент или стейдж доверенным (пропускает FileGuard)
|
|
559
565
|
* Поддерживает glob-паттерны: "script-*" соответствует "script-move", "script-pick" и т.д.
|
|
560
566
|
* @param {string} agentId - ID агента
|
|
567
|
+
* @param {string} [stageId] - ID стейджа (опционально)
|
|
561
568
|
* @returns {boolean}
|
|
562
569
|
*/
|
|
563
|
-
isTrusted(agentId) {
|
|
564
|
-
|
|
570
|
+
isTrusted(agentId, stageId) {
|
|
571
|
+
// Проверка по trustedAgents (glob-паттерны)
|
|
572
|
+
const agentMatch = this.trustedAgents.some(pattern => {
|
|
565
573
|
if (pattern.endsWith('*')) {
|
|
566
574
|
return agentId.startsWith(pattern.slice(0, -1));
|
|
567
575
|
}
|
|
568
576
|
return agentId === pattern;
|
|
569
577
|
});
|
|
578
|
+
if (agentMatch) return true;
|
|
579
|
+
|
|
580
|
+
// Проверка по trustedStages (точное совпадение)
|
|
581
|
+
if (stageId && this.trustedStages.includes(stageId)) {
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return false;
|
|
570
586
|
}
|
|
571
587
|
|
|
572
588
|
/**
|
|
@@ -575,9 +591,8 @@ class FileGuard {
|
|
|
575
591
|
* @returns {boolean}
|
|
576
592
|
*/
|
|
577
593
|
matchesProtected(filePath) {
|
|
578
|
-
// Получаем относительный путь от projectRoot для сопоставления с паттерном
|
|
579
594
|
const relativePath = path.relative(this.projectRoot, filePath).replace(/\\/g, '/');
|
|
580
|
-
return this.patterns.some(
|
|
595
|
+
return this.patterns.some(p => this._matchGlob(relativePath, p.pattern));
|
|
581
596
|
}
|
|
582
597
|
|
|
583
598
|
/**
|
|
@@ -649,20 +664,26 @@ class FileGuard {
|
|
|
649
664
|
if (!this.enabled) return;
|
|
650
665
|
this.snapshots.clear();
|
|
651
666
|
|
|
652
|
-
for (const pattern of this.patterns) {
|
|
667
|
+
for (const { pattern, mode } of this.patterns) {
|
|
653
668
|
if (!pattern.includes('*')) {
|
|
654
|
-
// Прямой путь к файлу — преобразуем в абсолютный
|
|
655
669
|
const absolutePath = path.resolve(this.projectRoot, pattern);
|
|
656
670
|
if (fs.existsSync(absolutePath)) {
|
|
657
|
-
|
|
671
|
+
if (mode === 'structure') {
|
|
672
|
+
this.snapshots.set(absolutePath, { hash: this._hashFile(absolutePath), content: fs.readFileSync(absolutePath, null), mode: 'structure' });
|
|
673
|
+
} else {
|
|
674
|
+
this.snapshots.set(absolutePath, this._hashFile(absolutePath));
|
|
675
|
+
}
|
|
658
676
|
}
|
|
659
677
|
} else {
|
|
660
|
-
// Glob-паттерн: сканируем базовую директорию относительно projectRoot
|
|
661
678
|
const baseDir = path.resolve(this.projectRoot, this._getBaseDir(pattern));
|
|
662
679
|
const files = this._getAllFiles(baseDir);
|
|
663
680
|
for (const filePath of files) {
|
|
664
681
|
if (this.matchesProtected(filePath)) {
|
|
665
|
-
|
|
682
|
+
if (mode === 'structure') {
|
|
683
|
+
this.snapshots.set(filePath, { hash: this._hashFile(filePath), content: fs.readFileSync(filePath, null), mode: 'structure' });
|
|
684
|
+
} else {
|
|
685
|
+
this.snapshots.set(filePath, this._hashFile(filePath));
|
|
686
|
+
}
|
|
666
687
|
}
|
|
667
688
|
}
|
|
668
689
|
}
|
|
@@ -681,18 +702,30 @@ class FileGuard {
|
|
|
681
702
|
|
|
682
703
|
const violations = [];
|
|
683
704
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
705
|
+
for (const [filePath, snapshot] of this.snapshots) {
|
|
706
|
+
const mode = snapshot.mode || 'full';
|
|
707
|
+
if (mode === 'structure') {
|
|
708
|
+
if (!fs.existsSync(filePath)) {
|
|
709
|
+
violations.push(filePath);
|
|
710
|
+
console.warn(`[FileGuard] WARNING: Protected file deleted: ${filePath}`);
|
|
711
|
+
try {
|
|
712
|
+
fs.writeFileSync(filePath, snapshot.content);
|
|
713
|
+
console.warn(`[FileGuard] WARNING: Restored deleted file: ${filePath}`);
|
|
714
|
+
} catch (err) {
|
|
715
|
+
console.error(`[FileGuard] ERROR: Failed to restore ${filePath}: ${err.message}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
} else {
|
|
719
|
+
const currentHash = this._hashFile(filePath);
|
|
720
|
+
if (currentHash !== snapshot) {
|
|
721
|
+
violations.push(filePath);
|
|
722
|
+
console.warn(`[FileGuard] WARNING: Protected file modified: ${filePath}`);
|
|
723
|
+
this._rollbackFile(filePath);
|
|
724
|
+
}
|
|
691
725
|
}
|
|
692
726
|
}
|
|
693
727
|
|
|
694
|
-
|
|
695
|
-
for (const pattern of this.patterns) {
|
|
728
|
+
for (const { pattern, mode } of this.patterns) {
|
|
696
729
|
const baseDir = pattern.includes('*')
|
|
697
730
|
? path.resolve(this.projectRoot, this._getBaseDir(pattern))
|
|
698
731
|
: path.resolve(this.projectRoot, pattern);
|
|
@@ -848,8 +881,8 @@ class StageExecutor {
|
|
|
848
881
|
console.log(` Skill: ${stage.skill}`);
|
|
849
882
|
}
|
|
850
883
|
|
|
851
|
-
// Снимаем snapshot защищённых файлов перед выполнением (кроме trusted agents)
|
|
852
|
-
const skipGuard = this.fileGuard && this.fileGuard.isTrusted(agentId);
|
|
884
|
+
// Снимаем snapshot защищённых файлов перед выполнением (кроме trusted agents и trusted stages)
|
|
885
|
+
const skipGuard = this.fileGuard && this.fileGuard.isTrusted(agentId, stageId);
|
|
853
886
|
if (this.fileGuard && !skipGuard) {
|
|
854
887
|
this.fileGuard.takeSnapshot();
|
|
855
888
|
}
|
|
@@ -1195,7 +1228,8 @@ class PipelineRunner {
|
|
|
1195
1228
|
// Инициализация FileGuard для защиты файлов от изменений агентами
|
|
1196
1229
|
const protectedPatterns = this.pipeline.protected_files || [];
|
|
1197
1230
|
const trustedAgents = this.pipeline.trusted_agents || [];
|
|
1198
|
-
|
|
1231
|
+
const trustedStages = this.pipeline.trusted_stages || [];
|
|
1232
|
+
this.fileGuard = new FileGuard(protectedPatterns, projectRoot, trustedAgents, trustedStages);
|
|
1199
1233
|
this.projectRoot = projectRoot;
|
|
1200
1234
|
this.currentExecutor = null;
|
|
1201
1235
|
|
|
@@ -36,6 +36,7 @@ const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
|
|
|
36
36
|
const BACKLOG_DIR = path.join(TICKETS_DIR, 'backlog');
|
|
37
37
|
const READY_DIR = path.join(TICKETS_DIR, 'ready');
|
|
38
38
|
const DONE_DIR = path.join(TICKETS_DIR, 'done');
|
|
39
|
+
const ARCHIVE_DIR = path.join(TICKETS_DIR, 'archive');
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* Проверяет одно условие тикета
|
|
@@ -53,7 +54,10 @@ function checkCondition(condition) {
|
|
|
53
54
|
case 'tasks_completed': {
|
|
54
55
|
if (!value || (Array.isArray(value) && value.length === 0)) return true;
|
|
55
56
|
const ids = Array.isArray(value) ? value : [value];
|
|
56
|
-
return ids.every(taskId =>
|
|
57
|
+
return ids.every(taskId =>
|
|
58
|
+
fs.existsSync(path.join(DONE_DIR, `${taskId}.md`)) ||
|
|
59
|
+
fs.existsSync(path.join(ARCHIVE_DIR, `${taskId}.md`))
|
|
60
|
+
);
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
case 'date_after':
|
|
@@ -76,7 +80,10 @@ function checkCondition(condition) {
|
|
|
76
80
|
*/
|
|
77
81
|
function checkDependencies(dependencies) {
|
|
78
82
|
if (!dependencies || dependencies.length === 0) return true;
|
|
79
|
-
return dependencies.every(depId =>
|
|
83
|
+
return dependencies.every(depId =>
|
|
84
|
+
fs.existsSync(path.join(DONE_DIR, `${depId}.md`)) ||
|
|
85
|
+
fs.existsSync(path.join(ARCHIVE_DIR, `${depId}.md`))
|
|
86
|
+
);
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/**
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* check-relevance.js - Скрипт проверки актуальности тикета
|
|
5
|
+
*
|
|
6
|
+
* Использование:
|
|
7
|
+
* node check-relevance.js <path-to-ticket>
|
|
8
|
+
*
|
|
9
|
+
* Вывод:
|
|
10
|
+
* ---RESULT---
|
|
11
|
+
* verdict: relevant|irrelevant
|
|
12
|
+
* reason: ...
|
|
13
|
+
* ---RESULT---
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "fs";
|
|
17
|
+
import path from "path";
|
|
18
|
+
import YAML from "../lib/js-yaml.mjs";
|
|
19
|
+
import { findProjectRoot } from "../lib/find-root.mjs";
|
|
20
|
+
import {
|
|
21
|
+
parseFrontmatter,
|
|
22
|
+
serializeFrontmatter,
|
|
23
|
+
getLastReviewStatus,
|
|
24
|
+
} from "../lib/utils.mjs";
|
|
25
|
+
|
|
26
|
+
const PROJECT_DIR = findProjectRoot();
|
|
27
|
+
const WORKFLOW_DIR = path.join(PROJECT_DIR, ".workflow");
|
|
28
|
+
const TICKETS_DIR = path.join(WORKFLOW_DIR, "tickets");
|
|
29
|
+
|
|
30
|
+
const VALID_STATUSES = [
|
|
31
|
+
"backlog",
|
|
32
|
+
"ready",
|
|
33
|
+
"in-progress",
|
|
34
|
+
"blocked",
|
|
35
|
+
"review",
|
|
36
|
+
"done",
|
|
37
|
+
"archive",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function getCurrentStatus(ticketPath) {
|
|
41
|
+
const fileName = path.basename(ticketPath);
|
|
42
|
+
for (const status of VALID_STATUSES) {
|
|
43
|
+
const statusDir = path.join(TICKETS_DIR, status);
|
|
44
|
+
const expectedPath = path.join(statusDir, fileName);
|
|
45
|
+
if (ticketPath === expectedPath) {
|
|
46
|
+
return status;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractPlanId(parentPlan) {
|
|
53
|
+
if (!parentPlan) return null;
|
|
54
|
+
const basename = path.basename(parentPlan, ".md");
|
|
55
|
+
const match = basename.match(/^PLAN-(\d+)$/i);
|
|
56
|
+
if (match) {
|
|
57
|
+
return `PLAN-${String(parseInt(match[1], 10)).padStart(3, "0")}`;
|
|
58
|
+
}
|
|
59
|
+
return basename;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getDodCompletion(content) {
|
|
63
|
+
const dodSectionMatch = content.match(/## Критерии готовности.*?\n([\s\S]*?)(?=\n## |\n# |\z)/i);
|
|
64
|
+
if (!dodSectionMatch) return { completed: false, total: 0, checked: 0 };
|
|
65
|
+
|
|
66
|
+
const section = dodSectionMatch[1];
|
|
67
|
+
const checkedMatches = section.match(/\[x\]/gi) || [];
|
|
68
|
+
const uncheckedMatches = section.match(/\[ \]/gi) || [];
|
|
69
|
+
|
|
70
|
+
const total = checkedMatches.length + uncheckedMatches.length;
|
|
71
|
+
const completed = total > 0 && uncheckedMatches.length === 0;
|
|
72
|
+
|
|
73
|
+
return { completed, total, checked: checkedMatches.length };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hasResultSection(content) {
|
|
77
|
+
const resultPatterns = [
|
|
78
|
+
/##\s*Result/gi,
|
|
79
|
+
/##\s*Результат/gi,
|
|
80
|
+
/##\s*Результат выполнения/gi,
|
|
81
|
+
];
|
|
82
|
+
return resultPatterns.some((pattern) => pattern.test(content));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getBlockedSection(content) {
|
|
86
|
+
const blockedMatch = content.match(/##\s*Блокировки\s*\n([\s\S]*?)(?=\n## |\n# |\z)/i);
|
|
87
|
+
return blockedMatch ? blockedMatch[1].trim() : "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findTicketInColumns(ticketId) {
|
|
91
|
+
for (const status of VALID_STATUSES) {
|
|
92
|
+
const statusDir = path.join(TICKETS_DIR, status);
|
|
93
|
+
const ticketPath = path.join(statusDir, `${ticketId}.md`);
|
|
94
|
+
if (fs.existsSync(ticketPath)) {
|
|
95
|
+
return status;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function checkRelevance(ticketPath) {
|
|
102
|
+
if (!fs.existsSync(ticketPath)) {
|
|
103
|
+
return {
|
|
104
|
+
verdict: "relevant",
|
|
105
|
+
reason: "file_not_found",
|
|
106
|
+
error: `Ticket file not found: ${ticketPath}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let content;
|
|
111
|
+
try {
|
|
112
|
+
content = fs.readFileSync(ticketPath, "utf8");
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return {
|
|
115
|
+
verdict: "relevant",
|
|
116
|
+
reason: "read_error",
|
|
117
|
+
error: `Failed to read ticket: ${e.message}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let frontmatter, body;
|
|
122
|
+
try {
|
|
123
|
+
({ frontmatter, body } = parseFrontmatter(content));
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return {
|
|
126
|
+
verdict: "relevant",
|
|
127
|
+
reason: "invalid_frontmatter",
|
|
128
|
+
warning: `Failed to parse frontmatter: ${e.message}. Treating as relevant (fail-safe).`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const currentStatus = getCurrentStatus(ticketPath);
|
|
133
|
+
const fullContent = body;
|
|
134
|
+
|
|
135
|
+
const lastReview = getLastReviewStatus(fullContent);
|
|
136
|
+
if (lastReview === "skipped") {
|
|
137
|
+
return { verdict: "irrelevant", reason: "already_skipped" };
|
|
138
|
+
}
|
|
139
|
+
if (lastReview === "failed") {
|
|
140
|
+
return { verdict: "relevant", reason: "review_failed_needs_rework" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (frontmatter.blocked === true || frontmatter.blocked === "true") {
|
|
144
|
+
return { verdict: "relevant", reason: "blocked" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const blockedSection = getBlockedSection(fullContent);
|
|
148
|
+
const hasActiveBlockers = blockedSection.length > 0 && !blockedSection.includes("нет");
|
|
149
|
+
if (hasActiveBlockers) {
|
|
150
|
+
return { verdict: "relevant", reason: "blocked" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parentPlan = frontmatter.parent_plan;
|
|
154
|
+
if (parentPlan) {
|
|
155
|
+
const planId = extractPlanId(parentPlan);
|
|
156
|
+
if (planId) {
|
|
157
|
+
const planPath = path.join(WORKFLOW_DIR, "plans", "current", `${planId}.md`);
|
|
158
|
+
if (fs.existsSync(planPath)) {
|
|
159
|
+
try {
|
|
160
|
+
const planContent = fs.readFileSync(planPath, "utf8");
|
|
161
|
+
const { frontmatter: planFm } = parseFrontmatter(planContent);
|
|
162
|
+
const planStatus = planFm.status;
|
|
163
|
+
if (["completed", "archived", "cancelled"].includes(planStatus)) {
|
|
164
|
+
return { verdict: "irrelevant", reason: "plan_inactive" };
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
// fail-safe: treat as relevant
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
// fail-safe: treat as relevant
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// fail-safe: parent_plan is empty
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const dod = getDodCompletion(fullContent);
|
|
178
|
+
const hasResult = hasResultSection(fullContent);
|
|
179
|
+
|
|
180
|
+
if (dod.completed && hasResult) {
|
|
181
|
+
if (lastReview === "passed") {
|
|
182
|
+
return { verdict: "irrelevant", reason: "dod_completed" };
|
|
183
|
+
} else if (lastReview === null) {
|
|
184
|
+
return { verdict: "relevant", reason: "needs_review" };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const dependencies = frontmatter.dependencies || [];
|
|
189
|
+
if (dependencies.length > 0) {
|
|
190
|
+
for (const dep of dependencies) {
|
|
191
|
+
const depStatus = findTicketInColumns(dep);
|
|
192
|
+
if (depStatus === null) {
|
|
193
|
+
return { verdict: "irrelevant", reason: "dependencies_inactive" };
|
|
194
|
+
}
|
|
195
|
+
if (depStatus === "blocked") {
|
|
196
|
+
try {
|
|
197
|
+
const blockedDir = path.join(TICKETS_DIR, "blocked", `${dep}.md`);
|
|
198
|
+
const blockedContent = fs.readFileSync(blockedDir, "utf8");
|
|
199
|
+
const { body: blockedBody } = parseFrontmatter(blockedContent);
|
|
200
|
+
if (blockedBody.toLowerCase().includes("неактуально")) {
|
|
201
|
+
return { verdict: "irrelevant", reason: "dependencies_inactive" };
|
|
202
|
+
}
|
|
203
|
+
} catch (e) {
|
|
204
|
+
// ignore
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { verdict: "relevant", reason: "all_checks_passed" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function addSkippedReview(ticketPath, reason) {
|
|
214
|
+
const now = new Date();
|
|
215
|
+
const date = now.toISOString().slice(0, 10);
|
|
216
|
+
|
|
217
|
+
let content;
|
|
218
|
+
try {
|
|
219
|
+
content = fs.readFileSync(ticketPath, "utf8");
|
|
220
|
+
} catch (e) {
|
|
221
|
+
throw new Error(`Failed to read ticket: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let { frontmatter, body } = parseFrontmatter(content);
|
|
225
|
+
|
|
226
|
+
const reviewSectionMatch = body.match(/##\s*Ревью\s*\n([\s\S]*)/i);
|
|
227
|
+
let newBody;
|
|
228
|
+
|
|
229
|
+
if (reviewSectionMatch) {
|
|
230
|
+
const reviewContent = reviewSectionMatch[1];
|
|
231
|
+
const lines = reviewContent.split("\n");
|
|
232
|
+
let insertIndex = 0;
|
|
233
|
+
for (let i = 0; i < lines.length; i++) {
|
|
234
|
+
if (lines[i].trim().startsWith("|") && lines[i].includes("---")) {
|
|
235
|
+
insertIndex = i + 1;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const newRow = `| ${date} | ⏭️ skipped | ${reason} |`;
|
|
241
|
+
lines.splice(insertIndex, 0, newRow);
|
|
242
|
+
newBody = body.slice(0, reviewSectionMatch.index) + lines.join("\n");
|
|
243
|
+
} else {
|
|
244
|
+
const reviewTable = `\n## Ревью\n\n| Дата | Статус | Самари |\n|------|--------|--------|\n| ${date} | ⏭️ skipped | ${reason} |\n`;
|
|
245
|
+
newBody = body.trimEnd() + reviewTable;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const newContent = serializeFrontmatter(frontmatter) + newBody;
|
|
249
|
+
fs.writeFileSync(ticketPath, newContent, "utf8");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function main() {
|
|
253
|
+
const args = process.argv.slice(2);
|
|
254
|
+
let ticketPath;
|
|
255
|
+
|
|
256
|
+
if (args.length === 0) {
|
|
257
|
+
console.error("Usage: node check-relevance.js <path-to-ticket>");
|
|
258
|
+
console.error("Example: node check-relevance.js .workflow/tickets/in-progress/IMPL-001.md");
|
|
259
|
+
process.exit(1);
|
|
260
|
+
} else if (args.length === 1) {
|
|
261
|
+
const prompt = args[0];
|
|
262
|
+
const ticketMatch = prompt.match(/ticket_id:\s*(\S+)/);
|
|
263
|
+
if (ticketMatch) {
|
|
264
|
+
const ticketId = ticketMatch[1];
|
|
265
|
+
ticketPath = path.join(TICKETS_DIR, "in-progress", `${ticketId}.md`);
|
|
266
|
+
} else {
|
|
267
|
+
ticketPath = args[0];
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
ticketPath = args[0];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!path.isAbsolute(ticketPath)) {
|
|
274
|
+
ticketPath = path.resolve(process.cwd(), ticketPath);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const result = await checkRelevance(ticketPath);
|
|
278
|
+
|
|
279
|
+
if (result.warning && !result.error) {
|
|
280
|
+
console.error(`[WARNING] ${result.warning}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (result.error) {
|
|
284
|
+
console.error(`[ERROR] ${result.error}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (result.verdict === "irrelevant") {
|
|
288
|
+
try {
|
|
289
|
+
addSkippedReview(ticketPath, result.reason);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
console.error(`[ERROR] Failed to add review entry: ${e.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
console.log("---RESULT---");
|
|
296
|
+
console.log(`verdict: ${result.verdict}`);
|
|
297
|
+
console.log(`reason: ${result.reason}`);
|
|
298
|
+
console.log("---RESULT---");
|
|
299
|
+
|
|
300
|
+
if (result.error) {
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
main().catch((e) => {
|
|
306
|
+
console.error("[FATAL]", e.message);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* get-next-id.js - Универсальный генератор ID для тикетов, планов и других артефактов
|
|
5
|
+
*
|
|
6
|
+
* Использование:
|
|
7
|
+
* node get-next-id.js --prefix TASK --dir tickets
|
|
8
|
+
* node get-next-id.js --prefix PLAN --dir plans
|
|
9
|
+
*
|
|
10
|
+
* Вывод: следующий ID через ---RESULT---, например: TASK-007
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { findProjectRoot } from "workflow-ai/lib/find-root.mjs";
|
|
16
|
+
import { printResult } from "workflow-ai/lib/utils.mjs";
|
|
17
|
+
|
|
18
|
+
const PROJECT_DIR = findProjectRoot();
|
|
19
|
+
|
|
20
|
+
function parseArgs() {
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
let prefix = null;
|
|
23
|
+
let dir = null;
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
if (args[i] === "--prefix" && i + 1 < args.length) {
|
|
27
|
+
prefix = args[i + 1];
|
|
28
|
+
i++;
|
|
29
|
+
} else if (args[i] === "--dir" && i + 1 < args.length) {
|
|
30
|
+
dir = args[i + 1];
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { prefix, dir };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findMaxNumber(targetDir, prefix) {
|
|
39
|
+
let maxNum = 0;
|
|
40
|
+
const regex = new RegExp(`^${prefix}-(\\d{3})\\.md$`, "i");
|
|
41
|
+
|
|
42
|
+
function scanDirectory(dir) {
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
48
|
+
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const fullPath = path.join(dir, entry.name);
|
|
51
|
+
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
scanDirectory(fullPath);
|
|
54
|
+
} else if (entry.isFile()) {
|
|
55
|
+
const match = entry.name.match(regex);
|
|
56
|
+
if (match) {
|
|
57
|
+
const num = parseInt(match[1], 10);
|
|
58
|
+
if (num > maxNum) {
|
|
59
|
+
maxNum = num;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
scanDirectory(targetDir);
|
|
67
|
+
return maxNum;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatNumber(num) {
|
|
71
|
+
return num.toString().padStart(3, "0");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main() {
|
|
75
|
+
const { prefix, dir } = parseArgs();
|
|
76
|
+
|
|
77
|
+
if (!prefix || !dir) {
|
|
78
|
+
console.error("Usage: node get-next-id.js --prefix <PREFIX> --dir <DIRECTORY>");
|
|
79
|
+
console.error("Example: node get-next-id.js --prefix TASK --dir tickets");
|
|
80
|
+
printResult({
|
|
81
|
+
status: "error",
|
|
82
|
+
error: "Missing required arguments: --prefix and --dir",
|
|
83
|
+
});
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let targetDir;
|
|
88
|
+
|
|
89
|
+
if (dir === "tickets") {
|
|
90
|
+
targetDir = path.join(PROJECT_DIR, ".workflow", "tickets");
|
|
91
|
+
} else if (dir === "plans") {
|
|
92
|
+
targetDir = path.join(PROJECT_DIR, ".workflow", "plans");
|
|
93
|
+
} else {
|
|
94
|
+
targetDir = path.join(PROJECT_DIR, dir);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!fs.existsSync(targetDir)) {
|
|
98
|
+
const nextId = `${prefix}-001`;
|
|
99
|
+
printResult({ status: "success", id: nextId });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const maxNum = findMaxNumber(targetDir, prefix);
|
|
104
|
+
const nextNum = maxNum + 1;
|
|
105
|
+
const nextId = `${prefix}-${formatNumber(nextNum)}`;
|
|
106
|
+
|
|
107
|
+
printResult({ status: "success", id: nextId });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
main();
|
|
@@ -27,6 +27,7 @@ const PROJECT_DIR = findProjectRoot();
|
|
|
27
27
|
const TICKETS_DIR = path.join(PROJECT_DIR, ".workflow", "tickets");
|
|
28
28
|
const IN_PROGRESS_DIR = path.join(TICKETS_DIR, "in-progress");
|
|
29
29
|
const REVIEW_DIR = path.join(TICKETS_DIR, "review");
|
|
30
|
+
const ARCHIVE_DIR = path.join(TICKETS_DIR, "archive");
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Парсит ticket_id из промпта (контекста pipeline runner)
|
|
@@ -54,6 +55,7 @@ function moveToReview(ticketId) {
|
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
57
|
const donePath = path.join(TICKETS_DIR, "done", `${ticketId}.md`);
|
|
58
|
+
const archivePath = path.join(ARCHIVE_DIR, `${ticketId}.md`);
|
|
57
59
|
if (fs.existsSync(donePath)) {
|
|
58
60
|
// Проверяем, есть ли у тикета ревью — если нет, перемещаем в review/
|
|
59
61
|
const doneContent = fs.readFileSync(donePath, "utf8");
|
|
@@ -81,6 +83,13 @@ function moveToReview(ticketId) {
|
|
|
81
83
|
reason: `${ticketId} already in done/ with review`,
|
|
82
84
|
};
|
|
83
85
|
}
|
|
86
|
+
if (fs.existsSync(archivePath)) {
|
|
87
|
+
return {
|
|
88
|
+
status: "skipped",
|
|
89
|
+
ticket_id: ticketId,
|
|
90
|
+
reason: `${ticketId} already in archive/`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
84
93
|
return {
|
|
85
94
|
status: "error",
|
|
86
95
|
ticket_id: ticketId,
|
|
@@ -543,6 +543,25 @@ function pickNextTicket(planId) {
|
|
|
543
543
|
return false;
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
+
// Обнаружение и удаление дубликатов: тикет не должен существовать в других колонках
|
|
547
|
+
const ticketFileName = `${ticket.id}.md`;
|
|
548
|
+
const otherDirs = [DONE_DIR, IN_PROGRESS_DIR, REVIEW_DIR, BLOCKED_DIR];
|
|
549
|
+
const duplicateDir = otherDirs.find(dir =>
|
|
550
|
+
fs.existsSync(path.join(dir, ticketFileName))
|
|
551
|
+
);
|
|
552
|
+
if (duplicateDir) {
|
|
553
|
+
const dirName = path.basename(duplicateDir);
|
|
554
|
+
logger.warn(`Duplicate detected: ${ticket.id} exists in ready/ and ${dirName}/. Moving ready/ copy to archive/`);
|
|
555
|
+
const archivePath = path.join(ARCHIVE_DIR, ticketFileName);
|
|
556
|
+
try {
|
|
557
|
+
fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
|
|
558
|
+
fs.renameSync(ticket.filePath, archivePath);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
logger.error(`Failed to archive duplicate ${ticket.id}: ${err.message}`);
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
|
|
546
565
|
return true;
|
|
547
566
|
});
|
|
548
567
|
|