dev-memory-cli 0.22.2 → 0.23.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/README.md CHANGED
@@ -9,7 +9,7 @@ Dev Memory Skill Suite 为 Codex、Claude 等 coding agent 提供跨会话开发
9
9
  ## 核心能力
10
10
 
11
11
  - **会话恢复**:`SessionStart` 自动注入当前 repo + branch 的浓缩记忆和权威文件路径。
12
- - **语义化写入**:按 decisionriskglossary、source、overview kind 写入对应文件。
12
+ - **后台语义沉淀**:定期读取完整会话,对照已有记忆后执行 appendrewritedelete 或明确 skip。
13
13
  - **纠错优先**:修正旧记忆时定位并改写原 entry,避免同时保留互相冲突的新旧结论。
14
14
  - **分支隔离**:同一仓库的不同分支拥有独立工作记忆,可 fork、rename、reset 或归档。
15
15
  - **跨分支共享**:稳定规则进入 repo 层;候选知识可在分支完成时提炼上提。
@@ -17,17 +17,13 @@ Dev Memory Skill Suite 为 Codex、Claude 等 coding agent 提供跨会话开发
17
17
  - **多仓库工作区**:一个 workspace 下可同时加载多个 repo,并为主仓库保留更完整的上下文。
18
18
  - **本地管理面板**:浏览、编辑已存储记忆,预览 SessionStart 注入文本,并查看会话扫描与 token 用量。
19
19
 
20
- ## 五个 Skill
20
+ ## 一个常驻 Skill
21
21
 
22
22
  | Skill | 职责 | 适用场景 |
23
23
  | --- | --- | --- |
24
24
  | `dev-memory-read` | 定位并搜索当前 repo/branch 的权威记忆,只读不写 | 主动恢复既有记忆、查询历史 TODO |
25
- | `dev-memory-capture` | 写入、改写或删除记忆;同步 Git 派生索引 | 稳定结论沉淀、纠错、阶段 checkpoint |
26
- | `dev-memory-setup` | 把 `unsorted.md` 中的内容分类合并到结构化文件 | 未分类内容整理、记忆结构初始化 |
27
- | `dev-memory-tidy` | 批量审查陈旧、重复、错误或模板残留条目,备份后应用 | 结构化记忆校准与清理 |
28
- | `dev-memory-graduate` | 提炼跨分支知识并归档已完成分支 | 分支收尾与归档 |
29
25
 
30
- Skill 定义触发语义与协作流程,文件操作统一由 `dev-memory-cli` 执行。
26
+ 普通开发会话不再常驻 capture/setup/tidy/graduate Skill。会话写入由 session-scan 后台处理;整理和归档由 CLI 临时启动专用维护 Agent,并只把对应维护 prompt 放进那个独立会话。
31
27
 
32
28
  ## 安装
33
29
 
@@ -44,13 +40,13 @@ npx skills add xluos/dev-memory-skill-suite --list
44
40
  安装到 Codex 全局:
45
41
 
46
42
  ```bash
47
- npx skills add xluos/dev-memory-skill-suite --skill '*' -a codex -g -y
43
+ npx skills add xluos/dev-memory-skill-suite --skill dev-memory-read -a codex -g -y
48
44
  ```
49
45
 
50
46
  安装到检测到的所有 agent:
51
47
 
52
48
  ```bash
53
- npx skills add xluos/dev-memory-skill-suite --all -g -y
49
+ npx skills add xluos/dev-memory-skill-suite --skill dev-memory-read --all -g -y
54
50
  ```
55
51
 
56
52
  ### 安装 CLI 与 Hook
@@ -81,31 +77,27 @@ dev-memory-cli session-scan install
81
77
  dev-memory-cli session-scan status
82
78
  ```
83
79
 
84
- 扫描任务默认在本地时间 03:00 运行。`install-hooks codex` 只安装 CLI hook,不会隐式安装定时任务。
80
+ 扫描任务默认在本地时间 03:00 和 13:00 运行,时间列表可配置。LaunchAgent 触发时会读取 macOS HID 空闲时长;最近 10 分钟有键鼠输入则记录 `skipped_active` 并退出,不扫描文件或调用模型。活跃检测失败时默认保守跳过。手工执行 `session-scan run` 不受该检测影响。`install-hooks codex` 只安装 CLI hook,不会隐式安装定时任务。
85
81
 
86
82
  ## 基本使用
87
83
 
88
- 写入操作具备 lazy initialization。存储骨架缺失时自动创建,setup 不作为写入前置条件。
84
+ 记忆是显式 opt-in。首次使用先初始化,然后由生命周期 hook 恢复、由定期 session-scan 沉淀新会话语义。
89
85
 
90
86
  ```bash
