dsh-plugin-orchd 0.1.0 → 0.1.1

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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * 只读 git 时间采集:给看板的默认排序提供「最近动作时间」与「合并时间」。
3
+ *
4
+ * 为什么需要它(FR-5.4 修订,2026-10-29):默认排序改为——
5
+ * · 活跃任务(pending / claimed / done / in_review)按「最近一次动作」由近到远;
6
+ * · 终态任务(completed / cancelled)按「合并进 main 的时间」由近到远。
7
+ * 但引擎的 `status --all` 与 `status <id>` **都不返回时间字段**(只有 task_id / name /
8
+ * status / module / attempt_count / source / claimed_by,外加可选的 review_phase /
9
+ * revive_marker),唯一带精确时间戳的是账本事件流(`_ledger.sqlite3`),而插件按 A2
10
+ * 不直读账本。所以这里走 git 侧的两个**近似**来源:
11
+ *
12
+ * ① 活跃任务 → `task/<id>` 分支的 tip 时间。任务还在推进时分支还在(终态回收才会删
13
+ * 分支与 worktree),tip 提交时间就是「最近一次动作」的最佳近似。
14
+ * ② 终态任务 → 主分支上的合并提交时间(`Merge branch 'task/<id>'`)。分支已被回收,
15
+ * 合并提交是唯一留下的时间戳。
16
+ *
17
+ * 两处已知盲区(默认排序把它们兜底到本组最后,**不臆造时间**):
18
+ * · 尚未 claim 的 pending:还没有 `task/<id>` 分支;
19
+ * · cancelled:多半从未合并,没有合并提交。squash / 直推进主分支的形态同样拿不到
20
+ * 合并提交(不猜)。
21
+ *
22
+ * 只读纪律(与引擎调用面同一口径,docs/03 第 8 节):
23
+ * · **只跑 `GIT_READONLY_INVOCATIONS` 里那两条固定只读命令**,参数里不含任务 id、
24
+ * 不含任何项目数据 —— 没有命令拼接面;
25
+ * · 不写工作区、不动索引、不 fetch(纯本地查询);
26
+ * · 子进程 env 只保留运行必需项,不注入任何身份变量。
27
+ */
28
+
29
+ import { spawn } from 'node:child_process'
30
+
31
+ /** ① 活跃任务的 `task/*` 分支 tip 时间(`--sort=-committerdate`:第一份即最新)。 */
32
+ const BRANCH_TIP_ARGS = Object.freeze([
33
+ 'for-each-ref',
34
+ '--sort=-committerdate',
35
+ '--format=%(committerdate:iso-strict)%09%(refname:short)',
36
+ 'refs/heads/task'
37
+ ])
38
+
39
+ /** ② 主分支上的合并提交(`-n` 上限够覆盖几百个终态任务的合并历史)。 */
40
+ const MERGE_COMMIT_ARGS = Object.freeze([
41
+ 'log',
42
+ '--merges',
43
+ '--format=%cI%09%s',
44
+ '-n',
45
+ '4000'
46
+ ])
47
+
48
+ /**
49
+ * 允许执行的 git 调用面(**唯一来源**:实现从它取参数,测试按它做静态断言)。
50
+ * 想加新调用必须先改这里 —— 与引擎白名单同一个 fail-closed 思路。
51
+ */
52
+ export const GIT_READONLY_INVOCATIONS = Object.freeze([
53
+ Object.freeze({ name: 'branch-tips', args: BRANCH_TIP_ARGS }),
54
+ Object.freeze({ name: 'merge-commits', args: MERGE_COMMIT_ARGS })
55
+ ])
56
+
57
+ /** 子进程 env 白名单:git 只读查询的最小集合(PATH 必需,Windows 还要 SystemRoot)。 */
58
+ export const GIT_CHILD_ENV_KEYS = Object.freeze(['PATH', 'SystemRoot', 'SystemDrive', 'TEMP', 'TMP'])
59
+
60
+ /** 采集结果的空形态(git 不可用 / 非 git 项目 / 超时都返回它,只是 `reason` 不同)。 */
61
+ const EMPTY_TIMES = Object.freeze({ branchTimes: {}, mergeTimes: {} })
62
+
63
+ /**
64
+ * 子进程 env:白名单式构造,绝不把宿主变量(尤其是身份类)带下去。
65
+ * @param {Record<string, string|undefined>} [source]
66
+ */
67
+ export function buildGitChildEnv(source = process.env) {
68
+ const env = {}
69
+ for (const key of GIT_CHILD_ENV_KEYS) {
70
+ const value = source[key]
71
+ if (typeof value === 'string' && value !== '') env[key] = value
72
+ }
73
+ return env
74
+ }
75
+
76
+ /**
77
+ * `task/<id>` 或 `task/<line>/<id>` → 任务 id(多线取最后一段;id 自身不含 `/`)。
78
+ * 非任务分支(main / feature/... )→ null。
79
+ * @param {string} refName
80
+ * @returns {string|null}
81
+ */
82
+ export function parseTaskBranchRef(refName) {
83
+ if (typeof refName !== 'string') return null
84
+ const match = /^task\/(.+)$/.exec(refName.trim())
85
+ if (match === null) return null
86
+ const segments = match[1].split('/')
87
+ const id = segments[segments.length - 1].trim()
88
+ return id === '' ? null : id
89
+ }
90
+
91
+ /**
92
+ * 合并提交标题 → 任务 id。只认 `Merge branch 'task/<id>'`(可带 ` into <branch>` 尾巴)。
93
+ * @param {string} subject
94
+ * @returns {string|null}
95
+ */
96
+ export function parseMergeSubject(subject) {
97
+ if (typeof subject !== 'string') return null
98
+ const match = /Merge branch 'task\/([^']+)'/.exec(subject)
99
+ if (match === null) return null
100
+ const segments = match[1].split('/')
101
+ const id = segments[segments.length - 1].trim()
102
+ return id === '' ? null : id
103
+ }
104
+
105
+ /** ISO 时间的最小形状校验(`iso-strict` / `%cI` 都是 `<date>T<time>` 开头)。 */
106
+ function looksLikeIso(value) {
107
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)
108
+ }
109
+
110
+ /** 按第一个制表符切开 `--format` 的两列(时间 + 后续列)。 */
111
+ function splitColumns(line) {
112
+ // 双保险:`%x09` 这种写法在部分 git/环境上会被**原样带出**成字面量 `%09`
113
+ // (2026-10-29 实测踩到:GIT 2.56 + execFileSync 走 argv 时),先归一成真分隔符,
114
+ // 免得整个时间层静默变空。
115
+ const text = line.trim().replace(/%0?9/g, '\t')
116
+ if (text === '') return null
117
+ const index = text.indexOf('\t')
118
+ if (index < 0) return null
119
+ const when = text.slice(0, index).trim()
120
+ const rest = text.slice(index + 1).trim()
121
+ if (rest === '' || !looksLikeIso(when)) return null
122
+ return { when, rest }
123
+ }
124
+
125
+ /**
126
+ * 跑一条 git 只读命令。失败/超时都**不抛错**(排序降级即可,不能因为 git 坏了让面板挂)。
127
+ * @param {readonly string[]} args
128
+ * @param {{ cwd: string, timeoutMs: number, spawnImpl: Function }} options
129
+ */
130
+ export function runGitReadonly(args, { cwd, timeoutMs = 8000, spawnImpl = spawn }) {
131
+ return new Promise((resolve) => {
132
+ let child
133
+ try {
134
+ child = spawnImpl('git', [...args], {
135
+ cwd,
136
+ windowsHide: true,
137
+ env: buildGitChildEnv(),
138
+ stdio: ['ignore', 'pipe', 'pipe']
139
+ })
140
+ } catch (error) {
141
+ resolve({ ok: false, reason: 'git-spawn-failed', stdout: '', stderr: String(error) })
142
+ return
143
+ }
144
+
145
+ let stdout = ''
146
+ let stderr = ''
147
+ let settled = false
148
+ let timedOut = false
149
+
150
+ const finish = (value) => {
151
+ if (settled) return
152
+ settled = true
153
+ clearTimeout(timer)
154
+ resolve(value)
155
+ }
156
+
157
+ const timer = setTimeout(() => {
158
+ timedOut = true
159
+ try {
160
+ child.kill()
161
+ } catch {
162
+ /* 忽略:可能已退出 */
163
+ }
164
+ }, Math.max(1, timeoutMs))
165
+
166
+ // 刻意**不**设 setEncoding:按 Buffer 收集后统一 utf8 解码,
167
+ // 免得子进程(git)输出编码受宿主 locale 影响(中文提交信息会乱码)。
168
+ const chunks = []
169
+ const errChunks = []
170
+ child.stdout.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))))
171
+ child.stderr.on('data', (chunk) => errChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))))
172
+
173
+ child.on('error', (error) => {
174
+ finish({ ok: false, reason: 'git-spawn-failed', stdout: '', stderr: String(error) })
175
+ })
176
+
177
+ child.on('close', (code) => {
178
+ stdout = Buffer.concat(chunks).toString('utf8')
179
+ stderr = Buffer.concat(errChunks).toString('utf8')
180
+ if (timedOut) {
181
+ finish({ ok: false, reason: 'git-timeout', stdout, stderr })
182
+ return
183
+ }
184
+ if (code !== 0) {
185
+ // 非 git 仓库在这里表现得最典型(exit 128 + "not a git repository")
186
+ finish({ ok: false, reason: 'git-failed', code, stdout, stderr })
187
+ return
188
+ }
189
+ finish({ ok: true, reason: null, code: 0, stdout, stderr })
190
+ })
191
+ })
192
+ }
193
+
194
+ /**
195
+ * 采集 git 时间层。
196
+ *
197
+ * @param {object} options
198
+ * @param {string} options.worktree 主工作树(git 仓库根)
199
+ * @param {number} [options.timeoutMs]
200
+ * @param {Function} [options.spawnImpl] 仅供测试注入
201
+ * @returns {Promise<{ available: boolean, reason: string|null, branchTimes: Record<string,string>, mergeTimes: Record<string,string> }>}
202
+ */
203
+ export async function collectGitTimes({ worktree, timeoutMs = 8000, spawnImpl = spawn } = {}) {
204
+ if (typeof worktree !== 'string' || worktree === '') {
205
+ return { available: false, reason: 'no-worktree', ...EMPTY_TIMES }
206
+ }
207
+
208
+ const [branch, merge] = await Promise.all([
209
+ runGitReadonly(BRANCH_TIP_ARGS, { cwd: worktree, timeoutMs, spawnImpl }),
210
+ runGitReadonly(MERGE_COMMIT_ARGS, { cwd: worktree, timeoutMs, spawnImpl })
211
+ ])
212
+
213
+ if (branch.ok !== true && merge.ok !== true) {
214
+ return { available: false, reason: branch.reason ?? merge.reason ?? 'git-failed', ...EMPTY_TIMES }
215
+ }
216
+
217
+ const branchTimes = {}
218
+ if (branch.ok === true) {
219
+ for (const line of branch.stdout.split('\n')) {
220
+ const columns = splitColumns(line)
221
+ if (columns === null) continue
222
+ const id = parseTaskBranchRef(columns.rest)
223
+ // 已按 committerdate 倒序:同一任务分支只留第一次出现(= 最新)
224
+ if (id !== null && branchTimes[id] === undefined) branchTimes[id] = columns.when
225
+ }
226
+ }
227
+
228
+ const mergeTimes = {}
229
+ if (merge.ok === true) {
230
+ for (const line of merge.stdout.split('\n')) {
231
+ const columns = splitColumns(line)
232
+ if (columns === null) continue
233
+ const id = parseMergeSubject(columns.rest)
234
+ // `log` 也是新→旧:同一任务只留最近一次合并
235
+ if (id !== null && mergeTimes[id] === undefined) mergeTimes[id] = columns.when
236
+ }
237
+ }
238
+
239
+ return { available: true, reason: null, branchTimes, mergeTimes }
240
+ }
package/lib/ideas.js CHANGED
@@ -15,6 +15,12 @@
15
15
  * 3. **归档不能当状态分布用**:`IDEAS-archive.md` 543 KB / 372 个 `## `,其中 311 条仍写着
