[CLAUDE] Docs: S148 — session-model port form hub + /check-email 4 cửa + Sàn-3 dual-accept
Session-model (owner chốt "đối ứng đúng chính xác như hub"):
- Form hub: _context-s-<N>.md (STOCK-map + FLOW append-only + STOCK-touched)
+ _pause-<i>/_tiep-<i> marker 5-trường + _snapshot-<i> + _end (thay closed.md)
- Port /snapshot + scripts/session_scaffold.py (near-verbatim)
+ scripts/session_ctx.py TRIMMED CÓ KHAI (chỉ machine-block + secrets-sweep;
KHÔNG port jsonl/overhead/cap-getter vì chưa có caller = ghost-wire)
- session-2 migrate sang form hub; session-1 giữ legacy (FROZEN)
Sàn-3 ORPHAN-L:
- DUAL-ACCEPT hub + legacy; glob pause-* KHÔNG khớp _pause-1.md nên không đếm đôi
- VÁ bug có sẵn từ S146: chốt-kết ĐÓNG TRỌN thư-mục (bản cũ c=1 chỉ tha 1 pause,
lệch chính câu session-end §6.3-bis vẫn nói "mọi pause")
- Fault-inject 10/10 hai chiều + anti-Goodhart
/check-email:
- Wire 4 CỬA phiên (session-start/tiep/session-end/pause), 2 CHẾ-ĐỘ:
DÒ ~5ms ở cửa dừng-nối (ràng buộc BINDING hub goi-chot §3) ⟂ KÉO ở bookend
- DÒ quét 2 kênh + định tuyến: outbox/se -> /check-email · outbox/all -> /adap-apply
- STAGE-2: 10 thư fan-out verify 2 tuyến 10/10 -> inbox/ai_infra/; backlog root = 0
HANDOFF re-stamp: #1 ĐÓNG (trio đã chạy S144) · #2 đổi trục · #3 anh chốt (a)
+ 4 mục mới (13)-(16); carry #15/#17 đóng, #16 đóng nửa (khai rõ vế còn hở)
H24 tick S147->S148: counter 21->22 CLEAN
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
220
scripts/session_ctx.py
Normal file
220
scripts/session_ctx.py
Normal file
@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""session_ctx.py -- session-model derive helpers (SE port, S148 2026-07-24).
|
||||
|
||||
SE PORT of AI_INFRA scripts/session_ctx.py -- 🔴 TRIMMED ON PURPOSE, KHAI RO:
|
||||
hub's original (~26 KB) also carries jsonl transcript parsing, anh-message containment
|
||||
verification, token-overhead accounting and a context-cap getter. SE ports only the TWO
|
||||
capabilities its rituals actually call:
|
||||
|
||||
machine-block -> derive {anchor, changed_files, run_id} for /snapshot + /pause
|
||||
secrets-sweep -> pre-commit Category-5 gate over a session dir
|
||||
|
||||
NOT ported (do NOT claim these exist here): verify-containment / overhead / cap-getter /
|
||||
window-jsonl resolution. Porting them without a caller would be ghost-wire -- the exact
|
||||
class SE keeps getting bitten by. Add one ONLY when a ritual actually needs it.
|
||||
|
||||
Fidelity pin: field semantics + CLI shape follow hub so a future hub change can be
|
||||
re-pulled by diff. Behaviour differences from hub are marked `SE-DELTA`.
|
||||
|
||||
stdlib-only . Python 3.11+ . UTF-8 no-BOM . LF . ASCII-only source.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_SESSIONS_ROOT = ROOT / ".claude" / "sessions"
|
||||
|
||||
# Commits authored by the Stop-hook (wal-flush.ps1) -- never a valid anchor.
|
||||
_WAL_SUBJECT_RE = re.compile(r'^wal:')
|
||||
# runId as written into WAL by the hmw run-trace convention.
|
||||
_RUNID_RE = re.compile(r'\b(wf_[A-Za-z0-9_-]+)')
|
||||
|
||||
# Category-5 secret prefixes. ACCEPTED-GAP: pattern-bounded, token-anchored -- this is a
|
||||
# conservative pre-commit BLOCK, NOT a proof of cleanliness (a DB password in an anh-msg
|
||||
# verbatim would pass). Say so; do not sell it as complete.
|
||||
SECRET_PATTERNS = ('voyage', 'sk-', 'gitea_pat', 'cfut_')
|
||||
_SECRET_RES = [(p, re.compile(r'\b' + re.escape(p), re.I)) for p in SECRET_PATTERNS]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# git helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _git(args, repo_root):
|
||||
"""Run `git -C <repo_root> <args>`; return stdout. Fail-loud: a bad range is a real
|
||||
error for a derive that must be reproducible."""
|
||||
try:
|
||||
r = subprocess.run(['git', '-C', str(repo_root)] + list(args),
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace')
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
raise SystemExit(f"[session_ctx] git failed to launch: {e}")
|
||||
if r.returncode != 0:
|
||||
raise SystemExit(f"[session_ctx] git {' '.join(args)} -> rc={r.returncode}: {r.stderr.strip()}")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def _parse_log_line(line):
|
||||
parts = line.split('\x1f')
|
||||
return {'sha': parts[0], 'subject': parts[1], 'ts': parts[2]} if len(parts) == 3 else None
|
||||
|
||||
|
||||
def find_anchor(repo_root, max_scan=100):
|
||||
"""Nearest NON-wal named commit walking git log from HEAD -> {sha, subject, ts} or None.
|
||||
|
||||
Why not HEAD: the Stop-hook auto-commits `wal:` at every turn boundary, and closeout
|
||||
squash rewrites those shas. Anchoring on a named commit survives the rewrite; anchoring
|
||||
on HEAD rots (sha AND subject/ts both move)."""
|
||||
out = _git(['log', f'-n{max_scan}', '--format=%H%x1f%s%x1f%cI'], repo_root)
|
||||
for line in out.splitlines():
|
||||
rec = _parse_log_line(line)
|
||||
if rec is None or _WAL_SUBJECT_RE.match(rec['subject']):
|
||||
continue
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_ref(ref, repo_root):
|
||||
"""Resolve an explicit anchor ref (sha/tag/HEAD~n) to {sha, subject, ts}."""
|
||||
out = _git(['log', '-n1', '--format=%H%x1f%s%x1f%cI', ref], repo_root).strip()
|
||||
return _parse_log_line(out.splitlines()[0]) if out else None
|
||||
|
||||
|
||||
def _iso_utc(epoch):
|
||||
return datetime.datetime.fromtimestamp(epoch, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S')
|
||||
|
||||
|
||||
def last_snapshot_mtime(session_dir):
|
||||
"""mtime of the most recent _snapshot-* file (fallback --since anchor). None if absent."""
|
||||
snaps = [s for s in Path(session_dir).glob('_snapshot-*') if s.is_file()]
|
||||
return max(s.stat().st_mtime for s in snaps) if snaps else None
|
||||
|
||||
|
||||
def live_run_id(repo_root=None, wal_path=None):
|
||||
"""Live runId from .claude/WAL.md (LAST match = most-recently-appended). None if absent."""
|
||||
wal = Path(wal_path) if wal_path else (Path(repo_root or ROOT) / ".claude" / "WAL.md")
|
||||
if not wal.exists():
|
||||
return None
|
||||
matches = _RUNID_RE.findall(wal.read_text(encoding='utf-8', errors='replace'))
|
||||
return matches[-1] if matches else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) machine-block
|
||||
# ---------------------------------------------------------------------------
|
||||
def machine_block(session_n, sessions_root=None, repo_root=None, anchor=None):
|
||||
"""Derive {session, anchor{subject,ts}, changed_files, changed_count, run_id}.
|
||||
|
||||
`anchor` = explicit ref override (pin it for reproducibility); default = nearest non-wal.
|
||||
Anchor is identified by {subject, ts}, NOT sha -- sha does not survive closeout squash."""
|
||||
repo_root = Path(repo_root or ROOT)
|
||||
sessions_root = Path(sessions_root) if sessions_root else DEFAULT_SESSIONS_ROOT
|
||||
session_dir = sessions_root / f"session-{session_n}"
|
||||
|
||||
a = _resolve_ref(anchor, repo_root) if anchor is not None else find_anchor(repo_root)
|
||||
used_fallback = False
|
||||
if a is not None:
|
||||
files_out = _git(['diff', '--name-only', f'{a["sha"]}..HEAD'], repo_root)
|
||||
anchor_id = {'subject': a['subject'], 'ts': a['ts']}
|
||||
else:
|
||||
used_fallback = True
|
||||
mt = last_snapshot_mtime(session_dir)
|
||||
files_out = (_git(['log', f'--since={_iso_utc(mt)}', '--name-only', '--format='], repo_root)
|
||||
if mt is not None else '')
|
||||
anchor_id = {'subject': None, 'ts': None}
|
||||
|
||||
changed = sorted({f.strip() for f in files_out.splitlines() if f.strip()})
|
||||
return {
|
||||
'session': session_n,
|
||||
'anchor': anchor_id,
|
||||
'anchor_from_fallback_since': used_fallback,
|
||||
'changed_files': changed,
|
||||
'changed_count': len(changed),
|
||||
'run_id': live_run_id(repo_root),
|
||||
}
|
||||
|
||||
|
||||
def render_machine_block(mb):
|
||||
"""Deterministic text rendering for embedding into a _context FLOW entry."""
|
||||
a = mb['anchor']
|
||||
lines = ['<!-- machine-block: scripts/session_ctx.py machine-block (script-derive) -->']
|
||||
lines.append(f'anchor: "{a["subject"]}" @ {a["ts"]}' if a['subject'] is not None
|
||||
else 'anchor: (fallback --since mtime of last _snapshot-*)')
|
||||
lines.append(f'runId: {mb["run_id"] or "(none)"}')
|
||||
lines.append(f'changed-files ({mb["changed_count"]}, git diff --name-only anchor..HEAD):')
|
||||
lines.extend(f' {f}' for f in mb['changed_files'])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (b) secrets-sweep
|
||||
# ---------------------------------------------------------------------------
|
||||
def secrets_sweep(session_dir):
|
||||
"""Scan the WHOLE write-set of a session dir for Category-5 patterns.
|
||||
Returns list of hits {file, line, pattern, snippet}. See ACCEPTED-GAP at module top."""
|
||||
session_dir = Path(session_dir)
|
||||
hits = []
|
||||
if not session_dir.exists():
|
||||
return hits
|
||||
for p in sorted(session_dir.rglob('*')):
|
||||
if not p.is_file():
|
||||
continue
|
||||
try:
|
||||
text = p.read_text(encoding='utf-8', errors='replace')
|
||||
except OSError:
|
||||
continue
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
for pat, rx in _SECRET_RES:
|
||||
if rx.search(line):
|
||||
hits.append({'file': str(p), 'line': lineno, 'pattern': pat,
|
||||
'snippet': line.strip()[:120]})
|
||||
return hits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(prog='session_ctx.py',
|
||||
description='session-model derive helpers (SE port, trimmed).')
|
||||
sub = ap.add_subparsers(dest='cmd', required=True)
|
||||
|
||||
mb = sub.add_parser('machine-block', help='derive anchor + changed-files + runId')
|
||||
mb.add_argument('--session', type=int, required=True, metavar='N')
|
||||
mb.add_argument('--anchor', default=None, help='explicit anchor ref (default: nearest non-wal)')
|
||||
mb.add_argument('--json', action='store_true', help='emit JSON (default: rendered text)')
|
||||
|
||||
ss = sub.add_parser('secrets-sweep', help='Category-5 pre-commit gate over a session dir')
|
||||
ss.add_argument('--session', type=int, required=True, metavar='N')
|
||||
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.cmd == 'machine-block':
|
||||
block = machine_block(args.session, anchor=args.anchor)
|
||||
print(json.dumps(block, ensure_ascii=False, indent=2) if args.json
|
||||
else render_machine_block(block))
|
||||
return 0
|
||||
|
||||
if args.cmd == 'secrets-sweep':
|
||||
session_dir = DEFAULT_SESSIONS_ROOT / f"session-{args.session}"
|
||||
hits = secrets_sweep(session_dir)
|
||||
if not hits:
|
||||
print(f"[secrets-sweep] session-{args.session}: 0 hit "
|
||||
f"({len(SECRET_PATTERNS)} pattern, pattern-bounded -- KHONG phai chung-minh sach)")
|
||||
return 0
|
||||
# SE-DELTA: hub prints hits then exits 1; same contract, message localised.
|
||||
print(f"[secrets-sweep] session-{args.session}: {len(hits)} HIT -- CHAN commit", file=sys.stderr)
|
||||
for h in hits:
|
||||
print(f" {h['file']}:{h['line']} [{h['pattern']}] {h['snippet']}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
154
scripts/session_scaffold.py
Normal file
154
scripts/session_scaffold.py
Normal file
@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""session_scaffold.py -- scaffold 1 logic-session folder + _context skeleton.
|
||||
|
||||
SE PORT of AI_INFRA scripts/session_scaffold.py (adopted S148, 2026-07-24, owner
|
||||
"doi ung dung chinh xac nhu hub"). Near-verbatim: the logic is repo-agnostic; only
|
||||
the template CONTENT differs (SE STOCK-map rows). Kept stdlib-only + same CLI so a
|
||||
future hub change can be re-pulled by diff instead of re-derived.
|
||||
|
||||
Tach LOGIC-session khoi VAT-LY window: tao `.claude/sessions/session-<N>/` +
|
||||
`_context-s-<N>.md` tu template `.claude/templates/session-context-template.md`.
|
||||
|
||||
Design pins:
|
||||
- path HARD-PIN: `.claude/sessions/session-<N>/` (KHONG configurable -- uniform cross-repo).
|
||||
- ts-moc = `git log -1 --format=%cI` (committer ISO-8601 cua HEAD), KHONG datetime.now
|
||||
tuy-tien; nguon-ts ghi thang vao file (audit). Fallback datetime.now CHI khi git down,
|
||||
tag ro "FALLBACK".
|
||||
- Idempotent: folder ton tai -> bao + KHONG de `_context` (chi tao neu thieu).
|
||||
- Cap so <N> KHONG phai viec cua script nay -- caller truyen vao. Doc-quyen cap so =
|
||||
/session-start (session-start.md BUOC 0.8); /pause va /tiep chi tao folder cho <N> dang mo.
|
||||
|
||||
stdlib-only . Python 3.11+ . UTF-8 no-BOM . LF.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SESSIONS_SUBPATH: tuple[str, ...] = (".claude", "sessions")
|
||||
TEMPLATE_SUBPATH: tuple[str, ...] = (".claude", "templates", "session-context-template.md")
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
"""Repo root via `git rev-parse --show-toplevel`; fallback = parent-of-scripts/."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
root = out.stdout.strip()
|
||||
if root:
|
||||
return Path(root)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||
pass
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def head_ts() -> tuple[str, str]:
|
||||
"""Return (ts, source). Prefer HEAD committer ISO-8601, tagged with short-sha for audit."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "log", "-1", "--format=%cI%x09%h"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
if out:
|
||||
ts, _, sha = out.partition("\t")
|
||||
ts, sha = ts.strip(), sha.strip()
|
||||
if ts:
|
||||
return ts, f"git log -1 --format=%cI @ HEAD {sha}"
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||
pass
|
||||
return (
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"datetime.now(UTC) FALLBACK (git unavailable -- non-deterministic)",
|
||||
)
|
||||
|
||||
|
||||
def render(template_text: str, n: int, ts: str, ts_source: str) -> str:
|
||||
"""Fill placeholders with str.replace (NOT str.format) so literal `{`/`}` in the
|
||||
template's JSON schema example survive untouched."""
|
||||
return (
|
||||
template_text
|
||||
.replace("{{N}}", str(n))
|
||||
.replace("{{TS}}", ts)
|
||||
.replace("{{TS_SOURCE}}", ts_source)
|
||||
)
|
||||
|
||||
|
||||
def _rel(p: Path, root: Path) -> str:
|
||||
try:
|
||||
return p.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return str(p)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="session_scaffold.py",
|
||||
description="Scaffold 1 logic-session folder + _context skeleton "
|
||||
"(.claude/sessions/session-<N>/). Idempotent; safe to re-run.",
|
||||
)
|
||||
ap.add_argument("--session", type=int, required=True, metavar="N",
|
||||
help="logic-session ID N (folder = session-<N>, regex ^session-\\d+$)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="print planned actions, write NOTHING")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
n: int = args.session
|
||||
if n < 0:
|
||||
print(f"ERROR: --session must be a non-negative integer (got {n})", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
root = repo_root()
|
||||
session_dir = root.joinpath(*SESSIONS_SUBPATH) / f"session-{n}"
|
||||
context_path = session_dir / f"_context-s-{n}.md"
|
||||
template_path = root.joinpath(*TEMPLATE_SUBPATH)
|
||||
|
||||
if not template_path.is_file():
|
||||
print(f"ERROR: template not found: {template_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ts, ts_source = head_ts()
|
||||
folder_exists = session_dir.exists()
|
||||
context_exists = context_path.exists()
|
||||
|
||||
if args.dry_run:
|
||||
print(f"[dry-run] session N = {n}")
|
||||
print(f"[dry-run] ts (moc) = {ts}")
|
||||
print(f"[dry-run] ts-source = {ts_source}")
|
||||
print(f"[dry-run] template = {_rel(template_path, root)}")
|
||||
print(f"[dry-run] session folder = {_rel(session_dir, root)} "
|
||||
f"({'EXISTS' if folder_exists else 'would mkdir'})")
|
||||
print(f"[dry-run] context file = {_rel(context_path, root)} "
|
||||
f"({'EXISTS -> would SKIP (idempotent)' if context_exists else 'would write'})")
|
||||
print("[dry-run] NOTHING written.")
|
||||
return 0
|
||||
|
||||
created_folder = False
|
||||
if not folder_exists:
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
created_folder = True
|
||||
|
||||
if context_exists:
|
||||
print(f"[idempotent] {_rel(context_path, root)} EXISTS -- NOT overwriting.")
|
||||
if created_folder:
|
||||
print(f"[note] folder {_rel(session_dir, root)} was missing -> created "
|
||||
f"(unusual: context present without folder).")
|
||||
return 0
|
||||
|
||||
template_text = template_path.read_text(encoding="utf-8")
|
||||
rendered = render(template_text, n, ts, ts_source)
|
||||
with open(context_path, "w", encoding="utf-8", newline="\n") as fh:
|
||||
fh.write(rendered)
|
||||
|
||||
print(f"[created] folder = {_rel(session_dir, root)} ({'new' if created_folder else 'existed'})")
|
||||
print(f"[created] context = {_rel(context_path, root)}")
|
||||
print(f"[created] ts(moc) = {ts} (source: {ts_source})")
|
||||
print("[note] NOT committed (commit theo nghi-thuc /pause | /snapshot | /session-end).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user