@nitra/cursor 3.2.2 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.3.0] - 2026-06-01
4
+
5
+ ### Added
6
+
7
+ - flow: фази spec і plan (brainstorm human↔agent + agent↔agent --panel), trace простежує лінк plan→flow, verify м'яко попереджає про відсутній план
8
+
9
+ ## [3.2.3] - 2026-06-01
10
+
11
+ ### Fixed
12
+
13
+ - k8s `workloadAppLabel`: CronJob/workload без `spec` (чи `spec: null`) повертає `null` замість TypeError — `check k8s` більше не падає на неповному маніфесті
14
+
3
15
  ## [3.2.2] - 2026-06-01
4
16
 
5
17
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.2.2",
3
+ "version": "3.3.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Контракт Пасивного Турнікета n-cursor flow — IDE-агент сам пише код, але ізолює/перевіряє/релізить через flow init/verify/release.
2
+ description: Контракт Пасивного Турнікета n-cursor flow — IDE-агент сам пише код, але ізолює/планує/перевіряє/релізить через flow init/spec/plan/verify/release.
3
3
  globs:
4
4
  alwaysApply: true
5
5
  ---
@@ -22,9 +22,38 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
22
22
  Створює ізольований worktree (`.worktrees/<branch>/`) і стан задачі. Якщо ти
23
23
  вже в worktree — новий не вкладається.
24
24
 
25
- 2. **Пиши код** сам, кроками. TDD: спершу падаючі тести, тоді реалізація.
25
+ 2. **Spec (дизайн)** рекомендовано, не блокує. Brainstorm нашими термінами
26
+ (НЕ викликаючи superpowers):
26
27
 
27
- 3. **Перевіряй** після кожного логічного кроку:
28
+ - **human↔agent (дефолт):** питання по одному (перевага multiple-choice) →
29
+ 2-3 підходи з рекомендацією → дизайн секціями з апрувом людини;
30
+ - **agent↔agent:** `npx @nitra/cursor flow spec --panel` — панель персон
31
+ (architect/skeptic/tester) → суддя-синтез; презентуй синтез людині.
32
+
33
+ Збережи дизайн → `docs/specs/<date>-<slug>.md` (`kind: nitra-spec`,
34
+ `plan: null`), тоді зафіксуй:
35
+
36
+ ```
37
+ npx @nitra/cursor flow spec
38
+ ```
39
+
40
+ 3. **План** — декомпозиція дизайну в кроки:
41
+
42
+ - збережи `docs/plans/<date>-<slug>.md` (`kind: nitra-plan`, `spec:` → лінк на
43
+ spec, `flow:` → шлях `.flow.json`; секція `## Кроки` — нумерований список
44
+ `N. <task> — acceptance: <критерій>`);
45
+ - зафіксуй (`--panel` — для agent↔agent синтезу кроків):
46
+
47
+ ```
48
+ npx @nitra/cursor flow plan
49
+ ```
50
+
51
+ Команда дзеркалить кроки у `.flow.json` (`status: planned`) і запускає `trace`
52
+ для перевірки ланцюга spec↔plan↔flow. `verify` без плану лише попередить.
53
+
54
+ 4. **Пиши код** сам, кроками. TDD: спершу падаючі тести, тоді реалізація.
55
+
56
+ 5. **Перевіряй** після кожного логічного кроку:
28
57
 
29
58
  ```
30
59
  npx @nitra/cursor flow verify
@@ -33,10 +62,10 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
33
62
  Проганяє Quality Gates (lint + coverage). Повертає `0` (pass) або `1` із
34
63
  виводом проваленого gate.
35
64
 
36
- 4. **На провал** — виправ код за виводом і виклич `flow verify` знову. Максимум
65
+ 6. **На провал** — виправ код за виводом і виклич `flow verify` знову. Максимум
37
66
  **3 спроби**; якщо не вдається — зупинись і поклич людину.
38
67
 
39
- 5. **Фініш** — лише після зеленого `verify`:
68
+ 7. **Фініш** — лише після зеленого `verify`:
40
69
 
41
70
  ```
42
71
  npx @nitra/cursor flow release --bump <patch|minor|major> --section <Added|Changed|Fixed> --message "<що зроблено>"
@@ -49,3 +78,5 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
49
78
  - Не обходь `verify` — це єдиний критерій «готово».
50
79
  - Не редагуй `version` чи `CHANGELOG.md` вручну (це робить CI з `.changes/`).
