@thincoder/core 0.9.2 → 0.9.3

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 (52) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +1 -0
  3. package/agent/completion.mjs +3 -1
  4. package/agent/family-tools.mjs +12 -9
  5. package/agent/helpers.mjs +11 -2
  6. package/agent/run-stages.mjs +27 -5
  7. package/agent/setup.mjs +6 -0
  8. package/agent/write-gate.mjs +5 -5
  9. package/agent-tools/advisor-async.mjs +4 -4
  10. package/agent-tools/advisor.mjs +1 -1
  11. package/agent-tools/async-discard.mjs +1 -1
  12. package/agent-tools/audit-block.mjs +106 -0
  13. package/agent-tools/batch-lifecycle.mjs +72 -16
  14. package/agent-tools/batch-skeleton.mjs +69 -7
  15. package/agent-tools/batch.mjs +19 -6
  16. package/agent-tools/context.mjs +174 -0
  17. package/agent-tools/goal.mjs +7 -0
  18. package/agent-tools/parent-channel.mjs +2 -2
  19. package/agent-tools/plan.mjs +6 -6
  20. package/agent-tools/read-history.mjs +122 -24
  21. package/agent-tools/settings.mjs +4 -2
  22. package/agent-tools/subagent-async.mjs +3 -3
  23. package/agent-tools/subagent-spawn.mjs +29 -101
  24. package/agent-tools/task.mjs +11 -0
  25. package/agent-tools.mjs +4 -1
  26. package/agent.mjs +10 -4
  27. package/config.mjs +1 -1
  28. package/context.mjs +66 -121
  29. package/fts-text.mjs +41 -0
  30. package/memory/core.mjs +4 -18
  31. package/memory/schema.mjs +4 -11
  32. package/package.json +5 -1
  33. package/prompts/common.md +2 -2
  34. package/prompts/discipline-engineering.md +17 -2
  35. package/prompts/persona-engineering.md +1 -1
  36. package/session-gc.mjs +11 -0
  37. package/session-index-build.mjs +298 -0
  38. package/session-index-cmd.mjs +61 -0
  39. package/session-index-pass.mjs +95 -0
  40. package/session-index-query.mjs +102 -0
  41. package/session-index.mjs +285 -0
  42. package/session-slots-manifest.mjs +19 -0
  43. package/token-window.mjs +188 -0
  44. package/tools/bash.mjs +4 -15
  45. package/tools/execute.mjs +5 -13
  46. package/tools/git-checkpoint.mjs +1 -1
  47. package/tools/git-ext.mjs +23 -20
  48. package/tools/git-run.mjs +141 -0
  49. package/tools/git.mjs +42 -36
  50. package/tools/index.mjs +3 -1
  51. package/tools/process-tree.mjs +20 -0
  52. package/tools/shared.mjs +8 -4
package/tools/git-ext.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  * 供 git.mjs 核心 action 复用——500 行硬限拆分)。CLI 与 VS Code 两端同构(镜像,修改须两端同批)。
5
5
  */
6
6
  import { runGit, truncate } from "./shared.mjs"
7
- import { execFileSync } from "node:child_process"
7
+ import { spawnGit, gitTimeoutNote } from "./git-run.mjs"
8
8
 
9
9
  /** Keep only output lines matching a regex (git filter, case-insensitive). */
10
10
  export function filterLines(output, filter) {
@@ -19,12 +19,15 @@ export function filterLines(output, filter) {
19
19
  }
20
20
 
21
21
  /** Run git and report failure (stderr + exit code) instead of swallowing it.
22
- * Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
23
- export function runGitStrict(cwd, cmdArgs, config = []) {
22
+ * Used by write ops (commit/push/rm) where a silent "" would masquerade as success.
23
+ * §6.14:体改**异步薄壳**(`spawnGit` 单点——加固 env + 两档超时 + 树杀);`{ok, out, err}` 形逐字保留
24
+ * + 超时分支(`err` = 单源超时注——可辨性不靠 stderr 文本猜)。 */
25
+ export async function runGitStrict(cwd, cmdArgs, config = []) {
24
26
  try {
25
- const out = execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
27
+ const out = (await spawnGit(cwd, [...config, ...cmdArgs])).trim().replace(/\r/g, "")
26
28
  return { ok: true, out }
27
29
  } catch (e) {
30
+ if (e.timedOut) return { ok: false, out: String(e.stdout || "").trim(), err: gitTimeoutNote(e.timeoutMs) }
28
31
  return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
29
32
  }
30
33
  }
@@ -36,7 +39,7 @@ export function validateRef(ref, what = "git ref") {
36
39
  }
37
40
 
38
41
  /** Normalize args.config into `-c key=value` pairs (git -c overrides, e.g. a proxy).
39
- * Values are execFileSync array args (no shell injection) — still reject newlines/empty. */
42
+ * Values are spawn array args (no shell injection) — still reject newlines/empty. */
40
43
  export function gitConfigArgs(config) {
41
44
  if (config == null) return []
42
45
  if (!Array.isArray(config)) throw new Error("config must be an array of \"key=value\" strings")
@@ -70,11 +73,11 @@ export async function executeExtAction(args, ctx) {
70
73
  if (!args.remote) return "Error: clone requires remote (URL or local path)"
71
74
  const cmdArgs = ["clone", args.remote]
72
75
  if (args.path) cmdArgs.push(args.path)
73
- const r = runGitStrict(ctx.cwd, cmdArgs, gitConfigArgs(args.config))
76
+ const r = await runGitStrict(ctx.cwd, cmdArgs, gitConfigArgs(args.config))
74
77
  return r.ok ? truncate(r.out || `Cloned ${args.remote}`) : truncate(`git clone failed: ${r.err || r.out}`)
75
78
  }
76
79
  case "init": {
77
- const r = runGitStrict(ctx.cwd, ["init"])
80
+ const r = await runGitStrict(ctx.cwd, ["init"])
78
81
  return r.ok ? truncate(r.out || "Initialized empty git repository") : truncate(`git init failed: ${r.err || r.out}`)
79
82
  }
80
83
  case "rebase": {
@@ -89,21 +92,21 @@ export async function executeExtAction(args, ctx) {
89
92
  if (sub === "abort") cmdArgs.push("--abort")
90
93
  else if (sub === "continue") cmdArgs.push("--continue")
91
94
  else { if (!args.ref) return "Error: rebase requires ref (branch/commit to rebase onto)"; cmdArgs.push(validateRef(args.ref)) }
92
- const r = runGitStrict(ctx.cwd, cmdArgs)
95
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
93
96
  return r.ok ? truncate(snap + (r.out || `Rebase ${sub} complete`)) : truncate(snap + `git rebase failed: ${r.err || r.out} — use rebaseAction=abort to abort`)
94
97
  }
95
98
  case "remote": {
96
99
  const sub = args.remoteAction ?? "list"
97
- if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["remote", "-v"]) || "(no remotes)", args.filter))
100
+ if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["remote", "-v"]) || "(no remotes)", args.filter))
98
101
  if (!args.remote) return `Error: remote ${sub} requires remote (name)`
