@michelj/context-guard 0.4.2 → 0.4.4

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.
Files changed (36) hide show
  1. package/README.md +113 -150
  2. package/README.zh-CN.md +113 -150
  3. package/SKILL.md +46 -0
  4. package/agents/openai.yaml +4 -0
  5. package/bin/context-guard-skill.js +234 -46
  6. package/bin/postinstall.js +1 -1
  7. package/hooks.json +29 -5
  8. package/package.json +12 -5
  9. package/prototype/workbench.html +4933 -0
  10. package/references/bug-record-template.md +37 -0
  11. package/references/context-template.md +19 -0
  12. package/scripts/context_guard.py +806 -0
  13. package/scripts/context_guard_hook.py +302 -0
  14. package/scripts/map_owns.py +769 -0
  15. package/skills/context-guard/README.md +0 -234
  16. package/skills/context-guard/README.zh-CN.md +0 -234
  17. package/skills/context-guard/SKILL.md +0 -558
  18. package/skills/context-guard/agents/openai.yaml +0 -4
  19. package/skills/context-guard/references/context-template.md +0 -290
  20. package/skills/context-guard/references/register-template.md +0 -85
  21. package/skills/context-guard/references/task-case-template.md +0 -63
  22. package/skills/context-guard/scripts/context_guard.py +0 -4886
  23. package/skills/context-guard/scripts/context_guard_hook.py +0 -522
  24. package/skills/context-guard/tests/BC-20260618-063.sh +0 -116
  25. package/skills/context-guard/tests/BC-20260618-065.sh +0 -66
  26. package/skills/context-guard/tests/BC-20260626-080.sh +0 -48
  27. package/skills/context-guard/tests/BC-20260626-081.sh +0 -40
  28. package/skills/context-guard/tests/BC-20260626-082.sh +0 -32
  29. package/skills/context-guard/tests/BC-20260626-083.sh +0 -66
  30. package/skills/context-guard/tests/BC-20260627-084.sh +0 -74
  31. package/skills/context-guard/tests/BC-20260630-086.sh +0 -50
  32. package/skills/context-guard/tests/BC-20260630-087.sh +0 -103
  33. package/skills/context-guard/tests/BC-20260630-088.sh +0 -32
  34. package/skills/context-guard/tests/BC-20260630-089.sh +0 -63
  35. package/skills/context-guard/tests/BC-20260701-090.sh +0 -83
  36. package/skills/context-guard/tests/BC-20260702-096.sh +0 -45
