dev-memory-cli 0.18.2 → 0.18.3

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
@@ -563,6 +694,147 @@ KIND_MAP = {
563
694
  "pending": {"file": "pending_promotion", "section": "候选条目", "default_mode": "append"},
564
695
  }
565
696
 
697
+
698
+ # Worktree write-back deliberately mirrors only append-style knowledge. Snapshot
699
+ # fields like progress/overview/next represent the current branch state and would
700
+ # corrupt the source branch if copied back blindly.
701
+ WORKTREE_WRITEBACK_KINDS = {"decision", "risk", "glossary", "source"}
702
+
703
+
704
+ def _truthy(value):
705
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
706
+
707
+
708
+ def _worktree_writeback_enabled(repo_root):
709
+ env_value = os.environ.get("DEV_MEMORY_WORKTREE_WRITEBACK")
710
+ if env_value is not None:
711
+ return _truthy(env_value)
712
+ cfg = run_git(
713
+ ["config", "--bool", "--get", "dev-memory.worktreeWriteback"],
714
+ cwd=repo_root,
715
+ check=False,
716
+ )
717
+ return cfg.returncode == 0 and _truthy(cfg.stdout)
718
+
719
+
720
+ def _worktree_inherit_source(manifest, branch_name):
721
+ provenance = manifest.get("provenance") or []
722
+ if not isinstance(provenance, list):
723
+ return None
724
+ for item in reversed(provenance):
725
+ if not isinstance(item, dict):
726
+ continue
727
+ if item.get("op") != "worktree-inherit":
728
+ continue
729
+ source = str(item.get("from") or "").strip()
730
+ if source and source != branch_name:
731
+ return source
732
+ return None
733
+
734
+
735
+ def _branch_head(repo_root, branch_name):
736
+ result = run_git(["rev-parse", branch_name], cwd=repo_root, check=False)
737
+ return result.stdout.strip() if result.returncode == 0 else None
738
+
739
+
740
+ def _worktree_writeback_context(repo_root, repo_dir, paths, branch_name):
741
+ if branch_name is None or not _worktree_writeback_enabled(repo_root):
742
+ return None
743
+ manifest = read_json(paths["manifest"]) or {}
744
+ source_name = _worktree_inherit_source(manifest, branch_name)
745
+ if not source_name:
746
+ return None
747
+ source_key = sanitize_branch_name(source_name)
748
+ source_dir = repo_dir / "branches" / source_key
749
+ if not source_dir.exists() or not source_dir.is_dir():
750
+ return {
751
+ "source": source_name,
752
+ "source_key": source_key,
753
+ "source_dir": str(source_dir),
754
+ "paths": None,
755
+ "attempted": False,
756
+ "touched": [],
757
+ "skipped": [{"reason": "source-missing", "source_dir": str(source_dir)}],
758
+ }
759
+ return {
760
+ "source": source_name,
761
+ "source_key": source_key,
762
+ "source_dir": str(source_dir),
763
+ "paths": asset_paths(repo_dir, source_dir),
764
+ "attempted": False,
765
+ "touched": [],
766
+ "skipped": [],
767
+ }
768
+
769
+
770
+ def _maybe_worktree_writeback(ctx, kind, body, *, title_override=None, mode_override=None,
771
+ force=False, dedup_threshold=None, summary=None):
772
+ if not ctx:
773
+ return None
774
+ if kind not in WORKTREE_WRITEBACK_KINDS:
775
+ return None
776
+ mode = mode_override or KIND_MAP.get(kind, {}).get("default_mode", "upsert")
777
+ if mode != "append":
778
+ return None
779
+ ctx["attempted"] = True
780
+ if not ctx.get("paths"):
781
+ return None
782
+ hint = _check_dedup_for_kind(
783
+ ctx["paths"],
784
+ kind,
785
+ body,
786
+ force=force,
787
+ title_override=title_override,
788
+ threshold=dedup_threshold,
789
+ )
790
+ if hint is not None:
791
+ ctx["skipped"].append({
792
+ "kind": kind,
793
+ "reason": "dedup-blocked",
794
+ "content_preview": _first_nonempty_line(body)[:_SIM_PREVIEW_LEN],
795
+ "dedup_hint": hint,
796
+ })
797
+ return None
798
+ rec = _write_one(ctx["paths"], kind, body, title_override=title_override, mode_override=mode)
799
+ rec["mode"] = "worktree-writeback-append"
800
+ rec["source_branch"] = ctx["source"]
801
+ ctx["touched"].append(rec)
802
+ _emit_capture_log(
803
+ ctx["paths"],
804
+ action="worktree-writeback",
805
+ kind_label=kind,
806
+ summary=summary or body,
807
+ touched=[rec],
808
+ )
809
+ return rec
810
+
811
+
812
+ def _finalize_worktree_writeback(ctx, repo_root, repo_key, storage_root):
813
+ if not ctx or not ctx.get("paths") or not ctx["touched"]:
814
+ return None
815
+ updated_at = now_iso()
816
+ manifest = read_json(ctx["paths"]["manifest"]) or {}
817
+ manifest.update({
818
+ "repo_key": repo_key,
819
+ "branch": ctx["source"],
820
+ "branch_key": ctx["source_key"],
821
+ "storage_root": str(storage_root),
822
+ "updated_at": updated_at,
823
+ "last_seen_head": _branch_head(repo_root, ctx["source"]),
824
+ "last_capture_kind": "worktree-writeback",
825
+ "last_capture_mode": "worktree-writeback",
826
+ "last_capture_update_mode": "worktree-writeback",
827
+ "last_capture_targets": ctx["touched"],
828
+ })
829
+ write_json(ctx["paths"]["manifest"], manifest)
830
+ return {
831
+ "source": ctx["source"],
832
+ "source_dir": ctx["source_dir"],
833
+ "touched": ctx["touched"],
834
+ "skipped": ctx["skipped"],
835
+ "updated_at": updated_at,
836
+ }
837
+
566
838
  # Session-payload → kind mapping, used by `record --summary-json`. Each entry
