zames_pro 2.70.0 → 2.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ import { loadHooks, runLifecycleHooks } from './hooks.js';
2
+ // Subagent runner (BACKLOG N36). A `Task` tool call from the model is handled
3
+ // by the agent loop as a SEAM (like onAutoCompact): it calls back into the
4
+ // caller, which owns the whole lifecycle. This module builds that callback.
5
+ //
6
+ // Why a SEPARATE chat: in zames "context" IS the DeepSeek chat, so context
7
+ // isolation can only be achieved by opening a new chat, running a nested
8
+ // agent loop there, and restoring the parent chat afterwards. The nested loop
9
+ // returns ONLY the subagent's final `respond` report; the parent never sees
10
+ // the subagent's raw turns, which is the whole point.
11
+ //
12
+ // This is deliberately SEQUENTIAL: one browser, one send slot. Parallel
13
+ // subagents would need a second page and a shared send scheduler (a change to
14
+ // browser.ts, which owns the live Playwright context) — out of scope here.
15
+ /** Hard cap on iterations for one subagent run. */
16
+ export const SUBAGENT_MAX_ITER = 30;
17
+ // The report is fed back into the PARENT chat as a tool result, so a runaway
18
+ // subagent must not be able to blow up the parent's context with one answer.
19
+ // The tail is trimmed with a marker, not silently dropped.
20
+ export const SUBAGENT_REPORT_CAP = 20_000;
21
+ /** Cap a subagent report before it becomes a parent-context tool result. */
22
+ export function capReport(text, cap = SUBAGENT_REPORT_CAP) {
23
+ if (text.length <= cap)
24
+ return text;
25
+ return (text.slice(0, cap) +
26
+ '\n… [report truncated: ' +
27
+ (text.length - cap) +
28
+ ' chars omitted]');
29
+ }
30
+ /**
31
+ * The subagent persona, prepended to the task message. It is deliberately NOT
32
+ * injected as a replacement system prompt: the nested runAgentLoop builds the
33
+ * standard system prompt itself (tool list + call format), and the model needs
34
+ * exactly that to work. The persona only adds role + output contract.
35
+ */
36
+ export function subagentPersona(type) {
37
+ const base = 'You are a SUBAGENT launched by a main coding agent to complete ONE ' +
38
+ 'self-contained sub-task in an ISOLATED chat. You do NOT see the main ' +
39
+ "conversation's history, so rely only on the instructions below and on " +
40
+ 'the files/tools available. You cannot talk to the operator, so do not ' +
41
+ 'ask questions. When the sub-task is done, call respond with a concise, ' +
42
+ 'information-dense report: findings, exact file paths, relevant code ' +
43
+ 'snippets and a clear conclusion. Do NOT call respond until the work is ' +
44
+ 'complete.';
45
+ if (type === 'explore') {
46
+ return (base +
47
+ '\n\nYou are in EXPLORE (read-only) mode: you may search and read files ' +
48
+ 'but you CANNOT modify anything or run mutating commands. Investigate ' +
49
+ 'and report your findings.');
50
+ }
51
+ return base;
52
+ }
53
+ /**
54
+ * Build the `onSubagent` callback for runAgentLoop. It enforces the per-task
55
+ * budget, opens a fresh chat, runs a nested loop (with the subagent's own
56
+ * task text and a restricted or full tool set), restores the parent chat and
57
+ * returns only the report.
58
+ */
59
+ export function createSubagentRunner(opts) {
60
+ const { browser, workdir, locale, runAgentLoop, buildTools, transcript = null, maxSubagents, askDeadlineMs, maxAfterToolRetries, onAskPermission, } = opts;
61
+ let used = 0;
62
+ return async (req) => {
63
+ if (used >= maxSubagents) {
64
+ return {
65
+ ok: false,
66
+ text: 'Subagent budget exhausted (' +
67
+ maxSubagents +
68
+ ' per task). Do the remaining work yourself with the other tools.',
69
+ };
70
+ }
71
+ used++;
72
+ const parentChatId = await browser.getCurrentChatId().catch(() => null);
73
+ const subTools = buildTools(req.type === 'explore');
74
+ const taskText = subagentPersona(req.type) + '\n\n--- SUB-TASK ---\n' + req.prompt;
75
+ // Save the parent's send hooks: the nested loop OVERWRITES them on
76
+ // browser, and leaving the nested (no-op) callbacks in place would kill
77
+ // the parent's spinner/status for the rest of the task.
78
+ const savedHooks = {
79
+ onSendStart: browser.onSendStart ?? null,
80
+ onSendPause: browser.onSendPause ?? null,
81
+ onSendState: browser.onSendState ?? null,
82
+ onNotice: browser.onNotice ?? null,
83
+ };
84
+ let report = '';
85
+ let failed = '';
86
+ try {
87
+ report = await runAgentLoop({
88
+ browser,
89
+ tools: subTools,
90
+ task: taskText,
91
+ workdir,
92
+ maxIterations: SUBAGENT_MAX_ITER,
93
+ // A fresh chat gives the subagent an isolated context; the nested loop
94
+ // opens it AND sends the standard system prompt itself.
95
+ freshChat: true,
96
+ sendSystemPrompt: true,
97
+ transcript,
98
+ locale,
99
+ askDeadlineMs,
100
+ maxAfterToolRetries,
101
+ // A subagent NEVER touches BACKLOG.md, even in the operator's dev
102
+ // mode: its sub-task is scoped, and a general subagent appending
103
+ // "improvement" notes would be unrelated, unsupervised edits.
104
+ selfImprovement: false,
105
+ // No recursion: the subagent cannot spawn its own subagents.
106
+ onSubagent: null,
107
+ // Do not persist the subagent chat as a session.
108
+ onChatReady: () => { },
109
+ // Keep the PARENT's UI alive during the nested run. The nested loop
110
+ // OVERWRITES browser.onSendStart/… with its own callbacks; without
111
+ // forwarding the parent's hooks the spinner and the throttle countdown
112
+ // would go blank for the whole subagent run (a long subagent looked
113
+ // like a frozen, dead agent). The nested loop, not this runner, owns
114
+ // the assignment, so we hand it the functions it should install.
115
+ onThinking: savedHooks.onSendStart ?? undefined,
116
+ onSendPause: savedHooks.onSendPause ?? undefined,
117
+ onSendState: savedHooks.onSendState ?? undefined,
118
+ onNotice: savedHooks.onNotice ?? undefined,
119
+ onAskPermission: onAskPermission
120
+ ? (info) => onAskPermission({
121
+ tool: info.tool,
122
+ reason: info.reason,
123
+ action: info.action,
124
+ })
125
+ : undefined,
126
+ });
127
+ }
128
+ catch (e) {
129
+ failed = e.message;
130
+ }
131
+ finally {
132
+ // Restore the parent chat, then the hooks, so a failure in openChat
133
+ // still leaves the hooks correct.
134
+ if (parentChatId) {
135
+ await browser.openChat(parentChatId).catch(() => false);
136
+ }
137
+ browser.onSendStart = savedHooks.onSendStart;
138
+ browser.onSendPause = savedHooks.onSendPause;
139
+ browser.onSendState = savedHooks.onSendState;
140
+ browser.onNotice = savedHooks.onNotice;
141
+ }
142
+ // SubagentStop hook (N32): fires after the nested loop finishes, so a hook
143
+ // can clean up or notify. Best-effort; its stdout is appended to the report.
144
+ let hookOut = '';
145
+ try {
146
+ hookOut = await runLifecycleHooks(loadHooks(workdir), 'SubagentStop', workdir, { type: req.type, ok: failed ? 'false' : 'true' });
147
+ }
148
+ catch { }
149
+ if (failed)
150
+ return { ok: false, text: failed };
151
+ return {
152
+ ok: true,
153
+ text: capReport(hookOut ? report + '\n\n' + hookOut : report),
154
+ };
155
+ };
156
+ }
@@ -21,6 +21,35 @@ Rules for that note:
21
21
  - this is the ONE exception to "do not touch unrelated files". If nothing