@@ -0,0 +1,769 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve a repo-relative path to the map node that owns it.
3
+
4
+ owns = source ownership (file or directory prefix ending in /).
5
+ files on a node remain evidence attachments, not ownership.
6
+
7
+ Longest match wins. Exact file beats a parent directory.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ CG_OWNS = {
18
+ "N11": ["SKILL.md"],
19
+ "N21": ["prototype/workbench.html"],
20
+ "M21": ["prototype/"],
21
+ "N31": [".codex/context/index.md"],
22
+ "N32": [".codex/context/preferences.json"],
23
+ "N33": [".codex/context/architecture.md"],
24
+ "N34": [".codex/context/user-messages.md"],
25
+ "N35": [".codex/context/private/"],
26
+ "N36": [".codex/context/map.json"],
27
+ "M3": [".codex/context/"],
28
+ "N41": ["scripts/context_guard.py"],
29
+ "N43": ["scripts/context_guard_hook.py"],
30
+ "M4": ["scripts/"],
31
+ "N51": ["README.md"],
32
+ }
33
+
34
+
35
+ def norm_repo_path(path: str) -> str:
36
+ p = str(path or "").replace("\\", "/").strip()
37
+ if p.startswith("./"):
38
+ p = p[2:]
39
+ return p.lstrip("/")
40
+
41
+
42
+ def own_score(owned: str, file: str) -> int:
43
+ o, f = norm_repo_path(owned), norm_repo_path(file)
44
+ if not o or not f:
45
+ return 0
46
+ if f == o:
47
+ return 10000 + len(o)
48
+ directory = o if o.endswith("/") else o + "/"
49
+ if f.startswith(directory):
50
+ return 1000 + len(directory)
51
+ return 0
52
+
53
+
54
+ def walk_nodes(node, parents=None):
55
+ if not node:
56
+ return
57
+ chain = (parents or []) + [node]
58
+ yield node, chain
59
+ for child in node.get("children") or []:
60
+ yield from walk_nodes(child, chain)
61
+ for child in node.get("_inbox") or []:
62
+ yield from walk_nodes(child, chain)
63
+
64
+
65
+ def load_map(root: Path) -> dict:
66
+ path = root / ".codex" / "context" / "map.json"
67
+ return json.loads(path.read_text(encoding="utf-8"))
68
+
69
+
70
+ def lookup(doc: dict, file: str) -> dict | None:
71
+ file = norm_repo_path(file)
72
+ best = None
73
+ score = 0
74
+ ties: list[dict] = []
75
+ root = doc.get("root") or doc
76
+ for node, chain in walk_nodes(root):
77
+ if node.get("proposal") == "cancelled":
78
+ continue
79
+ for owned in node.get("owns") or []:
80
+ s = own_score(owned, file)
81
+ if not s:
82
+ continue
83
+ if s > score:
84
+ score = s
85
+ best = (node, chain)
86
+ ties = [node]
87
+ elif s == score and best and best[0].get("id") != node.get("id"):
88
+ ties.append(node)
89
+ if not best:
90
+ return None
91
+ node, chain = best
92
+ if len(ties) > 1:
93
+ ties.sort(key=lambda n: (0 if n.get("kind") == "work" else 1, -len(str(n.get("id") or ""))))
94
+ node = ties[0]
95
+ chain = next(c for n, c in walk_nodes(root) if n.get("id") == node.get("id"))
96
+ def mem_rows(n):
97
+ return [
98
+ {
99
+ "id": m.get("id"),
100
+ "text": m.get("text"),
101
+ "state": m.get("state"),
102
+ "record": m.get("record"),
103
+ }
104
+ for m in (n.get("memories") or [])
105
+ if isinstance(m, dict)
106
+ ]
107
+
108
+ def bug_rows(n):
109
+ return [
110
+ {
111
+ "id": b.get("id"),
112
+ "title": b.get("title"),
113
+ "status": b.get("status"),
114
+ "record": b.get("record"),
115
+ }
116
+ for b in (n.get("bugs") or [])
117
+ if isinstance(b, dict) and b.get("status") != "dormant"
118
+ ]
119
+
120
+ extra_bugs = []
121
+ nid = node.get("id")
122
+ for n, _ in walk_nodes(root):
123
+ if n.get("id") == nid:
124
+ continue
125
+ for b in n.get("bugs") or []:
126
+ if not isinstance(b, dict) or b.get("status") == "dormant":
127
+ continue
128
+ if nid in also_ids(b):
129
+ extra_bugs.append(
130
+ {
131
+ "id": b.get("id"),
132
+ "title": b.get("title"),
133
+ "status": b.get("status"),
134
+ "record": b.get("record"),
135
+ "home": n.get("id"),
136
+ }
137
+ )
138
+ extra_mems = []
139
+ for n, _ in walk_nodes(root):
140
+ if n.get("id") == nid:
141
+ continue
142
+ for m in n.get("memories") or []:
143
+ if not isinstance(m, dict):
144
+ continue
145
+ if nid in also_ids(m):
146
+ extra_mems.append(
147
+ {
148
+ "id": m.get("id"),
149
+ "text": m.get("text"),
150
+ "state": m.get("state"),
151
+ "record": m.get("record"),
152
+ "home": n.get("id"),
153
+ }
154
+ )
155
+
156
+ return {
157
+ "path": file,
158
+ "node_id": node.get("id"),
159
+ "title": node.get("title"),
160
+ "kind": node.get("kind"),
161
+ "owns": node.get("owns") or [],
162
+ "conflict": len({n.get("id") for n in ties}) > 1,
163
+ "tie_ids": [n.get("id") for n in ties] if len(ties) > 1 else [],
164
+ "memories": mem_rows(node),
165
+ "also_memories": extra_mems,
166
+ "bugs": bug_rows(node),
167
+ "also_bugs": extra_bugs,
168
+ "ancestors": [
169
+ {
170
+ "id": a.get("id"),
171
+ "title": a.get("title"),
172
+ "memories": mem_rows(a),
173
+ "bugs": bug_rows(a),
174
+ }
175
+ for a in chain[:-1]
176
+ ],
177
+ }
178
+
179
+
180
+ def also_ids(obj) -> list[str]:
181
+ raw = (obj or {}).get("also") or []
182
+ if isinstance(raw, str):
183
+ raw = [p.strip() for p in raw.replace(",", ",").split(",") if p.strip()]
184
+ return [str(x) for x in raw if x]
185
+
186
+
187
+ def stamp_owns(node: dict, table: dict) -> None:
188
+ if node.get("id") in table:
189
+ node["owns"] = list(table[node["id"]])
190
+ else:
191
+ node.pop("owns", None)
192
+ for child in node.get("children") or []:
193
+ stamp_owns(child, table)
194
+ for child in node.get("_inbox") or []:
195
+ stamp_owns(child, table)
196
+
197
+
198
+ def build_owns_index(doc: dict) -> dict:
199
+ rows = []
200
+ root = doc.get("root") or doc
201
+ for node, _ in walk_nodes(root):
202
+ if node.get("proposal") == "cancelled":
203
+ continue
204
+ for owned in node.get("owns") or []:
205
+ rows.append(
206
+ {
207
+ "path": owned,
208
+ "node": node.get("id"),
209
+ "kind": node.get("kind"),
210
+ "title": node.get("title"),
211
+ }
212
+ )
213
+ return {"owns": rows}
214
+
215
+
216
+ def write_owns_index(root: Path) -> Path:
217
+ dest = root / ".codex" / "context" / "owns-index.json"
218
+ dest.write_text(
219
+ json.dumps(build_owns_index(load_map(root)), ensure_ascii=False, indent=2) + "\n",
220
+ encoding="utf-8",
221
+ )
222
+ return dest
223
+
224
+
225
+ def stamp_map(root: Path) -> Path:
226
+ path = root / ".codex" / "context" / "map.json"
227
+ doc = json.loads(path.read_text(encoding="utf-8"))
228
+ if doc.get("root"):
229
+ stamp_owns(doc["root"], CG_OWNS)
230
+ path.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
231
+ return path
232
+
233
+
234
+ def flow_neighbors(doc: dict, nid: str) -> list[dict]:
235
+ seen = set()
236
+ rows = []
237
+ for f in doc.get("flows") or []:
238
+ a, b = f.get("from"), f.get("to")
239
+ other = None
240
+ if a == nid:
241
+ other = b
242
+ elif b == nid:
243
+ other = a
244
+ if not other or other in seen:
245
+ continue
246
+ seen.add(other)
247
+ rows.append({"id": other, "label": f.get("label") or ""})
248
+ return rows
249
+
250
+
251
+ def card_markdown(node: dict, chain: list, doc: dict) -> str:
252
+ nid = str(node.get("id") or "")
253
+ parent = chain[-2] if len(chain) > 1 else None
254
+ chain_s = " > ".join(
255
+ f"{n.get('id')} {n.get('title')}" for n in chain if n.get("id")
256
+ )
257
+ owns = node.get("owns") or []
258
+ related = flow_neighbors(doc, nid)
259
+ kids = []
260
+ for c in (node.get("children") or []) + (node.get("_inbox") or []):
261
+ if c.get("proposal") == "cancelled":
262
+ continue
263
+ kids.append(f"- {c.get('id')} {c.get('title')}")
264
+ mems = []
265
+ for m in node.get("memories") or []:
266
+ if isinstance(m, dict) and m.get("text"):
267
+ extra = also_ids(m)
268
+ tag = f" · 也挂 {', '.join(extra)}" if extra else ""
269
+ mems.append(f"- {m.get('text')}{tag}")
270
+ ideas = []
271
+ for m in node.get("ideas") or []:
272
+ if isinstance(m, dict) and m.get("text"):
273
+ ideas.append(f"- {m.get('text')}")
274
+ bugs = []
275
+ for b in node.get("bugs") or []:
276
+ if not isinstance(b, dict) or b.get("status") == "dormant":
277
+ continue
278
+ bid = b.get("id")
279
+ extra = also_ids(b)
280
+ tag = f" · 也挂 {', '.join(extra)}" if extra else ""
281
+ bugs.append(
282
+ f"- [{bid}](../bugs/{bid}.md) → [经验](../fixes/{bid}.md) {b.get('title') or ''}{tag}"
283
+ )
284
+ tree = doc.get("root") or doc
285
+ for n, _ in walk_nodes(tree):
286
+ if n.get("id") == nid or n.get("proposal") == "cancelled":
287
+ continue
288
+ for m in n.get("memories") or []:
289
+ if isinstance(m, dict) and nid in also_ids(m) and m.get("text"):
290
+ mems.append(f"- (主卡 {n.get('id')}){m.get('text')}")
291
+ for b in n.get("bugs") or []:
292
+ if not isinstance(b, dict) or b.get("status") == "dormant":
293
+ continue
294
+ if nid in also_ids(b):
295
+ bid = b.get("id")
296
+ bugs.append(
297
+ f"- [{bid}](../bugs/{bid}.md) → [经验](../fixes/{bid}.md) {b.get('title') or ''} · 主卡 {n.get('id')}"
298
+ )
299
+ rel_s = ", ".join(
300
+ f"{r['id']}({r['label']})" if r.get("label") else r["id"] for r in related
301
+ ) or "(none)"
302
+ lines = [
303
+ f"# {nid} {node.get('title') or ''}",
304
+ "",
305
+ f"- kind: {node.get('kind') or ''}",
306
+ f"- parent: {parent.get('id') if parent else '(root)'}",
307
+ f"- chain: {chain_s}",
308
+ f"- owns: {', '.join(owns) if owns else '(none)'}",
309
+ f"- related: {rel_s}",
310
+ f"- card: .codex/context/cards/{nid}.md",
311
+ "",
312
+ "## 记忆",
313
+ "",
314
+ "\n".join(mems) if mems else "(无)",
315
+ "",
316
+ ]
317
+ if ideas:
318
+ lines.extend(["## Idea", "", "\n".join(ideas), ""])
319
+ lines.extend([
320
+ "## Bug",
321
+ "",
322
+ "\n".join(bugs) if bugs else "(无)",
323
+ "",
324
+ "## 孩子",
325
+ "",
326
+ "\n".join(kids) if kids else "(无)",
327
+ "",
328
+ ])
329
+ return "\n".join(lines)
330
+
331
+
332
+ def write_cards(root: Path) -> Path:
333
+ ctx = root / ".codex" / "context"
334
+ dest = ctx / "cards"
335
+ dest.mkdir(parents=True, exist_ok=True)
336
+ for old in dest.glob("*.md"):
337
+ old.unlink()
338
+ doc = load_map(root)
339
+ tree = doc.get("root") or doc
340
+ for node, chain in walk_nodes(tree):
341
+ if node.get("proposal") == "cancelled" or not node.get("id"):
342
+ continue
343
+ (dest / f"{node['id']}.md").write_text(
344
+ card_markdown(node, chain, doc), encoding="utf-8"
345
+ )
346
+ write_owns_index(root)
347
+ write_bugs_index(root)
348
+ write_tasks_index(root)
349
+ write_jump_index(root)
350
+ return dest
351
+
352
+
353
+ def parse_md_fields(text: str) -> dict:
354
+ fields: dict[str, str] = {}
355
+ for i, line in enumerate(text.splitlines()):
356
+ if i == 0 and line.startswith("# "):
357
+ fields["_title"] = line[2:].strip()
358
+ continue
359
+ if line.startswith("- ") and ":" in line:
360
+ key, _, val = line[2:].partition(":")
361
+ fields[key.strip()] = val.strip()
362
+ return fields
363
+
364
+
365
+ def write_bugs_index(root: Path) -> Path:
366
+ ctx = root / ".codex" / "context"
367
+ bugs_dir = ctx / "bugs"
368
+ index: dict = {}
369
+ if bugs_dir.is_dir():
370
+ for path in sorted(bugs_dir.glob("B*.md")):
371
+ fields = parse_md_fields(path.read_text(encoding="utf-8"))
372
+ bid = path.stem
373
+ title = fields.get("_title") or bid
374
+ if title.startswith(bid + " "):
375
+ title = title[len(bid) + 1 :]
376
+ keys = [k.strip() for k in (fields.get("keys") or "").split(",") if k.strip()]
377
+ index[bid] = {
378
+ "title": title,
379
+ "keys": keys,
380
+ "status": fields.get("status") or "",
381
+ "bug": f".codex/context/bugs/{bid}.md",
382
+ "fix": fields.get("fix") or f".codex/context/fixes/{bid}.md",
383
+ "card": fields.get("card") or "",
384
+ }
385
+ dest = ctx / "bugs-index.json"
386
+ dest.write_text(json.dumps(index, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
387
+ return dest
388
+
389
+
390
+ def write_tasks_index(root: Path) -> Path:
391
+ ctx = root / ".codex" / "context"
392
+ tasks_dir = ctx / "tasks"
393
+ index: dict = {}
394
+ if tasks_dir.is_dir():
395
+ for path in sorted(tasks_dir.glob("J*.md")):
396
+ fields = parse_md_fields(path.read_text(encoding="utf-8"))
397
+ jid = path.stem
398
+ title = fields.get("_title") or jid
399
+ if title.startswith(jid + " "):
400
+ title = title[len(jid) + 1 :]
401
+ keys = [k.strip() for k in (fields.get("keys") or "").split(",") if k.strip()]
402
+ chain = [p.strip() for p in (fields.get("chain") or "").replace(">", " ").split() if p.strip()]
403
+ index[jid] = {
404
+ "title": title,
405
+ "keys": keys,
406
+ "task": f".codex/context/tasks/{jid}.md",
407
+ "chain": chain,
408
+ "card": fields.get("card") or "",
409
+ }
410
+ dest = ctx / "tasks-index.json"
411
+ dest.write_text(json.dumps(index, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
412
+ return dest
413
+
414
+
415
+ def write_jump_index(root: Path) -> Path:
416
+ ctx = ctx_dir(root)
417
+ owns = []
418
+ map_path = ctx / "map.json"
419
+ if map_path.is_file():
420
+ doc = load_map(root)
421
+ tree = doc.get("root") or doc
422
+ for node, chain in walk_nodes(tree):
423
+ if node.get("proposal") == "cancelled" or not node.get("id"):
424
+ continue
425
+ ancestors = [n.get("id") for n in chain[:-1] if n.get("id")]
426
+ for owned in node.get("owns") or []:
427
+ owns.append(
428
+ {
429
+ "path": owned,
430
+ "node": node.get("id"),
431
+ "kind": node.get("kind"),
432
+ "card": f".codex/context/cards/{node['id']}.md",
433
+ "chain": ancestors,
434
+ }
435
+ )
436
+ else:
437
+ for r in read_json(ctx / "owns-index.json", {"owns": []}).get("owns") or []:
438
+ if not r.get("path") or not r.get("node"):
439
+ continue
440
+ owns.append(
441
+ {
442
+ "path": r.get("path"),
443
+ "node": r.get("node"),
444
+ "kind": r.get("kind"),
445
+ "card": f".codex/context/cards/{r.get('node')}.md",
446
+ "chain": r.get("chain") or [],
447
+ }
448
+ )
449
+ packed = {
450
+ "owns": owns,
451
+ "bugs": read_json(ctx / "bugs-index.json", {}),
452
+ "tasks": read_json(ctx / "tasks-index.json", {}),
453
+ }
454
+ dest = ctx / "jump-index.json"
455
+ dest.write_text(json.dumps(packed, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
456
+ return dest
457
+
458
+
459
+ def load_packed(root: Path) -> dict:
460
+ ctx = ctx_dir(root)
461
+ dest = ctx / "jump-index.json"
462
+ if dest.is_file():
463
+ return read_json(dest, {"owns": [], "bugs": {}, "tasks": {}})
464
+ owns = []
465
+ for r in read_json(ctx / "owns-index.json", {"owns": []}).get("owns") or []:
466
+ if not r.get("path") or not r.get("node"):
467
+ continue
468
+ owns.append(
469
+ {
470
+ "path": r["path"],
471
+ "node": r["node"],
472
+ "kind": r.get("kind"),
473
+ "card": f".codex/context/cards/{r['node']}.md",
474
+ "chain": r.get("chain") or [],
475
+ }
476
+ )
477
+ return {
478
+ "owns": owns,
479
+ "bugs": read_json(ctx / "bugs-index.json", {}),
480
+ "tasks": read_json(ctx / "tasks-index.json", {}),
481
+ }
482
+
483
+
484
+ def lookup_owns(rows: list, file: str) -> dict | None:
485
+ file = norm_repo_path(file)
486
+ best = None
487
+ score = 0
488
+ ties: list[dict] = []
489
+ for row in rows or []:
490
+ s = own_score(row.get("path") or "", file)
491
+ if not s:
492
+ continue
493
+ if s > score:
494
+ score = s
495
+ best = row
496
+ ties = [row]
497
+ elif s == score and best and row.get("node") != best.get("node"):
498
+ ties.append(row)
499
+ if not best:
500
+ return None
501
+ if len(ties) > 1:
502
+ ties.sort(key=lambda r: (0 if r.get("kind") == "work" else 1, -len(str(r.get("node") or ""))))
503
+ best = ties[0]
504
+ return best
505
+
506
+
507
+ def find_bug_card(doc: dict, bug_id: str) -> dict | None:
508
+ root = doc.get("root") or doc
509
+ for node, chain in walk_nodes(root):
510
+ for b in node.get("bugs") or []:
511
+ if isinstance(b, dict) and b.get("id") == bug_id:
512
+ return {"bug": b, "node": node, "chain": chain}
513
+ return None
514
+
515
+
516
+ def ctx_dir(root: Path) -> Path:
517
+ return root / ".codex" / "context"
518
+
519
+
520
+ def read_json(path: Path, default):
521
+ if not path.is_file():
522
+ return default
523
+ return json.loads(path.read_text(encoding="utf-8"))
524
+
525
+
526
+ def best_index_match(query: str, index: dict) -> str | None:
527
+ q = (query or "").strip().lower()
528
+ if not q:
529
+ return None
530
+ if q in {k.lower(): k for k in index}:
531
+ for k in index:
532
+ if k.lower() == q:
533
+ return k
534
+ best, score = None, 0
535
+ for kid, row in index.items():
536
+ s = 0
537
+ title = str(row.get("title") or "").lower()
538
+ if q == kid.lower():
539
+ return kid
540
+ if q in title:
541
+ s += 3
542
+ for key in row.get("keys") or []:
543
+ kl = str(key).lower()
544
+ if kl == q:
545
+ s += 5
546
+ elif kl in q or q in kl:
547
+ s += 2
548
+ if s > score:
549
+ best, score = kid, s
550
+ return best if score else None
551
+
552
+
553
+ def as_query_list(value) -> list[str]:
554
+ if value is None or value is False:
555
+ return []
556
+ if isinstance(value, (list, tuple)):
557
+ return [str(x).strip() for x in value if str(x).strip()]
558
+ text = str(value).strip()
559
+ return [text] if text else []
560
+
561
+
562
+ def parse_jump_json(raw: str) -> dict:
563
+ text = (raw or "").strip()
564
+ if text.startswith("@"):
565
+ text = Path(text[1:]).expanduser().read_text(encoding="utf-8")
566
+ data = json.loads(text)
567
+ if not isinstance(data, dict):
568
+ raise ValueError("jump --json must be a JSON object")
569
+ return data
570
+
571
+
572
+ def jump_many(root: Path, queries: dict | None = None) -> dict:
573
+ queries = queries or {}
574
+ packed = load_packed(root)
575
+ hits = []
576
+ for item in as_query_list(queries.get("path")):
577
+ hits.append(jump(root, path=item, packed=packed))
578
+ for item in as_query_list(queries.get("bug")):
579
+ hits.append(jump(root, bug=item, packed=packed))
580
+ for item in as_query_list(queries.get("task")):
581
+ hits.append(jump(root, task=item, packed=packed))
582
+ if queries.get("last"):
583
+ hits.append(jump(root, last=True, packed=packed))
584
+ if not hits:
585
+ return {
586
+ "error": "jump --json needs path, bug, task, or last",
587
+ "hits": [],
588
+ "open": [],
589
+ "then": [],
590
+ }
591
+ open_files: list[str] = []
592
+ then: list[str] = []
593
+ seen: set[str] = set()
594
+
595
+ def absorb(items, dest):
596
+ for item in items or []:
597
+ if item and item not in seen:
598
+ seen.add(item)
599
+ dest.append(item)
600
+
601
+ for hit in hits:
602
+ absorb(hit.get("open"), open_files)
603
+ absorb(hit.get("then"), then)
604
+ return {"hits": hits, "open": open_files, "then": then}
605
+
606
+
607
+ def jump(root: Path, *, path="", bug="", task="", last=False, packed=None) -> dict:
608
+ ctx = ctx_dir(root)
609
+ packed = packed if packed is not None else load_packed(root)
610
+ if last:
611
+ lines = []
612
+ sess = ctx / "sessions.jsonl"
613
+ if sess.is_file():
614
+ lines = [ln for ln in sess.read_text(encoding="utf-8").splitlines() if ln.strip()]
615
+ if not lines:
616
+ return {"kind": "session", "open": [], "then": []}
617
+ row = json.loads(lines[-1])
618
+ open_files: list[str] = []
619
+ then: list[str] = []
620
+ for tid in row.get("tasks") or []:
621
+ open_files.append(f".codex/context/tasks/{tid}.md")
622
+ for bid in row.get("bugs") or []:
623
+ open_files.append(f".codex/context/bugs/{bid}.md")
624
+ then.append(f".codex/context/fixes/{bid}.md")
625
+ then.extend(row.get("files") or [])
626
+ if not open_files:
627
+ open_files = then[:2]
628
+ then = then[2:]
629
+ return {
630
+ "kind": "session",
631
+ "id": row.get("id"),
632
+ "open": open_files[:3],
633
+ "then": then[:6],
634
+ }
635
+ if bug:
636
+ index = packed.get("bugs") or {}
637
+ bid = bug.strip()
638
+ if bid not in index:
639
+ bid = best_index_match(bug, index) or ""
640
+ if not bid or bid not in index:
641
+ return {"kind": "bug", "query": bug, "open": [], "then": []}
642
+ row = index[bid]
643
+ open_files = [p for p in (row.get("bug"), row.get("fix")) if p]
644
+ then = [row["card"]] if row.get("card") else []
645
+ return {"kind": "bug", "id": bid, "open": open_files, "then": then}
646
+ if task:
647
+ index = packed.get("tasks") or {}
648
+ jid = task.strip()
649
+ if jid not in index:
650
+ jid = best_index_match(task, index) or ""
651
+ if not jid or jid not in index:
652
+ return {"kind": "task", "query": task, "open": [], "then": []}
653
+ row = index[jid]
654
+ open_files = [p for p in (row.get("task"),) if p]
655
+ then = [f".codex/context/cards/{nid}.md" for nid in (row.get("chain") or [])]
656
+ if row.get("card") and row["card"] not in then:
657
+ then.append(row["card"])
658
+ return {"kind": "task", "id": jid, "open": open_files, "then": then}
659
+ if path:
660
+ row = lookup_owns(packed.get("owns") or [], path)
661
+ if not row:
662
+ return {"kind": "path", "query": path, "open": [], "then": []}
663
+ nid = row.get("node")
664
+ card = row.get("card") or f".codex/context/cards/{nid}.md"
665
+ ancestors = [a for a in (row.get("chain") or []) if a and a != nid]
666
+ then = [f".codex/context/cards/{a}.md" for a in reversed(ancestors)][:3]
667
+ return {"kind": "path", "id": nid, "open": [card], "then": then}
668
+ return {"error": "jump needs --path, --bug, --task, --last, or --json", "open": [], "then": []}
669
+
670
+
671
+ def main() -> int:
672
+ parser = argparse.ArgumentParser(description="Map path ownership for Context Guard")
673
+ parser.add_argument(
674
+ "command",
675
+ choices=["lookup", "stamp", "index", "cards", "where", "bugs-index", "tasks-index", "jump-index", "jump"],
676
+ )
677
+ parser.add_argument("--path", default="", help="Repo-relative file to look up")
678
+ parser.add_argument("--bug", default="", help="Bug id such as B20, or a keyword")
679
+ parser.add_argument("--task", default="", help="Task id such as J1, or a keyword")
680
+ parser.add_argument("--last", action="store_true", help="Jump from the latest sessions.jsonl line")
681
+ parser.add_argument(
682
+ "--json",
683
+ default="",
684
+ dest="jump_json",
685
+ help='Batch queries JSON or @file: {"path":[...],"bug":[...],"task":[...],"last":true}',
686
+ )
687
+ parser.add_argument("--root", type=Path, default=None)
688
+ args = parser.parse_args()
689
+ root = (args.root or Path.cwd()).resolve()
690
+ if args.command == "stamp":
691
+ print(stamp_map(root))
692
+ return 0
693
+ if args.command == "index":
694
+ print(write_owns_index(root))
695
+ return 0
696
+ if args.command == "cards":
697
+ print(write_cards(root))
698
+ return 0
699
+ if args.command == "bugs-index":
700
+ print(write_bugs_index(root))
701
+ return 0
702
+ if args.command == "tasks-index":
703
+ print(write_tasks_index(root))
704
+ return 0
705
+ if args.command == "jump-index":
706
+ print(write_jump_index(root))
707
+ return 0
708
+ if args.command == "jump":
709
+ if args.jump_json:
710
+ try:
711
+ result = jump_many(root, parse_jump_json(args.jump_json))
712
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
713
+ result = {"error": str(exc), "hits": [], "open": [], "then": []}
714
+ else:
715
+ result = jump(
716
+ root, path=args.path, bug=args.bug, task=args.task, last=args.last
717
+ )
718
+ print(json.dumps(result, ensure_ascii=False, indent=2))
719
+ return 1 if result.get("error") or not (result.get("open") or result.get("then")) else 0
720
+ if args.command == "where":
721
+ doc = load_map(root)
722
+ if args.bug:
723
+ hit = find_bug_card(doc, args.bug)
724
+ if not hit:
725
+ print(json.dumps({"bug": args.bug, "card": None}, ensure_ascii=False, indent=2))
726
+ return 1
727
+ nid = hit["node"].get("id")
728
+ also = also_ids(hit["bug"])
729
+ print(
730
+ json.dumps(
731
+ {
732
+ "bug": args.bug,
733
+ "bug_file": f".codex/context/bugs/{args.bug}.md",
734
+ "fix_file": f".codex/context/fixes/{args.bug}.md",
735
+ "node_id": nid,
736
+ "card": f".codex/context/cards/{nid}.md",
737
+ "also": also,
738
+ "also_cards": [f".codex/context/cards/{x}.md" for x in also],
739
+ "chain": [n.get("id") for n in hit["chain"]],
740
+ },
741
+ ensure_ascii=False,
742
+ indent=2,
743
+ )
744
+ )
745
+ return 0
746
+ if not args.path:
747
+ print("where needs --path or --bug", file=sys.stderr)
748
+ return 2
749
+ hit = lookup(doc, args.path)
750
+ if not hit:
751
+ print(json.dumps({"path": norm_repo_path(args.path), "card": None}, ensure_ascii=False, indent=2))
752
+ return 1
753
+ nid = hit["node_id"]
754
+ hit["card"] = f".codex/context/cards/{nid}.md"
755
+ print(json.dumps(hit, ensure_ascii=False, indent=2))
756
+ return 0
757
+ if not args.path:
758
+ print("lookup needs --path", file=sys.stderr)
759
+ return 2
760
+ hit = lookup(load_map(root), args.path)
761
+ if not hit:
762
+ print(json.dumps({"path": norm_repo_path(args.path), "node_id": None}, ensure_ascii=False, indent=2))
763
+ return 1
764
+ print(json.dumps(hit, ensure_ascii=False, indent=2))
765
+ return 0
766
+
767
+
768
+ if __name__ == "__main__":
769
+ raise SystemExit(main())