16
16
  * `pending`——归档保留原文,`status` 是**历史值**(AC-13.1/13.9)。
17
17
  *
18
+ * 状态词表(引擎 2026-10-23 调整后):`study` / `questioning` / `pending` / `covered`。
19
+ * · `taskified` 已正名为 `covered`(语义:被既有任务覆盖 / 撞车并入,待归档);
20
+ * · `dropped` 已从活跃台账下线(「放弃」改由 `idea close --reason` 一次离场归档)。
21
+ * 老引擎写下的 `taskified` / `dropped` 会按未知状态走降级路径(原文进 `extra`),
22
+ * 不静默改写、不猜测映射。
23
+ *
18
24
  * 只读:本模块只读文件,不写任何东西(FR-10 / FR-13.8)。
19
25
  */
20
26
 
@@ -23,47 +29,50 @@ import { createReadStream, statSync } from 'node:fs'
23
29
  const IDEAS_FILENAME = 'IDEAS.md'
24
30
  const ARCHIVE_FILENAME = 'IDEAS-archive.md'
25
31
 
26
- /** 引擎定义的 5 个状态(不得自造)。 */
27
- export const IDEA_STATUSES = Object.freeze(['study', 'questioning', 'pending', 'taskified', 'dropped'])
32
+ /**
33
+ * 引擎定义的 4 个状态(不得自造)。
34
+ *
35
+ * `covered` 取代旧词 `taskified`:**只**表示「撞车并入已有任务,待归档」,
36
+ * **不是**「已注册 / 已立任务」的标记(引擎 `rules/intake.md` ①)。
37
+ * 旧的 `dropped` 已从活跃台账下线(引擎 `task-intake-exit-verb-close`)。
38
+ */
39
+ export const IDEA_STATUSES = Object.freeze(['study', 'questioning', 'pending', 'covered'])
28
40
 
