@nitra/cursor 4.1.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/docs/n-cursor.md +1 -9
  3. package/bin/n-cursor.js +3 -25
  4. package/docs/stryker.config.md +37 -0
  5. package/docs/vitest.config.md +23 -0
  6. package/package.json +2 -1
  7. package/rules/docker/lib/docs/docker-mirror.md +1 -1
  8. package/rules/docker/lib/docs/docker-native-addon.md +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/scripts/dispatcher/docs/graph.md +0 -346
  19. package/scripts/dispatcher/docs/index.md +0 -236
  20. package/scripts/dispatcher/docs/trace.md +0 -296
  21. package/scripts/dispatcher/graph/lib/cmd-init.mjs +0 -112
  22. package/scripts/dispatcher/graph/lib/cmd-invalidate.mjs +0 -96
  23. package/scripts/dispatcher/graph/lib/cmd-kill.mjs +0 -141
  24. package/scripts/dispatcher/graph/lib/cmd-plan.mjs +0 -142
  25. package/scripts/dispatcher/graph/lib/cmd-run.mjs +0 -328
  26. package/scripts/dispatcher/graph/lib/cmd-scan.mjs +0 -115
  27. package/scripts/dispatcher/graph/lib/cmd-setup.mjs +0 -111
  28. package/scripts/dispatcher/graph/lib/cmd-signals.mjs +0 -328
  29. package/scripts/dispatcher/graph/lib/cmd-status.mjs +0 -131
  30. package/scripts/dispatcher/graph/lib/cmd-verify.mjs +0 -100
  31. package/scripts/dispatcher/graph/lib/cmd-watch.mjs +0 -128
  32. package/scripts/dispatcher/graph/lib/config.mjs +0 -103
  33. package/scripts/dispatcher/graph/lib/frontmatter.mjs +0 -224
  34. package/scripts/dispatcher/graph/lib/nnn.mjs +0 -127
  35. package/scripts/dispatcher/graph/lib/node-state.mjs +0 -157
  36. package/scripts/dispatcher/graph/lib/scanner.mjs +0 -235
  37. package/scripts/dispatcher/graph/lib/worktree-ops.mjs +0 -193
  38. package/scripts/dispatcher/graph-tasks.mjs +0 -92
  39. package/scripts/dispatcher/graph.mjs +0 -212
  40. package/scripts/dispatcher/index.mjs +0 -45
  41. package/scripts/dispatcher/lib/docs/active.md +0 -348
  42. package/scripts/dispatcher/lib/docs/artifact.md +0 -232
  43. package/scripts/dispatcher/lib/docs/budget.md +0 -167
  44. package/scripts/dispatcher/lib/docs/capability.md +0 -196
  45. package/scripts/dispatcher/lib/docs/commands.md +0 -210
  46. package/scripts/dispatcher/lib/docs/events.md +0 -183
  47. package/scripts/dispatcher/lib/docs/executor.md +0 -190
  48. package/scripts/dispatcher/lib/docs/gate.md +0 -231
  49. package/scripts/dispatcher/lib/docs/level.md +0 -335
  50. package/scripts/dispatcher/lib/docs/plan-panel.md +0 -181
  51. package/scripts/dispatcher/lib/docs/plan.md +0 -200
  52. package/scripts/dispatcher/lib/docs/planner.md +0 -269
  53. package/scripts/dispatcher/lib/docs/review.md +0 -255
  54. package/scripts/dispatcher/lib/docs/reviewer.md +0 -240
  55. package/scripts/dispatcher/lib/docs/snapshot.md +0 -247
  56. package/scripts/dispatcher/lib/docs/spec.md +0 -203
  57. package/scripts/dispatcher/lib/docs/state-store.md +0 -303
  58. package/scripts/dispatcher/lib/docs/subagent-runner.md +0 -173
  59. package/scripts/dispatcher/lib/events.mjs +0 -67
  60. package/scripts/dispatcher/lib/executor.mjs +0 -107
  61. package/scripts/dispatcher/lib/plan-panel.mjs +0 -76
  62. package/scripts/dispatcher/lib/state-store.mjs +0 -173
  63. package/scripts/dispatcher/lib/subagent-runner.mjs +0 -53
  64. package/scripts/graph/index.mjs +0 -115
  65. package/scripts/graph/lib/config.mjs +0 -62
  66. package/scripts/graph/lib/dag.mjs +0 -161
  67. package/scripts/graph/lib/frontmatter.mjs +0 -70
  68. package/scripts/graph/lib/nnn.mjs +0 -77
  69. package/scripts/graph/lib/state.mjs +0 -110
  70. package/scripts/graph/scan.mjs +0 -64
  71. package/scripts/graph/status.mjs +0 -86
