@dsh-fly/dsh-plugin-fly-smart 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -2
- package/THIRD_PARTY_NOTICES.md +73 -0
- package/assets/smart-debug/SKILL.md +319 -0
- package/assets/smart-diagnose/SKILL.md +170 -0
- package/assets/smart-parallel/SKILL.md +190 -0
- package/assets/smart-plan/SKILL.md +184 -0
- package/assets/smart-review-receive/SKILL.md +230 -0
- package/assets/smart-skills/SKILL.md +96 -0
- package/assets/smart-tdd/SKILL.md +333 -0
- package/assets/smart-verify-claim/SKILL.md +129 -0
- package/assets/smart-worktree/SKILL.md +182 -0
- package/assets/smart-write-skill/SKILL.md +277 -0
- package/cordis.patch.yml +12 -0
- package/index.js +847 -0
- package/lib/client.js +1569 -0
- package/lib/state.mjs +2389 -0
- package/package.json +67 -4
- package/panel/reader.js +481 -0
- package/panel/routes.mjs +393 -0
- package/tools/index.js +1505 -0
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-fly/dsh-plugin-fly-smart",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
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-tools": ">=0.2.0-rc.1 <0.2.1 || >=0.2.1-rc.1 <0.3.0-0",
|
|
34
|
+
"@deepseek-ai/schemastery": ">=3.18.0 <3.19.0 || >=3.19.0-rc.1 <4.0.0-0",
|
|
35
|
+
"@deepseek-ai/dsh-skill": ">=0.2.0-rc.1 <0.2.1 || >=0.2.1-rc.1 <0.3.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-tools": "^0.2.0-rc.2",
|
|
53
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
54
|
+
"vite": "^5.4.21",
|
|
55
|
+
"@dsh-fly/fly-panel-style": "^0.1.0",
|
|
56
|
+
"@deepseek-ai/dsh-skill": "^0.2.0-rc.2"
|
|
57
|
+
},
|
|
58
|
+
"dsh": {
|
|
59
|
+
"bundle": {
|
|
60
|
+
"patch": "./cordis.patch.yml"
|
|
61
|
+
},
|
|
62
|
+
"client": {
|
|
63
|
+
"platform": "web",
|
|
64
|
+
"inject": [
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
package/panel/reader.js
ADDED
|
@@ -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
|
+
}
|