99
102
  validateRef(args.remote, "remote name")
100
103
  if (sub === "add" || sub === "set-url") {
101
104
  if (!args.remoteUrl) return `Error: remote ${sub} requires remoteUrl`
102
- const r = runGitStrict(ctx.cwd, ["remote", sub === "add" ? "add" : "set-url", args.remote, args.remoteUrl])
105
+ const r = await runGitStrict(ctx.cwd, ["remote", sub === "add" ? "add" : "set-url", args.remote, args.remoteUrl])
103
106
  return r.ok ? `Remote ${args.remote} ${sub === "add" ? "added" : "URL set"}` : truncate(`git remote ${sub} failed: ${r.err || r.out}`)
104
107
  }
105
108
  if (sub === "remove") {
106
- const r = runGitStrict(ctx.cwd, ["remote", "remove", args.remote])
109
+ const r = await runGitStrict(ctx.cwd, ["remote", "remove", args.remote])
107
110
  return r.ok ? `Remote ${args.remote} removed` : truncate(`git remote remove failed: ${r.err || r.out}`)
108
111
  }
109
112
  return "Error: remote requires remoteAction — use: list | add | remove | set-url"
@@ -113,7 +116,7 @@ export async function executeExtAction(args, ctx) {
113
116
  // dryRun (-n) is a preview: no deletion, no snapshot.
114
117
  const snap = args.dryRun ? "" : await snapshotBefore(ctx, "clean")
115
118
  const cmdArgs = ["clean", args.dryRun ? "-n" : "-f", "-d"]
116
- const r = runGitStrict(ctx.cwd, cmdArgs)
119
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
117
120
  return r.ok ? truncate(snap + (r.out || (args.dryRun ? "Nothing to clean (dry run)" : "Clean complete"))) : truncate(snap + `git clean failed: ${r.err || r.out}`)
118
121
  }
119
122
  case "switch": {
@@ -122,28 +125,28 @@ export async function executeExtAction(args, ctx) {
122
125
  const cmdArgs = ["switch"]
123
126
  if (args.create) cmdArgs.push("-c")
124
127
  cmdArgs.push(args.name)
125
- const r = runGitStrict(ctx.cwd, cmdArgs)
128
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
126
129
  return r.ok ? truncate(r.out || `Switched to branch ${args.name}`) : truncate(`git switch failed: ${r.err || r.out}`)
127
130
  }
128
131
  case "apply": {
129
132
  // Apply a patch — non-destructive (fails cleanly on conflict, applies nothing).
130
133
  if (!args.path) return "Error: apply requires path (patch file)"
131
- const r = runGitStrict(ctx.cwd, ["apply", "--", args.path])
134
+ const r = await runGitStrict(ctx.cwd, ["apply", "--", args.path])
132
135
  return r.ok ? truncate(r.out || `Applied ${args.path}`) : truncate(`git apply failed: ${r.err || r.out}`)
133
136
  }
134
137
  case "worktree": {
135
138
  const sub = args.worktreeAction ?? "list"
136
- if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["worktree", "list"]) || "(no worktrees)", args.filter))
139
+ if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["worktree", "list"]) || "(no worktrees)", args.filter))
137
140
  if (sub === "add") {
138
141
  if (!args.path) return "Error: worktree add requires path (new worktree directory)"
139
142
  const cmdArgs = ["worktree", "add", args.path]
140
143
  if (args.ref) cmdArgs.push(validateRef(args.ref))
141
- const r = runGitStrict(ctx.cwd, cmdArgs)
144
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
142
145
  return r.ok ? truncate(r.out || `Worktree added at ${args.path}`) : truncate(`git worktree add failed: ${r.err || r.out}`)
143
146
  }
144
147
  if (sub === "remove") {
145
148
  if (!args.path) return "Error: worktree remove requires path"
146
- const r = runGitStrict(ctx.cwd, ["worktree", "remove", args.path])
149
+ const r = await runGitStrict(ctx.cwd, ["worktree", "remove", args.path])
147
150
  return r.ok ? truncate(r.out || `Worktree removed: ${args.path}`) : truncate(`git worktree remove failed: ${r.err || r.out}`)
148
151
  }
149
152
  return "Error: worktree requires worktreeAction — use: list | add | remove"
