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/lib/master.js CHANGED
@@ -36,15 +36,17 @@ export function launcherOf(root) {
36
36
  return join(orchdDirOf(root), LAUNCHER_FILENAME)
37
37
  }
38
38
 
39
- /**
40
- * 安装痕迹判定(T6 的定稿):项目根下存在 「.orchd」 目录即算「有痕迹」。
41
- * 不把 junction/符号链接排除在外——参考项目的 「.orchd」 正是指向真实目录的 junction。
42
- * @param {string} root
43
- * @returns {boolean}
44
- */
45
- export function hasInstallationTrace(root) {
46
- return isDirectory(orchdDirOf(root))
47
- }
39
+ // ── 安装痕迹不再是独立的判定步骤 ────────────────────────────────────────────
40
+ //
41
+ // 历史坑(根因治理前):这里曾导出 `hasInstallationTrace(root)`(= 「根下有没有 `.orchd`」)
42
+ // 作为 `detect` 的第 ① 步,而 `resolveLayout` 又自带一套容器主工作树自动探测。两条路径对
43
+ // 「痕迹在哪」的口径不一致 → 每出现一种新布局形态就得在其中一条上补特例、另一条漏掉
44
+ // (junction / 根空 `.orchd` / 根**无** `.orchd` / 子目录标记),于是来回打补丁:
45
+ // 「3DViewer 空 `.orchd`」修过一次,「容器根连 `.orchd` 都没有」又漏一次。
46
+ //
47
+ // 现在把「是否接入 + 主工作树/定义文件在哪」**收敛为唯一真源**:`resolveLayout`。
48
+ // 它返回的 `no-installation` 就是「根与子工作树都没有安装痕迹」这唯一一种结论,
49
+ // `detect` 只消费它,不再有任何独立的痕迹判定。
48
50
 
49
51
  /**
50
52
  * 自动探测「容器布局的主工作树」(FR-2.2 补充,用户实测驱动)。
@@ -127,7 +129,17 @@ function findContainerWorktree(root) {
127
129
  }
128
130
 
129
131
  /**
130
- * 解析布局并推导三套路径。
132
+ * 布局发现 —— 「是否接入 orchd」与「主工作树/定义文件在哪」的**唯一真源**。
133
+ *
134
+ * 所有布局形态(junction / 根空 `.orchd` / 根**无** `.orchd` / 子目录标记 / 标记与实际工作树
135
+ * 不一致)都在**这一个函数**里收敛;`detect` 只消费它的结论,不再另判安装痕迹(历史坑见上方注释)。
136
+ *
137
+ * 三种结论:
138
+ * · resolved → `ok:true`,带 kind / mainWorktree / masterPath / storeHint
139
+ * · broken → `ok:false`,reason ∈ { layout-marker-mismatch, layout-unresolved }
140
+ * · no-installation → `ok:false`,reason = no-installation ← detect 据此判 not_installed
141
+ *
142
+ * 三套路径:
131
143
  *
132
144
  * 「」「
133
145
  * container → mainWorktree = json.main_worktree
@@ -137,19 +149,28 @@ function findContainerWorktree(root) {
137
149
  * → mainWorktree = root
138
150
  * masterPath = <root>/.orchd/_master.json
139
151
  * storeHint = <root>/.orchd
140
- * 探针兜底:masterPath 不存在则试另一分支候选;都失败 → layout-unresolved
152
+ * 探针兜底:主候选 masterPath 不存在则试另一分支候选
153
+ *
154
+ * 探针兜底后,扫一层非隐藏子目录找容器主工作树(见 findContainerWorktree):
155
+ * 找到(自述 container / 有 _master.json / 有 __main__.py)→ container
156
+ * 找不到时再看根自己有没有 `.orchd`:
157
+ * 有(哪怕空目录)→ flat(是否 uninitialized 交给 detect 的定义层判定)
158
+ * 没有 → no-installation(根与子工作树都没有痕迹)
159
+ * container 标记下两边 master 都不在 → layout-unresolved
141
160
  * 「」「
142
161
  *
143
162
  * @param {string} root 项目根(可能是容器根)
144
- * @param {{ probeFallback?: boolean }} [options] probeFallback=false 时跳过探针兜底(纯逻辑测试用)
145
- * @returns {{ ok: true, kind: 'container'|'flat', mainWorktree: string, masterPath: string, storeHint: string,
146
- * markerPath: string, markerRead: boolean, markerError?: string, note?: string }
147
- * | { ok: false, reason: 'layout-unresolved', detail: object }}
163
+ * @param {{ probeFallback?: boolean }} [options] probeFallback=false 时跳过探针兜底与自动探测(纯逻辑测试用)
164
+ * @returns {{ ok: true, kind: 'container'|'flat', mainWorktree: string, masterPath: string, storeHint: string,
165
+ * containerRoot: string|null, markerPath: string, markerRead: boolean, markerError?: string, note?: string }
166
+ * | { ok: false, reason: 'layout-marker-mismatch'|'layout-unresolved'|'no-installation', detail: object }}
148
167
  */