567
839
  # is (payload_key, kind, optional_transform). Several keys may route to the
568
840
  # same kind; that's fine — the write layer upserts.
@@ -582,6 +854,17 @@ SESSION_PAYLOAD_MAP = [
582
854
  ("source_updates", "shared-source", None),
583
855
  ]
584
856
 
857
+ SESSION_DIRECT_PAYLOAD_MAP = [
858
+ ("progress", "progress"),
859
+ ("next", "next"),
860
+ ]
861
+
862
+ SESSION_EXTRA_PAYLOAD_MAP = [
863
+ ("glossary", "glossary", None),
864
+ ("shared_context", "shared-context", None),
865
+ ("shared_sources", "shared-source", None),
866
+ ]
867
+
585
868
 
586
869
  def normalize_items(items):
587
870
  if items is None:
@@ -596,8 +879,19 @@ def bullets(items, empty_text="- 待补充", wrap_code=False):
596
879
  return render_bullets(normalize_items(items), empty_text=empty_text, wrap_code=wrap_code)
597
880
 
598
881
 
882
+ def _decision_summary(item):
883
+ if isinstance(item, str):
884
+ return item.strip()
885
+ if not isinstance(item, dict):
886
+ return ""
887
+ return str(item.get("decision") or item.get("summary") or "").strip()
888
+
889
+
599
890
  def decision_body(item):
600
- parts = [f"- 结论: {item['decision']}"]
891
+ summary = _decision_summary(item)
892
+ parts = [f"- 结论: {summary}"]
893
+ if not isinstance(item, dict):
894
+ return "\n".join(parts)
601
895
  if item.get("reason"):
602
896
  parts.append(f"- 原因: {item['reason']}")
603
897
  if item.get("impact"):
@@ -684,6 +978,22 @@ def _load_optional_text(value, file_path=None):
684
978
  return None
685
979
 
686
980
 
