thincoder 0.12.59 → 0.12.60

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 (127) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +2 -2
  3. package/bin/thincoder.mjs +80 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +7 -4
  6. package/src/advisor/messages.mjs +24 -4
  7. package/src/advisor/run.mjs +35 -33
  8. package/src/advisor.mjs +25 -6
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +102 -19
  11. package/src/agent/helpers.mjs +36 -0
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +18 -2
  16. package/src/agent/spawn-child.mjs +29 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +110 -108
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +121 -102
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +24 -29
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/read-history.mjs +155 -31
  25. package/src/agent-tools/recent-changes.mjs +2 -1
  26. package/src/agent-tools/settings.mjs +7 -17
  27. package/src/agent-tools/subagent-actions.mjs +168 -130
  28. package/src/agent-tools/subagent-async.mjs +129 -174
  29. package/src/agent-tools/subagent-panel.mjs +153 -0
  30. package/src/agent-tools/subagent-run.mjs +202 -0
  31. package/src/agent-tools/subagent-scheduler.mjs +45 -21
  32. package/src/agent-tools/subagent-spawn.mjs +406 -0
  33. package/src/agent-tools/subagent.mjs +107 -555
  34. package/src/agent-tools/verify.mjs +118 -270
  35. package/src/agent.mjs +57 -190
  36. package/src/cli/distill-command.mjs +10 -4
  37. package/src/cli/make-agent.mjs +3 -1
  38. package/src/cli/memory-command.mjs +2 -1
  39. package/src/cli/permission.mjs +2 -2
  40. package/src/cli/setup-wizard.mjs +17 -12
  41. package/src/config.mjs +56 -8
  42. package/src/context.mjs +5 -147
  43. package/src/crash-reports.mjs +123 -0
  44. package/src/distill.mjs +11 -11
  45. package/src/explore-distill.mjs +155 -0
  46. package/src/memory/code-sync.mjs +2 -1
  47. package/src/memory/core.mjs +6 -193
  48. package/src/memory/delete.mjs +234 -0
  49. package/src/memory/docs.mjs +58 -48
  50. package/src/memory.mjs +3 -1
  51. package/src/peer-domains.mjs +265 -0
  52. package/src/peer-instances.mjs +231 -0
  53. package/src/prompt-overlays.mjs +25 -0
  54. package/src/prompts/advisor-design.md +9 -76
  55. package/src/prompts/advisor-round1.md +9 -68
  56. package/src/prompts/advisor-round2.md +7 -54
  57. package/src/prompts/advisor-round3.md +7 -54
  58. package/src/prompts/coder.md +7 -50
  59. package/src/prompts/consult-base.md +4 -24
  60. package/src/prompts/discipline.md +26 -44
  61. package/src/prompts/eng-coder.md +7 -32
  62. package/src/prompts/engineering-sub.md +3 -23
  63. package/src/prompts/engineering.md +53 -306
  64. package/src/prompts/explore.md +3 -12
  65. package/src/prompts/main.md +10 -32
  66. package/src/prompts/methodology-template.md +28 -48
  67. package/src/prompts/plan.md +2 -9
  68. package/src/prompts/system.md +16 -35
  69. package/src/provider/core.mjs +6 -67
  70. package/src/provider/errors.mjs +76 -0
  71. package/src/provider/retry.mjs +8 -45
  72. package/src/session-gc.mjs +214 -0
  73. package/src/session-guard.mjs +47 -0
  74. package/src/session-rename.mjs +38 -0
  75. package/src/session-slots.mjs +181 -58
  76. package/src/session.mjs +48 -89
  77. package/src/token-ttl.mjs +273 -0
  78. package/src/tools/checklist-sync.mjs +181 -0
  79. package/src/tools/checklist.mjs +52 -39
  80. package/src/tools/edit-batch.mjs +109 -10
  81. package/src/tools/edit-diff.mjs +110 -27
  82. package/src/tools/edit.md +17 -12
  83. package/src/tools/execute.mjs +31 -4
  84. package/src/tools/file.mjs +11 -6
  85. package/src/tools/git.mjs +14 -6
  86. package/src/tools/glob-dialect.mjs +130 -0
  87. package/src/tools/glob.md +3 -3
  88. package/src/tools/grep.md +1 -1
  89. package/src/tools/index.mjs +5 -6
  90. package/src/tools/ops.mjs +175 -3
  91. package/src/tools/patch.mjs +3 -3
  92. package/src/tools/question.md +3 -0
  93. package/src/tools/read.md +0 -1
  94. package/src/tools/shared.mjs +14 -13
  95. package/src/tools/system.mjs +44 -9
  96. package/src/tools/wait_for.md +22 -0
  97. package/src/tui/agent-turn.mjs +17 -228
  98. package/src/tui/cmd-config.mjs +48 -7
  99. package/src/tui/cmd-eng.mjs +20 -16
  100. package/src/tui/cmd-mcp.mjs +8 -2
  101. package/src/tui/cmd-new.mjs +3 -2
  102. package/src/tui/cmd-session.mjs +19 -4
  103. package/src/tui/cmd-think.mjs +10 -10
  104. package/src/tui/cmd-upgrade.mjs +19 -4
  105. package/src/tui/config-helpers.mjs +28 -16
  106. package/src/tui/distill-cmd.mjs +1 -1
  107. package/src/tui/index.mjs +3 -2
  108. package/src/tui/interaction.mjs +3 -3
  109. package/src/tui/mouse.mjs +7 -1
  110. package/src/tui/pickers.mjs +40 -22
  111. package/src/tui/render-segments.mjs +27 -10
  112. package/src/tui/startup.mjs +4 -0
  113. package/src/tui/subagent-blocks.mjs +95 -263
  114. package/src/tui/subagent-children.mjs +176 -0
  115. package/src/tui/subagent-freeze.mjs +172 -0
  116. package/src/tui/subagent-panel.mjs +61 -23
  117. package/src/tui/suspension-drive.mjs +351 -0
  118. package/src/tui/tool-args.mjs +3 -3
  119. package/src/tui/tool-display.mjs +142 -0
  120. package/src/tui/tool-events.mjs +37 -173
  121. package/src/tui/tui-lifecycle.mjs +29 -0
  122. package/src/tui/update-notice.mjs +4 -0
  123. package/src/tui/wizard.mjs +12 -6
  124. package/src/tools/pdf-parse-text.mjs +0 -497
  125. package/src/tools/pdf-parse-xref.mjs +0 -499
  126. package/src/tools/pdf.mjs +0 -155
  127. package/src/tools/read_pdf.md +0 -21
