dsh-plugin-orchd 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,6 +40,14 @@ dsh plugin add dsh-plugin-orchd
40
40
  dsh plugin --profile web add github:7bder/dsh-plugin-orchd
41
41
  ```
42
42
 
43
+ ## 截图
44
+
45
+ ![看板:状态条即筛选、搜索、排序、任务列表](assets/screenshot-1.png)
46
+
47
+ ![依赖图:分层 DAG、悬空依赖如实标注、异常提示](assets/screenshot-2.png)
48
+
49
+ ![任务详情:验收标准、可复制的验证命令、依赖与审查时间线](assets/screenshot-3.png)
50
+
43
51
  Desktop 的 `desktop` profile 由应用托管:普通 `dsh plugin --profile desktop ...` 会被 CLI 直接拒(
44
52
  `profile "desktop" is managed exclusively by the Electron application`),应用自带终端里不带 `--profile`
45
53
  的命令才改得到它。装好后**重启 DSH**——宿主侧模块不热替换:会话 header 角落出现开关,右侧栏出现「orchd 任务」页。
package/client/client.js CHANGED
@@ -126,7 +126,7 @@ window.__ModuleLoader__.load({
126
126
  // 列表与筛选(FR-5)
127
127
  search: '搜索 ID / 名称…',
128
128
  sortBy: '排序',
129
- sortDefault: '活跃优先',
129
+ sortDefault: '活跃优先 · 按时间',
130
130
  sortImportance: '重要度',
131
131
  sortDifficulty: '难度',
132
132
  sortModule: '模块',
@@ -200,7 +200,7 @@ window.__ModuleLoader__.load({
200
200
  ideasTitle: '想法',
201
201
  ideasArchived: '归档',
202
202
  ideasArchivedUnit: '条',
203
- ideasArchivedHint: '归档只计数,不加载全文;已立任务 / 已丢弃的条目都到这里了',
203
+ ideasArchivedHint: '归档只计数,不加载全文;已立任务 / 已覆盖 / 人工 close 的条目都到这里了',
204
204
  ideasArchivedHistorical: '归档保留原文,因此这里的 status 是历史值(可能仍写着 pending)',
205
205
  ideasEngineUnsupported: '本项目的 orchd 引擎没有 ideas 子命令(旧版本):下面这份列表与状态是直接读 IDEAS.md 得到的,没有引擎清单可交叉核对。升级引擎后会自动启用交叉核对。',
206
206
  ideasBadgeHint: '在等你裁决的想法数(论证中 + 待补充)',
@@ -211,12 +211,12 @@ window.__ModuleLoader__.load({
211
211
  ideasOnlyInMarkdown: '仅存在于 IDEAS.md(引擎清单里没有)',
212
212
  ideasOwnerUser: '等你裁决',
213
213
  ideasOwnerAgent: '等 agent 拆解',
214
- ideasOwnerNone: '已离队',
214
+ ideasOwnerNone: '无需等待',
215
215
  ideasConverted: '已转成的任务',
216
216
  ideasConvertedHint: '定义层里 source 指向这条想法的任务',
217
217
  ideasConvertedTasks: '个任务',
218
218
  ideasStatusUnknown: '未知状态',
219
- ideasStatusDropped: '已丢弃',
219
+ ideasArchiveLegacyDropped: '历史已放弃(dropped)',
220
220
  ideasUntasked: '未立任务',
221
221
  ideasNoId: '无法对照',
222
222
  ideasDetail: '正文',
@@ -224,15 +224,15 @@ window.__ModuleLoader__.load({
224
224
  ideasExtra: '未知字段',
225
225
  ideasDetailPath: '详档',
226
226
  ideasCommands: '你可以执行的命令(id 已填好)',
227
- ideasCommandsHint: '本面板只读:不提供 propose / confirm / drop / archive 按钮,只帮你复制命令。这些命令按当前状态给出;在途任务引用期间引擎会拒绝搬走(source_live_reference)。',
228
- ideasCommandLegend: 'append = 往这条想法追加一条带时间戳的追记(不动原正文、不改状态,就是「追加记录」里那几行);confirm / drop / archive = 你对这条想法的裁决(仅用户可执行)。括号里的 <…> 是占位符,换成你自己要写的字。',
227
+ ideasCommandsHint: '本面板只读:不提供 propose / confirm / close 按钮,只帮你复制命令。这些命令按当前状态给出;在途任务引用期间引擎会拒绝搬走(source_live_reference)。',
228
+ ideasCommandLegend: 'append = 往这条想法追加一条带时间戳的追记(不动原正文、不改状态,就是「追加记录」里那几行);confirm / close = 你对这条想法的裁决(仅用户可执行;close 一律归档并留下裁决理由)。括号里的 <…> 是占位符,换成你自己要写的字。',
229
229
  ideasPropose: '新建想法(引擎侧的正式通道)',
230
230
  ideasProposeHint: '新建时还没有 id,所以标题里要自己声明一个(形如「标题(id: my-idea)」)。',
231
231
  iStudy: '论证中',
232
232
  iQuestioning: '待补充',
233
233
  iPending: '待拆解',
234
- iTaskified: '已转任务',
235
- iDropped: '已丢弃',
234
+ iConverted: '已转任务',
235
+ iCovered: '已覆盖',
236
236
  // ── FR-7 依赖图(P2b)──
237
237
  graphCounts: '节点 {n} · 边 {e}',
238
238
  graphIncludeTerminal: '含终态任务',
@@ -335,7 +335,7 @@ window.__ModuleLoader__.load({
335
335
  summaryFromPlugin: 'Source: plugin',
336
336
  search: 'Search id / name…',
337
337
  sortBy: 'Sort',
338
- sortDefault: 'Active first',
338
+ sortDefault: 'Active first · by time',
339
339
  sortImportance: 'Importance',
340
340
  sortDifficulty: 'Difficulty',
341
341
  sortModule: 'Module',
@@ -404,7 +404,7 @@ window.__ModuleLoader__.load({
404
404
  ideasTitle: 'Ideas',
405
405
  ideasArchived: 'Archived',
406
406
  ideasArchivedUnit: '',
407
- ideasArchivedHint: 'Archived entries are counted only; entries that became tasks or were dropped live here',
407
+ ideasArchivedHint: 'Archived entries are counted only; entries that became tasks, were covered, or were closed live here',
408
408
  ideasArchivedHistorical: 'The archive keeps the original text, so these statuses are historical',
409
409
  ideasEngineUnsupported: "This project's orchd engine has no ideas subcommand (older build): the list and statuses below are read straight from IDEAS.md, with no engine ledger to cross-check. Cross-checking turns on automatically after an engine upgrade.",
410
410
  ideasBadgeHint: 'Ideas waiting on you (studying + needs info)',
@@ -415,12 +415,12 @@ window.__ModuleLoader__.load({
415
415
  ideasOnlyInMarkdown: 'Only present in IDEAS.md (missing from the engine list)',
416
416
  ideasOwnerUser: 'waiting on you',
417
417
  ideasOwnerAgent: 'waiting on an agent',
418
- ideasOwnerNone: 'left the queue',
418
+ ideasOwnerNone: 'no action pending',
419
419
  ideasConverted: 'Tasks created from this idea',
420
420
  ideasConvertedHint: 'Definition-layer tasks whose source points at this idea',
421
421
  ideasConvertedTasks: 'tasks',
422
422
  ideasStatusUnknown: 'unknown status',
423
- ideasStatusDropped: 'dropped',
423
+ ideasArchiveLegacyDropped: 'historically dropped',
424
424
  ideasUntasked: 'never tasked',
425
425
  ideasNoId: 'no id',
426
426
  ideasDetail: 'Body',
@@ -428,15 +428,15 @@ window.__ModuleLoader__.load({
428
428
  ideasExtra: 'Unrecognised fields',
429
429
  ideasDetailPath: 'Detail doc',
430
430
  ideasCommands: 'Commands you can run (id already filled in)',
431
- ideasCommandsHint: 'This panel is read-only: no propose / confirm / drop / archive buttons — it only copies commands. They match the current status; while in-flight tasks reference the entry the engine refuses to move it (source_live_reference).',
432
- ideasCommandLegend: 'append = add one timestamped note to this idea (it never touches the original body or status — these are the lines shown under "Appended notes"); confirm / drop / archive = your decision on this idea (user-only). The <…> parts are placeholders — replace them with your own text.',
431
+ ideasCommandsHint: 'This panel is read-only: no propose / confirm / close buttons — it only copies commands. They match the current status; while in-flight tasks reference the entry the engine refuses to move it (source_live_reference).',
432
+ ideasCommandLegend: 'append = add one timestamped note to this idea (it never touches the original body or status — these are the lines shown under "Appended notes"); confirm / close = your decision on this idea (user-only; close always archives the entry with your reason). The <…> parts are placeholders — replace them with your own text.',
433
433
  ideasPropose: 'New idea (the engine-side channel)',
434
434
  ideasProposeHint: 'A new idea has no id yet, so declare one in the title (e.g. "Title (id: my-idea)").',
435
435
  iStudy: 'Studying',
436
436
  iQuestioning: 'Needs info',
437
437
  iPending: 'Awaiting breakdown',
438
- iTaskified: 'Turned into tasks',
439
- iDropped: 'Dropped',
438
+ iConverted: 'Turned into tasks',
439
+ iCovered: 'Covered',
440
440
  graphCounts: '{n} nodes · {e} edges',
441
441
  graphIncludeTerminal: 'Include finished tasks',
442
442
  graphIncludeTerminalHint: 'Also draw completed / cancelled tasks (by default only active tasks and their direct neighbours are drawn)',
@@ -771,28 +771,14 @@ window.__ModuleLoader__.load({
771
771
  function interactionCss() {
772
772
  const hover = 'var(--dsw-alias-interactive-bg-hover, rgba(127, 127, 127, 0.12))'
773
773
  const active = 'var(--dsw-alias-interactive-bg-active, rgba(127, 127, 127, 0.2))'
774
- // 平台品牌强调色(`--dsw-alias-state-business-primary` = DeepSeek 品牌蓝)。
775
- // 兜底写 `rgba()` 而**不是 `#rrggbb`**:本仓库 FR-11.3 禁止前端出现裸色值 hex
776
- // (lint 套件会抓,实测第 8 轮就被抓到过一次)。兜底保证变量缺失时脉冲依旧醒目。
777
- const accent = 'var(--dsw-alias-state-business-primary, rgba(77, 107, 254, 1))'
774
+
778
775
  return [
779
776
  // 悬停:禁用态不亮(否则会让人以为能点)
780
777
  `[data-orchd-toggle]:hover,[data-orchd-panel] button:hover:not(:disabled){background-color:${hover}!important}`,
781
778
  // 按下:比悬停更深一档
782
779
  `[data-orchd-toggle]:active,[data-orchd-panel] button:active:not(:disabled){background-color:${active}!important}`,
783
780
  // 键盘可达:焦点环(鼠标点击不显示,避免视觉噪音)
784
- '[data-orchd-toggle]:focus-visible,[data-orchd-panel] button:focus-visible{outline:2px solid var(--dsw-alias-label-secondary, currentColor);outline-offset:1px}',
785
- // 标题脉冲环:点击图标时面板**本来就开着**(`openTab` 只是聚焦,没有任何可见变化),
786
- // 用它提示「面板在这儿」。
787
- // ⚠ 强度:第一版借的是 hover 那个 12% 的弱色 ⇒ 用户实测「太弱了」。
788
- // 现在用**平台品牌强调色**(`--dsw-alias-state-business-primary`,第一方用它的地方很多),
789
- // 并且:① 环从 0 扩散到 14px(辐射式「ping」,比静止细环显眼得多);
790
- // ② 标题文字同时变亮成强调色 ⇒ 文字本身也在闪,不只是外圈。
791
- // 仍用 `box-shadow` + `color`:都**不参与布局**,标题不会被挤动;
792
- // 且是一次性动画(非状态样式),播完自动回到无环/原色。
793
- `@keyframes orchd-title-flash{0%{box-shadow:0 0 0 0 ${accent}}100%{box-shadow:0 0 0 14px transparent}}`,
794
- `@keyframes orchd-title-flash-text{0%,35%{color:${accent}}100%{color:inherit}}`,
795
- '[data-orchd-title-flash="true"]{border-radius:999px;animation:orchd-title-flash 1s ease-out 1,orchd-title-flash-text 1s ease-out 1}'
781
+ '[data-orchd-toggle]:focus-visible,[data-orchd-panel] button:focus-visible{outline:2px solid var(--dsw-alias-label-secondary, currentColor);outline-offset:1px}'
796
782
  ].join('')
