@nitra/cursor 4.1.1 → 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 +20 -0
- package/bin/docs/n-cursor.md +1 -9
- package/bin/n-cursor.js +3 -25
- package/docs/stryker.config.md +37 -0
- package/docs/vitest.config.md +23 -0
- package/package.json +2 -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/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
package/scripts/graph/index.mjs
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `n-cursor graph` — CLI entry point для нової graph-архітектури.
|
|
3
|
-
* Реалізує команди з docs/думка.MD (фінальний дизайн 2026-06-07).
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @param {string[]} argv процесні аргументи після 'graph'
|
|
8
|
-
* @returns {Promise<number>} exit code
|
|
9
|
-
*/
|
|
10
|
-
export async function runGraphCli(argv) {
|
|
11
|
-
const [cmd, ...rest] = argv
|
|
12
|
-
|
|
13
|
-
const hasJson = rest.includes('--json')
|
|
14
|
-
const cleanRest = rest.filter(a => a !== '--json')
|
|
15
|
-
|
|
16
|
-
switch (cmd) {
|
|
17
|
-
case 'scan':
|
|
18
|
-
return (await import('./scan.mjs')).runScan(cleanRest, { json: hasJson })
|
|
19
|
-
|
|
20
|
-
case 'status': {
|
|
21
|
-
const path = cleanRest[0]
|
|
22
|
-
return (await import('./status.mjs')).runStatus(path, { json: hasJson })
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
case 'setup':
|
|
26
|
-
return (await import('./setup.mjs')).runSetup(cleanRest)
|
|
27
|
-
|
|
28
|
-
case 'init': {
|
|
29
|
-
const [name, ...initRest] = cleanRest
|
|
30
|
-
return (await import('./init.mjs')).runInit(name, parseFlags(initRest))
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
case 'plan': {
|
|
34
|
-
const flags = parseFlags(cleanRest)
|
|
35
|
-
return (await import('./plan.mjs')).runPlan(flags.path, flags)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
case 'run': {
|
|
39
|
-
const flags = parseFlags(cleanRest)
|
|
40
|
-
return (await import('./run.mjs')).runRun(flags.path, flags)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
case 'kill': {
|
|
44
|
-
const path = cleanRest[0]
|
|
45
|
-
return (await import('./kill.mjs')).runKill(path)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
case 'invalidate': {
|
|
49
|
-
const flags = parseFlags(cleanRest)
|
|
50
|
-
return (await import('./invalidate.mjs')).runInvalidate(flags.path, flags)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
case 'done':
|
|
54
|
-
case 'audit':
|
|
55
|
-
case 'failed':
|
|
56
|
-
case 'spawn': {
|
|
57
|
-
const path = cleanRest[0]
|
|
58
|
-
return (await import('./signals.mjs')).runSignal(cmd, path, parseFlags(cleanRest.slice(1)))
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
case undefined:
|
|
62
|
-
case '--help':
|
|
63
|
-
case 'help':
|
|
64
|
-
printHelp()
|
|
65
|
-
return 0
|
|
66
|
-
|
|
67
|
-
default:
|
|
68
|
-
console.error(`Unknown graph command: ${cmd}`)
|
|
69
|
-
printHelp()
|
|
70
|
-
return 1
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** @param {string[]} args @returns {Record<string, string | boolean>} */
|
|
75
|
-
function parseFlags(args) {
|
|
76
|
-
/** @type {Record<string, string | boolean>} */
|
|
77
|
-
const flags = {}
|
|
78
|
-
for (let i = 0; i < args.length; i++) {
|
|
79
|
-
const a = args[i]
|
|
80
|
-
if (a.startsWith('--')) {
|
|
81
|
-
const key = a.slice(2)
|
|
82
|
-
const next = args[i + 1]
|
|
83
|
-
if (next && !next.startsWith('--')) {
|
|
84
|
-
flags[key] = next
|
|
85
|
-
i++
|
|
86
|
-
} else {
|
|
87
|
-
flags[key] = true
|
|
88
|
-
}
|
|
89
|
-
} else if (!flags.path) {
|
|
90
|
-
flags.path = a
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return flags
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function printHelp() {
|
|
97
|
-
console.log(`n-cursor graph <command> [options]
|
|
98
|
-
|
|
99
|
-
Commands:
|
|
100
|
-
setup Initialize project (.n-cursor.json, hooks)
|
|
101
|
-
init <name> [--task "..."] Create task.md for a new node
|
|
102
|
-
plan [<path>] [--mode agent] Stage 1: spec + decompose → plan_NNN.md
|
|
103
|
-
status [<path>] [--json] Show graph or node state
|
|
104
|
-
scan [--json] Full scan; exit 1 if any failed nodes
|
|
105
|
-
run [<path>] [--actor a] [--auto] Execute node or run orchestrator
|
|
106
|
-
kill <path> Kill worktrees + cascade invalidate
|
|
107
|
-
invalidate <path> [--no-cascade] Mark node as invalidated
|
|
108
|
-
|
|
109
|
-
Agent signals (called from within worktree):
|
|
110
|
-
done <path> Signal success → merge
|
|
111
|
-
audit <path> Request audit → pending-audit_NNN.md
|
|
112
|
-
failed <path> Signal failure
|
|
113
|
-
spawn <path> Register composite subgraph
|
|
114
|
-
`)
|
|
115
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Завантаження `.n-cursor.json` + per-node `.n-cursor-override.json`.
|
|
3
|
-
* Всі поля опціональні — повертає merge із дефолтами.
|
|
4
|
-
*/
|
|
5
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
-
import { join } from 'node:path'
|
|
7
|
-
|
|
8
|
-
/** @typedef {{ tasks_dir: string, worktrees_dir: string, warn_worktrees_above: number, max_worktrees: number, default_budget_sec: number, budget_hard_sec_multiplier: number, progress_timeout_sec: number, stderr_lines: number, claude_model: string, audit_model: string, model_map: Record<string,string>, stale_worktree_min: number, system_prompt: string }} GraphConfig */
|
|
9
|
-
|
|
10
|
-
/** @type {GraphConfig} */
|
|
11
|
-
const DEFAULTS = {
|
|
12
|
-
tasks_dir: './tasks',
|
|
13
|
-
worktrees_dir: './.worktrees',
|
|
14
|
-
warn_worktrees_above: 4,
|
|
15
|
-
max_worktrees: 8,
|
|
16
|
-
default_budget_sec: 1800,
|
|
17
|
-
budget_hard_sec_multiplier: 3,
|
|
18
|
-
progress_timeout_sec: 300,
|
|
19
|
-
stderr_lines: 50,
|
|
20
|
-
claude_model: 'claude-sonnet-4-6',
|
|
21
|
-
audit_model: 'claude-haiku-4-5-20251001',
|
|
22
|
-
model_map: {
|
|
23
|
-
MIM: 'claude-haiku-4-5-20251001',
|
|
24
|
-
AVG: 'claude-sonnet-4-6',
|
|
25
|
-
MAX: 'claude-opus-4-8',
|
|
26
|
-
},
|
|
27
|
-
stale_worktree_min: 30,
|
|
28
|
-
system_prompt: '.n-cursor/system-prompt.md',
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Читає `.n-cursor.json` з root та мержить із дефолтами.
|
|
33
|
-
* @param {string} root
|
|
34
|
-
* @returns {GraphConfig}
|
|
35
|
-
*/
|
|
36
|
-
export function loadConfig(root) {
|
|
37
|
-
const path = join(root, '.n-cursor.json')
|
|
38
|
-
if (!existsSync(path)) return { ...DEFAULTS }
|
|
39
|
-
try {
|
|
40
|
-
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
41
|
-
return { ...DEFAULTS, ...raw, model_map: { ...DEFAULTS.model_map, ...raw.model_map } }
|
|
42
|
-
} catch {
|
|
43
|
-
return { ...DEFAULTS }
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Читає per-node `.n-cursor-override.json` та мержить із базовим конфігом.
|
|
49
|
-
* @param {GraphConfig} base
|
|
50
|
-
* @param {string} nodePath абсолютний шлях до директорії вузла
|
|
51
|
-
* @returns {GraphConfig}
|
|
52
|
-
*/
|
|
53
|
-
export function loadNodeOverride(base, nodePath) {
|
|
54
|
-
const path = join(nodePath, '.n-cursor-override.json')
|
|
55
|
-
if (!existsSync(path)) return base
|
|
56
|
-
try {
|
|
57
|
-
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
58
|
-
return { ...base, ...raw }
|
|
59
|
-
} catch {
|
|
60
|
-
return base
|
|
61
|
-
}
|
|
62
|
-
}
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Побудова DAG з файлової структури `tasks/`.
|
|
3
|
-
* Читає task.md кожного вузла (один раз), будує граф в пам'яті.
|
|
4
|
-
* Deps satisfaction вираховується пакетно — не per-node.
|
|
5
|
-
*/
|
|
6
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
7
|
-
import { join, relative } from 'node:path'
|
|
8
|
-
|
|
9
|
-
import { parseFrontmatter } from './frontmatter.mjs'
|
|
10
|
-
import { deriveAtomicState, deriveCompositeState } from './state.mjs'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @typedef {{
|
|
14
|
-
* id: string,
|
|
15
|
-
* path: string,
|
|
16
|
-
* parentId: string | null,
|
|
17
|
-
* deps: string[],
|
|
18
|
-
* mode: string,
|
|
19
|
-
* executor: { type: string, model_tier: string, skills: string[] },
|
|
20
|
-
* budget_sec: number,
|
|
21
|
-
* isComposite: boolean,
|
|
22
|
-
* children: string[],
|
|
23
|
-
* state: import('./state.mjs').NodeState,
|
|
24
|
-
* meta: Record<string, unknown>
|
|
25
|
-
* }} GraphNode
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Сканує tasks_dir і будує повний DAG.
|
|
30
|
-
* @param {string} tasksDir абсолютний шлях
|
|
31
|
-
* @param {string} [worktreesDir]
|
|
32
|
-
* @returns {Map<string, GraphNode>}
|
|
33
|
-
*/
|
|
34
|
-
export function buildDag(tasksDir, worktreesDir = '') {
|
|
35
|
-
/** @type {Map<string, GraphNode>} */
|
|
36
|
-
const nodes = new Map()
|
|
37
|
-
|
|
38
|
-
// 1. Collect all nodes
|
|
39
|
-
collectNodes(tasksDir, tasksDir, null, nodes)
|
|
40
|
-
|
|
41
|
-
// 2. Resolve parent-child relationships
|
|
42
|
-
for (const node of nodes.values()) {
|
|
43
|
-
if (node.parentId) {
|
|
44
|
-
const parent = nodes.get(node.parentId)
|
|
45
|
-
if (parent && !parent.children.includes(node.id)) {
|
|
46
|
-
parent.children.push(node.id)
|
|
47
|
-
parent.isComposite = true
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// 3. Derive states (bottom-up)
|
|
53
|
-
const resolvedIds = new Set()
|
|
54
|
-
deriveStatesBottomUp(tasksDir, nodes, resolvedIds, worktreesDir)
|
|
55
|
-
|
|
56
|
-
return nodes
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* @param {string} dir поточна директорія
|
|
61
|
-
* @param {string} tasksDir корінь tasks
|
|
62
|
-
* @param {string | null} parentId
|
|
63
|
-
* @param {Map<string, GraphNode>} nodes
|
|
64
|
-
*/
|
|
65
|
-
function collectNodes(dir, tasksDir, parentId, nodes) {
|
|
66
|
-
const taskFile = join(dir, 'task.md')
|
|
67
|
-
if (!existsSync(taskFile)) return
|
|
68
|
-
|
|
69
|
-
const id = relative(tasksDir, dir).replace(/\\/gu, '/')
|
|
70
|
-
if (!id) return
|
|
71
|
-
|
|
72
|
-
const { data } = parseFrontmatter(readSafe(taskFile))
|
|
73
|
-
|
|
74
|
-
/** @type {string[]} */
|
|
75
|
-
const deps = Array.isArray(data.deps) ? data.deps.map(String) : []
|
|
76
|
-
|
|
77
|
-
const executor = typeof data.executor === 'object' && data.executor !== null
|
|
78
|
-
? data.executor
|
|
79
|
-
: { type: 'agent', model_tier: 'AVG', skills: [] }
|
|
80
|
-
|
|
81
|
-
/** @type {GraphNode} */
|
|
82
|
-
const node = {
|
|
83
|
-
id,
|
|
84
|
-
path: dir,
|
|
85
|
-
parentId,
|
|
86
|
-
deps,
|
|
87
|
-
mode: String(data.mode ?? 'human'),
|
|
88
|
-
executor: {
|
|
89
|
-
type: String(executor.type ?? 'agent'),
|
|
90
|
-
model_tier: String(executor.model_tier ?? 'AVG'),
|
|
91
|
-
skills: Array.isArray(executor.skills) ? executor.skills : [],
|
|
92
|
-
},
|
|
93
|
-
budget_sec: Number(data.budget_sec ?? 1800),
|
|
94
|
-
isComposite: false,
|
|
95
|
-
children: [],
|
|
96
|
-
state: 'needs-plan',
|
|
97
|
-
meta: data,
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
nodes.set(id, node)
|
|
101
|
-
|
|
102
|
-
// Scan children
|
|
103
|
-
try {
|
|
104
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
105
|
-
if (entry.isDirectory()) {
|
|
106
|
-
collectNodes(join(dir, entry.name), tasksDir, id, nodes)
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
} catch { /* skip */ }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Обходить граф знизу вверх (листи → корінь) і деривує стани.
|
|
114
|
-
* @param {string} tasksDir
|
|
115
|
-
* @param {Map<string, GraphNode>} nodes
|
|
116
|
-
* @param {Set<string>} resolvedIds
|
|
117
|
-
* @param {string} worktreesDir
|
|
118
|
-
*/
|
|
119
|
-
function deriveStatesBottomUp(tasksDir, nodes, resolvedIds, worktreesDir) {
|
|
120
|
-
// Topological sort (Kahn's algorithm by deps within siblings)
|
|
121
|
-
const visited = new Set()
|
|
122
|
-
const order = []
|
|
123
|
-
|
|
124
|
-
/** @param {string} id */
|
|
125
|
-
function visit(id) {
|
|
126
|
-
if (visited.has(id)) return
|
|
127
|
-
visited.add(id)
|
|
128
|
-
const node = nodes.get(id)
|
|
129
|
-
if (!node) return
|
|
130
|
-
for (const child of node.children) visit(child)
|
|
131
|
-
order.push(id)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
for (const id of nodes.keys()) visit(id)
|
|
135
|
-
|
|
136
|
-
// Process leaves first (order is reversed)
|
|
137
|
-
for (const id of order) {
|
|
138
|
-
const node = nodes.get(id)
|
|
139
|
-
if (!node) continue
|
|
140
|
-
|
|
141
|
-
if (node.isComposite) {
|
|
142
|
-
const childStates = node.children.map(cid => nodes.get(cid)?.state ?? 'needs-plan')
|
|
143
|
-
node.state = deriveCompositeState(node.path, childStates)
|
|
144
|
-
} else {
|
|
145
|
-
const depsResolved = node.deps.every(dep => {
|
|
146
|
-
// Deps are sibling IDs — resolve relative to parent
|
|
147
|
-
const siblingId = node.parentId ? `${node.parentId}/${dep}` : dep
|
|
148
|
-
const sibling = nodes.get(siblingId) ?? nodes.get(dep)
|
|
149
|
-
return sibling?.state === 'resolved'
|
|
150
|
-
})
|
|
151
|
-
node.state = deriveAtomicState(node.path, { depsResolved })
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (node.state === 'resolved') resolvedIds.add(id)
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** @param {string} path @returns {string} */
|
|
159
|
-
function readSafe(path) {
|
|
160
|
-
try { return readFileSync(path, 'utf8') } catch { return '' }
|
|
161
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Мінімальний YAML-фронтматер парсер для Markdown-файлів.
|
|
3
|
-
* Підтримує: рядки, числа, boolean, масиви (flow і block), вкладені об'єкти (один рівень).
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---/u
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @param {string} content вміст md-файлу
|
|
10
|
-
* @returns {{ data: Record<string, unknown>, body: string }}
|
|
11
|
-
*/
|
|
12
|
-
export function parseFrontmatter(content) {
|
|
13
|
-
const m = FM_RE.exec(content)
|
|
14
|
-
if (!m) return { data: {}, body: content }
|
|
15
|
-
const raw = m[1]
|
|
16
|
-
const body = content.slice(m[0].length).trimStart()
|
|
17
|
-
return { data: parseYamlBlock(raw), body }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* @param {string} block YAML-блок між ---
|
|
22
|
-
* @returns {Record<string, unknown>}
|
|
23
|
-
*/
|
|
24
|
-
function parseYamlBlock(block) {
|
|
25
|
-
const result = {}
|
|
26
|
-
const lines = block.split(/\r?\n/u)
|
|
27
|
-
let i = 0
|
|
28
|
-
|
|
29
|
-
while (i < lines.length) {
|
|
30
|
-
const line = lines[i]
|
|
31
|
-
const keyMatch = /^([a-z_][a-z0-9_]*):\s*(.*)/iu.exec(line)
|
|
32
|
-
if (!keyMatch) { i++; continue }
|
|
33
|
-
|
|
34
|
-
const key = keyMatch[1]
|
|
35
|
-
const rest = keyMatch[2].trim()
|
|
36
|
-
|
|
37
|
-
if (rest === '' || rest === '|' || rest === '>') {
|
|
38
|
-
// block scalar або об'єкт — збираємо indented рядки
|
|
39
|
-
const children = []
|
|
40
|
-
i++
|
|
41
|
-
while (i < lines.length && /^\s+/u.test(lines[i])) {
|
|
42
|
-
children.push(lines[i])
|
|
43
|
-
i++
|
|
44
|
-
}
|
|
45
|
-
if (children.length > 0 && /^\s+-\s+/u.test(children[0])) {
|
|
46
|
-
result[key] = children.map(l => l.replace(/^\s+-\s+/u, '').trim())
|
|
47
|
-
} else {
|
|
48
|
-
result[key] = parseYamlBlock(children.map(l => l.replace(/^\s{2}/u, '')).join('\n'))
|
|
49
|
-
}
|
|
50
|
-
} else if (rest.startsWith('[')) {
|
|
51
|
-
result[key] = rest.slice(1, rest.lastIndexOf(']')).split(',').map(s => s.trim()).filter(Boolean)
|
|
52
|
-
i++
|
|
53
|
-
} else {
|
|
54
|
-
result[key] = parseScalar(rest)
|
|
55
|
-
i++
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return result
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** @param {string} s */
|
|
63
|
-
function parseScalar(s) {
|
|
64
|
-
if (s === 'true') return true
|
|
65
|
-
if (s === 'false') return false
|
|
66
|
-
if (s === 'null' || s === '~') return null
|
|
67
|
-
const n = Number(s)
|
|
68
|
-
if (!Number.isNaN(n) && s !== '') return n
|
|
69
|
-
return s.replace(/^["']|["']$/gu, '')
|
|
70
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NNN-лічильники — рахують існуючі файли певного патерну і повертають наступний NNN.
|
|
3
|
-
* Zero-padded до 3 цифр: 001, 002, …
|
|
4
|
-
*/
|
|
5
|
-
import { readdirSync } from 'node:fs'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* @param {string} dir абсолютний шлях до директорії вузла
|
|
9
|
-
* @param {RegExp} pattern регексп для імен файлів (напр. /^run_(\d{3})\.md$/)
|
|
10
|
-
* @returns {number} кількість файлів що підходять
|
|
11
|
-
*/
|
|
12
|
-
export function countFiles(dir, pattern) {
|
|
13
|
-
try {
|
|
14
|
-
return readdirSync(dir).filter(f => pattern.test(f)).length
|
|
15
|
-
} catch {
|
|
16
|
-
return 0
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* @param {string} dir
|
|
22
|
-
* @returns {string} наступний NNN для run_NNN.md (zero-padded)
|
|
23
|
-
*/
|
|
24
|
-
export function nextRunNNN(dir) {
|
|
25
|
-
const count = countFiles(dir, /^run_\d{3}\.md$/u)
|
|
26
|
-
return String(count + 1).padStart(3, '0')
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @param {string} dir
|
|
31
|
-
* @returns {string} наступний NNN для plan_NNN.md (zero-padded)
|
|
32
|
-
*/
|
|
33
|
-
export function nextPlanNNN(dir) {
|
|
34
|
-
const count = countFiles(dir, /^plan_\d{3}\.md$/u)
|
|
35
|
-
return String(count + 1).padStart(3, '0')
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Знаходить NNN останнього fact_NNN.md.
|
|
40
|
-
* @param {string} dir
|
|
41
|
-
* @returns {string | null}
|
|
42
|
-
*/
|
|
43
|
-
export function latestFactNNN(dir) {
|
|
44
|
-
try {
|
|
45
|
-
const files = readdirSync(dir)
|
|
46
|
-
.filter(f => /^fact_\d{3}\.md$/u.test(f))
|
|
47
|
-
.sort()
|
|
48
|
-
if (files.length === 0) return null
|
|
49
|
-
return files.at(-1).replace('fact_', '').replace('.md', '')
|
|
50
|
-
} catch {
|
|
51
|
-
return null
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Знаходить NNN pending-audit без відповідного audit-result.
|
|
57
|
-
* @param {string} dir
|
|
58
|
-
* @returns {string | null}
|
|
59
|
-
*/
|
|
60
|
-
export function pendingAuditNNN(dir) {
|
|
61
|
-
try {
|
|
62
|
-
const files = readdirSync(dir)
|
|
63
|
-
const pending = files.filter(f => /^pending-audit_\d{3}\.md$/u.test(f)).sort()
|
|
64
|
-
for (const p of pending) {
|
|
65
|
-
const nnn = p.replace('pending-audit_', '').replace('.md', '')
|
|
66
|
-
if (!files.includes(`audit-result_${nnn}.md`)) return nnn
|
|
67
|
-
}
|
|
68
|
-
return null
|
|
69
|
-
} catch {
|
|
70
|
-
return null
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** @param {number} n @returns {string} */
|
|
75
|
-
export function pad3(n) {
|
|
76
|
-
return String(n).padStart(3, '0')
|
|
77
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Визначення стану вузла за наявністю файлів (O(1), без читання вмісту).
|
|
3
|
-
* Пріоритет: invalidated > resolved > pending-audit > stalled > running > waiting/blocked > failed > needs-plan
|
|
4
|
-
*/
|
|
5
|
-
import { existsSync, readdirSync } from 'node:fs'
|
|
6
|
-
import { join } from 'node:path'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @typedef {'needs-plan'|'waiting'|'blocked'|'running'|'stalled'|'pending-audit'|'resolved'|'failed'|'invalidated'} NodeState
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Визначає стан атомарного вузла за файлами у директорії.
|
|
14
|
-
* @param {string} dir абсолютний шлях до директорії вузла
|
|
15
|
-
* @param {{ depsResolved: boolean }} opts
|
|
16
|
-
* @returns {NodeState}
|
|
17
|
-
*/
|
|
18
|
-
export function deriveAtomicState(dir, { depsResolved = true } = {}) {
|
|
19
|
-
if (existsSync(join(dir, 'invalidated'))) return 'invalidated'
|
|
20
|
-
|
|
21
|
-
const files = listFiles(dir)
|
|
22
|
-
|
|
23
|
-
if (hasFact(files) && !files.includes('invalidated')) return 'resolved'
|
|
24
|
-
|
|
25
|
-
const pendingNNN = findPendingAudit(files)
|
|
26
|
-
if (pendingNNN !== null) return 'pending-audit'
|
|
27
|
-
|
|
28
|
-
const runningUntil = findRunningUntil(files)
|
|
29
|
-
if (runningUntil !== null) {
|
|
30
|
-
const ts = Number(runningUntil)
|
|
31
|
-
const now = Math.floor(Date.now() / 1000)
|
|
32
|
-
return ts > now ? 'running' : 'stalled'
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const hasPlan = files.some(f => /^plan_\d{3}\.md$/u.test(f))
|
|
36
|
-
const hasRun = files.some(f => /^run_\d{3}\.md$/u.test(f))
|
|
37
|
-
|
|
38
|
-
if (!hasPlan) return 'needs-plan'
|
|
39
|
-
if (hasRun && !hasFact(files)) return 'failed'
|
|
40
|
-
if (hasPlan && !depsResolved) return 'blocked'
|
|
41
|
-
return 'waiting'
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Визначає стан composite вузла за станами дітей.
|
|
46
|
-
* @param {string} dir
|
|
47
|
-
* @param {NodeState[]} childStates
|
|
48
|
-
* @returns {NodeState}
|
|
49
|
-
*/
|
|
50
|
-
export function deriveCompositeState(dir, childStates) {
|
|
51
|
-
if (existsSync(join(dir, 'invalidated'))) return 'invalidated'
|
|
52
|
-
|
|
53
|
-
if (childStates.length === 0) return 'needs-plan'
|
|
54
|
-
if (childStates.every(s => s === 'resolved')) return 'resolved'
|
|
55
|
-
if (childStates.some(s => s === 'running' || s === 'pending-audit')) return 'running'
|
|
56
|
-
if (childStates.some(s => s === 'stalled')) return 'stalled'
|
|
57
|
-
if (childStates.some(s => s === 'failed') && !childStates.some(s => s === 'running')) return 'failed'
|
|
58
|
-
return 'waiting'
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Перевіряє наявність orphan worktree для вузла (resolved + worktree exists).
|
|
63
|
-
* @param {string} dir
|
|
64
|
-
* @param {string} worktreesDir
|
|
65
|
-
* @returns {boolean}
|
|
66
|
-
*/
|
|
67
|
-
export function hasOrphanWorktree(dir, worktreesDir) {
|
|
68
|
-
const nodeName = dir.split('/').at(-1)
|
|
69
|
-
try {
|
|
70
|
-
return readdirSync(worktreesDir).some(d => d.startsWith(nodeName))
|
|
71
|
-
} catch {
|
|
72
|
-
return false
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// --- helpers ---
|
|
77
|
-
|
|
78
|
-
/** @param {string} dir @returns {string[]} */
|
|
79
|
-
function listFiles(dir) {
|
|
80
|
-
try { return readdirSync(dir) } catch { return [] }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** @param {string[]} files @returns {boolean} */
|
|
84
|
-
function hasFact(files) {
|
|
85
|
-
return files.some(f => /^fact_\d{3}\.md$/u.test(f))
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Знаходить перший pending-audit_NNN без audit-result_NNN.
|
|
90
|
-
* @param {string[]} files
|
|
91
|
-
* @returns {string | null} NNN або null
|
|
92
|
-
*/
|
|
93
|
-
function findPendingAudit(files) {
|
|
94
|
-
const pending = files.filter(f => /^pending-audit_\d{3}\.md$/u.test(f)).sort()
|
|
95
|
-
for (const p of pending) {
|
|
96
|
-
const nnn = p.replace('pending-audit_', '').replace('.md', '')
|
|
97
|
-
if (!files.includes(`audit-result_${nnn}.md`)) return nnn
|
|
98
|
-
}
|
|
99
|
-
return null
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Знаходить `running_until_<ts>` файл і повертає ts як рядок.
|
|
104
|
-
* @param {string[]} files
|
|
105
|
-
* @returns {string | null}
|
|
106
|
-
*/
|
|
107
|
-
function findRunningUntil(files) {
|
|
108
|
-
const f = files.find(f => /^running_until_\d+$/u.test(f))
|
|
109
|
-
return f ? f.replace('running_until_', '') : null
|
|
110
|
-
}
|
package/scripts/graph/scan.mjs
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `graph scan` — повне сканування граф: відновлює стан з файлів, виводить результат.
|
|
3
|
-
* exit 0 — граф чистий; exit 1 — є вузли у стані failed.
|
|
4
|
-
*/
|
|
5
|
-
import { resolve } from 'node:path'
|
|
6
|
-
import { cwd } from 'node:process'
|
|
7
|
-
|
|
8
|
-
import { loadConfig } from './lib/config.mjs'
|
|
9
|
-
import { buildDag } from './lib/dag.mjs'
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* @param {string[]} args
|
|
13
|
-
* @param {{ json?: boolean }} opts
|
|
14
|
-
*/
|
|
15
|
-
export async function runScan(args, opts = {}) {
|
|
16
|
-
const root = cwd()
|
|
17
|
-
const config = loadConfig(root)
|
|
18
|
-
const tasksDir = resolve(root, config.tasks_dir)
|
|
19
|
-
const worktreesDir = resolve(root, config.worktrees_dir)
|
|
20
|
-
|
|
21
|
-
const nodes = buildDag(tasksDir, worktreesDir)
|
|
22
|
-
|
|
23
|
-
if (opts.json) {
|
|
24
|
-
const out = {}
|
|
25
|
-
for (const [id, node] of nodes) out[id] = { state: node.state, deps: node.deps }
|
|
26
|
-
process.stdout.write(JSON.stringify(out, null, 2) + '\n')
|
|
27
|
-
} else {
|
|
28
|
-
printScanTable(nodes)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const hasFailed = [...nodes.values()].some(n => n.state === 'failed')
|
|
32
|
-
return hasFailed ? 1 : 0
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** @param {Map<string, import('./lib/dag.mjs').GraphNode>} nodes */
|
|
36
|
-
function printScanTable(nodes) {
|
|
37
|
-
const STATE_ICON = {
|
|
38
|
-
'needs-plan': '⏳',
|
|
39
|
-
waiting: '○',
|
|
40
|
-
blocked: '○',
|
|
41
|
-
running: '◉',
|
|
42
|
-
stalled: '⚠',
|
|
43
|
-
'pending-audit': '🔍',
|
|
44
|
-
resolved: '✓',
|
|
45
|
-
failed: '✗',
|
|
46
|
-
invalidated: '⊘',
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const counts = {}
|
|
50
|
-
for (const { state } of nodes.values()) counts[state] = (counts[state] ?? 0) + 1
|
|
51
|
-
|
|
52
|
-
const summary = Object.entries(counts).map(([s, n]) => `${s}:${n}`).join(' ')
|
|
53
|
-
console.log(`graph — ${summary}\n`)
|
|
54
|
-
|
|
55
|
-
for (const [id, node] of nodes) {
|
|
56
|
-
const icon = STATE_ICON[node.state] ?? '?'
|
|
57
|
-
let detail = ''
|
|
58
|
-
if (node.state === 'needs-plan') detail = `run: graph plan tasks/${id}/`
|
|
59
|
-
else if (node.state === 'blocked') detail = `blocked: ${node.deps.join(', ')}`
|
|
60
|
-
else if (node.state === 'stalled') detail = 'stalled — deadline passed'
|
|
61
|
-
const suffix = detail ? ` [${detail}]` : ''
|
|
62
|
-
console.log(` ${icon} ${id.padEnd(30)} [${node.state}]${suffix}`)
|
|
63
|
-
}
|
|
64
|
-
}
|