ygl-trees 0.78.0 → 0.79.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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);
@@ -6146,6 +6308,7 @@ var init_eval_enhance = require_rolldown_runtime.__esmMin((() => {
6146
6308
  //#region packages/research/pipeline/src/predict.ts
6147
6309
  var predict_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({
6148
6310
  auditPredictions: () => auditPredictions,
6311
+ loadStateSource: () => loadStateSource,
6149
6312
  measureCriterion: () => measureCriterion,
6150
6313
  measureNpmVersions: () => measureNpmVersions,
6151
6314
  parsePrediction: () => parsePrediction
@@ -6173,23 +6336,74 @@ function parsePrediction(text, created) {
6173
6336
  deadline: ""
6174
6337
  };
6175
6338
  }
6176
- /** Locally measurable criterion matchers. Null → manual. */
6177
- function measureCriterion(crit, db) {
6178
- const tree = crit.match(/树\s*≥\s*(\d+)/);
6179
- const kb = crit.match(/kb\s*≥\s*(\d+)/);
6180
- if (!tree && !kb) return null;
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) {
6181
6373
  const parts = [];
6182
6374
  const hits = [];
6375
+ const tree = crit.match(/树\s*≥\s*(\d+)/);
6183
6376
  if (tree) {
6184
6377
  const n = db.get("SELECT COUNT(*) c FROM trees").c;
6185
6378
  parts.push(`trees=${n}/${tree[1]}`);
6186
6379
  hits.push(n >= +tree[1]);
6187
6380
  }
6381
+ const kb = crit.match(/kb\s*≥\s*(\d+)/);
6188
6382
  if (kb) {
6189
6383
  const n = db.get("SELECT COUNT(*) c FROM kb_notes").c;
6190
6384
  parts.push(`kb=${n}/${kb[1]}`);
6191
6385
  hits.push(n >= +kb[1]);
6192
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;
6193
6407
  const hit = crit.includes(" 或 ") ? hits.some(Boolean) : hits.every(Boolean);
6194
6408
  return {
6195
6409
  actual: parts.join(" · "),
@@ -6224,7 +6438,8 @@ async function auditPredictions(inp) {
6224
6438
  const m = c.text.match(/\d{4}-\d{2}-\d{2}\s*前\s*(.+?)(?:——|$)/) || c.text.match(/\]\s*(.+?)(?:——|$)/);
6225
6439
  const criterion = m ? m[1].trim() : "";
6226
6440
  const daysLeft = deadline ? Math.round((Date.parse(deadline) - Date.parse(today)) / 864e5) : null;
6227
- let measure = measureCriterion(criterion, inp.db);
6441
+ const resolved = c.status === "supported" || c.status === "refuted";
6442
+ let measure = measureCriterion(criterion, inp.db, inp.state);
6228
6443
  if (!measure && /npm 再发\s*≥\s*(\d+)\s*个版本/.test(criterion)) {
6229
6444
  const need = +criterion.match(/npm 再发\s*≥\s*(\d+)/)[1];
6230
6445
  const base = c.text.match(/基线\s*\S*?@?(\d{4}-\d{2}-\d{2})/);
@@ -6242,7 +6457,10 @@ async function auditPredictions(inp) {
6242
6457
  }
6243
6458
  let verdict;
6244
6459
  let hint;
6245
- if (!measure) {
6460
+ if (resolved) {
6461
+ verdict = "resolved";
6462
+ hint = `已人工仲裁:${c.status}(回测终态,不再复判)`;
6463
+ } else if (!measure) {
6246
6464
  verdict = "manual";
6247
6465
  hint = "外部世界判据——人工核验后 claim resolve";
6248
6466
  } else if (daysLeft !== null && daysLeft > 0) verdict = "pending";
@@ -6252,7 +6470,7 @@ async function auditPredictions(inp) {
6252
6470
  horizon,
6253
6471
  deadline,
6254
6472
  criterion,
6255
- actual: measure ? measure.actual + (verdict === "pending" && measure.hit ? " · 已达标" : "") : "—",
6473
+ actual: measure ? measure.actual + (!resolved && verdict === "pending" && measure.hit ? " · 已达标" : "") : "—",
6256
6474
  verdict,
6257
6475
  days_left: daysLeft,
6258
6476
  hint
@@ -6572,9 +6790,27 @@ function run$8(env, action, rest, args) {
6572
6790
  if (sub === "evidence") {
6573
6791
  if (subAction === "add") {
6574
6792
  const sourceId = rest[1];
6575
- 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();
6576
6812
  if (!sourceId || !quote) {
6577
- bad(ctx, "usage: research evidence add <sourceId> --quote \"...\"");
6813
+ bad(ctx, "usage: research evidence add <sourceId> --quote \"...\" | --file <path> [--note ..] [--locator ..]");
6578
6814
  return 2;
6579
6815
  }
6580
6816
  const note = String(args.flags.note || "");
@@ -6674,7 +6910,7 @@ function run$8(env, action, rest, args) {
6674
6910
  }
6675
6911
  if (subAction === "link") {
6676
6912
  if (!rest[1] || !rest[2]) {
6677
- bad(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes]");
6913
+ bad(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes] [--by-source]");
6678
6914
  return 2;
6679
6915
  }
6680
6916
  const stanceRaw = String(args.flags.stance || "supports");
@@ -6683,6 +6919,34 @@ function run$8(env, action, rest, args) {
6683
6919
  return 2;
6684
6920
  }
6685
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
+ }
6686
6950
  return getOrm(env.db.path).then((ds) => claimLink(ds, rest[1], rest[2], stance)).then((r) => {
6687
6951
  ok(ctx, `linked; claim status now: ${r.status}`);
6688
6952
  try {
@@ -6717,6 +6981,18 @@ function run$8(env, action, rest, args) {
6717
6981
  } catch {}
6718
6982
  return 0;
6719
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
+ }
6720
6996
  bad(ctx, e?.message || String(e));
6721
6997
  return 1;
6722
6998
  });
@@ -6846,17 +7122,26 @@ function run$8(env, action, rest, args) {
6846
7122
  else out(ctx, paint(C.dim, " no [预测·…] claims in this project", ctx.color));
6847
7123
  return 0;
6848
7124
  }
6849
- const { auditPredictions } = require_rolldown_runtime.__toCommonJS(predict_exports);
7125
+ const { auditPredictions, loadStateSource } = require_rolldown_runtime.__toCommonJS(predict_exports);
6850
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
+ }
6851
7132
  const rows = await auditPredictions({
6852
7133
  claims,
6853
7134
  db: store.db,
6854
- npmProbe: args.flags.offline !== true
7135
+ npmProbe: args.flags.offline !== true,
7136
+ state
6855
7137
  });
7138
+ const resolvedN = rows.filter((r) => r.verdict === "resolved").length;
7139
+ const visible = args.flags.all ? rows : rows.filter((r) => r.verdict !== "resolved");
6856
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));
6857
7142
  else {
6858
- head(ctx, `prediction audit — ${project.name} (${rows.length} predictions)`);
6859
- table(ctx, rows, [
7143
+ head(ctx, `prediction audit — ${project.name} (${visible.length}/${rows.length}${resolvedN && !args.flags.all ? ` · ${resolvedN} resolved 隐藏 (--all)` : ""})`);
7144
+ table(ctx, visible, [
6860
7145
  "claim",
6861
7146
  "horizon",
6862
7147
  "deadline",
@@ -6873,14 +7158,14 @@ function run$8(env, action, rest, args) {
6873
7158
  "verdict",
6874
7159
  "days_left"
6875
7160
  ]);
6876
- const actionable = rows.filter((r) => r.verdict === "hit" || r.verdict === "miss");
7161
+ const actionable = visible.filter((r) => r.verdict === "hit" || r.verdict === "miss");
6877
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));
6878
- for (const r of rows.filter((x) => x.verdict === "manual")) out(ctx, paint(C.dim, ` · ${r.claim_id}: ${r.hint}`, 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));
6879
7164
  }
6880
7165
  return 0;
6881
7166
  })();
6882
7167
  }
6883
- bad(ctx, "usage: research predict audit [--offline]");
7168
+ bad(ctx, "usage: research predict audit [--offline] [--state <file|url>] [--all]");
6884
7169
  return 2;
6885
7170
  }
6886
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));
@@ -6896,6 +7181,25 @@ init_entities$1();
6896
7181
  function run$7(env, action, rest, args) {
6897
7182
  const { ctx, store } = env;
6898
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
+ }
6899
7203
  switch (action) {
6900
7204
  case "new": {
6901
7205
  if (!name) {
@@ -6903,8 +7207,11 @@ function run$7(env, action, rest, args) {
6903
7207
  return 2;
6904
7208
  }
6905
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)` : "";
6906
7212
  return getOrm(env.db.path).then((ds) => projectCreate(ds, name, question)).then((p) => {
6907
- 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}`);
6908
7215
  return 0;
6909
7216
  }).catch((e) => {
6910
7217
  bad(ctx, e?.message || String(e));
@@ -6944,6 +7251,7 @@ function run$7(env, action, rest, args) {
6944
7251
  return 1;
6945
7252
  }
6946
7253
  store.db.metaSet("current_project", name);
7254
+ logSelectionEvent("project.use", name);
6947
7255
  ack(ctx, { project: name }, `now using: ${name}`);
6948
7256
  return 0;
6949
7257
  case "show": {
@@ -7109,6 +7417,10 @@ function run$6(env, action, rest, args) {
7109
7417
  case "spec": {
7110
7418
  const title = rest[0] || String(args.flags.title || "");
7111
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
+ }
7112
7424
  if (!title || !body) {
7113
7425
  bad(ctx, "usage: produce spec <title> --body|-f <file>");
7114
7426
  return 2;
@@ -7946,10 +8258,41 @@ function run$5(env, action, rest, args) {
7946
8258
  /** tree-domain write through the transactional service (async command). */
7947
8259
  function runTreeWrite(fn, print) {
7948
8260
  return getOrm(env.db.path).then(fn).then(print).catch((e) => {
7949
- bad(ctx, e?.message || String(e));
8261
+ bad(ctx, enrichNodeNotFound(e?.message || String(e)));
7950
8262
  return 1;
7951
8263
  });
7952
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
+ }
7953
8296
  if (env.group === "tree") {
7954
8297
  const sub = action;
7955
8298
  const name = rest[0];
@@ -7962,6 +8305,7 @@ function run$5(env, action, rest, args) {
7962
8305
  const proj = pid ? db.get("SELECT id FROM projects WHERE name=?", [pid]) : null;
7963
8306
  return runTreeWrite((ds) => treeCreate(ds, name, proj?.id || ""), (t) => {
7964
8307
  db.metaSet("current_tree", t.name);
8308
+ logSelectionEvent(db, "tree.use", t.name);
7965
8309
  ack(ctx, t, t?.duplicate === true ? `tree exists, selected: ${t.name} (duplicate, not re-created)` : `tree created and selected: ${t.name}`);
7966
8310
  return 0;
7967
8311
  });
@@ -7990,6 +8334,7 @@ function run$5(env, action, rest, args) {
7990
8334
  return 1;
7991
8335
  }
7992
8336
  db.metaSet("current_tree", name);
8337
+ logSelectionEvent(db, "tree.use", name);
7993
8338
  ack(ctx, { tree: name }, `now using tree: ${name}`);
7994
8339
  return 0;
7995
8340
  }
@@ -8198,43 +8543,43 @@ function run$5(env, action, rest, args) {
8198
8543
  });
8199
8544
  if (sub === "can") {
8200
8545
  if (!key) {
8201
- 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]");
8202
8547
  return 2;
8203
8548
  }
8204
- const state = parseState(args);
8205
- const ctxName = flagStr("context", "default");
8206
- const r = trees.canUnlock(tid, key, ctxName, state);
8207
- const evalConds = flagBool("eval") ? trees.condList(tid, key).filter((c) => /^eval(-choice|-score)?$/.test(String(c.op))) : [];
8208
- if (!evalConds.length) {
8209
- if (ctx.json) {
8210
- console.log(JSON.stringify(r, null, 2));
8211
- return 0;
8212
- }
8213
- head(ctx, `can unlock: ${r.key}`);
8214
- out(ctx, ` ${r.ok ? paint(C.green, "✓ 可解锁", ctx.color) : paint(C.red, "✗ 不可解锁", ctx.color)}`);
8215
- if (r.missing_required.length) {
8216
- out(ctx, paint(C.bold, " 必检未满足", ctx.color));
8217
- for (const m of r.missing_required) out(ctx, ` - ${m}`);
8218
- }
8219
- if (r.blocked_by.length) {
8220
- out(ctx, paint(C.bold, " 被排斥", ctx.color));
8221
- for (const b of r.blocked_by) out(ctx, ` - ${b}`);
8222
- }
8223
- if (r.missing_optional.length) {
8224
- out(ctx, paint(C.dim, " 可选未满足(不阻塞)", ctx.color));
8225
- for (const m of r.missing_optional) out(ctx, ` - ${m}`);
8226
- }
8227
- if (r.cost_multiplier !== 1) out(ctx, ` 成本系数 ${r.cost_multiplier}`);
8228
- 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;
8229
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");
8230
8561
  const offline = !!process.env.YGL_EVAL_OFFLINE || configGetBool(env.db, "eval.offline");
8231
8562
  const cfgToken = configGet(env.db, "decision_model_llm_token").trim();
8232
8563
  const token = offline ? "" : cfgToken || evalToken();
8233
8564
  const endpoint = configGet(env.db, "decision_model_llm_api_base").trim() || "https://ai-gateway.vercel.sh/v4/ai/evaluation-model";
8234
8565
  const passT = configGetNumber(env.db, "eval.pass_threshold");
8235
8566
  const failT = configGetNumber(env.db, "eval.fail_threshold");
8236
- const contextPkg = trees.evalContext(tid, key, state, ctxName);
8237
- 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);
8238
8583
  const evaluated = [];
8239
8584
  let usageIn = 0, usageOut = 0;
8240
8585
  for (const c of evalConds) {
@@ -8295,13 +8640,13 @@ function run$5(env, action, rest, args) {
8295
8640
  require_text.shortId([
8296
8641
  "eval.gate",
8297
8642
  "node",
8298
- key
8643
+ k
8299
8644
  ], 12),
8300
8645
  require_text.nowIso(),
8301
8646
  "cli",
8302
8647
  "eval.gate",
8303
8648
  "node",
8304
- key,
8649
+ k,
8305
8650
  JSON.stringify({
8306
8651
  conds: evaluated.map((e) => ({
8307
8652
  name: e.name,
@@ -8318,31 +8663,74 @@ function run$5(env, action, rest, args) {
8318
8663
  })
8319
8664
  ]);
8320
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));
8321
8672
  if (ctx.json) {
8322
- console.log(JSON.stringify(r, null, 2));
8323
- return 0;
8324
- }
8325
- head(ctx, `can unlock: ${r.key}`);
8326
- out(ctx, ` ${r.ok ? paint(C.green, "✓ 可解锁", ctx.color) : pendingList.length ? paint(C.yellow, "… 待判定(决策模型 pending)", ctx.color) : paint(C.red, "✗ 不可解锁", ctx.color)}`);
8327
- if (r.missing_required.length) {
8328
- out(ctx, paint(C.bold, " 必检未满足", ctx.color));
8329
- for (const m of r.missing_required) out(ctx, ` - ${m}`);
8330
- }
8331
- if (pendingList.length) {
8332
- out(ctx, paint(C.bold, " 决策模型 pending(灰带/未配置,非终态)", ctx.color));
8333
- 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;
8334
8675
  }
8335
- if (r.blocked_by.length) {
8336
- out(ctx, paint(C.bold, " 被排斥", ctx.color));
8337
- 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;
8338
8721
  }
8339
- if (r.missing_optional.length) {
8340
- out(ctx, paint(C.dim, " 可选未满足(不阻塞)", ctx.color));
8341
- 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}` : ""}`);
8342
8732
  }
8343
- for (const e of evaluated) out(ctx, paint(C.dim, ` eval ${e.name}: P=${e.p ?? "-"} → ${e.outcome}${e.reason ? ` (${e.reason})` : ""}`, ctx.color));
8344
- if (r.cost_multiplier !== 1) out(ctx, ` 成本系数 ${r.cost_multiplier}`);
8345
- return 0;
8733
+ return results.some((r) => r.error) ? 1 : 0;
8346
8734
  })().catch((e) => {
8347
8735
  bad(ctx, e?.message || String(e));
8348
8736
  return 1;
@@ -8350,6 +8738,16 @@ function run$5(env, action, rest, args) {
8350
8738
  }
8351
8739
  if (sub === "ready") {
8352
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
+ }
8353
8751
  const ctxName = flagStr("context", "default");
8354
8752
  if (!flagBool("eval")) {
8355
8753
  const rows = trees.ready(tid, ctxName, state);
@@ -8530,7 +8928,13 @@ function run$5(env, action, rest, args) {
8530
8928
  bad(ctx, "usage: node path <key>");
8531
8929
  return 2;
8532
8930
  }
8533
- 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
+ }
8534
8938
  if (ctx.json) {
8535
8939
  console.log(JSON.stringify(p, null, 2));
8536
8940
  return 0;
@@ -8640,7 +9044,7 @@ function run$5(env, action, rest, args) {
8640
9044
  if (sub === "show") {
8641
9045
  const n = trees.nodeGet(tid, key);
8642
9046
  if (!n) {
8643
- bad(ctx, `node not found: ${key}`);
9047
+ bad(ctx, enrichNodeNotFound(`node not found: ${key}`));
8644
9048
  return 1;
8645
9049
  }
8646
9050
  const st = trees.nodeStateOf(tid, key);
@@ -8747,10 +9151,36 @@ function run$5(env, action, rest, args) {
8747
9151
  conditions: r.conditions
8748
9152
  }, `imported tree ${r.tree.name}: ${r.nodes} nodes, ${r.edges} edges, ${r.conditions} conditions`);
8749
9153
  db.metaSet("current_tree", r.tree.name);
9154
+ logSelectionEvent(db, "tree.use", r.tree.name);
8750
9155
  if (!ctx.json) out(ctx, paint(C.dim, ` index rebuilt; now using tree: ${r.tree.name}`, ctx.color));
8751
9156
  return 0;
8752
9157
  });
8753
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
+ }
8754
9184
  if (sub === "rm") {
8755
9185
  if (!key) {
8756
9186
  bad(ctx, "usage: node rm <key>");
@@ -8777,6 +9207,30 @@ function parseState(args) {
8777
9207
  }
8778
9208
  return out;
8779
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
+ }
8780
9234
  //#endregion
8781
9235
  //#region packages/knowledge/skills/src/skill-cmds.ts
8782
9236
  /** skill group — the L1/L2/L3 registry agents query before acting.
@@ -9151,7 +9605,22 @@ function run$3(env, action, rest, args) {
9151
9605
  confidence: String(args.flags.confidence || configGet(env.db, "default_confidence")),
9152
9606
  force
9153
9607
  })).then((r) => {
9154
- 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
+ }
9155
9624
  return 0;
9156
9625
  }).catch((e) => {
9157
9626
  bad(ctx, e?.message || String(e));
@@ -10675,6 +11144,13 @@ function createDashboardServer(env) {
10675
11144
  * lifecycle-managed: dispose cleans it automatically. */
10676
11145
  function serve(env, args) {
10677
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
+ }
10678
11154
  const reactAvailable = resolveDistRoot() !== null;
10679
11155
  return new Promise((resolve) => {
10680
11156
  const port = Number(args.flags.port || 8642);
@@ -10832,16 +11308,33 @@ function run(env, action, rest, args) {
10832
11308
  detail: configRows.length === 0 ? "no overrides (all defaults)" : `${configRows.length} override(s), all valid`
10833
11309
  });
10834
11310
  const lockPath = `${db.path}.ygllock`;
10835
- let lockHolder = "";
10836
- try {
10837
- lockHolder = require("node:fs").readFileSync(`${lockPath}/pid`, "utf8").trim();
10838
- } catch {}
10839
- const hasLock = require("node:fs").existsSync(lockPath) && lockHolder !== String(process.pid);
10840
- checks.push({
10841
- name: "stale_lock",
10842
- status: hasLock ? "warn" : "pass",
10843
- detail: hasLock ? `lock dir detected: ${lockPath} — remove if no other ygl-trees process is running` : "no stale locks"
10844
- });
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
+ }
10845
11338
  {
10846
11339
  const evalConds = db.all(`SELECT c.id, c.name, c.op, c.value, n.key AS node
10847
11340
  FROM node_conditions c JOIN nodes n ON n.id=c.node_id
@@ -10885,6 +11378,38 @@ function run(env, action, rest, args) {
10885
11378
  detail: `profile read failed: ${e?.message || e}`
10886
11379
  });
10887
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
+ }
10888
11413
  const failed = checks.filter((c) => c.status === "fail").length;
10889
11414
  const warned = checks.filter((c) => c.status === "warn").length;
10890
11415
  const passed = checks.filter((c) => c.status === "pass").length;
@@ -10910,18 +11435,21 @@ function run(env, action, rest, args) {
10910
11435
  db.run("UPDATE meta SET value='0' WHERE key='index_dirty'");
10911
11436
  fixes.push("index dirty flag cleared (run: ygl-trees node index)");
10912
11437
  }
10913
- const lockDir = `${env.db.path}.ygllock`;
10914
- let holder = "";
10915
- try {
10916
- holder = node_fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
10917
- } catch {}
10918
- if (node_fs.existsSync(lockDir) && holder !== String(process.pid)) try {
10919
- node_fs.rmSync(lockDir, {
10920
- recursive: true,
10921
- force: true
10922
- });
10923
- fixes.push("stale lock removed");
10924
- } 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
+ }
10925
11453
  if (fixes.length) for (const f of fixes) ok(ctx, ` fixed: ${f}`);
10926
11454
  else out(ctx, paint(C.dim, " (--fix: no auto-fixable issues found)", ctx.color));
10927
11455
  }
@@ -11046,10 +11574,11 @@ ${paint(C.bold, "research", true)} collect → screen → extract → claim
11046
11574
  source add --title --url [--abstract --content --relevance --confidence --query]
11047
11575
  source fetch <url> fetch page, extract title/meta/text (zero side-effect on failure)
11048
11576
  source list | search "<q>" | show <id> | rm <id>
11049
- evidence add <sourceId> --quote "..." [--note --locator]
11577
+ evidence add <sourceId> --quote "..." | --file <path> [--note --locator]
11050
11578
  evidence list
11051
11579
  claim add "<text>" [--confidence A|B|C]
11052
11580
  claim link <claimId> <evidenceId> [--stance supports|refutes]
11581
+ --by-source <claimId> <sourceId>: link ALL evidence under a source
11053
11582
  claim resolve <claimId> --as supported|refuted|contested [--note]
11054
11583
  claim list
11055
11584
 
@@ -11105,6 +11634,7 @@ ${paint(C.bold, "tree / node", true)} tech-tree node model
11105
11634
  node validate cycles, dangling edges, dirty index
11106
11635
  node export [--out file.json] portable JSON
11107
11636
  node import --file <file.json> [--tree name] [--merge] — strict, transactional
11637
+ node timeline [tree] per-tree growth history (add/link/unlock events)
11108
11638
 
11109
11639
  Writes are idempotent (claim/evidence/query/kb/job dedupe by content
11110
11640
  hash); pass --force to keep a deliberate duplicate.
@@ -11145,6 +11675,7 @@ async function main(argv) {
11145
11675
  const group = args._[0];
11146
11676
  const action = args._[1];
11147
11677
  if (group === "webui" && (action === "serve" || action === "export")) process.env.YGL_SERVE = "1";
11678
+ if (group === "db" && action === "unlock") process.env.YGL_SERVE = "1";
11148
11679
  if (args.flags.version === true || group === "version") {
11149
11680
  out(ctx, appVersion());
11150
11681
  return 0;
@@ -11264,8 +11795,13 @@ async function main(argv) {
11264
11795
  alive = err.code === "EPERM";
11265
11796
  }
11266
11797
  if (alive) {
11267
- out(ctx, ` lock held by live process pid ${holder} (serve? webui dashboard)`);
11268
- 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`);
11269
11805
  } else {
11270
11806
  out(ctx, ` orphaned lock (pid ${holder} is dead) — removing: ${lock}`);
11271
11807
  try {
@@ -11289,7 +11825,9 @@ async function main(argv) {
11289
11825
  db?.close();
11290
11826
  } catch {}
11291
11827
  try {
11292
- (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);
11293
11831
  } catch {}
11294
11832
  });
11295
11833
  process.on("SIGINT", () => shutdown.interrupt(0));
@@ -11584,13 +12122,14 @@ function generateLlmsManifest() {
11584
12122
  ["db export <file>", "full backup"],
11585
12123
  ["db sync pg <url>", "one-way mirror to PostgreSQL"],
11586
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)"],
11587
12126
  ["db reset", "delete database"]
11588
12127
  ]
11589
12128
  ],
11590
12129
  [
11591
12130
  "doctor",
11592
- "Health check (9 items)",
11593
- [["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)"]]
11594
12133
  ],
11595
12134
  [
11596
12135
  "config",