ygl-trees 0.77.3 → 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.
@@ -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
- ` lock: ${lockDir}\n` +
460
- ` if this is a webui serve dashboard, it shares WAL safely — CLI reads work,\n` +
461
- ` write commands need the serve process stopped (or YGL_TEST=1 to bypass)\n` +
462
- ` if no other ygl-trees is running, remove it: rmdir "${lockDir}"`);
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
- (0, fmt_1.writeResult)(ctx, r, (row) => `note stored: ${row.id} ${(0, text_1.clip)(title, 50)}`);
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
- // The CLI process running doctor holds its own .ygllock while checking —
141
- // exclude our own live lock; warn only on foreign/stale holders.
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
- let lockHolder = "";
145
- try {
146
- lockHolder = require("node:fs").readFileSync(`${lockPath}/pid`, "utf8").trim();
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 (never our own live lock)
251
- const lockDir = `${env.db.path}.ygllock`;
252
- let holder = "";
253
- try {
254
- holder = fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
255
- }
256
- catch { /* */ }
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.rmSync(lockDir, { recursive: true, force: true });
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
- const quote = String(args.flags.quote || readBody());
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
  });
@@ -599,6 +670,59 @@ function run(env, action, rest, args) {
599
670
  });
600
671
  }
601
672
  }
602
- (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", ctx.color));
673
+ // ---------------------------------------------------------------- predict group
674
+ // pred-val/backtest-pipeline: deadline audit of `[预测·N天]` claims.
675
+ // Measures locally-measurable criteria, reports hit/miss/pending/manual —
676
+ // never auto-resolves (arbitration stays human, frozen contract).
677
+ if (sub === "predict") {
678
+ if (subAction === "audit") {
679
+ const claims = store.db.all("SELECT id, text, status, created FROM claims WHERE project_id=? AND text LIKE '[预测·%' ORDER BY created", [project.id]);
680
+ if (!claims.length) {
681
+ if (ctx.json)
682
+ console.log("[]");
683
+ else
684
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, " no [预测·…] claims in this project", ctx.color));
685
+ return 0;
686
+ }
687
+ const { auditPredictions, loadStateSource } = require("./predict");
688
+ return (async () => {
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");
701
+ if (ctx.json) {
702
+ console.log(JSON.stringify(rows, null, 2));
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
+ }
707
+ else {
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"], [
710
+ "claim_id", "horizon", "deadline", "criterion", "actual", "verdict", "days_left",
711
+ ]);
712
+ const actionable = visible.filter((r) => r.verdict === "hit" || r.verdict === "miss");
713
+ for (const r of actionable) {
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));
715
+ }
716
+ for (const r of visible.filter((x) => x.verdict === "manual")) {
717
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` · ${r.claim_id}: ${r.hint}`, ctx.color));
718
+ }
719
+ }
720
+ return 0;
721
+ })();
722
+ }
723
+ (0, fmt_1.bad)(ctx, "usage: research predict audit [--offline] [--state <file|url>] [--all]");
724
+ return 2;
725
+ }
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));
603
727
  return 0;
604
728
  }