@@ -154,17 +157,17 @@ export async function executeExtAction(args, ctx) {
154
157
  const cmdArgs = ["archive", "--format=tar", "-o", args.path]
155
158
  if (args.ref) cmdArgs.push(validateRef(args.ref))
156
159
  else cmdArgs.push("HEAD")
157
- const r = runGitStrict(ctx.cwd, cmdArgs)
160
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
158
161
  return r.ok ? truncate(r.out || `Archived ${args.ref ?? "HEAD"} to ${args.path}`) : truncate(`git archive failed: ${r.err || r.out}`)
159
162
  }
160
163
  case "blame": {
161
164
  if (!args.path) return "Error: blame requires path (file)"
162
- const out = runGit(ctx.cwd, ["blame", "--", args.path])
165
+ const out = await runGit(ctx.cwd, ["blame", "--", args.path])
163
166
  return truncate(out || `(no blame output for ${args.path})`)
164
167
  }
165
168
  case "mv": {
166
169
  if (!args.path || !args.dest) return "Error: mv requires path (source) and dest (destination)"
167
- const r = runGitStrict(ctx.cwd, ["mv", "--", args.path, args.dest])
170
+ const r = await runGitStrict(ctx.cwd, ["mv", "--", args.path, args.dest])
168
171
  return r.ok ? truncate(r.out || `Moved ${args.path} → ${args.dest}`) : truncate(`git mv failed: ${r.err || r.out}`)
169
172
  }
170
173
  default:
