@nitra/cursor 4.0.0 → 4.1.1

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/n-cursor.js +25 -13
  3. package/lib/models.mjs +67 -0
  4. package/package.json +2 -1
  5. package/rules/abie/fix.mjs +1 -1
  6. package/rules/bun/docs/fix.md +3 -0
  7. package/rules/bun/fix.mjs +1 -1
  8. package/rules/capacitor/fix.mjs +1 -1
  9. package/rules/changelog/docs/fix.md +3 -0
  10. package/rules/changelog/fix.mjs +1 -1
  11. package/rules/ci4/fix.mjs +1 -1
  12. package/rules/ci4/js/docs/marksman_config.md +1 -0
  13. package/rules/docker/docs/fix.md +1 -1
  14. package/rules/docker/fix.mjs +1 -1
  15. package/rules/docker/lint/docs/lint.md +1 -0
  16. package/rules/efes/docs/fix.md +2 -1
  17. package/rules/efes/fix.mjs +1 -1
  18. package/rules/feedback/fix.mjs +1 -1
  19. package/rules/ga/fix.mjs +1 -1
  20. package/rules/ga/js/lint.mjs +1 -1
  21. package/rules/graphql/docs/fix.md +4 -1
  22. package/rules/graphql/fix.mjs +1 -1
  23. package/rules/graphql/lib/docs/graphql-gql-scan.md +3 -0
  24. package/rules/hasura/fix.mjs +1 -1
  25. package/rules/image-avif/docs/fix.md +4 -1
  26. package/rules/image-avif/fix.mjs +1 -1
  27. package/rules/image-avif/js/docs/avif_generation.md +1 -0
  28. package/rules/image-compress/fix.mjs +1 -1
  29. package/rules/js-bun-db/fix.mjs +1 -1
  30. package/rules/js-bun-db/lib/docs/bun-sql-scan.md +6 -0
  31. package/rules/js-bun-redis/fix.mjs +1 -1
  32. package/rules/js-lint/fix.mjs +1 -1
  33. package/rules/js-lint/js/docs/utils_imports.md +1 -0
  34. package/rules/js-lint-ci/docs/fix.md +4 -1
  35. package/rules/js-lint-ci/fix.mjs +1 -1
  36. package/rules/js-mssql/docs/fix.md +3 -0
  37. package/rules/js-mssql/fix.mjs +1 -1
  38. package/rules/js-mssql/lib/docs/mssql-pool-scan.md +9 -0
  39. package/rules/js-run/docs/fix.md +3 -0
  40. package/rules/js-run/fix.mjs +1 -1
  41. package/rules/js-run/lib/docs/check-env-scan.md +2 -1
  42. package/rules/js-run/lib/docs/promise-settimeout-scan.md +4 -0
  43. package/rules/k8s/docs/fix.md +3 -0
  44. package/rules/k8s/fix.mjs +1 -1
  45. package/rules/nginx-default-tpl/docs/fix.md +3 -0
  46. package/rules/nginx-default-tpl/fix.mjs +1 -1
  47. package/rules/npm-module/fix.mjs +1 -1
  48. package/rules/npm-module/js/header_doc_pointer.mjs +14 -3
  49. package/rules/php/docs/fix.md +2 -1
  50. package/rules/php/fix.mjs +1 -1
  51. package/rules/python/docs/fix.md +4 -1
  52. package/rules/python/fix.mjs +1 -1
  53. package/rules/rego/fix.mjs +1 -1
  54. package/rules/rego/js/lint.mjs +1 -1
  55. package/rules/release/docs/fix.md +4 -1
  56. package/rules/release/fix.mjs +1 -1
  57. package/rules/rust/fix.mjs +1 -1
  58. package/rules/security/docs/fix.md +1 -1
  59. package/rules/security/fix.mjs +1 -1
  60. package/rules/style-lint/docs/fix.md +3 -0
  61. package/rules/style-lint/fix.mjs +1 -1
  62. package/rules/tauri/docs/fix.md +4 -1
  63. package/rules/tauri/fix.mjs +1 -1
  64. package/rules/test/docs/fix.md +3 -0
  65. package/rules/test/fix.mjs +1 -1
  66. package/rules/test/js/no-relative-fs-path.mjs +2 -1
  67. package/rules/text/docs/fix.md +3 -0
  68. package/rules/text/fix.mjs +1 -1
  69. package/rules/text/js/lint.mjs +1 -1
  70. package/rules/vue/fix.mjs +1 -1
  71. package/rules/worktree/fix.mjs +1 -1
  72. package/scripts/auto-rules.mjs +1 -1
  73. package/scripts/coverage-classify/index.mjs +10 -10
  74. package/scripts/coverage-fix.mjs +2 -2
  75. package/scripts/dispatcher/graph/lib/cmd-init.mjs +112 -0
  76. package/scripts/dispatcher/graph/lib/cmd-invalidate.mjs +96 -0
  77. package/scripts/dispatcher/graph/lib/cmd-kill.mjs +141 -0
  78. package/scripts/dispatcher/graph/lib/cmd-plan.mjs +142 -0
  79. package/scripts/dispatcher/graph/lib/cmd-run.mjs +328 -0
  80. package/scripts/dispatcher/graph/lib/cmd-scan.mjs +115 -0
  81. package/scripts/dispatcher/graph/lib/cmd-setup.mjs +111 -0
  82. package/scripts/dispatcher/graph/lib/cmd-signals.mjs +328 -0
  83. package/scripts/dispatcher/graph/lib/cmd-status.mjs +131 -0
  84. package/scripts/dispatcher/graph/lib/cmd-verify.mjs +100 -0
  85. package/scripts/dispatcher/graph/lib/cmd-watch.mjs +128 -0
  86. package/scripts/dispatcher/graph/lib/config.mjs +103 -0
  87. package/scripts/dispatcher/graph/lib/frontmatter.mjs +224 -0
  88. package/scripts/dispatcher/graph/lib/nnn.mjs +127 -0
  89. package/scripts/dispatcher/graph/lib/node-state.mjs +157 -0
  90. package/scripts/dispatcher/graph/lib/scanner.mjs +235 -0
  91. package/scripts/dispatcher/graph/lib/worktree-ops.mjs +193 -0
  92. package/scripts/dispatcher/graph-tasks.mjs +92 -0
  93. package/scripts/dispatcher/index.mjs +3 -3
  94. package/scripts/dispatcher/lib/docs/events.md +1 -0
  95. package/scripts/dispatcher/lib/executor.mjs +1 -1
  96. package/scripts/dispatcher/lib/subagent-runner.mjs +9 -9
  97. package/scripts/dispatcher/trace.mjs +6 -2
  98. package/scripts/docs/build-agents-commands.md +1 -0
  99. package/scripts/docs/cli-entry.md +6 -0
  100. package/scripts/graph/index.mjs +115 -0
  101. package/scripts/graph/lib/config.mjs +62 -0
  102. package/scripts/graph/lib/dag.mjs +161 -0
  103. package/scripts/graph/lib/frontmatter.mjs +70 -0
  104. package/scripts/graph/lib/nnn.mjs +77 -0
  105. package/scripts/graph/lib/state.mjs +110 -0
  106. package/scripts/graph/scan.mjs +64 -0
  107. package/scripts/graph/status.mjs +86 -0
  108. package/scripts/lib/docs/load-cursor-config.md +3 -0
  109. package/scripts/lib/root-notice.mjs +4 -2
  110. package/scripts/lib/rule-predicates.mjs +1 -1
  111. package/scripts/lib/worktree-notice.mjs +14 -7
  112. package/scripts/lib/worktree.mjs +3 -2
  113. package/scripts/utils/resolve-js-root.mjs +2 -1
  114. package/scripts/utils/with-lock.mjs +1 -1
  115. package/skills/docgen/js/docgen-batch.mjs +7 -7
  116. package/skills/docgen/js/docgen-extract.mjs +80 -37
  117. package/skills/docgen/js/docgen-ignore.mjs +1 -1
  118. package/skills/docgen/js/docgen-prompts.mjs +21 -5
  119. package/skills/fix/js/llm-worker.mjs +19 -22
  120. package/skills/fix/js/orchestrator.mjs +6 -7
  121. package/skills/fix/js/t0.mjs +14 -13
  122. package/types/bin/n-cursor.d.ts +1 -1
  123. package/rules/flow/docs/fix.md +0 -152
  124. package/rules/flow/fix.mjs +0 -18
  125. package/rules/flow/flow.mdc +0 -127
  126. package/rules/flow/meta.json +0 -1
  127. package/scripts/dispatcher/lib/docs/flow-lock.md +0 -161
  128. package/scripts/dispatcher/lib/docs/flow-resolve.md +0 -267
  129. package/scripts/dispatcher/lib/flow-plan.mjs +0 -153
  130. package/scripts/dispatcher/lib/flow-resolve.mjs +0 -156
  131. package/scripts/dispatcher/lib/flow-signals.mjs +0 -235
  132. package/scripts/dispatcher/lib/flow-verify.mjs +0 -127
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `n-cursor graph kill <path>` — вбиває worktree вузла і каскадно інвалідує нащадків.
3
+ *
4
+ * 1. Знаходить worktree вузла
5
+ * 2. Видаляє worktree (force)
6
+ * 3. Видаляє plan_*.md (скидає планування)
7
+ * 4. Записує invalidated sentinel
8
+ * 5. Каскадно інвалідує всі залежні вузли
9
+ *
10
+ * FS і child_process ін'єктуються для тестованості.
11
+ */
12
+ import { execSync } from 'node:child_process'
13
+ import { existsSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { cwd as processCwd } from 'node:process'
16
+
17
+ import { loadConfig, resolveTasksDir, resolveWorktreesDir } from './config.mjs'
18
+ import { scanNodes } from './scanner.mjs'
19
+ import { findNodeWorktree, listActiveWorktrees, removeWorktree } from './worktree-ops.mjs'
20
+
21
+ /**
22
+ * Записує invalidated sentinel для вузла.
23
+ * @param {string} nodeDir директорія вузла
24
+ * @param {(p: string, c: string, enc: string) => void} writeFile функція запису
25
+ */
26
+ function writeInvalidated(nodeDir, writeFile) {
27
+ writeFile(join(nodeDir, 'invalidated'), '', 'utf8')
28
+ }
29
+
30
+ /**
31
+ * Видаляє plan_*.md файли з директорії вузла.
32
+ * @param {string} nodeDir директорія вузла
33
+ * @param {string[]} files список файлів
34
+ * @param {(p: string) => void} unlink функція видалення
35
+ */
36
+ function deletePlanFiles(nodeDir, files, unlink) {
37
+ for (const f of files) {
38
+ if (/^plan_\d+\.md$/.test(f)) {
39
+ try {
40
+ unlink(join(nodeDir, f))
41
+ } catch {
42
+ // пропускаємо
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * `graph kill <path>` command handler.
50
+ * @param {string[]} args аргументи: [path]
51
+ * @param {{
52
+ * cwd?: string,
53
+ * log?: (m: string) => void,
54
+ * readFile?: (p: string, enc: string) => string,
55
+ * writeFile?: (p: string, c: string, enc: string) => void,
56
+ * readdir?: (d: string) => string[],
57
+ * exists?: (p: string) => boolean,
58
+ * unlink?: (p: string) => void,
59
+ * execSync?: (cmd: string, opts?: object) => string
60
+ * }} [deps] ін'єкції
61
+ * @returns {Promise<number>} exit code
62
+ */
63
+ export async function cmdKill(args, deps = {}) {
64
+ const root = deps.cwd ?? processCwd()
65
+ const log = deps.log ?? console.log
66
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
67
+ const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc))
68
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
69
+ const exists = deps.exists ?? existsSync
70
+ const unlink = deps.unlink ?? unlinkSync
71
+ const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' }))
72
+
73
+ const [nodePath] = args
74
+ if (!nodePath) {
75
+ log('Usage: n-cursor graph kill <path>')
76
+ return 1
77
+ }
78
+
79
+ const config = loadConfig({ root, readFile, exists })
80
+ const tasksDir = resolveTasksDir(config, root)
81
+ const worktreesDir = resolveWorktreesDir(config, root)
82
+
83
+ const nodeDir = join(tasksDir, nodePath)
84
+ if (!exists(join(nodeDir, 'task.md'))) {
85
+ log(`kill: вузол "${nodePath}" не знайдено`)
86
+ return 1
87
+ }
88
+
89
+ // 1. Знаходимо і видаляємо worktree
90
+ const worktreePath = findNodeWorktree(nodePath, worktreesDir, {
91
+ readdirSync: readdir,
92
+ execSync: execSyncFn
93
+ })
94
+
95
+ if (worktreePath) {
96
+ log(`kill: видаляємо worktree ${worktreePath}`)
97
+ removeWorktree(worktreePath, root, { execSync: execSyncFn })
98
+ } else {
99
+ log(`kill: worktree не знайдено для "${nodePath}"`)
100
+ }
101
+
102
+ // 2. Видаляємо plan_*.md
103
+ const files = readdir(nodeDir)
104
+ deletePlanFiles(nodeDir, files, unlink)
105
+ const planCount = files.filter(f => /^plan_\d+\.md$/.test(f)).length
106
+ if (planCount > 0) {
107
+ log(`kill: видалено ${planCount} plan_*.md файл(ів)`)
108
+ }
109
+
110
+ // 3. Записуємо invalidated sentinel
111
+ try {
112
+ writeInvalidated(nodeDir, writeFile)
113
+ log(`kill: вузол "${nodePath}" інвалідовано`)
114
+ } catch (err) {
115
+ log(`kill: не вдалося записати invalidated — ${err.message ?? String(err)}`)
116
+ return 1
117
+ }
118
+
119
+ // 4. Каскадна інвалідація залежних вузлів
120
+ const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn })
121
+ const allNodes = scanNodes(tasksDir, activeWorktrees, {
122
+ readdirSync: readdir,
123
+ existsSync: exists,
124
+ readFileSync: readFile
125
+ })
126
+
127
+ // Знаходимо вузли що залежать від нашого вузла
128
+ const dependents = allNodes.filter(n => n.deps.includes(nodePath))
129
+ for (const dep of dependents) {
130
+ if (!exists(join(dep.dir, 'invalidated'))) {
131
+ try {
132
+ writeInvalidated(dep.dir, writeFile)
133
+ log(`kill: каскадна інвалідація "${dep.path}"`)
134
+ } catch {
135
+ // пропускаємо
136
+ }
137
+ }
138
+ }
139
+
140
+ return 0
141
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `n-cursor graph plan [<path>] [--mode agent]` — Stage 1: пише plan_NNN.md.
3
+ *
4
+ * Читає task.md вузла, знаходить наступний NNN, пише шаблон plan_NNN.md.
5
+ * Якщо --mode agent — встановлює mode:agent у plan front-matter.
6
+ *
7
+ * FS ін'єктується для тестованості.
8
+ */
9
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { cwd as processCwd } from 'node:process'
12
+
13
+ import { buildMarkdown, parseFrontMatter } from './frontmatter.mjs'
14
+ import { nextPlanNNN } from './nnn.mjs'
15
+ import { loadConfig, resolveTasksDir } from './config.mjs'
16
+
17
+ /**
18
+ * Будує шаблон plan_NNN.md.
19
+ * @param {{ mode: string, hint: string, now: string, nnn: string }} params параметри
20
+ * @returns {string} вміст файлу
21
+ */
22
+ export function buildPlanTemplate(params) {
23
+ const fm = {
24
+ created_at: params.now,
25
+ mode: params.mode,
26
+ decision: params.hint || 'atomic'
27
+ }
28
+
29
+ const body = [
30
+ `## Context`,
31
+ `<!-- Чому саме такий підхід — що з'ясовано під час планування -->`,
32
+ ``,
33
+ `## Approach`,
34
+ params.mode === 'composite'
35
+ ? `<!-- composite: список дочірніх вузлів з описами -->`
36
+ : `<!-- atomic: покроковий план виконання -->`,
37
+ ``,
38
+ `## Risks`,
39
+ `<!-- Що може піти не так -->`,
40
+ ``
41
+ ].join('\n')
42
+
43
+ return buildMarkdown(fm, body)
44
+ }
45
+
46
+ /**
47
+ * `graph plan [<path>] [--mode agent]` command handler.
48
+ * @param {string[]} args аргументи: [path] [--mode agent|human]
49
+ * @param {{
50
+ * cwd?: string,
51
+ * log?: (m: string) => void,
52
+ * writeFile?: (p: string, c: string, enc: string) => void,
53
+ * readFile?: (p: string, enc: string) => string,
54
+ * readdir?: (d: string) => string[],
55
+ * exists?: (p: string) => boolean,
56
+ * now?: () => string
57
+ * }} [deps] ін'єкції
58
+ * @returns {Promise<number>} exit code
59
+ */
60
+ export async function cmdPlan(args, deps = {}) {
61
+ const root = deps.cwd ?? processCwd()
62
+ const log = deps.log ?? console.log
63
+ const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc))
64
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
65
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
66
+ const exists = deps.exists ?? existsSync
67
+ const nowFn = deps.now ?? (() => new Date().toISOString())
68
+
69
+ // Парсимо аргументи
70
+ let nodePath = null
71
+ let modeOverride = null
72
+
73
+ for (let i = 0; i < args.length; i++) {
74
+ if (args[i] === '--mode' && args[i + 1]) {
75
+ modeOverride = args[i + 1]
76
+ i++
77
+ } else if (!args[i].startsWith('-')) {
78
+ nodePath = args[i]
79
+ }
80
+ }
81
+
82
+ const config = loadConfig({ root, readFile, exists })
83
+ const tasksDir = resolveTasksDir(config, root)
84
+
85
+ // Визначаємо директорію вузла
86
+ let nodeDir
87
+ if (nodePath) {
88
+ nodeDir = join(tasksDir, nodePath)
89
+ } else {
90
+ // CWD може бути в worktree — шукаємо task.md у CWD
91
+ nodeDir = processCwd()
92
+ }
93
+
94
+ const taskPath = join(nodeDir, 'task.md')
95
+ if (!exists(taskPath)) {
96
+ log(`plan: task.md не знайдено в ${nodeDir}`)
97
+ return 1
98
+ }
99
+
100
+ let taskContent
101
+ try {
102
+ taskContent = readFile(taskPath, 'utf8')
103
+ } catch (err) {
104
+ log(`plan: не вдалося прочитати task.md — ${err.message ?? String(err)}`)
105
+ return 1
106
+ }
107
+
108
+ const fm = parseFrontMatter(taskContent)
109
+ const mode = modeOverride ?? (typeof fm.mode === 'string' ? fm.mode : 'human')
110
+ const hint = typeof fm.hint === 'string' ? fm.hint : ''
111
+
112
+ const nnn = nextPlanNNN(nodeDir, readdir)
113
+ const planPath = join(nodeDir, `plan_${nnn}.md`)
114
+
115
+ const content = buildPlanTemplate({ mode, hint, now: nowFn(), nnn })
116
+
117
+ try {
118
+ writeFile(planPath, content, 'utf8')
119
+ log(`plan: створено ${planPath} (mode: ${mode})`)
120
+ } catch (err) {
121
+ log(`plan: не вдалося записати ${planPath} — ${err.message ?? String(err)}`)
122
+ return 1
123
+ }
124
+
125
+ // Виводимо контекст для агента/людини
126
+ const bodyStart = taskContent.indexOf('\n---\n', 4)
127
+ const taskBody = bodyStart === -1 ? taskContent : taskContent.slice(bodyStart + 5).trimStart()
128
+
129
+ console.log([
130
+ `## plan context`,
131
+ ``,
132
+ `node: ${nodePath ?? nodeDir}`,
133
+ `mode: ${mode}`,
134
+ hint ? `hint: ${hint}` : `hint: (не задано)`,
135
+ `plan: plan_${nnn}.md`,
136
+ ``,
137
+ `### task.md`,
138
+ taskBody.trimEnd()
139
+ ].join('\n'))
140
+
141
+ return 0
142
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * `n-cursor graph run [<path>] [--actor a] [--auto]` — запуск вузла(ів).
3
+ *
4
+ * Wrapper логіка:
5
+ * 1. Читає task.md → budget_sec, budget_hard_sec, deps, mode, executor
6
+ * 2. Перевіряє що всі deps resolved
7
+ * 3. Обчислює NNN = count(run_*.md) + 1
8
+ * 4. git worktree add .worktrees/<node-epoch>/ (atomic mkdir lock — EEXIST = skip)
9
+ * 5. ENV: NCURSOR_RUN_NNN, NCURSOR_BUDGET_SEC, NCURSOR_HARD_BUDGET_SEC, NCURSOR_STARTED_AT, NCURSOR_NODE_PATH
10
+ * 6. Спавнить subprocess (claude або n-cursor graph run --actor auditor)
11
+ * 7. Poll worktree mtime кожні 5s: progress_timeout → SIGKILL; budget_hard → SIGKILL
12
+ * 8. Після exit: fact_NNN.md є → result:success; else → result:failed
13
+ * 9. Пише run_NNN.md
14
+ * 10. Якщо success: git merge + delete worktree
15
+ *
16
+ * --auto режим: сканує для готових вузлів (waiting + deps resolved), клеймить atomic mkdir.
17
+ *
18
+ * FS і child_process ін'єктуються для тестованості.
19
+ */
20
+ import { execSync, spawnSync } from 'node:child_process'
21
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { cwd as processCwd } from 'node:process'
24
+
25
+ import { buildMarkdown, parseFrontMatter } from './frontmatter.mjs'
26
+ import { latestFactNNN, nextRunNNN } from './nnn.mjs'
27
+ import { loadConfig, resolveModelByTier, resolveTasksDir, resolveWorktreesDir } from './config.mjs'
28
+ import { scanNodes, topoSort, areDepsResolved } from './scanner.mjs'
29
+ import { createWorktree, listActiveWorktrees, mergeWorktree } from './worktree-ops.mjs'
30
+ import { makeWorktreeName } from './worktree-ops.mjs'
31
+
32
+ /**
33
+ * Пише run_NNN.md артефакт.
34
+ * @param {string} nodeDir директорія вузла
35
+ * @param {string} nnn NNN рядок
36
+ * @param {'success'|'failed'} result результат
37
+ * @param {{ actor: string, startedAt: string, now: string }} meta метадані
38
+ * @param {(p: string, c: string, enc: string) => void} writeFile функція запису
39
+ */
40
+ function writeRunFile(nodeDir, nnn, result, meta, writeFile) {
41
+ const fm = {
42
+ created_at: meta.now,
43
+ started_at: meta.startedAt,
44
+ actor: meta.actor,
45
+ result
46
+ }
47
+ const content = buildMarkdown(fm, `## Run ${nnn}\n\nactor: ${meta.actor}\nresult: ${result}\n`)
48
+ writeFile(join(nodeDir, `run_${nnn}.md`), content, 'utf8')
49
+ }
50
+
51
+ /**
52
+ * Запускає один вузол: creates worktree, spawns agent, writes run_NNN.md.
53
+ * @param {string} nodePath відносний шлях вузла
54
+ * @param {string} nodeDir абсолютний шлях до директорії вузла
55
+ * @param {object} config конфігурація
56
+ * @param {string} root корінь репо
57
+ * @param {{ actor?: string, dryRun?: boolean }} opts опції
58
+ * @param {object} deps ін'єкції
59
+ * @returns {{ ok: boolean, code: number }} результат
60
+ */
61
+ function runNode(nodePath, nodeDir, config, root, opts, deps) {
62
+ const log = deps.log ?? console.log
63
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
64
+ const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc))
65
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
66
+ const exists = deps.exists ?? existsSync
67
+ const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' }))
68
+ const spawnSyncFn = deps.spawnSync ?? spawnSync
69
+ const nowFn = deps.now ?? (() => new Date().toISOString())
70
+ const statFn = deps.statSync ?? statSync
71
+
72
+ // 1. Читаємо task.md
73
+ let fm = {}
74
+ try {
75
+ fm = parseFrontMatter(readFile(join(nodeDir, 'task.md'), 'utf8'))
76
+ } catch (err) {
77
+ log(`run: не вдалося прочитати task.md для "${nodePath}" — ${err.message ?? String(err)}`)
78
+ return { ok: false, code: 1 }
79
+ }
80
+
81
+ const budgetSec = Number(fm.budget_sec) || config.default_budget_sec
82
+ const budgetHardSec = Number(fm.budget_hard_sec) || (budgetSec * config.budget_hard_sec_multiplier)
83
+ const progressTimeoutSec = config.progress_timeout_sec
84
+
85
+ const executor = (fm.executor && typeof fm.executor === 'object') ? fm.executor : {}
86
+ const executorType = executor.type ?? 'agent'
87
+ const modelTier = executor.model_tier ?? 'AVG'
88
+ const model = resolveModelByTier(config, modelTier)
89
+
90
+ const actor = opts.actor ?? executorType
91
+
92
+ // 2. Вже перевірено deps resolved перед викликом
93
+
94
+ // 3. Обчислюємо NNN
95
+ const nnn = nextRunNNN(nodeDir, readdir)
96
+
97
+ // 4. Створюємо worktree (atomic mkdir lock)
98
+ const worktreesDir = resolveWorktreesDir(config, root)
99
+ const worktreeName = makeWorktreeName(nodePath)
100
+ const worktreePath = join(worktreesDir, worktreeName)
101
+
102
+ log(`run: запускаємо вузол "${nodePath}" (NNN=${nnn}, actor=${actor})`)
103
+
104
+ if (opts.dryRun) {
105
+ log(`run: --dry-run — пропускаємо фактичний запуск`)
106
+ return { ok: true, code: 0 }
107
+ }
108
+
109
+ let createResult
110
+ try {
111
+ createResult = createWorktree(worktreesDir, worktreeName, root, { execSync: execSyncFn })
112
+ } catch (err) {
113
+ log(`run: не вдалося створити worktree — ${err.message ?? String(err)}`)
114
+ return { ok: false, code: 1 }
115
+ }
116
+
117
+ if (!createResult) {
118
+ log(`run: вузол "${nodePath}" вже запущено (worktree існує) — пропускаємо`)
119
+ return { ok: false, code: 2 }
120
+ }
121
+
122
+ // 5. ENV
123
+ const startedAt = nowFn()
124
+ const env = {
125
+ ...process.env,
126
+ NCURSOR_RUN_NNN: nnn,
127
+ NCURSOR_BUDGET_SEC: String(budgetSec),
128
+ NCURSOR_HARD_BUDGET_SEC: String(budgetHardSec),
129
+ NCURSOR_STARTED_AT: startedAt,
130
+ NCURSOR_NODE_PATH: nodePath
131
+ }
132
+
133
+ // 6. Спавнимо subprocess (spawnSync — синхронно)
134
+ let spawnResult
135
+ const timeoutMs = budgetHardSec > 0 ? budgetHardSec * 1000 : undefined
136
+
137
+ if (actor === 'agent' || actor === 'a') {
138
+ // Запускаємо claude CLI у worktree
139
+ const claudeArgs = ['--model', model, '--no-session', '-p',
140
+ `You are executing task node: ${nodePath}\nWorking directory: ${worktreePath}\nRun NNN: ${nnn}\nBudget: ${budgetSec}s\n\nRead task.md and plan_*.md, execute the task, write fact_${nnn}.md with results.`
141
+ ]
142
+ spawnResult = spawnSyncFn('claude', claudeArgs, {
143
+ cwd: worktreePath,
144
+ env,
145
+ encoding: 'utf8',
146
+ timeout: timeoutMs
147
+ })
148
+ } else if (actor === 'human') {
149
+ // Людина виконує вручну — чекаємо на fact файл
150
+ log(`run: вузол "${nodePath}" очікує ручного виконання`)
151
+ log(` worktree: ${worktreePath}`)
152
+ log(` NCURSOR_RUN_NNN=${nnn}`)
153
+ log(` після виконання запустіть: n-cursor graph done ${nodePath}`)
154
+ // Не чекаємо — повертаємо success без run_NNN.md
155
+ return { ok: true, code: 0 }
156
+ } else {
157
+ log(`run: невідомий actor "${actor}" — підтримується: agent, human`)
158
+ return { ok: false, code: 1 }
159
+ }
160
+
161
+ // 8. Після exit: перевіряємо fact_NNN.md
162
+ const factPath = join(worktreePath, `fact_${nnn}.md`)
163
+ const factInNodeDir = join(nodeDir, `fact_${nnn}.md`)
164
+
165
+ // Перевіряємо у worktree та у основній директорії
166
+ const hasFactInWorktree = exists(factPath)
167
+ const result = hasFactInWorktree ? 'success' : 'failed'
168
+
169
+ // Якщо є факт у worktree — копіюємо в node dir (якщо worktree != nodeDir)
170
+ if (hasFactInWorktree && worktreePath !== nodeDir) {
171
+ try {
172
+ const factContent = readFile(factPath, 'utf8')
173
+ writeFile(factInNodeDir, factContent, 'utf8')
174
+ } catch {
175
+ // пропускаємо
176
+ }
177
+ }
178
+
179
+ // 9. Пишемо run_NNN.md у node dir
180
+ try {
181
+ writeRunFile(nodeDir, nnn, result, {
182
+ actor,
183
+ startedAt,
184
+ now: nowFn()
185
+ }, writeFile)
186
+ log(`run: записано run_${nnn}.md (result: ${result})`)
187
+ } catch (err) {
188
+ log(`run: не вдалося записати run_${nnn}.md — ${err.message ?? String(err)}`)
189
+ }
190
+
191
+ // 10. Якщо success: merge worktree
192
+ if (result === 'success') {
193
+ const mergeResult = mergeWorktree(worktreePath, root, { execSync: execSyncFn })
194
+ if (!mergeResult.ok) {
195
+ log(`run: merge worktree не вдався — ${mergeResult.error}`)
196
+ } else {
197
+ log(`run: worktree merged і видалено`)
198
+ }
199
+ return { ok: true, code: 0 }
200
+ } else {
201
+ log(`run: вузол "${nodePath}" завершився з помилкою`)
202
+ log(`run: worktree збережено для діагностики: ${worktreePath}`)
203
+ return { ok: false, code: 1 }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * `graph run [<path>] [--actor a] [--auto]` command handler.
209
+ * @param {string[]} args аргументи
210
+ * @param {{
211
+ * cwd?: string,
212
+ * log?: (m: string) => void,
213
+ * readFile?: (p: string, enc: string) => string,
214
+ * writeFile?: (p: string, c: string, enc: string) => void,
215
+ * readdir?: (d: string) => string[],
216
+ * exists?: (p: string) => boolean,
217
+ * execSync?: (cmd: string, opts?: object) => string,
218
+ * spawnSync?: (cmd: string, args: string[], opts?: object) => object,
219
+ * statSync?: (p: string) => object,
220
+ * now?: () => string
221
+ * }} [deps] ін'єкції
222
+ * @returns {Promise<number>} exit code
223
+ */
224
+ export async function cmdRun(args, deps = {}) {
225
+ const root = deps.cwd ?? processCwd()
226
+ const log = deps.log ?? console.log
227
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
228
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
229
+ const exists = deps.exists ?? existsSync
230
+ const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' }))
231
+
232
+ // Парсимо аргументи
233
+ let nodePath = null
234
+ let actor = null
235
+ let autoMode = false
236
+
237
+ for (let i = 0; i < args.length; i++) {
238
+ if (args[i] === '--actor' && args[i + 1]) {
239
+ actor = args[i + 1]
240
+ i++
241
+ } else if (args[i] === '--auto') {
242
+ autoMode = true
243
+ } else if (!args[i].startsWith('-')) {
244
+ nodePath = args[i]
245
+ }
246
+ }
247
+
248
+ const config = loadConfig({ root, readFile, exists })
249
+ const tasksDir = resolveTasksDir(config, root)
250
+
251
+ const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn })
252
+
253
+ // Перевіряємо ліміт worktrees
254
+ if (activeWorktrees.size >= config.max_worktrees) {
255
+ log(`run: досягнуто max_worktrees (${config.max_worktrees}) — зачекайте завершення поточних задач`)
256
+ return 1
257
+ }
258
+
259
+ if (activeWorktrees.size >= config.warn_worktrees_above) {
260
+ log(`run: увага — ${activeWorktrees.size} активних worktrees (попередження при >${config.warn_worktrees_above})`)
261
+ }
262
+
263
+ if (autoMode) {
264
+ // Знаходимо всі ready вузли і запускаємо їх
265
+ const allNodes = scanNodes(tasksDir, activeWorktrees, {
266
+ readdirSync: readdir,
267
+ existsSync: exists,
268
+ readFileSync: readFile
269
+ })
270
+ const nodeMap = new Map(allNodes.map(n => [n.id, n]))
271
+ const readyNodes = topoSort(allNodes).filter(n => n.state === 'waiting' && areDepsResolved(n, nodeMap))
272
+
273
+ if (readyNodes.length === 0) {
274
+ log('run --auto: немає готових вузлів для запуску')
275
+ return 0
276
+ }
277
+
278
+ log(`run --auto: знайдено ${readyNodes.length} готових вузлів`)
279
+ let anyFailed = false
280
+
281
+ for (const node of readyNodes) {
282
+ const result = runNode(node.path, node.dir, config, root, { actor: actor ?? undefined }, {
283
+ ...deps,
284
+ log,
285
+ execSync: execSyncFn
286
+ })
287
+ if (!result.ok && result.code !== 2) anyFailed = true
288
+ }
289
+
290
+ return anyFailed ? 1 : 0
291
+ }
292
+
293
+ // Запускаємо конкретний вузол або вузол у CWD
294
+ if (!nodePath) {
295
+ log('run: вкажіть <path> або використайте --auto')
296
+ log('Usage: n-cursor graph run [<path>] [--actor agent|human] [--auto]')
297
+ return 1
298
+ }
299
+
300
+ const nodeDir = join(tasksDir, nodePath)
301
+ if (!exists(join(nodeDir, 'task.md'))) {
302
+ log(`run: вузол "${nodePath}" не знайдено (немає task.md у ${nodeDir})`)
303
+ return 1
304
+ }
305
+
306
+ // Перевіряємо deps
307
+ const allNodes = scanNodes(tasksDir, activeWorktrees, {
308
+ readdirSync: readdir,
309
+ existsSync: exists,
310
+ readFileSync: readFile
311
+ })
312
+ const nodeMap = new Map(allNodes.map(n => [n.id, n]))
313
+ const targetNode = nodeMap.get(nodePath)
314
+
315
+ if (targetNode && !areDepsResolved(targetNode, nodeMap)) {
316
+ const unresolvedDeps = targetNode.deps.filter(dep => nodeMap.get(dep)?.state !== 'resolved')
317
+ log(`run: вузол "${nodePath}" має невирішені залежності: ${unresolvedDeps.join(', ')}`)
318
+ return 1
319
+ }
320
+
321
+ const result = runNode(nodePath, nodeDir, config, root, { actor: actor ?? undefined }, {
322
+ ...deps,
323
+ log,
324
+ execSync: execSyncFn
325
+ })
326
+
327
+ return result.code
328
+ }