rakit-cli 2.0.1__py3-none-any.whl

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.
@@ -0,0 +1,325 @@
1
+ """Server MCP `stdio` JSON-RPC 2.0 ringan untuk asisten AI editor lokal (ADR-002)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rakit_cli.config import (
11
+ WorkspaceConfig,
12
+ get_effective_api_token,
13
+ load_workspace_config,
14
+ save_workspace_config,
15
+ write_active_task_prompt,
16
+ )
17
+ from rakit_cli.security import redact_sensitive_text
18
+ from rakit_cli.tasks_parser import parse_tasks_markdown
19
+
20
+ MCP_TOOLS_CATALOG: list[dict[str, Any]] = [
21
+ {
22
+ "name": "rakit_status",
23
+ "description": "Membaca ikatan proyek aktif dan progres TASKS.md di folder kerja notebook.",
24
+ "inputSchema": {
25
+ "type": "object",
26
+ "properties": {
27
+ "workspace_path": {
28
+ "type": "string",
29
+ "description": "Jalur folder proyek aktif (opsional)",
30
+ }
31
+ },
32
+ },
33
+ },
34
+ {
35
+ "name": "rakit_claim_ticket",
36
+ "description": (
37
+ "Mengikat kunci tiket aktif pada workspace lokal untuk dikerjakan asisten AI."
38
+ ),
39
+ "inputSchema": {
40
+ "type": "object",
41
+ "properties": {
42
+ "ticket_key": {"type": "string"},
43
+ "workspace_path": {
44
+ "type": "string",
45
+ "description": "Jalur folder proyek aktif (opsional)",
46
+ },
47
+ },
48
+ "required": ["ticket_key"],
49
+ },
50
+ },
51
+ {
52
+ "name": "rakit_sync_progress",
53
+ "description": "Menyusun muatan progres sub-tugas TASKS.md yang tersanitasi dari rahasia.",
54
+ "inputSchema": {
55
+ "type": "object",
56
+ "properties": {
57
+ "summary": {"type": "string"},
58
+ "workspace_path": {
59
+ "type": "string",
60
+ "description": "Jalur folder proyek aktif (opsional)",
61
+ },
62
+ },
63
+ },
64
+ },
65
+ {
66
+ "name": "rakit_ask_human",
67
+ "description": (
68
+ "Menyiapkan pertanyaan klarifikasi ask_human untuk dikirim ke HP pengembang."
69
+ ),
70
+ "inputSchema": {
71
+ "type": "object",
72
+ "properties": {
73
+ "question": {"type": "string"},
74
+ "workspace_path": {
75
+ "type": "string",
76
+ "description": "Jalur folder proyek aktif (opsional)",
77
+ },
78
+ },
79
+ "required": ["question"],
80
+ },
81
+ },
82
+ ]
83
+
84
+
85
+ def find_workspace_root(start_dir: Path | None = None) -> Path:
86
+ """
87
+ TUJUAN: Menemukan folder workspace yang memuat berkas `.rakit/workspace.json`.
88
+
89
+ ALUR:
90
+ 1. Memeriksa environment variable `RAKIT_WORKSPACE` bila disetel.
91
+ 2. Melakukan traversal ke direktori induk (parent traversal) dari `start_dir`.
92
+ 3. Bila tidak ditemukan, kembali ke `start_dir` atau direktori aktif.
93
+ """
94
+ import os
95
+
96
+ env_root = os.environ.get("RAKIT_WORKSPACE", "").strip()
97
+ if env_root:
98
+ candidate = Path(env_root).resolve()
99
+ if (candidate / ".rakit" / "workspace.json").exists():
100
+ return candidate
101
+
102
+ cur = (start_dir or Path.cwd()).resolve()
103
+ for candidate in [cur, *cur.parents]:
104
+ if (candidate / ".rakit" / "workspace.json").exists():
105
+ return candidate
106
+ return cur
107
+
108
+
109
+ def handle_jsonrpc_message(
110
+ request: dict[str, Any],
111
+ *,
112
+ workspace_root: Path,
113
+ ) -> dict[str, Any] | None:
114
+ """
115
+ TUJUAN: Menangani satu pesan JSON-RPC 2.0 MCP (`initialize`, `tools/list`, `tools/call`).
116
+
117
+ ALUR:
118
+ 1. Mengabaikan notifikasi satu arah yang tidak memiliki `id`.
119
+ 2. Mengembalikan katalog 4 perkakas pada `tools/list`.
120
+ 3. Mengeksekusi logika workspace lokal pada `tools/call` dengan penyensoran rahasia.
121
+
122
+ DIPANGGIL DARI: Loop `stdio` di `main()` dan pengujian unit MCP
123
+ DEPENDENSI: config, security, tasks_parser
124
+ """
125
+ method = str(request.get("method") or "")
126
+ request_id = request.get("id")
127
+ if request_id is None:
128
+ return None
129
+
130
+ if method == "initialize":
131
+ return {
132
+ "jsonrpc": "2.0",
133
+ "id": request_id,
134
+ "result": {
135
+ "protocolVersion": "2024-11-05",
136
+ "serverInfo": {"name": "rakit-mcp", "version": "2.0.0"},
137
+ "capabilities": {"tools": {}},
138
+ },
139
+ }
140
+
141
+ if method == "tools/list":
142
+ return {
143
+ "jsonrpc": "2.0",
144
+ "id": request_id,
145
+ "result": {"tools": MCP_TOOLS_CATALOG},
146
+ }
147
+
148
+ if method == "tools/call":
149
+ params = request.get("params") or {}
150
+ tool_name = str(params.get("name") or "")
151
+ arguments = params.get("arguments") or {}
152
+ result_payload = _execute_mcp_tool(
153
+ tool_name=tool_name,
154
+ arguments=arguments,
155
+ workspace_root=workspace_root,
156
+ )
157
+ return {
158
+ "jsonrpc": "2.0",
159
+ "id": request_id,
160
+ "result": {
161
+ "content": [
162
+ {
163
+ "type": "text",
164
+ "text": json.dumps(result_payload, ensure_ascii=False),
165
+ }
166
+ ]
167
+ },
168
+ }
169
+
170
+ return {
171
+ "jsonrpc": "2.0",
172
+ "id": request_id,
173
+ "error": {"code": -32601, "message": f"Method tidak dikenali: {method}"},
174
+ }
175
+
176
+
177
+ def _execute_mcp_tool(
178
+ *,
179
+ tool_name: str,
180
+ arguments: dict[str, Any],
181
+ workspace_root: Path,
182
+ ) -> dict[str, Any]:
183
+ """Menjalankan salah satu dari 4 perkakas MCP lokal."""
184
+ target_root = workspace_root
185
+ arg_ws = arguments.get("workspace_path")
186
+ if isinstance(arg_ws, str) and arg_ws.strip():
187
+ target_root = Path(arg_ws).resolve()
188
+ else:
189
+ target_root = find_workspace_root(workspace_root)
190
+
191
+ cfg = load_workspace_config(target_root)
192
+ if cfg is None:
193
+ return {"ok": False, "code": "WORKSPACE_NOT_INITIALIZED"}
194
+
195
+ tasks_path = target_root / "TASKS.md"
196
+ tasks_summary = (
197
+ parse_tasks_markdown(tasks_path.read_text(encoding="utf-8"))
198
+ if tasks_path.exists()
199
+ else parse_tasks_markdown("")
200
+ )
201
+
202
+ if tool_name == "rakit_status":
203
+ return {
204
+ "ok": True,
205
+ "project_slug": cfg.project_slug,
206
+ "active_ticket_key": cfg.active_ticket_key,
207
+ "tasks_completed": tasks_summary.completed_count,
208
+ "tasks_total": tasks_summary.total_count,
209
+ "next_open_task": tasks_summary.next_open_task,
210
+ }
211
+
212
+ if tool_name == "rakit_claim_ticket":
213
+ ticket_key = str(arguments.get("ticket_key") or "").strip()
214
+ if not ticket_key:
215
+ return {"ok": False, "code": "TICKET_KEY_REQUIRED"}
216
+ updated = WorkspaceConfig(
217
+ project_slug=cfg.project_slug,
218
+ api_base_url=cfg.api_base_url,
219
+ machine_label=cfg.machine_label,
220
+ root_fingerprint=cfg.root_fingerprint,
221
+ active_ticket_key=ticket_key,
222
+ api_token=cfg.api_token,
223
+ secret_blocklist=cfg.secret_blocklist,
224
+ )
225
+ save_workspace_config(target_root, updated)
226
+
227
+ server_status = "saved_locally_offline"
228
+ auth_hint = ""
229
+ token = get_effective_api_token(cfg)
230
+ if cfg.api_base_url:
231
+ try:
232
+ import urllib.error
233
+ import urllib.request
234
+
235
+ claim_url = f"{cfg.api_base_url}/tickets/{ticket_key}/claim/"
236
+ claim_body = json.dumps(
237
+ {
238
+ "holder_id": cfg.machine_label or "mcp-agent",
239
+ "holder_kind": "NOTEBOOK_CLI",
240
+ "ttl_minutes": 30,
241
+ "project_slug": cfg.project_slug,
242
+ }
243
+ ).encode("utf-8")
244
+ headers = {"Content-Type": "application/json"}
245
+ if token:
246
+ headers["Authorization"] = f"Token {token}"
247
+ req = urllib.request.Request(
248
+ claim_url, data=claim_body, headers=headers, method="POST"
249
+ )
250
+ with urllib.request.urlopen(req, timeout=4) as resp:
251
+ if resp.status in {200, 201}:
252
+ server_status = "claimed_on_server"
253
+ raw_claim = json.loads(resp.read().decode("utf-8"))
254
+ ticket_obj = (raw_claim.get("data") or {}).get("ticket") or {}
255
+ if ticket_obj:
256
+ write_active_task_prompt(target_root, ticket_obj)
257
+ except urllib.error.HTTPError as err:
258
+ if err.code in {401, 403}:
259
+ server_status = "unauthorized_401"
260
+ auth_hint = (
261
+ "Token API RAKIT belum disetel atau tidak valid. "
262
+ "Salin token dari Web Console: http://localhost:8000/accounts/profile/ "
263
+ "lalu simpan dengan: rakit token <TOKEN>"
264
+ )
265
+ else:
266
+ server_status = f"http_{err.code}"
267
+ except Exception as e:
268
+ server_status = f"offline_fallback: {e}"
269
+
270
+ res: dict[str, Any] = {
271
+ "ok": True,
272
+ "active_ticket_key": ticket_key,
273
+ "server_claim_status": server_status,
274
+ }
275
+ if auth_hint:
276
+ res["auth_hint"] = auth_hint
277
+ return res
278
+
279
+ if tool_name == "rakit_sync_progress":
280
+ raw_summary = str(arguments.get("summary") or tasks_summary.next_open_task)
281
+ return {
282
+ "ok": True,
283
+ "active_ticket_key": cfg.active_ticket_key,
284
+ "tasks_completed": tasks_summary.completed_count,
285
+ "tasks_total": tasks_summary.total_count,
286
+ "summary": redact_sensitive_text(raw_summary),
287
+ }
288
+
289
+ if tool_name == "rakit_ask_human":
290
+ question = redact_sensitive_text(str(arguments.get("question") or "").strip())
291
+ if not question:
292
+ return {"ok": False, "code": "QUESTION_REQUIRED"}
293
+ return {
294
+ "ok": True,
295
+ "active_ticket_key": cfg.active_ticket_key,
296
+ "paused_status": "NEEDS_HUMAN",
297
+ "question": question,
298
+ }
299
+
300
+ return {"ok": False, "code": "UNKNOWN_TOOL"}
301
+
302
+
303
+ def main() -> int:
304
+ """Menjalankan loop pembacaan baris JSON-RPC 2.0 melalui `sys.stdin` dan `sys.stdout`."""
305
+ import argparse
306
+
307
+ parser = argparse.ArgumentParser(description="RAKIT MCP stdio server")
308
+ parser.add_argument("--workspace", "-w", type=Path, default=None, help="Jalur workspace")
309
+ args, _ = parser.parse_known_args()
310
+
311
+ workspace_root = find_workspace_root(args.workspace.resolve() if args.workspace else None)
312
+ for raw_line in sys.stdin:
313
+ stripped = raw_line.strip()
314
+ if not stripped:
315
+ continue
316
+ request_obj = json.loads(stripped)
317
+ response_obj = handle_jsonrpc_message(request_obj, workspace_root=workspace_root)
318
+ if response_obj is not None:
319
+ sys.stdout.write(json.dumps(response_obj, ensure_ascii=False) + "\n")
320
+ sys.stdout.flush()
321
+ return 0
322
+
323
+
324
+ if __name__ == "__main__":
325
+ raise SystemExit(main())
rakit_cli/security.py ADDED
@@ -0,0 +1,90 @@
1
+ """Saringan keamanan pemindaian lokal & penyensoran rahasia sebelum dikirim ke server RAKIT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ BLOCKED_DIRECTORY_NAMES: frozenset[str] = frozenset(
9
+ {
10
+ ".git",
11
+ ".venv",
12
+ "venv",
13
+ "node_modules",
14
+ "__pycache__",
15
+ ".pytest_cache",
16
+ ".dart_tool",
17
+ "build",
18
+ "secrets",
19
+ ".secrets",
20
+ ".rakit",
21
+ }
22
+ )
23
+
24
+ BLOCKED_SUFFIXES: tuple[str, ...] = (
25
+ ".pem",
26
+ ".key",
27
+ ".p12",
28
+ ".pfx",
29
+ ".keystore",
30
+ ".jks",
31
+ ".sqlite3",
32
+ )
33
+
34
+ BLOCKED_FILE_PREFIXES: tuple[str, ...] = (
35
+ "id_rsa",
36
+ "id_ed25519",
37
+ "id_ecdsa",
38
+ )
39
+
40
+ _SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
41
+ re.compile(
42
+ r"(?i)(token|api[_-]?key|secret|password)\s*[:=]\s*['\"]?[A-Za-z0-9_\-\.]{8,}['\"]?"
43
+ ),
44
+ re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"),
45
+ re.compile(r"sk-[A-Za-z0-9_\-]{20,}"),
46
+ )
47
+
48
+
49
+ def is_safe_workspace_path(relative_path: str | Path) -> bool:
50
+ """
51
+ TUJUAN: Memastikan berkas lokal aman dipindai tanpa menyentuh `.env` atau kunci privat (NFR-05).
52
+
53
+ ALUR:
54
+ 1. Menolak segmen direktori terlarang / berawalan titik (`.git`, `.venv`, dll.).
55
+ 2. Menolak berkas dot/tersembunyi (`.gitignore`, `.coverage`, `.python-version`, `.env*`).
56
+ 3. Menolak sertifikat/kunci privat (`*.pem`, `*.key`, `id_rsa*`).
57
+
58
+ DIPANGGIL DARI: Pemindai pohon direktori `rakit scan`
59
+ DEPENDENSI: pathlib
60
+ """
61
+ posix_path = PurePosixPath(str(relative_path).replace("\\", "/"))
62
+ for part in posix_path.parts[:-1]:
63
+ if part.startswith(".") or part in BLOCKED_DIRECTORY_NAMES:
64
+ return False
65
+
66
+ filename = posix_path.name
67
+ lower_name = filename.lower()
68
+ if lower_name.startswith(".") or lower_name in BLOCKED_DIRECTORY_NAMES:
69
+ return False
70
+
71
+ if lower_name.endswith(BLOCKED_SUFFIXES):
72
+ return False
73
+
74
+ if lower_name.startswith(BLOCKED_FILE_PREFIXES):
75
+ return False
76
+
77
+ return True
78
+
79
+
80
+ def redact_sensitive_text(raw_text: str) -> str:
81
+ """
82
+ TUJUAN: Menyensor pola token, password, dan kunci API dari ringkasan teks sebelum dikirim.
83
+
84
+ DIPANGGIL DARI: `rakit sync` dan perkakas MCP `rakit_ask_human`
85
+ DEPENDENSI: re
86
+ """
87
+ sanitized = raw_text
88
+ for pattern in _SECRET_PATTERNS:
89
+ sanitized = pattern.sub("[REDACTED_SECRET]", sanitized)
90
+ return sanitized
@@ -0,0 +1,218 @@
1
+ """Modul pemasang & pengelola AI Skill RAKIT v2.0 untuk asisten coding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ _FALLBACK_SKILL_CONTENT = """---
8
+ name: rakit
9
+ description: >-
10
+ Skill orkestrator alur kerja AI & pengembang untuk ekosistem RAKIT v2.0.
11
+ Membimbing siklus hidup pengembangan dari ide (PRD, User Story, ADR, DB Design, Prototipe),
12
+ interaksi tiket backlog via MCP/CLI, hingga quality gates.
13
+ ---
14
+
15
+ # RAKIT v2.0 AI Assistant Skill
16
+ Skill ini memandu AI mengeksekusi tiket backlog dan quality gates RAKIT v2.0 secara riil.
17
+ """
18
+
19
+ MODULAR_COMPANION_SKILLS: dict[str, str] = {
20
+ "rakit-status": """---
21
+ name: rakit-status
22
+ description: Membaca status koneksi proyek RAKIT, kunci tiket aktif, dan progres TASKS.md.
23
+ ---
24
+
25
+ # /rakit-status — Status Proyek & Progres
26
+ 1. Panggil MCP `rakit_status` atau jalankan `rakit status`.
27
+ 2. Sajikan laporan status proyek, tiket aktif, progres TASKS.md, dan tugas terbuka berikutnya.
28
+ """,
29
+ "rakit-claim-ticket": """---
30
+ name: rakit-claim-ticket
31
+ description: Mengklaim tiket aktif dari backlog ke workspace lokal & buat .rakit/PROMPT.md.
32
+ ---
33
+
34
+ # /rakit-claim-ticket — Klaim Tiket Aktif
35
+ 1. Panggil MCP `rakit_claim_ticket` dengan ticket_key atau jalankan `rakit start <KEY>`.
36
+ 2. Baca `.rakit/PROMPT.md` untuk memahami allowed_paths dan kriteria GWT.
37
+ 3. Mulai pengerjaan TDD secara nyata di repositori!
38
+ """,
39
+ "rakit-sync-progress": """---
40
+ name: rakit-sync-progress
41
+ description: Menyaring rahasia & sinkronkan progres to-do TASKS.md ke Web Console RAKIT.
42
+ ---
43
+
44
+ # /rakit-sync-progress — Sinkronisasi Progres
45
+ 1. Pastikan to-do selesai di `TASKS.md` sudah bertanda `[x]`.
46
+ 2. Panggil MCP `rakit_sync_progress` atau jalankan `rakit sync --note "<CATATAN>"`.
47
+ 3. Tampilkan konfirmasi progres dan persentase terbaru.
48
+ """,
49
+ "rakit-ask-human": """---
50
+ name: rakit-ask-human
51
+ description: Eskalasi blocker/keraguan bisnis ke Web Console & HP pengembang (NEEDS_HUMAN).
52
+ ---
53
+
54
+ # /rakit-ask-human — Eskalasi Blocker ke Pengembang
55
+ 1. Rumuskan pertanyaan ringkas tanpa data rahasia/token.
56
+ 2. Panggil tool MCP `rakit_ask_human` dengan argumen pertanyaan.
57
+ 3. Status tiket berubah menjadi `NEEDS_HUMAN` di Web Console & HP pengembang.
58
+ 4. Tunggu respon manusia sebelum melanjutkan kode.
59
+ """,
60
+ "rakit-new-task": """---
61
+ name: rakit-new-task
62
+ description: Formulasi tiket baru berformat GWT, tentukan allowed_paths, & daftar ke backlog.
63
+ ---
64
+
65
+ # /rakit-new-task — Buat Tiket Baru
66
+ 1. Tentukan sejak awal bersama pengguna:
67
+ - **Kategori Alur** (mis. `Manajemen Aset`, `Auth & Login`, `Inti Sistem`),
68
+ - **Story Points** (mis. `3` atau `5`), **Allowed Paths**, **Given-When-Then**, dan **Cakupan**.
69
+ 2. Tambahkan butir `- [ ]` terstruktur ke `TASKS.md` dengan format metadata berikut
70
+ agar langsung terbaca oleh `rakit scan --persist`:
71
+ - `- **Kategori:** <Kategori Alur>`
72
+ - `- **Story Points:** <angka>`
73
+ - `- **Allowed Paths:** <daftar folder/berkas dipisah koma>`
74
+ - `- **Given-When-Then (GWT):**` (`- **Given:**`, `- **When:**`, `- **Then:**`)
75
+ - `- **Cakupan:** <rincian poin pekerjaan & unit test>`
76
+ 3. Jalankan `rakit scan --persist` untuk mendaftarkan tiket lengkap ke BACKLOG Web Console.
77
+ """,
78
+ "rakit-prd": """---
79
+ name: rakit-prd
80
+ description: Menyusun dokumen Product Requirement Document di docs/prd/<fitur>.md (G1 Gate).
81
+ ---
82
+
83
+ # /rakit-prd — Penyusunan PRD
84
+ 1. Buat dokumen PRD di `docs/prd/<nama_fitur>.md`.
85
+ 2. Susun Problem Statement, Goals, Non-Goals, FR (GWT), Security, dan allowed_paths.
86
+ 3. Periksa kelengkapan Gerbang G1 (Spec Gate).
87
+ """,
88
+ "rakit-user-story": """---
89
+ name: rakit-user-story
90
+ description: Memecah PRD menjadi User Stories berformat GWT dan estimasi Story Points.
91
+ ---
92
+
93
+ # /rakit-user-story — Pecah User Story
94
+ 1. Baca PRD dan buat berkas `docs/prd/user-stories/<fitur>.md`.
95
+ 2. Format: As a... I want to... So that..., Acceptance Criteria GWT, estimasi.
96
+ 3. Daftarkan checklist sub-tugas ke `TASKS.md`.
97
+ """,
98
+ "rakit-adr": """---
99
+ name: rakit-adr
100
+ description: Membuat Architectural Decision Record di docs/adr/ format Michael Nygard.
101
+ ---
102
+
103
+ # /rakit-adr — Architectural Decision Record
104
+ 1. Cari nomor urut ADR berikutnya di `docs/adr/`.
105
+ 2. Tulis berkas `docs/adr/ADR-XXX-<judul>.md`.
106
+ 3. Cantumkan Status, Konteks, Keputusan, Konsekuensi, dan Compliance.
107
+ """,
108
+ "rakit-db-design": """---
109
+ name: rakit-db-design
110
+ description: Merancang skema database relasional, ERD mermaid, tenant isolation, & migrasi.
111
+ ---
112
+
113
+ # /rakit-db-design — Desain Database Relasional
114
+ 1. Rancang entitas, relasi, UUID/BigInt PK, FK cascade, dan composite index.
115
+ 2. Terapkan multi-tenant (`tenant_id`), audit trail, dan soft-delete.
116
+ 3. Buat diagram Mermaid ERD dan deklarasi model Django lengkap.
117
+ """,
118
+ "rakit-proto": """---
119
+ name: rakit-proto
120
+ description: Merancang prototipe antarmuka UI (Dasa UI v2, Cotton, HTMX, Flutter Pastel).
121
+ ---
122
+
123
+ # /rakit-proto — Prototipe Antarmuka
124
+ 1. Desain komponen UI menggunakan sistem desain yang berlaku:
125
+ - Web: Dasa UI v2 + Django-Cotton + HTMX parsial.
126
+ - Mobile: Flutter M3 Pastel (`useMaterial3: true`, RakitSemanticColors).
127
+ 2. Dilarang memakai inline CSS liar atau warna hex hardcoded.
128
+ """,
129
+ "rakit-verify": """---
130
+ name: rakit-verify
131
+ description: Audit mutu lokal (linter, pytest, coverage, TASKS.md) menuju Gerbang G2 (QA).
132
+ ---
133
+
134
+ # /rakit-verify — Verifikasi Mutu Gerbang G2
135
+ 1. Pastikan to-do terkait di `TASKS.md` sudah `[x]`.
136
+ 2. Jalankan linter (mis. `uv run ruff check .`) dan unit test (`uv run pytest`)
137
+ sesuai konfigurasi `verification` di `rakit.yaml`.
138
+ 3. Jalankan `rakit verify`.
139
+ 4. Panggil `rakit_sync_progress` untuk melaporkan kesiapan tiket ke QA.
140
+ """,
141
+ "rakit-uat": """---
142
+ name: rakit-uat
143
+ description: Menyusun dokumen UAT Acceptance Test Plan dan skenario Playwright E2E.
144
+ ---
145
+
146
+ # /rakit-uat — User Acceptance Test (UAT)
147
+ 1. Buat dokumen skenario acceptance di `docs/uat/<fitur>.md`.
148
+ 2. Tulis skenario E2E pada direktori proyek target sesuai `e2e_dir` di `rakit.yaml`
149
+ (mis. `tests/e2e/test_<fitur>.py` atau `e2e/<fitur>.spec.ts`).
150
+ 3. Pastikan bukti uji/video H.264 `.mp4` (jika tersedia) dikirim ke API RAKIT.
151
+ """,
152
+ "rakit-release": """---
153
+ name: rakit-release
154
+ description: Checklist Gerbang G3 (Rilis Produksi), bump SemVer, CHANGELOG, & artefak.
155
+ ---
156
+
157
+ # /rakit-release — Gerbang G3 & Rilis Produksi
158
+ 1. Periksa kelulusan seluruh tiket dan audit keamanan.
159
+ 2. Naikkan versi SemVer di `pyproject.toml` dan `pubspec.yaml`.
160
+ 3. Perbarui `CHANGELOG.md` dan verifikasi artefak rilis.
161
+ """,
162
+ }
163
+
164
+
165
+ def get_rakit_skill_content() -> str:
166
+ """Mengembalikan konten Markdown lengkap dari berkas SKILL.md RAKIT v2.0."""
167
+ template_path = Path(__file__).parent / "templates" / "SKILL.md"
168
+ if template_path.exists():
169
+ return template_path.read_text(encoding="utf-8")
170
+ return _FALLBACK_SKILL_CONTENT.strip() + "\n"
171
+
172
+
173
+ def install_skill(
174
+ destination_dir: Path,
175
+ *,
176
+ force: bool = False,
177
+ ) -> tuple[Path, str]:
178
+ """
179
+ TUJUAN: Memasang berkas SKILL.md ke folder target beserta skill pendamping.
180
+
181
+ PARAMETER:
182
+ - destination_dir: Folder tujuan (misal .agents/skills/rakit)
183
+ - force: Bila True, timpa berkas jika sudah ada.
184
+
185
+ KEMBALIAN:
186
+ Tuple (jalur_berkas_skill, status: 'created' | 'updated' | 'existing_preserved')
187
+ """
188
+ destination_dir.mkdir(parents=True, exist_ok=True)
189
+ skill_file = destination_dir / "SKILL.md"
190
+
191
+ content = get_rakit_skill_content()
192
+ is_update = skill_file.exists()
193
+ status = "existing_preserved"
194
+ if not skill_file.exists() or force:
195
+ skill_file.write_text(content, encoding="utf-8")
196
+ status = "updated" if is_update else "created"
197
+
198
+ # Jika destination_dir bernama 'rakit', otomatis pasang skill pendamping di folder induknya
199
+ if destination_dir.name == "rakit":
200
+ base_skills_dir = destination_dir.parent
201
+ for skill_name, skill_text in MODULAR_COMPANION_SKILLS.items():
202
+ child_dir = base_skills_dir / skill_name
203
+ child_dir.mkdir(parents=True, exist_ok=True)
204
+ child_file = child_dir / "SKILL.md"
205
+ if not child_file.exists() or force:
206
+ child_file.write_text(skill_text.strip() + "\n", encoding="utf-8")
207
+
208
+ return skill_file, status
209
+
210
+
211
+ def get_default_workspace_skill_dir(workspace_root: Path) -> Path:
212
+ """Mengembalikan jalur standar direktori skill workspace lokal (.agents/skills/rakit)."""
213
+ return workspace_root / ".agents" / "skills" / "rakit"
214
+
215
+
216
+ def get_default_global_skill_dir() -> Path:
217
+ """Mengembalikan jalur standar direktori skill global pengguna (~/.agents/skills/rakit)."""
218
+ return Path.home() / ".agents" / "skills" / "rakit"
@@ -0,0 +1,59 @@
1
+ """Pengurai berkas `TASKS.md` untuk menghitung progres sub-tugas secara deterministik."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ _CHECKBOX_PATTERN = re.compile(r"^\s*-\s*\[(?P<mark>[ xX])\]\s+(?P<label>.+?)\s*$")
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TasksProgressSummary:
13
+ """
14
+ TUJUAN: Menyimpan ringkasan tugas selesai, total tugas, persentase, dan item terbuka berikutnya.
15
+
16
+ DIPANGGIL DARI: `rakit sync`, `rakit scan`, dan perkakas MCP `rakit_status`
17
+ DEPENDENSI: dataclasses
18
+ """
19
+
20
+ completed_count: int
21
+ total_count: int
22
+ progress_pct: int
23
+ next_open_task: str
24
+
25
+
26
+ def parse_tasks_markdown(markdown_text: str) -> TasksProgressSummary:
27
+ """
28
+ TUJUAN: Menghitung kotak centang `- [x]` dan `- [ ]` dari isi berkas `TASKS.md`.
29
+
30
+ ALUR:
31
+ 1. Memindai baris demi baris memakai ekspresi reguler kotak centang Markdown.
32
+ 2. Mencatat butir `- [ ]` pertama sebagai `next_open_task` (milestone aktif).
33
+
34
+ DIPANGGIL DARI: CLI `rakit` dan server MCP `rakit-mcp`
35
+ DEPENDENSI: re
36
+ """
37
+ completed = 0
38
+ total = 0
39
+ next_open = ""
40
+
41
+ for line in markdown_text.splitlines():
42
+ match = _CHECKBOX_PATTERN.match(line)
43
+ if match is None:
44
+ continue
45
+ total += 1
46
+ mark = match.group("mark")
47
+ label = match.group("label").strip()
48
+ if mark.lower() == "x":
49
+ completed += 1
50
+ elif not next_open:
51
+ next_open = label
52
+
53
+ pct = int((completed / total) * 100) if total > 0 else 0
54
+ return TasksProgressSummary(
55
+ completed_count=completed,
56
+ total_count=total,
57
+ progress_pct=min(pct, 100),
58
+ next_open_task=next_open,
59
+ )