iterate-plugin 3.13.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,6 +114,7 @@ Both modes share:
114
114
  - **ProgressCapsule** (`shell.overlay`) — popup notification on each round completion / convergence (incl. convergence confirm).
115
115
  - **SettingsPanel** (`settings.section`) — theme toggle, triage-persistence notes, config-management guide (covers `validation.commands` / `language` / `personalization.known_intentional` with an explicit "empty validation.commands = unprotected" warning; a field picker generates the matching `iterate_config write` instruction), **v3.7: a 回读当前配置 (read back current config) button that closes the write → read-back loop** — it copies an `iterate_config read` instruction and, once the model echoes the result into the session, shows the current value of the selected field, runtime status overview (artifact layout + view/cleanup tool guide + disk-snapshot pull), one-click triage data reset.
116
116
  - **Bilingual UI (v3.7)** — every piece of UI chrome (panels, empty states, tabs, badges, settings, stop-reason and completion labels) ships in 简体中文 and English. Pick the language under **Settings → 界面语言 / Interface language**; the choice persists in `localStorage` (`iterate-ui-lang`) and otherwise auto-detects from `navigator.language` (a stored choice always wins, and auto-detection never writes a preference). **Model-facing copy deliberately stays Chinese** — the `build*Instruction` payloads and the copy-paste guides are contract text mirroring `SKILL.md`, so the two expanded guides carry a 「中文(粘贴给模型的契约文案)」 chip instead of looking like a missed translation.
117
+ - **Right-click menu (v3.14, dsh ≥ 0.2.2-alpha.3)** — the iterate entry points now also hang off the **native unified context menus**: right-click a **Session** in the sidebar to `打开 iterate 质量指挥中心` (expands the docked observatory and jumps to the live stream), copy the preflight / dry-run / checkpoint-resume / disk-snapshot instructions, or right-click a **file** (with line) / **directory** to copy a *scoped* dry-run review instruction. Bilingual labels, copy-failure recovery guidance, and a shared success toast; a host without `contextMenus` degrades to no menu items (the docked panel remains the entry point).
117
118
 
118
119
  The UI layer is **defensive by design**: it degrades gracefully if any of `slots` / `theme` / `React` is unavailable — it never crashes the client.
119
120
 
