@nitra/cursor 3.15.0 → 3.16.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 +6 -0
- package/bin/n-cursor.js +9 -1
- package/package.json +1 -1
- package/scripts/dispatcher/graph.mjs +212 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.16.0] - 2026-06-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- graph: команда status — read-only скан DAG вузлів (docs/graphs/<g>/nodes/) і derive позиції (done/failed/awaiting-human/in_progress/ready/blocked); фундамент node-dag-state
|
|
8
|
+
|
|
3
9
|
## [3.15.0] - 2026-06-02
|
|
4
10
|
|
|
5
11
|
### 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
|
@@ -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
|
+
}
|