981
+ def _load_json_payload(value, file_path=None):
982
+ if value:
983
+ return json.loads(value)
984
+ if file_path:
985
+ return json.loads(Path(file_path).read_text(encoding="utf-8"))
986
+ raise RuntimeError("one of --json / --json-file is required")
987
+
988
+
989
+ def _decision_content(item):
990
+ if isinstance(item, str):
991
+ return item.strip()
992
+ if isinstance(item, dict):
993
+ return decision_body(item)
994
+ return ""
995
+
996
+
687
997
  def _load_free_content(args):
688
998
  """Load content for single-kind writes. Supports:
689
999
  --content / --content-file : inline body
@@ -794,6 +1104,7 @@ def command_record(args):
794
1104
  repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
795
1105
  args.repo, args.context_dir, args.branch
796
1106
  )
1107
+ worktree_writeback = _worktree_writeback_context(repo_root, repo_dir, paths, branch_name)
797
1108
  already_setup = get_setup_completed(paths["manifest"])
798
1109
  force = bool(getattr(args, "force", False))
799
1110
 
@@ -844,10 +1155,30 @@ def command_record(args):
844
1155
  "dedup_hint": hint,
845
1156
  })
846
1157
  return None
847
- return _write_one(paths, kind, body, title_override=title_override)
1158
+ rec = _write_one(paths, kind, body, title_override=title_override)
1159
+ _maybe_worktree_writeback(
1160
+ worktree_writeback,
1161
+ kind,
1162
+ body,
1163
+ title_override=title_override,
1164
+ force=force,
1165
+ dedup_threshold=dedup_threshold,
1166
+ summary=source_label,
1167
+ )
1168
+ return rec
1169
+
1170
+ # Worker-facing current-state fields. Keep these as plain text because
1171
+ # progress/next sections are snapshots, not bullet-list accumulators.
1172
+ for key, kind in SESSION_DIRECT_PAYLOAD_MAP:
1173
+ items = normalize_items(payload.get(key))
1174
+ if not items:
1175
+ continue
1176
+ rec = _write_or_block(kind, "\n".join(items), source_label=f"payload[{key}]")
1177
+ if rec is not None:
1178
+ touched.append(rec)
848
1179
 
849
1180
  # Simple scalar mappings.
850
- for key, kind, subsection_title in SESSION_PAYLOAD_MAP:
1181
+ for key, kind, subsection_title in SESSION_PAYLOAD_MAP + SESSION_EXTRA_PAYLOAD_MAP:
851
1182
  items = normalize_items(payload.get(key))
852
1183
  if not items:
853
1184
  continue
@@ -873,18 +1204,30 @@ def command_record(args):
873
1204
  if rec is not None:
874
1205
  touched.append(rec)
875
1206
 
876
- # Structured decisions (decision/reason/impact trios).
877
- decision_items = [decision_body(item) for item in (payload.get("decisions") or []) if item.get("decision")]
1207
+ # Structured decisions (decision|summary/reason/impact trios).
1208
+ decision_payload_items = [
1209
+ item for item in (payload.get("decisions") or []) if _decision_summary(item)
1210
+ ]
1211
+ decision_items = [decision_body(item) for item in decision_payload_items]
878
1212
  if decision_items:
879
1213
  body = "\n\n".join(decision_items)
880
1214
  rec = _write_or_block("decision", body, source_label="payload[decisions]")
881
1215
  if rec is not None:
882
1216
  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)
1217
+ for item in decision_payload_items:
1218
+ pending = _maybe_stage_pending(paths, _decision_summary(item), branch_name or "")
1219
+ if pending:
1220
+ touched.append(pending)
1221
+
1222
+ shared_decision_payload_items = [
1223
+ item for item in (payload.get("shared_decisions") or []) if _decision_summary(item)
1224
+ ]
1225
+ shared_decision_items = [decision_body(item) for item in shared_decision_payload_items]
1226
+ if shared_decision_items:
1227
+ body = "\n\n".join(shared_decision_items)
1228
+ rec = _write_or_block("shared-decision", body, source_label="payload[shared_decisions]")
1229
+ if rec is not None:
1230
+ touched.append(rec)
888
1231
 
889
1232
  extra_manifest = {"last_session_sync_title": title, "last_session_sync_mode": "capture-session"}
890
1233
 
@@ -926,7 +1269,17 @@ def command_record(args):
926
1269
  }, ensure_ascii=False, indent=2))
927
1270
  return 2
928
1271
 
929
- touched.append(_write_one(paths, kind, content, title_override=args.title))
1272
+ rec = _write_one(paths, kind, content, title_override=args.title)
1273
+ touched.append(rec)
1274
+ _maybe_worktree_writeback(
1275
+ worktree_writeback,
1276
+ kind,
1277
+ content,
1278
+ title_override=args.title,
1279
+ force=force,
1280
+ dedup_threshold=dedup_threshold,
1281
+ summary=content,
1282
+ )
930
1283
  rec = _maybe_stage_pending(paths, content, branch_name or "")
931
1284
  if rec:
932
1285
  touched.append(rec)
@@ -999,6 +1352,16 @@ def command_record(args):
999
1352
  "touched_targets": touched,
1000
1353
  "updated_at": manifest["updated_at"],
1001
1354
  }
1355
+ writeback_output = _finalize_worktree_writeback(worktree_writeback, repo_root, repo_key, storage_root)
1356
+ if writeback_output:
1357
+ output["worktree_writeback"] = writeback_output
1358
+ elif worktree_writeback and worktree_writeback.get("attempted") and worktree_writeback.get("skipped"):
1359
+ output["worktree_writeback"] = {
1360
+ "source": worktree_writeback.get("source"),
1361
+ "source_dir": worktree_writeback.get("source_dir"),
1362
+ "touched": [],
1363
+ "skipped": worktree_writeback["skipped"],
1364
+ }
1002
1365
  if dedup_blocked:
1003
1366
  # Batch mode: surface blocked items but still report what was written.
1004
1367
  output["dedup_blocked"] = dedup_blocked
@@ -1020,68 +1383,13 @@ def command_rewrite_entry(args):
1020
1383
  )
1021
1384
 
1022
1385
  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
1386
  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)
1387
+ try:
1388
+ mutation = _entry_mutation(paths, eid, new_text=new_text)
1389
+ except RuntimeError as exc:
1390
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
1079
1391
  return 1
1080
1392
 
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
1393
  # Manifest bookkeeping (same shape as record).
1086
1394
  manifest = read_json(paths["manifest"])
1087
1395
  updated_at = now_iso()
@@ -1098,8 +1406,8 @@ def command_rewrite_entry(args):
1098
1406
  "last_capture_mode": "rewrite-entry",
1099
1407
  "last_capture_update_mode": "rewrite-entry",
1100
1408
  "last_capture_targets": [{
1101
- "file": _label(file_key),
1102
- "section": section_title,
1409
+ "file": mutation["file"],
1410
+ "section": mutation["section"],
1103
1411
  "mode": "rewrite-entry",
1104
1412
  "id": eid,
1105
1413
  }],
@@ -1112,24 +1420,245 @@ def command_rewrite_entry(args):
1112
1420
  _emit_capture_log(
1113
1421
  paths,
1114
1422
  action="rewrite-entry",
1115
- kind_label=file_key,
1423
+ kind_label=mutation["file_key"],
1116
1424
  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)],
1425
+ touched=[{"file": mutation["file"], "section": mutation["section"], "mode": "rewrite-entry"}],
1426
+ extra_details=[("id", eid), ("previous", mutation["previous_first_line"])],
1119
1427
  )
1120
1428
 
1121
1429
  print(json.dumps({
1122
1430
  "mode": "rewrite-entry",
1123
1431
  "id": eid,
1124
- "file": _label(file_key),
1125
- "section": section_title,
1126
- "previous_first_line": previous_first_line,
1432
+ "file": mutation["file"],
1433
+ "section": mutation["section"],
1434
+ "previous_first_line": mutation["previous_first_line"],
1127
1435
  "new_first_line": new_first_line,
1128
1436
  "updated_at": updated_at,
1129
1437
  }, ensure_ascii=False, indent=2))
1130
1438
  return 0
1131
1439
 
1132
1440
 
1441
+ def command_delete_entry(args):
1442
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1443
+ args.repo, args.context_dir, args.branch
1444
+ )
1445
+ eid = (args.id or "").strip()
1446
+ try:
1447
+ mutation = _entry_mutation(paths, eid, delete=True)
1448
+ except RuntimeError as exc:
1449
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
1450
+ return 1
1451
+
1452
+ manifest = read_json(paths["manifest"])
1453
+ updated_at = now_iso()
1454
+ manifest.update({
1455
+ "repo_root": str(repo_root),
1456
+ "repo_key": repo_key,
1457
+ "branch": branch_name,
1458
+ "branch_key": branch_key,
1459
+ "storage_root": str(storage_root),
1460
+ "updated_at": updated_at,
1461
+ "last_seen_head": get_head_commit(repo_root) if repo_root else None,
1462
+ "last_capture_kind": None,
1463
+ "last_capture_mode": "delete-entry",
1464
+ "last_capture_update_mode": "delete-entry",
1465
+ "last_capture_targets": [{
1466
+ "file": mutation["file"],
1467
+ "section": mutation["section"],
1468
+ "mode": "delete-entry",
1469
+ "id": eid,
1470
+ }],
1471
+ })
1472
+ write_json(paths["manifest"], manifest)
1473
+
1474
+ _emit_capture_log(
1475
+ paths,
1476
+ action="delete-entry",
1477
+ kind_label=mutation["file_key"],
1478
+ summary=mutation["previous_first_line"] or eid,
1479
+ touched=[{"file": mutation["file"], "section": mutation["section"], "mode": "delete-entry"}],
1480
+ extra_details=[("id", eid)],
1481
+ )
1482
+
1483
+ print(json.dumps({
1484
+ "mode": "delete-entry",
1485
+ "id": eid,
1486
+ "file": mutation["file"],
1487
+ "section": mutation["section"],
1488
+ "deleted_first_line": mutation["previous_first_line"],
1489
+ "updated_at": updated_at,
1490
+ }, ensure_ascii=False, indent=2))
1491
+ return 0
1492
+
1493
+
1494
+ def command_apply_summary_output(args):
1495
+ repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1496
+ args.repo, args.context_dir, args.branch
1497
+ )
1498
+ worktree_writeback = _worktree_writeback_context(repo_root, repo_dir, paths, branch_name)
1499
+ payload = _load_json_payload(args.json, args.json_file)
1500
+ if not isinstance(payload, dict):
1501
+ raise RuntimeError("summary output must be a JSON object")
1502
+
1503
+ touched = []
1504
+ actions = []
1505
+ dedup_blocked = []
1506
+ force = bool(getattr(args, "force", False))
1507
+
1508
+ # Preflight destructive/targeted edits before any write happens. This
1509
+ # avoids a partial upsert/append landing before a later bad entry id fails.
1510
+ for item in payload.get("rewrites") or []:
1511
+ _validate_entry_reference(paths, item.get("id"), require_content=item.get("content"))
1512
+ for item in payload.get("deletes") or []:
1513
+ _validate_entry_reference(paths, item.get("id"))
1514
+
1515
+ def add_write(kind, content, *, mode_override=None, source=None):
1516
+ if not kind:
1517
+ return
1518
+ if kind not in KIND_MAP:
1519
+ raise RuntimeError(f"unsupported kind in summary output: {kind}")
1520
+ body = "\n".join(normalize_items(content))
1521
+ if not body:
1522
+ return
1523
+ mode = mode_override or KIND_MAP[kind].get("default_mode", "upsert")
1524
+ if mode == "append":
1525
+ hint = _check_dedup_for_kind(paths, kind, body, force=force)
1526
+ if hint is not None:
1527
+ dedup_blocked.append({
1528
+ "source": source,
1529
+ "kind": kind,
1530
+ "content_preview": _first_nonempty_line(body)[:_SIM_PREVIEW_LEN],
1531
+ "dedup_hint": hint,
1532
+ })
1533
+ return
1534
+ rec = _write_one(paths, kind, body, mode_override=mode_override)
1535
+ touched.append(rec)
1536
+ _maybe_worktree_writeback(
1537
+ worktree_writeback,
1538
+ kind,
1539
+ body,
1540
+ mode_override=mode_override,
1541
+ force=force,
1542
+ summary=source,
1543
+ )
1544
+ actions.append({
1545
+ "op": mode,
1546
+ "kind": kind,
1547
+ "source": source,
1548
+ "target": rec,
1549
+ })
1550
+
1551
+ # Convenience summary-output fields. These mirror --summary-json but keep
1552
+ # execution in code instead of making the agent compose CLI calls.
1553
+ add_write("progress", payload.get("progress"), source="progress")
1554
+ add_write("next", payload.get("next"), source="next")
1555
+ for item in payload.get("decisions") or []:
1556
+ add_write("decision", _decision_content(item), source="decisions")
1557
+ for item in payload.get("risks") or []:
1558
+ add_write("risk", item, source="risks")
1559
+ for item in payload.get("glossary") or []:
1560
+ add_write("glossary", item, source="glossary")
1561
+ for item in payload.get("shared_decisions") or []:
1562
+ add_write("shared-decision", _decision_content(item), source="shared_decisions")
1563
+ for item in payload.get("shared_context") or []:
1564
+ add_write("shared-context", item, source="shared_context")
1565
+ for item in payload.get("shared_sources") or []:
1566
+ add_write("shared-source", item, source="shared_sources")
1567
+
1568
+ # Explicit patch operations.
1569
+ for item in payload.get("upserts") or []:
1570
+ add_write(item.get("kind"), item.get("content"), mode_override="upsert", source="upserts")
1571
+ for item in payload.get("appends") or []:
1572
+ add_write(item.get("kind"), item.get("content"), mode_override="append", source="appends")
1573
+ for item in payload.get("rewrites") or []:
1574
+ mutation = _entry_mutation(paths, item.get("id"), new_text=item.get("content"))
1575
+ touched.append({"file": mutation["file"], "section": mutation["section"], "mode": "rewrite-entry"})
1576
+ actions.append({
1577
+ "op": "rewrite-entry",
1578
+ "id": mutation["id"],
1579
+ "file": mutation["file"],
1580
+ "section": mutation["section"],
1581
+ "previous_first_line": mutation["previous_first_line"],
1582
+ "reason": item.get("reason"),
1583
+ })
1584
+ for item in payload.get("deletes") or []:
1585
+ mutation = _entry_mutation(paths, item.get("id"), delete=True)
1586
+ touched.append({"file": mutation["file"], "section": mutation["section"], "mode": "delete-entry"})
1587
+ actions.append({
1588
+ "op": "delete-entry",
1589
+ "id": mutation["id"],
1590
+ "file": mutation["file"],
1591
+ "section": mutation["section"],
1592
+ "deleted_first_line": mutation["previous_first_line"],
1593
+ "reason": item.get("reason"),
1594
+ })
1595
+
1596
+ updated_at = now_iso()
1597
+
1598
+ if touched:
1599
+ manifest = read_json(paths["manifest"])
1600
+ manifest.update({
1601
+ "repo_root": str(repo_root),
1602
+ "repo_key": repo_key,
1603
+ "branch": branch_name,
1604
+ "branch_key": branch_key,
1605
+ "storage_root": str(storage_root),
1606
+ "updated_at": updated_at,
1607
+ "last_seen_head": get_head_commit(repo_root) if repo_root else None,
1608
+ "last_capture_kind": "summary-output",
1609
+ "last_capture_mode": "apply-summary-output",
1610
+ "last_capture_update_mode": "apply-summary-output",
1611
+ "last_capture_targets": touched,
1612
+ })
1613
+ write_json(paths["manifest"], manifest)
1614
+
1615
+ repo_manifest = read_json(paths["repo_manifest"])
1616
+ repo_manifest.update({
1617
+ "repo_root": str(repo_root),
1618
+ "repo_key": repo_key,
1619
+ "storage_root": str(storage_root),
1620
+ "updated_at": updated_at,
1621
+ "last_seen_branch": branch_name,
1622
+ "last_seen_head": manifest["last_seen_head"],
1623
+ })
1624
+ write_json(paths["repo_manifest"], repo_manifest)
1625
+
1626
+ _emit_capture_log(
1627
+ paths,
1628
+ action="apply-summary-output",
1629
+ kind_label="summary-output",
1630
+ summary=payload.get("title") or f"{len(touched)} target(s)",
1631
+ touched=touched,
1632
+ )
1633
+
1634
+ output = {
1635
+ "mode": "apply-summary-output",
1636
+ "repo_root": str(repo_root),
1637
+ "repo_key": repo_key,
1638
+ "branch": branch_name,
1639
+ "touched_targets": touched,
1640
+ "actions": actions,
1641
+ "skip_reason": payload.get("skip_reason") if not touched else None,
1642
+ "updated_at": updated_at,
1643
+ }
1644
+ writeback_output = _finalize_worktree_writeback(worktree_writeback, repo_root, repo_key, storage_root)
1645
+ if writeback_output:
1646
+ output["worktree_writeback"] = writeback_output
1647
+ elif worktree_writeback and worktree_writeback.get("attempted") and worktree_writeback.get("skipped"):
1648
+ output["worktree_writeback"] = {
1649
+ "source": worktree_writeback.get("source"),
1650
+ "source_dir": worktree_writeback.get("source_dir"),
1651
+ "touched": [],
1652
+ "skipped": worktree_writeback["skipped"],
1653
+ }
1654
+ if dedup_blocked:
1655
+ output["dedup_blocked"] = dedup_blocked
1656
+ print(json.dumps(output, ensure_ascii=False, indent=2))
1657
+ return 2
1658
+ print(json.dumps(output, ensure_ascii=False, indent=2))
1659
+ return 0
1660
+
1661
+
1133
1662
  def command_sync_working_tree(args):
1134
1663
  repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir, paths = ensure_branch_paths_exist(
1135
1664
  args.repo, args.context_dir, args.branch
@@ -1315,6 +1844,22 @@ def main():
1315
1844
  rewrite.add_argument("--content", help="Inline markdown content to replace the entry with")
1316
1845
  rewrite.add_argument("--content-file", help="File containing replacement markdown content")
1317
1846
 
1847
+ delete = subparsers.add_parser(
1848
+ "delete-entry",
1849
+ help="Delete an existing entry by id",
1850
+ )
1851
+ _add_common_args(delete)
1852
+ delete.add_argument("--id", required=True, help="Entry id in the form <file_key>::<section_idx>::<entry_idx>")
1853
+
1854
+ apply_summary = subparsers.add_parser(
1855
+ "apply-summary-output",
1856
+ help="Apply a structured summary-output JSON patch",
1857
+ )
1858
+ _add_common_args(apply_summary)
1859
+ apply_summary.add_argument("--json", help="Inline summary-output JSON")
1860
+ apply_summary.add_argument("--json-file", help="File containing summary-output JSON")
1861
+ apply_summary.add_argument("--force", action="store_true", help="Skip dedup checks for append operations")
1862
+
1318
1863
  sync = subparsers.add_parser("sync-working-tree", help="Refresh progress.md auto-sync block from git facts")
1319
1864
  _add_common_args(sync)
1320
1865
 
@@ -1330,6 +1875,8 @@ def main():
1330
1875
  "classify": command_classify,
1331
1876
  "record": command_record,
1332
1877
  "rewrite-entry": command_rewrite_entry,
1878
+ "delete-entry": command_delete_entry,
1879
+ "apply-summary-output": command_apply_summary_output,
1333
1880
  "sync-working-tree": command_sync_working_tree,
1334
1881
  "record-head": command_record_head,
1335
1882
  }