@@ -0,0 +1,141 @@
1
+ /**
2
+ * tools/git-run.mjs — git spawn 单点(TOOLS.md §6.14 · 批 GIT-NONINTERACTIVE · 台账 #207)。
3
+ *
4
+ * 为什么单点:git 的四交互族(编辑器 / 凭据 / GUI / pager)在**无 TTY** 下永久等待——
5
+ * `rebase --continue` 起编辑器即冻死整个会话(已发布 `0.12.64` 实报)。加固只许一处
6
+ * (逐调用点补 = 必漏):三个形适配器(`shared.mjs` `runGit` · `git.mjs` `runGitRaw` ·
7
+ * `git-ext.mjs` `runGitStrict`)体转**异步薄壳**委托本档,签名与返回形零变。
8
+ *
9
+ * 三件套:① `GIT_ENV` 加固集(**env 形**——实测 `GIT_EDITOR` env 优先于 `core.editor`,
10
+ * `-c` 形单独不足)② 两档超时(本地 120s / 网络 300s · 全量适用)③ 超时动作序
11
+ * (SIGTERM → 1.5s 树杀 → 1.5s kick——孙进程持管道时 `close` 永不触发)。
12
+ */
13
+ import { spawn } from "node:child_process"
14
+ import { killProcessTree } from "./process-tree.mjs"
15
+
16
+ /** 加固集(逐字 = §6.14「加固集」):继承面在前,加固键一律置后覆盖——继承面不得反超。
17
+ * 编辑器族 4 键 · pager 族 2 键 · 凭据族 2 键(本批新增,bash 面亦无)· 终端族 1 键。 */
18
+ export const GIT_ENV = {
19
+ ...process.env, // 继承面(PATH / HOME / 用户 proxy 等)
20
+ GIT_EDITOR: "true", // 编辑器族:提交信息编辑器(解析链最高优先键)
21
+ GIT_SEQUENCE_EDITOR: "true", // 编辑器族:rebase todo 列表编辑器
22
+ EDITOR: "true", // 编辑器族:兜底链 VISUAL / EDITOR
23
+ VISUAL: "true",
24
+ GIT_PAGER: "cat", // pager 族(bash 工具先例同值)
25
+ PAGER: "cat",
26
+ GIT_TERMINAL_PROMPT: "0", // 凭据族:禁终端提示
27
+ GIT_ASKPASS: "", // 凭据族:空串 ⇒ 不调 askpass 程序(同时封 core.askpass 升级路)
28
+ TERM: "dumb", // 终端族(bash 工具先例同值)
29
+ }
30
+
31
+ export const GIT_TIMEOUT_MS = 120_000
32
+ export const GIT_NET_TIMEOUT_MS = 300_000
33
+ /** 网路面五动作(300s 档——合法耗时可远超本地;依据 = 需求档 §4.7 TTY-DRIVE N3 候选参照值)。 */
34
+ const GIT_NET_ACTIONS = new Set(["push", "fetch", "pull", "clone", "ls-remote"])
35
+
36
+ /** 测试态缝(模块级 · 缺省 null = 生产零行为变——先例 `manifest.mjs:40-41` · `session-gc.mjs:136`)。
37
+ * 用例 `finally` 复位;不经用户参数面(边界:不新增参数 / 用户选项)。 */
38
+ let gitTimeoutOverride = null
39
+ export function _setGitTimeoutForTest(ms) { gitTimeoutOverride = ms }
40
+ export function _resetGitTimeoutForTest() { gitTimeoutOverride = null }
41
+
42
+ /** 子命令取形:跳 `-c <k=v>` 对(适配器把 config 前置在 args 头)——首个其余 arg 即子命令。 */
43
+ function gitSubcommand(args) {
44
+ for (let i = 0; i < args.length; i++) {
45
+ if (args[i] === "-c") { i++; continue }
46
+ return args[i]
47
+ }
48
+ return ""
49
+ }
50
+
51
+ /** 超时文案单源(逐字 = §6.14「超时错误文案」):两帧嵌同一条——`runGit` / `runGitRaw` 抛错经
52
+ * `gitFailureMessage`;`runGitStrict` 的 `err` = 本条(调用方另加 `git <action> failed: ` 前缀)。 */
53
+ export function gitTimeoutNote(ms) {
54
+ return `timed out after ${ms / 1000}s (killed) — no interactive input is possible here (editor / credential / network); `
55
+ + "process killed, tree best-effort — retry or use the bash tool; an interrupted write keeps git state (`git status`) "
56
+ + "— git abort / continue, or checkpoint action=checkpoint checkpointAction=list"
57
+ }
58
+
59
+ /** git spawn 单点。返回 Promise<string>(stdout 原文——trim / `\r` 归一 / 溢出分支等形由适配器保留)。
60
+ * 失败 / 超时 / 溢出 ⇒ reject;错误形 = `execFileSync` 同构(`.stdout` / `.stderr` / `.status` / `.message` /
61
+ * `.code`),超时另带 `.timedOut = true` / `.timeoutMs`(可辨性不靠 stderr 文本猜)。
62
+ * 缺省:timeout 按面取常量(网络五动作 300s / 其余 120s)· maxBuffer = Node `execFileSync` 缺省 1MB
63
+ * (`runGit` / `runGitRaw` 显式传 10MB——形保真清单 ②)。 */
64
+ export function spawnGit(cwd, args, { timeout, maxBuffer } = {}) {
65
+ const timeoutMs = timeout ?? gitTimeoutOverride ?? (GIT_NET_ACTIONS.has(gitSubcommand(args)) ? GIT_NET_TIMEOUT_MS : GIT_TIMEOUT_MS)
66
+ const cap = maxBuffer ?? 1024 * 1024
67
+ return new Promise((resolve, reject) => {
68
+ const child = spawn("git", args, {
69
+ cwd,
70
+ env: GIT_ENV,
71
+ stdio: ["ignore", "pipe", "pipe"],
72
+ windowsHide: true,
73
+ detached: process.platform !== "win32", // POSIX:组首 ⇒ 树杀(-pid 组杀)可达孙进程(execute / bash 先例)
74
+ })
75
+ let stdout = "", stderr = "", mode = null, settled = false
76
+ let outBytes = 0, errBytes = 0 // maxBuffer 口径 = **byte**(`execFileSync` 同源)
77
+ let timer = null, killTimer = null, kickTimer = null
78
+ const finish = (fn) => {
79
+ if (settled) return
80
+ settled = true
81
+ clearTimeout(timer); clearTimeout(killTimer); clearTimeout(kickTimer)
82
+ fn()
83
+ }
84
+ const timeoutErr = () => Object.assign(new Error(gitTimeoutNote(timeoutMs)), {
85
+ code: "ETIMEDOUT", timedOut: true, timeoutMs, stdout, stderr, status: null,
86
+ })
87
+ const overflowErr = () => Object.assign(new Error(`git output exceeded maxBuffer (${cap} bytes)`), {
88
+ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", stdout, stderr, status: null,
89
+ })
90
+ const exitErr = (code, signal) => Object.assign(new Error(`Command failed: git ${args.join(" ")}${stderr ? `\n${stderr}` : ""}`), {
91
+ cmd: `git ${args.join(" ")}`, status: code, signal, killed: Boolean(signal), stdout, stderr,
92
+ })
93
+ const settleWith = (code, signal) => {
94
+ // 超时收尾:树杀必达(`close` 早退 ⇒ 1.5s 定时器已被 finish 清掉——**不持管道**的孙进程
95
+ // 会因此漏杀;树杀在头进程死后于 POSIX 仍可达全组)
96
+ if (mode === "timeout") { treeKill(); return reject(timeoutErr()) }
97
+ if (mode === "overflow") return reject(overflowErr())
98
+ if (code === 0 && !signal) return resolve(stdout)
99
+ return reject(exitErr(code, signal))
100
+ }
101
+ const treeKill = () => { try { killProcessTree(child) } catch { /* 尽力而为 */ } }
102
+ // 超时动作序(§6.14):① 直接子 → ② 逾 1.5s 树杀(`killProcessTree`,尽力而为)→ ③ 逾 1.5s 未 `close`
103
+ // 亦 settle(kick——孙进程持管道时 `close` 永不触发,`bash.mjs:200-217` 同款)。上界 = timeout + 3s。
104
+ // 平台分岔(§6.14 平台注的后果):win32 上 SIGTERM 实为硬终止且**不连带子进程**——父一死,
105
+ // `taskkill /PID <父> /T` 就够不到孙进程(实测:helper 孤儿存活、管道不放 ⇒ 只能等 kick)⇒
106
+ // win32 直接走树杀(须在树仍可寻址时执行);POSIX 保留宽限序(组杀在头进程死后仍可达全组)。
107
+ const armKill = () => {
108
+ if (process.platform === "win32") treeKill()
109
+ else { try { child.kill("SIGTERM") } catch { /* 已退 */ } }
110
+ killTimer = setTimeout(treeKill, 1500)
111
+ kickTimer = setTimeout(() => finish(() => settleWith(null, "SIGTERM")), 3000)
112
+ }
113
+ timer = setTimeout(() => { if (!mode) { mode = "timeout"; armKill() } }, timeoutMs)
114
+ // 解码 = **流级 UTF-8**(`StringDecoder` 正确拼接跨 chunk 的多字节序列——逐 chunk `toString()`
115
+ // 会把半截序列解成 U+FFFD;批前三档经 `execFileSync({encoding:"utf8"})` 整体解码)
116
+ child.stdout.setEncoding("utf8")
117
+ child.stderr.setEncoding("utf8")
118
+ child.stdout.on("data", (d) => {
119
+ if (mode === "overflow") return
120
+ stdout += d
121
+ outBytes += Buffer.byteLength(d)
122
+ // 截断按**字符**(判据只认 code + 部分输出——多字节前缀可略超 cap 字节,语义不损)
123
+ if (outBytes > cap) { mode = "overflow"; stdout = stdout.slice(0, cap); armKill() }
124
+ })
125
+ child.stderr.on("data", (d) => {
126
+ if (errBytes > cap) return // 两管各自独立计(`spawnSync` 的 maxBuffer 同口径:per-stream)
127
+ stderr += d
128
+ errBytes += Buffer.byteLength(d)
129
+ })
130
+ child.on("error", (e) => {
131
+ // 杀失败等信号面错误不得遮蔽已定模式(timeout / overflow 由 kick 收尾)
132
+ if (mode) return
133
+ finish(() => reject(Object.assign(e, { stdout, stderr, status: null })))
134
+ })
135
+ child.on("exit", (code, signal) => {
136
+ // 直接子已退但孙进程持管道 ⇒ `close` 不到:逾 1.5s 亦按已收集输出 settle(kick)
137
+ if (!settled && !kickTimer) kickTimer = setTimeout(() => finish(() => settleWith(code, signal)), 1500)
138
+ })
139
+ child.on("close", (code, signal) => finish(() => settleWith(code, signal)))
140
+ })
141
+ }
package/tools/git.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  runGit,
5
5
  gitFailureMessage
