dev-memory-cli 0.22.1 → 0.23.0
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/README.md +255 -291
- package/bin/dev-memory.js +125 -2
- package/hooks/README.md +181 -47
- package/hooks/codex-hooks.json +1 -1
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +152 -11
- package/lib/dev_memory_common.py +68 -19
- package/lib/dev_memory_graduate.py +1 -1
- package/lib/dev_memory_session_scan.py +1565 -0
- package/lib/dev_memory_setup.py +8 -3
- package/lib/dev_memory_summary.py +25 -16
- package/lib/maintenance/archive.md +84 -0
- package/lib/maintenance/tidy.md +103 -0
- package/lib/ui-app.html +128 -1
- package/lib/ui-server.js +67 -1
- package/package.json +2 -1
- package/scripts/hooks/_common.py +48 -12
- package/scripts/hooks/stop.py +6 -0
- package/suite-manifest.json +4 -4
|
@@ -16,9 +16,11 @@ Subcommands:
|
|
|
16
16
|
|
|
17
17
|
import argparse
|
|
18
18
|
import difflib
|
|
19
|
+
import fcntl
|
|
19
20
|
import json
|
|
20
21
|
import os
|
|
21
22
|
import sys
|
|
23
|
+
from contextlib import contextmanager
|
|
22
24
|
from pathlib import Path
|
|
23
25
|
|
|
24
26
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
@@ -27,6 +29,7 @@ from dev_memory_common import (
|
|
|
27
29
|
AUTO_END,
|
|
28
30
|
AUTO_START,
|
|
29
31
|
PLACEHOLDER_MARKERS,
|
|
32
|
+
atomic_write_text,
|
|
30
33
|
append_log_event,
|
|
31
34
|
append_to_section,
|
|
32
35
|
asset_paths,
|
|
@@ -34,9 +37,11 @@ from dev_memory_common import (
|
|
|
34
37
|
collect_git_facts,
|
|
35
38
|
ensure_branch_paths_exist,
|
|
36
39
|
get_head_commit,
|
|
40
|
+
get_branch_paths,
|
|
37
41
|
get_setup_completed,
|
|
38
42
|
is_cross_branch_candidate,
|
|
39
43
|
join_sections,
|
|
44
|
+
limit_markdown_entries,
|
|
40
45
|
list_missing_docs,
|
|
41
46
|
merged_focus_areas,
|
|
42
47
|
now_iso,
|
|
@@ -101,7 +106,7 @@ def _emit_capture_log(paths, *, action, kind_label, summary, touched, extra_deta
|
|
|
101
106
|
)
|
|
102
107
|
|
|
103
108
|
|
|
104
|
-
def _append_with_separator(path, title, body):
|
|
109
|
+
def _append_with_separator(path, title, body, *, enforce_limit=True, max_entries=None):
|
|
105
110
|
"""Like append_to_section but always inserts a blank line between the
|
|
106
111
|
existing section body and the new entry. Drops placeholder-only bodies
|
|
107
112
|
instead of padding them with a blank line.
|
|
@@ -112,6 +117,7 @@ def _append_with_separator(path, title, body):
|
|
|
112
117
|
matched = False
|
|
113
118
|
updated = []
|
|
114
119
|
body_stripped = body.strip()
|
|
120
|
+
pruned = 0
|
|
115
121
|
|
|
116
122
|
def _is_placeholder_only(text):
|
|
117
123
|
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
|
@@ -125,13 +131,21 @@ def _append_with_separator(path, title, body):
|
|
|
125
131
|
combined = body_stripped
|
|
126
132
|
else:
|
|
127
133
|
combined = existing_body.rstrip() + "\n\n" + body_stripped
|
|
134
|
+
if enforce_limit:
|
|
135
|
+
combined, pruned = limit_markdown_entries(combined, max_entries=max_entries)
|
|
128
136
|
updated.append((existing_title, combined))
|
|
129
137
|
matched = True
|
|
130
138
|
else:
|
|
131
139
|
updated.append((existing_title, existing_body))
|
|
132
140
|
if not matched:
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
bounded, pruned = (
|
|
142
|
+
limit_markdown_entries(body_stripped, max_entries=max_entries)
|
|
143
|
+
if enforce_limit
|
|
144
|
+
else (body_stripped, 0)
|
|
145
|
+
)
|
|
146
|
+
updated.append((title, bounded))
|
|
147
|
+
atomic_write_text(path, join_sections(prefix, updated))
|
|
148
|
+
return pruned
|
|
135
149
|
|
|
136
150
|
|
|
137
151
|
# ---------------------------------------------------------------------------
|
|
@@ -760,7 +774,7 @@ def _entry_mutation(paths, eid, *, new_text=None, delete=False):
|
|
|
760
774
|
|
|
761
775
|
new_sections = list(sections)
|
|
762
776
|
new_sections[section_idx] = (section_title, new_body)
|
|
763
|
-
target_path
|
|
777
|
+
atomic_write_text(target_path, join_sections(prefix, new_sections))
|
|
764
778
|
return {
|
|
765
779
|
"id": eid,
|
|
766
780
|
"file_key": file_key,
|
|
@@ -1090,7 +1104,7 @@ def _prune_repo_shared_section(path, section_title, *, max_entries, max_entry_ch
|
|
|
1090
1104
|
updated.append((existing_title, new_body))
|
|
1091
1105
|
if not changed:
|
|
1092
1106
|
return None
|
|
1093
|
-
path
|
|
1107
|
+
atomic_write_text(path, join_sections(prefix, updated))
|
|
1094
1108
|
return result
|
|
1095
1109
|
|
|
1096
1110
|
|
|
@@ -1125,6 +1139,43 @@ def prune_repo_shared_memory(paths, *, max_entries=None, max_entry_chars=None):
|
|
|
1125
1139
|
return touched
|
|
1126
1140
|
|
|
1127
1141
|
|
|
1142
|
+
def prune_bounded_memory(paths, *, max_entries=None):
|
|
1143
|
+
"""Apply the general semantic-entry cap after a multi-operation summary."""
|
|
1144
|
+
max_entries = _int_env("DEV_MEMORY_MAX_ENTRIES", 200) if max_entries is None else max_entries
|
|
1145
|
+
targets = {
|
|
1146
|
+
(spec["file"], spec["section"])
|
|
1147
|
+
for spec in KIND_MAP.values()
|
|
1148
|
+
if spec.get("default_mode") == "append"
|
|
1149
|
+
}
|
|
1150
|
+
touched = []
|
|
1151
|
+
for file_key, section_title in sorted(targets):
|
|
1152
|
+
path = paths.get(file_key)
|
|
1153
|
+
if not path or not path.exists():
|
|
1154
|
+
continue
|
|
1155
|
+
content = path.read_text(encoding="utf-8")
|
|
1156
|
+
prefix, sections = split_sections(content)
|
|
1157
|
+
updated = []
|
|
1158
|
+
changed = False
|
|
1159
|
+
for title, body in sections:
|
|
1160
|
+
if title.strip() != section_title.strip():
|
|
1161
|
+
updated.append((title, body))
|
|
1162
|
+
continue
|
|
1163
|
+
bounded, pruned = limit_markdown_entries(body, max_entries=max_entries)
|
|
1164
|
+
updated.append((title, bounded))
|
|
1165
|
+
if pruned:
|
|
1166
|
+
changed = True
|
|
1167
|
+
touched.append({
|
|
1168
|
+
"file": _label(file_key),
|
|
1169
|
+
"section": title,
|
|
1170
|
+
"mode": "prune",
|
|
1171
|
+
"pruned": pruned,
|
|
1172
|
+
"max_entries": max_entries,
|
|
1173
|
+
})
|
|
1174
|
+
if changed:
|
|
1175
|
+
atomic_write_text(path, join_sections(prefix, updated))
|
|
1176
|
+
return touched
|
|
1177
|
+
|
|
1178
|
+
|
|
1128
1179
|
# ---------------------------------------------------------------------------
|
|
1129
1180
|
# Write primitives
|
|
1130
1181
|
# ---------------------------------------------------------------------------
|
|
@@ -1140,7 +1191,7 @@ def _resolve_target(paths, kind, title_override=None):
|
|
|
1140
1191
|
return spec["file"], target_path, section_title
|
|
1141
1192
|
|
|
1142
1193
|
|
|
1143
|
-
def _write_one(paths, kind, body, title_override=None, *, mode_override=None):
|
|
1194
|
+
def _write_one(paths, kind, body, title_override=None, *, mode_override=None, enforce_limit=True):
|
|
1144
1195
|
"""Write a body into the kind's target file. Picks append vs upsert from
|
|
1145
1196
|
KIND_MAP[kind].default_mode unless overridden. progress.md uses a
|
|
1146
1197
|
dedicated upsert that preserves the auto-sync marker.
|
|
@@ -1159,13 +1210,28 @@ def _write_one(paths, kind, body, title_override=None, *, mode_override=None):
|
|
|
1159
1210
|
body_to_write = body.strip()
|
|
1160
1211
|
if not body_to_write.startswith(("- ", "* ", "#")):
|
|
1161
1212
|
body_to_write = "- " + body_to_write
|
|
1162
|
-
|
|
1213
|
+
entry_limit = (
|
|
1214
|
+
_REPO_SHARED_MAX_ENTRIES
|
|
1215
|
+
if file_key in {"repo_decisions", "repo_glossary"}
|
|
1216
|
+
else _int_env("DEV_MEMORY_MAX_ENTRIES", 200)
|
|
1217
|
+
)
|
|
1218
|
+
pruned = _append_with_separator(
|
|
1219
|
+
target_path,
|
|
1220
|
+
section_title,
|
|
1221
|
+
body_to_write,
|
|
1222
|
+
enforce_limit=enforce_limit,
|
|
1223
|
+
max_entries=entry_limit,
|
|
1224
|
+
)
|
|
1163
1225
|
else:
|
|
1164
1226
|
if file_key == "progress":
|
|
1165
1227
|
upsert_progress_section(target_path, section_title, body)
|
|
1166
1228
|
else:
|
|
1167
1229
|
upsert_markdown_section(target_path, section_title, body)
|
|
1168
|
-
|
|
1230
|
+
result = {"file": _label(file_key), "section": section_title, "mode": mode}
|
|
1231
|
+
if mode == "append" and pruned:
|
|
1232
|
+
result["pruned"] = pruned
|
|
1233
|
+
result["max_entries"] = entry_limit
|
|
1234
|
+
return result
|
|
1169
1235
|
|
|
1170
1236
|
|
|
1171
1237
|
def _label(file_key):
|
|
@@ -1216,6 +1282,8 @@ def _decision_content(item):
|
|
|
1216
1282
|
if isinstance(item, str):
|
|
1217
1283
|
return item.strip()
|
|
1218
1284
|
if isinstance(item, dict):
|
|
1285
|
+
if not _decision_summary(item):
|
|
1286
|
+
return ""
|
|
1219
1287
|
return decision_body(item)
|
|
1220
1288
|
return ""
|
|
1221
1289
|
|
|
@@ -1819,7 +1887,13 @@ def command_apply_summary_output(args):
|
|
|
1819
1887
|
"dedup_hint": hint,
|
|
1820
1888
|
})
|
|
1821
1889
|
return
|
|
1822
|
-
rec = _write_one(
|
|
1890
|
+
rec = _write_one(
|
|
1891
|
+
paths,
|
|
1892
|
+
kind,
|
|
1893
|
+
body,
|
|
1894
|
+
mode_override=mode_override,
|
|
1895
|
+
enforce_limit=False,
|
|
1896
|
+
)
|
|
1823
1897
|
touched.append(rec)
|
|
1824
1898
|
_maybe_worktree_writeback(
|
|
1825
1899
|
worktree_writeback,
|
|
@@ -1894,6 +1968,51 @@ def command_apply_summary_output(args):
|
|
|
1894
1968
|
"reason": item.get("reason"),
|
|
1895
1969
|
})
|
|
1896
1970
|
|
|
1971
|
+
# Repair schema placeholder prose left by malformed historical summary
|
|
1972
|
+
# output. Valid entries are top-level bullets; these exact unbulleted
|
|
1973
|
+
# words are generator scaffolding and should never survive in memory.
|
|
1974
|
+
placeholder_words = {
|
|
1975
|
+
"decision", "summary", "reason", "impact", "risk", "mitigation",
|
|
1976
|
+
"term", "definition", "name", "url", "note", "label", "path", "content",
|
|
1977
|
+
}
|
|
1978
|
+
for file_key in ("decisions", "risks", "glossary", "repo_glossary"):
|
|
1979
|
+
path = paths[file_key]
|
|
1980
|
+
if not path.exists():
|
|
1981
|
+
continue
|
|
1982
|
+
content = path.read_text(encoding="utf-8")
|
|
1983
|
+
prefix, sections = split_sections(content)
|
|
1984
|
+
updated = []
|
|
1985
|
+
removed = 0
|
|
1986
|
+
for section_title, section_body in sections:
|
|
1987
|
+
kept_lines = []
|
|
1988
|
+
section_removed = 0
|
|
1989
|
+
for line in section_body.splitlines():
|
|
1990
|
+
stripped = line.strip().strip("::").lower()
|
|
1991
|
+
is_bullet = line.lstrip().startswith(("- ", "* ", "+ "))
|
|
1992
|
+
if stripped in placeholder_words and not is_bullet:
|
|
1993
|
+
section_removed += 1
|
|
1994
|
+
continue
|
|
1995
|
+
kept_lines.append(line)
|
|
1996
|
+
if section_removed:
|
|
1997
|
+
rec = {
|
|
1998
|
+
"file": _label(file_key),
|
|
1999
|
+
"section": section_title,
|
|
2000
|
+
"mode": "remove-placeholder-orphans",
|
|
2001
|
+
"removed": section_removed,
|
|
2002
|
+
}
|
|
2003
|
+
touched.append(rec)
|
|
2004
|
+
actions.append({"op": "remove-placeholder-orphans", **rec})
|
|
2005
|
+
removed += section_removed
|
|
2006
|
+
updated.append((section_title, "\n".join(kept_lines).strip()))
|
|
2007
|
+
if removed:
|
|
2008
|
+
atomic_write_text(path, join_sections(prefix, updated))
|
|
2009
|
+
|
|
2010
|
+
bounded = prune_bounded_memory(paths)
|
|
2011
|
+
if bounded:
|
|
2012
|
+
touched.extend(bounded)
|
|
2013
|
+
for rec in bounded:
|
|
2014
|
+
actions.append({"op": "prune-memory", **rec})
|
|
2015
|
+
|
|
1897
2016
|
pruned = prune_repo_shared_memory(paths)
|
|
1898
2017
|
if pruned:
|
|
1899
2018
|
touched.extend(pruned)
|
|
@@ -2103,6 +2222,20 @@ def _add_common_args(parser):
|
|
|
2103
2222
|
parser.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
|
|
2104
2223
|
|
|
2105
2224
|
|
|
2225
|
+
@contextmanager
|
|
2226
|
+
def _repo_command_lock(args):
|
|
2227
|
+
"""Serialize capture commands per repo so read-modify-write stays coherent."""
|
|
2228
|
+
_, _, _, _, _, repo_dir, _ = get_branch_paths(args.repo, args.context_dir, args.branch)
|
|
2229
|
+
lock_path = repo_dir / "jobs" / "capture.lock"
|
|
2230
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2231
|
+
with lock_path.open("a+", encoding="utf-8") as lock_stream:
|
|
2232
|
+
fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
|
|
2233
|
+
try:
|
|
2234
|
+
yield
|
|
2235
|
+
finally:
|
|
2236
|
+
fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
|
|
2237
|
+
|
|
2238
|
+
|
|
2106
2239
|
def main():
|
|
2107
2240
|
parser = argparse.ArgumentParser(
|
|
2108
2241
|
description="Unified write entrypoint for dev-memory repo+branch memory (merges old sync + update).",
|
|
@@ -2115,7 +2248,11 @@ def main():
|
|
|
2115
2248
|
suggest = subparsers.add_parser("suggest-kind", help="Dry-run classify a content string")
|
|
2116
2249
|
suggest.add_argument("--content", help="Inline content to classify")
|
|
2117
2250
|
suggest.add_argument("--content-file", help="File containing content to classify")
|
|
2118
|
-
suggest.add_argument(
|
|
2251
|
+
suggest.add_argument(
|
|
2252
|
+
"--already-setup",
|
|
2253
|
+
action="store_true",
|
|
2254
|
+
help="Accepted for compatibility; ambiguous content remains unsorted",
|
|
2255
|
+
)
|
|
2119
2256
|
suggest.add_argument("--branch-name", help="Branch name for cross-branch candidate check")
|
|
2120
2257
|
|
|
2121
2258
|
classify = subparsers.add_parser("classify", help="Classify content against the current branch context")
|
|
@@ -2219,7 +2356,11 @@ def main():
|
|
|
2219
2356
|
"sync-working-tree": command_sync_working_tree,
|
|
2220
2357
|
"record-head": command_record_head,
|
|
2221
2358
|
}
|
|
2222
|
-
|
|
2359
|
+
handler = handlers[args.command]
|
|
2360
|
+
if args.command == "suggest-kind":
|
|
2361
|
+
return handler(args)
|
|
2362
|
+
with _repo_command_lock(args):
|
|
2363
|
+
return handler(args)
|
|
2223
2364
|
except Exception as exc:
|
|
2224
2365
|
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
|
2225
2366
|
return 1
|
package/lib/dev_memory_common.py
CHANGED
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import re
|
|
7
7
|
import shutil
|
|
8
8
|
import subprocess
|
|
9
|
+
import tempfile
|
|
9
10
|
import time
|
|
10
11
|
import uuid
|
|
11
12
|
from collections import Counter
|
|
@@ -511,11 +512,29 @@ def asset_paths(repo_dir, branch_dir):
|
|
|
511
512
|
|
|
512
513
|
def ensure_file(path, content):
|
|
513
514
|
if not path.exists():
|
|
514
|
-
path
|
|
515
|
+
atomic_write_text(path, content)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def atomic_write_text(path, content):
|
|
519
|
+
"""Atomically replace a UTF-8 text file without exposing partial bytes."""
|
|
520
|
+
path = Path(path)
|
|
521
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
522
|
+
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o644
|
|
523
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
524
|
+
tmp_path = Path(tmp_name)
|
|
525
|
+
try:
|
|
526
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
527
|
+
stream.write(content)
|
|
528
|
+
stream.flush()
|
|
529
|
+
os.fsync(stream.fileno())
|
|
530
|
+
os.chmod(tmp_path, mode)
|
|
531
|
+
os.replace(tmp_path, path)
|
|
532
|
+
finally:
|
|
533
|
+
tmp_path.unlink(missing_ok=True)
|
|
515
534
|
|
|
516
535
|
|
|
517
536
|
def write_json(path, payload):
|
|
518
|
-
path
|
|
537
|
+
atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
519
538
|
|
|
520
539
|
|
|
521
540
|
def read_json(path):
|
|
@@ -1234,8 +1253,7 @@ def ensure_branch_paths_exist(repo, context_dir=None, branch=None):
|
|
|
1234
1253
|
# Heuristic classifier (capture routing)
|
|
1235
1254
|
# ---------------------------------------------------------------------------
|
|
1236
1255
|
|
|
1237
|
-
# Order matters: the first pattern that matches wins.
|
|
1238
|
-
# ahead of progress since their signals are more specific.
|
|
1256
|
+
# Order matters: the first pattern that matches wins.
|
|
1239
1257
|
_CLASSIFY_PATTERNS = [
|
|
1240
1258
|
("decision", re.compile(r"结论[::]|决[定议][::]|不再|改为|采用|废弃|选择.+?不选|abandoned|adopt")),
|
|
1241
1259
|
("risk", re.compile(r"阻塞|注意|坑|失败|风险|卡住|gotcha|caveat|warning")),
|
|
@@ -1244,19 +1262,18 @@ _CLASSIFY_PATTERNS = [
|
|
|
1244
1262
|
|
|
1245
1263
|
|
|
1246
1264
|
def classify_content(text, *, already_setup=False):
|
|
1247
|
-
"""Classify free-form content into
|
|
1248
|
-
glossary/unsorted. Used by capture when the caller doesn't pass --kind.
|
|
1265
|
+
"""Classify free-form content into decision/risk/glossary/unsorted.
|
|
1249
1266
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1267
|
+
``already_setup`` is retained for callers that still pass the setup state,
|
|
1268
|
+
but ambiguous content always stays in ``unsorted``. Ephemeral progress is
|
|
1269
|
+
intentionally derived from Git instead of being a semantic capture kind.
|
|
1253
1270
|
"""
|
|
1254
1271
|
if not text or not text.strip():
|
|
1255
1272
|
return "unsorted"
|
|
1256
1273
|
for label, pattern in _CLASSIFY_PATTERNS:
|
|
1257
1274
|
if pattern.search(text):
|
|
1258
1275
|
return label
|
|
1259
|
-
return "
|
|
1276
|
+
return "unsorted"
|
|
1260
1277
|
|
|
1261
1278
|
|
|
1262
1279
|
# Branch-name tokens that appear in generic technical content too often to
|
|
@@ -1707,7 +1724,7 @@ def ensure_progress_auto_block(path):
|
|
|
1707
1724
|
else:
|
|
1708
1725
|
updated = content.rstrip() + auto_section
|
|
1709
1726
|
|
|
1710
|
-
path
|
|
1727
|
+
atomic_write_text(path, updated + ("" if updated.endswith("\n") else "\n"))
|
|
1711
1728
|
return path.read_text(encoding="utf-8")
|
|
1712
1729
|
|
|
1713
1730
|
|
|
@@ -1764,7 +1781,7 @@ def upsert_markdown_section(path, title, body):
|
|
|
1764
1781
|
updated.append((existing_title, existing_body))
|
|
1765
1782
|
if not replaced:
|
|
1766
1783
|
updated.append((title, body))
|
|
1767
|
-
path
|
|
1784
|
+
atomic_write_text(path, join_sections(prefix, updated))
|
|
1768
1785
|
|
|
1769
1786
|
|
|
1770
1787
|
def _section_is_placeholder_only(text):
|
|
@@ -1774,7 +1791,37 @@ def _section_is_placeholder_only(text):
|
|
|
1774
1791
|
return all(any(marker in line for marker in PLACEHOLDER_MARKERS) for line in lines)
|
|
1775
1792
|
|
|
1776
1793
|
|
|
1777
|
-
def
|
|
1794
|
+
def memory_max_entries():
|
|
1795
|
+
value = os.environ.get("DEV_MEMORY_MAX_ENTRIES", "200").strip()
|
|
1796
|
+
try:
|
|
1797
|
+
return max(1, int(value))
|
|
1798
|
+
except ValueError:
|
|
1799
|
+
return 200
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def limit_markdown_entries(body, max_entries=None):
|
|
1803
|
+
"""Bound an accumulated markdown section while retaining newest entries.
|
|
1804
|
+
|
|
1805
|
+
Accumulation sections are stored oldest-to-newest so entry ids remain
|
|
1806
|
+
stable. Only top-level bullet blocks count as entries; continuation lines
|
|
1807
|
+
stay attached to their bullet.
|
|
1808
|
+
"""
|
|
1809
|
+
limit = memory_max_entries() if max_entries is None else max(1, int(max_entries))
|
|
1810
|
+
lines = (body or "").strip().splitlines()
|
|
1811
|
+
starts = [idx for idx, line in enumerate(lines) if re.match(r"^[-*]\s+", line)]
|
|
1812
|
+
if len(starts) <= limit:
|
|
1813
|
+
return (body or "").strip(), 0
|
|
1814
|
+
blocks = []
|
|
1815
|
+
for pos, start in enumerate(starts):
|
|
1816
|
+
end = starts[pos + 1] if pos + 1 < len(starts) else len(lines)
|
|
1817
|
+
blocks.append("\n".join(lines[start:end]).strip())
|
|
1818
|
+
preamble = "\n".join(lines[:starts[0]]).strip()
|
|
1819
|
+
kept = blocks[-limit:]
|
|
1820
|
+
bounded = "\n\n".join(([preamble] if preamble else []) + kept).strip()
|
|
1821
|
+
return bounded, len(blocks) - len(kept)
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
def append_to_section(path, title, body, *, max_entries=None):
|
|
1778
1825
|
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
1779
1826
|
prefix, sections = split_sections(content)
|
|
1780
1827
|
target = title.strip()
|
|
@@ -1786,13 +1833,15 @@ def append_to_section(path, title, body):
|
|
|
1786
1833
|
combined = body.strip()
|
|
1787
1834
|
else:
|
|
1788
1835
|
combined = (existing_body.rstrip() + "\n" + body.strip()).strip()
|
|
1836
|
+
combined, _pruned = limit_markdown_entries(combined, max_entries=max_entries)
|
|
1789
1837
|
updated.append((existing_title, combined))
|
|
1790
1838
|
matched = True
|
|
1791
1839
|
else:
|
|
1792
1840
|
updated.append((existing_title, existing_body))
|
|
1793
1841
|
if not matched:
|
|
1794
|
-
|
|
1795
|
-
|
|
1842
|
+
bounded, _pruned = limit_markdown_entries(body.strip(), max_entries=max_entries)
|
|
1843
|
+
updated.append((title, bounded))
|
|
1844
|
+
atomic_write_text(path, join_sections(prefix, updated))
|
|
1796
1845
|
|
|
1797
1846
|
|
|
1798
1847
|
def upsert_progress_section(path, title, body):
|
|
@@ -1819,7 +1868,7 @@ def upsert_progress_section(path, title, body):
|
|
|
1819
1868
|
if not replaced:
|
|
1820
1869
|
updated.append((title, body))
|
|
1821
1870
|
rewritten = join_sections(prefix, updated).rstrip() + "\n\n" + marker + after
|
|
1822
|
-
path
|
|
1871
|
+
atomic_write_text(path, rewritten)
|
|
1823
1872
|
|
|
1824
1873
|
|
|
1825
1874
|
def sync_progress(paths, facts):
|
|
@@ -1834,7 +1883,7 @@ def sync_progress(paths, facts):
|
|
|
1834
1883
|
)
|
|
1835
1884
|
current = ensure_progress_auto_block(paths["progress"])
|
|
1836
1885
|
updated = replace_auto_block(current, build_auto_block(facts))
|
|
1837
|
-
paths["progress"]
|
|
1886
|
+
atomic_write_text(paths["progress"], updated)
|
|
1838
1887
|
|
|
1839
1888
|
|
|
1840
1889
|
# ---------------------------------------------------------------------------
|
|
@@ -1888,7 +1937,7 @@ def append_log_event(log_path, action, *, kind=None, summary=None, details=None)
|
|
|
1888
1937
|
return
|
|
1889
1938
|
if not log_path.exists():
|
|
1890
1939
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1891
|
-
log_path
|
|
1940
|
+
atomic_write_text(log_path, template_log("unknown"))
|
|
1892
1941
|
|
|
1893
1942
|
header_parts = [f"## [{now_iso()}] {action}"]
|
|
1894
1943
|
if kind:
|
|
@@ -1908,7 +1957,7 @@ def append_log_event(log_path, action, *, kind=None, summary=None, details=None)
|
|
|
1908
1957
|
existing = log_path.read_text(encoding="utf-8")
|
|
1909
1958
|
if not existing.endswith("\n"):
|
|
1910
1959
|
existing += "\n"
|
|
1911
|
-
log_path
|
|
1960
|
+
atomic_write_text(log_path, existing + "\n" + block)
|
|
1912
1961
|
|
|
1913
1962
|
|
|
1914
1963
|
# ---------------------------------------------------------------------------
|
|
@@ -123,7 +123,7 @@ def _apply_entries(target_path, entries):
|
|
|
123
123
|
if not section:
|
|
124
124
|
raise ValueError(f"harvest entry missing 'section': {entry}")
|
|
125
125
|
if mode == "append":
|
|
126
|
-
append_to_section(target_path, section, body)
|
|
126
|
+
append_to_section(target_path, section, body, max_entries=20)
|
|
127
127
|
elif mode == "replace":
|
|
128
128
|
upsert_markdown_section(target_path, section, body)
|
|
129
129
|
else:
|