ygl-trees 0.77.3 → 0.79.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,10 +74,44 @@ function run(env, action, rest, args) {
74
74
  .then(fn)
75
75
  .then(print)
76
76
  .catch((e) => {
77
- (0, fmt_1.bad)(ctx, e?.message || String(e));
77
+ (0, fmt_1.bad)(ctx, enrichNodeNotFound(e?.message || String(e)));
78
78
  return 1;
79
79
  });
80
80
  }
81
+ /** Report ③-3: selection changes are now first-class events — a parallel
82
+ * session flipping current_tree/current_project becomes observable
83
+ * (agent brief surfaces it; db timeline shows the history). Session
84
+ * identity comes from YGL_SESSION (agents that care can set it). */
85
+ function logSelectionEvent(database, kind, name) {
86
+ try {
87
+ database.run("INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,?)", [
88
+ `ev${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
89
+ new Date().toISOString(),
90
+ process.env.YGL_SESSION || "cli",
91
+ kind,
92
+ kind === "tree.use" ? "tree" : "project",
93
+ name,
94
+ JSON.stringify({ name, session: process.env.YGL_SESSION || "cli" }),
95
+ "info",
96
+ ]);
97
+ }
98
+ catch { /* telemetry only — never block the selection */
99
+ }
100
+ }
101
+ /** Report ③ enrichment: "node not found: X" earned a cross-tree lookup —
102
+ * if a parallel session flipped current_tree, the key usually exists in
103
+ * another tree; name it instead of making the user blind-list. */
104
+ function enrichNodeNotFound(msg) {
105
+ const m = /^node not found: (.+)$/.exec(msg);
106
+ if (!m)
107
+ return msg;
108
+ const others = trees.nodeInOtherTrees(m[1], tid);
109
+ if (others.length) {
110
+ const names = others.map((o) => `'${o.tree}'`).join(", ");
111
+ return `${msg}\n key exists in other tree(s): ${names} — pin it: ygl-trees ${env.group} ... -t ${others[0].tree}`;
112
+ }
113
+ return `${msg} (current tree: '${tree?.name ?? "?"}' — node list to inspect, -t <tree> to switch)`;
114
+ }
81
115
  // ---------------------------------------------------------------- tree group
82
116
  if (env.group === "tree") {
83
117
  const sub = action; // new | list | use
@@ -91,6 +125,7 @@ function run(env, action, rest, args) {
91
125
  const proj = pid ? db.get("SELECT id FROM projects WHERE name=?", [pid]) : null;
92
126
  return runTreeWrite((ds) => treeWrites.treeCreate(ds, name, proj?.id || ""), (t) => {
93
127
  db.metaSet("current_tree", t.name);
128
+ logSelectionEvent(db, "tree.use", t.name);
94
129
  (0, fmt_1.ack)(ctx, t, t?.duplicate === true
95
130
  ? `tree exists, selected: ${t.name} (duplicate, not re-created)`
96
131
  : `tree created and selected: ${t.name}`);
@@ -114,6 +149,7 @@ function run(env, action, rest, args) {
114
149
  return 1;
115
150
  }
116
151
  db.metaSet("current_tree", name);
152
+ logSelectionEvent(db, "tree.use", name);
117
153
  (0, fmt_1.ack)(ctx, { tree: name }, `now using tree: ${name}`);
118
154
  return 0;
119
155
  }
@@ -300,56 +336,52 @@ function run(env, action, rest, args) {
300
336
  }
301
337
  if (sub === "can") {
302
338
  if (!key) {
303
- (0, fmt_1.bad)(ctx, "usage: node can <key> [--context ctx] [--state k=v] [--eval]");
339
+ (0, fmt_1.bad)(ctx, "usage: node can <key...> [--context ctx] [--state k=v] [--state-file file] [--eval]");
304
340
  return 2;
305
341
  }
306
- const state = parseState(args);
307
- const ctxName = flagStr("context", "default");
308
- const r = trees.canUnlock(tid, key, ctxName, state);
309
- const evalConds = flagBool("eval")
310
- ? trees.condList(tid, key).filter((c) => /^eval(-choice|-score)?$/.test(String(c.op)))
311
- : [];
312
- if (!evalConds.length) {
313
- if (ctx.json) {
314
- console.log(JSON.stringify(r, null, 2));
315
- return 0;
316
- }
317
- (0, fmt_1.head)(ctx, `can unlock: ${r.key}`);
318
- (0, fmt_1.out)(ctx, ` ${r.ok ? (0, fmt_1.paint)(fmt_1.C.green, "✓ 可解锁", ctx.color) : (0, fmt_1.paint)(fmt_1.C.red, "✗ 不可解锁", ctx.color)}`);
319
- if (r.missing_required.length) {
320
- (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 必检未满足", ctx.color));
321
- for (const m of r.missing_required)
322
- (0, fmt_1.out)(ctx, ` - ${m}`);
323
- }
324
- if (r.blocked_by.length) {
325
- (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 被排斥", ctx.color));
326
- for (const b of r.blocked_by)
327
- (0, fmt_1.out)(ctx, ` - ${b}`);
328
- }
329
- if (r.missing_optional.length) {
330
- (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, " 可选未满足(不阻塞)", ctx.color));
331
- for (const m of r.missing_optional)
332
- (0, fmt_1.out)(ctx, ` - ${m}`);
333
- }
334
- if (r.cost_multiplier !== 1)
335
- (0, fmt_1.out)(ctx, ` 成本系数 ${r.cost_multiplier}`);
336
- return 0;
342
+ // 0.79.1 (R297 user report): batch mode — multiple keys judged in ONE
343
+ // process (probes used to pay ~1s cold start per node), plus
344
+ // --state-file for JSON state bags (shared or per-node).
345
+ const keys = [...new Set(rest.filter(Boolean))];
346
+ const sharedState = parseState(args);
347
+ let perKeyBags = null;
348
+ try {
349
+ perKeyBags = loadStateFile(args, sharedState);
337
350
  }
338
- // R8: decision-model boolean evaluation for op=eval conditions.
339
- // Offline-safe: no token / network failure → pending (rc=0).
340
- // Credential/endpoint priority (user ruling): CONFIG first —
341
- // decision_model_llm_token / decision_model_llm_api_base — then env
342
- // AI_GATEWAY_API_KEY / ~/.vercel.ai-gateway.env, then the default
343
- // Vercel gateway. Thresholds + offline are config-tunable too;
344
- // env YGL_EVAL_OFFLINE still wins over config eval.offline.
351
+ catch (e) {
352
+ const msg = e?.message || String(e);
353
+ (0, fmt_1.bad)(ctx, msg);
354
+ return /^usage:/.test(msg) ? 2 : /must contain a JSON object/.test(msg) ? 2 : 1;
355
+ }
356
+ const stateFor = (k) => perKeyBags ? (perKeyBags[k] && typeof perKeyBags[k] === "object" && !Array.isArray(perKeyBags[k]) ? perKeyBags[k] : {}) : sharedState;
357
+ const ctxName = flagStr("context", "default");
358
+ // eval-family config read once per run (R8 credential priority:
359
+ // config decision_model_llm_* first, then env AI_GATEWAY_API_KEY)
345
360
  const offline = !!process.env.YGL_EVAL_OFFLINE || (0, config_1.configGetBool)(env.db, "eval.offline");
346
361
  const cfgToken = (0, config_1.configGet)(env.db, "decision_model_llm_token").trim();
347
362
  const token = offline ? "" : cfgToken || (0, eval_1.evalToken)();
348
363
  const endpoint = (0, config_1.configGet)(env.db, "decision_model_llm_api_base").trim() || eval_1.DEFAULT_ENDPOINT;
349
364
  const passT = (0, config_1.configGetNumber)(env.db, "eval.pass_threshold");
350
365
  const failT = (0, config_1.configGetNumber)(env.db, "eval.fail_threshold");
351
- const contextPkg = trees.evalContext(tid, key, state, ctxName);
352
- return (async () => {
366
+ /** Judge one node: canUnlock (+ eval gates when --eval). Never throws —
367
+ * hard failures come back as {key, error}. */
368
+ const judgeOne = async (k) => {
369
+ let r;
370
+ try {
371
+ r = trees.canUnlock(tid, k, ctxName, stateFor(k));
372
+ }
373
+ catch (e) {
374
+ return { key: k, error: enrichNodeNotFound(e?.message || String(e)) };
375
+ }
376
+ const state = stateFor(k);
377
+ const evalConds = flagBool("eval")
378
+ ? trees.condList(tid, k).filter((c) => /^eval(-choice|-score)?$/.test(String(c.op)))
379
+ : [];
380
+ if (!evalConds.length)
381
+ return r;
382
+ // R8: decision-model evaluation for op=eval conditions.
383
+ // Offline-safe: no token / network failure → pending (rc=0).
384
+ const contextPkg = trees.evalContext(tid, k, state, ctxName);
353
385
  const evaluated = [];
354
386
  let usageIn = 0, usageOut = 0; // R10.4: token spend per run
355
387
  for (const c of evalConds) {
@@ -398,7 +430,7 @@ function run(env, action, rest, args) {
398
430
  r.ok = r.missing_required.length === 0 && r.blocked_by.length === 0 && pendingList.length === 0;
399
431
  try {
400
432
  const run = process.env.YGL_RUN_ID || "";
401
- db.run("INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,'info')", [(0, text_1.shortId)(["eval.gate", "node", key], 12), (0, text_1.nowIso)(), "cli", "eval.gate", "node", key,
433
+ db.run("INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,'info')", [(0, text_1.shortId)(["eval.gate", "node", k], 12), (0, text_1.nowIso)(), "cli", "eval.gate", "node", k,
402
434
  JSON.stringify({
403
435
  conds: evaluated.map((e) => ({ name: e.name, type: e.type, outcome: e.outcome, p: e.p ?? null, score: e.score ?? null })),
404
436
  usage: { inputTokens: usageIn, outputTokens: usageOut },
@@ -406,37 +438,90 @@ function run(env, action, rest, args) {
406
438
  })]);
407
439
  }
408
440
  catch { /* audit best-effort */ }
441
+ return r;
442
+ };
443
+ const batch = keys.length > 1;
444
+ return (async () => {
445
+ const results = [];
446
+ for (const k of keys)
447
+ results.push(await judgeOne(k));
409
448
  if (ctx.json) {
410
- console.log(JSON.stringify(r, null, 2));
411
- return 0;
412
- }
413
- (0, fmt_1.head)(ctx, `can unlock: ${r.key}`);
414
- (0, fmt_1.out)(ctx, ` ${r.ok ? (0, fmt_1.paint)(fmt_1.C.green, "✓ 可解锁", ctx.color) : pendingList.length ? (0, fmt_1.paint)(fmt_1.C.yellow, "… 待判定(决策模型 pending)", ctx.color) : (0, fmt_1.paint)(fmt_1.C.red, "✗ 不可解锁", ctx.color)}`);
415
- if (r.missing_required.length) {
416
- (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 必检未满足", ctx.color));
417
- for (const m of r.missing_required)
418
- (0, fmt_1.out)(ctx, ` - ${m}`);
449
+ // single key → object (frozen shape); batch → array
450
+ console.log(JSON.stringify(batch ? results : results[0], null, 2));
451
+ return results.some((r) => r.error) ? 1 : 0;
419
452
  }
420
- if (pendingList.length) {
421
- (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 决策模型 pending(灰带/未配置,非终态)", ctx.color));
422
- for (const p of pendingList)
423
- (0, fmt_1.out)(ctx, ` - ${p}`);
424
- }
425
- if (r.blocked_by.length) {
426
- (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 被排斥", ctx.color));
427
- for (const b of r.blocked_by)
428
- (0, fmt_1.out)(ctx, ` - ${b}`);
453
+ if (!batch) {
454
+ const r = results[0];
455
+ if (r.error) {
456
+ (0, fmt_1.bad)(ctx, r.error);
457
+ return 1;
458
+ }
459
+ if (!r.evaluated) {
460
+ (0, fmt_1.head)(ctx, `can unlock: ${r.key}`);
461
+ (0, fmt_1.out)(ctx, ` ${r.ok ? (0, fmt_1.paint)(fmt_1.C.green, "✓ 可解锁", ctx.color) : (0, fmt_1.paint)(fmt_1.C.red, "✗ 不可解锁", ctx.color)}`);
462
+ if (r.missing_required.length) {
463
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 必检未满足", ctx.color));
464
+ for (const m of r.missing_required)
465
+ (0, fmt_1.out)(ctx, ` - ${m}`);
466
+ }
467
+ if (r.blocked_by.length) {
468
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 被排斥", ctx.color));
469
+ for (const b of r.blocked_by)
470
+ (0, fmt_1.out)(ctx, ` - ${b}`);
471
+ }
472
+ if (r.missing_optional.length) {
473
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, " 可选未满足(不阻塞)", ctx.color));
474
+ for (const m of r.missing_optional)
475
+ (0, fmt_1.out)(ctx, ` - ${m}`);
476
+ }
477
+ if (r.cost_multiplier !== 1)
478
+ (0, fmt_1.out)(ctx, ` 成本系数 ${r.cost_multiplier}`);
479
+ return 0;
480
+ }
481
+ (0, fmt_1.head)(ctx, `can unlock: ${r.key}`);
482
+ (0, fmt_1.out)(ctx, ` ${r.ok ? (0, fmt_1.paint)(fmt_1.C.green, "✓ 可解锁", ctx.color) : r.pending.length ? (0, fmt_1.paint)(fmt_1.C.yellow, "… 待判定(决策模型 pending)", ctx.color) : (0, fmt_1.paint)(fmt_1.C.red, "✗ 不可解锁", ctx.color)}`);
483
+ if (r.missing_required.length) {
484
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 必检未满足", ctx.color));
485
+ for (const m of r.missing_required)
486
+ (0, fmt_1.out)(ctx, ` - ${m}`);
487
+ }
488
+ if (r.pending.length) {
489
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 决策模型 pending(灰带/未配置,非终态)", ctx.color));
490
+ for (const p of r.pending)
491
+ (0, fmt_1.out)(ctx, ` - ${p}`);
492
+ }
493
+ if (r.blocked_by.length) {
494
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.bold, " 被排斥", ctx.color));
495
+ for (const b of r.blocked_by)
496
+ (0, fmt_1.out)(ctx, ` - ${b}`);
497
+ }
498
+ if (r.missing_optional.length) {
499
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, " 可选未满足(不阻塞)", ctx.color));
500
+ for (const m of r.missing_optional)
501
+ (0, fmt_1.out)(ctx, ` - ${m}`);
502
+ }
503
+ for (const e of r.evaluated)
504
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` eval ${e.name}: P=${e.p ?? "-"} → ${e.outcome}${e.reason ? ` (${e.reason})` : ""}`, ctx.color));
505
+ if (r.cost_multiplier !== 1)
506
+ (0, fmt_1.out)(ctx, ` 成本系数 ${r.cost_multiplier}`);
507
+ return 0;
429
508
  }
430
- if (r.missing_optional.length) {
431
- (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, " 可选未满足(不阻塞)", ctx.color));
432
- for (const m of r.missing_optional)
433
- (0, fmt_1.out)(ctx, ` - ${m}`);
509
+ // batch human view: one compact line per node (probe-friendly)
510
+ const via = perKeyBags ? "per-node --state-file" : Object.keys(sharedState).length ? "shared state" : "";
511
+ (0, fmt_1.head)(ctx, `can unlock × ${results.length}${via ? ` (${via})` : ""}`);
512
+ for (const r of results) {
513
+ if (r.error) {
514
+ (0, fmt_1.out)(ctx, ` ${(0, fmt_1.paint)(fmt_1.C.red, "✗", ctx.color)} ${r.key} ${r.error}`);
515
+ continue;
516
+ }
517
+ const mark = r.ok ? (0, fmt_1.paint)(fmt_1.C.green, "✓", ctx.color) : r.pending?.length ? (0, fmt_1.paint)(fmt_1.C.yellow, "…", ctx.color) : (0, fmt_1.paint)(fmt_1.C.red, "✗", ctx.color);
518
+ const blockers = [
519
+ ...(r.missing_required || []).map((m) => m),
520
+ ...(r.blocked_by || []).map((b) => `excluded:${b}`),
521
+ ].join(", ");
522
+ (0, fmt_1.out)(ctx, ` ${mark} ${r.key}${r.pending?.length ? " (eval pending)" : ""}${blockers ? ` — ${blockers}` : ""}`);
434
523
  }
435
- for (const e of evaluated)
436
- (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` eval ${e.name}: P=${e.p ?? "-"} → ${e.outcome}${e.reason ? ` (${e.reason})` : ""}`, ctx.color));
437
- if (r.cost_multiplier !== 1)
438
- (0, fmt_1.out)(ctx, ` 成本系数 ${r.cost_multiplier}`);
439
- return 0;
524
+ return results.some((r) => r.error) ? 1 : 0;
440
525
  })().catch((e) => {
441
526
  (0, fmt_1.bad)(ctx, e?.message || String(e));
442
527
  return 1;
@@ -444,6 +529,20 @@ function run(env, action, rest, args) {
444
529
  }
445
530
  if (sub === "ready") {
446
531
  const state = parseState(args);
532
+ try {
533
+ // 0.79.1: --state-file flat bag works here too (per-node shape makes
534
+ // no sense for ready — it judges the whole tree, so flat-only).
535
+ const bags = loadStateFile(args, state);
536
+ if (bags) {
537
+ (0, fmt_1.bad)(ctx, "--state-file per-node shape (key→object) only applies to batch `node can k1 k2 …`; ready wants a flat counter object");
538
+ return 2;
539
+ }
540
+ }
541
+ catch (e) {
542
+ const msg = e?.message || String(e);
543
+ (0, fmt_1.bad)(ctx, msg);
544
+ return /^usage:/.test(msg) || /must contain a JSON object/.test(msg) ? 2 : 1;
545
+ }
447
546
  const ctxName = flagStr("context", "default");
448
547
  if (!flagBool("eval")) {
449
548
  const rows = trees.ready(tid, ctxName, state);
@@ -549,7 +648,14 @@ function run(env, action, rest, args) {
549
648
  (0, fmt_1.bad)(ctx, "usage: node path <key>");
550
649
  return 2;
551
650
  }
552
- const p = trees.pathTo(tid, key, flagStr("context", "default"));
651
+ let p;
652
+ try {
653
+ p = trees.pathTo(tid, key, flagStr("context", "default"));
654
+ }
655
+ catch (e) {
656
+ (0, fmt_1.bad)(ctx, enrichNodeNotFound(e?.message || String(e)));
657
+ return 1;
658
+ }
553
659
  if (ctx.json) {
554
660
  console.log(JSON.stringify(p, null, 2));
555
661
  return 0;
@@ -610,7 +716,7 @@ function run(env, action, rest, args) {
610
716
  if (sub === "show") {
611
717
  const n = trees.nodeGet(tid, key);
612
718
  if (!n) {
613
- (0, fmt_1.bad)(ctx, `node not found: ${key}`);
719
+ (0, fmt_1.bad)(ctx, enrichNodeNotFound(`node not found: ${key}`));
614
720
  return 1;
615
721
  }
616
722
  const st = trees.nodeStateOf(tid, key);
@@ -704,11 +810,39 @@ function run(env, action, rest, args) {
704
810
  return runTreeWrite((ds) => treeWrites.importTree(ds, spec, { name: flagStr("tree") || undefined, merge: flagBool("merge") }), (r) => {
705
811
  (0, fmt_1.ack)(ctx, { tree: r.tree.name, nodes: r.nodes, edges: r.edges, conditions: r.conditions }, `imported tree ${r.tree.name}: ${r.nodes} nodes, ${r.edges} edges, ${r.conditions} conditions`);
706
812
  db.metaSet("current_tree", r.tree.name);
813
+ logSelectionEvent(db, "tree.use", r.tree.name);
707
814
  if (!ctx.json)
708
815
  (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, ` index rebuilt; now using tree: ${r.tree.name}`, ctx.color));
709
816
  return 0;
710
817
  });
711
818
  }
819
+ // Report ⑤: tree-scoped growth timeline — workspace-level `db timeline`
820
+ // already existed; this projects a single tree's unlock/add milestones.
821
+ if (sub === "timeline") {
822
+ const rows = db.all(`SELECT e.ts, e.kind, e.payload FROM events e
823
+ WHERE e.entity = 'node'
824
+ AND e.entity_id IN (SELECT id FROM nodes WHERE tree_id = ?)
825
+ ORDER BY e.ts DESC LIMIT 30`, [tid]);
826
+ if (ctx.json) {
827
+ console.log(JSON.stringify(rows.map((r) => ({
828
+ ts: r.ts, kind: r.kind, ...JSON.parse(r.payload || "{}"),
829
+ })), null, 2));
830
+ return 0;
831
+ }
832
+ (0, fmt_1.head)(ctx, `Timeline — tree '${tree.name}' (last ${rows.length})`);
833
+ for (const r of rows) {
834
+ let detail = "";
835
+ try {
836
+ const p = JSON.parse(r.payload || "{}");
837
+ detail = [p.key, p.name ? String(p.name).slice(0, 30) : ""].filter(Boolean).join(" ");
838
+ }
839
+ catch { /* */ }
840
+ (0, fmt_1.out)(ctx, ` ${String(r.ts).slice(0, 16)} ${r.kind.padEnd(14)} ${detail}`);
841
+ }
842
+ if (!rows.length)
843
+ (0, fmt_1.out)(ctx, (0, fmt_1.paint)(fmt_1.C.dim, " (no node events yet — unlock/add to start the history)", ctx.color));
844
+ return 0;
845
+ }
712
846
  if (sub === "rm") {
713
847
  if (!key) {
714
848
  (0, fmt_1.bad)(ctx, "usage: node rm <key>");
@@ -737,3 +871,33 @@ function parseState(args) {
737
871
  }
738
872
  return out;
739
873
  }
874
+ /**
875
+ * 0.79.1 (R297 user report): `--state-file <path.json>` loads the state bag
876
+ * from JSON instead of inline `--state k=v`. Two accepted shapes:
877
+ * flat shared {"y_imported": 12} → merged over --state, all keys share it
878
+ * per-node map {"node-a": {"y": 1}, ...} → each judged node gets its own bag
879
+ * (disambiguated by "every value is a plain object"). Returns the per-node map
880
+ * when shape B, null when merged into `shared`. Throws on unreadable/invalid.
881
+ */
882
+ function loadStateFile(args, shared) {
883
+ const sf = args.flags["state-file"];
884
+ if (sf === undefined)
885
+ return null; // flag absent → no bags (plain can/ready)
886
+ if (sf === true || sf === "")
887
+ throw new Error("usage: --state-file <path.json>");
888
+ let parsed;
889
+ try {
890
+ parsed = JSON.parse(require("node:fs").readFileSync(String(sf), "utf8"));
891
+ }
892
+ catch (e) {
893
+ throw new Error(`cannot read --state-file ${sf}: ${e?.message || e}`);
894
+ }
895
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
896
+ throw new Error("--state-file must contain a JSON object (flat counters, or per-node key→object map)");
897
+ }
898
+ const vals = Object.values(parsed);
899
+ if (vals.length && vals.every((v) => v && typeof v === "object" && !Array.isArray(v)))
900
+ return parsed;
901
+ Object.assign(shared, parsed);
902
+ return null;
903
+ }
@@ -44,6 +44,14 @@ class TreeStore {
44
44
  nodeGet(treeId, keyOrId) {
45
45
  return this.db.get("SELECT * FROM nodes WHERE tree_id=? AND (key=? OR id=?)", [treeId, keyOrId, keyOrId]);
46
46
  }
47
+ /** Which OTHER trees contain this key? (Report ③: a parallel session
48
+ * flipped current_tree; `node can p_dates` then said just "node not
49
+ * found" — the key existed the whole time, in another tree. This lookup
50
+ * lets the error name the tree instead of forcing a blind node list.) */
51
+ nodeInOtherTrees(keyOrId, excludeTreeId) {
52
+ return this.db.all(`SELECT t.name AS tree, n.key FROM nodes n JOIN trees t ON t.id=n.tree_id
53
+ WHERE n.tree_id<>? AND (n.key=? OR n.id=?) LIMIT 5`, [excludeTreeId, keyOrId, keyOrId]);
54
+ }
47
55
  /** Runtime state for one node (default context). Separate from nodeGet on
48
56
  * purpose: nodeGet feeds WRITE paths, and a context join there could return
49
57
  * one row per context — silently wrong for link/unlink/rm. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ygl-trees",
3
- "version": "0.77.3",
3
+ "version": "0.79.1",
4
4
  "description": "猿光 · 科技树 — database-centric CLI for research → production → knowledge reuse, built for AI agents.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/apps/cli/index.js",
@@ -26,8 +26,8 @@
26
26
  "reflect-metadata": "^0.2.2",
27
27
  "typeorm": "^1.1.2",
28
28
  "@deepseek-ai/cordis": "~4.0.5-alpha.2",
29
- "@deepseek-ai/cosmokit": "~1.8.6-alpha.2",
30
29
  "@deepseek-ai/cordis-plugin-timer": "~1.1.7-alpha.2",
30
+ "@deepseek-ai/cosmokit": "~1.8.6-alpha.2",
31
31
  "@deepseek-ai/schemastery": "~3.18.5-alpha.2"
32
32
  },
33
33
  "devDependencies": {
@@ -49,7 +49,8 @@
49
49
  ],
50
50
  "exclude": [
51
51
  "node_modules",
52
- "dist"
52
+ "dist",
53
+ "tmp"
53
54
  ],
54
55
  "env": {
55
56
  "YGL_TEST": "1"
@@ -9,7 +9,7 @@ description: >-
9
9
  触发词:ygl-trees 入门、初始化、quickstart、工作区、退出码、exit code、备份、export、
10
10
  PostgreSQL、sync、migrate、镜像、job、tick、定时、doctor、健康检查、任务、task、
11
11
  关联、rel、插件、plugin、WebUI、webui、仪表盘、dashboard、bench、基准、bundle。
12
- version: 0.73.0
12
+ version: 0.79.0
13
13
  tags: ygl-trees,quickstart,backup,postgres,schedule,doctor,plugin,webui,bench
14
14
  ---
15
15
 
@@ -56,25 +56,35 @@ ygl-trees llms --json # 结构化 JSON
56
56
  ## 2b. 运维命令速查
57
57
 
58
58
  ```bash
59
- ygl-trees doctor # 9 项健康检查
60
- ygl-trees doctor --fix # 一键安全修复(脏索引标记 + 陈旧锁清理)
59
+ ygl-trees doctor # 10 项健康检查(含锁 pid 存活、幻影写入对账)
60
+ ygl-trees doctor --fix # 一键安全修复(脏索引 + 死 pid 陈旧锁;活 holder 不动)
61
+ ygl-trees db unlock # 清残留 .ygllock(死 pid 直清;活 holder 回显其命令行并拒绝,--force 强清)
61
62
  ygl-trees kb rm <id> # 删除笔记(与 skill rm 对称)
62
63
  ygl-trees webui serve --host 0.0.0.0 --port 8642 # LAN/WSL 可访问
64
+ ygl-trees webui serve --help # 只打印用法不启服务(0.79.1 短路)
63
65
  ```
64
66
 
65
67
  PG 迁移预检:`db migrate pg` 前自动逐表扫描 INTEGER 列类型——非整数值会**点名 table.column rowid=N** 而非迁移时才爆。
66
68
 
67
- ## 2c. DB 锁行为(0.68)
69
+ ## 2b2. 多会话安全(0.79)
68
70
 
69
- - CLI 短命令:进程级锁(保护写窗口,退出自动释放)
70
- - webui serve:无锁(WAL 并发安全,与 CLI 共存)
71
+ - `tree use` / `project use` 落 `tree.use` / `project.use` 事件(`YGL_SESSION` 会话标识)
72
+ - `agent brief` 的 `selection_changed`:最近 10 分钟选择变更;跨会话变更置顶提示带 `-t`/`-p`
73
+ - `kb add` 回执自检(`verified:true`)+ 工作区回显(防写错 home 的"丢笔记"事故)
74
+ - `research claim link <cid> <sid> --by-source`:来源下全部证据一次挂齐;误传来源 id 会列出其证据 id
75
+
76
+ ## 2c. DB 锁行为(0.68 → 0.79.1)
77
+
78
+ - CLI 短命令:进程级锁(保护写窗口,退出自动释放);锁文件记录**持有者命令行**,撞锁/`db unlock`/`doctor` 都回显真实持有者身份
79
+ - webui serve:无锁(WAL 并发安全,与 CLI 共存;从不取写锁)
71
80
  - 死锁自愈:下一进程探测持有者 PID,死则即时清(无需手动 rmdir)
81
+ - 无锁进程(serve / `db unlock` 自身)退出绝不误清并行会话的活锁(0.79.1 所有权守卫)
72
82
  - YGL_TEST=1:全旁路(测试并行)
73
83
 
74
84
  ## 3. 健康检查与任务
75
85
 
76
86
  ```bash
77
- ygl-trees doctor # 9 项健康检查(schema/索引/FTS/重复/孤儿/config/锁/eval信封/插件)
87
+ ygl-trees doctor # 10 项健康检查(schema/索引/FTS/重复/孤儿/config/锁存活/eval信封/插件/幻影写入)
78
88
  ygl-trees task add "<标题>" # 建任务(todo 起步)
79
89
  ygl-trees task list # 按状态分组
80
90
  ygl-trees task done <id> # 勾销(全程留痕)
@@ -120,6 +130,7 @@ ygl-trees db export <file.sql> # 全部数据表 + 行数摘要
120
130
  ygl-trees db sync pg <url> # 可刷新 PK-only 镜像
121
131
  ygl-trees db migrate pg <url> [--fresh] # 一次性完整保真迁移
122
132
  ygl-trees job add <title> -- "<cmd>" --every 60 # cron 每分钟跑 job tick
133
+ ygl-trees job add "闸门探针" -- "node probe.mjs" --every 30 # 树节奏化监控:任意命令皆可(0.79.1 实证)
123
134
  ```
124
135
 
125
136
  禁止双后端同写:ygl-trees 永远只写 SQLite,PG 是单向出口。
@@ -57,5 +57,7 @@ ygl-trees produce export <reportId> report.md
57
57
 
58
58
  - 先 `agent brief --json` 看 next_actions(它会告诉你缺来源/缺证据/缺断言)
59
59
  - 每条断言至少一条证据再进报告;contested 的要么补证据要么 resolve
60
+ - 预测断言(`[预测·N天]`)定期 `research predict audit` 回测:本地判据自动测;`--state <file|url>`
61
+ 桥接外部计数(裸键名后缀匹配 `counts.y_imported`);已 resolve 的默认隐藏(`--all` 查看终态表)
60
62
  - `--json` 输出可直接 parse 拿 id;列表命令字段:source(id,title,url,relevance,…)、
61
63
  claim(id,text,status,confidence,sup,ref)、evidence(id,source,locator,quote,used_by)
@@ -38,6 +38,7 @@ ygl-trees node index && ygl-trees node validate
38
38
 
39
39
  ```bash
40
40
  ygl-trees node can steel --state "lab_level=3,has_eng=1" # 三类失败分开列+成本系数
41
+ ygl-trees node can a b c --state-file bags.json # 批量:一进程测 N 节点(探针 1s×N → 1s)
41
42
  ygl-trees node ready # 当前可解锁集合
42
43
  ygl-trees node path steel # 解锁顺序+累计成本(目标恒最后)
43
44
  ygl-trees node ancestors bronze # 闭包索引直查,depth=1 是直接父
@@ -46,6 +47,7 @@ ygl-trees node unlock mining && ygl-trees node lock mining
46
47
 
47
48
  - 写入时环守卫:门控边(prereq/unlock)成环当场拒绝
48
49
  - `node can` 输出:missing_required(阻塞)/ blocked_by(被排斥)/ missing_optional(不阻塞)
50
+ - `--state-file` 两种形状:扁平 `{"y_imported":40}` 全节点共享;按节点 `{"a":{"y_imported":40}}` 各取各袋;多键 `--json` 返回数组(0.79.1)
49
51
 
50
52
  ## 3. 迁移与保真
51
53