87
+ # 为当前 repo + branch 初始化记忆骨架
88
+ dev-memory-cli init
89
+
91
90
  # 查看当前仓库和分支对应的记忆路径
92
91
  dev-memory-cli read show
93
92
 
94
93
  # 只在当前 repo 的记忆范围内搜索
95
94
  dev-memory-cli read search --query "发布流程" --query "回滚"
96
95
 
97
- # 写入一条分支决策
98
- dev-memory-cli capture record \
99
- --kind decision \
100
- --content "发布前必须先跑完整打包检查"
101
-
102
- # 写入一条跨分支规则
103
- dev-memory-cli capture record \
104
- --kind shared-decision \
105
- --content "Python 测试优先使用项目虚拟环境"
96
+ # 启动独立整理 Agent(不会把整理 prompt 放进普通开发会话)
97
+ dev-memory-cli maintain tidy
106
98
 
107
- # 刷新 Git working tree 派生的文件索引
108
- dev-memory-cli capture sync-working-tree
99
+ # 启动独立归档 Agent
100
+ dev-memory-cli maintain archive --branch feature/example
109
101
  ```
110
102
 
111
103
  `read search` 默认搜索当前 branch + repo 共享层。跨分支和归档范围通过 `--scope` 显式指定:
@@ -115,11 +107,11 @@ dev-memory-cli read search --scope all-branches --query "关键词"
115
107
  dev-memory-cli read search --scope archived --query "关键词"
116
108
  ```
117
109
 
118
- ### 写入路由
110
+ ### 底层写入路由
119
111
 
120
112
  ![Capture 写入路由](docs/diagrams/capture.png)
121
113
 
122
- `capture record` 支持三种输入方式:
114
+ `capture` 是 session-scan 和维护 Agent 使用的底层写入引擎,同时保留为高级管理 CLI;它不再对应常驻 Skill。`capture record` 支持三种输入方式:
123
115
 
124
116
  - **显式 kind**:调用方明确指定内容语义,直接写目标 section。
125
117
  - **自动分类**:`--auto` 根据文本信号判断 decision、risk、glossary;无法明确分类时进入 `unsorted.md`。
@@ -150,19 +142,18 @@ dev-memory-cli capture rewrite-entry --id <entry-id> --content "修正后的完
150
142
  dev-memory-cli capture delete-entry --id <entry-id>
151
143
  ```
152
144
 
153
- 符合跨分支复用判定的内容会同时进入 `pending-promotion.md`,由 graduate 在分支收尾阶段审核;候选内容不会直接写入 repo 共享层。
145
+ 符合跨分支复用判定的内容会同时进入 `pending-promotion.md`,由归档维护 Agent 在分支收尾阶段审核;候选内容不会直接写入 repo 共享层。
154
146
 
155
147
  ### 整理与归档
156
148
 
157
- 三个维护动作边界不同:
149
+ 普通入口只有两个,都会启动独立交互式 Agent:
158
150
 
159
- | 动作 | 处理对象 | 结果 |
151
+ | 命令 | 处理对象 | 结果 |
160
152
  | --- | --- | --- |
161
- | `setup` | `unsorted.md` 中尚未分类的内容 | 合并到 decisions、risks、glossary repo 共享层 |
162
- | `tidy` | 已结构化但陈旧、重复或错误的内容 | proposal 审核,备份后 edit/delete/reset |
163
- | `graduate` | 已完成分支的有效记忆 | 上提跨分支知识,并把分支目录移入 `_archived` |
153
+ | `dev-memory-cli maintain tidy` | `unsorted.md` 与已结构化但漂移的内容 | 分类、proposal HTML 审核、备份后 edit/delete/reset |
154
+ | `dev-memory-cli maintain archive` | 已完成分支的有效记忆 | dry-run、上提跨分支知识、确认后移入 `_archived` |
164
155
 
165
- `tidy apply` 和 `graduate apply` 会改变或移动已有记忆,仅在显式调用后执行。
156
+ 维护 Agent 内部使用 `setup`、`tidy`、`graduate` 等低层子命令。`tidy apply` 和 `graduate apply` 会改变或移动已有记忆,仍然受人工审核和显式确认门禁保护。用 `--print-prompt` 可以只查看将传给独立 Agent 的完整维护指令。
166
157
 
167
158
  ### 分支记忆操作
168
159
 
@@ -223,11 +214,19 @@ dev-memory-cli session-scan run --dry-run --json
223
214
  dev-memory-cli session-scan run
224
215
  dev-memory-cli session-scan stats --json
225
216
  dev-memory-cli session-scan history --limit 20
217
+ dev-memory-cli session-scan replay --run-id <run-id> --session-id <session-id> [--session-id <session-id> ...] [--executor codex]
226
218
  dev-memory-cli session-scan config show
227
219
  dev-memory-cli session-scan config set-executor codex
228
220
  dev-memory-cli session-scan config set-model codex <model>
221
+ dev-memory-cli session-scan config set-schedule 03:00 13:00
222
+ dev-memory-cli session-scan config set-active-minutes 10
223
+ dev-memory-cli session-scan config set-timeout 360
229
224
  ```