22
22
  worth noting, add nothing.
23
23
  `;
24
+ // Guidance for the `Task` tool (subagents), injected only when that tool is in
25
+ // the model's set. Without it the model under-uses subagents (it does every
26
+ // broad search itself and floods the main context) or misuses them (delegates
27
+ // work that needs THIS conversation's history, which the isolated subagent
28
+ // cannot see). Kept short: it competes with the tool list for space.
29
+ const SUBAGENT_SECTION = `
30
+ ## Task tool (subagents)
31
+
32
+ The Task tool delegates a self-contained sub-task to a subagent that works in
33
+ its OWN separate chat with an ISOLATED context and returns ONLY a final
34
+ report. The subagent does NOT see this conversation, so the prompt you pass
35
+ must be fully self-contained (goal, exact questions, file paths, context).
36
+
37
+ Use it to PROTECT this chat's context and to parallelize independent work:
38
+
39
+ - Broad exploration whose raw hits would flood this chat ("find every use of
40
+ X", "map how module Y works end to end"): delegate it with
41
+ subagent_type "explore" (read-only) and keep only the report.
42
+ - A self-contained research or verification sub-task you can specify in a
43
+ paragraph: delegate it and continue the main line meanwhile.
44
+ - Several INDEPENDENT investigations: emit several Task calls in ONE batch.
45
+
46
+ Do NOT use it for:
47
+ - work that depends on this conversation's history (the subagent cannot see
48
+ it) - pass everything needed in the prompt, or do the work yourself;
49
+ - a trivial single read/edit you can do directly (a subagent costs a full
50
+ extra chat with its own system prompt and sends);
51
+ - edits that must run strictly in order with the rest of this chat.
52
+ `;
24
53
  /** Render the AGENTS.md / MEMORY / skills / commands blocks of the prompt. */
25
54
  export function renderContextSection(context) {
26
55
  if (!context)
@@ -112,6 +141,12 @@ export function renderToolSection(tools) {
112
141
  export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = 'ru', attachments = [], context = null, selfImprovement = false, }) {
113
142
  const t = translate(locale);
114
143
  const toolDescriptions = renderToolSection(tools);
144
+ // Emit the subagent guidance ONLY when the Task tool is actually in the set
145
+ // (subagents enabled and not in plan mode), so the prompt never tells the
146
+ // model about a tool it does not have.
147
+ const subagentSection = tools.some((x) => x.name === 'Task')
148
+ ? SUBAGENT_SECTION
149
+ : '';
115
150
  const gitSection = gitContext
116
151
  ? `\n## Git context\n\n${gitContext}\n`
