@nitra/cursor 1.41.0 → 2.0.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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.0] - 2026-05-31
4
+
5
+ ### Added
6
+
7
+ - n-cursor flow (v2.0-a Ф0-Ф1.1): каркас dispatcher + CLI `case 'flow'` (init/verify/release/run/resume/cancel/repair — поки stub-и), Capability Router з явною декларацією моделі (native/polyfill, default→polyfill лише за наявного runner-а), crash-safe state-store (.flow.json sibling, atomic temp+fsync+rename, fail-closed на corruption). 29 unit-тестів (withTmpDir).
8
+ - n-cursor flow v2.0-a Ф1.2-Ф1.4: WAL-журнал .events.jsonl (append-only, торований останній рядок толерується), per-branch lock через reuse withLock із fail-closed override (додано опцію onWaitTimeout у спільний with-lock, back-compat), cleanupFlowSiblings (.flow.json/.events.jsonl/lock). recordTransition (WAL: подія до зміни статусу). +22 тести.
9
+ - n-cursor flow v2.0-a Ф2 (verify): reviewer.mjs — Level-1 «Суддя» проганяє lint+coverage gates через ін'єктований runner (fail-fast, fingerprint на повному pass через reuse worktree-fingerprint); flow verify — Пасивний Турнікет: запускає gates у поточному worktree, записує результати+fingerprint у наявний стан (recordTransition), exit 0/1. +11 тестів.
10
+ - n-cursor flow v2.0-a Ф2·2: flow init (n-cursor worktree add + detect-existing-isolation через git rev-parse, init .flow.json з base_commit; reuse worktreePaths/sanitizeBranch) + flow release (n-cursor change + completion snapshot у стан і task record) + snapshot.mjs (buildCompletionSnapshot/upsertSummaryBlock/writeSummaryToTaskRecord). reviewer тепер захоплює вивід проваленого gate. Пасивний Турнікет (init/verify/release) завершено. +18 тестів.
11
+ - n-cursor flow Ф2·4: bundled-правило flow (матеріалізується як .cursor/rules/n-flow.mdc) — контракт Пасивного Турнікета для IDE-агентів (Cursor/Claude Code): init -> сам пишеш код (TDD) -> verify (3 спроби на фейл) -> release. alwaysApply; pure-doc + стандартний fix.mjs (runStandardRule). Авто-дискавериться, активується при sync. Завершує Фасад A повністю.
12
+ - n-cursor flow v2.0-a Ф3 (двигун-блоки): SubagentRunner (§15.1) — selectBackend (sdk>claude>cursor за ANTHROPIC_API_KEY/PATH, fail коли нічого нема), cliRunner (claude/cursor-agent -p, CLI-auth), sdkRunner (claude-agent-sdk, dynamic import); planner — parsePlan (валідація+нормалізація, fail-closed на невалідному) + generatePlan. Усе з мок-ін'єкцією (нуль реальних API/SDK у тестах). +24 тести.
13
+ - n-cursor flow v2.0-a Ф4·a: executor.mjs — серце Активного Раннера. Виконує план покроково: мікропромпт зі стану (не історія) -> спавн субагента -> verify -> commit ЛИШЕ після зеленого (commit-інваріант §4.1.7) -> repair (per-step retry) -> на вичерпанні HITL (blocked-on-human + structured question). microprompt/patchStep — pure. Усе з ін'єкцією runner/verify/commit (нуль реальних LLM/git у тестах). +6 тестів.
14
+ - n-cursor flow v2.0-a Ф4·b: Активний Раннер end-to-end. flow run (ensureWorktree -> planner -> executor; exit 0 done / 1 fail / 2 blocked-on-human), flow resume (safe-resume git reset + HITL-відповіді як hint + свіжі спроби), flow cancel (cleanup sibling-ів), flow repair (--discard-step-work / діагностика). ensureWorktree витягнуто як спільне для init/run. Усі 7 підкоманд реальні. +12 тестів (103 у dispatcher). v2.0-a (Фасади A+B) функціонально завершено.
15
+ - n-cursor flow v2.0-a: (1) budget guard для flow run --autonomous (withBudget обгортає runner, BudgetExceeded при перевищенні maxApiCalls з .n-cursor.json flow.autonomous; на abort -> status failed, exit 1). (2) Ф1.4 борг закрито: worktree remove тепер reuse-кличе cleanupFlowSiblings (flow-sibling-и не осиротіють). Заодно виправлено передіснуючі switch-case-braces у worktree-cli. +4 тести.
16
+ - n-cursor flow v2.0-b: команда n-cursor trace — наскрізна простежуваність (§5.4/§7). Читає front-matter артефактів у docs/{tasks,specs,plans,adr}, будує ланцюг за лінками adr/spec/plan/change/task, флагує розриви (лінк на неіснуючий файл) з exit 1; --json для machine-readable. parseFrontMatter/analyze/render — pure, FS ін'єктовний. Підтверджено на власних spec<->plan. +10 тестів.
17
+
18
+ ### Changed
19
+
20
+ - n-cursor flow v2.0.0 (major): Dual-Mode Dispatcher — Пасивний Турнікет (flow init/verify/release) + Активний Раннер (flow run/resume/cancel/repair) + n-cursor trace + docs/{specs,plans} міграція
21
+
3
22
  ## [1.41.0] - 2026-05-31
