@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/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@dsh-fly/dsh-plugin-fly-smart",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.2",
4
+ "description": "Fly smart 工作流:宿主平面的工作台面板(本包**裸名**这一行,客户端半边登记在它上面)+ agent 平面的阶段机与 smart_* 工具(包内子路径 tools/index.js,由 preset 按会话挂载)。",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json",
11
+ "./*": "./*"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "panel/reader.js",
16
+ "panel/routes.mjs",
17
+ "lib/state.mjs",
18
+ "lib/client.js",
19
+ "tools",
20
+ "cordis.patch.yml",
21
+ "assets",
22
+ "THIRD_PARTY_NOTICES.md"
23
+ ],
24
+ "scripts": {
25
+ "verify": "node scripts/verify-smart.mjs",
26
+ "test": "node --test tests/*.spec.mjs panel/tests/*.spec.mjs",
27
+ "build:client": "node scripts/build-client.mjs",
28
+ "identity": "node scripts/check-commit-identity.mjs --all"
29
+ },
30
+ "peerDependencies": {
31
+ "@deepseek-ai/cordis": ">=4.0.2 <4.1.0 || >=4.1.0-rc.1 <5.0.0-0",
32
+ "@deepseek-ai/dsh-llm": ">=0.2.0-rc.1 <0.2.1 || >=0.2.1-rc.1 <0.3.0-0",
33
+ "@deepseek-ai/dsh-skill": ">=0.2.0-rc.1 <0.2.1 || >=0.2.1-rc.1 <0.3.0-0",
34
+ "@deepseek-ai/dsh-tools": ">=0.2.0-rc.1 <0.2.1 || >=0.2.1-rc.1 <0.3.0-0",
35
+ "@deepseek-ai/schemastery": ">=3.18.0 <3.19.0 || >=3.19.0-rc.1 <4.0.0-0"
36
+ },
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/ethanchen669/dsh-plugin-fly-smart.git"
41
+ },
42
+ "homepage": "https://github.com/ethanchen669/dsh-plugin-fly-smart#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/ethanchen669/dsh-plugin-fly-smart/issues"
45
+ },
46
+ "publishConfig": {
47
+ "registry": "https://registry.npmjs.org/"
48
+ },
49
+ "devDependencies": {
50
+ "@deepseek-ai/cordis": "^4.0.4",
51
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
52
+ "@deepseek-ai/dsh-skill": "^0.2.0-rc.2",
53
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
54
+ "@deepseek-ai/schemastery": "^3.18.4",
55
+ "@dsh-fly/fly-panel-style": "^0.1.0",
56
+ "js-yaml": "^5.5.0",
57
+ "vite": "^5.4.21"
58
+ },
59
+ "dsh": {
60
+ "bundle": {
61
+ "patch": "./cordis.patch.yml"
62
+ },
63
+ "client": {
64
+ "platform": "web",
65
+ "inject": [
66
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
67
+ ]
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,481 @@
1
+ /**
2
+ * DSH Fly smart workbench — the on-disk reader for `~/.dsh-fly/smart/`.
3
+ *
4
+ * The smart **agent plane** (`dsh-plugin-fly-smart`) owns the state machine and
5
+ * writes the task tree; this module only ever reads it back, and it does so with
6
+ * `node:fs` alone so the page keeps working in a composition where the tools row
7
+ * is not mounted. The two halves of smart mode therefore meet on disk, not in a
8
+ * service graph — which is exactly why the page can be a separate package.
9
+ *
10
+ * Layout this file pins (kept in step with `dsh-plugin-fly-smart/lib/state.mjs`):
11
+ *
12
+ * <root>/index.json { tasks: [{ id, title, phase, awaitingGate, updatedAt }] }
13
+ * <root>/tasks/<id>/state.json phase machine, gates[], consults{}, verifications[], history[]
14
+ * <root>/tasks/<id>/{brainstorm,design,plan,acceptance}.md
15
+ * <root>/tasks/<id>/{execution-report,review,summary}.md
16
+ * <root>/tasks/<id>/consult/<phase>.json
17
+ * <root>/tasks/<id>/verify/<ts>.md
18
+ * <root>/tasks/<id>/history/<ts>-<file>.md
19
+ *
20
+ * Two rules shape every function here:
21
+ *
22
+ * 1. **A missing file is a fact, not an error.** A task mid-flight has no
23
+ * `review.md` yet, and the page must say "not yet" instead of blanking. Only
24
+ * a missing *root* is a capability error, and the route layer turns that into
25
+ * a 503 (never an empty 200).
26
+ * 2. **Nothing read off disk may become a path.** `state.json` carries file
27
+ * names, and a task id is attacker-shaped input on a local HTTP surface, so
28
+ * every id is matched against {@link ID_PATTERN} and every stored file name is
29
+ * re-derived from the phase instead of trusted.
30
+ *
31
+ * @module dsh-plugin-fly-smart-panel/reader
32
+ */
33
+
34
+ import { createHash } from 'node:crypto'
35
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
36
+ import { join } from 'node:path'
37
+
38
+ /** Artifact phase → file name (six artifacts; `summary.md` is separate). */
39
+ export const ARTIFACT_FILE = {
40
+ brainstorm: 'brainstorm.md',
41
+ design: 'design.md',
42
+ plan: 'plan.md',
43
+ acceptance: 'acceptance.md',
44
+ executing: 'execution-report.md',
45
+ reviewing: 'review.md',
46
+ }
47
+
48
+ /** The artifact phases in board order; what the viewer offers as tabs. */
49
+ export const ARTIFACT_PHASES = Object.keys(ARTIFACT_FILE)
50
+
51
+ /** The four design phases, each closed by a small user gate. */
52
+ export const DESIGN_PHASES = ['brainstorm', 'design', 'plan', 'acceptance']
53
+
54
+ /** Gate that closes each phase's step (G5 closes the review). */
55
+ export const GATE_AFTER = {
56
+ brainstorm: 'G1',
57
+ design: 'G2',
58
+ plan: 'G3',
59
+ acceptance: 'G4',
60
+ reviewing: 'G5',
61
+ }
62
+
63
+ /** The board, in order: what the page draws and where the task stands. */
64
+ export const BOARD_PHASES = [
65
+ { id: 'brainstorm', label: '脑暴', gate: 'G1' },
66
+ { id: 'design', label: '设计', gate: 'G2' },
67
+ { id: 'plan', label: '计划', gate: 'G3' },
68
+ { id: 'acceptance', label: '验收设计', gate: 'G4' },
69
+ { id: 'executing', label: '执行', gate: null },
70
+ { id: 'reviewing', label: '验收', gate: 'G5' },
71
+ { id: 'done', label: '完成', gate: null },
72
+ ]
73
+
74
+ /**
75
+ * optimized 任务的阶段板:验收设计与计划是**一对**(两文件一次写入、一道门),
76
+ * 所以没有独立的「验收设计」阶段,也没有 G4。板格仍保留那一位(产物要能看到),
77
+ * 但把它的门与状态交给 pair 表达 —— 否则面板会对一个永远不会发生的 G4 等下去。
78
+ */
79
+ const OPTIMIZED_BOARD = BOARD_PHASES.map(entry => entry.id !== 'acceptance'
80
+ ? entry
81
+ : { id: entry.id, label: '验收设计(并入计划)', gate: null, mergedInto: 'plan' })
82
+
83
+ /**
84
+ * 这个任务该画哪一套板。未知/缺省 ⇒ v1 的四阶段板(旧任务不受影响)。
85
+ * @param workflow - `state.workflow`(可能为 undefined)。
86
+ */
87
+ /**
88
+ * 相位序列(S5):`shape` × `workflow` 算出这个任务走哪些相位。
89
+ *
90
+ * **与 `lib/state.mjs` 的 `phasesFor` 同一判据**,在这里复制一份是因为面板是宿主平面的
91
+ * 独立模块(不 import 状态机,避免把 agent 平面的实现拖进宿主)。两份的判据都有测试钉:
92
+ * 一边改了另一边的用例会红。
93
+ */
94
+ export function phasesFor(state) {
95
+ const SHAPES = {
96
+ feature: ['brainstorm', 'design', 'plan', 'acceptance', 'executing', 'reviewing'],
97
+ fix: ['brainstorm', 'plan', 'executing', 'reviewing'],
98
+ review: ['reviewing'],
99
+ investigation: ['brainstorm'],
100
+ }
101
+ const shape = SHAPES[state?.shape] ?? SHAPES.feature
102
+ return state?.workflow === 'optimized' ? shape.filter(phase => phase !== 'acceptance') : shape
103
+ }
104
+
105
+ export function boardFor(workflow, phases = null) {
106
+ const board = workflow === 'optimized' ? OPTIMIZED_BOARD : BOARD_PHASES
107
+ // shape 裁掉的相位是**不存在**,不该在板上留一个永远「待做」的格子(S5)。
108
+ // `done` 永远留着:它不是走出来的相位,是收尾的那格。
109
+ if (!Array.isArray(phases) || phases.length === 0) return board
110
+ // `mergedInto` 的格子(optimized 的验收设计并入计划)也留着:它的产物**在盘上**,
111
+ // 用户要能点开看 —— 裁的是「这个任务不走那一步」,不是「那一步的产物不存在」。
112
+ return board.filter(entry => entry.id === 'done' || entry.mergedInto !== undefined || phases.includes(entry.id))
113
+ }
114
+
115
+ /**
116
+ * A task id as the state machine generates it (`<timestamp>-<slug>`). Also the
117
+ * only shape allowed into a path: no `/`, and `..` is refused separately because
118
+ * the pattern itself would let it through.
119
+ */
120
+ export const ID_PATTERN = /^[A-Za-z0-9._-]+$/u
121
+
122
+ /** The route line `smart_execute` writes at the top of `execution-report.md`. */
123
+ export const EXECUTION_ROUTE_PATTERN = /^>\s*执行模型[::]\s*([^\s/]+)\s*\/\s*(\S+)\s*$/mu
124
+
125
+ /** Max id length; the state machine's ids are far shorter. */
126
+ const ID_MAX = 128
127
+
128
+ /**
129
+ * Is this string usable as a task id — safe to join into `<root>/tasks`?
130
+ * @param id - the raw query value.
131
+ * @returns true only for the state machine's own id shape.
132
+ */
133
+ export function isSafeTaskId(id) {
134
+ return typeof id === 'string' && id.length > 0 && id.length <= ID_MAX && ID_PATTERN.test(id) && !id.includes('..')
135
+ }
136
+
137
+ /** Parse JSON, treating unreadable/partial files as absent. */
138
+ function readJson(path) {
139
+ try {
140
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
141
+ return parsed !== null && typeof parsed === 'object' ? parsed : null
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+
147
+ /** Read a UTF-8 text file, or null when it does not exist. */
148
+ function readText(path) {
149
+ try {
150
+ return readFileSync(path, 'utf8')
151
+ } catch {
152
+ return null
153
+ }
154
+ }
155
+
156
+ /** `mtime` as ISO text — the only timestamp a bare evidence file has. */
157
+ function mtimeIso(path) {
158
+ try {
159
+ return statSync(path).mtime.toISOString()
160
+ } catch {
161
+ return null
162
+ }
163
+ }
164
+
165
+ /** Size in bytes, or null when the file vanished between listing and stat. */
166
+ function sizeOf(path) {
167
+ try {
168
+ return statSync(path).size
169
+ } catch {
170
+ return null
171
+ }
172
+ }
173
+
174
+ /** File names inside a task sub-directory, sorted, `[]` when the directory is absent. */
175
+ function listFiles(directory, suffix) {
176
+ try {
177
+ return readdirSync(directory, { withFileTypes: true })
178
+ .filter(entry => entry.isFile() && entry.name.endsWith(suffix))
179
+ .map(entry => entry.name)
180
+ .sort()
181
+ } catch {
182
+ return []
183
+ }
184
+ }
185
+
186
+ /** The task ids present under `<root>/tasks`, sorted. */
187
+ function listTaskIds(rootDir) {
188
+ try {
189
+ return readdirSync(join(rootDir, 'tasks'), { withFileTypes: true })
190
+ .filter(entry => entry.isDirectory() && isSafeTaskId(entry.name))
191
+ .map(entry => entry.name)
192
+ .sort()
193
+ } catch {
194
+ return []
195
+ }
196
+ }
197
+
198
+ /** Decisions recorded across all phases of a raw `state.json` (the fallback path counts here). */
199
+ function countDecided(decisions) {
200
+ let total = 0
201
+ for (const rounds of Object.values(decisions ?? {})) {
202
+ for (const round of Array.isArray(rounds) ? rounds : []) total += Array.isArray(round?.entries) ? round.entries.length : 0
203
+ }
204
+ return total
205
+ }
206
+
207
+ /** One index row, from whatever the index (or `state.json`) carries. */
208
+ function taskRow(source) {
209
+ return {
210
+ id: source.id,
211
+ title: typeof source.title === 'string' ? source.title : '',
212
+ phase: typeof source.phase === 'string' ? source.phase : 'unknown',
213
+ awaitingGate: source.awaitingGate ?? null,
214
+ // 裁决两行两种来源都得对:索引行由 state.mjs 写(decided 是数字),回退路径拿到的是
215
+ // 整个 state.json(只有 decisions 明细,条数在这里数)。
216
+ pendingDecisions: source.pendingDecisions ?? null,
217
+ decided: typeof source.decided === 'number' ? source.decided : countDecided(source.decisions),
218
+ // 模型自主裁决(Ruling:)条数:与「待裁决」是两件事 —— 一个是等用户答,一个是
219
+ // 模型替用户定过。索引行给数字,完整条目在任务详情里(`rulings`)。
220
+ rulings: typeof source.rulings === 'number' ? source.rulings : Array.isArray(source.rulings) ? source.rulings.length : 0,
221
+ // 本阶段返工额度(index.json 由 lib/state.mjs 的 updateIndex 写好):额度用尽的任务
222
+ // 在下拉里就能认出来,不必逐个点开。
223
+ //
224
+ // 缺字段给 `null`("不知道"),**不给 0**:老索引行(以及任何不是 state.mjs 写出来的行)
225
+ // 本来就没有这三个字段,补成 0 会让 `used >= cap` 退化成 `0 >= 0` ⇒ 在**已完成的旧任务**
226
+ // 上画一个假的「额度用尽」(2026-10-02 实测:从全局老根搬过来的那个任务就带着它)。
227
+ // 缺失 ≠ 假值:消费端要么两个都是数字,要么当不知道。
228
+ reworkUsed: typeof source.reworkUsed === 'number' ? source.reworkUsed : null,
229
+ reworkCap: typeof source.reworkCap === 'number' ? source.reworkCap : null,
230
+ reworkGrants: typeof source.reworkGrants === 'number' ? source.reworkGrants : null,
231
+ // briefing 格(state.mjs updateIndex 写好):缺失给 null(不知道),不补假值。
232
+ briefing: source.briefing !== null && typeof source.briefing === 'object' ? source.briefing : null,
233
+ updatedAt: source.updatedAt ?? null,
234
+ archived: source.archivedAt !== undefined && source.archivedAt !== null,
235
+ }
236
+ }
237
+
238
+ /**
239
+ * The task index, newest first.
240
+ *
241
+ * `index.json` is the fast path. When it is missing or unreadable the reader
242
+ * falls back to scanning `tasks/*​/state.json`, because a board that goes blank
243
+ * while tasks sit on disk is worse than a slightly slower read; `source` says
244
+ * which path was taken so the page can report it.
245
+ *
246
+ * @param rootDir - the absolute smart root.
247
+ * @returns `{ source, tasks, indexFile }`.
248
+ */
249
+ export function readIndex(rootDir) {
250
+ const indexFile = join(rootDir, 'index.json')
251
+ const raw = readJson(indexFile)
252
+ const entries = Array.isArray(raw?.tasks) ? raw.tasks.filter(entry => isSafeTaskId(entry?.id)) : null
253
+
254
+ const tasks = entries === null
255
+ ? listTaskIds(rootDir)
256
+ .map(id => readJson(join(rootDir, 'tasks', id, 'state.json')))
257
+ .filter(state => state !== null && isSafeTaskId(state.id))
258
+ .map(taskRow)
259
+ : entries.map(taskRow)
260
+
261
+ tasks.sort((left, right) => String(right.updatedAt ?? '').localeCompare(String(left.updatedAt ?? '')))
262
+ return { source: entries === null ? 'tasks/*/state.json' : 'index.json', indexFile, tasks }
263
+ }
264
+
265
+ /**
266
+ * Consult records for one task, in design order.
267
+ *
268
+ * The state machine writes both `state.consults[phase]` (metadata) and
269
+ * `consult/<phase>.json` (the review body). This merges them, and re-derives the
270
+ * file path from the phase rather than trusting the stored name.
271
+ * @param dir - the task directory.
272
+ * @param state - the parsed `state.json`.
273
+ */
274
+ export function readConsults(dir, state) {
275
+ const stored = state.consults !== null && typeof state.consults === 'object' ? state.consults : {}
276
+ const phases = []
277
+ for (const phase of [...DESIGN_PHASES, ...Object.keys(stored)]) {
278
+ // A phase name becomes a file name below, so only the known shape is allowed.
279
+ if (isSafeTaskId(phase) && !phases.includes(phase)) phases.push(phase)
280
+ }
281
+ phases.sort((left, right) => {
282
+ const [a, b] = [DESIGN_PHASES.indexOf(left), DESIGN_PHASES.indexOf(right)]
283
+ return (a === -1 ? 99 : a) - (b === -1 ? 99 : b)
284
+ })
285
+
286
+ return phases.flatMap(phase => {
287
+ const meta = stored[phase] !== null && typeof stored[phase] === 'object' ? stored[phase] : {}
288
+ const record = readJson(join(dir, 'consult', `${phase}.json`))
289
+ if (record === null && Object.keys(meta).length === 0) return []
290
+ return [{
291
+ phase,
292
+ at: record?.at ?? meta.at ?? null,
293
+ provider: record?.provider ?? meta.provider ?? null,
294
+ model: record?.model ?? meta.model ?? null,
295
+ question: typeof (record?.question ?? meta.question) === 'string' ? (record.question ?? meta.question) : '',
296
+ file: `consult/${phase}.json`,
297
+ body: typeof record?.body === 'string' ? record.body : null,
298
+ }]
299
+ })
300
+ }
301
+
302
+ /**
303
+ * Verification evidence for one task, oldest first.
304
+ *
305
+ * The evidence files under `verify/` are the truth (they are the artifact a human
306
+ * opens); the `state.verifications[]` entries only annotate them with the route
307
+ * that produced them. A record whose file is gone is kept and flagged, because a
308
+ * missing piece of evidence is itself a finding.
309
+ * @param dir - the task directory.
310
+ * @param state - the parsed `state.json`.
311
+ */
312
+ export function readVerifications(dir, state) {
313
+ const stored = Array.isArray(state.verifications) ? state.verifications : []
314
+ const rows = listFiles(join(dir, 'verify'), '.md').map(name => {
315
+ const file = `verify/${name}`
316
+ const meta = stored.find(entry => typeof entry?.file === 'string' && (entry.file === file || entry.file.endsWith(`/${name}`))) ?? {}
317
+ return {
318
+ at: meta.at ?? mtimeIso(join(dir, 'verify', name)),
319
+ file,
320
+ provider: meta.provider ?? null,
321
+ model: meta.model ?? null,
322
+ acceptance: meta.acceptance ?? null,
323
+ bytes: sizeOf(join(dir, 'verify', name)),
324
+ missing: false,
325
+ }
326
+ })
327
+ for (const entry of stored) {
328
+ const name = typeof entry?.file === 'string' ? entry.file.split('/').at(-1) : null
329
+ if (name === null || rows.some(row => row.file.endsWith(`/${name}`))) continue
330
+ rows.push({ at: entry.at ?? null, file: entry.file, provider: entry.provider ?? null, model: entry.model ?? null, acceptance: entry.acceptance ?? null, bytes: null, missing: true })
331
+ }
332
+ rows.sort((left, right) => String(left.at ?? '').localeCompare(String(right.at ?? '')))
333
+ return rows
334
+ }
335
+
336
+ /** Archived (superseded) artifacts moved aside by `smart_rework`, oldest first. */
337
+ export function readHistory(dir) {
338
+ return listFiles(join(dir, 'history'), '.md')
339
+ .map(name => ({ file: `history/${name}`, bytes: sizeOf(join(dir, 'history', name)), at: mtimeIso(join(dir, 'history', name)) }))
340
+ .sort((left, right) => String(left.at ?? '').localeCompare(String(right.at ?? '')))
341
+ }
342
+
343
+ /**
344
+ * The route line `smart_execute` writes into `execution-report.md`.
345
+ * @param markdown - the report text, or undefined while the task has not executed.
346
+ * @returns `{ provider, model, line }` or null.
347
+ */
348
+ export function parseExecutionRoute(markdown) {
349
+ if (typeof markdown !== 'string') return null
350
+ const match = EXECUTION_ROUTE_PATTERN.exec(markdown)
351
+ return match === null ? null : { provider: match[1], model: match[2], line: match[0].trim() }
352
+ }
353
+
354
+ /**
355
+ * Compare the executed route with the hand routes that reviewed the design.
356
+ *
357
+ * This is the one cross-plane fact the page can prove on its own: the tools record
358
+ * the hand route on every consult (`consult/<phase>.json`) and the executed route
359
+ * in the report header, and a mismatch means the plan was approved by one model
360
+ * and run by another. `drifted` is false while either side is unknown — "no
361
+ * execution yet" is not drift, and a page that cries wolf stops being read.
362
+ *
363
+ * @param artifacts - `readTask`'s artifact map.
364
+ * @param consults - `readTask`'s consult records.
365
+ * @returns `{ hand, execution, executed, drifted }`.
366
+ */
367
+ export function routeReport(artifacts, consults) {
368
+ const hand = []
369
+ for (const record of consults) {
370
+ if (typeof record.provider !== 'string' || record.provider === '') continue
371
+ if (typeof record.model !== 'string' || record.model === '') continue
372
+ const route = `${record.provider}/${record.model}`
373
+ if (!hand.includes(route)) hand.push(route)
374
+ }
375
+ const execution = parseExecutionRoute(artifacts.executing?.markdown)
376
+ const executed = execution === null ? null : `${execution.provider}/${execution.model}`
377
+ return { hand, execution, executed, drifted: executed !== null && hand.length > 0 && !hand.includes(executed) }
378
+ }
379
+
380
+ /**
381
+ * Everything the page shows for one task.
382
+ * @param rootDir - the absolute smart root.
383
+ * @param id - the task id (validated here as well as at the route).
384
+ * @returns the task view, or null when there is no such task.
385
+ */
386
+ /**
387
+ * Per-phase briefing status, derived the way `state.mjs` derives it (acceptance
388
+ * A10's total ordering). The reader is read-only and shares no code with the
389
+ * agent-plane package — the two halves meet on disk, so this re-derives instead
390
+ * of trusting a remembered `status`: stale means "the artifact on disk moved".
391
+ *
392
+ * @param dir - the task directory.
393
+ * @param state - the parsed `state.json`.
394
+ * @returns `{ [phase]: { rounds, current, status, at, route, source } }`.
395
+ */
396
+ export function briefingReport(dir, state) {
397
+ const stored = state.briefings !== null && typeof state.briefings === 'object' ? state.briefings : {}
398
+ const report = {}
399
+ for (const phase of ARTIFACT_PHASES) {
400
+ const entry = stored[phase] !== null && typeof stored[phase] === 'object' ? stored[phase] : null
401
+ const rounds = Array.isArray(entry?.rounds) ? entry.rounds : []
402
+ const round = rounds.find(item => item?.n === (entry?.current ?? 0)) ?? null
403
+ const sha = sha256Of(join(dir, ARTIFACT_FILE[phase]))
404
+ const latest = rounds.at(-1)?.at ?? null
405
+ let status
406
+ if (typeof entry?.failedAt === 'string' && (latest === null || entry.failedAt > latest)) status = 'failed'
407
+ else if (round === null) status = 'none'
408
+ else if (round.artifactSha256 !== sha) status = 'stale'
409
+ else if (round.decision === 'confirm') status = 'confirmed'
410
+ else if (round.decision === 'revise') status = 'revise'
411
+ else status = 'awaiting-user'
412
+ report[phase] = {
413
+ rounds: rounds.length,
414
+ current: entry?.current ?? 0,
415
+ status,
416
+ at: round?.at ?? entry?.failedAt ?? null,
417
+ route: round === null || (round.provider == null && round.model == null) ? null : `${round.provider ?? '?'}/${round.model ?? '?'}`,
418
+ source: round?.source ?? null,
419
+ lastError: typeof entry?.lastError === 'string' ? entry.lastError : null,
420
+ }
421
+ }
422
+ return report
423
+ }
424
+
425
+ /** sha256 of a file's bytes (hex), or null when missing — same rule as state.mjs. */
426
+ function sha256Of(file) {
427
+ try {
428
+ return createHash('sha256').update(readFileSync(file)).digest('hex')
429
+ } catch {
430
+ return null
431
+ }
432
+ }
433
+
434
+ export function readTask(rootDir, id) {
435
+ if (!isSafeTaskId(id)) return null
436
+ const dir = join(rootDir, 'tasks', id)
437
+ if (!existsSync(join(dir, 'state.json'))) return null
438
+ const state = readJson(join(dir, 'state.json'))
439
+ if (state === null) return null
440
+
441
+ const artifacts = {}
442
+ for (const phase of ARTIFACT_PHASES) {
443
+ const markdown = readText(join(dir, ARTIFACT_FILE[phase]))
444
+ if (markdown !== null) {
445
+ artifacts[phase] = {
446
+ phase,
447
+ file: ARTIFACT_FILE[phase],
448
+ bytes: sizeOf(join(dir, ARTIFACT_FILE[phase])),
449
+ markdown,
450
+ }
451
+ }
452
+ }
453
+
454
+ // 相位序列由 shape × workflow 算出(state 里存的是参数,不是序列本身)。
455
+ const phases = phasesFor(state)
456
+ const consults = readConsults(dir, state)
457
+ const summary = readText(join(dir, 'summary.md'))
458
+ return {
459
+ id,
460
+ taskDir: dir,
461
+ state,
462
+ // 面板不必自己猜工作流:reader 把"该画哪套板"一并发过来。
463
+ workflow: state.workflow ?? 'v1',
464
+ // shape/size 与这个任务的相位序列(S5):面板据此画板格。
465
+ shape: state.shape ?? 'feature',
466
+ size: state.size ?? 'full',
467
+ phases,
468
+ board: boardFor(state.workflow, phases),
469
+ artifacts,
470
+ briefings: briefingReport(dir, state),
471
+ consults,
472
+ verifications: readVerifications(dir, state),
473
+ // 模型自主裁决(Ruling:):完整条目随任务详情下发 —— 这是「替用户定过的事」,
474
+ // 用户只有在这里(和收尾总结里)才看得全。
475
+ rulings: Array.isArray(state.rulings) ? state.rulings : [],
476
+ gates: Array.isArray(state.gates) ? state.gates : [],
477
+ history: readHistory(dir),
478
+ summary: summary === null ? null : { file: 'summary.md', bytes: sizeOf(join(dir, 'summary.md')), markdown: summary },
479
+ routes: routeReport(artifacts, consults),
480
+ }
481
+ }