@shgroup/dsh-serenity-hooks 1.48.0 → 1.48.2

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/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.48.0",
3
+ "version": "1.48.2",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2(deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
package/lib/git-ops.d.ts CHANGED
@@ -2,13 +2,23 @@
2
2
  * git-ops.ts — container_git 纯操作层(cc_git → container_git,v1.30;零 DSH 依赖)
3
3
  *
4
4
  * 行为对齐 osp(opencode-serenity-plugin/src/git/cc-git-tool.ts)——osp 是 ACC 工具 spec:
5
- * - status:JSON {clean, files:[{status,file}], summary}
6
- * - commit:git add -A + commit -m;clean 返回 '(nothing to commit — working tree clean)'
7
- * - push:无 remote 报错;非快进 → [REJECTED] + 操作建议(绝不自动 force)
5
+ * - status:JSON {clean, files:[{status,file}], summary, ahead, behind}
6
+ * - commit:默认「只提交指定 paths」;未给 paths ⇒ 整仓 add -A(并在回执里显式告知)
7
+ * - commit:**默认随后 push**(`noPush` 可关);push 结果如实附在回执里
8
+ * - push:成功判据 = **`git push --porcelain` 的 stdout 里读到 ref 行**(绝不采信"stderr 为空")
8
9
  * - pull:git fetch + merge --ff-only;up-to-date / [REJECTED] 建议
9
10
  * - log:--oneline [-n <count>]
10
11
  * - diff:git diff [--cached] [<ref>] [-- <path>]
11
12
  * 保留 dsp 增强:localstore git 合规联动(S134:deny 且 .gitignore 未覆盖 → status 提示 / commit 拒绝)。
13
+ *
14
+ * 🔴 2026-09-26 修(tiangong-serenity 报障):**push 曾把"超时被杀"报成成功**。
15
+ * 机制(**实测**,非推断):`spawnSync` 超时**不设 `err.killed`**(实测 `code=ETIMEDOUT` /
16
+ * `signal=SIGTERM` / `status=null` / `stderr` 长度 0),而 `execFileSync` **成功时丢弃 stderr**;
17
+ * 于是"超时被杀"与"真成功"都交出 `{stdout:'', stderr:''}` —— 旧判据 `if (!stderr) → 成功`
18
+ * 把两者判成同一件事,回显 `Pushed to origin/<branch>` 而远程 ref 从未变。
19
+ * ⇒ 本版三处硬修:① `git()` 交出**结构化结果**(ok/timedOut/status),守卫一律看它,不再看
20
+ * "stderr 是否为空";② push 的成功判据改为**可核验事实**(`--porcelain` 的 ref 行);
21
+ * ③ 超时/未确认一律**不报成功**,并给出 `git ls-remote` 核验指令。
12
22
  */
13
23
  import type { JsonValue } from './json.js';
14
24
  type GitAction = 'status' | 'commit' | 'push' | 'log' | 'pull' | 'diff';
@@ -16,6 +26,10 @@ export declare const GIT_ACTIONS: readonly GitAction[];
16
26
  interface GitArgs {
17
27
  action: GitAction;
18
28
  message?: string;
29
+ /** commit:只提交这些路径(不传 ⇒ 整仓 `add -A`) */
30
+ paths?: string[];
31
+ /** commit:只提交到本地、不随后推送(默认提交后自动推送) */
32
+ noPush?: boolean;
19
33
  /** log 条数(对齐 osp 参数 n,默认 10,max 100) */
20
34
  count?: number;
21
35
  /** diff: 显示暂存区变更(--cached) */
package/lib/index.js CHANGED
@@ -611,13 +611,23 @@ function createKitTool(ctx) {
611
611
  * git-ops.ts — container_git 纯操作层(cc_git → container_git,v1.30;零 DSH 依赖)
612
612
  *
613
613
  * 行为对齐 osp(opencode-serenity-plugin/src/git/cc-git-tool.ts)——osp 是 ACC 工具 spec:
614
- * - status:JSON {clean, files:[{status,file}], summary}
615
- * - commit:git add -A + commit -m;clean 返回 '(nothing to commit — working tree clean)'
616
- * - push:无 remote 报错;非快进 → [REJECTED] + 操作建议(绝不自动 force)
614
+ * - status:JSON {clean, files:[{status,file}], summary, ahead, behind}
615
+ * - commit:默认「只提交指定 paths」;未给 paths ⇒ 整仓 add -A(并在回执里显式告知)
616
+ * - commit:**默认随后 push**(`noPush` 可关);push 结果如实附在回执里
617
+ * - push:成功判据 = **`git push --porcelain` 的 stdout 里读到 ref 行**(绝不采信"stderr 为空")
617
618
  * - pull:git fetch + merge --ff-only;up-to-date / [REJECTED] 建议
618
619
  * - log:--oneline [-n <count>]
619
620
  * - diff:git diff [--cached] [<ref>] [-- <path>]
620
621
  * 保留 dsp 增强:localstore git 合规联动(S134:deny 且 .gitignore 未覆盖 → status 提示 / commit 拒绝)。
622
+ *
623
+ * 🔴 2026-09-26 修(tiangong-serenity 报障):**push 曾把"超时被杀"报成成功**。
624
+ * 机制(**实测**,非推断):`spawnSync` 超时**不设 `err.killed`**(实测 `code=ETIMEDOUT` /
625
+ * `signal=SIGTERM` / `status=null` / `stderr` 长度 0),而 `execFileSync` **成功时丢弃 stderr**;
626
+ * 于是"超时被杀"与"真成功"都交出 `{stdout:'', stderr:''}` —— 旧判据 `if (!stderr) → 成功`
627
+ * 把两者判成同一件事,回显 `Pushed to origin/<branch>` 而远程 ref 从未变。
628
+ * ⇒ 本版三处硬修:① `git()` 交出**结构化结果**(ok/timedOut/status),守卫一律看它,不再看
629
+ * "stderr 是否为空";② push 的成功判据改为**可核验事实**(`--porcelain` 的 ref 行);
630
+ * ③ 超时/未确认一律**不报成功**,并给出 `git ls-remote` 核验指令。
621
631
  */
622
632
  const GIT_ACTIONS = [
623
633
  "status",
@@ -627,48 +637,132 @@ const GIT_ACTIONS = [
627
637
  "pull",
628
638
  "diff"
629
639
  ];
630
- /** git 操作超时(ms)——网络路径(push/pull/fetch)可能挂起(GCM 弹认证框等),
631
- * 无 timeout 会冻结 Node 事件循环 / DSH 3080 server(Windows 审计问题 12) */
632
- const GIT_TIMEOUT_MS = 3e4;
633
- function git(root, args) {
640
+ /** 本地动作超时(ms) */
641
+ const DEFAULT_LOCAL_TIMEOUT_MS = 3e4;
642
+ /** 网络动作(fetch/push/pull)超时(ms)——大体量推送可能远超 30s(tiangong 实例:>60s) */
643
+ const DEFAULT_NET_TIMEOUT_MS = 18e4;
644
+ /**
645
+ * 超时可覆盖(`DSH_localTimeout()` / `DSH_netTimeout()`)。
646
+ * 🔵 两个用途:① 慢远端/大体量推送的用户可自行放宽 ② 测试可在**不真等 180s** 的前提下
647
+ * 驱动"超时"这条路径(本仓对它的回归测试就靠这个缝)。
648
+ * ⚠️ **每次调用现读**(不在模块加载时固化)——否则测试改了 env 也影响不到已加载的模块。
649
+ */
650
+ function timeoutMs(envName, dflt) {
651
+ const v = Number(process.env[envName] ?? "");
652
+ return Number.isFinite(v) && v > 0 ? v : dflt;
653
+ }
654
+ const localTimeout = () => timeoutMs("DSH_GIT_TIMEOUT_MS", DEFAULT_LOCAL_TIMEOUT_MS);
655
+ const netTimeout = () => timeoutMs("DSH_GIT_NET_TIMEOUT_MS", DEFAULT_NET_TIMEOUT_MS);
656
+ /**
657
+ * 执行 git 并交出**结构化结果**。
658
+ * 🔴 超时判据必须用 `code==='ETIMEDOUT' || signal!=null && status==null`——
659
+ * `spawnSync` 超时**不设 `err.killed`**(2026-09-26 实测;旧守卫 `err.killed ? … : err.stderr`
660
+ * 因此是死代码,超时被当成"stderr 为空 = 成功")。
661
+ */
662
+ function git(root, args, timeoutMs = localTimeout()) {
634
663
  try {
664
+ const stdout = execFileSync("git", args, {
665
+ cwd: root,
666
+ encoding: "utf8",
667
+ stdio: [
668
+ "ignore",
669
+ "pipe",
670
+ "pipe"
671
+ ],
672
+ maxBuffer: 1048576,
673
+ timeout: timeoutMs
674
+ });
635
675
  return {
636
- stdout: execFileSync("git", args, {
637
- cwd: root,
638
- encoding: "utf8",
639
- stdio: [
640
- "ignore",
641
- "pipe",
642
- "pipe"
643
- ],
644
- maxBuffer: 1048576,
645
- timeout: GIT_TIMEOUT_MS
646
- }).trimEnd(),
647
- stderr: ""
676
+ ok: true,
677
+ stdout: String(stdout).trimEnd(),
678
+ stderr: "",
679
+ timedOut: false,
680
+ status: 0
648
681
  };
649
682
  } catch (err) {
683
+ const timedOut = err?.killed === true || err?.code === "ETIMEDOUT" || err?.signal != null && err?.status == null;
650
684
  return {
651
- stdout: (err.stdout?.toString() ?? "").trimEnd(),
652
- stderr: err.killed ? `git operation timed out (${GIT_TIMEOUT_MS / 1e3}s)` : (err.stderr?.toString() ?? "").trimEnd()
685
+ ok: false,
686
+ stdout: (err?.stdout?.toString() ?? "").trimEnd(),
687
+ stderr: (err?.stderr?.toString() ?? "").trimEnd(),
688
+ timedOut,
689
+ status: typeof err?.status === "number" ? err.status : null
653
690
  };
654
691
  }
655
692
  }
656
693
  function getCurrentBranch(root) {
657
- const { stdout } = git(root, [
694
+ const r = git(root, [
658
695
  "rev-parse",
659
696
  "--abbrev-ref",
660
697
  "HEAD"
661
698
  ]);
662
- if (!stdout) throw new Error("container_git: cannot determine current branch");
663
- return stdout;
699
+ if (!r.ok || !r.stdout) throw new Error("container_git: cannot determine current branch");
700
+ return r.stdout;
664
701
  }
665
702
  function hasChanges(root) {
666
- const { stdout } = git(root, ["status", "--porcelain"]);
667
- return stdout.length > 0;
703
+ return git(root, [
704
+ "-c",
705
+ "core.quotepath=false",
706
+ "status",
707
+ "--porcelain"
708
+ ]).stdout.length > 0;
668
709
  }
669
710
  function hasRemote(root) {
670
- const { stdout } = git(root, ["remote"]);
671
- return stdout.length > 0;
711
+ return git(root, ["remote"]).stdout.length > 0;
712
+ }
713
+ /** 推送成功与否的**唯一**核验指令(给使用者,也写进回执) */
714
+ function verifyHint(branch) {
715
+ return `🔴 核验(唯一判据):git ls-remote origin ${branch},或 git fetch && git rev-parse origin/${branch} —— 与本地 HEAD 比对。**不要采信任何回执。**`;
716
+ }
717
+ const REJECTED_TEXT = (branch) => `[REJECTED] Push to origin/${branch} was rejected (non-fast-forward).\n\nRemote has new commits and your local branch is behind. Suggested actions:\n 1. Use bash: git fetch origin ${branch}\n 2. Inspect remote changes: git log HEAD..origin/${branch}\n 3. Merge or rebase: git merge origin/${branch} or git rebase origin/${branch}\n 4. Resolve conflicts manually if any: git add ... && git commit\n 5. Push again: container_git push`;
718
+ /**
719
+ * 推送并把结果**如实**翻成文本(`hard` = 作为独立动作时应抛错)。
720
+ * 🔴 成功判据 = `--porcelain` 的 **stdout 里有 ref 行**;退出码 0 但读不到 ref 行 ⇒ **不视为成功**。
721
+ */
722
+ function pushCore(root) {
723
+ if (!hasRemote(root)) return {
724
+ text: "⚠️ 未配置 remote ⇒ **未推送**(本地提交不受影响)。加 remote: git remote add origin <url>",
725
+ hard: true
726
+ };
727
+ const branch = getCurrentBranch(root);
728
+ git(root, [
729
+ "fetch",
730
+ "origin",
731
+ branch
732
+ ], netTimeout());
733
+ const r = git(root, [
734
+ "push",
735
+ "--porcelain",
736
+ "origin",
737
+ branch
738
+ ], netTimeout());
739
+ if (r.timedOut) return {
740
+ text: `[TIMEOUT] git push 在 ${netTimeout() / 1e3}s 内未结束 —— 🔴 **推送是否成功【未确认】**(既未成功也未失败)。\n原始输出: ${r.stdout || "(空)"}\n${r.stderr ? `stderr: ${r.stderr}\n` : ""}${verifyHint(branch)}`,
741
+ hard: true
742
+ };
743
+ const refLines = r.stdout.split("\n").filter((l) => /^[ =*+!-]\t/.test(l));
744
+ if (refLines.some((l) => l.startsWith("!"))) return {
745
+ text: REJECTED_TEXT(branch),
746
+ hard: false
747
+ };
748
+ const updated = refLines.filter((l) => /^[ *+-]/.test(l));
749
+ const upToDate = refLines.length > 0 && refLines.every((l) => l.startsWith("="));
750
+ if (updated.length > 0 && !upToDate) return {
751
+ text: `✅ 已推送 origin/${branch}\n${updated.join("\n")}`,
752
+ hard: false
753
+ };
754
+ if (upToDate) return {
755
+ text: `origin/${branch} 已是最新(无可推送的新提交)\n${refLines.join("\n")}`,
756
+ hard: false
757
+ };
758
+ if (!r.ok) return {
759
+ text: `[FAILED] git push 退出码 ${r.status ?? "未知"} —— **未推送**\n${r.stderr || r.stdout || "(无输出)"}\n${verifyHint(branch)}`,
760
+ hard: true
761
+ };
762
+ return {
763
+ text: `[UNVERIFIED] git push 退出码 0,但**未从 --porcelain 读到 ref 行** ⇒ 🔴 **不视为成功**。\n原始输出: ${r.stdout || "(空)"}\n${verifyHint(branch)}`,
764
+ hard: true
765
+ };
672
766
  }
673
767
  function runGit(root, args) {
674
768
  switch (args.action) {
@@ -679,7 +773,8 @@ function runGit(root, args) {
679
773
  "status",
680
774
  "--porcelain"
681
775
  ]);
682
- if (r.stderr) throw new Error(`status failed: ${r.stderr.trim()}`);
776
+ if (r.timedOut) throw new Error(`status timed out (${localTimeout() / 1e3}s)`);
777
+ if (!r.ok) throw new Error(`status failed: ${r.stderr || r.stdout || "(no output)"}`);
683
778
  const files = (r.stdout ? r.stdout.split("\n") : []).map((line) => {
684
779
  return {
685
780
  status: line.slice(0, 2).trim() || "??",
@@ -691,6 +786,21 @@ function runGit(root, args) {
691
786
  files,
692
787
  summary: files.length === 0 ? "(clean)" : `${files.length} file(s) with changes`
693
788
  };
789
+ if (hasRemote(root)) try {
790
+ const branch = getCurrentBranch(root);
791
+ const rr = git(root, [
792
+ "rev-list",
793
+ "--left-right",
794
+ "--count",
795
+ `origin/${branch}...HEAD`
796
+ ]);
797
+ if (rr.ok && /^\d+\s+\d+$/.test(rr.stdout)) {
798
+ const [behind, ahead] = rr.stdout.split(/\s+/).map(Number);
799
+ out.ahead = ahead;
800
+ out.behind = behind;
801
+ if (ahead > 0) out.summary = `${out.summary} | 🔴 本地领先 origin/${branch} **${ahead} 笔未推送**`;
802
+ }
803
+ } catch {}
694
804
  const ls = checkLocalstoreGitCompliance(root);
695
805
  if (!ls.ok) out.warning = ls.reason;
696
806
  return out;
@@ -699,36 +809,44 @@ function runGit(root, args) {
699
809
  const ls = checkLocalstoreGitCompliance(root);
700
810
  if (!ls.ok) throw new Error(ls.reason);
701
811
  if (!args.message || args.message.trim() === "") throw new Error("container_git commit: missing required arg \"message\"");
702
- if (!hasChanges(root)) return "(nothing to commit — working tree clean)";
703
- const addResult = git(root, ["add", "-A"]);
704
- if (addResult.stderr) throw new Error(`container_git commit: git add failed\n${addResult.stderr}`);
705
- const commitResult = git(root, [
812
+ const paths = (args.paths ?? []).map((p) => p.trim()).filter((p) => p !== "");
813
+ const scoped = paths.length > 0;
814
+ if (!scoped && !hasChanges(root)) return "(nothing to commit — working tree clean)";
815
+ const addR = git(root, scoped ? [
816
+ "add",
817
+ "--",
818
+ ...paths
819
+ ] : ["add", "-A"]);
820
+ if (addR.timedOut) throw new Error(`container_git commit: git add timed out (${localTimeout() / 1e3}s)`);
821
+ if (!addR.ok) throw new Error(`container_git commit: git add failed\n${addR.stderr || addR.stdout}`);
822
+ const commitR = git(root, scoped ? [
823
+ "commit",
824
+ "-m",
825
+ args.message,
826
+ "--",
827
+ ...paths
828
+ ] : [
706
829
  "commit",
707
830
  "-m",
708
831
  args.message
709
832
  ]);
710
- if (commitResult.stderr && commitResult.stderr.includes("nothing to commit")) return "(nothing to commit — working tree clean)";
711
- if (commitResult.stderr && !commitResult.stdout) throw new Error(`container_git commit: git commit failed\n${commitResult.stderr}`);
712
- return (commitResult.stdout || commitResult.stderr || "committed").trimEnd();
833
+ if (commitR.timedOut) throw new Error(`container_git commit: git commit timed out (${localTimeout() / 1e3}s)`);
834
+ if (`${commitR.stdout}\n${commitR.stderr}`.includes("nothing to commit")) return "(nothing to commit — working tree clean)";
835
+ if (!commitR.ok) throw new Error(`container_git commit: git commit failed\n${commitR.stderr || commitR.stdout}`);
836
+ const lines = [(commitR.stdout || commitR.stderr || "committed").trimEnd()];
837
+ if (!scoped) lines.push("ℹ️ 本次是**整仓**提交(未传 `paths`)—— 包含工作树里**所有**改动,可能含其它轨迹的在途文件。要只提交指定路径 ⇒ 传 `paths`。");
838
+ if (args.noPush) {
839
+ lines.push("⚠️ 按 `noPush` 只提交到**本地** —— 尚未推送到 origin。推送:container_git push");
840
+ return lines.join("\n");
841
+ }
842
+ const p = pushCore(root);
843
+ lines.push(p.hard ? `🔴 **已本地提交,但未推送** ——\n${p.text}` : p.text);
844
+ return lines.join("\n");
713
845
  }
714
846
  case "push": {
715
- if (!hasRemote(root)) throw new Error("container_git push: no remote configured. Add one with:\n git remote add origin <url>");
716
- const branch = getCurrentBranch(root);
717
- try {
718
- git(root, [
719
- "fetch",
720
- "origin",
721
- branch
722
- ]);
723
- } catch {}
724
- const { stdout, stderr } = git(root, [
725
- "push",
726
- "origin",
727
- branch
728
- ]);
729
- if (stderr.includes("non-fast-forward") || stderr.includes("rejected") || stderr.includes("[rejected]")) return `[REJECTED] Push to origin/${branch} was rejected (non-fast-forward).\n\nRemote has new commits and your local branch is behind. Suggested actions:\n 1. Use bash: git fetch origin ${branch}\n 2. Inspect remote changes: git log HEAD..origin/${branch}\n 3. Merge or rebase: git merge origin/${branch} or git rebase origin/${branch}\n 4. Resolve conflicts manually if any: git add ... && git commit\n 5. Push again: container_git push`;
730
- if (!stderr || stderr.includes("->") || stderr === "") return stdout || `Pushed to origin/${branch}`;
731
- throw new Error(`container_git push failed:\n${stderr}`);
847
+ const p = pushCore(root);
848
+ if (p.hard) throw new Error(p.text);
849
+ return p.text;
732
850
  }
733
851
  case "pull": {
734
852
  if (!hasRemote(root)) throw new Error("container_git pull: no remote configured. Add one with:\n git remote add origin <url>");
@@ -737,26 +855,29 @@ function runGit(root, args) {
737
855
  "fetch",
738
856
  "origin",
739
857
  branch
740
- ]);
741
- if (fetchResult.stderr) return `[WARN] fetch had stderr:\n${fetchResult.stderr}`;
858
+ ], netTimeout());
859
+ if (fetchResult.timedOut) return `[TIMEOUT] git fetch 在 ${netTimeout() / 1e3}s 内未结束 —— **是否已拉到【未确认】**;未做合并。`;
860
+ if (!fetchResult.ok) return `[WARN] fetch failed:\n${fetchResult.stderr || fetchResult.stdout}`;
742
861
  const revResult = git(root, [
743
862
  "rev-list",
744
863
  "--count",
745
864
  "HEAD..FETCH_HEAD"
746
865
  ]);
747
- if (revResult.stderr) return `[WARN] cannot check ahead count:\n${revResult.stderr}`;
866
+ if (!revResult.ok) return `[WARN] cannot check ahead count:\n${revResult.stderr || revResult.stdout}`;
748
867
  if (revResult.stdout === "0" || revResult.stdout === "") return "Already up to date.";
749
868
  const mergeResult = git(root, [
750
869
  "merge",
751
870
  "--ff-only",
752
871
  "FETCH_HEAD"
753
872
  ]);
754
- if (!mergeResult.stderr) {
873
+ if (mergeResult.timedOut) return `[TIMEOUT] git merge 在 ${localTimeout() / 1e3}s 内未结束 —— **合并是否完成【未确认】**。`;
874
+ if (mergeResult.ok) {
755
875
  const msg = mergeResult.stdout || "Pulled successfully.";
756
876
  return msg.endsWith("\n") ? msg.trimEnd() : msg;
757
877
  }
758
- if (mergeResult.stderr.includes("non-fast-forward") || mergeResult.stderr.includes("Not possible to fast-forward") || mergeResult.stderr.includes("rejected") || mergeResult.stderr.includes("could not be applied")) return `[REJECTED] Pull from origin/${branch} was rejected (non-fast-forward).\n\nRemote has new commits and your local history diverged from remote (non-fast-forward). Suggested actions:\n 1. Inspect differences: container_git log HEAD..origin/${branch}\n 2. Merge manually with bash: git merge origin/${branch}\n 3. Or rebase: git rebase origin/${branch}\n 4. Resolve conflicts manually if any: git add <file> && git commit\n 5. Push: container_git push`;
759
- throw new Error(`container_git pull failed:\n${mergeResult.stderr}`);
878
+ const mErr = `${mergeResult.stderr}\n${mergeResult.stdout}`;
879
+ if (mErr.includes("non-fast-forward") || mErr.includes("Not possible to fast-forward") || mErr.includes("rejected") || mErr.includes("could not be applied")) return `[REJECTED] Pull from origin/${branch} was rejected (non-fast-forward).\n\nRemote has new commits and your local history diverged from remote (non-fast-forward). Suggested actions:\n 1. Inspect differences: container_git log HEAD..origin/${branch}\n 2. Merge manually with bash: git merge origin/${branch}\n 3. Or rebase: git rebase origin/${branch}\n 4. Resolve conflicts manually if any: git add <file> && git commit\n 5. Push: container_git push`;
880
+ throw new Error(`container_git pull failed:\n${mergeResult.stderr || mergeResult.stdout}`);
760
881
  }
761
882
  case "log": {
762
883
  const n = Math.min(Math.max(args.count ?? 10, 1), 100);
@@ -768,7 +889,8 @@ function runGit(root, args) {
768
889
  "-n",
769
890
  String(n)
770
891
  ]);
771
- if (r.stderr) throw new Error(`git log failed: ${r.stderr.trim()}`);
892
+ if (r.timedOut) throw new Error(`git log timed out (${localTimeout() / 1e3}s)`);
893
+ if (!r.ok) throw new Error(`git log failed: ${r.stderr || r.stdout || "(no output)"}`);
772
894
  if (!r.stdout) return "(no commits)";
773
895
  return r.stdout;
774
896
  }
@@ -777,10 +899,11 @@ function runGit(root, args) {
777
899
  if (args.staged) diffArgs.push("--cached");
778
900
  if (args.ref) diffArgs.push(args.ref);
779
901
  if (args.path) diffArgs.push("--", args.path);
780
- const { stdout, stderr } = git(root, diffArgs);
781
- if (stderr) return `[WARN] git diff had stderr:\n${stderr}`;
782
- if (!stdout) return "(no diff)";
783
- return stdout;
902
+ const r = git(root, diffArgs);
903
+ if (r.timedOut) throw new Error(`git diff timed out (${localTimeout() / 1e3}s)`);
904
+ if (!r.ok) return `[WARN] git diff failed:\n${r.stderr || r.stdout || "(no output)"}`;
905
+ if (!r.stdout) return "(no diff)";
906
+ return r.stdout;
784
907
  }
785
908
  default: throw new Error(`Unknown action: ${args.action}`);
786
909
  }
@@ -798,7 +921,7 @@ function renderText$7(value) {
798
921
  }
799
922
  const gitTool = defineTool({
800
923
  name: "container_git",
801
- description: "CCC git operations (cc-git semantics, aligned with osp). status/commit/push/log/pull/diff; non-fast-forward push/pull outputs [REJECTED] + suggested actions (never auto-force). merge/rebase/conflict resolution go through bash.",
924
+ description: "CCC git operations (cc-git semantics, aligned with osp). status/commit/push/log/pull/diff. commit 默认**随后自动 push**(noPush 可关);commit 传 paths ⇒ 只提交这些路径(不传=整仓 add -A,回执会显式告知)。push 成功判据 = --porcelain 的 ref 行(**超时/未确认一律不报成功**,回执给 `git ls-remote` 核验指令);非快进 push/pull 输出 [REJECTED] + 建议(never auto-force)。merge/rebase/conflict resolution go through bash.",
802
925
  parameters: {
803
926
  action: {
804
927
  type: "string",
@@ -810,6 +933,15 @@ const gitTool = defineTool({
810
933
  type: "string",
811
934
  description: "Commit message (required for commit)"
812
935
  },
936
+ paths: {
937
+ type: "array",
938
+ items: { type: "string" },
939
+ description: "commit: 只提交这些路径(如 [\"docs/a.md\",\"src/b.ts\"])。不传 = 整仓 `git add -A`(会吸走其它轨迹的在途文件,回执会显式提示)"
940
+ },
941
+ noPush: {
942
+ type: "boolean",
943
+ description: "commit: 只提交到本地,不随后推送(默认提交后自动推送)"
944
+ },
813
945
  count: {
814
946
  type: "integer",
815
947
  description: "Log count (default 10, max 100; runtime clamp 1-100)"
@@ -17031,6 +17163,7 @@ function opencodeRouteHeaders() {
17031
17163
  const OPENCODE_EXTRA_MODELS = { "deepseek-v4.1-flash": {
17032
17164
  name: "DeepSeek V4.1 Flash",
17033
17165
  api: "openai-completions",
17166
+ baseUrl: "https://opencode.ai/zen/go/v1",
17034
17167
  contextWindow: 1e6,
17035
17168
  maxTokens: 384e3,
17036
17169
  input: ["text"],
@@ -17074,41 +17207,104 @@ const OPENCODE_GO_NON_COMPLETIONS = /* @__PURE__ */ new Set([
17074
17207
  "muse-spark-1.2-contributor"
17075
17208
  ]);
17076
17209
  /**
17077
- * 往**已有显式 `models` 列表**的路由里追加目录未收录的模型(**纯函数**)。
17210
+ * 把 {@link OPENCODE_EXTRA_MODELS} 落到一个**已有显式 `models` 列表**的路由上(**纯函数**)。
17211
+ *
17212
+ * 两种落法(v1.48.1 起):
17213
+ * · **追加**(模型不在表里)—— 目录里没有它,连条目都得我们造。
17214
+ * · **补规格**(模型已在表里、但缺 `reasoningEfforts`)—— 🔴 **这是"手加过"的形态**:
17215
+ * 用户把模型抄进列表了,可没抄推理档 ⇒ 宿主 `resolveModelReasoning` 走 `base?.reasoning ?? false`
17216
+ * ⇒ **能用但永远不出 thinking**。只补"缺失的模型"是补不到这一形态的。
17078
17217
  *
17079
- * 三条拒绝条件(每条都对应一个会让配置变坏的真实形态):
17080
- * ① **没有显式 `models` 列表** ⇒ 该路由此刻服务的是 pi-ai 目录本体;
17081
- * 一旦我们写出 `models`,**整个目录会被顶掉**(宿主的 `models` 是"整体替换"语义)
17082
- * ⇒ 宁可不动。
17218
+ * 拒绝条件(每条都对应一个会让配置变坏的真实形态;宁可不动也不硬来):
17219
+ * ① **没有显式 `models` 列表**(含空表)⇒ 该路由此刻服务的是 pi-ai 目录本体;
17220
+ * 一旦我们写出 `models`,**整个目录会被顶掉**(宿主的 `models` 是"整体替换"语义)。
17083
17221
  * ② **列表里出现已知异协议模型** ⇒ 设路由级 `api` 会覆盖它们的 `base.api`,把它们配坏。
17084
17222
  * ③ **路由已显式设了别的 `api`** ⇒ 同上,且那是用户的显式选择,不覆盖。
17223
+ * ④ **要补规格却判不出协议**(路由没写 `api`,又不追加任何模型)⇒ 无法保证 compat 合法。
17085
17224
  */
17086
17225
  function planExtraModels(profile) {
17087
17226
  const raw = profile.models;
17088
17227
  if (!Array.isArray(raw) || raw.length === 0) return {
17089
17228
  addedIds: [],
17229
+ enrichedIds: [],
17090
17230
  skip: "catalog-served"
17091
17231
  };
17092
- const ids = /* @__PURE__ */ new Set();
17093
- for (const entry of raw) if (isPlainObject$1(entry) && typeof entry.id === "string") ids.add(entry.id);
17094
- const missing = Object.entries(OPENCODE_EXTRA_MODELS).filter(([id]) => !ids.has(id));
17095
- if (missing.length === 0) return { addedIds: [] };
17096
- for (const id of ids) if (OPENCODE_GO_NON_COMPLETIONS.has(id)) return {
17232
+ const byId = /* @__PURE__ */ new Map();
17233
+ for (const entry of raw) if (isPlainObject$1(entry) && typeof entry.id === "string") byId.set(entry.id, entry);
17234
+ for (const id of byId.keys()) if (OPENCODE_GO_NON_COMPLETIONS.has(id)) return {
17097
17235
  addedIds: [],
17236
+ enrichedIds: [],
17098
17237
  skip: "mixed-protocol"
17099
17238
  };
17100
- const apis = new Set(missing.map(([, model]) => model.api));
17101
- if (apis.size !== 1) return {
17239
+ const missing = Object.entries(OPENCODE_EXTRA_MODELS).filter(([id]) => !byId.has(id));
17240
+ const existingApi = typeof profile.api === "string" && profile.api !== "" ? profile.api : void 0;
17241
+ let pendingApi;
17242
+ if (missing.length > 0) {
17243
+ const apis = new Set(missing.map(([, model]) => model.api));
17244
+ if (apis.size !== 1) return {
17245
+ addedIds: [],
17246
+ enrichedIds: [],
17247
+ skip: "mixed-protocol"
17248
+ };
17249
+ pendingApi = [...apis][0];
17250
+ }
17251
+ if (existingApi !== void 0 && pendingApi !== void 0 && existingApi !== pendingApi) return {
17102
17252
  addedIds: [],
17103
- skip: "mixed-protocol"
17253
+ enrichedIds: [],
17254
+ skip: "api-conflict"
17104
17255
  };
17105
- const api = [...apis][0];
17106
- const existingApi = profile.api;
17107
- if (typeof existingApi === "string" && existingApi !== api) return {
17256
+ /** 写完之后该路由的**有效协议**(第三方条目也会按它解析) */
17257
+ const effectiveApi = existingApi ?? pendingApi;
17258
+ const toEnrich = [];
17259
+ for (const [id, model] of Object.entries(OPENCODE_EXTRA_MODELS)) {
17260
+ const entry = byId.get(id);
17261
+ if (entry === void 0) continue;
17262
+ if (entry.reasoningEfforts !== void 0) continue;
17263
+ if (effectiveApi === void 0) return {
17264
+ addedIds: [],
17265
+ enrichedIds: [],
17266
+ skip: "api-missing"
17267
+ };
17268
+ if (effectiveApi !== model.api) return {
17269
+ addedIds: [],
17270
+ enrichedIds: [],
17271
+ skip: "api-conflict"
17272
+ };
17273
+ toEnrich.push([
17274
+ id,
17275
+ model,
17276
+ entry
17277
+ ]);
17278
+ }
17279
+ if (missing.length === 0 && toEnrich.length === 0) return {
17108
17280
  addedIds: [],
17109
- skip: "api-conflict"
17281
+ enrichedIds: []
17110
17282
  };
17111
- const appended = missing.map(([id, model]) => ({
17283
+ let baseUrl;
17284
+ if (!(typeof profile.baseURL === "string" && profile.baseURL !== "")) {
17285
+ const want = new Set([...missing.map(([, m]) => m), ...toEnrich.map(([, m]) => m)].map((m) => m.baseUrl));
17286
+ if (want.size !== 1) return {
17287
+ addedIds: [],
17288
+ enrichedIds: [],
17289
+ skip: "mixed-protocol"
17290
+ };
17291
+ baseUrl = [...want][0];
17292
+ }
17293
+ const enrichById = new Map(toEnrich.map(([id, model]) => [id, model]));
17294
+ const models = raw.map((entry) => {
17295
+ if (!isPlainObject$1(entry) || typeof entry.id !== "string") return entry;
17296
+ const model = enrichById.get(entry.id);
17297
+ if (model === void 0) return entry;
17298
+ return {
17299
+ ...entry,
17300
+ reasoningEfforts: { ...model.reasoningEfforts },
17301
+ compat: {
17302
+ ...model.compat,
17303
+ ...isPlainObject$1(entry.compat) ? entry.compat : {}
17304
+ }
17305
+ };
17306
+ });
17307
+ for (const [id, model] of missing) models.push({
17112
17308
  id,
17113
17309
  name: model.name,
17114
17310
  contextWindow: model.contextWindow,
@@ -17116,11 +17312,13 @@ function planExtraModels(profile) {
17116
17312
  input: [...model.input],
17117
17313
  reasoningEfforts: { ...model.reasoningEfforts },
17118
17314
  compat: { ...model.compat }
17119
- }));
17315
+ });
17120
17316
  return {
17121
- models: [...raw, ...appended],
17122
- ...existingApi === api ? {} : { api },
17123
- addedIds: missing.map(([id]) => id)
17317
+ models,
17318
+ ...pendingApi !== void 0 && existingApi === void 0 ? { api: pendingApi } : {},
17319
+ ...baseUrl === void 0 ? {} : { baseUrl },
17320
+ addedIds: missing.map(([id]) => id),
17321
+ enrichedIds: toEnrich.map(([id]) => id)
17124
17322
  };
17125
17323
  }
17126
17324
  function isPlainObject$1(value) {
@@ -17198,16 +17396,24 @@ function planOpencodeAutoConfig(input) {
17198
17396
  route,
17199
17397
  reason: extra.skip
17200
17398
  });
17201
- else if (extra.addedIds.length > 0) actions.push({
17202
- kind: "add-models",
17203
- route,
17204
- ids: extra.addedIds,
17205
- api: extra.api ?? null
17206
- });
17399
+ else {
17400
+ if (extra.addedIds.length > 0) actions.push({
17401
+ kind: "add-models",
17402
+ route,
17403
+ ids: extra.addedIds,
17404
+ api: extra.api ?? null
17405
+ });
17406
+ if (extra.enrichedIds.length > 0) actions.push({
17407
+ kind: "enrich-models",
17408
+ route,
17409
+ ids: extra.enrichedIds
17410
+ });
17411
+ }
17207
17412
  const routePatch = {};
17208
17413
  if (keys.length > 0) routePatch.headers = missing;
17209
17414
  if (extra.models !== void 0) routePatch.models = extra.models;
17210
17415
  if (extra.api !== void 0) routePatch.api = extra.api;
17416
+ if (extra.baseUrl !== void 0) routePatch.baseURL = extra.baseUrl;
17211
17417
  if (keys.length === 0 && Object.keys(routePatch).length === 0) {
17212
17418
  actions.push({
17213
17419
  kind: "skip-route",
@@ -17249,7 +17455,8 @@ function describeOpencodeAction(action) {
17249
17455
  case "create-route": return `创建 ${action.route} 路由(检测到 ${OPENCODE_API_KEY_ENV})+ 路由头`;
17250
17456
  case "skip-route": return `${action.route} 路由头已齐备(跳过)`;
17251
17457
  case "add-models": return `为 ${action.route} 补目录未收录的模型:${action.ids.join(", ")}` + (action.api === null ? "" : `(并设路由 api=${action.api})`);
17252
- case "skip-models": return `${action.route} 未补模型(${action.reason === "catalog-served" ? "该路由未声明 models 列表,补 models 会顶掉整个目录" : action.reason === "mixed-protocol" ? "该路由含异协议模型,设路由 api 会把它们配坏" : "该路由已显式设了别的 api"})`;
17458
+ case "enrich-models": return `为 ${action.route} 已列出的模型补推理档:${action.ids.join(", ")}(此前「能用但没 thinking」)`;
17459
+ case "skip-models": return `${action.route} 未补模型(${action.reason === "catalog-served" ? "该路由未声明 models 列表,补 models 会顶掉整个目录" : action.reason === "mixed-protocol" ? "该路由含异协议模型,设路由 api 会把它们配坏" : action.reason === "api-missing" ? "该模型已列出但缺推理档,而路由没写 api ⇒ 判不出该用哪套 compat" : "该路由的 api 与本表规格不一致"})`;
17253
17460
  case "idle": return `无需动作(${action.reason})`;
17254
17461
  }
17255
17462
  }
@@ -94,6 +94,16 @@ export interface OpencodeExtraModel {
94
94
  readonly name: string;
95
95
  /** 该模型说的 wire 协议;见 {@link OPENCODE_GO_NON_COMPLETIONS} 的安全闸 */
96
96
  readonly api: string;
97
+ /**
98
+ * 该模型的端点基址 —— 🔴 **目录外模型的必需品**(v1.48.1 补)。
99
+ *
100
+ * 宿主 `resolveRouteModels` 的兜底链是 `request.baseURL ?? base?.baseUrl ?? providerBaseUrl`;
101
+ * 目录里没有它 ⇒ `base` 为 undefined,而 pi-ai 的 `opencodeGoProvider()` **也没有 provider 级
102
+ * baseUrl** ⇒ 三级全空 ⇒ 宿主**严格写入直接把整条补丁拒掉**(v1.48.0 真机实测原文:
103
+ * `model "deepseek-v4.1-flash" needs a baseURL; the installed catalog does not describe this route`)。
104
+ * 故本字段是**唯一可控的那一环**:路由自身没写 `baseURL` 时,必须由我们补上。
105
+ */
106
+ readonly baseUrl: string;
97
107
  readonly contextWindow: number;
98
108
  readonly maxTokens: number;
99
109
  readonly input: readonly string[];
@@ -135,10 +145,14 @@ type OpencodeAutoConfigAction = {
135
145
  route: string;
136
146
  ids: readonly string[];
137
147
  api: string | null;
148
+ } | {
149
+ kind: 'enrich-models';
150
+ route: string;
151
+ ids: readonly string[];
138
152
  } | {
139
153
  kind: 'skip-models';
140
154
  route: string;
141
- reason: 'catalog-served' | 'mixed-protocol' | 'api-conflict';
155
+ reason: 'catalog-served' | 'mixed-protocol' | 'api-conflict' | 'api-missing';
142
156
  } | {
143
157
  kind: 'idle';
144
158
  reason: 'namespace-unregistered' | 'no-route';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.48.0",
3
+ "version": "1.48.2",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1。",
5
5
  "license": "MIT",
6
6
  "repository": {