4
23
 
5
24
  ### Changed
package/bin/n-cursor.js CHANGED
@@ -1534,6 +1534,22 @@ try {
1534
1534
 
1535
1535
  break
1536
1536
  }
1537
+ case 'flow': {
1538
+ // n-cursor flow — Dual-Mode Dispatcher (spec §8): Пасивний Турнікет
1539
+ // (init/verify/release) + Активний Раннер (run) навколо .flow.json.
1540
+ const { runFlowCli } = await import('../scripts/dispatcher/index.mjs')
1541
+ process.exitCode = await runFlowCli(args)
1542
+
1543
+ break
1544
+ }
1545
+ case 'trace': {
1546
+ // n-cursor trace — наскрізна простежуваність (spec §5.4/§7): граф
1547
+ // ADR↔spec↔plan↔change за front-matter + флаг розривів. exit 1 на розрив.
1548
+ const { runTraceCli } = await import('../scripts/dispatcher/trace.mjs')
1549
+ process.exitCode = runTraceCli(args)
1550
+
1551
+ break
1552
+ }
1537
1553
  case undefined:
1538
1554
  case '': {
1539
1555
  await runSync()
@@ -1543,7 +1559,7 @@ try {
1543
1559
  default: {
1544
1560
  console.error(`❌ Невідома команда: ${command}`)
1545
1561
  console.error(
1546
- ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, change, release, skill, worktree, lint-ci`
1562
+ ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, change, release, skill, worktree, lint-ci, flow, trace`
1547
1563
  )
1548
1564
  process.exitCode = 1
1549
1565
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.41.0",
3
+ "version": "2.0.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,18 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
2
+ import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
3
+
4
+ /**
5
+ * Запускає правило: applies → JS-concerns → policy → mdc-refs (через runStandardRule).
6
+ * Pure-doc contract-правило: програмних concern-ів немає, тож по суті валідує `.mdc`.
7
+ * @param {import('../../scripts/lib/run-standard-rule.mjs').RuleContext} [ctx] контекст прогону (walkCache тощо)
8
+ * @returns {Promise<number>} 0 — OK, 1 — порушення
9
+ */
10
+ export function run(ctx) {
11
+ return runStandardRule(import.meta.dirname, ctx)
12
+ }
13
+
14
+ if (isRunAsCli(import.meta.url)) {
15
+ // Standalone: bun rules/flow/fix.mjs — повний еквівалент `npx @nitra/cursor fix flow`.
16
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ process.exit(await runRuleCli(import.meta.dirname))
18
+ }
@@ -0,0 +1,51 @@
1
+ ---
2
+ description: Контракт Пасивного Турнікета n-cursor flow — IDE-агент сам пише код, але ізолює/перевіряє/релізить через flow init/verify/release.
3
+ globs:
4
+ alwaysApply: true
5
+ ---
6
+
7
+ # n-cursor flow — Пасивний Турнікет (контракт виконавця)
8
+
9
+ `n-cursor flow` — це Dual-Mode Dispatcher. В інтерактивному середовищі (Cursor
10
+ Composer, Claude Code) працює **Пасивний Турнікет**: ти, агент, **сам пишеш
11
+ код**, а `n-cursor` лише ізолює роботу, **судить** її якість і релізить. Жодного
12
+ прихованого спавну субагентів — керуєш ти.
13
+
14
+ ## Контракт (виконуй у цьому порядку)
15
+
16
+ 1. **Старт** — на початку задачі:
17
+
18
+ ```
19
+ npx @nitra/cursor flow init <branch> "<опис>"
20
+ ```
21
+
22
+ Створює ізольований worktree (`.worktrees/<branch>/`) і стан задачі. Якщо ти
23
+ вже в worktree — новий не вкладається.
24
+
25
+ 2. **Пиши код** сам, кроками. TDD: спершу падаючі тести, тоді реалізація.
26
+
27
+ 3. **Перевіряй** після кожного логічного кроку:
28
+
29
+ ```
30
+ npx @nitra/cursor flow verify
31
+ ```
32
+
33
+ Проганяє Quality Gates (lint + coverage). Повертає `0` (pass) або `1` із
34
+ виводом проваленого gate.
35
+
36
+ 4. **На провал** — виправ код за виводом і виклич `flow verify` знову. Максимум
37
+ **3 спроби**; якщо не вдається — зупинись і поклич людину.
38
+
39
+ 5. **Фініш** — лише після зеленого `verify`:
40
+
41
+ ```
42
+ npx @nitra/cursor flow release --bump <patch|minor|major> --section <Added|Changed|Fixed> --message "<що зроблено>"
43
+ ```
44
+
45
+ Генерує `.changes/` і пише completion snapshot. Гілка готова до merge.
46
+
47
+ ## Чого не роби
48
+
49
+ - Не обходь `verify` — це єдиний критерій «готово».
50
+ - Не редагуй `version` чи `CHANGELOG.md` вручну (це робить CI з `.changes/`).
51
+ - Не коміть стан `.worktrees/<branch>.flow.json` у гілку — він поза git.
@@ -0,0 +1 @@
1
+ {}
@@ -2,7 +2,7 @@
2
2
  description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
3
3
  globs: "**/package.json,**/src/conn/**"
4
4
  alwaysApply: false
5
- version: '1.12'
5
+ version: '1.14'
6
6
  ---
7
7
 
8
8
  ## Підтримувані версії баз даних
@@ -57,16 +57,12 @@ const sql = format(`MERGE INTO t USING (VALUES %s) AS s(id, date, data) ON ...`,
57
57
  await pgWrite.unsafe(sql)
58
58
 
59
59
  // ✅ UNNEST — 3 параметри незалежно від розміру batch; план стабільний і може кешуватись
60
- const ids = batch.map(r => r.id)
61
- const dates = batch.map(r => r.date)
62
- const data = batch.map(r => JSON.stringify(r.data))
63
-
64
60
  await pgWrite`
65
61
  WITH s(id, date, data) AS (
66
62
  SELECT * FROM unnest(
67
- ${ids}::int[],
68
- ${dates}::date[],
69
- ${data}::jsonb[]
63
+ ${pgWrite.array(batch.map(r => r.id), 'int4')},
64
+ ${pgWrite.array(batch.map(r => r.date), 'date')},
65
+ ${pgWrite.array(batch.map(r => r.data), 'jsonb')}
70
66
  )
71
67
  )
72
68
  MERGE INTO my_table AS t
@@ -98,6 +94,22 @@ await pgWrite`
98
94
  `
99
95
  ```
100
96
 
97
+ ## JSONB-параметри: без `JSON.stringify`
98
+
99
+ Bun SQL серіалізує JS-об'єкти й масиви у JSON автоматично — викликати `JSON.stringify` перед передачею в `::jsonb` / `::jsonb[]` **заборонено**.
100
+
101
+ ```javascript
102
+ // ❌ зайвий JSON.stringify — подвійна серіалізація або зайвий рядок
103
+ await sql`INSERT INTO events (details) VALUES (${JSON.stringify(detailsForEvent)}::jsonb)`
104
+
105
+ await sql`SELECT * FROM unnest(${sql.array(batch.map(r => JSON.stringify(r.data)), 'jsonb')})`
106
+
107
+ // ✅ об'єкт/масив передається напряму
108
+ await sql`INSERT INTO events (details) VALUES (${detailsForEvent}::jsonb)`
109
+
110
+ await sql`SELECT * FROM unnest(${sql.array(batch.map(r => r.data), 'jsonb')})`
111
+ ```
112
+
101
113
  `UNION ALL`-цикл замість `unnest` підходить для малих динамічних запитів (2–5 рядків), де кожна гілка семантично різна. Для bulk upsert — завжди `unnest`.
102
114
 
103
115
  ### Заборонений «drop-in» шим
@@ -396,5 +396,5 @@
396
396
  "builtin": true
397
397
  },
398
398
  "globals": {},
399
- "ignorePatterns": ["**/schema.graphql", "**/auto-imports.d.ts"]
399
+ "ignorePatterns": ["npm/types/**", "demo/node/rules-demo.js", "**/schema.graphql", "**/auto-imports.d.ts"]
400
400
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * CLI-диспетчер `n-cursor flow` (spec §8 Dual-Mode Dispatcher).
3
+ *
4
+ * Два фасади навколо єдиного джерела істини `.flow.json`:
5
+ * - **Пасивний Турнікет** (Фасад A): `init`, `verify`, `release` — для IDE-
6
+ * агентів (Cursor/Claude Code), що самі пишуть код; `n-cursor` лише судить.
7
+ * - **Активний Раннер** (Фасад B): `run`, `resume`, `cancel`, `repair` —
8
+ * повний 5-фазний polyfill-цикл для headless/CI.
9
+ */
10
+ import { cancel, repair, resume, run } from './lib/active.mjs'
11
+ import { init, release, verify } from './lib/commands.mjs'
12
+
13
+ const USAGE = [
14
+ 'Usage:',
15
+ ' npx @nitra/cursor flow init "<опис>" # Фасад A: worktree + .flow.json',
16
+ ' npx @nitra/cursor flow verify # Фасад A: Quality Gates (pass/fail)',
17
+ ' npx @nitra/cursor flow release # Фасад A: .changes + completion snapshot',
18
+ ' npx @nitra/cursor flow run "<опис>" # Фасад B: повний 5-фазний цикл',
19
+ ' npx @nitra/cursor flow resume # продовжити з чекпойнта',
20
+ ' npx @nitra/cursor flow cancel # скасувати, прибрати стан',
21
+ ' npx @nitra/cursor flow repair [--discard-step-work] # відновлення пошкодженого стану'
22
+ ].join('\n')
23
+
24
+ /** Підкоманди flow. */
25
+ export const SUBCOMMANDS = ['init', 'verify', 'release', 'run', 'resume', 'cancel', 'repair']
26
+
27
+ /**
28
+ * Усі handler-и реальні (Ф2 Турнікет + Ф4 Активний Раннер).
29
+ * @type {Record<string, (rest: string[], deps: object) => Promise<number>>}
30
+ */
31
+ export const DEFAULT_HANDLERS = { init, verify, release, run, resume, cancel, repair }
32
+
33
+ /**
34
+ * Точка входу `case 'flow'` у `bin/n-cursor.js`. Парсить підкоманду й
35
+ * маршрутизує до handler-а. Невідома/відсутня підкоманда → usage + код 1.
36
+ * @param {string[]} args аргументи після `flow`
37
+ * @param {{ handlers?: Record<string, (rest: string[], deps: object) => Promise<number>> }} [deps] ін'єкція handler-ів (для тестів)
38
+ * @returns {Promise<number>} exit code
39
+ */
40
+ export async function runFlowCli(args, deps = {}) {
41
+ const [sub, ...rest] = args
42
+ const handlers = deps.handlers ?? DEFAULT_HANDLERS
43
+ if (!sub || ! Object.hasOwn(handlers, sub)) {
44
+ console.error(USAGE)
45
+ return 1
46
+ }
47
+ return await handlers[sub](rest, deps)
48
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Активний Раннер (spec §8.1 Фасад B): `run`/`resume`/`cancel`/`repair`. Зшиває
3
+ * ensureWorktree + planner + executor + verify у повний 5-фазний цикл. Уся IO
4
+ * ін'єктується (`runner`/`verify`/`commit`/`run`/`now`) — тестується без
5
+ * реальних LLM/git/gates.
6
+ */
7
+ import { spawnSync } from 'node:child_process'
8
+ import { readFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { cwd as processCwd } from 'node:process'
11
+
12
+ import { BudgetExceeded, withBudget } from './budget.mjs'
13
+ import { ensureWorktree, realRun } from './commands.mjs'
14
+ import { flowEventsPath } from './events.mjs'
15
+ import { executePlan } from './executor.mjs'
16
+ import { generatePlan } from './planner.mjs'
17
+ import { runReview } from './reviewer.mjs'
18
+ import {
19
+ cleanupFlowSiblings,
20
+ flowStatePath,
21
+ readState,
22
+ updateState,
23
+ writeState
24
+ } from './state-store.mjs'
25
+ import { createRunner } from './subagent-runner.mjs'
26
+
27
+ /**
28
+ * Дефолтний commit: `git add -A && git commit -m` у worktree.
29
+ * @param {string} cwd worktree
30
+ * @param {string} msg повідомлення
31
+ * @returns {void}
32
+ */
33
+ function defaultCommit(cwd, msg) {
34
+ spawnSync('git', ['add', '-A'], { cwd })
35
+ spawnSync('git', ['commit', '-m', msg], { cwd })
36
+ }
37
+
38
+ /**
39
+ * Дефолтний verify для executor-а: проганяє gates і повертає verdict.
40
+ * @param {string} cwd worktree
41
+ * @returns {{ pass: boolean, failedOutput: string | null }} verdict
42
+ */
43
+ function defaultVerify(cwd) {
44
+ return runReview({ run: realRun, cwd, fingerprint: () => null })
45
+ }
46
+
47
+ /**
48
+ * Читає `flow.autonomous` із `.n-cursor.json` (бюджет автономного режиму).
49
+ * @param {string} cwd корінь
50
+ * @returns {{ maxApiCalls?: number, maxCostUsd?: number, onBudgetExceeded?: string }} конфіг бюджету
51
+ */
52
+ function readFlowAutonomous(cwd) {
53
+ try {
54
+ const cfg = JSON.parse(readFileSync(join(cwd, '.n-cursor.json'), 'utf8'))
55
+ return cfg?.flow?.autonomous ?? {}
56
+ } catch {
57
+ return {}
58
+ }
59
+ }
60
+
61
+ /**
62
+ * `flow run [--autonomous] <branch> "<task>"` — повний цикл: ensureWorktree →
63
+ * план → executor. У `--autonomous` runner обгортається budget guard-ом (§9.4).
64
+ * @param {string[]} rest аргументи (`--autonomous` + `<branch> <task...>`)
65
+ * @param {{ runner?: object, verify?: (cwd: string) => object, commit?: (cwd: string, msg: string) => void, run?: (cmd: string, args: string[], opts: object) => object, autonomous?: boolean, budget?: object, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
66
+ * @returns {Promise<number>} exit code: 0 done, 1 fail, 2 blocked-on-human
67
+ */
68
+ export async function run(rest, deps = {}) {
69
+ const log = deps.log ?? console.error
70
+ const now = deps.now ?? Date.now
71
+ const autonomous = deps.autonomous ?? rest.includes('--autonomous')
72
+ const positional = rest.filter(a => !a.startsWith('--'))
73
+
74
+ const ew = ensureWorktree(positional, deps)
75
+ if (ew.code !== 0) return ew.code
76
+ const { worktreeDir, branch, desc, baseCommit } = ew
77
+ const statePath = flowStatePath(worktreeDir)
78
+ writeState(statePath, {
79
+ branch,
80
+ status: 'in_progress',
81
+ started_at: new Date(now()).toISOString(),
82
+ metadata: { base_commit: baseCommit },
83
+ plan: []
84
+ })
85
+
86
+ let runner
87
+ try {
88
+ runner = deps.runner ?? (await createRunner(deps))
89
+ } catch (error) {
90
+ log(`run: ${error.message}`)
91
+ return 1
92
+ }
93
+ if (autonomous) {
94
+ const budget = deps.budget ?? readFlowAutonomous(deps.cwd ?? processCwd())
95
+ runner = withBudget(runner, { maxApiCalls: budget.maxApiCalls, log })
96
+ }
97
+
98
+ try {
99
+ const plan = await generatePlan({ runner, task: desc, cwd: worktreeDir })
100
+ updateState(statePath, s => ({ ...s, plan }))
101
+ const result = await executePlan(
102
+ { statePath, eventsPath: flowEventsPath(worktreeDir) },
103
+ { runner, verify: deps.verify ?? defaultVerify, commit: deps.commit ?? defaultCommit, cwd: worktreeDir, log, now }
104
+ )
105
+ if (result.status === 'done') {
106
+ log('run: build done — далі `flow release`')
107
+ return 0
108
+ }
109
+ if (result.status === 'blocked-on-human') {
110
+ log(`run: blocked-on-human на кроці ${result.step}`)
111
+ return 2
112
+ }
113
+ return 1
114
+ } catch (error) {
115
+ if (error instanceof BudgetExceeded) {
116
+ log(`run: ${error.message} — abort`)
117
+ updateState(statePath, s => ({ ...s, status: 'failed' }))
118
+ return 1
119
+ }
120
+ log(`run: ${error.message}`)
121
+ return 1
122
+ }
123
+ }
124
+
125
+ /**
126
+ * `flow resume` — продовжує з чекпойнта. Safe-resume (§4.1.7): скидає частковий
127
+ * доробок до останнього коміту; застосовує HITL-відповіді як підказки й дає
128
+ * крокам свіжі спроби.
129
+ * @param {string[]} _rest аргументи (не використовуються)
130
+ * @param {object} [deps] ін'єкції (як у `run`)
131
+ * @returns {Promise<number>} exit code
132
+ */
133
+ export async function resume(_rest, deps = {}) {
134
+ const cwd = deps.cwd ?? processCwd()
135
+ const log = deps.log ?? console.error
136
+ const now = deps.now ?? Date.now
137
+ const run_ = deps.run ?? realRun
138
+
139
+ const statePath = flowStatePath(cwd)
140
+ const state = readState(statePath)
141
+ if (!state) {
142
+ log('resume: стану нема')
143
+ return 1
144
+ }
145
+
146
+ const openHitl = (state.hitl ?? []).filter(q => !q.answer)
147
+ if (state.status === 'blocked-on-human' && openHitl.length > 0) {
148
+ log(`resume: ще blocked — ${openHitl.length} відкритих HITL-питань (заповни answer і повтори)`)
149
+ return 2
150
+ }
151
+ if (!state.plan?.length) {
152
+ log('resume: нема плану')
153
+ return 1
154
+ }
155
+
156
+ // safe-resume: скинути частковий доробок невдалого кроку до останнього коміту
157
+ run_('git', ['reset', '--hard', 'HEAD'], { cwd })
158
+
159
+ // застосувати HITL-відповіді як hint + дати незавершеним крокам свіжі спроби
160
+ const answers = new Map((state.hitl ?? []).filter(q => q.answer).map(q => [q.step, q.answer]))
161
+ updateState(statePath, s => ({
162
+ ...s,
163
+ status: 'in_progress',
164
+ plan: s.plan.map(st =>
165
+ st.status === 'done' ? st : { ...st, retry_count: 0, ...(answers.has(st.step) ? { hint: answers.get(st.step) } : {}) }
166
+ ),
167
+ hitl: (s.hitl ?? []).map(q => (q.answer ? { ...q, status: 'answered' } : q))
168
+ }))
169
+
170
+ let runner
171
+ try {
172
+ runner = deps.runner ?? (await createRunner(deps))
173
+ } catch (error) {
174
+ log(`resume: ${error.message}`)
175
+ return 1
176
+ }
177
+
178
+ const result = await executePlan(
179
+ { statePath, eventsPath: flowEventsPath(cwd) },
180
+ { runner, verify: deps.verify ?? defaultVerify, commit: deps.commit ?? defaultCommit, cwd, log, now }
181
+ )
182
+ if (result.status === 'done') return 0
183
+ if (result.status === 'blocked-on-human') return 2
184
+ return 1
185
+ }
186
+
187
+ /**
188
+ * `flow cancel` — скасування: прибирає transient sibling-и (стан/журнал/lock).
189
+ * @param {string[]} _rest аргументи
190
+ * @param {{ cwd?: string, log?: (m: string) => void }} [deps] ін'єкції
191
+ * @returns {Promise<number>} 0
192
+ */
193
+ export async function cancel(_rest, deps = {}) {
194
+ const cwd = deps.cwd ?? processCwd()
195
+ const log = deps.log ?? console.error
196
+ cleanupFlowSiblings(cwd)
197
+ log('cancel: стан і sibling-и прибрано')
198
+ return 0
199
+ }
200
+
201
+ /**
202
+ * `flow repair [--discard-step-work]` — fail-closed escape: діагностика стану або
203
+ * жорстке скидання робочого дерева до HEAD (свідоме викидання доробку).
204
+ * @param {string[]} rest аргументи
205
+ * @param {{ run?: (cmd: string, args: string[], opts: object) => object, cwd?: string, log?: (m: string) => void }} [deps] ін'єкції
206
+ * @returns {Promise<number>} exit code
207
+ */
208
+ export async function repair(rest, deps = {}) {
209
+ const cwd = deps.cwd ?? processCwd()
210
+ const log = deps.log ?? console.error
211
+ const run_ = deps.run ?? realRun
212
+
213
+ if (rest.includes('--discard-step-work')) {
214
+ run_('git', ['reset', '--hard', 'HEAD'], { cwd })
215
+ log('repair: робоче дерево скинуто до HEAD (--discard-step-work)')
216
+ return 0
217
+ }
218
+ try {
219
+ const state = readState(flowStatePath(cwd))
220
+ log(state ? `repair: стан валідний (status: ${state.status})` : 'repair: стану нема')
221
+ return 0
222
+ } catch (error) {
223
+ log(`repair: стан пошкоджено — ${error.message}. Спробуй \`flow repair --discard-step-work\` або \`flow cancel\`.`)
224
+ return 1
225
+ }
226
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Budget guard для автономного режиму (spec §9.4): обгортає SubagentRunner
3
+ * лічильником викликів і кидає `BudgetExceeded` при перевищенні `maxApiCalls`.
4
+ * Це запобіжник проти неконтрольованих витрат на сервері (де нема людини).
5
+ *
6
+ * (`maxCostUsd` — коли runner повертатиме tokens/cost; наразі рахуємо виклики.)
7
+ */
8
+
9
+ /** Помилка перевищення бюджету (ловиться в `run`, §9.4). */
10
+ export class BudgetExceeded extends Error {}
11
+
12
+ /**
13
+ * Обгортає runner лічильником API-викликів.
14
+ * @param {{ backend?: string, runStep: (prompt: string, opts?: object) => object }} runner базовий runner
15
+ * @param {{ maxApiCalls?: number, log?: (m: string) => void }} [opts] ліміт і лог
16
+ * @returns {{ backend: string, runStep: (prompt: string, opts?: object) => Promise<object>, readonly calls: number }} обгорнутий runner
17
+ */
18
+ export function withBudget(runner, opts = {}) {
19
+ const maxApiCalls = opts.maxApiCalls ?? Number.POSITIVE_INFINITY
20
+ const log = opts.log ?? (() => {})
21
+ let calls = 0
22
+ return {
23
+ backend: runner.backend,
24
+ get calls() {
25
+ return calls
26
+ },
27
+ async runStep(prompt, stepOpts) {
28
+ if (calls >= maxApiCalls) {
29
+ throw new BudgetExceeded(`budget: вичерпано maxApiCalls=${maxApiCalls}`)
30
+ }
31
+ calls += 1
32
+ log(`budget: API-виклик ${calls}/${maxApiCalls}`)
33
+ return runner.runStep(prompt, stepOpts)
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Capability Router — резолвер режиму оркестрації (`native` vs `polyfill`)
3
+ * за **явною декларацією моделі** (spec §2.2).
4
+ *
5
+ * Рантайм-детекції моделі в кодобазі немає — тому модель НЕ вгадуємо, а
6
+ * оголошуємо за пріоритетом: CLI `--model` > env `N_CURSOR_FLOW_MODEL` >
7
+ * config `flow.model`. Default-режим (`polyfill`) дозволений ЛИШЕ за наявного
8
+ * `SubagentRunner` (§15.1); інакше — fail (caller кидає помилку), бо polyfill
9
+ * без runner-а не «працює з будь-якою моделлю».
10
+ *
11
+ * Усі функції чисті (без I/O) — джерела (`args`/`env`/`config`/`matrix`/
12
+ * `hasRunner`) передаються ззовні, що робить модуль тривіально тестованим.
13
+ */
14
+
15
+ export const DEFAULT_ORCHESTRATION = 'polyfill'
16
+
17
+ /**
18
+ * Витягує значення `--model <value>` з argv. Не мутує вхід.
19
+ * @param {string[]} args аргументи підкоманди flow
20
+ * @returns {string | null} оголошена модель або null
21
+ */
22
+ export function parseModelFlag(args) {
23
+ const i = args.indexOf('--model')
24
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : null
25
+ }
26
+
27
+ /**
28
+ * Оголошена модель за пріоритетом CLI > env > config.
29
+ * @param {{ cliModel?: string | null, envModel?: string | null, configModel?: string | null }} sources джерела декларації
30
+ * @returns {string | null} модель або null, якщо ніде не оголошено
31
+ */
32
+ export function declaredModel({ cliModel = null, envModel = null, configModel = null } = {}) {
33
+ return cliModel || envModel || configModel || null
34
+ }
35
+
36
+ /**
37
+ * Режим оркестрації для оголошеної моделі за `capability-matrix`.
38
+ * Невідома/неоголошена модель → `matrix.default` → `DEFAULT_ORCHESTRATION`.
39
+ * @param {string | null} model оголошена модель
40
+ * @param {{ models?: Record<string, { orchestration?: string }>, default?: { orchestration?: string } }} matrix матриця можливостей
41
+ * @returns {'native' | 'polyfill'} режим
42
+ */
43
+ export function orchestrationFor(model, matrix) {
44
+ const entry = model && matrix && matrix.models ? matrix.models[model] : null
45
+ return (
46
+ (entry && entry.orchestration) ||
47
+ (matrix && matrix.default && matrix.default.orchestration) ||
48
+ DEFAULT_ORCHESTRATION
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Чи стартує polyfill: потрібен доступний `SubagentRunner`.
54
+ * @param {{ hasRunner: boolean }} ctx контекст середовища
55
+ * @returns {boolean} true, якщо runner у наявності
56
+ */
57
+ export function polyfillStartable({ hasRunner }) {
58
+ return hasRunner === true
59
+ }
60
+
61
+ /**
62
+ * Повний резолв: оголошена модель + режим. Кидає, якщо polyfill без runner-а.
63
+ * @param {{ args?: string[], env?: Record<string, string | undefined>, config?: { flow?: { model?: string } }, matrix: object, hasRunner: boolean }} input джерела
64
+ * @returns {{ model: string | null, mode: 'native' | 'polyfill' }} оголошена модель і режим
65
+ */
66
+ export function resolveFlow({ args = [], env = {}, config = {}, matrix, hasRunner }) {
67
+ const model = declaredModel({
68
+ cliModel: parseModelFlag(args),
69
+ envModel: env.N_CURSOR_FLOW_MODEL ?? null,
70
+ configModel: (config && config.flow && config.flow.model) ?? null
71
+ })
72
+ const mode = orchestrationFor(model, matrix)
73
+ if (mode === 'polyfill' && !polyfillStartable({ hasRunner })) {
74
+ throw new Error(
75
+ 'n-cursor flow: режим polyfill потребує доступного SubagentRunner ' +
76
+ '(`claude` або `cursor-agent` у PATH), але жодного не знайдено. ' +
77
+ 'Оголосіть модель із native_workflows (--model) або встановіть CLI-runner.'
78
+ )
79
+ }
80
+ return { model, mode }
81
+ }