@nitra/cursor 3.15.0 → 3.17.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.17.0] - 2026-06-02
4
+
5
+ ### Added
6
+
7
+ - skill changes (auto: завжди, worktree: true) — покласти change-файл у кожен зачеплений workspace через n-cursor change перед фінішем
8
+
9
+ ## [3.16.0] - 2026-06-02
10
+
11
+ ### Added
12
+
13
+ - graph: команда status — read-only скан DAG вузлів (docs/graphs/<g>/nodes/) і derive позиції (done/failed/awaiting-human/in_progress/ready/blocked); фундамент node-dag-state
14
+
3
15
  ## [3.15.0] - 2026-06-02
4
16
 
5
17
  ### Added
package/bin/n-cursor.js CHANGED
@@ -1572,6 +1572,14 @@ try {
1572
1572
 
1573
1573
  break
1574
1574
  }
1575
+ case 'graph': {
1576
+ // n-cursor graph — read-only позиція DAG вузлів (node-dag-state spec):
1577
+ // status сканує docs/graphs/<g>/nodes/ і деривує статус кожного вузла.
1578
+ const { runGraphCli } = await import('../scripts/dispatcher/graph.mjs')
1579
+ process.exitCode = runGraphCli(args)
1580
+
1581
+ break
1582
+ }
1575
1583
  case undefined:
