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,8 +34,23 @@ var db_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({
34
34
  EDGE_RELS: () => EDGE_RELS,
35
35
  SCHEMA_SQL: () => SCHEMA_SQL,
36
36
  SCHEMA_VERSION: () => 9,
37
- dbPath: () => dbPath
37
+ dbPath: () => dbPath,
38
+ pidAlive: () => pidAlive
38
39
  });
40
+ /** PID liveness probe (shared by lock acquisition, `db unlock`, and doctor).
41
+ * signal 0 = pure existence check (does NOT kill the process)
42
+ * EPERM = exists but permission-denied (Windows ACL) — still alive
43
+ * ESRCH = no such process — dead. NaN (garbage pid file) counts as dead. */
44
+ function pidAlive(pidStr) {
45
+ const pid = Number(pidStr);
46
+ if (!Number.isInteger(pid) || pid <= 0) return false;
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ } catch (e) {
51
+ return e.code === "EPERM";
52
+ }
53
+ }
39
54
  /** Resolve the active database file. */
40
55
  function dbPath(home) {
41
56
  return node_path.join(home, "ygl-trees.db");
@@ -392,6 +407,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_refs ON skill_refs(skill_id, path);
392
407
  DB = class {
393
408
  db;
394
409
  path;
410
+ /** True when THIS instance acquired the .ygllock (0.79.1) — lock-less
411
+ * instances (serve, db unlock) never release a foreign lock. */
412
+ ownsLock = false;
395
413
  constructor(file, opts) {
396
414
  this.path = file;
397
415
  node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
@@ -424,11 +442,23 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_refs ON skill_refs(skill_id, path);
424
442
  force: true
425
443
  });
426
444
  } catch {}
427
- else throw new Error(`database is locked by another process (pid: ${holder}).\n lock: ${lockDir}\n if this is a webui serve dashboard, it shares WAL safely — CLI reads work,\n write commands need the serve process stopped (or YGL_TEST=1 to bypass)\n if no other ygl-trees is running, remove it: rmdir "${lockDir}"`);
445
+ else {
446
+ let holderCmd = "";
447
+ try {
448
+ holderCmd = node_fs.readFileSync(node_path.join(lockDir, "cmd"), "utf8").trim();
449
+ } catch {}
450
+ throw new Error(`database is locked by another process (pid: ${holder}).\n` + (holderCmd ? ` holder command: ${holderCmd}\n` : ` the holder is almost certainly a parallel CLI session (serve never takes the write lock);\n`) + ` read commands still work. The lock self-heals once pid ${holder} exits —\n simply retry after it finishes.\n to clear manually: ygl-trees db unlock (checks pid liveness, refuses live holders)\n last resort: rmdir "${lockDir}"`);
451
+ }
428
452
  }