6
6
  } from "./shared.mjs";
7
- import { execFileSync } from "node:child_process";
7
+ import { spawnGit } from "./git-run.mjs";
8
8
  import { resolve } from "node:path";
9
9
  import { filterLines, runGitStrict, validateRef, gitConfigArgs, snapshotBefore, executeExtAction } from "./git-ext.mjs";
10
10
  import { executeCheckpointAction } from "./git-checkpoint.mjs";
@@ -15,10 +15,11 @@ import { discoverRepos, MANIFEST_REL } from "../manifest.mjs";
15
15
  * strips a porcelain line's leading " " (the unstaged marker) and misclassifies an
16
16
  * unstaged-only first line as staged. status uses this so the staged/unstaged column survives.
17
17
  * #55 fail-closed:失败不再吞成 ""(曾把非仓 / 任意失败渲染成 `(clean — no changes)`)——
18
- * 溢出保留部分输出;其余 ⇒ throw(消息契约 = `shared.mjs` `gitFailureMessage`)。 */
19
- function runGitRaw(cwd, cmdArgs, config = []) {
18
+ * 溢出保留部分输出;其余 ⇒ throw(消息契约 = `shared.mjs` `gitFailureMessage`)。§6.14:体改**异步薄壳**
19
+ * (`spawnGit` 单点——加固 env + 两档超时 + 树杀;签名与产出形零变)。 */
20
+ async function runGitRaw(cwd, cmdArgs, config = []) {
20
21
  try {
21
- return execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }).replace(/\r/g, "").replace(/\n$/, "")
22
+ return (await spawnGit(cwd, [...config, ...cmdArgs], { maxBuffer: 10 * 1024 * 1024 })).replace(/\r/g, "").replace(/\n$/, "")
22
23
  } catch (e) {
23
24
  if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) return String(e.stdout).replace(/\r/g, "")
24
25
  throw new Error(gitFailureMessage(e, cmdArgs, cwd))
@@ -150,13 +151,16 @@ const gitActionCore = {
150
151
  if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
151
152
  const flags = args.staged ? ["--staged"] : []
152
153
  const paths = args.path ? [args.path] : []
153
- const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
154
- return truncate(filterLines(out || "(no changes)", args.filter))
154
+ const out = await runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
155
+ // #71(hygiene-sweep 批):传了 path 且输出空 ⇒ 注记(pathspec 未命中也是 exit 0——
156
+ // 原样「(no changes)」为假洁净;不传 path 逐字零变)
157
+ const empty = args.path ? `(no changes — pathspec '${args.path}' matched nothing in ${ref})` : "(no changes)"
158
+ return truncate(filterLines(out || empty, args.filter))
155
159
  }
