yarramate 1.5.0 → 1.7.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.
Files changed (49) hide show
  1. package/dist/adapters/visual/session-server.js +27 -1
  2. package/dist/adapters/visual/workspace-model.d.ts +16 -3
  3. package/dist/adapters/visual/workspace-model.js +12 -5
  4. package/dist/ask-command.js +25 -16
  5. package/dist/catalogue-sources.d.ts +20 -0
  6. package/dist/catalogue-sources.js +39 -0
  7. package/dist/check-command.js +38 -9
  8. package/dist/cli-support.d.ts +8 -1
  9. package/dist/cli-support.js +5 -1
  10. package/dist/cli.d.ts +10 -0
  11. package/dist/cli.js +17 -1
  12. package/dist/design-command.js +21 -9
  13. package/dist/export-command.js +41 -5
  14. package/dist/import-command.d.ts +16 -0
  15. package/dist/import-command.js +156 -0
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/interrogate-command.d.ts +82 -1
  19. package/dist/interrogate-command.js +195 -29
  20. package/dist/interrogation-entry.d.ts +1 -1
  21. package/dist/interrogation-entry.js +1 -1
  22. package/dist/projection.d.ts +47 -0
  23. package/dist/projection.js +126 -0
  24. package/dist/visual-app-lib/editor.js +26771 -26713
  25. package/dist/visual-app-lib/types/adapters/visual/workspace-model.d.ts +16 -3
  26. package/dist/visual-app-lib/types/interrogate-command.d.ts +82 -1
  27. package/dist/visual-app-lib/types/projection.d.ts +47 -0
  28. package/dist/visual-app-lib/types/workspace.d.ts +15 -0
  29. package/dist/workbook-entry.d.ts +16 -0
  30. package/dist/workbook-entry.js +16 -0
  31. package/dist/workbook-import-entry.d.ts +30 -0
  32. package/dist/workbook-import-entry.js +30 -0
  33. package/dist/workbook-merge.d.ts +77 -0
  34. package/dist/workbook-merge.js +108 -0
  35. package/dist/workbook-operations.d.ts +9 -0
  36. package/dist/workbook-operations.js +139 -0
  37. package/dist/workbook-read.d.ts +43 -0
  38. package/dist/workbook-read.js +314 -0
  39. package/dist/workbook-xlsx.d.ts +54 -0
  40. package/dist/workbook-xlsx.js +228 -0
  41. package/dist/workbook.d.ts +32 -0
  42. package/dist/workbook.js +308 -0
  43. package/dist/workspace.d.ts +15 -0
  44. package/dist/workspace.js +1 -0
  45. package/docs/CONSUMING-YARRAMATE.md +127 -0
  46. package/package.json +9 -1
  47. package/schema/yarramate-interrogation-report.schema.json +9 -0
  48. package/schema/yarramate-question-catalogue.schema.json +2 -3
  49. package/schema/yarramate-workspace.schema.json +4 -0