453
+ let acquired = false;
429
454
  if (useLock) try {
430
455
  node_fs.mkdirSync(lockDir, { recursive: true });
431
456
  node_fs.writeFileSync(node_path.join(lockDir, "pid"), String(process.pid));
457
+ acquired = true;
458
+ try {
459
+ const cmd = [process.argv0, ...process.argv.slice(1)].join(" ").slice(0, 240);
460
+ node_fs.writeFileSync(node_path.join(lockDir, "cmd"), cmd);
461
+ } catch {}
432
462
  const cleanup = () => {
433
463
  for (let i = 0; i < 3; i++) try {
434
464
  node_fs.rmSync(lockDir, {
@@ -441,14 +471,19 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_refs ON skill_refs(skill_id, path);
441
471
  process.on("beforeExit", cleanup);
442
472
  process.on("exit", cleanup);
443
473
  } catch {}
474
+ this.ownsLock = acquired;
444
475
  this.db = new node_sqlite3_wasm.Database(file);
445
476
  this.db.exec("PRAGMA journal_mode=WAL");
446
477
  this.db.exec("PRAGMA synchronous=NORMAL");
447
478
  this.db.exec("PRAGMA foreign_keys=ON");
448
479
  this.migrate();
449
480
  }
450
- /** Release the write lock (called by close() and process exit). */
481
+ /** Release the write lock (called by close() and process exit).
482
+ * Ownership-guarded since 0.79.1: a lock-less instance (serve, db unlock)
483
+ * shares the workspace with a foreign holder — its close() must not nuke
484
+ * that holder's lock. */
451
485
  releaseLock() {
486
+ if (!this.ownsLock) return;
452
487
  try {
453
488
  const lockDir = `${this.path}.ygllock`;
454
489
  if (node_fs.existsSync(lockDir)) node_fs.rmSync(lockDir, {
@@ -467,6 +502,22 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_refs ON skill_refs(skill_id, path);
467
502
  });
468
503
  } catch {}
469
504
  }
505
+ /** Static: read the holder pid of a lock file ("" when absent/unreadable). */
506
+ static lockHolder(file) {
507
+ try {
508
+ return node_fs.readFileSync(node_path.join(`${file}.ygllock`, "pid"), "utf8").trim();
509
+ } catch {
510
+ return "";
511
+ }
512
+ }
513
+ /** Static: read the holder's self-reported command line ("" when absent). */
514
+ static lockHolderCmd(file) {
515
+ try {
516
+ return node_fs.readFileSync(node_path.join(`${file}.ygllock`, "cmd"), "utf8").trim();
517
+ } catch {
518
+ return "";
519
+ }
520
+ }
470
521
  migrate() {
471
522
  let row;
472
523
  try {
@@ -1927,6 +1978,18 @@ var TreeStore = class {
1927
1978
  keyOrId
1928
1979
  ]);
1929
1980
  }
1981
+ /** Which OTHER trees contain this key? (Report ③: a parallel session
1982
+ * flipped current_tree; `node can p_dates` then said just "node not
1983
+ * found" — the key existed the whole time, in another tree. This lookup
1984
+ * lets the error name the tree instead of forcing a blind node list.) */
1985
+ nodeInOtherTrees(keyOrId, excludeTreeId) {
1986
+ return this.db.all(`SELECT t.name AS tree, n.key FROM nodes n JOIN trees t ON t.id=n.tree_id
1987
+ WHERE n.tree_id<>? AND (n.key=? OR n.id=?) LIMIT 5`, [
1988
+ excludeTreeId,
1989
+ keyOrId,
1990
+ keyOrId
1991
+ ]);
1992
+ }
1930
1993
  /** Runtime state for one node (default context). Separate from nodeGet on
1931
1994
  * purpose: nodeGet feeds WRITE paths, and a context join there could return
1932
1995
  * one row per context — silently wrong for link/unlink/rm. */
@@ -4684,6 +4747,77 @@ function run$12(env, action, rest, args) {
4684
4747
  if (ctx.json) console.log(JSON.stringify({ path: file }, null, 2));
4685
4748
  else out(ctx, file);
4686
4749
  return 0;
4750
+ case "unlock": {
4751
+ const lockDir = `${file}.ygllock`;
4752
+ let holder = "";
4753
+ try {
4754
+ holder = node_fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
4755
+ } catch {}
4756
+ if (!node_fs.existsSync(lockDir)) {
4757
+ if (ctx.json) console.log(JSON.stringify({
4758
+ lock: lockDir,
4759
+ holder: null,
4760
+ cleared: false,
4761
+ reason: "no lock present"
4762
+ }, null, 2));
4763
+ else ok(ctx, `no lock present: ${lockDir}`);
4764
+ return 0;
4765
+ }
4766
+ if (holder && pidAlive(holder) && !args.flags.force) {
4767
+ const self = Number(holder) === process.pid;
4768
+ let holderCmd = "";
4769
+ try {
4770
+ holderCmd = node_fs.readFileSync(`${lockDir}/cmd`, "utf8").trim();
4771
+ } catch {}
4772
+ const msg = self ? `lock is held by THIS process (pid ${holder}) — acquired during boot; clearing.` : `lock held by LIVE process (pid: ${holder}${holderCmd ? ` — ${holderCmd}` : ""}) — refusing to clear.`;
4773
+ if (self) try {
4774
+ node_fs.rmSync(lockDir, {
4775
+ recursive: true,
4776
+ force: true
4777
+ });
4778
+ if (ctx.json) console.log(JSON.stringify({
4779
+ lock: lockDir,
4780
+ holder,
4781
+ cleared: true,
4782
+ reason: "self (acquired during boot)"
4783
+ }, null, 2));
4784
+ else ok(ctx, `lock cleared: ${lockDir} (self — acquired during boot)`);
4785
+ return 0;
4786
+ } catch {}
4787
+ if (ctx.json) console.log(JSON.stringify({
4788
+ lock: lockDir,
4789
+ holder,
4790
+ holder_cmd: holderCmd || null,
4791
+ cleared: false,
4792
+ reason: "holder alive (use --force to override)"
4793
+ }, null, 2));
4794
+ else {
4795
+ bad(ctx, msg);
4796
+ out(ctx, paint(C.yellow, ` that is a parallel CLI session (serve never takes the write lock) — retry after it exits;`, ctx.color));
4797
+ out(ctx, paint(C.yellow, ` the lock self-heals then. --force overrides (corruption risk if writes are in flight).`, ctx.color));
4798
+ }
4799
+ return 1;
4800
+ }
4801
+ try {
4802
+ node_fs.rmSync(lockDir, {
4803
+ recursive: true,
4804
+ force: true
4805
+ });
4806
+ const why = !holder || !pidAlive(holder) ? `stale (pid ${holder || "?"} dead)` : "forced";
4807
+ if (ctx.json) console.log(JSON.stringify({
4808
+ lock: lockDir,
4809
+ holder: holder || null,
4810
+ cleared: true,
4811
+ reason: why
4812
+ }, null, 2));
4813
+ else ok(ctx, `lock cleared: ${lockDir} (${why})`);
4814
+ return 0;
4815
+ } catch (e) {
4816
+ if (ctx.json) console.log(JSON.stringify({ error: `remove failed: ${e?.message || e}` }, null, 2));
4817
+ else bad(ctx, `remove failed: ${e?.message || e}`);
4818
+ return 1;
4819
+ }
4820
+ }
4687
4821
  case "stats": {
4688
4822
  const s = env.store.stats();
4689
4823
  if (ctx.json) {
@@ -5635,6 +5769,34 @@ function buildBrief(env) {
5635
5769
  };
5636
5770
  let project = null;
5637
5771
  if (cur) project = store.projectByName(cur);
5772
+ try {
5773
+ const recent = store.db.all(`SELECT kind, entity_id, ts, payload FROM events
5774
+ WHERE kind IN ('tree.use','project.use')
5775
+ AND ts >= datetime('now','-10 minutes')
5776
+ ORDER BY ts DESC LIMIT 5`);
5777
+ if (recent.length) {
5778
+ payload.selection_changed = recent.map((r) => ({
5779
+ kind: r.kind,
5780
+ name: r.entity_id,
5781
+ session: (() => {
5782
+ try {
5783
+ return JSON.parse(r.payload || "{}").session || null;
5784
+ } catch {
5785
+ return null;
5786
+ }
5787
+ })(),
5788
+ ts: r.ts
5789
+ }));
5790
+ const others = recent.filter((r) => {
5791
+ try {
5792
+ return (JSON.parse(r.payload || "{}").session || "cli") !== (process.env.YGL_SESSION || "cli");
5793
+ } catch {
5794
+ return false;
5795
+ }
5796
+ });
5797
+ if (others.length) payload.next_actions.unshift(`# 并行会话切换过选择: ${others.map((o) => `${o.kind === "tree.use" ? "tree" : "project"} '${o.entity_id}'`).join(", ")} — 命令请带 -t/-p 固定上下文`);
5798
+ }
5799
+ } catch {}
5638
5800
  if (project) {
5639
5801
  const claims = store.claimList(project.id);
5640
5802
  const ev = store.evidenceList(project.id);
@@ -6143,6 +6305,181 @@ var init_eval_enhance = require_rolldown_runtime.__esmMin((() => {
6143
6305
  init_eval();
6144
6306
  }));
6145
6307
  //#endregion
6308
+ //#region packages/research/pipeline/src/predict.ts
6309
+ var predict_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({
6310
+ auditPredictions: () => auditPredictions,
6311
+ loadStateSource: () => loadStateSource,
6312
+ measureCriterion: () => measureCriterion,
6313
+ measureNpmVersions: () => measureNpmVersions,
6314
+ parsePrediction: () => parsePrediction
6315
+ });
6316
+ /** Extract `[预测·X]` horizon + deadline. Explicit `YYYY-MM-DD 前` wins;
6317
+ * otherwise derive from claim creation date + horizon days (7天/90天). */
6318
+ function parsePrediction(text, created) {
6319
+ const h = text.match(/\[预测·([^\]]+)\]/);
6320
+ const horizon = h ? h[1] : "";
6321
+ const d = text.match(/(\d{4}-\d{2}-\d{2})\s*前/);
6322
+ if (d) return {
6323
+ horizon,
6324
+ deadline: d[1]
6325
+ };
6326
+ const days = horizon.match(/(\d+)\s*天/);
6327
+ if (days && created) {
6328
+ const t = Date.parse(created.replace(" ", "T"));
6329
+ if (!Number.isNaN(t)) return {
6330
+ horizon,
6331
+ deadline: new Date(t + +days[1] * 864e5).toISOString().slice(0, 10)
6332
+ };
6333
+ }
6334
+ return {
6335
+ horizon,
6336
+ deadline: ""
6337
+ };
6338
+ }
6339
+ /** 0.79.1 --state bridge: load a flat counter map from a JSON file path or an
6340
+ * http(s) URL (e.g. a DSH API endpoint). Nested objects flatten to dot keys
6341
+ * ({counts:{y:12}} → "counts.y"); only finite numbers are kept. Null on any
6342
+ * failure — the audit degrades those criteria back to manual. */
6343
+ async function loadStateSource(src, fetchImpl = fetch) {
6344
+ try {
6345
+ let text;
6346
+ if (/^https?:\/\//.test(src)) {
6347
+ const ctl = new AbortController();
6348
+ const t = setTimeout(() => ctl.abort(), 3e3);
6349
+ const r = await fetchImpl(src, { signal: ctl.signal });
6350
+ clearTimeout(t);
6351
+ if (!r.ok) return null;
6352
+ text = await r.text();
6353
+ } else text = require("node:fs").readFileSync(src, "utf8");
6354
+ const flat = {};
6355
+ const walk = (obj, prefix) => {
6356
+ if (!obj || typeof obj !== "object") return;
6357
+ for (const [k, v] of Object.entries(obj)) {
6358
+ const key = prefix ? `${prefix}.${k}` : k;
6359
+ if (typeof v === "number" && Number.isFinite(v)) flat[key] = v;
6360
+ else if (v && typeof v === "object" && !Array.isArray(v)) walk(v, key);
6361
+ }
6362
+ };
6363
+ walk(JSON.parse(text), "");
6364
+ return flat;
6365
+ } catch {
6366
+ return null;
6367
+ }
6368
+ }
6369
+ /** Locally measurable criterion matchers. Null → manual.
6370
+ * Precedence: 树/kb workspace counts (local truth) → any `key≥N` whose key
6371
+ * exists in the optional --state counter map (external truth, 0.79.1). */
6372
+ function measureCriterion(crit, db, state) {
6373
+ const parts = [];
6374
+ const hits = [];
6375
+ const tree = crit.match(/树\s*≥\s*(\d+)/);
6376
+ if (tree) {
6377
+ const n = db.get("SELECT COUNT(*) c FROM trees").c;
6378
+ parts.push(`trees=${n}/${tree[1]}`);
6379
+ hits.push(n >= +tree[1]);
6380
+ }
6381
+ const kb = crit.match(/kb\s*≥\s*(\d+)/);
6382
+ if (kb) {
6383
+ const n = db.get("SELECT COUNT(*) c FROM kb_notes").c;
6384
+ parts.push(`kb=${n}/${kb[1]}`);
6385
+ hits.push(n >= +kb[1]);
6386
+ }
6387
+ if (state) {
6388
+ const seen = /* @__PURE__ */ new Set();
6389
+ for (const m of crit.matchAll(/([\w\u4e00-\u9fa5][\w\u4e00-\u9fa5.-]*)\s*≥\s*(\d+)/g)) {
6390
+ const key = m[1], need = +m[2];
6391
+ if (key === "树" || key === "kb" || seen.has(key)) continue;
6392
+ let stateKey = key in state ? key : null;
6393
+ if (!stateKey) {
6394
+ const cands = Object.keys(state).filter((sk) => sk.endsWith("." + key) && Number.isFinite(state[sk]));
6395
+ if (cands.length) {
6396
+ cands.sort((a, b) => a.split(".").length - b.split(".").length || a.localeCompare(b));
6397
+ stateKey = cands[0];
6398
+ }
6399
+ }
6400
+ if (!stateKey || !Number.isFinite(state[stateKey])) continue;
6401
+ seen.add(key);
6402
+ parts.push(`${key}=${state[stateKey]}/${need}${stateKey !== key ? ` (${stateKey})` : ""}`);
6403
+ hits.push(state[stateKey] >= need);
6404
+ }
6405
+ }
6406
+ if (!parts.length) return null;
6407
+ const hit = crit.includes(" 或 ") ? hits.some(Boolean) : hits.every(Boolean);
6408
+ return {
6409
+ actual: parts.join(" · "),
6410
+ hit
6411
+ };
6412
+ }
6413
+ /** npm version count since a date — registry probe with a hard 3s timeout,
6414
+ * offline-safe fallback to manual. */
6415
+ async function measureNpmVersions(pkg, since, fetchImpl) {
6416
+ try {
6417
+ const ctl = new AbortController();
6418
+ const t = setTimeout(() => ctl.abort(), 3e3);
6419
+ const r = await fetchImpl(`https://registry.npmjs.org/${pkg}`, { signal: ctl.signal });
6420
+ clearTimeout(t);
6421
+ if (!r.ok) return null;
6422
+ const times = (await r.json()).time || {};
6423
+ const versions = Object.entries(times).filter(([v, ts]) => v !== "created" && v !== "modified" && String(ts) >= since).map(([v]) => v);
6424
+ return {
6425
+ actual: `npm versions since ${since}: ${versions.length} (${versions.slice(-3).join(",")})`,
6426
+ hit: versions.length > 0
6427
+ };
6428
+ } catch {
6429
+ return null;
6430
+ }
6431
+ }
6432
+ async function auditPredictions(inp) {
6433
+ const today = inp.today || (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6434
+ const rows = [];
6435
+ for (const c of inp.claims) {
6436
+ if (!c.text.startsWith("[预测·")) continue;
6437
+ const { horizon, deadline } = parsePrediction(c.text, c.created);
6438
+ const m = c.text.match(/\d{4}-\d{2}-\d{2}\s*前\s*(.+?)(?:——|$)/) || c.text.match(/\]\s*(.+?)(?:——|$)/);
6439
+ const criterion = m ? m[1].trim() : "";
6440
+ const daysLeft = deadline ? Math.round((Date.parse(deadline) - Date.parse(today)) / 864e5) : null;
6441
+ const resolved = c.status === "supported" || c.status === "refuted";
6442
+ let measure = measureCriterion(criterion, inp.db, inp.state);
6443
+ if (!measure && /npm 再发\s*≥\s*(\d+)\s*个版本/.test(criterion)) {
6444
+ const need = +criterion.match(/npm 再发\s*≥\s*(\d+)/)[1];
6445
+ const base = c.text.match(/基线\s*\S*?@?(\d{4}-\d{2}-\d{2})/);
6446
+ const since = base ? base[1] : (c.created || today).slice(0, 10);
6447
+ if (inp.npmProbe !== false) {
6448
+ const v = await measureNpmVersions("ygl-trees", since, inp.fetchImpl || fetch);
6449
+ if (v) {
6450
+ const n = +(v.actual.match(/:\s*(\d+)/) || [0, 0])[1];
6451
+ measure = {
6452
+ actual: `${v.actual} · need ≥${need}`,
6453
+ hit: n >= need
6454
+ };
6455
+ }
6456
+ }
6457
+ }
6458
+ let verdict;
6459
+ let hint;
6460
+ if (resolved) {
6461
+ verdict = "resolved";
6462
+ hint = `已人工仲裁:${c.status}(回测终态,不再复判)`;
6463
+ } else if (!measure) {
6464
+ verdict = "manual";
6465
+ hint = "外部世界判据——人工核验后 claim resolve";
6466
+ } else if (daysLeft !== null && daysLeft > 0) verdict = "pending";
6467
+ else verdict = measure.hit ? "hit" : "miss";
6468
+ rows.push({
6469
+ claim_id: c.id,
6470
+ horizon,
6471
+ deadline,
6472
+ criterion,
6473
+ actual: measure ? measure.actual + (!resolved && verdict === "pending" && measure.hit ? " · 已达标" : "") : "—",
6474
+ verdict,
6475
+ days_left: daysLeft,
6476
+ hint
6477
+ });
6478
+ }
6479
+ return rows;
6480
+ }
6481
+ require_rolldown_runtime.__esmMin((() => {}));
6482
+ //#endregion
6146
6483
  //#region packages/research/pipeline/src/cmds.ts
6147
6484
  /** research group — collect → screen → extract → claim.
6148
6485
  * `source fetch` is async (Node 18+ global fetch) and returns a promise.
@@ -6453,9 +6790,27 @@ function run$8(env, action, rest, args) {
6453
6790
  if (sub === "evidence") {
6454
6791
  if (subAction === "add") {
6455
6792
  const sourceId = rest[1];
6456
- const quote = String(args.flags.quote || readBody());
6793
+ const file = String(args.flags.file || "");
6794
+ let quote = String(args.flags.quote || "");
6795
+ if (file) {
6796
+ if (quote) {
6797
+ bad(ctx, "--file and --quote are mutually exclusive");
6798
+ return 2;
6799
+ }
6800
+ try {
6801
+ quote = require("fs").readFileSync(file, "utf8").trim();
6802
+ } catch (e) {
6803
+ bad(ctx, `cannot read --file: ${e?.message || e}`);
6804
+ return 1;
6805
+ }
6806
+ if (!quote) {
6807
+ bad(ctx, `--file is empty: ${file}`);
6808
+ return 2;
6809
+ }
6810
+ }
6811
+ if (!quote) quote = readBody();
6457
6812
  if (!sourceId || !quote) {
6458
- bad(ctx, "usage: research evidence add <sourceId> --quote \"...\"");
6813
+ bad(ctx, "usage: research evidence add <sourceId> --quote \"...\" | --file <path> [--note ..] [--locator ..]");
6459
6814
  return 2;
6460
6815
  }
6461
6816
  const note = String(args.flags.note || "");
@@ -6555,7 +6910,7 @@ function run$8(env, action, rest, args) {
6555
6910
  }
6556
6911
  if (subAction === "link") {
6557
6912
  if (!rest[1] || !rest[2]) {
6558
- bad(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes]");
6913
+ bad(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes] [--by-source]");
6559
6914
  return 2;
6560
6915
  }
6561
6916
  const stanceRaw = String(args.flags.stance || "supports");
@@ -6564,6 +6919,34 @@ function run$8(env, action, rest, args) {
6564
6919
  return 2;
6565
6920
  }
6566
6921
  const stance = stanceRaw;
6922
+ if (args.flags["by-source"]) {
6923
+ const evs = store.db.all("SELECT id FROM evidence WHERE source_id=? AND project_id=? ORDER BY created", [rest[2], project.id]);
6924
+ if (!evs.length) {
6925
+ bad(ctx, store.db.get("SELECT id FROM sources WHERE id=? AND project_id=?", [rest[2], project.id]) ? `source ${rest[2]} has no evidence yet — research evidence add ${rest[2]} --quote ".." first` : `source not found: ${rest[2]}`);
6926
+ return 1;
6927
+ }
6928
+ return getOrm(env.db.path).then(async (ds) => {
6929
+ const existing = new Set(store.db.all("SELECT evidence_id FROM claim_evidence WHERE claim_id=?", [rest[1]]).map((x) => x.evidence_id));
6930
+ let linked = 0, dups = 0, status = "";
6931
+ for (const ev of evs) {
6932
+ const r = await claimLink(ds, rest[1], ev.id, stance);
6933
+ existing.has(ev.id) ? dups++ : linked++;
6934
+ status = r.status;
6935
+ }
6936
+ if (ctx.json) console.log(JSON.stringify({
6937
+ claim: rest[1],
6938
+ source: rest[2],
6939
+ linked,
6940
+ duplicates: dups,
6941
+ status
6942
+ }, null, 2));
6943
+ else ok(ctx, `linked ${linked} evidence from source ${rest[2]} (${dups} already) — claim status: ${status}`);
6944
+ return 0;
6945
+ }).catch((e) => {
6946
+ bad(ctx, e?.message || String(e));
6947
+ return 1;
6948
+ });
6949
+ }
6567
6950
  return getOrm(env.db.path).then((ds) => claimLink(ds, rest[1], rest[2], stance)).then((r) => {
6568
6951
  ok(ctx, `linked; claim status now: ${r.status}`);
6569
6952
  try {
@@ -6598,6 +6981,18 @@ function run$8(env, action, rest, args) {
6598
6981
  } catch {}
6599
6982
  return 0;
6600
6983
  }).catch((e) => {
6984
+ const m = /^evidence not found: (.+)$/.exec(e?.message || "");
6985
+ if (m) {
6986
+ const evs = store.db.all("SELECT id FROM evidence WHERE source_id=? AND project_id=? ORDER BY created LIMIT 8", [m[1], project.id]);
6987
+ if (evs.length) {
6988
+ 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)`);
6989
+ return 1;
6990
+ }
6991
+ if (store.db.get("SELECT id FROM sources WHERE id=? AND project_id=?", [m[1], project.id])) {
6992
+ bad(ctx, `${e.message} — that id is a SOURCE with no evidence yet: research evidence add ${m[1]} --quote ".." first`);
6993
+ return 1;
6994
+ }
6995
+ }
6601
6996
  bad(ctx, e?.message || String(e));
6602
6997
  return 1;
6603
6998
  });
@@ -6719,7 +7114,61 @@ function run$8(env, action, rest, args) {
6719
7114
  return 1;
6720
7115
  });
6721
7116
  }
6722
- out(ctx, paint(C.dim, " research: query add|list · source add|list|search|show|rm · evidence add|list · claim add|link|list", ctx.color));
7117
+ if (sub === "predict") {
7118
+ if (subAction === "audit") {
7119
+ const claims = store.db.all("SELECT id, text, status, created FROM claims WHERE project_id=? AND text LIKE '[预测·%' ORDER BY created", [project.id]);
7120
+ if (!claims.length) {
7121
+ if (ctx.json) console.log("[]");
7122
+ else out(ctx, paint(C.dim, " no [预测·…] claims in this project", ctx.color));
7123
+ return 0;
7124
+ }
7125
+ const { auditPredictions, loadStateSource } = require_rolldown_runtime.__toCommonJS(predict_exports);
7126
+ return (async () => {
7127
+ let state = null;
7128
+ if (args.flags.state) {
7129
+ state = await loadStateSource(String(args.flags.state));
7130
+ if (!state && !ctx.json) out(ctx, paint(C.yellow, ` ⚠ --state ${args.flags.state} 加载失败(文件/URL 不可读或非 JSON)——相关判据回落 manual`, ctx.color));
7131
+ }
7132
+ const rows = await auditPredictions({
7133
+ claims,
7134
+ db: store.db,
7135
+ npmProbe: args.flags.offline !== true,
7136
+ state
7137
+ });
7138
+ const resolvedN = rows.filter((r) => r.verdict === "resolved").length;
7139
+ const visible = args.flags.all ? rows : rows.filter((r) => r.verdict !== "resolved");
7140
+ if (ctx.json) console.log(JSON.stringify(rows, null, 2));
7141
+ else if (!visible.length) out(ctx, paint(C.dim, ` 全部 ${rows.length} 条预测已结案(resolved)——--all 查看终态表`, ctx.color));
7142
+ else {
7143
+ head(ctx, `prediction audit — ${project.name} (${visible.length}/${rows.length}${resolvedN && !args.flags.all ? ` · ${resolvedN} resolved 隐藏 (--all)` : ""})`);
7144
+ table(ctx, visible, [
7145
+ "claim",
7146
+ "horizon",
7147
+ "deadline",
7148
+ "判据",
7149
+ "实测",
7150
+ "判定",
7151
+ "days"
7152
+ ], [
7153
+ "claim_id",
7154
+ "horizon",
7155
+ "deadline",
7156
+ "criterion",
7157
+ "actual",
7158
+ "verdict",
7159
+ "days_left"
7160
+ ]);
7161
+ const actionable = visible.filter((r) => r.verdict === "hit" || r.verdict === "miss");
7162
+ for (const r of actionable) out(ctx, paint(C.dim, ` → research claim resolve ${r.claim_id} --as ${r.verdict === "hit" ? "supported" : "refuted"} # ${r.deadline} 已到`, ctx.color));
7163
+ for (const r of visible.filter((x) => x.verdict === "manual")) out(ctx, paint(C.dim, ` · ${r.claim_id}: ${r.hint}`, ctx.color));
7164
+ }
7165
+ return 0;
7166
+ })();
7167
+ }
7168
+ bad(ctx, "usage: research predict audit [--offline] [--state <file|url>] [--all]");
7169
+ return 2;
7170
+ }
7171
+ out(ctx, paint(C.dim, " research: query add|list · source add|list|search|show|rm · evidence add|list · claim add|link|list · predict audit", ctx.color));
6723
7172
  return 0;
6724
7173
  }
6725
7174
  //#endregion
@@ -6732,6 +7181,25 @@ init_entities$1();
6732
7181
  function run$7(env, action, rest, args) {
6733
7182
  const { ctx, store } = env;
6734
7183
  const name = rest[0];
7184
+ /** Report ③-3: selection changes are events (session-tagged) — see the
7185
+ * twin helper in techtree/model/src/cmds.ts for the rationale. */
7186
+ function logSelectionEvent(kind, projName) {
7187
+ try {
7188
+ store.db.run("INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,?)", [
7189
+ `ev${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
7190
+ (/* @__PURE__ */ new Date()).toISOString(),
7191
+ process.env.YGL_SESSION || "cli",
7192
+ kind,
7193
+ "project",
7194
+ projName,
7195
+ JSON.stringify({
7196
+ name: projName,
7197
+ session: process.env.YGL_SESSION || "cli"
7198
+ }),
7199
+ "info"
7200
+ ]);
7201
+ } catch {}
7202
+ }
6735
7203
  switch (action) {
6736
7204
  case "new": {
6737
7205
  if (!name) {
@@ -6739,8 +7207,11 @@ function run$7(env, action, rest, args) {
6739
7207
  return 2;
6740
7208
  }
6741
7209
  const question = String(args.flags.q || args.flags.question || "");
7210
+ const prior = store.db.metaGet("current_project");
7211
+ const priorHint = prior && prior !== name ? ` (prior project '${prior}' is no longer selected — project use ${prior} to switch back)` : "";
6742
7212
  return getOrm(env.db.path).then((ds) => projectCreate(ds, name, question)).then((p) => {
6743
- ack(ctx, p, p?.duplicate === true ? `project exists, selected: ${p.name} (duplicate, not re-created)` : `project created and selected: ${p.name}`);
7213
+ logSelectionEvent("project.use", p.name);
7214
+ ack(ctx, p, p?.duplicate === true ? `project exists, selected: ${p.name} (duplicate, not re-created)${priorHint}` : `project created and selected: ${p.name}${priorHint}`);
6744
7215
  return 0;
6745
7216
  }).catch((e) => {
6746
7217
  bad(ctx, e?.message || String(e));
@@ -6780,6 +7251,7 @@ function run$7(env, action, rest, args) {
6780
7251
  return 1;
6781
7252
  }
6782
7253
  store.db.metaSet("current_project", name);
7254
+ logSelectionEvent("project.use", name);
6783
7255
  ack(ctx, { project: name }, `now using: ${name}`);
6784
7256
  return 0;
6785
7257
  case "show": {
@@ -6945,6 +7417,10 @@ function run$6(env, action, rest, args) {
6945
7417
  case "spec": {
6946
7418
  const title = rest[0] || String(args.flags.title || "");
6947
7419
  const body = readBody();
7420
+ if (args.flags["design-type"] !== void 0) {
7421
+ bad(ctx, "--design-type belongs to 'produce design' (spec does not take it; if this is a design decision, use: produce design <title> --design-type decision)");
7422
+ return 2;
7423
+ }
6948
7424
  if (!title || !body) {
6949
7425
  bad(ctx, "usage: produce spec <title> --body|-f <file>");
6950
7426
  return 2;
@@ -7782,10 +8258,41 @@ function run$5(env, action, rest, args) {
7782
8258
  /** tree-domain write through the transactional service (async command). */
7783
8259
  function runTreeWrite(fn, print) {
7784
8260
  return getOrm(env.db.path).then(fn).then(print).catch((e) => {
7785
- bad(ctx, e?.message || String(e));
8261
+ bad(ctx, enrichNodeNotFound(e?.message || String(e)));
7786
8262
  return 1;
7787
8263
  });
7788
8264
  }
8265
+ /** Report ③-3: selection changes are now first-class events — a parallel
8266
+ * session flipping current_tree/current_project becomes observable
8267
+ * (agent brief surfaces it; db timeline shows the history). Session
8268
+ * identity comes from YGL_SESSION (agents that care can set it). */
8269
+ function logSelectionEvent(database, kind, name) {
8270
+ try {
8271
+ database.run("INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,?)", [
8272
+ `ev${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
8273
+ (/* @__PURE__ */ new Date()).toISOString(),
8274
+ process.env.YGL_SESSION || "cli",
8275
+ kind,
8276
+ kind === "tree.use" ? "tree" : "project",
8277
+ name,
8278
+ JSON.stringify({
8279
+ name,
8280
+ session: process.env.YGL_SESSION || "cli"
8281
+ }),
8282
+ "info"
8283
+ ]);
8284
+ } catch {}
8285
+ }
8286
+ /** Report ③ enrichment: "node not found: X" earned a cross-tree lookup —
8287
+ * if a parallel session flipped current_tree, the key usually exists in
8288
+ * another tree; name it instead of making the user blind-list. */
8289
+ function enrichNodeNotFound(msg) {
8290
+ const m = /^node not found: (.+)$/.exec(msg);
8291
+ if (!m) return msg;
8292
+ const others = trees.nodeInOtherTrees(m[1], tid);
8293
+ if (others.length) return `${msg}\n key exists in other tree(s): ${others.map((o) => `'${o.tree}'`).join(", ")} — pin it: ygl-trees ${env.group} ... -t ${others[0].tree}`;
8294
+ return `${msg} (current tree: '${tree?.name ?? "?"}' — node list to inspect, -t <tree> to switch)`;
8295
+ }
7789
8296
  if (env.group === "tree") {
7790
8297
  const sub = action;
7791
8298
  const name = rest[0];
@@ -7798,6 +8305,7 @@ function run$5(env, action, rest, args) {
7798
8305
  const proj = pid ? db.get("SELECT id FROM projects WHERE name=?", [pid]) : null;
7799
8306
  return runTreeWrite((ds) => treeCreate(ds, name, proj?.id || ""), (t) => {
7800
8307
  db.metaSet("current_tree", t.name);
8308
+ logSelectionEvent(db, "tree.use", t.name);
7801
8309
  ack(ctx, t, t?.duplicate === true ? `tree exists, selected: ${t.name} (duplicate, not re-created)` : `tree created and selected: ${t.name}`);
7802
8310
  return 0;
7803
8311
  });
@@ -7826,6 +8334,7 @@ function run$5(env, action, rest, args) {
7826
8334
  return 1;
7827
8335
  }
7828
8336
  db.metaSet("current_tree", name);
8337
+ logSelectionEvent(db, "tree.use", name);
7829
8338
  ack(ctx, { tree: name }, `now using tree: ${name}`);
7830
8339
  return 0;
7831
8340
  }
@@ -8034,43 +8543,43 @@ function run$5(env, action, rest, args) {
8034
8543
  });
8035
8544
  if (sub === "can") {
8036
8545
  if (!key) {
8037
- bad(ctx, "usage: node can <key> [--context ctx] [--state k=v] [--eval]");
8546
+ bad(ctx, "usage: node can <key...> [--context ctx] [--state k=v] [--state-file file] [--eval]");
8038
8547
  return 2;
8039
8548
  }
8040
- const state = parseState(args);
8041
- const ctxName = flagStr("context", "default");
8042
- const r = trees.canUnlock(tid, key, ctxName, state);
8043
- const evalConds = flagBool("eval") ? trees.condList(tid, key).filter((c) => /^eval(-choice|-score)?$/.test(String(c.op))) : [];
8044
- if (!evalConds.length) {
8045
- if (ctx.json) {
8046
- console.log(JSON.stringify(r, null, 2));
8047
- return 0;
8048
- }
8049
- head(ctx, `can unlock: ${r.key}`);
8050
- out(ctx, ` ${r.ok ? paint(C.green, "✓ 可解锁", ctx.color) : paint(C.red, "✗ 不可解锁", ctx.color)}`);
8051
- if (r.missing_required.length) {
8052
- out(ctx, paint(C.bold, " 必检未满足", ctx.color));
8053
- for (const m of r.missing_required) out(ctx, ` - ${m}`);
8054
- }
8055
- if (r.blocked_by.length) {
8056
- out(ctx, paint(C.bold, " 被排斥", ctx.color));
8057
- for (const b of r.blocked_by) out(ctx, ` - ${b}`);
8058
- }
8059
- if (r.missing_optional.length) {
8060
- out(ctx, paint(C.dim, " 可选未满足(不阻塞)", ctx.color));
8061
- for (const m of r.missing_optional) out(ctx, ` - ${m}`);
8062
- }
8063
- if (r.cost_multiplier !== 1) out(ctx, ` 成本系数 ${r.cost_multiplier}`);
8064
- return 0;
8549
+ const keys = [...new Set(rest.filter(Boolean))];
8550
+ const sharedState = parseState(args);
8551
+ let perKeyBags = null;
8552
+ try {
8553
+ perKeyBags = loadStateFile(args, sharedState);
8554
+ } catch (e) {
8555
+ const msg = e?.message || String(e);
8556
+ bad(ctx, msg);
8557
+ return /^usage:/.test(msg) ? 2 : /must contain a JSON object/.test(msg) ? 2 : 1;
8065
8558
  }
8559
+ const stateFor = (k) => perKeyBags ? perKeyBags[k] && typeof perKeyBags[k] === "object" && !Array.isArray(perKeyBags[k]) ? perKeyBags[k] : {} : sharedState;
8560
+ const ctxName = flagStr("context", "default");
8066
8561
  const offline = !!process.env.YGL_EVAL_OFFLINE || configGetBool(env.db, "eval.offline");
8067
8562
  const cfgToken = configGet(env.db, "decision_model_llm_token").trim();
8068
8563
  const token = offline ? "" : cfgToken || evalToken();
8069
8564
  const endpoint = configGet(env.db, "decision_model_llm_api_base").trim() || "https://ai-gateway.vercel.sh/v4/ai/evaluation-model";
8070
8565
  const passT = configGetNumber(env.db, "eval.pass_threshold");
8071
8566
  const failT = configGetNumber(env.db, "eval.fail_threshold");
8072
- const contextPkg = trees.evalContext(tid, key, state, ctxName);
8073
- return (async () => {
8567
+ /** Judge one node: canUnlock (+ eval gates when --eval). Never throws —
8568
+ * hard failures come back as {key, error}. */
8569
+ const judgeOne = async (k) => {
8570
+ let r;
8571
+ try {
8572
+ r = trees.canUnlock(tid, k, ctxName, stateFor(k));
8573
+ } catch (e) {
8574
+ return {
8575
+ key: k,
8576
+ error: enrichNodeNotFound(e?.message || String(e))
8577
+ };
8578
+ }
8579
+ const state = stateFor(k);
8580
+ const evalConds = flagBool("eval") ? trees.condList(tid, k).filter((c) => /^eval(-choice|-score)?$/.test(String(c.op))) : [];
8581
+ if (!evalConds.length) return r;
8582
+ const contextPkg = trees.evalContext(tid, k, state, ctxName);
8074
8583
  const evaluated = [];
8075
8584
  let usageIn = 0, usageOut = 0;
8076
8585
  for (const c of evalConds) {
@@ -8131,13 +8640,13 @@ function run$5(env, action, rest, args) {
8131
8640
  require_text.shortId([
8132
8641
  "eval.gate",
8133
8642
  "node",
8134
- key
8643
+ k
8135
8644
  ], 12),
8136
8645
  require_text.nowIso(),
8137
8646
  "cli",
8138
8647
  "eval.gate",
8139
8648
  "node",
8140
- key,
8649
+ k,
8141
8650
  JSON.stringify({
8142
8651
  conds: evaluated.map((e) => ({
8143
8652
  name: e.name,
@@ -8154,31 +8663,74 @@ function run$5(env, action, rest, args) {
8154
8663
  })
8155
8664
  ]);
8156
8665
  } catch {}
8666
+ return r;
8667
+ };
8668
+ const batch = keys.length > 1;
8669
+ return (async () => {
8670
+ const results = [];
8671
+ for (const k of keys) results.push(await judgeOne(k));
8157
8672
  if (ctx.json) {
8158
- console.log(JSON.stringify(r, null, 2));
8159
- return 0;
8160
- }
8161
- head(ctx, `can unlock: ${r.key}`);
8162
- out(ctx, ` ${r.ok ? paint(C.green, "✓ 可解锁", ctx.color) : pendingList.length ? paint(C.yellow, "… 待判定(决策模型 pending)", ctx.color) : paint(C.red, "✗ 不可解锁", ctx.color)}`);
8163
- if (r.missing_required.length) {
8164
- out(ctx, paint(C.bold, " 必检未满足", ctx.color));
8165
- for (const m of r.missing_required) out(ctx, ` - ${m}`);
8166
- }
8167
- if (pendingList.length) {
8168
- out(ctx, paint(C.bold, " 决策模型 pending(灰带/未配置,非终态)", ctx.color));
8169
- for (const p of pendingList) out(ctx, ` - ${p}`);
8673
+ console.log(JSON.stringify(batch ? results : results[0], null, 2));
8674
+ return results.some((r) => r.error) ? 1 : 0;
8170
8675
  }
8171
- if (r.blocked_by.length) {
8172
- out(ctx, paint(C.bold, " 被排斥", ctx.color));
8173
- for (const b of r.blocked_by) out(ctx, ` - ${b}`);
8676
+ if (!batch) {
8677
+ const r = results[0];
8678
+ if (r.error) {
8679
+ bad(ctx, r.error);
8680
+ return 1;
8681
+ }
8682
+ if (!r.evaluated) {
8683
+ head(ctx, `can unlock: ${r.key}`);
8684
+ out(ctx, ` ${r.ok ? paint(C.green, "✓ 可解锁", ctx.color) : paint(C.red, "✗ 不可解锁", ctx.color)}`);
8685
+ if (r.missing_required.length) {
8686
+ out(ctx, paint(C.bold, " 必检未满足", ctx.color));
8687
+ for (const m of r.missing_required) out(ctx, ` - ${m}`);
8688
+ }
8689
+ if (r.blocked_by.length) {
8690
+ out(ctx, paint(C.bold, " 被排斥", ctx.color));
8691
+ for (const b of r.blocked_by) out(ctx, ` - ${b}`);
8692
+ }
8693
+ if (r.missing_optional.length) {
8694
+ out(ctx, paint(C.dim, " 可选未满足(不阻塞)", ctx.color));
8695
+ for (const m of r.missing_optional) out(ctx, ` - ${m}`);
8696
+ }
8697
+ if (r.cost_multiplier !== 1) out(ctx, ` 成本系数 ${r.cost_multiplier}`);
8698
+ return 0;
8699
+ }
8700
+ head(ctx, `can unlock: ${r.key}`);
8701
+ out(ctx, ` ${r.ok ? paint(C.green, "✓ 可解锁", ctx.color) : r.pending.length ? paint(C.yellow, "… 待判定(决策模型 pending)", ctx.color) : paint(C.red, "✗ 不可解锁", ctx.color)}`);
8702
+ if (r.missing_required.length) {
8703
+ out(ctx, paint(C.bold, " 必检未满足", ctx.color));
8704
+ for (const m of r.missing_required) out(ctx, ` - ${m}`);
8705
+ }
8706
+ if (r.pending.length) {
8707
+ out(ctx, paint(C.bold, " 决策模型 pending(灰带/未配置,非终态)", ctx.color));
8708
+ for (const p of r.pending) out(ctx, ` - ${p}`);
8709
+ }
8710
+ if (r.blocked_by.length) {
8711
+ out(ctx, paint(C.bold, " 被排斥", ctx.color));
8712
+ for (const b of r.blocked_by) out(ctx, ` - ${b}`);
8713
+ }
8714
+ if (r.missing_optional.length) {
8715
+ out(ctx, paint(C.dim, " 可选未满足(不阻塞)", ctx.color));
8716
+ for (const m of r.missing_optional) out(ctx, ` - ${m}`);
8717
+ }
8718
+ 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));
8719
+ if (r.cost_multiplier !== 1) out(ctx, ` 成本系数 ${r.cost_multiplier}`);
8720
+ return 0;
8174
8721
  }
8175
- if (r.missing_optional.length) {
8176
- out(ctx, paint(C.dim, " 可选未满足(不阻塞)", ctx.color));
8177
- for (const m of r.missing_optional) out(ctx, ` - ${m}`);
8722
+ const via = perKeyBags ? "per-node --state-file" : Object.keys(sharedState).length ? "shared state" : "";
8723
+ head(ctx, `can unlock × ${results.length}${via ? ` (${via})` : ""}`);
8724
+ for (const r of results) {
8725
+ if (r.error) {
8726
+ out(ctx, ` ${paint(C.red, "✗", ctx.color)} ${r.key} ${r.error}`);
8727
+ continue;
8728
+ }
8729
+ const mark = r.ok ? paint(C.green, "✓", ctx.color) : r.pending?.length ? paint(C.yellow, "…", ctx.color) : paint(C.red, "✗", ctx.color);
8730
+ const blockers = [...(r.missing_required || []).map((m) => m), ...(r.blocked_by || []).map((b) => `excluded:${b}`)].join(", ");
8731
+ out(ctx, ` ${mark} ${r.key}${r.pending?.length ? " (eval pending)" : ""}${blockers ? ` — ${blockers}` : ""}`);
8178
8732
  }
8179
- for (const e of evaluated) out(ctx, paint(C.dim, ` eval ${e.name}: P=${e.p ?? "-"} → ${e.outcome}${e.reason ? ` (${e.reason})` : ""}`, ctx.color));
8180
- if (r.cost_multiplier !== 1) out(ctx, ` 成本系数 ${r.cost_multiplier}`);
8181
- return 0;
8733
+ return results.some((r) => r.error) ? 1 : 0;
8182
8734
  })().catch((e) => {
8183
8735
  bad(ctx, e?.message || String(e));
8184
8736
  return 1;
@@ -8186,6 +8738,16 @@ function run$5(env, action, rest, args) {
8186
8738
  }
8187
8739
  if (sub === "ready") {
8188
8740
  const state = parseState(args);
8741
+ try {
8742
+ if (loadStateFile(args, state)) {
8743
+ bad(ctx, "--state-file per-node shape (key→object) only applies to batch `node can k1 k2 …`; ready wants a flat counter object");
8744
+ return 2;
8745
+ }
8746
+ } catch (e) {
8747
+ const msg = e?.message || String(e);
8748
+ bad(ctx, msg);
8749
+ return /^usage:/.test(msg) || /must contain a JSON object/.test(msg) ? 2 : 1;
8750
+ }
8189
8751
  const ctxName = flagStr("context", "default");
8190
8752
  if (!flagBool("eval")) {
8191
8753
  const rows = trees.ready(tid, ctxName, state);
@@ -8366,7 +8928,13 @@ function run$5(env, action, rest, args) {
8366
8928
  bad(ctx, "usage: node path <key>");
8367
8929
  return 2;
8368
8930
  }
8369
- const p = trees.pathTo(tid, key, flagStr("context", "default"));
8931
+ let p;
8932
+ try {
8933
+ p = trees.pathTo(tid, key, flagStr("context", "default"));
8934
+ } catch (e) {
8935
+ bad(ctx, enrichNodeNotFound(e?.message || String(e)));
8936
+ return 1;
8937
+ }
8370
8938
  if (ctx.json) {
8371
8939
  console.log(JSON.stringify(p, null, 2));
8372
8940
  return 0;
@@ -8476,7 +9044,7 @@ function run$5(env, action, rest, args) {
8476
9044
  if (sub === "show") {
8477
9045
  const n = trees.nodeGet(tid, key);
8478
9046
  if (!n) {
8479
- bad(ctx, `node not found: ${key}`);
9047
+ bad(ctx, enrichNodeNotFound(`node not found: ${key}`));
8480
9048
  return 1;
8481
9049
  }
8482
9050
  const st = trees.nodeStateOf(tid, key);
@@ -8583,10 +9151,36 @@ function run$5(env, action, rest, args) {
8583
9151
  conditions: r.conditions
8584
9152
  }, `imported tree ${r.tree.name}: ${r.nodes} nodes, ${r.edges} edges, ${r.conditions} conditions`);
8585
9153
  db.metaSet("current_tree", r.tree.name);
9154
+ logSelectionEvent(db, "tree.use", r.tree.name);
8586
9155
  if (!ctx.json) out(ctx, paint(C.dim, ` index rebuilt; now using tree: ${r.tree.name}`, ctx.color));
8587
9156
  return 0;
8588
9157
  });
8589
9158
  }
9159
+ if (sub === "timeline") {
9160
+ const rows = db.all(`SELECT e.ts, e.kind, e.payload FROM events e
9161
+ WHERE e.entity = 'node'
9162
+ AND e.entity_id IN (SELECT id FROM nodes WHERE tree_id = ?)
9163
+ ORDER BY e.ts DESC LIMIT 30`, [tid]);
9164
+ if (ctx.json) {
9165
+ console.log(JSON.stringify(rows.map((r) => ({
9166
+ ts: r.ts,
9167
+ kind: r.kind,
9168
+ ...JSON.parse(r.payload || "{}")
9169
+ })), null, 2));
9170
+ return 0;
9171
+ }
9172
+ head(ctx, `Timeline — tree '${tree.name}' (last ${rows.length})`);
9173
+ for (const r of rows) {
9174
+ let detail = "";
9175
+ try {
9176
+ const p = JSON.parse(r.payload || "{}");
9177
+ detail = [p.key, p.name ? String(p.name).slice(0, 30) : ""].filter(Boolean).join(" ");
9178
+ } catch {}
9179
+ out(ctx, ` ${String(r.ts).slice(0, 16)} ${r.kind.padEnd(14)} ${detail}`);
9180
+ }
9181
+ if (!rows.length) out(ctx, paint(C.dim, " (no node events yet — unlock/add to start the history)", ctx.color));
9182
+ return 0;
9183
+ }
8590
9184
  if (sub === "rm") {
8591
9185
  if (!key) {
8592
9186
  bad(ctx, "usage: node rm <key>");
@@ -8613,6 +9207,30 @@ function parseState(args) {
8613
9207
  }
8614
9208
  return out;
8615
9209
  }
9210
+ /**
9211
+ * 0.79.1 (R297 user report): `--state-file <path.json>` loads the state bag
9212
+ * from JSON instead of inline `--state k=v`. Two accepted shapes:
9213
+ * flat shared {"y_imported": 12} → merged over --state, all keys share it
9214
+ * per-node map {"node-a": {"y": 1}, ...} → each judged node gets its own bag
9215
+ * (disambiguated by "every value is a plain object"). Returns the per-node map
9216
+ * when shape B, null when merged into `shared`. Throws on unreadable/invalid.
9217
+ */
9218
+ function loadStateFile(args, shared) {
9219
+ const sf = args.flags["state-file"];
9220
+ if (sf === void 0) return null;
9221
+ if (sf === true || sf === "") throw new Error("usage: --state-file <path.json>");
9222
+ let parsed;
9223
+ try {
9224
+ parsed = JSON.parse(require("node:fs").readFileSync(String(sf), "utf8"));
9225
+ } catch (e) {
9226
+ throw new Error(`cannot read --state-file ${sf}: ${e?.message || e}`);
9227
+ }
9228
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("--state-file must contain a JSON object (flat counters, or per-node key→object map)");
9229
+ const vals = Object.values(parsed);
9230
+ if (vals.length && vals.every((v) => v && typeof v === "object" && !Array.isArray(v))) return parsed;
9231
+ Object.assign(shared, parsed);
9232
+ return null;
9233
+ }
8616
9234
  //#endregion
8617
9235
  //#region packages/knowledge/skills/src/skill-cmds.ts
8618
9236
  /** skill group — the L1/L2/L3 registry agents query before acting.
@@ -8987,7 +9605,22 @@ function run$3(env, action, rest, args) {
8987
9605
  confidence: String(args.flags.confidence || configGet(env.db, "default_confidence")),
8988
9606
  force
8989
9607
  })).then((r) => {
8990
- writeResult(ctx, r, (row) => `note stored: ${row.id} ${require_text.clip(title, 50)}`);
9608
+ const row = r.row;
9609
+ const verify = store.kbGet(row.id);
9610
+ if (ctx.json) console.log(JSON.stringify({
9611
+ ...row,
9612
+ ...r.dup ? { duplicate: true } : {},
9613
+ verified: !!verify,
9614
+ workspace: env.home
9615
+ }, null, 2));
9616
+ else {
9617
+ writeResult(ctx, r, (rw) => `note stored: ${rw.id} ${require_text.clip(title, 50)}`);
9618
+ out(ctx, paint(C.dim, ` @ ${env.home}`, ctx.color));
9619
+ if (!verify) {
9620
+ out(ctx, paint(C.yellow, ` ⚠ verify miss: id ${row.id} not readable back — the write may not have landed.`, ctx.color));
9621
+ out(ctx, paint(C.yellow, ` run: ygl-trees doctor (write_integrity check names phantom writes)`, ctx.color));
9622
+ }
9623
+ }
8991
9624
  return 0;
8992
9625
  }).catch((e) => {
8993
9626
  bad(ctx, e?.message || String(e));
@@ -10511,6 +11144,13 @@ function createDashboardServer(env) {
10511
11144
  * lifecycle-managed: dispose cleans it automatically. */
10512
11145
  function serve(env, args) {
10513
11146
  const { ctx } = env;
11147
+ if (args.flags.help || args.flags.h) {
11148
+ out(ctx, `usage: ygl-trees webui serve [--port N] [--host H]`);
11149
+ out(ctx, paint(C.dim, ` --port N listen port (default 8642; 0 = pick a free one)`, ctx.color));
11150
+ out(ctx, paint(C.dim, ` --host H bind address (default 127.0.0.1; 0.0.0.0 opens to LAN)`, ctx.color));
11151
+ out(ctx, paint(C.dim, ` endpoints: / React client · /classic embedded dashboard · /api/events SSE`, ctx.color));
11152
+ return Promise.resolve(0);
11153
+ }
10514
11154
  const reactAvailable = resolveDistRoot() !== null;
10515
11155
  return new Promise((resolve) => {
10516
11156
  const port = Number(args.flags.port || 8642);
@@ -10668,16 +11308,33 @@ function run(env, action, rest, args) {
10668
11308
  detail: configRows.length === 0 ? "no overrides (all defaults)" : `${configRows.length} override(s), all valid`
10669
11309
  });
10670
11310
  const lockPath = `${db.path}.ygllock`;
10671
- let lockHolder = "";
10672
- try {
10673
- lockHolder = require("node:fs").readFileSync(`${lockPath}/pid`, "utf8").trim();
10674
- } catch {}
10675
- const hasLock = require("node:fs").existsSync(lockPath) && lockHolder !== String(process.pid);
10676
- checks.push({
10677
- name: "stale_lock",
10678
- status: hasLock ? "warn" : "pass",
10679
- detail: hasLock ? `lock dir detected: ${lockPath} — remove if no other ygl-trees process is running` : "no stale locks"
10680
- });
11311
+ {
11312
+ let lockHolder = "";
11313
+ try {
11314
+ lockHolder = node_fs.readFileSync(`${lockPath}/pid`, "utf8").trim();
11315
+ } catch {}
11316
+ const mine = lockHolder === String(process.pid);
11317
+ const foreignLock = node_fs.existsSync(lockPath) && !mine;
11318
+ const holderAlive = !!lockHolder && pidAlive(lockHolder);
11319
+ let holderCmd = "";
11320
+ try {
11321
+ holderCmd = node_fs.readFileSync(`${lockPath}/cmd`, "utf8").trim();
11322
+ } catch {}
11323
+ let status = "pass";
11324
+ let detail = "no stale locks";
11325
+ if (foreignLock && holderAlive) {
11326
+ status = "pass";
11327
+ detail = `lock held by live pid ${lockHolder}${holderCmd ? ` (${holderCmd.slice(0, 80)})` : " (parallel session)"} — normal in multi-session use`;
11328
+ } else if (foreignLock) {
11329
+ status = "warn";
11330
+ detail = `stale lock (pid ${lockHolder || "?"} dead) — ygl-trees db unlock or doctor --fix clears it`;
11331
+ }
11332
+ checks.push({
11333
+ name: "stale_lock",
11334
+ status,
11335
+ detail
11336
+ });
11337
+ }
10681
11338
  {
10682
11339
  const evalConds = db.all(`SELECT c.id, c.name, c.op, c.value, n.key AS node
10683
11340
  FROM node_conditions c JOIN nodes n ON n.id=c.node_id
@@ -10721,6 +11378,38 @@ function run(env, action, rest, args) {
10721
11378
  detail: `profile read failed: ${e?.message || e}`
10722
11379
  });
10723
11380
  }
11381
+ {
11382
+ const PHANTOM_SPECS = [
11383
+ {
11384
+ kind: "kb.add",
11385
+ table: "kb_notes",
11386
+ label: "kb"
11387
+ },
11388
+ {
11389
+ kind: "source.add",
11390
+ table: "sources",
11391
+ label: "source"
11392
+ },
11393
+ {
11394
+ kind: "evidence.add",
11395
+ table: "evidence",
11396
+ label: "evidence"
11397
+ }
11398
+ ];
11399
+ const phantoms = [];
11400
+ for (const spec of PHANTOM_SPECS) {
11401
+ const rows = db.all(`SELECT e.entity_id id, e.ts FROM events e
11402
+ WHERE e.kind='${spec.kind}'
11403
+ AND e.ts >= datetime('now', '-2 days')
11404
+ AND NOT EXISTS (SELECT 1 FROM ${spec.table} t WHERE t.id = e.entity_id)`);
11405
+ for (const r of rows) phantoms.push(`${spec.label} ${r.id} (${String(r.ts).slice(0, 16)})`);
11406
+ }
11407
+ checks.push({
11408
+ name: "write_integrity",
11409
+ status: phantoms.length ? "fail" : "pass",
11410
+ detail: phantoms.length ? `${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)` : "recent write events all match stored rows"
11411
+ });
11412
+ }
10724
11413
  const failed = checks.filter((c) => c.status === "fail").length;
10725
11414
  const warned = checks.filter((c) => c.status === "warn").length;
10726
11415
  const passed = checks.filter((c) => c.status === "pass").length;
@@ -10746,18 +11435,21 @@ function run(env, action, rest, args) {
10746
11435
  db.run("UPDATE meta SET value='0' WHERE key='index_dirty'");
10747
11436
  fixes.push("index dirty flag cleared (run: ygl-trees node index)");
10748
11437
  }
10749
- const lockDir = `${env.db.path}.ygllock`;
10750
- let holder = "";
10751
- try {
10752
- holder = node_fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
10753
- } catch {}
10754
- if (node_fs.existsSync(lockDir) && holder !== String(process.pid)) try {
10755
- node_fs.rmSync(lockDir, {
10756
- recursive: true,
10757
- force: true
10758
- });
10759
- fixes.push("stale lock removed");
10760
- } catch {}
11438
+ {
11439
+ const { pidAlive } = (init_db(), require_rolldown_runtime.__toCommonJS(db_exports));
11440
+ const lockDir = `${env.db.path}.ygllock`;
11441
+ let holder = "";
11442
+ try {
11443
+ holder = node_fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
11444
+ } catch {}
11445
+ if (node_fs.existsSync(lockDir) && holder !== String(process.pid) && !pidAlive(holder)) try {
11446
+ node_fs.rmSync(lockDir, {
11447
+ recursive: true,
11448
+ force: true
11449
+ });
11450
+ fixes.push("stale lock removed (holder pid dead)");
11451
+ } catch {}
11452
+ }
10761
11453
  if (fixes.length) for (const f of fixes) ok(ctx, ` fixed: ${f}`);
10762
11454
  else out(ctx, paint(C.dim, " (--fix: no auto-fixable issues found)", ctx.color));
10763
11455
  }
@@ -10882,10 +11574,11 @@ ${paint(C.bold, "research", true)} collect → screen → extract → claim
10882
11574
  source add --title --url [--abstract --content --relevance --confidence --query]
10883
11575
  source fetch <url> fetch page, extract title/meta/text (zero side-effect on failure)
10884
11576
  source list | search "<q>" | show <id> | rm <id>
10885
- evidence add <sourceId> --quote "..." [--note --locator]
11577
+ evidence add <sourceId> --quote "..." | --file <path> [--note --locator]
10886
11578
  evidence list
10887
11579
  claim add "<text>" [--confidence A|B|C]
10888
11580
  claim link <claimId> <evidenceId> [--stance supports|refutes]
11581
+ --by-source <claimId> <sourceId>: link ALL evidence under a source
10889
11582
  claim resolve <claimId> --as supported|refuted|contested [--note]
10890
11583
  claim list
10891
11584
 
@@ -10941,6 +11634,7 @@ ${paint(C.bold, "tree / node", true)} tech-tree node model
10941
11634
  node validate cycles, dangling edges, dirty index
10942
11635
  node export [--out file.json] portable JSON
10943
11636
  node import --file <file.json> [--tree name] [--merge] — strict, transactional
11637
+ node timeline [tree] per-tree growth history (add/link/unlock events)
10944
11638
 
10945
11639
  Writes are idempotent (claim/evidence/query/kb/job dedupe by content
10946
11640
  hash); pass --force to keep a deliberate duplicate.
@@ -10981,6 +11675,7 @@ async function main(argv) {
10981
11675
  const group = args._[0];
10982
11676
  const action = args._[1];
10983
11677
  if (group === "webui" && (action === "serve" || action === "export")) process.env.YGL_SERVE = "1";
11678
+ if (group === "db" && action === "unlock") process.env.YGL_SERVE = "1";
10984
11679
  if (args.flags.version === true || group === "version") {
10985
11680
  out(ctx, appVersion());
10986
11681
  return 0;
@@ -11100,8 +11795,13 @@ async function main(argv) {
11100
11795
  alive = err.code === "EPERM";
11101
11796
  }
11102
11797
  if (alive) {
11103
- out(ctx, ` lock held by live process pid ${holder} (serve? webui dashboard)`);
11104
- out(ctx, ` to run CLI write commands, stop that process first — or use YGL_TEST=1 to bypass`);
11798
+ let holderCmd = "";
11799
+ try {
11800
+ holderCmd = require("node:fs").readFileSync(require("node:path").join(lock, "cmd"), "utf8").trim();
11801
+ } catch {}
11802
+ out(ctx, ` lock held by live process pid ${holder}${holderCmd ? `\n holder command: ${holderCmd}` : " (parallel CLI session — serve never takes the write lock)"}`);
11803
+ out(ctx, ` reads still work; the lock serializes CLI writers and self-heals when pid ${holder} exits`);
11804
+ out(ctx, ` force through (know what you are doing): YGL_TEST=1 · inspect/clear: ygl-trees db unlock`);
11105
11805
  } else {
11106
11806
  out(ctx, ` orphaned lock (pid ${holder} is dead) — removing: ${lock}`);
11107
11807
  try {
@@ -11125,7 +11825,9 @@ async function main(argv) {
11125
11825
  db?.close();
11126
11826
  } catch {}
11127
11827
  try {
11128
- (init_db(), require_rolldown_runtime.__toCommonJS(db_exports)).DB.releaseLockFor(file);
11828
+ const { DB } = (init_db(), require_rolldown_runtime.__toCommonJS(db_exports));
11829
+ if (db?.ownsLock) DB.releaseLockFor(file);
11830
+ else if (DB.lockHolder(file) === String(process.pid)) DB.releaseLockFor(file);
11129
11831
  } catch {}
11130
11832
  });
11131
11833
  process.on("SIGINT", () => shutdown.interrupt(0));
@@ -11420,13 +12122,14 @@ function generateLlmsManifest() {
11420
12122
  ["db export <file>", "full backup"],
11421
12123
  ["db sync pg <url>", "one-way mirror to PostgreSQL"],
11422
12124
  ["db migrate pg <url> [--fresh]", "full-fidelity graduation (with type preflight)"],
12125
+ ["db unlock [--force]", "clear stale .ygllock (pid-liveness checked; refuses live holders)"],
11423
12126
  ["db reset", "delete database"]
11424
12127
  ]
11425
12128
  ],
11426
12129
  [
11427
12130
  "doctor",
11428
- "Health check (9 items)",
11429
- [["doctor [--fix]", "diagnose (+ auto-fix: dirty index, stale lock)"]]
12131
+ "Health check (10 items)",
12132
+ [["doctor [--fix]", "diagnose (+ auto-fix: dirty index, dead stale lock, phantom writes)"]]
11430
12133
  ],
11431
12134
  [
11432
12135
  "config",