@michelj/context-guard 0.4.4 → 0.6.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/Coordinator.md +88 -0
- package/Executor.md +53 -0
- package/README.md +72 -102
- package/README.zh-CN.md +72 -102
- package/SKILL.md +26 -33
- package/THIRD_PARTY_NOTICES.md +47 -0
- package/Tester.md +53 -0
- package/bin/build-runtime.mjs +96 -0
- package/bin/context-guard-skill.js +287 -69
- package/bin/postinstall.js +1 -1
- package/hooks.json +80 -4
- package/licenses/JSONParse-MIT.txt +24 -0
- package/licenses/Marked-MIT.txt +44 -0
- package/licenses/Portless-Apache-2.0.txt +201 -0
- package/package.json +31 -5
- package/prototype/LICENSES/Marked-MIT.txt +44 -0
- package/prototype/LICENSES/Ready-redistribution.txt +14 -0
- package/prototype/attachments.mjs +75 -0
- package/prototype/coordinator-markdown.mjs +283 -0
- package/prototype/coordinator-working-blot.mjs +124 -0
- package/prototype/vendor/marked.mjs +2189 -0
- package/prototype/workbench-app.js +5197 -0
- package/prototype/workbench-data.js +33 -0
- package/prototype/workbench-sync.mjs +898 -0
- package/prototype/workbench.css +1050 -0
- package/prototype/workbench.html +139 -4861
- package/prototype/working-blot-atlas.png +0 -0
- package/references/agent-handoff.md +40 -0
- package/references/claude-runtime.md +120 -0
- package/references/cloud-sync-interface.md +66 -0
- package/references/design-current.md +14 -0
- package/references/map-mount.md +41 -0
- package/references/map-read.md +50 -0
- package/references/memory-definition.md +120 -0
- package/references/memory-filesystem-v2/Bug.en.md +162 -0
- package/references/memory-filesystem-v2/Bug.md +162 -0
- package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
- package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
- package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
- package/references/memory-filesystem-v2/Idea.en.md +36 -0
- package/references/memory-filesystem-v2/Idea.md +36 -0
- package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
- package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
- package/references/memory-filesystem-v2/README.md +60 -0
- package/references/memory-filesystem-v2/Todo.en.md +137 -0
- package/references/memory-filesystem-v2/Todo.md +137 -0
- package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
- package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
- package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
- package/references/named-workbench.md +124 -0
- package/references/plan-review.md +12 -0
- package/references/server-memory.md +276 -0
- package/references/test-check.md +7 -0
- package/references/user-reply.md +38 -0
- package/references/workbench-interface.md +531 -0
- package/roles.md +13 -0
- package/scripts/context_guard.py +1163 -321
- package/scripts/context_guard_hook.py +1864 -63
- package/scripts/map_owns.py +68 -138
- package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
- package/scripts/shared/filesystem-v2.mjs +430 -0
- package/scripts/shared/io.mjs +117 -0
- package/scripts/shared/map-model.mjs +506 -0
- package/scripts/shared/memory-schema.mjs +13 -0
- package/scripts/shared/protocol-blobs.mjs +112 -0
- package/scripts/shared/protocol-map.mjs +146 -0
- package/scripts/shared/protocol-snapshots.mjs +84 -0
- package/scripts/shared/protocol-store.mjs +624 -0
- package/scripts/shared/protocol-workflow.mjs +226 -0
- package/scripts/shared/protocol.mjs +125 -0
- package/scripts/shared/vendor/jsonparse.cjs +413 -0
- package/scripts/workbench/access.mjs +496 -0
- package/scripts/workbench/attachments.mjs +92 -0
- package/scripts/workbench/browser-login.mjs +78 -0
- package/scripts/workbench/claude-runtime.mjs +372 -0
- package/scripts/workbench/cli.mjs +980 -0
- package/scripts/workbench/device-heartbeat.mjs +72 -0
- package/scripts/workbench/hook-status.mjs +38 -0
- package/scripts/workbench/inbox.mjs +155 -0
- package/scripts/workbench/journal.mjs +56 -0
- package/scripts/workbench/memory-merge.mjs +65 -0
- package/scripts/workbench/memory.mjs +252 -0
- package/scripts/workbench/named-proxy.mjs +108 -0
- package/scripts/workbench/named.mjs +152 -0
- package/scripts/workbench/portless-routes.mjs +51 -0
- package/scripts/workbench/project.mjs +327 -0
- package/scripts/workbench/projections.mjs +68 -0
- package/scripts/workbench/protocol-client.mjs +165 -0
- package/scripts/workbench/protocol-delivery.mjs +133 -0
- package/scripts/workbench/protocol-device.mjs +316 -0
- package/scripts/workbench/protocol-events.mjs +53 -0
- package/scripts/workbench/protocol-repository.mjs +58 -0
- package/scripts/workbench/reconcile.mjs +244 -0
- package/scripts/workbench/registry.mjs +111 -0
- package/scripts/workbench/runtime.mjs +54 -0
- package/scripts/workbench/server.mjs +1171 -0
- package/scripts/workbench/store.mjs +243 -0
- package/scripts/workbench/sync-coordinator.mjs +518 -0
- package/scripts/workbench/sync.mjs +86 -0
- package/references/bug-record-template.md +0 -37
- package/references/context-template.md +0 -19
package/scripts/context_guard.py
CHANGED
|
@@ -4,22 +4,23 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
+
import errno
|
|
8
|
+
import functools
|
|
9
|
+
import hashlib
|
|
7
10
|
import json
|
|
8
11
|
import os
|
|
9
12
|
import re
|
|
10
|
-
import signal
|
|
11
|
-
import socket
|
|
12
13
|
import subprocess
|
|
13
14
|
import sys
|
|
14
15
|
import time
|
|
15
|
-
import urllib.error
|
|
16
|
-
import urllib.request
|
|
17
16
|
import webbrowser
|
|
17
|
+
import uuid
|
|
18
|
+
from contextlib import contextmanager
|
|
18
19
|
from datetime import datetime, timezone
|
|
19
|
-
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
20
20
|
from pathlib import Path
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
|
|
22
|
+
WINDOWS_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
23
|
+
WRITABLE_BUG_STATUSES = ("open", "fixed", "resolved", "unfixable")
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def configure_stdio() -> None:
|
|
@@ -29,41 +30,6 @@ def configure_stdio() -> None:
|
|
|
29
30
|
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
30
31
|
|
|
31
32
|
|
|
32
|
-
PARKED = (
|
|
33
|
-
"export-roadmap",
|
|
34
|
-
"create-branch-task",
|
|
35
|
-
"checkpoint-roadmap-node",
|
|
36
|
-
"subagent-register",
|
|
37
|
-
"subagent-complete",
|
|
38
|
-
"validate-bad-cases",
|
|
39
|
-
"validate-roadmap-maintenance",
|
|
40
|
-
"validate-feature-chains",
|
|
41
|
-
"test-hub-add",
|
|
42
|
-
"test-hub-list",
|
|
43
|
-
"test-hub-enable",
|
|
44
|
-
"test-hub-disable",
|
|
45
|
-
"test-hub-set-policy",
|
|
46
|
-
"test-hub-remove",
|
|
47
|
-
"feature-chain-add",
|
|
48
|
-
"feature-chain-propose",
|
|
49
|
-
"feature-chain-auto-propose",
|
|
50
|
-
"feature-chain-attach-bc",
|
|
51
|
-
"feature-chain-approve",
|
|
52
|
-
"feature-chain-dry-run",
|
|
53
|
-
"feature-chain-set-policy",
|
|
54
|
-
"feature-chain-set-checkpoint",
|
|
55
|
-
"feature-chain-suggest",
|
|
56
|
-
"feature-chain-plan",
|
|
57
|
-
"feature-chain-list",
|
|
58
|
-
"feature-chain-summary",
|
|
59
|
-
"feature-chain-overlap",
|
|
60
|
-
"feature-chain-coverage",
|
|
61
|
-
"feature-chain-candidates",
|
|
62
|
-
"show-test-hub",
|
|
63
|
-
"serve-test-hub",
|
|
64
|
-
"dev-complete",
|
|
65
|
-
)
|
|
66
|
-
|
|
67
33
|
INDEX_MD = """# Context Index
|
|
68
34
|
|
|
69
35
|
- Current: none
|
|
@@ -95,16 +61,16 @@ FIND_MD = """# Four stores — jump small, then open one file
|
|
|
95
61
|
1. Sessions — `sessions.jsonl` (append-only) and `sessions/{id}.md`
|
|
96
62
|
2. Bugs — `bugs-index.json`, then `bugs/{id}.md` and `fixes/{id}.md`
|
|
97
63
|
3. Tasks — `tasks/{id}.md`
|
|
98
|
-
4. Map —
|
|
64
|
+
4. Map — `context-guard map read/apply` uses the authoritative map and a page synchronization checkpoint. `archive-session --files ...` records completed work on owning nodes. Unowned files stay unclassified unless `--input` explicitly assigns them or supplies an evidence-backed node proposal.
|
|
99
65
|
|
|
100
|
-
Do not paste `map.json` or `jump-index.json`. Do not Grep this whole folder.
|
|
101
|
-
|
|
66
|
+
Do not paste `map.json` or `jump-index.json`. Do not Grep this whole folder. Do not read or update a legacy `roadmap.md`.
|
|
67
|
+
Before using cards/indexes, verify projection-status.json matches the current map version. Generate with `python3 scripts/map_owns.py cards --root <project>`, or read the current node through the Node CLI. See the installed skill references/workbench-interface.md.
|
|
102
68
|
"""
|
|
103
69
|
|
|
104
70
|
ARCHITECTURE_MD = """# Architecture Map
|
|
105
71
|
|
|
106
72
|
Status: pending
|
|
107
|
-
Later sessions:
|
|
73
|
+
Later sessions: use `context-guard map read --root <project> --session <actual-session-id> --node <id>`. Do not re-analyze unless asked.
|
|
108
74
|
|
|
109
75
|
Last initialized: {today}
|
|
110
76
|
"""
|
|
@@ -134,6 +100,7 @@ def folder_root(cwd: Path) -> Path:
|
|
|
134
100
|
stderr=subprocess.DEVNULL,
|
|
135
101
|
text=True,
|
|
136
102
|
timeout=2,
|
|
103
|
+
creationflags=WINDOWS_NO_WINDOW,
|
|
137
104
|
).strip()
|
|
138
105
|
if out:
|
|
139
106
|
return Path(out)
|
|
@@ -154,6 +121,26 @@ def guard_implicit_skill_root(root: Path, explicit_root: bool) -> int:
|
|
|
154
121
|
|
|
155
122
|
|
|
156
123
|
def context_dir(root: Path) -> Path:
|
|
124
|
+
root = root.resolve()
|
|
125
|
+
binding_file = root / ".codex/context/private/project-binding.json"
|
|
126
|
+
if not binding_file.exists():
|
|
127
|
+
return root / ".codex" / "context"
|
|
128
|
+
binding = json.loads(binding_file.read_text(encoding="utf-8"))
|
|
129
|
+
target = Path(binding.get("projectRoot", ""))
|
|
130
|
+
if binding.get("version") != 1 or not target.is_absolute():
|
|
131
|
+
raise ValueError("Invalid workbench project binding")
|
|
132
|
+
target = target.resolve(strict=True)
|
|
133
|
+
if target == root or (target / ".codex/context/private/project-binding.json").exists():
|
|
134
|
+
raise ValueError("Workbench binding chains are not supported")
|
|
135
|
+
def git_common(folder: Path) -> Path:
|
|
136
|
+
result = subprocess.run(["git", "rev-parse", "--git-common-dir"], cwd=folder,
|
|
137
|
+
text=True, capture_output=True, check=True, timeout=5,
|
|
138
|
+
creationflags=WINDOWS_NO_WINDOW)
|
|
139
|
+
return (folder / result.stdout.strip()).resolve(strict=True)
|
|
140
|
+
if git_common(root) != git_common(target) or not (target / ".codex/context/map.json").is_file():
|
|
141
|
+
raise ValueError("Bound worktree must reference an existing Map in the same Git repository")
|
|
142
|
+
# A legacy project binding selects the service, never another worktree's
|
|
143
|
+
# Session storage. Preserve the source records and registration evidence.
|
|
157
144
|
return root / ".codex" / "context"
|
|
158
145
|
|
|
159
146
|
|
|
@@ -199,14 +186,43 @@ def read_json(path: Path, default: object) -> object:
|
|
|
199
186
|
return default
|
|
200
187
|
|
|
201
188
|
|
|
202
|
-
def
|
|
189
|
+
def read_input_json(input_path: str) -> object:
|
|
190
|
+
"""Read CLI JSON as UTF-8 bytes so Windows console encodings cannot corrupt it."""
|
|
191
|
+
if input_path == "-":
|
|
192
|
+
return json.loads(sys.stdin.buffer.read().decode("utf-8"))
|
|
193
|
+
return json.loads(Path(input_path).resolve().read_text(encoding="utf-8"))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def atomic_write_text(path: Path, content: str) -> None:
|
|
203
197
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
-
path.
|
|
198
|
+
temporary = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
199
|
+
try:
|
|
200
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
|
|
201
|
+
handle.write(content)
|
|
202
|
+
handle.flush()
|
|
203
|
+
os.fsync(handle.fileno())
|
|
204
|
+
os.replace(temporary, path)
|
|
205
|
+
if os.name != "nt":
|
|
206
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
207
|
+
try:
|
|
208
|
+
os.fsync(directory)
|
|
209
|
+
finally:
|
|
210
|
+
os.close(directory)
|
|
211
|
+
finally:
|
|
212
|
+
temporary.unlink(missing_ok=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def write_json(path: Path, value: object) -> None:
|
|
216
|
+
atomic_write_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
|
205
217
|
|
|
206
218
|
|
|
207
219
|
def read_preferences(ctx: Path) -> dict[str, str]:
|
|
208
|
-
|
|
209
|
-
|
|
220
|
+
local = ctx / "preferences.json"
|
|
221
|
+
data = json.loads(local.read_text(encoding="utf-8")) if local.exists() else {}
|
|
222
|
+
if not isinstance(data, dict):
|
|
223
|
+
raise ValueError("Invalid preferences; repair the file instead of repeating setup")
|
|
224
|
+
shared = run_node_workbench(["preferences", "--root", str(ctx.parent.parent)])
|
|
225
|
+
return {**data, **shared}
|
|
210
226
|
|
|
211
227
|
|
|
212
228
|
def write_preferences(ctx: Path, preferences: dict[str, str]) -> None:
|
|
@@ -278,7 +294,8 @@ def init_context(root: Path) -> list[Path]:
|
|
|
278
294
|
"bootstrap": "pending",
|
|
279
295
|
"updated": today,
|
|
280
296
|
"flows": [],
|
|
281
|
-
"
|
|
297
|
+
"project": root.name,
|
|
298
|
+
"root": {"id": "T0", "title": root.name, "kind": "module", "state": "dirty", "children": [], "memories": [], "ideas": [], "bugs": [], "dormant": [], "files": [], "owns": []},
|
|
282
299
|
},
|
|
283
300
|
ensure_ascii=False,
|
|
284
301
|
indent=2,
|
|
@@ -295,9 +312,10 @@ def init_context(root: Path) -> list[Path]:
|
|
|
295
312
|
|
|
296
313
|
|
|
297
314
|
def set_record_language(root: Path, language: str) -> Path:
|
|
315
|
+
normalized = normalize_record_language(language)
|
|
316
|
+
run_node_workbench(["preferences", "--root", str(root), "--language", normalized])
|
|
298
317
|
init_context(root)
|
|
299
318
|
ctx = context_dir(root)
|
|
300
|
-
normalized = normalize_record_language(language)
|
|
301
319
|
preferences = default_preferences()
|
|
302
320
|
preferences.update(read_preferences(ctx))
|
|
303
321
|
preferences["record_language"] = normalized
|
|
@@ -346,8 +364,12 @@ def append_session_event(
|
|
|
346
364
|
) -> Path:
|
|
347
365
|
init_context(root)
|
|
348
366
|
ctx = context_dir(root)
|
|
367
|
+
recorded_at = utc_now()
|
|
349
368
|
record: dict[str, object] = {
|
|
350
|
-
"at":
|
|
369
|
+
"at": recorded_at,
|
|
370
|
+
"occurred_at": recorded_at,
|
|
371
|
+
"recorded_at": recorded_at,
|
|
372
|
+
"event_id": str(uuid.uuid4()),
|
|
351
373
|
"event": event,
|
|
352
374
|
"platform": platform,
|
|
353
375
|
"session_id": session_id,
|
|
@@ -358,10 +380,513 @@ def append_session_event(
|
|
|
358
380
|
handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
359
381
|
session_path = ensure_session_file(root, session_id, platform)
|
|
360
382
|
with session_path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
361
|
-
handle.write(f"- {record['at']} · {event}\n")
|
|
383
|
+
handle.write(f"- {record['at']} · {event} · {record['event_id']}\n")
|
|
362
384
|
return session_path
|
|
363
385
|
|
|
364
386
|
|
|
387
|
+
def hook_runtime_path(root: Path, session_id: str) -> Path:
|
|
388
|
+
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()
|
|
389
|
+
return context_dir(root) / "private" / "hook-runtime" / f"{digest}.json"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
_HOOK_RUNTIME_LOCKS: dict[str, dict[str, object]] = {}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class _HookRuntimeLockLease:
|
|
396
|
+
def __init__(self, key: str):
|
|
397
|
+
self.key = key
|
|
398
|
+
self.closed = False
|
|
399
|
+
|
|
400
|
+
def close(self) -> None:
|
|
401
|
+
if self.closed:
|
|
402
|
+
return
|
|
403
|
+
self.closed = True
|
|
404
|
+
state = _HOOK_RUNTIME_LOCKS.get(self.key)
|
|
405
|
+
if not state:
|
|
406
|
+
return
|
|
407
|
+
count = int(state["count"]) - 1
|
|
408
|
+
if count > 0:
|
|
409
|
+
state["count"] = count
|
|
410
|
+
return
|
|
411
|
+
handle = state["handle"]
|
|
412
|
+
try:
|
|
413
|
+
if os.name == "nt":
|
|
414
|
+
import msvcrt
|
|
415
|
+
handle.seek(0)
|
|
416
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
417
|
+
else:
|
|
418
|
+
import fcntl
|
|
419
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
420
|
+
finally:
|
|
421
|
+
handle.close()
|
|
422
|
+
_HOOK_RUNTIME_LOCKS.pop(self.key, None)
|
|
423
|
+
|
|
424
|
+
def __enter__(self) -> "_HookRuntimeLockLease":
|
|
425
|
+
return self
|
|
426
|
+
|
|
427
|
+
def __exit__(self, _type: object, _value: object, _traceback: object) -> None:
|
|
428
|
+
self.close()
|
|
429
|
+
|
|
430
|
+
def __del__(self) -> None:
|
|
431
|
+
self.close()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def acquire_hook_runtime_lock(root: Path, session_id: str, timeout: float = 10.0) -> _HookRuntimeLockLease:
|
|
435
|
+
"""Acquire a crash-safe, re-entrant process lock for one Session runtime."""
|
|
436
|
+
target = hook_runtime_path(root, session_id)
|
|
437
|
+
lock_path = target.with_suffix(".lock")
|
|
438
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
key = str(lock_path.resolve())
|
|
440
|
+
held = _HOOK_RUNTIME_LOCKS.get(key)
|
|
441
|
+
if held:
|
|
442
|
+
held["count"] = int(held["count"]) + 1
|
|
443
|
+
return _HookRuntimeLockLease(key)
|
|
444
|
+
|
|
445
|
+
handle = lock_path.open("a+b")
|
|
446
|
+
if os.name == "nt":
|
|
447
|
+
handle.seek(0, os.SEEK_END)
|
|
448
|
+
if handle.tell() == 0:
|
|
449
|
+
handle.write(b"\0")
|
|
450
|
+
handle.flush()
|
|
451
|
+
deadline = time.monotonic() + timeout
|
|
452
|
+
while True:
|
|
453
|
+
try:
|
|
454
|
+
if os.name == "nt":
|
|
455
|
+
import msvcrt
|
|
456
|
+
handle.seek(0)
|
|
457
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
458
|
+
else:
|
|
459
|
+
import fcntl
|
|
460
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
461
|
+
break
|
|
462
|
+
except (BlockingIOError, OSError) as exc:
|
|
463
|
+
if isinstance(exc, OSError) and exc.errno not in {None, errno.EACCES, errno.EAGAIN}:
|
|
464
|
+
handle.close()
|
|
465
|
+
raise
|
|
466
|
+
if time.monotonic() >= deadline:
|
|
467
|
+
handle.close()
|
|
468
|
+
raise TimeoutError(f"timed out waiting for hook runtime lock: {lock_path}") from exc
|
|
469
|
+
time.sleep(0.02)
|
|
470
|
+
_HOOK_RUNTIME_LOCKS[key] = {"handle": handle, "count": 1}
|
|
471
|
+
return _HookRuntimeLockLease(key)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@contextmanager
|
|
475
|
+
def hook_runtime_lock(root: Path, session_id: str, timeout: float = 10.0):
|
|
476
|
+
lease = acquire_hook_runtime_lock(root, session_id, timeout)
|
|
477
|
+
try:
|
|
478
|
+
yield
|
|
479
|
+
finally:
|
|
480
|
+
lease.close()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def serialize_hook_runtime(session_arg: int):
|
|
484
|
+
"""Serialize a CLI operation that performs a runtime read-modify-write."""
|
|
485
|
+
def decorate(function):
|
|
486
|
+
@functools.wraps(function)
|
|
487
|
+
def wrapped(*args, **kwargs):
|
|
488
|
+
root = args[0] if args else kwargs["root"]
|
|
489
|
+
session_id = args[session_arg] if len(args) > session_arg else kwargs.get("session_id", "")
|
|
490
|
+
if not session_id:
|
|
491
|
+
return function(*args, **kwargs)
|
|
492
|
+
with hook_runtime_lock(root, str(session_id)):
|
|
493
|
+
return function(*args, **kwargs)
|
|
494
|
+
return wrapped
|
|
495
|
+
return decorate
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def serialize_named_lock(name: str):
|
|
499
|
+
"""Serialize a project-wide registry update independently of Session IDs."""
|
|
500
|
+
def decorate(function):
|
|
501
|
+
@functools.wraps(function)
|
|
502
|
+
def wrapped(*args, **kwargs):
|
|
503
|
+
root = args[0] if args else kwargs["root"]
|
|
504
|
+
with hook_runtime_lock(root, f"registry:{name}"):
|
|
505
|
+
return function(*args, **kwargs)
|
|
506
|
+
return wrapped
|
|
507
|
+
return decorate
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def read_hook_runtime(root: Path, session_id: str) -> dict[str, object]:
|
|
511
|
+
target = hook_runtime_path(root, session_id)
|
|
512
|
+
if not target.exists():
|
|
513
|
+
value: object = {}
|
|
514
|
+
else:
|
|
515
|
+
try:
|
|
516
|
+
value = json.loads(target.read_text(encoding="utf-8"))
|
|
517
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
518
|
+
raise ValueError(f"hook runtime is unreadable; preserve and repair {target}") from exc
|
|
519
|
+
if not isinstance(value, dict):
|
|
520
|
+
raise ValueError(f"hook runtime must be a JSON object: {target}")
|
|
521
|
+
recorded_session = value.get("session_id")
|
|
522
|
+
if recorded_session not in {None, session_id}:
|
|
523
|
+
raise ValueError(f"hook runtime belongs to another Session: {target}")
|
|
524
|
+
if "signals" in value and not isinstance(value["signals"], list):
|
|
525
|
+
raise ValueError(f"hook runtime signals must be a list: {target}")
|
|
526
|
+
value.setdefault("v", 1)
|
|
527
|
+
value.setdefault("session_id", session_id)
|
|
528
|
+
value.setdefault("signals", [])
|
|
529
|
+
return value
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def write_hook_runtime(root: Path, session_id: str, value: dict[str, object]) -> Path:
|
|
533
|
+
value["v"] = 1
|
|
534
|
+
value["session_id"] = session_id
|
|
535
|
+
value["updated_at"] = utc_now()
|
|
536
|
+
target = hook_runtime_path(root, session_id)
|
|
537
|
+
atomic_write_text(target, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
|
538
|
+
return target
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def prompt_signal_id(session_id: str, turn_id: str, prompt: str) -> str:
|
|
542
|
+
digest = hashlib.sha256(
|
|
543
|
+
json.dumps([session_id, turn_id, prompt], ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
544
|
+
).hexdigest()
|
|
545
|
+
return f"SIG-{digest[:20]}"
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@serialize_hook_runtime(1)
|
|
549
|
+
def add_prompt_signal(root: Path, session_id: str, turn_id: str, prompt: str) -> dict[str, object]:
|
|
550
|
+
runtime = read_hook_runtime(root, session_id)
|
|
551
|
+
signals = runtime.get("signals")
|
|
552
|
+
if not isinstance(signals, list):
|
|
553
|
+
signals = []
|
|
554
|
+
runtime["signals"] = signals
|
|
555
|
+
signal_id = prompt_signal_id(session_id, turn_id, prompt)
|
|
556
|
+
existing = next((item for item in signals if isinstance(item, dict) and item.get("id") == signal_id), None)
|
|
557
|
+
if existing:
|
|
558
|
+
return existing
|
|
559
|
+
signal: dict[str, object] = {
|
|
560
|
+
"id": signal_id,
|
|
561
|
+
"turn_id": turn_id,
|
|
562
|
+
"created_at": utc_now(),
|
|
563
|
+
"prompt_hash": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
|
|
564
|
+
"preview": " ".join(prompt.split())[:400],
|
|
565
|
+
"status": "pending",
|
|
566
|
+
}
|
|
567
|
+
signals.append(signal)
|
|
568
|
+
if len(signals) > 100:
|
|
569
|
+
unresolved = [item for item in signals if isinstance(item, dict) and item.get("status") == "pending"]
|
|
570
|
+
resolved = [item for item in signals if isinstance(item, dict) and item.get("status") != "pending"][-50:]
|
|
571
|
+
runtime["signals"] = unresolved + resolved
|
|
572
|
+
write_hook_runtime(root, session_id, runtime)
|
|
573
|
+
return signal
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@serialize_hook_runtime(1)
|
|
577
|
+
def resolve_prompt_signal(
|
|
578
|
+
root: Path,
|
|
579
|
+
session_id: str,
|
|
580
|
+
signal_id: str,
|
|
581
|
+
kind: str,
|
|
582
|
+
node_id: str = "",
|
|
583
|
+
record_id: str = "",
|
|
584
|
+
) -> dict[str, object]:
|
|
585
|
+
runtime = read_hook_runtime(root, session_id)
|
|
586
|
+
signals = runtime.get("signals")
|
|
587
|
+
if not isinstance(signals, list):
|
|
588
|
+
raise ValueError("hook runtime has no prompt signals")
|
|
589
|
+
signal = next((item for item in signals if isinstance(item, dict) and item.get("id") == signal_id), None)
|
|
590
|
+
if not signal:
|
|
591
|
+
raise ValueError(f"unknown prompt signal: {signal_id}")
|
|
592
|
+
previous = str(signal.get("kind") or "")
|
|
593
|
+
if signal.get("status") == "resolved" and previous and previous != kind:
|
|
594
|
+
upgrade = {"task": {"todo", "bad-case"}}
|
|
595
|
+
if kind not in upgrade.get(previous, set()):
|
|
596
|
+
raise ValueError(f"prompt signal is already resolved as {previous}")
|
|
597
|
+
signal.update({
|
|
598
|
+
"status": "resolved",
|
|
599
|
+
"kind": kind,
|
|
600
|
+
"resolved_at": utc_now(),
|
|
601
|
+
"node_id": node_id or None,
|
|
602
|
+
"record_id": record_id or None,
|
|
603
|
+
})
|
|
604
|
+
write_hook_runtime(root, session_id, runtime)
|
|
605
|
+
return signal
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@serialize_hook_runtime(1)
|
|
609
|
+
def split_signal(root: Path, session_id: str, signal_id: str, items: object) -> list[dict]:
|
|
610
|
+
"""The Agent separates meanings; the hook never guesses from keywords."""
|
|
611
|
+
if not isinstance(items, list) or not 2 <= len(items) <= 20 or not all(isinstance(item, str) and item.strip() for item in items):
|
|
612
|
+
raise ValueError("split-signal needs 2–20 nonempty text items")
|
|
613
|
+
runtime = read_hook_runtime(root, session_id)
|
|
614
|
+
parent = next((item for item in runtime["signals"] if item.get("id") == signal_id), None)
|
|
615
|
+
if not parent:
|
|
616
|
+
raise ValueError("unknown parent signal")
|
|
617
|
+
normalized = [item.strip() for item in items]
|
|
618
|
+
digest = hashlib.sha256(json.dumps(normalized, ensure_ascii=False).encode()).hexdigest()
|
|
619
|
+
if parent.get("status") == "resolved":
|
|
620
|
+
if parent.get("kind") == "split" and parent.get("split_hash") == digest:
|
|
621
|
+
return [item for item in runtime["signals"] if item.get("parent_id") == signal_id]
|
|
622
|
+
raise ValueError("parent signal already resolved; cannot replace its classification")
|
|
623
|
+
children = [{"id": prompt_signal_id(session_id, f"{signal_id}:{index}", text), "parent_id": signal_id,
|
|
624
|
+
"turn_id": parent.get("turn_id"), "created_at": utc_now(), "preview": text[:400],
|
|
625
|
+
"prompt_hash": hashlib.sha256(text.encode()).hexdigest(), "status": "pending"}
|
|
626
|
+
for index, text in enumerate(normalized)]
|
|
627
|
+
parent.update({"status": "resolved", "kind": "split", "resolved_at": utc_now(), "split_hash": digest})
|
|
628
|
+
runtime["signals"].extend(children)
|
|
629
|
+
write_hook_runtime(root, session_id, runtime)
|
|
630
|
+
append_session_event(root, "signal-split", session_platform(root, session_id), session_id,
|
|
631
|
+
{"signal_id": signal_id, "child_ids": [item["id"] for item in children]})
|
|
632
|
+
return children
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def session_records(root: Path) -> list[dict[str, object]]:
|
|
636
|
+
records: list[dict[str, object]] = []
|
|
637
|
+
path = context_dir(root) / "sessions.jsonl"
|
|
638
|
+
if not path.exists():
|
|
639
|
+
return records
|
|
640
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
641
|
+
try:
|
|
642
|
+
value = json.loads(line)
|
|
643
|
+
except json.JSONDecodeError:
|
|
644
|
+
continue
|
|
645
|
+
if isinstance(value, dict) and isinstance(value.get("session_id"), str):
|
|
646
|
+
records.append(value)
|
|
647
|
+
return records
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def resolve_session_id(root: Path, explicit: str = "") -> str:
|
|
651
|
+
if explicit.strip():
|
|
652
|
+
return explicit.strip()
|
|
653
|
+
for key in ("CODEX_THREAD_ID", "CLAUDE_SESSION_ID", "CURSOR_SESSION_ID"):
|
|
654
|
+
value = os.environ.get(key, "").strip()
|
|
655
|
+
if value:
|
|
656
|
+
return value
|
|
657
|
+
state = read_json(context_dir(root) / "private" / "hook-sessions.json", {})
|
|
658
|
+
if isinstance(state, dict):
|
|
659
|
+
values = [value.strip() for value in state.values() if isinstance(value, str) and value.strip()]
|
|
660
|
+
if values:
|
|
661
|
+
return values[-1]
|
|
662
|
+
return ""
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def session_platform(root: Path, session_id: str) -> str:
|
|
666
|
+
platform = "cli"
|
|
667
|
+
for record in session_records(root):
|
|
668
|
+
if record.get("session_id") == session_id and isinstance(record.get("platform"), str):
|
|
669
|
+
platform = str(record["platform"])
|
|
670
|
+
return platform
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
@serialize_hook_runtime(1)
|
|
674
|
+
def archive_session(
|
|
675
|
+
root: Path,
|
|
676
|
+
session_id: str,
|
|
677
|
+
summary: str,
|
|
678
|
+
decisions: str,
|
|
679
|
+
next_steps: str,
|
|
680
|
+
files: str,
|
|
681
|
+
input_path: str = "",
|
|
682
|
+
) -> Path:
|
|
683
|
+
init_context(root)
|
|
684
|
+
known = {str(item.get("session_id")) for item in session_records(root)}
|
|
685
|
+
if not session_id or session_id not in known:
|
|
686
|
+
raise ValueError("archive-session needs a session previously recorded by a lifecycle hook")
|
|
687
|
+
file_list = list(dict.fromkeys(item.strip() for item in files.split(",") if item.strip()))
|
|
688
|
+
map_result: dict[str, object] = {
|
|
689
|
+
"committed": True,
|
|
690
|
+
"reconciliation": {"files": [], "mapped": {}, "uncovered": [], "operations": []},
|
|
691
|
+
}
|
|
692
|
+
governance: dict[str, object] = {}
|
|
693
|
+
runtime = read_hook_runtime(root, session_id)
|
|
694
|
+
plan = runtime.get("active_plan")
|
|
695
|
+
require_human_work_review(root, session_id, plan if isinstance(plan, dict) else None)
|
|
696
|
+
closure: dict[str, object] = {}
|
|
697
|
+
if input_path:
|
|
698
|
+
raw_governance = read_input_json(input_path)
|
|
699
|
+
if not isinstance(raw_governance, dict):
|
|
700
|
+
raise ValueError("archive-session --input needs a JSON object")
|
|
701
|
+
unsupported = set(raw_governance) - {"assignments", "proposal", "verification", "assessment", "scope_review", "failure_review", "subagent_review"}
|
|
702
|
+
if unsupported:
|
|
703
|
+
raise ValueError(f"archive-session --input has unsupported fields: {', '.join(sorted(unsupported))}")
|
|
704
|
+
governance = {key: value for key, value in raw_governance.items() if key in {"assignments", "proposal"}}
|
|
705
|
+
closure = {key: value for key, value in raw_governance.items() if key not in governance}
|
|
706
|
+
current_snapshot = None
|
|
707
|
+
if isinstance(plan, dict):
|
|
708
|
+
from context_guard_hook import scope_snapshot
|
|
709
|
+
assessment = closure.get("assessment")
|
|
710
|
+
if not summary.strip() or not isinstance(closure.get("verification"), str) or not closure["verification"].strip():
|
|
711
|
+
raise ValueError("active plan archive needs summary and verification evidence in --input")
|
|
712
|
+
if not isinstance(assessment, dict) or assessment.get("decision") not in {"reuse", "propose", "none"} or not str(assessment.get("reason") or "").strip():
|
|
713
|
+
raise ValueError("archive needs assessment {decision:reuse|propose|none, reason}")
|
|
714
|
+
if (assessment["decision"] == "propose") != bool(governance.get("proposal")):
|
|
715
|
+
raise ValueError("proposal input must agree with the node/module assessment")
|
|
716
|
+
for flag, field in (("scope_review_required", "scope_review"), ("failure_review_required", "failure_review")):
|
|
717
|
+
if plan.get(flag) and not str(closure.get(field) or "").strip():
|
|
718
|
+
raise ValueError(f"archive needs {field}: explain actual scope or failed tool recovery")
|
|
719
|
+
reviews = closure.get("subagent_review") or {}
|
|
720
|
+
for agent_id, agent in (runtime.get("subagents") or {}).items():
|
|
721
|
+
if agent.get("plan_id") == plan["id"] and (not isinstance(reviews, dict) or not str(reviews.get(agent_id) or "").strip()):
|
|
722
|
+
raise ValueError(f"archive needs subagent_review for {agent_id}: verify or explicitly discard its result")
|
|
723
|
+
current_snapshot = scope_snapshot(root, plan["paths"])
|
|
724
|
+
baseline = plan.get("baseline") or {}
|
|
725
|
+
changed = {file for file in set(baseline) | set(current_snapshot) if baseline.get(file) != current_snapshot.get(file)}
|
|
726
|
+
if changed - set(file_list):
|
|
727
|
+
raise ValueError("archive omitted changed files: " + ", ".join(sorted(changed - set(file_list))))
|
|
728
|
+
if set(file_list) - (changed | set(plan.get("actual_paths") or [])):
|
|
729
|
+
raise ValueError("archive includes files not observed changed by the plan")
|
|
730
|
+
if governance and not file_list:
|
|
731
|
+
raise ValueError("archive-session --input needs --files")
|
|
732
|
+
if file_list or isinstance(plan, dict):
|
|
733
|
+
map_result = run_node_workbench(
|
|
734
|
+
["map", "reconcile", "--root", str(root), "--session", session_id],
|
|
735
|
+
{
|
|
736
|
+
"summary": summary.strip(),
|
|
737
|
+
"decisions": decisions.strip(),
|
|
738
|
+
"next": next_steps.strip(),
|
|
739
|
+
"files": file_list,
|
|
740
|
+
**({"nodeIds": plan["node_ids"] if not file_list else [], "planId": plan["id"],
|
|
741
|
+
"verification": closure["verification"], "assessment": closure["assessment"]} if isinstance(plan, dict) else {}),
|
|
742
|
+
**governance,
|
|
743
|
+
},
|
|
744
|
+
)
|
|
745
|
+
reconciliation = map_result.get("reconciliation", {})
|
|
746
|
+
if not isinstance(reconciliation, dict):
|
|
747
|
+
reconciliation = {}
|
|
748
|
+
if isinstance(plan, dict) and (map_result.get("committed") is not True or reconciliation.get("unclassified") or reconciliation.get("uncovered")):
|
|
749
|
+
raise ValueError("plan archive incomplete: assign uncovered files to existing nodes or submit an evidence-backed proposal")
|
|
750
|
+
node_ids = list((reconciliation.get("mapped") or {}).keys())
|
|
751
|
+
proposed_id = str(reconciliation.get("proposedId") or "")
|
|
752
|
+
if proposed_id:
|
|
753
|
+
node_ids.append(proposed_id)
|
|
754
|
+
path = append_session_event(
|
|
755
|
+
root,
|
|
756
|
+
"archive",
|
|
757
|
+
session_platform(root, session_id),
|
|
758
|
+
session_id,
|
|
759
|
+
{
|
|
760
|
+
"has_summary": bool(summary.strip()),
|
|
761
|
+
"map_sync": "synced",
|
|
762
|
+
"map_nodes": node_ids,
|
|
763
|
+
"map_version": map_result.get("version"),
|
|
764
|
+
},
|
|
765
|
+
)
|
|
766
|
+
lines = ["", f"## Archive {utc_now()}", ""]
|
|
767
|
+
for heading, value in (
|
|
768
|
+
("Summary", summary),
|
|
769
|
+
("Decisions", decisions),
|
|
770
|
+
("Next", next_steps),
|
|
771
|
+
):
|
|
772
|
+
if value.strip():
|
|
773
|
+
lines.extend([f"### {heading}", "", value.strip(), ""])
|
|
774
|
+
if closure:
|
|
775
|
+
lines.extend(["### Verification and assessment", "", json.dumps(closure, ensure_ascii=False, indent=2), ""])
|
|
776
|
+
if file_list:
|
|
777
|
+
lines.extend(["### Files", "", *[f"- {item}" for item in file_list], ""])
|
|
778
|
+
mapped = reconciliation.get("mapped") or {}
|
|
779
|
+
unclassified = reconciliation.get("unclassified") or reconciliation.get("uncovered") or []
|
|
780
|
+
lines.extend([
|
|
781
|
+
"### Map",
|
|
782
|
+
"",
|
|
783
|
+
"- status: synced",
|
|
784
|
+
f"- existing nodes: {', '.join(mapped) if isinstance(mapped, dict) and mapped else 'none'}",
|
|
785
|
+
f"- proposed node: {proposed_id or 'none'}",
|
|
786
|
+
f"- unclassified files: {', '.join(unclassified) if isinstance(unclassified, list) and unclassified else 'none'}",
|
|
787
|
+
f"- version: {map_result.get('version') or 'unchanged'}",
|
|
788
|
+
"",
|
|
789
|
+
])
|
|
790
|
+
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
791
|
+
handle.write("\n".join(lines).rstrip() + "\n")
|
|
792
|
+
memory = run_node_workbench(["memory", "status", "--root", str(root), "--session", session_id])
|
|
793
|
+
memory_version = None
|
|
794
|
+
if memory.get("current"):
|
|
795
|
+
for attempt in range(3):
|
|
796
|
+
try:
|
|
797
|
+
receipt = run_node_workbench(["memory", "sync", "--root", str(root), "--session", session_id])
|
|
798
|
+
break
|
|
799
|
+
except RuntimeError as exc:
|
|
800
|
+
try:
|
|
801
|
+
code = json.loads(str(exc)).get("error", {}).get("code")
|
|
802
|
+
except ValueError:
|
|
803
|
+
code = None
|
|
804
|
+
if code != "VERSION_CONFLICT" or attempt == 2:
|
|
805
|
+
raise
|
|
806
|
+
snapshot = receipt.get("snapshot") or {}
|
|
807
|
+
memory_version = snapshot.get("version")
|
|
808
|
+
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True,
|
|
809
|
+
timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
810
|
+
if head.returncode or snapshot.get("sourceCommit") != head.stdout.strip():
|
|
811
|
+
raise ValueError("Server Session memory does not match the current Git commit")
|
|
812
|
+
print(f"[context-guard] server archive acknowledged: {receipt.get('snapshot', {}).get('version')}")
|
|
813
|
+
else:
|
|
814
|
+
print("[context-guard] server memory not configured; local archive remains unsynced")
|
|
815
|
+
if isinstance(plan, dict):
|
|
816
|
+
latest = read_hook_runtime(root, session_id)
|
|
817
|
+
active = latest.get("active_plan") or {}
|
|
818
|
+
if active.get("id") != plan["id"] or active.get("revision") != plan.get("revision"):
|
|
819
|
+
raise ValueError("plan changed during archive; Map write succeeded but completion receipt needs revalidation")
|
|
820
|
+
plan["archive"] = {"at": utc_now(), "revision": plan.get("revision"), "snapshot": current_snapshot,
|
|
821
|
+
"map_version": map_result.get("version"), "node_ids": node_ids,
|
|
822
|
+
**({"server_memory_version": memory_version} if memory_version else {}), **closure}
|
|
823
|
+
latest["active_plan"] = plan
|
|
824
|
+
write_hook_runtime(root, session_id, latest)
|
|
825
|
+
print(f"[context-guard] archived session: {session_id} ({path})")
|
|
826
|
+
if file_list:
|
|
827
|
+
print(
|
|
828
|
+
"[context-guard] map synchronized: "
|
|
829
|
+
f"{len(reconciliation.get('mapped') or {})} existing node(s), "
|
|
830
|
+
f"{len(reconciliation.get('unclassified') or reconciliation.get('uncovered') or [])} unclassified file(s), "
|
|
831
|
+
f"{'1 proposed node' if proposed_id else 'no node proposal'}"
|
|
832
|
+
)
|
|
833
|
+
return path
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def validate_candidates(value: object) -> dict[str, object]:
|
|
837
|
+
if not isinstance(value, dict) or not isinstance(value.get("lenses"), list) or not value["lenses"]:
|
|
838
|
+
raise ValueError("candidate input needs a non-empty lenses array")
|
|
839
|
+
if len(value["lenses"]) > 12:
|
|
840
|
+
raise ValueError("candidate input supports at most 12 lenses")
|
|
841
|
+
lens_ids: set[str] = set()
|
|
842
|
+
candidate_ids: set[str] = set()
|
|
843
|
+
normalized: list[dict[str, object]] = []
|
|
844
|
+
for lens in value["lenses"]:
|
|
845
|
+
if not isinstance(lens, dict):
|
|
846
|
+
raise ValueError("each lens must be an object")
|
|
847
|
+
lens_id = safe_identifier(str(lens.get("id", "")), "")
|
|
848
|
+
title = str(lens.get("title", "")).strip()
|
|
849
|
+
candidates = lens.get("candidates")
|
|
850
|
+
if not lens_id or lens_id in lens_ids or not title or not isinstance(candidates, list):
|
|
851
|
+
raise ValueError("each lens needs a unique id, title, and candidates array")
|
|
852
|
+
lens_ids.add(lens_id)
|
|
853
|
+
items: list[dict[str, object]] = []
|
|
854
|
+
for candidate in candidates:
|
|
855
|
+
if not isinstance(candidate, dict):
|
|
856
|
+
raise ValueError("each candidate must be an object")
|
|
857
|
+
candidate_id = safe_identifier(str(candidate.get("id", "")), "")
|
|
858
|
+
candidate_title = str(candidate.get("title", "")).strip()
|
|
859
|
+
if not candidate_id or candidate_id in candidate_ids or not candidate_title:
|
|
860
|
+
raise ValueError("each candidate needs a globally unique id and a title")
|
|
861
|
+
owns = candidate.get("owns", [])
|
|
862
|
+
if not isinstance(owns, list) or any(not isinstance(item, str) for item in owns):
|
|
863
|
+
raise ValueError("candidate owns must be an array of paths")
|
|
864
|
+
candidate_ids.add(candidate_id)
|
|
865
|
+
items.append({
|
|
866
|
+
"id": candidate_id,
|
|
867
|
+
"title": candidate_title,
|
|
868
|
+
"purpose": str(candidate.get("purpose", "")).strip(),
|
|
869
|
+
"owns": [item.strip() for item in owns if item.strip()],
|
|
870
|
+
})
|
|
871
|
+
normalized.append({
|
|
872
|
+
"id": lens_id,
|
|
873
|
+
"title": title,
|
|
874
|
+
"why": str(lens.get("why", "")).strip(),
|
|
875
|
+
"candidates": items,
|
|
876
|
+
})
|
|
877
|
+
return {"v": 1, "generated_at": utc_now(), "lenses": normalized}
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def write_candidates(root: Path, input_path: str) -> Path:
|
|
881
|
+
init_context(root)
|
|
882
|
+
value = read_input_json(input_path)
|
|
883
|
+
normalized = validate_candidates(value)
|
|
884
|
+
path = context_dir(root) / "l1-candidates.json"
|
|
885
|
+
write_json(path, normalized)
|
|
886
|
+
print(f"[context-guard] candidates: {path}")
|
|
887
|
+
return path
|
|
888
|
+
|
|
889
|
+
|
|
365
890
|
def next_bug_id(ctx: Path) -> str:
|
|
366
891
|
numbers = []
|
|
367
892
|
for path in (ctx / "bugs").glob("B*.md"):
|
|
@@ -376,40 +901,292 @@ def find_map_node(node: object, node_id: str) -> dict[str, object] | None:
|
|
|
376
901
|
return None
|
|
377
902
|
if str(node.get("id", "")) == node_id:
|
|
378
903
|
return node
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
904
|
+
for key in ("children", "_inbox"):
|
|
905
|
+
items = node.get(key)
|
|
906
|
+
if isinstance(items, list):
|
|
907
|
+
for child in items:
|
|
908
|
+
found = find_map_node(child, node_id)
|
|
909
|
+
if found:
|
|
910
|
+
return found
|
|
911
|
+
return None
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def authoritative_map_file(root: Path, session_id: str = "") -> Path:
|
|
915
|
+
"""Resolve the same Session Map file the hook/workbench layer treats as authoritative."""
|
|
916
|
+
ctx = context_dir(root)
|
|
917
|
+
map_file = ctx / "map.json"
|
|
918
|
+
if not session_id:
|
|
919
|
+
return map_file
|
|
920
|
+
probe = subprocess.run(
|
|
921
|
+
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
922
|
+
cwd=str(root.resolve()),
|
|
923
|
+
capture_output=True,
|
|
924
|
+
text=True,
|
|
925
|
+
timeout=5,
|
|
926
|
+
check=False,
|
|
927
|
+
creationflags=WINDOWS_NO_WINDOW,
|
|
928
|
+
)
|
|
929
|
+
if probe.returncode != 0:
|
|
930
|
+
return map_file
|
|
931
|
+
shared = Path(probe.stdout.strip()) / "context-guard"
|
|
932
|
+
bindings = read_json(shared / "workbench-bindings.json", {})
|
|
933
|
+
sessions = bindings.get("sessions") if isinstance(bindings, dict) else None
|
|
934
|
+
bound = sessions.get(session_id) if isinstance(sessions, dict) else None
|
|
935
|
+
if isinstance(bound, dict) and bound.get("worktreeRoot") == str(root.resolve()):
|
|
936
|
+
scope = hashlib.sha256((session_id + "\0" + str(bound.get("worktreeId", ""))).encode("utf-8")).hexdigest()
|
|
937
|
+
return shared / "session-memory" / scope / "map.json"
|
|
938
|
+
return map_file
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def load_authoritative_map(root: Path, session_id: str = "") -> dict[str, object]:
|
|
942
|
+
doc = read_json(authoritative_map_file(root, session_id), {})
|
|
943
|
+
return doc if isinstance(doc, dict) else {}
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def iter_map_nodes(node: object):
|
|
947
|
+
if not isinstance(node, dict):
|
|
948
|
+
return
|
|
949
|
+
yield node
|
|
950
|
+
for key in ("children", "_inbox"):
|
|
951
|
+
items = node.get(key)
|
|
952
|
+
if isinstance(items, list):
|
|
953
|
+
for child in items:
|
|
954
|
+
yield from iter_map_nodes(child)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def approved_work_item_review(item: object, session_id: str) -> bool:
|
|
958
|
+
if not isinstance(item, dict):
|
|
959
|
+
return False
|
|
960
|
+
review = item.get("review")
|
|
961
|
+
if not isinstance(review, dict) or review.get("decision") != "approved":
|
|
962
|
+
return False
|
|
963
|
+
if str(review.get("sessionId") or "") == session_id:
|
|
964
|
+
return True
|
|
965
|
+
sessions = item.get("sessions") if isinstance(item.get("sessions"), list) else []
|
|
966
|
+
dispatch = item.get("dispatch") if isinstance(item.get("dispatch"), dict) else {}
|
|
967
|
+
return session_id in sessions or dispatch.get("session_id") == session_id
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def recorded_human_work_review(root: Path, session_id: str, plan: object = None) -> dict[str, object] | None:
|
|
971
|
+
"""Reuse Map item.review and Cloud acceptance receipts. Do not invent a Hook."""
|
|
972
|
+
node_ids = None
|
|
973
|
+
if isinstance(plan, dict):
|
|
974
|
+
ids = plan.get("node_ids")
|
|
975
|
+
if isinstance(ids, list) and any(isinstance(item, str) and item for item in ids):
|
|
976
|
+
node_ids = {str(item) for item in ids if item}
|
|
977
|
+
doc = load_authoritative_map(root, session_id)
|
|
978
|
+
for node in iter_map_nodes(doc.get("root") if isinstance(doc, dict) else None):
|
|
979
|
+
if node_ids is not None and str(node.get("id") or "") not in node_ids:
|
|
980
|
+
continue
|
|
981
|
+
for field in ("bugs", "todos"):
|
|
982
|
+
items = node.get(field)
|
|
983
|
+
if not isinstance(items, list):
|
|
984
|
+
continue
|
|
985
|
+
for item in items:
|
|
986
|
+
if approved_work_item_review(item, session_id):
|
|
987
|
+
return {"source": "map-item-review", "nodeId": node.get("id"), "itemId": item.get("id")}
|
|
988
|
+
try:
|
|
989
|
+
execution = run_node_workbench(["map", "execution", "--root", str(root), "--session", session_id])
|
|
990
|
+
except (OSError, RuntimeError, ValueError, json.JSONDecodeError):
|
|
991
|
+
execution = {}
|
|
992
|
+
active = execution.get("active") if isinstance(execution, dict) and isinstance(execution.get("active"), dict) else {}
|
|
993
|
+
acceptance = active.get("acceptanceReview") if isinstance(active.get("acceptanceReview"), dict) else {}
|
|
994
|
+
if acceptance.get("decision") == "approved":
|
|
995
|
+
return {"source": "cloud-acceptance", "taskId": active.get("taskId")}
|
|
996
|
+
if active.get("stage") in {"accepted", "closing", "closed"}:
|
|
997
|
+
return {"source": "cloud-accepted-stage", "taskId": active.get("taskId")}
|
|
385
998
|
return None
|
|
386
999
|
|
|
387
1000
|
|
|
388
|
-
def
|
|
389
|
-
|
|
390
|
-
document = read_json(path, {})
|
|
391
|
-
if not isinstance(document, dict):
|
|
1001
|
+
def require_human_work_review(root: Path, session_id: str, plan: object = None) -> None:
|
|
1002
|
+
if recorded_human_work_review(root, session_id, plan):
|
|
392
1003
|
return
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if not isinstance(bugs, list):
|
|
405
|
-
bugs = []
|
|
406
|
-
target["bugs"] = bugs
|
|
407
|
-
bugs[:] = [item for item in bugs if not isinstance(item, dict) or item.get("id") != bug["id"]]
|
|
408
|
-
bugs.append(bug)
|
|
409
|
-
document["updated"] = datetime.now().strftime("%Y-%m-%d")
|
|
410
|
-
write_json(path, document)
|
|
1004
|
+
raise ValueError("Human review of this work is required before archive-session or plan-finish")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def require_known_map_node(root: Path, node_id: str, session_id: str = "") -> None:
|
|
1008
|
+
"""Validate a node against the authoritative Session Map, not the legacy disk cache."""
|
|
1009
|
+
if not node_id:
|
|
1010
|
+
return
|
|
1011
|
+
map_doc = load_authoritative_map(root, session_id)
|
|
1012
|
+
map_root = map_doc.get("root") if isinstance(map_doc, dict) else None
|
|
1013
|
+
if find_map_node(map_root, node_id) is None:
|
|
1014
|
+
raise ValueError(f"unknown map node: {node_id}")
|
|
411
1015
|
|
|
412
1016
|
|
|
1017
|
+
@serialize_hook_runtime(4)
|
|
1018
|
+
def record_todo(
|
|
1019
|
+
root: Path,
|
|
1020
|
+
title: str,
|
|
1021
|
+
description: str,
|
|
1022
|
+
node_id: str,
|
|
1023
|
+
session_id: str,
|
|
1024
|
+
signal_id: str,
|
|
1025
|
+
) -> dict[str, object]:
|
|
1026
|
+
init_context(root)
|
|
1027
|
+
if not title.strip() or not node_id.strip() or not signal_id.strip():
|
|
1028
|
+
raise ValueError("record-todo needs --title, --node, and --signal")
|
|
1029
|
+
known = {str(item.get("session_id")) for item in session_records(root)}
|
|
1030
|
+
if not session_id or session_id not in known:
|
|
1031
|
+
raise ValueError("record-todo needs a session previously recorded by a lifecycle hook")
|
|
1032
|
+
runtime = read_hook_runtime(root, session_id)
|
|
1033
|
+
signals = runtime.get("signals") if isinstance(runtime.get("signals"), list) else []
|
|
1034
|
+
signal = next((item for item in signals if isinstance(item, dict) and item.get("id") == signal_id), None)
|
|
1035
|
+
if not signal:
|
|
1036
|
+
raise ValueError(f"unknown prompt signal: {signal_id}")
|
|
1037
|
+
if signal.get("status") == "resolved" and signal.get("kind") not in {None, "", "todo", "task"}:
|
|
1038
|
+
raise ValueError(f"prompt signal is already resolved as {signal.get('kind')}")
|
|
1039
|
+
require_known_map_node(root, node_id, session_id)
|
|
1040
|
+
todo_id = "TD-" + hashlib.sha256(f"{session_id}\0{signal_id}".encode("utf-8")).hexdigest()[:16]
|
|
1041
|
+
result = run_node_workbench(
|
|
1042
|
+
["record-todo", "--root", str(root), "--session", session_id],
|
|
1043
|
+
{
|
|
1044
|
+
"id": todo_id,
|
|
1045
|
+
"node": node_id,
|
|
1046
|
+
"signalId": signal_id,
|
|
1047
|
+
"title": title.strip(),
|
|
1048
|
+
"description": description.strip(),
|
|
1049
|
+
"at": str(signal.get("created_at") or utc_now()),
|
|
1050
|
+
},
|
|
1051
|
+
)
|
|
1052
|
+
resolve_prompt_signal(root, session_id, signal_id, "todo", node_id, todo_id)
|
|
1053
|
+
append_session_event(
|
|
1054
|
+
root,
|
|
1055
|
+
"todo-recorded",
|
|
1056
|
+
session_platform(root, session_id),
|
|
1057
|
+
session_id,
|
|
1058
|
+
{
|
|
1059
|
+
"signal_id": signal_id,
|
|
1060
|
+
"node_ids": [node_id],
|
|
1061
|
+
"record_id": todo_id,
|
|
1062
|
+
"map_version": result.get("version"),
|
|
1063
|
+
},
|
|
1064
|
+
)
|
|
1065
|
+
print(f"[context-guard] recorded todo: {todo_id} ({node_id})")
|
|
1066
|
+
return {"id": todo_id, "node": node_id, "version": result.get("version"), "duplicate": bool(result.get("duplicate"))}
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
@serialize_hook_runtime(1)
|
|
1070
|
+
def resolve_signal(root: Path, session_id: str, signal_id: str, kind: str) -> dict[str, object]:
|
|
1071
|
+
if kind not in {"task", "ignore"}:
|
|
1072
|
+
raise ValueError("resolve-signal --kind must be task or ignore; use record-todo/record-bad-case for durable records")
|
|
1073
|
+
signal = resolve_prompt_signal(root, session_id, signal_id, kind)
|
|
1074
|
+
append_session_event(
|
|
1075
|
+
root,
|
|
1076
|
+
"signal-resolved",
|
|
1077
|
+
session_platform(root, session_id),
|
|
1078
|
+
session_id,
|
|
1079
|
+
{"signal_id": signal_id, "signal_kind": kind, "turn_id": signal.get("turn_id")},
|
|
1080
|
+
)
|
|
1081
|
+
print(f"[context-guard] resolved signal: {signal_id} ({kind})")
|
|
1082
|
+
return signal
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def run_node_workbench(args: list[str], payload: object = None) -> dict:
|
|
1086
|
+
command = ["node", str(Path(__file__).resolve().parent / "workbench" / "cli.mjs"), *args]
|
|
1087
|
+
completed = subprocess.run(command, input=json.dumps(payload, ensure_ascii=False) if payload is not None else None,
|
|
1088
|
+
text=True, encoding="utf-8", capture_output=True, timeout=180,
|
|
1089
|
+
creationflags=WINDOWS_NO_WINDOW)
|
|
1090
|
+
try:
|
|
1091
|
+
result = json.loads(completed.stdout)
|
|
1092
|
+
except ValueError as exc:
|
|
1093
|
+
raise RuntimeError(completed.stderr or completed.stdout or "Node interface failed") from exc
|
|
1094
|
+
if completed.returncode or result.get("error"):
|
|
1095
|
+
raise RuntimeError(json.dumps(result, ensure_ascii=False))
|
|
1096
|
+
return result
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def attach_bug_to_map(ctx: Path, bug: dict[str, object], node_id: str, session_id: str) -> None:
|
|
1100
|
+
if not node_id:
|
|
1101
|
+
return
|
|
1102
|
+
args = ["attach-bug", "--root", str(ctx.parent.parent)]
|
|
1103
|
+
if session_id:
|
|
1104
|
+
args.extend(["--session", session_id])
|
|
1105
|
+
run_node_workbench(args, {"node": node_id, "bug": bug})
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def bad_case_transaction_dir(ctx: Path) -> Path:
|
|
1109
|
+
return ctx / "private" / "bad-case-transactions"
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def bad_case_failpoint(stage: str) -> None:
|
|
1113
|
+
if os.environ.get("CONTEXT_GUARD_TESTING") == "1" and os.environ.get("CONTEXT_GUARD_BAD_CASE_FAILPOINT") == stage:
|
|
1114
|
+
os._exit(91)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def remove_durable_file(path: Path) -> None:
|
|
1118
|
+
path.unlink(missing_ok=True)
|
|
1119
|
+
if os.name != "nt" and path.parent.exists():
|
|
1120
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
1121
|
+
try:
|
|
1122
|
+
os.fsync(directory)
|
|
1123
|
+
finally:
|
|
1124
|
+
os.close(directory)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def bad_case_transaction_path(ctx: Path, transaction_id: str) -> Path:
|
|
1128
|
+
digest = hashlib.sha256(transaction_id.encode("utf-8")).hexdigest()
|
|
1129
|
+
return bad_case_transaction_dir(ctx) / f"{digest}.json"
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def apply_bad_case_transaction(root: Path, transaction: dict[str, object], journal: Path) -> None:
|
|
1133
|
+
ctx = context_dir(root)
|
|
1134
|
+
operation = str(transaction.get("operation") or "")
|
|
1135
|
+
bug_id = str(transaction.get("bug_id") or "")
|
|
1136
|
+
session_id = str(transaction.get("session_id") or "")
|
|
1137
|
+
if operation not in {"occurrence", "fix"} or not re.fullmatch(r"B\d+", bug_id):
|
|
1138
|
+
raise ValueError(f"invalid bad-case recovery journal: {journal}")
|
|
1139
|
+
|
|
1140
|
+
bug_path = ctx / "bugs" / f"{bug_id}.md"
|
|
1141
|
+
fix_path = ctx / "fixes" / f"{bug_id}.md"
|
|
1142
|
+
atomic_write_text(bug_path, str(transaction["bug_text"]))
|
|
1143
|
+
bad_case_failpoint("after-bug-file")
|
|
1144
|
+
atomic_write_text(fix_path, str(transaction["fix_text"]))
|
|
1145
|
+
bad_case_failpoint("after-fix-file")
|
|
1146
|
+
|
|
1147
|
+
index = read_json(ctx / "bugs-index.json", {})
|
|
1148
|
+
if not isinstance(index, dict):
|
|
1149
|
+
index = {}
|
|
1150
|
+
index[bug_id] = transaction["index_entry"]
|
|
1151
|
+
write_json(ctx / "bugs-index.json", index)
|
|
1152
|
+
bad_case_failpoint("after-index")
|
|
1153
|
+
|
|
1154
|
+
map_bug = transaction.get("map_bug")
|
|
1155
|
+
if operation == "occurrence" and isinstance(map_bug, dict):
|
|
1156
|
+
attach_bug_to_map(ctx, map_bug, str(transaction.get("node") or ""), session_id)
|
|
1157
|
+
elif operation == "fix" and isinstance(transaction.get("index_entry"), dict) and transaction["index_entry"].get("card"):
|
|
1158
|
+
update_bug_on_map(ctx, bug_id, str(transaction.get("status") or "fixed"), session_id)
|
|
1159
|
+
bad_case_failpoint("after-map")
|
|
1160
|
+
|
|
1161
|
+
events = read_json(ctx / "bad-case-events.json", [])
|
|
1162
|
+
if not isinstance(events, list):
|
|
1163
|
+
events = []
|
|
1164
|
+
event = transaction.get("event")
|
|
1165
|
+
transaction_id = str(transaction.get("transaction_id") or "")
|
|
1166
|
+
if isinstance(event, dict) and not any(isinstance(item, dict) and item.get("transaction_id") == transaction_id for item in events):
|
|
1167
|
+
events.append(event)
|
|
1168
|
+
write_json(ctx / "bad-case-events.json", events)
|
|
1169
|
+
bad_case_failpoint("after-event")
|
|
1170
|
+
|
|
1171
|
+
signal_id = str(transaction.get("signal_id") or "")
|
|
1172
|
+
if operation == "occurrence" and signal_id:
|
|
1173
|
+
with hook_runtime_lock(root, session_id):
|
|
1174
|
+
resolve_prompt_signal(root, session_id, signal_id, "bad-case", str(transaction.get("node") or ""), bug_id)
|
|
1175
|
+
remove_durable_file(journal)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def recover_bad_case_transactions(root: Path) -> None:
|
|
1179
|
+
ctx = context_dir(root)
|
|
1180
|
+
directory = bad_case_transaction_dir(ctx)
|
|
1181
|
+
for journal in sorted(directory.glob("*.json")) if directory.exists() else []:
|
|
1182
|
+
transaction = read_json(journal, None)
|
|
1183
|
+
if not isinstance(transaction, dict):
|
|
1184
|
+
raise ValueError(f"bad-case recovery journal is unreadable; preserve and repair {journal}")
|
|
1185
|
+
apply_bad_case_transaction(root, transaction, journal)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
@serialize_named_lock("bad-case-registry")
|
|
1189
|
+
@serialize_hook_runtime(9)
|
|
413
1190
|
def record_bad_case(
|
|
414
1191
|
root: Path,
|
|
415
1192
|
title: str,
|
|
@@ -420,12 +1197,42 @@ def record_bad_case(
|
|
|
420
1197
|
node: str,
|
|
421
1198
|
status: str,
|
|
422
1199
|
keys: str,
|
|
1200
|
+
session_id: str,
|
|
1201
|
+
signal_id: str = "",
|
|
423
1202
|
) -> tuple[str, Path]:
|
|
424
1203
|
init_context(root)
|
|
1204
|
+
if status not in WRITABLE_BUG_STATUSES:
|
|
1205
|
+
raise ValueError("Bugs cannot be deferred; delete the Bug file if no fix is needed, or close it as unfixable")
|
|
425
1206
|
ctx = context_dir(root)
|
|
1207
|
+
recover_bad_case_transactions(root)
|
|
1208
|
+
events = read_json(ctx / "bad-case-events.json", [])
|
|
1209
|
+
if not isinstance(events, list):
|
|
1210
|
+
events = []
|
|
1211
|
+
if signal_id:
|
|
1212
|
+
prior = next((item for item in events if isinstance(item, dict) and item.get("signal_id") == signal_id), None)
|
|
1213
|
+
if prior and re.fullmatch(r"B\d+", str(prior.get("case") or "")):
|
|
1214
|
+
existing_id = str(prior["case"])
|
|
1215
|
+
existing_path = ctx / "bugs" / f"{existing_id}.md"
|
|
1216
|
+
if existing_path.is_file():
|
|
1217
|
+
print(f"[context-guard] recorded bad case: {existing_id} ({existing_path}) [duplicate]")
|
|
1218
|
+
return existing_id, existing_path
|
|
1219
|
+
runtime = read_hook_runtime(root, session_id)
|
|
1220
|
+
signals = runtime.get("signals") if isinstance(runtime.get("signals"), list) else []
|
|
1221
|
+
signal = next((item for item in signals if isinstance(item, dict) and item.get("id") == signal_id), None)
|
|
1222
|
+
if not signal:
|
|
1223
|
+
raise ValueError(f"unknown prompt signal: {signal_id}")
|
|
1224
|
+
if signal.get("status") == "resolved" and signal.get("kind") not in {None, "", "bad-case", "task"}:
|
|
1225
|
+
raise ValueError(f"prompt signal is already resolved as {signal.get('kind')}")
|
|
1226
|
+
if node:
|
|
1227
|
+
require_known_map_node(root, node, session_id)
|
|
1228
|
+
if session_id:
|
|
1229
|
+
known = {str(item.get("session_id")) for item in session_records(root)}
|
|
1230
|
+
if session_id not in known:
|
|
1231
|
+
raise ValueError("bad-case session must first be recorded by a lifecycle hook")
|
|
426
1232
|
bug_id = next_bug_id(ctx)
|
|
427
1233
|
key_list = [item.strip() for item in keys.split(",") if item.strip()]
|
|
428
1234
|
bug_path = ctx / "bugs" / f"{bug_id}.md"
|
|
1235
|
+
fix_path = ctx / "fixes" / f"{bug_id}.md"
|
|
429
1236
|
card_path = f".codex/context/cards/{node}.md" if node else ""
|
|
430
1237
|
lines = [
|
|
431
1238
|
f"# {bug_id} {title.strip()}",
|
|
@@ -433,14 +1240,31 @@ def record_bad_case(
|
|
|
433
1240
|
f"- node: {node or 'unassigned'}",
|
|
434
1241
|
f"- status: {status}",
|
|
435
1242
|
f"- 现象: {phenomenon.strip()}",
|
|
436
|
-
f"- 触发: {trigger.strip()}",
|
|
437
|
-
f"- 原因: {cause.strip() or '待确认'}",
|
|
438
|
-
f"- guard: {guard.strip() or '待补充'}",
|
|
439
1243
|
f"- keys: {', '.join(key_list)}",
|
|
1244
|
+
f"- sessions: {session_id or 'unassigned'}",
|
|
1245
|
+
f"- fix: .codex/context/fixes/{bug_id}.md",
|
|
440
1246
|
]
|
|
441
1247
|
if card_path:
|
|
442
1248
|
lines.append(f"- card: {card_path}")
|
|
443
|
-
|
|
1249
|
+
bug_text = "\n".join(lines) + "\n"
|
|
1250
|
+
fix_lines = [
|
|
1251
|
+
f"# {bug_id} {title.strip()}",
|
|
1252
|
+
"",
|
|
1253
|
+
f"- bug: .codex/context/bugs/{bug_id}.md",
|
|
1254
|
+
f"- node: {node or 'unassigned'}",
|
|
1255
|
+
f"- status: {status}",
|
|
1256
|
+
]
|
|
1257
|
+
if card_path:
|
|
1258
|
+
fix_lines.append(f"- card: {card_path}")
|
|
1259
|
+
fix_lines.extend([
|
|
1260
|
+
"", "## 触发", trigger.strip() or "待补充",
|
|
1261
|
+
"", "## 根因", cause.strip() or "待确认",
|
|
1262
|
+
"", "## 怎么修", "待记录修复方法" if status == "fixed" else "未修",
|
|
1263
|
+
"", "## 怎么防", guard.strip() or "待补充",
|
|
1264
|
+
"", "## 代码", "待补充",
|
|
1265
|
+
"", "## 证据", "待补充",
|
|
1266
|
+
])
|
|
1267
|
+
fix_text = "\n".join(fix_lines) + "\n"
|
|
444
1268
|
|
|
445
1269
|
index = read_json(ctx / "bugs-index.json", {})
|
|
446
1270
|
if not isinstance(index, dict):
|
|
@@ -451,131 +1275,130 @@ def record_bad_case(
|
|
|
451
1275
|
"status": status,
|
|
452
1276
|
"bug": f".codex/context/bugs/{bug_id}.md",
|
|
453
1277
|
"fix": f".codex/context/fixes/{bug_id}.md",
|
|
1278
|
+
"sessions": [session_id] if session_id else [],
|
|
454
1279
|
}
|
|
455
1280
|
if card_path:
|
|
456
1281
|
entry["card"] = card_path
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
1282
|
+
transaction_id = f"bad-case:{bug_id}:occurrence"
|
|
1283
|
+
event = {
|
|
1284
|
+
"at": utc_now(),
|
|
1285
|
+
"event": "occurrence",
|
|
1286
|
+
"case": bug_id,
|
|
1287
|
+
"status": status,
|
|
1288
|
+
"session_id": session_id or None,
|
|
1289
|
+
"phenomenon": phenomenon.strip(),
|
|
1290
|
+
"trigger": trigger.strip(),
|
|
1291
|
+
"signal_id": signal_id or None,
|
|
1292
|
+
"transaction_id": transaction_id,
|
|
1293
|
+
}
|
|
1294
|
+
transaction: dict[str, object] = {
|
|
1295
|
+
"v": 1,
|
|
1296
|
+
"transaction_id": transaction_id,
|
|
1297
|
+
"operation": "occurrence",
|
|
1298
|
+
"bug_id": bug_id,
|
|
1299
|
+
"node": node,
|
|
1300
|
+
"session_id": session_id,
|
|
1301
|
+
"signal_id": signal_id,
|
|
1302
|
+
"status": status,
|
|
1303
|
+
"bug_text": bug_text,
|
|
1304
|
+
"fix_text": fix_text,
|
|
1305
|
+
"index_entry": entry,
|
|
1306
|
+
"map_bug": {
|
|
463
1307
|
"id": bug_id,
|
|
464
1308
|
"title": title.strip(),
|
|
465
1309
|
"desc": phenomenon.strip(),
|
|
466
1310
|
"status": status,
|
|
467
1311
|
"files": "",
|
|
468
|
-
"sessions":
|
|
1312
|
+
"sessions": [session_id] if session_id else [],
|
|
469
1313
|
"record": f".codex/context/bugs/{bug_id}.md",
|
|
470
1314
|
},
|
|
471
|
-
|
|
472
|
-
|
|
1315
|
+
"event": event,
|
|
1316
|
+
}
|
|
1317
|
+
journal = bad_case_transaction_path(ctx, transaction_id)
|
|
1318
|
+
write_json(journal, transaction)
|
|
1319
|
+
apply_bad_case_transaction(root, transaction, journal)
|
|
473
1320
|
print(f"[context-guard] recorded bad case: {bug_id} ({bug_path})")
|
|
474
1321
|
return bug_id, bug_path
|
|
475
1322
|
|
|
476
1323
|
|
|
477
|
-
def
|
|
478
|
-
|
|
1324
|
+
def replace_markdown_section(text: str, heading: str, value: str) -> str:
|
|
1325
|
+
pattern = re.compile(rf"(?ms)(^## {re.escape(heading)}\n).*?(?=^## |\Z)")
|
|
1326
|
+
if pattern.search(text):
|
|
1327
|
+
return pattern.sub(lambda match: match.group(1) + value.strip() + "\n\n", text, count=1).rstrip() + "\n"
|
|
1328
|
+
return text.rstrip() + f"\n\n## {heading}\n{value.strip()}\n"
|
|
479
1329
|
|
|
480
1330
|
|
|
481
|
-
def
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return data if isinstance(data, dict) else None
|
|
487
|
-
except (OSError, ValueError, urllib.error.URLError) as exc:
|
|
488
|
-
if report_error:
|
|
489
|
-
print(f"[context-guard] health request failed: {exc}", file=sys.stderr)
|
|
490
|
-
return None
|
|
1331
|
+
def update_bug_on_map(ctx: Path, bug_id: str, status: str, session_id: str) -> None:
|
|
1332
|
+
args = ["update-bug", "--root", str(ctx.parent.parent)]
|
|
1333
|
+
if session_id:
|
|
1334
|
+
args.extend(["--session", session_id])
|
|
1335
|
+
run_node_workbench(args, {"bug": {"id": bug_id, "status": status}})
|
|
491
1336
|
|
|
492
1337
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
return str(candidate)
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
class WorkbenchServer(ThreadingHTTPServer):
|
|
561
|
-
daemon_threads = True
|
|
562
|
-
|
|
563
|
-
def server_bind(self) -> None:
|
|
564
|
-
# HTTPServer performs reverse DNS here. A loopback-only workbench does
|
|
565
|
-
# not need it, and macOS DNS lookup can stall before serving requests.
|
|
566
|
-
TCPServer.server_bind(self)
|
|
567
|
-
host, port = self.server_address[:2]
|
|
568
|
-
self.server_name = host
|
|
569
|
-
self.server_port = port
|
|
570
|
-
|
|
571
|
-
def __init__(self, address: tuple[str, int], root: Path):
|
|
572
|
-
self.project_root = root.resolve()
|
|
573
|
-
self.skill_root = context_guard_skill_root()
|
|
574
|
-
super().__init__(address, WorkbenchHandler)
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
def workbench_url(host: str, port: int) -> str:
|
|
578
|
-
return f"http://{host}:{port}/prototype/workbench.html"
|
|
1338
|
+
@serialize_named_lock("bad-case-registry")
|
|
1339
|
+
@serialize_hook_runtime(5)
|
|
1340
|
+
def record_bad_case_fix(
|
|
1341
|
+
root: Path,
|
|
1342
|
+
bug_id: str,
|
|
1343
|
+
method: str,
|
|
1344
|
+
evidence: str,
|
|
1345
|
+
status: str,
|
|
1346
|
+
session_id: str,
|
|
1347
|
+
) -> Path:
|
|
1348
|
+
init_context(root)
|
|
1349
|
+
ctx = context_dir(root)
|
|
1350
|
+
recover_bad_case_transactions(root)
|
|
1351
|
+
bug_id = bug_id.strip().upper()
|
|
1352
|
+
if not re.fullmatch(r"B\d+", bug_id):
|
|
1353
|
+
raise ValueError("case must use the B<number> identifier")
|
|
1354
|
+
bug_path = ctx / "bugs" / f"{bug_id}.md"
|
|
1355
|
+
fix_path = ctx / "fixes" / f"{bug_id}.md"
|
|
1356
|
+
if not bug_path.is_file() or not fix_path.is_file():
|
|
1357
|
+
raise ValueError(f"unknown bad case: {bug_id}")
|
|
1358
|
+
if not method.strip() or not evidence.strip():
|
|
1359
|
+
raise ValueError("record-bad-case-fix needs --method and --evidence")
|
|
1360
|
+
if status not in WRITABLE_BUG_STATUSES:
|
|
1361
|
+
raise ValueError("Bugs cannot be deferred; delete the Bug file if no fix is needed, or close it as unfixable")
|
|
1362
|
+
bug_text = re.sub(r"(?m)^- status: .*?$", f"- status: {status}", bug_path.read_text(encoding="utf-8"), count=1)
|
|
1363
|
+
fix_text = re.sub(r"(?m)^- status: .*?$", f"- status: {status}", fix_path.read_text(encoding="utf-8"), count=1)
|
|
1364
|
+
fix_text = replace_markdown_section(fix_text, "怎么修", method)
|
|
1365
|
+
fix_text = replace_markdown_section(fix_text, "证据", evidence)
|
|
1366
|
+
index = read_json(ctx / "bugs-index.json", {})
|
|
1367
|
+
if not isinstance(index, dict) or not isinstance(index.get(bug_id), dict):
|
|
1368
|
+
raise ValueError(f"bad case is missing from bugs-index.json: {bug_id}")
|
|
1369
|
+
index[bug_id]["status"] = status
|
|
1370
|
+
if session_id:
|
|
1371
|
+
sessions = index[bug_id].setdefault("sessions", [])
|
|
1372
|
+
if isinstance(sessions, list) and session_id not in sessions:
|
|
1373
|
+
sessions.append(session_id)
|
|
1374
|
+
transaction_id = f"bad-case:{bug_id}:fix:{hashlib.sha256(json.dumps([status, method.strip(), evidence.strip()], ensure_ascii=False).encode('utf-8')).hexdigest()[:16]}"
|
|
1375
|
+
event = {
|
|
1376
|
+
"at": utc_now(),
|
|
1377
|
+
"event": "fix",
|
|
1378
|
+
"case": bug_id,
|
|
1379
|
+
"status": status,
|
|
1380
|
+
"session_id": session_id or None,
|
|
1381
|
+
"method": method.strip(),
|
|
1382
|
+
"evidence": evidence.strip(),
|
|
1383
|
+
"transaction_id": transaction_id,
|
|
1384
|
+
}
|
|
1385
|
+
transaction: dict[str, object] = {
|
|
1386
|
+
"v": 1,
|
|
1387
|
+
"transaction_id": transaction_id,
|
|
1388
|
+
"operation": "fix",
|
|
1389
|
+
"bug_id": bug_id,
|
|
1390
|
+
"session_id": session_id,
|
|
1391
|
+
"status": status,
|
|
1392
|
+
"bug_text": bug_text,
|
|
1393
|
+
"fix_text": fix_text,
|
|
1394
|
+
"index_entry": index[bug_id],
|
|
1395
|
+
"event": event,
|
|
1396
|
+
}
|
|
1397
|
+
journal = bad_case_transaction_path(ctx, transaction_id)
|
|
1398
|
+
write_json(journal, transaction)
|
|
1399
|
+
apply_bad_case_transaction(root, transaction, journal)
|
|
1400
|
+
print(f"[context-guard] recorded bad case fix: {bug_id} ({fix_path})")
|
|
1401
|
+
return fix_path
|
|
579
1402
|
|
|
580
1403
|
|
|
581
1404
|
def validate_workbench_host(host: str) -> None:
|
|
@@ -586,23 +1409,9 @@ def validate_workbench_host(host: str) -> None:
|
|
|
586
1409
|
def serve_workbench(root: Path, host: str, port: int) -> int:
|
|
587
1410
|
validate_workbench_host(host)
|
|
588
1411
|
init_context(root)
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
url = workbench_url(host, actual_port)
|
|
593
|
-
state = {"pid": os.getpid(), "root": str(root.resolve()), "url": url, "started": utc_now()}
|
|
594
|
-
write_json(workbench_state_path(root), state)
|
|
595
|
-
print(f"[context-guard] workbench: {url}", flush=True)
|
|
596
|
-
try:
|
|
597
|
-
server.serve_forever(poll_interval=0.2)
|
|
598
|
-
except KeyboardInterrupt:
|
|
599
|
-
pass
|
|
600
|
-
finally:
|
|
601
|
-
server.server_close()
|
|
602
|
-
current = read_json(workbench_state_path(root), {})
|
|
603
|
-
if isinstance(current, dict) and current.get("pid") == os.getpid():
|
|
604
|
-
workbench_state_path(root).unlink(missing_ok=True)
|
|
605
|
-
return 0
|
|
1412
|
+
return subprocess.call(["node", str(Path(__file__).resolve().parent / "workbench" / "cli.mjs"),
|
|
1413
|
+
"serve", "--root", str(root), "--host", host, "--port", str(port)],
|
|
1414
|
+
creationflags=WINDOWS_NO_WINDOW)
|
|
606
1415
|
|
|
607
1416
|
|
|
608
1417
|
def maybe_open_browser(url: str, enabled: bool) -> None:
|
|
@@ -614,97 +1423,32 @@ def maybe_open_browser(url: str, enabled: bool) -> None:
|
|
|
614
1423
|
pass
|
|
615
1424
|
|
|
616
1425
|
|
|
617
|
-
def start_workbench(
|
|
618
|
-
|
|
619
|
-
host: str = "127.0.0.1",
|
|
620
|
-
port: int = 8877,
|
|
621
|
-
open_browser: bool = True,
|
|
622
|
-
) -> str | None:
|
|
1426
|
+
def start_workbench(root: Path, host: str = "127.0.0.1", port: int = 8877, open_browser: bool = True, raise_errors: bool = False,
|
|
1427
|
+
session_id: str = "") -> str | None:
|
|
623
1428
|
validate_workbench_host(host)
|
|
624
1429
|
if os.environ.get("CONTEXT_GUARD_DISABLE_WORKBENCH") == "1":
|
|
625
1430
|
return None
|
|
626
1431
|
init_context(root)
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1432
|
+
try:
|
|
1433
|
+
should_open = open_browser and os.environ.get("CONTEXT_GUARD_HEADLESS") != "1" and not os.environ.get("CI")
|
|
1434
|
+
result = run_node_workbench([
|
|
1435
|
+
"workbench", "--root", str(root), "--port", str(port),
|
|
1436
|
+
*(["--session", session_id] if session_id else []),
|
|
1437
|
+
*(["--claim-open"] if should_open else []),
|
|
1438
|
+
])
|
|
1439
|
+
url = result["url"]
|
|
1440
|
+
maybe_open_browser(url, should_open and result.get("shouldOpen", False))
|
|
631
1441
|
return url
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
"workbench",
|
|
638
|
-
"--root",
|
|
639
|
-
str(root.resolve()),
|
|
640
|
-
"--host",
|
|
641
|
-
host,
|
|
642
|
-
"--port",
|
|
643
|
-
str(port),
|
|
644
|
-
"--foreground",
|
|
645
|
-
"--no-open",
|
|
646
|
-
]
|
|
647
|
-
kwargs: dict[str, object] = {
|
|
648
|
-
"cwd": str(root),
|
|
649
|
-
"stdin": subprocess.DEVNULL,
|
|
650
|
-
"stdout": subprocess.DEVNULL,
|
|
651
|
-
"stderr": subprocess.DEVNULL,
|
|
652
|
-
"close_fds": True,
|
|
653
|
-
}
|
|
654
|
-
if os.name == "nt":
|
|
655
|
-
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
656
|
-
else:
|
|
657
|
-
kwargs["start_new_session"] = True
|
|
658
|
-
log_path = workbench_state_path(root).with_suffix(".log")
|
|
659
|
-
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
660
|
-
with log_path.open("w", encoding="utf-8") as log_file:
|
|
661
|
-
kwargs["stdout"] = log_file
|
|
662
|
-
kwargs["stderr"] = log_file
|
|
663
|
-
process = subprocess.Popen(command, **kwargs)
|
|
664
|
-
url = workbench_url(host, port)
|
|
665
|
-
deadline = time.monotonic() + 10
|
|
666
|
-
health = None
|
|
667
|
-
while time.monotonic() < deadline:
|
|
668
|
-
health = workbench_health(url, timeout=0.2)
|
|
669
|
-
if health and health.get("root") == str(root.resolve()):
|
|
670
|
-
maybe_open_browser(url, open_browser)
|
|
671
|
-
return url
|
|
672
|
-
if process.poll() is not None:
|
|
673
|
-
break
|
|
674
|
-
time.sleep(0.1)
|
|
675
|
-
exit_code = process.poll()
|
|
676
|
-
workbench_health(url, report_error=True)
|
|
677
|
-
if exit_code is None:
|
|
678
|
-
process.terminate()
|
|
679
|
-
detail = log_path.read_text(encoding="utf-8", errors="replace").strip()
|
|
680
|
-
print(
|
|
681
|
-
f"[context-guard] workbench startup failed; exit={exit_code}; "
|
|
682
|
-
f"health={health!r}; state={read_json(workbench_state_path(root), {})!r}; "
|
|
683
|
-
f"expected_root={str(root.resolve())!r}; log: {log_path}"
|
|
684
|
-
+ (f"\n{detail[-2000:]}" if detail else ""),
|
|
685
|
-
file=sys.stderr,
|
|
686
|
-
)
|
|
687
|
-
return None
|
|
1442
|
+
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
|
|
1443
|
+
print(f"[context-guard] Node workbench: {exc}", file=sys.stderr)
|
|
1444
|
+
if raise_errors:
|
|
1445
|
+
raise
|
|
1446
|
+
return None
|
|
688
1447
|
|
|
689
1448
|
|
|
690
1449
|
def stop_workbench(root: Path) -> bool:
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
workbench_state_path(root).unlink(missing_ok=True)
|
|
694
|
-
return False
|
|
695
|
-
pid = state.get("pid")
|
|
696
|
-
if not isinstance(pid, int) or pid == os.getpid():
|
|
697
|
-
return False
|
|
698
|
-
try:
|
|
699
|
-
os.kill(pid, signal.SIGTERM)
|
|
700
|
-
except OSError:
|
|
701
|
-
return False
|
|
702
|
-
for _ in range(20):
|
|
703
|
-
if not workbench_health(str(state["url"]), timeout=0.1):
|
|
704
|
-
break
|
|
705
|
-
time.sleep(0.1)
|
|
706
|
-
workbench_state_path(root).unlink(missing_ok=True)
|
|
707
|
-
return True
|
|
1450
|
+
result = run_node_workbench(["workbench", "--root", str(root), "--stop"])
|
|
1451
|
+
return bool(result.get("stopped"))
|
|
708
1452
|
|
|
709
1453
|
|
|
710
1454
|
def show_roadmap(root: Path, should_open: bool) -> int:
|
|
@@ -717,28 +1461,29 @@ def show_roadmap(root: Path, should_open: bool) -> int:
|
|
|
717
1461
|
return 0
|
|
718
1462
|
|
|
719
1463
|
|
|
720
|
-
def parked_command(name: str) -> int:
|
|
721
|
-
print(
|
|
722
|
-
f"[context-guard] `{name}` is parked. v1 is sessions / bugs / tasks / map. "
|
|
723
|
-
"See TODO.md at the repo root. Do not expand Test Hub or Roadmap HTML.",
|
|
724
|
-
file=sys.stderr,
|
|
725
|
-
)
|
|
726
|
-
return 2
|
|
727
|
-
|
|
728
|
-
|
|
729
1464
|
def main() -> int:
|
|
730
1465
|
configure_stdio()
|
|
731
1466
|
parser = argparse.ArgumentParser(description="Context Guard v1 utilities")
|
|
732
1467
|
parser.add_argument(
|
|
733
1468
|
"command",
|
|
734
|
-
choices=[
|
|
1469
|
+
choices=[
|
|
1470
|
+
"init", "set-language", "workbench", "record-bad-case",
|
|
1471
|
+
"record-bad-case-fix", "record-todo", "resolve-signal", "archive-session",
|
|
1472
|
+
"write-candidates",
|
|
1473
|
+
"plan-start", "plan-finish", "plan-status", "split-signal",
|
|
1474
|
+
],
|
|
735
1475
|
)
|
|
736
1476
|
parser.add_argument("--root", type=Path, default=None)
|
|
737
1477
|
parser.add_argument("--language", default=None)
|
|
738
|
-
parser.add_argument("--open", action="store_true")
|
|
739
1478
|
parser.add_argument("--no-open", action="store_true")
|
|
740
1479
|
parser.add_argument("--foreground", action="store_true")
|
|
741
1480
|
parser.add_argument("--stop", action="store_true")
|
|
1481
|
+
parser.add_argument("--binding-status", action="store_true")
|
|
1482
|
+
parser.add_argument("--workbench-url")
|
|
1483
|
+
parser.add_argument("--bind-main")
|
|
1484
|
+
parser.add_argument("--local-main")
|
|
1485
|
+
parser.add_argument("--remote", default="origin")
|
|
1486
|
+
parser.add_argument("--rebind", action="store_true")
|
|
742
1487
|
parser.add_argument("--host", default="127.0.0.1")
|
|
743
1488
|
parser.add_argument("--port", type=int, default=8877)
|
|
744
1489
|
parser.add_argument("--title", default="")
|
|
@@ -747,16 +1492,47 @@ def main() -> int:
|
|
|
747
1492
|
parser.add_argument("--cause", default="")
|
|
748
1493
|
parser.add_argument("--guard", default="")
|
|
749
1494
|
parser.add_argument("--node", default="")
|
|
750
|
-
parser.add_argument("--status", choices=
|
|
1495
|
+
parser.add_argument("--status", choices=list(WRITABLE_BUG_STATUSES), default="open")
|
|
751
1496
|
parser.add_argument("--keys", default="")
|
|
1497
|
+
parser.add_argument("--session", default="")
|
|
1498
|
+
parser.add_argument("--case", dest="case_id", default="")
|
|
1499
|
+
parser.add_argument("--method", default="")
|
|
1500
|
+
parser.add_argument("--evidence", default="")
|
|
1501
|
+
parser.add_argument("--summary", default="")
|
|
1502
|
+
parser.add_argument("--description", default="")
|
|
1503
|
+
parser.add_argument("--signal", default="")
|
|
1504
|
+
parser.add_argument("--kind", default="")
|
|
1505
|
+
parser.add_argument("--decisions", default="")
|
|
1506
|
+
parser.add_argument("--next", dest="next_steps", default="")
|
|
1507
|
+
parser.add_argument("--files", default="")
|
|
1508
|
+
parser.add_argument("--input", default="")
|
|
752
1509
|
args, _unknown = parser.parse_known_args()
|
|
753
1510
|
explicit = args.root is not None
|
|
754
1511
|
root = (args.root or folder_root(Path.cwd())).resolve()
|
|
755
1512
|
blocked = guard_implicit_skill_root(root, explicit)
|
|
756
1513
|
if blocked:
|
|
757
1514
|
return blocked
|
|
758
|
-
if args.command
|
|
759
|
-
|
|
1515
|
+
if args.command == "split-signal":
|
|
1516
|
+
try:
|
|
1517
|
+
data = read_input_json(args.input)
|
|
1518
|
+
if not isinstance(data, dict):
|
|
1519
|
+
raise ValueError("split-signal input needs an items array")
|
|
1520
|
+
print(json.dumps(split_signal(root, resolve_session_id(root, args.session), args.signal, data.get("items")), ensure_ascii=False))
|
|
1521
|
+
return 0
|
|
1522
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
1523
|
+
print(f"[context-guard] split-signal failed: {exc}", file=sys.stderr)
|
|
1524
|
+
return 1
|
|
1525
|
+
if args.command.startswith("plan-"):
|
|
1526
|
+
try:
|
|
1527
|
+
from context_guard_hook import plan_command
|
|
1528
|
+
data = read_input_json(args.input) if args.input else {}
|
|
1529
|
+
if not isinstance(data, dict):
|
|
1530
|
+
raise ValueError("plan input must be an object")
|
|
1531
|
+
print(json.dumps(plan_command(root, resolve_session_id(root, args.session), args.command, data), ensure_ascii=False))
|
|
1532
|
+
return 0
|
|
1533
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
1534
|
+
print(f"[context-guard] {args.command} failed: {exc}", file=sys.stderr)
|
|
1535
|
+
return 1
|
|
760
1536
|
if args.command == "init":
|
|
761
1537
|
created = init_context(root)
|
|
762
1538
|
print(f"[context-guard] context: {context_dir(root)}")
|
|
@@ -773,19 +1549,87 @@ def main() -> int:
|
|
|
773
1549
|
if not args.title or not args.phenomenon:
|
|
774
1550
|
print("[context-guard] record-bad-case needs --title and --phenomenon", file=sys.stderr)
|
|
775
1551
|
return 2
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1552
|
+
try:
|
|
1553
|
+
record_bad_case(
|
|
1554
|
+
root,
|
|
1555
|
+
args.title,
|
|
1556
|
+
args.phenomenon,
|
|
1557
|
+
args.trigger,
|
|
1558
|
+
args.cause,
|
|
1559
|
+
args.guard,
|
|
1560
|
+
args.node,
|
|
1561
|
+
args.status,
|
|
1562
|
+
args.keys,
|
|
1563
|
+
resolve_session_id(root, args.session),
|
|
1564
|
+
args.signal,
|
|
1565
|
+
)
|
|
1566
|
+
return 0
|
|
1567
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
1568
|
+
print(f"[context-guard] record-bad-case failed: {exc}", file=sys.stderr)
|
|
1569
|
+
return 1
|
|
1570
|
+
if args.command == "record-todo":
|
|
1571
|
+
try:
|
|
1572
|
+
record_todo(
|
|
1573
|
+
root, args.title, args.description, args.node,
|
|
1574
|
+
resolve_session_id(root, args.session), args.signal,
|
|
1575
|
+
)
|
|
1576
|
+
return 0
|
|
1577
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
1578
|
+
print(f"[context-guard] record-todo failed: {exc}", file=sys.stderr)
|
|
1579
|
+
return 1
|
|
1580
|
+
if args.command == "resolve-signal":
|
|
1581
|
+
try:
|
|
1582
|
+
resolve_signal(root, resolve_session_id(root, args.session), args.signal, args.kind)
|
|
1583
|
+
return 0
|
|
1584
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
1585
|
+
print(f"[context-guard] resolve-signal failed: {exc}", file=sys.stderr)
|
|
1586
|
+
return 1
|
|
1587
|
+
if args.command == "record-bad-case-fix":
|
|
1588
|
+
try:
|
|
1589
|
+
record_bad_case_fix(
|
|
1590
|
+
root, args.case_id, args.method, args.evidence, args.status,
|
|
1591
|
+
resolve_session_id(root, args.session),
|
|
1592
|
+
)
|
|
1593
|
+
return 0
|
|
1594
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
1595
|
+
print(f"[context-guard] record-bad-case-fix failed: {exc}", file=sys.stderr)
|
|
1596
|
+
return 1
|
|
1597
|
+
if args.command == "archive-session":
|
|
1598
|
+
if not any((args.summary.strip(), args.decisions.strip(), args.next_steps.strip(), args.files.strip())):
|
|
1599
|
+
print("[context-guard] archive-session needs durable --summary, --decisions, --next, or --files", file=sys.stderr)
|
|
1600
|
+
return 2
|
|
1601
|
+
try:
|
|
1602
|
+
archive_session(
|
|
1603
|
+
root, resolve_session_id(root, args.session), args.summary,
|
|
1604
|
+
args.decisions, args.next_steps, args.files, args.input,
|
|
1605
|
+
)
|
|
1606
|
+
return 0
|
|
1607
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
1608
|
+
print(f"[context-guard] archive-session failed: {exc}", file=sys.stderr)
|
|
1609
|
+
return 1
|
|
1610
|
+
if args.command == "write-candidates":
|
|
1611
|
+
if not args.input:
|
|
1612
|
+
print("[context-guard] write-candidates needs --input <json-file-or->", file=sys.stderr)
|
|
1613
|
+
return 2
|
|
1614
|
+
try:
|
|
1615
|
+
write_candidates(root, args.input)
|
|
1616
|
+
return 0
|
|
1617
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1618
|
+
print(f"[context-guard] write-candidates failed: {exc}", file=sys.stderr)
|
|
1619
|
+
return 1
|
|
788
1620
|
if args.command == "workbench":
|
|
1621
|
+
if args.binding_status or args.bind_main or args.local_main or args.session:
|
|
1622
|
+
command = ["workbench", "--root", str(root)]
|
|
1623
|
+
for key, value in [("binding-status", args.binding_status), ("workbench-url", args.workbench_url), ("bind-main", args.bind_main), ("local-main", args.local_main), ("remote", args.remote), ("session", args.session), ("rebind", args.rebind)]:
|
|
1624
|
+
if value:
|
|
1625
|
+
command.append("--" + key)
|
|
1626
|
+
if value is not True:
|
|
1627
|
+
command.append(str(value))
|
|
1628
|
+
result = run_node_workbench(command)
|
|
1629
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
1630
|
+
if result.get("url"):
|
|
1631
|
+
maybe_open_browser(result["url"], not args.no_open)
|
|
1632
|
+
return 0
|
|
789
1633
|
if args.stop:
|
|
790
1634
|
stopped = stop_workbench(root)
|
|
791
1635
|
print(f"[context-guard] workbench: {'stopped' if stopped else 'not running'}")
|
|
@@ -797,8 +1641,6 @@ def main() -> int:
|
|
|
797
1641
|
except (OSError, ValueError) as exc:
|
|
798
1642
|
print(f"[context-guard] workbench failed: {exc}", file=sys.stderr)
|
|
799
1643
|
return 1
|
|
800
|
-
if args.command == "show-roadmap":
|
|
801
|
-
return show_roadmap(root, args.open and not args.no_open)
|
|
802
1644
|
return 2
|
|
803
1645
|
|
|
804
1646
|
|