@@ -0,0 +1,308 @@
1
+ import { writeXlsx } from './workbook-xlsx.js';
2
+ /**
3
+ * The model as a workbook an architect can work in (#355).
4
+ *
5
+ * The shape is borrowed from a real consulting workbook rather than invented:
6
+ * identity is columnar and readability is derived. A stable id in column A,
7
+ * foreign keys inline, and an adjacent `(auto)` column carrying the referenced
8
+ * subject's NAME so a sheet reads without hunting across tabs. The id is what
9
+ * round-trips; the `(auto)` column is a convenience and is ignored on import.
10
+ *
11
+ * Losslessness does not depend on this file enumerating every predicate
12
+ * correctly. Well-known predicates become columns because a person needs them
13
+ * to be columns; **everything else lands in `07 Other Facts` verbatim**, so a
14
+ * predicate added to the compiler after this was written still survives a
15
+ * round trip. A mapping that silently dropped what it did not recognise is the
16
+ * failure this design exists to avoid.
17
+ */
18
+ export const WORKBOOK_FORMAT = 'yarramate/workbook/v1';
19
+ /** Consumed into a column on `01 Concepts`, in this order. */
20
+ const CONCEPT_COLUMNS = [
21
+ ['yarramate/concept/kind', 'Kind'],
22
+ ['yarramate/concept/name', 'Name'],
23
+ ['yarramate/concept/description', 'Description'],
24
+ ['yarramate/lifecycle/status', 'Status'],
25
+ ['yarramate/ownership/owner', 'Owner'],
26
+ ['yarramate/organisation/folder', 'Folder'],
27
+ ];
28
+ /** Consumed into a column on `02 Relationships`, in this order. */
29
+ const RELATIONSHIP_COLUMNS = [
30
+ ['yarramate/relationship/name', 'Name'],
31
+ ['yarramate/relationship/description', 'Description'],
32
+ ['yarramate/access/mode', 'Mode'],
33
+ ['yarramate/flow/content', 'Content'],
34
+ ['yarramate/lifecycle/status', 'Status'],
35
+ ];
36
+ /**
37
+ * Consumed into a column on `03 States`.
38
+ *
39
+ * `concept/kind` is here as well as on `01 Concepts` because a state IS a
40
+ * concept: it carries an ordinary kind (`plateau`) alongside its state type
41
+ * (`baseline`, `transition`, `target`). Omitting it dropped four kind claims
42
+ * into the overflow sheet, which round-tripped correctly and read as though
43
+ * the mapping had not recognised them.
44
+ */
45
+ const STATE_COLUMNS = [
46
+ ['yarramate/concept/kind', 'Kind'],
47
+ ['yarramate/state/type', 'State kind'],
48
+ ['yarramate/concept/name', 'Name'],
49
+ ['yarramate/concept/description', 'Description'],
50
+ ];
51
+ /** Ref-valued predicates that get their own sheet rather than the overflow. */
52
+ const REFERENCE_PREDICATES = new Set([
53
+ 'yarramate/reference/refers-to',
54
+ 'yarramate/lineage/supersedes',
55
+ 'yarramate/lineage/supersedes-respect',
56
+ 'yarramate/identity/distinct-from',
57
+ 'yarramate/constraint/requires',
58
+ 'yarramate/constraint/expects',
59
+ ]);
60
+ const ALIAS_PREDICATE = 'yarramate/concept/alias';
61
+ const PRESENT_IN_PREDICATE = 'yarramate/state/present-in';
62
+ const STATE_TYPE_PREDICATE = 'yarramate/state/type';
63
+ const STATE_AFTER_PREDICATE = 'yarramate/state/after';
64
+ const KIND_PREDICATE = 'yarramate/concept/kind';
65
+ const NAME_PREDICATE = 'yarramate/concept/name';
66
+ const valueOf = (claim) => 'value' in claim.object ? claim.object.value : claim.object.ref;
67
+ const isRef = (claim) => 'ref' in claim.object;
68
+ /**
69
+ * A relationship IS a claim: its `id` is the relationship id, its `subject` is
70
+ * the source, its `predicate` is the kind and its object refs the target.
71
+ * Claims ABOUT a relationship carry that id as their own subject.
72
+ */
73
+ const relationshipClaims = (result) => {
74
+ const relationshipIds = new Set(result.subjects
75
+ .filter(({ type }) => type === 'relationship')
76
+ .map(({ id }) => id));
77
+ return result.claims.filter((claim) => relationshipIds.has(claim.id));
78
+ };
79
+ export const buildWorkbookSheets = (result, provenance) => {
80
+ const conceptIds = new Set(result.subjects.filter(({ type }) => type === 'concept').map(({ id }) => id));
81
+ const relationshipIds = new Set(result.subjects
82
+ .filter(({ type }) => type === 'relationship')
83
+ .map(({ id }) => id));
84
+ const bySubject = new Map();
85
+ for (const claim of result.claims) {
86
+ const held = bySubject.get(claim.subject);
87
+ if (held === undefined)
88
+ bySubject.set(claim.subject, [claim]);
89
+ else
90
+ held.push(claim);
91
+ }
92
+ const nameOf = (id) => {
93
+ const claim = bySubject
94
+ .get(id)
95
+ ?.find(({ predicate }) => predicate === NAME_PREDICATE);
96
+ return claim === undefined ? '' : valueOf(claim);
97
+ };
98
+ const first = (id, predicate) => {
99
+ const claim = bySubject.get(id)?.find((held) => held.predicate === predicate);
100
+ return claim === undefined ? '' : valueOf(claim);
101
+ };
102
+ /**
103
+ * The document a subject is declared in, as a PATH.
104
+ *
105
+ * Carried because an `apply` operation targets a document path, so without
106
+ * it a workbook can say what a subject is and not where it lives, and
107
+ * nothing can be written back. Every claim knows its own source, so this is
108
+ * recovered rather than guessed.
109
+ */
110
+ const documentOf = (id) => bySubject.get(id)?.[0]?.source.path ?? '';
111
+ const documentOfClaim = (claim) => claim.source.path;
112
+ // A state is an ordinary concept carrying state predicates, so it is lifted
113
+ // onto its own sheet rather than being a second kind of thing.
114
+ const stateIds = new Set([...conceptIds].filter((id) => bySubject
115
+ .get(id)
116
+ ?.some(({ predicate }) => predicate === STATE_TYPE_PREDICATE) === true));
117
+ /** Every claim a column or a named sheet has already carried. */
118
+ const consumed = new Set();
119
+ const consume = (id, predicate) => {
120
+ const claim = bySubject.get(id)?.find((held) => held.predicate === predicate);
121
+ if (claim !== undefined)
122
+ consumed.add(claim.id);
123
+ };
124
+ const conceptRows = [
125
+ ['Concept ID', ...CONCEPT_COLUMNS.map(([, label]) => label), 'Document'],
126
+ ];
127
+ for (const id of [...conceptIds].filter((one) => !stateIds.has(one)).sort()) {
128
+ for (const [predicate] of CONCEPT_COLUMNS)
129
+ consume(id, predicate);
130
+ conceptRows.push([
131
+ id,
132
+ ...CONCEPT_COLUMNS.map(([predicate]) => first(id, predicate)),
133
+ documentOf(id),
134
+ ]);
135
+ }
136
+ const relationshipRows = [
137
+ [
138
+ 'Relationship ID',
139
+ 'Kind',
140
+ 'From',
141
+ '↳ From name (auto)',
142
+ 'To',
143
+ '↳ To name (auto)',
144
+ ...RELATIONSHIP_COLUMNS.map(([, label]) => label),
145
+ 'Document',
146
+ ],
147
+ ];
148
+ for (const claim of [...relationshipClaims(result)].sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0)) {
149
+ consumed.add(claim.id);
150
+ for (const [predicate] of RELATIONSHIP_COLUMNS)
151
+ consume(claim.id, predicate);
152
+ const target = 'ref' in claim.object ? claim.object.ref : '';
153
+ relationshipRows.push([
154
+ claim.id,
155
+ claim.predicate,
156
+ claim.subject,
157
+ nameOf(claim.subject),
158
+ target,
159
+ nameOf(target),
160
+ ...RELATIONSHIP_COLUMNS.map(([predicate]) => first(claim.id, predicate)),
161
+ documentOfClaim(claim),
162
+ ]);
163
+ }
164
+ const stateRows = [
165
+ [
166
+ 'State ID',
167
+ ...STATE_COLUMNS.map(([, label]) => label),
168
+ 'After',
169
+ '↳ After name (auto)',
170
+ 'Document',
171
+ ],
172
+ ];
173
+ for (const id of [...stateIds].sort()) {
174
+ for (const [predicate] of STATE_COLUMNS)
175
+ consume(id, predicate);
176
+ consume(id, STATE_AFTER_PREDICATE);
177
+ const after = first(id, STATE_AFTER_PREDICATE);
178
+ stateRows.push([
179
+ id,
180
+ ...STATE_COLUMNS.map(([predicate]) => first(id, predicate)),
181
+ after,
182
+ nameOf(after),
183
+ documentOf(id),
184
+ ]);
185
+ }
186
+ const presentInRows = [
187
+ ['Subject ID', '↳ Subject name (auto)', 'State ID', '↳ State name (auto)'],
188
+ ];
189
+ const referenceRows = [
190
+ ['Subject ID', '↳ Subject name (auto)', 'Predicate', 'Target ID', '↳ Target name (auto)'],
191
+ ];
192
+ const aliasRows = [['Subject ID', '↳ Subject name (auto)', 'Also known as']];
193
+ const otherRows = [['Subject ID', 'Predicate', 'Value', 'Target ID']];
194
+ for (const claim of result.claims) {
195
+ if (consumed.has(claim.id))
196
+ continue;
197
+ if (relationshipIds.has(claim.id))
198
+ continue;
199
+ if (claim.predicate === PRESENT_IN_PREDICATE) {
200
+ const state = valueOf(claim);
201
+ presentInRows.push([claim.subject, nameOf(claim.subject), state, nameOf(state)]);
202
+ continue;
203
+ }
204
+ if (claim.predicate === ALIAS_PREDICATE) {
205
+ aliasRows.push([claim.subject, nameOf(claim.subject), valueOf(claim)]);
206
+ continue;
207
+ }
208
+ if (REFERENCE_PREDICATES.has(claim.predicate)) {
209
+ const target = valueOf(claim);
210
+ referenceRows.push([
211
+ claim.subject,
212
+ nameOf(claim.subject),
213
+ claim.predicate,
214
+ target,
215
+ nameOf(target),
216
+ ]);
217
+ continue;
218
+ }
219
+ // The overflow, and the reason a round trip stays lossless when the
220
+ // compiler grows a predicate this file has never heard of.
221
+ otherRows.push([
222
+ claim.subject,
223
+ claim.predicate,
224
+ isRef(claim) ? '' : valueOf(claim),
225
+ isRef(claim) ? valueOf(claim) : '',
226
+ ]);
227
+ }
228
+ const readMe = [
229
+ ['YarraMate workbook'],
230
+ [],
231
+ ['Workspace', provenance.workspace],
232
+ ['Projection', result.projection],
233
+ ['Built by', `yarramate ${provenance.yarramateVersion}`],
234
+ [],
235
+ ['How to use this'],
236
+ ['1.', 'Edit the numbered sheets. Column A is the identity of the row and is what the model is keyed on.'],
237
+ ['2.', 'A column marked (auto) is derived for readability. Edits to it are ignored.'],
238
+ ['3.', 'Add a row with a new ID to add a subject. Kind and Status are dropdowns, and Document says which file it belongs to.'],
239
+ ['4.', 'Deleting a row does NOT delete anything. Removals are reported, never applied.'],
240
+ ['5.', 'Send the file back and import it. Your edits are merged; only a field changed on both sides is refused.'],
241
+ [],
242
+ [],
243
+ ['What this holds, and what it does not'],
244
+ [
245
+ '',
246
+ `A projection is a QUERY, so this workbook is the slice it names: ${conceptRows.length - 1} concepts and ${relationshipRows.length - 1} relationships.`,
247
+ ],
248
+ [
249
+ '',
250
+ 'Everything the model knows about a subject that IS here is here, including anything these columns do not model, on 07 Other Facts.',
251
+ ],
252
+ [
253
+ '',
254
+ 'A subject outside the slice has no row, and importing this file cannot change it. A narrow workbook is safe against a wide model.',
255
+ ],
256
+ [],
257
+ ['Sheets beginning ~ are machinery. Do not edit them.'],
258
+ ];
259
+ const listColumns = [
260
+ ['Concept kind', ...provenance.conceptKinds],
261
+ ['Relationship kind', ...provenance.relationshipKinds],
262
+ ['Status', ...provenance.statuses],
263
+ ];
264
+ const listHeight = Math.max(...listColumns.map((column) => column.length));
265
+ const listRows = [];
266
+ for (let row = 0; row < listHeight; row += 1) {
267
+ listRows.push(listColumns.map((column) => column[row] ?? ''));
268
+ }
269
+ const metaRows = [
270
+ ['Key', 'Value'],
271
+ ['Format', WORKBOOK_FORMAT],
272
+ ['Workspace', provenance.workspace],
273
+ ['Projection', result.projection],
274
+ ['YarraMate version', provenance.yarramateVersion],
275
+ ...Object.entries(provenance.sourceDigests)
276
+ .sort(([left], [right]) => (left < right ? -1 : 1))
277
+ .map(([path, digest]) => [`Digest ${path}`, digest]),
278
+ ];
279
+ const working = [
280
+ { name: '00 Read Me', rows: readMe },
281
+ { name: '01 Concepts', rows: conceptRows, frozenRows: 1 },
282
+ { name: '02 Relationships', rows: relationshipRows, frozenRows: 1 },
283
+ { name: '03 States', rows: stateRows, frozenRows: 1 },
284
+ { name: '04 Present In', rows: presentInRows, frozenRows: 1 },
285
+ { name: '05 References', rows: referenceRows, frozenRows: 1 },
286
+ { name: '06 Aliases', rows: aliasRows, frozenRows: 1 },
287
+ { name: '07 Other Facts', rows: otherRows, frozenRows: 1 },
288
+ ];
289
+ // The merge ancestor. Never edited, never updated: it records what the model
290
+ // looked like when this workbook was made, so import can tell an FDE's edit
291
+ // from a change the repository made underneath since. Each row carries its
292
+ // sheet name so one sheet can hold them all.
293
+ const baselineRows = [['Sheet', 'Cells...']];
294
+ for (const sheet of working) {
295
+ if (sheet.name === '00 Read Me')
296
+ continue;
297
+ for (const row of sheet.rows)
298
+ baselineRows.push([sheet.name, ...row]);
299
+ }
300
+ return [
301
+ ...working,
302
+ { name: '~Lists', state: 'hidden', rows: listRows },
303
+ { name: '~Meta', state: 'hidden', rows: metaRows },
304
+ { name: '~Baseline', state: 'veryHidden', rows: baselineRows },
305
+ ];
306
+ };
307
+ /** The workbook, as bytes. Synchronous and deterministic. */
308
+ export const workbookFrom = (result, provenance) => writeXlsx(buildWorkbookSheets(result, provenance));
@@ -7,6 +7,7 @@ export interface WorkspaceManifest {
7
7
  readonly projections: readonly string[];
8
8
  readonly adapterMappings: readonly string[];
9
9
  readonly patterns?: readonly string[];
10
+ readonly questions?: readonly string[];
10
11
  readonly evidence?: readonly string[];
11
12
  readonly contracts?: readonly string[];
12
13
  }
