@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/index.js
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Fly smart workbench — host half.
|
|
3
|
+
*
|
|
4
|
+
* An ordinary Cordis row that claims the Web carrier prefix `/fly/smart`. It
|
|
5
|
+
* serves the smart task tree as JSON **and owns the route-configuration surface**:
|
|
6
|
+
* the page inside the right drawer is the product entry for `smart.brainRoute` /
|
|
7
|
+
* `smart.handRoute`, so the user never has to find the upstream Settings form
|
|
8
|
+
* (which only projects host-plane rows anyway — see `docs/smart-mode-design.md`).
|
|
9
|
+
*
|
|
10
|
+
* The state machine still lives in the agent-plane package `dsh-plugin-fly-smart`;
|
|
11
|
+
* duplicating it here would give the product two truths about what phase a task is
|
|
12
|
+
* in. What this row owns is the *configuration* interface, because it is the one
|
|
13
|
+
* that can: it has the HTTP surface the browser half talks to.
|
|
14
|
+
*
|
|
15
|
+
* Only `node:` builtins plus this package's own modules are imported, so the
|
|
16
|
+
* Loader can import this from a profile's own `node_modules` with no build step.
|
|
17
|
+
*
|
|
18
|
+
* Routes:
|
|
19
|
+
*
|
|
20
|
+
* GET /fly/smart → `{ plugin, path, root, exists, routes, routeConfig, endpoints }`
|
|
21
|
+
* GET /fly/smart/tasks → `{ root, tasks, legacyCount, unknownWorkspace? }`
|
|
22
|
+
* GET /fly/smart/task?id=<id> → `{ root, state, artifacts, consults, … }`
|
|
23
|
+
* GET /fly/smart/routes → `{ path, default, session, effective, … }`
|
|
24
|
+
* POST /fly/smart/routes → write one hand route; `{ changed, … }`
|
|
25
|
+
*
|
|
26
|
+
* Status contract, checked in this order so the client can tell the cases apart:
|
|
27
|
+
*
|
|
28
|
+
* 404 unknown endpoint — before anything else, so a typo never looks like a refusal
|
|
29
|
+
* 405 wrong method, with the derived `allow` header
|
|
30
|
+
* 403 cross-site write (CSRF) — separate from 415 so a security refusal is never
|
|
31
|
+
* reported as a content-type problem
|
|
32
|
+
* 415 a write body that is not JSON
|
|
33
|
+
* 413 a write body over 4096 bytes (connection stays reusable)
|
|
34
|
+
* 400 malformed route / unknown key / impossible session id / too many new sessions
|
|
35
|
+
* 503 the root directory does not exist, or this composition cannot write routes.
|
|
36
|
+
* Never an empty 200: "no data" and "no capability" must not look alike.
|
|
37
|
+
* 409 the mirror lock is held by another writer
|
|
38
|
+
* 404 the root exists but that task does not
|
|
39
|
+
*
|
|
40
|
+
* `unknownWorkspace` is the one soft case: the root exists, but this build could
|
|
41
|
+
* not learn which session's workspace is asking (`acceptance.md` M3/M5). It is a
|
|
42
|
+
* 200 with an empty list plus the host's own sentence — a browser logs every
|
|
43
|
+
* non-2xx fetch as a console error, and a missing session id is not a fault.
|
|
44
|
+
*
|
|
45
|
+
* @module dsh-plugin-fly-smart-panel
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
49
|
+
import { homedir } from 'node:os'
|
|
50
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
51
|
+
import { fileURLToPath } from 'node:url'
|
|
52
|
+
import { BUNDLED_SKILL_RANK } from '@deepseek-ai/dsh-skill'
|
|
53
|
+
import z from '@deepseek-ai/schemastery'
|
|
54
|
+
import { ID_PATTERN, isSafeTaskId, readIndex, readTask } from './panel/reader.js'
|
|
55
|
+
import {
|
|
56
|
+
LockBusy,
|
|
57
|
+
MAX_BODY_BYTES,
|
|
58
|
+
MAX_SESSIONS,
|
|
59
|
+
MIRROR_PATH_ENV,
|
|
60
|
+
MIRROR_VERSION,
|
|
61
|
+
ROUTE_MIRROR_FILE,
|
|
62
|
+
formatRoute,
|
|
63
|
+
isValidSessionId,
|
|
64
|
+
mergeRowRoutes,
|
|
65
|
+
mirrorLockPath,
|
|
66
|
+
parseRouteString,
|
|
67
|
+
readMirrorDocument,
|
|
68
|
+
resolveHand,
|
|
69
|
+
resolveReview,
|
|
70
|
+
resolveMirrorPath,
|
|
71
|
+
updateMirror,
|
|
72
|
+
} from './panel/routes.mjs'
|
|
73
|
+
|
|
74
|
+
export { MIRROR_PATH_ENV, MIRROR_VERSION, ROUTE_MIRROR_FILE, isValidSessionId, mergeRowRoutes, parseRouteString, resolveMirrorPath }
|
|
75
|
+
|
|
76
|
+
/** Cordis plugin name, matched by the bundle patch's row `name`. */
|
|
77
|
+
export const name = 'fly-smart-panel'
|
|
78
|
+
|
|
79
|
+
/** Host dependencies: the Web carrier, plus `skills` (this row ships bundled skills). */
|
|
80
|
+
export const inject = ['webServer', 'skills']
|
|
81
|
+
|
|
82
|
+
/** Route namespace this workbench owns. */
|
|
83
|
+
export const FLY_SMART_PATH = '/fly/smart'
|
|
84
|
+
|
|
85
|
+
/** Service name this row publishes for other Host plugins and tools. */
|
|
86
|
+
export const FLY_SMART_SERVICE = 'flySmartPanel'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Where smart mode wrote **before** the task root became the session workspace.
|
|
90
|
+
*
|
|
91
|
+
* Still the read-compat root: tasks created by older builds live here, and the
|
|
92
|
+
* workbench must keep showing (and finishing) them. New tasks do not land here.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveLegacyRoot() {
|
|
95
|
+
return join(homedir(), '.dsh-fly', 'smart')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The legacy root, as a constant (kept for callers that only need the default). */
|
|
99
|
+
export const DEFAULT_ROOT_DIR = resolveLegacyRoot()
|
|
100
|
+
|
|
101
|
+
/** Every task of the workspace root lives under this directory name. */
|
|
102
|
+
export const WORKSPACE_ROOT_DIR = join('.dsh-fly', 'smart')
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Environment override for the smart root.
|
|
106
|
+
*
|
|
107
|
+
* The row config is the product-facing switch; this exists because verification
|
|
108
|
+
* boots a real carrier in a scratch home (`DSH_HOME`), while `homedir()` still
|
|
109
|
+
* points at the developer's real home — without an override a verify run would
|
|
110
|
+
* have to plant fixtures in the user's own `~/.dsh-fly/smart`, which is exactly
|
|
111
|
+
* the kind of write the hard rules forbid.
|
|
112
|
+
*/
|
|
113
|
+
export const ROOT_DIR_ENV = 'DSH_FLY_SMART_ROOT'
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Smart's live preferences, declared here because this is the **host-plane** row.
|
|
117
|
+
*
|
|
118
|
+
* `rootDir` is not volatile: the browser has no business editing where tasks land.
|
|
119
|
+
* `brainRoute` / `handRoute` stay volatile because the upstream Settings form is
|
|
120
|
+
* still a supported (if no longer primary) way to set the pair — the drawer page
|
|
121
|
+
* writes the same mirror the tools read, so both entries agree on disk.
|
|
122
|
+
*/
|
|
123
|
+
export const Config = z.object({
|
|
124
|
+
rootDir: z.string().default(''),
|
|
125
|
+
// `"provider/model"`(可选 `@effort`);空 = 用户还没选,工具会在调用时指路。
|
|
126
|
+
brainRoute: z.string().default('').volatile(),
|
|
127
|
+
handRoute: z.string().default('').volatile(),
|
|
128
|
+
reviewRoute: z.string().default('').volatile(),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read one row-config field that may arrive as a **reference box**.
|
|
133
|
+
*
|
|
134
|
+
* `.volatile()` fields (the ones the upstream Settings form needs to see) are refs
|
|
135
|
+
* in `config`, with the value behind `.get()` — the engine reads its own the same
|
|
136
|
+
* way (`dsh-agent-preset-registry`: `this.config.selectedDefault.get()`). Fields
|
|
137
|
+
* without `.volatile()` (our `rootDir`) are plain values, so **one config object
|
|
138
|
+
* carries both shapes** and the read has to accept either.
|
|
139
|
+
*
|
|
140
|
+
* Getting this wrong is silent: `typeof ref === 'string'` is always false, so the
|
|
141
|
+
* row looks empty and every "the row outranks the page" branch quietly takes the
|
|
142
|
+
* other way. That is a real bug this file shipped once (found while writing the
|
|
143
|
+
* docs, `upstream-tracking.md` §7.1).
|
|
144
|
+
*
|
|
145
|
+
* @param field - a ref box, a plain value, or nothing.
|
|
146
|
+
* @returns the trimmed string value, or `''`.
|
|
147
|
+
*/
|
|
148
|
+
export function readRowString(field) {
|
|
149
|
+
const value = field !== null && typeof field === 'object' && typeof field.get === 'function' ? field.get() : field
|
|
150
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Endpoint table, also served at the namespace root so the UI can self-describe.
|
|
155
|
+
*
|
|
156
|
+
* Every route this row answers appears here — a "self-describing" table that omits
|
|
157
|
+
* the configuration endpoint would be describing a different product.
|
|
158
|
+
*/
|
|
159
|
+
export const ENDPOINTS = [
|
|
160
|
+
{ path: `${FLY_SMART_PATH}/tasks`, summary: '任务索引(id/标题/阶段/开着的门/更新时间),按更新时间倒序', methods: ['GET', 'HEAD'] },
|
|
161
|
+
{ path: `${FLY_SMART_PATH}/task?id=<id>`, summary: '单个任务的状态机、六个产物、评审记录、取证、门记录与返工历史', methods: ['GET', 'HEAD'] },
|
|
162
|
+
{ path: `${FLY_SMART_PATH}/routes`, summary: '双模型路由配置:本会话 / 所有会话默认的 hand 路由(GET 读,POST 写)', methods: ['GET', 'HEAD', 'POST'] },
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
/** Methods per endpoint, derived for the `allow` header (never hard-coded twice). */
|
|
166
|
+
const ALLOWED = new Map([
|
|
167
|
+
['/', ['GET', 'HEAD']],
|
|
168
|
+
['/tasks', ['GET', 'HEAD']],
|
|
169
|
+
['/task', ['GET', 'HEAD']],
|
|
170
|
+
['/routes', ['GET', 'HEAD', 'POST']],
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the **explicit** smart root: row config first, then the env override.
|
|
175
|
+
*
|
|
176
|
+
* "Explicit" means a human said so (or a test did) — it is not derived from a
|
|
177
|
+
* session. When it is set, the workbench lists that root and does not pretend to
|
|
178
|
+
* know a workspace.
|
|
179
|
+
*
|
|
180
|
+
* @param rawConfig - the row's `config` object, as Cordis passes it.
|
|
181
|
+
* @param env - the process environment (injectable for tests).
|
|
182
|
+
* @returns `{ root, source }` or null.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveExplicitRoot(rawConfig = {}, env = process.env) {
|
|
185
|
+
const configured = readRowString(rawConfig?.rootDir)
|
|
186
|
+
if (configured !== '') return { root: resolve(configured), source: 'row' }
|
|
187
|
+
const fromEnv = typeof env?.[ROOT_DIR_ENV] === 'string' ? env[ROOT_DIR_ENV].trim() : ''
|
|
188
|
+
if (fromEnv !== '') return { root: resolve(fromEnv), source: 'env' }
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the smart root: row config, then the env override, then the legacy root.
|
|
194
|
+
* @param rawConfig - the row's `config` object, as Cordis passes it.
|
|
195
|
+
* @param env - the process environment (injectable for tests).
|
|
196
|
+
* @returns an absolute path.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveRootDir(rawConfig = {}, env = process.env) {
|
|
199
|
+
return resolveExplicitRoot(rawConfig, env)?.root ?? resolveLegacyRoot()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The task root of one session's workspace: `<header.cwd>/.dsh-fly/smart`.
|
|
204
|
+
*
|
|
205
|
+
* `header.cwd` is the session's own working directory — **not** the host process
|
|
206
|
+
* cwd and **not** the workspace service's root. Those three are routinely
|
|
207
|
+
* different (the desktop shell's cwd is `/`), and `process.cwd()` is banned as a
|
|
208
|
+
* fallback for exactly that reason: it would silently file a task into the repo
|
|
209
|
+
* root. Unknown cwd means "unknown workspace", which the page renders as such.
|
|
210
|
+
*
|
|
211
|
+
* @param sessionId - the session asking.
|
|
212
|
+
* @param sessions - the `sessions` service, if this composition has one.
|
|
213
|
+
* @returns an absolute root, or null when it cannot be known.
|
|
214
|
+
*/
|
|
215
|
+
export function workspaceRootFromSession(sessionId, sessions) {
|
|
216
|
+
if (!isValidSessionId(sessionId) || sessions === undefined || sessions === null) return null
|
|
217
|
+
let session
|
|
218
|
+
try {
|
|
219
|
+
session = typeof sessions.get === 'function' ? sessions.get(sessionId) : undefined
|
|
220
|
+
} catch {
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
const cwd = session?.header?.cwd
|
|
224
|
+
if (typeof cwd !== 'string' || cwd === '' || !isAbsolute(cwd)) return null
|
|
225
|
+
return join(cwd, WORKSPACE_ROOT_DIR)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Every root this request could be talking about, in precedence order.
|
|
230
|
+
*
|
|
231
|
+
* @param input - `{ rawConfig, env, sessionId, sessions }`.
|
|
232
|
+
* @returns `{ primary, explicit, explicitSource, workspace, workspaceSource, legacy, unknownWorkspace, candidates }`.
|
|
233
|
+
*/
|
|
234
|
+
export function resolveRoots({ rawConfig = {}, env = process.env, sessionId = null, sessions = undefined } = {}) {
|
|
235
|
+
const explicit = resolveExplicitRoot(rawConfig, env)
|
|
236
|
+
const workspace = workspaceRootFromSession(sessionId, sessions)
|
|
237
|
+
const legacy = resolveLegacyRoot()
|
|
238
|
+
const candidates = []
|
|
239
|
+
for (const root of [explicit?.root ?? null, workspace, legacy]) {
|
|
240
|
+
if (root !== null && !candidates.includes(root)) candidates.push(root)
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
primary: explicit?.root ?? workspace ?? legacy,
|
|
244
|
+
explicit: explicit?.root ?? null,
|
|
245
|
+
explicitSource: explicit?.source ?? null,
|
|
246
|
+
workspace,
|
|
247
|
+
workspaceSource: workspace === null ? null : 'header.cwd',
|
|
248
|
+
legacy,
|
|
249
|
+
unknownWorkspace: explicit === null && workspace === null,
|
|
250
|
+
candidates,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Find the task directory of one id across the candidate roots.
|
|
256
|
+
*
|
|
257
|
+
* The root a task is read from is decided by **what is on disk**, not by a value
|
|
258
|
+
* stored inside the task: a recorded `rootDir` in `state.json` is only checked for
|
|
259
|
+
* consistency, so copying a task directory cannot redirect a read (or a later
|
|
260
|
+
* write) into an arbitrary path (`acceptance.md` Y7).
|
|
261
|
+
*
|
|
262
|
+
* @param taskId - task id.
|
|
263
|
+
* @param candidates - roots in precedence order.
|
|
264
|
+
* @returns `{ root, taskDir, note }` or null.
|
|
265
|
+
*/
|
|
266
|
+
export function locateTask(taskId, candidates) {
|
|
267
|
+
if (!isSafeTaskId(taskId)) return null
|
|
268
|
+
const hits = []
|
|
269
|
+
for (const root of candidates) {
|
|
270
|
+
const taskDir = join(root, 'tasks', taskId)
|
|
271
|
+
if (existsSync(join(taskDir, 'state.json'))) hits.push({ root, taskDir })
|
|
272
|
+
}
|
|
273
|
+
if (hits.length === 0) return null
|
|
274
|
+
return {
|
|
275
|
+
...hits[0],
|
|
276
|
+
// 同 taskId 双根:工作区根胜出,但要留下痕迹 —— 静默挑一个会让"我的产物去哪了"
|
|
277
|
+
// 变成无从下手的悬案(acceptance.md M10)。
|
|
278
|
+
...hits.length > 1 ? { note: `task "${taskId}" exists in ${hits.length} roots; reading ${hits[0].root}` } : {},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The sentence that says smart has produced nothing yet. */
|
|
283
|
+
export function missingRootMessage(rootDir) {
|
|
284
|
+
return `smart 任务目录不存在:${rootDir}(smart 模式还没有产出任何任务;在会话里发「/smart <任务>」开始一个)`
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The sentence for "the root is fine, but I cannot tell which workspace you are". */
|
|
288
|
+
export function unknownWorkspaceMessage(legacyRoot) {
|
|
289
|
+
return `无法确定当前工作区:右抽屉没有读到会话的工作目录,所以只列出兼容根 ${legacyRoot} 的计数。请在会话里打开这一页(或先选中一个会话)。`
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A write the handler must refuse with a specific status. */
|
|
293
|
+
export class RouteWriteError extends Error {
|
|
294
|
+
constructor(status, message) {
|
|
295
|
+
super(message)
|
|
296
|
+
this.name = 'RouteWriteError'
|
|
297
|
+
this.status = status
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The read side of the configuration surface.
|
|
303
|
+
*
|
|
304
|
+
* Reconciles the scripting plane's row config on read (`acceptance.md` S1 item 4):
|
|
305
|
+
* a non-empty row value is the hard floor and is merged into the mirror's
|
|
306
|
+
* `default` layer before the state is reported, so what the page shows is what the
|
|
307
|
+
* agent will use. An empty row value writes **nothing at all** — a boot must never
|
|
308
|
+
* touch the user's mirror.
|
|
309
|
+
*
|
|
310
|
+
* @param input - `{ mirrorPath, rowHand, sessionId }`.
|
|
311
|
+
* @returns the payload served at `/fly/smart/routes` and as `routeConfig`.
|
|
312
|
+
*/
|
|
313
|
+
export function readRoutesConfig({ mirrorPath, rowHand = '', rowReview = '', sessionId = null }) {
|
|
314
|
+
if (rowHand !== '') {
|
|
315
|
+
try { mergeRowRoutes(mirrorPath, { hand: rowHand }) } catch { /* read remains useful */ }
|
|
316
|
+
}
|
|
317
|
+
const doc = readMirrorDocument(mirrorPath)
|
|
318
|
+
const known = isValidSessionId(sessionId)
|
|
319
|
+
const session = known && doc.sessions[sessionId] !== undefined
|
|
320
|
+
? { id: sessionId, hand: doc.sessions[sessionId].hand ?? '', review: doc.sessions[sessionId].review ?? '', at: doc.sessions[sessionId].at }
|
|
321
|
+
: null
|
|
322
|
+
const mirror = resolveHand(doc, known ? sessionId : null)
|
|
323
|
+
const review = resolveReview(doc, known ? sessionId : null)
|
|
324
|
+
const effective = rowHand !== '' ? { hand: rowHand, source: 'preset' } : { hand: mirror.hand, source: mirror.source }
|
|
325
|
+
const effectiveReview = rowReview !== '' ? { review: rowReview, source: 'preset' } : review
|
|
326
|
+
return {
|
|
327
|
+
path: mirrorPath, lock: mirrorLockPath(mirrorPath), version: MIRROR_VERSION, exists: existsSync(mirrorPath),
|
|
328
|
+
presetRow: { hand: rowHand, review: rowReview, at: null, effective: rowHand !== '' || rowReview !== '' },
|
|
329
|
+
default: doc.defaultHand === '' && doc.defaultReview === '' ? null : { hand: doc.defaultHand, review: doc.defaultReview, at: doc.defaultAt },
|
|
330
|
+
session, sessionKnown: known, sessionCount: Object.keys(doc.sessions).length,
|
|
331
|
+
brain: doc.legacyBrain === '' ? null : { value: doc.legacyBrain, consumer: null },
|
|
332
|
+
effective, effectiveReview, effectiveFromPanel: rowHand === '' && rowReview === '',
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The write side: one hand route into one layer of the mirror.
|
|
338
|
+
*
|
|
339
|
+
* @param input - `{ mirrorPath, rowHand, hand, scope, sessionId }`.
|
|
340
|
+
* @returns the fixed success payload `{ changed, effectiveFromPanel, source, sessionKnown, at, path }`.
|
|
341
|
+
* @throws {RouteWriteError} for the 400-class refusals.
|
|
342
|
+
*/
|
|
343
|
+
export function writeRoutesConfig({ mirrorPath, rowHand = '', rowReview = '', value, hand = '', role = 'hand', scope, sessionId = null }) {
|
|
344
|
+
value = value ?? hand ?? ''
|
|
345
|
+
const doc = readMirrorDocument(mirrorPath)
|
|
346
|
+
if (scope === 'session' && value !== '' && doc.sessions[sessionId] === undefined && Object.keys(doc.sessions).length >= MAX_SESSIONS) {
|
|
347
|
+
throw new RouteWriteError(400, `smart routes: 会话路由已达上限 ${MAX_SESSIONS} 条 —— 只挡新增键,先清掉一些会话路由再保存`)
|
|
348
|
+
}
|
|
349
|
+
let result
|
|
350
|
+
try {
|
|
351
|
+
result = updateMirror(mirrorPath, role === 'review' ? { role, review: value, scope, sessionId } : { role, hand: value, scope, sessionId })
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (error instanceof LockBusy) throw new RouteWriteError(409, error.message)
|
|
354
|
+
throw new RouteWriteError(500, `smart routes: 镜像写入失败:${String(error?.message ?? error)}`)
|
|
355
|
+
}
|
|
356
|
+
const config = readRoutesConfig({ mirrorPath, rowHand, rowReview, sessionId })
|
|
357
|
+
return {
|
|
358
|
+
changed: result.changed,
|
|
359
|
+
effectiveFromPanel: rowHand === '' && rowReview === '',
|
|
360
|
+
source: role === 'review' ? config.effectiveReview.source : config.effective.source,
|
|
361
|
+
sessionKnown: config.sessionKnown,
|
|
362
|
+
at: result.at,
|
|
363
|
+
path: result.path,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Does this request look like a cross-site write? */
|
|
368
|
+
function crossSite(req) {
|
|
369
|
+
const site = String(req?.headers?.['sec-fetch-site'] ?? '').toLowerCase()
|
|
370
|
+
if (site === 'cross-site') return true
|
|
371
|
+
const origin = typeof req?.headers?.origin === 'string' ? req.headers.origin : ''
|
|
372
|
+
if (origin === '' || origin === 'null') return false
|
|
373
|
+
const host = req?.headers?.host
|
|
374
|
+
if (typeof host !== 'string' || host === '') return false
|
|
375
|
+
try {
|
|
376
|
+
return new URL(origin).host !== host
|
|
377
|
+
} catch {
|
|
378
|
+
return true
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Read a request body with a hard byte ceiling.
|
|
384
|
+
*
|
|
385
|
+
* Resolves as soon as the ceiling is crossed **without** unsubscribing: the rest
|
|
386
|
+
* of the request is drained, which is what keeps the connection reusable for the
|
|
387
|
+
* next request (`acceptance.md` S1b-2 measures this on a real socket).
|
|
388
|
+
*
|
|
389
|
+
* @param req - incoming request.
|
|
390
|
+
* @param limit - maximum accepted bytes.
|
|
391
|
+
* @returns `{ text }` or `{ tooLarge: true }`.
|
|
392
|
+
*/
|
|
393
|
+
export async function readBody(req, limit = MAX_BODY_BYTES) {
|
|
394
|
+
return await new Promise(resolveBody => {
|
|
395
|
+
let total = 0
|
|
396
|
+
const chunks = []
|
|
397
|
+
let decided = false
|
|
398
|
+
const finish = value => {
|
|
399
|
+
if (decided) return
|
|
400
|
+
decided = true
|
|
401
|
+
resolveBody(value)
|
|
402
|
+
}
|
|
403
|
+
req?.on?.('data', chunk => {
|
|
404
|
+
total += chunk.length
|
|
405
|
+
if (total > limit) {
|
|
406
|
+
chunks.length = 0
|
|
407
|
+
finish({ tooLarge: true })
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
chunks.push(chunk)
|
|
411
|
+
})
|
|
412
|
+
req?.on?.('end', () => finish({ text: Buffer.concat(chunks).toString('utf8') }))
|
|
413
|
+
req?.on?.('error', () => finish({ text: '' }))
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Answer one smart request. The handler owns the whole response lifecycle — the
|
|
419
|
+
* carrier answers a thrown handler with a bare 400 and no diagnostics.
|
|
420
|
+
*
|
|
421
|
+
* @param deps - the composition's capabilities:
|
|
422
|
+
* - `roots(sessionId)` → {@link resolveRoots} shape
|
|
423
|
+
* - `readRoutes(sessionId)` → {@link readRoutesConfig} shape
|
|
424
|
+
* - `writeRoutes({hand, scope, sessionId})` → write payload, or undefined for a read-only host
|
|
425
|
+
* - `locate(taskId, sessionId?)` → `{root, taskDir, note?}` or null
|
|
426
|
+
* - `readIndex(root)` / `readTask(root, id)` → readers (injectable for tests)
|
|
427
|
+
* - `log()` → optional logger service
|
|
428
|
+
* @param req - the incoming request.
|
|
429
|
+
* @param res - the response to finish.
|
|
430
|
+
* @returns a promise that settles once the response has been written.
|
|
431
|
+
*/
|
|
432
|
+
export async function answerSmartRequest(deps, req, res) {
|
|
433
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
434
|
+
const pathname = String(req?.url ?? '/').split('?')[0]
|
|
435
|
+
const index = deps?.readIndex ?? readIndex
|
|
436
|
+
const detail = deps?.readTask ?? readTask
|
|
437
|
+
const log = typeof deps?.log === 'function' ? deps.log() : undefined
|
|
438
|
+
|
|
439
|
+
/** Log a line when a logger is available; a missing logger is not an error. */
|
|
440
|
+
const note = message => {
|
|
441
|
+
try {
|
|
442
|
+
log?.warn?.(message)
|
|
443
|
+
} catch {
|
|
444
|
+
// 日志降级:宿主没有 log 服务时只体现在响应字段里(acceptance.md M15)。
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const json = (status, payload) => {
|
|
449
|
+
const body = JSON.stringify(payload, null, 2)
|
|
450
|
+
res.writeHead(status, {
|
|
451
|
+
'content-type': 'application/json; charset=utf-8',
|
|
452
|
+
'cache-control': 'no-store',
|
|
453
|
+
'x-content-type-options': 'nosniff',
|
|
454
|
+
})
|
|
455
|
+
res.end(method === 'HEAD' ? undefined : body)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── 1. Route table: an unknown path is a 404 before any other judgement ──────
|
|
459
|
+
const relative = pathname === FLY_SMART_PATH
|
|
460
|
+
? '/'
|
|
461
|
+
: pathname.startsWith(`${FLY_SMART_PATH}/`) ? pathname.slice(FLY_SMART_PATH.length) : null
|
|
462
|
+
if (relative === null || !ALLOWED.has(relative)) {
|
|
463
|
+
json(404, { error: `unknown smart endpoint: ${pathname}`, endpoints: ENDPOINTS })
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── 2. Method: 405 with the derived allow list ──────────────────────────────
|
|
468
|
+
const allowed = ALLOWED.get(relative)
|
|
469
|
+
const isWrite = relative === '/routes' && method === 'POST'
|
|
470
|
+
if (!allowed.includes(method)) {
|
|
471
|
+
res.writeHead(405, {
|
|
472
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
473
|
+
'cache-control': 'no-store',
|
|
474
|
+
allow: allowed.join(', '),
|
|
475
|
+
})
|
|
476
|
+
res.end('method not allowed')
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const sessionId = (() => {
|
|
481
|
+
try {
|
|
482
|
+
const url = new URL(String(req?.url ?? '/'), 'http://127.0.0.1')
|
|
483
|
+
const raw = url.searchParams.get('sessionId')
|
|
484
|
+
return raw === null || raw === '' ? null : raw
|
|
485
|
+
} catch {
|
|
486
|
+
return null
|
|
487
|
+
}
|
|
488
|
+
})()
|
|
489
|
+
|
|
490
|
+
// ── 3. CSRF, then content type, then size, then shape ──────────────────────
|
|
491
|
+
let body = null
|
|
492
|
+
if (isWrite) {
|
|
493
|
+
if (crossSite(req)) {
|
|
494
|
+
json(403, { error: 'smart routes: 跨站写入被拒(同源页面才能改模型路由)' })
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
const type = String(req?.headers?.['content-type'] ?? '').toLowerCase()
|
|
498
|
+
if (!type.startsWith('application/json')) {
|
|
499
|
+
json(415, { error: 'smart routes: 请求体必须是 application/json(面板只回显这句宿主文案)' })
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
const read = await readBody(req)
|
|
503
|
+
if (read.tooLarge === true) {
|
|
504
|
+
json(413, { error: `smart routes: 请求体超过 ${MAX_BODY_BYTES} 字节上限(路由值本身很短,请检查客户端)` })
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
body = JSON.parse(read.text === '' ? '{}' : read.text)
|
|
509
|
+
} catch {
|
|
510
|
+
json(400, { error: 'smart routes: 请求体不是合法 JSON' })
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
const roots = deps.roots(sessionId)
|
|
517
|
+
const missingRoot = () => json(503, {
|
|
518
|
+
error: missingRootMessage(roots.primary),
|
|
519
|
+
root: roots.primary,
|
|
520
|
+
endpoints: ENDPOINTS,
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
if (relative === '/') {
|
|
524
|
+
// "有数据吗"问的是**任何一个**候选根:产品里新任务是工作区的,老任务在 legacy
|
|
525
|
+
// 根里,只探测 primary 会把"这台机器上还有老任务"误报成"什么都没有"。
|
|
526
|
+
const exists = roots.candidates.some(root => existsSync(root))
|
|
527
|
+
const config = deps.readRoutes(sessionId)
|
|
528
|
+
json(200, {
|
|
529
|
+
plugin: name,
|
|
530
|
+
path: FLY_SMART_PATH,
|
|
531
|
+
root: roots.primary,
|
|
532
|
+
rootSource: roots.explicitSource ?? roots.workspaceSource ?? 'legacy',
|
|
533
|
+
exists,
|
|
534
|
+
routes: config,
|
|
535
|
+
// 面板读到的配置随根 payload 一起回去:页面首屏不必再发一次 GET /routes。
|
|
536
|
+
routeConfig: config,
|
|
537
|
+
...exists ? {} : { message: missingRootMessage(roots.primary) },
|
|
538
|
+
endpoints: ENDPOINTS,
|
|
539
|
+
})
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (relative === '/routes') {
|
|
544
|
+
if (!isWrite) {
|
|
545
|
+
json(200, deps.readRoutes(sessionId))
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (typeof deps.writeRoutes !== 'function') {
|
|
549
|
+
json(503, {
|
|
550
|
+
error: 'smart routes: 这个宿主没有可写的路由面(只读组合)—— 用 tool-fly-smart 行的 config 配置配对',
|
|
551
|
+
path: deps.readRoutes(sessionId).path,
|
|
552
|
+
})
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
const payload = body
|
|
556
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
557
|
+
json(400, { error: 'smart routes: 请求体必须是一个 JSON 对象' })
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
for (const key of Object.keys(payload)) {
|
|
561
|
+
if (!['hand', 'review', 'role', 'scope', 'sessionId'].includes(key)) {
|
|
562
|
+
json(400, { error: `smart routes: 未知字段 ${JSON.stringify(key)}(只认 hand / review / role / scope / sessionId)` })
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const scope = payload.scope ?? 'session'
|
|
567
|
+
if (scope !== 'session' && scope !== 'default') {
|
|
568
|
+
json(400, { error: `smart routes: scope 只能是 "session" 或 "default"(收到 ${JSON.stringify(payload.scope)})` })
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
// 一条写路径服务两个角色:`role` 指名要写哪一条(缺省看有没有 review 字段,再退 hand)。
|
|
572
|
+
// 老客户端的 `{ hand, scope, sessionId }` 因此逐字节照旧可用。
|
|
573
|
+
const role = payload.role === 'review' || typeof payload.review === 'string' ? 'review' : 'hand'
|
|
574
|
+
const value = payload[role]
|
|
575
|
+
if (typeof value !== 'string') {
|
|
576
|
+
json(400, { error: `smart routes: ${role} 必须是字符串(空串 = 删除该层的路由)` })
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
const routeValue = value.trim()
|
|
580
|
+
if (routeValue !== '' && parseRouteString(routeValue) === undefined) {
|
|
581
|
+
json(400, { error: `smart routes: 路由值 ${JSON.stringify(routeValue)} 不合法 —— 期望 provider/model(可选 @effort),例如 deepseek/deepseek-flash` })
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
const wanted = payload.sessionId ?? sessionId
|
|
585
|
+
if (wanted !== null && wanted !== undefined && !isValidSessionId(wanted)) {
|
|
586
|
+
json(400, { error: `smart routes: sessionId ${JSON.stringify(wanted)} 不可用(不能为空、不能含 / 或 .. 、不能有控制字符、不超过 200 字符)` })
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
if (scope === 'session' && !isValidSessionId(wanted)) {
|
|
590
|
+
json(400, { error: 'smart routes: 保存到本会话需要 sessionId(页面没能读到会话 id 时请改用"设为所有会话默认")' })
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
const written = deps.writeRoutes({ role, value: routeValue, hand: role === 'hand' ? routeValue : '', scope, sessionId: isValidSessionId(wanted) ? wanted : null })
|
|
594
|
+
json(200, written)
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (relative === '/tasks') {
|
|
599
|
+
const anyRootExists = roots.candidates.some(root => existsSync(root))
|
|
600
|
+
if (!anyRootExists) {
|
|
601
|
+
missingRoot()
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
if (roots.unknownWorkspace) {
|
|
605
|
+
const legacyIndex = existsSync(roots.legacy) ? index(roots.legacy) : { source: 'none', tasks: [] }
|
|
606
|
+
json(200, {
|
|
607
|
+
root: roots.legacy,
|
|
608
|
+
source: legacyIndex.source,
|
|
609
|
+
tasks: [],
|
|
610
|
+
legacyCount: legacyIndex.tasks.length,
|
|
611
|
+
unknownWorkspace: true,
|
|
612
|
+
message: unknownWorkspaceMessage(roots.legacy),
|
|
613
|
+
})
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
if (roots.explicit !== null) {
|
|
617
|
+
const only = index(roots.explicit)
|
|
618
|
+
json(200, { root: roots.explicit, source: only.source, tasks: only.tasks, legacyCount: 0 })
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
// 工作区模式:本工作区的任务 + legacy 兼容根的计数与可读列表(同 id 工作区胜出)。
|
|
622
|
+
const hasWorkspace = roots.workspace !== null && existsSync(roots.workspace)
|
|
623
|
+
const workspaceIndex = hasWorkspace ? index(roots.workspace) : { source: 'none', tasks: [] }
|
|
624
|
+
const legacyIndex = existsSync(roots.legacy) ? index(roots.legacy) : { source: 'none', tasks: [] }
|
|
625
|
+
const seen = new Map()
|
|
626
|
+
for (const row of [...workspaceIndex.tasks, ...legacyIndex.tasks]) if (!seen.has(row.id)) seen.set(row.id, row)
|
|
627
|
+
const duplicateIds = workspaceIndex.tasks
|
|
628
|
+
.filter(row => legacyIndex.tasks.some(other => other.id === row.id))
|
|
629
|
+
.map(row => row.id)
|
|
630
|
+
json(200, {
|
|
631
|
+
root: hasWorkspace ? roots.workspace : roots.legacy,
|
|
632
|
+
source: hasWorkspace ? workspaceIndex.source : legacyIndex.source,
|
|
633
|
+
tasks: [...seen.values()],
|
|
634
|
+
legacyCount: legacyIndex.tasks.length,
|
|
635
|
+
legacyRoot: roots.legacy,
|
|
636
|
+
...duplicateIds.length === 0 ? {} : {
|
|
637
|
+
note: `同 taskId 双根(工作区根优先):${duplicateIds.join(', ')}`,
|
|
638
|
+
},
|
|
639
|
+
})
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// relative === '/task'
|
|
644
|
+
const url = new URL(String(req?.url ?? '/'), 'http://127.0.0.1')
|
|
645
|
+
const id = url.searchParams.get('id')
|
|
646
|
+
// Refused before anything is joined into a path.
|
|
647
|
+
if (id === null || !isSafeTaskId(id)) {
|
|
648
|
+
json(400, {
|
|
649
|
+
error: `smart: invalid task id ${JSON.stringify(id)} — expected /${ID_PATTERN.source}/ and no ".."`,
|
|
650
|
+
root: roots.primary,
|
|
651
|
+
})
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
if (!roots.candidates.some(root => existsSync(root))) {
|
|
655
|
+
missingRoot()
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
const found = deps.locate(id, sessionId)
|
|
659
|
+
if (found === null) {
|
|
660
|
+
json(404, {
|
|
661
|
+
error: `smart: no such task "${id}" under ${roots.candidates.map(root => join(root, 'tasks')).join(' or ')}`,
|
|
662
|
+
root: roots.primary,
|
|
663
|
+
})
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
const task = detail(found.root, id)
|
|
667
|
+
if (task === null) {
|
|
668
|
+
json(404, { error: `smart: no such task "${id}" under ${join(found.root, 'tasks')}`, root: found.root })
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
// Y7:state.rootDir 只当校验值 —— 它不决定读哪个根(那是磁盘扫描的事),
|
|
672
|
+
// 不一致就要大声,因为那正是"复制任务目录 → 重定向写入"的指纹。
|
|
673
|
+
const recorded = task.state?.rootDir
|
|
674
|
+
const mismatch = typeof recorded === 'string' && recorded !== '' && resolve(recorded) !== resolve(found.root)
|
|
675
|
+
? { recorded, actual: found.root }
|
|
676
|
+
: null
|
|
677
|
+
if (mismatch !== null) note(`fly-smart-panel: task "${id}" state.rootDir=${mismatch.recorded} 与实际读根 ${mismatch.actual} 不一致`)
|
|
678
|
+
json(200, {
|
|
679
|
+
root: found.root,
|
|
680
|
+
...found.note === undefined ? {} : { note: found.note },
|
|
681
|
+
...mismatch === null ? {} : { rootDirMismatch: mismatch },
|
|
682
|
+
...task,
|
|
683
|
+
})
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (error instanceof RouteWriteError) {
|
|
686
|
+
json(error.status, { error: error.message })
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
note(`fly-smart-panel: request failed: ${String(error?.message ?? error)}`)
|
|
690
|
+
json(500, { error: `fly-smart-panel: read failed: ${String(error)}` })
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Plugin body: publish the service and claim the route namespace.
|
|
696
|
+
*
|
|
697
|
+
* @param ctx - the Host plugin context.
|
|
698
|
+
* @param rawConfig - the row's `config` object (`{ rootDir, brainRoute, handRoute }`).
|
|
699
|
+
*/
|
|
700
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
701
|
+
// bundled skills(移植自 obra/superpowers,见 THIRD_PARTY_NOTICES.md)
|
|
702
|
+
//
|
|
703
|
+
// 技能放在**宿主平面**这一行:它们的价值是**零 smart 任务就能用** —— 修个 bug、
|
|
704
|
+
// 写段测试、声称完成,这些不需要开工作流。形状照 `dsh-plugin-fly-blackbox`:
|
|
705
|
+
// 注册一个 skill provider,从包内 `assets/<name>/` 读,别名即目录名。
|
|
706
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
/** `assets/` 的绝对路径(打包版走真实路径,不是 SEA)。 */
|
|
709
|
+
const ASSETS_ROOT = fileURLToPath(new URL('./assets/', import.meta.url))
|
|
710
|
+
|
|
711
|
+
/** frontmatter 里必须有的两个键:`name` 与 `description`(技能目录只看 description)。 */
|
|
712
|
+
function skillFrontmatter(raw, path) {
|
|
713
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw)
|
|
714
|
+
if (block?.[1] === undefined) throw new Error(`fly-smart: ${path} has no YAML frontmatter`)
|
|
715
|
+
const name = /^name:\s*(.+)$/mu.exec(block[1])?.[1]?.trim()
|
|
716
|
+
const description = /^description:\s*(.+)$/mu.exec(block[1])?.[1]?.trim()
|
|
717
|
+
if (name === undefined || name === '') throw new Error(`fly-smart: ${path} has no name`)
|
|
718
|
+
if (description === undefined || description === '') throw new Error(`fly-smart: ${path} has no description`)
|
|
719
|
+
return { name, description, content: raw.slice(block[0].length).trim() }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Every bundled skill, listed once at startup.
|
|
724
|
+
*
|
|
725
|
+
* A missing or malformed `SKILL.md` throws here rather than silently dropping the
|
|
726
|
+
* skill: "the skill is not in the catalog" is the exact failure the plugin exists
|
|
727
|
+
* to prevent, and it must be loud.
|
|
728
|
+
*/
|
|
729
|
+
function bundledSkills() {
|
|
730
|
+
const entries = readdirSync(ASSETS_ROOT, { withFileTypes: true })
|
|
731
|
+
.filter(entry => entry.isDirectory())
|
|
732
|
+
.map(entry => entry.name)
|
|
733
|
+
.sort()
|
|
734
|
+
return entries.map(dirName => {
|
|
735
|
+
const base = join(ASSETS_ROOT, dirName)
|
|
736
|
+
const locator = join(base, 'SKILL.md')
|
|
737
|
+
const raw = readFileSync(locator, 'utf8')
|
|
738
|
+
const { name, description } = skillFrontmatter(raw, locator)
|
|
739
|
+
return {
|
|
740
|
+
name,
|
|
741
|
+
description,
|
|
742
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
743
|
+
provider: 'fly-smart',
|
|
744
|
+
source: 'bundled',
|
|
745
|
+
rank: BUNDLED_SKILL_RANK,
|
|
746
|
+
resourceBase: { kind: 'directory', path: base },
|
|
747
|
+
locator,
|
|
748
|
+
}
|
|
749
|
+
})
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function smartSkillProvider() {
|
|
753
|
+
const skills = bundledSkills()
|
|
754
|
+
return {
|
|
755
|
+
name: 'fly-smart',
|
|
756
|
+
list: () => Promise.resolve(skills),
|
|
757
|
+
async get(requested, options) {
|
|
758
|
+
const { rank: _rank, locator: file, ...summary } = requested
|
|
759
|
+
const raw = readFileSync(file, { encoding: 'utf8', signal: options?.signal })
|
|
760
|
+
return { ...summary, content: skillFrontmatter(raw, file).content }
|
|
761
|
+
},
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function apply(ctx, rawConfig = {}) {
|
|
766
|
+
const env = process.env
|
|
767
|
+
const mirrorPath = resolveMirrorPath(env)
|
|
768
|
+
const rowHand = readRowString(rawConfig?.handRoute)
|
|
769
|
+
const rowReview = readRowString(rawConfig?.reviewRoute)
|
|
770
|
+
|
|
771
|
+
// 行 ref 非空 = 脚本化硬底:启动时并进 default 层(永不删键)。行 ref 为空时
|
|
772
|
+
// **一个字节都不写** —— 真实的 ~/.dsh-fly/smart-routes.json 是用户的配置,不是我们的草稿纸。
|
|
773
|
+
ctx.effect(() => {
|
|
774
|
+
if (rowHand === '') return
|
|
775
|
+
try {
|
|
776
|
+
mergeRowRoutes(mirrorPath, { hand: rowHand })
|
|
777
|
+
} catch (error) {
|
|
778
|
+
ctx.get?.('log')?.warn?.(`fly-smart-panel: route mirror merge failed: ${String(error?.message ?? error)}`)
|
|
779
|
+
}
|
|
780
|
+
}, 'fly-smart-panel: route mirror')
|
|
781
|
+
|
|
782
|
+
const sessions = () => {
|
|
783
|
+
try {
|
|
784
|
+
// 可选链:宿主行没有声明 `inject: ['sessions']`(见 acceptance.md M4),
|
|
785
|
+
// 拿不到就是"工作区未知",而不是整块不渲染。
|
|
786
|
+
return ctx.get?.('sessions')
|
|
787
|
+
} catch {
|
|
788
|
+
return undefined
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const rootsOf = sessionId => resolveRoots({ rawConfig, env, sessionId, sessions: sessions() })
|
|
793
|
+
|
|
794
|
+
const service = Object.freeze({
|
|
795
|
+
path: FLY_SMART_PATH,
|
|
796
|
+
rootDir: resolveRootDir(rawConfig, env),
|
|
797
|
+
legacyRoot: resolveLegacyRoot(),
|
|
798
|
+
mirrorPath,
|
|
799
|
+
activatedAt: new Date().toISOString(),
|
|
800
|
+
endpoints: ENDPOINTS.map(endpoint => ({ ...endpoint })),
|
|
801
|
+
roots: sessionId => rootsOf(sessionId),
|
|
802
|
+
routes: sessionId => readRoutesConfig({ mirrorPath, rowHand, rowReview, sessionId }),
|
|
803
|
+
tasks: sessionId => {
|
|
804
|
+
const roots = rootsOf(sessionId)
|
|
805
|
+
return roots.unknownWorkspace ? { source: 'unknown', tasks: [] } : readIndex(roots.primary)
|
|
806
|
+
},
|
|
807
|
+
locate: (id, sessionId) => locateTask(id, rootsOf(sessionId).candidates),
|
|
808
|
+
task: (id, sessionId) => {
|
|
809
|
+
const found = locateTask(id, rootsOf(sessionId).candidates)
|
|
810
|
+
return found === null ? null : readTask(found.root, id)
|
|
811
|
+
},
|
|
812
|
+
})
|
|
813
|
+
ctx.provide(FLY_SMART_SERVICE, service)
|
|
814
|
+
|
|
815
|
+
const deps = {
|
|
816
|
+
roots: rootsOf,
|
|
817
|
+
readRoutes: sessionId => readRoutesConfig({ mirrorPath, rowHand, rowReview, sessionId }),
|
|
818
|
+
writeRoutes: ({ role = 'hand', value = '', hand = value, scope, sessionId }) => writeRoutesConfig({ mirrorPath, rowHand, rowReview, role, value, hand, scope, sessionId }),
|
|
819
|
+
locate: (id, sessionId) => locateTask(id, rootsOf(sessionId).candidates),
|
|
820
|
+
readIndex,
|
|
821
|
+
readTask,
|
|
822
|
+
log: () => {
|
|
823
|
+
try {
|
|
824
|
+
return ctx.get?.('log')
|
|
825
|
+
} catch {
|
|
826
|
+
return undefined
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// 自带技能:零 smart 任务即可用(修 bug / 写测试 / 声称完成都不必开工作流)。
|
|
832
|
+
// **故意在注册时就展开全部技能**:少一个 SKILL.md 或 frontmatter 缺键 ⇒ 抛,
|
|
833
|
+
// 而不是静默少一个技能 —— 「技能不在目录里」正是本插件要防的失败形状。
|
|
834
|
+
ctx.skills.registerProvider(() => smartSkillProvider())
|
|
835
|
+
|
|
836
|
+
ctx.effect(
|
|
837
|
+
() => ctx.webServer.register({
|
|
838
|
+
kind: 'prefix',
|
|
839
|
+
path: FLY_SMART_PATH,
|
|
840
|
+
handler: (req, res) => answerSmartRequest(deps, req, res),
|
|
841
|
+
}),
|
|
842
|
+
'fly-smart-panel: /fly/smart/*',
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Re-exported for the route panel's own tests and for `formatRoute` consumers. */
|
|
847
|
+
export { formatRoute }
|