@nitra/cursor 3.29.0 → 4.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 +7 -0
- package/package.json +1 -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
|
@@ -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
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Спільні утиліти фаз `spec`/`plan` (Пасивний Турнікет): резолв traceable-
|
|
3
|
-
* артефакту в `docs/<kind>/`, екстракт кроків плану зі секції `## Кроки`, і
|
|
4
|
-
* read-only перевірка цілісності ланцюга через `n-cursor trace` (`trace.mjs`).
|
|
5
|
-
*
|
|
6
|
-
* Лінки front-matter (`spec.plan`/`plan.spec`/`plan.flow`) пише сам агент за
|
|
7
|
-
* контрактом `flow.mdc` — тут лише ВЕРИФІКАЦІЯ (мутатора `trace link` нема).
|
|
8
|
-
*/
|
|
9
|
-
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
10
|
-
import { join } from 'node:path'
|
|
11
|
-
|
|
12
|
-
import { runTraceCli } from '../trace.mjs'
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Резолвить артефакт у `docs/<kind>/`. Пріоритет: файли, чия назва містить
|
|
16
|
-
* хвіст гілки (slug, напр. `flow-gate` з `claude/flow-gate`); серед них (або
|
|
17
|
-
* серед усіх, якщо збігу нема) — **найсвіжіший за mtime**. Лексикографічний
|
|
18
|
-
* вибір був хибним при кількох артефактах на одну дату (виявлено dogfood'ом).
|
|
19
|
-
* @param {string} cwd корінь worktree
|
|
20
|
-
* @param {'specs' | 'plans'} kind підкаталог `docs`
|
|
21
|
-
* @param {string} [branch] гілка задачі — для пріоритету за slug
|
|
22
|
-
* @returns {string | null} абсолютний шлях або null, якщо каталог/файли відсутні
|
|
23
|
-
*/
|
|
24
|
-
export function resolveArtifact(cwd, kind, branch) {
|
|
25
|
-
const dir = join(cwd, 'docs', kind)
|
|
26
|
-
if (!existsSync(dir)) return null
|
|
27
|
-
const md = readdirSync(dir).filter(f => f.endsWith('.md'))
|
|
28
|
-
if (md.length === 0) return null
|
|
29
|
-
|
|
30
|
-
const slug = branch ? branch.split('/').pop() : null
|
|
31
|
-
const matched = slug ? md.filter(f => f.includes(slug)) : []
|
|
32
|
-
const pool = matched.length > 0 ? matched : md
|
|
33
|
-
|
|
34
|
-
const best = pool
|
|
35
|
-
.map(f => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
|
|
36
|
-
.toSorted((a, b) => a.mtime - b.mtime || (a.f < b.f ? -1 : 1))
|
|
37
|
-
.at(-1)
|
|
38
|
-
return join(dir, best.f)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Маркер критерію приймання в рядку кроку (порівняння — case-insensitive). */
|
|
42
|
-
const ACCEPTANCE_MARK = '— acceptance:'
|
|
43
|
-
/** Лише цифри — перевірка нумерації кроку (лінійний, без backtracking). */
|
|
44
|
-
const DIGITS_RE = /^\d+$/u
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Кроки зі секції плану — нумерований список `N. <task> — acceptance: <crit>`.
|
|
48
|
-
* Best-effort парсинг через `indexOf` (без regex-backtracking): рядки поза
|
|
49
|
-
* форматом ігноруються.
|
|
50
|
-
* @param {string} text вміст plan-doc
|
|
51
|
-
* @returns {{ task: string, acceptance?: string }[]} кроки у порядку появи
|
|
52
|
-
*/
|
|
53
|
-
export function extractSteps(text) {
|
|
54
|
-
const steps = []
|
|
55
|
-
for (const raw of String(text).split('\n')) {
|
|
56
|
-
const line = raw.trim()
|
|
57
|
-
const dot = line.indexOf('. ')
|
|
58
|
-
if (dot <= 0 || !DIGITS_RE.test(line.slice(0, dot))) continue
|
|
59
|
-
const body = line.slice(dot + 2).trim()
|
|
60
|
-
const sep = body.toLowerCase().indexOf(ACCEPTANCE_MARK)
|
|
61
|
-
if (sep === -1) {
|
|
62
|
-
steps.push({ task: body })
|
|
63
|
-
} else {
|
|
64
|
-
steps.push({ task: body.slice(0, sep).trim(), acceptance: body.slice(sep + ACCEPTANCE_MARK.length).trim() })
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return steps
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Read-only перевірка цілісності ланцюга артефактів (не мутує — лише сигнал).
|
|
72
|
-
* @param {string} cwd корінь worktree
|
|
73
|
-
* @param {(cwd: string) => number} [runTrace] runner trace (0 — цілісно, 1 — розрив); ін'єкція для тестів
|
|
74
|
-
* @returns {boolean} true, якщо ланцюг цілісний
|
|
75
|
-
*/
|
|
76
|
-
export function verifyTrace(cwd, runTrace) {
|
|
77
|
-
const run = runTrace ?? (c => runTraceCli([], { cwd: c, log: () => {} }))
|
|
78
|
-
return run(cwd) === 0
|
|
79
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Budget guard для автономного режиму (spec §9.4): обгортає SubagentRunner
|
|
3
|
-
* лічильником викликів і кидає `BudgetExceeded` при перевищенні `maxApiCalls`.
|
|
4
|
-
* Це запобіжник проти неконтрольованих витрат на сервері (де нема людини).
|
|
5
|
-
*
|
|
6
|
-
* (`maxCostUsd` — коли runner повертатиме tokens/cost; наразі рахуємо виклики.)
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** Помилка перевищення бюджету (ловиться в `run`, §9.4). */
|
|
10
|
-
export class BudgetExceeded extends Error {}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Обгортає runner лічильником API-викликів.
|
|
14
|
-
* @param {{ backend?: string, runStep: (prompt: string, opts?: object) => object }} runner базовий runner
|
|
15
|
-
* @param {{ maxApiCalls?: number, log?: (m: string) => void }} [opts] ліміт і лог
|
|
16
|
-
* @returns {{ backend: string, runStep: (prompt: string, opts?: object) => Promise<object>, readonly calls: number }} обгорнутий runner
|
|
17
|
-
*/
|
|
18
|
-
export function withBudget(runner, opts = {}) {
|
|
19
|
-
const maxApiCalls = opts.maxApiCalls ?? Number.POSITIVE_INFINITY
|
|
20
|
-
const log = opts.log ?? (() => {})
|
|
21
|
-
let calls = 0
|
|
22
|
-
return {
|
|
23
|
-
backend: runner.backend,
|
|
24
|
-
get calls() {
|
|
25
|
-
return calls
|
|
26
|
-
},
|
|
27
|
-
async runStep(prompt, stepOpts) {
|
|
28
|
-
if (calls >= maxApiCalls) {
|
|
29
|
-
throw new BudgetExceeded(`budget: вичерпано maxApiCalls=${maxApiCalls}`)
|
|
30
|
-
}
|
|
31
|
-
calls += 1
|
|
32
|
-
log(`budget: API-виклик ${calls}/${maxApiCalls}`)
|
|
33
|
-
return runner.runStep(prompt, stepOpts)
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Capability Router — резолвер режиму оркестрації (`native` vs `polyfill`)
|
|
3
|
-
* за **явною декларацією моделі** (spec §2.2).
|
|
4
|
-
*
|
|
5
|
-
* Рантайм-детекції моделі в кодобазі немає — тому модель НЕ вгадуємо, а
|
|
6
|
-
* оголошуємо за пріоритетом: CLI `--model` > env `N_CURSOR_FLOW_MODEL` >
|
|
7
|
-
* config `flow.model`. Default-режим (`polyfill`) дозволений ЛИШЕ за наявного
|
|
8
|
-
* `SubagentRunner` (§15.1); інакше — fail (caller кидає помилку), бо polyfill
|
|
9
|
-
* без runner-а не «працює з будь-якою моделлю».
|
|
10
|
-
*
|
|
11
|
-
* Усі функції чисті (без I/O) — джерела (`args`/`env`/`config`/`matrix`/
|
|
12
|
-
* `hasRunner`) передаються ззовні, що робить модуль тривіально тестованим.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export const DEFAULT_ORCHESTRATION = 'polyfill'
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Витягує значення `--model <value>` з argv. Не мутує вхід.
|
|
19
|
-
* @param {string[]} args аргументи підкоманди flow
|
|
20
|
-
* @returns {string | null} оголошена модель або null
|
|
21
|
-
*/
|
|
22
|
-
export function parseModelFlag(args) {
|
|
23
|
-
const i = args.indexOf('--model')
|
|
24
|
-
return i !== -1 && i + 1 < args.length ? args[i + 1] : null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Оголошена модель за пріоритетом CLI > env > config.
|
|
29
|
-
* @param {{ cliModel?: string | null, envModel?: string | null, configModel?: string | null }} sources джерела декларації
|
|
30
|
-
* @returns {string | null} модель або null, якщо ніде не оголошено
|
|
31
|
-
*/
|
|
32
|
-
export function declaredModel({ cliModel = null, envModel = null, configModel = null } = {}) {
|
|
33
|
-
return cliModel || envModel || configModel || null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Режим оркестрації для оголошеної моделі за `capability-matrix`.
|
|
38
|
-
* Невідома/неоголошена модель → `matrix.default` → `DEFAULT_ORCHESTRATION`.
|
|
39
|
-
* @param {string | null} model оголошена модель
|
|
40
|
-
* @param {{ models?: Record<string, { orchestration?: string }>, default?: { orchestration?: string } }} matrix матриця можливостей
|
|
41
|
-
* @returns {'native' | 'polyfill'} режим
|
|
42
|
-
*/
|
|
43
|
-
export function orchestrationFor(model, matrix) {
|
|
44
|
-
const entry = model && matrix && matrix.models ? matrix.models[model] : null
|
|
45
|
-
return (
|
|
46
|
-
(entry && entry.orchestration) ||
|
|
47
|
-
(matrix && matrix.default && matrix.default.orchestration) ||
|
|
48
|
-
DEFAULT_ORCHESTRATION
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Чи стартує polyfill: потрібен доступний `SubagentRunner`.
|
|
54
|
-
* @param {{ hasRunner: boolean }} ctx контекст середовища
|
|
55
|
-
* @returns {boolean} true, якщо runner у наявності
|
|
56
|
-
*/
|
|
57
|
-
export function polyfillStartable({ hasRunner }) {
|
|
58
|
-
return hasRunner === true
|
|
59
|
-
}
|