Squashed '.stage-pilot/' content from commit 2c0510c

git-subtree-dir: .stage-pilot
git-subtree-split: 2c0510c68add43c50adcdedb30c174e68e730395
This commit is contained in:
2026-06-03 08:56:02 +00:00
commit 9339d3d503
100 changed files with 8843 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
log() {
printf '[bootstrap] %s\n' "$*"
}
fail() {
printf '[bootstrap] ERROR: %s\n' "$*" >&2
exit 1
}
current_os() {
uname -s
}
HOST_ROOT="${1:-$(pwd)}"
ensure_host_structure() {
# active SDLC 문서 루트만 생성한다.
# .github/.claude 하위 파일은 install.sh가 source/에서 복사하며 필요한 디렉터리를 함께 만든다.
local dirs=(
"docs/discovery"
"docs/srs"
"docs/batches"
"docs/releases"
)
for dir in "${dirs[@]}"; do
mkdir -p "${HOST_ROOT}/${dir}"
done
}
report_github_structure() {
if [ ! -d "${HOST_ROOT}/.github" ]; then
return 0
fi
log "Host .github structure:"
if command -v find >/dev/null 2>&1; then
find "${HOST_ROOT}/.github" -maxdepth 2 -mindepth 1 | sed "s#${HOST_ROOT}/##" | sort
else
log "find command not available; skipping structure scan."
fi
}
main() {
os_name="$(current_os)"
[ -d "${HOST_ROOT}" ] || fail "Host root not found: ${HOST_ROOT}"
log "Host root: ${HOST_ROOT}"
ensure_host_structure
# Intentionally do not install dependencies in bootstrap.
# This script only prepares the host directory layout.
log "${os_name} detected. Skipping Python/Node installation by design."
report_github_structure
if command -v python3 >/dev/null 2>&1; then
log "python3: $(python3 --version 2>&1)"
else
log "python3: not found"
fi
if command -v node >/dev/null 2>&1; then
log "node: $(node --version 2>&1)"
else
log "node: not found"
fi
log "Bootstrap completed successfully."
}
main "$@"
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: install.sh [--dry-run] [HOST_ROOT] [POLICY]
source/ 아래의 모든 파일을 호스트 저장소 루트로 복사한다.
source/.github/... -> HOST_ROOT/.github/...
source/.claude/... -> HOST_ROOT/.claude/...
단, source/instruction.md는 도구 중립 단일 원본으로 다음 두 복사본으로 fan-out 한다.
-> HOST_ROOT/.github/copilot-instructions.md (Copilot용, verbatim 복사)
-> HOST_ROOT/CLAUDE.md 의 StagePilot 마커 블록 (Claude용)
(instruction.md 자체는 루트로 그대로 복사하지 않는다.)
또한 .stage-pilot/skills를 .github/skills로 동기화한다.
.stage-pilot/skills/... -> HOST_ROOT/.github/skills/...
Options:
--dry-run 변경 없이 복사/생성 계획만 출력한다.
-h, --help 도움말을 출력한다.
Arguments:
HOST_ROOT 대상 호스트 저장소 루트 (default: 현재 디렉터리)
POLICY 기존 파일 충돌 정책: replace|preserve|fail
(default: STAGEPILOT_CONFLICT_POLICY 또는 preserve)
EOF
}
log() {
printf '[install] %s\n' "$*"
}
fail() {
printf '[install] ERROR: %s\n' "$*" >&2
exit 1
}
# pwd -P로 물리 경로를 잡아 심링크(.stage-pilot/* 등)를 실제 위치로 해석한다.
# 그렇지 않으면 SOURCE_DIR가 심링크가 되어 `find`가 내려가지 못한다.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
PACKAGE_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd -P)"
SOURCE_DIR="${PACKAGE_ROOT}/source"
SKILLS_SOURCE_DIR="${PACKAGE_ROOT}/skills"
DRY_RUN=0
ARGS=()
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
DRY_RUN=1
;;
-h|--help)
usage
exit 0
;;
--*)
fail "Unknown option: $1"
;;
*)
ARGS+=("$1")
;;
esac
shift
done
HOST_ROOT="${ARGS[0]:-$(pwd)}"
POLICY="${ARGS[1]:-${STAGEPILOT_CONFLICT_POLICY:-preserve}}"
TARGET_CLAUDE="${HOST_ROOT}/CLAUDE.md"
INSTRUCTION_SOURCE="${SOURCE_DIR}/instruction.md"
STAGEPILOT_BEGIN="<!-- STAGEPILOT:BEGIN -->"
STAGEPILOT_END="<!-- STAGEPILOT:END -->"
case "${POLICY}" in
replace|preserve|fail)
;;
*)
fail "Invalid conflict policy '${POLICY}'. Allowed: replace|preserve|fail"
;;
esac
[ -d "${SOURCE_DIR}" ] || fail "Source directory not found: ${SOURCE_DIR}"
[ -d "${HOST_ROOT}" ] || fail "Host root not found: ${HOST_ROOT}"
copied=0
skipped=0
conflicted=0
failed=0
# source/<rel> 파일 하나를 HOST_ROOT/<rel>로 정책에 맞춰 복사한다.
copy_one() {
local src_file="$1"
local rel_path="$2"
local dst_file="${HOST_ROOT}/${rel_path}"
local dst_dir
dst_dir="$(dirname "${dst_file}")"
if [ ! -e "${dst_file}" ]; then
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] copy: ${rel_path}"
else
mkdir -p "${dst_dir}"
cp -a "${src_file}" "${dst_file}"
fi
copied=$((copied + 1))
return
fi
if cmp -s "${src_file}" "${dst_file}"; then
skipped=$((skipped + 1))
return
fi
conflicted=$((conflicted + 1))
case "${POLICY}" in
replace)
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] replace: ${rel_path}"
else
mkdir -p "${dst_dir}"
cp -a "${src_file}" "${dst_file}"
fi
copied=$((copied + 1))
;;
preserve)
log "preserve existing: ${rel_path}"
skipped=$((skipped + 1))
;;
fail)
printf '[install] CONFLICT: %s\n' "${rel_path}" >&2
failed=$((failed + 1))
;;
esac
}
# 1) source/ 전체를 호스트 루트로 복사 (.github, .claude 등 구조 보존)
# 단, instruction.md는 단일 원본이므로 루트로 직접 복사하지 않고 아래에서 fan-out 한다.
while IFS= read -r -d '' src_file; do
rel_path="${src_file#${SOURCE_DIR}/}"
if [ "${rel_path}" = "instruction.md" ]; then
continue
fi
copy_one "${src_file}" "${rel_path}"
done < <(find "${SOURCE_DIR}" -type f -print0 | sort -z)
# 1-b) instruction.md -> .github/copilot-instructions.md (Copilot 매직 파일명)로 fan-out
if [ -f "${INSTRUCTION_SOURCE}" ]; then
copy_one "${INSTRUCTION_SOURCE}" ".github/copilot-instructions.md"
fi
# 1-c) .stage-pilot/skills -> .github/skills 동기화 (심볼릭 링크 미사용)
if [ -d "${SKILLS_SOURCE_DIR}" ]; then
while IFS= read -r -d '' src_file; do
rel_path="${src_file#${SKILLS_SOURCE_DIR}/}"
copy_one "${src_file}" ".github/skills/${rel_path}"
done < <(find "${SKILLS_SOURCE_DIR}" -type f -print0 | sort -z)
else
log "skills source not found: ${SKILLS_SOURCE_DIR}"
fi
# 2) CLAUDE.md: instruction.md를 단일 원본으로 StagePilot 블록 생성/갱신.
# 호스트가 직접 추가한 CLAUDE.md 내용은 마커 밖에 그대로 보존한다.
generate_claude_md() {
[ -f "${INSTRUCTION_SOURCE}" ] || { log "instruction.md 없음; CLAUDE.md 생략"; return 0; }
local payload output
payload="$(mktemp)"
output="$(mktemp)"
# 원본에 이미 마커가 있으면 그 블록만, 없으면 파일 전체를 payload로 사용한다.
if grep -Fq "${STAGEPILOT_BEGIN}" "${INSTRUCTION_SOURCE}" && grep -Fq "${STAGEPILOT_END}" "${INSTRUCTION_SOURCE}"; then
awk -v b="${STAGEPILOT_BEGIN}" -v e="${STAGEPILOT_END}" '
$0 == b { f=1; next }
$0 == e { f=0; exit }
f { print }
' "${INSTRUCTION_SOURCE}" > "${payload}"
else
cat "${INSTRUCTION_SOURCE}" > "${payload}"
fi
if [ ! -e "${TARGET_CLAUDE}" ]; then
{
printf '%s\n' "${STAGEPILOT_BEGIN}"
cat "${payload}"
printf '%s\n' "${STAGEPILOT_END}"
} > "${output}"
elif grep -Fq "${STAGEPILOT_BEGIN}" "${TARGET_CLAUDE}" && grep -Fq "${STAGEPILOT_END}" "${TARGET_CLAUDE}"; then
awk -v b="${STAGEPILOT_BEGIN}" -v e="${STAGEPILOT_END}" -v p="${payload}" '
$0 == b { print; while ((getline line < p) > 0) print line; f=1; next }
$0 == e { f=0; print; next }
!f { print }
' "${TARGET_CLAUDE}" > "${output}"
else
cat "${TARGET_CLAUDE}" > "${output}"
{
printf '\n## StagePilot\n'
printf '%s\n' "${STAGEPILOT_BEGIN}"
cat "${payload}"
printf '%s\n' "${STAGEPILOT_END}"
} >> "${output}"
fi
if [ "${DRY_RUN}" -eq 1 ]; then
if command -v diff >/dev/null 2>&1 && [ -e "${TARGET_CLAUDE}" ]; then
log "[dry-run] CLAUDE.md diff:"
diff -u "${TARGET_CLAUDE}" "${output}" || true
else
log "[dry-run] create/update: CLAUDE.md"
fi
else
cp -a "${output}" "${TARGET_CLAUDE}"
log "CLAUDE.md StagePilot 블록 갱신"
fi
rm -f "${payload}" "${output}"
}
generate_claude_md
log "host_root=${HOST_ROOT}"
log "source=${SOURCE_DIR}"
log "policy=${POLICY}"
log "copied=${copied} skipped=${skipped} conflicted=${conflicted}"
if [ "${failed}" -ne 0 ]; then
if [ "${DRY_RUN}" -eq 1 ]; then
fail "[dry-run] policy=fail에서 충돌이 발생합니다."
fi
fail "policy=fail 충돌로 설치를 중단합니다."
fi
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] 디렉터리 골격 생성 단계 생략. 변경된 파일 없음."
exit 0
fi
# 3) 디렉터리 골격 생성 (docs/discovery|srs|batches|releases 등)
BOOTSTRAP_SCRIPT="${PACKAGE_ROOT}/tools/bootstrap.sh"
[ -f "${BOOTSTRAP_SCRIPT}" ] || fail "Bootstrap script not found: ${BOOTSTRAP_SCRIPT}"
/bin/bash "${BOOTSTRAP_SCRIPT}" "${HOST_ROOT}"
log "Install completed successfully."
+913
View File
@@ -0,0 +1,913 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
import re
import sys
try:
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover
ZoneInfo = None
RUNTIME_CHOICES = (
"cli",
"api-service",
"web-app",
"worker",
"library",
"mixed",
"other",
)
INTERFACE_TYPE_CHOICES = (
"http-api",
"cli",
"event",
"file",
"internal-service",
"other",
)
UPPERCASE_TOKENS = {
"api": "API",
"http": "HTTP",
"cli": "CLI",
"id": "ID",
"io": "I/O",
"json": "JSON",
"sql": "SQL",
"ui": "UI",
"ux": "UX",
"url": "URL",
}
STORAGE_BACKEND_HINTS = (
(("postgresql", "postgres", "psql"), "PostgreSQL"),
(("mysql", "mariadb"), "MySQL"),
(("sqlite",), "SQLite"),
(("mongodb", "mongo"), "MongoDB"),
(("dynamodb",), "DynamoDB"),
(("redis",), "Redis"),
(("s3", "blob storage", "object storage"), "Object Storage"),
)
@dataclass(frozen=True)
class Entrypoint:
name: str
purpose: str
@dataclass(frozen=True)
class TopLevelArea:
path: str
responsibility: str
@dataclass(frozen=True)
class InterfaceContractHint:
name: str
interface_type: str
actors: str
purpose: str
@dataclass(frozen=True)
class EntityHint:
name: str
purpose: str
@dataclass(frozen=True)
class CompatibilityRuleHint:
interface_name: str
rule: str
@dataclass(frozen=True)
class InterfaceDetailHint:
interface_name: str
value: str
@dataclass(frozen=True)
class SeedAnswers:
repository_name: str
display_name: str
summary: str
primary_domain: str
tech_stack: list[str]
primary_runtime: str
primary_entrypoints: list[Entrypoint]
planned_top_level_areas: list[TopLevelArea]
interface_contracts: list[InterfaceContractHint]
interface_inputs: list[InterfaceDetailHint]
interface_outputs: list[InterfaceDetailHint]
interface_errors: list[InterfaceDetailHint]
core_entities: list[EntityHint]
persistence_backend: str
compatibility_rules: list[CompatibilityRuleHint]
known_unknowns: list[str]
capture_mode: str
def fail(message: str) -> int:
print(f"[bootstrap-seed] ERROR: {message}", file=sys.stderr)
return 1
def log(message: str) -> None:
print(f"[bootstrap-seed] {message}")
def default_display_name(repository_name: str) -> str:
words = re.sub(r"[-_]+", " ", repository_name.strip()).split()
if not words:
return "Project"
return " ".join(word.capitalize() for word in words)
def kst_timestamp() -> str:
if ZoneInfo is not None:
current = datetime.now(ZoneInfo("Asia/Seoul"))
else: # pragma: no cover
current = datetime.now(timezone(timedelta(hours=9)))
return current.strftime("%Y-%m-%d %H:%M")
def yaml_scalar(value: str) -> str:
escaped = value.replace("'", "''")
return f"'{escaped}'"
def parse_csv_list(raw_value: str) -> list[str]:
return [item.strip() for item in raw_value.split(",") if item.strip()]
def parse_pair(raw_value: str, label: str) -> tuple[str, str]:
if "::" not in raw_value:
raise ValueError(f"{label} entries must use 'name :: purpose' format: {raw_value}")
left, right = raw_value.split("::", 1)
left = left.strip()
right = right.strip()
if not left or not right:
raise ValueError(f"{label} entries must include values on both sides of '::': {raw_value}")
return left, right
def parse_parts(raw_value: str, label: str, expected_parts: int) -> tuple[str, ...]:
parts = tuple(part.strip() for part in raw_value.split("::"))
if len(parts) != expected_parts or any(not part for part in parts):
raise ValueError(
f"{label} entries must use exactly {expected_parts} parts separated by '::': {raw_value}"
)
return parts
def prompt_value(question: str, *, default: str | None = None, choices: tuple[str, ...] | None = None) -> str:
while True:
choice_hint = f" ({', '.join(choices)})" if choices else ""
default_hint = f" [{default}]" if default else ""
raw_value = input(f"{question}{choice_hint}{default_hint}: ").strip()
if not raw_value and default is not None:
return default
if not raw_value:
print("Value is required.")
continue
if choices and raw_value not in choices:
print(f"Choose one of: {', '.join(choices)}")
continue
return raw_value
def prompt_lines(question: str, *, example: str, required: bool = False, max_items: int | None = None) -> list[str]:
print(question)
print(f"Format: {example}")
print("Press Enter on an empty line to finish.")
values: list[str] = []
while True:
raw_value = input("> ").strip()
if not raw_value:
if required and not values:
print("At least one line is required.")
continue
break
values.append(raw_value)
if max_items is not None and len(values) >= max_items:
break
return values
def title_case_phrase(text: str) -> str:
tokens = [token for token in re.split(r"[\s/_-]+", text.strip()) if token]
formatted: list[str] = []
for token in tokens:
lowered = token.lower()
if lowered in UPPERCASE_TOKENS:
formatted.append(UPPERCASE_TOKENS[lowered])
else:
formatted.append(token.capitalize())
return " ".join(formatted)
def compact_label(text: str, *, max_words: int = 5) -> str:
cleaned = re.sub(r"[^A-Za-z0-9\s/_-]+", " ", text).strip()
label = title_case_phrase(cleaned)
words = label.split()
if len(words) > max_words:
return " ".join(words[:max_words])
return label
def infer_interface_type(primary_runtime: str, entrypoint_name: str, index: int) -> str:
lowered = entrypoint_name.lower()
if primary_runtime in {"api-service", "web-app"} and index == 0:
return "http-api"
if primary_runtime == "library":
return "internal-service"
if primary_runtime in {"cli", "worker"}:
return "cli"
if any(token in lowered for token in ("uvicorn", "gunicorn", ":app")):
return "http-api"
if lowered.startswith(("python ", "python3 ", "node ")) or " -m " in lowered:
return "cli"
return "other"
def default_interface_actors(interface_type: str) -> str:
actor_map = {
"http-api": "clients and downstream services",
"cli": "operators and automation",
"event": "publishers and subscribers",
"file": "file producers and consumers",
"internal-service": "internal services",
"other": "project operators and integrators",
}
return actor_map[interface_type]
def default_interface_stability(interface_type: str, capture_mode: str) -> str:
if interface_type == "cli":
return "internal"
if capture_mode == "declared":
return "experimental"
return "internal"
def derive_interface_name(entrypoint: Entrypoint, interface_type: str, index: int) -> str:
purpose_label = compact_label(entrypoint.purpose)
if interface_type == "http-api":
return "Primary HTTP API" if index == 0 else f"{purpose_label} API"
if interface_type == "cli":
return f"{purpose_label} Command"
if interface_type == "event":
return f"{purpose_label} Event"
if interface_type == "file":
return f"{purpose_label} File Contract"
if interface_type == "internal-service":
return f"{purpose_label} Service"
return f"{purpose_label} Interface"
def build_interface_hints(answers: SeedAnswers) -> list[InterfaceContractHint]:
hints = list(answers.interface_contracts[:2])
for index, entrypoint in enumerate(answers.primary_entrypoints):
if len(hints) >= 2:
break
interface_type = infer_interface_type(answers.primary_runtime, entrypoint.name, index)
hints.append(
InterfaceContractHint(
name=derive_interface_name(entrypoint, interface_type, index),
interface_type=interface_type,
actors=default_interface_actors(interface_type),
purpose=entrypoint.purpose,
)
)
while len(hints) < 2:
index = len(hints)
fallback_type = "http-api" if index == 0 and answers.primary_runtime in {"api-service", "web-app"} else "internal-service"
hints.append(
InterfaceContractHint(
name="Primary HTTP API" if fallback_type == "http-api" else "Internal Service Interface",
interface_type=fallback_type,
actors=default_interface_actors(fallback_type),
purpose=f"support the primary {answers.primary_domain} workflow",
)
)
return hints[:2]
def default_entity_name(primary_domain: str, index: int) -> str:
domain_label = compact_label(primary_domain, max_words=4) or "Domain"
if index == 0:
return f"{domain_label} Record"
return f"{domain_label} Snapshot"
def default_entity_purpose(primary_domain: str, index: int) -> str:
if index == 0:
return f"represent a core business record in the {primary_domain} domain"
return f"represent a derived or supporting artifact in the {primary_domain} domain"
def build_entity_hints(answers: SeedAnswers) -> list[EntityHint]:
hints = list(answers.core_entities[:2])
while len(hints) < 2:
index = len(hints)
hints.append(
EntityHint(
name=default_entity_name(answers.primary_domain, index),
purpose=default_entity_purpose(answers.primary_domain, index),
)
)
return hints[:2]
def detect_storage_backend(tech_stack: list[str]) -> str:
lowered_stack = [item.lower() for item in tech_stack]
for keywords, label in STORAGE_BACKEND_HINTS:
if any(keyword in item for item in lowered_stack for keyword in keywords):
return label
return "application data store"
def normalize_storage_backend(raw_value: str) -> str:
lowered_value = raw_value.strip().lower()
if not lowered_value:
return "application data store"
for keywords, label in STORAGE_BACKEND_HINTS:
if any(keyword in lowered_value for keyword in keywords):
return label
return raw_value.strip()
def derive_interface_inputs(contract: InterfaceContractHint) -> list[str]:
if contract.interface_type == "http-api":
return [f"service request handled by {contract.name} :: {contract.purpose}"]
if contract.interface_type == "cli":
return [f"command invocation :: {contract.purpose}"]
if contract.interface_type == "event":
return [f"event publication or subscription :: {contract.purpose}"]
if contract.interface_type == "file":
return [f"file input or output trigger :: {contract.purpose}"]
return [f"interface invocation :: {contract.purpose}"]
def derive_interface_outputs(contract: InterfaceContractHint) -> list[str]:
if contract.interface_type == "http-api":
return [f"structured service response :: result of {contract.purpose}"]
if contract.interface_type == "cli":
return [f"console output or generated artifact :: result of {contract.purpose}"]
if contract.interface_type == "event":
return [f"published event payload :: result of {contract.purpose}"]
if contract.interface_type == "file":
return [f"generated or updated file artifact :: result of {contract.purpose}"]
return [f"observable interface result :: outcome of {contract.purpose}"]
def derive_interface_errors(contract: InterfaceContractHint) -> list[str]:
if contract.interface_type == "http-api":
return ["4xx/5xx response :: the request could not be completed successfully"]
if contract.interface_type == "cli":
return ["non-zero exit code :: the command failed for operators or automation"]
return ["reported interface failure :: the contract could not be completed successfully"]
def derive_interface_compatibility(contract: InterfaceContractHint) -> list[str]:
if contract.interface_type == "cli":
return ["arguments and exit-code semantics should remain stable for automation callers"]
return ["payload shape and externally visible behavior should remain backward compatible for stable consumers"]
def resolve_interface_compatibility_rules(
answers: SeedAnswers,
contract: InterfaceContractHint,
) -> list[str]:
shared_rules = [
rule.rule for rule in answers.compatibility_rules if rule.interface_name.strip() == "*"
]
named_rules = [
rule.rule
for rule in answers.compatibility_rules
if rule.interface_name.strip().casefold() == contract.name.casefold()
]
if shared_rules or named_rules:
return shared_rules + named_rules
return derive_interface_compatibility(contract)
def resolve_interface_detail_hints(
detail_hints: list[InterfaceDetailHint],
contract: InterfaceContractHint,
) -> list[str]:
return [
hint.value
for hint in detail_hints
if hint.interface_name.strip().casefold() == contract.name.casefold()
]
def derive_interface_shared_constraints(answers: SeedAnswers, contracts: list[InterfaceContractHint]) -> list[str]:
constraints: list[str] = []
if any(contract.interface_type == "http-api" for contract in contracts):
constraints.append("request validation should happen before persistence or response rendering")
if any(contract.interface_type == "cli" for contract in contracts):
constraints.append("automation-facing commands should communicate failure through stable exit semantics")
if not constraints:
constraints.append("externally visible interfaces should document compatibility expectations before release")
return constraints[:2]
def derive_interface_current_gaps(answers: SeedAnswers, contracts: list[InterfaceContractHint]) -> list[str]:
if answers.known_unknowns:
return answers.known_unknowns[:2]
gaps: list[str] = []
if any(contract.interface_type == "http-api" for contract in contracts):
gaps.append("authentication and API versioning strategy are not defined yet")
if any(contract.interface_type == "cli" for contract in contracts):
gaps.append("automation caller expectations are not fully documented yet")
return gaps[:2] or ["consumer-specific compatibility expectations are not defined yet"]
def derive_entity_source(storage_backend: str, index: int) -> str:
if storage_backend == "PostgreSQL":
return "PostgreSQL primary tables" if index == 0 else "PostgreSQL derived tables"
if storage_backend == "MySQL":
return "MySQL primary tables" if index == 0 else "MySQL derived tables"
if storage_backend == "SQLite":
return "SQLite primary tables" if index == 0 else "SQLite derived tables"
if storage_backend == "MongoDB":
return "MongoDB primary collections" if index == 0 else "MongoDB derived collections"
if storage_backend == "Object Storage":
return "object storage manifests" if index == 0 else "object storage derived artifacts"
return "primary application records" if index == 0 else "derived application records"
def derive_entity_lifecycle(index: int) -> str:
if index == 0:
return "created -> updated -> archived"
return "derived -> published -> superseded"
def derive_entity_fields(index: int, entity: EntityHint, source_entity: EntityHint | None = None) -> list[str]:
if index == 0:
return [
"id :: string or uuid :: stable identifier for the entity",
"status :: enum :: current lifecycle state for the entity",
"created_at :: timestamp :: when the entity was first recorded",
]
source_name = source_entity.name if source_entity is not None else "the primary entity"
return [
"id :: string or uuid :: stable identifier for the derived entity",
f"source_id :: string or uuid :: reference to {source_name}",
"updated_at :: timestamp :: when the derived state was last recomputed or refreshed",
]
def derive_entity_relationships(index: int, entity: EntityHint, other_entity: EntityHint) -> list[str]:
if index == 0:
return [f"{other_entity.name} :: derived from or associated with {entity.name}"]
return [f"{other_entity.name} :: source record or upstream entity for {entity.name}"]
def derive_entity_state_rules(index: int, entity: EntityHint, other_entity: EntityHint) -> list[str]:
if index == 0:
return [
"only one current active record should be treated as authoritative per business key",
"archived records should remain available for audit or replay needs when retained",
]
return [
f"{entity.name} should not exist without a corresponding {other_entity.name}",
f"{entity.name} should be refreshed whenever the authoritative {other_entity.name} changes",
]
def derive_consistency_rules(entities: list[EntityHint]) -> list[str]:
return [
"stable identifiers must remain consistent across related entities",
f"derived records must not outlive their source relationship to {entities[0].name}",
]
def derive_persistence_notes(storage_backend: str) -> list[str]:
return [
f"{storage_backend} is expected to store the current cross-cutting records and derived artifacts",
"migration, indexing, and retention strategy should be updated when schema assumptions change",
]
def derive_model_current_gaps(answers: SeedAnswers) -> list[str]:
if answers.known_unknowns:
return answers.known_unknowns[:2]
return [
"exact schema and indexing strategy are not defined yet",
"retention and archival policy are not defined yet",
]
def collect_required_args(args: argparse.Namespace) -> tuple[str, str, list[str], str, list[str]]:
missing: list[str] = []
if not args.project_summary:
missing.append("--project-summary")
if not args.primary_domain:
missing.append("--primary-domain")
if not args.tech_stack:
missing.append("--tech-stack")
if not args.primary_runtime:
missing.append("--primary-runtime")
if not args.primary_entrypoint:
missing.append("--primary-entrypoint")
if missing:
raise ValueError("Missing required arguments for --non-interactive: " + ", ".join(missing))
tech_stack = parse_csv_list(args.tech_stack)
if not tech_stack:
raise ValueError("--tech-stack must include at least one comma-separated item")
return (
args.project_summary,
args.primary_domain,
tech_stack,
args.primary_runtime,
args.primary_entrypoint,
)
def collect_answers(args: argparse.Namespace, host_root: Path) -> SeedAnswers:
repository_name = args.repository_name or host_root.resolve().name or "project"
display_name = args.display_name or default_display_name(repository_name)
if args.non_interactive:
summary, primary_domain, tech_stack, primary_runtime, primary_entrypoints = collect_required_args(args)
top_level_area_values = args.top_level_area
interface_contract_values = args.interface_contract
interface_input_values = args.interface_input
interface_output_values = args.interface_output
interface_error_values = args.interface_error
core_entity_values = args.core_entity
persistence_backend = normalize_storage_backend(args.persistence_backend) if args.persistence_backend else detect_storage_backend(tech_stack)
compatibility_rule_values = args.compatibility_rule
known_unknowns = args.known_unknown
else:
print("[bootstrap-seed] Answer the minimum bootstrap questions for baseline.yaml.")
print(f"[bootstrap-seed] Host root: {host_root}")
display_name = prompt_value("Display name", default=display_name)
summary = args.project_summary or prompt_value("project-summary | 이 프로젝트를 한 문장으로 설명하면 무엇인가?")
primary_domain = args.primary_domain or prompt_value("primary-domain | 이 프로젝트의 주요 도메인은 무엇인가?")
tech_stack = parse_csv_list(args.tech_stack) if args.tech_stack else parse_csv_list(
prompt_value("tech-stack | 계획 중인 주 언어, 프레임워크, 핵심 인프라는 무엇인가? (comma-separated)")
)
primary_runtime = args.primary_runtime or prompt_value(
"primary-runtime | 계획 중인 주 실행 형태는 무엇인가?",
choices=RUNTIME_CHOICES,
)
primary_entrypoints = args.primary_entrypoint or prompt_lines(
"primary-entrypoints | 대표 진입점 1~3개를 적어 달라.",
example="name :: purpose",
required=True,
max_items=3,
)
top_level_area_values = args.top_level_area or prompt_lines(
"planned-top-level-areas | 이미 알고 있는 top-level 경로가 있으면 적어 달라.",
example="path :: responsibility",
)
interface_contract_values = args.interface_contract or prompt_lines(
"interface-contracts | 대표 인터페이스 1~2개가 있으면 적어 달라.",
example="name :: type :: actors :: purpose",
max_items=2,
)
interface_input_values = args.interface_input or prompt_lines(
"interface-inputs | 각 인터페이스의 대표 input을 적어 달라.",
example="interface-name :: input contract or invocation note",
)
interface_output_values = args.interface_output or prompt_lines(
"interface-outputs | 각 인터페이스의 대표 output을 적어 달라.",
example="interface-name :: output contract or artifact",
)
interface_error_values = args.interface_error or prompt_lines(
"interface-errors | 각 인터페이스의 대표 error를 적어 달라.",
example="interface-name :: error code or failure behavior",
)
core_entity_values = args.core_entity or prompt_lines(
"core-entities | 핵심 엔티티 1~2개가 있으면 적어 달라.",
example="name :: purpose",
max_items=2,
)
persistence_backend = normalize_storage_backend(args.persistence_backend) if args.persistence_backend else prompt_value(
"persistence-backend | 주 persistence backend가 있으면 적어 달라.",
default=detect_storage_backend(tech_stack),
)
compatibility_rule_values = args.compatibility_rule or prompt_lines(
"compatibility-rules | 인터페이스 호환성 규칙이 있으면 적어 달라.",
example="interface-name :: rule 또는 * :: rule",
)
known_unknowns = args.known_unknown or prompt_lines(
"known-unknowns | 아직 미정인 핵심 항목이 있으면 적어 달라.",
example="one unresolved item per line",
)
if primary_runtime not in RUNTIME_CHOICES:
raise ValueError(f"primary runtime must be one of: {', '.join(RUNTIME_CHOICES)}")
if not tech_stack:
raise ValueError("tech-stack must include at least one item")
if not primary_entrypoints:
raise ValueError("at least one primary-entrypoint is required")
if len(primary_entrypoints) > 3:
raise ValueError("primary-entrypoint supports at most 3 values")
if len(interface_contract_values) > 2:
raise ValueError("interface-contract supports at most 2 values")
if len(core_entity_values) > 2:
raise ValueError("core-entity supports at most 2 values")
entrypoints = [Entrypoint(*parse_pair(value, "primary-entrypoint")) for value in primary_entrypoints]
top_level_areas = [TopLevelArea(*parse_pair(value, "top-level-area")) for value in top_level_area_values]
interface_contracts = [
InterfaceContractHint(*parse_parts(value, "interface-contract", 4)) for value in interface_contract_values
]
for contract in interface_contracts:
if contract.interface_type not in INTERFACE_TYPE_CHOICES:
raise ValueError(
"interface-contract type must be one of: " + ", ".join(INTERFACE_TYPE_CHOICES)
)
interface_inputs = [InterfaceDetailHint(*parse_pair(value, "interface-input")) for value in interface_input_values]
interface_outputs = [InterfaceDetailHint(*parse_pair(value, "interface-output")) for value in interface_output_values]
interface_errors = [InterfaceDetailHint(*parse_pair(value, "interface-error")) for value in interface_error_values]
core_entities = [EntityHint(*parse_parts(value, "core-entity", 2)) for value in core_entity_values]
compatibility_rules = [
CompatibilityRuleHint(*parse_pair(value, "compatibility-rule")) for value in compatibility_rule_values
]
return SeedAnswers(
repository_name=repository_name,
display_name=display_name,
summary=summary,
primary_domain=primary_domain,
tech_stack=tech_stack,
primary_runtime=primary_runtime,
primary_entrypoints=entrypoints,
planned_top_level_areas=top_level_areas,
interface_contracts=interface_contracts,
interface_inputs=interface_inputs,
interface_outputs=interface_outputs,
interface_errors=interface_errors,
core_entities=core_entities,
persistence_backend=persistence_backend,
compatibility_rules=compatibility_rules,
known_unknowns=known_unknowns,
capture_mode=args.capture_mode or "declared",
)
def render_seed(answers: SeedAnswers) -> str:
updated_from = {
"declared": "user-answer",
"observed": "repo-observation",
"mixed": "mixed",
}[answers.capture_mode]
interface_contracts = build_interface_hints(answers)
entities = build_entity_hints(answers)
storage_backend = answers.persistence_backend
interface_shared_constraints = derive_interface_shared_constraints(answers, interface_contracts)
interface_current_gaps = derive_interface_current_gaps(answers, interface_contracts)
consistency_rules = derive_consistency_rules(entities)
persistence_notes = derive_persistence_notes(storage_backend)
model_current_gaps = derive_model_current_gaps(answers)
lines = [
"schema_version: 1",
f"capture_mode: {answers.capture_mode}",
"",
"project:",
f" repository_name: {yaml_scalar(answers.repository_name)}",
f" display_name: {yaml_scalar(answers.display_name)}",
f" summary: {yaml_scalar(answers.summary)}",
f" primary_domain: {yaml_scalar(answers.primary_domain)}",
"",
"stack:",
" tech_stack:",
]
for item in answers.tech_stack:
lines.append(f" - {yaml_scalar(item)}")
lines.extend(
[
"",
"runtime:",
f" primary_runtime: {answers.primary_runtime}",
" primary_entrypoints:",
]
)
for entrypoint in answers.primary_entrypoints:
lines.append(f" - name: {yaml_scalar(entrypoint.name)}")
lines.append(f" purpose: {yaml_scalar(entrypoint.purpose)}")
lines.extend(["", "interfaces:", " contracts:"])
for contract in interface_contracts:
resolved_inputs = resolve_interface_detail_hints(answers.interface_inputs, contract)
resolved_outputs = resolve_interface_detail_hints(answers.interface_outputs, contract)
resolved_errors = resolve_interface_detail_hints(answers.interface_errors, contract)
lines.append(f" - name: {yaml_scalar(contract.name)}")
lines.append(f" type: {yaml_scalar(contract.interface_type)}")
lines.append(f" actors: {yaml_scalar(contract.actors)}")
lines.append(f" purpose: {yaml_scalar(contract.purpose)}")
lines.append(
f" stability: {yaml_scalar(default_interface_stability(contract.interface_type, answers.capture_mode))}"
)
lines.append(" inputs:")
for item in resolved_inputs or derive_interface_inputs(contract):
lines.append(f" - {yaml_scalar(item)}")
lines.append(" outputs:")
for item in resolved_outputs or derive_interface_outputs(contract):
lines.append(f" - {yaml_scalar(item)}")
lines.append(" errors:")
for item in resolved_errors or derive_interface_errors(contract):
lines.append(f" - {yaml_scalar(item)}")
lines.append(" compatibility_rules:")
for item in resolve_interface_compatibility_rules(answers, contract):
lines.append(f" - {yaml_scalar(item)}")
lines.append(" shared_constraints:")
for item in interface_shared_constraints:
lines.append(f" - {yaml_scalar(item)}")
lines.append(" current_gaps:")
for item in interface_current_gaps:
lines.append(f" - {yaml_scalar(item)}")
lines.extend(["", "structure:"])
if answers.planned_top_level_areas:
lines.append(" planned_top_level_areas:")
for area in answers.planned_top_level_areas:
lines.append(f" - path: {yaml_scalar(area.path)}")
lines.append(f" responsibility: {yaml_scalar(area.responsibility)}")
else:
lines.append(" planned_top_level_areas: []")
lines.extend(["", "data_model:", f" persistence_backend: {yaml_scalar(storage_backend)}", " entities:"])
for index, entity in enumerate(entities):
other_entity = entities[1 - index]
source_entity = entities[0] if index == 1 else None
lines.append(f" - name: {yaml_scalar(entity.name)}")
lines.append(f" purpose: {yaml_scalar(entity.purpose)}")
lines.append(f" source_of_truth: {yaml_scalar(derive_entity_source(storage_backend, index))}")
lines.append(f" lifecycle: {yaml_scalar(derive_entity_lifecycle(index))}")
lines.append(" key_fields:")
for item in derive_entity_fields(index, entity, source_entity):
lines.append(f" - {yaml_scalar(item)}")
lines.append(" relationships:")
for item in derive_entity_relationships(index, entity, other_entity):
lines.append(f" - {yaml_scalar(item)}")
lines.append(" state_rules:")
for item in derive_entity_state_rules(index, entity, other_entity):
lines.append(f" - {yaml_scalar(item)}")
lines.append(" consistency_rules:")
for item in consistency_rules:
lines.append(f" - {yaml_scalar(item)}")
lines.append(" persistence_notes:")
for item in persistence_notes:
lines.append(f" - {yaml_scalar(item)}")
lines.append(" current_gaps:")
for item in model_current_gaps:
lines.append(f" - {yaml_scalar(item)}")
lines.extend(["", "notes:"])
if answers.known_unknowns:
lines.append(" known_unknowns:")
for item in answers.known_unknowns:
lines.append(f" - {yaml_scalar(item)}")
else:
lines.append(" known_unknowns: []")
lines.extend(
[
"",
"metadata:",
f" captured_at_kst: {yaml_scalar(kst_timestamp())}",
" source: stagepilot-bootstrap-seed.py",
f" updated_from: {updated_from}",
"",
]
)
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Create .stage-pilot/bootstrap/baseline.yaml from StagePilot bootstrap answers.",
)
parser.add_argument("host_root", nargs="?", default=".", help="Host repository root. Defaults to the current directory.")
parser.add_argument("--output", help="Explicit output path. Defaults to HOST_ROOT/.stage-pilot/bootstrap/baseline.yaml.")
parser.add_argument("--force", action="store_true", help="Overwrite an existing seed file.")
parser.add_argument("--dry-run", action="store_true", help="Print the generated seed to stdout without writing a file.")
parser.add_argument("--non-interactive", action="store_true", help="Require explicit CLI answers instead of interactive prompts.")
parser.add_argument("--capture-mode", choices=("declared", "observed", "mixed"), help="Seed capture mode.")
parser.add_argument("--repository-name", help="Repository name stored in the seed. Defaults to the host root folder name.")
parser.add_argument("--display-name", help="Display name stored in the seed. Defaults to a title-cased repository name.")
parser.add_argument("--project-summary", help="One-sentence project summary.")
parser.add_argument("--primary-domain", help="Primary domain noun phrase.")
parser.add_argument("--tech-stack", help="Comma-separated technology list.")
parser.add_argument("--primary-runtime", choices=RUNTIME_CHOICES, help="Primary runtime type.")
parser.add_argument(
"--primary-entrypoint",
action="append",
default=[],
help="Primary entrypoint line in 'name :: purpose' format. Repeat up to 3 times.",
)
parser.add_argument(
"--top-level-area",
action="append",
default=[],
help="Optional top-level area in 'path :: responsibility' format.",
)
parser.add_argument(
"--interface-contract",
action="append",
default=[],
help="Optional interface contract in 'name :: type :: actors :: purpose' format. Repeat up to 2 times.",
)
parser.add_argument(
"--interface-input",
action="append",
default=[],
help="Optional interface input in 'interface-name :: input contract or invocation note' format.",
)
parser.add_argument(
"--interface-output",
action="append",
default=[],
help="Optional interface output in 'interface-name :: output contract or artifact' format.",
)
parser.add_argument(
"--interface-error",
action="append",
default=[],
help="Optional interface error in 'interface-name :: error code or failure behavior' format.",
)
parser.add_argument(
"--core-entity",
action="append",
default=[],
help="Optional core entity in 'name :: purpose' format. Repeat up to 2 times.",
)
parser.add_argument("--persistence-backend", help="Optional primary persistence backend or storage system.")
parser.add_argument(
"--compatibility-rule",
action="append",
default=[],
help="Optional compatibility rule in 'interface-name :: rule' format. Use '* :: rule' for a shared rule.",
)
parser.add_argument(
"--known-unknown",
action="append",
default=[],
help="Optional unresolved bootstrap note. Repeat for multiple items.",
)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
host_root = Path(args.host_root).resolve()
if not host_root.exists() or not host_root.is_dir():
return fail(f"host root not found: {host_root}")
output_path = Path(args.output).resolve() if args.output else host_root / ".stage-pilot/bootstrap/baseline.yaml"
try:
answers = collect_answers(args, host_root)
content = render_seed(answers)
except ValueError as error:
return fail(str(error))
except EOFError:
return fail("input ended before the questionnaire was completed")
if args.dry_run:
print(content, end="")
return 0
if output_path.exists() and not args.force:
return fail(f"seed file already exists: {output_path}. Use --force to overwrite.")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content, encoding="utf-8")
log(f"wrote {output_path}")
log("Next: run /bootstrap-baseline in Copilot Chat to render baseline docs from the seed.")
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: stagepilot.sh <command> [args]
Commands:
bootstrap-seed [HOST_ROOT] [options]
Create .stage-pilot/bootstrap/baseline.yaml from interactive answers
or explicit CLI arguments.
doctor [HOST_ROOT] [--strict-missing-docs] [--report <path>]
Run the StagePilot validation script against a workspace.
EOF
}
fail() {
printf '[stagepilot] ERROR: %s\n' "$*" >&2
exit 1
}
run_doctor() {
local script_dir package_root doctor_script
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
package_root="$(cd "${script_dir}/.." && pwd -P)"
doctor_script="${package_root}/tools/stagepilot-doctor.py"
[ -f "${doctor_script}" ] || fail "Doctor script not found: ${doctor_script}"
command -v python3 >/dev/null 2>&1 || fail "python3 is required to run doctor"
python3 "${doctor_script}" "$@"
}
run_bootstrap_seed() {
local script_dir package_root seed_script
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
package_root="$(cd "${script_dir}/.." && pwd -P)"
seed_script="${package_root}/tools/stagepilot-bootstrap-seed.py"
[ -f "${seed_script}" ] || fail "Bootstrap seed helper not found: ${seed_script}"
command -v python3 >/dev/null 2>&1 || fail "python3 is required to generate a bootstrap seed"
python3 "${seed_script}" "$@"
}
main() {
local command="${1:-}"
if [ -z "${command}" ]; then
usage
exit 1
fi
shift || true
case "${command}" in
bootstrap-seed)
run_bootstrap_seed "$@"
;;
doctor)
run_doctor "$@"
;;
-h|--help|help)
usage
;;
*)
usage
fail "Unknown command: ${command}"
;;
esac
}
main "$@"
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: update.sh [options] [HOST_ROOT]
Options:
--dry-run Show planned commands without executing them.
--remote <name> Git remote name to use when available (default: stage-pilot)
--repo-url <url> Fallback repository URL when remote is missing.
(default: https://github.com/genichin/stage-pilot.git)
--branch <name> Upstream branch to sync (default: main)
--prefix <path> Subtree prefix path (default: .stage-pilot)
--overwrite Overwrite conflicts during install (same as --policy replace).
(default behavior)
--no-overwrite Preserve host files on conflicts (same as --policy preserve).
--policy <name> install.sh conflict policy: replace|preserve|fail
(default: replace)
--skip-install Only update subtree. Do not run install.sh.
-h, --help Show this help message.
Arguments:
HOST_ROOT Host repository root (default: current directory)
EOF
}
log() {
printf '[update] %s\n' "$*"
}
fail() {
printf '[update] ERROR: %s\n' "$*" >&2
exit 1
}
run_cmd() {
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] $*"
else
"$@"
fi
}
DRY_RUN=0
REMOTE_NAME="stage-pilot"
REPO_URL="https://github.com/genichin/stage-pilot.git"
BRANCH="main"
PREFIX=".stage-pilot"
POLICY="replace"
SKIP_INSTALL=0
ARGS=()
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
DRY_RUN=1
;;
--remote)
[ "$#" -ge 2 ] || fail "--remote requires a value"
REMOTE_NAME="$2"
shift
;;
--repo-url)
[ "$#" -ge 2 ] || fail "--repo-url requires a value"
REPO_URL="$2"
shift
;;
--branch)
[ "$#" -ge 2 ] || fail "--branch requires a value"
BRANCH="$2"
shift
;;
--prefix)
[ "$#" -ge 2 ] || fail "--prefix requires a value"
PREFIX="$2"
shift
;;
--overwrite)
POLICY="replace"
;;
--no-overwrite)
POLICY="preserve"
;;
--policy)
[ "$#" -ge 2 ] || fail "--policy requires a value"
POLICY="$2"
shift
;;
--skip-install)
SKIP_INSTALL=1
;;
-h|--help)
usage
exit 0
;;
--*)
fail "Unknown option: $1"
;;
*)
ARGS+=("$1")
;;
esac
shift
done
HOST_ROOT="${ARGS[0]:-$(pwd)}"
case "${POLICY}" in
replace|preserve|fail)
;;
*)
fail "Invalid --policy '${POLICY}'. Allowed: replace|preserve|fail"
;;
esac
[ -d "${HOST_ROOT}" ] || fail "Host root not found: ${HOST_ROOT}"
if ! git -C "${HOST_ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
fail "Host root is not a git repository: ${HOST_ROOT}"
fi
if git -C "${HOST_ROOT}" remote get-url "${REMOTE_NAME}" >/dev/null 2>&1; then
SOURCE_REF="${REMOTE_NAME}"
log "Using git remote '${REMOTE_NAME}'"
else
SOURCE_REF="${REPO_URL}"
log "Remote '${REMOTE_NAME}' not found; using URL '${REPO_URL}'"
fi
if [ -d "${HOST_ROOT}/${PREFIX}" ]; then
run_cmd git -C "${HOST_ROOT}" subtree pull --prefix="${PREFIX}" "${SOURCE_REF}" "${BRANCH}" --squash
else
log "Subtree prefix not found; adding subtree at ${PREFIX}"
run_cmd git -C "${HOST_ROOT}" subtree add --prefix="${PREFIX}" "${SOURCE_REF}" "${BRANCH}" --squash
fi
if [ "${SKIP_INSTALL}" -eq 1 ]; then
log "Skip install requested. Update completed."
exit 0
fi
INSTALL_SCRIPT="${HOST_ROOT}/${PREFIX}/tools/install.sh"
[ -f "${INSTALL_SCRIPT}" ] || fail "Install script not found after update: ${INSTALL_SCRIPT}"
if [ "${DRY_RUN}" -eq 1 ]; then
run_cmd /bin/bash "${INSTALL_SCRIPT}" --dry-run "${HOST_ROOT}" "${POLICY}"
else
/bin/bash "${INSTALL_SCRIPT}" "${HOST_ROOT}" "${POLICY}"
fi
log "Update completed successfully."