its-magic 0.1.2-9 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1602 -755
- package/bin/its-magic.js +121 -11
- package/bin/postinstall.js +21 -0
- package/installer.ps1 +759 -0
- package/installer.py +981 -0
- package/installer.sh +649 -0
- package/package.json +18 -14
- package/scripts/check_intake_template_parity.py +284 -0
- package/scripts/doc_profile_lib.py +415 -0
- package/scripts/guard_installer_publish.py +88 -0
- package/scripts/intake_bug_routing_guard.py +67 -0
- package/scripts/intake_evidence_lib.py +802 -0
- package/scripts/intake_evidence_validate.py +73 -0
- package/scripts/materialize_codebase_map.py +184 -0
- package/scripts/remote_config_summary.py +243 -0
- package/template/.cursor/agents/curator.mdc +35 -0
- package/template/.cursor/agents/dev.mdc +29 -0
- package/template/.cursor/agents/po.mdc +141 -0
- package/template/.cursor/agents/qa.mdc +28 -0
- package/template/.cursor/agents/release.mdc +28 -0
- package/template/.cursor/agents/security.mdc +98 -0
- package/template/.cursor/agents/tech-lead.mdc +57 -0
- package/template/.cursor/commands/architecture.md +127 -0
- package/template/.cursor/commands/ask.md +55 -0
- package/template/.cursor/commands/auto.md +533 -0
- package/template/.cursor/commands/discovery.md +47 -0
- package/template/.cursor/commands/execute.md +343 -0
- package/template/.cursor/commands/intake.md +323 -0
- package/template/.cursor/commands/map-codebase.md +46 -0
- package/template/.cursor/commands/memory-audit.md +175 -0
- package/template/.cursor/commands/milestone-complete.md +51 -0
- package/template/.cursor/commands/milestone-start.md +59 -0
- package/template/.cursor/commands/pause.md +64 -0
- package/{.cursor/commands/gsd-phase-context.md → template/.cursor/commands/phase-context.md} +6 -2
- package/template/.cursor/commands/plan-verify.md +34 -0
- package/template/.cursor/commands/qa.md +226 -0
- package/template/.cursor/commands/quick.md +41 -0
- package/template/.cursor/commands/refresh-context.md +82 -0
- package/template/.cursor/commands/release.md +505 -0
- package/template/.cursor/commands/research.md +49 -0
- package/template/.cursor/commands/resume.md +67 -0
- package/template/.cursor/commands/security-review.md +81 -0
- package/template/.cursor/commands/sprint-plan.md +103 -0
- package/template/.cursor/commands/status-reconcile.md +95 -0
- package/template/.cursor/commands/verify-work.md +152 -0
- package/template/.cursor/dev-environment.json.example +22 -0
- package/{.cursor → template/.cursor}/hooks/README.md +3 -2
- package/{.cursor/hooks/gsd-hook.py → template/.cursor/hooks/hook.py} +15 -6
- package/template/.cursor/hooks.json +26 -0
- package/template/.cursor/remote.json +31 -0
- package/template/.cursor/rules/caveman.mdc +184 -0
- package/template/.cursor/rules/coding-standards.mdc +39 -0
- package/template/.cursor/rules/core.mdc +111 -0
- package/template/.cursor/rules/handoffs.mdc +27 -0
- package/template/.cursor/rules/quality.mdc +49 -0
- package/template/.cursor/scratchpad.local.example.md +323 -0
- package/template/.cursor/scratchpad.md +324 -0
- package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/SKILL.md +2 -2
- package/template/.cursorignore +6 -0
- package/template/.env.example +28 -0
- package/template/.github/workflows/ci.yml +194 -0
- package/{.github → template/.github}/workflows/deploy.yml +5 -2
- package/template/.its-magic-version +1 -0
- package/template/README.md +1602 -0
- package/template/decisions/DEC-0001.md +11 -0
- package/template/decisions/DEC-0002.md +11 -0
- package/template/docs/developer/README.md +44 -0
- package/template/docs/engineering/architecture.md +9 -0
- package/template/docs/engineering/artifact-ordering-policy.md +48 -0
- package/template/docs/engineering/artifact-ownership-policy.md +57 -0
- package/template/docs/engineering/auto-orchestration-reference.md +1211 -0
- package/{docs → template/docs}/engineering/codebase-map.md +0 -5
- package/template/docs/engineering/compatibility-report.md +20 -0
- package/template/docs/engineering/compatibility-signals.md +7 -0
- package/template/docs/engineering/component-scope-report.md +16 -0
- package/template/docs/engineering/component-scope.md +19 -0
- package/template/docs/engineering/context/installer-owned-paths.manifest +96 -0
- package/template/docs/engineering/context/readme-section-affinity.json +30 -0
- package/template/docs/engineering/decisions.md +16 -0
- package/template/docs/engineering/legacy-drift-audit.md +21 -0
- package/template/docs/engineering/manifests/components/api-gateway.manifest.yaml +12 -0
- package/template/docs/engineering/manifests/registry.manifest.yaml +20 -0
- package/template/docs/engineering/manifests/repo.manifest.yaml +11 -0
- package/template/docs/engineering/phase-context.md +16 -0
- package/template/docs/engineering/release-targets.json +123 -0
- package/template/docs/engineering/research.md +29 -0
- package/template/docs/engineering/runbook.md +2320 -0
- package/template/docs/engineering/runtime-connectivity.md +81 -0
- package/template/docs/engineering/security-review.md +33 -0
- package/template/docs/engineering/spec-pack/README.md +20 -0
- package/template/docs/engineering/state-archive/README.md +15 -0
- package/template/docs/engineering/state.md +20 -0
- package/template/docs/engineering/status-normalization-report.md +19 -0
- package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
- package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
- package/template/docs/product/acceptance.md +1 -0
- package/template/docs/product/backlog.md +5 -0
- package/template/docs/product/vision.md +11 -0
- package/template/docs/user-guides/README.md +40 -0
- package/{handoffs → template/handoffs}/dev_to_qa.md +0 -3
- package/{handoffs → template/handoffs}/po_to_tl.md +0 -3
- package/{handoffs → template/handoffs}/qa_to_dev.md +1 -2
- package/template/handoffs/release_notes.md +51 -0
- package/template/handoffs/release_queue.md +47 -0
- package/template/handoffs/release_to_dev.md +31 -0
- package/template/handoffs/releases/Sxxxx-release-notes.md +62 -0
- package/template/handoffs/resume_brief.md +14 -0
- package/{handoffs → template/handoffs}/tl_to_dev.md +0 -2
- package/template/handoffs/token_cost_runs/README.md +26 -0
- package/template/its_magic/.its-magic-version +1 -0
- package/template/its_magic/README.md +9 -0
- package/template/scripts/auto_outer_driver.py +521 -0
- package/template/scripts/caveman_compress_input.py +903 -0
- package/template/scripts/check_downstream_ci_guard.py +67 -0
- package/template/scripts/check_intake_template_parity.py +284 -0
- package/template/scripts/check_token_cost_parity.py +69 -0
- package/template/scripts/dev_environment_lib.py +463 -0
- package/template/scripts/doc_profile_lib.py +415 -0
- package/template/scripts/downstream_ci_guard_lib.py +222 -0
- package/template/scripts/enforce-triad-hot-surface.py +753 -0
- package/template/scripts/guard_installer_publish.py +88 -0
- package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
- package/template/scripts/intake_bug_routing_guard.py +67 -0
- package/template/scripts/intake_evidence_lib.py +802 -0
- package/template/scripts/intake_evidence_validate.py +73 -0
- package/template/scripts/materialize_codebase_map.py +184 -0
- package/template/scripts/pack_json_validate.py +130 -0
- package/template/scripts/project_readme_coverage_lib.py +417 -0
- package/template/scripts/readme_feature_coverage_lib.py +608 -0
- package/template/scripts/remote_config_summary.py +243 -0
- package/template/scripts/sync_push_gates.py +198 -0
- package/template/scripts/token_cost_compare.py +40 -0
- package/template/scripts/token_cost_lib.py +108 -0
- package/template/scripts/uat_probe_lib.py +868 -0
- package/template/scripts/validate-and-push.ps1 +280 -0
- package/template/scripts/validate-and-push.sh +243 -0
- package/template/scripts/validate_doc_profile.py +103 -0
- package/template/scripts/validate_project_readme_coverage.py +151 -0
- package/template/scripts/validate_readme_feature_coverage.py +140 -0
- package/template/sprints/S0001/progress.md +1 -0
- package/template/sprints/S0001/qa-findings.md +9 -0
- package/template/sprints/S0001/release-findings.md +24 -0
- package/template/sprints/S0001/sprint.md +9 -0
- package/template/sprints/S0001/summary.md +7 -0
- package/template/sprints/S0001/tasks.md +1 -0
- package/template/sprints/S0001/uat.json +6 -0
- package/template/sprints/S0001/uat.md +5 -0
- package/template/sprints/quick/Q0001/summary.md +1 -0
- package/{sprints → template/sprints}/quick/Q0001/task.json +0 -1
- package/.cursor/agents/curator.mdc +0 -21
- package/.cursor/agents/dev.mdc +0 -20
- package/.cursor/agents/po.mdc +0 -19
- package/.cursor/agents/qa.mdc +0 -19
- package/.cursor/agents/release.mdc +0 -19
- package/.cursor/agents/tech-lead.mdc +0 -21
- package/.cursor/commands/gsd-architecture.md +0 -29
- package/.cursor/commands/gsd-auto.md +0 -27
- package/.cursor/commands/gsd-discovery.md +0 -27
- package/.cursor/commands/gsd-execute.md +0 -32
- package/.cursor/commands/gsd-intake.md +0 -28
- package/.cursor/commands/gsd-map-codebase.md +0 -25
- package/.cursor/commands/gsd-milestone-complete.md +0 -24
- package/.cursor/commands/gsd-milestone-start.md +0 -26
- package/.cursor/commands/gsd-pause.md +0 -25
- package/.cursor/commands/gsd-plan-verify.md +0 -26
- package/.cursor/commands/gsd-qa.md +0 -28
- package/.cursor/commands/gsd-quick.md +0 -24
- package/.cursor/commands/gsd-refresh-context.md +0 -26
- package/.cursor/commands/gsd-release.md +0 -29
- package/.cursor/commands/gsd-research.md +0 -28
- package/.cursor/commands/gsd-resume.md +0 -26
- package/.cursor/commands/gsd-sprint-plan.md +0 -30
- package/.cursor/commands/gsd-verify-work.md +0 -25
- package/.cursor/hooks.json +0 -26
- package/.cursor/plans/cursor-gsd-team-kit_8cfee9b8.plan.md +0 -57
- package/.cursor/remote.json +0 -18
- package/.cursor/rules/gsd-core.mdc +0 -18
- package/.cursor/rules/gsd-handoffs.mdc +0 -10
- package/.cursor/rules/gsd-quality.mdc +0 -15
- package/.cursor/scratchpad.md +0 -34
- package/.github/workflows/ci.yml +0 -47
- package/decisions/DEC-0001.md +0 -21
- package/decisions/DEC-0002.md +0 -21
- package/docs/engineering/architecture.md +0 -354
- package/docs/engineering/decisions.md +0 -6
- package/docs/engineering/research.md +0 -11
- package/docs/engineering/runbook.md +0 -32
- package/docs/engineering/state.md +0 -33
- package/docs/product/acceptance.md +0 -6
- package/docs/product/backlog.md +0 -7
- package/docs/product/vision.md +0 -46
- package/gsd-installer.ps1 +0 -189
- package/gsd-installer.py +0 -195
- package/gsd-installer.sh +0 -201
- package/handoffs/release_notes.md +0 -14
- package/handoffs/resume_brief.md +0 -8
- package/milestones/M0001/milestone.json +0 -7
- package/milestones/M0001/phases.json +0 -9
- package/milestones/M0001/progress.md +0 -3
- package/milestones/M0001/summary.md +0 -3
- package/scripts/generate-release-notes.ps1 +0 -74
- package/scripts/generate-release-notes.sh +0 -63
- package/scripts/release-all.ps1 +0 -423
- package/scripts/release-all.sh +0 -226
- package/sprints/S0001/progress.md +0 -4
- package/sprints/S0001/qa-findings.md +0 -113
- package/sprints/S0001/sprint.md +0 -70
- package/sprints/S0001/summary.md +0 -46
- package/sprints/S0001/tasks.md +0 -35
- package/sprints/S0001/uat.json +0 -8
- package/sprints/S0001/uat.md +0 -8
- package/sprints/quick/Q0001/summary.md +0 -3
- /package/{.cursor/rules/gsd-escalation.mdc → template/.cursor/rules/escalation.mdc} +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/milestone.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/phase-context.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/plan-verify.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/uat.json +0 -0
- /package/{docs → template/docs}/engineering/context/phase-template.json +0 -0
- /package/{docs → template/docs}/engineering/dependencies.json +0 -0
- /package/{sprints → template/sprints}/S0001/plan-verify.json +0 -0
package/installer.py
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import filecmp
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
REPO_URL = "https://github.com/fl0wm0ti0n/its-magic"
|
|
13
|
+
MANIFEST_RELATIVE_PATH = os.path.join("docs", "engineering", "context", "installer-owned-paths.manifest")
|
|
14
|
+
MANIFEST_REQUIRED_SCRIPTS_SECTION = "required_install_script_paths"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize(path):
|
|
18
|
+
return os.path.normpath(os.path.abspath(path))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_version(source_root):
|
|
22
|
+
package_path = os.path.join(source_root, "package.json")
|
|
23
|
+
try:
|
|
24
|
+
with open(package_path, "r", encoding="utf-8") as f:
|
|
25
|
+
return json.load(f).get("version", "unknown")
|
|
26
|
+
except Exception:
|
|
27
|
+
return "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_source_files(source_root, include_paths):
|
|
31
|
+
files = []
|
|
32
|
+
for rel in include_paths:
|
|
33
|
+
src = os.path.join(source_root, rel)
|
|
34
|
+
if os.path.isfile(src):
|
|
35
|
+
files.append(rel)
|
|
36
|
+
elif os.path.isdir(src):
|
|
37
|
+
for root, _, filenames in os.walk(src):
|
|
38
|
+
for name in filenames:
|
|
39
|
+
full = os.path.join(root, name)
|
|
40
|
+
rel_path = os.path.relpath(full, source_root)
|
|
41
|
+
files.append(rel_path)
|
|
42
|
+
return sorted(set(files))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_manifest_paths(manifest_path, section_name):
|
|
46
|
+
items = []
|
|
47
|
+
in_section = False
|
|
48
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
49
|
+
for raw in f:
|
|
50
|
+
line = raw.strip()
|
|
51
|
+
if not line or line.startswith("#"):
|
|
52
|
+
continue
|
|
53
|
+
if line.startswith("[") and line.endswith("]"):
|
|
54
|
+
in_section = line == f"[{section_name}]"
|
|
55
|
+
continue
|
|
56
|
+
if in_section:
|
|
57
|
+
items.append(line)
|
|
58
|
+
return items
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_ownership_manifest(source_root, script_dir):
|
|
62
|
+
candidates = [
|
|
63
|
+
os.path.join(source_root, MANIFEST_RELATIVE_PATH),
|
|
64
|
+
os.path.join(script_dir, MANIFEST_RELATIVE_PATH),
|
|
65
|
+
]
|
|
66
|
+
for path in candidates:
|
|
67
|
+
if not os.path.isfile(path):
|
|
68
|
+
continue
|
|
69
|
+
install_paths = read_manifest_paths(path, "install_include_paths")
|
|
70
|
+
clean_paths = read_manifest_paths(path, "clean_paths")
|
|
71
|
+
required_script_paths = read_manifest_paths(path, MANIFEST_REQUIRED_SCRIPTS_SECTION)
|
|
72
|
+
if not install_paths or not clean_paths:
|
|
73
|
+
raise RuntimeError(f"[INSTALL_MANIFEST_ERROR] {path} is missing required sections or entries.")
|
|
74
|
+
if not required_script_paths:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
f"[INSTALL_MANIFEST_ERROR] {path} is missing [{MANIFEST_REQUIRED_SCRIPTS_SECTION}] entries."
|
|
77
|
+
)
|
|
78
|
+
return install_paths, clean_paths, required_script_paths, path
|
|
79
|
+
raise RuntimeError("[INSTALL_SOURCE_ERROR] installer-owned-paths.manifest not found. Reinstall its-magic package.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_install_completeness(target_root, source_root, required_script_paths, manifest_path):
|
|
83
|
+
missing_paths = []
|
|
84
|
+
for rel in sorted(set(required_script_paths)):
|
|
85
|
+
src = os.path.join(source_root, rel)
|
|
86
|
+
dst = os.path.join(target_root, rel)
|
|
87
|
+
if not os.path.isfile(src) or not os.path.isfile(dst):
|
|
88
|
+
missing_paths.append(rel.replace("\\", "/"))
|
|
89
|
+
if not missing_paths:
|
|
90
|
+
return True
|
|
91
|
+
print(
|
|
92
|
+
"[INSTALL_COMPLETENESS_FAILED] Required installer scripts are missing after "
|
|
93
|
+
"copy/classification invariant check."
|
|
94
|
+
)
|
|
95
|
+
for rel in missing_paths:
|
|
96
|
+
print(f"[INSTALL_REQUIRED_SCRIPT_MISSING:{rel}]")
|
|
97
|
+
print(
|
|
98
|
+
"Fix: update manifest parity and required-script inventory at "
|
|
99
|
+
f"{MANIFEST_RELATIVE_PATH} (section [{MANIFEST_REQUIRED_SCRIPTS_SECTION}]), "
|
|
100
|
+
"ensure each listed script exists in template/scripts and clean-path ownership, "
|
|
101
|
+
"then rerun installer missing/upgrade."
|
|
102
|
+
)
|
|
103
|
+
print(f"Manifest source: {manifest_path}")
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ensure_parent(path):
|
|
108
|
+
parent = os.path.dirname(path)
|
|
109
|
+
if parent and not os.path.isdir(parent):
|
|
110
|
+
os.makedirs(parent, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def backup_files(target_root, rel_paths):
|
|
114
|
+
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%SZ")
|
|
115
|
+
backup_root = os.path.join(target_root, "backups", timestamp)
|
|
116
|
+
for rel in rel_paths:
|
|
117
|
+
src = os.path.join(target_root, rel)
|
|
118
|
+
if os.path.isfile(src):
|
|
119
|
+
dst = os.path.join(backup_root, rel)
|
|
120
|
+
ensure_parent(dst)
|
|
121
|
+
shutil.copy2(src, dst)
|
|
122
|
+
return backup_root
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def choose_mode():
|
|
126
|
+
print("Select install mode:")
|
|
127
|
+
print("1) missing-only (copy only files that do not exist)")
|
|
128
|
+
print("2) overwrite-all (replace existing files)")
|
|
129
|
+
print("3) interactive (prompt per file)")
|
|
130
|
+
print("4) upgrade (update framework files, preserve user data)")
|
|
131
|
+
choice = input("Enter 1, 2, 3, or 4: ").strip()
|
|
132
|
+
if choice == "1":
|
|
133
|
+
return "missing"
|
|
134
|
+
if choice == "2":
|
|
135
|
+
return "overwrite"
|
|
136
|
+
if choice == "4":
|
|
137
|
+
return "upgrade"
|
|
138
|
+
return "interactive"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
FRAMEWORK_PREFIXES = (
|
|
142
|
+
".cursor/commands/", ".cursor/rules/", ".cursor/agents/",
|
|
143
|
+
".cursor/skills/", ".cursor/hooks/", ".github/workflows/",
|
|
144
|
+
"scripts/validate-and-push", "docs/engineering/context/", "its_magic/",
|
|
145
|
+
)
|
|
146
|
+
FRAMEWORK_EXACT = {
|
|
147
|
+
".cursor/hooks.json", ".cursor/scratchpad.local.example.md",
|
|
148
|
+
".its-magic-version", "its_magic/.its-magic-version", "its_magic/README.md",
|
|
149
|
+
}
|
|
150
|
+
USER_DATA_PREFIXES = (
|
|
151
|
+
"docs/product/", "docs/engineering/", "docs/user-guides/",
|
|
152
|
+
"sprints/", "handoffs/", "decisions/",
|
|
153
|
+
)
|
|
154
|
+
MIXED_FILES = {"README.md"}
|
|
155
|
+
|
|
156
|
+
# Model B (DEC-0055 / US-0073): baseline bytes live in template only; installs materialize `.cursor/scratchpad.md`.
|
|
157
|
+
SCRATCHPAD_BASELINE_REL = os.path.join(".cursor", "scratchpad.md")
|
|
158
|
+
SCRATCHPAD_EXAMPLE_REL = os.path.join(".cursor", "scratchpad.local.example.md")
|
|
159
|
+
SCRATCHPAD_LOCAL_REL = os.path.join(".cursor", "scratchpad.local.md")
|
|
160
|
+
|
|
161
|
+
# After merge (local > baseline > example), these must be non-empty (fail closed).
|
|
162
|
+
REQUIRED_SCRATCHPAD_KEYS = (
|
|
163
|
+
"MAGIC_CONTEXT_STRICT",
|
|
164
|
+
"AUTO_FLOW_MODE",
|
|
165
|
+
"PHASE_MODE",
|
|
166
|
+
"PERMISSION_MODE",
|
|
167
|
+
"AUTO_LOOP_MAX_CYCLES",
|
|
168
|
+
"SYNC_POLICY_MODE",
|
|
169
|
+
"DONE",
|
|
170
|
+
"TEAM_MODE",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_scratchpad_file(path):
|
|
175
|
+
"""Parse KEY=value lines; empty values are retained (explicit override to empty)."""
|
|
176
|
+
if not os.path.isfile(path):
|
|
177
|
+
return {}
|
|
178
|
+
out = {}
|
|
179
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
180
|
+
for raw in f:
|
|
181
|
+
line = raw.strip()
|
|
182
|
+
if not line or line.startswith("#") or line.startswith("- "):
|
|
183
|
+
continue
|
|
184
|
+
if "=" not in line:
|
|
185
|
+
continue
|
|
186
|
+
key, _, val = line.partition("=")
|
|
187
|
+
key = key.strip()
|
|
188
|
+
if not key:
|
|
189
|
+
continue
|
|
190
|
+
out[key] = val.strip()
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def merge_scratchpad_layers(target_root):
|
|
195
|
+
"""
|
|
196
|
+
Model B merge precedence: local > materialized baseline > example (later wins only when key absent).
|
|
197
|
+
"""
|
|
198
|
+
ex_path = os.path.join(target_root, SCRATCHPAD_EXAMPLE_REL)
|
|
199
|
+
base_path = os.path.join(target_root, SCRATCHPAD_BASELINE_REL)
|
|
200
|
+
loc_path = os.path.join(target_root, SCRATCHPAD_LOCAL_REL)
|
|
201
|
+
example = parse_scratchpad_file(ex_path)
|
|
202
|
+
baseline = parse_scratchpad_file(base_path)
|
|
203
|
+
local = parse_scratchpad_file(loc_path)
|
|
204
|
+
merged = {}
|
|
205
|
+
all_keys = set(example) | set(baseline) | set(local)
|
|
206
|
+
for key in all_keys:
|
|
207
|
+
if key in local:
|
|
208
|
+
merged[key] = local[key]
|
|
209
|
+
elif key in baseline:
|
|
210
|
+
merged[key] = baseline[key]
|
|
211
|
+
elif key in example:
|
|
212
|
+
merged[key] = example[key]
|
|
213
|
+
paths = {"example": ex_path, "baseline": base_path, "local": loc_path}
|
|
214
|
+
return merged, paths
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def validate_merged_scratchpad(target_root):
|
|
218
|
+
"""Return (ok, list of diagnostic lines)."""
|
|
219
|
+
merged, paths = merge_scratchpad_layers(target_root)
|
|
220
|
+
diagnostics = []
|
|
221
|
+
if not os.path.isfile(paths["example"]):
|
|
222
|
+
diagnostics.append(
|
|
223
|
+
"[SCRATCHPAD_MERGE_ERROR] EXAMPLE_LAYER_MISSING: "
|
|
224
|
+
f".cursor/scratchpad.local.example.md not found under {target_root}. "
|
|
225
|
+
"Fix: re-run its-magic install/upgrade."
|
|
226
|
+
)
|
|
227
|
+
if not os.path.isfile(paths["baseline"]):
|
|
228
|
+
diagnostics.append(
|
|
229
|
+
"[SCRATCHPAD_MERGE_ERROR] MATERIALIZED_BASELINE_MISSING: "
|
|
230
|
+
f".cursor/scratchpad.md not found under {target_root}. "
|
|
231
|
+
"Fix: run `python installer.py --scratchpad-postinstall --target <repo> --mode missing` "
|
|
232
|
+
"or re-run its-magic install (Model B materialization; see docs)."
|
|
233
|
+
)
|
|
234
|
+
missing = []
|
|
235
|
+
for key in REQUIRED_SCRATCHPAD_KEYS:
|
|
236
|
+
val = merged.get(key)
|
|
237
|
+
if val is None or str(val).strip() == "":
|
|
238
|
+
missing.append(key)
|
|
239
|
+
if missing:
|
|
240
|
+
diagnostics.append(
|
|
241
|
+
"[SCRATCHPAD_MERGE_ERROR] REQUIRED_KEY_MISSING_AFTER_MERGE: "
|
|
242
|
+
f"keys={','.join(missing)}. Layers consulted: local, baseline|materialized, example "
|
|
243
|
+
f"({paths['local']}, {paths['baseline']}, {paths['example']}). "
|
|
244
|
+
"Fix: set non-empty values in .cursor/scratchpad.local.md or restore materialized baseline from template."
|
|
245
|
+
)
|
|
246
|
+
ok = not diagnostics
|
|
247
|
+
return ok, diagnostics
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def materialize_scratchpad_example(target_root, source_root, print_ok=True):
|
|
251
|
+
"""
|
|
252
|
+
Always refresh framework-owned scratchpad.local.example from template first
|
|
253
|
+
(example-first ordering before baseline; never touches scratchpad.local.md).
|
|
254
|
+
"""
|
|
255
|
+
src = os.path.join(source_root, SCRATCHPAD_EXAMPLE_REL)
|
|
256
|
+
dst = os.path.join(target_root, SCRATCHPAD_EXAMPLE_REL)
|
|
257
|
+
if not os.path.isfile(src):
|
|
258
|
+
print(
|
|
259
|
+
"[SCRATCHPAD_EXAMPLE_ERROR] TEMPLATE_EXAMPLE_MISSING: "
|
|
260
|
+
f"expected template file at {src}. Reinstall its-magic package."
|
|
261
|
+
)
|
|
262
|
+
return False
|
|
263
|
+
ensure_parent(dst)
|
|
264
|
+
shutil.copy2(src, dst)
|
|
265
|
+
if print_ok:
|
|
266
|
+
print(
|
|
267
|
+
"[SCRATCHPAD_LAYER] example_refresh: copied template "
|
|
268
|
+
f"{SCRATCHPAD_EXAMPLE_REL} -> target (ordering: example before baseline)."
|
|
269
|
+
)
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def materialize_scratchpad_baseline(target_root, source_root, mode, print_ok=True):
|
|
274
|
+
"""
|
|
275
|
+
Write stable baseline bytes from template when Model B requires it.
|
|
276
|
+
Never touches .cursor/scratchpad.local.md.
|
|
277
|
+
"""
|
|
278
|
+
src = os.path.join(source_root, SCRATCHPAD_BASELINE_REL)
|
|
279
|
+
dst = os.path.join(target_root, SCRATCHPAD_BASELINE_REL)
|
|
280
|
+
if not os.path.isfile(src):
|
|
281
|
+
print(
|
|
282
|
+
"[SCRATCHPAD_MATERIALIZE_ERROR] TEMPLATE_BASELINE_MISSING: "
|
|
283
|
+
f"expected template file at {src}. Reinstall its-magic package."
|
|
284
|
+
)
|
|
285
|
+
return False
|
|
286
|
+
wrote = False
|
|
287
|
+
if mode == "overwrite":
|
|
288
|
+
ensure_parent(dst)
|
|
289
|
+
shutil.copy2(src, dst)
|
|
290
|
+
wrote = True
|
|
291
|
+
elif mode == "upgrade":
|
|
292
|
+
if not os.path.isfile(dst):
|
|
293
|
+
ensure_parent(dst)
|
|
294
|
+
shutil.copy2(src, dst)
|
|
295
|
+
wrote = True
|
|
296
|
+
else:
|
|
297
|
+
# missing, interactive
|
|
298
|
+
if not os.path.isfile(dst):
|
|
299
|
+
ensure_parent(dst)
|
|
300
|
+
shutil.copy2(src, dst)
|
|
301
|
+
wrote = True
|
|
302
|
+
if wrote and print_ok:
|
|
303
|
+
print(
|
|
304
|
+
"[SCRATCHPAD_LAYER] baseline_materialize: wrote materialized "
|
|
305
|
+
f"{SCRATCHPAD_BASELINE_REL} from template (Model B)."
|
|
306
|
+
)
|
|
307
|
+
elif print_ok and os.path.isfile(dst):
|
|
308
|
+
print(
|
|
309
|
+
"[SCRATCHPAD_LAYER] baseline_skip: materialized baseline already present "
|
|
310
|
+
f"({SCRATCHPAD_BASELINE_REL}); not overwritten in this mode."
|
|
311
|
+
)
|
|
312
|
+
return True
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _load_doc_profile_lib():
|
|
316
|
+
"""
|
|
317
|
+
Load doc_profile_lib from scripts/ adjacent to this installer (repo checkout or npm package root).
|
|
318
|
+
Path is derived from __file__ only (no cwd / PYTHONPATH dependency).
|
|
319
|
+
"""
|
|
320
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
321
|
+
path = os.path.join(here, "scripts", "doc_profile_lib.py")
|
|
322
|
+
if not os.path.isfile(path):
|
|
323
|
+
raise RuntimeError(
|
|
324
|
+
"[DOC_PROFILE_LIB_MISSING] Expected documentation profile library at "
|
|
325
|
+
f"{path} (same directory as installer.py). "
|
|
326
|
+
"Global installs require this file in the published its-magic package; "
|
|
327
|
+
f"reinstall or upgrade its-magic ({REPO_URL})."
|
|
328
|
+
)
|
|
329
|
+
spec = importlib.util.spec_from_file_location("doc_profile_lib", path)
|
|
330
|
+
if spec is None or spec.loader is None:
|
|
331
|
+
raise RuntimeError(
|
|
332
|
+
"[DOC_PROFILE_LIB_LOAD_ERROR] Could not create import spec for "
|
|
333
|
+
f"{path}. Reinstall its-magic ({REPO_URL})."
|
|
334
|
+
)
|
|
335
|
+
mod = importlib.util.module_from_spec(spec)
|
|
336
|
+
sys.modules["doc_profile_lib"] = mod
|
|
337
|
+
try:
|
|
338
|
+
spec.loader.exec_module(mod)
|
|
339
|
+
except Exception as e:
|
|
340
|
+
sys.modules.pop("doc_profile_lib", None)
|
|
341
|
+
raise RuntimeError(
|
|
342
|
+
"[DOC_PROFILE_LIB_LOAD_ERROR] doc_profile_lib failed to load "
|
|
343
|
+
f"({e!r}). Reinstall its-magic ({REPO_URL})."
|
|
344
|
+
) from e
|
|
345
|
+
return mod
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _doc_profile_sync(target_root, merged, print_ok=True):
|
|
349
|
+
"""Append missing normative README/developer doc sections from merged profile (non-destructive)."""
|
|
350
|
+
doc_profile_lib = _load_doc_profile_lib()
|
|
351
|
+
notes = doc_profile_lib.ensure_doc_surfaces_merged(merged, target_root, print_ok=print_ok)
|
|
352
|
+
bad = [ln for ln in notes if ln.startswith("[DOC_PROFILE_INVALID]")]
|
|
353
|
+
return (not bad), notes
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def run_scratchpad_postinstall(target_root, source_root, mode, print_ok=True):
|
|
357
|
+
if not materialize_scratchpad_example(target_root, source_root, print_ok=print_ok):
|
|
358
|
+
return False
|
|
359
|
+
if not materialize_scratchpad_baseline(target_root, source_root, mode, print_ok=print_ok):
|
|
360
|
+
return False
|
|
361
|
+
ok, diagnostics = validate_merged_scratchpad(target_root)
|
|
362
|
+
for line in diagnostics:
|
|
363
|
+
print(line)
|
|
364
|
+
if ok:
|
|
365
|
+
merged, _paths = merge_scratchpad_layers(target_root)
|
|
366
|
+
try:
|
|
367
|
+
dp_ok, dp_notes = _doc_profile_sync(target_root, merged, print_ok=print_ok)
|
|
368
|
+
except RuntimeError as e:
|
|
369
|
+
print(str(e))
|
|
370
|
+
ok = False
|
|
371
|
+
else:
|
|
372
|
+
for line in dp_notes:
|
|
373
|
+
print(line)
|
|
374
|
+
if not dp_ok:
|
|
375
|
+
ok = False
|
|
376
|
+
if ok and print_ok:
|
|
377
|
+
loc = os.path.join(target_root, SCRATCHPAD_LOCAL_REL)
|
|
378
|
+
if os.path.isfile(loc):
|
|
379
|
+
print(
|
|
380
|
+
"[SCRATCHPAD_LAYER] user_local: preserved "
|
|
381
|
+
f"{SCRATCHPAD_LOCAL_REL} (merge precedence unchanged)."
|
|
382
|
+
)
|
|
383
|
+
print(
|
|
384
|
+
"[SCRATCHPAD_POSTINSTALL_OK] Model B: example refreshed, baseline handled, "
|
|
385
|
+
"merged scratchpad validation passed."
|
|
386
|
+
)
|
|
387
|
+
return ok
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def classify_file(rel_path):
|
|
391
|
+
normalized = rel_path.replace(os.sep, "/")
|
|
392
|
+
if normalized in MIXED_FILES:
|
|
393
|
+
return "mixed"
|
|
394
|
+
for p in FRAMEWORK_PREFIXES:
|
|
395
|
+
if normalized.startswith(p):
|
|
396
|
+
return "framework"
|
|
397
|
+
if normalized in FRAMEWORK_EXACT:
|
|
398
|
+
return "framework"
|
|
399
|
+
for p in USER_DATA_PREFIXES:
|
|
400
|
+
if normalized.startswith(p):
|
|
401
|
+
return "user-data"
|
|
402
|
+
return "framework"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def read_installed_version(target_root):
|
|
406
|
+
primary = os.path.join(target_root, "its_magic", ".its-magic-version")
|
|
407
|
+
if os.path.isfile(primary):
|
|
408
|
+
with open(primary, "r", encoding="utf-8") as f:
|
|
409
|
+
return f.read().strip()
|
|
410
|
+
legacy = os.path.join(target_root, ".its-magic-version")
|
|
411
|
+
if os.path.isfile(legacy):
|
|
412
|
+
with open(legacy, "r", encoding="utf-8") as f:
|
|
413
|
+
return f.read().strip()
|
|
414
|
+
return "unknown"
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def write_installed_version(target_root, ver):
|
|
418
|
+
vf = os.path.join(target_root, "its_magic", ".its-magic-version")
|
|
419
|
+
ensure_parent(vf)
|
|
420
|
+
with open(vf, "w", encoding="utf-8") as f:
|
|
421
|
+
f.write(ver)
|
|
422
|
+
legacy = os.path.join(target_root, ".its-magic-version")
|
|
423
|
+
if os.path.isfile(legacy):
|
|
424
|
+
os.remove(legacy)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def sync_root_readme_to_its_magic(target_root):
|
|
428
|
+
root_readme = os.path.join(target_root, "README.md")
|
|
429
|
+
if not os.path.isfile(root_readme):
|
|
430
|
+
return False
|
|
431
|
+
its_magic_readme = os.path.join(target_root, "its_magic", "README.md")
|
|
432
|
+
ensure_parent(its_magic_readme)
|
|
433
|
+
shutil.copy2(root_readme, its_magic_readme)
|
|
434
|
+
return True
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def read_runbook_key(runbook_path, key):
|
|
438
|
+
if not os.path.isfile(runbook_path):
|
|
439
|
+
return ""
|
|
440
|
+
needle = f"{key}:"
|
|
441
|
+
with open(runbook_path, "r", encoding="utf-8") as f:
|
|
442
|
+
for raw in f:
|
|
443
|
+
line = raw.rstrip("\n")
|
|
444
|
+
if line.startswith(needle):
|
|
445
|
+
return line[len(needle):].strip()
|
|
446
|
+
return ""
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def write_runbook_key(runbook_path, key, value):
|
|
450
|
+
with open(runbook_path, "r", encoding="utf-8") as f:
|
|
451
|
+
lines = f.readlines()
|
|
452
|
+
needle = f"{key}:"
|
|
453
|
+
changed = False
|
|
454
|
+
for idx, raw in enumerate(lines):
|
|
455
|
+
line = raw.rstrip("\n")
|
|
456
|
+
if line.startswith(needle):
|
|
457
|
+
lines[idx] = f"{needle} {value}\n"
|
|
458
|
+
changed = True
|
|
459
|
+
break
|
|
460
|
+
if changed:
|
|
461
|
+
with open(runbook_path, "w", encoding="utf-8") as f:
|
|
462
|
+
f.writelines(lines)
|
|
463
|
+
return changed
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def package_has_script(target_root, script_name):
|
|
467
|
+
pkg_path = os.path.join(target_root, "package.json")
|
|
468
|
+
if not os.path.isfile(pkg_path):
|
|
469
|
+
return False
|
|
470
|
+
try:
|
|
471
|
+
with open(pkg_path, "r", encoding="utf-8") as f:
|
|
472
|
+
pkg = json.load(f)
|
|
473
|
+
scripts = pkg.get("scripts", {})
|
|
474
|
+
val = scripts.get(script_name)
|
|
475
|
+
return isinstance(val, str) and val.strip() != ""
|
|
476
|
+
except Exception:
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def detect_runbook_defaults(target_root):
|
|
481
|
+
tests_sh = os.path.join(target_root, "tests", "run-tests.sh")
|
|
482
|
+
has_pkg = os.path.isfile(os.path.join(target_root, "package.json"))
|
|
483
|
+
has_py = any(
|
|
484
|
+
os.path.isfile(os.path.join(target_root, p))
|
|
485
|
+
for p in ("pyproject.toml", "requirements.txt", "setup.py")
|
|
486
|
+
)
|
|
487
|
+
has_go = os.path.isfile(os.path.join(target_root, "go.mod"))
|
|
488
|
+
|
|
489
|
+
result = {"TEST_COMMAND": "", "LINT_COMMAND": "", "TYPECHECK_COMMAND": ""}
|
|
490
|
+
|
|
491
|
+
if has_pkg and package_has_script(target_root, "test"):
|
|
492
|
+
result["TEST_COMMAND"] = "npm run test"
|
|
493
|
+
if package_has_script(target_root, "lint"):
|
|
494
|
+
result["LINT_COMMAND"] = "npm run lint"
|
|
495
|
+
if package_has_script(target_root, "typecheck"):
|
|
496
|
+
result["TYPECHECK_COMMAND"] = "npm run typecheck"
|
|
497
|
+
elif has_go:
|
|
498
|
+
result["TEST_COMMAND"] = "go test ./..."
|
|
499
|
+
elif has_py:
|
|
500
|
+
result["TEST_COMMAND"] = "python -m pytest"
|
|
501
|
+
elif os.path.isfile(tests_sh):
|
|
502
|
+
result["TEST_COMMAND"] = "sh tests/run-tests.sh"
|
|
503
|
+
|
|
504
|
+
return result
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def validate_bootstrap_command(target_root, key, command):
|
|
508
|
+
if not command:
|
|
509
|
+
return False, f"{key}_UNDETECTED"
|
|
510
|
+
if command.startswith("npm run "):
|
|
511
|
+
if not shutil.which("npm"):
|
|
512
|
+
return False, "NPM_NOT_FOUND"
|
|
513
|
+
script_name = command[len("npm run "):].strip()
|
|
514
|
+
if not package_has_script(target_root, script_name):
|
|
515
|
+
return False, f"NPM_SCRIPT_MISSING:{script_name}"
|
|
516
|
+
return True, "OK"
|
|
517
|
+
if command.startswith("python -m "):
|
|
518
|
+
if not shutil.which("python"):
|
|
519
|
+
return False, "PYTHON_NOT_FOUND"
|
|
520
|
+
if command == "python -m pytest":
|
|
521
|
+
has_py = any(
|
|
522
|
+
os.path.isfile(os.path.join(target_root, p))
|
|
523
|
+
for p in ("pyproject.toml", "requirements.txt", "setup.py")
|
|
524
|
+
)
|
|
525
|
+
if not has_py:
|
|
526
|
+
return False, "PYTHON_STACK_MARKERS_MISSING"
|
|
527
|
+
return True, "OK"
|
|
528
|
+
if command.startswith("go test "):
|
|
529
|
+
if not shutil.which("go"):
|
|
530
|
+
return False, "GO_NOT_FOUND"
|
|
531
|
+
if not os.path.isfile(os.path.join(target_root, "go.mod")):
|
|
532
|
+
return False, "GO_MOD_MISSING"
|
|
533
|
+
return True, "OK"
|
|
534
|
+
if command.startswith("powershell "):
|
|
535
|
+
if not os.path.isfile(os.path.join(target_root, "tests", "run-tests.ps1")):
|
|
536
|
+
return False, "RUN_TESTS_PS1_MISSING"
|
|
537
|
+
return True, "OK"
|
|
538
|
+
if command.startswith("sh "):
|
|
539
|
+
if not shutil.which("sh"):
|
|
540
|
+
return False, "SH_NOT_FOUND"
|
|
541
|
+
if not os.path.isfile(os.path.join(target_root, "tests", "run-tests.sh")):
|
|
542
|
+
return False, "RUN_TESTS_SH_MISSING"
|
|
543
|
+
return True, "OK"
|
|
544
|
+
try:
|
|
545
|
+
parts = shlex.split(command)
|
|
546
|
+
except ValueError:
|
|
547
|
+
return False, "COMMAND_PARSE_FAILED"
|
|
548
|
+
if not parts:
|
|
549
|
+
return False, "COMMAND_EMPTY"
|
|
550
|
+
if not shutil.which(parts[0]):
|
|
551
|
+
return False, f"EXECUTABLE_NOT_FOUND:{parts[0]}"
|
|
552
|
+
return True, "OK"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def bootstrap_runbook_commands(target_root):
|
|
556
|
+
runbook_path = os.path.join(target_root, "docs", "engineering", "runbook.md")
|
|
557
|
+
if not os.path.isfile(runbook_path):
|
|
558
|
+
return True, []
|
|
559
|
+
|
|
560
|
+
defaults = detect_runbook_defaults(target_root)
|
|
561
|
+
diagnostics = []
|
|
562
|
+
changed = []
|
|
563
|
+
|
|
564
|
+
for key in ("TEST_COMMAND", "LINT_COMMAND", "TYPECHECK_COMMAND"):
|
|
565
|
+
current = read_runbook_key(runbook_path, key)
|
|
566
|
+
if current:
|
|
567
|
+
continue
|
|
568
|
+
candidate = defaults.get(key, "")
|
|
569
|
+
if not candidate:
|
|
570
|
+
if key == "TEST_COMMAND":
|
|
571
|
+
diagnostics.append(
|
|
572
|
+
"[RUNBOOK_BOOTSTRAP_ERROR] TEST_COMMAND_UNRESOLVED: could not detect a valid baseline test command. "
|
|
573
|
+
"Fix: define TEST_COMMAND in docs/engineering/runbook.md or add detectable stack markers (package.json scripts.test, pyproject.toml, go.mod)."
|
|
574
|
+
)
|
|
575
|
+
continue
|
|
576
|
+
valid, reason = validate_bootstrap_command(target_root, key, candidate)
|
|
577
|
+
if valid:
|
|
578
|
+
if write_runbook_key(runbook_path, key, candidate):
|
|
579
|
+
changed.append(f"{key}={candidate}")
|
|
580
|
+
elif key == "TEST_COMMAND":
|
|
581
|
+
diagnostics.append(
|
|
582
|
+
f"[RUNBOOK_BOOTSTRAP_ERROR] TEST_COMMAND_INVALID:{reason}. "
|
|
583
|
+
"Fix: set a valid TEST_COMMAND in docs/engineering/runbook.md."
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
if changed:
|
|
587
|
+
diagnostics.append("[RUNBOOK_BOOTSTRAP] Applied defaults: " + ", ".join(changed))
|
|
588
|
+
|
|
589
|
+
has_test = read_runbook_key(runbook_path, "TEST_COMMAND") != ""
|
|
590
|
+
if not has_test:
|
|
591
|
+
return False, diagnostics
|
|
592
|
+
return True, diagnostics
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def prompt_yes_no(label, default=False):
|
|
596
|
+
suffix = "Y/n" if default else "y/N"
|
|
597
|
+
value = input(f"{label} [{suffix}]: ").strip().lower()
|
|
598
|
+
if not value:
|
|
599
|
+
return default
|
|
600
|
+
return value in ("y", "yes")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def show_banner(include_install_message=False):
|
|
604
|
+
try:
|
|
605
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
606
|
+
except Exception:
|
|
607
|
+
pass
|
|
608
|
+
m = "\033[1;35m"
|
|
609
|
+
c = "\033[1;36m"
|
|
610
|
+
y = "\033[1;33m"
|
|
611
|
+
g = "\033[1;32m"
|
|
612
|
+
r = "\033[0m"
|
|
613
|
+
print()
|
|
614
|
+
print(f"{m} ██╗████████╗███████╗ ███╗ ███╗ █████╗ ██████╗ ██╗ ██████╗{r}")
|
|
615
|
+
print(f"{m} ██║╚══██╔══╝██╔════╝ ████╗ ████║██╔══██╗██╔════╝ ██║██╔════╝{r}")
|
|
616
|
+
print(f"{m} ██║ ██║ ███████╗█████╗██╔████╔██║███████║██║ ███╗██║██║ {r}")
|
|
617
|
+
print(f"{c} ██║ ██║ ╚════██║╚════╝██║╚██╔╝██║██╔══██║██║ ██║██║██║ {r}")
|
|
618
|
+
print(f"{c} ██║ ██║ ███████║ ██║ ╚═╝ ██║██║ ██║╚██████╔╝██║╚██████╗{r}")
|
|
619
|
+
print(f"{c} ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝{r}")
|
|
620
|
+
print()
|
|
621
|
+
print(f"{y} AI dev team{r}")
|
|
622
|
+
if include_install_message:
|
|
623
|
+
print(f"{g} Installation complete!{r}")
|
|
624
|
+
print()
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def show_help(version):
|
|
628
|
+
show_banner(include_install_message=False)
|
|
629
|
+
print(f"its-magic v{version}")
|
|
630
|
+
print(f"Repository: {REPO_URL}")
|
|
631
|
+
print()
|
|
632
|
+
print("Install AI dev team workflow files into any Cursor repository.")
|
|
633
|
+
print()
|
|
634
|
+
print("Usage:")
|
|
635
|
+
print(" its-magic --target <path> [--mode <mode>] [--backup] [--create]")
|
|
636
|
+
print(" its-magic --clean-repo [--target <path>] [--yes]")
|
|
637
|
+
print(" its-magic --help | --version")
|
|
638
|
+
print()
|
|
639
|
+
print("Install options:")
|
|
640
|
+
print(" --target <path> Path to the repository where workflow files are installed.")
|
|
641
|
+
print(" If omitted you will be prompted interactively.")
|
|
642
|
+
print(" --mode <mode> How to handle files that already exist in the target:")
|
|
643
|
+
print(" missing Only copy files that do not exist yet (default).")
|
|
644
|
+
print(" Safe for repos that already have some workflow files.")
|
|
645
|
+
print(" overwrite Replace every file, even if it already exists.")
|
|
646
|
+
print(" Combine with --backup to keep a snapshot first.")
|
|
647
|
+
print(" interactive Ask per file whether to overwrite or skip.")
|
|
648
|
+
print(" upgrade Update framework files while preserving user data.")
|
|
649
|
+
print(" Use after updating its-magic to a newer version.")
|
|
650
|
+
print(" --backup Before overwriting, save existing files to backups/<timestamp>/.")
|
|
651
|
+
print(" Ignored when mode is 'missing' (nothing gets replaced).")
|
|
652
|
+
print(" --create Create the target directory if it does not exist.")
|
|
653
|
+
print(" Note: installer bootstraps runbook TEST/LINT/TYPECHECK commands")
|
|
654
|
+
print(" from OS+stack detection; unresolved TEST_COMMAND fails fast with")
|
|
655
|
+
print(" [RUNBOOK_BOOTSTRAP_ERROR] diagnostics.")
|
|
656
|
+
print(" Note: scratchpad Model B: `.cursor/scratchpad.md` is")
|
|
657
|
+
print(" materialized from the packaged template when missing; merged validation")
|
|
658
|
+
print(" requires Python 3 on PATH for installer.ps1 / installer.sh. Recovery:")
|
|
659
|
+
print(" python installer.py --scratchpad-postinstall --target <repo> --mode missing")
|
|
660
|
+
print()
|
|
661
|
+
print("Clean options:")
|
|
662
|
+
print(" --clean-repo Remove all its-magic workflow artifacts from the target repo")
|
|
663
|
+
print(" (owned paths from installer manifest, including .cursor,")
|
|
664
|
+
print(" docs/product, docs/engineering, docs/user-guides, sprints,")
|
|
665
|
+
print(" handoffs, decisions, workflow scripts, CI files, and")
|
|
666
|
+
print(" installer metadata under its_magic/ (legacy .its-magic-version")
|
|
667
|
+
print(" is also removed when present). Your own source code is never touched.")
|
|
668
|
+
print(" --target <path> Repo to clean (default: current directory).")
|
|
669
|
+
print(" --yes Skip the confirmation prompt.")
|
|
670
|
+
print()
|
|
671
|
+
print("Info:")
|
|
672
|
+
print(" --help, -h Show this help and exit.")
|
|
673
|
+
print(" --version, -v Print the installed version and exit.")
|
|
674
|
+
print()
|
|
675
|
+
print("Examples:")
|
|
676
|
+
print(" its-magic --target . --mode missing Safe first-time setup")
|
|
677
|
+
print(" its-magic --target . --mode upgrade Update framework, keep user data")
|
|
678
|
+
print(" its-magic --target . --mode overwrite --backup Replace all files, keep backup")
|
|
679
|
+
print(" its-magic --clean-repo --target . --yes Remove workflow artifacts silently")
|
|
680
|
+
print()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def clean_repo(target_root, clean_paths):
|
|
684
|
+
for rel in clean_paths:
|
|
685
|
+
full = os.path.join(target_root, rel)
|
|
686
|
+
if os.path.exists(full):
|
|
687
|
+
if os.path.isdir(full):
|
|
688
|
+
shutil.rmtree(full)
|
|
689
|
+
else:
|
|
690
|
+
os.remove(full)
|
|
691
|
+
print(f"Removed: {rel}")
|
|
692
|
+
print("Clean completed.")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def main():
|
|
696
|
+
script_dir = normalize(os.path.dirname(__file__))
|
|
697
|
+
template_dir = os.path.join(script_dir, "template")
|
|
698
|
+
source_root = template_dir
|
|
699
|
+
version = read_version(script_dir)
|
|
700
|
+
|
|
701
|
+
parser = argparse.ArgumentParser(
|
|
702
|
+
description="Install its-magic into a repo",
|
|
703
|
+
add_help=False,
|
|
704
|
+
)
|
|
705
|
+
parser.add_argument("--target", help="Target repository path")
|
|
706
|
+
parser.add_argument("--mode", choices=["missing", "overwrite", "interactive", "upgrade"], help="Install mode")
|
|
707
|
+
parser.add_argument("--backup", action="store_true", help="Backup files before overwriting")
|
|
708
|
+
parser.add_argument("--create", action="store_true", help="Create target directory if missing")
|
|
709
|
+
parser.add_argument("--clean-repo", action="store_true", help="Remove installed workflow artifacts")
|
|
710
|
+
parser.add_argument("--yes", action="store_true", help="Skip clean confirmation prompt")
|
|
711
|
+
parser.add_argument("--help", "-h", action="store_true", help="Show help")
|
|
712
|
+
parser.add_argument("--version", "-v", action="store_true", help="Show version")
|
|
713
|
+
parser.add_argument(
|
|
714
|
+
"--scratchpad-postinstall",
|
|
715
|
+
action="store_true",
|
|
716
|
+
help=argparse.SUPPRESS,
|
|
717
|
+
)
|
|
718
|
+
parser.add_argument("--source-root", help=argparse.SUPPRESS)
|
|
719
|
+
parser.add_argument(
|
|
720
|
+
"--validate-install-completeness",
|
|
721
|
+
action="store_true",
|
|
722
|
+
help=argparse.SUPPRESS,
|
|
723
|
+
)
|
|
724
|
+
args = parser.parse_args()
|
|
725
|
+
|
|
726
|
+
if len(sys.argv) == 1 or args.help:
|
|
727
|
+
show_help(version)
|
|
728
|
+
return 0
|
|
729
|
+
|
|
730
|
+
if args.version:
|
|
731
|
+
print(f"its-magic v{version}")
|
|
732
|
+
return 0
|
|
733
|
+
|
|
734
|
+
if args.source_root:
|
|
735
|
+
source_root = normalize(args.source_root)
|
|
736
|
+
|
|
737
|
+
if args.scratchpad_postinstall:
|
|
738
|
+
target_root = normalize(args.target) if args.target else normalize(".")
|
|
739
|
+
mode = args.mode or "missing"
|
|
740
|
+
if mode not in ("missing", "overwrite", "interactive", "upgrade"):
|
|
741
|
+
print(
|
|
742
|
+
"[SCRATCHPAD_POSTINSTALL_ERROR] INVALID_MODE: use --mode "
|
|
743
|
+
"missing|overwrite|interactive|upgrade with --scratchpad-postinstall."
|
|
744
|
+
)
|
|
745
|
+
return 1
|
|
746
|
+
if not os.path.isdir(source_root):
|
|
747
|
+
print("[INSTALL_SOURCE_ERROR] template directory is missing. Reinstall its-magic package.")
|
|
748
|
+
return 1
|
|
749
|
+
if not os.path.isdir(target_root):
|
|
750
|
+
print(f"[SCRATCHPAD_POSTINSTALL_ERROR] TARGET_MISSING: {target_root}")
|
|
751
|
+
return 1
|
|
752
|
+
ok = run_scratchpad_postinstall(target_root, source_root, mode, print_ok=True)
|
|
753
|
+
return 0 if ok else 1
|
|
754
|
+
|
|
755
|
+
if args.validate_install_completeness:
|
|
756
|
+
target_root = normalize(args.target) if args.target else normalize(".")
|
|
757
|
+
if not os.path.isdir(target_root):
|
|
758
|
+
print(f"[INSTALL_COMPLETENESS_FAILED] target directory missing: {target_root}")
|
|
759
|
+
return 1
|
|
760
|
+
if not os.path.isdir(source_root):
|
|
761
|
+
print("[INSTALL_SOURCE_ERROR] template directory is missing. Reinstall its-magic package.")
|
|
762
|
+
return 1
|
|
763
|
+
try:
|
|
764
|
+
_install_paths, _clean_paths, required_script_paths, manifest_path = load_ownership_manifest(
|
|
765
|
+
source_root, script_dir
|
|
766
|
+
)
|
|
767
|
+
except RuntimeError as exc:
|
|
768
|
+
print(str(exc))
|
|
769
|
+
return 1
|
|
770
|
+
ok = validate_install_completeness(target_root, source_root, required_script_paths, manifest_path)
|
|
771
|
+
return 0 if ok else 1
|
|
772
|
+
|
|
773
|
+
if not os.path.isdir(source_root):
|
|
774
|
+
print("[INSTALL_SOURCE_ERROR] template directory is missing. Reinstall its-magic package.")
|
|
775
|
+
return 1
|
|
776
|
+
try:
|
|
777
|
+
include_paths, clean_paths, required_script_paths, manifest_path = load_ownership_manifest(
|
|
778
|
+
source_root, script_dir
|
|
779
|
+
)
|
|
780
|
+
except RuntimeError as exc:
|
|
781
|
+
print(str(exc))
|
|
782
|
+
return 1
|
|
783
|
+
|
|
784
|
+
target_root = normalize(args.target) if args.target else None
|
|
785
|
+
|
|
786
|
+
if args.clean_repo:
|
|
787
|
+
if not target_root:
|
|
788
|
+
target_root = normalize(".")
|
|
789
|
+
if not os.path.isdir(target_root):
|
|
790
|
+
print("Target directory does not exist.")
|
|
791
|
+
return 1
|
|
792
|
+
if not args.yes and not prompt_yes_no(f"Clean its-magic workflow artifacts in {target_root}?", default=False):
|
|
793
|
+
print("Aborted.")
|
|
794
|
+
return 1
|
|
795
|
+
clean_repo(target_root, clean_paths)
|
|
796
|
+
return 0
|
|
797
|
+
|
|
798
|
+
if not target_root:
|
|
799
|
+
target_root = normalize(input("Target repository path: ").strip())
|
|
800
|
+
|
|
801
|
+
if not os.path.isdir(target_root):
|
|
802
|
+
if args.create or prompt_yes_no("Target missing. Create?", default=False):
|
|
803
|
+
os.makedirs(target_root, exist_ok=True)
|
|
804
|
+
else:
|
|
805
|
+
print("Target directory does not exist.")
|
|
806
|
+
return 1
|
|
807
|
+
|
|
808
|
+
mode = args.mode or choose_mode()
|
|
809
|
+
backup_enabled = args.backup
|
|
810
|
+
if mode in ("overwrite", "interactive") and not args.backup:
|
|
811
|
+
backup_enabled = prompt_yes_no("Backup existing files before overwrite?", False)
|
|
812
|
+
|
|
813
|
+
files = list_source_files(source_root, include_paths)
|
|
814
|
+
if not files:
|
|
815
|
+
print("No source files found to install.")
|
|
816
|
+
return 1
|
|
817
|
+
|
|
818
|
+
overwrite_candidates = []
|
|
819
|
+
if backup_enabled and mode == "overwrite":
|
|
820
|
+
for rel in files:
|
|
821
|
+
if os.path.isfile(os.path.join(target_root, rel)):
|
|
822
|
+
overwrite_candidates.append(rel)
|
|
823
|
+
if overwrite_candidates:
|
|
824
|
+
broot = backup_files(target_root, overwrite_candidates)
|
|
825
|
+
print(f"Backup created at: {broot}")
|
|
826
|
+
|
|
827
|
+
if mode == "upgrade":
|
|
828
|
+
old_ver = read_installed_version(target_root)
|
|
829
|
+
print(f"\n\033[1;36mUpgrading from v{old_ver} to v{version}\033[0m\n")
|
|
830
|
+
|
|
831
|
+
if backup_enabled:
|
|
832
|
+
bc = [r for r in files if classify_file(r) == "framework" and os.path.isfile(os.path.join(target_root, r))]
|
|
833
|
+
if bc:
|
|
834
|
+
broot = backup_files(target_root, bc)
|
|
835
|
+
print(f"Backup created at: {broot}")
|
|
836
|
+
|
|
837
|
+
added, updated, review = [], [], []
|
|
838
|
+
unchanged = preserved = 0
|
|
839
|
+
scratchpad_example_rel = ".cursor/scratchpad.local.example.md"
|
|
840
|
+
scratchpad_example_status = "not-seen"
|
|
841
|
+
|
|
842
|
+
for rel in files:
|
|
843
|
+
src = os.path.join(source_root, rel)
|
|
844
|
+
dst = os.path.join(target_root, rel)
|
|
845
|
+
exists = os.path.isfile(dst)
|
|
846
|
+
cat = classify_file(rel)
|
|
847
|
+
|
|
848
|
+
if not exists:
|
|
849
|
+
ensure_parent(dst)
|
|
850
|
+
shutil.copy2(src, dst)
|
|
851
|
+
added.append(rel)
|
|
852
|
+
if rel == scratchpad_example_rel:
|
|
853
|
+
scratchpad_example_status = "added"
|
|
854
|
+
continue
|
|
855
|
+
|
|
856
|
+
if cat == "framework":
|
|
857
|
+
if filecmp.cmp(src, dst, shallow=False):
|
|
858
|
+
unchanged += 1
|
|
859
|
+
if rel == scratchpad_example_rel:
|
|
860
|
+
scratchpad_example_status = "unchanged"
|
|
861
|
+
else:
|
|
862
|
+
ensure_parent(dst)
|
|
863
|
+
shutil.copy2(src, dst)
|
|
864
|
+
updated.append(rel)
|
|
865
|
+
if rel == scratchpad_example_rel:
|
|
866
|
+
scratchpad_example_status = "updated"
|
|
867
|
+
continue
|
|
868
|
+
|
|
869
|
+
if cat == "user-data":
|
|
870
|
+
preserved += 1
|
|
871
|
+
continue
|
|
872
|
+
|
|
873
|
+
if cat == "mixed":
|
|
874
|
+
preserved += 1
|
|
875
|
+
if not filecmp.cmp(src, dst, shallow=False):
|
|
876
|
+
review.append(rel)
|
|
877
|
+
continue
|
|
878
|
+
|
|
879
|
+
if not run_scratchpad_postinstall(target_root, source_root, "upgrade", print_ok=True):
|
|
880
|
+
return 1
|
|
881
|
+
if not validate_install_completeness(target_root, source_root, required_script_paths, manifest_path):
|
|
882
|
+
return 1
|
|
883
|
+
|
|
884
|
+
write_installed_version(target_root, version)
|
|
885
|
+
sync_root_readme_to_its_magic(target_root)
|
|
886
|
+
runbook_ok, runbook_notes = bootstrap_runbook_commands(target_root)
|
|
887
|
+
for note in runbook_notes:
|
|
888
|
+
print(note)
|
|
889
|
+
if not runbook_ok:
|
|
890
|
+
return 1
|
|
891
|
+
|
|
892
|
+
show_banner()
|
|
893
|
+
g = "\033[1;32m"
|
|
894
|
+
y = "\033[1;33m"
|
|
895
|
+
p = "\033[1;35m"
|
|
896
|
+
d = "\033[0;90m"
|
|
897
|
+
r = "\033[0m"
|
|
898
|
+
print(f"{g}Upgrade complete: v{old_ver} -> v{version}{r}\n")
|
|
899
|
+
if added:
|
|
900
|
+
print(f" {g}Added (new): {len(added)} files{r}")
|
|
901
|
+
for f in added:
|
|
902
|
+
print(f" {f}")
|
|
903
|
+
if updated:
|
|
904
|
+
print(f" {y}Updated (framework): {len(updated)} files{r}")
|
|
905
|
+
for f in updated:
|
|
906
|
+
print(f" {f}")
|
|
907
|
+
print(f" Unchanged: {unchanged} files")
|
|
908
|
+
print(f" Preserved (user): {preserved} files")
|
|
909
|
+
if scratchpad_example_status == "not-seen":
|
|
910
|
+
scratchpad_example_status = "not-in-manifest"
|
|
911
|
+
print(f" Scratchpad example: {scratchpad_example_status} (.cursor/scratchpad.local.example.md)")
|
|
912
|
+
print(
|
|
913
|
+
" Scratchpad layers: post-install refreshed example-first, then baseline "
|
|
914
|
+
"(see [SCRATCHPAD_LAYER] lines)."
|
|
915
|
+
)
|
|
916
|
+
if os.path.isfile(os.path.join(target_root, ".cursor", "scratchpad.local.md")):
|
|
917
|
+
print(" User local file: preserved (.cursor/scratchpad.local.md)")
|
|
918
|
+
if review:
|
|
919
|
+
print(f"\n {p}Review recommended: {len(review)} files{r}")
|
|
920
|
+
for f in review:
|
|
921
|
+
print(f" {f}")
|
|
922
|
+
print(f" {d}Check .cursor/scratchpad.local.example.md for new flags.{r}")
|
|
923
|
+
print(f"\nRepository: {REPO_URL}\n")
|
|
924
|
+
return 0
|
|
925
|
+
|
|
926
|
+
for rel in files:
|
|
927
|
+
src = os.path.join(source_root, rel)
|
|
928
|
+
dst = os.path.join(target_root, rel)
|
|
929
|
+
exists = os.path.isfile(dst)
|
|
930
|
+
|
|
931
|
+
if mode == "missing":
|
|
932
|
+
if exists:
|
|
933
|
+
continue
|
|
934
|
+
ensure_parent(dst)
|
|
935
|
+
shutil.copy2(src, dst)
|
|
936
|
+
continue
|
|
937
|
+
|
|
938
|
+
if mode == "overwrite":
|
|
939
|
+
ensure_parent(dst)
|
|
940
|
+
shutil.copy2(src, dst)
|
|
941
|
+
continue
|
|
942
|
+
|
|
943
|
+
if mode == "interactive":
|
|
944
|
+
if not exists:
|
|
945
|
+
ensure_parent(dst)
|
|
946
|
+
shutil.copy2(src, dst)
|
|
947
|
+
continue
|
|
948
|
+
answer = input(f"File exists: {rel} | [o]verwrite [s]kip [q]uit: ").strip().lower()
|
|
949
|
+
if answer == "q":
|
|
950
|
+
print("Aborted.")
|
|
951
|
+
return 1
|
|
952
|
+
if answer == "o":
|
|
953
|
+
if backup_enabled:
|
|
954
|
+
broot = backup_files(target_root, [rel])
|
|
955
|
+
print(f"Backed up: {rel} -> {broot}")
|
|
956
|
+
ensure_parent(dst)
|
|
957
|
+
shutil.copy2(src, dst)
|
|
958
|
+
|
|
959
|
+
if not run_scratchpad_postinstall(target_root, source_root, mode, print_ok=True):
|
|
960
|
+
return 1
|
|
961
|
+
if not validate_install_completeness(target_root, source_root, required_script_paths, manifest_path):
|
|
962
|
+
return 1
|
|
963
|
+
|
|
964
|
+
write_installed_version(target_root, version)
|
|
965
|
+
sync_root_readme_to_its_magic(target_root)
|
|
966
|
+
runbook_ok, runbook_notes = bootstrap_runbook_commands(target_root)
|
|
967
|
+
for note in runbook_notes:
|
|
968
|
+
print(note)
|
|
969
|
+
if not runbook_ok:
|
|
970
|
+
return 1
|
|
971
|
+
|
|
972
|
+
show_banner(include_install_message=True)
|
|
973
|
+
print(f"its-magic v{version}")
|
|
974
|
+
print(f"Repository: {REPO_URL}")
|
|
975
|
+
print()
|
|
976
|
+
return 0
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
if __name__ == "__main__":
|
|
980
|
+
raise SystemExit(main())
|
|
981
|
+
|