230
225
 
226
+ 已安装定时任务时,`set-schedule` 会自动重载 LaunchAgent,使新的时间列表立即生效。
227
+
228
+ `replay` 会按历史 run 记录的 `cursor_before` / `cursor_after` 精确重放指定会话,不回退当前会话游标;`--executor` 只覆盖本次运行,不修改持久配置。单分块会话只调用一次模型;最终结果必须包含至少一个记忆变更,或提供非空 `skip_reason`。空对象、只有 `title`、以及没有产生语义 action 且未说明原因的结果都会标记失败并保留重试空间。单次模型调用默认 360 秒超时,超时不会立即重试。run 账本会记录输出字段计数、`skip_reason`、payload 哈希、有效/观测字节数和语义 action 数,不保存原始总结正文。
229
+
231
230
  ## 运行模式
232
231
 
233
232
  | 模式 | 检测条件 | 行为 |
@@ -243,7 +242,7 @@ dev-memory-cli workspace show
243
242
  dev-memory-cli workspace primary <repo-basename>
244
243
  ```
245
244
 
246
- `DEV_MEMORY_PRIMARY_REPO` 可作为临时 override。Skill workspace 中写入时应使用 `--repo <repo-path-or-basename>` 明确目标仓库。
245
+ `DEV_MEMORY_PRIMARY_REPO` 可作为临时 override。维护命令在 workspace 中应使用 `--repo <repo-path-or-basename>` 明确目标仓库。
247
246
 
248
247
  ## 存储结构
249
248
 
@@ -319,6 +318,9 @@ dev-memory-cli context injection-preview \
319
318
 
320
319
  ```text
321
320
  dev-memory-cli read <show|search>
321
+ dev-memory-cli init [--repo PATH] [--branch NAME]
322
+ dev-memory-cli maintain <tidy|archive> [--repo PATH] [--branch NAME] [--executor auto|codex|coco]
323
+ # low-level mutation/admin commands:
322
324
  dev-memory-cli capture <show|suggest-kind|classify|record|list-entries|find-candidates|rewrite-entry|delete-entry|apply-summary-output|sync-working-tree|record-head>
323
325
  dev-memory-cli setup <init|merge-unsorted|mark-completed>
324
326
  dev-memory-cli tidy <prepare|apply>
