asia-aidlc 1.14.1 → 1.14.5

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.
Files changed (44) hide show
  1. package/.aidlc-engine/cli/flow.py +125 -16
  2. package/CHANGELOG.md +192 -0
  3. package/README.md +29 -0
  4. package/bin/aidlc.js +2 -1
  5. package/lib/engine.js +29 -16
  6. package/lib/index.js +16 -5
  7. package/lib/runner.js +25 -1
  8. package/package.json +2 -2
  9. package/.tmp-init-team-test.py +0 -119
  10. package/.tmp-mcp-init-test.py +0 -53
  11. package/.trae/commands/flow-audit.md +0 -13
  12. package/.trae/commands/flow-branch.md +0 -16
  13. package/.trae/commands/flow-e2e.md +0 -6
  14. package/.trae/commands/flow-gate.md +0 -28
  15. package/.trae/commands/flow-matrix.md +0 -6
  16. package/.trae/commands/flow-status.md +0 -6
  17. package/.trae/commands/flow-transition.md +0 -13
  18. package/.trae/commands/flow-validate.md +0 -8
  19. package/.trae/hooks/gate_hook.py +0 -334
  20. package/.trae/hooks.json +0 -60
  21. package/.trae/mcp.json +0 -11
  22. package/.trae/rules/flow-orchestration.md +0 -128
  23. package/.trae/skills/audit-logger/SKILL.md +0 -60
  24. package/.trae/skills/audit-logger/scripts/append.py +0 -107
  25. package/.trae/skills/compliance-checker/SKILL.md +0 -40
  26. package/.trae/skills/compliance-checker/scripts/check.py +0 -32
  27. package/.trae/skills/design-doc-generator/SKILL.md +0 -38
  28. package/.trae/skills/gate-verifier/SKILL.md +0 -61
  29. package/.trae/skills/gate-verifier/scripts/verify.py +0 -228
  30. package/.trae/skills/observation-doc-generator/SKILL.md +0 -36
  31. package/.trae/skills/openspec-executor/SKILL.md +0 -61
  32. package/.trae/skills/release-doc-generator/SKILL.md +0 -38
  33. package/.trae/skills/render-views/SKILL.md +0 -73
  34. package/.trae/skills/req-doc-generator/SKILL.md +0 -60
  35. package/.trae/skills/req-doc-generator/scripts/create_req.py +0 -222
  36. package/.trae/skills/state-validator/SKILL.md +0 -47
  37. package/.trae/skills/state-validator/scripts/validate.py +0 -312
  38. package/.trae/skills/task-split/SKILL.md +0 -114
  39. package/.trae/skills/task-split/scripts/tasks.py +0 -511
  40. package/.trae/skills/team-init/SKILL.md +0 -99
  41. package/.trae/skills/team-init/scripts/init_owners.py +0 -614
  42. package/.trae/skills/test-manager/SKILL.md +0 -38
  43. package/.trae/skills/traceability-updater/SKILL.md +0 -34
  44. package/.trae/skills/traceability-updater/scripts/update.py +0 -98
@@ -426,6 +426,8 @@ ROLE_NODE_HINT = (
426
426
  )
427
427
  NODE_LABEL = (("N0", "任务受理"), ("N1", "需求生成"), ("N2", "方案生成"),
428
428
  ("N3", "代码生成"), ("N4", "测试执行"), ("N5", "交付发布"))
429
+ # 名单里的「角色」是自由文本,但给几个示例用户才知道该写什么(角色会用于自动推断阶段归属)
430
+ ROLE_EXAMPLES = ("产品负责人", "技术负责人", "研发负责人", "QA负责人", "交付负责人")
429
431
 
430
432
 
431
433
  def parse_members(text):
@@ -457,14 +459,43 @@ def suggest_owners(members):
457
459
  return owners
458
460
 
459
461
 
462
+ def can_prompt():
463
+ """当前环境能否**真的**交互提问。
464
+
465
+ Pyodide(npm 包 `aidlc`)下 stdin 是只读的 EOF 流:`input()` 直接抛 EOFError、`isatty()` 为 False,
466
+ 而且 stdout 是**按行批处理**的(没带换行的提示语要等下一次 flush 才冒出来)。
467
+ 所以那里不能提问——必须明确告知并给一行命令,绝不能"假装在等输入"。
468
+ """
469
+ if os.environ.get("AIDLC_NPM", "").strip() == "1":
470
+ return False
471
+ try:
472
+ return bool(sys.stdin) and sys.stdin.isatty()
473
+ except Exception:
474
+ return False
475
+
476
+
477
+ NO_TTY_HINT = ("[hint] 当前环境不支持交互提问(Pyodide/npm 形态,或没有终端),已跳过提问。\n"
478
+ " 一步配好团队:flow init --team \"张敏:产品负责人,李强:技术负责人\" --admin 张敏\n"
479
+ " (按角色自动推断阶段归属;也可随时补:skill team-init set --node N3 --owner 王磊)")
480
+
481
+
460
482
  def _ask(prompt, default=""):
461
- """读一行输入;非 TTY / EOF / Ctrl-C 一律回落到默认值(绝不因提问把 init 弄挂)。"""
483
+ """读一行输入。返回 None 表示**读不到输入**(EOF / 无终端 / Pyodide)——与「按回车用默认值」区分开。
484
+
485
+ 提示语单独 write + flush:stdout 若被按行批处理(Pyodide 的 batched 模式),
486
+ 交给 input(prompt) 的提示语不会及时显示。
487
+ """
488
+ shown = (" [%s]" % default) if default else ""
462
489
  try:
463
- shown = (" [%s]" % default) if default else ""
464
- line = input("%s%s: " % (prompt, shown))
490
+ sys.stdout.write("%s%s: " % (prompt, shown))
491
+ sys.stdout.flush()
492
+ except Exception:
493
+ pass
494
+ try:
495
+ line = input()
465
496
  except (EOFError, KeyboardInterrupt, OSError):
466
497
  print()
467
- return default
498
+ return None
468
499
  return line.strip() or default
469
500
 
470
501
 
@@ -484,12 +515,23 @@ def setup_team_wizard(system, state):
484
515
  print("\n" + "─" * 8 + " 下一步:配置团队成员 " + "─" * 8)
485
516
  print(" 它决定:门控审批人校验、视图里的「配置负责人」、日历按人筛选。")
486
517
  print(" [1] 现在配(约 30 秒) [2] 先跳过(随时可配:flow init --team \"张敏:产品负责人\")")
