zames_pro 2.60.0 → 2.63.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.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,96 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [2.63.0] - 2026-10-06
9
+
10
+ ### Added
11
+
12
+ - Реальные подтверждения действий (BACKLOG C1): политика approval в
13
+ `.zames/permissions.json` (`default` + `rules` с regexp по `tool`/`command`/
14
+ `path` и действием `allow`|`deny`|`ask`). Вызывается в `agent-loop.ts` ПЕРЕД
15
+ каждым инструментом: `deny` блокирует вызов, `ask` спрашивает оператора через
16
+ `onAskPermission` (в TTY — интерактивный промпт над инпут-линией, в non-TTY —
17
+ запрет). Новый чистый модуль `src/permissions.ts` + тесты
18
+ (test/permissions.test.ts, test/permissions-loop.test.ts).
19
+
20
+ - Кастомные команды: аргументы и подсказки (BACKLOG B7). Frontmatter
21
+ `argument-hint:` показывается в списке «/» и в `/help` (не вставляется в
22
+ строку ввода), а `arguments:` объявляет обязательные позиционные аргументы.
23
+ В теле команды подставляются `$1 $2`, именованные `$name`, а также прежние
24
+ `{{args}}`/`$ARGUMENTS`. Если обязательные аргументы не переданы — команда
25
+ не уходит в чат, печатается подсказка. Новые чистые хелперы
26
+ `splitCommandArgs()` / `expandCommandArgs()` / `missingCommandArgs()`
27
+ (src/commands.ts, покрыты тестами).
28
+
29
+ - Path-scoped правила (BACKLOG B6): вложенные `AGENTS.md`/`MEMORY.md` из
30
+ подпапок, которых касается задача, подтягиваются автоматически. Текст задачи
31
+ сканируется на path-токены, для найденных директорий (и их предков ниже
32
+ рабочей) читаются ближайшие инструкции и рендерятся отдельной секцией
33
+ `## Scoped instructions (...)` — явно помечены как действующие только для
34
+ этих файлов. `loadProjectContext(workdir, touchPaths?)` и
35
+ `renderContextSection()` (src/context.ts, src/system-prompt.ts; покрыто
36
+ тестами).
37
+
38
+ - `@file`-ссылки в задаче (BACKLOG B5): `реши задачу @src/browser.ts`
39
+ подставляет содержимое указанного файла прямо в задачу, экономя отдельный
40
+ ход агента на чтение. Распознаётся `@path` на границе слова (в начале строки
41
+ или после пробела/скобки/кавычки), с расширением файла; `user@host` и
42
+ декораторы (`@Component`) не трогаются. Существующие файлы инлайнятся
43
+ (лимит 60 КБ на файл, 200 КБ суммарно — сверх этого усечение с пометкой),
44
+ несуществующие остаются как есть. Хелпер `extractAtFileRefs()` в
45
+ `src/path-token.ts` (чистый, покрыт тестами).
46
+
47
+ ### Changed
48
+
49
+ - `AGENTS.md` уменьшен (BACKLOG C2): глубокие root-cause разборы («агент
50
+ остановился», чтение ответа из DOM, send-хуки, `LineEditor`, вложения) и
51
+ терминальная механика вынесены в `docs/DESIGN-NOTES.md` (progressive
52
+ disclosure — не грузится в каждую задачу). В AGENTS.md остались действующие
53
+ правила и краткая выжимка со ссылкой.
54
+
55
+ ## [2.62.0]
56
+
57
+ ### Added
58
+
59
+ - Checkpoints / rewind (BACKLOG B3): at the start of every task the working
60
+ tree is snapshotted into a tarball under `~/.zames/checkpoints/`, and
61
+ `/rewind [n]` rolls it back in one step (with a confirmation prompt and an
62
+ automatic `pre-rewind` backup of the current state, so the rewind itself is
63
+ reversible). `/rewind-list` shows the recent checkpoints. `node_modules`,
64
+ `.git`, `dist` and `tmp` are never snapshotted or deleted. Unlike a manual
65
+ git stash, this works in a non-git directory and never touches the
66
+ operator's index. New module `src/checkpoint.ts` (pure helpers + `CheckpointStore`,
67
+ unit-tested); configurable via `checkpoint.enabled` / `checkpoint.maxBackups`
68
+ (default 50) in `/config`.
69
+
70
+ ## [2.61.0]
71
+
72
+ ### Added
73
+
74
+ - `/backlog <text>` — record an improvement idea in `BACKLOG.md` without
75
+ implementing it. Deterministic: a fresh `N<n>` id under the matching
76
+ `P0..P3` section; an optional leading `P0..P3` picks the section, the first
77
+ line is the title, the rest the body. `/backlog collapse` prunes the
78
+ archived blocks.
79
+ - BACKLOG.md maintenance (`src/backlog.ts`, pure/tested): `/improve` now
80
+ auto-prunes a finished item's archived `<details>` copy after a successful
81
+ run (the `### X. [x] ... done` summary line is kept), and a startup warning
82
+ fires when BACKLOG.md exceeds 500 lines / 60 KB. The collapser tolerates an
83
+ UNCLOSED `<details>` (a real file had one).
84
+ - Dev-mode self-improvement note: in `--dev` the system prompt tells the model
85
+ it may append ONE short BACKLOG.md bullet when it spots an improvement
86
+ outside the current task (off in a normal run).
87
+
88
+ ### Changed
89
+
90
+ - Self-development commands (`/improve`, `/backlog`, `/self-review`,
91
+ `/self-fix`, `/self-done`, `/self-list`, `/self-diff`, `/self-apply`) are now
92
+ DEV-ONLY: they are hidden from `/help` and the «/» hints, and rejected by the
93
+ main loop, unless the operator runs in dev mode (`--dev` or
94
+ `config.hotReload`). A regular package install no longer advertises them, and
95
+ a hand-typed `/improve` can no longer edit an unrelated project's
96
+ BACKLOG.md. The list is `DEV_ONLY_COMMANDS` / `isDevOnlyCommand()` in
97
+ `src/commands.ts` (pure, tested).
9
98
 
