pi-proactive 0.2.0-beta.4 → 0.2.0-beta.6
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/CHANGELOG.md +77 -0
- package/README.md +32 -14
- package/build/extensions/proactive.js +2 -1
- package/daemon/run-daemon.mjs +268 -0
- package/package.json +12 -3
- package/skills/pi-proactive/SKILL.md +67 -9
- package/tools/daemon-common.mjs +289 -0
- package/tools/daemon-install.mjs +488 -0
- package/tools/daemon-uninstall.mjs +302 -0
- package/tools/enumerate-residue.mjs +487 -0
- package/tools/verify-windows.mjs +358 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 独立残留枚举器(DEBT-1)—— 按**冻结口径**实现。
|
|
4
|
+
*
|
|
5
|
+
* 口径依据(唯一真源):docs/project/cp010-enumeration-contract.md
|
|
6
|
+
* §3 残留七字段 + note;pidAlive 三值(true=条目在且进程活 / false=条目在但进程死 / null=条目不在)
|
|
7
|
+
* §4 三态(expired / unreachable 计入;inflight 不计入、单列;端点=文件在且无活监听者 ⇒ 计入)
|
|
8
|
+
* §5 canonical 发射(顶层键固定;scope 恒 canonical 顺序;scope_dirs = --scope 书写顺序;residues 按 id 排序)
|
|
9
|
+
* §6 对拍(丢 checked_at / checked_at_iso / ledger_id;note 逐字比;退出码 0/4/5)
|
|
10
|
+
*
|
|
11
|
+
* 纪律:
|
|
12
|
+
* - **只读**:本工具只读被枚举目录,**恒不写台账**(`--no-record` 是事实描述,不是开关)。
|
|
13
|
+
* - **不打扰**:判定「有活监听者」只依据注册表事实(pid 是否存活),**不连接对端 socket**。
|
|
14
|
+
* - 零第三方依赖;发布包内只有 .mjs(无 .ts)。
|
|
15
|
+
*
|
|
16
|
+
* 退出码:0=成功 / 2=用法或输入错误 / 4=对拍·空对空·不给结论 / 5=对拍·有差异。
|
|
17
|
+
*
|
|
18
|
+
* 与 ai-native 现行实现的**已知口径差异**(逐条证据见 docs/project/cp010-evidence/PROVENANCE.md):
|
|
19
|
+
* ① 本实现按契约 §3 只发七字段 + note;对方还多发 unreachable / identity_via 两个字段;
|
|
20
|
+
* ② 本实现按契约 §5 只发八个顶层键;对方还多发 fault_count / collaboration_count / inflight_count;
|
|
21
|
+
* ③ 「TTL 内但收件方进程已死」:本实现判 unreachable(契约首行「残留 = 不可能再被消费」),
|
|
22
|
+
* 对方判 inflight(只看「注册表有条目」);
|
|
23
|
+
* ④ 端点名解析:本实现认全 src/socket.ts 的两种形态(常规名 + G-R5 降级哈希名),
|
|
24
|
+
* 对方只认降级哈希名 ⇒ 常规名端点上字段不一致。
|
|
25
|
+
*/
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
|
|
31
|
+
/** canonical 目录顺序(契约 §5)。 */
|
|
32
|
+
const CANONICAL_SCOPE = ["mailbox", "run", "agents"];
|
|
33
|
+
/** 信封缺省 TTL(与 src/envelope.ts: DEFAULT_TTL_MS 一致)。 */
|
|
34
|
+
const DEFAULT_TTL_MS = 3_600_000;
|
|
35
|
+
/** 身份不可达时的 ownerProject 兜底。 */
|
|
36
|
+
const UNKNOWN_OWNER = "unknown";
|
|
37
|
+
/** 对拍时丢弃的字段(契约 §6:只丢这三个)。 */
|
|
38
|
+
const DIFF_DROP_FIELDS = ["checked_at", "checked_at_iso", "ledger_id"];
|
|
39
|
+
/** 残留条目的字段集合(契约 §3:七字段 + note)——逐字固定,不多不少。 */
|
|
40
|
+
const RESIDUE_FIELDS = ["kind", "id", "path", "agentId", "pidAlive", "ownerProject", "mtime", "note"];
|
|
41
|
+
|
|
42
|
+
const EXIT_OK = 0;
|
|
43
|
+
const EXIT_USAGE = 2;
|
|
44
|
+
const EXIT_EMPTY_VS_EMPTY = 4;
|
|
45
|
+
const EXIT_DIFFERENT = 5;
|
|
46
|
+
|
|
47
|
+
const USAGE = `用法:
|
|
48
|
+
enumerate-residue.mjs [--agent-dir <dir>] [--scope mailbox,run,agents] [--json] [--no-record]
|
|
49
|
+
enumerate-residue.mjs ... --diff <产物.json> [--lenient]
|
|
50
|
+
|
|
51
|
+
--agent-dir agent 根目录(默认 PI_CODING_AGENT_DIR 或 ~/.pi/agent);扫描 <dir>/proactive/{mailbox,run,agents}
|
|
52
|
+
--scope 要扫描的目录(逗号分隔);书写顺序原样出现在 scope_dirs;scope 恒为 canonical 顺序
|
|
53
|
+
--json stdout 输出 JSON(默认)
|
|
54
|
+
--no-record 显式声明「不写台账」——本工具恒只读,该参数只为调用兼容
|
|
55
|
+
--diff 与一份产物做字段级对拍;0=一致 / 4=空对空·不给结论 / 5=有差异
|
|
56
|
+
--lenient 对拍宽松档(有损:pidAlive null↔false 视为等价;数组按 id 排序后比较)
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------- 只读小工具 ----
|
|
60
|
+
|
|
61
|
+
function readDirSafe(dir) {
|
|
62
|
+
try {
|
|
63
|
+
return fs.readdirSync(dir).sort();
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function statSafe(p) {
|
|
70
|
+
try {
|
|
71
|
+
return fs.statSync(p);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readJsonSafe(p) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** pid 是否存活(EPERM = 进程在但无权发信号 ⇒ 仍算活)。 */
|
|
86
|
+
function pidAlive(pid) {
|
|
87
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 0);
|
|
90
|
+
return true;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return e?.code === "EPERM";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ------------------------------------------------------------------ 命名规则 ----
|
|
97
|
+
// 三条纯函数与 pi-proactive 源码**逐字一致**(发布包内没有 .ts,故此处复制;改规则时必须同步两处):
|
|
98
|
+
// src/socket.ts: sanitizeSocketFileName / hashedSocketFileName
|
|
99
|
+
// src/envelope.ts: agentSegment
|
|
100
|
+
|
|
101
|
+
/** socket 文件名安全化:`/ \ # 空白` → `-`。 */
|
|
102
|
+
function sanitizeSocketFileName(agentId) {
|
|
103
|
+
return agentId.replace(/[/\\#\s]/g, "-") || "unknown";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 降级端点名:`h-<sha256(agentId) 前 16 位>`。 */
|
|
107
|
+
function hashedSocketFileName(agentId) {
|
|
108
|
+
return `h-${createHash("sha256").update(agentId, "utf8").digest("hex").slice(0, 16)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 邮箱/注册表段名:`/ \ 空白` → `_`(保留 `#`)。 */
|
|
112
|
+
function agentSegment(agentId) {
|
|
113
|
+
return agentId.replace(/[/\\\s]/g, "_") || "unknown";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// -------------------------------------------------------------------- 注册表 ----
|
|
117
|
+
|
|
118
|
+
/** 读注册表(只读):`<agentDir>/proactive/agents/*.json`。 */
|
|
119
|
+
function readRegistry(agentsDir) {
|
|
120
|
+
const byId = new Map();
|
|
121
|
+
const bySegment = new Map();
|
|
122
|
+
for (const name of readDirSafe(agentsDir)) {
|
|
123
|
+
if (!name.endsWith(".json")) continue;
|
|
124
|
+
const parsed = readJsonSafe(path.join(agentsDir, name));
|
|
125
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
126
|
+
const agentId =
|
|
127
|
+
typeof parsed.agentId === "string" && parsed.agentId.length > 0 ? parsed.agentId : name.slice(0, -5);
|
|
128
|
+
parsed.agentId = agentId;
|
|
129
|
+
byId.set(agentId, parsed);
|
|
130
|
+
const seg = agentSegment(agentId);
|
|
131
|
+
if (!bySegment.has(seg)) bySegment.set(seg, parsed);
|
|
132
|
+
}
|
|
133
|
+
return { byId, bySegment };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function lookup(registry, agentId) {
|
|
137
|
+
return registry.byId.get(agentId) ?? registry.bySegment.get(agentId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** ownerProject = 注册表条目 cwd 的目录名;身份不可达 ⇒ "unknown"。 */
|
|
141
|
+
function ownerProject(entry) {
|
|
142
|
+
const cwd = entry && typeof entry.cwd === "string" ? entry.cwd : "";
|
|
143
|
+
const base = cwd ? path.basename(cwd.replace(/\/+$/, "")) : "";
|
|
144
|
+
return base || UNKNOWN_OWNER;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------------------------ 枚举本体 ----
|
|
148
|
+
|
|
149
|
+
/** 邮箱:未认领的信封。expired / unreachable 计入;inflight 不计入。 */
|
|
150
|
+
function scanMailbox(mailboxDir, registry, now) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const inbox of readDirSafe(mailboxDir)) {
|
|
153
|
+
if (inbox === "claims") continue; // 认领锁目录,不是收件箱
|
|
154
|
+
const inboxDir = path.join(mailboxDir, inbox);
|
|
155
|
+
const st = statSafe(inboxDir);
|
|
156
|
+
if (!st || !st.isDirectory()) continue;
|
|
157
|
+
for (const name of readDirSafe(inboxDir)) {
|
|
158
|
+
if (!name.endsWith(".json")) continue;
|
|
159
|
+
const file = path.join(inboxDir, name);
|
|
160
|
+
const fst = statSafe(file);
|
|
161
|
+
if (!fst || !fst.isFile()) continue;
|
|
162
|
+
const env = readJsonSafe(file);
|
|
163
|
+
const to = env && typeof env.to === "string" && env.to ? env.to : inbox;
|
|
164
|
+
const entry = lookup(registry, to);
|
|
165
|
+
const id = env && typeof env.id === "string" && env.id ? env.id : name.slice(0, -5);
|
|
166
|
+
const createdAt =
|
|
167
|
+
env && typeof env.createdAt === "number" && Number.isFinite(env.createdAt) ? env.createdAt : fst.mtimeMs;
|
|
168
|
+
const ttlMs = env && typeof env.ttlMs === "number" && env.ttlMs > 0 ? env.ttlMs : DEFAULT_TTL_MS;
|
|
169
|
+
const alive = entry ? pidAlive(entry.pid) : null; // null = 条目不在
|
|
170
|
+
const expired = now - createdAt >= ttlMs; // 恰好等于 TTL 即过期
|
|
171
|
+
const state = expired ? "expired" : alive === true ? "inflight" : "unreachable";
|
|
172
|
+
const note = expired
|
|
173
|
+
? `TTL expired; ttlMs=${ttlMs}`
|
|
174
|
+
: alive === true
|
|
175
|
+
? "inflight (TTL 内且收件方可达)"
|
|
176
|
+
: entry
|
|
177
|
+
? "identity gone (process dead)"
|
|
178
|
+
: "identity gone (registry entry absent)";
|
|
179
|
+
out.push({
|
|
180
|
+
kind: "envelope",
|
|
181
|
+
id,
|
|
182
|
+
path: file,
|
|
183
|
+
agentId: to,
|
|
184
|
+
pidAlive: alive,
|
|
185
|
+
ownerProject: ownerProject(entry),
|
|
186
|
+
// mtime = 对象自身的时间戳:信封用 createdAt(TTL 的基准),端点没有 createdAt ⇒ 用文件 mtime。
|
|
187
|
+
mtime: Math.floor(createdAt),
|
|
188
|
+
note,
|
|
189
|
+
state,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 端点:`<agentDir>/proactive/run/*.sock`。文件在且无活监听者 ⇒ 计入。 */
|
|
197
|
+
function scanRun(runDir, registry) {
|
|
198
|
+
const out = [];
|
|
199
|
+
const byFile = new Map();
|
|
200
|
+
for (const entry of registry.byId.values()) {
|
|
201
|
+
const agentId = entry.agentId;
|
|
202
|
+
if (typeof agentId !== "string" || !agentId) continue;
|
|
203
|
+
// 端点名有两种形态(src/socket.ts: 常规名 / G-R5 长度降级名)——两种都要认。
|
|
204
|
+
byFile.set(`${sanitizeSocketFileName(agentId)}.sock`, entry);
|
|
205
|
+
byFile.set(`${hashedSocketFileName(agentId)}.sock`, entry);
|
|
206
|
+
}
|
|
207
|
+
for (const name of readDirSafe(runDir)) {
|
|
208
|
+
if (!name.endsWith(".sock")) continue;
|
|
209
|
+
const file = path.join(runDir, name);
|
|
210
|
+
const st = statSafe(file);
|
|
211
|
+
// 端点在真机上就是 unix domain socket(statSync().isFile() === false!)⇒ 只排除目录,不能用 isFile 过滤。
|
|
212
|
+
if (!st || st.isDirectory()) continue;
|
|
213
|
+
const entry = byFile.get(name);
|
|
214
|
+
const alive = entry ? pidAlive(entry.pid) : null;
|
|
215
|
+
if (alive === true) continue; // 有活监听者 ⇒ 不计入
|
|
216
|
+
out.push({
|
|
217
|
+
kind: "endpoint",
|
|
218
|
+
id: name,
|
|
219
|
+
path: file,
|
|
220
|
+
agentId: entry ? entry.agentId : null,
|
|
221
|
+
pidAlive: alive,
|
|
222
|
+
ownerProject: ownerProject(entry),
|
|
223
|
+
mtime: Math.floor(st.mtimeMs),
|
|
224
|
+
note: entry ? "entry alive but process dead" : "registry entry gone (swept)",
|
|
225
|
+
state: "endpoint",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 残留条目 → 契约 §3 的七字段 + note(逐字固定顺序)。 */
|
|
232
|
+
function toResidue(r) {
|
|
233
|
+
const out = {};
|
|
234
|
+
for (const f of RESIDUE_FIELDS) out[f] = r[f];
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 枚举:产出 canonical 产物(契约 §5)。 */
|
|
239
|
+
function enumerate(opts) {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
const root = path.join(opts.agentDir, "proactive");
|
|
242
|
+
const dirs = {
|
|
243
|
+
mailbox: path.join(root, "mailbox"),
|
|
244
|
+
run: path.join(root, "run"),
|
|
245
|
+
agents: path.join(root, "agents"),
|
|
246
|
+
};
|
|
247
|
+
const selected = CANONICAL_SCOPE.filter((d) => opts.scope.includes(d));
|
|
248
|
+
const registry = opts.scope.includes("agents")
|
|
249
|
+
? readRegistry(dirs.agents)
|
|
250
|
+
: { byId: new Map(), bySegment: new Map() };
|
|
251
|
+
|
|
252
|
+
const records = [];
|
|
253
|
+
if (opts.scope.includes("mailbox")) records.push(...scanMailbox(dirs.mailbox, registry, now));
|
|
254
|
+
if (opts.scope.includes("run")) records.push(...scanRun(dirs.run, registry));
|
|
255
|
+
records.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
256
|
+
|
|
257
|
+
const counts = { expired: 0, unreachable: 0, endpoint: 0, inflight: 0 };
|
|
258
|
+
for (const r of records) counts[r.state] += 1;
|
|
259
|
+
const residues = records.filter((r) => r.state !== "inflight").map(toResidue);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
checked_at: now,
|
|
263
|
+
checked_at_iso: new Date(now).toISOString(),
|
|
264
|
+
scope: selected.map((d) => dirs[d]),
|
|
265
|
+
scope_dirs: [...opts.scope],
|
|
266
|
+
residue_count: residues.length,
|
|
267
|
+
zero: residues.length === 0,
|
|
268
|
+
residues,
|
|
269
|
+
note:
|
|
270
|
+
`残留 ${residues.length} 条(expired ${counts.expired} · unreachable ${counts.unreachable} · ` +
|
|
271
|
+
`endpoint ${counts.endpoint});在途 ${counts.inflight} 条(TTL 内且收件方可达,不计入);只读枚举,未写台账`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------- 对拍 ----
|
|
276
|
+
|
|
277
|
+
function sameValue(a, b) {
|
|
278
|
+
return deepEqual(a, b);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 深比较(JSON 值):数组/对象按内容比,不是按引用。 */
|
|
282
|
+
function deepEqual(a, b) {
|
|
283
|
+
if (a === b) return true;
|
|
284
|
+
if (typeof a === "number" && typeof b === "number") return Number.isNaN(a) && Number.isNaN(b);
|
|
285
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
286
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
287
|
+
if (Array.isArray(a)) {
|
|
288
|
+
if (a.length !== b.length) return false;
|
|
289
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
290
|
+
}
|
|
291
|
+
const ka = Object.keys(a);
|
|
292
|
+
const kb = Object.keys(b);
|
|
293
|
+
if (ka.length !== kb.length) return false;
|
|
294
|
+
return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 宽松档的有损等价:pidAlive null↔false 视为等价(契约 §6)。 */
|
|
298
|
+
function lossyEqual(field, a, b) {
|
|
299
|
+
return field === "pidAlive" && (a === null || b === null) && (a === false || b === false);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function diffValue(field, a, b, lenient) {
|
|
303
|
+
if (sameValue(a, b)) return null;
|
|
304
|
+
if (lenient && lossyEqual(field, a, b)) return null;
|
|
305
|
+
return { field, ours: a === undefined ? null : a, theirs: b === undefined ? null : b };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function compareResidues(oursList, theirList, lenient) {
|
|
309
|
+
const differences = [];
|
|
310
|
+
const o = new Map(oursList.map((r) => [r.id, r]));
|
|
311
|
+
const t = new Map(theirList.map((r) => [r.id, r]));
|
|
312
|
+
// 严格档:残留条目的**顺序**也算差异(契约 §6 的「数组排序」有损项默认关闭)。
|
|
313
|
+
if (!lenient) {
|
|
314
|
+
const oIds = oursList.map((r) => r.id);
|
|
315
|
+
const tIds = theirList.map((r) => r.id);
|
|
316
|
+
if (!deepEqual(oIds, tIds)) {
|
|
317
|
+
differences.push({ field: "residues[].id 序列", ours: oIds, theirs: tIds, reason: "残留条目顺序不同" });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
for (const id of [...o.keys()].sort()) {
|
|
321
|
+
const a = o.get(id);
|
|
322
|
+
const b = t.get(id);
|
|
323
|
+
if (!b) {
|
|
324
|
+
differences.push({ field: `residues[id=${id}]`, ours: id, theirs: null, reason: "我方多出残留条目" });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
for (const f of RESIDUE_FIELDS) {
|
|
328
|
+
const d = diffValue(f, a[f], b[f], lenient);
|
|
329
|
+
if (d) differences.push({ field: `residues[id=${id}].${f}`, ours: d.ours, theirs: d.theirs });
|
|
330
|
+
}
|
|
331
|
+
for (const f of Object.keys(b)) {
|
|
332
|
+
if (!RESIDUE_FIELDS.includes(f)) {
|
|
333
|
+
differences.push({ field: `residues[id=${id}].${f}`, ours: null, theirs: b[f], reason: "对方多出字段" });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const f of Object.keys(a)) {
|
|
337
|
+
if (!RESIDUE_FIELDS.includes(f)) {
|
|
338
|
+
differences.push({ field: `residues[id=${id}].${f}`, ours: a[f], theirs: null, reason: "我方多出字段" });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const id of [...t.keys()].sort()) {
|
|
343
|
+
if (!o.has(id)) {
|
|
344
|
+
differences.push({ field: `residues[id=${id}]`, ours: null, theirs: id, reason: "对方多出残留条目" });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return differences;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** 字段级对拍(契约 §6)。 */
|
|
351
|
+
function compare(ours, theirs, lenient) {
|
|
352
|
+
const ourRes = Array.isArray(ours.residues) ? ours.residues : [];
|
|
353
|
+
const theirRes = Array.isArray(theirs.residues) ? theirs.residues : [];
|
|
354
|
+
const ourCount = typeof ours.residue_count === "number" ? ours.residue_count : ourRes.length;
|
|
355
|
+
const theirCount = typeof theirs.residue_count === "number" ? theirs.residue_count : theirRes.length;
|
|
356
|
+
const summary = {
|
|
357
|
+
ours: { residue_count: ourCount, zero: ours.zero === true },
|
|
358
|
+
theirs: { residue_count: theirCount, zero: theirs.zero === true },
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// 空对空 ⇒ 不给结论(两边都没残留时,「一致」是空洞的)。
|
|
362
|
+
if (ourCount === 0 && theirCount === 0) {
|
|
363
|
+
return {
|
|
364
|
+
verdict: "empty_vs_empty",
|
|
365
|
+
reason: "空对空·不给结论(两侧都没有残留 ⇒ 一致性无法判别)",
|
|
366
|
+
lossy: !!lenient,
|
|
367
|
+
differences: [],
|
|
368
|
+
...summary,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const differences = [];
|
|
373
|
+
for (const key of Object.keys(theirs)) {
|
|
374
|
+
if (DIFF_DROP_FIELDS.includes(key)) continue;
|
|
375
|
+
if (!(key in ours)) {
|
|
376
|
+
differences.push({ field: key, ours: null, theirs: theirs[key], reason: "对方多出顶层键" });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (key === "residues") continue;
|
|
380
|
+
const d = diffValue(key, ours[key], theirs[key], lenient);
|
|
381
|
+
if (d) differences.push({ field: key, ours: d.ours, theirs: d.theirs });
|
|
382
|
+
}
|
|
383
|
+
for (const key of Object.keys(ours)) {
|
|
384
|
+
if (DIFF_DROP_FIELDS.includes(key)) continue;
|
|
385
|
+
if (!(key in theirs)) {
|
|
386
|
+
differences.push({ field: key, ours: ours[key], theirs: null, reason: "我方多出顶层键" });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
differences.push(...compareResidues(ourRes, theirRes, lenient));
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
verdict: differences.length === 0 ? "identical" : "different",
|
|
393
|
+
reason:
|
|
394
|
+
differences.length === 0
|
|
395
|
+
? "字段级一致(已丢 checked_at / checked_at_iso / ledger_id)"
|
|
396
|
+
: `有差异(${differences.length} 处)`,
|
|
397
|
+
lossy: !!lenient,
|
|
398
|
+
lossy_note: lenient ? "有损档:pidAlive null↔false 视为等价;数组按 id 排序后比较" : null,
|
|
399
|
+
differences,
|
|
400
|
+
...summary,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ------------------------------------------------------------------------ CLI ----
|
|
405
|
+
|
|
406
|
+
function parseArgs(argv) {
|
|
407
|
+
const opts = {
|
|
408
|
+
agentDir: null,
|
|
409
|
+
scope: null,
|
|
410
|
+
json: false,
|
|
411
|
+
diff: null,
|
|
412
|
+
lenient: false,
|
|
413
|
+
};
|
|
414
|
+
for (let i = 0; i < argv.length; i++) {
|
|
415
|
+
const a = argv[i];
|
|
416
|
+
if (a === "--agent-dir") opts.agentDir = argv[++i] ?? null;
|
|
417
|
+
else if (a === "--scope") opts.scope = argv[++i] ?? null;
|
|
418
|
+
else if (a === "--json") opts.json = true;
|
|
419
|
+
else if (a === "--no-record") opts.noRecord = true;
|
|
420
|
+
else if (a === "--diff") opts.diff = argv[++i] ?? null;
|
|
421
|
+
else if (a === "--lenient") opts.lenient = true;
|
|
422
|
+
else if (a === "--record") return { error: "本工具恒只读(不写台账):--record 未实现,请用 --no-record" };
|
|
423
|
+
else if (a === "-h" || a === "--help") return { help: true };
|
|
424
|
+
else return { error: `未知参数:${a}` };
|
|
425
|
+
}
|
|
426
|
+
if (opts.agentDir === null) {
|
|
427
|
+
opts.agentDir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
428
|
+
}
|
|
429
|
+
if (opts.scope === null) {
|
|
430
|
+
opts.scope = [...CANONICAL_SCOPE];
|
|
431
|
+
} else {
|
|
432
|
+
const names = opts.scope.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
433
|
+
for (const n of names) {
|
|
434
|
+
if (!CANONICAL_SCOPE.includes(n)) return { error: `--scope 含未知目录:${n}(只能是 ${CANONICAL_SCOPE.join("/")})` };
|
|
435
|
+
}
|
|
436
|
+
const seen = new Set();
|
|
437
|
+
opts.scope = names.filter((n) => (seen.has(n) ? false : (seen.add(n), true)));
|
|
438
|
+
if (opts.scope.length === 0) return { error: "--scope 为空" };
|
|
439
|
+
}
|
|
440
|
+
opts.agentDir = path.resolve(opts.agentDir);
|
|
441
|
+
if (opts.diff !== null) opts.diff = path.resolve(opts.diff);
|
|
442
|
+
return { opts };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function humanReport(artifact) {
|
|
446
|
+
const lines = [
|
|
447
|
+
`残留 ${artifact.residue_count} 条|检查时刻 ${artifact.checked_at_iso}`,
|
|
448
|
+
`note: ${artifact.note}`,
|
|
449
|
+
];
|
|
450
|
+
for (const r of artifact.residues) {
|
|
451
|
+
lines.push(`- [${r.kind}] ${r.id} pidAlive=${String(r.pidAlive)} owner=${r.ownerProject} ${r.note}`);
|
|
452
|
+
lines.push(` ${r.path}`);
|
|
453
|
+
}
|
|
454
|
+
return `${lines.join("\n")}\n`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function main(argv) {
|
|
458
|
+
const parsed = parseArgs(argv);
|
|
459
|
+
if (parsed.error) {
|
|
460
|
+
process.stderr.write(`用法错误:${parsed.error}\n${USAGE}`);
|
|
461
|
+
return EXIT_USAGE;
|
|
462
|
+
}
|
|
463
|
+
if (parsed.help) {
|
|
464
|
+
process.stdout.write(USAGE);
|
|
465
|
+
return EXIT_OK;
|
|
466
|
+
}
|
|
467
|
+
const opts = parsed.opts;
|
|
468
|
+
const artifact = enumerate(opts);
|
|
469
|
+
|
|
470
|
+
if (opts.diff !== null) {
|
|
471
|
+
const peer = readJsonSafe(opts.diff);
|
|
472
|
+
if (!peer || typeof peer !== "object" || Array.isArray(peer)) {
|
|
473
|
+
process.stderr.write(`用法错误:读不到对拍产物或不是 JSON 对象:${opts.diff}\n`);
|
|
474
|
+
return EXIT_USAGE;
|
|
475
|
+
}
|
|
476
|
+
const report = compare(artifact, peer, opts.lenient);
|
|
477
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
478
|
+
if (report.verdict === "identical") return EXIT_OK;
|
|
479
|
+
if (report.verdict === "empty_vs_empty") return EXIT_EMPTY_VS_EMPTY;
|
|
480
|
+
return EXIT_DIFFERENT;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
process.stdout.write(opts.json ? `${JSON.stringify(artifact, null, 2)}\n` : humanReport(artifact));
|
|
484
|
+
return EXIT_OK;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
process.exitCode = main(process.argv.slice(2));
|