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.
- package/README.md +203 -74
- package/README.public.md +121 -0
- package/client/client.js +106 -118
- package/lib/cache.js +5 -0
- package/lib/cli-invoke.js +12 -5
- package/lib/detect.js +37 -23
- package/lib/git-times.js +240 -0
- package/lib/ideas.js +82 -57
- package/lib/master.js +61 -23
- package/lib/model.js +51 -5
- package/lib/orchd-cli.js +59 -5
- package/lib/routes.js +61 -24
- package/package.json +1 -1
package/lib/git-times.js
ADDED
|
@@ -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
|
-
/**
|
|
27
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
* · `
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
/**
|
|
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
|
|
305
|
+
for (const status of IDEA_DISPLAY_STATUSES) byStatus[status] = 0
|
|
285
306
|
let unknown = 0
|
|
286
307
|
for (const entry of entries) {
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
/** 活跃 =
|
|
296
|
-
active:
|
|
317
|
+
/** 活跃 = 尚未转成任务、也未被覆盖的条目(FR-13.6b) */
|
|
318
|
+
active: byStatus.study + byStatus.questioning + byStatus.pending,
|
|
297
319
|
/** 已转任务(活跃层内)—— 由定义层溯源推出,不是归档计数 */
|
|
298
|
-
converted:
|
|
299
|
-
/**
|
|
300
|
-
|
|
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
|
-
*
|
|
308
|
-
* `全部 = 活跃 + 已转任务 +
|
|
329
|
+
* 固定为**全部展示桶**(0 值可点,FR-5.9),因为统计条必须能完整表达三分区:
|
|
330
|
+
* `全部 = 活跃 + 已转任务 + 已覆盖`,其中 `活跃 = 论证中 + 待补充 + 待拆解`。
|
|
309
331
|
* 早先试过「只显示实际出现过的状态」以避免恒 0 芯片,但那会让分区在界面上不闭合
|
|
310
|
-
* (例如
|
|
332
|
+
* (例如 已覆盖 缺失时看不出 0 是「没有」还是「没显示」)——用户要求分区自洽,故固定。
|
|
311
333
|
*/
|
|
312
334
|
export function ideaStripStatuses() {
|
|
313
|
-
return [...
|
|
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
|
-
*
|
|
433
|
-
* 未立任务
|
|
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
|
-
//
|
|
466
|
+
// 「历史已放弃」要按**归档件自己的**字面 status 判(不能拿 IDEAS.md 的状态表来查,那是活跃层文件)
|
|
442
467
|
const statusById = statusByIdOverride ?? scan.statusById ?? {}
|
|
443
|
-
let
|
|
444
|
-
let
|
|
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
|
-
|
|
473
|
+
converted += 1
|
|
449
474
|
continue
|
|
450
475
|
}
|
|
451
|
-
if (statusById[id] === 'dropped')
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
264
|
+
// 探测不到主工作树:根有 `.orchd`(哪怕空目录)→ flat(装了没初始化,交给 detect 判);
|
|
265
|
+
// 根连 `.orchd` 都没有 → 根与子工作树都没有痕迹 → 未接入。
|
|
266
|
+
if (rootHasOrchd) return primary
|
|
267
|
+
|
|
241
268
|
return {
|
|
242
269
|
ok: false,
|
|
243
|
-
reason: '
|
|
270
|
+
reason: 'no-installation',
|
|
244
271
|
detail: {
|
|
245
272
|
root: rootCanonical,
|
|
246
273
|
markerPath,
|
|
247
274
|
markerRead,
|
|
248
275
|
...(markerError === undefined ? {} : { markerError }),
|
|
249
|
-
|
|
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
|
-
|
|
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
|
/**
|