@nitra/cursor 3.29.0 → 4.1.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 +13 -0
- package/lib/models.mjs +68 -0
- package/package.json +2 -1
- package/scripts/dispatcher/index.mjs +20 -61
- package/scripts/dispatcher/lib/flow-plan.mjs +153 -0
- package/scripts/dispatcher/lib/flow-signals.mjs +235 -0
- package/scripts/dispatcher/lib/flow-verify.mjs +127 -0
- package/scripts/worktree-cli.mjs +2 -2
- package/skills/docgen/js/docgen-gen.mjs +42 -125
- package/scripts/dispatcher/lib/active.mjs +0 -222
- package/scripts/dispatcher/lib/artifact.mjs +0 -79
- package/scripts/dispatcher/lib/budget.mjs +0 -36
- package/scripts/dispatcher/lib/capability.mjs +0 -59
- package/scripts/dispatcher/lib/commands.mjs +0 -296
- package/scripts/dispatcher/lib/flow-lock.mjs +0 -39
- package/scripts/dispatcher/lib/gate.mjs +0 -91
- package/scripts/dispatcher/lib/level.mjs +0 -135
- package/scripts/dispatcher/lib/plan.mjs +0 -88
- package/scripts/dispatcher/lib/planner.mjs +0 -73
- package/scripts/dispatcher/lib/review.mjs +0 -176
- package/scripts/dispatcher/lib/reviewer.mjs +0 -44
- package/scripts/dispatcher/lib/snapshot.mjs +0 -58
- package/scripts/dispatcher/lib/spec.mjs +0 -97
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler `flow verify` — Stage 2 structural check (думка.MD § "flow verify").
|
|
3
|
+
*
|
|
4
|
+
* Перевіряє що `outputs_NNN.md` існує і непорожній у директорії поточного вузла
|
|
5
|
+
* (CWD). Якщо так — виводить `## Done when` секцію з `task.md` та вміст
|
|
6
|
+
* `outputs_NNN.md` на stdout для агентської self-evaluation.
|
|
7
|
+
*
|
|
8
|
+
* exit 0 = структурно OK
|
|
9
|
+
* exit 1 = структурна помилка (outputs відсутній або порожній)
|
|
10
|
+
*
|
|
11
|
+
* НІЯКОГО артефакту не пишеться. FS ін'єктується для тестування без диска.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { cwd as processCwd } from 'node:process'
|
|
16
|
+
|
|
17
|
+
const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
|
|
18
|
+
const SECTION_RE = /^## (.+)$/m
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Читає секцію за заголовком із markdown-файлу.
|
|
22
|
+
* Повертає вміст від заголовка до наступного `## ` або кінця файлу.
|
|
23
|
+
* @param {string} text вміст файлу
|
|
24
|
+
* @param {string} heading заголовок без `## `
|
|
25
|
+
* @returns {string | null} вміст секції (включно з рядком заголовка) або null
|
|
26
|
+
*/
|
|
27
|
+
function extractSection(text, heading) {
|
|
28
|
+
const lines = text.split(/\r?\n/)
|
|
29
|
+
const start = lines.findIndex(l => l === `## ${heading}`)
|
|
30
|
+
if (start === -1) return null
|
|
31
|
+
const end = lines.findIndex((l, i) => i > start && SECTION_RE.test(l))
|
|
32
|
+
const section = end === -1 ? lines.slice(start) : lines.slice(start, end)
|
|
33
|
+
return section.join('\n').trimEnd()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Знаходить outputs-файл з найбільшим NNN у директорії вузла.
|
|
38
|
+
* @param {string[]} files список файлів директорії
|
|
39
|
+
* @returns {string | null} ім'я файлу (напр. `outputs_001.md`) або null
|
|
40
|
+
*/
|
|
41
|
+
export function findLatestOutputs(files) {
|
|
42
|
+
let max = -1
|
|
43
|
+
let best = null
|
|
44
|
+
for (const f of files) {
|
|
45
|
+
const m = f.match(/^outputs_(\d+)\.md$/)
|
|
46
|
+
if (m) {
|
|
47
|
+
const n = parseInt(m[1], 10)
|
|
48
|
+
if (n > max) {
|
|
49
|
+
max = n
|
|
50
|
+
best = f
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return best
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `flow verify` handler.
|
|
59
|
+
*
|
|
60
|
+
* @param {string[]} _rest аргументи після `verify` (не використовуються)
|
|
61
|
+
* @param {{
|
|
62
|
+
* cwd?: string,
|
|
63
|
+
* log?: (m: string) => void,
|
|
64
|
+
* readFile?: (path: string, enc: string) => string,
|
|
65
|
+
* readdir?: (dir: string) => string[],
|
|
66
|
+
* exists?: (path: string) => boolean
|
|
67
|
+
* }} [deps] ін'єкції
|
|
68
|
+
* @returns {Promise<number>} exit code (0=OK, 1=структурна помилка)
|
|
69
|
+
*/
|
|
70
|
+
export async function verify(_rest, deps = {}) {
|
|
71
|
+
const cwd = deps.cwd ?? processCwd()
|
|
72
|
+
const log = deps.log ?? console.error
|
|
73
|
+
const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc))
|
|
74
|
+
const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : []))
|
|
75
|
+
const exists = deps.exists ?? existsSync
|
|
76
|
+
|
|
77
|
+
// Перевіряємо outputs_NNN.md
|
|
78
|
+
const files = readdir(cwd)
|
|
79
|
+
const outputsName = findLatestOutputs(files)
|
|
80
|
+
if (!outputsName) {
|
|
81
|
+
log('flow verify: outputs_NNN.md не знайдено — структурна помилка')
|
|
82
|
+
return 1
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const outputsPath = join(cwd, outputsName)
|
|
86
|
+
if (!exists(outputsPath)) {
|
|
87
|
+
log(`flow verify: ${outputsName} не існує — структурна помилка`)
|
|
88
|
+
return 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let outputsContent
|
|
92
|
+
try {
|
|
93
|
+
outputsContent = readFile(outputsPath, 'utf8')
|
|
94
|
+
} catch (err) {
|
|
95
|
+
log(`flow verify: не вдалося прочитати ${outputsName} — ${err instanceof Error ? err.message : String(err)}`)
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Перевіряємо що файл не порожній (без front-matter — суто тіло)
|
|
100
|
+
const withoutFm = outputsContent.replace(FRONT_MATTER_RE, '').trim()
|
|
101
|
+
if (withoutFm.length === 0) {
|
|
102
|
+
log(`flow verify: ${outputsName} порожній — структурна помилка`)
|
|
103
|
+
return 1
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Виводимо Done when + outputs на stdout для агентської self-evaluation
|
|
107
|
+
const outLines = [`## verify context`, ``]
|
|
108
|
+
|
|
109
|
+
const taskPath = join(cwd, 'task.md')
|
|
110
|
+
if (exists(taskPath)) {
|
|
111
|
+
try {
|
|
112
|
+
const taskContent = readFile(taskPath, 'utf8')
|
|
113
|
+
const doneWhen = extractSection(taskContent, 'Done when')
|
|
114
|
+
if (doneWhen) {
|
|
115
|
+
outLines.push(doneWhen, '')
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// якщо task.md недоступний — не блокуємо verify
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
outLines.push(`### ${outputsName}`, ``, outputsContent.trimEnd())
|
|
123
|
+
|
|
124
|
+
console.log(outLines.join('\n'))
|
|
125
|
+
|
|
126
|
+
return 0
|
|
127
|
+
}
|
package/scripts/worktree-cli.mjs
CHANGED
|
@@ -15,7 +15,6 @@ 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'
|
|
19
18
|
import {
|
|
20
19
|
buildDescription,
|
|
21
20
|
buildDirtyNotice,
|
|
@@ -161,7 +160,8 @@ function cmdRemove(rest, ctx) {
|
|
|
161
160
|
return 1
|
|
162
161
|
}
|
|
163
162
|
if (existsSync(paths.descFile)) rmSync(paths.descFile, { force: true })
|
|
164
|
-
|
|
163
|
+
// В новій архітектурі (думка.MD) state зберігається у файлах вузлів (task.md, outputs, run),
|
|
164
|
+
// а не у .flow.json/.events.jsonl/lock sibling-ах. Cleanup sibling-ів більше не потрібен.
|
|
165
165
|
ctx.log(`✅ прибрано: ${paths.checkout} (гілку ${branch} лишено)`)
|
|
166
166
|
return 0
|
|
167
167
|
}
|
|
@@ -1,70 +1,14 @@
|
|
|
1
1
|
/** @see ./docs/docgen-gen.md */
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import { basename } from 'node:path'
|
|
4
|
-
import { request } from 'node:http'
|
|
5
4
|
import { spawnSync } from 'node:child_process'
|
|
6
5
|
import { env } from 'node:process'
|
|
7
|
-
import {
|
|
6
|
+
import { resolveModel } from '../../../lib/models.mjs'
|
|
8
7
|
import { extractFacts } from './docgen-extract.mjs'
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
/** Strips provider prefix from tier string for direct ollama HTTP (ollama/gemma3:4b → gemma3:4b). */
|
|
12
|
-
function localModelId(tier) {
|
|
13
|
-
if (!tier) return 'gemma3:4b'
|
|
14
|
-
const i = tier.indexOf('/')
|
|
15
|
-
return i === -1 ? tier : tier.slice(i + 1)
|
|
16
|
-
}
|
|
8
|
+
import { STYLE, oneShotPromptText, sectionMessages } from './docgen-prompts.mjs'
|
|
17
9
|
|
|
18
10
|
const QUALITY_THRESHOLD = 70
|
|
19
11
|
|
|
20
|
-
/** Один виклик чату до ollama зі streaming (токени стримуються → socket активний, жодного timeout). */
|
|
21
|
-
async function ollamaChat(messages, { model, numPredict = 600 }) {
|
|
22
|
-
const body = JSON.stringify({
|
|
23
|
-
model,
|
|
24
|
-
messages,
|
|
25
|
-
stream: true,
|
|
26
|
-
think: false,
|
|
27
|
-
options: { num_ctx: 8192, temperature: 0.2, num_predict: numPredict },
|
|
28
|
-
keep_alive: '15m'
|
|
29
|
-
})
|
|
30
|
-
return new Promise((resolve, reject) => {
|
|
31
|
-
const req = request(
|
|
32
|
-
{
|
|
33
|
-
hostname: 'localhost',
|
|
34
|
-
port: 11434,
|
|
35
|
-
path: '/api/chat',
|
|
36
|
-
method: 'POST',
|
|
37
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
38
|
-
},
|
|
39
|
-
res => {
|
|
40
|
-
let text = '',
|
|
41
|
-
genTok = 0,
|
|
42
|
-
buf = ''
|
|
43
|
-
res.on('data', chunk => {
|
|
44
|
-
buf += chunk.toString()
|
|
45
|
-
const lines = buf.split('\n')
|
|
46
|
-
buf = lines.pop()
|
|
47
|
-
for (const line of lines) {
|
|
48
|
-
if (!line.trim()) continue
|
|
49
|
-
try {
|
|
50
|
-
const j = JSON.parse(line)
|
|
51
|
-
text += j.message?.content ?? ''
|
|
52
|
-
if (j.done) genTok = j.eval_count ?? 0
|
|
53
|
-
} catch {
|
|
54
|
-
/* partial line */
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
})
|
|
58
|
-
res.on('end', () => resolve({ text, genTok }))
|
|
59
|
-
res.on('error', reject)
|
|
60
|
-
}
|
|
61
|
-
)
|
|
62
|
-
req.on('error', reject)
|
|
63
|
-
req.write(body)
|
|
64
|
-
req.end()
|
|
65
|
-
})
|
|
66
|
-
}
|
|
67
|
-
|
|
68
12
|
/** Прибирає ```-обгортку й випадковий провідний `##`-заголовок із секції. */
|
|
69
13
|
function stripSection(text) {
|
|
70
14
|
let t = text.trim()
|
|
@@ -145,17 +89,21 @@ function scoreDoc(md, facts) {
|
|
|
145
89
|
return { score: Math.max(0, score), issues }
|
|
146
90
|
}
|
|
147
91
|
|
|
148
|
-
/**
|
|
149
|
-
function
|
|
150
|
-
const fullPrompt = `${STYLE}\n\n${oneShotPromptText(facts, src)}`
|
|
92
|
+
/** Викликає pi і повертає stdout. Кидає якщо pi повертає ненульовий код. */
|
|
93
|
+
function callPi(prompt, model, timeoutMs) {
|
|
151
94
|
const modelArgs = model ? ['--model', model] : []
|
|
152
|
-
const r = spawnSync('pi', ['-p',
|
|
95
|
+
const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
|
|
153
96
|
encoding: 'utf8',
|
|
154
|
-
timeout:
|
|
97
|
+
timeout: timeoutMs
|
|
155
98
|
})
|
|
156
|
-
if (r.error) throw new Error(`pi
|
|
157
|
-
if (r.status !== 0) throw new Error(`pi
|
|
158
|
-
|
|
99
|
+
if (r.error) throw new Error(`pi error: ${r.error.message}`)
|
|
100
|
+
if (r.status !== 0) throw new Error(`pi exit ${r.status}: ${r.stderr?.slice(0, 300) ?? ''}`)
|
|
101
|
+
return r.stdout?.trim() ?? ''
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One-shot: один pi-виклик на весь документ. */
|
|
105
|
+
function piOneShot(facts, src, model, timeoutMs = 120_000) {
|
|
106
|
+
const text = callPi(`${STYLE}\n\n${oneShotPromptText(facts, src)}`, model, timeoutMs)
|
|
159
107
|
let md = stripSignatures(stripSection(text))
|
|
160
108
|
if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
|
|
161
109
|
return { md: md + '\n', genTok: 0 }
|
|
@@ -177,58 +125,43 @@ function assemble(stem, sections) {
|
|
|
177
125
|
return parts.join('\n\n') + '\n'
|
|
178
126
|
}
|
|
179
127
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Orchestrated: N окремих pi-викликів, по одному на секцію.
|
|
130
|
+
* Код потрапляє лише в `behavior`; решта секцій — на мінімальному факт-листі.
|
|
131
|
+
*/
|
|
132
|
+
function piOrchestrated(facts, src, model, timeoutMs) {
|
|
182
133
|
const sections = {}
|
|
183
|
-
let genTok = 0
|
|
184
134
|
for (const s of sectionMessages(facts, src)) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
135
|
+
// messages = [{role:'system',content}, {role:'user',content}] → plain text prompt для pi
|
|
136
|
+
const prompt = s.messages.map(m => m.content).join('\n\n')
|
|
137
|
+
sections[s.key] = stripSignatures(stripSection(callPi(prompt, model, timeoutMs)))
|
|
188
138
|
}
|
|
189
|
-
return { md: assemble(basename(facts.relPath), sections), genTok }
|
|
139
|
+
return { md: assemble(basename(facts.relPath), sections), genTok: 0 }
|
|
190
140
|
}
|
|
191
141
|
|
|
192
|
-
/** One-shot режим: один промпт на весь документ. */
|
|
193
|
-
async function generateOneShot(facts, src, model) {
|
|
194
|
-
const { text, genTok } = await ollamaChat(oneShotMessages(facts, src), { model, numPredict: 1500 })
|
|
195
|
-
let md = stripSignatures(stripSection(text)) // Stage-2 лінт і для one-shot
|
|
196
|
-
if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
|
|
197
|
-
return { md: md + '\n', genTok }
|
|
198
|
-
}
|
|
199
142
|
|
|
200
|
-
|
|
143
|
+
|
|
144
|
+
/** Файли з sym ≥ цього значення одразу йдуть у Tier 2 (без Tier 1 проходу). */
|
|
201
145
|
const DEFAULT_SYM_THRESHOLD = 4
|
|
202
|
-
/** Максимальний час
|
|
146
|
+
/** Максимальний час Tier 1 генерації на один файл перед ескалацією у Tier 2. */
|
|
203
147
|
const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
|
|
204
|
-
/** Дефолтна Tier 1 модель: N_CURSOR_DOCGEN_MODEL →
|
|
205
|
-
const DEFAULT_LOCAL_MODEL =
|
|
206
|
-
/** Дефолтна Tier 2
|
|
148
|
+
/** Дефолтна Tier 1 модель: N_CURSOR_DOCGEN_MODEL → resolveModel('min'). */
|
|
149
|
+
const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? resolveModel('min')
|
|
150
|
+
/** Дефолтна Tier 2 модель: N_CURSOR_DOCGEN_CLOUD_MODEL → resolveModel('avg'). */
|
|
207
151
|
const DEFAULT_CLOUD_MODEL = env.N_CURSOR_DOCGEN_CLOUD_MODEL ?? resolveModel('avg')
|
|
208
152
|
|
|
209
|
-
/** Повертає promise, що відхиляється через `ms` мс з повідомленням про timeout. */
|
|
210
|
-
function withTimeout(promise, ms) {
|
|
211
|
-
return Promise.race([
|
|
212
|
-
promise,
|
|
213
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`local timeout after ${ms / 1000}s`)), ms))
|
|
214
|
-
])
|
|
215
|
-
}
|
|
216
|
-
|
|
217
153
|
/**
|
|
218
154
|
* Головний API: файл → { md, genTok, ms, score, issues, tier }.
|
|
219
155
|
*
|
|
220
156
|
* Routing (sym-threshold):
|
|
221
|
-
* sym < symThreshold → Tier 1
|
|
157
|
+
* sym < symThreshold → Tier 1 pi(resolveModel('min'), timeout=5хв) + det-scorer
|
|
222
158
|
* → timeout або det-score < threshold → Tier 2
|
|
223
159
|
* sym >= symThreshold → Pre-routing одразу Tier 2
|
|
224
|
-
*
|
|
225
|
-
* @param {string} cloudModel — модель для Tier 2 генерації (Sonnet за замовч.)
|
|
226
160
|
*/
|
|
227
161
|
export async function generateDoc(
|
|
228
162
|
file,
|
|
229
163
|
{
|
|
230
164
|
model = DEFAULT_LOCAL_MODEL,
|
|
231
|
-
mode = 'orchestrated',
|
|
232
165
|
cloudModel = DEFAULT_CLOUD_MODEL,
|
|
233
166
|
threshold = QUALITY_THRESHOLD,
|
|
234
167
|
symThreshold = DEFAULT_SYM_THRESHOLD
|
|
@@ -238,39 +171,24 @@ export async function generateDoc(
|
|
|
238
171
|
const facts = extractFacts(src, file)
|
|
239
172
|
const t0 = Date.now()
|
|
240
173
|
|
|
241
|
-
// Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2
|
|
174
|
+
// Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2
|
|
242
175
|
const complexity = facts.internalSymbols?.length ?? 0
|
|
243
176
|
if (complexity >= symThreshold && cloudModel) {
|
|
244
177
|
const r2 = piOneShot(facts, src, cloudModel)
|
|
245
|
-
return {
|
|
246
|
-
...r2,
|
|
247
|
-
ms: Date.now() - t0,
|
|
248
|
-
score: null,
|
|
249
|
-
issues: [`pre-routed:sym=${complexity}`],
|
|
250
|
-
tier: 2,
|
|
251
|
-
model: cloudModel
|
|
252
|
-
}
|
|
178
|
+
return { ...r2, ms: Date.now() - t0, score: null, issues: [`pre-routed:sym=${complexity}`], tier: 2, model: cloudModel }
|
|
253
179
|
}
|
|
254
180
|
|
|
255
|
-
// Tier 1:
|
|
181
|
+
// Tier 1: pi orchestrated (секція за секцією), timeout на секцію = LOCAL_TIMEOUT_MS
|
|
182
|
+
// facts.unsupported → one-shot (структура файлу нестандартна)
|
|
256
183
|
let r
|
|
257
184
|
try {
|
|
258
|
-
|
|
259
|
-
facts
|
|
260
|
-
|
|
261
|
-
: generateOrchestrated(facts, src, model)
|
|
262
|
-
r = await withTimeout(localPromise, LOCAL_TIMEOUT_MS)
|
|
185
|
+
r = facts.unsupported
|
|
186
|
+
? piOneShot(facts, src, model, LOCAL_TIMEOUT_MS)
|
|
187
|
+
: piOrchestrated(facts, src, model, LOCAL_TIMEOUT_MS)
|
|
263
188
|
} catch (e) {
|
|
264
189
|
if (cloudModel) {
|
|
265
190
|
const r2 = piOneShot(facts, src, cloudModel)
|
|
266
|
-
return {
|
|
267
|
-
...r2,
|
|
268
|
-
ms: Date.now() - t0,
|
|
269
|
-
score: null,
|
|
270
|
-
issues: [`local-timeout: ${e.message}`],
|
|
271
|
-
tier: 2,
|
|
272
|
-
model: cloudModel
|
|
273
|
-
}
|
|
191
|
+
return { ...r2, ms: Date.now() - t0, score: null, issues: [`tier1-error: ${e.message}`], tier: 2, model: cloudModel }
|
|
274
192
|
}
|
|
275
193
|
throw e
|
|
276
194
|
}
|
|
@@ -286,19 +204,18 @@ export async function generateDoc(
|
|
|
286
204
|
return { ...r, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 1, model }
|
|
287
205
|
}
|
|
288
206
|
|
|
289
|
-
// CLI: node docgen-gen.mjs <file> [--
|
|
207
|
+
// CLI: node docgen-gen.mjs <file> [--model <m>] [--sym-threshold N] [--tier-only]
|
|
290
208
|
import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
|
|
291
209
|
if (isRunAsCli(import.meta.url)) {
|
|
292
210
|
const args = process.argv.slice(2)
|
|
293
211
|
const file = args.find(a => !a.startsWith('--'))
|
|
294
|
-
const mode = args.includes('--oneshot') ? 'oneshot' : 'orchestrated'
|
|
295
212
|
const tierOnly = args.includes('--tier-only')
|
|
296
213
|
const mi = args.indexOf('--model')
|
|
297
214
|
const model = mi >= 0 ? args[mi + 1] : DEFAULT_LOCAL_MODEL
|
|
298
215
|
const si = args.indexOf('--sym-threshold')
|
|
299
216
|
const symThreshold = si >= 0 ? Number(args[si + 1]) : DEFAULT_SYM_THRESHOLD
|
|
300
217
|
if (!file) {
|
|
301
|
-
console.error('Usage: node docgen-gen.mjs <file> [--
|
|
218
|
+
console.error('Usage: node docgen-gen.mjs <file> [--model <m>] [--sym-threshold N] [--tier-only]')
|
|
302
219
|
process.exit(1)
|
|
303
220
|
}
|
|
304
221
|
if (tierOnly) {
|
|
@@ -313,8 +230,8 @@ if (isRunAsCli(import.meta.url)) {
|
|
|
313
230
|
process.stdout.write(`${icon} ${label} | ${file}\n`)
|
|
314
231
|
process.exit(0)
|
|
315
232
|
}
|
|
316
|
-
const r = await generateDoc(file, { model,
|
|
233
|
+
const r = await generateDoc(file, { model, symThreshold })
|
|
317
234
|
const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
|
|
318
|
-
process.stderr.write(`[tier${r.tier}
|
|
235
|
+
process.stderr.write(`[tier${r.tier} pi-orchestrated] ${r.ms}ms / score=${r.score}${issuesTxt}\n`)
|
|
319
236
|
process.stdout.write(r.md)
|
|
320
237
|
}
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Активний Раннер (spec §8.1 Фасад B): `run`/`resume`/`cancel`/`repair`. Зшиває
|
|
3
|
-
* ensureWorktree + planner + executor + verify у повний 5-фазний цикл. Уся IO
|
|
4
|
-
* ін'єктується (`runner`/`verify`/`commit`/`run`/`now`) — тестується без
|
|
5
|
-
* реальних LLM/git/gates.
|
|
6
|
-
*/
|
|
7
|
-
import { spawnSync } from 'node:child_process'
|
|
8
|
-
import { readFileSync } from 'node:fs'
|
|
9
|
-
import { join } from 'node:path'
|
|
10
|
-
import { cwd as processCwd } from 'node:process'
|
|
11
|
-
|
|
12
|
-
import { BudgetExceeded, withBudget } from './budget.mjs'
|
|
13
|
-
import { ensureWorktree, realRun } from './commands.mjs'
|
|
14
|
-
import { flowEventsPath } from './events.mjs'
|
|
15
|
-
import { executePlan } from './executor.mjs'
|
|
16
|
-
import { generatePlan } from './planner.mjs'
|
|
17
|
-
import { runReview } from './reviewer.mjs'
|
|
18
|
-
import { cleanupFlowSiblings, flowStatePath, readState, updateState, writeState } from './state-store.mjs'
|
|
19
|
-
import { createRunner } from './subagent-runner.mjs'
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Дефолтний commit: `git add -A && git commit -m` у worktree.
|
|
23
|
-
* @param {string} cwd worktree
|
|
24
|
-
* @param {string} msg повідомлення
|
|
25
|
-
* @returns {void}
|
|
26
|
-
*/
|
|
27
|
-
function defaultCommit(cwd, msg) {
|
|
28
|
-
spawnSync('git', ['add', '-A'], { cwd })
|
|
29
|
-
spawnSync('git', ['commit', '-m', msg], { cwd })
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Дефолтний verify для executor-а: проганяє gates і повертає verdict.
|
|
34
|
-
* @param {string} cwd worktree
|
|
35
|
-
* @returns {{ pass: boolean, failedOutput: string | null }} verdict
|
|
36
|
-
*/
|
|
37
|
-
function defaultVerify(cwd) {
|
|
38
|
-
return runReview({ run: realRun, cwd, fingerprint: () => null })
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Читає `flow.autonomous` із `.n-cursor.json` (бюджет автономного режиму).
|
|
43
|
-
* @param {string} cwd корінь
|
|
44
|
-
* @returns {{ maxApiCalls?: number, maxCostUsd?: number, onBudgetExceeded?: string }} конфіг бюджету
|
|
45
|
-
*/
|
|
46
|
-
function readFlowAutonomous(cwd) {
|
|
47
|
-
try {
|
|
48
|
-
const cfg = JSON.parse(readFileSync(join(cwd, '.n-cursor.json'), 'utf8'))
|
|
49
|
-
return cfg?.flow?.autonomous ?? {}
|
|
50
|
-
} catch {
|
|
51
|
-
return {}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* `flow run [--autonomous] <branch> "<task>"` — повний цикл: ensureWorktree →
|
|
57
|
-
* план → executor. У `--autonomous` runner обгортається budget guard-ом (§9.4).
|
|
58
|
-
* @param {string[]} rest аргументи (`--autonomous` + `<branch> <task...>`)
|
|
59
|
-
* @param {{ runner?: object, verify?: (cwd: string) => object, commit?: (cwd: string, msg: string) => void, run?: (cmd: string, args: string[], opts: object) => object, autonomous?: boolean, budget?: object, cwd?: string, log?: (m: string) => void, now?: () => number }} [deps] ін'єкції
|
|
60
|
-
* @returns {Promise<number>} exit code: 0 done, 1 fail, 2 blocked-on-human
|
|
61
|
-
*/
|
|
62
|
-
export async function run(rest, deps = {}) {
|
|
63
|
-
const log = deps.log ?? console.error
|
|
64
|
-
const now = deps.now ?? Date.now
|
|
65
|
-
const autonomous = deps.autonomous ?? rest.includes('--autonomous')
|
|
66
|
-
const positional = rest.filter(a => !a.startsWith('--'))
|
|
67
|
-
|
|
68
|
-
const ew = ensureWorktree(positional, deps)
|
|
69
|
-
if (ew.code !== 0) return ew.code
|
|
70
|
-
const { worktreeDir, branch, desc, baseCommit } = ew
|
|
71
|
-
const statePath = flowStatePath(worktreeDir)
|
|
72
|
-
writeState(statePath, {
|
|
73
|
-
branch,
|
|
74
|
-
status: 'in_progress',
|
|
75
|
-
started_at: new Date(now()).toISOString(),
|
|
76
|
-
metadata: { base_commit: baseCommit },
|
|
77
|
-
plan: []
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
let runner
|
|
81
|
-
try {
|
|
82
|
-
runner = deps.runner ?? (await createRunner(deps))
|
|
83
|
-
} catch (error) {
|
|
84
|
-
log(`run: ${error.message}`)
|
|
85
|
-
return 1
|
|
86
|
-
}
|
|
87
|
-
if (autonomous) {
|
|
88
|
-
const budget = deps.budget ?? readFlowAutonomous(deps.cwd ?? processCwd())
|
|
89
|
-
runner = withBudget(runner, { maxApiCalls: budget.maxApiCalls, log })
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
const plan = await generatePlan({ runner, task: desc, cwd: worktreeDir })
|
|
94
|
-
updateState(statePath, s => ({ ...s, plan }))
|
|
95
|
-
const result = await executePlan(
|
|
96
|
-
{ statePath, eventsPath: flowEventsPath(worktreeDir) },
|
|
97
|
-
{ runner, verify: deps.verify ?? defaultVerify, commit: deps.commit ?? defaultCommit, cwd: worktreeDir, log, now }
|
|
98
|
-
)
|
|
99
|
-
if (result.status === 'done') {
|
|
100
|
-
log('run: build done — далі `flow release`')
|
|
101
|
-
return 0
|
|
102
|
-
}
|
|
103
|
-
if (result.status === 'blocked-on-human') {
|
|
104
|
-
log(`run: blocked-on-human на кроці ${result.step}`)
|
|
105
|
-
return 2
|
|
106
|
-
}
|
|
107
|
-
return 1
|
|
108
|
-
} catch (error) {
|
|
109
|
-
if (error instanceof BudgetExceeded) {
|
|
110
|
-
log(`run: ${error.message} — abort`)
|
|
111
|
-
updateState(statePath, s => ({ ...s, status: 'failed' }))
|
|
112
|
-
return 1
|
|
113
|
-
}
|
|
114
|
-
log(`run: ${error.message}`)
|
|
115
|
-
return 1
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* `flow resume` — продовжує з чекпойнта. Safe-resume (§4.1.7): скидає частковий
|
|
121
|
-
* доробок до останнього коміту; застосовує HITL-відповіді як підказки й дає
|
|
122
|
-
* крокам свіжі спроби.
|
|
123
|
-
* @param {string[]} _rest аргументи (не використовуються)
|
|
124
|
-
* @param {object} [deps] ін'єкції (як у `run`)
|
|
125
|
-
* @returns {Promise<number>} exit code
|
|
126
|
-
*/
|
|
127
|
-
export async function resume(_rest, deps = {}) {
|
|
128
|
-
const cwd = deps.cwd ?? processCwd()
|
|
129
|
-
const log = deps.log ?? console.error
|
|
130
|
-
const now = deps.now ?? Date.now
|
|
131
|
-
const run_ = deps.run ?? realRun
|
|
132
|
-
|
|
133
|
-
const statePath = flowStatePath(cwd)
|
|
134
|
-
const state = readState(statePath)
|
|
135
|
-
if (!state) {
|
|
136
|
-
log('resume: стану нема')
|
|
137
|
-
return 1
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const openHitl = (state.hitl ?? []).filter(q => !q.answer)
|
|
141
|
-
if (state.status === 'blocked-on-human' && openHitl.length > 0) {
|
|
142
|
-
log(`resume: ще blocked — ${openHitl.length} відкритих HITL-питань (заповни answer і повтори)`)
|
|
143
|
-
return 2
|
|
144
|
-
}
|
|
145
|
-
if (!state.plan?.length) {
|
|
146
|
-
log('resume: нема плану')
|
|
147
|
-
return 1
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// safe-resume: скинути частковий доробок невдалого кроку до останнього коміту
|
|
151
|
-
run_('git', ['reset', '--hard', 'HEAD'], { cwd })
|
|
152
|
-
|
|
153
|
-
// застосувати HITL-відповіді як hint + дати незавершеним крокам свіжі спроби
|
|
154
|
-
const answers = new Map((state.hitl ?? []).filter(q => q.answer).map(q => [q.step, q.answer]))
|
|
155
|
-
updateState(statePath, s => ({
|
|
156
|
-
...s,
|
|
157
|
-
status: 'in_progress',
|
|
158
|
-
plan: s.plan.map(st =>
|
|
159
|
-
st.status === 'done'
|
|
160
|
-
? st
|
|
161
|
-
: { ...st, retry_count: 0, ...(answers.has(st.step) ? { hint: answers.get(st.step) } : {}) }
|
|
162
|
-
),
|
|
163
|
-
hitl: (s.hitl ?? []).map(q => (q.answer ? { ...q, status: 'answered' } : q))
|
|
164
|
-
}))
|
|
165
|
-
|
|
166
|
-
let runner
|
|
167
|
-
try {
|
|
168
|
-
runner = deps.runner ?? (await createRunner(deps))
|
|
169
|
-
} catch (error) {
|
|
170
|
-
log(`resume: ${error.message}`)
|
|
171
|
-
return 1
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const result = await executePlan(
|
|
175
|
-
{ statePath, eventsPath: flowEventsPath(cwd) },
|
|
176
|
-
{ runner, verify: deps.verify ?? defaultVerify, commit: deps.commit ?? defaultCommit, cwd, log, now }
|
|
177
|
-
)
|
|
178
|
-
if (result.status === 'done') return 0
|
|
179
|
-
if (result.status === 'blocked-on-human') return 2
|
|
180
|
-
return 1
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* `flow cancel` — скасування: прибирає transient sibling-и (стан/журнал/lock).
|
|
185
|
-
* @param {string[]} _rest аргументи
|
|
186
|
-
* @param {{ cwd?: string, log?: (m: string) => void }} [deps] ін'єкції
|
|
187
|
-
* @returns {Promise<number>} 0
|
|
188
|
-
*/
|
|
189
|
-
export async function cancel(_rest, deps = {}) {
|
|
190
|
-
const cwd = deps.cwd ?? processCwd()
|
|
191
|
-
const log = deps.log ?? console.error
|
|
192
|
-
cleanupFlowSiblings(cwd)
|
|
193
|
-
log('cancel: стан і sibling-и прибрано')
|
|
194
|
-
return 0
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* `flow repair [--discard-step-work]` — fail-closed escape: діагностика стану або
|
|
199
|
-
* жорстке скидання робочого дерева до HEAD (свідоме викидання доробку).
|
|
200
|
-
* @param {string[]} rest аргументи
|
|
201
|
-
* @param {{ run?: (cmd: string, args: string[], opts: object) => object, cwd?: string, log?: (m: string) => void }} [deps] ін'єкції
|
|
202
|
-
* @returns {Promise<number>} exit code
|
|
203
|
-
*/
|
|
204
|
-
export async function repair(rest, deps = {}) {
|
|
205
|
-
const cwd = deps.cwd ?? processCwd()
|
|
206
|
-
const log = deps.log ?? console.error
|
|
207
|
-
const run_ = deps.run ?? realRun
|
|
208
|
-
|
|
209
|
-
if (rest.includes('--discard-step-work')) {
|
|
210
|
-
run_('git', ['reset', '--hard', 'HEAD'], { cwd })
|
|
211
|
-
log('repair: робоче дерево скинуто до HEAD (--discard-step-work)')
|
|
212
|
-
return 0
|
|
213
|
-
}
|
|
214
|
-
try {
|
|
215
|
-
const state = readState(flowStatePath(cwd))
|
|
216
|
-
log(state ? `repair: стан валідний (status: ${state.status})` : 'repair: стану нема')
|
|
217
|
-
return 0
|
|
218
|
-
} catch (error) {
|
|
219
|
-
log(`repair: стан пошкоджено — ${error.message}. Спробуй \`flow repair --discard-step-work\` або \`flow cancel\`.`)
|
|
220
|
-
return 1
|
|
221
|
-
}
|
|
222
|
-
}
|