@reunionstudio/airlock-mcp 0.1.2 → 0.1.4
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 +134 -14
- package/.agents/skills/airlock-mcp/agents/openai.yaml +2 -2
- package/README.md +93 -11
- package/docs/architecture.md +17 -6
- package/docs/install-surface.md +45 -5
- package/docs/spec-workbench-architecture.md +14 -3
- package/docs/spec-workspace.md +5 -0
- package/docs/workflows.md +127 -3
- package/package.json +1 -1
- package/patterns/manifest.json +6 -0
- package/patterns/okf-knowledge-bundle/README.md +64 -0
- package/patterns/okf-knowledge-bundle/sample.records.json +16 -0
- package/patterns/okf-knowledge-bundle/spec.config.json +64 -0
- package/setup.py +1 -1
- package/src/airlock_mcp/__init__.py +1 -1
- package/src/airlock_mcp/app_context.py +303 -0
- package/src/airlock_mcp/art.py +1 -1
- package/src/airlock_mcp/bootstrap.py +65 -4
- package/src/airlock_mcp/cli.py +32 -0
- package/src/airlock_mcp/summary.py +1 -0
- package/src/mcp.mjs +5 -5
- package/src/text.mjs +85 -16
- package/src/workbench.mjs +65 -2
package/docs/workflows.md
CHANGED
|
@@ -87,9 +87,13 @@ The MCP-style flow should be:
|
|
|
87
87
|
4. If Codex is creating it, choose where the repo directory should live before
|
|
88
88
|
files are written.
|
|
89
89
|
5. Tell Codex: `Use Airlock to help me improve a process with specs.`
|
|
90
|
-
6. Airlock MCP welcomes
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
6. Airlock MCP welcomes and asks which delivery mode the user wants:
|
|
91
|
+
spec-first, app-first from existing specs, or co-development.
|
|
92
|
+
7. For spec-first or co-development, Airlock MCP asks what process the user
|
|
93
|
+
wants to improve, explains the loop around the process, and proposes a small
|
|
94
|
+
first spec plus a plan for more.
|
|
95
|
+
8. For app-first or co-development, Airlock MCP identifies the app goal,
|
|
96
|
+
available specs, read specs, write specs, runtime, and approved access path.
|
|
93
97
|
|
|
94
98
|
`init-repo` creates `AGENTS.md`, `.agents/skills/airlock-mcp/SKILL.md`, and
|
|
95
99
|
`workspaces/`. That is what lets Codex understand prompts such as
|
|
@@ -130,6 +134,104 @@ current artifacts and record the divergence.
|
|
|
130
134
|
Ask for the messy version. Help turn it into a small first Airlock spec and a
|
|
131
135
|
plan for more.
|
|
132
136
|
|
|
137
|
+
## Build An App From Existing Specs
|
|
138
|
+
|
|
139
|
+
Use this when the user already has access to specs and wants to build an app,
|
|
140
|
+
dashboard, approval queue, decision UI, analysis workflow, scheduled agent, or
|
|
141
|
+
other code that reads from and submits through Airlock.
|
|
142
|
+
|
|
143
|
+
Do not bootstrap or edit a specs workspace by default. Work in the app repo
|
|
144
|
+
unless the user asks to change specs. Start by asking for:
|
|
145
|
+
|
|
146
|
+
- the app goal and decision the app should support
|
|
147
|
+
- the specs the user can access
|
|
148
|
+
- read specs, such as budgets, expenses, requests, forecasts, observations,
|
|
149
|
+
payouts, or reference data
|
|
150
|
+
- write specs, such as decisions, approvals, comments, commitments, actions, or
|
|
151
|
+
follow-ups
|
|
152
|
+
- where the app should run, such as Streamlit, web app, CLI, notebook,
|
|
153
|
+
scheduled agent, or an existing framework
|
|
154
|
+
- the available Airlock/Snowflake access path
|
|
155
|
+
- identity, evidence, timestamp, approval, and separation-of-duties needs
|
|
156
|
+
|
|
157
|
+
Use the installed Airlock procedure grammar:
|
|
158
|
+
|
|
159
|
+
- `airlock.observe.*` is read-only and should answer discovery, governance,
|
|
160
|
+
audit, health, activity, billing-event, and access-explanation questions.
|
|
161
|
+
- `airlock.agent.*` is for governed work by the current actor, including
|
|
162
|
+
validation, loading, workflow actions, attachments, references, and
|
|
163
|
+
delegations.
|
|
164
|
+
- `airlock.admin.*` is for administrative mutation and operational changes.
|
|
165
|
+
|
|
166
|
+
For app-first work, start with observe payloads such as `observe.procedures`,
|
|
167
|
+
`observe.specs`, `observe.spec`, `observe.governance_map`,
|
|
168
|
+
`observe.explain_access`, `observe.health`, `observe.activity`,
|
|
169
|
+
`observe.admin_activity`, `observe.spec_admin_activity`, `observe.billing`, and
|
|
170
|
+
`observe.billing_events`. For `alter_spec` activity, use `CHANGED_SECTIONS` and
|
|
171
|
+
`CHANGED_FIELDS` to triage what changed before fetching version snapshots. Do not use retired admin read
|
|
172
|
+
wrappers such as `admin.list_specs`, `admin.describe_role`, `admin.get_spec`,
|
|
173
|
+
or `admin.list_events`; use observe list/detail procedures instead.
|
|
174
|
+
|
|
175
|
+
The app should follow the loop:
|
|
176
|
+
|
|
177
|
+
1. Observe/read governed records through approved Airlock or Snowflake surfaces.
|
|
178
|
+
2. Orient with summaries, comparisons, ranked options, recommendations,
|
|
179
|
+
exception queues, dashboards, or proposals.
|
|
180
|
+
3. Decide by capturing the human or agent choice with rationale and evidence.
|
|
181
|
+
4. Act/write by submitting the decision, approval, action, comment, or follow-up
|
|
182
|
+
through an Airlock spec contract.
|
|
183
|
+
|
|
184
|
+
Keep the boundary explicit: do not write directly to Airlock-owned tables,
|
|
185
|
+
stages, generated views, or generated tables. Do not bypass spec workflow just
|
|
186
|
+
because the app can connect to Snowflake. If no suitable decision or action spec
|
|
187
|
+
exists, explain the gap and propose a small spec-design step.
|
|
188
|
+
|
|
189
|
+
If the app repo needs local context for coding, seed it with spec snapshots and
|
|
190
|
+
a manifest:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
airlock-mcp init-app-context . --mode app-first --spec ../home-specs/workspaces/expenses
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Use `--mode co-development` when the app and specs will evolve together. Repeat
|
|
197
|
+
`--spec` for each workspace or spec JSON file the app should know about. The
|
|
198
|
+
command creates:
|
|
199
|
+
|
|
200
|
+
```text
|
|
201
|
+
airlock/
|
|
202
|
+
AGENTS.md
|
|
203
|
+
README.md
|
|
204
|
+
specs.manifest.json
|
|
205
|
+
spec-snapshots/
|
|
206
|
+
sample-records/
|
|
207
|
+
generated/
|
|
208
|
+
types/
|
|
209
|
+
sql/
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The app repo can import or generate helper code from these snapshots, but the
|
|
213
|
+
snapshots are not canonical. Canonical specs remain in the specs repo or
|
|
214
|
+
installed Airlock. Mark each manifest spec as read, write, or read_write for
|
|
215
|
+
the app before building data access and submission flows.
|
|
216
|
+
|
|
217
|
+
## Co-Develop Specs And App
|
|
218
|
+
|
|
219
|
+
Use this when the app surface and the Airlock contracts need to shape each
|
|
220
|
+
other. This is common when a dashboard, queue, approval flow, or agent workflow
|
|
221
|
+
reveals which observations, proposals, decisions, or actions need governed
|
|
222
|
+
records.
|
|
223
|
+
|
|
224
|
+
Keep two tracks visible:
|
|
225
|
+
|
|
226
|
+
- Spec track: row grain, columns, samples, attachments, access, validation,
|
|
227
|
+
workflow, and installed Airlock validation.
|
|
228
|
+
- App track: screens, reads, summaries, ranked options, decision capture,
|
|
229
|
+
governed writes, runtime, and tests.
|
|
230
|
+
|
|
231
|
+
Start with the smallest useful contract. The app may expose a thin prototype
|
|
232
|
+
against samples while the spec is being drafted, but live submissions should
|
|
233
|
+
wait for installed Airlock validation and the approved read/write path.
|
|
234
|
+
|
|
133
235
|
## Start From Feedback
|
|
134
236
|
|
|
135
237
|
Use this when the user chooses a shared feedback loop for humans and agents.
|
|
@@ -144,6 +246,28 @@ airlock-mcp check workspaces/feedback-loop
|
|
|
144
246
|
Then ask Codex to help decide what the posts should observe, who responds, and
|
|
145
247
|
what next spec should grow from the feedback.
|
|
146
248
|
|
|
249
|
+
## Start From Governed Knowledge
|
|
250
|
+
|
|
251
|
+
Use this when the user wants accepted Markdown business context for people or
|
|
252
|
+
agents: policies, runbooks, metric definitions, investigations, decisions, or
|
|
253
|
+
operating notes.
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
airlock-mcp init sales-context --pattern okf-knowledge-bundle
|
|
257
|
+
airlock-mcp check workspaces/sales-context
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
This pattern sets `core_config.payload_adapter` to `okf_knowledge_bundle`.
|
|
261
|
+
Installed Airlock loads locally validated bundles with
|
|
262
|
+
`airlock.admin.load_okf_bundle(...)`, can refresh parsed metadata with
|
|
263
|
+
`airlock.admin.sync_okf_bundle_metadata(...)`, and exposes authoritative
|
|
264
|
+
accepted concept metadata through
|
|
265
|
+
`AIRLOCK_DATA.ACTIVE.V_OKF_CONCEPT_METADATA`.
|
|
266
|
+
|
|
267
|
+
Do not treat draft or rejected Markdown bundles as authoritative agent context.
|
|
268
|
+
The MCP local checker validates the spec workspace; bundle validation belongs
|
|
269
|
+
to Airlock's OKF validator and installed procedures.
|
|
270
|
+
|
|
147
271
|
## Start From A Known Process
|
|
148
272
|
|
|
149
273
|
Use this when the process is known but the data shape is not.
|
package/package.json
CHANGED
package/patterns/manifest.json
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
"title": "Posts feedback loop",
|
|
12
12
|
"summary": "Small governed feedback stream for discovering what to build next.",
|
|
13
13
|
"directory": "starter-posts"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "okf-knowledge-bundle",
|
|
17
|
+
"title": "OKF knowledge bundle",
|
|
18
|
+
"summary": "Governed Markdown knowledge bundles for accepted agent context.",
|
|
19
|
+
"directory": "okf-knowledge-bundle"
|
|
14
20
|
}
|
|
15
21
|
]
|
|
16
22
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Starter Pattern: OKF Knowledge Bundle
|
|
2
|
+
|
|
3
|
+
Use `okf-knowledge-bundle` when the team wants governed Markdown knowledge for
|
|
4
|
+
people and agents to use as accepted business context.
|
|
5
|
+
|
|
6
|
+
This pattern is for Airlock's `okf_knowledge_bundle` payload adapter. It is
|
|
7
|
+
inspired by Google's Open Knowledge Format, but Airlock applies a narrower
|
|
8
|
+
profile so accepted bundles can become governed context instead of loose wiki
|
|
9
|
+
content.
|
|
10
|
+
|
|
11
|
+
## Use When
|
|
12
|
+
|
|
13
|
+
- Policies, runbooks, metrics, definitions, decisions, investigations, or
|
|
14
|
+
operating context need a governed place to land.
|
|
15
|
+
- Agents need business context that can be accepted, audited, and queried from
|
|
16
|
+
Snowflake.
|
|
17
|
+
- The team has useful Markdown knowledge, but needs Airlock workflow, PDP,
|
|
18
|
+
Expectations, evidence, and acceptance boundaries around it.
|
|
19
|
+
|
|
20
|
+
## Bundle Shape
|
|
21
|
+
|
|
22
|
+
- Bundle source is a directory or `.zip` archive of UTF-8 Markdown files.
|
|
23
|
+
- Root `index.md` may contain bundle frontmatter such as `okf_version`,
|
|
24
|
+
`bundle_name`, `bundle_version`, `domain`, `owner`, and `purpose`.
|
|
25
|
+
- Every non-reserved Markdown file is a concept document with YAML frontmatter.
|
|
26
|
+
- Concept documents require `type`.
|
|
27
|
+
- Recommended concept fields are `title`, `description`, `resource`, `tags`,
|
|
28
|
+
`timestamp`, and `source_links`.
|
|
29
|
+
- `index.md` and `log.md` are reserved at every hierarchy level.
|
|
30
|
+
|
|
31
|
+
## Airlock Contract
|
|
32
|
+
|
|
33
|
+
The spec must declare:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"core_config": {
|
|
38
|
+
"payload_adapter": "okf_knowledge_bundle"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Installed Airlock provides two admin procedures for this adapter:
|
|
44
|
+
|
|
45
|
+
- `airlock.admin.load_okf_bundle(...)`: load a locally validated bundle into
|
|
46
|
+
Airlock stage storage and `FILE_MANIFEST`, then project concept metadata.
|
|
47
|
+
- `airlock.admin.sync_okf_bundle_metadata(...)`: project parsed concept
|
|
48
|
+
metadata for an existing active manifest row.
|
|
49
|
+
|
|
50
|
+
Use `validate_only => TRUE` before writing. Treat accepted context as
|
|
51
|
+
authoritative only from:
|
|
52
|
+
|
|
53
|
+
```sql
|
|
54
|
+
AIRLOCK_DATA.ACTIVE.V_OKF_CONCEPT_METADATA
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Draft and rejected bundles are not authoritative agent context.
|
|
58
|
+
|
|
59
|
+
## Local Workbench Notes
|
|
60
|
+
|
|
61
|
+
`airlock-mcp check` validates the Airlock spec draft and sample metadata. It
|
|
62
|
+
does not validate a Markdown bundle. Bundle validation currently lives in the
|
|
63
|
+
Airlock source repo's `airlock.okf_knowledge_bundle.validate_okf_bundle`
|
|
64
|
+
helper and the installed Airlock OKF procedures.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"spec_name": "okf_knowledge",
|
|
3
|
+
"filename": "okf_knowledge_sample.csv",
|
|
4
|
+
"records": [
|
|
5
|
+
{
|
|
6
|
+
"concept_path": "runbooks/monthly-close",
|
|
7
|
+
"concept_type": "Runbook",
|
|
8
|
+
"bundle_version": "2026.06"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"concept_path": "metrics/net-revenue",
|
|
12
|
+
"concept_type": "Metric",
|
|
13
|
+
"bundle_version": "2026.06"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"core_config": {
|
|
3
|
+
"spec_name": "okf_knowledge",
|
|
4
|
+
"spec_alias": "OKF Knowledge",
|
|
5
|
+
"description": "Governed OKF-style Markdown knowledge bundles for accepted agent context.",
|
|
6
|
+
"owner_role": "app_admin",
|
|
7
|
+
"current_mode": "append",
|
|
8
|
+
"is_published": false,
|
|
9
|
+
"is_locked": false,
|
|
10
|
+
"is_archived": false,
|
|
11
|
+
"is_multi_role": false,
|
|
12
|
+
"file_workflow": false,
|
|
13
|
+
"workflow_steps": [],
|
|
14
|
+
"materialize_as_table": false,
|
|
15
|
+
"payload_adapter": "okf_knowledge_bundle"
|
|
16
|
+
},
|
|
17
|
+
"column_config": [
|
|
18
|
+
{
|
|
19
|
+
"name": "concept_path",
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "Stable normalized Markdown concept path without the .md suffix.",
|
|
22
|
+
"tests": ["not_null", "unique"]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "concept_type",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Concept type from OKF frontmatter.",
|
|
28
|
+
"tests": ["not_null"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "bundle_version",
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Bundle version containing the concept.",
|
|
34
|
+
"tests": ["not_null"]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"rules": [],
|
|
38
|
+
"file_rules": {
|
|
39
|
+
"file_format": {
|
|
40
|
+
"file_type": "csv",
|
|
41
|
+
"record_delimiter": "\n",
|
|
42
|
+
"field_delimiter": ",",
|
|
43
|
+
"field_optionally_enclosed_by": "\"",
|
|
44
|
+
"escape_unenclosed_field": "\\",
|
|
45
|
+
"encoding": "UTF8",
|
|
46
|
+
"parse_header": true,
|
|
47
|
+
"save_header": true
|
|
48
|
+
},
|
|
49
|
+
"filename_rules": {
|
|
50
|
+
"enforce_filename_pattern": false,
|
|
51
|
+
"filename_pattern": null,
|
|
52
|
+
"sample_filenames": null
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"sample_data": {
|
|
56
|
+
"sample_data_acknowledged": true,
|
|
57
|
+
"sample_rows": [],
|
|
58
|
+
"edited_sample_data": "concept_path,concept_type,bundle_version\nrunbooks/monthly-close,Runbook,2026.06\nmetrics/net-revenue,Metric,2026.06\n"
|
|
59
|
+
},
|
|
60
|
+
"attachment_policy": {
|
|
61
|
+
"attachments_enabled": true,
|
|
62
|
+
"attachment_required": false
|
|
63
|
+
}
|
|
64
|
+
}
|
package/setup.py
CHANGED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .jsonio import read_json, write_json, write_text
|
|
9
|
+
from .specs import extract_spec_config, spec_name
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
APP_CONTEXT_MODES = ("spec-first", "app-first", "co-development")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
APP_README = """# Airlock App Context
|
|
16
|
+
|
|
17
|
+
This folder is app-local context for building software that uses Airlock specs.
|
|
18
|
+
|
|
19
|
+
The canonical specs live in the specs repo or installed Airlock. Files here are
|
|
20
|
+
snapshots, samples, generated helpers, and planning context for app development.
|
|
21
|
+
Refresh them when the canonical spec changes.
|
|
22
|
+
|
|
23
|
+
Use:
|
|
24
|
+
|
|
25
|
+
- `specs.manifest.json` to track which specs the app reads from or writes to.
|
|
26
|
+
- `spec-snapshots/` for app-local copies of spec configs.
|
|
27
|
+
- `sample-records/` for app-local sample records used in UI and test fixtures.
|
|
28
|
+
- `generated/types/` for generated application types.
|
|
29
|
+
- `generated/sql/` for reviewed SQL helpers or query templates.
|
|
30
|
+
|
|
31
|
+
Installed Airlock separates procedure intent:
|
|
32
|
+
|
|
33
|
+
- `observe.*`: read-only governance observation for discovery, health, access
|
|
34
|
+
explanation, activity, admin activity, billing events, governance maps, and
|
|
35
|
+
context packets.
|
|
36
|
+
- `agent.*`: governed agent work in the actor's scope.
|
|
37
|
+
- `admin.*`: administrative mutation and operational changes.
|
|
38
|
+
|
|
39
|
+
Prefer `observe.*` for app read-side setup and monitoring questions. Use
|
|
40
|
+
`observe.admin_activity` for broad admin mutation and maintenance audit
|
|
41
|
+
questions, and `observe.spec_admin_activity` for one spec's definition-change
|
|
42
|
+
timeline. For `alter_spec` activity, use `CHANGED_SECTIONS` and
|
|
43
|
+
`CHANGED_FIELDS` to triage what changed before fetching version snapshots.
|
|
44
|
+
Do not use retired admin read wrappers such as `admin.list_specs`,
|
|
45
|
+
`admin.describe_role`, or `admin.list_events`; use the matching observe procedure.
|
|
46
|
+
|
|
47
|
+
Do not store credentials here. Do not write directly to Airlock-owned tables,
|
|
48
|
+
stages, generated views, or generated tables. Use approved Airlock/Snowflake
|
|
49
|
+
access paths and submit governed decisions or actions through spec contracts.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
APP_AGENTS = """# Airlock App Guidance
|
|
54
|
+
|
|
55
|
+
This repo may contain application code that uses Airlock specs.
|
|
56
|
+
|
|
57
|
+
## Modes
|
|
58
|
+
|
|
59
|
+
- Spec-first: design governed specs before building the app surface.
|
|
60
|
+
- App-first: use existing specs to build an app, dashboard, queue, or workflow.
|
|
61
|
+
- Co-development: evolve the app and specs together while keeping the contract
|
|
62
|
+
explicit.
|
|
63
|
+
|
|
64
|
+
## App Context
|
|
65
|
+
|
|
66
|
+
The `airlock/` folder is app-local. Its spec snapshots are references for
|
|
67
|
+
development, not canonical specs. Canonical specs live in the specs repo or
|
|
68
|
+
installed Airlock.
|
|
69
|
+
|
|
70
|
+
Keep two tracks visible during co-development:
|
|
71
|
+
|
|
72
|
+
- Spec track: row grain, columns, samples, access, validation, and workflow.
|
|
73
|
+
- App track: screens, reads, decisions, writes, user actions, and runtime.
|
|
74
|
+
|
|
75
|
+
Use approved Airlock/Snowflake access paths. Do not bypass spec workflow or
|
|
76
|
+
write directly to Airlock-owned tables, stages, generated views, or generated
|
|
77
|
+
tables. If the app needs to submit a decision or action and no suitable write
|
|
78
|
+
spec exists, propose a small spec-design step.
|
|
79
|
+
|
|
80
|
+
Installed Airlock procedure grammar:
|
|
81
|
+
|
|
82
|
+
- `observe.*` is read-only governance observation. Use it for discovery,
|
|
83
|
+
health, access explanation, governance maps, activity, billing events, and
|
|
84
|
+
context packets.
|
|
85
|
+
- `agent.*` is governed agent work in the actor's scope.
|
|
86
|
+
- `admin.*` is administrative mutation. Do not use retired admin read wrappers;
|
|
87
|
+
use observe list/detail procedures instead.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class AppContextResult:
|
|
93
|
+
root: Path
|
|
94
|
+
mode: str
|
|
95
|
+
created: tuple[Path, ...]
|
|
96
|
+
updated: tuple[Path, ...]
|
|
97
|
+
kept: tuple[Path, ...]
|
|
98
|
+
specs: tuple[str, ...]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _safe_name(value: str) -> str:
|
|
102
|
+
cleaned = re.sub(r"[^a-zA-Z0-9_.-]+", "_", value.strip())
|
|
103
|
+
cleaned = cleaned.strip("._-").lower()
|
|
104
|
+
return cleaned or "spec"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _write_text_if_needed(path: Path, text: str, *, force: bool) -> str:
|
|
108
|
+
if path.exists() and not force:
|
|
109
|
+
return "kept"
|
|
110
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
write_text(path, text, force=True)
|
|
112
|
+
return "created"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _write_json_if_needed(path: Path, value: Any, *, force: bool) -> str:
|
|
116
|
+
if path.exists() and not force:
|
|
117
|
+
return "kept"
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
write_json(path, value, force=True)
|
|
120
|
+
return "created"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_spec_source(source: Path) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
124
|
+
if source.is_dir():
|
|
125
|
+
raw_spec = read_json(source / "spec.config.json")
|
|
126
|
+
if not isinstance(raw_spec, dict):
|
|
127
|
+
raise ValueError(f"invalid spec workspace: {source}")
|
|
128
|
+
raw_sample = read_json(source / "sample.records.json")
|
|
129
|
+
return raw_spec, raw_sample if isinstance(raw_sample, dict) else None
|
|
130
|
+
|
|
131
|
+
raw = read_json(source)
|
|
132
|
+
if raw is None:
|
|
133
|
+
raise FileNotFoundError(source)
|
|
134
|
+
return extract_spec_config(raw), None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _manifest(mode: str, entries: list[dict[str, Any]]) -> dict[str, Any]:
|
|
138
|
+
return {
|
|
139
|
+
"schema_version": 1,
|
|
140
|
+
"mode": mode,
|
|
141
|
+
"canonical_source": "specs repo or installed Airlock",
|
|
142
|
+
"snapshot_policy": "Snapshots are app-local development references, not canonical specs.",
|
|
143
|
+
"installed_airlock_contract": {
|
|
144
|
+
"observe": "read-only governance observation, context, activity, health, billing events, and access explanation",
|
|
145
|
+
"agent": "governed agent work in the actor scope",
|
|
146
|
+
"admin": "administrative mutation and operational changes",
|
|
147
|
+
},
|
|
148
|
+
"specs": entries,
|
|
149
|
+
"tracks": {
|
|
150
|
+
"spec_track": "row grain, columns, samples, access, validation, workflow",
|
|
151
|
+
"app_track": "screens, reads, decisions, writes, user actions, runtime",
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _merge_manifest(existing: Any, new_manifest: dict[str, Any]) -> dict[str, Any]:
|
|
157
|
+
if not isinstance(existing, dict):
|
|
158
|
+
return new_manifest
|
|
159
|
+
|
|
160
|
+
merged = dict(existing)
|
|
161
|
+
merged.update(
|
|
162
|
+
{
|
|
163
|
+
"schema_version": new_manifest["schema_version"],
|
|
164
|
+
"mode": new_manifest["mode"],
|
|
165
|
+
"canonical_source": new_manifest["canonical_source"],
|
|
166
|
+
"snapshot_policy": new_manifest["snapshot_policy"],
|
|
167
|
+
"installed_airlock_contract": new_manifest["installed_airlock_contract"],
|
|
168
|
+
"tracks": new_manifest["tracks"],
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
existing_entries = existing.get("specs")
|
|
173
|
+
by_name: dict[str, dict[str, Any]] = {}
|
|
174
|
+
if isinstance(existing_entries, list):
|
|
175
|
+
for entry in existing_entries:
|
|
176
|
+
if isinstance(entry, dict) and isinstance(entry.get("spec_name"), str):
|
|
177
|
+
by_name[entry["spec_name"]] = dict(entry)
|
|
178
|
+
|
|
179
|
+
for entry in new_manifest["specs"]:
|
|
180
|
+
previous = by_name.get(entry["spec_name"], {})
|
|
181
|
+
merged_entry = {**previous, **entry}
|
|
182
|
+
if previous.get("role") and entry.get("role") == "unknown":
|
|
183
|
+
merged_entry["role"] = previous["role"]
|
|
184
|
+
by_name[entry["spec_name"]] = merged_entry
|
|
185
|
+
|
|
186
|
+
merged["specs"] = list(by_name.values())
|
|
187
|
+
return merged
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def init_app_context(
|
|
191
|
+
target: Path,
|
|
192
|
+
*,
|
|
193
|
+
mode: str = "app-first",
|
|
194
|
+
spec_sources: list[Path] | None = None,
|
|
195
|
+
force: bool = False,
|
|
196
|
+
) -> AppContextResult:
|
|
197
|
+
if mode not in APP_CONTEXT_MODES:
|
|
198
|
+
raise ValueError(f"mode must be one of: {', '.join(APP_CONTEXT_MODES)}")
|
|
199
|
+
|
|
200
|
+
root = target.expanduser().resolve()
|
|
201
|
+
if root.exists() and not root.is_dir():
|
|
202
|
+
raise NotADirectoryError(str(root))
|
|
203
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
|
|
205
|
+
airlock_root = root / "airlock"
|
|
206
|
+
created: list[Path] = []
|
|
207
|
+
updated: list[Path] = []
|
|
208
|
+
kept: list[Path] = []
|
|
209
|
+
|
|
210
|
+
for directory in (
|
|
211
|
+
airlock_root,
|
|
212
|
+
airlock_root / "spec-snapshots",
|
|
213
|
+
airlock_root / "sample-records",
|
|
214
|
+
airlock_root / "generated",
|
|
215
|
+
airlock_root / "generated" / "types",
|
|
216
|
+
airlock_root / "generated" / "sql",
|
|
217
|
+
):
|
|
218
|
+
if directory.exists():
|
|
219
|
+
kept.append(directory)
|
|
220
|
+
else:
|
|
221
|
+
directory.mkdir(parents=True)
|
|
222
|
+
created.append(directory)
|
|
223
|
+
|
|
224
|
+
entries: list[dict[str, Any]] = []
|
|
225
|
+
spec_names: list[str] = []
|
|
226
|
+
for source in spec_sources or []:
|
|
227
|
+
spec_config, sample_records = _load_spec_source(source.expanduser())
|
|
228
|
+
name = spec_name(spec_config)
|
|
229
|
+
safe = _safe_name(name)
|
|
230
|
+
snapshot_path = airlock_root / "spec-snapshots" / f"{safe}.spec.config.json"
|
|
231
|
+
bucket = created if _write_json_if_needed(snapshot_path, spec_config, force=force) == "created" else kept
|
|
232
|
+
bucket.append(snapshot_path)
|
|
233
|
+
|
|
234
|
+
sample_relative: str | None = None
|
|
235
|
+
if sample_records is not None:
|
|
236
|
+
sample_path = airlock_root / "sample-records" / f"{safe}.sample.records.json"
|
|
237
|
+
bucket = created if _write_json_if_needed(sample_path, sample_records, force=force) == "created" else kept
|
|
238
|
+
bucket.append(sample_path)
|
|
239
|
+
sample_relative = str(sample_path.relative_to(root))
|
|
240
|
+
|
|
241
|
+
core = spec_config.get("core_config")
|
|
242
|
+
core = core if isinstance(core, dict) else {}
|
|
243
|
+
entries.append(
|
|
244
|
+
{
|
|
245
|
+
"spec_name": name,
|
|
246
|
+
"spec_alias": core.get("spec_alias"),
|
|
247
|
+
"source": str(source),
|
|
248
|
+
"snapshot": str(snapshot_path.relative_to(root)),
|
|
249
|
+
"sample_records": sample_relative,
|
|
250
|
+
"role": "unknown",
|
|
251
|
+
"snapshot_only": True,
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
spec_names.append(name)
|
|
255
|
+
|
|
256
|
+
for path, content in (
|
|
257
|
+
(airlock_root / "README.md", APP_README),
|
|
258
|
+
(airlock_root / "AGENTS.md", APP_AGENTS),
|
|
259
|
+
(airlock_root / "generated" / "types" / ".gitkeep", ""),
|
|
260
|
+
(airlock_root / "generated" / "sql" / ".gitkeep", ""),
|
|
261
|
+
):
|
|
262
|
+
bucket = created if _write_text_if_needed(path, content, force=force) == "created" else kept
|
|
263
|
+
bucket.append(path)
|
|
264
|
+
|
|
265
|
+
manifest_path = airlock_root / "specs.manifest.json"
|
|
266
|
+
manifest = _manifest(mode, entries)
|
|
267
|
+
if manifest_path.exists() and not force:
|
|
268
|
+
write_json(manifest_path, _merge_manifest(read_json(manifest_path), manifest), force=True)
|
|
269
|
+
updated.append(manifest_path)
|
|
270
|
+
else:
|
|
271
|
+
bucket = created if _write_json_if_needed(manifest_path, manifest, force=force) == "created" else kept
|
|
272
|
+
bucket.append(manifest_path)
|
|
273
|
+
|
|
274
|
+
return AppContextResult(
|
|
275
|
+
root=airlock_root,
|
|
276
|
+
mode=mode,
|
|
277
|
+
created=tuple(created),
|
|
278
|
+
updated=tuple(updated),
|
|
279
|
+
kept=tuple(kept),
|
|
280
|
+
specs=tuple(spec_names),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def format_app_context_result(result: AppContextResult) -> str:
|
|
285
|
+
lines = [f"initialized app context {result.root}", f"mode: {result.mode}"]
|
|
286
|
+
for path in result.created:
|
|
287
|
+
lines.append(f"created {path.relative_to(result.root.parent)}")
|
|
288
|
+
for path in result.updated:
|
|
289
|
+
lines.append(f"updated {path.relative_to(result.root.parent)}")
|
|
290
|
+
for path in result.kept:
|
|
291
|
+
lines.append(f"kept {path.relative_to(result.root.parent)}")
|
|
292
|
+
lines.append("seeded_specs: " + (", ".join(result.specs) if result.specs else "none"))
|
|
293
|
+
lines.extend(
|
|
294
|
+
[
|
|
295
|
+
"",
|
|
296
|
+
"next:",
|
|
297
|
+
"1. Treat spec snapshots as app-local references, not canonical specs.",
|
|
298
|
+
"2. Mark each manifest spec as read, write, or read_write for the app.",
|
|
299
|
+
"3. Use observe.* for read-only governance discovery and agent.* for governed actor work.",
|
|
300
|
+
"4. Build governed submissions through approved Airlock/Snowflake access paths.",
|
|
301
|
+
]
|
|
302
|
+
)
|
|
303
|
+
return "\n".join(lines)
|
package/src/airlock_mcp/art.py
CHANGED
|
@@ -21,6 +21,6 @@ def about_text() -> str:
|
|
|
21
21
|
return f"""{MCP_MARK.rstrip()}
|
|
22
22
|
|
|
23
23
|
version: {__version__}
|
|
24
|
-
commands: init-repo, init, import-spec, clone, rename, archive, restore, list-workspaces, summary, next, check, export-csv, render-sql, self-update
|
|
24
|
+
commands: init-repo, init-app-context, init, import-spec, clone, rename, archive, restore, list-workspaces, summary, next, check, export-csv, render-sql, self-update
|
|
25
25
|
principle: Codex is the conversation; files are memory; Airlock is authority.
|
|
26
26
|
"""
|