156
160
  case "status": {
157
161
  // Preserve per-line leading whitespace — porcelain " M"/"M " staged/unstaged markers are
158
162
  // significant (runGit trims the whole output's leading space, corrupting an unstaged-first-line).
159
- const porcelain = runGitRaw(ctx.cwd, ["status", "--porcelain"])
163
+ const porcelain = await runGitRaw(ctx.cwd, ["status", "--porcelain"])
160
164
  if (!porcelain) return "(clean — no changes)"
161
165
 
162
166
  const staged = []
@@ -196,19 +200,21 @@ const gitActionCore = {
196
200
  ? ["log", "-" + n, "--oneline"]
197
201
  : ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
198
202
  if (args.path) cmdArgs.push("--", args.path)
199
- const out = runGit(ctx.cwd, cmdArgs)
200
- return truncate(filterLines(out || "(no commits)", args.filter))
203
+ const out = await runGit(ctx.cwd, cmdArgs)
204
+ // #71:同上(log 腿——传了 path 且输出空 ⇒ 注记;不传 path 逐字零变)
205
+ const empty = args.path ? `(no commits — pathspec '${args.path}' matched nothing)` : "(no commits)"
206
+ return truncate(filterLines(out || empty, args.filter))
201
207
  }
202
208
  case "show": {
203
209
  const ref = args.ref ?? "HEAD"
204
210
  if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
205
- const out = runGit(ctx.cwd, ["show", "--stat", ref])
211
+ const out = await runGit(ctx.cwd, ["show", "--stat", ref])
206
212
  return truncate(out || "(no such commit)")
207
213
  }
208
214
  case "rm": {
209
215
  if (!args.path) return "Error: rm requires path (the file/directory to untrack, relative to repo root)"
210
216
  const paths = args.path.split(/\s+/).filter(Boolean)
211
- const r = runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", ...paths])
217
+ const r = await runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", ...paths])
212
218
  return r.ok ? truncate(r.out || `Untracked ${paths.join(" ")} (kept on disk)`) : truncate(`git rm failed: ${r.err || r.out}`)
213
219
  }
214
220
  case "commit": {
@@ -222,12 +228,12 @@ const gitActionCore = {
222
228
  if (args.path !== undefined && args.path !== null) {
223
229
  const trimmed = args.path.trim()
224
230
  if (!trimmed) return "Error: commit path is empty/whitespace — give at least one file path (space-separated)"
225
- commit = runGitStrict(ctx.cwd, ["commit", "--only", "-m", args.message, "--", ...trimmed.split(/\s+/)])
231
+ commit = await runGitStrict(ctx.cwd, ["commit", "--only", "-m", args.message, "--", ...trimmed.split(/\s+/)])
226
232
  } else {
227
- const add = runGitStrict(ctx.cwd, ["add", "-A"])
233
+ const add = await runGitStrict(ctx.cwd, ["add", "-A"])
228
234
  if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
229
235
  if (add.out) parts.push(add.out)
230
- commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
236
+ commit = await runGitStrict(ctx.cwd, ["commit", "-m", args.message])
231
237
  }
232
238
  if (commit.ok) {
233
239
  if (commit.out) parts.push(commit.out)
@@ -249,7 +255,7 @@ const gitActionCore = {
249
255
  if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
250
256
  if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
251
257
  if (args.tags) cmdArgs.push("--tags")
252
- const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
258
+ const r = await runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
253
259
  return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
254
260
  }
255
261
  case "ls-remote": {
@@ -258,7 +264,7 @@ const gitActionCore = {
258
264
  const cmdArgs = ["ls-remote"]
259
265
  if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
260
266
  if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
261
- const out = runGit(ctx.cwd, cmdArgs, cfgArgs)
267
+ const out = await runGit(ctx.cwd, cmdArgs, cfgArgs)
262
268
  if (!out) return "(no refs / remote unreachable)"
263
269
  return truncate(filterLines(out, args.filter))
264
270
  }
@@ -267,51 +273,51 @@ const gitActionCore = {
267
273
  // 多路径:空格分隔(ref 先例 L175——2026-09-05 发版痛点——git add 单路径被迫 N 次调用)
268
274
  const paths = args.path ? args.path.split(/\s+/).filter(Boolean) : null
269
275
  const cmdArgs = paths?.length ? ["add", "--", ...paths] : ["add", "-A"]
270
- const r = runGitStrict(ctx.cwd, cmdArgs)
276
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
271
277
  return r.ok ? truncate(r.out || `Staged ${paths?.join(" ") || "all changes"}`) : truncate(`git add failed: ${r.err || r.out}`)
272
278
  }
273
279
  case "tag": {
274
280
  const sub = args.tagAction
275
- if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["tag", "-l"]) || "(no tags)", args.filter))
281
+ if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["tag", "-l"]) || "(no tags)", args.filter))
276
282
  if (sub === "create") {
277
283
  if (!args.name) return "Error: tag create requires name"
278
284
  validateRef(args.name, "tag")
279
285
  const cmdArgs = ["tag", args.name]
280
286
  if (args.ref) cmdArgs.push(validateRef(args.ref))
281
- const r = runGitStrict(ctx.cwd, cmdArgs)
287
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
282
288
  return r.ok ? `Tag ${args.name} created` : truncate(`git tag failed: ${r.err || r.out}`)
283
289
  }
284
290
  if (sub === "delete") {
285
291
  if (!args.name) return "Error: tag delete requires name"
286
292
  validateRef(args.name, "tag")
287
293
  const snap = await snapshotBefore(ctx, `tag delete ${args.name}`)
288
- const r = runGitStrict(ctx.cwd, ["tag", "-d", args.name])
294
+ const r = await runGitStrict(ctx.cwd, ["tag", "-d", args.name])
289
295
  return r.ok ? truncate(snap + `Tag ${args.name} deleted`) : truncate(`git tag -d failed: ${r.err || r.out}`)
290
296
  }
291
297
  return "Error: tag requires tagAction — use: list | create | delete"
292
298
  }
293
299
  case "branch": {
294
300
  const sub = args.branchAction
295
- if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["branch", "--all", "-vv"]) || "(no branches)", args.filter))
301
+ if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["branch", "--all", "-vv"]) || "(no branches)", args.filter))
296
302
  if (sub === "create") {
297
303
  if (!args.name) return "Error: branch create requires name"
298
304
  validateRef(args.name, "branch")
299
305
  const cmdArgs = ["branch", args.name]
300
306
  if (args.ref) cmdArgs.push(validateRef(args.ref))
301
- const r = runGitStrict(ctx.cwd, cmdArgs)
307
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
302
308
  return r.ok ? `Branch ${args.name} created` : truncate(`git branch failed: ${r.err || r.out}`)
303
309
  }
304
310
  if (sub === "switch") {
305
311
  if (!args.name) return "Error: branch switch requires name"
306
312
  validateRef(args.name, "branch")
307
- const r = runGitStrict(ctx.cwd, ["checkout", args.name])
313
+ const r = await runGitStrict(ctx.cwd, ["checkout", args.name])
308
314
  return r.ok ? `Switched to branch ${args.name}` : truncate(`git checkout ${args.name} failed: ${r.err || r.out}`)
309
315
  }
310
316
  if (sub === "delete") {
311
317
  if (!args.name) return "Error: branch delete requires name"
312
318
  validateRef(args.name, "branch")
313
319
  const snap = await snapshotBefore(ctx, `branch delete ${args.name}`)
314
- const r = runGitStrict(ctx.cwd, ["branch", "-d", args.name])
320
+ const r = await runGitStrict(ctx.cwd, ["branch", "-d", args.name])
315
321
  return r.ok ? truncate(snap + `Branch ${args.name} deleted`) : truncate(`git branch -d failed: ${r.err || r.out}`)
316
322
  }
317
323
  return "Error: branch requires branchAction — use: list | create | delete | switch"
