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.
@@ -54,7 +54,7 @@ LEGACY_V1_FILES = ("development.md", "context.md", "sources.md")
54
54
  # Bottom-up clustering of changed-file paths into a small set of "focus
55
55
  # directories". The cluster never grows larger than this many entries; a
56
56
  # higher number gives finer granularity at the cost of a longer hint list.
57
- FOCUS_AREA_LIMIT = 5
57
+ FOCUS_AREA_LIMIT = 10
58
58
 
59
59
 
60
60
  def now_iso():
@@ -581,7 +581,6 @@ def template_overview(branch_name):
581
581
  ("分支", f"- {branch_name}"),
582
582
  ("当前目标", "- 待补充"),
583
583
  ("范围边界", "- 待补充"),
584
- ("当前阶段", "- 待补充"),
585
584
  ("关键约束", "- 待补充"),
586
585
  ],
587
586
  )
@@ -599,12 +598,10 @@ def template_decisions(branch_name):
599
598
 
600
599
  def template_progress(branch_name):
601
600
  return render_title_doc(
602
- "当前进展",
601
+ "自动索引",
603
602
  [
604
603
  ("分支", f"- {branch_name}"),
605
604
  ("建议优先查看的目录", "- 待刷新"),
606
- ("当前进展", "- 待补充"),
607
- ("下一步", "- 待补充"),
608
605
  (
609
606
  "自动同步区",
610
607
  "本区由 `dev-memory-cli capture sync-working-tree` / SessionStart hook 自动刷新,请不要手工编辑。\n\n"
@@ -684,11 +681,9 @@ def template_repo_log(repo_name):
684
681
 
685
682
  def template_progress_no_git(project_name):
686
683
  return render_title_doc(
687
- "当前进展(no-git 模式)",
684
+ "自动索引(no-git 模式)",
688
685
  [
689
686
  ("项目", f"- {project_name}"),
690
- ("当前进展", "- 待补充"),
691
- ("下一步", "- 待补充"),
692
687
  (
693
688
  "自动同步区",
694
689
  "本区由 capture / context 刷新。no-git 模式下无 git facts,保持最小骨架。\n\n"
@@ -950,6 +945,8 @@ def migrate_v1_to_v2_branch(branch_dir, branch_name):
950
945
  written = []
951
946
 
952
947
  progress_sections = list(merged_buckets.get("progress", []))
948
+ progress_sections = [(t, b) for t, b in progress_sections
949
+ if t not in ("当前进展", "下一步")]
953
950
  if auto_block is not None:
954
951
  progress_sections.append((
955
952
  "自动同步区",
@@ -959,7 +956,7 @@ def migrate_v1_to_v2_branch(branch_dir, branch_name):
959
956
  if progress_sections:
960
957
  target = branch_dir / "progress.md"
961
958
  target.write_text(
962
- render_title_doc("当前进展", [header] + progress_sections),
959
+ render_title_doc("自动索引", [header] + progress_sections),
963
960
  encoding="utf-8",
964
961
  )
965
962
  written.append("progress.md")
@@ -1243,7 +1240,6 @@ _CLASSIFY_PATTERNS = [
1243
1240
  ("decision", re.compile(r"结论[::]|决[定议][::]|不再|改为|采用|废弃|选择.+?不选|abandoned|adopt")),
1244
1241
  ("risk", re.compile(r"阻塞|注意|坑|失败|风险|卡住|gotcha|caveat|warning")),
1245
1242
  ("glossary", re.compile(r"即[::]|\s即\s|指的是|对应|链接|https?://|api\s*=|缩写|术语|简称|别名")),
1246
- ("progress", re.compile(r"当前|已完成|下一步|commit|提交|实现|进展|todo|wip")),
1247
1243
  ]
1248
1244
 
1249
1245
 
@@ -1337,12 +1333,38 @@ def summarize_scopes(paths):
1337
1333
  return [{"scope": scope, "files": count} for scope, count in sorted(counter.items())]
1338
1334
 
1339
1335
 
1336
+ _FOCUS_EXCLUDED_PREFIXES = (
1337
+ ".claude/", ".vscode/", ".idea/", ".git/",
1338
+ "node_modules/", "__pycache__/", ".venv/",
1339
+ )
1340
+
1341
+ _FOCUS_EXCLUDED_ROOT_FILES = {
1342
+ "go.mod", "go.sum", "go.work", "go.work.sum",
1343
+ "package.json", "package-lock.json", "pnpm-lock.yaml", "bun.lockb",
1344
+ "tsconfig.json", "tsconfig.base.json",
1345
+ "main.go", "main.py",
1346
+ ".gitignore", ".editorconfig", ".prettierrc",
1347
+ "skills-lock.json", "eden.monorepo.json",
1348
+ "Makefile", "Dockerfile",
1349
+ }
1350
+
1351
+
1352
+ def _should_exclude_path(path_str):
1353
+ if any(path_str.startswith(prefix) or path_str == prefix.rstrip("/")
1354
+ for prefix in _FOCUS_EXCLUDED_PREFIXES):
1355
+ return True
1356
+ if "/" not in path_str and path_str in _FOCUS_EXCLUDED_ROOT_FILES:
1357
+ return True
1358
+ return False
1359
+
1360
+
1340
1361
  def _initial_parent(path_str):
1341
- """File path → its immediate parent directory key. Files at repo root use
1342
- '.', everything else uses the POSIX-style parent dir string."""
1362
+ """File path → its immediate parent directory key. Root-level files map to
1363
+ None (skipped by callers) since individual files are not useful focus
1364
+ directories."""
1343
1365
  parent = Path(path_str).parent
1344
1366
  if str(parent) in ("", "."):
1345
- return "."
1367
+ return None
1346
1368
  return parent.as_posix()
1347
1369
 
1348
1370
 
@@ -1359,6 +1381,27 @@ def _rolled_up(key):
1359
1381
  return Path(key).parent.as_posix()
1360
1382
 
1361
1383
 
1384
+ def _dedup_parent_child(dirs):
1385
+ """Remove wider parents when a more specific child exists."""
1386
+ result = []
1387
+ for d in dirs:
1388
+ if any(other != d and other.startswith(d + "/") for other in dirs):
1389
+ continue
1390
+ result.append(d)
1391
+ return result
1392
+
1393
+
1394
+ def _drop_low_weight(ranked):
1395
+ """Drop weight-1 entries when higher-weight entries exist, since a single
1396
+ file change doesn't carry meaningful focus signal."""
1397
+ if not ranked:
1398
+ return ranked
1399
+ max_weight = ranked[0][1]
1400
+ if max_weight < 3:
1401
+ return ranked
1402
+ return [(k, w) for k, w in ranked if w >= 2]
1403
+
1404
+
1362
1405
  def summarize_focus_areas(paths, limit=None):
1363
1406
  """Cluster changed-file paths into ≤ `limit` focus directories.
1364
1407
 
@@ -1368,12 +1411,19 @@ def summarize_focus_areas(paths, limit=None):
1368
1411
  count (the "dominant cluster" — most files concentrated under a single
1369
1412
  deeper subtree), and merge only those originals into it. Buckets that
1370
1413
  were already at depth 1 or '.' are left untouched, so isolated shallow
1371
- directories survive. Tie-break: deeper rolled-up key wins, then
1372
- lexicographic.
1414
+ directories and root-level files survive. Tie-break: deeper rolled-up key
1415
+ wins, then lexicographic.
1373
1416
  """
1374
1417
  if limit is None:
1375
1418
  limit = FOCUS_AREA_LIMIT
1376
- buckets = Counter(_initial_parent(p) for p in paths)
1419
+ filtered = [p for p in paths if not _should_exclude_path(p)]
1420
+ buckets = Counter()
1421
+ for p in filtered:
1422
+ key = _initial_parent(p)
1423
+ if key is not None:
1424
+ buckets[key] += 1
1425
+ if not buckets:
1426
+ return []
1377
1427
  while len(buckets) > limit:
1378
1428
  proposals = {} # rolled_key -> [sum_count, [original_keys...]]
1379
1429
  for key, count in buckets.items():
@@ -1394,7 +1444,9 @@ def summarize_focus_areas(paths, limit=None):
1394
1444
  buckets.pop(k)
1395
1445
  buckets[winner_key] = buckets.get(winner_key, 0) + winner_count
1396
1446
  ranked = sorted(buckets.items(), key=lambda kv: (-kv[1], kv[0]))
1397
- return [k for k, _ in ranked[:limit]]
1447
+ ranked = _drop_low_weight(ranked)
1448
+ result = [k for k, _ in ranked[:limit]]
1449
+ return _dedup_parent_child(result)
1398
1450
 
1399
1451
 
1400
1452
  # Number of recent commits whose touched files contribute to the "focus area"
@@ -1405,19 +1457,22 @@ def summarize_focus_areas(paths, limit=None):
1405
1457
  RECENT_COMMITS_FOR_FOCUS = 10
1406
1458
 
1407
1459
 
1408
- def _files_in_recent_commits(repo_root, count):
1409
- """Files touched by the last `count` non-merge commits on HEAD."""
1410
- out = git_lines(
1411
- [
1412
- "log",
1413
- f"-n{count}",
1414
- "--no-merges",
1415
- "--name-only",
1416
- "--pretty=format:",
1417
- ],
1418
- cwd=repo_root,
1419
- check=False,
1420
- )
1460
+ def _files_in_recent_commits(repo_root, count, base_ref=None):
1461
+ """Files touched by recent non-merge commits on the current branch.
1462
+
1463
+ When a default base is known, bound the range to ``base..HEAD`` so noisy
1464
+ commits already merged into the base branch do not pollute focus areas.
1465
+ """
1466
+ cmd = [
1467
+ "log",
1468
+ f"-n{count}",
1469
+ "--no-merges",
1470
+ "--name-only",
1471
+ "--pretty=format:",
1472
+ ]
1473
+ if base_ref:
1474
+ cmd.append(f"{base_ref}..HEAD")
1475
+ out = git_lines(cmd, cwd=repo_root, check=False)
1421
1476
  seen = []
1422
1477
  seen_set = set()
1423
1478
  for line in out:
@@ -1432,9 +1487,8 @@ def collect_git_facts(repo_root, branch_name, _storage_root=None):
1432
1487
  working_tree_files = git_lines(["diff", "--name-only"], cwd=repo_root)
1433
1488
  staged_files = git_lines(["diff", "--cached", "--name-only"], cwd=repo_root)
1434
1489
  untracked_files = git_lines(["ls-files", "--others", "--exclude-standard"], cwd=repo_root)
1435
- recent_commit_files = _files_in_recent_commits(repo_root, RECENT_COMMITS_FOR_FOCUS)
1436
-
1437
1490
  default_base = detect_default_base(repo_root)
1491
+ recent_commit_files = _files_in_recent_commits(repo_root, RECENT_COMMITS_FOR_FOCUS, default_base)
1438
1492
 
1439
1493
  all_paths = sorted(set(working_tree_files + staged_files + untracked_files + recent_commit_files))
1440
1494
  return {
@@ -1456,28 +1510,29 @@ def merged_focus_areas(new_paths, existing, limit=None):
1456
1510
  tree signal.
1457
1511
 
1458
1512
  Algorithm (matches user's mental model — "specific signals over wide
1459
- parents, stale entries kept ranked low until limit prunes them"):
1513
+ parents; stale entries disappear once current git facts no longer cover
1514
+ them"):
1460
1515
 
1461
1516
  1. Internally dedupe `existing`: when a wider parent dir (e.g. `apps`)
1462
- coexists with its specific descendant (e.g. `apps/x/y/prompts`),
1463
- drop the wider parent. The wider parent is almost always a leftover
1464
- from an earlier roll-up that swallowed too much; the specific
1465
- descendants carry the real signal.
1517
+ coexists with either an existing or freshly-derived specific
1518
+ descendant (e.g. `apps/x/y/prompts`), drop the wider parent. The wider
1519
+ parent is almost always a leftover from an earlier roll-up that
1520
+ swallowed too much; the specific descendants carry the real signal.
1466
1521
  2. Score every surviving existing dir by how many `new_paths` it
1467
- actually covers (0 means stale, e.g. `.vscode` after a one-shot
1468
- IDE write but we keep it in the ranking, only the final limit
1469
- truncation prunes it from the bottom).
1470
- 3. Compute `uncovered` = paths not under any surviving existing dir.
1522
+ actually covers. 0 means stale, e.g. `.vscode` after a one-shot IDE
1523
+ write; stale entries are not carried forward.
1524
+ 3. Compute `uncovered` = paths not under any active existing dir.
1471
1525
  4. Roll up the uncovered paths' immediate parents into ≤ remaining
1472
1526
  budget buckets, **forbidding roll-up into any ancestor of an
1473
1527
  existing dir** so the output cannot regenerate the over-wide
1474
1528
  parent we just removed in step 1.
1475
- 5. Merge existing weights with the rolled-up uncovered buckets,
1476
- sort by weight desc, truncate to `limit`. Stale 0-weight entries
1477
- naturally sit at the bottom and get culled when buckets > limit.
1529
+ 5. Merge active existing weights with the rolled-up uncovered buckets,
1530
+ sort by weight desc, truncate to `limit`.
1478
1531
  """
1479
1532
  if limit is None:
1480
1533
  limit = FOCUS_AREA_LIMIT
1534
+ existing = [d for d in existing
1535
+ if "/" in d and not _should_exclude_path(d)]
1481
1536
  if not existing:
1482
1537
  return summarize_focus_areas(new_paths, limit=limit)
1483
1538
 
@@ -1486,24 +1541,31 @@ def merged_focus_areas(new_paths, existing, limit=None):
1486
1541
  return "/" not in path
1487
1542
  return path == dir_ or path.startswith(dir_ + "/")
1488
1543
 
1544
+ fresh_focus = summarize_focus_areas(new_paths, limit=limit)
1545
+
1489
1546
  def _drop_wider_parents(items):
1490
- items = list(dict.fromkeys(items)) # dedupe preserving order
1491
- return [d for d in items if not any(other != d and _is_under(other, d) for other in items)]
1547
+ items = [d for d in dict.fromkeys(items) if d != "."] # dedupe preserving order
1548
+ comparison_items = items + fresh_focus
1549
+ return [d for d in items if not any(other != d and _is_under(other, d) for other in comparison_items)]
1492
1550
 
1493
1551
  # (1) Drop wider parent entries inside existing.
1494
1552
  deduped_existing = _drop_wider_parents(existing)
1495
1553
 
1496
- # (2) Real coverage weight per surviving existing dir.
1554
+ # (2) Real coverage weight per surviving existing dir. Drop stale existing
1555
+ # dirs before computing uncovered paths or slot budget; otherwise stale
1556
+ # manifest entries still force new hot spots to roll up too far.
1497
1557
  existing_weights = {d: sum(1 for p in new_paths if _is_under(p, d)) for d in deduped_existing}
1558
+ active_existing_weights = {d: w for d, w in existing_weights.items() if w > 0}
1498
1559
 
1499
- # (3) Paths not yet covered by any existing dir.
1500
- uncovered = [p for p in new_paths if not any(_is_under(p, d) for d in deduped_existing)]
1560
+ # (3) Paths not yet covered by any active existing dir.
1561
+ active_existing_dirs = set(active_existing_weights)
1562
+ uncovered = [p for p in new_paths if not any(_is_under(p, d) for d in active_existing_dirs)]
1501
1563
 
1502
1564
  # (4) Forbidden roll-up targets = any ancestor of a surviving existing
1503
1565
  # dir. Without this guard the roll-up of `uncovered` could produce a
1504
1566
  # parent like `apps` and we'd be back where we started.
1505
1567
  forbidden_ancestors = set()
1506
- for d in deduped_existing:
1568
+ for d in active_existing_dirs:
1507
1569
  if d in ("", "."):
1508
1570
  continue
1509
1571
  parent = Path(d).parent
@@ -1517,11 +1579,13 @@ def merged_focus_areas(new_paths, existing, limit=None):
1517
1579
  parent = parent.parent
1518
1580
 
1519
1581
  # Reserve slots for existing entries; uncovered fills the rest.
1520
- remaining_budget = max(1, limit - len(existing_weights))
1582
+ remaining_budget = max(1, limit - len(active_existing_weights))
1521
1583
 
1522
1584
  new_buckets = Counter()
1523
1585
  for p in uncovered:
1524
- new_buckets[_initial_parent(p)] += 1
1586
+ key = _initial_parent(p)
1587
+ if key is not None and not _should_exclude_path(p):
1588
+ new_buckets[key] += 1
1525
1589
 
1526
1590
  while len(new_buckets) > remaining_budget:
1527
1591
  proposals = {}
@@ -1558,16 +1622,10 @@ def merged_focus_areas(new_paths, existing, limit=None):
1558
1622
  # - new_quota = floor(limit/2) → reserved for fresh buckets
1559
1623
  # - either side can borrow unused slots from the other (so we never
1560
1624
  # leave a slot empty just to enforce the partition).
1561
- # - stale existing (weight=0) is a fallback that only fills slots left
1562
- # after both quotas are settled — they ride at the bottom and get
1563
- # pruned naturally.
1564
1625
  existing_active = sorted(
1565
- ((d, w) for d, w in existing_weights.items() if w > 0),
1626
+ active_existing_weights.items(),
1566
1627
  key=lambda kv: (-kv[1], kv[0]),
1567
1628
  )
1568
- existing_stale = sorted(
1569
- (d for d, w in existing_weights.items() if w == 0),
1570
- )
1571
1629
  new_items = sorted(new_buckets.items(), key=lambda kv: (-kv[1], kv[0]))
1572
1630
 
1573
1631
  existing_quota = (limit + 1) // 2 # ceil(limit/2)
@@ -1584,26 +1642,26 @@ def merged_focus_areas(new_paths, existing, limit=None):
1584
1642
  if new_overflow > 0:
1585
1643
  existing_take.extend(existing_active[existing_quota:existing_quota + new_overflow])
1586
1644
 
1587
- final = []
1645
+ selected = []
1588
1646
  seen = set()
1589
- for d, _ in existing_take + new_take:
1590
- if d in seen:
1591
- continue
1592
- seen.add(d)
1593
- final.append(d)
1594
- for d in existing_stale:
1595
- if len(final) >= limit:
1596
- break
1647
+ for d, weight in existing_take + new_take:
1597
1648
  if d in seen:
1598
1649
  continue
1599
1650
  seen.add(d)
1600
- final.append(d)
1601
- return final[:limit]
1651
+ selected.append((d, weight))
1652
+ selected.sort(key=lambda kv: (-kv[1], kv[0]))
1653
+ selected = _drop_low_weight(selected)
1654
+ result = [d for d, _ in selected[:limit]]
1655
+ return _dedup_parent_child(result)
1602
1656
 
1603
1657
 
1604
1658
  def build_auto_block(facts):
1605
1659
  base_line = facts["default_base"] or "未检测到 origin/HEAD"
1606
1660
  head_line = facts["last_seen_head"] or "尚未检测到 HEAD"
1661
+ if facts["default_base"]:
1662
+ history_cmd = f"git log --oneline -n {RECENT_COMMITS_FOR_FOCUS} --no-merges {facts['default_base']}..HEAD"
1663
+ else:
1664
+ history_cmd = f"git log --oneline -n {RECENT_COMMITS_FOR_FOCUS} --no-merges"
1607
1665
  focus_lines = render_bullets(facts["focus_areas"], empty_text="- 当前未检测到改动目录", wrap_code=True)
1608
1666
  scope_lines = render_bullets(
1609
1667
  [f"{item['scope']} ({item['files']} files)" for item in facts["scope_summary"]],
@@ -1620,7 +1678,7 @@ def build_auto_block(facts):
1620
1678
  "#### 顶层改动范围\n\n"
1621
1679
  f"{scope_lines}\n\n"
1622
1680
  "#### 按需查看提交历史\n\n"
1623
- f"- `git log --oneline -n {RECENT_COMMITS_FOR_FOCUS} --no-merges`\n"
1681
+ f"- `{history_cmd}`\n"
1624
1682
  "- `git diff --name-only`\n"
1625
1683
  )
1626
1684
 
@@ -103,6 +103,33 @@ def command_sync(args):
103
103
  print(json.dumps(payload, ensure_ascii=False, indent=2))
104
104
 
105
105
 
106
+ def command_injection_preview(args):
107
+ """Render the SessionStart injection context for a given repo_key + branch,
108
+ without needing an actual git working tree. Used by the UI panel."""
109
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts" / "hooks"))
110
+ from _common import _build_context_from_assets # noqa: E402
111
+ from dev_memory_common import asset_paths as _asset_paths # noqa: E402
112
+
113
+ storage_root = Path(args.context_dir) if args.context_dir else Path.home() / ".dev-memory" / "repos"
114
+ repo_dir = storage_root / args.repo_key
115
+ branch_dir = repo_dir / "branches" / args.branch.replace("/", "__")
116
+
117
+ if not branch_dir.exists():
118
+ print(json.dumps({"error": f"branch dir not found: {branch_dir}"}, ensure_ascii=False))
119
+ return 1
120
+
121
+ paths = _asset_paths(repo_dir, branch_dir)
122
+ assets = {
123
+ "repo_dir": repo_dir,
124
+ "branch_dir": branch_dir,
125
+ "branch_name": args.branch,
126
+ "paths": paths,
127
+ }
128
+ context = _build_context_from_assets(assets, full=True)
129
+ print(json.dumps({"context": context or ""}, ensure_ascii=False))
130
+ return 0
131
+
132
+
106
133
  def main():
107
134
  parser = argparse.ArgumentParser(description="Read or refresh repo+branch development assets.")
108
135
  subparsers = parser.add_subparsers(dest="command", required=True)
@@ -113,12 +140,19 @@ def main():
113
140
  sub.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
114
141
  sub.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
115
142
 
143
+ preview_sub = subparsers.add_parser("injection-preview")
144
+ preview_sub.add_argument("--repo-key", required=True, help="Repo key (directory name under storage root)")
145
+ preview_sub.add_argument("--branch", required=True, help="Branch name (original, with slashes)")
146
+ preview_sub.add_argument("--context-dir", help="Storage root. Defaults to ~/.dev-memory/repos")
147
+
116
148
  args = parser.parse_args()
117
149
  try:
118
150
  if args.command == "show":
119
151
  command_show(args)
120
- else:
152
+ elif args.command == "sync":
121
153
  command_sync(args)
154
+ elif args.command == "injection-preview":
155
+ return command_injection_preview(args)
122
156
  except Exception as exc:
123
157
  print(f"ERROR: {exc}", file=sys.stderr)
124
158
  return 1
@@ -10,6 +10,8 @@ the heuristic missed, but the primary signal is pending-promotion.
10
10
  """
11
11
 
12
12
  import argparse
13
+ import contextlib
14
+ import fcntl
13
15
  import json
14
16
  import sys
15
17
  from datetime import datetime, timezone
@@ -130,21 +132,11 @@ def _apply_entries(target_path, entries):
130
132
  return n
131
133
 
132
134
 
133
- def command_apply(args):
134
- if detect_no_git_mode(args.repo):
135
- print(json.dumps({"error": "no-git mode: graduate has nothing to archive"}, ensure_ascii=False))
136
- return 1
137
-
138
- repo_root, branch_name, branch_key, _, repo_key, repo_dir, branch_dir = get_branch_paths(args.repo, args.context_dir, args.branch)
139
-
140
- if not branch_dir.exists():
141
- print(json.dumps({"error": f"branch memory not initialized: {branch_dir}"}, ensure_ascii=False))
142
- return 1
143
-
144
- harvest = read_json(Path(args.harvest_file))
135
+ def _load_harvest(harvest_file):
136
+ harvest_path = Path(harvest_file)
137
+ harvest = read_json(harvest_path)
145
138
  if not harvest:
146
- print(json.dumps({"error": f"harvest file empty or missing: {args.harvest_file}"}, ensure_ascii=False))
147
- return 1
139
+ raise ValueError(f"harvest file empty or missing: {harvest_file}")
148
140
 
149
141
  # Reject unknown top-level keys explicitly. The most common failure mode
150
142
  # is a pre-v2 harvest.json still using repo_context / repo_sources —
@@ -160,13 +152,74 @@ def command_apply(args):
160
152
  " (v1 schema? repo_context → repo_decisions or repo_glossary; "
161
153
  "repo_sources → repo_glossary; see references/harvest-schema.md)"
162
154
  )
163
- print(
164
- json.dumps(
165
- {"error": f"unknown harvest key(s): {unknown_keys}{legacy_hint}"},
166
- ensure_ascii=False,
155
+ raise ValueError(f"unknown harvest key(s): {unknown_keys}{legacy_hint}")
156
+ return harvest
157
+
158
+
159
+ def _resolve_apply_context(args):
160
+ if detect_no_git_mode(args.repo):
161
+ raise ValueError("no-git mode: graduate has nothing to archive")
162
+
163
+ repo_root, branch_name, branch_key, _, repo_key, repo_dir, branch_dir = get_branch_paths(
164
+ args.repo,
165
+ args.context_dir,
166
+ args.branch,
167
+ )
168
+ if not branch_dir.exists():
169
+ raise ValueError(f"branch memory not initialized: {branch_dir}")
170
+
171
+ return {
172
+ "repo_root": repo_root,
173
+ "branch_name": branch_name,
174
+ "branch_key": branch_key,
175
+ "repo_key": repo_key,
176
+ "repo_dir": repo_dir,
177
+ "branch_dir": branch_dir,
178
+ }
179
+
180
+
181
+ def _graduate_apply_lock_path(repo_dir):
182
+ return Path(repo_dir) / "jobs" / "graduate-apply.lock"
183
+
184
+
185
+ @contextlib.contextmanager
186
+ def _graduate_apply_queue(repo_dir):
187
+ """Serialize graduate apply per repo memory directory.
188
+
189
+ Pre-flight validation happens before this context. The lock only protects
190
+ repo-shared writes + branch archive moves, so independent invocations can
191
+ still fail fast on bad harvest files before waiting behind a long apply.
192
+ """
193
+ lock_path = _graduate_apply_lock_path(repo_dir)
194
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
195
+ with lock_path.open("a+", encoding="utf-8") as lock_fh:
196
+ waited = False
197
+ try:
198
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
199
+ except BlockingIOError:
200
+ waited = True
201
+ print(
202
+ f"[dev-memory][graduate] another apply is running; queued on {lock_path}",
203
+ file=sys.stderr,
167
204
  )
168
- )
169
- return 1
205
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
206
+ try:
207
+ yield waited
208
+ finally:
209
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
210
+
211
+
212
+ def _apply_validated(context, harvest, *, queue_waited=False):
213
+ repo_root = context["repo_root"]
214
+ branch_name = context["branch_name"]
215
+ branch_key = context["branch_key"]
216
+ repo_dir = context["repo_dir"]
217
+ branch_dir = context["branch_dir"]
218
+
219
+ # The branch may have been archived by an earlier queued apply while this
220
+ # process was waiting. Re-check inside the critical section before writing.
221
+ if not branch_dir.exists():
222
+ raise ValueError(f"branch memory not initialized: {branch_dir}")
170
223
 
171
224
  paths = asset_paths(repo_dir, branch_dir)
172
225
 
@@ -226,10 +279,20 @@ def command_apply(args):
226
279
  "harvested_total": total,
227
280
  "archive_summary": str((archive_dst or branch_dir) / "archive_summary.md"),
228
281
  "archived_to": str(archive_dst) if archive_dst else None,
282
+ "queue_waited": queue_waited,
229
283
  }, ensure_ascii=False, indent=2))
230
284
  return 0
231
285
 
232
286
 
287
+ def command_apply(args):
288
+ # Pre-flight first: bad input should return immediately even if another
289
+ # session is currently applying a valid graduate harvest.
290
+ context = _resolve_apply_context(args)
291
+ harvest = _load_harvest(args.harvest_file)
292
+ with _graduate_apply_queue(context["repo_dir"]) as waited:
293
+ return _apply_validated(context, harvest, queue_waited=waited)
294
+
295
+
233
296
  def command_index(args):
234
297
  if detect_no_git_mode(args.repo):
235
298
  print(json.dumps({"error": "no-git mode: no archive index"}, ensure_ascii=False))