@@ -24,7 +24,7 @@
24
24
  * filter — return only output lines matching this regex (case-insensitive)
25
25
  * timeoutMs — timeout (default 30s, max 600000ms)
26
26
  */
27
- import { spawn } from "node:child_process"
27
+ import { spawn, execFileSync } from "node:child_process"
28
28
  import { resolve } from "node:path"
29
29
  import { DESC } from "./shared.mjs"
30
30
 
@@ -58,6 +58,19 @@ function applyFilter(output, filter) {
58
58
  }
59
59
  }
60
60
 
61
+ /** Platform-aware process tree kill — mirror of system.mjs/verify.mjs killProcessTree.
62
+ * Timeout/abort must reach grandchildren: a script that spawned children keeps the
63
+ * pipes open otherwise — "close" never fires and the tool stalls until the 3s kick
64
+ * while the orphan keeps running (2026-09-05 advisor 🟡#4). */
65
+ function killProcessTree(child) {
66
+ if (process.platform === "win32") {
67
+ try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
68
+ } else {
69
+ try { process.kill(-child.pid, "SIGKILL") } catch {}
70
+ try { child.kill("SIGKILL") } catch {}
71
+ }
72
+ }
73
+
61
74
  /** Spawn node with the given args, capture stdout/stderr, enforce timeout/abort.
62
75
  * Resolves { text, ok } — ok=false on non-zero exit / timeout / abort. */
63
76
  function runNode(childArgs, baseDir, timeoutMs, signal) {
@@ -66,6 +79,13 @@ function runNode(childArgs, baseDir, timeoutMs, signal) {
66
79
  cwd: baseDir,
67
80
  stdio: ["ignore", "pipe", "pipe"],
68
81
  windowsHide: true,
82
+ // POSIX detached → 子进程为组首——killProcessTree 的 -pid 组杀可达孙进程(win 用
83
+ // taskkill /T 不需 detached)——2026-09-05 advisor 🟡#4
84
+ detached: process.platform !== "win32",
85
+ // 双保险(2026-09-05——裸 spawn 无 signal 教训——system.mjs 同款):abort 时
86
+ // Node 自动杀直接子进程(第一道)——onAbort 手动 kill 兜底(SIGKILL 防信号陷阱
87
+ // 脚本——见 kill 注释)——AbortError 在 error 分支让路(close 必随——走 mode 收尾)
88
+ ...(signal ? { signal } : {}),
69
89
  })
70
90
 
71
91
  let outBuf = "", errBuf = "", truncated = false, settled = false, mode = null
@@ -79,8 +99,9 @@ function runNode(childArgs, baseDir, timeoutMs, signal) {
79
99
  if (signal) signal.removeEventListener("abort", onAbort)
80
100
  resolvePromise({ text, ok })
81
101
  }
82
- // SIGKILL (not SIGTERM) so a signal-trapping script can't dodge the watchdog.
83
- const kill = () => { try { child.kill("SIGKILL") } catch { /* already gone */ } }
102
+ // Tree kill(SIGKILL/taskkill /T——signal-trapping 脚本躲不开 watchdog;孙进程持管道
103
+ // 时直接 kill 不达——close 不触发拖到 kick——2026-09-05 advisor 🟡#4 对齐 bash/verify)
104
+ const kill = () => { killProcessTree(child) }
84
105
  // After kill, wait for "close" (child fully reaped) before settling — settling
85
106
  // early races the caller deleting the cwd dir while the child still holds it.
86
107
  const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : timeoutErrorText(timeoutMs), false), 3000) }
@@ -100,7 +121,13 @@ function runNode(childArgs, baseDir, timeoutMs, signal) {
100
121
  }
101
122
  child.stdout.on("data", (d) => { outBuf = cap(outBuf, d.toString()) })
102
123
  child.stderr.on("data", (d) => { errBuf = cap(errBuf, d.toString()) })
