@nitra/cursor 1.41.1 → 2.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 +19 -0
- package/bin/n-cursor.js +17 -1
- package/package.json +1 -1
- package/rules/flow/fix.mjs +18 -0
- package/rules/flow/flow.mdc +51 -0
- package/rules/flow/meta.json +1 -0
- package/rules/js-lint/js/data/tooling/oxlint-canonical.json +1 -1
- package/scripts/dispatcher/index.mjs +48 -0
- package/scripts/dispatcher/lib/active.mjs +226 -0
- package/scripts/dispatcher/lib/budget.mjs +36 -0
- package/scripts/dispatcher/lib/capability.mjs +81 -0
- package/scripts/dispatcher/lib/commands.mjs +193 -0
- package/scripts/dispatcher/lib/events.mjs +67 -0
- package/scripts/dispatcher/lib/executor.mjs +102 -0
- package/scripts/dispatcher/lib/flow-lock.mjs +39 -0
- package/scripts/dispatcher/lib/planner.mjs +66 -0
- package/scripts/dispatcher/lib/reviewer.mjs +38 -0
- package/scripts/dispatcher/lib/snapshot.mjs +58 -0
- package/scripts/dispatcher/lib/state-store.mjs +173 -0
- package/scripts/dispatcher/lib/subagent-runner.mjs +120 -0
- package/scripts/dispatcher/trace.mjs +114 -0
- package/scripts/utils/with-lock.mjs +7 -3
- package/scripts/worktree-cli.mjs +12 -5
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-safe сховище runtime-стану `flow` (spec §4, §4.1).
|
|
3
|
+
*
|
|
4
|
+
* Локація — **sibling-файл** `.worktrees/<sanitized-branch>.flow.json` поруч із
|
|
5
|
+
* checkout (НЕ всередині нього: файл усередині worktree = untracked у feature-
|
|
6
|
+
* гілці й ризикує потрапити в `git add -A`). Деривація шляху: для checkout-
|
|
7
|
+
* директорії `.worktrees/feat-x` стан → `.worktrees/feat-x.flow.json`.
|
|
8
|
+
*
|
|
9
|
+
* Crash-safety (§4.1):
|
|
10
|
+
* - **atomic write**: temp на тому ж FS → `fsync` файла → `rename` (атомарна
|
|
11
|
+
* заміна; частковий запис неможливий);
|
|
12
|
+
* - **fail-closed на corruption**: нечитабельний/невалідний JSON або несумісний
|
|
13
|
+
* `schema_version` → throw (не стартуємо новий flow над зіпсованим станом).
|
|
14
|
+
*
|
|
15
|
+
* Усі шляхи — абсолютні (вимога `no-relative-fs-path`).
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
closeSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
fsyncSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
openSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
writeFileSync
|
|
27
|
+
} from 'node:fs'
|
|
28
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
29
|
+
import { randomBytes } from 'node:crypto'
|
|
30
|
+
import { pid } from 'node:process'
|
|
31
|
+
|
|
32
|
+
import { appendEvent } from './events.mjs'
|
|
33
|
+
|
|
34
|
+
export const SCHEMA_VERSION = 1
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Шлях sibling-файла стану для заданого checkout-каталогу worktree.
|
|
38
|
+
* @param {string} worktreeDir абсолютний шлях checkout (напр. `…/.worktrees/feat-x`)
|
|
39
|
+
* @returns {string} абсолютний шлях `…/.worktrees/feat-x.flow.json`
|
|
40
|
+
*/
|
|
41
|
+
export function flowStatePath(worktreeDir) {
|
|
42
|
+
if (!isAbsolute(worktreeDir)) {
|
|
43
|
+
throw new Error(`flowStatePath: очікується абсолютний шлях (отримано: ${worktreeDir})`)
|
|
44
|
+
}
|
|
45
|
+
return join(dirname(worktreeDir), `${basename(worktreeDir)}.flow.json`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* fsync файла за абсолютним шляхом (дані на диск до rename).
|
|
50
|
+
* @param {string} path абсолютний шлях
|
|
51
|
+
* @returns {void}
|
|
52
|
+
*/
|
|
53
|
+
function fsyncPath(path) {
|
|
54
|
+
const fd = openSync(path, 'r')
|
|
55
|
+
try {
|
|
56
|
+
fsyncSync(fd)
|
|
57
|
+
} finally {
|
|
58
|
+
closeSync(fd)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Атомарно записує стан: temp(той самий каталог)+fsync+rename. Додає
|
|
64
|
+
* `schema_version`. Повертає фактично записаний об'єкт.
|
|
65
|
+
* @param {string} statePath абсолютний шлях `.flow.json`
|
|
66
|
+
* @param {object} state стан без `schema_version`
|
|
67
|
+
* @returns {object} записаний об'єкт (зі `schema_version`)
|
|
68
|
+
*/
|
|
69
|
+
export function writeState(statePath, state) {
|
|
70
|
+
if (!isAbsolute(statePath)) {
|
|
71
|
+
throw new Error(`writeState: очікується абсолютний шлях (отримано: ${statePath})`)
|
|
72
|
+
}
|
|
73
|
+
const dir = dirname(statePath)
|
|
74
|
+
mkdirSync(dir, { recursive: true })
|
|
75
|
+
const payload = { schema_version: SCHEMA_VERSION, ...state }
|
|
76
|
+
const tmp = join(dir, `.${basename(statePath)}.${pid}.${randomBytes(6).toString('hex')}.tmp`)
|
|
77
|
+
writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
78
|
+
fsyncPath(tmp)
|
|
79
|
+
renameSync(tmp, statePath)
|
|
80
|
+
// best-effort fsync каталогу (durability rename). Не на всіх платформах
|
|
81
|
+
// (Windows кидає EISDIR/EPERM) — тому загорнуто й помилки ігноруємо.
|
|
82
|
+
try {
|
|
83
|
+
fsyncPath(dir)
|
|
84
|
+
} catch {
|
|
85
|
+
/* fsync каталогу недоступний на цій платформі — некритично */
|
|
86
|
+
}
|
|
87
|
+
return payload
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Читає стан. Відсутній файл → null. Пошкоджений JSON або несумісний
|
|
92
|
+
* `schema_version` → throw (**fail-closed**, §4.1.6).
|
|
93
|
+
* @param {string} statePath абсолютний шлях `.flow.json`
|
|
94
|
+
* @returns {object | null} стан або null, якщо файлу нема
|
|
95
|
+
*/
|
|
96
|
+
export function readState(statePath) {
|
|
97
|
+
if (!isAbsolute(statePath)) {
|
|
98
|
+
throw new Error(`readState: очікується абсолютний шлях (отримано: ${statePath})`)
|
|
99
|
+
}
|
|
100
|
+
if (!existsSync(statePath)) return null
|
|
101
|
+
const raw = readFileSync(statePath, 'utf8')
|
|
102
|
+
let parsed
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(raw)
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error(`readState: пошкоджений стан (невалідний JSON) у ${statePath} — fail-closed`)
|
|
107
|
+
}
|
|
108
|
+
if (typeof parsed !== 'object' || parsed === null || parsed.schema_version !== SCHEMA_VERSION) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`readState: несумісний або пошкоджений schema_version у ${statePath} ` +
|
|
111
|
+
`(очікується ${SCHEMA_VERSION}) — fail-closed`
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
return parsed
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Читає стан, застосовує `fn` і атомарно записує результат. Якщо файлу нема —
|
|
119
|
+
* `fn` отримує `{}`.
|
|
120
|
+
* @param {string} statePath абсолютний шлях `.flow.json`
|
|
121
|
+
* @param {(state: object) => object} fn трансформер стану
|
|
122
|
+
* @returns {object} записаний об'єкт
|
|
123
|
+
*/
|
|
124
|
+
export function updateState(statePath, fn) {
|
|
125
|
+
const current = readState(statePath)
|
|
126
|
+
return writeState(statePath, fn(current ?? {}))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Видаляє sibling-файл стану (cleanup при `worktree remove` / `flow cancel`).
|
|
131
|
+
* Ідемпотентно (відсутній файл — не помилка).
|
|
132
|
+
* @param {string} statePath абсолютний шлях `.flow.json`
|
|
133
|
+
* @returns {void}
|
|
134
|
+
*/
|
|
135
|
+
export function removeState(statePath) {
|
|
136
|
+
if (!isAbsolute(statePath)) {
|
|
137
|
+
throw new Error(`removeState: очікується абсолютний шлях (отримано: ${statePath})`)
|
|
138
|
+
}
|
|
139
|
+
rmSync(statePath, { force: true })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* WAL-перехід (§4.1.2): спершу дописує подію в журнал, ТОДІ атомарно змінює
|
|
144
|
+
* статус у snapshot. Якщо запис стану впаде — подія вже durable (журнал —
|
|
145
|
+
* джерело для reconcile при `resume`).
|
|
146
|
+
* @param {{ statePath: string, eventsPath: string }} paths шляхи стану й журналу
|
|
147
|
+
* @param {object} event подія переходу
|
|
148
|
+
* @param {(state: object) => object} stateFn трансформер стану
|
|
149
|
+
* @param {() => number} [now] фабрика часу (ms)
|
|
150
|
+
* @returns {object} записаний стан
|
|
151
|
+
*/
|
|
152
|
+
export function recordTransition({ statePath, eventsPath }, event, stateFn, now = Date.now) {
|
|
153
|
+
appendEvent(eventsPath, event, now)
|
|
154
|
+
return updateState(statePath, stateFn)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Прибирає всі runtime-sibling-и worktree: `.flow.json`, `.events.jsonl`,
|
|
159
|
+
* лок-каталог `.flow-lock-<branch>/`. Ідемпотентно. Викликається `flow cancel`
|
|
160
|
+
* і `worktree remove` (інакше sibling-и осиротіють — git їх не чистить).
|
|
161
|
+
* @param {string} worktreeDir абсолютний шлях checkout (`…/.worktrees/feat-x`)
|
|
162
|
+
* @returns {void}
|
|
163
|
+
*/
|
|
164
|
+
export function cleanupFlowSiblings(worktreeDir) {
|
|
165
|
+
if (!isAbsolute(worktreeDir)) {
|
|
166
|
+
throw new Error(`cleanupFlowSiblings: очікується абсолютний шлях (отримано: ${worktreeDir})`)
|
|
167
|
+
}
|
|
168
|
+
const base = basename(worktreeDir)
|
|
169
|
+
const dir = dirname(worktreeDir)
|
|
170
|
+
rmSync(join(dir, `${base}.flow.json`), { force: true })
|
|
171
|
+
rmSync(join(dir, `${base}.events.jsonl`), { force: true })
|
|
172
|
+
rmSync(join(dir, `.flow-lock-${base}`), { recursive: true, force: true })
|
|
173
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubagentRunner (spec §15.1) — абстракція спавну сфокусованого субагента для
|
|
3
|
+
* Активного Раннера (Ф3/Ф4). Backend обирається за доступністю:
|
|
4
|
+
* 1. `claude-agent-sdk` (програмний, потребує `ANTHROPIC_API_KEY`);
|
|
5
|
+
* 2. `claude -p` (CLI-auth користувача);
|
|
6
|
+
* 3. `cursor-agent -p` (CLI-auth).
|
|
7
|
+
* Нема жодного → throw (polyfill без runner-а не стартує, §2.2).
|
|
8
|
+
*
|
|
9
|
+
* pi.dev для inner-спавну НЕ використовується: у автономному режимі pi.dev —
|
|
10
|
+
* зовнішній драйвер, тож спавн ним внутрішніх субагентів = рекурсія (§9.1).
|
|
11
|
+
*
|
|
12
|
+
* Усі probe-залежності (`spawn`/`isInPath`/`canImportSdk`/`query`) ін'єктуються,
|
|
13
|
+
* щоб тестувати без реальних процесів і без SDK.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { env as processEnv } from 'node:process'
|
|
17
|
+
|
|
18
|
+
const NO_BACKEND =
|
|
19
|
+
'SubagentRunner: ні claude-agent-sdk (з ANTHROPIC_API_KEY), ні `claude`/`cursor-agent` у PATH — ' +
|
|
20
|
+
'субагентів спавнити нічим. Встанови CLI-runner або задай ANTHROPIC_API_KEY.'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Чи є бінарник у PATH (через `command -v`).
|
|
24
|
+
* @param {string} name ім'я виконуваного
|
|
25
|
+
* @param {typeof import('node:child_process').spawnSync} [spawn] ін'єкція для тестів
|
|
26
|
+
* @returns {boolean} true, якщо знайдено
|
|
27
|
+
*/
|
|
28
|
+
export function isBinaryInPath(name, spawn = spawnSync) {
|
|
29
|
+
const r = spawn('command', ['-v', name], { shell: true, encoding: 'utf8' })
|
|
30
|
+
return (r.status ?? 1) === 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Обирає backend субагентів за пріоритетом sdk > claude > cursor.
|
|
35
|
+
* @param {{ hasApiKey: boolean, canImportSdk: boolean, isInPath: (name: string) => boolean }} probes доступність
|
|
36
|
+
* @returns {'sdk' | 'claude' | 'cursor' | null} backend або null
|
|
37
|
+
*/
|
|
38
|
+
export function selectBackend({ hasApiKey, canImportSdk, isInPath }) {
|
|
39
|
+
if (hasApiKey && canImportSdk) return 'sdk'
|
|
40
|
+
if (isInPath('claude')) return 'claude'
|
|
41
|
+
if (isInPath('cursor-agent')) return 'cursor'
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* CLI-runner (`claude -p` / `cursor-agent -p`) — CLI-auth, без API key.
|
|
47
|
+
* @param {'claude' | 'cursor-agent'} bin виконуваний
|
|
48
|
+
* @param {{ spawn?: typeof import('node:child_process').spawnSync }} [deps] ін'єкція
|
|
49
|
+
* @returns {{ backend: string, runStep: (prompt: string, opts?: { cwd?: string }) => { ok: boolean, output: string } }} runner
|
|
50
|
+
*/
|
|
51
|
+
export function cliRunner(bin, deps = {}) {
|
|
52
|
+
const spawn = deps.spawn ?? spawnSync
|
|
53
|
+
return {
|
|
54
|
+
backend: bin,
|
|
55
|
+
runStep(prompt, { cwd } = {}) {
|
|
56
|
+
const r = spawn(bin, ['-p'], { input: prompt, cwd, encoding: 'utf8' })
|
|
57
|
+
return { ok: (r.status ?? 1) === 0, output: `${r.stdout ?? ''}${r.stderr ?? ''}` }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* SDK-runner (`claude-agent-sdk`). `query` ін'єктується; за замовчуванням —
|
|
64
|
+
* динамічний import (optional dependency).
|
|
65
|
+
* @param {{ query?: (input: object) => object }} [deps] ін'єкція (query повертає async-iterable повідомлень)
|
|
66
|
+
* @returns {{ backend: string, runStep: (prompt: string, opts?: { cwd?: string }) => Promise<{ ok: boolean, output: string }> }} runner
|
|
67
|
+
*/
|
|
68
|
+
export function sdkRunner(deps = {}) {
|
|
69
|
+
return {
|
|
70
|
+
backend: 'sdk',
|
|
71
|
+
async runStep(prompt, { cwd } = {}) {
|
|
72
|
+
let query = deps.query
|
|
73
|
+
if (!query) {
|
|
74
|
+
const mod = await import('@anthropic-ai/claude-agent-sdk')
|
|
75
|
+
query = mod.query
|
|
76
|
+
}
|
|
77
|
+
let output = ''
|
|
78
|
+
let ok = true
|
|
79
|
+
try {
|
|
80
|
+
for await (const msg of query({ prompt, options: { cwd, maxTurns: 20, allowedTools: ['Read', 'Edit', 'Bash'] } })) {
|
|
81
|
+
if (typeof msg?.text === 'string') output += msg.text
|
|
82
|
+
if (msg?.type === 'result') ok = msg.is_error !== true
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { ok: false, output: String(error?.message ?? error) }
|
|
86
|
+
}
|
|
87
|
+
return { ok, output }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Створює runner за доступним backend-ом. `backend`/probe-и можна задати явно
|
|
94
|
+
* (тести); інакше визначаються з env/PATH/SDK.
|
|
95
|
+
* @param {{ backend?: string, env?: Record<string, string | undefined>, isInPath?: (name: string) => boolean, canImportSdk?: boolean, spawn?: (cmd: string, args: string[], opts: object) => object, query?: (input: object) => object }} [deps] ін'єкції
|
|
96
|
+
* @returns {Promise<{ backend: string, runStep: (prompt: string, opts?: object) => object }>} runner
|
|
97
|
+
*/
|
|
98
|
+
export async function createRunner(deps = {}) {
|
|
99
|
+
const env = deps.env ?? processEnv
|
|
100
|
+
const isInPath = deps.isInPath ?? (name => isBinaryInPath(name, deps.spawn))
|
|
101
|
+
const canImportSdk = deps.canImportSdk ?? (await probeSdk())
|
|
102
|
+
const backend =
|
|
103
|
+
deps.backend ?? selectBackend({ hasApiKey: Boolean(env.ANTHROPIC_API_KEY), canImportSdk, isInPath })
|
|
104
|
+
if (!backend) throw new Error(NO_BACKEND)
|
|
105
|
+
if (backend === 'sdk') return sdkRunner(deps)
|
|
106
|
+
return cliRunner(backend === 'claude' ? 'claude' : 'cursor-agent', deps)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Чи імпортується `claude-agent-sdk` (optional dependency).
|
|
111
|
+
* @returns {Promise<boolean>} true, якщо доступний
|
|
112
|
+
*/
|
|
113
|
+
async function probeSdk() {
|
|
114
|
+
try {
|
|
115
|
+
await import('@anthropic-ai/claude-agent-sdk')
|
|
116
|
+
return true
|
|
117
|
+
} catch {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `n-cursor trace` (spec §5.4/§7) — наскрізна простежуваність: читає front-matter
|
|
3
|
+
* артефактів у `docs/{tasks,specs,plans,adr}`, будує ланцюг за лінками
|
|
4
|
+
* (`adr`/`spec`/`plan`/`change`/`task`) і **флагує розриви** (лінк на неіснуючий
|
|
5
|
+
* файл). Read-only. `--json` для machine-readable.
|
|
6
|
+
*
|
|
7
|
+
* FS-доступ (`readdir`/`readFile`/`exists`) ін'єктується — тестується без диска.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { cwd as processCwd } from 'node:process'
|
|
12
|
+
|
|
13
|
+
/** Поля-лінки у front-matter, що утворюють ланцюг. */
|
|
14
|
+
const LINK_FIELDS = ['adr', 'spec', 'plan', 'change', 'task']
|
|
15
|
+
|
|
16
|
+
/** Каталоги з traceable-артефактами. */
|
|
17
|
+
const DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Парсить плаский YAML-front-matter (key: value). Не обробляє вкладеність —
|
|
21
|
+
* достатньо для spec/plan/task-record полів. Інлайн-коментарі (` #…`) відрізає.
|
|
22
|
+
* @param {string} content вміст файла
|
|
23
|
+
* @returns {Record<string, string | null> | null} мапа полів або null, якщо немає front-matter
|
|
24
|
+
*/
|
|
25
|
+
export function parseFrontMatter(content) {
|
|
26
|
+
if (!content.startsWith('---')) return null
|
|
27
|
+
const end = content.indexOf('\n---', 3)
|
|
28
|
+
if (end === -1) return null
|
|
29
|
+
const fm = {}
|
|
30
|
+
for (const line of content.slice(3, end).split('\n')) {
|
|
31
|
+
const ci = line.indexOf(':')
|
|
32
|
+
if (ci === -1) continue
|
|
33
|
+
const key = line.slice(0, ci).trim()
|
|
34
|
+
if (!isSimpleKey(key)) continue
|
|
35
|
+
let val = line.slice(ci + 1)
|
|
36
|
+
const hi = val.indexOf(' #')
|
|
37
|
+
if (hi !== -1) val = val.slice(0, hi)
|
|
38
|
+
val = val.trim().replace(/^["']/u, '').replace(/["']$/u, '')
|
|
39
|
+
fm[key] = val === '' || val === 'null' ? null : val
|
|
40
|
+
}
|
|
41
|
+
return fm
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Чи `key` — простий ідентифікатор (літери/підкреслення).
|
|
46
|
+
* @param {string} key ключ
|
|
47
|
+
* @returns {boolean} true для простого ключа
|
|
48
|
+
*/
|
|
49
|
+
function isSimpleKey(key) {
|
|
50
|
+
return key.length > 0 && [...key].every(c => /[a-z_]/iu.test(c))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Будує аналіз: для кожного артефакту — його лінки зі статусом ok/розрив.
|
|
55
|
+
* @param {{ file: string, fm: Record<string, string | null> }[]} artifacts артефакти з front-matter
|
|
56
|
+
* @param {(target: string) => boolean} exists чи існує цільовий файл лінка
|
|
57
|
+
* @returns {{ file: string, kind: string | null, id: string | null, status: string | null, links: { field: string, target: string, ok: boolean }[] }[]} аналіз
|
|
58
|
+
*/
|
|
59
|
+
export function analyze(artifacts, exists) {
|
|
60
|
+
return artifacts.map(({ file, fm }) => ({
|
|
61
|
+
file,
|
|
62
|
+
kind: fm.kind ?? null,
|
|
63
|
+
id: fm.id ?? null,
|
|
64
|
+
status: fm.status ?? null,
|
|
65
|
+
links: LINK_FIELDS.filter(f => fm[f]).map(f => ({ field: f, target: fm[f], ok: exists(fm[f]) }))
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Текстовий рендер аналізу.
|
|
71
|
+
* @param {object[]} analysis результат `analyze`
|
|
72
|
+
* @returns {string} людино-читабельний вивід
|
|
73
|
+
*/
|
|
74
|
+
export function render(analysis) {
|
|
75
|
+
if (analysis.length === 0) return 'trace: артефактів із front-matter не знайдено'
|
|
76
|
+
const lines = []
|
|
77
|
+
for (const a of analysis) {
|
|
78
|
+
lines.push(`${a.kind ?? '?'} · ${a.id ?? a.file} [${a.status ?? '—'}]`)
|
|
79
|
+
for (const l of a.links) {
|
|
80
|
+
const mark = l.ok ? '→' : '✗'
|
|
81
|
+
const note = l.ok ? '' : ' (РОЗРИВ — файл відсутній)'
|
|
82
|
+
lines.push(` ${mark} ${l.field}: ${l.target}${note}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return lines.join('\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* CLI `n-cursor trace [--json]`. Повертає 1, якщо є розриви ланцюга.
|
|
90
|
+
* @param {string[]} args аргументи
|
|
91
|
+
* @param {{ cwd?: string, readdir?: (dir: string) => string[], readFile?: (file: string) => string, exists?: (file: string) => boolean, log?: (m: string) => void }} [deps] ін'єкції
|
|
92
|
+
* @returns {number} exit code (0 — цілісно, 1 — є розриви)
|
|
93
|
+
*/
|
|
94
|
+
export function runTraceCli(args, deps = {}) {
|
|
95
|
+
const root = deps.cwd ?? processCwd()
|
|
96
|
+
const readdir = deps.readdir ?? (dir => (existsSync(dir) ? readdirSync(dir) : []))
|
|
97
|
+
const readFile = deps.readFile ?? (file => readFileSync(file, 'utf8'))
|
|
98
|
+
const exists = deps.exists ?? (file => existsSync(file))
|
|
99
|
+
const log = deps.log ?? console.log
|
|
100
|
+
|
|
101
|
+
const artifacts = []
|
|
102
|
+
for (const dir of DIRS) {
|
|
103
|
+
for (const name of readdir(join(root, dir))) {
|
|
104
|
+
if (!name.endsWith('.md')) continue
|
|
105
|
+
const rel = `${dir}/${name}`
|
|
106
|
+
const fm = parseFrontMatter(readFile(join(root, rel)))
|
|
107
|
+
if (fm && (fm.id || fm.kind)) artifacts.push({ file: rel, fm })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const analysis = analyze(artifacts, target => exists(join(root, target)))
|
|
112
|
+
log(args.includes('--json') ? JSON.stringify(analysis, null, 2) : render(analysis))
|
|
113
|
+
return analysis.some(a => a.links.some(l => !l.ok)) ? 1 : 0
|
|
114
|
+
}
|
|
@@ -13,7 +13,8 @@ const DEFAULTS = {
|
|
|
13
13
|
ttl: 600_000,
|
|
14
14
|
staleThreshold: 1_800_000,
|
|
15
15
|
waitTimeout: 1_200_000,
|
|
16
|
-
pollInterval: 1500
|
|
16
|
+
pollInterval: 1500,
|
|
17
|
+
onWaitTimeout: 'run-unlocked'
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -57,11 +58,11 @@ export function shouldDedup(result, fingerprint, ttl) {
|
|
|
57
58
|
* Серіалізує важку команду через атомарний lock і dedup за fingerprint.
|
|
58
59
|
* @param {string} key ключ локу (наприклад `lint-ga`, `fix-bun`)
|
|
59
60
|
* @param {() => number | Promise<number>} runFn основна робота; повертає exit code
|
|
60
|
-
* @param {{ttl?:number, staleThreshold?:number, waitTimeout?:number, pollInterval?:number, cacheDir?:string, getFingerprint?:() => string | null}} [opts] TTL, шлях
|
|
61
|
+
* @param {{ttl?:number, staleThreshold?:number, waitTimeout?:number, pollInterval?:number, onWaitTimeout?:'run-unlocked'|'fail', cacheDir?:string, getFingerprint?:() => string | null}} [opts] TTL, шлях кешу, поведінка на таймаут (default `run-unlocked`; `fail` = fail-closed) та override fingerprint
|
|
61
62
|
* @returns {Promise<number>} exit code виконаної або дедуплікованої команди
|
|
62
63
|
*/
|
|
63
64
|
export async function withLock(key, runFn, opts = {}) {
|
|
64
|
-
const { ttl, staleThreshold, waitTimeout, pollInterval } = { ...DEFAULTS, ...opts }
|
|
65
|
+
const { ttl, staleThreshold, waitTimeout, pollInterval, onWaitTimeout } = { ...DEFAULTS, ...opts }
|
|
65
66
|
const getFingerprint = opts.getFingerprint ?? worktreeFingerprint
|
|
66
67
|
const cacheDir = opts.cacheDir ?? resolveLockCacheDir(key)
|
|
67
68
|
const lockDir = join(cacheDir, 'lock')
|
|
@@ -76,6 +77,9 @@ export async function withLock(key, runFn, opts = {}) {
|
|
|
76
77
|
|
|
77
78
|
while (true) {
|
|
78
79
|
if (Date.now() - loopStart >= waitTimeout) {
|
|
80
|
+
if (onWaitTimeout === 'fail') {
|
|
81
|
+
throw new Error(`${key}: не вдалося взяти лок за ${waitTimeout / 60_000} хв — fail-closed`)
|
|
82
|
+
}
|
|
79
83
|
console.error(`⚠️ ${key}: чекав ${waitTimeout / 60_000} хв — запускаю без локу`)
|
|
80
84
|
return await runFn()
|
|
81
85
|
}
|
package/scripts/worktree-cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'no
|
|
|
15
15
|
import { join } from 'node:path'
|
|
16
16
|
import { cwd as processCwd } from 'node:process'
|
|
17
17
|
|
|
18
|
+
import { cleanupFlowSiblings } from './dispatcher/lib/state-store.mjs'
|
|
18
19
|
import { buildDescription, findOrphanDescFiles, worktreePaths } from './lib/worktree.mjs'
|
|
19
20
|
|
|
20
21
|
const USAGE = [
|
|
@@ -136,6 +137,7 @@ function cmdRemove(rest, ctx) {
|
|
|
136
137
|
return 1
|
|
137
138
|
}
|
|
138
139
|
if (existsSync(paths.descFile)) rmSync(paths.descFile, { force: true })
|
|
140
|
+
cleanupFlowSiblings(paths.checkout) // flow-sibling-и (.flow.json/.events.jsonl/lock) — інакше осиротіють (§1.4)
|
|
139
141
|
ctx.log(`✅ прибрано: ${paths.checkout} (гілку ${branch} лишено)`)
|
|
140
142
|
return 0
|
|
141
143
|
}
|
|
@@ -185,16 +187,21 @@ export function runWorktreeCli(argv, options = {}) {
|
|
|
185
187
|
}
|
|
186
188
|
const [sub, ...rest] = argv
|
|
187
189
|
switch (sub) {
|
|
188
|
-
case 'add':
|
|
190
|
+
case 'add': {
|
|
189
191
|
return Promise.resolve(cmdAdd(rest, ctx))
|
|
190
|
-
|
|
192
|
+
}
|
|
193
|
+
case 'remove': {
|
|
191
194
|
return Promise.resolve(cmdRemove(rest, ctx))
|
|
192
|
-
|
|
195
|
+
}
|
|
196
|
+
case 'list': {
|
|
193
197
|
return Promise.resolve(cmdList(ctx))
|
|
194
|
-
|
|
198
|
+
}
|
|
199
|
+
case 'prune': {
|
|
195
200
|
return Promise.resolve(cmdPrune(ctx))
|
|
196
|
-
|
|
201
|
+
}
|
|
202
|
+
default: {
|
|
197
203
|
ctx.logError(USAGE)
|
|
198
204
|
return Promise.resolve(1)
|
|
205
|
+
}
|
|
199
206
|
}
|
|
200
207
|
}
|