1576
1584
  case '': {
1577
1585
  await runSync()
@@ -1581,7 +1589,7 @@ try {
1581
1589
  default: {
1582
1590
  console.error(`❌ Невідома команда: ${command}`)
1583
1591
  console.error(
1584
- ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, change, release, skill, worktree, lint-ci, flow, trace`
1592
+ ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, change, release, skill, worktree, lint-ci, flow, trace, graph`
1585
1593
  )
1586
1594
  process.exitCode = 1
1587
1595
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.15.0",
3
+ "version": "3.17.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `n-cursor graph` — read-only позиція DAG вузлів (контракт
3
+ * `docs/specs/2026-06-01-node-dag-state.md`). Перший зріз: `status` —
4
+ * сканує `docs/graphs/<g>/nodes/*.md`, групує файли по вузлах, деривує статус
5
+ * (done/failed/awaiting-human/in_progress/ready/blocked) і друкує таблицю.
6
+ *
7
+ * Стан — у файлах; нічого не мутує. FS (`readdir`/`readFile`/`exists`)
8
+ * ін'єктується — тестується без диска. claim/tick/dispatch — наступні зрізи.
9
+ */
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { cwd as processCwd } from 'node:process'
13
+
14
+ import { parseFrontMatter } from './trace.mjs'
15
+
16
+ /** Суфікси-артефакти вузла без qid. */
17
+ const PLAIN = [
18
+ ['.plan', 'plan'],
19
+ ['.claim', 'claim'],
20
+ ['.fact', 'fact']
21
+ ]
22
+ /** Префікси-артефакти з qid (`.ask-<qid>`, `.ans-<qid>`). */
23
+ const QID = [
24
+ ['.ask-', 'ask'],
25
+ ['.ans-', 'ans']
26
+ ]
27
+
28
+ /**
29
+ * Класифікує файл-артефакт вузла за назвою.
30
+ * @param {string} name назва файлу (напр. `B02-parser.ask-q1.md`)
31
+ * @returns {{ stem: string, kind: string, qid?: string } | null} класифікація або null
32
+ */
33
+ export function classifyArtifact(name) {
34
+ if (!name.endsWith('.md')) return null
35
+ const base = name.slice(0, -'.md'.length)
36
+ for (const [suffix, kind] of PLAIN) {
37
+ if (base.endsWith(suffix)) return { stem: base.slice(0, -suffix.length), kind }
38
+ }
39
+ for (const [prefix, kind] of QID) {
40
+ const i = base.lastIndexOf(prefix)
41
+ if (i !== -1) return { stem: base.slice(0, i), kind, qid: base.slice(i + prefix.length) }
42
+ }
43
+ return null
44
+ }
45
+
46
+ /**
47
+ * Парсить inline-список `[A, B]` із front-matter у масив id.
48
+ * @param {string | null | undefined} value значення поля
49
+ * @returns {string[]} елементи (trim, без порожніх)
50
+ */
51
+ export function parseIdList(value) {
52
+ if (typeof value !== 'string') return []
53
+ return value
54
+ .replace('[', '')
55
+ .replace(']', '')
56
+ .split(',')
57
+ .map(s => s.trim())
58
+ .filter(Boolean)
59
+ }
60
+
61
+ /**
62
+ * Сканує вузли графа: групує файли по stem, читає plan/fact front-matter.
63
+ * @param {string} root корінь репо
64
+ * @param {string} graph id графа (каталог `docs/graphs/<graph>`)
65
+ * @param {{ readdir?: (dir: string) => string[], readFile?: (file: string) => string }} [deps] ін'єкції FS
66
+ * @returns {{ id: string, slug: string, dependsOn: string[], owner: string | null, hasClaim: boolean, hasFact: boolean, factStatus: string | null, asks: string[], answered: string[] }[]} вузли
67
+ */
68
+ export function scanGraph(root, graph, deps = {}) {
69
+ const readdir = deps.readdir ?? (dir => (existsSync(dir) ? readdirSync(dir) : []))
70
+ const readFile = deps.readFile ?? (file => readFileSync(file, 'utf8'))
71
+ const dir = join(root, 'docs', 'graphs', graph, 'nodes')
72
+
73
+ const byStem = new Map()
74
+ const ensure = stem => {
75
+ if (!byStem.has(stem)) {
76
+ byStem.set(stem, {
77
+ stem,
78
+ id: stem.split('-')[0],
79
+ slug: stem.slice(stem.indexOf('-') + 1),
80
+ dependsOn: [],
81
+ owner: null,
82
+ hasClaim: false,
83
+ hasFact: false,
84
+ factStatus: null,
85
+ asks: [],
86
+ answered: []
87
+ })
88
+ }
89
+ return byStem.get(stem)
90
+ }
91
+
92
+ for (const name of readdir(dir)) {
93
+ const art = classifyArtifact(name)
94
+ if (!art) continue
95
+ const node = ensure(art.stem)
96
+ switch (art.kind) {
97
+ case 'plan': {
98
+ const fm = parseFrontMatter(readFile(join(dir, name))) ?? {}
99
+ node.id = fm.id ?? node.id
100
+ node.dependsOn = parseIdList(fm.dependsOn)
101
+ node.owner = fm.owner ?? null
102
+ break
103
+ }
104
+ case 'claim': {
105
+ node.hasClaim = true
106
+ break
107
+ }
108
+ case 'fact': {
109
+ const fm = parseFrontMatter(readFile(join(dir, name))) ?? {}
110
+ node.hasFact = true
111
+ node.factStatus = fm.status ?? 'done'
112
+ break
113
+ }
114
+ case 'ask': {
115
+ node.asks.push(art.qid)
116
+ break
117
+ }
118
+ case 'ans': {
119
+ node.answered.push(art.qid)
120
+ break
121
+ }
122
+ // no default
123
+ }
124
+ }
125
+ return [...byStem.values()]
126
+ }
127
+
128
+ /**
129
+ * Деривує статус одного вузла (чиста).
130
+ * @param {{ hasFact: boolean, factStatus: string | null, hasClaim: boolean, asks: string[], answered: string[], dependsOn: string[] }} node вузол
131
+ * @param {Set<string>} doneSet id вузлів зі статусом done
132
+ * @returns {'done' | 'failed' | 'awaiting-human' | 'in_progress' | 'ready' | 'blocked'} статус
133
+ */
134
+ export function deriveStatus(node, doneSet) {
135
+ if (node.hasFact) return node.factStatus === 'failed' ? 'failed' : 'done'
136
+ const openAsk = node.asks.some(q => !node.answered.includes(q))
137
+ if (node.hasClaim && openAsk) return 'awaiting-human'
138
+ if (node.hasClaim) return 'in_progress'
139
+ if (node.dependsOn.every(d => doneSet.has(d))) return 'ready'
140
+ return 'blocked'
141
+ }
142
+
143
+ /**
144
+ * Деривує статуси всіх вузлів графа (спершу doneSet із fact done).
145
+ * @param {object[]} nodes вузли зі `scanGraph`
146
+ * @returns {object[]} вузли з полем `status`
147
+ */
148
+ export function deriveGraph(nodes) {
149
+ const doneSet = new Set(nodes.filter(n => n.hasFact && n.factStatus !== 'failed').map(n => n.id))
150
+ return nodes.map(n => ({ ...n, status: deriveStatus(n, doneSet) }))
151
+ }
152
+
153
+ /**
154
+ * Текстовий рендер позиції графа.
155
+ * @param {string} graph id графа
156
+ * @param {object[]} nodes вузли з полем `status`
157
+ * @returns {string} людино-читабельна таблиця
158
+ */
159
+ export function renderGraph(graph, nodes) {
160
+ if (nodes.length === 0) return `граф ${graph}: вузлів не знайдено`
161
+ const order = ['in_progress', 'awaiting-human', 'ready', 'blocked', 'failed', 'done']
162
+ const counts = order
163
+ .map(s => [s, nodes.filter(n => n.status === s).length])
164
+ .filter(([, c]) => c > 0)
165
+ .map(([s, c]) => `${s}:${c}`)
166
+ .join(' ')
167
+ const lines = [`граф ${graph} — ${counts}`]
168
+ for (const n of nodes) {
169
+ const owner = n.owner ? ` ${n.owner}` : ''
170
+ const deps = n.dependsOn.length > 0 ? ` ←[${n.dependsOn.join(',')}]` : ''
171
+ lines.push(` ${n.id} · ${n.slug} [${n.status}]${owner}${deps}`)
172
+ }
173
+ return lines.join('\n')
174
+ }
175
+
176
+ /**
177
+ * Перелік графів (каталоги в `docs/graphs/`).
178
+ * @param {string} root корінь репо
179
+ * @param {(dir: string) => string[]} readdir інжектована readdir
180
+ * @returns {string[]} id графів
181
+ */
182
+ function listGraphs(root, readdir) {
183
+ return readdir(join(root, 'docs', 'graphs'))
184
+ }
185
+
186
+ /**
187
+ * CLI `n-cursor graph <status> [graph]`. Read-only.
188
+ * @param {string[]} args аргументи після `graph`
189
+ * @param {{ cwd?: string, readdir?: (dir: string) => string[], readFile?: (file: string) => string, log?: (m: string) => void }} [deps] ін'єкції
190
+ * @returns {number} exit code (0 ok, 1 невідома підкоманда)
191
+ */
192
+ export function runGraphCli(args, deps = {}) {
193
+ const root = deps.cwd ?? processCwd()
194
+ const readdir = deps.readdir ?? (dir => (existsSync(dir) ? readdirSync(dir) : []))
195
+ const log = deps.log ?? console.log
196
+ const [sub, graphArg] = args
197
+
198
+ if (sub !== 'status') {
199
+ log('Usage: n-cursor graph status [<graph>]')
200
+ return 1
201
+ }
202
+
203
+ const graphs = graphArg ? [graphArg] : listGraphs(root, readdir)
204
+ if (graphs.length === 0) {
205
+ log('graph: у docs/graphs/ немає графів')
206
+ return 0
207
+ }
208
+ for (const g of graphs) {
209
+ log(renderGraph(g, deriveGraph(scanGraph(root, g, { readdir, readFile: deps.readFile }))))
210
+ }
211
+ return 0
212
+ }
@@ -1 +1 @@
1
- { "auto": "завжди", "worktree": false }
1
+ { "auto": "завжди", "worktree": true }