diff --git a/README.md b/README.md index ed401ed..a1946f2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ copy toàn bộ CASAN hoặc dựng lại project shell. - [CASAN Project Shell production](docs/guides/CASAN_PROJECT_SHELL_PRODUCTION.md) - [Requirements](#requirements) - [Input for Flow](#input-for-flow) -- [Output for FLow](#output-for-flow) +- [Output for Flow](#output-for-flow) - [Full AI-SDLC Flow](#full-ai-sdlc-flow) - [Flow Steps](#flow-steps) - [Flow Diagram](#flow-diagram) @@ -28,7 +28,6 @@ copy toàn bộ CASAN hoặc dựng lại project shell. - [AI-SDLC Usage Guide](#ai-sdlc-usage-guide) - [Directory Structure](#directory-structure) - [Directory Details](#directory-details) -- [OKR Web Application — Quick Start](#okr-web-application--quick-start) ## CASAN Quick Start @@ -100,6 +99,15 @@ Mặc định production là `--mode enforce`. Chỉ dùng `--mode observe` cho Nếu ứng dụng tự gọi LLM API qua backend riêng, ngoài Claude Code/Codex/Copilot, luồng đó không tự động đi qua project hooks và cần adapter CASAN riêng. +Với project đã adopt CASAN theo mô hình cũ và đang chứa +`packages/casan-harness`, init nhận diện đây là vendored application thay vì +source hub: + +- Không cần `--force`. +- Không xóa harness, scripts, workflow hoặc evidence cũ. +- Tự nâng cấp các block nằm giữa marker `CASAN_PROMPT_ENFORCEMENT_START/END`. +- Báo rõ file nào còn prose legacy ngoài marker cần review thủ công. + ### 3. Hành vi chat theo client | Client đã enable | Prompt đi qua CASAN | Onboarding bắt buộc | @@ -143,9 +151,9 @@ Apply AI to the SDLC process to automate and optimize the creation of software p ## Input for Flow -- `apps/okr/domain/input/okr-requirement.md` — Raw requirements from customers -- `docs/technical_architecture.md` — Technical stack decisions and constraints -- `apps/okr/domain/input/change-request/` — Change requests (e.g., `change-0406.md`) +- `apps//domain/input/requirement.md` — project requirements +- `apps//domain/input/architecture.md` — architecture and constraints +- Project-owned input, reference, agent, and skill directories remain untouched ## Output for Flow @@ -155,7 +163,7 @@ Apply AI to the SDLC process to automate and optimize the creation of software p | **AI Agent Logs** | `docs/output/output_logs/` | Execution logs from AI agents | | **SRS-Systems** | `docs/output/srs-systems/` | System-wide SRS (generated once during input clarification phase, not part of the main flow steps) | | **Spec-Kit Artifacts** | `specs/[FEATURE_NAME]/` | Feature artifacts generated by Spec-Kit agents (`spec.md`, `plan.md`, `tasks.md`, contracts, checklists) | -| **Source Code** | `apps/okr/backend/` + `apps/okr/frontend/` | Application source code (NestJS backend + React frontend) | +| **Source Code** | Project-defined locations | CASAN does not impose an application framework or source layout | --- @@ -280,7 +288,7 @@ The entire flow is orchestrated by **`casan.pipeline`**, which executes all step ## AI-SDLC Usage Guide 1. **Adopt governance**: Run `casan init`, select the IDE clients, complete any trust/VSIX onboarding, then run `casan doctor` -2. **Prepare Input**: Place raw requirements in `apps/okr/domain/input/okr-requirement.md` and tech stack in `docs/technical_architecture.md` +2. **Prepare Input**: Place requirements and architecture under `apps//domain/input/` 3. **Configuration**: Set up the project constitution first, keep specifications focused on what and why, and only decide technical stack where the input explicitly requires it 4. **(One-time) Generate System SRS**: Run `casan.srs` to generate system-wide SRS → `docs/output/srs-systems/` 5. **Run AI Flow**: Invoke `casan.pipeline` to execute the full 13-step flow automatically, with strict TDD enforced across specification, planning, task generation, and implementation @@ -289,7 +297,7 @@ The entire flow is orchestrated by **`casan.pipeline`**, which executes all step - `docs/output/output_logs/` — AI agent execution logs - `docs/output/srs-systems/` — System-wide SRS (generated once) - `specs/[FEATURE_NAME]/` — Spec-Kit artifacts (`spec.md`, `plan.md`, `tasks.md`, etc.) - - `apps/okr/backend/` + `apps/okr/frontend/` — Source code (NestJS backend + React frontend) + - Source code remains in the locations defined by the adopted project --- @@ -329,11 +337,16 @@ The entire flow is orchestrated by **`casan.pipeline`**, which executes all step │ ├── bd-ipa-template.md │ └── dd-ipa-template.md │ +├── apps/ +│ └── /domain/ # Per-project CASAN domain pack +│ ├── input/ +│ │ ├── requirement.md # Project requirements +│ │ └── architecture.md # Architecture and constraints +│ ├── corpus/ # Benign/red-team evaluation corpus +│ ├── golden-runs/ # Golden outputs for drift checks +│ └── traceability-map.json +│ ├── docs/ -│ ├── input/ # Input for the entire AI-SDLC flow -│ ├── change-request/ # Change requests -│ │ └── okr-requirement.md # Raw requirements from customers -│ ├── technical_architecture.md # Technical stack decisions and constraints │ └── output/ # Output of the AI-SDLC process │ ├── ipa-docs/ # IPA documents │ │ ├── srs/ # SRS documents per module @@ -344,21 +357,9 @@ The entire flow is orchestrated by **`casan.pipeline`**, which executes all step │ ├── output_logs/ # AI agent execution logs (per feature) │ └── srs-systems/ # System-wide SRS (generated once, pre-flow) │ -├── specs/ # Spec-Kit feature artifacts (per feature) -│ ├── 001-access-authentication/ -│ ├── 002-workspace-dashboard/ -│ ├── 003-objective-keyresult/ -│ └── 004-review-collaboration/ -│ -├── e2e/ # End-to-end tests (Playwright) -│ └── auth/ # Auth E2E tests -│ -├── apps/okr/frontend/ # React + Vite SPA (source code) -│ -├── apps/okr/backend/ # NestJS API service (source code) -│ -├── docker/ # Docker utilities -└── docker-compose.yml # Docker Compose orchestration +├── specs/ # Optional Spec-Kit feature artifacts +├── / # Project-owned; CASAN init preserves them +└── / # Project-owned; CASAN init preserves them ``` ### Directory Details @@ -381,15 +382,10 @@ Spec-Kit configuration directory. Stores project-wide principles, scripts, and a - **`scripts/`** — Utility scripts in `bash/` and `powershell/` for automation tasks. - **`templates/`** — Format templates used by agents to generate artifacts. Includes Spec-Kit templates (`spec-template.md`, `plan-template.md`, `tasks-template.md`, `constitution-template.md`, `checklist-template.md`, `agent-file-template.md`) and IPA templates (`srs-template.md`, `bd-ipa-template.md`, `dd-ipa-template.md`). -#### `apps/okr/domain/input/` -Input data for the AI-SDLC flow: -- **`okr-requirement.md`** — Raw requirement documents from the customer. - -#### `docs/technical_architecture.md` -Technical stack decisions and constraints (languages, frameworks, infrastructure). - -#### `apps/okr/domain/input/change-request/` -Change requests applied during the project lifecycle (e.g., `change-0406.md`). +#### `apps//domain/input/` +Per-project requirements and architecture used by CASAN gates and traceability. +The L2 initializer creates missing domain-pack files without overwriting existing +project content. #### `docs/output/` All artifacts produced by the AI-SDLC process: @@ -402,115 +398,7 @@ All artifacts produced by the AI-SDLC process: Each folder contains `spec.md`, `plan.md`, `tasks.md`, `checklists/`, and `contracts/`. This directory serves as the **knowledge base** for agents. #### Source Code -Source code generated by Agents during `speckit.implement` (STEP 10). -`apps/okr/backend/` and `apps/okr/frontend/` contain the application source code, co-located with the monorepo root: -- **`apps/okr/backend/`** — NestJS application. `src/` contains feature modules (`auth/`, `users/`, `objectives/`, `workspaces/`, `common/`). `prisma/` contains `schema.prisma`, migrations, and `seed.ts`. `test/` contains unit tests organized by module (`auth/`, `objectives/`, `users/`). Includes `Dockerfile`. -- **`apps/okr/frontend/`** — React + Vite SPA (TypeScript). `src/pages/` for route-level components (Login, ForgotPassword, Dashboard, CreateObjective, EditObjective, ObjectiveDetail, KeyResultDetail); `src/components/` organized by feature (`auth/`, `dashboard/`, `objective-detail/`, `objective-form/`), `layout/` (Sidebar, AppHeader, AppLayout) and `ui/` (Button, Input, Alert). `src/hooks/` (useAuth, useObjectives, useUsers, useWorkspaces), `src/lib/` (api, queryClient), `src/schemas/`, `src/types/`. `test/` contains unit tests (`hooks/`, `pages/`). Includes `Dockerfile`. -- **`e2e/`** — End-to-end tests using Playwright, organized by feature (e.g., `auth/auth.spec.ts`). -- **`docker/`** — Docker utilities. Main orchestration is in `docker-compose.yml` at the project root. - ---- - -## OKR Web Application — Quick Start - -### Description - -The **OKR Web Application** is a full-stack web app for managing Objectives and Key Results (OKR). It consists of: - -- **Backend**: NestJS REST API with Prisma ORM and MySQL -- **Frontend**: React + Vite SPA with Tailwind CSS -- **Database**: MySQL 8.0 - -### Prerequisites - -- [Docker](https://docs.docker.com/get-docker/) and Docker Compose -- Node.js 20.x and npm (for local development without Docker) - -### Quick Start (Docker) - -```bash -# Navigate to the repository root -cd - -# Build and start all services (MySQL + Backend + Frontend) -docker compose up --build - -# To run in detached mode -docker compose up --build -d -``` - -Docker Compose will automatically: -1. Start MySQL and wait for it to be healthy -2. Run Prisma migrations (`prisma migrate deploy`) -3. Seed the database with default data (`prisma db seed`) -4. Start the NestJS backend -5. Start the React frontend dev server - -### Default Credentials - -| Role | Email | Password | -|------|-------|----------| -| Admin | `admin@okr.local` | `Password@123` | -| Manager | `manager@okr.local` | `Password@123` | -| Employee | `employee@okr.local` | `Password@123` | - -### Service URLs - -| Service | URL | -|---------|-----| -| Frontend | http://localhost:5173 | -| Backend API | http://localhost:3000 | -| API Documentation (Swagger) | http://localhost:3000/api/docs | -| MySQL | localhost:3307 | - -### Development Without Docker - -#### Backend - -```bash -cd apps/okr/backend - -# Install dependencies -npm install - -# Copy and configure environment variables -cp .env.example .env -# Edit .env — set DATABASE_URL to point to your local MySQL instance - -# Run database migrations -npx prisma migrate dev - -# Seed the database -npx prisma db seed - -# Start the development server -npm run start:dev -``` - -#### Frontend - -```bash -cd apps/okr/frontend - -# Install dependencies -npm install - -# Copy and configure environment variables -cp .env.example .env -# Edit .env if the backend runs on a different port - -# Start the Vite dev server -npm run dev -``` - -The frontend will be available at http://localhost:5173 and proxies API requests to http://localhost:3000/api/v1. - -### Stopping Services - -```bash -# Stop all containers -docker compose down - -# Stop and remove volumes (resets the database) -docker compose down -v -``` +CASAN does not prescribe NestJS, React, Java, .NET, or any other application +stack. Existing source, CI, `.claude`, `.github`, agents, skills, prompts, and +project instructions remain project-owned. Hybrid init only adds or merges the +CASAN integration files documented above. diff --git a/docs/casan/CASAN_INSTALL_HYBRID.md b/docs/casan/CASAN_INSTALL_HYBRID.md index 1680bcc..a56fc53 100644 --- a/docs/casan/CASAN_INSTALL_HYBRID.md +++ b/docs/casan/CASAN_INSTALL_HYBRID.md @@ -69,6 +69,9 @@ casan level set 2 # đổi level project (không cần ini `init` nhiều lần không nhân đôi hook. - **Guardrail:** `init` **từ chối** khi target chính là một CASAN source hub (để không tự chặn agent đang phát triển CASAN); dùng `--force` nếu thực sự cần. +- Project cũ có vendored `packages/casan-harness` **không bị xem là source hub**: + init tự migrate sang hybrid, giữ nguyên harness/CI/scripts cũ để tương thích và + không yêu cầu `--force`. `--level 3` (platform) chỉ áp base L2 + nhắc rằng platform là service deploy riêng; `--level 4` (enterprise) bị từ chối (chưa ship). @@ -100,6 +103,33 @@ Bootstrap `.casan/casan-hook.py` tự đọc `config.json`; developer không cò source `.casan/agentic.env` trước khi mở IDE. Mỗi invocation kiểm tra live hash của harness global so với `version.lock` trước khi chạy adapter. +### Project đã có `.claude`, `.github`, agents, skills hoặc CASAN vendored + +Chỉ cần commit/backup trạng thái hiện tại, cài CASAN global rồi chạy init tại +project root, kể cả khi đường dẫn có khoảng trắng: + +```bash +cd '/path/to/Basic Design (Screen&Report)_v2.7' +casan init --project basic-design-v27 --client claude,codex,vscode-copilot +casan doctor +``` + +Quy tắc migration: + +- `.claude/agents`, `.claude/skills`, `.claude/commands`, `.github/**` và + workflow hiện hữu không bị xóa hoặc ghi đè. +- CASAN chỉ merge handler của mình vào `.claude/settings.json` và + `.codex/hooks.json`; cấu hình/hook không thuộc CASAN được giữ nguyên. +- Nếu có CASAN vendored cũ, `packages/casan-harness`, `bin/casan-chat`, CI và + evidence cũ được giữ lại. Chỉ xóa chúng sau khi CI/scripts/domain smoke đã + chuyển sang harness global. +- Block legacy nằm đúng giữa marker + `CASAN_PROMPT_ENFORCEMENT_START/END` được nâng cấp tự động. +- Prose legacy nằm ngoài marker không bị sửa âm thầm; `init` và `doctor` liệt kê + file cần review. Đặc biệt phải bỏ tuyên bố cũ rằng Claude/Codex direct chat + luôn nằm ngoài CASAN, vì Plan-20 project hooks đã thay đổi hành vi đó. +- Codex vẫn cần `/hooks` trust; Copilot built-in vẫn cần explicit `@casan`. + Sau `init`, developer gõ prompt bình thường trong client — trace H1→H7 + H6 theo Plan-20. Repo chỉ có mấy file config nhỏ; nâng cấp harness làm ở `$CASAN_HOME`. diff --git a/docs/guides/CASAN_PROJECT_SHELL_PRODUCTION.md b/docs/guides/CASAN_PROJECT_SHELL_PRODUCTION.md index aff3c13..ece8787 100644 --- a/docs/guides/CASAN_PROJECT_SHELL_PRODUCTION.md +++ b/docs/guides/CASAN_PROJECT_SHELL_PRODUCTION.md @@ -3,8 +3,8 @@ ## Outcome CASAN can create and govern multiple independent applications without embedding product knowledge -in root `.github`, shared harness code, patch verification, or pipeline orchestration. OKR and -Service Desk are consumers of the same versioned contract. +in root `.github`, shared harness code, patch verification, or pipeline orchestration. Demo +fixtures and Service Desk are consumers of the same versioned contract. ## Trust boundaries @@ -70,6 +70,6 @@ update fails, the incomplete directory is removed and the project is not exposed ## Verification evidence Automated tests cover valid manifests, traversal rejection, command allowlisting, conflicting-file -protection, idempotent scaffold/harness registration, generated NestJS/React build and tests, OKR -compatibility, Service Desk isolation, source-generation review loops, and per-project patch +protection, idempotent scaffold/harness registration, generated NestJS/React build and tests, +demo-fixture compatibility, Service Desk isolation, source-generation review loops, and per-project patch verification command selection. diff --git a/docs/guides/CASAN_USING_FOR_REAL_PROJECTS_VI.md b/docs/guides/CASAN_USING_FOR_REAL_PROJECTS_VI.md index 1681e1a..16e3388 100644 --- a/docs/guides/CASAN_USING_FOR_REAL_PROJECTS_VI.md +++ b/docs/guides/CASAN_USING_FOR_REAL_PROJECTS_VI.md @@ -222,7 +222,7 @@ coding agent. Miễn command đi qua harness, evidence được ghi theo cùng c ## 7. Case 3 — dự án Service Desk thật đã có trong repository này -Đây là bằng chứng CASAN không chỉ dùng được cho OKR: +Đây là ví dụ một project consumer độc lập với các demo fixture: ```bash node --test apps/service-desk/test/ticket.test.mjs @@ -237,7 +237,8 @@ bash packages/casan-harness/scripts/bash/verify-harness-reuse.sh ``` Service Desk có source/test riêng và Domain Pack riêng. Nó dùng chung đúng một -harness version với OKR. Không có gate nào được copy riêng cho Service Desk. +harness version với các project khác. Không có gate nào được copy riêng cho +Service Desk. ## 8. Case 4 — muốn model thực sự “xây dự án cho tôi” diff --git a/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md b/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md index 0d4b07d..5713467 100644 --- a/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md +++ b/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md @@ -1,7 +1,7 @@ # CASAN Plan-21 — Hybrid Global Install + `casan init` > Ngày lập: 2026-07-23 -> Trạng thái: **IMPLEMENTED + HARDENED — global install + selectable IDE init + pinned global bootstrap + VS Code `@casan` + merge-safe (60/60 hybrid acceptance)** +> Trạng thái: **IMPLEMENTED + HARDENED — global install + selectable IDE init + pinned global bootstrap + VS Code `@casan` + merge-safe/vendored migration (66/66 hybrid acceptance)** > Liên quan: [Plan-20](CASAN_PLAN_20_AGENTIC_CLIENT_INTEGRATION.md) (adapter/hook là base phổ quát), [CASAN_INSTALL_HYBRID.md](../casan/CASAN_INSTALL_HYBRID.md) ## 1. Mục tiêu @@ -42,7 +42,7 @@ harness global thao tác đúng trên state của dự án hiện tại. | Integrity hash primitive | `packages/casan-harness/scripts/python/harness_hash.py` | | `casan init` / `verify` | `packages/casan-devkit/casan-init.py` | | CLI wiring | `bin/casan` (`init`, `verify-harness`) | -| Acceptance suite | `packages/casan-devkit/tests/hybrid-install-tests.sh` — **60/60 PASS** | +| Acceptance suite | `packages/casan-devkit/tests/hybrid-install-tests.sh` — **66/66 PASS** | | Adoption doc | `docs/casan/CASAN_INSTALL_HYBRID.md` | ### Level selection (4 packaging levels) & safe adoption @@ -65,6 +65,10 @@ harness global thao tác đúng trên state của dự án hiện tại. `@casan` Chat Participant. Built-in Copilot không bị claim là global-intercept. - **Source-hub guardrail:** `init` refuses to adopt a CASAN source hub into itself (would block the developing agent); `--force` overrides. +- **Vendored migration:** a consumer repo containing an older + `packages/casan-harness` is distinguished from the product source hub, + migrated without `--force`, and retains its project-owned agents, skills, + prompts, workflows, scripts, evidence, and compatibility harness. ## 5. Definition of Done diff --git a/packages/casan-devkit/casan-init.py b/packages/casan-devkit/casan-init.py index be2aa00..076cd15 100755 --- a/packages/casan-devkit/casan-init.py +++ b/packages/casan-devkit/casan-init.py @@ -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 = "" +PROMPT_MARKER_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 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. +""" % 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": diff --git a/packages/casan-devkit/tests/hybrid-install-tests.sh b/packages/casan-devkit/tests/hybrid-install-tests.sh index 9d08440..8973d14 100755 --- a/packages/casan-devkit/tests/hybrid-install-tests.sh +++ b/packages/casan-devkit/tests/hybrid-install-tests.sh @@ -164,13 +164,71 @@ LVL=$( ( cd "$L2" && "$DKC" level show ) | python3 -c 'import json,sys;print(jso [ "$LVL" = "2" ] && pass "casan level show reports project target level" || fail "level show wrong ($LVL)" echo "===== ⑨ SAFETY: init refuses to adopt a CASAN source hub into itself =====" -HUB="$WORK/fakehub"; mkdir -p "$HUB/packages/casan-harness/scripts/bash" +HUB="$WORK/fakehub" +mkdir -p "$HUB/packages/casan-harness/scripts/bash" \ + "$HUB/packages/casan-devkit" "$HUB/packaging" echo '#casan' > "$HUB/packages/casan-harness/scripts/bash/casan-harness.sh" +echo '#devkit' > "$HUB/packages/casan-devkit/casan-init.py" +echo '{}' > "$HUB/packaging/levels.json" +echo '#!/bin/sh' > "$HUB/install.sh" GRC=0; ( cd "$HUB" && "$DKC" init --project hub >/dev/null 2>"$WORK/hub.err" ) || GRC=$? [ "$GRC" -eq 65 ] && pass "init refuses a source hub (rc=65)" || fail "source-hub not refused (rc=$GRC)" [ ! -f "$HUB/.claude/settings.json" ] && pass "no hooks written into the refused hub" || fail "hooks written into source hub" ( cd "$HUB" && "$DKC" init --project hub --force >/dev/null 2>&1 ) && pass "--force overrides the source-hub guard" || fail "--force did not override guard" +echo "===== ⑨b MIGRATION: existing vendored project shell is preserved =====" +LEGACY="$WORK/legacy-vendored" +mkdir -p "$LEGACY/packages/casan-harness/scripts/bash" \ + "$LEGACY/.claude/commands/bd" "$LEGACY/.github/skills/domain" \ + "$LEGACY/.gitea/workflows" "$LEGACY/.casan" +echo '#legacy harness' > "$LEGACY/packages/casan-harness/scripts/bash/casan-harness.sh" +echo 'existing command' > "$LEGACY/.claude/commands/bd/generate.md" +echo 'existing skill' > "$LEGACY/.github/skills/domain/SKILL.md" +echo 'existing workflow' > "$LEGACY/.gitea/workflows/existing.yml" +cat > "$LEGACY/AGENTS.md" <<'EOF' +Project-owned instructions. + +## CASAN prompt enforcement — mandatory +- The supported prompt boundary is `bin/casan-chat`. + +EOF +cat > "$LEGACY/.casan/prompt-policy.json" <<'EOF' +{"schema_version":1,"mode":"enforced","project_id":"legacy-vendored","domain_root":"apps/legacy-vendored/domain","direct_external_ui":"not_interceptable_not_certified"} +EOF +chmod 0555 "$LEGACY/.claude" +( cd "$LEGACY" && "$DKC" init --project legacy-vendored --client claude,codex >/dev/null 2>&1 ) \ + && pass "vendored application migrates without unsafe --force" \ + || fail "vendored application was mistaken for a source hub" +[ -f "$LEGACY/.claude/commands/bd/generate.md" ] \ + && [ -f "$LEGACY/.github/skills/domain/SKILL.md" ] \ + && [ -f "$LEGACY/.gitea/workflows/existing.yml" ] \ + && pass "existing commands, GitHub skills, and CI are preserved" \ + || fail "existing project shell content was changed" +grep -q 'CASAN IDE governance' "$LEGACY/AGENTS.md" \ + && grep -q 'Project-owned instructions.' "$LEGACY/AGENTS.md" \ + && pass "only the CASAN-managed legacy instruction block is migrated" \ + || fail "managed prompt-boundary migration was not precise" +python3 - "$LEGACY/.casan/prompt-policy.json" <<'PY' \ + && pass "legacy prompt policy records hybrid client-dependent routing" \ + || fail "legacy prompt policy was not migrated" +import json,sys +d=json.load(open(sys.argv[1])) +assert d["direct_external_ui"] == "client_dependent_see_config" +assert d["agentic_clients"] == ["claude","codex"] +assert d["hybrid_adoption"] is True +PY +[ -f "$LEGACY/packages/casan-harness/scripts/bash/casan-harness.sh" ] \ + && pass "vendored harness is retained for compatibility, not deleted" \ + || fail "migration deleted the existing vendored harness" +python3 - "$LEGACY/.claude" <<'PY' \ + && pass "owner-controlled read-only config directory mode is restored" \ + || fail "migration did not restore the original directory permissions" +import os,stat,sys +assert stat.S_IMODE(os.stat(sys.argv[1]).st_mode) == 0o555 +PY +# Restore fixture writability so the suite's temporary-directory cleanup works. +chmod 0755 "$LEGACY/.claude" + echo "===== ⑩ VS Code/Copilot @casan packaging + install + doctor =====" FAKE_BIN="$WORK/fake-bin"; mkdir -p "$FAKE_BIN" cat > "$FAKE_BIN/code" <<'EOF'