| // g-170:质量判据编辑弹窗(方案 A)——详情弹窗「质量判据」标题处入口打开。 | ||
| // 逐行编辑/新增/删除/上移/下移;保存统一 trim/去重/1..N 重排(服务端 updateCriteria); | ||
| // D6:进入编辑前明确告知保存后清空该目标已有 localStorage 勾选,保存成功后清空; | ||
| // D8:携带 base_items 乐观并发 token,409 冲突时自动以本地内容覆盖服务器重试(force=true), | ||
| // 不静默丢弃本地修改,并给出可理解反馈。 | ||
| function CriteriaModal(props) { | ||
| const { goalId, onClose, onSaved } = props; | ||
| const [state, setState] = React.useState({ loading: true }); | ||
| const [rows, setRows] = React.useState([]); | ||
| const [baseItems, setBaseItems] = React.useState(null); | ||
| const [note, setNote] = React.useState(null); | ||
| const [saving, setSaving] = React.useState(false); | ||
| // g-181:backdrop 误关保护——组件顶部调用(多分支共享同一 guard,保持 Hook 顺序稳定) | ||
| const backdropGuard = useBackdropClose(onClose); | ||
| // 与 core criteriaItems 同构的「N. 」编号前缀剥离(编辑行只保留原文) | ||
| const stripNum = (s) => String(s).replace(/^\d+[.、)]\s*/, ""); | ||
| React.useEffect(() => { | ||
| fetch(graphUrl("/api/dsh-graph/goal", { id: goalId })) | ||
| .then((r) => r.json()) | ||
| .then((data) => { | ||
| if (data.error) { setState({ loading: false, error: data.error }); return; } | ||
| const items = Array.isArray(data.criteria_items) ? data.criteria_items : []; | ||
| setBaseItems(items); | ||
| setRows(items.map(stripNum)); | ||
| setState({ loading: false, data }); | ||
| }) | ||
| .catch((e) => setState({ loading: false, error: String(e) })); | ||
| }, [goalId]); | ||
| const setRow = (i, v) => setRows(rows.map((r, j) => (j === i ? v : r))); | ||
| const removeRow = (i) => setRows(rows.filter((_, j) => j !== i)); | ||
| const moveRow = (i, dir) => { | ||
| const j = i + dir; | ||
| if (j < 0 || j >= rows.length) return; | ||
| const next = [...rows]; | ||
| [next[i], next[j]] = [next[j], next[i]]; | ||
| setRows(next); | ||
| }; | ||
| const addRow = () => setRows([...rows, ""]); | ||
| // D8:保存携带 base_items;409 冲突 → 自动以本地内容覆盖服务器重试(force=true) | ||
| const doSave = async () => { | ||
| setSaving(true); setNote(null); | ||
| const items = rows.map((s) => s.trim()).filter((s) => s !== ""); | ||
| const post = (force) => fetch(graphUrl("/api/dsh-graph/set-criteria"), { | ||
| method: "POST", headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ goal: goalId, items, base_items: baseItems ?? [], force: !!force }), | ||
| }); | ||
| try { | ||
| let r = await post(false); | ||
| let data = await r.json(); | ||
| if (r.status === 409) { | ||
| // D8:并发变化 → 自动以本地编辑内容覆盖服务器,不静默丢弃本地修改 | ||
| setNote("⚠️ 检测到判据已被其他编辑修改,正在以本地内容覆盖服务器…"); | ||
| r = await post(true); | ||
| data = await r.json(); | ||
| if (data.ok) setNote("✅ 已保存(并发覆盖)"); | ||
| } | ||
| if (data.ok) { | ||
| // D6:保存成功后清空该目标已有 localStorage 勾选 | ||
| try { localStorage.removeItem("dsh-graph.crit." + goalId); } catch {} | ||
| window.dispatchEvent(new Event("dsh-graph.criteria-changed")); | ||
| showToast("✅ 判据已保存(勾选已清空)"); | ||
| onSaved?.(); | ||
| onClose?.(); | ||
| } else { | ||
| setNote("⚠️ 保存失败:" + (data.error || "未知错误")); | ||
| } | ||
| } catch (e) { | ||
| setNote("⚠️ 请求失败:" + String(e?.message ?? e)); | ||
| } | ||
| setSaving(false); | ||
| }; | ||
| if (state.loading) { | ||
| return h("div", { style: S.overlay, ...backdropGuard }, | ||
| h("div", { style: { ...S.modal, maxWidth: 620 }, onClick: (e) => e.stopPropagation() }, | ||
| h("span", { style: S.close, onClick: onClose }, "✕"), | ||
| h("div", { style: { fontWeight: 700, fontSize: 15 } }, "✏️ 编辑质量判据"), | ||
| h("div", { style: { ...S.meta, marginTop: 6 } }, "加载中…"))); | ||
| } | ||
| if (state.error) { | ||
| return h("div", { style: S.overlay, ...backdropGuard }, | ||
| h("div", { style: { ...S.modal, maxWidth: 620 }, onClick: (e) => e.stopPropagation() }, | ||
| h("span", { style: S.close, onClick: onClose }, "✕"), | ||
| h("div", { style: { fontWeight: 700, fontSize: 15 } }, "✏️ 编辑质量判据"), | ||
| h("div", { style: { ...S.meta, marginTop: 6, color: "var(--dsw-alias-state-error-primary, #d66)" } }, "加载失败:" + state.error))); | ||
| } | ||
| const goalTitle = state.data?.meta?.title ?? null; | ||
| const rowBtn = (label, tip, onClick, extra) => h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "0 5px", flexShrink: 0, ...(extra ?? {}) }, | ||
| className: "dg-btn", | ||
| title: tip, | ||
| onClick: (e) => { e.stopPropagation(); onClick(); }, | ||
| }, label); | ||
| return h("div", { style: S.overlay, ...backdropGuard }, | ||
| h("div", { style: { ...S.modal, maxWidth: 620 }, onClick: (e) => e.stopPropagation() }, | ||
| h("span", { style: S.close, onClick: onClose }, "✕"), | ||
| h("div", { style: { fontWeight: 700, fontSize: 15 } }, "✏️ 编辑质量判据"), | ||
| goalTitle ? h("div", { style: { ...S.meta, marginTop: 2 } }, `${goalId} | ${goalTitle}`) : null, | ||
| // D6:进入编辑前明确告知保存后果 | ||
| h("div", { style: { marginTop: 8, padding: "6px 8px", borderRadius: 4, fontSize: 12, | ||
| background: "rgba(224,165,58,.14)", border: "1px solid rgba(224,165,58,.4)" } }, | ||
| "⚠️ 保存后将清空该目标已有的判据勾选状态。"), | ||
| h("div", { style: { marginTop: 10, display: "flex", flexDirection: "column", gap: 4 } }, | ||
| rows.length === 0 | ||
| ? h("div", { style: { ...S.meta, fontSize: 12, opacity: 0.6, padding: "4px 0" } }, | ||
| "(暂无判据——点击下方「➕ 新增判据」添加)") | ||
| : rows.map((row, i) => | ||
| h("div", { key: i, style: { display: "flex", alignItems: "center", gap: 4 } }, | ||
| h("span", { style: { ...S.meta, fontSize: 11, width: 22, flexShrink: 0, textAlign: "right" } }, | ||
| `${i + 1}.`), | ||
| h("input", { | ||
| style: { ...S.promptInput, flex: 1 }, | ||
| value: row, | ||
| placeholder: "判据内容…", | ||
| onChange: (e) => setRow(i, e.target.value), | ||
| }), | ||
| rowBtn("↑", "上移", () => moveRow(i, -1), { opacity: i === 0 ? 0.35 : 1 }), | ||
| rowBtn("↓", "下移", () => moveRow(i, 1), { opacity: i === rows.length - 1 ? 0.35 : 1 }), | ||
| rowBtn("🗑", "删除该条", () => removeRow(i))))), | ||
| h("button", { | ||
| style: { ...S.btn, marginTop: 8 }, className: "dg-btn", | ||
| onClick: addRow, | ||
| }, "➕ 新增判据"), | ||
| h("div", { style: { display: "flex", gap: 6, marginTop: 12 } }, | ||
| h("button", { | ||
| style: { ...S.btnAccept, padding: "4px 14px", fontSize: 13 }, className: "dg-btn-accept", | ||
| disabled: saving, onClick: doSave, | ||
| }, saving ? "保存中…" : "💾 保存"), | ||
| h("button", { | ||
| style: { ...S.btn, padding: "4px 14px", fontSize: 13 }, className: "dg-btn", | ||
| disabled: saving, onClick: onClose, | ||
| }, "取消")), | ||
| note ? h("div", { style: { ...S.meta, marginTop: 6, fontSize: 11 } }, note) : null)); | ||
| } |
| // ===== g-132:workspace 看板设置弹窗(读取/可视化编辑 .dsh-graph/project.yaml 安全配置) ===== | ||
| // 字段范围(本期):executor.provider/model、defaults.review、defaults.pk、supervisor.automation、 | ||
| // 子代理补充提示词 workspace 覆盖(三态:default 继承 / 自定义覆盖 / 显式空禁用)。 | ||
| // 保存走 PUT/POST /api/dsh-graph/settings(原子写;保留注释/未知键;失败不半写入)。 | ||
| function SettingsModal(props) { | ||
| const [loading, setLoading] = React.useState(true); | ||
| const [form, setForm] = React.useState(null); | ||
| const [saving, setSaving] = React.useState(false); | ||
| const [note, setNote] = React.useState(null); // {kind:"ok"|"err", text} | ||
| const [error, setError] = React.useState(null); | ||
| const [showAdvanced, setShowAdvanced] = React.useState(false); | ||
| // att-002:服务端下发的 canonical .dsh-graph/project.yaml 绝对路径(只消费,不自行猜 graphRoot) | ||
| const [configFile, setConfigFile] = React.useState(null); | ||
| const set = (path, value) => { | ||
| setForm((f) => { | ||
| const next = JSON.parse(JSON.stringify(f)); | ||
| let cur = next; | ||
| for (let i = 0; i < path.length - 1; i++) { | ||
| if (!cur[path[i]] || typeof cur[path[i]] !== "object") cur[path[i]] = {}; | ||
| cur = cur[path[i]]; | ||
| } | ||
| cur[path[path.length - 1]] = value; | ||
| return next; | ||
| }); | ||
| }; | ||
| // 三态提示词切换:default/disable 清空 value,override 保留文本 | ||
| const setPromptState = (key, state) => { | ||
| set(["prompt_overrides", key, "state"], state); | ||
| if (state !== "override") set(["prompt_overrides", key, "value"], null); | ||
| }; | ||
| const setPromptValue = (key, value) => set(["prompt_overrides", key, "value"], value); | ||
| const load = async () => { | ||
| setLoading(true); setError(null); | ||
| try { | ||
| const r = await fetch(graphUrl("/api/dsh-graph/settings")); | ||
| const data = await r.json(); | ||
| if (!r.ok) throw new Error(data?.error || ("请求失败 " + r.status)); | ||
| setForm(data); | ||
| setConfigFile(data.configFile ?? null); | ||
| } catch (e) { | ||
| setError("加载配置失败:" + String(e?.message ?? e)); | ||
| } finally { setLoading(false); } | ||
| }; | ||
| React.useEffect(() => { load(); }, []); | ||
| // 目录化 select(与 settings.js g-133 同源):挂载时用同 scope 的 gConnectionApi/loadHostCatalog | ||
| // 读取当前 Host 的 llm.providers/llm.models 合法目录。RPC 缺失/失败时目录状态置 unavailable, | ||
| // 降级为「提示 + 保留已存值」,不阻止保存。provider 只列 active 且有模型目录的 provider; | ||
| // model 按当前 provider 过滤;空项代表继承父会话;未列出的已存旧值保留为固定 option。 | ||
| const [catalog, setCatalog] = React.useState({ status: "loading" }); | ||
| React.useEffect(() => { | ||
| let alive = true; | ||
| if (!gConnectionApi?.llm?.providers || !gConnectionApi?.llm?.models) { setCatalog({ status: "unavailable" }); return; } | ||
| loadHostCatalog(gConnectionApi) | ||
| .then((c) => { if (alive) setCatalog(c); }) | ||
| .catch(() => { if (alive) setCatalog({ status: "unavailable" }); }); | ||
| return () => { alive = false; }; | ||
| }, []); | ||
| // g-181:backdrop 误关保护——组件顶部调用(多分支共享同一 guard,保持 Hook 顺序稳定) | ||
| const backdropGuard = useBackdropClose(props.onClose); | ||
| const save = async () => { | ||
| if (!form) return; | ||
| setSaving(true); setNote(null); setError(null); | ||
| const lanesRaw = form.defaults?.pk?.lanes; | ||
| const lanes = lanesRaw === null || lanesRaw === "" ? 1 : Number(lanesRaw); | ||
| if (!Number.isInteger(lanes) || lanes < 1) { | ||
| setNote({ kind: "err", text: "pk.lanes 必须是 >=1 的整数" }); | ||
| setSaving(false); return; | ||
| } | ||
| const patch = { | ||
| executor: { provider: form.executor?.provider ?? "", model: form.executor?.model ?? "" }, | ||
| defaults: { | ||
| review: { reviewer: form.defaults?.review?.reviewer ?? "", prompt: form.defaults?.review?.prompt ?? null }, | ||
| pk: { lanes, sandbox: form.defaults?.pk?.sandbox ?? "" }, | ||
| }, | ||
| supervisor: { automation: { ...(form.supervisor?.automation ?? {}) } }, | ||
| prompt_overrides: { | ||
| subagent: form.prompt_overrides?.subagent ?? { state: "default", value: null }, | ||
| }, | ||
| }; | ||
| try { | ||
| const r = await fetch(graphUrl("/api/dsh-graph/settings"), { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify(patch), | ||
| }); | ||
| const data = await r.json(); | ||
| if (!r.ok) throw new Error(data?.error || ("保存失败 " + r.status)); | ||
| setForm(data.config ?? form); // 用服务端回填的最新配置刷新 | ||
| setNote({ kind: "ok", text: "✅ 已保存(刷新/重开弹窗值保留)" }); | ||
| props.onSaved?.(); | ||
| } catch (e) { | ||
| setNote({ kind: "err", text: "保存失败:" + String(e?.message ?? e) }); | ||
| } finally { setSaving(false); } | ||
| }; | ||
| if (loading) { | ||
| return h("div", { style: S.overlay, ...backdropGuard }, | ||
| h("div", { style: { ...S.modal, maxWidth: 520 }, onClick: (e) => e.stopPropagation() }, | ||
| h("span", { style: S.close, onClick: props.onClose }, "✕"), | ||
| h("div", { style: S.modalH }, "看板设置"), | ||
| h("div", { style: { ...S.meta, marginTop: 8 } }, "正在读取配置…"))); | ||
| } | ||
| if (!form) { | ||
| return h("div", { style: S.overlay, ...backdropGuard }, | ||
| h("div", { style: { ...S.modal, maxWidth: 520 }, onClick: (e) => e.stopPropagation() }, | ||
| h("span", { style: S.close, onClick: props.onClose }, "✕"), | ||
| h("div", { style: S.modalH }, "看板设置"), | ||
| error ? h("div", { style: { ...S.meta, color: "var(--dsw-alias-state-error-primary, #f08080)", marginTop: 8 } }, error) : null, | ||
| h("button", { style: { ...S.btn, marginTop: 10 }, className: "dg-btn", onClick: load }, "重试"))); | ||
| } | ||
| const auto = form.supervisor?.automation ?? {}; | ||
| const automationOptions = (val) => [ | ||
| h("option", { value: "", style: { background: "var(--dsw-alias-bg-layer-3, #2a2b31)", color: "var(--dsw-alias-label-primary, #e6e6e6)" } }, "(未设置)"), | ||
| h("option", { value: "human", style: { background: "var(--dsw-alias-bg-layer-3, #2a2b31)", color: "var(--dsw-alias-label-primary, #e6e6e6)" } }, "human(人工)"), | ||
| h("option", { value: "ai", style: { background: "var(--dsw-alias-bg-layer-3, #2a2b31)", color: "var(--dsw-alias-label-primary, #e6e6e6)" } }, "ai(自动)"), | ||
| ]; | ||
| const promptOverride = (key, label) => { | ||
| const ov = form.prompt_overrides?.[key] ?? { state: "default", value: null }; | ||
| const body = | ||
| ov.state === "override" | ||
| ? h("textarea", { | ||
| style: { ...S.promptInput, width: "100%", minHeight: 56, resize: "vertical" }, | ||
| value: ov.value ?? "", | ||
| placeholder: "输入覆盖文本(支持空格/引号/#/多行)…", | ||
| onChange: (e) => setPromptValue(key, e.target.value), | ||
| }) | ||
| : h("div", { style: S.meta }, | ||
| ov.state === "default" ? "(继承当前 profile 全局提示词)" : "(全局提示词被禁用)"); | ||
| const stateBtn = (st) => h("button", { | ||
| key: st, | ||
| className: "dg-btn", | ||
| style: { | ||
| ...S.btn, fontSize: 11, padding: "2px 8px", cursor: "pointer", | ||
| border: "1px solid " + (ov.state === st ? "rgba(76,141,255,.55)" : "rgba(128,128,128,.3)"), | ||
| background: ov.state === st ? "rgba(76,141,255,.15)" : "rgba(128,128,128,.12)", | ||
| fontWeight: ov.state === st ? 700 : 400, | ||
| }, | ||
| title: st === "default" ? "继承当前 DSH profile 全局值" : (st === "override" ? "自定义文本覆盖全局" : "显式禁用全局提示词"), | ||
| onClick: () => setPromptState(key, st), | ||
| }, st === "default" ? "default(继承)" : (st === "override" ? "override(覆盖)" : "disable(禁用)")); | ||
| return h("div", { style: { marginBottom: 10 } }, | ||
| h("div", { style: { fontWeight: 600, marginBottom: 4 } }, label), | ||
| h("div", { style: { display: "flex", gap: 6, marginBottom: 4 } }, | ||
| ["default", "override", "disable"].map((st) => stateBtn(st))), | ||
| body); | ||
| }; | ||
| // ===== g-133:provider/model 合法目录派生(与 settings.js 页面同源逻辑,字段换成 executor.*) ===== | ||
| // 目录仅 advisory 可选列表:未列出的已存旧值保留为固定 option(带「未列出/读取中/不可用」后缀), | ||
| // 不拦截保存;空值 = 继承父会话。保存仍写 form.executor.provider/model 到 workspace project.yaml。 | ||
| const catReady = catalog.status === "ready"; | ||
| const providerById = new Map(catReady ? catalog.providers.map((p) => [p.provider, p]) : []); | ||
| const groupById = new Map(catReady ? catalog.groups.map((g) => [g.id, g]) : []); | ||
| const providerLabel = (id) => { | ||
| const p = providerById.get(id); | ||
| if (p?.displayName && p.displayName !== id) return p.displayName + "(" + id + ")"; | ||
| return p?.displayName || groupById.get(id)?.name || id; | ||
| }; | ||
| const legalProviders = catReady | ||
| ? catalog.providers.filter((p) => p.active && (groupById.get(p.provider)?.models.length ?? 0) > 0) | ||
| : []; | ||
| const legalProviderIds = new Set(legalProviders.map((p) => p.provider)); | ||
| const allLegalModels = []; // 未选 provider 时全量合法模型(label: provider/name 区分) | ||
| const legalModelsByProvider = new Map(); // providerId -> Set(modelId) | ||
| if (catReady) { | ||
| for (const g of catalog.groups) { | ||
| const ids = new Set(); | ||
| for (const m of g.models) { | ||
| ids.add(m.id); | ||
| allLegalModels.push({ value: m.id, label: providerLabel(g.id) + "/" + (m.name ?? m.id) }); | ||
| } | ||
| legalModelsByProvider.set(g.id, ids); | ||
| } | ||
| } | ||
| const curProvider = form.executor?.provider ?? ""; | ||
| const curModel = form.executor?.model ?? ""; | ||
| const legacySuffix = catReady | ||
| ? "(已存值,当前目录未列出)" | ||
| : (catalog.status === "loading" ? "(目录读取中…)" : "(目录不可用)"); | ||
| // provider 切换:切到合法新 provider 且现有 model 不属于其目录则清空 model(保留空=继承语义); | ||
| // 切到已存 legacy provider / 留空不强行清空,避免丢失已存 model。 | ||
| const onProviderChange = (v) => { | ||
| set(["executor", "provider"], v); | ||
| if (v !== "" && legalProviderIds.has(v) && curModel !== "" && !(legalModelsByProvider.get(v)?.has(curModel))) { | ||
| set(["executor", "model"], ""); | ||
| } | ||
| }; | ||
| const opt = (key, value, label) => | ||
| h("option", { key, value, style: { background: "var(--dsw-alias-bg-layer-3, #2a2b31)", color: "var(--dsw-alias-label-primary, #e6e6e6)" } }, label); | ||
| const providerOptions = (() => { | ||
| const opts = [opt("__blank-p", "", "(继承父会话)")]; | ||
| // 已存 provider 未在合法目录中(含目录未就绪时无法校验)→ 保留为固定 option | ||
| if (curProvider !== "" && !(catReady && legalProviderIds.has(curProvider))) { | ||
| opts.push(opt("__cur-p", curProvider, curProvider + legacySuffix)); | ||
| } | ||
| if (catReady) for (const p of legalProviders) opts.push(opt(p.provider, p.provider, providerLabel(p.provider))); | ||
| return opts; | ||
| })(); | ||
| const modelOptions = (() => { | ||
| const opts = [opt("__blank-m", "", "(继承父会话)")]; | ||
| // 已存 model 是否出现在目录中:目录就绪时按所选 provider 校验;未就绪时无法校验 → 一律保留 | ||
| const curListed = catReady && (curProvider !== "" | ||
| ? (legalModelsByProvider.get(curProvider)?.has(curModel) ?? false) | ||
| : allLegalModels.some((m) => m.value === curModel)); | ||
| if (curModel !== "" && !curListed) opts.push(opt("__cur-m", curModel, curModel + legacySuffix)); | ||
| if (catReady) { | ||
| if (curProvider !== "") { | ||
| const g = groupById.get(curProvider); | ||
| if (g) for (const m of g.models) opts.push(opt(g.id + "/" + m.id, m.id, m.name ?? m.id)); | ||
| } else { | ||
| for (const m of allLegalModels) opts.push(opt(m.label, m.value, m.label)); | ||
| } | ||
| } | ||
| return opts; | ||
| })(); | ||
| return h("div", { style: S.overlay, ...backdropGuard }, | ||
| h("div", { style: { ...S.modal, maxWidth: 640 }, onClick: (e) => e.stopPropagation() }, | ||
| h("span", { style: S.close, onClick: props.onClose }, "✕"), | ||
| h("div", { style: S.modalH }, "看板设置"), | ||
| h("div", { style: S.meta }, "编辑当前 workspace 的 .dsh-graph/project.yaml 安全配置;写回保留未知键与注释。"), | ||
| // att-002:配置文件操作入口——复用 goal-modal 的 Host openPath/copyText/toast/fallback 机制 | ||
| configFile | ||
| ? h("div", { style: { display: "flex", alignItems: "center", gap: 4, marginTop: 4 } }, | ||
| h("span", { style: { fontSize: 11, opacity: 0.7 } }, "📄 project.yaml"), | ||
| h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "1px 6px" }, | ||
| className: "dg-btn", | ||
| title: "用系统默认编辑器打开 project.yaml", | ||
| onClick: async (e) => { | ||
| e.stopPropagation(); | ||
| try { | ||
| const conn = connectionRt ?? appCtx?.get?.("connection"); | ||
| if (conn?.api?.host?.openPath) { | ||
| const result = await conn.api.host.openPath({ path: configFile }); | ||
| if (result?.opened) { showToast("✅ 已打开 project.yaml"); return; } | ||
| } | ||
| await copyText(configFile); | ||
| showToast("✅ 路径已复制(打开不可用)"); | ||
| } catch { | ||
| await copyText(configFile); | ||
| showToast("✅ 路径已复制"); | ||
| } | ||
| }, | ||
| }, "打开"), | ||
| h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "1px 6px" }, | ||
| className: "dg-btn", | ||
| title: "复制 project.yaml 路径", | ||
| onClick: async (e) => { e.stopPropagation(); const ok = await copyText(configFile); if (ok) showToast("✅ 路径已复制"); }, | ||
| }, "复制路径")) | ||
| : null, | ||
| h("button", { className: "dg-btn", style: { ...S.btn, marginTop: 6, fontSize: 12 }, onClick: () => setShowAdvanced((v) => !v) }, showAdvanced ? "隐藏高级/仅存储字段" : "显示高级/仅存储字段"), | ||
| h("hr", { style: { border: "none", borderTop: "1px solid rgba(128,128,128,.25)", margin: "10px 0" } }), | ||
| h("div", { style: { fontWeight: 700, marginBottom: 4 } }, "执行子代理模型路由"), | ||
| // g-133:两列并排各占一半的可收缩 flex 布局——父容器 minWidth:0、子列 flex:"1 1 0"+minWidth:0、 | ||
| // 控件 boxSizing:"border-box",避免 provider/model 两列在窄容器下重叠/溢出。 | ||
| h("div", { style: { display: "flex", gap: 8, minWidth: 0 } }, | ||
| h("div", { style: { flex: "1 1 0", minWidth: 0 } }, | ||
| h("label", { style: { display: "block", marginBottom: 2, fontSize: 11, opacity: 0.8 } }, "provider"), | ||
| h("select", { style: { ...S.promptInput, width: "100%", boxSizing: "border-box" }, value: curProvider, onChange: (e) => onProviderChange(e.target.value) }, | ||
| ...providerOptions)), | ||
| h("div", { style: { flex: "1 1 0", minWidth: 0 } }, | ||
| h("label", { style: { display: "block", marginBottom: 2, fontSize: 11, opacity: 0.8 } }, "model"), | ||
| h("select", { style: { ...S.promptInput, width: "100%", boxSizing: "border-box" }, value: curModel, onChange: (e) => set(["executor", "model"], e.target.value) }, | ||
| ...modelOptions))), | ||
| h("div", { style: { ...S.meta, marginTop: 4 } }, | ||
| catReady | ||
| ? "目录来自当前 Host(llm.providers/models,仅可选列表):provider 仅列 active 且有模型目录的项;model 按当前 provider 过滤;空项继承父会话;已存但未列出的旧值保留为固定选项、仍可保存。" | ||
| : (catalog.status === "loading" ? "正在读取当前 Host 的合法 provider/model 目录…" : "当前 Host 目录不可用(llm.providers/models 缺失)——已存值保留可选、仍可保存。")), | ||
| h("hr", { style: { display: showAdvanced ? "block" : "none", border: "none", borderTop: "1px solid rgba(128,128,128,.25)", margin: "10px 0" } }), | ||
| h("div", { style: { display: showAdvanced ? "block" : "none", fontWeight: 700, marginBottom: 4 } }, "高级/仅存储字段"), | ||
| h("div", { style: { display: showAdvanced ? "flex" : "none", gap: 8, flexWrap: "wrap" } }, | ||
| h("div", { style: { flex: "1 1 120px" } }, | ||
| h("label", { style: { display: "block", marginBottom: 2, fontSize: 11, opacity: 0.8 } }, "review.reviewer"), | ||
| h("input", { style: { ...S.promptInput, width: "100%" }, value: form.defaults?.review?.reviewer ?? "", onChange: (e) => set(["defaults", "review", "reviewer"], e.target.value) })), | ||
| h("div", { style: { flex: "1 1 120px" } }, | ||
| h("label", { style: { display: "block", marginBottom: 2, fontSize: 11, opacity: 0.8 } }, "review.prompt"), | ||
| h("input", { style: { ...S.promptInput, width: "100%" }, value: form.defaults?.review?.prompt ?? "", onChange: (e) => set(["defaults", "review", "prompt"], e.target.value === "" ? null : e.target.value) })), | ||
| h("div", { style: { flex: "1 1 90px" } }, | ||
| h("label", { style: { display: "block", marginBottom: 2, fontSize: 11, opacity: 0.8 } }, "pk.lanes"), | ||
| h("input", { style: { ...S.promptInput, width: "100%" }, type: "number", min: 1, value: form.defaults?.pk?.lanes ?? 1, onChange: (e) => set(["defaults", "pk", "lanes"], e.target.value) })), | ||
| h("div", { style: { flex: "1 1 120px" } }, | ||
| h("label", { style: { display: "block", marginBottom: 2, fontSize: 11, opacity: 0.8 } }, "pk.sandbox"), | ||
| h("input", { style: { ...S.promptInput, width: "100%" }, value: form.defaults?.pk?.sandbox ?? "", onChange: (e) => set(["defaults", "pk", "sandbox"], e.target.value) }))), | ||
| h("hr", { style: { display: showAdvanced ? "block" : "none", border: "none", borderTop: "1px solid rgba(128,128,128,.25)", margin: "10px 0" } }), | ||
| h("div", { style: { display: showAdvanced ? "block" : "none", fontWeight: 700, marginBottom: 4 } }, "主管自动化(高级/仅存储字段)"), | ||
| h("div", { style: { display: showAdvanced ? "grid" : "none", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 } }, | ||
| Object.keys({ scope_planning: "范围规划", integration_decision: "集成决策", rework: "返工决策", memory_promotion: "记忆提炼", skill_proposal: "技能提案", release: "发布" }).map((k) => | ||
| h("div", { key: k }, | ||
| h("label", { style: { display: "block", marginBottom: 2, fontSize: 11, opacity: 0.8 } }, k), | ||
| h("select", { style: { ...S.promptInput, width: "100%" }, value: auto[k] ?? "", onChange: (e) => set(["supervisor", "automation", k], e.target.value === "" ? null : e.target.value) }, | ||
| ...automationOptions(auto[k]))))), | ||
| h("hr", { style: { display: showAdvanced ? "block" : "none", border: "none", borderTop: "1px solid rgba(128,128,128,.25)", margin: "10px 0" } }), | ||
| h("div", { style: { fontWeight: 700, marginBottom: 4 } }, "补充提示词 workspace 覆盖"), | ||
| promptOverride("subagent", "子代理补充提示词"), | ||
| h("div", { style: { display: "flex", gap: 8, alignItems: "center", marginTop: 6 } }, | ||
| h("button", { style: { ...S.btn, padding: "6px 16px", fontSize: 13 }, className: "dg-btn", disabled: saving, onClick: save }, | ||
| saving ? "保存中…" : "保存"), | ||
| h("button", { style: { ...S.btn, padding: "6px 12px", fontSize: 12 }, className: "dg-btn", onClick: props.onClose }, "关闭"), | ||
| note ? h("span", { style: { ...S.meta, color: note.kind === "ok" ? "var(--dsw-alias-label-primary, #6ee7a0)" : "var(--dsw-alias-state-error-primary, #f08080)", marginLeft: 8 } }, note.text) : null), | ||
| error ? h("div", { style: { ...S.meta, color: "var(--dsw-alias-state-error-primary, #f08080)", marginTop: 6 } }, error) : null)); | ||
| } |
| // g-133:dsh-graph profile 级全局默认设置页(settings.section:看板设置)。 | ||
| // 仅保留子代理默认 provider、默认 model id、子代理默认补充提示词。 | ||
| // 该配置写入当前 DSH profile 的用户级全局设置(settingsScope.bind({namespace:"dsh-graph"})), | ||
| // 不写当前 workspace;provider/model 仅作缺省值(workspace project.yaml 明确配置优先)。 | ||
| // 覆盖层:settings scope 按 profile 隔离;memory scope 通过 Host settings RPC 兼容读写。 | ||
| // g-133:provider/model 由自由文本 input 改为合法目录 select——目录来自 ctx.get('connection').api | ||
| // 的 llm.providers/llm.models(仅 advisory 可选列表,不拦截保存);settings.yaml 已存但目录 | ||
| // 未列出的旧值保留为「已存值(当前目录未列出)」固定 option(不可自由编辑、可继续保存)。 | ||
| const GRAPH_SETTINGS_NS = "dsh-graph"; | ||
| // plugin.js apply 里绑定后的 settings scope(从 ctx.settingsScope.bind 得到),组件经它读写。 | ||
| let gSettingsScope = null; | ||
| // g-133:数据源 = ctx.get('connection').api(registerGraphSettingsSection 捕获),挂载时读 llm 目录。 | ||
| let gConnectionApi = null; | ||
| // 3082 的 settingsScope 在非 loopback 浏览器上下文会是 memory;此时仍可 | ||
| // 通过已存在的 profile settings RPC 读写 Host,而不是把配置伪装成 workspace 数据。 | ||
| function createGraphSettingsApiScope(api) { | ||
| if (!api?.settings?.describe || !api?.settings?.mutate) return null; | ||
| let snapshot = { status: "loading", value: null, writable: false, revision: undefined }; | ||
| const listeners = new Set(); | ||
| const notify = () => listeners.forEach((listener) => listener()); | ||
| const scope = { | ||
| getSnapshot: () => snapshot, | ||
| subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, | ||
| async load() { | ||
| const response = await api.settings.describe({}); | ||
| if (!response?.result?.ok) throw new Error(response?.result?.error?.message ?? "读取 profile 设置失败"); | ||
| const view = response.result.value; | ||
| const row = view.namespaces?.find((candidate) => candidate.ns === GRAPH_SETTINGS_NS); | ||
| if (!row) { | ||
| snapshot = { ...snapshot, status: "unavailable", writable: view.writable !== false }; | ||
| } else { | ||
| snapshot = { status: "ready", value: row.value ?? {}, writable: view.writable !== false, revision: row.revision }; | ||
| } | ||
| notify(); | ||
| }, | ||
| async set(field, value) { | ||
| const response = await api.settings.mutate({ | ||
| ns: GRAPH_SETTINGS_NS, | ||
| ops: [{ op: "set", path: [field], value }], | ||
| ...(snapshot.revision === undefined ? {} : { expectedRevision: snapshot.revision }), | ||
| }); | ||
| if (!response?.result?.ok) throw new Error(response?.result?.error?.message ?? "保存 profile 设置失败"); | ||
| const row = response.result.value; | ||
| snapshot = { ...snapshot, status: "ready", value: row.value ?? snapshot.value, revision: row.revision }; | ||
| notify(); | ||
| }, | ||
| }; | ||
| scope.load().catch((error) => { | ||
| snapshot = { ...snapshot, status: "unavailable", error: String(error?.message ?? error) }; | ||
| notify(); | ||
| }); | ||
| return scope; | ||
| } | ||
| // 优先使用官方 settingsScope;memory scope 只提供本地空壳,必须改用 Host API。 | ||
| function bindGraphSettingsScope(ctx) { | ||
| try { | ||
| const bound = ctx?.get?.("settingsScope")?.bind({ namespace: GRAPH_SETTINGS_NS }); | ||
| if (bound && bound.getSnapshot?.().mode !== "memory") return (gSettingsScope = bound); | ||
| const connection = ctx?.get?.("connection") ?? ctx?.connection; | ||
| return (gSettingsScope = createGraphSettingsApiScope(connection?.api)); | ||
| } catch { | ||
| gSettingsScope = null; | ||
| return null; | ||
| } | ||
| } | ||
| // g-133:从当前 Host 并行读取合法 provider/model 目录(connection.api.llm RPC)。 | ||
| // providers: [{provider, displayName, active,...}];models: {groups:[{id,name,models:[{id,name,...}]}], failures:[...]}。 | ||
| // 浏览器侧 RPC 结果形如 { result: { ok, value } };RPC 缺失/失败时返回 {status:"unavailable"}, | ||
| // 组件降级为「显示提示 + 保留已存值」,不让整个设置页崩溃。 | ||
| async function loadHostCatalog(api) { | ||
| if (!api?.llm?.providers || !api?.llm?.models) return { status: "unavailable" }; | ||
| const [pRes, mRes] = await Promise.allSettled([api.llm.providers({}), api.llm.models({})]); | ||
| const pv = pRes.status === "fulfilled" ? pRes.value?.result?.value : null; | ||
| const mv = mRes.status === "fulfilled" ? mRes.value?.result?.value : null; | ||
| if (!pv || !mv) return { status: "unavailable" }; | ||
| return { | ||
| status: "ready", | ||
| providers: Array.isArray(pv.providers) ? pv.providers : [], | ||
| groups: Array.isArray(mv.groups) ? mv.groups : [], | ||
| failures: Array.isArray(mv.failures) ? mv.failures : [], | ||
| }; | ||
| } | ||
| const GSS = { | ||
| panel: { display: "flex", flexDirection: "column", gap: 14, maxWidth: 720 }, | ||
| title: { margin: 0, fontSize: 16, fontWeight: 600, color: "var(--dsw-alias-label-primary)" }, | ||
| desc: { margin: 0, fontSize: 13, lineHeight: 1.6, color: "var(--dsw-alias-label-tertiary)" }, | ||
| field: { display: "flex", flexDirection: "column", gap: 6 }, | ||
| label: { fontSize: 12, fontWeight: 600, color: "var(--dsw-alias-label-secondary)" }, | ||
| hint: { fontSize: 11, color: "var(--dsw-alias-label-tertiary)" }, | ||
| select: { | ||
| boxSizing: "border-box", width: "100%", border: "1px solid var(--dsw-alias-border-l2)", | ||
| borderRadius: 8, padding: "6px 10px", fontSize: 13, color: "var(--dsw-alias-label-primary)", | ||
| background: "var(--dsw-alias-bg-layer-1)", fontFamily: "inherit", | ||
| }, | ||
| textarea: { | ||
| boxSizing: "border-box", width: "100%", minHeight: 72, resize: "vertical", | ||
| border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 8, padding: "6px 10px", | ||
| fontSize: 13, color: "var(--dsw-alias-label-primary)", background: "var(--dsw-alias-bg-layer-1)", | ||
| fontFamily: "inherit", | ||
| }, | ||
| row: { display: "flex", gap: 8, alignItems: "center" }, | ||
| btnPrimary: { | ||
| boxSizing: "border-box", height: 32, cursor: "pointer", border: "none", borderRadius: 16, | ||
| padding: "0 14px", fontSize: 13, background: "var(--dsw-alias-button-primary-fill)", | ||
| color: "var(--dsw-alias-label-primary-foreground)", | ||
| }, | ||
| noteOk: { fontSize: 12, color: "var(--dsw-alias-state-success-primary)" }, | ||
| noteErr: { fontSize: 12, color: "var(--dsw-alias-state-error-primary)" }, | ||
| note: { fontSize: 12, color: "var(--dsw-alias-label-tertiary)" }, | ||
| badge: { fontSize: 11, color: "var(--dsw-alias-label-tertiary)" }, | ||
| }; | ||
| // 看板设置页组件:读/写 dsh-graph profile 全局默认。 | ||
| function GraphSettingsSection(_props) { | ||
| const [snap, setSnap] = React.useState(gSettingsScope ? gSettingsScope.getSnapshot() : null); | ||
| const [draft, setDraft] = React.useState(null); | ||
| const [saving, setSaving] = React.useState(false); | ||
| const [saved, setSaved] = React.useState(""); | ||
| const [error, setError] = React.useState(""); | ||
| const [catalog, setCatalog] = React.useState({ status: "loading" }); | ||
| React.useEffect(() => { | ||
| if (!gSettingsScope) return; | ||
| const upd = () => { const s = gSettingsScope.getSnapshot(); setSnap(s); }; | ||
| upd(); | ||
| const un = gSettingsScope.subscribe(upd); | ||
| return un; | ||
| }, []); | ||
| // g-133:挂载时用捕获的 connection.api 并行读取 llm.providers/llm.models(当前 Host 合法目录)。 | ||
| // RPC 缺失/失败时目录状态置 unavailable,页面降级为「提示 + 保留已存值」,不崩溃。 | ||
| React.useEffect(() => { | ||
| let alive = true; | ||
| const api = gConnectionApi; | ||
| if (!api?.llm?.providers || !api?.llm?.models) { setCatalog({ status: "unavailable" }); return; } | ||
| loadHostCatalog(api) | ||
| .then((c) => { if (alive) setCatalog(c); }) | ||
| .catch(() => { if (alive) setCatalog({ status: "unavailable" }); }); | ||
| return () => { alive = false; }; | ||
| }, []); | ||
| // 没有 settings scope 且没有 Host API:整页降级(确实无持久化能力) | ||
| if (!gSettingsScope) { | ||
| return h("div", { style: GSS.panel }, | ||
| h("h3", { style: GSS.title }, "看板设置"), | ||
| h("p", { style: GSS.desc }, "当前 DSH profile 未暴露设置服务(settingsScope 缺失),无法读写 dsh-graph 全局配置。"), | ||
| ); | ||
| } | ||
| const status = snap?.status ?? "loading"; | ||
| const value = snap?.value ?? null; | ||
| const writable = snap?.writable !== false; | ||
| if (status === "loading") { | ||
| return h("div", { style: GSS.panel }, | ||
| h("h3", { style: GSS.title }, "看板设置"), | ||
| h("p", { style: GSS.note }, "正在读取 dsh-graph 全局配置…"), | ||
| ); | ||
| } | ||
| if (status === "unavailable") { | ||
| return h("div", { style: GSS.panel }, | ||
| h("h3", { style: GSS.title }, "看板设置"), | ||
| h("p", { style: GSS.desc }, "此 profile 未暴露 dsh-graph 设置命名空间(可能未连接 Host,或为 memory 模式),无法读写全局配置。"), | ||
| ); | ||
| } | ||
| // 当前已保存快照 + 本地草稿(草稿缺省即当前值) | ||
| const draftValue = draft ?? { | ||
| subagentProvider: value?.subagentProvider ?? "", | ||
| subagentModel: value?.subagentModel ?? "", | ||
| subagentPrompt: value?.subagentPrompt ?? "", | ||
| }; | ||
| const setField = (k, v) => setDraft({ ...draftValue, [k]: v }); | ||
| // g-133:合法目录派生。合法 provider = active 且有非空模型目录;model 合法 = 属于所选 provider 目录 | ||
| //(未选 provider 时属于任一目录);空值 = 继承父会话。目录仅作 advisory 可选列表,不拦截保存。 | ||
| const catReady = catalog.status === "ready"; | ||
| const providerById = new Map(catReady ? catalog.providers.map((p) => [p.provider, p]) : []); | ||
| const groupById = new Map(catReady ? catalog.groups.map((g) => [g.id, g]) : []); | ||
| const providerLabel = (id) => { | ||
| const p = providerById.get(id); | ||
| if (p?.displayName && p.displayName !== id) return p.displayName + "(" + id + ")"; | ||
| return p?.displayName || groupById.get(id)?.name || id; | ||
| }; | ||
| const legalProviders = catReady | ||
| ? catalog.providers.filter((p) => p.active && (groupById.get(p.provider)?.models.length ?? 0) > 0) | ||
| : []; | ||
| const legalProviderIds = new Set(legalProviders.map((p) => p.provider)); | ||
| const allLegalModels = []; // 未选 provider 时全量合法模型(label: provider/name 区分) | ||
| const legalModelsByProvider = new Map(); // providerId -> Set(modelId) | ||
| if (catReady) { | ||
| for (const g of catalog.groups) { | ||
| const ids = new Set(); | ||
| for (const m of g.models) { | ||
| ids.add(m.id); | ||
| allLegalModels.push({ value: m.id, label: providerLabel(g.id) + "/" + (m.name ?? m.id) }); | ||
| } | ||
| legalModelsByProvider.set(g.id, ids); | ||
| } | ||
| } | ||
| const curProvider = draftValue.subagentProvider ?? ""; | ||
| const curModel = draftValue.subagentModel ?? ""; | ||
| // 已存旧值未出现在目录时的 option 后缀:目录就绪 → 「已存值(当前目录未列出)」; | ||
| // 目录未就绪 → 按读取中/不可用提示,保证已存值始终可见可选(advisory,不拦截保存)。 | ||
| const legacySuffix = catReady | ||
| ? "(已存值,当前目录未列出)" | ||
| : (catalog.status === "loading" ? "(目录读取中…)" : "(目录不可用)"); | ||
| // provider 切换:切到合法新 provider 且现有 model 不属于其目录则清空 model(保留空=继承语义); | ||
| // 切到已存 legacy provider / 留空不强行清空,避免丢失已存 model。 | ||
| const onProviderChange = (v) => { | ||
| const next = { ...draftValue, subagentProvider: v }; | ||
| if (v !== "" && legalProviderIds.has(v) && curModel !== "" && !(legalModelsByProvider.get(v)?.has(curModel))) { | ||
| next.subagentModel = ""; | ||
| } | ||
| setDraft(next); | ||
| }; | ||
| const providerOptions = (() => { | ||
| const opts = [h("option", { key: "__blank-p", value: "" }, "(继承父会话)")]; | ||
| // 已存 provider 未在合法目录中(含目录未就绪时无法校验)→ 保留为固定 option | ||
| if (curProvider !== "" && !(catReady && legalProviderIds.has(curProvider))) { | ||
| opts.push(h("option", { key: "__cur-p", value: curProvider }, curProvider + legacySuffix)); | ||
| } | ||
| if (catReady) for (const p of legalProviders) { | ||
| opts.push(h("option", { key: p.provider, value: p.provider }, providerLabel(p.provider))); | ||
| } | ||
| return opts; | ||
| })(); | ||
| const modelOptions = (() => { | ||
| const opts = [h("option", { key: "__blank-m", value: "" }, "(继承父会话)")]; | ||
| // 已存 model 是否出现在目录中:目录就绪时按所选 provider 校验;未就绪时无法校验 → 一律保留 | ||
| const curListed = catReady && (curProvider !== "" | ||
| ? (legalModelsByProvider.get(curProvider)?.has(curModel) ?? false) | ||
| : allLegalModels.some((m) => m.value === curModel)); | ||
| if (curModel !== "" && !curListed) { | ||
| opts.push(h("option", { key: "__cur-m", value: curModel }, curModel + legacySuffix)); | ||
| } | ||
| if (catReady) { | ||
| if (curProvider !== "") { | ||
| const g = groupById.get(curProvider); | ||
| if (g) for (const m of g.models) opts.push(h("option", { key: g.id + "/" + m.id, value: m.id }, m.name ?? m.id)); | ||
| } else { | ||
| for (const m of allLegalModels) opts.push(h("option", { key: m.label, value: m.value }, m.label)); | ||
| } | ||
| } | ||
| return opts; | ||
| })(); | ||
| const save = async () => { | ||
| if (!gSettingsScope || !writable) return; | ||
| // g-133:目录仅作 advisory 可选列表,不拦截保存——留空继承、已存旧值、目录合法项均可保存。 | ||
| setSaving(true); setError(""); setSaved(""); | ||
| try { | ||
| // 一次提交,按字段逐个 set(settings scope 每字段 revision-fenced 写入)。 | ||
| await gSettingsScope.set("subagentProvider", draftValue.subagentProvider ?? ""); | ||
| await gSettingsScope.set("subagentModel", draftValue.subagentModel ?? ""); | ||
| await gSettingsScope.set("subagentPrompt", draftValue.subagentPrompt ?? ""); | ||
| setSaved("已保存到当前 profile。"); | ||
| setDraft(null); // 成功后才归位草稿(快照已更新) | ||
| } catch (e) { | ||
| // 失败保留草稿(用户可纠错重试)且不丢已保存旧值(settings 失败不落盘) | ||
| setError("保存失败:" + String(e?.message ?? e)); | ||
| } finally { | ||
| setSaving(false); | ||
| } | ||
| }; | ||
| return h("div", { style: GSS.panel }, | ||
| h("h3", { style: GSS.title }, "看板设置"), | ||
| h("p", { style: GSS.desc }, | ||
| "管理 dsh-graph 的 profile 级全局默认:子代理默认 provider/model 与补充提示词。" + | ||
| "该配置写入当前 DSH profile,跨 workspace 生效;workspace 的 project.yaml 明确配置优先。" + | ||
| "补充提示词默认为空,workspace 用 default/自定义文本/显式空值选择继承、覆盖或禁用。"), | ||
| h("p", { style: GSS.badge }, status === "ready" && !writable ? "当前为只读(Host 设置不可写)。" : ""), | ||
| h("div", { style: GSS.field }, | ||
| h("label", { style: GSS.label }, "子代理默认 provider"), | ||
| h("select", { style: GSS.select, value: curProvider, disabled: !writable, | ||
| onChange: (e) => onProviderChange(e.target.value) }, ...providerOptions), | ||
| h("span", { style: GSS.hint }, | ||
| catReady | ||
| ? "仅作缺省值:graph_start_attempt 单次指定的 provider 与 workspace project.yaml 的 executor.provider 更优先;留空继承父会话。目录仅作可选列表(advisory),已存但未列出的旧值保留为固定选项、仍可保存。" | ||
| : (catalog.status === "loading" ? "正在读取当前 Host 的合法 provider 目录…" : "无法读取当前 Host 的合法 provider 目录(llm.providers/models 不可用),目录加载失败——已存值保留可选,可先编辑补充提示词。"))), | ||
| h("div", { style: GSS.field }, | ||
| h("label", { style: GSS.label }, "子代理默认 model id"), | ||
| h("select", { style: GSS.select, value: curModel, disabled: !writable, | ||
| onChange: (e) => setField("subagentModel", e.target.value) }, ...modelOptions), | ||
| h("span", { style: GSS.hint }, | ||
| catReady | ||
| ? "按所选 provider 过滤;未选 provider 时列出全部目录模型(provider/模型名)。同理仅作缺省值,单次 model 与 project.yaml 的 executor.model 更优先;留空继承父会话。" | ||
| : (catalog.status === "loading" ? "正在读取当前 Host 的合法模型目录…" : "无法读取当前 Host 的合法模型目录(llm.providers/models 不可用),目录加载失败——已存值保留可选,可先编辑补充提示词。"))), | ||
| catReady && catalog.failures.length > 0 | ||
| ? h("span", { style: GSS.hint }, "部分 provider 的模型目录读取失败(" + catalog.failures.map((f) => f.id).join("、") + "),相关 provider 暂不可选。") | ||
| : null, | ||
| h("div", { style: GSS.field }, | ||
| h("label", { style: GSS.label }, "子代理默认补充提示词"), | ||
| h("textarea", { style: GSS.textarea, value: draftValue.subagentPrompt, disabled: !writable, | ||
| placeholder: "可选:注入到每个执行子代理 prompt 的补充内容(默认空)", | ||
| onChange: (e) => setField("subagentPrompt", e.target.value) }), | ||
| h("span", { style: GSS.hint }, "默认为空;workspace 覆盖字段 default 继承此项,自定义文本覆盖,显式空值禁用该项全局提示词。")), | ||
| h("div", { style: GSS.row }, | ||
| h("button", { style: GSS.btnPrimary, disabled: saving || !writable, onClick: save }, | ||
| saving ? "保存中…" : "保存"), | ||
| saved ? h("span", { style: GSS.noteOk }, saved) : null, | ||
| error ? h("span", { style: GSS.noteErr }, error) : null), | ||
| ); | ||
| } | ||
| // 注册「看板设置」settings.section 页(plugin.js apply 调用)。settingsScope 缺失时整页降级。 | ||
| function registerGraphSettingsSection(ctx) { | ||
| try { | ||
| // g-133:数据源捕获 —— ctx.get('connection').api(组件挂载时读 llm.providers/models 目录)。 | ||
| gConnectionApi = (() => { | ||
| try { return (ctx?.get?.("connection") ?? ctx?.connection)?.api ?? null; } catch { return null; } | ||
| })(); | ||
| bindGraphSettingsScope(ctx); | ||
| ctx.slots.inject("settings.section", () => | ||
| ctx.slots.register( | ||
| { name: "settings.section", id: "dsh-graph-settings", order: 60, label: "看板设置" }, | ||
| (props) => h(GraphSettingsSection, props), | ||
| ), | ||
| ); | ||
| } catch { /* slots 缺失或重复注册:静默(不影响看板/工具) */ } | ||
| } |
+17
-0
@@ -16,2 +16,15 @@ /** 事件流:events.jsonl 是全部状态的唯一真相源(R-02)。 */ | ||
| } | ||
| /** 毫秒精度 nowIso:boardProjection 的 generated_at 使用它(g-171), | ||
| * 与 updated_at(goal.md mtimeMs,毫秒级)同秒比较时不会被秒级截断产生负 age。 */ | ||
| export function nowIsoMs() { | ||
| const d = new Date(); | ||
| const off = -d.getTimezoneOffset(); | ||
| const sign = off >= 0 ? "+" : "-"; | ||
| const hh = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0"); | ||
| const mm = String(Math.abs(off) % 60).padStart(2, "0"); | ||
| const pad = (n) => String(n).padStart(2, "0"); | ||
| return (`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + | ||
| `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + | ||
| `.${String(d.getMilliseconds()).padStart(3, "0")}${sign}${hh}:${mm}`); | ||
| } | ||
| export function appendEvent(root, ev) { | ||
@@ -152,2 +165,6 @@ const rec = { ts: ev.ts ?? nowIso(), ...ev }; | ||
| } | ||
| // g-138:goal.postponed 回到 backlog → draft | ||
| if (ev.event === "goal.postponed") { | ||
| statuses.set(ev.goal, "draft"); | ||
| } | ||
| // g-140:goal.deleted 终态 | ||
@@ -154,0 +171,0 @@ if (ev.event === "goal.deleted") { |
+3
-0
@@ -5,2 +5,5 @@ /** 目标状态机与迁移不变式(schema/SCHEMA.md §7)。 */ | ||
| } | ||
| /** g-170:并发冲突(乐观 base_items 不一致)专用错误——REST 层映射为 409。 */ | ||
| export class GraphConflictError extends GraphError { | ||
| } | ||
| export const STATUSES = [ | ||
@@ -7,0 +10,0 @@ "draft", |
+7
-2
@@ -6,3 +6,3 @@ /** | ||
| import { GraphError } from "./machine.js"; | ||
| import { init, createGoal, setCriteria, transition, validate, rebuild, addCard, fillCard, reviewCard, startAttempt, reportStatus, moveGoal, amendGoal, deleteGoal, archiveGoal, unarchiveGoal, deleteCard, } from "./ops.js"; | ||
| import { init, createGoal, setCriteria, transition, validate, rebuild, addCard, fillCard, reviewCard, startAttempt, reportStatus, moveGoal, amendGoal, deleteGoal, archiveGoal, unarchiveGoal, deleteCard, postponeGoal, } from "./ops.js"; | ||
| function parseArgs(argv) { | ||
@@ -161,2 +161,7 @@ const args = { root: ".dsh-graph", flags: new Map() }; | ||
| } | ||
| case "postpone-goal": { | ||
| postponeGoal(args.root, need(args, "goal"), { actor, reason: flag(args, "reason") }); | ||
| console.log("ok"); | ||
| return; | ||
| } | ||
| case "validate": { | ||
@@ -186,3 +191,3 @@ const problems = validate(args.root); | ||
| default: | ||
| throw new GraphError("用法:node core/main.ts [--root DIR] <init|create-goal|set-criteria|transition|add-card|fill-card|review-card|delete-card|start-attempt|report-status|move-goal|amend-goal|archive-goal|unarchive-goal|delete-goal|validate|rebuild> [flags]"); | ||
| throw new GraphError("用法:node core/main.ts [--root DIR] <init|create-goal|set-criteria|transition|add-card|fill-card|review-card|delete-card|start-attempt|report-status|move-goal|amend-goal|archive-goal|unarchive-goal|delete-goal|postpone-goal|validate|rebuild> [flags]"); | ||
| } | ||
@@ -189,0 +194,0 @@ } |
+71
-8
@@ -5,2 +5,12 @@ /** | ||
| */ | ||
| // g-158:四种固定类型、默认 task、非法值回退 task | ||
| export const GOAL_TYPES = ["feature", "bug", "task", "improvement"]; | ||
| export const DEFAULT_GOAL_TYPE = "task"; | ||
| /** 将任意值规范化为合法类型;非法/空值回退 task(不抛错)。 */ | ||
| export function normalizeGoalType(raw) { | ||
| const s = String(raw ?? "").trim().toLowerCase(); | ||
| if (GOAL_TYPES.includes(s)) | ||
| return s; | ||
| return DEFAULT_GOAL_TYPE; | ||
| } | ||
| const DELIM = "---"; | ||
@@ -90,17 +100,70 @@ /** 解析 Markdown 文档:第一个 --- 与第二个 --- 之间为 JSON frontmatter。 */ | ||
| } | ||
| /** 判据小节是否有实质内容(去掉 HTML 注释后仍有非空行)。 */ | ||
| const CRITERIA_PLACEHOLDERS = new Set([ | ||
| "(待登记)", | ||
| "(待登记;进入 in_progress 前必须非空且已确认)", | ||
| "(待填写)", | ||
| ]); | ||
| /** 判据小节是否有实质内容(去掉 HTML 注释和模板占位行)。 */ | ||
| export function criteriaPresent(body) { | ||
| return criteriaItems(body).length > 0; | ||
| } | ||
| /** 质量判据的稳定有序 key:与客户端 checklist 使用同一规范化行文本。 */ | ||
| export function criteriaItems(body) { | ||
| const t = sectionText(body, "质量判据"); | ||
| if (t === null) | ||
| return false; | ||
| return []; | ||
| const stripped = t.replace(/<!--[\s\S]*?-->/g, ""); | ||
| return stripped.split("\n").some((l) => l.trim() !== ""); | ||
| return stripped.split("\n") | ||
| .map((l) => l.trim()) | ||
| .filter((l) => l !== "" && !CRITERIA_PLACEHOLDERS.has(l)); | ||
| } | ||
| /** 判据小节的实质内容行数(去掉 HTML 注释后非空行;≥1 即视为已登记判据)。g-77647351 看板用。 */ | ||
| export function countCriteria(body) { | ||
| const t = sectionText(body, "质量判据"); | ||
| if (t === null) | ||
| return 0; | ||
| const stripped = t.replace(/<!--[\s\S]*?-->/g, ""); | ||
| return stripped.split("\n").filter((l) => l.trim() !== "").length; | ||
| return criteriaItems(body).length; | ||
| } | ||
| /** | ||
| * g-170:重写质量判据小节内容(不含 `## 质量判据` 标题行,与 sectionText 同构)。 | ||
| * 只替换「判据项行」(与 criteriaItems 同源定义:非空、非 HTML 注释、非模板占位行), | ||
| * 其余内容——HTML 注释、空行——原样保留;有实质判据时模板占位行一并移除。 | ||
| * newItems 为空时仅删除判据项行(占位行保留,草稿清空后仍提示登记)。 | ||
| * 返回新小节内容。 | ||
| */ | ||
| export function rebuildCriteriaSection(raw, newItems) { | ||
| // 先屏蔽 HTML 注释(可跨行),避免注释内形似判据的行被误判 | ||
| const comments = []; | ||
| let cIdx = 0; | ||
| const masked = raw.replace(/<!--[\s\S]*?-->/g, (m) => { | ||
| comments.push(m); | ||
| return `\u0000DG_COMMENT_${cIdx++}\u0000`; | ||
| }); | ||
| const lines = masked.split("\n"); | ||
| const isCommentSentinel = (t) => /^\u0000DG_COMMENT_\d+\u0000$/.test(t); | ||
| const out = []; | ||
| let inserted = false; | ||
| for (const line of lines) { | ||
| const trimmed = line.trim(); | ||
| const sentinel = isCommentSentinel(trimmed); | ||
| // 判据项行:非注释、非空、非模板占位;有实质判据时占位行也一并删除(脚手架不再需要) | ||
| const droppable = !sentinel && trimmed !== "" && | ||
| (newItems.length > 0 || !CRITERIA_PLACEHOLDERS.has(trimmed)); | ||
| if (droppable) { | ||
| if (!inserted && newItems.length > 0) { | ||
| newItems.forEach((it, i) => out.push(`${i + 1}. ${it}`)); | ||
| inserted = true; | ||
| } | ||
| continue; // 判据项行(及有实质判据时的占位行)不保留 | ||
| } | ||
| out.push(line); | ||
| } | ||
| if (!inserted && newItems.length > 0) { | ||
| // 没有既有判据项行(空节/纯注释/纯占位):文末补空行后插入新列表 | ||
| if (out.length > 0 && out[out.length - 1] !== "") | ||
| out.push(""); | ||
| newItems.forEach((it, i) => out.push(`${i + 1}. ${it}`)); | ||
| } | ||
| let result = out.join("\n"); | ||
| for (let i = 0; i < comments.length; i++) { | ||
| result = result.replace(`\u0000DG_COMMENT_${i}\u0000`, comments[i]); | ||
| } | ||
| return result; | ||
| } |
+14
-5
@@ -439,5 +439,6 @@ /** 版本泳道管理:创建/重命名/删除版本泳道(g-134)+ 发布 guard(g-135)。 */ | ||
| const VERSION_STATUS_ALLOWLIST = ["planning", "active"]; | ||
| /** 设置版本状态(working → active / active → planning 等)。 | ||
| /** 设置版本状态(working -> active / active -> planning 等)。 | ||
| * released 只能经 releaseVersion 路径,此函数拒绝。 | ||
| * 事件先行:先写 version.status_changed 事件,再更新 version.md。 */ | ||
| * 事件先行:先写 version.status_changed 事件,再更新 version.md。 | ||
| * released->active 例外:需要 confirmed=true + human/supervisor actor。 */ | ||
| export function setVersionStatus(root, opts) { | ||
@@ -452,3 +453,3 @@ const slug = opts.slug.trim(); | ||
| } | ||
| // allowlist:只接受合法的非 released 状态 | ||
| // allowlist:只接受合法的非 released 状态(released→active 例外路径绕过 allowlist) | ||
| if (!VERSION_STATUS_ALLOWLIST.includes(newStatus)) { | ||
@@ -464,5 +465,13 @@ throw new GraphError(`非法版本状态:${newStatus}(合法值:${VERSION_STATUS_ALLOWLIST.join(", ")})`); | ||
| return; // 幂等 | ||
| // released 是终态——不允许回退到 planning/active | ||
| // released→active 例外路径:需要确认标志和可信 actor | ||
| if (oldStatus === "released") { | ||
| throw new GraphError("版本已 released,不能回退到其他状态——released 是终态"); | ||
| if (newStatus !== "active") { | ||
| throw new GraphError("版本已 released,只能恢复为 active,不能回退到其他状态"); | ||
| } | ||
| if (!opts.confirmed) { | ||
| throw new GraphError("恢复 released 版本需要明确确认(confirmed=true)"); | ||
| } | ||
| if (!opts.actor.startsWith("human:") && !opts.actor.startsWith("supervisor:")) { | ||
| throw new GraphError(`恢复 released 版本仅限负责人确认(actor=${opts.actor}),执行子代理不能操作`); | ||
| } | ||
| } | ||
@@ -469,0 +478,0 @@ // R-02:事件先行 |
| if (blocked) { | ||
| return h("div", { style: { ...S.statusLine, color: "#d66" } }, "⛔ " + text); | ||
| return h("div", { style: { ...S.statusLine, color: "var(--dsw-alias-state-error-primary, #d66)" } }, "⛔ " + text); | ||
| } | ||
@@ -215,3 +215,3 @@ const animClass = running ? "dg-running-flow" : ""; | ||
| ? h("div", { style: { display: "flex", flexDirection: "column", gap: 6 } }, | ||
| h("div", { style: { ...S.meta, color: "#d66", fontSize: 12 } }, | ||
| h("div", { style: { ...S.meta, color: "var(--dsw-alias-state-error-primary, #d66)", fontSize: 12 } }, | ||
| `⚠️ 确认删除卡片「${card.title}」?请输入卡片 id 确认:`), | ||
@@ -228,4 +228,4 @@ h("div", { style: { ...S.meta, fontSize: 11, opacity: 0.7 } }, | ||
| h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "2px 8px", background: deleteIdInput.trim() === card.id ? "rgba(214,102,102,.3)" : undefined }, | ||
| className: "dg-btn", | ||
| style: { ...S.btnDanger, fontSize: 11, padding: "2px 8px" }, | ||
| className: "dg-btn-danger", | ||
| disabled: deleteIdInput.trim() !== card.id, | ||
@@ -264,4 +264,4 @@ onClick: async () => { | ||
| h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "2px 8px", background: "rgba(214,102,102,.2)" }, | ||
| className: "dg-btn", | ||
| style: { ...S.btnDanger, fontSize: 11, padding: "2px 8px" }, | ||
| className: "dg-btn-danger", | ||
| title: "删除此卡片(需输入卡片 id 确认)", | ||
@@ -285,3 +285,3 @@ onClick: () => { setDeleteConfirm(true); setDeleteIdInput(""); setDeleteNote(null); }, | ||
| null, | ||
| h("div", { style: { ...S.overlay, background: "rgba(0,0,0,.35)" }, onClick: props.onClose }), | ||
| h("div", { style: { ...S.overlay, background: "var(--dsw-alias-bg-mask-1, rgba(0,0,0,.35))" }, onClick: props.onClose }), | ||
| h("div", { style: S.drawer, onClick: (e) => e.stopPropagation() }, | ||
@@ -288,0 +288,0 @@ h("span", { style: S.close, onClick: props.onClose }, "✕"), |
+145
-11
@@ -15,2 +15,57 @@ const [open, setOpen] = React.useState(false); | ||
| const CRITERIA_PLACEHOLDERS = new Set([ | ||
| "(待登记)", | ||
| "(待登记;进入 in_progress 前必须非空且已确认)", | ||
| "(待填写)", | ||
| ]); | ||
| // g-163:按当前判据有序 key 渲染方块,不用完成数量推断前缀。 | ||
| function CriteriaProgress(props) { | ||
| // criteria_count 是零态的权威信号;即使旧 payload 误带一条占位项也不显示方块。 | ||
| const reportedCount = props.count ?? props.criteria_count ?? props.criteriaCount; | ||
| if (reportedCount != null && Number(reportedCount) === 0) return null; | ||
| // BoardGoal 的 snake_case 字段是唯一正式契约;兼容旧/第三方 payload | ||
| // 的 camelCase 别名,避免字段契约不一致时整行被误判为 0 条。 | ||
| const rawItems = props.items ?? props.criteria_items ?? props.criteriaItems; | ||
| const keys = Array.isArray(rawItems) | ||
| ? [...new Set(rawItems.map(String).map((key) => key.trim()).filter((key) => key && !CRITERIA_PLACEHOLDERS.has(key)))] | ||
| : []; | ||
| const storeKey = "dsh-graph.crit." + props.goalId; | ||
| const readChecked = () => { | ||
| try { const value = JSON.parse(localStorage.getItem(storeKey) ?? "[]"); return Array.isArray(value) ? value : []; } | ||
| catch { return []; } | ||
| }; | ||
| const [checked, setChecked] = React.useState(readChecked); | ||
| React.useEffect(() => { | ||
| const refresh = () => setChecked(readChecked()); | ||
| window.addEventListener("storage", refresh); | ||
| window.addEventListener("dsh-graph.criteria-changed", refresh); | ||
| return () => { | ||
| window.removeEventListener("storage", refresh); | ||
| window.removeEventListener("dsh-graph.criteria-changed", refresh); | ||
| }; | ||
| }, [storeKey]); | ||
| if (!keys.length) return null; | ||
| // 仅精确匹配当前有序 key;未知、过期及重复 checked 自然不会计数。 | ||
| const checkedSet = new Set(Array.isArray(checked) ? checked.map(String) : []); | ||
| const done = keys.filter((key) => checkedSet.has(key)).length; | ||
| const total = keys.length; | ||
| const label = `质量判据:已完成 ${done}/${total}`; | ||
| // emoji 是双宽字形:每格固定窄宽并 scaleX 收窄,最多保留 10 格,避免长列表撑宽卡片。 | ||
| const shown = keys.slice(0, 10); | ||
| const blocks = shown.map((key) => h("span", { | ||
| key, className: "dg-criteria-block", "aria-hidden": "true", | ||
| style: { display: "inline-block", width: 5, transform: "scaleX(.2)", transformOrigin: "right center" }, | ||
| }, checkedSet.has(key) ? "🟩" : "◽")); | ||
| if (total > shown.length) { | ||
| blocks.push(h("span", { key: "count", style: { letterSpacing: "normal", marginLeft: -2 } }, `${done}/${total}`)); | ||
| } | ||
| return h("span", { | ||
| className: "dg-criteria-progress", role: "img", title: label, "aria-label": label, | ||
| style: { display: "inline-block", maxWidth: "100%", height: 16, lineHeight: "16px", | ||
| whiteSpace: "nowrap", overflow: "hidden", verticalAlign: "middle", fontSize: 11, | ||
| letterSpacing: "-3px", marginLeft: 0, paddingRight: 2 }, | ||
| }, blocks); | ||
| } | ||
| // 目标卡:只保留关键信息(标题/状态/状态行/徽标/依赖),子卡片扼要列出、点击开抽屉 | ||
@@ -32,2 +87,5 @@ // 依赖徽章状态化(发现#23):已交付依赖显示「依赖满足」,仅未交付依赖显示「等待」并触发琥珀边框 | ||
| const hasDep = pendingDeps.length > 0; | ||
| // g-158:类型色覆盖默认左侧色条(blocked/dep 语义用状态文本/标记表达,左栏始终类型色) | ||
| const tColor = goalTypeColor(g.type); | ||
| const borderColor = tColor; | ||
| const style = { | ||
@@ -37,5 +95,46 @@ ...S.goalCard, | ||
| ...(blocked ? S.blockedCard : {}), | ||
| /* g-168 polish border */ borderLeft: `5px solid ${borderColor}`, | ||
| }; | ||
| const badges = []; | ||
| if (g.reviewer === "human") badges.push("👤人审"); | ||
| // g-168:PM 润色仅通过透明遮罩覆盖卡片边框,卡片本体保持可见。 | ||
| // g-171:更新强调浮层(left:-5px 覆盖 5px 类型色边框)同样需要卡片定位锚点。 | ||
| const cardStyle = g._polishActive ? { ...style, position: "relative", animation: "none" } : g._updateEmphasis ? { ...style, position: "relative" } : style; | ||
| // g-171:更新强调——左侧类型色边框上的金属光泽浮层(10 秒生命周期内循环扫光并淡出)。 | ||
| // 折叠/展开两条路径都挂载同一浮层;pointer-events:none + aria-hidden,不改变布局/点击/拖拽。 | ||
| // 实现采用内联 animation(不依赖 .dg-update-sheen class 的 opacity),避免 | ||
| // prefers-reduced-motion 把浮层整体 opacity:0 隐藏——"哪个目标被更新"是功能性信息, | ||
| // 降级为静态可见而非完全消失(g-171 回退修复:用户系统开减少动态效果导致动画不可见)。 | ||
| const updateSheen = g._updateEmphasis ? h("div", { | ||
| key: "update-sheen-" + g._updateEmphasis.token, | ||
| className: "dg-update-sheen", | ||
| "aria-hidden": "true", | ||
| style: { animation: "dg-update-fade " + g._updateEmphasis.remaining + "ms linear forwards" }, | ||
| }, h("div", { className: "dg-update-sheen-bar" })) : null; | ||
| const polishOverlay = g._polishActive ? h("div", { | ||
| key: "polish-overlay", "aria-hidden": "true", | ||
| style: { | ||
| position: "absolute", inset: 0, pointerEvents: "none", borderRadius: 6, | ||
| border: "2px solid rgba(76,141,255,.82)", | ||
| background: "linear-gradient(90deg, rgba(76,141,255,.08), rgba(58,166,117,.22), rgba(76,141,255,.08))", | ||
| backgroundSize: "200% 100%", boxShadow: "0 0 0 2px rgba(76,141,255,.28), 0 0 12px rgba(58,166,117,.26)", | ||
| animation: "dg-polish-flow 2.5s ease 1 forwards", | ||
| }, | ||
| }) : null; | ||
| const badges = []; | ||
| // g-158:类型标记 badge(F/B/T/I + tooltip)——标题左侧,颜色与左栏/弹窗同源 | ||
| const aType = normalizeGoalType(g.type); | ||
| const tBadge = h("span", { | ||
| key: "type-badge", | ||
| style: { | ||
| display: "inline-block", width: 16, height: 16, lineHeight: "16px", | ||
| textAlign: "center", borderRadius: 3, fontSize: 10, fontWeight: 700, | ||
| background: goalTypeColor(aType), color: "#fff", | ||
| verticalAlign: "middle", marginRight: 2, | ||
| }, | ||
| title: GOAL_TYPE_LABELS[aType] ?? aType, | ||
| }, GOAL_TYPE_ABBREV[aType] ?? aType[0]?.toUpperCase()); | ||
| if (g.reviewer === "human") badges.push("👤"); | ||
| if (g.reviewer === "ai") badges.push("🤖AI审"); | ||
@@ -56,3 +155,4 @@ if (g.pk_lanes > 1) badges.push("PK×" + g.pk_lanes); | ||
| chevron, | ||
| h("span", { style: { ...S.title, display: "inline", verticalAlign: "middle" } }, `🎯 ${g.title}`)); | ||
| tBadge, | ||
| h("span", { style: { ...S.title, display: "inline", verticalAlign: "middle" } }, g.title)); | ||
| // g-77647351:拖放 class 合并 | ||
@@ -65,2 +165,3 @@ const dragClass = [ | ||
| drag?.marker === "after" ? " dg-drop-after" : "", | ||
| g._polishActive ? " dg-running-flow" : "", | ||
| ].filter(Boolean).join(" "); | ||
@@ -73,2 +174,21 @@ // g-77647351:拖放事件 props | ||
| e.dataTransfer.setData("text/plain", g.id); | ||
| // g-173 follow-up:backlog 卡片默认拖拽虚影会渲染整个 .dg-backlog-flat 行 | ||
| // (flex-wrap 容器内多卡同行)。显式把当前卡片克隆节点作为 setDragImage, | ||
| // 虚影只显示当前这一张卡;克隆节点置于视口外并同步宽度,避免布局塌缩。 | ||
| try { | ||
| const src = e.currentTarget; | ||
| const ghost = src.cloneNode(true); | ||
| ghost.classList.remove("dg-dragging", "dg-running-flow", "dg-drop-before", "dg-drop-after"); | ||
| const rect = src.getBoundingClientRect(); | ||
| ghost.style.position = "fixed"; | ||
| ghost.style.left = "-9999px"; | ||
| ghost.style.top = "0"; | ||
| ghost.style.width = rect.width + "px"; | ||
| ghost.style.margin = "0"; | ||
| ghost.style.pointerEvents = "none"; | ||
| ghost.style.zIndex = "99999"; | ||
| document.body.appendChild(ghost); | ||
| e.dataTransfer.setDragImage(ghost, 16, 10); | ||
| setTimeout(() => { if (ghost.parentNode) ghost.parentNode.removeChild(ghost); }, 0); | ||
| } catch { /* setDragImage 不可用时保持浏览器默认虚影 */ } | ||
| drag.start(); | ||
@@ -100,7 +220,14 @@ }, | ||
| "div", | ||
| { key: g.id, style, className: dragClass, | ||
| { key: g.id, style: cardStyle, className: dragClass, | ||
| title: "点击打开详情", onClick: () => onOpen(g.id), ...dragProps, ...dropProps }, | ||
| titleRow, | ||
| polishOverlay, | ||
| updateSheen, | ||
| titleRow, | ||
| h("div", { style: S.meta }, | ||
| `${g.id} | ${STATUS_LABEL[g.status] ?? g.status}${badges.length ? " | " + badges.join(" ") : ""}`), | ||
| `${g.id} | ${STATUS_LABEL[g.status] ?? g.status}${badges.length ? " | " + badges.join(" ") : ""}`, | ||
| h(CriteriaProgress, { | ||
| goalId: g.id, | ||
| items: g.criteria_items ?? g.criteriaItems, | ||
| count: g.criteria_count ?? g.criteriaCount, | ||
| })), | ||
| ); | ||
@@ -110,16 +237,23 @@ } | ||
| "div", | ||
| { key: g.id, style, className: dragClass, | ||
| { key: g.id, style: cardStyle, className: dragClass, | ||
| title: "点击打开详情", onClick: () => onOpen(g.id), ...dragProps, ...dropProps }, | ||
| titleRow, | ||
| polishOverlay, | ||
| updateSheen, | ||
| titleRow, | ||
| h("div", { style: S.meta }, | ||
| `${g.id} | ${STATUS_LABEL[g.status] ?? g.status}${badges.length ? " | " + badges.join(" ") : ""}`, | ||
| h(CriteriaProgress, { | ||
| goalId: g.id, | ||
| items: g.criteria_items ?? g.criteriaItems, | ||
| count: g.criteria_count ?? g.criteriaCount, | ||
| }), | ||
| sessionLinkBtn(g.attempt_parent_session_id, g.attempt_child_id, "↗ 转到对话")), | ||
| hasDep | ||
| ? h("div", { style: { ...S.meta, color: "#e0a53a" } }, `⛓ 等待 ${pendingDeps.join("、")} 交付`) | ||
| ? h("div", { style: { ...S.meta, color: "var(--dsw-alias-state-warn-label, #e0a53a)" } }, `⛓ 等待 ${pendingDeps.join("、")} 交付`) | ||
| : null, | ||
| metDeps.length | ||
| ? h("div", { style: { ...S.meta, color: "#3aa675" } }, `✅ 依赖满足:${metDeps.join("、")} 已交付`) | ||
| ? h("div", { style: { ...S.meta, color: "var(--dsw-alias-label-primary, #3aa675)" } }, `✅ 依赖满足:${metDeps.join("、")} 已交付`) | ||
| : null, | ||
| blocked && g.blocked_reason | ||
| ? h("div", { style: { ...S.statusLine, color: "#d66" } }, "⛔ " + g.blocked_reason) | ||
| ? h("div", { style: { ...S.statusLine, color: "var(--dsw-alias-state-error-primary, #d66)" } }, "⛔ " + g.blocked_reason) | ||
| : null, | ||
@@ -126,0 +260,0 @@ // g-a92e1406:执行会话内嵌实时条——status_line 摘要并入状态小窗 |
+117
-6
@@ -0,1 +1,4 @@ | ||
| // g-174:标题栏显示的插件版本(快速通道:硬编码当前包版本,不做版本号自动同步机制) | ||
| const PLUGIN_VERSION = "0.7.1"; | ||
| const STAGES = [ | ||
@@ -15,4 +18,19 @@ { key: "describe", label: "描述", statuses: ["draft", "planning"] }, | ||
| // g-158:目标类型视觉配置——颜色、缩写、完整名(四者共用同一语义色) | ||
| const GOAL_TYPES = ["feature", "bug", "task", "improvement"]; | ||
| const GOAL_TYPE_COLORS = { feature: "#4c8dff", bug: "#d66", task: "#8a8a8a", improvement: "#3aa675" }; | ||
| const GOAL_TYPE_ABBREV = { feature: "F", bug: "B", task: "T", improvement: "I" }; | ||
| const GOAL_TYPE_LABELS = { feature: "feature", bug: "bug", task: "task", improvement: "improvement" }; | ||
| // g-158:规范化类型——非法值安全回退 task | ||
| function normalizeGoalType(raw) { | ||
| return GOAL_TYPES.includes(raw) ? raw : "task"; | ||
| } | ||
| // g-158:获取类型色——回退 task 色 | ||
| function goalTypeColor(type) { | ||
| return GOAL_TYPE_COLORS[normalizeGoalType(type)] ?? GOAL_TYPE_COLORS.task; | ||
| } | ||
| const EVENT_LABEL = { | ||
| "goal.created": "创建目标", "goal.planned": "完成规划", "criteria.confirmed": "确认判据", | ||
| "criteria.updated": "更新判据", // g-170 | ||
| "goal.transition": null, "attempt.started": "派发执行", "attempt.status_reported": null, | ||
@@ -26,2 +44,3 @@ "completion.claimed": "声明完成", "review.passed": "评审通过", "review.failed": "评审未通过", | ||
| "goal.renamed": "重命名目标", | ||
| "goal.type_changed": "变更类型", // g-158 | ||
| "goal.directive_set": "设置最近指令", "goal.comment_added": "添加评论", | ||
@@ -35,4 +54,6 @@ "attempt.handoff.confirmed": "确认返工 handoff", "attempt.handoff.superseded": "覆盖旧 handoff", | ||
| "goal.transition", "goal.amended", "scope.note", "criteria.confirmed", | ||
| "criteria.updated", // g-170 | ||
| "completion.claimed", "review.passed", "review.failed", "attempt.started", | ||
| "goal.moved", "goal.created", "attempt.status_reported", "goal.renamed", | ||
| "goal.type_changed", // g-158 | ||
| "goal.directive_set", "goal.comment_added", | ||
@@ -51,2 +72,3 @@ "attempt.handoff.confirmed", "attempt.handoff.superseded", | ||
| else if (e.event === "goal.renamed") what = `重命名:${d.old_title ?? ""} → ${d.new_title ?? ""}`; | ||
| else if (e.event === "goal.type_changed") what = `变更类型:${GOAL_TYPE_LABELS[d.old_type] ?? d.old_type} → ${GOAL_TYPE_LABELS[d.new_type] ?? d.new_type}`; // g-158 | ||
| else if (e.event === "scope.note") what = `补充:${d.note ?? ""}`; | ||
@@ -82,4 +104,48 @@ else if (e.event === "goal.directive_set") what = `设置指令:${(d.directive ?? "").slice(0, 80)}${(d.directive ?? "").length > 80 ? "…" : ""}`; | ||
| .dg-collapsed:hover { background: rgba(128,128,128,.14); } | ||
| .dg-btn { transition: filter .12s ease; } | ||
| .dg-btn:hover { filter: brightness(1.25); } | ||
| .dg-deliver-collapsed:hover { background: rgba(128,128,128,.14); } | ||
| .dg-blocked-collapsed:hover { background: rgba(128,128,128,.14); } | ||
| .dg-btn { transition: background .12s ease, border-color .12s ease, filter .12s ease; } | ||
| /* g-176:hover 不再用 brightness(1.20)(浅色主题下会洗白),改用主题化背景加深 */ | ||
| .dg-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.25)); } | ||
| .dg-btn:active { filter: brightness(0.95); } | ||
| .dg-btn:disabled { opacity: 0.45; cursor: default; filter: none; } | ||
| /* g-162:普通泳道内容底部居中的扁平折叠入口;released 不使用此控件 */ | ||
| .dg-lane-collapse { | ||
| position: absolute; left: 50%; right: auto; bottom: 2px; transform: translateX(-50%); width: 32px; height: 9px; padding: 0; border: 1px solid rgba(128,128,128,.42); | ||
| border-radius: 2px; background: rgba(128,128,128,.16); cursor: pointer; | ||
| display: flex; align-items: center; justify-content: center; | ||
| } | ||
| .dg-lane-collapse { transition: transform .14s ease, background .14s ease, filter .14s ease; } | ||
| /* g-176:hover 去掉 brightness(1.15)(浅色主题下会洗白),保留背景加深 */ | ||
| .dg-lane-collapse:hover { background: rgba(128,128,128,.28); transform: translateX(-50%) translateY(-2px); } | ||
| .dg-lane-collapse:active { transform: translateX(-50%) translateY(0); } | ||
| .dg-lane-collapse-triangle { | ||
| width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; | ||
| border-bottom: 5px solid var(--dsw-alias-label-tertiary, rgba(220,220,220,.82)); | ||
| } | ||
| /* g-153:主要操作按钮 hover/active/disabled */ | ||
| .dg-btn-primary { transition: background .12s ease, border-color .12s ease, filter .12s ease; } | ||
| .dg-btn-primary:hover { background: rgba(76,141,255,.30); border-color: rgba(76,141,255,.55); } | ||
| .dg-btn-primary:active { background: rgba(76,141,255,.40); } | ||
| .dg-btn-primary:disabled { opacity: 0.45; cursor: default; } | ||
| /* g-153:危险操作按钮 hover/active/disabled */ | ||
| .dg-btn-danger { transition: background .12s ease, border-color .12s ease, filter .12s ease; } | ||
| .dg-btn-danger:hover { background: rgba(214,102,102,.30); border-color: rgba(214,102,102,.50); } | ||
| .dg-btn-danger:active { background: rgba(214,102,102,.42); } | ||
| .dg-btn-danger:disabled { opacity: 0.45; cursor: default; } | ||
| /* g-153:接受/确认操作按钮 hover/active/disabled */ | ||
| .dg-btn-accept { transition: background .12s ease, border-color .12s ease, filter .12s ease; } | ||
| .dg-btn-accept:hover { background: rgba(58,166,117,.30); border-color: rgba(58,166,117,.55); } | ||
| .dg-btn-accept:active { background: rgba(58,166,117,.42); } | ||
| .dg-btn-accept:disabled { opacity: 0.45; cursor: default; } | ||
| /* g-153:下拉菜单/选择控件——g-176:改 DSH 主题变量并保留暗色 fallback */ | ||
| .dg-select { | ||
| font-size: 12px; padding: 3px 8px; cursor: pointer; | ||
| background: var(--dsw-alias-bg-layer-2, rgba(30,31,36,.92)); color: var(--dsw-alias-label-primary, #e6e6e6); | ||
| border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.35)); border-radius: 4px; | ||
| transition: border-color .12s ease; | ||
| } | ||
| .dg-select:hover { border-color: rgba(128,128,128,.55); } | ||
| .dg-select:focus { border-color: rgba(76,141,255,.55); outline: none; } | ||
| .dg-select option { background: var(--dsw-alias-bg-layer-3, #222328); color: var(--dsw-alias-label-primary, #e6e6e6); } | ||
| /* g-125 fb3:三角展开/收起按钮——暗底纹、窄宽度,不占整列、不像播放按钮 */ | ||
@@ -107,7 +173,52 @@ .dg-chevron { | ||
| @keyframes dg-flow-bg { | ||
| 0% { background-position: 0% 50%; } | ||
| 0% { background-position: 0% 50%; } | ||
| 50% { background-position: 100% 50%; } | ||
| 100% { background-position: 0% 50%; } | ||
| } | ||
| @keyframes dg-pulse { | ||
| 100% { background-position: 0% 50%; } | ||
| } | ||
| @keyframes dg-polish-flow { | ||
| 0%, 80% { background-position: 0% 50%; opacity: 1; } | ||
| 100% { background-position: 100% 50%; opacity: 0; } | ||
| } | ||
| /* g-171:更新强调动画——左侧类型色边框金属光泽浮层(10 秒生命周期内循环扫光并淡出) */ | ||
| .dg-update-sheen { | ||
| position: absolute; left: -5px; top: 0; bottom: 0; width: 5px; | ||
| overflow: hidden; pointer-events: none; border-radius: 6px 0 0 6px; | ||
| /* 时长由内联 animationDuration(剩余毫秒)覆盖;forwards 结束停留不可见 */ | ||
| animation: dg-update-fade 10s linear forwards; | ||
| } | ||
| .dg-update-sheen-bar { | ||
| position: absolute; left: 0; right: 0; top: 0; height: 40%; | ||
| background: linear-gradient(180deg, rgba(255,255,255,0), rgba(255,255,255,.92), rgba(205,212,224,.55), rgba(255,255,255,0)); | ||
| animation: dg-update-sheen-sweep 1.6s linear infinite; | ||
| } | ||
| @keyframes dg-update-sheen-sweep { | ||
| 0% { transform: translateY(-130%); } | ||
| 100% { transform: translateY(230%); } | ||
| } | ||
| @keyframes dg-update-fade { | ||
| 0% { opacity: 1; } | ||
| 100% { opacity: 0; } | ||
| } | ||
| @media (prefers-reduced-motion: reduce) { | ||
| .dg-update-sheen, .dg-update-sheen-bar { animation: none !important; } | ||
| /* g-171 回退修复:reduced-motion 下不隐藏浮层(原 opacity:0 导致用户系统开 | ||
| "减少动态效果"时动画完全不可见)。降级为静态斜向金属光泽高光——135° 对角线 | ||
| 渐变直接在浮层上画"一宽一细两条高光"(细亮线 + 宽柔光带,中间暗间隙分隔, | ||
| 两侧羽化)。不用旋转子条(stop 沿 5px 水平方向分布像素太少,羽化无余地); | ||
| 135° 渐变轴沿浮层对角线(长度≈卡片高度),stop 百分比有足够像素跨度。 | ||
| 不用纯色整条填充(避免误判为类型色改变)。 */ | ||
| .dg-update-sheen { | ||
| background: linear-gradient(135deg, | ||
| rgba(255,255,255,0) 0%, | ||
| rgba(255,255,255,0) 35%, | ||
| rgba(255,255,255,.95) 45%, | ||
| rgba(255,255,255,0) 52%, | ||
| rgba(255,255,255,.35) 62%, | ||
| rgba(255,255,255,.6) 72%, | ||
| rgba(255,255,255,0) 85%, | ||
| rgba(255,255,255,0) 100%); | ||
| } | ||
| .dg-update-sheen-bar { display: none; } | ||
| } | ||
| @keyframes dg-pulse { | ||
| 0%, 100% { opacity: 1; transform: scale(1); } | ||
@@ -114,0 +225,0 @@ 50% { opacity: 0.45; transform: scale(1.25); } |
@@ -8,2 +8,4 @@ function BackwardReasonPrompt(props) { | ||
| const { session } = useBoundSession(parentId, childId); | ||
| // g-181:overlay backdrop 误关保护(内容起点后释放到 backdrop 的合成 click 吞掉) | ||
| const backdropGuard = useBackdropClose(onCancel); | ||
| const sendReason = async () => { | ||
@@ -26,3 +28,3 @@ if (!reason.trim()) { onConfirm(""); return; } | ||
| }; | ||
| return h("div", { style: S.overlay, onClick: onCancel }, | ||
| return h("div", { style: S.overlay, ...backdropGuard }, | ||
| h("div", { style: { ...S.modal, maxWidth: 480 }, onClick: (e) => e.stopPropagation() }, | ||
@@ -46,3 +48,3 @@ h("span", { style: S.close, onClick: onCancel }, "✕"), | ||
| h("button", { | ||
| style: { ...S.btn, padding: "4px 14px", fontSize: 13 }, className: "dg-btn", | ||
| style: { ...S.btnPrimary, padding: "4px 14px", fontSize: 13 }, className: "dg-btn-primary", | ||
| disabled: sending, onClick: sendReason, | ||
@@ -73,2 +75,5 @@ }, sending ? "发送中…" : (sent ? "✅ 已发送" : "确认回退")), | ||
| // g-181:overlay backdrop 误关保护(内容起点后释放到 backdrop 的合成 click 吞掉) | ||
| const backdropGuard = useBackdropClose(onCancel); | ||
| const startExec = async () => { | ||
@@ -142,3 +147,3 @@ if (!supervisorSession) { | ||
| return h("div", { style: S.overlay, onClick: onCancel }, | ||
| return h("div", { style: S.overlay, ...backdropGuard }, | ||
| h("div", { style: { ...S.modal, maxWidth: 480 }, onClick: (e) => e.stopPropagation() }, | ||
@@ -165,3 +170,3 @@ h("span", { style: S.close, onClick: onCancel }, "✕"), | ||
| !hasCriteria | ||
| ? h("div", { style: { ...S.meta, color: "#e0a53a", marginBottom: 4 } }, | ||
| ? h("div", { style: { ...S.meta, color: "var(--dsw-alias-state-warn-label, #e0a53a)", marginBottom: 4 } }, | ||
| "⚠️ 质量判据尚未登记——将以授权模式强制迁移到执行列。") | ||
@@ -171,3 +176,3 @@ : null, | ||
| h("button", { | ||
| style: { ...S.btn, padding: "4px 14px", fontSize: 13 }, className: "dg-btn", | ||
| style: { ...S.btnPrimary, padding: "4px 14px", fontSize: 13 }, className: "dg-btn-primary", | ||
| disabled: loading, onClick: startExec, | ||
@@ -187,2 +192,4 @@ }, loading ? "处理中…" : (hasChild ? "🔄 重新执行" : "🚀 确认执行")), | ||
| const { goalId, goalTitle, supervisorSession, onConfirm, onCancel } = props; | ||
| // g-181:overlay backdrop 误关保护(内容起点后释放到 backdrop 的合成 click 吞掉) | ||
| const backdropGuard = useBackdropClose(onCancel); | ||
| const promptText = `【交付通知】目标「${goalTitle ?? goalId}」(${goalId})即将标记为已交付。请进行最终复核:代码合并、文档更新等交付工作。`; | ||
@@ -202,3 +209,3 @@ const jumpToSupervisor = async () => { | ||
| }; | ||
| return h("div", { style: S.overlay, onClick: onCancel }, | ||
| return h("div", { style: S.overlay, ...backdropGuard }, | ||
| h("div", { style: { ...S.modal, maxWidth: 520 }, onClick: (e) => e.stopPropagation() }, | ||
@@ -214,3 +221,3 @@ h("span", { style: S.close, onClick: onCancel }, "✕"), | ||
| h("br"), | ||
| h("span", { style: { color: "#e0a53a" } }, | ||
| h("span", { style: { color: "var(--dsw-alias-state-warn-label, #e0a53a)" } }, | ||
| "⚠️ 标记为「已交付」后需主管评审通过才能正式完成。")), | ||
@@ -225,3 +232,3 @@ h("div", { style: { display: "flex", gap: 8, marginTop: 4, flexWrap: "wrap" } }, | ||
| h("button", { | ||
| style: { ...S.btn, padding: "4px 14px", fontSize: 13 }, className: "dg-btn", | ||
| style: { ...S.btnAccept, padding: "4px 14px", fontSize: 13 }, className: "dg-btn-accept", | ||
| onClick: () => onConfirm(), | ||
@@ -228,0 +235,0 @@ }, "📦 确认交付"), |
@@ -6,5 +6,6 @@ } | ||
| function CriteriaChecklist(props) { | ||
| const items = String(props.crit ?? "").split("\n") | ||
| // 与 core/model.ts criteriaItems 同源:先移除跨行 HTML 注释,再按行 trim。 | ||
| const items = String(props.crit ?? "").replace(/<!--[\s\S]*?-->/g, "").split("\n") | ||
| .map((l) => l.trim()) | ||
| .filter((l) => l && !l.startsWith("<!--")); | ||
| .filter((l) => l); | ||
| const storeKey = "dsh-graph.crit." + props.goalId; | ||
@@ -24,2 +25,3 @@ const readChecked = () => { | ||
| try { localStorage.setItem(storeKey, JSON.stringify(next)); } catch {} | ||
| window.dispatchEvent(new Event("dsh-graph.criteria-changed")); | ||
| }; | ||
@@ -73,3 +75,3 @@ const sendFb = async (criterion) => { | ||
| "position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:99999;" + | ||
| "background:rgba(30,30,30,.94);color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;" + | ||
| "background:var(--dsw-alias-toast-bg, rgba(30,30,30,.94));color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;" + | ||
| "box-shadow:0 4px 16px rgba(0,0,0,.35);pointer-events:none;opacity:0;transition:opacity .18s ease;max-width:80vw;"; | ||
@@ -109,2 +111,77 @@ host.textContent = text; | ||
| // g-168:仅将确实在执行中的非收集 attempt 视为活跃。 | ||
| // result=pending 本身不够:旧 attempt 可能长期 pending,需 status_line 明确未结束。 | ||
| function hasActiveExecutionAttempt(attempts) { | ||
| return (attempts ?? []).some((a) => { | ||
| if (a?.executor === "agent:collect" || a?.result !== "pending") return false; | ||
| const line = String(a?.status_line ?? "").trim(); | ||
| return line !== "" && !/空闲|完成|待命|已交付|结束|等待|finished|done|idle|completed/i.test(line); | ||
| }); | ||
| } | ||
| // g-168:定义/润色入口。两条路径都只产生建议,不改目标或状态。 | ||
| function DefinitionPolish(props) { | ||
| const { goalId, goalPath, supervisorSession, status, attempts, onPmStarted, onPmFinished, onClose } = props; | ||
| const [mode, setMode] = React.useState("idle"); // idle | supervisor | pm | ||
| const [guidance, setGuidance] = React.useState(""); | ||
| const [note, setNote] = React.useState(null); | ||
| const [loading, setLoading] = React.useState(false); | ||
| const [fallback, setFallback] = React.useState(false); | ||
| const allowed = ["draft", "planning", "collecting", "ready"]; | ||
| const hasActiveAttempt = hasActiveExecutionAttempt(attempts); | ||
| if (!allowed.includes(status) || hasActiveAttempt) return null; | ||
| const request = `【${goalId} 定义/润色请求】\n目标 ID:${goalId}\ngoal.md 工作区相对路径:${String(goalPath ?? "(路径未知)")}\n人工指导意见:${guidance.trim() || "(无)"}`; | ||
| const openSupervisor = async () => { | ||
| setLoading(true); setNote(null); | ||
| try { | ||
| const rt = sessionsRt ?? appCtx?.get?.("sessions"); | ||
| if (!rt) throw new Error("会话服务不可用"); | ||
| if (!supervisorSession) throw new Error("未配置主管会话(project.yaml 的 supervisor.session)"); | ||
| const copied = await copyText(request); | ||
| rt.open?.(supervisorSession); activateChatTab(); | ||
| if (copied) showToast("✅ 请求已复制到剪贴板,可在主管对话窗粘贴发送"); | ||
| setMode("supervisor"); | ||
| setFallback(!copied); | ||
| setNote(copied ? "✅ 请求已复制,已打开主管会话,请粘贴发送" : "⚠️ 自动复制失败,请手动复制下方请求"); | ||
| } catch (e) { setNote("⚠️ 主管路径失败:" + String(e?.message ?? e)); } | ||
| setLoading(false); | ||
| }; | ||
| const askPm = async () => { | ||
| const startedAt = Date.now(); | ||
| onPmStarted?.(goalId); | ||
| setLoading(true); setMode("pm"); setNote("⏳ 产品经理 Agent 正在处理…"); | ||
| onClose?.(); | ||
| try { | ||
| const r = await fetch(graphUrl("/api/dsh-graph/define-polish"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ goal: goalId, goal_path: goalPath, guidance: guidance.trim() }) }); | ||
| const data = await r.json(); | ||
| if (data.ok) setNote("✅ 产品经理 Agent 已受理,建议将返回主管会话"); | ||
| else setNote("⚠️ 产品经理 Agent 失败:" + (data.child_error || data.error || "未知错误")); | ||
| } catch (e) { setNote("⚠️ 产品经理 Agent 失败:" + String(e?.message ?? e)); } | ||
| finally { | ||
| // spawnChild 通常很快返回;保持 accepted-running 动画至少一小段可观察时间。 | ||
| const remaining = Math.max(0, 2500 - (Date.now() - startedAt)); | ||
| if (remaining) await new Promise((resolve) => setTimeout(resolve, remaining)); | ||
| onPmFinished?.(goalId); | ||
| setLoading(false); | ||
| } | ||
| }; | ||
| const pmRunning = loading && mode === "pm"; | ||
| const pmStyle = pmRunning ? { | ||
| border: "1px solid rgba(76,141,255,.75)", borderRadius: 4, padding: "2px 6px", | ||
| background: "linear-gradient(90deg, rgba(76,141,255,.16), rgba(58,166,117,.30), rgba(76,141,255,.16))", | ||
| backgroundSize: "200% 100%", animation: "dg-polish-flow 2.5s ease 1 forwards", | ||
| } : undefined; | ||
| return h("div", { className: pmRunning ? "dg-running-flow" : undefined, style: pmStyle }, | ||
| h("button", { style: { ...S.btn, padding: "4px 12px", fontSize: 13 }, className: "dg-btn", disabled: loading, onClick: () => { setMode(mode === "idle" ? "supervisor" : "idle"); setNote(null); } }, "📝 定义/润色"), | ||
| mode !== "idle" ? h("div", { style: { display: "flex", flexDirection: "column", gap: 5, marginTop: 5 } }, | ||
| h("div", { style: S.meta }, "可填写额外指导意见,再选择处理方式:"), | ||
| h("textarea", { style: { ...S.promptInput, minHeight: 48, resize: "vertical", fontFamily: "inherit", fontSize: 12 }, value: guidance, placeholder: "人工指导意见(可选)…", onChange: (e) => setGuidance(e.target.value) }), | ||
| h("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" } }, | ||
| h("button", { style: S.btn, className: "dg-btn", disabled: loading, onClick: openSupervisor }, "发送给主管(复制请求)"), | ||
| h("button", { style: S.btn, className: "dg-btn", disabled: loading, onClick: askPm }, "交给产品经理 Agent")), | ||
| note ? h("div", { style: S.meta }, note) : null, | ||
| fallback ? h("textarea", { readOnly: true, value: request, style: { ...S.promptInput, minHeight: 72, resize: "vertical", fontFamily: "monospace", fontSize: 11 }, "aria-label": "定义润色请求手动复制内容" }) : null) : null); | ||
| } | ||
| // g-109:目标描述区执行/反馈交互组件(执行按钮直接创建子代理;接受默认经主管 Agent 复核, | ||
@@ -114,2 +191,3 @@ // 无异议生效,有异议显示在按钮处并转「强制接受」,可选理由记 goal.amended 事件供学习) | ||
| const { goalId, status, events, supervisorSession, onRefresh } = props; | ||
| const { attempts } = props; | ||
| const [mode, setMode] = React.useState("idle"); // idle | feedback | ||
@@ -244,5 +322,3 @@ const [fbText, setFbText] = React.useState(""); | ||
| // 只认非 collect 的 attempt:凡非收集类(agent:collect)的 attempt 都视为活跃执行。 | ||
| const hasActiveAttempt = (events ?? []).some( | ||
| (e) => e.event === "attempt.started" && e.details?.executor !== "agent:collect", | ||
| ); | ||
| const hasActiveAttempt = hasActiveExecutionAttempt(attempts); | ||
| // review 及之后阶段、或已有活跃 attempt,不显示执行/反馈按钮 | ||
@@ -256,3 +332,3 @@ const allowed = ["draft", "planning", "collecting", "ready"]; | ||
| ? h("button", { | ||
| style: { ...S.btn, padding: "4px 12px", fontSize: 13 }, className: "dg-btn dg-accept", | ||
| style: { ...S.btnAccept, padding: "4px 12px", fontSize: 13 }, className: "dg-btn-accept", | ||
| disabled: loading, onClick: doAccept, | ||
@@ -263,3 +339,3 @@ }, "✅ 接受") | ||
| : acceptState === "resolved" | ||
| ? h("span", { style: { ...S.meta, fontSize: 12, color: "#3aa675" } }, "✅ 已接受生效") | ||
| ? h("span", { style: { ...S.meta, fontSize: 12, color: "var(--dsw-alias-label-primary, #3aa675)" } }, "✅ 已接受生效") | ||
| : null, | ||
@@ -271,11 +347,10 @@ h("button", { | ||
| }, "🚀 执行"), | ||
| h("button", { | ||
| style: { ...S.btn, padding: "4px 12px", fontSize: 13 }, className: "dg-btn", | ||
| disabled: loading, | ||
| onClick: () => { setMode(mode === "feedback" ? "idle" : "feedback"); setNote(null); }, | ||
| }, "💬 反馈")), | ||
| h(DefinitionPolish, { | ||
| goalId, goalPath: props.goalPath, supervisorSession, status, events, attempts, | ||
| onPmStarted: props.onPmStarted, onPmFinished: props.onPmFinished, onClose: props.onClose, | ||
| })), | ||
| // g-109 判据:主管有异议 → 显示在按钮处,可转「强制接受」(可选理由记事件供学习) | ||
| acceptState === "objection" | ||
| ? h("div", { key: "obj", style: { display: "flex", flexDirection: "column", gap: 4, marginTop: 2 } }, | ||
| h("div", { style: { ...S.meta, color: "#e0a53a" } }, | ||
| h("div", { style: { ...S.meta, color: "var(--dsw-alias-state-warn-label, #e0a53a)" } }, | ||
| "⚠️ 主管异议:" + (objectionText ?? "(无内容)")), | ||
@@ -292,3 +367,3 @@ forceMode | ||
| h("button", { | ||
| style: { ...S.btn, fontSize: 12 }, className: "dg-btn dg-accept", | ||
| style: { ...S.btnAccept, fontSize: 12 }, className: "dg-btn-accept", | ||
| disabled: loading, onClick: doForceAccept, | ||
@@ -302,3 +377,3 @@ }, "确认强制接受"), | ||
| : h("button", { | ||
| style: { ...S.btn, fontSize: 12, alignSelf: "flex-start" }, className: "dg-btn dg-accept", | ||
| style: { ...S.btnAccept, fontSize: 12, alignSelf: "flex-start" }, className: "dg-btn-accept", | ||
| onClick: () => setForceMode(true), | ||
@@ -305,0 +380,0 @@ }, "强制接受(跳过复核)"), |
+163
-29
@@ -294,5 +294,9 @@ // g-150:handoff 组件(显示当前有效 handoff + 登记新 handoff) | ||
| const [relaunchRoute, setRelaunchRoute] = React.useState(null); // g-109:最近一次重新执行的模型路由(显示兜底) | ||
| const [criteriaOpen, setCriteriaOpen] = React.useState(false); // g-170:判据编辑弹窗(详情内「质量判据」标题处入口) | ||
| const [renaming, setRenaming] = React.useState(false); | ||
| const [newTitle, setNewTitle] = React.useState(""); | ||
| const [renameNote, setRenameNote] = React.useState(null); | ||
| // g-158:类型编辑状态 | ||
| const [typeEditing, setTypeEditing] = React.useState(false); | ||
| const [typeNote, setTypeNote] = React.useState(null); | ||
| // g-148:load 提升到组件体,供 AcceptFeedback 通过 onRefresh 回调刷新详情 | ||
@@ -313,2 +317,5 @@ const aliveRef = React.useRef(true); | ||
| // g-181:主 overlay backdrop 误关保护(内容起点后释放到 backdrop 的合成 click 吞掉) | ||
| const backdropGuard = useBackdropClose(props.onClose); | ||
| const section = (body, name) => { | ||
@@ -347,9 +354,9 @@ const m = new RegExp(`## ${name}\\n([\\s\\S]*?)(?=\\n## |$)`).exec(body ?? ""); | ||
| pendingDeps.length | ||
| ? h("div", { key: "m2", style: { ...S.meta, color: "#e0a53a" } }, `⛓ 等待 ${pendingDeps.join("、")} 交付`) | ||
| ? h("div", { key: "m2", style: { ...S.meta, color: "var(--dsw-alias-state-warn-label, #e0a53a)" } }, `⛓ 等待 ${pendingDeps.join("、")} 交付`) | ||
| : null, | ||
| metDeps.length | ||
| ? h("div", { key: "m2b", style: { ...S.meta, color: "#3aa675" } }, `✅ 依赖满足:${metDeps.join("、")} 已交付`) | ||
| ? h("div", { key: "m2b", style: { ...S.meta, color: "var(--dsw-alias-label-primary, #3aa675)" } }, `✅ 依赖满足:${metDeps.join("、")} 已交付`) | ||
| : null, | ||
| status === "blocked" && meta.blocked_reason | ||
| ? h("div", { key: "m3", style: { ...S.meta, color: "#d66" } }, "⛔ " + meta.blocked_reason) | ||
| ? h("div", { key: "m3", style: { ...S.meta, color: "var(--dsw-alias-state-error-primary, #d66)" } }, "⛔ " + meta.blocked_reason) | ||
| : null, | ||
@@ -386,3 +393,4 @@ ]; | ||
| } | ||
| function sectionBlock(key, title, body, extra, hideBodyWhenExtra) { | ||
| // g-170:titleExtra 渲染在小节标题右侧(判据编辑入口用) | ||
| function sectionBlock(key, title, body, extra, hideBodyWhenExtra, titleExtra) { | ||
| const { isPh, marker, body: content } = parsePlaceholder(body); | ||
@@ -392,3 +400,4 @@ return h("div", { key, style: S.modalSection }, | ||
| title, | ||
| isPh && !content ? h("span", { style: { ...S.meta, fontSize: 12, marginLeft: 6, fontWeight: 400 } }, marker) : null), | ||
| isPh && !content ? h("span", { style: { ...S.meta, fontSize: 12, marginLeft: 6, fontWeight: 400 } }, marker) : null, | ||
| titleExtra ?? null), | ||
| hideBodyWhenExtra && extra != null ? null : (isPh && !content ? null : content), | ||
@@ -401,11 +410,18 @@ extra ?? null); | ||
| desc != null ? sectionBlock("d", "📋 目标描述", desc, | ||
| h(AcceptFeedback, { goalId: props.id, status, events: d.events, supervisorSession: props.supervisorSession, onRefresh: load })) : null, | ||
| h(AcceptFeedback, { goalId: props.id, goalPath: String(d.goalFile ?? "").replace(/^.*?(?=\.dsh-graph[\\/])/, ""), title: d.title ?? props.title, description: desc, criteria: crit, status, events: d.events, attempts: d.attempts, supervisorSession: props.supervisorSession, onRefresh: load, onPmStarted: props.onPmStarted, onPmFinished: props.onPmFinished, onClose: props.onClose })) : null, | ||
| // g-109:判据栏只在 ready 及之后阶段显示 checklist(已确认可勾选),早期阶段只显示纯文本 | ||
| // g-170:「✏️ 判据」编辑入口放在小节标题处(负责人 2026-08-25 指示),点击打开判据编辑弹窗 | ||
| crit != null ? sectionBlock("c", "✅ 质量判据", crit, | ||
| !isPlaceholder(crit) && ["ready", "in_progress", "review", "delivered"].includes(status) | ||
| ? h(CriteriaChecklist, { goalId: props.id, crit, att, onClose: props.onClose }) | ||
| : null, true) : null, | ||
| : null, true, | ||
| h("button", { | ||
| style: { ...S.btnPrimary, fontSize: 11, padding: "1px 6px", marginLeft: 6, verticalAlign: "middle", opacity: 1 }, | ||
| className: "dg-btn", | ||
| title: "编辑质量判据(保存后清空该目标已有勾选)", | ||
| onClick: (e) => { e.stopPropagation(); setCriteriaOpen(true); }, | ||
| }, "✏️ 判据")) : null, | ||
| (d.cards ?? []).length | ||
| ? h("div", { key: "k", style: S.modalSection }, | ||
| h("div", { style: S.modalH }, "🗂 信息收集"), | ||
| h("div", { style: S.modalH }, "🔎 信息收集"), | ||
| d.cards.map((c) => h("div", { | ||
@@ -427,3 +443,3 @@ key: c.id, | ||
| : h("div", { key: "k", style: S.modalSection }, | ||
| h("div", { style: S.modalH }, "🗂 信息收集"), | ||
| h("div", { style: S.modalH }, "🔎 信息收集"), | ||
| h("div", { style: S.meta }, "(暂无上下文卡片)"), | ||
@@ -462,5 +478,4 @@ isBacklog | ||
| onChange: (e) => setLogFilter(e.target.value), | ||
| style: { fontSize: 12, padding: "2px 6px", cursor: "pointer", | ||
| background: "rgba(128,128,128,.10)", color: "inherit", | ||
| border: "1px solid rgba(128,128,128,.35)", borderRadius: 4 }, | ||
| style: S.select, | ||
| className: "dg-select", | ||
| }, | ||
@@ -470,3 +485,4 @@ h("option", { value: "" }, "全部类型"), ...typeOptions), | ||
| onClick: () => setLogSort(logSort === "asc" ? "desc" : "asc"), | ||
| style: { ...S.btn, border: "1px solid rgba(128,128,128,.35)", borderRadius: 4 }, | ||
| style: { ...S.btn }, | ||
| className: "dg-btn", | ||
| }, logSort === "asc" ? "↑ 时间正序" : "↓ 时间倒序")), | ||
@@ -507,3 +523,3 @@ // 事件日志表格:时间 / 事件 / 执行者 | ||
| fontWeight: tab === "detail" ? 700 : 400, | ||
| color: tab === "detail" ? "#8ab4ff" : "inherit", | ||
| color: tab === "detail" ? "var(--dsw-alias-label-primary, #8ab4ff)" : "inherit", | ||
| opacity: tab === "detail" ? 1 : 0.7, | ||
@@ -521,3 +537,3 @@ }, | ||
| fontWeight: tab === "activity" ? 700 : 400, | ||
| color: tab === "activity" ? "#8ab4ff" : "inherit", | ||
| color: tab === "activity" ? "var(--dsw-alias-label-primary, #8ab4ff)" : "inherit", | ||
| opacity: tab === "activity" ? 1 : 0.7, | ||
@@ -535,3 +551,3 @@ }, | ||
| fontWeight: tab === "context" ? 700 : 400, | ||
| color: tab === "context" ? "#8ab4ff" : "inherit", | ||
| color: tab === "context" ? "var(--dsw-alias-label-primary, #8ab4ff)" : "inherit", | ||
| opacity: tab === "context" ? 1 : 0.7, | ||
@@ -610,2 +626,28 @@ }, | ||
| // g-158:设置目标类型(只改 type,不改变量生命周期语义) | ||
| const doSetType = async (newType) => { | ||
| setTypeNote(null); | ||
| try { | ||
| const r = await fetch(graphUrl("/api/dsh-graph/set-goal-type"), { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ goal: props.id, type: newType }), | ||
| }); | ||
| const data = await r.json(); | ||
| if (data.ok) { | ||
| setTypeEditing(false); | ||
| setTypeNote(null); | ||
| // 刷新详情数据 | ||
| const goalRes = await fetch(graphUrl("/api/dsh-graph/goal", { id: props.id })); | ||
| const goalData = await goalRes.json(); | ||
| if (!goalData.error) setState({ loading: false, data: goalData }); | ||
| if (props.onRenamed) props.onRenamed(); // 刷新看板 | ||
| } else { | ||
| setTypeNote("⚠️ 设置失败:" + (data.error || "未知错误")); | ||
| } | ||
| } catch (e) { | ||
| setTypeNote("⚠️ 请求失败:" + String(e?.message ?? e)); | ||
| } | ||
| }; | ||
| // g-110: 归档/取消归档操作 | ||
@@ -615,2 +657,8 @@ const [archiveNote, setArchiveNote] = React.useState(null); | ||
| const canArchive = ["draft", "planning", "delivered"].includes(state.data?.meta?.status); | ||
| // g-138:暂缓操作(仅版本/standalone 目标,二次确认) | ||
| const [postponeConfirm, setPostponeConfirm] = React.useState(false); | ||
| const [postponeNote, setPostponeNote] = React.useState(null); | ||
| const goalFile = String(state.data?.goalFile ?? ""); | ||
| const isBacklogGoal = goalFile.includes("/backlog/") || goalFile.includes("\\\\backlog\\\\"); | ||
| const canPostpone = !isArchived && !isBacklogGoal && Boolean(state.data?.meta?.status); | ||
| // g-140: 删除操作(仅已归档目标可删除,二次确认) | ||
@@ -644,2 +692,25 @@ const [deleteConfirm, setDeleteConfirm] = React.useState(false); | ||
| // g-138:二次确认后调用单向暂缓接口,成功后关闭详情并刷新看板 | ||
| const doPostpone = async () => { | ||
| try { | ||
| const r = await fetch(graphUrl("/api/dsh-graph/postpone"), { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ goal: props.id }), | ||
| }); | ||
| const data = await r.json(); | ||
| if (data.ok) { | ||
| setPostponeNote("✅ 已暂缓"); | ||
| showToast("✅ 目标已暂缓并移回 backlog"); | ||
| setPostponeConfirm(false); | ||
| props.onArchived?.(); | ||
| props.onClose?.(); | ||
| } else { | ||
| setPostponeNote("⚠️ 暂缓失败:" + (data.error || "未知错误")); | ||
| } | ||
| } catch (e) { | ||
| setPostponeNote("⚠️ 请求失败:" + String(e?.message ?? e)); | ||
| } | ||
| }; | ||
| const doUnarchive = async () => { | ||
@@ -691,2 +762,6 @@ try { | ||
| // g-158:当前目标类型(从 state.data.meta.type 读取,回退 task)与类型色 | ||
| const currentType = normalizeGoalType(state.data?.meta?.type); | ||
| const currentTypeColor = goalTypeColor(currentType); | ||
| const titleEl = renaming | ||
@@ -712,2 +787,33 @@ ? h("div", { style: { display: "flex", alignItems: "center", gap: 6, marginTop: 4 } }, | ||
| : h("div", { style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" } }, | ||
| // g-158:类型标记 badge(标题最左侧,颜色与弹窗顶部边框、卡片左栏同源) | ||
| h("span", { | ||
| style: { | ||
| display: "inline-flex", alignItems: "center", justifyContent: "center", | ||
| width: 20, height: 20, lineHeight: "20px", borderRadius: 4, fontSize: 12, fontWeight: 700, | ||
| background: currentTypeColor, color: "#fff", cursor: "pointer", flexShrink: 0, | ||
| }, | ||
| title: `类型:${GOAL_TYPE_LABELS[currentType]}(点击切换)`, | ||
| onClick: (e) => { e.stopPropagation(); setTypeEditing(!typeEditing); setTypeNote(null); }, | ||
| }, GOAL_TYPE_ABBREV[currentType]), | ||
| // g-158:类型选择器弹出(点击 badge 展开) | ||
| typeEditing | ||
| ? h("div", { style: { display: "flex", gap: 3, alignItems: "center" } }, | ||
| ...GOAL_TYPES.map((t) => | ||
| h("button", { | ||
| key: t, | ||
| style: { | ||
| fontSize: 11, padding: "1px 6px", cursor: "pointer", | ||
| border: "1px solid " + (t === currentType ? goalTypeColor(t) : "rgba(128,128,128,.4)"), | ||
| borderRadius: 3, background: t === currentType ? goalTypeColor(t) : "rgba(128,128,128,.1)", | ||
| color: t === currentType ? "#fff" : "inherit", fontWeight: t === currentType ? 700 : 400, | ||
| }, | ||
| className: "dg-btn", | ||
| title: GOAL_TYPE_LABELS[t], | ||
| onClick: () => doSetType(t), | ||
| }, GOAL_TYPE_ABBREV[t])), | ||
| h("button", { | ||
| style: { ...S.btn, fontSize: 10, padding: "0 4px" }, className: "dg-btn", | ||
| onClick: () => { setTypeEditing(false); setTypeNote(null); }, | ||
| }, "✕")) | ||
| : null, | ||
| h("span", { style: { fontWeight: 700, fontSize: 15 } }, `🎯 ${props.title ?? props.id}`), | ||
@@ -733,2 +839,22 @@ h("button", { | ||
| : null, | ||
| // g-138:暂缓按钮位于归档按钮右侧,点击后要求二次确认 | ||
| canPostpone | ||
| ? (postponeConfirm | ||
| ? h("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, marginLeft: 2 } }, | ||
| h("span", { style: { ...S.meta, fontSize: 11, color: "var(--dsw-alias-state-error-primary, #d66)" } }, "确认暂缓?"), | ||
| h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "1px 6px", background: "rgba(224,165,58,.2)" }, className: "dg-btn", | ||
| title: "确认将目标移回 backlog", | ||
| onClick: doPostpone, | ||
| }, "⏸ 确认"), | ||
| h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "1px 6px" }, className: "dg-btn", | ||
| onClick: () => { setPostponeConfirm(false); setPostponeNote(null); }, | ||
| }, "取消")) | ||
| : h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "1px 6px", background: "rgba(224,165,58,.2)" }, className: "dg-btn", | ||
| title: "暂缓目标(移回 backlog,保留卡片与 attempts)", | ||
| onClick: () => { setPostponeConfirm(true); setPostponeNote(null); }, | ||
| }, "⏸ 暂缓")) | ||
| : null, | ||
| // g-140: 删除按钮(仅已归档目标显示,二次确认) | ||
@@ -738,5 +864,5 @@ isArchived | ||
| ? h("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, marginLeft: 2 } }, | ||
| h("span", { style: { ...S.meta, fontSize: 11, color: "#d66" } }, "确认删除?"), | ||
| h("span", { style: { ...S.meta, fontSize: 11, color: "var(--dsw-alias-state-error-primary, #d66)" } }, "确认删除?"), | ||
| h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "1px 6px", background: "rgba(214,102,102,.3)" }, className: "dg-btn", | ||
| style: { ...S.btnDanger, fontSize: 11, padding: "1px 6px" }, className: "dg-btn-danger", | ||
| title: "确认删除(不可恢复)", | ||
@@ -750,3 +876,3 @@ onClick: doDelete, | ||
| : h("button", { | ||
| style: { ...S.btn, fontSize: 11, padding: "1px 6px", background: "rgba(214,102,102,.2)" }, className: "dg-btn", | ||
| style: { ...S.btnDanger, fontSize: 11, padding: "1px 6px" }, className: "dg-btn-danger", | ||
| title: "删除目标(仅已归档目标可删除,含卡片/attempts)", | ||
@@ -757,13 +883,21 @@ onClick: () => { setDeleteConfirm(true); setDeleteNote(null); }, | ||
| archiveNote ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4 } }, archiveNote) : null, | ||
| deleteNote ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4 } }, deleteNote) : null); | ||
| postponeNote ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4 } }, postponeNote) : null, | ||
| deleteNote ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4 } }, deleteNote) : null, | ||
| typeNote ? h("span", { style: { ...S.meta, fontSize: 11, marginLeft: 4 } }, typeNote) : null); | ||
| return h( | ||
| "div", | ||
| { style: S.overlay, onClick: props.onClose }, | ||
| h("div", { style: S.modal, onClick: (e) => e.stopPropagation() }, | ||
| h("span", { style: S.close, onClick: props.onClose }, "✕"), | ||
| titleEl, | ||
| headMeta, | ||
| livePanel, | ||
| content), | ||
| return h(React.Fragment, null, | ||
| h("div", | ||
| { style: S.overlay, ...backdropGuard }, | ||
| // g-158:弹窗顶部边框使用类型色(与卡片左侧色条、标题 badge 同色) | ||
| h("div", { style: { ...S.modal, borderTop: `3px solid ${currentTypeColor}` }, onClick: (e) => e.stopPropagation() }, | ||
| h("span", { style: S.close, onClick: props.onClose }, "✕"), | ||
| titleEl, | ||
| headMeta, | ||
| livePanel, | ||
| content), | ||
| ), | ||
| // g-170:判据编辑弹窗(详情内「质量判据」标题处入口打开)——保存后刷新详情 | ||
| criteriaOpen | ||
| ? h(CriteriaModal, { goalId: props.id, onClose: () => setCriteriaOpen(false), onSaved: () => { setCriteriaOpen(false); load(); } }) | ||
| : null, | ||
| ); | ||
@@ -770,0 +904,0 @@ } |
@@ -29,3 +29,3 @@ wrap: { padding: 12, fontSize: 13, color: "inherit", overflowX: "auto" }, | ||
| overlay: { | ||
| position: "fixed", inset: 0, background: "rgba(0,0,0,.55)", | ||
| position: "fixed", inset: 0, background: "var(--dsw-alias-bg-mask-1, rgba(0,0,0,.55))", | ||
| display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9999, | ||
@@ -35,3 +35,3 @@ }, | ||
| position: "fixed", top: 0, right: 0, height: "100vh", width: 400, | ||
| background: "#1e1f24", color: "#e6e6e6", zIndex: 10000, | ||
| background: "var(--dsw-alias-bg-layer-1, #1e1f24)", color: "var(--dsw-alias-label-primary, #e6e6e6)", zIndex: 10000, | ||
| boxShadow: "-4px 0 16px rgba(0,0,0,.45)", | ||
@@ -44,3 +44,3 @@ padding: "20px 22px", overflowY: "auto", fontSize: 13, lineHeight: 1.7, | ||
| modal: { | ||
| background: "#1e1f24", color: "#e6e6e6", borderRadius: 10, | ||
| background: "var(--dsw-alias-bg-layer-1, #1e1f24)", color: "var(--dsw-alias-label-primary, #e6e6e6)", borderRadius: 10, | ||
| maxWidth: 720, width: "90%", maxHeight: "80vh", overflowY: "auto", | ||
@@ -51,3 +51,39 @@ padding: "16px 20px", fontSize: 13, lineHeight: 1.6, | ||
| modalH: { fontWeight: 700, marginBottom: 4 }, | ||
| btn: { fontSize: 12, padding: "2px 10px", cursor: "pointer" }, | ||
| // g-153:共享按钮样式 token——暗色主题下确保可读性与层级;g-176:改 DSH 主题变量并保留暗色 fallback | ||
| btn: { | ||
| fontSize: 12, padding: "2px 10px", cursor: "pointer", | ||
| background: "var(--dsw-alias-interactive-bg-hover-solid, rgba(128,128,128,.15))", | ||
| color: "var(--dsw-alias-label-primary, #e6e6e6)", | ||
| border: "1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.30))", borderRadius: 4, | ||
| }, | ||
| // g-153:主要操作按钮(蓝底高亮);g-176 follow-up:浅色下克制化—— | ||
| // tertiary 淡底 + label-primary 文字(高对比),语义由 primary 边框保留 | ||
| btnPrimary: { | ||
| fontSize: 12, padding: "2px 10px", cursor: "pointer", | ||
| background: "var(--dsw-alias-state-business-tertiary, rgba(76,141,255,.18))", | ||
| color: "var(--dsw-alias-label-primary, #8ab4ff)", | ||
| border: "1px solid var(--dsw-alias-state-business-primary, rgba(76,141,255,.40))", borderRadius: 4, | ||
| }, | ||
| // g-153:危险操作按钮(红底红字) | ||
| btnDanger: { | ||
| fontSize: 12, padding: "2px 10px", cursor: "pointer", | ||
| background: "rgba(214,102,102,.18)", color: "var(--dsw-alias-state-error-primary, #f08080)", | ||
| border: "1px solid rgba(214,102,102,.35)", borderRadius: 4, | ||
| }, | ||
| // g-153:接受/确认操作按钮(绿底绿字);g-176 follow-up:浅色下对比修复—— | ||
| // tertiary 淡绿底 + label-primary 文字(≥12:1),语义由 primary 绿边与 ✅ 保留 | ||
| btnAccept: { | ||
| fontSize: 12, padding: "2px 10px", cursor: "pointer", | ||
| background: "var(--dsw-alias-state-success-tertiary, rgba(58,166,117,.18))", | ||
| color: "var(--dsw-alias-label-primary, #6ee7a0)", | ||
| border: "1px solid var(--dsw-alias-state-success-primary, rgba(58,166,117,.40))", borderRadius: 4, | ||
| }, | ||
| // g-153:下拉菜单/选择控件样式 token | ||
| select: { | ||
| fontSize: 12, padding: "3px 8px", cursor: "pointer", | ||
| background: "var(--dsw-alias-bg-layer-2, rgba(30,31,36,.92))", | ||
| color: "var(--dsw-alias-label-primary, #e6e6e6)", | ||
| border: "1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.35))", borderRadius: 4, | ||
| }, | ||
| selectOption: { background: "var(--dsw-alias-bg-layer-3, #222328)", color: "var(--dsw-alias-label-primary, #e6e6e6)" }, | ||
| close: { float: "right", cursor: "pointer", opacity: 0.7, fontSize: 16 }, | ||
@@ -71,4 +107,4 @@ // g-107 会话内嵌实时区 | ||
| flex: 1, minWidth: 0, fontSize: 12, padding: "3px 6px", | ||
| background: "rgba(0,0,0,.25)", color: "inherit", | ||
| border: "1px solid rgba(128,128,128,.4)", borderRadius: 4, | ||
| background: "var(--dsw-alias-bg-layer-2, rgba(0,0,0,.25))", color: "inherit", | ||
| border: "1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4))", borderRadius: 4, | ||
| }, | ||
@@ -79,3 +115,3 @@ // g-108 看板顶部 supervisor 状态栏 | ||
| padding: "4px 10px", border: "1px solid rgba(58,166,117,.45)", | ||
| borderRadius: 6, background: "rgba(30,31,36,.92)", fontSize: 12, | ||
| borderRadius: 6, background: "var(--dsw-alias-bg-module-platform, rgba(30,31,36,.92))", fontSize: 12, | ||
| }, | ||
@@ -126,2 +162,20 @@ recordItem: { | ||
| // g-181:父级 overlay backdrop 误关保护。根因:pointerdown 在内容、mouseup 在 backdrop 时, | ||
| // 浏览器把 click 派发到 overlay 自身(事件路径不经过 panel),panel 的 stopPropagation 拦不住。 | ||
| // 仅检查 e.target === e.currentTarget 无效(该场景 click 的 target 就是 overlay)。 | ||
| // 方案:onPointerDown 记录手势起点(e.target !== e.currentTarget = 起点在内容); | ||
| // onClick 若起点在内容则清零并吞掉本次合成 click(不关闭),否则照常 onClose?.()。 | ||
| // useRef 跨重渲染稳定(如 GoalModal 定时 load 重建内容);pointer 事件兼容鼠标/触摸; | ||
| // 返回的 guard 对象 spread 到 overlay 元素上(onPointerDown/onClick 成对出现)。 | ||
| function useBackdropClose(onClose) { | ||
| const insideRef = React.useRef(false); | ||
| return { | ||
| onPointerDown: (e) => { insideRef.current = e.target !== e.currentTarget; }, | ||
| onClick: (e) => { | ||
| if (insideRef.current) { insideRef.current = false; e.stopPropagation(); return; } | ||
| onClose?.(); | ||
| }, | ||
| }; | ||
| } | ||
| // ===== g-107 会话内嵌实时:复用 DSH 客户端会话机制,不自建数据通道 ===== |
@@ -52,3 +52,3 @@ const snap = useSessionSnapshot(session); | ||
| h("div", { style: { display: "flex", alignItems: "center", gap: 5 } }, | ||
| h("span", { style: { color: running ? "#3aa675" : "rgba(128,128,128,.9)", flexShrink: 0 } }, | ||
| h("span", { style: { color: running ? "var(--dsw-alias-state-success-primary, #3aa675)" : "var(--dsw-alias-label-tertiary, rgba(128,128,128,.9))", flexShrink: 0 } }, | ||
| statusLabel), | ||
@@ -279,9 +279,4 @@ lineEl, | ||
| const selStyle = { | ||
| fontSize: 12, padding: "2px 6px", cursor: "pointer", maxWidth: 160, | ||
| background: "rgba(128,128,128,.10)", color: "inherit", | ||
| border: "1px solid rgba(128,128,128,.35)", borderRadius: 4, | ||
| }; | ||
| // 深色主题:浏览器原生 option 默认白底,下拉展开时突兀 → 显式深色底 | ||
| const optStyle = { background: "#2a2b31", color: "#e6e6e6" }; | ||
| const selStyle = S.select; | ||
| const optStyle = S.selectOption; | ||
| const defP = opts?.default?.provider ?? ""; | ||
@@ -300,2 +295,3 @@ const defM = opts?.default?.model ?? ""; | ||
| style: selStyle, value: provider, | ||
| className: "dg-select", | ||
| title: "LLM provider(缺省 project.yaml executor.provider)", | ||
@@ -307,2 +303,3 @@ onChange: (e) => { setProvider(e.target.value); setModel(""); }, | ||
| style: selStyle, value: model, | ||
| className: "dg-select", | ||
| disabled: !modelChoices.length, | ||
@@ -309,0 +306,0 @@ title: "模型(缺省 project.yaml executor.model)", |
@@ -124,2 +124,5 @@ // g-129 修复:缓存最近一次成功解析的 workspace——切到子代理会话(不在 workspace 映射、 | ||
| ); | ||
| // g-133:注册「看板设置」settings.section 页(profile 级全局默认配置)。 | ||
| // settingsScope 缺失 / slots 未就绪时整页降级,不影响看板与工具。 | ||
| try { registerGraphSettingsSection(ctx); } catch { /* 静默 */ } | ||
| console.log("[dsh-graph-host] client apply: kanban view registered"); | ||
@@ -126,0 +129,0 @@ }, |
@@ -17,4 +17,8 @@ function SupervisorBar(props) { | ||
| h(LiveStrip, { parentId: null, childId: props.id, statusLine: props.statusLine ?? null, statusAt: props.statusAt ?? null })), | ||
| h("span", { style: { ...S.meta, flexShrink: 0 } }, | ||
| model ? `${model.provider}/${model.model}` : modelErr ? "模型不可用" : "模型查询中…"), | ||
| model | ||
| ? h("div", { style: { ...S.meta, flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "flex-end", lineHeight: 1.2 } }, | ||
| h("span", null, model.provider), | ||
| h("span", null, model.model)) | ||
| : h("span", { style: { ...S.meta, flexShrink: 0 } }, | ||
| modelErr ? "模型不可用" : "模型查询中…"), | ||
| h("button", { | ||
@@ -31,3 +35,3 @@ style: { ...S.btn, flexShrink: 0 }, className: "dg-btn", | ||
| if (!childId || !reusedBy) return null; | ||
| return h("div", { style: { ...S.meta, color: "#e0a53a", marginTop: 2 } }, | ||
| return h("div", { style: { ...S.meta, color: "var(--dsw-alias-state-warn-label, #e0a53a)", marginTop: 2 } }, | ||
| `♻️ 被复用→${reusedBy}`); | ||
@@ -34,0 +38,0 @@ } |
+17
-7
| { | ||
| "name": "dsh-graph", | ||
| "version": "0.6.1", | ||
| "version": "0.7.1", | ||
| "description": "dsh-graph 单包(host+client 合并,g-116):把 dsh-graph 核心层包装为 DSH cordis 插件,同时提供 graph_* 目标生命周期工具(建卡/判据/迁移/派发/评审/交付)、/api/dsh-graph REST 端点与浏览器二维泳道看板(conversation.view)。包名 = repo 名(负责人定案);内部 host 插件 id 保留 dsh-graph-host。", | ||
@@ -40,2 +40,15 @@ "license": "MIT", | ||
| }, | ||
| "peerDependencies": { | ||
| "@deepseek-ai/cordis": "^4.0.1", | ||
| "@deepseek-ai/schemastery": "^3.18.1", | ||
| "@deepseek-ai/dsh-settings": "^0.1.0-rc.6" | ||
| }, | ||
| "dependencies": { | ||
| "@deepseek-ai/schemastery": "^3.18.1" | ||
| }, | ||
| "scripts": { | ||
| "prepack": "bash ../scripts/sync-core.sh && node -e \"require('node:fs').existsSync('core/ops.js') || (console.error('core 未同步'), process.exit(1))\"", | ||
| "build": "bash ../scripts/sync-core.sh", | ||
| "test": "node --test ../core/tests/*.test.ts" | ||
| }, | ||
| "dsh": { | ||
@@ -48,10 +61,7 @@ "bundle": { | ||
| "inject": [ | ||
| "@deepseek-ai/dsh-client-runtime" | ||
| "@deepseek-ai/dsh-client-runtime", | ||
| "@deepseek-ai/dsh-client-ui-settings" | ||
| ] | ||
| } | ||
| }, | ||
| "scripts": { | ||
| "build": "bash ../scripts/sync-core.sh", | ||
| "test": "node --test ../core/tests/*.test.ts" | ||
| } | ||
| } | ||
| } |
+165
-222
@@ -11,23 +11,19 @@ --- | ||
| ## 接手前置:确认已接管主管角色 | ||
| ## 接管前置 | ||
| **开始任何主管工作前,先确认你已接管本 workspace 的主管角色**(否则看板主管栏/ | ||
| 开始任何主管工作前,先确认已接管本 workspace 的主管角色(否则看板主管栏/ | ||
| 执行派发/live 子代理都找不到主管会话): | ||
| - 读 `project.yaml` 的 `supervisor.session`,或看 `graph_help` 的接管指引; | ||
| - 若**未配置 / 未指向本会话**:说明该 workspace 还没建立主管 → 运行 | ||
| `graph_claim_supervisor()` 由本会话接管(自动更新 `project.yaml` 的 | ||
| `supervisor.session`、记 `supervisor.claimed` 事件、返回 HANDOFF 全文); | ||
| - **例外(g-118 防争抢)**:若 `supervisor.session` **已指向其他会话**且负责人 | ||
| **没要求**你接管,则**不要 claim**——保持普通会话身份,避免与既有主管争抢角色; | ||
| 此时你只做普通工作,需接管时等负责人明确指示。 | ||
| - 若**未配置 / 未指向本会话**:运行 `graph_claim_supervisor()` 由本会话接管 | ||
| (更新 `supervisor.session`、记 `supervisor.claimed` 事件、返回 HANDOFF 全文); | ||
| - **防争抢**:若 `supervisor.session` 已指向其他会话且负责人没要求你接管,则 | ||
| **不要 claim**——保持普通会话身份,等负责人明确指示。 | ||
| > 只有 `graph_claim_supervisor()` 会写 `supervisor.session`;加载本 skill 本身**不会** | ||
| > 接管(g-118 防止临时会话无意争抢主管)。本 workspace 无任何进程接管时,由你显式接管。 | ||
| 只有 `graph_claim_supervisor()` 会写 `supervisor.session`;加载本 skill 本身**不会** | ||
| 接管。本 workspace 无任何进程接管时,由你显式接管。 | ||
| > **⚠️ 首要铁律(违反即降级)**:supervisor **只做规划、派发、把关、复核**, | ||
| > **绝不自己实现功能、绝不自己写大段代码、绝不自己长调研**——所有实现/调研/ | ||
| > 编写/手册一律派给子代理(`graph_start_attempt` / 收集子代理)。自己动手仅限 | ||
| > 一句话决策、一行小修。自己实现会把主管会话撑爆、认知降级(2026-08 新会话 | ||
| > 自实现 g-117 的教训)。 | ||
| > **铁律(违反即降级)**:supervisor **只做规划、派发、把关、复核**,**绝不自己 | ||
| > 实现功能、绝不自己写大段代码、绝不自己长调研**——实现/调研/编写/手册一律派给 | ||
| > 子代理(`graph_start_attempt` / 收集子代理)。自己动手仅限一句话决策、一行小修。 | ||
@@ -37,245 +33,195 @@ ## 不可妥协 | ||
| 1. **判据先于执行**:目标进 `in_progress` 前判据必须已登记并经负责人确认; | ||
| 2. **状态不是证据,产出物才是**:任何"完成"只是声明,必须过判据核验; | ||
| 3. **事件先行**:任何状态/归属/内容变化先落事件流(R-02); | ||
| 4. **人工 gate 停轮(四类操作默认需确认,负责人 2026-08-22 定)**:以下四类 | ||
| 操作**默认都需负责人确认**,仅在全自动模式或 Full access 下豁免(指南用词 | ||
| 「自动授权模式」即指此): | ||
| 2. **状态不是证据,产出物才是**:任何「完成」只是声明,必须过判据核验; | ||
| 3. **事件先行**:任何状态/归属/内容变化先落事件流; | ||
| 4. **人工 gate 停轮**(四类操作默认需负责人确认,仅自动授权模式/Full access 豁免): | ||
| - **开始工作**:目标 `ready→in_progress` 前必须征得同意,确认后才派发执行 | ||
| attempt——不能凭「判据已登记」就默认放行,也不能把「方向性授权」(如 | ||
| 「开工优化清单」)误读成「逐目标放行」; | ||
| - **审核**:`review` verdict 由负责人裁决,supervisor 不自行 delivered | ||
| (g-124 越权教训); | ||
| attempt——不能凭「判据已登记」默认放行,也不能把「方向性授权」误读成 | ||
| 「逐目标放行」; | ||
| - **审核**:`review` verdict 由负责人裁决,supervisor 不自行 delivered; | ||
| - **发布**:`delivered` / npm 发布 / git tag 均为人工 gate; | ||
| - **调整版本计划**:排期移动(backlog↔版本↔独立)、版本 released/active | ||
| 变更需确认; | ||
| 「简单任务例外」:简单的一两行改动可先做后追认,但**不得擅自扩大范围**、 | ||
| 不得把简单例外套用到复杂任务(负责人 2026-08-22 尺度); | ||
| - 「简单任务例外」:一两行改动可先做后追认,但不得擅自扩大范围、不得把 | ||
| 简单例外套用到复杂任务; | ||
| 5. **不静默修复**:缺陷与矛盾记录入册(证据台账/记忆),宁可 blocked 不可猜测; | ||
| 6. **惰性激活**:下游工作(收集、执行)只有上游结论成立后才派发; | ||
| 7. **目标内容体现最终修订**:负责人的补充与修正用 `graph_amend_goal` 记录, | ||
| 并把最终版写进目标描述——后续执行者读到的是最终版,不是初版; | ||
| **append 用法(防重复小节,g-119/g-120 教训)**:`graph_create_goal` 生成的 | ||
| 描述是「(待填写)」占位,补描述用 `graph_amend_goal(append=...)` 时 | ||
| **append 只传正文内容,绝不自带「## 目标描述」等标题**——amendGoal 内部 | ||
| replace 已并入目标描述小节;自带标题会生成重复小节,看板取第一个 | ||
| (占位)显示为「待填写」; | ||
| 8. **supervisor 时间纪律(负责人 critical 指示)**:不做长时间探索/调研/编写, | ||
| 充分利用 dsh-graph 把工作派下去——信息收集、功能开发、手册编写等都交给 | ||
| 子代理;supervisor 只做规划、派发、把关、复核,亲自动手仅限短平快决策/小修; | ||
| 9. **负责人直接干预子代理时 supervisor 不插手**:负责人正通过 checklist 💬 反馈或 | ||
| 直接会话指挥某子代理时,supervisor 不重复派活、不打断、不代判——等该子代理 | ||
| 完成既定目标并自行 `graph_report_status` 汇报后,supervisor 再回到 review 关口 | ||
| 做判据核验。不要与负责人抢着指挥同一个子代理。 | ||
| 10. **`graph_amend_goal` 的 note vs append(负责人 2026-08-23 定)**: | ||
| - `note`(必填):修订备注,**只记 `goal.amended` 事件**,**不写进目标描述正文**—— | ||
| 用于轻量/过程性/跨目标备注(如「已复核」「已派发 g-131」「这条转 g-138 承接」)。 | ||
| - `append`(可选):**写进目标描述正文**(并入 `## 目标描述` 小节)——用于会影响 | ||
| 目标范围/需求/设计、执行者与看板应读到的**内容**(需求、反馈、设计方案、约束、决策理由)。 | ||
| - **判定**:凡属"目标自身内容"(要落实、要被执行者/看板看到)→ 用 `append` 写入正文; | ||
| 凡属"过程/事件/仅留痕"(不改目标描述)→ 只用 `note`。**拿不准就 `append`**(落进正文 | ||
| 最不易丢),纯 `note` 只用于确认/过程性话。append 只传正文、勿自带 `## 标题`(见 #7)。 | ||
| 7. **目标内容体现最终修订**:负责人的补充与修正用 `graph_amend_goal` 记录,并把 | ||
| 最终版写进目标描述——后续执行者读到的是最终版,不是初版; | ||
| **append 用法(防重复小节)**:补描述用 `graph_amend_goal(append=...)` 时 | ||
| **append 只传正文内容,绝不自带「## 目标描述」等标题**——amendGoal 内部已并入 | ||
| 目标描述小节;自带标题会生成重复小节,看板取第一个(占位)显示为「待填写」; | ||
| 8. **时间纪律**:不做长时间探索/调研/编写,充分利用 dsh-graph 把工作派下去—— | ||
| 信息收集、功能开发、手册编写都交给子代理;自己动手仅限短平快决策/小修; | ||
| 9. **负责人直接干预子代理时不插手**:负责人正通过 checklist 💬 反馈或直接会话指挥 | ||
| 某子代理时,supervisor 不重复派活、不打断、不代判——等该子代理完成既定目标并 | ||
| 自行 `graph_report_status` 汇报后,再回到 review 关口做判据核验; | ||
| 10. **`graph_amend_goal` 的 note vs append**: | ||
| - `note`(必填):修订备注,只记 `goal.amended` 事件,**不写进目标描述正文**—— | ||
| 用于轻量/过程性/跨目标备注; | ||
| - `append`(可选):**写进目标描述正文**——用于影响目标范围/需求/设计、执行者 | ||
| 与看板应读到的**内容**(需求、反馈、设计方案、约束、决策理由); | ||
| - 判定:凡属「目标自身内容」(要落实、要被执行者/看板看到)→ `append`;凡属 | ||
| 「过程/事件/仅留痕」→ 只用 `note`。**拿不准就 `append`**。append 只传正文、 | ||
| 勿自带 `## 标题`(见 #7)。 | ||
| ## 阶段推进规范 | ||
| ## 阶段推进 | ||
| 卡片在看板上的横向位置由你**主动推进**——每到阶段边界立即调用 | ||
| `graph_transition` 移动卡片,绝不让状态滞留(看板列=状态的投影,滞留即 | ||
| 对负责人撒谎): | ||
| 卡片在看板上的横向位置由你**主动推进**——每到阶段边界立即调用 `graph_transition` | ||
| 移动卡片,绝不让状态滞留(看板列=状态的投影,滞留即对负责人撒谎): | ||
| 1. **描述完成**(建卡、修订落定、范围明确)→ `draft→planning`; | ||
| 有信息要收集 → `planning→collecting`,卡片移入"收集"列; | ||
| **无收集需求(调研结论已在描述/凭常识可做)→ `planning→ready` 直达**, | ||
| 1. **描述完成**(建卡、修订落定、范围明确)→ `draft→planning`;有信息要收集 → | ||
| `planning→collecting`,卡片移入「收集」列;**无收集需求 → `planning→ready` 直达**, | ||
| 不为走流程而收集(收集不是形式主义); | ||
| 2. **收集完成**(上下文卡片全部 filled/reviewed)→ `collecting→ready`; | ||
| 判据登记并经负责人确认后 → **先询问负责人同意**(负责人 2026-08-22 指示: | ||
| 进 `in_progress` 前必须征得同意,除非自动授权模式),同意后 | ||
| `ready→in_progress`,卡片移入"执行"列,同时派发执行 attempt | ||
| (进 in_progress 的判据门禁由引擎强制); | ||
| 3. **执行方声明完成** → `in_progress→review`,卡片移入"确认"列, | ||
| 停轮等人工审核; | ||
| 2. **收集完成**(上下文卡片全部 filled/reviewed)→ `collecting→ready`;判据登记并 | ||
| 经负责人确认后 → **先询问负责人同意**(除非自动授权模式),同意后 | ||
| `ready→in_progress`,同时派发执行 attempt(判据门禁由引擎强制); | ||
| 3. **执行方声明完成** → `in_progress→review`,卡片移入「确认」列,停轮等人工审核; | ||
| 4. **负责人 verdict**:通过 → `review→delivered`;较大返工或新范围打回 → | ||
| `review→in_progress` 并开新 attempt(不沿用失败 attempt)。 | ||
| **review 期间一旦子代理收到反馈重新开始改动/修 bug,立即把卡片 | ||
| `review→in_progress` 放回执行 lane**——看板必须反映"正在改动"的事实。 | ||
| 对同一 goal 的小范围 review 缺陷,优先复用原执行 Agent 的既有 attempt 会话, | ||
| 以 `send_message` 发送精确修复反馈;不必新建 attempt,也不必新增 context card。 | ||
| 此例外仅适用于已有 Agent 的后续返工,不改变新 child 的首次主要任务、边界与 | ||
| 验收必须在 spawn 前进入初始 prompt 的要求;较大返工或新范围仍遵循既有负责人 | ||
| gate/新 attempt 政策。改完重新声明完成再回 review;打回重做才开新 attempt; | ||
| **review 期间子代理收到反馈重新开始改动/修 bug,立即把卡片 `review→in_progress` | ||
| 放回执行 lane**——看板必须反映「正在改动」的事实。同 goal 小范围 review 缺陷, | ||
| 优先复用原执行 Agent 的既有 attempt 会话,用 `send_message` 发送精确修复反馈; | ||
| 不必新建 attempt,也不必新增 context card。此例外仅适用于已有 Agent 的后续返工, | ||
| 不改变新 child 首次主要任务、边界与验收必须在 spawn 前进入初始 prompt 的要求; | ||
| 较大返工或新范围仍遵循既有负责人 gate/新 attempt 政策; | ||
| 5. 任何阶段受阻 → `→blocked` 必须带具体 reason;解除只能回到 `blocked_from`。 | ||
| ### Review strictness calibration(项目专属) | ||
| ### Review 严格度校准(项目专属) | ||
| Review 严格度**不是全局默认值**。首次初始化/接手一个项目时(最迟在首次实质技术复核前),supervisor 应请负责人明确该项目的 review 原则:威胁/信任模型(本地可信或多用户/对抗);必须阻断的类别(判据、正常流程、数据丢失、输入错误);对 legacy/畸形可选数据的防御性兼容要求;所需证据(测试、代码审查、UI smoke、生成工件);并发与崩溃恢复预期;以及集成/合并纪律。将负责人的回答写入该项目持久的 goal/supervisor memory,并在后续 review 中据此执行;**不得把本项目的选择硬编码为通用规则**。 | ||
| Review 严格度**不是全局默认值**。首次初始化/接手一个项目时(最迟在首次实质技术复核 | ||
| 前),请负责人明确该项目的 review 原则:威胁/信任模型(本地可信或多用户/对抗);必须 | ||
| 阻断的类别(判据、正常流程、数据丢失、输入错误);对 legacy/畸形可选数据的防御性兼容 | ||
| 要求;所需证据(测试、代码审查、UI smoke、生成工件);并发与崩溃恢复预期;以及集成/ | ||
| 合并纪律。将负责人的回答写入该项目持久的 goal/supervisor memory,并在后续 review 中 | ||
| 据此执行;**不得把本项目的选择硬编码为通用规则**。 | ||
| 6. **交付前置(负责人 2026-08-22 定,2026-08-23 细化 commit 归属):到 delivered 的目标,其改动必须已 git commit**—— | ||
| 但**区分谁、何时 commit**: | ||
| - **worktree 开发**(隔离分支):子代理在 worktree 内 commit OK;supervisor 复核通过后 | ||
| merge/`git checkout --` 到 main(只合代码,别重置 `.dsh-graph`)。 | ||
| - **直接 main 开发**:子代理**不提前 commit**——review 还会修 bug,提前提交会产生碎 | ||
| 提交/与后续修改冲突。正确:子代理把**改动留在工作树不提交**,supervisor 复核+ | ||
| 修完 bug 后,**统一提交一个最终 commit**(交付前置:delivered 前该目标改动已落库)。 | ||
| - 即:commit 由 supervisor 在交付前**统一收口**;子代理无需(也不应)在 main 上抢提交。 | ||
| 6. **交付前置**:到 delivered 的目标,其改动必须已 git commit——但**区分谁、何时 | ||
| commit**: | ||
| - **worktree 开发**(隔离分支):子代理在 worktree 内 commit OK;supervisor | ||
| 复核通过后 merge/`git checkout --` 到 main(只合代码,别重置 `.dsh-graph`); | ||
| - **直接 main 开发**:子代理**不提前 commit**——review 还会修 bug,提前提交会 | ||
| 产生碎提交/与后续修改冲突。正确:改动留在工作树不提交,supervisor 复核+修完 | ||
| bug 后,**统一提交一个最终 commit**; | ||
| - 即 commit 由 supervisor 在交付前**统一收口**;子代理无需(也不应)在 main 上 | ||
| 抢提交。 | ||
| 要点:状态迁移一律走工具(事件先行,R-02),**绝不手改 frontmatter 状态 | ||
| 字段**;判据确认与 review verdict 是人工 gate,停轮等输入,不用自动续轮 | ||
| 冲过去。 | ||
| 要点:状态迁移一律走工具(事件先行),**绝不手改 frontmatter 状态字段**;判据确认 | ||
| 与 review verdict 是人工 gate,停轮等输入,不用自动续轮冲过去。 | ||
| ## 信息收集规范 | ||
| ## 信息收集 | ||
| 收集项即上下文卡片,一张卡一个收集任务。 | ||
| 收集项即上下文卡片,一张卡一个收集任务。**前提:需求描述已定稿(目标离开描述阶段)** | ||
| ——描述未完成前不列收集清单、不建上下文卡片、不派发收集子代理(需求可能变,提前收集 | ||
| 是浪费): | ||
| **前提:需求描述已定稿(目标离开描述阶段)。** 描述未完成前不列收集清单、 | ||
| 不建上下文卡片、不派发收集子代理——需求可能变,提前收集是浪费 | ||
| (负责人 2026-08-21 指示): | ||
| 1. `graph_add_card` 占位(empty)——只登记"需要哪方面的资料",不预设查什么、怎么查; | ||
| 1. `graph_add_card` 占位(empty)——只登记「需要哪方面的资料」,不预设查什么、怎么查; | ||
| 2. 派发收集子代理后**必须立即**用 `graph_bind_collect_card(goal, card, child_id[, parent_session_id])` | ||
| 把 child_id 绑定到卡片:卡片 → collecting,写 `child_id`/`parent_session_id`, | ||
| 记 `card.collecting` 事件(事件先行,R-02)——**未绑定即流程违规** | ||
| (g-118 教训:主管侧无绑定工具,只能写 tmp 探针脚本直调 core 补绑)。 | ||
| `parent_session_id` 的**权威来源是子代理会话文件头**: | ||
| `zstd -dc ~/.dsh/sessions/<工作区目录>/<child_id>/session.jsonl.zstd | head -1` | ||
| 的 `parentSession` 字段。工具缺省取当前会话 id(主管派发场景即主管会话, | ||
| 应与子代理会话头 parentSession 一致);不一致或补绑历史子代理时**显式传入 | ||
| 反查值**。**禁止按工作区+时间推断**——已翻车(发现#22,推断错会话导致 | ||
| ↗ 跳到新会话页); | ||
| 记 `card.collecting` 事件(事件先行)——**未绑定即流程违规**。 | ||
| `parent_session_id` 的**权威来源是子代理会话文件头**(`parentSession` 字段); | ||
| 工具缺省取当前会话 id(主管派发场景即主管会话),不一致或补绑历史子代理时**显式 | ||
| 传入反查值**。**禁止按工作区+时间推断**; | ||
| 3. 子代理产出回填:`graph_fill_card` 写全文 + 一句 `summary` → filled; | ||
| **summary 写法约束**:一句话要点式、**≤100 字左右**——看板子卡片摘要 | ||
| 默认折叠显示 2 行(超长截断+省略号,点击展开全文),长摘要会被截断、可读性差; | ||
| 细节写进 `text` 全文,不要把长文塞进 summary;能一句话讲清的要点才有资格做 | ||
| summary(源头减少长摘要,负责人 2026-08-22 UI 反馈); | ||
| 重要资料可 `graph_review_card` → reviewed。 | ||
| 调研类收集子代理任务范围要窄、纯文档读取为主,不做实机验证 | ||
| (反例:g-107 ev-01,宽范围调研产出空报告); | ||
| 4. 执行 attempt 启动时,按 `context_cards` 顺序把 filled/reviewed 卡片注入执行 | ||
| 子代理上下文,注入清单记入 `attempt.started` 的 `details.injected_cards`; | ||
| 5. 收集子代理输出简单干净时,**复用其会话续轮进入执行阶段**(缓存友好), | ||
| 不另开新会话。 | ||
| **summary ≤100 字左右**(看板子卡片默认折叠显示 2 行,超长截断+省略号)——细节 | ||
| 写进 `text` 全文,不要把长文塞进 summary;重要资料可 `graph_review_card` → reviewed。 | ||
| 调研类收集子代理任务范围要窄、纯文档读取为主,不做实机验证; | ||
| 4. 执行 attempt 启动时,按 `context_cards` 顺序把 filled/reviewed 卡片注入执行子代理 | ||
| 上下文,注入清单记入 `attempt.started` 的 `details.injected_cards`; | ||
| 5. 收集子代理输出简单干净时,**复用其会话续轮进入执行阶段**(缓存友好),不另开新会话。 | ||
| **会话复用政策**(负责人 2026-08-21 定):跨目标复用保持主管判断(宽松), | ||
| 但复用前应先 **fork 新子代理 + compact 上下文**——卡片绑定干净的新子代理 | ||
| (继承压缩后的上下文),原子代理留在原 turn 可续(后续可对原会话继续对话)。 | ||
| 机制:DSH `sessions.fork({sessionId, atSeq?})` 已存在(fork 携带源历史、 | ||
| 按 parentSessionId 嵌套谱系);主管侧的工具化路径(fork+compact 一步完成) | ||
| 待补,暂以手工/客户端 RPC 执行。 | ||
| **复用时必须改子代理名称**:fork 创建时设新 label(如 `graph:g-108/att-001`)—— | ||
| DSH 无 rename API(label 只在 spawn 时写入 descriptor),直复用改不了名, | ||
| 这是 fork 复用的又一理由。**原绑定卡片的实时代理要标记「被复用」**: | ||
| board 投影派生——child_id 被多个目标绑定 → 旧绑定显示「被复用→新目标」; | ||
| 复用时主动记 `attempt.reused` 事件(child_id、reused_by)作为派生数据。 | ||
| **会话复用政策**:跨目标复用保持主管判断(宽松),但复用前应先 **fork 新子代理 + | ||
| compact 上下文**——卡片绑定干净的新子代理(继承压缩后的上下文),原子代理留在原 turn | ||
| 可续。**复用时必须改子代理名称**(fork 创建时设新 label,DSH 无 rename API); | ||
| **原绑定卡片的实时代理要标记「被复用」**(child_id 被多个目标绑定 → 旧绑定显示 | ||
| 「被复用→新目标」),并主动记 `attempt.reused` 事件(child_id、reused_by)。 | ||
| **何时复用、何时新开**(指导原则): | ||
| - **默认新开**——不信任复用,除非有明确收益;新目标、新领域、 | ||
| 与既有会话无关的任务一律新派; | ||
| - **复用的唯一正当理由:上下文是难以重建的生产资料**。两种典型: | ||
| ① 同一工件的直接延续(如在前一目标作者会话上继续改同一文件, | ||
| 组件知识、负责人多轮 review 的偏好都在上下文里); | ||
| - **默认新开**——不信任复用,除非有明确收益;新目标、新领域、与既有会话无关的 | ||
| 任务一律新派; | ||
| - **复用的唯一正当理由:上下文是难以重建的生产资料**。两种典型:① 同一工件的 | ||
| 直接延续(继续改同一文件,组件知识与负责人多轮 review 偏好都在上下文里); | ||
| ② 同目标内收集→执行续轮(调研结论刚在上下文里,重读即浪费); | ||
| - **评审/验证角色永用新人**——复核者对被审代码必须无作者偏见; | ||
| - **会话已长/杂/带失败史时宁开新**:上下文膨胀与误导内容的成本 | ||
| 高于重建上下文的成本; | ||
| - 决定复用 → 必须走 fork+compact(上条政策);判断不确定时新开, | ||
| 宁可损失缓存,不可损失干净。 | ||
| - **会话已长/杂/带失败史时宁开新**:上下文膨胀与误导内容的成本高于重建上下文的成本; | ||
| - 决定复用 → 必须走 fork+compact;判断不确定时新开,宁可损失缓存,不可损失干净。 | ||
| ## 执行规范 | ||
| - **主管自报状态(每轮开始立即 + 持续更新)**:supervisor 自己也要用 | ||
| `graph_report_supervisor_status` 在**每轮开始的第一时间**报一句最新状态, | ||
| 覆盖上一轮残留——否则看板顶部会长时间显示过期 status(负责人 2026-08 指出)。 | ||
| 客户端已有过期清空机制(新一轮 running 翻转时旧状态过期,状态行显示 | ||
| **状态延续时长**——statusAt 距今多久,g-124),主管应尽快替换; | ||
| **不止轮首**:每完成一个动作/阶段变化(派发执行、收集回填、复核结论、 | ||
| 提交推送、状态迁移、等负责人输入时)都立即更新一句——与执行子代理 | ||
| 「每做一个动作就写一句」的标准对等(负责人 2026-08-22 指出:大部分时间 | ||
| 没更新 status line,看板顶部长期显示过期状态)。等人工输入的空窗期也要 | ||
| 报「正在等 X」,让负责人知道你没卡死; | ||
| - **每轮收尾更新为完成态(负责人 2026-08-22 指示)**:**结束工作前**(每轮 | ||
| 收尾、即将空闲/等待输入)最后一步用 `graph_report_supervisor_status` 把 | ||
| status 更新为「空闲待命 / 本轮完成 / 等待输入」等完成态——避免实际已空闲 | ||
| 但看板仍显示「正在做 X」的错位,看板如实反映空闲/完成状态; | ||
| - **自报状态(每轮开始立即 + 持续更新)**:supervisor 自己也要用 | ||
| `graph_report_supervisor_status` 在**每轮开始的第一时间**报一句最新状态,覆盖上一轮 | ||
| 残留——否则看板顶部长时间显示过期 status。**不止轮首**:每完成一个动作/阶段变化 | ||
| (派发执行、收集回填、复核结论、提交推送、状态迁移、等负责人输入时)都立即更新一句; | ||
| 等人工输入的空窗期也要报「正在等 X」,让负责人知道你没卡死; | ||
| - **每轮收尾更新为完成态**:结束工作前最后一步把 status 更新为「空闲待命 / 本轮完成 / | ||
| 等待输入」等完成态——看板如实反映空闲/完成状态; | ||
| - `graph_start_attempt` 派发执行;**status_line 由执行子代理自己更新** | ||
| (`graph_report_status`),**supervisor 绝不替子代理汇报**——卡片上那句话 | ||
| 是子代理的自述,代劳即伪造进展(spawn 提示词模板已内联更新方法,见 | ||
| host/index.js)。要求子代理**及时**更新:每做一个动作就写一句, | ||
| **简短(一句人话,尽量 20 字内)描述此刻在干什么**,不攒到结束、不写长篇; | ||
| - **泳道迁移由执行子代理自己调整**(spawn 提示词模板已内联 graph_transition | ||
| 指令:开工→in_progress、完成→review、阻塞→blocked):supervisor **不要 | ||
| 替子代理代劳 transition**——看板列=状态的投影,子代理不主动移卡即状态 | ||
| 滞留(负责人 2026-08 指出)。子代理迁移被引擎拒绝(判据未登记等)时它 | ||
| 会保留 status 汇报继续工作,supervisor 只需在复核时把关状态与产出一致; | ||
| **执行派发自动落执行 lane(负责人 2026-08-22 补充)**:`graph_start_attempt` | ||
| 工具与 GUI「执行」按钮派发成功后**自动 transition 到 in_progress**(引擎层 | ||
| start-execution/工具已内置,避免子代理漏移、目标滞留收集/ready lane); | ||
| 若迁移被拒(门槛未满足),supervisor 复核时注意把关; | ||
| **禁区:执行子代理不得自移 `review→delivered`**——delivered 是 human gate, | ||
| 只有负责人 verdict 通过后由 supervisor 执行(g-112 教训:执行方误把 | ||
| 「主管技术复核通过」当「确认交付」自移 delivered,已记录并追认)。 | ||
| (`graph_report_status`),**supervisor 绝不替子代理汇报**——卡片上那句话是子代理的 | ||
| 自述,代劳即伪造进展。要求子代理**及时**更新:每做一个动作就写一句,**简短 | ||
| (一句人话,尽量 20 字内)**,不攒到结束、不写长篇; | ||
| - **子代理等待与中断纪律**:派发子代理后,supervisor **不要长时间 think/poll 等待**, | ||
| 更不能**仅因等待就中断子代理**。要给子代理足够工作时间时,可启动**受管定时/后台任务 | ||
| 脚本**后**回到空闲待命**,继续处理其他独立事项。子代理完成后**会主动注入上下文回报**, | ||
| 因此**不要忙等、不要反复 `list_agents` 轮询、不要在其运行期间提前 `send_message`、 | ||
| 不要无具体原因 `interrupt`**。**只有**在**具体阻塞**(卡死/反复失败/异常终止)、 | ||
| **安全风险**或**任务已失效**(目标取消/范围作废)时才中断,且**必须说明中断理由**; | ||
| - **泳道迁移由执行子代理自己调整**(spawn 提示词已内联 graph_transition 指令): | ||
| supervisor **不要替子代理代劳 transition**——看板列=状态的投影,子代理不主动移卡 | ||
| 即状态滞留。子代理迁移被引擎拒绝(判据未登记等)时它会保留 status 汇报继续工作, | ||
| supervisor 只需在复核时把关状态与产出一致; | ||
| **执行派发自动落执行 lane**:`graph_start_attempt` 工具与 GUI「执行」按钮派发成功后 | ||
| **自动 transition 到 in_progress**(引擎内置,避免子代理漏移);若迁移被拒(门槛未 | ||
| 满足),supervisor 复核时注意把关; | ||
| **禁区:执行子代理不得自移 `review→delivered`**——delivered 是 human gate,只有 | ||
| 负责人 verdict 通过后由 supervisor 执行; | ||
| - **派发提示词规范(防找错文件)**: | ||
| - 目标描述、判据、范围要点**全文内联**进提示词,不让子代理自己去读 | ||
| goal.md(目标目录是 slug/连号混排,子代理猜路径必踩坑——发现: | ||
| 子代理读 `.dsh-graph/g-a92e1406/goal.md` 不存在); | ||
| - 必须引用的文件给**工作目录相对精确路径**(含 versions/vX.Y/goals/ | ||
| 前缀),禁止"自己去找到 goal.md"式指令; | ||
| - 目标描述、判据、范围要点**全文内联**进提示词,不让子代理自己去读 goal.md | ||
| (目标目录是 slug/连号混排,子代理猜路径必踩坑); | ||
| - 必须引用的文件给**工作目录相对精确路径**(含 versions/vX.Y/goals/ 前缀),禁止 | ||
| 「自己去找到 goal.md」式指令; | ||
| - 冻结脚本路径、验收命令逐条写全; | ||
| - **worktree 隔离(负责人 2026-08-22 指示,含 2026-08-22 二次强化)**:并发/复杂的 | ||
| 执行任务,子代理宜先 `git worktree add` 独立工作树(与 main 隔离)再改代码, | ||
| review 交付阶段由 supervisor 复核通过后合并回 main——避免并发子代理互相踩提交、 | ||
| 避免半成品直接落 main。**「直接 main」仅限真正的一两行、且是唯一改动的文件、且 | ||
| 无其他目标并发改该文件**(负责人 2026-08-22 二次强化:g-129 与 g-77647351 并发改 | ||
| client.js 都直接 main,造成分叉冲突、merge 地狱——**多目标并发改同一文件时,子代理 | ||
| 必须 worktree**,不得因「自认为改动简单」而直接 main); | ||
| worktree 指令(g-120)由执行派发默认注入 spawn 提示词,可显式关闭跳过: | ||
| `graph_start_attempt` 传 `worktree=false`、GUI 端点 | ||
| `/api/dsh-graph/start-execution` 传 body `worktree: false`; | ||
| 数据分工:代码改动在 worktree,看板数据 `.dsh-graph/` 仍在主工作树写 | ||
| (graph_* 工具写的是主工作树的看板/事件流,不被 worktree 分支隔离); | ||
| - **只在仓库根跑 graph_* 工具(负责人 2026-08-22;g-149 修订)**:执行/调研子代理务必以 | ||
| **仓库根**为工作目录跑 graph_* 工具,**绝不在包目录(如 `dsh-graph-host/`)下跑**—— | ||
| 否则工具会按会话 cwd 在包目录自动 init 出一个 `.dsh-graph/` 骨架(「子代理误建数据 | ||
| 目录」的已知问题,曾多次清理)。父仓库 `.gitignore` 以 `**/.dsh-graph/` 通配规则 | ||
| 防止任何子目录的 `.dsh-graph` 被 `git add -A` 收集(覆盖包目录、子 Agent cwd、 | ||
| linked worktree 等所有场景),但会弄乱工作区——子代理应统一在仓库根的 `.dsh-graph/` | ||
| 读写看板数据;supervisor 派发时若发现子代理 cwd 落在包目录,及时纠正。 | ||
| **禁止** supervisor 或子 Agent 使用 `git add -f`、`git rm --cached` 等方式把 | ||
| `.dsh-graph` 数据重新纳入父代码仓库 Git——数据归内层独立仓库管理,迁移由 | ||
| `scripts/migrate-dsh-graph-repo.sh --apply` 显式执行。 | ||
| - **worktree 隔离**:并发/复杂的执行任务,子代理先 `git worktree add` 独立工作树 | ||
| (与 main 隔离)再改代码,review 交付阶段由 supervisor 复核通过后合并回 main—— | ||
| 避免并发子代理互相踩提交、半成品直接落 main。**「直接 main」仅限真正的一两行、 | ||
| 唯一文件改动、且无其他目标并发改该文件;多目标并发改同一文件时必须 worktree**, | ||
| 不得自认为改动简单就直改 main; | ||
| worktree 指令由执行派发默认注入 spawn 提示词,可显式关闭:`graph_start_attempt` | ||
| 传 `worktree=false`、GUI 端点 `/api/dsh-graph/start-execution` 传 body | ||
| `worktree: false`; | ||
| 数据分工:代码改动在 worktree,看板数据 `.dsh-graph/` 仍在主工作树写(graph_* | ||
| 工具写的是主工作树的看板/事件流,不被 worktree 分支隔离); | ||
| - **只在仓库根跑 graph_* 工具**:执行/调研子代理务必以**仓库根**为工作目录跑 | ||
| graph_* 工具,**绝不在包目录(如 `dsh-graph-host/`)下跑**——否则工具会按会话 cwd | ||
| 在包目录自动 init 出一个 `.dsh-graph/` 骨架,弄乱工作区。**禁止**用 `git add -f`、 | ||
| `git rm --cached` 等方式把 `.dsh-graph` 数据纳入父仓库 Git——数据归内层独立仓库管理, | ||
| 迁移由 `scripts/migrate-dsh-graph-repo.sh --apply` 显式执行; | ||
| - **模型路由**:执行子代理**不继承父会话模型**——统一走 project.yaml 的 | ||
| `executor.provider/model`(当前 deepseek-official/deepseek-v4-flash), | ||
| `graph_start_attempt` 的 provider/model 参数可临时覆盖;路由结果显示在 | ||
| 返回的 `model_route` 字段。背景:默认路由曾把子代理打到余额不足的 | ||
| newapi-aseit(403 insufficient_user_quota 空失败,负责人指正); | ||
| `executor.provider/model`,`graph_start_attempt` 的 provider/model 参数可临时覆盖; | ||
| 路由结果显示在返回的 `model_route` 字段; | ||
| - 完成声明 ≠ 交付:声明后进入 review,默认人工审;不通过则打回开新 attempt; | ||
| - **复核纪律(逐行对照,不信脚本 PASS)**:子代理声明「完成/修复」后,supervisor | ||
| 复核时**逐行读最终代码、逐条件分支验证声明的行为是否真实现**——脚本 PASS 是必要 | ||
| 非充分(教训:att-001 声明与代码不符、att-004 越权修掉 3 个真缺陷;check 脚本 | ||
| grep 标记抓不到行为回归)。验证前 **sleep 2s 等文件写入稳定**,避免瞬时误报; | ||
| - 验收脚本(判据中的 `[script]` 项)由规划方在 planning 时冻结(R-03), | ||
| 执行方不得修改;脚本报错优先怀疑实现与设计,不是脚本。 | ||
| - **发现排期/归属变化先查事件 actor(负责人 2026-08-23)**:supervisor 发现目标被移动/改排期 | ||
| (backlog↔版本↔独立变化)时,**先看该卡片 `goal.moved` / `goal.transition` 事件的 actor**—— | ||
| 若为 `human:gui`(负责人 GUI 操作),说明是负责人刻意为之,**不要刻意恢复/纠正**,按新归属为准; | ||
| 只有非用户改动且与设计冲突时才复核/纠正。先核实再行动(g-126 教训:别只看表面变化就断言并动手)。 | ||
| 非充分。验证前 **sleep 2s 等文件写入稳定**,避免瞬时误报; | ||
| - 验收脚本(判据中的 `[script]` 项)由规划方在 planning 时冻结,执行方不得修改; | ||
| 脚本报错优先怀疑实现与设计,不是脚本; | ||
| - **发现排期/归属变化先查事件 actor**:发现目标被移动/改排期时,**先看该卡片 | ||
| `goal.moved` / `goal.transition` 事件的 actor**——若为 `human:gui`(负责人 GUI 操作), | ||
| 是负责人刻意为之,**不要刻意恢复/纠正**,按新归属为准;只有非用户改动且与设计冲突 | ||
| 时才复核/纠正。先核实再行动。 | ||
| ## 环境事实与排查 | ||
| ## 环境事实与排查(必读,来自历次翻车) | ||
| - **本地 dev 的 root 覆盖必须用相对值 `.dsh-graph`**:绝对路径会被 | ||
| `path.resolve(workspace, config.root)` 顶掉、破坏 workspace 跟随(host/client | ||
| 两半都踩过);发布包 bundle patch 本就是相对值,无此问题。 | ||
| - **sessions 列表条目 `cwd` 不可靠**(DSH 源码 `...entry.cwd !== void 0 ? {cwd}:{}`): | ||
| 取当前会话 workspace 用 **workspaces 服务** `workspaces.list.getSnapshot().items.find(w => w.sessionIds.includes(sid))?.path`。 | ||
| `path.resolve(workspace, config.root)` 顶掉、破坏 workspace 跟随; | ||
| - **sessions 列表条目 `cwd` 不可靠**:取当前会话 workspace 用 **workspaces 服务** | ||
| `workspaces.list.getSnapshot().items.find(w => w.sessionIds.includes(sid))?.path`; | ||
| - **冻结脚本 SIGPIPE 竞态**:`awk '…' | grep -q '…'` 在 `set -o pipefail` 下 grep 提前 | ||
| 退出会让 awk 被 SIGPIPE、间歇 FAIL;管道里改 `grep "…" >/dev/null`(读完再退)。 | ||
| 退出会让 awk 被 SIGPIPE、间歇 FAIL;管道里改 `grep "…" >/dev/null`(读完再退); | ||
| - **子代理「空失败」排查**:`zstd -dc ~/.dsh/sessions/<项目key>/<child_id>/session.jsonl.zstd | tail` | ||
| 看末行 `turn/end` 的 error(常见 403 余额不足 / no adapter / 限流)。 | ||
| 看末行 `turn/end` 的 error(常见 403 余额不足 / no adapter / 限流); | ||
| - **子代理 spawn 两个 provider 概念别混**:subagent provider(spawn/fork,选带 | ||
| prepareContinuable 能力的)≠ LLM provider(agentOptions,用户可选);找不到 | ||
| subagent provider 时明确报错列已注册名,绝不回退字面量 "spawn"。 | ||
| - **改 host 插件代码后必须重启 dsh web 服务才生效**:运行中的服务进程持有 | ||
| 启动时加载的插件内存快照,profile 即使 link 到本地工作树,新注册的 graph_* | ||
| 工具/端点在新会话里也看不到(g-117 复核:新会话列工具只有 14 个、缺 | ||
| graph_claim_supervisor,重启后 16 个齐全)。验证工具可见性前先确认服务 | ||
| 重启过。 | ||
| subagent provider 时明确报错列已注册名,绝不回退字面量 "spawn"; | ||
| - **改 host 插件代码后必须重启 dsh web 服务才生效**:运行中的服务进程持有启动时加载 | ||
| 的插件内存快照;验证工具可见性前先确认服务重启过。 | ||
@@ -291,14 +237,11 @@ ## 工具速查 | ||
| ## 换会话(g-117:一键交接) | ||
| ## 换会话 | ||
| 换会话不再是手改 project.yaml + 手写 HANDOFF.md: | ||
| 1. **旧会话交接**:`graph_handoff` —— 自动生成/更新 `.dsh-graph/HANDOFF.md`(board | ||
| 投影 + 长期记忆 + 关键环境事实段),产物不依赖会话上下文; | ||
| 2. **新会话接手**:`graph_claim_supervisor` —— 把 project.yaml 的 `supervisor.session` | ||
| 更新为当前会话 id、记 `supervisor.claimed` 事件(幂等:重复调用不重复记),并把 | ||
| HANDOFF 全文作为返回值直接注入上下文(无需再读文件)。看板顶部主管栏读 | ||
| `readSupervisorSession`,claim 后立即指向新会话。 | ||
| 1. **旧会话交接**:`graph_handoff` —— 自动生成/更新 `.dsh-graph/HANDOFF.md` | ||
| (board 投影 + 长期记忆 + 关键环境事实段),产物不依赖会话上下文; | ||
| 2. **新会话接手**:`graph_claim_supervisor` —— 把 project.yaml 的 | ||
| `supervisor.session` 更新为当前会话 id(ex.agent.session 链)、记 | ||
| `supervisor.claimed` 事件(幂等:重复调用不重复记),并把 HANDOFF 全文作为 | ||
| 返回值直接注入上下文(无需再读文件)。看板顶部主管栏读 | ||
| `readSupervisorSession`(现读),claim 后立即指向新会话。 | ||
| ## 沉淀 | ||
@@ -305,0 +248,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
959375
38.46%31
10.71%17401
35.13%9
-10%4
Infinity%93
24%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added