diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..492153f
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,75 @@
+# Copilot PR Review Instructions — FastEdge-sdk-js
+
+## Constitution
+
+This repository is `@gcoredev/fastedge-sdk-js` — the JavaScript/TypeScript SDK for Gcore FastEdge. It provides three CLI tools (`fastedge-build`, `fastedge-init`, `fastedge-assets`), a static site server API (`createStaticServer`), and TypeScript type definitions for the FastEdge WASM runtime.
+
+### Principles (enforce during review)
+
+1. **Type accuracy** — TypeScript declarations in `types/` are the authoritative public API surface. Code and docs must match them exactly.
+2. **No over-engineering** — Simple solutions over complex abstractions. Three similar lines > premature abstraction.
+3. **CLI flag consistency** — All CLI flags must match `arg()` definitions in `src/cli/*/`. No undocumented flags, no documented-but-removed flags.
+4. **Build pipeline integrity** — The componentize pipeline (esbuild → Wizer → JCO) is sensitive to ordering. Changes must preserve stage dependencies.
+5. **Runtime constraints** — Code targeting the WASM runtime has strict limitations (no Node APIs, init-time-only Wizer calls). Reviewers must flag violations.
+
+### Public API contract
+
+The public API surface is defined by:
+- `types/` — TypeScript declarations (runtime APIs, static server, KV store)
+- `src/cli/fastedge-build/` — Build CLI flags and config interface
+- `src/cli/fastedge-init/` — Scaffold CLI wizard
+- `src/cli/fastedge-assets/` — Asset manifest CLI
+- `src/server/static-assets/static-server/` — `createStaticServer` API
+
+Changes to these surfaces require updated `docs/`, updated tests, and a semver-appropriate version bump.
+
+## Documentation Freshness
+
+`docs/` is the single source of truth for public API documentation. When code changes affect the public API or user-facing behavior, **request changes** if the corresponding doc file was not updated in the same PR.
+
+### Public API changes (must update docs/)
+- New, modified, or removed CLI flags in `src/cli/fastedge-build/build.ts`
+- Changes to `BuildConfig` or `AssetCacheConfig` interfaces in `src/cli/fastedge-build/types.ts`
+- Changes to scaffold wizard behavior in `src/cli/fastedge-init/`
+- Changes to asset manifest CLI in `src/cli/fastedge-assets/`
+- Changes to `createStaticServer` API or `ServerConfig` in `src/server/static-assets/`
+- Changes to TypeScript declarations in `types/`
+- Changes to `package.json` exports or bin entries
+
+### Mapping: code location → doc file
+
+| Code path | Doc file |
+|-----------|----------|
+| `src/cli/fastedge-build/` (flags, config) | `docs/BUILD_CLI.md` |
+| `src/cli/fastedge-build/types.ts` (BuildConfig) | `docs/BUILD_CLI.md` |
+| `src/cli/fastedge-build/config-build.ts` (build types) | `docs/BUILD_CLI.md` |
+| `src/cli/fastedge-init/` (wizard, templates) | `docs/INIT_CLI.md` |
+| `src/cli/fastedge-assets/` (manifest CLI) | `docs/ASSETS_CLI.md` |
+| `src/server/static-assets/static-server/` | `docs/STATIC_SITES.md` |
+| `src/server/static-assets/asset-manifest/` | `docs/ASSETS_CLI.md` |
+| `types/fastedge-env.d.ts` | `docs/SDK_API.md` |
+| `types/fastedge-secret.d.ts` | `docs/SDK_API.md` |
+| `types/fastedge-kv.d.ts` | `docs/SDK_API.md` |
+| `types/globals.d.ts` | `docs/SDK_API.md` |
+| `package.json` (exports, bin) | `docs/INDEX.md` |
+| `types/`, `src/cli/`, `README.md` (quickstart examples) | `docs/quickstart.md` |
+| `fastedge-plugin-source/manifest.json` | `.github/copilot-instructions.md` |
+
+### Violation example
+
+> PR changes `BuildConfig` interface in `types.ts` but `docs/BUILD_CLI.md` still shows the old field names → **request changes**. The config interface must be documented before merge.
+
+### Quickstart protection
+
+If any public API signature or CLI behavior changes, check whether `docs/quickstart.md` examples are still accurate. Request changes if examples would no longer work against the updated code.
+
+### Pipeline source contract
+
+If `fastedge-plugin-source/manifest.json` lists source files that overlap with files changed in this PR, request that `docs/` is updated to keep the plugin pipeline's source material current.
+
+## Quality Rules
+
+- All TypeScript signatures in docs must match `types/` declarations exactly
+- All CLI flags in docs must match `arg()` definitions in source
+- KvStore `get()` returns `ArrayBuffer | null` (not string) — flag incorrect types
+- No marketing language in documentation — precise, technical prose only
diff --git a/.github/workflows/collect.py b/.github/workflows/collect.py
new file mode 100644
index 0000000..ce1fb65
--- /dev/null
+++ b/.github/workflows/collect.py
@@ -0,0 +1,959 @@
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any
+
+SOURCE_BRANCH = "doctor-v1"
+TOOLKIT_SOURCE_DIR = "toolkit"
+RUNTIME_SOURCE_DIR = "workflows/runtime"
+TOOLKIT_TARGET_ROOT = ".toolkit"
+RUNTIME_TARGET_ROOT = ".github/workflows"
+TOOLKIT_SOURCE_SHA_PATH = ".toolkit/SOURCE_SHA"
+RUNTIME_SOURCE_SHA_PATH = ".toolkit/DOCTOR_RUNTIME_SHA"
+TOOLKIT_REQUIRED_DIRECTORIES = {
+ "claude": ".toolkit/claude",
+ "scripts": ".toolkit/scripts",
+ "workflows": ".toolkit/workflows",
+ "examples": ".toolkit/examples",
+}
+RUNTIME_SOURCE_FILES = [
+ # Closed-set allowlist: adding a new runtime file requires updating this list
+ # so doctor never silently ignores source bundle changes.
+ "doctor.yml",
+ "collect.py",
+ "doctor-analyze.md",
+ "doctor-analyze.prompt.md",
+ "doctor-analyze.lock.yml",
+]
+RUNTIME_BOOTSTRAP_FILES = [
+ "doctor.yml",
+]
+RUNTIME_SYNC_FILES: list[str] = []
+STALE_RUNTIME_TARGET_FILES = sorted(set(RUNTIME_SOURCE_FILES) - set(RUNTIME_BOOTSTRAP_FILES))
+HYGIENE_EXCLUDED_FILE_NAMES = {".DS_Store"}
+HYGIENE_EXCLUDED_DIR_NAMES = {"__pycache__"}
+HYGIENE_EXCLUDED_SUFFIXES = {".pyc", ".pyo"}
+_TRAILER_PATTERNS = {
+ "method": re.compile(r"^Method:\s*(.+)$", re.IGNORECASE),
+ "agent": re.compile(r"^Agent:\s*(.+)$", re.IGNORECASE),
+ "co_authored_by": re.compile(r"^Co-authored-by:\s*(.+)$", re.IGNORECASE),
+ "refs": re.compile(r"^Refs:\s*(.+)$", re.IGNORECASE),
+ "closes": re.compile(r"^Closes:\s*(.+)$", re.IGNORECASE),
+}
+PRESENCE_ONLY_PATHS = [
+ "AGENTS.md",
+ "CLAUDE.md",
+ "DOCS.md",
+ ".specify/memory/constitution.md",
+ "docs/quickstart.md",
+]
+
+
+def _path_to_id(path: str) -> str:
+ return path.strip("./").replace("/", "_").replace(".", "_")
+
+
+def _path_exists(repo_root: Path, relative_path: str) -> bool:
+ return (repo_root / relative_path).exists()
+
+
+def _path_state(repo_root: Path, relative_path: str) -> str:
+ path = repo_root / relative_path
+ if path.exists():
+ return "present"
+ if os.path.lexists(path):
+ return "broken_link"
+ return "missing"
+
+
+def _path_present(repo_root: Path, relative_path: str) -> bool:
+ return _path_state(repo_root, relative_path) == "present"
+
+
+def _is_hygiene_ignored(candidate: Path) -> bool:
+ return (
+ candidate.name in HYGIENE_EXCLUDED_FILE_NAMES
+ or candidate.suffix in HYGIENE_EXCLUDED_SUFFIXES
+ or any(part in HYGIENE_EXCLUDED_DIR_NAMES for part in candidate.parts)
+ )
+
+
+def _is_managed_source_file(candidate: Path) -> bool:
+ return candidate.is_file() and not _is_hygiene_ignored(candidate)
+
+
+def _new_finding(
+ finding_id: str,
+ *,
+ category: str,
+ severity: str,
+ path: str | None,
+ state: str,
+ auto_action: str,
+ message: str,
+ expected: str | None = None,
+ actual: str | None = None,
+) -> dict[str, Any]:
+ return {
+ "id": finding_id,
+ "category": category,
+ "severity": severity,
+ "path": path,
+ "state": state,
+ "expected": expected,
+ "actual": actual,
+ "auto_action": auto_action,
+ "message": message,
+ }
+
+
+def _read_text(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def load_required_runtime_files_from_source(
+ *,
+ source_root: Path,
+ selected_relative_paths: list[str] | tuple[str, ...] | None = None,
+) -> dict[str, str]:
+ source_root = Path(source_root)
+ runtime_source_root = source_root / RUNTIME_SOURCE_DIR
+ if not runtime_source_root.exists():
+ raise FileNotFoundError(f"Runtime source root is missing: {runtime_source_root}")
+
+ actual_relative_paths = sorted(
+ candidate.relative_to(runtime_source_root).as_posix()
+ for candidate in runtime_source_root.rglob("*")
+ if _is_managed_source_file(candidate)
+ )
+ actual_path_set = set(actual_relative_paths)
+
+ missing_sources = [
+ relative_path
+ for relative_path in RUNTIME_SOURCE_FILES
+ if relative_path not in actual_path_set
+ ]
+ if missing_sources:
+ missing_list = ", ".join(missing_sources)
+ raise FileNotFoundError(f"Required doctor runtime source files are missing: {missing_list}")
+
+ unexpected_sources = [
+ relative_path
+ for relative_path in actual_relative_paths
+ if relative_path not in RUNTIME_SOURCE_FILES
+ ]
+ if unexpected_sources:
+ unexpected_list = ", ".join(unexpected_sources)
+ raise RuntimeError(
+ "Unexpected doctor runtime source files found: "
+ f"{unexpected_list}. Update RUNTIME_SOURCE_FILES to manage them explicitly."
+ )
+
+ selected_relative_paths = list(RUNTIME_SYNC_FILES if selected_relative_paths is None else selected_relative_paths)
+ return {
+ f"{RUNTIME_TARGET_ROOT}/{relative_path}": _read_text(runtime_source_root / relative_path)
+ for relative_path in selected_relative_paths
+ }
+
+
+def _read_sha_marker(repo_root: Path, relative_path: str) -> str | None:
+ marker_path = repo_root / relative_path
+ if not marker_path.exists():
+ return None
+
+ raw = marker_path.read_text(encoding="utf-8").strip()
+ return raw or None
+
+
+def _resolve_source_sha(source_root: Path, explicit_source_sha: str | None) -> str:
+ if explicit_source_sha:
+ return explicit_source_sha.strip()
+
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=source_root,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"Unable to resolve source SHA from {source_root}")
+
+ return result.stdout.strip()
+
+
+def build_expected_manifest_stub() -> dict[str, Any]:
+ return {
+ "source_branch": SOURCE_BRANCH,
+ "source_sha": "",
+ "managed_root": TOOLKIT_TARGET_ROOT,
+ "managed_directories": list(TOOLKIT_REQUIRED_DIRECTORIES.values()),
+ "managed_files": {},
+ "runtime_root": RUNTIME_TARGET_ROOT,
+ "runtime_files": {},
+ "runtime_bootstrap_files": {},
+ "presence_paths": list(PRESENCE_ONLY_PATHS),
+ }
+
+
+def build_expected_manifest_from_source(
+ *,
+ source_root: Path,
+ source_sha: str | None = None,
+ source_branch: str = SOURCE_BRANCH,
+) -> dict[str, Any]:
+ source_root = Path(source_root)
+ if not source_root.exists():
+ raise FileNotFoundError(f"Source root is missing: {source_root}")
+
+ resolved_source_sha = _resolve_source_sha(source_root, source_sha)
+ toolkit_source_root = source_root / TOOLKIT_SOURCE_DIR
+ runtime_source_root = source_root / RUNTIME_SOURCE_DIR
+ if not toolkit_source_root.exists():
+ raise FileNotFoundError(f"Toolkit source root is missing: {toolkit_source_root}")
+ if not runtime_source_root.exists():
+ raise FileNotFoundError(f"Runtime source root is missing: {runtime_source_root}")
+
+ managed_files: dict[str, str] = {}
+ managed_directories = sorted(
+ f"{TOOLKIT_TARGET_ROOT}/{candidate.name}"
+ for candidate in toolkit_source_root.iterdir()
+ if candidate.is_dir() and candidate.name not in HYGIENE_EXCLUDED_FILE_NAMES
+ )
+
+ for source_file in sorted(candidate for candidate in toolkit_source_root.rglob("*") if _is_managed_source_file(candidate)):
+ relative_path = source_file.relative_to(toolkit_source_root).as_posix()
+ managed_files[f"{TOOLKIT_TARGET_ROOT}/{relative_path}"] = _read_text(source_file)
+
+ managed_files[TOOLKIT_SOURCE_SHA_PATH] = f"{resolved_source_sha}\n"
+ managed_files[RUNTIME_SOURCE_SHA_PATH] = f"{resolved_source_sha}\n"
+ runtime_files = load_required_runtime_files_from_source(
+ source_root=source_root,
+ selected_relative_paths=RUNTIME_SYNC_FILES,
+ )
+ runtime_bootstrap_files = load_required_runtime_files_from_source(
+ source_root=source_root,
+ selected_relative_paths=RUNTIME_BOOTSTRAP_FILES,
+ )
+
+ return {
+ "source_branch": source_branch,
+ "source_sha": resolved_source_sha,
+ "managed_root": TOOLKIT_TARGET_ROOT,
+ "managed_directories": managed_directories,
+ "managed_files": managed_files,
+ "runtime_root": RUNTIME_TARGET_ROOT,
+ "runtime_files": runtime_files,
+ "runtime_bootstrap_files": runtime_bootstrap_files,
+ "presence_paths": list(PRESENCE_ONLY_PATHS),
+ }
+
+
+def build_compliance_checks(*, repo_root: Path) -> dict[str, dict[str, bool]]:
+ repo_root = Path(repo_root)
+
+ return {
+ "required_paths": {
+ "agents_present": _path_present(repo_root, "AGENTS.md"),
+ "claude_present": _path_present(repo_root, "CLAUDE.md"),
+ "docs_md_present": _path_present(repo_root, "DOCS.md"),
+ "constitution_present": _path_present(repo_root, ".specify/memory/constitution.md"),
+ "quickstart_present": _path_present(repo_root, "docs/quickstart.md"),
+ },
+ "toolkit": {
+ "root_present": (repo_root / TOOLKIT_TARGET_ROOT).is_dir(),
+ "source_sha_present": _path_exists(repo_root, TOOLKIT_SOURCE_SHA_PATH),
+ "runtime_sha_present": _path_exists(repo_root, RUNTIME_SOURCE_SHA_PATH),
+ "claude_present": (repo_root / TOOLKIT_REQUIRED_DIRECTORIES["claude"]).is_dir(),
+ "scripts_present": (repo_root / TOOLKIT_REQUIRED_DIRECTORIES["scripts"]).is_dir(),
+ "workflows_present": (repo_root / TOOLKIT_REQUIRED_DIRECTORIES["workflows"]).is_dir(),
+ "examples_present": (repo_root / TOOLKIT_REQUIRED_DIRECTORIES["examples"]).is_dir(),
+ },
+ "doctor_runtime": {
+ "doctor_yml_present": _path_exists(repo_root, f"{RUNTIME_TARGET_ROOT}/doctor.yml"),
+ },
+ }
+
+
+def _iter_relative_entries(repo_root: Path, subtree_root: str) -> dict[str, str]:
+ root = repo_root / subtree_root
+ if not root.exists():
+ return {}
+
+ entries: dict[str, str] = {}
+ for candidate in root.rglob("*"):
+ if _is_hygiene_ignored(candidate):
+ continue
+ if candidate.is_file():
+ entries[candidate.relative_to(repo_root).as_posix()] = "present"
+ continue
+ if candidate.is_symlink() and not candidate.exists():
+ entries[candidate.relative_to(repo_root).as_posix()] = "broken_link"
+ return entries
+
+
+def _compare_expected_files(
+ *,
+ repo_root: Path,
+ expected_files: dict[str, str],
+ category: str,
+ change_message_prefix: str,
+ auto_action: str = "act",
+ missing_severity: str = "error",
+ changed_severity: str = "warning",
+ broken_link_severity: str = "error",
+) -> list[dict[str, Any]]:
+ findings: list[dict[str, Any]] = []
+
+ for relative_path, expected_content in expected_files.items():
+ file_state = _path_state(repo_root, relative_path)
+ if file_state == "missing":
+ findings.append(
+ _new_finding(
+ f"{category}.{_path_to_id(relative_path)}.missing",
+ category=category,
+ severity=missing_severity,
+ path=relative_path,
+ state="missing",
+ auto_action=auto_action,
+ message=f"{change_message_prefix} is missing: {relative_path}",
+ expected=expected_content,
+ )
+ )
+ continue
+ if file_state == "broken_link":
+ findings.append(
+ _new_finding(
+ f"{category}.{_path_to_id(relative_path)}.broken_link",
+ category=category,
+ severity=broken_link_severity,
+ path=relative_path,
+ state="broken_link",
+ auto_action=auto_action,
+ message=f"{change_message_prefix} is a broken symlink: {relative_path}",
+ expected=expected_content,
+ )
+ )
+ continue
+
+ file_path = repo_root / relative_path
+ actual_content = file_path.read_text(encoding="utf-8").rstrip("\n")
+ if actual_content != str(expected_content).rstrip("\n"):
+ findings.append(
+ _new_finding(
+ f"{category}.{_path_to_id(relative_path)}.changed",
+ category=category,
+ severity=changed_severity,
+ path=relative_path,
+ state="changed",
+ auto_action=auto_action,
+ message=f"{change_message_prefix} content differs: {relative_path}",
+ expected=str(expected_content),
+ actual=actual_content,
+ )
+ )
+
+ return findings
+
+
+def scan_repository_files(*, repo_root: Path, expected_manifest: dict[str, Any]) -> dict[str, Any]:
+ repo_root = Path(repo_root)
+ managed_files: dict[str, str] = dict(expected_manifest.get("managed_files", {}))
+ runtime_files: dict[str, str] = dict(expected_manifest.get("runtime_files", {}))
+ runtime_bootstrap_files: dict[str, str] = dict(expected_manifest.get("runtime_bootstrap_files", {}))
+ managed_root = str(expected_manifest.get("managed_root") or TOOLKIT_TARGET_ROOT)
+ presence_paths: list[str] = list(expected_manifest.get("presence_paths", PRESENCE_ONLY_PATHS))
+ checks = build_compliance_checks(repo_root=repo_root)
+
+ findings = [
+ *_compare_expected_files(
+ repo_root=repo_root,
+ expected_files=managed_files,
+ category="managed",
+ change_message_prefix="Managed path",
+ ),
+ *_compare_expected_files(
+ repo_root=repo_root,
+ expected_files=runtime_files,
+ category="runtime",
+ change_message_prefix="Doctor runtime path",
+ ),
+ *_compare_expected_files(
+ repo_root=repo_root,
+ expected_files=runtime_bootstrap_files,
+ category="observed",
+ change_message_prefix="Doctor bootstrap",
+ auto_action="none",
+ missing_severity="warning",
+ changed_severity="warning",
+ broken_link_severity="warning",
+ ),
+ ]
+
+ managed_expected_paths = set(managed_files)
+ managed_actual_entries = _iter_relative_entries(repo_root, managed_root)
+ for actual_path in sorted(set(managed_actual_entries) - managed_expected_paths):
+ actual_state = managed_actual_entries[actual_path]
+ if actual_state == "broken_link":
+ findings.append(
+ _new_finding(
+ f"managed.{_path_to_id(actual_path)}.broken_link",
+ category="managed",
+ severity="error",
+ path=actual_path,
+ state="broken_link",
+ auto_action="act",
+ message=f"Unexpected extra managed path is a broken symlink: {actual_path}",
+ )
+ )
+ continue
+ findings.append(
+ _new_finding(
+ f"managed.{_path_to_id(actual_path)}.unexpected_extra",
+ category="managed",
+ severity="warning",
+ path=actual_path,
+ state="unexpected_extra",
+ auto_action="act",
+ message=f"Unexpected extra managed path present: {actual_path}",
+ )
+ )
+
+ for presence_path in presence_paths:
+ presence_state = _path_state(repo_root, presence_path)
+ if presence_state == "present":
+ continue
+ if presence_state == "broken_link":
+ findings.append(
+ _new_finding(
+ f"observed.{_path_to_id(presence_path)}.broken_link",
+ category="observed",
+ severity="warning",
+ path=presence_path,
+ state="broken_link",
+ auto_action="analyze",
+ message=f"Required path is a broken symlink: {presence_path}",
+ )
+ )
+ continue
+ findings.append(
+ _new_finding(
+ f"observed.{_path_to_id(presence_path)}.missing",
+ category="observed",
+ severity="warning",
+ path=presence_path,
+ state="missing",
+ auto_action="analyze",
+ message=f"Required path is missing: {presence_path}",
+ )
+ )
+
+ for relative_name in STALE_RUNTIME_TARGET_FILES:
+ stale_path = f"{RUNTIME_TARGET_ROOT}/{relative_name}"
+ stale_state = _path_state(repo_root, stale_path)
+ if stale_state == "missing":
+ continue
+ if stale_state == "broken_link":
+ findings.append(
+ _new_finding(
+ f"observed.{_path_to_id(stale_path)}.broken_link",
+ category="observed",
+ severity="warning",
+ path=stale_path,
+ state="broken_link",
+ auto_action="none",
+ message=f"Stale doctor runtime path must be removed manually: {stale_path}",
+ )
+ )
+ continue
+ findings.append(
+ _new_finding(
+ f"observed.{_path_to_id(stale_path)}.stale",
+ category="observed",
+ severity="warning",
+ path=stale_path,
+ state="stale",
+ auto_action="none",
+ message=f"Stale doctor runtime path must be removed manually: {stale_path}",
+ )
+ )
+
+ return {
+ "checks": checks,
+ "findings": findings,
+ "managed_drift_present": any(item["category"] == "managed" for item in findings),
+ "runtime_drift_present": any(item["category"] == "runtime" for item in findings),
+ "observed_findings_present": any(item["category"] == "observed" for item in findings),
+ }
+
+
+def select_act_reason(flags: dict[str, bool]) -> str:
+ priority = [
+ "toolkit_missing",
+ "source_sha_missing",
+ "runtime_sha_missing",
+ "managed_drift",
+ ]
+
+ for reason in priority:
+ if flags.get(reason, False):
+ return reason
+ if flags.get("operational_error", False):
+ return "operational_error"
+ return "healthy"
+
+
+def enrich_commit_record_with_trailers(commit_payload: dict[str, Any]) -> dict[str, Any]:
+ message = str(commit_payload.get("message", ""))
+ parse_message = message.replace("\\n", "\n")
+
+ method: str | None = None
+ agent: str | None = None
+ co_authored_by: list[str] = []
+ refs: list[str] = []
+ closes: list[str] = []
+
+ for line in parse_message.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+
+ method_match = _TRAILER_PATTERNS["method"].match(line)
+ if method_match:
+ method = method_match.group(1).strip()
+ continue
+
+ agent_match = _TRAILER_PATTERNS["agent"].match(line)
+ if agent_match:
+ agent = agent_match.group(1).strip()
+ continue
+
+ co_author_match = _TRAILER_PATTERNS["co_authored_by"].match(line)
+ if co_author_match:
+ co_authored_by.append(co_author_match.group(1).strip())
+ continue
+
+ refs_match = _TRAILER_PATTERNS["refs"].match(line)
+ if refs_match:
+ refs.append(refs_match.group(1).strip())
+ continue
+
+ closes_match = _TRAILER_PATTERNS["closes"].match(line)
+ if closes_match:
+ closes.append(closes_match.group(1).strip())
+
+ return {
+ "sha": commit_payload.get("sha"),
+ "author": commit_payload.get("author"),
+ "authored_at": commit_payload.get("authored_at"),
+ "message": message,
+ "method": method,
+ "agent": agent,
+ "co_authored_by": co_authored_by,
+ "refs": refs,
+ "closes": closes,
+ }
+
+
+def build_activity_sections(
+ *,
+ daily_commits: list[dict[str, Any]],
+ daily_prs: list[dict[str, Any]],
+ daily_issues: list[dict[str, Any]],
+ snapshot_prs: list[dict[str, Any]],
+ snapshot_issues: list[dict[str, Any]],
+) -> dict[str, Any]:
+ return {
+ "activity": {
+ "commits": list(daily_commits),
+ "prs": list(daily_prs),
+ "issues": list(daily_issues),
+ },
+ "snapshot": {
+ "prs": list(snapshot_prs),
+ "issues": list(snapshot_issues),
+ },
+ }
+
+
+def _build_environment_findings(*, issues_enabled: bool) -> list[dict[str, Any]]:
+ if issues_enabled:
+ return []
+
+ return [
+ _new_finding(
+ "operational.issues_disabled",
+ category="operational",
+ severity="warning",
+ path=None,
+ state="issues_disabled",
+ auto_action="none",
+ message="Issues are disabled in the target repository",
+ )
+ ]
+
+
+def _stringify_bool(value: bool) -> str:
+ return "true" if value else "false"
+
+
+def _parse_bool(value: str | bool | None, *, default: bool = False) -> bool:
+ if isinstance(value, bool):
+ return value
+ if value is None:
+ return default
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _rfc3339_now() -> str:
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
+
+
+def _default_window(report_date: str) -> tuple[str, str]:
+ report_start = datetime.strptime(report_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
+ window_from = report_start - timedelta(days=1)
+ return (
+ window_from.isoformat().replace("+00:00", "Z"),
+ report_start.isoformat().replace("+00:00", "Z"),
+ )
+
+
+def _load_json_source(
+ *,
+ env_var: str,
+ path_env_var: str,
+ default: Any,
+) -> Any:
+ path_value = os.getenv(path_env_var)
+ if path_value:
+ return json.loads(Path(path_value).read_text(encoding="utf-8"))
+
+ raw_value = os.getenv(env_var)
+ if raw_value:
+ return json.loads(raw_value)
+
+ return default
+
+
+def resolve_source_state(*, repo_root: Path, expected_manifest: dict[str, Any]) -> dict[str, Any]:
+ repo_root = Path(repo_root)
+ source_branch = str(expected_manifest.get("source_branch") or SOURCE_BRANCH)
+ source_sha = str(expected_manifest.get("source_sha") or "").strip()
+ findings: list[dict[str, Any]] = []
+
+ if not source_sha:
+ findings.append(
+ _new_finding(
+ "operational.source_manifest_unavailable",
+ category="operational",
+ severity="error",
+ path=None,
+ state="source_manifest_unavailable",
+ auto_action="none",
+ message="Collect did not receive a valid source manifest from the configured source ref",
+ )
+ )
+
+ return {
+ "source_branch": source_branch,
+ "source_sha": source_sha,
+ "installed_source_sha": _read_sha_marker(repo_root, TOOLKIT_SOURCE_SHA_PATH),
+ "installed_runtime_sha": _read_sha_marker(repo_root, RUNTIME_SOURCE_SHA_PATH),
+ "fatal_error": not bool(source_sha),
+ "findings": findings,
+ }
+
+
+def build_job_outputs(
+ *,
+ report_date: str,
+ source_sha: str | None,
+ installed_source_sha: str | None,
+ installed_runtime_sha: str | None,
+ should_act: bool,
+ act_reason: str,
+ issues_enabled: bool,
+ observed_findings_present: bool,
+ analyze_day: str | None = None,
+ report_tz: str | None = None,
+) -> dict[str, str]:
+ outputs = {
+ "should_act": _stringify_bool(should_act),
+ "source_sha": source_sha or "",
+ "installed_source_sha": installed_source_sha or "",
+ "installed_runtime_sha": installed_runtime_sha or "",
+ "act_reason": act_reason,
+ "issues_enabled": _stringify_bool(issues_enabled),
+ "observed_findings_present": _stringify_bool(observed_findings_present),
+ "stats_artifact_name": f"doctor-stats-{report_date}",
+ "findings_artifact_name": f"doctor-findings-{report_date}",
+ }
+
+ if analyze_day is not None:
+ outputs["analyze_day"] = analyze_day
+ if report_tz is not None:
+ outputs["report_tz"] = report_tz
+
+ return outputs
+
+
+def assemble_collect_result(
+ *,
+ repo_root: Path,
+ expected_manifest: dict[str, Any],
+ issues_enabled: bool,
+ repo_full_name: str,
+ repo_default_branch: str,
+ workflow_name: str,
+ workflow_run_id: int,
+ workflow_run_attempt: int,
+ workflow_event_name: str,
+ workflow_actor: str,
+ source_repo_full_name: str,
+ analyze_day: str | None,
+ report_tz: str | None,
+ collected_at: str,
+ report_date: str,
+ window_from: str,
+ window_to: str,
+ daily_commits: list[dict[str, Any]] | None = None,
+ daily_prs: list[dict[str, Any]] | None = None,
+ daily_issues: list[dict[str, Any]] | None = None,
+ snapshot_prs: list[dict[str, Any]] | None = None,
+ snapshot_issues: list[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+ repo_root = Path(repo_root)
+ source_state = resolve_source_state(repo_root=repo_root, expected_manifest=expected_manifest)
+ scan_result = scan_repository_files(repo_root=repo_root, expected_manifest=expected_manifest)
+ environment_findings = _build_environment_findings(issues_enabled=issues_enabled)
+
+ flags = {
+ "toolkit_missing": not (repo_root / TOOLKIT_TARGET_ROOT).is_dir(),
+ "source_sha_missing": not _path_exists(repo_root, TOOLKIT_SOURCE_SHA_PATH),
+ "runtime_sha_missing": not _path_exists(repo_root, RUNTIME_SOURCE_SHA_PATH),
+ "managed_drift": scan_result["managed_drift_present"],
+ "operational_error": source_state["fatal_error"],
+ }
+
+ should_act = any(
+ flags[name]
+ for name in (
+ "toolkit_missing",
+ "source_sha_missing",
+ "runtime_sha_missing",
+ "managed_drift",
+ )
+ )
+ if flags["operational_error"]:
+ should_act = False
+
+ if should_act:
+ act_reason = select_act_reason(flags)
+ elif flags["operational_error"]:
+ act_reason = "operational_error"
+ else:
+ act_reason = "healthy"
+
+ findings = [
+ *source_state["findings"],
+ *scan_result["findings"],
+ *environment_findings,
+ ]
+
+ normalized_commits = [
+ enrich_commit_record_with_trailers(commit)
+ if any(field not in commit for field in ("method", "agent", "co_authored_by", "refs", "closes"))
+ else dict(commit)
+ for commit in (daily_commits or [])
+ ]
+ activity_sections = build_activity_sections(
+ daily_commits=normalized_commits,
+ daily_prs=list(daily_prs or []),
+ daily_issues=list(daily_issues or []),
+ snapshot_prs=list(snapshot_prs or []),
+ snapshot_issues=list(snapshot_issues or []),
+ )
+
+ job_outputs = build_job_outputs(
+ report_date=report_date,
+ source_sha=source_state["source_sha"],
+ installed_source_sha=source_state["installed_source_sha"],
+ installed_runtime_sha=source_state["installed_runtime_sha"],
+ should_act=should_act,
+ act_reason=act_reason,
+ issues_enabled=issues_enabled,
+ observed_findings_present=scan_result["observed_findings_present"],
+ analyze_day=analyze_day,
+ report_tz=report_tz,
+ )
+
+ contract_payload = {
+ "managed_root": str(expected_manifest.get("managed_root") or TOOLKIT_TARGET_ROOT),
+ "managed_directories": list(
+ expected_manifest.get("managed_directories", TOOLKIT_REQUIRED_DIRECTORIES.values())
+ ),
+ "runtime_root": str(expected_manifest.get("runtime_root") or RUNTIME_TARGET_ROOT),
+ "runtime_files": sorted(expected_manifest.get("runtime_files", {}).keys()),
+ "presence_paths": list(expected_manifest.get("presence_paths", PRESENCE_ONLY_PATHS)),
+ }
+
+ stats_payload: dict[str, Any] | None = None
+ if not source_state["fatal_error"]:
+ stats_payload = {
+ "schema_version": "doctor-stats/v3",
+ "collected_at": collected_at,
+ "report_date": report_date,
+ "window": {
+ "from": window_from,
+ "to": window_to,
+ },
+ "repo": {
+ "full_name": repo_full_name,
+ "default_branch": repo_default_branch,
+ },
+ "workflow": {
+ "name": workflow_name,
+ "run_id": workflow_run_id,
+ "run_attempt": workflow_run_attempt,
+ "event_name": workflow_event_name,
+ "actor": workflow_actor,
+ },
+ "source_repo": {
+ "full_name": source_repo_full_name,
+ "source_branch": source_state["source_branch"],
+ "source_sha": source_state["source_sha"],
+ },
+ "toolkit": {
+ "installed_source_sha": source_state["installed_source_sha"],
+ },
+ "doctor_runtime": {
+ "installed_source_sha": source_state["installed_runtime_sha"],
+ },
+ "contract": contract_payload,
+ **activity_sections,
+ }
+
+ findings_payload = {
+ "schema_version": "doctor-findings/v4",
+ "collected_at": collected_at,
+ "report_date": report_date,
+ "repo": {
+ "full_name": repo_full_name,
+ "default_branch": repo_default_branch,
+ },
+ "source_repo": {
+ "full_name": source_repo_full_name,
+ "source_branch": source_state["source_branch"],
+ "source_sha": source_state["source_sha"],
+ },
+ "toolkit": {
+ "installed_source_sha": source_state["installed_source_sha"],
+ },
+ "doctor_runtime": {
+ "installed_source_sha": source_state["installed_runtime_sha"],
+ },
+ "decision": {
+ "should_act": should_act,
+ "act_reason": act_reason,
+ "managed_drift_present": scan_result["managed_drift_present"],
+ "runtime_drift_present": scan_result["runtime_drift_present"],
+ "observed_findings_present": scan_result["observed_findings_present"],
+ },
+ "contract": contract_payload,
+ "checks": scan_result["checks"],
+ "findings": findings,
+ }
+
+ return {
+ "fatal_error": source_state["fatal_error"],
+ "job_outputs": job_outputs,
+ "stats_payload": stats_payload,
+ "findings_payload": findings_payload,
+ }
+
+
+def _write_json_file(path: Path, payload: dict[str, Any]) -> None:
+ path.write_text(f"{json.dumps(payload, indent=2)}\n", encoding="utf-8")
+
+
+def _write_github_output(outputs: dict[str, str]) -> None:
+ github_output = os.getenv("GITHUB_OUTPUT")
+ if not github_output:
+ return
+
+ with Path(github_output).open("a", encoding="utf-8") as handle:
+ for key, value in outputs.items():
+ handle.write(f"{key}={value}\n")
+
+
+def main() -> int:
+ repo_root = Path(os.getenv("DOCTOR_REPO_ROOT", ".")).resolve()
+ output_dir = Path(os.getenv("DOCTOR_OUTPUT_DIR", ".")).resolve()
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ collected_at = os.getenv("DOCTOR_COLLECTED_AT", _rfc3339_now())
+ report_date = os.getenv("DOCTOR_REPORT_DATE", collected_at[:10])
+ default_window_from, default_window_to = _default_window(report_date)
+
+ source_root = os.getenv("DOCTOR_SOURCE_ROOT")
+ if source_root:
+ expected_manifest = build_expected_manifest_from_source(
+ source_root=Path(source_root).resolve(),
+ source_sha=os.getenv("DOCTOR_SOURCE_SHA") or None,
+ source_branch=os.getenv("DOCTOR_SOURCE_BRANCH", SOURCE_BRANCH),
+ )
+ else:
+ expected_manifest = _load_json_source(
+ env_var="DOCTOR_EXPECTED_MANIFEST",
+ path_env_var="DOCTOR_EXPECTED_MANIFEST_PATH",
+ default={},
+ )
+
+ activity_payload = _load_json_source(
+ env_var="DOCTOR_ACTIVITY",
+ path_env_var="DOCTOR_ACTIVITY_PATH",
+ default={},
+ )
+
+ result = assemble_collect_result(
+ repo_root=repo_root,
+ expected_manifest=expected_manifest,
+ issues_enabled=_parse_bool(os.getenv("DOCTOR_ISSUES_ENABLED"), default=True),
+ repo_full_name=os.getenv("DOCTOR_REPO_FULL_NAME", os.getenv("GITHUB_REPOSITORY", "")),
+ repo_default_branch=os.getenv("DOCTOR_REPO_DEFAULT_BRANCH", "main"),
+ workflow_name=os.getenv("GITHUB_WORKFLOW", "doctor"),
+ workflow_run_id=int(os.getenv("GITHUB_RUN_ID", "0")),
+ workflow_run_attempt=int(os.getenv("GITHUB_RUN_ATTEMPT", "0")),
+ workflow_event_name=os.getenv("GITHUB_EVENT_NAME", "workflow_dispatch"),
+ workflow_actor=os.getenv("GITHUB_ACTOR", ""),
+ source_repo_full_name=os.getenv("DOCTOR_SOURCE_REPO", "G-Core/agent-toolkit"),
+ analyze_day=os.getenv("DOCTOR_ANALYZE_DAY"),
+ report_tz=os.getenv("DOCTOR_REPORT_TZ"),
+ collected_at=collected_at,
+ report_date=report_date,
+ window_from=os.getenv("DOCTOR_WINDOW_FROM", default_window_from),
+ window_to=os.getenv("DOCTOR_WINDOW_TO", default_window_to),
+ daily_commits=activity_payload.get("commits", []),
+ daily_prs=activity_payload.get("prs", []),
+ daily_issues=activity_payload.get("issues", []),
+ snapshot_prs=activity_payload.get("snapshot_prs", []),
+ snapshot_issues=activity_payload.get("snapshot_issues", []),
+ )
+
+ findings_path = output_dir / "doctor-findings.json"
+ _write_json_file(findings_path, result["findings_payload"])
+
+ if result["stats_payload"] is not None:
+ _write_json_file(output_dir / "doctor-stats.json", result["stats_payload"])
+
+ _write_github_output(result["job_outputs"])
+ return 1 if result["fatal_error"] else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.github/workflows/copilot-sync.yml b/.github/workflows/copilot-sync.yml
new file mode 100644
index 0000000..c4ca5e4
--- /dev/null
+++ b/.github/workflows/copilot-sync.yml
@@ -0,0 +1,20 @@
+name: Copilot Instructions Sync
+
+on:
+ pull_request:
+ paths:
+ - fastedge-plugin-source/manifest.json
+ - fastedge-plugin-source/check-copilot-sync.sh
+ - .github/copilot-instructions.md
+ - .github/workflows/copilot-sync.yml
+
+jobs:
+ check-sync:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Check manifest ↔ copilot-instructions sync
+ run: bash fastedge-plugin-source/check-copilot-sync.sh
diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml
index 013f0b0..b733ef3 100644
--- a/.github/workflows/docs.yaml
+++ b/.github/workflows/docs.yaml
@@ -4,7 +4,7 @@ on:
push:
branches: ['main']
paths:
- - 'docs/**'
+ - 'github-pages/**'
workflow_dispatch: # trigger manually
@@ -18,7 +18,7 @@ concurrency:
cancel-in-progress: false
env:
- BUILD_PATH: './docs'
+ BUILD_PATH: './github-pages'
jobs:
build:
diff --git a/.github/workflows/doctor-analyze.lock.yml b/.github/workflows/doctor-analyze.lock.yml
new file mode 100644
index 0000000..2ce1161
--- /dev/null
+++ b/.github/workflows/doctor-analyze.lock.yml
@@ -0,0 +1,1807 @@
+#
+# ___ _ _
+# / _ \ | | (_)
+# | |_| | __ _ ___ _ __ | |_ _ ___
+# | _ |/ _` |/ _ \ '_ \| __| |/ __|
+# | | | | (_| | __/ | | | |_| | (__
+# \_| |_/\__, |\___|_| |_|\__|_|\___|
+# __/ |
+# _ _ |___/
+# | | | | / _| |
+# | | | | ___ _ __ _ __| |_| | _____ ____
+# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
+# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
+# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
+#
+# This file was automatically generated by gh-aw (v0.44.0). DO NOT EDIT.
+#
+# To update this file, run:
+# python3 workflows/build_doctor_analyze_lock.py
+# This tracked lock is generated by gh aw compile and then post-processed.
+# Do not edit this file manually.
+#
+# For more information: https://github.github.com/gh-aw/introduction/overview/
+#
+#
+# frontmatter-hash: 26ed66316fc76b55ddfd8e9b55e3ebebba4ee98bbbea3e8d33c3f45a23ed4a52
+
+name: "doctor-analyze"
+"on":
+ workflow_call:
+ inputs:
+ analyze_day:
+ required: true
+ type: string
+ findings_artifact_name:
+ required: true
+ type: string
+ force_analyze:
+ required: false
+ type: boolean
+ installed_runtime_sha:
+ required: false
+ type: string
+ installed_source_sha:
+ required: false
+ type: string
+ issues_enabled:
+ required: true
+ type: string
+ observed_findings_present:
+ required: true
+ type: string
+ report_tz:
+ required: true
+ type: string
+ source_sha:
+ required: false
+ type: string
+ stats_artifact_name:
+ required: true
+ type: string
+
+permissions: {}
+
+concurrency:
+ group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}"
+
+run-name: "doctor-analyze"
+
+jobs:
+ activation:
+ needs: pre_activation
+ if: needs.pre_activation.outputs.activated == 'true'
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ outputs:
+ comment_id: ""
+ comment_repo: ""
+ steps:
+ - name: Setup Scripts
+ uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0
+ with:
+ destination: /opt/gh-aw/actions
+ agent:
+ needs: activation
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ issues: read
+ pull-requests: read
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_AW_ASSETS_ALLOWED_EXTS: ""
+ GH_AW_ASSETS_BRANCH: ""
+ GH_AW_ASSETS_MAX_SIZE_KB: 0
+ GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl
+ GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json
+ GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json
+ GH_AW_WORKFLOW_ID_SANITIZED: doctoranalyze
+ outputs:
+ checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
+ has_patch: ${{ steps.collect_output.outputs.has_patch }}
+ model: ${{ steps.generate_aw_info.outputs.model }}
+ output: ${{ steps.collect_output.outputs.output }}
+ output_types: ${{ steps.collect_output.outputs.output_types }}
+ secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
+ steps:
+ - name: Setup Scripts
+ uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Create gh-aw temp directory
+ run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh
+ - name: Download doctor-stats artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
+ with:
+ name: ${{ inputs.stats_artifact_name }}
+ path: /tmp/gh-aw/collect-artifacts/stats
+ - name: Download doctor-findings artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
+ with:
+ name: ${{ inputs.findings_artifact_name }}
+ path: /tmp/gh-aw/collect-artifacts/findings
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Checkout PR branch
+ id: checkout-pr
+ if: |
+ github.event.pull_request
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs');
+ await main();
+ - name: Generate agentic run info
+ id: generate_aw_info
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const fs = require('fs');
+
+ const awInfo = {
+ engine_id: "claude",
+ engine_name: "Claude Code",
+ model: process.env.GH_AW_MODEL_AGENT_CLAUDE || "",
+ version: "",
+ agent_version: "2.1.42",
+ cli_version: "v0.44.0",
+ workflow_name: "doctor-analyze",
+ experimental: false,
+ supports_tools_allowlist: true,
+ supports_http_transport: true,
+ run_id: context.runId,
+ run_number: context.runNumber,
+ run_attempt: process.env.GITHUB_RUN_ATTEMPT,
+ repository: context.repo.owner + '/' + context.repo.repo,
+ ref: context.ref,
+ sha: context.sha,
+ actor: context.actor,
+ event_name: context.eventName,
+ staged: false,
+ allowed_domains: ["defaults"],
+ firewall_enabled: true,
+ awf_version: "v0.18.0",
+ awmg_version: "v0.1.4",
+ steps: {
+ firewall: "squid"
+ },
+ created_at: new Date().toISOString()
+ };
+
+ // Write to /tmp/gh-aw directory to avoid inclusion in PR
+ const tmpPath = '/tmp/gh-aw/aw_info.json';
+ fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2));
+ console.log('Generated aw_info.json at:', tmpPath);
+ console.log(JSON.stringify(awInfo, null, 2));
+
+ // Set model as output for reuse in other steps/jobs
+ core.setOutput('model', awInfo.model);
+ - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret
+ id: validate-secret
+ run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code
+ env:
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ - name: Setup Node.js
+ uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - name: Install awf binary
+ run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.18.0
+ - name: Install Claude Code CLI
+ run: npm install -g --silent @anthropic-ai/claude-code@2.1.42
+ - name: Determine automatic lockdown mode for GitHub MCP server
+ id: determine-automatic-lockdown
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
+ GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
+ with:
+ script: |
+ const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs');
+ await determineAutomaticLockdown(github, context, core);
+ - name: Download container images
+ run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.18.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.18.0 ghcr.io/github/gh-aw-firewall/squid:0.18.0 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine
+ - name: Write Safe Outputs Config
+ run: |
+ mkdir -p /opt/gh-aw/safeoutputs
+ mkdir -p /tmp/gh-aw/safeoutputs
+ mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
+ cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF'
+ {"add_comment":{"max":1},"close_issue":{"max":1},"create_issue":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1},"update_issue":{"max":1}}
+ GH_AW_SAFE_OUTPUTS_CONFIG_EOF
+ cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF'
+ [
+ {
+ "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "body": {
+ "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.",
+ "type": "string"
+ },
+ "labels": {
+ "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "parent": {
+ "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.",
+ "type": [
+ "number",
+ "string"
+ ]
+ },
+ "temporary_id": {
+ "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.",
+ "pattern": "^aw_[A-Za-z0-9]{4,8}$",
+ "type": "string"
+ },
+ "title": {
+ "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "title",
+ "body"
+ ],
+ "type": "object"
+ },
+ "name": "create_issue"
+ },
+ {
+ "description": "Close a GitHub issue with a closing comment. You can and should always add a comment when closing an issue to explain the action or provide context. This tool is ONLY for closing issues - use update_issue if you need to change the title, body, labels, or other metadata without closing. Use close_issue when work is complete, the issue is no longer relevant, or it's a duplicate. The closing comment should explain the resolution or reason for closing. If the issue is already closed, a comment will still be posted. CONSTRAINTS: Maximum 1 issue(s) can be closed.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "body": {
+ "description": "Closing comment explaining why the issue is being closed and summarizing any resolution, workaround, or conclusion.",
+ "type": "string"
+ },
+ "issue_number": {
+ "description": "Issue number to close. This is the numeric ID from the GitHub URL (e.g., 901 in github.com/owner/repo/issues/901). If omitted, closes the issue that triggered this workflow (requires an issue event trigger).",
+ "type": [
+ "number",
+ "string"
+ ]
+ }
+ },
+ "required": [
+ "body"
+ ],
+ "type": "object"
+ },
+ "name": "close_issue"
+ },
+ {
+ "description": "Add a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. CONSTRAINTS: Maximum 1 comment(s) can be added.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "body": {
+ "description": "The comment text in Markdown format. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation.",
+ "type": "string"
+ },
+ "item_number": {
+ "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). If omitted, the tool will attempt to resolve the target from the current workflow context (triggering issue, PR, or discussion).",
+ "type": "number"
+ }
+ },
+ "required": [
+ "body"
+ ],
+ "type": "object"
+ },
+ "name": "add_comment"
+ },
+ {
+ "description": "Update an existing GitHub issue's title, body, labels, assignees, or milestone WITHOUT closing it. This tool is primarily for editing issue metadata and content. While it supports changing status between 'open' and 'closed', use close_issue instead when you want to close an issue with a closing comment. Body updates support replacing, appending to, prepending content, or updating a per-run \"island\" section. CONSTRAINTS: Maximum 1 issue(s) can be updated.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "assignees": {
+ "description": "Replace the issue assignees with this list of GitHub usernames (e.g., ['octocat', 'mona']).",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "body": {
+ "description": "Issue body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this content is added with a separator and an attribution footer. For 'replace-island', only the run-specific section is updated.",
+ "type": "string"
+ },
+ "issue_number": {
+ "description": "Issue number to update. This is the numeric ID from the GitHub URL (e.g., 789 in github.com/owner/repo/issues/789). Required when the workflow target is '*' (any issue).",
+ "type": [
+ "number",
+ "string"
+ ]
+ },
+ "labels": {
+ "description": "Replace the issue labels with this list (e.g., ['bug', 'tracking:foo']). Labels must exist in the repository.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "milestone": {
+ "description": "Milestone number to assign (e.g., 1). Use null to clear.",
+ "type": [
+ "number",
+ "string"
+ ]
+ },
+ "operation": {
+ "description": "How to update the issue body: 'append' (default - add to end with separator), 'prepend' (add to start with separator), 'replace' (overwrite entire body), or 'replace-island' (update a run-specific section).",
+ "enum": [
+ "replace",
+ "append",
+ "prepend",
+ "replace-island"
+ ],
+ "type": "string"
+ },
+ "status": {
+ "description": "New issue status: 'open' to reopen a closed issue, 'closed' to close an open issue.",
+ "enum": [
+ "open",
+ "closed"
+ ],
+ "type": "string"
+ },
+ "title": {
+ "description": "New issue title to replace the existing title.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "name": "update_issue"
+ },
+ {
+ "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "alternatives": {
+ "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).",
+ "type": "string"
+ },
+ "reason": {
+ "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).",
+ "type": "string"
+ },
+ "tool": {
+ "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "reason"
+ ],
+ "type": "object"
+ },
+ "name": "missing_tool"
+ },
+ {
+ "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "message": {
+ "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').",
+ "type": "string"
+ }
+ },
+ "required": [
+ "message"
+ ],
+ "type": "object"
+ },
+ "name": "noop"
+ },
+ {
+ "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "alternatives": {
+ "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).",
+ "type": "string"
+ },
+ "context": {
+ "description": "Additional context about the missing data or where it should come from (max 256 characters).",
+ "type": "string"
+ },
+ "data_type": {
+ "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.",
+ "type": "string"
+ },
+ "reason": {
+ "description": "Explanation of why this data is needed to complete the task (max 256 characters).",
+ "type": "string"
+ }
+ },
+ "required": [],
+ "type": "object"
+ },
+ "name": "missing_data"
+ }
+ ]
+ GH_AW_SAFE_OUTPUTS_TOOLS_EOF
+ cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF'
+ {
+ "add_comment": {
+ "defaultMax": 1,
+ "fields": {
+ "body": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "item_number": {
+ "issueOrPRNumber": true
+ }
+ }
+ },
+ "close_issue": {
+ "defaultMax": 1,
+ "fields": {
+ "body": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "issue_number": {
+ "optionalPositiveInteger": true
+ }
+ }
+ },
+ "create_issue": {
+ "defaultMax": 1,
+ "fields": {
+ "body": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "labels": {
+ "type": "array",
+ "itemType": "string",
+ "itemSanitize": true,
+ "itemMaxLength": 128
+ },
+ "parent": {
+ "issueOrPRNumber": true
+ },
+ "repo": {
+ "type": "string",
+ "maxLength": 256
+ },
+ "temporary_id": {
+ "type": "string"
+ },
+ "title": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ }
+ }
+ },
+ "missing_tool": {
+ "defaultMax": 20,
+ "fields": {
+ "alternatives": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 512
+ },
+ "reason": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "tool": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ }
+ }
+ },
+ "noop": {
+ "defaultMax": 1,
+ "fields": {
+ "message": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ }
+ }
+ },
+ "update_issue": {
+ "defaultMax": 1,
+ "fields": {
+ "body": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "issue_number": {
+ "issueOrPRNumber": true
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "open",
+ "closed"
+ ]
+ },
+ "title": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ }
+ },
+ "customValidation": "requiresOneOf:status,title,body"
+ }
+ }
+ GH_AW_SAFE_OUTPUTS_VALIDATION_EOF
+ - name: Generate Safe Outputs MCP Server Config
+ id: safe-outputs-config
+ run: |
+ # Generate a secure random API key (360 bits of entropy, 40+ chars)
+ # Mask immediately to prevent timing vulnerabilities
+ API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${API_KEY}"
+
+ PORT=3001
+
+ # Set outputs for next steps
+ {
+ echo "safe_outputs_api_key=${API_KEY}"
+ echo "safe_outputs_port=${PORT}"
+ } >> "$GITHUB_OUTPUT"
+
+ echo "Safe Outputs MCP server will run on port ${PORT}"
+
+ - name: Start Safe Outputs MCP HTTP Server
+ id: safe-outputs-start
+ env:
+ DEBUG: '*'
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }}
+ GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json
+ GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json
+ GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ run: |
+ # Environment variables are set above to prevent template injection
+ export DEBUG
+ export GH_AW_SAFE_OUTPUTS_PORT
+ export GH_AW_SAFE_OUTPUTS_API_KEY
+ export GH_AW_SAFE_OUTPUTS_TOOLS_PATH
+ export GH_AW_SAFE_OUTPUTS_CONFIG_PATH
+ export GH_AW_MCP_LOG_DIR
+
+ bash /opt/gh-aw/actions/start_safe_outputs_server.sh
+
+ - name: Start MCP gateway
+ id: start-mcp-gateway
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
+ GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }}
+ GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ set -eo pipefail
+ mkdir -p /tmp/gh-aw/mcp-config
+
+ # Export gateway environment variables for MCP config and gateway script
+ export MCP_GATEWAY_PORT="80"
+ export MCP_GATEWAY_DOMAIN="host.docker.internal"
+ MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${MCP_GATEWAY_API_KEY}"
+ export MCP_GATEWAY_API_KEY
+ export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
+ mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
+ export DEBUG="*"
+
+ export GH_AW_ENGINE="claude"
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4'
+
+ cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh
+ {
+ "mcpServers": {
+ "github": {
+ "container": "ghcr.io/github/github-mcp-server:v0.30.3",
+ "env": {
+ "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN",
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN",
+ "GITHUB_READ_ONLY": "1",
+ "GITHUB_TOOLSETS": "context,repos,issues,pull_requests"
+ }
+ },
+ "safeoutputs": {
+ "type": "http",
+ "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
+ "headers": {
+ "Authorization": "$GH_AW_SAFE_OUTPUTS_API_KEY"
+ }
+ }
+ },
+ "gateway": {
+ "port": $MCP_GATEWAY_PORT,
+ "domain": "${MCP_GATEWAY_DOMAIN}",
+ "apiKey": "${MCP_GATEWAY_API_KEY}",
+ "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
+ }
+ }
+ GH_AW_MCP_CONFIG_EOF
+ - name: Generate workflow overview
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs');
+ await generateWorkflowOverview(core);
+ - name: Create prompt with built-in context
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ run: |
+ bash /opt/gh-aw/actions/create_prompt_first.sh
+ cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT"
+
+ GH_AW_PROMPT_EOF
+ cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT"
+ cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT"
+ cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT"
+ cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT"
+
+ GitHub API Access Instructions
+
+ The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations.
+
+
+ To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls.
+
+ Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body).
+
+ **IMPORTANT - temporary_id format rules:**
+ - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed)
+ - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i
+ - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive)
+ - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
+ - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore)
+ - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678
+ - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate
+
+ Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i.
+
+ Discover available tools from the safeoutputs MCP server.
+
+ **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped.
+
+ **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed.
+
+
+
+ The following GitHub context information is available for this workflow:
+ {{#if __GH_AW_GITHUB_ACTOR__ }}
+ - **actor**: __GH_AW_GITHUB_ACTOR__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_REPOSITORY__ }}
+ - **repository**: __GH_AW_GITHUB_REPOSITORY__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_WORKSPACE__ }}
+ - **workspace**: __GH_AW_GITHUB_WORKSPACE__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }}
+ - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }}
+ - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }}
+ - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }}
+ - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_RUN_ID__ }}
+ - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
+ {{/if}}
+
+
+ The following workflow call inputs are available for this workflow:
+ - **issues_enabled**: __GH_AW_INPUTS_ISSUES_ENABLED__
+ - **observed_findings_present**: __GH_AW_INPUTS_OBSERVED_FINDINGS_PRESENT__
+ - **stats_artifact_name**: __GH_AW_INPUTS_STATS_ARTIFACT_NAME__
+ - **findings_artifact_name**: __GH_AW_INPUTS_FINDINGS_ARTIFACT_NAME__
+ - **source_sha**: __GH_AW_INPUTS_SOURCE_SHA__
+ - **installed_source_sha**: __GH_AW_INPUTS_INSTALLED_SOURCE_SHA__
+ - **installed_runtime_sha**: __GH_AW_INPUTS_INSTALLED_RUNTIME_SHA__
+ - **analyze_day**: __GH_AW_INPUTS_ANALYZE_DAY__
+ - **report_tz**: __GH_AW_INPUTS_REPORT_TZ__
+ - **force_analyze**: __GH_AW_INPUTS_FORCE_ANALYZE__
+ - **run_id**: __GH_AW_GITHUB_RUN_ID__
+ - **actor**: __GH_AW_GITHUB_ACTOR__
+ - **workflow-run-url**: __GH_AW_GITHUB_SERVER_URL__/__GH_AW_GITHUB_REPOSITORY__/actions/runs/__GH_AW_GITHUB_RUN_ID__
+
+
+ GH_AW_PROMPT_EOF
+ cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT"
+
+ GH_AW_PROMPT_EOF
+ cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT"
+ # Doctor Analyze
+
+ ## TL;DR
+
+ Read-only analyze job for the `doctor` workflow. Downloads `collect` artifacts,
+ validates findings, and manages a deterministic GitHub issue for observed drift.
+ Writes `doctor-report.json` as the only persistent output.
+
+ You are the `analyze` job for the repository described in the `` block above.
+ This workflow is called only from `doctor.yml` and must stay read-only for repository files.
+ Never create PRs. Never edit tracked repository files. The only persistent outputs you may write are:
+
+ - `doctor-report.json` in the workspace root
+ - mirrored copies of the same report under `/tmp/gh-aw/agent/` so the
+ current compiled workflow uploads them via generic agent artifacts
+
+ Temporary scratch files are allowed only if they are needed to build or validate the report.
+
+ ## Runtime Context
+
+ Treat the `` block from the system prompt as the runtime contract from `collect`.
+ It must provide these keys:
+
+ - `issues_enabled`
+ - `observed_findings_present`
+ - `stats_artifact_name`
+ - `findings_artifact_name`
+ - `source_sha`
+ - `installed_source_sha`
+ - `installed_runtime_sha`
+ - `analyze_day`
+ - `report_tz`
+ - `force_analyze`
+ - `run_id`
+ - `actor`
+ - `workflow_run_url`
+
+ Read `run_attempt` from env var `GITHUB_RUN_ATTEMPT` via Bash.
+ Read `event_name` from env var `GITHUB_EVENT_NAME` via Bash.
+ If the `` block is missing any required key, record
+ that in `runtime_notes[]`, write `doctor-report.json` with
+ `issue.reason = "model_error"`, and exit with failure.
+
+ Use `report_tz` to derive:
+
+ - `report_date` in `YYYY-MM-DD`
+ - `today_name` as a lowercase English weekday name
+ - `analyze_day_match = (today_name == analyze_day.lower())`
+ - `manual_override = (force_analyze == true)`
+
+ ## Operating Rules
+
+ - Use GitHub MCP tools only for read operations: artifacts, issues, labels, workflow metadata, repository contents.
+ - Use safe outputs only for issue mutations: `create-issue`, `update-issue`, `close-issue`, `add-comment`.
+ - Never perform direct write mutations through bash, git, or raw GitHub API calls.
+ - Prefer GitHub MCP tools for workflow runs, artifacts, issues, and labels.
+ - If workflow artifact MCP tools are unavailable in the exposed tool list,
+ you may use `Bash` with authenticated GitHub API calls for artifact lookup
+ and download only.
+ - Never use unauthenticated `gh` or unauthenticated `curl` for GitHub API access.
+ - For authenticated fallback in Bash, export
+ `GH_TOKEN="$GITHUB_TOKEN"` before `gh api`, or use `curl` with header
+ `Authorization: Bearer $GITHUB_TOKEN`.
+ - `collect` is the source of truth for findings and severities. Do not invent new finding IDs or new finding categories.
+ - Limit issue-generating analysis to findings where:
+ - `category == observed`
+ - `auto_action == analyze`
+ - `path` is one of:
+ - `AGENTS.md`
+ - `CLAUDE.md`
+ - `DOCS.md`
+ - `.specify/memory/constitution.md`
+ - `docs/quickstart.md`
+ - You may read repository files to improve recommendations and evidence,
+ but not to expand the scope beyond the allowed observed-only paths.
+
+ ## Required Procedure
+
+ 1. Start by creating a scratch work directory for downloaded artifacts and intermediate notes.
+ 2. Compute `report_date`, `today_name`, `analyze_day_match`, and `manual_override`.
+ 3. Initialize `runtime_notes[]` and a draft `doctor-report.json` object.
+
+ ### Runtime Gate
+
+ Apply gates in this order:
+
+ 1. If `observed_findings_present != "true"`:
+ - write `doctor-report.json`
+ - set:
+ - `decision.observed_findings_present = false`
+ - `decision.qualifying_findings_present = false`
+ - `issue.action = "none"`
+ - `issue.reason = "no_observed_findings"`
+ - do not download artifacts
+ - do not call safe outputs
+ - exit successfully
+ 2. If `manual_override` is false and `analyze_day_match` is false:
+ - write `doctor-report.json`
+ - set:
+ - `decision.observed_findings_present = true`
+ - `decision.qualifying_findings_present = false`
+ - `issue.action = "skipped"`
+ - `issue.reason = "not_analyze_day"`
+ - do not call safe outputs
+ - exit successfully
+ 3. Otherwise continue to artifact download and analysis.
+
+ ## Artifact Download And Validation
+
+ Download artifacts from the current workflow run identified in
+ `` using the exact artifact names from `collect`:
+
+ - the `stats_artifact_name` value from ``
+ - the `findings_artifact_name` value from ``
+ - the predownloaded local files, when present:
+ - `/tmp/gh-aw/collect-artifacts/stats/doctor-stats.json`
+ - `/tmp/gh-aw/collect-artifacts/findings/doctor-findings.json`
+
+ Required procedure for artifact access:
+
+ 1. First check the predownloaded local files under
+ `/tmp/gh-aw/collect-artifacts/`. If both required files are present, use
+ them as the primary source and do not attempt network artifact lookup.
+ 2. If either local file is missing, prefer GitHub MCP workflow run and artifact tools when they are available.
+ 3. If the matching collect artifacts are not listed yet, wait 2 seconds and retry once using the same access path.
+ 4. If workflow artifact MCP tools are unavailable, use authenticated Bash fallback:
+ - `GH_TOKEN="$GITHUB_TOKEN" gh api ...`
+ - or `curl -H "Authorization: Bearer $GITHUB_TOKEN" ...`
+ 5. Only use workspace or runner filesystem search as a secondary diagnostic
+ note after both the predownloaded local files and the authenticated
+ access paths fail.
+ 6. Do not use unauthenticated `gh api`, unauthenticated `curl`, or raw REST calls without `GITHUB_TOKEN`.
+
+ Expect these files:
+
+ - `doctor-stats.json`
+ - `doctor-findings.json`
+
+ Validate both artifacts before analysis. Treat any missing file, invalid
+ JSON, missing required field, wrong schema version, or type mismatch as
+ `artifact_invalid`.
+
+ Minimum required validation for `doctor-stats.json`:
+
+ - `schema_version == "doctor-stats/v3"`
+ - `collected_at`
+ - `report_date`
+ - `window.from`
+ - `window.to`
+ - `repo.full_name`
+ - `repo.default_branch`
+ - `workflow.run_id`
+ - `workflow.run_attempt`
+ - `workflow.event_name`
+ - `workflow.actor`
+ - `source_repo.full_name`
+ - `source_repo.source_branch`
+ - `source_repo.source_sha`
+ - `activity.commits`
+ - `activity.prs`
+ - `activity.issues`
+ - `snapshot.prs`
+ - `snapshot.issues`
+
+ Minimum required validation for `doctor-findings.json`:
+
+ - `schema_version == "doctor-findings/v4"`
+ - `collected_at`
+ - `report_date`
+ - `repo.full_name`
+ - `repo.default_branch`
+ - `source_repo.source_branch`
+ - `source_repo.source_sha`
+ - `decision.should_act`
+ - `decision.act_reason`
+ - `decision.managed_drift_present`
+ - `decision.runtime_drift_present`
+ - `decision.observed_findings_present`
+ - `findings[]`
+
+ For every analyzed finding, require:
+
+ - `id`
+ - `category`
+ - `severity`
+ - `path`
+ - `state`
+ - `auto_action`
+ - `message`
+
+ If artifact validation fails:
+
+ - write `doctor-report.json`
+ - set:
+ - `issue.action = "failed"`
+ - `issue.reason = "artifact_invalid"`
+ - add a precise explanation to `runtime_notes[]`
+ - do not call safe outputs
+ - exit with failure
+
+ ## Analysis Scope
+
+ Analyze only findings from `doctor-findings.json` where:
+
+ - `category == "observed"`
+ - `auto_action == "analyze"`
+
+ Exclude from issue generation:
+
+ - `managed`
+ - `runtime`
+ - `operational`
+ - observed checks outside the allowed path scope
+
+ Keep `info` findings in `analysis[]`, but they do not qualify for an issue.
+ Qualifying findings are only severities:
+
+ - `warning`
+ - `error`
+
+ ## Analysis Rules
+
+ For each in-scope observed finding:
+
+ 1. Preserve the original `finding_id`, `path`, `severity`, `state`, and `message`.
+ 2. Read the current repository path when it exists and use that only as supporting evidence.
+ 3. Produce one `analysis[]` item with:
+ - `finding_id`
+ - `category = "observed"`
+ - `path`
+ - `severity`
+ - `state`
+ - `qualifies_for_issue`
+ - `summary`
+ - `recommendation`
+ - `evidence[]`
+
+ Use these guidance rules:
+
+ - `AGENTS.md` missing:
+ - explain that the repo is missing the agent entry point
+ - recommend restoring the file or a valid symlink in the root
+ - `CLAUDE.md` missing:
+ - explain that the repo is missing the Claude-facing entry point
+ - recommend restoring the file or a valid symlink in the root
+ - `DOCS.md` missing:
+ - explain that the repo is missing the main docs entry point in the root
+ - recommend restoring the file or a valid symlink in the root
+ - `docs/quickstart.md` missing or placeholder:
+ - recommend a real first-run or onboarding guide
+ - `.specify/memory/constitution.md` missing or placeholder:
+ - recommend restoring and tracking a real working agreement
+
+ When files exist but appear placeholder-like or incomplete, use concrete evidence such as:
+
+ - obvious placeholder markers: `TODO`, `TBD`, `placeholder`, `coming soon`, `lorem ipsum`, `template`
+ - extremely short content with no actionable project-specific instructions
+
+ Do not create duplicate recommendations for the same path family. Group repeated ideas.
+
+ ## Issue Policy
+
+ Deterministic issue title:
+
+ - `Toolkit doctor: observed drift`
+
+ Preferred labels when available:
+
+ - `toolkit`
+ - `doctor`
+
+ Issue body must contain these sections:
+
+ - `## Summary`
+ - `## Findings`
+ - `## Recommendations`
+ - `## Context`
+
+ Issue body requirements:
+
+ - concise summary of current qualifying warnings and errors
+ - bullet list or table of qualifying findings with:
+ - path
+ - severity
+ - message
+ - short grouped recommendations
+ - link to the `workflow_run_url` value from ``
+ - short activity context from `doctor-stats.json` only if it clearly helps prioritization
+
+ Use read-only GitHub tools to find an existing open issue with the exact title `Toolkit doctor: observed drift`.
+ Do not create duplicates.
+
+ Before issue mutation:
+
+ 1. Check whether labels `toolkit` and `doctor` exist.
+ 2. If one or both labels are missing:
+ - continue without the missing labels
+ - record the problem in `runtime_notes[]`
+ 3. If an existing deterministic issue is locked:
+ - write `doctor-report.json`
+ - set:
+ - `issue.action = "failed"`
+ - `issue.reason = "issue_locked"`
+ - do not create a duplicate
+ - exit with failure
+
+ ## Issue Lifecycle
+
+ After analysis, compute:
+
+ - `findings_analyzed`
+ - `qualifying_findings`
+ - `warning_count`
+ - `error_count`
+ - `qualifying_findings_present`
+
+ Then apply this lifecycle:
+
+ 1. If `qualifying_findings_present == false`:
+ - if an open deterministic issue exists:
+ - add a resolution comment that observed drift is no longer present in the latest analyze run
+ - close that issue
+ - set:
+ - `issue.action = "closed"`
+ - `issue.reason = "closed_resolved"`
+ - otherwise:
+ - set:
+ - `issue.action = "none"`
+ - `issue.reason = "no_qualifying_findings"`
+ 2. If `qualifying_findings_present == true` and `issues_enabled != "true"`:
+ - perform the full analysis
+ - do not create, update, close, or comment on issues
+ - set:
+ - `issue.action = "skipped"`
+ - `issue.reason = "issues_disabled"`
+ - if an existing deterministic issue exists, keep its metadata in the report but do not mutate it
+ 3. If `qualifying_findings_present == true` and no open deterministic issue exists:
+ - create one issue through `create-issue`
+ - set:
+ - `issue.action = "created"`
+ - `issue.reason = "created_new"`
+ 4. If `qualifying_findings_present == true` and an open deterministic issue exists:
+ - update that exact issue through `update-issue`
+ - set:
+ - `issue.action = "updated"`
+ - `issue.reason = "updated_existing"`
+
+ Never use direct GitHub write APIs for issues. Safe outputs only.
+
+ ## doctor-report.json
+
+ Always write `doctor-report.json` before exiting, including skip and failure paths.
+
+ Write the same final JSON payload to all of these paths:
+
+ - `./doctor-report.json`
+ - `/tmp/gh-aw/agent/doctor-report.json`
+ - `/tmp/gh-aw/agent/doctor-report-${report_date}.json`
+
+ This is a temporary workaround until the compiled reusable workflow gets a
+ dedicated `upload-artifact` step for a `doctor-report-YYYY-MM-DD` artifact.
+
+ Required top-level schema:
+
+ ```json
+ {
+ "schema_version": "doctor-report/v1",
+ "analyzed_at": "RFC3339",
+ "report_date": "YYYY-MM-DD",
+ "repo": {
+ "full_name": "owner/repo"
+ },
+ "workflow": {
+ "run_id": 0,
+ "run_attempt": 0,
+ "event_name": "workflow_dispatch|schedule|...",
+ "actor": "github-user"
+ },
+ "inputs": {
+ "stats_artifact_name": "doctor-stats-YYYY-MM-DD",
+ "findings_artifact_name": "doctor-findings-YYYY-MM-DD"
+ },
+ "decision": {
+ "observed_findings_present": true,
+ "issues_enabled": true,
+ "analyze_day_match": true,
+ "manual_override": false,
+ "qualifying_findings_present": true
+ },
+ "issue": {
+ "action": "created|updated|closed|skipped|none|failed",
+ "reason": "created_new|updated_existing|closed_resolved|issues_disabled|not_analyze_day|no_observed_findings|no_qualifying_findings|artifact_invalid|model_error|issue_locked",
+ "number": 0,
+ "url": "https://github.com/owner/repo/issues/123",
+ "title": "Toolkit doctor: observed drift"
+ },
+ "summary": {
+ "findings_analyzed": 0,
+ "qualifying_findings": 0,
+ "warning_count": 0,
+ "error_count": 0
+ },
+ "runtime_notes": [],
+ "analysis": [
+ {
+ "finding_id": "observed.docs_quickstart_md.missing",
+ "category": "observed",
+ "path": "docs/quickstart.md",
+ "severity": "warning",
+ "state": "missing",
+ "qualifies_for_issue": true,
+ "summary": "Repository is missing the onboarding quickstart.",
+ "recommendation": "Add a real quickstart with first-run instructions for contributors.",
+ "evidence": [
+ "doctor-findings.json: observed.docs_quickstart_md.missing",
+ "docs/quickstart.md is absent in the checked out repository"
+ ]
+ }
+ ]
+ }
+ ```
+
+ Populate `issue.number`, `issue.url`, and `issue.title` with `null` when absent.
+ `analysis[]` must include every analyzed observed finding, including
+ `info`, with `qualifies_for_issue` set appropriately.
+
+ ## Failure Handling
+
+ If you encounter a temporary tool or execution error while downloading
+ artifacts, reading repository context, or building issue text:
+
+ 1. retry once
+ 2. if the retry succeeds, continue
+ 3. if the retry fails, write `doctor-report.json` with:
+ - `issue.action = "failed"`
+ - `issue.reason = "model_error"`
+ 4. add a precise note to `runtime_notes[]`
+ 5. exit with failure
+
+ For all failure paths:
+
+ - prefer a valid `doctor-report.json` over incomplete changes
+ - do not create duplicate issues
+ - do not mutate repository files
+
+ ## Final Checklist
+
+ Before finishing, verify all of the following:
+
+ - `doctor-report.json` exists and is valid JSON
+ - the mirrored report copies exist in `/tmp/gh-aw/agent/`
+ - `schema_version == "doctor-report/v1"`
+ - issue decisions use only the allowed enums
+ - safe outputs, if any, were used only after artifact validation and only when allowed by runtime gates
+ - no repository files were modified other than `doctor-report.json` and temporary scratch files
+ GH_AW_PROMPT_EOF
+ - name: Substitute placeholders
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ GH_AW_GITHUB_SERVER_URL: ${{ github.server_url }}
+ GH_AW_INPUTS_ANALYZE_DAY: ${{ inputs.analyze_day }}
+ GH_AW_INPUTS_FINDINGS_ARTIFACT_NAME: ${{ inputs.findings_artifact_name }}
+ GH_AW_INPUTS_FORCE_ANALYZE: ${{ inputs.force_analyze }}
+ GH_AW_INPUTS_INSTALLED_RUNTIME_SHA: ${{ inputs.installed_runtime_sha }}
+ GH_AW_INPUTS_INSTALLED_SOURCE_SHA: ${{ inputs.installed_source_sha }}
+ GH_AW_INPUTS_ISSUES_ENABLED: ${{ inputs.issues_enabled }}
+ GH_AW_INPUTS_OBSERVED_FINDINGS_PRESENT: ${{ inputs.observed_findings_present }}
+ GH_AW_INPUTS_REPORT_TZ: ${{ inputs.report_tz }}
+ GH_AW_INPUTS_SOURCE_SHA: ${{ inputs.source_sha }}
+ GH_AW_INPUTS_STATS_ARTIFACT_NAME: ${{ inputs.stats_artifact_name }}
+ with:
+ script: |
+ const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs');
+
+ // Call the substitution function
+ return await substitutePlaceholders({
+ file: process.env.GH_AW_PROMPT,
+ substitutions: {
+ GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
+ GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID,
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER,
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER,
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
+ GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
+ GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
+ GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
+ GH_AW_GITHUB_SERVER_URL: process.env.GH_AW_GITHUB_SERVER_URL,
+ GH_AW_INPUTS_ANALYZE_DAY: process.env.GH_AW_INPUTS_ANALYZE_DAY,
+ GH_AW_INPUTS_FINDINGS_ARTIFACT_NAME: process.env.GH_AW_INPUTS_FINDINGS_ARTIFACT_NAME,
+ GH_AW_INPUTS_FORCE_ANALYZE: process.env.GH_AW_INPUTS_FORCE_ANALYZE,
+ GH_AW_INPUTS_INSTALLED_RUNTIME_SHA: process.env.GH_AW_INPUTS_INSTALLED_RUNTIME_SHA,
+ GH_AW_INPUTS_INSTALLED_SOURCE_SHA: process.env.GH_AW_INPUTS_INSTALLED_SOURCE_SHA,
+ GH_AW_INPUTS_ISSUES_ENABLED: process.env.GH_AW_INPUTS_ISSUES_ENABLED,
+ GH_AW_INPUTS_OBSERVED_FINDINGS_PRESENT: process.env.GH_AW_INPUTS_OBSERVED_FINDINGS_PRESENT,
+ GH_AW_INPUTS_REPORT_TZ: process.env.GH_AW_INPUTS_REPORT_TZ,
+ GH_AW_INPUTS_SOURCE_SHA: process.env.GH_AW_INPUTS_SOURCE_SHA,
+ GH_AW_INPUTS_STATS_ARTIFACT_NAME: process.env.GH_AW_INPUTS_STATS_ARTIFACT_NAME,
+ }
+ });
+ - name: Interpolate variables and render templates
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs');
+ await main();
+ - name: Validate prompt placeholders
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh
+ - name: Print prompt
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ run: bash /opt/gh-aw/actions/print_prompt_summary.sh
+ - name: Clean git credentials
+ run: bash /opt/gh-aw/actions/clean_git_credentials.sh
+ - name: Execute Claude Code CLI
+ id: agentic_execution
+ # Allowed tools (sorted):
+ # - Bash
+ # - BashOutput
+ # - Edit
+ # - ExitPlanMode
+ # - Glob
+ # - Grep
+ # - KillBash
+ # - LS
+ # - MultiEdit
+ # - NotebookEdit
+ # - NotebookRead
+ # - Read
+ # - Task
+ # - TodoWrite
+ # - Write
+ # - mcp__github__download_workflow_run_artifact
+ # - mcp__github__get_code_scanning_alert
+ # - mcp__github__get_commit
+ # - mcp__github__get_dependabot_alert
+ # - mcp__github__get_discussion
+ # - mcp__github__get_discussion_comments
+ # - mcp__github__get_file_contents
+ # - mcp__github__get_job_logs
+ # - mcp__github__get_label
+ # - mcp__github__get_latest_release
+ # - mcp__github__get_me
+ # - mcp__github__get_notification_details
+ # - mcp__github__get_pull_request
+ # - mcp__github__get_pull_request_comments
+ # - mcp__github__get_pull_request_diff
+ # - mcp__github__get_pull_request_files
+ # - mcp__github__get_pull_request_review_comments
+ # - mcp__github__get_pull_request_reviews
+ # - mcp__github__get_pull_request_status
+ # - mcp__github__get_release_by_tag
+ # - mcp__github__get_secret_scanning_alert
+ # - mcp__github__get_tag
+ # - mcp__github__get_workflow_run
+ # - mcp__github__get_workflow_run_logs
+ # - mcp__github__get_workflow_run_usage
+ # - mcp__github__issue_read
+ # - mcp__github__list_branches
+ # - mcp__github__list_code_scanning_alerts
+ # - mcp__github__list_commits
+ # - mcp__github__list_dependabot_alerts
+ # - mcp__github__list_discussion_categories
+ # - mcp__github__list_discussions
+ # - mcp__github__list_issue_types
+ # - mcp__github__list_issues
+ # - mcp__github__list_label
+ # - mcp__github__list_notifications
+ # - mcp__github__list_pull_requests
+ # - mcp__github__list_releases
+ # - mcp__github__list_secret_scanning_alerts
+ # - mcp__github__list_starred_repositories
+ # - mcp__github__list_tags
+ # - mcp__github__list_workflow_jobs
+ # - mcp__github__list_workflow_run_artifacts
+ # - mcp__github__list_workflow_runs
+ # - mcp__github__list_workflows
+ # - mcp__github__pull_request_read
+ # - mcp__github__search_code
+ # - mcp__github__search_issues
+ # - mcp__github__search_orgs
+ # - mcp__github__search_pull_requests
+ # - mcp__github__search_repositories
+ # - mcp__github__search_users
+ timeout-minutes: 20
+ run: |
+ set -o pipefail
+ sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.18.0 --skip-pull --enable-api-proxy \
+ -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ BASH_DEFAULT_TIMEOUT_MS: 60000
+ BASH_MAX_TIMEOUT_MS: 60000
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ DISABLE_BUG_COMMAND: 1
+ DISABLE_ERROR_REPORTING: 1
+ DISABLE_TELEMETRY: 1
+ GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json
+ GH_AW_MODEL_AGENT_CLAUDE: ${{ vars.GH_AW_MODEL_AGENT_CLAUDE || '' }}
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ MCP_TIMEOUT: 120000
+ MCP_TOOL_TIMEOUT: 60000
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Stop MCP gateway
+ if: always()
+ continue-on-error: true
+ env:
+ MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
+ MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
+ GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
+ run: |
+ bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID"
+ - name: Redact secrets in logs
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs');
+ await main();
+ env:
+ GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
+ SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
+ SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
+ SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Upload Safe Outputs
+ if: always()
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: safe-output
+ path: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ if-no-files-found: warn
+ - name: Ingest agent output
+ id: collect_output
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com"
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs');
+ await main();
+ - name: Upload sanitized agent output
+ if: always() && env.GH_AW_AGENT_OUTPUT
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: agent-output
+ path: ${{ env.GH_AW_AGENT_OUTPUT }}
+ if-no-files-found: warn
+ - name: Parse agent logs for step summary
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/parse_claude_log.cjs');
+ await main();
+ - name: Parse MCP gateway logs for step summary
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs');
+ await main();
+ - name: Print firewall logs
+ if: always()
+ continue-on-error: true
+ env:
+ AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
+ run: |
+ # Fix permissions on firewall logs so they can be uploaded as artifacts
+ # AWF runs with sudo, creating files owned by root
+ sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true
+ awf logs summary | tee -a "$GITHUB_STEP_SUMMARY"
+ - name: Upload agent artifacts
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: agent-artifacts
+ path: |
+ /tmp/gh-aw/aw-prompts/prompt.txt
+ /tmp/gh-aw/aw_info.json
+ /tmp/gh-aw/mcp-logs/
+ /tmp/gh-aw/sandbox/firewall/logs/
+ /tmp/gh-aw/agent-stdio.log
+ /tmp/gh-aw/agent/
+ if-no-files-found: ignore
+
+ conclusion:
+ needs:
+ - activation
+ - agent
+ - detection
+ - safe_outputs
+ if: (always()) && (needs.agent.result != 'skipped')
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ outputs:
+ noop_message: ${{ steps.noop.outputs.noop_message }}
+ tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
+ total_count: ${{ steps.missing_tool.outputs.total_count }}
+ steps:
+ - name: Setup Scripts
+ uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Download agent output artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
+ with:
+ name: agent-output
+ path: /tmp/gh-aw/safeoutputs/
+ - name: Setup agent output environment variable
+ run: |
+ mkdir -p /tmp/gh-aw/safeoutputs/
+ find "/tmp/gh-aw/safeoutputs/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
+ - name: Process No-Op Messages
+ id: noop
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_NOOP_MAX: 1
+ GH_AW_WORKFLOW_NAME: "doctor-analyze"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/noop.cjs');
+ await main();
+ - name: Record Missing Tool
+ id: missing_tool
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "doctor-analyze"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/missing_tool.cjs');
+ await main();
+ - name: Handle Agent Failure
+ id: handle_agent_failure
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "doctor-analyze"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_WORKFLOW_ID: "doctor-analyze"
+ GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }}
+ GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs');
+ await main();
+ - name: Handle No-Op Message
+ id: handle_noop_message
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "doctor-analyze"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }}
+ GH_AW_NOOP_REPORT_AS_ISSUE: "true"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs');
+ await main();
+ - name: Update reaction comment with completion status
+ id: conclusion
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
+ GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }}
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_WORKFLOW_NAME: "doctor-analyze"
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs');
+ await main();
+
+ detection:
+ needs: agent
+ if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true'
+ runs-on: ubuntu-latest
+ permissions: {}
+ timeout-minutes: 10
+ outputs:
+ success: ${{ steps.parse_results.outputs.success }}
+ steps:
+ - name: Setup Scripts
+ uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Download agent artifacts
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
+ with:
+ name: agent-artifacts
+ path: /tmp/gh-aw/threat-detection/
+ - name: Download agent output artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
+ with:
+ name: agent-output
+ path: /tmp/gh-aw/threat-detection/
+ - name: Echo agent output types
+ env:
+ AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }}
+ run: |
+ echo "Agent output-types: $AGENT_OUTPUT_TYPES"
+ - name: Setup threat detection
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ WORKFLOW_NAME: "doctor-analyze"
+ WORKFLOW_DESCRIPTION: "No description provided"
+ HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs');
+ await main();
+ - name: Ensure threat-detection directory and log
+ run: |
+ mkdir -p /tmp/gh-aw/threat-detection
+ touch /tmp/gh-aw/threat-detection/detection.log
+ - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret
+ id: validate-secret
+ run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code
+ env:
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ - name: Setup Node.js
+ uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - name: Install Claude Code CLI
+ run: npm install -g --silent @anthropic-ai/claude-code@2.1.42
+ - name: Execute Claude Code CLI
+ id: agentic_execution
+ # Allowed tools (sorted):
+ # - Bash(cat)
+ # - Bash(grep)
+ # - Bash(head)
+ # - Bash(jq)
+ # - Bash(ls)
+ # - Bash(tail)
+ # - Bash(wc)
+ # - BashOutput
+ # - ExitPlanMode
+ # - Glob
+ # - Grep
+ # - KillBash
+ # - LS
+ # - NotebookRead
+ # - Read
+ # - Task
+ # - TodoWrite
+ timeout-minutes: 20
+ run: |
+ set -o pipefail
+ # Execute Claude Code CLI with prompt from file
+ claude --print --disable-slash-commands --no-chrome --allowed-tools 'Bash(cat),Bash(grep),Bash(head),Bash(jq),Bash(ls),Bash(tail),Bash(wc),BashOutput,ExitPlanMode,Glob,Grep,KillBash,LS,NotebookRead,Read,Task,TodoWrite' --debug-file /tmp/gh-aw/threat-detection/detection.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_CLAUDE:+ --model "$GH_AW_MODEL_DETECTION_CLAUDE"} 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ BASH_DEFAULT_TIMEOUT_MS: 60000
+ BASH_MAX_TIMEOUT_MS: 60000
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ DISABLE_BUG_COMMAND: 1
+ DISABLE_ERROR_REPORTING: 1
+ DISABLE_TELEMETRY: 1
+ GH_AW_MODEL_DETECTION_CLAUDE: ${{ vars.GH_AW_MODEL_DETECTION_CLAUDE || '' }}
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ MCP_TIMEOUT: 120000
+ MCP_TOOL_TIMEOUT: 60000
+ - name: Parse threat detection results
+ id: parse_results
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs');
+ await main();
+ - name: Upload threat detection log
+ if: always()
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: threat-detection.log
+ path: /tmp/gh-aw/threat-detection/detection.log
+ if-no-files-found: ignore
+
+ pre_activation:
+ runs-on: ubuntu-slim
+ outputs:
+ activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
+ steps:
+ - name: Setup Scripts
+ uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Check team membership for workflow
+ id: check_membership
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REQUIRED_ROLES: admin,maintainer,write
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/check_membership.cjs');
+ await main();
+
+ safe_outputs:
+ needs:
+ - agent
+ - detection
+ if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true')
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ timeout-minutes: 15
+ env:
+ GH_AW_ENGINE_ID: "claude"
+ GH_AW_WORKFLOW_ID: "doctor-analyze"
+ GH_AW_WORKFLOW_NAME: "doctor-analyze"
+ outputs:
+ create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
+ create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
+ process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
+ process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
+ steps:
+ - name: Setup Scripts
+ uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Download agent output artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
+ with:
+ name: agent-output
+ path: /tmp/gh-aw/safeoutputs/
+ - name: Setup agent output environment variable
+ run: |
+ mkdir -p /tmp/gh-aw/safeoutputs/
+ find "/tmp/gh-aw/safeoutputs/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
+ - name: Process Safe Outputs
+ id: process_safe_outputs
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"close_issue\":{\"max\":1},\"create_issue\":{\"max\":1},\"missing_data\":{},\"missing_tool\":{},\"update_issue\":{\"allow_body\":true,\"max\":1}}"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs');
+ await main();
+
diff --git a/.github/workflows/doctor-analyze.md b/.github/workflows/doctor-analyze.md
new file mode 100644
index 0000000..73250d9
--- /dev/null
+++ b/.github/workflows/doctor-analyze.md
@@ -0,0 +1,503 @@
+---
+name: doctor-analyze
+on:
+ workflow_call:
+ inputs:
+ force_analyze:
+ required: false
+ type: boolean
+ analyze_day:
+ required: true
+ type: string
+ report_tz:
+ required: true
+ type: string
+ issues_enabled:
+ required: true
+ type: string
+ observed_findings_present:
+ required: true
+ type: string
+ stats_artifact_name:
+ required: true
+ type: string
+ findings_artifact_name:
+ required: true
+ type: string
+ source_sha:
+ required: false
+ type: string
+ installed_source_sha:
+ required: false
+ type: string
+ installed_runtime_sha:
+ required: false
+ type: string
+permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+ actions: read
+safe-outputs:
+ create-issue:
+ update-issue:
+ close-issue:
+ add-comment:
+engine: claude
+---
+
+# Doctor Analyze
+
+## TL;DR
+
+Read-only analyze job for the `doctor` workflow. Downloads `collect` artifacts,
+validates findings, and manages a deterministic GitHub issue for observed drift.
+Writes `doctor-report.json` as the only persistent output.
+
+You are the `analyze` job for the repository described in the `` block above.
+This workflow is called only from `doctor.yml` and must stay read-only for repository files.
+Never create PRs. Never edit tracked repository files. The only persistent outputs you may write are:
+
+- `doctor-report.json` in the workspace root
+- mirrored copies of the same report under `/tmp/gh-aw/agent/` so the
+ current compiled workflow uploads them via generic agent artifacts
+
+Temporary scratch files are allowed only if they are needed to build or validate the report.
+
+## Runtime Context
+
+Treat the `` block from the system prompt as the runtime contract from `collect`.
+It must provide these keys:
+
+- `issues_enabled`
+- `observed_findings_present`
+- `stats_artifact_name`
+- `findings_artifact_name`
+- `source_sha`
+- `installed_source_sha`
+- `installed_runtime_sha`
+- `analyze_day`
+- `report_tz`
+- `force_analyze`
+- `run_id`
+- `actor`
+- `workflow_run_url`
+
+Read `run_attempt` from env var `GITHUB_RUN_ATTEMPT` via Bash.
+Read `event_name` from env var `GITHUB_EVENT_NAME` via Bash.
+If the `` block is missing any required key, record
+that in `runtime_notes[]`, write `doctor-report.json` with
+`issue.reason = "model_error"`, and exit with failure.
+
+Use `report_tz` to derive:
+
+- `report_date` in `YYYY-MM-DD`
+- `today_name` as a lowercase English weekday name
+- `analyze_day_match = (today_name == analyze_day.lower())`
+- `manual_override = (force_analyze == true)`
+
+## Operating Rules
+
+- Use GitHub MCP tools only for read operations: artifacts, issues, labels, workflow metadata, repository contents.
+- Use safe outputs only for issue mutations: `create-issue`, `update-issue`, `close-issue`, `add-comment`.
+- Never perform direct write mutations through bash, git, or raw GitHub API calls.
+- Prefer GitHub MCP tools for workflow runs, artifacts, issues, and labels.
+- If workflow artifact MCP tools are unavailable in the exposed tool list,
+ you may use `Bash` with authenticated GitHub API calls for artifact lookup
+ and download only.
+- Never use unauthenticated `gh` or unauthenticated `curl` for GitHub API access.
+- For authenticated fallback in Bash, export
+ `GH_TOKEN="$GITHUB_TOKEN"` before `gh api`, or use `curl` with header
+ `Authorization: Bearer $GITHUB_TOKEN`.
+- `collect` is the source of truth for findings and severities. Do not invent new finding IDs or new finding categories.
+- Limit issue-generating analysis to findings where:
+ - `category == observed`
+ - `auto_action == analyze`
+ - `path` is one of:
+ - `AGENTS.md`
+ - `CLAUDE.md`
+ - `DOCS.md`
+ - `.specify/memory/constitution.md`
+ - `docs/quickstart.md`
+- You may read repository files to improve recommendations and evidence,
+ but not to expand the scope beyond the allowed observed-only paths.
+
+## Required Procedure
+
+1. Start by creating a scratch work directory for downloaded artifacts and intermediate notes.
+2. Compute `report_date`, `today_name`, `analyze_day_match`, and `manual_override`.
+3. Initialize `runtime_notes[]` and a draft `doctor-report.json` object.
+
+### Runtime Gate
+
+Apply gates in this order:
+
+1. If `observed_findings_present != "true"`:
+ - write `doctor-report.json`
+ - set:
+ - `decision.observed_findings_present = false`
+ - `decision.qualifying_findings_present = false`
+ - `issue.action = "none"`
+ - `issue.reason = "no_observed_findings"`
+ - do not download artifacts
+ - do not call safe outputs
+ - exit successfully
+2. If `manual_override` is false and `analyze_day_match` is false:
+ - write `doctor-report.json`
+ - set:
+ - `decision.observed_findings_present = true`
+ - `decision.qualifying_findings_present = false`
+ - `issue.action = "skipped"`
+ - `issue.reason = "not_analyze_day"`
+ - do not call safe outputs
+ - exit successfully
+3. Otherwise continue to artifact download and analysis.
+
+## Artifact Download And Validation
+
+Download artifacts from the current workflow run identified in
+`` using the exact artifact names from `collect`:
+
+- the `stats_artifact_name` value from ``
+- the `findings_artifact_name` value from ``
+- the predownloaded local files, when present:
+ - `/tmp/gh-aw/collect-artifacts/stats/doctor-stats.json`
+ - `/tmp/gh-aw/collect-artifacts/findings/doctor-findings.json`
+
+Required procedure for artifact access:
+
+1. First check the predownloaded local files under
+ `/tmp/gh-aw/collect-artifacts/`. If both required files are present, use
+ them as the primary source and do not attempt network artifact lookup.
+2. If either local file is missing, prefer GitHub MCP workflow run and artifact tools when they are available.
+3. If the matching collect artifacts are not listed yet, wait 2 seconds and retry once using the same access path.
+4. If workflow artifact MCP tools are unavailable, use authenticated Bash fallback:
+ - `GH_TOKEN="$GITHUB_TOKEN" gh api ...`
+ - or `curl -H "Authorization: Bearer $GITHUB_TOKEN" ...`
+5. Only use workspace or runner filesystem search as a secondary diagnostic
+ note after both the predownloaded local files and the authenticated
+ access paths fail.
+6. Do not use unauthenticated `gh api`, unauthenticated `curl`, or raw REST calls without `GITHUB_TOKEN`.
+
+Expect these files:
+
+- `doctor-stats.json`
+- `doctor-findings.json`
+
+Validate both artifacts before analysis. Treat any missing file, invalid
+JSON, missing required field, wrong schema version, or type mismatch as
+`artifact_invalid`.
+
+Minimum required validation for `doctor-stats.json`:
+
+- `schema_version == "doctor-stats/v3"`
+- `collected_at`
+- `report_date`
+- `window.from`
+- `window.to`
+- `repo.full_name`
+- `repo.default_branch`
+- `workflow.run_id`
+- `workflow.run_attempt`
+- `workflow.event_name`
+- `workflow.actor`
+- `source_repo.full_name`
+- `source_repo.source_branch`
+- `source_repo.source_sha`
+- `activity.commits`
+- `activity.prs`
+- `activity.issues`
+- `snapshot.prs`
+- `snapshot.issues`
+
+Minimum required validation for `doctor-findings.json`:
+
+- `schema_version == "doctor-findings/v4"`
+- `collected_at`
+- `report_date`
+- `repo.full_name`
+- `repo.default_branch`
+- `source_repo.source_branch`
+- `source_repo.source_sha`
+- `decision.should_act`
+- `decision.act_reason`
+- `decision.managed_drift_present`
+- `decision.runtime_drift_present`
+- `decision.observed_findings_present`
+- `findings[]`
+
+For every analyzed finding, require:
+
+- `id`
+- `category`
+- `severity`
+- `path`
+- `state`
+- `auto_action`
+- `message`
+
+If artifact validation fails:
+
+- write `doctor-report.json`
+- set:
+ - `issue.action = "failed"`
+ - `issue.reason = "artifact_invalid"`
+- add a precise explanation to `runtime_notes[]`
+- do not call safe outputs
+- exit with failure
+
+## Analysis Scope
+
+Analyze only findings from `doctor-findings.json` where:
+
+- `category == "observed"`
+- `auto_action == "analyze"`
+
+Exclude from issue generation:
+
+- `managed`
+- `runtime`
+- `operational`
+- observed checks outside the allowed path scope
+
+Keep `info` findings in `analysis[]`, but they do not qualify for an issue.
+Qualifying findings are only severities:
+
+- `warning`
+- `error`
+
+## Analysis Rules
+
+For each in-scope observed finding:
+
+1. Preserve the original `finding_id`, `path`, `severity`, `state`, and `message`.
+2. Read the current repository path when it exists and use that only as supporting evidence.
+3. Produce one `analysis[]` item with:
+ - `finding_id`
+ - `category = "observed"`
+ - `path`
+ - `severity`
+ - `state`
+ - `qualifies_for_issue`
+ - `summary`
+ - `recommendation`
+ - `evidence[]`
+
+Use these guidance rules:
+
+- `AGENTS.md` missing:
+ - explain that the repo is missing the agent entry point
+ - recommend restoring the file or a valid symlink in the root
+- `CLAUDE.md` missing:
+ - explain that the repo is missing the Claude-facing entry point
+ - recommend restoring the file or a valid symlink in the root
+- `DOCS.md` missing:
+ - explain that the repo is missing the main docs entry point in the root
+ - recommend restoring the file or a valid symlink in the root
+- `docs/quickstart.md` missing or placeholder:
+ - recommend a real first-run or onboarding guide
+- `.specify/memory/constitution.md` missing or placeholder:
+ - recommend restoring and tracking a real working agreement
+
+When files exist but appear placeholder-like or incomplete, use concrete evidence such as:
+
+- obvious placeholder markers: `TODO`, `TBD`, `placeholder`, `coming soon`, `lorem ipsum`, `template`
+- extremely short content with no actionable project-specific instructions
+
+Do not create duplicate recommendations for the same path family. Group repeated ideas.
+
+## Issue Policy
+
+Deterministic issue title:
+
+- `Toolkit doctor: observed drift`
+
+Preferred labels when available:
+
+- `toolkit`
+- `doctor`
+
+Issue body must contain these sections:
+
+- `## Summary`
+- `## Findings`
+- `## Recommendations`
+- `## Context`
+
+Issue body requirements:
+
+- concise summary of current qualifying warnings and errors
+- bullet list or table of qualifying findings with:
+ - path
+ - severity
+ - message
+- short grouped recommendations
+- link to the `workflow_run_url` value from ``
+- short activity context from `doctor-stats.json` only if it clearly helps prioritization
+
+Use read-only GitHub tools to find an existing open issue with the exact title `Toolkit doctor: observed drift`.
+Do not create duplicates.
+
+Before issue mutation:
+
+1. Check whether labels `toolkit` and `doctor` exist.
+2. If one or both labels are missing:
+ - continue without the missing labels
+ - record the problem in `runtime_notes[]`
+3. If an existing deterministic issue is locked:
+ - write `doctor-report.json`
+ - set:
+ - `issue.action = "failed"`
+ - `issue.reason = "issue_locked"`
+ - do not create a duplicate
+ - exit with failure
+
+## Issue Lifecycle
+
+After analysis, compute:
+
+- `findings_analyzed`
+- `qualifying_findings`
+- `warning_count`
+- `error_count`
+- `qualifying_findings_present`
+
+Then apply this lifecycle:
+
+1. If `qualifying_findings_present == false`:
+ - if an open deterministic issue exists:
+ - add a resolution comment that observed drift is no longer present in the latest analyze run
+ - close that issue
+ - set:
+ - `issue.action = "closed"`
+ - `issue.reason = "closed_resolved"`
+ - otherwise:
+ - set:
+ - `issue.action = "none"`
+ - `issue.reason = "no_qualifying_findings"`
+2. If `qualifying_findings_present == true` and `issues_enabled != "true"`:
+ - perform the full analysis
+ - do not create, update, close, or comment on issues
+ - set:
+ - `issue.action = "skipped"`
+ - `issue.reason = "issues_disabled"`
+ - if an existing deterministic issue exists, keep its metadata in the report but do not mutate it
+3. If `qualifying_findings_present == true` and no open deterministic issue exists:
+ - create one issue through `create-issue`
+ - set:
+ - `issue.action = "created"`
+ - `issue.reason = "created_new"`
+4. If `qualifying_findings_present == true` and an open deterministic issue exists:
+ - update that exact issue through `update-issue`
+ - set:
+ - `issue.action = "updated"`
+ - `issue.reason = "updated_existing"`
+
+Never use direct GitHub write APIs for issues. Safe outputs only.
+
+## doctor-report.json
+
+Always write `doctor-report.json` before exiting, including skip and failure paths.
+
+Write the same final JSON payload to all of these paths:
+
+- `./doctor-report.json`
+- `/tmp/gh-aw/agent/doctor-report.json`
+- `/tmp/gh-aw/agent/doctor-report-${report_date}.json`
+
+This is a temporary workaround until the compiled reusable workflow gets a
+dedicated `upload-artifact` step for a `doctor-report-YYYY-MM-DD` artifact.
+
+Required top-level schema:
+
+```json
+{
+ "schema_version": "doctor-report/v1",
+ "analyzed_at": "RFC3339",
+ "report_date": "YYYY-MM-DD",
+ "repo": {
+ "full_name": "owner/repo"
+ },
+ "workflow": {
+ "run_id": 0,
+ "run_attempt": 0,
+ "event_name": "workflow_dispatch|schedule|...",
+ "actor": "github-user"
+ },
+ "inputs": {
+ "stats_artifact_name": "doctor-stats-YYYY-MM-DD",
+ "findings_artifact_name": "doctor-findings-YYYY-MM-DD"
+ },
+ "decision": {
+ "observed_findings_present": true,
+ "issues_enabled": true,
+ "analyze_day_match": true,
+ "manual_override": false,
+ "qualifying_findings_present": true
+ },
+ "issue": {
+ "action": "created|updated|closed|skipped|none|failed",
+ "reason": "created_new|updated_existing|closed_resolved|issues_disabled|not_analyze_day|no_observed_findings|no_qualifying_findings|artifact_invalid|model_error|issue_locked",
+ "number": 0,
+ "url": "https://github.com/owner/repo/issues/123",
+ "title": "Toolkit doctor: observed drift"
+ },
+ "summary": {
+ "findings_analyzed": 0,
+ "qualifying_findings": 0,
+ "warning_count": 0,
+ "error_count": 0
+ },
+ "runtime_notes": [],
+ "analysis": [
+ {
+ "finding_id": "observed.docs_quickstart_md.missing",
+ "category": "observed",
+ "path": "docs/quickstart.md",
+ "severity": "warning",
+ "state": "missing",
+ "qualifies_for_issue": true,
+ "summary": "Repository is missing the onboarding quickstart.",
+ "recommendation": "Add a real quickstart with first-run instructions for contributors.",
+ "evidence": [
+ "doctor-findings.json: observed.docs_quickstart_md.missing",
+ "docs/quickstart.md is absent in the checked out repository"
+ ]
+ }
+ ]
+}
+```
+
+Populate `issue.number`, `issue.url`, and `issue.title` with `null` when absent.
+`analysis[]` must include every analyzed observed finding, including
+`info`, with `qualifies_for_issue` set appropriately.
+
+## Failure Handling
+
+If you encounter a temporary tool or execution error while downloading
+artifacts, reading repository context, or building issue text:
+
+1. retry once
+2. if the retry succeeds, continue
+3. if the retry fails, write `doctor-report.json` with:
+ - `issue.action = "failed"`
+ - `issue.reason = "model_error"`
+4. add a precise note to `runtime_notes[]`
+5. exit with failure
+
+For all failure paths:
+
+- prefer a valid `doctor-report.json` over incomplete changes
+- do not create duplicate issues
+- do not mutate repository files
+
+## Final Checklist
+
+Before finishing, verify all of the following:
+
+- `doctor-report.json` exists and is valid JSON
+- the mirrored report copies exist in `/tmp/gh-aw/agent/`
+- `schema_version == "doctor-report/v1"`
+- issue decisions use only the allowed enums
+- safe outputs, if any, were used only after artifact validation and only when allowed by runtime gates
+- no repository files were modified other than `doctor-report.json` and temporary scratch files
diff --git a/.github/workflows/doctor-analyze.prompt.md b/.github/workflows/doctor-analyze.prompt.md
new file mode 100644
index 0000000..0958728
--- /dev/null
+++ b/.github/workflows/doctor-analyze.prompt.md
@@ -0,0 +1,455 @@
+# Doctor Analyze
+
+## TL;DR
+
+Read-only analyze job for the `doctor` workflow. Downloads `collect` artifacts,
+validates findings, and manages a deterministic GitHub issue for observed drift.
+Writes `doctor-report.json` as the only persistent output.
+
+You are the `analyze` job for the repository described in the `` block above.
+This workflow is called only from `doctor.yml` and must stay read-only for repository files.
+Never create PRs. Never edit tracked repository files. The only persistent outputs you may write are:
+
+- `doctor-report.json` in the workspace root
+- mirrored copies of the same report under `/tmp/gh-aw/agent/` so the
+ current compiled workflow uploads them via generic agent artifacts
+
+Temporary scratch files are allowed only if they are needed to build or validate the report.
+
+## Runtime Context
+
+Treat the `` block from the system prompt as the runtime contract from `collect`.
+It must provide these keys:
+
+- `issues_enabled`
+- `observed_findings_present`
+- `stats_artifact_name`
+- `findings_artifact_name`
+- `source_sha`
+- `installed_source_sha`
+- `installed_runtime_sha`
+- `analyze_day`
+- `report_tz`
+- `force_analyze`
+- `run_id`
+- `actor`
+- `workflow_run_url`
+
+Read `run_attempt` from env var `GITHUB_RUN_ATTEMPT` via Bash.
+Read `event_name` from env var `GITHUB_EVENT_NAME` via Bash.
+If the `` block is missing any required key, record
+that in `runtime_notes[]`, write `doctor-report.json` with
+`issue.reason = "model_error"`, and exit with failure.
+
+Use `report_tz` to derive:
+
+- `report_date` in `YYYY-MM-DD`
+- `today_name` as a lowercase English weekday name
+- `analyze_day_match = (today_name == analyze_day.lower())`
+- `manual_override = (force_analyze == true)`
+
+## Operating Rules
+
+- Use GitHub MCP tools only for read operations: artifacts, issues, labels, workflow metadata, repository contents.
+- Use safe outputs only for issue mutations: `create-issue`, `update-issue`, `close-issue`, `add-comment`.
+- Never perform direct write mutations through bash, git, or raw GitHub API calls.
+- Prefer GitHub MCP tools for workflow runs, artifacts, issues, and labels.
+- If workflow artifact MCP tools are unavailable in the exposed tool list,
+ you may use `Bash` with authenticated GitHub API calls for artifact lookup
+ and download only.
+- Never use unauthenticated `gh` or unauthenticated `curl` for GitHub API access.
+- For authenticated fallback in Bash, export
+ `GH_TOKEN="$GITHUB_TOKEN"` before `gh api`, or use `curl` with header
+ `Authorization: Bearer $GITHUB_TOKEN`.
+- `collect` is the source of truth for findings and severities. Do not invent new finding IDs or new finding categories.
+- Limit issue-generating analysis to findings where:
+ - `category == observed`
+ - `auto_action == analyze`
+ - `path` is one of:
+ - `AGENTS.md`
+ - `CLAUDE.md`
+ - `DOCS.md`
+ - `.specify/memory/constitution.md`
+ - `docs/quickstart.md`
+- You may read repository files to improve recommendations and evidence,
+ but not to expand the scope beyond the allowed observed-only paths.
+
+## Required Procedure
+
+1. Start by creating a scratch work directory for downloaded artifacts and intermediate notes.
+2. Compute `report_date`, `today_name`, `analyze_day_match`, and `manual_override`.
+3. Initialize `runtime_notes[]` and a draft `doctor-report.json` object.
+
+### Runtime Gate
+
+Apply gates in this order:
+
+1. If `observed_findings_present != "true"`:
+ - write `doctor-report.json`
+ - set:
+ - `decision.observed_findings_present = false`
+ - `decision.qualifying_findings_present = false`
+ - `issue.action = "none"`
+ - `issue.reason = "no_observed_findings"`
+ - do not download artifacts
+ - do not call safe outputs
+ - exit successfully
+2. If `manual_override` is false and `analyze_day_match` is false:
+ - write `doctor-report.json`
+ - set:
+ - `decision.observed_findings_present = true`
+ - `decision.qualifying_findings_present = false`
+ - `issue.action = "skipped"`
+ - `issue.reason = "not_analyze_day"`
+ - do not call safe outputs
+ - exit successfully
+3. Otherwise continue to artifact download and analysis.
+
+## Artifact Download And Validation
+
+Download artifacts from the current workflow run identified in
+`` using the exact artifact names from `collect`:
+
+- the `stats_artifact_name` value from ``
+- the `findings_artifact_name` value from ``
+- the predownloaded local files, when present:
+ - `/tmp/gh-aw/collect-artifacts/stats/doctor-stats.json`
+ - `/tmp/gh-aw/collect-artifacts/findings/doctor-findings.json`
+
+Required procedure for artifact access:
+
+1. First check the predownloaded local files under
+ `/tmp/gh-aw/collect-artifacts/`. If both required files are present, use
+ them as the primary source and do not attempt network artifact lookup.
+2. If either local file is missing, prefer GitHub MCP workflow run and artifact tools when they are available.
+3. If the matching collect artifacts are not listed yet, wait 2 seconds and retry once using the same access path.
+4. If workflow artifact MCP tools are unavailable, use authenticated Bash fallback:
+ - `GH_TOKEN="$GITHUB_TOKEN" gh api ...`
+ - or `curl -H "Authorization: Bearer $GITHUB_TOKEN" ...`
+5. Only use workspace or runner filesystem search as a secondary diagnostic
+ note after both the predownloaded local files and the authenticated
+ access paths fail.
+6. Do not use unauthenticated `gh api`, unauthenticated `curl`, or raw REST calls without `GITHUB_TOKEN`.
+
+Expect these files:
+
+- `doctor-stats.json`
+- `doctor-findings.json`
+
+Validate both artifacts before analysis. Treat any missing file, invalid
+JSON, missing required field, wrong schema version, or type mismatch as
+`artifact_invalid`.
+
+Minimum required validation for `doctor-stats.json`:
+
+- `schema_version == "doctor-stats/v3"`
+- `collected_at`
+- `report_date`
+- `window.from`
+- `window.to`
+- `repo.full_name`
+- `repo.default_branch`
+- `workflow.run_id`
+- `workflow.run_attempt`
+- `workflow.event_name`
+- `workflow.actor`
+- `source_repo.full_name`
+- `source_repo.source_branch`
+- `source_repo.source_sha`
+- `activity.commits`
+- `activity.prs`
+- `activity.issues`
+- `snapshot.prs`
+- `snapshot.issues`
+
+Minimum required validation for `doctor-findings.json`:
+
+- `schema_version == "doctor-findings/v4"`
+- `collected_at`
+- `report_date`
+- `repo.full_name`
+- `repo.default_branch`
+- `source_repo.source_branch`
+- `source_repo.source_sha`
+- `decision.should_act`
+- `decision.act_reason`
+- `decision.managed_drift_present`
+- `decision.runtime_drift_present`
+- `decision.observed_findings_present`
+- `findings[]`
+
+For every analyzed finding, require:
+
+- `id`
+- `category`
+- `severity`
+- `path`
+- `state`
+- `auto_action`
+- `message`
+
+If artifact validation fails:
+
+- write `doctor-report.json`
+- set:
+ - `issue.action = "failed"`
+ - `issue.reason = "artifact_invalid"`
+- add a precise explanation to `runtime_notes[]`
+- do not call safe outputs
+- exit with failure
+
+## Analysis Scope
+
+Analyze only findings from `doctor-findings.json` where:
+
+- `category == "observed"`
+- `auto_action == "analyze"`
+
+Exclude from issue generation:
+
+- `managed`
+- `runtime`
+- `operational`
+- observed checks outside the allowed path scope
+
+Keep `info` findings in `analysis[]`, but they do not qualify for an issue.
+Qualifying findings are only severities:
+
+- `warning`
+- `error`
+
+## Analysis Rules
+
+For each in-scope observed finding:
+
+1. Preserve the original `finding_id`, `path`, `severity`, `state`, and `message`.
+2. Read the current repository path when it exists and use that only as supporting evidence.
+3. Produce one `analysis[]` item with:
+ - `finding_id`
+ - `category = "observed"`
+ - `path`
+ - `severity`
+ - `state`
+ - `qualifies_for_issue`
+ - `summary`
+ - `recommendation`
+ - `evidence[]`
+
+Use these guidance rules:
+
+- `AGENTS.md` missing:
+ - explain that the repo is missing the agent entry point
+ - recommend restoring the file or a valid symlink in the root
+- `CLAUDE.md` missing:
+ - explain that the repo is missing the Claude-facing entry point
+ - recommend restoring the file or a valid symlink in the root
+- `DOCS.md` missing:
+ - explain that the repo is missing the main docs entry point in the root
+ - recommend restoring the file or a valid symlink in the root
+- `docs/quickstart.md` missing or placeholder:
+ - recommend a real first-run or onboarding guide
+- `.specify/memory/constitution.md` missing or placeholder:
+ - recommend restoring and tracking a real working agreement
+
+When files exist but appear placeholder-like or incomplete, use concrete evidence such as:
+
+- obvious placeholder markers: `TODO`, `TBD`, `placeholder`, `coming soon`, `lorem ipsum`, `template`
+- extremely short content with no actionable project-specific instructions
+
+Do not create duplicate recommendations for the same path family. Group repeated ideas.
+
+## Issue Policy
+
+Deterministic issue title:
+
+- `Toolkit doctor: observed drift`
+
+Preferred labels when available:
+
+- `toolkit`
+- `doctor`
+
+Issue body must contain these sections:
+
+- `## Summary`
+- `## Findings`
+- `## Recommendations`
+- `## Context`
+
+Issue body requirements:
+
+- concise summary of current qualifying warnings and errors
+- bullet list or table of qualifying findings with:
+ - path
+ - severity
+ - message
+- short grouped recommendations
+- link to the `workflow_run_url` value from ``
+- short activity context from `doctor-stats.json` only if it clearly helps prioritization
+
+Use read-only GitHub tools to find an existing open issue with the exact title `Toolkit doctor: observed drift`.
+Do not create duplicates.
+
+Before issue mutation:
+
+1. Check whether labels `toolkit` and `doctor` exist.
+2. If one or both labels are missing:
+ - continue without the missing labels
+ - record the problem in `runtime_notes[]`
+3. If an existing deterministic issue is locked:
+ - write `doctor-report.json`
+ - set:
+ - `issue.action = "failed"`
+ - `issue.reason = "issue_locked"`
+ - do not create a duplicate
+ - exit with failure
+
+## Issue Lifecycle
+
+After analysis, compute:
+
+- `findings_analyzed`
+- `qualifying_findings`
+- `warning_count`
+- `error_count`
+- `qualifying_findings_present`
+
+Then apply this lifecycle:
+
+1. If `qualifying_findings_present == false`:
+ - if an open deterministic issue exists:
+ - add a resolution comment that observed drift is no longer present in the latest analyze run
+ - close that issue
+ - set:
+ - `issue.action = "closed"`
+ - `issue.reason = "closed_resolved"`
+ - otherwise:
+ - set:
+ - `issue.action = "none"`
+ - `issue.reason = "no_qualifying_findings"`
+2. If `qualifying_findings_present == true` and `issues_enabled != "true"`:
+ - perform the full analysis
+ - do not create, update, close, or comment on issues
+ - set:
+ - `issue.action = "skipped"`
+ - `issue.reason = "issues_disabled"`
+ - if an existing deterministic issue exists, keep its metadata in the report but do not mutate it
+3. If `qualifying_findings_present == true` and no open deterministic issue exists:
+ - create one issue through `create-issue`
+ - set:
+ - `issue.action = "created"`
+ - `issue.reason = "created_new"`
+4. If `qualifying_findings_present == true` and an open deterministic issue exists:
+ - update that exact issue through `update-issue`
+ - set:
+ - `issue.action = "updated"`
+ - `issue.reason = "updated_existing"`
+
+Never use direct GitHub write APIs for issues. Safe outputs only.
+
+## doctor-report.json
+
+Always write `doctor-report.json` before exiting, including skip and failure paths.
+
+Write the same final JSON payload to all of these paths:
+
+- `./doctor-report.json`
+- `/tmp/gh-aw/agent/doctor-report.json`
+- `/tmp/gh-aw/agent/doctor-report-${report_date}.json`
+
+This is a temporary workaround until the compiled reusable workflow gets a
+dedicated `upload-artifact` step for a `doctor-report-YYYY-MM-DD` artifact.
+
+Required top-level schema:
+
+```json
+{
+ "schema_version": "doctor-report/v1",
+ "analyzed_at": "RFC3339",
+ "report_date": "YYYY-MM-DD",
+ "repo": {
+ "full_name": "owner/repo"
+ },
+ "workflow": {
+ "run_id": 0,
+ "run_attempt": 0,
+ "event_name": "workflow_dispatch|schedule|...",
+ "actor": "github-user"
+ },
+ "inputs": {
+ "stats_artifact_name": "doctor-stats-YYYY-MM-DD",
+ "findings_artifact_name": "doctor-findings-YYYY-MM-DD"
+ },
+ "decision": {
+ "observed_findings_present": true,
+ "issues_enabled": true,
+ "analyze_day_match": true,
+ "manual_override": false,
+ "qualifying_findings_present": true
+ },
+ "issue": {
+ "action": "created|updated|closed|skipped|none|failed",
+ "reason": "created_new|updated_existing|closed_resolved|issues_disabled|not_analyze_day|no_observed_findings|no_qualifying_findings|artifact_invalid|model_error|issue_locked",
+ "number": 0,
+ "url": "https://github.com/owner/repo/issues/123",
+ "title": "Toolkit doctor: observed drift"
+ },
+ "summary": {
+ "findings_analyzed": 0,
+ "qualifying_findings": 0,
+ "warning_count": 0,
+ "error_count": 0
+ },
+ "runtime_notes": [],
+ "analysis": [
+ {
+ "finding_id": "observed.docs_quickstart_md.missing",
+ "category": "observed",
+ "path": "docs/quickstart.md",
+ "severity": "warning",
+ "state": "missing",
+ "qualifies_for_issue": true,
+ "summary": "Repository is missing the onboarding quickstart.",
+ "recommendation": "Add a real quickstart with first-run instructions for contributors.",
+ "evidence": [
+ "doctor-findings.json: observed.docs_quickstart_md.missing",
+ "docs/quickstart.md is absent in the checked out repository"
+ ]
+ }
+ ]
+}
+```
+
+Populate `issue.number`, `issue.url`, and `issue.title` with `null` when absent.
+`analysis[]` must include every analyzed observed finding, including
+`info`, with `qualifies_for_issue` set appropriately.
+
+## Failure Handling
+
+If you encounter a temporary tool or execution error while downloading
+artifacts, reading repository context, or building issue text:
+
+1. retry once
+2. if the retry succeeds, continue
+3. if the retry fails, write `doctor-report.json` with:
+ - `issue.action = "failed"`
+ - `issue.reason = "model_error"`
+4. add a precise note to `runtime_notes[]`
+5. exit with failure
+
+For all failure paths:
+
+- prefer a valid `doctor-report.json` over incomplete changes
+- do not create duplicate issues
+- do not mutate repository files
+
+## Final Checklist
+
+Before finishing, verify all of the following:
+
+- `doctor-report.json` exists and is valid JSON
+- the mirrored report copies exist in `/tmp/gh-aw/agent/`
+- `schema_version == "doctor-report/v1"`
+- issue decisions use only the allowed enums
+- safe outputs, if any, were used only after artifact validation and only when allowed by runtime gates
+- no repository files were modified other than `doctor-report.json` and temporary scratch files
diff --git a/.github/workflows/doctor.yml b/.github/workflows/doctor.yml
new file mode 100644
index 0000000..f5b0a69
--- /dev/null
+++ b/.github/workflows/doctor.yml
@@ -0,0 +1,320 @@
+name: doctor
+
+on:
+ schedule:
+ - cron: '0 2 * * *'
+ workflow_dispatch:
+ inputs:
+ force_analyze:
+ description: Force analyze outside configured analyze day
+ required: false
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+ actions: read
+
+env:
+ SOURCE_REPO: G-Core/agent-toolkit
+ REPORT_TZ: UTC
+ PYTHON_VERSION: '3.12'
+ ANALYZE_DAY: monday
+
+jobs:
+ collect:
+ runs-on: ubuntu-latest
+ outputs:
+ should_act: ${{ steps.collect.outputs.should_act }}
+ source_sha: ${{ steps.collect.outputs.source_sha }}
+ installed_source_sha: ${{ steps.collect.outputs.installed_source_sha }}
+ installed_runtime_sha: ${{ steps.collect.outputs.installed_runtime_sha }}
+ act_reason: ${{ steps.collect.outputs.act_reason }}
+ analyze_day: ${{ steps.collect.outputs.analyze_day }}
+ report_tz: ${{ steps.collect.outputs.report_tz }}
+ issues_enabled: ${{ steps.collect.outputs.issues_enabled }}
+ observed_findings_present: ${{ steps.collect.outputs.observed_findings_present }}
+ stats_artifact_name: ${{ steps.collect.outputs.stats_artifact_name }}
+ findings_artifact_name: ${{ steps.collect.outputs.findings_artifact_name }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Collect runtime inputs from GitHub API
+ id: collect_inputs
+ uses: actions/github-script@v8
+ env:
+ SOURCE_REPO: ${{ env.SOURCE_REPO }}
+ SOURCE_GITHUB_TOKEN: ${{ secrets.TOOLKIT_READ_TOKEN || secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const fs = require("fs");
+ const path = require("path");
+
+ const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/");
+ const repoMeta = await github.rest.repos.get({ owner, repo });
+ const repoDefaultBranch = repoMeta.data.default_branch;
+ const issuesEnabled = repoMeta.data.has_issues ? "true" : "false";
+
+ const now = new Date();
+ const reportDate = now.toISOString().slice(0, 10);
+ const reportDateStart = new Date(`${reportDate}T00:00:00Z`);
+ const windowTo = reportDateStart.toISOString();
+ const windowFrom = new Date(reportDateStart.getTime() - 24 * 60 * 60 * 1000).toISOString();
+ const collectedAt = now.toISOString();
+
+ const commits = await github.paginate(github.rest.repos.listCommits, {
+ owner,
+ repo,
+ since: windowFrom,
+ until: windowTo,
+ per_page: 100,
+ });
+ const dailyCommits = commits.map((item) => ({
+ sha: item.sha,
+ author: item.author?.login || item.commit?.author?.name || "unknown",
+ authored_at: item.commit?.author?.date || null,
+ message: item.commit?.message || "",
+ }));
+
+ const pullsAll = await github.paginate(github.rest.pulls.list, {
+ owner,
+ repo,
+ state: "all",
+ sort: "updated",
+ direction: "desc",
+ per_page: 100,
+ });
+ const inWindow = (value) => {
+ if (!value) {
+ return false;
+ }
+ return value >= windowFrom && value < windowTo;
+ };
+ const mapPull = (item) => ({
+ number: item.number,
+ author: item.user?.login || "unknown",
+ title: item.title || "",
+ state: item.state || "open",
+ created_at: item.created_at,
+ merged_at: item.merged_at,
+ closed_at: item.closed_at,
+ labels: (item.labels || []).map((label) => label.name),
+ assignees: (item.assignees || []).map((assignee) => assignee.login),
+ });
+ const dailyPRs = pullsAll
+ .filter((item) => inWindow(item.created_at) || inWindow(item.merged_at) || inWindow(item.closed_at))
+ .map(mapPull);
+
+ const issuesAll = await github.paginate(github.rest.issues.listForRepo, {
+ owner,
+ repo,
+ state: "all",
+ since: windowFrom,
+ per_page: 100,
+ });
+ const isPlainIssue = (item) => !item.pull_request;
+ const mapIssue = (item) => ({
+ number: item.number,
+ author: item.user?.login || "unknown",
+ title: item.title || "",
+ state: item.state || "open",
+ created_at: item.created_at,
+ closed_at: item.closed_at,
+ labels: (item.labels || []).map((label) =>
+ typeof label === "string" ? label : label.name
+ ),
+ assignees: (item.assignees || []).map((assignee) => assignee.login),
+ });
+ const dailyIssues = issuesAll
+ .filter((item) => isPlainIssue(item) && (inWindow(item.created_at) || inWindow(item.closed_at)))
+ .map(mapIssue);
+
+ const pullsOpen = await github.paginate(github.rest.pulls.list, {
+ owner,
+ repo,
+ state: "open",
+ per_page: 100,
+ });
+ const snapshotPRs = pullsOpen.map((item) => ({
+ number: item.number,
+ author: item.user?.login || "unknown",
+ title: item.title || "",
+ created_at: item.created_at,
+ days_open: Math.floor((Date.now() - Date.parse(item.created_at)) / 86400000),
+ labels: (item.labels || []).map((label) => label.name),
+ assignees: (item.assignees || []).map((assignee) => assignee.login),
+ }));
+
+ const issuesOpen = await github.paginate(github.rest.issues.listForRepo, {
+ owner,
+ repo,
+ state: "open",
+ per_page: 100,
+ });
+ const snapshotIssues = issuesOpen
+ .filter(isPlainIssue)
+ .map((item) => ({
+ number: item.number,
+ author: item.user?.login || "unknown",
+ title: item.title || "",
+ created_at: item.created_at,
+ assignees: (item.assignees || []).map((assignee) => assignee.login),
+ labels: (item.labels || []).map((label) =>
+ typeof label === "string" ? label : label.name
+ ),
+ }));
+
+ const activityPayload = {
+ commits: dailyCommits,
+ prs: dailyPRs,
+ issues: dailyIssues,
+ snapshot_prs: snapshotPRs,
+ snapshot_issues: snapshotIssues,
+ };
+
+ const workDir = path.join(process.env.RUNNER_TEMP, "doctor-inputs");
+ fs.mkdirSync(workDir, { recursive: true });
+ const activityPath = path.join(workDir, "activity.json");
+ fs.writeFileSync(activityPath, JSON.stringify(activityPayload));
+
+ core.setOutput("activity_path", activityPath);
+ core.setOutput("repo_default_branch", repoDefaultBranch);
+ core.setOutput("issues_enabled", issuesEnabled);
+ core.setOutput("collected_at", collectedAt);
+ core.setOutput("report_date", reportDate);
+ core.setOutput("window_from", windowFrom);
+ core.setOutput("window_to", windowTo);
+
+ - name: Checkout toolkit source
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ env.SOURCE_REPO }}
+ ref: doctor-v1
+ path: .doctor-source
+ token: ${{ secrets.TOOLKIT_READ_TOKEN || secrets.GITHUB_TOKEN }}
+
+ - name: Run collect
+ id: collect
+ env:
+ DOCTOR_REPO_ROOT: ${{ github.workspace }}
+ DOCTOR_OUTPUT_DIR: ${{ runner.temp }}/doctor
+ DOCTOR_SOURCE_ROOT: ${{ github.workspace }}/.doctor-source
+ DOCTOR_ACTIVITY_PATH: ${{ steps.collect_inputs.outputs.activity_path }}
+ DOCTOR_ISSUES_ENABLED: ${{ steps.collect_inputs.outputs.issues_enabled }}
+ DOCTOR_REPO_FULL_NAME: ${{ github.repository }}
+ DOCTOR_REPO_DEFAULT_BRANCH: ${{ steps.collect_inputs.outputs.repo_default_branch }}
+ DOCTOR_SOURCE_REPO: ${{ env.SOURCE_REPO }}
+ DOCTOR_SOURCE_BRANCH: doctor-v1
+ DOCTOR_COLLECTED_AT: ${{ steps.collect_inputs.outputs.collected_at }}
+ DOCTOR_REPORT_DATE: ${{ steps.collect_inputs.outputs.report_date }}
+ DOCTOR_WINDOW_FROM: ${{ steps.collect_inputs.outputs.window_from }}
+ DOCTOR_WINDOW_TO: ${{ steps.collect_inputs.outputs.window_to }}
+ DOCTOR_ANALYZE_DAY: ${{ env.ANALYZE_DAY }}
+ DOCTOR_REPORT_TZ: ${{ env.REPORT_TZ }}
+ run: python .doctor-source/workflows/runtime/collect.py
+
+ - name: Upload doctor-stats artifact
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ steps.collect.outputs.stats_artifact_name }}
+ path: ${{ runner.temp }}/doctor/doctor-stats.json
+ if-no-files-found: error
+
+ - name: Upload doctor-findings artifact
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ steps.collect.outputs.findings_artifact_name }}
+ path: ${{ runner.temp }}/doctor/doctor-findings.json
+ if-no-files-found: error
+
+ act:
+ needs: collect
+ if: ${{ needs.collect.outputs.should_act == 'true' }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ actions: read
+ steps:
+ - name: Checkout target repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.repository.default_branch }}
+
+ - name: Download doctor-findings artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.collect.outputs.findings_artifact_name }}
+ path: ${{ runner.temp }}/doctor-findings
+
+ - name: Checkout toolkit source
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ env.SOURCE_REPO }}
+ ref: doctor-v1
+ path: .doctor-source
+ token: ${{ secrets.TOOLKIT_READ_TOKEN || secrets.GITHUB_TOKEN }}
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Run act
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ DOCTOR_REPO_ROOT: ${{ github.workspace }}
+ DOCTOR_SOURCE_ROOT: ${{ github.workspace }}/.doctor-source
+ DOCTOR_FINDINGS_PATH: ${{ runner.temp }}/doctor-findings/doctor-findings.json
+ DOCTOR_SOURCE_SHA: ${{ needs.collect.outputs.source_sha }}
+ DOCTOR_INSTALLED_SOURCE_SHA: ${{ needs.collect.outputs.installed_source_sha }}
+ DOCTOR_INSTALLED_RUNTIME_SHA: ${{ needs.collect.outputs.installed_runtime_sha }}
+ DOCTOR_ACT_REASON: ${{ needs.collect.outputs.act_reason }}
+ DOCTOR_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ DOCTOR_SOURCE_REPO: ${{ env.SOURCE_REPO }}
+ DOCTOR_WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ run: python .doctor-source/workflows/act.py
+
+ analyze:
+ needs:
+ - collect
+ - act
+ if: >-
+ ${{
+ always() &&
+ needs.collect.result == 'success' &&
+ (needs.act.result == 'success' || needs.act.result == 'skipped')
+ }}
+ # Required for reusable gh-aw analyze with safe outputs. GitHub validates
+ # the caller permission envelope before analyze starts; the current
+ # compiled lock also requests discussions: write.
+ permissions:
+ actions: read
+ contents: read
+ issues: write
+ pull-requests: write
+ discussions: write
+ uses: G-Core/agent-toolkit/.github/workflows/doctor-analyze.lock.yml@doctor-v1
+ secrets: inherit
+ with:
+ force_analyze: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_analyze == 'true' }}
+ analyze_day: ${{ needs.collect.outputs.analyze_day }}
+ report_tz: ${{ needs.collect.outputs.report_tz }}
+ issues_enabled: ${{ needs.collect.outputs.issues_enabled }}
+ observed_findings_present: ${{ needs.collect.outputs.observed_findings_present }}
+ stats_artifact_name: ${{ needs.collect.outputs.stats_artifact_name }}
+ findings_artifact_name: ${{ needs.collect.outputs.findings_artifact_name }}
+ source_sha: ${{ needs.collect.outputs.source_sha }}
+ installed_source_sha: ${{ needs.collect.outputs.installed_source_sha }}
+ installed_runtime_sha: ${{ needs.collect.outputs.installed_runtime_sha }}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..61ac6ca
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,228 @@
+# AI Agent Instructions for FastEdge JS SDK
+
+## CRITICAL: Read Smart, Not Everything
+
+**DO NOT read all context files upfront.** This repository uses a **discovery-based context system** to minimize token usage while maximizing effectiveness.
+
+---
+
+## Getting Started: Discovery Pattern
+
+### Step 1: Read the Index (REQUIRED — ~130 lines)
+
+**First action when starting work:** Read `context/CONTEXT_INDEX.md`
+
+This lightweight file gives you:
+- Project quick start (what this repo does in 10 lines)
+- Documentation map organized by topic with sizes
+- Decision tree for what to read based on your task
+- Search patterns for finding information
+
+### Step 2: Read Based on Your Task (JUST-IN-TIME)
+
+Use the decision tree in CONTEXT_INDEX.md to determine what to read. **Only read what's relevant to your current task.**
+
+**Examples:**
+
+**Task: "Add a new CLI flag to fastedge-build"**
+- Read: `context/development/BUILD_SYSTEM.md` (CLI build section)
+- Read: `src/cli/fastedge-build/build.ts` (entry point)
+- Grep: `context/CHANGELOG.md` for similar past changes
+
+**Task: "Fix regex precompilation bug"**
+- Read: `context/architecture/COMPONENTIZE_PIPELINE.md` (Stage 2)
+- Read: `src/componentize/precompile.ts`
+- Run: `pnpm run test:unit:dev` to verify
+
+**Task: "Add new host API (e.g., cache)"**
+- Read: `context/architecture/RUNTIME_ARCHITECTURE.md` (builtins + WIT)
+- Read: `runtime/fastedge/builtins/fastedge.cpp` (template)
+- Run: `pnpm run generate:wit-world` after changes
+
+**Task: "Update TypeScript type definitions"**
+- Read: `types/` directory (authoritative API surface)
+- Run: `pnpm run build:types` to verify
+- Check: `github-pages/src/content/docs/reference/` if user-facing docs need updates
+
+### Step 3: Search, Don't Read Everything
+
+**Use grep and search tools** instead of reading large files linearly:
+
+- **CHANGELOG.md**: Will grow over time — always grep, never read end-to-end
+- **Architecture docs** (~160-170 lines): Read specific sections by heading
+- **Source code**: Use path aliases to navigate (`~componentize/`, `~utils/`, etc.)
+
+---
+
+## Decision Tree Reference
+
+**Quick lookup for common tasks:**
+
+| Task Type | What to Read |
+|-----------|-------------|
+| **Adding a CLI feature** | BUILD_SYSTEM + relevant CLI entry point |
+| **Fixing componentize bug** | COMPONENTIZE_PIPELINE + specific stage file |
+| **Adding runtime host API** | RUNTIME_ARCHITECTURE (builtins + WIT sections) |
+| **Modifying static assets** | COMPONENTIZE_PIPELINE (Build Types) + `src/server/static-assets/` |
+| **Updating type definitions** | `types/` directory + `github-pages/` reference docs |
+| **Adding an example** | Browse `examples/` for similar existing example |
+| **Changing build system** | BUILD_SYSTEM + `esbuild/` scripts |
+| **Working with WIT** | RUNTIME_ARCHITECTURE (WIT section) + `runtime/FastEdge-wit/` |
+| **Writing tests** | TESTING_GUIDE |
+| **Understanding the system** | PROJECT_OVERVIEW (~150 lines) |
+| **Updating docs site** | `github-pages/` (Astro, separate workspace) |
+| **Working on fastedge-init** | `src/cli/fastedge-init/` + PROJECT_OVERVIEW (Build Types) |
+
+---
+
+## Anti-Patterns (What NOT to Do)
+
+**Don't:** Read all 6 context docs upfront (~700 lines wasted if you only need one)
+**Don't:** Read `runtime/` C++ source for JS-only CLI changes
+**Don't:** Read `github-pages/` Astro source when working on SDK internals
+**Don't:** Read entire architecture docs when you need one specific section
+**Don't:** Modify `lib/*.wasm` directly — these are build artifacts
+
+**Do:** Read `context/CONTEXT_INDEX.md` first — always
+**Do:** Use grep to search CHANGELOG and large source files
+**Do:** Read `types/` for the authoritative public API surface
+**Do:** Read `examples/` for real-world usage patterns
+**Do:** Follow path aliases (`~componentize/`, `~utils/`) when navigating source
+
+---
+
+## Critical Working Practices
+
+### Task Checklists (ALWAYS USE)
+
+When starting any non-trivial task (multi-step, multiple files, features, etc.):
+
+1. Use `TaskCreate` to break work into discrete steps
+2. Mark tasks `in_progress` when starting, `completed` when done
+3. This helps track progress and prevents missed steps
+
+### Parallel Agents
+
+For independent work, spawn parallel agents:
+- Research different subsystems simultaneously
+- Run tests while writing docs
+- Read multiple source files at once
+
+### Documentation Maintenance
+
+When you make significant changes, update the relevant context docs:
+
+1. **After adding a feature:** Add a CHANGELOG.md entry
+2. **After changing architecture:** Update the relevant architecture doc
+3. **After changing build config:** Update BUILD_SYSTEM.md
+4. **After adding tests:** Verify TESTING_GUIDE.md is still accurate
+
+**CHANGELOG entry format:**
+```markdown
+## [YYYY-MM-DD] — Brief Description
+
+### Overview
+One sentence summary.
+
+### Changes
+- Bullet list of what changed
+```
+
+---
+
+## Context Organization
+
+```
+FastEdge-sdk-js/
+├── CLAUDE.md ← YOU ARE HERE
+├── context/
+│ ├── CONTEXT_INDEX.md ← Read first (discovery hub)
+│ ├── PROJECT_OVERVIEW.md ← New to codebase? Start here
+│ ├── CHANGELOG.md ← Search with grep
+│ ├── architecture/
+│ │ ├── COMPONENTIZE_PIPELINE.md ← JS→WASM pipeline (4 stages)
+│ │ └── RUNTIME_ARCHITECTURE.md ← StarlingMonkey, builtins, WIT
+│ └── development/
+│ ├── BUILD_SYSTEM.md ← esbuild, TypeScript, path aliases
+│ └── TESTING_GUIDE.md ← Jest setup, test organization
+├── src/ ← TypeScript source
+│ ├── cli/ ← 3 CLI tools
+│ ├── componentize/ ← Compilation pipeline
+│ ├── server/static-assets/ ← Static site support
+│ └── utils/ ← Shared utilities
+├── runtime/ ← StarlingMonkey + builtins (C++/Rust)
+├── types/ ← TypeScript declarations (public API)
+├── examples/ ← 13 standalone example apps
+├── github-pages/ ← Astro docs site (GitHub Pages)
+├── docs/ ← Pipeline docs (planned)
+├── esbuild/ ← Build scripts
+├── config/ ← Jest + ESLint config
+├── integration-tests/ ← CLI integration tests
+└── compiler/ ← Docker build environment
+```
+
+---
+
+## Search Tips
+
+**Find pipeline code:**
+```bash
+grep -r "componentize" src/
+grep -r "precompile" src/componentize/
+```
+
+**Find runtime builtins:**
+```bash
+grep -r "add_builtin" runtime/fastedge/
+grep -r "INIT_ONLY" runtime/
+```
+
+**Find test files:**
+```bash
+find src/ -name "*.test.ts"
+find integration-tests/ -name "*.test.*"
+```
+
+**Find path alias usage:**
+```bash
+grep -r "~componentize/" src/
+grep -r "~utils/" src/
+```
+
+**Find CLI argument definitions:**
+```bash
+grep -r "arg(" src/cli/
+```
+
+---
+
+## Quick Reference
+
+**Tech Stack:** TypeScript, esbuild, Jest, StarlingMonkey (SpiderMonkey), Wizer, JCO
+**Package:** `@gcoredev/fastedge-sdk-js` v2.1.0
+**Node:** >= 20, pnpm >= 10
+**License:** Apache-2.0
+
+**Common Commands:**
+
+| Command | Purpose |
+|---------|---------|
+| `pnpm run build:js` | Build CLI + libs + types (no runtime) |
+| `pnpm run build:dev` | Full build including runtime (slow) |
+| `pnpm run test:unit:dev` | Fast unit tests |
+| `pnpm run test:unit` | Unit + slow tests |
+| `pnpm run test:integration` | Integration tests |
+| `pnpm run typecheck` | Type checking only |
+| `pnpm run lint` | ESLint |
+| `pnpm run generate:wit-world` | Regenerate WIT bindings |
+
+---
+
+## Summary
+
+1. Read `context/CONTEXT_INDEX.md` first
+2. Use the decision tree to find relevant docs
+3. Read only what you need for your current task
+4. Use grep for CHANGELOG and large files
+5. Update context docs after significant changes
+6. Use TaskCreate for multi-step work
diff --git a/config/eslint/repo/.eslintrc.cjs b/config/eslint/repo/.eslintrc.cjs
index 79fa2a3..339546c 100644
--- a/config/eslint/repo/.eslintrc.cjs
+++ b/config/eslint/repo/.eslintrc.cjs
@@ -34,6 +34,7 @@ module.exports = {
'lib/**',
'**/runtime/fastedge/**',
'**/runtime/StarlingMonkey/**',
+ 'github-pages/**',
],
plugins: [
'@typescript-eslint',
@@ -75,7 +76,11 @@ module.exports = {
'import/group-exports': 'off',
'import/exports-last': 'off',
'import/no-default-export': 'off',
+ 'import/unambiguous': 'off',
'no-console': 'off',
+ 'no-return-await': 'off',
+ 'prefer-destructuring': 'off',
+ '@typescript-eslint/no-unused-vars': 'off',
},
},
{
diff --git a/config/jest/jest.config.js b/config/jest/jest.config.js
index 8f3ce6f..e2fa563 100644
--- a/config/jest/jest.config.js
+++ b/config/jest/jest.config.js
@@ -15,7 +15,7 @@ const config = {
testPathIgnorePatterns: [
'node_modules',
'dist',
- 'docs',
+ 'github-pages',
'runtime/StarlingMonkey/',
'runtime/fastedge/deps',
],
diff --git a/context/CHANGELOG.md b/context/CHANGELOG.md
new file mode 100644
index 0000000..84aac9d
--- /dev/null
+++ b/context/CHANGELOG.md
@@ -0,0 +1,39 @@
+# Changelog
+
+Track significant changes to the FastEdge JS SDK codebase.
+When this file grows large, use grep to search — don't read linearly.
+
+---
+
+## [2026-03-26] — Examples Consolidation
+
+### Overview
+Moved 6 code snippets from `github-pages/examples/` into `examples/` as standalone projects, eliminating duplication. The Astro docs site now imports example code from the main `examples/` folder via a Vite alias.
+
+### Changes
+- Created 6 new standalone examples: `hello-world`, `downstream-fetch`, `downstream-modify-response`, `headers`, `kv-store-basic`, `variables-and-secrets`
+- Added Vite resolve alias `@examples` → `../examples/` in `github-pages/astro.config.mjs`
+- Updated all 6 MDX files to import from `@examples//src/index.js?raw`
+- Renamed `basic.mdx` → `hello-world.mdx`
+- Reorganized `examples/README.md` into "Getting Started" and "Full Examples" sections
+- Deleted `github-pages/examples/` (no longer needed)
+
+---
+
+## [2026-03-25] — Agent Context Setup
+
+### Overview
+Added CLAUDE.md and context/ folder for agent discoverability. Renamed `docs/` to `github-pages/` per company-wide convention to make room for pipeline-compatible documentation.
+
+### Changes
+- Renamed `docs/` → `github-pages/` (Astro GitHub Pages site)
+- Updated references in `tsconfig.json`, `pnpm-workspace.yaml`, `.github/workflows/docs.yaml`, `config/jest/jest.config.js`
+- Created `context/` folder with discovery-based navigation system:
+ - `PROJECT_OVERVIEW.md` — lightweight project overview
+ - `architecture/COMPONENTIZE_PIPELINE.md` — JS→WASM compilation pipeline
+ - `architecture/RUNTIME_ARCHITECTURE.md` — StarlingMonkey, builtins, WIT
+ - `development/BUILD_SYSTEM.md` — esbuild, TypeScript, path aliases
+ - `development/TESTING_GUIDE.md` — Jest setup, test organization
+ - `CONTEXT_INDEX.md` — discovery hub
+ - `CHANGELOG.md` — this file
+- Created `CLAUDE.md` — agent onboarding instructions
diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md
new file mode 100644
index 0000000..6bc1a63
--- /dev/null
+++ b/context/CONTEXT_INDEX.md
@@ -0,0 +1,142 @@
+# Context Discovery Index
+
+## Quick Start
+
+- **Project:** FastEdge JS SDK + CLI toolchain
+- **Package:** `@gcoredev/fastedge-sdk-js` v2.1.0
+- **Purpose:** Compile JS/TS apps into WASM components for Gcore FastEdge edge platform
+- **CLIs:** `fastedge-build` (compiler), `fastedge-init` (scaffolder), `fastedge-assets` (static manifests)
+- **Runtime:** StarlingMonkey (SpiderMonkey → WASM) with custom C++ builtins
+- **WIT World:** `gcore:fastedge/reactor` — imports http, dictionary, secret, key-value; exports http-handler
+- **Build:** esbuild + Wizer + JCO → WASM Component Model
+- **Tests:** Jest (unit in `src/**/__tests__/`, integration in `integration-tests/`)
+- **Node:** >= 22, pnpm >= 10
+
+---
+
+## Documentation Map
+
+### Architecture (read when modifying internal structure)
+
+| Document | Lines | Purpose |
+|----------|-------|---------|
+| `architecture/COMPONENTIZE_PIPELINE.md` | ~160 | The 4-stage JS→WASM compilation chain (esbuild → regex precompile → Wizer → JCO). Read when working on `fastedge-build` or `src/componentize/`. |
+| `architecture/RUNTIME_ARCHITECTURE.md` | ~170 | StarlingMonkey runtime, C++ builtins (env, kv, secret, fs, console), host-api bridge, WIT definitions, build process. Read when working on `runtime/`. |
+
+### Development (read when implementing or testing)
+
+| Document | Lines | Purpose |
+|----------|-------|---------|
+| `development/BUILD_SYSTEM.md` | ~110 | esbuild scripts, TypeScript config (3 tsconfigs), path aliases, npm package contents. Read when changing build configuration. |
+| `development/TESTING_GUIDE.md` | ~90 | Jest setup, unit vs integration tests, test organization, running tests. Read when adding or modifying tests. |
+
+### Reference (search on-demand)
+
+| Document | Lines | Purpose |
+|----------|-------|---------|
+| `PROJECT_OVERVIEW.md` | ~150 | Lightweight project overview — architecture, key modules, dev setup, common commands. Read when new to the codebase. |
+| `CHANGELOG.md` | ~25+ | Change history. Use grep, don't read linearly as this file grows. |
+
+### External (not in context/)
+
+| Resource | Location | Purpose |
+|----------|----------|---------|
+| TypeScript declarations | `types/` | Authoritative public API surface (env, fs, kv, secret, globals) |
+| GitHub Pages docs | `github-pages/` | Astro-based user-facing documentation site |
+| Pipeline docs | `docs/` (planned) | Human-facing docs feeding into fastedge-plugin |
+| Examples | `examples/` | 13 standalone example apps showing real patterns |
+
+---
+
+## Decision Tree: What Should I Read?
+
+### Adding a CLI Feature (flag, option, subcommand)
+1. Read `development/BUILD_SYSTEM.md` (CLI build section)
+2. Read the relevant CLI entry point: `src/cli/fastedge-{build,init,assets}/`
+3. Grep `CHANGELOG.md` for similar past changes
+
+### Fixing a Componentize Bug
+1. Read `architecture/COMPONENTIZE_PIPELINE.md`
+2. Read the specific pipeline stage file (see Key Files table in that doc)
+3. Run `pnpm run test:unit:dev` to verify
+
+### Adding a Runtime Host API
+1. Read `architecture/RUNTIME_ARCHITECTURE.md` (builtins + host-api + WIT sections)
+2. Read existing builtin as template: `runtime/fastedge/builtins/fastedge.cpp`
+3. Update WIT: `runtime/FastEdge-wit/` + `runtime/fastedge/host-api/wit/`
+4. Run `pnpm run generate:wit-world`
+
+### Modifying Static Assets System
+1. Read `architecture/COMPONENTIZE_PIPELINE.md` (Build Types section)
+2. Read `src/server/static-assets/` source files
+3. Check tests in `src/server/static-assets/**/__tests__/`
+
+### Updating Type Definitions
+1. Read `types/` directory (authoritative API surface)
+2. Check `github-pages/src/content/docs/reference/` for user-facing docs
+3. Run `pnpm run build:types` to verify
+
+### Adding a New Example
+1. Browse `examples/` for an existing example similar to your target
+2. Each example is standalone with its own `package.json`
+3. Install SDK via `npm install --save-dev @gcoredev/fastedge-sdk-js`
+
+### Changing the Build System
+1. Read `development/BUILD_SYSTEM.md`
+2. Check `esbuild/cli-binaries.js` and `esbuild/fastedge-libs.js`
+3. Verify path aliases match in `tsconfig.json` and `config/jest/jest.config.js`
+
+### Working with WIT Definitions
+1. Read `architecture/RUNTIME_ARCHITECTURE.md` (WIT section)
+2. Read `runtime/FastEdge-wit/world.wit` (top-level world)
+3. Read `runtime/fastedge/host-api/wit/` (local bindings)
+4. Run `pnpm run generate:wit-world` after changes
+
+### Writing Tests
+1. Read `development/TESTING_GUIDE.md`
+2. Follow co-located pattern: `__tests__/` next to source
+3. Integration tests go in `integration-tests/`
+
+### Understanding the System (new to codebase)
+1. Read `PROJECT_OVERVIEW.md` (~150 lines)
+2. Skim `architecture/COMPONENTIZE_PIPELINE.md` (pipeline diagram)
+3. Browse `examples/` for usage patterns
+
+### Modifying esbuild Configuration
+1. Read `development/BUILD_SYSTEM.md` (esbuild sections)
+2. Read `esbuild/cli-binaries.js` or `esbuild/fastedge-libs.js`
+3. Check external packages list — adding/removing externals affects bundle size
+
+### Working on fastedge-init Scaffolder
+1. Read `src/cli/fastedge-init/` source files
+2. Read `PROJECT_OVERVIEW.md` (Build Types section)
+3. Check `integration-tests/` for CLI tests
+
+### Updating the Docs Site (GitHub Pages)
+1. The Astro site is in `github-pages/` (separate pnpm workspace)
+2. Code examples in the docs are imported from `examples/` via a Vite alias (`@examples` → `../examples/`). Edit the source in `examples//src/index.js` — the docs site picks it up automatically.
+3. Changes to `github-pages/**` trigger the docs deploy workflow
+4. Run `cd github-pages && pnpm build` to verify locally
+
+---
+
+## Search Tips
+
+- **Don't** read `CHANGELOG.md` linearly — grep for keywords
+- **Grep patterns:**
+ - `grep -r "componentize" src/` — find pipeline references
+ - `grep -r "INIT_ONLY" runtime/` — find initialization-only APIs
+ - `grep -r "add_builtin" runtime/` — find registered builtins
+ - `grep -r "external:" esbuild/` — find externalized packages
+- **File discovery:** `find src/ -name "*.test.ts"` to locate tests
+
+## Documentation Size Reference
+
+| Category | Documents | Total Lines |
+|----------|-----------|-------------|
+| Architecture | 2 docs | ~330 |
+| Development | 2 docs | ~200 |
+| Reference | 2 docs | ~175 |
+| **Total** | **6 docs** | **~700** |
+
+All documents are designed for single-sitting reads. No doc exceeds 170 lines.
diff --git a/context/PROJECT_OVERVIEW.md b/context/PROJECT_OVERVIEW.md
new file mode 100644
index 0000000..257bf52
--- /dev/null
+++ b/context/PROJECT_OVERVIEW.md
@@ -0,0 +1,151 @@
+# FastEdge JS SDK - Project Overview
+
+## Purpose
+
+The FastEdge JS SDK (`@gcoredev/fastedge-sdk-js`) enables developers to build JavaScript/TypeScript applications that compile to WebAssembly components for deployment on Gcore's FastEdge edge platform. It provides three CLI tools for the development lifecycle and a custom JavaScript runtime built on SpiderMonkey.
+
+**npm package:** `@gcoredev/fastedge-sdk-js` v2.1.0
+**Repository:** `G-Core/FastEdge-sdk-js`
+**License:** Apache-2.0
+
+## CLI Tools
+
+| Tool | Purpose | Entry Point |
+|------|---------|-------------|
+| `fastedge-build` | Compile JS/TS to WASM component | `src/cli/fastedge-build/build.ts` |
+| `fastedge-init` | Interactive project scaffolding wizard | `src/cli/fastedge-init/init.ts` |
+| `fastedge-assets` | Static asset manifest generation | `src/cli/fastedge-assets/asset-cli.ts` |
+
+## Architecture Overview
+
+### Compilation Pipeline (fastedge-build)
+
+```
+JS/TS source
+ → esbuild bundle (single file)
+ → regex precompilation (Unicode → ASCII for SpiderMonkey)
+ → Wizer pre-initialization (snapshot with fastedge-runtime.wasm)
+ → JCO componentization (WASI preview1 adapter)
+ → output .wasm component
+```
+
+See `context/architecture/COMPONENTIZE_PIPELINE.md` for full details.
+
+### Runtime
+
+The runtime is based on **StarlingMonkey** (Mozilla SpiderMonkey compiled to WASM) with custom C++ builtins providing FastEdge-specific APIs (env, kv, secret, fs, console). The runtime is defined by the `gcore:fastedge` WIT world.
+
+See `context/architecture/RUNTIME_ARCHITECTURE.md` for full details.
+
+### Build Types
+
+- **`http`** — Standard HTTP event handler (`addEventListener('fetch', ...)`)
+- **`static`** — Static site serving (assets embedded in WASM via manifest)
+
+## Key Modules
+
+| Directory | Purpose |
+|-----------|---------|
+| `src/cli/` | CLI entry points (3 tools) |
+| `src/componentize/` | JS→WASM compilation pipeline |
+| `src/server/static-assets/` | Asset manifest, loader, embedded static server |
+| `src/utils/` | Shared utilities (paths, config, logging) |
+| `src/constants/` | Project constants (`.fastedge` directory) |
+| `runtime/fastedge/` | StarlingMonkey build + C++ builtins + host-api + WIT |
+| `runtime/StarlingMonkey/` | Git submodule (SpiderMonkey-based JS engine) |
+| `runtime/FastEdge-wit/` | WIT world definition (`gcore:fastedge`) |
+| `types/` | TypeScript declarations (authoritative public API surface) |
+| `github-pages/` | Astro documentation site (GitHub Pages) |
+| `examples/` | Standalone example apps (13 examples: 6 getting-started + 7 full) |
+| `config/` | Jest + ESLint configurations |
+| `esbuild/` | esbuild build scripts |
+| `compiler/` | Docker compiler for CI/CD |
+
+## Package Exports
+
+**Binaries:** `fastedge-build`, `fastedge-init`, `fastedge-assets`
+**Library:** `lib/index.js` (re-exports `create-static-server.js`)
+**Runtime:** `lib/fastedge-runtime.wasm`, `lib/preview1-adapter.wasm`
+**Types:** `types/index.d.ts` → env, fs, kv, secret, globals
+
+## Application Model
+
+FastEdge apps use a Service Worker-style API:
+
+```js
+async function app(event) {
+ const request = event.request;
+ return new Response(`You made a request to ${request.url}`);
+}
+
+addEventListener('fetch', (event) => {
+ event.respondWith(app(event));
+});
+```
+
+**Constraint:** `event.respondWith()` must be called synchronously within the event listener. The handler passed to it can be async.
+
+## Runtime APIs (available in WASM)
+
+| Import | API |
+|--------|-----|
+| `fastedge::env` | `getEnv(name)` — environment variables |
+| `fastedge::kv` | `KvStore.open(name)` → get, scan, zrangeByScore, zscan, bfExists |
+| `fastedge::secret` | `getSecret(name)` — deployment-time secrets |
+| `fastedge::fs` | Filesystem access for embedded static assets |
+| Global | `fetch`, `Request`, `Response`, `Headers`, `URL`, `crypto`, streams, timers |
+
+## Development Setup
+
+### Prerequisites
+
+- Node >= 22, pnpm >= 10
+- Rust + `wasm32-wasi` target
+- wasi-sdk v20 (at `/opt/wasi-sdk/`)
+- binaryen, cbindgen, build-essential
+
+### First Time
+
+```sh
+git submodule update --recursive --init
+pnpm install
+```
+
+### Common Commands
+
+| Command | Purpose |
+|---------|---------|
+| `pnpm run build:js` | Build CLI + libs + types (no runtime rebuild) |
+| `pnpm run build:dev` | Full build including runtime (slow) |
+| `pnpm run test:unit:dev` | Fast unit tests |
+| `pnpm run test:unit` | Unit tests + slow tests |
+| `pnpm run test:integration` | Integration tests |
+| `pnpm run typecheck` | Type checking only |
+| `pnpm run lint` | ESLint |
+
+### Path Aliases (tsconfig.json)
+
+| Alias | Maps To |
+|-------|---------|
+| `~componentize/*` | `src/componentize/*` |
+| `~utils/*` | `src/utils/*` |
+| `~static-assets/*` | `src/server/static-assets/*` |
+| `~fastedge-build/*` | `src/cli/fastedge-build/*` |
+| `~fastedge-init/*` | `src/cli/fastedge-init/*` |
+| `~fastedge-assets/*` | `src/cli/fastedge-assets/*` |
+| `~constants/*` | `src/constants/*` |
+
+## Project Configuration
+
+- **`.fastedge/build-config.js`** — Per-project build config (created by `fastedge-init`)
+- **`tsconfig.json`** — Main TypeScript config (noEmit, path aliases)
+- **`tsconfig.build.json`** — Type declaration generation
+- **`tsconfig.typecheck.json`** — Type checking without emit
+
+## Philosophy
+
+- **WASM Component Model** — produces standard components (not core modules)
+- **StarlingMonkey runtime** — SpiderMonkey-based (not V8, not QuickJS)
+- **Type-safe** — TypeScript throughout, esbuild for bundling, tsc for types only
+- **Service Worker API** — familiar pattern for web developers
+- **Edge-first** — designed for cold-start performance via Wizer pre-initialization
diff --git a/context/architecture/COMPONENTIZE_PIPELINE.md b/context/architecture/COMPONENTIZE_PIPELINE.md
new file mode 100644
index 0000000..c725733
--- /dev/null
+++ b/context/architecture/COMPONENTIZE_PIPELINE.md
@@ -0,0 +1,159 @@
+# Componentize Pipeline
+
+## Overview
+
+The componentize pipeline is the core of `fastedge-build`. It converts a JavaScript/TypeScript source file into a WASM Component Model binary that can run on the FastEdge edge platform.
+
+**Entry point:** `src/componentize/componentize.ts` → `componentize(jsInput, output, opts)`
+**Called by:** `src/cli/fastedge-build/config-build.ts` → `buildWasm()` / `buildFromConfig()`
+
+## Pipeline Stages
+
+```
+ ┌─────────────────┐
+ │ JS/TS Source │
+ └────────┬────────┘
+ │
+ ┌────────▼────────┐
+ Stage 1 │ esbuild Bundle │ getJsInputContents()
+ └────────┬────────┘
+ │
+ ┌────────▼────────┐
+ Stage 2 │ Regex Precompile│ precompile()
+ └────────┬────────┘
+ │
+ ┌────────▼────────┐
+ Stage 3 │ Wizer Init │ spawnSync(wizer, ...)
+ │ + fastedge- │
+ │ runtime.wasm │
+ └────────┬────────┘
+ │
+ ┌────────▼────────┐
+ Stage 4 │ JCO Component │ componentNew()
+ │ + preview1 │
+ │ adapter.wasm │
+ └────────┬────────┘
+ │
+ ┌────────▼────────┐
+ │ Output .wasm │
+ └─────────────────┘
+```
+
+### Stage 1: esbuild Bundling
+
+**File:** `src/componentize/get-js-input.ts` → `getJsInputContents()`
+**Bundler:** `src/componentize/es-bundle.ts` → `esBundle()`
+
+Bundles the user's JS/TS source into a single file using esbuild. This resolves all imports and produces a self-contained script ready for WASM embedding.
+
+- Controlled by `preBundleJSInput` option (default: `true`)
+- If `false`, reads the file directly without bundling
+
+### Stage 2: Regex Precompilation
+
+**File:** `src/componentize/precompile.ts` → `precompile()`
+**Origin:** Adapted from Fastly's `js-compute-runtime` (Apache 2.0)
+
+**Why this exists:** SpiderMonkey inside StarlingMonkey has limited regex support. Unicode property escapes (`\p{...}`) and other advanced regex features need transformation to ASCII-compatible equivalents before the runtime can handle them.
+
+**How it works:**
+1. Parses JS source with `acorn` (AST)
+2. Walks AST with `acorn-walk` finding all regex literals
+3. Transforms each regex using `regexpu-core` (Unicode → ASCII)
+4. Uses `magic-string` to replace regexes in-place
+5. Prepends a precompilation block that exercises each regex with ASCII and UTF-8 strings — this ensures SpiderMonkey interns and compiles them during Wizer initialization rather than at request time
+
+### Stage 3: Wizer Pre-initialization
+
+**Tool:** `@bytecodealliance/wizer`
+**Input:** `lib/fastedge-runtime.wasm` (StarlingMonkey engine) + bundled JS
+**Output:** Pre-initialized core WASM module
+
+Wizer creates a snapshot of the StarlingMonkey runtime with the user's JavaScript already loaded and executed up to the point of the `addEventListener('fetch', ...)` call. This eliminates cold-start JavaScript parsing and initialization at request time.
+
+**Key flags:**
+- `--allow-wasi` — enables WASI imports
+- `--wasm-bulk-memory=true` — required for StarlingMonkey
+- `--wasm-reference-types=true` — required for StarlingMonkey
+- `--inherit-env=true` — passes environment to initialization
+- `-r _start=wizer.resume` — entry point mapping
+
+**Environment variables:**
+- `ENABLE_PBL` — enables pre-built library mode (optional)
+
+### Stage 4: JCO Component Composition
+
+**Tool:** `@bytecodealliance/jco` → `componentNew()`
+**Adapter:** `lib/preview1-adapter.wasm`
+
+Wraps the Wizer output (a core WASM module) into a WASM Component Model component by attaching the WASI preview1 adapter. This makes the module compatible with any WASM Component Model host (including the FastEdge CDN's Wasmtime runtime).
+
+After composition, `addWasmMetadata()` (`src/componentize/add-wasm-metadata.ts`) stamps the component with package metadata from `package.json`.
+
+## Build Types
+
+### HTTP Handler (`type: 'http'`)
+
+Standard path: source → componentize → output.wasm
+
+### Static Site (`type: 'static'`)
+
+Extended path: source → `createStaticAssetsManifest()` → componentize → output.wasm
+
+For static builds, `createStaticAssetsManifest()` (`src/server/static-assets/asset-manifest/create-manifest.ts`) scans the project's static files, inlines them into a manifest, and generates an entry point that serves them using `create-static-server.ts`.
+
+## Configuration
+
+### CLI Arguments (`fastedge-build`)
+
+```
+fastedge-build