ygl-trees 0.66.0 → 0.68.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.68.0(2026-10-17)锁设计修复——serve/CLI 共存 + PID 活锁检测 + JSON 纯净 + v9 时间纪律
4
+ - **F1(P0):webui serve 不再持排他锁**——原实现进程级锁让 serve 生命周期内挡掉一切 CLI 命令;现 serve 跳过锁(`YGL_SERVE=1` 旁路),SQLite WAL 天然支持并发读 + 序列化写;CLI 短命令仍锁(单写者保护写窗口)
5
+ - **P1(用户反馈):PID 活锁检测替代 15 分钟 mtime**——Windows 上 `process.on("exit")` 的 rmSync 句柄未释放时静默失败→锁残留→下进程撞白锁;现用 `process.kill(pid, 0)` 探测持有者是否存活(EPERM=活/ESRCH=死),死 PID **立即清锁**(不看 mtime),活 PID **如实报告**(不误报 stale)
6
+ - **F2:--json 模式锁错误纯净**——原锁提示文本泄露到 stdout 破坏 JSON 通道契约;现 --json 模式抑制人类提示,且提示本身也改为 PID 活锁感知(活锁不叫 stale)
7
+ - **F3:taskSetStatus 更新 artifacts.updated**——任务状态变更现在同步写 updated 时间戳(v9 纪律补漏)
8
+ - **exit 清理加固**:`beforeExit` + `exit` 双钩子 + 3 次重试(Windows EBUSY 容忍)
9
+ - **vendor/cordis bin 字段清理**:bin.js 已删但声明残留——移除
10
+
11
+ ## 0.67.0(2026-10-17)WebUI antd 迭代——Ant Design 组件库升级 React 客户端
12
+ - **antd v6 + @ant-design/icons**:专业组件库驱动的富客户端
13
+ - **组件替换**:Layout(Header/Content)、Card 统计卡片、Statistic 数字展示、Table 任务清单(可筛选)、Tag 状态标签、Alert 错误提示、Space/Row/Col 布局
14
+ - **DESIGN.md token 映射**:ConfigProvider theme(colorPrimary=#7c9885 sage、darkAlgorithm 暗色主题、zh_CN 中文语言包)
15
+ - **代码分割**:manualChunks 拆分 vendor/antd/xyflow/markdown 四块,antd 不阻塞首屏
16
+ - **保持不变**:xyflow 树视图、react-markdown 产出渲染、SSE 实时推送、内嵌 dashboard 回退
17
+
3
18
  ## 0.66.0(2026-10-17)新增 `llms` 命令——结构化能力清单(Ant Design for-agents 模式)
4
19
  - **`ygl-trees llms`**:一条命令输出完整 markdown 能力清单——冻结契约表(6 项)+ 16 个命令组全部用法 + 决策模型说明 + Quick Start + 设计系统入口——**任何 LLM 一次性获取全部能力面**
5
20
  - **静态 `llms.txt`**:仓库根目录,可被任何 LLM 爬取/注入上下文(同 Ant Design 的 llms.txt 模式)
@@ -116,132 +116,132 @@ function appVersion() {
116
116
  }
117
117
  // ── Usage text ──
118
118
  // (kept in apps/cli because it's presentation, not domain logic)
119
- const USAGE = `
120
- ${(0, fmt_1.paint)(fmt_1.C.bold, "ygl-trees", true)} 猿光 · 科技树
121
- ${(0, fmt_1.paint)(fmt_1.C.dim, "database-centric CLI: research → production → knowledge reuse", true)}
122
-
123
- ygl-trees <group> <action> [args] [options] add --json for machine output
124
-
125
- ${(0, fmt_1.paint)(fmt_1.C.bold, "config", true)} user-tunable settings (registry defaults + overrides)
126
- list all keys: value + default + note
127
- get <key> resolved value (config table > default)
128
- set <key> <value> validated against registry (type/range); events logged
129
- reset <key> drop override → back to default
130
-
131
- ${(0, fmt_1.paint)(fmt_1.C.bold, "db", true)}
132
- init create the workspace + schema
133
- stats row counts per table
134
- path print the database file path
135
- export <file.sql> [--lean] dump ALL data tables (+ summary); --lean skips derived
136
- sync pg <url> one-way full mirror to PostgreSQL (L1, see docs/pg-compat.md)
137
- migrate pg <url> [--fresh] one-time full-fidelity graduation (constraints + data, atomic)
138
- dups list duplicate-content rows
139
- reset delete the database (asks nothing, be careful)
140
- sql "<sql>" ad-hoc statement (can mutate — power tool, no guardrails)
141
-
142
- ${(0, fmt_1.paint)(fmt_1.C.bold, "skill", true)} L1 description / L2 body / L3 references
143
- import <file.md> import a SKILL.md (frontmatter = L1, body = L2)
144
- add --name --description --body [-f file.md]
145
- list | show <name> | rm <name> | enable <name> | disable <name>
146
- match "<query>" full-text match, prints L1 candidates for an agent
147
- refs add <skill> <path> --body ...
148
-
149
- ${(0, fmt_1.paint)(fmt_1.C.bold, "project", true)}
150
- new <name> -q "<question>" create + select
151
- list | use <name> | show
152
-
153
- ${(0, fmt_1.paint)(fmt_1.C.bold, "research", true)} collect → screen → extract → claim
154
- query add "<text>" register a search query
155
- query attach <qid> <sid> link source to query (recalled count)
156
- query list
157
- source add --title --url [--abstract --content --relevance --confidence --query]
158
- source fetch <url> fetch page, extract title/meta/text (zero side-effect on failure)
159
- source list | search "<q>" | show <id> | rm <id>
160
- evidence add <sourceId> --quote "..." [--note --locator]
161
- evidence list
162
- claim add "<text>" [--confidence A|B|C]
163
- claim link <claimId> <evidenceId> [--stance supports|refutes]
164
- claim resolve <claimId> --as supported|refuted|contested [--note]
165
- claim list
166
-
167
- ${(0, fmt_1.paint)(fmt_1.C.bold, "produce", true)} outline → section → report · spec/design(工程产出)
168
- outline auto-outline from claims
169
- section <title> --body|-f add a section artifact
170
- report assemble claims+evidence+sections into a report
171
- spec <title> --body|-f requirements spec artifact (R12)
172
- design <title> --body|-f design artifact (R12) --design-type ui-ux|arch|decision
173
- list | show <id> | export <id> <file>
174
-
175
- ${(0, fmt_1.paint)(fmt_1.C.bold, "task", true)} 任务清单(todo|doing|done 状态机,R12)
176
- add "<title>" [--note ...] create a task (status todo)
177
- list [--status todo|doing|done]
178
- doing|done <id> move along the state machine(勾销全程留痕)
179
-
180
- ${(0, fmt_1.paint)(fmt_1.C.bold, "kb", true)} long-term knowledge
181
- add --title --body|-f [--tags --confidence]
182
- import <dir|file.md> batch markdown (frontmatter, idempotent, single tx)
183
- search "<q>" | list | show <id>
184
- link <a> <b> [--rel]
185
- ingest <artifactId> import a finished artifact
186
- promote <noteId> [--name] turn a note into a reusable skill
187
-
188
- ${(0, fmt_1.paint)(fmt_1.C.bold, "job", true)} batch + schedule (drive with cron: job tick)
189
- add <title> -- <command> [--every <min>] [--at "YYYY-MM-DD HH:MM"]
190
- list | run <id> | tick | enable <id> | disable <id> | logs
191
-
192
- ${(0, fmt_1.paint)(fmt_1.C.bold, "tree / node", true)} tech-tree node model
193
- tree new <name> [--project p] create a tree (nodes live in it)
194
- tree list | tree use <name>
195
-
196
- node add <key> [--name --kind --tier --cost --tags --payload]
197
- node link <parent> <child> [--rel prereq|unlock|excludes|enables]
198
- [--mode AND|OR|XOR|NOT] [--optional]
199
- [--min-level n] [--weight n]
200
- node unlink <parent> <child> [--rel prereq]
201
- node cond <key> --name <c> [--kind flag|resource|level|quest|time|expr]
202
- [--op >=|==|<=|!=|has] [--value v] [--optional]
203
- [--op eval --value "<布尔问题>"]
204
- [--op eval-choice --value "<问题>" --options "k=desc,..." --answer k]
205
- [--op eval-score --value "<问题>" --levels "级0,..." --min-score n]
206
- [--effect gate|cost_mul] [--weight n]
207
- node index rebuild closure + topo order
208
- node can <key> [--state k=v] evaluate required vs optional gates
209
- (--eval: eval 题型经决策模型判定;
210
- boolean P / choice P(期望答案) / score ±1 灰带)
211
- node ready [--state k=v] [--eval] currently unlockable set
212
- node path <key> dependency-order path to target (all locked ancestors; OR branches not optimized)
213
- node parents|children <key> direct relations
214
- node ancestors|descendants <key> transitive, via closure index
215
- node list | show <key> | search "<q>" | unlock|lock <key>
216
- node validate cycles, dangling edges, dirty index
217
- node export [--out file.json] portable JSON
218
- node import --file <file.json> [--tree name] [--merge] — strict, transactional
219
-
220
- Writes are idempotent (claim/evidence/query/kb/job dedupe by content
221
- hash); pass --force to keep a deliberate duplicate.
222
-
223
- ${(0, fmt_1.paint)(fmt_1.C.bold, "webui", true)} dashboard (zero-dependency)
224
- export [--out <file>] single-file dashboard.html (open in browser)
225
- serve [--port N] live dashboard at http://127.0.0.1:N/ (Ctrl+C to stop)
226
-
227
- ${(0, fmt_1.paint)(fmt_1.C.bold, "rel", true)} 关联织物(spec R9:任意两类实体互相挂接)
228
- add <src> <dst> [--rel t] link two refs: node:key claim:id kb:id artifact:id
229
- skill:name evidence:id source:id query:id
230
- rm <src> <dst> [--rel t] unlink
231
- list [--src r] [--dst r] browse (either side filter, resolves displays)
232
-
233
- ${(0, fmt_1.paint)(fmt_1.C.bold, "plugin", true)} 用户层插件分发(spec R14 L3,业务域扩展位)
234
- init create $YGL_HOME/plugins profile
235
- install <spec> npm package / git url / local dir (auto link:)
236
- remove <name> | list manage the user layer ($YGL_HOME/ygl.user.bundle.json)
237
- 插件契约:export apply(ctx,config) + 可选 commands 贡献 CLI 命令组
238
-
239
- ${(0, fmt_1.paint)(fmt_1.C.bold, "agent", true)} agent-facing views
240
- brief current project state + next actions (start here)
241
- match "<task>" which skill should handle this task
242
- log recent events
243
-
244
- Global: --json --no-color --db <file> --home <dir>
119
+ const USAGE = `
120
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "ygl-trees", true)} 猿光 · 科技树
121
+ ${(0, fmt_1.paint)(fmt_1.C.dim, "database-centric CLI: research → production → knowledge reuse", true)}
122
+
123
+ ygl-trees <group> <action> [args] [options] add --json for machine output
124
+
125
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "config", true)} user-tunable settings (registry defaults + overrides)
126
+ list all keys: value + default + note
127
+ get <key> resolved value (config table > default)
128
+ set <key> <value> validated against registry (type/range); events logged
129
+ reset <key> drop override → back to default
130
+
131
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "db", true)}
132
+ init create the workspace + schema
133
+ stats row counts per table
134
+ path print the database file path
135
+ export <file.sql> [--lean] dump ALL data tables (+ summary); --lean skips derived
136
+ sync pg <url> one-way full mirror to PostgreSQL (L1, see docs/pg-compat.md)
137
+ migrate pg <url> [--fresh] one-time full-fidelity graduation (constraints + data, atomic)
138
+ dups list duplicate-content rows
139
+ reset delete the database (asks nothing, be careful)
140
+ sql "<sql>" ad-hoc statement (can mutate — power tool, no guardrails)
141
+
142
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "skill", true)} L1 description / L2 body / L3 references
143
+ import <file.md> import a SKILL.md (frontmatter = L1, body = L2)
144
+ add --name --description --body [-f file.md]
145
+ list | show <name> | rm <name> | enable <name> | disable <name>
146
+ match "<query>" full-text match, prints L1 candidates for an agent
147
+ refs add <skill> <path> --body ...
148
+
149
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "project", true)}
150
+ new <name> -q "<question>" create + select
151
+ list | use <name> | show
152
+
153
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "research", true)} collect → screen → extract → claim
154
+ query add "<text>" register a search query
155
+ query attach <qid> <sid> link source to query (recalled count)
156
+ query list
157
+ source add --title --url [--abstract --content --relevance --confidence --query]
158
+ source fetch <url> fetch page, extract title/meta/text (zero side-effect on failure)
159
+ source list | search "<q>" | show <id> | rm <id>
160
+ evidence add <sourceId> --quote "..." [--note --locator]
161
+ evidence list
162
+ claim add "<text>" [--confidence A|B|C]
163
+ claim link <claimId> <evidenceId> [--stance supports|refutes]
164
+ claim resolve <claimId> --as supported|refuted|contested [--note]
165
+ claim list
166
+
167
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "produce", true)} outline → section → report · spec/design(工程产出)
168
+ outline auto-outline from claims
169
+ section <title> --body|-f add a section artifact
170
+ report assemble claims+evidence+sections into a report
171
+ spec <title> --body|-f requirements spec artifact (R12)
172
+ design <title> --body|-f design artifact (R12) --design-type ui-ux|arch|decision
173
+ list | show <id> | export <id> <file>
174
+
175
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "task", true)} 任务清单(todo|doing|done 状态机,R12)
176
+ add "<title>" [--note ...] create a task (status todo)
177
+ list [--status todo|doing|done]
178
+ doing|done <id> move along the state machine(勾销全程留痕)
179
+
180
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "kb", true)} long-term knowledge
181
+ add --title --body|-f [--tags --confidence]
182
+ import <dir|file.md> batch markdown (frontmatter, idempotent, single tx)
183
+ search "<q>" | list | show <id>
184
+ link <a> <b> [--rel]
185
+ ingest <artifactId> import a finished artifact
186
+ promote <noteId> [--name] turn a note into a reusable skill
187
+
188
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "job", true)} batch + schedule (drive with cron: job tick)
189
+ add <title> -- <command> [--every <min>] [--at "YYYY-MM-DD HH:MM"]
190
+ list | run <id> | tick | enable <id> | disable <id> | logs
191
+
192
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "tree / node", true)} tech-tree node model
193
+ tree new <name> [--project p] create a tree (nodes live in it)
194
+ tree list | tree use <name>
195
+
196
+ node add <key> [--name --kind --tier --cost --tags --payload]
197
+ node link <parent> <child> [--rel prereq|unlock|excludes|enables]
198
+ [--mode AND|OR|XOR|NOT] [--optional]
199
+ [--min-level n] [--weight n]
200
+ node unlink <parent> <child> [--rel prereq]
201
+ node cond <key> --name <c> [--kind flag|resource|level|quest|time|expr]
202
+ [--op >=|==|<=|!=|has] [--value v] [--optional]
203
+ [--op eval --value "<布尔问题>"]
204
+ [--op eval-choice --value "<问题>" --options "k=desc,..." --answer k]
205
+ [--op eval-score --value "<问题>" --levels "级0,..." --min-score n]
206
+ [--effect gate|cost_mul] [--weight n]
207
+ node index rebuild closure + topo order
208
+ node can <key> [--state k=v] evaluate required vs optional gates
209
+ (--eval: eval 题型经决策模型判定;
210
+ boolean P / choice P(期望答案) / score ±1 灰带)
211
+ node ready [--state k=v] [--eval] currently unlockable set
212
+ node path <key> dependency-order path to target (all locked ancestors; OR branches not optimized)
213
+ node parents|children <key> direct relations
214
+ node ancestors|descendants <key> transitive, via closure index
215
+ node list | show <key> | search "<q>" | unlock|lock <key>
216
+ node validate cycles, dangling edges, dirty index
217
+ node export [--out file.json] portable JSON
218
+ node import --file <file.json> [--tree name] [--merge] — strict, transactional
219
+
220
+ Writes are idempotent (claim/evidence/query/kb/job dedupe by content
221
+ hash); pass --force to keep a deliberate duplicate.
222
+
223
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "webui", true)} dashboard (zero-dependency)
224
+ export [--out <file>] single-file dashboard.html (open in browser)
225
+ serve [--port N] live dashboard at http://127.0.0.1:N/ (Ctrl+C to stop)
226
+
227
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "rel", true)} 关联织物(spec R9:任意两类实体互相挂接)
228
+ add <src> <dst> [--rel t] link two refs: node:key claim:id kb:id artifact:id
229
+ skill:name evidence:id source:id query:id
230
+ rm <src> <dst> [--rel t] unlink
231
+ list [--src r] [--dst r] browse (either side filter, resolves displays)
232
+
233
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "plugin", true)} 用户层插件分发(spec R14 L3,业务域扩展位)
234
+ init create $YGL_HOME/plugins profile
235
+ install <spec> npm package / git url / local dir (auto link:)
236
+ remove <name> | list manage the user layer ($YGL_HOME/ygl.user.bundle.json)
237
+ 插件契约:export apply(ctx,config) + 可选 commands 贡献 CLI 命令组
238
+
239
+ ${(0, fmt_1.paint)(fmt_1.C.bold, "agent", true)} agent-facing views
240
+ brief current project state + next actions (start here)
241
+ match "<task>" which skill should handle this task
242
+ log recent events
243
+
244
+ Global: --json --no-color --db <file> --home <dir>
245
245
  `;
246
246
  function usage() {
247
247
  return USAGE;
@@ -256,6 +256,14 @@ async function main(argv) {
256
256
  };
257
257
  const group = args._[0];
258
258
  const action = args._[1];
259
+ // F1 fix (a2a review): webui serve holds its DB connection for the process
260
+ // lifetime — a process-level exclusive lock there would block ALL CLI
261
+ // commands until serve exits. SQLite WAL already permits concurrent readers
262
+ // + serialized writers, so serve skips the app-level lock. Short CLI
263
+ // commands still lock (single-writer for the brief write window).
264
+ if (group === "webui" && (action === "serve" || action === "export")) {
265
+ process.env.YGL_SERVE = "1";
266
+ }
259
267
  // P4 fix: --version prints the actual version number (cold-user report);
260
268
  // the parser eats --version as a flag, so check flags not positional args.
261
269
  if (args.flags.version === true || group === "version") {
@@ -307,10 +315,37 @@ async function main(argv) {
307
315
  return 1;
308
316
  }
309
317
  (0, fmt_1.bad)(ctx, `cannot open database ${file}: ${e?.message || e}`);
310
- const lock = `${file}.lock`;
311
- if (require("node:fs").existsSync(lock)) {
312
- (0, fmt_1.out)(ctx, ` stale lock dir detected: ${lock}`);
313
- (0, fmt_1.out)(ctx, ` if no other ygl-trees process is running, remove it: rmdir "${lock}"`);
318
+ // F2 fix (a2a review): --json mode must stay a single parseable object —
319
+ // suppress the human-only lock hints. Also check PID liveness, not just
320
+ // directory existence (a live serve holder is NOT stale).
321
+ const lock = `${file}.ygllock`;
322
+ if (!ctx.json && require("node:fs").existsSync(lock)) {
323
+ const pidFile = require("node:path").join(lock, "pid");
324
+ let holder = "?";
325
+ try {
326
+ holder = require("node:fs").readFileSync(pidFile, "utf8").trim();
327
+ }
328
+ catch { /* */ }
329
+ let alive = false;
330
+ try {
331
+ process.kill(Number(holder), 0);
332
+ alive = true;
333
+ }
334
+ catch (err) {
335
+ alive = err.code === "EPERM";
336
+ }
337
+ if (alive) {
338
+ (0, fmt_1.out)(ctx, ` lock held by live process pid ${holder} (serve? webui dashboard)`);
339
+ (0, fmt_1.out)(ctx, ` to run CLI write commands, stop that process first — or use YGL_TEST=1 to bypass`);
340
+ }
341
+ else {
342
+ (0, fmt_1.out)(ctx, ` orphaned lock (pid ${holder} is dead) — removing: ${lock}`);
343
+ try {
344
+ require("node:fs").rmSync(lock, { recursive: true, force: true });
345
+ }
346
+ catch { /* */ }
347
+ (0, fmt_1.out)(ctx, ` retry the command — the lock has been cleaned`);
348
+ }
314
349
  }
315
350
  return 1;
316
351
  }
@@ -52,9 +52,17 @@ async function bootApp(opts) {
52
52
  pluginCommands[g] = c;
53
53
  }
54
54
  }
55
- // settle the service layer (one yield; direct construction stays the CLI fallback)
56
- await new Promise((r) => setImmediate(r));
57
- const db = app.database?.db || new db_1.DB(opts.dbFile, { lock: process.env.YGL_TEST !== '1' });
55
+ // settle the service layer. Cordis runs plugin apply() inside fibers — one
56
+ // setImmediate tick does NOT guarantee the database service has constructed;
57
+ // the fallback below would then double-open the DB in one process (0.65.0
58
+ // P0: dir-lock self-deadlock / sqlite BUSY). Wait for it, bounded; the
59
+ // fallback stays as crash safety if the service never comes up.
60
+ let db = app.database?.db;
61
+ for (let i = 0; i < 50 && !db; i++) {
62
+ await new Promise((r) => setImmediate(r));
63
+ db = app.database?.db;
64
+ }
65
+ db = db || new db_1.DB(opts.dbFile, { lock: process.env.YGL_TEST !== '1' });
58
66
  const store = app.research?.store || new reads_1.Store(db);
59
67
  if (startupIssues.length) {
60
68
  throw new startup_diagnostics_1.StartupError(`${startupIssues.length} plugin issue(s) during startup`, startupIssues);
@@ -550,35 +550,50 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_refs ON skill_refs(skill_id, path);
550
550
  this.path = file;
551
551
  node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
552
552
  const useLock = opts?.lock === true;
553
- const lockDir = `${file}.lock`;
553
+ const legacyLockDir = `${file}.lock`;
554
+ if (node_fs.existsSync(node_path.join(legacyLockDir, "pid"))) try {
555
+ node_fs.rmSync(legacyLockDir, {
556
+ recursive: true,
557
+ force: true
558
+ });
559
+ } catch {}
560
+ const lockDir = `${file}.ygllock`;
554
561
  if (useLock && node_fs.existsSync(lockDir)) {
555
562
  let holder = "unknown";
556
563
  try {
557
564
  holder = node_fs.readFileSync(node_path.join(lockDir, "pid"), "utf8").trim();
558
565
  } catch {}
559
- try {
560
- const st = node_fs.statSync(lockDir);
561
- if (Date.now() - st.mtimeMs > 9e5) node_fs.rmSync(lockDir, {
566
+ const mine = holder === String(process.pid);
567
+ let holderAlive;
568
+ if (mine) holderAlive = false;
569
+ else try {
570
+ process.kill(Number(holder), 0);
571
+ holderAlive = true;
572
+ } catch (e) {
573
+ holderAlive = e.code === "EPERM";
574
+ }
575
+ if (!holderAlive) try {
576
+ node_fs.rmSync(lockDir, {
562
577
  recursive: true,
563
578
  force: true
564
579
  });
565
- else throw new Error(`database is locked by another process (pid: ${holder}).\n lock: ${lockDir}\n if no other ygl-trees is running, remove it: rmdir "${lockDir}"\n (stale locks auto-clear after 15 minutes)`);
566
- } catch (e) {
567
- if (String(e.message).includes("locked by another")) throw e;
568
- throw new Error(`database is locked: ${lockDir} (holder unreadable)`);
569
- }
580
+ } catch {}
581
+ 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}"`);
570
582
  }