@@ -17,6 +18,20 @@ export interface ResolvedWorkspace {
17
18
  readonly projections: readonly string[];
18
19
  readonly adapterMappings: readonly string[];
19
20
  readonly patterns: readonly string[];
21
+ /**
22
+ * Question catalogues the workspace itself carries (#345, ADR 0129).
23
+ * ADDITIVE to the shipped catalogue: `--catalogue` replaces the base, this
24
+ * adds to it, which is what lets a consultant author a question mid
25
+ * engagement without a product release.
26
+ *
27
+ * OPTIONAL in the type although `loadWorkspaceManifest` always populates it,
28
+ * and that is deliberate. `ResolvedWorkspace` is published, and adding
29
+ * `patterns` to it as a required field broke a consumer's production module:
30
+ * a required field is free for readers and a break for CONSTRUCTORS. Six
31
+ * fixtures in this repository construct one, which is the same signal from
32
+ * inside. Read it as `workspace.questions ?? []`.
33
+ */
34
+ readonly questions?: readonly string[];
20
35
  readonly evidence: readonly string[];
21
36
  readonly contracts: readonly string[];
22
37
  }
package/dist/workspace.js CHANGED
@@ -107,6 +107,7 @@ export function loadWorkspaceManifest(source, cwd) {
107
107
  projections: expand('projections', 'projection', value.projections),
108
108
  adapterMappings: expand('adapterMappings', 'adapter mapping', value.adapterMappings),
109
109
  patterns: expand('patterns', 'pattern', value.patterns ?? []),
110
+ questions: expand('questions', 'question catalogue', value.questions ?? []),
110
111
  evidence: expand('evidence', 'evidence', value.evidence ?? []),
111
112
  contracts: expand('contracts', 'Core contract', value.contracts ?? []),
112
113
  };
