@nitra/cursor 4.1.2 → 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.
- package/CHANGELOG.md +14 -0
- package/bin/docs/n-cursor.md +1 -9
- package/bin/n-cursor.js +3 -25
- package/package.json +1 -1
- package/rules/docker/lib/docs/docker-mirror.md +1 -1
- package/rules/docker/lib/docs/docker-native-addon.md +1 -1
- package/rules/test/coverage/coverage.mjs +9 -19
- package/rules/test/test.mdc +1 -1
- package/scripts/dispatcher/trace.mjs +4 -16
- package/scripts/docs/build-agents-commands.md +1 -1
- package/scripts/docs/worktree-cli.md +1 -1
- package/scripts/lib/changed-files.mjs +19 -3
- package/scripts/lib/sync-gitignore-worktree.mjs +4 -5
- package/scripts/worktree-cli.mjs +1 -2
- package/skills/docgen/js/docgen-gen.mjs +7 -7
- package/docs/flow.MD +0 -1364
- package/scripts/dispatcher/docs/graph.md +0 -346
- package/scripts/dispatcher/docs/index.md +0 -236
- package/scripts/dispatcher/docs/trace.md +0 -296
- package/scripts/dispatcher/graph/lib/cmd-init.mjs +0 -112
- package/scripts/dispatcher/graph/lib/cmd-invalidate.mjs +0 -96
- package/scripts/dispatcher/graph/lib/cmd-kill.mjs +0 -141
- package/scripts/dispatcher/graph/lib/cmd-plan.mjs +0 -142
- package/scripts/dispatcher/graph/lib/cmd-run.mjs +0 -328
- package/scripts/dispatcher/graph/lib/cmd-scan.mjs +0 -115
- package/scripts/dispatcher/graph/lib/cmd-setup.mjs +0 -111
- package/scripts/dispatcher/graph/lib/cmd-signals.mjs +0 -328
- package/scripts/dispatcher/graph/lib/cmd-status.mjs +0 -131
- package/scripts/dispatcher/graph/lib/cmd-verify.mjs +0 -100
- package/scripts/dispatcher/graph/lib/cmd-watch.mjs +0 -128
- package/scripts/dispatcher/graph/lib/config.mjs +0 -103
- package/scripts/dispatcher/graph/lib/frontmatter.mjs +0 -224
- package/scripts/dispatcher/graph/lib/nnn.mjs +0 -127
- package/scripts/dispatcher/graph/lib/node-state.mjs +0 -157
- package/scripts/dispatcher/graph/lib/scanner.mjs +0 -235
- package/scripts/dispatcher/graph/lib/worktree-ops.mjs +0 -193
- package/scripts/dispatcher/graph-tasks.mjs +0 -92
- package/scripts/dispatcher/graph.mjs +0 -212
- package/scripts/dispatcher/index.mjs +0 -45
- package/scripts/dispatcher/lib/docs/active.md +0 -348
- package/scripts/dispatcher/lib/docs/artifact.md +0 -232
- package/scripts/dispatcher/lib/docs/budget.md +0 -167
- package/scripts/dispatcher/lib/docs/capability.md +0 -196
- package/scripts/dispatcher/lib/docs/commands.md +0 -210
- package/scripts/dispatcher/lib/docs/events.md +0 -183
- package/scripts/dispatcher/lib/docs/executor.md +0 -190
- package/scripts/dispatcher/lib/docs/gate.md +0 -231
- package/scripts/dispatcher/lib/docs/level.md +0 -335
- package/scripts/dispatcher/lib/docs/plan-panel.md +0 -181
- package/scripts/dispatcher/lib/docs/plan.md +0 -200
- package/scripts/dispatcher/lib/docs/planner.md +0 -269
- package/scripts/dispatcher/lib/docs/review.md +0 -255
- package/scripts/dispatcher/lib/docs/reviewer.md +0 -240
- package/scripts/dispatcher/lib/docs/snapshot.md +0 -247
- package/scripts/dispatcher/lib/docs/spec.md +0 -203
- package/scripts/dispatcher/lib/docs/state-store.md +0 -303
- package/scripts/dispatcher/lib/docs/subagent-runner.md +0 -173
- package/scripts/dispatcher/lib/events.mjs +0 -67
- package/scripts/dispatcher/lib/executor.mjs +0 -107
- package/scripts/dispatcher/lib/plan-panel.mjs +0 -76
- package/scripts/dispatcher/lib/state-store.mjs +0 -173
- package/scripts/dispatcher/lib/subagent-runner.mjs +0 -53
- package/scripts/graph/index.mjs +0 -115
- package/scripts/graph/lib/config.mjs +0 -62
- package/scripts/graph/lib/dag.mjs +0 -161
- package/scripts/graph/lib/frontmatter.mjs +0 -70
- package/scripts/graph/lib/nnn.mjs +0 -77
- package/scripts/graph/lib/state.mjs +0 -110
- package/scripts/graph/scan.mjs +0 -64
- package/scripts/graph/status.mjs +0 -86
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
# `trace.mjs` — наскрізна простежуваність артефактів документації
|
|
2
|
-
|
|
3
|
-
## Огляд
|
|
4
|
-
|
|
5
|
-
Модуль реалізує CLI-команду `n-cursor trace` (специфікація §5.4 / §7) — інструмент **наскрізної простежуваності** (traceability) між артефактами в `docs/`. Він читає YAML-front-matter з усіх Markdown-файлів у каталогах `docs/tasks`, `docs/specs`, `docs/plans`, `docs/adr`, будує ланцюг зв'язків між ними за полями-лінками (`adr`, `spec`, `plan`, `flow`, `change`, `task`) і **флагує розриви** — тобто посилання на неіснуючі файли.
|
|
6
|
-
|
|
7
|
-
Модуль повністю **read-only**: жодних мутацій файлової системи. Підтримує два режими виводу:
|
|
8
|
-
|
|
9
|
-
- **текстовий** (за замовчуванням) — людино-читабельний звіт з символами `→`/`✗`/`~`;
|
|
10
|
-
- **JSON** (`--json`) — machine-readable структура для CI / інших інструментів.
|
|
11
|
-
|
|
12
|
-
Поведінка щодо FS повністю інжектабельна (`readdir`, `readFile`, `exists`, `cwd`, `log`), завдяки чому модуль тестується без реального диска та без зміни робочої директорії.
|
|
13
|
-
|
|
14
|
-
Окремий нюанс — поле `flow` трактується як **інформаційне**, а не chain-поле: воно вказує на runtime-стан у `.worktrees/<branch>.flow.json`, який gitignored і за межами `docs/`, тому його відсутність ніколи не вважається розривом ланцюга (інакше у чистому checkout або CI-сесії був би хибний сигнал).
|
|
15
|
-
|
|
16
|
-
## Експорти / API
|
|
17
|
-
|
|
18
|
-
| Експорт | Тип | Призначення |
|
|
19
|
-
| ----------------------------- | -------------- | ------------------------------------------------------------ |
|
|
20
|
-
| `parseFrontMatter(content)` | named function | Парсить плаский YAML-front-matter Markdown-файла. |
|
|
21
|
-
| `analyze(artifacts, resolve)` | named function | Будує аналіз лінків артефактів зі статусами `ok`/`breaking`. |
|
|
22
|
-
| `render(analysis)` | named function | Текстовий рендер результату `analyze`. |
|
|
23
|
-
| `runTraceCli(args, deps?)` | named function | Точка входу CLI `n-cursor trace [--json]`. |
|
|
24
|
-
|
|
25
|
-
Внутрішні (не експортуються): `isSimpleKey`, `resolveLink`, `renderLink`, константи `LINK_FIELDS`, `INFO_LINK_FIELDS`, `DIRS`.
|
|
26
|
-
|
|
27
|
-
### Константи модуля
|
|
28
|
-
|
|
29
|
-
- `LINK_FIELDS = ['adr', 'spec', 'plan', 'flow', 'change', 'task']` — впорядкований список полів front-matter, які розглядаються як лінки. Порядок впливає на порядок виводу.
|
|
30
|
-
- `INFO_LINK_FIELDS = new Set(['flow'])` — підмножина полів, відсутність яких **не** рве ланцюг (інформаційні, не breaking).
|
|
31
|
-
- `DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']` — каталоги, у яких шукаються traceable-артефакти.
|
|
32
|
-
|
|
33
|
-
## Функції
|
|
34
|
-
|
|
35
|
-
### `parseFrontMatter(content)`
|
|
36
|
-
|
|
37
|
-
**Сигнатура:** `parseFrontMatter(content: string): Record<string, string | null> | null`
|
|
38
|
-
|
|
39
|
-
**Параметри:**
|
|
40
|
-
|
|
41
|
-
- `content` — повний текст Markdown-файла.
|
|
42
|
-
|
|
43
|
-
**Повертає:**
|
|
44
|
-
|
|
45
|
-
- Об'єкт `{ key: value }` зі значеннями типу `string` (звичайне поле) або `null` (порожнє чи літерал `null`);
|
|
46
|
-
- `null`, якщо файл не починається з `---` або немає закриваючого `\n---`.
|
|
47
|
-
|
|
48
|
-
**Логіка:**
|
|
49
|
-
|
|
50
|
-
1. Перевіряє, що файл починається з `---`. Інакше — `null`.
|
|
51
|
-
2. Шукає закриваючий маркер `\n---` починаючи з 4-ї позиції. Якщо нема — `null`.
|
|
52
|
-
3. Розбиває блок front-matter на рядки.
|
|
53
|
-
4. Для кожного рядка: знаходить перше `:`, ділить на `key` / `val`.
|
|
54
|
-
5. Відсікає `key`, який не є простим ідентифікатором (`isSimpleKey`) — тобто рядки з вкладеними структурами, дефісами, цифрами тощо ігноруються.
|
|
55
|
-
6. Відрізає інлайн-коментар у форматі ` #…` (пробіл-решітка).
|
|
56
|
-
7. Тримує значення, прибирає одиничні зовнішні лапки `"`/`'`.
|
|
57
|
-
8. Якщо значення порожнє або дорівнює рядку `'null'` — нормалізує у `null`.
|
|
58
|
-
|
|
59
|
-
**Side effects:** немає (чиста функція).
|
|
60
|
-
|
|
61
|
-
### `isSimpleKey(key)` (внутрішня)
|
|
62
|
-
|
|
63
|
-
**Сигнатура:** `isSimpleKey(key: string): boolean`
|
|
64
|
-
|
|
65
|
-
**Параметри:** `key` — потенційний ключ front-matter.
|
|
66
|
-
|
|
67
|
-
**Повертає:** `true`, якщо ключ непорожній і складається тільки з літер `a–z`/`A–Z` та підкреслень. Регулярний вираз: `/[a-z_]/iu` для кожного символу.
|
|
68
|
-
|
|
69
|
-
**Side effects:** немає.
|
|
70
|
-
|
|
71
|
-
**Призначення:** захист від спроби парсити вкладені структури або не-ідентифікатори (рядки з `-`, цифрами, `.`, тощо).
|
|
72
|
-
|
|
73
|
-
### `analyze(artifacts, resolve)`
|
|
74
|
-
|
|
75
|
-
**Сигнатура:**
|
|
76
|
-
|
|
77
|
-
```
|
|
78
|
-
analyze(
|
|
79
|
-
artifacts: { file: string, fm: Record<string, string | null> }[],
|
|
80
|
-
resolve: (target: string, artifactFile: string) => boolean
|
|
81
|
-
): {
|
|
82
|
-
file: string,
|
|
83
|
-
kind: string | null,
|
|
84
|
-
id: string | null,
|
|
85
|
-
status: string | null,
|
|
86
|
-
links: { field: string, target: string, ok: boolean, breaking: boolean }[]
|
|
87
|
-
}[]
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
**Параметри:**
|
|
91
|
-
|
|
92
|
-
- `artifacts` — масив пар `{ file, fm }`: відносний шлях артефакту та розпарсений front-matter.
|
|
93
|
-
- `resolve` — предикат, що повертає `true`, якщо лінк-цільовий файл існує (зазвичай — bound-`resolveLink`).
|
|
94
|
-
|
|
95
|
-
**Повертає:** масив аналізованих артефактів. Для кожного:
|
|
96
|
-
|
|
97
|
-
- `kind`, `id`, `status` — з front-matter (або `null`);
|
|
98
|
-
- `links` — масив об'єктів про кожен наявний лінк:
|
|
99
|
-
- `field` — назва поля (один з `LINK_FIELDS`);
|
|
100
|
-
- `target` — значення лінка;
|
|
101
|
-
- `ok` — чи резолвиться цільовий файл;
|
|
102
|
-
- `breaking` — `false`, якщо поле в `INFO_LINK_FIELDS` (зараз — `flow`); `true` для всіх інших.
|
|
103
|
-
|
|
104
|
-
**Логіка:** проходить `LINK_FIELDS` у фіксованому порядку, бере тільки поля, наявні у `fm` з truthy-значенням.
|
|
105
|
-
|
|
106
|
-
**Side effects:** немає (логіка чиста, `resolve` інкапсулює I/O).
|
|
107
|
-
|
|
108
|
-
### `resolveLink(root, artifactFile, target, exists)` (внутрішня)
|
|
109
|
-
|
|
110
|
-
**Сигнатура:** `resolveLink(root: string, artifactFile: string, target: string, exists: (absPath: string) => boolean): boolean`
|
|
111
|
-
|
|
112
|
-
**Параметри:**
|
|
113
|
-
|
|
114
|
-
- `root` — абсолютний шлях кореня репо;
|
|
115
|
-
- `artifactFile` — rel-шлях артефакту (напр. `docs/plans/x.md`);
|
|
116
|
-
- `target` — значення лінка з front-matter;
|
|
117
|
-
- `exists` — інжекторована перевірка існування файла.
|
|
118
|
-
|
|
119
|
-
**Повертає:** `true`, якщо `target` резолвиться **або** відносно теки артефакту (`<root>/<dirname(artifactFile)>/<target>`), **або** root-relative (`<root>/<target>`). Обидві форми вважаються валідними — це конвенція документації (`../specs/…` чи `docs/specs/…`).
|
|
120
|
-
|
|
121
|
-
**Side effects:** виклик `exists` (зовнішнє I/O), але інкапсульовано — модуль сам диск не чіпає.
|
|
122
|
-
|
|
123
|
-
### `render(analysis)`
|
|
124
|
-
|
|
125
|
-
**Сигнатура:** `render(analysis: ReturnType<typeof analyze>): string`
|
|
126
|
-
|
|
127
|
-
**Параметри:** `analysis` — результат `analyze`.
|
|
128
|
-
|
|
129
|
-
**Повертає:** багаторядковий текст:
|
|
130
|
-
|
|
131
|
-
- Якщо `analysis` порожній — рядок `'trace: артефактів із front-matter не знайдено'`.
|
|
132
|
-
- Інакше для кожного артефакту:
|
|
133
|
-
- заголовок виду `${kind} · ${id ?? file} [${status ?? '—'}]`;
|
|
134
|
-
- вкладені рядки лінків (через `renderLink`), з відступом 3 пробіли.
|
|
135
|
-
|
|
136
|
-
**Side effects:** немає.
|
|
137
|
-
|
|
138
|
-
### `renderLink(l)` (внутрішня)
|
|
139
|
-
|
|
140
|
-
**Сигнатура:** `renderLink(l: { field: string, target: string, ok: boolean, breaking: boolean }): string`
|
|
141
|
-
|
|
142
|
-
**Повертає:** один з трьох форматів:
|
|
143
|
-
|
|
144
|
-
- `→ <field>: <target>` — резолвлено успішно (`l.ok === true`);
|
|
145
|
-
- `✗ <field>: <target> (РОЗРИВ — файл відсутній)` — нерезолвлене chain-поле (`breaking && !ok`);
|
|
146
|
-
- `~ <field>: <target> (runtime-стан — не рве ланцюг)` — нерезолвлене info-поле (`!breaking && !ok`, наприклад `flow`).
|
|
147
|
-
|
|
148
|
-
**Side effects:** немає.
|
|
149
|
-
|
|
150
|
-
### `runTraceCli(args, deps?)`
|
|
151
|
-
|
|
152
|
-
**Сигнатура:**
|
|
153
|
-
|
|
154
|
-
```
|
|
155
|
-
runTraceCli(
|
|
156
|
-
args: string[],
|
|
157
|
-
deps?: {
|
|
158
|
-
cwd?: string,
|
|
159
|
-
readdir?: (dir: string) => string[],
|
|
160
|
-
readFile?: (file: string) => string,
|
|
161
|
-
exists?: (file: string) => boolean,
|
|
162
|
-
log?: (m: string) => void
|
|
163
|
-
}
|
|
164
|
-
): number
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
**Параметри:**
|
|
168
|
-
|
|
169
|
-
- `args` — CLI-аргументи (підтримується тільки `--json`);
|
|
170
|
-
- `deps` — інжектовані залежності для тестування. Дефолти:
|
|
171
|
-
- `cwd` → `process.cwd()`;
|
|
172
|
-
- `readdir` → `readdirSync` з охороною `existsSync` (повертає `[]`, якщо каталога нема);
|
|
173
|
-
- `readFile` → `readFileSync(file, 'utf8')`;
|
|
174
|
-
- `exists` → `existsSync`;
|
|
175
|
-
- `log` → `console.log`.
|
|
176
|
-
|
|
177
|
-
**Повертає:** exit code:
|
|
178
|
-
|
|
179
|
-
- `0` — ланцюг цілісний (немає breaking-розривів);
|
|
180
|
-
- `1` — є хоча б один breaking-лінк, що не резолвиться.
|
|
181
|
-
|
|
182
|
-
**Side effects:**
|
|
183
|
-
|
|
184
|
-
- Читання FS через `readdir` / `readFile` / `exists` (інжектабельне).
|
|
185
|
-
- Виклик `log` — за замовчуванням друк у `stdout`.
|
|
186
|
-
- Жодних мутацій FS, мережі або стану процесу.
|
|
187
|
-
|
|
188
|
-
**Логіка покроково:**
|
|
189
|
-
|
|
190
|
-
1. Резолвить `root`, `readdir`, `readFile`, `exists`, `log` з `deps` або дефолтів.
|
|
191
|
-
2. Проходить кожен каталог з `DIRS`.
|
|
192
|
-
3. У кожному каталозі бере файли з розширенням `.md`.
|
|
193
|
-
4. Для кожного `.md`-файла:
|
|
194
|
-
- читає вміст через `readFile`;
|
|
195
|
-
- парсить front-matter через `parseFrontMatter`;
|
|
196
|
-
- якщо парсинг успішний і у front-matter є `id` або `kind` — додає в `artifacts`.
|
|
197
|
-
5. Викликає `analyze(artifacts, resolve)`, де `resolve` — частково застосований `resolveLink(root, file, target, exists)`.
|
|
198
|
-
6. Друкує результат: `JSON.stringify(analysis, null, 2)` для `--json`, інакше `render(analysis)`.
|
|
199
|
-
7. Повертає `1`, якщо існує лінк з `breaking && !ok`, інакше `0`. Нерезолвлений `flow` (info-поле) ігнорується для exit code.
|
|
200
|
-
|
|
201
|
-
## Залежності
|
|
202
|
-
|
|
203
|
-
### Системні (Node.js)
|
|
204
|
-
|
|
205
|
-
- `node:fs` — `existsSync`, `readdirSync`, `readFileSync` (використовуються тільки як дефолтні значення для `deps`).
|
|
206
|
-
- `node:path` — `dirname`, `join` для побудови шляхів у `resolveLink` та CLI.
|
|
207
|
-
- `node:process` — `cwd` (alias-імпорт `processCwd`) для дефолтного кореня.
|
|
208
|
-
|
|
209
|
-
### Внутрішньопроєктні
|
|
210
|
-
|
|
211
|
-
- Жодних. Модуль автономний.
|
|
212
|
-
|
|
213
|
-
### Зовнішні / npm
|
|
214
|
-
|
|
215
|
-
- Жодних.
|
|
216
|
-
|
|
217
|
-
### Споживачі
|
|
218
|
-
|
|
219
|
-
Модуль викликається з диспатчера `n-cursor` як CLI-команда `n-cursor trace`. Експортовані `parseFrontMatter`, `analyze`, `render` доступні для повторного використання іншими інструментами (напр. для дашбордів простежуваності або юніт-тестів).
|
|
220
|
-
|
|
221
|
-
## Потік виконання / Використання
|
|
222
|
-
|
|
223
|
-
### CLI
|
|
224
|
-
|
|
225
|
-
```
|
|
226
|
-
n-cursor trace
|
|
227
|
-
n-cursor trace --json
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
**Текстовий приклад виводу:**
|
|
231
|
-
|
|
232
|
-
```
|
|
233
|
-
spec · NSPEC-42 [draft]
|
|
234
|
-
→ adr: ../adr/NADR-7.md
|
|
235
|
-
✗ plan: ../plans/NPLAN-99.md (РОЗРИВ — файл відсутній)
|
|
236
|
-
~ flow: ../../.worktrees/feat-x.flow.json (runtime-стан — не рве ланцюг)
|
|
237
|
-
plan · NPLAN-12 [active]
|
|
238
|
-
→ spec: ../specs/NSPEC-42.md
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
**JSON-приклад:**
|
|
242
|
-
|
|
243
|
-
```
|
|
244
|
-
[
|
|
245
|
-
{
|
|
246
|
-
"file": "docs/specs/NSPEC-42.md",
|
|
247
|
-
"kind": "spec",
|
|
248
|
-
"id": "NSPEC-42",
|
|
249
|
-
"status": "draft",
|
|
250
|
-
"links": [
|
|
251
|
-
{ "field": "adr", "target": "../adr/NADR-7.md", "ok": true, "breaking": true },
|
|
252
|
-
{ "field": "plan", "target": "../plans/NPLAN-99.md", "ok": false, "breaking": true },
|
|
253
|
-
{ "field": "flow", "target": "../../.worktrees/x.flow.json","ok": false, "breaking": false }
|
|
254
|
-
]
|
|
255
|
-
}
|
|
256
|
-
]
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
### Алгоритм (псевдо-flowchart)
|
|
260
|
-
|
|
261
|
-
1. **DIR walk** — `for dir in DIRS: for name in readdir(root/dir)`.
|
|
262
|
-
2. **Filter** — лише `*.md`.
|
|
263
|
-
3. **Parse** — `parseFrontMatter(readFile(...))`.
|
|
264
|
-
4. **Filter artifacts** — лише ті, що мають `fm.id` або `fm.kind`.
|
|
265
|
-
5. **Analyze** — для кожного артефакту перетворити front-matter-лінки на `{ field, target, ok, breaking }`.
|
|
266
|
-
6. **Render / JSON** — серіалізація.
|
|
267
|
-
7. **Exit code** — `1` якщо `any(link.breaking && !link.ok)`, інакше `0`.
|
|
268
|
-
|
|
269
|
-
### Програмне використання
|
|
270
|
-
|
|
271
|
-
```
|
|
272
|
-
import { parseFrontMatter, analyze, render, runTraceCli } from './trace.mjs'
|
|
273
|
-
|
|
274
|
-
// Як CLI з мок-FS
|
|
275
|
-
const code = runTraceCli(['--json'], {
|
|
276
|
-
cwd: '/repo',
|
|
277
|
-
readdir: (dir) => fakeFs[dir] ?? [],
|
|
278
|
-
readFile: (file) => fakeFs[file],
|
|
279
|
-
exists: (file) => file in fakeFs,
|
|
280
|
-
log: (msg) => collected.push(msg)
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
// Як бібліотека (без I/O)
|
|
284
|
-
const fm = parseFrontMatter('---\nid: NSPEC-1\nkind: spec\nplan: ../plans/NPLAN-1.md\n---\n# …')
|
|
285
|
-
const result = analyze([{ file: 'docs/specs/x.md', fm }], () => true)
|
|
286
|
-
console.log(render(result))
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
### Семантика exit code
|
|
290
|
-
|
|
291
|
-
- `0` — всі **chain**-лінки (`adr`, `spec`, `plan`, `change`, `task`) резолвляться.
|
|
292
|
-
- `1` — є хоча б один chain-лінк, що **не** резолвиться. Поле `flow` ніколи не впливає на код виходу.
|
|
293
|
-
|
|
294
|
-
### Тестованість
|
|
295
|
-
|
|
296
|
-
Через повну ін'єкцію `cwd`/`readdir`/`readFile`/`exists`/`log` модуль покривається юніт-тестами без файлової системи. Чисті функції `parseFrontMatter`, `analyze`, `render` тестуються прямо на in-memory даних.
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `n-cursor graph init <name>` — створює task.md шаблон для нового вузла.
|
|
3
|
-
*
|
|
4
|
-
* Не потребує LLM. Просто пише task.md з front-matter і порожнім тілом.
|
|
5
|
-
* Ім'я може містити `/` для вкладених вузлів (напр. "research/collect-data").
|
|
6
|
-
*
|
|
7
|
-
* FS ін'єктується для тестованості.
|
|
8
|
-
*/
|
|
9
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
10
|
-
import { join } from 'node:path'
|
|
11
|
-
import { cwd as processCwd } from 'node:process'
|
|
12
|
-
|
|
13
|
-
import { buildMarkdown } from './frontmatter.mjs'
|
|
14
|
-
import { loadConfig, resolveTasksDir } from './config.mjs'
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Будує front-matter для task.md шаблону.
|
|
18
|
-
* @param {{ now: string, name: string }} params параметри
|
|
19
|
-
* @returns {Record<string, unknown>} front-matter об'єкт
|
|
20
|
-
*/
|
|
21
|
-
export function buildTaskFrontMatter(params) {
|
|
22
|
-
return {
|
|
23
|
-
created_at: params.now,
|
|
24
|
-
budget_sec: 600,
|
|
25
|
-
mode: 'human',
|
|
26
|
-
interactive: true,
|
|
27
|
-
executor: {
|
|
28
|
-
type: 'agent',
|
|
29
|
-
model_tier: 'AVG',
|
|
30
|
-
skills: ['bash', 'write-files']
|
|
31
|
-
},
|
|
32
|
-
hint: 'atomic',
|
|
33
|
-
deps: []
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* `graph init <name>` command handler.
|
|
39
|
-
* @param {string[]} args аргументи: [name]
|
|
40
|
-
* @param {{
|
|
41
|
-
* cwd?: string,
|
|
42
|
-
* log?: (m: string) => void,
|
|
43
|
-
* writeFile?: (p: string, c: string, enc: string) => void,
|
|
44
|
-
* exists?: (p: string) => boolean,
|
|
45
|
-
* mkdir?: (p: string, opts?: object) => void,
|
|
46
|
-
* now?: () => string,
|
|
47
|
-
* readFile?: (p: string, enc: string) => string
|
|
48
|
-
* }} [deps] ін'єкції
|
|
49
|
-
* @returns {Promise<number>} exit code
|
|
50
|
-
*/
|
|
51
|
-
export async function cmdInit(args, deps = {}) {
|
|
52
|
-
const root = deps.cwd ?? processCwd()
|
|
53
|
-
const log = deps.log ?? console.log
|
|
54
|
-
const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc))
|
|
55
|
-
const exists = deps.exists ?? existsSync
|
|
56
|
-
const mkdir = deps.mkdir ?? ((p, opts) => mkdirSync(p, opts))
|
|
57
|
-
const nowFn = deps.now ?? (() => new Date().toISOString())
|
|
58
|
-
|
|
59
|
-
const [name] = args
|
|
60
|
-
if (!name) {
|
|
61
|
-
log('Usage: n-cursor graph init <name>')
|
|
62
|
-
log(' name може містити / для вкладених вузлів (напр. "research/collect-data")')
|
|
63
|
-
return 1
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const config = loadConfig({ root, readFile: deps.readFile, exists })
|
|
67
|
-
const tasksDir = resolveTasksDir(config, root)
|
|
68
|
-
|
|
69
|
-
const nodeDir = join(tasksDir, name)
|
|
70
|
-
const taskPath = join(nodeDir, 'task.md')
|
|
71
|
-
|
|
72
|
-
if (exists(taskPath)) {
|
|
73
|
-
log(`init: ${taskPath} вже існує — пропускаємо`)
|
|
74
|
-
return 0
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Створюємо директорію рекурсивно
|
|
78
|
-
try {
|
|
79
|
-
mkdir(nodeDir, { recursive: true })
|
|
80
|
-
} catch (err) {
|
|
81
|
-
log(`init: не вдалося створити директорію ${nodeDir} — ${err.message ?? String(err)}`)
|
|
82
|
-
return 1
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const fm = buildTaskFrontMatter({ now: nowFn(), name })
|
|
86
|
-
const body = [
|
|
87
|
-
`## Mission`,
|
|
88
|
-
``,
|
|
89
|
-
`<!-- Опишіть завдання вузла тут -->`,
|
|
90
|
-
``,
|
|
91
|
-
`## Done when`,
|
|
92
|
-
``,
|
|
93
|
-
`<!-- Критерії успіху -->`,
|
|
94
|
-
``,
|
|
95
|
-
`## Context`,
|
|
96
|
-
``,
|
|
97
|
-
`<!-- Додатковий контекст для виконавця -->`,
|
|
98
|
-
``
|
|
99
|
-
].join('\n')
|
|
100
|
-
|
|
101
|
-
const content = buildMarkdown(fm, body)
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
writeFile(taskPath, content, 'utf8')
|
|
105
|
-
log(`init: створено ${taskPath}`)
|
|
106
|
-
} catch (err) {
|
|
107
|
-
log(`init: не вдалося записати ${taskPath} — ${err.message ?? String(err)}`)
|
|
108
|
-
return 1
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return 0
|
|
112
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `n-cursor graph invalidate <path> [--no-cascade]` — позначає вузол як invalidated.
|
|
3
|
-
*
|
|
4
|
-
* Записує порожній файл `invalidated` у директорію вузла.
|
|
5
|
-
* За замовчуванням каскадно інвалідує всі залежні вузли.
|
|
6
|
-
* --no-cascade — лише поточний вузол.
|
|
7
|
-
*
|
|
8
|
-
* FS ін'єктується для тестованості.
|
|
9
|
-
*/
|
|
10
|
-
import { execSync } from 'node:child_process'
|
|
11
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
-
import { join } from 'node:path'
|
|
13
|
-
import { cwd as processCwd } from 'node:process'
|
|
14
|
-
|
|
15
|
-
import { loadConfig, resolveTasksDir } from './config.mjs'
|
|
16
|
-
import { scanNodes } from './scanner.mjs'
|
|
17
|
-
import { listActiveWorktrees } from './worktree-ops.mjs'
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* `graph invalidate <path> [--no-cascade]` command handler.
|
|
21
|
-
* @param {string[]} args аргументи
|
|
22
|
-
* @param {{
|
|
23
|
-
* cwd?: string,
|
|
24
|
-
* log?: (m: string) => void,
|
|
25
|
-
* readFile?: (p: string, enc: string) => string,
|
|
26
|
-
* writeFile?: (p: string, c: string, enc: string) => void,
|
|
27
|
-
* readdir?: (d: string) => string[],
|
|
28
|
-
* exists?: (p: string) => boolean,
|
|
29
|
-
* execSync?: (cmd: string, opts?: object) => string
|
|
30
|
-
* }} [deps] ін'єкції
|
|
31
|
-
* @returns {Promise<number>} exit code
|
|
32
|
-
*/
|
|
33
|
-
export async function cmdInvalidate(args, deps = {}) {
|
|
34
|
-
const root = deps.cwd ?? processCwd()
|
|
35
|
-
const log = deps.log ?? console.log
|
|
36
|
-
const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
|
|
37
|
-
const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc))
|
|
38
|
-
const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
|
|
39
|
-
const exists = deps.exists ?? existsSync
|
|
40
|
-
const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' }))
|
|
41
|
-
|
|
42
|
-
let nodePath = null
|
|
43
|
-
let noCascade = false
|
|
44
|
-
|
|
45
|
-
for (const arg of args) {
|
|
46
|
-
if (arg === '--no-cascade') noCascade = true
|
|
47
|
-
else if (!arg.startsWith('-')) nodePath = arg
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (!nodePath) {
|
|
51
|
-
log('Usage: n-cursor graph invalidate <path> [--no-cascade]')
|
|
52
|
-
return 1
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const config = loadConfig({ root, readFile, exists })
|
|
56
|
-
const tasksDir = resolveTasksDir(config, root)
|
|
57
|
-
const nodeDir = join(tasksDir, nodePath)
|
|
58
|
-
|
|
59
|
-
if (!exists(join(nodeDir, 'task.md'))) {
|
|
60
|
-
log(`invalidate: вузол "${nodePath}" не знайдено`)
|
|
61
|
-
return 1
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Записуємо invalidated sentinel
|
|
65
|
-
try {
|
|
66
|
-
writeFile(join(nodeDir, 'invalidated'), '', 'utf8')
|
|
67
|
-
log(`invalidate: вузол "${nodePath}" інвалідовано`)
|
|
68
|
-
} catch (err) {
|
|
69
|
-
log(`invalidate: не вдалося записати invalidated — ${err.message ?? String(err)}`)
|
|
70
|
-
return 1
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (noCascade) return 0
|
|
74
|
-
|
|
75
|
-
// Каскадна інвалідація
|
|
76
|
-
const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn })
|
|
77
|
-
const allNodes = scanNodes(tasksDir, activeWorktrees, {
|
|
78
|
-
readdirSync: readdir,
|
|
79
|
-
existsSync: exists,
|
|
80
|
-
readFileSync: readFile
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
const dependents = allNodes.filter(n => n.deps.includes(nodePath))
|
|
84
|
-
for (const dep of dependents) {
|
|
85
|
-
if (!exists(join(dep.dir, 'invalidated'))) {
|
|
86
|
-
try {
|
|
87
|
-
writeFile(join(dep.dir, 'invalidated'), '', 'utf8')
|
|
88
|
-
log(`invalidate: каскадна інвалідація "${dep.path}"`)
|
|
89
|
-
} catch {
|
|
90
|
-
// пропускаємо
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return 0
|
|
96
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `n-cursor graph kill <path>` — вбиває worktree вузла і каскадно інвалідує нащадків.
|
|
3
|
-
*
|
|
4
|
-
* 1. Знаходить worktree вузла
|
|
5
|
-
* 2. Видаляє worktree (force)
|
|
6
|
-
* 3. Видаляє plan_*.md (скидає планування)
|
|
7
|
-
* 4. Записує invalidated sentinel
|
|
8
|
-
* 5. Каскадно інвалідує всі залежні вузли
|
|
9
|
-
*
|
|
10
|
-
* FS і child_process ін'єктуються для тестованості.
|
|
11
|
-
*/
|
|
12
|
-
import { execSync } from 'node:child_process'
|
|
13
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync, unlinkSync } 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 { findNodeWorktree, listActiveWorktrees, removeWorktree } from './worktree-ops.mjs'
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Записує invalidated sentinel для вузла.
|
|
23
|
-
* @param {string} nodeDir директорія вузла
|
|
24
|
-
* @param {(p: string, c: string, enc: string) => void} writeFile функція запису
|
|
25
|
-
*/
|
|
26
|
-
function writeInvalidated(nodeDir, writeFile) {
|
|
27
|
-
writeFile(join(nodeDir, 'invalidated'), '', 'utf8')
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Видаляє plan_*.md файли з директорії вузла.
|
|
32
|
-
* @param {string} nodeDir директорія вузла
|
|
33
|
-
* @param {string[]} files список файлів
|
|
34
|
-
* @param {(p: string) => void} unlink функція видалення
|
|
35
|
-
*/
|
|
36
|
-
function deletePlanFiles(nodeDir, files, unlink) {
|
|
37
|
-
for (const f of files) {
|
|
38
|
-
if (/^plan_\d+\.md$/.test(f)) {
|
|
39
|
-
try {
|
|
40
|
-
unlink(join(nodeDir, f))
|
|
41
|
-
} catch {
|
|
42
|
-
// пропускаємо
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* `graph kill <path>` command handler.
|
|
50
|
-
* @param {string[]} args аргументи: [path]
|
|
51
|
-
* @param {{
|
|
52
|
-
* cwd?: string,
|
|
53
|
-
* log?: (m: string) => void,
|
|
54
|
-
* readFile?: (p: string, enc: string) => string,
|
|
55
|
-
* writeFile?: (p: string, c: string, enc: string) => void,
|
|
56
|
-
* readdir?: (d: string) => string[],
|
|
57
|
-
* exists?: (p: string) => boolean,
|
|
58
|
-
* unlink?: (p: string) => void,
|
|
59
|
-
* execSync?: (cmd: string, opts?: object) => string
|
|
60
|
-
* }} [deps] ін'єкції
|
|
61
|
-
* @returns {Promise<number>} exit code
|
|
62
|
-
*/
|
|
63
|
-
export async function cmdKill(args, deps = {}) {
|
|
64
|
-
const root = deps.cwd ?? processCwd()
|
|
65
|
-
const log = deps.log ?? console.log
|
|
66
|
-
const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
|
|
67
|
-
const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc))
|
|
68
|
-
const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
|
|
69
|
-
const exists = deps.exists ?? existsSync
|
|
70
|
-
const unlink = deps.unlink ?? unlinkSync
|
|
71
|
-
const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' }))
|
|
72
|
-
|
|
73
|
-
const [nodePath] = args
|
|
74
|
-
if (!nodePath) {
|
|
75
|
-
log('Usage: n-cursor graph kill <path>')
|
|
76
|
-
return 1
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const config = loadConfig({ root, readFile, exists })
|
|
80
|
-
const tasksDir = resolveTasksDir(config, root)
|
|
81
|
-
const worktreesDir = resolveWorktreesDir(config, root)
|
|
82
|
-
|
|
83
|
-
const nodeDir = join(tasksDir, nodePath)
|
|
84
|
-
if (!exists(join(nodeDir, 'task.md'))) {
|
|
85
|
-
log(`kill: вузол "${nodePath}" не знайдено`)
|
|
86
|
-
return 1
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 1. Знаходимо і видаляємо worktree
|
|
90
|
-
const worktreePath = findNodeWorktree(nodePath, worktreesDir, {
|
|
91
|
-
readdirSync: readdir,
|
|
92
|
-
execSync: execSyncFn
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
if (worktreePath) {
|
|
96
|
-
log(`kill: видаляємо worktree ${worktreePath}`)
|
|
97
|
-
removeWorktree(worktreePath, root, { execSync: execSyncFn })
|
|
98
|
-
} else {
|
|
99
|
-
log(`kill: worktree не знайдено для "${nodePath}"`)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// 2. Видаляємо plan_*.md
|
|
103
|
-
const files = readdir(nodeDir)
|
|
104
|
-
deletePlanFiles(nodeDir, files, unlink)
|
|
105
|
-
const planCount = files.filter(f => /^plan_\d+\.md$/.test(f)).length
|
|
106
|
-
if (planCount > 0) {
|
|
107
|
-
log(`kill: видалено ${planCount} plan_*.md файл(ів)`)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// 3. Записуємо invalidated sentinel
|
|
111
|
-
try {
|
|
112
|
-
writeInvalidated(nodeDir, writeFile)
|
|
113
|
-
log(`kill: вузол "${nodePath}" інвалідовано`)
|
|
114
|
-
} catch (err) {
|
|
115
|
-
log(`kill: не вдалося записати invalidated — ${err.message ?? String(err)}`)
|
|
116
|
-
return 1
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// 4. Каскадна інвалідація залежних вузлів
|
|
120
|
-
const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn })
|
|
121
|
-
const allNodes = scanNodes(tasksDir, activeWorktrees, {
|
|
122
|
-
readdirSync: readdir,
|
|
123
|
-
existsSync: exists,
|
|
124
|
-
readFileSync: readFile
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
// Знаходимо вузли що залежать від нашого вузла
|
|
128
|
-
const dependents = allNodes.filter(n => n.deps.includes(nodePath))
|
|
129
|
-
for (const dep of dependents) {
|
|
130
|
-
if (!exists(join(dep.dir, 'invalidated'))) {
|
|
131
|
-
try {
|
|
132
|
-
writeInvalidated(dep.dir, writeFile)
|
|
133
|
-
log(`kill: каскадна інвалідація "${dep.path}"`)
|
|
134
|
-
} catch {
|
|
135
|
-
// пропускаємо
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return 0
|
|
141
|
-
}
|