29
41
  /**
30
- * 「在等谁」由 status 派生,**不是引擎字段**(FR-13.4)。
42
+ * 派生桶(**不是引擎状态**):任务定义层有 `source`/`additional_sources` 指向该想法。
43
+ *
44
+ * 为什么必须单列:`status` 只表达摄入/裁决态。被非终态任务引用的条目按 E025
45
+ * **必须保持 `pending`**,所以「已转任务」只能由定义层溯源推出(FR-13.3b)。
46
+ * 它不能复用 `covered`——`covered` 是「撞车并入已有任务」,两者语义不同。
47
+ */
48
+ export const IDEA_CONVERTED_STATUS = 'converted'
49
+
50
+ /** 统计条与行标签使用的展示桶:4 个引擎状态 + 1 个派生桶(顺序按生命周期)。 */
51
+ export const IDEA_DISPLAY_STATUSES = Object.freeze([
52
+ 'study', 'questioning', 'pending', IDEA_CONVERTED_STATUS, 'covered'
53
+ ])
54
+
55
+ /**
56
+ * 「在等谁」由展示桶派生,**不是引擎字段**(FR-13.4)。
31
57
  * 徽章数 = 等用户的那两个状态之和,不是总条目数(FR-13.2)。
58
+ *
59
+ * `covered` / `converted` 都已有人接手(并入既有任务 / 已拆出新任务),故为 `none`。
32
60
  */
