@dsh-fly/dsh-plugin-fly-smart 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +122 -2
- package/THIRD_PARTY_NOTICES.md +73 -0
- package/assets/smart-debug/SKILL.md +319 -0
- package/assets/smart-diagnose/SKILL.md +170 -0
- package/assets/smart-parallel/SKILL.md +190 -0
- package/assets/smart-plan/SKILL.md +184 -0
- package/assets/smart-review-receive/SKILL.md +230 -0
- package/assets/smart-skills/SKILL.md +96 -0
- package/assets/smart-tdd/SKILL.md +333 -0
- package/assets/smart-verify-claim/SKILL.md +129 -0
- package/assets/smart-worktree/SKILL.md +182 -0
- package/assets/smart-write-skill/SKILL.md +277 -0
- package/cordis.patch.yml +12 -0
- package/index.js +847 -0
- package/lib/client.js +1569 -0
- package/lib/state.mjs +2389 -0
- package/package.json +67 -4
- package/panel/reader.js +481 -0
- package/panel/routes.mjs +393 -0
- package/tools/index.js +1505 -0
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,2389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly smart mode — the phase machine, as pure functions over a task directory.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/smart-mode-design.md` §3 (phases + artifact contract) and §7
|
|
5
|
+
* (on-disk layout). This module owns *only* the state; the tools in `index.js`
|
|
6
|
+
* are thin wrappers, so every transition is unit-testable without cordis.
|
|
7
|
+
*
|
|
8
|
+
* Two rules the design calls load-bearing, both enforced here rather than in the
|
|
9
|
+
* prompt:
|
|
10
|
+
*
|
|
11
|
+
* - **Phase order is checked in code.** An artifact may only be recorded for
|
|
12
|
+
* the phase the task is actually in; anything else throws.
|
|
13
|
+
* - **Nothing advances on its own.** Recording an artifact sets
|
|
14
|
+
* `awaitingGate` (G1..G5); only `passGate` moves the phase forward, which is
|
|
15
|
+
* what makes the user confirmations real rather than narrated.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-plugin-fly-smart/state
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, rmSync } from 'node:fs'
|
|
21
|
+
import { randomBytes, createHash } from 'node:crypto'
|
|
22
|
+
import { homedir } from 'node:os'
|
|
23
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The route mirror the workbench writes and this plugin reads.
|
|
27
|
+
*
|
|
28
|
+
* ## Where it lives (and why not beside the root)
|
|
29
|
+
*
|
|
30
|
+
* The model pair is edited in the `fly.smart` right-drawer page, whose host row
|
|
31
|
+
* (`dsh-plugin-fly-smart-panel`) writes a mirror file that this agent-plane plugin
|
|
32
|
+
* reads: the two planes share no vocabulary, so the interface is a file. v1 put it
|
|
33
|
+
* beside the smart root (`dirname(rootDir)/smart-routes.json`), which was fine
|
|
34
|
+
* while the root was one fixed directory. The root is now the **session's
|
|
35
|
+
* workspace** (`<header.cwd>/.dsh-fly/smart`), so deriving the mirror from it would
|
|
36
|
+
* give every workspace its own route file — the page would write A while the tools
|
|
37
|
+
* read B, with every route silently resolving to nothing. Hence a fixed location:
|
|
38
|
+
* `~/.dsh-fly/smart-routes.json`, overridable only for tests/headless boots via
|
|
39
|
+
* `DSH_FLY_SMART_ROUTES` (`acceptance.md` Y2/M1).
|
|
40
|
+
*
|
|
41
|
+
* It sits *outside* the smart root for the original reason too: a file inside the
|
|
42
|
+
* root would make `existsSync(rootDir)` true on a machine that never ran a task,
|
|
43
|
+
* which is exactly the difference the workbench empty state and the 503 contract
|
|
44
|
+
* are built on.
|
|
45
|
+
*/
|
|
46
|
+
export const ROUTES_FILE = 'smart-routes.json'
|
|
47
|
+
|
|
48
|
+
/** Absolute-path override for the mirror **file** (tests, headless). */
|
|
49
|
+
export const MIRROR_PATH_ENV = 'DSH_FLY_SMART_ROUTES'
|
|
50
|
+
|
|
51
|
+
/** The three-layer schema version this reader understands. */
|
|
52
|
+
export const MIRROR_VERSION = 2
|
|
53
|
+
|
|
54
|
+
/** Where the mirror lives: the env override, else `~/.dsh-fly/smart-routes.json`. */
|
|
55
|
+
export function resolveMirrorPath(env = process.env) {
|
|
56
|
+
const configured = typeof env?.[MIRROR_PATH_ENV] === 'string' ? env[MIRROR_PATH_ENV].trim() : ''
|
|
57
|
+
if (configured !== '') return resolve(configured)
|
|
58
|
+
return join(homedir(), '.dsh-fly', ROUTES_FILE)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The pre-workspace task root: readable, continuable, never written by new tasks. */
|
|
62
|
+
export function resolveLegacyRoot() {
|
|
63
|
+
return join(homedir(), '.dsh-fly', 'smart')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `tasks/<id>/state.json` under one root — the on-disk fact that locates a task. */
|
|
67
|
+
export function taskStateFile(rootDir, id) {
|
|
68
|
+
return join(resolve(rootDir), 'tasks', id, 'state.json')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The workspace task root of a session: `<session.header.cwd>/.dsh-fly/smart`.
|
|
73
|
+
*
|
|
74
|
+
* `header.cwd` is the session's own working directory. It is **not** the host
|
|
75
|
+
* process cwd and **not** the workspace service's root: the three differ routinely
|
|
76
|
+
* (the desktop shell's cwd is `/`), and `process.cwd()` is banned as a fallback for
|
|
77
|
+
* exactly that reason — it would file a task into the repository root. Note
|
|
78
|
+
* `header.cwd` is optional upstream (`dsh-acp/lib/index.js:1267` treats a missing
|
|
79
|
+
* value as "cannot place this session"), so a missing value must be loud, not
|
|
80
|
+
* guessed (`acceptance.md` M11/Y4).
|
|
81
|
+
*
|
|
82
|
+
* @param session - `exec.agent.session`, or anything with a `header`.
|
|
83
|
+
* @returns `{ root, source }` or null.
|
|
84
|
+
*/
|
|
85
|
+
export function workspaceTaskRoot(session) {
|
|
86
|
+
const cwd = session?.header?.cwd
|
|
87
|
+
if (typeof cwd !== 'string' || cwd === '' || !isAbsolute(cwd)) return null
|
|
88
|
+
return { root: join(cwd, '.dsh-fly', 'smart'), source: 'header.cwd' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The preset row's `rootDir`, or null.
|
|
93
|
+
*
|
|
94
|
+
* A row `rootDir` exists for scripted/headless runs (verification), and it would
|
|
95
|
+
* otherwise **silently override the workspace root for every session** — the exact
|
|
96
|
+
* failure the per-call resolution exists to prevent. So it is treated as a
|
|
97
|
+
* declaration that this composition is not a product session and must be spelled
|
|
98
|
+
* out: `allowGlobalRoot: true`. Without it, this throws loudly instead of filing
|
|
99
|
+
* tasks into a directory nobody is looking at (`acceptance.md` Y1/M7).
|
|
100
|
+
*
|
|
101
|
+
* @param config - normalised row config.
|
|
102
|
+
* @returns `{ root, source }` or null.
|
|
103
|
+
* @throws {Error} when a row root is set without `allowGlobalRoot`.
|
|
104
|
+
*/
|
|
105
|
+
export function presetTaskRoot(config = {}) {
|
|
106
|
+
const raw = config.rootDir
|
|
107
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null
|
|
108
|
+
if (config.allowGlobalRoot !== true) {
|
|
109
|
+
throw new Error(`smart: the preset row sets rootDir=${JSON.stringify(raw)} — that would silently override every session's workspace root. Add \`allowGlobalRoot: true\` to the row when this is deliberate (tests / headless runs), or drop rootDir and let the workspace decide.`)
|
|
110
|
+
}
|
|
111
|
+
return { root: resolve(raw.trim()), source: 'preset' }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every root a task could live in, in precedence order.
|
|
116
|
+
*
|
|
117
|
+
* Writes go to {@link resolveTaskRoot}; this list is for **finding** an existing
|
|
118
|
+
* task (and for the read fallbacks). The env override is deliberately a read
|
|
119
|
+
* candidate only: `DSH_FLY_SMART_ROOT` does not redirect the agent's writes
|
|
120
|
+
* (`acceptance.md` M17) — a fixture that only sets it has not isolated the agent.
|
|
121
|
+
*
|
|
122
|
+
* @param input - `{ config, env, session }`.
|
|
123
|
+
* @returns `{ roots, primary }`.
|
|
124
|
+
*/
|
|
125
|
+
export function candidateTaskRoots({ config = {}, env = process.env, session = undefined } = {}) {
|
|
126
|
+
const roots = []
|
|
127
|
+
const push = entry => {
|
|
128
|
+
if (entry === null || entry === undefined) return
|
|
129
|
+
if (!roots.some(existing => existing.root === entry.root)) roots.push(entry)
|
|
130
|
+
}
|
|
131
|
+
push(presetTaskRoot(config))
|
|
132
|
+
const fromEnv = typeof env?.[ROOT_DIR_ENV] === 'string' ? env[ROOT_DIR_ENV].trim() : ''
|
|
133
|
+
if (fromEnv !== '') push({ root: resolve(fromEnv), source: 'env' })
|
|
134
|
+
push(workspaceTaskRoot(session))
|
|
135
|
+
push({ root: resolveLegacyRoot(), source: 'legacy' })
|
|
136
|
+
return { roots, primary: roots[0]?.root ?? resolveLegacyRoot() }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The root this call **writes** to: preset row (guarded) → session workspace → legacy.
|
|
141
|
+
*
|
|
142
|
+
* @param input - `{ config, session }`.
|
|
143
|
+
* @returns `{ root, source }`.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveTaskRoot({ config = {}, session = undefined } = {}) {
|
|
146
|
+
const preset = presetTaskRoot(config)
|
|
147
|
+
if (preset !== null) return preset
|
|
148
|
+
const workspace = workspaceTaskRoot(session)
|
|
149
|
+
if (workspace !== null) return workspace
|
|
150
|
+
// 归属未知 ⇒ **大声报错**,不静默落到全局老根。
|
|
151
|
+
//
|
|
152
|
+
// 老根只剩"找任务"的读候选(`candidateTaskRoots`):旧任务仍然可读、可继续、可归档。
|
|
153
|
+
// 但**写**进老根等于让任务不属于任何工作区 —— 它在每个工作区的面板里都会露头、只有它
|
|
154
|
+
// 会跨工作区累积、而且没有工作区认领它。这与 `presetTaskRoot` 的规矩是同一条:静默覆盖
|
|
155
|
+
// "每个会话的工作区根"必须显式声明(`rootDir` + `allowGlobalRoot: true`)。
|
|
156
|
+
throw new Error(
|
|
157
|
+
'smart: this call has no workspace — a session without an absolute `header.cwd` has no task '
|
|
158
|
+
+ 'root, and the legacy root is read-only now (it still serves old tasks: read / continue / '
|
|
159
|
+
+ 'archive). Give the session a real working directory, or declare the root explicitly in the '
|
|
160
|
+
+ 'preset row: `rootDir: <abs>` together with `allowGlobalRoot: true` (scripted/headless runs).',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Find an existing task directory across the candidate roots.
|
|
166
|
+
*
|
|
167
|
+
* The disk decides which copy is read (`state.json` must exist), so a recorded
|
|
168
|
+
* `rootDir` inside a task cannot redirect the read; the same-id-in-two-roots case
|
|
169
|
+
* resolves in precedence order and says so.
|
|
170
|
+
*
|
|
171
|
+
* @param taskId - task id.
|
|
172
|
+
* @param candidates - roots in precedence order (`{root, source}` or plain strings).
|
|
173
|
+
* @returns `{ root, source, taskDir, note? }` or null.
|
|
174
|
+
*/
|
|
175
|
+
export function locateTask(taskId, candidates) {
|
|
176
|
+
assertTaskId(taskId)
|
|
177
|
+
const hits = []
|
|
178
|
+
for (const entry of candidates) {
|
|
179
|
+
const root = typeof entry === 'string' ? entry : entry.root
|
|
180
|
+
if (existsSync(taskStateFile(root, taskId))) hits.push({ root, source: typeof entry === 'string' ? null : entry.source })
|
|
181
|
+
}
|
|
182
|
+
if (hits.length === 0) return null
|
|
183
|
+
return {
|
|
184
|
+
root: hits[0].root,
|
|
185
|
+
source: hits[0].source,
|
|
186
|
+
taskDir: join(resolve(hits[0].root), 'tasks', taskId),
|
|
187
|
+
...hits.length > 1 ? { note: `task "${taskId}" exists in ${hits.length} roots; using ${hits[0].root}` } : {},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Environment override for the smart root **read** candidates (see {@link candidateTaskRoots}). */
|
|
192
|
+
export const ROOT_DIR_ENV = 'DSH_FLY_SMART_ROOT'
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse one route value into `{ provider, model, reasoningEffort? }`.
|
|
196
|
+
*
|
|
197
|
+
* The workbench's text field carries `"provider/model"` (with an optional
|
|
198
|
+
* `@effort`); the preset row carries an object. Both are accepted here so the two
|
|
199
|
+
* sources cannot drift into different shapes.
|
|
200
|
+
*
|
|
201
|
+
* @param value - `"provider/model[@effort]"`, `{ provider, model }`, or empty.
|
|
202
|
+
* @returns a route, or undefined when nothing usable was given.
|
|
203
|
+
*/
|
|
204
|
+
export function parseRouteValue(value) {
|
|
205
|
+
if (value === undefined || value === null) return undefined
|
|
206
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
207
|
+
const { provider, model, reasoningEffort } = value
|
|
208
|
+
if (typeof provider !== 'string' || provider === '' || typeof model !== 'string' || model === '') return undefined
|
|
209
|
+
return reasoningEffort === undefined ? { provider, model } : { provider, model, reasoningEffort }
|
|
210
|
+
}
|
|
211
|
+
if (typeof value !== 'string') return undefined
|
|
212
|
+
const text = value.trim()
|
|
213
|
+
if (text === '') return undefined
|
|
214
|
+
const cut = text.indexOf('/')
|
|
215
|
+
if (cut <= 0 || cut === text.length - 1) return undefined
|
|
216
|
+
const provider = text.slice(0, cut).trim()
|
|
217
|
+
const rest = text.slice(cut + 1).trim()
|
|
218
|
+
if (provider === '' || rest === '') return undefined
|
|
219
|
+
const at = rest.indexOf('@')
|
|
220
|
+
if (at <= 0) return { provider, model: rest }
|
|
221
|
+
const model = rest.slice(0, at).trim()
|
|
222
|
+
const effort = rest.slice(at + 1).trim()
|
|
223
|
+
return model === '' ? undefined : effort === '' ? { provider, model } : { provider, model, reasoningEffort: effort }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read the mirror into its three layers, tolerating every kind of "not there yet".
|
|
228
|
+
*
|
|
229
|
+
* A missing, unreadable or malformed mirror is not an error — it means "the user
|
|
230
|
+
* has not chosen a pair", which the tools already report loudly at call time. The
|
|
231
|
+
* top-level `hand`/`brain` keys are the **legacy** layer: v2 readers prefer
|
|
232
|
+
* `default`, and the writer keeps the legacy key mirrored so an older agent (which
|
|
233
|
+
* knows only that key) keeps working during the compatibility window.
|
|
234
|
+
*
|
|
235
|
+
* @param mirrorPath - absolute mirror path (defaults to the resolved location).
|
|
236
|
+
* @returns `{ version, exists, default, sessions, legacy }`.
|
|
237
|
+
*/
|
|
238
|
+
export function readMirrorLayers(mirrorPath = resolveMirrorPath()) {
|
|
239
|
+
let raw = null
|
|
240
|
+
try {
|
|
241
|
+
raw = JSON.parse(readFileSync(mirrorPath, 'utf8'))
|
|
242
|
+
} catch {
|
|
243
|
+
raw = null
|
|
244
|
+
}
|
|
245
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) raw = {}
|
|
246
|
+
const sessions = {}
|
|
247
|
+
if (raw.sessions !== null && typeof raw.sessions === 'object' && !Array.isArray(raw.sessions)) {
|
|
248
|
+
for (const [id, value] of Object.entries(raw.sessions)) {
|
|
249
|
+
const hand = parseRouteValue(value?.hand)
|
|
250
|
+
const review = parseRouteValue(value?.review)
|
|
251
|
+
if (hand !== undefined || review !== undefined) sessions[id] = {
|
|
252
|
+
...hand === undefined ? {} : { hand },
|
|
253
|
+
...review === undefined ? {} : { review },
|
|
254
|
+
at: typeof value?.at === 'string' ? value.at : null,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const v2Default = parseRouteValue(raw.default?.hand)
|
|
259
|
+
const v3Review = parseRouteValue(raw.default?.review)
|
|
260
|
+
const legacyHand = parseRouteValue(raw.hand)
|
|
261
|
+
const legacyBrain = parseRouteValue(raw.brain)
|
|
262
|
+
return {
|
|
263
|
+
version: Number.isInteger(raw.version) ? raw.version : 1,
|
|
264
|
+
exists: raw.version !== undefined || raw.hand !== undefined || raw.review !== undefined || raw.default !== undefined || raw.sessions !== undefined,
|
|
265
|
+
default: v2Default ?? legacyHand,
|
|
266
|
+
review: v3Review,
|
|
267
|
+
defaultAt: typeof raw.default?.at === 'string' ? raw.default.at : typeof raw.at === 'string' ? raw.at : null,
|
|
268
|
+
sessions,
|
|
269
|
+
legacy: { hand: legacyHand, brain: legacyBrain },
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The hand route a **session** resolves to inside the mirror.
|
|
275
|
+
*
|
|
276
|
+
* Read order is `sessions[<id>]` → `default` (which already fell back to the legacy
|
|
277
|
+
* top-level key); the preset row's own config sits above both and is applied by the
|
|
278
|
+
* caller, which is the only place that has it.
|
|
279
|
+
*
|
|
280
|
+
* An unusable session id is not an error: it means "no per-session layer", so the
|
|
281
|
+
* call falls through to the default. The tool surface is exercised with bare `{}`
|
|
282
|
+
* session objects throughout the suite, and refusing there would make every one of
|
|
283
|
+
* those specs red for a reason that has nothing to do with routing.
|
|
284
|
+
*
|
|
285
|
+
* @param layers - {@link readMirrorLayers} result.
|
|
286
|
+
* @param sessionId - `exec.agent.session.id`, or undefined.
|
|
287
|
+
* @returns `{ route, source }` or null.
|
|
288
|
+
*/
|
|
289
|
+
export function mirrorHandRoute(layers, sessionId) {
|
|
290
|
+
const session = isUsableSessionId(sessionId) ? layers.sessions[sessionId] : undefined
|
|
291
|
+
if (session !== undefined) return { route: session.hand, source: 'session' }
|
|
292
|
+
if (layers.default !== undefined) return { route: layers.default, source: 'default' }
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Is this session id usable as a mirror key?
|
|
298
|
+
*
|
|
299
|
+
* Shape is **not** whitelisted: the engine mints `session-<n>` from a counter
|
|
300
|
+
* (`dsh-session` package `lib/index.js:1648`) as well as UUIDs, and CLI users pick
|
|
301
|
+
* their own ids. What is refused is what cannot be a key at all — empty, overlong,
|
|
302
|
+
* separators, `..`, control characters. The panel half pins the same rules, and a
|
|
303
|
+
* cross-package test walks one table through both so they cannot drift.
|
|
304
|
+
*
|
|
305
|
+
* @param value - candidate id.
|
|
306
|
+
* @returns whether the id may be used.
|
|
307
|
+
*/
|
|
308
|
+
export function isUsableSessionId(value) {
|
|
309
|
+
if (typeof value !== 'string') return false
|
|
310
|
+
if (value.length === 0 || value.length > 200) return false
|
|
311
|
+
if (value.includes('..') || value.includes('/') || value.includes('\\')) return false
|
|
312
|
+
// eslint-disable-next-line no-control-regex
|
|
313
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) return false
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The full ladder for one call: preset row → mirror session → mirror default.
|
|
319
|
+
*
|
|
320
|
+
* @param input - `{ config, session, mirrorPath, env }`.
|
|
321
|
+
* @returns `{ route, source }` or null when the user has configured nothing.
|
|
322
|
+
*/
|
|
323
|
+
export function currentHandRoute({ config = {}, session = undefined, mirrorPath = undefined, env = process.env } = {}) {
|
|
324
|
+
if (config.handRoute !== undefined) return { route: config.handRoute, source: 'preset' }
|
|
325
|
+
const layers = readMirrorLayers(mirrorPath ?? resolveMirrorPath(env))
|
|
326
|
+
return mirrorHandRoute(layers, session?.id)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function mirrorReviewRoute(layers, sessionId) {
|
|
330
|
+
const session = isUsableSessionId(sessionId) ? layers.sessions[sessionId] : undefined
|
|
331
|
+
if (session?.review !== undefined) return { route: session.review, source: 'session' }
|
|
332
|
+
if (layers.review !== undefined) return { route: layers.review, source: 'default' }
|
|
333
|
+
return null
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Resolve the review route from row config, then the mirror. */
|
|
337
|
+
/**
|
|
338
|
+
* 返工升档(superpowers:*"fix-loop escalation (rounds 4-5): use a model at least one
|
|
339
|
+
* tier above"*)。
|
|
340
|
+
*
|
|
341
|
+
* smart 的返工是**用户门控**的(`maxRework` + 人加额),但每次返工后那份稿子还是要过
|
|
342
|
+
* review child —— 一个反复修不好的点,值得让评审那侧看得更用力。
|
|
343
|
+
* 规则:同一路由,**`attempts ≥ 2` 时把 effort 提到该模型支持的最高档**;查不到档位表就
|
|
344
|
+
* 原样返回并如实标注 `escalated: false`(不猜、不造档位名)。
|
|
345
|
+
*
|
|
346
|
+
* **数的是"返工次数",不是"第几稿"**(容易读反,写清免得以后又有人记成"第 2 稿起"):
|
|
347
|
+
* 第一次写 `attempts = 0`,第 1 次返工 = 1,**第 2 次返工 = 2 才升档 —— 也就是第 3 稿起**。
|
|
348
|
+
*
|
|
349
|
+
* @param route - `{ provider, model, reasoningEffort? }`。
|
|
350
|
+
* @param attempts - 本阶段已返工次数(0 = 第一次写)。
|
|
351
|
+
* @param supported - 该模型支持的 effort id 列表,查不到给 `undefined`。
|
|
352
|
+
* @returns `{ route, escalated, attempts }`。
|
|
353
|
+
*/
|
|
354
|
+
export function escalatedEffort(route, attempts, supported) {
|
|
355
|
+
if (attempts < 2 || route === undefined || route === null) return { route, escalated: false, attempts }
|
|
356
|
+
if (!Array.isArray(supported) || supported.length === 0) return { route, escalated: false, attempts }
|
|
357
|
+
const highest = supported[supported.length - 1]
|
|
358
|
+
if (highest === undefined || highest === route.reasoningEffort) return { route, escalated: false, attempts }
|
|
359
|
+
return { route: { ...route, reasoningEffort: highest }, escalated: true, attempts }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 终审(G5 那一关的评审)**不许回落**。
|
|
364
|
+
*
|
|
365
|
+
* superpowers:*"dispatch it on the most capable available model, not the session
|
|
366
|
+
* default"* —— 而回落链(`review → hand → session`)最坏的情况是终审跑在**执行者用的
|
|
367
|
+
* 那条**上:自己审自己。所以终审必须是**显式配置的** review 路由
|
|
368
|
+
* (`preset` / `session` / `default`),来源是 `*-fallback` 一律拒绝。
|
|
369
|
+
*
|
|
370
|
+
* @param source - `currentReviewRoute` 回的来源标识。
|
|
371
|
+
* @throws 来源是回落时,指出该配什么。
|
|
372
|
+
*/
|
|
373
|
+
export function assertFinalReviewRoute(source) {
|
|
374
|
+
if (source === 'preset' || source === 'session' || source === 'default') return
|
|
375
|
+
throw new Error(
|
|
376
|
+
`smart: the final review refuses a fallback route (got "${source ?? 'none'}") — 终审不许跑在回落出来的路由上:`
|
|
377
|
+
+ `那样最坏情况是执行者审自己。给 tool-fly-smart 的行配置加显式的 reviewRoute: { provider, model }`
|
|
378
|
+
+ `(有 Fly 工作台时在右抽屉「Smart 工作流 → 三模型路由」里选 review),然后重试。`,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function currentReviewRoute({ config = {}, session = undefined, mirrorPath = undefined, env = process.env } = {}) {
|
|
383
|
+
if (config.reviewRoute !== undefined) return { route: config.reviewRoute, source: 'preset' }
|
|
384
|
+
if (config.handRoute !== undefined) return { route: config.handRoute, source: 'hand-fallback' }
|
|
385
|
+
const layers = readMirrorLayers(mirrorPath ?? resolveMirrorPath(env))
|
|
386
|
+
const review = mirrorReviewRoute(layers, session?.id)
|
|
387
|
+
if (review !== null) return review
|
|
388
|
+
const hand = mirrorHandRoute(layers, session?.id)
|
|
389
|
+
if (hand !== null) return { ...hand, source: `${hand.source}-fallback` }
|
|
390
|
+
if (config.handRoute !== undefined) return { route: config.handRoute, source: 'hand-fallback' }
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Back-compat shim: the legacy shape `{ brain, hand }` of the mirror.
|
|
395
|
+
*
|
|
396
|
+
* Kept because the brain half of the pair is still read (never written) from the
|
|
397
|
+
* mirror: the page writes only the hand route, so a user who configured the pair in
|
|
398
|
+
* the upstream Settings form keeps their brain route. `consumer: null` is the honest
|
|
399
|
+
* note that nothing in this plugin reads `brain` for a spawn.
|
|
400
|
+
*
|
|
401
|
+
* @param mirrorPath - absolute mirror path.
|
|
402
|
+
* @returns `{ brain?, hand? }` in the old shape.
|
|
403
|
+
*/
|
|
404
|
+
export function readRoutesMirror(mirrorPath = resolveMirrorPath()) {
|
|
405
|
+
const layers = readMirrorLayers(mirrorPath)
|
|
406
|
+
return {
|
|
407
|
+
...layers.legacy.brain === undefined ? {} : { brain: layers.legacy.brain },
|
|
408
|
+
...layers.default === undefined ? {} : { hand: layers.default },
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Every phase a task can be in, in order. */
|
|
413
|
+
export const PHASES = ['brainstorm', 'design', 'plan', 'acceptance', 'executing', 'reviewing', 'done', 'cancelled']
|
|
414
|
+
|
|
415
|
+
/** The four design phases, each ending at a small user gate (G1–G4). */
|
|
416
|
+
export const DESIGN_PHASES = ['brainstorm', 'design', 'plan', 'acceptance']
|
|
417
|
+
|
|
418
|
+
/** Artifact file per phase (the contract §3 checks). */
|
|
419
|
+
/**
|
|
420
|
+
* 工作流策略版本(**opt-in**,不改变既有任务语义)。
|
|
421
|
+
*
|
|
422
|
+
* - `v1`:四阶段各派一次独立 review consult + 每份产物一次 review briefing(历史行为)。
|
|
423
|
+
* - `optimized`:brainstorm 不派独立 consult(brain 出用户版要点,review 从 design 才开始)
|
|
424
|
+
* —— 候选方案阶段还没有可执行的东西可审,那一次评审是纯开销。
|
|
425
|
+
*
|
|
426
|
+
* 版本在 `createTask` 时**记进 state**:之后即使行配置改了,在途任务仍按自己那份跑完。
|
|
427
|
+
*/
|
|
428
|
+
export const WORKFLOWS = ['v1', 'optimized']
|
|
429
|
+
|
|
430
|
+
/** 每个工作流下**必须**有独立 consult 记录的设计阶段。 */
|
|
431
|
+
export const CONSULT_PHASES = {
|
|
432
|
+
v1: ['brainstorm', 'design', 'plan', 'acceptance'],
|
|
433
|
+
optimized: ['design', 'plan', 'acceptance'],
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** 这个任务需要 consult 的阶段(未知版本按 v1 兜底,绝不静默放宽)。 */
|
|
437
|
+
export function consultPhasesFor(state) {
|
|
438
|
+
// `size: 'light'` 明确是"零 review child"的档位:要点由 brain 自写,也就没有独立的
|
|
439
|
+
// 可行性评审。这不是**静默放宽**(判据仍在、只是判据本身说这里没有这一步),status
|
|
440
|
+
// 会如实报 `phases` 与 `size`,用户看得见自己选了什么。
|
|
441
|
+
if (state?.size === 'light') return []
|
|
442
|
+
return CONSULT_PHASES[state?.workflow] ?? CONSULT_PHASES.v1
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export const ARTIFACT_FILE = {
|
|
446
|
+
brainstorm: 'brainstorm.md',
|
|
447
|
+
design: 'design.md',
|
|
448
|
+
plan: 'plan.md',
|
|
449
|
+
acceptance: 'acceptance.md',
|
|
450
|
+
executing: 'execution-report.md',
|
|
451
|
+
reviewing: 'review.md',
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* optimized 工作流把 `acceptance.md` 写成 **plan 相位的伴随产物**(不改相位表)。
|
|
456
|
+
*
|
|
457
|
+
* 为什么不动相位表:`PHASES` / `NEXT_PHASE` / `GATE_AFTER` 是持久化状态与面板的公共契约,
|
|
458
|
+
* 改它等于给所有在途任务换语义。伴随文件让"计划与验收一起写、一起审、一道门"成立,而
|
|
459
|
+
* 盘上的相位名仍是 `plan` —— 旧任务、面板、`smart_execute` 的读取路径全都不用改。
|
|
460
|
+
*/
|
|
461
|
+
export const COMPANION_ARTIFACT = { plan: 'acceptance.md' }
|
|
462
|
+
|
|
463
|
+
/** 这个相位的伴随产物文件名(没有则 null)。 */
|
|
464
|
+
export function companionFileOf(phase) {
|
|
465
|
+
return COMPANION_ARTIFACT[phase] ?? null
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** 主产物 + 伴随产物的正文(伴随缺失时就是主产物),供"未决小节"这类整段判定用。 */
|
|
469
|
+
export function artifactTextWithCompanion(rootDir, id, phase) {
|
|
470
|
+
const main = readFileSync.length === 0 ? null : null // 占位,真正的读取在下面(保持纯函数形状)
|
|
471
|
+
void main
|
|
472
|
+
const parts = []
|
|
473
|
+
try {
|
|
474
|
+
const file = ARTIFACT_FILE[phase]
|
|
475
|
+
if (file !== undefined) parts.push(readFileSync(join(taskDir(rootDir, id), file), 'utf8'))
|
|
476
|
+
} catch { /* 主产物缺失:交给调用方各自的缺口检查 */ }
|
|
477
|
+
const companion = companionFileOf(phase)
|
|
478
|
+
if (companion !== null) {
|
|
479
|
+
try {
|
|
480
|
+
parts.push(readFileSync(join(taskDir(rootDir, id), companion), 'utf8'))
|
|
481
|
+
} catch { /* 伴随缺失同理 */ }
|
|
482
|
+
}
|
|
483
|
+
return parts.join('\n')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Gate that closes each phase's step. */
|
|
487
|
+
/**
|
|
488
|
+
* **shape**:任务形态,决定这个任务走哪些相位(S5)。
|
|
489
|
+
*
|
|
490
|
+
* 目的:让"加个 helper 函数"不被 6 相位 / 5–11 张卡 / 6–12 次子代理碾压。
|
|
491
|
+
* 每个 shape 是 `PHASES` 的一个**有序子集**;裁掉的相位是**不存在**,不是
|
|
492
|
+
* "存在但不查" —— 闸门不静默放宽,是这条设计的底线(见 optimized 那次踩的坑)。
|
|
493
|
+
*
|
|
494
|
+
* | shape | 相位 | 门 | 典型 |
|
|
495
|
+
* | --- | --- | --- | --- |
|
|
496
|
+
* | `feature` | 六相位 | G1–G5 | 完整交付一个功能 |
|
|
497
|
+
* | `fix` | brainstorm → plan → executing → reviewing | G1、G3、G5 | 修一处、要留证据 |
|
|
498
|
+
* | `review` | 只有 reviewing | G5 | 看看这个 diff |
|
|
499
|
+
* | `investigation` | 只有 brainstorm | G1 | 调查个问题、出结论 |
|
|
500
|
+
*/
|
|
501
|
+
export const SHAPES = {
|
|
502
|
+
feature: ['brainstorm', 'design', 'plan', 'acceptance', 'executing', 'reviewing'],
|
|
503
|
+
fix: ['brainstorm', 'plan', 'executing', 'reviewing'],
|
|
504
|
+
review: ['reviewing'],
|
|
505
|
+
investigation: ['brainstorm'],
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* **size**:成本档位,决定设计相位要不要派 review child。
|
|
510
|
+
*
|
|
511
|
+
* `light` ⇒ 设计相位的用户要点由 brain 自写(**零 review child**);`standard` / `full`
|
|
512
|
+
* 照常派。与 shape 正交:shape 管"走几步",size 管"每步花多少"。
|
|
513
|
+
*/
|
|
514
|
+
export const SIZES = ['light', 'standard', 'full']
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* 这个任务的相位序列(有序)。
|
|
518
|
+
*
|
|
519
|
+
* 两件事共同决定:`shape`(走哪些)与 `workflow: optimized`(把 `acceptance` 并进
|
|
520
|
+
* `plan`,那一相位**不存在**)。
|
|
521
|
+
* @param state - 任务 state(`shape` / `workflow` 缺省按 v1 全套)。
|
|
522
|
+
* @returns 相位数组,例如 `['brainstorm','plan','executing','reviewing']`。
|
|
523
|
+
*/
|
|
524
|
+
/**
|
|
525
|
+
* 一个 git 工作树当前的 HEAD(只读 `.git`,不跑命令)。
|
|
526
|
+
*
|
|
527
|
+
* **用途只有一个**:执行派发前记下 BASE。superpowers 明确警告过
|
|
528
|
+
* *"never `HEAD~1`, which silently drops all but the last commit of a multi-commit task"* ——
|
|
529
|
+
* 派发**之后**再算基线,多 commit 的任务会把前面的提交全漏掉。所以基线必须在派发前取。
|
|
530
|
+
*
|
|
531
|
+
* 只用 `node:fs`(工具层没有 shell 座位,也不该为此加一个):读 `.git/HEAD`,
|
|
532
|
+
* 是 `ref:` 就再解一层(`refs/heads/*` 或 `packed-refs`);`.git` 是文件(worktree/submodule)
|
|
533
|
+
* 时按 `gitdir: <path>` 走。解不出就返回 null —— **不是 git 工作树不算错**。
|
|
534
|
+
*
|
|
535
|
+
* @param dir - 工作树根(或其中任意路径,向上找 `.git`)。
|
|
536
|
+
* @returns 40 位 sha,或 null。
|
|
537
|
+
*/
|
|
538
|
+
export function gitHeadOf(dir) {
|
|
539
|
+
try {
|
|
540
|
+
let current = resolve(dir)
|
|
541
|
+
let gitDir = null
|
|
542
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
543
|
+
const candidate = join(current, '.git')
|
|
544
|
+
if (existsSync(candidate)) {
|
|
545
|
+
const stat = statSync(candidate)
|
|
546
|
+
if (stat.isDirectory()) { gitDir = candidate; break }
|
|
547
|
+
// worktree / submodule:`.git` 是文件,内容形如 `gitdir: /path/to/.git/worktrees/x`
|
|
548
|
+
const pointer = readFileSync(candidate, 'utf8').trim()
|
|
549
|
+
const target = /^gitdir:\s*(.+)$/u.exec(pointer)?.[1]?.trim()
|
|
550
|
+
if (target === undefined) return null
|
|
551
|
+
gitDir = isAbsolute(target) ? target : resolve(current, target)
|
|
552
|
+
break
|
|
553
|
+
}
|
|
554
|
+
const parent = dirname(current)
|
|
555
|
+
if (parent === current) return null
|
|
556
|
+
current = parent
|
|
557
|
+
}
|
|
558
|
+
if (gitDir === null) return null
|
|
559
|
+
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim()
|
|
560
|
+
const sha = /^[0-9a-f]{40}$/iu.test(head) ? head : null
|
|
561
|
+
if (sha !== null) return sha
|
|
562
|
+
const ref = /^ref:\s*(.+)$/u.exec(head)?.[1]?.trim()
|
|
563
|
+
if (ref === undefined) return null
|
|
564
|
+
const refFile = join(gitDir, ref)
|
|
565
|
+
if (existsSync(refFile)) {
|
|
566
|
+
const value = readFileSync(refFile, 'utf8').trim()
|
|
567
|
+
return /^[0-9a-f]{40}$/iu.test(value) ? value : null
|
|
568
|
+
}
|
|
569
|
+
// 打包引用(`packed-refs`):`<sha> <ref>` 一行一条。
|
|
570
|
+
const packed = join(gitDir, 'packed-refs')
|
|
571
|
+
if (!existsSync(packed)) return null
|
|
572
|
+
for (const line of readFileSync(packed, 'utf8').split('\n')) {
|
|
573
|
+
const match = /^([0-9a-f]{40})\s+(.+)$/u.exec(line.trim())
|
|
574
|
+
if (match !== null && match[2] === ref) return match[1]
|
|
575
|
+
}
|
|
576
|
+
return null
|
|
577
|
+
} catch {
|
|
578
|
+
return null
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function phasesFor(state) {
|
|
583
|
+
const shape = SHAPES[state?.shape] ?? SHAPES.feature
|
|
584
|
+
return state?.workflow === 'optimized' ? shape.filter(phase => phase !== 'acceptance') : shape
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 这个任务里 `from` 的下一个相位;没有下一个(或 `from` 不在这个任务里)⇒ null。
|
|
589
|
+
* @param state - 任务 state。
|
|
590
|
+
* @param from - 当前相位。
|
|
591
|
+
*/
|
|
592
|
+
export function nextPhaseFor(state, from) {
|
|
593
|
+
const list = phasesFor(state)
|
|
594
|
+
const index = list.indexOf(from)
|
|
595
|
+
return index === -1 || index + 1 >= list.length ? null : list[index + 1]
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* 这个任务的用户要点由谁写:`'review'`(派 review child)还是 `'inline'`(brain 自写)。
|
|
600
|
+
*
|
|
601
|
+
* 两个来源都是"省一次子代理调用":`workflow: optimized` 的 brainstorm 本来就是 inline;
|
|
602
|
+
* `size: 'light'` 把**全部设计相位**都变成 inline(轻任务不值得为每步起一个评审)。
|
|
603
|
+
*/
|
|
604
|
+
export function briefingSourceFor(state, phase) {
|
|
605
|
+
if (state?.size === 'light' && DESIGN_PHASES.includes(phase)) return 'inline'
|
|
606
|
+
if (state?.workflow === 'optimized' && phase === 'brainstorm') return 'inline'
|
|
607
|
+
return 'review'
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export const GATE_AFTER = {
|
|
611
|
+
brainstorm: 'G1',
|
|
612
|
+
design: 'G2',
|
|
613
|
+
plan: 'G3',
|
|
614
|
+
acceptance: 'G4',
|
|
615
|
+
reviewing: 'G5',
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Phase the machine moves to once a gate passes. */
|
|
619
|
+
/**
|
|
620
|
+
* 产物里标记"还有只有用户能定的问题"的小节关键词。
|
|
621
|
+
*
|
|
622
|
+
* 门的机械判据就挂在它上面:`## …需要你裁决…` 这种小节还在,门就不开(见 recordArtifact
|
|
623
|
+
* 与 gateGaps)。规则刻意做得**钝**——一个小节标题就能拦下整道门,因为"聪明"的解析漏掉一节
|
|
624
|
+
* 就会让门在裁决未收齐时打开,而那正是这套东西要堵的洞。模型要做的事也只有一件:收齐裁决后
|
|
625
|
+
* 把标题里的这几个字去掉(换成「已裁决」并逐条写上用户的选择)。
|
|
626
|
+
*/
|
|
627
|
+
export const DECISION_MARKER = '需要你裁决'
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Find the open-decision sections of an artifact.
|
|
631
|
+
*
|
|
632
|
+
* @param markdown - artifact body (null/undefined tolerated).
|
|
633
|
+
* @returns `{ items, headings }`: how many list items sit under such sections, and the
|
|
634
|
+
* headings themselves (empty `headings` = nothing open).
|
|
635
|
+
*/
|
|
636
|
+
export function openDecisions(markdown) {
|
|
637
|
+
if (typeof markdown !== 'string' || markdown.trim() === '') return { items: 0, headings: [] }
|
|
638
|
+
const headings = []
|
|
639
|
+
let items = 0
|
|
640
|
+
let inside = false
|
|
641
|
+
for (const raw of markdown.split('\n')) {
|
|
642
|
+
const line = raw.trim()
|
|
643
|
+
if (/^#{1,6}\s/u.test(line)) {
|
|
644
|
+
inside = line.includes(DECISION_MARKER)
|
|
645
|
+
if (inside) headings.push(line.replace(/^#{1,6}\s*/u, '').trim())
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
if (inside && /^([-*+]|\d+[.)])\s+\S/u.test(line)) items += 1
|
|
649
|
+
}
|
|
650
|
+
return { items, headings }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const NEXT_PHASE = {
|
|
654
|
+
brainstorm: 'design',
|
|
655
|
+
design: 'plan',
|
|
656
|
+
plan: 'acceptance',
|
|
657
|
+
acceptance: 'executing',
|
|
658
|
+
executing: 'reviewing',
|
|
659
|
+
reviewing: 'done',
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const TASK_ID = /^[A-Za-z0-9._-]+$/u
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Validate a task id: safe for a path segment, and never a traversal.
|
|
666
|
+
* @param id - candidate id.
|
|
667
|
+
* @returns the id, unchanged.
|
|
668
|
+
* @throws {Error} when the id could escape `tasks/`.
|
|
669
|
+
*/
|
|
670
|
+
export function assertTaskId(id) {
|
|
671
|
+
if (typeof id !== 'string' || id.length === 0 || id.length > 80) throw new Error('smart: task id must be a non-empty string (≤80 chars)')
|
|
672
|
+
if (!TASK_ID.test(id) || id === '.' || id === '..' || id.includes('..')) throw new Error(`smart: unsafe task id "${id}" (allowed: letters, digits, dot, dash, underscore)`)
|
|
673
|
+
return id
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Resolve row config into the shape the tools use, with defaults and checks.
|
|
678
|
+
*
|
|
679
|
+
* The two routes are deliberately opaque `{provider, model, reasoningEffort?}`
|
|
680
|
+
* pairs: the design keeps the pair configurable (Pro/Flash, cross-provider, …)
|
|
681
|
+
* and never hard-codes a model name.
|
|
682
|
+
*
|
|
683
|
+
* @param raw - the preset row's `config` object (may be undefined).
|
|
684
|
+
* @returns normalised config.
|
|
685
|
+
* @throws {Error} on a malformed route.
|
|
686
|
+
*/
|
|
687
|
+
export function resolveConfig(raw = {}) {
|
|
688
|
+
const route = (value, key) => {
|
|
689
|
+
if (value === undefined) return undefined
|
|
690
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`smart: ${key} must be an object { provider, model, reasoningEffort? }`)
|
|
691
|
+
const { provider, model, reasoningEffort } = value
|
|
692
|
+
if (typeof provider !== 'string' || provider.length === 0) throw new Error(`smart: ${key}.provider must be a non-empty string`)
|
|
693
|
+
if (typeof model !== 'string' || model.length === 0) throw new Error(`smart: ${key}.model must be a non-empty string`)
|
|
694
|
+
if (reasoningEffort !== undefined && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) throw new Error(`smart: ${key}.reasoningEffort must be a non-empty string when present`)
|
|
695
|
+
return reasoningEffort === undefined ? { provider, model } : { provider, model, reasoningEffort }
|
|
696
|
+
}
|
|
697
|
+
// **每阶段**的返工额度(不是整任务的总预算):某个阶段反复迭代不该把后面阶段的额度吃掉,
|
|
698
|
+
// 否则"设计稿改了三次"就直接等于"计划阶段一次也不许改"。
|
|
699
|
+
const maxRework = raw.maxRework === undefined ? 5 : Number(raw.maxRework)
|
|
700
|
+
if (!Number.isInteger(maxRework) || maxRework < 0) throw new Error('smart: maxRework must be a non-negative integer (per phase)')
|
|
701
|
+
if (raw.rootDir !== undefined && (typeof raw.rootDir !== 'string' || raw.rootDir.length === 0)) throw new Error('smart: rootDir must be a non-empty string when present')
|
|
702
|
+
if (raw.allowGlobalRoot !== undefined && typeof raw.allowGlobalRoot !== 'boolean') throw new Error('smart: allowGlobalRoot must be a boolean when present')
|
|
703
|
+
const brainstormDepth = raw.brainstormDepth === undefined ? 'balanced' : String(raw.brainstormDepth).trim().toLowerCase()
|
|
704
|
+
if (!BRAINSTORM_DEPTHS.includes(brainstormDepth)) throw new Error(`smart: brainstormDepth must be one of ${BRAINSTORM_DEPTHS.join(', ')}`)
|
|
705
|
+
const workflow = raw.workflow === undefined ? 'v1' : String(raw.workflow).trim().toLowerCase()
|
|
706
|
+
if (!WORKFLOWS.includes(workflow)) throw new Error(`smart: workflow must be one of ${WORKFLOWS.join(', ')}`)
|
|
707
|
+
return {
|
|
708
|
+
enabled: raw.enabled !== false,
|
|
709
|
+
rootDir: raw.rootDir,
|
|
710
|
+
// 行 rootDir 是"测试/无头"开关,必须显式承认它压掉工作区根(见 presetTaskRoot)。
|
|
711
|
+
allowGlobalRoot: raw.allowGlobalRoot === true,
|
|
712
|
+
brainRoute: route(raw.brainRoute, 'brainRoute'),
|
|
713
|
+
handRoute: route(raw.handRoute, 'handRoute'),
|
|
714
|
+
reviewRoute: route(raw.reviewRoute, 'reviewRoute'),
|
|
715
|
+
brainstormDepth,
|
|
716
|
+
// 新任务用哪一版工作流(v1 = 历史行为;optimized = brainstorm 不派独立 consult)。
|
|
717
|
+
workflow,
|
|
718
|
+
maxRework,
|
|
719
|
+
requireConsult: raw.requireConsult !== false,
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export const BRAINSTORM_DEPTHS = ['light', 'balanced', 'deep', 'exhaustive']
|
|
724
|
+
|
|
725
|
+
export const BRAINSTORM_EFFORT_CANDIDATES = {
|
|
726
|
+
light: ['low'],
|
|
727
|
+
balanced: ['medium', 'low'],
|
|
728
|
+
deep: ['high', 'medium', 'low'],
|
|
729
|
+
exhaustive: ['xhigh', 'max', 'high', 'medium', 'low'],
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Map a user-facing brainstorm depth to the strongest supported wire effort. */
|
|
733
|
+
export function resolveBrainstormEffort(depth = 'balanced', supportedEfforts = undefined) {
|
|
734
|
+
const normalized = String(depth).trim().toLowerCase()
|
|
735
|
+
if (!BRAINSTORM_DEPTHS.includes(normalized)) throw new Error(`smart: brainstormDepth must be one of ${BRAINSTORM_DEPTHS.join(', ')}`)
|
|
736
|
+
const candidates = BRAINSTORM_EFFORT_CANDIDATES[normalized]
|
|
737
|
+
if (supportedEfforts === undefined) return { depth: normalized, reasoningEffort: candidates[0], fallback: false }
|
|
738
|
+
const supported = new Set((Array.isArray(supportedEfforts) ? supportedEfforts : []).map(value => String(value).toLowerCase()))
|
|
739
|
+
const selected = candidates.find(value => supported.has(value))
|
|
740
|
+
if (selected === undefined) throw new Error(`smart: brainstorm depth "${normalized}" cannot be used — model supports [${[...supported].join(', ')}], but no compatible reasoning effort exists`)
|
|
741
|
+
return { depth: normalized, reasoningEffort: selected, fallback: selected !== candidates[0], supported: [...supported] }
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
export function tasksRoot(rootDir) {
|
|
746
|
+
return join(resolve(rootDir), 'tasks')
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Absolute directory of one task, proven to stay inside `tasks/`.
|
|
751
|
+
* @param rootDir - smart root directory.
|
|
752
|
+
* @param id - task id.
|
|
753
|
+
* @returns absolute task directory.
|
|
754
|
+
* @throws {Error} when the resolved path escapes the root.
|
|
755
|
+
*/
|
|
756
|
+
export function taskDir(rootDir, id) {
|
|
757
|
+
assertTaskId(id)
|
|
758
|
+
const base = tasksRoot(rootDir)
|
|
759
|
+
const dir = resolve(base, id)
|
|
760
|
+
if (dir !== join(base, id) || !dir.startsWith(base + sep)) throw new Error(`smart: task path escapes the root: ${dir}`)
|
|
761
|
+
return dir
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Write a file so a reader never sees a half-written document: same-directory
|
|
766
|
+
* temp file, then rename. The design's §11.5 check depends on this.
|
|
767
|
+
*
|
|
768
|
+
* The temp name carries the pid, a millisecond stamp **and** random bytes: a burst
|
|
769
|
+
* of writes inside one process (or an `apply()` racing a tool call) lands in the
|
|
770
|
+
* same millisecond routinely, and `pid-Date.now` then has two writers renaming to
|
|
771
|
+
* the same temp path — one loses, and the failure looks like a corrupted state
|
|
772
|
+
* file rather than a collision (`acceptance.md` S2b-4).
|
|
773
|
+
*
|
|
774
|
+
* @param file - destination path.
|
|
775
|
+
* @param text - full contents.
|
|
776
|
+
* @param mode - file mode when the file is created.
|
|
777
|
+
*/
|
|
778
|
+
export function writeAtomic(file, text, mode = 0o644) {
|
|
779
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString('hex')}`
|
|
780
|
+
writeFileSync(tmp, text, { encoding: 'utf8', mode })
|
|
781
|
+
try {
|
|
782
|
+
renameSync(tmp, file)
|
|
783
|
+
} catch (error) {
|
|
784
|
+
rmSync(tmp, { force: true })
|
|
785
|
+
throw error
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* 真实子代理调用计数(**索引**,不是主题;从不参与闸门判定)。
|
|
791
|
+
*
|
|
792
|
+
* 早先只有派生的 `spawnEstimate`(数磁盘上的记录),它分不清"review child 跑过"与
|
|
793
|
+
* "brain 自己写了一轮要点" —— 那条错已经修过,但派生计数仍回答不了"这一轮 bundle 命中
|
|
794
|
+
* 省了几次调用"。所以这里落一份显式计数:只在**真的起过 child / 真的命中缓存**的地方加一。
|
|
795
|
+
*
|
|
796
|
+
* 老 state 没有这个字段 ⇒ 读作全零(`metricsOf`),不需要迁移。
|
|
797
|
+
*/
|
|
798
|
+
export const METRIC_KEYS = ['reviewSpawns', 'handSpawns', 'brainRounds', 'consults', 'briefings', 'verifications', 'bundleHits', 'bundleMisses', 'gatesPassed', 'phaseTransitions']
|
|
799
|
+
|
|
800
|
+
/** 一个任务的计数快照(缺字段补零,绝不返回 undefined)。 */
|
|
801
|
+
export function metricsOf(state) {
|
|
802
|
+
const stored = state?.metrics ?? {}
|
|
803
|
+
return Object.fromEntries(METRIC_KEYS.map(key => [key, Number.isInteger(stored[key]) ? stored[key] : 0]))
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** 就地加计数(调用方随后自己 writeState)。 */
|
|
807
|
+
function bump(state, ...keys) {
|
|
808
|
+
const current = { ...metricsOf(state) }
|
|
809
|
+
for (const key of keys) current[key] = current[key] + 1
|
|
810
|
+
state.metrics = current
|
|
811
|
+
return state
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* 原子地写**一组**文件:先把每个都落到同目录临时文件,全部成功后再逐个 rename。
|
|
816
|
+
*
|
|
817
|
+
* 为什么不是"循环 writeAtomic":那种写法在第一份成功后崩掉就留下**半套产物**(盘上有
|
|
818
|
+
* `plan.md`、没有 `acceptance.md`),而闸门与面板都会把半套当成真实的半成品来读。这里要么
|
|
819
|
+
* 全成、要么一个字节都不留(临时文件全部清掉)。
|
|
820
|
+
*
|
|
821
|
+
* @param entries - `[{ file, text }]`(绝对路径)。
|
|
822
|
+
*/
|
|
823
|
+
export function writeAtomicMany(entries) {
|
|
824
|
+
const staged = []
|
|
825
|
+
try {
|
|
826
|
+
for (const { file, text } of entries) {
|
|
827
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString('hex')}`
|
|
828
|
+
writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o644 })
|
|
829
|
+
staged.push({ tmp, file })
|
|
830
|
+
}
|
|
831
|
+
for (const { tmp, file } of staged) renameSync(tmp, file)
|
|
832
|
+
} catch (error) {
|
|
833
|
+
for (const { tmp } of staged) rmSync(tmp, { force: true })
|
|
834
|
+
throw error
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Read `state.json`, or null when the task does not exist / is unreadable. */
|
|
839
|
+
export function readState(rootDir, id) {
|
|
840
|
+
const file = join(taskDir(rootDir, id), 'state.json')
|
|
841
|
+
try {
|
|
842
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
843
|
+
} catch (error) {
|
|
844
|
+
if (error?.code === 'ENOENT') return null
|
|
845
|
+
throw new Error(`smart: state.json of task "${id}" is unreadable: ${error.message}`)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Persist `state.json` and touch the task index. */
|
|
850
|
+
export function writeState(rootDir, state) {
|
|
851
|
+
const dir = taskDir(rootDir, state.id)
|
|
852
|
+
mkdirSync(dir, { recursive: true })
|
|
853
|
+
writeAtomic(join(dir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`)
|
|
854
|
+
updateIndex(rootDir, state)
|
|
855
|
+
return state
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Rewrite `index.json` from the state being saved (one line per task). */
|
|
859
|
+
function updateIndex(rootDir, state) {
|
|
860
|
+
const root = resolve(rootDir)
|
|
861
|
+
const file = join(root, 'index.json')
|
|
862
|
+
let entries = []
|
|
863
|
+
try {
|
|
864
|
+
entries = JSON.parse(readFileSync(file, 'utf8')).tasks ?? []
|
|
865
|
+
} catch {
|
|
866
|
+
entries = []
|
|
867
|
+
}
|
|
868
|
+
// 裁决也跟着进索引行:面板的板格走 index.json 快路径,只读面板要能一眼看到"这门为什么
|
|
869
|
+
// 还没开"(待裁决 n)与"用户已经定了多少"(已裁决 n),不必为每行再读一次 state.json。
|
|
870
|
+
const budget = reworkBudget(state, state.phase, state.settings?.maxRework ?? 5)
|
|
871
|
+
const row = {
|
|
872
|
+
id: state.id,
|
|
873
|
+
title: state.title,
|
|
874
|
+
phase: state.phase,
|
|
875
|
+
awaitingGate: state.awaitingGate ?? null,
|
|
876
|
+
pendingDecisions: state.pendingDecisions ?? null,
|
|
877
|
+
// 模型自主裁决(Ruling:)—— 与用户裁决(decisions)分开放:两者都要看得见,
|
|
878
|
+
// 但不能混为一谈(一个是「我替你定了」,一个是「你定的」)。索引行只给**条数**
|
|
879
|
+
//(面板板格够用),完整条目在 `smart_status` 的 `rulings` 里。
|
|
880
|
+
rulings: Array.isArray(state.rulings) ? state.rulings.length : 0,
|
|
881
|
+
decided: countDecisions(state.decisions),
|
|
882
|
+
// 本阶段返工额度(已用/上限/人加额次数):面板要在**撞墙之前**就能看见分母。
|
|
883
|
+
reworkUsed: budget.used,
|
|
884
|
+
reworkCap: budget.cap,
|
|
885
|
+
reworkGrants: budget.grants,
|
|
886
|
+
// briefing 一格(acceptance A10):当前阶段的状态 + 路由,与「待裁决」并列。
|
|
887
|
+
briefing: briefingCell(rootDir, state, state.phase),
|
|
888
|
+
updatedAt: state.updatedAt,
|
|
889
|
+
}
|
|
890
|
+
const next = [row, ...entries.filter(entry => entry.id !== state.id)].slice(0, 100)
|
|
891
|
+
mkdirSync(root, { recursive: true })
|
|
892
|
+
writeAtomic(file, `${JSON.stringify({ tasks: next }, null, 2)}\n`)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** How many decisions the user has recorded across all phases (index rows carry the count). */
|
|
896
|
+
function countDecisions(decisions) {
|
|
897
|
+
let total = 0
|
|
898
|
+
for (const rounds of Object.values(decisions ?? {})) {
|
|
899
|
+
for (const round of Array.isArray(rounds) ? rounds : []) total += Array.isArray(round?.entries) ? round.entries.length : 0
|
|
900
|
+
}
|
|
901
|
+
return total
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Every task row in the index, newest first. */
|
|
905
|
+
export function listTasks(rootDir) {
|
|
906
|
+
try {
|
|
907
|
+
return JSON.parse(readFileSync(join(resolve(rootDir), 'index.json'), 'utf8')).tasks ?? []
|
|
908
|
+
} catch {
|
|
909
|
+
return []
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Create a task in the first design phase.
|
|
915
|
+
* @param rootDir - smart root.
|
|
916
|
+
* @param input - `{ title, goal, taskId? }`.
|
|
917
|
+
* @param meta - `{ workspace?, workspaceSource? }`: where the session said it is.
|
|
918
|
+
* Recorded so a later `smart_finish` (possibly from another session) can promote
|
|
919
|
+
* the deliverables without guessing — the calling session's own cwd is the
|
|
920
|
+
* fallback, never `process.cwd()`.
|
|
921
|
+
* @returns the new state.
|
|
922
|
+
*/
|
|
923
|
+
export function createTask(rootDir, input = {}, meta = {}) {
|
|
924
|
+
const title = typeof input.title === 'string' ? input.title.trim() : ''
|
|
925
|
+
if (title.length === 0) throw new Error('smart: smart_begin needs a non-empty `title`')
|
|
926
|
+
const goal = typeof input.goal === 'string' ? input.goal.trim() : ''
|
|
927
|
+
// 默认 id 走同日去重(`YYYY-MM-DD` ⇒ 一天里可能撞);显式 id 照旧:重了就是调用方的错。
|
|
928
|
+
const explicit = input.taskId === undefined ? null : assertTaskId(input.taskId)
|
|
929
|
+
const id = explicit ?? uniqueTaskId(rootDir, defaultTaskId(title))
|
|
930
|
+
if (readState(rootDir, id) !== null) throw new Error(`smart: task "${id}" already exists`)
|
|
931
|
+
const now = new Date().toISOString()
|
|
932
|
+
// shape / size 随任务落盘(同 workflow 的纪律):之后改行配置不换在途任务的语义。
|
|
933
|
+
const shape = typeof meta.shape === 'string' && Object.hasOwn(SHAPES, meta.shape) ? meta.shape : 'feature'
|
|
934
|
+
const size = typeof meta.size === 'string' && SIZES.includes(meta.size) ? meta.size : 'full'
|
|
935
|
+
const state = {
|
|
936
|
+
id,
|
|
937
|
+
title,
|
|
938
|
+
goal,
|
|
939
|
+
createdAt: now,
|
|
940
|
+
updatedAt: now,
|
|
941
|
+
shape,
|
|
942
|
+
size,
|
|
943
|
+
// 起始相位是**这个任务自己序列**的第一个:`review` 形态直接从 reviewing 起。
|
|
944
|
+
phase: phasesFor({ shape, workflow: meta.workflow })[0],
|
|
945
|
+
awaitingGate: null,
|
|
946
|
+
artifacts: {},
|
|
947
|
+
consults: {},
|
|
948
|
+
// 用户裁决的原文(按阶段分轮次存):门记录的是"你批了没有",这里记的是"你定了什么"。
|
|
949
|
+
decisions: {},
|
|
950
|
+
gates: [],
|
|
951
|
+
reworkCount: 0,
|
|
952
|
+
// 用户显式加额的记录(只有门可以写,见 grantRework):审计要能看出"这一轮返工是人批的",
|
|
953
|
+
// 而不是模型自己把额度刷长了。
|
|
954
|
+
reworkGrants: [],
|
|
955
|
+
history: [],
|
|
956
|
+
// 额度上限是**行配置**,只有工具层知道;状态里戳一份,面板的速路径(index.json)与
|
|
957
|
+
// taskStatus 才能显示 "本阶段 3/5" 而不是一个没有分母的计数。门与返工时刷新它。
|
|
958
|
+
...Number.isInteger(meta.maxRework) && meta.maxRework >= 0 ? { settings: { maxRework: meta.maxRework } } : {},
|
|
959
|
+
...typeof meta.brainstormDepth === 'string' && meta.brainstormDepth !== '' ? { brainstormDepth: meta.brainstormDepth } : {},
|
|
960
|
+
// 策略版本随任务落盘:在途任务不因行配置改动而换语义。
|
|
961
|
+
...typeof meta.workflow === 'string' && WORKFLOWS.includes(meta.workflow) ? { workflow: meta.workflow } : {},
|
|
962
|
+
...typeof meta.brainstormEffort === 'string' && meta.brainstormEffort !== '' ? { brainstormEffort: meta.brainstormEffort } : {},
|
|
963
|
+
...meta.brainRoute !== undefined ? { brainRoute: meta.brainRoute } : {},
|
|
964
|
+
|
|
965
|
+
...typeof meta.workspace === 'string' && meta.workspace !== '' ? { workspace: meta.workspace } : {},
|
|
966
|
+
...typeof meta.workspaceSource === 'string' && meta.workspaceSource !== '' ? { workspaceSource: meta.workspaceSource } : {},
|
|
967
|
+
}
|
|
968
|
+
mkdirSync(taskDir(rootDir, id), { recursive: true })
|
|
969
|
+
return writeState(rootDir, state)
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** `YYYYMMDD-HHmmss-<slug>` — sortable and safe as a path segment. */
|
|
973
|
+
/**
|
|
974
|
+
* **本地**日历日期 `YYYY-MM-DD`。
|
|
975
|
+
*
|
|
976
|
+
* 任务 id 是**给人看的**(目录名、交付目录名、面板标题旁),所以按本机日历走:
|
|
977
|
+
* 用 UTC 会让"今天开的任务"在东八区显示成昨天(2026-10-12 用户裁决:时间戳那半不再用
|
|
978
|
+
* UTC `YYYYMMDD-HHmmss`,改成 locale 日期)。
|
|
979
|
+
*/
|
|
980
|
+
export function localDateStamp(now = new Date()) {
|
|
981
|
+
const pad = value => String(value).padStart(2, '0')
|
|
982
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** 标题里的 ASCII 段(小写、非字母数字折成 `-`、截 24 字符);中文标题 ⇒ 空串。 */
|
|
986
|
+
function titleSlug(title) {
|
|
987
|
+
return title.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 24)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* 默认任务 id:`<本地 YYYY-MM-DD>[-<ascii-slug>]`。
|
|
992
|
+
*
|
|
993
|
+
* `now` 可注入(测试用假时钟断"本地而非 UTC",别用 `toISOString()` 那半边)。
|
|
994
|
+
*/
|
|
995
|
+
export function defaultTaskId(title, now = new Date()) {
|
|
996
|
+
const stamp = localDateStamp(now)
|
|
997
|
+
const slug = titleSlug(title)
|
|
998
|
+
return slug.length === 0 ? stamp : `${stamp}-${slug}`
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* 同日不撞车:丢掉时分秒之后,一天里第二个任务必须自己长出一个序号。
|
|
1003
|
+
*
|
|
1004
|
+
* 只在**默认 id** 这条路上用;显式传 `taskId` 的调用方仍旧要拿到 `already exists`(那是它的错,
|
|
1005
|
+
* 不是我们替它改名)。
|
|
1006
|
+
*/
|
|
1007
|
+
function uniqueTaskId(rootDir, base) {
|
|
1008
|
+
if (readState(rootDir, base) === null) return base
|
|
1009
|
+
for (let n = 2; n < 1000; n += 1) {
|
|
1010
|
+
const candidate = `${base}-${n}`
|
|
1011
|
+
if (readState(rootDir, candidate) === null) return candidate
|
|
1012
|
+
}
|
|
1013
|
+
throw new Error(`smart: 同一天的任务太多了(${base}-2 … ${base}-999 都被占用)`)
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Record one phase artifact and put the task in front of that phase's gate.
|
|
1018
|
+
*
|
|
1019
|
+
* @param rootDir - smart root.
|
|
1020
|
+
* @param id - task id.
|
|
1021
|
+
* @param phase - phase the artifact belongs to.
|
|
1022
|
+
* @param markdown - artifact body.
|
|
1023
|
+
* @returns the updated state.
|
|
1024
|
+
* @throws {Error} when the phase is not the machine's current phase, or the body is empty.
|
|
1025
|
+
*/
|
|
1026
|
+
export function recordArtifact(rootDir, id, phase, markdown) {
|
|
1027
|
+
const state = readState(rootDir, id)
|
|
1028
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1029
|
+
const file = ARTIFACT_FILE[phase]
|
|
1030
|
+
if (file === undefined) throw new Error(`smart: "${phase}" is not an artifact phase (one of: ${Object.keys(ARTIFACT_FILE).join(', ')})`)
|
|
1031
|
+
// 相位顺序按**这个任务自己的序列**(shape × workflow)判,不再按全局相位表:
|
|
1032
|
+
// `fix` 形态里没有 design,那不是"还没到",是"这个任务不走那一步"。
|
|
1033
|
+
const list = phasesFor(state)
|
|
1034
|
+
if (!list.includes(phase)) {
|
|
1035
|
+
throw new Error(`smart: task "${id}" is a "${state.shape ?? 'feature'}" task — its phases are [${list.join(', ')}]; "${phase}" is not one of them (the shape sets what this task walks)`)
|
|
1036
|
+
}
|
|
1037
|
+
if (state.phase !== phase) {
|
|
1038
|
+
const here = list.indexOf(state.phase)
|
|
1039
|
+
const want = list.indexOf(phase)
|
|
1040
|
+
throw new Error(`smart: task "${id}" is in phase "${state.phase}"; the "${phase}" artifact comes ${want < here ? 'earlier (already recorded)' : `later — finish "${state.phase}" and pass its gate first`}`)
|
|
1041
|
+
}
|
|
1042
|
+
// 冻结的是**用户正在批的那份稿子**,不是"门开着"这个状态本身(acceptance A8):
|
|
1043
|
+
// 门开着 + 当前 briefing 轮已确认且 fresh ⇒ 拒绝重写;否则(门开着但 briefing 未确认 /
|
|
1044
|
+
// stale —— 迁移态、带外改写态、revise 后)允许重写,重写即天然是新一轮 briefing 的起点。
|
|
1045
|
+
// 保留原例外:稿子自己还带着未决小节时照旧允许重写(把用户裁决并回产物的在途任务不能卡死)。
|
|
1046
|
+
const staleOpen = state.awaitingGate !== null && openDecisions(readArtifact(rootDir, id, phase)).headings.length > 0
|
|
1047
|
+
const confirmedFresh = state.awaitingGate !== null
|
|
1048
|
+
&& briefingGaps(rootDir, state, phase).length === 0
|
|
1049
|
+
&& briefingStatusOf(state.briefings?.[phase], artifactSha256(rootDir, id, phase)) === 'confirmed'
|
|
1050
|
+
if (confirmedFresh && !staleOpen) throw new Error(`smart: task "${id}" is waiting on gate ${state.awaitingGate}; pass it before writing more artifacts`)
|
|
1051
|
+
if (typeof markdown !== 'string' || markdown.trim().length === 0) throw new Error('smart: artifact body must be non-empty markdown')
|
|
1052
|
+
if (phase === 'plan') assertPlanContract(markdown)
|
|
1053
|
+
const dir = taskDir(rootDir, id)
|
|
1054
|
+
mkdirSync(dir, { recursive: true })
|
|
1055
|
+
writeAtomic(join(dir, file), markdown.endsWith('\n') ? markdown : `${markdown}\n`)
|
|
1056
|
+
const now = new Date().toISOString()
|
|
1057
|
+
const text = readFileSync(join(taskDir(rootDir, id), file), 'utf8')
|
|
1058
|
+
state.artifacts[phase] = {
|
|
1059
|
+
file,
|
|
1060
|
+
bytes: Buffer.byteLength(markdown, 'utf8'),
|
|
1061
|
+
at: now,
|
|
1062
|
+
// sha256 绑的是**写进磁盘的那些字节**(writeAtomic 归一化过尾换行):briefing 确认
|
|
1063
|
+
// 绑的就是它,重算而不是复用 `bytes`(那是归一化前的长度,见 acceptance A6)。
|
|
1064
|
+
sha256: createHash('sha256').update(text).digest('hex'),
|
|
1065
|
+
}
|
|
1066
|
+
const open = openDecisions(markdown)
|
|
1067
|
+
if (open.headings.length > 0) {
|
|
1068
|
+
// 产物里还有只有用户能定的问题 —— **门先不开**(连 briefing 确认也开不了:confirmBriefing
|
|
1069
|
+
// 重查 openDecisions)。见 docs/smart-mode-design.md。
|
|
1070
|
+
state.pendingDecisions = { headings: open.headings, items: open.items }
|
|
1071
|
+
} else {
|
|
1072
|
+
delete state.pendingDecisions
|
|
1073
|
+
}
|
|
1074
|
+
// 门不再从这里开(acceptance A5):开门路径只有 `confirmBriefing('confirm')` —— 门是
|
|
1075
|
+
// "用户看懂了"的证明,不是"文件写完了"的证明。executing 落盘后也**不再自动推进**到
|
|
1076
|
+
// reviewing(G2 裁决①):等 briefing 确认后由 confirmBriefing 推进。
|
|
1077
|
+
state.awaitingGate = null
|
|
1078
|
+
state.updatedAt = now
|
|
1079
|
+
// 执行报告是 hand 子代理跑出来的(这条路径只由 smart_execute 走到)。
|
|
1080
|
+
if (phase === 'executing') bump(state, 'handSpawns')
|
|
1081
|
+
return writeState(rootDir, state)
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** Position of a phase in the design order (or PHASES order for later phases). */
|
|
1085
|
+
function phaseIndex(phase) {
|
|
1086
|
+
const design = DESIGN_PHASES.indexOf(phase)
|
|
1087
|
+
return design === -1 ? PHASES.indexOf(phase) : design
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* optimized 工作流:把 `plan.md` 与它的伴随 `acceptance.md` 作为**一次操作**落盘。
|
|
1092
|
+
*
|
|
1093
|
+
* 两条纪律:
|
|
1094
|
+
* 1. **要么两份都在、要么一份都不留**(`writeAtomicMany`)—— 半套产物会让闸门读到
|
|
1095
|
+
* "计划写了、验收没写"这种不存在的中间态;
|
|
1096
|
+
* 2. state 只在两份都写成功之后才更新,且相位名仍是 `plan`(相位表不动,见
|
|
1097
|
+
* `COMPANION_ARTIFACT` 的注释)。
|
|
1098
|
+
*
|
|
1099
|
+
* @param rootDir - smart root.
|
|
1100
|
+
* @param id - task id.
|
|
1101
|
+
* @param plan - plan.md 正文。
|
|
1102
|
+
* @param acceptance - acceptance.md 正文(optimized 下必填)。
|
|
1103
|
+
* @returns the updated state.
|
|
1104
|
+
*/
|
|
1105
|
+
export function recordPlanAcceptance(rootDir, id, plan, acceptance) {
|
|
1106
|
+
assertPlanContract(plan)
|
|
1107
|
+
const state = readState(rootDir, id)
|
|
1108
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1109
|
+
if (state.workflow !== 'optimized') {
|
|
1110
|
+
throw new Error(`smart: task "${id}" runs the ${state.workflow ?? 'v1'} workflow — plan and acceptance are separate phases there; record them with smart_artifact one at a time`)
|
|
1111
|
+
}
|
|
1112
|
+
if (state.phase !== 'plan') throw new Error(`smart: task "${id}" is in phase "${state.phase}"; the plan+acceptance pair comes with the plan phase`)
|
|
1113
|
+
if (typeof plan !== 'string' || plan.trim() === '') throw new Error('smart: plan body must be non-empty markdown')
|
|
1114
|
+
if (typeof acceptance !== 'string' || acceptance.trim() === '') throw new Error('smart: acceptance body must be non-empty markdown — the optimized workflow records both files together')
|
|
1115
|
+
// 冻结判据与单文件路径一致:门开着且当前 briefing 已确认且 fresh ⇒ 不许重写。
|
|
1116
|
+
const staleOpen = state.awaitingGate !== null && openDecisions(readArtifact(rootDir, id, 'plan')).headings.length > 0
|
|
1117
|
+
const confirmedFresh = state.awaitingGate !== null
|
|
1118
|
+
&& briefingGaps(rootDir, state, 'plan').length === 0
|
|
1119
|
+
&& briefingStatusOf(state.briefings?.plan, artifactSha256(rootDir, id, 'plan')) === 'confirmed'
|
|
1120
|
+
if (confirmedFresh && !staleOpen) throw new Error(`smart: task "${id}" is waiting on gate ${state.awaitingGate}; pass it before writing more artifacts`)
|
|
1121
|
+
|
|
1122
|
+
const dir = taskDir(rootDir, id)
|
|
1123
|
+
mkdirSync(dir, { recursive: true })
|
|
1124
|
+
const planText = plan.endsWith('\n') ? plan : `${plan}\n`
|
|
1125
|
+
const acceptanceText = acceptance.endsWith('\n') ? acceptance : `${acceptance}\n`
|
|
1126
|
+
writeAtomicMany([
|
|
1127
|
+
{ file: join(dir, 'plan.md'), text: planText },
|
|
1128
|
+
{ file: join(dir, 'acceptance.md'), text: acceptanceText },
|
|
1129
|
+
])
|
|
1130
|
+
const now = new Date().toISOString()
|
|
1131
|
+
state.artifacts.plan = {
|
|
1132
|
+
file: 'plan.md',
|
|
1133
|
+
bytes: Buffer.byteLength(plan, 'utf8'),
|
|
1134
|
+
at: now,
|
|
1135
|
+
sha256: createHash('sha256').update(planText).digest('hex'),
|
|
1136
|
+
companion: 'acceptance.md',
|
|
1137
|
+
companionSha256: createHash('sha256').update(acceptanceText).digest('hex'),
|
|
1138
|
+
}
|
|
1139
|
+
// 两份里的未决小节都算数:任一份还有「需要你裁决」,门就不开。
|
|
1140
|
+
const open = openDecisions(planText + '\n' + acceptanceText)
|
|
1141
|
+
if (open.headings.length > 0) {
|
|
1142
|
+
state.pendingDecisions = { headings: open.headings, items: open.items }
|
|
1143
|
+
} else {
|
|
1144
|
+
delete state.pendingDecisions
|
|
1145
|
+
}
|
|
1146
|
+
state.awaitingGate = null
|
|
1147
|
+
state.updatedAt = now
|
|
1148
|
+
bump(state, 'phaseTransitions')
|
|
1149
|
+
return writeState(rootDir, state)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* 记一条**模型自主裁决**(`Ruling:`)—— 计划有缺陷 / 有歧义 / 与 spec 冲突时,模型自己定、
|
|
1154
|
+
* 记账、继续走,而不是卡住等人。
|
|
1155
|
+
*
|
|
1156
|
+
* 为什么与 `smart_decide` 并存:后者是**只有用户能定**的事(`state.decisions`);这条是
|
|
1157
|
+
* **模型能定**的事。分流判据只有一句:*这件事只有用户能定吗?* —— 能定的不许替他定,
|
|
1158
|
+
* 不能定的不许卡住。superpowers 的原话:*"一个卡在问题上的会话,花掉他一整天,什么也没
|
|
1159
|
+
* 买到;一个错误的裁决,是他看得见、可以撤销的返工。"*
|
|
1160
|
+
*
|
|
1161
|
+
* 形状照它的 ledger 行:`Ruling: <决定了什么> — <为什么> — <错了代价是什么>`。
|
|
1162
|
+
* 三段都必须非空 —— 只写决定不写代价的裁决,事后没法判断该不该回滚。
|
|
1163
|
+
*
|
|
1164
|
+
* @param rootDir - smart root.
|
|
1165
|
+
* @param id - task id.
|
|
1166
|
+
* @param ruling - `{ decision, why, costIfWrong, phase?, by? }`。
|
|
1167
|
+
* @returns 写入后的 state。
|
|
1168
|
+
*/
|
|
1169
|
+
export function recordRuling(rootDir, id, ruling) {
|
|
1170
|
+
const state = readState(rootDir, id)
|
|
1171
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1172
|
+
const decision = typeof ruling?.decision === 'string' ? ruling.decision.trim() : ''
|
|
1173
|
+
const why = typeof ruling?.why === 'string' ? ruling.why.trim() : ''
|
|
1174
|
+
const costIfWrong = typeof ruling?.costIfWrong === 'string' ? ruling.costIfWrong.trim() : ''
|
|
1175
|
+
if (decision === '') throw new Error('smart: a ruling needs `decision` — what you decided')
|
|
1176
|
+
if (why === '') throw new Error('smart: a ruling needs `why` — the reasoning you decided it on')
|
|
1177
|
+
if (costIfWrong === '') throw new Error('smart: a ruling needs `costIfWrong` — what it costs if this turns out wrong (that is what makes it reviewable later)')
|
|
1178
|
+
const entry = {
|
|
1179
|
+
at: new Date().toISOString(),
|
|
1180
|
+
phase: ruling?.phase ?? state.phase,
|
|
1181
|
+
decision,
|
|
1182
|
+
why,
|
|
1183
|
+
costIfWrong,
|
|
1184
|
+
by: ruling?.by === 'hand' ? 'hand' : 'model',
|
|
1185
|
+
}
|
|
1186
|
+
if (!Array.isArray(state.rulings)) state.rulings = []
|
|
1187
|
+
state.rulings.push(entry)
|
|
1188
|
+
writeState(rootDir, state)
|
|
1189
|
+
return state
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* 执行报告里 hand 自己写下的裁决(`## Rulings` 小节)。
|
|
1194
|
+
*
|
|
1195
|
+
* hand 在执行中撞到计划缺陷/歧义时不能干等,但它的决定必须留痕 —— 这一节是它的 ledger。
|
|
1196
|
+
* 解析刻意钝(同 `openDecisions`):`## Rulings`(或 `## 裁决`)小节里的每一条
|
|
1197
|
+
* `- **决定** — 为什么 — 错了代价`,拆不出三段的整条丢弃并照实计数(**宁可少记,
|
|
1198
|
+
* 不记不存在的裁决**)。
|
|
1199
|
+
*
|
|
1200
|
+
* @param reportMarkdown - 执行报告正文。
|
|
1201
|
+
* @returns `{ rulings, skipped }` —— 能用的裁决与被丢弃的条数。
|
|
1202
|
+
*/
|
|
1203
|
+
export function rulingsFromReport(reportMarkdown) {
|
|
1204
|
+
const text = typeof reportMarkdown === 'string' ? reportMarkdown : ''
|
|
1205
|
+
const lines = text.split('\n')
|
|
1206
|
+
const start = lines.findIndex(line => /^#{2,6}\s+.*\b(Rulings|裁决)\b/u.test(line.trim()))
|
|
1207
|
+
if (start === -1) return { rulings: [], skipped: 0 }
|
|
1208
|
+
const rulings = []
|
|
1209
|
+
let skipped = 0
|
|
1210
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
1211
|
+
const line = lines[i].trim()
|
|
1212
|
+
if (/^#{1,6}\s/u.test(line)) break
|
|
1213
|
+
if (!line.startsWith('-')) continue
|
|
1214
|
+
// `- **决定** — 为什么 — 错了代价`:三段用 em dash 或 `—` 分。
|
|
1215
|
+
const parts = line.replace(/^[-*]\s+/u, '').split(/\s+[—–-]{1,2}\s+/u)
|
|
1216
|
+
const [decision, why, costIfWrong] = parts.map(part => part?.replace(/^\*\*|\*\*$/gu, '').trim() ?? '')
|
|
1217
|
+
if (parts.length < 3 || decision === '' || why === '' || costIfWrong === '') { skipped += 1; continue }
|
|
1218
|
+
rulings.push({ decision, why, costIfWrong })
|
|
1219
|
+
}
|
|
1220
|
+
return { rulings, skipped }
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Record the user's decisions for one phase — the answers to the questions only they
|
|
1225
|
+
* can make, still unanswered when the artifact was drafted.
|
|
1226
|
+
*
|
|
1227
|
+
* Kept as a transcript (rounds, newest last) so the gate record can be audited against
|
|
1228
|
+
* what the user actually said, and so the workbench can show "已裁决 n 项". This call
|
|
1229
|
+
* does **not** open the gate and does **not** touch the artifact: the brain model still
|
|
1230
|
+
* has to fold the answers into the artifact and re-record it (which is what removes the
|
|
1231
|
+
* 需要你裁决 heading and lets the gate open).
|
|
1232
|
+
*
|
|
1233
|
+
* @param rootDir - smart root.
|
|
1234
|
+
* @param id - task id.
|
|
1235
|
+
* @param phase - phase the decisions belong to.
|
|
1236
|
+
* @param entries - `[{ question, options?, multiSelect?, selected, custom }]`.
|
|
1237
|
+
* @param meta - `{ by }` audit trail.
|
|
1238
|
+
* @returns the updated state.
|
|
1239
|
+
*/
|
|
1240
|
+
export function recordDecisions(rootDir, id, phase, entries = [], meta = {}) {
|
|
1241
|
+
const state = readState(rootDir, id)
|
|
1242
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1243
|
+
if (!DESIGN_PHASES.includes(phase) && phase !== 'reviewing') {
|
|
1244
|
+
throw new Error(`smart: phase "${phase}" does not take decisions (design phases and reviewing do)`)
|
|
1245
|
+
}
|
|
1246
|
+
if (!Array.isArray(entries) || entries.length === 0) throw new Error('smart: smart_decide needs at least one question')
|
|
1247
|
+
for (const entry of entries) {
|
|
1248
|
+
if (typeof entry?.question !== 'string' || entry.question.trim() === '') throw new Error('smart: every decision needs a non-empty `question`')
|
|
1249
|
+
}
|
|
1250
|
+
const at = new Date().toISOString()
|
|
1251
|
+
state.decisions = state.decisions ?? {}
|
|
1252
|
+
state.decisions[phase] = [...state.decisions[phase] ?? [], { at, by: meta.by ?? 'user', entries }]
|
|
1253
|
+
state.updatedAt = at
|
|
1254
|
+
return writeState(rootDir, state)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Close the current gate and advance the machine.
|
|
1259
|
+
*
|
|
1260
|
+
* Gate preconditions live here (not in the prompt): G1–G4 need that phase's
|
|
1261
|
+
* artifact, and — while `requireConsult` is on — its consult record. G5 needs
|
|
1262
|
+
* the execution report and the review.
|
|
1263
|
+
*
|
|
1264
|
+
* @param rootDir - smart root.
|
|
1265
|
+
* @param id - task id.
|
|
1266
|
+
* @param gate - gate id being passed (`G1`..`G5`).
|
|
1267
|
+
* @param decision - `{ by, note? }` audit trail of who confirmed.
|
|
1268
|
+
* @param options - `{ requireConsult }` from row config (NOT task state).
|
|
1269
|
+
* @returns the updated state.
|
|
1270
|
+
*/
|
|
1271
|
+
export function passGate(rootDir, id, gate, decision = {}, options = {}) {
|
|
1272
|
+
const state = readState(rootDir, id)
|
|
1273
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1274
|
+
if (state.awaitingGate !== gate) throw new Error(`smart: task "${id}" is not waiting on gate ${gate}${state.awaitingGate === null ? ' (no gate open)' : ` (open: ${state.awaitingGate})`}`)
|
|
1275
|
+
const owner = Object.keys(GATE_AFTER).find(phase => GATE_AFTER[phase] === gate)
|
|
1276
|
+
if (owner === undefined) throw new Error(`smart: unknown gate "${gate}"`)
|
|
1277
|
+
const missing = gateGaps(rootDir, state, { requireConsult: options.requireConsult !== false })
|
|
1278
|
+
if (missing.length > 0) throw new Error(`smart: gate ${gate} is blocked — ${missing.join('; ')}`)
|
|
1279
|
+
const now = new Date().toISOString()
|
|
1280
|
+
state.gates.push({ gate, at: now, by: decision.by ?? 'user', note: decision.note ?? '' })
|
|
1281
|
+
// 下一个相位按**这个任务自己的序列**走:optimized 的 plan 相位已经把验收设计一起做完
|
|
1282
|
+
// (两文件同一次评审、同一道门)⇒ 序列里没有 acceptance,G3 直接进执行;`review` 形态
|
|
1283
|
+
// 只有 reviewing,过完 G5 就没有下一个了。v1 feature 的 plan → acceptance → executing
|
|
1284
|
+
// 一个字都不动(`phasesFor` 给的就是那条)。
|
|
1285
|
+
state.phase = nextPhaseFor(state, owner) ?? NEXT_PHASE[owner]
|
|
1286
|
+
state.awaitingGate = null
|
|
1287
|
+
bump(state, 'gatesPassed', 'phaseTransitions')
|
|
1288
|
+
state.updatedAt = now
|
|
1289
|
+
return writeState(rootDir, state)
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* What still blocks the open gate, as human-readable lines.
|
|
1294
|
+
*
|
|
1295
|
+
* @param rootDir - smart root (the decision check re-reads the artifact, so an edit
|
|
1296
|
+
* made outside smart_artifact cannot smuggle unanswered questions past the gate).
|
|
1297
|
+
* @param state - task state.
|
|
1298
|
+
* @param options - `{ requireConsult }` from row config (NOT task state).
|
|
1299
|
+
* @returns gap list (empty means the gate can close).
|
|
1300
|
+
*/
|
|
1301
|
+
export function gateGaps(rootDir, state, options = {}) {
|
|
1302
|
+
const requireConsult = options.requireConsult !== false
|
|
1303
|
+
if (state.awaitingGate === null) return ['no gate is open']
|
|
1304
|
+
const owner = Object.keys(GATE_AFTER).find(phase => GATE_AFTER[phase] === state.awaitingGate)
|
|
1305
|
+
const gaps = []
|
|
1306
|
+
// 只对**这个任务序列里**的相位要产物:`review` 形态没有执行阶段,就不存在
|
|
1307
|
+
// "缺 execution-report.md" 这回事 —— 闸门要的是 shape 说它走的那几步的产物。
|
|
1308
|
+
const walked = phasesFor(state)
|
|
1309
|
+
if (state.awaitingGate === 'G5') {
|
|
1310
|
+
for (const phase of ['executing', 'reviewing'].filter(item => walked.includes(item))) {
|
|
1311
|
+
if (state.artifacts[phase] === undefined) gaps.push(`missing ${ARTIFACT_FILE[phase]}`)
|
|
1312
|
+
}
|
|
1313
|
+
} else if (owner !== undefined && state.artifacts[owner] === undefined) {
|
|
1314
|
+
gaps.push(`missing ${ARTIFACT_FILE[owner]}`)
|
|
1315
|
+
}
|
|
1316
|
+
// optimized 的 plan 相位把 acceptance.md 当伴随产物一起写:它缺了同样是缺口
|
|
1317
|
+
// (两份是一次原子写入的,缺一份说明盘上的东西不是这个工作流产出的)。
|
|
1318
|
+
// **只有 optimized** 才把 acceptance.md 当 plan 的伴随产物:v1 的 acceptance.md 是
|
|
1319
|
+
// 下一个阶段自己的产物,在 G3 时当然还不存在(早先写成无条件 ⇒ v1 的 G3 全部误报缺口)。
|
|
1320
|
+
if (owner !== undefined && state.workflow === 'optimized') {
|
|
1321
|
+
const companion = companionFileOf(owner)
|
|
1322
|
+
if (companion !== null && !existsSync(join(taskDir(rootDir, state.id), companion))) {
|
|
1323
|
+
gaps.push(`missing ${companion} (the optimized plan phase records plan.md and acceptance.md together)`)
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
// Consult records belong to the four design phases: in the execution and
|
|
1327
|
+
// review phases the hand model IS the other half of the pair, so demanding a
|
|
1328
|
+
// separate review-of-the-review would be ceremony, not control.
|
|
1329
|
+
// 哪些阶段**必须**有独立 consult 由任务的策略版本决定(optimized 下 brainstorm 不派 review:
|
|
1330
|
+
// 候选方案阶段还没有可执行性可审)。未知版本按 v1 兜底,绝不静默放宽。
|
|
1331
|
+
if (requireConsult && owner !== undefined && consultPhasesFor(state).includes(owner) && state.consults[owner] === undefined) gaps.push(`missing consult record for "${owner}" (requireConsult)`)
|
|
1332
|
+
// briefing 闸门(acceptance A5):门所属阶段(G5 取 reviewing)的四态 —— 缺轮 / 轮正文空 /
|
|
1333
|
+
// stale / 未确认 —— 任一命中即挡在 ask 之前。开门路径只有 confirmBriefing('confirm'),
|
|
1334
|
+
// 所以这里兜的是**迁移态**(旧任务门开着但没有 briefings)与带外改写后的回退防线。
|
|
1335
|
+
const briefed = state.awaitingGate === 'G5' ? 'reviewing' : owner
|
|
1336
|
+
if (briefed !== undefined) gaps.push(...briefingGaps(rootDir, state, briefed))
|
|
1337
|
+
// 产物里还留着"需要你裁决"的小节:那些问题的答案只能来自用户,所以门不许过。这一条是
|
|
1338
|
+
// 本轮补的洞——在此之前门只查"文件在不在/评审有没有",一份满是未决问题的稿子照样能过。
|
|
1339
|
+
const decisionPhases = state.awaitingGate === 'G5' ? ['reviewing'] : owner === undefined ? [] : [owner]
|
|
1340
|
+
for (const phase of decisionPhases) {
|
|
1341
|
+
const open = openDecisions(state.workflow === 'optimized'
|
|
1342
|
+
? artifactTextWithCompanion(rootDir, state.id, phase)
|
|
1343
|
+
: readArtifact(rootDir, state.id, phase))
|
|
1344
|
+
if (open.headings.length === 0) continue
|
|
1345
|
+
const what = open.items === 0 ? 'an open section' : `${open.items} unanswered decision(s)`
|
|
1346
|
+
gaps.push(`${ARTIFACT_FILE[phase]} still has ${what} under "${open.headings.join(' / ')}" — put the questions to the user (smart_decide) and fold the answers into the artifact first`)
|
|
1347
|
+
}
|
|
1348
|
+
// 计划文档契约(smart-plan 的三块):一份缺 Interfaces / Global Constraints / Review Focus
|
|
1349
|
+
// 的 plan.md **不该被批准** —— 那三块正是"另一个模型只读这些文件就能执行"的最低保证。
|
|
1350
|
+
// 只查 plan.md 自己:optimized 的 acceptance.md 是另一份产物,不背这个契约。
|
|
1351
|
+
if (owner === 'plan' && state.artifacts.plan !== undefined) {
|
|
1352
|
+
for (const gap of planContractGaps(readArtifact(rootDir, state.id, 'plan'))) {
|
|
1353
|
+
gaps.push(`plan.md lacks ${gap} — see the smart-plan skill for what each block must carry`)
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return gaps
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* A task's snapshot for `smart_status`: phase, artifacts, open gate, gaps, and
|
|
1361
|
+
* what the operator should do next.
|
|
1362
|
+
* @param rootDir - smart root.
|
|
1363
|
+
* @param id - task id.
|
|
1364
|
+
* @param options - `{ requireConsult }` from row config.
|
|
1365
|
+
* @returns status object.
|
|
1366
|
+
*/
|
|
1367
|
+
export function taskStatus(rootDir, id, options = {}) {
|
|
1368
|
+
const state = readState(rootDir, id)
|
|
1369
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1370
|
+
const gaps = gateGaps(rootDir, state, { requireConsult: options.requireConsult !== false })
|
|
1371
|
+
return {
|
|
1372
|
+
id: state.id,
|
|
1373
|
+
title: state.title,
|
|
1374
|
+
goal: state.goal,
|
|
1375
|
+
phase: state.phase,
|
|
1376
|
+
// 任务自己的策略版本(旧任务没有这个字段 ⇒ 按 v1 报)。
|
|
1377
|
+
workflow: state.workflow ?? 'v1',
|
|
1378
|
+
awaitingGate: state.awaitingGate,
|
|
1379
|
+
artifacts: Object.fromEntries(Object.entries(state.artifacts).map(([phase, meta]) => [phase, meta])),
|
|
1380
|
+
consults: Object.keys(state.consults),
|
|
1381
|
+
// S3 之后模型要能自查"取证成没成、归档没归档":没有这两行,它写完验收也不知道
|
|
1382
|
+
// 证据是否真的落盘,只能靠记忆猜。
|
|
1383
|
+
verifications: (state.verifications ?? []).map(record => ({ at: record.at, file: record.file, route: record.provider === null || record.provider === undefined ? null : `${record.provider}/${record.model}` })),
|
|
1384
|
+
gates: state.gates,
|
|
1385
|
+
// 面板与模型都要能一眼看到"这门为什么还没开"/"用户已经定了什么"
|
|
1386
|
+
pendingDecisions: state.pendingDecisions ?? null,
|
|
1387
|
+
// 模型自主裁决(Ruling:)—— 与用户裁决(decisions)分开放:两者都要看得见,
|
|
1388
|
+
// 但不能混为一谈(一个是「我替你定了」,一个是「你定的」)。
|
|
1389
|
+
// shape/size 与这个任务的相位序列:面板据此画板格 —— 裁掉的相位是**不存在**,
|
|
1390
|
+
// 不该在板上留一个永远"待做"的格子。
|
|
1391
|
+
shape: state.shape ?? 'feature',
|
|
1392
|
+
size: state.size ?? 'full',
|
|
1393
|
+
phases: phasesFor(state),
|
|
1394
|
+
// 评审包(S6):执行者落的 `evidence/changes.md`。状态里如实报**在不在** ——
|
|
1395
|
+
// 它由执行者写,所以"有没有"本身就是信息(评审者据此知道看的是不是二手结论)。
|
|
1396
|
+
reviewPackage: existsSync(join(taskDir(rootDir, state.id), 'evidence', 'changes.md')),
|
|
1397
|
+
rulings: Array.isArray(state.rulings) ? state.rulings : [],
|
|
1398
|
+
decided: Object.fromEntries(Object.entries(state.decisions ?? {}).map(([phase, rounds]) => [phase, rounds.reduce((sum, round) => sum + (round.entries?.length ?? 0), 0)])),
|
|
1399
|
+
reworkCount: state.reworkCount,
|
|
1400
|
+
// briefing 面(每阶段:轮数、当前轮、状态、路由、source):模型自查"确认收了没有",
|
|
1401
|
+
// 面板画「待确认/已确认/已过期/生成失败」格。
|
|
1402
|
+
briefings: Object.fromEntries(BRIEFING_PHASES.map(phase => {
|
|
1403
|
+
const entry = state.briefings?.[phase]
|
|
1404
|
+
const round = currentBriefingRound(state, phase)
|
|
1405
|
+
return [phase, {
|
|
1406
|
+
rounds: entry?.rounds?.length ?? 0,
|
|
1407
|
+
current: entry?.current ?? 0,
|
|
1408
|
+
status: briefingStatusOf(entry, ARTIFACT_FILE[phase] === undefined ? null : artifactSha256(rootDir, state.id, phase)),
|
|
1409
|
+
at: round?.at ?? entry?.failedAt ?? null,
|
|
1410
|
+
route: round === null || (round.provider == null && round.model == null) ? null : `${round.provider}/${round.model}`,
|
|
1411
|
+
source: round?.source ?? null,
|
|
1412
|
+
}]
|
|
1413
|
+
})),
|
|
1414
|
+
// 子代理调用是这套流程的主要墙钟与费用:给一个**从磁盘算得出来**的估算(不新增状态面)。
|
|
1415
|
+
// review 侧 = 已落盘的 briefing 轮 + consult 记录 + 取证记录;hand 侧 = 执行报告在不在。
|
|
1416
|
+
// 它只用于"这一轮比上一轮贵/便宜"的对照,不参与任何闸门判定。
|
|
1417
|
+
spawns: spawnEstimate(state),
|
|
1418
|
+
// 真实计数(只在真的起过 child / 真的命中缓存处加一);老 state 读作全零。
|
|
1419
|
+
metrics: metricsOf(state),
|
|
1420
|
+
// 哪些阶段是一次 bundle 出的(同一 child 出要点 + consult)—— 审计要能看出来。
|
|
1421
|
+
reviewBundles: Object.fromEntries(Object.entries(state.reviewBundles ?? {}).map(([phase, bundle]) => [phase, { at: bundle.at, route: bundle.provider == null && bundle.model == null ? null : `${bundle.provider}/${bundle.model}`, source: bundle.source ?? null }])),
|
|
1422
|
+
// 分母也报给模型/面板:额度快用尽时模型该知道"再要一轮就得用户点头"。
|
|
1423
|
+
rework: reworkBudget(state, state.phase, options.maxRework ?? state.settings?.maxRework ?? 5),
|
|
1424
|
+
reworkedFrom: (state.history ?? []).map(entry => ({ at: entry.at, from: entry.from, to: entry.to })),
|
|
1425
|
+
...state.archivedAt === undefined ? {} : { archivedAt: state.archivedAt, summary: state.summary?.file ?? null },
|
|
1426
|
+
gaps,
|
|
1427
|
+
next: nextAction(rootDir, state, gaps),
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* 这套工作流至今花掉了多少次子代理调用(**估算**,从磁盘上的记录数出来)。
|
|
1433
|
+
*
|
|
1434
|
+
* 为什么是估算而不是埋点计数:briefing 轮、consult 记录、取证记录本身就是"一次子代理
|
|
1435
|
+
* 跑完留下的凭据",数它们不需要新的状态面,也不会因为中途崩溃而漏记(崩溃那一轮本来
|
|
1436
|
+
* 就没留下记录)。它够用来回答"优化到底省了没有":v1 的每个设计阶段是 1 briefing +
|
|
1437
|
+
* 1 consult,optimized 的 brainstorm 是 0 + 0。
|
|
1438
|
+
*/
|
|
1439
|
+
export function spawnEstimate(state) {
|
|
1440
|
+
const rounds = Object.values(state.briefings ?? {}).flatMap(entry => entry?.rounds ?? [])
|
|
1441
|
+
// **关键区别**:一轮要点不等于一次子代理调用。`brain/session` 的轮是 brain 自己写的
|
|
1442
|
+
// (optimized 的 brainstorm,零 child),把它算成 review 调用会把这套优化的证据抹掉 ——
|
|
1443
|
+
// 那一版就是错的:轮次被当成调用,于是"省掉一次"在读刻度上等于没省。
|
|
1444
|
+
const inline = rounds.filter(round => round?.source === 'brain/session').length
|
|
1445
|
+
const reviewed = rounds.length - inline
|
|
1446
|
+
const consults = Object.keys(state.consults ?? {}).length
|
|
1447
|
+
const verifications = (state.verifications ?? []).length
|
|
1448
|
+
const hand = state.artifacts?.executing === undefined ? 0 : 1
|
|
1449
|
+
const review = reviewed + consults + verifications
|
|
1450
|
+
// total 只数真正起过的子代理;inline 单独报(它省下来的正是这些)。
|
|
1451
|
+
return { review, hand, inline, total: review + hand }
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* One-line guidance derived from the machine's position.
|
|
1456
|
+
*
|
|
1457
|
+
* Kept accurate as the tool surface grew: it names the tool that moves the task
|
|
1458
|
+
* now (`smart_execute` while executing, `smart_verify` before the verdict), so a
|
|
1459
|
+
* model reading only `smart_status` knows what to do next.
|
|
1460
|
+
*/
|
|
1461
|
+
function nextAction(rootDir, state, gaps) {
|
|
1462
|
+
if (state.phase === 'cancelled') return 'task cancelled'
|
|
1463
|
+
if (state.phase === 'done') {
|
|
1464
|
+
return state.archivedAt === undefined ? 'task complete — archive it with smart_finish' : 'task complete and archived'
|
|
1465
|
+
}
|
|
1466
|
+
if (state.pendingDecisions !== undefined && state.pendingDecisions !== null) {
|
|
1467
|
+
const file = ARTIFACT_FILE[state.phase] ?? 'the artifact'
|
|
1468
|
+
return `the draft still has ${state.pendingDecisions.items} unanswered decision(s) in ${file} — put them to the user (smart_decide), fold the answers in, then re-record the artifact (smart_artifact); the gate opens only after that`
|
|
1469
|
+
}
|
|
1470
|
+
if (state.awaitingGate !== null) {
|
|
1471
|
+
const gate = state.awaitingGate
|
|
1472
|
+
// 把缺口写进指引本身:模型只读 next 也能知道差什么,不必回翻 status.gaps。
|
|
1473
|
+
if (gaps.length > 0) return `close ${gaps.join('; ')}, then ask the user to confirm gate ${gate}`
|
|
1474
|
+
return `ask the user to confirm gate ${gate} (smart_gate)`
|
|
1475
|
+
}
|
|
1476
|
+
// briefing 分支排在 executing 分支**之前**(acceptance B3):执行报告落盘后停在 executing,
|
|
1477
|
+
// 下一步是 briefing 而不是"再执行一次"。产物还没写时不算 —— 那时该写产物。
|
|
1478
|
+
const briefing = state.artifacts[state.phase] === undefined ? [] : briefingGaps(rootDir, state, state.phase)
|
|
1479
|
+
if (briefing.length > 0) {
|
|
1480
|
+
return briefing[0].startsWith('missing briefing')
|
|
1481
|
+
? `generate the briefing (smart_brief), present it to the user in conversation, then record their answer with smart_brief_confirm`
|
|
1482
|
+
: `fix the briefing (${briefing[0]}), then record the user's answer with smart_brief_confirm`
|
|
1483
|
+
}
|
|
1484
|
+
if (state.phase === 'executing') return 'call smart_execute to hand the approved plan to the hand model'
|
|
1485
|
+
if (state.phase === 'reviewing') {
|
|
1486
|
+
return (state.verifications ?? []).length === 0
|
|
1487
|
+
? 'gather evidence with smart_verify, then write review.md (smart_artifact phase="reviewing")'
|
|
1488
|
+
: 'write your verdict as review.md (smart_artifact phase="reviewing")'
|
|
1489
|
+
}
|
|
1490
|
+
const file = ARTIFACT_FILE[state.phase]
|
|
1491
|
+
return file === undefined ? `phase "${state.phase}" has no artifact to write` : `write ${file} (smart_artifact phase="${state.phase}")`
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Record the hand model's review of the current phase (design §4).
|
|
1496
|
+
*
|
|
1497
|
+
* Requires the phase's own artifact to exist — a review of a draft nobody wrote
|
|
1498
|
+
* is not a review. The `handRoute` is stored with the record so the workbench can
|
|
1499
|
+
* prove which route actually reviewed (design §6 warns when it drifts).
|
|
1500
|
+
*
|
|
1501
|
+
* @param rootDir - smart root.
|
|
1502
|
+
* @param id - task id.
|
|
1503
|
+
* @param phase - design phase being reviewed.
|
|
1504
|
+
* @param review - `{ provider, model, question, body }`.
|
|
1505
|
+
* @returns the updated state.
|
|
1506
|
+
*/
|
|
1507
|
+
export function recordConsult(rootDir, id, phase, review = {}) {
|
|
1508
|
+
const state = readState(rootDir, id)
|
|
1509
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1510
|
+
if (!DESIGN_PHASES.includes(phase)) throw new Error(`smart: consults cover the design phases (${DESIGN_PHASES.join(', ')}), not "${phase}"`)
|
|
1511
|
+
if (state.phase !== phase) throw new Error(`smart: task "${id}" is in phase "${state.phase}"; the "${phase}" review comes with its own phase`)
|
|
1512
|
+
if (state.artifacts[phase] === undefined) throw new Error(`smart: write ${ARTIFACT_FILE[phase]} before asking for a review of it`)
|
|
1513
|
+
if (typeof review.body !== 'string' || review.body.trim().length === 0) throw new Error('smart: a consult record needs the review text')
|
|
1514
|
+
const dir = taskDir(rootDir, id)
|
|
1515
|
+
mkdirSync(join(dir, 'consult'), { recursive: true })
|
|
1516
|
+
const at = new Date().toISOString()
|
|
1517
|
+
const record = {
|
|
1518
|
+
phase,
|
|
1519
|
+
at,
|
|
1520
|
+
provider: review.provider ?? null,
|
|
1521
|
+
model: review.model ?? null,
|
|
1522
|
+
question: review.question ?? '',
|
|
1523
|
+
body: review.body,
|
|
1524
|
+
}
|
|
1525
|
+
writeAtomic(join(dir, 'consult', `${phase}.json`), `${JSON.stringify(record, null, 2)}\n`)
|
|
1526
|
+
state.consults[phase] = { at, provider: record.provider, model: record.model, question: record.question, file: `consult/${phase}.json` }
|
|
1527
|
+
state.updatedAt = at
|
|
1528
|
+
bump(state, 'consults', 'reviewSpawns')
|
|
1529
|
+
return writeState(rootDir, state)
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/** One consult record, or null. */
|
|
1533
|
+
export function readConsult(rootDir, id, phase) {
|
|
1534
|
+
try {
|
|
1535
|
+
return JSON.parse(readFileSync(join(taskDir(rootDir, id), 'consult', `${phase}.json`), 'utf8'))
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
if (error?.code === 'ENOENT') return null
|
|
1538
|
+
throw error
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1543
|
+
// Briefing 回路(阶段产物对话式审阅):产物落盘 → review 路由出 briefing → 脑模型用
|
|
1544
|
+
// 对话讲给用户 → `smart_brief_confirm` 收用户真实应答 → **门才开**。
|
|
1545
|
+
//
|
|
1546
|
+
// 设计(docs/smart-mode-design.md「briefing 回路」,acceptance.md A5–A10)里两条承重:
|
|
1547
|
+
//
|
|
1548
|
+
// - **开门路径只有 `confirmBriefing('confirm')`。** `recordArtifact` 不再开门;门是
|
|
1549
|
+
// 用户看懂了的证明,不是"文件写完了"的证明。
|
|
1550
|
+
// - **briefing 绑产物 sha256(重读磁盘算,不采信调用方)。** 产物一改,旧确认自动
|
|
1551
|
+
// stale —— 用户确认的是一份**具体的稿子**,不是这个阶段名。
|
|
1552
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1553
|
+
|
|
1554
|
+
/** Phases whose artifact gets a briefing: every artifact phase. */
|
|
1555
|
+
export const BRIEFING_PHASES = Object.keys(ARTIFACT_FILE)
|
|
1556
|
+
|
|
1557
|
+
/** Absolute path of briefing round `n` for one phase. */
|
|
1558
|
+
export function briefingRoundPath(rootDir, id, phase, n) {
|
|
1559
|
+
return join(taskDir(rootDir, id), 'briefings', `${phase}-${n}.md`)
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* sha256 of the artifact bytes **on disk** (hex), or null when the artifact is missing.
|
|
1564
|
+
*
|
|
1565
|
+
* Re-read every time on purpose: the hash is the binding between a briefing and "the
|
|
1566
|
+
* draft the user confirmed", so it may not trust a caller's copy of either.
|
|
1567
|
+
*/
|
|
1568
|
+
export function artifactSha256(rootDir, id, phase) {
|
|
1569
|
+
const file = ARTIFACT_FILE[phase]
|
|
1570
|
+
if (file === undefined) throw new Error(`smart: "${phase}" is not an artifact phase`)
|
|
1571
|
+
try {
|
|
1572
|
+
return createHash('sha256').update(readFileSync(join(taskDir(rootDir, id), file))).digest('hex')
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
if (error?.code === 'ENOENT') return null
|
|
1575
|
+
throw error
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** One briefing round's body, or null when the file is missing. */
|
|
1580
|
+
export function readBriefingRound(rootDir, id, phase, n) {
|
|
1581
|
+
try {
|
|
1582
|
+
return readFileSync(briefingRoundPath(rootDir, id, phase, n), 'utf8')
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
if (error?.code === 'ENOENT') return null
|
|
1585
|
+
throw error
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* 计划文档契约(`smart-plan` 技能的三块):一份 plan.md 该带、而 smart 原先只要求
|
|
1591
|
+
* 「分组步骤 / 每步验收检查点 / 回滚点」的那三样。
|
|
1592
|
+
*
|
|
1593
|
+
* | 块 | 它买的是什么 |
|
|
1594
|
+
* | --- | --- |
|
|
1595
|
+
* | `Interfaces` | 一个实现者**只看得到自己的任务**,这一块是他怎么知道邻居任务用什么名字与类型 |
|
|
1596
|
+
* | `Global Constraints` | spec 里项目级硬约束的**逐字抄写**(版本下限、命名规则、平台要求)—— 每个任务的隐含前提 |
|
|
1597
|
+
* | `Review Focus` | spec **暗示了但没有任何任务的测试覆盖**的输入类/失败模式。*"spec 的沉默不是允许程序在这个输入上坏掉"* |
|
|
1598
|
+
*
|
|
1599
|
+
* 判据刻意做得**钝**(同 `openDecisions` 的理由):`##` 小节标题 + 三个关键词的字面出现。
|
|
1600
|
+
* 聪明的解析会漏掉一节,而漏掉的那一节正是计划不可执行的原因 —— 一个标题就能拦下整道门。
|
|
1601
|
+
*/
|
|
1602
|
+
export const PLAN_CONTRACT = ['Interfaces', 'Global Constraints', 'Review Focus']
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* plan.md 缺少的计划契约块(空数组 = 齐全)。
|
|
1606
|
+
*
|
|
1607
|
+
* - `Global Constraints` / `Review Focus`:必须是 `##`+ 小节标题(标题文本含该短语)。
|
|
1608
|
+
* - `Interfaces`:任务块里的是 **`**Interfaces:**` 粗体标签**而非标题,所以按关键词判;
|
|
1609
|
+
* 且**每个任务块都要有一份** —— 只在一处写 Interfaces 而有 5 个任务,等于 4 个任务的
|
|
1610
|
+
* 实现者仍然不知道邻居的名字。任务块数按 `^### Task` 数。
|
|
1611
|
+
*
|
|
1612
|
+
* @param markdown - plan.md 的正文。
|
|
1613
|
+
* @returns 缺失项的英文描述,空数组表示契约齐全。
|
|
1614
|
+
*/
|
|
1615
|
+
export function planContractGaps(markdown) {
|
|
1616
|
+
const text = typeof markdown === 'string' ? markdown : ''
|
|
1617
|
+
if (text.trim() === '') return ['plan.md is empty']
|
|
1618
|
+
const gaps = []
|
|
1619
|
+
const headings = artifactSections(text)
|
|
1620
|
+
for (const want of ['Global Constraints', 'Review Focus']) {
|
|
1621
|
+
if (!headings.some(heading => heading.includes(want))) gaps.push(`a "## …${want}…" section`)
|
|
1622
|
+
}
|
|
1623
|
+
// Interfaces:粗体标签,出现在每个 Task 块里。
|
|
1624
|
+
const taskBlocks = text.split('\n').filter(line => /^#{3,4}\s+.*\bTask\b/u.test(line.trim())).length
|
|
1625
|
+
const interfaces = (text.match(/\bInterfaces\b/gu) ?? []).length
|
|
1626
|
+
if (taskBlocks > 0 ? interfaces < taskBlocks : interfaces === 0) {
|
|
1627
|
+
gaps.push(`an "**Interfaces:**" block per task (${taskBlocks > 0 ? `found ${interfaces}, need ${taskBlocks}` : 'need at least one'})`)
|
|
1628
|
+
}
|
|
1629
|
+
for (const want of ['Consumes', 'Produces']) {
|
|
1630
|
+
if (!text.includes(want)) gaps.push(`"**${want}:**" lines under each Interfaces block`)
|
|
1631
|
+
}
|
|
1632
|
+
return gaps
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* plan.md 必须带计划契约 —— **写入处就挡**,不等门。
|
|
1637
|
+
*
|
|
1638
|
+
* 为什么两处都要:等 G3 才撞墙 = 白花 briefing + consult 两轮子代理;门上那道仍在
|
|
1639
|
+
* (defense in depth:盘上的文件也可能被人手改过)。
|
|
1640
|
+
* @param markdown - 要写进 plan.md 的正文。
|
|
1641
|
+
* @throws 契约不齐时逐条列出缺什么。
|
|
1642
|
+
*/
|
|
1643
|
+
export function assertPlanContract(markdown) {
|
|
1644
|
+
const gaps = planContractGaps(markdown)
|
|
1645
|
+
if (gaps.length > 0) {
|
|
1646
|
+
throw new Error(`smart: plan.md does not carry the document contract — ${gaps.join('; ')} — see the smart-plan skill for what each block must carry (an "**Interfaces:**" block per task with Consumes/Produces, a "## Global Constraints" section, a "## Review Focus" section)`)
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* The artifact's section headings a briefing must mention.
|
|
1652
|
+
*
|
|
1653
|
+
* ATX `##` and deeper, fence-aware (a `## fake` inside a fenced code block is not a
|
|
1654
|
+
* section), heading text trimmed the way `openDecisions` trims. A level-1 `#` title is
|
|
1655
|
+
* the document name, not a section.
|
|
1656
|
+
*/
|
|
1657
|
+
export function artifactSections(markdown) {
|
|
1658
|
+
if (typeof markdown !== 'string') return []
|
|
1659
|
+
const headings = []
|
|
1660
|
+
let fenced = false
|
|
1661
|
+
for (const raw of markdown.split('\n')) {
|
|
1662
|
+
const line = raw.trim()
|
|
1663
|
+
if (/^(```|~~~)/u.test(line)) { fenced = !fenced; continue }
|
|
1664
|
+
if (fenced) continue
|
|
1665
|
+
if (/^#{2,6}\s/u.test(line)) headings.push(line.replace(/^#{1,6}\s*/u, '').trim())
|
|
1666
|
+
}
|
|
1667
|
+
return headings
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* What a briefing body fails to cover (empty list = complete).
|
|
1672
|
+
*
|
|
1673
|
+
* Section rule (acceptance A1): every `##`+ heading text must appear in the body as a
|
|
1674
|
+
* literal substring. Artifact without such headings ⇒ the body must be non-empty and
|
|
1675
|
+
* carry the three keywords 结论 / 风险 / 未决. A long body proves nothing on its own.
|
|
1676
|
+
*/
|
|
1677
|
+
export function missingBriefingSections(artifact, body) {
|
|
1678
|
+
const text = typeof body === 'string' ? body : ''
|
|
1679
|
+
const missing = []
|
|
1680
|
+
if (text.trim() === '') missing.push('briefing body is empty')
|
|
1681
|
+
const sections = artifactSections(artifact)
|
|
1682
|
+
if (sections.length === 0) {
|
|
1683
|
+
for (const word of ['结论', '风险', '未决']) if (!text.includes(word)) missing.push(`keyword "${word}"`)
|
|
1684
|
+
} else {
|
|
1685
|
+
for (const heading of sections) if (!text.includes(heading)) missing.push(`section "${heading}"`)
|
|
1686
|
+
}
|
|
1687
|
+
return missing
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/** The current round record of one phase's briefing, or null. */
|
|
1691
|
+
function currentBriefingRound(state, phase) {
|
|
1692
|
+
const entry = state.briefings?.[phase]
|
|
1693
|
+
if (entry === undefined) return null
|
|
1694
|
+
const n = entry.current ?? 0
|
|
1695
|
+
return (entry.rounds ?? []).find(round => round.n === n) ?? null
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Derived briefing status (acceptance A10's total ordering — keep in sync with the
|
|
1700
|
+
* panel mapping). `failedAt` counts only when it is **strictly** newer than the latest
|
|
1701
|
+
* round; equal timestamps fall through (a failure marker and its retry share a clock).
|
|
1702
|
+
*
|
|
1703
|
+
* ① failedAt later than latest round ⇒ failed ② no round ⇒ none ③ round sha ≠ artifact
|
|
1704
|
+
* sha ⇒ stale ④ decision confirm ⇒ confirmed ⑤ decision revise ⇒ revise ⑥ awaiting-user.
|
|
1705
|
+
*/
|
|
1706
|
+
export function briefingStatusOf(entry, artifactSha) {
|
|
1707
|
+
const rounds = entry?.rounds ?? []
|
|
1708
|
+
const latest = rounds.at(-1)?.at ?? null
|
|
1709
|
+
if (typeof entry?.failedAt === 'string' && (latest === null || entry.failedAt > latest)) return 'failed'
|
|
1710
|
+
const round = entry === undefined ? null : rounds.find(item => item.n === (entry.current ?? 0))
|
|
1711
|
+
if (round === undefined || round === null) return 'none'
|
|
1712
|
+
if (round.artifactSha256 !== artifactSha) return 'stale'
|
|
1713
|
+
if (round.decision === 'confirm') return 'confirmed'
|
|
1714
|
+
if (round.decision === 'revise') return 'revise'
|
|
1715
|
+
return 'awaiting-user'
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/** Re-derive every phase's `status` in place (readers want it fresh, not remembered). */
|
|
1719
|
+
export function refreshBriefings(rootDir, state) {
|
|
1720
|
+
for (const phase of BRIEFING_PHASES) {
|
|
1721
|
+
const entry = state.briefings?.[phase]
|
|
1722
|
+
if (entry === undefined) continue
|
|
1723
|
+
entry.status = briefingStatusOf(entry, artifactSha256(rootDir, state.id, phase))
|
|
1724
|
+
}
|
|
1725
|
+
return state
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/** The index-row / panel cell for one phase's briefing (`null` = unknown). */
|
|
1729
|
+
export function briefingCell(rootDir, state, phase) {
|
|
1730
|
+
const entry = state.briefings?.[phase]
|
|
1731
|
+
// done/cancelled 不是产物阶段:没有产物可绑,格子照实报 none(不是抛错)。
|
|
1732
|
+
const sha = ARTIFACT_FILE[phase] === undefined ? null : artifactSha256(rootDir, state.id, phase)
|
|
1733
|
+
const status = briefingStatusOf(entry, sha)
|
|
1734
|
+
const round = currentBriefingRound(state, phase)
|
|
1735
|
+
return {
|
|
1736
|
+
status,
|
|
1737
|
+
at: round?.at ?? entry?.failedAt ?? null,
|
|
1738
|
+
route: round === null || (round.provider == null && round.model == null) ? null : `${round.provider}/${round.model}`,
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Record one briefing round for a phase artifact (design §briefing).
|
|
1744
|
+
*
|
|
1745
|
+
* @param rootDir - smart root.
|
|
1746
|
+
* @param id - task id.
|
|
1747
|
+
* @param phase - phase the briefing covers.
|
|
1748
|
+
* @param review - `{ provider, model, source, body }`.
|
|
1749
|
+
* @returns the updated state.
|
|
1750
|
+
* @throws {Error} when there is no artifact, the body is empty, or (A8) the gate is
|
|
1751
|
+
* open and the current round is already confirmed and fresh — a regeneration must
|
|
1752
|
+
* never silently discard what the user confirmed.
|
|
1753
|
+
*/
|
|
1754
|
+
export function recordBriefing(rootDir, id, phase, review = {}) {
|
|
1755
|
+
const state = readState(rootDir, id)
|
|
1756
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1757
|
+
if (ARTIFACT_FILE[phase] === undefined) throw new Error(`smart: "${phase}" is not an artifact phase`)
|
|
1758
|
+
if (state.phase !== phase) throw new Error(`smart: task "${id}" is in phase "${state.phase}"; the "${phase}" briefing comes with its own phase`)
|
|
1759
|
+
const artifact = readArtifact(rootDir, id, phase)
|
|
1760
|
+
if (artifact === null) throw new Error(`smart: write ${ARTIFACT_FILE[phase]} before briefing it`)
|
|
1761
|
+
if (typeof review.body !== 'string' || review.body.trim().length === 0) throw new Error('smart: a briefing needs its body text')
|
|
1762
|
+
const sha = artifactSha256(rootDir, id, phase)
|
|
1763
|
+
const current = currentBriefingRound(state, phase)
|
|
1764
|
+
if (state.awaitingGate !== null && current !== null && current.decision === 'confirm' && current.artifactSha256 === sha) {
|
|
1765
|
+
throw new Error(`smart: the briefing for "${phase}" is confirmed and current while gate ${state.awaitingGate} is open — answer 需要修改 at smart_brief_confirm (or pass the gate) instead of regenerating over the user's confirmation`)
|
|
1766
|
+
}
|
|
1767
|
+
const at = new Date().toISOString()
|
|
1768
|
+
const entry = state.briefings?.[phase] ?? { rounds: [], current: 0 }
|
|
1769
|
+
const n = (entry.rounds.at(-1)?.n ?? 0) + 1
|
|
1770
|
+
const dir = join(taskDir(rootDir, id), 'briefings')
|
|
1771
|
+
mkdirSync(dir, { recursive: true })
|
|
1772
|
+
writeAtomic(briefingRoundPath(rootDir, id, phase, n), review.body.endsWith('\n') ? review.body : `${review.body}\n`)
|
|
1773
|
+
entry.rounds.push({
|
|
1774
|
+
n,
|
|
1775
|
+
at,
|
|
1776
|
+
file: `briefings/${phase}-${n}.md`,
|
|
1777
|
+
provider: review.provider ?? null,
|
|
1778
|
+
model: review.model ?? null,
|
|
1779
|
+
source: review.source ?? null,
|
|
1780
|
+
// 返工升档(S4):本阶段反复修不好的点,评审那侧被提到该模型支持的最高档。
|
|
1781
|
+
// 如实记下来 —— 用户有权知道这一轮的评审**比上一轮更用力**,以及为什么。
|
|
1782
|
+
escalated: review.escalated === true,
|
|
1783
|
+
attempts: typeof review.attempts === 'number' ? review.attempts : 0,
|
|
1784
|
+
artifactSha256: sha,
|
|
1785
|
+
decision: null,
|
|
1786
|
+
confirmedAt: null,
|
|
1787
|
+
})
|
|
1788
|
+
entry.current = n
|
|
1789
|
+
state.briefings = { ...(state.briefings ?? {}), [phase]: entry }
|
|
1790
|
+
state.updatedAt = at
|
|
1791
|
+
// 一轮要点背后可能是 review child,也可能是 brain 自己写的:两者在成本上是两回事。
|
|
1792
|
+
bump(state, 'briefings')
|
|
1793
|
+
bump(state, review.source === 'brain/session' ? 'brainRounds' : 'reviewSpawns')
|
|
1794
|
+
return writeState(rootDir, refreshBriefings(rootDir, state))
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* Mark the last generation attempt as failed (no round is written for it).
|
|
1799
|
+
*
|
|
1800
|
+
* The panel needs to say 生成失败, so the failure itself is state — "wrote nothing" is
|
|
1801
|
+
* indistinguishable from "never tried" otherwise (acceptance A10).
|
|
1802
|
+
*/
|
|
1803
|
+
export function recordBriefingFailure(rootDir, id, phase, error) {
|
|
1804
|
+
const state = readState(rootDir, id)
|
|
1805
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1806
|
+
const at = new Date().toISOString()
|
|
1807
|
+
const entry = state.briefings?.[phase] ?? { rounds: [], current: 0 }
|
|
1808
|
+
entry.failedAt = at
|
|
1809
|
+
entry.lastError = String(error?.message ?? error).slice(0, 500)
|
|
1810
|
+
state.briefings = { ...(state.briefings ?? {}), [phase]: entry }
|
|
1811
|
+
state.updatedAt = at
|
|
1812
|
+
return writeState(rootDir, refreshBriefings(rootDir, state))
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Record the user's answer to a briefing (acceptance A7).
|
|
1817
|
+
*
|
|
1818
|
+
* The decision comes from `userQuestions.ask`'s real answer — never from a tool
|
|
1819
|
+
* argument. Only the **current, still-unanswered** round may be confirmed (the caller
|
|
1820
|
+
* passes the round number it showed the user; a newer round appearing in between makes
|
|
1821
|
+
* the answer stale).
|
|
1822
|
+
*
|
|
1823
|
+
* `confirm` opens the phase gate (`G1..G4`), advances executing → reviewing, or opens
|
|
1824
|
+
* `G5` from reviewing. `revise` closes the gate without consuming rework budget.
|
|
1825
|
+
*
|
|
1826
|
+
* @param rootDir - smart root.
|
|
1827
|
+
* @param id - task id.
|
|
1828
|
+
* @param phase - phase the briefing belongs to.
|
|
1829
|
+
* @param decision - `'confirm' | 'revise'`.
|
|
1830
|
+
* @param options - `{ round }`: the round number the user was shown.
|
|
1831
|
+
* @returns the updated state.
|
|
1832
|
+
*/
|
|
1833
|
+
export function confirmBriefing(rootDir, id, phase, decision, options = {}) {
|
|
1834
|
+
const state = readState(rootDir, id)
|
|
1835
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1836
|
+
if (decision !== 'confirm' && decision !== 'revise') throw new Error(`smart: briefing decision must be "confirm" or "revise", got "${decision}"`)
|
|
1837
|
+
const entry = state.briefings?.[phase]
|
|
1838
|
+
const round = currentBriefingRound(state, phase)
|
|
1839
|
+
if (entry === undefined || round === null) throw new Error(`smart: no briefing round to answer for "${phase}" — generate one first (smart_brief)`)
|
|
1840
|
+
if (options.round !== undefined && options.round !== round.n) {
|
|
1841
|
+
throw new Error(`smart: briefing round ${options.round} of "${phase}" is not the current one (now ${round.n}) — the user's answer refers to what they were shown; show them the new round`)
|
|
1842
|
+
}
|
|
1843
|
+
if (round.decision !== null) throw new Error(`smart: briefing round ${round.n} of "${phase}" is already answered (${round.decision})`)
|
|
1844
|
+
const artifact = readArtifact(rootDir, id, phase)
|
|
1845
|
+
const open = openDecisions(artifact)
|
|
1846
|
+
if (open.headings.length > 0) {
|
|
1847
|
+
throw new Error(`smart: ${ARTIFACT_FILE[phase]} still has unanswered decision(s) under "${open.headings.join(' / ')}" — put them to the user (smart_decide) and fold the answers in before confirming the briefing`)
|
|
1848
|
+
}
|
|
1849
|
+
const sha = artifactSha256(rootDir, id, phase)
|
|
1850
|
+
if (round.artifactSha256 !== sha) {
|
|
1851
|
+
throw new Error(`smart: briefing round ${round.n} of "${phase}" is stale — ${ARTIFACT_FILE[phase]} changed after it was generated; regenerate (smart_brief) and confirm the new round`)
|
|
1852
|
+
}
|
|
1853
|
+
const at = new Date().toISOString()
|
|
1854
|
+
round.decision = decision
|
|
1855
|
+
round.confirmedAt = at
|
|
1856
|
+
if (decision === 'revise') {
|
|
1857
|
+
// 用户要改稿:门保持关,且**不消耗**返工额度 —— 改稿是对话的一部分,不是返工。
|
|
1858
|
+
state.awaitingGate = null
|
|
1859
|
+
} else if (phase === 'executing') {
|
|
1860
|
+
// G2 裁决①:执行报告确认后才进验收(执行→验收的确认门,不占 G1–G5 编号)。
|
|
1861
|
+
state.phase = nextPhaseFor(state, phase) ?? NEXT_PHASE[phase]
|
|
1862
|
+
state.awaitingGate = null
|
|
1863
|
+
} else {
|
|
1864
|
+
state.awaitingGate = GATE_AFTER[phase] ?? null
|
|
1865
|
+
}
|
|
1866
|
+
state.updatedAt = at
|
|
1867
|
+
return writeState(rootDir, refreshBriefings(rootDir, state))
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* 一次 review child 同时产出「用户版要点」与「可行性 consult」时,两段一起落盘。
|
|
1872
|
+
*
|
|
1873
|
+
* 这是 optimized 工作流的省钱点:design/plan/acceptance 三段原本各要 2 次子代理调用
|
|
1874
|
+
* (auto briefing + consult),合成一次 bundle 后各只要 1 次。**两段的审计记录仍然分开**
|
|
1875
|
+
* (`briefings/` 与 `consult/` 各写各的),`reviewBundles` 只记"这一次是同一个 child 出的"。
|
|
1876
|
+
*
|
|
1877
|
+
* 原子性:两个内容文件先一起写(`writeAtomicMany`),state 只更新一次 —— 不会出现
|
|
1878
|
+
* "consult 有了、要点没有"这种半套成功;任一步失败就只剩产物本身,可重试。
|
|
1879
|
+
*
|
|
1880
|
+
* @param rootDir - smart root.
|
|
1881
|
+
* @param id - task id.
|
|
1882
|
+
* @param phase - phase being reviewed.
|
|
1883
|
+
* @param bundle - `{ provider, model, source, briefingBody, consultBody }`.
|
|
1884
|
+
* @returns the updated state.
|
|
1885
|
+
*/
|
|
1886
|
+
export function recordReviewBundle(rootDir, id, phase, bundle = {}) {
|
|
1887
|
+
const state = readState(rootDir, id)
|
|
1888
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
1889
|
+
if (state.phase !== phase) throw new Error(`smart: task "${id}" is in phase "${state.phase}"; the "${phase}" bundle comes with its own phase`)
|
|
1890
|
+
if (ARTIFACT_FILE[phase] === undefined) throw new Error(`smart: "${phase}" is not an artifact phase`)
|
|
1891
|
+
const artifact = readArtifact(rootDir, id, phase)
|
|
1892
|
+
if (artifact === null) throw new Error(`smart: write ${ARTIFACT_FILE[phase]} before reviewing it`)
|
|
1893
|
+
const briefingBody = typeof bundle.briefingBody === 'string' ? bundle.briefingBody : ''
|
|
1894
|
+
const consultBody = typeof bundle.consultBody === 'string' ? bundle.consultBody : ''
|
|
1895
|
+
if (briefingBody.trim() === '' || consultBody.trim() === '') {
|
|
1896
|
+
throw new Error('smart: a review bundle needs both halves — the user-facing briefing and the feasibility consult; nothing was recorded')
|
|
1897
|
+
}
|
|
1898
|
+
const missing = missingBriefingSections(artifact, briefingBody)
|
|
1899
|
+
if (missing.length > 0) throw new Error(`smart: the briefing half of the bundle for "${phase}" does not cover the artifact — missing: ${missing.join('; ')}. Nothing was recorded.`)
|
|
1900
|
+
|
|
1901
|
+
const entry = state.briefings?.[phase] ?? { rounds: [], current: 0 }
|
|
1902
|
+
const n = (entry.rounds.at(-1)?.n ?? 0) + 1
|
|
1903
|
+
const dir = join(taskDir(rootDir, id), 'briefings')
|
|
1904
|
+
mkdirSync(dir, { recursive: true })
|
|
1905
|
+
mkdirSync(join(taskDir(rootDir, id), 'consult'), { recursive: true })
|
|
1906
|
+
const at = new Date().toISOString()
|
|
1907
|
+
const roundFile = `briefings/${phase}-${n}.md`
|
|
1908
|
+
const consultFile = `consult/${phase}.json`
|
|
1909
|
+
const record = {
|
|
1910
|
+
phase,
|
|
1911
|
+
at,
|
|
1912
|
+
provider: bundle.provider ?? null,
|
|
1913
|
+
model: bundle.model ?? null,
|
|
1914
|
+
question: bundle.question ?? '',
|
|
1915
|
+
body: consultBody,
|
|
1916
|
+
}
|
|
1917
|
+
writeAtomicMany([
|
|
1918
|
+
{ file: join(taskDir(rootDir, id), roundFile), text: briefingBody.endsWith('\n') ? briefingBody : `${briefingBody}\n` },
|
|
1919
|
+
{ file: join(taskDir(rootDir, id), consultFile), text: `${JSON.stringify(record, null, 2)}\n` },
|
|
1920
|
+
])
|
|
1921
|
+
|
|
1922
|
+
const companion = readCompanionSha(rootDir, id, phase)
|
|
1923
|
+
entry.rounds.push({
|
|
1924
|
+
n,
|
|
1925
|
+
at,
|
|
1926
|
+
file: roundFile,
|
|
1927
|
+
provider: bundle.provider ?? null,
|
|
1928
|
+
model: bundle.model ?? null,
|
|
1929
|
+
source: bundle.source ?? null,
|
|
1930
|
+
artifactSha256: artifactSha256(rootDir, id, phase),
|
|
1931
|
+
...companion === undefined ? {} : { companionSha256: companion },
|
|
1932
|
+
decision: null,
|
|
1933
|
+
confirmedAt: null,
|
|
1934
|
+
bundled: true,
|
|
1935
|
+
})
|
|
1936
|
+
entry.current = n
|
|
1937
|
+
state.briefings = { ...(state.briefings ?? {}), [phase]: entry }
|
|
1938
|
+
state.consults[phase] = { at, provider: record.provider, model: record.model, question: record.question, file: consultFile, bundled: true }
|
|
1939
|
+
state.reviewBundles = {
|
|
1940
|
+
...(state.reviewBundles ?? {}),
|
|
1941
|
+
[phase]: {
|
|
1942
|
+
at,
|
|
1943
|
+
artifactSha256: artifactSha256(rootDir, id, phase),
|
|
1944
|
+
...companion === undefined ? {} : { companionSha256: companion },
|
|
1945
|
+
provider: record.provider,
|
|
1946
|
+
model: record.model,
|
|
1947
|
+
source: bundle.source ?? null,
|
|
1948
|
+
},
|
|
1949
|
+
}
|
|
1950
|
+
state.updatedAt = at
|
|
1951
|
+
// 一次 child 产出两段 ⇒ review 调用只记一次(这是省下来的那一次)。
|
|
1952
|
+
bump(state, 'reviewSpawns', 'briefings', 'consults')
|
|
1953
|
+
return writeState(rootDir, refreshBriefings(rootDir, state))
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
/** 伴随产物的 sha(没有伴随产物 ⇒ undefined)。 */
|
|
1957
|
+
function readCompanionSha(rootDir, id, phase) {
|
|
1958
|
+
const companion = companionFileOf(phase)
|
|
1959
|
+
if (companion === null) return undefined
|
|
1960
|
+
try {
|
|
1961
|
+
return createHash('sha256').update(readFileSync(join(taskDir(rootDir, id), companion))).digest('hex')
|
|
1962
|
+
} catch {
|
|
1963
|
+
return undefined
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* 这个阶段有没有**仍然 fresh** 的 bundle(同一份产物、含伴随文件)。
|
|
1969
|
+
*
|
|
1970
|
+
* fresh 的判据是 sha 而不是时间:产物一改(哪怕同长度),旧 bundle 里的 consult 就
|
|
1971
|
+
* 不再是对这一版稿子的意见,必须重跑 —— 否则用户拿到的是一份过期评审。
|
|
1972
|
+
*/
|
|
1973
|
+
export function freshReviewBundle(rootDir, state, phase) {
|
|
1974
|
+
const bundle = state.reviewBundles?.[phase]
|
|
1975
|
+
if (bundle === undefined) return null
|
|
1976
|
+
if (bundle.artifactSha256 !== artifactSha256(rootDir, state.id, phase)) return null
|
|
1977
|
+
const companion = readCompanionSha(rootDir, state.id, phase)
|
|
1978
|
+
if (companion !== undefined && bundle.companionSha256 !== companion) return null
|
|
1979
|
+
if (companion === undefined && bundle.companionSha256 !== undefined) return null
|
|
1980
|
+
return bundle
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/**
|
|
1984
|
+
* What still blocks treating a phase's briefing as confirmed (acceptance A5's four
|
|
1985
|
+
* conditions). Empty list = the briefing is usable.
|
|
1986
|
+
*
|
|
1987
|
+
* Conditions are checked against disk, not memory: the round body is re-read and the
|
|
1988
|
+
* artifact hash recomputed, so out-of-band edits cannot smuggle a stale confirmation
|
|
1989
|
+
* past the gate.
|
|
1990
|
+
*/
|
|
1991
|
+
export function briefingGaps(rootDir, state, phase) {
|
|
1992
|
+
const entry = state.briefings?.[phase]
|
|
1993
|
+
const round = currentBriefingRound(state, phase)
|
|
1994
|
+
if (entry === undefined || round === null) return [`missing briefing for "${phase}" (generate one with smart_brief)`]
|
|
1995
|
+
const body = readBriefingRound(rootDir, state.id, phase, round.n)
|
|
1996
|
+
if (body === null || body.trim() === '') return [`briefing round ${round.n} of "${phase}" is empty on disk (${round.file})`]
|
|
1997
|
+
const sha = artifactSha256(rootDir, state.id, phase)
|
|
1998
|
+
if (round.artifactSha256 !== sha) return [`briefing round ${round.n} of "${phase}" is stale — ${ARTIFACT_FILE[phase]} changed after it was generated`]
|
|
1999
|
+
if (round.decision !== 'confirm') return [`briefing round ${round.n} of "${phase}" is not confirmed (answer smart_brief_confirm first)`]
|
|
2000
|
+
return []
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* How many times this phase has already been redone — counted **per phase** off the
|
|
2005
|
+
* history (`to` is the phase we returned to).
|
|
2006
|
+
*
|
|
2007
|
+
* @param state - task state.
|
|
2008
|
+
* @param phase - design phase.
|
|
2009
|
+
* @returns the count.
|
|
2010
|
+
*/
|
|
2011
|
+
export function reworkUsedIn(state, phase) {
|
|
2012
|
+
return (state.history ?? []).filter(entry => entry.to === phase).length
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* A phase's rework budget: `{ phase, used, cap, grants, maxRework }`.
|
|
2017
|
+
*
|
|
2018
|
+
* `cap` is `maxRework` per human grant: each time the user answers 「继续重做」 at a
|
|
2019
|
+
* gate that has run out, they buy another full budget (recorded in `reworkGrants`,
|
|
2020
|
+
* so the audit shows the loop was authorized, not self-extended).
|
|
2021
|
+
*
|
|
2022
|
+
* @param state - task state.
|
|
2023
|
+
* @param phase - design phase.
|
|
2024
|
+
* @param maxRework - the row config's per-phase allowance.
|
|
2025
|
+
* @returns the budget.
|
|
2026
|
+
*/
|
|
2027
|
+
export function reworkBudget(state, phase, maxRework = 5) {
|
|
2028
|
+
const grants = (state.reworkGrants ?? []).filter(entry => entry.phase === phase)
|
|
2029
|
+
return { phase, used: reworkUsedIn(state, phase), cap: maxRework * (1 + grants.length), grants: grants.length, maxRework }
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Record the user's authorization for another rework budget in `phase`.
|
|
2034
|
+
*
|
|
2035
|
+
* Only the gate calls this, and only on an explicit answer: a model that could grant
|
|
2036
|
+
* itself more budget would make the bound meaningless.
|
|
2037
|
+
*
|
|
2038
|
+
* @param rootDir - smart root.
|
|
2039
|
+
* @param id - task id.
|
|
2040
|
+
* @param phase - design phase the extra budget belongs to.
|
|
2041
|
+
* @param meta - `{ by?, note?, maxRework? }`.
|
|
2042
|
+
* @returns the updated state.
|
|
2043
|
+
*/
|
|
2044
|
+
export function grantRework(rootDir, id, phase, meta = {}) {
|
|
2045
|
+
const state = readState(rootDir, id)
|
|
2046
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
2047
|
+
if (!DESIGN_PHASES.includes(phase)) throw new Error(`smart: rework grants can only name a design phase (${DESIGN_PHASES.join(', ')})`)
|
|
2048
|
+
const at = new Date().toISOString()
|
|
2049
|
+
state.reworkGrants = [...(state.reworkGrants ?? []), {
|
|
2050
|
+
phase,
|
|
2051
|
+
at,
|
|
2052
|
+
by: meta.by ?? 'user',
|
|
2053
|
+
budget: meta.maxRework ?? 5,
|
|
2054
|
+
...typeof meta.note === 'string' && meta.note !== '' ? { note: meta.note } : {},
|
|
2055
|
+
}]
|
|
2056
|
+
state.updatedAt = at
|
|
2057
|
+
return writeState(rootDir, state)
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/**
|
|
2061
|
+
* Send the task back to an earlier design phase (gate "退回" / acceptance
|
|
2062
|
+
* failure, design §3 rework loop).
|
|
2063
|
+
*
|
|
2064
|
+
* The superseded artifacts are **moved to `history/`, never deleted**: the whole
|
|
2065
|
+
* point of a rework is that we can see what was rejected. Counting is **per phase**
|
|
2066
|
+
* and bounded by `maxRework`; the machine refuses to loop on its own, but running
|
|
2067
|
+
* out is **the user's call, not a forced finish** — at the gate they can pick
|
|
2068
|
+
* 「继续重做」 and `grantRework()` buys another budget (see `index.js`).
|
|
2069
|
+
*
|
|
2070
|
+
* @param rootDir - smart root.
|
|
2071
|
+
* @param id - task id.
|
|
2072
|
+
* @param phase - design phase to return to.
|
|
2073
|
+
* @param reason - why (recorded).
|
|
2074
|
+
* @param options - `{ maxRework }` from row config.
|
|
2075
|
+
* @returns the updated state.
|
|
2076
|
+
*/
|
|
2077
|
+
export function reworkTo(rootDir, id, phase, reason = '', options = {}) {
|
|
2078
|
+
const state = readState(rootDir, id)
|
|
2079
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
2080
|
+
if (!DESIGN_PHASES.includes(phase)) throw new Error(`smart: rework can only return to a design phase (${DESIGN_PHASES.join(', ')})`)
|
|
2081
|
+
const budget = reworkBudget(state, phase, options.maxRework ?? 5)
|
|
2082
|
+
// 未按规定触发「额度耗尽 ⇒ 强制收尾」:额度不是墙,找谁开门要说清楚。
|
|
2083
|
+
if (budget.used >= budget.cap) {
|
|
2084
|
+
throw new Error(
|
|
2085
|
+
`smart: task "${id}" has used its ${budget.cap} rework(s) into "${phase}" — the machine will not loop on its own; `
|
|
2086
|
+
+ `the user decides at the gate (option 「继续重做」 grants another ${budget.maxRework}), not the model`,
|
|
2087
|
+
)
|
|
2088
|
+
}
|
|
2089
|
+
state.settings = { ...(state.settings ?? {}), maxRework: budget.maxRework }
|
|
2090
|
+
const from = state.phase
|
|
2091
|
+
const index = DESIGN_PHASES.indexOf(phase)
|
|
2092
|
+
const dir = taskDir(rootDir, id)
|
|
2093
|
+
mkdirSync(join(dir, 'history'), { recursive: true })
|
|
2094
|
+
const at = new Date().toISOString()
|
|
2095
|
+
const stamp = at.replace(/[:.]/gu, '-')
|
|
2096
|
+
const archived = []
|
|
2097
|
+
for (const dropping of [...DESIGN_PHASES.slice(index), 'executing', 'reviewing']) {
|
|
2098
|
+
if (state.artifacts[dropping] === undefined) continue
|
|
2099
|
+
const file = ARTIFACT_FILE[dropping]
|
|
2100
|
+
renameSync(join(dir, file), join(dir, 'history', `${stamp}-${file}`))
|
|
2101
|
+
// 伴随产物跟着主产物一起进 history —— 留一份孤儿 acceptance.md 在盘上,下一次落盘
|
|
2102
|
+
// 会把它当成"已经是这一轮的产物"读进来。
|
|
2103
|
+
const companion = state.workflow === 'optimized'
|
|
2104
|
+
? state.artifacts[dropping]?.companion ?? companionFileOf(dropping)
|
|
2105
|
+
: null
|
|
2106
|
+
if (typeof companion === 'string' && existsSync(join(dir, companion))) {
|
|
2107
|
+
renameSync(join(dir, companion), join(dir, 'history', `${stamp}-${companion}`))
|
|
2108
|
+
}
|
|
2109
|
+
archived.push(dropping)
|
|
2110
|
+
delete state.artifacts[dropping]
|
|
2111
|
+
if (state.consults[dropping] !== undefined) {
|
|
2112
|
+
renameSync(join(dir, 'consult', `${dropping}.json`), join(dir, 'history', `${stamp}-consult-${dropping}.json`))
|
|
2113
|
+
delete state.consults[dropping]
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
state.phase = phase
|
|
2117
|
+
state.awaitingGate = null
|
|
2118
|
+
state.reworkCount += 1
|
|
2119
|
+
state.history.push({ at, from, to: phase, reason, archived })
|
|
2120
|
+
state.updatedAt = at
|
|
2121
|
+
return writeState(rootDir, state)
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/**
|
|
2125
|
+
* Record one verification run's evidence (design §5, `smart_verify`).
|
|
2126
|
+
*
|
|
2127
|
+
* The evidence is a file, not a promise: the workbench and the review both read
|
|
2128
|
+
* it from disk, and the route that produced it is stored beside it so a drifted
|
|
2129
|
+
* reviewer is visible rather than trusted.
|
|
2130
|
+
*
|
|
2131
|
+
* @param rootDir - smart root.
|
|
2132
|
+
* @param id - task id.
|
|
2133
|
+
* @param evidence - `{ provider, model, body, acceptance }`.
|
|
2134
|
+
* @returns the updated state.
|
|
2135
|
+
*/
|
|
2136
|
+
export function recordVerification(rootDir, id, evidence = {}) {
|
|
2137
|
+
const state = readState(rootDir, id)
|
|
2138
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
2139
|
+
if (state.phase !== 'reviewing') throw new Error(`smart: verification belongs to the review phase; task "${id}" is in "${state.phase}"`)
|
|
2140
|
+
if (typeof evidence.body !== 'string' || evidence.body.trim().length === 0) throw new Error('smart: a verification run needs its evidence text')
|
|
2141
|
+
const dir = taskDir(rootDir, id)
|
|
2142
|
+
mkdirSync(join(dir, 'verify'), { recursive: true })
|
|
2143
|
+
const at = new Date().toISOString()
|
|
2144
|
+
const file = `verify/${at.replace(/[:.]/gu, '-')}.md`
|
|
2145
|
+
writeAtomic(join(dir, file), `${evidence.body.endsWith('\n') ? evidence.body : `${evidence.body}\n`}`)
|
|
2146
|
+
state.verifications ??= []
|
|
2147
|
+
state.verifications.push({ at, file, provider: evidence.provider ?? null, model: evidence.model ?? null, acceptance: evidence.acceptance ?? null })
|
|
2148
|
+
state.updatedAt = at
|
|
2149
|
+
bump(state, 'verifications', 'reviewSpawns')
|
|
2150
|
+
return writeState(rootDir, state)
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/** Every verification record, oldest first. */
|
|
2154
|
+
export function listVerifications(state) {
|
|
2155
|
+
return state.verifications ?? []
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
/**
|
|
2159
|
+
* Archive a finished task (design §5, `smart_finish`).
|
|
2160
|
+
*
|
|
2161
|
+
* Only a task whose final gate passed can be archived — the machine will not let
|
|
2162
|
+
* a summary paper over an open gate.
|
|
2163
|
+
*
|
|
2164
|
+
* The task belongs to a **workspace** now, so archiving has two halves: the hidden
|
|
2165
|
+
* tree keeps working state, and the workspace gets the delivery copy under
|
|
2166
|
+
* `docs/smart/<taskId>/`. The summary is written to the hidden tree **first**, so a
|
|
2167
|
+
* promotion that fails (read-only volume, cross-device rename, EACCES) leaves a
|
|
2168
|
+
* retryable task rather than a lost one — and it is explicitly **not** marked
|
|
2169
|
+
* archived in that case.
|
|
2170
|
+
*
|
|
2171
|
+
* @param rootDir - smart root.
|
|
2172
|
+
* @param id - task id.
|
|
2173
|
+
* @param summary - closing summary markdown.
|
|
2174
|
+
* @param options - `{ workspace, workspaceSource }`: the calling session's workspace
|
|
2175
|
+
* (`args.workspace` → `state.workspace` → the session's `header.cwd`, resolved by
|
|
2176
|
+
* the caller, which is the only place that has the session).
|
|
2177
|
+
* @returns the updated state.
|
|
2178
|
+
*/
|
|
2179
|
+
export function finishTask(rootDir, id, summary = '', options = {}) {
|
|
2180
|
+
const state = readState(rootDir, id)
|
|
2181
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
2182
|
+
if (state.phase !== 'done') throw new Error(`smart: task "${id}" is in phase "${state.phase}"; only a task past gate G5 can be archived`)
|
|
2183
|
+
if (state.archivedAt !== undefined) throw new Error(`smart: task "${id}" is already archived (${state.archivedAt})`)
|
|
2184
|
+
if (typeof summary !== 'string' || summary.trim().length === 0) throw new Error('smart: smart_finish needs a closing summary')
|
|
2185
|
+
const workspace = typeof options.workspace === 'string' && options.workspace.trim() !== ''
|
|
2186
|
+
? resolve(options.workspace.trim())
|
|
2187
|
+
: typeof state.workspace === 'string' && state.workspace.trim() !== '' ? resolve(state.workspace.trim()) : null
|
|
2188
|
+
if (workspace === null) {
|
|
2189
|
+
throw new Error(`smart: 无法归档:"${id}" 不知道自己属于哪个工作区(state.workspace 缺失),调用会话也没有 header.cwd。归档要把交付副本写进 <工作区>/docs/smart/,所以必须有工作区 —— 请在有工作目录的会话里 finish,或显式传 workspace。(禁 process.cwd() 兜底:宿主 cwd 不是用户工作区)`)
|
|
2190
|
+
}
|
|
2191
|
+
const dir = taskDir(rootDir, id)
|
|
2192
|
+
const at = new Date().toISOString()
|
|
2193
|
+
writeAtomic(join(dir, 'summary.md'), summary.endsWith('\n') ? summary : `${summary}\n`)
|
|
2194
|
+
state.archivedAt = at
|
|
2195
|
+
state.summary = { file: 'summary.md', at }
|
|
2196
|
+
state.workspace = workspace
|
|
2197
|
+
state.workspaceSource = options.workspaceSource ?? state.workspaceSource ?? 'session.header.cwd'
|
|
2198
|
+
state.updatedAt = at
|
|
2199
|
+
// 提升失败会抛出,且 archivedAt 只在提升成功后才落盘(state 在下面写)。
|
|
2200
|
+
const promotion = promoteDeliverables({ taskDir: dir, workspace, state })
|
|
2201
|
+
state.delivery = { dir: join(DELIVERY_DIR, id), files: promotion.files, at }
|
|
2202
|
+
const written = writeState(rootDir, state)
|
|
2203
|
+
return Object.assign(written, { _promotion: { target: promotion.target, files: promotion.files, written: promotion.written } })
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
/** Where a workspace's promoted deliverables live. */
|
|
2207
|
+
export const DELIVERY_DIR = join('docs', 'smart')
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* The files that become the workspace's delivery copy — and nothing else.
|
|
2211
|
+
*
|
|
2212
|
+
* Deliberately **not** the whole task directory: `consult/` holds the hand model's
|
|
2213
|
+
* review transcripts and `verify/` the raw evidence, and `history/` the superseded
|
|
2214
|
+
* drafts. Those stay in the hidden tree; a delivery copy that shipped every draft
|
|
2215
|
+
* and every reviewer's prose would be a pile, not a deliverable
|
|
2216
|
+
* (`acceptance.md` Y6/B6). `docs/smart/` is the reviewed result.
|
|
2217
|
+
*/
|
|
2218
|
+
export const DELIVERABLE_FILES = ['brainstorm.md', 'design.md', 'plan.md', 'acceptance.md', 'execution-report.md', 'review.md', 'summary.md']
|
|
2219
|
+
|
|
2220
|
+
/** The hidden manifest that marks a promotion this machine performed. */
|
|
2221
|
+
export const PROMOTION_MANIFEST = '.dsh-fly-promotion.json'
|
|
2222
|
+
|
|
2223
|
+
/**
|
|
2224
|
+
* The README written beside the promoted artifacts.
|
|
2225
|
+
*
|
|
2226
|
+
* Relative paths only: an absolute `/Users/...` in a delivered document names the
|
|
2227
|
+
* machine that produced it and breaks the moment the repo is cloned elsewhere.
|
|
2228
|
+
*
|
|
2229
|
+
* @param state - task state.
|
|
2230
|
+
* @param files - promoted file names.
|
|
2231
|
+
* @returns markdown.
|
|
2232
|
+
*/
|
|
2233
|
+
function deliveryReadme(state, files) {
|
|
2234
|
+
return [
|
|
2235
|
+
`# smart 任务交付副本:${state.title}`,
|
|
2236
|
+
'',
|
|
2237
|
+
`- 任务 id:\`${state.id}\``,
|
|
2238
|
+
`- 目标:${state.goal === '' ? '(未填写)' : state.goal}`,
|
|
2239
|
+
`- 归档时间:${state.archivedAt ?? new Date().toISOString()}`,
|
|
2240
|
+
'',
|
|
2241
|
+
'## 产物',
|
|
2242
|
+
'',
|
|
2243
|
+
...files.map(file => `- [\`${file}\`](./${file})`),
|
|
2244
|
+
'',
|
|
2245
|
+
'## 这是只读交付副本',
|
|
2246
|
+
'',
|
|
2247
|
+
'`docs/smart/` 是给仓库和评审者看的**交付副本**,不是工作态。修改请改隐藏任务树',
|
|
2248
|
+
'(`.dsh-fly/smart/tasks/<taskId>/`),然后重新 `smart_finish` 覆盖这里 —— 直接改这里',
|
|
2249
|
+
'不会回流,下一次归档会把你的改动盖掉。',
|
|
2250
|
+
'',
|
|
2251
|
+
'手模型评审原文与原始取证留在隐藏树(`consult/`、`verify/`),不进交付面。',
|
|
2252
|
+
'',
|
|
2253
|
+
].join('\n')
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* Promote a finished task's deliverables into the calling session's workspace.
|
|
2258
|
+
*
|
|
2259
|
+
* Semantics that matter (`acceptance.md` Y5/Y6/M9/M10):
|
|
2260
|
+
*
|
|
2261
|
+
* - **Individually atomic, collectively all-or-nothing-ish**: every file is
|
|
2262
|
+
* written to a staging directory first, and the whole promotion is a single
|
|
2263
|
+
* directory rename when the target does not exist yet. On a retry (target
|
|
2264
|
+
* exists) each file is moved in one at a time, which is why a failure reports
|
|
2265
|
+
* the paths it did write instead of pretending nothing happened.
|
|
2266
|
+
* - **A retry may clean up its own half-finished promotion**, and only its own:
|
|
2267
|
+
* the manifest lists the task and the files this machine wrote. A file that is
|
|
2268
|
+
* not ours is a user's file and refuses the promotion.
|
|
2269
|
+
* - **Failure never marks the task archived.** Cross-volume, read-only and
|
|
2270
|
+
* EACCES are explicit errors the caller can retry after fixing the volume.
|
|
2271
|
+
*
|
|
2272
|
+
* @param input - `{ taskDir, workspace, state }`.
|
|
2273
|
+
* @returns `{ target, manifest, files, written }` (paths relative to the workspace).
|
|
2274
|
+
* @throws {Error} with `.written` set to the relative paths already promoted.
|
|
2275
|
+
*/
|
|
2276
|
+
export function promoteDeliverables({ taskDir, workspace, state }) {
|
|
2277
|
+
const target = join(resolve(workspace), DELIVERY_DIR, state.id)
|
|
2278
|
+
const present = DELIVERABLE_FILES.filter(file => existsSync(join(taskDir, file)))
|
|
2279
|
+
if (present.length === 0) throw new Error(`smart: task "${state.id}" has no artifacts to promote (looked in ${taskDir})`)
|
|
2280
|
+
const files = [...present, 'README.md']
|
|
2281
|
+
|
|
2282
|
+
try {
|
|
2283
|
+
mkdirSync(target, { recursive: true })
|
|
2284
|
+
} catch (error) {
|
|
2285
|
+
throw promoteError(target, [], `cannot create ${target}: ${error.message}`)
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
const previous = readManifest(target)
|
|
2289
|
+
const foreign = files.filter(file => existsSync(join(target, file)) && !(previous?.taskId === state.id && previous.files.includes(file)))
|
|
2290
|
+
if (foreign.length > 0) {
|
|
2291
|
+
throw promoteError(target, [], `交付副本已存在,且不是本任务上一次提升写下的(拒绝覆盖用户文件):${foreign.map(file => `${DELIVERY_DIR}/${state.id}/${file}`).join(', ')}`)
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
const bodies = new Map()
|
|
2295
|
+
for (const file of present) bodies.set(file, readFileSync(join(taskDir, file)))
|
|
2296
|
+
bodies.set('README.md', Buffer.from(deliveryReadme(state, files), 'utf8'))
|
|
2297
|
+
|
|
2298
|
+
const written = []
|
|
2299
|
+
for (const file of files) {
|
|
2300
|
+
const destination = join(target, file)
|
|
2301
|
+
try {
|
|
2302
|
+
writeAtomic(destination, bodies.get(file), 0o644)
|
|
2303
|
+
written.push(`${DELIVERY_DIR}/${state.id}/${file}`)
|
|
2304
|
+
} catch (error) {
|
|
2305
|
+
throw promoteError(target, written, `promotion failed at ${destination}: ${error.message}`)
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
const manifest = {
|
|
2309
|
+
taskId: state.id,
|
|
2310
|
+
title: state.title,
|
|
2311
|
+
promotedAt: new Date().toISOString(),
|
|
2312
|
+
files,
|
|
2313
|
+
shasums: Object.fromEntries([...bodies].map(([file, body]) => [file, createHash('sha256').update(body).digest('hex')])),
|
|
2314
|
+
}
|
|
2315
|
+
try {
|
|
2316
|
+
writeAtomic(join(target, PROMOTION_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, 0o644)
|
|
2317
|
+
} catch (error) {
|
|
2318
|
+
throw promoteError(target, written, `promotion manifest failed: ${error.message}`)
|
|
2319
|
+
}
|
|
2320
|
+
return { target, manifest, files, written }
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/** Read a promotion manifest, or null. */
|
|
2324
|
+
export function readManifest(target) {
|
|
2325
|
+
try {
|
|
2326
|
+
const parsed = JSON.parse(readFileSync(join(target, PROMOTION_MANIFEST), 'utf8'))
|
|
2327
|
+
return parsed !== null && typeof parsed === 'object' && typeof parsed.taskId === 'string' && Array.isArray(parsed.files) ? parsed : null
|
|
2328
|
+
} catch {
|
|
2329
|
+
return null
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/** One promotion failure, carrying what was already written. */
|
|
2334
|
+
function promoteError(target, written, message) {
|
|
2335
|
+
const error = new Error(`smart: 归档提升失败(任务保持未归档,修好后可重试):${message}${written.length === 0 ? '' : `\n已写入:${written.join(', ')}`}\n目标目录:${target}`)
|
|
2336
|
+
error.written = written
|
|
2337
|
+
error.target = target
|
|
2338
|
+
return error
|
|
2339
|
+
}
|
|
2340
|
+
/** Stop a task without finishing it. */
|
|
2341
|
+
export function cancelTask(rootDir, id, reason = '') {
|
|
2342
|
+
const state = readState(rootDir, id)
|
|
2343
|
+
if (state === null) throw new Error(`smart: no such task "${id}"`)
|
|
2344
|
+
const at = new Date().toISOString()
|
|
2345
|
+
state.phase = 'cancelled'
|
|
2346
|
+
state.awaitingGate = null
|
|
2347
|
+
state.cancelled = { at, reason }
|
|
2348
|
+
state.updatedAt = at
|
|
2349
|
+
return writeState(rootDir, state)
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/** The phase a gate belongs to (`G1` → `brainstorm`), or undefined. */
|
|
2353
|
+
export function gateOwner(gate) {
|
|
2354
|
+
return Object.keys(GATE_AFTER).find(phase => GATE_AFTER[phase] === gate)
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/** Remove a task directory entirely (used by tests and `smart_abort`). */
|
|
2358
|
+
export function removeTask(rootDir, id) {
|
|
2359
|
+
rmSync(taskDir(rootDir, id), { recursive: true, force: true })
|
|
2360
|
+
const remaining = { tasks: listTasks(rootDir).filter(row => row.id !== id) }
|
|
2361
|
+
const file = join(resolve(rootDir), 'index.json')
|
|
2362
|
+
try {
|
|
2363
|
+
writeAtomic(file, `${JSON.stringify(remaining, null, 2)}\n`)
|
|
2364
|
+
} catch {
|
|
2365
|
+
/* index is a cache; a missing root is fine here */
|
|
2366
|
+
}
|
|
2367
|
+
return remaining.tasks
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
/** Artifact body, or null. */
|
|
2371
|
+
export function readArtifact(rootDir, id, phase) {
|
|
2372
|
+
const file = ARTIFACT_FILE[phase]
|
|
2373
|
+
if (file === undefined) throw new Error(`smart: "${phase}" is not an artifact phase`)
|
|
2374
|
+
try {
|
|
2375
|
+
return readFileSync(join(taskDir(rootDir, id), file), 'utf8')
|
|
2376
|
+
} catch (error) {
|
|
2377
|
+
if (error?.code === 'ENOENT') return null
|
|
2378
|
+
throw error
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
/** Task ids present on disk (for the workbench in S4). */
|
|
2383
|
+
export function taskIds(rootDir) {
|
|
2384
|
+
try {
|
|
2385
|
+
return readdirSync(tasksRoot(rootDir), { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name)
|
|
2386
|
+
} catch {
|
|
2387
|
+
return []
|
|
2388
|
+
}
|
|
2389
|
+
}
|