@@ -408,6 +408,133 @@ socket/session host.
408
408
 
409
409
  [...]
410
410
 
411
+ ## The model as an Excel workbook
412
+
413
+ `yarramate export xlsx <projection.yaml> <workspace.yaml> --out <file>` writes
414
+ a workbook an architect or FDE can work in, and `yarramate/workbook` publishes
415
+ the writer for a host that has no Node:
416
+
417
+ ```ts
418
+ import { workbookFrom } from 'yarramate/workbook'
419
+
420
+ const bytes = workbookFrom(projectionResult, {
421
+ workspace: 'acme',
422
+ yarramateVersion: '1.5.0',
423
+ sourceDigests,
424
+ conceptKinds,
425
+ relationshipKinds,
426
+ statuses: ['planned', 'current', 'retired'],
427
+ })
428
+ ```
429
+
430
+ Synchronous, dependency-free and deterministic: the same input always produces
431
+ the same bytes. The import graph is held free of Node builtins and of the
432
+ compiler at runtime, so it fits a Cloudflare Worker or a Durable Object. A
433
+ caller hands over an already-evaluated `ProjectionResult`, which is what keeps
434
+ schema validation out of that graph.
435
+
436
+ **It takes a projection**, so it inherits every facet a projection query has.
437
+ That is how a caller chooses which *version* to export: a query naming
438
+ `states` produces a workbook of that architecture state, with no separate
439
+ flag.
440
+
441
+ ```yaml
442
+ query:
443
+ states:
444
+ - target-state
445
+ ```
446
+
447
+ **Which makes a workbook a SLICE, and it is worth being plain about that.** An
448
+ omitted facet imposes no constraint, so `query: {}` exports the whole model,
449
+ and every facet you name narrows it. For a subject the query selected, nothing
450
+ is dropped: the projection filters claims by subject membership rather than by
451
+ predicate, so every fact about an included subject arrives, and `07 Other
452
+ Facts` catches whatever the named columns do not model. For a subject it did
453
+ not select, there is simply no row, and a missing row is never a deletion, so
454
+ **a narrow workbook cannot damage a wide model on the way back in**. The
455
+ `00 Read Me` sheet says all of this and reports the counts the slice actually
456
+ has, because an FDE handed a filtered workbook with no note will read it as
457
+ the model.
458
+
459
+ **Reading the sheets.** Column A is always the id and is what the model is
460
+ keyed on. A column headed `↳ … (auto)` is derived for readability and is
461
+ ignored on the way back. Kind and status columns are drawn from the compiled
462
+ profile, so they carry the vocabulary that workspace actually has.
463
+
464
+ Sheets beginning `~` are machinery. `~Meta` records the format, the versions
465
+ and the source digests. `~Baseline` is a hidden copy of the working rows
466
+ exactly as exported, and exists so a later import can tell an author's edit
467
+ from a change the repository made underneath. Neither is edited.
468
+
469
+ Anything the mapping does not recognise is carried verbatim on `07 Other
470
+ Facts` rather than dropped, so a workbook stays lossless across a compiler
471
+ that grows new predicates.
472
+
473
+ **Reading one back.** `yarramate import xlsx <workbook.xlsx> <workspace.yaml>`
474
+ merges an edited workbook into the model. An unedited round trip changes
475
+ nothing, byte for byte.
476
+
477
+ Edits land as `yarramate/operations/v1` through `apply`, so untouched YAML
478
+ keeps its comments, key order and formatting, and the import passes the same
479
+ atomic gate every other write does.
480
+
481
+ It is a three-way merge. `~Baseline` is the ancestor, so an edit the workspace
482
+ did not touch merges even if the repository moved on; only a field changed on
483
+ **both** sides is refused, and a refusal writes nothing anywhere. A missing row
484
+ is reported and never treated as a deletion, and a new row needs its `Document`
485
+ column filled in.
486
+
487
+ **On a host with no Node**, `yarramate/workbook/import` publishes the same
488
+ three steps the CLI runs, so ingesting a workbook is not CLI-only:
489
+
490
+ ```ts
491
+ import {
492
+ readWorkbook,
493
+ baselineSheets,
494
+ mergeWorkbook,
495
+ operationsFrom,
496
+ operationsDocument,
497
+ } from 'yarramate/workbook/import'
498
+
499
+ const read = await readWorkbook(bytes)
500
+ if (!read.ok) return refuse(read.reason)
501
+
502
+ const ancestor = read.sheets.get('~Baseline')
503
+ if (ancestor === undefined) return refuse('not produced by yarramate export xlsx')
504
+
505
+ const report = mergeWorkbook(
506
+ read.sheets,
507
+ baselineSheets(ancestor),
508
+ buildWorkbookSheets(currentResult, provenance),
509
+ )
510
+ if (report.conflicts.length > 0) return refuse(report.conflicts)
511
+
512
+ const { operations, refusals } = operationsFrom(report, read.sheets)
513
+ await apply(operationsDocument(operations))
514
+ ```
515
+
516
+ `readWorkbook` is **async** while the writer is synchronous, and deliberately
517
+ so: a workbook this package wrote has stored entries and inline strings, but
518
+ one a person saved from Excel comes back deflated and shared-stringed, and
519
+ inflation is only offered as a stream (`DecompressionStream('deflate-raw')`,
520
+ present on Workers, in browsers and in Node 18+). Keeping the writer
521
+ synchronous is what stops it infecting a synchronous store on the host side.
522
+
523
+ It is a **separate subpath** from `yarramate/workbook` on purpose. The package
524
+ declares no `sideEffects` field, so a bundler must assume every module might
525
+ have one and cannot shake an unused re-export away; a host that only generates
526
+ workbooks would otherwise carry the reader, the merge and the operations
527
+ emitter it never calls. `test/export-purity.test.ts` holds the two entries
528
+ disjoint as well as pure.
529
+
530
+ The host keeps three jobs: reading the bytes, evaluating the projection that
531
+ says what the model holds **now** (the third argument to `mergeWorkbook`, which
532
+ is what measures repo drift in the same terms the author edited in), and
533
+ applying the operations. Refusal wording is the host's too. The `Conflict`
534
+ values name the sheet, row, column, what the author wrote, what the workspace
535
+ now holds and their common ancestor, which reads like a merge tool rather than
536
+ like something you hand a consultant.
537
+
411
538
  ## MCP server for agent harnesses
