@nitra/cursor 4.0.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/n-cursor.js +25 -13
  3. package/lib/models.mjs +67 -0
  4. package/package.json +2 -1
  5. package/rules/abie/fix.mjs +1 -1
  6. package/rules/bun/docs/fix.md +3 -0
  7. package/rules/bun/fix.mjs +1 -1
  8. package/rules/capacitor/fix.mjs +1 -1
  9. package/rules/changelog/docs/fix.md +3 -0
  10. package/rules/changelog/fix.mjs +1 -1
  11. package/rules/ci4/fix.mjs +1 -1
  12. package/rules/ci4/js/docs/marksman_config.md +1 -0
  13. package/rules/docker/docs/fix.md +1 -1
  14. package/rules/docker/fix.mjs +1 -1
  15. package/rules/docker/lint/docs/lint.md +1 -0
  16. package/rules/efes/docs/fix.md +2 -1
  17. package/rules/efes/fix.mjs +1 -1
  18. package/rules/feedback/fix.mjs +1 -1
  19. package/rules/ga/fix.mjs +1 -1
  20. package/rules/ga/js/lint.mjs +1 -1
  21. package/rules/graphql/docs/fix.md +4 -1
  22. package/rules/graphql/fix.mjs +1 -1
  23. package/rules/graphql/lib/docs/graphql-gql-scan.md +3 -0
  24. package/rules/hasura/fix.mjs +1 -1
  25. package/rules/image-avif/docs/fix.md +4 -1
  26. package/rules/image-avif/fix.mjs +1 -1
  27. package/rules/image-avif/js/docs/avif_generation.md +1 -0
  28. package/rules/image-compress/fix.mjs +1 -1
  29. package/rules/js-bun-db/fix.mjs +1 -1
  30. package/rules/js-bun-db/lib/docs/bun-sql-scan.md +6 -0
  31. package/rules/js-bun-redis/fix.mjs +1 -1
  32. package/rules/js-lint/fix.mjs +1 -1
  33. package/rules/js-lint/js/docs/utils_imports.md +1 -0
  34. package/rules/js-lint-ci/docs/fix.md +4 -1
  35. package/rules/js-lint-ci/fix.mjs +1 -1
  36. package/rules/js-mssql/docs/fix.md +3 -0
  37. package/rules/js-mssql/fix.mjs +1 -1
  38. package/rules/js-mssql/lib/docs/mssql-pool-scan.md +9 -0
  39. package/rules/js-run/docs/fix.md +3 -0
  40. package/rules/js-run/fix.mjs +1 -1
  41. package/rules/js-run/lib/docs/check-env-scan.md +2 -1
  42. package/rules/js-run/lib/docs/promise-settimeout-scan.md +4 -0
  43. package/rules/k8s/docs/fix.md +3 -0
  44. package/rules/k8s/fix.mjs +1 -1
  45. package/rules/nginx-default-tpl/docs/fix.md +3 -0
  46. package/rules/nginx-default-tpl/fix.mjs +1 -1
  47. package/rules/npm-module/fix.mjs +1 -1
  48. package/rules/npm-module/js/header_doc_pointer.mjs +14 -3
  49. package/rules/php/docs/fix.md +2 -1
  50. package/rules/php/fix.mjs +1 -1
  51. package/rules/python/docs/fix.md +4 -1
  52. package/rules/python/fix.mjs +1 -1
  53. package/rules/rego/fix.mjs +1 -1
  54. package/rules/rego/js/lint.mjs +1 -1
  55. package/rules/release/docs/fix.md +4 -1
  56. package/rules/release/fix.mjs +1 -1
  57. package/rules/rust/fix.mjs +1 -1
  58. package/rules/security/docs/fix.md +1 -1
  59. package/rules/security/fix.mjs +1 -1
  60. package/rules/style-lint/docs/fix.md +3 -0
  61. package/rules/style-lint/fix.mjs +1 -1
  62. package/rules/tauri/docs/fix.md +4 -1
  63. package/rules/tauri/fix.mjs +1 -1
  64. package/rules/test/docs/fix.md +3 -0
  65. package/rules/test/fix.mjs +1 -1
  66. package/rules/test/js/no-relative-fs-path.mjs +2 -1
  67. package/rules/text/docs/fix.md +3 -0
  68. package/rules/text/fix.mjs +1 -1
  69. package/rules/text/js/lint.mjs +1 -1
  70. package/rules/vue/fix.mjs +1 -1
  71. package/rules/worktree/fix.mjs +1 -1
  72. package/scripts/auto-rules.mjs +1 -1
  73. package/scripts/coverage-classify/index.mjs +10 -10
  74. package/scripts/coverage-fix.mjs +2 -2
  75. package/scripts/dispatcher/graph/lib/cmd-init.mjs +112 -0
  76. package/scripts/dispatcher/graph/lib/cmd-invalidate.mjs +96 -0
  77. package/scripts/dispatcher/graph/lib/cmd-kill.mjs +141 -0
  78. package/scripts/dispatcher/graph/lib/cmd-plan.mjs +142 -0
  79. package/scripts/dispatcher/graph/lib/cmd-run.mjs +328 -0
  80. package/scripts/dispatcher/graph/lib/cmd-scan.mjs +115 -0
  81. package/scripts/dispatcher/graph/lib/cmd-setup.mjs +111 -0
  82. package/scripts/dispatcher/graph/lib/cmd-signals.mjs +328 -0
  83. package/scripts/dispatcher/graph/lib/cmd-status.mjs +131 -0
  84. package/scripts/dispatcher/graph/lib/cmd-verify.mjs +100 -0
  85. package/scripts/dispatcher/graph/lib/cmd-watch.mjs +128 -0
  86. package/scripts/dispatcher/graph/lib/config.mjs +103 -0
  87. package/scripts/dispatcher/graph/lib/frontmatter.mjs +224 -0
  88. package/scripts/dispatcher/graph/lib/nnn.mjs +127 -0
  89. package/scripts/dispatcher/graph/lib/node-state.mjs +157 -0
  90. package/scripts/dispatcher/graph/lib/scanner.mjs +235 -0
  91. package/scripts/dispatcher/graph/lib/worktree-ops.mjs +193 -0
  92. package/scripts/dispatcher/graph-tasks.mjs +92 -0
  93. package/scripts/dispatcher/index.mjs +3 -3
  94. package/scripts/dispatcher/lib/docs/events.md +1 -0
  95. package/scripts/dispatcher/lib/executor.mjs +1 -1
  96. package/scripts/dispatcher/lib/subagent-runner.mjs +9 -9
  97. package/scripts/dispatcher/trace.mjs +6 -2
  98. package/scripts/docs/build-agents-commands.md +1 -0
  99. package/scripts/docs/cli-entry.md +6 -0
  100. package/scripts/graph/index.mjs +115 -0
  101. package/scripts/graph/lib/config.mjs +62 -0
  102. package/scripts/graph/lib/dag.mjs +161 -0
  103. package/scripts/graph/lib/frontmatter.mjs +70 -0
  104. package/scripts/graph/lib/nnn.mjs +77 -0
  105. package/scripts/graph/lib/state.mjs +110 -0
  106. package/scripts/graph/scan.mjs +64 -0
  107. package/scripts/graph/status.mjs +86 -0
  108. package/scripts/lib/docs/load-cursor-config.md +3 -0
  109. package/scripts/lib/root-notice.mjs +4 -2
  110. package/scripts/lib/rule-predicates.mjs +1 -1
  111. package/scripts/lib/worktree-notice.mjs +14 -7
  112. package/scripts/lib/worktree.mjs +3 -2
  113. package/scripts/utils/resolve-js-root.mjs +2 -1
  114. package/scripts/utils/with-lock.mjs +1 -1
  115. package/skills/docgen/js/docgen-batch.mjs +7 -7
  116. package/skills/docgen/js/docgen-extract.mjs +80 -37
  117. package/skills/docgen/js/docgen-ignore.mjs +1 -1
  118. package/skills/docgen/js/docgen-prompts.mjs +21 -5
  119. package/skills/fix/js/llm-worker.mjs +19 -22
  120. package/skills/fix/js/orchestrator.mjs +6 -7
  121. package/skills/fix/js/t0.mjs +14 -13
  122. package/types/bin/n-cursor.d.ts +1 -1
  123. package/rules/flow/docs/fix.md +0 -152
  124. package/rules/flow/fix.mjs +0 -18
  125. package/rules/flow/flow.mdc +0 -127
  126. package/rules/flow/meta.json +0 -1
  127. package/scripts/dispatcher/lib/docs/flow-lock.md +0 -161
  128. package/scripts/dispatcher/lib/docs/flow-resolve.md +0 -267
  129. package/scripts/dispatcher/lib/flow-plan.mjs +0 -153
  130. package/scripts/dispatcher/lib/flow-resolve.mjs +0 -156
  131. package/scripts/dispatcher/lib/flow-signals.mjs +0 -235
  132. package/scripts/dispatcher/lib/flow-verify.mjs +0 -127
