ygl-trees 0.82.1 → 0.83.0
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 +9 -0
- package/README.md +6 -2
- package/dist/apps/cli/src/bin.js +35 -0
- package/dist/bundle/bin.cjs +62 -11
- package/dist/packages/ops/tools/src/job-cmds.js +11 -1
- package/dist/packages/research/pipeline/src/cmds.js +25 -7
- package/dist/packages/research/produce/src/produce-cmds.js +2 -2
- package/dist/packages/research/produce/src/project-cmds.js +1 -1
- package/dist/packages/techtree/model/src/cmds.js +30 -5
- package/package.json +3 -3
- package/skills/ygl-trees-eval-gates/SKILL.md +2 -0
- package/skills/ygl-trees-quickstart/SKILL.md +5 -1
- package/skills/ygl-trees-research/SKILL.md +1 -0
- package/skills/ygl-trees-techtree/SKILL.md +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.83.0(2026-10-17)并发防护:指针钉住 + 半写拒绝 + 帮助面
|
|
4
|
+
- **写命令长旗标钉住**(用户上报①,最重:并发会话两次劫持共享 current-project 指针,资产碎片化跨 3 项目):`research *` / `produce *`(含 task)/ `project *` 全部接受 `--project <p>` 长旗标,`node *` 接受 `--tree <t>`(短旗标 `-p`/`-t` 不变;`node import --tree` 仍是目标树名,别名不遮蔽);source/evidence/claim/task 写回执带 `· project <名>` 回显落点——写错项目当场可见,不必事后对账
|
|
5
|
+
- **`source add --query` 半写防护**(上报①:attach 失败仍落源):引用不存在时**先拒绝后写入**(rc=1 点名、零落地),不再"源已落、链接失败";attach 落库后失败的路径改报已落 id + `research query attach` 重试指路
|
|
6
|
+
- **`--help` 任意深度**(上报②:`kb add --help` 不识别直接 rc=2):任意子命令带 `--help` 打印所在组的用法切片 rc=0(`--json` 返回 `{help}`),在 flag 校验与 db boot 之前短路;`webui serve --help` 等原有短路兼容
|
|
7
|
+
- **no-db 报错点名工作区根因**(上报③:`db init` 前报错不提示 YGL_HOME):无库时错误带 `workspace: <path>` 与来源(YGL_HOME 显式 / 默认 ~/.ygl-trees),冷启动错环境一眼可辨
|
|
8
|
+
- **`node can --eval` attachments 快照**(建议④:P 值过了看不出吃了哪些 rel 证据):输出回显本次判定实际吃到的 rel 挂接——`--json` 增 `eval_attachments[]`,人类视图 `attachments ×N:` 逐条(rel ← type:id 摘要),复现不必翻库;无挂接时字段省略(字节兼容)
|
|
9
|
+
- **job 子命令无锁启动**(建议⑤配套):`job run/tick` 的子进程带 `YGL_SERVE=1` 启动——此前 runner 自持应用级写锁,任何 `ygl-trees …` 型任务命令必撞自身死锁(rc=1 "locked");WAL 并发读 + SQLite 层写串行,与 webui serve 同推理。到期巡检范式:`job add "到期巡检" --every 1440 -- ygl-trees node cond --due --days 30`(真实工作区已挂,30 天窗口即刻命中 w1 三门槛)
|
|
10
|
+
- 测试:demo 5k 段 7 断言(--project 钉住 / 落点校验 / 半写拒绝+零落地 / --help rc=0 / attachments 快照)+ 5h `--tree` 别名断言 → 113;readme-verify +3(kb add --help、source add --project、node list --tree 点名)→ 46
|
|
11
|
+
|
|
3
12
|
## 0.82.1(2026-10-17)预测生命周期护栏:挂证据 ≠ 仲裁
|
|
4
13
|
- **`claim link` 不再抢跑预测仲裁**(用户上报③:`--stance supports` 自动把 [预测·…] 断言置 supported,predict audit 判 supported||refuted → resolved——预测刚挂证据就退出跟踪表,实际无人仲裁):预测前缀断言挂证据时跳过自动晋升,状态保持 unverified 直到显式 `claim resolve` / 到期回测;证据链接与 stance 照常落库,回执明示 `auto_promote: false`(`--json` 返回 `{claim, evidence, stance, status, auto_promote}`);普通断言的自动推导不变(回归护栏)
|
|
5
14
|
- **`claim unresolve <claimId>`**(建议②:误晋升后无归位命令):复位通道把断言重置回 unverified(撤销链接副作用 / 抢跑仲裁),幂等(已 unverified → `duplicate:true`),落 `claim.unresolve` 事件留痕——0.82.1 前被误晋升的预测可用它归队
|
package/README.md
CHANGED
|
@@ -109,9 +109,12 @@ ygl-trees node import --file tree.json --tree copy # 严格校验、事务导
|
|
|
109
109
|
|
|
110
110
|
**跨树巡检 + 临期门槛(0.82)**:`node ready --all` 一次扫全部树(12 棵树 24 条命令 → 1 条);`node cond --due [--days N]` 列出问题文本带 `YYYY-MM-DD` 期限且 N 天内到期(含逾期)的 eval 条件——w1/d90 预测门槛不再依赖人记得看;`--eval` 逐条送判定模型(离线安全回落 pending),**判定只报告不仲裁**(通过 ≠ 自动解锁,resolve/unlock 保持人工,与 predict audit 同纪律)。
|
|
111
111
|
|
|
112
|
+
**并发钉住 + 半写防护(0.83,真实用户报告)**:并发会话共享 `current_project` 指针互踩导致资产碎片化——研究/产出/树三域写命令全部接受长旗标钉住:`research * --project <p>`、`produce/task * --project <p>`、`node * --tree <t>`(短旗标 `-p`/`-t` 不变);写回执带 `· project <名>` 回显落点,写错项目当场可见。`source add --query` 引用不存在时**先拒绝后写入**(rc=1、零落地),不再出现"源已落、链接失败"的半写;`job run/tick` 的子命令以无锁环境启动(WAL 并发读),`ygl-trees …` 型任务命令不再与运行器自身死锁。`--help` 在任意深度(如 `kb add --help`)打组帮助 rc=0;`db init` 前的报错提示 `YGL_HOME` 工作区根因;到期巡检可直接挂 job(`job add "到期巡检" --every 1440 -- ygl-trees node cond --due --days 30`)。
|
|
113
|
+
|
|
112
114
|
**决策模型评估门(spec R8;0.14 布尔 → 0.26 三题型 → 0.27 混批 → 0.80 织物并入)**:语义型必检条件用 eval 题型声明,`node can <key> --eval` 会把**节点元数据 + 直接父(关联)+ 祖先链(闭包索引)+ 条件组 + 已解锁集 + --state 袋 + rel 挂接摘要**组装为上下文包,交给评估模型(TypeSafe jev,经 Vercel AI Gateway)判定:
|
|
113
115
|
|
|
114
116
|
- **rel 织物并入(0.80)**:节点挂接的 evidence(quote 摘要)/ claim(text+status+confidence)/ kb/source(标题)等 rel 关系(双向,≤12 条,文本截 120 字)自动进上下文包的 `attachments`——「证据挂链」纪律从此对语义门槛可见,不必再 `--state k=v` 手动携带裁决事实(实测 P 0.30 → 0.92 的差距由挂链补平)
|
|
117
|
+
- **attachments 快照(0.83)**:`node can <key> --eval` 的输出(`--json` 的 `eval_attachments[]` + 人类视图 `attachments ×N:`)回显本次判定实际吃到的 rel 挂接——P 值过了也能一眼看出依据是哪几条 evidence/claim,复现不必翻库
|
|
115
118
|
|
|
116
119
|
- **三题型**:`--op eval`(布尔 P(true))/ `--op eval-choice`(多选一,判 **P(期望答案)**,`--options "k=desc,..."` + `--answer k`)/ `--op eval-score`(等级评分 `--levels "级0,..."` 2-10 级 + `--min-score n`,判概率加权均值 ±1 灰带)
|
|
117
120
|
- P≥0.7 通过 · P≤0.4 未通过 · **灰带 pending**(过程态,禁作终态,单列不阻塞也不放行);score 的灰带是 [min−1, min)
|
|
@@ -131,7 +134,8 @@ ygl-trees node import --file tree.json --tree copy # 严格校验、事务导
|
|
|
131
134
|
```
|
|
132
135
|
query add 登记检索式
|
|
133
136
|
query attach 检索式 ↔ 来源关联(query list 显示召回数)
|
|
134
|
-
source add 收来源,按 (project, url_hash) 去重;--query
|
|
137
|
+
source add 收来源,按 (project, url_hash) 去重;--query 顺带关联(引用不存在 → 整体拒绝 rc=1,零落地)
|
|
138
|
+
全组接受 --project <p> 长旗标钉住目标项目(并发会话不依赖共享 current 指针;回执带 · project 回显)
|
|
135
139
|
--adjudication "<裁决摘要>":会话内裁决原生载体(url 自动 session://adjudication/<时刻>,摘要兼作标题);--url 收任意 <scheme>://(https/file/session/a2a/… 跨 Agent 引用免落盘)
|
|
136
140
|
source fetch <url> 直接抓取网页入库:title/meta description/去标签正文;15s 超时、非 html 拒收、失败零副作用,正文过薄自动打 needs_review
|
|
137
141
|
evidence add 从来源摘原文片段,带 locator(页码/章节);长摘录用 --file <path> 免转义
|
|
@@ -296,7 +300,7 @@ ygl-trees agent ask "<question>" natural language interface
|
|
|
296
300
|
## 五b、开发与运维工具
|
|
297
301
|
|
|
298
302
|
```bash
|
|
299
|
-
pnpm test # 四段制回归:unit (vitest 177 例) + build (tsc + tsdown) + demo(
|
|
303
|
+
pnpm test # 四段制回归:unit (vitest 177 例) + build (tsc + tsdown) + demo(113 条断言)+ contract:audit(86 项)
|
|
300
304
|
pnpm bench # 性能基准七席(startup/写路径/检索/索引),每席含 RSS 内存端点 → tmp/bench-results.json
|
|
301
305
|
node benchmarks/bench.js --quick # 缩小规模快速跑
|
|
302
306
|
pnpm pack:check # 发布就绪门禁:打包 → 隔离安装 → 驱动发布产物跑命令面 + --json 纯净性
|
package/dist/apps/cli/src/bin.js
CHANGED
|
@@ -42,6 +42,7 @@ exports.loadBundle = loadBundle;
|
|
|
42
42
|
exports.repoRoot = repoRoot;
|
|
43
43
|
exports.appVersion = appVersion;
|
|
44
44
|
exports.usage = usage;
|
|
45
|
+
exports.groupHelp = groupHelp;
|
|
45
46
|
exports.main = main;
|
|
46
47
|
exports.generateLlmsManifest = generateLlmsManifest;
|
|
47
48
|
/**
|
|
@@ -251,6 +252,25 @@ Global: --json --no-color --db <file> --home <dir>
|
|
|
251
252
|
function usage() {
|
|
252
253
|
return USAGE;
|
|
253
254
|
}
|
|
255
|
+
/** 0.83 (user report ②): `--help` at any depth prints the relevant usage
|
|
256
|
+
* slice — `kb add --help` used to fall into flag validation rc=2. Renders the
|
|
257
|
+
* group's own block (found by its bold header line); unknown groups get the
|
|
258
|
+
* full usage. */
|
|
259
|
+
function groupHelp(group) {
|
|
260
|
+
const pfx = (0, fmt_1.paint)(fmt_1.C.bold, group, true);
|
|
261
|
+
const lines = USAGE.split("\n");
|
|
262
|
+
const start = lines.findIndex((l) => l.startsWith(pfx));
|
|
263
|
+
if (start < 0)
|
|
264
|
+
return USAGE;
|
|
265
|
+
let end = lines.length;
|
|
266
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
267
|
+
if (lines[i].startsWith("\u001b[1m")) {
|
|
268
|
+
end = i;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return lines.slice(0, 4).join("\n") + "\n\n" + lines.slice(start, end).join("\n").trimEnd();
|
|
273
|
+
}
|
|
254
274
|
// ── Main ──
|
|
255
275
|
async function main(argv) {
|
|
256
276
|
const args = (0, types_1.parse)(argv);
|
|
@@ -291,6 +311,17 @@ async function main(argv) {
|
|
|
291
311
|
(0, fmt_1.out)(ctx, appVersion());
|
|
292
312
|
return 0;
|
|
293
313
|
}
|
|
314
|
+
// 0.83 (user report ②): `--help` at ANY depth prints the group's usage
|
|
315
|
+
// slice rc=0 — `kb add --help` used to fall into flag validation rc=2.
|
|
316
|
+
// Fires before dispatch and before db boot: help never touches the db.
|
|
317
|
+
if (args.flags.help === true && group) {
|
|
318
|
+
const text = groupHelp(group);
|
|
319
|
+
if (ctx.json)
|
|
320
|
+
console.log(JSON.stringify({ help: text.replace(/\u001b\[[0-9;]*m/g, "") }, null, 2));
|
|
321
|
+
else
|
|
322
|
+
(0, fmt_1.out)(ctx, text);
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
294
325
|
// llms: structured capability manifest for LLM consumption (Ant Design
|
|
295
326
|
// "for-agents" pattern — one output gives any Agent the full command surface).
|
|
296
327
|
// Onboarding: detect first run (no DB) and guide to db init
|
|
@@ -364,6 +395,10 @@ async function main(argv) {
|
|
|
364
395
|
if (needInit && !require("node:fs").existsSync(file)) {
|
|
365
396
|
(0, fmt_1.bad)(ctx, `no database at ${file}`);
|
|
366
397
|
(0, fmt_1.out)(ctx, ` run: ygl-trees db init`);
|
|
398
|
+
// 0.83 (user report ③): name the workspace root cause — a foreign
|
|
399
|
+
// YGL_HOME (or its absence) is invisible otherwise, and the user chases
|
|
400
|
+
// the db instead of the environment.
|
|
401
|
+
(0, fmt_1.out)(ctx, ` workspace: ${home}${process.env.YGL_HOME ? " (from YGL_HOME)" : " (default ~/.ygl-trees — set YGL_HOME to switch)"}`);
|
|
367
402
|
return 2;
|
|
368
403
|
}
|
|
369
404
|
// ── Cordis composition via bootApp (DSH profile-boot exemplar) ──
|
package/dist/bundle/bin.cjs
CHANGED
|
@@ -5788,7 +5788,11 @@ function execute(env, job) {
|
|
|
5788
5788
|
"pipe",
|
|
5789
5789
|
"pipe"
|
|
5790
5790
|
],
|
|
5791
|
-
timeout: 6e5
|
|
5791
|
+
timeout: 6e5,
|
|
5792
|
+
env: {
|
|
5793
|
+
...process.env,
|
|
5794
|
+
YGL_SERVE: "1"
|
|
5795
|
+
}
|
|
5792
5796
|
});
|
|
5793
5797
|
rc = 0;
|
|
5794
5798
|
} catch (e) {
|
|
@@ -6631,7 +6635,7 @@ function run$8(env, action, rest, args) {
|
|
|
6631
6635
|
const subAction = rest[0];
|
|
6632
6636
|
let project;
|
|
6633
6637
|
try {
|
|
6634
|
-
project = store.projectResolve(args.flags.p ? String(args.flags.p) : void 0);
|
|
6638
|
+
project = store.projectResolve(args.flags.p ?? args.flags.project ? String(args.flags.p ?? args.flags.project) : void 0);
|
|
6635
6639
|
} catch (e) {
|
|
6636
6640
|
bad(ctx, e.message);
|
|
6637
6641
|
return 1;
|
|
@@ -6770,12 +6774,25 @@ function run$8(env, action, rest, args) {
|
|
|
6770
6774
|
tags: String(args.flags.tags || "")
|
|
6771
6775
|
};
|
|
6772
6776
|
const qRef = String(args.flags.query || "");
|
|
6777
|
+
if (qRef) {
|
|
6778
|
+
if (!store.db.get("SELECT id FROM queries WHERE (id=? OR text=?) AND project_id=?", [
|
|
6779
|
+
qRef,
|
|
6780
|
+
qRef,
|
|
6781
|
+
project.id
|
|
6782
|
+
])) {
|
|
6783
|
+
bad(ctx, `query not found: ${require_text.clip(qRef, 40)} — refusing to write (source add would land without its query link; fix the ref or drop --query)`);
|
|
6784
|
+
return 1;
|
|
6785
|
+
}
|
|
6786
|
+
}
|
|
6773
6787
|
return getOrm(env.db.path).then(async (ds) => {
|
|
6774
6788
|
const r = await sourceAdd(ds, project.id, spec);
|
|
6775
|
-
writeResult(ctx, r, (row) => `source added: ${row.id} ${require_text.clip(row.title, 60)}`);
|
|
6776
|
-
if (qRef) {
|
|
6789
|
+
writeResult(ctx, r, (row) => `source added: ${row.id} ${require_text.clip(row.title, 60)} · project ${project.name}`);
|
|
6790
|
+
if (qRef) try {
|
|
6777
6791
|
const at = await queryAttach(ds, project.id, qRef, r.row.id);
|
|
6778
6792
|
if (!ctx.json && !at.dup) out(ctx, paint(C.dim, ` attached to query: "${require_text.clip(at.queryText, 40)}"`, ctx.color));
|
|
6793
|
+
} catch (e) {
|
|
6794
|
+
bad(ctx, `source landed as ${r.row.id} but attach failed: ${e?.message} — research query attach ${require_text.clip(qRef, 20)} ${r.row.id} to retry`);
|
|
6795
|
+
return 1;
|
|
6779
6796
|
}
|
|
6780
6797
|
return 0;
|
|
6781
6798
|
}).catch((e) => {
|
|
@@ -6948,7 +6965,7 @@ function run$8(env, action, rest, args) {
|
|
|
6948
6965
|
}
|
|
6949
6966
|
const note = String(args.flags.note || "");
|
|
6950
6967
|
const locator = String(args.flags.locator || "");
|
|
6951
|
-
return runWrite(env, (ds) => evidenceAdd(ds, project.id, sourceId, quote, note, locator, force), (r) => `evidence added: ${r.id} ${require_text.clip(quote, 50)}`);
|
|
6968
|
+
return runWrite(env, (ds) => evidenceAdd(ds, project.id, sourceId, quote, note, locator, force), (r) => `evidence added: ${r.id} ${require_text.clip(quote, 50)} · project ${project.name}`);
|
|
6952
6969
|
}
|
|
6953
6970
|
if (action === "evidence" && subAction === "assess") {
|
|
6954
6971
|
const id = rest[1];
|
|
@@ -7044,7 +7061,7 @@ function run$8(env, action, rest, args) {
|
|
|
7044
7061
|
return 2;
|
|
7045
7062
|
}
|
|
7046
7063
|
const confidence = String(args.flags.confidence || configGet(env.db, "default_confidence"));
|
|
7047
|
-
return runWrite(env, (ds) => claimAdd(ds, project.id, text, confidence, force), (r) => `claim added: ${r.id} ${require_text.clip(text, 60)}`);
|
|
7064
|
+
return runWrite(env, (ds) => claimAdd(ds, project.id, text, confidence, force), (r) => `claim added: ${r.id} ${require_text.clip(text, 60)} · project ${project.name}`);
|
|
7048
7065
|
}
|
|
7049
7066
|
if (subAction === "link") {
|
|
7050
7067
|
if (!rest[1] || !rest[2]) {
|
|
@@ -7425,7 +7442,7 @@ function run$7(env, action, rest, args) {
|
|
|
7425
7442
|
ack(ctx, { project: name }, `now using: ${name}`);
|
|
7426
7443
|
return 0;
|
|
7427
7444
|
case "show": {
|
|
7428
|
-
const resolved = store.projectResolve(args.flags.p ? String(args.flags.p) : void 0);
|
|
7445
|
+
const resolved = store.projectResolve(args.flags.p ?? args.flags.project ? String(args.flags.p ?? args.flags.project) : void 0);
|
|
7429
7446
|
return getOrm(env.db.path).then(async (ds) => {
|
|
7430
7447
|
const proj = await ds.getRepository(Project).findOne({
|
|
7431
7448
|
where: { id: resolved.id },
|
|
@@ -7846,7 +7863,7 @@ function run$5(env, action, rest, args) {
|
|
|
7846
7863
|
}
|
|
7847
7864
|
let project;
|
|
7848
7865
|
try {
|
|
7849
|
-
project = store.projectResolve(args.flags.p ? String(args.flags.p) : void 0);
|
|
7866
|
+
project = store.projectResolve(args.flags.p ?? args.flags.project ? String(args.flags.p ?? args.flags.project) : void 0);
|
|
7850
7867
|
} catch (e) {
|
|
7851
7868
|
bad(ctx, e.message);
|
|
7852
7869
|
return 1;
|
|
@@ -7958,7 +7975,7 @@ function run$5(env, action, rest, args) {
|
|
|
7958
7975
|
return getOrm(env.db.path).then((ds) => taskAdd(ds, pid, title, note)).then((row) => {
|
|
7959
7976
|
if (!nodeRef) {
|
|
7960
7977
|
if (ctx.json) console.log(JSON.stringify(row, null, 2));
|
|
7961
|
-
else ok(ctx, `task added: ${row.id} ${title}`);
|
|
7978
|
+
else ok(ctx, `task added: ${row.id} ${title} · project ${project.name}`);
|
|
7962
7979
|
return 0;
|
|
7963
7980
|
}
|
|
7964
7981
|
return relAdd(env, `node:${nodeRef}`, `task:${row.id}`, "tracked-by").then((r) => {
|
|
@@ -8782,7 +8799,7 @@ require_text.init_text();
|
|
|
8782
8799
|
function run$4(env, action, rest, args) {
|
|
8783
8800
|
const { ctx, db } = env;
|
|
8784
8801
|
const trees = new TreeStore(db);
|
|
8785
|
-
const sel = args.flags.t ? String(args.flags.t) : void 0;
|
|
8802
|
+
const sel = !(action === "import") ? args.flags.t ?? args.flags.tree ? String(args.flags.t ?? args.flags.tree) : void 0 : args.flags.t ? String(args.flags.t) : void 0;
|
|
8786
8803
|
const flagNum = (k, d) => {
|
|
8787
8804
|
const v = Number(args.flags[k]);
|
|
8788
8805
|
return Number.isFinite(v) ? v : d;
|
|
@@ -9343,6 +9360,8 @@ function run$4(env, action, rest, args) {
|
|
|
9343
9360
|
}
|
|
9344
9361
|
r.pending = pendingList;
|
|
9345
9362
|
r.evaluated = evaluated;
|
|
9363
|
+
const atts = contextPkg.attachments;
|
|
9364
|
+
if (Array.isArray(atts) && atts.length) r.eval_attachments = atts;
|
|
9346
9365
|
r.ok = r.missing_required.length === 0 && r.blocked_by.length === 0 && pendingList.length === 0;
|
|
9347
9366
|
try {
|
|
9348
9367
|
const run = process.env.YGL_RUN_ID || "";
|
|
@@ -9426,6 +9445,13 @@ function run$4(env, action, rest, args) {
|
|
|
9426
9445
|
for (const m of r.missing_optional) out(ctx, ` - ${m}`);
|
|
9427
9446
|
}
|
|
9428
9447
|
for (const e of r.evaluated) out(ctx, paint(C.dim, ` eval ${e.name}: P=${e.p ?? "-"} → ${e.outcome}${e.reason ? ` (${e.reason})` : ""}`, ctx.color));
|
|
9448
|
+
if (Array.isArray(r.eval_attachments) && r.eval_attachments.length) {
|
|
9449
|
+
out(ctx, paint(C.dim, ` attachments ×${r.eval_attachments.length}:`, ctx.color));
|
|
9450
|
+
for (const a of r.eval_attachments) {
|
|
9451
|
+
const label = a.type === "claim" ? `${a.id}(${a.status})` : a.type === "evidence" ? `${a.id} "${require_text.clip(a.quote, 36)}"` : a.type === "kb" || a.type === "source" ? `${a.id} ${require_text.clip(a.title, 30)}` : a.type === "node" ? `${a.key ?? a.id}` : String(a.id);
|
|
9452
|
+
out(ctx, paint(C.dim, ` - ${a.rel} ← ${a.type}:${label}`, ctx.color));
|
|
9453
|
+
}
|
|
9454
|
+
}
|
|
9429
9455
|
if (r.cost_multiplier !== 1) out(ctx, ` 成本系数 ${r.cost_multiplier}`);
|
|
9430
9456
|
return 0;
|
|
9431
9457
|
}
|
|
@@ -9717,10 +9743,11 @@ function run$4(env, action, rest, args) {
|
|
|
9717
9743
|
if (sub === "list" || sub === void 0) {
|
|
9718
9744
|
for (const f of Object.keys(args.flags || {})) if (![
|
|
9719
9745
|
"t",
|
|
9746
|
+
"tree",
|
|
9720
9747
|
"json",
|
|
9721
9748
|
"no-color"
|
|
9722
9749
|
].includes(f)) {
|
|
9723
|
-
bad(ctx, `unknown flag --${f} for 'node list' — tree selection is '-t <tree>' (positional <tree> also works)`);
|
|
9750
|
+
bad(ctx, `unknown flag --${f} for 'node list' — tree selection is '-t|--tree <tree>' (positional <tree> also works)`);
|
|
9724
9751
|
return 2;
|
|
9725
9752
|
}
|
|
9726
9753
|
table(ctx, trees.nodeList(tid), [
|
|
@@ -12136,6 +12163,22 @@ Global: --json --no-color --db <file> --home <dir>
|
|
|
12136
12163
|
function usage() {
|
|
12137
12164
|
return USAGE;
|
|
12138
12165
|
}
|
|
12166
|
+
/** 0.83 (user report ②): `--help` at any depth prints the relevant usage
|
|
12167
|
+
* slice — `kb add --help` used to fall into flag validation rc=2. Renders the
|
|
12168
|
+
* group's own block (found by its bold header line); unknown groups get the
|
|
12169
|
+
* full usage. */
|
|
12170
|
+
function groupHelp(group) {
|
|
12171
|
+
const pfx = paint(C.bold, group, true);
|
|
12172
|
+
const lines = USAGE.split("\n");
|
|
12173
|
+
const start = lines.findIndex((l) => l.startsWith(pfx));
|
|
12174
|
+
if (start < 0) return USAGE;
|
|
12175
|
+
let end = lines.length;
|
|
12176
|
+
for (let i = start + 1; i < lines.length; i++) if (lines[i].startsWith("\x1B[1m")) {
|
|
12177
|
+
end = i;
|
|
12178
|
+
break;
|
|
12179
|
+
}
|
|
12180
|
+
return lines.slice(0, 4).join("\n") + "\n\n" + lines.slice(start, end).join("\n").trimEnd();
|
|
12181
|
+
}
|
|
12139
12182
|
async function main(argv) {
|
|
12140
12183
|
const args = parse(argv);
|
|
12141
12184
|
const ctx = {
|
|
@@ -12151,6 +12194,12 @@ async function main(argv) {
|
|
|
12151
12194
|
out(ctx, appVersion());
|
|
12152
12195
|
return 0;
|
|
12153
12196
|
}
|
|
12197
|
+
if (args.flags.help === true && group) {
|
|
12198
|
+
const text = groupHelp(group);
|
|
12199
|
+
if (ctx.json) console.log(JSON.stringify({ help: text.replace(/\u001b\[[0-9;]*m/g, "") }, null, 2));
|
|
12200
|
+
else out(ctx, text);
|
|
12201
|
+
return 0;
|
|
12202
|
+
}
|
|
12154
12203
|
if (!group || group === "help") {
|
|
12155
12204
|
const dbFile = require("node:path").join(process.env.YGL_HOME || require("node:path").join(require("node:os").homedir(), ".ygl-trees"), "ygl-trees.db");
|
|
12156
12205
|
if (!require("node:fs").existsSync(dbFile)) {
|
|
@@ -12211,6 +12260,7 @@ async function main(argv) {
|
|
|
12211
12260
|
if (!(group === "db" && action === "init") && group !== "plugin" && !require("node:fs").existsSync(file)) {
|
|
12212
12261
|
bad(ctx, `no database at ${file}`);
|
|
12213
12262
|
out(ctx, ` run: ygl-trees db init`);
|
|
12263
|
+
out(ctx, ` workspace: ${home}${process.env.YGL_HOME ? " (from YGL_HOME)" : " (default ~/.ygl-trees — set YGL_HOME to switch)"}`);
|
|
12214
12264
|
return 2;
|
|
12215
12265
|
}
|
|
12216
12266
|
const VERSION = appVersion();
|
|
@@ -12664,6 +12714,7 @@ if (require.main === module) main(process.argv.slice(2)).then((rc) => {
|
|
|
12664
12714
|
exports.HOME = HOME;
|
|
12665
12715
|
exports.appVersion = appVersion;
|
|
12666
12716
|
exports.generateLlmsManifest = generateLlmsManifest;
|
|
12717
|
+
exports.groupHelp = groupHelp;
|
|
12667
12718
|
exports.loadBundle = loadBundle;
|
|
12668
12719
|
exports.main = main;
|
|
12669
12720
|
exports.repoRoot = repoRoot;
|
|
@@ -162,7 +162,17 @@ function execute(env, job) {
|
|
|
162
162
|
let output = "";
|
|
163
163
|
const started = Date.now();
|
|
164
164
|
try {
|
|
165
|
-
|
|
165
|
+
// 0.83: the runner process itself holds the app-level write lock; a job
|
|
166
|
+
// command that invokes ygl-trees again would collide with its own parent
|
|
167
|
+
// (self-deadlock — every `ygl-trees …` job failed rc=1 "locked"). Children
|
|
168
|
+
// boot lock-free like webui serve: WAL already permits concurrent readers
|
|
169
|
+
// and serializes writers at the SQLite layer.
|
|
170
|
+
output = (0, node_child_process_1.execSync)(job.command, {
|
|
171
|
+
encoding: "utf8",
|
|
172
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
173
|
+
timeout: 600_000,
|
|
174
|
+
env: { ...process.env, YGL_SERVE: "1" },
|
|
175
|
+
});
|
|
166
176
|
rc = 0;
|
|
167
177
|
}
|
|
168
178
|
catch (e) {
|
|
@@ -72,7 +72,7 @@ function run(env, action, rest, args) {
|
|
|
72
72
|
// -- project resolution (allow --p override) -----------------------------
|
|
73
73
|
let project;
|
|
74
74
|
try {
|
|
75
|
-
project = store.projectResolve(args.flags.p ? String(args.flags.p) : undefined);
|
|
75
|
+
project = store.projectResolve((args.flags.p ?? args.flags.project) ? String(args.flags.p ?? args.flags.project) : undefined);
|
|
76
76
|
}
|
|
77
77
|
catch (e) {
|
|
78
78
|
(0, fmt_1.bad)(ctx, e.message);
|
|
@@ -222,14 +222,32 @@ function run(env, action, rest, args) {
|
|
|
222
222
|
tags: String(args.flags.tags || ""),
|
|
223
223
|
};
|
|
224
224
|
const qRef = String(args.flags.query || "");
|
|
225
|
+
// 0.83 (user report ①): half-write guard — `source add --query X` used
|
|
226
|
+
// to commit the source FIRST and attach separately; a bad query ref
|
|
227
|
+
// left the source landed with a pure error receipt (assets fragment
|
|
228
|
+
// silently). Pre-validate the query ref before ANY write; if the
|
|
229
|
+
// attach still fails post-write, name the landed source + the retry.
|
|
230
|
+
if (qRef) {
|
|
231
|
+
const q = store.db.get("SELECT id FROM queries WHERE (id=? OR text=?) AND project_id=?", [qRef, qRef, project.id]);
|
|
232
|
+
if (!q) {
|
|
233
|
+
(0, fmt_1.bad)(ctx, `query not found: ${(0, text_1.clip)(qRef, 40)} — refusing to write (source add would land without its query link; fix the ref or drop --query)`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
225
237
|
return (0, orm_1.getOrm)(env.db.path)
|
|
226
238
|
.then(async (ds) => {
|
|
227
239
|
const r = await writes.sourceAdd(ds, project.id, spec);
|
|
228
|
-
(0, fmt_1.writeResult)(ctx, r, (row) => `source added: ${row.id} ${(0, text_1.clip)(row.title, 60)}`);
|
|
240
|
+
(0, fmt_1.writeResult)(ctx, r, (row) => `source added: ${row.id} ${(0, text_1.clip)(row.title, 60)} · project ${project.name}`);
|
|
229
241
|
if (qRef) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
242
|
+
try {
|
|
243
|
+
const at = await writes.queryAttach(ds, project.id, qRef, r.row.id);
|
|
244
|
+
if (!ctx.json && !at.dup)
|
|
245
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` attached to query: "${(0, text_1.clip)(at.queryText, 40)}"`, ctx.color));
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
(0, fmt_1.bad)(ctx, `source landed as ${r.row.id} but attach failed: ${e?.message} — research query attach ${(0, text_1.clip)(qRef, 20)} ${r.row.id} to retry`);
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
233
251
|
}
|
|
234
252
|
return 0;
|
|
235
253
|
})
|
|
@@ -411,7 +429,7 @@ function run(env, action, rest, args) {
|
|
|
411
429
|
}
|
|
412
430
|
const note = String(args.flags.note || "");
|
|
413
431
|
const locator = String(args.flags.locator || "");
|
|
414
|
-
return runWrite(env, (ds) => writes.evidenceAdd(ds, project.id, sourceId, quote, note, locator, force), (r) => `evidence added: ${r.id} ${(0, text_1.clip)(quote, 50)}`);
|
|
432
|
+
return runWrite(env, (ds) => writes.evidenceAdd(ds, project.id, sourceId, quote, note, locator, force), (r) => `evidence added: ${r.id} ${(0, text_1.clip)(quote, 50)} · project ${project.name}`);
|
|
415
433
|
}
|
|
416
434
|
// R15.3: evidence assess — decision model evaluates evidence quality
|
|
417
435
|
if (action === "evidence" && subAction === "assess") {
|
|
@@ -504,7 +522,7 @@ function run(env, action, rest, args) {
|
|
|
504
522
|
return 2;
|
|
505
523
|
}
|
|
506
524
|
const confidence = String(args.flags.confidence || (0, config_1.configGet)(env.db, "default_confidence"));
|
|
507
|
-
return runWrite(env, (ds) => writes.claimAdd(ds, project.id, text, confidence, force), (r) => `claim added: ${r.id} ${(0, text_1.clip)(text, 60)}`);
|
|
525
|
+
return runWrite(env, (ds) => writes.claimAdd(ds, project.id, text, confidence, force), (r) => `claim added: ${r.id} ${(0, text_1.clip)(text, 60)} · project ${project.name}`);
|
|
508
526
|
}
|
|
509
527
|
if (subAction === "link") {
|
|
510
528
|
if (!rest[1] || !rest[2]) {
|
|
@@ -60,7 +60,7 @@ function run(env, action, rest, args) {
|
|
|
60
60
|
}
|
|
61
61
|
let project;
|
|
62
62
|
try {
|
|
63
|
-
project = store.projectResolve(args.flags.p ? String(args.flags.p) : undefined);
|
|
63
|
+
project = store.projectResolve((args.flags.p ?? args.flags.project) ? String(args.flags.p ?? args.flags.project) : undefined);
|
|
64
64
|
}
|
|
65
65
|
catch (e) {
|
|
66
66
|
(0, fmt_1.bad)(ctx, e.message);
|
|
@@ -204,7 +204,7 @@ function run(env, action, rest, args) {
|
|
|
204
204
|
if (ctx.json)
|
|
205
205
|
console.log(JSON.stringify(row, null, 2));
|
|
206
206
|
else
|
|
207
|
-
(0, fmt_1.ok)(ctx, `task added: ${row.id} ${title}`);
|
|
207
|
+
(0, fmt_1.ok)(ctx, `task added: ${row.id} ${title} · project ${project.name}`);
|
|
208
208
|
return 0;
|
|
209
209
|
}
|
|
210
210
|
// 0.81 ⑦: tasks join the relation fabric — `task add --node` links
|
|
@@ -115,7 +115,7 @@ function run(env, action, rest, args) {
|
|
|
115
115
|
return 0;
|
|
116
116
|
}
|
|
117
117
|
case "show": {
|
|
118
|
-
const resolved = store.projectResolve(args.flags.p ? String(args.flags.p) : undefined);
|
|
118
|
+
const resolved = store.projectResolve((args.flags.p ?? args.flags.project) ? String(args.flags.p ?? args.flags.project) : undefined);
|
|
119
119
|
return (0, orm_1.getOrm)(env.db.path)
|
|
120
120
|
.then(async (ds) => {
|
|
121
121
|
const proj = await ds.getRepository(entities_1.Project).findOne({
|
|
@@ -61,7 +61,14 @@ const text_1 = require("../../../core/text/src/text");
|
|
|
61
61
|
function run(env, action, rest, args) {
|
|
62
62
|
const { ctx, db } = env;
|
|
63
63
|
const trees = new reads_1.TreeStore(db);
|
|
64
|
-
|
|
64
|
+
// 0.83 (user report ①): --tree long alias alongside -t — pointer-hijack
|
|
65
|
+
// pinning needs an ergonomic, self-documenting form. EXCEPTION: `node
|
|
66
|
+
// import --tree <name>` is that command's own target-tree flag (create or
|
|
67
|
+
// merge destination), never a selector — alias must not shadow it.
|
|
68
|
+
const treeFlagAliasOk = !(action === "import");
|
|
69
|
+
const sel = treeFlagAliasOk
|
|
70
|
+
? ((args.flags.t ?? args.flags.tree) ? String(args.flags.t ?? args.flags.tree) : undefined)
|
|
71
|
+
: (args.flags.t ? String(args.flags.t) : undefined);
|
|
65
72
|
const flagNum = (k, d) => {
|
|
66
73
|
const v = Number(args.flags[k]);
|
|
67
74
|
return Number.isFinite(v) ? v : d;
|
|
@@ -548,6 +555,12 @@ function run(env, action, rest, args) {
|
|
|
548
555
|
}
|
|
549
556
|
r.pending = pendingList;
|
|
550
557
|
r.evaluated = evaluated;
|
|
558
|
+
// 0.83 (user report ④): snapshot the rel attachments the eval actually
|
|
559
|
+
// consumed — otherwise a passing P value can't be reproduced without
|
|
560
|
+
// digging the db. Byte-compat: field only when attachments exist.
|
|
561
|
+
const atts = contextPkg.attachments;
|
|
562
|
+
if (Array.isArray(atts) && atts.length)
|
|
563
|
+
r.eval_attachments = atts;
|
|
551
564
|
r.ok = r.missing_required.length === 0 && r.blocked_by.length === 0 && pendingList.length === 0;
|
|
552
565
|
try {
|
|
553
566
|
const run = process.env.YGL_RUN_ID || "";
|
|
@@ -623,6 +636,18 @@ function run(env, action, rest, args) {
|
|
|
623
636
|
}
|
|
624
637
|
for (const e of r.evaluated)
|
|
625
638
|
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` eval ${e.name}: P=${e.p ?? "-"} → ${e.outcome}${e.reason ? ` (${e.reason})` : ""}`, ctx.color));
|
|
639
|
+
// 0.83 ④: what the model actually ate this run (rel fabric snapshot)
|
|
640
|
+
if (Array.isArray(r.eval_attachments) && r.eval_attachments.length) {
|
|
641
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` attachments ×${r.eval_attachments.length}:`, ctx.color));
|
|
642
|
+
for (const a of r.eval_attachments) {
|
|
643
|
+
const label = a.type === "claim" ? `${a.id}(${a.status})`
|
|
644
|
+
: a.type === "evidence" ? `${a.id} "${(0, text_1.clip)(a.quote, 36)}"`
|
|
645
|
+
: a.type === "kb" || a.type === "source" ? `${a.id} ${(0, text_1.clip)(a.title, 30)}`
|
|
646
|
+
: a.type === "node" ? `${a.key ?? a.id}`
|
|
647
|
+
: String(a.id);
|
|
648
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` - ${a.rel} ← ${a.type}:${label}`, ctx.color));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
626
651
|
if (r.cost_multiplier !== 1)
|
|
627
652
|
(0, fmt_1.out)(ctx, ` 成本系数 ${r.cost_multiplier}`);
|
|
628
653
|
return 0;
|
|
@@ -819,11 +844,11 @@ function run(env, action, rest, args) {
|
|
|
819
844
|
if (sub === "list" || sub === undefined) {
|
|
820
845
|
// Contract ⑤ extension (user report: `node list --tree X` silently
|
|
821
846
|
// swallowed the unknown flag and listed the CURRENT tree). node list
|
|
822
|
-
// takes exactly -t <tree> / --json — any other flag is a typo
|
|
823
|
-
// an explicit rc=2 instead of silent misdirection.
|
|
847
|
+
// takes exactly -t/--tree <tree> / --json — any other flag is a typo
|
|
848
|
+
// worth an explicit rc=2 instead of silent misdirection.
|
|
824
849
|
for (const f of Object.keys(args.flags || {})) {
|
|
825
|
-
if (!["t", "json", "no-color"].includes(f)) {
|
|
826
|
-
(0, fmt_1.bad)(ctx, `unknown flag --${f} for 'node list' — tree selection is '-t <tree>' (positional <tree> also works)`);
|
|
850
|
+
if (!["t", "tree", "json", "no-color"].includes(f)) {
|
|
851
|
+
(0, fmt_1.bad)(ctx, `unknown flag --${f} for 'node list' — tree selection is '-t|--tree <tree>' (positional <tree> also works)`);
|
|
827
852
|
return 2;
|
|
828
853
|
}
|
|
829
854
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ygl-trees",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.83.0",
|
|
4
4
|
"description": "猿光 · 科技树 — database-centric CLI for research → production → knowledge reuse, built for AI agents.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/apps/cli/index.js",
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"reflect-metadata": "^0.2.2",
|
|
27
27
|
"typeorm": "^1.1.2",
|
|
28
28
|
"@deepseek-ai/cordis": "~4.0.5-alpha.2",
|
|
29
|
-
"@deepseek-ai/cordis-plugin-timer": "~1.1.7-alpha.2",
|
|
30
29
|
"@deepseek-ai/schemastery": "~3.18.5-alpha.2",
|
|
31
|
-
"@deepseek-ai/cosmokit": "~1.8.6-alpha.2"
|
|
30
|
+
"@deepseek-ai/cosmokit": "~1.8.6-alpha.2",
|
|
31
|
+
"@deepseek-ai/cordis-plugin-timer": "~1.1.7-alpha.2"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "^20.0.0",
|
|
@@ -66,6 +66,8 @@ ygl-trees node can gate-node --eval --json # evaluated[] 带 type 字段 + p
|
|
|
66
66
|
claim(text+status+confidence)、evidence(quote 截 120 字)、kb/source(标题)、node(key+name)、
|
|
67
67
|
其余类型 id-only。裁决链 `decided-by` 挂好后 eval 模型直接可见,不必 `--state k=v` 手动携带事实
|
|
68
68
|
(用户实测:不挂链 P=0.30,挂链后 0.92)。
|
|
69
|
+
**快照回显(0.83)**:`node can <key> --eval` 的输出把本次吃到的 attachments 打回来——`--json` 的
|
|
70
|
+
`eval_attachments[]` + 人类视图 `attachments ×N:` 逐条;P 值依据可复现,不必翻库。
|
|
69
71
|
|
|
70
72
|
## 5. 凭证与端点(优先级:config > 环境变量/文件 > 默认网关)
|
|
71
73
|
|
|
@@ -67,12 +67,16 @@ ygl-trees webui serve --help # 只打印用法不启服务(0.79.1 短路)
|
|
|
67
67
|
|
|
68
68
|
PG 迁移预检:`db migrate pg` 前自动逐表扫描 INTEGER 列类型——非整数值会**点名 table.column rowid=N** 而非迁移时才爆。
|
|
69
69
|
|
|
70
|
-
## 2b2. 多会话安全(0.79)
|
|
70
|
+
## 2b2. 多会话安全(0.79 → 0.83)
|
|
71
71
|
|
|
72
|
+
- **0.83 并发钉住(最重要)**:并发会话共享 current 指针互踩——写命令直接钉住目标:`research * --project <p>`、`produce/task * --project <p>`、`node * --tree <t>`(`node import --tree` 是目标树名,非选择器);写回执带 `· project <名>` 回显落点
|
|
72
73
|
- `tree use` / `project use` 落 `tree.use` / `project.use` 事件(`YGL_SESSION` 会话标识)
|
|
73
74
|
- `agent brief` 的 `selection_changed`:最近 10 分钟选择变更;跨会话变更置顶提示带 `-t`/`-p`
|
|
74
75
|
- `kb add` 回执自检(`verified:true`)+ 工作区回显(防写错 home 的"丢笔记"事故)
|
|
75
76
|
- `research claim link <cid> <sid> --by-source`:来源下全部证据一次挂齐;误传来源 id 会列出其证据 id
|
|
77
|
+
- `research source add --query` 引用不存在 → 整体拒绝 rc=1 零落地(0.83 半写防护)
|
|
78
|
+
- `--help` 任意深度打组帮助 rc=0(如 `kb add --help`);job 子命令无锁启动(`ygl-trees …` 型任务命令不再与 runner 死锁)
|
|
79
|
+
- 到期巡检范式:`job add "到期巡检" --every 1440 -- ygl-trees node cond --due --days 30`
|
|
76
80
|
|
|
77
81
|
## 2c. DB 锁行为(0.68 → 0.79.1)
|
|
78
82
|
|
|
@@ -34,6 +34,7 @@ ygl-trees research source add --adjudication "会话内裁决:三条件已逐
|
|
|
34
34
|
|
|
35
35
|
来源按 `(project, url_hash)` 去重;`--query` 顺带建立召回关联(query list 显示召回数)。
|
|
36
36
|
`--url` 收任意 `<scheme>://`(https/file/session/a2a/…,0.81)——跨 Agent 引用免落盘。
|
|
37
|
+
**并发钉住(0.83)**:全组接受 `--project <p>` 长旗标(与 `-p` 等价)——并发会话共享 current 指针互踩时直接钉住目标项目,回执带 `· project <名>` 回显落点;`--query` 引用不存在 → 整体拒绝 rc=1 零落地(半写防护)。
|
|
37
38
|
|
|
38
39
|
## 2. 摘录与断言
|
|
39
40
|
|
|
@@ -54,6 +54,8 @@ ygl-trees node unlock mining && ygl-trees node lock mining
|
|
|
54
54
|
- `--state-file` 两种形状:扁平 `{"y_imported":40}` 全节点共享;按节点 `{"a":{"y_imported":40}}` 各取各袋;多键 `--json` 返回数组(0.79.1)
|
|
55
55
|
- `ready --all` / `cond --due` 前置于树解析:无当前树也能跑,跨树巡检一条命令(0.82)
|
|
56
56
|
- `cond --due` 提取 eval 问题文本中的 `YYYY-MM-DD`;默认 7 天窗口(含逾期),`--eval` 判定后仍需人工 resolve/unlock——判定 ≠ 仲裁(0.82)
|
|
57
|
+
- **`--tree <t>` 长旗标(0.83)**:node 组树选择别名(与 `-t` 等价)——并发会话互踩 current_tree 时钉住目标;例外:`node import --tree <name>` 是导入目标树名,不是选择器
|
|
58
|
+
- **`can --eval` attachments 快照(0.83)**:`--json` 增 `eval_attachments[]`、人类视图 `attachments ×N:`——本次判定吃了哪些 rel 证据一目了然,P 值复现不必翻库
|
|
57
59
|
|
|
58
60
|
## 3. 迁移与保真
|
|
59
61
|
|