@@ -346,14 +347,13 @@ All runtime state lives under `.iterate/` at the project root (can be excluded v
346
347
  `0.1.7-alpha.1`, `0.1.7-alpha.2`, `0.1.7-rc.1`, `0.1.7-rc.2`, `0.2.0-rc.1`,
347
348
  `0.2.0-rc.2`, `0.2.1-alpha.1`, `0.2.1-alpha.2`, `0.2.1-alpha.3`,
348
349
  `0.2.1-alpha.4`, `0.2.1-alpha.5`, `0.2.1-rc.1`, `0.2.1-rc.2`,
349
- `0.2.1-rc.3`, `0.2.1-rc.4`, `0.2.1-rc.5`, `0.2.2-alpha.2` (28 releases,
350
- `0.1.1-rc.1` …
351
- `0.2.2-alpha.2`).
350
+ `0.2.1-rc.3`, `0.2.1-rc.4`, `0.2.1-rc.5`, `0.2.2-alpha.2`,
351
+ `0.2.2-alpha.3` (29 releases, `0.1.1-rc.1` … `0.2.2-alpha.3`).
352
352
  The plugin is built against `@deepseek-ai/dsh-tools` /
353
- `@deepseek-ai/dsh-util-values` `0.2.2-alpha.2` and uses only public contracts
353
+ `@deepseek-ai/dsh-util-values` `0.2.2-alpha.3` and uses only public contracts
354
354
  (tool registration, client slots + theme, bundle patch, and — since v3.9 —
355
355
  the session state-record API for session-linked run records).
356
- It also declares `engines.dsh` (`>=0.1.1-rc.1`) so the dsh ≥ 0.2.2-alpha.2
356
+ It also declares `engines.dsh` (`>=0.1.1-rc.1`) so the dsh ≥ 0.2.2-alpha.3
357
357
  runtime evaluates compatibility from that single range (earlier runtimes fall
358
358
  back to the `@deepseek-ai/dsh-*` peer versions listed in `package.json`).
359
359
  - **Disposable-Profile evidence (real, `dsh` CLI `0.1.1-rc.1`)**: on a temp
package/README.zh-CN.md CHANGED
@@ -113,6 +113,7 @@ dsh plugin --profile web add iterate-plugin
113
113
  - **进度胶囊 `ProgressCapsule`**(`shell.overlay`)— 每轮完成 / 收敛时右下角弹出通知(含收敛确认)。
114
114
  - **iterate 设置区 `SettingsPanel`**(`settings.section`)— 主题开关、分诊持久化说明、配置管理指引(覆盖 `validation.commands` / `language` / `personalization.known_intentional`,并显式警告"validation.commands 为空 = 本轮不受任何测试保护";字段选择器可生成对应的 `iterate_config write` 指令),**v3.7:新增「回读当前配置」补齐写入 → 回读闭环**——复制 `iterate_config read` 指令,模型把结果回显进会话后,卡片就地展示所选字段的当前值;运行时状态概览(产物布局 + 查看/清理工具指引 + 磁盘快照拉取)、一键清空分诊数据。
115
115
  - **双语 UI(v3.7)**——所有界面文案(面板、空态、页签、徽标、设置、停止原因与完成汇总)均提供简体中文与 English。在**设置 → 界面语言 / Interface language**切换;选择持久化在 `localStorage`(`iterate-ui-lang`),否则按 `navigator.language` 自动探测(已保存的选择始终优先,且自动探测不会写入偏好)。**面向模型的文案有意保持中文**——`build*Instruction` 指令与可粘贴指引是与 `SKILL.md` 对齐的契约文本,因此两处展开的指引带「中文(粘贴给模型的契约文案)」标签,避免被误当成漏翻。
116
+ - **右键菜单(v3.14,dsh ≥ 0.2.2-alpha.3)**——iterate 入口现在也挂在**原生统一右键菜单**上:右键侧边栏的**会话**可 `打开 iterate 质量指挥中心`(展开停靠观测台并跳到实时流)、复制预检 / dry-run / 断点恢复 / 磁盘快照指令;右键**文件**(带行号)/ **目录**可复制**定向** dry-run 审查指令(仅审查该文件/目录)。双语标签、复制失败的自救指引、共享成功 toast;宿主无 `contextMenus` 时降级为无菜单项(停靠面板仍是入口)。
116
117
 
117
118
  UI 层为**防御式设计**:`slots` / `theme` / `React` 任一不可用时自动降级,不会崩溃客户端。
118
119
 
@@ -336,7 +337,7 @@ validation:
336
337
  ### Node.js / DSH 兼容范围
337
338
 
338
339
  - **Node.js**:`>=20`(`package.json` 的 `engines.node`)。
339
- - **DSH**:`package.json` 的 `dsh.compatibility.dshReleases` 字段是发布兼容性的**唯一真源**(本 README 逐字镜像该字段;两者需同步更新)。以下官方版本声明为 `compatible`:`0.1.1-rc.1`、`0.1.2-alpha.4`、`0.1.2-alpha.5`、`0.1.2-rc.1`、`0.1.3-alpha.1`、`0.1.5-alpha.1`、`0.1.5-alpha.2`、`0.1.5-rc.1`、`0.1.5-rc.2`、`0.1.6-alpha.1`、`0.1.6-alpha.2`、`0.1.7-alpha.1`、`0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`、`0.2.1-alpha.2`、`0.2.1-alpha.3`、`0.2.1-alpha.4`、`0.2.1-alpha.5`、`0.2.1-rc.1`、`0.2.1-rc.2`、`0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.1-rc.5`、`0.2.2-alpha.2`(共 28 个版本,`0.1.1-rc.1` … `0.2.2-alpha.2`)。插件基于 `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.2.2-alpha.2` 构建,只使用公开契约(工具注册、客户端 slots + theme、bundle patch,以及 v3.9 起的会话状态记录接口)。插件同时声明 `engines.dsh`(`>=0.1.1-rc.1`),使 dsh ≥ 0.2.2-alpha.2 运行时只依据该范围判断兼容性(更早的运行时仍回退校验 `package.json` 中列出的 `@deepseek-ai/dsh-*` peer 版本)。
340
+ - **DSH**:`package.json` 的 `dsh.compatibility.dshReleases` 字段是发布兼容性的**唯一真源**(本 README 逐字镜像该字段;两者需同步更新)。以下官方版本声明为 `compatible`:`0.1.1-rc.1`、`0.1.2-alpha.4`、`0.1.2-alpha.5`、`0.1.2-rc.1`、`0.1.3-alpha.1`、`0.1.5-alpha.1`、`0.1.5-alpha.2`、`0.1.5-rc.1`、`0.1.5-rc.2`、`0.1.6-alpha.1`、`0.1.6-alpha.2`、`0.1.7-alpha.1`、`0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`、`0.2.1-alpha.2`、`0.2.1-alpha.3`、`0.2.1-alpha.4`、`0.2.1-alpha.5`、`0.2.1-rc.1`、`0.2.1-rc.2`、`0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.1-rc.5`、`0.2.2-alpha.2`、`0.2.2-alpha.3`(共 29 个版本,`0.1.1-rc.1` … `0.2.2-alpha.3`)。插件基于 `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.2.2-alpha.3` 构建,只使用公开契约(工具注册、客户端 slots + theme、bundle patch,以及 v3.9 起的会话状态记录接口)。插件同时声明 `engines.dsh`(`>=0.1.1-rc.1`),使 dsh ≥ 0.2.2-alpha.3 运行时只依据该范围判断兼容性(更早的运行时仍回退校验 `package.json` 中列出的 `@deepseek-ai/dsh-*` peer 版本)。
340
341
  - **一次性 Profile 证据(真实,`dsh` CLI `0.1.1-rc.1`)**:在临时 `$DSH_HOME` 下
341
342
  `dsh plugin --profile <p> add <本仓库>` 约 449ms 完成安装;`dsh --profile <p>
342
343
  --dump-config` 正确合成 `iterate-plugin` bundle patch;`dsh plugin --profile <p>
package/lib/client.js CHANGED
@@ -1,4 +1,4 @@
1
- /* iterate-plugin client source-digest: 909db487a440ae131a6de59f253a9d84b240e5fbd55bce9ae83a9876b711a9a2 */
1
+ /* iterate-plugin client source-digest: 5b22267e6de6fb0564d60874b3de1211755cdeb398f6c7ffa87dfa1007e6796f */
2
2
  window.__ModuleLoader__.load({ id: "iterate-plugin", factory: (require) => {
3
3
  var module = { exports: {} };
4
4
  var exports = module.exports;
@@ -1250,6 +1250,13 @@ var START_INSTRUCTIONS = {
1250
1250
  full: START_INSTRUCTION_FULL,
1251
1251
  reviewOnly: START_INSTRUCTION_REVIEW_ONLY
1252
1252
  };
1253
+ function buildScopedReviewInstruction(scope) {
1254
+ const hasFile = scope && typeof scope.file === "string" && scope.file.length > 0;
1255
+ const hasDir = !hasFile && scope && typeof scope.dir === "string" && scope.dir.length > 0;
1256
+ if (!hasFile && !hasDir) return "";
1257
+ const subject = hasFile ? `\u6587\u4EF6 \`${scope.file}\`${typeof scope.line === "number" && scope.line > 0 ? `\uFF08\u7B2C ${scope.line} \u884C\uFF09` : ""}` : `\u76EE\u5F55 \`${scope.dir}\``;
1258
+ return `\u5F00\u59CB\u4E00\u6B21\u4EC5\u8BC4\u5BA1\uFF08dry-run\uFF0C\u4E0D\u4FEE\u6539\u4EFB\u4F55\u6587\u4EF6\uFF09\uFF0C\u5BA1\u67E5\u8303\u56F4\u9650\u5B9A\u5728${subject}\uFF1A\u8BF7\u8C03\u7528 \`workflow\` \u5DE5\u5177\u8FD0\u884C iterate \u5DE5\u4F5C\u6D41\uFF0Cmode: "dry-run"\uFF0C\u6309\u7CFB\u7EDF\u63D0\u793A\u7684 Iterate Workflow \u6267\u884C plan \u2192 \u8BC4\u5BA1 \u2192 \u5206\u8BCA \u2192 \`iterate_review aggregate\` / \`meta-review\` \u7EC8\u62A5\u5E76 \`iterate_transcript\` capture\uFF1B\u8BC4\u5BA1\u65F6\u628A\u8303\u56F4\u9650\u5B9A\u5728${subject}\uFF0C\u5176\u4F59\u6587\u4EF6\u4EC5\u4F5C\u4E0A\u4E0B\u6587\u3001\u4E0D\u5F97\u7ED9\u51FA\u4FEE\u590D\u3002\u5168\u7A0B\u4E0D\u8981\u8C03\u7528 \`iterate_fix\` / \`iterate_rollback\`\u3002`;
1259
+ }
1253
1260
  function fixFindingPayload(finding) {
1254
1261
  const f = finding && typeof finding === "object" ? finding : {};
1255
1262
  const file = String(f.file ?? "");
@@ -2858,6 +2865,135 @@ function subscribeSession(listener) {
2858
2865
  };
2859
2866
  }
2860
2867
 
