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,1934 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import time
10
+ import uuid
11
+ from collections import Counter
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from urllib.parse import urlparse
15
+
16
+
17
+ DEFAULT_STORAGE_ROOT = Path.home() / ".dev-memory" / "repos"
18
+ DEFAULT_LEGACY_CONTEXT_DIR = ".dev-memory"
19
+ DEV_MEMORY_ID_FILE = ".dev-memory-id"
20
+
21
+ # Backward-compat: previous package name was `dev-assets`. We still recognize
22
+ # the old storage root, marker file, in-repo dir, and git config keys for one
23
+ # release so users can migrate without losing data. Migration script will move
24
+ # these forward; until then, reads silently fall through to the legacy paths.
25
+ LEGACY_STORAGE_ROOT = Path.home() / ".dev-assets" / "repos"
26
+ LEGACY_CONTEXT_DIR = ".dev-assets"
27
+ LEGACY_ID_FILE = ".dev-assets-id"
28
+ LEGACY_GIT_CONFIG_PREFIX = "dev-assets"
29
+ NO_GIT_BRANCH_SENTINEL = "_no_git"
30
+ AUTO_START = "<!-- AUTO-GENERATED-START -->"
31
+ AUTO_END = "<!-- AUTO-GENERATED-END -->"
32
+ PLACEHOLDER_MARKERS = ("待补充", "待刷新", "_尚未同步_")
33
+
34
+ # New v2 file layout: per-domain files instead of the old
35
+ # development/context/sources trio. `overview.md` stays because it's the
36
+ # cold-start snapshot (goal/scope/stage/constraints) and has no good home in
37
+ # the new four-category split.
38
+ MANAGED_FILES = (
39
+ "manifest.json",
40
+ "overview.md",
41
+ "decisions.md",
42
+ "progress.md",
43
+ "risks.md",
44
+ "glossary.md",
45
+ "unsorted.md",
46
+ "pending-promotion.md",
47
+ "log.md",
48
+ )
49
+
50
+ # Legacy v1 files are auto-migrated on first write/read then deleted. The list
51
+ # is kept here so list_missing_docs() and other scanners can ignore them.
52
+ LEGACY_V1_FILES = ("development.md", "context.md", "sources.md")
53
+
54
+ # Bottom-up clustering of changed-file paths into a small set of "focus
55
+ # directories". The cluster never grows larger than this many entries; a
56
+ # higher number gives finer granularity at the cost of a longer hint list.
57
+ FOCUS_AREA_LIMIT = 5
58
+
59
+
60
+ def now_iso():
61
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
62
+
63
+
64
+ def run_git(args, cwd, check=True):
65
+ result = subprocess.run(
66
+ ["git", *args],
67
+ cwd=cwd,
68
+ check=False,
69
+ capture_output=True,
70
+ text=True,
71
+ )
72
+ if check and result.returncode != 0:
73
+ raise RuntimeError(result.stderr.strip() or "git command failed")
74
+ return result
75
+
76
+
77
+ def git_stdout(args, cwd, check=True):
78
+ return run_git(args, cwd, check=check).stdout.strip()
79
+
80
+
81
+ def git_lines(args, cwd, check=True):
82
+ result = run_git(args, cwd, check=check)
83
+ if result.returncode != 0:
84
+ return []
85
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
86
+
87
+
88
+ def detect_repo_root(repo):
89
+ return Path(git_stdout(["rev-parse", "--show-toplevel"], cwd=repo))
90
+
91
+
92
+ def detect_branch(repo_root):
93
+ branch = git_stdout(["branch", "--show-current"], cwd=repo_root)
94
+ if not branch:
95
+ raise RuntimeError("current HEAD is detached; pass --branch explicitly")
96
+ return branch
97
+
98
+
99
+ def detect_git_dir(repo_root):
100
+ return git_stdout(["rev-parse", "--git-dir"], cwd=repo_root, check=False)
101
+
102
+
103
+ def detect_git_common_dir(repo_root):
104
+ return git_stdout(["rev-parse", "--git-common-dir"], cwd=repo_root, check=False)
105
+
106
+
107
+ def is_worktree(repo_root):
108
+ """True iff this checkout is a linked worktree (not the main repo).
109
+
110
+ Distinguishes the two by comparing --git-dir (per-worktree, e.g.
111
+ .git/worktrees/<name>) against --git-common-dir (shared, e.g. .git).
112
+ Older git versions print --git-common-dir as '.' when they're equal —
113
+ that's fine, the resolve() below normalizes both.
114
+ """
115
+ gd = detect_git_dir(repo_root)
116
+ gcd = detect_git_common_dir(repo_root)
117
+ if not gd or not gcd:
118
+ return False
119
+ base = Path(repo_root)
120
+ gd_path = Path(gd)
121
+ gcd_path = Path(gcd)
122
+ gd_abs = (gd_path if gd_path.is_absolute() else base / gd_path).resolve()
123
+ gcd_abs = (gcd_path if gcd_path.is_absolute() else base / gcd_path).resolve()
124
+ return gd_abs != gcd_abs
125
+
126
+
127
+ _REFLOG_CREATED_FROM_RE = re.compile(r"branch:\s*Created from\s+(\S+)")
128
+
129
+
130
+ def detect_worktree_base_branch(repo_root, branch_name):
131
+ """Best-effort: which branch was `branch_name` created from?
132
+
133
+ Reads the oldest reflog entry of `branch_name` — for `git worktree add -b X
134
+ path Y`, `git checkout -b X Y`, or `git switch -c X Y`, this entry reads
135
+ `branch: Created from Y`. Returns Y if it exists as a local branch ref and
136
+ differs from current; otherwise None (callers should fall back to seeding
137
+ a fresh skeleton).
138
+
139
+ Limitations:
140
+ - If the worktree was created without an explicit source (e.g. `worktree
141
+ add -b X path` defaults to HEAD), the reflog shows `Created from HEAD`
142
+ and we return None — we can't reliably resolve which branch HEAD was
143
+ pointing at at creation time.
144
+ - If branch_name has been amended/rebased enough that the oldest reflog
145
+ entry rolled off, we return None.
146
+ """
147
+ result = run_git(
148
+ ["reflog", "show", "--format=%gs", branch_name],
149
+ cwd=repo_root,
150
+ check=False,
151
+ )
152
+ if result.returncode != 0 or not result.stdout.strip():
153
+ return None
154
+ lines = [ln for ln in result.stdout.splitlines() if ln.strip()]
155
+ if not lines:
156
+ return None
157
+ oldest = lines[-1]
158
+ m = _REFLOG_CREATED_FROM_RE.search(oldest)
159
+ if not m:
160
+ return None
161
+ src = m.group(1).strip()
162
+ if not src or src == "HEAD" or src == branch_name:
163
+ return None
164
+ verify = run_git(
165
+ ["rev-parse", "--verify", "--quiet", f"refs/heads/{src}"],
166
+ cwd=repo_root,
167
+ check=False,
168
+ )
169
+ if verify.returncode != 0:
170
+ return None
171
+ return src
172
+
173
+
174
+ def sanitize_branch_name(branch_name):
175
+ cleaned = branch_name.strip().replace("/", "__")
176
+ cleaned = cleaned.replace(" ", "-")
177
+ if not cleaned:
178
+ raise ValueError("branch name is empty")
179
+ return cleaned
180
+
181
+
182
+ def sanitize_repo_name(repo_name):
183
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", repo_name.strip()).strip("-._")
184
+ return cleaned or "repo"
185
+
186
+
187
+ def set_storage_root_config(repo_root, storage_root):
188
+ desired = str(storage_root)
189
+
190
+ def current_value():
191
+ return run_git(
192
+ ["config", "--get", "dev-memory.root"],
193
+ cwd=repo_root,
194
+ check=False,
195
+ ).stdout.strip()
196
+
197
+ if current_value() == desired:
198
+ return False
199
+
200
+ last_error = ""
201
+ for attempt in range(5):
202
+ result = run_git(
203
+ ["config", "--local", "dev-memory.root", desired],
204
+ cwd=repo_root,
205
+ check=False,
206
+ )
207
+ if result.returncode == 0:
208
+ return True
209
+
210
+ last_error = result.stderr.strip() or "git config failed"
211
+ if "config.lock" not in last_error and "could not lock config file" not in last_error:
212
+ raise RuntimeError(last_error)
213
+
214
+ if current_value() == desired:
215
+ return False
216
+ time.sleep(0.05 * (attempt + 1))
217
+
218
+ if current_value() == desired:
219
+ return False
220
+ raise RuntimeError(last_error)
221
+
222
+
223
+ def get_storage_root(repo_root, explicit_value=None):
224
+ if explicit_value:
225
+ return Path(explicit_value).expanduser().resolve()
226
+
227
+ env_value = (
228
+ os.environ.get("DEV_MEMORY_ROOT", "").strip()
229
+ or os.environ.get("DEV_ASSETS_ROOT", "").strip()
230
+ )
231
+ if env_value:
232
+ return Path(env_value).expanduser().resolve()
233
+
234
+ for key in ("dev-memory.root", "dev-assets.root"):
235
+ configured = run_git(["config", "--get", key], cwd=repo_root, check=False)
236
+ configured_value = configured.stdout.strip()
237
+ if configured_value:
238
+ return Path(configured_value).expanduser().resolve()
239
+
240
+ for key in ("dev-memory.dir", "dev-assets.dir"):
241
+ legacy = run_git(["config", "--get", key], cwd=repo_root, check=False)
242
+ legacy_value = legacy.stdout.strip()
243
+ if legacy_value and Path(legacy_value).expanduser().is_absolute():
244
+ return Path(legacy_value).expanduser().resolve()
245
+
246
+ default_root = DEFAULT_STORAGE_ROOT.expanduser().resolve()
247
+ if not default_root.exists():
248
+ legacy_root = LEGACY_STORAGE_ROOT.expanduser().resolve()
249
+ if legacy_root.exists():
250
+ return legacy_root
251
+ return default_root
252
+
253
+
254
+ def get_legacy_context_dir(repo_root):
255
+ for key in ("dev-memory.dir", "dev-assets.dir"):
256
+ configured = run_git(["config", "--get", key], cwd=repo_root, check=False)
257
+ value = configured.stdout.strip()
258
+ if value:
259
+ return value
260
+ return DEFAULT_LEGACY_CONTEXT_DIR
261
+
262
+
263
+ def resolve_legacy_branch_dir(base_dir, branch_name, branch_key):
264
+ candidates = [
265
+ base_dir / branch_key,
266
+ base_dir / Path(branch_name),
267
+ ]
268
+ for candidate in candidates:
269
+ if candidate.exists():
270
+ return candidate
271
+ return candidates[0]
272
+
273
+
274
+ def normalize_remote_url(remote_url):
275
+ value = remote_url.strip()
276
+ if not value:
277
+ return None
278
+
279
+ if value.startswith("git@") and ":" in value:
280
+ host_part, repo_part = value.split(":", 1)
281
+ host = host_part.split("@", 1)[1].lower()
282
+ normalized = f"{host}/{repo_part}"
283
+ elif "://" in value:
284
+ parsed = urlparse(value)
285
+ host = (parsed.hostname or "").lower()
286
+ repo_part = parsed.path.lstrip("/")
287
+ normalized = f"{host}/{repo_part}" if host else value
288
+ else:
289
+ normalized = value
290
+
291
+ normalized = normalized.replace("\\", "/").rstrip("/")
292
+ if normalized.endswith(".git"):
293
+ normalized = normalized[:-4]
294
+ return normalized
295
+
296
+
297
+ def detect_repo_identity(repo_root):
298
+ remote = run_git(["remote", "get-url", "origin"], cwd=repo_root, check=False).stdout.strip()
299
+ if remote:
300
+ identity = normalize_remote_url(remote)
301
+ source = "origin"
302
+ else:
303
+ identity = repo_root.resolve().as_posix()
304
+ source = "path"
305
+
306
+ repo_slug = sanitize_repo_name(Path(identity).name or repo_root.name)
307
+ digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:12]
308
+ return {
309
+ "repo_identity": identity,
310
+ "repo_identity_source": source,
311
+ "repo_key": f"{repo_slug}-{digest}",
312
+ }
313
+
314
+
315
+ def detect_no_git_mode(cwd=None):
316
+ base = Path(cwd or ".").resolve()
317
+ if not base.exists() or not base.is_dir():
318
+ return False
319
+ probe = run_git(["rev-parse", "--show-toplevel"], cwd=base, check=False)
320
+ if probe.returncode == 0 and probe.stdout.strip():
321
+ return False
322
+ if list_repos_in_workspace(base):
323
+ return False
324
+ return True
325
+
326
+
327
+ def read_or_create_dev_memory_id(cwd):
328
+ cwd_path = Path(cwd).resolve()
329
+ id_file = cwd_path / DEV_MEMORY_ID_FILE
330
+ legacy_id_file = cwd_path / LEGACY_ID_FILE
331
+ if not id_file.exists() and legacy_id_file.exists():
332
+ # Use legacy file in-place for one release; migration script renames it.
333
+ id_file = legacy_id_file
334
+ if id_file.exists():
335
+ try:
336
+ payload = json.loads(id_file.read_text(encoding="utf-8"))
337
+ except json.JSONDecodeError:
338
+ payload = {}
339
+ if isinstance(payload, dict) and payload.get("id"):
340
+ return payload
341
+ id_file = cwd_path / DEV_MEMORY_ID_FILE
342
+ payload = {
343
+ "id": str(uuid.uuid4()),
344
+ "name": cwd_path.name,
345
+ "created_at": now_iso(),
346
+ }
347
+ id_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
348
+ return payload
349
+
350
+
351
+ def detect_repo_identity_no_git(cwd):
352
+ cwd_path = Path(cwd).resolve()
353
+ payload = read_or_create_dev_memory_id(cwd_path)
354
+ name = sanitize_repo_name(payload.get("name") or cwd_path.name)
355
+ digest = hashlib.sha1(payload["id"].encode("utf-8")).hexdigest()[:12]
356
+ return {
357
+ "repo_identity": f"no-git:{payload['id']}",
358
+ "repo_identity_source": "dev-memory-id",
359
+ "repo_key": f"{name}-{digest}",
360
+ }
361
+
362
+
363
+ def get_no_git_paths(cwd, context_dir=None):
364
+ cwd_path = Path(cwd).resolve()
365
+ if context_dir:
366
+ storage_root = Path(context_dir).expanduser().resolve()
367
+ else:
368
+ env_value = (
369
+ os.environ.get("DEV_MEMORY_ROOT", "").strip()
370
+ or os.environ.get("DEV_ASSETS_ROOT", "").strip()
371
+ )
372
+ if env_value:
373
+ storage_root = Path(env_value).expanduser().resolve()
374
+ else:
375
+ default_root = DEFAULT_STORAGE_ROOT.expanduser().resolve()
376
+ legacy_root = LEGACY_STORAGE_ROOT.expanduser().resolve()
377
+ storage_root = (
378
+ legacy_root if (not default_root.exists() and legacy_root.exists())
379
+ else default_root
380
+ )
381
+ identity = detect_repo_identity_no_git(cwd_path)
382
+ repo_dir = storage_root / identity["repo_key"]
383
+ # In no-git mode "branch_dir" collapses onto the repo-shared layer; the
384
+ # rest of the code stays polymorphic via branch_name=None.
385
+ branch_dir = repo_dir / "repo"
386
+ return cwd_path, None, None, storage_root, identity["repo_key"], repo_dir, branch_dir
387
+
388
+
389
+ def _resolve_workspace_repo(repo):
390
+ if not detect_workspace_mode(repo):
391
+ return repo
392
+ primary = (
393
+ os.environ.get("DEV_MEMORY_PRIMARY_REPO", "").strip()
394
+ or os.environ.get("DEV_ASSETS_PRIMARY_REPO", "").strip()
395
+ )
396
+ repos_in_ws = list_repos_in_workspace(repo)
397
+ names = [p.name for p in repos_in_ws]
398
+ if not primary:
399
+ raise RuntimeError(
400
+ f"workspace mode detected at '{repo}': pass --repo <basename> explicitly "
401
+ f"(one of: {names}) or set DEV_MEMORY_PRIMARY_REPO env."
402
+ )
403
+ match = next((p for p in repos_in_ws if p.name == primary), None)
404
+ if match is None:
405
+ raise RuntimeError(
406
+ f"workspace mode: DEV_MEMORY_PRIMARY_REPO='{primary}' not found in '{repo}'. "
407
+ f"Available: {names}."
408
+ )
409
+ return str(match)
410
+
411
+
412
+ def get_branch_paths(repo, context_dir=None, branch=None):
413
+ if branch is None and detect_no_git_mode(repo):
414
+ return get_no_git_paths(repo, context_dir)
415
+ repo = _resolve_workspace_repo(repo)
416
+ repo_root = detect_repo_root(repo)
417
+ branch_name = branch or detect_branch(repo_root)
418
+ branch_key = sanitize_branch_name(branch_name)
419
+ storage_root = get_storage_root(repo_root, context_dir)
420
+ identity = detect_repo_identity(repo_root)
421
+ repo_dir = storage_root / identity["repo_key"]
422
+ branch_dir = repo_dir / "branches" / branch_key
423
+ return repo_root, branch_name, branch_key, storage_root, identity["repo_key"], repo_dir, branch_dir
424
+
425
+
426
+ def detect_workspace_mode(cwd=None):
427
+ base = Path(cwd or ".").resolve()
428
+ if not base.exists() or not base.is_dir():
429
+ return False
430
+ probe = run_git(["rev-parse", "--show-toplevel"], cwd=base, check=False)
431
+ if probe.returncode == 0 and probe.stdout.strip():
432
+ return False
433
+ return bool(list_repos_in_workspace(base))
434
+
435
+
436
+ def list_repos_in_workspace(cwd=None):
437
+ base = Path(cwd or ".").resolve()
438
+ repos = []
439
+ try:
440
+ entries = sorted(base.iterdir(), key=lambda p: p.name)
441
+ except (OSError, PermissionError):
442
+ return []
443
+ for entry in entries:
444
+ if not entry.is_dir():
445
+ continue
446
+ if (entry / ".git").exists():
447
+ repos.append(entry)
448
+ return repos
449
+
450
+
451
+ def get_all_branch_paths(cwd=None, context_dir=None):
452
+ result = []
453
+ for repo_path in list_repos_in_workspace(cwd):
454
+ try:
455
+ result.append(get_branch_paths(str(repo_path), context_dir=context_dir))
456
+ except Exception:
457
+ continue
458
+ return result
459
+
460
+
461
+ def asset_paths(repo_dir, branch_dir):
462
+ """Return a flat map of path keys for both repo and branch layers.
463
+
464
+ Key naming convention: branch-level keys are bare ("decisions",
465
+ "progress", ...). Repo-shared keys are prefixed with "repo_". The old v1
466
+ keys (development/context/sources) are gone — callers that still reference
467
+ them should be updated.
468
+ """
469
+ repo_memory_dir = repo_dir / "repo"
470
+ paths = {
471
+ "repo_manifest": repo_memory_dir / "manifest.json",
472
+ "repo_overview": repo_memory_dir / "overview.md",
473
+ "repo_decisions": repo_memory_dir / "decisions.md",
474
+ "repo_glossary": repo_memory_dir / "glossary.md",
475
+ "repo_log": repo_memory_dir / "log.md",
476
+ "repo_artifacts": repo_memory_dir / "artifacts",
477
+ }
478
+ # In no-git mode, branch_dir collapses onto repo_memory_dir. Progress/risks
479
+ # live inline at the repo layer since there's no branch concept; the other
480
+ # v2 files reuse the repo keys rather than duplicating.
481
+ if branch_dir == repo_memory_dir:
482
+ paths.update({
483
+ "manifest": paths["repo_manifest"],
484
+ "overview": paths["repo_overview"],
485
+ "decisions": paths["repo_decisions"],
486
+ "progress": repo_memory_dir / "progress.md",
487
+ "risks": repo_memory_dir / "risks.md",
488
+ "glossary": paths["repo_glossary"],
489
+ "unsorted": repo_memory_dir / "unsorted.md",
490
+ "pending_promotion": repo_memory_dir / "pending-promotion.md",
491
+ "log": paths["repo_log"],
492
+ "artifacts": paths["repo_artifacts"],
493
+ "history": repo_memory_dir / "artifacts" / "history",
494
+ })
495
+ return paths
496
+ paths.update({
497
+ "manifest": branch_dir / "manifest.json",
498
+ "overview": branch_dir / "overview.md",
499
+ "decisions": branch_dir / "decisions.md",
500
+ "progress": branch_dir / "progress.md",
501
+ "risks": branch_dir / "risks.md",
502
+ "glossary": branch_dir / "glossary.md",
503
+ "unsorted": branch_dir / "unsorted.md",
504
+ "pending_promotion": branch_dir / "pending-promotion.md",
505
+ "log": branch_dir / "log.md",
506
+ "artifacts": branch_dir / "artifacts",
507
+ "history": branch_dir / "artifacts" / "history",
508
+ })
509
+ return paths
510
+
511
+
512
+ def ensure_file(path, content):
513
+ if not path.exists():
514
+ path.write_text(content, encoding="utf-8")
515
+
516
+
517
+ def write_json(path, payload):
518
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
519
+
520
+
521
+ def read_json(path):
522
+ if not path.exists():
523
+ return {}
524
+ return json.loads(path.read_text(encoding="utf-8"))
525
+
526
+
527
+ def ensure_manifest(path, defaults):
528
+ existing = read_json(path)
529
+ if not existing:
530
+ write_json(path, defaults)
531
+ return defaults
532
+
533
+ # Fill missing keys only — never overwrite existing values. The previous
534
+ # `merged.update(defaults)` order silently clobbered stateful fields like
535
+ # `focus_areas`, `scope_summary`, `last_seen_head` with empty defaults
536
+ # whenever ensure_branch_paths_exist ran (every capture / sync), making
537
+ # downstream merge_focus_areas always see `existing=[]`.
538
+ merged = dict(existing)
539
+ for key, value in defaults.items():
540
+ if key not in merged:
541
+ merged[key] = value
542
+ merged["initialized_at"] = existing.get("initialized_at", defaults.get("initialized_at"))
543
+ # Preserve setup_completed if already true — re-running init shouldn't
544
+ # reset user's setup progress.
545
+ if existing.get("setup_completed"):
546
+ merged["setup_completed"] = True
547
+ merged["setup_completed_at"] = existing.get("setup_completed_at") or merged.get("setup_completed_at")
548
+ write_json(path, merged)
549
+ return merged
550
+
551
+
552
+ def render_bullets(items, empty_text="- 待补充", wrap_code=False):
553
+ normalized = [str(item).strip() for item in (items or []) if str(item).strip()]
554
+ if not normalized:
555
+ return empty_text
556
+ lines = []
557
+ for item in normalized:
558
+ if wrap_code and not (item.startswith("`") and item.endswith("`")):
559
+ item = f"`{item}`"
560
+ lines.append(f"- {item}")
561
+ return "\n".join(lines)
562
+
563
+
564
+ def render_title_doc(doc_title, sections, intro=None):
565
+ parts = [f"# {doc_title}"]
566
+ if intro:
567
+ parts.extend(["", intro.strip()])
568
+ for title, body in sections:
569
+ parts.extend(["", f"## {title}", "", body.strip()])
570
+ return "\n".join(parts).rstrip() + "\n"
571
+
572
+
573
+ # ---------------------------------------------------------------------------
574
+ # Templates (v2)
575
+ # ---------------------------------------------------------------------------
576
+
577
+ def template_overview(branch_name):
578
+ return render_title_doc(
579
+ "概览",
580
+ [
581
+ ("分支", f"- {branch_name}"),
582
+ ("当前目标", "- 待补充"),
583
+ ("范围边界", "- 待补充"),
584
+ ("当前阶段", "- 待补充"),
585
+ ("关键约束", "- 待补充"),
586
+ ],
587
+ )
588
+
589
+
590
+ def template_decisions(branch_name):
591
+ return render_title_doc(
592
+ "分支决策",
593
+ [
594
+ ("分支", f"- {branch_name}"),
595
+ ("关键决策与原因", "- 待补充"),
596
+ ],
597
+ )
598
+
599
+
600
+ def template_progress(branch_name):
601
+ return render_title_doc(
602
+ "当前进展",
603
+ [
604
+ ("分支", f"- {branch_name}"),
605
+ ("建议优先查看的目录", "- 待刷新"),
606
+ ("当前进展", "- 待补充"),
607
+ ("下一步", "- 待补充"),
608
+ (
609
+ "自动同步区",
610
+ "本区由 `dev-memory-cli capture sync-working-tree` / SessionStart hook 自动刷新,请不要手工编辑。\n\n"
611
+ f"{AUTO_START}\n"
612
+ "_尚未同步_\n"
613
+ f"{AUTO_END}",
614
+ ),
615
+ ],
616
+ )
617
+
618
+
619
+ def template_risks(branch_name):
620
+ return render_title_doc(
621
+ "阻塞与注意点",
622
+ [
623
+ ("分支", f"- {branch_name}"),
624
+ ("阻塞与注意点", "- 待补充"),
625
+ ("后续继续前要注意", "- 待补充"),
626
+ ],
627
+ )
628
+
629
+
630
+ def template_glossary(branch_name):
631
+ return render_title_doc(
632
+ "术语与源资料",
633
+ [
634
+ ("分支", f"- {branch_name}"),
635
+ ("当前分支专有术语", "- 待补充"),
636
+ ("分支源资料入口", "- 待补充"),
637
+ ],
638
+ )
639
+
640
+
641
+ def template_unsorted():
642
+ return (
643
+ "# 未分类条目\n\n"
644
+ "本文件存放 heuristic 无法分类的内容,或用户手动甩进来尚未整理的内容。\n"
645
+ "下次 setup 或 capture --merge 时分类到 decisions/progress/risks/glossary。\n\n"
646
+ "## 待分类\n\n- 待补充\n"
647
+ )
648
+
649
+
650
+ def template_pending_promotion():
651
+ return (
652
+ "# 候选跨分支条目\n\n"
653
+ "本文件由 capture 在检测到内容可能跨分支复用时自动打标写入。\n"
654
+ "graduate 时优先从此文件筛选提炼到 repo 共享层。\n\n"
655
+ "## 候选条目\n\n- 待补充\n"
656
+ )
657
+
658
+
659
+ def template_log(scope_label):
660
+ """Branch-layer event log skeleton.
661
+
662
+ Append-only. Each entry is an H2 line starting with `## [<ISO timestamp>]`
663
+ so `grep '^## \\['` slices the timeline cleanly. Optional sub-detail
664
+ lines start with `- ` underneath.
665
+ """
666
+ return (
667
+ "# 事件日志\n\n"
668
+ f"- scope: {scope_label}\n\n"
669
+ "Append-only。capture / tidy / graduate 等动作落盘后追加一行。\n"
670
+ "`grep '^## \\[' log.md | tail -20` 看最近事件。\n\n"
671
+ "<!-- LOG ENTRIES BELOW -->\n"
672
+ )
673
+
674
+
675
+ def template_repo_log(repo_name):
676
+ return (
677
+ "# 仓库共享事件日志\n\n"
678
+ f"- repo: {repo_name}\n\n"
679
+ "Append-only。repo-shared 写入 / graduate harvest / 归档落盘后追加一行。\n"
680
+ "`grep '^## \\[' log.md | tail -20` 看最近事件。\n\n"
681
+ "<!-- LOG ENTRIES BELOW -->\n"
682
+ )
683
+
684
+
685
+ def template_progress_no_git(project_name):
686
+ return render_title_doc(
687
+ "当前进展(no-git 模式)",
688
+ [
689
+ ("项目", f"- {project_name}"),
690
+ ("当前进展", "- 待补充"),
691
+ ("下一步", "- 待补充"),
692
+ (
693
+ "自动同步区",
694
+ "本区由 capture / context 刷新。no-git 模式下无 git facts,保持最小骨架。\n\n"
695
+ f"{AUTO_START}\n"
696
+ "_尚未同步_\n"
697
+ f"{AUTO_END}",
698
+ ),
699
+ ],
700
+ )
701
+
702
+
703
+ def template_repo_overview(repo_name):
704
+ return render_title_doc(
705
+ "仓库共享概览",
706
+ [
707
+ ("仓库", f"- {repo_name}"),
708
+ ("长期目标与边界", "- 待补充"),
709
+ ("仓库级关键约束", "- 待补充"),
710
+ ],
711
+ )
712
+
713
+
714
+ def template_repo_decisions(repo_name):
715
+ return render_title_doc(
716
+ "跨分支通用决策",
717
+ [
718
+ ("仓库", f"- {repo_name}"),
719
+ ("跨分支通用决策", "- 待补充"),
720
+ ],
721
+ )
722
+
723
+
724
+ def template_repo_glossary(repo_name):
725
+ return render_title_doc(
726
+ "仓库共享术语与入口",
727
+ [
728
+ ("仓库", f"- {repo_name}"),
729
+ ("长期有效背景", "- 待补充"),
730
+ ("共享入口", "- 待补充"),
731
+ ("共享注意点", "- 待补充"),
732
+ ],
733
+ )
734
+
735
+
736
+ # ---------------------------------------------------------------------------
737
+ # Manifest builders
738
+ # ---------------------------------------------------------------------------
739
+
740
+ def build_repo_manifest(repo_root, storage_root, repo_key, identity, *, no_git=False):
741
+ manifest = {
742
+ "schema_version": 4,
743
+ "scope": "repo",
744
+ "storage_mode": "user-home-no-git" if no_git else "user-home-repo-plus-branch",
745
+ "repo_root": str(repo_root),
746
+ "repo_key": repo_key,
747
+ "repo_identity": identity["repo_identity"],
748
+ "repo_identity_source": identity["repo_identity_source"],
749
+ "storage_root": str(storage_root),
750
+ "initialized_at": now_iso(),
751
+ "updated_at": now_iso(),
752
+ "last_seen_branch": None,
753
+ "last_seen_head": None,
754
+ "default_base": None,
755
+ }
756
+ if no_git:
757
+ manifest["no_git"] = True
758
+ return manifest
759
+
760
+
761
+ def build_branch_manifest(repo_root, branch_name, branch_key, storage_root, repo_key):
762
+ return {
763
+ "schema_version": 4,
764
+ "scope": "branch",
765
+ "storage_mode": "user-home-repo-plus-branch",
766
+ "repo_root": str(repo_root),
767
+ "repo_key": repo_key,
768
+ "branch": branch_name,
769
+ "branch_key": branch_key,
770
+ "storage_root": str(storage_root),
771
+ "initialized_at": now_iso(),
772
+ "updated_at": now_iso(),
773
+ "last_seen_head": None,
774
+ "default_base": None,
775
+ "scope_summary": [],
776
+ "focus_areas": [],
777
+ # v2 additions: setup_completed flips to true when user runs setup
778
+ # merge-unsorted flow. Lazy-init writes proceed with false.
779
+ "setup_completed": False,
780
+ "setup_completed_at": None,
781
+ }
782
+
783
+
784
+ def get_setup_completed(manifest_path):
785
+ manifest = read_json(manifest_path)
786
+ return bool(manifest.get("setup_completed"))
787
+
788
+
789
+ def mark_setup_completed(manifest_path):
790
+ manifest = read_json(manifest_path)
791
+ manifest["setup_completed"] = True
792
+ manifest["setup_completed_at"] = now_iso()
793
+ manifest["updated_at"] = now_iso()
794
+ write_json(manifest_path, manifest)
795
+ return manifest
796
+
797
+
798
+ # ---------------------------------------------------------------------------
799
+ # Migration: v0 (in-repo .dev-memory/) and v1 (overview/development/context/sources)
800
+ # ---------------------------------------------------------------------------
801
+
802
+ def migrate_legacy_branch_assets(repo_root, branch_name, branch_key, branch_dir):
803
+ """v0 → v1: pull old in-repo `.dev-memory/<branch>/` into user-home storage."""
804
+ legacy_context_dir = get_legacy_context_dir(repo_root)
805
+ if Path(legacy_context_dir).expanduser().is_absolute():
806
+ legacy_root = Path(legacy_context_dir).expanduser().resolve()
807
+ else:
808
+ legacy_root = (repo_root / legacy_context_dir).resolve()
809
+
810
+ legacy_branch_dir = resolve_legacy_branch_dir(legacy_root, branch_name, branch_key)
811
+ if not legacy_branch_dir.exists() or not legacy_branch_dir.is_dir():
812
+ return None
813
+
814
+ branch_dir.mkdir(parents=True, exist_ok=True)
815
+ migrated = []
816
+ # Only copy v1-era file names (development/context/sources/overview/manifest).
817
+ for file_name in ("manifest.json", "overview.md", "development.md", "context.md", "sources.md"):
818
+ source = legacy_branch_dir / file_name
819
+ target = branch_dir / file_name
820
+ if source.exists() and not target.exists():
821
+ shutil.copy2(source, target)
822
+ migrated.append(file_name)
823
+
824
+ legacy_history = legacy_branch_dir / "artifacts" / "history"
825
+ target_history = branch_dir / "artifacts" / "history"
826
+ if legacy_history.exists() and not target_history.exists():
827
+ shutil.copytree(legacy_history, target_history, dirs_exist_ok=True)
828
+ migrated.append("artifacts/history")
829
+
830
+ return {"legacy_branch_dir": str(legacy_branch_dir), "migrated": migrated} if migrated else None
831
+
832
+
833
+ # v1 → v2 section routing: old section title → (target v2 file key, optional new section title)
834
+ # None as new_title means "keep original title".
835
+ _V1_BRANCH_SECTION_MAP = {
836
+ # from development.md
837
+ "建议优先查看的目录": ("progress", None),
838
+ "当前进展": ("progress", None),
839
+ "下一步": ("progress", None),
840
+ "阻塞与注意点": ("risks", None),
841
+ # "自动同步区" is handled specially (copied into progress.md as-is).
842
+ # from context.md
843
+ "当前有效上下文": ("glossary", "当前有效上下文"),
844
+ "关键决策与原因": ("decisions", "关键决策与原因"),
845
+ "后续继续前要注意": ("risks", "后续继续前要注意"),
846
+ # from sources.md
847
+ "当前分支优先阅读": ("glossary", "分支源资料入口"),
848
+ "提交与代码历史": ("glossary", "提交与代码历史参考"),
849
+ # "分支" section is header metadata, skip.
850
+ }
851
+
852
+ _V1_REPO_SECTION_MAP = {
853
+ # from repo/context.md
854
+ "长期有效背景": ("repo_glossary", "长期有效背景"),
855
+ "跨分支通用决策": ("repo_decisions", "跨分支通用决策"),
856
+ "共享注意点": ("repo_glossary", "共享注意点"),
857
+ # from repo/sources.md
858
+ "共享入口": ("repo_glossary", "共享入口"),
859
+ # "仓库" section is header metadata, skip.
860
+ }
861
+
862
+
863
+ def _collect_v1_sections(path, section_map, *, default_target=None):
864
+ """Read a v1 markdown file and bucket its sections by target v2 key.
865
+ Returns {v2_key: [(title, body), ...]}.
866
+
867
+ Unknown sections (not in section_map) are routed to `default_target` with
868
+ their original title preserved. Since the migration then deletes the v1
869
+ file, silently discarding unknowns would mean unrecoverable data loss for
870
+ any user-authored custom sections. Branch-layer callers pass
871
+ default_target="unsorted"; repo-layer callers pass "repo_glossary".
872
+ Set default_target=None to opt back into the old silent-drop behavior.
873
+ """
874
+ if not path.exists():
875
+ return {}
876
+ buckets = {}
877
+ _, sections = split_sections(path.read_text(encoding="utf-8"))
878
+ for title, body in sections:
879
+ t = title.strip()
880
+ if t == "分支" or t == "仓库":
881
+ continue
882
+ mapping = section_map.get(t)
883
+ if mapping:
884
+ target_key, new_title = mapping
885
+ new_title = new_title or t
886
+ elif default_target is not None:
887
+ # Preserve the original title so the user can tell what was
888
+ # unrecognised when they clean up unsorted.md manually.
889
+ target_key = default_target
890
+ new_title = t
891
+ else:
892
+ continue
893
+ buckets.setdefault(target_key, []).append((new_title, body))
894
+ return buckets
895
+
896
+
897
+ def _extract_auto_block(path):
898
+ """Return the content between AUTO_START/AUTO_END markers, or None."""
899
+ if not path.exists():
900
+ return None
901
+ content = path.read_text(encoding="utf-8")
902
+ if AUTO_START not in content or AUTO_END not in content:
903
+ return None
904
+ _, after_start = content.split(AUTO_START, 1)
905
+ block, _ = after_start.split(AUTO_END, 1)
906
+ return block.strip()
907
+
908
+
909
+ def _write_v2_file_from_buckets(target_path, doc_title, header_field, buckets, fallback_body="- 待补充"):
910
+ """Render the v2 target file from migrated section buckets + a header."""
911
+ sections = [header_field]
912
+ if buckets:
913
+ sections.extend(buckets)
914
+ else:
915
+ # No migrated content — let initialize_assets seed the placeholder
916
+ # template instead of writing an empty file here.
917
+ return False
918
+ target_path.write_text(render_title_doc(doc_title, sections), encoding="utf-8")
919
+ return True
920
+
921
+
922
+ def migrate_v1_to_v2_branch(branch_dir, branch_name):
923
+ """v1 → v2: split old development/context/sources into new four-file
924
+ structure plus unsorted/pending-promotion bootstrap. Old files are deleted
925
+ after successful migration (single-user offline cleanup — no .legacy kept).
926
+ Idempotent: returns None if no v1 files are present.
927
+ """
928
+ old_dev = branch_dir / "development.md"
929
+ old_ctx = branch_dir / "context.md"
930
+ old_src = branch_dir / "sources.md"
931
+
932
+ if not any(p.exists() for p in (old_dev, old_ctx, old_src)):
933
+ return None
934
+
935
+ # Collect sections from all v1 files, bucketed by v2 target key. Unknown
936
+ # sections land in "unsorted" so nothing is lost when the v1 files get
937
+ # deleted below — the user can triage them via dev-memory-setup later.
938
+ merged_buckets = {}
939
+ for v1_path in (old_dev, old_ctx, old_src):
940
+ buckets = _collect_v1_sections(v1_path, _V1_BRANCH_SECTION_MAP, default_target="unsorted")
941
+ for k, entries in buckets.items():
942
+ merged_buckets.setdefault(k, []).extend(entries)
943
+
944
+ # Preserve the development.md auto-sync block as-is inside progress.md.
945
+ auto_block = _extract_auto_block(old_dev)
946
+
947
+ # Write v2 files if there's content; skip if bucket is empty so init's
948
+ # placeholder template seeds the file instead.
949
+ header = ("分支", f"- {branch_name}")
950
+ written = []
951
+
952
+ progress_sections = list(merged_buckets.get("progress", []))
953
+ if auto_block is not None:
954
+ progress_sections.append((
955
+ "自动同步区",
956
+ "本区由 `dev-memory-cli capture sync-working-tree` / SessionStart hook 自动刷新,请不要手工编辑。\n\n"
957
+ f"{AUTO_START}\n{auto_block}\n{AUTO_END}",
958
+ ))
959
+ if progress_sections:
960
+ target = branch_dir / "progress.md"
961
+ target.write_text(
962
+ render_title_doc("当前进展", [header] + progress_sections),
963
+ encoding="utf-8",
964
+ )
965
+ written.append("progress.md")
966
+
967
+ for key, doc_title in (
968
+ ("decisions", "分支决策"),
969
+ ("risks", "阻塞与注意点"),
970
+ ("glossary", "术语与源资料"),
971
+ ):
972
+ entries = merged_buckets.get(key)
973
+ if not entries:
974
+ continue
975
+ target = branch_dir / f"{key}.md"
976
+ target.write_text(
977
+ render_title_doc(doc_title, [header] + entries),
978
+ encoding="utf-8",
979
+ )
980
+ written.append(f"{key}.md")
981
+
982
+ # Unknown legacy sections go into unsorted.md under a dedicated
983
+ # "legacy v1 未识别段落" group. Keeping the original section titles
984
+ # here is the whole point — the user can see what got stranded and
985
+ # classify properly via setup merge-unsorted.
986
+ unsorted_entries = merged_buckets.get("unsorted")
987
+ if unsorted_entries:
988
+ legacy_body = "\n\n".join(
989
+ f"### {t}\n\n{b.strip()}" for t, b in unsorted_entries
990
+ )
991
+ target = branch_dir / "unsorted.md"
992
+ target.write_text(
993
+ render_title_doc(
994
+ "未分类条目",
995
+ [
996
+ header,
997
+ (
998
+ "legacy v1 未识别段落",
999
+ "以下 section 在 v1 → v2 迁移时无法自动分类。"
1000
+ "请走 `dev-memory-setup merge-unsorted` 分到 decisions / "
1001
+ "progress / risks / glossary。\n\n" + legacy_body,
1002
+ ),
1003
+ ],
1004
+ ),
1005
+ encoding="utf-8",
1006
+ )
1007
+ written.append("unsorted.md")
1008
+
1009
+ # Delete old v1 files after successful migration.
1010
+ removed = []
1011
+ for p in (old_dev, old_ctx, old_src):
1012
+ if p.exists():
1013
+ p.unlink()
1014
+ removed.append(p.name)
1015
+
1016
+ return {"migrated_files": written, "removed_legacy": removed}
1017
+
1018
+
1019
+ def migrate_v1_to_v2_repo(repo_memory_dir, repo_name):
1020
+ """v1 → v2 for repo-shared layer: old repo/context.md + repo/sources.md
1021
+ are split into repo/decisions.md + repo/glossary.md. repo/overview.md
1022
+ stays put. Idempotent.
1023
+ """
1024
+ old_ctx = repo_memory_dir / "context.md"
1025
+ old_src = repo_memory_dir / "sources.md"
1026
+
1027
+ if not any(p.exists() for p in (old_ctx, old_src)):
1028
+ return None
1029
+
1030
+ # Unknown repo-layer sections fall to repo_glossary — it's the closest
1031
+ # shared-layer home and gets an explicit subsection so the user can
1032
+ # triage later.
1033
+ merged_buckets = {}
1034
+ for v1_path in (old_ctx, old_src):
1035
+ buckets = _collect_v1_sections(v1_path, _V1_REPO_SECTION_MAP, default_target="repo_glossary")
1036
+ for k, entries in buckets.items():
1037
+ merged_buckets.setdefault(k, []).extend(entries)
1038
+
1039
+ header = ("仓库", f"- {repo_name}")
1040
+ written = []
1041
+
1042
+ for key, doc_title, file_name in (
1043
+ ("repo_decisions", "跨分支通用决策", "decisions.md"),
1044
+ ("repo_glossary", "仓库共享术语与入口", "glossary.md"),
1045
+ ):
1046
+ entries = merged_buckets.get(key)
1047
+ if not entries:
1048
+ continue
1049
+ target = repo_memory_dir / file_name
1050
+ target.write_text(
1051
+ render_title_doc(doc_title, [header] + entries),
1052
+ encoding="utf-8",
1053
+ )
1054
+ written.append(file_name)
1055
+
1056
+ removed = []
1057
+ for p in (old_ctx, old_src):
1058
+ if p.exists():
1059
+ p.unlink()
1060
+ removed.append(p.name)
1061
+
1062
+ return {"migrated_files": written, "removed_legacy": removed}
1063
+
1064
+
1065
+ # ---------------------------------------------------------------------------
1066
+ # Initialize assets (lazy init entrypoint)
1067
+ # ---------------------------------------------------------------------------
1068
+
1069
+ def initialize_assets(repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir):
1070
+ """Create the repo-shared layer + current branch layer on disk, seeding
1071
+ each v2 file with a placeholder template. Idempotent — safe to call on
1072
+ every write.
1073
+
1074
+ Runs v0→v1 legacy migration (in-repo .dev-memory/ dir) then v1→v2
1075
+ migration (old 3-file layout) before seeding, so existing content is never
1076
+ clobbered.
1077
+ """
1078
+ repo_memory_dir = repo_dir / "repo"
1079
+ repo_memory_dir.mkdir(parents=True, exist_ok=True)
1080
+ no_git = branch_name is None and branch_dir == repo_memory_dir
1081
+ if not no_git:
1082
+ branch_dir.mkdir(parents=True, exist_ok=True)
1083
+ set_storage_root_config(repo_root, storage_root)
1084
+
1085
+ if no_git:
1086
+ identity = detect_repo_identity_no_git(repo_root)
1087
+ else:
1088
+ identity = detect_repo_identity(repo_root)
1089
+
1090
+ # v0 → v1 first (copies old in-repo files into branch_dir with v1 names)
1091
+ v0_migration = None if no_git else migrate_legacy_branch_assets(repo_root, branch_name, branch_key, branch_dir)
1092
+ # v1 → v2 next (splits old files into v2 four-file structure)
1093
+ v1_branch_migration = None if no_git else migrate_v1_to_v2_branch(branch_dir, branch_name)
1094
+ v1_repo_migration = migrate_v1_to_v2_repo(repo_memory_dir, repo_root.name)
1095
+
1096
+ paths = asset_paths(repo_dir, branch_dir)
1097
+ paths["repo_artifacts"].mkdir(exist_ok=True)
1098
+ if not no_git:
1099
+ paths["artifacts"].mkdir(exist_ok=True)
1100
+ paths["history"].mkdir(parents=True, exist_ok=True)
1101
+
1102
+ # Repo-shared layer seeding.
1103
+ ensure_manifest(paths["repo_manifest"], build_repo_manifest(repo_root, storage_root, repo_key, identity, no_git=no_git))
1104
+ ensure_file(paths["repo_overview"], template_repo_overview(repo_root.name))
1105
+ ensure_file(paths["repo_decisions"], template_repo_decisions(repo_root.name))
1106
+ ensure_file(paths["repo_glossary"], template_repo_glossary(repo_root.name))
1107
+ ensure_file(paths["repo_log"], template_repo_log(repo_root.name))
1108
+
1109
+ if no_git:
1110
+ # In no-git mode, progress/risks/unsorted/pending live at the repo
1111
+ # layer since there's no branch. Seed them with degraded templates.
1112
+ # log/log.md collapses onto repo_log via asset_paths(); no extra seed.
1113
+ ensure_file(paths["progress"], template_progress_no_git(repo_root.name))
1114
+ ensure_file(paths["risks"], template_risks(repo_root.name))
1115
+ ensure_file(paths["unsorted"], template_unsorted())
1116
+ ensure_file(paths["pending_promotion"], template_pending_promotion())
1117
+ return paths
1118
+
1119
+ # Branch layer seeding.
1120
+ ensure_manifest(paths["manifest"], build_branch_manifest(repo_root, branch_name, branch_key, storage_root, repo_key))
1121
+ ensure_file(paths["overview"], template_overview(branch_name))
1122
+ ensure_file(paths["decisions"], template_decisions(branch_name))
1123
+ ensure_file(paths["progress"], template_progress(branch_name))
1124
+ ensure_file(paths["risks"], template_risks(branch_name))
1125
+ ensure_file(paths["glossary"], template_glossary(branch_name))
1126
+ ensure_file(paths["unsorted"], template_unsorted())
1127
+ ensure_file(paths["pending_promotion"], template_pending_promotion())
1128
+ ensure_file(paths["log"], template_log(f"branch:{branch_name}"))
1129
+
1130
+ # Stamp migration info onto the branch manifest so graduate/context can
1131
+ # surface it when relevant.
1132
+ any_migration = v0_migration or v1_branch_migration or v1_repo_migration
1133
+ if any_migration:
1134
+ branch_manifest = read_json(paths["manifest"])
1135
+ note = {}
1136
+ if v0_migration:
1137
+ note["legacy_v0"] = v0_migration
1138
+ if v1_branch_migration:
1139
+ note["legacy_v1_branch"] = v1_branch_migration
1140
+ if v1_repo_migration:
1141
+ note["legacy_v1_repo"] = v1_repo_migration
1142
+ branch_manifest["legacy_migration"] = note
1143
+ branch_manifest["updated_at"] = now_iso()
1144
+ write_json(paths["manifest"], branch_manifest)
1145
+
1146
+ return paths
1147
+
1148
+
1149
+ def _inherit_from_worktree_base(repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir):
1150
+ """If this checkout is a linked worktree whose branch was just created
1151
+ from another branch that already has substantive memory, copy that memory
1152
+ dir into the new branch's slot before lazy-init seeds an empty skeleton.
1153
+
1154
+ Idempotent + side-effect-free no-op when:
1155
+ - DEV_MEMORY_DISABLE_WORKTREE_INHERIT env is set (escape hatch)
1156
+ - no_git mode (branch_name is None)
1157
+ - not a worktree (main repo / standalone clone)
1158
+ - reflog doesn't reveal a base branch
1159
+ - the resolved base branch's memory dir is missing or skeleton-only
1160
+ - the new branch's memory dir somehow already exists
1161
+
1162
+ Returns the source branch name on success; None on no-op. The caller still
1163
+ runs `initialize_assets` afterwards, which is idempotent — it'll only fill
1164
+ in files the inherited tree didn't already have.
1165
+ """
1166
+ if os.environ.get("DEV_MEMORY_DISABLE_WORKTREE_INHERIT", "").strip():
1167
+ return None
1168
+ if branch_name is None:
1169
+ return None
1170
+ if branch_dir.exists():
1171
+ return None
1172
+ try:
1173
+ if not is_worktree(repo_root):
1174
+ return None
1175
+ except Exception:
1176
+ return None
1177
+ source_name = detect_worktree_base_branch(repo_root, branch_name)
1178
+ if not source_name:
1179
+ return None
1180
+ source_key = sanitize_branch_name(source_name)
1181
+ if source_key == branch_key:
1182
+ return None
1183
+ source_dir = repo_dir / "branches" / source_key
1184
+ if not source_dir.exists() or not source_dir.is_dir():
1185
+ return None
1186
+ # Defer skeleton check + fork mechanics to the branch CLI module so the
1187
+ # provenance stamp / metadata rewrite stays in one place. Lazy import to
1188
+ # avoid a circular dependency at module load.
1189
+ try:
1190
+ from dev_memory_branch import (
1191
+ inspect_branch_dir,
1192
+ _rewrite_branch_metadata,
1193
+ _rewrite_manifest,
1194
+ _stamp_overview_provenance,
1195
+ )
1196
+ except Exception:
1197
+ return None
1198
+ snap = inspect_branch_dir(source_dir, source_name)
1199
+ if not snap.get("exists") or snap.get("is_skeleton"):
1200
+ return None
1201
+ branch_dir.parent.mkdir(parents=True, exist_ok=True)
1202
+ shutil.copytree(str(source_dir), str(branch_dir))
1203
+ _rewrite_manifest(branch_dir, branch_name, source_branch_name=source_name, op="worktree-inherit")
1204
+ _rewrite_branch_metadata(branch_dir, branch_name)
1205
+ _stamp_overview_provenance(branch_dir, source_name, "worktree-inherit")
1206
+ return source_name
1207
+
1208
+
1209
+ def ensure_branch_paths_exist(repo, context_dir=None, branch=None):
1210
+ """Lazy-init entrypoint. Returns the same tuple as get_branch_paths() plus
1211
+ a `paths` dict, creating the directory + v2 file skeleton if missing.
1212
+
1213
+ This is the thing capture/context should call instead of raising on
1214
+ missing branch_dir — the whole point of the v2 design is that writes never
1215
+ require prior setup.
1216
+ """
1217
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir = get_branch_paths(
1218
+ repo, context_dir, branch
1219
+ )
1220
+ if not branch_dir.exists():
1221
+ _inherit_from_worktree_base(
1222
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir,
1223
+ )
1224
+ initialize_assets(repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir)
1225
+ else:
1226
+ # Branch dir exists but may be v1 — run the migration silently.
1227
+ migrate_v1_to_v2_branch(branch_dir, branch_name or repo_root.name)
1228
+ migrate_v1_to_v2_repo(repo_dir / "repo", repo_root.name)
1229
+ # And make sure v2 skeleton files exist (adds missing ones without
1230
+ # clobbering existing content).
1231
+ initialize_assets(repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir)
1232
+ paths = asset_paths(repo_dir, branch_dir)
1233
+ return repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths
1234
+
1235
+
1236
+ # ---------------------------------------------------------------------------
1237
+ # Heuristic classifier (capture routing)
1238
+ # ---------------------------------------------------------------------------
1239
+
1240
+ # Order matters: the first pattern that matches wins. Keep decisions/risks
1241
+ # ahead of progress since their signals are more specific.
1242
+ _CLASSIFY_PATTERNS = [
1243
+ ("decision", re.compile(r"结论[::]|决[定议][::]|不再|改为|采用|废弃|选择.+?不选|abandoned|adopt")),
1244
+ ("risk", re.compile(r"阻塞|注意|坑|失败|风险|卡住|gotcha|caveat|warning")),
1245
+ ("glossary", re.compile(r"即[::]|\s即\s|指的是|对应|链接|https?://|api\s*=|缩写|术语|简称|别名")),
1246
+ ("progress", re.compile(r"当前|已完成|下一步|commit|提交|实现|进展|todo|wip")),
1247
+ ]
1248
+
1249
+
1250
+ def classify_content(text, *, already_setup=False):
1251
+ """Classify free-form content into one of decisions/progress/risks/
1252
+ glossary/unsorted. Used by capture when the caller doesn't pass --kind.
1253
+
1254
+ Before setup, ambiguous content falls to `unsorted` so the user can sort
1255
+ it later via setup merge. After setup, the default shifts to `progress`
1256
+ because the user has signaled they want aggressive categorization.
1257
+ """
1258
+ if not text or not text.strip():
1259
+ return "unsorted"
1260
+ for label, pattern in _CLASSIFY_PATTERNS:
1261
+ if pattern.search(text):
1262
+ return label
1263
+ return "progress" if already_setup else "unsorted"
1264
+
1265
+
1266
+ # Branch-name tokens that appear in generic technical content too often to
1267
+ # reliably signal "this is branch-local". Without filtering them out, a branch
1268
+ # named `feature/capture-hook` would reject any lesson that says "hook" — e.g.
1269
+ # "阻塞式 hook 不能做重活" — even though that's exactly the kind of cross-branch
1270
+ # insight we want to stage. Keep this list to tokens that overwhelmingly appear
1271
+ # as throw-away prefixes, layer names, or ubiquitous tech nouns.
1272
+ _GENERIC_BRANCH_TOKENS = frozenset({
1273
+ "feature", "feat", "fix", "bug", "bugfix", "hotfix", "chore", "refactor",
1274
+ "docs", "test", "tests", "wip", "tmp", "temp", "main", "master", "dev",
1275
+ "develop", "release", "hook", "hooks", "apis", "cli", "web", "app",
1276
+ "lib", "libs", "util", "utils", "core", "base", "auth", "data", "cache",
1277
+ "user", "users", "admin", "demo", "example", "sample", "draft",
1278
+ })
1279
+
1280
+ _CROSS_BRANCH_SIGNAL = re.compile(
1281
+ r"经验|模式|最佳实践|教训|通用|复用|以后.{0,6}都|所有.{0,10}都.{0,4}要|gotcha|pattern|lesson|repo-wide|across\s+branches",
1282
+ re.I,
1283
+ )
1284
+
1285
+
1286
+ def is_cross_branch_candidate(text, branch_name):
1287
+ """Heuristic: return True if content looks reusable across branches.
1288
+
1289
+ Conservative — returns True only when:
1290
+ - content doesn't mention *branch-specific* terms (generic tech tokens
1291
+ like "hook"/"api"/"feature" are ignored via `_GENERIC_BRANCH_TOKENS`,
1292
+ so a branch named `feature/capture-hook` doesn't reject any lesson
1293
+ mentioning "hook"), AND
1294
+ - content has lesson-learned signals (经验/模式/最佳实践/教训/通用/以后都/所有
1295
+ ...都要/gotcha/pattern/lesson/repo-wide).
1296
+
1297
+ Matches use word boundaries so "hook" in `hookup` doesn't falsely trigger.
1298
+
1299
+ Cross-branch candidates are copied into pending-promotion.md in addition
1300
+ to their primary target file. graduate then only scans pending-promotion
1301
+ instead of every branch file.
1302
+ """
1303
+ if not text or not branch_name:
1304
+ return False
1305
+ lowered = text.lower()
1306
+ for token in branch_name.lower().replace("/", " ").replace("_", " ").replace("-", " ").split():
1307
+ if len(token) < 4 or token in _GENERIC_BRANCH_TOKENS:
1308
+ continue
1309
+ if re.search(rf"\b{re.escape(token)}\b", lowered):
1310
+ return False
1311
+ return bool(_CROSS_BRANCH_SIGNAL.search(text))
1312
+
1313
+
1314
+ # ---------------------------------------------------------------------------
1315
+ # Git-derived facts and auto-block rendering
1316
+ # ---------------------------------------------------------------------------
1317
+
1318
+ def detect_default_base(repo_root):
1319
+ symbolic = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root, check=False)
1320
+ ref = symbolic.stdout.strip()
1321
+ if symbolic.returncode == 0 and ref:
1322
+ return ref.replace("refs/remotes/", "", 1)
1323
+ for candidate in ("origin/main", "origin/master"):
1324
+ probe = run_git(["rev-parse", "--verify", candidate], cwd=repo_root, check=False)
1325
+ if probe.returncode == 0:
1326
+ return candidate
1327
+ return None
1328
+
1329
+
1330
+ def top_level_scope(path_str):
1331
+ parts = Path(path_str).parts
1332
+ return parts[0] if parts else "."
1333
+
1334
+
1335
+ def summarize_scopes(paths):
1336
+ counter = Counter(top_level_scope(path) for path in paths)
1337
+ return [{"scope": scope, "files": count} for scope, count in sorted(counter.items())]
1338
+
1339
+
1340
+ def _initial_parent(path_str):
1341
+ """File path → its immediate parent directory key. Files at repo root use
1342
+ '.', everything else uses the POSIX-style parent dir string."""
1343
+ parent = Path(path_str).parent
1344
+ if str(parent) in ("", "."):
1345
+ return "."
1346
+ return parent.as_posix()
1347
+
1348
+
1349
+ def _can_roll_up(key):
1350
+ """A bucket is rollable if its key is at depth ≥ 2 (so rolling up keeps it
1351
+ above the repo root). 1-level keys like 'lib' or 'docs' are 'isolated
1352
+ shallow' buckets — keep them as-is rather than collapse to '.'."""
1353
+ if key in ("", "."):
1354
+ return False
1355
+ return len(Path(key).parts) >= 2
1356
+
1357
+
1358
+ def _rolled_up(key):
1359
+ return Path(key).parent.as_posix()
1360
+
1361
+
1362
+ def summarize_focus_areas(paths, limit=None):
1363
+ """Cluster changed-file paths into ≤ `limit` focus directories.
1364
+
1365
+ Algorithm (bottom-up): start with each file's immediate parent dir as a
1366
+ bucket; while the bucket count exceeds `limit`, roll every roll-able
1367
+ bucket up one level, find the rolled-up key with the largest summed
1368
+ count (the "dominant cluster" — most files concentrated under a single
1369
+ deeper subtree), and merge only those originals into it. Buckets that
1370
+ were already at depth 1 or '.' are left untouched, so isolated shallow
1371
+ directories survive. Tie-break: deeper rolled-up key wins, then
1372
+ lexicographic.
1373
+ """
1374
+ if limit is None:
1375
+ limit = FOCUS_AREA_LIMIT
1376
+ buckets = Counter(_initial_parent(p) for p in paths)
1377
+ while len(buckets) > limit:
1378
+ proposals = {} # rolled_key -> [sum_count, [original_keys...]]
1379
+ for key, count in buckets.items():
1380
+ if not _can_roll_up(key):
1381
+ continue
1382
+ new_key = _rolled_up(key)
1383
+ entry = proposals.setdefault(new_key, [0, []])
1384
+ entry[0] += count
1385
+ entry[1].append(key)
1386
+ if not proposals:
1387
+ break
1388
+ def score(item):
1389
+ new_key, (sum_count, _) = item
1390
+ depth = len(Path(new_key).parts) if new_key not in ("", ".") else 0
1391
+ return (sum_count, depth, new_key)
1392
+ winner_key, (winner_count, originals) = max(proposals.items(), key=score)
1393
+ for k in originals:
1394
+ buckets.pop(k)
1395
+ buckets[winner_key] = buckets.get(winner_key, 0) + winner_count
1396
+ ranked = sorted(buckets.items(), key=lambda kv: (-kv[1], kv[0]))
1397
+ return [k for k, _ in ranked[:limit]]
1398
+
1399
+
1400
+ # Number of recent commits whose touched files contribute to the "focus area"
1401
+ # signal. The merge-base diff was previously the long-tail input here, but on
1402
+ # long-lived branches it dilutes hot-spot detection. Recent commits are a
1403
+ # better proxy for "what's currently being worked on" without dragging in
1404
+ # branch-lifecycle history.
1405
+ RECENT_COMMITS_FOR_FOCUS = 10
1406
+
1407
+
1408
+ def _files_in_recent_commits(repo_root, count):
1409
+ """Files touched by the last `count` non-merge commits on HEAD."""
1410
+ out = git_lines(
1411
+ [
1412
+ "log",
1413
+ f"-n{count}",
1414
+ "--no-merges",
1415
+ "--name-only",
1416
+ "--pretty=format:",
1417
+ ],
1418
+ cwd=repo_root,
1419
+ check=False,
1420
+ )
1421
+ seen = []
1422
+ seen_set = set()
1423
+ for line in out:
1424
+ if not line or line in seen_set:
1425
+ continue
1426
+ seen_set.add(line)
1427
+ seen.append(line)
1428
+ return seen
1429
+
1430
+
1431
+ def collect_git_facts(repo_root, branch_name, _storage_root=None):
1432
+ working_tree_files = git_lines(["diff", "--name-only"], cwd=repo_root)
1433
+ staged_files = git_lines(["diff", "--cached", "--name-only"], cwd=repo_root)
1434
+ untracked_files = git_lines(["ls-files", "--others", "--exclude-standard"], cwd=repo_root)
1435
+ recent_commit_files = _files_in_recent_commits(repo_root, RECENT_COMMITS_FOR_FOCUS)
1436
+
1437
+ default_base = detect_default_base(repo_root)
1438
+
1439
+ all_paths = sorted(set(working_tree_files + staged_files + untracked_files + recent_commit_files))
1440
+ return {
1441
+ "branch": branch_name,
1442
+ "default_base": default_base,
1443
+ "last_seen_head": get_head_commit(repo_root),
1444
+ "working_tree_files": working_tree_files,
1445
+ "staged_files": staged_files,
1446
+ "untracked_files": untracked_files,
1447
+ "recent_commit_files": recent_commit_files,
1448
+ "scope_summary": summarize_scopes(all_paths),
1449
+ "focus_areas": summarize_focus_areas(all_paths),
1450
+ "updated_at": now_iso(),
1451
+ }
1452
+
1453
+
1454
+ def merged_focus_areas(new_paths, existing, limit=None):
1455
+ """Compute focus_areas, mixing existing focus dirs with the new working
1456
+ tree signal.
1457
+
1458
+ Algorithm (matches user's mental model — "specific signals over wide
1459
+ parents, stale entries kept ranked low until limit prunes them"):
1460
+
1461
+ 1. Internally dedupe `existing`: when a wider parent dir (e.g. `apps`)
1462
+ coexists with its specific descendant (e.g. `apps/x/y/prompts`),
1463
+ drop the wider parent. The wider parent is almost always a leftover
1464
+ from an earlier roll-up that swallowed too much; the specific
1465
+ descendants carry the real signal.
1466
+ 2. Score every surviving existing dir by how many `new_paths` it
1467
+ actually covers (0 means stale, e.g. `.vscode` after a one-shot
1468
+ IDE write — but we keep it in the ranking, only the final limit
1469
+ truncation prunes it from the bottom).
1470
+ 3. Compute `uncovered` = paths not under any surviving existing dir.
1471
+ 4. Roll up the uncovered paths' immediate parents into ≤ remaining
1472
+ budget buckets, **forbidding roll-up into any ancestor of an
1473
+ existing dir** so the output cannot regenerate the over-wide
1474
+ parent we just removed in step 1.
1475
+ 5. Merge existing weights with the rolled-up uncovered buckets,
1476
+ sort by weight desc, truncate to `limit`. Stale 0-weight entries
1477
+ naturally sit at the bottom and get culled when buckets > limit.
1478
+ """
1479
+ if limit is None:
1480
+ limit = FOCUS_AREA_LIMIT
1481
+ if not existing:
1482
+ return summarize_focus_areas(new_paths, limit=limit)
1483
+
1484
+ def _is_under(path, dir_):
1485
+ if dir_ in ("", "."):
1486
+ return "/" not in path
1487
+ return path == dir_ or path.startswith(dir_ + "/")
1488
+
1489
+ def _drop_wider_parents(items):
1490
+ items = list(dict.fromkeys(items)) # dedupe preserving order
1491
+ return [d for d in items if not any(other != d and _is_under(other, d) for other in items)]
1492
+
1493
+ # (1) Drop wider parent entries inside existing.
1494
+ deduped_existing = _drop_wider_parents(existing)
1495
+
1496
+ # (2) Real coverage weight per surviving existing dir.
1497
+ existing_weights = {d: sum(1 for p in new_paths if _is_under(p, d)) for d in deduped_existing}
1498
+
1499
+ # (3) Paths not yet covered by any existing dir.
1500
+ uncovered = [p for p in new_paths if not any(_is_under(p, d) for d in deduped_existing)]
1501
+
1502
+ # (4) Forbidden roll-up targets = any ancestor of a surviving existing
1503
+ # dir. Without this guard the roll-up of `uncovered` could produce a
1504
+ # parent like `apps` and we'd be back where we started.
1505
+ forbidden_ancestors = set()
1506
+ for d in deduped_existing:
1507
+ if d in ("", "."):
1508
+ continue
1509
+ parent = Path(d).parent
1510
+ while True:
1511
+ key = parent.as_posix()
1512
+ if key in ("", "."):
1513
+ break
1514
+ forbidden_ancestors.add(key)
1515
+ if parent == parent.parent:
1516
+ break
1517
+ parent = parent.parent
1518
+
1519
+ # Reserve slots for existing entries; uncovered fills the rest.
1520
+ remaining_budget = max(1, limit - len(existing_weights))
1521
+
1522
+ new_buckets = Counter()
1523
+ for p in uncovered:
1524
+ new_buckets[_initial_parent(p)] += 1
1525
+
1526
+ while len(new_buckets) > remaining_budget:
1527
+ proposals = {}
1528
+ for key, count in new_buckets.items():
1529
+ if not _can_roll_up(key):
1530
+ continue
1531
+ new_key = _rolled_up(key)
1532
+ if new_key in forbidden_ancestors:
1533
+ # Rolling up here would regenerate a wide parent we just
1534
+ # dropped — leave the deeper buckets where they are.
1535
+ continue
1536
+ entry = proposals.setdefault(new_key, [0, []])
1537
+ entry[0] += count
1538
+ entry[1].append(key)
1539
+ if not proposals:
1540
+ # No legal roll-up move left; live with the current bucket count
1541
+ # — final ranking + limit will handle the rest.
1542
+ break
1543
+
1544
+ def score(item):
1545
+ new_key, (sum_count, _) = item
1546
+ depth = len(Path(new_key).parts) if new_key not in ("", ".") else 0
1547
+ return (sum_count, depth, new_key)
1548
+
1549
+ winner_key, (winner_count, originals) = max(proposals.items(), key=score)
1550
+ for k in originals:
1551
+ new_buckets.pop(k)
1552
+ new_buckets[winner_key] = new_buckets.get(winner_key, 0) + winner_count
1553
+
1554
+ # (5) Quota partition so a flurry of new hot spots can't eject the
1555
+ # user's long-running focus dirs in one shot.
1556
+ #
1557
+ # - existing_quota = ceil(limit/2) → reserved for existing active dirs
1558
+ # - new_quota = floor(limit/2) → reserved for fresh buckets
1559
+ # - either side can borrow unused slots from the other (so we never
1560
+ # leave a slot empty just to enforce the partition).
1561
+ # - stale existing (weight=0) is a fallback that only fills slots left
1562
+ # after both quotas are settled — they ride at the bottom and get
1563
+ # pruned naturally.
1564
+ existing_active = sorted(
1565
+ ((d, w) for d, w in existing_weights.items() if w > 0),
1566
+ key=lambda kv: (-kv[1], kv[0]),
1567
+ )
1568
+ existing_stale = sorted(
1569
+ (d for d, w in existing_weights.items() if w == 0),
1570
+ )
1571
+ new_items = sorted(new_buckets.items(), key=lambda kv: (-kv[1], kv[0]))
1572
+
1573
+ existing_quota = (limit + 1) // 2 # ceil(limit/2)
1574
+ new_quota = limit - existing_quota
1575
+
1576
+ existing_take = list(existing_active[:existing_quota])
1577
+ new_take = list(new_items[:new_quota])
1578
+
1579
+ # Lend leftover slots across the partition before falling back to stale.
1580
+ existing_overflow = existing_quota - len(existing_take)
1581
+ new_overflow = new_quota - len(new_take)
1582
+ if existing_overflow > 0:
1583
+ new_take.extend(new_items[new_quota:new_quota + existing_overflow])
1584
+ if new_overflow > 0:
1585
+ existing_take.extend(existing_active[existing_quota:existing_quota + new_overflow])
1586
+
1587
+ final = []
1588
+ seen = set()
1589
+ for d, _ in existing_take + new_take:
1590
+ if d in seen:
1591
+ continue
1592
+ seen.add(d)
1593
+ final.append(d)
1594
+ for d in existing_stale:
1595
+ if len(final) >= limit:
1596
+ break
1597
+ if d in seen:
1598
+ continue
1599
+ seen.add(d)
1600
+ final.append(d)
1601
+ return final[:limit]
1602
+
1603
+
1604
+ def build_auto_block(facts):
1605
+ base_line = facts["default_base"] or "未检测到 origin/HEAD"
1606
+ head_line = facts["last_seen_head"] or "尚未检测到 HEAD"
1607
+ focus_lines = render_bullets(facts["focus_areas"], empty_text="- 当前未检测到改动目录", wrap_code=True)
1608
+ scope_lines = render_bullets(
1609
+ [f"{item['scope']} ({item['files']} files)" for item in facts["scope_summary"]],
1610
+ empty_text="- 当前未检测到改动范围",
1611
+ )
1612
+ return (
1613
+ "### 自动生成\n\n"
1614
+ f"- 更新时间: {facts['updated_at']}\n"
1615
+ f"- 当前分支: {facts['branch']}\n"
1616
+ f"- 默认基线分支: {base_line}\n"
1617
+ f"- 当前 HEAD: {head_line}\n\n"
1618
+ "#### 建议优先查看的目录\n\n"
1619
+ f"{focus_lines}\n\n"
1620
+ "#### 顶层改动范围\n\n"
1621
+ f"{scope_lines}\n\n"
1622
+ "#### 按需查看提交历史\n\n"
1623
+ f"- `git log --oneline -n {RECENT_COMMITS_FOR_FOCUS} --no-merges`\n"
1624
+ "- `git diff --name-only`\n"
1625
+ )
1626
+
1627
+
1628
+ def ensure_progress_auto_block(path):
1629
+ """Idempotently ensure progress.md has the auto-sync marker pair. Called
1630
+ before any auto-block replace/sync so freshly created files (or hand-
1631
+ edited ones that lost the markers) stay writable by sync_progress().
1632
+ """
1633
+ content = path.read_text(encoding="utf-8")
1634
+ if AUTO_START in content and AUTO_END in content:
1635
+ return content
1636
+
1637
+ marker = "## 自动同步区"
1638
+ auto_section = (
1639
+ f"\n\n{marker}\n\n"
1640
+ "本区由 `dev-memory-cli capture sync-working-tree` / SessionStart hook 自动刷新,请不要手工编辑。\n\n"
1641
+ f"{AUTO_START}\n"
1642
+ "_尚未同步_\n"
1643
+ f"{AUTO_END}\n"
1644
+ )
1645
+
1646
+ if marker in content:
1647
+ before, _ = content.split(marker, 1)
1648
+ updated = before.rstrip() + auto_section
1649
+ else:
1650
+ updated = content.rstrip() + auto_section
1651
+
1652
+ path.write_text(updated + ("" if updated.endswith("\n") else "\n"), encoding="utf-8")
1653
+ return path.read_text(encoding="utf-8")
1654
+
1655
+
1656
+ def replace_auto_block(content, replacement):
1657
+ if AUTO_START not in content or AUTO_END not in content:
1658
+ raise RuntimeError("progress.md is missing auto-generated markers")
1659
+ before, remainder = content.split(AUTO_START, 1)
1660
+ _, after = remainder.split(AUTO_END, 1)
1661
+ return f"{before}{AUTO_START}\n{replacement.rstrip()}\n{AUTO_END}{after}"
1662
+
1663
+
1664
+ # ---------------------------------------------------------------------------
1665
+ # Section-level markdown editing
1666
+ # ---------------------------------------------------------------------------
1667
+
1668
+ def split_sections(content):
1669
+ positions = list(re.finditer(r"^## (.+?)\n", content, re.M))
1670
+ if not positions:
1671
+ return content.rstrip(), []
1672
+
1673
+ prefix = content[: positions[0].start()].rstrip()
1674
+ sections = []
1675
+ for index, match in enumerate(positions):
1676
+ end = positions[index + 1].start() if index + 1 < len(positions) else len(content)
1677
+ title = match.group(1).strip()
1678
+ body = content[match.end() : end].strip()
1679
+ sections.append((title, body))
1680
+ return prefix, sections
1681
+
1682
+
1683
+ def join_sections(prefix, sections):
1684
+ parts = []
1685
+ prefix = prefix.rstrip()
1686
+ if prefix:
1687
+ parts.append(prefix)
1688
+ for title, body in sections:
1689
+ parts.append(f"## {title}\n\n{body.strip()}")
1690
+ return "\n\n".join(parts).rstrip() + "\n"
1691
+
1692
+
1693
+ def upsert_markdown_section(path, title, body):
1694
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
1695
+ prefix, sections = split_sections(content)
1696
+ target = title.strip()
1697
+ updated = []
1698
+ replaced = False
1699
+ for existing_title, existing_body in sections:
1700
+ if existing_title.strip() == target:
1701
+ if not replaced:
1702
+ updated.append((title, body))
1703
+ replaced = True
1704
+ # drop duplicates if any.
1705
+ else:
1706
+ updated.append((existing_title, existing_body))
1707
+ if not replaced:
1708
+ updated.append((title, body))
1709
+ path.write_text(join_sections(prefix, updated), encoding="utf-8")
1710
+
1711
+
1712
+ def _section_is_placeholder_only(text):
1713
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
1714
+ if not lines:
1715
+ return True
1716
+ return all(any(marker in line for marker in PLACEHOLDER_MARKERS) for line in lines)
1717
+
1718
+
1719
+ def append_to_section(path, title, body):
1720
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
1721
+ prefix, sections = split_sections(content)
1722
+ target = title.strip()
1723
+ matched = False
1724
+ updated = []
1725
+ for existing_title, existing_body in sections:
1726
+ if existing_title.strip() == target and not matched:
1727
+ if _section_is_placeholder_only(existing_body):
1728
+ combined = body.strip()
1729
+ else:
1730
+ combined = (existing_body.rstrip() + "\n" + body.strip()).strip()
1731
+ updated.append((existing_title, combined))
1732
+ matched = True
1733
+ else:
1734
+ updated.append((existing_title, existing_body))
1735
+ if not matched:
1736
+ updated.append((title, body.strip()))
1737
+ path.write_text(join_sections(prefix, updated), encoding="utf-8")
1738
+
1739
+
1740
+ def upsert_progress_section(path, title, body):
1741
+ """upsert a section into progress.md while preserving the auto-sync block
1742
+ at the end of the file. Any non-auto-sync sections before the marker get
1743
+ normal upsert semantics.
1744
+ """
1745
+ content = ensure_progress_auto_block(path)
1746
+ marker = "## 自动同步区"
1747
+ if marker not in content:
1748
+ raise RuntimeError("progress.md is missing the auto-sync section heading")
1749
+ before, after = content.split(marker, 1)
1750
+ prefix, sections = split_sections(before.rstrip())
1751
+ target = title.strip()
1752
+ updated = []
1753
+ replaced = False
1754
+ for existing_title, existing_body in sections:
1755
+ if existing_title.strip() == target:
1756
+ if not replaced:
1757
+ updated.append((title, body))
1758
+ replaced = True
1759
+ else:
1760
+ updated.append((existing_title, existing_body))
1761
+ if not replaced:
1762
+ updated.append((title, body))
1763
+ rewritten = join_sections(prefix, updated).rstrip() + "\n\n" + marker + after
1764
+ path.write_text(rewritten, encoding="utf-8")
1765
+
1766
+
1767
+ def sync_progress(paths, facts):
1768
+ """Refresh progress.md — writes both the human-readable focus section and
1769
+ the auto-sync block. Called by capture's `sync-working-tree` subcommand
1770
+ and by context's `sync`.
1771
+ """
1772
+ upsert_progress_section(
1773
+ paths["progress"],
1774
+ "建议优先查看的目录",
1775
+ render_bullets(facts["focus_areas"], empty_text="- 当前未检测到改动目录", wrap_code=True),
1776
+ )
1777
+ current = ensure_progress_auto_block(paths["progress"])
1778
+ updated = replace_auto_block(current, build_auto_block(facts))
1779
+ paths["progress"].write_text(updated, encoding="utf-8")
1780
+
1781
+
1782
+ # ---------------------------------------------------------------------------
1783
+ # Event log (append-only timeline)
1784
+ # ---------------------------------------------------------------------------
1785
+
1786
+ # Cap on the inline summary so a runaway body doesn't produce a 5K-char log
1787
+ # line. Anything longer is truncated with `…` — the full content already
1788
+ # lives in the target file the entry points at.
1789
+ _LOG_SUMMARY_MAX = 160
1790
+
1791
+
1792
+ def _summarize_for_log(text):
1793
+ """Collapse multi-line text to a single-line summary suitable for the
1794
+ H2 header. Strips bullet markers and trims to _LOG_SUMMARY_MAX chars."""
1795
+ if not text:
1796
+ return ""
1797
+ first = ""
1798
+ for line in str(text).splitlines():
1799
+ s = line.strip()
1800
+ if not s:
1801
+ continue
1802
+ if s.startswith(("- ", "* ")):
1803
+ s = s[2:].strip()
1804
+ first = s
1805
+ break
1806
+ if len(first) > _LOG_SUMMARY_MAX:
1807
+ first = first[: _LOG_SUMMARY_MAX - 1].rstrip() + "…"
1808
+ return first
1809
+
1810
+
1811
+ def append_log_event(log_path, action, *, kind=None, summary=None, details=None):
1812
+ """Append an event line to a log.md file.
1813
+
1814
+ Format:
1815
+ ## [<ISO timestamp>] <action>[ · <kind>][ | <summary>]
1816
+ - key: value # optional
1817
+ - key: value
1818
+
1819
+ `action` — short verb: capture / rewrite-entry / tidy / graduate / etc.
1820
+ `kind` — sub-classifier (e.g. "decision", "apply"); optional.
1821
+ `summary` — one-line content preview; multi-line input is collapsed.
1822
+ `details` — iterable of (key, value) pairs rendered as `- key: value`
1823
+ lines under the header. Values are str()-cast.
1824
+
1825
+ Idempotent on file creation: ensures the file exists with a minimal
1826
+ skeleton if missing (covers callers operating on legacy branch dirs
1827
+ that pre-date this feature).
1828
+ """
1829
+ if log_path is None:
1830
+ return
1831
+ if not log_path.exists():
1832
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1833
+ log_path.write_text(template_log("unknown"), encoding="utf-8")
1834
+
1835
+ header_parts = [f"## [{now_iso()}] {action}"]
1836
+ if kind:
1837
+ header_parts.append(f" · {kind}")
1838
+ summary_clean = _summarize_for_log(summary) if summary else ""
1839
+ if summary_clean:
1840
+ header_parts.append(f" | {summary_clean}")
1841
+ header = "".join(header_parts)
1842
+
1843
+ lines = [header]
1844
+ for key, value in (details or []):
1845
+ if value is None or value == "":
1846
+ continue
1847
+ lines.append(f"- {key}: {value}")
1848
+
1849
+ block = "\n".join(lines) + "\n"
1850
+ existing = log_path.read_text(encoding="utf-8")
1851
+ if not existing.endswith("\n"):
1852
+ existing += "\n"
1853
+ log_path.write_text(existing + "\n" + block, encoding="utf-8")
1854
+
1855
+
1856
+ # ---------------------------------------------------------------------------
1857
+ # Archive (graduate)
1858
+ # ---------------------------------------------------------------------------
1859
+
1860
+ ARCHIVE_DIR_NAME = "_archived"
1861
+ ARCHIVE_INDEX_NAME = "INDEX.md"
1862
+
1863
+
1864
+ def archive_root_dir(repo_dir):
1865
+ return repo_dir / "branches" / ARCHIVE_DIR_NAME
1866
+
1867
+
1868
+ def build_archive_summary(branch_manifest, git_log_lines, harvest_notes=None):
1869
+ parts = ["# 归档快照", ""]
1870
+ if harvest_notes:
1871
+ parts.extend(["## Harvest 备注", "", harvest_notes.strip(), ""])
1872
+ parts.extend([
1873
+ "## 归档时元数据",
1874
+ "",
1875
+ f"- 归档时间: {now_iso()}",
1876
+ f"- 分支: {branch_manifest.get('branch', '<unknown>')}",
1877
+ f"- 最终 HEAD: {branch_manifest.get('last_seen_head') or '<unknown>'}",
1878
+ f"- 默认基线: {branch_manifest.get('default_base') or '<unknown>'}",
1879
+ f"- 最近 capture 标题: {branch_manifest.get('last_session_sync_title') or '<none>'}",
1880
+ "",
1881
+ ])
1882
+ if git_log_lines:
1883
+ parts.append("## Git log (base..HEAD, oneline)")
1884
+ parts.append("")
1885
+ parts.append("```")
1886
+ parts.extend(git_log_lines)
1887
+ parts.append("```")
1888
+ parts.append("")
1889
+ return "\n".join(parts).rstrip() + "\n"
1890
+
1891
+
1892
+ def archive_branch_dir(branch_dir, archive_dst):
1893
+ archive_dst.parent.mkdir(parents=True, exist_ok=True)
1894
+ if archive_dst.exists():
1895
+ raise RuntimeError(f"archive destination already exists: {archive_dst}")
1896
+ shutil.move(str(branch_dir), str(archive_dst))
1897
+
1898
+
1899
+ def append_archive_index(index_path, line):
1900
+ if not index_path.exists():
1901
+ index_path.write_text(
1902
+ "# 归档分支索引\n\n按归档时间倒序追加。每条记录格式:\n\n"
1903
+ "`- <YYYY-MM-DD> <branch_name> (HEAD <sha>) → harvested <N> entries: <notes>`\n\n",
1904
+ encoding="utf-8",
1905
+ )
1906
+ with index_path.open("a", encoding="utf-8") as fh:
1907
+ fh.write(line.rstrip() + "\n")
1908
+
1909
+
1910
+ # ---------------------------------------------------------------------------
1911
+ # Health / metadata helpers
1912
+ # ---------------------------------------------------------------------------
1913
+
1914
+ def list_missing_docs(paths):
1915
+ """Return keys whose file is missing or still contains placeholder text.
1916
+ Skips manifest/artifacts/history and legacy files (handled elsewhere).
1917
+ """
1918
+ missing = []
1919
+ skip_keys = {"manifest", "repo_manifest", "artifacts", "history", "repo_artifacts"}
1920
+ for key, path in paths.items():
1921
+ if key in skip_keys:
1922
+ continue
1923
+ if not path.exists():
1924
+ missing.append(key)
1925
+ continue
1926
+ content = path.read_text(encoding="utf-8")
1927
+ if any(marker in content for marker in PLACEHOLDER_MARKERS):
1928
+ missing.append(key)
1929
+ return missing
1930
+
1931
+
1932
+ def get_head_commit(repo_root):
1933
+ sha = git_stdout(["rev-parse", "HEAD"], cwd=repo_root, check=False)
1934
+ return sha or None