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,544 @@
1
+ <!--
2
+ GENERATED FILE. Do not edit by hand.
3
+ Rendered from lib/core/card/schema.ts and lib/core/card/validate.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
+ # The node card, on the wire
9
+
10
+ A card is one YAML or JSON document describing one node. The wire format is
11
+ **snake_case** (`risk_markers`, `will_not`), and the validator maps it onto the
12
+ camelCase model quoted at the bottom of this file.
13
+
14
+ ## Every key the validator accepts
15
+
16
+ Exactly this set, and nothing else. An unrecognised key is reported as `info` and
17
+ **ignored**, so a typo does not fail the card; it silently does nothing.
18
+
19
+ - `action`
20
+ - `agent`
21
+ - `author`
22
+ - `cannot`
23
+ - `dependencies`
24
+ - `id`
25
+ - `inputs`
26
+ - `mcp`
27
+ - `model`
28
+ - `name`
29
+ - `notes`
30
+ - `outputs`
31
+ - `params`
32
+ - `phase`
33
+ - `phases`
34
+ - `provenance`
35
+ - `riskMarkers`
36
+ - `risk_markers`
37
+ - `skill`
38
+ - `spec`
39
+ - `tools`
40
+ - `type`
41
+ - `version`
42
+ - `willNot`
43
+ - `will_not`
44
+
45
+ Where two spellings appear (`phase`/`phases`, `will_not`/`willNot`,
46
+ `risk_markers`/`riskMarkers`) both load. Writing both on one card is an `info` and the
47
+ snake_case one wins. Prefer snake_case: it is what every shipped card is written in.
48
+
49
+ ## Required, and what happens when they are missing
50
+
51
+ A missing, null, non-string or blank value on any of these is an **error**, and no card
52
+ comes back at all.
53
+
54
+ | key | rule | diagnostic |
55
+ | --- | --- | --- |
56
+ | `id` | lowercase words joined by single hyphens, at most one namespace segment: `solver`, `me/solver-a` | `card/bad-id` |
57
+ | `name` | any non-blank string | `card/missing-field` |
58
+ | `type` | exactly one `node-type` term | `card/unknown-term`, `card/wrong-term-kind` |
59
+ | `action` | non-blank; short and machine-readable | `card/missing-field` |
60
+ | `spec` | non-blank; under 40 trimmed characters is `card/spec-too-thin`, a warning | `card/missing-field` |
61
+ | `inputs` | must be **present**; write `[]` explicitly when the node needs nothing | `card/missing-field` |
62
+ | `outputs` | must be **present**; `[]` is how a sink is declared | `card/missing-field` |
63
+ | `version` | full semver `MAJOR.MINOR.PATCH` | `card/bad-version` |
64
+
65
+ ## A port
66
+
67
+ | key | rule |
68
+ | --- | --- |
69
+ | `name` | required, non-blank, **unique within its side** (`card/duplicate-port`, error) |
70
+ | `type` | required, one `data-type` term |
71
+ | `description` | optional string |
72
+ | `required` | optional boolean, **inputs only**; on an output it is an `info` and is dropped. Defaults to true. |
73
+
74
+ ## Optional, and what they default to
75
+
76
+ | key | default | checked against the ontology? |
77
+ | --- | --- | --- |
78
+ | `phase` / `phases` | `[]` | yes: the five, never namespaced |
79
+ | `tools` | `[]` | yes: `tool` terms |
80
+ | `mcp` | `[]` | **no**: free text, installed server names |
81
+ | `params` | `{}` | no: any JSON-serialisable mapping, nesting depth under 100 |
82
+ | `dependencies` | `[]` | not here: checked against the graph by the resolver |
83
+ | `cannot` | `[]` | yes: `data-type` terms, and see below |
84
+ | `will_not` | `[]` | **no**: free text, see below |
85
+ | `risk_markers` | `[]` | yes: `risk-marker` terms |
86
+ | `model`, `agent`, `skill`, `notes`, `author`, `provenance` | absent | no |
87
+
88
+ ## `cannot` and `will_not` are two prohibitions, and only one is checked
89
+
90
+ Write a prohibition in the field that matches what you want to happen to it.
91
+
92
+ `cannot` holds **`data-type` term ids and nothing else**. The resolver refuses any
93
+ incoming edge whose *carrier* is that type or anything narrower, with
94
+ `bundle/prohibition-violated`, an error. That is what turns an absent edge from a
95
+ convention somebody remembered into a rule the engine holds the graph to. A sentence
96
+ written here is `card/unknown-term`, an error, and the card does not load.
97
+
98
+ **What counts as the carrier** decides how far the enforcement reaches, so read this
99
+ twice. On an edge with no `out=` pin the carriers are **every output of the source card**,
100
+ so an edge out of a node that emits the criteria at all is refused. On an edge pinned with
101
+ `out=`, the carrier is **that one port**, so pinning the edge to a different port satisfies
102
+ the prohibition. The bundle then loads, and the analyzer charges `criteria-leak` anyway,
103
+ because its topological walk reads the graph at node level and does not care which port an
104
+ edge carries. `cannot` is the fast tripwire that stops the bundle loading; the analyzer is
105
+ the backstop that prices it. Neither replaces the other.
106
+
107
+ `will_not` holds **your own sentences**: "never opens a shell", "does not edit the code
108
+ under test". Nothing checks them, because no engine can decide a sentence against a
109
+ topology. They are addressed to whoever reads the card and to the agent instantiated from
110
+ it, which is a real audience and not a lesser one. Putting a `data-type` here is
111
+ `card/prohibition-misfiled`, a **warning**: the card loads, the entry is shown, and
112
+ nothing enforces it.
113
+
114
+ Two asymmetries that decide what to put in `cannot`:
115
+
116
+ - Subsumption runs one way. `cannot: [structured]` refuses an incoming
117
+ `acceptance-criteria`, because that is narrower. `cannot: [acceptance-criteria]` does
118
+ **not** refuse an incoming `structured`.
119
+ - An output typed `any` never violates a narrower prohibition. Lazy typing makes the
120
+ whole mechanism unenforceable.
121
+
122
+ ## Who acts at the node
123
+
124
+ `type`, and nothing else. A `type` subsumed by `human-in-the-loop` (`human-gate`,
125
+ `human-input`, or `human-in-the-loop` itself, since subsumption is reflexive) is a node
126
+ where a person acts, and every other type is a node that runs unattended. The autonomy
127
+ reading, the schematic and the card page all ask that one question of that one field.
128
+
129
+ There used to be a `requires_human` boolean beside it. A card could set it to `false` on
130
+ a `human-gate`, or to `true` on a `tool`, and nothing refused the document. Writing it
131
+ today is `card/retired-field`, a **warning**: the card still loads, the key is ignored,
132
+ and the diagnostic says what the card's own `type` answers instead.
133
+
134
+ ## Which vocabulary a card is read against
135
+
136
+ The one this build ships. A card used to declare `ontology_version`, and the engine read
137
+ it against the vocabulary that string named, but a release stores its whole scorecard at
138
+ publish time, so no score is ever recomputed against an older vocabulary and nothing ever
139
+ asked for the older one. Terms are added and retired inside the one vocabulary with
140
+ `deprecated: {since, replacedBy}`, which is what a card naming a renamed term follows.
141
+ Writing `ontology_version:` today is `card/retired-field`, a **warning**, on the same
142
+ terms as `requires_human`.
143
+
144
+ ## Re-emitting a card
145
+
146
+ A published version is never edited in place. Rewriting a card's content while leaving
147
+ the old file beside it is `bundle/digest-mismatch` (error). A card version nothing
148
+ instantiates is `bundle/orphan-card` (warning): delete the superseded file rather than
149
+ keep it for history.
150
+
151
+ Bumping too small for what changed is `card/version-bump-too-small` (error). The engine
152
+ infers the smallest bump the edit needs, from `lib/core/version/bump.ts`, and the
153
+ declared version has to be at least that. The strongest reason wins when an edit touches
154
+ several rows.
155
+
156
+ | bump | forced by |
157
+ | --- | --- |
158
+ | **major** | a port removed or renamed |
159
+ | **major** | a port's `type` changed |
160
+ | **major** | an existing input made required |
161
+ | **major** | a required input added |
162
+ | **major** | `id` changed |
163
+ | **major** | `type` changed |
164
+ | **major** | a `cannot` entry added |
165
+ | **major** | a `will_not` entry withdrawn |
166
+ | **minor** | `spec` changed |
167
+ | **minor** | `skill` set, repointed or dropped |
168
+ | **minor** | `model` set, changed or dropped |
169
+ | **minor** | an optional input added |
170
+ | **minor** | an output added |
171
+ | **minor** | a `tools`, `mcp`, `risk_markers` or `dependencies` entry added |
172
+ | **minor** | a `params` key added |
173
+ | **minor** | a `cannot` entry withdrawn |
174
+ | **minor** | a `will_not` entry stated |
175
+ | **minor** | a phase added or dropped |
176
+ | **patch** | `name`, `action` or `notes` reworded |
177
+ | **patch** | a port `description` changed |
178
+ | **patch** | `agent` changed |
179
+ | **patch** | a `params` value changed, or a `params` key removed |
180
+ | **patch** | an input no longer required |
181
+ | **patch** | a `tools`, `mcp`, `risk_markers` or `dependencies` entry withdrawn |
182
+ | **patch** | a list reordered |
183
+
184
+ ## `lib/core/card/schema.ts`, verbatim
185
+
186
+ The engine's own model, quoted byte for byte. Read the sentences; the `doc 1 §3.2` style
187
+ citations point at design documents that do not ship with this skill and can be ignored.
188
+
189
+ ```ts
190
+ /* ============================================================
191
+ DarkPrint core — the node card
192
+ The contract that holds everything else up: identity,
193
+ behaviour, interfaces and evaluation metadata for one node.
194
+ Design doc §3–§4, engine spec §4.
195
+
196
+ ── Who reads which of these fields ──
197
+ A card is read twice, by two parties that never meet, and it
198
+ is worth knowing which of them is reading a field before
199
+ deciding what to write in it.
200
+
201
+ Some fields are handed to the RUNTIME. When a bundle is
202
+ exported, `attractor/emit.ts` writes them onto the DOT under
203
+ the names Attractor reserves for them, and the runner acts on
204
+ them: `spec` becomes `prompt`, `name` becomes `label`, `model`
205
+ becomes `llm_model`, `params.max_iterations` becomes
206
+ `max_retries`, `params.tool_command` becomes the node attribute
207
+ of the same name, and `type` and `phases` become the node's
208
+ `class` (prefixed `dp-`), which a `model_stylesheet` selects
209
+ on. Write those fields for a machine that will execute them.
210
+
211
+ Everything else is read by DARKPRINT, and by whoever opens the
212
+ card. `cannot`, `will_not`, `risk_markers`, `inputs`,
213
+ `outputs`, `dependencies`, `notes`, `tools`, `mcp` and `skill`
214
+ are scored, indexed, drawn and shown; no Attractor runner sees
215
+ any of them. The one thing that crosses back is the card's
216
+ identity, which travels as `card="id@version"` on the node.
217
+ Attractor does not reserve that name, so it ignores it, and
218
+ that is the entire reason a DarkPrint bundle runs unchanged.
219
+
220
+ The line between the two is a property of the NAME a value is
221
+ written under, never of the intention behind it: a DarkPrint
222
+ field emitted under a reserved Attractor name would not be
223
+ ignored, it would configure a run. `attractor/emit.ts` declares
224
+ both halves as lists and its tests hold the emitter to them, so
225
+ nothing crosses that line by accident. Nothing here asks a card
226
+ author to check anything; it asks them to know that `type` and
227
+ `spec` are instructions somebody's machine will follow, and
228
+ that `will_not` is a promise addressed to a person.
229
+ ============================================================ */
230
+
231
+ /** Any value that survives a JSON round-trip — what `params` is allowed to hold. */
232
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
233
+
234
+ /** A declared input or output port. */
235
+ export interface Port {
236
+ name: string;
237
+ /** `data-type` term id from the ontology. */
238
+ type: string;
239
+ description?: string;
240
+ /** Inputs only. Defaults to true. */
241
+ required?: boolean;
242
+ }
243
+
244
+ /**
245
+ * One node, fully described. The wire format (YAML/JSON on disk) is snake_case —
246
+ * `risk_markers`, `will_not` — and `validate.ts` maps it onto this camelCase model.
247
+ */
248
+ export interface NodeCard {
249
+ /* 3.1 identity */
250
+ /** Unique id, optionally namespaced ("berti/solver-a"). Ties the card to its DOT node. */
251
+ id: string;
252
+ name: string;
253
+ /**
254
+ * `node-type` term id. Doc 3 §1: exactly one.
255
+ *
256
+ * It is also the whole of the card's answer to whether a person acts at this node. A
257
+ * type subsumed by doc 3 §3's `human-in-the-loop` category is staffed and nothing else
258
+ * is, and `ontology/resolve.ts`'s `requiresHuman` is where that is read. There used to
259
+ * be a `requires_human` boolean here as well, so a card could say `type: human-gate`
260
+ * and `requires_human: false` in the same document: the archive loaded it, the
261
+ * schematic drew a person on the node, and the autonomy reading counted it unattended.
262
+ * Nothing in the system compared the two, and a field that can contradict the field
263
+ * beside it is not a second opinion, it is a second source of truth.
264
+ *
265
+ * It is also the field the exporter reads twice: once for the node's `shape`, which is
266
+ * how Attractor picks the handler that runs the node, and once for its `class`, where
267
+ * the type and every category above it are written out as `dp-agent`, `dp-human-gate`,
268
+ * `dp-orchestration` and so on. A `model_stylesheet` selects on class, so the second
269
+ * one is what lets whoever runs the bundle say "every agent on this machine runs on the
270
+ * cheap model" without editing a single card.
271
+ */
272
+ type: string;
273
+ /**
274
+ * `phase` term ids: any number of doc 3 §2's five, never namespaced (doc 3 §7 keeps the
275
+ * phases closed).
276
+ *
277
+ * Optional and repeatable, which reverses doc 3 §1's cardinality row on the author's
278
+ * ruling: the five phases are *"the expected high level phases a dark factory should
279
+ * have, but do not necessarily have to stick to nodes"*. They describe the **factory**,
280
+ * not every node in it. An intake, a retrieval step or a memory store sits in none of
281
+ * the five, and `phases: []` is its complete and correct answer — not a hole to be
282
+ * filled. A node that genuinely spans two, such as a synthesiser that both builds and
283
+ * repairs, declares both.
284
+ *
285
+ * Nothing downstream may render an empty list as a defect: it feeds phase coverage,
286
+ * which doc 2 §1.1 and doc 3 §2 make descriptive rather than a score.
287
+ *
288
+ * Each declared phase is also written onto the exported node as a `dp-planning`,
289
+ * `dp-testing` class beside the type's, so a stylesheet can route by lifecycle stage as
290
+ * well as by what the node is. A card that declares none carries no phase class, which
291
+ * is the same answer `phases: []` gives everywhere else.
292
+ *
293
+ * The wire key stays the singular `phase` and accepts a scalar or a sequence, because
294
+ * both spellings read naturally in YAML; `validate.ts` normalises them onto this field.
295
+ */
296
+ phases: string[];
297
+
298
+ /* 3.2 behaviour */
299
+ /** The operation the node performs, short and machine-readable. Doc 1 §3.2. */
300
+ action: string;
301
+ /**
302
+ * The natural-language specification handed to the agent. Doc 1 §0.1.2 and §3.2: this is
303
+ * the payload delivered to Claude Code, or an equivalent agent, when the graph is
304
+ * instantiated, so it must be self-sufficient — the agent reading it does not see the
305
+ * rest of the graph. It must also respect the graph's isolation rules: information a
306
+ * node is not meant to have does not appear here either, because an absent edge with the
307
+ * criteria written into the prose is a false isolation.
308
+ */
309
+ spec: string;
310
+ /**
311
+ * Which model the agent is instantiated with, written the way the provider writes the
312
+ * identifier: `claude-opus-5`, `claude-haiku-4-5`.
313
+ *
314
+ * **A default rather than a binding**, and that is the whole of its contract. Engine
315
+ * spec §2.6 reserves `llm_model` as "LLM model identifier. Overridable by stylesheet",
316
+ * and §8 gives the graph a `model_stylesheet` whose rules set the model for every node
317
+ * matching a shape, a class or an id.
318
+ *
319
+ * **An explicit node attribute outranks the sheet**, and the spec says so twice. §8.5
320
+ * gives the resolution order and puts the explicit node attribute first, above the
321
+ * matching stylesheet rule, above the graph-level default, above the handler default,
322
+ * and it gives the mechanism in the same section: the stylesheet is a transform that
323
+ * "only sets properties that the node does not already have explicitly". §8.3 states the
324
+ * same rule from the selector side, "Explicit node attributes always override stylesheet
325
+ * values (highest precedence)", and its specificity table ranks the sheet's own rules
326
+ * against each other rather than against the node. So `attractor/emit.ts` writing this
327
+ * field onto the node is what makes the downloaded bundle run on the named model until
328
+ * whoever runs it says otherwise, and a sheet the recipient adds cannot take it back.
329
+ *
330
+ * §2.6 is cited above for the NAME and never for the ranking. Its whole gloss is
331
+ * "Overridable by stylesheet", which says the sheet can reach a node and says nothing
332
+ * about what happens when both speak; reading it as a ranking is how four surfaces on
333
+ * this site came to print the reverse, and `components/spec/rows.test.ts` holds those
334
+ * four to §8.5 now. This docblock is the fifth carrier of the same claim, and
335
+ * `schema.test.ts` holds it to the same thing.
336
+ *
337
+ * Absent on most cards, and absence is an answer rather than a hole: the node takes
338
+ * whatever the graph or the runner supplies. Nothing that renders a card may draw the
339
+ * empty case as missing data.
340
+ *
341
+ * `version/bump.ts` prices a change here as minor for the same reason the field is a
342
+ * default: it moves what the node does without moving any port, type or param a
343
+ * blueprint declared against, and the operator can override it either way.
344
+ */
345
+ model?: string;
346
+ agent?: string;
347
+ /** `tool` term ids; `[]` when the node needs none. */
348
+ tools: string[];
349
+ /**
350
+ * The MCP servers this node needs, under the names they are registered with on the
351
+ * machine that runs the graph, such as `filesystem` or `github`.
352
+ *
353
+ * Free text by design: an MCP server is a concrete process somebody installed, and the
354
+ * vocabulary has no term for one. That is what keeps this field apart from `tools`,
355
+ * which holds `tool` capability terms. `tools` says what the node is permitted to do
356
+ * and `mcp` says which server supplies it, a node can carry either without the other,
357
+ * and merging the two would lose the question each of them answers.
358
+ *
359
+ * `[]` when the node needs none, which is the ordinary case.
360
+ */
361
+ mcp: string[];
362
+ /**
363
+ * Where the skill document defining this agent's behaviour lives, as a path inside the
364
+ * bundle or the repository that carries it, such as `skills/planner.md`.
365
+ *
366
+ * A pointer, and nothing in the engine reads what it points at. Doc 2 §3 puts a skill
367
+ * one level below the graph: a skill hands one agent a capability, while the blueprint
368
+ * decides who is wired to whom. Absent on a node whose `spec` is the whole of its
369
+ * instruction, and absence carries no judgement.
370
+ */
371
+ skill?: string;
372
+ /**
373
+ * Nested configuration for the node, free-form but JSON-serializable.
374
+ *
375
+ * Free-form is not the same as unread. Two top-level keys are interpreted, and an author
376
+ * who does not know which they are is one edit away from moving a published score or a
377
+ * runnable bundle without meaning to:
378
+ *
379
+ * `max_iterations`, also spelled `maxIterations` or `max_retries`, is the iteration cap.
380
+ * `card/iteration-cap.ts` is its one reader and two callers ask it the same question:
381
+ * `analysis/security.ts` charges doc 3 §4.1's `unbounded-loop` against a cycle no node
382
+ * of which declares a cap, and `attractor/emit.ts` writes the value onto the exported
383
+ * node as Attractor's reserved `max_retries`. One key, a security score and a runnable
384
+ * DOT.
385
+ *
386
+ * `tool_command` is the shell command a `shell-tool` node runs, and it is the whole of
387
+ * that node's instruction. Engine spec §4.10's tool handler reads a node attribute of
388
+ * that name and FAILs the node outright when it is empty ("No tool_command specified"),
389
+ * so a `shell-tool` card that omits it describes a node that cannot run: `validate.ts`
390
+ * says so at warning severity. Carrying it onto the exported node under Attractor's own
391
+ * name is `attractor/emit.ts`'s half.
392
+ *
393
+ * It sits in `params` rather than as a top-level card field on the owner's call. `params`
394
+ * already carries runner configuration and a command is more of that, so a shell-tool
395
+ * node costs the wire format no new key and every card written before the term still
396
+ * parses.
397
+ *
398
+ * Every other key travels with the card to whoever runs the graph and is read by nothing
399
+ * here, which is what the field is for. A third interpreted key belongs in this list on
400
+ * the day it is added.
401
+ */
402
+ params: Record<string, JsonValue>;
403
+
404
+ /* 3.3 interfaces */
405
+ inputs: Port[];
406
+ outputs: Port[];
407
+ /** Ids of other cards this one receives data from. */
408
+ dependencies: string[];
409
+ /**
410
+ * What this node must never RECEIVE, as ontology `data-type` term ids the engine
411
+ * enforces. The negative half of the interface: `inputs` and `dependencies` say what
412
+ * arrives, and this says what may not.
413
+ *
414
+ * `bundle/resolve.ts` holds the graph to every entry: an incoming edge able to carry
415
+ * the type, meaning the type itself or a narrower kind of it, raises
416
+ * `bundle/prohibition-violated` at error severity, naming the card, the edge and the
417
+ * type. That is what turns doc 2 §3's isolation argument from prose into something the
418
+ * engine enforces. `code-builder` declaring `cannot: [acceptance-criteria]` makes the
419
+ * starter's absent edge a rule the analyzer checks, in place of a convention the author
420
+ * happened to remember.
421
+ *
422
+ * A `data-type` is the only thing this field takes, because a data type is the only
423
+ * thing an edge carries and therefore the only thing the resolver can refuse. An entry
424
+ * naming a `phase`, a `node-type`, a `tool` or nothing at all is a `card/unknown-term`
425
+ * or a `card/wrong-term-kind` here, and belongs in `willNot` instead.
426
+ *
427
+ * `[]` when the node declares no enforced prohibition, which is the ordinary case.
428
+ */
429
+ cannot: string[];
430
+ /**
431
+ * What this node undertakes never to do, in the author's own sentences. The prohibitions
432
+ * that are real and that no engine can check: "never opens a shell", "does not edit the
433
+ * code under test", "cannot recommend an outcome".
434
+ *
435
+ * ── Why this is a field of its own, and why it is named this ──
436
+ * These two lists were one list until the split. That list carried two different
437
+ * promises under one key, and the only way to tell them apart was to resolve each entry
438
+ * against the vocabulary yourself — so a page that showed the list either lied by
439
+ * omission or grew a count (`enforcedCount`) to apologise for the conflation. A reader
440
+ * has to be able to tell which promise they are being given without running anything,
441
+ * and two keys is the only way to say it in the file itself.
442
+ *
443
+ * `will_not` rather than `unenforced`, `advisory`, `notes` or `soft_cannot`. Those words
444
+ * grade the promise, and this half is not the lesser half: a node that undertakes not to
445
+ * push to a repository is making the more consequential statement of the two on most
446
+ * cards in the archive. The English already draws the line the engine draws. `cannot` is
447
+ * an incapacity somebody else imposes and holds you to. `will not` is an undertaking you
448
+ * give, in your own words, and stand behind. The difference between them is exactly the
449
+ * difference between a rule `bundle/resolve.ts` checks and a rule it has no way to see,
450
+ * and it survives being read aloud by somebody who has never opened this file.
451
+ *
452
+ * Nothing in the engine checks an entry here, and nothing may report one as a defect for
453
+ * that reason. It is addressed to whoever reads the card and to the agent instantiated
454
+ * from it, which is a real audience: on `maintainer-approval` the entries here are
455
+ * restated in the `spec` the agent actually executes.
456
+ *
457
+ * An entry that names a `data-type` term is a `card/prohibition-misfiled` warning, since
458
+ * the author has written something the engine could have enforced into the field where
459
+ * it never will be. The card still loads and the entry is still shown, because what it
460
+ * says is what this field says.
461
+ *
462
+ * `[]` when the node states no undertaking.
463
+ */
464
+ willNot: string[];
465
+
466
+ /* 3.4 evaluation metadata */
467
+ /** `risk-marker` term ids. */
468
+ riskMarkers: string[];
469
+ notes?: string;
470
+
471
+ /* 3.5 service fields */
472
+ /**
473
+ * Semver of the card itself. §4: a published version is never edited in place.
474
+ *
475
+ * The only version anywhere in DarkPrint that names a contract. There used to be an
476
+ * `ontology_version` here too, naming the vocabulary the author wrote the card against,
477
+ * and the engine resolved that string to a stored vocabulary before reading the card.
478
+ * Nothing consumed the resolution, and the field asked every author to maintain an
479
+ * answer no reader had a question for. The vocabulary has since stopped carrying a
480
+ * version at all: it names what an Attractor node is, and Attractor's shapes are fixed
481
+ * by its spec. `deprecated: {since, replacedBy}` is what lets the one living vocabulary add and
482
+ * retire terms without any of that.
483
+ *
484
+ * This one stays, and it is not the same kind of thing: it pins a node to an exact card,
485
+ * it travels to Attractor as `card="id@version"`, and §4 makes a published version
486
+ * immutable so the pin means something.
487
+ */
488
+ version: string;
489
+ author?: string;
490
+ provenance?: string;
491
+ }
492
+
493
+ /**
494
+ * The `params` key that carries a `shell-tool` node's command (engine spec §4.10).
495
+ *
496
+ * Two modules ask the same question of it. `card/validate.ts` warns when a `shell-tool`
497
+ * card leaves it empty, and `attractor/emit.ts` writes the value onto the exported node
498
+ * under Attractor's own `tool_command`. They have to spell it the same way and agree on
499
+ * what counts as empty, or a card is told it is complete and exports without a command, or
500
+ * told it is incomplete and exports with one. That is the accident `card/iteration-cap.ts`
501
+ * was extracted to end over the same bag, with three keys instead of one.
502
+ *
503
+ * Named here because this file owns the wire vocabulary, so there is one spelling to import
504
+ * rather than two literals to keep level.
505
+ */
506
+ export const TOOL_COMMAND_KEY = "tool_command";
507
+
508
+ /** "id@version" — how a DOT node pins the exact card it instantiates. */
509
+ export type CardRef = string;
510
+
511
+ /**
512
+ * Mirrors the §5 `card/bad-id` rule. Kept private: `validate.ts` owns the
513
+ * user-facing diagnostic, this file only needs it to reject malformed refs.
514
+ */
515
+ const CARD_ID = /^(?:[a-z0-9]+(?:-[a-z0-9]+)*\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
516
+
517
+ /**
518
+ * Loose version shape: starts with a digit, then semver-ish characters. Full semver
519
+ * validation belongs to `parseSemver`; refusing "latest" here is what makes §4's
520
+ * "always pin the exact version" checkable at the reference site.
521
+ */
522
+ const REF_VERSION = /^[0-9][0-9A-Za-z.+-]*$/;
523
+
524
+ /** Build the canonical "id@version" reference. Does not validate its arguments. */
525
+ export function cardRef(id: string, version: string): CardRef {
526
+ return `${id}@${version}`;
527
+ }
528
+
529
+ /**
530
+ * Split a reference back into its parts, or `undefined` when it is not a pinned
531
+ * reference at all — an unversioned "solver-a", a malformed id, "solver-a@latest".
532
+ * Surrounding whitespace is tolerated because refs arrive from hand-written DOT
533
+ * attributes; anything else is rejected rather than repaired.
534
+ */
535
+ export function parseCardRef(ref: string): { id: string; version: string } | undefined {
536
+ const trimmed = ref.trim();
537
+ const at = trimmed.lastIndexOf("@");
538
+ if (at <= 0 || at === trimmed.length - 1) return undefined;
539
+ const id = trimmed.slice(0, at);
540
+ const version = trimmed.slice(at + 1);
541
+ if (!CARD_ID.test(id) || !REF_VERSION.test(version)) return undefined;
542
+ return { id, version };
543
+ }
544
+ ```