ygl-trees 0.78.0 → 0.80.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.
@@ -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) => {
@@ -48,6 +48,10 @@ const config_1 = require("../../../ops/tools/src/config");
48
48
  const entities_1 = require("./entities");
49
49
  /** Run a write through the TypeORM transactional write service and print
50
50
  * with JSON discipline. Keeps every write command 6 lines. */
51
+ /** 0.80: compact timestamp for session:// adjudication urls (yyyymmddhhmm). */
52
+ function nowCompact() {
53
+ return new Date().toISOString().replace(/[-:T]/g, "").slice(0, 12);
54
+ }
51
55
  function runWrite(env, fn, human) {
52
56
  const { ctx } = env;
53
57
  return (0, orm_1.getOrm)(env.db.path)
@@ -177,14 +181,31 @@ function run(env, action, rest, args) {
177
181
  (0, fmt_1.bad)(ctx, 'flag needs a value: --title "..." / --url "..." (bare flag was treated as boolean)');
178
182
  return 2;
179
183
  }
180
- const title = String(titleRaw || "");
181
- const url = String(urlRaw || "");
184
+ let title = String(titleRaw || "");
185
+ let url = String(urlRaw || "");
186
+ // 0.80: in-session adjudication carrier — the most common human
187
+ // arbitration form had no native --url shape (http(s)/file only),
188
+ // forcing temp-file workarounds. `--adjudication "<裁决摘要>"`
189
+ // fills url with a session:// pseudo-scheme and doubles as the
190
+ // title when --title is absent.
191
+ const adjRaw = args.flags.adjudication;
192
+ if (adjRaw === true) {
193
+ (0, fmt_1.bad)(ctx, 'flag needs a value: --adjudication "<裁决摘要>"');
194
+ return 2;
195
+ }
196
+ if (adjRaw !== undefined && adjRaw !== "") {
197
+ const adj = String(adjRaw);
198
+ if (!url)
199
+ url = `session://adjudication/${nowCompact()}`;
200
+ if (!title)
201
+ title = adj.slice(0, 60);
202
+ }
182
203
  if (!title && !url) {
183
204
  (0, fmt_1.bad)(ctx, "need --title and/or --url");
184
205
  return 2;
185
206
  }
186
- if (url && !/^https?:\/\/|^file:\/\//.test(url)) {
187
- (0, fmt_1.bad)(ctx, `--url must be http(s):// or file:// (got: "${(0, text_1.clip)(url, 30)}")`);
207
+ if (url && !/^https?:\/\/|^file:\/\/|^session:\/\//.test(url)) {
208
+ (0, fmt_1.bad)(ctx, `--url must be http(s)://, file:// or session:// (got: "${(0, text_1.clip)(url, 30)}")`);
188
209
  return 2;
189
210
  }
190
211
  const spec = {
@@ -358,9 +379,31 @@ function run(env, action, rest, args) {
358
379
  if (sub === "evidence") {
359
380
  if (subAction === "add") {
360
381
  const sourceId = rest[1];
361
- const quote = String(args.flags.quote || readBody());
382
+ // Report ⑤: --file reads the quote from disk — long multi-line excerpts
383
+ // stop fighting the shell for --quote escaping.
384
+ const file = String(args.flags.file || "");
385
+ let quote = String(args.flags.quote || "");
386
+ if (file) {
387
+ if (quote) {
388
+ (0, fmt_1.bad)(ctx, "--file and --quote are mutually exclusive");
389
+ return 2;
390
+ }
391
+ try {
392
+ quote = require("fs").readFileSync(file, "utf8").trim();
393
+ }
394
+ catch (e) {
395
+ (0, fmt_1.bad)(ctx, `cannot read --file: ${e?.message || e}`);
396
+ return 1;
397
+ }
398
+ if (!quote) {
399
+ (0, fmt_1.bad)(ctx, `--file is empty: ${file}`);
400
+ return 2;
401
+ }
402
+ }
403
+ if (!quote)
404
+ quote = readBody();
362
405
  if (!sourceId || !quote) {
363
- (0, fmt_1.bad)(ctx, "usage: research evidence add <sourceId> --quote \"...\"");
406
+ (0, fmt_1.bad)(ctx, "usage: research evidence add <sourceId> --quote \"...\" | --file <path> [--note ..] [--locator ..]");
364
407
  return 2;
365
408
  }
366
409
  const note = String(args.flags.note || "");
@@ -451,7 +494,7 @@ function run(env, action, rest, args) {
451
494
  }
452
495
  if (subAction === "link") {
453
496
  if (!rest[1] || !rest[2]) {
454
- (0, fmt_1.bad)(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes]");
497
+ (0, fmt_1.bad)(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes] [--by-source]");
455
498
  return 2;
456
499
  }
457
500
  const stanceRaw = String(args.flags.stance || "supports");
@@ -462,6 +505,39 @@ function run(env, action, rest, args) {
462
505
  return 2;
463
506
  }
464
507
  const stance = stanceRaw;
508
+ // Report ④-3: --by-source sugar — hand a SOURCE id and every evidence
509
+ // under it links in one shot (the three-id taxonomy forced a per-evidence
510
+ // lookup round trip before this).
511
+ if (args.flags["by-source"]) {
512
+ const evs = store.db.all("SELECT id FROM evidence WHERE source_id=? AND project_id=? ORDER BY created", [rest[2], project.id]);
513
+ if (!evs.length) {
514
+ const srcExists = store.db.get("SELECT id FROM sources WHERE id=? AND project_id=?", [rest[2], project.id]);
515
+ (0, fmt_1.bad)(ctx, srcExists
516
+ ? `source ${rest[2]} has no evidence yet — research evidence add ${rest[2]} --quote ".." first`
517
+ : `source not found: ${rest[2]}`);
518
+ return 1;
519
+ }
520
+ return (0, orm_1.getOrm)(env.db.path)
521
+ .then(async (ds) => {
522
+ const existing = new Set(store.db.all("SELECT evidence_id FROM claim_evidence WHERE claim_id=?", [rest[1]])
523
+ .map((x) => x.evidence_id));
524
+ let linked = 0, dups = 0, status = "";
525
+ for (const ev of evs) {
526
+ const r = await writes.claimLink(ds, rest[1], ev.id, stance);
527
+ existing.has(ev.id) ? dups++ : linked++;
528
+ status = r.status;
529
+ }
530
+ if (ctx.json)
531
+ console.log(JSON.stringify({ claim: rest[1], source: rest[2], linked, duplicates: dups, status }, null, 2));
532
+ else
533
+ (0, fmt_1.ok)(ctx, `linked ${linked} evidence from source ${rest[2]} (${dups} already) — claim status: ${status}`);
534
+ return 0;
535
+ })
536
+ .catch((e) => {
537
+ (0, fmt_1.bad)(ctx, e?.message || String(e));
538
+ return 1;
539
+ });
540
+ }
465
541
  return (0, orm_1.getOrm)(env.db.path)
466
542
  .then((ds) => writes.claimLink(ds, rest[1], rest[2], stance))
467
543
  .then((r) => {
@@ -491,6 +567,22 @@ function run(env, action, rest, args) {
491
567
  return 0;
492
568
  })
493
569
  .catch((e) => {
570
+ // Report ④-2: the three-id taxonomy (source/evidence/claim) makes
571
+ // "evidence not found: X" ambiguous when X is actually a SOURCE id.
572
+ // Detect that case and hand back the real evidence ids to use.
573
+ const m = /^evidence not found: (.+)$/.exec(e?.message || "");
574
+ if (m) {
575
+ const evs = store.db.all("SELECT id FROM evidence WHERE source_id=? AND project_id=? ORDER BY created LIMIT 8", [m[1], project.id]);
576
+ if (evs.length) {
577
+ (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)`);
578
+ return 1;
579
+ }
580
+ const srcExists = store.db.get("SELECT id FROM sources WHERE id=? AND project_id=?", [m[1], project.id]);
581
+ if (srcExists) {
582
+ (0, fmt_1.bad)(ctx, `${e.message} — that id is a SOURCE with no evidence yet: research evidence add ${m[1]} --quote ".." first`);
583
+ return 1;
584
+ }
585
+ }
494
586
  (0, fmt_1.bad)(ctx, e?.message || String(e));
495
587
  return 1;
496
588
  });
@@ -613,29 +705,43 @@ function run(env, action, rest, args) {
613
705
  (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, " no [预测·…] claims in this project", ctx.color));
614
706
  return 0;
615
707
  }
616
- const { auditPredictions } = require("./predict");
708
+ const { auditPredictions, loadStateSource } = require("./predict");
617
709
  return (async () => {
618
- const rows = await auditPredictions({ claims, db: store.db, npmProbe: args.flags.offline !== true });
710
+ // 0.79.1 --state bridge: external counter map (JSON file or URL, e.g.
711
+ // DSH API counts) turns state-referencing criteria auto-measurable.
712
+ let state = null;
713
+ if (args.flags.state) {
714
+ state = await loadStateSource(String(args.flags.state));
715
+ if (!state && !ctx.json) {
716
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.yellow, ` ⚠ --state ${args.flags.state} 加载失败(文件/URL 不可读或非 JSON)——相关判据回落 manual`, ctx.color));
717
+ }
718
+ }
719
+ const rows = await auditPredictions({ claims, db: store.db, npmProbe: args.flags.offline !== true, state });
720
+ const resolvedN = rows.filter((r) => r.verdict === "resolved").length;
721
+ const visible = args.flags.all ? rows : rows.filter((r) => r.verdict !== "resolved");
619
722
  if (ctx.json) {
620
723
  console.log(JSON.stringify(rows, null, 2));
621
724
  }
725
+ else if (!visible.length) {
726
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` 全部 ${rows.length} 条预测已结案(resolved)——--all 查看终态表`, ctx.color));
727
+ }
622
728
  else {
623
- (0, fmt_1.head)(ctx, `prediction audit — ${project.name} (${rows.length} predictions)`);
624
- (0, fmt_1.table)(ctx, rows, ["claim", "horizon", "deadline", "判据", "实测", "判定", "days"], [
729
+ (0, fmt_1.head)(ctx, `prediction audit — ${project.name} (${visible.length}/${rows.length}${resolvedN && !args.flags.all ? ` · ${resolvedN} resolved 隐藏 (--all)` : ""})`);
730
+ (0, fmt_1.table)(ctx, visible, ["claim", "horizon", "deadline", "判据", "实测", "判定", "days"], [
625
731
  "claim_id", "horizon", "deadline", "criterion", "actual", "verdict", "days_left",
626
732
  ]);
627
- const actionable = rows.filter((r) => r.verdict === "hit" || r.verdict === "miss");
733
+ const actionable = visible.filter((r) => r.verdict === "hit" || r.verdict === "miss");
628
734
  for (const r of actionable) {
629
735
  (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
736
  }
631
- for (const r of rows.filter((x) => x.verdict === "manual")) {
737
+ for (const r of visible.filter((x) => x.verdict === "manual")) {
632
738
  (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` · ${r.claim_id}: ${r.hint}`, ctx.color));
633
739
  }
634
740
  }
635
741
  return 0;
636
742
  })();
637
743
  }
638
- (0, fmt_1.bad)(ctx, "usage: research predict audit [--offline]");
744
+ (0, fmt_1.bad)(ctx, "usage: research predict audit [--offline] [--state <file|url>] [--all]");
639
745
  return 2;
640
746
  }
641
747
  (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));