149
168
  export function resolveLayout(root, options = {}) {
150
169
  const probeFallback = options.probeFallback !== false
151
170
  const rootCanonical = canonicalPath(root)
152
171
  const markerPath = layoutFileOf(rootCanonical)
172
+ // 「根自己有没有 .orchd」是区分 flat 与 no-installation 的唯一依据(即原 hasInstallationTrace 的语义)。
173
+ const rootHasOrchd = isDirectory(orchdDirOf(rootCanonical))
153
174
 
154
175
  let marker
155
176
  let markerRead = false
@@ -167,6 +188,10 @@ export function resolveLayout(root, options = {}) {
167
188
  ok: true,
168
189
  kind,
169
190
  mainWorktree: worktree,
191
+ // 容器根 = 主工作树的父目录(仅 container 布局有意义,flat 下为 null)。
192
+ // 用途:引擎可能装在容器根的 `.orchd/`(主工作树只有定义层),启动器定位要能回退过去
193
+ // —— 见 `orchd-cli.js::resolveLauncherPath`。
194
+ containerRoot: kind === 'container' ? dirname(worktree) : null,
170
195
  masterPath: join(orchdDir, MASTER_FILENAME),
171
196
  storeHint: kind === 'container' ? join(dirname(worktree), '.orchd-runtime') : orchdDir,
172
197
  markerPath,
@@ -223,9 +248,9 @@ export function resolveLayout(root, options = {}) {
223
248
  }
224
249
  }
225
250
 
226
- // ⚠ 根上**没有容器标记**(`alternate === null`)且根也没有定义文件时:
227
- // 再扫一层子目录找容器主工作树——这正是「容器根的 .orchd/ 是空的、标记只在 main/ 下」
228
- // 那种项目(用户实测识别不出来)。根上有标记时以标记为准,不在这里改判。
251
+ // ⚠ 根上**没有容器标记**(`alternate === null`)时:先扫一层子目录找容器主工作树
252
+ // (「容器根的标记只在 main/ 下」这种项目——不论根自己有没有 `.orchd` 都要找)。
253
+ // 找不到时再看根自己有没有 `.orchd`,决定是 flat 还是「未接入」。
229
254
  if (alternate === null) {
230
255
  const auto = findContainerWorktree(rootCanonical)
231
256
  if (auto !== null) {
@@ -235,23 +260,36 @@ export function resolveLayout(root, options = {}) {
235
260
  markerRead: auto.markerRead
236
261
  }
237
262
  }
238
- }
239
263
 
240
- if (alternate !== null) {
264
+ // 探测不到主工作树:根有 `.orchd`(哪怕空目录)→ flat(装了没初始化,交给 detect 判);
265
+ // 根连 `.orchd` 都没有 → 根与子工作树都没有痕迹 → 未接入。
266
+ if (rootHasOrchd) return primary
267
+
241
268
  return {
242
269
  ok: false,
243
- reason: 'layout-unresolved',
270
+ reason: 'no-installation',
244
271
  detail: {
245
272
  root: rootCanonical,
246
273
  markerPath,
247
274
  markerRead,
248
275
  ...(markerError === undefined ? {} : { markerError }),
249
- triedMasterPaths: [primary.masterPath, alternate.masterPath]
276
+ hint: 'no .orchd directory at the project root and no container main worktree found under it'
250
277
  }
251
278
  }
252
279
  }
253
280
 
254
- return primary
281
+ // container 标记在,但两边候选都没有主工作树的定义文件 → 布局无法收敛
282
+ return {
283
+ ok: false,
284
+ reason: 'layout-unresolved',
285
+ detail: {
286
+ root: rootCanonical,
287
+ markerPath,
288
+ markerRead,
289
+ ...(markerError === undefined ? {} : { markerError }),
290
+ triedMasterPaths: [primary.masterPath, alternate.masterPath]
291
+ }
292
+ }
255
293
  }
256
294
 