33
61
  export const IDEA_OWNER = Object.freeze({
34
62
  study: 'user',
35
63
  questioning: 'user',
36
64
  pending: 'agent',
37
- taskified: 'none',
38
- dropped: 'none'
65
+ [IDEA_CONVERTED_STATUS]: 'none',
66
+ covered: 'none'
39
67
  })
40
68
 
41
- /**
42
- * 活跃层可能出现的状态。
43
- *
44
- * ⚠ 为什么不是全部 5 个:`taskified` / `dropped` 的条目已被引擎**移入归档文件**
45
- * (`IDEAS-archive.md`),而归档按 FR-13.12 不进默认加载路径(参考项目实测 543 KB / 373 条)。
46
- * 因此这两个筛选在活跃列表上必然恒为 0——把恒 0 的筛选摆出来只会让人以为坏了
47
- * (用户实测反馈:「已经立任务的条目,为什么没有出现在已转任务中?」)。
48
- * 它们的数量由归档分类计数回答(见 `scanArchiveFile` 的 `byStatus`,FR-13.7)。
49
- *
50
- * 另外注意:**归档件里的 `- status:` 是入档历史值**,引擎明确「不改写 status」
51
- * (`rules/intake.md`:归档面的语义是「已消费」而非某个状态值)。
52
- */
53
- export const IDEA_ACTIVE_STATUSES = Object.freeze(['study', 'questioning', 'pending'])
54
-
55
- /** 活跃层里的「终态」:已转任务 / 已丢弃(不再算活跃)。 */
56
- export const IDEA_TERMINAL_STATUSES = Object.freeze(['taskified', 'dropped'])
57
-
58
- /** 归档里值得单独露出来的两个终态(FR-13.7 的分类计数)。 */
59
- export const IDEA_ARCHIVE_TERMINAL_STATUSES = Object.freeze(['taskified', 'dropped'])
60
69
 