797
783
  }
798
784
 
@@ -812,39 +798,8 @@ window.__ModuleLoader__.load({
812
798
  }
813
799
  }
814
800
 
815
- /**
816
- * 标题脉冲环的订阅点(模块级)。
817
- *
818
- * `mounted` 由面板组件在挂载/卸载时维护:图标被点时如果面板**本来就开着**,
819
- * `openTab` 只会聚焦已有 tab、界面上没有任何变化,用户会以为「点了没反应」——
820
- * 这时才在标题上闪一下指路;面板本来是关的,tab 出现本身就是反馈,不必再闪。
821
- */
822
- const panelState = { mounted: false, listeners: new Set() }
823
-
824
- function flashPanelTitle() {
825
- for (const listener of [...panelState.listeners]) {
826
- try {
827
- listener()
828
- } catch (error) {
829
- console.error('orchd-tasks: title flash listener failed', error)
830
- }
831
- }
832
- }
833
-
834
- /**
835
- * 订阅标题脉冲。返回 `flashId`:每次闪烁 +1,调用方把它当 `key` 用,
836
- * 靠**重挂载**让 CSS 动画能重放(同一元素上重复加同一个属性值不会重启动画)。
837
- */
838
- function useTitleFlash() {
839
- const [flashId, setFlashId] = React.useState(0)
840
- React.useEffect(() => {
841
- const listener = () => setFlashId((value) => value + 1)
842
- panelState.listeners.add(listener)
843
- return () => panelState.listeners.delete(listener)
844
- }, [])
845
- return flashId
846
- }
847
-
801
+ // 标题脉冲环已移除:入口按钮改为「打开 / 关闭」toggle(见 OrchdToggleButton),
802
+ // 再点有可见反馈(面板收起),不再需要「面板本来开着 ⇒ 点了没反应」的提示。
848
803
 
