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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.parsePrediction = parsePrediction;
14
+ exports.loadStateSource = loadStateSource;
14
15
  exports.measureCriterion = measureCriterion;
15
16
  exports.measureNpmVersions = measureNpmVersions;
16
17
  exports.auditPredictions = auditPredictions;
@@ -31,25 +32,89 @@ function parsePrediction(text, created) {
31
32
  }
32
33
  return { horizon, deadline: "" };
33
34
  }
34
- /** Locally measurable criterion matchers. Null → manual. */
35
- function measureCriterion(crit, db) {
36
- // 树≥N / kb≥N — workspace counts (pure local truth)
37
- const tree = crit.match(/树\s*≥\s*(\d+)/);
38
- const kb = crit.match(/kb\s*≥\s*(\d+)/);
39
- if (!tree && !kb)
35
+ /** 0.79.1 --state bridge: load a flat counter map from a JSON file path or an
36
+ * http(s) URL (e.g. a DSH API endpoint). Nested objects flatten to dot keys
37
+ * ({counts:{y:12}} → "counts.y"); only finite numbers are kept. Null on any
38
+ * failure — the audit degrades those criteria back to manual. */
39
+ async function loadStateSource(src, fetchImpl = fetch) {
40
+ try {
41
+ let text;
42
+ if (/^https?:\/\//.test(src)) {
43
+ const ctl = new AbortController();
44
+ const t = setTimeout(() => ctl.abort(), 3000);
45
+ const r = await fetchImpl(src, { signal: ctl.signal });
46
+ clearTimeout(t);
47
+ if (!r.ok)
48
+ return null;
49
+ text = await r.text();
50
+ }
51
+ else {
52
+ text = require("node:fs").readFileSync(src, "utf8");
53
+ }
54
+ const flat = {};
55
+ const walk = (obj, prefix) => {
56
+ if (!obj || typeof obj !== "object")
57
+ return;
58
+ for (const [k, v] of Object.entries(obj)) {
59
+ const key = prefix ? `${prefix}.${k}` : k;
60
+ if (typeof v === "number" && Number.isFinite(v))
61
+ flat[key] = v;
62
+ else if (v && typeof v === "object" && !Array.isArray(v))
63
+ walk(v, key);
64
+ }
65
+ };
66
+ walk(JSON.parse(text), "");
67
+ return flat;
68
+ }
69
+ catch {
40
70
  return null;
71
+ }
72
+ }
73
+ /** Locally measurable criterion matchers. Null → manual.
74
+ * Precedence: 树/kb workspace counts (local truth) → any `key≥N` whose key
75
+ * exists in the optional --state counter map (external truth, 0.79.1). */
76
+ function measureCriterion(crit, db, state) {
41
77
  const parts = [];
42
78
  const hits = [];
79
+ const tree = crit.match(/树\s*≥\s*(\d+)/);
43
80
  if (tree) {
44
81
  const n = db.get("SELECT COUNT(*) c FROM trees").c;
45
82
  parts.push(`trees=${n}/${tree[1]}`);
46
83
  hits.push(n >= +tree[1]);
47
84
  }
85
+ const kb = crit.match(/kb\s*≥\s*(\d+)/);
48
86
  if (kb) {
49
87
  const n = db.get("SELECT COUNT(*) c FROM kb_notes").c;
50
88
  parts.push(`kb=${n}/${kb[1]}`);
51
89
  hits.push(n >= +kb[1]);
52
90
  }
91
+ if (state) {
92
+ const seen = new Set();
93
+ for (const m of crit.matchAll(/([\w\u4e00-\u9fa5][\w\u4e00-\u9fa5.-]*)\s*≥\s*(\d+)/g)) {
94
+ const key = m[1], need = +m[2];
95
+ if (key === "树" || key === "kb" || seen.has(key))
96
+ continue;
97
+ // 0.79.1: suffix match — a bare criterion key (`y_imported`) binds to a
98
+ // nested flattened state key (`counts.y_imported`) so users don't have
99
+ // to know the state file's shape. Exact match wins; else the SHALLOWEST
100
+ // `.*.<key>` (fewest dots) keeps resolution deterministic.
101
+ let stateKey = key in state ? key : null;
102
+ if (!stateKey) {
103
+ const cands = Object.keys(state).filter((sk) => sk.endsWith("." + key) && Number.isFinite(state[sk]));
104
+ if (cands.length) {
105
+ cands.sort((a, b) => (a.split(".").length - b.split(".").length) || a.localeCompare(b));
106
+ stateKey = cands[0];
107
+ }
108
+ }
109
+ if (!stateKey || !Number.isFinite(state[stateKey]))
110
+ continue;
111
+ seen.add(key);
112
+ parts.push(`${key}=${state[stateKey]}/${need}${stateKey !== key ? ` (${stateKey})` : ""}`);
113
+ hits.push(state[stateKey] >= need);
114
+ }
115
+ }
116
+ if (!parts.length)
117
+ return null;
53
118
  const hasOr = crit.includes(" 或 ");
54
119
  const hit = hasOr ? hits.some(Boolean) : hits.every(Boolean);
55
120
  return { actual: parts.join(" · "), hit };
@@ -86,7 +151,11 @@ async function auditPredictions(inp) {
86
151
  const m = c.text.match(/\d{4}-\d{2}-\d{2}\s*前\s*(.+?)(?:——|$)/) || c.text.match(/\]\s*(.+?)(?:——|$)/);
87
152
  const criterion = m ? m[1].trim() : "";
88
153
  const daysLeft = deadline ? Math.round((Date.parse(deadline) - Date.parse(today)) / 86400000) : null;
89
- let measure = measureCriterion(criterion, inp.db);
154
+ // 0.79.1 (user report): a claim the human already arbitrated (supported /
155
+ // refuted) is CLOSED — the audit reports the terminal state instead of
156
+ // re-judging it, so the backtest table stops accumulating settled rows.
157
+ const resolved = c.status === "supported" || c.status === "refuted";
158
+ let measure = measureCriterion(criterion, inp.db, inp.state);
90
159
  // npm version criterion → registry probe (opt-out for offline/tests)
91
160
  if (!measure && /npm 再发\s*≥\s*(\d+)\s*个版本/.test(criterion)) {
92
161
  const need = +criterion.match(/npm 再发\s*≥\s*(\d+)/)[1];
@@ -102,7 +171,11 @@ async function auditPredictions(inp) {
102
171
  }
103
172
  let verdict;
104
173
  let hint;
105
- if (!measure) {
174
+ if (resolved) {
175
+ verdict = "resolved";
176
+ hint = `已人工仲裁:${c.status}(回测终态,不再复判)`;
177
+ }
178
+ else if (!measure) {
106
179
  verdict = "manual";
107
180
  hint = "外部世界判据——人工核验后 claim resolve";
108
181
  }
@@ -112,7 +185,7 @@ async function auditPredictions(inp) {
112
185
  verdict = measure.hit ? "hit" : "miss";
113
186
  rows.push({
114
187
  claim_id: c.id, horizon, deadline, criterion,
115
- actual: measure ? measure.actual + (verdict === "pending" && measure.hit ? " · 已达标" : "") : "—",
188
+ actual: measure ? measure.actual + (!resolved && verdict === "pending" && measure.hit ? " · 已达标" : "") : "—",
116
189
  verdict, days_left: daysLeft, hint,
117
190
  });
118
191
  }
@@ -133,6 +133,12 @@ function run(env, action, rest, args) {
133
133
  case "spec": {
134
134
  const title = rest[0] || String(args.flags.title || "");
135
135
  const body = readBody();
136
+ // Feedback confirmation ①: --design-type is a produce design flag; spec
137
+ // silently swallowing it let a decision record land as a plain spec.
138
+ if (args.flags["design-type"] !== undefined) {
139
+ (0, fmt_1.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)");
140
+ return 2;
141
+ }
136
142
  if (!title || !body) {
137
143
  (0, fmt_1.bad)(ctx, "usage: produce spec <title> --body|-f <file>");
138
144
  return 2;
@@ -41,6 +41,23 @@ const entities_1 = require("../../pipeline/src/entities");
41
41
  function run(env, action, rest, args) {
42
42
  const { ctx, store } = env;
43
43
  const name = rest[0];
44
+ /** Report ③-3: selection changes are events (session-tagged) — see the
45
+ * twin helper in techtree/model/src/cmds.ts for the rationale. */
46
+ function logSelectionEvent(kind, projName) {
47
+ try {
48
+ store.db.run("INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,?)", [
49
+ `ev${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
50
+ new Date().toISOString(),
51
+ process.env.YGL_SESSION || "cli",
52
+ kind,
53
+ "project",
54
+ projName,
55
+ JSON.stringify({ name: projName, session: process.env.YGL_SESSION || "cli" }),
56
+ "info",
57
+ ]);
58
+ }
59
+ catch { /* telemetry only */ }
60
+ }
44
61
  switch (action) {
45
62
  case "new": {
46
63
  if (!name) {
@@ -48,12 +65,19 @@ function run(env, action, rest, args) {
48
65
  return 2;
49
66
  }
50
67
  const question = String(args.flags.q || args.flags.question || "");
68
+ // Feedback confirmation ②: auto-selecting the new project silently
69
+ // re-points every flag-less command away from prior work — say so.
70
+ const prior = store.db.metaGet("current_project");
71
+ const priorHint = prior && prior !== name
72
+ ? ` (prior project '${prior}' is no longer selected — project use ${prior} to switch back)`
73
+ : "";
51
74
  return (0, orm_1.getOrm)(env.db.path)
52
75
  .then((ds) => writes.projectCreate(ds, name, question))
53
76
  .then((p) => {
77
+ logSelectionEvent("project.use", p.name);
54
78
  (0, fmt_1.ack)(ctx, p, p?.duplicate === true
55
- ? `project exists, selected: ${p.name} (duplicate, not re-created)`
56
- : `project created and selected: ${p.name}`);
79
+ ? `project exists, selected: ${p.name} (duplicate, not re-created)${priorHint}`
80
+ : `project created and selected: ${p.name}${priorHint}`);
57
81
  return 0;
58
82
  })
59
83
  .catch((e) => {
@@ -86,6 +110,7 @@ function run(env, action, rest, args) {
86
110
  return 1;
87
111
  }
88
112
  store.db.metaSet("current_project", name);
113
+ logSelectionEvent("project.use", name);
89
114
  (0, fmt_1.ack)(ctx, { project: name }, `now using: ${name}`);
90
115
  return 0;
91
116
  }
@@ -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
+ }