487
- if _ask("选择", "1") not in ("1", "y", "Y", "yes", ""):
518
+ pick = _ask("选择", "1")
519
+ if pick is None: # 读不到输入:别假装在等,直接给一行命令
520
+ return NO_TTY_HINT
521
+ if pick not in ("1", "y", "Y", "yes", ""):
488
522
  return " 已跳过。之后可用:flow init --team \"张敏:产品负责人,…\" --admin 张敏"
489
523
 
490
524
  proj = _ask("项目名", project)
525
+ if proj is None:
526
+ return NO_TTY_HINT
491
527
  admin = _ask("管理员 / 产品负责人(填入 N0/N1 与 req_review)", "")
528
+ if admin is None:
529
+ return NO_TTY_HINT
492
530
  print("团队名单(每行一条,格式 名字:角色;空行结束)")
531
+ print(" 角色示例:" + " / ".join(ROLE_EXAMPLES))
532
+ print(" 也可以写:架构师 / 项目经理 / 测试工程师 / 运维 —— 角色是自由文本,用于自动推断阶段归属")
533
+ print(" 只写名字也行(用这条命令会按名字建成员、阶段归属再单独指定)")
534
+ print(" 例:张敏:产品负责人 / 李强:技术负责人")
493
535
  roster = []
494
536
  while True:
495
537
  try:
@@ -574,7 +616,9 @@ def next_steps_block(state, team_done=False, hooks_done=False):
574
616
  lines += [
575
617
  "[next] step=team 状态=未配置 影响=门控审批人校验/视图「配置负责人」列/日历按人筛选",
576
618
  "[next] step=team 一条命令配好=flow init --team \"张敏:产品负责人,李强:技术负责人\" --admin 张敏",
577
- "[next] step=team 跳过=回答「先跳过」即可(不影响 init 结果;随时可再配)",
619
+ "[next] step=team 角色示例=" + " / ".join(ROLE_EXAMPLES) +
620
+ "(角色是自由文本,用于自动推断阶段归属)",
621
+ "[next] step=team 跳过=什么都不做即可(不影响 init 结果;随时可再配)",
578
622
  ]
579
623
  if not hooks_done:
580
624
  lines.append("[next] step=hooks 状态=未安装 影响=commit/切分支后视图不自动重建 "
@@ -590,11 +634,16 @@ def do_init(system, state, skills_dir=".trae/skills", no_skills=False, empty=Fal
590
634
  # 钩子装到状态文件所在仓库(脚本自身会 git rev-parse 定位仓库根,传目录即可)
591
635
  hook_start = os.path.dirname(os.path.abspath(state)) or os.getcwd()
592
636
  if os.path.exists(state):
593
- # 维护模式:已初始化的仓库不能重建状态,但「补装缺失的 Skill / 钩子」应当仍可执行
637
+ # 维护模式:已初始化的仓库不能重建状态,但「补装缺失的 Skill / 钩子 / 刷新引擎」应当仍可执行
594
638
  if git_hooks or sync_skills:
595
639
  out = "[skip] %s 已存在,不覆盖(维护模式:只执行所请求的安装动作)" % state
640
+ repo_root = _repo_root_from_state(state)
641
+ n_files, eng_dest = _materialize_engine(system, repo_root)
642
+ if n_files is not None:
643
+ system = eng_dest # 后续步骤改用项目内那份引擎
644
+ out += "\nOK: 引擎已落地 → %s(新写/更新 %d 个文件)" % (eng_dest, n_files)
596
645
  if sync_skills:
597
- out += "\nOK: " + _install_skills(system, _repo_root_from_state(state), skills_dir)
646
+ out += "\nOK: " + _install_skills(system, repo_root, skills_dir)
598
647
  if git_hooks:
599
648
  out += "\nOK: " + install_viewer_hooks(hook_start)
600
649
  return out
@@ -618,6 +667,16 @@ def do_init(system, state, skills_dir=".trae/skills", no_skills=False, empty=Fal
618
667
  write_state(state, data)
619
668
  data_root = os.path.dirname(os.path.abspath(state)) or os.getcwd()
620
669
  repo_root = os.path.dirname(data_root) or data_root # .trae/ 等项目配置仍装在仓库根
670
+ # npm 形态:先把引擎落地到项目内,**后续所有步骤都改用项目内那份**——
671
+ # 这样 .trae/mcp.json 的 ${workspaceFolder}/.aidlc-engine/... 与 git 钩子的路径才是真的。
672
+ n_files, eng_dest = _materialize_engine(system, repo_root)
673
+ if n_files is not None:
674
+ system = eng_dest
675
+ engine_note = "OK: 引擎已落地 → %s(新写/更新 %d 个文件)%s" % (
676
+ eng_dest, n_files,
677
+ ";之后 Trae MCP 与 git 钩子都指向项目内这份引擎" if n_files else "(已是最新)")
678
+ else:
679
+ engine_note = "OK: 引擎:%s" % eng_dest
621
680
  made = _scaffold(system, state)
622
681
  trae = _install_trae(system, repo_root)
623
682
  rule = _install_agent_rule(system, repo_root)
@@ -632,10 +691,10 @@ def do_init(system, state, skills_dir=".trae/skills", no_skills=False, empty=Fal
632
691
  dep = "提示: 未安装 jsonschema,validate 将降级运行(pip install jsonschema 启用完整 Schema 校验)"
633
692
  prefix = "(空白,无示例实体)" if empty else ""
634
693
  out = ("OK: 已从 example 生成 %s%s(%d 需求 / %d EPIC / %d WU / %d CHG / %d REL)\n"
635
- "OK: %s\nOK: Trae 接入: %s\nOK: %s\nOK: %s" % (
694
+ "%s\nOK: %s\nOK: Trae 接入: %s\nOK: %s\nOK: %s" % (
636
695
  state, prefix, len(data.get("requirements", [])), len(data.get("epics", [])),
637
696
  len(data.get("workunits", [])), len(data.get("changes", [])), len(data.get("releases", [])),
638
- made, trae, rule, sk))
697
+ engine_note, made, trae, rule, sk))
639
698
  if git_hooks:
640
699
  out += "\nOK: " + install_viewer_hooks(hook_start)
641
700
  if dep:
@@ -675,14 +734,17 @@ def do_init(system, state, skills_dir=".trae/skills", no_skills=False, empty=Fal
675
734
  out += "\nOK: 管理员 %s 已填入 N0/N1 与 req_review" % admin
676
735
  if not (ok1 and ok2):
677
736
  out += "\n[warn] 写入成员/阶段归属未成功,可重试:flow init --team \"…\" --admin <名字>"
678
- elif not team_done and not no_input and sys.stdin.isatty():
737
+ elif not team_done and not no_input and can_prompt():
679
738
  # 提问放在 main() 打印完汇总之后(否则提示会插在 OK 行前面)
680
739
  _PENDING_SETUP = {"system": system, "state": state}
740
+ elif not team_done and not no_input:
741
+ # Pyodide / 无终端:明确说清"不会提问",并给一行命令——
742
+ # 否则用户会盯着屏幕等一个永远不会来的提示(曾如此)
743
+ out += "\n" + NO_TTY_HINT
681
744
 
682
- if not git_hooks and not no_input and sys.stdin.isatty() and not team.strip():
683
- _PENDING_SETUP.setdefault("hooks_dir", hook_start)
684
- elif not git_hooks:
685
- pass
745
+ if not team.strip() and can_prompt() and not no_input:
746
+ if not git_hooks:
747
+ _PENDING_SETUP.setdefault("hooks_dir", hook_start)
686
748
 
687
749
  blk = next_steps_block(state, team_done=team_done or bool(team.strip()),
688
750
  hooks_done=git_hooks or bool(_PENDING_SETUP.get("hooks_dir")))
@@ -691,6 +753,50 @@ def do_init(system, state, skills_dir=".trae/skills", no_skills=False, empty=Fal
691
753
  return out
692
754
 
693
755
 
756
+ def _materialize_engine(system, repo_root):
757
+ """把「包里的引擎」落地到项目内 `.aidlc-engine/`(npm 安装形态专用,仓库内形态是 no-op)。
758
+
759
+ 为什么必须做:npm 形态下引擎只存在于 `node_modules/asia-aidlc/.aidlc-engine/`,
760
+ 而项目侧的接入件全都按**项目内**路径引用它——
761
+ · `.trae/mcp.json` → `${workspaceFolder}/.aidlc-engine/cli/flow_mcp.py`
762
+ · git 钩子 → `python .aidlc-engine/viewer/render.py --if-stale`
763
+ · `.trae/rules/flow-orchestration.md` → `.aidlc-engine/cli/flow.py`
764
+ 不落地的话,这三样在项目里全是**死路径**(MCP 起不来、钩子空跑)。
765
+
766
+ 幂等:逐文件比较,只写缺失或内容不同的;跳过 `__pycache__` / `*.pyc` / `.tmp*`。
767
+ 返回 (落地文件数, 目标路径) 或 (None, 原因)。
768
+ """
769
+ src = (os.environ.get("AIDLC_ENGINE_SRC") or "").strip()
770
+ if not src:
771
+ return None, "(仓库内形态:引擎已在项目里,无需落地)"
772
+ if not os.path.isdir(src):
773
+ return None, "找不到包内引擎 %s" % src
774
+ dest = os.path.join(repo_root, ".aidlc-engine")
775
+ if os.path.abspath(src) == os.path.abspath(dest):
776
+ return None, "(引擎与项目同一目录,无需落地)"
777
+ written = skipped = 0
778
+ for root, dirs, files in os.walk(src):
779
+ dirs[:] = [d for d in dirs if d != "__pycache__" and not d.startswith(".tmp")]
780
+ rel = os.path.relpath(root, src)
781
+ target_dir = dest if rel == "." else os.path.join(dest, rel)
782
+ os.makedirs(target_dir, exist_ok=True)
783
+ for name in files:
784
+ if name.endswith((".pyc", ".pyo")) or name.startswith(".tmp"):
785
+ continue
786
+ s, d = os.path.join(root, name), os.path.join(target_dir, name)
787
+ try:
788
+ if os.path.exists(d) and os.path.getsize(d) == os.path.getsize(s):
789
+ with open(s, "rb") as fa, open(d, "rb") as fb:
790
+ if fa.read() == fb.read():
791
+ skipped += 1
792
+ continue
793
+ shutil.copy2(s, d)
794
+ written += 1
795
+ except OSError as exc:
796
+ LOG.warning("引擎落地失败 %s:%s", rel, exc)
797
+ return written, dest
798
+
799
+
694
800
  def _scaffold(system, state):
695
801
  """按目录规约创建仓库骨架:aidlc-data/ 产物目录 + 登记册 + 仓库根 .gitignore 合并。全部幂等,已存在即跳过。"""
696
802
  data_root = os.path.dirname(os.path.abspath(state)) or os.getcwd()
@@ -2226,7 +2332,10 @@ def main():
2226
2332
  if extra:
2227
2333
  print("\n" + extra)
2228
2334
  if _PENDING_SETUP.get("hooks_dir"):
2229
- if _ask("是否安装 git 钩子(commit/切分支后自动重建视图)", "y").lower() in ("y", "yes", "1"):
2335
+ ans = _ask("是否安装 git 钩子(commit/切分支后自动重建视图)", "y")
2336
+ if ans is None:
2337
+ print(" 读不到输入,已跳过。之后可装:flow init --git-hooks / flow hooks install")
2338
+ elif str(ans).lower() in ("y", "yes", "1"):
2230
2339
  print("OK: " + install_viewer_hooks(_PENDING_SETUP["hooks_dir"]))
2231
2340
  else:
2232
2341
  print(" 已跳过。之后可装:flow init --git-hooks / flow hooks install")
package/CHANGELOG.md CHANGED
@@ -5,6 +5,198 @@
5
5
 
6
6
  ---
7
7
 
8
+ ## v1.25 · 2026-10-09 — npm 形态 init 只落 `aidlc-data/`:引擎与 `.trae/` 现在真的落地
9
+
10
+ ### 背景(用户报障)
11
+
12
+ > 「从 npm 上安装完这个包之后进行初始化,只会生成 `aidlc-data` 这个文件夹,而 `.aidlc-engine`、`.trae` 没有生成,这几个也很重要」
13
+
14
+ 用户是对的,而且这是**同一个根因**的两个症状。
15
+
16
+ ### 根因(实测量化)
17
+
18
+ npm 形态的挂载表只有两个子目录,**项目根本身没挂**:
19
+
20
+ ```
21
+ mounts: /project/.aidlc-engine ← 包里的引擎(NODEFS)
22
+ /project/aidlc-data ← 项目数据(NODEFS,mustExist:false 时创建)
23
+ /project 本身 = MEMFS ← 内存盘!
24
+ ```
25
+
26
+ 于是 `flow init` 打印了 `OK: Trae 接入: … → .trae/`「技能 14 个 → .trae/skills」,
27
+ **写入全落在内存盘 `/project/.trae`,进程退出即丢**;复现脚本在宿主上只看到 `aidlc-data/`,
28
+ `.trae` 与 `.aidlc-engine` 均为 false。而 `.aidlc-engine/` 属于"引擎来自包"的既有设计,**从未被复制**。
29
+
30
+ 致命连锁:即使 `.trae/` 落了,里面的路径也全是死的——
31
+ `.trae/mcp.json` → `${workspaceFolder}/.aidlc-engine/cli/flow_mcp.py`(不存在)、
32
+ git 钩子 → `python .aidlc-engine/viewer/render.py --if-stale`(不存在)、
33
+ `.trae/rules/flow-orchestration.md` → `.aidlc-engine/cli/flow.py`(不存在)。
34
+
35
+ ### 变更明细
36
+
37
+ 1. **`lib/engine.js` 挂载表重做**:npm 形态改为
38
+ `{host: <项目根>, guest: "/project"}`(NODEFS,写入直达宿主)+
39
+ `{host: <包内引擎>, guest: "/engine-src"}`(`ENGINE_SRC_GUEST`),
40
+ `resolveMounts` 返回值新增 `engineSrc`。
41
+ 2. **`lib/index.js`**:新增 `guestPath` 二选一规则——项目内**已有** `.aidlc-engine/cli/flow.py`
42
+ 就用 `/project/.aidlc-engine/…`(与 Trae 钩子/MCP 同一份引擎,避免版本错位),
43
+ 否则用 `/engine-src/…`(首次 init 前必须能跑起来);并通过环境变量 `AIDLC_ENGINE_SRC` 告知 Python 侧。
44
+ 3. **`flow.py` 新增 `_materialize_engine()`**:把 `/engine-src` 逐文件复制到 `<项目>/.aidlc-engine/`,
45
+ 跳过 `__pycache__`/`*.pyc`/`.tmp*`,**幂等**(同尺寸先比内容,只写有变化的);
46
+ init 之后 `system` 切到项目内那份,于是 `.trae/mcp.json`、git 钩子、`.trae/skills` 的路径全部指向项目内引擎。
47
+ **维护模式也刷新引擎**(`flow init --sync-skills` 可用于 `npm update` 后同步)。
48
+ 4. **`lib/runner.js` 修一个既有 bug**:`ensureMounts` 无条件重复 `FS.mount`,
49
+ 同一 Node 进程里**第二次** `runScript` 会抛 `ErrnoError`(我的验证脚本是第一个连调两次的用例)。
50
+ 改为记录每个 guest 当前挂的宿主:同宿主跳过,不同宿主先 `unmount` 再挂(支持一个进程里先后操作不同项目)。
51
+ 5. **`bin/aidlc.js`**:`doctor` 增加一行「项目内引擎 … OK / 未落地(先 aidlc flow init)」;
52
+ `项目根` 那行措辞改为「npm 形态:引擎来自包,`flow init` 会落地到项目内」。
53
+ 6. **`README.md` 新增「从 npm 安装到自己的项目」**:三样落地物各是什么、为什么 `.aidlc-engine/` 必须落地、
54
+ 升级后用 `--sync-skills` 刷新,以及上面那段"实现要点(改引擎挂载时必看)"。此前 README **完全没写** npm 形态,
55
+ 这正是这个坑长期没被发现的原因。
56
+ 7. **`lib/engine.js` 头注释**同步(原文还写着"用 MEMFS 造一个合成项目根")。
57
+
58
+ ### 验证(合成根形态,实跑)
59
+
60
+ 7 组断言全过:
61
+ - ① `init --empty --no-input --git-hooks` 退出码 0;
62
+ - ② 宿主上 **`.aidlc-engine/`(91 个文件)+ `.trae/` + `aidlc-data/`** 三者全部存在;
63
+ - ③ `.trae/mcp.json` 的 `args` 解出来是**真实存在**的文件;
64
+ - ④ git 钩子内容是 `.aidlc-engine/viewer/render.py`,且**不泄漏** `/engine-src`;
65
+ - ⑤ 后续 `flow status` 改用项目内引擎(不再报 ErrnoError);
66
+ - ⑥ `flow init --sync-skills` 幂等刷新(第二次「新写/更新 **0** 个文件」);
67
+ - ⑦ `flow view` 用项目内 `render.py` 产出日历。
68
+
69
+ 回归:`npm test` SMOKE PASS;仓库内形态(CPython)`flow init` 仍写 `.trae/` + `aidlc-data/`,
70
+ 且**不会**多建一份 `.aidlc-engine/`(正确 no-op,输出「(仓库内形态:引擎已在项目里,无需落地)」)。
71
+
72
+ ### 说明
73
+
74
+ - 落地后项目就是标准两目录布局,`git status` 会看到 `.aidlc-engine/` 与 `.trae/` 作为新增内容——这是**预期**的,
75
+ 它们是项目侧接入件,应当提交(派生视图仍在 `.gitignore` 里)。
76
+ - Trae 的 MCP server 仍是 `python <项目>/.aidlc-engine/cli/flow_mcp.py`,**需要本机有 Python**;
77
+ `aidlc …` 命令本身走内置 Pyodide、不需要(这是既有设计,本次未改)。
78
+
79
+ ---
80
+
81
+ ## v1.24 · 2026-10-09 — 团队名单给出角色示例 + 修 `.npmignore` 夹带仓库自身 `.trae/`
82
+
83
+ ### 背景(用户报障)
84
+
85
+ 1. **团队初始化时没有角色示例**:「后面都不知道要写什么角色」——向导只写了「格式 名字:角色」,
86
+ 没告诉用户角色长什么样。
87
+ 2. **「初始化之后原本的 `.trae` 文件夹没了,这个很重要」**——查清了机制,见下。
88
+
89
+ ### 变更明细
90
+
91
+ - **团队名单提示补角色示例**(向导 + `[next]` 块 + 常量 `ROLE_EXAMPLES`):
92
+ ```
93
+ 团队名单(每行一条,格式 名字:角色;空行结束)
94
+ 角色示例:产品负责人 / 技术负责人 / 研发负责人 / QA负责人 / 交付负责人
95
+ 也可以写:架构师 / 项目经理 / 测试工程师 / 运维 —— 角色是自由文本,用于自动推断阶段归属
96
+ 只写名字也行(按名字建成员、阶段归属再单独指定)
97
+ 例:张敏:产品负责人 / 李强:技术负责人
98
+ ```
99
+ Agent 路径的 `[next] step=team 角色示例=…` 同样带上,避免 Agent 在对话里也不知道该引导用户写什么。
100
+
101
+ - **`.npmignore` 修两处夹带**:
102
+ - **`/.trae/`**:仓库自己的 Trae 接入件(`.trae/` 是 `flow init` 装到**项目侧**的派生件)被打进了包。
103
+ 实测 **1.14.0 顶层 `.trae` 条目 = 0,1.14.1/1.14.2 变成 34**(因为仓库里出现 `.trae/` 后它不再被排除)。
104
+ 修后 `npm pack` **entryCount 87**、无顶层 `.trae`。
105
+ - **`/.tmp*`(不带斜杠)**:原来的 `/.tmp*/` 只匹配**目录**,临时**文件**(如 `.tmp-xxx.js`)仍会被打进包——
106
+ 实测修复前的包里就有这个文件。
107
+
108
+ ### 关于「`.trae` 没了」的排查结论(未改策略,待定)
109
+
110
+ - **`flow init` 不会删 `.trae`**:实测预置 4 个哨兵文件(`mcp.json` + 自有 command/rule/skill)后跑 init,
111
+ **哨兵全在**,`mcp.json` 是**合并**(用户 server + flow server 都在),总条目由少变多。
112
+ - **真正机制**:本仓库 `.gitignore` **第 34 行就是 `.trae`**(落在「IDE / 编辑器」区块,`.idea/`、`.vscode/` 旁边)
113
+ → `.trae/` **不被 git 跟踪** → `git clean -xfd`、**新克隆**、部分 checkout 都会**静默删掉它**。
114
+ - **文件自相矛盾**:同一个 `.gitignore` 第 69 行注释却写着「明确保留(不忽略)→ `.trae/` Trae 接入件」。
115
+ 两者必有一错。
116
+ - **用户项目不受影响** ✓:`templates/gitignore.example`(`flow init` 写给用户项目的那份)与
117
+ `GITIGNORE_ENTRIES` **都不含 `.trae`**,所以只有本框架仓库有这个坑。
118
+ - 策略二选一(需拍板):**(A) 取消忽略**,让 `.trae/` 可提交、可随克隆恢复、跨机器共享(代价:14 个 Skill
119
+ 是 `.aidlc-engine/skills/` 的副本,提交会重复且可能漂移);**(B) 保持忽略**(承认它是派生件),
120
+ 同时把第 69 行那条矛盾的注释改对,并在 init 输出里提示「`.trae/` 是派生件,被 clean/新克隆清掉后重跑本命令即可恢复」。
121
+ 倾向 (B)——它本就是 `flow init` 从 `.aidlc-engine/cli/.trae/` + skills 装出来的派生件。
122
+
123
+ ### 验证
124
+
125
+ - 向导输出含全部 5 个角色示例 + 「自由文本 / 可只写名字」说明(7 项断言全过);`[next]` 块含 `角色示例=`。
126
+ - `npm pack --json`:`entryCount: 87`,无 `package/.trae/**`、无 `.tmp*`;`.aidlc-engine/cli/.trae/**` 10 个条目仍在
127
+ (它是**源模板**,必须随包分发,否则装完无法生成 `.trae`)。
128
+ - 预置 `.trae` 后跑 init:哨兵 4/4 存活、`mcp.json` 合并保留用户 server。
129
+
130
+ ---
131
+
132
+ ## v1.23 · 2026-10-09 — `flow init` 收尾主动询问是否配团队(并修掉 npm 形态下的"假提示")
133
+
134
+ ### 背景
135
+
136
+ `flow init` 生成本地文件后就断了:用户看到一串 `OK:` 不知道该干什么;而「配团队」这件事**只存在于
137
+ Skill description 里**,Agent 想不到、用户也不知道——偏偏它是门控能否过的前置(门控审批人校验、
138
+ 视图「配置负责人」列、日历按人筛选都读 `team.json`)。
139
+
140
+ ### 变更明细(最简版:**单负责人**,不动数据模型)
141
+
142
+ - **`flow init` 收尾询问团队**(TTY 真交互):[1] 现在配 / [2] 先跳过 → 项目名 → 管理员 →
143
+ 名单(`名字:角色` 多行或逗号)→ 六阶段归属(**回车用按角色推断的建议值**)→ 落盘 + `validate`。
144
+ · 交互放在 `main()` 打印完汇总**之后**(`_PENDING_SETUP`),否则提问会插在 `OK:` 行前面;
145
+ · 项目名/管理员/阶段归属都有默认值,可一路回车;名单自动补上管理员;
146
+ · `parse_members` 兼容中英文冒号、逗号、换行、无角色。
147
+ - **非 TTY / `--no-input` 绝不读 stdin**,改为结构化待办:
148
+ `[next] step=team 状态=未配置 影响=… 一条命令配好=… 跳过=什么都不做即可` + `step=hooks` + `step=view`。
149
+ 这是给编排 Agent 的触发契约:它据此**在对话里**问用户。
150
+ - **一行带全**:`flow init --team "张敏:产品负责人,李强:技术负责人,…" --admin 张敏`
151
+ (`ROLE_NODE_HINT` 按角色推断阶段归属),配完自动跑 `validate`;已有 `team.json` 时走增量不覆盖。
152
+ - **MCP `flow_init` 增加 `team` / `admin`**(工具数仍 14):description 写明「初始化后请主动问用户是否配团队」
153
+ 「用户说先跳过就不要反复追问」,并**强制 `no_input=True`**(MCP 无人交互,提问必须由 Agent 在对话里完成)。
154
+
155
+ ### 修掉的四个缺陷
156
+
157
+ 1. **🔴 npm 形态下"假提示"(用户报障:输入 1 之后没反应)**:根因在 `lib/runner.js`——
158
+ `py.setStdout({batched})` **按行回调**,而 `input("选择 [1]: ")` 的提示语没有换行 → 被缓冲不显示;
159
+ 且全程没有 `py.setStdin(...)`。**探针实测**:Pyodide 下 `input()` 直接 `EOFError: EOF when reading a line`、
160
+ `sys.stdin.isatty()` 为 `False`。
161
+ 修法:新增 `can_prompt()`——`AIDLC_NPM=1`(npm/Pyodide 形态,由 `lib/index.js` 注入)一律**不问**,
162
+ 改为明确输出 `[hint] 当前环境不支持交互提问…` + 一行命令;`_ask()` 改为「先 `write`+`flush` 提示语再 `input()`」,
163
+ 并**用 `None` 区分"读不到输入"与"按回车用默认"**——读不到就中止向导,绝不再"假装在等输入"。
164
+ `next_steps_block` 里「跳过=回答『先跳过』即可」也改成「什么都不做即可」,不再诱导用户敲键盘。
165
+ 2. **`[FAIL]` 污染 init 输出 → 退出码误判**:team-init `validate` 的原始输出含 `[FAIL]`,直接拼进 init
166
+ 会让 `flow init` **退出码变 1**(明明成功)。新增 `_team_report()` 分级:硬问题(`DUTY_CONFLICT` /
167
+ 不在成员名单)→ `[warn]`;待补(未配置负责人/审批人)→ `[info]`;原始 `[FAIL]` 不再外泄。
168
+ 3. **MCP 全新项目把数据建到仓库根**:`flow_mcp.resolve_state()` 在 `aidlc-data/` 不存在时回退旧布局,
169
+ 与 `flow init` 的缺省两目录布局不一致。改为旧布局**只在真的存在时**才用。
170
+ 4. **`__pycache__` 被复制进 `.trae/skills/`**:进程内 import 引擎脚本会在源码树留 `.pyc`,再被
171
+ `_install_skills` 连带复制。新增 `_exec_no_pycache()`(加载时临时 `sys.dont_write_bytecode`),
172
+ 并让 `_install_skills` `ignore_patterns("__pycache__","*.pyc","*.pyo")`。
173
+
174
+ ### 验证
175
+
176
+ - **Pyodide 形态**:`AIDLC_NPM=1 flow init --empty` → 无任何提问、有 `[hint]` + `[next]` + 一行命令,退出码 0。
177
+ - **`--no-input`**:同上(不读 stdin)。
178
+ - **`--team` 一行带全(5 人 + `--git-hooks`)**:六阶段负责人与门控审批人齐备
179
+ (`req_review=[张敏]`、`workunit_gate=[王磊]`、`release_gate=[陈昊]`、`code_review=[]` 待指定),退出码 0,
180
+ 并给出 `code_review` 的补救命令;部分名单(2 人)报 `[info] 还有 6 项待补` + 两条补救命令,退出码 0。
181
+ - **向导逻辑(打桩 `input()`)21 项断言全过**,含新增:**EOF 时不落盘**、EOF 时明确提示"不支持交互"并给一行命令、
182
+ `can_prompt()` 在 `AIDLC_NPM=1` 下为 False、提示语确实被 `write+flush` 写出。
183
+ - **MCP 路径 6 项断言全过**:不向 stdout 泄漏、返回文本**不含**交互提问、含 `[next]`、`team.json` 落在 `aidlc-data/`、
184
+ 阶段归属按角色推断、待补项用 `[info]` 而非 `[FAIL]`。
185
+
186
+ ### 未做(明确记录)
187
+
188
+ - **阶段负责人多人(`owners` 列表)**:按「先以最初最简单的」暂不做;模型仍是单值 `owner`,
189
+ 门控审批人已是列表(`--approver` 可多次)。改动面(`render.py` 3 处读取 + `init_owners.py` 写入/校验 +
190
+ 职责分离按集合判定)已在设计稿列明。
191
+ - `flow setup` 独立向导命令、`team-init preset` 模板、「跳过」偏好持久化:未做(本次内联在 init 里)。
192
+
193
+ > ⚠️ 协作提示:本次编辑期间发现**同一工作区有第二个写者**(`CHANGELOG` v1.22 的 npm 打包/改名条目、
194
+ > `package.json` 改名 `asia-aidlc` + 版本 1.14.1 + 加 `build` 脚本 + **`description` 被清空**)。
195
+ > 这解释了更早 v1.17 那批改动"凭空消失"——并发写入互相覆盖。本次已把 `description` 填回;
196
+ > 建议后续串行编辑,避免继续互相覆盖。
197
+
198
+ ---
199
+
8
200
  ## v1.22 · 2026-10-09 — 补齐 npm 打包入口(`npm run build`)+ 包名统一为 `asia-aidlc`
9
201
 
10
202
  ### 背景(用户报障)
package/README.md CHANGED
@@ -124,6 +124,35 @@ python .aidlc-engine/cli/flow.py code verify --chg CHG-102 # 代
124
124
  3. 编排 Agent 提示词:Trae 环境由 init 自动写入 `.trae/rules/flow-orchestration.md`(alwaysApply);其他载体手工加载 `agent/orchestration-agent-system-prompt.md`。
125
125
  4. 按 `README.md` 的"落地三步"接入第一个真实需求,跑通一条完整链路。
126
126
 
127
+ ### 从 npm 安装到自己的项目
128
+
129
+ ```bash
130
+ npm i -D asia-aidlc # 或全局:npm i -g asia-aidlc(之后可直接 aidlc …,无需 npx)
131
+ npx aidlc flow init # 在你自己的项目根执行
132
+ ```
133
+
134
+ `flow init` 会把三样东西**落到你的项目里**(不是只留在 `node_modules/`):
135
+
136
+ | 落地物 | 作用 |
137
+ |---|---|
138
+ | `.aidlc-engine/` | 引擎(从包里复制过来)。**必须落地的原因**:`.trae/mcp.json` 指向 `${workspaceFolder}/.aidlc-engine/cli/flow_mcp.py`、git 钩子跑 `.aidlc-engine/viewer/render.py`——项目里没有引擎,这两条都是死路径(MCP 起不来、钩子空跑) |
139
+ | `.trae/` | Trae 接入件:MCP server、8 个斜杠命令、14 个技能、编排规则(`rules/flow-orchestration.md`) |
140
+ | `aidlc-data/` | 流程数据(状态真相 + 需求/设计/测试/发布产物) |
141
+
142
+ 落完之后项目就是标准的**两目录布局**,`.trae/` 与 `.aidlc-engine/` 都该进版本控制
143
+ (`flow init` 写入的 `.gitignore` 只忽略派生视图 `aidlc-data/*.html`、`aidlc-data/req/` 等,不忽略它们)。
144
+
145
+ 升级包之后刷新项目内引擎(幂等:只写内容有变化的文件):
146
+
147
+ ```bash
148
+ npx aidlc flow init --sync-skills # 维护模式:不覆盖状态,只刷新引擎 + 补装缺失 Skill
149
+ ```
150
+
151
+ > 实现要点(改引擎挂载时必看):npm 形态下**项目根本身**被挂到虚拟 FS 的 `/project`(NODEFS,写入直达宿主),
152
+ > 包里的引擎另挂到 `/engine-src`;`flow init` 把 `/engine-src` 落地成 `<项目>/.aidlc-engine/`,
153
+ > 之后所有命令改用项目内那份。早期只挂了 `aidlc-data` 与包内引擎两个子目录,`/project` 是内存盘,
154
+ > 于是 `.trae/` 写进了内存、进程退出即丢——**宿主上只剩 `aidlc-data/`**。
155
+
127
156
  ## 验证清单(用户后续验收用)
128
157
 
129
158
  - [ ] `state-validator` 对 example.json 返回 EXIT=0
package/bin/aidlc.js CHANGED
@@ -109,8 +109,9 @@ async function cmdDoctor() {
109
109
  const lines = [];
110
110
  lines.push(`Node ${process.version}`);
111
111
  lines.push(`包根 ${st.packageRoot}`);
112
- lines.push(`项目根 ${st.projectRoot}${st.synthetic ? "(合成根:引擎来自包,数据来自项目)" : "(仓库内形态)"}`);
112
+ lines.push(`项目根 ${st.projectRoot}${st.synthetic ? "(npm 形态:引擎来自包,flow init 会落地到项目内)" : "(仓库内形态)"}`);
113
113
  lines.push(`引擎 ${st.engineHost} ${fs.existsSync(st.engineHost) ? "OK" : "缺失"}`);
114
+ lines.push(`项目内引擎 ${path.join(st.projectRoot, ".aidlc-engine")} ${fs.existsSync(path.join(st.projectRoot, ".aidlc-engine", "cli", "flow.py")) ? "OK" : "未落地(先 aidlc flow init)"}`);
114
115
  lines.push(`数据目录 ${path.join(st.projectRoot, "aidlc-data")} ${st.hasData ? "OK" : "不存在(先 aidlc flow init)"}`);
115
116
  let pyVer = "未加载";
116
117
  try {
package/lib/engine.js CHANGED
@@ -2,10 +2,13 @@
2
2
  * engine.js —— 引擎侧定位:包根、项目根、Skill 清单、Pyodide 可用性。
3
3
  *
4
4
  * 两种部署形态(同一套代码):
5
- * A. 仓库内(本仓库就是项目):包根 === 项目根,直接整目录挂到 /project。
6
- * B. npm 安装(node_modules/asia-aidlc):项目根在用户目录,
7
- * 用 MEMFS 造一个「合成项目根」/project:/.aidlc-engine 挂包里的引擎,/aidlc-data 挂用户数据。
8
- * 这样脚本里所有基于 __file__ 的路径解析都落到 /project,**无需改一行 Python**。
5
+ * A. 仓库内(本仓库就是项目):包根 === 项目根,整目录挂到 /project。
6
+ * B. npm 安装(node_modules/asia-aidlc):项目根本身挂到 /project(NODEFS,写入直达宿主),
7
+ * 包里的引擎另挂到 /engine-src;`flow init` 会把 /engine-src **落地**成项目内的
8
+ * `.aidlc-engine/`,之后再跑命令就改用项目内那份(环境变量 AIDLC_ENGINE_SRC 由 lib/index.js 注入)。
9
+ *
10
+ * 于是两种形态最终都是同一个「两目录布局」(`<项目>/.aidlc-engine/` + `<项目>/aidlc-data/`),
11
+ * 脚本里所有基于 __file__ 的路径解析都落到 /project,**无需改一行 Python**。
9
12
  */
10
13
  const fs = require("fs");
11
14
  const path = require("path");
@@ -92,14 +95,22 @@ const NEEDS_SUBPROCESS = [
92
95
  "cli_flow.py",
93
96
  ];
94
97
 
98
+ /** npm 形态下「包里的引擎」挂载点:`flow init` 从这里复制到项目内的 .aidlc-engine/。 */
99
+ const ENGINE_SRC_GUEST = "/engine-src";
100
+
95
101
  function hasSubprocessDependency(relPath) {
96
102
  const p = String(relPath).split(path.sep).join("/");
97
103
  return NEEDS_SUBPROCESS.some((x) => p.endsWith(x));
98
104
  }
99
105
 
100
106
  /**
101
- * 计算挂载表。返回 { synthetic, mounts:[{host, guest, mustExist}], cwd, projectRoot }。
102
- * synthetic=true 表示 /project 是 MEMFS 合成根(npm 安装形态)。
107
+ * 计算挂载表。返回 { synthetic, mounts:[{host, guest, mustExist}], cwd, projectRoot, engineSrc }。
108
+ * synthetic=true 表示 npm 安装形态(引擎来自包,需要 `flow init` 落地到项目内)。
109
+ *
110
+ * ⚠️ 关键:npm 形态下**必须把项目根本身**挂到 /project。
111
+ * 早期只挂了 `/project/.aidlc-engine`(包里的引擎)与 `/project/aidlc-data` 两个子目录,
112
+ * `/project` 本身是 MEMFS 内存盘 —— 于是 `flow init` 写出的 `.trae/`(以及任何非 aidlc-data 的路径)
113
+ * 都写进了内存、进程退出即丢:实测宿主上只留下 `aidlc-data/`,`.trae` 和 `.aidlc-engine` 都不存在。
103
114
  */
104
115
  function resolveMounts(packageRoot, projectRoot) {
105
116
  const engineHost = path.join(packageRoot, ENGINE_DIR);
@@ -109,23 +120,25 @@ function resolveMounts(packageRoot, projectRoot) {
109
120
  synthetic: false,
110
121
  projectRoot,
111
122
  cwd: "/project",
123
+ engineSrc: null, // 仓库内形态:引擎就在 /project/.aidlc-engine
112
124
  mounts: [{ host: projectRoot, guest: "/project", mustExist: true }],
113
125
  };
114
126
  }
115
- const mounts = [{ host: engineHost, guest: `/project/${ENGINE_DIR}`, mustExist: true }];
116
- for (const sub of PROJECT_SUBDIRS) {
117
- const host = path.join(projectRoot, sub);
118
- if (fs.existsSync(host)) {
119
- mounts.push({ host, guest: `/project/${sub}`, mustExist: true });
120
- } else if (sub === "aidlc-data") {
121
- mounts.push({ host, guest: `/project/${sub}`, mustExist: false }); // 需要时在宿主建出来
122
- }
123
- }
124
- return { synthetic: true, projectRoot, cwd: "/project", mounts };
127
+ return {
128
+ synthetic: true,
129
+ projectRoot,
130
+ cwd: "/project",
131
+ engineSrc: ENGINE_SRC_GUEST, // 包里的引擎:供 `flow init` 复制到项目内
132
+ mounts: [
133
+ { host: projectRoot, guest: "/project", mustExist: true },
134
+ { host: engineHost, guest: ENGINE_SRC_GUEST, mustExist: true },
135
+ ],
136
+ };
125
137
  }
126
138
 
127
139
  module.exports = {
128
140
  ENGINE_DIR,
141
+ ENGINE_SRC_GUEST,
129
142
  PROJECT_SUBDIRS,
130
143
  findPackageRoot,
131
144
  findProjectRoot,
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * const r = await runSkill("team-init", ["init", "--project", "订单中心", "--admin", "张敏"]);
6
6
  * console.log(r.code, r.stdout);
7
7
  */
8
+ const fs = require("fs");
8
9
  const path = require("path");
9
10
  const eng = require("./engine");
10
11
  const { runPython } = require("./runner");
@@ -12,14 +13,24 @@ const { runPython } = require("./runner");
12
13
  function projectContext(opts = {}) {
13
14
  const packageRoot = opts.packageRoot || eng.findPackageRoot(__dirname);
14
15
  const projectRoot = opts.projectRoot || eng.findProjectRoot(opts.cwd || process.cwd());
15
- const { synthetic, mounts, cwd } = eng.resolveMounts(packageRoot, projectRoot);
16
- return { packageRoot, projectRoot, synthetic, mounts, cwd };
16
+ const { synthetic, mounts, cwd, engineSrc } = eng.resolveMounts(packageRoot, projectRoot);
17
+ return { packageRoot, projectRoot, synthetic, mounts, cwd, engineSrc };
17
18
  }
18
19
 
19
- /** 把引擎相对路径(如 .aidlc-engine/skills/x/scripts/y.py)映射为虚拟 FS 路径。 */
20
+ /**
21
+ * 把引擎相对路径(如 .aidlc-engine/skills/x/scripts/y.py)映射为虚拟 FS 路径。
22
+ *
23
+ * npm 形态下项目根与包引擎是**两个挂载点**,要分两种情形:
24
+ * · 项目内**已有**引擎(`flow init` 落地之后)→ 用 `/project/.aidlc-engine/…`,
25
+ * 与项目里 Trae 钩子/MCP 用同一份引擎,避免版本错位;
26
+ * · 项目内**还没有**引擎(首次 init 之前)→ 用包里的 `/engine-src/…`,否则连 init 都跑不起来。
27
+ */
20
28
  function guestPath(ctx, relPath) {
21
29
  const rel = String(relPath).split(path.sep).join("/").replace(/^\.\//, "");
22
- if (ctx.synthetic) return "/project/" + rel.replace(/^\.aidlc-engine\//, ".aidlc-engine/");
30
+ if (ctx.synthetic && ctx.engineSrc && rel.startsWith(eng.ENGINE_DIR + "/")) {
31
+ const inProject = fs.existsSync(path.join(ctx.projectRoot, eng.ENGINE_DIR, "cli", "flow.py"));
32
+ if (!inProject) return ctx.engineSrc + "/" + rel.slice(eng.ENGINE_DIR.length + 1);
33
+ }
23
34
  return "/project/" + rel;
24
35
  }
25
36
 
@@ -34,7 +45,7 @@ async function runScript(relPath, args = [], opts = {}) {
34
45
  args,
35
46
  mounts: ctx.mounts,
36
47
  cwd: ctx.cwd,
37
- env: { AIDLC_NPM: "1", ...(opts.env || {}) },
48
+ env: { AIDLC_NPM: "1", AIDLC_ENGINE_SRC: ctx.engineSrc || "", ...(opts.env || {}) },
38
49
  onStdout: opts.onStdout,
39
50
  onStderr: opts.onStderr,
40
51
  indexURL: opts.indexURL,
package/lib/runner.js CHANGED
@@ -61,17 +61,41 @@ if "subprocess" not in sys.modules:
61
61
  sys.modules["subprocess"] = _m
62
62
  `;
63
63
 
64
- /** 把宿主目录挂进虚拟 FS(NODEFS 直通)。挂载点若在 MEMFS 里则就地建目录,不碰宿主磁盘。 */
64
+ /**
65
+ * 把宿主目录挂进虚拟 FS(NODEFS 直通)。挂载点若在 MEMFS 里则就地建目录,不碰宿主磁盘。
66
+ *
67
+ * 同一 runtime 会被多次复用(同一个 Node 进程里连调两次 runScript),而 Emscripten 对
68
+ * **已挂载**的挂载点再 mount 会抛 ErrnoError —— 所以这里记住每个 guest 当前挂的是哪个宿主:
69
+ * · 同一个宿主 → 跳过(这是同进程多命令的常态)
70
+ * · 不同宿主 → 先 unmount 再挂(允许一个进程里先后操作不同项目)
71
+ */
72
+ const mountedHosts = new WeakMap(); // py → { guest: host }
73
+
65
74
  function ensureMounts(py, mounts) {
75
+ let state = mountedHosts.get(py);
76
+ if (!state) {
77
+ state = {};
78
+ mountedHosts.set(py, state);
79
+ }
66
80
  for (const m of mounts) {
67
81
  if (m.mustExist !== false && !fs.existsSync(m.host)) {
68
82
  throw new Error(`挂载源不存在:${m.host}`);
69
83
  }
84
+ if (state[m.guest] === m.host) continue;
85
+ if (state[m.guest]) {
86
+ try {
87
+ py.FS.unmount(m.guest);
88
+ } catch (e) {
89
+ /* 缓存与实际不一致时忽略,下面照常挂 */
90
+ }
91
+ delete state[m.guest];
92
+ }
70
93
  const parent = path.posix.dirname(m.guest);
71
94
  if (parent !== "/" && !py.FS.analyzePath(parent).exists) py.FS.mkdirTree(parent);
72
95
  if (!py.FS.analyzePath(m.guest).exists) py.FS.mkdir(m.guest);
73
96
  if (m.mustExist === false && !fs.existsSync(m.host)) fs.mkdirSync(m.host, { recursive: true });
74
97
  py.FS.mount(py.FS.filesystems.NODEFS, { root: m.host }, m.guest);
98
+ state[m.guest] = m.host;
75
99
  }
76
100
  }
77
101