@@ -320,12 +326,12 @@ const gitActionCore = {
320
326
  if (args.path) {
321
327
  // Restore file from index (discards working-tree changes to it) — destructive: snapshot first.
322
328
  const snap = await snapshotBefore(ctx, `checkout -- ${args.path}`)
323
- const r = runGitStrict(ctx.cwd, ["checkout", "--", args.path])
329
+ const r = await runGitStrict(ctx.cwd, ["checkout", "--", args.path])
324
330
  return r.ok ? truncate(snap + `Restored ${args.path}`) : truncate(`git checkout -- ${args.path} failed: ${r.err || r.out}`)
325
331
  }
326
332
  if (args.ref) {
327
333
  validateRef(args.ref, "ref")
328
- const r = runGitStrict(ctx.cwd, ["checkout", args.ref])
334
+ const r = await runGitStrict(ctx.cwd, ["checkout", args.ref])
329
335
  return r.ok ? truncate(r.out || `Checked out ${args.ref}`) : truncate(`git checkout ${args.ref} failed: ${r.err || r.out}`)
330
336
  }
331
337
  return "Error: checkout requires ref (branch/commit) or path (file to restore)"
@@ -336,21 +342,21 @@ const gitActionCore = {
336
342
  const cmdArgs = ["restore"]
337
343
  if (args.staged) cmdArgs.push("--staged")
338
344
  cmdArgs.push("--", args.path)
339
- const r = runGitStrict(ctx.cwd, cmdArgs)
345
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
340
346
  return r.ok ? truncate(snap + `Restored ${args.path}`) : truncate(`git restore failed: ${r.err || r.out}`)
341
347
  }
342
348
  case "stash": {
343
349
  const sub = args.stashAction
344
- if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["stash", "list"]) || "(no stashes)", args.filter))
350
+ if (sub === "list") return truncate(filterLines(await runGit(ctx.cwd, ["stash", "list"]) || "(no stashes)", args.filter))
345
351
  if (sub === "push") {
346
352
  const cmdArgs = ["stash", "push"]
347
353
  if (args.message) cmdArgs.push("-m", args.message)
348
- const r = runGitStrict(ctx.cwd, cmdArgs)
354
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
349
355
  return r.ok ? truncate(r.out || "Stashed") : truncate(`git stash push failed: ${r.err || r.out}`)
350
356
  }
351
357
  if (sub === "pop") {
352
358
  const snap = await snapshotBefore(ctx, "stash pop")
353
- const r = runGitStrict(ctx.cwd, ["stash", "pop"])
359
+ const r = await runGitStrict(ctx.cwd, ["stash", "pop"])
354
360
  return r.ok ? truncate(snap + (r.out || "Popped")) : truncate(`git stash pop failed: ${r.err || r.out}`)
355
361
  }
356
362
  return "Error: stash requires stashAction — use: push | pop | list"
@@ -359,14 +365,14 @@ const gitActionCore = {
359
365
  const cmdArgs = ["fetch"]
360
366
  if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
361
367
  if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
362
- const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
368
+ const r = await runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
363
369
  return r.ok ? truncate(r.out || "(fetch complete — no output)") : truncate(`git fetch failed: ${r.err || r.out}`)
364
370
  }
365
371
  case "pull": {
366
372
  const cmdArgs = ["pull"]
367
373
  if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
368
374
  if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
369
- const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
375
+ const r = await runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
370
376
  return r.ok ? truncate(r.out || "(pull complete — no output)") : truncate(`git pull failed: ${r.err || r.out}`)
371
377
  }
