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,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
|
+
|
|
10
|
+
from dev_memory_common import (
|
|
11
|
+
collect_git_facts,
|
|
12
|
+
ensure_branch_paths_exist,
|
|
13
|
+
get_setup_completed,
|
|
14
|
+
list_missing_docs,
|
|
15
|
+
merged_focus_areas,
|
|
16
|
+
read_json,
|
|
17
|
+
sync_progress,
|
|
18
|
+
write_json,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def command_show(args):
|
|
23
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
24
|
+
args.repo, args.context_dir, args.branch
|
|
25
|
+
)
|
|
26
|
+
payload = {
|
|
27
|
+
"repo_root": str(repo_root),
|
|
28
|
+
"repo_key": repo_key,
|
|
29
|
+
"branch": branch_name,
|
|
30
|
+
"branch_key": branch_key,
|
|
31
|
+
"storage_root": str(storage_root),
|
|
32
|
+
"repo_dir": str(repo_dir),
|
|
33
|
+
"branch_dir": str(branch_dir),
|
|
34
|
+
"setup_completed": get_setup_completed(paths["manifest"]),
|
|
35
|
+
"files": {key: str(value) for key, value in paths.items()},
|
|
36
|
+
"missing_or_placeholder": list_missing_docs(paths),
|
|
37
|
+
}
|
|
38
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def command_sync(args):
|
|
42
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
43
|
+
args.repo, args.context_dir, args.branch
|
|
44
|
+
)
|
|
45
|
+
if branch_name is None:
|
|
46
|
+
# no-git mode — no git facts to derive.
|
|
47
|
+
print(json.dumps({"mode": "no-git", "skipped": True}, ensure_ascii=False))
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
prior_manifest = read_json(paths["manifest"]) or {}
|
|
51
|
+
facts = collect_git_facts(repo_root, branch_name, storage_root)
|
|
52
|
+
all_paths = sorted(set(
|
|
53
|
+
facts["working_tree_files"]
|
|
54
|
+
+ facts["staged_files"]
|
|
55
|
+
+ facts["untracked_files"]
|
|
56
|
+
+ facts["recent_commit_files"]
|
|
57
|
+
))
|
|
58
|
+
facts["focus_areas"] = merged_focus_areas(all_paths, prior_manifest.get("focus_areas") or [])
|
|
59
|
+
sync_progress(paths, facts)
|
|
60
|
+
|
|
61
|
+
manifest = read_json(paths["manifest"])
|
|
62
|
+
manifest.update(
|
|
63
|
+
{
|
|
64
|
+
"repo_root": str(repo_root),
|
|
65
|
+
"repo_key": repo_key,
|
|
66
|
+
"branch": branch_name,
|
|
67
|
+
"branch_key": branch_key,
|
|
68
|
+
"storage_root": str(storage_root),
|
|
69
|
+
"updated_at": facts["updated_at"],
|
|
70
|
+
"last_seen_head": facts["last_seen_head"],
|
|
71
|
+
"default_base": facts["default_base"],
|
|
72
|
+
"scope_summary": facts["scope_summary"],
|
|
73
|
+
"focus_areas": facts["focus_areas"],
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
write_json(paths["manifest"], manifest)
|
|
77
|
+
|
|
78
|
+
repo_manifest = read_json(paths["repo_manifest"])
|
|
79
|
+
repo_manifest.update(
|
|
80
|
+
{
|
|
81
|
+
"repo_root": str(repo_root),
|
|
82
|
+
"repo_key": repo_key,
|
|
83
|
+
"storage_root": str(storage_root),
|
|
84
|
+
"updated_at": facts["updated_at"],
|
|
85
|
+
"last_seen_branch": branch_name,
|
|
86
|
+
"last_seen_head": facts["last_seen_head"],
|
|
87
|
+
"default_base": facts["default_base"],
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
write_json(paths["repo_manifest"], repo_manifest)
|
|
91
|
+
|
|
92
|
+
payload = {
|
|
93
|
+
"repo_root": str(repo_root),
|
|
94
|
+
"repo_key": repo_key,
|
|
95
|
+
"branch": branch_name,
|
|
96
|
+
"storage_root": str(storage_root),
|
|
97
|
+
"repo_dir": str(repo_dir),
|
|
98
|
+
"branch_dir": str(branch_dir),
|
|
99
|
+
"missing_or_placeholder": list_missing_docs(paths),
|
|
100
|
+
"focus_areas": facts["focus_areas"],
|
|
101
|
+
"files_considered": len(all_paths),
|
|
102
|
+
}
|
|
103
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main():
|
|
107
|
+
parser = argparse.ArgumentParser(description="Read or refresh repo+branch development assets.")
|
|
108
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
109
|
+
|
|
110
|
+
for name in ("show", "sync"):
|
|
111
|
+
sub = subparsers.add_parser(name)
|
|
112
|
+
sub.add_argument("--repo", default=".", help="Path inside the target Git repository")
|
|
113
|
+
sub.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
|
|
114
|
+
sub.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
|
|
115
|
+
|
|
116
|
+
args = parser.parse_args()
|
|
117
|
+
try:
|
|
118
|
+
if args.command == "show":
|
|
119
|
+
command_show(args)
|
|
120
|
+
else:
|
|
121
|
+
command_sync(args)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
124
|
+
return 1
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
dev-memory-graduate: harvest cross-branch reusable knowledge from a branch's
|
|
4
|
+
v2 memory, then archive the branch dir under `branches/_archived/`.
|
|
5
|
+
|
|
6
|
+
In v2, the harvest source is preferentially `pending-promotion.md` (content
|
|
7
|
+
that capture auto-staged as cross-branch-reusable) plus `decisions.md`. We
|
|
8
|
+
still dump progress/risks/glossary in dry-run so the human can spot anything
|
|
9
|
+
the heuristic missed, but the primary signal is pending-promotion.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
|
|
20
|
+
from dev_memory_common import (
|
|
21
|
+
ARCHIVE_INDEX_NAME,
|
|
22
|
+
append_archive_index,
|
|
23
|
+
append_log_event,
|
|
24
|
+
append_to_section,
|
|
25
|
+
archive_branch_dir,
|
|
26
|
+
archive_root_dir,
|
|
27
|
+
asset_paths,
|
|
28
|
+
build_archive_summary,
|
|
29
|
+
detect_no_git_mode,
|
|
30
|
+
get_branch_paths,
|
|
31
|
+
git_lines,
|
|
32
|
+
git_stdout,
|
|
33
|
+
read_json,
|
|
34
|
+
split_sections,
|
|
35
|
+
upsert_markdown_section,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_sections(path):
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return []
|
|
42
|
+
content = path.read_text(encoding="utf-8")
|
|
43
|
+
_, sections = split_sections(content)
|
|
44
|
+
return [{"title": title, "body": body} for title, body in sections]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _git_status(repo_root, default_base):
|
|
48
|
+
if default_base is None:
|
|
49
|
+
return {"ahead": None, "uncommitted": None, "default_base": None}
|
|
50
|
+
raw = git_stdout(["rev-list", "--count", f"{default_base}..HEAD"], cwd=repo_root, check=False)
|
|
51
|
+
try:
|
|
52
|
+
ahead = int(raw)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
ahead = None
|
|
55
|
+
uncommitted = bool(git_lines(["status", "--porcelain"], cwd=repo_root, check=False))
|
|
56
|
+
return {"ahead": ahead, "uncommitted": uncommitted, "default_base": default_base}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def command_dry_run(args):
|
|
60
|
+
if detect_no_git_mode(args.repo):
|
|
61
|
+
print(json.dumps({"error": "no-git mode: graduate has nothing to archive (no branches exist)"}, ensure_ascii=False))
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
repo_root, branch_name, branch_key, _, repo_key, repo_dir, branch_dir = get_branch_paths(args.repo, args.context_dir, args.branch)
|
|
65
|
+
|
|
66
|
+
if not branch_dir.exists():
|
|
67
|
+
print(json.dumps({"error": f"branch memory not initialized: {branch_dir}"}, ensure_ascii=False))
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
paths = asset_paths(repo_dir, branch_dir)
|
|
71
|
+
branch_manifest = read_json(paths["manifest"])
|
|
72
|
+
repo_manifest = read_json(paths["repo_manifest"])
|
|
73
|
+
default_base = branch_manifest.get("default_base") or repo_manifest.get("default_base")
|
|
74
|
+
|
|
75
|
+
payload = {
|
|
76
|
+
"repo_root": str(repo_root),
|
|
77
|
+
"repo_key": repo_key,
|
|
78
|
+
"branch": branch_name,
|
|
79
|
+
"branch_key": branch_key,
|
|
80
|
+
"branch_dir": str(branch_dir),
|
|
81
|
+
"archive_destination": str(archive_root_dir(repo_dir) / f"{branch_key}__{datetime.now(timezone.utc).strftime('%Y%m%d')}"),
|
|
82
|
+
"git_status": _git_status(repo_root, default_base),
|
|
83
|
+
# v2: primary harvest source is pending-promotion.md (capture-staged
|
|
84
|
+
# cross-branch candidates) plus decisions.md. Progress/risks/glossary
|
|
85
|
+
# are dumped only for human cross-check.
|
|
86
|
+
"primary_sources": {
|
|
87
|
+
"pending-promotion.md": _read_sections(paths["pending_promotion"]),
|
|
88
|
+
"decisions.md": _read_sections(paths["decisions"]),
|
|
89
|
+
},
|
|
90
|
+
"cross_check_sources": {
|
|
91
|
+
"progress.md": _read_sections(paths["progress"]),
|
|
92
|
+
"risks.md": _read_sections(paths["risks"]),
|
|
93
|
+
"glossary.md": _read_sections(paths["glossary"]),
|
|
94
|
+
"overview.md": _read_sections(paths["overview"]),
|
|
95
|
+
},
|
|
96
|
+
"repo_files": {
|
|
97
|
+
"overview.md": _read_sections(paths["repo_overview"]),
|
|
98
|
+
"decisions.md": _read_sections(paths["repo_decisions"]),
|
|
99
|
+
"glossary.md": _read_sections(paths["repo_glossary"]),
|
|
100
|
+
},
|
|
101
|
+
"harvest_targets": {
|
|
102
|
+
"repo_overview": ["长期目标与边界", "仓库级关键约束"],
|
|
103
|
+
"repo_decisions": ["跨分支通用决策"],
|
|
104
|
+
"repo_glossary": ["长期有效背景", "共享入口", "共享注意点"],
|
|
105
|
+
},
|
|
106
|
+
"instructions": (
|
|
107
|
+
"优先读 primary_sources 抓取通用知识(剥离业务名词后),按 harvest-schema.md 写出 harvest.json。"
|
|
108
|
+
"cross_check_sources 只做漏网之鱼检查,不是主源。确认无误后跑 graduate apply。"
|
|
109
|
+
),
|
|
110
|
+
}
|
|
111
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _apply_entries(target_path, entries):
|
|
116
|
+
n = 0
|
|
117
|
+
for entry in entries or []:
|
|
118
|
+
section = entry.get("section")
|
|
119
|
+
body = entry.get("body", "")
|
|
120
|
+
mode = entry.get("mode", "append")
|
|
121
|
+
if not section:
|
|
122
|
+
raise ValueError(f"harvest entry missing 'section': {entry}")
|
|
123
|
+
if mode == "append":
|
|
124
|
+
append_to_section(target_path, section, body)
|
|
125
|
+
elif mode == "replace":
|
|
126
|
+
upsert_markdown_section(target_path, section, body)
|
|
127
|
+
else:
|
|
128
|
+
raise ValueError(f"harvest entry has unknown mode '{mode}': {entry}")
|
|
129
|
+
n += 1
|
|
130
|
+
return n
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def command_apply(args):
|
|
134
|
+
if detect_no_git_mode(args.repo):
|
|
135
|
+
print(json.dumps({"error": "no-git mode: graduate has nothing to archive"}, ensure_ascii=False))
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
repo_root, branch_name, branch_key, _, repo_key, repo_dir, branch_dir = get_branch_paths(args.repo, args.context_dir, args.branch)
|
|
139
|
+
|
|
140
|
+
if not branch_dir.exists():
|
|
141
|
+
print(json.dumps({"error": f"branch memory not initialized: {branch_dir}"}, ensure_ascii=False))
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
harvest = read_json(Path(args.harvest_file))
|
|
145
|
+
if not harvest:
|
|
146
|
+
print(json.dumps({"error": f"harvest file empty or missing: {args.harvest_file}"}, ensure_ascii=False))
|
|
147
|
+
return 1
|
|
148
|
+
|
|
149
|
+
# Reject unknown top-level keys explicitly. The most common failure mode
|
|
150
|
+
# is a pre-v2 harvest.json still using repo_context / repo_sources —
|
|
151
|
+
# without this check those entries would be silently dropped while
|
|
152
|
+
# archive=true still mv'd the branch away, leaving the shared layer
|
|
153
|
+
# un-updated with no visible error.
|
|
154
|
+
known_keys = {"repo_overview", "repo_decisions", "repo_glossary", "notes", "archive"}
|
|
155
|
+
unknown_keys = sorted(k for k in harvest.keys() if k not in known_keys)
|
|
156
|
+
if unknown_keys:
|
|
157
|
+
legacy_hint = ""
|
|
158
|
+
if any(k in {"repo_context", "repo_sources"} for k in unknown_keys):
|
|
159
|
+
legacy_hint = (
|
|
160
|
+
" (v1 schema? repo_context → repo_decisions or repo_glossary; "
|
|
161
|
+
"repo_sources → repo_glossary; see references/harvest-schema.md)"
|
|
162
|
+
)
|
|
163
|
+
print(
|
|
164
|
+
json.dumps(
|
|
165
|
+
{"error": f"unknown harvest key(s): {unknown_keys}{legacy_hint}"},
|
|
166
|
+
ensure_ascii=False,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
return 1
|
|
170
|
+
|
|
171
|
+
paths = asset_paths(repo_dir, branch_dir)
|
|
172
|
+
|
|
173
|
+
# v2: the harvest target keys match the new repo-shared files.
|
|
174
|
+
applied = {
|
|
175
|
+
"repo_overview": _apply_entries(paths["repo_overview"], harvest.get("repo_overview")),
|
|
176
|
+
"repo_decisions": _apply_entries(paths["repo_decisions"], harvest.get("repo_decisions")),
|
|
177
|
+
"repo_glossary": _apply_entries(paths["repo_glossary"], harvest.get("repo_glossary")),
|
|
178
|
+
}
|
|
179
|
+
total = sum(applied.values())
|
|
180
|
+
|
|
181
|
+
branch_manifest = read_json(paths["manifest"])
|
|
182
|
+
default_base = branch_manifest.get("default_base")
|
|
183
|
+
git_log = []
|
|
184
|
+
if default_base:
|
|
185
|
+
git_log = git_lines(["log", "--oneline", f"{default_base}..HEAD"], cwd=repo_root, check=False)
|
|
186
|
+
|
|
187
|
+
summary_md = build_archive_summary(branch_manifest, git_log, harvest_notes=harvest.get("notes"))
|
|
188
|
+
summary_path = branch_dir / "archive_summary.md"
|
|
189
|
+
summary_path.write_text(summary_md, encoding="utf-8")
|
|
190
|
+
|
|
191
|
+
do_archive = harvest.get("archive", True)
|
|
192
|
+
archive_dst = None
|
|
193
|
+
if do_archive:
|
|
194
|
+
date_tag = datetime.now(timezone.utc).strftime("%Y%m%d")
|
|
195
|
+
archive_dst = archive_root_dir(repo_dir) / f"{branch_key}__{date_tag}"
|
|
196
|
+
archive_branch_dir(branch_dir, archive_dst)
|
|
197
|
+
head = branch_manifest.get("last_seen_head") or "<unknown>"
|
|
198
|
+
notes_short = (harvest.get("notes") or "").splitlines()[0][:80] if harvest.get("notes") else ""
|
|
199
|
+
index_line = f"- {date_tag[:4]}-{date_tag[4:6]}-{date_tag[6:8]} {branch_name} (HEAD {head}) → harvested {total} entries: {notes_short}"
|
|
200
|
+
append_archive_index(archive_root_dir(repo_dir) / ARCHIVE_INDEX_NAME, index_line)
|
|
201
|
+
|
|
202
|
+
# Event log: graduate is a repo-layer event (the branch log is gone with
|
|
203
|
+
# the archive move). One row in repo/log.md captures the harvest + archive
|
|
204
|
+
# destination so future readers can scan the timeline.
|
|
205
|
+
log_summary = f"branch={branch_name} harvested={total}"
|
|
206
|
+
log_details = [
|
|
207
|
+
("harvested_total", total),
|
|
208
|
+
("repo_overview", applied["repo_overview"]),
|
|
209
|
+
("repo_decisions", applied["repo_decisions"]),
|
|
210
|
+
("repo_glossary", applied["repo_glossary"]),
|
|
211
|
+
("archived_to", archive_dst.name if archive_dst else "no"),
|
|
212
|
+
]
|
|
213
|
+
if harvest.get("notes"):
|
|
214
|
+
log_details.append(("notes", (harvest["notes"] or "").splitlines()[0][:120]))
|
|
215
|
+
append_log_event(
|
|
216
|
+
paths.get("repo_log"),
|
|
217
|
+
"graduate",
|
|
218
|
+
kind="apply",
|
|
219
|
+
summary=log_summary,
|
|
220
|
+
details=log_details,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
print(json.dumps({
|
|
224
|
+
"branch": branch_name,
|
|
225
|
+
"harvested": applied,
|
|
226
|
+
"harvested_total": total,
|
|
227
|
+
"archive_summary": str((archive_dst or branch_dir) / "archive_summary.md"),
|
|
228
|
+
"archived_to": str(archive_dst) if archive_dst else None,
|
|
229
|
+
}, ensure_ascii=False, indent=2))
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def command_index(args):
|
|
234
|
+
if detect_no_git_mode(args.repo):
|
|
235
|
+
print(json.dumps({"error": "no-git mode: no archive index"}, ensure_ascii=False))
|
|
236
|
+
return 1
|
|
237
|
+
|
|
238
|
+
repo_root, _, _, _, _, repo_dir, _ = get_branch_paths(args.repo, args.context_dir, args.branch or "HEAD")
|
|
239
|
+
index_path = archive_root_dir(repo_dir) / ARCHIVE_INDEX_NAME
|
|
240
|
+
if not index_path.exists():
|
|
241
|
+
print(json.dumps({"index_path": str(index_path), "exists": False, "entries": []}, ensure_ascii=False))
|
|
242
|
+
return 0
|
|
243
|
+
text = index_path.read_text(encoding="utf-8")
|
|
244
|
+
entries = [line for line in text.splitlines() if line.startswith("- ")]
|
|
245
|
+
print(json.dumps({
|
|
246
|
+
"index_path": str(index_path),
|
|
247
|
+
"exists": True,
|
|
248
|
+
"entries": entries,
|
|
249
|
+
}, ensure_ascii=False, indent=2))
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main():
|
|
254
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
255
|
+
common.add_argument("--repo", default=".", help="Path inside the target Git repository")
|
|
256
|
+
common.add_argument("--context-dir", help="User-home storage root override")
|
|
257
|
+
common.add_argument("--branch", help="Branch name override (defaults to current)")
|
|
258
|
+
|
|
259
|
+
parser = argparse.ArgumentParser(description="Graduate a branch's dev-memory: harvest reusable knowledge, then archive.")
|
|
260
|
+
sub = parser.add_subparsers(dest="command")
|
|
261
|
+
sub.required = True
|
|
262
|
+
|
|
263
|
+
p_dry = sub.add_parser("dry-run", parents=[common], help="Dump branch + repo sections for harvest review")
|
|
264
|
+
p_dry.set_defaults(func=command_dry_run)
|
|
265
|
+
|
|
266
|
+
p_apply = sub.add_parser("apply", parents=[common], help="Apply harvest patch and archive branch dir")
|
|
267
|
+
p_apply.add_argument("--harvest-file", required=True, help="Path to harvest.json")
|
|
268
|
+
p_apply.set_defaults(func=command_apply)
|
|
269
|
+
|
|
270
|
+
p_idx = sub.add_parser("index", parents=[common], help="List archived branches")
|
|
271
|
+
p_idx.set_defaults(func=command_index)
|
|
272
|
+
|
|
273
|
+
args = parser.parse_args()
|
|
274
|
+
try:
|
|
275
|
+
return args.func(args)
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
|
278
|
+
return 1
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
dev-memory-setup: initialize the skeleton AND merge any unsorted.md content
|
|
4
|
+
into the structured v2 files. In v2, setup is no longer a gate — capture
|
|
5
|
+
lazy-inits files on first write. Setup's job is now:
|
|
6
|
+
|
|
7
|
+
1. Ensure skeleton exists (idempotent).
|
|
8
|
+
2. Scan unsorted.md and present each entry to the user for classification.
|
|
9
|
+
3. Route user's choices into decisions/progress/risks/glossary/shared-*.
|
|
10
|
+
4. Mark manifest.setup_completed = true.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
|
|
20
|
+
from dev_memory_common import (
|
|
21
|
+
ensure_branch_paths_exist,
|
|
22
|
+
get_setup_completed,
|
|
23
|
+
mark_setup_completed,
|
|
24
|
+
split_sections,
|
|
25
|
+
upsert_markdown_section,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _extract_unsorted_entries(unsorted_path):
|
|
30
|
+
"""Parse unsorted.md into a list of individual entries. Each entry is a
|
|
31
|
+
bullet line or a paragraph; we split on top-level bullets to give the
|
|
32
|
+
user one-at-a-time classification. Returns [] if empty or only
|
|
33
|
+
placeholder text.
|
|
34
|
+
"""
|
|
35
|
+
if not unsorted_path.exists():
|
|
36
|
+
return []
|
|
37
|
+
content = unsorted_path.read_text(encoding="utf-8")
|
|
38
|
+
_, sections = split_sections(content)
|
|
39
|
+
entries = []
|
|
40
|
+
for _, body in sections:
|
|
41
|
+
for line in body.splitlines():
|
|
42
|
+
stripped = line.strip()
|
|
43
|
+
if not stripped:
|
|
44
|
+
continue
|
|
45
|
+
if stripped in ("- 待补充", "- 待刷新"):
|
|
46
|
+
continue
|
|
47
|
+
# Only include top-level bullets; nested lines get combined.
|
|
48
|
+
if stripped.startswith("- "):
|
|
49
|
+
entries.append(stripped[2:].strip())
|
|
50
|
+
elif entries:
|
|
51
|
+
# Continuation of the previous bullet.
|
|
52
|
+
entries[-1] = entries[-1] + " " + stripped
|
|
53
|
+
return entries
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _report_skeleton(paths, setup_completed):
|
|
57
|
+
"""Build the `files` key for the setup output."""
|
|
58
|
+
return {
|
|
59
|
+
"setup_completed": setup_completed,
|
|
60
|
+
"files": {key: str(value) for key, value in paths.items()},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def command_init(args):
|
|
65
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
66
|
+
args.repo, args.context_dir, args.branch
|
|
67
|
+
)
|
|
68
|
+
report = _report_skeleton(paths, get_setup_completed(paths["manifest"]))
|
|
69
|
+
|
|
70
|
+
unsorted_entries = _extract_unsorted_entries(paths["unsorted"])
|
|
71
|
+
report.update(
|
|
72
|
+
{
|
|
73
|
+
"repo_root": str(repo_root),
|
|
74
|
+
"repo_key": repo_key,
|
|
75
|
+
"branch": branch_name,
|
|
76
|
+
"storage_root": str(storage_root),
|
|
77
|
+
"repo_dir": str(repo_dir),
|
|
78
|
+
"branch_dir": str(branch_dir),
|
|
79
|
+
"unsorted_entries": unsorted_entries,
|
|
80
|
+
"unsorted_count": len(unsorted_entries),
|
|
81
|
+
"mode": "init",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Merge plan: the caller (agent) receives unsorted entries from `init`,
|
|
89
|
+
# asks the user (or classifies with LLM) which bucket each goes to, then
|
|
90
|
+
# submits the classification as a JSON file to `merge-unsorted`.
|
|
91
|
+
#
|
|
92
|
+
# plan.json format:
|
|
93
|
+
# {
|
|
94
|
+
# "classifications": [
|
|
95
|
+
# {"entry": "original bullet text", "kind": "decision|progress|next|risk|glossary|source|shared-*|skip"},
|
|
96
|
+
# ...
|
|
97
|
+
# ],
|
|
98
|
+
# "clear_unsorted_on_done": true
|
|
99
|
+
# }
|
|
100
|
+
#
|
|
101
|
+
# The kind values match dev-memory-capture's KIND_MAP.
|
|
102
|
+
|
|
103
|
+
_SETUP_KIND_TO_TARGET = {
|
|
104
|
+
"decision": ("decisions", "关键决策与原因"),
|
|
105
|
+
"progress": ("progress", "当前进展"),
|
|
106
|
+
"next": ("progress", "下一步"),
|
|
107
|
+
"risk": ("risks", "阻塞与注意点"),
|
|
108
|
+
"glossary": ("glossary", "当前有效上下文"),
|
|
109
|
+
"source": ("glossary", "分支源资料入口"),
|
|
110
|
+
"shared-decision": ("repo_decisions", "跨分支通用决策"),
|
|
111
|
+
"shared-context": ("repo_glossary", "长期有效背景"),
|
|
112
|
+
"shared-source": ("repo_glossary", "共享入口"),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _apply_classifications(paths, classifications):
|
|
117
|
+
"""Group classifications by target section, then upsert-merge each group
|
|
118
|
+
into the target file. Returns a per-section tally.
|
|
119
|
+
"""
|
|
120
|
+
groups = {} # (file_key, section) -> list of entry strings
|
|
121
|
+
skipped = 0
|
|
122
|
+
for item in classifications or []:
|
|
123
|
+
entry = (item.get("entry") or "").strip()
|
|
124
|
+
kind = (item.get("kind") or "").strip()
|
|
125
|
+
if not entry:
|
|
126
|
+
continue
|
|
127
|
+
if kind == "skip" or not kind:
|
|
128
|
+
skipped += 1
|
|
129
|
+
continue
|
|
130
|
+
target = _SETUP_KIND_TO_TARGET.get(kind)
|
|
131
|
+
if not target:
|
|
132
|
+
skipped += 1
|
|
133
|
+
continue
|
|
134
|
+
groups.setdefault(target, []).append(entry)
|
|
135
|
+
|
|
136
|
+
tally = {}
|
|
137
|
+
for (file_key, section), entries in groups.items():
|
|
138
|
+
# Append to existing section rather than replace — preserves whatever
|
|
139
|
+
# was already in decisions/progress/etc. before setup merge ran.
|
|
140
|
+
from dev_memory_common import append_to_section
|
|
141
|
+
body = "\n".join(f"- {e}" for e in entries)
|
|
142
|
+
path = paths[file_key]
|
|
143
|
+
append_to_section(path, section, body)
|
|
144
|
+
tally[f"{file_key}:{section}"] = len(entries)
|
|
145
|
+
return {"applied": tally, "skipped": skipped}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _clear_unsorted(unsorted_path):
|
|
149
|
+
"""After merge, reset unsorted.md to its placeholder template."""
|
|
150
|
+
from dev_memory_common import template_unsorted
|
|
151
|
+
unsorted_path.write_text(template_unsorted(), encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def command_merge_unsorted(args):
|
|
155
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
156
|
+
args.repo, args.context_dir, args.branch
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
plan = json.loads(Path(args.plan_file).read_text(encoding="utf-8"))
|
|
160
|
+
classifications = plan.get("classifications") or []
|
|
161
|
+
clear_on_done = plan.get("clear_unsorted_on_done", True)
|
|
162
|
+
|
|
163
|
+
result = _apply_classifications(paths, classifications)
|
|
164
|
+
|
|
165
|
+
if clear_on_done and result["applied"]:
|
|
166
|
+
_clear_unsorted(paths["unsorted"])
|
|
167
|
+
|
|
168
|
+
mark_setup_completed(paths["manifest"])
|
|
169
|
+
|
|
170
|
+
print(
|
|
171
|
+
json.dumps(
|
|
172
|
+
{
|
|
173
|
+
"repo_root": str(repo_root),
|
|
174
|
+
"repo_key": repo_key,
|
|
175
|
+
"branch": branch_name,
|
|
176
|
+
"branch_dir": str(branch_dir),
|
|
177
|
+
"mode": "merge-unsorted",
|
|
178
|
+
"applied": result["applied"],
|
|
179
|
+
"skipped": result["skipped"],
|
|
180
|
+
"unsorted_cleared": clear_on_done and bool(result["applied"]),
|
|
181
|
+
"setup_completed": True,
|
|
182
|
+
},
|
|
183
|
+
ensure_ascii=False,
|
|
184
|
+
indent=2,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def command_mark_completed(args):
|
|
191
|
+
"""Just flip setup_completed to true without touching unsorted. Use when
|
|
192
|
+
there's nothing to merge but the user wants to formally mark setup done
|
|
193
|
+
so classifier defaults shift from unsorted to progress."""
|
|
194
|
+
repo_root, branch_name, _, _, _, _, branch_dir, paths = ensure_branch_paths_exist(
|
|
195
|
+
args.repo, args.context_dir, args.branch
|
|
196
|
+
)
|
|
197
|
+
mark_setup_completed(paths["manifest"])
|
|
198
|
+
print(
|
|
199
|
+
json.dumps(
|
|
200
|
+
{
|
|
201
|
+
"branch": branch_name,
|
|
202
|
+
"branch_dir": str(branch_dir),
|
|
203
|
+
"mode": "mark-completed",
|
|
204
|
+
"setup_completed": True,
|
|
205
|
+
},
|
|
206
|
+
ensure_ascii=False,
|
|
207
|
+
indent=2,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _add_common_args(p):
|
|
214
|
+
p.add_argument("--repo", default=".", help="Path inside the target Git repository")
|
|
215
|
+
p.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
|
|
216
|
+
p.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main():
|
|
220
|
+
parser = argparse.ArgumentParser(
|
|
221
|
+
description="Initialize repo+branch dev-memory skeleton, merge unsorted content, and mark setup done.",
|
|
222
|
+
)
|
|
223
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
224
|
+
|
|
225
|
+
p_init = sub.add_parser("init", help="Ensure skeleton exists, return paths + unsorted entries for review")
|
|
226
|
+
_add_common_args(p_init)
|
|
227
|
+
|
|
228
|
+
p_merge = sub.add_parser("merge-unsorted", help="Apply a classification plan over unsorted entries")
|
|
229
|
+
_add_common_args(p_merge)
|
|
230
|
+
p_merge.add_argument("--plan-file", required=True, help="Path to JSON plan with classifications")
|
|
231
|
+
|
|
232
|
+
p_mark = sub.add_parser("mark-completed", help="Flip manifest.setup_completed = true without merging")
|
|
233
|
+
_add_common_args(p_mark)
|
|
234
|
+
|
|
235
|
+
# Backward-compat: old callers ran `init_dev_memory.py --repo X` with no
|
|
236
|
+
# subcommand. If argv starts with a flag (not a known subcommand or help),
|
|
237
|
+
# inject `init` so the legacy invocation still works.
|
|
238
|
+
argv = sys.argv[1:]
|
|
239
|
+
known_cmds = {"init", "merge-unsorted", "mark-completed"}
|
|
240
|
+
if argv and argv[0] not in known_cmds and argv[0] not in ("-h", "--help"):
|
|
241
|
+
argv = ["init"] + argv
|
|
242
|
+
|
|
243
|
+
args = parser.parse_args(argv)
|
|
244
|
+
try:
|
|
245
|
+
return {
|
|
246
|
+
"init": command_init,
|
|
247
|
+
"merge-unsorted": command_merge_unsorted,
|
|
248
|
+
"mark-completed": command_mark_completed,
|
|
249
|
+
}[args.command](args)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
|
252
|
+
return 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
raise SystemExit(main())
|