@nitra/cursor 3.28.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 +13 -0
- package/package.json +1 -3
- package/scripts/coverage-classify/index.mjs +60 -72
- package/scripts/coverage-fix.mjs +26 -23
- 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/dispatcher/lib/subagent-runner.mjs +33 -102
- package/scripts/worktree-cli.mjs +2 -2
- package/skills/docgen/js/docgen-gen.mjs +54 -178
- package/skills/fix/js/llm-worker.mjs +4 -4
- 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,62 +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 {
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
5
|
import { env } from 'node:process'
|
|
6
|
-
import
|
|
6
|
+
import { resolveModel } from '../../../lib/models.mjs'
|
|
7
7
|
import { extractFacts } from './docgen-extract.mjs'
|
|
8
|
-
import {
|
|
8
|
+
import { STYLE, oneShotPromptText, sectionMessages } from './docgen-prompts.mjs'
|
|
9
9
|
|
|
10
10
|
const QUALITY_THRESHOLD = 70
|
|
11
11
|
|
|
12
|
-
/** Один виклик чату до ollama зі streaming (токени стримуються → socket активний, жодного timeout). */
|
|
13
|
-
async function ollamaChat(messages, { model, numPredict = 600 }) {
|
|
14
|
-
const body = JSON.stringify({
|
|
15
|
-
model,
|
|
16
|
-
messages,
|
|
17
|
-
stream: true,
|
|
18
|
-
think: false,
|
|
19
|
-
options: { num_ctx: 8192, temperature: 0.2, num_predict: numPredict },
|
|
20
|
-
keep_alive: '15m'
|
|
21
|
-
})
|
|
22
|
-
return new Promise((resolve, reject) => {
|
|
23
|
-
const req = request(
|
|
24
|
-
{
|
|
25
|
-
hostname: 'localhost',
|
|
26
|
-
port: 11434,
|
|
27
|
-
path: '/api/chat',
|
|
28
|
-
method: 'POST',
|
|
29
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
30
|
-
},
|
|
31
|
-
res => {
|
|
32
|
-
let text = '',
|
|
33
|
-
genTok = 0,
|
|
34
|
-
buf = ''
|
|
35
|
-
res.on('data', chunk => {
|
|
36
|
-
buf += chunk.toString()
|
|
37
|
-
const lines = buf.split('\n')
|
|
38
|
-
buf = lines.pop()
|
|
39
|
-
for (const line of lines) {
|
|
40
|
-
if (!line.trim()) continue
|
|
41
|
-
try {
|
|
42
|
-
const j = JSON.parse(line)
|
|
43
|
-
text += j.message?.content ?? ''
|
|
44
|
-
if (j.done) genTok = j.eval_count ?? 0
|
|
45
|
-
} catch {
|
|
46
|
-
/* partial line */
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
})
|
|
50
|
-
res.on('end', () => resolve({ text, genTok }))
|
|
51
|
-
res.on('error', reject)
|
|
52
|
-
}
|
|
53
|
-
)
|
|
54
|
-
req.on('error', reject)
|
|
55
|
-
req.write(body)
|
|
56
|
-
req.end()
|
|
57
|
-
})
|
|
58
|
-
}
|
|
59
|
-
|
|
60
12
|
/** Прибирає ```-обгортку й випадковий провідний `##`-заголовок із секції. */
|
|
61
13
|
function stripSection(text) {
|
|
62
14
|
let t = text.trim()
|
|
@@ -137,73 +89,24 @@ function scoreDoc(md, facts) {
|
|
|
137
89
|
return { score: Math.max(0, score), issues }
|
|
138
90
|
}
|
|
139
91
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
Відповідай ТІЛЬКИ JSON без пояснень:
|
|
148
|
-
{"огляд":N,"поведінка":N,"гарантії":N,"стиль":N,"issues":["коротко про кожен мінус 1-5 слів"]}`
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Stage 2.5 cloud: Claude Haiku оцінює якість доку проти коду + фактів.
|
|
152
|
-
* Використовує найдешевшу хмарну модель — haiku — для мінімальної вартості судді.
|
|
153
|
-
* @returns {{ score: number, scores: object, issues: string[], tok: number }}
|
|
154
|
-
*/
|
|
155
|
-
async function cloudScoreDoc(md, facts, src, model = 'claude-haiku-4-5-20251001') {
|
|
156
|
-
const client = new Anthropic()
|
|
157
|
-
const factsTxt = [
|
|
158
|
-
facts.exports?.length ? `Публічні функції: ${facts.exports.map(e => e.name).join(', ')}` : '',
|
|
159
|
-
facts.internalSymbols?.length ? `Внутрішні (не публічні): ${facts.internalSymbols.join(', ')}` : '',
|
|
160
|
-
facts.markers?.caches ? 'Кешування: є' : 'Кешування: немає',
|
|
161
|
-
facts.markers?.network ? 'Мережа: є' : 'Мережа: немає',
|
|
162
|
-
facts.markers?.readOnly ? 'Read-only (не змінює файли/стан)' : ''
|
|
163
|
-
]
|
|
164
|
-
.filter(Boolean)
|
|
165
|
-
.join('\n')
|
|
166
|
-
|
|
167
|
-
const msg = await client.messages.create({
|
|
168
|
-
model,
|
|
169
|
-
max_tokens: 256,
|
|
170
|
-
system: SCORE_RUBRIC,
|
|
171
|
-
messages: [
|
|
172
|
-
{
|
|
173
|
-
role: 'user',
|
|
174
|
-
content: [
|
|
175
|
-
{ type: 'text', text: `ФАКТИ:\n${factsTxt}`, cache_control: { type: 'ephemeral' } },
|
|
176
|
-
{ type: 'text', text: `КОД:\n\`\`\`\n${src.slice(0, 4000)}\n\`\`\``, cache_control: { type: 'ephemeral' } },
|
|
177
|
-
{ type: 'text', text: `ДОКУМЕНТАЦІЯ:\n${md}` }
|
|
178
|
-
]
|
|
179
|
-
}
|
|
180
|
-
]
|
|
92
|
+
/** Викликає pi і повертає stdout. Кидає якщо pi повертає ненульовий код. */
|
|
93
|
+
function callPi(prompt, model, timeoutMs) {
|
|
94
|
+
const modelArgs = model ? ['--model', model] : []
|
|
95
|
+
const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
|
|
96
|
+
encoding: 'utf8',
|
|
97
|
+
timeout: timeoutMs
|
|
181
98
|
})
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const total = (((j.огляд ?? 0) + (j.поведінка ?? 0) + (j.гарантії ?? 0) + (j.стиль ?? 0)) / 12) * 100
|
|
186
|
-
return { score: Math.round(total), scores: j, issues: j.issues ?? [], tok }
|
|
187
|
-
} catch {
|
|
188
|
-
return { score: 50, scores: {}, issues: ['parse-error'], tok }
|
|
189
|
-
}
|
|
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() ?? ''
|
|
190
102
|
}
|
|
191
103
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
const prompt = oneShotPromptText(facts, src)
|
|
196
|
-
const msg = await client.messages.create({
|
|
197
|
-
model,
|
|
198
|
-
max_tokens: 1500,
|
|
199
|
-
system: STYLE,
|
|
200
|
-
messages: [{ role: 'user', content: prompt }]
|
|
201
|
-
})
|
|
202
|
-
const text = msg.content[0]?.text ?? ''
|
|
203
|
-
const genTok = msg.usage?.output_tokens ?? 0
|
|
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)
|
|
204
107
|
let md = stripSignatures(stripSection(text))
|
|
205
108
|
if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
|
|
206
|
-
return { md: md + '\n', genTok }
|
|
109
|
+
return { md: md + '\n', genTok: 0 }
|
|
207
110
|
}
|
|
208
111
|
|
|
209
112
|
/** Stage 3: фіксовані заголовки у фіксованому порядку. */
|
|
@@ -222,55 +125,44 @@ function assemble(stem, sections) {
|
|
|
222
125
|
return parts.join('\n\n') + '\n'
|
|
223
126
|
}
|
|
224
127
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Orchestrated: N окремих pi-викликів, по одному на секцію.
|
|
130
|
+
* Код потрапляє лише в `behavior`; решта секцій — на мінімальному факт-листі.
|
|
131
|
+
*/
|
|
132
|
+
function piOrchestrated(facts, src, model, timeoutMs) {
|
|
227
133
|
const sections = {}
|
|
228
|
-
let genTok = 0
|
|
229
134
|
for (const s of sectionMessages(facts, src)) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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)))
|
|
233
138
|
}
|
|
234
|
-
return { md: assemble(basename(facts.relPath), sections), genTok }
|
|
139
|
+
return { md: assemble(basename(facts.relPath), sections), genTok: 0 }
|
|
235
140
|
}
|
|
236
141
|
|
|
237
|
-
/** One-shot режим: один промпт на весь документ. */
|
|
238
|
-
async function generateOneShot(facts, src, model) {
|
|
239
|
-
const { text, genTok } = await ollamaChat(oneShotMessages(facts, src), { model, numPredict: 1500 })
|
|
240
|
-
let md = stripSignatures(stripSection(text)) // Stage-2 лінт і для one-shot
|
|
241
|
-
if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
|
|
242
|
-
return { md: md + '\n', genTok }
|
|
243
|
-
}
|
|
244
142
|
|
|
245
|
-
|
|
143
|
+
|
|
144
|
+
/** Файли з sym ≥ цього значення одразу йдуть у Tier 2 (без Tier 1 проходу). */
|
|
246
145
|
const DEFAULT_SYM_THRESHOLD = 4
|
|
247
|
-
/** Максимальний час
|
|
146
|
+
/** Максимальний час Tier 1 генерації на один файл перед ескалацією у Tier 2. */
|
|
248
147
|
const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
promise,
|
|
254
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`local timeout after ${ms / 1000}s`)), ms))
|
|
255
|
-
])
|
|
256
|
-
}
|
|
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'). */
|
|
151
|
+
const DEFAULT_CLOUD_MODEL = env.N_CURSOR_DOCGEN_CLOUD_MODEL ?? resolveModel('avg')
|
|
257
152
|
|
|
258
153
|
/**
|
|
259
154
|
* Головний API: файл → { md, genTok, ms, score, issues, tier }.
|
|
260
155
|
*
|
|
261
156
|
* Routing (sym-threshold):
|
|
262
|
-
* sym < symThreshold → Tier 1
|
|
157
|
+
* sym < symThreshold → Tier 1 pi(resolveModel('min'), timeout=5хв) + det-scorer
|
|
263
158
|
* → timeout або det-score < threshold → Tier 2
|
|
264
159
|
* sym >= symThreshold → Pre-routing одразу Tier 2
|
|
265
|
-
*
|
|
266
|
-
* @param {string} cloudModel — модель для Tier 2 генерації (Sonnet за замовч.)
|
|
267
160
|
*/
|
|
268
161
|
export async function generateDoc(
|
|
269
162
|
file,
|
|
270
163
|
{
|
|
271
|
-
model =
|
|
272
|
-
|
|
273
|
-
cloudModel = 'claude-sonnet-4-6',
|
|
164
|
+
model = DEFAULT_LOCAL_MODEL,
|
|
165
|
+
cloudModel = DEFAULT_CLOUD_MODEL,
|
|
274
166
|
threshold = QUALITY_THRESHOLD,
|
|
275
167
|
symThreshold = DEFAULT_SYM_THRESHOLD
|
|
276
168
|
} = {}
|
|
@@ -279,39 +171,24 @@ export async function generateDoc(
|
|
|
279
171
|
const facts = extractFacts(src, file)
|
|
280
172
|
const t0 = Date.now()
|
|
281
173
|
|
|
282
|
-
// Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2
|
|
174
|
+
// Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2
|
|
283
175
|
const complexity = facts.internalSymbols?.length ?? 0
|
|
284
|
-
if (complexity >= symThreshold &&
|
|
285
|
-
const r2 =
|
|
286
|
-
return {
|
|
287
|
-
...r2,
|
|
288
|
-
ms: Date.now() - t0,
|
|
289
|
-
score: null,
|
|
290
|
-
issues: [`pre-routed:sym=${complexity}`],
|
|
291
|
-
tier: 2,
|
|
292
|
-
model: cloudModel
|
|
293
|
-
}
|
|
176
|
+
if (complexity >= symThreshold && cloudModel) {
|
|
177
|
+
const r2 = piOneShot(facts, src, cloudModel)
|
|
178
|
+
return { ...r2, ms: Date.now() - t0, score: null, issues: [`pre-routed:sym=${complexity}`], tier: 2, model: cloudModel }
|
|
294
179
|
}
|
|
295
180
|
|
|
296
|
-
// Tier 1:
|
|
181
|
+
// Tier 1: pi orchestrated (секція за секцією), timeout на секцію = LOCAL_TIMEOUT_MS
|
|
182
|
+
// facts.unsupported → one-shot (структура файлу нестандартна)
|
|
297
183
|
let r
|
|
298
184
|
try {
|
|
299
|
-
|
|
300
|
-
facts
|
|
301
|
-
|
|
302
|
-
: generateOrchestrated(facts, src, model)
|
|
303
|
-
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)
|
|
304
188
|
} catch (e) {
|
|
305
|
-
if (
|
|
306
|
-
const r2 =
|
|
307
|
-
return {
|
|
308
|
-
...r2,
|
|
309
|
-
ms: Date.now() - t0,
|
|
310
|
-
score: null,
|
|
311
|
-
issues: [`local-timeout: ${e.message}`],
|
|
312
|
-
tier: 2,
|
|
313
|
-
model: cloudModel
|
|
314
|
-
}
|
|
189
|
+
if (cloudModel) {
|
|
190
|
+
const r2 = piOneShot(facts, src, cloudModel)
|
|
191
|
+
return { ...r2, ms: Date.now() - t0, score: null, issues: [`tier1-error: ${e.message}`], tier: 2, model: cloudModel }
|
|
315
192
|
}
|
|
316
193
|
throw e
|
|
317
194
|
}
|
|
@@ -319,27 +196,26 @@ export async function generateDoc(
|
|
|
319
196
|
// Stage 2.5: детермінований скоринг (0 токенів) — gate перед Tier 2
|
|
320
197
|
const { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
|
|
321
198
|
|
|
322
|
-
if (detScore < threshold &&
|
|
323
|
-
const r2 =
|
|
199
|
+
if (detScore < threshold && cloudModel) {
|
|
200
|
+
const r2 = piOneShot(facts, src, cloudModel)
|
|
324
201
|
return { ...r2, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 2, model: cloudModel }
|
|
325
202
|
}
|
|
326
203
|
|
|
327
204
|
return { ...r, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 1, model }
|
|
328
205
|
}
|
|
329
206
|
|
|
330
|
-
// CLI: node docgen-gen.mjs <file> [--
|
|
207
|
+
// CLI: node docgen-gen.mjs <file> [--model <m>] [--sym-threshold N] [--tier-only]
|
|
331
208
|
import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
|
|
332
209
|
if (isRunAsCli(import.meta.url)) {
|
|
333
210
|
const args = process.argv.slice(2)
|
|
334
211
|
const file = args.find(a => !a.startsWith('--'))
|
|
335
|
-
const mode = args.includes('--oneshot') ? 'oneshot' : 'orchestrated'
|
|
336
212
|
const tierOnly = args.includes('--tier-only')
|
|
337
213
|
const mi = args.indexOf('--model')
|
|
338
|
-
const model = mi >= 0 ? args[mi + 1] :
|
|
214
|
+
const model = mi >= 0 ? args[mi + 1] : DEFAULT_LOCAL_MODEL
|
|
339
215
|
const si = args.indexOf('--sym-threshold')
|
|
340
216
|
const symThreshold = si >= 0 ? Number(args[si + 1]) : DEFAULT_SYM_THRESHOLD
|
|
341
217
|
if (!file) {
|
|
342
|
-
console.error('Usage: node docgen-gen.mjs <file> [--
|
|
218
|
+
console.error('Usage: node docgen-gen.mjs <file> [--model <m>] [--sym-threshold N] [--tier-only]')
|
|
343
219
|
process.exit(1)
|
|
344
220
|
}
|
|
345
221
|
if (tierOnly) {
|
|
@@ -354,8 +230,8 @@ if (isRunAsCli(import.meta.url)) {
|
|
|
354
230
|
process.stdout.write(`${icon} ${label} | ${file}\n`)
|
|
355
231
|
process.exit(0)
|
|
356
232
|
}
|
|
357
|
-
const r = await generateDoc(file, { model,
|
|
233
|
+
const r = await generateDoc(file, { model, symThreshold })
|
|
358
234
|
const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
|
|
359
|
-
process.stderr.write(`[tier${r.tier}
|
|
235
|
+
process.stderr.write(`[tier${r.tier} pi-orchestrated] ${r.ms}ms / score=${r.score}${issuesTxt}\n`)
|
|
360
236
|
process.stdout.write(r.md)
|
|
361
237
|
}
|
|
@@ -4,12 +4,12 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import { env } from 'node:process'
|
|
7
|
-
import {
|
|
7
|
+
import { resolveModel } from '../../../lib/models.mjs'
|
|
8
8
|
|
|
9
|
-
// Тир за замовчуванням:
|
|
9
|
+
// Тир за замовчуванням: min → avg при ескалації (каскад local→cloud).
|
|
10
10
|
// Перевизначення через N_CURSOR_FIX_MODEL / N_CURSOR_FIX_MODEL_HEAVY.
|
|
11
|
-
export const MODEL = env.N_CURSOR_FIX_MODEL ??
|
|
12
|
-
export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ??
|
|
11
|
+
export const MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
|
|
12
|
+
export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ?? resolveModel('avg')
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Витягує відносні шляхи файлів із violation output.
|
|
@@ -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
|
-
}
|