@nitra/cursor 4.1.0 → 4.1.1
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 +25 -13
- package/lib/models.mjs +1 -2
- package/package.json +1 -1
- package/rules/abie/fix.mjs +1 -1
- package/rules/bun/docs/fix.md +3 -0
- package/rules/bun/fix.mjs +1 -1
- package/rules/capacitor/fix.mjs +1 -1
- package/rules/changelog/docs/fix.md +3 -0
- package/rules/changelog/fix.mjs +1 -1
- package/rules/ci4/fix.mjs +1 -1
- package/rules/ci4/js/docs/marksman_config.md +1 -0
- package/rules/docker/docs/fix.md +1 -1
- package/rules/docker/fix.mjs +1 -1
- package/rules/docker/lint/docs/lint.md +1 -0
- package/rules/efes/docs/fix.md +2 -1
- package/rules/efes/fix.mjs +1 -1
- package/rules/feedback/fix.mjs +1 -1
- package/rules/ga/fix.mjs +1 -1
- package/rules/ga/js/lint.mjs +1 -1
- package/rules/graphql/docs/fix.md +4 -1
- package/rules/graphql/fix.mjs +1 -1
- package/rules/graphql/lib/docs/graphql-gql-scan.md +3 -0
- package/rules/hasura/fix.mjs +1 -1
- package/rules/image-avif/docs/fix.md +4 -1
- package/rules/image-avif/fix.mjs +1 -1
- package/rules/image-avif/js/docs/avif_generation.md +1 -0
- package/rules/image-compress/fix.mjs +1 -1
- package/rules/js-bun-db/fix.mjs +1 -1
- package/rules/js-bun-db/lib/docs/bun-sql-scan.md +6 -0
- package/rules/js-bun-redis/fix.mjs +1 -1
- package/rules/js-lint/fix.mjs +1 -1
- package/rules/js-lint/js/docs/utils_imports.md +1 -0
- package/rules/js-lint-ci/docs/fix.md +4 -1
- package/rules/js-lint-ci/fix.mjs +1 -1
- package/rules/js-mssql/docs/fix.md +3 -0
- package/rules/js-mssql/fix.mjs +1 -1
- package/rules/js-mssql/lib/docs/mssql-pool-scan.md +9 -0
- package/rules/js-run/docs/fix.md +3 -0
- package/rules/js-run/fix.mjs +1 -1
- package/rules/js-run/lib/docs/check-env-scan.md +2 -1
- package/rules/js-run/lib/docs/promise-settimeout-scan.md +4 -0
- package/rules/k8s/docs/fix.md +3 -0
- package/rules/k8s/fix.mjs +1 -1
- package/rules/nginx-default-tpl/docs/fix.md +3 -0
- package/rules/nginx-default-tpl/fix.mjs +1 -1
- package/rules/npm-module/fix.mjs +1 -1
- package/rules/npm-module/js/header_doc_pointer.mjs +14 -3
- package/rules/php/docs/fix.md +2 -1
- package/rules/php/fix.mjs +1 -1
- package/rules/python/docs/fix.md +4 -1
- package/rules/python/fix.mjs +1 -1
- package/rules/rego/fix.mjs +1 -1
- package/rules/rego/js/lint.mjs +1 -1
- package/rules/release/docs/fix.md +4 -1
- package/rules/release/fix.mjs +1 -1
- package/rules/rust/fix.mjs +1 -1
- package/rules/security/docs/fix.md +1 -1
- package/rules/security/fix.mjs +1 -1
- package/rules/style-lint/docs/fix.md +3 -0
- package/rules/style-lint/fix.mjs +1 -1
- package/rules/tauri/docs/fix.md +4 -1
- package/rules/tauri/fix.mjs +1 -1
- package/rules/test/docs/fix.md +3 -0
- package/rules/test/fix.mjs +1 -1
- package/rules/test/js/no-relative-fs-path.mjs +2 -1
- package/rules/text/docs/fix.md +3 -0
- package/rules/text/fix.mjs +1 -1
- package/rules/text/js/lint.mjs +1 -1
- package/rules/vue/fix.mjs +1 -1
- package/rules/worktree/fix.mjs +1 -1
- package/scripts/auto-rules.mjs +1 -1
- package/scripts/coverage-classify/index.mjs +10 -10
- package/scripts/coverage-fix.mjs +2 -2
- package/scripts/dispatcher/graph/lib/cmd-init.mjs +112 -0
- package/scripts/dispatcher/graph/lib/cmd-invalidate.mjs +96 -0
- package/scripts/dispatcher/graph/lib/cmd-kill.mjs +141 -0
- package/scripts/dispatcher/graph/lib/cmd-plan.mjs +142 -0
- package/scripts/dispatcher/graph/lib/cmd-run.mjs +328 -0
- package/scripts/dispatcher/graph/lib/cmd-scan.mjs +115 -0
- package/scripts/dispatcher/graph/lib/cmd-setup.mjs +111 -0
- package/scripts/dispatcher/graph/lib/cmd-signals.mjs +328 -0
- package/scripts/dispatcher/graph/lib/cmd-status.mjs +131 -0
- package/scripts/dispatcher/graph/lib/cmd-verify.mjs +100 -0
- package/scripts/dispatcher/graph/lib/cmd-watch.mjs +128 -0
- package/scripts/dispatcher/graph/lib/config.mjs +103 -0
- package/scripts/dispatcher/graph/lib/frontmatter.mjs +224 -0
- package/scripts/dispatcher/graph/lib/nnn.mjs +127 -0
- package/scripts/dispatcher/graph/lib/node-state.mjs +157 -0
- package/scripts/dispatcher/graph/lib/scanner.mjs +235 -0
- package/scripts/dispatcher/graph/lib/worktree-ops.mjs +193 -0
- package/scripts/dispatcher/graph-tasks.mjs +92 -0
- package/scripts/dispatcher/index.mjs +3 -3
- package/scripts/dispatcher/lib/docs/events.md +1 -0
- package/scripts/dispatcher/lib/executor.mjs +1 -1
- package/scripts/dispatcher/lib/subagent-runner.mjs +9 -9
- package/scripts/dispatcher/trace.mjs +6 -2
- package/scripts/docs/build-agents-commands.md +1 -0
- package/scripts/docs/cli-entry.md +6 -0
- package/scripts/graph/index.mjs +115 -0
- package/scripts/graph/lib/config.mjs +62 -0
- package/scripts/graph/lib/dag.mjs +161 -0
- package/scripts/graph/lib/frontmatter.mjs +70 -0
- package/scripts/graph/lib/nnn.mjs +77 -0
- package/scripts/graph/lib/state.mjs +110 -0
- package/scripts/graph/scan.mjs +64 -0
- package/scripts/graph/status.mjs +86 -0
- package/scripts/lib/docs/load-cursor-config.md +3 -0
- package/scripts/lib/root-notice.mjs +4 -2
- package/scripts/lib/rule-predicates.mjs +1 -1
- package/scripts/lib/worktree-notice.mjs +14 -7
- package/scripts/lib/worktree.mjs +3 -2
- package/scripts/utils/resolve-js-root.mjs +2 -1
- package/scripts/utils/with-lock.mjs +1 -1
- package/skills/docgen/js/docgen-batch.mjs +7 -7
- package/skills/docgen/js/docgen-extract.mjs +80 -37
- package/skills/docgen/js/docgen-ignore.mjs +1 -1
- package/skills/docgen/js/docgen-prompts.mjs +21 -5
- package/skills/fix/js/llm-worker.mjs +19 -22
- package/skills/fix/js/orchestrator.mjs +6 -7
- package/skills/fix/js/t0.mjs +14 -13
- package/types/bin/n-cursor.d.ts +1 -1
- package/rules/flow/docs/fix.md +0 -152
- package/rules/flow/fix.mjs +0 -18
- package/rules/flow/flow.mdc +0 -127
- package/rules/flow/meta.json +0 -1
- package/scripts/dispatcher/lib/docs/flow-lock.md +0 -161
- package/scripts/dispatcher/lib/docs/flow-resolve.md +0 -267
- package/scripts/dispatcher/lib/flow-plan.mjs +0 -153
- package/scripts/dispatcher/lib/flow-resolve.mjs +0 -156
- package/scripts/dispatcher/lib/flow-signals.mjs +0 -235
- package/scripts/dispatcher/lib/flow-verify.mjs +0 -127
|
@@ -119,6 +119,7 @@ Default-експорт відсутній.
|
|
|
119
119
|
1. **Обчислення шляху журналу** — один раз на сесію/процес:
|
|
120
120
|
```js
|
|
121
121
|
import { flowEventsPath, appendEvent, readEvents } from './events.mjs'
|
|
122
|
+
|
|
122
123
|
const eventsPath = flowEventsPath('/repo/.worktrees/feat-x')
|
|
123
124
|
// → '/repo/.worktrees/feat-x.events.jsonl'
|
|
124
125
|
```
|
|
@@ -49,7 +49,7 @@ export function patchStep(state, index, patch) {
|
|
|
49
49
|
* @returns {Promise<{ status: 'done' | 'blocked-on-human', step?: number }>} результат
|
|
50
50
|
*/
|
|
51
51
|
export async function executePlan(paths, deps) {
|
|
52
|
-
const { runner, verify, commit, cwd, maxRepairAttempts = 3, log = () => {}, now = Date.now } = deps
|
|
52
|
+
const { runner, verify, commit, cwd, maxRepairAttempts = 3, log = () => { /* noop */ }, now = Date.now } = deps
|
|
53
53
|
let state = readState(paths.statePath)
|
|
54
54
|
if (!state?.plan?.length) {
|
|
55
55
|
throw new Error('executor: у стані немає плану — спершу planner')
|
|
@@ -14,10 +14,10 @@ import { resolveModel } from '../../../lib/models.mjs'
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Викликає pi і повертає { ok, output }.
|
|
17
|
-
* @param {string} prompt
|
|
17
|
+
* @param {string} prompt текст промпта
|
|
18
18
|
* @param {string} model provider/model-id або '' для pi-дефолту
|
|
19
|
-
* @param {{ cwd?: string }} [opts]
|
|
20
|
-
* @returns {{ ok: boolean, output: string }}
|
|
19
|
+
* @param {{ cwd?: string }} [opts] опційні параметри (cwd)
|
|
20
|
+
* @returns {{ ok: boolean, output: string }} результат із статусом і output
|
|
21
21
|
*/
|
|
22
22
|
function callPi(prompt, model, { cwd } = {}) {
|
|
23
23
|
const modelArgs = model ? ['--model', model] : []
|
|
@@ -27,26 +27,26 @@ function callPi(prompt, model, { cwd } = {}) {
|
|
|
27
27
|
timeout: 600_000
|
|
28
28
|
})
|
|
29
29
|
const ok = !r.error && r.status === 0
|
|
30
|
-
const output = (r.stdout ?? '') + (r.error ? r.error.message :
|
|
30
|
+
const output = (r.stdout ?? '') + (r.error ? r.error.message : (ok ? '' : (r.stderr ?? '')))
|
|
31
31
|
return { ok, output }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Створює pi-runner. Повертає { backend: 'pi', runStep }.
|
|
36
36
|
* @param {{ model?: string, callPi?: Function }} [deps] ін'єкції для тестів
|
|
37
|
-
* @returns {Promise<{ backend: string, runStep: (prompt: string, opts?: object) => Promise<{ ok: boolean, output: string }> }>}
|
|
37
|
+
* @returns {Promise<{ backend: string, runStep: (prompt: string, opts?: object) => Promise<{ ok: boolean, output: string }> }>} runner із backend='pi' і методом runStep
|
|
38
38
|
*/
|
|
39
|
-
export
|
|
39
|
+
export function createRunner(deps = {}) {
|
|
40
40
|
const model = deps.model ?? resolveModel('avg')
|
|
41
41
|
const callPiFn = deps.callPi ?? callPi
|
|
42
42
|
|
|
43
43
|
return {
|
|
44
44
|
backend: 'pi',
|
|
45
|
-
|
|
45
|
+
runStep(prompt, opts = {}) {
|
|
46
46
|
try {
|
|
47
47
|
return callPiFn(prompt, model, opts)
|
|
48
|
-
} catch (
|
|
49
|
-
return { ok: false, output: String(
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return { ok: false, output: String(error?.message ?? error) }
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -24,6 +24,10 @@ const INFO_LINK_FIELDS = new Set(['flow'])
|
|
|
24
24
|
/** Каталоги з traceable-артефактами. */
|
|
25
25
|
const DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']
|
|
26
26
|
|
|
27
|
+
const LEADING_QUOTE_RE = /^["']/u
|
|
28
|
+
const TRAILING_QUOTE_RE = /["']$/u
|
|
29
|
+
const SIMPLE_KEY_CHAR_RE = /[a-z_]/iu
|
|
30
|
+
|
|
27
31
|
/**
|
|
28
32
|
* Парсить плаский YAML-front-matter (key: value). Не обробляє вкладеність —
|
|
29
33
|
* достатньо для spec/plan/task-record полів. Інлайн-коментарі (` #…`) відрізає.
|
|
@@ -43,7 +47,7 @@ export function parseFrontMatter(content) {
|
|
|
43
47
|
let val = line.slice(ci + 1)
|
|
44
48
|
const hi = val.indexOf(' #')
|
|
45
49
|
if (hi !== -1) val = val.slice(0, hi)
|
|
46
|
-
val = val.trim().replace(
|
|
50
|
+
val = val.trim().replace(LEADING_QUOTE_RE, '').replace(TRAILING_QUOTE_RE, '')
|
|
47
51
|
fm[key] = val === '' || val === 'null' ? null : val
|
|
48
52
|
}
|
|
49
53
|
return fm
|
|
@@ -55,7 +59,7 @@ export function parseFrontMatter(content) {
|
|
|
55
59
|
* @returns {boolean} true для простого ключа
|
|
56
60
|
*/
|
|
57
61
|
function isSimpleKey(key) {
|
|
58
|
-
return key.length > 0 && [...key].every(c =>
|
|
62
|
+
return key.length > 0 && [...key].every(c => SIMPLE_KEY_CHAR_RE.test(c))
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
/**
|
|
@@ -154,6 +154,7 @@ const items = await buildAgentsCommandBulletItems(process.cwd())
|
|
|
154
154
|
|
|
155
155
|
```js
|
|
156
156
|
import { buildAgentsCommandBulletItems } from '/абсолютний/шлях/до/build-agents-commands.mjs'
|
|
157
|
+
|
|
157
158
|
const items = await buildAgentsCommandBulletItems('/абсолютний/шлях/до/тимчасової/теки')
|
|
158
159
|
console.log(items)
|
|
159
160
|
```
|
|
@@ -94,6 +94,9 @@ export function isRunAsCli(metaUrl)
|
|
|
94
94
|
```js
|
|
95
95
|
import { isRunAsCli } from './cli-entry.mjs' // або відносний шлях
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
*
|
|
99
|
+
*/
|
|
97
100
|
export async function runCli(argv) {
|
|
98
101
|
// ...
|
|
99
102
|
}
|
|
@@ -111,6 +114,9 @@ if (isRunAsCli(import.meta.url)) {
|
|
|
111
114
|
// my-tool.mjs
|
|
112
115
|
import { isRunAsCli } from '../scripts/cli-entry.mjs'
|
|
113
116
|
|
|
117
|
+
/**
|
|
118
|
+
*
|
|
119
|
+
*/
|
|
114
120
|
export function doWork(args) {
|
|
115
121
|
/* ... */
|
|
116
122
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
}
|