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.
- package/README.md +45 -8
- package/bin/dev-memory.js +79 -1
- package/hooks/README.md +8 -2
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +637 -84
- package/lib/dev_memory_common.py +129 -71
- package/lib/dev_memory_context.py +35 -1
- package/lib/dev_memory_graduate.py +83 -20
- package/lib/dev_memory_read.py +317 -0
- package/lib/dev_memory_setup.py +0 -2
- package/lib/dev_memory_summary.py +233 -0
- package/lib/ui-app.html +41 -0
- package/lib/ui-server.js +53 -0
- package/package.json +1 -1
- package/scripts/hooks/_common.py +670 -15
- package/scripts/hooks/session_end.py +14 -0
- package/scripts/hooks/session_start.py +17 -1
- package/suite-manifest.json +1 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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":
|
|
1102
|
-
"section":
|
|
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":
|
|
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":
|
|
1125
|
-
"section":
|
|
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
|
}
|