@michelj/context-guard 0.4.3 → 0.6.1

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