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.
- package/README.md +27 -5
- package/bin/dev-memory.js +77 -1
- package/hooks/README.md +8 -2
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +625 -78
- package/lib/dev_memory_common.py +59 -58
- package/lib/dev_memory_graduate.py +83 -20
- package/lib/dev_memory_summary.py +233 -0
- package/package.json +1 -1
- package/scripts/hooks/_common.py +555 -2
- package/scripts/hooks/session_end.py +14 -0
- package/scripts/hooks/session_start.py +17 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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":
|
|
1102
|
-
"section":
|
|
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":
|
|
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":
|
|
1125
|
-
"section":
|
|
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
|
}
|