2868
+ // src/client/context-menu.ts
2869
+ var OBS_OPEN_EVENT = "iterate-plugin:open-observatory";
2870
+ function openObservatory(detail) {
2871
+ if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") return false;
2872
+ try {
2873
+ window.dispatchEvent(new CustomEvent(OBS_OPEN_EVENT, { detail: detail ?? {} }));
2874
+ return true;
2875
+ } catch {
2876
+ return false;
2877
+ }
2878
+ }
2879
+ var LABELS = {
2880
+ "iterate.open-panel": { zh: "\u6253\u5F00 iterate \u8D28\u91CF\u6307\u6325\u4E2D\u5FC3", en: "Open iterate command center" },
2881
+ "iterate.preflight": { zh: "\u590D\u5236\u5F00\u8DD1\u9884\u68C0\u6307\u4EE4", en: "Copy preflight instruction" },
2882
+ "iterate.dry-run": { zh: "\u590D\u5236 dry-run \u5BA1\u67E5\u6307\u4EE4", en: "Copy dry-run review instruction" },
2883
+ "iterate.resume": { zh: "\u590D\u5236\u65AD\u70B9\u6062\u590D\u6307\u4EE4", en: "Copy checkpoint-resume instruction" },
2884
+ "iterate.pull": { zh: "\u590D\u5236\u62C9\u53D6\u78C1\u76D8\u5FEB\u7167\u6307\u4EE4", en: "Copy disk-snapshot pull instruction" },
2885
+ "iterate.review-file": { zh: "\u590D\u5236\u300C\u4EC5\u5BA1\u67E5\u6B64\u6587\u4EF6\u300D\u6307\u4EE4", en: 'Copy "review this file only" instruction' },
2886
+ "iterate.review-dir": { zh: "\u590D\u5236\u300C\u4EC5\u5BA1\u67E5\u6B64\u76EE\u5F55\u300D\u6307\u4EE4", en: 'Copy "review this directory only" instruction' }
2887
+ };
2888
+ function pick(id, lang) {
2889
+ const entry = LABELS[id];
2890
+ if (!entry) return id;
2891
+ return lang === "zh" ? entry.zh : entry.en;
2892
+ }
2893
+ var COPY_FAILURE = {
2894
+ zh: "\u590D\u5236\u5931\u8D25\uFF1A\u526A\u8D34\u677F\u88AB\u62D2\u7EDD\u3002\u8BF7\u6539\u4E3A\u5728\u89C2\u6D4B\u53F0\u9762\u677F\u91CC\u7528\u540C\u4E00\u6307\u4EE4\u7684\u300C\u590D\u5236\u300D\u6309\u94AE\uFF0C\u6216\u624B\u52A8\u9009\u4E2D\u6307\u4EE4\u6587\u672C\u590D\u5236\u3002",
2895
+ en: 'Copy failed: clipboard denied. Use the matching "Copy" button in the observatory panel, or select the instruction text manually.'
2896
+ };
2897
+ function buildSessionMenuEntries(lang, opts) {
2898
+ return [
2899
+ { id: "iterate.open-panel", label: pick("iterate.open-panel", lang), openTab: "live" },
2900
+ { id: "iterate.preflight", label: pick("iterate.preflight", lang), separatorBefore: true },
2901
+ { id: "iterate.dry-run", label: pick("iterate.dry-run", lang) },
2902
+ { id: "iterate.resume", label: pick("iterate.resume", lang), disabled: opts?.hasCheckpoint === false },
2903
+ { id: "iterate.pull", label: pick("iterate.pull", lang), separatorBefore: true }
2904
+ ];
2905
+ }
2906
+ function buildFileMenuEntries(lang) {
2907
+ return [
2908
+ { id: "iterate.review-file", label: pick("iterate.review-file", lang) },
2909
+ { id: "iterate.open-panel", label: pick("iterate.open-panel", lang) }
2910
+ ];
2911
+ }
2912
+ function buildDirectoryMenuEntries(lang) {
2913
+ return [
2914
+ { id: "iterate.review-dir", label: pick("iterate.review-dir", lang) },
2915
+ { id: "iterate.preflight", label: pick("iterate.preflight", lang), separatorBefore: true },
2916
+ { id: "iterate.open-panel", label: pick("iterate.open-panel", lang) }
2917
+ ];
2918
+ }
2919
+ function toAction(entry, deps, lang) {
2920
+ const action = {
2921
+ id: entry.id,
2922
+ label: entry.label,
2923
+ danger: entry.danger,
2924
+ separatorBefore: entry.separatorBefore
2925
+ };
2926
+ if (entry.disabled) action.disabled = true;
2927
+ if (entry.openTab) {
2928
+ action.run = () => {
2929
+ deps.openPanel({ tab: entry.openTab });
2930
+ deps.notify(lang === "zh" ? "\u5DF2\u6253\u5F00 iterate \u89C2\u6D4B\u53F0\u3002" : "Iterate observatory opened.", true);
2931
+ };
2932
+ return action;
2933
+ }
2934
+ if (entry.instruction) {
2935
+ const text = entry.instruction;
2936
+ const failureLabel = COPY_FAILURE[lang];
2937
+ action.failureLabel = failureLabel;
2938
+ action.run = async () => {
2939
+ const ok = await deps.copy(text);
2940
+ if (!ok) throw new Error(failureLabel);
2941
+ deps.notify(lang === "zh" ? "\u6307\u4EE4\u5DF2\u590D\u5236\uFF0C\u7C98\u8D34\u7ED9\u6A21\u578B\u5373\u53EF\u3002" : "Instruction copied \u2014 paste it to the model.", true);
2942
+ };
2943
+ }
2944
+ return action;
2945
+ }
2946
+ function registerIterateContextMenus(service, deps, instructionByEntry) {
2947
+ const unregister = [];
2948
+ const register = (target, order, build) => {
2949
+ try {
2950
+ const dispose = service.register({
2951
+ id: `iterate-plugin.${target}`,
2952
+ target,
2953
+ order,
2954
+ resolve: (t2, _signal) => {
2955
+ const lang = getLang();
2956
+ return build(lang, t2).map((entry) => {
2957
+ const full = { ...entry, instruction: entry.instruction ?? instructionByEntry(entry.id) };
2958
+ return toAction(full, deps, lang);
2959
+ });
2960
+ }
2961
+ });
2962
+ unregister.push(dispose);
2963
+ } catch (err) {
2964
+ if (typeof console !== "undefined" && typeof console.error === "function") {
2965
+ console.error("[iterate-ui] context-menu registration failed for target", target, err);
2966
+ }
2967
+ }
2968
+ };
2969
+ register("session", 300, (_lang, t2) => {
2970
+ const target = t2;
2971
+ return buildSessionMenuEntries(getLang(), { hasCheckpoint: target?.value?.sessionId ? true : void 0 });
2972
+ });
2973
+ register("file", 300, (_lang, t2) => {
2974
+ const target = t2;
2975
+ return buildFileMenuEntries(getLang()).map((e) => ({
2976
+ ...e,
2977
+ instruction: e.id === "iterate.review-file" ? instructionByEntry(`file:${target?.value?.path ?? ""}${typeof target?.value?.line === "number" ? `:${target.value.line}` : ""}`) : e.instruction
2978
+ }));
2979
+ });
2980
+ register("directory", 300, (_lang, t2) => {
2981
+ const target = t2;
2982
+ return buildDirectoryMenuEntries(getLang()).map((e) => ({
2983
+ ...e,
2984
+ instruction: e.id === "iterate.review-dir" ? instructionByEntry(`dir:${target?.value?.path ?? ""}`) : e.instruction
2985
+ }));
2986
+ });
2987
+ return () => {
2988
+ for (const dispose of unregister.splice(0)) {
2989
+ try {
2990
+ dispose();
2991
+ } catch {
2992
+ }
2993
+ }
2994
+ };
2995
+ }
2996
+
2861
2997
  // src/client/index.ts