117
152
  : '\n## Git context\n\nNot a git repository (or git is not installed).\n';
@@ -186,7 +221,9 @@ Prefer the most specific tool; a wrong choice wastes a turn:
186
221
  directory -> LS.
187
222
  - Git operations -> the Git* tools, not Bash.
188
223
  - Reading a file whose path you already know -> Read, not Bash cat.
189
-
224
+ - A broad search/exploration that would flood this chat -> the Task tool
225
+ (a subagent in its own chat) if it is available, then work from its report.
226
+ ${subagentSection}
190
227
  ## MCP tools
191
228
 
192
229
  Some tools are provided by external MCP servers and their names look
package/dist/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
- import { assertCommandInsideRoot, runFile, runShell } from './shell.js';
3
+ import { assertCommandInsideRoot, runFile, runShell, startBackground, pollBackground, killBackground, } from './shell.js';
4
+ import { isImageName, guessMime, formatSize } from './attachments.js';
4
5
  import { safePath } from './sandbox.js';
5
6
  import { createGitTools } from './gitTools.js';
6
7
  import { createWebTools } from './web.js';
@@ -9,7 +10,7 @@ import { createExtraTools } from './extraTools.js';
9
10
  // user-facing. They must be in ENGLISH (like Claude Code / Codex). Only the
