ygl-trees 0.78.0 → 0.79.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/CHANGELOG.md +25 -0
- package/README.md +24 -3
- package/dist/apps/cli/src/bin.js +34 -7
- package/dist/bundle/bin.cjs +644 -105
- package/dist/packages/core/database/src/db.js +73 -5
- package/dist/packages/knowledge/skills/src/cmds.js +26 -1
- package/dist/packages/ops/tools/src/agent-cmds.js +35 -0
- package/dist/packages/ops/tools/src/db-cmds.js +70 -0
- package/dist/packages/ops/tools/src/doctor.js +79 -24
- package/dist/packages/ops/tools/src/webui.js +9 -0
- package/dist/packages/research/pipeline/src/cmds.js +95 -10
- package/dist/packages/research/pipeline/src/predict.js +82 -9
- package/dist/packages/research/produce/src/produce-cmds.js +6 -0
- package/dist/packages/research/produce/src/project-cmds.js +27 -2
- package/dist/packages/techtree/model/src/cmds.js +236 -72
- package/dist/packages/techtree/model/src/reads.js +8 -0
- package/package.json +5 -4
- package/skills/ygl-trees-quickstart/SKILL.md +18 -7
- package/skills/ygl-trees-research/SKILL.md +2 -0
- package/skills/ygl-trees-techtree/SKILL.md +2 -0
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.COND_KINDS = exports.EDGE_MODES = exports.EDGE_RELS = exports.DB = exports.SCHEMA_SQL = exports.SCHEMA_VERSION = void 0;
|
|
37
|
+
exports.pidAlive = pidAlive;
|
|
37
38
|
exports.dbPath = dbPath;
|
|
38
39
|
/**
|
|
39
40
|
* Database: single source of truth for ygl-trees.
|
|
@@ -395,6 +396,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_refs ON skill_refs(skill_id, path);
|
|
|
395
396
|
class DB {
|
|
396
397
|
db;
|
|
397
398
|
path;
|
|
399
|
+
/** True when THIS instance acquired the .ygllock (0.79.1) — lock-less
|
|
400
|
+
* instances (serve, db unlock) never release a foreign lock. */
|
|
401
|
+
ownsLock = false;
|
|
398
402
|
constructor(file, opts) {
|
|
399
403
|
this.path = file;
|
|
400
404
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -455,18 +459,39 @@ class DB {
|
|
|
455
459
|
catch { /* */ }
|
|
456
460
|
}
|
|
457
461
|
else {
|
|
462
|
+
// 0.79.1 (R297 user report): the old hint guessed "serve? webui
|
|
463
|
+
// dashboard" — but serve never takes the write lock, so the guess
|
|
464
|
+
// sent users to kill the wrong process. The lock dir now carries a
|
|
465
|
+
// `cmd` file (written at acquisition) naming the real holder.
|
|
466
|
+
let holderCmd = "";
|
|
467
|
+
try {
|
|
468
|
+
holderCmd = fs.readFileSync(path.join(lockDir, "cmd"), "utf8").trim();
|
|
469
|
+
}
|
|
470
|
+
catch { /* */ }
|
|
458
471
|
throw new Error(`database is locked by another process (pid: ${holder}).\n` +
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
`
|
|
472
|
+
(holderCmd
|
|
473
|
+
? ` holder command: ${holderCmd}\n`
|
|
474
|
+
: ` the holder is almost certainly a parallel CLI session (serve never takes the write lock);\n`) +
|
|
475
|
+
` read commands still work. The lock self-heals once pid ${holder} exits —\n` +
|
|
476
|
+
` simply retry after it finishes.\n` +
|
|
477
|
+
` to clear manually: ygl-trees db unlock (checks pid liveness, refuses live holders)\n` +
|
|
478
|
+
` last resort: rmdir "${lockDir}"`);
|
|
463
479
|
}
|
|
464
480
|
}
|
|
465
481
|
// acquire the lock (best-effort; crash cleanup via stale detection above)
|
|
482
|
+
let acquired = false;
|
|
466
483
|
if (useLock)
|
|
467
484
|
try {
|
|
468
485
|
fs.mkdirSync(lockDir, { recursive: true });
|
|
469
486
|
fs.writeFileSync(path.join(lockDir, "pid"), String(process.pid));
|
|
487
|
+
acquired = true;
|
|
488
|
+
// 0.79.1: self-reported holder identity — the collision error and
|
|
489
|
+
// `db unlock` read this back instead of guessing who holds the lock.
|
|
490
|
+
try {
|
|
491
|
+
const cmd = [process.argv0, ...process.argv.slice(1)].join(" ").slice(0, 240);
|
|
492
|
+
fs.writeFileSync(path.join(lockDir, "cmd"), cmd);
|
|
493
|
+
}
|
|
494
|
+
catch { /* identity is best-effort */ }
|
|
470
495
|
// P1 cold-user report (Windows): `process.on("exit")` rmSync can silently
|
|
471
496
|
// fail when the SQLite handle hasn't been released yet (EBUSY on the dir).
|
|
472
497
|
// Two mitigations:
|
|
@@ -486,6 +511,10 @@ class DB {
|
|
|
486
511
|
process.on("exit", cleanup);
|
|
487
512
|
}
|
|
488
513
|
catch { /* lock creation failure is non-fatal (read-only scenarios) */ }
|
|
514
|
+
// 0.79.1: ownership flag — lock-less instances (serve, db unlock) must
|
|
515
|
+
// never release a FOREIGN lock on close/exit (latent since serve existed,
|
|
516
|
+
// made acute by lock-free `db unlock`).
|
|
517
|
+
this.ownsLock = acquired;
|
|
489
518
|
this.db = new node_sqlite3_wasm_1.Database(file);
|
|
490
519
|
this.db.exec("PRAGMA journal_mode=WAL");
|
|
491
520
|
// WAL + NORMAL (sqlite.org recommended pairing): commits skip the per-tx
|
|
@@ -495,8 +524,13 @@ class DB {
|
|
|
495
524
|
this.db.exec("PRAGMA foreign_keys=ON");
|
|
496
525
|
this.migrate();
|
|
497
526
|
}
|
|
498
|
-
/** Release the write lock (called by close() and process exit).
|
|
527
|
+
/** Release the write lock (called by close() and process exit).
|
|
528
|
+
* Ownership-guarded since 0.79.1: a lock-less instance (serve, db unlock)
|
|
529
|
+
* shares the workspace with a foreign holder — its close() must not nuke
|
|
530
|
+
* that holder's lock. */
|
|
499
531
|
releaseLock() {
|
|
532
|
+
if (!this.ownsLock)
|
|
533
|
+
return;
|
|
500
534
|
try {
|
|
501
535
|
const lockDir = `${this.path}.ygllock`;
|
|
502
536
|
if (fs.existsSync(lockDir))
|
|
@@ -513,6 +547,24 @@ class DB {
|
|
|
513
547
|
}
|
|
514
548
|
catch { /* */ }
|
|
515
549
|
}
|
|
550
|
+
/** Static: read the holder pid of a lock file ("" when absent/unreadable). */
|
|
551
|
+
static lockHolder(file) {
|
|
552
|
+
try {
|
|
553
|
+
return fs.readFileSync(path.join(`${file}.ygllock`, "pid"), "utf8").trim();
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
return "";
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/** Static: read the holder's self-reported command line ("" when absent). */
|
|
560
|
+
static lockHolderCmd(file) {
|
|
561
|
+
try {
|
|
562
|
+
return fs.readFileSync(path.join(`${file}.ygllock`, "cmd"), "utf8").trim();
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
return "";
|
|
566
|
+
}
|
|
567
|
+
}
|
|
516
568
|
migrate() {
|
|
517
569
|
let row;
|
|
518
570
|
try {
|
|
@@ -720,6 +772,22 @@ exports.EDGE_RELS = ["prereq", "unlock", "excludes", "enables"];
|
|
|
720
772
|
exports.EDGE_MODES = ["AND", "OR", "XOR", "NOT"];
|
|
721
773
|
/** Condition kinds for non-structural gates. */
|
|
722
774
|
exports.COND_KINDS = ["flag", "resource", "level", "quest", "time", "expr"];
|
|
775
|
+
/** PID liveness probe (shared by lock acquisition, `db unlock`, and doctor).
|
|
776
|
+
* signal 0 = pure existence check (does NOT kill the process)
|
|
777
|
+
* EPERM = exists but permission-denied (Windows ACL) — still alive
|
|
778
|
+
* ESRCH = no such process — dead. NaN (garbage pid file) counts as dead. */
|
|
779
|
+
function pidAlive(pidStr) {
|
|
780
|
+
const pid = Number(pidStr);
|
|
781
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
782
|
+
return false;
|
|
783
|
+
try {
|
|
784
|
+
process.kill(pid, 0);
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
catch (e) {
|
|
788
|
+
return e.code === "EPERM"; // EPERM = alive (Windows), ESRCH = dead
|
|
789
|
+
}
|
|
790
|
+
}
|
|
723
791
|
/** Resolve the active database file. */
|
|
724
792
|
function dbPath(home) {
|
|
725
793
|
return path.join(home, "ygl-trees.db");
|
|
@@ -75,7 +75,32 @@ function run(env, action, rest, args) {
|
|
|
75
75
|
force,
|
|
76
76
|
}))
|
|
77
77
|
.then((r) => {
|
|
78
|
-
|
|
78
|
+
const row = r.row;
|
|
79
|
+
// Real-user report ①: three kb adds returned ✓, none was ever
|
|
80
|
+
// readable back. The transaction itself is sound (kb_notes +
|
|
81
|
+
// kb_fts + event commit together), so the loss was environmental
|
|
82
|
+
// (split workspace / different YGL_HOME between calls). Two in-band
|
|
83
|
+
// signals now make the landing point visible:
|
|
84
|
+
// 1. every receipt carries the workspace it wrote to
|
|
85
|
+
// 2. a verify probe re-reads the row through the SAME read path
|
|
86
|
+
// kb search/list use — a miss is printed loudly, not silently
|
|
87
|
+
const verify = store.kbGet(row.id);
|
|
88
|
+
if (ctx.json) {
|
|
89
|
+
console.log(JSON.stringify({
|
|
90
|
+
...row,
|
|
91
|
+
...(r.dup ? { duplicate: true } : {}),
|
|
92
|
+
verified: !!verify,
|
|
93
|
+
workspace: env.home,
|
|
94
|
+
}, null, 2));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
(0, fmt_1.writeResult)(ctx, r, (rw) => `note stored: ${rw.id} ${(0, text_1.clip)(title, 50)}`);
|
|
98
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` @ ${env.home}`, ctx.color));
|
|
99
|
+
if (!verify) {
|
|
100
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.yellow, ` ⚠ verify miss: id ${row.id} not readable back — the write may not have landed.`, ctx.color));
|
|
101
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.yellow, ` run: ygl-trees doctor (write_integrity check names phantom writes)`, ctx.color));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
79
104
|
return 0;
|
|
80
105
|
})
|
|
81
106
|
.catch((e) => {
|
|
@@ -23,6 +23,41 @@ function buildBrief(env) {
|
|
|
23
23
|
let project = null;
|
|
24
24
|
if (cur)
|
|
25
25
|
project = store.projectByName(cur);
|
|
26
|
+
// Report ③: selection-change awareness. tree.use / project.use events are
|
|
27
|
+
// logged by every selection flip; a recent one the CLI session didn't make
|
|
28
|
+
// itself usually means a parallel session re-pointed current_tree /
|
|
29
|
+
// current_project — exactly the 0.76 "node not found" incident class.
|
|
30
|
+
try {
|
|
31
|
+
const recent = store.db.all(`SELECT kind, entity_id, ts, payload FROM events
|
|
32
|
+
WHERE kind IN ('tree.use','project.use')
|
|
33
|
+
AND ts >= datetime('now','-10 minutes')
|
|
34
|
+
ORDER BY ts DESC LIMIT 5`);
|
|
35
|
+
if (recent.length) {
|
|
36
|
+
payload.selection_changed = recent.map((r) => ({
|
|
37
|
+
kind: r.kind,
|
|
38
|
+
name: r.entity_id,
|
|
39
|
+
session: (() => { try {
|
|
40
|
+
return JSON.parse(r.payload || "{}").session || null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
} })(),
|
|
45
|
+
ts: r.ts,
|
|
46
|
+
}));
|
|
47
|
+
const others = recent.filter((r) => {
|
|
48
|
+
try {
|
|
49
|
+
return (JSON.parse(r.payload || "{}").session || "cli") !== (process.env.YGL_SESSION || "cli");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
if (others.length) {
|
|
56
|
+
payload.next_actions.unshift(`# 并行会话切换过选择: ${others.map((o) => `${o.kind === "tree.use" ? "tree" : "project"} '${o.entity_id}'`).join(", ")} — 命令请带 -t/-p 固定上下文`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { /* events table optional in exotic setups */ }
|
|
26
61
|
if (project) {
|
|
27
62
|
const claims = store.claimList(project.id);
|
|
28
63
|
const ev = store.evidenceList(project.id);
|
|
@@ -134,6 +134,76 @@ function run(env, action, rest, args) {
|
|
|
134
134
|
else
|
|
135
135
|
(0, fmt_1.out)(ctx, file);
|
|
136
136
|
return 0;
|
|
137
|
+
case "unlock": {
|
|
138
|
+
// Real-user report ②: residual .ygllock after a crash left users with
|
|
139
|
+
// an "rmdir" instruction and no safety net. `db unlock` checks pid
|
|
140
|
+
// liveness — dead/absent holder clears, a LIVE holder is refused
|
|
141
|
+
// (that is a parallel session or webui serve, not garbage) unless --force.
|
|
142
|
+
const lockDir = `${file}.ygllock`;
|
|
143
|
+
let holder = "";
|
|
144
|
+
try {
|
|
145
|
+
holder = fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
|
|
146
|
+
}
|
|
147
|
+
catch { /* */ }
|
|
148
|
+
if (!fs.existsSync(lockDir)) {
|
|
149
|
+
if (ctx.json)
|
|
150
|
+
console.log(JSON.stringify({ lock: lockDir, holder: null, cleared: false, reason: "no lock present" }, null, 2));
|
|
151
|
+
else
|
|
152
|
+
(0, fmt_1.ok)(ctx, `no lock present: ${lockDir}`);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
if (holder && (0, db_1.pidAlive)(holder) && !args.flags.force) {
|
|
156
|
+
// Self-check: OUR OWN boot may hold this lock — that is not a
|
|
157
|
+
// parallel session, and refusing here would deadlock the command
|
|
158
|
+
// against itself. Clear it; the process exits right after anyway.
|
|
159
|
+
const self = Number(holder) === process.pid;
|
|
160
|
+
// 0.79.1: echo the holder's self-reported command line (R297 report —
|
|
161
|
+
// guessing "serve?" sent users to kill the wrong process).
|
|
162
|
+
let holderCmd = "";
|
|
163
|
+
try {
|
|
164
|
+
holderCmd = fs.readFileSync(`${lockDir}/cmd`, "utf8").trim();
|
|
165
|
+
}
|
|
166
|
+
catch { /* */ }
|
|
167
|
+
const msg = self
|
|
168
|
+
? `lock is held by THIS process (pid ${holder}) — acquired during boot; clearing.`
|
|
169
|
+
: `lock held by LIVE process (pid: ${holder}${holderCmd ? ` — ${holderCmd}` : ""}) — refusing to clear.`;
|
|
170
|
+
if (self) {
|
|
171
|
+
try {
|
|
172
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
173
|
+
if (ctx.json)
|
|
174
|
+
console.log(JSON.stringify({ lock: lockDir, holder, cleared: true, reason: "self (acquired during boot)" }, null, 2));
|
|
175
|
+
else
|
|
176
|
+
(0, fmt_1.ok)(ctx, `lock cleared: ${lockDir} (self — acquired during boot)`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
catch { /* fall through to the generic clear below */ }
|
|
180
|
+
}
|
|
181
|
+
if (ctx.json)
|
|
182
|
+
console.log(JSON.stringify({ lock: lockDir, holder, holder_cmd: holderCmd || null, cleared: false, reason: "holder alive (use --force to override)" }, null, 2));
|
|
183
|
+
else {
|
|
184
|
+
(0, fmt_1.bad)(ctx, msg);
|
|
185
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.yellow, ` that is a parallel CLI session (serve never takes the write lock) — retry after it exits;`, ctx.color));
|
|
186
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.yellow, ` the lock self-heals then. --force overrides (corruption risk if writes are in flight).`, ctx.color));
|
|
187
|
+
}
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
192
|
+
const why = !holder || !(0, db_1.pidAlive)(holder) ? `stale (pid ${holder || "?"} dead)` : "forced";
|
|
193
|
+
if (ctx.json)
|
|
194
|
+
console.log(JSON.stringify({ lock: lockDir, holder: holder || null, cleared: true, reason: why }, null, 2));
|
|
195
|
+
else
|
|
196
|
+
(0, fmt_1.ok)(ctx, `lock cleared: ${lockDir} (${why})`);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
if (ctx.json)
|
|
201
|
+
console.log(JSON.stringify({ error: `remove failed: ${e?.message || e}` }, null, 2));
|
|
202
|
+
else
|
|
203
|
+
(0, fmt_1.bad)(ctx, `remove failed: ${e?.message || e}`);
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
137
207
|
case "stats": {
|
|
138
208
|
const s = env.store.stats();
|
|
139
209
|
if (ctx.json) {
|
|
@@ -137,23 +137,44 @@ function run(env, action, rest, args) {
|
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
139
|
// ── 7. Stale lock check ──
|
|
140
|
-
//
|
|
141
|
-
//
|
|
140
|
+
// Real-user report ②: a residual lock with a DEAD pid earned an ambiguous
|
|
141
|
+
// "remove if no other process is running" warning, and a LIVE holder
|
|
142
|
+
// (parallel session / serve) was indistinguishable from garbage. Now the
|
|
143
|
+
// pid liveness probe (same one lock acquisition uses) decides:
|
|
144
|
+
// dead holder → warn (clearable via `db unlock` / doctor --fix)
|
|
145
|
+
// live holder → PASS (a live write-lock is a legitimate parallel session)
|
|
142
146
|
const dbPath = db.path;
|
|
143
147
|
const lockPath = `${dbPath}.ygllock`;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
148
|
+
{
|
|
149
|
+
// Report ②: a foreign LIVE holder is normal multi-session usage; only a
|
|
150
|
+
// DEAD holder is stale residue (db unlock / doctor --fix clear it).
|
|
151
|
+
let lockHolder = "";
|
|
152
|
+
try {
|
|
153
|
+
lockHolder = fs.readFileSync(`${lockPath}/pid`, "utf8").trim();
|
|
154
|
+
}
|
|
155
|
+
catch { /* absent/unreadable */ }
|
|
156
|
+
const mine = lockHolder === String(process.pid);
|
|
157
|
+
const foreignLock = fs.existsSync(lockPath) && !mine;
|
|
158
|
+
const holderAlive = !!lockHolder && (0, db_1.pidAlive)(lockHolder);
|
|
159
|
+
// 0.79.1: echo the holder's self-reported command line — naming the real
|
|
160
|
+
// holder beats guessing (R297 report: "serve?" guess sent users astray).
|
|
161
|
+
let holderCmd = "";
|
|
162
|
+
try {
|
|
163
|
+
holderCmd = fs.readFileSync(`${lockPath}/cmd`, "utf8").trim();
|
|
164
|
+
}
|
|
165
|
+
catch { /* pre-0.79.1 lock */ }
|
|
166
|
+
let status = "pass";
|
|
167
|
+
let detail = "no stale locks";
|
|
168
|
+
if (foreignLock && holderAlive) {
|
|
169
|
+
status = "pass";
|
|
170
|
+
detail = `lock held by live pid ${lockHolder}${holderCmd ? ` (${holderCmd.slice(0, 80)})` : " (parallel session)"} — normal in multi-session use`;
|
|
171
|
+
}
|
|
172
|
+
else if (foreignLock) {
|
|
173
|
+
status = "warn";
|
|
174
|
+
detail = `stale lock (pid ${lockHolder || "?"} dead) — ygl-trees db unlock or doctor --fix clears it`;
|
|
175
|
+
}
|
|
176
|
+
checks.push({ name: "stale_lock", status, detail });
|
|
147
177
|
}
|
|
148
|
-
catch { /* absent/unreadable */ }
|
|
149
|
-
const hasLock = require("node:fs").existsSync(lockPath) && lockHolder !== String(process.pid);
|
|
150
|
-
checks.push({
|
|
151
|
-
name: "stale_lock",
|
|
152
|
-
status: hasLock ? "warn" : "pass",
|
|
153
|
-
detail: hasLock
|
|
154
|
-
? `lock dir detected: ${lockPath} — remove if no other ygl-trees process is running`
|
|
155
|
-
: "no stale locks",
|
|
156
|
-
});
|
|
157
178
|
// ── 8. eval-family envelope sanity (0.28.0) ──
|
|
158
179
|
// A malformed envelope silently degrades: evalChoice/evalScore fall back to
|
|
159
180
|
// empty options/levels → the gate can never resolve (always pending).
|
|
@@ -208,6 +229,35 @@ function run(env, action, rest, args) {
|
|
|
208
229
|
checks.push({ name: "plugin_profile", status: "fail", detail: `profile read failed: ${e?.message || e}` });
|
|
209
230
|
}
|
|
210
231
|
}
|
|
232
|
+
// ── 10. Write integrity: recent write events whose rows are missing ──
|
|
233
|
+
// Real-user report ①: `kb add` returned ✓ but the note was never found
|
|
234
|
+
// again (search/list/promote all missed it). If a write event landed but
|
|
235
|
+
// the row did not, the write path lost data (rollback / crash between
|
|
236
|
+
// commit and receipt / a different workspace). This check replays recent
|
|
237
|
+
// write events against the base tables and names the phantom ids.
|
|
238
|
+
{
|
|
239
|
+
const PHANTOM_SPECS = [
|
|
240
|
+
{ kind: "kb.add", table: "kb_notes", label: "kb" },
|
|
241
|
+
{ kind: "source.add", table: "sources", label: "source" },
|
|
242
|
+
{ kind: "evidence.add", table: "evidence", label: "evidence" },
|
|
243
|
+
];
|
|
244
|
+
const phantoms = [];
|
|
245
|
+
for (const spec of PHANTOM_SPECS) {
|
|
246
|
+
const rows = db.all(`SELECT e.entity_id id, e.ts FROM events e
|
|
247
|
+
WHERE e.kind='${spec.kind}'
|
|
248
|
+
AND e.ts >= datetime('now', '-2 days')
|
|
249
|
+
AND NOT EXISTS (SELECT 1 FROM ${spec.table} t WHERE t.id = e.entity_id)`);
|
|
250
|
+
for (const r of rows)
|
|
251
|
+
phantoms.push(`${spec.label} ${r.id} (${String(r.ts).slice(0, 16)})`);
|
|
252
|
+
}
|
|
253
|
+
checks.push({
|
|
254
|
+
name: "write_integrity",
|
|
255
|
+
status: phantoms.length ? "fail" : "pass",
|
|
256
|
+
detail: phantoms.length
|
|
257
|
+
? `${phantoms.length} phantom write event(s) in last 48h: ${phantoms.slice(0, 3).join(", ")}${phantoms.length > 3 ? " …" : ""} — event logged but row missing (rollback or different workspace; check receipts' workspace line)`
|
|
258
|
+
: "recent write events all match stored rows",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
211
261
|
// ── Output ──
|
|
212
262
|
const failed = checks.filter((c) => c.status === "fail").length;
|
|
213
263
|
const warned = checks.filter((c) => c.status === "warn").length;
|
|
@@ -247,19 +297,24 @@ function run(env, action, rest, args) {
|
|
|
247
297
|
db.run("UPDATE meta SET value='0' WHERE key='index_dirty'");
|
|
248
298
|
fixes.push("index dirty flag cleared (run: ygl-trees node index)");
|
|
249
299
|
}
|
|
250
|
-
// stale lock → remove (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (fs.existsSync(lockDir) && holder !== String(process.pid)) {
|
|
300
|
+
// stale lock → remove ONLY if the holder pid is dead (report ②: --fix used
|
|
301
|
+
// to nuke ANY non-self lock — including a live parallel session's, which
|
|
302
|
+
// reintroduced exactly the double-writer corruption the lock prevents)
|
|
303
|
+
{
|
|
304
|
+
const { pidAlive } = require("../../../core/database/src/db");
|
|
305
|
+
const lockDir = `${env.db.path}.ygllock`;
|
|
306
|
+
let holder = "";
|
|
258
307
|
try {
|
|
259
|
-
fs.
|
|
260
|
-
fixes.push("stale lock removed");
|
|
308
|
+
holder = fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
|
|
261
309
|
}
|
|
262
310
|
catch { /* */ }
|
|
311
|
+
if (fs.existsSync(lockDir) && holder !== String(process.pid) && !pidAlive(holder)) {
|
|
312
|
+
try {
|
|
313
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
314
|
+
fixes.push("stale lock removed (holder pid dead)");
|
|
315
|
+
}
|
|
316
|
+
catch { /* */ }
|
|
317
|
+
}
|
|
263
318
|
}
|
|
264
319
|
if (fixes.length) {
|
|
265
320
|
for (const f of fixes)
|
|
@@ -997,6 +997,15 @@ function createDashboardServer(env) {
|
|
|
997
997
|
* lifecycle-managed: dispose cleans it automatically. */
|
|
998
998
|
function serve(env, args) {
|
|
999
999
|
const { ctx } = env;
|
|
1000
|
+
// 0.79.1 (R297 user report): `webui serve --help` used to fall through and
|
|
1001
|
+
// actually start a server — an accidental extra process. Short-circuit.
|
|
1002
|
+
if (args.flags.help || args.flags.h) {
|
|
1003
|
+
(0, fmt_1.out)(ctx, `usage: ygl-trees webui serve [--port N] [--host H]`);
|
|
1004
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` --port N listen port (default 8642; 0 = pick a free one)`, ctx.color));
|
|
1005
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` --host H bind address (default 127.0.0.1; 0.0.0.0 opens to LAN)`, ctx.color));
|
|
1006
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` endpoints: / React client · /classic embedded dashboard · /api/events SSE`, ctx.color));
|
|
1007
|
+
return Promise.resolve(0);
|
|
1008
|
+
}
|
|
1000
1009
|
// re-check (the server's hasWebApp is scoped to createDashboardServer)
|
|
1001
1010
|
const reactAvailable = (0, index_1.resolveDistRoot)() !== null;
|
|
1002
1011
|
return new Promise((resolve) => {
|
|
@@ -358,9 +358,31 @@ function run(env, action, rest, args) {
|
|
|
358
358
|
if (sub === "evidence") {
|
|
359
359
|
if (subAction === "add") {
|
|
360
360
|
const sourceId = rest[1];
|
|
361
|
-
|
|
361
|
+
// Report ⑤: --file reads the quote from disk — long multi-line excerpts
|
|
362
|
+
// stop fighting the shell for --quote escaping.
|
|
363
|
+
const file = String(args.flags.file || "");
|
|
364
|
+
let quote = String(args.flags.quote || "");
|
|
365
|
+
if (file) {
|
|
366
|
+
if (quote) {
|
|
367
|
+
(0, fmt_1.bad)(ctx, "--file and --quote are mutually exclusive");
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
quote = require("fs").readFileSync(file, "utf8").trim();
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
(0, fmt_1.bad)(ctx, `cannot read --file: ${e?.message || e}`);
|
|
375
|
+
return 1;
|
|
376
|
+
}
|
|
377
|
+
if (!quote) {
|
|
378
|
+
(0, fmt_1.bad)(ctx, `--file is empty: ${file}`);
|
|
379
|
+
return 2;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (!quote)
|
|
383
|
+
quote = readBody();
|
|
362
384
|
if (!sourceId || !quote) {
|
|
363
|
-
(0, fmt_1.bad)(ctx, "usage: research evidence add <sourceId> --quote \"...\"");
|
|
385
|
+
(0, fmt_1.bad)(ctx, "usage: research evidence add <sourceId> --quote \"...\" | --file <path> [--note ..] [--locator ..]");
|
|
364
386
|
return 2;
|
|
365
387
|
}
|
|
366
388
|
const note = String(args.flags.note || "");
|
|
@@ -451,7 +473,7 @@ function run(env, action, rest, args) {
|
|
|
451
473
|
}
|
|
452
474
|
if (subAction === "link") {
|
|
453
475
|
if (!rest[1] || !rest[2]) {
|
|
454
|
-
(0, fmt_1.bad)(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes]");
|
|
476
|
+
(0, fmt_1.bad)(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes] [--by-source]");
|
|
455
477
|
return 2;
|
|
456
478
|
}
|
|
457
479
|
const stanceRaw = String(args.flags.stance || "supports");
|
|
@@ -462,6 +484,39 @@ function run(env, action, rest, args) {
|
|
|
462
484
|
return 2;
|
|
463
485
|
}
|
|
464
486
|
const stance = stanceRaw;
|
|
487
|
+
// Report ④-3: --by-source sugar — hand a SOURCE id and every evidence
|
|
488
|
+
// under it links in one shot (the three-id taxonomy forced a per-evidence
|
|
489
|
+
// lookup round trip before this).
|
|
490
|
+
if (args.flags["by-source"]) {
|
|
491
|
+
const evs = store.db.all("SELECT id FROM evidence WHERE source_id=? AND project_id=? ORDER BY created", [rest[2], project.id]);
|
|
492
|
+
if (!evs.length) {
|
|
493
|
+
const srcExists = store.db.get("SELECT id FROM sources WHERE id=? AND project_id=?", [rest[2], project.id]);
|
|
494
|
+
(0, fmt_1.bad)(ctx, srcExists
|
|
495
|
+
? `source ${rest[2]} has no evidence yet — research evidence add ${rest[2]} --quote ".." first`
|
|
496
|
+
: `source not found: ${rest[2]}`);
|
|
497
|
+
return 1;
|
|
498
|
+
}
|
|
499
|
+
return (0, orm_1.getOrm)(env.db.path)
|
|
500
|
+
.then(async (ds) => {
|
|
501
|
+
const existing = new Set(store.db.all("SELECT evidence_id FROM claim_evidence WHERE claim_id=?", [rest[1]])
|
|
502
|
+
.map((x) => x.evidence_id));
|
|
503
|
+
let linked = 0, dups = 0, status = "";
|
|
504
|
+
for (const ev of evs) {
|
|
505
|
+
const r = await writes.claimLink(ds, rest[1], ev.id, stance);
|
|
506
|
+
existing.has(ev.id) ? dups++ : linked++;
|
|
507
|
+
status = r.status;
|
|
508
|
+
}
|
|
509
|
+
if (ctx.json)
|
|
510
|
+
console.log(JSON.stringify({ claim: rest[1], source: rest[2], linked, duplicates: dups, status }, null, 2));
|
|
511
|
+
else
|
|
512
|
+
(0, fmt_1.ok)(ctx, `linked ${linked} evidence from source ${rest[2]} (${dups} already) — claim status: ${status}`);
|
|
513
|
+
return 0;
|
|
514
|
+
})
|
|
515
|
+
.catch((e) => {
|
|
516
|
+
(0, fmt_1.bad)(ctx, e?.message || String(e));
|
|
517
|
+
return 1;
|
|
518
|
+
});
|
|
519
|
+
}
|
|
465
520
|
return (0, orm_1.getOrm)(env.db.path)
|
|
466
521
|
.then((ds) => writes.claimLink(ds, rest[1], rest[2], stance))
|
|
467
522
|
.then((r) => {
|
|
@@ -491,6 +546,22 @@ function run(env, action, rest, args) {
|
|
|
491
546
|
return 0;
|
|
492
547
|
})
|
|
493
548
|
.catch((e) => {
|
|
549
|
+
// Report ④-2: the three-id taxonomy (source/evidence/claim) makes
|
|
550
|
+
// "evidence not found: X" ambiguous when X is actually a SOURCE id.
|
|
551
|
+
// Detect that case and hand back the real evidence ids to use.
|
|
552
|
+
const m = /^evidence not found: (.+)$/.exec(e?.message || "");
|
|
553
|
+
if (m) {
|
|
554
|
+
const evs = store.db.all("SELECT id FROM evidence WHERE source_id=? AND project_id=? ORDER BY created LIMIT 8", [m[1], project.id]);
|
|
555
|
+
if (evs.length) {
|
|
556
|
+
(0, fmt_1.bad)(ctx, `${e.message} — that id is a SOURCE; its evidence: ${evs.map((x) => x.id).join(" ")} (or rerun with --by-source to link them all)`);
|
|
557
|
+
return 1;
|
|
558
|
+
}
|
|
559
|
+
const srcExists = store.db.get("SELECT id FROM sources WHERE id=? AND project_id=?", [m[1], project.id]);
|
|
560
|
+
if (srcExists) {
|
|
561
|
+
(0, fmt_1.bad)(ctx, `${e.message} — that id is a SOURCE with no evidence yet: research evidence add ${m[1]} --quote ".." first`);
|
|
562
|
+
return 1;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
494
565
|
(0, fmt_1.bad)(ctx, e?.message || String(e));
|
|
495
566
|
return 1;
|
|
496
567
|
});
|
|
@@ -613,29 +684,43 @@ function run(env, action, rest, args) {
|
|
|
613
684
|
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, " no [预测·…] claims in this project", ctx.color));
|
|
614
685
|
return 0;
|
|
615
686
|
}
|
|
616
|
-
const { auditPredictions } = require("./predict");
|
|
687
|
+
const { auditPredictions, loadStateSource } = require("./predict");
|
|
617
688
|
return (async () => {
|
|
618
|
-
|
|
689
|
+
// 0.79.1 --state bridge: external counter map (JSON file or URL, e.g.
|
|
690
|
+
// DSH API counts) turns state-referencing criteria auto-measurable.
|
|
691
|
+
let state = null;
|
|
692
|
+
if (args.flags.state) {
|
|
693
|
+
state = await loadStateSource(String(args.flags.state));
|
|
694
|
+
if (!state && !ctx.json) {
|
|
695
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.yellow, ` ⚠ --state ${args.flags.state} 加载失败(文件/URL 不可读或非 JSON)——相关判据回落 manual`, ctx.color));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const rows = await auditPredictions({ claims, db: store.db, npmProbe: args.flags.offline !== true, state });
|
|
699
|
+
const resolvedN = rows.filter((r) => r.verdict === "resolved").length;
|
|
700
|
+
const visible = args.flags.all ? rows : rows.filter((r) => r.verdict !== "resolved");
|
|
619
701
|
if (ctx.json) {
|
|
620
702
|
console.log(JSON.stringify(rows, null, 2));
|
|
621
703
|
}
|
|
704
|
+
else if (!visible.length) {
|
|
705
|
+
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` 全部 ${rows.length} 条预测已结案(resolved)——--all 查看终态表`, ctx.color));
|
|
706
|
+
}
|
|
622
707
|
else {
|
|
623
|
-
(0, fmt_1.head)(ctx, `prediction audit — ${project.name} (${rows.length}
|
|
624
|
-
(0, fmt_1.table)(ctx,
|
|
708
|
+
(0, fmt_1.head)(ctx, `prediction audit — ${project.name} (${visible.length}/${rows.length}${resolvedN && !args.flags.all ? ` · ${resolvedN} resolved 隐藏 (--all)` : ""})`);
|
|
709
|
+
(0, fmt_1.table)(ctx, visible, ["claim", "horizon", "deadline", "判据", "实测", "判定", "days"], [
|
|
625
710
|
"claim_id", "horizon", "deadline", "criterion", "actual", "verdict", "days_left",
|
|
626
711
|
]);
|
|
627
|
-
const actionable =
|
|
712
|
+
const actionable = visible.filter((r) => r.verdict === "hit" || r.verdict === "miss");
|
|
628
713
|
for (const r of actionable) {
|
|
629
714
|
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` → research claim resolve ${r.claim_id} --as ${r.verdict === "hit" ? "supported" : "refuted"} # ${r.deadline} 已到`, ctx.color));
|
|
630
715
|
}
|
|
631
|
-
for (const r of
|
|
716
|
+
for (const r of visible.filter((x) => x.verdict === "manual")) {
|
|
632
717
|
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` · ${r.claim_id}: ${r.hint}`, ctx.color));
|
|
633
718
|
}
|
|
634
719
|
}
|
|
635
720
|
return 0;
|
|
636
721
|
})();
|
|
637
722
|
}
|
|
638
|
-
(0, fmt_1.bad)(ctx, "usage: research predict audit [--offline]");
|
|
723
|
+
(0, fmt_1.bad)(ctx, "usage: research predict audit [--offline] [--state <file|url>] [--all]");
|
|
639
724
|
return 2;
|
|
640
725
|
}
|
|
641
726
|
(0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, " research: query add|list · source add|list|search|show|rm · evidence add|list · claim add|link|list · predict audit", ctx.color));
|