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,456 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PACKAGE_ROOT = Path(__file__).resolve().parents[2]
|
|
12
|
+
REPO_ROOT = Path(
|
|
13
|
+
os.environ.get("DEV_MEMORY_HOOK_REPO_ROOT")
|
|
14
|
+
or os.environ.get("DEV_ASSETS_HOOK_REPO_ROOT")
|
|
15
|
+
or "."
|
|
16
|
+
).expanduser().resolve()
|
|
17
|
+
LIB_ROOT = PACKAGE_ROOT / "lib"
|
|
18
|
+
if str(LIB_ROOT) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(LIB_ROOT))
|
|
20
|
+
|
|
21
|
+
from dev_memory_common import (
|
|
22
|
+
AUTO_END,
|
|
23
|
+
AUTO_START,
|
|
24
|
+
PLACEHOLDER_MARKERS,
|
|
25
|
+
asset_paths,
|
|
26
|
+
detect_no_git_mode,
|
|
27
|
+
detect_workspace_mode,
|
|
28
|
+
get_branch_paths,
|
|
29
|
+
list_repos_in_workspace,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CONTEXT_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_context.py"
|
|
34
|
+
# v2: sync/update merged into capture. All auto-block refresh and
|
|
35
|
+
# record-head calls now go through the capture script.
|
|
36
|
+
CAPTURE_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_capture.py"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_python(script_path, *args, cwd=None):
|
|
40
|
+
work_cwd = cwd if cwd is not None else REPO_ROOT
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["python3", str(script_path), *args],
|
|
43
|
+
cwd=work_cwd,
|
|
44
|
+
check=False,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
)
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"command failed: {script_path}")
|
|
50
|
+
return result.stdout.strip()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def log(message):
|
|
54
|
+
print(message, file=sys.stderr)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_assets_for(repo_root):
|
|
58
|
+
"""Resolve asset paths for an explicit repo root (workspace-mode friendly)."""
|
|
59
|
+
repo_root_str = str(repo_root)
|
|
60
|
+
root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir = get_branch_paths(repo_root_str)
|
|
61
|
+
return {
|
|
62
|
+
"repo_root": root,
|
|
63
|
+
"branch_name": branch_name,
|
|
64
|
+
"branch_key": branch_key,
|
|
65
|
+
"storage_root": storage_root,
|
|
66
|
+
"repo_key": repo_key,
|
|
67
|
+
"repo_dir": repo_dir,
|
|
68
|
+
"branch_dir": branch_dir,
|
|
69
|
+
"paths": asset_paths(repo_dir, branch_dir),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_assets():
|
|
74
|
+
return resolve_assets_for(REPO_ROOT)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_workspace_mode():
|
|
78
|
+
return detect_workspace_mode(str(REPO_ROOT))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_no_git_mode():
|
|
82
|
+
return detect_no_git_mode(str(REPO_ROOT))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_workspace_repos():
|
|
86
|
+
return list_repos_in_workspace(str(REPO_ROOT))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def primary_repo_name():
|
|
90
|
+
"""Basename of the focus repo from env; None if unset."""
|
|
91
|
+
value = (
|
|
92
|
+
os.environ.get("DEV_MEMORY_PRIMARY_REPO", "").strip()
|
|
93
|
+
or os.environ.get("DEV_ASSETS_PRIMARY_REPO", "").strip()
|
|
94
|
+
)
|
|
95
|
+
return value or None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def strip_managed_markers(text):
|
|
99
|
+
return text.replace(AUTO_START, "").replace(AUTO_END, "").replace("_尚未同步_", "").strip()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Sentinels produced by render_bullets/build_auto_block when git introspection
|
|
103
|
+
# finds nothing to report. Not added to lib PLACEHOLDER_MARKERS because
|
|
104
|
+
# list_missing_docs would then false-flag these as "section missing" — the user
|
|
105
|
+
# can't fill them in, they're auto-derived. Filtered only at injection time.
|
|
106
|
+
EMPTY_SENTINELS = (
|
|
107
|
+
"当前未检测到改动目录",
|
|
108
|
+
"当前未检测到改动范围",
|
|
109
|
+
"未检测到 origin/HEAD",
|
|
110
|
+
"尚未检测到 HEAD",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_placeholder(text):
|
|
115
|
+
stripped = strip_managed_markers(text)
|
|
116
|
+
if not stripped:
|
|
117
|
+
return True
|
|
118
|
+
if any(marker in stripped for marker in PLACEHOLDER_MARKERS):
|
|
119
|
+
return True
|
|
120
|
+
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
|
|
121
|
+
if lines and all(any(sentinel in line for sentinel in EMPTY_SENTINELS) for line in lines):
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def extract_section(path, title):
|
|
127
|
+
if not path.exists():
|
|
128
|
+
return None
|
|
129
|
+
content = path.read_text(encoding="utf-8")
|
|
130
|
+
match = re.search(rf"^## {re.escape(title)}\n\n(.*?)(?=^## |\Z)", content, flags=re.MULTILINE | re.DOTALL)
|
|
131
|
+
if not match:
|
|
132
|
+
return None
|
|
133
|
+
body = strip_managed_markers(match.group(1)).strip()
|
|
134
|
+
return None if is_placeholder(body) else body
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def compact_body(text, max_lines=8, max_chars=700):
|
|
138
|
+
"""Compact a section body. Returns (compacted_text, was_truncated). The
|
|
139
|
+
caller uses `was_truncated` to decide whether to append a "see full file"
|
|
140
|
+
hint so the AI doesn't mistake the trimmed snippet for the whole story.
|
|
141
|
+
"""
|
|
142
|
+
normalized = "\n".join(line.rstrip() for line in text.splitlines()).strip()
|
|
143
|
+
lines = [line for line in normalized.splitlines() if line.strip()]
|
|
144
|
+
truncated = False
|
|
145
|
+
if len(lines) > max_lines:
|
|
146
|
+
lines = lines[:max_lines]
|
|
147
|
+
if not lines[-1].endswith("..."):
|
|
148
|
+
lines.append("...")
|
|
149
|
+
truncated = True
|
|
150
|
+
compacted = "\n".join(lines)
|
|
151
|
+
if len(compacted) > max_chars:
|
|
152
|
+
compacted = compacted[: max_chars - 3].rstrip() + "..."
|
|
153
|
+
truncated = True
|
|
154
|
+
return compacted, truncated
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def sync_context_for(repo_root):
|
|
158
|
+
return json.loads(
|
|
159
|
+
run_python(CONTEXT_SCRIPT, "sync", "--repo", str(repo_root), cwd=str(repo_root))
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def sync_working_tree_for(repo_root):
|
|
164
|
+
return json.loads(
|
|
165
|
+
run_python(CAPTURE_SCRIPT, "sync-working-tree", "--repo", str(repo_root), cwd=str(repo_root))
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def record_head_for(repo_root):
|
|
170
|
+
return json.loads(
|
|
171
|
+
run_python(CAPTURE_SCRIPT, "record-head", "--repo", str(repo_root), cwd=str(repo_root))
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def maybe_sync_context():
|
|
176
|
+
return sync_context_for(REPO_ROOT)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def maybe_sync_working_tree():
|
|
180
|
+
return sync_working_tree_for(REPO_ROOT)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def maybe_record_head():
|
|
184
|
+
return record_head_for(REPO_ROOT)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# v2 section map: branch files split by domain. progress.md carries
|
|
188
|
+
# "建议优先查看的目录", "当前进展", "下一步"; risks.md carries "阻塞与注意点" and
|
|
189
|
+
# "后续继续前要注意"; decisions.md carries "关键决策与原因"; glossary.md carries
|
|
190
|
+
# "当前有效上下文".
|
|
191
|
+
_FULL_SECTION_KEYS = (
|
|
192
|
+
("overview", "当前目标"),
|
|
193
|
+
("overview", "范围边界"),
|
|
194
|
+
("overview", "当前阶段"),
|
|
195
|
+
("overview", "关键约束"),
|
|
196
|
+
("progress", "建议优先查看的目录"),
|
|
197
|
+
("progress", "当前进展"),
|
|
198
|
+
("risks", "阻塞与注意点"),
|
|
199
|
+
("progress", "下一步"),
|
|
200
|
+
("glossary", "当前有效上下文"),
|
|
201
|
+
("decisions", "关键决策与原因"),
|
|
202
|
+
("risks", "后续继续前要注意"),
|
|
203
|
+
("repo_overview", "长期目标与边界"),
|
|
204
|
+
("repo_overview", "仓库级关键约束"),
|
|
205
|
+
("repo_glossary", "共享入口"),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
_BRIEF_SECTION_KEYS = (
|
|
209
|
+
("overview", "当前目标"),
|
|
210
|
+
("overview", "当前阶段"),
|
|
211
|
+
("progress", "当前进展"),
|
|
212
|
+
("progress", "下一步"),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _extract_sections(paths, keys):
|
|
217
|
+
out = []
|
|
218
|
+
for file_key, title in keys:
|
|
219
|
+
body = extract_section(paths[file_key], title)
|
|
220
|
+
out.append((title, body, file_key))
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _build_context_from_assets(assets, *, full=True, heading=None):
|
|
225
|
+
if not assets["branch_dir"].exists():
|
|
226
|
+
# v2: capture lazy-inits on first write, so branch_dir typically
|
|
227
|
+
# exists after any real interaction. Missing here just means no
|
|
228
|
+
# write has happened yet — no need to push setup.
|
|
229
|
+
if heading is None:
|
|
230
|
+
return (
|
|
231
|
+
"当前仓库+分支还没有 dev-memory 记忆。"
|
|
232
|
+
"下一次 `dev-memory-capture` 写入时会自动 lazy init;现有结论若值得记一笔,直接走 capture。"
|
|
233
|
+
)
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
paths = assets["paths"]
|
|
237
|
+
keys = _FULL_SECTION_KEYS if full else _BRIEF_SECTION_KEYS
|
|
238
|
+
sections = _extract_sections(paths, keys)
|
|
239
|
+
max_lines, max_chars = (8, 700) if full else (3, 200)
|
|
240
|
+
|
|
241
|
+
parts = []
|
|
242
|
+
no_git = assets.get("branch_name") is None
|
|
243
|
+
if heading is not None:
|
|
244
|
+
# Workspace mode: caller passes a per-repo heading (e.g. "## [PRIMARY]
|
|
245
|
+
# repo @ branch") so multi-repo blocks are still distinguishable.
|
|
246
|
+
parts.append(heading)
|
|
247
|
+
elif no_git:
|
|
248
|
+
# No-git has nothing in the footer paths to identify scope by, keep a
|
|
249
|
+
# minimal label.
|
|
250
|
+
parts.append("已加载 dev-memory(no-git 模式)。")
|
|
251
|
+
# Single-repo + git: skip the heading. Branch identity is derivable from
|
|
252
|
+
# the footer's directory header (.../branches/<branch>/).
|
|
253
|
+
|
|
254
|
+
any_truncated = False
|
|
255
|
+
for title, body, file_key in sections:
|
|
256
|
+
if not body:
|
|
257
|
+
continue
|
|
258
|
+
compacted, truncated = compact_body(body, max_lines=max_lines, max_chars=max_chars)
|
|
259
|
+
block = f"{title}:\n{compacted}"
|
|
260
|
+
if truncated:
|
|
261
|
+
file_path = paths.get(file_key)
|
|
262
|
+
if file_path is not None:
|
|
263
|
+
# Plain-text anchor (no markdown italic). Filename-only — the
|
|
264
|
+
# absolute prefix lives in the footer's directory header so we
|
|
265
|
+
# avoid printing the same prefix on every truncation.
|
|
266
|
+
block += f"\n↪ 完整: {file_path.name}"
|
|
267
|
+
any_truncated = True
|
|
268
|
+
parts.append(block)
|
|
269
|
+
|
|
270
|
+
# Footer: dump the authoritative memory layout so the agent can Read files
|
|
271
|
+
# directly. Replaces the retired dev-memory-context skill. Path layout is
|
|
272
|
+
# "directory header + relative filenames" to keep the footer compact.
|
|
273
|
+
if not no_git:
|
|
274
|
+
archive_root = paths.get("repo_artifacts")
|
|
275
|
+
archive_dir = (
|
|
276
|
+
archive_root.parent.parent / "branches" / "_archived"
|
|
277
|
+
if archive_root is not None
|
|
278
|
+
else None
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
branch_specs = (
|
|
282
|
+
("progress", "hot 层:当前进展 + 下一步 + 自动同步区"),
|
|
283
|
+
("risks", "hot 层:阻塞 + 后续注意点"),
|
|
284
|
+
("decisions", "决策背景(为什么这么做)"),
|
|
285
|
+
("glossary", "术语 + 源资料入口"),
|
|
286
|
+
("overview", "分支概览(目标 / 范围 / 阶段 / 约束)"),
|
|
287
|
+
("log", "事件日志(append-only;`grep '^## \\[' log.md | tail -20` 看最近事件)"),
|
|
288
|
+
)
|
|
289
|
+
repo_specs = (
|
|
290
|
+
("repo_overview", "长期目标 + 跨分支约束"),
|
|
291
|
+
("repo_decisions", "跨分支通用决策"),
|
|
292
|
+
("repo_glossary", "长期背景 + 共享入口"),
|
|
293
|
+
("repo_log", "仓库事件日志(graduate / 共享层 capture)"),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _group(specs):
|
|
297
|
+
lines = []
|
|
298
|
+
common_dir = None
|
|
299
|
+
for key, label in specs:
|
|
300
|
+
p = paths.get(key)
|
|
301
|
+
if p is None:
|
|
302
|
+
continue
|
|
303
|
+
if common_dir is None:
|
|
304
|
+
common_dir = p.parent
|
|
305
|
+
lines.append(f"- {p.name} — {label}")
|
|
306
|
+
return common_dir, lines
|
|
307
|
+
|
|
308
|
+
branch_dir, branch_lines = _group(branch_specs)
|
|
309
|
+
repo_dir, repo_lines = _group(repo_specs)
|
|
310
|
+
|
|
311
|
+
footer_lines = ["---"]
|
|
312
|
+
if full:
|
|
313
|
+
opening = (
|
|
314
|
+
"SessionStart 注入的浓缩摘要 — "
|
|
315
|
+
+ (
|
|
316
|
+
"上文标注 ↪ 的段落已截断,详情 Read 对应文件。"
|
|
317
|
+
if any_truncated
|
|
318
|
+
else "需要更多细节时直接 Read 下面列出的文件。"
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
opening = "Brief 摘要。本 repo 完整记忆见以下文件:"
|
|
323
|
+
footer_lines.append(opening)
|
|
324
|
+
if branch_lines and branch_dir:
|
|
325
|
+
footer_lines.extend(["", f"分支层 `{branch_dir}/`:", *branch_lines])
|
|
326
|
+
if repo_lines and repo_dir:
|
|
327
|
+
footer_lines.extend(["", f"repo 共享层 `{repo_dir}/`:", *repo_lines])
|
|
328
|
+
if archive_dir is not None:
|
|
329
|
+
footer_lines.extend([
|
|
330
|
+
"",
|
|
331
|
+
f"归档分支查询:`grep -r 'KEYWORD' {archive_dir}/` (体量大时派 Task 子 agent)",
|
|
332
|
+
])
|
|
333
|
+
footer_lines.extend(["", "新决策 / 进展 / 阻塞 → `dev-memory-capture` 写入。"])
|
|
334
|
+
parts.append("\n".join(footer_lines))
|
|
335
|
+
|
|
336
|
+
return "\n\n".join(parts)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def build_session_start_context():
|
|
340
|
+
assets = resolve_assets()
|
|
341
|
+
# no-git mode skips maybe_sync_context() because that path runs git commands
|
|
342
|
+
# (working-tree diffing, focus-area detection) that don't apply here.
|
|
343
|
+
if assets.get("branch_name") is not None:
|
|
344
|
+
try:
|
|
345
|
+
maybe_sync_context()
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
log(f"[dev-memory][SessionStart] refresh skipped: {exc}")
|
|
348
|
+
return _build_context_from_assets(assets, full=True)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def build_context_for_repo(repo_path, *, full=True, is_primary=False):
|
|
352
|
+
"""Build a per-repo context block for workspace-mode SessionStart injection.
|
|
353
|
+
Returns None when the repo has no initialized branch memory or resolution fails.
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
assets = resolve_assets_for(repo_path)
|
|
357
|
+
except Exception as exc:
|
|
358
|
+
log(f"[dev-memory] resolve failed for {Path(repo_path).name}: {exc}")
|
|
359
|
+
return None
|
|
360
|
+
try:
|
|
361
|
+
sync_context_for(repo_path)
|
|
362
|
+
except Exception as exc:
|
|
363
|
+
log(f"[dev-memory] context sync skipped for {Path(repo_path).name}: {exc}")
|
|
364
|
+
tag = "[PRIMARY] " if is_primary else ""
|
|
365
|
+
heading = (
|
|
366
|
+
f"## {tag}`{Path(repo_path).name}` @ branch `{assets['branch_name']}`"
|
|
367
|
+
)
|
|
368
|
+
return _build_context_from_assets(assets, full=full, heading=heading)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def build_workspace_start_context():
|
|
372
|
+
"""SessionStart context for workspace mode. Primary repo gets full memory;
|
|
373
|
+
others get a brief overview only. Returns None if no initialized repos.
|
|
374
|
+
|
|
375
|
+
Fallback when DEV_MEMORY_PRIMARY_REPO is unset:
|
|
376
|
+
- Single-repo workspace → that repo is full (user's intent is obvious).
|
|
377
|
+
- Multi-repo workspace → all brief, so N full dumps can't drown the
|
|
378
|
+
session. Header tells the agent how to promote one to full.
|
|
379
|
+
"""
|
|
380
|
+
repos = list_workspace_repos()
|
|
381
|
+
if not repos:
|
|
382
|
+
return None
|
|
383
|
+
primary = primary_repo_name()
|
|
384
|
+
only_one_repo = len(repos) == 1
|
|
385
|
+
primary_hit = False
|
|
386
|
+
has_brief = False
|
|
387
|
+
sections = []
|
|
388
|
+
for repo_path in repos:
|
|
389
|
+
if primary is not None:
|
|
390
|
+
is_primary = (repo_path.name == primary)
|
|
391
|
+
else:
|
|
392
|
+
is_primary = only_one_repo
|
|
393
|
+
if is_primary:
|
|
394
|
+
primary_hit = True
|
|
395
|
+
else:
|
|
396
|
+
has_brief = True
|
|
397
|
+
ctx = build_context_for_repo(repo_path, full=is_primary, is_primary=is_primary)
|
|
398
|
+
if ctx:
|
|
399
|
+
sections.append(ctx)
|
|
400
|
+
if not sections:
|
|
401
|
+
return None
|
|
402
|
+
header_parts = [
|
|
403
|
+
f"已加载 dev-memory workspace 模式:共 {len(repos)} 个仓库 @ `{REPO_ROOT}`"
|
|
404
|
+
]
|
|
405
|
+
if primary:
|
|
406
|
+
status = "命中" if primary_hit else "未在 workspace 中找到"
|
|
407
|
+
header_parts.append(f"Primary 仓库:`{primary}` ({status})")
|
|
408
|
+
if has_brief:
|
|
409
|
+
header_parts.append(
|
|
410
|
+
"_其它仓库按 brief 摘要注入;每个 brief 末尾列出该仓库的完整记忆文件路径,"
|
|
411
|
+
"聚焦时直接 Read 即可(如需 CLI:`dev-memory-cli context show --repo <name>`)。_"
|
|
412
|
+
)
|
|
413
|
+
header = "\n".join(header_parts)
|
|
414
|
+
return header + "\n\n---\n\n" + "\n\n---\n\n".join(sections)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def record_head_all_repos():
|
|
418
|
+
"""Stop/SessionEnd hook helper for workspace mode. Iterates all repos; logs per-repo
|
|
419
|
+
outcome; swallows per-repo failures.
|
|
420
|
+
"""
|
|
421
|
+
results = []
|
|
422
|
+
for repo_path in list_workspace_repos():
|
|
423
|
+
try:
|
|
424
|
+
assets = resolve_assets_for(repo_path)
|
|
425
|
+
if not assets["branch_dir"].exists():
|
|
426
|
+
log(f"[dev-memory] {repo_path.name}: branch memory not initialized, skip")
|
|
427
|
+
continue
|
|
428
|
+
payload = record_head_for(repo_path)
|
|
429
|
+
log(
|
|
430
|
+
f"[dev-memory] {repo_path.name}: recorded HEAD "
|
|
431
|
+
f"{payload.get('last_seen_head')} for {payload.get('branch')}"
|
|
432
|
+
)
|
|
433
|
+
results.append((repo_path.name, payload))
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
log(f"[dev-memory] {repo_path.name}: record-head skipped: {exc}")
|
|
436
|
+
return results
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def sync_working_tree_all_repos():
|
|
440
|
+
"""PreCompact hook helper for workspace mode. Iterates all repos."""
|
|
441
|
+
results = []
|
|
442
|
+
for repo_path in list_workspace_repos():
|
|
443
|
+
try:
|
|
444
|
+
assets = resolve_assets_for(repo_path)
|
|
445
|
+
if not assets["branch_dir"].exists():
|
|
446
|
+
log(f"[dev-memory] {repo_path.name}: branch memory not initialized, skip")
|
|
447
|
+
continue
|
|
448
|
+
payload = sync_working_tree_for(repo_path)
|
|
449
|
+
log(
|
|
450
|
+
f"[dev-memory] {repo_path.name}: refreshed working-tree navigation for "
|
|
451
|
+
f"{payload.get('branch')} ({payload.get('files_considered')} files)"
|
|
452
|
+
)
|
|
453
|
+
results.append((repo_path.name, payload))
|
|
454
|
+
except Exception as exc:
|
|
455
|
+
log(f"[dev-memory] {repo_path.name}: working-tree sync skipped: {exc}")
|
|
456
|
+
return results
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreCompact hook — intentionally a no-op.
|
|
3
|
+
|
|
4
|
+
In earlier versions this hook refreshed progress.md's auto-sync block via
|
|
5
|
+
`capture sync-working-tree`, but SessionStart already does the same refresh
|
|
6
|
+
at the start of every conversation. Running it again right before context
|
|
7
|
+
compaction adds no signal (the agent's transcript already carries the prior
|
|
8
|
+
sync) and burns extra git commands. Kept as a stub so existing hook
|
|
9
|
+
installations don't error out — safe to remove from settings if you want.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from _common import log
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
log("[dev-memory][PreCompact] no-op (refresh handled at SessionStart)")
|
|
17
|
+
return 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from _common import (
|
|
4
|
+
is_no_git_mode,
|
|
5
|
+
is_workspace_mode,
|
|
6
|
+
log,
|
|
7
|
+
maybe_record_head,
|
|
8
|
+
record_head_all_repos,
|
|
9
|
+
resolve_assets,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
try:
|
|
15
|
+
if is_no_git_mode():
|
|
16
|
+
log("[dev-memory][SessionEnd] no-git mode: nothing to finalize (no HEAD)")
|
|
17
|
+
return 0
|
|
18
|
+
if is_workspace_mode():
|
|
19
|
+
results = record_head_all_repos()
|
|
20
|
+
if not results:
|
|
21
|
+
log("[dev-memory][SessionEnd] workspace mode: no initialized repos finalized")
|
|
22
|
+
return 0
|
|
23
|
+
assets = resolve_assets()
|
|
24
|
+
if not assets["branch_dir"].exists():
|
|
25
|
+
log("[dev-memory][SessionEnd] branch memory not initialized, skip")
|
|
26
|
+
return 0
|
|
27
|
+
payload = maybe_record_head()
|
|
28
|
+
log(f"[dev-memory][SessionEnd] finalized HEAD marker {payload['last_seen_head']} for {payload['branch']}")
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
log(f"[dev-memory][SessionEnd] skipped: {exc}")
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from _common import (
|
|
7
|
+
build_session_start_context,
|
|
8
|
+
build_workspace_start_context,
|
|
9
|
+
is_workspace_mode,
|
|
10
|
+
log,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolve_context():
|
|
15
|
+
if is_workspace_mode():
|
|
16
|
+
ctx = build_workspace_start_context()
|
|
17
|
+
if ctx:
|
|
18
|
+
return ctx
|
|
19
|
+
return "dev-memory workspace 模式:当前 workspace 下未发现已初始化的仓库记忆。"
|
|
20
|
+
return build_session_start_context()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main():
|
|
24
|
+
try:
|
|
25
|
+
additional_context = _resolve_context()
|
|
26
|
+
payload = {
|
|
27
|
+
"hookSpecificOutput": {
|
|
28
|
+
"hookEventName": "SessionStart",
|
|
29
|
+
"additionalContext": additional_context,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
33
|
+
return 0
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
log(f"[dev-memory][SessionStart] skipped: {exc}")
|
|
36
|
+
print(
|
|
37
|
+
json.dumps(
|
|
38
|
+
{
|
|
39
|
+
"hookSpecificOutput": {
|
|
40
|
+
"hookEventName": "SessionStart",
|
|
41
|
+
"additionalContext": "dev-memory SessionStart hook 未能加载上下文,本轮按普通会话继续。",
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
ensure_ascii=False,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from _common import (
|
|
4
|
+
is_no_git_mode,
|
|
5
|
+
is_workspace_mode,
|
|
6
|
+
log,
|
|
7
|
+
maybe_record_head,
|
|
8
|
+
record_head_all_repos,
|
|
9
|
+
resolve_assets,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
try:
|
|
15
|
+
if is_no_git_mode():
|
|
16
|
+
log("[dev-memory][Stop] no-git mode: nothing to record (no HEAD)")
|
|
17
|
+
return 0
|
|
18
|
+
if is_workspace_mode():
|
|
19
|
+
results = record_head_all_repos()
|
|
20
|
+
if not results:
|
|
21
|
+
log("[dev-memory][Stop] workspace mode: no initialized repos recorded")
|
|
22
|
+
return 0
|
|
23
|
+
assets = resolve_assets()
|
|
24
|
+
if not assets["branch_dir"].exists():
|
|
25
|
+
log("[dev-memory][Stop] branch memory not initialized, skip")
|
|
26
|
+
return 0
|
|
27
|
+
payload = maybe_record_head()
|
|
28
|
+
log(f"[dev-memory][Stop] recorded HEAD {payload['last_seen_head']} for {payload['branch']}")
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
log(f"[dev-memory][Stop] skipped: {exc}")
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"suite_name": "dev-memory-skill-suite",
|
|
3
|
+
"skills": [
|
|
4
|
+
"dev-memory-setup",
|
|
5
|
+
"dev-memory-capture",
|
|
6
|
+
"dev-memory-graduate",
|
|
7
|
+
"dev-memory-tidy"
|
|
8
|
+
],
|
|
9
|
+
"legacy_skills": [
|
|
10
|
+
"branch-context",
|
|
11
|
+
"branch-context-setup",
|
|
12
|
+
"dev-asset-setup",
|
|
13
|
+
"dev-asset-context",
|
|
14
|
+
"dev-asset-sync",
|
|
15
|
+
"dev-assets-sync",
|
|
16
|
+
"dev-assets-update",
|
|
17
|
+
"using-dev-assets",
|
|
18
|
+
"using-dev-memory",
|
|
19
|
+
"dev-memory-context",
|
|
20
|
+
"dev-assets-setup",
|
|
21
|
+
"dev-assets-context",
|
|
22
|
+
"dev-assets-capture",
|
|
23
|
+
"dev-assets-graduate",
|
|
24
|
+
"dev-assets-tidy"
|
|
25
|
+
]
|
|
26
|
+
}
|