849
804
  async function writeClipboard(text) {
850
805
  if (navigator.clipboard?.writeText !== undefined) {
@@ -1449,10 +1404,25 @@ window.__ModuleLoader__.load({
1449
1404
  return counts
1450
1405
  }
1451
1406
 
1452
- /** 默认「活跃优先,其次重要度」(FR-5.4)。 */ function sortRows(rows, by) {
1407
+ /** 默认「活跃优先 → 组内按时间」:活跃按最近动作、终态按合并时间(FR-5.4 修订)。 */ function sortRows(rows, by) {
1453
1408
  const rank = (value) => ({ critical: 4, high: 3, normal: 2, low: 1 })[value] ?? 0
1454
1409
  const diff = (value) => ({ high: 3, medium: 2, low: 1 })[value] ?? 0
1455
1410
  const activeFirst = (status) => (status === 'claimed' || status === 'done' || status === 'in_review' ? 0 : status === 'completed' || status === 'cancelled' ? 2 : 1)
1411
+ // 时间戳来自宿主(`lastActionAt` / `mergedAt`,git 近似;取不到为 null → 排本组最后)
1412
+ const timeOf = (row) => {
1413
+ const raw = row.status === 'completed' || row.status === 'cancelled' ? row.mergedAt : row.lastActionAt
1414
+ if (typeof raw !== 'string' || raw === '') return null
1415
+ const parsed = Date.parse(raw)
1416
+ return Number.isFinite(parsed) ? parsed : null
1417
+ }
1418
+ const byTime = (a, b) => {
1419
+ const ta = timeOf(a)
1420
+ const tb = timeOf(b)
1421
+ if (ta === null && tb === null) return 0
1422
+ if (ta === null) return 1
1423
+ if (tb === null) return -1
1424
+ return tb - ta
1425
+ }
1456
1426
  const copy = [...rows]
1457
1427
  const byId = (a, b) => String(a.id ?? '').localeCompare(String(b.id ?? ''))
1458
1428
  switch (by) {
@@ -1465,7 +1435,7 @@ window.__ModuleLoader__.load({
1465
1435
  case 'status':
1466
1436
  return copy.sort((a, b) => String(a.status ?? '~').localeCompare(String(b.status ?? '~')) || byId(a, b))
1467
1437
  default:
1468
- return copy.sort((a, b) => activeFirst(a.status) - activeFirst(b.status) || rank(b.importance ?? b.importanceDerived) - rank(a.importance ?? a.importanceDerived) || byId(a, b))
1438
+ return copy.sort((a, b) => activeFirst(a.status) - activeFirst(b.status) || byTime(a, b) || rank(b.importance ?? b.importanceDerived) - rank(a.importance ?? a.importanceDerived) || byId(a, b))
1469
1439
  }
1470
1440
  }
1471
1441
 
@@ -2250,26 +2220,44 @@ window.__ModuleLoader__.load({
2250
2220
 
2251
2221
  // ── FR-13 想法看板 ────────────────────────────────────────────────────
2252
2222
  /**
2253
- * 想法状态顺序(引擎顺序:study → questioning → pending → taskified → dropped)。
2254
- * 统计条**固定**展示这 5 个(0 值可点),这样三分区在界面上闭合:
2255
- * `全部 = 活跃 + 已转任务 + 已丢弃`(活跃 = 论证中 + 待补充 + 待拆解)。
2223
+ * 想法展示桶顺序(4 个引擎状态 + 派生桶 `converted`)。
2224
+ * 统计条**固定**展示这些(0 值可点),这样三分区在界面上闭合:
2225
+ * `全部 = 活跃 + 已转任务 + 已覆盖`(活跃 = 论证中 + 待补充 + 待拆解)。
2256
2226
  * 集合以宿主下发的 `activeStatuses` 为准,这里只是同源兜底。
2227
+ *
2228
+ * 引擎 2026-10-23 调整:`taskified` 正名为 `covered`(撞车并入已有任务、待归档),
2229
+ * `dropped` 从活跃台账下线(放弃改走 `idea close --reason`)。`converted` 是
2230
+ * **派生桶**(任务定义层 source/additional_sources 指向该想法),不是引擎状态。
2257
2231
  */
2258
- const IDEA_STATUSES_UI = ['study', 'questioning', 'pending', 'taskified', 'dropped']
2232
+ const IDEA_STATUSES_UI = ['study', 'questioning', 'pending', 'converted', 'covered']
2259
2233
 
2260
2234
  /**
2261
- * 想法状态 → i18n 键。
2235
+ * 想法展示桶 → i18n 键。
2262
2236
  *
2263
- * ⚠ 这 5 个状态是**引擎定义的**(`study` / `questioning` / `pending` / `taskified` / `dropped`),
2264
- * 不得自造。引擎将来加了新状态时解析层会把 `status` 置 null 并保留原文,
2265
- * 这里显示「未知状态 + 原文」,不崩、不猜。
2237
+ * ⚠ 前 4 个是**引擎定义的**(`study` / `questioning` / `pending` / `covered`),
2238
+ * 不得自造;`converted` 是本插件按定义层溯源派生的展示桶。引擎将来加了新状态时
2239
+ * 解析层会把 `status` 置 null 并保留原文,这里显示「未知状态 + 原文」,不崩、不猜。
2266
2240
  */
2267
2241
  const IDEA_STATUS_I18N = {
2268
2242
  study: 'iStudy',
2269
2243
  questioning: 'iQuestioning',
2270
2244
  pending: 'iPending',
2271
- taskified: 'iTaskified',
2272
- dropped: 'iDropped'
2245
+ converted: 'iConverted',
2246
+ covered: 'iCovered'
2247
+ }
2248
+
2249
+ /** 条目的展示桶:派生桶优先,其次才是引擎字面状态。 */
2250
+ function ideaDisplayStatus(entry) {
2251
+ if (entry === null || entry === undefined) return null
2252
+ if (typeof entry.displayStatus === 'string') return entry.displayStatus
2253
+ if (entry.converted === true) return 'converted'
2254
+ return entry.status ?? null
2255
+ }
2256
+
2257
+ /** 展示桶文案;未知状态显式说「未知」,不猜、不崩。 */
2258
+ function ideaStatusText(entry, t) {
2259
+ const status = ideaDisplayStatus(entry)
2260
+ return status === null ? t('ideasStatusUnknown') : t(IDEA_STATUS_I18N[status] ?? 'ideasStatusUnknown')
2273
2261
  }
2274
2262
 
2275
2263
  /** 想法的「在等谁」文案(FR-13.4:**不得只给状态码**)。 */
@@ -2293,29 +2281,30 @@ window.__ModuleLoader__.load({
2293
2281
  /**
2294
2282
  * 想法列表(FR-13.1/13.3/13.4)。
2295
2283
  *
2296
- * 与任务列表用**同一套**状态条筛选组件(FR-13.6),只是标签换成 5 个想法状态;
2284
+ * 与任务列表用**同一套**状态条筛选组件(FR-13.6),只是标签换成想法展示桶;
2297
2285
  * 徽章数在调用方(视图切换)显示,这里不重复。
2298
2286
  */
2299
2287
  function IdeasBoard({ data, filters, setFilters, t, onOpen }) {
2300
2288
  const entries = data?.entries ?? []
2301
2289
  const counts = data?.counts?.byStatus ?? {}
2302
- // ⚠ 统计条只放**活跃层可能出现的状态**(study/questioning/pending)。
2303
- // 统计条固定展示全部 5 个想法状态(0 值可点),以便三分区在界面上**闭合**:
2304
- // 全部 = 活跃 + 已转任务 + 已丢弃 (活跃 = 论证中 + 待补充 + 待拆解)
2290
+ // 统计条固定展示全部展示桶(4 个引擎状态 + 派生桶 converted,0 值可点),
2291
+ // 以便三分区在界面上**闭合**:
2292
+ // 全部 = 活跃 + 已转任务 + 已覆盖 (活跃 = 论证中 + 待补充 + 待拆解)
2305
2293
  // 集合由宿主下发(`activeStatuses`),客户端只做兜底。
2306
2294
  const labels = Array.isArray(data?.activeStatuses) && data.activeStatuses.length > 0
2307
2295
  ? data.activeStatuses
2308
2296
  : IDEA_STATUSES_UI
2309
- // 归档分组:与任务池对照得出(已转任务 / 已丢弃 / 未立任务 / 无 id)
2297
+ // 归档分组:与任务池对照得出(已转任务 / 历史已放弃 / 未立任务 / 无 id)
2310
2298
  const archived = data?.archived ?? {}
2311
2299
  const archivedTotal = data?.archivedCount ?? 0
2312
2300
 
2313
2301
  const filtered = entries.filter((entry) => {
2314
2302
  const mode = filters.mode
2303
+ const display = ideaDisplayStatus(entry)
2315
2304
  if (mode === 'active') {
2316
- // 想法没有「终态」概念,用「已离队」等价表达:active = 仍在队里
2317
- if (entry.status === 'taskified' || entry.status === 'dropped') return false
2318
- } else if (mode !== 'all' && entry.status !== mode) {
2305
+ // 活跃 = 仍待处理:已转任务(converted)与已覆盖(covered)都不算
2306
+ if (display === 'converted' || display === 'covered') return false
2307
+ } else if (mode !== 'all' && display !== mode) {
2319
2308
  return false
2320
2309
  }
2321
2310
  if ((filters.keyword ?? '') !== '') {
@@ -2327,10 +2316,10 @@ window.__ModuleLoader__.load({
2327
2316
 
2328
2317
  const summaryForStrip = {
2329
2318
  total: data?.counts?.total ?? entries.length,
2330
- // 「活跃」用宿主给的数(已扣除定义层已立任务的条目);拿不到时退回本地口径
2319
+ // 「活跃」用宿主给的数(已扣除已转任务 / 已覆盖);拿不到时退回本地口径
2331
2320
  active: typeof data?.active === 'number'
2332
2321
  ? data.active
2333
- : entries.filter((entry) => entry.status !== 'taskified' && entry.status !== 'dropped').length,
2322
+ : entries.filter((entry) => !['converted', 'covered'].includes(ideaDisplayStatus(entry))).length,
2334
2323
  ...counts,
2335
2324
  source: 'engine'
2336
2325
  }
@@ -2383,8 +2372,8 @@ window.__ModuleLoader__.load({
2383
2372
  archivedTotal === 0
2384
2373
  ? `${t('ideasArchived')} 0 ${t('ideasArchivedUnit')}`
2385
2374
  : `${t('ideasArchived')} ${archivedTotal}${t('ideasArchivedUnit')}(${[
2386
- `${t('ideasConverted')} ${archived.taskified ?? 0}`,
2387
- `${t('ideasStatusDropped')} ${archived.dropped ?? 0}`,
2375
+ `${t('ideasConverted')} ${archived.converted ?? 0}`,
2376
+ `${t('ideasArchiveLegacyDropped')} ${archived.legacyDropped ?? 0}`,
2388
2377
  `${t('ideasUntasked')} ${archived.untasked ?? 0}`,
2389
2378
  `${t('ideasNoId')} ${archived.noId ?? 0}`
2390
2379
  ].join(' · ')})`),
@@ -2448,7 +2437,7 @@ window.__ModuleLoader__.load({
2448
2437
  React.createElement('span', { key: 'm', style: styles.rowMeta }, [
2449
2438
  React.createElement('span', { key: 's', style: { ...styles.statusTag, color: ideaOwnerToken(entry.owner) } }, [
2450
2439
  React.createElement('span', { key: 'dd', style: styles.chipDot }),
2451
- React.createElement('span', { key: 'l' }, entry.status === null ? t('ideasStatusUnknown') : t(IDEA_STATUS_I18N[entry.status] ?? 'ideasStatusUnknown')),
2440
+ React.createElement('span', { key: 'l' }, ideaStatusText(entry, t)),
2452
2441
  React.createElement('span', { key: 'o' }, ` · ${ideaOwnerText(entry.owner, t)}`)
2453
2442
  ]),
2454
2443
  // 已转任务:由定义层溯源推出(status 只表达摄入/裁决态,不表达「已立任务」),顺带给出任务数量
@@ -2519,7 +2508,7 @@ window.__ModuleLoader__.load({
2519
2508
  style: { ...styles.statusTag, color: ideaOwnerToken(entry.owner) }
2520
2509
  }, [
2521
2510
  React.createElement('span', { key: 'd', style: styles.chipDot }),
2522
- React.createElement('span', { key: 'l' }, entry.status === null ? t('ideasStatusUnknown') : t(IDEA_STATUS_I18N[entry.status] ?? 'ideasStatusUnknown')),
2511
+ React.createElement('span', { key: 'l' }, ideaStatusText(entry, t)),
2523
2512
  React.createElement('span', { key: 'o' }, ` · ${ideaOwnerText(entry.owner, t)}`)
2524
2513
  ])
2525
2514
  ]),
@@ -2581,11 +2570,13 @@ window.__ModuleLoader__.load({
2581
2570
  *
2582
2571
  * 命令形态来自引擎实测(`orchd idea <c> --help`):
2583
2572
  * `append --id <id> --notes …`(按 `- id:` 精确值定位)
2584
- * `confirm/drop --title <title>`、`archive --title <title> --reason …`
2585
- * —— 后三者的 `--title` 支持「裸 slug」,而裸 slug 与 `- id:` 精确相等,故直接用 id。
2573
+ * `confirm --title <title>`、`close --title <title> --reason …`
2574
+ * —— 后两者的 `--title` 支持「裸 slug」,而裸 slug 与 `- id:` 精确相等,故直接用 id。
2586
2575
  *
2587
- * 按**有效状态**给(`study` 才能 confirm/drop;pending/questioning/taskified 走 archive)。
2576
+ * 引擎 2026-10-23 起人工离场出口统一为 `idea close --reason`(旧 drop / archive 已下线):
2577
+ * study/questioning/pending/covered 全部走 close;`study` 另给 confirm(升 pending)。
2588
2578
  * 在途任务引用期间引擎会拒绝搬走(`source_live_reference`),这是引擎的守卫,不是错误。
2579
+ * 判定用**字面 status**(`converted` 的条目字面仍是 pending)。
2589
2580
  */
2590
2581
  function ideaCommandsFor(entry) {
2591
2582
  const id = typeof entry.id === 'string' && entry.id !== '' ? entry.id : null
@@ -2593,9 +2584,9 @@ window.__ModuleLoader__.load({
2593
2584
  const commands = [{ key: 'append', text: `python .orchd/__main__.py idea append --id ${id} --notes "<追记内容>"` }]
2594
2585
  if (entry.status === 'study') {
2595
2586
  commands.push({ key: 'confirm', text: `python .orchd/__main__.py idea confirm --title ${id}` })
2596
- commands.push({ key: 'drop', text: `python .orchd/__main__.py idea drop --title ${id}` })
2597
- } else if (entry.status === 'questioning' || entry.status === 'pending' || entry.status === 'taskified') {
2598
- commands.push({ key: 'archive', text: `python .orchd/__main__.py idea archive --title ${id} --reason "<裁决理由>"` })
2587
+ commands.push({ key: 'close', text: `python .orchd/__main__.py idea close --title ${id} --reason "<裁决理由>"` })
2588
+ } else if (entry.status === 'questioning' || entry.status === 'pending' || entry.status === 'covered') {
2589
+ commands.push({ key: 'close', text: `python .orchd/__main__.py idea close --title ${id} --reason "<裁决理由>"` })
2599
2590
  }
2600
2591
  return commands
2601
2592
  }
@@ -2625,13 +2616,6 @@ window.__ModuleLoader__.load({
2625
2616
  // 每会话记忆(FR-12.2):视图与筛选状态按 sessionId 隔离保存,切走再切回可恢复
2626
2617
  const sessionMemory = React.useRef(new Map())
2627
2618
 
2628
- // 面板是否已挂载:图标被点时用来判断「面板本来就开着」(这时才需要在标题上闪一下指路)
2629
- React.useEffect(() => {
2630
- panelState.mounted = true
2631
- return () => {
2632
- panelState.mounted = false
2633
- }
2634
- }, [])
2635
2619
 
2636
2620
  // 会话切换时清掉「当前打开的任务/想法」(视图状态按会话隔离)
2637
2621
  React.useEffect(() => {
@@ -3094,21 +3078,17 @@ window.__ModuleLoader__.load({
3094
3078
  /** tab chip 标题(sidebar.right.pane.tab.title,keyed/session)。 */
3095
3079
  function OrchdTasksTitle(props) {
3096
3080
  const t = typeof props?.t === 'function' ? props.t : (key) => key
3097
- // 点图标时若面板本来就开着,就在标题上闪一下(见 flashPanelTitle 的注释)。
3098
- // `flashId` 当 `key` 用:每次闪烁重挂载标题,CSS 动画才能重放。
3099
- const flashId = useTitleFlash()
3100
3081
  return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6 } }, [
3101
3082
  React.createElement(OrchdIcon, { key: 'i', size: 14 }),
3102
- React.createElement('span', {
3103
- key: `t-${flashId}`,
3104
- 'data-orchd-title-flash': flashId > 0 ? 'true' : 'false'
3105
- }, t('title'))
3083
+ React.createElement('span', null, t('title'))
3106
3084
  ])
3107
3085
  }
3108
3086
 
3109
3087
  /**
3110
3088
  * 会话 header 的开关按钮。
3111
3089
  *
3090
+ * 点击 = 打开 / 关闭(面板正显示时收起右侧栏,否则打开并聚焦本面板)。
3091
+ *
3112
3092
  * 席位是 conversation.session.header.utilities,kind 为 list、scope 为 session。
3113
3093
  * 不要用 conversation.session.header.corner:那是 **single** 席位,右侧栏自带的
3114
3094
  * 「展开」按钮已经占着它;single 在同一优先级下只允许一个占位者,重复注册会抛错
@@ -3120,13 +3100,11 @@ window.__ModuleLoader__.load({
3120
3100
  type: 'button',
3121
3101
  style: styles.iconButton,
3122
3102
  onClick: () => {
3123
- // 面板**本来就开着**时 openTab 只是聚焦,界面毫无变化 → 在标题上闪一下指路
3124
- // (面板本来是关的就不闪:tab 出现本身就是反馈)
3125
- if (panelState.mounted) flashPanelTitle()
3103
+ // 点击只是「打开 / 关闭」:面板正显示时收起,否则打开并聚焦本面板。
3126
3104
  try {
3127
- props.open?.()
3105
+ props.toggle?.()
3128
3106
  } catch (error) {
3129
- console.error('orchd-tasks: openTab failed', error)
3107
+ console.error('orchd-tasks: toggle failed', error)
3130
3108
  }
3131
3109
  },
3132
3110
  'aria-label': t('toggleLabel'),
@@ -3220,7 +3198,17 @@ window.__ModuleLoader__.load({
3220
3198
  order: 30,
3221
3199
  locale: NS,
3222
3200
  inject: () => ({
3223
- open: () => ctx.sidebarRight.openTab(TAB_KIND)
3201
+ // 点击 = 打开 / 关闭:面板正显示时收起,否则打开并聚焦本面板。
3202
+ // 用平台 sidebarRight 的真实状态(isExpanded / active),不靠本地「挂载与否」猜测。
3203
+ toggle: () => {
3204
+ const sidebar = ctx.sidebarRight
3205
+ const active = typeof sidebar?.active === 'function' ? sidebar.active() : undefined
3206
+ if (typeof sidebar?.isExpanded === 'function' && sidebar.isExpanded() && active?.kind === TAB_KIND) {
3207
+ sidebar.toggleExpanded()
3208
+ } else {
3209
+ sidebar.openTab(TAB_KIND)
3210
+ }
3211
+ }
3224
3212
  })
3225
3213
  }, OrchdToggleButton)),
3226
3214
  'orchd-tasks: header toggle'
@@ -3237,7 +3225,7 @@ window.__ModuleLoader__.load({
3237
3225
  * 必须有单测兜底。测试用 `test/client.test.mjs` 的 `vm` + 假 `window.__ModuleLoader__`
3238
3226
  * 加载本文件再取这个对象断言。
3239
3227
  */
3240
- exports.__testables = { applyFilters, sortRows, countByStatus, TASK_STATUS_I18N, ALL_STATUSES, styles, IDEA_STATUSES_UI, IDEA_STATUS_I18N, ideaCommandsFor, computeThumb, scrollTopForThumbTop, SCROLLBAR_WIDTH, SCROLLBAR_MIN_THUMB, ROW_HEIGHT, IDEA_ROW_HEIGHT, interactionCss, INTERACTION_CSS_TAG, layoutDependencyGraph, graphAnomalyRows, graphNodeLabel, truncateLabel, graphTextUnits, GRAPH_NODE_W, GRAPH_NODE_H, GRAPH_GAP_X, GRAPH_GAP_Y }
3228
+ exports.__testables = { applyFilters, sortRows, countByStatus, TASK_STATUS_I18N, ALL_STATUSES, styles, IDEA_STATUSES_UI, IDEA_STATUS_I18N, ideaCommandsFor, ideaDisplayStatus, ideaStatusText, computeThumb, scrollTopForThumbTop, SCROLLBAR_WIDTH, SCROLLBAR_MIN_THUMB, ROW_HEIGHT, IDEA_ROW_HEIGHT, interactionCss, INTERACTION_CSS_TAG, layoutDependencyGraph, graphAnomalyRows, graphNodeLabel, truncateLabel, graphTextUnits, GRAPH_NODE_W, GRAPH_NODE_H, GRAPH_GAP_X, GRAPH_GAP_Y }
3241
3229
  return module.exports
3242
3230
  }
3243
3231
  })
package/lib/cache.js CHANGED
@@ -106,6 +106,11 @@ export function createProjectCaches(config) {
106
106
  review: createTtlCache({ ttlMs: 60_000, maxEntries: limit * 4 }),
107
107
  /** IDEAS 清单(`ideas list`):变化慢,用 pollTtlMs(FR-13)。 */
108
108
  ideas: createTtlCache({ ttlMs: config.pollTtlMs, maxEntries: limit }),
109
+ /**
110
+ * git 时间层(FR-5.4 修订):默认排序要用的「最近动作时间 / 合并时间」。
111
+ * 走两条只读 git 命令,变化比状态层慢,用 pollTtlMs(与 pool 同档)。
112
+ */
113
+ gitTimes: createTtlCache({ ttlMs: config.pollTtlMs, maxEntries: limit }),
109
114
  /** IDEAS 正文与归档计数:按文件指纹缓存(FR-13.12:归档不进默认加载路径)。 */
110
115
  ideasFiles: createTtlCache({ ttlMs: config.pollTtlMs, maxEntries: limit * 2 }),
111
116
  /** 定义层:TTL 视为无限,靠文件指纹失效(见 `definitionFingerprint`)。 */
package/lib/cli-invoke.js CHANGED
@@ -30,7 +30,10 @@ export function normalizeCliResult(result) {
30
30
  reason: result.outcome === 'ok' ? null : (result.reason ?? 'cli-failed'),
31
31
  elapsedMs: result.elapsedMs,
32
32
  argv: result.argv,
33
- stderr: result.stderr ?? null
33
+ stderr: result.stderr ?? null,
34
+ // 启动器定位结果(`worktree` 或 `container-root-fallback`):出问题时能直接看出用了哪一份入口
35
+ launcher: result.launcher ?? null,
36
+ launcherSource: result.launcherSource ?? null
34
37
  }
35
38
  }
36
39
 
@@ -98,6 +101,8 @@ export function createCliInvoker(config, runner = runOrchdCli, logger = null) {
98
101
  const result = normalizeCliResult(await runner({
99
102
  root: ctx.root,
100
103
  workDir: ctx.workDir ?? ctx.mainWorktree,
104
+ // 容器根(`resolveLayout` 给出):启动器在「工作树那份不存在」时可回退到它下面那一份
105
+ containerRoot: ctx.containerRoot ?? null,
101
106
  python: ctx.python,
102
107
  argv,
103
108
  timeoutMs,
@@ -121,10 +126,10 @@ export function createCliInvoker(config, runner = runOrchdCli, logger = null) {
121
126
  * ⚠ 引擎**只返回 3 个字段**(`title` / `id` / `status`),正文必须另读 `IDEAS.md`。
122
127
  */
123
128
  ideasList: (ctx) => call(ctx, ['ideas', 'list']),
124
- /** 供 `detect()` 使用的形态。`detect` 会传 `workDir`(它自己从 layout 取)。 */
125
- detectRunner: async ({ root, python, workDir, command }) => {
129
+ /** 供 `detect()` 使用的形态。`detect` 会传 `workDir` 与 `containerRoot`(它自己从 layout 取)。 */
130
+ detectRunner: async ({ root, python, workDir, containerRoot, command }) => {
126
131
  const argv = Array.isArray(command) ? command : ['status', '--all']
127
- const result = await call({ root, python, workDir }, argv)
132
+ const result = await call({ root, python, workDir, containerRoot }, argv)
128
133
  return {
129
134
  timedOut: result.timedOut,
130
135
  exitCode: result.exitCode,
@@ -132,7 +137,9 @@ export function createCliInvoker(config, runner = runOrchdCli, logger = null) {
132
137
  engineError: result.engineError,
133
138
  reason: result.reason,
134
139
  elapsedMs: result.elapsedMs,
135
- stderr: result.stderr
140
+ stderr: result.stderr,
141
+ launcher: result.launcher,
142
+ launcherSource: result.launcherSource
136
143
  }
137
144
  }
138
145
  }