61
70
  /**
62
71
  * 从任务定义层建「想法 id → 任务 id[]」的溯源索引(FR-13.3b / docs/03 E025)。
63
72
  *
64
73
  * 为什么必须靠定义层:**`status` 不是「是否已立任务」的标记**。引擎 `rules/intake.md` 明确:
65
74
  * · `source: idea:<id>` 的条目在任务非终态期间**必须保持 `pending`**(E025 硬约束);
66
- * · `taskified` **并非「已注册」语义**,只服务「撞车并入已有任务」。
75
+ * · `covered` **并非「已注册」语义**,只服务「撞车并入已有任务」。
67
76
  * 实测 `phase-workflow-engineering` 在 `IDEAS.md` 里写着 `pending`(这是**正确**的,
68
77
  * 它的 3 个任务都未终态),但定义层已有 3 个 `source: idea:phase-workflow-engineering`。
69
78
  * 只看 status 的话,这条已经立成任务的想法会被算进「活跃 / 待拆解」(用户实测发现)。
@@ -100,23 +109,27 @@ export function buildIdeaTaskIndex(tasks) {
100
109
  }
101
110
 
102
111
  /**
103
- * 把「定义层溯源」叠加到条目上,得到**有效状态**:
104
- * 只要这个想法 id 在任务定义层出现过,就算已转任务(覆盖字面上的 `pending`)。
112
+ * 把「定义层溯源」叠加到条目上,得到**展示桶**(不改写引擎的 `status`)。
113
+ *
114
+ * 只要这个想法 id 在任务定义层出现过,就额外标记 `converted: true` 并把
115
+ * `displayStatus` 置为派生桶 `converted`;`status` / `rawStatus` 仍是文件里的字面值
116
+ * (被非终态任务引用的条目按 E025 必须保持 `pending`,这不是滞后)。
105
117
  *
106
118
  * @param {object[]} entries `mergeIdeasList` 的结果
107
119
  * @param {Map<string, string[]>} ideaTaskIndex `buildIdeaTaskIndex` 的结果
108
120
  */
109
- export function applyConvertedStatus(entries, ideaTaskIndex) {
121
+ export function attachConvertedTasks(entries, ideaTaskIndex) {
110
122
  return entries.map((entry) => {
111
123
  const convertedTasks = typeof entry.id === 'string' ? (ideaTaskIndex.get(entry.id) ?? []) : []
112
124
  const converted = convertedTasks.length > 0
125
+ const displayStatus = converted ? IDEA_CONVERTED_STATUS : entry.status
113
126
  return {
114
127
  ...entry,
115
- rawStatus: entry.status,
128
+ rawStatus: entry.rawStatus ?? entry.status,
116
129
  convertedTasks,
117
- // 有效状态:定义层说已经立成任务,就不再算「待拆解」
118
- // (status 按引擎语义只表达摄入/裁决态,不表达「已立任务」)
119
- status: converted ? 'taskified' : entry.status
130
+ converted,
131
+ displayStatus,
132
+ owner: displayStatus === null ? 'none' : (IDEA_OWNER[displayStatus] ?? 'none')
120
133
  }
121
134
  })
122
135
  }
@@ -278,39 +291,48 @@ function finalizeEntry(raw) {
278
291
  }
279
292
  }