2862
2998
  var React = require("react");
2863
2999
  var name = "iterate-plugin";
@@ -3294,12 +3430,42 @@ function readTheme(ctx) {
3294
3430
  }
3295
3431
  return void 0;
3296
3432
  }
3433
+ function readContextMenus(ctx) {
3434
+ if (!ctx || typeof ctx.get !== "function") return void 0;
3435
+ const raw = ctx.get("contextMenus", false);
3436
+ if (!raw || typeof raw !== "object") return void 0;
3437
+ const svc = raw;
3438
+ if (typeof svc.register !== "function") return void 0;
3439
+ if (typeof svc.notify !== "function") return void 0;
3440
+ return svc;
3441
+ }
3442
+ function sessionIdOfSession(session) {
3443
+ if (!session || typeof session !== "object") return null;
3444
+ const s = session;
3445
+ const candidates = [
3446
+ s.sessionId,
3447
+ s.id,
3448
+ s.header,
3449
+ s.session
3450
+ ];
3451
+ for (const c of candidates) {
3452
+ if (typeof c === "string" && c) return c;
3453
+ if (c && typeof c === "object") {
3454
+ const o = c;
3455
+ for (const k of ["sessionId", "id"]) {
3456
+ if (typeof o[k] === "string" && o[k]) return o[k];
3457
+ }
3458
+ }
3459
+ }
3460
+ return null;
3461
+ }
3297
3462
  var slotsSvc = void 0;
3298
3463
  var themeSvc = void 0;
3299
3464
  var storage2 = null;
3300
3465
  var themeDisposer = null;
3301
3466
  var themeListener = null;
3302
3467
  var themeEnabled = true;
3468
+ var menuDisposer = null;
3303
3469
  var roundPulseListeners = [];
3304
3470
  function emitRoundPulse(round, converged) {
3305
3471
  const payload = { round, converged: converged === true };
@@ -4607,6 +4773,21 @@ function ObservatoryPanel(props) {
4607
4773
  doc.addEventListener("keydown", onKeyDown);
4608
4774
  return () => doc.removeEventListener("keydown", onKeyDown);
4609
4775
  }, [open]);
4776
+ const panelSessionId = sessionIdOfSession(session);
4777
+ React.useEffect(() => {
4778
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
4779
+ const onOpen = (ev) => {
4780
+ const detail = ev.detail;
4781
+ const detailSession = detail && typeof detail.sessionId === "string" && detail.sessionId ? detail.sessionId : null;
4782
+ if (detailSession && panelSessionId && detailSession !== panelSessionId) return;
4783
+ setOpen(true);
4784
+ const target = detail && typeof detail.tab === "string" ? detail.tab : "live";
4785
+ const valid = OBS_TABS.some((x) => x.key === target);
4786
+ setTab(valid ? target : "live");
4787
+ };
4788
+ window.addEventListener(OBS_OPEN_EVENT, onOpen);
4789
+ return () => window.removeEventListener(OBS_OPEN_EVENT, onOpen);
4790
+ }, [panelSessionId]);
4610
4791
  const copyInstruction = (key, text) => {
4611
4792
  if (!text) return;
4612
4793
  copyText(text).then((ok) => {
@@ -6217,6 +6398,54 @@ function apply(ctx) {
6217
6398
  )
6218
6399
  );
6219
6400
  }
6401
+ {
6402
+ const menuSvc = readContextMenus(ctx);
6403
+ if (menuSvc) {
6404
+ const instructionByEntry = (id) => {
6405
+ if (id === "iterate.preflight") return buildPreflightInstruction();
6406
+ if (id === "iterate.dry-run") return START_INSTRUCTIONS.reviewOnly;
6407
+ if (id === "iterate.resume") return buildCheckpointResumeInstruction();
6408
+ if (id === "iterate.pull") return buildDiskSnapshotInstruction();
6409
+ if (id.startsWith("file:")) {
6410
+ const rest = id.slice("file:".length);
6411
+ const colon = rest.lastIndexOf(":");
6412
+ const path = colon > 0 ? rest.slice(0, colon) : rest;
6413
+ const line = colon > 0 ? Number(rest.slice(colon + 1)) : void 0;
6414
+ return buildScopedReviewInstruction({ file: path, ...Number.isInteger(line) && line > 0 ? { line } : {} });
6415
+ }
6416
+ if (id.startsWith("dir:")) return buildScopedReviewInstruction({ dir: id.slice("dir:".length) });
6417
+ return "";
6418
+ };
6419
+ if (menuDisposer) {
6420
+ try {
6421
+ menuDisposer();
6422
+ } catch {
6423
+ }
6424
+ menuDisposer = null;
6425
+ }
6426
+ menuDisposer = registerIterateContextMenus(
6427
+ menuSvc,
6428
+ {
6429
+ copy: (text) => copyText(text),
6430
+ openPanel: (detail) => {
6431
+ openObservatory(detail);
6432
+ },
6433
+ notify: (text, success, action) => {
6434
+ menuSvc.notify(text, success, action);
6435
+ }
6436
+ },
6437
+ instructionByEntry
6438
+ );
6439
+ if (typeof ctx.effect === "function") {
6440
+ ctx.effect(() => {
6441
+ if (menuDisposer) {
6442
+ menuDisposer();
6443
+ menuDisposer = null;
6444
+ }
6445
+ });
6446
+ }
6447
+ }
6448
+ }
6220
6449
  registerThemeListener(ctx);
6221
6450
  }
6222
6451
  return module.exports; } });
package/lib/parse.js CHANGED
@@ -1788,6 +1788,34 @@ export const START_INSTRUCTIONS = {
1788
1788
  reviewOnly: START_INSTRUCTION_REVIEW_ONLY,
1789
1789
  }
1790
1790
 