@@ -119,6 +119,7 @@ Default-експорт відсутній.
119
119
  1. **Обчислення шляху журналу** — один раз на сесію/процес:
120
120
  ```js
121
121
  import { flowEventsPath, appendEvent, readEvents } from './events.mjs'
122
+
122
123
  const eventsPath = flowEventsPath('/repo/.worktrees/feat-x')
123
124
  // → '/repo/.worktrees/feat-x.events.jsonl'
124
125
  ```
@@ -49,7 +49,7 @@ export function patchStep(state, index, patch) {
49
49
  * @returns {Promise<{ status: 'done' | 'blocked-on-human', step?: number }>} результат
50
50
  */
51
51
  export async function executePlan(paths, deps) {
52
- const { runner, verify, commit, cwd, maxRepairAttempts = 3, log = () => {}, now = Date.now } = deps
52
+ const { runner, verify, commit, cwd, maxRepairAttempts = 3, log = () => { /* noop */ }, now = Date.now } = deps
53
53
  let state = readState(paths.statePath)
54
54
  if (!state?.plan?.length) {
55
55
  throw new Error('executor: у стані немає плану — спершу planner')
@@ -14,10 +14,10 @@ import { resolveModel } from '../../../lib/models.mjs'
14
14
 
15
15
  /**
16
16
  * Викликає pi і повертає { ok, output }.
17
- * @param {string} prompt
17
+ * @param {string} prompt текст промпта
18
18
  * @param {string} model provider/model-id або '' для pi-дефолту
19
- * @param {{ cwd?: string }} [opts]
20
- * @returns {{ ok: boolean, output: string }}
19
+ * @param {{ cwd?: string }} [opts] опційні параметри (cwd)
20
+ * @returns {{ ok: boolean, output: string }} результат із статусом і output
21
21
  */
22
22
  function callPi(prompt, model, { cwd } = {}) {
23
23
  const modelArgs = model ? ['--model', model] : []
@@ -27,26 +27,26 @@ function callPi(prompt, model, { cwd } = {}) {
27
27
  timeout: 600_000
28
28
  })
29
29
  const ok = !r.error && r.status === 0
30
- const output = (r.stdout ?? '') + (r.error ? r.error.message : !ok ? (r.stderr ?? '') : '')
30
+ const output = (r.stdout ?? '') + (r.error ? r.error.message : (ok ? '' : (r.stderr ?? '')))
31
31
  return { ok, output }
32
32
  }
33
33
 
34
34
  /**
35
35
  * Створює pi-runner. Повертає { backend: 'pi', runStep }.
36
36
  * @param {{ model?: string, callPi?: Function }} [deps] ін'єкції для тестів
37
- * @returns {Promise<{ backend: string, runStep: (prompt: string, opts?: object) => Promise<{ ok: boolean, output: string }> }>}
37
+ * @returns {Promise<{ backend: string, runStep: (prompt: string, opts?: object) => Promise<{ ok: boolean, output: string }> }>} runner із backend='pi' і методом runStep
38
38
  */
39
- export async function createRunner(deps = {}) {
39
+ export function createRunner(deps = {}) {
40
40
  const model = deps.model ?? resolveModel('avg')
41
41
  const callPiFn = deps.callPi ?? callPi
42
42
 
43
43
  return {
44
44
  backend: 'pi',
45
- async runStep(prompt, opts = {}) {
45
+ runStep(prompt, opts = {}) {
46
46
  try {
47
47
  return callPiFn(prompt, model, opts)
48
- } catch (e) {
49
- return { ok: false, output: String(e?.message ?? e) }
48
+ } catch (error) {
49
+ return { ok: false, output: String(error?.message ?? error) }
50
50
  }
51
51
  }
52
52
  }
@@ -24,6 +24,10 @@ const INFO_LINK_FIELDS = new Set(['flow'])
24
24
  /** Каталоги з traceable-артефактами. */
25
25
  const DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']
26
26
 
27
+ const LEADING_QUOTE_RE = /^["']/u
28
+ const TRAILING_QUOTE_RE = /["']$/u
29
+ const SIMPLE_KEY_CHAR_RE = /[a-z_]/iu
30
+
27
31
  /**
28
32
  * Парсить плаский YAML-front-matter (key: value). Не обробляє вкладеність —
29
33
  * достатньо для spec/plan/task-record полів. Інлайн-коментарі (` #…`) відрізає.
@@ -43,7 +47,7 @@ export function parseFrontMatter(content) {
43
47
  let val = line.slice(ci + 1)
44
48
  const hi = val.indexOf(' #')
45
49
  if (hi !== -1) val = val.slice(0, hi)
46
- val = val.trim().replace(/^["']/u, '').replace(/["']$/u, '')
50
+ val = val.trim().replace(LEADING_QUOTE_RE, '').replace(TRAILING_QUOTE_RE, '')
47
51
  fm[key] = val === '' || val === 'null' ? null : val
48
52
  }
49
53
  return fm
@@ -55,7 +59,7 @@ export function parseFrontMatter(content) {
55
59
  * @returns {boolean} true для простого ключа
56
60
  */
57
61
  function isSimpleKey(key) {
58
- return key.length > 0 && [...key].every(c => /[a-z_]/iu.test(c))
62
+ return key.length > 0 && [...key].every(c => SIMPLE_KEY_CHAR_RE.test(c))
59
63
  }
60
64
 
61
65
  /**
@@ -154,6 +154,7 @@ const items = await buildAgentsCommandBulletItems(process.cwd())
154
154
 
155
155
  ```js
156
156
  import { buildAgentsCommandBulletItems } from '/абсолютний/шлях/до/build-agents-commands.mjs'
157
+
157
158
  const items = await buildAgentsCommandBulletItems('/абсолютний/шлях/до/тимчасової/теки')
158
159
  console.log(items)
159
160
  ```
@@ -94,6 +94,9 @@ export function isRunAsCli(metaUrl)
94
94
  ```js
95
95
  import { isRunAsCli } from './cli-entry.mjs' // або відносний шлях
96
96
 
97
+ /**
98
+ *
99
+ */
97
100
  export async function runCli(argv) {
98
101
  // ...
99
102
  }
@@ -111,6 +114,9 @@ if (isRunAsCli(import.meta.url)) {
111
114
  // my-tool.mjs
112
115
  import { isRunAsCli } from '../scripts/cli-entry.mjs'
113
116
 
117
+ /**
118
+ *
119
+ */
114
120
  export function doWork(args) {
115
121
  /* ... */
116
122
  }
@@ -0,0 +1,115 @@
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
+ }
@@ -0,0 +1,62 @@
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
+ }
@@ -0,0 +1,161 @@
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
+ }
@@ -0,0 +1,70 @@
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
+ }
@@ -0,0 +1,77 @@
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
+ }