@reunionstudio/airlock-mcp 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/airlock-mcp/SKILL.md +137 -5
- package/.agents/skills/airlock-mcp/agents/openai.yaml +2 -2
- package/README.md +76 -15
- package/docs/architecture.md +21 -10
- package/docs/install-surface.md +46 -9
- package/docs/ooda-loop.md +11 -0
- package/docs/spec-workbench-architecture.md +17 -5
- package/docs/workflows.md +138 -17
- package/package.json +1 -1
- package/setup.py +1 -1
- package/src/airlock_mcp/__init__.py +1 -1
- package/src/airlock_mcp/app_context.py +271 -0
- package/src/airlock_mcp/art.py +1 -1
- package/src/airlock_mcp/bootstrap.py +74 -6
- package/src/airlock_mcp/cli.py +33 -1
- package/src/airlock_mcp/summary.py +215 -23
- package/src/mcp.mjs +5 -5
- package/src/text.mjs +86 -21
- package/src/workbench.mjs +64 -1
package/src/airlock_mcp/cli.py
CHANGED
|
@@ -5,6 +5,7 @@ import sys
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from . import __version__
|
|
8
|
+
from .app_context import APP_CONTEXT_MODES, format_app_context_result, init_app_context
|
|
8
9
|
from .art import about_text
|
|
9
10
|
from .bootstrap import bootstrap_repo, format_bootstrap_result
|
|
10
11
|
from .jsonio import read_json
|
|
@@ -64,6 +65,17 @@ def init_repo(args: argparse.Namespace) -> int:
|
|
|
64
65
|
return 0
|
|
65
66
|
|
|
66
67
|
|
|
68
|
+
def init_app(args: argparse.Namespace) -> int:
|
|
69
|
+
result = init_app_context(
|
|
70
|
+
Path(args.path),
|
|
71
|
+
mode=args.mode,
|
|
72
|
+
spec_sources=[Path(source) for source in args.spec],
|
|
73
|
+
force=args.force,
|
|
74
|
+
)
|
|
75
|
+
print(format_app_context_result(result))
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
67
79
|
def init_workspace(args: argparse.Namespace) -> int:
|
|
68
80
|
patterns = load_patterns(repo_root())
|
|
69
81
|
pattern = patterns[args.pattern]
|
|
@@ -303,11 +315,31 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
303
315
|
about_parser = subparsers.add_parser("about", help="Show the Airlock MCP mark and command map.")
|
|
304
316
|
about_parser.set_defaults(func=about)
|
|
305
317
|
|
|
306
|
-
init_repo_parser = subparsers.add_parser("init-repo", help="Prepare a specs repo for Codex and Airlock MCP.")
|
|
318
|
+
init_repo_parser = subparsers.add_parser("init-repo", help="Prepare a Git-backed specs repo for Codex and Airlock MCP.")
|
|
307
319
|
init_repo_parser.add_argument("path", nargs="?", default=".", help="Specs repo path. Defaults to current directory.")
|
|
308
320
|
init_repo_parser.add_argument("--force", action="store_true", help="Overwrite AGENTS.md and the repo-scoped skill.")
|
|
309
321
|
init_repo_parser.set_defaults(func=init_repo)
|
|
310
322
|
|
|
323
|
+
init_app_parser = subparsers.add_parser(
|
|
324
|
+
"init-app-context",
|
|
325
|
+
help="Prepare an app repo airlock/ context with spec snapshots and a manifest.",
|
|
326
|
+
)
|
|
327
|
+
init_app_parser.add_argument("path", nargs="?", default=".", help="App repo path. Defaults to current directory.")
|
|
328
|
+
init_app_parser.add_argument(
|
|
329
|
+
"--mode",
|
|
330
|
+
choices=APP_CONTEXT_MODES,
|
|
331
|
+
default="app-first",
|
|
332
|
+
help="Development mode. Defaults to app-first.",
|
|
333
|
+
)
|
|
334
|
+
init_app_parser.add_argument(
|
|
335
|
+
"--spec",
|
|
336
|
+
action="append",
|
|
337
|
+
default=[],
|
|
338
|
+
help="Spec workspace directory or spec JSON file to snapshot. Repeat for multiple specs.",
|
|
339
|
+
)
|
|
340
|
+
init_app_parser.add_argument("--force", action="store_true", help="Overwrite app context files.")
|
|
341
|
+
init_app_parser.set_defaults(func=init_app)
|
|
342
|
+
|
|
311
343
|
init_parser = subparsers.add_parser("init", help="Create a spec workspace.")
|
|
312
344
|
init_parser.add_argument("name", help="Workspace folder name.")
|
|
313
345
|
init_parser.add_argument(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
@@ -30,6 +31,40 @@ def _variant_shape_fields(spec_config: dict[str, Any]) -> set[str]:
|
|
|
30
31
|
return fields
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def _format_value(value: Any, *, default: str = "unknown") -> str:
|
|
35
|
+
if value is None:
|
|
36
|
+
return default
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
return "true" if value else "false"
|
|
39
|
+
if isinstance(value, (dict, list)):
|
|
40
|
+
text = json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
41
|
+
else:
|
|
42
|
+
text = str(value)
|
|
43
|
+
return text if text else default
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_literal(value: Any, *, default: str = "unknown") -> str:
|
|
47
|
+
if value is None:
|
|
48
|
+
return default
|
|
49
|
+
if isinstance(value, bool):
|
|
50
|
+
return "true" if value else "false"
|
|
51
|
+
if isinstance(value, str):
|
|
52
|
+
return json.dumps(value)
|
|
53
|
+
return str(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _join(values: list[str], *, default: str = "none") -> str:
|
|
57
|
+
cleaned = [value for value in values if value]
|
|
58
|
+
return ", ".join(cleaned) if cleaned else default
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _truncate(value: Any, limit: int = 96) -> str:
|
|
62
|
+
text = _format_value(value, default="")
|
|
63
|
+
if len(text) <= limit:
|
|
64
|
+
return text
|
|
65
|
+
return text[: limit - 3] + "..."
|
|
66
|
+
|
|
67
|
+
|
|
33
68
|
def _guest_access_summary(spec_config: dict[str, Any]) -> str:
|
|
34
69
|
guest_access = spec_config.get("guest_access")
|
|
35
70
|
if not isinstance(guest_access, dict):
|
|
@@ -50,6 +85,34 @@ def _guest_access_summary(spec_config: dict[str, Any]) -> str:
|
|
|
50
85
|
return "shared public folder: " + (", ".join(enabled) if enabled else "no enabled subfolders")
|
|
51
86
|
|
|
52
87
|
|
|
88
|
+
def _enabled_guest_subfolders(guest_access: dict[str, Any]) -> list[str]:
|
|
89
|
+
public_folder = guest_access.get("public_folder")
|
|
90
|
+
if not isinstance(public_folder, dict):
|
|
91
|
+
return []
|
|
92
|
+
subfolders = public_folder.get("subfolders")
|
|
93
|
+
if not isinstance(subfolders, dict):
|
|
94
|
+
return []
|
|
95
|
+
return [
|
|
96
|
+
str(name)
|
|
97
|
+
for name, value in subfolders.items()
|
|
98
|
+
if isinstance(value, dict) and value.get("enabled") is True
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _guest_role_lines(guest_access: dict[str, Any]) -> list[str]:
|
|
103
|
+
roles = guest_access.get("guest_roles")
|
|
104
|
+
if not isinstance(roles, list):
|
|
105
|
+
return []
|
|
106
|
+
lines: list[str] = []
|
|
107
|
+
for role in roles:
|
|
108
|
+
if not isinstance(role, dict):
|
|
109
|
+
continue
|
|
110
|
+
role_name = _format_value(role.get("role_name"))
|
|
111
|
+
access_level = _format_value(role.get("access_level"))
|
|
112
|
+
lines.append(f"{role_name} -> {access_level}")
|
|
113
|
+
return lines
|
|
114
|
+
|
|
115
|
+
|
|
53
116
|
def _attachment_summary(spec_config: dict[str, Any]) -> str:
|
|
54
117
|
policy = spec_config.get("attachment_policy")
|
|
55
118
|
if not isinstance(policy, dict):
|
|
@@ -59,6 +122,86 @@ def _attachment_summary(spec_config: dict[str, Any]) -> str:
|
|
|
59
122
|
return "required" if policy.get("attachment_required") is True else "optional"
|
|
60
123
|
|
|
61
124
|
|
|
125
|
+
def _column_tests(column: dict[str, Any]) -> list[str]:
|
|
126
|
+
tests = column.get("tests")
|
|
127
|
+
return [str(test) for test in tests if isinstance(test, str)] if isinstance(tests, list) else []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _column_line(column: dict[str, Any], variant_shapes: set[str]) -> str:
|
|
131
|
+
name = _format_value(column.get("name"))
|
|
132
|
+
column_type = _format_value(column.get("type"))
|
|
133
|
+
tests = _column_tests(column)
|
|
134
|
+
attributes: list[str] = []
|
|
135
|
+
if "not_null" in tests:
|
|
136
|
+
attributes.append("required")
|
|
137
|
+
if "unique" in tests:
|
|
138
|
+
attributes.append("unique")
|
|
139
|
+
extra_tests = [test for test in tests if test not in {"not_null", "unique"}]
|
|
140
|
+
if extra_tests:
|
|
141
|
+
attributes.append("tests: " + ", ".join(extra_tests))
|
|
142
|
+
if column.get("type") == "variant":
|
|
143
|
+
attributes.append("variant shaped" if name in variant_shapes else "variant unshaped")
|
|
144
|
+
if column.get("format"):
|
|
145
|
+
attributes.append("format " + _format_value(column.get("format")))
|
|
146
|
+
description = column.get("description")
|
|
147
|
+
suffix = f" - {_truncate(description, 140)}" if isinstance(description, str) and description else ""
|
|
148
|
+
return f" - {name}: {column_type}; {_join(attributes)}{suffix}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _variant_rule_lines(spec_config: dict[str, Any]) -> list[str]:
|
|
152
|
+
rules = spec_config.get("rules")
|
|
153
|
+
if not isinstance(rules, list):
|
|
154
|
+
return []
|
|
155
|
+
lines: list[str] = []
|
|
156
|
+
for rule in rules:
|
|
157
|
+
if not isinstance(rule, dict) or rule.get("type") != "variant_shape":
|
|
158
|
+
continue
|
|
159
|
+
field = rule.get("field", rule.get("column"))
|
|
160
|
+
if not isinstance(field, str) or not field:
|
|
161
|
+
field = "unknown"
|
|
162
|
+
roots = rule.get("allowed_root_keys")
|
|
163
|
+
root_values = [str(value) for value in roots if isinstance(value, str)] if isinstance(roots, list) else []
|
|
164
|
+
paths = rule.get("paths")
|
|
165
|
+
path_count = len(paths) if isinstance(paths, list) else 0
|
|
166
|
+
required_count = (
|
|
167
|
+
len([path for path in paths if isinstance(path, dict) and path.get("required") is True])
|
|
168
|
+
if isinstance(paths, list)
|
|
169
|
+
else 0
|
|
170
|
+
)
|
|
171
|
+
lines.append(
|
|
172
|
+
f" - {field}: roots {_join(root_values)}; paths {path_count}; required_paths {required_count}"
|
|
173
|
+
)
|
|
174
|
+
return lines
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ordered_record_fields(columns: list[dict[str, Any]], record: dict[str, Any]) -> list[str]:
|
|
178
|
+
ordered = [
|
|
179
|
+
str(column.get("name"))
|
|
180
|
+
for column in columns
|
|
181
|
+
if isinstance(column.get("name"), str) and column.get("name") in record
|
|
182
|
+
]
|
|
183
|
+
extras = sorted(str(key) for key in record if str(key) not in ordered)
|
|
184
|
+
return ordered + extras
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _sample_key(columns: list[dict[str, Any]], records: list[Any]) -> str:
|
|
188
|
+
if not records or not isinstance(records[0], dict):
|
|
189
|
+
return "none"
|
|
190
|
+
first = records[0]
|
|
191
|
+
preferred: list[str] = []
|
|
192
|
+
for test_name in ("unique", "not_null"):
|
|
193
|
+
preferred.extend(
|
|
194
|
+
str(column.get("name"))
|
|
195
|
+
for column in columns
|
|
196
|
+
if isinstance(column.get("name"), str) and test_name in _column_tests(column)
|
|
197
|
+
)
|
|
198
|
+
preferred.extend(str(column.get("name")) for column in columns if isinstance(column.get("name"), str))
|
|
199
|
+
for field in preferred:
|
|
200
|
+
if field in first and not isinstance(first[field], (dict, list)):
|
|
201
|
+
return f"{field}={_truncate(first[field])}"
|
|
202
|
+
return "none"
|
|
203
|
+
|
|
204
|
+
|
|
62
205
|
def _text_status(workspace: Path, filename: str) -> str:
|
|
63
206
|
path = workspace / filename
|
|
64
207
|
if not path.exists():
|
|
@@ -82,34 +225,83 @@ def workspace_summary(
|
|
|
82
225
|
variants = [str(column.get("name")) for column in columns if column.get("type") == "variant"]
|
|
83
226
|
variant_shapes = _variant_shape_fields(spec_config)
|
|
84
227
|
records = sample_records.get("records")
|
|
85
|
-
|
|
228
|
+
record_list = records if isinstance(records, list) else []
|
|
229
|
+
record_count = len(record_list)
|
|
230
|
+
first_record = record_list[0] if record_list and isinstance(record_list[0], dict) else {}
|
|
86
231
|
file_rules = spec_config.get("file_rules")
|
|
87
232
|
file_format = file_rules.get("file_format") if isinstance(file_rules, dict) else None
|
|
88
|
-
|
|
233
|
+
file_format = file_format if isinstance(file_format, dict) else {}
|
|
234
|
+
guest_access = spec_config.get("guest_access")
|
|
235
|
+
guest_access = guest_access if isinstance(guest_access, dict) else {}
|
|
236
|
+
attachment_policy = spec_config.get("attachment_policy")
|
|
237
|
+
attachment_policy = attachment_policy if isinstance(attachment_policy, dict) else {}
|
|
238
|
+
variant_rule_lines = _variant_rule_lines(spec_config)
|
|
89
239
|
|
|
90
240
|
lines = [
|
|
91
241
|
f"workspace: {workspace}",
|
|
92
242
|
f"spec: {core.get('spec_name', 'unknown')} ({core.get('spec_alias', 'no alias')})",
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
),
|
|
104
|
-
f"
|
|
105
|
-
f"
|
|
106
|
-
f"
|
|
107
|
-
f"
|
|
108
|
-
"
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
),
|
|
113
|
-
f"
|
|
243
|
+
"",
|
|
244
|
+
"core:",
|
|
245
|
+
f" spec_name: {_format_value(core.get('spec_name'))}",
|
|
246
|
+
f" spec_alias: {_format_value(core.get('spec_alias'), default='none')}",
|
|
247
|
+
f" owner_role: {_format_value(core.get('owner_role'))}",
|
|
248
|
+
f" published: {_format_value(core.get('is_published'))}",
|
|
249
|
+
f" archived: {_format_value(core.get('is_archived'))}",
|
|
250
|
+
f" description: {_truncate(core.get('description')) or 'none'}",
|
|
251
|
+
"",
|
|
252
|
+
"file_rules:",
|
|
253
|
+
f" file_type: {_format_value(file_format.get('file_type'))}",
|
|
254
|
+
f" parse_header: {_format_value(file_format.get('parse_header'))}",
|
|
255
|
+
f" save_header: {_format_value(file_format.get('save_header'))}",
|
|
256
|
+
f" field_delimiter: {_format_literal(file_format.get('field_delimiter'))}",
|
|
257
|
+
f" record_delimiter: {_format_literal(file_format.get('record_delimiter'))}",
|
|
258
|
+
f" encoding: {_format_value(file_format.get('encoding'))}",
|
|
259
|
+
"",
|
|
260
|
+
"attachments:",
|
|
261
|
+
f" summary: {_attachment_summary(spec_config)}",
|
|
262
|
+
f" enabled: {_format_value(attachment_policy.get('attachments_enabled'))}",
|
|
263
|
+
f" required: {_format_value(attachment_policy.get('attachment_required'))}",
|
|
264
|
+
"",
|
|
265
|
+
"guest_access:",
|
|
266
|
+
f" summary: {_guest_access_summary(spec_config)}",
|
|
267
|
+
f" isolated_directories_enabled: {_format_value(guest_access.get('isolated_directories_enabled'))}",
|
|
268
|
+
f" public_folder_enabled: {_format_value((guest_access.get('public_folder') or {}).get('enabled') if isinstance(guest_access.get('public_folder'), dict) else None)}",
|
|
269
|
+
f" enabled_subfolders: {_join(_enabled_guest_subfolders(guest_access))}",
|
|
270
|
+
f" guest_roles: {_join(_guest_role_lines(guest_access))}",
|
|
271
|
+
"",
|
|
272
|
+
"column_rules:",
|
|
273
|
+
f" total: {len(columns)}",
|
|
274
|
+
f" required_fields: {_join(required)}",
|
|
275
|
+
f" variant_fields: {_join([f'{name} shaped' if name in variant_shapes else f'{name} unshaped' for name in variants])}",
|
|
276
|
+
" columns:",
|
|
114
277
|
]
|
|
278
|
+
lines.extend(_column_line(column, variant_shapes) for column in columns)
|
|
279
|
+
lines.extend(
|
|
280
|
+
[
|
|
281
|
+
" variant_shape_rules:",
|
|
282
|
+
*(variant_rule_lines if variant_rule_lines else [" - none"]),
|
|
283
|
+
"",
|
|
284
|
+
"samples:",
|
|
285
|
+
f" spec_name: {_format_value(sample_records.get('spec_name'))}",
|
|
286
|
+
f" filename: {_format_value(sample_records.get('filename'))}",
|
|
287
|
+
f" records: {record_count}",
|
|
288
|
+
f" first_record_key: {_sample_key(columns, record_list)}",
|
|
289
|
+
f" first_record_fields: {_join(_ordered_record_fields(columns, first_record))}",
|
|
290
|
+
"",
|
|
291
|
+
"notes:",
|
|
292
|
+
*(
|
|
293
|
+
f" {_text_status(workspace, filename)}"
|
|
294
|
+
for filename in ("brief.md", "decisions.md", "questions.md", "review.md")
|
|
295
|
+
),
|
|
296
|
+
"",
|
|
297
|
+
"check:",
|
|
298
|
+
f" errors: {len(result.errors)}",
|
|
299
|
+
f" warnings: {len(result.warnings)}",
|
|
300
|
+
]
|
|
301
|
+
)
|
|
302
|
+
if result.findings:
|
|
303
|
+
lines.append(" findings:")
|
|
304
|
+
lines.extend(
|
|
305
|
+
f" - {finding.level}: {finding.path}: {finding.message}" for finding in result.findings
|
|
306
|
+
)
|
|
115
307
|
return "\n".join(lines)
|
package/src/mcp.mjs
CHANGED
|
@@ -7,7 +7,7 @@ const GETTING_STARTED_URI = "airlock://getting-started";
|
|
|
7
7
|
|
|
8
8
|
const START_TOOL = {
|
|
9
9
|
name: "airlock_start",
|
|
10
|
-
description: "Return Airlock MCP setup guidance for
|
|
10
|
+
description: "Return Airlock MCP setup guidance for specs and apps that use specs.",
|
|
11
11
|
inputSchema: {
|
|
12
12
|
type: "object",
|
|
13
13
|
properties: {
|
|
@@ -44,10 +44,10 @@ export function handleMcpRequest(message) {
|
|
|
44
44
|
},
|
|
45
45
|
serverInfo: {
|
|
46
46
|
name: "airlock",
|
|
47
|
-
version: "0.1.
|
|
47
|
+
version: "0.1.3",
|
|
48
48
|
},
|
|
49
49
|
instructions:
|
|
50
|
-
"Airlock MCP helps agents improve processes with Airlock specs. Use airlock_start for orientation or the airlock_* tools to bootstrap, draft, check, summarize, export, and render specs.",
|
|
50
|
+
"Airlock MCP helps agents improve processes with Airlock specs and build apps or workflows that use existing specs. Use airlock_start for orientation or the airlock_* tools to bootstrap, draft, check, summarize, export, and render specs.",
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -84,7 +84,7 @@ export function handleMcpRequest(message) {
|
|
|
84
84
|
{
|
|
85
85
|
name: "airlock-start",
|
|
86
86
|
title: "Start Airlock",
|
|
87
|
-
description: "Bootstrap a
|
|
87
|
+
description: "Bootstrap a Git-backed specs repo or choose an Airlock app/spec path.",
|
|
88
88
|
arguments: [
|
|
89
89
|
{
|
|
90
90
|
name: "project",
|
|
@@ -103,7 +103,7 @@ export function handleMcpRequest(message) {
|
|
|
103
103
|
}
|
|
104
104
|
const project = params?.arguments?.project || "Home";
|
|
105
105
|
return makeResponse(id, {
|
|
106
|
-
description: "Start building
|
|
106
|
+
description: "Start building specs or apps that use Airlock specs.",
|
|
107
107
|
messages: [
|
|
108
108
|
{
|
|
109
109
|
role: "user",
|
package/src/text.mjs
CHANGED
|
@@ -21,23 +21,52 @@ export function airlockPrompt(project) {
|
|
|
21
21
|
const repoName = specsRepoName(project);
|
|
22
22
|
return `I want to use Airlock MCP to start working with Airlock specs for ${repoName}.
|
|
23
23
|
|
|
24
|
-
Set up this project as an Airlock specs repo.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
Set up this project as an Airlock specs repo. If this project is not already a
|
|
25
|
+
Git repo, recommend storing it in version control, preferably GitHub if that is
|
|
26
|
+
available. If you are creating the repo for me, ask where the ${repoName}
|
|
27
|
+
directory should live before making it, then offer to initialize git and create
|
|
28
|
+
or push a GitHub repo. If I am in an app repo and want to build software that
|
|
29
|
+
uses existing specs, or develop the app and specs together, do not bootstrap a
|
|
30
|
+
specs repo unless I ask for spec edits.
|
|
31
|
+
|
|
32
|
+
Welcome me by asking whether I want spec-first, app-first, or co-development:
|
|
33
|
+
spec-first designs governed specs before the app, app-first builds from existing
|
|
34
|
+
specs, and co-development evolves the app and specs together. For spec-first or
|
|
35
|
+
co-development, ask what process I want to improve. Explain that Airlock works
|
|
36
|
+
best when we can identify the loop around that process: what information comes
|
|
37
|
+
in, what context helps us understand it, what decision needs to be made, and
|
|
38
|
+
what action happens after the decision. Information may come from apps, files,
|
|
39
|
+
forms, people, emails, calls, mail, websites, APIs, data feeds, or physical
|
|
40
|
+
events. Actions may go back through those same places. Ask whether I already
|
|
41
|
+
have artifacts: CSV or Excel files, JSON samples, API docs, schemas, forms,
|
|
42
|
+
screenshots, PDFs, exports, message examples, or other content people already
|
|
43
|
+
use. Treat a small real sample as stronger evidence than a long explanation,
|
|
44
|
+
and remind me to redact secrets.
|
|
45
|
+
|
|
46
|
+
When useful, check the reusable airlock-specs library for starting points,
|
|
47
|
+
patterns, and ideas, but do not assume those library specs match current
|
|
48
|
+
third-party systems. Prefer current API docs, real exports, samples, and other
|
|
49
|
+
artifacts when they conflict with a library shape.
|
|
50
|
+
|
|
51
|
+
Ask for the messy version, then help turn it into a small first Airlock spec
|
|
52
|
+
and a plan for more. Do not create the first workspace until I choose a path.
|
|
53
|
+
|
|
54
|
+
If I want app-first or co-development, ask for the app goal, the specs I can
|
|
55
|
+
access, which specs are read sources, which spec records decisions or actions,
|
|
56
|
+
and where the app should run. Offer to run \`airlock-mcp init-app-context\` in
|
|
57
|
+
the app repo to seed \`airlock/specs.manifest.json\`, spec snapshots, sample
|
|
58
|
+
records, and generated helper folders. Help code the app using approved
|
|
59
|
+
Airlock/Snowflake access paths. Do not write directly to Airlock-owned tables
|
|
60
|
+
or bypass spec workflow.`;
|
|
33
61
|
}
|
|
34
62
|
|
|
35
63
|
export function nextSteps(project) {
|
|
36
64
|
const repoName = specsRepoName(project);
|
|
37
65
|
return `Next:
|
|
38
66
|
1. Open Codex.
|
|
39
|
-
2. Create a
|
|
40
|
-
3.
|
|
67
|
+
2. Create or open a Git-backed specs repo named ${repoName}. GitHub is the recommended default when available.
|
|
68
|
+
3. If Codex is creating it, ask Codex where the ${repoName} directory should live before it makes files.
|
|
69
|
+
4. Ask Codex:
|
|
41
70
|
|
|
42
71
|
${airlockPrompt(project)
|
|
43
72
|
.split("\n")
|
|
@@ -46,11 +75,16 @@ ${airlockPrompt(project)
|
|
|
46
75
|
|
|
47
76
|
Airlock MCP will offer:
|
|
48
77
|
- process discovery before choosing a spec pattern
|
|
78
|
+
- spec-first, app-first, and co-development planning
|
|
49
79
|
- spec design with the bundled workbench
|
|
50
80
|
- Airlock operating patterns for OODA loops and separation of duties
|
|
81
|
+
- app context seeding with spec snapshots and manifests
|
|
82
|
+
- app and workflow coding against existing Airlock specs
|
|
51
83
|
- observe specs for controlled interface ingestion
|
|
52
84
|
- orient specs for proposals, context, scoring, or exception queues
|
|
53
|
-
- decision specs and action specs for governed follow-through
|
|
85
|
+
- decision specs and action specs for governed follow-through
|
|
86
|
+
- artifact-grounded drafts from CSV, Excel, JSON, API docs, schemas, forms, screenshots, PDFs, or exports
|
|
87
|
+
- airlock-specs library patterns as starting points, checked against current artifacts`;
|
|
54
88
|
}
|
|
55
89
|
|
|
56
90
|
export function gettingStartedText(project) {
|
|
@@ -58,26 +92,37 @@ export function gettingStartedText(project) {
|
|
|
58
92
|
|
|
59
93
|
Airlock MCP is the single installed interface for AI agents working with
|
|
60
94
|
Airlock. It helps a person and their agent improve processes by designing
|
|
61
|
-
specs, using specs for governed data movement,
|
|
62
|
-
|
|
95
|
+
specs, using specs for governed data movement, planning OODA loops, and building
|
|
96
|
+
apps or workflows that read from and submit through existing specs.
|
|
63
97
|
|
|
64
|
-
Airlock MCP gives agents
|
|
98
|
+
Airlock MCP gives agents three kinds of Airlock help:
|
|
65
99
|
|
|
66
100
|
1. Spec design: draft, check, revise, import, clone, and prepare specs for
|
|
67
101
|
installed Airlock validation.
|
|
68
102
|
2. Airlock operating patterns: use specs to organize observations, orientation,
|
|
69
103
|
governed decisions, controlled actions, separation of duties, and feedback
|
|
70
104
|
loops.
|
|
105
|
+
3. App and workflow implementation: build dashboards, queues, decision UIs,
|
|
106
|
+
analyses, and agent workflows that use existing specs without bypassing
|
|
107
|
+
Airlock contracts.
|
|
71
108
|
|
|
72
|
-
Start in a
|
|
73
|
-
|
|
74
|
-
|
|
109
|
+
Start in a Git-backed specs repo such as ${specsRepoName(project)}. GitHub is
|
|
110
|
+
the recommended default when the user has it set up, but any normal repository
|
|
111
|
+
works. If Codex is creating the repo, ask where the directory should live before
|
|
112
|
+
making files. Do not work inside the Airlock MCP implementation repo unless you
|
|
113
|
+
are changing the tools themselves.
|
|
75
114
|
|
|
76
|
-
Use this prompt in the
|
|
115
|
+
Use this prompt in the specs repo:
|
|
77
116
|
|
|
78
117
|
${airlockPrompt(project)}
|
|
79
118
|
|
|
80
|
-
First ask
|
|
119
|
+
First ask which delivery mode the user wants:
|
|
120
|
+
|
|
121
|
+
1. Spec-first: design governed specs before building the app surface.
|
|
122
|
+
2. App-first: build an app or workflow from existing specs.
|
|
123
|
+
3. Co-development: develop the app and specs together.
|
|
124
|
+
|
|
125
|
+
For spec-first and co-development work, ask: What process do you want to improve?
|
|
81
126
|
|
|
82
127
|
Airlock works best when we can identify the loop around a process:
|
|
83
128
|
|
|
@@ -91,8 +136,26 @@ websites, APIs, data feeds, or physical events. Actions may go back through
|
|
|
91
136
|
those same places. Airlock calls these places interfaces: where the process
|
|
92
137
|
observes from or acts through.
|
|
93
138
|
|
|
139
|
+
If you already have artifacts, attach or point Codex at them: CSV or Excel
|
|
140
|
+
files, JSON samples, API docs, schemas, forms, screenshots, PDFs, exports,
|
|
141
|
+
message examples, or other defined content people already use. A small real
|
|
142
|
+
sample is often better than a long explanation. Redact secrets before sharing.
|
|
143
|
+
|
|
144
|
+
Airlock MCP can also use the reusable airlock-specs library for starting
|
|
145
|
+
points, patterns, and ideas. Library specs are not guaranteed to match the
|
|
146
|
+
current shape of any third-party system. Prefer current API docs, real exports,
|
|
147
|
+
samples, and other artifacts when they conflict with the library.
|
|
148
|
+
|
|
94
149
|
Give Codex the messy version of the process. Airlock MCP should help turn it
|
|
95
|
-
into a small first Airlock spec and a plan for more
|
|
150
|
+
into a small first Airlock spec and a plan for more.
|
|
151
|
+
|
|
152
|
+
For app-first and co-development work, give Codex the app goal and available
|
|
153
|
+
specs. Airlock MCP should identify read specs, write specs, orienting views,
|
|
154
|
+
decision capture, and safe Airlock/Snowflake access paths. It can seed an app
|
|
155
|
+
repo with \`airlock/specs.manifest.json\`, spec snapshots, sample records, and
|
|
156
|
+
generated helper folders. The app should submit decisions, approvals, actions,
|
|
157
|
+
comments, or follow-ups through Airlock spec contracts, not direct table writes.
|
|
158
|
+
In co-development mode, keep the spec track and app track visible side by side.`;
|
|
96
159
|
}
|
|
97
160
|
|
|
98
161
|
export function helpText() {
|
|
@@ -117,5 +180,7 @@ Airlock MCP is the single installed interface for agents working with Airlock.
|
|
|
117
180
|
Spec building is bundled inside that experience.
|
|
118
181
|
Airlock operating patterns help connect specs into OODA loops, separation of
|
|
119
182
|
duties, governed decisions, controlled actions, and feedback loops.
|
|
183
|
+
Airlock MCP can also help build apps and workflows that use existing specs for
|
|
184
|
+
approved reads and governed submissions.
|
|
120
185
|
`;
|
|
121
186
|
}
|
package/src/workbench.mjs
CHANGED
|
@@ -23,6 +23,8 @@ const workspaceProperty = {
|
|
|
23
23
|
maxLength: MAX_ARG_LENGTH,
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
const appModeValues = ["spec-first", "app-first", "co-development"];
|
|
27
|
+
|
|
26
28
|
export const WORKBENCH_TOOLS = [
|
|
27
29
|
{
|
|
28
30
|
name: "airlock_doctor",
|
|
@@ -31,7 +33,7 @@ export const WORKBENCH_TOOLS = [
|
|
|
31
33
|
},
|
|
32
34
|
{
|
|
33
35
|
name: "airlock_init_repo",
|
|
34
|
-
description: "Bootstrap a specs repo with AGENTS.md, the Airlock MCP skill, and workspaces/.",
|
|
36
|
+
description: "Bootstrap a Git-backed specs repo with AGENTS.md, the Airlock MCP skill, and workspaces/.",
|
|
35
37
|
inputSchema: objectSchema({
|
|
36
38
|
path: {
|
|
37
39
|
type: "string",
|
|
@@ -42,6 +44,32 @@ export const WORKBENCH_TOOLS = [
|
|
|
42
44
|
cwd: cwdProperty,
|
|
43
45
|
}),
|
|
44
46
|
},
|
|
47
|
+
{
|
|
48
|
+
name: "airlock_init_app_context",
|
|
49
|
+
description: "Seed an app repo with airlock/ spec snapshots, samples, generated placeholders, and a manifest.",
|
|
50
|
+
inputSchema: objectSchema({
|
|
51
|
+
path: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "App repo path to initialize. Defaults to the current directory.",
|
|
54
|
+
maxLength: MAX_ARG_LENGTH,
|
|
55
|
+
},
|
|
56
|
+
mode: {
|
|
57
|
+
type: "string",
|
|
58
|
+
enum: appModeValues,
|
|
59
|
+
description: "Development mode. Defaults to app-first.",
|
|
60
|
+
},
|
|
61
|
+
specs: {
|
|
62
|
+
type: "array",
|
|
63
|
+
items: {
|
|
64
|
+
type: "string",
|
|
65
|
+
maxLength: MAX_ARG_LENGTH,
|
|
66
|
+
},
|
|
67
|
+
description: "Spec workspace directories or spec JSON files to snapshot.",
|
|
68
|
+
},
|
|
69
|
+
force: forceProperty,
|
|
70
|
+
cwd: cwdProperty,
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
45
73
|
{
|
|
46
74
|
name: "airlock_list_patterns",
|
|
47
75
|
description: "List bundled Airlock spec starter patterns.",
|
|
@@ -230,6 +258,28 @@ function optionalEnum(args, name, allowed, fallback) {
|
|
|
230
258
|
return value;
|
|
231
259
|
}
|
|
232
260
|
|
|
261
|
+
function optionalStringArray(args, name) {
|
|
262
|
+
const value = args?.[name];
|
|
263
|
+
if (value === undefined || value === null) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
if (!Array.isArray(value)) {
|
|
267
|
+
throw new Error(`${name} must be an array`);
|
|
268
|
+
}
|
|
269
|
+
return value.map((entry, index) => {
|
|
270
|
+
if (typeof entry !== "string") {
|
|
271
|
+
throw new Error(`${name}[${index}] must be a string`);
|
|
272
|
+
}
|
|
273
|
+
if (entry.length > MAX_ARG_LENGTH || /[\u0000-\u001f\u007f]/.test(entry)) {
|
|
274
|
+
throw new Error(`${name}[${index}] must be ${MAX_ARG_LENGTH} characters or fewer with no control characters`);
|
|
275
|
+
}
|
|
276
|
+
if (entry.startsWith("-")) {
|
|
277
|
+
throw new Error(`${name}[${index}] must not start with '-'`);
|
|
278
|
+
}
|
|
279
|
+
return entry;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
233
283
|
function requiredEnum(args, name, allowed) {
|
|
234
284
|
const value = optionalEnum(args, name, allowed, undefined);
|
|
235
285
|
if (value === undefined) {
|
|
@@ -264,6 +314,19 @@ function cliArgsForTool(name, args = {}) {
|
|
|
264
314
|
if (force) cliArgs.push("--force");
|
|
265
315
|
return { cwd, cliArgs };
|
|
266
316
|
}
|
|
317
|
+
case "airlock_init_app_context": {
|
|
318
|
+
const cliArgs = [
|
|
319
|
+
"init-app-context",
|
|
320
|
+
optionalString(args, "path", "."),
|
|
321
|
+
"--mode",
|
|
322
|
+
optionalEnum(args, "mode", appModeValues, "app-first"),
|
|
323
|
+
];
|
|
324
|
+
for (const spec of optionalStringArray(args, "specs")) {
|
|
325
|
+
cliArgs.push("--spec", spec);
|
|
326
|
+
}
|
|
327
|
+
if (force) cliArgs.push("--force");
|
|
328
|
+
return { cwd, cliArgs };
|
|
329
|
+
}
|
|
267
330
|
case "airlock_list_patterns":
|
|
268
331
|
return { cwd, cliArgs: ["list-patterns"] };
|
|
269
332
|
case "airlock_show_pattern": {
|