optimize docs
This commit is contained in:
@@ -26,10 +26,12 @@ launcher) or --harness.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -52,6 +54,48 @@ VSCODE_EXTENSION_IDS = {
|
||||
"fpt-casan.casan-governed-chat",
|
||||
),
|
||||
}
|
||||
PROMPT_MARKER_START = "<!-- CASAN_PROMPT_ENFORCEMENT_START -->"
|
||||
PROMPT_MARKER_END = "<!-- CASAN_PROMPT_ENFORCEMENT_END -->"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def owner_writable(path):
|
||||
"""Temporarily make an owner-controlled path writable, then restore modes.
|
||||
|
||||
Some existing shells intentionally commit `.claude`/`.github` directories
|
||||
as 0555. CASAN may add its config there only when the current user owns the
|
||||
path; it never escalates privileges or leaves permissions broadened.
|
||||
"""
|
||||
changed = []
|
||||
candidates = []
|
||||
if os.path.exists(path):
|
||||
candidates.append(path)
|
||||
parent = os.path.dirname(path)
|
||||
while parent and not os.path.exists(parent):
|
||||
parent = os.path.dirname(parent)
|
||||
if parent and parent not in candidates:
|
||||
candidates.append(parent)
|
||||
|
||||
try:
|
||||
for candidate in candidates:
|
||||
info = os.stat(candidate)
|
||||
mode = stat.S_IMODE(info.st_mode)
|
||||
if mode & stat.S_IWUSR:
|
||||
continue
|
||||
getuid = getattr(os, "getuid", None)
|
||||
if getuid is not None and info.st_uid != getuid():
|
||||
raise PermissionError(
|
||||
"CASAN cannot write %s: current user does not own %s" %
|
||||
(path, candidate))
|
||||
os.chmod(candidate, mode | stat.S_IWUSR)
|
||||
changed.append((candidate, mode))
|
||||
yield
|
||||
finally:
|
||||
for candidate, mode in reversed(changed):
|
||||
try:
|
||||
os.chmod(candidate, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def now_iso():
|
||||
@@ -65,11 +109,12 @@ def devkit_root():
|
||||
def _copy_if_absent(src, dst, created_rel, target, created):
|
||||
if not os.path.exists(src) or os.path.exists(dst):
|
||||
return False
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
with open(src, "rb") as fh:
|
||||
data = fh.read()
|
||||
with open(dst, "wb") as fh:
|
||||
fh.write(data)
|
||||
with owner_writable(dst):
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
with open(dst, "wb") as fh:
|
||||
fh.write(data)
|
||||
created.append(os.path.relpath(dst, target))
|
||||
return True
|
||||
|
||||
@@ -148,17 +193,18 @@ def compute_harness_hash(harness):
|
||||
|
||||
|
||||
def _write(path, text, backups):
|
||||
if os.path.exists(path):
|
||||
bak = path + ".casan-bak"
|
||||
if not os.path.exists(bak):
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
old = fh.read()
|
||||
with open(bak, "w", encoding="utf-8") as fh:
|
||||
fh.write(old)
|
||||
backups.append(bak)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
with owner_writable(path):
|
||||
if os.path.exists(path):
|
||||
bak = path + ".casan-bak"
|
||||
if not os.path.exists(bak):
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
old = fh.read()
|
||||
with open(bak, "w", encoding="utf-8") as fh:
|
||||
fh.write(old)
|
||||
backups.append(bak)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
def _copy_template(src, dst, backups):
|
||||
@@ -183,8 +229,9 @@ def _backup_once(path, backups):
|
||||
if os.path.exists(path) and not os.path.exists(bak):
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
old = fh.read()
|
||||
with open(bak, "w", encoding="utf-8") as fh:
|
||||
fh.write(old)
|
||||
with owner_writable(bak):
|
||||
with open(bak, "w", encoding="utf-8") as fh:
|
||||
fh.write(old)
|
||||
backups.append(bak)
|
||||
|
||||
|
||||
@@ -271,10 +318,11 @@ def merge_json_hooks(target_file, template_file, marker, backups):
|
||||
doc[k] = v
|
||||
changed = (not existed) or before != json.dumps(doc, ensure_ascii=False, sort_keys=True)
|
||||
if changed:
|
||||
_backup_once(target_file, backups)
|
||||
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
|
||||
with owner_writable(target_file):
|
||||
_backup_once(target_file, backups)
|
||||
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
|
||||
return ("created" if not existed else "merged") if changed else "unchanged"
|
||||
|
||||
|
||||
@@ -285,9 +333,10 @@ def remove_json_hooks(target_file, marker, backups):
|
||||
doc = _load_json_or(target_file, None)
|
||||
if not isinstance(doc, dict) or not _remove_marker_hooks(doc, marker):
|
||||
return "unchanged"
|
||||
_backup_once(target_file, backups)
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
|
||||
with owner_writable(target_file):
|
||||
_backup_once(target_file, backups)
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
|
||||
return "removed"
|
||||
|
||||
|
||||
@@ -326,15 +375,133 @@ def clean_legacy_codex_config(target_file, backups):
|
||||
r"(?ms)\n?\[hooks\]\nenabled = true\nproject_hooks = true\n(?=\s*$)",
|
||||
"\n", cleaned)
|
||||
cleaned = cleaned.strip() + ("\n" if cleaned.strip() else "")
|
||||
_backup_once(target_file, backups)
|
||||
if cleaned:
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(cleaned)
|
||||
else:
|
||||
os.unlink(target_file)
|
||||
with owner_writable(target_file):
|
||||
_backup_once(target_file, backups)
|
||||
if cleaned:
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(cleaned)
|
||||
else:
|
||||
os.unlink(target_file)
|
||||
return "cleaned"
|
||||
|
||||
|
||||
def is_casan_source_hub(target):
|
||||
"""Distinguish this product's source checkout from an adopted vendored app.
|
||||
|
||||
Older DevKit adoption copied ``packages/casan-harness`` into an application.
|
||||
That directory alone therefore does not make the application a CASAN source
|
||||
hub. The source checkout also carries the DevKit source, release manifest,
|
||||
and root installer.
|
||||
"""
|
||||
required = (
|
||||
os.path.join("packages", "casan-harness", "scripts", "bash",
|
||||
"casan-harness.sh"),
|
||||
os.path.join("packages", "casan-devkit", "casan-init.py"),
|
||||
os.path.join("packaging", "levels.json"),
|
||||
"install.sh",
|
||||
)
|
||||
return all(os.path.isfile(os.path.join(target, item)) for item in required)
|
||||
|
||||
|
||||
def _hybrid_prompt_block(clients):
|
||||
enabled = ", ".join(clients) if clients else "none"
|
||||
return """<!-- CASAN_PROMPT_ENFORCEMENT_START -->
|
||||
## CASAN IDE governance — mandatory
|
||||
|
||||
- `.casan/config.json` is the source of truth for enabled integrations (currently: %s).
|
||||
- Claude Code and Codex normal prompts are governed only when their integration is enabled and the project hooks are loaded; Codex additionally requires `/hooks` trust review.
|
||||
- GitHub Copilot built-in chat is not globally intercepted. Use the explicit `@casan` participant for a CASAN-owned Copilot turn.
|
||||
- A retained `bin/casan-chat` entrypoint remains a compatible governed route, but it is no longer the only supported prompt boundary.
|
||||
- Do not claim CASAN certification without the matching trace/evidence, and do not bypass a denied or degraded decision.
|
||||
<!-- CASAN_PROMPT_ENFORCEMENT_END -->""" % enabled
|
||||
|
||||
|
||||
def _replace_managed_prompt_block(path, replacement, backups):
|
||||
if not os.path.isfile(path):
|
||||
return "absent"
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
current = fh.read()
|
||||
pattern = re.compile(
|
||||
re.escape(PROMPT_MARKER_START) + r".*?" +
|
||||
re.escape(PROMPT_MARKER_END), re.DOTALL)
|
||||
if not pattern.search(current):
|
||||
return "unmanaged"
|
||||
updated = pattern.sub(replacement, current)
|
||||
if updated == current:
|
||||
return "unchanged"
|
||||
with owner_writable(path):
|
||||
_backup_once(path, backups)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(updated.rstrip() + "\n")
|
||||
return "migrated"
|
||||
|
||||
|
||||
def _legacy_instruction_conflicts(target):
|
||||
"""Report unmarked legacy prose that init must not silently rewrite."""
|
||||
conflicts = []
|
||||
phrases = (
|
||||
"The supported prompt boundary is `bin/casan-chat`",
|
||||
"CASAN Core が intercept できない",
|
||||
"Direct Claude, ChatGPT, Codex or Copilot UI prompts are outside",
|
||||
)
|
||||
for relative in ("AGENTS.md", "CLAUDE.md",
|
||||
os.path.join(".github", "copilot-instructions.md")):
|
||||
path = os.path.join(target, relative)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
current = fh.read()
|
||||
unmanaged = re.sub(
|
||||
re.escape(PROMPT_MARKER_START) + r".*?" +
|
||||
re.escape(PROMPT_MARKER_END), "", current, flags=re.DOTALL)
|
||||
if any(phrase in unmanaged for phrase in phrases):
|
||||
conflicts.append(relative.replace(os.sep, "/"))
|
||||
return conflicts
|
||||
|
||||
|
||||
def migrate_vendored_prompt_contract(target, clients, backups):
|
||||
"""Migrate only CASAN-owned legacy files; retain all project-owned content."""
|
||||
replacement = _hybrid_prompt_block(clients)
|
||||
instruction_files = {}
|
||||
for relative in ("AGENTS.md", "CLAUDE.md",
|
||||
os.path.join(".github", "copilot-instructions.md")):
|
||||
instruction_files[relative.replace(os.sep, "/")] = (
|
||||
_replace_managed_prompt_block(
|
||||
os.path.join(target, relative), replacement, backups))
|
||||
|
||||
policy_path = os.path.join(target, ".casan", "prompt-policy.json")
|
||||
policy_status = "absent"
|
||||
if os.path.isfile(policy_path):
|
||||
policy = _load_json_or(policy_path, None)
|
||||
if isinstance(policy, dict):
|
||||
before = json.dumps(policy, ensure_ascii=False, sort_keys=True)
|
||||
policy["direct_external_ui"] = "client_dependent_see_config"
|
||||
policy["agentic_clients"] = clients
|
||||
policy["hybrid_adoption"] = True
|
||||
if before != json.dumps(policy, ensure_ascii=False, sort_keys=True):
|
||||
with owner_writable(policy_path):
|
||||
_backup_once(policy_path, backups)
|
||||
with open(policy_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(
|
||||
policy, ensure_ascii=False, indent=2) + "\n")
|
||||
policy_status = "migrated"
|
||||
else:
|
||||
policy_status = "unchanged"
|
||||
else:
|
||||
policy_status = "unreadable"
|
||||
|
||||
return {
|
||||
"detected": True,
|
||||
"vendored_harness": "retained_for_compatibility",
|
||||
"managed_instruction_blocks": instruction_files,
|
||||
"prompt_policy": policy_status,
|
||||
"manual_review": _legacy_instruction_conflicts(target),
|
||||
"note": (
|
||||
"Existing .claude agents/skills/commands, .github content, CI, "
|
||||
"and the vendored harness were preserved."),
|
||||
}
|
||||
|
||||
|
||||
def select_clients(values, interactive):
|
||||
"""Normalize repeatable/comma-separated selections.
|
||||
|
||||
@@ -403,10 +570,11 @@ def merge_vscode_recommendations(target_file, clients, backups):
|
||||
changed = (not existed and bool(doc)) or before != json.dumps(
|
||||
doc, ensure_ascii=False, sort_keys=True)
|
||||
if changed:
|
||||
_backup_once(target_file, backups)
|
||||
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
|
||||
with owner_writable(target_file):
|
||||
_backup_once(target_file, backups)
|
||||
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
|
||||
return ("created" if not existed else "merged") if changed else "unchanged"
|
||||
|
||||
|
||||
@@ -461,17 +629,18 @@ def cmd_init(args):
|
||||
sys.stderr.write("casan init: target is not a directory: %s\n" % target)
|
||||
return 66
|
||||
|
||||
# Guardrail: refuse to adopt a CASAN SOURCE HUB into itself. Installing the
|
||||
# Plan-20 PreToolUse hook into the CASAN repo would block the very agent
|
||||
# developing CASAN (no admission => deny). Detect the hub by the harness
|
||||
# source living inside the target. Override with --force for the rare
|
||||
# intentional case.
|
||||
# Guardrail: refuse to adopt the CASAN PRODUCT SOURCE HUB into itself.
|
||||
# A vendored application from the previous DevKit model also contains
|
||||
# packages/casan-harness, so that directory by itself is not sufficient to
|
||||
# identify a source hub. Such applications are migrated in place below.
|
||||
hub_marker = os.path.join(target, "packages", "casan-harness", "scripts", "bash", "casan-harness.sh")
|
||||
if os.path.exists(hub_marker) and not args.force:
|
||||
source_hub = is_casan_source_hub(target)
|
||||
vendored_harness = os.path.isfile(hub_marker) and not source_hub
|
||||
if source_hub and not args.force:
|
||||
sys.stderr.write(
|
||||
"casan init: target looks like a CASAN SOURCE HUB (%s exists) — refusing to "
|
||||
"casan init: target is a CASAN SOURCE HUB — refusing to "
|
||||
"adopt CASAN into itself (the Plan-20 hooks would block your own agent). "
|
||||
"Use --force only if you really mean to.\n" % os.path.relpath(hub_marker, target))
|
||||
"Use --force only if you really mean to.\n")
|
||||
return 65
|
||||
|
||||
project = args.project or re.sub(r"[^a-z0-9-]", "-", os.path.basename(target).lower()).strip("-")
|
||||
@@ -571,8 +740,10 @@ def cmd_init(args):
|
||||
|
||||
# ── .specify/ state root marker ──
|
||||
specify = os.path.join(target, ".specify")
|
||||
os.makedirs(os.path.join(specify, "state"), exist_ok=True)
|
||||
os.makedirs(os.path.join(specify, "logs"), exist_ok=True)
|
||||
with owner_writable(os.path.join(specify, "state")):
|
||||
os.makedirs(os.path.join(specify, "state"), exist_ok=True)
|
||||
with owner_writable(os.path.join(specify, "logs")):
|
||||
os.makedirs(os.path.join(specify, "logs"), exist_ok=True)
|
||||
gi = os.path.join(specify, ".gitignore")
|
||||
if not os.path.exists(gi):
|
||||
_write(gi, "# CASAN runtime state — do not commit\nlogs/\nstate/\n", backups); created_add(gi)
|
||||
@@ -626,6 +797,12 @@ def cmd_init(args):
|
||||
{"status": "skipped", "reason": "vscode-copilot_not_selected"}
|
||||
)
|
||||
|
||||
legacy_migration = (
|
||||
migrate_vendored_prompt_contract(target, clients, backups)
|
||||
if vendored_harness else
|
||||
{"detected": False}
|
||||
)
|
||||
|
||||
# ── Level 2 (devkit) adoption extras: CI workflow + domain-pack scaffold.
|
||||
# These are the real difference between L1 (gate/hooks only) and L2 (full
|
||||
# adoption). Copied only if absent — never clobber the project's own files.
|
||||
@@ -665,10 +842,16 @@ def cmd_init(args):
|
||||
"created": created,
|
||||
"hook_merges": merges,
|
||||
"vscode_extension": vscode_install,
|
||||
"legacy_migration": legacy_migration,
|
||||
"level_extras": level_extras,
|
||||
"note": ("harness NOT copied into repo (hybrid model); selected client hooks "
|
||||
"MERGED and unselected CASAN hooks removed; run `casan doctor`"),
|
||||
}, ensure_ascii=False, indent=2))
|
||||
if legacy_migration.get("manual_review"):
|
||||
sys.stderr.write(
|
||||
"casan init: WARNING — legacy CASAN prose outside managed markers "
|
||||
"still needs review in: %s\n" %
|
||||
", ".join(legacy_migration["manual_review"]))
|
||||
if preview:
|
||||
sys.stderr.write("casan init: NOTE — Level 3 (platform) is a PREVIEW SERVICE (Control "
|
||||
"Panel/Dashboard), adopted by DEPLOYING it, not by repo config. "
|
||||
@@ -825,6 +1008,16 @@ def cmd_doctor(args):
|
||||
"warnings": [],
|
||||
}
|
||||
ready = integrity_ok and bootstrap_ok
|
||||
vendored_harness = os.path.isfile(os.path.join(
|
||||
target, "packages", "casan-harness", "scripts", "bash",
|
||||
"casan-harness.sh"))
|
||||
legacy_conflicts = _legacy_instruction_conflicts(target)
|
||||
checks["legacy_migration"] = {
|
||||
"vendored_harness_present": vendored_harness,
|
||||
"vendored_harness_status": (
|
||||
"retained_for_compatibility" if vendored_harness else "not_present"),
|
||||
"instruction_files_requiring_review": legacy_conflicts,
|
||||
}
|
||||
|
||||
code = shutil.which("code")
|
||||
installed_extensions = set()
|
||||
@@ -888,6 +1081,15 @@ def cmd_doctor(args):
|
||||
checks["warnings"].append(
|
||||
"Only prompts explicitly sent to @casan use the CASAN-owned Copilot route; "
|
||||
"built-in Copilot chat is not globally intercepted.")
|
||||
if vendored_harness:
|
||||
checks["warnings"].append(
|
||||
"A legacy vendored packages/casan-harness is retained. Remove it only "
|
||||
"after CI, bin scripts, and domain smoke tests use the global harness.")
|
||||
if legacy_conflicts:
|
||||
checks["warnings"].append(
|
||||
"Legacy prompt-boundary prose outside CASAN-managed markers conflicts "
|
||||
"with Plan-20 hooks and needs manual review: %s." %
|
||||
", ".join(legacy_conflicts))
|
||||
checks["status"] = "ready" if ready else "not_ready"
|
||||
print(json.dumps(checks, ensure_ascii=False, indent=2))
|
||||
return 0 if ready else 2
|
||||
@@ -938,7 +1140,13 @@ def main(argv=None):
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.cmd == "init":
|
||||
return cmd_init(args)
|
||||
try:
|
||||
return cmd_init(args)
|
||||
except PermissionError as error:
|
||||
sys.stderr.write(
|
||||
"casan init: permission denied while merging project config: "
|
||||
"%s\n" % error)
|
||||
return 77
|
||||
if args.cmd == "verify":
|
||||
return cmd_verify(args)
|
||||
if args.cmd == "level":
|
||||
|
||||
Reference in New Issue
Block a user