diff --git a/README.md b/README.md index 595247a..0b8785c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ Mô hình production mặc định: suite, test scripts, internal CI runners, `level5`, dashboard lab, source docs hay release tooling. +Ngay sau init, CASAN ghi hai projection do CASAN sở hữu: +`.casan/discovery.json` và `.casan/readiness.json`. Readiness tách ba chiều: +**Core**, **Domain Pipeline** và **Provider Telemetry**. Core có thể sẵn sàng +cho prompt/report dù hai chiều tùy chọn còn `not_configured` hoặc +`optional_unavailable`. + ## Trạng thái sản phẩm | Thành phần | Trạng thái | Phạm vi | @@ -57,6 +63,7 @@ không cần Node/npm hoặc Platform. ```bash # Sau một prompt casan report latest +casan readiness --refresh # Core / Domain / Provider, không chạy pipeline casan view # mở run mới nhất trong Core viewer casan report export --format html # run dossier, chỉ tạo khi được yêu cầu casan report export --h6 --format html # H6 dossier on-demand @@ -154,6 +161,7 @@ Project đã init không bị hỏi lại runtime: CASAN giữ nguyên mode hi ```bash casan doctor +casan readiness --refresh casan verify-harness casan edition show ``` @@ -161,6 +169,11 @@ casan edition show Với Codex, mở `/hooks`, review và trust đúng project hook sau lần init hoặc khi bootstrap hash thay đổi. +`casan readiness` là product status dành cho người vận hành và dashboard; +`casan doctor` là diagnostic sâu cho integrity, hook, smoke test và trust. +Không dùng trạng thái thiếu Domain Pack hoặc thiếu token/cost provider để hạ +Core thành failed. + Codex hooks gọi bootstrap tương đối từ project root và không phụ thuộc vào `git rev-parse`, nên ownership hoặc cấu hình Git không thể làm hỏng lifecycle hook. Git vẫn được khuyến nghị mạnh cho source provenance, review diff và @@ -383,6 +396,9 @@ installation hoặc project runtime. │ ├── version.lock │ ├── casan-hook.py │ ├── agentic.env +│ ├── discovery.json +│ ├── readiness.json +│ ├── domain.json # chỉ khi chọn manifest bằng casan domain configure │ ├── init-manifest.json │ └── runtime/casan-core/ # chỉ khi --runtime vendored ├── .specify/ @@ -403,6 +419,9 @@ installation hoặc project runtime. | `.casan/version.lock` | Pin version và SHA-256 của Core runtime đã resolve | | `.casan/casan-hook.py` | Stdlib bootstrap, verify pin rồi dispatch adapter | | `.casan/agentic.env` | Compatibility/reference flags; runtime đọc `config.json` | +| `.casan/discovery.json` | Inventory bounded các marker/source/requirements/Domain Pack candidate; không sửa source | +| `.casan/readiness.json` | Contract Core / Domain Pipeline / Provider Telemetry dùng chung cho CLI và viewer | +| `.casan/domain.json` | Reference CASAN-owned tới manifest do project sở hữu; chỉ tạo khi `casan domain configure` | | `.casan/init-manifest.json` | Danh sách file CASAN quản lý, checksum và backup | | `.casan/runtime/casan-core/` | Core production-only; chỉ có ở Vendored | | `.specify/logs`, `.specify/state` | Trace, audit và state runtime; không commit | diff --git a/bin/casan b/bin/casan index d83fdc3..65fb8af 100755 --- a/bin/casan +++ b/bin/casan @@ -76,6 +76,8 @@ Commands: init [--runtime managed|vendored] Adopt/reconfigure CASAN (interactive wizard by default) uninstall [--purge] Remove CASAN from this project (preserves user config) doctor [--client ...] Verify configured hooks, pin, adapters, and VS Code route + readiness [--refresh] [--json] Assess Core, Domain Pipeline, and Provider Telemetry + domain Manage optional project-owned Domain Pack selection edition Show / change the product edition level Deprecated alias for edition verify-harness Verify the resolved harness matches the project pin @@ -90,7 +92,7 @@ Commands: report latest [--json] Show the latest prompt assurance receipt report export [trace] [--format] Export a run/H6 dossier on demand view [trace-id] Open the Core Local Assurance Viewer - pipeline [--manifest path] Run the manifest-driven SRS→test pipeline + pipeline [--manifest path] Run an optional project-owned SRS→test pipeline dashboard Run local assurance (Core) or Control Plane version Print version help This help @@ -116,6 +118,10 @@ case "$cmd" in doctor) [[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: doctor requires the casan-devkit package" >&2; exit 1; } exec python3 "$DEVKIT_ROOT/casan-init.py" doctor "$@" ;; + readiness) + exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" readiness "$@" ;; + domain) + exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" domain "$@" ;; level) [[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: level requires the casan-devkit package" >&2; exit 1; } sub="${1:-show}"; shift || true @@ -180,7 +186,21 @@ case "$cmd" in exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" view "$@" ;; pipeline) RUNNER="$CASAN_APP_ROOT/scripts/run-casan-pipeline.mjs" - [[ -f "$RUNNER" ]] || { echo "casan: pipeline runner is not installed" >&2; exit 1; } + if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + cat <<'EOF' +Usage: casan pipeline [project runner arguments] + +Runs a project-owned manifest-driven SRS→test pipeline when +scripts/run-casan-pipeline.mjs exists. The pipeline is an optional Domain Pack +capability; CASAN Core prompt assurance and visual reports do not depend on it. +EOF + exit 0 + fi + if [[ ! -f "$RUNNER" ]]; then + echo "CASAN_PIPELINE_NOT_CONFIGURED — no project-owned Domain Pipeline runner was found." >&2 + echo "Core remains available: use 'casan view' and 'casan readiness'." >&2 + exit 2 + fi exec node "$RUNNER" "$@" ;; dashboard) CONTROL_PLANE_SCRIPT="$HARNESS/../casan-control-panel/scripts/control-plane-local.sh" diff --git a/docs/output/product-audit/zero-config/00-init-empty-state.png b/docs/output/product-audit/zero-config/00-init-empty-state.png new file mode 100644 index 0000000..74cf485 Binary files /dev/null and b/docs/output/product-audit/zero-config/00-init-empty-state.png differ diff --git a/docs/output/product-audit/zero-config/01-assurance-overview.png b/docs/output/product-audit/zero-config/01-assurance-overview.png new file mode 100644 index 0000000..de56510 Binary files /dev/null and b/docs/output/product-audit/zero-config/01-assurance-overview.png differ diff --git a/docs/output/product-audit/zero-config/02-run-dossier-h1-h7.png b/docs/output/product-audit/zero-config/02-run-dossier-h1-h7.png new file mode 100644 index 0000000..c3d5c26 Binary files /dev/null and b/docs/output/product-audit/zero-config/02-run-dossier-h1-h7.png differ diff --git a/docs/output/product-audit/zero-config/03-h6-trace-filtered.png b/docs/output/product-audit/zero-config/03-h6-trace-filtered.png new file mode 100644 index 0000000..94cfd43 Binary files /dev/null and b/docs/output/product-audit/zero-config/03-h6-trace-filtered.png differ diff --git a/docs/output/product-audit/zero-config/04-commercial-readiness-empty-state.png b/docs/output/product-audit/zero-config/04-commercial-readiness-empty-state.png new file mode 100644 index 0000000..66171e5 Binary files /dev/null and b/docs/output/product-audit/zero-config/04-commercial-readiness-empty-state.png differ diff --git a/docs/output/product-audit/zero-config/05-commercial-h6-empty-state.png b/docs/output/product-audit/zero-config/05-commercial-h6-empty-state.png new file mode 100644 index 0000000..96e3bd6 Binary files /dev/null and b/docs/output/product-audit/zero-config/05-commercial-h6-empty-state.png differ diff --git a/docs/output/product-audit/zero-config/06-basic-design-commercial-overview.png b/docs/output/product-audit/zero-config/06-basic-design-commercial-overview.png new file mode 100644 index 0000000..778994c Binary files /dev/null and b/docs/output/product-audit/zero-config/06-basic-design-commercial-overview.png differ diff --git a/docs/output/product-audit/zero-config/07-basic-design-commercial-h6.png b/docs/output/product-audit/zero-config/07-basic-design-commercial-h6.png new file mode 100644 index 0000000..a85a4cf Binary files /dev/null and b/docs/output/product-audit/zero-config/07-basic-design-commercial-h6.png differ diff --git a/docs/output/product-audit/zero-config/README.md b/docs/output/product-audit/zero-config/README.md new file mode 100644 index 0000000..b7c73a1 --- /dev/null +++ b/docs/output/product-audit/zero-config/README.md @@ -0,0 +1,52 @@ +# Core-only commercial product audit + +Audit date: 2026-07-29 +Reference project: `Basic Design (Screen&Report)_v2.7` + +## Problem reproduced + +The previous empty-state overview displayed READY and operational-looking +zeroes before any governed run existed. Core, Domain Pipeline, and Provider +Telemetry were not separated, so a customer could not tell which capability +was installed, optional, or waiting for evidence. + +Evidence: + +- `00-init-empty-state.png` +- `01-assurance-overview.png` +- `02-run-dossier-h1-h7.png` +- `03-h6-trace-filtered.png` + +## Corrected product contract + +- `casan init --edition core` writes `.casan/discovery.json` and + `.casan/readiness.json`. +- Core can be `ready` while Domain Pipeline is `not_configured`/`discovered` + and Provider Telemetry is `optional_unavailable`. +- The overview hides operational KPIs until a governed run exists. +- The empty H6 view hides failure, latency, token, cost, coverage, chart and + export controls rather than displaying fabricated zeroes. +- HTML/JSON export remains on-demand. +- `casan pipeline --help` never executes a pipeline; a missing project runner + returns `CASAN_PIPELINE_NOT_CONFIGURED` without marking Core failed. + +Evidence: + +- `04-commercial-readiness-empty-state.png` +- `05-commercial-h6-empty-state.png` +- `06-basic-design-commercial-overview.png` +- `07-basic-design-commercial-h6.png` + +## Acceptance evidence + +- clean Core-only vendored init: Core `ready`, Domain Pipeline + `not_configured`, Provider Telemetry `optional_unavailable`; +- no application source or `apps/` directory created by Core init; +- Basic Design discovery: Core `ready_with_attention` because Claude/Codex are + healthy while the selected VS Code route lacks its extension; Domain + Pipeline `discovered`; Provider Telemetry `available`; +- Basic Design H1–H7 domain smoke: seven controls passed; +- Inspect latest run opens the interactive H1–H7 dossier; +- Open H6 opens the trace-filtered AgentOps dossier; +- Core and DevKit release artifacts build and pass extracted-artifact smoke; +- hybrid install suite: 104 pass, 0 fail. diff --git a/docs/packaging/ADOPTION_GUIDE.md b/docs/packaging/ADOPTION_GUIDE.md index 953b50a..69b8a1a 100644 --- a/docs/packaging/ADOPTION_GUIDE.md +++ b/docs/packaging/ADOPTION_GUIDE.md @@ -13,6 +13,7 @@ sh install.sh --level devkit cd /path/to/my-project casan init --project ticketing --client claude,codex casan doctor +casan readiness --refresh casan verify-harness ``` @@ -31,6 +32,16 @@ The viewer is single-project, offline-capable and read-only. It starts lazily on `127.0.0.1`, so projects do not need Platform, Node/npm or an always-running service merely to inspect H1–H7 and H6. +The dashboard and CLI expose three independent states: + +- Core: installation acceptance and client activation; +- Domain Pipeline: optional project-specific SRS→test configuration; +- Provider Telemetry: optional model/provider token and cost coverage. + +`not_configured` Domain Pipeline and `optional_unavailable` Provider Telemetry +do not block Core. `casan report export` is on-demand and must not run after +every prompt. + Use this for developer workstations and managed CI runners. CI must install the same release recorded by `.casan/version.lock` before running gates. diff --git a/docs/packaging/COMMERCIAL_READINESS_CONTRACT.md b/docs/packaging/COMMERCIAL_READINESS_CONTRACT.md new file mode 100644 index 0000000..1e3eca3 --- /dev/null +++ b/docs/packaging/COMMERCIAL_READINESS_CONTRACT.md @@ -0,0 +1,64 @@ +# CASAN Commercial Readiness Contract + +## Promise + +After `casan init`, a repository can use CASAN Core prompt assurance and the +Local Assurance Viewer without modifying application source, installing a +Domain Pack, running a domain pipeline, or exporting HTML after every prompt. + +CASAN never turns missing evidence into zero and never calls an installation +READY when a required client activation step remains. + +## State model + +| Dimension | Ready state | Non-blocking state | Owner | +|---|---|---|---| +| Core | `ready`, `ready_with_attention` | — | CASAN runtime + selected client | +| Domain Pipeline | `configured` | `discovered`, `not_configured` | Project team | +| Provider Telemetry | `available` | `optional_unavailable` | Client/provider adapter | + +Core installation acceptance uses H1–H7 as an installation contract: + +1. H1 project configuration exists and is valid; +2. H2 selected client integration is installed and trust is explicit; +3. H3 local report capability exists in the resolved runtime; +4. H4 enforcement mode is disclosed (`observe` is a warning); +5. H5 evidence directories are writable; +6. H6 provider token/cost telemetry is measured when available and explicitly + optional when unavailable; +7. H7 bootstrap and version/hash pin exist. + +When at least one selected client route is operational but another selected +route fails the deep doctor check, Core is `ready_with_attention`: reports and +governed prompts remain usable through the healthy route, while the broken +route is shown as an explicit activation action. + +These checks do not claim that a real prompt run is H1–H7 certified. Run +certification is created only from canonical lifecycle evidence after a prompt. + +## CASAN-owned files + +- `.casan/discovery.json`: bounded read-only discovery projection; +- `.casan/readiness.json`: shared CLI/viewer state; +- `.casan/domain.json`: optional reference to a project-owned manifest. + +No command above creates requirements, traceability mappings, tests, source +files or a fake runnable pipeline. + +## Operator path + +```bash +casan init +casan doctor +casan readiness --refresh +casan view + +# Optional, only for project-specific SRS→test acceptance +casan domain discover +casan domain configure path/to/project.manifest.json +casan pipeline --help +``` + +`casan pipeline` runs only when the project provides +`scripts/run-casan-pipeline.mjs`. Otherwise it returns +`CASAN_PIPELINE_NOT_CONFIGURED` while leaving Core fully usable. diff --git a/docs/packaging/CORE_LOCAL_ASSURANCE_VIEWER.md b/docs/packaging/CORE_LOCAL_ASSURANCE_VIEWER.md index 95919e2..9d210d2 100644 --- a/docs/packaging/CORE_LOCAL_ASSURANCE_VIEWER.md +++ b/docs/packaging/CORE_LOCAL_ASSURANCE_VIEWER.md @@ -7,6 +7,7 @@ reports. Platform is not a prerequisite for basic visibility. Core owns the single-project review loop: +- commercial readiness split into Core, Domain Pipeline, and Provider Telemetry; - latest assurance receipt and run history; - interactive H1→H7 evidence spine; - H6 runtime, reliability, token/cost coverage, freshness and findings; @@ -38,6 +39,7 @@ review artifact, not the evidence source of truth. ```bash casan report latest +casan readiness --refresh casan view [trace-id] [--no-open] casan report export [trace-id] --format html|json [-o path] @@ -52,6 +54,11 @@ casan dashboard stop When Platform code is installed, `casan dashboard` manages the centralized Control Plane. `casan view` remains the predictable Core single-project viewer. +An empty project must not show operational zeroes or a fabricated READY +verdict. Before the first governed prompt, the overview shows installation +readiness and actionable onboarding only. Operational KPIs appear after +canonical run evidence exists. + ## Runtime and packaging contract - Python standard library only; no Node/npm or network dependency. @@ -109,6 +116,8 @@ The Core viewer is releasable only when all of these pass: 6. browser validation covers the report hierarchy, interaction and responsive navigation; 7. existing harness, DevKit installation and packaging suites remain green. +8. a clean `casan init --edition core` project reports Core ready without + requiring a Domain Pack, provider token/cost telemetry, or app-source edits. ## Maturity statement diff --git a/docs/packaging/DOMAIN_PACK_GUIDE.md b/docs/packaging/DOMAIN_PACK_GUIDE.md index 364208e..8013ea6 100644 --- a/docs/packaging/DOMAIN_PACK_GUIDE.md +++ b/docs/packaging/DOMAIN_PACK_GUIDE.md @@ -1,7 +1,8 @@ # CASAN Domain Pack Guide -A **Domain Pack** is the per-project data the harness needs to govern YOUR domain. It lives -at `apps//domain/` and is selected via `CASAN_DOMAIN_ROOT`. The harness code +A **Domain Pack** is optional per-project data the harness needs for a +project-specific SRS→test pipeline. Core prompt assurance and visual reporting +do not require it. A pack commonly lives at `apps//domain/`. The harness code (`packages/casan-harness/`) never contains domain data — this is what makes it reusable. Scaffold template: `packages/casan-devkit/templates/domain-pack/`. @@ -39,9 +40,18 @@ apps//domain/ ## Wire it up ```bash +casan domain discover +casan domain configure apps//domain/project.manifest.json +casan domain status + +# Existing automation remains supported export CASAN_PROJECT_MANIFEST=apps//domain/project.manifest.json bin/casan gate # runs domain-dependent suites against your pack ``` +`casan domain configure` writes only `.casan/domain.json`; it references an +existing in-project manifest and never copies, rewrites or invents application +requirements, source mappings or tests. + `domain_root` is also recorded per-project in `packages/casan-harness/config/project-registry.json` so `bin/casan reuse` can prove multi-project reuse. diff --git a/packages/casan-devkit/casan-init.py b/packages/casan-devkit/casan-init.py index 6d14a2b..0e9d08b 100755 --- a/packages/casan-devkit/casan-init.py +++ b/packages/casan-devkit/casan-init.py @@ -30,6 +30,7 @@ from __future__ import annotations import argparse from contextlib import contextmanager import hashlib +import importlib.util import json import os import queue @@ -160,6 +161,9 @@ def _render_init(result): ("Clients", _client_names(result["clients"])), ("Files", "%d created or updated" % len(result["created"])), ("Harness", result["harness_version"]), + ("Core readiness", result["readiness"]["dimensions"]["core"]["status"].replace("_", " ")), + ("Domain Pipeline", result["readiness"]["dimensions"]["domain_pipeline"]["status"].replace("_", " ")), + ("Provider Telemetry", result["readiness"]["dimensions"]["provider_telemetry"]["status"].replace("_", " ")), ]) print() print(_color("1", "Integrations")) @@ -178,9 +182,13 @@ def _render_init(result): _mark(ok), "VS Code extension", extension.get("status"))) print() print(_color("1", "Next steps")) - print(" 1. Run `casan doctor`") - if "codex" in result["clients"]: - print(" 2. In a local Codex client, open `/hooks` and trust this project's hook.") + actions = result["readiness"].get("next_actions") or [] + steps = [action["message"] for action in actions] + steps.append("Run `casan doctor` for the deep integrity and client smoke check.") + steps.append( + "After a governed prompt, run `casan view` to inspect its H1–H7 evidence.") + for index, step in enumerate(steps, 1): + print(" %d. %s" % (index, step)) def _render_verify(result): @@ -1221,6 +1229,7 @@ def cmd_init(args): cfg = { "schema_version": "21.3", "project_id": project, + "project_name": project, "created_at": now_iso(), "enforcement_mode": args.mode, "integration_mode": args.integration_mode, @@ -1413,6 +1422,40 @@ def cmd_init(args): newly_owned_files.update(domain_files) level_extras.append("apps/%s/domain (%d files)" % (project, n)) + # ── Commercial readiness contract. + # Core acceptance never invents or modifies application-domain inputs. + # Domain Pipeline and Provider Telemetry have independent readiness states. + readiness_module_path = os.path.join( + active_harness, "scripts", "python", "readiness.py") + if not os.path.isfile(readiness_module_path): + sys.stderr.write( + "casan init: resolved Core is missing the readiness contract\n") + return 1 + readiness_state_paths = ( + os.path.join(target, ".casan", "discovery.json"), + os.path.join(target, ".casan", "readiness.json"), + ) + for state_path in readiness_state_paths: + mark_owned_if_absent(state_path) + try: + spec = importlib.util.spec_from_file_location( + "casan_runtime_readiness", readiness_module_path) + readiness_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(readiness_module) + client_state = {} + if "codex" in clients: + client_state["codex"] = _codex_project_hook_trust( + target, timeout=3) + readiness = readiness_module.assess( + target, persist=True, client_state=client_state) + except (AttributeError, OSError, TypeError, ValueError) as error: + sys.stderr.write( + "casan init: cannot generate commercial readiness state: %s\n" % + error) + return 1 + for state_path in readiness_state_paths: + created_add(state_path) + # ── manifest (so uninstall/verify know what init created) ── owned_file_hashes = { path: digest @@ -1460,6 +1503,7 @@ def cmd_init(args): "level_extras": level_extras, "level_removed": level_removed, "level_retained": level_retained, + "readiness": readiness, "note": ( "managed runtime is referenced by version/hash lock" if runtime_mode == "managed" else @@ -1910,6 +1954,18 @@ def cmd_doctor(args): ready = ready and client_ready checks["client_checks"][client] = item + for client, item in checks["client_checks"].items(): + if item.get("ready") or client == "codex": + continue + checks["required_actions"].append({ + "code": "%s_activation" % client.replace("-", "_"), + "message": ( + item.get("install_action") + or "Repair the selected %s client route, then run `casan doctor`." + % CLIENT_LABELS.get(client, client) + ), + }) + if "codex" in clients: codex_check = checks["client_checks"].get("codex", {}) trust = codex_check.get("trust", {}) @@ -1964,6 +2020,31 @@ def cmd_doctor(args): else "user_action_required" if checks["required_actions"] else "ready" ) + if harness: + readiness_module_path = os.path.join( + harness, "scripts", "python", "readiness.py") + try: + if not os.path.isfile(readiness_module_path): + raise FileNotFoundError + spec = importlib.util.spec_from_file_location( + "casan_doctor_readiness", readiness_module_path) + readiness_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(readiness_module) + readiness_clients = { + client: { + **(item.get("trust", {}) if client == "codex" else {}), + "operational": bool(item.get("ready")), + } + for client, item in checks["client_checks"].items() + } + checks["readiness"] = readiness_module.assess( + target, persist=True, client_state=readiness_clients) + except FileNotFoundError: + pass + except (AttributeError, OSError, TypeError, ValueError) as error: + checks["warnings"].append( + "Commercial readiness state could not be refreshed: %s." % + error) _emit_json_or_human(args, checks, _render_doctor) return 0 if ready else 2 @@ -2150,6 +2231,9 @@ def cmd_uninstall(args): ".casan/version.lock", ".casan/agentic.env", ".casan/casan-hook.py", + ".casan/domain.json", + ".casan/discovery.json", + ".casan/readiness.json", ".casan/init-manifest.json"): _remove_owned_file( os.path.join(target, *relative.split("/")), removed, target) diff --git a/packages/casan-devkit/tests/hybrid-install-tests.sh b/packages/casan-devkit/tests/hybrid-install-tests.sh index 502a60d..bcc598d 100755 --- a/packages/casan-devkit/tests/hybrid-install-tests.sh +++ b/packages/casan-devkit/tests/hybrid-install-tests.sh @@ -80,6 +80,7 @@ for required in ( "packages/casan-harness/scripts/python/local_report.py", "packages/casan-harness/scripts/python/local_viewer.py", "packages/casan-harness/scripts/python/report_cli.py", + "packages/casan-harness/scripts/python/readiness.py", ): assert (root / required).is_file(), required registry = json.loads( @@ -148,6 +149,7 @@ for required in ( "packages/casan-harness/config/tool-registry.yaml", "packages/casan-harness/scripts/bash/casan-harness.sh", "packages/casan-harness/scripts/python/local_viewer.py", + "packages/casan-harness/scripts/python/readiness.py", "packaging/runtime-layout.json", "scripts/copy-runtime.py", "install.sh", @@ -159,9 +161,31 @@ echo "===== ② casan init (config only, no harness copy) =====" PROJ="$WORK/proj/my-app"; mkdir -p "$PROJ"; echo '{"name":"x"}' > "$PROJ/package.json" ( cd "$PROJ" && "$CASAN" init --project my-app --mode enforce >/dev/null 2>&1 ) \ && pass "casan init completes" || fail "casan init failed" -for f in .casan/config.json .casan/version.lock .casan/agentic.env .casan/casan-hook.py .claude/settings.json .codex/hooks.json .vscode/extensions.json .specify/.gitignore; do +for f in .casan/config.json .casan/version.lock .casan/agentic.env .casan/casan-hook.py .casan/discovery.json .casan/readiness.json .claude/settings.json .codex/hooks.json .vscode/extensions.json .specify/.gitignore; do [[ -f "$PROJ/$f" ]] && pass "init wrote $f" || fail "init missing $f" done +python3 - "$PROJ/.casan/readiness.json" <<'PY' \ + && pass "Core readiness is independent from optional Domain/Provider states" \ + || fail "commercial readiness contract is invalid" +import json +import sys + +d = json.load(open(sys.argv[1], encoding="utf-8")) +assert d["dimensions"]["core"]["status"] in {"ready", "ready_with_attention"} +assert d["dimensions"]["domain_pipeline"]["status"] == "not_configured" +assert d["dimensions"]["provider_telemetry"]["status"] == "optional_unavailable" +PY +CASAN_APP_ROOT="$PROJ" "$CASAN" pipeline --help 2>&1 \ + | grep -q "optional Domain Pack" \ + && pass "pipeline help describes the optional capability without executing it" \ + || fail "pipeline help executed or hid the Core/Domain boundary" +set +e +PIPELINE_MISSING="$(CASAN_APP_ROOT="$PROJ" "$CASAN" pipeline 2>&1)" +PIPELINE_RC=$? +set -e +[[ "$PIPELINE_RC" -eq 2 && "$PIPELINE_MISSING" == *"CASAN_PIPELINE_NOT_CONFIGURED"* ]] \ + && pass "missing Domain Pipeline is explicit and does not impersonate a Core failure" \ + || fail "missing Domain Pipeline capability was not reported truthfully" python3 - "$PROJ/.casan/config.json" <<'PY' \ && pass "config declares supported and excluded client surfaces" \ || fail "config client surface contract is incomplete" diff --git a/packages/casan-harness/assets/local-viewer/app.css b/packages/casan-harness/assets/local-viewer/app.css index 3132f6b..b2309b1 100644 --- a/packages/casan-harness/assets/local-viewer/app.css +++ b/packages/casan-harness/assets/local-viewer/app.css @@ -125,11 +125,14 @@ main { max-width: 1500px; margin: 0 auto; padding: 28px clamp(20px,4vw,48px) 64p font-size: 9px; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; white-space: nowrap; } .pill.pass, .pill.certified, .pill.success, .pill.fresh, .pill.operational { background: var(--green-soft); color: var(--green); } -.pill.warning, .pill.attention, .pill.partial, .pill.stale, .pill.in-progress, .pill.in_progress { background: var(--amber-soft); color: var(--amber); } +.pill.warning, .pill.attention, .pill.ready-with-attention, .pill.ready_with_attention, .pill.partial, .pill.stale, .pill.in-progress, .pill.in_progress { background: var(--amber-soft); color: var(--amber); } .pill.error, .pill.failed, .pill.fail, .pill.blocked, .pill.non-certified, .pill.non_certified { background: var(--rose-soft); color: var(--rose); } .pill.neutral, .pill.queued, .pill.unknown, .pill.no-data, .pill.no_data { background: #f2f4f7; color: #475467; } .hero .pill { border: 1px solid #344054; background: #1d2939; color: #d0d5dd; } .metric-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; } +.metric-grid.readiness-grid { grid-template-columns: repeat(3,minmax(0,1fr)); } +.readiness-grid .metric-value { font-size: 24px; text-transform: capitalize; } +.onboarding-actions { margin-bottom: 18px; } .metric-card, .panel { border: 1px solid var(--line); border-radius: 18px; background: white; box-shadow: var(--shadow); } @@ -275,6 +278,7 @@ tbody tr:hover { background: #f0fdfa; } @media (max-width: 1120px) { .metric-grid { grid-template-columns: repeat(2,1fr); } + .metric-grid.readiness-grid { grid-template-columns: 1fr; } .two-col, .breakdown-grid, .dossier { grid-template-columns: 1fr; } .hero-grid { grid-template-columns: 1fr; } .hero-actions { justify-content: flex-start; } diff --git a/packages/casan-harness/assets/local-viewer/app.js b/packages/casan-harness/assets/local-viewer/app.js index dab94d8..b14f128 100644 --- a/packages/casan-harness/assets/local-viewer/app.js +++ b/packages/casan-harness/assets/local-viewer/app.js @@ -27,6 +27,7 @@ ? "Unavailable" : new Intl.NumberFormat("en-US").format(Number(value)); const time = (value) => value ? String(value).replace("T", " ").replace("Z", " UTC") : "No timestamp"; const money = (value) => value === null || value === undefined ? "Unavailable" : `$${Number(value).toFixed(6)}`; + const statusLabel = (value) => String(value || "unknown").replaceAll("_", " "); const pill = (value) => `${esc(String(value || "unknown").replaceAll("_", " "))}`; async function api(path) { @@ -122,6 +123,22 @@ const certified = latest.certified === true; const latestTrace = latest.trace_id || recent[0]?.trace_id; const maturity = profile.maturity || {}; + const readiness = profile.readiness || {}; + const dimensions = readiness.dimensions || {}; + const core = dimensions.core || { status: "not_assessed", summary: "Core readiness has not been assessed." }; + const domain = dimensions.domain_pipeline || { status: "not_assessed", summary: "Domain Pipeline readiness has not been assessed." }; + const telemetry = dimensions.provider_telemetry || { status: "not_assessed", summary: "Provider Telemetry readiness has not been assessed." }; + const nextActions = readiness.next_actions || []; + const coreReady = ["ready", "ready_with_attention"].includes(core.status); + const readinessActionList = `
${nextActions.map((action, index) => `
+ ${String(index + 1).padStart(2, "0")} +
${esc(action.code)}

${esc(action.message)}

+
`).join("")}
`; + const readinessCards = `
+ ${metric("Core", statusLabel(core.status), core.summary)} + ${metric("Domain Pipeline", statusLabel(domain.status), domain.summary)} + ${metric("Provider Telemetry", statusLabel(telemetry.status), telemetry.summary)} +
`; const actions = latestTrace ? ` ` @@ -129,22 +146,43 @@ $("#overview-view").innerHTML = hero({ eyebrow: "CASAN Core · Local assurance", - title: latestTrace ? "One prompt. One evidence trail." : "Ready for the first governed run.", - description: "A read-only, offline-capable assurance workspace reconstructed from canonical project evidence. No Platform service or HTML generation on the prompt hot path.", - verdict: latestTrace ? (certified ? "certified" : "review required") : "ready", + title: latestTrace ? "One prompt. One evidence trail." : + coreReady ? "Core reporting is ready." : "Finish Core activation.", + description: latestTrace + ? "A read-only, offline-capable assurance workspace reconstructed from canonical project evidence. No Platform service or HTML generation on the prompt hot path." + : coreReady + ? "Core visual reporting works without a Domain Pack or application-source changes. Send a governed prompt to create the first run dossier." + : "Core visual reporting works without a Domain Pack or application-source changes. Complete the activation action below, then send a governed prompt.", + verdict: latestTrace ? (certified ? "certified" : "review required") : core.status, meta: [ `${profile.project_name} · ${profile.edition}`, latestTrace ? `Trace ${latestTrace}` : "No finalized trace yet", - `Maturity ${maturity.level == null ? "not assessed" : `L${maturity.level}`} · ${maturity.status}`, + `Maturity ${maturity.level == null ? "not assessed" : `L${maturity.level}`} · ${statusLabel(maturity.status)}`, ], actions, - }) + - `
+ }) + readinessCards + + (latestTrace && nextActions.length ? panel( + "Activation attention", + "A selected client route needs action.", + "Healthy routes remain operational; complete this step before relying on the affected integration.", + readinessActionList, + pill(core.status), + ) : "") + + (latestTrace ? `
${metric("Governed records", number(registry.count), `${recent.length} recent records loaded`)} ${metric("Latest H6 quality", latest.telemetry_quality || "Unavailable", "Missing values are disclosed, never coerced to zero")} ${metric("Observed failures", number(failures), `${recent.length ? Math.round((failures / recent.length) * 100) : 0}% of loaded records`)} ${metric("Evidence gates", latestTrace ? "H1 → H7" : "Waiting", latestTrace ? "Control-by-control reconstruction available" : "Evidence appears after a prompt finalizes")} -
` + +
` : panel( + "First-run onboarding", + "No operational KPIs are shown until evidence exists.", + "CASAN does not turn missing telemetry into zeroes or claim a run is certified before a governed prompt finishes.", + `${nextActions.length ? `
${readinessActionList}
` : ""} +
+
Available now
Local viewer, H1–H7 run dossier, history, H6 AgentOps and on-demand JSON/HTML export.
+
Optional expansion
Install a Domain Pack only when the team needs project-specific SRS→test acceptance.
+
` + )) + disclosure( "About this local report", "Edition boundaries and implementation notes are hidden until needed.", @@ -172,13 +210,22 @@ function renderRuns() { const rows = state.registry.runs || []; const selected = state.run; + const hasRuns = rows.length > 0; const body = hero({ eyebrow: "Run assurance registry", - title: "Every governed run, one evidence trail.", - description: "Select a lifecycle record to reconstruct H1–H7 decisions, inspect sanitized evidence and export an independent dossier.", - verdict: rows.some((row) => row.status === "failed") ? "attention" : "operational", - meta: [`${state.registry.count} records`, `${rows.filter((row) => row.status === "failed").length} failures`, "Local canonical evidence"], - actions: ``, + title: hasRuns ? "Every governed run, one evidence trail." : "No governed runs yet.", + description: hasRuns + ? "Select a lifecycle record to reconstruct H1–H7 decisions, inspect sanitized evidence and export an independent dossier." + : "The registry will populate after a configured client finalizes its first governed prompt.", + verdict: hasRuns + ? (rows.some((row) => row.status === "failed") ? "attention" : "operational") + : "waiting", + meta: hasRuns + ? [`${state.registry.count} records`, `${rows.filter((row) => row.status === "failed").length} failures`, "Local canonical evidence"] + : ["No operational counters yet", "Local canonical evidence"], + actions: hasRuns + ? `` + : "", }) + (selected ? renderTrace(selected) : "") + recentRunsPanel(rows); $("#runs-view").innerHTML = body; bindActions(); @@ -225,10 +272,33 @@ function renderH6() { const data = state.h6; if (!data) return; + const hasRuns = Number(data.summary?.runs) > 0; const query = data.scope || {}; const queryObject = Object.fromEntries(Object.entries(query).filter(([, value]) => value)); const actions = `JSON evidence Export dossier`; + if (!hasRuns) { + const provider = state.project?.readiness?.dimensions?.provider_telemetry || {}; + $("#h6-view").innerHTML = + hero({ + eyebrow: "H6 · AgentOps assurance", + title: "H6 begins with the first governed run.", + description: "No runtime record exists yet, so CASAN hides failure, latency, token and cost counters instead of presenting fabricated zeroes.", + verdict: "waiting", + meta: [data.report_id, `Generated ${time(data.generated_at)}`, "No operational evidence yet"], + actions: "", + }) + + panel( + "H6 onboarding", + "Provider telemetry is optional.", + provider.summary || "Provider token and cost telemetry has not been observed; this does not block Core.", + `
+
Core remains ready
Send a prompt through a configured client. CASAN will persist runtime evidence automatically.
+
No automatic export
Open H6 or export a dossier only when a review artifact is needed.
+
`, + ); + return; + } const findings = data.findings.length ? `
${data.findings.map((item, index) => `
${String(index + 1).padStart(2, "0")}
${esc(item.code)}

${esc(item.message)}

`).join("")}
` : `
No threshold breach was detected in the selected evidence boundary.
`; diff --git a/packages/casan-harness/scripts/python/local_report.py b/packages/casan-harness/scripts/python/local_report.py index f8af72f..ea43e19 100755 --- a/packages/casan-harness/scripts/python/local_report.py +++ b/packages/casan-harness/scripts/python/local_report.py @@ -174,6 +174,7 @@ def paths(root: str | Path) -> dict[str, Path]: state = state_root(root) return { "config": Path(root).resolve() / ".casan" / "config.json", + "readiness": Path(root).resolve() / ".casan" / "readiness.json", "receipt": state / "state" / "latest-run.json", "metrics": state / "logs" / "cost" / "metrics.jsonl", "provider": state / "logs" / "level5" / "provider-usage.jsonl", @@ -185,6 +186,7 @@ def paths(root: str | Path) -> dict[str, Path]: def project_profile(root: str | Path) -> dict[str, Any]: config = read_json(paths(root)["config"]) + readiness = read_json(paths(root)["readiness"]) maturity = config.get("maturity") if isinstance(config.get("maturity"), dict) else {} return { "project_id": _text(config.get("project_id"), Path(root).resolve().name), @@ -199,6 +201,26 @@ def project_profile(root: str | Path) -> dict[str, Any]: "status": _text(maturity.get("status"), "not_assessed"), "evidence": _text(maturity.get("evidence")) or None, }, + "readiness": readiness if readiness else { + "schema_version": 1, + "overall_status": "not_assessed", + "dimensions": { + "core": { + "status": "not_assessed", + "summary": "Run `casan readiness --refresh` to assess this installation.", + "acceptance": [], + }, + "domain_pipeline": { + "status": "not_assessed", + "summary": "Domain Pipeline readiness has not been assessed.", + }, + "provider_telemetry": { + "status": "not_assessed", + "summary": "Provider Telemetry readiness has not been assessed.", + }, + }, + "next_actions": [], + }, "viewer": { "mode": "local_core", "scope": "single_project", diff --git a/packages/casan-harness/scripts/python/readiness.py b/packages/casan-harness/scripts/python/readiness.py new file mode 100644 index 0000000..0fae32f --- /dev/null +++ b/packages/casan-harness/scripts/python/readiness.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 +"""CASAN commercial-readiness discovery and assessment. + +This module is intentionally stdlib-only. It assesses the CASAN-owned +installation contract without changing application source or inventing domain +traceability. Core, Domain Pipeline, and Provider Telemetry are independent +dimensions because each has a different owner and activation lifecycle. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import os +from pathlib import Path +from typing import Any + + +SCHEMA_VERSION = 1 +SKIP_DIRECTORIES = { + ".casan", ".git", ".hg", ".idea", ".specify", ".svn", ".venv", + "coverage", "dist", "node_modules", "target", "vendor", +} +PROJECT_MARKERS = ( + "package.json", "pyproject.toml", "pom.xml", "build.gradle", + "build.gradle.kts", "go.mod", "Cargo.toml", "composer.json", +) +SOURCE_ROOT_NAMES = ("src", "app", "apps", "packages", "lib", "services") +REQUIREMENT_NAMES = ( + "requirements.md", "requirement.md", "srs.md", "spec.md", + "product-requirements.md", +) + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, TypeError, ValueError): + return {} + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(value, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + os.replace(temporary, path) + + +def configure_domain(root: str | Path, manifest: str | Path) -> dict[str, Any]: + """Select an existing project manifest without copying or editing app code.""" + project_root = Path(root).expanduser().resolve() + selected = Path(manifest).expanduser() + if not selected.is_absolute(): + selected = project_root / selected + selected = selected.resolve() + try: + relative = selected.relative_to(project_root).as_posix() + except ValueError as error: + raise ValueError("domain manifest must stay inside the project root") from error + if not selected.is_file(): + raise ValueError("domain manifest does not exist") + try: + document = json.loads(selected.read_text(encoding="utf-8")) + except (OSError, TypeError, ValueError) as error: + raise ValueError("domain manifest must be valid JSON") from error + if not isinstance(document, dict) or not ( + document.get("project_id") or document.get("schema_version") + ): + raise ValueError( + "domain manifest must declare project_id or schema_version") + value = { + "schema_version": SCHEMA_VERSION, + "configured_at": utc_now(), + "manifest": relative, + "ownership": "project_source_referenced_by_casan", + } + write_json(project_root / ".casan" / "domain.json", value) + return value + + +def _relative(root: Path, path: Path) -> str: + return path.relative_to(root).as_posix() + + +def _bounded_files(root: Path, maximum_depth: int = 5) -> list[Path]: + files: list[Path] = [] + for current, directories, names in os.walk(root): + current_path = Path(current) + try: + depth = len(current_path.relative_to(root).parts) + except ValueError: + continue + directories[:] = sorted( + name for name in directories + if name not in SKIP_DIRECTORIES and not name.startswith(".cache") + ) + if depth >= maximum_depth: + directories[:] = [] + for name in sorted(names): + files.append(current_path / name) + if len(files) >= 10_000: + return files + return files + + +def discover(root: str | Path) -> dict[str, Any]: + project_root = Path(root).expanduser().resolve() + priority_files: list[Path] = [] + for relative in ("apps", "packages", "docs"): + candidate = project_root / relative + if candidate.is_dir(): + priority_files.extend(_bounded_files(candidate, maximum_depth=6)) + files = list(dict.fromkeys( + priority_files + _bounded_files(project_root) + )) + markers = [ + name for name in PROJECT_MARKERS + if (project_root / name).is_file() + ] + source_roots = [ + name for name in SOURCE_ROOT_NAMES + if (project_root / name).is_dir() + ] + domain_packs: list[str] = [] + manifests: list[str] = [] + requirements: list[str] = [] + for path in files: + relative = _relative(project_root, path) + lowered = path.name.lower() + if lowered == "domain-pack.yaml": + domain_packs.append(relative) + elif lowered == "project.manifest.json": + manifests.append(relative) + elif ( + lowered in REQUIREMENT_NAMES + or ("requirement" in lowered and lowered.endswith(".md")) + ): + requirements.append(relative) + return { + "schema_version": SCHEMA_VERSION, + "generated_at": utc_now(), + "project_root": str(project_root), + "project_markers": markers[:32], + "source_roots": source_roots[:32], + "domain_pack_candidates": domain_packs[:64], + "project_manifest_candidates": manifests[:64], + "requirements_candidates": requirements[:64], + "scan": { + "maximum_depth": 5, + "maximum_files_per_root": 10_000, + "priority_roots": [ + value for value in ("apps", "packages", "docs") + if (project_root / value).is_dir() + ], + "files_observed": len(files), + "truncated": len(files) >= 10_000, + }, + } + + +def _contains(path: Path, *needles: str) -> bool: + try: + if not path.is_file() or path.stat().st_size > 2 * 1024 * 1024: + return False + content = path.read_text(encoding="utf-8", errors="replace") + return all(needle in content for needle in needles) + except OSError: + return False + + +def _runtime_harness(root: Path, config: dict[str, Any]) -> Path | None: + runtime_path = config.get("runtime_path") + if not isinstance(runtime_path, str) or not runtime_path.strip(): + return None + base = Path(runtime_path).expanduser() + if not base.is_absolute(): + base = root / base + candidate = base.resolve() + nested = candidate / "packages" / "casan-harness" + if nested.is_dir(): + return nested + return candidate if candidate.name == "casan-harness" and candidate.is_dir() else None + + +def _check( + gate: str, + title: str, + status: str, + summary: str, + evidence: list[str] | None = None, +) -> dict[str, Any]: + return { + "gate": gate, + "title": title, + "status": status, + "summary": summary, + "evidence": evidence or [], + } + + +def _provider_available(root: Path) -> bool: + candidates = ( + root / ".specify" / "logs" / "level5" / "provider-usage.jsonl", + root / ".specify" / "logs" / "cost" / "metrics.jsonl", + ) + for path in candidates: + try: + if path.is_file() and path.stat().st_size > 2: + text = path.read_text(encoding="utf-8", errors="replace")[-262_144:] + for line in reversed(text.splitlines()): + try: + row = json.loads(line) + except (TypeError, ValueError): + continue + if not isinstance(row, dict): + continue + if ( + isinstance(row.get("provider"), str) and row["provider"].strip() + or isinstance(row.get("model"), str) and row["model"].strip() + or isinstance(row.get("total_tokens"), (int, float)) + or isinstance(row.get("cost_estimate"), (int, float)) + ): + return True + except OSError: + continue + return False + + +def assess( + root: str | Path, + *, + persist: bool = False, + client_state: dict[str, Any] | None = None, +) -> dict[str, Any]: + project_root = Path(root).expanduser().resolve() + config_path = project_root / ".casan" / "config.json" + lock_path = project_root / ".casan" / "version.lock" + bootstrap_path = project_root / ".casan" / "casan-hook.py" + config = read_json(config_path) + lock = read_json(lock_path) + domain_config = read_json(project_root / ".casan" / "domain.json") + previous_readiness = read_json(project_root / ".casan" / "readiness.json") + if client_state is None: + previous_core = ( + (previous_readiness.get("dimensions") or {}).get("core") or {} + ) + previous_client_state = previous_core.get("client_activation") + client_state = ( + previous_client_state + if isinstance(previous_client_state, dict) else {} + ) + discovery = discover(project_root) + clients = [ + str(value) for value in config.get("clients", []) + if isinstance(value, str) + ] + runtime = _runtime_harness(project_root, config) + + h1_ok = bool(config.get("project_id") and config.get("schema_version")) + h2_evidence: list[str] = [] + client_integrations: list[dict[str, Any]] = [] + for client in clients: + declared_state = (client_state or {}).get(client) or {} + if client == "claude": + relative = ".claude/settings.json" + present = _contains( + project_root / relative, "casan-hook.py", "--client claude") + operational = bool( + present and declared_state.get("operational", True)) + elif client == "codex": + relative = ".codex/hooks.json" + present = _contains( + project_root / relative, "casan-hook.py", "--client codex") + trust_state = declared_state + operational = bool( + present and trust_state.get("verified") + and trust_state.get("operational") + ) + elif client == "vscode-copilot": + relative = ".casan/casan-hook.py" + present = (project_root / relative).is_file() + operational = bool( + present and declared_state.get("operational", True)) + else: + client_integrations.append({ + "client": client, + "status": "unsupported", + "evidence": None, + }) + continue + if present: + h2_evidence.append(relative) + client_integrations.append({ + "client": client, + "status": ( + "operational" if operational else + "action_required" if present else + "missing" + ), + "evidence": relative if present else None, + }) + operational_clients = sum( + item["status"] == "operational" for item in client_integrations) + configured_clients = sum( + item["status"] in {"operational", "action_required"} + for item in client_integrations) + h2_status = ( + "warning" if operational_clients and operational_clients < len(client_integrations) else + "pass" if operational_clients else + "action_required" if configured_clients else + "fail" + ) + + report_engine = ( + runtime / "scripts" / "python" / "local_report.py" + if runtime else Path() + ) + h3_ok = bool(runtime and report_engine.is_file()) + enforcement = str(config.get("enforcement_mode") or "unknown") + h4_status = "pass" if enforcement == "enforce" else "warning" + state_dirs = ( + project_root / ".specify" / "logs", + project_root / ".specify" / "state", + ) + h5_ok = all( + path.is_dir() and os.access(path, os.W_OK | os.X_OK) + for path in state_dirs + ) + provider_available = _provider_available(project_root) + h7_ok = bool( + lock.get("harness_hash") + and lock.get("harness_version") + and bootstrap_path.is_file() + ) + + trust = (client_state or {}).get("codex") or {} + codex_action_required = ( + "codex" in clients + and not (trust.get("verified") and trust.get("operational")) + ) + + checks = [ + _check("H1", "Project contract", "pass" if h1_ok else "fail", + "CASAN project identity and schema are valid." if h1_ok + else "CASAN project configuration is missing or invalid.", + [".casan/config.json"] if h1_ok else []), + _check("H2", "Client activation", h2_status, + f"{operational_clients} configured client route(s) are operational." + if h2_status in {"pass", "warning"} else + "Configured client hooks require an activation step." + if h2_status == "action_required" else + "No complete supported client integration is installed.", + h2_evidence), + _check("H3", "Local report capability", "pass" if h3_ok else "fail", + "Core Local Assurance Viewer and on-demand export are available." + if h3_ok else "The resolved Core report runtime is unavailable.", + [str(report_engine)] if h3_ok else []), + _check("H4", "Enforcement boundary", h4_status, + "Policy enforcement is active." if enforcement == "enforce" + else "Observe mode records evidence but does not enforce decisions.", + [".casan/config.json"]), + _check("H5", "Evidence persistence", "pass" if h5_ok else "fail", + "Local logs and state directories are writable." if h5_ok + else "CASAN evidence directories are missing or not writable.", + [".specify/logs", ".specify/state"] if h5_ok else []), + _check("H6", "Provider telemetry", "pass" if provider_available else "optional_unavailable", + "Provider token/cost telemetry is available." if provider_available + else "Core runtime evidence is available; provider token/cost telemetry is optional and not observed yet."), + _check("H7", "Pinned runtime", "pass" if h7_ok else "fail", + "The project bootstrap and pinned runtime contract are present." + if h7_ok else "The bootstrap or version pin is incomplete.", + [".casan/casan-hook.py", ".casan/version.lock"] if h7_ok else []), + ] + blocking = any(item["status"] == "fail" for item in checks) + action_required = any( + item["status"] == "action_required" for item in checks + ) + attention = any(item["status"] == "warning" for item in checks) + core_status = ( + "blocked" if blocking else + "action_required" if action_required else + "ready_with_attention" if attention else + "ready" + ) + + manifests = discovery["project_manifest_candidates"] + packs = discovery["domain_pack_candidates"] + selected_manifest = domain_config.get("manifest") + selected_path = ( + project_root / selected_manifest + if isinstance(selected_manifest, str) else None + ) + if selected_path and selected_path.is_file(): + domain_status = "configured" + domain_summary = ( + "A project manifest is selected. Run project validation or the " + "project-owned pipeline to produce domain acceptance evidence." + ) + elif manifests: + domain_status = "discovered" + domain_summary = ( + "Project manifest candidates were discovered but none is selected. " + "Use `casan domain configure ` when domain acceptance is needed." + ) + elif packs: + domain_status = "discovered" + domain_summary = ( + "A Domain Pack was discovered, but no project manifest proves a " + "runnable domain pipeline." + ) + else: + domain_status = "not_configured" + domain_summary = ( + "No Domain Pack is configured. Core prompt assurance and reports " + "remain fully available without modifying application source." + ) + + telemetry_status = "available" if provider_available else "optional_unavailable" + next_actions: list[dict[str, str]] = [] + if blocking: + next_actions.append({ + "code": "repair_core", + "message": "Run `casan init` again, then `casan doctor`.", + }) + if codex_action_required: + reason = str(trust.get("reason") or trust.get("status") or "not_verified") + next_actions.append({ + "code": "verify_codex_trust", + "message": ( + "Open `/hooks` in Codex and review the project hooks, then run " + f"`casan doctor` (current trust state: {reason})." + ), + }) + for integration in client_integrations: + if ( + integration["status"] != "operational" + and integration["client"] != "codex" + ): + next_actions.append({ + "code": "activate_%s" % integration["client"].replace("-", "_"), + "message": ( + f"Complete activation for the selected " + f"{integration['client']} client route, then run `casan doctor`." + ), + }) + optional_actions: list[dict[str, str]] = [] + if domain_status != "configured": + optional_actions.append({ + "code": "configure_domain_pipeline", + "message": ( + "Optional: install/configure a CASAN Domain Pack when the team " + "needs project-specific SRS→test acceptance." + ), + }) + + readiness = { + "schema_version": SCHEMA_VERSION, + "generated_at": utc_now(), + "project_id": str(config.get("project_id") or project_root.name), + "overall_status": core_status, + "dimensions": { + "core": { + "status": core_status, + "summary": ( + "Core is ready for governed prompts and visual local reports." + if core_status == "ready" else + "Core is operational through at least one client route; another selected route needs attention." + if core_status == "ready_with_attention" else + "Core requires an explicit activation step." + if core_status == "action_required" else + "Core installation is incomplete." + ), + "acceptance": checks, + "client_activation": client_state or {}, + "client_integrations": client_integrations, + }, + "domain_pipeline": { + "status": domain_status, + "summary": domain_summary, + "domain_packs": packs, + "project_manifests": manifests, + "selected_manifest": selected_manifest + if selected_path and selected_path.is_file() else None, + }, + "provider_telemetry": { + "status": telemetry_status, + "summary": ( + "Provider token and cost telemetry has been observed." + if provider_available else + "Provider token and cost telemetry is optional and has not " + "been observed; this does not block Core." + ), + }, + }, + "next_actions": next_actions, + "optional_actions": optional_actions, + "discovery_file": ".casan/discovery.json", + } + if persist: + write_json(project_root / ".casan" / "discovery.json", discovery) + write_json(project_root / ".casan" / "readiness.json", readiness) + return readiness + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=os.getcwd()) + parser.add_argument("--write", action="store_true") + arguments = parser.parse_args() + print(json.dumps( + assess(arguments.root, persist=arguments.write), + ensure_ascii=False, + indent=2, + )) diff --git a/packages/casan-harness/scripts/python/report_cli.py b/packages/casan-harness/scripts/python/report_cli.py index dd3faf0..e968bab 100755 --- a/packages/casan-harness/scripts/python/report_cli.py +++ b/packages/casan-harness/scripts/python/report_cli.py @@ -24,6 +24,11 @@ from local_report import ( report_html, run_report, ) +from readiness import ( + assess as assess_readiness, + configure_domain, + discover as discover_project, +) def find_root(start: str) -> Path: @@ -302,6 +307,21 @@ def parser() -> argparse.ArgumentParser: latest = commands.add_parser("latest", help="show the latest finalized prompt receipt") latest.add_argument("--json", action="store_true") + readiness = commands.add_parser( + "readiness", + help="assess Core, Domain Pipeline, and Provider Telemetry separately", + ) + readiness.add_argument("--refresh", action="store_true") + readiness.add_argument("--json", action="store_true") + + domain = commands.add_parser( + "domain", + help="discover or select an optional project-owned Domain Pack manifest", + ) + domain.add_argument("action", choices=["status", "discover", "configure"]) + domain.add_argument("manifest", nargs="?") + domain.add_argument("--json", action="store_true") + view = commands.add_parser("view", help="open a trace in the local Core viewer") view.add_argument("trace_id", nargs="?") view.add_argument("--no-open", action="store_true") @@ -333,6 +353,51 @@ def main(argv=None) -> int: print(json.dumps(receipt, ensure_ascii=False, indent=2)) return 0 if receipt else 1 return print_receipt(receipt, root) + if args.command == "readiness": + result = assess_readiness(root, persist=args.refresh) + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + dimensions = result["dimensions"] + print(f"CASAN readiness — {result['project_id']}") + print(f" Core {dimensions['core']['status']}") + print(f" Domain Pipeline {dimensions['domain_pipeline']['status']}") + print(f" Provider Telemetry {dimensions['provider_telemetry']['status']}") + for action in result.get("next_actions", []): + print(f" → {action['message']}") + return 2 if result["overall_status"] == "blocked" else 0 + if args.command == "domain": + try: + if args.action == "discover": + payload = discover_project(root) + elif args.action == "configure": + if not args.manifest: + raise ValueError( + "`casan domain configure` requires a manifest path") + configure_domain(root, args.manifest) + payload = assess_readiness(root, persist=True) + else: + payload = assess_readiness(root, persist=False)[ + "dimensions"]["domain_pipeline"] + except ValueError as error: + print(f"CASAN_DOMAIN_INVALID — {error}", file=sys.stderr) + return 2 + if args.json: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + elif args.action == "discover": + print("CASAN Domain discovery") + print( + f" Manifests {len(payload['project_manifest_candidates'])}") + print(f" Domain Packs {len(payload['domain_pack_candidates'])}") + print(f" Requirements {len(payload['requirements_candidates'])}") + else: + dimension = ( + payload["dimensions"]["domain_pipeline"] + if "dimensions" in payload else payload + ) + print(f"CASAN Domain Pipeline — {dimension['status']}") + print(f" {dimension['summary']}") + return 0 if args.command == "viewer": return viewer_command(root, args.action, args.port, args.no_open) if args.command == "export": diff --git a/packages/casan-harness/tests/core-local-viewer-artifact-tests.sh b/packages/casan-harness/tests/core-local-viewer-artifact-tests.sh index d4b07bf..0891702 100755 --- a/packages/casan-harness/tests/core-local-viewer-artifact-tests.sh +++ b/packages/casan-harness/tests/core-local-viewer-artifact-tests.sh @@ -27,6 +27,7 @@ CASAN_BIN="$BUNDLE/bin/casan" test -f "$BUNDLE/packages/casan-harness/assets/local-viewer/index.html" test -f "$BUNDLE/packages/casan-harness/scripts/python/local_report.py" test -f "$BUNDLE/packages/casan-harness/scripts/python/local_viewer.py" +test -f "$BUNDLE/packages/casan-harness/scripts/python/readiness.py" test ! -e "$BUNDLE/packages/casan-control-panel" test ! -e "$BUNDLE/package.json" @@ -37,18 +38,26 @@ mkdir -p \ "$PROJECT/.specify/logs/trace-events" \ "$PROJECT/.specify/logs/cost" -python3 - "$PROJECT" <<'PY' +python3 - "$PROJECT" "$BUNDLE" <<'PY' import json from pathlib import Path import sys root = Path(sys.argv[1]) +bundle = Path(sys.argv[2]) (root / ".casan/config.json").write_text(json.dumps({ "schema_version": "21.3", "project_id": "artifact-project", "project_name": "Artifact Project", "edition": "core", "enforcement_mode": "enforce", + "runtime_path": str(bundle), + "clients": ["vscode-copilot"], +}), encoding="utf-8") +(root / ".casan/casan-hook.py").write_text("# casan-hook.py\n", encoding="utf-8") +(root / ".casan/version.lock").write_text(json.dumps({ + "harness_version": "artifact", + "harness_hash": "artifact-hash", }), encoding="utf-8") trace_id = "artifact-run-1" trace = { @@ -100,6 +109,7 @@ PY ( cd "$PROJECT" + CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" readiness --refresh --json > "$WORK/readiness.json" CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" report latest | grep -q "view_command=casan view artifact-run-1" CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" report export artifact-run-1 \ --format html --output "$WORK/run.html" >/dev/null @@ -118,6 +128,13 @@ assert d["summary"]["runs"] == 1 assert d["summary"]["tokens"]["total"] is None assert d["summary"]["cost_usd"]["estimated"] is None PY +python3 - "$WORK/readiness.json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1], encoding="utf-8")) +assert d["dimensions"]["core"]["status"] == "ready" +assert d["dimensions"]["domain_pipeline"]["status"] == "not_configured" +assert d["dimensions"]["provider_telemetry"]["status"] == "optional_unavailable" +PY URL="$(CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" view artifact-run-1 --no-open)" VIEWER_STARTED=1 diff --git a/packages/casan-harness/tests/local-viewer-tests.py b/packages/casan-harness/tests/local-viewer-tests.py index bd35b59..9fa448f 100755 --- a/packages/casan-harness/tests/local-viewer-tests.py +++ b/packages/casan-harness/tests/local-viewer-tests.py @@ -30,6 +30,16 @@ class LocalReportTests(unittest.TestCase): "enforcement_mode": "enforce", "maturity": {"level": 4, "status": "assessed"}, }), encoding="utf-8") + (self.root / ".casan" / "readiness.json").write_text(json.dumps({ + "schema_version": 1, + "overall_status": "ready", + "dimensions": { + "core": {"status": "ready", "acceptance": []}, + "domain_pipeline": {"status": "not_configured"}, + "provider_telemetry": {"status": "optional_unavailable"}, + }, + "next_actions": [], + }), encoding="utf-8") for path in ( ".specify/state", ".specify/logs/trace", @@ -92,6 +102,8 @@ class LocalReportTests(unittest.TestCase): self.assertEqual(h6["summary"]["runs"], 0) self.assertIsNone(h6["summary"]["tokens"]["total"]) self.assertTrue(any(item["code"] == "METRICS_MISSING" for item in h6["findings"])) + profile = REPORT.project_profile(self.root) + self.assertEqual(profile["readiness"]["dimensions"]["core"]["status"], "ready") def test_run_reconstructs_all_gates_and_redacts_sensitive_fields(self): self.write_run() @@ -160,6 +172,10 @@ class LocalReportTests(unittest.TestCase): self.assertIn('tabindex="-1"', script) self.assertIn('dossier.focus({ preventScroll: true })', script) self.assertIn("dossier.scrollIntoView({", script) + self.assertIn("No operational KPIs are shown until evidence exists.", script) + self.assertIn("H6 begins with the first governed run.", script) + self.assertIn("No operational counters yet", script) + self.assertNotIn('"Ready for the first governed run."', script) if __name__ == "__main__": diff --git a/packages/casan-harness/tests/readiness-tests.py b/packages/casan-harness/tests/readiness-tests.py new file mode 100644 index 0000000..f162518 --- /dev/null +++ b/packages/casan-harness/tests/readiness-tests.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Acceptance tests for CASAN's three-dimensional readiness contract.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + + +MODULE = Path(__file__).resolve().parents[1] / "scripts" / "python" / "readiness.py" +SPEC = importlib.util.spec_from_file_location("casan_readiness", MODULE) +READINESS = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(READINESS) + + +class ReadinessTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.runtime = self.root / "runtime" + report = ( + self.runtime / "packages" / "casan-harness" / "scripts" / + "python" / "local_report.py" + ) + report.parent.mkdir(parents=True) + report.write_text("# report engine\n", encoding="utf-8") + (self.root / ".casan").mkdir() + (self.root / ".casan" / "casan-hook.py").write_text( + "# bootstrap\n", encoding="utf-8") + (self.root / ".casan" / "config.json").write_text(json.dumps({ + "schema_version": "21.3", + "project_id": "commercial-core", + "edition": "core", + "enforcement_mode": "enforce", + "runtime_path": str(self.runtime), + "clients": ["codex"], + }), encoding="utf-8") + (self.root / ".casan" / "version.lock").write_text(json.dumps({ + "harness_version": "1.0.5", + "harness_hash": "abc123", + }), encoding="utf-8") + (self.root / ".codex").mkdir() + (self.root / ".codex" / "hooks.json").write_text( + '{"hooks":{"SessionStart":[{"hooks":[{"command":"python3 .casan/casan-hook.py --client codex"}]}]}}', + encoding="utf-8", + ) + (self.root / ".specify" / "logs").mkdir(parents=True) + (self.root / ".specify" / "state").mkdir(parents=True) + (self.root / "package.json").write_text( + '{"name":"commercial-core"}', encoding="utf-8") + + def tearDown(self): + self.temporary.cleanup() + + def test_core_is_ready_without_a_domain_pack_or_provider_telemetry(self): + result = READINESS.assess(self.root, client_state={ + "codex": { + "verified": True, + "operational": True, + "status": "trusted", + }, + }) + dimensions = result["dimensions"] + self.assertEqual(result["overall_status"], "ready") + self.assertEqual(dimensions["core"]["status"], "ready") + self.assertEqual( + dimensions["domain_pipeline"]["status"], "not_configured") + self.assertEqual( + dimensions["provider_telemetry"]["status"], + "optional_unavailable", + ) + self.assertEqual( + dimensions["core"]["acceptance"][5]["status"], + "optional_unavailable", + ) + + def test_unverified_codex_trust_is_an_action_not_a_fake_ready_state(self): + result = READINESS.assess(self.root, client_state={ + "codex": { + "verified": True, + "operational": False, + "status": "modified", + }, + }) + self.assertEqual(result["overall_status"], "action_required") + self.assertEqual( + result["dimensions"]["core"]["acceptance"][1]["status"], + "action_required", + ) + self.assertTrue(any( + action["code"] == "verify_codex_trust" + for action in result["next_actions"] + )) + + def test_one_healthy_route_keeps_core_operational_with_attention(self): + config_path = self.root / ".casan" / "config.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + config["clients"] = ["codex", "vscode-copilot"] + config_path.write_text(json.dumps(config), encoding="utf-8") + result = READINESS.assess(self.root, client_state={ + "codex": { + "verified": True, + "operational": True, + "status": "trusted", + }, + "vscode-copilot": {"operational": False}, + }) + self.assertEqual(result["overall_status"], "ready_with_attention") + self.assertEqual( + result["dimensions"]["core"]["acceptance"][1]["status"], + "warning", + ) + self.assertTrue(any( + action["code"] == "activate_vscode_copilot" + for action in result["next_actions"] + )) + + def test_discovery_is_casan_owned_and_never_creates_domain_inputs(self): + result = READINESS.assess(self.root, persist=True, client_state={ + "codex": { + "verified": True, + "operational": True, + "status": "trusted", + }, + }) + self.assertEqual(result["dimensions"]["domain_pipeline"]["domain_packs"], []) + self.assertTrue((self.root / ".casan" / "discovery.json").is_file()) + self.assertTrue((self.root / ".casan" / "readiness.json").is_file()) + self.assertFalse((self.root / "apps").exists()) + + def test_domain_configuration_references_existing_project_source_only(self): + manifest = self.root / "docs" / "project.manifest.json" + manifest.parent.mkdir() + manifest.write_text(json.dumps({ + "schema_version": 1, + "project_id": "commercial-core", + }), encoding="utf-8") + configured = READINESS.configure_domain( + self.root, "docs/project.manifest.json") + self.assertEqual(configured["manifest"], "docs/project.manifest.json") + result = READINESS.assess(self.root, client_state={ + "codex": { + "verified": True, + "operational": True, + "status": "trusted", + }, + }) + self.assertEqual( + result["dimensions"]["domain_pipeline"]["status"], "configured") + self.assertEqual( + result["dimensions"]["domain_pipeline"]["selected_manifest"], + "docs/project.manifest.json", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/packaging/levels.json b/packaging/levels.json index 97b421f..f2c367c 100644 --- a/packaging/levels.json +++ b/packaging/levels.json @@ -7,7 +7,7 @@ "level": 1, "package_names": ["casan-core", "casan-harness"], "status": "implemented", - "summary": "Minimal H1-H7 production runtime: security/action gates, evidence pack, audit, cost/telemetry, policy/config defaults, adapters, lightweight CLI, and a zero-dependency single-project Local Assurance Viewer with H1-H7, H6, history and on-demand export. Source-only tests and Platform helpers are excluded.", + "summary": "Minimal H1-H7 production runtime: security/action gates, evidence pack, audit, cost/telemetry, policy/config defaults, adapters, lightweight CLI, three-dimensional commercial readiness, and a zero-dependency single-project Local Assurance Viewer with H1-H7, H6, history and on-demand export. Source-only tests and Platform helpers are excluded.", "target_users": ["governance-harness-only projects", "BJT initial adoption", "CI gate usage"], "includes": [ "packages/casan-harness", @@ -44,6 +44,7 @@ "docs/packaging/DOCKER_GUIDE.md", "docs/packaging/CASAN_PACKAGING_PLAN.md", "docs/packaging/CORE_LOCAL_ASSURANCE_VIEWER.md", + "docs/packaging/COMMERCIAL_READINESS_CONTRACT.md", "scripts/package-release.sh" ], "artifact": "casan-devkit-v{VERSION}.tar.gz" diff --git a/packaging/runtime-layout.json b/packaging/runtime-layout.json index 3a97d59..47c9dce 100644 --- a/packaging/runtime-layout.json +++ b/packaging/runtime-layout.json @@ -40,6 +40,7 @@ "scripts/python/local_report.py", "scripts/python/local_viewer.py", "scripts/python/report_cli.py", + "scripts/python/readiness.py", "scripts/python/harness_hash.py" ] },