feat(project-bootstrap): .mappa маркер при создании проекта — шаг 5.8 + рендер-ассет, project-create шаг 5.5 (wiki:3340, task:1583) [v3.3.0]

This commit is contained in:
2026-08-29 23:55:18 +03:00
parent e2f2e3a342
commit c09901f9a6
6 changed files with 630 additions and 6 deletions

Binary file not shown.

View File

@@ -44,7 +44,7 @@ function New-SkillArchive {
[System.IO.Compression.ZipArchiveMode]::Create [System.IO.Compression.ZipArchiveMode]::Create
) )
try { try {
$files = Get-ChildItem -Path $sourceFull -Recurse -File $files = Get-ChildItem -Path $sourceFull -Recurse -File | Where-Object { $_.FullName -notmatch '__pycache__' }
foreach ($file in $files) { foreach ($file in $files) {
$rel = $file.FullName.Substring($sourceFull.Length + 1) -replace '\\','/' $rel = $file.FullName.Substring($sourceFull.Length + 1) -replace '\\','/'
$entryName = "$SkillName/$rel" $entryName = "$SkillName/$rel"

View File

@@ -1,7 +1,7 @@
--- ---
name: project-bootstrap name: project-bootstrap
author: ours author: ours
version: 3.2.0 version: 3.3.0
description: > description: >
Initializes or upgrades a project in the current folder: git, .gitignore, README.md, Initializes or upgrades a project in the current folder: git, .gitignore, README.md,
.wiki/ using Karpathy's method, .tasks/ for task tracking, AGENTS.md (canon) with .wiki/ using Karpathy's method, .tasks/ for task tracking, AGENTS.md (canon) with
@@ -9,6 +9,7 @@ description: >
Creates remote Gitea repo and syncs projects-meta cache for greenfield projects. Creates remote Gitea repo and syncs projects-meta cache for greenfield projects.
Includes the mappa-bootstrap-project module (v3, решение 4 mappa-as-product): Includes the mappa-bootstrap-project module (v3, решение 4 mappa-as-product):
mappa MCP connect + mappa-конвенции + методика-install (версия в манифест). mappa MCP connect + mappa-конвенции + методика-install (версия в манифест).
Creates the `.mappa` marker (wiki:3340) so the folder is a mappa project.
Use this skill when the user says "initialize project", "bootstrap", "setup project", Use this skill when the user says "initialize project", "bootstrap", "setup project",
"upgrade project", "add wiki", "add tasks", "start project", "set everything up", "upgrade project", "add wiki", "add tasks", "start project", "set everything up",
"create new project", or launches the agent in a new folder and wants a full setup. "create new project", or launches the agent in a new folder and wants a full setup.
@@ -36,6 +37,7 @@ ls -A 2>/dev/null | grep -q . && echo "empty:no" || echo "empty:yes"
[ -d .tasks ] && echo "tasks:yes" || echo "tasks:no" [ -d .tasks ] && echo "tasks:yes" || echo "tasks:no"
[ -f CLAUDE.md ] && echo "claude:yes" || echo "claude:no" [ -f CLAUDE.md ] && echo "claude:yes" || echo "claude:no"
[ -f README.md ] && echo "readme:yes" || echo "readme:no" [ -f README.md ] && echo "readme:yes" || echo "readme:no"
[ -d .mappa ] && echo "mappa-marker:yes" || echo "mappa-marker:no"
``` ```
Determine mode: Determine mode:
@@ -682,6 +684,46 @@ install-команда — скил mappa-bootstrap, см. 5.7.3).
--- ---
## Step 5.8 — `.mappa` маркер (контракт wiki:3340)
Машиночитаемый маркер проекта в корне папки (схема v1 — `.mappa/config.yaml`):
гейт mappa-скилов («без маркера папка не участвует в mappa-операциях»,
task:1546) + признак корня проекта для харнессов. Создаётся на bootstrap —
без ручного прогона генератора (task:1583). Детерминированный рендер:
фиксированный порядок полей, без секретов, без timestamp — повторный запуск
no-op (`keep`).
1. **Собрать значения** (реестр mappa → локальное знание):
- `project` — канон папки (`basename "$PWD"`); если проект уже в реестре
(`projects_resolve`) — сверить, не расходится ли;
- `tenant` — `MAPPA_TENANT` (по умолчанию `vitya`);
- `url` — `MAPPA_CORE_URL` (без trailing slash);
- `git_provider` — из реестра `projects.git_provider` (например `gitea`),
иначе из шага 1.5 (создано через Gitea API → gitea); опционально;
- `git` — `projects.qualified` (owner/repo) из реестра, иначе из remote
шага 1.5; опционально (опустить, если неизвестно).
2. **Записать маркер** (скрипт — ассет этого скила, реализует контракт
wiki:3340; в репо: `skills/project-bootstrap/assets/dot_mappa_marker.py`):
```bash
python assets/dot_mappa_marker.py write \
--project "$(basename "$PWD")" --tenant vitya --url "$MAPPA_CORE_URL" \
--git-provider gitea --git "$OWNER/$REPO"
```
Без `--git-provider`/`--git`, если поля неизвестны. Повторный прогон —
no-op (`keep`); отличающийся существующий маркер без `--force` НЕ
перезаписывается — покажи diff и спроси (правило «never overwrite»).
3. **Верифицировать**: `python assets/dot_mappa_marker.py check` → exit 0.
4. **Контракт-тест** (TDD, task:1583): `python assets/test_dot_mappa_marker.py`
— «после bootstrap есть `.mappa/config.yaml`», детерминизм, без секретов,
порядок полей, идемпотентность.
Маркер публичен (без секретов) и попадает в коммит шага 6. Валидный
существующий маркер не трогаем.
---
## Step 6 — Commit ## Step 6 — Commit
```bash ```bash
@@ -706,6 +748,7 @@ Print a final report:
✅ Done! Created: ✅ Done! Created:
.wiki/ — project wiki (Karpathy method) .wiki/ — project wiki (Karpathy method)
.tasks/ — task tracking system .tasks/ — task tracking system
.mappa/ — mappa project marker (wiki:3340, schema v1)
AGENTS.md — skill triggers (canon) AGENTS.md — skill triggers (canon)
CLAUDE.md — legacy pointer CLAUDE.md — legacy pointer
.gitignore — standard template .gitignore — standard template

View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""dot_mappa_marker.py — deterministic render + write of the `.mappa` marker.
Contract: mappa wiki:3340 (concepts/dot-mappa-marker), schema v1.
Used by project-create (step 5.5) and project-bootstrap (step 5.8) so a project
folder gets its marker at create time — no manual generator run needed
(task:1583). The batch generator (mappa `server/scripts/gen-dot-mappa-markers.ts`)
remains for registry-wide migration; this is the per-project create path.
Guarantees (the contract):
* `.mappa/config.yaml` — каталог + файл внутри
* fixed field order (schema_version, protocol_version, project, tenant, url,
git_provider?, git?)
* deterministic render — no timestamps, same input → same bytes
* NO secrets — only public registry fields; url with credentials is rejected
* optional fields (`git_provider`, `git`) omitted when absent
* idempotent write: same content → no-op (keep); different content → refuse
without --force
Usage:
python dot_mappa_marker.py render --project NAME --tenant TENANT --url URL \
[--git-provider P] [--git OWNER/REPO] # print content to stdout
python dot_mappa_marker.py write --project NAME --tenant TENANT --url URL \
[--git-provider P] [--git OWNER/REPO] [--dir PATH] [--force] # write marker
python dot_mappa_marker.py check --dir PATH # verify existing marker
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
SCHEMA_VERSION = 1
PROTOCOL_VERSION = 1
# Canonical header comment — same as the contract example (wiki:3340).
HEADER = "# mappa project marker — machine-readable identifier of a mappa project folder"
# YAML: these are indicator characters / reserved tokens — never plain.
_INDICATOR_START = set("!&*{}[],#|>@`\"'%?:~-")
_RESERVED_PLAIN = {"null", "Null", "NULL", "~", "true", "True", "TRUE", "false",
"False", "FALSE", "yes", "Yes", "YES", "no", "No", "NO",
"on", "On", "ON", "off", "Off", "OFF", "-", "?", ":"}
class MarkerConflict(Exception):
"""An existing marker differs from the requested one and --force is absent."""
def yaml_scalar(value: str) -> str:
"""YAML plain-safe scalar: plain without quotes when safe, else double-quoted.
Plain-safe = non-empty, no leading indicator, not a reserved token, no flow
chars, no embedded newlines, no surrounding whitespace. Double-quoting uses
JSON escaping, which is a valid subset of YAML double-quoted style.
"""
s = str(value)
if s == "":
return '""'
if s.strip() != s:
return json.dumps(s)
if s[0] in _INDICATOR_START or s in _RESERVED_PLAIN:
return json.dumps(s)
if s.startswith(("- ", "? ", ": ")):
return json.dumps(s)
# plain scalars stay plain unless they would confuse the parser:
# ": " (mapping indicator), trailing ":", " #" (comment), newlines/tabs
if ": " in s or s.endswith(":") or " #" in s or "\n" in s or "\t" in s:
return json.dumps(s)
return s
def validate_folder_name(name: str) -> str:
"""A folder name (canon/tenant/git_provider) must be a single sane segment."""
if not name or name in (".", ".."):
raise ValueError(f"invalid name {name!r}: must be a non-empty folder name")
if any(sep in name for sep in ("/", "\\", "\x00")):
raise ValueError(f"invalid name {name!r}: must be a single path segment")
if name != name.strip():
raise ValueError(f"invalid name {name!r}: no surrounding whitespace allowed")
return name
def validate_git_ref(git: str) -> str:
"""`git` = projects.qualified (owner/repo) — no credentials, no colon."""
g = str(git)
if not g or "/" not in g:
raise ValueError(f"invalid git ref {g!r}: expected owner/repo")
if any(c in g for c in ("@", ":", " ", "\t", "\n", "\\")):
raise ValueError(f"invalid git ref {g!r}: no credentials / separators allowed")
if g.startswith("/") or g.endswith("/") or ".." in g.split("/"):
raise ValueError(f"invalid git ref {g!r}: must be owner/repo, not a path")
return g
def normalize_url(url: str) -> str:
"""Absolute http(s) URL without credentials and without trailing slash."""
u = str(url).strip()
if not (u.startswith("http://") or u.startswith("https://")):
raise ValueError(f"invalid url {u!r}: must be http(s)://host...")
authority = u.split("://", 1)[1].split("/", 1)[0]
if "@" in authority:
raise ValueError("url must not contain credentials (no secrets in the marker)")
return u.rstrip("/")
def render(
project: str,
tenant: str,
url: str,
git_provider: str | None = None,
git: str | None = None,
) -> str:
"""Deterministic `.mappa/config.yaml` content per wiki:3340 schema v1."""
project = validate_folder_name(project)
tenant = validate_folder_name(tenant)
url = normalize_url(url)
lines = [
HEADER,
f"schema_version: {SCHEMA_VERSION}",
f"protocol_version: {PROTOCOL_VERSION}",
f"project: {yaml_scalar(project)}",
f"tenant: {yaml_scalar(tenant)}",
f"url: {yaml_scalar(url)}",
]
if git_provider:
lines.append(f"git_provider: {yaml_scalar(validate_folder_name(git_provider))}")
if git:
lines.append(f"git: {yaml_scalar(validate_git_ref(git))}")
return "\n".join(lines) + "\n"
def _sane_dir(directory: str | Path) -> Path:
"""Resolve the target directory; reject `..` segments and non-dirs."""
p = Path(directory)
if ".." in p.parts:
raise ValueError(f"invalid directory {str(directory)!r}: '..' segments not allowed")
if p.exists() and not p.is_dir():
raise ValueError(f"invalid directory {str(directory)!r}: not a directory")
return p
def write_marker(directory: str | Path, content: str, force: bool = False) -> tuple[Path, str]:
"""Write `.mappa/config.yaml` under `directory`.
Returns (marker_path, outcome) where outcome is one of
"created" | "keep" (idempotent no-op) | "overwrite" (force).
Raises MarkerConflict when an existing marker differs and force is False.
"""
marker = _sane_dir(directory) / ".mappa" / "config.yaml"
if marker.exists():
existing = marker.read_text(encoding="utf-8")
if existing == content:
return marker, "keep"
if not force:
raise MarkerConflict(
f"{marker} already exists with different content; "
"pass --force to overwrite (contract: no silent overwrite)"
)
marker.write_text(content, encoding="utf-8")
return marker, "overwrite"
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(content, encoding="utf-8")
return marker, "created"
def _parse_marker_lines(body: str) -> list[tuple[str, str]]:
"""(key, value) pairs of data lines — comments skipped, first colon splits."""
pairs = []
for line in body.splitlines():
if not line or line.startswith("#"):
continue
if ": " not in line:
raise ValueError(f"malformed marker line (no 'key: value'): {line!r}")
key, value = line.split(": ", 1)
pairs.append((key, value.strip()))
return pairs
def check_marker(directory: str | Path) -> tuple[bool, str]:
"""Gate check (wiki:3340 / task:1546): is `directory` a mappa project?
Returns (ok, message). ok means `.mappa/config.yaml` exists and its data
lines start with exactly the required fields (schema_version,
protocol_version, project, tenant, url) in canonical order with valid
values; optional `git_provider`/`git` may follow.
"""
marker = _sane_dir(directory) / ".mappa" / "config.yaml"
if not marker.is_file():
return False, f"no marker: {marker} (folder without marker is not a mappa project)"
try:
pairs = _parse_marker_lines(marker.read_text(encoding="utf-8"))
except ValueError as e:
return False, f"marker {marker}: {e}"
if len(pairs) < 5:
return False, f"marker {marker}: fewer than the 5 required fields"
required = ["schema_version", "protocol_version", "project", "tenant", "url"]
if [k for k, _ in pairs[:5]] != required:
return False, f"marker {marker}: field order mismatch ({[k for k, _ in pairs[:5]]})"
values = dict(pairs)
if values["schema_version"] != str(SCHEMA_VERSION):
return False, f"marker {marker}: schema_version must be {SCHEMA_VERSION}"
if values["protocol_version"] != str(PROTOCOL_VERSION):
return False, f"marker {marker}: protocol_version must be {PROTOCOL_VERSION}"
try:
validate_folder_name(values["project"])
validate_folder_name(values["tenant"])
normalize_url(values["url"])
if "git_provider" in values:
validate_folder_name(values["git_provider"])
if "git" in values:
validate_git_ref(values["git"])
except ValueError as e:
return False, f"marker {marker}: {e}"
return True, f"marker ok: {marker}"
def _add_common(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--project", required=True, help="канон папки = реестр projects.name (slug)")
parser.add_argument("--tenant", required=True, help="тенант, где живёт проект (MAPPA_TENANT)")
parser.add_argument("--url", required=True, help="MAPPA_CORE_URL (без trailing slash)")
parser.add_argument("--git-provider", default=None, help="projects.git_provider (gitea/...) — опционально")
parser.add_argument("--git", default=None, help="projects.qualified (owner/repo) — опционально")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=".mappa marker per wiki:3340 (schema v1)")
sub = parser.add_subparsers(dest="cmd", required=True)
p_render = sub.add_parser("render", help="print deterministic marker content")
_add_common(p_render)
p_write = sub.add_parser("write", help="write .mappa/config.yaml into a folder")
_add_common(p_write)
p_write.add_argument("--dir", default=".", help="project folder (default: cwd)")
p_write.add_argument("--force", action="store_true", help="overwrite a differing marker")
p_check = sub.add_parser("check", help="gate check: is the folder a mappa project?")
p_check.add_argument("--dir", default=".", help="project folder (default: cwd)")
args = parser.parse_args(argv)
if args.cmd in ("render", "write"):
content = render(args.project, args.tenant, args.url, args.git_provider, args.git)
if args.cmd == "render":
sys.stdout.write(content)
return 0
marker, outcome = write_marker(args.dir, content, force=args.force)
print(f"{outcome}: {marker}")
return 0
if args.cmd == "check":
ok, msg = check_marker(args.dir)
print(msg)
return 0 if ok else 1
return 2 # unreachable
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""Contract test for the `.mappa` marker — mappa wiki:3340 (concepts/dot-mappa-marker).
The contract under test (task:1583): after the project-create/bootstrap marker
step, the project folder contains `.mappa/config.yaml` matching schema v1:
fixed field order, deterministic render (no timestamps), NO secrets, optional
fields (`git_provider`, `git`) omitted when absent, idempotent write.
Run: python test_dot_mappa_marker.py (or: python -m unittest test_dot_mappa_marker)
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import dot_mappa_marker as dmm # noqa: E402
CANON = "skills" # registry projects.name — канон папки (slug)
TENANT = "vitya"
URL = "https://mappa.vds.kzntsv.site"
GIT_PROVIDER = "gitea"
GIT = "OpeItcLoc03/skills"
FIELD_ORDER = [
"schema_version",
"protocol_version",
"project",
"tenant",
"url",
"git_provider",
"git",
]
def field_keys(body: str) -> list[str]:
return [
line.split(":", 1)[0]
for line in body.splitlines()
if line and not line.startswith("#") and ": " in line
]
def write_contract_marker(tmp: str) -> Path:
"""Helper: create a valid marker as the bootstrap step would."""
marker, outcome = dmm.write_marker(tmp, dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT))
assert outcome == "created"
return marker
class ContractTests(unittest.TestCase):
"""Contract: after bootstrap there is `.mappa/config.yaml` (task:1583)."""
def setUp(self) -> None:
self.tmp = tempfile.mkdtemp(prefix="mappa-marker-test-")
def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)
# --- presence / shape -------------------------------------------------
def test_bootstrap_marker_step_creates_config_yaml(self) -> None:
"""The bootstrap marker step leaves `.mappa/config.yaml` in the folder."""
content = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
marker, outcome = dmm.write_marker(self.tmp, content)
self.assertEqual(outcome, "created")
self.assertTrue(marker.is_file())
self.assertEqual(marker.name, "config.yaml")
self.assertEqual(marker.parent.name, ".mappa")
def test_fixed_field_order(self) -> None:
body = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
self.assertEqual(field_keys(body), FIELD_ORDER)
def test_deterministic_render_no_timestamp(self) -> None:
a = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
b = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
self.assertEqual(a, b)
# no ISO-date-like content
self.assertNotRegex(a, r"\d{4}-\d{2}-\d{2}")
def test_optional_fields_omitted_when_absent(self) -> None:
body = dmm.render(CANON, TENANT, URL)
self.assertEqual(field_keys(body), FIELD_ORDER[:5])
self.assertNotIn("git_provider", body)
self.assertNotIn("\ngit:", body)
def test_no_secrets_in_marker(self) -> None:
body = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
lowered = body.lower()
# credentials in the url authority are rejected separately
for secret in ("token", "password", "secret", "api_key", "key:", "@"):
self.assertNotIn(secret, lowered)
# --- idempotent write --------------------------------------------------
def test_idempotent_write_keeps_same_content(self) -> None:
content = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
marker, first = dmm.write_marker(self.tmp, content)
marker, second = dmm.write_marker(self.tmp, content)
self.assertEqual(first, "created")
self.assertEqual(second, "keep")
self.assertEqual(marker.read_text(encoding="utf-8"), content)
def test_refuses_overwrite_of_different_marker_without_force(self) -> None:
dmm.write_marker(self.tmp, dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT))
with self.assertRaises(dmm.MarkerConflict):
dmm.write_marker(self.tmp, dmm.render(CANON, TENANT, URL, "github", GIT))
def test_force_overwrites_different_marker(self) -> None:
dmm.write_marker(self.tmp, dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT))
marker, outcome = dmm.write_marker(
self.tmp, dmm.render(CANON, TENANT, URL, "github", GIT), force=True
)
self.assertEqual(outcome, "overwrite")
self.assertIn("git_provider: github", marker.read_text(encoding="utf-8"))
# --- input validation ---------------------------------------------------
def test_folder_name_path_segments_rejected(self) -> None:
for bad in ("../evil", "a/b", "a\\b", ".", "..", ""):
with self.assertRaises(ValueError, msg=f"name {bad!r} must be rejected"):
dmm.render(bad, TENANT, URL)
def test_url_trailing_slash_stripped_but_path_kept(self) -> None:
body = dmm.render(CANON, TENANT, URL + "//")
self.assertIn(f"url: {URL}", body)
# a trailing slash after a path must be stripped, the path kept
body2 = dmm.render(CANON, TENANT, "https://example.com/mappa/")
self.assertIn("url: https://example.com/mappa", body2)
def test_url_with_credentials_rejected(self) -> None:
with self.assertRaises(ValueError):
dmm.render(CANON, TENANT, "https://user:pass@mappa.vds.kzntsv.site")
def test_url_scheme_restricted_to_http_https(self) -> None:
for bad in ("ftp://mappa.example", "javascript://x", "mappa.vds.kzntsv.site", "://x"):
with self.assertRaises(ValueError, msg=f"url {bad!r} must be rejected"):
dmm.render(CANON, TENANT, bad)
def test_git_ref_with_credentials_rejected(self) -> None:
for bad in ("user:pass@host/repo", "victor/repo@token", "../config", "/owner/repo", "owner/repo/", "owner repo", "norepo"):
with self.assertRaises(ValueError, msg=f"git {bad!r} must be rejected"):
dmm.render(CANON, TENANT, URL, GIT_PROVIDER, bad)
def test_directory_with_parent_segments_rejected(self) -> None:
with self.assertRaises(ValueError):
dmm.write_marker("some/../elsewhere", dmm.render(CANON, TENANT, URL))
with self.assertRaises(ValueError):
dmm.check_marker("../etc")
# --- YAML scalar edge cases -------------------------------------------
def test_yaml_scalar_quoting_edge_cases(self) -> None:
# reserved tokens / indicators must be double-quoted (never plain)
for special in ("~", "@host", "-", "?", ":", "null", "yes", "on", "true",
"a: b", "ends:", " #lead", "has tab\tinside"):
self.assertTrue(dmm.yaml_scalar(special).startswith('"'),
f"{special!r} must be double-quoted, got {dmm.yaml_scalar(special)!r}")
# plain-safe values stay plain
for plain in ("vitya", "OpeItcLoc03/skills", "https://mappa.vds.kzntsv.site",
"a:b", "x#y", "lead#ing", "my-proj"):
self.assertEqual(dmm.yaml_scalar(plain), plain)
self.assertEqual(dmm.yaml_scalar(""), '""')
# --- check_marker (gate) ----------------------------------------------
def test_check_ok_on_valid_marker(self) -> None:
write_contract_marker(self.tmp)
ok, msg = dmm.check_marker(self.tmp)
self.assertTrue(ok, msg)
def test_check_fails_on_missing_marker(self) -> None:
ok, _ = dmm.check_marker(self.tmp)
self.assertFalse(ok)
def test_check_fails_on_wrong_field_order(self) -> None:
(Path(self.tmp) / ".mappa").mkdir()
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
"# c\nproject: skills\nschema_version: 1\nprotocol_version: 1\n"
"tenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
encoding="utf-8",
)
ok, _ = dmm.check_marker(self.tmp)
self.assertFalse(ok)
def test_check_fails_on_extra_field_before_required(self) -> None:
(Path(self.tmp) / ".mappa").mkdir()
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
"extra: sneaky\nschema_version: 1\nprotocol_version: 1\n"
"project: skills\ntenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
encoding="utf-8",
)
ok, _ = dmm.check_marker(self.tmp)
self.assertFalse(ok)
def test_check_fails_on_wrong_versions(self) -> None:
(Path(self.tmp) / ".mappa").mkdir()
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
"schema_version: 2\nprotocol_version: 1\nproject: skills\n"
"tenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
encoding="utf-8",
)
ok, _ = dmm.check_marker(self.tmp)
self.assertFalse(ok)
def test_check_fails_on_malicious_project_value(self) -> None:
(Path(self.tmp) / ".mappa").mkdir()
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
"schema_version: 1\nprotocol_version: 1\nproject: ../../evil\n"
"tenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
encoding="utf-8",
)
ok, _ = dmm.check_marker(self.tmp)
self.assertFalse(ok)
def test_check_fails_on_malformed_line(self) -> None:
(Path(self.tmp) / ".mappa").mkdir()
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
"schema_version: 1\nprotocol_version: 1\nproject skills\n"
"tenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
encoding="utf-8",
)
ok, _ = dmm.check_marker(self.tmp)
self.assertFalse(ok)
def test_check_accepts_url_with_port(self) -> None:
(Path(self.tmp) / ".mappa").mkdir()
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
"schema_version: 1\nprotocol_version: 1\nproject: skills\n"
"tenant: vitya\nurl: https://mappa.example:8443\n",
encoding="utf-8",
)
ok, _ = dmm.check_marker(self.tmp)
self.assertTrue(ok)
# --- CLI end-to-end ----------------------------------------------------
def test_cli_write_creates_marker(self) -> None:
"""End-to-end: the documented CLI command produces the marker."""
proc = subprocess.run(
[
sys.executable,
str(Path(__file__).resolve().parent / "dot_mappa_marker.py"),
"write",
"--project", CANON,
"--tenant", TENANT,
"--url", URL,
"--git-provider", GIT_PROVIDER,
"--git", GIT,
"--dir", self.tmp,
],
capture_output=True,
text=True,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
marker = Path(self.tmp) / ".mappa" / "config.yaml"
self.assertTrue(marker.is_file())
self.assertEqual(field_keys(marker.read_text(encoding="utf-8")), FIELD_ORDER)
def test_cli_check_verifies_marker(self) -> None:
write_contract_marker(self.tmp)
script = Path(__file__).resolve().parent / "dot_mappa_marker.py"
ok = subprocess.run(
[sys.executable, str(script), "check", "--dir", self.tmp],
capture_output=True,
text=True,
)
self.assertEqual(ok.returncode, 0, ok.stderr)
# check on an empty dir fails (gate semantics: no marker → not a mappa project)
empty = tempfile.mkdtemp(prefix="mappa-marker-empty-")
try:
missing = subprocess.run(
[sys.executable, str(script), "check", "--dir", empty],
capture_output=True,
text=True,
)
self.assertNotEqual(missing.returncode, 0)
finally:
shutil.rmtree(empty, ignore_errors=True)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -1,15 +1,16 @@
--- ---
name: project-create name: project-create
author: ours author: ours
version: 0.1.1 version: 0.2.0
description: > description: >
Mappa-side cycle of creating a new project: ask the operator for the hosting Mappa-side cycle of creating a new project: ask the operator for the hosting
address (platform + user/org) FIRST — never derive it from neighbouring address (platform + user/org) FIRST — never derive it from neighbouring
projects — then pre-flight checks (free in mappa AND in gitea), then create projects — then pre-flight checks (free in mappa AND in gitea), then create
mappa registration and the gitea repo SIMULTANEOUSLY (repo via .admin: task mappa registration and the gitea repo SIMULTANEOUSLY (repo via .admin: task
+ covering letter, priority P0, no paired review for ops), then ask where on + covering letter, priority P0, no paired review for ops), then ask where on
disk the project folder goes and what it's named, create the folder, and disk the project folder goes and what it's named, create the folder, write
hand over to project-bootstrap (general skill). Triggers (bilingual): the `.mappa` marker (wiki:3340), and hand over to project-bootstrap (general
skill). Triggers (bilingual):
«создай проект», «заведи проект», «новый проект», «создать проект на «создай проект», «заведи проект», «новый проект», «создать проект на
гите», "create a project", "start a new project", "set up a project", гите», "create a project", "start a new project", "set up a project",
«куда разместить проект». NOT repo content/bootstrap (→ project-bootstrap), «куда разместить проект». NOT repo content/bootstrap (→ project-bootstrap),
@@ -51,7 +52,34 @@ Before any `projects_register` / gitea repo creation for a **new** project.
- repo via `.admin``task_create` in `.admin` (**priority P0** — it blocks the project chain) + covering letter via `inbox_send` (a task on the board doesn't ping a live session). The repo task is an **ops task → NO paired review** (mappa-delegation: skip review for ops). Acceptance: repo created private, answer letter with clone URLs + which token is needed for push. - repo via `.admin``task_create` in `.admin` (**priority P0** — it blocks the project chain) + covering letter via `inbox_send` (a task on the board doesn't ping a live session). The repo task is an **ops task → NO paired review** (mappa-delegation: skip review for ops). Acceptance: repo created private, answer letter with clone URLs + which token is needed for push.
4. **Ask the operator: where on disk the project folder goes and what it's named** (location AND folder name — both are the operator's call) — do not guess the path or the name. 4. **Ask the operator: where on disk the project folder goes and what it's named** (location AND folder name — both are the operator's call) — do not guess the path or the name.
5. **Create the folder** on the agent's local filesystem (the operator's workstation, not a remote host). 5. **Create the folder** on the agent's local filesystem (the operator's workstation, not a remote host).
6. **Hand over to bootstrap**`project-bootstrap` (general skill) does git init, .gitignore, README, AGENTS.md, remote connect, push. Separate step, after the repo exists. 6. **Write the `.mappa` marker** — see Step 5.5 below. Right after the folder exists, the marker values are known from this cycle; no manual generator run.
7. **Hand over to bootstrap**`project-bootstrap` (general skill) does git init, .gitignore, README, AGENTS.md, remote connect, push. Separate step, after the repo exists.
## Step 5.5 — `.mappa` маркер (контракт wiki:3340)
Сразу после создания папки — маркер `.mappa/config.yaml` (гейт mappa-скилов:
«без маркера папка не участвует в mappa-операциях», task:1546). Значения уже
известны из этого цикла — ручной прогон генератора не нужен (task:1583):
- `project` — канон (имя папки, выбрано оператором в шаге 4);
- `tenant``MAPPA_TENANT` (по умолчанию `vitya`);
- `url``MAPPA_CORE_URL` (без trailing slash);
- `git_provider` — платформа из шага 1 (gitea/github/…);
- `git` — qualified (owner/repo) из ответа `.admin` (шаг 3, clone URL).
Запись — детерминированный рендер по контракту. Канон-скрипт — ассет
`project-bootstrap` (в репо: `skills/project-bootstrap/assets/dot_mappa_marker.py`;
тот же шаг в bootstrap 5.8 — повторный прогон там no-op):
```bash
python <skills-repo>/skills/project-bootstrap/assets/dot_mappa_marker.py write \
--project "$FOLDER_NAME" --tenant vitya --url "$MAPPA_CORE_URL" \
--git-provider gitea --git "$OWNER/$REPO" --dir "$FOLDER_PATH"
```
Верифицировать: `python .../dot_mappa_marker.py check --dir "$FOLDER_PATH"`
→ exit 0. Маркер без секретов, коммитится. Если project-bootstrap не
установлен — маркер всё равно появится на хэндовере (шаг 7 → bootstrap 5.8).
## Why the repo goes through `.admin` ## Why the repo goes through `.admin`