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.
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/bin/dev-memory.js +590 -0
- package/hooks/README.md +84 -0
- package/hooks/codex-hooks.json +32 -0
- package/hooks/hooks.json +60 -0
- package/lib/assets/tidy_review.html +889 -0
- package/lib/dev_memory_branch.py +853 -0
- package/lib/dev_memory_capture.py +1343 -0
- package/lib/dev_memory_common.py +1934 -0
- package/lib/dev_memory_context.py +129 -0
- package/lib/dev_memory_graduate.py +282 -0
- package/lib/dev_memory_setup.py +256 -0
- package/lib/dev_memory_tidy.py +1622 -0
- package/lib/ui-app.html +1052 -0
- package/lib/ui-server.js +330 -0
- package/package.json +52 -0
- package/scripts/hooks/_common.py +456 -0
- package/scripts/hooks/pre_compact.py +21 -0
- package/scripts/hooks/session_end.py +35 -0
- package/scripts/hooks/session_start.py +51 -0
- package/scripts/hooks/stop.py +35 -0
- package/suite-manifest.json +26 -0
|
@@ -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
|