257
295
  /**
package/lib/model.js CHANGED
@@ -78,6 +78,8 @@ export function buildTaskRow(definition, state, extra = {}, weights = null) {
78
78
  // 状态层用 `task_id`(`status --all` 的 tasks[]),`pool --all` 用 `id`;两个都认。
79
79
  const stateId = state !== null && typeof state === 'object' ? (state.task_id ?? state.id) : undefined
80
80
  const id = definitionRow.id ?? (typeof stateId === 'string' ? stateId : undefined) ?? null
81
+ const times = extra.times ?? null
82
+ const timeOf = (map) => (typeof id === 'string' && typeof map?.[id] === 'string' ? map[id] : null)
81
83
 
82
84
  return {
83
85
  id,
@@ -99,6 +101,14 @@ export function buildTaskRow(definition, state, extra = {}, weights = null) {
99
101
  reviewPhase: stateRow.reviewPhase,
100
102
  reviewClaimedBy: stateRow.reviewClaimedBy,
101
103
  blockedDownstream: Number.isInteger(extra.blockedDownstream) ? extra.blockedDownstream : null,
104
+ /**
105
+ * 「最近动作时间」的 git 近似(FR-5.4 修订):活跃任务取 `task/<id>` 分支的 tip 时间。
106
+ * 引擎不提供任何时间字段,来源与口径见 `git-times.js`;取不到就是 null(排序兜底,
107
+ * **不臆造时间**,UI 若显示则必须走占位符)。
108
+ */
109
+ lastActionAt: timeOf(times?.branchTimes ?? null),
110
+ /** 「合并进主分支时间」的 git 近似:终态任务取 `Merge branch 'task/<id>'` 提交时间。 */
111
+ mergedAt: timeOf(times?.mergeTimes ?? null),
102
112
  inDefinition: definition !== undefined && definition !== null,
103
113
  inState: state !== undefined && state !== null
104
114
  }
