darkprint 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.
@@ -0,0 +1,300 @@
1
+ # The DOT, and what a card becomes when it is compiled
2
+
3
+ Three things live here: how to write `topology.dot` so it loads clean, how a fork is
4
+ guarded so a runner takes the arm the author meant, and what happens to a card when the
5
+ bundle is compiled into a pipeline a runner takes. **This skill compiles nothing**, and no
6
+ published bundle folder carries a compiled graph. The third part is background, and an
7
+ author is entitled to know what their `spec` turns into and which parts of their card never
8
+ leave DarkPrint.
9
+
10
+ ---
11
+
12
+ ## Part 1: writing `topology.dot`
13
+
14
+ ### The shape
15
+
16
+ ```dot
17
+ digraph help_centre_line {
18
+ rankdir=LR;
19
+ node [shape=box, style=rounded];
20
+
21
+ // One statement per node. The id is the instance; the card is the definition.
22
+ outline_writer [card="article-outliner@1.0.0"];
23
+ draft_writer [card="article-drafter@1.0.0"];
24
+
25
+ // Edges carry data. Pin the ports whenever more than one pair would fit.
26
+ outline_writer -> editorial_check [label="acceptance criteria", out="criteria", in="criteria"];
27
+ }
28
+ ```
29
+
30
+ ### Node ids
31
+
32
+ `[A-Za-z_][A-Za-z0-9_]*`. No hyphens, no leading digit, never quoted.
33
+
34
+ - A hyphen or a quoted id is `attractor/bad-node-id` or `attractor/quoted-node-id` (warnings,
35
+ visible on `/upload`, and they make the bundle look broken).
36
+ - `digraph`, `edge`, `graph`, `node`, `strict`, `subgraph` are statement keywords and cannot
37
+ open a node statement. The same warning, and the same fix: rename the node.
38
+ - `start`, `Start`, `exit`, `end` are legal here and **no diagnostic warns about them**.
39
+ Attractor resolves those names as the pipeline's entry and exit, so they collide with the
40
+ boundary nodes DarkPrint synthesises when it compiles; the exporter renames such a node
41
+ and records the author's id in a `dp_node` attribute. Avoid them, because the compiled
42
+ file then no longer says what the topology says.
43
+
44
+ The card id grammar is the other one: `^(?:namespace/)?[a-z0-9]+(-[a-z0-9]+)*$`. Lowercase,
45
+ hyphens, no underscores. **The two grammars are incompatible for every multi-word name**, so
46
+ always write the pin explicitly:
47
+
48
+ ```dot
49
+ editorial_check [card="editorial-check@1.0.0"];
50
+ ```
51
+
52
+ `card="id@version"` is canonical and always wins. The bare `version="1.0.0"` fallback makes the
53
+ node id double as the card id, which only works for a single lowercase word. Do not use it.
54
+ An unpinned pointer (`card="solver"`, `card="solver@latest"`, `version="1.x"`) is
55
+ `bundle/unpinned-card`, an **error**.
56
+
57
+ ### Attribute syntax
58
+
59
+ Attributes are **comma**-separated. `[a=1; b=2]` and `[a=1 b=2]` are both
60
+ `attractor/attr-separator`.
61
+
62
+ Comments are `//` or `/* … */`. A `#` comment is `attractor/hash-comment`.
63
+
64
+ No string concatenation (`"a" + "b"`), no `<html-like>` literals, and a duration is an integer
65
+ followed by one of `ms s m h d`. Anything else is `attractor/unsupported-value`.
66
+
67
+ ### Never write `type=` on a node
68
+
69
+ `type` is a **reserved Attractor node attribute** and means *handler override*. DarkPrint's
70
+ node type is an ontology term inside the YAML card and never a DOT attribute. Writing it is
71
+ `attractor/reserved-attribute`, and the value would be read as something entirely different
72
+ from what you meant.
73
+
74
+ **That is the only reserved node attribute the linter reports.** The rest of Attractor's
75
+ reserved node names (`prompt`, `max_retries`, `llm_model`, `label`, `shape`, `class`,
76
+ `timeout`, `goal_gate`, `fidelity`, `thread_id`, `retry_target`, `fallback_retry_target`,
77
+ `llm_provider`, `reasoning_effort`, `auto_status`, `allow_partial`) pass through the linter
78
+ in silence and are written straight into the compiled file, where they configure the run.
79
+ A `prompt=` on a topology node replaces the card's `spec` at run time with nothing saying
80
+ so. Grep your own DOT for them before you call it finished. The bundle's own node
81
+ attributes are `card`, and optionally `digest` (a prefix of the card digest, checked as
82
+ `bundle/digest-mismatch`).
83
+
84
+ On an **edge**, `label`, `condition`, `weight`, `fidelity`, `thread_id` and `loop_restart` are
85
+ reserved. `label`, `condition` and `weight` are the three this skill writes deliberately, and
86
+ the next section is about them. `out=` and `in=` are not reserved, which is what lets the
87
+ port pins ride along safely.
88
+
89
+ ### One graph, one file
90
+
91
+ Only the first graph in the file is read (`attractor/multiple-graphs`, `dot/unsupported`). It
92
+ must be a `digraph`, and every edge `->`; a `graph` or a `--` edge is `dot/not-directed`, an
93
+ **error** that stops the pipeline dead. Do not write `strict digraph`.
94
+
95
+ Only **one** `.dot` file goes in the bundle. The upload page classifies files by name: the
96
+ first `.dot` or `.gv` is the topology and a second one is demoted with a note.
97
+
98
+ ### Filenames are load-bearing
99
+
100
+ The upload page, the CLI and the publish route all read roles off filenames, so:
101
+
102
+ - exactly one `topology.dot`;
103
+ - cards under `cards/`, named `<card-id>@<version>.yaml`;
104
+ - `blueprint.yaml` at the root is the manifest (slug, title, summary, description, category,
105
+ tags), and `ontology/extensions.yaml` is the local vocabulary when there is one;
106
+ - **never name a card `blueprint.yaml` or `extensions.yaml`.** A card named either is
107
+ classified as the manifest or the vocabulary, vanishes from the card set, and every node
108
+ pinning it reports `bundle/missing-card`.
109
+
110
+ Nothing in the engine requires a card's filename to match its `id` and `version`; the card's
111
+ own fields are its identity. Matching them is a courtesy to whoever opens the folder, and this
112
+ skill always does it.
113
+
114
+ ---
115
+
116
+ ## Part 2: the guard on a fork
117
+
118
+ A node with two or more outgoing edges is a fork, and a runner takes exactly one arm. Which
119
+ one is decided in a fixed order, from the Attractor spec's edge selection:
120
+
121
+ 1. an edge whose `condition` holds;
122
+ 2. an edge whose `label` matches the label the node's own status asked for
123
+ (`preferred_label`);
124
+ 3. an edge to a node id the status suggested as next;
125
+ 4. the higher `weight`, default 0;
126
+ 5. on a tie, **the target node id that sorts first**.
127
+
128
+ So two bare edges out of one node are a branch decided by the spelling of the node names.
129
+ The shipped starter learned this the hard way: while `tester -> debugger` and
130
+ `tester -> deployer` carried a label and nothing else, `debugger` beat `deployer` on the
131
+ fourth character and the factory repaired forever without releasing. Every fork this skill
132
+ writes carries a `condition` on every arm, or a `weight` the author chose on purpose.
133
+
134
+ ### The grammar
135
+
136
+ ```
137
+ Condition ::= Clause ( '&&' Clause )*
138
+ Clause ::= Key Operator Literal
139
+ Key ::= 'outcome' | 'preferred_label' | 'context.' Path
140
+ Operator ::= '=' | '!='
141
+ ```
142
+
143
+ `outcome` is the value a runner sets from the node's own status on every pass, and it is
144
+ the key to use for a verdict. `preferred_label` is the label the node asked for, when a
145
+ node writes one. `context.<path>` reads a value the run has stored under a dotted path.
146
+
147
+ The literal is compared as text, so `outcome=success`, `outcome!=success` and
148
+ `context.retries=2` all parse. Quote a literal with spaces: `preferred_label="ship it"`.
149
+
150
+ What does **not** parse, and what the linter reports as `attractor/condition-syntax`:
151
+ `||`, `!`, `>`, `<`, `>=`, `<=`, `contains`, `matches`, parentheses, a bare key
152
+ (`tests_pass`), a bare literal (`success`), and the empty string. A runner grades that rule
153
+ as an error and refuses the whole pipeline before the first node runs, so a mistyped guard
154
+ is a blueprint that validates and never executes. Note `condition=""` separately: it is not
155
+ a syntax error to a runner but it is NO condition, so the edge falls back to the order above.
156
+
157
+ ### The shape this skill writes
158
+
159
+ ```dot
160
+ check_step -> fix_step [label="failure evidence", style=dashed, condition="outcome!=success"];
161
+ check_step -> ship_step [label="approved", condition="outcome=success"];
162
+ ```
163
+
164
+ One key against its own negation is the only total split the grammar can make: there is no
165
+ disjunction, so `partial_success`, `retry`, `fail` and a verdict the check could not reach
166
+ all land on the arm that does not ship, and exactly one arm is ever eligible. A `decision`
167
+ node, whose whole job is to route, follows the same rule with `context.<path>` keys when the
168
+ route is not a plain verdict, and every arm still carries a guard.
169
+
170
+ `weight` is for a preference among arms that are all allowed: `weight=10` on the arm to
171
+ take when nothing else decides. It is an integer, and the linter checks nothing about it.
172
+
173
+ Nothing in DarkPrint evaluates a guard. A guarded edge counts exactly as much as an
174
+ unguarded one in every risk reading, because the guard runs on somebody else's machine
175
+ against data the engine never sees. `condition="false"` does not remove an edge from the
176
+ criteria walk.
177
+
178
+ ---
179
+
180
+ ## Part 3: what a card becomes
181
+
182
+ `darkprint export <dir> --attractor` compiles a resolved bundle into an Attractor DOT and
183
+ writes it to stdout. **This skill does not write that file**, and a published bundle folder
184
+ does not carry one: duplicating the emit rules here would let the two drift, and the
185
+ exporter is the one that gets tested against Attractor's parser on every build.
186
+
187
+ It is documented because an author writing a `spec` should know it is the thing that will be
188
+ handed to the agent verbatim.
189
+
190
+ | the card says | the compiled node says |
191
+ |---|---|
192
+ | `spec` | `prompt`, the payload the agent actually receives |
193
+ | `name` | `label` |
194
+ | `model` | `llm_model`, Attractor's reserved model identifier |
195
+ | `params.max_retries` (or `max_iterations`, or `maxIterations`) | `max_retries` |
196
+ | `params.tool_command`, on a `shell-tool` | `tool_command` |
197
+ | `type` and `phases` | `class`, every name prefixed `dp-` |
198
+ | `type: agent` | `shape=box` → the `codergen` handler |
199
+ | `type: validation` | `shape=box` → the `codergen` handler |
200
+ | `type: tool` | `shape=box` → the `codergen` handler |
201
+ | `type: human-gate` | `shape=hexagon` → the `wait.human` handler |
202
+ | `type: human-input` | `shape=hexagon` → the `wait.human` handler |
203
+ | `type: decision` | `shape=diamond` → the `conditional` handler |
204
+ | `type: shell-tool` | `shape=parallelogram` → the `tool` handler |
205
+ | `type: parallel` | `shape=component` → the `parallel` handler |
206
+ | `type: parallel.fan-in` | `shape=tripleoctagon` → the `parallel.fan_in` handler |
207
+ | `type: manager-loop` | `shape=house` → the `stack.manager_loop` handler |
208
+ | edge `label`, `condition`, `weight` | the same three edge attributes, carried verbatim |
209
+ | the manifest's `summary` and `title` | graph `goal` and `label` |
210
+
211
+ A `__start` and a `__exit` node are **synthesised** rather than borrowed from your graph: your
212
+ entry node is an ordinary card node with a prompt to run, and re-shaping it into a boundary
213
+ would mean that prompt never runs. `card="id@version"` rides along in the compiled file
214
+ untouched, because Attractor ignores attributes it does not reserve.
215
+
216
+ ### What the compiled file cannot say
217
+
218
+ The compiled DOT opens with a comment listing every attribute Attractor reads that a
219
+ DarkPrint blueprint has no way to set. That list is derived from the engine rather than
220
+ written by hand, so it is the current answer and not a sentence somebody forgot to update.
221
+ Today it covers goal gates, timeouts, the whole retry policy above `max_retries`, fidelity,
222
+ thread ids, the parallel join policy, the manager-loop controls and the graph-level
223
+ defaults, hooks and model stylesheet. Each one falls back to whatever the runner's own
224
+ default is, and nothing warns anybody: a gate nobody wrote is a gate that never fires. An
225
+ author who needs one adds it to the compiled file by hand, and a later export replaces the
226
+ whole file. The same two lists are printed into every published bundle's `README.md`, under
227
+ "What these files leave to the runner".
228
+
229
+ The other half is what stops at the DarkPrint boundary. A node's `prompt` is everything the
230
+ runner receives from its card, so ports, dependencies, `cannot`, `will_not` and
231
+ `risk_markers` are not enforced by anything in the compiled file. They are enforced by the
232
+ engine, at the moment the bundle is validated, which is a different moment from the moment
233
+ the pipeline runs.
234
+
235
+ Three consequences for how you write a card:
236
+
237
+ 1. **`spec` is the prompt.** It is delivered to an agent that does not see the rest of the
238
+ graph, so it has to be self-sufficient, and it must respect the isolation the topology
239
+ declares. An absent edge with the criteria paraphrased into the prose is a false isolation.
240
+ 2. **`model` is what the node runs on unless somebody edits the file.** DarkPrint writes
241
+ `llm_model` and writes no `model_stylesheet`, so there is no sheet in the compiled graph
242
+ to override it. Absence is still an answer: a card that names no model emits no
243
+ `llm_model` at all, and the node takes whatever the runner supplies.
244
+ 3. **The iteration cap has one home.** Write it as a top-level key of `params`. The security
245
+ analyzer and the exporter read it through the same function, so a cap that reads as
246
+ uncapped would also compile as unbounded. And it is the number Attractor counts, attempts
247
+ after the first, so `max_retries: 2` is three attempts in all.
248
+
249
+ ---
250
+
251
+ ## Part 4: reading a pipeline back
252
+
253
+ `darkprint import <pipeline.dot> --as <handle> --out <dir>` is the other direction. It reads
254
+ an Attractor pipeline and writes a **draft** bundle: a `topology.dot` and one card per node,
255
+ in the layout the upload page and `darkprint validate` both accept. The mapping table above
256
+ is run backwards, from the same table, so the two directions cannot drift apart.
257
+
258
+ | the pipeline says | the card says |
259
+ |---|---|
260
+ | `prompt` | `spec` |
261
+ | `label` | `name` |
262
+ | `llm_model` | `model` |
263
+ | `max_retries` | `params.max_iterations`, the first of the three accepted cap keys, holding the same number |
264
+ | `tool_command` | `params.tool_command`, on a `shell-tool` card |
265
+ | `shape` | `type`, through the table above |
266
+ | `class` | which of a shared shape's two types, and the `phase` list |
267
+ | `card="id@version"` | the card's own `id` and `version` |
268
+
269
+ ### Both flags are required, and neither has a default
270
+
271
+ `--out` because a bundle is a folder and a folder cannot go down a pipe. `--as` because a
272
+ `prompt` is somebody's writing: every synthesised card carries `author` set to the handle you
273
+ give and `provenance` set to `derived:attractor <the pipeline>`, so a compiled card can be
274
+ told from a written one by reading it or by grepping for the marker. Every card comes out at
275
+ version `0.1.0`. It is a draft and it is meant to be edited before anybody publishes it.
276
+
277
+ ### What a round trip keeps, and what it does not
278
+
279
+ `tests/attractor-round-trip.test.ts` runs a corpus of pipelines out and back and asserts that
280
+ every attribute an Attractor runner reads survives unchanged. What it does not keep is
281
+ recorded there too, exactly, rather than left to be discovered:
282
+
283
+ - **Ports, dependencies, `cannot`, `will_not` and `risk_markers` come back empty.** No
284
+ Attractor file has ever carried them, so an import cannot invent them. You write them.
285
+ - **A node with no `prompt` becomes a card with no `spec`.** Attractor allows it; a DarkPrint
286
+ card does not. The import says which node, and the bundle does not resolve until you write
287
+ the missing half.
288
+ - **`shape=box` comes back as `agent` and `shape=hexagon` as `human-gate`** unless the file
289
+ carries the `class` DarkPrint writes. Attractor selects one handler for `agent` and
290
+ `validation` alike, and stores nothing that separates them.
291
+ - **Subgraphs are gone**, and with them the classes derived from a subgraph's label.
292
+ - **Every attribute in the compiled file's own disclosure header is dropped**: goal gates,
293
+ timeouts, the retry policy above `max_retries`, the parallel and manager-loop controls, the
294
+ graph defaults, hooks and the model stylesheet.
295
+ - **An attribute on an edge to the start or the exit is dropped**, because DarkPrint
296
+ synthesises both boundary nodes and derives their wiring rather than storing it.
297
+
298
+ A DarkPrint bundle that goes out through `export` and back through `import` comes home byte
299
+ for byte, the blueprint digest line aside. The losses above are what a *foreign* pipeline
300
+ pays, and they are the price of the two formats not being the same size.
@@ -0,0 +1,95 @@
1
+ # The live preview
2
+
3
+ An optional window on the interview. When the author opts in, the blueprint-writing skill
4
+ posts the draft to darkprint.io after every phase, and the page at
5
+ `https://www.darkprint.io/tutorial/live/<token>` draws the graph as it takes shape, in the
6
+ same panel a published blueprint page uses. The site runs nothing: it keeps the last draft it
7
+ received and shows it. Every field below is transcribed from `lib/core/tutorial/live.ts`, the
8
+ one contract the skill, the routes and the page share.
9
+
10
+ ## The three endpoints
11
+
12
+ | call | body | answers |
13
+ |---|---|---|
14
+ | `POST https://www.darkprint.io/api/tutorial/live` | `{}` | `{ token, url, expiresAt }` |
15
+ | `GET https://www.darkprint.io/api/tutorial/live/<token>` | none | `{ token, revision, updatedAt, expiresAt, draft }`, with `ETag: "<revision>"` and a 304 on `If-None-Match` |
16
+ | `PUT https://www.darkprint.io/api/tutorial/live/<token>` | a `LiveDraft` | `{ revision, updatedAt, expiresAt }` |
17
+
18
+ The token is 32 URL-safe characters, `[A-Za-z0-9_-]{32}`, and anything else is refused
19
+ before the store is asked. A page lives 24 hours from the moment it was opened, and every
20
+ accepted PUT refreshes that. The contract caps one draft at 512 KiB of JSON. A 400 names the
21
+ field that is wrong in one sentence; fix the payload and send again. The page polls the GET
22
+ itself, so the skill never calls it.
23
+
24
+ Open once and remember the token from the answer:
25
+
26
+ ```
27
+ curl -fsS -X POST https://www.darkprint.io/api/tutorial/live \
28
+ -H "content-type: application/json" --data '{}'
29
+ ```
30
+
31
+ When the author arrived with the URL already, the token is its last path segment and the POST
32
+ is skipped; when that URL's origin is not `https://www.darkprint.io`, use its origin in every
33
+ line here, because the page lives where it was opened. Each shell command may run in a fresh
34
+ shell, so set the variable at the head of every command rather than exporting it once. Then,
35
+ at each boundary, the JSON goes on stdin, so nothing is written to disk before the author has
36
+ said yes to the files:
37
+
38
+ ```
39
+ DARKPRINT_LIVE_TOKEN=<token>; \
40
+ curl -fsS -X PUT "https://www.darkprint.io/api/tutorial/live/$DARKPRINT_LIVE_TOKEN" \
41
+ -H "content-type: application/json" --data-binary @- <<'JSON'
42
+ { "phase": "nodes", "task": "...", "bundle": { "manifest": { ... }, "dot": "...", "cardFiles": { ... } }, "ledger": { ... } }
43
+ JSON
44
+ ```
45
+
46
+ A failed PUT, whether the network is down or the answer is a 400, is one line to the author,
47
+ and the interview goes on. The folder is the deliverable; the page is a window on it.
48
+
49
+ ## `LiveDraft`, field by field
50
+
51
+ | field | required | what |
52
+ |---|---|---|
53
+ | `phase` | yes | one of `need`, `reuse`, `nodes`, `ports`, `guards`, `risk`, `written`, `enriched`, `published` |
54
+ | `task` | no | the author's one-sentence answer to Q0.1 |
55
+ | `bundle.manifest` | yes | `slug`, `title`, `summary` as strings and `tags` as an array of strings; `description` and `category` ride along once Q5.3 derives them |
56
+ | `bundle.dot` | yes | the DOT so far; `""` before any node has a name |
57
+ | `bundle.cardFiles` | yes | an object keyed `cards/<id>@<version>.yaml` whose values are the YAML text; `{}` before any card is drafted |
58
+ | `ledger` | no | `settled`, `open`, `blocked`: the three lists of posture rule 4, each an array of strings |
59
+ | `hits` | no | every registry hit considered, each `{ kind, ref, title, score }`: `kind` is `blueprint` or `card`, `ref` is `owner/slug` or `id@version`, `score` is the search module's own number |
60
+ | `publishedRef` | no | `owner/slug` once the author has published, so the page can link to it |
61
+
62
+ `hits` comes straight off the search answer. A blueprint hit already carries `ref`, `title`
63
+ and `score`; a card hit carries `name` where the draft wants `title`, so copy `name` into it.
64
+ Send every hit shown to the author, including the ones recommended against; the page is
65
+ meant to show the search happening.
66
+
67
+ **Before Q5.3 has derived the manifest**, send provisional values, because the PUT refuses a
68
+ draft without them: `title` is Q0.1 as a title, `summary` is the Q0.1 sentence, `slug` is the
69
+ title in card-id grammar (`^[a-z0-9]+(-[a-z0-9]+)*$`), `tags` is `[]`. Replace them with the
70
+ derived ones at `risk`.
71
+
72
+ **The bundle may be partial.** The PUT checks the shape of the JSON and nothing else: a draft
73
+ that `darkprint validate` would refuse with `bundle/port-mismatch` is still accepted, and the
74
+ page draws what the engine can resolve and lists the rest as still to settle. Send the DOT as
75
+ soon as the nodes have names, and each card as soon as it exists in memory. The files on the
76
+ author's disk are written when SKILL.md says and not before; the draft is a copy of what is
77
+ settled so far, never a copy of the folder.
78
+
79
+ ## The phases, and when to send each
80
+
81
+ | `phase` | send it | the page's label, and what the draft carries by then |
82
+ |---|---|---|
83
+ | `need` | after Q0.6, before searching | "The need": the task sentence and no graph yet |
84
+ | `reuse` | after Phase 1, whether or not anything fit | "Reuse before drawing": the hits considered, with their scores |
85
+ | `nodes` | after Phase 2, once the split is agreed | "The nodes": a DOT with named nodes and no edges |
86
+ | `ports` | after Q3.6, when the ledger closes | "Ports and edges": the derived edges, and the ports still unmatched in `ledger.open` |
87
+ | `guards` | after Q4.7 | "Isolation and guards": the `cannot` entries and the condition on every fork |
88
+ | `risk` | after the show-back, before writing | "Risk and identity": the manifest as derived, the risk markers, the names |
89
+ | `written` | after the files are written and validated | "Folder written": the whole bundle, as it sits on disk |
90
+ | `enriched` | after an enrich merge | "Enriched from the registry": the grown graph, and the hits that fed it |
91
+ | `published` | after the author publishes, with `publishedRef` | "Published to an account": a link to the blueprint's page |
92
+
93
+ The quoted words are `LIVE_PHASE_LABELS` from the contract, the heading the page prints for
94
+ each phase. What follows each is what the draft carries at that point; the page draws
95
+ whatever of it resolves, and a draft sent twice under the same phase replaces the earlier one.
@@ -0,0 +1,242 @@
1
+ <!--
2
+ GENERATED FILE. Do not edit by hand.
3
+ Rendered from lib/core/ontology/core.ts and lib/core/config.ts by scripts/skill-refs.ts.
4
+ Regenerate with: npm run generate:skill-refs
5
+ scripts/generate-skill-refs.test.ts fails the suite if this file drifts.
6
+ -->
7
+
8
+ # DarkPrint core vocabulary
9
+
10
+ 54 terms. The vocabulary carries no version of its own: it names what an
11
+ Attractor node IS, and Attractor fixes those shapes in its own spec.
12
+
13
+ Every card field that names a term is resolved against this list. A term that is not
14
+ here is `card/unknown-term` (error). A term of the wrong kind, a `data-type` in the
15
+ `tools` list or a `tool` in `type`, is `card/wrong-term-kind` (error).
16
+
17
+ A card names no vocabulary version either. There is one vocabulary and every card is
18
+ read against it, so writing `ontology_version:` on a card is `card/retired-field`
19
+ (warning).
20
+
21
+ ## phase: the five, closed
22
+
23
+ Optional and repeatable. A card may declare none, one, or several. `phase: []`, or the
24
+ field omitted entirely, is a **complete and correct answer**, and the validator emits
25
+ nothing at all about it: an intake step, a retrieval step and a memory store sit in none
26
+ of the five. Phase coverage is descriptive and nothing scores off it, so never invent a
27
+ phase to fill a strip.
28
+
29
+ This is the one dimension that is never namespaced: `me/triage` as a phase is
30
+ `card/namespaced-phase` (error). Listed in lifecycle order, which is the order coverage
31
+ reports in.
32
+
33
+ | id | label | meaning |
34
+ | --- | --- | --- |
35
+ | `planning` | Planning | From the request to a plan and the acceptance criteria. |
36
+ | `implementation` | Implementation | From the plan to the artefact. |
37
+ | `testing` | Testing | Runs the checks and produces the evidence. |
38
+ | `debugging` | Debugging | From failure evidence to a targeted fix. |
39
+ | `deployment` | Deployment | Release, publication, delivery. |
40
+
41
+ ## node-type: what does the job
42
+
43
+ Exactly one per card, in `type`. Three of these are **abstract categories** and a node
44
+ should not be typed with one: they exist so the metrics can ask a subsumption question.
45
+
46
+ `human-gate` and `human-input` are subsumed by `human-in-the-loop` and carry
47
+ `impliesHuman`. Declaring one of them is the whole of how a card says a person acts at
48
+ the node: there is no second field beside `type` to set, and nothing else on the card
49
+ can say otherwise.
50
+
51
+ The `orchestration` branch is control flow: `parallel` splits the run, `parallel.fan-in`
52
+ joins it back, `manager-loop` supervises a sub-run and decides whether it repeats. The
53
+ three are named after the Attractor handlers they compile to, so a bundle's `topology.dot`
54
+ reads the same on both sides.
55
+
56
+ Autonomy is read twice off `type` and the weaker reading is the one that lands in a band.
57
+ How much runs alone: the share of nodes whose `type` is *not* subsumed by
58
+ `human-in-the-loop`. How much of the deciding runs alone: the same share taken over the
59
+ **control points** only, which are the nodes under `evaluative` or `orchestration` plus
60
+ `human-gate`, and it decides a band only from 2 control points up. Bands: > 0.9 closed-loop, ≥ 0.7 conditional, ≥ 0.5 supervised, below that assisted.
61
+
62
+ | id | label | broader | implies human | governs flow | meaning |
63
+ | --- | --- | --- | --- | --- | --- |
64
+ | `agent` | Agent | — | no | no | A model that reasons and produces non-deterministic output. |
65
+ | `decision` | Decision | `evaluative` | no | yes | A conditional switch that evaluates and routes without producing artefacts. |
66
+ | `evaluative` | Evaluative | — | no | yes | The abstract category of nodes that judge or route rather than produce an artefact. |
67
+ | `human-gate` | Human gate | `human-in-the-loop` | yes | yes | A point where a person must approve or reject. |
68
+ | `human-in-the-loop` | Human in the loop | — | no | no | The abstract category of nodes at which a person acts, and the one the autonomy metric interrogates. |
69
+ | `human-input` | Human input | `human-in-the-loop` | yes | no | A point where a person must supply data or content. |
70
+ | `manager-loop` | Manager loop | `orchestration` | no | yes | Supervises a sub-run, polling the work and deciding whether to act on it and whether to go round again until its stop condition holds. |
71
+ | `orchestration` | Orchestration | — | no | yes | The abstract category of nodes that shape the run itself: how many copies of a step exist, when they converge, whether the whole thing repeats. |
72
+ | `parallel` | Parallel | `orchestration` | no | yes | Splits the run into branches that proceed at the same time, producing nothing itself and deciding only how many copies of the work exist. |
73
+ | `parallel.fan-in` | Parallel fan-in | `orchestration` | no | yes | Waits for the branches a parallel node opened and joins them back into one line, deciding when the run continues rather than what it continues with. |
74
+ | `shell-tool` | Shell tool | `tool` | no | no | A tool node whose instruction is a shell command the runner executes directly. |
75
+ | `tool` | Tool | — | no | no | A deterministic operation: running tests, compiling, formatting, calling an API. |
76
+ | `validation` | Validation | `evaluative` | no | yes | Compares an artefact against criteria and produces a verdict with evidence. |
77
+
78
+ **The trap.** `tool`'s own description names running tests, which invites typing the
79
+ test runner `tool`. The `criteria-leak` check defines its generator set as the
80
+ predecessors of nodes typed `validation`; type the judge `tool` and that set is empty,
81
+ the check does not run, and the blueprint scores 4 on security because nothing was
82
+ asked, not because nothing was found. The engine says so with
83
+ `analysis/criteria-leak-unanchored` (warning). Whatever decides the run is finished is
84
+ `validation`.
85
+
86
+ ## risk-marker: what it costs
87
+
88
+ Declared in `risk_markers`. The security score starts at 4 and each distinct marker
89
+ present anywhere in the blueprint is charged **once**, however many nodes carry it:
90
+ `clamp(round(4 − Σ weights), 1, 4)`. Weights are read from `lib/core/config.ts`; a locally
91
+ namespaced marker with no weight counts 0
92
+ and does not move the score.
93
+
94
+ Three markers are **inferred** from the graph whether or not any card declares them.
95
+ Declaring one the engine would have inferred anyway changes nothing; failing to declare
96
+ one does not hide it.
97
+
98
+ | id | weight | how it arrives | broader | meaning |
99
+ | --- | --- | --- | --- | --- |
100
+ | `arbitrary-code-execution` | −2.0 | declared | `execution-risk` | The node can run code or shell commands that were not decided in advance. |
101
+ | `criteria-leak` | −2.0 | inferred | `isolation-breach` | The node can see the acceptance criteria its own output will be judged against. |
102
+ | `execution-risk` | — | category | — | The abstract category for markers about running code the blueprint did not fix in advance. |
103
+ | `irreversible-action` | −1.5 | declared | — | The node takes actions that cannot be undone: publishing, sending, deleting. |
104
+ | `isolation-breach` | — | category | — | The abstract category for markers where information or state crosses a boundary the topology was meant to hold. |
105
+ | `secret-access` | −1.0 | declared | — | The node handles credentials, keys or tokens. |
106
+ | `unbounded-loop` | −1.5 | inferred | — | The node sits in a cycle with no iteration cap and no exit condition. |
107
+ | `unchecked-write` | −1.0 | declared | `isolation-breach` | The node writes to disk, a database or a repository with no check upstream. |
108
+ | `unvalidated-external-access` | −1.0 | inferred | — | The node reaches the network, an API or an external resource with no validation node upstream. |
109
+
110
+ How the three inferred ones are found:
111
+
112
+ - `unbounded-loop`: every strongly connected component in the graph, unless some card
113
+ in it declares an iteration cap. The cap is a **top-level** key of `params`, one of
114
+ `max_iterations`, `maxIterations`, `max_retries`, holding a non-negative
115
+ integer (`0` counts). Nested inside another object it is not read, and the cycle takes
116
+ the charge on every member with no obvious cause.
117
+ - `unvalidated-external-access`: a node whose `tools` include anything subsumed by
118
+ `web-search`, `http-fetch`, `sql` or `ci`, which has at least one successor, and at
119
+ least one of those successors is not a `validation` node. `messaging`, `git`,
120
+ `vector-store`, `file-io`, `shell` and `python-sandbox` are deliberately excluded.
121
+ - `criteria-leak`: see below. This is the one the whole design is built around.
122
+
123
+ ### criteria-leak, precisely
124
+
125
+ Two sets are computed first. **Producers** are nodes declaring an output port whose type
126
+ is subsumed by `acceptance-criteria`. **Judges** are nodes typed `validation`, and
127
+ **generators** are the predecessors of any judge, closed upward through non-judge nodes.
128
+ The marker fires, at −2.0, when:
129
+
130
+ - **topological**: walking forward from a producer, absorbing at judges, reaches a
131
+ generator. The criteria reach the node whose work is being judged.
132
+ - **declarative**: one node emits both an `acceptance-criteria` port *and* another port
133
+ a directly connected judge reads as the artefact under judgement. One node writing the
134
+ criteria and the work is structurally illegal, off the declarations alone.
135
+
136
+ And it warns without charging when:
137
+
138
+ - **content**: the 3-gram Jaccard similarity between a generator's `spec` and a producer's
139
+ exceeds 0.35
140
+ (`analysis/criteria-leak-suspected`). An absent edge with the criteria paraphrased into
141
+ the prose is a false isolation, and this is the half that catches it. Specs under three
142
+ words are excluded from the comparison entirely.
143
+ - **relayed**: reachable only by walking *through* a judge
144
+ (`analysis/criteria-relayed-through-judge`). The engine cannot tell an endorsed
145
+ `judge → fixer → judge` loop from a forbidden `judge → builder → judge` one, so it
146
+ declines to decide and says which it saw.
147
+ - **out of band**: a `params` key matching `/criteri/i` naming something nothing in the
148
+ graph produces (`analysis/criteria-out-of-band`). Isolation has stopped being a property
149
+ of the topology for that node.
150
+ - **unanchored**: one of the two legs is missing: producers with no generators, or
151
+ generators with no producers (`analysis/criteria-leak-unanchored`). **The check did not
152
+ run.** A 4 in this state is silence, not a pass. Both sets empty is silent by design.
153
+
154
+ ## data-type: what an edge carries
155
+
156
+ Every port declares one, in `type`. Compatibility along an edge is directional: a source
157
+ port fits a target port when the types are equal, when either side is `any`, or when the
158
+ **source is narrower** than the target. Never the other way. `code → text` carries;
159
+ `text → code` is `bundle/type-mismatch` (error).
160
+
161
+ **Do not reach for `any`.** It matches everything, which means every edge passes, no
162
+ `cannot` prohibition can be violated, and the criteria check has nothing to anchor on. A
163
+ bundle typed `any` throughout loads perfectly and checks nothing.
164
+
165
+ `acceptance-criteria` is load-bearing: it is the port type the entire `criteria-leak`
166
+ machinery anchors on. Criteria typed `text` or `structured` are criteria the engine
167
+ cannot see.
168
+
169
+ | id | label | broader | meaning |
170
+ | --- | --- | --- | --- |
171
+ | `acceptance-criteria` | Acceptance criteria | `structured` | The conditions an artefact must satisfy, produced in planning and used to judge the result. |
172
+ | `any` | Any | — | The top of the data lattice, which accepts anything and asserts nothing. |
173
+ | `artifact` | Artifact | `binary` | A build output or file bundle produced by the run. |
174
+ | `binary` | Binary | `any` | Opaque bytes the graph moves without reading. |
175
+ | `code` | Code | `text` | Source code in some language, meant to be run or reviewed rather than read as prose. |
176
+ | `event` | Event | `signal` | A notice that something happened, with a name and a time. |
177
+ | `json` | JSON | `structured` | A JSON value, self-describing and machine-parsable. |
178
+ | `markdown` | Markdown | `text` | Text carrying Markdown structure: headings, lists, emphasis. |
179
+ | `plan` | Plan | `structured` | An ordered set of steps a downstream node is expected to carry out. |
180
+ | `report` | Report | `structured` | A finished write-up of what happened or what was found, meant to be read. |
181
+ | `signal` | Signal | `any` | A lightweight message that carries coordination rather than content. |
182
+ | `status` | Status | `signal` | The outcome of a step, in a form the graph can branch on. |
183
+ | `structured` | Structured | `any` | Data with a shape the receiving node can count on. |
184
+ | `table` | Table | `structured` | Rows over a fixed set of columns. |
185
+ | `text` | Text | `any` | Free-form prose with no structure the next node can rely on. |
186
+
187
+ The lattice, as `broader` draws it:
188
+
189
+ ```
190
+ any
191
+ binary
192
+ artifact
193
+ signal
194
+ event
195
+ status
196
+ structured
197
+ acceptance-criteria
198
+ json
199
+ plan
200
+ report
201
+ table
202
+ text
203
+ code
204
+ markdown
205
+ ```
206
+
207
+ ## tool: what a node is permitted to do
208
+
209
+ Declared in `tools`. Note the deliberate id collision: `tool` is both a `node-type` and
210
+ the *kind* of these terms. The field a term appears in decides which is meant, so there
211
+ is no ambiguity to resolve: `type: tool` is the node type and `tools: [shell]` is a
212
+ capability.
213
+
214
+ `tools` says what the node is permitted to do. `mcp` says which installed server supplies
215
+ it, is free text, and is checked against nothing, because an MCP server is a process somebody
216
+ installed and the vocabulary has no term for one. A node can carry either without the other.
217
+
218
+ | id | label | broader | meaning |
219
+ | --- | --- | --- | --- |
220
+ | `ci` | CI | `tool-capability` | Triggers or inspects a continuous-integration pipeline. |
221
+ | `file-io` | File I/O | `tool-capability` | Reads and writes files on the local filesystem. |
222
+ | `git` | Git | `tool-capability` | Reads and writes a Git repository: branches, commits, diffs. |
223
+ | `http-fetch` | HTTP fetch | `tool-capability` | Fetches a URL over HTTP and hands back the response. |
224
+ | `human-review` | Human review | `tool-capability` | Routes the work to a person and waits for their verdict. |
225
+ | `messaging` | Messaging | `tool-capability` | Sends messages to a chat or notification channel. |
226
+ | `python-sandbox` | Python sandbox | `tool-capability` | Executes Python in an isolated interpreter. |
227
+ | `shell` | Shell | `tool-capability` | Runs shell commands on the host. |
228
+ | `sql` | SQL | `tool-capability` | Issues SQL statements against a database. |
229
+ | `tool-capability` | Tool capability | — | The root of the tool vocabulary, a capability a node needs from its host. |
230
+ | `vector-store` | Vector store | `tool-capability` | Embeds, stores and retrieves vectors for semantic recall. |
231
+ | `web-search` | Web search | `tool-capability` | Queries a search engine and returns ranked results. |
232
+
233
+ ## Local terms
234
+
235
+ `node-type`, `risk-marker`, `data-type` and `tool` accept a locally namespaced term
236
+ (`me/my-term`), which must be rooted in an `extensions.yaml` carried by the bundle.
237
+ `phase` never accepts one.
238
+
239
+ **Do not emit local terms unless the author asks for one and writes the extension.** An
240
+ unrooted local term is silently ignored, which is the worst outcome available: the card
241
+ loads, the field reads as declared, and nothing enforces it.
242
+