51
80
  - Не коміть стан `.worktrees/<branch>.flow.json` у гілку — він поза git.
81
+ - Не лінкуй spec↔plan неконсистентно — тримай `spec.plan`/`plan.spec`/`plan.flow`
82
+ у front-matter; `flow spec`/`flow plan` перевіряють їх через `trace`.
@@ -4044,13 +4044,13 @@ function appLabelFromPodTemplate(spec) {
4044
4044
  export function workloadAppLabel(manifest) {
4045
4045
  const kind = manifest.kind
4046
4046
  if (typeof kind !== 'string') return null
4047
+ const spec = getNestedObject(manifest, 'spec')
4048
+ if (spec === null) return null
4047
4049
  if (kind === 'CronJob') {
4048
- const jobTemplate = getNestedObject(getNestedObject(manifest, 'spec'), 'jobTemplate')
4050
+ const jobTemplate = getNestedObject(spec, 'jobTemplate')
4049
4051
  const jobSpec = jobTemplate === null ? null : getNestedObject(jobTemplate, 'spec')
4050
4052
  return jobSpec === null ? null : appLabelFromPodTemplate(jobSpec)
4051
4053
  }
4052
- const spec = getNestedObject(manifest, 'spec')
4053
- if (spec === null) return null
4054
4054
  if (kind === 'Job') return appLabelFromPodTemplate(spec)
4055
4055
  return appLabelFromSpecSelector(spec)
4056
4056
  }
@@ -9,10 +9,14 @@
9
9
  */
10
10
  import { cancel, repair, resume, run } from './lib/active.mjs'
11
11
  import { init, release, verify } from './lib/commands.mjs'
12
+ import { plan } from './lib/plan.mjs'
13
+ import { spec } from './lib/spec.mjs'
12
14
 
13
15
  const USAGE = [
14
16
  'Usage:',
15
17
  ' npx @nitra/cursor flow init "<опис>" # Фасад A: worktree + .flow.json',
18
+ ' npx @nitra/cursor flow spec [--panel] # Фасад A: фаза дизайну → docs/specs/<…>',
19
+ ' npx @nitra/cursor flow plan [--panel] # Фасад A: фаза плану → docs/plans/<…> + state',
16
20
  ' npx @nitra/cursor flow verify # Фасад A: Quality Gates (pass/fail)',
17
21
  ' npx @nitra/cursor flow release # Фасад A: .changes + completion snapshot',
18
22
  ' npx @nitra/cursor flow run "<опис>" # Фасад B: повний 5-фазний цикл',
@@ -22,13 +26,13 @@ const USAGE = [
22
26
  ].join('\n')
23
27
 
24
28
  /** Підкоманди flow. */
25
- export const SUBCOMMANDS = ['init', 'verify', 'release', 'run', 'resume', 'cancel', 'repair']
29
+ export const SUBCOMMANDS = ['init', 'spec', 'plan', 'verify', 'release', 'run', 'resume', 'cancel', 'repair']
26
30
 
27
31
  /**
28
- * Усі handler-и реальні (Ф2 Турнікет + Ф4 Активний Раннер).
32
+ * Усі handler-и реальні (Ф1 Spec/Plan + Ф2 Турнікет + Ф4 Активний Раннер).
29
33
  * @type {Record<string, (rest: string[], deps: object) => Promise<number>>}
30
34
  */
31
- export const DEFAULT_HANDLERS = { init, verify, release, run, resume, cancel, repair }
35
+ export const DEFAULT_HANDLERS = { init, spec, plan, verify, release, run, resume, cancel, repair }
32
36
 
33
37
  /**
34
38
  * Точка входу `case 'flow'` у `bin/n-cursor.js`. Парсить підкоманду й
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Спільні утиліти фаз `spec`/`plan` (Пасивний Турнікет): резолв traceable-
3
+ * артефакту в `docs/<kind>/`, екстракт кроків плану зі секції `## Кроки`, і
4
+ * read-only перевірка цілісності ланцюга через `n-cursor trace` (`trace.mjs`).
5
+ *
6
+ * Лінки front-matter (`spec.plan`/`plan.spec`/`plan.flow`) пише сам агент за
7
+ * контрактом `flow.mdc` — тут лише ВЕРИФІКАЦІЯ (мутатора `trace link` нема).
8
+ */
9
+ import { existsSync, readdirSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ import { runTraceCli } from '../trace.mjs'
13
+
14
+ /**
15
+ * Найсвіжіший `docs/<kind>/*.md` (лексикографічно — дата у префіксі назви).
16
+ * @param {string} cwd корінь worktree
17
+ * @param {'specs' | 'plans'} kind підкаталог `docs`
18
+ * @returns {string | null} абсолютний шлях або null, якщо каталог/файли відсутні
19
+ */
20
+ export function resolveArtifact(cwd, kind) {
21
+ const dir = join(cwd, 'docs', kind)
22
+ if (!existsSync(dir)) return null
23
+ const md = readdirSync(dir)
24
+ .filter(f => f.endsWith('.md'))
25
+ .toSorted()
26
+ return md.length > 0 ? join(dir, md.at(-1)) : null
27
+ }
28
+
29
+ /** Маркер критерію приймання в рядку кроку (порівняння — case-insensitive). */
30
+ const ACCEPTANCE_MARK = '— acceptance:'
31
+ /** Лише цифри — перевірка нумерації кроку (лінійний, без backtracking). */
32
+ const DIGITS_RE = /^\d+$/u
33
+
34
+ /**
35
+ * Кроки зі секції плану — нумерований список `N. <task> — acceptance: <crit>`.
36
+ * Best-effort парсинг через `indexOf` (без regex-backtracking): рядки поза
37
+ * форматом ігноруються.
38
+ * @param {string} text вміст plan-doc
39
+ * @returns {{ task: string, acceptance?: string }[]} кроки у порядку появи
40
+ */
41
+ export function extractSteps(text) {
42
+ const steps = []
43
+ for (const raw of String(text).split('\n')) {
44
+ const line = raw.trim()
45
+ const dot = line.indexOf('. ')
46
+ if (dot <= 0 || !DIGITS_RE.test(line.slice(0, dot))) continue
47
+ const body = line.slice(dot + 2).trim()
48
+ const sep = body.toLowerCase().indexOf(ACCEPTANCE_MARK)
49
+ if (sep === -1) {
50
+ steps.push({ task: body })
51
+ } else {
52
+ steps.push({ task: body.slice(0, sep).trim(), acceptance: body.slice(sep + ACCEPTANCE_MARK.length).trim() })
53
+ }
54
+ }
55
+ return steps
56
+ }
57
+
58
+ /**
59
+ * Read-only перевірка цілісності ланцюга артефактів (не мутує — лише сигнал).
60
+ * @param {string} cwd корінь worktree
61
+ * @param {(cwd: string) => number} [runTrace] runner trace (0 — цілісно, 1 — розрив); ін'єкція для тестів
62
+ * @returns {boolean} true, якщо ланцюг цілісний
63
+ */
64
+ export function verifyTrace(cwd, runTrace) {
65
+ const run = runTrace ?? (c => runTraceCli([], { cwd: c, log: () => {} }))
66
+ return run(cwd) === 0
67
+ }
@@ -126,6 +126,13 @@ export async function verify(_rest, deps = {}) {
126
126
  const log = deps.log ?? console.error
127
127
  const fingerprint = deps.fingerprint ?? (() => worktreeFingerprint())
128
128
 
129
+ const statePath = flowStatePath(cwd)
130
+ const state = readState(statePath)
131
+ // М'які ворота: відсутній план — лише попередження, exit-код визначають gate-и.
132
+ if (state && !(state.plan?.length)) {
133
+ log('⚠️ verify: плану не зафіксовано (`flow plan`) — рекомендовано спершу сформувати план')
134
+ }
135
+
129
136
  const verdict = runReview({ run, cwd, fingerprint })
130
137
 
131
138
  for (const g of verdict.gates) {
@@ -134,8 +141,7 @@ export async function verify(_rest, deps = {}) {
134
141
  if (!verdict.pass && verdict.failedOutput) log(verdict.failedOutput)
135
142
  log(verdict.pass ? '✅ verify: усі gate-и пройдено' : '❌ verify: провалено')
136
143
 
137
- const statePath = flowStatePath(cwd)
138
- if (readState(statePath)) {
144
+ if (state) {
139
145
  recordTransition(
140
146
  { statePath, eventsPath: flowEventsPath(cwd) },
141
147
  { type: 'verify', pass: verdict.pass },
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Agent↔agent brainstorm (bmad party-mode + superpowers dispatching у наших
3
+ * термінах): персони-субагенти пропонують погляди, суддя-субагент синтезує одну
4
+ * відповідь. Спільний для фази `spec` (mode: 'spec' — підходи) і `plan`
5
+ * (mode: 'plan' — JSON-кроки). Перевикористовує runner-інтерфейс Фасада B
6
+ * (`runStep(prompt, opts) => { ok, output }`, як `planner.mjs`/`active.mjs`).
7
+ *
8
+ * HITL: panel лише ПОВЕРТАЄ синтез — апрув людини й збереження артефакту робить
9
+ * агент за контрактом `flow.mdc` (фіксація — окрема команда `flow spec`/`flow plan`).
10
+ */
11
+
12
+ /** Персони панелі: [ім'я, системний промпт]. */
13
+ const PERSONAS = [
14
+ ['architect', 'Ти — architect. Запропонуй найчистішу архітектуру розв’язання. Стисло, по суті.'],
15
+ ['skeptic', 'Ти — skeptic. Назви ризики, граничні випадки і що може піти не так. Стисло.'],
16
+ ['tester', 'Ти — tester. Опиши, які тести доведуть коректність. Стисло.']
17
+ ]
18
+
19
+ /**
20
+ * Промпт судді за режимом.
21
+ * @param {'spec' | 'plan'} mode режим синтезу
22
+ * @param {string} proposals склеєні думки персон
23
+ * @param {string} task опис задачі
24
+ * @returns {string} промпт судді
25
+ */
26
+ function judgePrompt(mode, proposals, task) {
27
+ const head =
28
+ mode === 'plan'
29
+ ? [
30
+ 'Синтезуй із думок персон ОДИН покроковий план реалізації.',
31
+ 'Кожен крок — ≤ 5 хв розробки, з критерієм приймання.',
32
+ 'Поверни ЛИШЕ JSON-масив без коментарів: [{ "task": "...", "acceptance": "..." }, ...].'
33
+ ]
34
+ : [
35
+ 'Синтезуй із думок персон 2-3 підходи до розв’язання з рекомендацією й коротким дизайном.',
36
+ 'Поверни людино-читабельний текст (Markdown).'
37
+ ]
38
+ return [...head, '', proposals, '', `Задача: ${task}`].join('\n')
39
+ }
40
+
41
+ /**
42
+ * Проводить панель і повертає синтез.
43
+ * @param {{ task: string, cwd: string, runner: { runStep: (p: string, o?: object) => { ok: boolean, output: string } | Promise<{ ok: boolean, output: string }> }, log?: (m: string) => void, mode?: 'spec' | 'plan' }} input ін'єкції
44
+ * @returns {Promise<{ task: string, acceptance?: string }[] | string | null>} кроки (plan), текст (spec) або null (фейл)
45
+ */
46
+ export async function runPanel({ task, cwd, runner, log = console.error, mode = 'plan' }) {
47
+ if (!runner) {
48
+ log('panel: нема runner — режим --panel недоступний')
49
+ return null
50
+ }
51
+ const proposals = await Promise.all(
52
+ PERSONAS.map(async ([name, sys]) => {
53
+ const r = await runner.runStep(`${sys}\n\nЗадача: ${task}`, { cwd })
54
+ return `### ${name}\n${r.ok ? r.output : '(порожньо)'}`
55
+ })
56
+ )
57
+ const judge = await runner.runStep(judgePrompt(mode, proposals.join('\n\n'), task), { cwd })
58
+ if (!judge.ok) {
59
+ log('panel: суддя-синтез завершився помилкою')
60
+ return null
61
+ }
62
+ if (mode === 'spec') return judge.output
63
+
64
+ const start = judge.output.indexOf('[')
65
+ const end = judge.output.lastIndexOf(']')
66
+ if (start === -1 || end === -1 || end < start) {
67
+ log('panel: суддя не повернув JSON-план')
68
+ return null
69
+ }
70
+ try {
71
+ return JSON.parse(judge.output.slice(start, end + 1))
72
+ } catch {
73
+ log('panel: невалідний JSON синтезу')
74
+ return null
75
+ }
76
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `flow plan [--panel] [<plan.md>]` — фаза плану (Пасивний Турнікет, lifecycle
3
+ * §4). Фіксує `docs/plans/<date>-<slug>.md`: дзеркалить кроки (`## Кроки`) у
4
+ * `.flow.json plan[]`, виставляє `status: planned`, верифікує ланцюг через
5
+ * read-only `trace`. Код не пише; лінки front-matter (`spec`/`flow`) пише агент.
6
+ *
7
+ * Brainstorm: human↔agent (агент пише plan-doc у діалозі) або agent↔agent
8
+ * (`--panel`: панель персон → суддя синтезує кроки).
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs'
11
+ import { cwd as processCwd } from 'node:process'
12
+
13
+ import { extractSteps, resolveArtifact, verifyTrace } from './artifact.mjs'
14
+ import { flowEventsPath } from './events.mjs'
15
+ import { parsePlan } from './planner.mjs'
16
+ import { runPanel } from './plan-panel.mjs'
17
+ import { createRunner } from './subagent-runner.mjs'
18
+ import { flowStatePath, readState, recordTransition } from './state-store.mjs'
19
+
20
+ /**
21
+ * @param {string[]} rest аргументи (`--panel`, опц. `<plan.md>`)
22
+ * @param {{ cwd?: string, log?: (m: string) => void, runner?: object, trace?: (cwd: string) => number, now?: () => number }} [deps] ін'єкції
23
+ * @returns {Promise<number>} exit code (0 ok, 1 нема стану/доку/невалідний план)
24
+ */
25
+ export async function plan(rest, deps = {}) {
26
+ const cwd = deps.cwd ?? processCwd()
27
+ const log = deps.log ?? console.error
28
+ const statePath = flowStatePath(cwd)
29
+ const state = readState(statePath)
30
+ if (!state) {
31
+ log('plan: стану нема — спершу `flow init`')
32
+ return 1
33
+ }
34
+ if (state.status !== 'spec' && !state.spec_doc) {
35
+ log('plan: дизайн ще не зафіксовано — рекомендовано спершу `flow spec` (не блокує)')
36
+ }
37
+
38
+ const doc = rest.find(a => a.endsWith('.md')) ?? resolveArtifact(cwd, 'plans')
39
+ let steps
40
+ if (rest.includes('--panel')) {
41
+ let runner = deps.runner
42
+ if (!runner) {
43
+ try {
44
+ runner = await createRunner(deps)
45
+ } catch (error) {
46
+ log(`plan: ${error.message}`)
47
+ return 1
48
+ }
49
+ }
50
+ steps = await runPanel({ task: state.branch, cwd, runner, log, mode: 'plan' })
51
+ if (!steps) return 1
52
+ } else {
53
+ if (!doc || !existsSync(doc)) {
54
+ log('plan: нема docs/plans/<date>-<slug>.md — спершу пройди brainstorm (див. flow.mdc)')
55
+ return 1
56
+ }
57
+ steps = extractSteps(readFileSync(doc, 'utf8'))
58
+ }
59
+
60
+ let normalized
61
+ try {
62
+ normalized = parsePlan(JSON.stringify(steps))
63
+ } catch (error) {
64
+ log(`plan: ${error.message}`)
65
+ return 1
66
+ }
67
+
68
+ if (!verifyTrace(cwd, deps.trace)) {
69
+ log('⚠️ plan: trace виявив розрив ланцюга — перевір лінки spec/plan/flow')
70
+ }
71
+
72
+ recordTransition(
73
+ { statePath, eventsPath: flowEventsPath(cwd) },
74
+ { type: 'plan', steps: normalized.length },
75
+ s => ({ ...s, plan: normalized, plan_doc: doc ?? null, status: 'planned' }),
76
+ deps.now ?? Date.now
77
+ )
78
+ log(`plan: зафіксовано ${normalized.length} кроків → status: planned`)
79
+ return 0
80
+ }
@@ -4,6 +4,9 @@
4
4
  * Нормалізує кроки до `{ step, task, status: 'pending', retry_count: 0 }`.
5
5
  */
6
6
 
7
+ /** Заборонені плейсхолдер-значення `task` (план із ними — не план, fail-closed). */
8
+ const PLACEHOLDER = /^(tbd|todo|fixme|\.\.\.|placeholder)$/i
9
+
7
10
  /**
8
11
  * Системно-користувацький промпт планувальника.
9
12
  * @param {string} task опис фічі
@@ -45,6 +48,10 @@ export function parsePlan(text) {
45
48
  if (!task || typeof task !== 'string') {
46
49
  throw new Error(`planner: крок ${i} без текстового поля task — fail-closed`)
47
50
  }
51
+ const trimmed = task.trim()
52
+ if (!trimmed || PLACEHOLDER.test(trimmed)) {
53
+ throw new Error(`planner: крок ${i} — placeholder/порожній task (${task}) — fail-closed`)
54
+ }
48
55
  const step = { step: i, task, status: 'pending', retry_count: 0 }
49
56
  if (s?.acceptance) step.acceptance = String(s.acceptance)
50
57
  return step
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `flow spec [--panel] [<spec.md>]` — фаза дизайну (Пасивний Турнікет, lifecycle
3
+ * §3). Фіксує `docs/specs/<date>-<slug>.md` (дизайн із brainstorm) у стані й
4
+ * верифікує ланцюг через read-only `trace`. Код не пише; лінки front-matter
5
+ * пише агент за контрактом `flow.mdc`.
6
+ *
7
+ * Brainstorm: human↔agent — у діалозі IDE-агента (контракт); agent↔agent —
8
+ * `--panel` (панель персон → суддя, синтез презентується людині).
9
+ */
10
+ import { existsSync } from 'node:fs'
11
+ import { cwd as processCwd } from 'node:process'
12
+
13
+ import { resolveArtifact, verifyTrace } from './artifact.mjs'
14
+ import { flowEventsPath } from './events.mjs'
15
+ import { runPanel } from './plan-panel.mjs'
16
+ import { createRunner } from './subagent-runner.mjs'
17
+ import { flowStatePath, readState, recordTransition } from './state-store.mjs'
18
+
19
+ /**
20
+ * @param {string[]} rest аргументи (`--panel`, опц. `<spec.md>`)
21
+ * @param {{ cwd?: string, log?: (m: string) => void, runner?: object, trace?: (cwd: string) => number, now?: () => number }} [deps] ін'єкції
22
+ * @returns {Promise<number>} exit code (0 ok, 1 нема стану/доку)
23
+ */
24
+ export async function spec(rest, deps = {}) {
25
+ const cwd = deps.cwd ?? processCwd()
26
+ const log = deps.log ?? console.error
27
+ const statePath = flowStatePath(cwd)
28
+ const state = readState(statePath)
29
+ if (!state) {
30
+ log('spec: стану нема — спершу `flow init`')
31
+ return 1
32
+ }
33
+
34
+ if (rest.includes('--panel')) {
35
+ let runner = deps.runner
36
+ if (!runner) {
37
+ try {
38
+ runner = await createRunner(deps)
39
+ } catch (error) {
40
+ log(`spec: ${error.message}`)
41
+ return 1
42
+ }
43
+ }
44
+ const synth = await runPanel({ task: state.branch, cwd, runner, log, mode: 'spec' })
45
+ if (synth) {
46
+ log('spec: панель синтезувала підходи (нижче) — збережи дизайн у docs/specs/ і повтори `flow spec`:')
47
+ log(typeof synth === 'string' ? synth : JSON.stringify(synth))
48
+ }
49
+ }
50
+
51
+ const doc = rest.find(a => a.endsWith('.md')) ?? resolveArtifact(cwd, 'specs')
52
+ if (!doc || !existsSync(doc)) {
53
+ log('spec: нема docs/specs/<date>-<slug>.md — спершу пройди brainstorm (див. flow.mdc)')
54
+ return 1
55
+ }
56
+ if (!verifyTrace(cwd, deps.trace)) {
57
+ log('⚠️ spec: trace виявив розрив ланцюга — перевір лінки front-matter (adr/spec/plan)')
58
+ }
59
+
60
+ recordTransition(
61
+ { statePath, eventsPath: flowEventsPath(cwd) },
62
+ { type: 'spec' },
63
+ s => ({ ...s, spec_doc: doc, status: 'spec' }),
64
+ deps.now ?? Date.now
65
+ )
66
+ log(`spec: зафіксовано ${doc} → status: spec`)
67
+ return 0
68
+ }
@@ -11,7 +11,7 @@ import { join } from 'node:path'
11
11
  import { cwd as processCwd } from 'node:process'
12
12
 
13
13
  /** Поля-лінки у front-matter, що утворюють ланцюг. */
14
- const LINK_FIELDS = ['adr', 'spec', 'plan', 'change', 'task']
14
+ const LINK_FIELDS = ['adr', 'spec', 'plan', 'flow', 'change', 'task']
15
15
 
16
16
  /** Каталоги з traceable-артефактами. */
17
17
  const DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']