@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.
@@ -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
- record_count = len(records) if isinstance(records, list) else 0
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
- file_type = file_format.get("file_type") if isinstance(file_format, dict) else "unknown"
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
- 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)",
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 building and using Airlock specs.",
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.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 blank specs repo and choose the first Airlock path.",
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 and using Airlock specs in a blank specs repo.",
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. Welcome me by asking what
25
- process I want to improve. Explain that Airlock works best when we can
26
- identify the loop around that process: what information comes in, what context
27
- helps us understand it, what decision needs to be made, and what action happens
28
- after the decision. Information may come from apps, files, forms, people,
29
- emails, calls, mail, websites, APIs, data feeds, or physical events. Actions
30
- may go back through those same places. Ask for the messy version, then help
31
- turn it into a small first Airlock spec and a plan for more. Do not create the
32
- first workspace until I choose a path.`;
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 new blank project named ${repoName}.
40
- 3. Ask Codex:
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, and planning OODA loops that
62
- can be assisted by people or agents.
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 two kinds of Airlock expertise:
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 blank project specs repo such as ${specsRepoName(project)}. Do not
73
- work inside the Airlock MCP implementation repo unless you are changing the
74
- tools themselves.
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 blank specs repo:
115
+ Use this prompt in the specs repo:
77
116
 
78
117
  ${airlockPrompt(project)}
79
118
 
80
- First ask: What process do you want to improve?
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": {