412
539
 
413
540
  Harnesses that load MCP servers can connect the bundled read-only adapter:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarramate",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Tool-neutral semantic architecture engine and guided methodology",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,6 +58,14 @@
58
58
  "types": "./dist/interrogation-entry.d.ts",
59
59
  "import": "./dist/interrogation-entry.js"
60
60
  },
61
+ "./workbook": {
62
+ "types": "./dist/workbook-entry.d.ts",
63
+ "import": "./dist/workbook-entry.js"
64
+ },
65
+ "./workbook/import": {
66
+ "types": "./dist/workbook-import-entry.d.ts",
67
+ "import": "./dist/workbook-import-entry.js"
68
+ },
61
69
  "./visual-app": {
62
70
  "types": "./dist/visual-app-lib/editor.d.ts",
63
71
  "import": "./dist/visual-app-lib/editor.js"
@@ -24,6 +24,15 @@
24
24
  "type": "string",
25
25
  "minLength": 1
26
26
  },
27
+ "catalogues": {
28
+ "description": "Every catalogue that contributed, as id@version, base first (#345, ADR 0129). Present only when more than one did. OPTIONAL, and `catalogue` keeps naming the base with its existing value shape, because turning that field into an array would break every reader: an optional additive field is free for readers and does not force constructors. Question ids in this report are qualified as catalogue#question and carry NO version, so a catalogue version bump strands no stored judgment keyed on them; the versions live here instead.",
29
+ "type": "array",
30
+ "minItems": 1,
31
+ "items": {
32
+ "type": "string",
33
+ "minLength": 1
34
+ }
35
+ },
27
36
  "semantics": {
28
37
  "type": "string",
29
38
  "minLength": 1,
@@ -44,9 +44,9 @@
44
44
  }
