@nitra/cursor 4.1.2 → 5.0.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 (72) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/docs/n-cursor.md +1 -9
  3. package/bin/n-cursor.js +3 -25
  4. package/package.json +1 -1
  5. package/rules/docker/lib/docs/docker-mirror.md +1 -1
  6. package/rules/docker/lib/docs/docker-native-addon.md +1 -1
  7. package/rules/npm-module/npm-module.mdc +1 -1
  8. package/rules/npm-module/policy/npm_publish_yml/template/npm-publish.yml.snippet.yml +1 -1
  9. package/rules/test/coverage/coverage.mjs +9 -19
  10. package/rules/test/test.mdc +1 -1
  11. package/scripts/dispatcher/trace.mjs +4 -16
  12. package/scripts/docs/build-agents-commands.md +1 -1
  13. package/scripts/docs/worktree-cli.md +1 -1
  14. package/scripts/lib/changed-files.mjs +19 -3
  15. package/scripts/lib/sync-gitignore-worktree.mjs +4 -5
  16. package/scripts/worktree-cli.mjs +1 -2
  17. package/skills/docgen/js/docgen-gen.mjs +7 -7
  18. package/docs/flow.MD +0 -1364
  19. package/scripts/dispatcher/docs/graph.md +0 -346
  20. package/scripts/dispatcher/docs/index.md +0 -236
  21. package/scripts/dispatcher/docs/trace.md +0 -296
  22. package/scripts/dispatcher/graph/lib/cmd-init.mjs +0 -112
  23. package/scripts/dispatcher/graph/lib/cmd-invalidate.mjs +0 -96
  24. package/scripts/dispatcher/graph/lib/cmd-kill.mjs +0 -141
  25. package/scripts/dispatcher/graph/lib/cmd-plan.mjs +0 -142
  26. package/scripts/dispatcher/graph/lib/cmd-run.mjs +0 -328
  27. package/scripts/dispatcher/graph/lib/cmd-scan.mjs +0 -115
  28. package/scripts/dispatcher/graph/lib/cmd-setup.mjs +0 -111
  29. package/scripts/dispatcher/graph/lib/cmd-signals.mjs +0 -328
  30. package/scripts/dispatcher/graph/lib/cmd-status.mjs +0 -131
  31. package/scripts/dispatcher/graph/lib/cmd-verify.mjs +0 -100
  32. package/scripts/dispatcher/graph/lib/cmd-watch.mjs +0 -128
  33. package/scripts/dispatcher/graph/lib/config.mjs +0 -103
  34. package/scripts/dispatcher/graph/lib/frontmatter.mjs +0 -224
  35. package/scripts/dispatcher/graph/lib/nnn.mjs +0 -127
  36. package/scripts/dispatcher/graph/lib/node-state.mjs +0 -157
  37. package/scripts/dispatcher/graph/lib/scanner.mjs +0 -235
  38. package/scripts/dispatcher/graph/lib/worktree-ops.mjs +0 -193
  39. package/scripts/dispatcher/graph-tasks.mjs +0 -92
  40. package/scripts/dispatcher/graph.mjs +0 -212
  41. package/scripts/dispatcher/index.mjs +0 -45
  42. package/scripts/dispatcher/lib/docs/active.md +0 -348
  43. package/scripts/dispatcher/lib/docs/artifact.md +0 -232
  44. package/scripts/dispatcher/lib/docs/budget.md +0 -167
  45. package/scripts/dispatcher/lib/docs/capability.md +0 -196
  46. package/scripts/dispatcher/lib/docs/commands.md +0 -210
  47. package/scripts/dispatcher/lib/docs/events.md +0 -183
  48. package/scripts/dispatcher/lib/docs/executor.md +0 -190
  49. package/scripts/dispatcher/lib/docs/gate.md +0 -231
  50. package/scripts/dispatcher/lib/docs/level.md +0 -335
  51. package/scripts/dispatcher/lib/docs/plan-panel.md +0 -181
  52. package/scripts/dispatcher/lib/docs/plan.md +0 -200
  53. package/scripts/dispatcher/lib/docs/planner.md +0 -269
  54. package/scripts/dispatcher/lib/docs/review.md +0 -255
  55. package/scripts/dispatcher/lib/docs/reviewer.md +0 -240
  56. package/scripts/dispatcher/lib/docs/snapshot.md +0 -247
  57. package/scripts/dispatcher/lib/docs/spec.md +0 -203
  58. package/scripts/dispatcher/lib/docs/state-store.md +0 -303
  59. package/scripts/dispatcher/lib/docs/subagent-runner.md +0 -173
  60. package/scripts/dispatcher/lib/events.mjs +0 -67
  61. package/scripts/dispatcher/lib/executor.mjs +0 -107
  62. package/scripts/dispatcher/lib/plan-panel.mjs +0 -76
  63. package/scripts/dispatcher/lib/state-store.mjs +0 -173
  64. package/scripts/dispatcher/lib/subagent-runner.mjs +0 -53
  65. package/scripts/graph/index.mjs +0 -115
  66. package/scripts/graph/lib/config.mjs +0 -62
  67. package/scripts/graph/lib/dag.mjs +0 -161
  68. package/scripts/graph/lib/frontmatter.mjs +0 -70
  69. package/scripts/graph/lib/nnn.mjs +0 -77
  70. package/scripts/graph/lib/state.mjs +0 -110
  71. package/scripts/graph/scan.mjs +0 -64
  72. package/scripts/graph/status.mjs +0 -86