10
11
  // strings the OPERATOR sees (help, spinner, service messages) are localized —
11
12
  // those live in src/i18n.ts (CATALOG) and are picked by the locale setting.
12
- export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
13
+ export function createTools(workdir, { undo, todos, readOnly = false, subagents = false, } = {}) {
13
14
  const root = path.resolve(workdir);
14
15
  const safe = (p) => safePath(root, p);
15
16
  // Extracts text from content/content_base64. base64 is needed because the
@@ -26,6 +27,17 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
26
27
  // that omits `path` (or sends it as null) becomes String(undefined) ===
27
28
  // "undefined" and the tool silently creates a file literally named
28
29
  // "undefined" in the working directory. `req()` rejects that up front.
30
+ // A NUL byte is the classic binary signal (text files essentially never
31
+ // contain one). We only scan the head: a huge archive must not be walked
32
+ // byte-by-byte just to decide it is not text.
33
+ const isBinaryBuffer = (buf) => {
34
+ const n = Math.min(buf.length, 8000);
35
+ for (let i = 0; i < n; i++) {
36
+ if (buf[i] === 0)
37
+ return true;
38
+ }
39
+ return false;
40
+ };
29
41
  const req = (v, name) => {
30
42
  if (v === undefined || v === null) {
31
43
  throw new Error(`Missing required argument: ${name}`);
@@ -42,7 +54,10 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
42
54
  description: 'Read a file. Optional: offset and limit (lines). ' +
43
55
  'numbered=true adds cat -n style line numbers (1-based) for reference ' +
44
56
  'only — do NOT copy the numbers into Edit old_string. ' +
45
- 'Lines longer than 2000 chars are truncated.',
57
+ 'Lines longer than 2000 chars are truncated. ' +
58
+ 'Binary files (images, archives) are not decoded: an image returns its ' +
59
+ 'path/size as a marker (the operator can paste it to attach it), and ' +
60
+ 'other binaries return a short hex header instead of mojibake.',
46
61
  parameters: {
47
62
  path: 'string',
48
63
  offset: 'number?',
@@ -51,7 +66,34 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
51
66
  },
52
67
  fn: async ({ path: p, offset, limit, numbered }) => {
53
68
  const file = safe(req(p, 'path'));
54
- const content = await fs.readFile(file, 'utf-8');
69
+ // Binary guard (N35): reading an image/archive as utf-8 produced
70
+ // mojibake the model tried to reason about. Detect a binary buffer
71
+ // BEFORE decoding and answer with useful metadata instead.
72
+ const buf = await fs.readFile(file);
73
+ if (isImageName(file)) {
74
+ return ('[image: ' +
75
+ p +
76
+ '] (' +
77
+ guessMime(file) +
78
+ ', ' +
79
+ formatSize(buf.length) +
80
+ ') — this is an image; its pixels cannot be read as text. To have ' +
81
+ 'the model SEE it, the operator can paste/drag the file into the ' +
82
+ 'chat (it becomes an [image#N] attachment).');
83
+ }
84
+ if (isBinaryBuffer(buf)) {
85
+ const head = buf.subarray(0, 16);
86
+ return ('(binary file: ' +
87
+ p +
88
+ ', ' +
89
+ formatSize(buf.length) +
90
+ ', ' +
91
+ guessMime(file) +
92
+ ')\nFirst bytes (hex): ' +
93
+ Buffer.from(head).toString('hex') +
94
+ '\nUse Bash (e.g. `file`, `xxd`, `unzip -l`) to inspect it.');
95
+ }
96
+ const content = buf.toString('utf-8');
55
97
  if (content === '')
56
98
  return '(file is empty)';
57
99
  const lines = content.split('\n');
@@ -126,13 +168,43 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
126
168
  {
127
169
  name: 'Bash',
128
170
  description: 'Run a shell command in the working directory (cmd.exe on Windows, sh on Linux/macOS). ' +
129
- 'Do not use for long-running processes (servers) — they will hit the timeout. ' +
130
171
  'Do not use for commands that require interactive input. ' +
172
+ 'For a long-running process (dev server, watch, long build) set ' +
173
+ 'run_in_background=true: it starts the command and returns an id ' +
174
+ 'immediately; poll its output with BashOutput and stop it with BashOutput kill. ' +
131
175
  'For git use the Git* tools. For the web use WebFetch / WebSearch.',
132
- parameters: { command: 'string', timeout: 'number?' },
133
- fn: async ({ command, timeout }, ctx) => {
134
- assertCommandInsideRoot(root, req(command, 'command'));
135
- return runShell(workdir, req(command, 'command'), timeout, ctx?.signal);
176
+ parameters: {
177
+ command: 'string',
178
+ timeout: 'number?',
179
+ run_in_background: 'boolean?',
180
+ },
181
+ fn: async ({ command, timeout, run_in_background }, ctx) => {
182
+ const cmd = req(command, 'command');
183
+ assertCommandInsideRoot(root, cmd);
184
+ if (run_in_background) {
185
+ const id = startBackground(workdir, cmd);
186
+ return ('Started in background with id ' +
187
+ id +
188
+ '. Poll it with BashOutput (id="' +
189
+ id +
190
+ '") and stop it with BashOutput (id="' +
191
+ id +
192
+ '", kill=true).');
193
+ }
194
+ return runShell(workdir, cmd, timeout, ctx?.signal);
195
+ },
196
+ },
197
+ {
198
+ name: 'BashOutput',
199
+ description: 'Read the accumulated stdout+stderr of a process started with Bash ' +
200
+ 'run_in_background=true, and optionally kill it. id — the id returned ' +
201
+ 'by Bash. kill=true terminates the process (and its children).',
202
+ parameters: { id: 'string', kill: 'boolean?' },
203
+ fn: async ({ id, kill }) => {
204
+ const pid = req(id, 'id');
205
+ if (kill)
206
+ return killBackground(pid);
207
+ return pollBackground(pid);
136
208
  },
137
209
  },
138
210
  {
@@ -200,11 +272,34 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
200
272
  parameters: { message: 'string' },
201
273
  fn: async ({ message }) => message,
202
274
  };
275
+ // `Task` is a SEAM handled by the agent loop (onSubagent), not an ordinary
276
+ // fn. Its body here is only a fallback for a direct unit-test call; the live
277
+ // path never reaches it because agent-loop intercepts the name first.
278
+ const taskTool = {
279
+ name: 'Task',
280
+ description: 'Delegate a self-contained sub-task to a SUBAGENT that works in its own ' +
281
+ 'separate chat with an isolated context, then returns ONLY a final ' +
282
+ 'report. Use it to keep heavy research out of the main context. ' +
283
+ 'subagent_type: "explore" (read-only: search/read, cannot modify) or ' +
284
+ '"general" (full tools). The subagent does NOT see this conversation, so ' +
285
+ '`prompt` must be fully self-contained: state the goal, the exact ' +
286
+ 'questions, and any file paths/context it needs. Returns the subagent ' +
287
+ 'report as text.',
288
+ parameters: {
289
+ description: 'string',
290
+ prompt: 'string',
291
+ subagent_type: 'string?',
292
+ },
293
+ fn: async () => 'Subagents are not available in this run. Do the work yourself.',
294
+ };
295
+ // In plan (read-only) mode `Task` is dropped too: a `general` subagent could
296
+ // otherwise write through it and defeat the read-only promise.
203
297
  const all = [
204
298
  ...baseTools,
205
299
  ...extraTools,
206
300
  ...gitTools,
207
301
  ...webTools,
302
+ ...(subagents && !readOnly ? [taskTool] : []),
208
303
  respondTool,
209
304
  ];
210
305
  return readOnly ? filterToolsForReadOnly(all) : all;
@@ -220,6 +315,7 @@ export const MUTATING_TOOLS = new Set([
220
315
  'MultiEdit',
221
316
  'ApplyPatch',
222
317
  'Bash',
318
+ 'BashOutput',
223
319
  'GitAdd',
224
320
  'GitCommit',
225
321
  'GitPush',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.70.0",
3
+ "version": "2.72.0",
4
4
  "description": "Terminal coding agent that drives chat.deepseek.com through Playwright — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,21 +17,23 @@
17
17
  "scripts": {
18
18
  "start": "node ./dist/index.js",
19
19
  "dev": "ZAMES_NET_DEBUG=1 tsx ./src/index.ts --dev --headed",
20
- "clean": "node scripts/clean-dist.mjs",
21
- "build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
22
- "coverage:gate": "node scripts/coverage-gate.mjs",
20
+ "clean": "tsx scripts/clean-dist.mts",
21
+ "build": "tsx scripts/clean-dist.mts && tsc -p tsconfig.build.json",
22
+ "coverage:gate": "tsx scripts/coverage-gate.mts",
23
23
  "changelog:draft": "tsx scripts/changelog-draft.mts",
24
+ "changelog:sync": "tsx scripts/changelog-draft.mts --write",
25
+ "changelog:check": "tsx scripts/changelog-draft.mts --check",
24
26
  "release": "tsx scripts/release.mts",
25
27
  "typecheck": "tsc --noEmit",
26
- "lint": "node scripts/eslint-ts6.mjs",
27
- "lint:fix": "node scripts/eslint-ts6.mjs --fix",
28
+ "lint": "tsx scripts/eslint-ts6.mts",
29
+ "lint:fix": "tsx scripts/eslint-ts6.mts --fix",
28
30
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"*.{js,json,md}\"",
29
31
  "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
30
32
  "prepublishOnly": "npm run build:readme && npm run build",
31
33
  "test": "tsx --test test/*.test.ts",
32
34
  "test:watch": "tsx --test --watch test/*.test.ts",
33
- "self-smoke": "tsx scripts/self-smoke.mjs",
34
- "render-demo": "node scripts/render-demo.mjs",
35
+ "self-smoke": "tsx scripts/self-smoke.mts",
36
+ "render-demo": "tsx scripts/render-demo.mts",
35
37
  "build:readme": "tsx scripts/build-readme.mts",
36
38
  "postinstall": "node scripts/postinstall.mjs",
37
39
  "prepare": "husky"
@@ -1,8 +1,12 @@
1
1
  import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
2
5
  import {
3
6
  parseConventionalCommit,
4
7
  groupCommits,
5
8
  renderChangelogDraft,
9
+ syncUnreleased,
6
10
  } from '../src/changelog.js'
7
11
 
8
12
  // Draft a CHANGELOG section from conventional commits since a ref (BACKLOG
@@ -13,6 +17,8 @@ import {
13
17
  // Usage:
14
18
  // npm run changelog:draft # since the latest tag, or an explicit ref
15
19
  // npm run changelog:draft -- v2.63.0
20
+ // npm run changelog:draft -- --write # update the Unreleased section in place
21
+ // npm run changelog:draft -- --check # exit 1 if Unreleased is out of date
16
22
 
17
23
  function git(args: string[]): string {
18
24
  const r = spawnSync('git', args, { encoding: 'utf-8' })
@@ -20,7 +26,10 @@ function git(args: string[]): string {
20
26
  return (r.stdout || '').toString()
21
27
  }
22
28
 
23
- const argRef = process.argv.slice(2).find((a) => !a.startsWith('-'))
29
+ const argv = process.argv.slice(2)
30
+ const write = argv.includes('--write')
31
+ const check = argv.includes('--check')
32
+ const argRef = argv.find((a) => !a.startsWith('-'))
24
33
 
25
34
  // Resolve the start ref: an explicit arg, otherwise the latest tag,
26
35
  // otherwise the root commit.
@@ -46,6 +55,34 @@ const parsed = subjects
46
55
  .filter((c): c is NonNullable<typeof c> => c !== null)
47
56
  const body = renderChangelogDraft(groupCommits(parsed))
48
57
 
58
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
59
+ const changelogPath = path.join(root, 'CHANGELOG.md')
60
+
61
+ // D2: sync the `## [Unreleased]` section in CHANGELOG.md from the conventional
62
+ // commits since the last tag. `--write` edits the file; `--check` fails when
63
+ // the committed section is out of date (for CI); neither just prints the body.
64
+ if (write || check) {
65
+ const onDisk = fs.readFileSync(changelogPath, 'utf-8')
66
+ const nextText = syncUnreleased(onDisk, body)
67
+ if (write) {
68
+ if (nextText !== onDisk) {
69
+ fs.writeFileSync(changelogPath, nextText)
70
+ console.log('changelog: updated the Unreleased section')
71
+ } else {
72
+ console.log('changelog: Unreleased already up to date')
73
+ }
74
+ process.exit(0)
75
+ }
76
+ if (nextText !== onDisk) {
77
+ console.error(
78
+ 'changelog: the Unreleased section is out of date — run `npm run changelog:sync`',
79
+ )
80
+ process.exit(1)
81
+ }
82
+ console.log('changelog: Unreleased is up to date')
83
+ process.exit(0)
84
+ }
85
+
49
86
  if (!body) {
50
87
  console.error('no conventional commits since ' + ref)
51
88
  process.exit(0)
@@ -11,6 +11,12 @@ import Module from 'module'
11
11
  import path from 'path'
12
12
  import { fileURLToPath } from 'url'
13
13
 
14
+ // Module._resolveFilename is a private Node field (not in @types/node), so it
15
+ // is reached through a small structural type instead of `any`.
16
+ type ModuleWithResolve = typeof Module & {
17
+ _resolveFilename: (request: string, ...rest: unknown[]) => string
18
+ }
19
+
14
20
  const require = createRequire(import.meta.url)
15
21
  const here = path.dirname(fileURLToPath(import.meta.url))
16
22
  const ts6 = path.join(here, '..', 'node_modules', 'typescript-6')
@@ -26,16 +32,17 @@ try {
26
32
 
27
33
  // Patch Module._resolveFilename so any require('typescript') resolves to the
28
34
  // TS 6 package instead of the TS 7 the project depends on.
29
- const origResolve = Module._resolveFilename
30
- Module._resolveFilename = function (request, ...rest) {
35
+ const M = Module as ModuleWithResolve
36
+ const origResolve = M._resolveFilename
37
+ M._resolveFilename = (request: string, ...rest: unknown[]) => {
31
38
  if (request === 'typescript') {
32
39
  try {
33
40
  return require.resolve(path.join(ts6, 'lib', 'typescript.js'))
34
41
  } catch {
35
- return origResolve.call(this, request, ...rest)
42
+ return origResolve(request, ...rest)
36
43
  }
37
44
  }
38
- return origResolve.call(this, request, ...rest)
45
+ return origResolve(request, ...rest)
39
46
  }
40
47
 
41
48
  const { ESLint } = await import('eslint')
@@ -23,7 +23,9 @@ import {
23
23
  // npm run release patch --dry-run # print the plan, change nothing
24
24
  //
25
25
  // Run via tsx so it can reuse src/changelog.ts (the conventional-commit
26
- // parser) — a plain .mjs cannot import the TypeScript sources.
26
+ // parser) — a plain .mjs cannot import the TypeScript sources. The other
27
+ // helper scripts are .mts for the same reason; postinstall stays .mjs because
28
+ // it runs before tsx is guaranteed to be installed.
27
29
 
28
30
  const NL = String.fromCharCode(10)
29
31
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
@@ -43,7 +45,11 @@ function fail(msg: string): never {
43
45
  }
44
46
 
45
47
  function run(cmd: string, args: string[]): void {
46
- const r = spawnSync(cmd, args, { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' })
48
+ const r = spawnSync(cmd, args, {
49
+ cwd: root,
50
+ stdio: 'inherit',
51
+ shell: process.platform === 'win32',
52
+ })
47
53
  if (r.status !== 0) fail(cmd + ' ' + args.join(' ') + ' exited ' + r.status)
48
54
  }
49
55
 
@@ -100,8 +106,16 @@ if (git(['tag', '--list', tag]).trim()) {
100
106
  // last tag. English commit subjects become the section body; the operator can
101
107
  // still reword the section afterwards.
102
108
  const latestTag = git(['describe', '--tags', '--abbrev=0'], true).trim()
103
- const ref = latestTag || git(['rev-list', '--max-parents=0', 'HEAD']).trim().split(NL).pop() || 'HEAD'
104
- const subjects = git(['log', '--no-merges', '--pretty=format:%s', ref + '..HEAD'])
109
+ const ref =
110
+ latestTag ||
111
+ git(['rev-list', '--max-parents=0', 'HEAD']).trim().split(NL).pop() ||
112
+ 'HEAD'
113
+ const subjects = git([
114
+ 'log',
115
+ '--no-merges',
116
+ '--pretty=format:%s',
117
+ ref + '..HEAD',
118
+ ])
105
119
  .split(NL)
106
120
  .map((s) => s.trim())
107
121
  .filter(Boolean)
@@ -129,7 +143,14 @@ const sectionBody = body
129
143
  .join(NL)
130
144
  const date = new Date().toISOString().slice(0, 10)
131
145
  const section =
132
- '## [' + next + '] - ' + date + NL + NL + (sectionBody || '### Changed' + NL + NL + '- Internal improvements.') + NL
146
+ '## [' +
147
+ next +
148
+ '] - ' +
149
+ date +
150
+ NL +
151
+ NL +
152
+ (sectionBody || '### Changed' + NL + NL + '- Internal improvements.') +
153
+ NL
133
154
 
134
155
  if (dryRun) {
135
156
  console.log('release(dry-run): ' + pkg.version + ' -> ' + next)
@@ -148,14 +169,22 @@ if (idx < 0) fail('CHANGELOG.md has no "' + anchor + '" heading')
148
169
  const afterAnchor = idx + anchor.length
149
170
  const updated =
150
171
  changelog.slice(0, afterAnchor) +
151
- NL + NL + section +
172
+ NL +
173
+ NL +
174
+ section +
152
175
  changelog.slice(afterAnchor).replace(/^\n+/, NL)
153
176
  fs.writeFileSync(changelogPath, updated)
154
177
 
155
178
  // 6. Bump package.json, preserving its exact formatting (single-line replace
156
179
  // of the version value only).
157
180
  const pkgText = fs.readFileSync(pkgPath, 'utf-8')
158
- fs.writeFileSync(pkgPath, pkgText.replace('"version": "' + pkg.version + '"', '"version": "' + next + '"'))
181
+ fs.writeFileSync(
182
+ pkgPath,
183
+ pkgText.replace(
184
+ '"version": "' + pkg.version + '"',
185
+ '"version": "' + next + '"',
186
+ ),
187
+ )
159
188
 
160
189
  // 7. Commit + tag. The push is intentionally NOT done here.
161
190
  git(['add', 'package.json', 'CHANGELOG.md'])