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/detect.js CHANGED
@@ -3,25 +3,26 @@
3
3
  *
4
4
  * 依据 docs/02-设计方案.md 第 5 节。判定顺序(每一步的 「reason」 都机器可读,FR-2.2):
5
5
  *
6
- * ① 安装痕迹 → not_installed
7
- * ② 布局 / 布局一致性 → broken/layout-unresolved | broken/layout-marker-mismatch
8
- * ③ Python → broken/python-not-found
9
- * ④ 定义层 缺失 → uninitialized(或 broken/definition-archived-unsupported)
10
- * ④' 定义层 损坏 → broken/master-unparsable
11
- * ⑤ 状态层(一次 CLI) → broken/cli-timeout | broken/cli-failed ← P1 起启用
12
- * ⑥ 一致性交叉检查 → broken/definition-state-mismatch ← P1 起启用
6
+ * ① 布局发现(唯一真源) → not_installed
7
+ * | broken/layout-unresolved | broken/layout-marker-mismatch
8
+ * (痕迹判定与主工作树定位都收敛在 master.js::resolveLayout 的三种结论里)
9
+ * ② Python → broken/python-not-found
10
+ * ③ 定义层 缺失 → uninitialized(或 broken/definition-archived-unsupported)
11
+ * ③' 定义层 损坏 → broken/master-unparsable
12
+ * ④ 状态层(一次 CLI) → broken/cli-timeout | broken/cli-failed ← P1 起启用
13
+ * ⑤ 一致性交叉检查 → broken/definition-state-mismatch ← P1 起启用
13
14
  * → ready
14
15
  *
15
- * 与文档的唯一偏差:把「布局一致性」放到了 Python 探测之前。理由是文档把
16
- * «布局标记与真实主工作树不一致» 列进了 「broken」 的判定集合(FR-2 表格与 AC-2.5),
17
- * 而 python-not-found 会抢先返回、把 AC-2.5 永久遮蔽。两者都是 broken,
18
- * 只是谁先被报出来;先报更具体的那一个更利于排障。
16
+ * 与文档的偏差(已在根因治理时收敛):布局发现(含「是否接入」的痕迹判定)排在 Python 探测之前。
17
+ * 理由:文档把 «布局标记与真实主工作树不一致» 列进 「broken」 判定集合(FR-2 表格与 AC-2.5),
18
+ * 而 python-not-found 会抢先返回、把 AC-2.5 永久遮蔽;布局发现放最前,not_installed 与 layout-*
19
+ * 都能先被报出。两者都是 broken,只是谁先报;先报更具体的那一个更利于排障。
19
20
  *
20
21
  * P0 范围(docs/05 第 3.2 节):不含 CLI 状态层调用、不含一致性交叉检查,
21
22
  * 因此本模块的 「runCli」 允许缺省;P1 传入真实封装即可启用 ⑤⑥。
22
23
  */
23
24
 
24
- import { hasInstallationTrace, findArchiveCandidates, readMaster, resolveLayout } from './master.js'
25
+ import { findArchiveCandidates, readMaster, resolveLayout } from './master.js'
25
26
  import { probePython } from './python.js'
26
27
 
27
28
  /** 机器可读的失败原因分类(FR-2.2)。 */
@@ -48,24 +49,23 @@ export const BROKEN_REASONS = Object.freeze([
48
49
  * @returns {Promise<object>} DetectedState(形状见 docs/02 第 7 节)
49
50
  */
50
51
  export async function detect({ root, config, runCli }) {
51
- // ① 安装痕迹
52
- if (!hasInstallationTrace(root)) {
53
- return { state: 'not_installed', root }
54
- }
55
-
56
- // ② 布局(含一致性)
52
+ // ① 布局发现(唯一真源:痕迹判定 + 主工作树/定义文件定位,见 master.js::resolveLayout)
57
53
  const layout = resolveLayout(root)
58
54
  if (layout.ok !== true) {
55
+ // 「根与子工作树都没有痕迹」是唯一判 not_installed 的依据;其余都是 broken
56
+ if (layout.reason === 'no-installation') {
57
+ return { state: 'not_installed', root }
58
+ }
59
59
  return { state: 'broken', root, reason: layout.reason, detail: layout.detail }
60
60
  }
61
61
 
62
- // ③ Python
62
+ // ② Python
63
63
  const python = await probePython(config)
64
64
  if (python.error !== undefined) {
65
65
  return { state: 'broken', root, reason: 'python-not-found', detail: python.error, layout }
66
66
  }
67
67
 
68
- // ④ 定义层
68
+ // ③ 定义层
69
69
  const master = readMaster(layout.masterPath)
70
70
  if (master.ok !== true) {
71
71
  // 文件不存在 → 装了但没初始化。唯一例外:定义被归档到别处(R8),
@@ -96,7 +96,7 @@ export async function detect({ root, config, runCli }) {
96
96
  }
97
97
  }
98
98
 
99
- // ⑤⑥ 状态层(P0 不做)
99
+ // ④⑤ 状态层(P0 不做)
100
100
  if (typeof runCli !== 'function') {
101
101
  return {
102
102
  state: 'ready',
@@ -113,7 +113,15 @@ export async function detect({ root, config, runCli }) {
113
113
  }
114
114
 
115
115
  // workDir = 主工作树:container 布局下引擎拒绝以容器根为 cwd(E036,见 orchd-cli.js 头注释)
116
- const status = await runCli({ root, python, layout, workDir: layout.mainWorktree, command: ['status', '--all'] })
116
+ // containerRoot = 布局声明的容器根:引擎装在容器根、主工作树只有定义层时,启动器要从那里回退取
117
+ const status = await runCli({
118
+ root,
119
+ python,
120
+ layout,
121
+ workDir: layout.mainWorktree,
122
+ containerRoot: layout.containerRoot ?? null,
123
+ command: ['status', '--all']
124
+ })
117
125
  if (status.timedOut === true) {
118
126
  return { state: 'broken', root, reason: 'cli-timeout', detail: status, layout, python }
119
127
  }
@@ -143,7 +151,13 @@ export async function detect({ root, config, runCli }) {
143
151
  schemaVersion: master.schemaVersion,
144
152
  taskCount: master.taskCount,
145
153
  summary: status.json?.summary ?? null,
146
- statusLayer: { checked: true }
154
+ statusLayer: {
155
+ checked: true,
156
+ // 用了哪一份启动器(`worktree` / `container-root-fallback`):ready 时也留在诊断里,
157
+ // 免得「明明修好了」和「悄悄退回了另一侧」在面板上分不出来
158
+ launcher: status.launcher ?? null,
159
+ launcherSource: status.launcherSource ?? null
160
+ }
147
161
  }
148
162
  }
149
163
 
@@ -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
  }