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
package/lib/dev_memory_common.py
CHANGED
|
@@ -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 =
|
|
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
|
-
"
|
|
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("
|
|
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.
|
|
1342
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
|
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
|
|
1463
|
-
drop the wider parent. The wider
|
|
1464
|
-
from an earlier roll-up that
|
|
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
|
|
1468
|
-
|
|
1469
|
-
|
|
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`.
|
|
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 =
|
|
1491
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
1582
|
+
remaining_budget = max(1, limit - len(active_existing_weights))
|
|
1521
1583
|
|
|
1522
1584
|
new_buckets = Counter()
|
|
1523
1585
|
for p in uncovered:
|
|
1524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1645
|
+
selected = []
|
|
1588
1646
|
seen = set()
|
|
1589
|
-
for d,
|
|
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
|
-
|
|
1601
|
-
|
|
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"- `
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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))
|