dev-memory-cli 0.18.2

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,1343 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ dev-memory-capture: unified write entrypoint for repo+branch development
4
+ assets. Merges the old sync (checkpoint batch record) and update (targeted
5
+ section rewrite) into one skill. Also the lazy-init entry — never raises on
6
+ missing branch_dir; the directory is seeded on first write.
7
+
8
+ Subcommands:
9
+ record : write content into one or more sections (inline/kind/auto/batch)
10
+ show : dump current paths + missing docs
11
+ sync-working-tree : refresh progress.md's auto-sync block from git facts
12
+ record-head : stamp last_seen_head onto branch+repo manifests
13
+ suggest-kind : dry-run heuristic classifier
14
+ classify : dry-run — run classifier AND cross-branch detector
15
+ """
16
+
17
+ import argparse
18
+ import difflib
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+
25
+ from dev_memory_common import (
26
+ AUTO_END,
27
+ AUTO_START,
28
+ PLACEHOLDER_MARKERS,
29
+ append_log_event,
30
+ append_to_section,
31
+ asset_paths,
32
+ classify_content,
33
+ collect_git_facts,
34
+ ensure_branch_paths_exist,
35
+ get_head_commit,
36
+ get_setup_completed,
37
+ is_cross_branch_candidate,
38
+ join_sections,
39
+ list_missing_docs,
40
+ merged_focus_areas,
41
+ now_iso,
42
+ read_json,
43
+ render_bullets,
44
+ split_sections,
45
+ sync_progress,
46
+ upsert_markdown_section,
47
+ upsert_progress_section,
48
+ write_json,
49
+ )
50
+
51
+
52
+ def _log_targets_for(touched):
53
+ """Return (touches_repo, formatted_targets_line) from the touched list.
54
+
55
+ `touched` items look like {"file": "branch/decisions.md", "section": ...,
56
+ "mode": ...}. The formatted line keeps it short for log readability:
57
+ each target rendered as `branch/decisions.md(append)`, dropping the
58
+ section to avoid blowing past _LOG_SUMMARY_MAX on batch writes.
59
+ """
60
+ if not touched:
61
+ return False, None
62
+ touches_repo = any(t.get("file", "").startswith("repo/") for t in touched)
63
+ parts = []
64
+ for t in touched:
65
+ file_ = t.get("file", "?")
66
+ mode = t.get("mode", "?")
67
+ parts.append(f"{file_}({mode})")
68
+ return touches_repo, ", ".join(parts)
69
+
70
+
71
+ def _emit_capture_log(paths, *, action, kind_label, summary, touched, extra_details=None):
72
+ """Append an event row to log.md after a successful capture write.
73
+
74
+ Writes to the branch-level log by default. If any touched target lives
75
+ under the repo-shared layer (`repo/...`), also mirrors a row into the
76
+ repo log so cross-branch readers see shared-layer mutations.
77
+ """
78
+ touches_repo, targets_line = _log_targets_for(touched)
79
+ details = []
80
+ if targets_line:
81
+ details.append(("targets", targets_line))
82
+ for k, v in (extra_details or []):
83
+ details.append((k, v))
84
+ append_log_event(
85
+ paths.get("log"),
86
+ action,
87
+ kind=kind_label,
88
+ summary=summary,
89
+ details=details,
90
+ )
91
+ if touches_repo and paths.get("repo_log") and paths.get("repo_log") != paths.get("log"):
92
+ append_log_event(
93
+ paths.get("repo_log"),
94
+ action,
95
+ kind=kind_label,
96
+ summary=summary,
97
+ details=details,
98
+ )
99
+
100
+
101
+ def _append_with_separator(path, title, body):
102
+ """Like append_to_section but always inserts a blank line between the
103
+ existing section body and the new entry. Drops placeholder-only bodies
104
+ instead of padding them with a blank line.
105
+ """
106
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
107
+ prefix, sections = split_sections(content)
108
+ target = title.strip()
109
+ matched = False
110
+ updated = []
111
+ body_stripped = body.strip()
112
+
113
+ def _is_placeholder_only(text):
114
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
115
+ if not lines:
116
+ return True
117
+ return all(any(marker in ln for marker in PLACEHOLDER_MARKERS) for ln in lines)
118
+
119
+ for existing_title, existing_body in sections:
120
+ if existing_title.strip() == target and not matched:
121
+ if _is_placeholder_only(existing_body):
122
+ combined = body_stripped
123
+ else:
124
+ combined = existing_body.rstrip() + "\n\n" + body_stripped
125
+ updated.append((existing_title, combined))
126
+ matched = True
127
+ else:
128
+ updated.append((existing_title, existing_body))
129
+ if not matched:
130
+ updated.append((title, body_stripped))
131
+ path.write_text(join_sections(prefix, updated), encoding="utf-8")
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Dedup check primitives
136
+ # ---------------------------------------------------------------------------
137
+
138
+ # Chinese (and a few English) phrases that overtly signal "I'm rewriting an
139
+ # earlier entry". When the new content contains any of these, similarity is
140
+ # boosted (so even mid-similarity matches surface) and the recommendation
141
+ # leans toward update_existing — capture should treat this as a hint that the
142
+ # user already knows there's a prior entry to revise.
143
+ SUPERSEDES_KEYWORDS = (
144
+ "supersedes",
145
+ "重新校正",
146
+ "已更新",
147
+ "新版",
148
+ "修正",
149
+ "推翻",
150
+ "取代",
151
+ "覆盖",
152
+ "撤销",
153
+ )
154
+
155
+ # 80-char preview window for similarity comparison. Long bodies dilute the
156
+ # ratio with prose; capping at the first line's first 80 chars keeps the
157
+ # signal-to-noise ratio high — typical entries in decisions.md / risks.md
158
+ # fit their key claim into the lead line.
159
+ _SIM_PREVIEW_LEN = 80
160
+
161
+
162
+ def _first_nonempty_line(text):
163
+ """Return the first non-blank line of `text`, stripped. Empty string if
164
+ `text` is all blank. Used as the comparison anchor for similarity_check
165
+ because top-level bullets carry their "thesis" in the lead line."""
166
+ if not text:
167
+ return ""
168
+ for line in text.splitlines():
169
+ s = line.strip()
170
+ if s:
171
+ return s
172
+ return ""
173
+
174
+
175
+ def _strip_bullet_prefix(text):
176
+ """Drop a leading `- ` / `* ` from a single line so the comparison key is
177
+ the entry's content, not its bullet marker. Without this, every entry's
178
+ preview starts with `- ` and difflib over-credits common prefixes."""
179
+ s = text.lstrip()
180
+ if s.startswith("- ") or s.startswith("* "):
181
+ return s[2:].strip()
182
+ return s
183
+
184
+
185
+ def _section_top_level_entries(section_body):
186
+ """Yield (entry_idx, full_text) for each top-level bullet entry in a
187
+ section body. Mirrors tidy's `_parse_entries` boundary rules (top-level
188
+ `- ` starts a new entry; indented or non-bullet continuation lines fold
189
+ into the current entry) but stays local to capture so the modules don't
190
+ grow a cross-dependency on a private symbol.
191
+
192
+ The AUTO-GENERATED block (machine-managed state in progress.md) is
193
+ excluded — dedup against auto-block content would block every progress
194
+ write the moment git facts repeat.
195
+ """
196
+ if not section_body:
197
+ return
198
+ # Drop the AUTO-GENERATED block if present so we don't dedup against
199
+ # auto-managed content (e.g. progress.md focus areas regenerated by
200
+ # sync-working-tree). The visible top of the section still gets scanned.
201
+ body = section_body
202
+ if AUTO_START in body and AUTO_END in body:
203
+ start = body.index(AUTO_START)
204
+ end = body.index(AUTO_END) + len(AUTO_END)
205
+ body = (body[:start].rstrip() + "\n" + body[end:].lstrip()).strip()
206
+
207
+ current_lines = None
208
+ current_idx = -1
209
+ counter = -1
210
+ entries = []
211
+
212
+ def flush():
213
+ if current_lines is None:
214
+ return
215
+ entries.append((current_idx, "\n".join(current_lines).strip()))
216
+
217
+ for raw in body.splitlines():
218
+ stripped = raw.strip()
219
+ if not stripped:
220
+ if current_lines is not None:
221
+ flush()
222
+ current_lines_local = None # noqa: F841 (clarity)
223
+ current_lines = None
224
+ continue
225
+ if stripped.startswith("- ") and not (raw.startswith(" ") or raw.startswith("\t")):
226
+ if current_lines is not None:
227
+ flush()
228
+ counter += 1
229
+ current_idx = counter
230
+ current_lines = [raw]
231
+ elif current_lines is not None:
232
+ current_lines.append(raw)
233
+ # Prose before any bullet is ignored — matches _parse_entries.
234
+ if current_lines is not None:
235
+ flush()
236
+
237
+ for entry_idx, text in entries:
238
+ yield entry_idx, text
239
+
240
+
241
+ def _is_placeholder_entry(text):
242
+ """A top-level bullet whose content is exactly the placeholder marker
243
+ (待补充/待刷新) shouldn't participate in dedup — it represents "empty
244
+ slot, please fill" not "real content."""
245
+ body = _strip_bullet_prefix(_first_nonempty_line(text))
246
+ return body in ("待补充", "待刷新")
247
+
248
+
249
+ def similarity_check(new_content, section_body, threshold=0.7):
250
+ """Find near-duplicate top-level bullets in `section_body` for `new_content`.
251
+
252
+ Returns a list of matches sorted by similarity desc:
253
+ [{"entry_idx": int, "similarity": float, "match_first_line": str,
254
+ "match_full_text": str}, ...]
255
+
256
+ Algorithm:
257
+ 1. Compute the comparison key for `new_content`: first non-empty line,
258
+ bullet prefix stripped, capped at _SIM_PREVIEW_LEN chars.
259
+ 2. For each top-level bullet in `section_body` (placeholders + AUTO
260
+ blocks excluded), compute the same key and a
261
+ difflib.SequenceMatcher ratio against the new key.
262
+ 3. If `new_content` contains any SUPERSEDES_KEYWORDS, boost the
263
+ similarity by +0.15 (capped at 1.0). Rationale: an explicit "I'm
264
+ updating X" cue from the user is a stronger signal than text
265
+ similarity alone — surface mid-similarity candidates that would
266
+ otherwise sit below the threshold.
267
+ 4. Keep only matches with (boosted) similarity ≥ `threshold`.
268
+ """
269
+ new_first = _strip_bullet_prefix(_first_nonempty_line(new_content))
270
+ if not new_first:
271
+ return []
272
+ new_key = new_first[:_SIM_PREVIEW_LEN]
273
+
274
+ has_supersedes = any(kw in new_content for kw in SUPERSEDES_KEYWORDS)
275
+
276
+ matches = []
277
+ for entry_idx, entry_text in _section_top_level_entries(section_body):
278
+ if _is_placeholder_entry(entry_text):
279
+ continue
280
+ existing_first = _strip_bullet_prefix(_first_nonempty_line(entry_text))
281
+ if not existing_first:
282
+ continue
283
+ existing_key = existing_first[:_SIM_PREVIEW_LEN]
284
+ ratio = difflib.SequenceMatcher(None, new_key, existing_key).ratio()
285
+ if has_supersedes:
286
+ ratio = min(1.0, ratio + 0.15)
287
+ if ratio >= threshold:
288
+ matches.append({
289
+ "entry_idx": entry_idx,
290
+ "similarity": round(ratio, 4),
291
+ "match_first_line": existing_first,
292
+ "match_full_text": entry_text,
293
+ "supersedes_signal_detected": has_supersedes,
294
+ })
295
+ matches.sort(key=lambda m: m["similarity"], reverse=True)
296
+ return matches
297
+
298
+
299
+ def _file_key_to_label(file_key):
300
+ """Same as _label() but standalone (avoids forward reference). Kept here
301
+ so _check_dedup_for_kind can emit the user-facing target_file string
302
+ before _label is defined later in the module."""
303
+ if file_key.startswith("repo_"):
304
+ return f"repo/{file_key[5:]}.md"
305
+ if file_key == "pending_promotion":
306
+ return "branch/pending-promotion.md"
307
+ return f"branch/{file_key}.md"
308
+
309
+
310
+ def _build_dedup_hint(kind, file_key, section_title, new_content, matches):
311
+ """Assemble the dedup_hint payload returned to the caller when a write is
312
+ blocked. Schema is part of the public CLI contract — additions OK,
313
+ renames/removals are breaking changes.
314
+
315
+ `recommendation` logic:
316
+ - explicit supersedes signal in new content → "update_existing"
317
+ - single match with very high similarity (≥ 0.9) → "update_existing"
318
+ - otherwise → "review_and_decide" (let agent inspect matches[])
319
+ """
320
+ preview = _first_nonempty_line(new_content)[:_SIM_PREVIEW_LEN]
321
+ has_supersedes = any(kw in new_content for kw in SUPERSEDES_KEYWORDS)
322
+ high_conf_single = len(matches) == 1 and matches[0]["similarity"] >= 0.9
323
+ if has_supersedes or high_conf_single:
324
+ recommendation = "update_existing"
325
+ else:
326
+ recommendation = "review_and_decide"
327
+
328
+ # Find the section_idx for each match so we can render full entry ids.
329
+ # The caller has already passed `section_title`; section_idx is derived
330
+ # later in _check_dedup_for_kind when it has the file context.
331
+ next_actions = [
332
+ "如果是修订旧条目: dev-memory-cli capture rewrite-entry --id <match_id> --content '<text>'",
333
+ "如果确实要再写一条: dev-memory-cli capture record --kind {kind} --content '<text>' --force".format(kind=kind),
334
+ "如果不写: 不调任何命令",
335
+ ]
336
+ return {
337
+ "blocked": True,
338
+ "reason": "similar_entry_exists",
339
+ "kind": kind,
340
+ "target_file": _file_key_to_label(file_key),
341
+ "section": section_title,
342
+ "new_content_preview": preview,
343
+ "matches": matches, # ids filled in by caller after section_idx lookup
344
+ "recommendation": recommendation,
345
+ "next_actions": next_actions,
346
+ }
347
+
348
+
349
+ def _resolve_section_idx(target_path, section_title):
350
+ """Return the 0-based section index of `section_title` in `target_path`,
351
+ or None if the file doesn't exist / section is absent. Used to construct
352
+ full entry ids (`<file_key>::<section_idx>::<entry_idx>`) for matches
353
+ surfaced via the dedup hint."""
354
+ if not target_path.exists():
355
+ return None
356
+ content = target_path.read_text(encoding="utf-8")
357
+ _, sections = split_sections(content)
358
+ target = section_title.strip()
359
+ for idx, (title, _body) in enumerate(sections):
360
+ if title.strip() == target:
361
+ return idx
362
+ return None
363
+
364
+
365
+ def _load_section_body(target_path, section_title):
366
+ """Return the body of `section_title` in `target_path` (empty string if
367
+ file missing / section absent)."""
368
+ if not target_path.exists():
369
+ return ""
370
+ content = target_path.read_text(encoding="utf-8")
371
+ _, sections = split_sections(content)
372
+ target = section_title.strip()
373
+ for title, body in sections:
374
+ if title.strip() == target:
375
+ return body
376
+ return ""
377
+
378
+
379
+ def _check_dedup_for_kind(paths, kind, body, force=False, title_override=None, threshold=None):
380
+ """Return a dedup_hint dict if the write should be blocked, else None.
381
+
382
+ Pure read; writes nothing. Skips:
383
+ - kinds whose default_mode is not "append" (upsert always overwrites,
384
+ dedup is meaningless there)
385
+ - force=True (explicit opt-out)
386
+ - kinds not in KIND_MAP (caller handles error elsewhere)
387
+ - empty/whitespace body (caller handles error elsewhere)
388
+
389
+ `threshold` (optional float in (0.0, 1.0]) overrides similarity_check's
390
+ default 0.7. None preserves default behavior — kept as the production
391
+ contract; the override is exposed only via the hidden CLI flag
392
+ `--dedup-threshold` for debugging / experiments. Validation of the range
393
+ is performed at the CLI layer (command_record) before reaching here, so
394
+ this helper trusts the caller's value when it's not None.
395
+
396
+ When matches are found, the returned hint includes each match's full
397
+ `<file_key>::<section_idx>::<entry_idx>` id so the agent can pass it
398
+ straight to `rewrite-entry --id <id>`.
399
+ """
400
+ if force:
401
+ return None
402
+ if not body or not body.strip():
403
+ return None
404
+ spec = KIND_MAP.get(kind)
405
+ if not spec:
406
+ return None
407
+ if spec.get("default_mode") != "append":
408
+ return None
409
+
410
+ file_key = spec["file"]
411
+ target_path = paths[file_key]
412
+ section_title = (title_override or spec["section"]).strip()
413
+ section_body = _load_section_body(target_path, section_title)
414
+ if threshold is None:
415
+ matches = similarity_check(body, section_body)
416
+ else:
417
+ matches = similarity_check(body, section_body, threshold=threshold)
418
+ if not matches:
419
+ return None
420
+
421
+ section_idx = _resolve_section_idx(target_path, section_title)
422
+ # Fill in full entry ids on each match. If section_idx is None (section
423
+ # somehow not found, but we got matches — implies an in-memory race or a
424
+ # bug), fall back to entry-idx-only ids so the agent at least sees the
425
+ # ordinal.
426
+ for m in matches:
427
+ if section_idx is None:
428
+ m["id"] = f"{file_key}::?::{m['entry_idx']}"
429
+ else:
430
+ m["id"] = f"{file_key}::{section_idx}::{m['entry_idx']}"
431
+
432
+ return _build_dedup_hint(kind, file_key, section_title, body, matches)
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # rewrite-entry primitives
437
+ # ---------------------------------------------------------------------------
438
+
439
+ def _parse_entry_id_local(eid):
440
+ """Parse `<file_key>::<section_idx>::<entry_idx>` into a tuple. Local copy
441
+ rather than importing tidy's private function — keeps capture's CLI
442
+ surface decoupled from tidy's internal refactor risk."""
443
+ if not isinstance(eid, str):
444
+ return None
445
+ parts = eid.split("::")
446
+ if len(parts) != 3:
447
+ return None
448
+ try:
449
+ return parts[0], int(parts[1]), int(parts[2])
450
+ except ValueError:
451
+ return None
452
+
453
+
454
+ def _replace_entry_at_index(body, target_idx, new_text):
455
+ """Replace the top-level bullet at ordinal `target_idx` in `body` with
456
+ `new_text`. Returns (new_body, previous_first_line) where
457
+ `previous_first_line` is the first line of the entry that got
458
+ overwritten (for caller's audit output).
459
+
460
+ Why this lives here and not in tidy: tidy's `_apply_actions_to_section`
461
+ takes an entry-action dict and processes the whole section; capture's
462
+ rewrite-entry only ever touches one entry, so a focused helper keeps the
463
+ code reviewable and skips tidy's keep/delete branching.
464
+
465
+ Multi-line `new_text` is rendered as a single bullet with continuation
466
+ lines indented two spaces — same shape `_apply_actions_to_section` uses
467
+ for edited entries, so the on-disk format stays consistent across both
468
+ write paths.
469
+ """
470
+ lines = body.splitlines()
471
+ out_lines = []
472
+ current_block_lines = None
473
+ current_idx = -1
474
+ counter = -1
475
+ found = False
476
+ previous_first_line = None
477
+
478
+ def render_new():
479
+ new_text_stripped = (new_text or "").strip()
480
+ if not new_text_stripped:
481
+ # Empty new_text would silently delete — disallow at this layer;
482
+ # caller validates before calling.
483
+ return []
484
+ first, *rest = new_text_stripped.split("\n")
485
+ out = ["- " + first.strip()]
486
+ for cont in rest:
487
+ out.append(" " + cont.strip())
488
+ return out
489
+
490
+ def flush():
491
+ nonlocal current_block_lines, current_idx, found, previous_first_line
492
+ if current_block_lines is None:
493
+ return
494
+ if current_idx == target_idx and not found:
495
+ # Capture previous first line before replacement for the audit
496
+ # output.
497
+ for ln in current_block_lines:
498
+ s = ln.strip()
499
+ if s.startswith("- "):
500
+ previous_first_line = s[2:].strip()
501
+ break
502
+ if s:
503
+ previous_first_line = s
504
+ break
505
+ out_lines.extend(render_new())
506
+ found = True
507
+ else:
508
+ out_lines.extend(current_block_lines)
509
+ current_block_lines = None
510
+ current_idx = -1
511
+
512
+ for raw in lines:
513
+ stripped = raw.strip()
514
+ if stripped.startswith("- ") and not (raw.startswith(" ") or raw.startswith("\t")):
515
+ flush()
516
+ counter += 1
517
+ current_idx = counter
518
+ current_block_lines = [raw]
519
+ elif current_block_lines is not None and stripped and (raw.startswith(" ") or raw.startswith("\t")):
520
+ current_block_lines.append(raw)
521
+ elif not stripped:
522
+ flush()
523
+ out_lines.append(raw)
524
+ else:
525
+ # Free-form prose between bullets — pass through.
526
+ flush()
527
+ out_lines.append(raw)
528
+ flush()
529
+
530
+ # Trim trailing blank lines.
531
+ while out_lines and not out_lines[-1].strip():
532
+ out_lines.pop()
533
+
534
+ return "\n".join(out_lines).strip(), previous_first_line, found
535
+
536
+
537
+ # v2 KIND_MAP. `default_mode` governs whether a new entry accumulates
538
+ # (append) or replaces the whole section (upsert). Accumulation fits
539
+ # "decisions", "risks", "glossary" where each entry is independent and
540
+ # historically meaningful; upsert fits "progress/next/overview" where the
541
+ # section represents *latest state* and older content is stale by design.
542
+ KIND_MAP = {
543
+ # accumulation (each entry stands alone, new ones add to the list)
544
+ "decision": {"file": "decisions", "section": "关键决策与原因", "default_mode": "append"},
545
+ "risk": {"file": "risks", "section": "阻塞与注意点", "default_mode": "append"},
546
+ "glossary": {"file": "glossary", "section": "当前有效上下文", "default_mode": "append"},
547
+ "source": {"file": "glossary", "section": "分支源资料入口", "default_mode": "append"},
548
+ # snapshot (section always reflects the latest state; new write replaces)
549
+ "progress": {"file": "progress", "section": "当前进展", "default_mode": "upsert"},
550
+ "next": {"file": "progress", "section": "下一步", "default_mode": "upsert"},
551
+ "overview": {"file": "overview", "section": "当前目标", "default_mode": "upsert"},
552
+ "scope": {"file": "overview", "section": "范围边界", "default_mode": "upsert"},
553
+ "stage": {"file": "overview", "section": "当前阶段", "default_mode": "upsert"},
554
+ "constraint": {"file": "overview", "section": "关键约束", "default_mode": "upsert"},
555
+ # repo-shared: decisions/context/source accumulate, overview/constraint snapshot
556
+ "shared-decision": {"file": "repo_decisions", "section": "跨分支通用决策", "default_mode": "append"},
557
+ "shared-context": {"file": "repo_glossary", "section": "长期有效背景", "default_mode": "append"},
558
+ "shared-source": {"file": "repo_glossary", "section": "共享入口", "default_mode": "append"},
559
+ "shared-overview": {"file": "repo_overview", "section": "长期目标与边界", "default_mode": "upsert"},
560
+ "shared-constraint": {"file": "repo_overview", "section": "仓库级关键约束", "default_mode": "upsert"},
561
+ # fallback bins always accumulate
562
+ "unsorted": {"file": "unsorted", "section": "待分类", "default_mode": "append"},
563
+ "pending": {"file": "pending_promotion", "section": "候选条目", "default_mode": "append"},
564
+ }
565
+
566
+ # Session-payload → kind mapping, used by `record --summary-json`. Each entry
567
+ # is (payload_key, kind, optional_transform). Several keys may route to the
568
+ # same kind; that's fine — the write layer upserts.
569
+ SESSION_PAYLOAD_MAP = [
570
+ ("overview_summary", "overview", None),
571
+ ("implementation_notes", "progress", None),
572
+ ("changes", "progress", None),
573
+ ("next_steps", "next", None),
574
+ ("risks", "risk", None),
575
+ ("memory", "glossary", None),
576
+ ("context_updates", "glossary", None),
577
+ ("review_notes", "decision", None),
578
+ ("frontend_updates", "glossary", "前端相关"),
579
+ ("backend_updates", "glossary", "后端相关"),
580
+ ("test_updates", "glossary", "测试相关"),
581
+ ("sources", "shared-source", None),
582
+ ("source_updates", "shared-source", None),
583
+ ]
584
+
585
+
586
+ def normalize_items(items):
587
+ if items is None:
588
+ return []
589
+ if isinstance(items, str):
590
+ stripped = items.strip()
591
+ return [stripped] if stripped else []
592
+ return [str(item).strip() for item in items if str(item).strip()]
593
+
594
+
595
+ def bullets(items, empty_text="- 待补充", wrap_code=False):
596
+ return render_bullets(normalize_items(items), empty_text=empty_text, wrap_code=wrap_code)
597
+
598
+
599
+ def decision_body(item):
600
+ parts = [f"- 结论: {item['decision']}"]
601
+ if item.get("reason"):
602
+ parts.append(f"- 原因: {item['reason']}")
603
+ if item.get("impact"):
604
+ parts.append(f"- 影响范围: {item['impact']}")
605
+ return "\n".join(parts)
606
+
607
+
608
+ # ---------------------------------------------------------------------------
609
+ # Write primitives
610
+ # ---------------------------------------------------------------------------
611
+
612
+ def _resolve_target(paths, kind, title_override=None):
613
+ """Return (target_path, section_title) for a given kind. Raises on
614
+ unknown kind so the caller can surface a clear error."""
615
+ spec = KIND_MAP.get(kind)
616
+ if not spec:
617
+ raise RuntimeError(f"unsupported kind: {kind}")
618
+ target_path = paths[spec["file"]]
619
+ section_title = (title_override or spec["section"]).strip()
620
+ return spec["file"], target_path, section_title
621
+
622
+
623
+ def _write_one(paths, kind, body, title_override=None, *, mode_override=None):
624
+ """Write a body into the kind's target file. Picks append vs upsert from
625
+ KIND_MAP[kind].default_mode unless overridden. progress.md uses a
626
+ dedicated upsert that preserves the auto-sync marker.
627
+
628
+ When appending multi-line bodies, a blank line is inserted between the
629
+ existing content and the new entry so each entry reads as its own unit.
630
+ """
631
+ file_key, target_path, section_title = _resolve_target(paths, kind, title_override)
632
+ spec = KIND_MAP.get(kind) or {}
633
+ mode = mode_override or spec.get("default_mode", "upsert")
634
+
635
+ if mode == "append":
636
+ # Auto-prefix a bullet if the body isn't already one — accumulation
637
+ # sections are bullet lists by convention. The separator helper
638
+ # handles the blank-line spacing so entries remain visually distinct.
639
+ body_to_write = body.strip()
640
+ if not body_to_write.startswith(("- ", "* ", "#")):
641
+ body_to_write = "- " + body_to_write
642
+ _append_with_separator(target_path, section_title, body_to_write)
643
+ else:
644
+ if file_key == "progress":
645
+ upsert_progress_section(target_path, section_title, body)
646
+ else:
647
+ upsert_markdown_section(target_path, section_title, body)
648
+ return {"file": _label(file_key), "section": section_title, "mode": mode}
649
+
650
+
651
+ def _label(file_key):
652
+ if file_key.startswith("repo_"):
653
+ return f"repo/{file_key[5:]}.md"
654
+ if file_key == "pending_promotion":
655
+ return "branch/pending-promotion.md"
656
+ return f"branch/{file_key}.md"
657
+
658
+
659
+ def _maybe_stage_pending(paths, body, branch_name):
660
+ """If body looks cross-branch-reusable, append a copy to pending-promotion
661
+ with a lightweight marker. Returns the touch record or None.
662
+ """
663
+ if not is_cross_branch_candidate(body, branch_name):
664
+ return None
665
+ # Append rather than upsert — pending should accumulate candidates, not
666
+ # overwrite.
667
+ entry = f"- {now_iso()[:10]}: {body.strip().splitlines()[0][:160]}"
668
+ # Keep full text as a nested detail so graduate has the original.
669
+ full = f"{entry}\n - 原文: {body.strip().replace(chr(10), ' / ')[:500]}"
670
+ append_to_section(paths["pending_promotion"], "候选条目", full)
671
+ return {"file": "branch/pending-promotion.md", "section": "候选条目", "mode": "append-candidate"}
672
+
673
+
674
+ # ---------------------------------------------------------------------------
675
+ # Content loaders
676
+ # ---------------------------------------------------------------------------
677
+
678
+ def _load_optional_text(value, file_path=None):
679
+ if value:
680
+ stripped = value.strip()
681
+ return stripped or None
682
+ if file_path:
683
+ return (Path(file_path).read_text(encoding="utf-8").strip()) or None
684
+ return None
685
+
686
+
687
+ def _load_free_content(args):
688
+ """Load content for single-kind writes. Supports:
689
+ --content / --content-file : inline body
690
+ --summary / --summary-file : session-derived summary (prefixed)
691
+ --user-input / --user-input-file : user turn (prefixed)
692
+ When both summary and user-input are given, they're combined; inline
693
+ content becomes an additional 补充备注 section.
694
+ """
695
+ inline = _load_optional_text(args.content, getattr(args, "content_file", None))
696
+ summary = _load_optional_text(getattr(args, "summary", None), getattr(args, "summary_file", None))
697
+ user_input = _load_optional_text(getattr(args, "user_input", None), getattr(args, "user_input_file", None))
698
+
699
+ if summary or user_input:
700
+ blocks = []
701
+ if user_input:
702
+ blocks.append(f"### 用户这次输入\n\n{user_input}")
703
+ if summary:
704
+ blocks.append(f"### 基于当前会话整理\n\n{summary}")
705
+ if inline:
706
+ blocks.append(f"### 补充备注\n\n{inline}")
707
+ return "\n\n".join(blocks), "session+input"
708
+ if inline:
709
+ return inline, "content-only"
710
+ return None, None
711
+
712
+
713
+ # ---------------------------------------------------------------------------
714
+ # Commands
715
+ # ---------------------------------------------------------------------------
716
+
717
+ def command_show(args):
718
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
719
+ args.repo, args.context_dir, args.branch
720
+ )
721
+ payload = {
722
+ "repo_root": str(repo_root),
723
+ "repo_key": repo_key,
724
+ "branch": branch_name,
725
+ "branch_key": branch_key,
726
+ "storage_root": str(storage_root),
727
+ "repo_dir": str(repo_dir),
728
+ "branch_dir": str(branch_dir),
729
+ "setup_completed": get_setup_completed(paths["manifest"]),
730
+ "files": {key: str(value) for key, value in paths.items()},
731
+ "missing_or_placeholder": list_missing_docs(paths),
732
+ }
733
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
734
+ return 0
735
+
736
+
737
+ def command_suggest_kind(args):
738
+ content = _load_optional_text(args.content, args.content_file)
739
+ if not content:
740
+ raise RuntimeError("one of --content / --content-file is required")
741
+ already_setup = bool(args.already_setup)
742
+ kind = classify_content(content, already_setup=already_setup)
743
+ spec = KIND_MAP.get(kind, {"file": "unsorted", "section": "待分类"})
744
+ branch_name = args.branch_name or ""
745
+ cross_branch = is_cross_branch_candidate(content, branch_name) if branch_name else None
746
+ print(
747
+ json.dumps(
748
+ {
749
+ "kind": kind,
750
+ "target_file": _label(spec["file"]),
751
+ "section": spec["section"],
752
+ "cross_branch_candidate": cross_branch,
753
+ "already_setup": already_setup,
754
+ },
755
+ ensure_ascii=False,
756
+ indent=2,
757
+ )
758
+ )
759
+ return 0
760
+
761
+
762
+ def command_classify(args):
763
+ # Like suggest-kind but always resolves through the lazy-init pipeline so
764
+ # the caller also gets the computed paths (useful for the capture skill's
765
+ # inner loop).
766
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
767
+ args.repo, args.context_dir, args.branch
768
+ )
769
+ content = _load_optional_text(args.content, args.content_file)
770
+ if not content:
771
+ raise RuntimeError("one of --content / --content-file is required")
772
+ already_setup = get_setup_completed(paths["manifest"])
773
+ kind = classify_content(content, already_setup=already_setup)
774
+ spec = KIND_MAP.get(kind, KIND_MAP["unsorted"])
775
+ cross_branch = is_cross_branch_candidate(content, branch_name or "")
776
+ print(
777
+ json.dumps(
778
+ {
779
+ "branch": branch_name,
780
+ "already_setup": already_setup,
781
+ "kind": kind,
782
+ "target_file": _label(spec["file"]),
783
+ "section": spec["section"],
784
+ "cross_branch_candidate": cross_branch,
785
+ },
786
+ ensure_ascii=False,
787
+ indent=2,
788
+ )
789
+ )
790
+ return 0
791
+
792
+
793
+ def command_record(args):
794
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
795
+ args.repo, args.context_dir, args.branch
796
+ )
797
+ already_setup = get_setup_completed(paths["manifest"])
798
+ force = bool(getattr(args, "force", False))
799
+
800
+ # Hidden --dedup-threshold override. Validated here so the error surface
801
+ # is consistent with other record-time failures (RuntimeError → exit 1 +
802
+ # error JSON via main's try/except). None means "use similarity_check
803
+ # default" — the production path.
804
+ dedup_threshold = getattr(args, "dedup_threshold", None)
805
+ if dedup_threshold is not None:
806
+ if not isinstance(dedup_threshold, (int, float)):
807
+ raise RuntimeError(
808
+ f"--dedup-threshold must be a number, got {type(dedup_threshold).__name__}"
809
+ )
810
+ if not (0.0 < dedup_threshold <= 1.0):
811
+ raise RuntimeError(
812
+ f"--dedup-threshold must be in (0.0, 1.0], got {dedup_threshold}"
813
+ )
814
+
815
+ touched = []
816
+ dedup_blocked = [] # entries that were stopped by dedup check (batch mode)
817
+ mode_used = None
818
+
819
+ # Mode 1: batch session payload (merges old sync record-session).
820
+ payload = None
821
+ if args.summary_json:
822
+ payload = json.loads(args.summary_json)
823
+ elif args.summary_file:
824
+ payload = json.loads(Path(args.summary_file).read_text(encoding="utf-8"))
825
+
826
+ if payload:
827
+ mode_used = "session-payload"
828
+ title = payload.get("title") or "checkpoint"
829
+
830
+ def _write_or_block(kind, body, title_override=None, source_label=None):
831
+ """Run dedup check; if blocked, push onto dedup_blocked; else write.
832
+ `source_label` is a free-form tag (e.g. "decisions[0]") so the
833
+ caller can correlate the blocked entry back to its payload slot.
834
+ """
835
+ hint = _check_dedup_for_kind(
836
+ paths, kind, body, force=force,
837
+ title_override=title_override, threshold=dedup_threshold,
838
+ )
839
+ if hint is not None:
840
+ dedup_blocked.append({
841
+ "source": source_label,
842
+ "kind": kind,
843
+ "content_preview": _first_nonempty_line(body)[:_SIM_PREVIEW_LEN],
844
+ "dedup_hint": hint,
845
+ })
846
+ return None
847
+ return _write_one(paths, kind, body, title_override=title_override)
848
+
849
+ # Simple scalar mappings.
850
+ for key, kind, subsection_title in SESSION_PAYLOAD_MAP:
851
+ items = normalize_items(payload.get(key))
852
+ if not items:
853
+ continue
854
+ body = bullets(items) if not subsection_title else f"### {subsection_title}\n\n{bullets(items)}"
855
+ rec = _write_or_block(kind, body, source_label=f"payload[{key}]")
856
+ if rec is not None:
857
+ touched.append(rec)
858
+ # Cross-branch staging — apply to each item separately for best recall.
859
+ for item in items:
860
+ pending = _maybe_stage_pending(paths, item, branch_name or "")
861
+ if pending:
862
+ touched.append(pending)
863
+
864
+ # Risks also go into the 后续继续前要注意 section (same as v1 behavior).
865
+ risk_items = normalize_items(payload.get("risks"))
866
+ if risk_items:
867
+ rec = _write_or_block(
868
+ "risk",
869
+ bullets(risk_items),
870
+ title_override="后续继续前要注意",
871
+ source_label="payload[risks→后续继续前要注意]",
872
+ )
873
+ if rec is not None:
874
+ touched.append(rec)
875
+
876
+ # Structured decisions (decision/reason/impact trios).
877
+ decision_items = [decision_body(item) for item in (payload.get("decisions") or []) if item.get("decision")]
878
+ if decision_items:
879
+ body = "\n\n".join(decision_items)
880
+ rec = _write_or_block("decision", body, source_label="payload[decisions]")
881
+ if rec is not None:
882
+ touched.append(rec)
883
+ for item in payload.get("decisions") or []:
884
+ if item.get("decision"):
885
+ pending = _maybe_stage_pending(paths, item["decision"], branch_name or "")
886
+ if pending:
887
+ touched.append(pending)
888
+
889
+ extra_manifest = {"last_session_sync_title": title, "last_session_sync_mode": "capture-session"}
890
+
891
+ else:
892
+ # Mode 2/3: free-form content with kind (explicit or auto-classify).
893
+ content, update_mode = _load_free_content(args)
894
+ if content is None:
895
+ raise RuntimeError(
896
+ "provide one of: --summary-json/-file (batch), --kind+--content (targeted), "
897
+ "or --auto --content (heuristic classify)"
898
+ )
899
+
900
+ kind = args.kind.lower() if args.kind else None
901
+ if args.auto or not kind:
902
+ classified = classify_content(content, already_setup=already_setup)
903
+ kind = kind or classified
904
+ mode_used = f"auto-classified-{classified}"
905
+ else:
906
+ mode_used = "explicit-kind"
907
+
908
+ # Dedup pre-check: if blocked, do not write — return hint and exit
909
+ # with code 2 so callers can distinguish "blocked, take action" from
910
+ # "actual error" (exit 1).
911
+ hint = _check_dedup_for_kind(
912
+ paths, kind, content, force=force,
913
+ title_override=args.title, threshold=dedup_threshold,
914
+ )
915
+ if hint is not None:
916
+ print(json.dumps({
917
+ "blocked": True,
918
+ "repo_root": str(repo_root),
919
+ "repo_key": repo_key,
920
+ "branch": branch_name,
921
+ "storage_root": str(storage_root),
922
+ "branch_dir": str(branch_dir),
923
+ "mode": mode_used,
924
+ "kind": kind,
925
+ "dedup_hint": hint,
926
+ }, ensure_ascii=False, indent=2))
927
+ return 2
928
+
929
+ touched.append(_write_one(paths, kind, content, title_override=args.title))
930
+ rec = _maybe_stage_pending(paths, content, branch_name or "")
931
+ if rec:
932
+ touched.append(rec)
933
+
934
+ extra_manifest = {"last_capture_kind": kind, "last_capture_mode": mode_used, "last_capture_update_mode": update_mode}
935
+
936
+ # Manifest bookkeeping.
937
+ manifest = read_json(paths["manifest"])
938
+ manifest.update(
939
+ {
940
+ "repo_root": str(repo_root),
941
+ "repo_key": repo_key,
942
+ "branch": branch_name,
943
+ "branch_key": branch_key,
944
+ "storage_root": str(storage_root),
945
+ "updated_at": now_iso(),
946
+ "last_seen_head": get_head_commit(repo_root) if repo_root else None,
947
+ }
948
+ )
949
+ manifest.update(extra_manifest)
950
+ manifest["last_capture_targets"] = touched
951
+ write_json(paths["manifest"], manifest)
952
+
953
+ repo_manifest = read_json(paths["repo_manifest"])
954
+ repo_manifest.update(
955
+ {
956
+ "repo_root": str(repo_root),
957
+ "repo_key": repo_key,
958
+ "storage_root": str(storage_root),
959
+ "updated_at": manifest["updated_at"],
960
+ "last_seen_branch": branch_name,
961
+ "last_seen_head": manifest["last_seen_head"],
962
+ }
963
+ )
964
+ write_json(paths["repo_manifest"], repo_manifest)
965
+
966
+ # Event log: one row per record call. Skip when nothing was actually
967
+ # written (e.g. batch mode where every entry was dedup-blocked) — the log
968
+ # would be a misleading "we did something" signal.
969
+ if touched:
970
+ if payload:
971
+ log_kind = "session-payload"
972
+ log_summary = payload.get("title") or f"{len(touched)} target(s)"
973
+ else:
974
+ log_kind = manifest.get("last_capture_kind") or "auto"
975
+ log_summary = (
976
+ content if 'content' in locals() and content else f"{len(touched)} target(s)"
977
+ )
978
+ extra = []
979
+ if dedup_blocked:
980
+ extra.append(("blocked", len(dedup_blocked)))
981
+ _emit_capture_log(
982
+ paths,
983
+ action="capture",
984
+ kind_label=log_kind,
985
+ summary=log_summary,
986
+ touched=touched,
987
+ extra_details=extra,
988
+ )
989
+
990
+ output = {
991
+ "repo_root": str(repo_root),
992
+ "repo_key": repo_key,
993
+ "branch": branch_name,
994
+ "storage_root": str(storage_root),
995
+ "repo_dir": str(repo_dir),
996
+ "branch_dir": str(branch_dir),
997
+ "mode": mode_used,
998
+ "setup_completed": already_setup,
999
+ "touched_targets": touched,
1000
+ "updated_at": manifest["updated_at"],
1001
+ }
1002
+ if dedup_blocked:
1003
+ # Batch mode: surface blocked items but still report what was written.
1004
+ output["dedup_blocked"] = dedup_blocked
1005
+ print(json.dumps(output, ensure_ascii=False, indent=2))
1006
+ # Batch with any blocked entries → exit 2 so caller knows to handle them.
1007
+ return 2 if dedup_blocked else 0
1008
+
1009
+
1010
+ def command_rewrite_entry(args):
1011
+ """Replace a single existing entry by id with new content.
1012
+
1013
+ Why this is a subcommand rather than a flag on `record`: rewrite-entry's
1014
+ inputs (id + new content) don't overlap with record's classify/auto/
1015
+ session-payload surface, and keeping it separate makes the dedup-driven
1016
+ "update existing instead of append" workflow read clearly in CLI logs.
1017
+ """
1018
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1019
+ args.repo, args.context_dir, args.branch
1020
+ )
1021
+
1022
+ eid = (args.id or "").strip()
1023
+ parsed = _parse_entry_id_local(eid)
1024
+ if not parsed:
1025
+ print(json.dumps({
1026
+ "error": f"malformed entry id: {eid!r}",
1027
+ "expected_format": "<file_key>::<section_idx>::<entry_idx>",
1028
+ }, ensure_ascii=False, indent=2), file=sys.stderr)
1029
+ return 1
1030
+ file_key, section_idx, entry_idx = parsed
1031
+ if file_key not in paths:
1032
+ print(json.dumps({
1033
+ "error": f"unknown file_key {file_key!r}",
1034
+ "available_file_keys": sorted(paths.keys()),
1035
+ }, ensure_ascii=False, indent=2), file=sys.stderr)
1036
+ return 1
1037
+
1038
+ new_text = _load_optional_text(args.content, args.content_file)
1039
+ if not new_text:
1040
+ print(json.dumps({"error": "one of --content / --content-file is required"}, ensure_ascii=False, indent=2), file=sys.stderr)
1041
+ return 1
1042
+
1043
+ target_path = paths[file_key]
1044
+ if not target_path.exists():
1045
+ print(json.dumps({
1046
+ "error": f"file does not exist: {target_path}",
1047
+ "file_key": file_key,
1048
+ }, ensure_ascii=False, indent=2), file=sys.stderr)
1049
+ return 1
1050
+
1051
+ content = target_path.read_text(encoding="utf-8")
1052
+ prefix, sections = split_sections(content)
1053
+ if section_idx < 0 or section_idx >= len(sections):
1054
+ print(json.dumps({
1055
+ "error": f"section_idx {section_idx} out of range (file has {len(sections)} sections)",
1056
+ "id": eid,
1057
+ }, ensure_ascii=False, indent=2), file=sys.stderr)
1058
+ return 1
1059
+
1060
+ section_title, section_body = sections[section_idx]
1061
+
1062
+ # Preserve any AUTO-GENERATED block: rewrite only the body before
1063
+ # AUTO_START, then re-attach the block as-is. Matches tidy's behavior so
1064
+ # progress.md auto-blocks survive rewrite-entry calls.
1065
+ if AUTO_START in section_body and AUTO_END in section_body:
1066
+ head_end = section_body.index(AUTO_START)
1067
+ head = section_body[:head_end].rstrip()
1068
+ tail = section_body[head_end:]
1069
+ new_head, previous_first_line, found = _replace_entry_at_index(head, entry_idx, new_text)
1070
+ new_body = (new_head + "\n\n" + tail).strip() if new_head else tail.strip()
1071
+ else:
1072
+ new_body, previous_first_line, found = _replace_entry_at_index(section_body, entry_idx, new_text)
1073
+
1074
+ if not found:
1075
+ print(json.dumps({
1076
+ "error": f"entry_idx {entry_idx} not found in section {section_idx!r}",
1077
+ "id": eid,
1078
+ }, ensure_ascii=False, indent=2), file=sys.stderr)
1079
+ return 1
1080
+
1081
+ new_sections = list(sections)
1082
+ new_sections[section_idx] = (section_title, new_body)
1083
+ target_path.write_text(join_sections(prefix, new_sections), encoding="utf-8")
1084
+
1085
+ # Manifest bookkeeping (same shape as record).
1086
+ manifest = read_json(paths["manifest"])
1087
+ updated_at = now_iso()
1088
+ manifest.update(
1089
+ {
1090
+ "repo_root": str(repo_root),
1091
+ "repo_key": repo_key,
1092
+ "branch": branch_name,
1093
+ "branch_key": branch_key,
1094
+ "storage_root": str(storage_root),
1095
+ "updated_at": updated_at,
1096
+ "last_seen_head": get_head_commit(repo_root) if repo_root else None,
1097
+ "last_capture_kind": None,
1098
+ "last_capture_mode": "rewrite-entry",
1099
+ "last_capture_update_mode": "rewrite-entry",
1100
+ "last_capture_targets": [{
1101
+ "file": _label(file_key),
1102
+ "section": section_title,
1103
+ "mode": "rewrite-entry",
1104
+ "id": eid,
1105
+ }],
1106
+ }
1107
+ )
1108
+ write_json(paths["manifest"], manifest)
1109
+
1110
+ new_first_line = _first_nonempty_line(_strip_bullet_prefix(_first_nonempty_line(new_text)))
1111
+
1112
+ _emit_capture_log(
1113
+ paths,
1114
+ action="rewrite-entry",
1115
+ kind_label=file_key,
1116
+ summary=new_first_line,
1117
+ touched=[{"file": _label(file_key), "section": section_title, "mode": "rewrite-entry"}],
1118
+ extra_details=[("id", eid), ("previous", previous_first_line)],
1119
+ )
1120
+
1121
+ print(json.dumps({
1122
+ "mode": "rewrite-entry",
1123
+ "id": eid,
1124
+ "file": _label(file_key),
1125
+ "section": section_title,
1126
+ "previous_first_line": previous_first_line,
1127
+ "new_first_line": new_first_line,
1128
+ "updated_at": updated_at,
1129
+ }, ensure_ascii=False, indent=2))
1130
+ return 0
1131
+
1132
+
1133
+ def command_sync_working_tree(args):
1134
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1135
+ args.repo, args.context_dir, args.branch
1136
+ )
1137
+ if branch_name is None:
1138
+ # no-git mode — no git facts to derive.
1139
+ print(json.dumps({"mode": "no-git", "skipped": True}, ensure_ascii=False))
1140
+ return 0
1141
+
1142
+ prior_manifest = read_json(paths["manifest"]) or {}
1143
+ facts = collect_git_facts(repo_root, branch_name, storage_root)
1144
+ all_paths = sorted(set(
1145
+ facts["working_tree_files"]
1146
+ + facts["staged_files"]
1147
+ + facts["untracked_files"]
1148
+ + facts["recent_commit_files"]
1149
+ ))
1150
+ facts["focus_areas"] = merged_focus_areas(all_paths, prior_manifest.get("focus_areas") or [])
1151
+ sync_progress(paths, facts)
1152
+
1153
+ manifest = read_json(paths["manifest"])
1154
+ manifest.update(
1155
+ {
1156
+ "repo_root": str(repo_root),
1157
+ "repo_key": repo_key,
1158
+ "branch": branch_name,
1159
+ "branch_key": branch_key,
1160
+ "storage_root": str(storage_root),
1161
+ "updated_at": facts["updated_at"],
1162
+ "last_seen_head": facts["last_seen_head"],
1163
+ "default_base": facts["default_base"],
1164
+ "scope_summary": facts["scope_summary"],
1165
+ "focus_areas": facts["focus_areas"],
1166
+ }
1167
+ )
1168
+ write_json(paths["manifest"], manifest)
1169
+
1170
+ repo_manifest = read_json(paths["repo_manifest"])
1171
+ repo_manifest.update(
1172
+ {
1173
+ "repo_root": str(repo_root),
1174
+ "repo_key": repo_key,
1175
+ "storage_root": str(storage_root),
1176
+ "updated_at": facts["updated_at"],
1177
+ "last_seen_branch": branch_name,
1178
+ "last_seen_head": facts["last_seen_head"],
1179
+ "default_base": facts["default_base"],
1180
+ }
1181
+ )
1182
+ write_json(paths["repo_manifest"], repo_manifest)
1183
+
1184
+ print(
1185
+ json.dumps(
1186
+ {
1187
+ "repo_root": str(repo_root),
1188
+ "repo_key": repo_key,
1189
+ "branch": branch_name,
1190
+ "storage_root": str(storage_root),
1191
+ "repo_dir": str(repo_dir),
1192
+ "branch_dir": str(branch_dir),
1193
+ "mode": "sync-working-tree",
1194
+ "focus_areas": manifest["focus_areas"],
1195
+ "files_considered": len(all_paths),
1196
+ },
1197
+ ensure_ascii=False,
1198
+ indent=2,
1199
+ )
1200
+ )
1201
+ return 0
1202
+
1203
+
1204
+ def command_record_head(args):
1205
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1206
+ args.repo, args.context_dir, args.branch
1207
+ )
1208
+ manifest = read_json(paths["manifest"])
1209
+ head = args.commit or (get_head_commit(repo_root) if repo_root else None)
1210
+ manifest.update(
1211
+ {
1212
+ "repo_root": str(repo_root),
1213
+ "repo_key": repo_key,
1214
+ "branch": branch_name,
1215
+ "branch_key": branch_key,
1216
+ "storage_root": str(storage_root),
1217
+ "updated_at": now_iso(),
1218
+ "last_seen_head": head,
1219
+ }
1220
+ )
1221
+ write_json(paths["manifest"], manifest)
1222
+
1223
+ repo_manifest = read_json(paths["repo_manifest"])
1224
+ repo_manifest.update(
1225
+ {
1226
+ "repo_root": str(repo_root),
1227
+ "repo_key": repo_key,
1228
+ "storage_root": str(storage_root),
1229
+ "updated_at": manifest["updated_at"],
1230
+ "last_seen_branch": branch_name,
1231
+ "last_seen_head": head,
1232
+ }
1233
+ )
1234
+ write_json(paths["repo_manifest"], repo_manifest)
1235
+
1236
+ print(
1237
+ json.dumps(
1238
+ {
1239
+ "repo_root": str(repo_root),
1240
+ "repo_key": repo_key,
1241
+ "branch": branch_name,
1242
+ "storage_root": str(storage_root),
1243
+ "repo_dir": str(repo_dir),
1244
+ "branch_dir": str(branch_dir),
1245
+ "mode": "record-head",
1246
+ "last_seen_head": head,
1247
+ },
1248
+ ensure_ascii=False,
1249
+ indent=2,
1250
+ )
1251
+ )
1252
+ return 0
1253
+
1254
+
1255
+ def _add_common_args(parser):
1256
+ parser.add_argument("--repo", default=".", help="Path inside the target Git repository")
1257
+ parser.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
1258
+ parser.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
1259
+
1260
+
1261
+ def main():
1262
+ parser = argparse.ArgumentParser(
1263
+ description="Unified write entrypoint for dev-memory repo+branch memory (merges old sync + update).",
1264
+ )
1265
+ subparsers = parser.add_subparsers(dest="command", required=True)
1266
+
1267
+ show = subparsers.add_parser("show", help="Show current paths + missing docs")
1268
+ _add_common_args(show)
1269
+
1270
+ suggest = subparsers.add_parser("suggest-kind", help="Dry-run classify a content string")
1271
+ suggest.add_argument("--content", help="Inline content to classify")
1272
+ suggest.add_argument("--content-file", help="File containing content to classify")
1273
+ suggest.add_argument("--already-setup", action="store_true", help="Hint that setup is completed (shifts default from unsorted to progress)")
1274
+ suggest.add_argument("--branch-name", help="Branch name for cross-branch candidate check")
1275
+
1276
+ classify = subparsers.add_parser("classify", help="Classify content against the current branch context")
1277
+ _add_common_args(classify)
1278
+ classify.add_argument("--content", help="Inline content to classify")
1279
+ classify.add_argument("--content-file", help="File containing content to classify")
1280
+
1281
+ record = subparsers.add_parser("record", help="Write content into one or more sections")
1282
+ _add_common_args(record)
1283
+ record.add_argument("--kind", help=f"Explicit kind. One of: {', '.join(sorted(KIND_MAP.keys()))}")
1284
+ record.add_argument("--auto", action="store_true", help="Ignore --kind; run classifier on content")
1285
+ record.add_argument("--title", help="Override the default section title")
1286
+ record.add_argument("--content", help="Inline markdown content")
1287
+ record.add_argument("--content-file", help="File containing markdown content")
1288
+ record.add_argument("--summary", help="Session-derived summary")
1289
+ record.add_argument("--summary-file", help="File with a session-derived summary")
1290
+ record.add_argument("--user-input", help="Latest user input to store alongside summary")
1291
+ record.add_argument("--user-input-file", help="File containing the latest user input")
1292
+ record.add_argument("--summary-json", help="Inline JSON session payload (batch record)")
1293
+ record.add_argument(
1294
+ "--force",
1295
+ action="store_true",
1296
+ help="Skip dedup check and append unconditionally (use after reviewing matches[] in a prior blocked response)",
1297
+ )
1298
+ # Hidden debugging/experiment flag: override similarity_check's default
1299
+ # 0.7 threshold. Range (0.0, 1.0]; outside that command_record raises
1300
+ # → exit 1 + error JSON. Not in --help on purpose (argparse.SUPPRESS) —
1301
+ # production callers shouldn't need to touch this.
1302
+ record.add_argument(
1303
+ "--dedup-threshold",
1304
+ type=float,
1305
+ default=None,
1306
+ help=argparse.SUPPRESS,
1307
+ )
1308
+
1309
+ rewrite = subparsers.add_parser(
1310
+ "rewrite-entry",
1311
+ help="Replace an existing entry by id with new content (use after dedup hint suggests update_existing)",
1312
+ )
1313
+ _add_common_args(rewrite)
1314
+ rewrite.add_argument("--id", required=True, help="Entry id in the form <file_key>::<section_idx>::<entry_idx>")
1315
+ rewrite.add_argument("--content", help="Inline markdown content to replace the entry with")
1316
+ rewrite.add_argument("--content-file", help="File containing replacement markdown content")
1317
+
1318
+ sync = subparsers.add_parser("sync-working-tree", help="Refresh progress.md auto-sync block from git facts")
1319
+ _add_common_args(sync)
1320
+
1321
+ head = subparsers.add_parser("record-head", help="Stamp last_seen_head onto branch+repo manifests")
1322
+ _add_common_args(head)
1323
+ head.add_argument("--commit", help="Explicit commit sha to record (defaults to HEAD)")
1324
+
1325
+ args = parser.parse_args()
1326
+ try:
1327
+ handlers = {
1328
+ "show": command_show,
1329
+ "suggest-kind": command_suggest_kind,
1330
+ "classify": command_classify,
1331
+ "record": command_record,
1332
+ "rewrite-entry": command_rewrite_entry,
1333
+ "sync-working-tree": command_sync_working_tree,
1334
+ "record-head": command_record_head,
1335
+ }
1336
+ return handlers[args.command](args)
1337
+ except Exception as exc:
1338
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
1339
+ return 1
1340
+
1341
+
1342
+ if __name__ == "__main__":
1343
+ raise SystemExit(main())