372
378
  case "reset": {
@@ -376,24 +382,24 @@ const gitActionCore = {
376
382
  if (mode === "hard") snap = await snapshotBefore(ctx, "reset --hard") // destructive: drops working-tree changes
377
383
  const cmdArgs = ["reset", `--${mode}`]
378
384
  if (args.ref) cmdArgs.push(validateRef(args.ref))
379
- const r = runGitStrict(ctx.cwd, cmdArgs)
385
+ const r = await runGitStrict(ctx.cwd, cmdArgs)
380
386
  return r.ok ? truncate(snap + (r.out || `Reset (${mode}) complete`)) : truncate(`git reset failed: ${r.err || r.out}`)
381
387
  }
382
388
  case "revert": {
383
389
  const ref = validateRef(args.ref ?? "HEAD")
384
- const r = runGitStrict(ctx.cwd, ["revert", "--no-edit", ref])
390
+ const r = await runGitStrict(ctx.cwd, ["revert", "--no-edit", ref])
385
391
  return r.ok ? truncate(r.out || `Reverted ${ref}`) : truncate(`git revert failed: ${r.err || r.out}`)
386
392
  }
387
393
  case "merge": {
388
394
  if (!args.ref) return "Error: merge requires ref (branch/commit to merge)"
389
395
  validateRef(args.ref, "ref")
390
- const r = runGitStrict(ctx.cwd, ["merge", "--no-edit", args.ref])
396
+ const r = await runGitStrict(ctx.cwd, ["merge", "--no-edit", args.ref])
391
397
  return r.ok ? truncate(r.out || `Merged ${args.ref}`) : truncate(`git merge failed: ${r.err || r.out} — resolve conflicts, then commit`)
392
398
  }
393
399
  case "cherry-pick": {
394
400
  if (!args.ref) return "Error: cherry-pick requires ref (commit)"
395
401
  validateRef(args.ref, "ref")
396
- const r = runGitStrict(ctx.cwd, ["cherry-pick", args.ref])
402
+ const r = await runGitStrict(ctx.cwd, ["cherry-pick", args.ref])
397
403
  return r.ok ? truncate(r.out || `Cherry-picked ${args.ref}`) : truncate(`git cherry-pick failed: ${r.err || r.out}`)
398
404
  }
399
405
  // F7 扩展 action + checkpoint:实现拆在 git-ext.mjs / git-checkpoint.mjs(500 行硬限)
package/tools/index.mjs CHANGED
@@ -45,8 +45,10 @@ export {
45
45
  // faces (memory / code+doc search / repo outline / settings / peer instances)
46
46
  // whose factories need the shell's memory handle at run time.
47
47
  //
48
- // Host-only tools (VS Code `context` / `focus` — TOOLS #179 ④, IDE capabilities)
48
+ // Host-only tools (VS Code `ide` / `focus` — TOOLS #179 ④, IDE capabilities)
49
49
  // are NOT part of the core registry: the VS Code shell adds them itself.
50
+ // D-CC26(context-tool 批 2026-09-21):宿主 IDE 快照工具自 `context` **改名 `ide`**(`tools/ide.mjs`)
51
+ // ——让出 `context` 名给核新工具(`agent-tools/context.mjs`;同名撞车 ⇒ provider 逐字 400)。
50
52
  //
51
53
  // `read_image` registration follows VS Code (#70): it is registered only when the
52
54
  // model accepts image input (`specForModel(model).multimodal`) — the conservative
@@ -0,0 +1,20 @@
1
+ /**
2
+ * tools/process-tree.mjs — 平台感知树杀单源(自 `execute.mjs` 抽出——TOOLS.md §6.14 落位表行 2)。
3
+ * 抽出理由 = **断环**:`shared → git-run → execute → shared` 会 TDZ(`execute.mjs:29` 在模块求值期
4
+ * 调 `DESC`(`const`))⇒ 树杀须住两不依赖档。行为逐字同(win32 `taskkill /T /F` · POSIX 组杀 + 直杀兜底)。
5
+ * `execute.mjs` 保持再导出面(`test/tool-seams.test.mjs:26` 消费)。
6
+ */
7
+ import { execFileSync } from "node:child_process"
8
+
9
+ /** Platform-aware process tree kill — mirror of system.mjs/verify.mjs killProcessTree.
10
+ * Timeout/abort must reach grandchildren: a script that spawned children keeps the
11
+ * pipes open otherwise — "close" never fires and the tool stalls until the 3s kick
12
+ * while the orphan keeps running (2026-09-05 advisor 🟡#4). */
13
+ export function killProcessTree(child) {
14
+ if (process.platform === "win32") {
15
+ try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
16
+ } else {
17
+ try { process.kill(-child.pid, "SIGKILL") } catch {}
18
+ try { child.kill("SIGKILL") } catch {}
19
+ }
20
+ }
package/tools/shared.mjs CHANGED
@@ -8,6 +8,7 @@ import { readFileSync, existsSync, realpathSync, readdirSync, statSync, openSync
8
8
  import { dirname, join, resolve } from "node:path"
9
9
  import { fileURLToPath } from "node:url"
10
10
  import { loadToolDoc, applyPromptInjections } from "../prompt-files.mjs"
11
+ import { spawnGit, gitTimeoutNote, GIT_TIMEOUT_MS } from "./git-run.mjs"
11
12
 
12
13
  const __dirname = dirname(fileURLToPath(import.meta.url))
13
14
  // 工具描述加载面——**核内单一解析面**(D-C13 / 契约 8):解析根 = `prompt-files.mjs`
@@ -438,8 +439,10 @@ export function htmlToText(html) {
438
439
 
439
440
  /** git 失败消息构造单点(#55 fail-closed——`runGit` / `runGitRaw` 两读取面同款):形态 =
440
441
  * `git <args…> failed: <stderr 首行> (cwd: <绝对 cwd>)`;**非仓**失败(退出码 128 ∧ stderr 首行含
441
- * `not a git repository`)尾附 workdir 指引;spawn 失败(无 stderr)取 `e.message` 首行——**禁空尾**。 */
442
+ * `not a git repository`)尾附 workdir 指引;spawn 失败(无 stderr)取 `e.message` 首行——**禁空尾**。
443
+ * **超时**(`e.timedOut`——§6.14):帧 + 单源超时注(`gitTimeoutNote`,含树杀尽义与恢复指引)。 */
442
444
  export function gitFailureMessage(e, cmdArgs, cwd) {
445
+ if (e?.timedOut) return `git ${cmdArgs.join(" ")} failed: ${gitTimeoutNote(e.timeoutMs ?? GIT_TIMEOUT_MS)} (cwd: ${resolve(cwd ?? ".")})`
443
446
  const stderr = String(e?.stderr ?? "").trim()
444
447
  const first = stderr.split("\n")[0].trim() || String(e?.message || e?.code || "unknown error").split("\n")[0]
445
448
  const notRepo = e?.status === 128 && first.includes("not a git repository")
@@ -449,10 +452,11 @@ export function gitFailureMessage(e, cmdArgs, cwd) {
449
452
 
450
453
  /** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty.
451
454
  * config: optional array of `-c key=value` overrides (e.g. ["http.proxy=http://10.2.2.112:3128"]) —
452
- * inserted verbatim after `git`, so network actions (push/fetch/pull/ls-remote) can route through a proxy. */
453
- export function runGit(cwd, cmdArgs, config = []) {
455
+ * inserted verbatim after `git`, so network actions (push/fetch/pull/ls-remote) can route through a proxy.
456
+ * §6.14:体改**异步薄壳**委托 `spawnGit` 单点(加固 env + 两档超时 + 树杀);trim / `\r` / 溢出 / 抛出四形逐字保留。 */
457
+ export async function runGit(cwd, cmdArgs, config = []) {
454
458
  try {
455
- return execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
459
+ return (await spawnGit(cwd, [...config, ...cmdArgs], { maxBuffer: 10 * 1024 * 1024 })).trim().replace(/\r/g, "")
456
460
  } catch (e) {
457
461
  // maxBuffer overflow: e.stdout contains partial collected output — return it
458
462
  // (callers show "(truncated)"-style tails). Every OTHER failure (non-git repo,