@@ -155,7 +165,7 @@ export function buildTaskDetail(definition, state, index, extra = {}) {
155
165
  * @param {object} args.index 定义层索引
156
166
  * @param {string} [args.masterPath]
157
167
  */
158
- export function buildBoard({ statusJson, poolJson, index, masterPath }) {
168
+ export function buildBoard({ statusJson, poolJson, index, masterPath, gitTimes }) {
159
169
  const stateRows = new Map()
160
170
  for (const task of Array.isArray(statusJson?.tasks) ? statusJson.tasks : []) {
161
171
  if (task !== null && typeof task === 'object' && typeof task.task_id === 'string') stateRows.set(task.task_id, task)
@@ -175,7 +185,7 @@ export function buildBoard({ statusJson, poolJson, index, masterPath }) {
175
185
  for (const id of ids) {
176
186
  const definition = index?.tasks?.get(id)
177
187
  const state = stateRows.get(id)
178
- rows.push(buildTaskRow(definition, state, { blockedDownstream: blockedByTask.get(id) }, weights))
188
+ rows.push(buildTaskRow(definition, state, { blockedDownstream: blockedByTask.get(id), times: gitTimes }, weights))
179
189
  }
180
190
 
181
191
  sortRows(rows, 'default', weights)
@@ -196,7 +206,14 @@ export function buildBoard({ statusJson, poolJson, index, masterPath }) {
196
206
  hasDag: (index?.tasks?.size ?? 0) > 0,
197
207
  hasBlockedCount: blockedByTask.size > 0,
198
208
  hasDefinitionLayer: (index?.tasks?.size ?? 0) > 0,
199
- hasStateLayer: stateRows.size > 0
209
+ hasStateLayer: stateRows.size > 0,
210
+ /** 排序用的 git 时间层(FR-5.4 修订):活跃=分支 tip 时间、终态=合并提交时间。 */
211
+ hasActionTimes: Object.keys(gitTimes?.branchTimes ?? {}).length > 0,
212
+ hasMergeTimes: Object.keys(gitTimes?.mergeTimes ?? {}).length > 0,
213
+ /** git 时间层不可用时的原因(非 git 项目 / git 缺失 / 超时);可用时为 null。 */
214
+ gitTimesReason: gitTimes !== undefined && gitTimes !== null && gitTimes.available === false
215
+ ? (gitTimes.reason ?? 'unavailable')
216
+ : null
200
217
  },
201
218
  /** 两层不一致时显式报告(不静默丢数据)。 */
202
219
  orphans: { onlyInState, onlyInDefinition }
@@ -226,16 +243,45 @@ export function summarize(engineSummary, rows) {
226
243
  }
227
244
 
228
245
  /**
229
- * 排序。默认按「活跃优先,其次重要度」(FR-5.4)。
246
+ * 排序。默认档 = 「活跃优先 → 组内按时间」(FR-5.4 修订,2026-10-29):
247
+ * · 组:活跃(claimed / done / in_review)→ 待办(pending)→ 终态(completed / cancelled);
248
+ * · 活跃组内按 `lastActionAt` 由近到远(git 近似:`task/<id>` 分支 tip 时间);
249
+ * · 终态组内按 `mergedAt` 由近到远(git 近似:合并进主分支的提交时间);
250
+ * · 取不到时间的(未 claim 的 pending、cancelled、非 git 项目)留在本组最后,退回
251
+ * 「重要度 → id」——**不臆造时间**。
252
+ * 其它档位(importance / difficulty / module / status)行为不变。
253
+ *
230
254
  * @param {object[]} rows 原地排序
231
255
  * @param {'default'|'importance'|'difficulty'|'module'|'status'} by
256
+ * @param {object|null} weights 引擎的重要度权重
232
257
  */
233
258
  export function sortRows(rows, by = 'default', weights = null) {
234
259
  const statusRank = (status) => (ACTIVE_STATUSES.has(status) ? 0 : TERMINAL_STATUSES.has(status) ? 2 : 1)
260
+ // 时间戳只算一次(比较器会被调用 O(n log n) 次);非法值一律折叠成 null
261
+ const timeCache = new Map()
262
+ const timeValue = (row) => {
263
+ if (timeCache.has(row)) return timeCache.get(row)
264
+ const raw = TERMINAL_STATUSES.has(row.status) ? row.mergedAt : row.lastActionAt
265
+ const parsed = typeof raw === 'string' && raw !== '' ? Date.parse(raw) : Number.NaN
266
+ const value = Number.isFinite(parsed) ? parsed : null
267
+ timeCache.set(row, value)
268
+ return value
269
+ }
270
+ const byTime = (a, b) => {
271
+ const ta = timeValue(a)
272
+ const tb = timeValue(b)
273
+ if (ta === null && tb === null) return 0
274
+ if (ta === null) return 1
275
+ if (tb === null) return -1
276
+ return tb - ta
277
+ }
278
+ const byImportance = (a, b) =>
279
+ importanceRank(b.importance ?? b.importanceDerived, weights) - importanceRank(a.importance ?? a.importanceDerived, weights)
235
280
  const comparators = {
236
281
  default: (a, b) =>
237
282
  statusRank(a.status) - statusRank(b.status) ||
238
- importanceRank(b.importance ?? b.importanceDerived, weights) - importanceRank(a.importance ?? a.importanceDerived, weights) ||
283
+ byTime(a, b) ||
284
+ byImportance(a, b) ||
239
285
  (a.id ?? '').localeCompare(b.id ?? ''),
240
286
  importance: (a, b) => importanceRank(b.importance ?? b.importanceDerived, weights) - importanceRank(a.importance ?? a.importanceDerived, weights) || (a.id ?? '').localeCompare(b.id ?? ''),
241
287
  difficulty: (a, b) => difficultyRank(b.difficulty) - difficultyRank(a.difficulty) || (a.id ?? '').localeCompare(b.id ?? ''),
package/lib/orchd-cli.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { spawn } from 'node:child_process'
16
16
  import { launcherOf } from './master.js'
17
+ import { isFile, keyOf } from './paths.js'
17
18
 
18
19
  /** 子进程环境:逐项登记(docs/03 第 6.3 节)。 */
19
20
  export const CHILD_ENV = Object.freeze({
@@ -118,6 +119,55 @@ function parseJson(text) {
118
119
  }
119
120
  }
120
121
 
122
+ /**
123
+ * 启动器定位:按「同一工作树 → 会话根 → 布局声明的容器根」的顺序取第一个**真实存在**的
124
+ * `.orchd/__main__.py`。
125
+ *
126
+ * 背景(用户实测,WallDetect 形态):引擎的安装落点与主工作树**可以分离**——有的项目在
127
+ * 容器根跑安装器,引擎落在 `<容器根>/.orchd/`(含 `orchd/` 包与入口),而主工作树
128
+ * `<容器根>/main/.orchd/` 只有定义层(`_master.json` / `.layout.json` / `shared/` /
129
+ * `snapshots/`),**没有**引擎入口。此时按「启动器取主工作树」的旧逻辑会
130
+ * `can't open file ... __main__.py`(exit 2)→ 四态检测恒为 `broken/cli-failed`
131
+ * (面板显示「状态异常」,所有读命令连带失败)。
132
+ *
133
+ * 实测:`cwd = 主工作树` + `启动器 = 容器根 .orchd/__main__.py` → `exit=0`(正常返回 JSON)。
134
+ * 原因是启动器用 `Path(__file__).resolve()` 定位引擎包,**不依赖 cwd**;而 E036 约束只针对
135
+ * cwd,与启动器在哪一侧无关。所以 cwd 仍然必须是主工作树(见 `runOrchdCli` 头注释)。
136
+ *
137
+ * 三个候选的顺序与去重(按启动器路径去重,Windows 大小写不敏感):
138
+ * ① `cwd`(主工作树)—— 正常形态,也是历史行为;
139
+ * ② `root`(会话根)—— 会话根就是容器根时命中;
140
+ * ③ `containerRoot`(由 `resolveLayout` 给出,= 主工作树的父目录)—— 会话根是主工作树时命中。
141
+ *
142
+ * 三个都不存在时返回 ①:让解释器自己报错,stderr 里那条路径正好用于诊断。
143
+ *
144
+ * @param {string} cwd 工作目录(主工作树;flat 布局下等于项目根)
145
+ * @param {string} root 会话根(容器根或 flat 根或主工作树)
146
+ * @param {string|null} [containerRoot] 布局声明的容器根(flat 布局下为 null)
147
+ * @returns {{ path: string, source: 'worktree'|'container-root-fallback' }}
148
+ */
149
+ export function resolveLauncherPath(cwd, root, containerRoot) {
150
+ const seen = new Set()
151
+ const candidates = []
152
+ const add = (dir, source) => {
153
+ if (typeof dir !== 'string' || dir === '') return
154
+ const path = launcherOf(dir)
155
+ const key = keyOf(path)
156
+ if (seen.has(key)) return
157
+ seen.add(key)
158
+ candidates.push({ path, source })
159
+ }
160
+
161
+ add(cwd, 'worktree')
162
+ add(root, 'container-root-fallback')
163
+ add(containerRoot ?? null, 'container-root-fallback')
164
+
165
+ for (const candidate of candidates) {
166
+ if (isFile(candidate.path)) return candidate
167
+ }
168
+ return candidates[0]
169
+ }
170
+
121
171
  /**
122
172
  * 调用项目自带的引擎(唯一入口)。
123
173
  *
@@ -128,12 +178,14 @@ function parseJson(text) {
128
178
  * 所以 `workDir` 由调用方从 `layout.mainWorktree` 传进来;布局解析不出来时它等于项目根,
129
179
  * 这正好就是 flat 布局的情形,行为与 P0 一致。
130
180
  *
131
- * 启动器取同一个目录下的 `.orchd/__main__.py`。实测「主工作树 cwd + 容器根启动器(junction)」
132
- * 也能跑通,但那是靠 junction 的等价性,所以不采用。
181
+ * 启动器优先取**同一个工作树**下的 `.orchd/__main__.py`;该文件不存在时回退到项目根
182
+ * (容器根)的 `.orchd/__main__.py`——见 `resolveLauncherPath`:引擎的安装落点与主工作树
183
+ * 可以分离(用户实测的项目正是这种形态),该组合实测 `exit=0`。
133
184
  *
134
185
  * @param {object} args
135
186
  * @param {string} args.root 项目根(容器根或 flat 根)——仅用于诊断信息
136
187
  * @param {string} [args.workDir] 引擎工作目录(**必须是主工作树**)
188
+ * @param {string|null} [args.containerRoot] 布局声明的容器根(`resolveLayout` 给出;启动器回退候选之一)
137
189
  * @param {{path: string, args: string[]}} args.python 已探测到的解释器
138
190
  * @param {string[]} args.argv 例如 `['status', '--all']`
139
191
  * @param {number} [args.timeoutMs]
@@ -141,7 +193,7 @@ function parseJson(text) {
141
193
  * @param {Function} [args.spawnImpl] 仅测试用:替换 `child_process.spawn`
142
194
  * @returns {Promise<object>} 见下方各 return 分支
143
195
  */
144
- export async function runOrchdCli({ root, workDir, python, argv, timeoutMs = 20_000, signal, spawnImpl = spawn }) {
196
+ export async function runOrchdCli({ root, workDir, containerRoot, python, argv, timeoutMs = 20_000, signal, spawnImpl = spawn }) {
145
197
  const allowed = checkWhitelist(argv)
146
198
  if (allowed.ok !== true) {
147
199
  return {
@@ -159,7 +211,8 @@ export async function runOrchdCli({ root, workDir, python, argv, timeoutMs = 20_
159
211
  }
160
212
 
161
213
  const cwd = typeof workDir === 'string' && workDir !== '' ? workDir : root
162
- const launcher = launcherOf(cwd)
214
+ const chosenLauncher = resolveLauncherPath(cwd, root, containerRoot ?? null)
215
+ const launcher = chosenLauncher.path
163
216
  const command = python.path
164
217
  const commandArgs = [...(python.args ?? []), launcher, ...argv]
165
218
 
@@ -169,7 +222,8 @@ export async function runOrchdCli({ root, workDir, python, argv, timeoutMs = 20_
169
222
  const finish = (value) => {
170
223
  if (settled) return
171
224
  settled = true
172
- resolve({ ...value, elapsedMs: Date.now() - startedAt, argv })
225
+ // launcher / launcherSource 一并带出:回退发生时诊断区能直接看出用的是哪一份入口
226
+ resolve({ ...value, elapsedMs: Date.now() - startedAt, argv, launcher, launcherSource: chosenLauncher.source })
173
227
  }
174
228
 
175
229
  let child
package/lib/routes.js CHANGED
@@ -21,9 +21,10 @@ import { detect, summarizeLayout } from './detect.js'
21
21
  import { cachedDefinition } from './cache.js'
22
22
  import { readMasterIndexed } from './master.js'
23
23
  import { buildBoard, buildGraph, buildReviewTimeline, buildTaskDetail } from './model.js'
24
+ import { collectGitTimes } from './git-times.js'
24
25
  import { isTaskId, isUnsupportedCommand } from './cli-invoke.js'
25
26
  import { INSTALL_COMMAND, buildInstallPrompt, injectInstallPrompt, probeInjectSupport } from './install-entry.js'
26
- import { IDEA_OWNER, IDEA_STATUSES, applyConvertedStatus, buildIdeaTaskIndex, classifyArchive, ideaStripStatuses, ideasArchiveOf, ideasFileOf, parseIdeasMarkdown, scanArchiveFile, summarizeIdeas } from './ideas.js'
27
+ import { IDEA_OWNER, IDEA_STATUSES, attachConvertedTasks, buildIdeaTaskIndex, classifyArchive, ideaStripStatuses, ideasArchiveOf, ideasFileOf, parseIdeasMarkdown, scanArchiveFile, summarizeIdeas } from './ideas.js'
27
28
  import { ORCHD_OFFICIAL_REPO, ORCHD_MIRROR_REPO, resolveRepo } from './config.js'
28
29
  import { keyOf } from './paths.js'
29
30
 
@@ -138,11 +139,26 @@ async function loadDefinition(deps, detected) {
138
139
  return read?.ok === true ? read.index : undefined
139
140
  }
140
141
 
142
+ /**
143
+ * 引擎调用上下文:**每个调用点都走这里**,保证两个位置信息都带上——
144
+ * · `workDir` = 主工作树(E036 约束:container 布局下引擎拒绝以容器根为 cwd);
145
+ * · `containerRoot` = 布局声明的容器根(引擎装在容器根、主工作树只有定义层时,
146
+ * 启动器要从那里回退取,见 `orchd-cli.js::resolveLauncherPath`)。
147
+ */
148
+ function cliTarget(detected) {
149
+ return {
150
+ root: detected.root,
151
+ workDir: detected.layout?.mainWorktree,
152
+ containerRoot: detected.layout?.containerRoot ?? null,
153
+ python: detected.python
154
+ }
155
+ }
156
+
141
157
  /** 状态层 `status --all`(5 s TTL;失败不污染缓存由 cache 保证)。 */
142
158
  async function loadStatusLayer(deps, detected, refresh) {
143
159
  const root = detected.root
144
160
  return deps.caches.status.get(keyOf(root), refresh, () =>
145
- deps.cli.statusAll({ root, workDir: detected.layout?.mainWorktree, python: detected.python })
161
+ deps.cli.statusAll(cliTarget(detected))
146
162
  )
147
163
  }
148
164
 
@@ -150,10 +166,24 @@ async function loadStatusLayer(deps, detected, refresh) {
150
166
  async function loadPoolLayer(deps, detected, refresh) {
151
167
  const root = detected.root
152
168
  return deps.caches.pool.get(keyOf(root), refresh, () =>
153
- deps.cli.poolAll({ root, workDir: detected.layout?.mainWorktree, python: detected.python })
169
+ deps.cli.poolAll(cliTarget(detected))
154
170
  )
155
171
  }
156
172
 
173
+ /**
174
+ * git 时间层(FR-5.4 修订):默认排序要的「最近动作时间 / 合并时间」。
175
+ *
176
+ * 为什么走 git 而不是引擎:`status --all` 不返回任何时间字段,唯一带时间戳的账本
177
+ * 插件不直读(A2)。口径与只读纪律见 `git-times.js` 头注释。
178
+ * 失败/非 git 项目返回 `available:false`(带原因),排序退回「重要度」——不臆造、不挂面板。
179
+ */
180
+ async function loadGitTimes(deps, detected, refresh) {
181
+ // 采集器可注入(测试用假实现,避免测试去真跑 git)
182
+ const collect = deps.collectGitTimes ?? collectGitTimes
183
+ const worktree = detected.layout?.mainWorktree ?? detected.root
184
+ return deps.caches.gitTimes.get(keyOf(worktree), refresh, () => collect({ worktree }))
185
+ }
186
+
157
187
  /** GET /orchd/api/context */
158
188
  async function handleContext(deps, req, res, url) {
159
189
  const startedAt = Date.now()
@@ -280,17 +310,20 @@ async function handleBoard(deps, req, res, url) {
280
310
  }
281
311
 
282
312
  const pool = await loadPoolLayer(deps, detected, refresh)
313
+ const gitTimes = await loadGitTimes(deps, detected, refresh)
283
314
 
284
315
  const board = buildBoard({
285
316
  statusJson: status.json,
286
317
  poolJson: pool?.ok === true ? pool.json : undefined,
287
318
  index,
288
- masterPath: detected.layout?.masterPath
319
+ masterPath: detected.layout?.masterPath,
320
+ gitTimes
289
321
  })
290
322
  board.layers = {
291
323
  definition: index === undefined ? 'missing' : 'ok',
292
324
  state: 'ok',
293
- pool: pool?.ok === true ? 'ok' : (pool?.reason ?? 'not-loaded')
325
+ pool: pool?.ok === true ? 'ok' : (pool?.reason ?? 'not-loaded'),
326
+ gitTimes: gitTimes?.available === true ? 'ok' : (gitTimes?.reason ?? 'not-loaded')
294
327
  }
295
328
  sendJson(res, 200, envelope({ ok: true, startedAt, data: board }))
296
329
  } catch (error) {
@@ -333,7 +366,7 @@ async function handleTask(deps, req, res, url) {
333
366
  const definition = index?.tasks?.get(taskId)
334
367
 
335
368
  const detail = await deps.caches.detail.get(`${keyOf(detected.root)}|${taskId}`, refresh, async () =>
336
- deps.cli.statusOne({ root: detected.root, workDir: detected.layout?.mainWorktree, python: detected.python }, taskId)
369
+ deps.cli.statusOne(cliTarget(detected), taskId)
337
370
  )
338
371
 
339
372
  // 单任务失败**不得**让整个面板进入 broken(docs/02 第 12 节末):这里返回 ok:false,
@@ -364,7 +397,7 @@ async function handleTask(deps, req, res, url) {
364
397
  let reviewHistory
365
398
  if (url.searchParams.get('review') === '1') {
366
399
  const review = await deps.caches.review.get(`${keyOf(detected.root)}|${taskId}`, refresh, () =>
367
- deps.cli.reviewShow({ root: detected.root, workDir: detected.layout?.mainWorktree, python: detected.python }, taskId)
400
+ deps.cli.reviewShow(cliTarget(detected), taskId)
368
401
  )
369
402
  reviewHistory = review?.ok === true ? review.json : { unavailable: true, reason: review?.reason ?? 'not-loaded' }
370
403
  }
@@ -427,7 +460,7 @@ async function handleHistory(deps, req, res, url) {
427
460
  return
428
461
  }
429
462
 
430
- const target = { root: detected.root, workDir: detected.layout?.mainWorktree, python: detected.python }
463
+ const target = cliTarget(detected)
431
464
  const review = await deps.caches.review.get(`${keyOf(detected.root)}|${taskId}`, refresh, () =>
432
465
  deps.cli.reviewShow(target, taskId)
433
466
  )
@@ -529,16 +562,19 @@ async function handleGraph(deps, req, res, url) {
529
562
  }
530
563
 
531
564
  const pool = await loadPoolLayer(deps, detected, refresh)
565
+ const gitTimes = await loadGitTimes(deps, detected, refresh)
532
566
  const board = buildBoard({
533
567
  statusJson: status.json,
534
568
  poolJson: pool?.ok === true ? pool.json : undefined,
535
569
  index,
536
- masterPath: detected.layout?.masterPath
570
+ masterPath: detected.layout?.masterPath,
571
+ gitTimes
537
572
  })
538
573
  board.layers = {
539
574
  definition: index === undefined ? 'missing' : 'ok',
540
575
  state: 'ok',
541
- pool: pool?.ok === true ? 'ok' : (pool?.reason ?? 'not-loaded')
576
+ pool: pool?.ok === true ? 'ok' : (pool?.reason ?? 'not-loaded'),
577
+ gitTimes: gitTimes?.available === true ? 'ok' : (gitTimes?.reason ?? 'not-loaded')
542
578
  }
543
579
 
544
580
  if (focus !== null && !board.tasks.some((row) => row.id === focus)) {
@@ -603,7 +639,7 @@ async function handleIdeas(deps, req, res, url) {
603
639
 
604
640
  // 清单:引擎只给 title / id / status
605
641
  const list = await deps.caches.ideas.get(keyOf(detected.root), refresh, () =>
606
- deps.cli.ideasList({ root: detected.root, workDir: detected.layout?.mainWorktree, python: detected.python })
642
+ deps.cli.ideasList(cliTarget(detected))
607
643
  )
608
644
  // 引擎**没有 `ideas` 子命令**(旧版本,实测 v1.5.0:argparse `invalid choice: 'ideas'`,
609
645
  // exit=2,E007)⇒ 这不是项目坏了,而是**能力缺失**:优雅降级成「只读 IDEAS.md + 归档」,
@@ -640,20 +676,20 @@ async function handleIdeas(deps, req, res, url) {
640
676
 
641
677
  const parsed = parseIdeasMarkdown(files.markdown.text ?? '')
642
678
  // ⚠ `status` 不是「是否已立任务」的标记(引擎 rules/intake.md:在途条目按 E025
643
- // 必须保持 pending,`taskified` 只服务撞车并入)。所以用定义层做「已转任务」判定,
644
- // 覆盖字面上的 pending(FR-13.3b / docs/03 E025 溯源)。
679
+ // 必须保持 pending,`covered` 只服务撞车并入)。所以用定义层做「已转任务」判定,
680
+ // 但**不改写 status**——派生桶单独放在 `displayStatus`(FR-13.3b / docs/03 E025 溯源)。
645
681
  const definition = await loadDefinition(deps, detected)
646
682
  // 引用判据 = `source` ∪ `additional_sources`(引擎 rules ④ 同判据)
647
683
  const ideaTaskIndex = buildIdeaTaskIndex(definition?.tasks)
648
684
  // 引擎不支持 `ideas` 时没有清单可合并,只读 Markdown(`mergeIdeasList(null, …)` 就是这条路,
649
685
  // 条目会带上「仅存在于 IDEAS.md」的标记,界面本来就会这么显示)
650
686
  const engineList = ideasUnsupported ? null : list.json
651
- const entries = applyConvertedStatus(mergeIdeasList(engineList, parsed.entries), ideaTaskIndex)
687
+ const entries = attachConvertedTasks(mergeIdeasList(engineList, parsed.entries), ideaTaskIndex)
652
688
  const summary = summarizeIdeas(entries)
653
689
  // 归档分组:**与任务池对照**(用户口径 / FR-13.7)。
654
690
  // 不按归档件里的字面 status 分组——引擎明确归档不改写 status(语义是「已消费」),
655
691
  // 实测 312/373 条仍写着 pending,其中 287 条在任务池里有引用。
656
- // 「已丢弃」用扫描时收下的归档件自身 status(`statusById`),不能用 IDEAS.md 的状态表。
692
+ // 「历史已放弃」用扫描时收下的归档件自身 status(`statusById`),不能用 IDEAS.md 的状态表。
657
693
  const archived = classifyArchive(files.archive, ideaTaskIndex)
658
694
  // ⚠ 两个集合必须各自取「本层」的 id:曾误用合并后的 entries 当作引擎清单,
659
695
  // 导致只存在于 Markdown 的条目也被算成「引擎也有」,mismatches 恒为空。
@@ -679,25 +715,26 @@ async function handleIdeas(deps, req, res, url) {
679
715
  unsupportedHint: ideasUnsupported ? 'engine-ideas-command-missing' : null,
680
716
  /**
681
717
  * 活跃层的**三分区**(用户口径):
682
- * `total = active + converted + dropped`
683
- * 其中 `active = 论证中 + 待补充 + 待拆解`,converted/dropped 都算离队。
718
+ * `total = active + converted + covered`
719
+ * 其中 `active = 论证中 + 待补充 + 待拆解`;
720
+ * `converted`(定义层有任务引用)与 `covered`(撞车并入既有任务)都不计入活跃。
684
721
  */
685
722
  active: summary.active,
686
723
  /** 活跃层内已转任务的条数(由任务池引用推出,不是归档计数) */
687
724
  converted: summary.converted,
688
- /** 活跃层内已丢弃(离队) */
689
- dropped: summary.dropped,
725
+ /** 活跃层内已覆盖(撞车并入既有任务,待 close 归档) */
726
+ covered: summary.covered,
690
727
  archivedCount: files.archive.count,
691
728
  /**
692
729
  * 归档分组(FR-13.7):**与任务池对照**判定「已转任务」,不按归档件里的字面 status。
693
- * taskified 任务池(source ∪ additional_sources)引用了该 id
694
- * dropped 未被引用且字面 status = dropped
695
- * untasked 未被引用(人工裁决「已消费」离场)
696
- * noId 小节缺 `- id:`,无从对照(存量数据问题,显式计数不静默丢)
730
+ * converted 任务池(source ∪ additional_sources)引用了该 id
731
+ * legacyDropped 未被引用且字面 status = dropped(旧词表遗留历史值)
732
+ * untasked 未被引用(人工裁决离场,含 close 归档的 covered/pending/study)
733
+ * noId 小节缺 `- id:`,无从对照(存量数据问题,显式计数不静默丢)
697
734
  * `byLiteralStatus` 仅作诊断:引擎明确归档不改写 status,不是分组依据。
698
735
  */
699
736
  archived: { ...archived, byLiteralStatus: files.archive.byStatus ?? {} },
700
- // 统计条要渲染的芯片状态集合:固定 5 个想法状态,保证三分区在界面上闭合(FR-13.6)
737
+ // 统计条要渲染的芯片集合:4 个引擎状态 + 派生桶 converted,保证三分区在界面上闭合(FR-13.6)
701
738
  activeStatuses: ideaStripStatuses(),
702
739
  layers: {
703
740
  list: ideasUnsupported ? 'unsupported' : 'ok',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-orchd",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Read-only orchd panel for the DSH right sidebar: task board, dependency graph, ideas and review timeline for the project of the current session. · 把 orchd 项目状态带进 DSH 右侧栏的只读面板。",
5
5
  "license": "MIT",
6
6
  "repository": {