dev-memory-cli 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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())