280
293
 
281
- /** 汇总计数 + 徽章数(FR-13.2)。 */
294
+ /**
295
+ * 汇总计数 + 徽章数(FR-13.2)。
296
+ *
297
+ * `byStatus` 按**展示桶**计数:4 个引擎状态 + 派生桶 `converted`(已转任务)。
298
+ * 已转任务的条目不会同时计入 `pending`,因此三分区闭合:
299
+ * `全部 = 活跃 + 已转任务 + 已覆盖`(未知状态另计,不进分区)
300
+ * `活跃 = 论证中 + 待补充 + 待拆解`
301
+ * `covered` 不计入活跃:它表示「已并入既有任务,待用户 close 归档」。
302
+ */
282
303
  export function summarizeIdeas(entries) {
283
304
  const byStatus = {}
284
- for (const status of IDEA_STATUSES) byStatus[status] = 0
305
+ for (const status of IDEA_DISPLAY_STATUSES) byStatus[status] = 0
285
306
  let unknown = 0
286
307
  for (const entry of entries) {
287
- if (entry.status === null) unknown += 1
288
- else byStatus[entry.status] += 1
308
+ const display = entry.displayStatus ?? (entry.converted === true ? IDEA_CONVERTED_STATUS : entry.status)
309
+ if (display === null || display === undefined || byStatus[display] === undefined) unknown += 1
310
+ else byStatus[display] += 1
289
311
  }
290
312
  return {
291
313
  byStatus,
292
314
  unknown,
293
315
  needsUser: byStatus.study + byStatus.questioning,
294
316
  total: entries.length,
295
- /** 活跃 = 活跃层里**尚未转成任务**的条目(FR-13.6b;已扣除定义层已立任务的) */
296
- active: entries.filter((entry) => !IDEA_TERMINAL_STATUSES.includes(entry.status)).length,
317
+ /** 活跃 = 尚未转成任务、也未被覆盖的条目(FR-13.6b) */
318
+ active: byStatus.study + byStatus.questioning + byStatus.pending,
297
319
  /** 已转任务(活跃层内)—— 由定义层溯源推出,不是归档计数 */
298
- converted: entries.filter((entry) => entry.status === 'taskified').length,
299
- /** 已丢弃(活跃层内)—— 与 converted 同属「离队」 */
300
- dropped: entries.filter((entry) => entry.status === 'dropped').length
320
+ converted: byStatus[IDEA_CONVERTED_STATUS],
321
+ /** 已覆盖(活跃层内)—— 撞车并入既有任务,待 close 归档 */
322
+ covered: byStatus.covered
301
323
  }
302
324
  }
303
325
 
304
326
  /**
305
327
  * 统计条要渲染的芯片状态集合(FR-13.6)。
306
328
  *
307
- * 固定为**全部 5 个想法状态**(0 值可点,FR-5.9),因为统计条必须能完整表达三分区:
308
- * `全部 = 活跃 + 已转任务 + 已丢弃`,其中 `活跃 = 论证中 + 待补充 + 待拆解`。
329
+ * 固定为**全部展示桶**(0 值可点,FR-5.9),因为统计条必须能完整表达三分区:
330
+ * `全部 = 活跃 + 已转任务 + 已覆盖`,其中 `活跃 = 论证中 + 待补充 + 待拆解`。
309
331
  * 早先试过「只显示实际出现过的状态」以避免恒 0 芯片,但那会让分区在界面上不闭合
310
- * (例如 已丢弃 缺失时看不出 0 是「没有」还是「没显示」)——用户要求分区自洽,故固定。
332
+ * (例如 已覆盖 缺失时看不出 0 是「没有」还是「没显示」)——用户要求分区自洽,故固定。
311
333
  */