1791
+ /**
1792
+ * Scoped dry-run review instruction (right-click menu: file / directory).
1793
+ *
1794
+ * `iterate_review plan` has no per-call `scope` parameter, so a single-file or
1795
+ * single-directory review is expressed as a directed instruction the model
1796
+ * resolves through the `workflow` tool exactly like the full dry-run flow, but
1797
+ * with the review scope explicitly narrowed to the clicked subject. The user
1798
+ * never has to type the path themselves.
1799
+ *
1800
+ * @param {{ file?: string, line?: number, dir?: string }} scope
1801
+ * @returns {string} empty when no scope is given (defensive; never throw)
1802
+ */
1803
+ export function buildScopedReviewInstruction(scope) {
1804
+ const hasFile = scope && typeof scope.file === 'string' && scope.file.length > 0
1805
+ const hasDir = !hasFile && scope && typeof scope.dir === 'string' && scope.dir.length > 0
1806
+ if (!hasFile && !hasDir) return ''
1807
+ const subject = hasFile
1808
+ ? `文件 \`${scope.file}\`${typeof scope.line === 'number' && scope.line > 0 ? `(第 ${scope.line} 行)` : ''}`
1809
+ : `目录 \`${scope.dir}\``
1810
+ return (
1811
+ `开始一次仅评审(dry-run,不修改任何文件),审查范围限定在${subject}:` +
1812
+ '请调用 `workflow` 工具运行 iterate 工作流,mode: "dry-run",' +
1813
+ `按系统提示的 Iterate Workflow 执行 plan → 评审 → 分诊 → \`iterate_review aggregate\` / \`meta-review\` 终报并 \`iterate_transcript\` capture;` +
1814
+ `评审时把范围限定在${subject},其余文件仅作上下文、不得给出修复。` +
1815
+ '全程不要调用 `iterate_fix` / `iterate_rollback`。'
1816
+ )
1817
+ }
1818
+
1791
1819
  /** Normalize an arbitrary finding-ish node into the payload field set. */
1792
1820
  function fixFindingPayload(finding) {
1793
1821
  const f = finding && typeof finding === 'object' ? finding : {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,8 @@
55
55
  "0.2.1-rc.3": "compatible",
56
56
  "0.2.1-rc.4": "compatible",
57
57
  "0.2.1-rc.5": "compatible",
58
- "0.2.2-alpha.2": "compatible"
58
+ "0.2.2-alpha.2": "compatible",
59
+ "0.2.2-alpha.3": "compatible"
59
60
  }
60
61
  },
61
62
  "client": {
@@ -96,10 +97,10 @@
96
97
  "js-yaml": "4.3.2"
97
98
  },
98
99
  "peerDependencies": {
99
- "@deepseek-ai/cordis": "^4.0.5-alpha.2 || ~4.0.6-alpha.1",
100
- "@deepseek-ai/dsh-session": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2",
101
- "@deepseek-ai/dsh-tools": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2",
102
- "@deepseek-ai/dsh-util-values": "0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2"
100
+ "@deepseek-ai/cordis": "^4.0.5-alpha.2 || ~4.0.6-alpha.1 || ~4.0.6-alpha.2",
101
+ "@deepseek-ai/dsh-session": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3",
102
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3",
103
+ "@deepseek-ai/dsh-util-values": "0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3"
103
104
  },
104
105
  "peerDependenciesMeta": {
105
106
  "@deepseek-ai/cordis": {
@@ -116,20 +117,20 @@
116
117
  }
117
118
  },
