@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.
- package/CHANGELOG.md +6 -0
- package/bin/n-cursor.js +25 -13
- package/lib/models.mjs +1 -2
- package/package.json +1 -1
- package/rules/abie/fix.mjs +1 -1
- package/rules/bun/docs/fix.md +3 -0
- package/rules/bun/fix.mjs +1 -1
- package/rules/capacitor/fix.mjs +1 -1
- package/rules/changelog/docs/fix.md +3 -0
- package/rules/changelog/fix.mjs +1 -1
- package/rules/ci4/fix.mjs +1 -1
- package/rules/ci4/js/docs/marksman_config.md +1 -0
- package/rules/docker/docs/fix.md +1 -1
- package/rules/docker/fix.mjs +1 -1
- package/rules/docker/lint/docs/lint.md +1 -0
- package/rules/efes/docs/fix.md +2 -1
- package/rules/efes/fix.mjs +1 -1
- package/rules/feedback/fix.mjs +1 -1
- package/rules/ga/fix.mjs +1 -1
- package/rules/ga/js/lint.mjs +1 -1
- package/rules/graphql/docs/fix.md +4 -1
- package/rules/graphql/fix.mjs +1 -1
- package/rules/graphql/lib/docs/graphql-gql-scan.md +3 -0
- package/rules/hasura/fix.mjs +1 -1
- package/rules/image-avif/docs/fix.md +4 -1
- package/rules/image-avif/fix.mjs +1 -1
- package/rules/image-avif/js/docs/avif_generation.md +1 -0
- package/rules/image-compress/fix.mjs +1 -1
- package/rules/js-bun-db/fix.mjs +1 -1
- package/rules/js-bun-db/lib/docs/bun-sql-scan.md +6 -0
- package/rules/js-bun-redis/fix.mjs +1 -1
- package/rules/js-lint/fix.mjs +1 -1
- package/rules/js-lint/js/docs/utils_imports.md +1 -0
- package/rules/js-lint-ci/docs/fix.md +4 -1
- package/rules/js-lint-ci/fix.mjs +1 -1
- package/rules/js-mssql/docs/fix.md +3 -0
- package/rules/js-mssql/fix.mjs +1 -1
- package/rules/js-mssql/lib/docs/mssql-pool-scan.md +9 -0
- package/rules/js-run/docs/fix.md +3 -0
- package/rules/js-run/fix.mjs +1 -1
- package/rules/js-run/lib/docs/check-env-scan.md +2 -1
- package/rules/js-run/lib/docs/promise-settimeout-scan.md +4 -0
- package/rules/k8s/docs/fix.md +3 -0
- package/rules/k8s/fix.mjs +1 -1
- package/rules/nginx-default-tpl/docs/fix.md +3 -0
- package/rules/nginx-default-tpl/fix.mjs +1 -1
- package/rules/npm-module/fix.mjs +1 -1
- package/rules/npm-module/js/header_doc_pointer.mjs +14 -3
- package/rules/php/docs/fix.md +2 -1
- package/rules/php/fix.mjs +1 -1
- package/rules/python/docs/fix.md +4 -1
- package/rules/python/fix.mjs +1 -1
- package/rules/rego/fix.mjs +1 -1
- package/rules/rego/js/lint.mjs +1 -1
- package/rules/release/docs/fix.md +4 -1
- package/rules/release/fix.mjs +1 -1
- package/rules/rust/fix.mjs +1 -1
- package/rules/security/docs/fix.md +1 -1
- package/rules/security/fix.mjs +1 -1
- package/rules/style-lint/docs/fix.md +3 -0
- package/rules/style-lint/fix.mjs +1 -1
- package/rules/tauri/docs/fix.md +4 -1
- package/rules/tauri/fix.mjs +1 -1
- package/rules/test/docs/fix.md +3 -0
- package/rules/test/fix.mjs +1 -1
- package/rules/test/js/no-relative-fs-path.mjs +2 -1
- package/rules/text/docs/fix.md +3 -0
- package/rules/text/fix.mjs +1 -1
- package/rules/text/js/lint.mjs +1 -1
- package/rules/vue/fix.mjs +1 -1
- package/rules/worktree/fix.mjs +1 -1
- package/scripts/auto-rules.mjs +1 -1
- package/scripts/coverage-classify/index.mjs +10 -10
- package/scripts/coverage-fix.mjs +2 -2
- package/scripts/dispatcher/graph/lib/cmd-init.mjs +112 -0
- package/scripts/dispatcher/graph/lib/cmd-invalidate.mjs +96 -0
- package/scripts/dispatcher/graph/lib/cmd-kill.mjs +141 -0
- package/scripts/dispatcher/graph/lib/cmd-plan.mjs +142 -0
- package/scripts/dispatcher/graph/lib/cmd-run.mjs +328 -0
- package/scripts/dispatcher/graph/lib/cmd-scan.mjs +115 -0
- package/scripts/dispatcher/graph/lib/cmd-setup.mjs +111 -0
- package/scripts/dispatcher/graph/lib/cmd-signals.mjs +328 -0
- package/scripts/dispatcher/graph/lib/cmd-status.mjs +131 -0
- package/scripts/dispatcher/graph/lib/cmd-verify.mjs +100 -0
- package/scripts/dispatcher/graph/lib/cmd-watch.mjs +128 -0
- package/scripts/dispatcher/graph/lib/config.mjs +103 -0
- package/scripts/dispatcher/graph/lib/frontmatter.mjs +224 -0
- package/scripts/dispatcher/graph/lib/nnn.mjs +127 -0
- package/scripts/dispatcher/graph/lib/node-state.mjs +157 -0
- package/scripts/dispatcher/graph/lib/scanner.mjs +235 -0
- package/scripts/dispatcher/graph/lib/worktree-ops.mjs +193 -0
- package/scripts/dispatcher/graph-tasks.mjs +92 -0
- package/scripts/dispatcher/index.mjs +3 -3
- package/scripts/dispatcher/lib/docs/events.md +1 -0
- package/scripts/dispatcher/lib/executor.mjs +1 -1
- package/scripts/dispatcher/lib/subagent-runner.mjs +9 -9
- package/scripts/dispatcher/trace.mjs +6 -2
- package/scripts/docs/build-agents-commands.md +1 -0
- package/scripts/docs/cli-entry.md +6 -0
- package/scripts/graph/index.mjs +115 -0
- package/scripts/graph/lib/config.mjs +62 -0
- package/scripts/graph/lib/dag.mjs +161 -0
- package/scripts/graph/lib/frontmatter.mjs +70 -0
- package/scripts/graph/lib/nnn.mjs +77 -0
- package/scripts/graph/lib/state.mjs +110 -0
- package/scripts/graph/scan.mjs +64 -0
- package/scripts/graph/status.mjs +86 -0
- package/scripts/lib/docs/load-cursor-config.md +3 -0
- package/scripts/lib/root-notice.mjs +4 -2
- package/scripts/lib/rule-predicates.mjs +1 -1
- package/scripts/lib/worktree-notice.mjs +14 -7
- package/scripts/lib/worktree.mjs +3 -2
- package/scripts/utils/resolve-js-root.mjs +2 -1
- package/scripts/utils/with-lock.mjs +1 -1
- package/skills/docgen/js/docgen-batch.mjs +7 -7
- package/skills/docgen/js/docgen-extract.mjs +80 -37
- package/skills/docgen/js/docgen-ignore.mjs +1 -1
- package/skills/docgen/js/docgen-prompts.mjs +21 -5
- package/skills/fix/js/llm-worker.mjs +19 -22
- package/skills/fix/js/orchestrator.mjs +6 -7
- package/skills/fix/js/t0.mjs +14 -13
- package/types/bin/n-cursor.d.ts +1 -1
- package/rules/flow/docs/fix.md +0 -152
- package/rules/flow/fix.mjs +0 -18
- package/rules/flow/flow.mdc +0 -127
- package/rules/flow/meta.json +0 -1
- package/scripts/dispatcher/lib/docs/flow-lock.md +0 -161
- package/scripts/dispatcher/lib/docs/flow-resolve.md +0 -267
- package/scripts/dispatcher/lib/flow-plan.mjs +0 -153
- package/scripts/dispatcher/lib/flow-resolve.mjs +0 -156
- package/scripts/dispatcher/lib/flow-signals.mjs +0 -235
- 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/
|
|
12
|
-
import { verify } from './lib/
|
|
13
|
-
import { audit, done, failed, spawn } from './lib/
|
|
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:',
|