@nitra/cursor 4.1.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 +6 -0
  2. package/bin/n-cursor.js +25 -13
  3. package/lib/models.mjs +1 -2
  4. package/package.json +1 -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,157 @@
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
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * DAG-сканер вузлів задач.
3
+ *
4
+ * Рекурсивно обходить tasks_dir, знаходить всі вузли (директорії з task.md),
5
+ * читає їх залежності з task.md front-matter, деривує стани та виконує
6
+ * топологічне сортування (Kahn's algorithm).
7
+ *
8
+ * FS ін'єктується. Нічого не пише на диск.
9
+ */
10
+ import { execSync } from 'node:child_process'
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+
14
+ import { parseFrontMatter } from './frontmatter.mjs'
15
+ import { deriveNodeState, isComposite } from './node-state.mjs'
16
+
17
+ /**
18
+ * @typedef {{
19
+ * id: string,
20
+ * path: string,
21
+ * dir: string,
22
+ * deps: string[],
23
+ * state: string,
24
+ * composite: boolean,
25
+ * children: string[]
26
+ * }} NodeInfo
27
+ */
28
+
29
+ /**
30
+ * Рекурсивно знаходить всі вузли DAG у tasks_dir.
31
+ * Вузол = директорія що містить task.md.
32
+ * @param {string} tasksDir абсолютний шлях до tasks/
33
+ * @param {{
34
+ * readdirSync?: (d: string) => string[],
35
+ * existsSync?: (p: string) => boolean,
36
+ * readFileSync?: (p: string, enc: string) => string
37
+ * }} [deps] ін'єкції
38
+ * @returns {{ dir: string, relPath: string }[]} список знайдених вузлів
39
+ */
40
+ export function findNodes(tasksDir, deps = {}) {
41
+ const readdir = deps.readdirSync ?? readdirSync
42
+ const exists = deps.existsSync ?? existsSync
43
+
44
+ const nodes = []
45
+
46
+ function scan(dir, prefix = '') {
47
+ let entries
48
+ try {
49
+ entries = readdir(dir)
50
+ } catch {
51
+ return
52
+ }
53
+
54
+ const hasTaskMd = entries.includes('task.md')
55
+ if (hasTaskMd) {
56
+ nodes.push({
57
+ dir,
58
+ relPath: prefix ? prefix : dir.split('/').pop() ?? dir
59
+ })
60
+ }
61
+
62
+ // Рекурсивно шукаємо дочірні директорії
63
+ for (const name of entries) {
64
+ // Пропускаємо зарезервовані та приховані директорії/файли
65
+ if (name.startsWith('.') || name.includes('.')) continue
66
+ const childDir = join(dir, name)
67
+ // Перевіряємо що це директорія (якщо має subdirs або task.md)
68
+ const childRelPath = prefix ? `${prefix}/${name}` : name
69
+ try {
70
+ // Перевірка що childDir — дійсно директорія
71
+ readdir(childDir)
72
+ scan(childDir, childRelPath)
73
+ } catch {
74
+ // не директорія або не читається
75
+ }
76
+ }
77
+ }
78
+
79
+ scan(tasksDir)
80
+ return nodes
81
+ }
82
+
83
+ /**
84
+ * Сканує DAG і повертає всі вузли з деривованими станами.
85
+ * @param {string} tasksDir абсолютний шлях до tasks/
86
+ * @param {Set<string>} activeWorktrees активні worktree imена
87
+ * @param {{
88
+ * readdirSync?: (d: string) => string[],
89
+ * existsSync?: (p: string) => boolean,
90
+ * readFileSync?: (p: string, enc: string) => string
91
+ * }} [deps] ін'єкції
92
+ * @returns {NodeInfo[]} список вузлів
93
+ */
94
+ export function scanNodes(tasksDir, activeWorktrees, deps = {}) {
95
+ const readdir = deps.readdirSync ?? readdirSync
96
+ const exists = deps.existsSync ?? existsSync
97
+ const readFile = deps.readFileSync ?? ((p, enc) => readFileSync(p, enc))
98
+
99
+ const found = findNodes(tasksDir, { readdirSync: readdir, existsSync: exists, readFileSync: readFile })
100
+
101
+ return found.map(({ dir, relPath }) => {
102
+ let fm = {}
103
+ try {
104
+ const taskContent = readFile(join(dir, 'task.md'), 'utf8')
105
+ fm = parseFrontMatter(taskContent)
106
+ } catch {
107
+ // порожній front-matter
108
+ }
109
+
110
+ const deps_ = Array.isArray(fm.deps) ? fm.deps.map(String) : []
111
+ const state = deriveNodeState(dir, activeWorktrees, { readdirSync: readdir, readFileSync: readFile, existsSync: exists })
112
+ const composite = isComposite(dir, { readdirSync: readdir, existsSync: exists })
113
+
114
+ // Дочірні вузли
115
+ let children = []
116
+ if (composite) {
117
+ let entries
118
+ try {
119
+ entries = readdir(dir)
120
+ } catch {
121
+ entries = []
122
+ }
123
+ children = entries
124
+ .filter(name => !name.startsWith('.') && !name.endsWith('.md') && !name.endsWith('.json'))
125
+ .filter(name => {
126
+ try {
127
+ return exists(join(dir, name, 'task.md'))
128
+ } catch {
129
+ return false
130
+ }
131
+ })
132
+ .map(name => `${relPath}/${name}`)
133
+ }
134
+
135
+ return {
136
+ id: relPath,
137
+ path: relPath,
138
+ dir,
139
+ deps: deps_,
140
+ state,
141
+ composite,
142
+ children
143
+ }
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Топологічне сортування вузлів (алгоритм Кана).
149
+ * Вузли без залежностей — першими. Циклічні залежності — не гарантовано.
150
+ * @param {NodeInfo[]} nodes вузли зі списком deps
151
+ * @returns {NodeInfo[]} відсортований список (або той самий порядок якщо циклічні)
152
+ */
153
+ export function topoSort(nodes) {
154
+ const idToNode = new Map(nodes.map(n => [n.id, n]))
155
+ const inDegree = new Map(nodes.map(n => [n.id, 0]))
156
+ const adj = new Map(nodes.map(n => [n.id, []]))
157
+
158
+ for (const node of nodes) {
159
+ for (const dep of node.deps) {
160
+ if (idToNode.has(dep)) {
161
+ adj.get(dep).push(node.id)
162
+ inDegree.set(node.id, (inDegree.get(node.id) ?? 0) + 1)
163
+ }
164
+ }
165
+ }
166
+
167
+ const queue = nodes.filter(n => (inDegree.get(n.id) ?? 0) === 0).map(n => n.id)
168
+ const sorted = []
169
+
170
+ while (queue.length > 0) {
171
+ const id = queue.shift()
172
+ const node = idToNode.get(id)
173
+ if (node) sorted.push(node)
174
+ for (const next of (adj.get(id) ?? [])) {
175
+ const deg = (inDegree.get(next) ?? 0) - 1
176
+ inDegree.set(next, deg)
177
+ if (deg === 0) queue.push(next)
178
+ }
179
+ }
180
+
181
+ // Якщо є цикли — додаємо решту у кінець
182
+ if (sorted.length < nodes.length) {
183
+ for (const n of nodes) {
184
+ if (!sorted.includes(n)) sorted.push(n)
185
+ }
186
+ }
187
+
188
+ return sorted
189
+ }
190
+
191
+ /**
192
+ * Перевіряє чи всі залежності вузла resolved.
193
+ * @param {NodeInfo} node вузол
194
+ * @param {Map<string, NodeInfo>} nodeMap map id -> NodeInfo
195
+ * @returns {boolean} true якщо всі deps resolved
196
+ */
197
+ export function areDepsResolved(node, nodeMap) {
198
+ return node.deps.every(dep => {
199
+ const depNode = nodeMap.get(dep)
200
+ return depNode?.state === 'resolved'
201
+ })
202
+ }
203
+
204
+ /**
205
+ * Знаходить активні worktrees з git worktree list.
206
+ * @param {string} root корінь репо
207
+ * @param {{ execSync?: (cmd: string, opts?: object) => string }} [deps] ін'єкції
208
+ * @returns {Set<string>} set імен worktree
209
+ */
210
+ export function getActiveWorktrees(root, deps = {}) {
211
+ const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts))
212
+ try {
213
+ const out = execSyncFn('git worktree list --porcelain', { cwd: root, encoding: 'utf8' })
214
+ return parseWorktreeList(String(out))
215
+ } catch {
216
+ return new Set()
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Парсить вивід `git worktree list --porcelain` і повертає набір імен worktree.
222
+ * @param {string} output вивід команди
223
+ * @returns {Set<string>} set імен (останній компонент шляху)
224
+ */
225
+ export function parseWorktreeList(output) {
226
+ const names = new Set()
227
+ for (const line of output.split('\n')) {
228
+ if (line.startsWith('worktree ')) {
229
+ const path = line.slice('worktree '.length).trim()
230
+ const name = path.split('/').pop() ?? ''
231
+ if (name) names.add(name)
232
+ }
233
+ }
234
+ return names
235
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Git worktree management для graph task system.
3
+ *
4
+ * Atomic mkdir lock: EEXIST → skip (вже запущено).
5
+ * Worktree name: sanitize(node-path) + '-' + epoch (секунди).
6
+ *
7
+ * Всі git операції через execSync (node:child_process). FS через ін'єкцію.
8
+ */
9
+ import { execSync } from 'node:child_process'
10
+ import { mkdirSync, readdirSync, rmSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
13
+ import { sanitizeNodeName } from './node-state.mjs'
14
+
15
+ /**
16
+ * Генерує ім'я worktree для вузла.
17
+ * @param {string} nodePath відносний шлях вузла (напр. "research/collect-data")
18
+ * @param {number} [epochSec] epoch в секундах (default: Date.now()/1000)
19
+ * @returns {string} ім'я worktree
20
+ */
21
+ export function makeWorktreeName(nodePath, epochSec) {
22
+ const epoch = epochSec ?? Math.floor(Date.now() / 1000)
23
+ const sanitized = sanitizeNodeName(nodePath.replace(/\//g, '-'))
24
+ return `${sanitized}-${epoch}`
25
+ }
26
+
27
+ /**
28
+ * Створює git worktree для вузла з atomic mkdir lock.
29
+ * Повертає null якщо worktree вже існує (EEXIST → вже запущено).
30
+ *
31
+ * @param {string} worktreesDir абсолютний шлях до .worktrees/
32
+ * @param {string} worktreeName ім'я нового worktree
33
+ * @param {string} root корінь репо
34
+ * @param {{
35
+ * execSync?: (cmd: string, opts?: object) => string,
36
+ * mkdirSync?: (p: string, opts?: object) => void
37
+ * }} [deps] ін'єкції
38
+ * @returns {{ worktreePath: string } | null} шлях worktree або null якщо вже існує
39
+ */
40
+ export function createWorktree(worktreesDir, worktreeName, root, deps = {}) {
41
+ const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts))
42
+ const mkdirSyncFn = deps.mkdirSync ?? mkdirSync
43
+
44
+ const worktreePath = join(worktreesDir, worktreeName)
45
+
46
+ // Atomic mkdir lock: якщо директорія вже є — хтось вже запустив цей вузол
47
+ try {
48
+ mkdirSyncFn(worktreePath, { recursive: false })
49
+ } catch (err) {
50
+ if (err.code === 'EEXIST') return null
51
+ throw err
52
+ }
53
+
54
+ try {
55
+ // Видаляємо порожню директорію — git worktree add створить її сам
56
+ rmSync(worktreePath, { recursive: true, force: true })
57
+ execSyncFn(`git worktree add "${worktreePath}" HEAD`, { cwd: root, encoding: 'utf8' })
58
+ } catch (err) {
59
+ // Якщо git worktree add не вдався — прибираємо директорію
60
+ try {
61
+ rmSync(worktreePath, { recursive: true, force: true })
62
+ } catch {
63
+ // пропускаємо
64
+ }
65
+ throw err
66
+ }
67
+
68
+ return { worktreePath }
69
+ }
70
+
71
+ /**
72
+ * Видаляє git worktree.
73
+ * @param {string} worktreePath абсолютний шлях до worktree
74
+ * @param {string} root корінь репо
75
+ * @param {{
76
+ * execSync?: (cmd: string, opts?: object) => string
77
+ * }} [deps] ін'єкції
78
+ */
79
+ export function removeWorktree(worktreePath, root, deps = {}) {
80
+ const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts))
81
+ try {
82
+ execSyncFn(`git worktree remove --force "${worktreePath}"`, { cwd: root, encoding: 'utf8' })
83
+ } catch {
84
+ // Якщо не вдалось через git — видаляємо вручну
85
+ try {
86
+ rmSync(worktreePath, { recursive: true, force: true })
87
+ execSyncFn('git worktree prune', { cwd: root, encoding: 'utf8' })
88
+ } catch {
89
+ // пропускаємо — можливо вже видалено
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Мерджить зміни з worktree у main-гілку і видаляє worktree.
96
+ * @param {string} worktreePath абсолютний шлях до worktree
97
+ * @param {string} root корінь репо
98
+ * @param {{
99
+ * execSync?: (cmd: string, opts?: object) => string
100
+ * }} [deps] ін'єкції
101
+ * @returns {{ ok: boolean, error?: string }} результат
102
+ */
103
+ export function mergeWorktree(worktreePath, root, deps = {}) {
104
+ const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts))
105
+
106
+ try {
107
+ // Отримуємо ім'я гілки worktree
108
+ const branch = execSyncFn('git rev-parse --abbrev-ref HEAD', {
109
+ cwd: worktreePath,
110
+ encoding: 'utf8'
111
+ }).trim()
112
+
113
+ // Додаємо всі зміни і комітимо
114
+ execSyncFn('git add -A', { cwd: worktreePath, encoding: 'utf8' })
115
+
116
+ let hasChanges = false
117
+ try {
118
+ execSyncFn('git diff --cached --quiet', { cwd: worktreePath, encoding: 'utf8' })
119
+ } catch {
120
+ hasChanges = true
121
+ }
122
+
123
+ if (hasChanges) {
124
+ execSyncFn('git commit -m "graph: node task completion"', { cwd: worktreePath, encoding: 'utf8' })
125
+ }
126
+
127
+ // Якщо worktree на окремій гілці — мерджимо в main
128
+ if (branch && branch !== 'HEAD' && branch !== 'main' && branch !== 'master') {
129
+ execSyncFn(`git merge --no-ff "${branch}" -m "graph: merge node ${branch}"`, {
130
+ cwd: root,
131
+ encoding: 'utf8'
132
+ })
133
+ }
134
+ } catch (err) {
135
+ return { ok: false, error: err.message ?? String(err) }
136
+ }
137
+
138
+ // Видаляємо worktree
139
+ removeWorktree(worktreePath, root, { execSync: execSyncFn })
140
+ return { ok: true }
141
+ }
142
+
143
+ /**
144
+ * Повертає список активних worktrees з репо.
145
+ * @param {string} root корінь репо
146
+ * @param {{
147
+ * execSync?: (cmd: string, opts?: object) => string
148
+ * }} [deps] ін'єкції
149
+ * @returns {Set<string>} set імен worktrees
150
+ */
151
+ export function listActiveWorktrees(root, deps = {}) {
152
+ const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts))
153
+
154
+ try {
155
+ const out = execSyncFn('git worktree list --porcelain', { cwd: root, encoding: 'utf8' })
156
+ const names = new Set()
157
+ for (const line of String(out).split('\n')) {
158
+ if (line.startsWith('worktree ')) {
159
+ const path = line.slice('worktree '.length).trim()
160
+ const name = path.split('/').pop() ?? ''
161
+ if (name) names.add(name)
162
+ }
163
+ }
164
+ return names
165
+ } catch {
166
+ return new Set()
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Знаходить worktree що належить вузлу (за prefix).
172
+ * @param {string} nodePath відносний шлях вузла
173
+ * @param {string} worktreesDir абсолютний шлях до .worktrees/
174
+ * @param {{
175
+ * readdirSync?: (d: string) => string[],
176
+ * execSync?: (cmd: string, opts?: object) => string
177
+ * }} [deps] ін'єкції
178
+ * @returns {string | null} абсолютний шлях до worktree або null
179
+ */
180
+ export function findNodeWorktree(nodePath, worktreesDir, deps = {}) {
181
+ const readdirSyncFn = deps.readdirSync ?? readdirSync
182
+ const prefix = sanitizeNodeName(nodePath.replace(/\//g, '-'))
183
+
184
+ let entries
185
+ try {
186
+ entries = readdirSyncFn(worktreesDir)
187
+ } catch {
188
+ return null
189
+ }
190
+
191
+ const match = entries.find(name => name.startsWith(prefix + '-') || name === prefix)
192
+ return match ? join(worktreesDir, match) : null
193
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `n-cursor graph` — task DAG orchestration system.
3
+ *
4
+ * Управляє задачами у `tasks/<node>/` директоріях. Стан вузлів деривується
5
+ * з файлів (immutable protocol): task.md + plan/run/fact/pending-audit/audit-result/invalidated.
6
+ *
7
+ * Підкоманди:
8
+ * setup — ініціалізація проєкту (.n-cursor.json, tasks/, git hook)
9
+ * init <name> — створити task.md шаблон
10
+ * plan [<path>] [--mode agent] — Stage 1: написати plan_NNN.md
11
+ * status [<path>] [--json] — показати стан DAG
12
+ * scan [--json] — повний скан, exit 1 якщо failed
13
+ * run [<path>] [--actor a] [--auto] — запустити вузол(и)
14
+ * kill <path> — вбити worktree + каскадна інвалідація + видалити plan_*.md
15
+ * invalidate <path> [--no-cascade] — позначити як invalidated
16
+ * done <path> — успіх → merge worktree
17
+ * audit <path> — pending-audit_NNN.md + merge agent worktree
18
+ * failed <path> — провал → run_NNN.md (failed)
19
+ * spawn <path> — composite → перевірити дочірні вузли
20
+ * watch — одноразовий скан: audit queue + stale + needs-plan
21
+ */
22
+
23
+ import { cmdSetup } from './graph/lib/cmd-setup.mjs'
24
+ import { cmdInit } from './graph/lib/cmd-init.mjs'
25
+ import { cmdPlan } from './graph/lib/cmd-plan.mjs'
26
+ import { cmdStatus } from './graph/lib/cmd-status.mjs'
27
+ import { cmdScan } from './graph/lib/cmd-scan.mjs'
28
+ import { cmdRun } from './graph/lib/cmd-run.mjs'
29
+ import { cmdKill } from './graph/lib/cmd-kill.mjs'
30
+ import { cmdInvalidate } from './graph/lib/cmd-invalidate.mjs'
31
+ import { cmdDone, cmdAudit, cmdFailed, cmdSpawn } from './graph/lib/cmd-signals.mjs'
32
+ import { cmdWatch } from './graph/lib/cmd-watch.mjs'
33
+
34
+ const USAGE = [
35
+ 'Usage: n-cursor graph <command> [args]',
36
+ '',
37
+ 'Commands:',
38
+ ' setup init project: .n-cursor.json, tasks/, git hook',
39
+ ' init <name> create task.md template',
40
+ ' plan [<path>] [--mode agent] Stage 1: write plan_NNN.md',
41
+ ' status [<path>] [--json] show DAG state',
42
+ ' scan [--json] full scan, exit 1 if failed',
43
+ ' run [<path>] [--actor a] [--auto] run node(s)',
44
+ ' kill <path> kill worktree + cascade invalidate + delete plan_*.md',
45
+ ' invalidate <path> [--no-cascade] mark invalidated',
46
+ ' done <path> success → merge worktree',
47
+ ' audit <path> pending-audit_NNN.md + merge agent worktree',
48
+ ' failed <path> failure → write run_NNN.md',
49
+ ' spawn <path> composite → validate children registered',
50
+ ' watch one-shot scan: audit queue + stale + needs-plan'
51
+ ].join('\n')
52
+
53
+ /** @type {Record<string, (args: string[], deps: object) => Promise<number>>} */
54
+ const COMMANDS = {
55
+ setup: cmdSetup,
56
+ init: cmdInit,
57
+ plan: cmdPlan,
58
+ status: cmdStatus,
59
+ scan: cmdScan,
60
+ run: cmdRun,
61
+ kill: cmdKill,
62
+ invalidate: cmdInvalidate,
63
+ done: cmdDone,
64
+ audit: cmdAudit,
65
+ failed: cmdFailed,
66
+ spawn: cmdSpawn,
67
+ watch: cmdWatch
68
+ }
69
+
70
+ /**
71
+ * Точка входу `n-cursor graph` та `n-cursor watch`.
72
+ * Парсить підкоманду і маршрутизує до відповідного handler-а.
73
+ *
74
+ * @param {string[]} args аргументи після `graph` (або `watch`)
75
+ * @param {{
76
+ * cwd?: string,
77
+ * log?: (m: string) => void,
78
+ * [key: string]: unknown
79
+ * }} [deps] ін'єкції (пробрасуються до підкоманд)
80
+ * @returns {Promise<number>} exit code
81
+ */
82
+ export async function runGraphTasksCli(args, deps = {}) {
83
+ const [sub, ...rest] = args
84
+
85
+ if (!sub || !Object.hasOwn(COMMANDS, sub)) {
86
+ console.error(USAGE)
87
+ if (sub) console.error(`\nНевідома підкоманда: "${sub}"`)
88
+ return 1
89
+ }
90
+
91
+ return await COMMANDS[sub](rest, deps)
92
+ }
@@ -8,9 +8,9 @@
8
8
  * flow failed — CWD → node path → `graph failed <path>`
9
9
  * flow spawn — CWD → node path → `graph spawn <path>`
10
10
  */
11
- import { plan } from './lib/flow-plan.mjs'
12
- import { verify } from './lib/flow-verify.mjs'
13
- import { audit, done, failed, spawn } from './lib/flow-signals.mjs'
11
+ import { cmdPlan as plan } from './graph/lib/cmd-plan.mjs'
12
+ import { cmdVerify as verify } from './graph/lib/cmd-verify.mjs'
13
+ import { cmdAudit as audit, cmdDone as done, cmdFailed as failed, cmdSpawn as spawn } from './graph/lib/cmd-signals.mjs'
14
14
 
15
15
  const USAGE = [
16
16
  'Usage:',