118
119
  "devDependencies": {
119
- "@deepseek-ai/cordis": "4.0.6-alpha.1",
120
- "@deepseek-ai/dsh-agent": "0.2.2-alpha.2",
121
- "@deepseek-ai/dsh-jobs": "0.2.2-alpha.2",
122
- "@deepseek-ai/dsh-llm": "0.2.2-alpha.2",
123
- "@deepseek-ai/dsh-ptc-runtime": "0.2.2-alpha.2",
124
- "@deepseek-ai/dsh-sandbox": "0.2.2-alpha.2",
125
- "@deepseek-ai/dsh-sandbox-policy": "0.2.2-alpha.2",
126
- "@deepseek-ai/dsh-scope": "0.2.2-alpha.2",
127
- "@deepseek-ai/dsh-session": "0.2.2-alpha.2",
128
- "@deepseek-ai/dsh-system-prompt": "0.2.2-alpha.2",
129
- "@deepseek-ai/dsh-tools": "0.2.2-alpha.2",
130
- "@deepseek-ai/dsh-user-approval": "0.2.2-alpha.2",
131
- "@deepseek-ai/dsh-util-values": "0.2.2-alpha.2",
132
- "@deepseek-ai/dsh-working-directory": "0.2.2-alpha.2",
120
+ "@deepseek-ai/cordis": "4.0.6-alpha.2",
121
+ "@deepseek-ai/dsh-agent": "0.2.2-alpha.3",
122
+ "@deepseek-ai/dsh-jobs": "0.2.2-alpha.3",
123
+ "@deepseek-ai/dsh-llm": "0.2.2-alpha.3",
124
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.2-alpha.3",
125
+ "@deepseek-ai/dsh-sandbox": "0.2.2-alpha.3",
126
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.2-alpha.3",
127
+ "@deepseek-ai/dsh-scope": "0.2.2-alpha.3",
128
+ "@deepseek-ai/dsh-session": "0.2.2-alpha.3",
129
+ "@deepseek-ai/dsh-system-prompt": "0.2.2-alpha.3",
130
+ "@deepseek-ai/dsh-tools": "0.2.2-alpha.3",
131
+ "@deepseek-ai/dsh-user-approval": "0.2.2-alpha.3",
132
+ "@deepseek-ai/dsh-util-values": "0.2.2-alpha.3",
133
+ "@deepseek-ai/dsh-working-directory": "0.2.2-alpha.3",
133
134
  "@types/js-yaml": "4.0.9",
134
135
  "@types/node": "22.15.0",
135
136
  "@types/react": "19.2.2",
@@ -0,0 +1,289 @@
1
+ /**
2
+ * src/client/context-menu.ts — iterate-plugin right-click menu contributions.
3
+ *
4
+ * dsh 0.2.2-alpha.3 added the *unified context-menu* capability
5
+ * (`@deepseek-ai/dsh-client-ui-context-menu`): any client plugin may register
6
+ * actions that appear on right-click / Shift+F10 / ContextMenu key over a
7
+ * Session, file, directory, URL, image, tab, or workspace, without importing
8
+ * a React component. The captured target stays fixed even when selection
9
+ * changes, and contributions are withdrawn automatically when the plugin
10
+ * unloads.
11
+ *
12
+ * This module turns that new upstream capability into a *user-perceivable*
13
+ * iterate surface: instead of only being reachable through the docked
14
+ * observatory and the empty-dashboard CTA, the iterate entry points now also
15
+ * hang off the native right-click menus — on a Session (open the quality
16
+ * command center, copy a preflight / dry-run / resume / disk-snapshot
17
+ * instruction), on a file (dry-run review scoped to that file), and on a
18
+ * directory (scoped review + preflight).
19
+ *
20
+ * Design (mirrors the rest of the client half):
21
+ * - **Defensive**: the `contextMenus` service is optional and probed at
22
+ * apply-time; a host that predates 0.2.2-alpha.3 simply has no menu
23
+ * entries (the docked panel remains the entry point). Nothing throws.
24
+ * - **Pure builders**: `buildSessionMenuEntries` / `buildFileMenuEntries` /
25
+ * `buildDirectoryMenuEntries` return *localized descriptors* with no DOM
26
+ * or clipboard side effects, so they are unit-testable on their own
27
+ * (normal + empty + boundary). The imperative wiring (`copy` / `openPanel`
28
+ * / `notify`) is injected by `registerIterateContextMenus`.
29
+ * - **Bilingual**: every label carries zh + en and resolves against the
30
+ * language passed in (the caller reads `getLang()` at menu-build time, so
31
+ * a language switch is picked up on the next open).
32
+ *
33
+ * Event bridge: the observatory is a React panel with its own open/tab state.
34
+ * The browser client cannot call harness tools, so the "open command center"
35
+ * menu action dispatches a window CustomEvent (`OBS_OPEN_EVENT`) that the
36
+ * ObservatoryPanel listens for — the panel expands and switches tab without
37
+ * any cross-plugin import. The event carries an optional `sessionId` so a
38
+ * right-click in the session sidebar targets the right session's panel and is
39
+ * ignored by every other mounted panel.
40
+ */
41
+
42
+ import { getLang, type Lang } from './i18n.ts'
43
+
44
+ // ─── Structural service types (defensive; mirrors SlotsService) ──────────────
45
+ //
46
+ // The dsh client runtime is not type-imported (no compile-time coupling to a
47
+ // package a pre-0.2.2-alpha.3 host may not have). The surface below is the
48
+ // subset this plugin consumes; `register` is only ever called when a service
49
+ // with the right shape is present.
50
+
51
+ /** A right-click target this plugin contributes actions to. */
52
+ export type IterateMenuTarget =
53
+ | { kind: 'session'; value: { sessionId: string; displayTitle?: string } }
54
+ | { kind: 'file'; value: { sessionId: string; path: string; line?: number } }
55
+ | { kind: 'directory'; value: { sessionId: string; path: string } }
56
+
57
+ /** One menu action (presentation + optional execution). */
58
+ export interface IterateMenuAction {
59
+ id: string
60
+ label: string
61
+ disabled?: boolean
62
+ danger?: boolean
63
+ separatorBefore?: boolean
64
+ /** Localized operation + recovery guidance when `run` rejects. */
65
+ failureLabel?: string
66
+ run?: () => void | Promise<void>
67
+ }
68
+
69
+ /** A namespaced contribution, ordered within its target family. */
70
+ export interface IterateMenuContribution<K extends string> {
71
+ id: string
72
+ target: K
73
+ order: number
74
+ resolve(target: unknown, signal: AbortSignal): readonly IterateMenuAction[]
75
+ }
76
+
77
+ /** Structural view of the dsh `contextMenus` service this plugin consumes. */
78
+ export interface ContextMenusService {
79
+ register<K extends string>(contribution: IterateMenuContribution<K>): () => void
80
+ notify(
81
+ text: string,
82
+ success?: boolean,
83
+ action?: { label: string; run: () => void | Promise<void>; failureLabel: string },
84
+ ): void
85
+ }
86
+
87
+ // ─── Observatory open event bridge ───────────────────────────────────────────
88
+
89
+ /** Window event that asks the (matching) ObservatoryPanel to expand. */
90
+ export const OBS_OPEN_EVENT = 'iterate-plugin:open-observatory'
91
+
92
+ /** Payload carried by {@link OBS_OPEN_EVENT}. */
93
+ export interface ObsOpenDetail {
94
+ /** Optional target session; omitted = open whatever panel is mounted. */
95
+ sessionId?: string
96
+ /** Tab key to switch to (`live` by default). */
97
+ tab?: string
98
+ }
99
+
100
+ /**
101
+ * Dispatch the "open observatory" event. No-op outside a browser (SSR / test
102
+ * environments without `window`). Returns whether the event was dispatched.
103
+ */
104
+ export function openObservatory(detail?: ObsOpenDetail): boolean {
105
+ if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return false
106
+ try {
107
+ window.dispatchEvent(new CustomEvent<ObsOpenDetail>(OBS_OPEN_EVENT, { detail: detail ?? {} }))
108
+ return true
109
+ } catch {
110
+ return false
111
+ }
112
+ }
113
+
114
+ // ─── Localized descriptors (pure, testable) ──────────────────────────────────
115
+
116
+ /** The action ids this plugin registers, for tests + duplicate guards. */
117
+ export const SESSION_MENU_IDS = ['iterate.open-panel', 'iterate.preflight', 'iterate.dry-run', 'iterate.resume', 'iterate.pull'] as const
118
+ export const FILE_MENU_IDS = ['iterate.review-file', 'iterate.open-panel'] as const
119
+ export const DIRECTORY_MENU_IDS = ['iterate.review-dir', 'iterate.preflight', 'iterate.open-panel'] as const
120
+
121
+ interface MenuEntry {
122
+ id: string
123
+ label: string
124
+ instruction?: string
125
+ /** Tab key to open when this entry is the "open panel" action. */
126
+ openTab?: string
127
+ danger?: boolean
128
+ disabled?: boolean
129
+ separatorBefore?: boolean
130
+ }
131
+
132
+ /** Bilingual labels; resolved by `pick` at build time. */
133
+ const LABELS: Record<string, { zh: string; en: string }> = {
134
+ 'iterate.open-panel': { zh: '打开 iterate 质量指挥中心', en: 'Open iterate command center' },
135
+ 'iterate.preflight': { zh: '复制开跑预检指令', en: 'Copy preflight instruction' },
136
+ 'iterate.dry-run': { zh: '复制 dry-run 审查指令', en: 'Copy dry-run review instruction' },
137
+ 'iterate.resume': { zh: '复制断点恢复指令', en: 'Copy checkpoint-resume instruction' },
138
+ 'iterate.pull': { zh: '复制拉取磁盘快照指令', en: 'Copy disk-snapshot pull instruction' },
139
+ 'iterate.review-file': { zh: '复制「仅审查此文件」指令', en: 'Copy "review this file only" instruction' },
140
+ 'iterate.review-dir': { zh: '复制「仅审查此目录」指令', en: 'Copy "review this directory only" instruction' },
141
+ }
142
+
143
+ /** Pick a localized label; unknown ids degrade to the raw id (never throw). */
144
+ function pick(id: string, lang: Lang): string {
145
+ const entry = LABELS[id]
146
+ if (!entry) return id
147
+ return lang === 'zh' ? entry.zh : entry.en
148
+ }
149
+
150
+ /** Copy-failure guidance (a menu action's `failureLabel`). */
151
+ const COPY_FAILURE: Record<'zh' | 'en', string> = {
152
+ zh: '复制失败:剪贴板被拒绝。请改为在观测台面板里用同一指令的「复制」按钮,或手动选中指令文本复制。',
153
+ en: 'Copy failed: clipboard denied. Use the matching "Copy" button in the observatory panel, or select the instruction text manually.',
154
+ }
155
+
156
+ /** Build the Session target menu entries (pure). */
157
+ export function buildSessionMenuEntries(lang: Lang, opts?: { hasCheckpoint?: boolean }): MenuEntry[] {
158
+ return [
159
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang), openTab: 'live' },
160
+ { id: 'iterate.preflight', label: pick('iterate.preflight', lang), separatorBefore: true },
161
+ { id: 'iterate.dry-run', label: pick('iterate.dry-run', lang) },
162
+ { id: 'iterate.resume', label: pick('iterate.resume', lang), disabled: opts?.hasCheckpoint === false },
163
+ { id: 'iterate.pull', label: pick('iterate.pull', lang), separatorBefore: true },
164
+ ]
165
+ }
166
+
167
+ /** Build the File target menu entries (pure). */
168
+ export function buildFileMenuEntries(lang: Lang): MenuEntry[] {
169
+ return [
170
+ { id: 'iterate.review-file', label: pick('iterate.review-file', lang) },
171
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang) },
172
+ ]
173
+ }
174
+
175
+ /** Build the Directory target menu entries (pure). */
176
+ export function buildDirectoryMenuEntries(lang: Lang): MenuEntry[] {
177
+ return [
178
+ { id: 'iterate.review-dir', label: pick('iterate.review-dir', lang) },
179
+ { id: 'iterate.preflight', label: pick('iterate.preflight', lang), separatorBefore: true },
180
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang) },
181
+ ]
182
+ }
183
+
184
+ // ─── Imperative wiring (register + run callbacks) ────────────────────────────
185
+
186
+ /** Injection points the menu wiring needs (kept dependency-light for tests). */
187
+ export interface IterateMenuDeps {
188
+ /** Copy an instruction; resolves to whether the clipboard write succeeded. */
189
+ copy: (text: string) => Promise<boolean>
190
+ /** Open the observatory panel (optionally for a session, on a tab). */
191
+ openPanel: (detail?: ObsOpenDetail) => void
192
+ /** Show the shared toast. */
193
+ notify: (text: string, success?: boolean, action?: { label: string; run: () => void | Promise<void>; failureLabel: string }) => void
194
+ }
195
+
196
+ /** Map a pure entry to a runnable menu action, wiring the injected deps. */
197
+ function toAction(entry: MenuEntry, deps: IterateMenuDeps, lang: Lang): IterateMenuAction {
198
+ const action: IterateMenuAction = {
199
+ id: entry.id,
200
+ label: entry.label,
201
+ danger: entry.danger,
202
+ separatorBefore: entry.separatorBefore,
203
+ }
204
+ if (entry.disabled) action.disabled = true
205
+ if (entry.openTab) {
206
+ action.run = () => {
207
+ deps.openPanel({ tab: entry.openTab })
208
+ deps.notify(lang === 'zh' ? '已打开 iterate 观测台。' : 'Iterate observatory opened.', true)
209
+ }
210
+ return action
211
+ }
212
+ if (entry.instruction) {
213
+ const text = entry.instruction
214
+ const failureLabel = COPY_FAILURE[lang]
215
+ action.failureLabel = failureLabel
216
+ action.run = async () => {
217
+ const ok = await deps.copy(text)
218
+ if (!ok) throw new Error(failureLabel)
219
+ deps.notify(lang === 'zh' ? '指令已复制,粘贴给模型即可。' : 'Instruction copied — paste it to the model.', true)
220
+ }
221
+ }
222
+ return action
223
+ }
224
+
225
+ /**
226
+ * Register this plugin's context-menu contributions on the (already-validated)
227
+ * `contextMenus` service. Returns an unregister function; a host without the
228
+ * service is a no-op that returns `() => {}` (callers guard).
229
+ */
230
+ export function registerIterateContextMenus(
231
+ service: ContextMenusService,
232
+ deps: IterateMenuDeps,
233
+ instructionByEntry: (id: string) => string,
234
+ ): () => void {
235
+ const unregister: Array<() => void> = []
236
+
237
+ const register = <K extends string>(target: K, order: number, build: (lang: Lang, target: unknown) => MenuEntry[]) => {
238
+ try {
239
+ const dispose = service.register({
240
+ id: `iterate-plugin.${target}`,
241
+ target,
242
+ order,
243
+ resolve: (t, _signal) => {
244
+ const lang = getLang()
245
+ return build(lang, t).map((entry) => {
246
+ const full: MenuEntry = { ...entry, instruction: entry.instruction ?? instructionByEntry(entry.id) }
247
+ return toAction(full, deps, lang)
248
+ })
249
+ },
250
+ })
251
+ unregister.push(dispose)
252
+ } catch (err) {
253
+ // A duplicate contribution id or an unsupported target family must not
254
+ // take the plugin down — log and continue with the remaining targets.
255
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
256
+ console.error('[iterate-ui] context-menu registration failed for target', target, err)
257
+ }
258
+ }
259
+ }
260
+
261
+ register('session', 300, (_lang, t) => {
262
+ const target = t as { value?: { sessionId?: string } } | undefined
263
+ return buildSessionMenuEntries(getLang(), { hasCheckpoint: target?.value?.sessionId ? true : undefined })
264
+ })
265
+ register('file', 300, (_lang, t) => {
266
+ const target = t as { value?: { path?: string; line?: number } } | undefined
267
+ return buildFileMenuEntries(getLang()).map((e) => ({
268
+ ...e,
269
+ instruction: e.id === 'iterate.review-file'
270
+ ? instructionByEntry(`file:${target?.value?.path ?? ''}${typeof target?.value?.line === 'number' ? `:${target.value.line}` : ''}`)
271
+ : e.instruction,
272
+ }))
273
+ })
274
+ register('directory', 300, (_lang, t) => {
275
+ const target = t as { value?: { path?: string } } | undefined
276
+ return buildDirectoryMenuEntries(getLang()).map((e) => ({
277
+ ...e,
278
+ instruction: e.id === 'iterate.review-dir'
279
+ ? instructionByEntry(`dir:${target?.value?.path ?? ''}`)
280
+ : e.instruction,
281
+ }))
282
+ })
283
+
284
+ return () => {
285
+ for (const dispose of unregister.splice(0)) {
286
+ try { dispose() } catch { /* noop */ }
287
+ }
288
+ }
289
+ }
@@ -78,6 +78,7 @@ import {
78
78
  scanSessionForPreflight,
79
79
  buildPreflightInstruction,
80
80
  buildValidationApplyInstruction,
81
+ buildScopedReviewInstruction,
81
82
  filterLiveEntries,
82
83
  filterTimelineEntries,
83
84
  serializeObservatoryExport,
@@ -132,6 +133,14 @@ import {
132
133
  type Lang,
133
134
  } from './i18n.ts'
134
135
  import { noteSession, readSession, subscribeSession } from './session-cache.ts'
136
+ // v3.14: right-click menu contributions (dsh 0.2.2-alpha.3 unified context menus).
137
+ import {
138
+ openObservatory,
139
+ OBS_OPEN_EVENT,
140
+ registerIterateContextMenus,
141
+ type ContextMenusService,
142
+ type ObsOpenDetail,
143
+ } from './context-menu.ts'
135
144
 
136
145
  // ─── Module contract ─────────────────────────────────────────────────────────
137
146
 
@@ -907,6 +916,47 @@ function readTheme(ctx: ClientContext): ThemeService | undefined {
907
916
  return undefined
908
917
  }
909
918
 
919
+ /**
920
+ * Resolve the dsh 0.2.2-alpha.3 `contextMenus` service. Unlike slots/theme it
921
+ * is NOT in the typed `ClientContext`, so it is probed through `ctx.get` (the
922
+ * cordis service locator) and structurally validated. A host that predates the
923
+ * capability returns `undefined` and the plugin simply has no menu entries.
924
+ */
925
+ function readContextMenus(ctx: ClientContext): ContextMenusService | undefined {
926
+ if (!ctx || typeof ctx.get !== 'function') return undefined
927
+ const raw = ctx.get('contextMenus', false)
928
+ if (!raw || typeof raw !== 'object') return undefined
929
+ const svc = raw as { register?: unknown; notify?: unknown }
930
+ if (typeof svc.register !== 'function') return undefined
931
+ if (typeof svc.notify !== 'function') return undefined
932
+ return svc as unknown as ContextMenusService
933
+ }
934
+
935
+ /**
936
+ * Extract a session identifier from an opaque host-shaped session object.
937
+ * The slot `props.session` shape is host-defined; this probes the common
938
+ * keys so the observatory open-event can target the right session and be
939
+ * ignored by every other mounted panel. Returns `null` when unknown (the
940
+ * panel then responds to any open event — the single-panel fallback).
941
+ */
942
+ function sessionIdOfSession(session: unknown): string | null {
943
+ if (!session || typeof session !== 'object') return null
944
+ const s = session as Record<string, unknown>
945
+ const candidates = [
946
+ s.sessionId, s.id, s.header, s.session,
947
+ ] as unknown[]
948
+ for (const c of candidates) {
949
+ if (typeof c === 'string' && c) return c
950
+ if (c && typeof c === 'object') {
951
+ const o = c as Record<string, unknown>
952
+ for (const k of ['sessionId', 'id']) {
953
+ if (typeof o[k] === 'string' && o[k]) return o[k] as string
954
+ }
955
+ }
956
+ }
957
+ return null
958
+ }
959
+
910
960
  // ─── Module-level runtime state (shared across slots) ────────────────────────
911
961
 
912
962
  let slotsSvc: SlotsService | undefined = undefined
@@ -915,6 +965,8 @@ let storage: ReturnType<typeof createStorage> | null = null
915
965
  let themeDisposer: (() => void) | null = null
916
966
  let themeListener: (() => void) | null = null
917
967
  let themeEnabled = true
968
+ /** v3.14: right-click menu contribution disposer (re-apply() never stacks). */
969
+ let menuDisposer: (() => void) | null = null
918
970
  const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
919
971
 
920
972
  /**
@@ -2392,6 +2444,28 @@ function ObservatoryPanel(props: SlotProps) {
2392
2444
  // eslint-disable-next-line react-hooks/exhaustive-deps
2393
2445
  }, [open])
2394
2446
 
2447
+ // v3.14: the right-click "open command center" menu action dispatches a
2448
+ // window event; this panel expands and switches tab when the event targets
2449
+ // it (no sessionId = single-panel fallback, a different sessionId = ignore).
2450
+ const panelSessionId = sessionIdOfSession(session)
2451
+ React.useEffect(() => {
2452
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return
2453
+ const onOpen = (ev: Event) => {
2454
+ const detail = (ev as CustomEvent<ObsOpenDetail>).detail
2455
+ const detailSession = detail && typeof detail.sessionId === 'string' && detail.sessionId
2456
+ ? detail.sessionId
2457
+ : null
2458
+ if (detailSession && panelSessionId && detailSession !== panelSessionId) return
2459
+ setOpen(true)
2460
+ const target = detail && typeof detail.tab === 'string' ? detail.tab : 'live'
2461
+ const valid = OBS_TABS.some((x) => x.key === target)
2462
+ setTab(valid ? target : 'live')
2463
+ }
2464
+ window.addEventListener(OBS_OPEN_EVENT, onOpen)
2465
+ return () => window.removeEventListener(OBS_OPEN_EVENT, onOpen)
2466
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2467
+ }, [panelSessionId])
2468
+
2395
2469
  /** Copy an instruction, flashing "已复制" on the originating button only when the copy actually succeeded. */
2396
2470
  const copyInstruction = (key: string, text: string) => {
2397
2471
  if (!text) return
@@ -3867,6 +3941,49 @@ export function apply(ctx: ClientContext): void {
3867
3941
  )
3868
3942
  }
3869
3943
 
3944
+ // 5b. v3.14: right-click menu contributions (dsh 0.2.2-alpha.3 unified
3945
+ // context menus). Optional service: a host that predates the capability has
3946
+ // no menu entries, and the docked observatory stays the entry point.
3947
+ {
3948
+ const menuSvc = readContextMenus(ctx)
3949
+ if (menuSvc) {
3950
+ // Resolve a paste-able instruction for a menu action id. Session-level
3951
+ // ids map to the shared builders (the SAME single source of truth the
3952
+ // observatory copy buttons use); file:/dir: ids are the scoped review.
3953
+ const instructionByEntry = (id: string): string => {
3954
+ if (id === 'iterate.preflight') return buildPreflightInstruction()
3955
+ if (id === 'iterate.dry-run') return START_INSTRUCTIONS.reviewOnly
3956
+ if (id === 'iterate.resume') return buildCheckpointResumeInstruction()
3957
+ if (id === 'iterate.pull') return buildDiskSnapshotInstruction()
3958
+ if (id.startsWith('file:')) {
3959
+ const rest = id.slice('file:'.length)
3960
+ const colon = rest.lastIndexOf(':')
3961
+ const path = colon > 0 ? rest.slice(0, colon) : rest
3962
+ const line = colon > 0 ? Number(rest.slice(colon + 1)) : undefined
3963
+ return buildScopedReviewInstruction({ file: path, ...(Number.isInteger(line) && line! > 0 ? { line: line! } : {}) })
3964
+ }
3965
+ if (id.startsWith('dir:')) return buildScopedReviewInstruction({ dir: id.slice('dir:'.length) })
3966
+ return ''
3967
+ }
3968
+ if (menuDisposer) {
3969
+ try { menuDisposer() } catch { /* noop */ }
3970
+ menuDisposer = null
3971
+ }
3972
+ menuDisposer = registerIterateContextMenus(
3973
+ menuSvc,
3974
+ {
3975
+ copy: (text) => copyText(text),
3976
+ openPanel: (detail) => { openObservatory(detail) },
3977
+ notify: (text, success, action) => { menuSvc.notify(text, success, action) },
3978
+ },
3979
+ instructionByEntry,
3980
+ )
3981
+ if (typeof ctx.effect === 'function') {
3982
+ ctx.effect(() => { if (menuDisposer) { menuDisposer(); menuDisposer = null } })
3983
+ }
3984
+ }
3985
+ }
3986
+
3870
3987
  // 6: keep theme toggle state consistent across theme/change events.
3871
3988
  // Registered through registerThemeListener so repeated apply() and client
3872
3989
  // unmounts never leak a second listener.