10
99
  ## [2.60.0]
11
100
 
@@ -220,7 +309,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
220
309
  - Session banner warns when no saved DeepSeek session exists.
221
310
  - `--no-color` flag (explicit `NO_COLOR`).
222
311
 
223
- [Unreleased]: https://github.com/Viqto0r/zames_pro/compare/v2.60.0...HEAD
312
+ [Unreleased]: https://github.com/Viqto0r/zames_pro/compare/v2.63.0...HEAD
313
+ [2.63.0]: https://github.com/Viqto0r/zames_pro/compare/v2.61.0...v2.63.0
314
+ [2.61.0]: https://github.com/Viqto0r/zames_pro/compare/v2.60.0...v2.61.0
224
315
  [2.60.0]: https://github.com/Viqto0r/zames_pro/compare/v2.59.0...v2.60.0
225
316
  [2.59.0]: https://github.com/Viqto0r/zames_pro/compare/v2.58.0...v2.59.0
226
317
  [2.58.0]: https://github.com/Viqto0r/zames_pro/compare/v2.57.1...v2.58.0
package/README.md CHANGED
@@ -1,13 +1,20 @@
1
- # zames_pro
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/logo-small.jpg" alt="zames logo" width="180">
3
+ </p>
2
4
 
3
- [![npm version](https://img.shields.io/npm/v/zames_pro.svg)](https://www.npmjs.com/package/zames_pro)
4
- [![npm downloads](https://img.shields.io/npm/dm/zames_pro.svg)](https://www.npmjs.com/package/zames_pro)
5
- [![tests](https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml/badge.svg)](https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
- [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](package.json)
8
- [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
5
+ <h1 align="center">zames_pro</h1>
9
6
 
10
- ![zames logo](https://raw.githubusercontent.com/Viqto0r/zames_pro/master/logo.jpg)
7
+ <p align="center">
8
+ <strong>A terminal coding agent that drives chat.deepseek.com through Playwright — no API key required.</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/zames_pro"><img src="https://img.shields.io/npm/v/zames_pro.svg" alt="npm version"></a>
13
+ <a href="https://www.npmjs.com/package/zames_pro"><img src="https://img.shields.io/npm/dm/zames_pro.svg" alt="npm downloads"></a>
14
+ <a href="https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml"><img src="https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml/badge.svg" alt="tests"></a>
15
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
16
+ <a href="package.json"><img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg" alt="Node.js"></a>
17
+ </p>
11
18
 
12
19
  A terminal coding agent that works on top of [chat.deepseek.com](https://chat.deepseek.com/) through Playwright.
13
20
  In spirit it is similar to Claude Code / Codex CLI: it starts in the current
@@ -16,6 +23,44 @@ directory, reads and edits files, runs commands, and commits to git.
16
23
  > No API key required — it drives the DeepSeek web chat like a regular user
17
24
  > through a real (headless) browser.
18
25
 
26
+ ## How it works
27
+
28
+ zames does not call the model API. It launches a headless Chromium with a
29
+ persistent profile, signs in to `chat.deepseek.com` like a human, types the task
30
+ into the chat box, and reads the answer back.
31
+
32
+ ```
33
+ terminal (you) zames chat.deepseek.com
34
+ ─────────────── ───── ─────────────────
35
+ task ──────────────────────▶ system prompt + task ────────▶ browser types it
36
+ ▲ │
37
+ │ model answers
38
+ │ │
39
+ answer ◀──── render ◀───── parse tool-call ◀──── raw answer ◀────┘
40
+ │
41
+ ▼
42
+ run tool (Read/Edit/Bash/…) → feed result back
43
+ ```
44
+
45
+ Key pieces:
46
+
47
+ - **The answer is read from the raw network stream** (SSE), not the rendered
48
+ DOM, so tool-call JSON with template strings and escapes survives intact.
49
+ - **A persistent profile** (`~/.zames/profile`) keeps you signed in; the
50
+ headless User-Agent is patched so DeepSeek's CDN does not 403 the login.
51
+ - **A send throttle** (15 s by default) keeps the web chat's rate limit happy
52
+ during long tool-heavy runs.
53
+ - **The agent is sandboxed** to the directory it was started in — no tool can
54
+ read or write above it.
55
+
56
+ ## Table of contents
57
+
58
+ - [Features](#features) · [Why zames?](#why-zames) · [Requirements](#requirements)
59
+ - [How it works](#how-it-works) · [Installation](#installation) · [Signing in](#signing-in) · [Usage](#usage)
60
+ - [Tools](#tools) · [Slash commands](#slash-commands)
61
+ - [Project context, skills and memory](#project-context-skills-and-memory) · [MCP (external tools)](#mcp-external-tools) · [Configuration](#configuration)
62
+ - [FAQ](#faq) · [Links](#links) · [License](#license)
63
+
19
64
  ## Features
20
65
 
21
66
  - **Tools like Claude Code / Codex** — `Read`, `Write`, `Edit`, `Bash`,
@@ -29,8 +74,6 @@ directory, reads and edits files, runs commands, and commits to git.
29
74
  custom commands from the repo and `~/.zames`, the same idea as Codex / Claude
30
75
  Code.
31
76
  - **MCP support** — plug in external tool servers (e.g. `@playwright/mcp`).
32
- - **Self-review** — `/self-review` snapshots `src/` so the agent can review and
33
- fix itself in a sandbox (`/self-fix`, `/self-apply`).
34
77
  - **Scheduling** — `/loop`, `/cron` and `/jobs` repeat tasks on a timer.
35
78
  - **Bilingual UI** — Russian / English (`/config lang`).
36
79
 
@@ -47,7 +90,7 @@ directory, reads and edits files, runs commands, and commits to git.
47
90
 
48
91
  ## Requirements
49
92
 
50
- - Node.js >= 20 (CI and development use Node 24; see `.nvmrc`)
93
+ - Node.js >= 20
51
94
  - A DeepSeek account. On first launch zames asks for your DeepSeek
52
95
  login/password in the terminal (and stores them in `~/.zames/config.json`
53
96
  after a successful sign-in, so a later logout is handled automatically
@@ -85,13 +128,6 @@ version). You do not need `--headed` just to log in.
85
128
  Credentials and toggles can also be edited from `/config`
86
129
  (`browser.auth.username`, `browser.auth.password`, `browser.auth.saveSession`).
87
130
 
88
- ## Links
89
-
90
- - npm: <https://www.npmjs.com/package/zames_pro>
91
- - Changelog: [`CHANGELOG.md`](CHANGELOG.md)
92
- - Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md)
93
- - Security policy: [`SECURITY.md`](SECURITY.md)
94
-
95
131
  ## Installation
96
132
 
97
133
  ```bash
@@ -247,10 +283,6 @@ Codex CLI:
247
283
  instead of the whole list.
248
284
  - /review [focus] [--staged] — ask the agent to review uncommitted changes
249
285
  and report findings (no code changes).
250
- - /improve [id] — self-improvement loop: take the next open item from
251
- `BACKLOG.md` (or a specific id, e.g. `/improve B3`), implement it, run the
252
- typecheck/lint/tests, mark it done and add a CHANGELOG entry. Nothing is
253
- committed — the changes stay in the working tree for review.
254
286
  - /plan [on|off] — plan (read-only) mode. While it is on, the mutating tools
255
287
  (Write/Edit/MultiEdit/ApplyPatch/Bash, GitAdd/GitCommit/GitPush) are removed
256
288
  from the tool set, so the agent can investigate without touching the tree.
@@ -384,7 +416,7 @@ Changes are written to the project `.zamesrc.json` and applied right away
384
416
  (help, messages, spinner) and the language the agent answers you in. The
385
417
  locale lives in `ui.locale` in the config file.
386
418
 
387
- Agent data is stored in `~/.zames`: browser profile, logs, undo history, self-review snapshots.
419
+ Agent data is stored in `~/.zames`: browser profile, logs, undo history, sessions.
388
420
 
389
421
  ## FAQ
390
422
 
@@ -414,6 +446,15 @@ Same shape (tools, `AGENTS.md`, skills, MCP, slash commands) but it runs on your
414
446
  DeepSeek account instead of an API, as a browser automation rather than a
415
447
  first-party API client.
416
448
 
449
+ ## Links
450
+
451
+ - **npm:** <https://www.npmjs.com/package/zames_pro>
452
+ - **GitHub:** <https://github.com/Viqto0r/zames_pro>
453
+ - **Changelog:** [`CHANGELOG.md`](CHANGELOG.md)
454
+ - **Contributing:** [`CONTRIBUTING.md`](CONTRIBUTING.md)
455
+ - **Security policy:** [`SECURITY.md`](SECURITY.md)
456
+ - **Code of conduct:** [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)
457
+
417
458
  ## License
418
459
 
419
460
  MIT
@@ -6,12 +6,15 @@ import { translate } from './i18n.js';
6
6
  import { substituteAttachmentMarkers } from './commands.js';
7
7
  import { normText } from './browser.js';
8
8
  import { loadHooks, runPreToolUse, runPostToolUse, } from './hooks.js';
9
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, }) {
9
+ import { loadPermissions, decidePermission, } from './permissions.js';
10
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, onAskPermission = undefined, selfImprovement = false, }) {
10
11
  // Resolve the hook config ONCE per task: a read per tool call would be
11
12
  // wasteful, and a mid-task edit of hooks.json is not something to chase.
12
13
  // `undefined` means "read .zames/hooks.json"; an explicit null disables
13
14
  // hooks entirely.
14
15
  const hookConfig = hooks === undefined ? loadHooks(workdir) : (hooks ?? {});
16
+ // Same for the approval policy (C1): read `.zames/permissions.json` once.
17
+ const permissionPolicy = permissions === undefined ? loadPermissions(workdir) : permissions;
15
18
  // UI callbacks must NEVER break the agent loop. A rendering error (a huge
16
19
  // tool result, a broken markdown frame, a closed terminal) used to throw
17
20
  // out of the loop right after a tool call — the session looked "stopped
@@ -91,7 +94,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
91
94
  }
92
95
  let context;
93
96
  try {
94
- context = await loadProjectContext(workdir);
97
+ // Pass the task text so nested AGENTS.md/MEMORY.md for the directories
98
+ // this task actually touches are pulled in (B6, path-scoped rules).
99
+ context = await loadProjectContext(workdir, [task]);
95
100
  }
96
101
  catch {
97
102
  context = null;
@@ -102,11 +107,13 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
102
107
  gitContext: gitText,
103
108
  locale,
104
109
  context,
110
+ selfImprovement,
105
111
  });
106
112
  transcript?.log('system_prompt', {
107
113
  length: systemPrompt.length,
108
114
  gitContext: gitText,
109
115
  agents: context?.agents.map((f) => f.path) ?? [],
116
+ scopedAgents: context?.scopedAgents?.map((f) => f.path) ?? [],
110
117
  memory: context?.memory.map((f) => f.path) ?? [],
111
118
  skills: context?.skills.map((s) => s.name) ?? [],
112
119
  commands: context?.commands.map((c) => c.name) ?? [],
@@ -726,6 +733,47 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
726
733
  results.push({ tool: call.tool, result: blocked });
727
734
  continue;
728
735
  }
736
+ // Approval policy (C1): a `deny` rule blocks the call (like a PreToolUse
737
+ // denial); an `ask` rule prompts the operator. Hooks stay authoritative
738
+ // for programmatic guards — this is the human-in-the-loop layer.
739
+ const decision = decidePermission(permissionPolicy, call.tool, call.args);
740
+ if (decision.action === 'deny') {
741
+ const blocked = `Blocked by permission policy: ${decision.reason}`;
742
+ transcript?.log('permission_deny', {
743
+ tool: call.tool,
744
+ reason: decision.reason,
745
+ });
746
+ safeToolResult(blocked);
747
+ transcript?.log('tool_result', {
748
+ tool: call.tool,
749
+ result: blocked,
750
+ });
751
+ results.push({ tool: call.tool, result: blocked });
752
+ continue;
753
+ }
754
+ if (decision.action === 'ask' && onAskPermission) {
755
+ let allowed;
756
+ try {
757
+ allowed = await onAskPermission({ ...decision, tool: call.tool });
758
+ }
759
+ catch {
760
+ allowed = false;
761
+ }
762
+ if (!allowed) {
763
+ const blocked = `Denied by operator: ${decision.reason}`;
764
+ transcript?.log('permission_denied', {
765
+ tool: call.tool,
766
+ reason: decision.reason,
767
+ });
768
+ safeToolResult(blocked);
769
+ transcript?.log('tool_result', {
770
+ tool: call.tool,
771
+ result: blocked,
772
+ });
773
+ results.push({ tool: call.tool, result: blocked });
774
+ continue;
775
+ }
776
+ }
729
777
  let result;
730
778
  // While the tool runs, poll for an Esc/Ctrl+C: the abort flag is a plain
731
779
  // boolean set by stopGeneration(), so the only way to turn it into a
@@ -734,6 +782,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
734
782
  const poll = setInterval(syncToolAbort, 100);
735
783
  if (typeof poll.unref === 'function')
736
784
  poll.unref();
785
+ // Time the tool (T-D3): the transcript carries durationMs so /cost can
786
+ // show where the time goes (frequent Read→Edit cycles vs slow Bash).
787
+ const toolStart = Date.now();
737
788
  try {
738
789
  result = await tool.fn(call.args, { signal: toolAbort.signal });
739
790
  }
@@ -743,6 +794,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
743
794
  finally {
744
795
  clearInterval(poll);
745
796
  }
797
+ const toolMs = Date.now() - toolStart;
746
798
  // PostToolUse hooks run AFTER the tool; their stdout is appended to the
747
799
  // result (e.g. `prettier` output) before it is fed back to the model.
748
800
  // Best-effort: a hook failure is ignored, the tool result still stands.
@@ -758,6 +810,7 @@ ${post}`;
758
810
  transcript?.log('tool_result', {
759
811
  tool: call.tool,
760
812
  result: String(result),
813
+ durationMs: toolMs,
761
814
  });
762
815
  results.push({ tool: call.tool, result });
763
816
  // The operator pressed Esc/Ctrl+C while the tool was running. The tool
@@ -0,0 +1,166 @@
1
+ import { parseBacklogItems } from './commands.js';
2
+ // BACKLOG.md maintenance — the pure half of /backlog and the automatic pruning
3
+ // that runs after /improve. BACKLOG.md is the agent's own improvement-notes
4
+ // file (gitignored); it is READ by /improve, so keeping it small matters —
5
+ // every finished item used to keep a full copy of its original text inside a
6
+ // <details> block, and the file grew without bound.
7
+ //
8
+ // Everything here is pure and unit-tested; index.ts only reads/writes the file.
9
+ const NL = String.fromCharCode(10);
10
+ // Agent-appended items use this id prefix. A distinct letter keeps them apart
11
+ // from the hand-numbered A/B/C/D/E entries, so it is obvious which notes the
12
+ // agent added on its own.
13
+ const NEW_ITEM_PREFIX = 'N';
14
+ // Past these thresholds BACKLOG.md costs more tokens (on /improve) than it is
15
+ // worth, so the operator is nudged to collapse it.
16
+ export const BACKLOG_WARN_LINES = 500;
17
+ export const BACKLOG_WARN_CHARS = 60_000;
18
+ function squashBlankLines(lines) {
19
+ const out = [];
20
+ let blanks = 0;
21
+ for (const line of lines) {
22
+ if (line.trim() === '') {
23
+ blanks++;
24
+ if (blanks > 2)
25
+ continue;
26
+ }
27
+ else {
28
+ blanks = 0;
29
+ }
30
+ out.push(line);
31
+ }
32
+ return out;
33
+ }
34
+ // A real, kept heading: a `## P<n>` section or a `### X<n>.` item heading that
35
+ // is NOT the archived copy (archived copies carry `~~` right after the id).
36
+ function isRealBoundary(line) {
37
+ const t = line.trim();
38
+ if (/^##\s/.test(t))
39
+ return true;
40
+ return /^###\s+[A-Z]\d+[.]/.test(t) && !/^###\s+[A-Z]\d+[.]\s+~~/.test(t);
41
+ }
42
+ function isDetailsOpen(line) {
43
+ return line.trim().startsWith('<details');
44
+ }
45
+ function isArchivedHeading(line) {
46
+ return /^###\s+[A-Z]\d+[.]\s+~~/.test(line.trim());
47
+ }
48
+ /**
49
+ * Remove archived copies of finished items. A finished item keeps its
50
+ * `### X. [x] ... done` summary line; the `### X. ~~original~~` copy and its
51
+ * body (normally wrapped in a <details> block) are dropped.
52
+ *
53
+ * Deliberately tolerant of an UNCLOSED <details>: a real backlog item was
54
+ * written as `### A1. [x] ...` then `<details>...` with no `</details>`, so the
55
+ * whole file became one nested block. Keying the end of a block on the next
56
+ * REAL heading (a `### X.` without `~~`, or a `## ` section) — not only on
57
+ * `</details>` — keeps the summary lines and the open items even then. Pure and
58
+ * unit-tested; a heading that looks like an item INSIDE an archive is skipped
59
+ * because it carries `~~`.
60
+ */
61
+ export function collapseBacklog(text) {
62
+ const src = String(text ?? '');
63
+ const lines = src.split(NL);
64
+ const out = [];
65
+ let collapsed = 0;
66
+ let i = 0;
67
+ while (i < lines.length) {
68
+ if (isDetailsOpen(lines[i]) || isArchivedHeading(lines[i])) {
69
+ // Skip to the end of the archive: the first `</details>` OR the first
70
+ // real heading, whichever comes first (see the note above).
71
+ let j = i + 1;
72
+ while (j < lines.length) {
73
+ const t = lines[j].trim();
74
+ if (t.startsWith('</details>')) {
75
+ j++;
76
+ break;
77
+ }
78
+ if (isRealBoundary(t))
79
+ break;
80
+ j++;
81
+ }
82
+ collapsed++;
83
+ i = j;
84
+ continue;
85
+ }
86
+ out.push(lines[i]);
87
+ i++;
88
+ }
89
+ const next = squashBlankLines(out).join(NL);
90
+ return { text: next, changed: next !== src, collapsed };
91
+ }
92
+ export function backlogStats(text) {
93
+ const src = String(text ?? '');
94
+ const items = parseBacklogItems(src);
95
+ let archived = 0;
96
+ for (const line of src.split(NL)) {
97
+ if (line.trim().startsWith('<details'))
98
+ archived++;
99
+ }
100
+ return {
101
+ lines: src.split(NL).length,
102
+ chars: src.length,
103
+ open: items.filter((i) => i.status === 'open').length,
104
+ done: items.filter((i) => i.status === 'done').length,
105
+ archived,
106
+ };
107
+ }
108
+ /** True when the file is big enough to deserve a collapse nudge. */
109
+ export function backlogNeedsPruning(stats) {
110
+ return stats.lines > BACKLOG_WARN_LINES || stats.chars > BACKLOG_WARN_CHARS;
111
+ }
112
+ /** The next free N<n> id not yet used in the file. */
113
+ export function nextBacklogId(text) {
114
+ let max = 0;
115
+ const re = /^###[ ]+([A-Z])([0-9]+)[.]/gm;
116
+ let m;
117
+ while ((m = re.exec(String(text ?? '')))) {
118
+ if (m[1] === NEW_ITEM_PREFIX)
119
+ max = Math.max(max, Number(m[2]));
120
+ }
121
+ return NEW_ITEM_PREFIX + (max + 1);
122
+ }
123
+ /**
124
+ * Insert a new OPEN item under the matching `## P<n>` section (or append a new
125
+ * section at the end). Returns the new file text and the generated id. Pure;
126
+ * the caller writes the file.
127
+ */
128
+ export function appendBacklogItem(text, item) {
129
+ const src = String(text ?? '');
130
+ const title = String(item.title ?? '')
131
+ .trim()
132
+ .replace(/ {2,}/g, ' ');
133
+ const prio = /^P[0-3]$/i.test(String(item.priority ?? '').trim())
134
+ ? String(item.priority).trim().toUpperCase()
135
+ : 'P2';
136
+ const note = String(item.note ?? '').trim();
137
+ const id = nextBacklogId(src);
138
+ const entry = ['### ' + id + '. ' + title, ''];
139
+ if (note) {
140
+ for (const l of note.split(NL))
141
+ entry.push(l);
142
+ entry.push('');
143
+ }
144
+ const lines = src.split(NL);
145
+ const headerRe = new RegExp('^##[ ]+' + prio + '(?![0-9])');
146
+ let at = -1;
147
+ for (let i = 0; i < lines.length; i++) {
148
+ if (headerRe.test(lines[i])) {
149
+ // Insert right after the header and any blank lines that follow it, so
150
+ // the newest note sits at the top of its priority block.
151
+ let j = i + 1;
152
+ while (j < lines.length && lines[j].trim() === '')
153
+ j++;
154
+ at = j;
155
+ break;
156
+ }
157
+ }
158
+ if (at === -1) {
159
+ while (lines.length && lines[lines.length - 1].trim() === '')
160
+ lines.pop();
161
+ const out = lines.concat(['', '## ' + prio, '', ...entry]).join(NL);
162
+ return { text: out, id };
163
+ }
164
+ const out = lines.slice(0, at).concat(entry, lines.slice(at)).join(NL);
165
+ return { text: out, id };
166
+ }