103
- child.on("error", (e) => settle(`Error: failed to start node: ${e.message}`, false))
124
+ child.on("error", (e) => {
125
+ // signal 双保险(2026-09-05):abort 时 Node signal option 杀子进程 → 本事件以
126
+ // AbortError 先触发——让路(close 必随——close 分支 mode==="abort" →
127
+ // settle("(stopped)"))——不把中止误报为启动失败
128
+ if (e.name === "AbortError") return
129
+ settle(`Error: failed to start node: ${e.message}`, false)
130
+ })
104
131
  child.on("close", (code) => {
105
132
  if (mode === "abort") return settle("(stopped)", false)
106
133
  if (mode === "timeout") return settle(timeoutErrorText(timeoutMs), false)
@@ -217,21 +217,26 @@ export const editTool = {
217
217
  type: "object",
218
218
  properties: {
219
219
  path: { type: "string", description: "File path — single form: required; with the edits array: optional top-level default for entries without their own path" },
220
- old_string: { type: "string", description: "Exact text to replace" },
221
- new_string: { type: "string", description: "Replacement text" },
220
+ old_string: { type: "string", description: "Exact text to replace (tolerant matching: exact → whitespace-only variant → fuzzy match at ≥90% line equality after whitespace/indent/quote normalization). Mutually exclusive with line/startLine/endLine" },
221
+ new_string: { type: "string", description: "Replacement text — content-based edits require it (empty is an explicit error); with line-based targeting (line/startLine/endLine) give it to replace the line/range, or OMIT it to delete (an explicit empty string is an error — omission is the delete signal)" },
222
+ line: { type: "integer", description: "1-based line number — replace that single line with new_string, or OMIT new_string to DELETE the line; no old_string needed (mutually exclusive with old_string and startLine/endLine)" },
223
+ startLine: { type: "integer", description: "1-based first line of the range to replace with new_string (inclusive — requires endLine; mutually exclusive with old_string); OMIT new_string to DELETE the range" },
224
+ endLine: { type: "integer", description: "1-based last line of the range to replace with new_string (inclusive — requires startLine; mutually exclusive with old_string); OMIT new_string to DELETE the range" },
222
225
  replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
223
226
  edits: {
224
227
  type: "array",
225
- description: "Batch form — multiple edits in ONE call, atomic (any failure writes nothing; same-file entries apply serially, each based on the previous result). Use it for multiple changes to the same file AND for independent changes across multiple files — prefer one batched call over N single edits. A top-level path is allowed — it defaults entries without their own path (entry paths win). Mutually exclusive with top-level old_string/new_string — provide each change's old/new inside its edits entry.",
228
+ description: "Batch form — multiple edits in ONE call, atomic (any failure writes nothing; same-file entries apply serially, each based on the previous result). Use it for multiple changes to the same file AND for independent changes across multiple files — prefer one batched call over N single edits. A top-level path is allowed — it defaults entries without their own path (entry paths win). Mutually exclusive with top-level old_string/new_string/line/startLine/endLine — provide each change's targeting (old_string, or line / startLine+endLine) inside its edits entry; new_string replaces when given, and line-targeted entries may omit it to DELETE the line/range.",
226
229
  items: {
227
230
  type: "object",
228
231
  properties: {
229
232
  path: { type: "string" },
230
233
  old_string: { type: "string" },
231
234
  new_string: { type: "string" },
235
+ line: { type: "integer" },
236
+ startLine: { type: "integer" },
237
+ endLine: { type: "integer" },
232
238
  replace_all: { type: "boolean" },
233
239
  },
234
- required: ["old_string", "new_string"],
235
240
  },
236
241
  },
237
242
  },
@@ -253,8 +258,8 @@ export const editTool = {
253
258
  // (应用逻辑在 edit-batch.mjs——2026-09-01 拆出,500 行硬限,先例 git-ext.mjs)
254
259
  if (args.edits) return applyEditBatch(args, ctx)
255
260
 
256
- // 单文件(现状路径)——执行体整段迁出至 edit-diff.mjs(TOOLS.md §15 D15.1——
257
- // 行级 LCS 判定:零重叠→插入 / 一般 diff→LCS / 空 new→显式报错)
261
+ // 单文件(现状路径)——执行体整段迁出至 edit-diff.mjs(EDIT.md §6——
262
+ // 行级 LCS 判定:零重叠→替换即删 / 一般 diff→LCS / 空 new→显式报错)
258
263
  return runSingleEdit(args, ctx)
259
264
  },
260
265
  }
package/src/tools/git.mjs CHANGED
@@ -42,7 +42,7 @@ export const gitTool = {
42
42
  action: { type: "string", enum: ["diff", "status", "log", "show", "checkpoint", "add", "rm", "commit", "push", "tag", "branch", "checkout", "restore", "stash", "fetch", "pull", "reset", "revert", "merge", "cherry-pick", "ls-remote", "clone", "init", "rebase", "remote", "clean", "switch", "apply", "worktree", "archive", "blame", "mv"], description: "diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick / ls-remote / clone / init / rebase / remote / clean / switch / apply / worktree / archive / blame / mv — clean/rebase 操作前自动快照,checkpointAction=rewind 恢复" },
43
43
  // diff/log params
44
44
  staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
45
- path: { type: "string", description: "(diff/log/add/commit/checkout/restore/checkpoint:cat/versions/rewind/rm/apply/archive/blame/mv/worktree) File or directory to scope to / stage / restore(checkout/restore 操作前自动快照,checkpointAction=rewind 恢复)" },
45
+ path: { type: "string", description: "(diff/log/add/commit/checkout/restore/checkpoint:cat/versions/rewind/rm/apply/archive/blame/mv/worktree) File or directory to scope to / stage / restore(checkout/restore 操作前自动快照,checkpointAction=rewind 恢复)— add/rm/commit: space-separated for multiple(git add a b c——2026-09-05 发版痛点;含空格的文件名会被按多路径拆分——请用 bash git 处理)" },
46
46
  ref: { type: "string", description: "(diff/show/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create/rebase/worktree:add/archive) Commit/branch/ref; (push/pull/fetch) the branch or tag (space-separated for multiple)" },
47
47
  count: { type: "number", description: "(log) Number of commits (default 10)" },
48
48
  oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
@@ -143,13 +143,16 @@ export const gitTool = {
143
143
  }
144
144
  case "rm": {
145
145
  if (!args.path) return "Error: rm requires path (the file/directory to untrack, relative to repo root)"
146
- const r = runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", args.path])
147
- return r.ok ? truncate(r.out || `Untracked ${args.path} (kept on disk)`) : truncate(`git rm failed: ${r.err || r.out}`)
146
+ const paths = args.path.split(/\s+/).filter(Boolean)
147
+ const r = runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", ...paths])
148
+ return r.ok ? truncate(r.out || `Untracked ${paths.join(" ")} (kept on disk)`) : truncate(`git rm failed: ${r.err || r.out}`)
148
149
  }
149
150
  case "commit": {
150
151
  if (!args.message) return "Error: commit requires message"
151
152
  // Granular staging when path given (only stage these); otherwise stage all (add -A).
152
- const add = runGitStrict(ctx.cwd, args.path ? ["add", "--", args.path] : ["add", "-A"])
153
+ // 多路径:空格分隔(ref 先例——2026-09-05 发版痛点)
154
+ const staged = args.path ? args.path.split(/\s+/).filter(Boolean) : null
155
+ const add = runGitStrict(ctx.cwd, staged?.length ? ["add", "--", ...staged] : ["add", "-A"])
153
156
  if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
154
157
  const commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
155
158
  const parts = []
@@ -189,9 +192,11 @@ export const gitTool = {
189
192
  }
190
193
  case "add": {
191
194
  // Granular staging: stage `path` when given, else all changes (add -A).
192
- const cmdArgs = args.path ? ["add", "--", args.path] : ["add", "-A"]
195
+ // 多路径:空格分隔(ref 先例 L175——2026-09-05 发版痛点——git add 单路径被迫 N 次调用)
196
+ const paths = args.path ? args.path.split(/\s+/).filter(Boolean) : null
197
+ const cmdArgs = paths?.length ? ["add", "--", ...paths] : ["add", "-A"]
193
198
  const r = runGitStrict(ctx.cwd, cmdArgs)
194
- return r.ok ? truncate(r.out || `Staged ${args.path || "all changes"}`) : truncate(`git add failed: ${r.err || r.out}`)
199
+ return r.ok ? truncate(r.out || `Staged ${paths?.join(" ") || "all changes"}`) : truncate(`git add failed: ${r.err || r.out}`)
195
200
  }
196
201
  case "tag": {
197
202
  const sub = args.tagAction
@@ -361,6 +366,9 @@ export const questionTool = {
361
366
  readonly: true,
362
367
  async execute(args, ctx) {
363
368
  if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
369
+ // §22 D-Q3 机械限制(2026-09-06 用户裁定:100 字符 / 4 条——超限拒绝回模型,不弹卡、不调 onQuestion)
370
+ if (args.question.length > 100) return "(error: question too long (>100 chars) — ask ONE short question; background belongs in your normal reply text)"
371
+ if (Array.isArray(args.options) && args.options.length > 4) return "(error: too many options (>4) — offer at most 4 plain-string options)"
364
372
  return ctx.onQuestion(args.question, args.options ?? [])
365
373
  },
366
374
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * glob-dialect.mjs — glob 方言(TOOLS.md §17 — 2026-09-06)单一权威模块(CLI)。
3
+ *
4
+ * `{a,b}` brace expansion is handled BEFORE the sentinel/escape flow (braces must
5
+ * not be literal-escaped — a bare "star-star slash star .{js,txt}" pattern
6
+ * previously escaped to a literal that silently matched nothing). Unsupported
7
+ * extglob dialects (`?(x)`/`@(a|b)`/`+(x)`/`*(x)`/`!(x)`) and malformed braces
8
+ * (empty `{}`, unclosed `{`, nested `{a,{b,c}}`) are EXPLICIT errors — never a
9
+ * silent no-match. Exclude prefixes (`!pattern`) are the CALLER's job: the
10
+ * glob/grep tools whitespace-split the pattern expression (splitGlobPatterns —
11
+ * never inside a brace group) and pass the parts to compileGlobMatchers, which
12
+ * intersects includes and excludes.
13
+ *
14
+ * Extracted from shared.mjs (2026-09-06 advisor #5 — shared.mjs exceeded the
15
+ * 500-line cap after §17); shared.mjs re-exports these symbols so all existing
16
+ * importers (system.mjs / ls filter / tests) keep their import paths.
17
+ */
18
+
19
+ const BRACE_OPEN = "\u0003", BRACE_SEP = "\u0004", BRACE_CLOSE = "\u0005"
20
+ const EXT_GLOB_RE = /[@?+*!]\(/
21
+
22
+ export const GLOB_EXTGLOB_ERROR =
23
+ "glob syntax error: unsupported extglob syntax — ?(x)/@(a|b)/+(x)/*(x)/!(x) are not supported; use {a,b} brace expansion or multiple space-separated patterns instead"
24
+ export const GLOB_EMPTY_BRACE_ERROR =
25
+ 'glob syntax error: empty brace group "{}" — put at least one alternative between the braces (e.g. {js,txt})'
26
+ export const GLOB_UNCLOSED_BRACE_ERROR =
27
+ 'glob syntax error: unclosed brace group — add a matching "}" (e.g. {js,txt})'
28
+ export const GLOB_NESTED_BRACE_ERROR =
29
+ "glob syntax error: nested brace groups are not supported (e.g. {a,{b,c}}) — use a flat alternative list (e.g. {a,b,c})"
30
+
31
+ function assertNoExtglob(pattern) {
32
+ if (EXT_GLOB_RE.test(pattern)) throw new Error(GLOB_EXTGLOB_ERROR)
33
+ }
34
+
35
+ /** Replace every {a,b,c} group with placeholder alternation markers. Runs BEFORE
36
+ * the sentinel/escape flow so the inserted alternation survives literal escaping. */
37
+ function expandBraces(pattern) {
38
+ for (;;) {
39
+ const open = pattern.indexOf("{")
40
+ if (open === -1) return pattern
41
+ let close = -1
42
+ for (let j = open + 1; j < pattern.length; j++) {
43
+ if (pattern[j] === "{") throw new Error(GLOB_NESTED_BRACE_ERROR)
44
+ if (pattern[j] === "}") { close = j; break }
45
+ }
46
+ if (close === -1) throw new Error(GLOB_UNCLOSED_BRACE_ERROR)
47
+ const inner = pattern.slice(open + 1, close)
48
+ if (inner === "") throw new Error(GLOB_EMPTY_BRACE_ERROR)
49
+ const alts = inner.split(",").map((a) => a.trim())
50
+ pattern = pattern.slice(0, open) + BRACE_OPEN + alts.join(BRACE_SEP) + BRACE_CLOSE + pattern.slice(close + 1)
51
+ }
52
+ }
53
+
54
+ /** Convert a single glob pattern to a RegExp (anchored full match). Supports the
55
+ * recursive "star-star" forms, single-star, "?", "[..]" character classes and
56
+ * {a,b} brace expansion. Malformed braces and unsupported extglob syntax throw
57
+ * explicit errors (no silent mismatch). */
58
+ export function globToRegex(pattern) {
59
+ pattern = String(pattern)
60
+ assertNoExtglob(pattern)
61
+ pattern = expandBraces(pattern)
62
+ // Sentinel chars: \u0001/\u0002 never appear in real glob patterns (they
63
+ // come from model output or the filesystem) — safe as **/ and ** placeholders.
64
+ const DS = "\u0001", DP = "\u0002"
65
+ const escaped = pattern
66
+ .replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
67
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
68
+ .replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
69
+ .replace(new RegExp(DS, "g"), "(?:.+/)?")
70
+ .replace(new RegExp(DP, "g"), ".*")
71
+ .replace(new RegExp(BRACE_OPEN, "g"), "(?:")
72
+ .replace(new RegExp(BRACE_SEP, "g"), "|")
73
+ .replace(new RegExp(BRACE_CLOSE, "g"), ")")
74
+ return new RegExp(`^${escaped}$`)
75
+ }
76
+
77
+ /** Whitespace-split a glob expression into parts WITHOUT splitting inside {a,b}
78
+ * brace groups and WITHOUT splitting literal spaces inside a single pattern
79
+ * (2026-09-06 audit F1 / advisor #4): a whitespace run is a pattern separator
80
+ * ONLY when the next non-space token starts an exclusion ("!" — the §17
81
+ * "include !exclude" form). So a BARE pattern like "docs/my file/*.md" and an
82
+ * exclude like "!docs/my file/**" each stay ONE part; the standard
83
+ * "js-pattern 空格 !test-pattern" form still splits into its two parts. */
84
+ export function splitGlobPatterns(expr) {
85
+ const s = String(expr ?? "").trim()
86
+ if (!s) return []
87
+ const parts = []
88
+ let depth = 0
89
+ let cur = ""
90
+ for (let i = 0; i < s.length; i++) {
91
+ const ch = s[i]
92
+ if (ch === "{") depth++
93
+ else if (ch === "}") depth = Math.max(0, depth - 1)
94
+ if (/\s/.test(ch) && depth === 0) {
95
+ let j = i
96
+ while (j < s.length && /\s/.test(s[j])) j++
97
+ if (s[j] === "!") {
98
+ if (cur) { parts.push(cur); cur = "" }
99
+ i = j - 1 // next loop iteration continues at the "!"
100
+ continue
101
+ }
102
+ // literal space inside a pattern (or trailing) — keep it in the current part
103
+ cur += ch
104
+ continue
105
+ }
106
+ cur += ch
107
+ }
108
+ if (cur) parts.push(cur)
109
+ return parts
110
+ }
111
+
112
+ /** Compile whitespace-split pattern parts (see splitGlobPatterns) into a matcher:
113
+ * non-"!" parts are includes, leading-"!" parts are excludes — a rel path matches
114
+ * when it matches ≥1 include (or there are no includes) and no exclude. Every
115
+ * part runs the full brace/error dialect through globToRegex (excludes too). */
116
+ export function compileGlobMatchers(parts) {
117
+ const includes = []
118
+ const excludes = []
119
+ for (const p of parts) {
120
+ const part = String(p)
121
+ if (part.startsWith("!")) excludes.push(part.slice(1))
122
+ else if (part !== "") includes.push(part)
123
+ }
124
+ const include = includes.map((p) => globToRegex(p))
125
+ const exclude = excludes.map((p) => globToRegex(p))
126
+ const test = (relPath) =>
127
+ (include.length === 0 || include.some((re) => re.test(relPath))) &&
128
+ !exclude.some((re) => re.test(relPath))
129
+ return { include, exclude, test }
130
+ }
package/src/tools/glob.md CHANGED
@@ -1,11 +1,11 @@
1
- Find files by glob pattern. Returns matching paths. Supports `**` for recursive matching (e.g. `src/**/*.mjs` for all .mjs in src/, `**/*.test.mjs` for all test files).
1
+ Find files by glob pattern. Returns matching paths (relative to the search path), sorted, capped at 1000. Use this to discover file structure; use grep to search file contents.
2
2
 
3
3
  Parameters:
4
- - pattern (required): Glob pattern — supports **, *, ?, and character classes
4
+ - pattern (required): Glob pattern — supports `**` (recursive), `**/`, `*` (within a segment), `?` (single char), `[..]` character classes, and `{a,b}` brace expansion (e.g. `**/*.{js,txt}` matches .js and .txt files at any depth). Space-separated multiple patterns with a leading `!` are EXCLUSIONS — `**/*.js !test/**` matches .js files except those under test/. Pattern matching is relative to `path`. A pattern containing a literal space is fine on its own (spaces only separate patterns when a `!` exclusion is present). An expression splits ONLY before `!`-exclusion tokens — to match several extensions in one include, use a `{a,b}` group (e.g. `**/*.{js,md}`); adjacent space-separated includes are not a supported form.
5
5
  - path: Directory to search in (default cwd)
6
6
 
7
7
  Notes:
8
+ - Invalid glob syntax is an EXPLICIT error — never a silent no-match. Unsupported extglob dialects (`?(x)`/`@(a|b)`/`+(x)`) and malformed braces (empty `{}`, unclosed `{`, nested `{a,{b,c}}`) return `glob error: ...` — use `{a,b}` or a space-separated `!exclude` pattern instead.
8
9
  - Skips node_modules, .git, dist, build, .turbo, coverage
9
10
  - Results capped at 1000 matches
10
- - Use this to discover file structure; use grep to search file contents
11
11
  - Prefer patterns with a literal anchor (extension or subdirectory) over bare wildcards
package/src/tools/grep.md CHANGED
@@ -5,7 +5,7 @@ Search file contents with a regex. Returns matching lines as path:line: content.
5
5
  Parameters:
6
6
  - pattern (required): JavaScript regular expression, or a literal string when literal=true
7
7
  - path: Directory or file to search (default cwd)
8
- - glob: Only search files matching this glob (e.g. '*.mjs')
8
+ - glob: Only search files matching this glob — supports `**`, `*`, `?`, `[..]`, `{a,b}` braces and space-separated exclusion (`"**/*.js !test/**"` = .js files outside test/); e.g. '*.mjs'
9
9
  - ignoreCase: Case-insensitive match (default false)
10
10
  - literal: Literal string match — no regex interpretation (default false; use for strings with `. \` etc.)
11
11
  - before: Lines of context to show before each match (grep -B). Default 0
@@ -2,7 +2,6 @@
2
2
  export { toOpenAISchema } from "./shared.mjs";
3
3
 
4
4
  import { readTool, writeTool, editTool, insertAfterTool, readImageTool, hashlineEditTool } from "./file.mjs";
5
- import { readPdfTool } from "./pdf.mjs";
6
5
  import { applyPatchTool, deleteTool } from "./patch.mjs";
7
6
  import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
8
7
  import { websearchTool, fetchTool } from "./web.mjs";
@@ -11,25 +10,25 @@ import { checklistTool } from "./checklist.mjs";
11
10
  import { lintTool } from "./linter.mjs";
12
11
  import { lspTool } from "./lsp.mjs";
13
12
  import { executeTool } from "./execute.mjs";
14
- import { fileOpsTool, processTool, getCurrentTimeTool } from "./ops.mjs";
13
+ import { fileOpsTool, processTool, getCurrentTimeTool, waitForTool } from "./ops.mjs";
15
14
  import { treeTool } from "./tree.mjs";
16
15
 
17
16
  export const builtinTools = [
18
17
  readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
19
- readImageTool, readPdfTool, bashTool, globTool, grepTool,
18
+ readImageTool, bashTool, globTool, grepTool,
20
19
  websearchTool, lsTool, fetchTool, deleteTool,
21
20
  gitTool, questionTool,
22
21
  checklistTool, lintTool, lspTool, executeTool,
23
- fileOpsTool, processTool, getCurrentTimeTool,
22
+ fileOpsTool, processTool, getCurrentTimeTool, waitForTool,
24
23
  treeTool,
25
24
  ];
26
25
 
27
26
  export {
28
27
  readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
29
- readImageTool, readPdfTool, bashTool, globTool, grepTool,
28
+ readImageTool, bashTool, globTool, grepTool,
30
29
  websearchTool, lsTool, fetchTool, deleteTool,
31
30
  gitTool, questionTool,
32
31
  checklistTool, lintTool, lspTool, executeTool,
33
- fileOpsTool, processTool, getCurrentTimeTool,
32
+ fileOpsTool, processTool, getCurrentTimeTool, waitForTool,
34
33
  treeTool,
35
34
  };
package/src/tools/ops.mjs CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * ops.mjs — operational tools: file_ops (move/copy/rename), process (list),
3
- * get_current_time. Each exists so the model reaches for a dedicated tool
4
- * instead of shelling out to `bash` for the same operation (parity with thinworker).
3
+ * get_current_time, wait_for (condition wait). Each exists so the model reaches
4
+ * for a dedicated tool instead of shelling out to `bash` for the same operation
5
+ * (parity with thinworker).
5
6
  */
6
7
  import { DESC, resolveInCwd, truncate } from "./shared.mjs"
7
8
  import { cp, rename, rm } from "node:fs/promises"
9
+ import { existsSync } from "node:fs"
8
10
  import { execFileSync } from "node:child_process"
11
+ import net from "node:net"
9
12
 
10
13
  // ─── file_ops ──────────────────────────────────────────────────
11
14
 
@@ -111,4 +114,173 @@ export const getCurrentTimeTool = {
111
114
  const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
112
115
  return `Date: ${now.toISOString()} (UTC)\nTimezone: ${tz}\nWeekday: ${days[now.getDay()]}\nLocal: ${now.toLocaleString()}`
113
116
  },
114
- }
117
+ }
118
+
119
+ // ─── wait_for ───────────────────────────────────────────────────
120
+
121
+ /** wait_for bounds (TOOLS.md §16): a BUILT-IN ceiling replaces the unbounded
122
+ * sleep-then-wait — timeout_ms defaults to 30s (config.json agent.waitForTimeoutMs
123
+ * overrides), capped at WAIT_FOR_MAX_TIMEOUT_MS (600s — the execute-tool timeout
124
+ * convention, Math.min(t, 600_000)); interval_ms defaults to 1s with a 100ms
125
+ * floor so polling never busy-spins. */
126
+ export const WAIT_FOR_DEFAULT_TIMEOUT_MS = 30_000
127
+ export const WAIT_FOR_MAX_TIMEOUT_MS = 600_000
128
+ export const WAIT_FOR_DEFAULT_INTERVAL_MS = 1_000
129
+ export const WAIT_FOR_MIN_INTERVAL_MS = 100
130
+
131
+ // Condition-source seam (§16 评审 #4): production uses the real evaluator
132
+ // (evaluateWaitForCondition); tests inject a deterministic fake source via
133
+ // setWaitForConditionSource (default null — production path unchanged).
134
+ let injectedConditionSource = null
135
+ export function setWaitForConditionSource(source) {
136
+ injectedConditionSource = typeof source === "function" ? source : null
137
+ }
138
+
139
+ /** Parse a wait_for condition into { kind, arg }. Unknown conditions are an
140
+ * EXPLICIT error enumerating the supported forms (评审 #2 — never a silent
141
+ * wait on a misspelled condition). */
142
+ export function parseWaitForCondition(condition) {
143
+ const c = String(condition ?? "").trim()
144
+ let m
145
+ if (c === "advisor settled") return { kind: "advisor", arg: null }
146
+ if (c === "consult done") return { kind: "consult", arg: null }
147
+ if ((m = c.match(/^subagent id:\s*(\d+) done$/))) return { kind: "subagent", arg: String(Number(m[1])) }
148
+ if ((m = c.match(/^file exists:\s*(.+)$/))) return { kind: "file", arg: m[1].trim() }
149
+ if ((m = c.match(/^port open:\s*(\d+)$/))) return { kind: "port", arg: Number(m[1]) }
150
+ throw new Error(
151
+ `wait_for: unsupported condition "${c}" — supported: "advisor settled", "subagent id:N done", "consult done", "file exists:path", "port open:N"`,
152
+ )
153
+ }
154
+
155
+ /** Async-subagent pool of an agent — ctx.agent 可达性(评审 #1):dispatch 把
156
+ * agent 实例放进工具 ctx(agent/dispatch.mjs toolCtx.agent),池挂在 agent 上
157
+ * (深度 0 的 VS Code 侧 agent._asyncSubagents 与 history._asyncSubagents 同
158
+ * 一 Map——见 agent.mjs;CLI 侧直接是 agent._asyncSubagents)。 */
159
+ function asyncPool(agent) {
160
+ if (!agent) return new Map()
161
+ if (agent._asyncSubagents instanceof Map) return agent._asyncSubagents
162
+ return agent.history?._asyncSubagents instanceof Map ? agent.history._asyncSubagents : new Map()
163
+ }
164
+
165
+ /** "subagent id:N done" — the async entry for N settled (status done / done flag)
166
+ * or already left the pool (moved to pending results / digested / never spawned —
167
+ * nothing is left to wait on). Absent ids are done by vacuity: the model waits on
168
+ * ids its own spawn ack just returned, so an id no longer in the pool means done.
169
+ * Dual key lookup (2026-09-06 advisor #1): VS Code pool keys are the numeric
170
+ * spawn-time id, the CLI keys them as String(id) — accept both. */
171
+ function asyncEntryDone(agent, id) {
172
+ const pool = asyncPool(agent)
173
+ const key = String(id)
174
+ const entry = pool.get(key) ?? pool.get(Number(key)) ?? null
175
+ if (!entry) return true
176
+ return entry.done === true || entry.status === "done"
177
+ }
178
+
179
+ /** Any async entry matching pred that is still running/queued (not done). */
180
+ function hasRunningAsync(agent, pred) {
181
+ for (const e of asyncPool(agent).values()) {
182
+ if (pred(e) && !(e.done === true || e.status === "done")) return true
183
+ }
184
+ return false
185
+ }
186
+
187
+ /** "consult done" — every consult session has drained (pending 0) or was
188
+ * explicitly stopped (its children were aborted). No sessions → true (vacuously). */
189
+ function consultAllDone(agent) {
190
+ const sessions = agent?._consultSessions
191
+ if (!sessions || sessions.size === 0) return true
192
+ for (const s of sessions.values()) {
193
+ if (!s.stopped && (s.pending ?? 0) > 0) return false
194
+ }
195
+ return true
196
+ }
197
+
198
+ /** "port open:N" — 127.0.0.1 probe with a short connect timeout (never hangs a poll). */
199
+ function portOpenProbe(port) {
200
+ return new Promise((resolve) => {
201
+ const socket = net.connect({ host: "127.0.0.1", port, timeout: 400 })
202
+ let settled = false
203
+ const finish = (v) => {
204
+ if (settled) return
205
+ settled = true
206
+ socket.destroy()
207
+ resolve(v)
208
+ }
209
+ socket.once("connect", () => finish(true))
210
+ socket.once("error", () => finish(false))
211
+ socket.once("timeout", () => finish(false))
212
+ })
213
+ }
214
+
215
+ /** Real condition evaluator (production path) — returns a boolean; throws on an
216
+ * unsupported condition string. Polling is async (await setTimeout) — the event
217
+ * loop keeps running so background async work (subagents/consult) can settle
218
+ * between polls (评审 #1 — no blocking spin). */
219
+ export async function evaluateWaitForCondition(condition, ctx) {
220
+ const parsed = parseWaitForCondition(condition)
221
+ const agent = ctx?.agent
222
+ switch (parsed.kind) {
223
+ case "advisor":
224
+ // Advisor reviews run as blocking tool calls (runAdvisorReview) or as
225
+ // advisor-role async children — "settled" = no advisor-role entry still
226
+ // running/queued in the async pool.
227
+ return !hasRunningAsync(agent, (e) => e.role === "advisor")
228
+ case "subagent":
229
+ return asyncEntryDone(agent, parsed.arg)
230
+ case "consult":
231
+ return consultAllDone(agent)
232
+ case "file":
233
+ return existsSync(resolveInCwd(ctx, parsed.arg))
234
+ case "port":
235
+ return await portOpenProbe(parsed.arg)
236
+ default:
237
+ return false
238
+ }
239
+ }
240
+
241
+ async function evalConditionSource(condition, ctx) {
242
+ if (injectedConditionSource) return injectedConditionSource(condition, ctx)
243
+ return evaluateWaitForCondition(condition, ctx)
244
+ }
245
+
246
+ export const waitForTool = {
247
+ name: "wait_for",
248
+ description: DESC("wait_for"),
249
+ parameters: {
250
+ type: "object",
251
+ properties: {
252
+ condition: { type: "string", description: 'Condition expression — "advisor settled", "subagent id:N done", "consult done", "file exists:path", "port open:N"' },
253
+ interval_ms: { type: "integer", description: "Poll interval in ms (default 1000, floor 100)" },
254
+ timeout_ms: { type: "integer", description: "Overall timeout in ms (default 30000, cap 600000; config.json agent.waitForTimeoutMs overrides the default)" },
255
+ },
256
+ required: ["condition"],
257
+ },
258
+ readonly: true,
259
+ async execute(args, ctx = {}) {
260
+ const condition = typeof args?.condition === "string" ? args.condition.trim() : ""
261
+ if (!condition) return "Error: condition is required"
262
+ const intervalMs = Math.max(WAIT_FOR_MIN_INTERVAL_MS, Math.floor(args?.interval_ms ?? WAIT_FOR_DEFAULT_INTERVAL_MS))
263
+ const cfgTimeout = ctx?.agent?.config?.agent?.waitForTimeoutMs
264
+ const base = Number.isFinite(args?.timeout_ms)
265
+ ? args.timeout_ms
266
+ : Number.isFinite(cfgTimeout) && cfgTimeout > 0 ? cfgTimeout : WAIT_FOR_DEFAULT_TIMEOUT_MS
267
+ const timeoutMs = Math.min(Math.max(1, Math.floor(base)), WAIT_FOR_MAX_TIMEOUT_MS)
268
+ const started = Date.now()
269
+ let polls = 0
270
+ for (;;) {
271
+ // Cancel/interrupt-safe exit(T-W7):Ctrl+C / 定向 abort 传播——不吞信号、
272
+ // 不把用户停变成工具错误(dispatch 对 aborted signal 原样再抛)。
273
+ if (ctx.signal?.aborted) throw new Error("wait_for: interrupted by abort signal")
274
+ polls++
275
+ const ok = await evalConditionSource(condition, ctx)
276
+ if (ok === true) {
277
+ return `wait_for: condition satisfied after ${Date.now() - started}ms (${polls} check${polls === 1 ? "" : "s"}): "${condition}"`
278
+ }
279
+ const remaining = started + timeoutMs - Date.now()
280
+ if (remaining <= 0) {
281
+ return `wait_for: timed out after ${timeoutMs}ms waiting for "${condition}" (${polls} checks — condition never became true)`
282
+ }
283
+ await new Promise((r) => setTimeout(r, Math.min(intervalMs, remaining)))
284
+ }
285
+ },
286
+ }
@@ -16,7 +16,7 @@ import { relative, dirname } from "node:path";
16
16
  * Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
17
17
  * Consume hunk lines by the line counts in the @@ header — LLMs often strip context blank lines to pure empty lines,
18
18
  * so we use counts rather than first characters to determine hunk boundaries.
19
- * D15.6: a bare "@@" header (no coordinates) is accepted — the hunk body runs until the next hunk/file header
19
+ * APPLY-PATCH.md §3: a bare "@@" header (no coordinates) is accepted — the hunk body runs until the next hunk/file header
20
20
  * ("@@" / "--- " / "+++ " / "diff " / "index "), purely located by its ops.
21
21
  */
22
22
  /**
@@ -68,7 +68,7 @@ function parsePatch(patch) {
68
68
  if (!cur) throw new Error("Malformed patch: hunk header before any file header")
69
69
  const m = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/)
70
70
  if (!m) {
71
- // D15.6 (TOOLS.md §15):坐标裸 "@@" 头——hunk 完全靠操作行定位。
71
+ // APPLY-PATCH.md §3:坐标裸 "@@" 头——hunk 完全靠操作行定位。
72
72
  // 无行数可用:操作行以 " " / "-" / "+" 开头(空行宽容为上下文行),
73
73
  // 直到下一个 hunk 头 / 文件头 "@@"/"--- "/"+++ "/"diff "/"index " 为止。
74
74
  if (!/^@@(?: @@)?\s*$/.test(line)) {
@@ -94,7 +94,7 @@ function parsePatch(patch) {
94
94
  }
95
95
  if (hunk.ops.length === 0) throw new Error(`Malformed patch: empty coordinate-less hunk "${line.trim()}"`)
96
96
  const ctxCount = hunk.ops.filter((o) => o.type === " ").length
97
- // §15.3 (TOOLS.md D15.10.1——2026-09-04):context<2 且含 ≥1 个 - 行 → 接受——定位锚 = hunk 内
97
+ // §3 (APPLY-PATCH.md——2026-09-04):context<2 且含 ≥1 个 - 行 → 接受——定位锚 = hunk 内
98
98
  // 匹配行序列(空格上下文行 + - 行——按出现序)连续——唯一匹配即应用(applyHunks 既有锚匹配域——
99
99
  // 多匹配 / not-found 语义不变)。0 上下文与 1 上下文同待遇(评审 #4a)。
100
100
  // 纯 +(无 - 锚)且 context<2 仍拒——插入位置不可判——报错引导加锚(NF15.8c)。
@@ -9,4 +9,7 @@ Notes:
9
9
  - The answer is injected as the next user message
10
10
  - Returns the user's answer — the chosen option or free text — as the next message; the loop resumes when it arrives.
11
11
  - Use sparingly — prefer making reasonable decisions when possible
12
+ - Ask ONE question per call — never bundle multiple sub-questions into one question string; ask the next one after the answer arrives.
13
+ - Keep the question text short — one or two sentences. Background, context, and analysis belong in your normal reply text, NOT in the question.
14
+ - Routine confirmations (confirm gates) belong in your plain reply text — the user answers in their next message. Use this tool ONLY when you need the user's decision or input to proceed.
12
15
  - After receiving an answer about a design convention, tool preference, or recurring pattern: save it with the memory tool (action: put). This prevents asking the same question in future sessions — the user shouldn't have to repeat their preferences.
package/src/tools/read.md CHANGED
@@ -7,7 +7,6 @@ Read a text file. Returns numbered lines. Use offset/limit to page large files.
7
7
  - Know the symbol but not the location? → `code_search` or `lsp definition`
8
8
  - Know the file but not the lines? → `grep` to find line numbers, then read that range with offset/limit
9
9
  - Reading an image? → `read_image` instead
10
- - Reading a PDF? → `read_pdf` instead (read would decode the binary as UTF-8 garbage)
11
10
 
12
11
  Parameters:
13
12
  - path (required): File path, relative to cwd or absolute (alias: filePath)
@@ -363,19 +363,20 @@ export function detectDanger(command) {
363
363
  }
364
364
 
365
365
 
366
- /** Convert glob pattern to regex */
367
- export function globToRegex(pattern) {
368
- // Sentinel chars: \u0001/\u0002 never appear in real glob patterns (they
369
- // come from model output or the filesystem) safe as **/ and ** placeholders.
370
- const DS = "\u0001", DP = "\u0002"
371
- const escaped = pattern
372
- .replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
373
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
374
- .replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
375
- .replace(new RegExp(DS, "g"), "(?:.+/)?")
376
- .replace(new RegExp(DP, "g"), ".*")
377
- return new RegExp(`^${escaped}$`)
378
- }
366
+ /** Glob dialect (TOOLS.md §17) lives in glob-dialect.mjs single authority for
367
+ * brace expansion / exclusion / explicit syntax errors (extracted 2026-09-06 —
368
+ * advisor #5: shared.mjs exceeded 500 lines). Re-exported so existing importers
369
+ * (system.mjs tools, ls filter, tests) keep their import paths unchanged. */
370
+ export {
371
+ globToRegex,
372
+ splitGlobPatterns,
373
+ compileGlobMatchers,
374
+ GLOB_EXTGLOB_ERROR,
375
+ GLOB_EMPTY_BRACE_ERROR,
376
+ GLOB_UNCLOSED_BRACE_ERROR,
377
+ GLOB_NESTED_BRACE_ERROR,
378
+ } from "./glob-dialect.mjs"
379
+
379
380
 
380
381
  /** Decode a numeric HTML entity to its code point — invalid/out-of-range
381
382
  * values (e.g. &#999999999999;) must not throw RangeError; keep the source