45
45
  },
46
46
  "waves": {
47
- "description": "Ordered interview phases. Array order is interview order. The wave taxonomy is catalogue opinion, not engine semantics.",
47
+ "description": "Ordered interview phases. Array order is interview order. The wave taxonomy is catalogue opinion, not engine semantics. MAY BE EMPTY (#345, ADR 0129): a catalogue that only contributes questions to waves ANOTHER catalogue declared is the ordinary shape of a project catalogue, and requiring a wave here would force it to declare one it does not want - which is refused as a duplicate the moment two catalogues do it. Evaluated alone, a catalogue with no waves and a question naming one is YM911, correctly: nothing declares it.",
48
48
  "type": "array",
49
- "minItems": 1,
49
+ "minItems": 0,
50
50
  "items": {
51
51
  "type": "object",
52
52
  "additionalProperties": false,
@@ -112,7 +112,6 @@
112
112
  "description": "Which subjects a subject-scope question applies to. Field vocabulary mirrors the projection query selector (ADR 0017).",
113
113
  "type": "object",
114
114
  "additionalProperties": false,
115
-
116
115
  "properties": {
117
116
  "kinds": {
118
117
  "type": "array",
@@ -32,6 +32,10 @@
32
32
  "description": "yarramate/pattern/v1 documents: the shape a concept kind promises, and the wiring the compiler mints between an instance and the parts it binds (ADR 0123). Structure, where profiles declare vocabulary. Optional, like evidence and contracts.",
33
33
  "$ref": "#/$defs/patterns"
34
34
  },
35
+ "questions": {
36
+ "description": "yarramate/question-catalogue/v1 documents the WORKSPACE carries: questions true of this engagement and nowhere else (#345, ADR 0129). ADDITIVE to the shipped catalogue rather than replacing it, which is what --catalogue and MountOptions.catalogue do. Question ids are qualified as catalogue#question on the way out, so two catalogues may carry the same local id. A wave is declared exactly once across the resolved set, and any catalogue may contribute questions to a wave it did not declare. Optional, like patterns, evidence and contracts.",
37
+ "$ref": "#/$defs/patterns"
38
+ },
35
39
  "adapterMappings": {
36
40
  "$ref": "#/$defs/patterns"
37
41
  },