571
583
  if (useLock) try {
572
584
  node_fs.mkdirSync(lockDir, { recursive: true });
573
585
  node_fs.writeFileSync(node_path.join(lockDir, "pid"), String(process.pid));
574
- process.on("exit", () => {
575
- try {
586
+ const cleanup = () => {
587
+ for (let i = 0; i < 3; i++) try {
576
588
  node_fs.rmSync(lockDir, {
577
589
  recursive: true,
578
590
  force: true
579
591
  });
592
+ return;
580
593
  } catch {}
581
- });
594
+ };
595
+ process.on("beforeExit", cleanup);
596
+ process.on("exit", cleanup);
582
597
  } catch {}
583
598
  this.db = new node_sqlite3_wasm.Database(file);
584
599
  this.db.exec("PRAGMA journal_mode=WAL");
@@ -589,7 +604,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_refs ON skill_refs(skill_id, path);
589
604
  /** Release the write lock (called by close() and process exit). */
590
605
  releaseLock() {
591
606
  try {
592
- const lockDir = `${this.path}.lock`;
607
+ const lockDir = `${this.path}.ygllock`;
593
608
  if (node_fs.existsSync(lockDir)) node_fs.rmSync(lockDir, {
594
609
  recursive: true,
595
610
  force: true
@@ -599,7 +614,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_refs ON skill_refs(skill_id, path);
599
614
  /** Static: force-release a lock (for cleanup hooks). */
600
615
  static releaseLockFor(file) {
601
616
  try {
602
- const lockDir = `${file}.lock`;
617
+ const lockDir = `${file}.ygllock`;
603
618
  if (node_fs.existsSync(lockDir)) node_fs.rmSync(lockDir, {
604
619
  recursive: true,
605
620
  force: true
@@ -919,8 +934,14 @@ function parse(argv) {
919
934
  const flags = {};
920
935
  let rest = false;
921
936
  const takesValue = (name) => !BOOLEAN_FLAGS.has(name);
922
- /** `-1` looks like a flag but is a negative number — accept it as a value. */
923
- const isValue = (s) => !s.startsWith("-") || /^-\d+(\.\d+)?$/.test(s);
937
+ /** A token is a flag boundary only if this parser could itself consume it as
938
+ * a flag: `--`, a long `--name`, or a single-letter `-x`. Any other token
939
+ * starting with `-` (e.g. `- 双平台…`, `-1`, `-abc`) can never be parsed as
940
+ * a flag, so a value-taking flag safely claims it — `--quote "- text"`
941
+ * keeps its value instead of collapsing to `true` and leaking the text
942
+ * into positionals. */
943
+ const looksLikeFlag = (s) => s === "--" || s.startsWith("--") || /^-[A-Za-z]$/.test(s);
944
+ const isValue = (s) => !looksLikeFlag(s);
924
945
  for (let i = 0; i < argv.length; i++) {
925
946
  const a = argv[i];
926
947
  if (rest) {
@@ -973,7 +994,8 @@ var DatabaseService = class extends _deepseek_ai_cordis.Service {
973
994
  db;
974
995
  constructor(ctx, dbFile) {
975
996
  super(ctx, "database");
976
- this.db = new DB(dbFile, { lock: process.env.YGL_TEST !== "1" });
997
+ const lock = process.env.YGL_TEST !== "1" && process.env.YGL_SERVE !== "1";
998
+ this.db = new DB(dbFile, { lock });
977
999
  }
978
1000
  };
979
1001
  function apply$3(ctx, config) {
@@ -2655,7 +2677,8 @@ function pnpm(home, args) {
2655
2677
  q(exe),
2656
2678
  ...args.map(q),
2657
2679
  "--dir",
2658
- q(profileDir(home))
2680
+ q(profileDir(home)),
2681
+ "--ignore-workspace"
2659
2682
  ].join(" "), {
2660
2683
  stdio: "pipe",
2661
2684
  shell: true
@@ -2665,7 +2688,8 @@ function pnpm(home, args) {
2665
2688
  (0, node_child_process.execFileSync)(pm, [
2666
2689
  ...args,
2667
2690
  "--dir",
2668
- profileDir(home)
2691
+ profileDir(home),
2692
+ "--ignore-workspace"
2669
2693
  ], { stdio: "pipe" });
2670
2694
  }
2671
2695
  function resolvePm(pm) {
@@ -2911,8 +2935,12 @@ async function bootApp(opts) {
2911
2935
  }
2912
2936
  for (const [g, c] of Object.entries(up.commands || {})) if (typeof c.run === "function") pluginCommands[g] = c;
2913
2937
  }
2914
- await new Promise((r) => setImmediate(r));
2915
- const db = app.database?.db || new DB(opts.dbFile, { lock: process.env.YGL_TEST !== "1" });
2938
+ let db = app.database?.db;
2939
+ for (let i = 0; i < 50 && !db; i++) {
2940
+ await new Promise((r) => setImmediate(r));
2941
+ db = app.database?.db;
2942
+ }
2943
+ db = db || new DB(opts.dbFile, { lock: process.env.YGL_TEST !== "1" });
2916
2944
  const store = app.research?.store || new Store(db);
2917
2945
  if (startupIssues.length) throw new StartupError(`${startupIssues.length} plugin issue(s) during startup`, startupIssues);
2918
2946
  return {
@@ -3802,7 +3830,11 @@ var init_entities$1 = __esmMin((() => {
3802
3830
  type: "text",
3803
3831
  nullable: true
3804
3832
  }), __decorateMetadata("design:type", String)], Claim.prototype, "updated", void 0);
3805
- __decorate([(0, typeorm.JoinColumn)({ name: "project_id" }), __decorateMetadata("design:type", typeof Project === "undefined" ? Object : Project)], Claim.prototype, "project", void 0);
3833
+ __decorate([
3834
+ (0, typeorm.ManyToOne)(() => Project, (p) => p.claims, { createForeignKeyConstraints: false }),
3835
+ (0, typeorm.JoinColumn)({ name: "project_id" }),
3836
+ __decorateMetadata("design:type", typeof Project === "undefined" ? Object : Project)
3837
+ ], Claim.prototype, "project", void 0);
3806
3838
  __decorate([(0, typeorm.OneToMany)(() => ClaimEvidence, (ce) => ce.claim, { createForeignKeyConstraints: false }), __decorateMetadata("design:type", Array)], Claim.prototype, "claimEvidence", void 0);
3807
3839
  Claim = __decorate([(0, typeorm.Entity)({ name: "claims" })], Claim);
3808
3840
  ClaimEvidence = class ClaimEvidence {
@@ -4467,10 +4499,14 @@ async function taskSetStatus(ds, taskId, status) {
4467
4499
  meta = rows[0].meta ? JSON.parse(rows[0].meta) : {};
4468
4500
  } catch {}
4469
4501
  const from = meta.status || "todo";
4470
- await mgr.query(`UPDATE artifacts SET meta=? WHERE id=?`, [JSON.stringify({
4471
- ...meta,
4472
- status
4473
- }), taskId]);
4502
+ await mgr.query(`UPDATE artifacts SET meta=?, updated=? WHERE id=?`, [
4503
+ JSON.stringify({
4504
+ ...meta,
4505
+ status
4506
+ }),
4507
+ nowIso(),
4508
+ taskId
4509
+ ]);
4474
4510
  await logEvent$1(mgr, "task.status", "task", taskId, `${from} → ${status}`, status === "done" ? "info" : "info");
4475
4511
  return {
4476
4512
  id: taskId,
@@ -7196,7 +7232,7 @@ async function importTree(ds, spec, opts = {}) {
7196
7232
  const pid = nodeId.get(String(e.parent));
7197
7233
  const cid = nodeId.get(String(e.child));
7198
7234
  await mgr.query(`INSERT INTO node_edges(id,tree_id,parent_id,child_id,rel,mode,required,min_level,weight,note,created)
7199
- VALUES(?,?,?,?,?,?,?,?,?,?)
7235
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)
7200
7236
  ON CONFLICT(parent_id,child_id,rel) DO UPDATE SET
7201
7237
  mode=excluded.mode, required=excluded.required, min_level=excluded.min_level,
7202
7238
  weight=excluded.weight, note=excluded.note`, [
@@ -7215,7 +7251,8 @@ async function importTree(ds, spec, opts = {}) {
7215
7251
  e.required === false ? 0 : 1,
7216
7252
  Number(e.min_level ?? 1),
7217
7253
  Number(e.weight ?? 1),
7218
- String(e.note || "")
7254
+ String(e.note || ""),
7255
+ nowIso()
7219
7256
  ]);
7220
7257
  }
7221
7258
  for (const c of conds) {
@@ -10005,8 +10042,12 @@ function run(env, action, rest, args) {
10005
10042
  status: "pass",
10006
10043
  detail: configRows.length === 0 ? "no overrides (all defaults)" : `${configRows.length} override(s), all valid`
10007
10044
  });
10008
- const lockPath = `${db.path}.lock`;
10009
- const hasLock = require("node:fs").existsSync(lockPath);
10045
+ const lockPath = `${db.path}.ygllock`;
10046
+ let lockHolder = "";
10047
+ try {
10048
+ lockHolder = require("node:fs").readFileSync(`${lockPath}/pid`, "utf8").trim();
10049
+ } catch {}
10050
+ const hasLock = require("node:fs").existsSync(lockPath) && lockHolder !== String(process.pid);
10010
10051
  checks.push({
10011
10052
  name: "stale_lock",
10012
10053
  status: hasLock ? "warn" : "pass",
@@ -10080,8 +10121,12 @@ function run(env, action, rest, args) {
10080
10121
  db.run("UPDATE meta SET value='0' WHERE key='index_dirty'");
10081
10122
  fixes.push("index dirty flag cleared (run: ygl-trees node index)");
10082
10123
  }
10083
- const lockDir = `${env.db.path}.lock`;
10084
- if (node_fs.existsSync(lockDir)) try {
10124
+ const lockDir = `${env.db.path}.ygllock`;
10125
+ let holder = "";
10126
+ try {
10127
+ holder = node_fs.readFileSync(`${lockDir}/pid`, "utf8").trim();
10128
+ } catch {}
10129
+ if (node_fs.existsSync(lockDir) && holder !== String(process.pid)) try {
10085
10130
  node_fs.rmSync(lockDir, {
10086
10131
  recursive: true,
10087
10132
  force: true
@@ -10309,6 +10354,7 @@ async function main(argv) {
10309
10354
  };
10310
10355
  const group = args._[0];
10311
10356
  const action = args._[1];
10357
+ if (group === "webui" && (action === "serve" || action === "export")) process.env.YGL_SERVE = "1";
10312
10358
  if (args.flags.version === true || group === "version") {
10313
10359
  out(ctx, appVersion());
10314
10360
  return 0;
@@ -10351,10 +10397,33 @@ async function main(argv) {
10351
10397
  return 1;
10352
10398
  }
10353
10399
  bad(ctx, `cannot open database ${file}: ${e?.message || e}`);
10354
- const lock = `${file}.lock`;
10355
- if (require("node:fs").existsSync(lock)) {
10356
- out(ctx, ` stale lock dir detected: ${lock}`);
10357
- out(ctx, ` if no other ygl-trees process is running, remove it: rmdir "${lock}"`);
10400
+ const lock = `${file}.ygllock`;
10401
+ if (!ctx.json && require("node:fs").existsSync(lock)) {
10402
+ const pidFile = require("node:path").join(lock, "pid");
10403
+ let holder = "?";
10404
+ try {
10405
+ holder = require("node:fs").readFileSync(pidFile, "utf8").trim();
10406
+ } catch {}
10407
+ let alive = false;
10408
+ try {
10409
+ process.kill(Number(holder), 0);
10410
+ alive = true;
10411
+ } catch (err) {
10412
+ alive = err.code === "EPERM";
10413
+ }
10414
+ if (alive) {
10415
+ out(ctx, ` lock held by live process pid ${holder} (serve? webui dashboard)`);
10416
+ out(ctx, ` to run CLI write commands, stop that process first — or use YGL_TEST=1 to bypass`);
10417
+ } else {
10418
+ out(ctx, ` orphaned lock (pid ${holder} is dead) — removing: ${lock}`);
10419
+ try {
10420
+ require("node:fs").rmSync(lock, {
10421
+ recursive: true,
10422
+ force: true
10423
+ });
10424
+ } catch {}
10425
+ out(ctx, ` retry the command — the lock has been cleaned`);
10426
+ }
10358
10427
  }
10359
10428
  return 1;
10360
10429
  }