312
334
  export function ideaStripStatuses() {
313
- return [...IDEA_STATUSES]
335
+ return [...IDEA_DISPLAY_STATUSES]
314
336
  }
315
337
 
316
338
  /**
@@ -338,7 +360,7 @@ export function scanArchiveFile(filePath, onError) {
338
360
  const ids = []
339
361
  /** @type {Record<string, number>} */
340
362
  const byStatus = {}
341
- /** @type {Record<string, string>} 小节 id → 该小节字面 status(判定「已丢弃」要用) */
363
+ /** @type {Record<string, string>} 小节 id → 该小节字面 status(判定「历史已放弃」要用) */
342
364
  const statusById = {}
343
365
  /** 当前小节的 id / 首个 status(小节只有第一个 `- id:` / `- status:` 算数) */
344
366
  let sectionId = null
@@ -428,34 +450,37 @@ export function scanArchiveFile(filePath, onError) {
428
450
  * `pending`——按字面分组会把它们全算成「没立任务」,而其中 287 条在任务池里有引用。
429
451
  *
430
452
  * 分组(互斥,合计 = `count`):
431
- * 已转任务 任务池(`source` ∪ `additional_sources`)引用了该 id
432
- * 已丢弃 未被引用,且字面 status = dropped
433
- * 未立任务 未被引用,也不是 dropped(人工裁决「已消费」离场)
434
- * 无法对照 小节缺 `- id:`,无从判断(实测存量 20 条)
453
+ * 已转任务 converted 任务池(`source` ∪ `additional_sources`)引用了该 id
454
+ * 历史已放弃 legacyDropped 未被引用,且字面 status = dropped(**旧词表遗留数据**)
455
+ * 未立任务 untasked 未被引用(人工 `idea close` 裁决离场,含 covered/pending/study)
456
+ * 无法对照 noId 小节缺 `- id:`,无从判断(实测存量 20 条)
457
+ *
458
+ * ⚠ `dropped` 已从引擎词表下线,分组里的 `legacyDropped` 只统计**入档历史值**,
459
+ * 不代表当前状态机还有这个状态。
435
460
  *
436
461
  * @param {{count: number, ids: string[], noIdCount: number, statusById?: Record<string, string>}} scan `scanArchiveFile` 的结果
437
462
  * @param {Map<string, string[]>} ideaTaskIndex `buildIdeaTaskIndex` 的结果
438
463
  * @param {Record<string, string>} [statusByIdOverride] 覆盖 scan 里的 id → 字面 status(测试用)
439
464
  */
440
465
  export function classifyArchive(scan, ideaTaskIndex, statusByIdOverride) {
441
- // 「已丢弃」要按**归档件自己的**字面 status 判(不能拿 IDEAS.md 的状态表来查,那是活跃层文件)
466
+ // 「历史已放弃」要按**归档件自己的**字面 status 判(不能拿 IDEAS.md 的状态表来查,那是活跃层文件)
442
467
  const statusById = statusByIdOverride ?? scan.statusById ?? {}
443
- let taskified = 0
444
- let dropped = 0
468
+ let converted = 0
469
+ let legacyDropped = 0
445
470
  let untasked = 0
446
471
  for (const id of scan.ids ?? []) {
447
472
  if (ideaTaskIndex instanceof Map && ideaTaskIndex.has(id)) {
448
- taskified += 1
473
+ converted += 1
449
474
  continue
450
475
  }
451
- if (statusById[id] === 'dropped') dropped += 1
476
+ if (statusById[id] === 'dropped') legacyDropped += 1
452
477
  else untasked += 1
453
478
  }
454
479
  const noId = Math.max(0, scan.count - (scan.ids?.length ?? 0))
455
480
  return {
456
481
  total: scan.count,
457
- taskified,
458
- dropped,
482
+ converted,
483
+ legacyDropped,
459
484
  untasked,
460
485
  noId: Math.max(noId, scan.noIdCount ?? 0)
461
486
  }
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
  /**