@@ -1,103 +0,0 @@
1
- /**
2
- * Завантаження конфігурації `.n-cursor.json` для graph-команд.
3
- *
4
- * Читає JSON з кореня репо (або з вказаного шляху), мержить із дефолтами.
5
- * Підтримує per-node override через task.md (якщо вузол передає свої налаштування).
6
- *
7
- * FS ін'єктується для тестованості без диска.
8
- */
9
- import { existsSync, readFileSync } from 'node:fs'
10
- import { join } from 'node:path'
11
- import { cwd as processCwd } from 'node:process'
12
-
13
- /** Дефолтні значення конфігурації. */
14
- export const CONFIG_DEFAULTS = {
15
- tasks_dir: './tasks',
16
- worktrees_dir: './.worktrees',
17
- warn_worktrees_above: 4,
18
- max_worktrees: 8,
19
- default_budget_sec: 1800,
20
- budget_hard_sec_multiplier: 3,
21
- progress_timeout_sec: 300,
22
- claude_model: 'claude-sonnet-4-6',
23
- audit_model: 'claude-haiku-4-5-20251001',
24
- model_map: {
25
- MIM: 'claude-haiku-4-5-20251001',
26
- AVG: 'claude-sonnet-4-6',
27
- MAX: 'claude-opus-4-8'
28
- },
29
- stale_worktree_min: 30,
30
- system_prompt: '.n-cursor/system-prompt.md'
31
- }
32
-
33
- /**
34
- * Завантажує конфігурацію з `.n-cursor.json` і мержить із дефолтами.
35
- * @param {{
36
- * root?: string,
37
- * readFile?: (p: string, enc: string) => string,
38
- * exists?: (p: string) => boolean
39
- * }} [deps] ін'єкції
40
- * @returns {typeof CONFIG_DEFAULTS} злита конфігурація
41
- */
42
- export function loadConfig(deps = {}) {
43
- const root = deps.root ?? processCwd()
44
- const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
45
- const exists = deps.exists ?? existsSync
46
-
47
- const configPath = join(root, '.n-cursor.json')
48
-
49
- if (!exists(configPath)) {
50
- return { ...CONFIG_DEFAULTS }
51
- }
52
-
53
- let raw
54
- try {
55
- raw = JSON.parse(readFile(configPath, 'utf8'))
56
- } catch {
57
- return { ...CONFIG_DEFAULTS }
58
- }
59
-
60
- return {
61
- ...CONFIG_DEFAULTS,
62
- ...raw,
63
- model_map: {
64
- ...CONFIG_DEFAULTS.model_map,
65
- ...(raw.model_map ?? {})
66
- }
67
- }
68
- }
69
-
70
- /**
71
- * Повертає абсолютний шлях до tasks_dir.
72
- * @param {typeof CONFIG_DEFAULTS} config конфігурація
73
- * @param {string} root корінь репо
74
- * @returns {string} абсолютний шлях
75
- */
76
- export function resolveTasksDir(config, root) {
77
- const d = config.tasks_dir
78
- return d.startsWith('/') ? d : join(root, d)
79
- }
80
-
81
- /**
82
- * Повертає абсолютний шлях до worktrees_dir.
83
- * @param {typeof CONFIG_DEFAULTS} config конфігурація
84
- * @param {string} root корінь репо
85
- * @returns {string} абсолютний шлях
86
- */
87
- export function resolveWorktreesDir(config, root) {
88
- const d = config.worktrees_dir
89
- return d.startsWith('/') ? d : join(root, d)
90
- }
91
-
92
- /**
93
- * Резолвить модель за model_tier із model_map або повертає claude_model (дефолт).
94
- * @param {typeof CONFIG_DEFAULTS} config конфігурація
95
- * @param {string | undefined} modelTier 'MIM' | 'AVG' | 'MAX'
96
- * @returns {string} model id
97
- */
98
- export function resolveModelByTier(config, modelTier) {
99
- if (modelTier && config.model_map[modelTier]) {
100
- return config.model_map[modelTier]
101
- }
102
- return config.claude_model
103
- }
@@ -1,224 +0,0 @@
1
- /**
2
- * YAML front-matter parser/serializer для graph task-файлів.
3
- *
4
- * Підтримує:
5
- * - Прості `key: value` рядки
6
- * - Вкладені об'єкти (блок з відступами), напр. `executor:` + indented children
7
- * - Списки: `deps:` / `skills:` із рядками ` - item`
8
- * - Серіалізацію назад у YAML (для запису front-matter)
9
- *
10
- * Чисто — без залежностей, тільки вбудований JS. FS не торкається.
11
- */
12
-
13
- const FM_BOUNDARY_RE = /^---\r?\n([\s\S]*?)\r?\n---/
14
-
15
- /**
16
- * Парсить YAML front-matter з markdown-тексту.
17
- * Повертає словник (може містити вкладені об'єкти та масиви).
18
- * @param {string} text вміст файлу
19
- * @returns {Record<string, unknown>} ключ-значення, або {} якщо front-matter відсутній
20
- */
21
- export function parseFrontMatter(text) {
22
- const m = text.match(FM_BOUNDARY_RE)
23
- if (!m) return {}
24
- return parseYamlBlock(m[1])
25
- }
26
-
27
- /**
28
- * Отримує тіло документа (без front-matter).
29
- * @param {string} text вміст файлу
30
- * @returns {string} тіло без front-matter
31
- */
32
- export function getBody(text) {
33
- const m = text.match(FM_BOUNDARY_RE)
34
- if (!m) return text
35
- return text.slice(m[0].length).trimStart()
36
- }
37
-
38
- /**
39
- * Парсить YAML-блок (без --- рядків).
40
- * @param {string} block YAML-текст
41
- * @returns {Record<string, unknown>} розпарсений об'єкт
42
- */
43
- function parseYamlBlock(block) {
44
- const lines = block.split(/\r?\n/)
45
- const result = {}
46
- let i = 0
47
-
48
- while (i < lines.length) {
49
- const line = lines[i]
50
- if (!line.trim() || line.trimStart().startsWith('#')) {
51
- i++
52
- continue
53
- }
54
-
55
- const indent = getIndent(line)
56
- if (indent > 0) {
57
- // Верхній рівень — пропускаємо "бродячі" дочірні рядки
58
- i++
59
- continue
60
- }
61
-
62
- const colonIdx = line.indexOf(':')
63
- if (colonIdx === -1) {
64
- i++
65
- continue
66
- }
67
-
68
- const key = line.slice(0, colonIdx).trim()
69
- const rawVal = line.slice(colonIdx + 1).trim()
70
-
71
- if (rawVal.length > 0) {
72
- // Inline значення — scalar
73
- result[key] = parseScalar(rawVal)
74
- i++
75
- } else {
76
- // Значення відсутнє після ':' — дивимось наступні рядки
77
- i++
78
- if (i >= lines.length) {
79
- result[key] = null
80
- continue
81
- }
82
-
83
- const nextLine = lines[i]
84
- if (!nextLine.trim()) {
85
- result[key] = null
86
- continue
87
- }
88
-
89
- const nextIndent = getIndent(nextLine)
90
- if (nextIndent === 0) {
91
- // Без відступу — null
92
- result[key] = null
93
- continue
94
- }
95
-
96
- const nextTrimmed = nextLine.trimStart()
97
- if (nextTrimmed.startsWith('- ')) {
98
- // Список
99
- const arr = []
100
- while (i < lines.length) {
101
- const l = lines[i]
102
- if (!l.trim()) {
103
- i++
104
- continue
105
- }
106
- const ind = getIndent(l)
107
- if (ind === 0) break
108
- const t = l.trimStart()
109
- if (t.startsWith('- ')) {
110
- arr.push(parseScalar(t.slice(2).trim()))
111
- }
112
- i++
113
- }
114
- result[key] = arr
115
- } else {
116
- // Вкладений об'єкт
117
- const childLines = []
118
- while (i < lines.length) {
119
- const l = lines[i]
120
- if (!l.trim()) {
121
- i++
122
- continue
123
- }
124
- if (getIndent(l) === 0) break
125
- // Нормалізуємо відступ (видаляємо перший рівень)
126
- childLines.push(l.slice(nextIndent))
127
- i++
128
- }
129
- result[key] = parseYamlBlock(childLines.join('\n'))
130
- }
131
- }
132
- }
133
-
134
- return result
135
- }
136
-
137
- /**
138
- * Повертає кількість пробілів на початку рядка.
139
- * @param {string} line рядок
140
- * @returns {number} кількість пробілів
141
- */
142
- function getIndent(line) {
143
- let count = 0
144
- for (const ch of line) {
145
- if (ch === ' ') count++
146
- else break
147
- }
148
- return count
149
- }
150
-
151
- /**
152
- * Парсить скалярне значення: число, булеве, null, або рядок.
153
- * @param {string} s рядок-значення
154
- * @returns {unknown} розпарсене значення
155
- */
156
- function parseScalar(s) {
157
- if (s === 'true') return true
158
- if (s === 'false') return false
159
- if (s === 'null' || s === '~') return null
160
- const n = Number(s)
161
- if (!Number.isNaN(n) && s.trim().length > 0) return n
162
- // Знімаємо лапки
163
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
164
- return s.slice(1, -1)
165
- }
166
- return s
167
- }
168
-
169
- /**
170
- * Серіалізує об'єкт у YAML-рядок (для front-matter).
171
- * Підтримує прості scalar, масиви та вкладені об'єкти.
172
- * @param {Record<string, unknown>} obj об'єкт для серіалізації
173
- * @param {number} [indentLevel=0] рівень відступу
174
- * @returns {string} YAML-рядок (без --- маркерів)
175
- */
176
- export function serializeYaml(obj, indentLevel = 0) {
177
- const indent = ' '.repeat(indentLevel)
178
- const lines = []
179
-
180
- for (const [key, val] of Object.entries(obj)) {
181
- if (val === null || val === undefined) {
182
- lines.push(`${indent}${key}:`)
183
- } else if (Array.isArray(val)) {
184
- lines.push(`${indent}${key}:`)
185
- for (const item of val) {
186
- lines.push(`${indent} - ${serializeScalar(item)}`)
187
- }
188
- } else if (typeof val === 'object') {
189
- lines.push(`${indent}${key}:`)
190
- lines.push(serializeYaml(val, indentLevel + 1))
191
- } else {
192
- lines.push(`${indent}${key}: ${serializeScalar(val)}`)
193
- }
194
- }
195
-
196
- return lines.join('\n')
197
- }
198
-
199
- /**
200
- * Серіалізує скалярне значення у рядок.
201
- * @param {unknown} val значення
202
- * @returns {string} рядкове представлення
203
- */
204
- function serializeScalar(val) {
205
- if (typeof val === 'string') {
206
- // Додаємо лапки якщо містить спецсимволи
207
- if (/[:#\[\]{},\n]/.test(val) || val.trim() !== val) {
208
- return `"${val.replace(/"/g, '\\"')}"`
209
- }
210
- return val
211
- }
212
- return String(val)
213
- }
214
-
215
- /**
216
- * Будує markdown-файл із front-matter і тілом.
217
- * @param {Record<string, unknown>} fm об'єкт front-matter
218
- * @param {string} [body=''] тіло документа
219
- * @returns {string} повний вміст файлу
220
- */
221
- export function buildMarkdown(fm, body = '') {
222
- const yaml = serializeYaml(fm)
223
- return ['---', yaml, '---', '', body].join('\n')
224
- }
@@ -1,127 +0,0 @@
1
- /**
2
- * NNN-нумерація для артефактів вузлів графу (run_NNN.md, fact_NNN.md, тощо).
3
- *
4
- * Всі функції — чисті утиліти, файлову систему отримують через ін'єкцію.
5
- * NNN = рядок з ведучими нулями до 3 цифр: '001', '002', …
6
- */
7
-
8
- const RUN_FILE_RE = /^run_(\d+)\.md$/
9
- const PLAN_FILE_RE = /^plan_(\d+)\.md$/
10
- const FACT_FILE_RE = /^fact_(\d+)\.md$/
11
- const PENDING_AUDIT_FILE_RE = /^pending-audit_(\d+)\.md$/
12
- const AUDIT_RESULT_FILE_RE = /^audit-result_(\d+)\.md$/
13
-
14
- /**
15
- * Форматує число як NNN рядок (три цифри з ведучими нулями).
16
- * @param {number} n невід'ємне ціле число
17
- * @returns {string} '001', '002', …
18
- */
19
- export function padNNN(n) {
20
- return String(n).padStart(3, '0')
21
- }
22
-
23
- /**
24
- * Знаходить максимальний NNN серед файлів що відповідають regex, або 0 якщо не знайдено.
25
- * @param {string[]} files список файлів директорії
26
- * @param {RegExp} re regex з групою захоплення числа
27
- * @returns {number} максимальний NNN або 0
28
- */
29
- function maxNNN(files, re) {
30
- let max = 0
31
- for (const f of files) {
32
- const m = f.match(re)
33
- if (m) {
34
- const n = parseInt(m[1], 10)
35
- if (n > max) max = n
36
- }
37
- }
38
- return max
39
- }
40
-
41
- /**
42
- * Наступний NNN для run_NNN.md: count(run_*.md) + 1.
43
- * @param {string} nodeDir абсолютний шлях до директорії вузла
44
- * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
45
- * @returns {string} наступний NNN рядок
46
- */
47
- export function nextRunNNN(nodeDir, readdirSync) {
48
- const files = readdirSync(nodeDir)
49
- let count = 0
50
- for (const f of files) {
51
- if (RUN_FILE_RE.test(f)) count++
52
- }
53
- return padNNN(count + 1)
54
- }
55
-
56
- /**
57
- * Наступний NNN для plan_NNN.md: max(plan_*.md numbers) + 1.
58
- * @param {string} nodeDir абсолютний шлях до директорії вузла
59
- * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
60
- * @returns {string} наступний NNN рядок
61
- */
62
- export function nextPlanNNN(nodeDir, readdirSync) {
63
- const files = readdirSync(nodeDir)
64
- return padNNN(maxNNN(files, PLAN_FILE_RE) + 1)
65
- }
66
-
67
- /**
68
- * Найвищий NNN серед fact_NNN.md, або null якщо немає.
69
- * @param {string} nodeDir абсолютний шлях до директорії вузла
70
- * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
71
- * @returns {string | null} NNN рядок або null
72
- */
73
- export function latestFactNNN(nodeDir, readdirSync) {
74
- const files = readdirSync(nodeDir)
75
- const m = maxNNN(files, FACT_FILE_RE)
76
- return m > 0 ? padNNN(m) : null
77
- }
78
-
79
- /**
80
- * Перевіряє чи є pending-audit без відповідного audit-result.
81
- * @param {string} nodeDir абсолютний шлях до директорії вузла
82
- * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
83
- * @returns {{ has: boolean, nnn: string | null }} результат
84
- */
85
- export function hasPendingAudit(nodeDir, readdirSync) {
86
- const files = readdirSync(nodeDir)
87
- const fileSet = new Set(files)
88
-
89
- const pendingNNNs = []
90
- for (const f of files) {
91
- const m = f.match(PENDING_AUDIT_FILE_RE)
92
- if (m) pendingNNNs.push(m[1])
93
- }
94
-
95
- for (const nnn of pendingNNNs) {
96
- const resultFile = `audit-result_${nnn}.md`
97
- if (!fileSet.has(resultFile)) {
98
- return { has: true, nnn: padNNN(parseInt(nnn, 10)) }
99
- }
100
- }
101
-
102
- return { has: false, nnn: null }
103
- }
104
-
105
- /**
106
- * Знаходить NNN для останнього pending-audit_NNN.md (для audit-result).
107
- * @param {string} nodeDir абсолютний шлях до директорії вузла
108
- * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
109
- * @returns {string | null} NNN рядок або null
110
- */
111
- export function latestPendingAuditNNN(nodeDir, readdirSync) {
112
- const files = readdirSync(nodeDir)
113
- const m = maxNNN(files, PENDING_AUDIT_FILE_RE)
114
- return m > 0 ? padNNN(m) : null
115
- }
116
-
117
- /**
118
- * Знаходить NNN для останнього audit-result_NNN.md.
119
- * @param {string} nodeDir абсолютний шлях до директорії вузла
120
- * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
121
- * @returns {string | null} NNN рядок або null
122
- */
123
- export function latestAuditResultNNN(nodeDir, readdirSync) {
124
- const files = readdirSync(nodeDir)
125
- const m = maxNNN(files, AUDIT_RESULT_FILE_RE)
126
- return m > 0 ? padNNN(m) : null
127
- }
@@ -1,157 +0,0 @@
1
- /**
2
- * Деривація стану вузла з файлової системи (immutable file-presence protocol).
3
- *
4
- * Стан визначається виключно наявністю файлів у tasks/<node>/:
5
- * invalidated > resolved > pending-audit > running > failed > waiting > needs-plan
6
- *
7
- * Чиста функція — FS ін'єктується. Не пише нічого на диск.
8
- */
9
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
10
- import { join } from 'node:path'
11
-
12
- import { hasPendingAudit, latestFactNNN } from './nnn.mjs'
13
-
14
- /** Всі можливі стани вузла. */
15
- export const NODE_STATES = /** @type {const} */ ([
16
- 'needs-plan',
17
- 'waiting',
18
- 'running',
19
- 'pending-audit',
20
- 'resolved',
21
- 'failed',
22
- 'invalidated'
23
- ])
24
-
25
- /**
26
- * Перевіряє чи директорія є composite-вузлом (містить дочірні директорії з task.md).
27
- * @param {string} nodeDir абсолютний шлях до директорії вузла
28
- * @param {{ readdirSync?: (d: string) => string[], existsSync?: (p: string) => boolean }} [deps] ін'єкції
29
- * @returns {boolean} true якщо є хоча б один дочірній вузол
30
- */
31
- export function isComposite(nodeDir, deps = {}) {
32
- const readdir = deps.readdirSync ?? readdirSync
33
- const exists = deps.existsSync ?? existsSync
34
-
35
- let entries
36
- try {
37
- entries = readdir(nodeDir)
38
- } catch {
39
- return false
40
- }
41
-
42
- return entries.some(name => {
43
- const childTask = join(nodeDir, name, 'task.md')
44
- return exists(childTask)
45
- })
46
- }
47
-
48
- /**
49
- * Деривує composite-стан з масиву станів дочірніх вузлів.
50
- * @param {string[]} childStates масив станів дочірніх вузлів
51
- * @returns {string} агрегований стан
52
- */
53
- export function deriveCompositeState(childStates) {
54
- if (childStates.length === 0) return 'waiting'
55
- if (childStates.some(s => s === 'invalidated')) return 'invalidated'
56
- if (childStates.some(s => s === 'failed')) return 'failed'
57
- if (childStates.some(s => s === 'running')) return 'running'
58
- if (childStates.some(s => s === 'pending-audit')) return 'pending-audit'
59
- if (childStates.every(s => s === 'resolved')) return 'resolved'
60
- return 'waiting'
61
- }
62
-
63
- /**
64
- * Деривує стан одного вузла з присутності файлів.
65
- *
66
- * Пріоритет: invalidated > resolved > pending-audit > running > failed > waiting > needs-plan
67
- *
68
- * @param {string} nodeDir абсолютний шлях до директорії вузла
69
- * @param {Set<string>} activeWorktrees set імен активних worktree (наприклад, 'my-node-1234567890')
70
- * @param {{
71
- * readdirSync?: (d: string) => string[],
72
- * readFileSync?: (p: string, enc: string) => string,
73
- * existsSync?: (p: string) => boolean
74
- * }} [deps] ін'єкції
75
- * @returns {string} стан вузла
76
- */
77
- export function deriveNodeState(nodeDir, activeWorktrees, deps = {}) {
78
- const readdir = deps.readdirSync ?? readdirSync
79
- const readFile = deps.readFileSync ?? ((p, enc) => readFileSync(p, enc))
80
- const exists = deps.existsSync ?? existsSync
81
-
82
- // Файл task.md обов'язковий
83
- if (!exists(join(nodeDir, 'task.md'))) {
84
- return 'needs-plan'
85
- }
86
-
87
- let files
88
- try {
89
- files = readdir(nodeDir)
90
- } catch {
91
- return 'needs-plan'
92
- }
93
-
94
- const fileSet = new Set(files)
95
-
96
- // 1. invalidated — sentinel файл
97
- if (fileSet.has('invalidated')) return 'invalidated'
98
-
99
- // 2. resolved — є fact_NNN.md і немає invalidated
100
- const factNNN = latestFactNNN(nodeDir, readdir)
101
- if (factNNN !== null) return 'resolved'
102
-
103
- // 3. pending-audit — є pending-audit_NNN.md без відповідного audit-result_NNN.md
104
- const { has: hasPending } = hasPendingAudit(nodeDir, readdir)
105
- if (hasPending) return 'pending-audit'
106
-
107
- // 4. running — активний worktree існує (перевіряємо за prefix node dir name)
108
- const nodeName = nodeDir.split('/').filter(Boolean).pop() ?? ''
109
- if (activeWorktrees.size > 0) {
110
- for (const wt of activeWorktrees) {
111
- // worktree name: sanitized-node-path-epoch
112
- if (wt.includes(sanitizeNodeName(nodeName))) return 'running'
113
- }
114
- }
115
-
116
- // 5. failed — є run_NNN.md з result:failed, без fact_NNN.md і без активного worktree
117
- const runFiles = files.filter(f => /^run_\d+\.md$/.test(f))
118
- if (runFiles.length > 0) {
119
- // Перевіряємо останній run файл
120
- let hasFailedRun = false
121
- for (const runFile of runFiles) {
122
- try {
123
- const content = readFile(join(nodeDir, runFile), 'utf8')
124
- if (content.includes('result: failed') || content.includes('result:failed')) {
125
- hasFailedRun = true
126
- }
127
- } catch {
128
- // пропускаємо нечитабельні файли
129
- }
130
- }
131
- if (hasFailedRun) return 'failed'
132
- }
133
-
134
- // 6. waiting — є plan_NNN.md АБО mode:agent
135
- const hasPlan = files.some(f => /^plan_\d+\.md$/.test(f))
136
- if (hasPlan) return 'waiting'
137
-
138
- // Читаємо mode з task.md
139
- try {
140
- const taskContent = readFile(join(nodeDir, 'task.md'), 'utf8')
141
- if (taskContent.includes('mode: agent')) return 'waiting'
142
- } catch {
143
- // пропускаємо
144
- }
145
-
146
- // 7. needs-plan — task.md є, mode:human (default), немає plan_NNN.md
147
- return 'needs-plan'
148
- }
149
-
150
- /**
151
- * Санітизує ім'я вузла для використання в назві worktree.
152
- * @param {string} name ім'я вузла (може містити /)
153
- * @returns {string} санітизоване ім'я
154
- */
155
- export function sanitizeNodeName(name) {
156
- return name.replace(/[^a-zA-Z0-9_-]/g, '-')
157
- }