ygl-trees 0.78.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,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. */
@@ -2250,6 +2313,12 @@ var TreeStore = class {
2250
2313
  * + unlocked set + the caller's state bag. The eval API demands a context
2251
2314
  * package — bare state fell into the gray band 6/8 in the material's
2252
2315
  * routing test, so we ship everything the tree knows about this node.
2316
+ *
2317
+ * 0.80: the rel fabric joins the package — evidence/claim/kb summaries
2318
+ * attached to the node (either direction) land in `attachments`, so a
2319
+ * decided-by chain is visible to the eval model without manually
2320
+ * re-carrying facts via --state (user finding: P 0.30 → 0.92 gap).
2321
+ * Bounded: ≤12 rows, text clipped to 120 chars.
2253
2322
  */
2254
2323
  evalContext(treeId, keyOrId, stateBag, context = "default") {
2255
2324
  const n = this.nodeGet(treeId, keyOrId);
@@ -2273,7 +2342,7 @@ var TreeStore = class {
2273
2342
  effect: c.effect
2274
2343
  }));
2275
2344
  const unlocked = [...this.unlockedSet(treeId, context)].map((id) => idToKey.get(id) || id);
2276
- return {
2345
+ const pkg = {
2277
2346
  node: {
2278
2347
  key: n.key,
2279
2348
  name: n.name,
@@ -2287,6 +2356,62 @@ var TreeStore = class {
2287
2356
  unlocked,
2288
2357
  state: stateBag
2289
2358
  };
2359
+ const attachments = this.relAttachments(String(n.id));
2360
+ if (attachments.length) pkg.attachments = attachments;
2361
+ return pkg;
2362
+ }
2363
+ /** 0.80: rel-fabric summaries for the eval context package (both directions). */
2364
+ relAttachments(nodeId) {
2365
+ const rows = this.db.all(`SELECT rel, src_type, src_id, dst_type, dst_id FROM relations
2366
+ WHERE (src_type='node' AND src_id=?) OR (dst_type='node' AND dst_id=?)
2367
+ ORDER BY rowid LIMIT 12`, [nodeId, nodeId]);
2368
+ const clip = (v, n = 120) => String(v ?? "").slice(0, n);
2369
+ return rows.map((r) => {
2370
+ const srcIsMe = r.src_type === "node" && r.src_id === nodeId;
2371
+ const type = String(srcIsMe ? r.dst_type : r.src_type);
2372
+ const id = String(srcIsMe ? r.dst_id : r.src_id);
2373
+ const base = {
2374
+ rel: String(r.rel),
2375
+ type,
2376
+ id
2377
+ };
2378
+ const q = (sql) => this.db.get(sql, [id]);
2379
+ if (type === "claim") {
2380
+ const c = q("SELECT text, status, confidence FROM claims WHERE id=?");
2381
+ if (c) return {
2382
+ ...base,
2383
+ text: clip(c.text),
2384
+ status: c.status,
2385
+ confidence: c.confidence
2386
+ };
2387
+ } else if (type === "evidence") {
2388
+ const e = q("SELECT quote FROM evidence WHERE id=?");
2389
+ if (e) return {
2390
+ ...base,
2391
+ quote: clip(e.quote)
2392
+ };
2393
+ } else if (type === "kb") {
2394
+ const k = q("SELECT title FROM kb_notes WHERE id=?");
2395
+ if (k) return {
2396
+ ...base,
2397
+ title: clip(k.title, 80)
2398
+ };
2399
+ } else if (type === "source") {
2400
+ const s = q("SELECT title FROM sources WHERE id=?");
2401
+ if (s) return {
2402
+ ...base,
2403
+ title: clip(s.title, 80)
2404
+ };
2405
+ } else if (type === "node") {
2406
+ const nd = q("SELECT key, name FROM nodes WHERE id=?");
2407
+ if (nd) return {
2408
+ ...base,
2409
+ key: nd.key,
2410
+ name: clip(nd.name, 80)
2411
+ };
2412
+ }
2413
+ return base;
2414
+ });
2290
2415
  }
2291
2416
  /** Every node whose required gates are currently satisfied. */
2292
2417
  ready(treeId, context = "default", state = {}) {
@@ -4684,6 +4809,77 @@ function run$12(env, action, rest, args) {
4684
4809
  if (ctx.json) console.log(JSON.stringify({ path: file }, null, 2));
4685
4810
  else out(ctx, file);
4686
4811
  return 0;
4812
+ case "unlock": {
4813
+ const lockDir = `${file}.ygllock`;
4814
+ let holder = "";
4815
+ try {
4816
+ holder = node_fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
4817
+ } catch {}
4818
+ if (!node_fs.existsSync(lockDir)) {
4819
+ if (ctx.json) console.log(JSON.stringify({
4820
+ lock: lockDir,
4821
+ holder: null,
4822
+ cleared: false,
4823
+ reason: "no lock present"
4824
+ }, null, 2));
4825
+ else ok(ctx, `no lock present: ${lockDir}`);
4826
+ return 0;
4827
+ }
4828
+ if (holder && pidAlive(holder) && !args.flags.force) {
4829
+ const self = Number(holder) === process.pid;
4830
+ let holderCmd = "";
4831
+ try {
4832
+ holderCmd = node_fs.readFileSync(`${lockDir}/cmd`, "utf8").trim();
4833
+ } catch {}
4834
+ 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.`;
4835
+ if (self) try {
4836
+ node_fs.rmSync(lockDir, {
4837
+ recursive: true,
4838
+ force: true
4839
+ });
4840
+ if (ctx.json) console.log(JSON.stringify({
4841
+ lock: lockDir,
4842
+ holder,
4843
+ cleared: true,
4844
+ reason: "self (acquired during boot)"
4845
+ }, null, 2));
4846
+ else ok(ctx, `lock cleared: ${lockDir} (self — acquired during boot)`);
4847
+ return 0;
4848
+ } catch {}
4849
+ if (ctx.json) console.log(JSON.stringify({
4850
+ lock: lockDir,
4851
+ holder,
4852
+ holder_cmd: holderCmd || null,
4853
+ cleared: false,
4854
+ reason: "holder alive (use --force to override)"
4855
+ }, null, 2));
4856
+ else {
4857
+ bad(ctx, msg);
4858
+ out(ctx, paint(C.yellow, ` that is a parallel CLI session (serve never takes the write lock) — retry after it exits;`, ctx.color));
4859
+ out(ctx, paint(C.yellow, ` the lock self-heals then. --force overrides (corruption risk if writes are in flight).`, ctx.color));
4860
+ }
4861
+ return 1;
4862
+ }
4863
+ try {
4864
+ node_fs.rmSync(lockDir, {
4865
+ recursive: true,
4866
+ force: true
4867
+ });
4868
+ const why = !holder || !pidAlive(holder) ? `stale (pid ${holder || "?"} dead)` : "forced";
4869
+ if (ctx.json) console.log(JSON.stringify({
4870
+ lock: lockDir,
4871
+ holder: holder || null,
4872
+ cleared: true,
4873
+ reason: why
4874
+ }, null, 2));
4875
+ else ok(ctx, `lock cleared: ${lockDir} (${why})`);
4876
+ return 0;
4877
+ } catch (e) {
4878
+ if (ctx.json) console.log(JSON.stringify({ error: `remove failed: ${e?.message || e}` }, null, 2));
4879
+ else bad(ctx, `remove failed: ${e?.message || e}`);
4880
+ return 1;
4881
+ }
4882
+ }
4687
4883
  case "stats": {
4688
4884
  const s = env.store.stats();
4689
4885
  if (ctx.json) {
@@ -5635,6 +5831,34 @@ function buildBrief(env) {
5635
5831
  };
5636
5832
  let project = null;
5637
5833
  if (cur) project = store.projectByName(cur);
5834
+ try {
5835
+ const recent = store.db.all(`SELECT kind, entity_id, ts, payload FROM events
5836
+ WHERE kind IN ('tree.use','project.use')
5837
+ AND ts >= datetime('now','-10 minutes')
5838
+ ORDER BY ts DESC LIMIT 5`);
5839
+ if (recent.length) {
5840
+ payload.selection_changed = recent.map((r) => ({
5841
+ kind: r.kind,
5842
+ name: r.entity_id,
5843
+ session: (() => {
5844
+ try {
5845
+ return JSON.parse(r.payload || "{}").session || null;
5846
+ } catch {
5847
+ return null;
5848
+ }
5849
+ })(),
5850
+ ts: r.ts
5851
+ }));
5852
+ const others = recent.filter((r) => {
5853
+ try {
5854
+ return (JSON.parse(r.payload || "{}").session || "cli") !== (process.env.YGL_SESSION || "cli");
5855
+ } catch {
5856
+ return false;
5857
+ }
5858
+ });
5859
+ if (others.length) payload.next_actions.unshift(`# 并行会话切换过选择: ${others.map((o) => `${o.kind === "tree.use" ? "tree" : "project"} '${o.entity_id}'`).join(", ")} — 命令请带 -t/-p 固定上下文`);
5860
+ }
5861
+ } catch {}
5638
5862
  if (project) {
5639
5863
  const claims = store.claimList(project.id);
5640
5864
  const ev = store.evidenceList(project.id);
@@ -6146,6 +6370,7 @@ var init_eval_enhance = require_rolldown_runtime.__esmMin((() => {
6146
6370
  //#region packages/research/pipeline/src/predict.ts
6147
6371
  var predict_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({
6148
6372
  auditPredictions: () => auditPredictions,
6373
+ loadStateSource: () => loadStateSource,
6149
6374
  measureCriterion: () => measureCriterion,
6150
6375
  measureNpmVersions: () => measureNpmVersions,
6151
6376
  parsePrediction: () => parsePrediction
@@ -6173,23 +6398,74 @@ function parsePrediction(text, created) {
6173
6398
  deadline: ""
6174
6399
  };
6175
6400
  }
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;
6401
+ /** 0.79.1 --state bridge: load a flat counter map from a JSON file path or an
6402
+ * http(s) URL (e.g. a DSH API endpoint). Nested objects flatten to dot keys
6403
+ * ({counts:{y:12}} → "counts.y"); only finite numbers are kept. Null on any
6404
+ * failure — the audit degrades those criteria back to manual. */
6405
+ async function loadStateSource(src, fetchImpl = fetch) {
6406
+ try {
6407
+ let text;
6408
+ if (/^https?:\/\//.test(src)) {
6409
+ const ctl = new AbortController();
6410
+ const t = setTimeout(() => ctl.abort(), 3e3);
6411
+ const r = await fetchImpl(src, { signal: ctl.signal });
6412
+ clearTimeout(t);
6413
+ if (!r.ok) return null;
6414
+ text = await r.text();
6415
+ } else text = require("node:fs").readFileSync(src, "utf8");
6416
+ const flat = {};
6417
+ const walk = (obj, prefix) => {
6418
+ if (!obj || typeof obj !== "object") return;
6419
+ for (const [k, v] of Object.entries(obj)) {
6420
+ const key = prefix ? `${prefix}.${k}` : k;
6421
+ if (typeof v === "number" && Number.isFinite(v)) flat[key] = v;
6422
+ else if (v && typeof v === "object" && !Array.isArray(v)) walk(v, key);
6423
+ }
6424
+ };
6425
+ walk(JSON.parse(text), "");
6426
+ return flat;
6427
+ } catch {
6428
+ return null;
6429
+ }
6430
+ }
6431
+ /** Locally measurable criterion matchers. Null → manual.
6432
+ * Precedence: 树/kb workspace counts (local truth) → any `key≥N` whose key
6433
+ * exists in the optional --state counter map (external truth, 0.79.1). */
6434
+ function measureCriterion(crit, db, state) {
6181
6435
  const parts = [];
6182
6436
  const hits = [];
6437
+ const tree = crit.match(/树\s*≥\s*(\d+)/);
6183
6438
  if (tree) {
6184
6439
  const n = db.get("SELECT COUNT(*) c FROM trees").c;
6185
6440
  parts.push(`trees=${n}/${tree[1]}`);
6186
6441
  hits.push(n >= +tree[1]);
6187
6442
  }
6443
+ const kb = crit.match(/kb\s*≥\s*(\d+)/);
6188
6444
  if (kb) {
6189
6445
  const n = db.get("SELECT COUNT(*) c FROM kb_notes").c;
6190
6446
  parts.push(`kb=${n}/${kb[1]}`);
6191
6447
  hits.push(n >= +kb[1]);
6192
6448
  }
6449
+ if (state) {
6450
+ const seen = /* @__PURE__ */ new Set();
6451
+ for (const m of crit.matchAll(/([\w\u4e00-\u9fa5][\w\u4e00-\u9fa5.-]*)\s*≥\s*(\d+)/g)) {
6452
+ const key = m[1], need = +m[2];
6453
+ if (key === "树" || key === "kb" || seen.has(key)) continue;
6454
+ let stateKey = key in state ? key : null;
6455
+ if (!stateKey) {
6456
+ const cands = Object.keys(state).filter((sk) => sk.endsWith("." + key) && Number.isFinite(state[sk]));
6457
+ if (cands.length) {
6458
+ cands.sort((a, b) => a.split(".").length - b.split(".").length || a.localeCompare(b));
6459
+ stateKey = cands[0];
6460
+ }
6461
+ }
6462
+ if (!stateKey || !Number.isFinite(state[stateKey])) continue;
6463
+ seen.add(key);
6464
+ parts.push(`${key}=${state[stateKey]}/${need}${stateKey !== key ? ` (${stateKey})` : ""}`);
6465
+ hits.push(state[stateKey] >= need);
6466
+ }
6467
+ }
6468
+ if (!parts.length) return null;
6193
6469
  const hit = crit.includes(" 或 ") ? hits.some(Boolean) : hits.every(Boolean);
6194
6470
  return {
6195
6471
  actual: parts.join(" · "),
@@ -6224,7 +6500,8 @@ async function auditPredictions(inp) {
6224
6500
  const m = c.text.match(/\d{4}-\d{2}-\d{2}\s*前\s*(.+?)(?:——|$)/) || c.text.match(/\]\s*(.+?)(?:——|$)/);
6225
6501
  const criterion = m ? m[1].trim() : "";
6226
6502
  const daysLeft = deadline ? Math.round((Date.parse(deadline) - Date.parse(today)) / 864e5) : null;
6227
- let measure = measureCriterion(criterion, inp.db);
6503
+ const resolved = c.status === "supported" || c.status === "refuted";
6504
+ let measure = measureCriterion(criterion, inp.db, inp.state);
6228
6505
  if (!measure && /npm 再发\s*≥\s*(\d+)\s*个版本/.test(criterion)) {
6229
6506
  const need = +criterion.match(/npm 再发\s*≥\s*(\d+)/)[1];
6230
6507
  const base = c.text.match(/基线\s*\S*?@?(\d{4}-\d{2}-\d{2})/);
@@ -6242,7 +6519,10 @@ async function auditPredictions(inp) {
6242
6519
  }
6243
6520
  let verdict;
6244
6521
  let hint;
6245
- if (!measure) {
6522
+ if (resolved) {
6523
+ verdict = "resolved";
6524
+ hint = `已人工仲裁:${c.status}(回测终态,不再复判)`;
6525
+ } else if (!measure) {
6246
6526
  verdict = "manual";
6247
6527
  hint = "外部世界判据——人工核验后 claim resolve";
6248
6528
  } else if (daysLeft !== null && daysLeft > 0) verdict = "pending";
@@ -6252,7 +6532,7 @@ async function auditPredictions(inp) {
6252
6532
  horizon,
6253
6533
  deadline,
6254
6534
  criterion,
6255
- actual: measure ? measure.actual + (verdict === "pending" && measure.hit ? " · 已达标" : "") : "—",
6535
+ actual: measure ? measure.actual + (!resolved && verdict === "pending" && measure.hit ? " · 已达标" : "") : "—",
6256
6536
  verdict,
6257
6537
  days_left: daysLeft,
6258
6538
  hint
@@ -6274,6 +6554,10 @@ init_writes();
6274
6554
  init_entities$1();
6275
6555
  /** Run a write through the TypeORM transactional write service and print
6276
6556
  * with JSON discipline. Keeps every write command 6 lines. */
6557
+ /** 0.80: compact timestamp for session:// adjudication urls (yyyymmddhhmm). */
6558
+ function nowCompact() {
6559
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 12);
6560
+ }
6277
6561
  function runWrite(env, fn, human) {
6278
6562
  const { ctx } = env;
6279
6563
  return getOrm(env.db.path).then(fn).then((r) => {
@@ -6398,14 +6682,24 @@ function run$8(env, action, rest, args) {
6398
6682
  bad(ctx, "flag needs a value: --title \"...\" / --url \"...\" (bare flag was treated as boolean)");
6399
6683
  return 2;
6400
6684
  }
6401
- const title = String(titleRaw || "");
6402
- const url = String(urlRaw || "");
6685
+ let title = String(titleRaw || "");
6686
+ let url = String(urlRaw || "");
6687
+ const adjRaw = args.flags.adjudication;
6688
+ if (adjRaw === true) {
6689
+ bad(ctx, "flag needs a value: --adjudication \"<裁决摘要>\"");
6690
+ return 2;
6691
+ }
6692
+ if (adjRaw !== void 0 && adjRaw !== "") {
6693
+ const adj = String(adjRaw);
6694
+ if (!url) url = `session://adjudication/${nowCompact()}`;
6695
+ if (!title) title = adj.slice(0, 60);
6696
+ }
6403
6697
  if (!title && !url) {
6404
6698
  bad(ctx, "need --title and/or --url");
6405
6699
  return 2;
6406
6700
  }
6407
- if (url && !/^https?:\/\/|^file:\/\//.test(url)) {
6408
- bad(ctx, `--url must be http(s):// or file:// (got: "${require_text.clip(url, 30)}")`);
6701
+ if (url && !/^https?:\/\/|^file:\/\/|^session:\/\//.test(url)) {
6702
+ bad(ctx, `--url must be http(s)://, file:// or session:// (got: "${require_text.clip(url, 30)}")`);
6409
6703
  return 2;
6410
6704
  }
6411
6705
  const spec = {
@@ -6572,9 +6866,27 @@ function run$8(env, action, rest, args) {
6572
6866
  if (sub === "evidence") {
6573
6867
  if (subAction === "add") {
6574
6868
  const sourceId = rest[1];
6575
- const quote = String(args.flags.quote || readBody());
6869
+ const file = String(args.flags.file || "");
6870
+ let quote = String(args.flags.quote || "");
6871
+ if (file) {
6872
+ if (quote) {
6873
+ bad(ctx, "--file and --quote are mutually exclusive");
6874
+ return 2;
6875
+ }
6876
+ try {
6877
+ quote = require("fs").readFileSync(file, "utf8").trim();
6878
+ } catch (e) {
6879
+ bad(ctx, `cannot read --file: ${e?.message || e}`);
6880
+ return 1;
6881
+ }
6882
+ if (!quote) {
6883
+ bad(ctx, `--file is empty: ${file}`);
6884
+ return 2;
6885
+ }
6886
+ }
6887
+ if (!quote) quote = readBody();
6576
6888
  if (!sourceId || !quote) {
6577
- bad(ctx, "usage: research evidence add <sourceId> --quote \"...\"");
6889
+ bad(ctx, "usage: research evidence add <sourceId> --quote \"...\" | --file <path> [--note ..] [--locator ..]");
6578
6890
  return 2;
6579
6891
  }
6580
6892
  const note = String(args.flags.note || "");
@@ -6674,7 +6986,7 @@ function run$8(env, action, rest, args) {
6674
6986
  }
6675
6987
  if (subAction === "link") {
6676
6988
  if (!rest[1] || !rest[2]) {
6677
- bad(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes]");
6989
+ bad(ctx, "usage: research claim link <claimId> <evidenceId> [--stance refutes] [--by-source]");
6678
6990
  return 2;
6679
6991
  }
6680
6992
  const stanceRaw = String(args.flags.stance || "supports");
@@ -6683,6 +6995,34 @@ function run$8(env, action, rest, args) {
6683
6995
  return 2;
6684
6996
  }
6685
6997
  const stance = stanceRaw;
6998
+ if (args.flags["by-source"]) {
6999
+ const evs = store.db.all("SELECT id FROM evidence WHERE source_id=? AND project_id=? ORDER BY created", [rest[2], project.id]);
7000
+ if (!evs.length) {
7001
+ 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]}`);
7002
+ return 1;
7003
+ }
7004
+ return getOrm(env.db.path).then(async (ds) => {
7005
+ const existing = new Set(store.db.all("SELECT evidence_id FROM claim_evidence WHERE claim_id=?", [rest[1]]).map((x) => x.evidence_id));
7006
+ let linked = 0, dups = 0, status = "";
7007
+ for (const ev of evs) {
7008
+ const r = await claimLink(ds, rest[1], ev.id, stance);
7009
+ existing.has(ev.id) ? dups++ : linked++;
7010
+ status = r.status;
7011
+ }
7012
+ if (ctx.json) console.log(JSON.stringify({
7013
+ claim: rest[1],
7014
+ source: rest[2],
7015
+ linked,
7016
+ duplicates: dups,
7017
+ status
7018
+ }, null, 2));
7019
+ else ok(ctx, `linked ${linked} evidence from source ${rest[2]} (${dups} already) — claim status: ${status}`);
7020
+ return 0;
7021
+ }).catch((e) => {
7022
+ bad(ctx, e?.message || String(e));
7023
+ return 1;
7024
+ });
7025
+ }
6686
7026
  return getOrm(env.db.path).then((ds) => claimLink(ds, rest[1], rest[2], stance)).then((r) => {
6687
7027
  ok(ctx, `linked; claim status now: ${r.status}`);
6688
7028
  try {
@@ -6717,6 +7057,18 @@ function run$8(env, action, rest, args) {
6717
7057
  } catch {}
6718
7058
  return 0;
6719
7059
  }).catch((e) => {
7060
+ const m = /^evidence not found: (.+)$/.exec(e?.message || "");
7061
+ if (m) {
7062
+ const evs = store.db.all("SELECT id FROM evidence WHERE source_id=? AND project_id=? ORDER BY created LIMIT 8", [m[1], project.id]);
7063
+ if (evs.length) {
7064
+ 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)`);
7065
+ return 1;
7066
+ }
7067
+ if (store.db.get("SELECT id FROM sources WHERE id=? AND project_id=?", [m[1], project.id])) {
7068
+ bad(ctx, `${e.message} — that id is a SOURCE with no evidence yet: research evidence add ${m[1]} --quote ".." first`);
7069
+ return 1;
7070
+ }
7071
+ }
6720
7072
  bad(ctx, e?.message || String(e));
6721
7073
  return 1;
6722
7074
  });
@@ -6846,17 +7198,26 @@ function run$8(env, action, rest, args) {
6846
7198
  else out(ctx, paint(C.dim, " no [预测·…] claims in this project", ctx.color));
6847
7199
  return 0;
6848
7200
  }
6849
- const { auditPredictions } = require_rolldown_runtime.__toCommonJS(predict_exports);
7201
+ const { auditPredictions, loadStateSource } = require_rolldown_runtime.__toCommonJS(predict_exports);
6850
7202
  return (async () => {
7203
+ let state = null;
7204
+ if (args.flags.state) {
7205
+ state = await loadStateSource(String(args.flags.state));
7206
+ if (!state && !ctx.json) out(ctx, paint(C.yellow, ` ⚠ --state ${args.flags.state} 加载失败(文件/URL 不可读或非 JSON)——相关判据回落 manual`, ctx.color));
7207
+ }
6851
7208
  const rows = await auditPredictions({
6852
7209
  claims,
6853
7210
  db: store.db,
6854
- npmProbe: args.flags.offline !== true
7211
+ npmProbe: args.flags.offline !== true,
7212
+ state
6855
7213
  });
7214
+ const resolvedN = rows.filter((r) => r.verdict === "resolved").length;
7215
+ const visible = args.flags.all ? rows : rows.filter((r) => r.verdict !== "resolved");
6856
7216
  if (ctx.json) console.log(JSON.stringify(rows, null, 2));
7217
+ else if (!visible.length) out(ctx, paint(C.dim, ` 全部 ${rows.length} 条预测已结案(resolved)——--all 查看终态表`, ctx.color));
6857
7218
  else {
6858
- head(ctx, `prediction audit — ${project.name} (${rows.length} predictions)`);
6859
- table(ctx, rows, [
7219
+ head(ctx, `prediction audit — ${project.name} (${visible.length}/${rows.length}${resolvedN && !args.flags.all ? ` · ${resolvedN} resolved 隐藏 (--all)` : ""})`);
7220
+ table(ctx, visible, [
6860
7221
  "claim",
6861
7222
  "horizon",
6862
7223
  "deadline",
@@ -6873,14 +7234,14 @@ function run$8(env, action, rest, args) {
6873
7234
  "verdict",
6874
7235
  "days_left"
6875
7236
  ]);
6876
- const actionable = rows.filter((r) => r.verdict === "hit" || r.verdict === "miss");
7237
+ const actionable = visible.filter((r) => r.verdict === "hit" || r.verdict === "miss");
6877
7238
  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));
7239
+ for (const r of visible.filter((x) => x.verdict === "manual")) out(ctx, paint(C.dim, ` · ${r.claim_id}: ${r.hint}`, ctx.color));
6879
7240
  }
6880
7241
  return 0;
6881
7242
  })();
6882
7243
  }
6883
- bad(ctx, "usage: research predict audit [--offline]");
7244
+ bad(ctx, "usage: research predict audit [--offline] [--state <file|url>] [--all]");
6884
7245
  return 2;
6885
7246
  }
6886
7247
  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 +7257,25 @@ init_entities$1();
6896
7257
  function run$7(env, action, rest, args) {
6897
7258
  const { ctx, store } = env;
6898
7259
  const name = rest[0];
7260
+ /** Report ③-3: selection changes are events (session-tagged) — see the
7261
+ * twin helper in techtree/model/src/cmds.ts for the rationale. */
7262
+ function logSelectionEvent(kind, projName) {
7263
+ try {
7264
+ store.db.run("INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,?)", [
7265
+ `ev${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
7266
+ (/* @__PURE__ */ new Date()).toISOString(),
7267
+ process.env.YGL_SESSION || "cli",
7268
+ kind,
7269
+ "project",
7270
+ projName,
7271
+ JSON.stringify({
7272
+ name: projName,
7273
+ session: process.env.YGL_SESSION || "cli"
7274
+ }),
7275
+ "info"
7276
+ ]);
7277
+ } catch {}
7278
+ }
6899
7279
  switch (action) {
6900
7280
  case "new": {
6901
7281
  if (!name) {
@@ -6903,8 +7283,11 @@ function run$7(env, action, rest, args) {
6903
7283
  return 2;
6904
7284
  }
6905
7285
  const question = String(args.flags.q || args.flags.question || "");
7286
+ const prior = store.db.metaGet("current_project");
7287
+ const priorHint = prior && prior !== name ? ` (prior project '${prior}' is no longer selected — project use ${prior} to switch back)` : "";
6906
7288
  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}`);
7289
+ logSelectionEvent("project.use", p.name);
7290
+ ack(ctx, p, p?.duplicate === true ? `project exists, selected: ${p.name} (duplicate, not re-created)${priorHint}` : `project created and selected: ${p.name}${priorHint}`);
6908
7291
  return 0;
6909
7292
  }).catch((e) => {
6910
7293
  bad(ctx, e?.message || String(e));
@@ -6944,6 +7327,7 @@ function run$7(env, action, rest, args) {
6944
7327
  return 1;
6945
7328
  }
6946
7329
  store.db.metaSet("current_project", name);
7330
+ logSelectionEvent("project.use", name);
6947
7331
  ack(ctx, { project: name }, `now using: ${name}`);
6948
7332
  return 0;
6949
7333
  case "show": {
@@ -7109,6 +7493,10 @@ function run$6(env, action, rest, args) {
7109
7493
  case "spec": {
7110
7494
  const title = rest[0] || String(args.flags.title || "");
7111
7495
  const body = readBody();
7496
+ if (args.flags["design-type"] !== void 0) {
7497
+ 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)");
7498
+ return 2;
7499
+ }
7112
7500
  if (!title || !body) {
7113
7501
  bad(ctx, "usage: produce spec <title> --body|-f <file>");
7114
7502
  return 2;
@@ -7946,10 +8334,41 @@ function run$5(env, action, rest, args) {
7946
8334
  /** tree-domain write through the transactional service (async command). */
7947
8335
  function runTreeWrite(fn, print) {
7948
8336
  return getOrm(env.db.path).then(fn).then(print).catch((e) => {
7949
- bad(ctx, e?.message || String(e));
8337
+ bad(ctx, enrichNodeNotFound(e?.message || String(e)));
7950
8338
  return 1;
7951
8339
  });
7952
8340
  }
8341
+ /** Report ③-3: selection changes are now first-class events — a parallel
8342
+ * session flipping current_tree/current_project becomes observable
8343
+ * (agent brief surfaces it; db timeline shows the history). Session
8344
+ * identity comes from YGL_SESSION (agents that care can set it). */
8345
+ function logSelectionEvent(database, kind, name) {
8346
+ try {
8347
+ database.run("INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,?)", [
8348
+ `ev${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
8349
+ (/* @__PURE__ */ new Date()).toISOString(),
8350
+ process.env.YGL_SESSION || "cli",
8351
+ kind,
8352
+ kind === "tree.use" ? "tree" : "project",
8353
+ name,
8354
+ JSON.stringify({
8355
+ name,
8356
+ session: process.env.YGL_SESSION || "cli"
8357
+ }),
8358
+ "info"
8359
+ ]);
8360
+ } catch {}
8361
+ }
8362
+ /** Report ③ enrichment: "node not found: X" earned a cross-tree lookup —
8363
+ * if a parallel session flipped current_tree, the key usually exists in
8364
+ * another tree; name it instead of making the user blind-list. */
8365
+ function enrichNodeNotFound(msg) {
8366
+ const m = /^node not found: (.+)$/.exec(msg);
8367
+ if (!m) return msg;
8368
+ const others = trees.nodeInOtherTrees(m[1], tid);
8369
+ 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}`;
8370
+ return `${msg} (current tree: '${tree?.name ?? "?"}' — node list to inspect, -t <tree> to switch)`;
8371
+ }
7953
8372
  if (env.group === "tree") {
7954
8373
  const sub = action;
7955
8374
  const name = rest[0];
@@ -7962,6 +8381,7 @@ function run$5(env, action, rest, args) {
7962
8381
  const proj = pid ? db.get("SELECT id FROM projects WHERE name=?", [pid]) : null;
7963
8382
  return runTreeWrite((ds) => treeCreate(ds, name, proj?.id || ""), (t) => {
7964
8383
  db.metaSet("current_tree", t.name);
8384
+ logSelectionEvent(db, "tree.use", t.name);
7965
8385
  ack(ctx, t, t?.duplicate === true ? `tree exists, selected: ${t.name} (duplicate, not re-created)` : `tree created and selected: ${t.name}`);
7966
8386
  return 0;
7967
8387
  });
@@ -7990,6 +8410,7 @@ function run$5(env, action, rest, args) {
7990
8410
  return 1;
7991
8411
  }
7992
8412
  db.metaSet("current_tree", name);
8413
+ logSelectionEvent(db, "tree.use", name);
7993
8414
  ack(ctx, { tree: name }, `now using tree: ${name}`);
7994
8415
  return 0;
7995
8416
  }
@@ -8198,43 +8619,43 @@ function run$5(env, action, rest, args) {
8198
8619
  });
8199
8620
  if (sub === "can") {
8200
8621
  if (!key) {
8201
- bad(ctx, "usage: node can <key> [--context ctx] [--state k=v] [--eval]");
8622
+ bad(ctx, "usage: node can <key...> [--context ctx] [--state k=v] [--state-file file] [--eval]");
8202
8623
  return 2;
8203
8624
  }
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;
8625
+ const keys = [...new Set(rest.filter(Boolean))];
8626
+ const sharedState = parseState(args);
8627
+ let perKeyBags = null;
8628
+ try {
8629
+ perKeyBags = loadStateFile(args, sharedState);
8630
+ } catch (e) {
8631
+ const msg = e?.message || String(e);
8632
+ bad(ctx, msg);
8633
+ return /^usage:/.test(msg) ? 2 : /must contain a JSON object/.test(msg) ? 2 : 1;
8229
8634
  }
8635
+ const stateFor = (k) => perKeyBags ? perKeyBags[k] && typeof perKeyBags[k] === "object" && !Array.isArray(perKeyBags[k]) ? perKeyBags[k] : {} : sharedState;
8636
+ const ctxName = flagStr("context", "default");
8230
8637
  const offline = !!process.env.YGL_EVAL_OFFLINE || configGetBool(env.db, "eval.offline");
8231
8638
  const cfgToken = configGet(env.db, "decision_model_llm_token").trim();
8232
8639
  const token = offline ? "" : cfgToken || evalToken();
8233
8640
  const endpoint = configGet(env.db, "decision_model_llm_api_base").trim() || "https://ai-gateway.vercel.sh/v4/ai/evaluation-model";
8234
8641
  const passT = configGetNumber(env.db, "eval.pass_threshold");
8235
8642
  const failT = configGetNumber(env.db, "eval.fail_threshold");
8236
- const contextPkg = trees.evalContext(tid, key, state, ctxName);
8237
- return (async () => {
8643
+ /** Judge one node: canUnlock (+ eval gates when --eval). Never throws —
8644
+ * hard failures come back as {key, error}. */
8645
+ const judgeOne = async (k) => {
8646
+ let r;
8647
+ try {
8648
+ r = trees.canUnlock(tid, k, ctxName, stateFor(k));
8649
+ } catch (e) {
8650
+ return {
8651
+ key: k,
8652
+ error: enrichNodeNotFound(e?.message || String(e))
8653
+ };
8654
+ }
8655
+ const state = stateFor(k);
8656
+ const evalConds = flagBool("eval") ? trees.condList(tid, k).filter((c) => /^eval(-choice|-score)?$/.test(String(c.op))) : [];
8657
+ if (!evalConds.length) return r;
8658
+ const contextPkg = trees.evalContext(tid, k, state, ctxName);
8238
8659
  const evaluated = [];
8239
8660
  let usageIn = 0, usageOut = 0;
8240
8661
  for (const c of evalConds) {
@@ -8295,13 +8716,13 @@ function run$5(env, action, rest, args) {
8295
8716
  require_text.shortId([
8296
8717
  "eval.gate",
8297
8718
  "node",
8298
- key
8719
+ k
8299
8720
  ], 12),
8300
8721
  require_text.nowIso(),
8301
8722
  "cli",
8302
8723
  "eval.gate",
8303
8724
  "node",
8304
- key,
8725
+ k,
8305
8726
  JSON.stringify({
8306
8727
  conds: evaluated.map((e) => ({
8307
8728
  name: e.name,
@@ -8318,31 +8739,74 @@ function run$5(env, action, rest, args) {
8318
8739
  })
8319
8740
  ]);
8320
8741
  } catch {}
8742
+ return r;
8743
+ };
8744
+ const batch = keys.length > 1;
8745
+ return (async () => {
8746
+ const results = [];
8747
+ for (const k of keys) results.push(await judgeOne(k));
8321
8748
  if (ctx.json) {
8322
- console.log(JSON.stringify(r, null, 2));
8323
- return 0;
8749
+ console.log(JSON.stringify(batch ? results : results[0], null, 2));
8750
+ return results.some((r) => r.error) ? 1 : 0;
8324
8751
  }
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}`);
8334
- }
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}`);
8752
+ if (!batch) {
8753
+ const r = results[0];
8754
+ if (r.error) {
8755
+ bad(ctx, r.error);
8756
+ return 1;
8757
+ }
8758
+ if (!r.evaluated) {
8759
+ head(ctx, `can unlock: ${r.key}`);
8760
+ out(ctx, ` ${r.ok ? paint(C.green, "✓ 可解锁", ctx.color) : paint(C.red, "✗ 不可解锁", ctx.color)}`);
8761
+ if (r.missing_required.length) {
8762
+ out(ctx, paint(C.bold, " 必检未满足", ctx.color));
8763
+ for (const m of r.missing_required) out(ctx, ` - ${m}`);
8764
+ }
8765
+ if (r.blocked_by.length) {
8766
+ out(ctx, paint(C.bold, " 被排斥", ctx.color));
8767
+ for (const b of r.blocked_by) out(ctx, ` - ${b}`);
8768
+ }
8769
+ if (r.missing_optional.length) {
8770
+ out(ctx, paint(C.dim, " 可选未满足(不阻塞)", ctx.color));
8771
+ for (const m of r.missing_optional) out(ctx, ` - ${m}`);
8772
+ }
8773
+ if (r.cost_multiplier !== 1) out(ctx, ` 成本系数 ${r.cost_multiplier}`);
8774
+ return 0;
8775
+ }
8776
+ head(ctx, `can unlock: ${r.key}`);
8777
+ out(ctx, ` ${r.ok ? paint(C.green, "✓ 可解锁", ctx.color) : r.pending.length ? paint(C.yellow, "… 待判定(决策模型 pending)", ctx.color) : paint(C.red, "✗ 不可解锁", ctx.color)}`);
8778
+ if (r.missing_required.length) {
8779
+ out(ctx, paint(C.bold, " 必检未满足", ctx.color));
8780
+ for (const m of r.missing_required) out(ctx, ` - ${m}`);
8781
+ }
8782
+ if (r.pending.length) {
8783
+ out(ctx, paint(C.bold, " 决策模型 pending(灰带/未配置,非终态)", ctx.color));
8784
+ for (const p of r.pending) out(ctx, ` - ${p}`);
8785
+ }
8786
+ if (r.blocked_by.length) {
8787
+ out(ctx, paint(C.bold, " 被排斥", ctx.color));
8788
+ for (const b of r.blocked_by) out(ctx, ` - ${b}`);
8789
+ }
8790
+ if (r.missing_optional.length) {
8791
+ out(ctx, paint(C.dim, " 可选未满足(不阻塞)", ctx.color));
8792
+ for (const m of r.missing_optional) out(ctx, ` - ${m}`);
8793
+ }
8794
+ 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));
8795
+ if (r.cost_multiplier !== 1) out(ctx, ` 成本系数 ${r.cost_multiplier}`);
8796
+ return 0;
8338
8797
  }
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}`);
8798
+ const via = perKeyBags ? "per-node --state-file" : Object.keys(sharedState).length ? "shared state" : "";
8799
+ head(ctx, `can unlock × ${results.length}${via ? ` (${via})` : ""}`);
8800
+ for (const r of results) {
8801
+ if (r.error) {
8802
+ out(ctx, ` ${paint(C.red, "✗", ctx.color)} ${r.key} ${r.error}`);
8803
+ continue;
8804
+ }
8805
+ const mark = r.ok ? paint(C.green, "✓", ctx.color) : r.pending?.length ? paint(C.yellow, "…", ctx.color) : paint(C.red, "✗", ctx.color);
8806
+ const blockers = [...(r.missing_required || []).map((m) => m), ...(r.blocked_by || []).map((b) => `excluded:${b}`)].join(", ");
8807
+ out(ctx, ` ${mark} ${r.key}${r.pending?.length ? " (eval pending)" : ""}${blockers ? ` — ${blockers}` : ""}`);
8342
8808
  }
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;
8809
+ return results.some((r) => r.error) ? 1 : 0;
8346
8810
  })().catch((e) => {
8347
8811
  bad(ctx, e?.message || String(e));
8348
8812
  return 1;
@@ -8350,6 +8814,16 @@ function run$5(env, action, rest, args) {
8350
8814
  }
8351
8815
  if (sub === "ready") {
8352
8816
  const state = parseState(args);
8817
+ try {
8818
+ if (loadStateFile(args, state)) {
8819
+ bad(ctx, "--state-file per-node shape (key→object) only applies to batch `node can k1 k2 …`; ready wants a flat counter object");
8820
+ return 2;
8821
+ }
8822
+ } catch (e) {
8823
+ const msg = e?.message || String(e);
8824
+ bad(ctx, msg);
8825
+ return /^usage:/.test(msg) || /must contain a JSON object/.test(msg) ? 2 : 1;
8826
+ }
8353
8827
  const ctxName = flagStr("context", "default");
8354
8828
  if (!flagBool("eval")) {
8355
8829
  const rows = trees.ready(tid, ctxName, state);
@@ -8530,7 +9004,13 @@ function run$5(env, action, rest, args) {
8530
9004
  bad(ctx, "usage: node path <key>");
8531
9005
  return 2;
8532
9006
  }
8533
- const p = trees.pathTo(tid, key, flagStr("context", "default"));
9007
+ let p;
9008
+ try {
9009
+ p = trees.pathTo(tid, key, flagStr("context", "default"));
9010
+ } catch (e) {
9011
+ bad(ctx, enrichNodeNotFound(e?.message || String(e)));
9012
+ return 1;
9013
+ }
8534
9014
  if (ctx.json) {
8535
9015
  console.log(JSON.stringify(p, null, 2));
8536
9016
  return 0;
@@ -8640,7 +9120,7 @@ function run$5(env, action, rest, args) {
8640
9120
  if (sub === "show") {
8641
9121
  const n = trees.nodeGet(tid, key);
8642
9122
  if (!n) {
8643
- bad(ctx, `node not found: ${key}`);
9123
+ bad(ctx, enrichNodeNotFound(`node not found: ${key}`));
8644
9124
  return 1;
8645
9125
  }
8646
9126
  const st = trees.nodeStateOf(tid, key);
@@ -8747,10 +9227,36 @@ function run$5(env, action, rest, args) {
8747
9227
  conditions: r.conditions
8748
9228
  }, `imported tree ${r.tree.name}: ${r.nodes} nodes, ${r.edges} edges, ${r.conditions} conditions`);
8749
9229
  db.metaSet("current_tree", r.tree.name);
9230
+ logSelectionEvent(db, "tree.use", r.tree.name);
8750
9231
  if (!ctx.json) out(ctx, paint(C.dim, ` index rebuilt; now using tree: ${r.tree.name}`, ctx.color));
8751
9232
  return 0;
8752
9233
  });
8753
9234
  }
9235
+ if (sub === "timeline") {
9236
+ const rows = db.all(`SELECT e.ts, e.kind, e.payload FROM events e
9237
+ WHERE e.entity = 'node'
9238
+ AND e.entity_id IN (SELECT id FROM nodes WHERE tree_id = ?)
9239
+ ORDER BY e.ts DESC LIMIT 30`, [tid]);
9240
+ if (ctx.json) {
9241
+ console.log(JSON.stringify(rows.map((r) => ({
9242
+ ts: r.ts,
9243
+ kind: r.kind,
9244
+ ...JSON.parse(r.payload || "{}")
9245
+ })), null, 2));
9246
+ return 0;
9247
+ }
9248
+ head(ctx, `Timeline — tree '${tree.name}' (last ${rows.length})`);
9249
+ for (const r of rows) {
9250
+ let detail = "";
9251
+ try {
9252
+ const p = JSON.parse(r.payload || "{}");
9253
+ detail = [p.key, p.name ? String(p.name).slice(0, 30) : ""].filter(Boolean).join(" ");
9254
+ } catch {}
9255
+ out(ctx, ` ${String(r.ts).slice(0, 16)} ${r.kind.padEnd(14)} ${detail}`);
9256
+ }
9257
+ if (!rows.length) out(ctx, paint(C.dim, " (no node events yet — unlock/add to start the history)", ctx.color));
9258
+ return 0;
9259
+ }
8754
9260
  if (sub === "rm") {
8755
9261
  if (!key) {
8756
9262
  bad(ctx, "usage: node rm <key>");
@@ -8777,6 +9283,30 @@ function parseState(args) {
8777
9283
  }
8778
9284
  return out;
8779
9285
  }
9286
+ /**
9287
+ * 0.79.1 (R297 user report): `--state-file <path.json>` loads the state bag
9288
+ * from JSON instead of inline `--state k=v`. Two accepted shapes:
9289
+ * flat shared {"y_imported": 12} → merged over --state, all keys share it
9290
+ * per-node map {"node-a": {"y": 1}, ...} → each judged node gets its own bag
9291
+ * (disambiguated by "every value is a plain object"). Returns the per-node map
9292
+ * when shape B, null when merged into `shared`. Throws on unreadable/invalid.
9293
+ */
9294
+ function loadStateFile(args, shared) {
9295
+ const sf = args.flags["state-file"];
9296
+ if (sf === void 0) return null;
9297
+ if (sf === true || sf === "") throw new Error("usage: --state-file <path.json>");
9298
+ let parsed;
9299
+ try {
9300
+ parsed = JSON.parse(require("node:fs").readFileSync(String(sf), "utf8"));
9301
+ } catch (e) {
9302
+ throw new Error(`cannot read --state-file ${sf}: ${e?.message || e}`);
9303
+ }
9304
+ 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)");
9305
+ const vals = Object.values(parsed);
9306
+ if (vals.length && vals.every((v) => v && typeof v === "object" && !Array.isArray(v))) return parsed;
9307
+ Object.assign(shared, parsed);
9308
+ return null;
9309
+ }
8780
9310
  //#endregion
8781
9311
  //#region packages/knowledge/skills/src/skill-cmds.ts
8782
9312
  /** skill group — the L1/L2/L3 registry agents query before acting.
@@ -9151,7 +9681,22 @@ function run$3(env, action, rest, args) {
9151
9681
  confidence: String(args.flags.confidence || configGet(env.db, "default_confidence")),
9152
9682
  force
9153
9683
  })).then((r) => {
9154
- writeResult(ctx, r, (row) => `note stored: ${row.id} ${require_text.clip(title, 50)}`);
9684
+ const row = r.row;
9685
+ const verify = store.kbGet(row.id);
9686
+ if (ctx.json) console.log(JSON.stringify({
9687
+ ...row,
9688
+ ...r.dup ? { duplicate: true } : {},
9689
+ verified: !!verify,
9690
+ workspace: env.home
9691
+ }, null, 2));
9692
+ else {
9693
+ writeResult(ctx, r, (rw) => `note stored: ${rw.id} ${require_text.clip(title, 50)}`);
9694
+ out(ctx, paint(C.dim, ` @ ${env.home}`, ctx.color));
9695
+ if (!verify) {
9696
+ out(ctx, paint(C.yellow, ` ⚠ verify miss: id ${row.id} not readable back — the write may not have landed.`, ctx.color));
9697
+ out(ctx, paint(C.yellow, ` run: ygl-trees doctor (write_integrity check names phantom writes)`, ctx.color));
9698
+ }
9699
+ }
9155
9700
  return 0;
9156
9701
  }).catch((e) => {
9157
9702
  bad(ctx, e?.message || String(e));
@@ -10675,6 +11220,13 @@ function createDashboardServer(env) {
10675
11220
  * lifecycle-managed: dispose cleans it automatically. */
10676
11221
  function serve(env, args) {
10677
11222
  const { ctx } = env;
11223
+ if (args.flags.help || args.flags.h) {
11224
+ out(ctx, `usage: ygl-trees webui serve [--port N] [--host H]`);
11225
+ out(ctx, paint(C.dim, ` --port N listen port (default 8642; 0 = pick a free one)`, ctx.color));
11226
+ out(ctx, paint(C.dim, ` --host H bind address (default 127.0.0.1; 0.0.0.0 opens to LAN)`, ctx.color));
11227
+ out(ctx, paint(C.dim, ` endpoints: / React client · /classic embedded dashboard · /api/events SSE`, ctx.color));
11228
+ return Promise.resolve(0);
11229
+ }
10678
11230
  const reactAvailable = resolveDistRoot() !== null;
10679
11231
  return new Promise((resolve) => {
10680
11232
  const port = Number(args.flags.port || 8642);
@@ -10832,16 +11384,33 @@ function run(env, action, rest, args) {
10832
11384
  detail: configRows.length === 0 ? "no overrides (all defaults)" : `${configRows.length} override(s), all valid`
10833
11385
  });
10834
11386
  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
- });
11387
+ {
11388
+ let lockHolder = "";
11389
+ try {
11390
+ lockHolder = node_fs.readFileSync(`${lockPath}/pid`, "utf8").trim();
11391
+ } catch {}
11392
+ const mine = lockHolder === String(process.pid);
11393
+ const foreignLock = node_fs.existsSync(lockPath) && !mine;
11394
+ const holderAlive = !!lockHolder && pidAlive(lockHolder);
11395
+ let holderCmd = "";
11396
+ try {
11397
+ holderCmd = node_fs.readFileSync(`${lockPath}/cmd`, "utf8").trim();
11398
+ } catch {}
11399
+ let status = "pass";
11400
+ let detail = "no stale locks";
11401
+ if (foreignLock && holderAlive) {
11402
+ status = "pass";
11403
+ detail = `lock held by live pid ${lockHolder}${holderCmd ? ` (${holderCmd.slice(0, 80)})` : " (parallel session)"} — normal in multi-session use`;
11404
+ } else if (foreignLock) {
11405
+ status = "warn";
11406
+ detail = `stale lock (pid ${lockHolder || "?"} dead) — ygl-trees db unlock or doctor --fix clears it`;
11407
+ }
11408
+ checks.push({
11409
+ name: "stale_lock",
11410
+ status,
11411
+ detail
11412
+ });
11413
+ }
10845
11414
  {
10846
11415
  const evalConds = db.all(`SELECT c.id, c.name, c.op, c.value, n.key AS node
10847
11416
  FROM node_conditions c JOIN nodes n ON n.id=c.node_id
@@ -10885,6 +11454,38 @@ function run(env, action, rest, args) {
10885
11454
  detail: `profile read failed: ${e?.message || e}`
10886
11455
  });
10887
11456
  }
11457
+ {
11458
+ const PHANTOM_SPECS = [
11459
+ {
11460
+ kind: "kb.add",
11461
+ table: "kb_notes",
11462
+ label: "kb"
11463
+ },
11464
+ {
11465
+ kind: "source.add",
11466
+ table: "sources",
11467
+ label: "source"
11468
+ },
11469
+ {
11470
+ kind: "evidence.add",
11471
+ table: "evidence",
11472
+ label: "evidence"
11473
+ }
11474
+ ];
11475
+ const phantoms = [];
11476
+ for (const spec of PHANTOM_SPECS) {
11477
+ const rows = db.all(`SELECT e.entity_id id, e.ts FROM events e
11478
+ WHERE e.kind='${spec.kind}'
11479
+ AND e.ts >= datetime('now', '-2 days')
11480
+ AND NOT EXISTS (SELECT 1 FROM ${spec.table} t WHERE t.id = e.entity_id)`);
11481
+ for (const r of rows) phantoms.push(`${spec.label} ${r.id} (${String(r.ts).slice(0, 16)})`);
11482
+ }
11483
+ checks.push({
11484
+ name: "write_integrity",
11485
+ status: phantoms.length ? "fail" : "pass",
11486
+ 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"
11487
+ });
11488
+ }
10888
11489
  const failed = checks.filter((c) => c.status === "fail").length;
10889
11490
  const warned = checks.filter((c) => c.status === "warn").length;
10890
11491
  const passed = checks.filter((c) => c.status === "pass").length;
@@ -10910,18 +11511,21 @@ function run(env, action, rest, args) {
10910
11511
  db.run("UPDATE meta SET value='0' WHERE key='index_dirty'");
10911
11512
  fixes.push("index dirty flag cleared (run: ygl-trees node index)");
10912
11513
  }
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 {}
11514
+ {
11515
+ const { pidAlive } = (init_db(), require_rolldown_runtime.__toCommonJS(db_exports));
11516
+ const lockDir = `${env.db.path}.ygllock`;
11517
+ let holder = "";
11518
+ try {
11519
+ holder = node_fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
11520
+ } catch {}
11521
+ if (node_fs.existsSync(lockDir) && holder !== String(process.pid) && !pidAlive(holder)) try {
11522
+ node_fs.rmSync(lockDir, {
11523
+ recursive: true,
11524
+ force: true
11525
+ });
11526
+ fixes.push("stale lock removed (holder pid dead)");
11527
+ } catch {}
11528
+ }
10925
11529
  if (fixes.length) for (const f of fixes) ok(ctx, ` fixed: ${f}`);
10926
11530
  else out(ctx, paint(C.dim, " (--fix: no auto-fixable issues found)", ctx.color));
10927
11531
  }
@@ -11046,10 +11650,11 @@ ${paint(C.bold, "research", true)} collect → screen → extract → claim
11046
11650
  source add --title --url [--abstract --content --relevance --confidence --query]
11047
11651
  source fetch <url> fetch page, extract title/meta/text (zero side-effect on failure)
11048
11652
  source list | search "<q>" | show <id> | rm <id>
11049
- evidence add <sourceId> --quote "..." [--note --locator]
11653
+ evidence add <sourceId> --quote "..." | --file <path> [--note --locator]
11050
11654
  evidence list
11051
11655
  claim add "<text>" [--confidence A|B|C]
11052
11656
  claim link <claimId> <evidenceId> [--stance supports|refutes]
11657
+ --by-source <claimId> <sourceId>: link ALL evidence under a source
11053
11658
  claim resolve <claimId> --as supported|refuted|contested [--note]
11054
11659
  claim list
11055
11660
 
@@ -11105,6 +11710,7 @@ ${paint(C.bold, "tree / node", true)} tech-tree node model
11105
11710
  node validate cycles, dangling edges, dirty index
11106
11711
  node export [--out file.json] portable JSON
11107
11712
  node import --file <file.json> [--tree name] [--merge] — strict, transactional
11713
+ node timeline [tree] per-tree growth history (add/link/unlock events)
11108
11714
 
11109
11715
  Writes are idempotent (claim/evidence/query/kb/job dedupe by content
11110
11716
  hash); pass --force to keep a deliberate duplicate.
@@ -11145,6 +11751,7 @@ async function main(argv) {
11145
11751
  const group = args._[0];
11146
11752
  const action = args._[1];
11147
11753
  if (group === "webui" && (action === "serve" || action === "export")) process.env.YGL_SERVE = "1";
11754
+ if (group === "db" && action === "unlock") process.env.YGL_SERVE = "1";
11148
11755
  if (args.flags.version === true || group === "version") {
11149
11756
  out(ctx, appVersion());
11150
11757
  return 0;
@@ -11264,8 +11871,13 @@ async function main(argv) {
11264
11871
  alive = err.code === "EPERM";
11265
11872
  }
11266
11873
  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`);
11874
+ let holderCmd = "";
11875
+ try {
11876
+ holderCmd = require("node:fs").readFileSync(require("node:path").join(lock, "cmd"), "utf8").trim();
11877
+ } catch {}
11878
+ out(ctx, ` lock held by live process pid ${holder}${holderCmd ? `\n holder command: ${holderCmd}` : " (parallel CLI session — serve never takes the write lock)"}`);
11879
+ out(ctx, ` reads still work; the lock serializes CLI writers and self-heals when pid ${holder} exits`);
11880
+ out(ctx, ` force through (know what you are doing): YGL_TEST=1 · inspect/clear: ygl-trees db unlock`);
11269
11881
  } else {
11270
11882
  out(ctx, ` orphaned lock (pid ${holder} is dead) — removing: ${lock}`);
11271
11883
  try {
@@ -11289,7 +11901,9 @@ async function main(argv) {
11289
11901
  db?.close();
11290
11902
  } catch {}
11291
11903
  try {
11292
- (init_db(), require_rolldown_runtime.__toCommonJS(db_exports)).DB.releaseLockFor(file);
11904
+ const { DB } = (init_db(), require_rolldown_runtime.__toCommonJS(db_exports));
11905
+ if (db?.ownsLock) DB.releaseLockFor(file);
11906
+ else if (DB.lockHolder(file) === String(process.pid)) DB.releaseLockFor(file);
11293
11907
  } catch {}
11294
11908
  });
11295
11909
  process.on("SIGINT", () => shutdown.interrupt(0));
@@ -11584,13 +12198,14 @@ function generateLlmsManifest() {
11584
12198
  ["db export <file>", "full backup"],
11585
12199
  ["db sync pg <url>", "one-way mirror to PostgreSQL"],
11586
12200
  ["db migrate pg <url> [--fresh]", "full-fidelity graduation (with type preflight)"],
12201
+ ["db unlock [--force]", "clear stale .ygllock (pid-liveness checked; refuses live holders)"],
11587
12202
  ["db reset", "delete database"]
11588
12203
  ]
11589
12204
  ],
11590
12205
  [
11591
12206
  "doctor",
11592
- "Health check (9 items)",
11593
- [["doctor [--fix]", "diagnose (+ auto-fix: dirty index, stale lock)"]]
12207
+ "Health check (10 items)",
12208
+ [["doctor [--fix]", "diagnose (+ auto-fix: dirty index, dead stale lock, phantom writes)"]]
11594
12209
  ],
11595
12210
  [
11596
12211
  "config",