@@ -327,7 +329,7 @@ dev-memory-cli branch [list|inspect|rename|fork|delete|init|inherit-worktree-bas
327
329
  dev-memory-cli context <show|sync|injection-preview>
328
330
  dev-memory-cli workspace <show|primary>
329
331
  dev-memory-cli summary <extract-core>
330
- dev-memory-cli session-scan <run|install|status|stats|history|show|uninstall|config>
332
+ dev-memory-cli session-scan <run|replay|install|status|stats|history|show|uninstall|config>
331
333
  dev-memory-cli hook <session-start|pre-compact|stop|session-end>
332
334
  dev-memory-cli ui
333
335
  dev-memory-cli install-hooks <codex|claude|--all>
@@ -352,11 +354,12 @@ dev-memory 的能力边界不包括:
352
354
  ```text
353
355
  bin/dev-memory.js # CLI 入口与 Node 侧命令
354
356
  lib/dev_memory_*.py # 记忆、分支、会话扫描、整理与归档实现
357
+ lib/maintenance/ # 按需注入独立维护 Agent 的 prompt
355
358
  lib/ui-server.js # 本地管理面板服务
356
359
  lib/ui-app.html # 管理面板前端
357
360
  scripts/hooks/ # 生命周期 hook 实现
358
361
  hooks/ # Codex / Claude hook 模板
359
- skills/ # 五个 skill 的声明与工作流
362
+ skills/dev-memory-read/ # 唯一常驻 Skill
360
363
  tests/ # Python 测试
361
364
  docs/diagrams/ # README 图表及源文件
362
365
  ```
package/bin/dev-memory.js CHANGED
@@ -376,6 +376,112 @@ function commandPySubcommand(name, rawArgs) {
376
376
  runPython(scriptPath, rawArgs);
377
377
  }
378
378
 
379
+ const MAINTENANCE_MODES = new Set(["tidy", "archive"]);
380
+ const MAINTENANCE_MARKER = "DEV_MEMORY_INTERNAL_MAINTENANCE_AGENT_V1";
381
+
382
+ function maintenanceHelp() {
383
+ process.stdout.write(`Usage:
384
+ dev-memory-cli maintain tidy [--repo PATH] [--branch NAME] [--scope branch|branch+repo]
385
+ [--executor auto|codex|coco] [--model MODEL]
386
+ dev-memory-cli maintain archive [--repo PATH] [--branch NAME]
387
+ [--executor auto|codex|coco] [--model MODEL]
388
+ dev-memory-cli maintain <tidy|archive> --print-prompt [other options]
389
+
390
+ The command starts a dedicated interactive maintenance-agent session. Tidy
391
+ must not apply destructive changes before HTML review; archive must not apply
392
+ before dry-run review and explicit confirmation.
393
+ `);
394
+ }
395
+
396
+ function resolveMaintenanceExecutor(requested) {
397
+ const name = requested || process.env.DEV_MEMORY_MAINTENANCE_EXECUTOR || "auto";
398
+ if (!new Set(["auto", "codex", "coco"]).has(name)) {
399
+ fail(`unsupported maintenance executor: ${name}`);
400
+ }
401
+ if (name !== "auto") return name;
402
+ for (const candidate of ["codex", "coco"]) {
403
+ if (commandExists(candidate)) return candidate;
404
+ }
405
+ fail("no maintenance executor found; install codex/coco or use --print-prompt");
406
+ }
407
+
408
+ function buildMaintenancePrompt(mode, options, repoRoot) {
409
+ const promptPath = packageScript("lib", "maintenance", `${mode}.md`);
410
+ if (!fs.existsSync(promptPath)) fail(`missing maintenance prompt: ${promptPath}`);
411
+ const workflow = fs.readFileSync(promptPath, "utf8").trim();
412
+ const cliPath = path.resolve(process.argv[1]);
413
+ const branch = options.branch || "<current-git-branch>";
414
+ const scope = mode === "tidy" ? (options.scope || "branch") : "branch";
415
+ return `${MAINTENANCE_MARKER}
416
+
417
+ 你是 dev-memory 的专用维护 Agent。本会话只处理下面指定仓库的记忆维护,不承担普通开发任务。
418
+
419
+ 目标仓库:${repoRoot}
420
+ 目标分支:${branch}
421
+ 维护模式:${mode}
422
+ 整理范围:${scope}
423
+ 本次必须使用的 CLI:node ${JSON.stringify(cliPath)}
424
+
425
+ 不要依赖全局 dev-memory capture/setup/tidy/graduate Skill;完整维护流程已经随本提示提供。
426
+ 涉及删除、改写或归档时,必须遵守下面流程中的人工审核和确认门禁。
427
+
428
+ ${workflow}
429
+ `;
430
+ }
431
+
432
+ function commandMaintain(rawArgs) {
433
+ const mode = rawArgs[0];
434
+ if (!mode || mode === "--help" || mode === "-h") {
435
+ maintenanceHelp();
436
+ return;
437
+ }
438
+ if (!MAINTENANCE_MODES.has(mode)) {
439
+ fail(`unknown maintenance mode: ${mode}`);
440
+ }
441
+ const { positional, options } = parseArgs(rawArgs.slice(1));
442
+ if (positional.length) fail(`unexpected maintenance argument: ${positional[0]}`);
443
+ const repoRoot = path.resolve(options.repo || process.cwd());
444
+ if (!fs.existsSync(repoRoot) || !fs.statSync(repoRoot).isDirectory()) {
445
+ fail(`maintenance repo does not exist: ${repoRoot}`);
446
+ }
447
+ if (mode === "tidy" && options.scope && !new Set(["branch", "branch+repo"]).has(options.scope)) {
448
+ fail(`unsupported tidy scope: ${options.scope}`);
449
+ }
450
+ const prompt = buildMaintenancePrompt(mode, options, repoRoot);
451
+ if (options["print-prompt"] || options["dry-run"]) {
452
+ process.stdout.write(prompt);
453
+ return;
454
+ }
455
+
456
+ const executor = resolveMaintenanceExecutor(options.executor);
457
+ let args;
458
+ if (executor === "codex") {
459
+ const modelArgs = options.model ? ["--model", String(options.model)] : [];
460
+ args = [
461
+ "-C", repoRoot,
462
+ "--sandbox", "danger-full-access",
463
+ "--ask-for-approval", "on-request",
464
+ "--dangerously-bypass-hook-trust",
465
+ ...modelArgs,
466
+ prompt,
467
+ ];
468
+ } else {
469
+ const modelArgs = options.model ? ["--config", `model=${String(options.model)}`] : [];
470
+ args = ["--permission-mode", "default", ...modelArgs, prompt];
471
+ }
472
+ const result = spawnSync(executor, args, {
473
+ cwd: repoRoot,
474
+ env: {
475
+ ...process.env,
476
+ DEV_MEMORY_MAINTENANCE_AGENT: "1",
477
+ DEV_MEMORY_MAINTENANCE_MODE: mode,
478
+ },
479
+ stdio: "inherit",
480
+ });
481
+ if (result.error) fail(`unable to start ${executor}: ${result.error.message}`);
482
+ if (result.status !== 0) process.exit(result.status || 1);
483
+ }
484
+
379
485
  function branchScript() {
380
486
  return packageScript("lib", "dev_memory_branch.py");
381
487
  }
@@ -692,8 +798,11 @@ function printHelp() {
692
798
  dev-memory-cli install-hooks --all [--repo PATH] [--global]
693
799
  dev-memory-cli ui [--port N] [--host HOST] [--no-open] [--read-only]
694
800
  dev-memory-cli workspace <show|primary> [...]
801
+ dev-memory-cli init [--repo PATH] [--branch NAME]
695
802
  dev-memory-cli read <show|search> [...]
803
+ dev-memory-cli maintain <tidy|archive> [...] # starts a dedicated interactive agent
696
804
  dev-memory-cli context <show|...> [...]
805
+ # Low-level mutation/admin commands used by session-scan and maintenance agents:
697
806
  dev-memory-cli capture <record|show|sync-working-tree|record-head|suggest-kind|classify> [...]
698
807
  dev-memory-cli setup <init|merge-unsorted|mark-completed> [...]
699
808
  dev-memory-cli graduate <dry-run|apply|index> [...]
@@ -719,6 +828,14 @@ function main() {
719
828
  commandPySubcommand(command, argv.slice(1));
720
829
  return;
721
830
  }
831
+ if (command === "init") {
832
+ commandPySubcommand("setup", ["init", ...argv.slice(1)]);
833
+ return;
834
+ }
835
+ if (command === "maintain") {
836
+ commandMaintain(argv.slice(1));
837
+ return;
838
+ }
722
839
  if (command === "branch") {
723
840
  commandBranch(argv.slice(1));
724
841
  return;
package/hooks/README.md CHANGED
@@ -130,7 +130,9 @@ dev-memory-cli session-scan install
130
130
  dev-memory-cli session-scan status
131
131
  ```
132
132
 
133
- 任务默认每天本地时间 03:00 运行。首次扫描只回看最近 3 天;后续使用持久化字节游标补扫上次成功处理后产生的全部数据。`install-hooks codex` `session-scan install` 相互独立。
133
+ 任务默认每天本地时间 03:00 13:00 运行。LaunchAgent 使用 `session-scan run --scheduled`,运行前检查 macOS HID 空闲时长;最近 10 分钟有键鼠输入时写入 `skipped_active` 记录并退出,不读取会话正文、不调用模型。检测不可用时默认保守跳过。手工执行 `session-scan run` 不启用活跃检测。
134
+
135
+ 首次扫描只回看最近 3 天;后续使用持久化字节游标补扫上次成功处理后产生的全部数据。`install-hooks codex` 与 `session-scan install` 相互独立。
134
136
 
135
137
  扫描器只提取尚未处理的 user/assistant 语义消息,不限制消息数量,也不截断单条消息。材料超过单次模型上下文时按顺序分块,每个分块都进入中间摘要,再结合现有 memory 生成最终结构化结果。游标只在最终结果成功应用后推进。
136
138
 
@@ -143,6 +145,10 @@ dev-memory-cli session-scan status
143
145
  "session_scan": {
144
146
  "executor": "auto",
145
147
  "order": ["coco", "codex", "claude"],
148
+ "schedule_times": ["03:00", "13:00"],
149
+ "skip_when_computer_active": true,
150
+ "active_within_minutes": 10,
151
+ "activity_check_fail_closed": true,
146
152
  "chunk_chars": 60000,
147
153
  "idle_minutes": 60,
148
154
  "first_lookback_days": 3,
@@ -162,9 +168,14 @@ dev-memory-cli session-scan config show
162
168
  dev-memory-cli session-scan config set-executor codex
163
169
  dev-memory-cli session-scan config set-model codex <model>
164
170
  dev-memory-cli session-scan config set-profile codex <profile>
171
+ dev-memory-cli session-scan config set-schedule 03:00 13:00
172
+ dev-memory-cli session-scan config set-active-minutes 10
173
+ dev-memory-cli session-scan config set-active-check on
165
174
  dev-memory-cli session-scan config validate
166
175
  ```
167
176
 
177
+ 已安装定时任务时,`set-schedule` 会自动重载 LaunchAgent;活跃阈值和开关由每次运行动态读取,无需重装。
178
+
168
179
  `auto` 只在命令不存在或 preset 被禁用时选择下一个执行器。模型调用失败不会自动换供应商,以免重复产生不可控费用。
169
180
 
170
181
  ### 递归防护
@@ -16,9 +16,11 @@ Subcommands:
16
16
 
17
17
  import argparse
18
18
  import difflib
19
+ import fcntl
19
20
  import json
20
21
  import os
21
22
  import sys
23
+ from contextlib import contextmanager
22
24
  from pathlib import Path
23
25
 
24
26
  sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -27,6 +29,7 @@ from dev_memory_common import (
27
29
  AUTO_END,
28
30
  AUTO_START,
29
31
  PLACEHOLDER_MARKERS,
32
+ atomic_write_text,
30
33
  append_log_event,
31
34
  append_to_section,
32
35
  asset_paths,
@@ -34,6 +37,7 @@ from dev_memory_common import (
34
37
  collect_git_facts,
35
38
  ensure_branch_paths_exist,
36
39
  get_head_commit,
40
+ get_branch_paths,
37
41
  get_setup_completed,
38
42
  is_cross_branch_candidate,
39
43
  join_sections,
@@ -140,7 +144,7 @@ def _append_with_separator(path, title, body, *, enforce_limit=True, max_entries
140
144
  else (body_stripped, 0)
141
145
  )
142
146
  updated.append((title, bounded))
143
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
147
+ atomic_write_text(path, join_sections(prefix, updated))
144
148
  return pruned
145
149
 
146
150
 
@@ -770,7 +774,7 @@ def _entry_mutation(paths, eid, *, new_text=None, delete=False):
770
774
 
771
775
  new_sections = list(sections)
772
776
  new_sections[section_idx] = (section_title, new_body)
773
- target_path.write_text(join_sections(prefix, new_sections), encoding="utf-8")
777
+ atomic_write_text(target_path, join_sections(prefix, new_sections))
774
778
  return {
775
779
  "id": eid,
776
780
  "file_key": file_key,
@@ -1100,7 +1104,7 @@ def _prune_repo_shared_section(path, section_title, *, max_entries, max_entry_ch
1100
1104
  updated.append((existing_title, new_body))
1101
1105
  if not changed:
1102
1106
  return None
1103
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1107
+ atomic_write_text(path, join_sections(prefix, updated))
1104
1108
  return result
1105
1109
 
1106
1110
 
@@ -1168,7 +1172,7 @@ def prune_bounded_memory(paths, *, max_entries=None):
1168
1172
  "max_entries": max_entries,
1169
1173
  })
1170
1174
  if changed:
1171
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1175
+ atomic_write_text(path, join_sections(prefix, updated))
1172
1176
  return touched
1173
1177
 
1174
1178
 
@@ -1278,6 +1282,8 @@ def _decision_content(item):
1278
1282
  if isinstance(item, str):
1279
1283
  return item.strip()
1280
1284
  if isinstance(item, dict):
1285
+ if not _decision_summary(item):
1286
+ return ""
1281
1287
  return decision_body(item)
1282
1288
  return ""
1283
1289
 
@@ -1962,6 +1968,45 @@ def command_apply_summary_output(args):
1962
1968
  "reason": item.get("reason"),
1963
1969
  })
1964
1970
 
1971
+ # Repair schema placeholder prose left by malformed historical summary
1972
+ # output. Valid entries are top-level bullets; these exact unbulleted
1973
+ # words are generator scaffolding and should never survive in memory.
1974
+ placeholder_words = {
1975
+ "decision", "summary", "reason", "impact", "risk", "mitigation",
1976
+ "term", "definition", "name", "url", "note", "label", "path", "content",
1977
+ }
1978
+ for file_key in ("decisions", "risks", "glossary", "repo_glossary"):
1979
+ path = paths[file_key]
1980
+ if not path.exists():
1981
+ continue
1982
+ content = path.read_text(encoding="utf-8")
1983
+ prefix, sections = split_sections(content)
1984
+ updated = []
1985
+ removed = 0
1986
+ for section_title, section_body in sections:
1987
+ kept_lines = []
1988
+ section_removed = 0
1989
+ for line in section_body.splitlines():
1990
+ stripped = line.strip().strip("::").lower()
1991
+ is_bullet = line.lstrip().startswith(("- ", "* ", "+ "))
1992
+ if stripped in placeholder_words and not is_bullet:
1993
+ section_removed += 1
1994
+ continue
1995
+ kept_lines.append(line)
1996
+ if section_removed:
1997
+ rec = {
1998
+ "file": _label(file_key),
1999
+ "section": section_title,
2000
+ "mode": "remove-placeholder-orphans",
2001
+ "removed": section_removed,
2002
+ }
2003
+ touched.append(rec)
2004
+ actions.append({"op": "remove-placeholder-orphans", **rec})
2005
+ removed += section_removed
2006
+ updated.append((section_title, "\n".join(kept_lines).strip()))
2007
+ if removed:
2008
+ atomic_write_text(path, join_sections(prefix, updated))
2009
+
1965
2010
  bounded = prune_bounded_memory(paths)
1966
2011
  if bounded:
1967
2012
  touched.extend(bounded)
@@ -2177,6 +2222,20 @@ def _add_common_args(parser):
2177
2222
  parser.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
2178
2223
 
2179
2224
 
2225
+ @contextmanager
2226
+ def _repo_command_lock(args):
2227
+ """Serialize capture commands per repo so read-modify-write stays coherent."""
2228
+ _, _, _, _, _, repo_dir, _ = get_branch_paths(args.repo, args.context_dir, args.branch)
2229
+ lock_path = repo_dir / "jobs" / "capture.lock"
2230
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
2231
+ with lock_path.open("a+", encoding="utf-8") as lock_stream:
2232
+ fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
2233
+ try:
2234
+ yield
2235
+ finally:
2236
+ fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
2237
+
2238
+
2180
2239
  def main():
2181
2240
  parser = argparse.ArgumentParser(
2182
2241
  description="Unified write entrypoint for dev-memory repo+branch memory (merges old sync + update).",
@@ -2297,7 +2356,11 @@ def main():
2297
2356
  "sync-working-tree": command_sync_working_tree,
2298
2357
  "record-head": command_record_head,
2299
2358
  }
2300
- return handlers[args.command](args)
2359
+ handler = handlers[args.command]
2360
+ if args.command == "suggest-kind":
2361
+ return handler(args)
2362
+ with _repo_command_lock(args):
2363
+ return handler(args)
2301
2364
  except Exception as exc:
2302
2365
  print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
2303
2366
  return 1
@@ -6,6 +6,7 @@ import os
6
6
  import re
7
7
  import shutil
8
8
  import subprocess
9
+ import tempfile
9
10
  import time
10
11
  import uuid
11
12
  from collections import Counter
@@ -511,11 +512,29 @@ def asset_paths(repo_dir, branch_dir):
511
512
 
512
513
  def ensure_file(path, content):
513
514
  if not path.exists():
514
- path.write_text(content, encoding="utf-8")
515
+ atomic_write_text(path, content)
516
+
517
+
518
+ def atomic_write_text(path, content):
519
+ """Atomically replace a UTF-8 text file without exposing partial bytes."""
520
+ path = Path(path)
521
+ path.parent.mkdir(parents=True, exist_ok=True)
522
+ mode = (path.stat().st_mode & 0o777) if path.exists() else 0o644
523
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
524
+ tmp_path = Path(tmp_name)
525
+ try:
526
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
527
+ stream.write(content)
528
+ stream.flush()
529
+ os.fsync(stream.fileno())
530
+ os.chmod(tmp_path, mode)
531
+ os.replace(tmp_path, path)
532
+ finally:
533
+ tmp_path.unlink(missing_ok=True)
515
534
 
516
535
 
517
536
  def write_json(path, payload):
518
- path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
537
+ atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
519
538
 
520
539
 
521
540
  def read_json(path):
@@ -1705,7 +1724,7 @@ def ensure_progress_auto_block(path):
1705
1724
  else:
1706
1725
  updated = content.rstrip() + auto_section
1707
1726
 
1708
- path.write_text(updated + ("" if updated.endswith("\n") else "\n"), encoding="utf-8")
1727
+ atomic_write_text(path, updated + ("" if updated.endswith("\n") else "\n"))
1709
1728
  return path.read_text(encoding="utf-8")
1710
1729
 
1711
1730
 
@@ -1762,7 +1781,7 @@ def upsert_markdown_section(path, title, body):
1762
1781
  updated.append((existing_title, existing_body))
1763
1782
  if not replaced:
1764
1783
  updated.append((title, body))
1765
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1784
+ atomic_write_text(path, join_sections(prefix, updated))
1766
1785
 
1767
1786
 
1768
1787
  def _section_is_placeholder_only(text):
@@ -1822,7 +1841,7 @@ def append_to_section(path, title, body, *, max_entries=None):
1822
1841
  if not matched:
1823
1842
  bounded, _pruned = limit_markdown_entries(body.strip(), max_entries=max_entries)
1824
1843
  updated.append((title, bounded))
1825
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1844
+ atomic_write_text(path, join_sections(prefix, updated))
1826
1845
 
1827
1846
 
1828
1847
  def upsert_progress_section(path, title, body):
@@ -1849,7 +1868,7 @@ def upsert_progress_section(path, title, body):
1849
1868
  if not replaced:
1850
1869
  updated.append((title, body))
1851
1870
  rewritten = join_sections(prefix, updated).rstrip() + "\n\n" + marker + after
1852
- path.write_text(rewritten, encoding="utf-8")
1871
+ atomic_write_text(path, rewritten)
1853
1872
 
1854
1873
 
1855
1874
  def sync_progress(paths, facts):
@@ -1864,7 +1883,7 @@ def sync_progress(paths, facts):
1864
1883
  )
1865
1884
  current = ensure_progress_auto_block(paths["progress"])
1866
1885
  updated = replace_auto_block(current, build_auto_block(facts))
1867
- paths["progress"].write_text(updated, encoding="utf-8")
1886
+ atomic_write_text(paths["progress"], updated)
1868
1887
 
1869
1888
 
1870
1889
  # ---------------------------------------------------------------------------
@@ -1918,7 +1937,7 @@ def append_log_event(log_path, action, *, kind=None, summary=None, details=None)
1918
1937
  return
1919
1938
  if not log_path.exists():
1920
1939
  log_path.parent.mkdir(parents=True, exist_ok=True)
1921
- log_path.write_text(template_log("unknown"), encoding="utf-8")
1940
+ atomic_write_text(log_path, template_log("unknown"))
1922
1941
 
1923
1942
  header_parts = [f"## [{now_iso()}] {action}"]
1924
1943
  if kind:
@@ -1938,7 +1957,7 @@ def append_log_event(log_path, action, *, kind=None, summary=None, details=None)
1938
1957
  existing = log_path.read_text(encoding="utf-8")
1939
1958
  if not existing.endswith("\n"):
1940
1959
  existing += "\n"
1941
- log_path.write_text(existing + "\n" + block, encoding="utf-8")
1960
+ atomic_write_text(log_path, existing + "\n" + block)
1942
1961
 
1943
1962
 
1944
1963
  # ---------------------------------------------------------------------------