@reunionstudio/airlock-mcp 0.1.0

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.
Files changed (57) hide show
  1. package/.agents/skills/airlock-mcp/SKILL.md +122 -0
  2. package/.agents/skills/airlock-mcp/agents/openai.yaml +4 -0
  3. package/LICENSE +187 -0
  4. package/README.md +126 -0
  5. package/SECURITY.md +31 -0
  6. package/bin/airlock-mcp.mjs +5 -0
  7. package/docs/architecture.md +82 -0
  8. package/docs/install-surface.md +112 -0
  9. package/docs/ooda-loop.md +40 -0
  10. package/docs/spec-workbench-architecture.md +161 -0
  11. package/docs/spec-workspace.md +33 -0
  12. package/docs/workflows.md +229 -0
  13. package/package.json +46 -0
  14. package/patterns/blank/README.md +14 -0
  15. package/patterns/blank/sample.records.json +19 -0
  16. package/patterns/blank/spec.config.json +72 -0
  17. package/patterns/guest-access/individual-isolation.md +27 -0
  18. package/patterns/guest-access/role-isolation.md +26 -0
  19. package/patterns/guest-access/shared-contribution.md +25 -0
  20. package/patterns/manifest.json +16 -0
  21. package/patterns/spec-types/commitment.md +24 -0
  22. package/patterns/spec-types/observation.md +22 -0
  23. package/patterns/spec-types/reconciliation.md +19 -0
  24. package/patterns/spec-types/reference-master-data.md +21 -0
  25. package/patterns/starter-posts/README.md +32 -0
  26. package/patterns/starter-posts/sample.records.json +27 -0
  27. package/patterns/starter-posts/spec.config.json +135 -0
  28. package/schemas/airlock-mcp-workspace.schema.json +14 -0
  29. package/setup.py +41 -0
  30. package/src/airlock_mcp/__init__.py +3 -0
  31. package/src/airlock_mcp/__main__.py +5 -0
  32. package/src/airlock_mcp/art.py +26 -0
  33. package/src/airlock_mcp/bootstrap.py +161 -0
  34. package/src/airlock_mcp/cli.py +450 -0
  35. package/src/airlock_mcp/jsonio.py +56 -0
  36. package/src/airlock_mcp/manage.py +247 -0
  37. package/src/airlock_mcp/models.py +43 -0
  38. package/src/airlock_mcp/patterns.py +49 -0
  39. package/src/airlock_mcp/project.py +39 -0
  40. package/src/airlock_mcp/records.py +43 -0
  41. package/src/airlock_mcp/specs.py +110 -0
  42. package/src/airlock_mcp/sql.py +15 -0
  43. package/src/airlock_mcp/summary.py +115 -0
  44. package/src/airlock_mcp/updater.py +76 -0
  45. package/src/airlock_mcp/validation.py +334 -0
  46. package/src/airlock_mcp/workspace.py +223 -0
  47. package/src/cli.mjs +89 -0
  48. package/src/install.mjs +100 -0
  49. package/src/mcp.mjs +184 -0
  50. package/src/text.mjs +108 -0
  51. package/src/workbench.mjs +368 -0
  52. package/workspaces/_template/brief.md +18 -0
  53. package/workspaces/_template/decisions.md +40 -0
  54. package/workspaces/_template/questions.md +9 -0
  55. package/workspaces/_template/review.md +21 -0
  56. package/workspaces/_template/sample.records.json +19 -0
  57. package/workspaces/_template/spec.config.json +72 -0
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ DEFAULT_SOURCE = "git+https://github.com/reunionstudio/airlock-mcp.git"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class UpdatePlan:
14
+ method: str
15
+ root: Path
16
+ command: tuple[str, ...]
17
+ reason: str
18
+
19
+
20
+ def _is_git_checkout(root: Path) -> bool:
21
+ return (root / ".git").exists()
22
+
23
+
24
+ def build_update_plan(root: Path, *, method: str = "auto", source: str = DEFAULT_SOURCE) -> UpdatePlan:
25
+ if method not in {"auto", "git", "pip"}:
26
+ raise ValueError("method must be auto, git, or pip")
27
+
28
+ if method == "git" or (method == "auto" and _is_git_checkout(root)):
29
+ return UpdatePlan(
30
+ method="git",
31
+ root=root,
32
+ command=("git", "-C", str(root), "pull", "--ff-only"),
33
+ reason="update checkout with a fast-forward-only pull",
34
+ )
35
+
36
+ return UpdatePlan(
37
+ method="pip",
38
+ root=root,
39
+ command=(sys.executable, "-m", "pip", "install", "--upgrade", source),
40
+ reason="upgrade installed package from source",
41
+ )
42
+
43
+
44
+ def format_update_plan(plan: UpdatePlan, *, dry_run: bool) -> str:
45
+ prefix = "dry-run: " if dry_run else ""
46
+ return "\n".join(
47
+ (
48
+ f"{prefix}self-update method: {plan.method}",
49
+ f"root: {plan.root}",
50
+ f"reason: {plan.reason}",
51
+ "command: " + " ".join(plan.command),
52
+ )
53
+ )
54
+
55
+
56
+ def git_checkout_dirty(root: Path) -> bool:
57
+ result = subprocess.run(
58
+ ("git", "-C", str(root), "status", "--porcelain"),
59
+ check=False,
60
+ capture_output=True,
61
+ text=True,
62
+ )
63
+ if result.returncode != 0:
64
+ return True
65
+ return bool(result.stdout.strip())
66
+
67
+
68
+ def run_update(plan: UpdatePlan, *, force: bool = False) -> subprocess.CompletedProcess[str]:
69
+ if plan.method == "git" and git_checkout_dirty(plan.root) and not force:
70
+ return subprocess.CompletedProcess(
71
+ plan.command,
72
+ 2,
73
+ "",
74
+ "working tree has uncommitted changes; commit, stash, or pass --force\n",
75
+ )
76
+ return subprocess.run(plan.command, check=False, capture_output=True, text=True)
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .jsonio import read_json
7
+ from .models import CheckResult, Finding
8
+
9
+
10
+ ALLOWED_COLUMN_TYPES = {
11
+ "string",
12
+ "number",
13
+ "integer",
14
+ "boolean",
15
+ "date",
16
+ "datetime",
17
+ "variant",
18
+ }
19
+
20
+ BANNED_PAYLOAD_FIELDS = {
21
+ "approval_status",
22
+ "approved_at",
23
+ "approved_by",
24
+ "reviewer_notes",
25
+ "workflow_state",
26
+ "workflow_status",
27
+ "workflow_step",
28
+ }
29
+
30
+ WORKFLOW_FIELD_PREFIXES = (
31
+ "approval_",
32
+ "approved_",
33
+ "reviewer_",
34
+ "workflow_",
35
+ "pushback_",
36
+ )
37
+
38
+ ALLOWED_ACCESS_LEVELS = {"append_access", "read_access", "full_access"}
39
+
40
+ REQUIRED_TEXT_FILES = ("brief.md", "decisions.md", "questions.md", "review.md")
41
+
42
+
43
+ def require_dict(value: Any, findings: list[Finding], path: str) -> dict[str, Any] | None:
44
+ if not isinstance(value, dict):
45
+ findings.append(Finding("error", path, "Expected object."))
46
+ return None
47
+ return value
48
+
49
+
50
+ def require_list(value: Any, findings: list[Finding], path: str) -> list[Any] | None:
51
+ if not isinstance(value, list):
52
+ findings.append(Finding("error", path, "Expected array."))
53
+ return None
54
+ return value
55
+
56
+
57
+ def _variant_shape_field(rule: dict[str, Any]) -> str | None:
58
+ value = rule.get("field", rule.get("column"))
59
+ return value if isinstance(value, str) and value else None
60
+
61
+
62
+ def _looks_like_workflow_state(name: str) -> bool:
63
+ return name in BANNED_PAYLOAD_FIELDS or name.startswith(WORKFLOW_FIELD_PREFIXES)
64
+
65
+
66
+ def _public_access_enabled(guest_access: dict[str, Any], access_level: str) -> bool:
67
+ public_folder = guest_access.get("public_folder")
68
+ if not isinstance(public_folder, dict) or public_folder.get("enabled") is not True:
69
+ return False
70
+ subfolders = public_folder.get("subfolders")
71
+ if not isinstance(subfolders, dict):
72
+ return False
73
+ target = subfolders.get(access_level)
74
+ return isinstance(target, dict) and target.get("enabled") is True
75
+
76
+
77
+ def validate_guest_access(guest_access: dict[str, Any], findings: list[Finding]) -> None:
78
+ isolated = guest_access.get("isolated_directories_enabled") is True
79
+ guest_roles = guest_access.get("guest_roles")
80
+ if guest_roles is None:
81
+ return
82
+
83
+ roles = require_list(guest_roles, findings, "spec.config.json:guest_access.guest_roles")
84
+ if not roles:
85
+ return
86
+
87
+ for index, raw_role in enumerate(roles):
88
+ path = f"spec.config.json:guest_access.guest_roles[{index}]"
89
+ if isinstance(raw_role, str):
90
+ continue
91
+ role = require_dict(raw_role, findings, path)
92
+ if not role:
93
+ continue
94
+
95
+ role_name = role.get("role_name")
96
+ if not isinstance(role_name, str) or not role_name:
97
+ findings.append(Finding("error", f"{path}.role_name", "Guest role name is required."))
98
+
99
+ access_level = role.get("access_level")
100
+ if access_level is None:
101
+ if not isolated:
102
+ findings.append(
103
+ Finding(
104
+ "warning",
105
+ path,
106
+ "Shared guest role has no access_level; confirm the intended public-folder access.",
107
+ )
108
+ )
109
+ continue
110
+
111
+ if access_level not in ALLOWED_ACCESS_LEVELS:
112
+ findings.append(
113
+ Finding(
114
+ "error",
115
+ f"{path}.access_level",
116
+ "Expected append_access, read_access, or full_access.",
117
+ )
118
+ )
119
+ continue
120
+
121
+ if not isolated and not _public_access_enabled(guest_access, str(access_level)):
122
+ findings.append(
123
+ Finding(
124
+ "error",
125
+ f"{path}.access_level",
126
+ f"`{access_level}` requires the matching public_folder subfolder to be enabled.",
127
+ )
128
+ )
129
+
130
+
131
+ def validate_spec_config(spec: dict[str, Any], findings: list[Finding]) -> set[str]:
132
+ core = require_dict(spec.get("core_config"), findings, "spec.config.json:core_config")
133
+ if core:
134
+ for key in ("spec_name", "spec_alias", "description", "owner_role"):
135
+ if not core.get(key):
136
+ findings.append(
137
+ Finding("error", f"spec.config.json:core_config.{key}", "Required value is missing.")
138
+ )
139
+
140
+ columns = require_list(spec.get("column_config"), findings, "spec.config.json:column_config")
141
+ column_names: set[str] = set()
142
+ variant_columns: set[str] = set()
143
+ variant_shape_fields: set[str] = set()
144
+ if columns:
145
+ seen: set[str] = set()
146
+ for index, raw_column in enumerate(columns):
147
+ path = f"spec.config.json:column_config[{index}]"
148
+ column = require_dict(raw_column, findings, path)
149
+ if not column:
150
+ continue
151
+
152
+ name = column.get("name")
153
+ column_type = column.get("type")
154
+ tests = column.get("tests")
155
+
156
+ if not isinstance(name, str) or not name:
157
+ findings.append(Finding("error", f"{path}.name", "Column name is required."))
158
+ continue
159
+ if name in seen:
160
+ findings.append(Finding("error", f"{path}.name", f"Duplicate column `{name}`."))
161
+ seen.add(name)
162
+ column_names.add(name)
163
+
164
+ if _looks_like_workflow_state(name):
165
+ findings.append(
166
+ Finding(
167
+ "warning",
168
+ f"{path}.name",
169
+ f"`{name}` looks like Airlock workflow or review state; keep it only if it is an upstream fact.",
170
+ )
171
+ )
172
+
173
+ if column_type not in ALLOWED_COLUMN_TYPES:
174
+ findings.append(Finding("error", f"{path}.type", f"Unsupported column type `{column_type}`."))
175
+ if column_type == "variant":
176
+ variant_columns.add(name)
177
+ if column_type in {"date", "datetime"}:
178
+ fmt = column.get("format")
179
+ if not isinstance(fmt, str) or "%" not in fmt:
180
+ findings.append(
181
+ Finding(
182
+ "error",
183
+ f"{path}.format",
184
+ "Date and datetime formats must use strftime tokens such as %Y-%m-%d.",
185
+ )
186
+ )
187
+ if not isinstance(column.get("description"), str) or not column.get("description"):
188
+ findings.append(Finding("error", f"{path}.description", "Column description is required."))
189
+ if not isinstance(tests, list):
190
+ findings.append(Finding("error", f"{path}.tests", "Column tests must be an array."))
191
+
192
+ rules = spec.get("rules", [])
193
+ if rules is not None:
194
+ rules_list = require_list(rules, findings, "spec.config.json:rules")
195
+ if rules_list:
196
+ for index, raw_rule in enumerate(rules_list):
197
+ path = f"spec.config.json:rules[{index}]"
198
+ rule = require_dict(raw_rule, findings, path)
199
+ if not rule:
200
+ continue
201
+ if rule.get("type") == "variant_shape":
202
+ field = _variant_shape_field(rule)
203
+ if not field:
204
+ findings.append(
205
+ Finding("error", f"{path}.field", "variant_shape needs `field` or `column`.")
206
+ )
207
+ elif field not in variant_columns:
208
+ findings.append(
209
+ Finding(
210
+ "error",
211
+ f"{path}.field",
212
+ "variant_shape field must reference a declared variant column.",
213
+ )
214
+ )
215
+ else:
216
+ variant_shape_fields.add(field)
217
+ allowed = rule.get("allowed_root_keys")
218
+ if allowed is not None and not isinstance(allowed, list):
219
+ findings.append(Finding("error", f"{path}.allowed_root_keys", "Expected array."))
220
+ paths = rule.get("paths")
221
+ optional_paths = rule.get("optional_paths")
222
+ required_paths = rule.get("required_paths")
223
+ if paths is not None and not isinstance(paths, list):
224
+ findings.append(Finding("error", f"{path}.paths", "Expected array."))
225
+ if optional_paths is not None and not isinstance(optional_paths, list):
226
+ findings.append(Finding("error", f"{path}.optional_paths", "Expected array."))
227
+ if required_paths is not None and not isinstance(required_paths, list):
228
+ findings.append(Finding("error", f"{path}.required_paths", "Expected array."))
229
+
230
+ for name in sorted(variant_columns - variant_shape_fields):
231
+ findings.append(
232
+ Finding(
233
+ "warning",
234
+ "spec.config.json:rules",
235
+ f"Variant column `{name}` has no variant_shape rule.",
236
+ )
237
+ )
238
+
239
+ file_rules = require_dict(spec.get("file_rules"), findings, "spec.config.json:file_rules")
240
+ if file_rules:
241
+ file_format = require_dict(
242
+ file_rules.get("file_format"), findings, "spec.config.json:file_rules.file_format"
243
+ )
244
+ if file_format and file_format.get("file_type") not in {"csv", "excel"}:
245
+ findings.append(
246
+ Finding(
247
+ "warning",
248
+ "spec.config.json:file_rules.file_format.file_type",
249
+ "Local records adapter currently checks CSV and Excel-shaped specs best.",
250
+ )
251
+ )
252
+
253
+ guest_access = spec.get("guest_access")
254
+ if guest_access is not None and not isinstance(guest_access, dict):
255
+ findings.append(Finding("error", "spec.config.json:guest_access", "Expected object."))
256
+ elif isinstance(guest_access, dict):
257
+ validate_guest_access(guest_access, findings)
258
+
259
+ return column_names
260
+
261
+
262
+ def validate_sample_records(
263
+ sample: dict[str, Any], spec: dict[str, Any], columns: set[str], findings: list[Finding]
264
+ ) -> None:
265
+ core = spec.get("core_config") if isinstance(spec.get("core_config"), dict) else {}
266
+ expected_spec_name = core.get("spec_name")
267
+ if sample.get("spec_name") != expected_spec_name:
268
+ findings.append(
269
+ Finding(
270
+ "error",
271
+ "sample.records.json:spec_name",
272
+ f"Expected `{expected_spec_name}` to match spec.config.json.",
273
+ )
274
+ )
275
+ if not sample.get("filename"):
276
+ findings.append(Finding("error", "sample.records.json:filename", "Filename is required."))
277
+
278
+ records = require_list(sample.get("records"), findings, "sample.records.json:records")
279
+ if not records:
280
+ return
281
+
282
+ required_fields = {
283
+ column["name"]
284
+ for column in spec.get("column_config", [])
285
+ if isinstance(column, dict)
286
+ and isinstance(column.get("tests"), list)
287
+ and "not_null" in column["tests"]
288
+ }
289
+ variant_fields = {
290
+ column["name"]
291
+ for column in spec.get("column_config", [])
292
+ if isinstance(column, dict) and column.get("type") == "variant"
293
+ }
294
+
295
+ for index, raw_record in enumerate(records):
296
+ path = f"sample.records.json:records[{index}]"
297
+ record = require_dict(raw_record, findings, path)
298
+ if not record:
299
+ continue
300
+ extra = set(record) - columns
301
+ missing = {field for field in required_fields if record.get(field) in (None, "")}
302
+ for field in sorted(extra):
303
+ findings.append(
304
+ Finding("error", f"{path}.{field}", "Record field is not declared in column_config.")
305
+ )
306
+ for field in sorted(missing):
307
+ findings.append(Finding("error", f"{path}.{field}", "Required sample value is missing."))
308
+ for field in sorted(variant_fields & set(record)):
309
+ if record[field] not in (None, "") and not isinstance(record[field], (dict, list)):
310
+ findings.append(
311
+ Finding("error", f"{path}.{field}", "Variant sample values should be objects or arrays.")
312
+ )
313
+
314
+
315
+ def check_workspace(workspace: Path) -> CheckResult:
316
+ findings: list[Finding] = []
317
+ if not workspace.exists():
318
+ return CheckResult((Finding("error", str(workspace), "Workspace not found."),))
319
+
320
+ for filename in REQUIRED_TEXT_FILES:
321
+ path = workspace / filename
322
+ if not path.exists():
323
+ findings.append(Finding("error", filename, "Missing workspace file."))
324
+ elif not path.read_text(encoding="utf-8").strip():
325
+ findings.append(Finding("warning", filename, "Workspace file is empty."))
326
+
327
+ spec = read_json(workspace / "spec.config.json", findings)
328
+ sample = read_json(workspace / "sample.records.json", findings)
329
+ if isinstance(spec, dict):
330
+ columns = validate_spec_config(spec, findings)
331
+ if isinstance(sample, dict):
332
+ validate_sample_records(sample, spec, columns, findings)
333
+
334
+ return CheckResult(tuple(findings))
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .jsonio import read_json, write_json, write_text
9
+ from .models import Pattern
10
+ from .patterns import load_pattern_records, load_pattern_spec
11
+ from .specs import retitle_spec, sample_records_for_spec, spec_name
12
+
13
+
14
+ WORKSPACE_FILES = (
15
+ "brief.md",
16
+ "decisions.md",
17
+ "questions.md",
18
+ "review.md",
19
+ "spec.config.json",
20
+ "sample.records.json",
21
+ )
22
+
23
+
24
+ def workspace_markdown(name: str, source_name: str, summary: str, *, mode: str = "create") -> dict[str, str]:
25
+ if mode == "create":
26
+ goal = f"Start from the `{source_name}` pattern: {summary}"
27
+ source_block = f"Mode: create\n\nPattern: {source_name}"
28
+ else:
29
+ goal = summary
30
+ source_block = f"Mode: {mode}\n\nSource: {source_name}"
31
+
32
+ return {
33
+ "brief.md": f"""# Spec Brief
34
+
35
+ ## Goal
36
+
37
+ {goal}
38
+
39
+ ## Users And Agents
40
+
41
+ Who submits, reviews, reads, delegates, or acts on this data?
42
+
43
+ ## Systems To Observe
44
+
45
+ List the systems, files, screenshots, APIs, users, or existing Airlock specs
46
+ that inform the decision.
47
+
48
+ ## First Useful Outcome
49
+
50
+ What should become possible after the first version lands in Airlock?
51
+ """,
52
+ "decisions.md": """# Decisions
53
+
54
+ ## Row Grain
55
+
56
+ One row is:
57
+
58
+ ## OODA Loop
59
+
60
+ - Observe:
61
+ - Orient:
62
+ - Decide:
63
+ - Act:
64
+
65
+ ## Identifiers
66
+
67
+ Stable ids and retry-safe keys:
68
+
69
+ ## Business Time
70
+
71
+ Event, observed, captured, effective, or transaction timestamps:
72
+
73
+ ## Typed Columns
74
+
75
+ Fields people will filter, join, audit, aggregate, or report on:
76
+
77
+ ## Variant Context
78
+
79
+ Optional context that may evolve:
80
+
81
+ ## Evidence
82
+
83
+ Attachments and evidence metadata:
84
+
85
+ ## Access
86
+
87
+ Submitter, reviewer, reader, owner, and delegation model:
88
+
89
+ ## Workflow And Expectations
90
+
91
+ States, pushback, due dates, order, or cadence:
92
+ """,
93
+ "questions.md": """# Questions
94
+
95
+ Use this file for decisions that change the model.
96
+
97
+ - What row grain would be expensive to change later?
98
+ - What evidence is required?
99
+ - What business timestamp matters?
100
+ - Which fields must be typed columns?
101
+ - Who can see shared data?
102
+ """,
103
+ "review.md": f"""# Review
104
+
105
+ ## Local Check
106
+
107
+ Run:
108
+
109
+ ```bash
110
+ airlock-mcp check .
111
+ ```
112
+
113
+ ## Source
114
+
115
+ {source_block}
116
+
117
+ Adaptations:
118
+
119
+ ## Airlock Validation
120
+
121
+ Result:
122
+
123
+ ## Remaining Risk
124
+
125
+ Human decisions still open:
126
+ """,
127
+ }
128
+
129
+
130
+ def create_workspace_from_pattern(
131
+ target: Path,
132
+ pattern: Pattern,
133
+ *,
134
+ workspace_name: str,
135
+ force: bool = False,
136
+ ) -> None:
137
+ if target.exists() and not force:
138
+ raise FileExistsError(target)
139
+ target.mkdir(parents=True, exist_ok=True)
140
+
141
+ spec_config = copy.deepcopy(load_pattern_spec(pattern))
142
+ sample_records = copy.deepcopy(load_pattern_records(pattern))
143
+ if pattern.name == "blank":
144
+ spec_config = retitle_spec(spec_config, workspace_name)
145
+ sample_records["spec_name"] = spec_name(spec_config)
146
+ sample_records["filename"] = f"{sample_records['spec_name']}_001"
147
+
148
+ for filename, content in workspace_markdown(workspace_name, pattern.name, pattern.summary).items():
149
+ write_text(target / filename, content, force=force)
150
+ write_json(target / "spec.config.json", spec_config, force=force)
151
+ write_json(target / "sample.records.json", sample_records, force=force)
152
+
153
+
154
+ def create_workspace_from_spec_config(
155
+ target: Path,
156
+ spec_config: dict[str, Any],
157
+ *,
158
+ mode: str,
159
+ source: str,
160
+ force: bool = False,
161
+ ) -> None:
162
+ if target.exists() and not force:
163
+ raise FileExistsError(target)
164
+ target.mkdir(parents=True, exist_ok=True)
165
+
166
+ records = sample_records_for_spec(spec_config)
167
+ summary = f"Imported canonical spec config from {source}."
168
+ for filename, content in workspace_markdown(target.name, source, summary, mode=mode).items():
169
+ write_text(target / filename, content, force=force)
170
+ write_json(target / "spec.config.json", spec_config, force=force)
171
+ write_json(target / "sample.records.json", records, force=force)
172
+
173
+
174
+ def clone_workspace(source: Path, target: Path, *, workspace_name: str, force: bool = False) -> None:
175
+ if not source.exists():
176
+ raise FileNotFoundError(source)
177
+ if target.exists() and not force:
178
+ raise FileExistsError(target)
179
+ target.mkdir(parents=True, exist_ok=True)
180
+
181
+ for filename in WORKSPACE_FILES:
182
+ source_file = source / filename
183
+ if source_file.exists() and filename not in {"spec.config.json", "sample.records.json", "review.md"}:
184
+ shutil.copyfile(source_file, target / filename)
185
+
186
+ spec_config = read_json(source / "spec.config.json")
187
+ if not isinstance(spec_config, dict):
188
+ raise ValueError(f"Source workspace has invalid spec.config.json: {source}")
189
+
190
+ cloned_config = retitle_spec(spec_config, workspace_name)
191
+ records = sample_records_for_spec(cloned_config)
192
+ write_json(target / "spec.config.json", cloned_config, force=True)
193
+ write_json(target / "sample.records.json", records, force=True)
194
+
195
+ review = f"""# Review
196
+
197
+ ## Local Check
198
+
199
+ Run:
200
+
201
+ ```bash
202
+ airlock-mcp check .
203
+ ```
204
+
205
+ ## Source
206
+
207
+ Mode: clone
208
+
209
+ Source workspace: {source}
210
+
211
+ Original spec: {spec_name(spec_config)}
212
+
213
+ New spec: {spec_name(cloned_config)}
214
+
215
+ ## Airlock Validation
216
+
217
+ Result:
218
+
219
+ ## Remaining Risk
220
+
221
+ Human decisions still open:
222
+ """
223
+ write_text(target / "review.md", review, force=True)