@@ -1,115 +0,0 @@
1
- /**
2
- * `n-cursor graph` — CLI entry point для нової graph-архітектури.
3
- * Реалізує команди з docs/думка.MD (фінальний дизайн 2026-06-07).
4
- */
5
-
6
- /**
7
- * @param {string[]} argv процесні аргументи після 'graph'
8
- * @returns {Promise<number>} exit code
9
- */
10
- export async function runGraphCli(argv) {
11
- const [cmd, ...rest] = argv
12
-
13
- const hasJson = rest.includes('--json')
14
- const cleanRest = rest.filter(a => a !== '--json')
15
-
16
- switch (cmd) {
17
- case 'scan':
18
- return (await import('./scan.mjs')).runScan(cleanRest, { json: hasJson })
19
-
20
- case 'status': {
21
- const path = cleanRest[0]
22
- return (await import('./status.mjs')).runStatus(path, { json: hasJson })
23
- }
24
-
25
- case 'setup':
26
- return (await import('./setup.mjs')).runSetup(cleanRest)
27
-
28
- case 'init': {
29
- const [name, ...initRest] = cleanRest
30
- return (await import('./init.mjs')).runInit(name, parseFlags(initRest))
31
- }
32
-
33
- case 'plan': {
34
- const flags = parseFlags(cleanRest)
35
- return (await import('./plan.mjs')).runPlan(flags.path, flags)
36
- }
37
-
38
- case 'run': {
39
- const flags = parseFlags(cleanRest)
40
- return (await import('./run.mjs')).runRun(flags.path, flags)
41
- }
42
-
43
- case 'kill': {
44
- const path = cleanRest[0]
45
- return (await import('./kill.mjs')).runKill(path)
46
- }
47
-
48
- case 'invalidate': {
49
- const flags = parseFlags(cleanRest)
50
- return (await import('./invalidate.mjs')).runInvalidate(flags.path, flags)
51
- }
52
-
53
- case 'done':
54
- case 'audit':
55
- case 'failed':
56
- case 'spawn': {
57
- const path = cleanRest[0]
58
- return (await import('./signals.mjs')).runSignal(cmd, path, parseFlags(cleanRest.slice(1)))
59
- }
60
-
61
- case undefined:
62
- case '--help':
63
- case 'help':
64
- printHelp()
65
- return 0
66
-
67
- default:
68
- console.error(`Unknown graph command: ${cmd}`)
69
- printHelp()
70
- return 1
71
- }
72
- }
73
-
74
- /** @param {string[]} args @returns {Record<string, string | boolean>} */
75
- function parseFlags(args) {
76
- /** @type {Record<string, string | boolean>} */
77
- const flags = {}
78
- for (let i = 0; i < args.length; i++) {
79
- const a = args[i]
80
- if (a.startsWith('--')) {
81
- const key = a.slice(2)
82
- const next = args[i + 1]
83
- if (next && !next.startsWith('--')) {
84
- flags[key] = next
85
- i++
86
- } else {
87
- flags[key] = true
88
- }
89
- } else if (!flags.path) {
90
- flags.path = a
91
- }
92
- }
93
- return flags
94
- }
95
-
96
- function printHelp() {
97
- console.log(`n-cursor graph <command> [options]
98
-
99
- Commands:
100
- setup Initialize project (.n-cursor.json, hooks)
101
- init <name> [--task "..."] Create task.md for a new node
102
- plan [<path>] [--mode agent] Stage 1: spec + decompose → plan_NNN.md
103
- status [<path>] [--json] Show graph or node state
104
- scan [--json] Full scan; exit 1 if any failed nodes
105
- run [<path>] [--actor a] [--auto] Execute node or run orchestrator
106
- kill <path> Kill worktrees + cascade invalidate
107
- invalidate <path> [--no-cascade] Mark node as invalidated
108
-
109
- Agent signals (called from within worktree):
110
- done <path> Signal success → merge
111
- audit <path> Request audit → pending-audit_NNN.md
112
- failed <path> Signal failure
113
- spawn <path> Register composite subgraph
114
- `)
115
- }
@@ -1,62 +0,0 @@
1
- /**
2
- * Завантаження `.n-cursor.json` + per-node `.n-cursor-override.json`.
3
- * Всі поля опціональні — повертає merge із дефолтами.
4
- */
5
- import { existsSync, readFileSync } from 'node:fs'
6
- import { join } from 'node:path'
7
-
8
- /** @typedef {{ tasks_dir: string, worktrees_dir: string, warn_worktrees_above: number, max_worktrees: number, default_budget_sec: number, budget_hard_sec_multiplier: number, progress_timeout_sec: number, stderr_lines: number, claude_model: string, audit_model: string, model_map: Record<string,string>, stale_worktree_min: number, system_prompt: string }} GraphConfig */
9
-
10
- /** @type {GraphConfig} */
11
- const DEFAULTS = {
12
- tasks_dir: './tasks',
13
- worktrees_dir: './.worktrees',
14
- warn_worktrees_above: 4,
15
- max_worktrees: 8,
16
- default_budget_sec: 1800,
17
- budget_hard_sec_multiplier: 3,
18
- progress_timeout_sec: 300,
19
- stderr_lines: 50,
20
- claude_model: 'claude-sonnet-4-6',
21
- audit_model: 'claude-haiku-4-5-20251001',
22
- model_map: {
23
- MIM: 'claude-haiku-4-5-20251001',
24
- AVG: 'claude-sonnet-4-6',
25
- MAX: 'claude-opus-4-8',
26
- },
27
- stale_worktree_min: 30,
28
- system_prompt: '.n-cursor/system-prompt.md',
29
- }
30
-
31
- /**
32
- * Читає `.n-cursor.json` з root та мержить із дефолтами.
33
- * @param {string} root
34
- * @returns {GraphConfig}
35
- */
36
- export function loadConfig(root) {
37
- const path = join(root, '.n-cursor.json')
38
- if (!existsSync(path)) return { ...DEFAULTS }
39
- try {
40
- const raw = JSON.parse(readFileSync(path, 'utf8'))
41
- return { ...DEFAULTS, ...raw, model_map: { ...DEFAULTS.model_map, ...raw.model_map } }
42
- } catch {
43
- return { ...DEFAULTS }
44
- }
45
- }
46
-
47
- /**
48
- * Читає per-node `.n-cursor-override.json` та мержить із базовим конфігом.
49
- * @param {GraphConfig} base
50
- * @param {string} nodePath абсолютний шлях до директорії вузла
51
- * @returns {GraphConfig}
52
- */
53
- export function loadNodeOverride(base, nodePath) {
54
- const path = join(nodePath, '.n-cursor-override.json')
55
- if (!existsSync(path)) return base
56
- try {
57
- const raw = JSON.parse(readFileSync(path, 'utf8'))
58
- return { ...base, ...raw }
59
- } catch {
60
- return base
61
- }
62
- }
@@ -1,161 +0,0 @@
1
- /**
2
- * Побудова DAG з файлової структури `tasks/`.
3
- * Читає task.md кожного вузла (один раз), будує граф в пам'яті.
4
- * Deps satisfaction вираховується пакетно — не per-node.
5
- */
6
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
7
- import { join, relative } from 'node:path'
8
-
9
- import { parseFrontmatter } from './frontmatter.mjs'
10
- import { deriveAtomicState, deriveCompositeState } from './state.mjs'
11
-
12
- /**
13
- * @typedef {{
14
- * id: string,
15
- * path: string,
16
- * parentId: string | null,
17
- * deps: string[],
18
- * mode: string,
19
- * executor: { type: string, model_tier: string, skills: string[] },
20
- * budget_sec: number,
21
- * isComposite: boolean,
22
- * children: string[],
23
- * state: import('./state.mjs').NodeState,
24
- * meta: Record<string, unknown>
25
- * }} GraphNode
26
- */
27
-
28
- /**
29
- * Сканує tasks_dir і будує повний DAG.
30
- * @param {string} tasksDir абсолютний шлях
31
- * @param {string} [worktreesDir]
32
- * @returns {Map<string, GraphNode>}
33
- */
34
- export function buildDag(tasksDir, worktreesDir = '') {
35
- /** @type {Map<string, GraphNode>} */
36
- const nodes = new Map()
37
-
38
- // 1. Collect all nodes
39
- collectNodes(tasksDir, tasksDir, null, nodes)
40
-
41
- // 2. Resolve parent-child relationships
42
- for (const node of nodes.values()) {
43
- if (node.parentId) {
44
- const parent = nodes.get(node.parentId)
45
- if (parent && !parent.children.includes(node.id)) {
46
- parent.children.push(node.id)
47
- parent.isComposite = true
48
- }
49
- }
50
- }
51
-
52
- // 3. Derive states (bottom-up)
53
- const resolvedIds = new Set()
54
- deriveStatesBottomUp(tasksDir, nodes, resolvedIds, worktreesDir)
55
-
56
- return nodes
57
- }
58
-
59
- /**
60
- * @param {string} dir поточна директорія
61
- * @param {string} tasksDir корінь tasks
62
- * @param {string | null} parentId
63
- * @param {Map<string, GraphNode>} nodes
64
- */
65
- function collectNodes(dir, tasksDir, parentId, nodes) {
66
- const taskFile = join(dir, 'task.md')
67
- if (!existsSync(taskFile)) return
68
-
69
- const id = relative(tasksDir, dir).replace(/\\/gu, '/')
70
- if (!id) return
71
-
72
- const { data } = parseFrontmatter(readSafe(taskFile))
73
-
74
- /** @type {string[]} */
75
- const deps = Array.isArray(data.deps) ? data.deps.map(String) : []
76
-
77
- const executor = typeof data.executor === 'object' && data.executor !== null
78
- ? data.executor
79
- : { type: 'agent', model_tier: 'AVG', skills: [] }
80
-
81
- /** @type {GraphNode} */
82
- const node = {
83
- id,
84
- path: dir,
85
- parentId,
86
- deps,
87
- mode: String(data.mode ?? 'human'),
88
- executor: {
89
- type: String(executor.type ?? 'agent'),
90
- model_tier: String(executor.model_tier ?? 'AVG'),
91
- skills: Array.isArray(executor.skills) ? executor.skills : [],
92
- },
93
- budget_sec: Number(data.budget_sec ?? 1800),
94
- isComposite: false,
95
- children: [],
96
- state: 'needs-plan',
97
- meta: data,
98
- }
99
-
100
- nodes.set(id, node)
101
-
102
- // Scan children
103
- try {
104
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
105
- if (entry.isDirectory()) {
106
- collectNodes(join(dir, entry.name), tasksDir, id, nodes)
107
- }
108
- }
109
- } catch { /* skip */ }
110
- }
111
-
112
- /**
113
- * Обходить граф знизу вверх (листи → корінь) і деривує стани.
114
- * @param {string} tasksDir
115
- * @param {Map<string, GraphNode>} nodes
116
- * @param {Set<string>} resolvedIds
117
- * @param {string} worktreesDir
118
- */
119
- function deriveStatesBottomUp(tasksDir, nodes, resolvedIds, worktreesDir) {
120
- // Topological sort (Kahn's algorithm by deps within siblings)
121
- const visited = new Set()
122
- const order = []
123
-
124
- /** @param {string} id */
125
- function visit(id) {
126
- if (visited.has(id)) return
127
- visited.add(id)
128
- const node = nodes.get(id)
129
- if (!node) return
130
- for (const child of node.children) visit(child)
131
- order.push(id)
132
- }
133
-
134
- for (const id of nodes.keys()) visit(id)
135
-
136
- // Process leaves first (order is reversed)
137
- for (const id of order) {
138
- const node = nodes.get(id)
139
- if (!node) continue
140
-
141
- if (node.isComposite) {
142
- const childStates = node.children.map(cid => nodes.get(cid)?.state ?? 'needs-plan')
143
- node.state = deriveCompositeState(node.path, childStates)
144
- } else {
145
- const depsResolved = node.deps.every(dep => {
146
- // Deps are sibling IDs — resolve relative to parent
147
- const siblingId = node.parentId ? `${node.parentId}/${dep}` : dep
148
- const sibling = nodes.get(siblingId) ?? nodes.get(dep)
149
- return sibling?.state === 'resolved'
150
- })
151
- node.state = deriveAtomicState(node.path, { depsResolved })
152
- }
153
-
154
- if (node.state === 'resolved') resolvedIds.add(id)
155
- }
156
- }
157
-
158
- /** @param {string} path @returns {string} */
159
- function readSafe(path) {
160
- try { return readFileSync(path, 'utf8') } catch { return '' }
161
- }
@@ -1,70 +0,0 @@
1
- /**
2
- * Мінімальний YAML-фронтматер парсер для Markdown-файлів.
3
- * Підтримує: рядки, числа, boolean, масиви (flow і block), вкладені об'єкти (один рівень).
4
- */
5
-
6
- const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---/u
7
-
8
- /**
9
- * @param {string} content вміст md-файлу
10
- * @returns {{ data: Record<string, unknown>, body: string }}
11
- */
12
- export function parseFrontmatter(content) {
13
- const m = FM_RE.exec(content)
14
- if (!m) return { data: {}, body: content }
15
- const raw = m[1]
16
- const body = content.slice(m[0].length).trimStart()
17
- return { data: parseYamlBlock(raw), body }
18
- }
19
-
20
- /**
21
- * @param {string} block YAML-блок між ---
22
- * @returns {Record<string, unknown>}
23
- */
24
- function parseYamlBlock(block) {
25
- const result = {}
26
- const lines = block.split(/\r?\n/u)
27
- let i = 0
28
-
29
- while (i < lines.length) {
30
- const line = lines[i]
31
- const keyMatch = /^([a-z_][a-z0-9_]*):\s*(.*)/iu.exec(line)
32
- if (!keyMatch) { i++; continue }
33
-
34
- const key = keyMatch[1]
35
- const rest = keyMatch[2].trim()
36
-
37
- if (rest === '' || rest === '|' || rest === '>') {
38
- // block scalar або об'єкт — збираємо indented рядки
39
- const children = []
40
- i++
41
- while (i < lines.length && /^\s+/u.test(lines[i])) {
42
- children.push(lines[i])
43
- i++
44
- }
45
- if (children.length > 0 && /^\s+-\s+/u.test(children[0])) {
46
- result[key] = children.map(l => l.replace(/^\s+-\s+/u, '').trim())
47
- } else {
48
- result[key] = parseYamlBlock(children.map(l => l.replace(/^\s{2}/u, '')).join('\n'))
49
- }
50
- } else if (rest.startsWith('[')) {
51
- result[key] = rest.slice(1, rest.lastIndexOf(']')).split(',').map(s => s.trim()).filter(Boolean)
52
- i++
53
- } else {
54
- result[key] = parseScalar(rest)
55
- i++
56
- }
57
- }
58
-
59
- return result
60
- }
61
-
62
- /** @param {string} s */
63
- function parseScalar(s) {
64
- if (s === 'true') return true
65
- if (s === 'false') return false
66
- if (s === 'null' || s === '~') return null
67
- const n = Number(s)
68
- if (!Number.isNaN(n) && s !== '') return n
69
- return s.replace(/^["']|["']$/gu, '')
70
- }
@@ -1,77 +0,0 @@
1
- /**
2
- * NNN-лічильники — рахують існуючі файли певного патерну і повертають наступний NNN.
3
- * Zero-padded до 3 цифр: 001, 002, …
4
- */
5
- import { readdirSync } from 'node:fs'
6
-
7
- /**
8
- * @param {string} dir абсолютний шлях до директорії вузла
9
- * @param {RegExp} pattern регексп для імен файлів (напр. /^run_(\d{3})\.md$/)
10
- * @returns {number} кількість файлів що підходять
11
- */
12
- export function countFiles(dir, pattern) {
13
- try {
14
- return readdirSync(dir).filter(f => pattern.test(f)).length
15
- } catch {
16
- return 0
17
- }
18
- }
19
-
20
- /**
21
- * @param {string} dir
22
- * @returns {string} наступний NNN для run_NNN.md (zero-padded)
23
- */
24
- export function nextRunNNN(dir) {
25
- const count = countFiles(dir, /^run_\d{3}\.md$/u)
26
- return String(count + 1).padStart(3, '0')
27
- }
28
-
29
- /**
30
- * @param {string} dir
31
- * @returns {string} наступний NNN для plan_NNN.md (zero-padded)
32
- */
33
- export function nextPlanNNN(dir) {
34
- const count = countFiles(dir, /^plan_\d{3}\.md$/u)
35
- return String(count + 1).padStart(3, '0')
36
- }
37
-
38
- /**
39
- * Знаходить NNN останнього fact_NNN.md.
40
- * @param {string} dir
41
- * @returns {string | null}
42
- */
43
- export function latestFactNNN(dir) {
44
- try {
45
- const files = readdirSync(dir)
46
- .filter(f => /^fact_\d{3}\.md$/u.test(f))
47
- .sort()
48
- if (files.length === 0) return null
49
- return files.at(-1).replace('fact_', '').replace('.md', '')
50
- } catch {
51
- return null
52
- }
53
- }
54
-
55
- /**
56
- * Знаходить NNN pending-audit без відповідного audit-result.
57
- * @param {string} dir
58
- * @returns {string | null}
59
- */
60
- export function pendingAuditNNN(dir) {
61
- try {
62
- const files = readdirSync(dir)
63
- const pending = files.filter(f => /^pending-audit_\d{3}\.md$/u.test(f)).sort()
64
- for (const p of pending) {
65
- const nnn = p.replace('pending-audit_', '').replace('.md', '')
66
- if (!files.includes(`audit-result_${nnn}.md`)) return nnn
67
- }
68
- return null
69
- } catch {
70
- return null
71
- }
72
- }
73
-
74
- /** @param {number} n @returns {string} */
75
- export function pad3(n) {
76
- return String(n).padStart(3, '0')
77
- }
@@ -1,110 +0,0 @@
1
- /**
2
- * Визначення стану вузла за наявністю файлів (O(1), без читання вмісту).
3
- * Пріоритет: invalidated > resolved > pending-audit > stalled > running > waiting/blocked > failed > needs-plan
4
- */
5
- import { existsSync, readdirSync } from 'node:fs'
6
- import { join } from 'node:path'
7
-
8
- /**
9
- * @typedef {'needs-plan'|'waiting'|'blocked'|'running'|'stalled'|'pending-audit'|'resolved'|'failed'|'invalidated'} NodeState
10
- */
11
-
12
- /**
13
- * Визначає стан атомарного вузла за файлами у директорії.
14
- * @param {string} dir абсолютний шлях до директорії вузла
15
- * @param {{ depsResolved: boolean }} opts
16
- * @returns {NodeState}
17
- */
18
- export function deriveAtomicState(dir, { depsResolved = true } = {}) {
19
- if (existsSync(join(dir, 'invalidated'))) return 'invalidated'
20
-
21
- const files = listFiles(dir)
22
-
23
- if (hasFact(files) && !files.includes('invalidated')) return 'resolved'
24
-
25
- const pendingNNN = findPendingAudit(files)
26
- if (pendingNNN !== null) return 'pending-audit'
27
-
28
- const runningUntil = findRunningUntil(files)
29
- if (runningUntil !== null) {
30
- const ts = Number(runningUntil)
31
- const now = Math.floor(Date.now() / 1000)
32
- return ts > now ? 'running' : 'stalled'
33
- }
34
-
35
- const hasPlan = files.some(f => /^plan_\d{3}\.md$/u.test(f))
36
- const hasRun = files.some(f => /^run_\d{3}\.md$/u.test(f))
37
-
38
- if (!hasPlan) return 'needs-plan'
39
- if (hasRun && !hasFact(files)) return 'failed'
40
- if (hasPlan && !depsResolved) return 'blocked'
41
- return 'waiting'
42
- }
43
-
44
- /**
45
- * Визначає стан composite вузла за станами дітей.
46
- * @param {string} dir
47
- * @param {NodeState[]} childStates
48
- * @returns {NodeState}
49
- */
50
- export function deriveCompositeState(dir, childStates) {
51
- if (existsSync(join(dir, 'invalidated'))) return 'invalidated'
52
-
53
- if (childStates.length === 0) return 'needs-plan'
54
- if (childStates.every(s => s === 'resolved')) return 'resolved'
55
- if (childStates.some(s => s === 'running' || s === 'pending-audit')) return 'running'
56
- if (childStates.some(s => s === 'stalled')) return 'stalled'
57
- if (childStates.some(s => s === 'failed') && !childStates.some(s => s === 'running')) return 'failed'
58
- return 'waiting'
59
- }
60
-
61
- /**
62
- * Перевіряє наявність orphan worktree для вузла (resolved + worktree exists).
63
- * @param {string} dir
64
- * @param {string} worktreesDir
65
- * @returns {boolean}
66
- */
67
- export function hasOrphanWorktree(dir, worktreesDir) {
68
- const nodeName = dir.split('/').at(-1)
69
- try {
70
- return readdirSync(worktreesDir).some(d => d.startsWith(nodeName))
71
- } catch {
72
- return false
73
- }
74
- }
75
-
76
- // --- helpers ---
77
-
78
- /** @param {string} dir @returns {string[]} */
79
- function listFiles(dir) {
80
- try { return readdirSync(dir) } catch { return [] }
81
- }
82
-
83
- /** @param {string[]} files @returns {boolean} */
84
- function hasFact(files) {
85
- return files.some(f => /^fact_\d{3}\.md$/u.test(f))
86
- }
87
-
88
- /**
89
- * Знаходить перший pending-audit_NNN без audit-result_NNN.
90
- * @param {string[]} files
91
- * @returns {string | null} NNN або null
92
- */
93
- function findPendingAudit(files) {
94
- const pending = files.filter(f => /^pending-audit_\d{3}\.md$/u.test(f)).sort()
95
- for (const p of pending) {
96
- const nnn = p.replace('pending-audit_', '').replace('.md', '')
97
- if (!files.includes(`audit-result_${nnn}.md`)) return nnn
98
- }
99
- return null
100
- }
101
-
102
- /**
103
- * Знаходить `running_until_<ts>` файл і повертає ts як рядок.
104
- * @param {string[]} files
105
- * @returns {string | null}
106
- */
107
- function findRunningUntil(files) {
108
- const f = files.find(f => /^running_until_\d+$/u.test(f))
109
- return f ? f.replace('running_until_', '') : null
110
- }
@@ -1,64 +0,0 @@
1
- /**
2
- * `graph scan` — повне сканування граф: відновлює стан з файлів, виводить результат.
3
- * exit 0 — граф чистий; exit 1 — є вузли у стані failed.
4
- */
5
- import { resolve } from 'node:path'
6
- import { cwd } from 'node:process'
7
-
8
- import { loadConfig } from './lib/config.mjs'
9
- import { buildDag } from './lib/dag.mjs'
10
-
11
- /**
12
- * @param {string[]} args
13
- * @param {{ json?: boolean }} opts
14
- */
15
- export async function runScan(args, opts = {}) {
16
- const root = cwd()
17
- const config = loadConfig(root)
18
- const tasksDir = resolve(root, config.tasks_dir)
19
- const worktreesDir = resolve(root, config.worktrees_dir)
20
-
21
- const nodes = buildDag(tasksDir, worktreesDir)
22
-
23
- if (opts.json) {
24
- const out = {}
25
- for (const [id, node] of nodes) out[id] = { state: node.state, deps: node.deps }
26
- process.stdout.write(JSON.stringify(out, null, 2) + '\n')
27
- } else {
28
- printScanTable(nodes)
29
- }
30
-
31
- const hasFailed = [...nodes.values()].some(n => n.state === 'failed')
32
- return hasFailed ? 1 : 0
33
- }
34
-
35
- /** @param {Map<string, import('./lib/dag.mjs').GraphNode>} nodes */
36
- function printScanTable(nodes) {
37
- const STATE_ICON = {
38
- 'needs-plan': '⏳',
39
- waiting: '○',
40
- blocked: '○',
41
- running: '◉',
42
- stalled: '⚠',
43
- 'pending-audit': '🔍',
44
- resolved: '✓',
45
- failed: '✗',
46
- invalidated: '⊘',
47
- }
48
-
49
- const counts = {}
50
- for (const { state } of nodes.values()) counts[state] = (counts[state] ?? 0) + 1
51
-
52
- const summary = Object.entries(counts).map(([s, n]) => `${s}:${n}`).join(' ')
53
- console.log(`graph — ${summary}\n`)
54
-
55
- for (const [id, node] of nodes) {
56
- const icon = STATE_ICON[node.state] ?? '?'
57
- let detail = ''
58
- if (node.state === 'needs-plan') detail = `run: graph plan tasks/${id}/`
59
- else if (node.state === 'blocked') detail = `blocked: ${node.deps.join(', ')}`
60
- else if (node.state === 'stalled') detail = 'stalled — deadline passed'
61
- const suffix = detail ? ` [${detail}]` : ''
62
- console.log(` ${icon} ${id.padEnd(30)} [${node.state}]${suffix}`)
63
- }
64
- }