@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,247 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .jsonio import read_json, write_json
9
+ from .specs import retitle_spec, spec_name
10
+ from .summary import workspace_summary
11
+ from .validation import check_workspace
12
+
13
+
14
+ ARCHIVE_DIRNAME = "_archive"
15
+ WORKSPACE_MARKERS = ("spec.config.json", "sample.records.json")
16
+ DECISION_PROMPTS = (
17
+ "One row is:",
18
+ "- Observe:",
19
+ "- Orient:",
20
+ "- Decide:",
21
+ "- Act:",
22
+ "Stable ids and retry-safe keys:",
23
+ "Event, observed, captured, effective, or transaction timestamps:",
24
+ "Fields people will filter, join, audit, aggregate, or report on:",
25
+ "Optional context that may evolve:",
26
+ "Attachments and evidence metadata:",
27
+ "Submitter, reviewer, reader, owner, and delegation model:",
28
+ "States, pushback, due dates, order, or cadence:",
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class WorkspaceInfo:
34
+ name: str
35
+ path: Path
36
+ spec_name: str
37
+ spec_alias: str
38
+ records: int
39
+ errors: int
40
+ warnings: int
41
+ archived: bool = False
42
+
43
+
44
+ def is_workspace(path: Path) -> bool:
45
+ return path.is_dir() and all((path / marker).exists() for marker in WORKSPACE_MARKERS)
46
+
47
+
48
+ def _spec_label(spec: Any, key: str, fallback: str) -> str:
49
+ if isinstance(spec, dict):
50
+ core = spec.get("core_config")
51
+ if isinstance(core, dict) and isinstance(core.get(key), str) and core[key]:
52
+ return core[key]
53
+ return fallback
54
+
55
+
56
+ def workspace_info(path: Path, *, archived: bool = False) -> WorkspaceInfo:
57
+ spec = read_json(path / "spec.config.json")
58
+ sample = read_json(path / "sample.records.json")
59
+ records = sample.get("records") if isinstance(sample, dict) else []
60
+ result = check_workspace(path)
61
+ return WorkspaceInfo(
62
+ name=path.name,
63
+ path=path,
64
+ spec_name=_spec_label(spec, "spec_name", "unknown"),
65
+ spec_alias=_spec_label(spec, "spec_alias", "Unknown"),
66
+ records=len(records) if isinstance(records, list) else 0,
67
+ errors=len(result.errors),
68
+ warnings=len(result.warnings),
69
+ archived=archived,
70
+ )
71
+
72
+
73
+ def discover_workspaces(root: Path, *, include_archived: bool = False) -> list[WorkspaceInfo]:
74
+ if not root.exists():
75
+ return []
76
+
77
+ infos: list[WorkspaceInfo] = []
78
+ for path in sorted(root.iterdir(), key=lambda item: item.name.lower()):
79
+ if path.name.startswith(".") or path.name.startswith("_"):
80
+ continue
81
+ if is_workspace(path):
82
+ infos.append(workspace_info(path))
83
+
84
+ archive_root = root / ARCHIVE_DIRNAME
85
+ if include_archived and archive_root.exists():
86
+ for path in sorted(archive_root.iterdir(), key=lambda item: item.name.lower()):
87
+ if is_workspace(path):
88
+ infos.append(workspace_info(path, archived=True))
89
+ return infos
90
+
91
+
92
+ def format_workspace_table(infos: list[WorkspaceInfo]) -> str:
93
+ if not infos:
94
+ return "no workspaces found"
95
+ rows = [
96
+ (
97
+ info.name,
98
+ info.spec_name,
99
+ str(info.records),
100
+ "archived" if info.archived else "active",
101
+ f"{info.errors}/{info.warnings}",
102
+ str(info.path),
103
+ )
104
+ for info in infos
105
+ ]
106
+ headers = ("workspace", "spec", "records", "state", "errors/warnings", "path")
107
+ widths = [
108
+ max(len(headers[index]), *(len(row[index]) for row in rows))
109
+ for index in range(len(headers))
110
+ ]
111
+ lines = [
112
+ " ".join(headers[index].ljust(widths[index]) for index in range(len(headers))),
113
+ " ".join("-" * width for width in widths),
114
+ ]
115
+ lines.extend(" ".join(row[index].ljust(widths[index]) for index in range(len(row))) for row in rows)
116
+ return "\n".join(lines)
117
+
118
+
119
+ def format_workspace_paths(infos: list[WorkspaceInfo]) -> str:
120
+ return "\n".join(str(info.path) for info in infos)
121
+
122
+
123
+ def archive_workspace(source: Path, *, archive_root: Path | None = None) -> Path:
124
+ if not is_workspace(source):
125
+ raise FileNotFoundError(source)
126
+ target_root = archive_root or source.parent / ARCHIVE_DIRNAME
127
+ target = target_root / source.name
128
+ if target.exists():
129
+ raise FileExistsError(target)
130
+ target_root.mkdir(parents=True, exist_ok=True)
131
+ shutil.move(str(source), str(target))
132
+ return target
133
+
134
+
135
+ def restore_workspace(source: Path, *, output_root: Path | None = None) -> Path:
136
+ if not is_workspace(source):
137
+ raise FileNotFoundError(source)
138
+ if output_root is None and source.parent.name != ARCHIVE_DIRNAME:
139
+ raise ValueError("restore source must be under _archive or --output must be provided")
140
+ target_root = output_root or source.parent.parent
141
+ target = target_root / source.name
142
+ if target.exists():
143
+ raise FileExistsError(target)
144
+ target_root.mkdir(parents=True, exist_ok=True)
145
+ shutil.move(str(source), str(target))
146
+ return target
147
+
148
+
149
+ def rename_workspace(
150
+ source: Path,
151
+ new_name: str,
152
+ *,
153
+ output_root: Path | None = None,
154
+ retitle: bool = True,
155
+ ) -> Path:
156
+ if not is_workspace(source):
157
+ raise FileNotFoundError(source)
158
+ target_root = output_root or source.parent
159
+ target = target_root / new_name
160
+ if target.exists():
161
+ raise FileExistsError(target)
162
+
163
+ spec = read_json(source / "spec.config.json")
164
+ sample = read_json(source / "sample.records.json")
165
+ old_spec_name = spec_name(spec) if isinstance(spec, dict) else None
166
+
167
+ target_root.mkdir(parents=True, exist_ok=True)
168
+ shutil.move(str(source), str(target))
169
+
170
+ if retitle and isinstance(spec, dict):
171
+ updated = retitle_spec(spec, new_name)
172
+ new_spec_name = spec_name(updated)
173
+ write_json(target / "spec.config.json", updated, force=True)
174
+ if isinstance(sample, dict):
175
+ sample["spec_name"] = new_spec_name
176
+ if sample.get("filename") in {None, "", f"{old_spec_name}_001"}:
177
+ sample["filename"] = f"{new_spec_name}_001"
178
+ write_json(target / "sample.records.json", sample, force=True)
179
+ return target
180
+
181
+
182
+ def _line_answers_prompt(line: str, prompt: str) -> bool:
183
+ stripped = line.strip()
184
+ return stripped.startswith(prompt) and stripped != prompt
185
+
186
+
187
+ def _is_prompt_line(line: str) -> bool:
188
+ stripped = line.strip()
189
+ return any(stripped.startswith(prompt) for prompt in DECISION_PROMPTS)
190
+
191
+
192
+ def _has_following_answer(lines: list[str], index: int) -> bool:
193
+ for line in lines[index + 1 :]:
194
+ stripped = line.strip()
195
+ if not stripped:
196
+ continue
197
+ if stripped.startswith("## ") or _is_prompt_line(stripped):
198
+ return False
199
+ return True
200
+ return False
201
+
202
+
203
+ def _text_has_open_placeholders(path: Path) -> bool:
204
+ if not path.exists():
205
+ return True
206
+ text = path.read_text(encoding="utf-8")
207
+ if not text.strip():
208
+ return True
209
+ lines = text.splitlines()
210
+ seen_prompt = False
211
+ for index, line in enumerate(lines):
212
+ for prompt in DECISION_PROMPTS:
213
+ if line.strip().startswith(prompt):
214
+ seen_prompt = True
215
+ if not _line_answers_prompt(line, prompt) and not _has_following_answer(lines, index):
216
+ return True
217
+ return not seen_prompt
218
+
219
+
220
+ def next_steps(workspace: Path) -> str:
221
+ spec = read_json(workspace / "spec.config.json")
222
+ sample = read_json(workspace / "sample.records.json")
223
+ result = check_workspace(workspace)
224
+ lines = [workspace_summary(workspace, spec if isinstance(spec, dict) else {}, sample if isinstance(sample, dict) else {}, result)]
225
+ lines.append("")
226
+ lines.append("next:")
227
+ if result.errors:
228
+ lines.append("1. Fix local check errors before exporting CSV or rendering SQL.")
229
+ lines.append(f"2. Run `airlock-mcp check {workspace}` again.")
230
+ return "\n".join(lines)
231
+ if result.warnings:
232
+ lines.append("1. Review local warnings and either fix them or record the deliberate exception in review.md.")
233
+ lines.append(f"2. Run `airlock-mcp summary {workspace}` to confirm the shape.")
234
+ return "\n".join(lines)
235
+ if _text_has_open_placeholders(workspace / "decisions.md"):
236
+ lines.append("1. Fill in decisions.md for row grain, identifiers, time, evidence, access, and OODA.")
237
+ lines.append(f"2. Run `airlock-mcp next {workspace}` when the design notes are current.")
238
+ return "\n".join(lines)
239
+ records = sample.get("records") if isinstance(sample, dict) else []
240
+ if not records:
241
+ lines.append("1. Add at least one realistic sample record.")
242
+ lines.append(f"2. Run `airlock-mcp check {workspace}`.")
243
+ return "\n".join(lines)
244
+ lines.append(f"1. Export sample CSV: `airlock-mcp export-csv {workspace}`.")
245
+ lines.append(f"2. Render validate-only SQL: `airlock-mcp render-sql {workspace}`.")
246
+ lines.append("3. Run installed Airlock validation before any mutating create or alter call.")
247
+ return "\n".join(lines)
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Pattern:
10
+ name: str
11
+ title: str
12
+ summary: str
13
+ directory: Path
14
+ spec_config_path: Path
15
+ sample_records_path: Path
16
+ readme_path: Path | None = None
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Finding:
21
+ level: str
22
+ path: str
23
+ message: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class CheckResult:
28
+ findings: tuple[Finding, ...]
29
+
30
+ @property
31
+ def errors(self) -> tuple[Finding, ...]:
32
+ return tuple(finding for finding in self.findings if finding.level == "error")
33
+
34
+ @property
35
+ def warnings(self) -> tuple[Finding, ...]:
36
+ return tuple(finding for finding in self.findings if finding.level == "warning")
37
+
38
+ @property
39
+ def ok(self) -> bool:
40
+ return not self.errors
41
+
42
+
43
+ JsonObject = dict[str, Any]
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .jsonio import read_json
6
+ from .models import Pattern
7
+ from .project import patterns_dir
8
+
9
+
10
+ def load_patterns(root: Path | None = None) -> dict[str, Pattern]:
11
+ base = patterns_dir(root)
12
+ manifest = read_json(base / "manifest.json")
13
+ if not isinstance(manifest, dict):
14
+ raise RuntimeError(f"Pattern manifest not found or invalid: {base / 'manifest.json'}")
15
+
16
+ patterns: dict[str, Pattern] = {}
17
+ for raw in manifest.get("patterns", []):
18
+ if not isinstance(raw, dict):
19
+ continue
20
+ name = str(raw.get("name") or "").strip()
21
+ directory_name = str(raw.get("directory") or name).strip()
22
+ if not name:
23
+ continue
24
+ directory = base / directory_name
25
+ pattern = Pattern(
26
+ name=name,
27
+ title=str(raw.get("title") or name),
28
+ summary=str(raw.get("summary") or ""),
29
+ directory=directory,
30
+ spec_config_path=directory / "spec.config.json",
31
+ sample_records_path=directory / "sample.records.json",
32
+ readme_path=(directory / "README.md") if (directory / "README.md").exists() else None,
33
+ )
34
+ patterns[name] = pattern
35
+ return patterns
36
+
37
+
38
+ def load_pattern_spec(pattern: Pattern) -> dict:
39
+ spec = read_json(pattern.spec_config_path)
40
+ if not isinstance(spec, dict):
41
+ raise RuntimeError(f"Pattern spec config is missing or invalid: {pattern.spec_config_path}")
42
+ return spec
43
+
44
+
45
+ def load_pattern_records(pattern: Pattern) -> dict:
46
+ records = read_json(pattern.sample_records_path)
47
+ if not isinstance(records, dict):
48
+ raise RuntimeError(f"Pattern sample records are missing or invalid: {pattern.sample_records_path}")
49
+ return records
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sysconfig
5
+ from pathlib import Path
6
+
7
+
8
+ def repo_root() -> Path:
9
+ """Resolve the Airlock MCP checkout root.
10
+
11
+ `AIRLOCK_MCP_HOME` can point the CLI at a different checkout. Otherwise,
12
+ walk upward from this module and the current directory looking for the
13
+ pattern manifest. Editable installs use the module path; direct source runs
14
+ often use the current working directory.
15
+ """
16
+
17
+ override = os.environ.get("AIRLOCK_MCP_HOME") or os.environ.get("AIRLOCK_SMITH_HOME")
18
+ if override:
19
+ return Path(override).expanduser().resolve()
20
+
21
+ candidates = [Path(__file__).resolve(), Path.cwd().resolve()]
22
+ for start in candidates:
23
+ for parent in [start, *start.parents]:
24
+ if (parent / "patterns" / "manifest.json").exists():
25
+ return parent
26
+
27
+ installed_data = Path(sysconfig.get_path("data")) / "airlock_mcp"
28
+ if (installed_data / "patterns" / "manifest.json").exists():
29
+ return installed_data
30
+
31
+ return Path(__file__).resolve().parents[2]
32
+
33
+
34
+ def patterns_dir(root: Path | None = None) -> Path:
35
+ return (root or repo_root()) / "patterns"
36
+
37
+
38
+ def workspace_template_dir(root: Path | None = None) -> Path:
39
+ return (root or repo_root()) / "workspaces" / "_template"
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from io import StringIO
6
+ from typing import Any
7
+
8
+
9
+ def column_names(spec_config: dict[str, Any]) -> list[str]:
10
+ columns = spec_config.get("column_config")
11
+ if not isinstance(columns, list):
12
+ return []
13
+ names: list[str] = []
14
+ for column in columns:
15
+ if isinstance(column, dict) and isinstance(column.get("name"), str) and column["name"]:
16
+ names.append(column["name"])
17
+ return names
18
+
19
+
20
+ def _csv_value(value: Any) -> Any:
21
+ if value is None:
22
+ return ""
23
+ if isinstance(value, (dict, list)):
24
+ return json.dumps(value, separators=(",", ":"), sort_keys=True)
25
+ return value
26
+
27
+
28
+ def records_to_csv(spec_config: dict[str, Any], sample_records: dict[str, Any]) -> str:
29
+ """Render sample.records.json records as CSV in spec column order."""
30
+
31
+ fields = column_names(spec_config)
32
+ records = sample_records.get("records")
33
+ if not isinstance(records, list):
34
+ records = []
35
+
36
+ output = StringIO()
37
+ writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore", lineterminator="\n")
38
+ writer.writeheader()
39
+ for raw_record in records:
40
+ if not isinstance(raw_record, dict):
41
+ continue
42
+ writer.writerow({field: _csv_value(raw_record.get(field)) for field in fields})
43
+ return output.getvalue()
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+
6
+ from .jsonio import coerce_json_object
7
+
8
+
9
+ def slug_to_snake(value: str) -> str:
10
+ import re
11
+
12
+ cleaned = re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower()
13
+ return cleaned or "draft_spec"
14
+
15
+
16
+ def spec_name(spec_config: dict[str, Any]) -> str:
17
+ core = spec_config.get("core_config")
18
+ if isinstance(core, dict):
19
+ name = core.get("spec_name")
20
+ if isinstance(name, str) and name.strip():
21
+ return name.strip()
22
+ return "draft_spec"
23
+
24
+
25
+ def retitle_spec(spec_config: dict[str, Any], workspace_name: str) -> dict[str, Any]:
26
+ updated = copy.deepcopy(spec_config)
27
+ core = updated.setdefault("core_config", {})
28
+ if isinstance(core, dict):
29
+ new_name = slug_to_snake(workspace_name)
30
+ core["spec_name"] = new_name
31
+ core["spec_alias"] = workspace_name.replace("-", " ").replace("_", " ").title()
32
+ return updated
33
+
34
+
35
+ def extract_spec_config(raw: Any) -> dict[str, Any]:
36
+ """Extract canonical spec config from common Airlock/spec-library shapes."""
37
+
38
+ data = coerce_json_object(raw)
39
+ if not isinstance(data, dict):
40
+ raise ValueError("Expected a JSON object containing a spec config.")
41
+
42
+ for key in ("specConfig", "spec_config", "SPEC_CONFIG"):
43
+ if key in data:
44
+ extracted = coerce_json_object(data[key])
45
+ if isinstance(extracted, dict):
46
+ return extracted
47
+
48
+ if "core_config" in data or "column_config" in data:
49
+ return data
50
+
51
+ specs = data.get("specs")
52
+ if isinstance(specs, list) and specs:
53
+ first = specs[0]
54
+ if isinstance(first, dict):
55
+ config = first.get("config") or first
56
+ if isinstance(config, dict):
57
+ core = {
58
+ "spec_name": data.get("specName") or data.get("SPEC_NAME") or config.get("spec_name") or "imported_spec",
59
+ "spec_alias": data.get("specAlias") or data.get("SPEC_ALIAS") or data.get("specName") or "Imported Spec",
60
+ "description": data.get("summary") or data.get("description") or "",
61
+ "owner_role": config.get("owner_role") or "app_admin",
62
+ "is_published": False,
63
+ "is_archived": False,
64
+ }
65
+ return {
66
+ "core_config": core,
67
+ "column_config": config.get("columns") or config.get("column_config") or [],
68
+ "file_rules": {"file_format": config.get("file_format") or {}},
69
+ "rules": config.get("rules") or [],
70
+ "attachment_policy": config.get("attachment_policy") or {},
71
+ "guest_access": data.get("guest_access") or config.get("guest_access") or {},
72
+ }
73
+
74
+ raise ValueError("Could not find specConfig, spec_config, SPEC_CONFIG, or canonical spec keys.")
75
+
76
+
77
+ def sample_value_for_column(column: dict[str, Any]) -> Any:
78
+ column_type = str(column.get("type") or "string").lower()
79
+ name = str(column.get("name") or "field")
80
+ if column_type in {"number", "float", "decimal"}:
81
+ return 1.0
82
+ if column_type == "integer":
83
+ return 1
84
+ if column_type == "boolean":
85
+ return True
86
+ if column_type == "date":
87
+ return "2026-06-13"
88
+ if column_type == "datetime":
89
+ return "2026-06-13 09:00:00"
90
+ if column_type == "variant":
91
+ return {"source": {"system": "manual"}}
92
+ if name.endswith("_id"):
93
+ return f"{name.upper()}-001"
94
+ return f"example {name.replace('_', ' ')}"
95
+
96
+
97
+ def sample_records_for_spec(spec_config: dict[str, Any]) -> dict[str, Any]:
98
+ columns = spec_config.get("column_config")
99
+ if not isinstance(columns, list):
100
+ columns = []
101
+ record: dict[str, Any] = {}
102
+ for column in columns:
103
+ if isinstance(column, dict) and column.get("name"):
104
+ record[str(column["name"])] = sample_value_for_column(column)
105
+ current_spec_name = spec_name(spec_config)
106
+ return {
107
+ "spec_name": current_spec_name,
108
+ "filename": f"{current_spec_name}_001",
109
+ "records": [record],
110
+ }
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .specs import spec_name
7
+
8
+
9
+ def render_admin_sql(spec_config: dict[str, Any], *, app_name: str, operation: str) -> str:
10
+ body = json.dumps(spec_config, indent=2)
11
+ current_spec = spec_name(spec_config)
12
+ if operation == "alter":
13
+ escaped = current_spec.replace("'", "''")
14
+ return f"CALL {app_name}.admin.alter_spec('{escaped}', PARSE_JSON($${body}$$), TRUE);"
15
+ return f"CALL {app_name}.admin.create_spec(PARSE_JSON($${body}$$), TRUE);"
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .models import CheckResult
7
+
8
+
9
+ def _core(spec_config: dict[str, Any]) -> dict[str, Any]:
10
+ core = spec_config.get("core_config")
11
+ return core if isinstance(core, dict) else {}
12
+
13
+
14
+ def _columns(spec_config: dict[str, Any]) -> list[dict[str, Any]]:
15
+ columns = spec_config.get("column_config")
16
+ return [column for column in columns if isinstance(column, dict)] if isinstance(columns, list) else []
17
+
18
+
19
+ def _variant_shape_fields(spec_config: dict[str, Any]) -> set[str]:
20
+ rules = spec_config.get("rules")
21
+ if not isinstance(rules, list):
22
+ return set()
23
+ fields: set[str] = set()
24
+ for rule in rules:
25
+ if not isinstance(rule, dict) or rule.get("type") != "variant_shape":
26
+ continue
27
+ field = rule.get("field", rule.get("column"))
28
+ if isinstance(field, str) and field:
29
+ fields.add(field)
30
+ return fields
31
+
32
+
33
+ def _guest_access_summary(spec_config: dict[str, Any]) -> str:
34
+ guest_access = spec_config.get("guest_access")
35
+ if not isinstance(guest_access, dict):
36
+ return "not configured"
37
+ if guest_access.get("isolated_directories_enabled") is True:
38
+ return "isolated directories enabled"
39
+ public_folder = guest_access.get("public_folder")
40
+ if not isinstance(public_folder, dict) or public_folder.get("enabled") is not True:
41
+ return "configured without public folder"
42
+ subfolders = public_folder.get("subfolders")
43
+ if not isinstance(subfolders, dict):
44
+ return "shared public folder"
45
+ enabled = [
46
+ name
47
+ for name, value in subfolders.items()
48
+ if isinstance(value, dict) and value.get("enabled") is True
49
+ ]
50
+ return "shared public folder: " + (", ".join(enabled) if enabled else "no enabled subfolders")
51
+
52
+
53
+ def _attachment_summary(spec_config: dict[str, Any]) -> str:
54
+ policy = spec_config.get("attachment_policy")
55
+ if not isinstance(policy, dict):
56
+ return "not configured"
57
+ if policy.get("attachments_enabled") is not True:
58
+ return "disabled"
59
+ return "required" if policy.get("attachment_required") is True else "optional"
60
+
61
+
62
+ def _text_status(workspace: Path, filename: str) -> str:
63
+ path = workspace / filename
64
+ if not path.exists():
65
+ return f"{filename}: missing"
66
+ return f"{filename}: filled" if path.read_text(encoding="utf-8").strip() else f"{filename}: empty"
67
+
68
+
69
+ def workspace_summary(
70
+ workspace: Path,
71
+ spec_config: dict[str, Any],
72
+ sample_records: dict[str, Any],
73
+ result: CheckResult,
74
+ ) -> str:
75
+ core = _core(spec_config)
76
+ columns = _columns(spec_config)
77
+ required = [
78
+ str(column.get("name"))
79
+ for column in columns
80
+ if isinstance(column.get("tests"), list) and "not_null" in column["tests"]
81
+ ]
82
+ variants = [str(column.get("name")) for column in columns if column.get("type") == "variant"]
83
+ variant_shapes = _variant_shape_fields(spec_config)
84
+ records = sample_records.get("records")
85
+ record_count = len(records) if isinstance(records, list) else 0
86
+ file_rules = spec_config.get("file_rules")
87
+ file_format = file_rules.get("file_format") if isinstance(file_rules, dict) else None
88
+ file_type = file_format.get("file_type") if isinstance(file_format, dict) else "unknown"
89
+
90
+ lines = [
91
+ f"workspace: {workspace}",
92
+ f"spec: {core.get('spec_name', 'unknown')} ({core.get('spec_alias', 'no alias')})",
93
+ f"owner_role: {core.get('owner_role', 'unknown')}",
94
+ f"columns: {len(columns)} total, {len(required)} required, {len(variants)} variant",
95
+ "required_fields: " + (", ".join(required) if required else "none"),
96
+ "variant_fields: "
97
+ + (
98
+ ", ".join(
99
+ f"{name}{' shaped' if name in variant_shapes else ' unshaped'}" for name in variants
100
+ )
101
+ if variants
102
+ else "none"
103
+ ),
104
+ f"sample_records: {record_count}",
105
+ f"file_type: {file_type}",
106
+ f"attachments: {_attachment_summary(spec_config)}",
107
+ f"guest_access: {_guest_access_summary(spec_config)}",
108
+ "notes: "
109
+ + "; ".join(
110
+ _text_status(workspace, filename)
111
+ for filename in ("brief.md", "decisions.md", "questions.md", "review.md")
112
+ ),
113
+ f"check: {len(result.errors)} error(s), {len(result.warnings)} warning(s)",
114
+ ]
115
+ return "\n".join(lines)