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,1343 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
dev-memory-capture: unified write entrypoint for repo+branch development
|
|
4
|
+
assets. Merges the old sync (checkpoint batch record) and update (targeted
|
|
5
|
+
section rewrite) into one skill. Also the lazy-init entry — never raises on
|
|
6
|
+
missing branch_dir; the directory is seeded on first write.
|
|
7
|
+
|
|
8
|
+
Subcommands:
|
|
9
|
+
record : write content into one or more sections (inline/kind/auto/batch)
|
|
10
|
+
show : dump current paths + missing docs
|
|
11
|
+
sync-working-tree : refresh progress.md's auto-sync block from git facts
|
|
12
|
+
record-head : stamp last_seen_head onto branch+repo manifests
|
|
13
|
+
suggest-kind : dry-run heuristic classifier
|
|
14
|
+
classify : dry-run — run classifier AND cross-branch detector
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import difflib
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
24
|
+
|
|
25
|
+
from dev_memory_common import (
|
|
26
|
+
AUTO_END,
|
|
27
|
+
AUTO_START,
|
|
28
|
+
PLACEHOLDER_MARKERS,
|
|
29
|
+
append_log_event,
|
|
30
|
+
append_to_section,
|
|
31
|
+
asset_paths,
|
|
32
|
+
classify_content,
|
|
33
|
+
collect_git_facts,
|
|
34
|
+
ensure_branch_paths_exist,
|
|
35
|
+
get_head_commit,
|
|
36
|
+
get_setup_completed,
|
|
37
|
+
is_cross_branch_candidate,
|
|
38
|
+
join_sections,
|
|
39
|
+
list_missing_docs,
|
|
40
|
+
merged_focus_areas,
|
|
41
|
+
now_iso,
|
|
42
|
+
read_json,
|
|
43
|
+
render_bullets,
|
|
44
|
+
split_sections,
|
|
45
|
+
sync_progress,
|
|
46
|
+
upsert_markdown_section,
|
|
47
|
+
upsert_progress_section,
|
|
48
|
+
write_json,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _log_targets_for(touched):
|
|
53
|
+
"""Return (touches_repo, formatted_targets_line) from the touched list.
|
|
54
|
+
|
|
55
|
+
`touched` items look like {"file": "branch/decisions.md", "section": ...,
|
|
56
|
+
"mode": ...}. The formatted line keeps it short for log readability:
|
|
57
|
+
each target rendered as `branch/decisions.md(append)`, dropping the
|
|
58
|
+
section to avoid blowing past _LOG_SUMMARY_MAX on batch writes.
|
|
59
|
+
"""
|
|
60
|
+
if not touched:
|
|
61
|
+
return False, None
|
|
62
|
+
touches_repo = any(t.get("file", "").startswith("repo/") for t in touched)
|
|
63
|
+
parts = []
|
|
64
|
+
for t in touched:
|
|
65
|
+
file_ = t.get("file", "?")
|
|
66
|
+
mode = t.get("mode", "?")
|
|
67
|
+
parts.append(f"{file_}({mode})")
|
|
68
|
+
return touches_repo, ", ".join(parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _emit_capture_log(paths, *, action, kind_label, summary, touched, extra_details=None):
|
|
72
|
+
"""Append an event row to log.md after a successful capture write.
|
|
73
|
+
|
|
74
|
+
Writes to the branch-level log by default. If any touched target lives
|
|
75
|
+
under the repo-shared layer (`repo/...`), also mirrors a row into the
|
|
76
|
+
repo log so cross-branch readers see shared-layer mutations.
|
|
77
|
+
"""
|
|
78
|
+
touches_repo, targets_line = _log_targets_for(touched)
|
|
79
|
+
details = []
|
|
80
|
+
if targets_line:
|
|
81
|
+
details.append(("targets", targets_line))
|
|
82
|
+
for k, v in (extra_details or []):
|
|
83
|
+
details.append((k, v))
|
|
84
|
+
append_log_event(
|
|
85
|
+
paths.get("log"),
|
|
86
|
+
action,
|
|
87
|
+
kind=kind_label,
|
|
88
|
+
summary=summary,
|
|
89
|
+
details=details,
|
|
90
|
+
)
|
|
91
|
+
if touches_repo and paths.get("repo_log") and paths.get("repo_log") != paths.get("log"):
|
|
92
|
+
append_log_event(
|
|
93
|
+
paths.get("repo_log"),
|
|
94
|
+
action,
|
|
95
|
+
kind=kind_label,
|
|
96
|
+
summary=summary,
|
|
97
|
+
details=details,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _append_with_separator(path, title, body):
|
|
102
|
+
"""Like append_to_section but always inserts a blank line between the
|
|
103
|
+
existing section body and the new entry. Drops placeholder-only bodies
|
|
104
|
+
instead of padding them with a blank line.
|
|
105
|
+
"""
|
|
106
|
+
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
107
|
+
prefix, sections = split_sections(content)
|
|
108
|
+
target = title.strip()
|
|
109
|
+
matched = False
|
|
110
|
+
updated = []
|
|
111
|
+
body_stripped = body.strip()
|
|
112
|
+
|
|
113
|
+
def _is_placeholder_only(text):
|
|
114
|
+
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
|
115
|
+
if not lines:
|
|
116
|
+
return True
|
|
117
|
+
return all(any(marker in ln for marker in PLACEHOLDER_MARKERS) for ln in lines)
|
|
118
|
+
|
|
119
|
+
for existing_title, existing_body in sections:
|
|
120
|
+
if existing_title.strip() == target and not matched:
|
|
121
|
+
if _is_placeholder_only(existing_body):
|
|
122
|
+
combined = body_stripped
|
|
123
|
+
else:
|
|
124
|
+
combined = existing_body.rstrip() + "\n\n" + body_stripped
|
|
125
|
+
updated.append((existing_title, combined))
|
|
126
|
+
matched = True
|
|
127
|
+
else:
|
|
128
|
+
updated.append((existing_title, existing_body))
|
|
129
|
+
if not matched:
|
|
130
|
+
updated.append((title, body_stripped))
|
|
131
|
+
path.write_text(join_sections(prefix, updated), encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Dedup check primitives
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
# Chinese (and a few English) phrases that overtly signal "I'm rewriting an
|
|
139
|
+
# earlier entry". When the new content contains any of these, similarity is
|
|
140
|
+
# boosted (so even mid-similarity matches surface) and the recommendation
|
|
141
|
+
# leans toward update_existing — capture should treat this as a hint that the
|
|
142
|
+
# user already knows there's a prior entry to revise.
|
|
143
|
+
SUPERSEDES_KEYWORDS = (
|
|
144
|
+
"supersedes",
|
|
145
|
+
"重新校正",
|
|
146
|
+
"已更新",
|
|
147
|
+
"新版",
|
|
148
|
+
"修正",
|
|
149
|
+
"推翻",
|
|
150
|
+
"取代",
|
|
151
|
+
"覆盖",
|
|
152
|
+
"撤销",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# 80-char preview window for similarity comparison. Long bodies dilute the
|
|
156
|
+
# ratio with prose; capping at the first line's first 80 chars keeps the
|
|
157
|
+
# signal-to-noise ratio high — typical entries in decisions.md / risks.md
|
|
158
|
+
# fit their key claim into the lead line.
|
|
159
|
+
_SIM_PREVIEW_LEN = 80
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _first_nonempty_line(text):
|
|
163
|
+
"""Return the first non-blank line of `text`, stripped. Empty string if
|
|
164
|
+
`text` is all blank. Used as the comparison anchor for similarity_check
|
|
165
|
+
because top-level bullets carry their "thesis" in the lead line."""
|
|
166
|
+
if not text:
|
|
167
|
+
return ""
|
|
168
|
+
for line in text.splitlines():
|
|
169
|
+
s = line.strip()
|
|
170
|
+
if s:
|
|
171
|
+
return s
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _strip_bullet_prefix(text):
|
|
176
|
+
"""Drop a leading `- ` / `* ` from a single line so the comparison key is
|
|
177
|
+
the entry's content, not its bullet marker. Without this, every entry's
|
|
178
|
+
preview starts with `- ` and difflib over-credits common prefixes."""
|
|
179
|
+
s = text.lstrip()
|
|
180
|
+
if s.startswith("- ") or s.startswith("* "):
|
|
181
|
+
return s[2:].strip()
|
|
182
|
+
return s
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _section_top_level_entries(section_body):
|
|
186
|
+
"""Yield (entry_idx, full_text) for each top-level bullet entry in a
|
|
187
|
+
section body. Mirrors tidy's `_parse_entries` boundary rules (top-level
|
|
188
|
+
`- ` starts a new entry; indented or non-bullet continuation lines fold
|
|
189
|
+
into the current entry) but stays local to capture so the modules don't
|
|
190
|
+
grow a cross-dependency on a private symbol.
|
|
191
|
+
|
|
192
|
+
The AUTO-GENERATED block (machine-managed state in progress.md) is
|
|
193
|
+
excluded — dedup against auto-block content would block every progress
|
|
194
|
+
write the moment git facts repeat.
|
|
195
|
+
"""
|
|
196
|
+
if not section_body:
|
|
197
|
+
return
|
|
198
|
+
# Drop the AUTO-GENERATED block if present so we don't dedup against
|
|
199
|
+
# auto-managed content (e.g. progress.md focus areas regenerated by
|
|
200
|
+
# sync-working-tree). The visible top of the section still gets scanned.
|
|
201
|
+
body = section_body
|
|
202
|
+
if AUTO_START in body and AUTO_END in body:
|
|
203
|
+
start = body.index(AUTO_START)
|
|
204
|
+
end = body.index(AUTO_END) + len(AUTO_END)
|
|
205
|
+
body = (body[:start].rstrip() + "\n" + body[end:].lstrip()).strip()
|
|
206
|
+
|
|
207
|
+
current_lines = None
|
|
208
|
+
current_idx = -1
|
|
209
|
+
counter = -1
|
|
210
|
+
entries = []
|
|
211
|
+
|
|
212
|
+
def flush():
|
|
213
|
+
if current_lines is None:
|
|
214
|
+
return
|
|
215
|
+
entries.append((current_idx, "\n".join(current_lines).strip()))
|
|
216
|
+
|
|
217
|
+
for raw in body.splitlines():
|
|
218
|
+
stripped = raw.strip()
|
|
219
|
+
if not stripped:
|
|
220
|
+
if current_lines is not None:
|
|
221
|
+
flush()
|
|
222
|
+
current_lines_local = None # noqa: F841 (clarity)
|
|
223
|
+
current_lines = None
|
|
224
|
+
continue
|
|
225
|
+
if stripped.startswith("- ") and not (raw.startswith(" ") or raw.startswith("\t")):
|
|
226
|
+
if current_lines is not None:
|
|
227
|
+
flush()
|
|
228
|
+
counter += 1
|
|
229
|
+
current_idx = counter
|
|
230
|
+
current_lines = [raw]
|
|
231
|
+
elif current_lines is not None:
|
|
232
|
+
current_lines.append(raw)
|
|
233
|
+
# Prose before any bullet is ignored — matches _parse_entries.
|
|
234
|
+
if current_lines is not None:
|
|
235
|
+
flush()
|
|
236
|
+
|
|
237
|
+
for entry_idx, text in entries:
|
|
238
|
+
yield entry_idx, text
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_placeholder_entry(text):
|
|
242
|
+
"""A top-level bullet whose content is exactly the placeholder marker
|
|
243
|
+
(待补充/待刷新) shouldn't participate in dedup — it represents "empty
|
|
244
|
+
slot, please fill" not "real content."""
|
|
245
|
+
body = _strip_bullet_prefix(_first_nonempty_line(text))
|
|
246
|
+
return body in ("待补充", "待刷新")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def similarity_check(new_content, section_body, threshold=0.7):
|
|
250
|
+
"""Find near-duplicate top-level bullets in `section_body` for `new_content`.
|
|
251
|
+
|
|
252
|
+
Returns a list of matches sorted by similarity desc:
|
|
253
|
+
[{"entry_idx": int, "similarity": float, "match_first_line": str,
|
|
254
|
+
"match_full_text": str}, ...]
|
|
255
|
+
|
|
256
|
+
Algorithm:
|
|
257
|
+
1. Compute the comparison key for `new_content`: first non-empty line,
|
|
258
|
+
bullet prefix stripped, capped at _SIM_PREVIEW_LEN chars.
|
|
259
|
+
2. For each top-level bullet in `section_body` (placeholders + AUTO
|
|
260
|
+
blocks excluded), compute the same key and a
|
|
261
|
+
difflib.SequenceMatcher ratio against the new key.
|
|
262
|
+
3. If `new_content` contains any SUPERSEDES_KEYWORDS, boost the
|
|
263
|
+
similarity by +0.15 (capped at 1.0). Rationale: an explicit "I'm
|
|
264
|
+
updating X" cue from the user is a stronger signal than text
|
|
265
|
+
similarity alone — surface mid-similarity candidates that would
|
|
266
|
+
otherwise sit below the threshold.
|
|
267
|
+
4. Keep only matches with (boosted) similarity ≥ `threshold`.
|
|
268
|
+
"""
|
|
269
|
+
new_first = _strip_bullet_prefix(_first_nonempty_line(new_content))
|
|
270
|
+
if not new_first:
|
|
271
|
+
return []
|
|
272
|
+
new_key = new_first[:_SIM_PREVIEW_LEN]
|
|
273
|
+
|
|
274
|
+
has_supersedes = any(kw in new_content for kw in SUPERSEDES_KEYWORDS)
|
|
275
|
+
|
|
276
|
+
matches = []
|
|
277
|
+
for entry_idx, entry_text in _section_top_level_entries(section_body):
|
|
278
|
+
if _is_placeholder_entry(entry_text):
|
|
279
|
+
continue
|
|
280
|
+
existing_first = _strip_bullet_prefix(_first_nonempty_line(entry_text))
|
|
281
|
+
if not existing_first:
|
|
282
|
+
continue
|
|
283
|
+
existing_key = existing_first[:_SIM_PREVIEW_LEN]
|
|
284
|
+
ratio = difflib.SequenceMatcher(None, new_key, existing_key).ratio()
|
|
285
|
+
if has_supersedes:
|
|
286
|
+
ratio = min(1.0, ratio + 0.15)
|
|
287
|
+
if ratio >= threshold:
|
|
288
|
+
matches.append({
|
|
289
|
+
"entry_idx": entry_idx,
|
|
290
|
+
"similarity": round(ratio, 4),
|
|
291
|
+
"match_first_line": existing_first,
|
|
292
|
+
"match_full_text": entry_text,
|
|
293
|
+
"supersedes_signal_detected": has_supersedes,
|
|
294
|
+
})
|
|
295
|
+
matches.sort(key=lambda m: m["similarity"], reverse=True)
|
|
296
|
+
return matches
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _file_key_to_label(file_key):
|
|
300
|
+
"""Same as _label() but standalone (avoids forward reference). Kept here
|
|
301
|
+
so _check_dedup_for_kind can emit the user-facing target_file string
|
|
302
|
+
before _label is defined later in the module."""
|
|
303
|
+
if file_key.startswith("repo_"):
|
|
304
|
+
return f"repo/{file_key[5:]}.md"
|
|
305
|
+
if file_key == "pending_promotion":
|
|
306
|
+
return "branch/pending-promotion.md"
|
|
307
|
+
return f"branch/{file_key}.md"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _build_dedup_hint(kind, file_key, section_title, new_content, matches):
|
|
311
|
+
"""Assemble the dedup_hint payload returned to the caller when a write is
|
|
312
|
+
blocked. Schema is part of the public CLI contract — additions OK,
|
|
313
|
+
renames/removals are breaking changes.
|
|
314
|
+
|
|
315
|
+
`recommendation` logic:
|
|
316
|
+
- explicit supersedes signal in new content → "update_existing"
|
|
317
|
+
- single match with very high similarity (≥ 0.9) → "update_existing"
|
|
318
|
+
- otherwise → "review_and_decide" (let agent inspect matches[])
|
|
319
|
+
"""
|
|
320
|
+
preview = _first_nonempty_line(new_content)[:_SIM_PREVIEW_LEN]
|
|
321
|
+
has_supersedes = any(kw in new_content for kw in SUPERSEDES_KEYWORDS)
|
|
322
|
+
high_conf_single = len(matches) == 1 and matches[0]["similarity"] >= 0.9
|
|
323
|
+
if has_supersedes or high_conf_single:
|
|
324
|
+
recommendation = "update_existing"
|
|
325
|
+
else:
|
|
326
|
+
recommendation = "review_and_decide"
|
|
327
|
+
|
|
328
|
+
# Find the section_idx for each match so we can render full entry ids.
|
|
329
|
+
# The caller has already passed `section_title`; section_idx is derived
|
|
330
|
+
# later in _check_dedup_for_kind when it has the file context.
|
|
331
|
+
next_actions = [
|
|
332
|
+
"如果是修订旧条目: dev-memory-cli capture rewrite-entry --id <match_id> --content '<text>'",
|
|
333
|
+
"如果确实要再写一条: dev-memory-cli capture record --kind {kind} --content '<text>' --force".format(kind=kind),
|
|
334
|
+
"如果不写: 不调任何命令",
|
|
335
|
+
]
|
|
336
|
+
return {
|
|
337
|
+
"blocked": True,
|
|
338
|
+
"reason": "similar_entry_exists",
|
|
339
|
+
"kind": kind,
|
|
340
|
+
"target_file": _file_key_to_label(file_key),
|
|
341
|
+
"section": section_title,
|
|
342
|
+
"new_content_preview": preview,
|
|
343
|
+
"matches": matches, # ids filled in by caller after section_idx lookup
|
|
344
|
+
"recommendation": recommendation,
|
|
345
|
+
"next_actions": next_actions,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _resolve_section_idx(target_path, section_title):
|
|
350
|
+
"""Return the 0-based section index of `section_title` in `target_path`,
|
|
351
|
+
or None if the file doesn't exist / section is absent. Used to construct
|
|
352
|
+
full entry ids (`<file_key>::<section_idx>::<entry_idx>`) for matches
|
|
353
|
+
surfaced via the dedup hint."""
|
|
354
|
+
if not target_path.exists():
|
|
355
|
+
return None
|
|
356
|
+
content = target_path.read_text(encoding="utf-8")
|
|
357
|
+
_, sections = split_sections(content)
|
|
358
|
+
target = section_title.strip()
|
|
359
|
+
for idx, (title, _body) in enumerate(sections):
|
|
360
|
+
if title.strip() == target:
|
|
361
|
+
return idx
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _load_section_body(target_path, section_title):
|
|
366
|
+
"""Return the body of `section_title` in `target_path` (empty string if
|
|
367
|
+
file missing / section absent)."""
|
|
368
|
+
if not target_path.exists():
|
|
369
|
+
return ""
|
|
370
|
+
content = target_path.read_text(encoding="utf-8")
|
|
371
|
+
_, sections = split_sections(content)
|
|
372
|
+
target = section_title.strip()
|
|
373
|
+
for title, body in sections:
|
|
374
|
+
if title.strip() == target:
|
|
375
|
+
return body
|
|
376
|
+
return ""
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _check_dedup_for_kind(paths, kind, body, force=False, title_override=None, threshold=None):
|
|
380
|
+
"""Return a dedup_hint dict if the write should be blocked, else None.
|
|
381
|
+
|
|
382
|
+
Pure read; writes nothing. Skips:
|
|
383
|
+
- kinds whose default_mode is not "append" (upsert always overwrites,
|
|
384
|
+
dedup is meaningless there)
|
|
385
|
+
- force=True (explicit opt-out)
|
|
386
|
+
- kinds not in KIND_MAP (caller handles error elsewhere)
|
|
387
|
+
- empty/whitespace body (caller handles error elsewhere)
|
|
388
|
+
|
|
389
|
+
`threshold` (optional float in (0.0, 1.0]) overrides similarity_check's
|
|
390
|
+
default 0.7. None preserves default behavior — kept as the production
|
|
391
|
+
contract; the override is exposed only via the hidden CLI flag
|
|
392
|
+
`--dedup-threshold` for debugging / experiments. Validation of the range
|
|
393
|
+
is performed at the CLI layer (command_record) before reaching here, so
|
|
394
|
+
this helper trusts the caller's value when it's not None.
|
|
395
|
+
|
|
396
|
+
When matches are found, the returned hint includes each match's full
|
|
397
|
+
`<file_key>::<section_idx>::<entry_idx>` id so the agent can pass it
|
|
398
|
+
straight to `rewrite-entry --id <id>`.
|
|
399
|
+
"""
|
|
400
|
+
if force:
|
|
401
|
+
return None
|
|
402
|
+
if not body or not body.strip():
|
|
403
|
+
return None
|
|
404
|
+
spec = KIND_MAP.get(kind)
|
|
405
|
+
if not spec:
|
|
406
|
+
return None
|
|
407
|
+
if spec.get("default_mode") != "append":
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
file_key = spec["file"]
|
|
411
|
+
target_path = paths[file_key]
|
|
412
|
+
section_title = (title_override or spec["section"]).strip()
|
|
413
|
+
section_body = _load_section_body(target_path, section_title)
|
|
414
|
+
if threshold is None:
|
|
415
|
+
matches = similarity_check(body, section_body)
|
|
416
|
+
else:
|
|
417
|
+
matches = similarity_check(body, section_body, threshold=threshold)
|
|
418
|
+
if not matches:
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
section_idx = _resolve_section_idx(target_path, section_title)
|
|
422
|
+
# Fill in full entry ids on each match. If section_idx is None (section
|
|
423
|
+
# somehow not found, but we got matches — implies an in-memory race or a
|
|
424
|
+
# bug), fall back to entry-idx-only ids so the agent at least sees the
|
|
425
|
+
# ordinal.
|
|
426
|
+
for m in matches:
|
|
427
|
+
if section_idx is None:
|
|
428
|
+
m["id"] = f"{file_key}::?::{m['entry_idx']}"
|
|
429
|
+
else:
|
|
430
|
+
m["id"] = f"{file_key}::{section_idx}::{m['entry_idx']}"
|
|
431
|
+
|
|
432
|
+
return _build_dedup_hint(kind, file_key, section_title, body, matches)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
# rewrite-entry primitives
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def _parse_entry_id_local(eid):
|
|
440
|
+
"""Parse `<file_key>::<section_idx>::<entry_idx>` into a tuple. Local copy
|
|
441
|
+
rather than importing tidy's private function — keeps capture's CLI
|
|
442
|
+
surface decoupled from tidy's internal refactor risk."""
|
|
443
|
+
if not isinstance(eid, str):
|
|
444
|
+
return None
|
|
445
|
+
parts = eid.split("::")
|
|
446
|
+
if len(parts) != 3:
|
|
447
|
+
return None
|
|
448
|
+
try:
|
|
449
|
+
return parts[0], int(parts[1]), int(parts[2])
|
|
450
|
+
except ValueError:
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _replace_entry_at_index(body, target_idx, new_text):
|
|
455
|
+
"""Replace the top-level bullet at ordinal `target_idx` in `body` with
|
|
456
|
+
`new_text`. Returns (new_body, previous_first_line) where
|
|
457
|
+
`previous_first_line` is the first line of the entry that got
|
|
458
|
+
overwritten (for caller's audit output).
|
|
459
|
+
|
|
460
|
+
Why this lives here and not in tidy: tidy's `_apply_actions_to_section`
|
|
461
|
+
takes an entry-action dict and processes the whole section; capture's
|
|
462
|
+
rewrite-entry only ever touches one entry, so a focused helper keeps the
|
|
463
|
+
code reviewable and skips tidy's keep/delete branching.
|
|
464
|
+
|
|
465
|
+
Multi-line `new_text` is rendered as a single bullet with continuation
|
|
466
|
+
lines indented two spaces — same shape `_apply_actions_to_section` uses
|
|
467
|
+
for edited entries, so the on-disk format stays consistent across both
|
|
468
|
+
write paths.
|
|
469
|
+
"""
|
|
470
|
+
lines = body.splitlines()
|
|
471
|
+
out_lines = []
|
|
472
|
+
current_block_lines = None
|
|
473
|
+
current_idx = -1
|
|
474
|
+
counter = -1
|
|
475
|
+
found = False
|
|
476
|
+
previous_first_line = None
|
|
477
|
+
|
|
478
|
+
def render_new():
|
|
479
|
+
new_text_stripped = (new_text or "").strip()
|
|
480
|
+
if not new_text_stripped:
|
|
481
|
+
# Empty new_text would silently delete — disallow at this layer;
|
|
482
|
+
# caller validates before calling.
|
|
483
|
+
return []
|
|
484
|
+
first, *rest = new_text_stripped.split("\n")
|
|
485
|
+
out = ["- " + first.strip()]
|
|
486
|
+
for cont in rest:
|
|
487
|
+
out.append(" " + cont.strip())
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
def flush():
|
|
491
|
+
nonlocal current_block_lines, current_idx, found, previous_first_line
|
|
492
|
+
if current_block_lines is None:
|
|
493
|
+
return
|
|
494
|
+
if current_idx == target_idx and not found:
|
|
495
|
+
# Capture previous first line before replacement for the audit
|
|
496
|
+
# output.
|
|
497
|
+
for ln in current_block_lines:
|
|
498
|
+
s = ln.strip()
|
|
499
|
+
if s.startswith("- "):
|
|
500
|
+
previous_first_line = s[2:].strip()
|
|
501
|
+
break
|
|
502
|
+
if s:
|
|
503
|
+
previous_first_line = s
|
|
504
|
+
break
|
|
505
|
+
out_lines.extend(render_new())
|
|
506
|
+
found = True
|
|
507
|
+
else:
|
|
508
|
+
out_lines.extend(current_block_lines)
|
|
509
|
+
current_block_lines = None
|
|
510
|
+
current_idx = -1
|
|
511
|
+
|
|
512
|
+
for raw in lines:
|
|
513
|
+
stripped = raw.strip()
|
|
514
|
+
if stripped.startswith("- ") and not (raw.startswith(" ") or raw.startswith("\t")):
|
|
515
|
+
flush()
|
|
516
|
+
counter += 1
|
|
517
|
+
current_idx = counter
|
|
518
|
+
current_block_lines = [raw]
|
|
519
|
+
elif current_block_lines is not None and stripped and (raw.startswith(" ") or raw.startswith("\t")):
|
|
520
|
+
current_block_lines.append(raw)
|
|
521
|
+
elif not stripped:
|
|
522
|
+
flush()
|
|
523
|
+
out_lines.append(raw)
|
|
524
|
+
else:
|
|
525
|
+
# Free-form prose between bullets — pass through.
|
|
526
|
+
flush()
|
|
527
|
+
out_lines.append(raw)
|
|
528
|
+
flush()
|
|
529
|
+
|
|
530
|
+
# Trim trailing blank lines.
|
|
531
|
+
while out_lines and not out_lines[-1].strip():
|
|
532
|
+
out_lines.pop()
|
|
533
|
+
|
|
534
|
+
return "\n".join(out_lines).strip(), previous_first_line, found
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# v2 KIND_MAP. `default_mode` governs whether a new entry accumulates
|
|
538
|
+
# (append) or replaces the whole section (upsert). Accumulation fits
|
|
539
|
+
# "decisions", "risks", "glossary" where each entry is independent and
|
|
540
|
+
# historically meaningful; upsert fits "progress/next/overview" where the
|
|
541
|
+
# section represents *latest state* and older content is stale by design.
|
|
542
|
+
KIND_MAP = {
|
|
543
|
+
# accumulation (each entry stands alone, new ones add to the list)
|
|
544
|
+
"decision": {"file": "decisions", "section": "关键决策与原因", "default_mode": "append"},
|
|
545
|
+
"risk": {"file": "risks", "section": "阻塞与注意点", "default_mode": "append"},
|
|
546
|
+
"glossary": {"file": "glossary", "section": "当前有效上下文", "default_mode": "append"},
|
|
547
|
+
"source": {"file": "glossary", "section": "分支源资料入口", "default_mode": "append"},
|
|
548
|
+
# snapshot (section always reflects the latest state; new write replaces)
|
|
549
|
+
"progress": {"file": "progress", "section": "当前进展", "default_mode": "upsert"},
|
|
550
|
+
"next": {"file": "progress", "section": "下一步", "default_mode": "upsert"},
|
|
551
|
+
"overview": {"file": "overview", "section": "当前目标", "default_mode": "upsert"},
|
|
552
|
+
"scope": {"file": "overview", "section": "范围边界", "default_mode": "upsert"},
|
|
553
|
+
"stage": {"file": "overview", "section": "当前阶段", "default_mode": "upsert"},
|
|
554
|
+
"constraint": {"file": "overview", "section": "关键约束", "default_mode": "upsert"},
|
|
555
|
+
# repo-shared: decisions/context/source accumulate, overview/constraint snapshot
|
|
556
|
+
"shared-decision": {"file": "repo_decisions", "section": "跨分支通用决策", "default_mode": "append"},
|
|
557
|
+
"shared-context": {"file": "repo_glossary", "section": "长期有效背景", "default_mode": "append"},
|
|
558
|
+
"shared-source": {"file": "repo_glossary", "section": "共享入口", "default_mode": "append"},
|
|
559
|
+
"shared-overview": {"file": "repo_overview", "section": "长期目标与边界", "default_mode": "upsert"},
|
|
560
|
+
"shared-constraint": {"file": "repo_overview", "section": "仓库级关键约束", "default_mode": "upsert"},
|
|
561
|
+
# fallback bins always accumulate
|
|
562
|
+
"unsorted": {"file": "unsorted", "section": "待分类", "default_mode": "append"},
|
|
563
|
+
"pending": {"file": "pending_promotion", "section": "候选条目", "default_mode": "append"},
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
# Session-payload → kind mapping, used by `record --summary-json`. Each entry
|
|
567
|
+
# is (payload_key, kind, optional_transform). Several keys may route to the
|
|
568
|
+
# same kind; that's fine — the write layer upserts.
|
|
569
|
+
SESSION_PAYLOAD_MAP = [
|
|
570
|
+
("overview_summary", "overview", None),
|
|
571
|
+
("implementation_notes", "progress", None),
|
|
572
|
+
("changes", "progress", None),
|
|
573
|
+
("next_steps", "next", None),
|
|
574
|
+
("risks", "risk", None),
|
|
575
|
+
("memory", "glossary", None),
|
|
576
|
+
("context_updates", "glossary", None),
|
|
577
|
+
("review_notes", "decision", None),
|
|
578
|
+
("frontend_updates", "glossary", "前端相关"),
|
|
579
|
+
("backend_updates", "glossary", "后端相关"),
|
|
580
|
+
("test_updates", "glossary", "测试相关"),
|
|
581
|
+
("sources", "shared-source", None),
|
|
582
|
+
("source_updates", "shared-source", None),
|
|
583
|
+
]
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def normalize_items(items):
|
|
587
|
+
if items is None:
|
|
588
|
+
return []
|
|
589
|
+
if isinstance(items, str):
|
|
590
|
+
stripped = items.strip()
|
|
591
|
+
return [stripped] if stripped else []
|
|
592
|
+
return [str(item).strip() for item in items if str(item).strip()]
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def bullets(items, empty_text="- 待补充", wrap_code=False):
|
|
596
|
+
return render_bullets(normalize_items(items), empty_text=empty_text, wrap_code=wrap_code)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def decision_body(item):
|
|
600
|
+
parts = [f"- 结论: {item['decision']}"]
|
|
601
|
+
if item.get("reason"):
|
|
602
|
+
parts.append(f"- 原因: {item['reason']}")
|
|
603
|
+
if item.get("impact"):
|
|
604
|
+
parts.append(f"- 影响范围: {item['impact']}")
|
|
605
|
+
return "\n".join(parts)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# ---------------------------------------------------------------------------
|
|
609
|
+
# Write primitives
|
|
610
|
+
# ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
def _resolve_target(paths, kind, title_override=None):
|
|
613
|
+
"""Return (target_path, section_title) for a given kind. Raises on
|
|
614
|
+
unknown kind so the caller can surface a clear error."""
|
|
615
|
+
spec = KIND_MAP.get(kind)
|
|
616
|
+
if not spec:
|
|
617
|
+
raise RuntimeError(f"unsupported kind: {kind}")
|
|
618
|
+
target_path = paths[spec["file"]]
|
|
619
|
+
section_title = (title_override or spec["section"]).strip()
|
|
620
|
+
return spec["file"], target_path, section_title
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _write_one(paths, kind, body, title_override=None, *, mode_override=None):
|
|
624
|
+
"""Write a body into the kind's target file. Picks append vs upsert from
|
|
625
|
+
KIND_MAP[kind].default_mode unless overridden. progress.md uses a
|
|
626
|
+
dedicated upsert that preserves the auto-sync marker.
|
|
627
|
+
|
|
628
|
+
When appending multi-line bodies, a blank line is inserted between the
|
|
629
|
+
existing content and the new entry so each entry reads as its own unit.
|
|
630
|
+
"""
|
|
631
|
+
file_key, target_path, section_title = _resolve_target(paths, kind, title_override)
|
|
632
|
+
spec = KIND_MAP.get(kind) or {}
|
|
633
|
+
mode = mode_override or spec.get("default_mode", "upsert")
|
|
634
|
+
|
|
635
|
+
if mode == "append":
|
|
636
|
+
# Auto-prefix a bullet if the body isn't already one — accumulation
|
|
637
|
+
# sections are bullet lists by convention. The separator helper
|
|
638
|
+
# handles the blank-line spacing so entries remain visually distinct.
|
|
639
|
+
body_to_write = body.strip()
|
|
640
|
+
if not body_to_write.startswith(("- ", "* ", "#")):
|
|
641
|
+
body_to_write = "- " + body_to_write
|
|
642
|
+
_append_with_separator(target_path, section_title, body_to_write)
|
|
643
|
+
else:
|
|
644
|
+
if file_key == "progress":
|
|
645
|
+
upsert_progress_section(target_path, section_title, body)
|
|
646
|
+
else:
|
|
647
|
+
upsert_markdown_section(target_path, section_title, body)
|
|
648
|
+
return {"file": _label(file_key), "section": section_title, "mode": mode}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _label(file_key):
|
|
652
|
+
if file_key.startswith("repo_"):
|
|
653
|
+
return f"repo/{file_key[5:]}.md"
|
|
654
|
+
if file_key == "pending_promotion":
|
|
655
|
+
return "branch/pending-promotion.md"
|
|
656
|
+
return f"branch/{file_key}.md"
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _maybe_stage_pending(paths, body, branch_name):
|
|
660
|
+
"""If body looks cross-branch-reusable, append a copy to pending-promotion
|
|
661
|
+
with a lightweight marker. Returns the touch record or None.
|
|
662
|
+
"""
|
|
663
|
+
if not is_cross_branch_candidate(body, branch_name):
|
|
664
|
+
return None
|
|
665
|
+
# Append rather than upsert — pending should accumulate candidates, not
|
|
666
|
+
# overwrite.
|
|
667
|
+
entry = f"- {now_iso()[:10]}: {body.strip().splitlines()[0][:160]}"
|
|
668
|
+
# Keep full text as a nested detail so graduate has the original.
|
|
669
|
+
full = f"{entry}\n - 原文: {body.strip().replace(chr(10), ' / ')[:500]}"
|
|
670
|
+
append_to_section(paths["pending_promotion"], "候选条目", full)
|
|
671
|
+
return {"file": "branch/pending-promotion.md", "section": "候选条目", "mode": "append-candidate"}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------------------
|
|
675
|
+
# Content loaders
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
def _load_optional_text(value, file_path=None):
|
|
679
|
+
if value:
|
|
680
|
+
stripped = value.strip()
|
|
681
|
+
return stripped or None
|
|
682
|
+
if file_path:
|
|
683
|
+
return (Path(file_path).read_text(encoding="utf-8").strip()) or None
|
|
684
|
+
return None
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _load_free_content(args):
|
|
688
|
+
"""Load content for single-kind writes. Supports:
|
|
689
|
+
--content / --content-file : inline body
|
|
690
|
+
--summary / --summary-file : session-derived summary (prefixed)
|
|
691
|
+
--user-input / --user-input-file : user turn (prefixed)
|
|
692
|
+
When both summary and user-input are given, they're combined; inline
|
|
693
|
+
content becomes an additional 补充备注 section.
|
|
694
|
+
"""
|
|
695
|
+
inline = _load_optional_text(args.content, getattr(args, "content_file", None))
|
|
696
|
+
summary = _load_optional_text(getattr(args, "summary", None), getattr(args, "summary_file", None))
|
|
697
|
+
user_input = _load_optional_text(getattr(args, "user_input", None), getattr(args, "user_input_file", None))
|
|
698
|
+
|
|
699
|
+
if summary or user_input:
|
|
700
|
+
blocks = []
|
|
701
|
+
if user_input:
|
|
702
|
+
blocks.append(f"### 用户这次输入\n\n{user_input}")
|
|
703
|
+
if summary:
|
|
704
|
+
blocks.append(f"### 基于当前会话整理\n\n{summary}")
|
|
705
|
+
if inline:
|
|
706
|
+
blocks.append(f"### 补充备注\n\n{inline}")
|
|
707
|
+
return "\n\n".join(blocks), "session+input"
|
|
708
|
+
if inline:
|
|
709
|
+
return inline, "content-only"
|
|
710
|
+
return None, None
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# ---------------------------------------------------------------------------
|
|
714
|
+
# Commands
|
|
715
|
+
# ---------------------------------------------------------------------------
|
|
716
|
+
|
|
717
|
+
def command_show(args):
|
|
718
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
719
|
+
args.repo, args.context_dir, args.branch
|
|
720
|
+
)
|
|
721
|
+
payload = {
|
|
722
|
+
"repo_root": str(repo_root),
|
|
723
|
+
"repo_key": repo_key,
|
|
724
|
+
"branch": branch_name,
|
|
725
|
+
"branch_key": branch_key,
|
|
726
|
+
"storage_root": str(storage_root),
|
|
727
|
+
"repo_dir": str(repo_dir),
|
|
728
|
+
"branch_dir": str(branch_dir),
|
|
729
|
+
"setup_completed": get_setup_completed(paths["manifest"]),
|
|
730
|
+
"files": {key: str(value) for key, value in paths.items()},
|
|
731
|
+
"missing_or_placeholder": list_missing_docs(paths),
|
|
732
|
+
}
|
|
733
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
734
|
+
return 0
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def command_suggest_kind(args):
|
|
738
|
+
content = _load_optional_text(args.content, args.content_file)
|
|
739
|
+
if not content:
|
|
740
|
+
raise RuntimeError("one of --content / --content-file is required")
|
|
741
|
+
already_setup = bool(args.already_setup)
|
|
742
|
+
kind = classify_content(content, already_setup=already_setup)
|
|
743
|
+
spec = KIND_MAP.get(kind, {"file": "unsorted", "section": "待分类"})
|
|
744
|
+
branch_name = args.branch_name or ""
|
|
745
|
+
cross_branch = is_cross_branch_candidate(content, branch_name) if branch_name else None
|
|
746
|
+
print(
|
|
747
|
+
json.dumps(
|
|
748
|
+
{
|
|
749
|
+
"kind": kind,
|
|
750
|
+
"target_file": _label(spec["file"]),
|
|
751
|
+
"section": spec["section"],
|
|
752
|
+
"cross_branch_candidate": cross_branch,
|
|
753
|
+
"already_setup": already_setup,
|
|
754
|
+
},
|
|
755
|
+
ensure_ascii=False,
|
|
756
|
+
indent=2,
|
|
757
|
+
)
|
|
758
|
+
)
|
|
759
|
+
return 0
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def command_classify(args):
|
|
763
|
+
# Like suggest-kind but always resolves through the lazy-init pipeline so
|
|
764
|
+
# the caller also gets the computed paths (useful for the capture skill's
|
|
765
|
+
# inner loop).
|
|
766
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
767
|
+
args.repo, args.context_dir, args.branch
|
|
768
|
+
)
|
|
769
|
+
content = _load_optional_text(args.content, args.content_file)
|
|
770
|
+
if not content:
|
|
771
|
+
raise RuntimeError("one of --content / --content-file is required")
|
|
772
|
+
already_setup = get_setup_completed(paths["manifest"])
|
|
773
|
+
kind = classify_content(content, already_setup=already_setup)
|
|
774
|
+
spec = KIND_MAP.get(kind, KIND_MAP["unsorted"])
|
|
775
|
+
cross_branch = is_cross_branch_candidate(content, branch_name or "")
|
|
776
|
+
print(
|
|
777
|
+
json.dumps(
|
|
778
|
+
{
|
|
779
|
+
"branch": branch_name,
|
|
780
|
+
"already_setup": already_setup,
|
|
781
|
+
"kind": kind,
|
|
782
|
+
"target_file": _label(spec["file"]),
|
|
783
|
+
"section": spec["section"],
|
|
784
|
+
"cross_branch_candidate": cross_branch,
|
|
785
|
+
},
|
|
786
|
+
ensure_ascii=False,
|
|
787
|
+
indent=2,
|
|
788
|
+
)
|
|
789
|
+
)
|
|
790
|
+
return 0
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def command_record(args):
|
|
794
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
795
|
+
args.repo, args.context_dir, args.branch
|
|
796
|
+
)
|
|
797
|
+
already_setup = get_setup_completed(paths["manifest"])
|
|
798
|
+
force = bool(getattr(args, "force", False))
|
|
799
|
+
|
|
800
|
+
# Hidden --dedup-threshold override. Validated here so the error surface
|
|
801
|
+
# is consistent with other record-time failures (RuntimeError → exit 1 +
|
|
802
|
+
# error JSON via main's try/except). None means "use similarity_check
|
|
803
|
+
# default" — the production path.
|
|
804
|
+
dedup_threshold = getattr(args, "dedup_threshold", None)
|
|
805
|
+
if dedup_threshold is not None:
|
|
806
|
+
if not isinstance(dedup_threshold, (int, float)):
|
|
807
|
+
raise RuntimeError(
|
|
808
|
+
f"--dedup-threshold must be a number, got {type(dedup_threshold).__name__}"
|
|
809
|
+
)
|
|
810
|
+
if not (0.0 < dedup_threshold <= 1.0):
|
|
811
|
+
raise RuntimeError(
|
|
812
|
+
f"--dedup-threshold must be in (0.0, 1.0], got {dedup_threshold}"
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
touched = []
|
|
816
|
+
dedup_blocked = [] # entries that were stopped by dedup check (batch mode)
|
|
817
|
+
mode_used = None
|
|
818
|
+
|
|
819
|
+
# Mode 1: batch session payload (merges old sync record-session).
|
|
820
|
+
payload = None
|
|
821
|
+
if args.summary_json:
|
|
822
|
+
payload = json.loads(args.summary_json)
|
|
823
|
+
elif args.summary_file:
|
|
824
|
+
payload = json.loads(Path(args.summary_file).read_text(encoding="utf-8"))
|
|
825
|
+
|
|
826
|
+
if payload:
|
|
827
|
+
mode_used = "session-payload"
|
|
828
|
+
title = payload.get("title") or "checkpoint"
|
|
829
|
+
|
|
830
|
+
def _write_or_block(kind, body, title_override=None, source_label=None):
|
|
831
|
+
"""Run dedup check; if blocked, push onto dedup_blocked; else write.
|
|
832
|
+
`source_label` is a free-form tag (e.g. "decisions[0]") so the
|
|
833
|
+
caller can correlate the blocked entry back to its payload slot.
|
|
834
|
+
"""
|
|
835
|
+
hint = _check_dedup_for_kind(
|
|
836
|
+
paths, kind, body, force=force,
|
|
837
|
+
title_override=title_override, threshold=dedup_threshold,
|
|
838
|
+
)
|
|
839
|
+
if hint is not None:
|
|
840
|
+
dedup_blocked.append({
|
|
841
|
+
"source": source_label,
|
|
842
|
+
"kind": kind,
|
|
843
|
+
"content_preview": _first_nonempty_line(body)[:_SIM_PREVIEW_LEN],
|
|
844
|
+
"dedup_hint": hint,
|
|
845
|
+
})
|
|
846
|
+
return None
|
|
847
|
+
return _write_one(paths, kind, body, title_override=title_override)
|
|
848
|
+
|
|
849
|
+
# Simple scalar mappings.
|
|
850
|
+
for key, kind, subsection_title in SESSION_PAYLOAD_MAP:
|
|
851
|
+
items = normalize_items(payload.get(key))
|
|
852
|
+
if not items:
|
|
853
|
+
continue
|
|
854
|
+
body = bullets(items) if not subsection_title else f"### {subsection_title}\n\n{bullets(items)}"
|
|
855
|
+
rec = _write_or_block(kind, body, source_label=f"payload[{key}]")
|
|
856
|
+
if rec is not None:
|
|
857
|
+
touched.append(rec)
|
|
858
|
+
# Cross-branch staging — apply to each item separately for best recall.
|
|
859
|
+
for item in items:
|
|
860
|
+
pending = _maybe_stage_pending(paths, item, branch_name or "")
|
|
861
|
+
if pending:
|
|
862
|
+
touched.append(pending)
|
|
863
|
+
|
|
864
|
+
# Risks also go into the 后续继续前要注意 section (same as v1 behavior).
|
|
865
|
+
risk_items = normalize_items(payload.get("risks"))
|
|
866
|
+
if risk_items:
|
|
867
|
+
rec = _write_or_block(
|
|
868
|
+
"risk",
|
|
869
|
+
bullets(risk_items),
|
|
870
|
+
title_override="后续继续前要注意",
|
|
871
|
+
source_label="payload[risks→后续继续前要注意]",
|
|
872
|
+
)
|
|
873
|
+
if rec is not None:
|
|
874
|
+
touched.append(rec)
|
|
875
|
+
|
|
876
|
+
# Structured decisions (decision/reason/impact trios).
|
|
877
|
+
decision_items = [decision_body(item) for item in (payload.get("decisions") or []) if item.get("decision")]
|
|
878
|
+
if decision_items:
|
|
879
|
+
body = "\n\n".join(decision_items)
|
|
880
|
+
rec = _write_or_block("decision", body, source_label="payload[decisions]")
|
|
881
|
+
if rec is not None:
|
|
882
|
+
touched.append(rec)
|
|
883
|
+
for item in payload.get("decisions") or []:
|
|
884
|
+
if item.get("decision"):
|
|
885
|
+
pending = _maybe_stage_pending(paths, item["decision"], branch_name or "")
|
|
886
|
+
if pending:
|
|
887
|
+
touched.append(pending)
|
|
888
|
+
|
|
889
|
+
extra_manifest = {"last_session_sync_title": title, "last_session_sync_mode": "capture-session"}
|
|
890
|
+
|
|
891
|
+
else:
|
|
892
|
+
# Mode 2/3: free-form content with kind (explicit or auto-classify).
|
|
893
|
+
content, update_mode = _load_free_content(args)
|
|
894
|
+
if content is None:
|
|
895
|
+
raise RuntimeError(
|
|
896
|
+
"provide one of: --summary-json/-file (batch), --kind+--content (targeted), "
|
|
897
|
+
"or --auto --content (heuristic classify)"
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
kind = args.kind.lower() if args.kind else None
|
|
901
|
+
if args.auto or not kind:
|
|
902
|
+
classified = classify_content(content, already_setup=already_setup)
|
|
903
|
+
kind = kind or classified
|
|
904
|
+
mode_used = f"auto-classified-{classified}"
|
|
905
|
+
else:
|
|
906
|
+
mode_used = "explicit-kind"
|
|
907
|
+
|
|
908
|
+
# Dedup pre-check: if blocked, do not write — return hint and exit
|
|
909
|
+
# with code 2 so callers can distinguish "blocked, take action" from
|
|
910
|
+
# "actual error" (exit 1).
|
|
911
|
+
hint = _check_dedup_for_kind(
|
|
912
|
+
paths, kind, content, force=force,
|
|
913
|
+
title_override=args.title, threshold=dedup_threshold,
|
|
914
|
+
)
|
|
915
|
+
if hint is not None:
|
|
916
|
+
print(json.dumps({
|
|
917
|
+
"blocked": True,
|
|
918
|
+
"repo_root": str(repo_root),
|
|
919
|
+
"repo_key": repo_key,
|
|
920
|
+
"branch": branch_name,
|
|
921
|
+
"storage_root": str(storage_root),
|
|
922
|
+
"branch_dir": str(branch_dir),
|
|
923
|
+
"mode": mode_used,
|
|
924
|
+
"kind": kind,
|
|
925
|
+
"dedup_hint": hint,
|
|
926
|
+
}, ensure_ascii=False, indent=2))
|
|
927
|
+
return 2
|
|
928
|
+
|
|
929
|
+
touched.append(_write_one(paths, kind, content, title_override=args.title))
|
|
930
|
+
rec = _maybe_stage_pending(paths, content, branch_name or "")
|
|
931
|
+
if rec:
|
|
932
|
+
touched.append(rec)
|
|
933
|
+
|
|
934
|
+
extra_manifest = {"last_capture_kind": kind, "last_capture_mode": mode_used, "last_capture_update_mode": update_mode}
|
|
935
|
+
|
|
936
|
+
# Manifest bookkeeping.
|
|
937
|
+
manifest = read_json(paths["manifest"])
|
|
938
|
+
manifest.update(
|
|
939
|
+
{
|
|
940
|
+
"repo_root": str(repo_root),
|
|
941
|
+
"repo_key": repo_key,
|
|
942
|
+
"branch": branch_name,
|
|
943
|
+
"branch_key": branch_key,
|
|
944
|
+
"storage_root": str(storage_root),
|
|
945
|
+
"updated_at": now_iso(),
|
|
946
|
+
"last_seen_head": get_head_commit(repo_root) if repo_root else None,
|
|
947
|
+
}
|
|
948
|
+
)
|
|
949
|
+
manifest.update(extra_manifest)
|
|
950
|
+
manifest["last_capture_targets"] = touched
|
|
951
|
+
write_json(paths["manifest"], manifest)
|
|
952
|
+
|
|
953
|
+
repo_manifest = read_json(paths["repo_manifest"])
|
|
954
|
+
repo_manifest.update(
|
|
955
|
+
{
|
|
956
|
+
"repo_root": str(repo_root),
|
|
957
|
+
"repo_key": repo_key,
|
|
958
|
+
"storage_root": str(storage_root),
|
|
959
|
+
"updated_at": manifest["updated_at"],
|
|
960
|
+
"last_seen_branch": branch_name,
|
|
961
|
+
"last_seen_head": manifest["last_seen_head"],
|
|
962
|
+
}
|
|
963
|
+
)
|
|
964
|
+
write_json(paths["repo_manifest"], repo_manifest)
|
|
965
|
+
|
|
966
|
+
# Event log: one row per record call. Skip when nothing was actually
|
|
967
|
+
# written (e.g. batch mode where every entry was dedup-blocked) — the log
|
|
968
|
+
# would be a misleading "we did something" signal.
|
|
969
|
+
if touched:
|
|
970
|
+
if payload:
|
|
971
|
+
log_kind = "session-payload"
|
|
972
|
+
log_summary = payload.get("title") or f"{len(touched)} target(s)"
|
|
973
|
+
else:
|
|
974
|
+
log_kind = manifest.get("last_capture_kind") or "auto"
|
|
975
|
+
log_summary = (
|
|
976
|
+
content if 'content' in locals() and content else f"{len(touched)} target(s)"
|
|
977
|
+
)
|
|
978
|
+
extra = []
|
|
979
|
+
if dedup_blocked:
|
|
980
|
+
extra.append(("blocked", len(dedup_blocked)))
|
|
981
|
+
_emit_capture_log(
|
|
982
|
+
paths,
|
|
983
|
+
action="capture",
|
|
984
|
+
kind_label=log_kind,
|
|
985
|
+
summary=log_summary,
|
|
986
|
+
touched=touched,
|
|
987
|
+
extra_details=extra,
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
output = {
|
|
991
|
+
"repo_root": str(repo_root),
|
|
992
|
+
"repo_key": repo_key,
|
|
993
|
+
"branch": branch_name,
|
|
994
|
+
"storage_root": str(storage_root),
|
|
995
|
+
"repo_dir": str(repo_dir),
|
|
996
|
+
"branch_dir": str(branch_dir),
|
|
997
|
+
"mode": mode_used,
|
|
998
|
+
"setup_completed": already_setup,
|
|
999
|
+
"touched_targets": touched,
|
|
1000
|
+
"updated_at": manifest["updated_at"],
|
|
1001
|
+
}
|
|
1002
|
+
if dedup_blocked:
|
|
1003
|
+
# Batch mode: surface blocked items but still report what was written.
|
|
1004
|
+
output["dedup_blocked"] = dedup_blocked
|
|
1005
|
+
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
1006
|
+
# Batch with any blocked entries → exit 2 so caller knows to handle them.
|
|
1007
|
+
return 2 if dedup_blocked else 0
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def command_rewrite_entry(args):
|
|
1011
|
+
"""Replace a single existing entry by id with new content.
|
|
1012
|
+
|
|
1013
|
+
Why this is a subcommand rather than a flag on `record`: rewrite-entry's
|
|
1014
|
+
inputs (id + new content) don't overlap with record's classify/auto/
|
|
1015
|
+
session-payload surface, and keeping it separate makes the dedup-driven
|
|
1016
|
+
"update existing instead of append" workflow read clearly in CLI logs.
|
|
1017
|
+
"""
|
|
1018
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
1019
|
+
args.repo, args.context_dir, args.branch
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
eid = (args.id or "").strip()
|
|
1023
|
+
parsed = _parse_entry_id_local(eid)
|
|
1024
|
+
if not parsed:
|
|
1025
|
+
print(json.dumps({
|
|
1026
|
+
"error": f"malformed entry id: {eid!r}",
|
|
1027
|
+
"expected_format": "<file_key>::<section_idx>::<entry_idx>",
|
|
1028
|
+
}, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1029
|
+
return 1
|
|
1030
|
+
file_key, section_idx, entry_idx = parsed
|
|
1031
|
+
if file_key not in paths:
|
|
1032
|
+
print(json.dumps({
|
|
1033
|
+
"error": f"unknown file_key {file_key!r}",
|
|
1034
|
+
"available_file_keys": sorted(paths.keys()),
|
|
1035
|
+
}, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1036
|
+
return 1
|
|
1037
|
+
|
|
1038
|
+
new_text = _load_optional_text(args.content, args.content_file)
|
|
1039
|
+
if not new_text:
|
|
1040
|
+
print(json.dumps({"error": "one of --content / --content-file is required"}, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1041
|
+
return 1
|
|
1042
|
+
|
|
1043
|
+
target_path = paths[file_key]
|
|
1044
|
+
if not target_path.exists():
|
|
1045
|
+
print(json.dumps({
|
|
1046
|
+
"error": f"file does not exist: {target_path}",
|
|
1047
|
+
"file_key": file_key,
|
|
1048
|
+
}, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1049
|
+
return 1
|
|
1050
|
+
|
|
1051
|
+
content = target_path.read_text(encoding="utf-8")
|
|
1052
|
+
prefix, sections = split_sections(content)
|
|
1053
|
+
if section_idx < 0 or section_idx >= len(sections):
|
|
1054
|
+
print(json.dumps({
|
|
1055
|
+
"error": f"section_idx {section_idx} out of range (file has {len(sections)} sections)",
|
|
1056
|
+
"id": eid,
|
|
1057
|
+
}, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1058
|
+
return 1
|
|
1059
|
+
|
|
1060
|
+
section_title, section_body = sections[section_idx]
|
|
1061
|
+
|
|
1062
|
+
# Preserve any AUTO-GENERATED block: rewrite only the body before
|
|
1063
|
+
# AUTO_START, then re-attach the block as-is. Matches tidy's behavior so
|
|
1064
|
+
# progress.md auto-blocks survive rewrite-entry calls.
|
|
1065
|
+
if AUTO_START in section_body and AUTO_END in section_body:
|
|
1066
|
+
head_end = section_body.index(AUTO_START)
|
|
1067
|
+
head = section_body[:head_end].rstrip()
|
|
1068
|
+
tail = section_body[head_end:]
|
|
1069
|
+
new_head, previous_first_line, found = _replace_entry_at_index(head, entry_idx, new_text)
|
|
1070
|
+
new_body = (new_head + "\n\n" + tail).strip() if new_head else tail.strip()
|
|
1071
|
+
else:
|
|
1072
|
+
new_body, previous_first_line, found = _replace_entry_at_index(section_body, entry_idx, new_text)
|
|
1073
|
+
|
|
1074
|
+
if not found:
|
|
1075
|
+
print(json.dumps({
|
|
1076
|
+
"error": f"entry_idx {entry_idx} not found in section {section_idx!r}",
|
|
1077
|
+
"id": eid,
|
|
1078
|
+
}, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1079
|
+
return 1
|
|
1080
|
+
|
|
1081
|
+
new_sections = list(sections)
|
|
1082
|
+
new_sections[section_idx] = (section_title, new_body)
|
|
1083
|
+
target_path.write_text(join_sections(prefix, new_sections), encoding="utf-8")
|
|
1084
|
+
|
|
1085
|
+
# Manifest bookkeeping (same shape as record).
|
|
1086
|
+
manifest = read_json(paths["manifest"])
|
|
1087
|
+
updated_at = now_iso()
|
|
1088
|
+
manifest.update(
|
|
1089
|
+
{
|
|
1090
|
+
"repo_root": str(repo_root),
|
|
1091
|
+
"repo_key": repo_key,
|
|
1092
|
+
"branch": branch_name,
|
|
1093
|
+
"branch_key": branch_key,
|
|
1094
|
+
"storage_root": str(storage_root),
|
|
1095
|
+
"updated_at": updated_at,
|
|
1096
|
+
"last_seen_head": get_head_commit(repo_root) if repo_root else None,
|
|
1097
|
+
"last_capture_kind": None,
|
|
1098
|
+
"last_capture_mode": "rewrite-entry",
|
|
1099
|
+
"last_capture_update_mode": "rewrite-entry",
|
|
1100
|
+
"last_capture_targets": [{
|
|
1101
|
+
"file": _label(file_key),
|
|
1102
|
+
"section": section_title,
|
|
1103
|
+
"mode": "rewrite-entry",
|
|
1104
|
+
"id": eid,
|
|
1105
|
+
}],
|
|
1106
|
+
}
|
|
1107
|
+
)
|
|
1108
|
+
write_json(paths["manifest"], manifest)
|
|
1109
|
+
|
|
1110
|
+
new_first_line = _first_nonempty_line(_strip_bullet_prefix(_first_nonempty_line(new_text)))
|
|
1111
|
+
|
|
1112
|
+
_emit_capture_log(
|
|
1113
|
+
paths,
|
|
1114
|
+
action="rewrite-entry",
|
|
1115
|
+
kind_label=file_key,
|
|
1116
|
+
summary=new_first_line,
|
|
1117
|
+
touched=[{"file": _label(file_key), "section": section_title, "mode": "rewrite-entry"}],
|
|
1118
|
+
extra_details=[("id", eid), ("previous", previous_first_line)],
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
print(json.dumps({
|
|
1122
|
+
"mode": "rewrite-entry",
|
|
1123
|
+
"id": eid,
|
|
1124
|
+
"file": _label(file_key),
|
|
1125
|
+
"section": section_title,
|
|
1126
|
+
"previous_first_line": previous_first_line,
|
|
1127
|
+
"new_first_line": new_first_line,
|
|
1128
|
+
"updated_at": updated_at,
|
|
1129
|
+
}, ensure_ascii=False, indent=2))
|
|
1130
|
+
return 0
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def command_sync_working_tree(args):
|
|
1134
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
1135
|
+
args.repo, args.context_dir, args.branch
|
|
1136
|
+
)
|
|
1137
|
+
if branch_name is None:
|
|
1138
|
+
# no-git mode — no git facts to derive.
|
|
1139
|
+
print(json.dumps({"mode": "no-git", "skipped": True}, ensure_ascii=False))
|
|
1140
|
+
return 0
|
|
1141
|
+
|
|
1142
|
+
prior_manifest = read_json(paths["manifest"]) or {}
|
|
1143
|
+
facts = collect_git_facts(repo_root, branch_name, storage_root)
|
|
1144
|
+
all_paths = sorted(set(
|
|
1145
|
+
facts["working_tree_files"]
|
|
1146
|
+
+ facts["staged_files"]
|
|
1147
|
+
+ facts["untracked_files"]
|
|
1148
|
+
+ facts["recent_commit_files"]
|
|
1149
|
+
))
|
|
1150
|
+
facts["focus_areas"] = merged_focus_areas(all_paths, prior_manifest.get("focus_areas") or [])
|
|
1151
|
+
sync_progress(paths, facts)
|
|
1152
|
+
|
|
1153
|
+
manifest = read_json(paths["manifest"])
|
|
1154
|
+
manifest.update(
|
|
1155
|
+
{
|
|
1156
|
+
"repo_root": str(repo_root),
|
|
1157
|
+
"repo_key": repo_key,
|
|
1158
|
+
"branch": branch_name,
|
|
1159
|
+
"branch_key": branch_key,
|
|
1160
|
+
"storage_root": str(storage_root),
|
|
1161
|
+
"updated_at": facts["updated_at"],
|
|
1162
|
+
"last_seen_head": facts["last_seen_head"],
|
|
1163
|
+
"default_base": facts["default_base"],
|
|
1164
|
+
"scope_summary": facts["scope_summary"],
|
|
1165
|
+
"focus_areas": facts["focus_areas"],
|
|
1166
|
+
}
|
|
1167
|
+
)
|
|
1168
|
+
write_json(paths["manifest"], manifest)
|
|
1169
|
+
|
|
1170
|
+
repo_manifest = read_json(paths["repo_manifest"])
|
|
1171
|
+
repo_manifest.update(
|
|
1172
|
+
{
|
|
1173
|
+
"repo_root": str(repo_root),
|
|
1174
|
+
"repo_key": repo_key,
|
|
1175
|
+
"storage_root": str(storage_root),
|
|
1176
|
+
"updated_at": facts["updated_at"],
|
|
1177
|
+
"last_seen_branch": branch_name,
|
|
1178
|
+
"last_seen_head": facts["last_seen_head"],
|
|
1179
|
+
"default_base": facts["default_base"],
|
|
1180
|
+
}
|
|
1181
|
+
)
|
|
1182
|
+
write_json(paths["repo_manifest"], repo_manifest)
|
|
1183
|
+
|
|
1184
|
+
print(
|
|
1185
|
+
json.dumps(
|
|
1186
|
+
{
|
|
1187
|
+
"repo_root": str(repo_root),
|
|
1188
|
+
"repo_key": repo_key,
|
|
1189
|
+
"branch": branch_name,
|
|
1190
|
+
"storage_root": str(storage_root),
|
|
1191
|
+
"repo_dir": str(repo_dir),
|
|
1192
|
+
"branch_dir": str(branch_dir),
|
|
1193
|
+
"mode": "sync-working-tree",
|
|
1194
|
+
"focus_areas": manifest["focus_areas"],
|
|
1195
|
+
"files_considered": len(all_paths),
|
|
1196
|
+
},
|
|
1197
|
+
ensure_ascii=False,
|
|
1198
|
+
indent=2,
|
|
1199
|
+
)
|
|
1200
|
+
)
|
|
1201
|
+
return 0
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def command_record_head(args):
|
|
1205
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
|
|
1206
|
+
args.repo, args.context_dir, args.branch
|
|
1207
|
+
)
|
|
1208
|
+
manifest = read_json(paths["manifest"])
|
|
1209
|
+
head = args.commit or (get_head_commit(repo_root) if repo_root else None)
|
|
1210
|
+
manifest.update(
|
|
1211
|
+
{
|
|
1212
|
+
"repo_root": str(repo_root),
|
|
1213
|
+
"repo_key": repo_key,
|
|
1214
|
+
"branch": branch_name,
|
|
1215
|
+
"branch_key": branch_key,
|
|
1216
|
+
"storage_root": str(storage_root),
|
|
1217
|
+
"updated_at": now_iso(),
|
|
1218
|
+
"last_seen_head": head,
|
|
1219
|
+
}
|
|
1220
|
+
)
|
|
1221
|
+
write_json(paths["manifest"], manifest)
|
|
1222
|
+
|
|
1223
|
+
repo_manifest = read_json(paths["repo_manifest"])
|
|
1224
|
+
repo_manifest.update(
|
|
1225
|
+
{
|
|
1226
|
+
"repo_root": str(repo_root),
|
|
1227
|
+
"repo_key": repo_key,
|
|
1228
|
+
"storage_root": str(storage_root),
|
|
1229
|
+
"updated_at": manifest["updated_at"],
|
|
1230
|
+
"last_seen_branch": branch_name,
|
|
1231
|
+
"last_seen_head": head,
|
|
1232
|
+
}
|
|
1233
|
+
)
|
|
1234
|
+
write_json(paths["repo_manifest"], repo_manifest)
|
|
1235
|
+
|
|
1236
|
+
print(
|
|
1237
|
+
json.dumps(
|
|
1238
|
+
{
|
|
1239
|
+
"repo_root": str(repo_root),
|
|
1240
|
+
"repo_key": repo_key,
|
|
1241
|
+
"branch": branch_name,
|
|
1242
|
+
"storage_root": str(storage_root),
|
|
1243
|
+
"repo_dir": str(repo_dir),
|
|
1244
|
+
"branch_dir": str(branch_dir),
|
|
1245
|
+
"mode": "record-head",
|
|
1246
|
+
"last_seen_head": head,
|
|
1247
|
+
},
|
|
1248
|
+
ensure_ascii=False,
|
|
1249
|
+
indent=2,
|
|
1250
|
+
)
|
|
1251
|
+
)
|
|
1252
|
+
return 0
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _add_common_args(parser):
|
|
1256
|
+
parser.add_argument("--repo", default=".", help="Path inside the target Git repository")
|
|
1257
|
+
parser.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
|
|
1258
|
+
parser.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def main():
|
|
1262
|
+
parser = argparse.ArgumentParser(
|
|
1263
|
+
description="Unified write entrypoint for dev-memory repo+branch memory (merges old sync + update).",
|
|
1264
|
+
)
|
|
1265
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1266
|
+
|
|
1267
|
+
show = subparsers.add_parser("show", help="Show current paths + missing docs")
|
|
1268
|
+
_add_common_args(show)
|
|
1269
|
+
|
|
1270
|
+
suggest = subparsers.add_parser("suggest-kind", help="Dry-run classify a content string")
|
|
1271
|
+
suggest.add_argument("--content", help="Inline content to classify")
|
|
1272
|
+
suggest.add_argument("--content-file", help="File containing content to classify")
|
|
1273
|
+
suggest.add_argument("--already-setup", action="store_true", help="Hint that setup is completed (shifts default from unsorted to progress)")
|
|
1274
|
+
suggest.add_argument("--branch-name", help="Branch name for cross-branch candidate check")
|
|
1275
|
+
|
|
1276
|
+
classify = subparsers.add_parser("classify", help="Classify content against the current branch context")
|
|
1277
|
+
_add_common_args(classify)
|
|
1278
|
+
classify.add_argument("--content", help="Inline content to classify")
|
|
1279
|
+
classify.add_argument("--content-file", help="File containing content to classify")
|
|
1280
|
+
|
|
1281
|
+
record = subparsers.add_parser("record", help="Write content into one or more sections")
|
|
1282
|
+
_add_common_args(record)
|
|
1283
|
+
record.add_argument("--kind", help=f"Explicit kind. One of: {', '.join(sorted(KIND_MAP.keys()))}")
|
|
1284
|
+
record.add_argument("--auto", action="store_true", help="Ignore --kind; run classifier on content")
|
|
1285
|
+
record.add_argument("--title", help="Override the default section title")
|
|
1286
|
+
record.add_argument("--content", help="Inline markdown content")
|
|
1287
|
+
record.add_argument("--content-file", help="File containing markdown content")
|
|
1288
|
+
record.add_argument("--summary", help="Session-derived summary")
|
|
1289
|
+
record.add_argument("--summary-file", help="File with a session-derived summary")
|
|
1290
|
+
record.add_argument("--user-input", help="Latest user input to store alongside summary")
|
|
1291
|
+
record.add_argument("--user-input-file", help="File containing the latest user input")
|
|
1292
|
+
record.add_argument("--summary-json", help="Inline JSON session payload (batch record)")
|
|
1293
|
+
record.add_argument(
|
|
1294
|
+
"--force",
|
|
1295
|
+
action="store_true",
|
|
1296
|
+
help="Skip dedup check and append unconditionally (use after reviewing matches[] in a prior blocked response)",
|
|
1297
|
+
)
|
|
1298
|
+
# Hidden debugging/experiment flag: override similarity_check's default
|
|
1299
|
+
# 0.7 threshold. Range (0.0, 1.0]; outside that command_record raises
|
|
1300
|
+
# → exit 1 + error JSON. Not in --help on purpose (argparse.SUPPRESS) —
|
|
1301
|
+
# production callers shouldn't need to touch this.
|
|
1302
|
+
record.add_argument(
|
|
1303
|
+
"--dedup-threshold",
|
|
1304
|
+
type=float,
|
|
1305
|
+
default=None,
|
|
1306
|
+
help=argparse.SUPPRESS,
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
rewrite = subparsers.add_parser(
|
|
1310
|
+
"rewrite-entry",
|
|
1311
|
+
help="Replace an existing entry by id with new content (use after dedup hint suggests update_existing)",
|
|
1312
|
+
)
|
|
1313
|
+
_add_common_args(rewrite)
|
|
1314
|
+
rewrite.add_argument("--id", required=True, help="Entry id in the form <file_key>::<section_idx>::<entry_idx>")
|
|
1315
|
+
rewrite.add_argument("--content", help="Inline markdown content to replace the entry with")
|
|
1316
|
+
rewrite.add_argument("--content-file", help="File containing replacement markdown content")
|
|
1317
|
+
|
|
1318
|
+
sync = subparsers.add_parser("sync-working-tree", help="Refresh progress.md auto-sync block from git facts")
|
|
1319
|
+
_add_common_args(sync)
|
|
1320
|
+
|
|
1321
|
+
head = subparsers.add_parser("record-head", help="Stamp last_seen_head onto branch+repo manifests")
|
|
1322
|
+
_add_common_args(head)
|
|
1323
|
+
head.add_argument("--commit", help="Explicit commit sha to record (defaults to HEAD)")
|
|
1324
|
+
|
|
1325
|
+
args = parser.parse_args()
|
|
1326
|
+
try:
|
|
1327
|
+
handlers = {
|
|
1328
|
+
"show": command_show,
|
|
1329
|
+
"suggest-kind": command_suggest_kind,
|
|
1330
|
+
"classify": command_classify,
|
|
1331
|
+
"record": command_record,
|
|
1332
|
+
"rewrite-entry": command_rewrite_entry,
|
|
1333
|
+
"sync-working-tree": command_sync_working_tree,
|
|
1334
|
+
"record-head": command_record_head,
|
|
1335
|
+
}
|
|
1336
|
+
return handlers[args.command](args)
|
|
1337
|
+
except Exception as exc:
|
|
1338
|
+
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
|
1339
|
+
return 1
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
if __name__ == "__main__":
|
|
1343
|
+
raise SystemExit(main())
|