dev-memory-cli 0.18.2 → 0.19.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.
@@ -17,6 +17,7 @@ Subcommands:
17
17
  import argparse
18
18
  import difflib
19
19
  import json
20
+ import os
20
21
  import sys
21
22
  from pathlib import Path
22
23
 
@@ -41,6 +42,8 @@ from dev_memory_common import (
41
42
  now_iso,
42
43
  read_json,
43
44
  render_bullets,
45
+ run_git,
46
+ sanitize_branch_name,
44
47
  split_sections,
45
48
  sync_progress,
46
49
  upsert_markdown_section,
@@ -534,6 +537,134 @@ def _replace_entry_at_index(body, target_idx, new_text):
534
537
  return "\n".join(out_lines).strip(), previous_first_line, found
535
538
 
536
539
 
540
+ def _delete_entry_at_index(body, target_idx):
541
+ lines = body.splitlines()
542
+ out_lines = []
543
+ current_block_lines = None
544
+ current_idx = -1
545
+ counter = -1
546
+ found = False
547
+ previous_first_line = None
548
+
549
+ def capture_previous():
550
+ nonlocal previous_first_line
551
+ for ln in current_block_lines or []:
552
+ s = ln.strip()
553
+ if s.startswith("- "):
554
+ previous_first_line = s[2:].strip()
555
+ break
556
+ if s:
557
+ previous_first_line = s
558
+ break
559
+
560
+ def flush():
561
+ nonlocal current_block_lines, current_idx, found
562
+ if current_block_lines is None:
563
+ return
564
+ if current_idx == target_idx and not found:
565
+ capture_previous()
566
+ found = True
567
+ else:
568
+ out_lines.extend(current_block_lines)
569
+ current_block_lines = None
570
+ current_idx = -1
571
+
572
+ for raw in lines:
573
+ stripped = raw.strip()
574
+ if stripped.startswith("- ") and not (raw.startswith(" ") or raw.startswith("\t")):
575
+ flush()
576
+ counter += 1
577
+ current_idx = counter
578
+ current_block_lines = [raw]
579
+ elif current_block_lines is not None and stripped and (raw.startswith(" ") or raw.startswith("\t")):
580
+ current_block_lines.append(raw)
581
+ elif not stripped:
582
+ flush()
583
+ out_lines.append(raw)
584
+ else:
585
+ flush()
586
+ out_lines.append(raw)
587
+ flush()
588
+
589
+ while out_lines and not out_lines[-1].strip():
590
+ out_lines.pop()
591
+
592
+ return "\n".join(out_lines).strip(), previous_first_line, found
593
+
594
+
595
+ def _entry_mutation(paths, eid, *, new_text=None, delete=False):
596
+ parsed = _parse_entry_id_local((eid or "").strip())
597
+ if not parsed:
598
+ raise RuntimeError(f"malformed entry id: {eid!r}; expected <file_key>::<section_idx>::<entry_idx>")
599
+ file_key, section_idx, entry_idx = parsed
600
+ if file_key not in paths:
601
+ raise RuntimeError(f"unknown file_key {file_key!r}; available: {', '.join(sorted(paths.keys()))}")
602
+
603
+ if not delete and not (new_text or "").strip():
604
+ raise RuntimeError("one of --content / --content-file is required")
605
+
606
+ target_path = paths[file_key]
607
+ if not target_path.exists():
608
+ raise RuntimeError(f"file does not exist: {target_path}")
609
+
610
+ content = target_path.read_text(encoding="utf-8")
611
+ prefix, sections = split_sections(content)
612
+ if section_idx < 0 or section_idx >= len(sections):
613
+ raise RuntimeError(f"section_idx {section_idx} out of range (file has {len(sections)} sections)")
614
+
615
+ section_title, section_body = sections[section_idx]
616
+ mutator = _delete_entry_at_index if delete else (
617
+ lambda body, idx: _replace_entry_at_index(body, idx, new_text)
618
+ )
619
+
620
+ if AUTO_START in section_body and AUTO_END in section_body:
621
+ head_end = section_body.index(AUTO_START)
622
+ head = section_body[:head_end].rstrip()
623
+ tail = section_body[head_end:]
624
+ new_head, previous_first_line, found = mutator(head, entry_idx)
625
+ new_body = (new_head + "\n\n" + tail).strip() if new_head else tail.strip()
626
+ else:
627
+ new_body, previous_first_line, found = mutator(section_body, entry_idx)
628
+
629
+ if not found:
630
+ raise RuntimeError(f"entry_idx {entry_idx} not found in section {section_idx!r}")
631
+
632
+ new_sections = list(sections)
633
+ new_sections[section_idx] = (section_title, new_body)
634
+ target_path.write_text(join_sections(prefix, new_sections), encoding="utf-8")
635
+ return {
636
+ "id": eid,
637
+ "file_key": file_key,
638
+ "file": _label(file_key),
639
+ "section": section_title,
640
+ "previous_first_line": previous_first_line,
641
+ }
642
+
643
+
644
+ def _validate_entry_reference(paths, eid, *, require_content=None):
645
+ parsed = _parse_entry_id_local((eid or "").strip())
646
+ if not parsed:
647
+ raise RuntimeError(f"malformed entry id: {eid!r}; expected <file_key>::<section_idx>::<entry_idx>")
648
+ file_key, section_idx, entry_idx = parsed
649
+ if file_key not in paths:
650
+ raise RuntimeError(f"unknown file_key {file_key!r}; available: {', '.join(sorted(paths.keys()))}")
651
+ if require_content is not None and not (require_content or "").strip():
652
+ raise RuntimeError(f"rewrite {eid!r} requires non-empty content")
653
+ target_path = paths[file_key]
654
+ if not target_path.exists():
655
+ raise RuntimeError(f"file does not exist: {target_path}")
656
+ content = target_path.read_text(encoding="utf-8")
657
+ _prefix, sections = split_sections(content)
658
+ if section_idx < 0 or section_idx >= len(sections):
659
+ raise RuntimeError(f"section_idx {section_idx} out of range (file has {len(sections)} sections)")
660
+ section_body = sections[section_idx][1]
661
+ if AUTO_START in section_body and AUTO_END in section_body:
662
+ section_body = section_body[:section_body.index(AUTO_START)].rstrip()
663
+ entries = {idx for idx, _text in _section_top_level_entries(section_body)}
664
+ if entry_idx not in entries:
665
+ raise RuntimeError(f"entry_idx {entry_idx} not found in section {section_idx!r}")
666
+
667
+
537
668
  # v2 KIND_MAP. `default_mode` governs whether a new entry accumulates
538
669
  # (append) or replaces the whole section (upsert). Accumulation fits
539
670
  # "decisions", "risks", "glossary" where each entry is independent and
@@ -546,11 +677,8 @@ KIND_MAP = {
546
677
  "glossary": {"file": "glossary", "section": "当前有效上下文", "default_mode": "append"},
547
678
  "source": {"file": "glossary", "section": "分支源资料入口", "default_mode": "append"},
548
679
  # 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
680
  "overview": {"file": "overview", "section": "当前目标", "default_mode": "upsert"},
552
681
  "scope": {"file": "overview", "section": "范围边界", "default_mode": "upsert"},
553
- "stage": {"file": "overview", "section": "当前阶段", "default_mode": "upsert"},
554
682
  "constraint": {"file": "overview", "section": "关键约束", "default_mode": "upsert"},
555
683
  # repo-shared: decisions/context/source accumulate, overview/constraint snapshot
556
684
  "shared-decision": {"file": "repo_decisions", "section": "跨分支通用决策", "default_mode": "append"},
@@ -558,19 +686,159 @@ KIND_MAP = {
558
686
  "shared-source": {"file": "repo_glossary", "section": "共享入口", "default_mode": "append"},
559
687
  "shared-overview": {"file": "repo_overview", "section": "长期目标与边界", "default_mode": "upsert"},
560
688
  "shared-constraint": {"file": "repo_overview", "section": "仓库级关键约束", "default_mode": "upsert"},
689
+ # semantic file map: agent outputs merged mapping each session
690
+ "filemap": {"file": "progress", "section": "功能文件索引", "default_mode": "upsert"},
561
691
  # fallback bins always accumulate
562
692
  "unsorted": {"file": "unsorted", "section": "待分类", "default_mode": "append"},
563
693
  "pending": {"file": "pending_promotion", "section": "候选条目", "default_mode": "append"},
564
694
  }
565
695
 
696
+
697
+ # Worktree write-back deliberately mirrors only append-style knowledge. Snapshot
698
+ # fields like progress/overview/next represent the current branch state and would
699
+ # corrupt the source branch if copied back blindly.
700
+ WORKTREE_WRITEBACK_KINDS = {"decision", "risk", "glossary", "source"}
701
+
702
+
703
+ def _truthy(value):
704
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
705
+
706
+
707
+ def _worktree_writeback_enabled(repo_root):
708
+ env_value = os.environ.get("DEV_MEMORY_WORKTREE_WRITEBACK")
709
+ if env_value is not None:
710
+ return _truthy(env_value)
711
+ cfg = run_git(
712
+ ["config", "--bool", "--get", "dev-memory.worktreeWriteback"],
713
+ cwd=repo_root,
714
+ check=False,
715
+ )
716
+ return cfg.returncode == 0 and _truthy(cfg.stdout)
717
+
718
+
719
+ def _worktree_inherit_source(manifest, branch_name):
720
+ provenance = manifest.get("provenance") or []
721
+ if not isinstance(provenance, list):
722
+ return None
723
+ for item in reversed(provenance):
724
+ if not isinstance(item, dict):
725
+ continue
726
+ if item.get("op") != "worktree-inherit":
727
+ continue
728
+ source = str(item.get("from") or "").strip()
729
+ if source and source != branch_name:
730
+ return source
731
+ return None
732
+
733
+
734
+ def _branch_head(repo_root, branch_name):
735
+ result = run_git(["rev-parse", branch_name], cwd=repo_root, check=False)
736
+ return result.stdout.strip() if result.returncode == 0 else None
737
+
738
+
739
+ def _worktree_writeback_context(repo_root, repo_dir, paths, branch_name):
740
+ if branch_name is None or not _worktree_writeback_enabled(repo_root):
741
+ return None
742
+ manifest = read_json(paths["manifest"]) or {}
743
+ source_name = _worktree_inherit_source(manifest, branch_name)
744
+ if not source_name:
745
+ return None
746
+ source_key = sanitize_branch_name(source_name)
747
+ source_dir = repo_dir / "branches" / source_key
748
+ if not source_dir.exists() or not source_dir.is_dir():
749
+ return {
750
+ "source": source_name,
751
+ "source_key": source_key,
752
+ "source_dir": str(source_dir),
753
+ "paths": None,
754
+ "attempted": False,
755
+ "touched": [],
756
+ "skipped": [{"reason": "source-missing", "source_dir": str(source_dir)}],
757
+ }
758
+ return {
759
+ "source": source_name,
760
+ "source_key": source_key,
761
+ "source_dir": str(source_dir),
762
+ "paths": asset_paths(repo_dir, source_dir),
763
+ "attempted": False,
764
+ "touched": [],
765
+ "skipped": [],
766
+ }
767
+
768
+
769
+ def _maybe_worktree_writeback(ctx, kind, body, *, title_override=None, mode_override=None,
770
+ force=False, dedup_threshold=None, summary=None):
771
+ if not ctx:
772
+ return None
773
+ if kind not in WORKTREE_WRITEBACK_KINDS:
774
+ return None
775
+ mode = mode_override or KIND_MAP.get(kind, {}).get("default_mode", "upsert")
776
+ if mode != "append":
777
+ return None
778
+ ctx["attempted"] = True
779
+ if not ctx.get("paths"):
780
+ return None
781
+ hint = _check_dedup_for_kind(
782
+ ctx["paths"],
783
+ kind,
784
+ body,
785
+ force=force,
786
+ title_override=title_override,
787
+ threshold=dedup_threshold,
788
+ )
789
+ if hint is not None:
790
+ ctx["skipped"].append({
791
+ "kind": kind,
792
+ "reason": "dedup-blocked",
793
+ "content_preview": _first_nonempty_line(body)[:_SIM_PREVIEW_LEN],
794
+ "dedup_hint": hint,
795
+ })
796
+ return None
797
+ rec = _write_one(ctx["paths"], kind, body, title_override=title_override, mode_override=mode)
798
+ rec["mode"] = "worktree-writeback-append"
799
+ rec["source_branch"] = ctx["source"]
800
+ ctx["touched"].append(rec)
801
+ _emit_capture_log(
802
+ ctx["paths"],
803
+ action="worktree-writeback",
804
+ kind_label=kind,
805
+ summary=summary or body,
806
+ touched=[rec],
807
+ )
808
+ return rec
809
+
810
+
811
+ def _finalize_worktree_writeback(ctx, repo_root, repo_key, storage_root):
812
+ if not ctx or not ctx.get("paths") or not ctx["touched"]:
813
+ return None
814
+ updated_at = now_iso()
815
+ manifest = read_json(ctx["paths"]["manifest"]) or {}
816
+ manifest.update({
817
+ "repo_key": repo_key,
818
+ "branch": ctx["source"],
819
+ "branch_key": ctx["source_key"],
820
+ "storage_root": str(storage_root),
821
+ "updated_at": updated_at,
822
+ "last_seen_head": _branch_head(repo_root, ctx["source"]),
823
+ "last_capture_kind": "worktree-writeback",
824
+ "last_capture_mode": "worktree-writeback",
825
+ "last_capture_update_mode": "worktree-writeback",
826
+ "last_capture_targets": ctx["touched"],
827
+ })
828
+ write_json(ctx["paths"]["manifest"], manifest)
829
+ return {
830
+ "source": ctx["source"],
831
+ "source_dir": ctx["source_dir"],
832
+ "touched": ctx["touched"],
833
+ "skipped": ctx["skipped"],
834
+ "updated_at": updated_at,
835
+ }
836
+
566
837
  # Session-payload → kind mapping, used by `record --summary-json`. Each entry
567
838
  # is (payload_key, kind, optional_transform). Several keys may route to the
568
839
  # same kind; that's fine — the write layer upserts.
569
840
  SESSION_PAYLOAD_MAP = [
570
841
  ("overview_summary", "overview", None),
571
- ("implementation_notes", "progress", None),
572
- ("changes", "progress", None),
573
- ("next_steps", "next", None),
574
842
  ("risks", "risk", None),
575
843
  ("memory", "glossary", None),
576
844
  ("context_updates", "glossary", None),
@@ -582,6 +850,14 @@ SESSION_PAYLOAD_MAP = [
582
850
  ("source_updates", "shared-source", None),
583
851
  ]
584
852
 
853
+ SESSION_DIRECT_PAYLOAD_MAP = []
854
+
855
+ SESSION_EXTRA_PAYLOAD_MAP = [
856
+ ("glossary", "glossary", None),
857
+ ("shared_context", "shared-context", None),
858
+ ("shared_sources", "shared-source", None),
859
+ ]
860
+
585
861
 
586
862
  def normalize_items(items):
587
863
  if items is None:
@@ -596,8 +872,19 @@ def bullets(items, empty_text="- 待补充", wrap_code=False):
596
872
  return render_bullets(normalize_items(items), empty_text=empty_text, wrap_code=wrap_code)
597
873
 
598
874
 
875
+ def _decision_summary(item):
876
+ if isinstance(item, str):
877
+ return item.strip()
878
+ if not isinstance(item, dict):
879
+ return ""
880
+ return str(item.get("decision") or item.get("summary") or "").strip()
881
+
882
+
599
883
  def decision_body(item):
600
- parts = [f"- 结论: {item['decision']}"]
884
+ summary = _decision_summary(item)
885
+ parts = [f"- 结论: {summary}"]
886
+ if not isinstance(item, dict):
887
+ return "\n".join(parts)
601
888
  if item.get("reason"):
602
889
  parts.append(f"- 原因: {item['reason']}")
603
890
  if item.get("impact"):
@@ -684,6 +971,22 @@ def _load_optional_text(value, file_path=None):
684
971
  return None
685
972
 
686
973
 
974
+ def _load_json_payload(value, file_path=None):
975
+ if value:
976
+ return json.loads(value)
977
+ if file_path:
978
+ return json.loads(Path(file_path).read_text(encoding="utf-8"))
979
+ raise RuntimeError("one of --json / --json-file is required")
980
+
981
+
982
+ def _decision_content(item):
983
+ if isinstance(item, str):
984
+ return item.strip()
985
+ if isinstance(item, dict):
986
+ return decision_body(item)
987
+ return ""
988
+
989
+
687
990
  def _load_free_content(args):
688
991
  """Load content for single-kind writes. Supports:
689
992
  --content / --content-file : inline body
@@ -794,6 +1097,7 @@ def command_record(args):
794
1097
  repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
795
1098
  args.repo, args.context_dir, args.branch
796
1099
  )
1100
+ worktree_writeback = _worktree_writeback_context(repo_root, repo_dir, paths, branch_name)
797
1101
  already_setup = get_setup_completed(paths["manifest"])
798
1102
  force = bool(getattr(args, "force", False))
799
1103
 
@@ -844,10 +1148,30 @@ def command_record(args):
844
1148
  "dedup_hint": hint,
845
1149
  })
846
1150
  return None
847
- return _write_one(paths, kind, body, title_override=title_override)
1151
+ rec = _write_one(paths, kind, body, title_override=title_override)
1152
+ _maybe_worktree_writeback(
1153
+ worktree_writeback,
1154
+ kind,
1155
+ body,
1156
+ title_override=title_override,
1157
+ force=force,
1158
+ dedup_threshold=dedup_threshold,
1159
+ summary=source_label,
1160
+ )
1161
+ return rec
1162
+
1163
+ # Worker-facing current-state fields. Keep these as plain text because
1164
+ # progress/next sections are snapshots, not bullet-list accumulators.
1165
+ for key, kind in SESSION_DIRECT_PAYLOAD_MAP:
1166
+ items = normalize_items(payload.get(key))
1167
+ if not items:
1168
+ continue
1169
+ rec = _write_or_block(kind, "\n".join(items), source_label=f"payload[{key}]")
1170
+ if rec is not None:
1171
+ touched.append(rec)
848
1172
 
849
1173
  # Simple scalar mappings.
850
- for key, kind, subsection_title in SESSION_PAYLOAD_MAP:
1174
+ for key, kind, subsection_title in SESSION_PAYLOAD_MAP + SESSION_EXTRA_PAYLOAD_MAP:
851
1175
  items = normalize_items(payload.get(key))
852
1176
  if not items:
853
1177
  continue
@@ -873,18 +1197,30 @@ def command_record(args):
873
1197
  if rec is not None:
874
1198
  touched.append(rec)
875
1199
 
876
- # Structured decisions (decision/reason/impact trios).
877
- decision_items = [decision_body(item) for item in (payload.get("decisions") or []) if item.get("decision")]
1200
+ # Structured decisions (decision|summary/reason/impact trios).
1201
+ decision_payload_items = [
1202
+ item for item in (payload.get("decisions") or []) if _decision_summary(item)
1203
+ ]
1204
+ decision_items = [decision_body(item) for item in decision_payload_items]
878
1205
  if decision_items:
879
1206
  body = "\n\n".join(decision_items)
880
1207
  rec = _write_or_block("decision", body, source_label="payload[decisions]")
881
1208
  if rec is not None:
882
1209
  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)
1210
+ for item in decision_payload_items:
1211
+ pending = _maybe_stage_pending(paths, _decision_summary(item), branch_name or "")
1212
+ if pending:
1213
+ touched.append(pending)
1214
+
1215
+ shared_decision_payload_items = [
1216
+ item for item in (payload.get("shared_decisions") or []) if _decision_summary(item)
1217
+ ]
1218
+ shared_decision_items = [decision_body(item) for item in shared_decision_payload_items]
1219
+ if shared_decision_items:
1220
+ body = "\n\n".join(shared_decision_items)
1221
+ rec = _write_or_block("shared-decision", body, source_label="payload[shared_decisions]")
1222
+ if rec is not None:
1223
+ touched.append(rec)
888
1224
 
889
1225
  extra_manifest = {"last_session_sync_title": title, "last_session_sync_mode": "capture-session"}
890
1226
 
@@ -926,7 +1262,17 @@ def command_record(args):
926
1262
  }, ensure_ascii=False, indent=2))
927
1263
  return 2
928
1264
 
929
- touched.append(_write_one(paths, kind, content, title_override=args.title))
1265
+ rec = _write_one(paths, kind, content, title_override=args.title)
1266
+ touched.append(rec)
1267
+ _maybe_worktree_writeback(
1268
+ worktree_writeback,
1269
+ kind,
1270
+ content,
1271
+ title_override=args.title,
1272
+ force=force,
1273
+ dedup_threshold=dedup_threshold,
1274
+ summary=content,
1275
+ )
930
1276
  rec = _maybe_stage_pending(paths, content, branch_name or "")
931
1277
  if rec:
932
1278
  touched.append(rec)
@@ -999,6 +1345,16 @@ def command_record(args):
999
1345
  "touched_targets": touched,
1000
1346
  "updated_at": manifest["updated_at"],
1001
1347
  }
1348
+ writeback_output = _finalize_worktree_writeback(worktree_writeback, repo_root, repo_key, storage_root)
1349
+ if writeback_output:
1350
+ output["worktree_writeback"] = writeback_output
1351
+ elif worktree_writeback and worktree_writeback.get("attempted") and worktree_writeback.get("skipped"):
1352
+ output["worktree_writeback"] = {
1353
+ "source": worktree_writeback.get("source"),
1354
+ "source_dir": worktree_writeback.get("source_dir"),
1355
+ "touched": [],
1356
+ "skipped": worktree_writeback["skipped"],
1357
+ }
1002
1358
  if dedup_blocked:
1003
1359
  # Batch mode: surface blocked items but still report what was written.
1004
1360
  output["dedup_blocked"] = dedup_blocked
@@ -1020,68 +1376,13 @@ def command_rewrite_entry(args):
1020
1376
  )
1021
1377
 
1022
1378
  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
1379
  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)
1380
+ try:
1381
+ mutation = _entry_mutation(paths, eid, new_text=new_text)
1382
+ except RuntimeError as exc:
1383
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
1079
1384
  return 1
1080
1385
 
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
1386
  # Manifest bookkeeping (same shape as record).
1086
1387
  manifest = read_json(paths["manifest"])
1087
1388
  updated_at = now_iso()
@@ -1098,8 +1399,8 @@ def command_rewrite_entry(args):
1098
1399
  "last_capture_mode": "rewrite-entry",
1099
1400
  "last_capture_update_mode": "rewrite-entry",
1100
1401
  "last_capture_targets": [{
1101
- "file": _label(file_key),
1102
- "section": section_title,
1402
+ "file": mutation["file"],
1403
+ "section": mutation["section"],
1103
1404
  "mode": "rewrite-entry",
1104
1405
  "id": eid,
1105
1406
  }],
@@ -1112,24 +1413,258 @@ def command_rewrite_entry(args):
1112
1413
  _emit_capture_log(
1113
1414
  paths,
1114
1415
  action="rewrite-entry",
1115
- kind_label=file_key,
1416
+ kind_label=mutation["file_key"],
1116
1417
  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)],
1418
+ touched=[{"file": mutation["file"], "section": mutation["section"], "mode": "rewrite-entry"}],
1419
+ extra_details=[("id", eid), ("previous", mutation["previous_first_line"])],
1119
1420
  )
1120
1421
 
1121
1422
  print(json.dumps({
1122
1423
  "mode": "rewrite-entry",
1123
1424
  "id": eid,
1124
- "file": _label(file_key),
1125
- "section": section_title,
1126
- "previous_first_line": previous_first_line,
1425
+ "file": mutation["file"],
1426
+ "section": mutation["section"],
1427
+ "previous_first_line": mutation["previous_first_line"],
1127
1428
  "new_first_line": new_first_line,
1128
1429
  "updated_at": updated_at,
1129
1430
  }, ensure_ascii=False, indent=2))
1130
1431
  return 0
1131
1432
 
1132
1433
 
1434
+ def command_delete_entry(args):
1435
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1436
+ args.repo, args.context_dir, args.branch
1437
+ )
1438
+ eid = (args.id or "").strip()
1439
+ try:
1440
+ mutation = _entry_mutation(paths, eid, delete=True)
1441
+ except RuntimeError as exc:
1442
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
1443
+ return 1
1444
+
1445
+ manifest = read_json(paths["manifest"])
1446
+ updated_at = now_iso()
1447
+ manifest.update({
1448
+ "repo_root": str(repo_root),
1449
+ "repo_key": repo_key,
1450
+ "branch": branch_name,
1451
+ "branch_key": branch_key,
1452
+ "storage_root": str(storage_root),
1453
+ "updated_at": updated_at,
1454
+ "last_seen_head": get_head_commit(repo_root) if repo_root else None,
1455
+ "last_capture_kind": None,
1456
+ "last_capture_mode": "delete-entry",
1457
+ "last_capture_update_mode": "delete-entry",
1458
+ "last_capture_targets": [{
1459
+ "file": mutation["file"],
1460
+ "section": mutation["section"],
1461
+ "mode": "delete-entry",
1462
+ "id": eid,
1463
+ }],
1464
+ })
1465
+ write_json(paths["manifest"], manifest)
1466
+
1467
+ _emit_capture_log(
1468
+ paths,
1469
+ action="delete-entry",
1470
+ kind_label=mutation["file_key"],
1471
+ summary=mutation["previous_first_line"] or eid,
1472
+ touched=[{"file": mutation["file"], "section": mutation["section"], "mode": "delete-entry"}],
1473
+ extra_details=[("id", eid)],
1474
+ )
1475
+
1476
+ print(json.dumps({
1477
+ "mode": "delete-entry",
1478
+ "id": eid,
1479
+ "file": mutation["file"],
1480
+ "section": mutation["section"],
1481
+ "deleted_first_line": mutation["previous_first_line"],
1482
+ "updated_at": updated_at,
1483
+ }, ensure_ascii=False, indent=2))
1484
+ return 0
1485
+
1486
+
1487
+ def command_apply_summary_output(args):
1488
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1489
+ args.repo, args.context_dir, args.branch
1490
+ )
1491
+ worktree_writeback = _worktree_writeback_context(repo_root, repo_dir, paths, branch_name)
1492
+ payload = _load_json_payload(args.json, args.json_file)
1493
+ if not isinstance(payload, dict):
1494
+ raise RuntimeError("summary output must be a JSON object")
1495
+
1496
+ touched = []
1497
+ actions = []
1498
+ dedup_blocked = []
1499
+ force = bool(getattr(args, "force", False))
1500
+
1501
+ # Preflight destructive/targeted edits before any write happens. This
1502
+ # avoids a partial upsert/append landing before a later bad entry id fails.
1503
+ for item in payload.get("rewrites") or []:
1504
+ _validate_entry_reference(paths, item.get("id"), require_content=item.get("content"))
1505
+ for item in payload.get("deletes") or []:
1506
+ _validate_entry_reference(paths, item.get("id"))
1507
+
1508
+ def add_write(kind, content, *, mode_override=None, source=None):
1509
+ if not kind:
1510
+ return
1511
+ if kind not in KIND_MAP:
1512
+ raise RuntimeError(f"unsupported kind in summary output: {kind}")
1513
+ body = "\n".join(normalize_items(content))
1514
+ if not body:
1515
+ return
1516
+ mode = mode_override or KIND_MAP[kind].get("default_mode", "upsert")
1517
+ if mode == "append":
1518
+ hint = _check_dedup_for_kind(paths, kind, body, force=force)
1519
+ if hint is not None:
1520
+ dedup_blocked.append({
1521
+ "source": source,
1522
+ "kind": kind,
1523
+ "content_preview": _first_nonempty_line(body)[:_SIM_PREVIEW_LEN],
1524
+ "dedup_hint": hint,
1525
+ })
1526
+ return
1527
+ rec = _write_one(paths, kind, body, mode_override=mode_override)
1528
+ touched.append(rec)
1529
+ _maybe_worktree_writeback(
1530
+ worktree_writeback,
1531
+ kind,
1532
+ body,
1533
+ mode_override=mode_override,
1534
+ force=force,
1535
+ summary=source,
1536
+ )
1537
+ actions.append({
1538
+ "op": mode,
1539
+ "kind": kind,
1540
+ "source": source,
1541
+ "target": rec,
1542
+ })
1543
+
1544
+ # Convenience summary-output fields. These mirror --summary-json but keep
1545
+ # execution in code instead of making the agent compose CLI calls.
1546
+ for item in payload.get("decisions") or []:
1547
+ add_write("decision", _decision_content(item), source="decisions")
1548
+ for item in payload.get("risks") or []:
1549
+ add_write("risk", item, source="risks")
1550
+ for item in payload.get("glossary") or []:
1551
+ add_write("glossary", item, source="glossary")
1552
+ for item in payload.get("shared_decisions") or []:
1553
+ add_write("shared-decision", _decision_content(item), source="shared_decisions")
1554
+ for item in payload.get("shared_context") or []:
1555
+ add_write("shared-context", item, source="shared_context")
1556
+ for item in payload.get("shared_sources") or []:
1557
+ add_write("shared-source", item, source="shared_sources")
1558
+
1559
+ file_map = payload.get("file_map")
1560
+ if isinstance(file_map, list) and file_map:
1561
+ lines = []
1562
+ for entry in file_map:
1563
+ if not isinstance(entry, dict):
1564
+ continue
1565
+ label = entry.get("label", "").strip()
1566
+ paths_val = entry.get("paths") or ([entry["path"]] if entry.get("path") else [])
1567
+ if not label or not paths_val:
1568
+ continue
1569
+ joined = ", ".join(f"`{p}`" for p in paths_val)
1570
+ lines.append(f"- {label}: {joined}")
1571
+ if lines:
1572
+ add_write("filemap", "\n".join(lines), source="file_map")
1573
+
1574
+ # Explicit patch operations.
1575
+ for item in payload.get("upserts") or []:
1576
+ add_write(item.get("kind"), item.get("content"), mode_override="upsert", source="upserts")
1577
+ for item in payload.get("appends") or []:
1578
+ add_write(item.get("kind"), item.get("content"), mode_override="append", source="appends")
1579
+ for item in payload.get("rewrites") or []:
1580
+ mutation = _entry_mutation(paths, item.get("id"), new_text=item.get("content"))
1581
+ touched.append({"file": mutation["file"], "section": mutation["section"], "mode": "rewrite-entry"})
1582
+ actions.append({
1583
+ "op": "rewrite-entry",
1584
+ "id": mutation["id"],
1585
+ "file": mutation["file"],
1586
+ "section": mutation["section"],
1587
+ "previous_first_line": mutation["previous_first_line"],
1588
+ "reason": item.get("reason"),
1589
+ })
1590
+ for item in payload.get("deletes") or []:
1591
+ mutation = _entry_mutation(paths, item.get("id"), delete=True)
1592
+ touched.append({"file": mutation["file"], "section": mutation["section"], "mode": "delete-entry"})
1593
+ actions.append({
1594
+ "op": "delete-entry",
1595
+ "id": mutation["id"],
1596
+ "file": mutation["file"],
1597
+ "section": mutation["section"],
1598
+ "deleted_first_line": mutation["previous_first_line"],
1599
+ "reason": item.get("reason"),
1600
+ })
1601
+
1602
+ updated_at = now_iso()
1603
+
1604
+ if touched:
1605
+ manifest = read_json(paths["manifest"])
1606
+ manifest.update({
1607
+ "repo_root": str(repo_root),
1608
+ "repo_key": repo_key,
1609
+ "branch": branch_name,
1610
+ "branch_key": branch_key,
1611
+ "storage_root": str(storage_root),
1612
+ "updated_at": updated_at,
1613
+ "last_seen_head": get_head_commit(repo_root) if repo_root else None,
1614
+ "last_capture_kind": "summary-output",
1615
+ "last_capture_mode": "apply-summary-output",
1616
+ "last_capture_update_mode": "apply-summary-output",
1617
+ "last_capture_targets": touched,
1618
+ })
1619
+ write_json(paths["manifest"], manifest)
1620
+
1621
+ repo_manifest = read_json(paths["repo_manifest"])
1622
+ repo_manifest.update({
1623
+ "repo_root": str(repo_root),
1624
+ "repo_key": repo_key,
1625
+ "storage_root": str(storage_root),
1626
+ "updated_at": updated_at,
1627
+ "last_seen_branch": branch_name,
1628
+ "last_seen_head": manifest["last_seen_head"],
1629
+ })
1630
+ write_json(paths["repo_manifest"], repo_manifest)
1631
+
1632
+ _emit_capture_log(
1633
+ paths,
1634
+ action="apply-summary-output",
1635
+ kind_label="summary-output",
1636
+ summary=payload.get("title") or f"{len(touched)} target(s)",
1637
+ touched=touched,
1638
+ )
1639
+
1640
+ output = {
1641
+ "mode": "apply-summary-output",
1642
+ "repo_root": str(repo_root),
1643
+ "repo_key": repo_key,
1644
+ "branch": branch_name,
1645
+ "touched_targets": touched,
1646
+ "actions": actions,
1647
+ "skip_reason": payload.get("skip_reason") if not touched else None,
1648
+ "updated_at": updated_at,
1649
+ }
1650
+ writeback_output = _finalize_worktree_writeback(worktree_writeback, repo_root, repo_key, storage_root)
1651
+ if writeback_output:
1652
+ output["worktree_writeback"] = writeback_output
1653
+ elif worktree_writeback and worktree_writeback.get("attempted") and worktree_writeback.get("skipped"):
1654
+ output["worktree_writeback"] = {
1655
+ "source": worktree_writeback.get("source"),
1656
+ "source_dir": worktree_writeback.get("source_dir"),
1657
+ "touched": [],
1658
+ "skipped": worktree_writeback["skipped"],
1659
+ }
1660
+ if dedup_blocked:
1661
+ output["dedup_blocked"] = dedup_blocked
1662
+ print(json.dumps(output, ensure_ascii=False, indent=2))
1663
+ return 2
1664
+ print(json.dumps(output, ensure_ascii=False, indent=2))
1665
+ return 0
1666
+
1667
+
1133
1668
  def command_sync_working_tree(args):
1134
1669
  repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1135
1670
  args.repo, args.context_dir, args.branch
@@ -1315,6 +1850,22 @@ def main():
1315
1850
  rewrite.add_argument("--content", help="Inline markdown content to replace the entry with")
1316
1851
  rewrite.add_argument("--content-file", help="File containing replacement markdown content")
1317
1852
 
1853
+ delete = subparsers.add_parser(
1854
+ "delete-entry",
1855
+ help="Delete an existing entry by id",
1856
+ )
1857
+ _add_common_args(delete)
1858
+ delete.add_argument("--id", required=True, help="Entry id in the form <file_key>::<section_idx>::<entry_idx>")
1859
+
1860
+ apply_summary = subparsers.add_parser(
1861
+ "apply-summary-output",
1862
+ help="Apply a structured summary-output JSON patch",
1863
+ )
1864
+ _add_common_args(apply_summary)
1865
+ apply_summary.add_argument("--json", help="Inline summary-output JSON")
1866
+ apply_summary.add_argument("--json-file", help="File containing summary-output JSON")
1867
+ apply_summary.add_argument("--force", action="store_true", help="Skip dedup checks for append operations")
1868
+
1318
1869
  sync = subparsers.add_parser("sync-working-tree", help="Refresh progress.md auto-sync block from git facts")
1319
1870
  _add_common_args(sync)
1320
1871
 
@@ -1330,6 +1881,8 @@ def main():
1330
1881
  "classify": command_classify,
1331
1882
  "record": command_record,
1332
1883
  "rewrite-entry": command_rewrite_entry,
1884
+ "delete-entry": command_delete_entry,
1885
+ "apply-summary-output": command_apply_summary_output,
1333
1886
  "sync-working-tree": command_sync_working_tree,
1334
1887
  "record-head": command_record_head,
1335
1888
  }