@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,100 @@
1
+ /**
2
+ * Handler `flow verify` — Stage 2 structural check (думка.MD § "flow verify").
3
+ *
4
+ * Перевіряє що `fact_NNN.md` існує і непорожній у директорії поточного вузла
5
+ * (CWD). Якщо так — виводить `## Done when` секцію з `task.md` та вміст
6
+ * `fact_NNN.md` на stdout для агентської self-evaluation.
7
+ *
8
+ * exit 0 = структурно OK
9
+ * exit 1 = структурна помилка (fact відсутній або порожній)
10
+ *
11
+ * FS ін'єктується для тестування без диска.
12
+ */
13
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { cwd as processCwd } from 'node:process'
16
+
17
+ import { latestFactNNN } from './nnn.mjs'
18
+
19
+ const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
20
+ const SECTION_RE = /^## (.+)$/m
21
+ const LINE_SPLIT_RE = /\r?\n/
22
+
23
+ /**
24
+ * Читає секцію за заголовком із markdown-файлу.
25
+ * @param {string} text вміст файлу
26
+ * @param {string} heading заголовок без `## `
27
+ * @returns {string | null} вміст секції або null
28
+ */
29
+ function extractSection(text, heading) {
30
+ const lines = text.split(LINE_SPLIT_RE)
31
+ const start = lines.indexOf(`## ${heading}`)
32
+ if (start === -1) return null
33
+ const end = lines.findIndex((l, i) => i > start && SECTION_RE.test(l))
34
+ const section = end === -1 ? lines.slice(start) : lines.slice(start, end)
35
+ return section.join('\n').trimEnd()
36
+ }
37
+
38
+ /**
39
+ * `flow verify` handler.
40
+ * @param {string[]} _rest аргументи після `verify` (не використовуються)
41
+ * @param {{
42
+ * cwd?: string,
43
+ * log?: (m: string) => void,
44
+ * readFile?: (path: string, enc: string) => string,
45
+ * readdir?: (dir: string) => string[],
46
+ * exists?: (path: string) => boolean
47
+ * }} [deps] ін'єкції
48
+ * @returns {number} exit code (0=OK, 1=структурна помилка)
49
+ */
50
+ export function cmdVerify(_rest, deps = {}) {
51
+ const cwd = deps.cwd ?? processCwd()
52
+ const log = deps.log ?? console.error
53
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
54
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
55
+ const exists = deps.exists ?? existsSync
56
+
57
+ const factNNN = latestFactNNN(cwd, readdir)
58
+ if (!factNNN) {
59
+ log('verify: fact_NNN.md не знайдено — структурна помилка')
60
+ return 1
61
+ }
62
+
63
+ const factPath = join(cwd, `fact_${factNNN}.md`)
64
+ if (!exists(factPath)) {
65
+ log(`verify: fact_${factNNN}.md не існує — структурна помилка`)
66
+ return 1
67
+ }
68
+
69
+ let factContent
70
+ try {
71
+ factContent = readFile(factPath, 'utf8')
72
+ } catch (error) {
73
+ log(`verify: не вдалося прочитати fact_${factNNN}.md — ${error instanceof Error ? error.message : String(error)}`)
74
+ return 1
75
+ }
76
+
77
+ const withoutFm = factContent.replace(FRONT_MATTER_RE, '').trim()
78
+ if (withoutFm.length === 0) {
79
+ log(`verify: fact_${factNNN}.md порожній — структурна помилка`)
80
+ return 1
81
+ }
82
+
83
+ const outLines = [`## verify context`, ``]
84
+
85
+ const taskPath = join(cwd, 'task.md')
86
+ if (exists(taskPath)) {
87
+ try {
88
+ const taskContent = readFile(taskPath, 'utf8')
89
+ const doneWhen = extractSection(taskContent, 'Done when')
90
+ if (doneWhen) outLines.push(doneWhen, '')
91
+ } catch {
92
+ // task.md недоступний — не блокуємо verify
93
+ }
94
+ }
95
+
96
+ outLines.push(`### fact_${factNNN}.md`, ``, factContent.trimEnd())
97
+ console.log(outLines.join('\n'))
98
+
99
+ return 0
100
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `n-cursor watch` — одноразовий скан стану DAG.
3
+ *
4
+ * Спрощена (no-daemon) реалізація:
5
+ * - Знаходить pending-audit без audit-result → логує (треба ручний аудит)
6
+ * - Знаходить stale worktrees > stale_worktree_min хвилин → попереджає
7
+ * - Знаходить needs-plan вузли → перелічує
8
+ * - exit 0 якщо чисто, exit 1 якщо потрібна увага
9
+ *
10
+ * FS і child_process ін'єктуються для тестованості.
11
+ */
12
+ import { execSync } from 'node:child_process'
13
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { cwd as processCwd } from 'node:process'
16
+
17
+ import { loadConfig, resolveTasksDir, resolveWorktreesDir } from './config.mjs'
18
+ import { scanNodes } from './scanner.mjs'
19
+ import { listActiveWorktrees } from './worktree-ops.mjs'
20
+
21
+ /**
22
+ * `watch` command handler (one-shot scan).
23
+ * @param {string[]} args аргументи (зазвичай порожні)
24
+ * @param {{
25
+ * cwd?: string,
26
+ * log?: (m: string) => void,
27
+ * readFile?: (p: string, enc: string) => string,
28
+ * readdir?: (d: string) => string[],
29
+ * exists?: (p: string) => boolean,
30
+ * execSync?: (cmd: string, opts?: object) => string,
31
+ * statSync?: (p: string) => { mtimeMs: number },
32
+ * now?: () => number
33
+ * }} [deps] ін'єкції
34
+ * @returns {Promise<number>} exit code (0=clean, 1=attention)
35
+ */
36
+ export async function cmdWatch(args, deps = {}) {
37
+ const root = deps.cwd ?? processCwd()
38
+ const log = deps.log ?? console.log
39
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
40
+ const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
41
+ const exists = deps.exists ?? existsSync
42
+ const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' }))
43
+ const statFn = deps.statSync ?? statSync
44
+ const nowMs = deps.now ?? (() => Date.now())
45
+
46
+ const config = loadConfig({ root, readFile, exists })
47
+ const tasksDir = resolveTasksDir(config, root)
48
+ const worktreesDir = resolveWorktreesDir(config, root)
49
+ const staleMs = config.stale_worktree_min * 60 * 1000
50
+
51
+ const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn })
52
+
53
+ const allNodes = scanNodes(tasksDir, activeWorktrees, {
54
+ readdirSync: readdir,
55
+ existsSync: exists,
56
+ readFileSync: readFile
57
+ })
58
+
59
+ let needsAttention = false
60
+
61
+ // 1. Pending-audit без audit-result
62
+ const pendingAudit = allNodes.filter(n => n.state === 'pending-audit')
63
+ if (pendingAudit.length > 0) {
64
+ needsAttention = true
65
+ log(`[watch] pending-audit (${pendingAudit.length}) — потрібна ручна перевірка:`)
66
+ for (const n of pendingAudit) {
67
+ log(` - ${n.path}`)
68
+ }
69
+ }
70
+
71
+ // 2. Stale worktrees
72
+ let worktreeEntries = []
73
+ try {
74
+ worktreeEntries = readdir(worktreesDir)
75
+ } catch {
76
+ // worktrees dir може не існувати
77
+ }
78
+
79
+ const now = nowMs()
80
+ const staleWorktrees = []
81
+ for (const name of worktreeEntries) {
82
+ const wtPath = join(worktreesDir, name)
83
+ try {
84
+ const stat = statFn(wtPath)
85
+ const ageMs = now - stat.mtimeMs
86
+ if (ageMs > staleMs) {
87
+ staleWorktrees.push({ name, ageMin: Math.floor(ageMs / 60000) })
88
+ }
89
+ } catch {
90
+ // пропускаємо
91
+ }
92
+ }
93
+
94
+ if (staleWorktrees.length > 0) {
95
+ needsAttention = true
96
+ log(`[watch] stale worktrees (${staleWorktrees.length}) — неактивні > ${config.stale_worktree_min} хв:`)
97
+ for (const wt of staleWorktrees) {
98
+ log(` - ${wt.name} (${wt.ageMin} хв)`)
99
+ }
100
+ }
101
+
102
+ // 3. Needs-plan вузли
103
+ const needsPlan = allNodes.filter(n => n.state === 'needs-plan')
104
+ if (needsPlan.length > 0) {
105
+ log(`[watch] needs-plan (${needsPlan.length}) — потрібне планування:`)
106
+ for (const n of needsPlan) {
107
+ log(` - ${n.path}`)
108
+ }
109
+ }
110
+
111
+ // 4. Failed вузли
112
+ const failed = allNodes.filter(n => n.state === 'failed')
113
+ if (failed.length > 0) {
114
+ needsAttention = true
115
+ log(`[watch] failed (${failed.length}) — завершились з помилкою:`)
116
+ for (const n of failed) {
117
+ log(` - ${n.path}`)
118
+ }
119
+ }
120
+
121
+ if (!needsAttention && pendingAudit.length === 0 && failed.length === 0) {
122
+ const running = allNodes.filter(n => n.state === 'running').length
123
+ const resolved = allNodes.filter(n => n.state === 'resolved').length
124
+ log(`[watch] OK — total:${allNodes.length} running:${running} resolved:${resolved}`)
125
+ }
126
+
127
+ return needsAttention ? 1 : 0
128
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Завантаження конфігурації `.n-cursor.json` для graph-команд.
3
+ *
4
+ * Читає JSON з кореня репо (або з вказаного шляху), мержить із дефолтами.
5
+ * Підтримує per-node override через task.md (якщо вузол передає свої налаштування).
6
+ *
7
+ * FS ін'єктується для тестованості без диска.
8
+ */
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { cwd as processCwd } from 'node:process'
12
+
13
+ /** Дефолтні значення конфігурації. */
14
+ export const CONFIG_DEFAULTS = {
15
+ tasks_dir: './tasks',
16
+ worktrees_dir: './.worktrees',
17
+ warn_worktrees_above: 4,
18
+ max_worktrees: 8,
19
+ default_budget_sec: 1800,
20
+ budget_hard_sec_multiplier: 3,
21
+ progress_timeout_sec: 300,
22
+ claude_model: 'claude-sonnet-4-6',
23
+ audit_model: 'claude-haiku-4-5-20251001',
24
+ model_map: {
25
+ MIM: 'claude-haiku-4-5-20251001',
26
+ AVG: 'claude-sonnet-4-6',
27
+ MAX: 'claude-opus-4-8'
28
+ },
29
+ stale_worktree_min: 30,
30
+ system_prompt: '.n-cursor/system-prompt.md'
31
+ }
32
+
33
+ /**
34
+ * Завантажує конфігурацію з `.n-cursor.json` і мержить із дефолтами.
35
+ * @param {{
36
+ * root?: string,
37
+ * readFile?: (p: string, enc: string) => string,
38
+ * exists?: (p: string) => boolean
39
+ * }} [deps] ін'єкції
40
+ * @returns {typeof CONFIG_DEFAULTS} злита конфігурація
41
+ */
42
+ export function loadConfig(deps = {}) {
43
+ const root = deps.root ?? processCwd()
44
+ const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
45
+ const exists = deps.exists ?? existsSync
46
+
47
+ const configPath = join(root, '.n-cursor.json')
48
+
49
+ if (!exists(configPath)) {
50
+ return { ...CONFIG_DEFAULTS }
51
+ }
52
+
53
+ let raw
54
+ try {
55
+ raw = JSON.parse(readFile(configPath, 'utf8'))
56
+ } catch {
57
+ return { ...CONFIG_DEFAULTS }
58
+ }
59
+
60
+ return {
61
+ ...CONFIG_DEFAULTS,
62
+ ...raw,
63
+ model_map: {
64
+ ...CONFIG_DEFAULTS.model_map,
65
+ ...(raw.model_map ?? {})
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Повертає абсолютний шлях до tasks_dir.
72
+ * @param {typeof CONFIG_DEFAULTS} config конфігурація
73
+ * @param {string} root корінь репо
74
+ * @returns {string} абсолютний шлях
75
+ */
76
+ export function resolveTasksDir(config, root) {
77
+ const d = config.tasks_dir
78
+ return d.startsWith('/') ? d : join(root, d)
79
+ }
80
+
81
+ /**
82
+ * Повертає абсолютний шлях до worktrees_dir.
83
+ * @param {typeof CONFIG_DEFAULTS} config конфігурація
84
+ * @param {string} root корінь репо
85
+ * @returns {string} абсолютний шлях
86
+ */
87
+ export function resolveWorktreesDir(config, root) {
88
+ const d = config.worktrees_dir
89
+ return d.startsWith('/') ? d : join(root, d)
90
+ }
91
+
92
+ /**
93
+ * Резолвить модель за model_tier із model_map або повертає claude_model (дефолт).
94
+ * @param {typeof CONFIG_DEFAULTS} config конфігурація
95
+ * @param {string | undefined} modelTier 'MIM' | 'AVG' | 'MAX'
96
+ * @returns {string} model id
97
+ */
98
+ export function resolveModelByTier(config, modelTier) {
99
+ if (modelTier && config.model_map[modelTier]) {
100
+ return config.model_map[modelTier]
101
+ }
102
+ return config.claude_model
103
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * YAML front-matter parser/serializer для graph task-файлів.
3
+ *
4
+ * Підтримує:
5
+ * - Прості `key: value` рядки
6
+ * - Вкладені об'єкти (блок з відступами), напр. `executor:` + indented children
7
+ * - Списки: `deps:` / `skills:` із рядками ` - item`
8
+ * - Серіалізацію назад у YAML (для запису front-matter)
9
+ *
10
+ * Чисто — без залежностей, тільки вбудований JS. FS не торкається.
11
+ */
12
+
13
+ const FM_BOUNDARY_RE = /^---\r?\n([\s\S]*?)\r?\n---/
14
+
15
+ /**
16
+ * Парсить YAML front-matter з markdown-тексту.
17
+ * Повертає словник (може містити вкладені об'єкти та масиви).
18
+ * @param {string} text вміст файлу
19
+ * @returns {Record<string, unknown>} ключ-значення, або {} якщо front-matter відсутній
20
+ */
21
+ export function parseFrontMatter(text) {
22
+ const m = text.match(FM_BOUNDARY_RE)
23
+ if (!m) return {}
24
+ return parseYamlBlock(m[1])
25
+ }
26
+
27
+ /**
28
+ * Отримує тіло документа (без front-matter).
29
+ * @param {string} text вміст файлу
30
+ * @returns {string} тіло без front-matter
31
+ */
32
+ export function getBody(text) {
33
+ const m = text.match(FM_BOUNDARY_RE)
34
+ if (!m) return text
35
+ return text.slice(m[0].length).trimStart()
36
+ }
37
+
38
+ /**
39
+ * Парсить YAML-блок (без --- рядків).
40
+ * @param {string} block YAML-текст
41
+ * @returns {Record<string, unknown>} розпарсений об'єкт
42
+ */
43
+ function parseYamlBlock(block) {
44
+ const lines = block.split(/\r?\n/)
45
+ const result = {}
46
+ let i = 0
47
+
48
+ while (i < lines.length) {
49
+ const line = lines[i]
50
+ if (!line.trim() || line.trimStart().startsWith('#')) {
51
+ i++
52
+ continue
53
+ }
54
+
55
+ const indent = getIndent(line)
56
+ if (indent > 0) {
57
+ // Верхній рівень — пропускаємо "бродячі" дочірні рядки
58
+ i++
59
+ continue
60
+ }
61
+
62
+ const colonIdx = line.indexOf(':')
63
+ if (colonIdx === -1) {
64
+ i++
65
+ continue
66
+ }
67
+
68
+ const key = line.slice(0, colonIdx).trim()
69
+ const rawVal = line.slice(colonIdx + 1).trim()
70
+
71
+ if (rawVal.length > 0) {
72
+ // Inline значення — scalar
73
+ result[key] = parseScalar(rawVal)
74
+ i++
75
+ } else {
76
+ // Значення відсутнє після ':' — дивимось наступні рядки
77
+ i++
78
+ if (i >= lines.length) {
79
+ result[key] = null
80
+ continue
81
+ }
82
+
83
+ const nextLine = lines[i]
84
+ if (!nextLine.trim()) {
85
+ result[key] = null
86
+ continue
87
+ }
88
+
89
+ const nextIndent = getIndent(nextLine)
90
+ if (nextIndent === 0) {
91
+ // Без відступу — null
92
+ result[key] = null
93
+ continue
94
+ }
95
+
96
+ const nextTrimmed = nextLine.trimStart()
97
+ if (nextTrimmed.startsWith('- ')) {
98
+ // Список
99
+ const arr = []
100
+ while (i < lines.length) {
101
+ const l = lines[i]
102
+ if (!l.trim()) {
103
+ i++
104
+ continue
105
+ }
106
+ const ind = getIndent(l)
107
+ if (ind === 0) break
108
+ const t = l.trimStart()
109
+ if (t.startsWith('- ')) {
110
+ arr.push(parseScalar(t.slice(2).trim()))
111
+ }
112
+ i++
113
+ }
114
+ result[key] = arr
115
+ } else {
116
+ // Вкладений об'єкт
117
+ const childLines = []
118
+ while (i < lines.length) {
119
+ const l = lines[i]
120
+ if (!l.trim()) {
121
+ i++
122
+ continue
123
+ }
124
+ if (getIndent(l) === 0) break
125
+ // Нормалізуємо відступ (видаляємо перший рівень)
126
+ childLines.push(l.slice(nextIndent))
127
+ i++
128
+ }
129
+ result[key] = parseYamlBlock(childLines.join('\n'))
130
+ }
131
+ }
132
+ }
133
+
134
+ return result
135
+ }
136
+
137
+ /**
138
+ * Повертає кількість пробілів на початку рядка.
139
+ * @param {string} line рядок
140
+ * @returns {number} кількість пробілів
141
+ */
142
+ function getIndent(line) {
143
+ let count = 0
144
+ for (const ch of line) {
145
+ if (ch === ' ') count++
146
+ else break
147
+ }
148
+ return count
149
+ }
150
+
151
+ /**
152
+ * Парсить скалярне значення: число, булеве, null, або рядок.
153
+ * @param {string} s рядок-значення
154
+ * @returns {unknown} розпарсене значення
155
+ */
156
+ function parseScalar(s) {
157
+ if (s === 'true') return true
158
+ if (s === 'false') return false
159
+ if (s === 'null' || s === '~') return null
160
+ const n = Number(s)
161
+ if (!Number.isNaN(n) && s.trim().length > 0) return n
162
+ // Знімаємо лапки
163
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
164
+ return s.slice(1, -1)
165
+ }
166
+ return s
167
+ }
168
+
169
+ /**
170
+ * Серіалізує об'єкт у YAML-рядок (для front-matter).
171
+ * Підтримує прості scalar, масиви та вкладені об'єкти.
172
+ * @param {Record<string, unknown>} obj об'єкт для серіалізації
173
+ * @param {number} [indentLevel=0] рівень відступу
174
+ * @returns {string} YAML-рядок (без --- маркерів)
175
+ */
176
+ export function serializeYaml(obj, indentLevel = 0) {
177
+ const indent = ' '.repeat(indentLevel)
178
+ const lines = []
179
+
180
+ for (const [key, val] of Object.entries(obj)) {
181
+ if (val === null || val === undefined) {
182
+ lines.push(`${indent}${key}:`)
183
+ } else if (Array.isArray(val)) {
184
+ lines.push(`${indent}${key}:`)
185
+ for (const item of val) {
186
+ lines.push(`${indent} - ${serializeScalar(item)}`)
187
+ }
188
+ } else if (typeof val === 'object') {
189
+ lines.push(`${indent}${key}:`)
190
+ lines.push(serializeYaml(val, indentLevel + 1))
191
+ } else {
192
+ lines.push(`${indent}${key}: ${serializeScalar(val)}`)
193
+ }
194
+ }
195
+
196
+ return lines.join('\n')
197
+ }
198
+
199
+ /**
200
+ * Серіалізує скалярне значення у рядок.
201
+ * @param {unknown} val значення
202
+ * @returns {string} рядкове представлення
203
+ */
204
+ function serializeScalar(val) {
205
+ if (typeof val === 'string') {
206
+ // Додаємо лапки якщо містить спецсимволи
207
+ if (/[:#\[\]{},\n]/.test(val) || val.trim() !== val) {
208
+ return `"${val.replace(/"/g, '\\"')}"`
209
+ }
210
+ return val
211
+ }
212
+ return String(val)
213
+ }
214
+
215
+ /**
216
+ * Будує markdown-файл із front-matter і тілом.
217
+ * @param {Record<string, unknown>} fm об'єкт front-matter
218
+ * @param {string} [body=''] тіло документа
219
+ * @returns {string} повний вміст файлу
220
+ */
221
+ export function buildMarkdown(fm, body = '') {
222
+ const yaml = serializeYaml(fm)
223
+ return ['---', yaml, '---', '', body].join('\n')
224
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * NNN-нумерація для артефактів вузлів графу (run_NNN.md, fact_NNN.md, тощо).
3
+ *
4
+ * Всі функції — чисті утиліти, файлову систему отримують через ін'єкцію.
5
+ * NNN = рядок з ведучими нулями до 3 цифр: '001', '002', …
6
+ */
7
+
8
+ const RUN_FILE_RE = /^run_(\d+)\.md$/
9
+ const PLAN_FILE_RE = /^plan_(\d+)\.md$/
10
+ const FACT_FILE_RE = /^fact_(\d+)\.md$/
11
+ const PENDING_AUDIT_FILE_RE = /^pending-audit_(\d+)\.md$/
12
+ const AUDIT_RESULT_FILE_RE = /^audit-result_(\d+)\.md$/
13
+
14
+ /**
15
+ * Форматує число як NNN рядок (три цифри з ведучими нулями).
16
+ * @param {number} n невід'ємне ціле число
17
+ * @returns {string} '001', '002', …
18
+ */
19
+ export function padNNN(n) {
20
+ return String(n).padStart(3, '0')
21
+ }
22
+
23
+ /**
24
+ * Знаходить максимальний NNN серед файлів що відповідають regex, або 0 якщо не знайдено.
25
+ * @param {string[]} files список файлів директорії
26
+ * @param {RegExp} re regex з групою захоплення числа
27
+ * @returns {number} максимальний NNN або 0
28
+ */
29
+ function maxNNN(files, re) {
30
+ let max = 0
31
+ for (const f of files) {
32
+ const m = f.match(re)
33
+ if (m) {
34
+ const n = parseInt(m[1], 10)
35
+ if (n > max) max = n
36
+ }
37
+ }
38
+ return max
39
+ }
40
+
41
+ /**
42
+ * Наступний NNN для run_NNN.md: count(run_*.md) + 1.
43
+ * @param {string} nodeDir абсолютний шлях до директорії вузла
44
+ * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
45
+ * @returns {string} наступний NNN рядок
46
+ */
47
+ export function nextRunNNN(nodeDir, readdirSync) {
48
+ const files = readdirSync(nodeDir)
49
+ let count = 0
50
+ for (const f of files) {
51
+ if (RUN_FILE_RE.test(f)) count++
52
+ }
53
+ return padNNN(count + 1)
54
+ }
55
+
56
+ /**
57
+ * Наступний NNN для plan_NNN.md: max(plan_*.md numbers) + 1.
58
+ * @param {string} nodeDir абсолютний шлях до директорії вузла
59
+ * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
60
+ * @returns {string} наступний NNN рядок
61
+ */
62
+ export function nextPlanNNN(nodeDir, readdirSync) {
63
+ const files = readdirSync(nodeDir)
64
+ return padNNN(maxNNN(files, PLAN_FILE_RE) + 1)
65
+ }
66
+
67
+ /**
68
+ * Найвищий NNN серед fact_NNN.md, або null якщо немає.
69
+ * @param {string} nodeDir абсолютний шлях до директорії вузла
70
+ * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
71
+ * @returns {string | null} NNN рядок або null
72
+ */
73
+ export function latestFactNNN(nodeDir, readdirSync) {
74
+ const files = readdirSync(nodeDir)
75
+ const m = maxNNN(files, FACT_FILE_RE)
76
+ return m > 0 ? padNNN(m) : null
77
+ }
78
+
79
+ /**
80
+ * Перевіряє чи є pending-audit без відповідного audit-result.
81
+ * @param {string} nodeDir абсолютний шлях до директорії вузла
82
+ * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
83
+ * @returns {{ has: boolean, nnn: string | null }} результат
84
+ */
85
+ export function hasPendingAudit(nodeDir, readdirSync) {
86
+ const files = readdirSync(nodeDir)
87
+ const fileSet = new Set(files)
88
+
89
+ const pendingNNNs = []
90
+ for (const f of files) {
91
+ const m = f.match(PENDING_AUDIT_FILE_RE)
92
+ if (m) pendingNNNs.push(m[1])
93
+ }
94
+
95
+ for (const nnn of pendingNNNs) {
96
+ const resultFile = `audit-result_${nnn}.md`
97
+ if (!fileSet.has(resultFile)) {
98
+ return { has: true, nnn: padNNN(parseInt(nnn, 10)) }
99
+ }
100
+ }
101
+
102
+ return { has: false, nnn: null }
103
+ }
104
+
105
+ /**
106
+ * Знаходить NNN для останнього pending-audit_NNN.md (для audit-result).
107
+ * @param {string} nodeDir абсолютний шлях до директорії вузла
108
+ * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
109
+ * @returns {string | null} NNN рядок або null
110
+ */
111
+ export function latestPendingAuditNNN(nodeDir, readdirSync) {
112
+ const files = readdirSync(nodeDir)
113
+ const m = maxNNN(files, PENDING_AUDIT_FILE_RE)
114
+ return m > 0 ? padNNN(m) : null
115
+ }
116
+
117
+ /**
118
+ * Знаходить NNN для останнього audit-result_NNN.md.
119
+ * @param {string} nodeDir абсолютний шлях до директорії вузла
120
+ * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir
121
+ * @returns {string | null} NNN рядок або null
122
+ */
123
+ export function latestAuditResultNNN(nodeDir, readdirSync) {
124
+ const files = readdirSync(nodeDir)
125
+ const m = maxNNN(files, AUDIT_RESULT_FILE_RE)
126
+ return m > 0 ? padNNN(m) : null
127
+ }