workflow-ai 1.0.57 → 1.0.58

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 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/hardlinks |
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 (symlinks to global)
70
- └── scripts/ # Automation scripts (hardlinks to global)
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 symlinked into projects.
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 # Symlink/hardlink management
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.57",
3
+ "version": "1.0.58",
4
4
  "description": "AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
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, createScriptHardlinks, ejectSkill, listSkillsWithStatus } from './junction-manager.mjs';
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] Initialize .workflow/ in target directory
18
- workflow run [options] Run the AI pipeline
19
- workflow update [path] Update global dir and recreate junctions/hardlinks
20
- workflow eject <skill> [path] Eject a skill (copy from global to project)
21
- workflow list [path] List skills with status (shared/ejected/project-only)
22
- workflow help Show this help
23
- workflow version Show version
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
- createScriptHardlinks(globalDir, scriptsDir);
107
- console.log('✅ Script hardlinks recreated');
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;
@@ -35,12 +35,14 @@ function copyDirectory(src, dest) {
35
35
  cpSync(src, dest, { recursive: true });
36
36
  }
37
37
 
38
- function copySkillsAndScripts(packageRoot) {
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
- copySkillsAndScripts(packageRoot);
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
- copySkillsAndScripts(packageRoot);
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
- copySkillsAndScripts(packageRoot);
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, createScriptHardlinks } from './junction-manager.mjs';
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 hardlinks
389
+ // Step 3: Create script junction
375
390
  const srcScriptsDest = join(workflowRoot, 'src', 'scripts');
376
- createScriptHardlinks(globalDir, srcScriptsDest);
377
- result.steps.push('Created script hardlinks from global dir → .workflow/src/scripts/');
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: Generate config files
409
+ // Step 6: Create config junction
395
410
  const configDest = join(workflowRoot, 'config');
396
- ensureDir(configDest);
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 QWEN.md
425
+ // Step 8: Generate CLAUDE.md, QWEN.md and .kilocodemodes
436
426
  generateClaudeMd(workflowRoot, projectRoot, packageRoot);
437
427
  generateQwenMd(workflowRoot, projectRoot, packageRoot);
438
- result.steps.push('Generated CLAUDE.md and QWEN.md from agent-templates');
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);
@@ -109,24 +109,59 @@ export function createSkillJunctions(globalDir, projectSkillsDir) {
109
109
  }
110
110
  }
111
111
 
112
- export function createScriptHardlinks(globalDir, projectScriptsDir) {
112
+ export function createScriptJunction(globalDir, projectScriptsDir) {
113
113
  const globalScriptsDir = join(globalDir, 'scripts');
114
114
  if (!existsSync(globalScriptsDir)) {
115
115
  return;
116
116
  }
117
- if (!existsSync(projectScriptsDir)) {
118
- mkdirSync(projectScriptsDir, { recursive: true });
117
+
118
+ // If local (ejected) scripts dir exists, don't overwrite
119
+ if (existsSync(projectScriptsDir) && !isJunction(projectScriptsDir)) {
120
+ return;
119
121
  }
120
122
 
121
- const files = readdirSync(globalScriptsDir);
122
- for (const file of files) {
123
- const targetPath = join(globalScriptsDir, file);
124
- const stats = lstatSync(targetPath);
125
- if (stats.isFile()) {
126
- const linkPath = join(projectScriptsDir, file);
127
- createHardlink(targetPath, linkPath);
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) {
@@ -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 => fs.existsSync(path.join(DONE_DIR, `${taskId}.md`)));
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 => fs.existsSync(path.join(DONE_DIR, `${depId}.md`)));
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,