workflow-ai 1.0.56 → 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.56",
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) {
package/src/runner.mjs CHANGED
@@ -1030,6 +1030,15 @@ class StageExecutor {
1030
1030
  }
1031
1031
  this.logger.info(`OUTPUT ↑`, stageId);
1032
1032
  }
1033
+
1034
+ // Логгируем stderr независимо от exit code
1035
+ if (stderr.trim()) {
1036
+ this.logger.warn(`STDERR ↓`, stageId);
1037
+ for (const line of stderr.trim().split('\n')) {
1038
+ this.logger.warn(` ${line}`, stageId);
1039
+ }
1040
+ this.logger.warn(`STDERR ↑`, stageId);
1041
+ }
1033
1042
  }
1034
1043
 
1035
1044
  // Парсим результат из вывода агента через ResultParser
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * archive-plan-tickets.js - Архивирует все done-тикеты указанного плана
5
+ *
6
+ * Использование:
7
+ * node archive-plan-tickets.js <plan_id>
8
+ *
9
+ * Пример:
10
+ * node archive-plan-tickets.js PLAN-002
11
+ * node archive-plan-tickets.js 2
12
+ */
13
+
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import { findProjectRoot } from 'workflow-ai/lib/find-root.mjs';
17
+ import { parseFrontmatter, serializeFrontmatter, normalizePlanId, extractPlanId, printResult } from 'workflow-ai/lib/utils.mjs';
18
+
19
+ const PROJECT_DIR = findProjectRoot();
20
+ const WORKFLOW_DIR = path.join(PROJECT_DIR, '.workflow');
21
+ const TICKETS_DIR = path.join(WORKFLOW_DIR, 'tickets');
22
+ const DONE_DIR = path.join(TICKETS_DIR, 'done');
23
+ const ARCHIVE_DIR = path.join(TICKETS_DIR, 'archive');
24
+
25
+ /**
26
+ * Архивирует все done-тикеты указанного плана
27
+ */
28
+ function archivePlanTickets(planId) {
29
+ if (!planId) {
30
+ return { status: 'error', error: 'Missing plan_id' };
31
+ }
32
+
33
+ if (!fs.existsSync(DONE_DIR)) {
34
+ return { status: 'ok', plan_id: planId, archived: 0, ticket_ids: '' };
35
+ }
36
+
37
+ if (!fs.existsSync(ARCHIVE_DIR)) {
38
+ fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
39
+ }
40
+
41
+ const files = fs.readdirSync(DONE_DIR).filter(f => f.endsWith('.md') && f !== '.gitkeep.md');
42
+ const archived = [];
43
+
44
+ for (const file of files) {
45
+ const filePath = path.join(DONE_DIR, file);
46
+ try {
47
+ const content = fs.readFileSync(filePath, 'utf8');
48
+ const { frontmatter, body } = parseFrontmatter(content);
49
+
50
+ const ticketPlanId = normalizePlanId(frontmatter.parent_plan);
51
+ if (ticketPlanId !== planId) continue;
52
+
53
+ const ticketId = frontmatter.id || file.replace('.md', '');
54
+
55
+ frontmatter.updated_at = new Date().toISOString();
56
+ frontmatter.archived_at = new Date().toISOString();
57
+
58
+ const destPath = path.join(ARCHIVE_DIR, file);
59
+ fs.writeFileSync(destPath, serializeFrontmatter(frontmatter) + body, 'utf8');
60
+ fs.unlinkSync(filePath);
61
+
62
+ archived.push(ticketId);
63
+ console.log(`[ARCHIVE] ${ticketId}: done → archive`);
64
+ } catch (e) {
65
+ console.error(`[ERROR] Failed to archive ${file}: ${e.message}`);
66
+ }
67
+ }
68
+
69
+ return {
70
+ status: 'ok',
71
+ plan_id: planId,
72
+ archived: archived.length,
73
+ ticket_ids: archived.join(',')
74
+ };
75
+ }
76
+
77
+ // Main entry point
78
+ const rawArgs = process.argv.slice(2);
79
+ let planId;
80
+
81
+ if (rawArgs.length >= 1) {
82
+ // Прямой вызов или pipeline context
83
+ const arg = rawArgs[0];
84
+ const planMatch = arg.match(/plan_id:\s*(\S+)/i);
85
+ planId = planMatch ? normalizePlanId(planMatch[1]) : normalizePlanId(arg);
86
+ } else {
87
+ planId = extractPlanId();
88
+ }
89
+
90
+ if (!planId) {
91
+ console.error('Usage: node archive-plan-tickets.js <plan_id>');
92
+ console.error('Example: node archive-plan-tickets.js PLAN-002');
93
+ printResult({ status: 'error', error: 'Missing plan_id argument' });
94
+ process.exit(1);
95
+ }
96
+
97
+ const result = archivePlanTickets(planId);
98
+ printResult(result);
99
+
100
+ if (result.status === 'error') {
101
+ process.exit(1);
102
+ }