@dsh-fly/dsh-plugin-fly-smart 0.0.0-stage → 0.2.2

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/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
+ }