yarramate 0.13.0 → 0.14.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.
package/README.md CHANGED
@@ -5,6 +5,8 @@
5
5
  [![CodeQL](https://github.com/yarrasys/yarramate/actions/workflows/codeql.yml/badge.svg)](https://github.com/yarrasys/yarramate/actions/workflows/codeql.yml)
6
6
  [![license: MIT](https://img.shields.io/github/license/yarrasys/yarramate)](LICENSE)
7
7
 
8
+ Product site: **[yarramate.dev](https://yarramate.dev)**
9
+
8
10
  YarraMate keeps your coding agents' architecture context correct. Declare
9
11
  the design once, as a checked model in git; agents receive prose rendered
10
12
  from it — bounded briefs and open design questions — and the CLI
@@ -1,7 +1,6 @@
1
1
  import Ajv2020Module from 'ajv/dist/2020.js';
2
2
  import { diagnosticOrder, loadSourceDocument, locateSourcePath, } from './source-document.js';
3
- import adapterMappingSchema from '../schema/yarramate-adapter-mapping.schema.json' with {
4
- type: 'json'
3
+ import adapterMappingSchema from '../schema/yarramate-adapter-mapping.schema.json' with { type: 'json'
5
4
  };
6
5
  const Ajv2020 = Ajv2020Module.default;
7
6
  const validateSchema = new Ajv2020({ allErrors: true }).compile(adapterMappingSchema);
@@ -12,11 +12,9 @@ import { adapterMappingLocation, loadAdapterMapping, validateAdapterMapping, } f
12
12
  import { locateSourcePath } from '../source-document.js';
13
13
  import { prepareLikeC4Export, } from './likec4-prepare.js';
14
14
  import { exportLikeC4Project, loadLikeC4ProjectDefinition, } from './likec4-project.js';
15
- import generatedProjectSchema from '../../schema/yarramate-likec4-generated-project.schema.json' with {
16
- type: 'json'
15
+ import generatedProjectSchema from '../../schema/yarramate-likec4-generated-project.schema.json' with { type: 'json'
17
16
  };
18
- import generatedProjectV2Schema from '../../schema/yarramate-likec4-generated-project-v2.schema.json' with {
19
- type: 'json'
17
+ import generatedProjectV2Schema from '../../schema/yarramate-likec4-generated-project-v2.schema.json' with { type: 'json'
20
18
  };
21
19
  const Ajv2020 = Ajv2020Module.default;
22
20
  const validateGeneratedProjectMarker = new Ajv2020({
@@ -1,7 +1,6 @@
1
1
  import Ajv2020Module from 'ajv/dist/2020.js';
2
2
  import { diagnosticOrder, loadSourceDocument, locateSourcePath, } from '../source-document.js';
3
- import kindMappingSchema from '../../schema/yarramate-likec4-kind-mapping.schema.json' with {
4
- type: 'json'
3
+ import kindMappingSchema from '../../schema/yarramate-likec4-kind-mapping.schema.json' with { type: 'json'
5
4
  };
6
5
  const Ajv2020 = Ajv2020Module.default;
7
6
  const validateSchema = new Ajv2020({ allErrors: true }).compile(kindMappingSchema);
@@ -1,8 +1,7 @@
1
1
  import Ajv2020Module from 'ajv/dist/2020.js';
2
2
  import { loadSourceDocument } from '../source-document.js';
3
3
  import { exportLikeC4, } from './likec4-export.js';
4
- import likeC4ProjectSchema from '../../schema/yarramate-likec4-project.schema.json' with {
5
- type: 'json'
4
+ import likeC4ProjectSchema from '../../schema/yarramate-likec4-project.schema.json' with { type: 'json'
6
5
  };
7
6
  const Ajv2020 = Ajv2020Module.default;
8
7
  const validateLikeC4Project = new Ajv2020({ allErrors: true }).compile(likeC4ProjectSchema);
@@ -6,8 +6,7 @@ import { diagnosticJson, humanDiagnostics, usage, } from './cli-support.js';
6
6
  import { compileWorkspace } from './compiler.js';
7
7
  import { loadSourceDocument, locateSourcePath, } from './source-document.js';
8
8
  import { loadWorkspaceManifest } from './workspace.js';
9
- import operationsSchema from '../schema/yarramate-operations.schema.json' with {
10
- type: 'json'
9
+ import operationsSchema from '../schema/yarramate-operations.schema.json' with { type: 'json'
11
10
  };
12
11
  const Ajv2020 = Ajv2020Module.default;
13
12
  const validateOperations = new Ajv2020({ allErrors: true }).compile(operationsSchema);
@@ -114,12 +113,15 @@ const itemMap = (source, collection, id) => {
114
113
  const pair = pairFor(root, collection);
115
114
  if (pair === undefined || !isSeq(pair.value))
116
115
  return undefined;
117
- const found = pair.value.items.find((candidate) => isMap(candidate) &&
116
+ const sequence = pair.value;
117
+ const found = sequence.items.find((candidate) => isMap(candidate) &&
118
118
  candidate.items.some((field) => isScalar(field.key) &&
119
119
  field.key.value === 'id' &&
120
120
  isScalar(field.value) &&
121
121
  field.value.value === id));
122
- return found === undefined ? undefined : { map: found };
122
+ return found === undefined
123
+ ? undefined
124
+ : { map: found, sequence };
123
125
  };
124
126
  // The indent item fields sit at, read off the item's own first field.
125
127
  const fieldIndentOf = (source, map) => indentAt(source, nodeRange(map.items[0].key)[0]);
@@ -208,6 +210,40 @@ const removeField = (source, map, key) => {
208
210
  : nodeRange(pair.value)[2];
209
211
  return splice(source, start, lineEndAfter(source, valueEnd), '');
210
212
  };
213
+ // Whole-subject deletion (#123): remove the exact authored item range,
214
+ // marker line included. Unlike field removal — where line deletion on a
215
+ // flow item would silently take the whole item (the 0.8.1 regression) —
216
+ // deleting the whole item is precisely the intent here, so flow-style
217
+ // items in a block sequence share the line-based path. An item inside a
218
+ // flow collection rewrites the collection value instead, and removing
219
+ // the last item leaves an explicit empty collection: the document
220
+ // schema requires the key.
221
+ const removeCollectionItem = (source, collection, id) => {
222
+ const { map, sequence } = itemMap(source, collection, id);
223
+ if (sequence.flow) {
224
+ const remaining = sequence.toJSON().filter((item) => item.id !== id);
225
+ const [start, valueEnd] = nodeRange(sequence);
226
+ return splice(source, start, valueEnd, remaining.length === 0
227
+ ? '[]'
228
+ : stringify(remaining, {
229
+ collectionStyle: 'flow',
230
+ lineWidth: 0,
231
+ }).trimEnd());
232
+ }
233
+ // Node ranges may extend past the trailing newline to the next line
234
+ // start; afterContentLine anchors the removal on the item's own last
235
+ // content line.
236
+ const end = afterContentLine(source, nodeRange(map)[2]);
237
+ if (sequence.items.length === 1) {
238
+ let valueStart = nodeRange(sequence)[0];
239
+ while (valueStart > 0 &&
240
+ (source[valueStart - 1] === ' ' || source[valueStart - 1] === '\n')) {
241
+ valueStart -= 1;
242
+ }
243
+ return splice(source, valueStart, end, ' []\n');
244
+ }
245
+ return splice(source, lineStartOf(source, nodeRange(map)[0]), end, '');
246
+ };
211
247
  // ---------------------------------------------------------------------------
212
248
  export function runApplyCommand(options, cwd) {
213
249
  const json = options.includes('--json');
@@ -253,16 +289,20 @@ export function runApplyCommand(options, cwd) {
253
289
  addedRelationships: 0,
254
290
  updatedConcepts: 0,
255
291
  updatedRelationships: 0,
292
+ deletedConcepts: 0,
293
+ deletedRelationships: 0,
256
294
  };
295
+ const deletions = [];
296
+ const locateOperation = (index, message) => ({
297
+ severity: 'error',
298
+ code: 'YM912',
299
+ message,
300
+ ...locateSourcePath(operationsPath, yaml, lineCounter, ['operations', index, 'document'], `/operations/${index}/document`),
301
+ });
257
302
  for (const [index, operation] of operations.entries()) {
258
303
  const absolute = resolve(cwd, operation.document);
259
304
  const manifestPath = workspaceDocuments.get(absolute);
260
- const locate = (message) => ({
261
- severity: 'error',
262
- code: 'YM912',
263
- message,
264
- ...locateSourcePath(operationsPath, yaml, lineCounter, ['operations', index, 'document'], `/operations/${index}/document`),
265
- });
305
+ const locate = (message) => locateOperation(index, message);
266
306
  if (manifestPath === undefined) {
267
307
  return failed([
268
308
  locate(`Operation ${index} targets "${operation.document}", which is not a document of workspace "${workspace.id}"`),
@@ -280,6 +320,26 @@ export function runApplyCommand(options, cwd) {
280
320
  source = appendCollectionItem(source, 'relationships', operation.relationship);
281
321
  counts.addedRelationships += 1;
282
322
  }
323
+ else if (operation.op === 'delete-concept' ||
324
+ operation.op === 'delete-relationship') {
325
+ const collection = operation.op === 'delete-concept' ? 'concepts' : 'relationships';
326
+ const id = operation.op === 'delete-concept'
327
+ ? operation.concept.id
328
+ : operation.relationship.id;
329
+ if (itemMap(source, collection, id) === undefined) {
330
+ return failed([
331
+ locate(`Operation ${index} deletes "${id}", which does not exist in ${operation.document}`),
332
+ ]);
333
+ }
334
+ source = removeCollectionItem(source, collection, id);
335
+ deletions.push({ index, absolute, id });
336
+ if (operation.op === 'delete-concept') {
337
+ counts.deletedConcepts += 1;
338
+ }
339
+ else {
340
+ counts.deletedRelationships += 1;
341
+ }
342
+ }
283
343
  else {
284
344
  const collection = operation.op === 'update-concept' ? 'concepts' : 'relationships';
285
345
  const payload = (operation.op === 'update-concept'
@@ -334,6 +394,92 @@ export function runApplyCommand(options, cwd) {
334
394
  }
335
395
  candidates.set(absolute, source);
336
396
  }
397
+ // Reference integrity for deletes (#123), evaluated against the
398
+ // post-batch state: stage everything first, then look, so a concept
399
+ // deleted together with its referring relationships in one batch
400
+ // succeeds while a target anything still points at rejects the
401
+ // whole batch. Referring sites are relationship endpoints, owner,
402
+ // constraint and identified references; projection selectors are
403
+ // deliberately unchecked — they tolerate no-match by design. The
404
+ // compile gate below stays the backstop.
405
+ if (deletions.length > 0) {
406
+ const staged = workspace.documents.map((path) => {
407
+ const absolute = resolve(cwd, path);
408
+ return {
409
+ absolute,
410
+ value: parseDocument(candidates.get(absolute) ?? readFileSync(absolute, 'utf8')).toJSON(),
411
+ };
412
+ });
413
+ const qualify = (documentId, reference) => reference.includes('#') ? reference : `${documentId}#${reference}`;
414
+ const referrers = staged.flatMap(({ value }) => {
415
+ const documentId = value?.id;
416
+ if (documentId === undefined)
417
+ return [];
418
+ const sites = [];
419
+ for (const concept of value?.concepts ?? []) {
420
+ const subject = `${documentId}#${concept.id}`;
421
+ if (concept.owner !== undefined) {
422
+ sites.push({
423
+ ref: qualify(documentId, concept.owner),
424
+ subject,
425
+ field: 'owner',
426
+ });
427
+ }
428
+ for (const constraint of concept.constraints ?? []) {
429
+ sites.push({
430
+ ref: qualify(documentId, constraint.ref),
431
+ subject,
432
+ field: 'constraints',
433
+ });
434
+ }
435
+ for (const reference of concept.references ?? []) {
436
+ sites.push({
437
+ ref: qualify(documentId, reference.ref),
438
+ subject,
439
+ field: 'references',
440
+ });
441
+ }
442
+ }
443
+ for (const relationship of value?.relationships ?? []) {
444
+ const subject = `${documentId}#${relationship.id}`;
445
+ for (const endpoint of ['from', 'to']) {
446
+ const reference = relationship[endpoint];
447
+ if (reference !== undefined) {
448
+ sites.push({
449
+ ref: qualify(documentId, reference),
450
+ subject,
451
+ field: endpoint,
452
+ });
453
+ }
454
+ }
455
+ for (const reference of relationship.references ?? []) {
456
+ sites.push({
457
+ ref: qualify(documentId, reference.ref),
458
+ subject,
459
+ field: 'references',
460
+ });
461
+ }
462
+ }
463
+ return sites;
464
+ });
465
+ const documentIds = new Map(staged.map(({ absolute, value }) => [absolute, value?.id]));
466
+ const violations = deletions.flatMap((deletion) => {
467
+ const documentId = documentIds.get(deletion.absolute);
468
+ if (documentId === undefined)
469
+ return [];
470
+ const target = `${documentId}#${deletion.id}`;
471
+ const referring = referrers.filter((site) => site.ref === target);
472
+ if (referring.length === 0)
473
+ return [];
474
+ return [
475
+ locateOperation(deletion.index, `Operation ${deletion.index} deletes "${deletion.id}", which is still referenced by ${referring
476
+ .map((site) => `"${site.subject}" (${site.field})`)
477
+ .join(', ')}`),
478
+ ];
479
+ });
480
+ if (violations.length > 0)
481
+ return failed(violations);
482
+ }
337
483
  // The atomic gate: the whole candidate workspace must compile before a
338
484
  // single byte is written; any diagnostic rejects the entire batch.
339
485
  const compilation = compileWorkspace([...workspace.profiles, ...workspace.documents].map((path) => {
@@ -366,7 +512,9 @@ export function runApplyCommand(options, cwd) {
366
512
  const applied = counts.addedConcepts +
367
513
  counts.addedRelationships +
368
514
  counts.updatedConcepts +
369
- counts.updatedRelationships;
515
+ counts.updatedRelationships +
516
+ counts.deletedConcepts +
517
+ counts.deletedRelationships;
370
518
  return {
371
519
  exitCode: 0,
372
520
  stdout: `Applied ${applied} operation${applied === 1 ? '' : 's'} to ${touched.join(', ')}\n`,
@@ -3,7 +3,7 @@ import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { parseDocument } from 'yaml';
5
5
  import { compareArchitectureStates, } from './architecture-state.js';
6
- import { renderBrief } from './brief.js';
6
+ import { coreLocalKind, renderBrief } from './brief.js';
7
7
  import { deriveChangedSubjects } from './changed.js';
8
8
  import { runCheckCommand } from './check-command.js';
9
9
  import { diagnosticJson, humanDiagnostics, usage, } from './cli-support.js';
@@ -105,18 +105,118 @@ const plannedLines = (subjects) => {
105
105
  return ` ${subject.id.padEnd(width)} ${clauses.join('; ')}`;
106
106
  });
107
107
  };
108
+ // On a dense graph, hub seeds make 1-hop connected expansion reach most
109
+ // of the model (a focused ask on a 248-concept model reached 243). The
110
+ // cap keeps each seed's most material neighbours and announces the rest
111
+ // (ADR 0070); --neighbours overrides it, 0 lifts it.
112
+ const defaultNeighbourCap = 12;
113
+ const motivationKindIds = new Set(conceptKinds
114
+ .filter(({ layer }) => layer === 'motivation')
115
+ .map(({ id }) => id));
116
+ // Neighbours rank by the same reading the brief ranks paragraphs with
117
+ // under a budget (ADR 0042/0055): motivation first, then planned,
118
+ // current, retired. Ties break on seed affinity (a neighbour touching
119
+ // more seeds is more material to the slice), then id — deterministic.
120
+ const materialityRank = (graph, profileContext, id) => {
121
+ const kind = claimValue(graph.claims, id, 'yarramate/concept/kind');
122
+ const core = kind === undefined
123
+ ? undefined
124
+ : coreLocalKind(kind, profileContext?.conceptKindLineages);
125
+ if (core !== undefined && motivationKindIds.has(core))
126
+ return 0;
127
+ const status = claimValue(graph.claims, id, 'yarramate/lifecycle/status');
128
+ return status === 'planned' ? 1 : status === 'retired' ? 3 : 2;
129
+ };
108
130
  // The one-hop connected neighbourhood every slice and advice mode uses:
109
131
  // the same machinery context --subject exposed, now seeded by matching.
110
- const sliceProjection = (graph, seeds, title, profileContext) => evaluateProjection(graph, {
111
- format: 'yarramate/projection/v1',
112
- id: 'ask-slice',
113
- version: '0.0',
114
- query: { subjects: [...seeds], relationships: 'connected' },
115
- presentation: {
116
- title,
117
- description: `Connected neighbourhood of ${seeds.join(', ')}`,
118
- },
119
- }, profileContext);
132
+ // The capped result is always a subset of the uncapped one — the cap
133
+ // drops neighbours and their edges, never seeds, and never adds edges
134
+ // the connected expansion would not have selected.
135
+ const sliceProjection = (graph, seeds, title, profileContext, neighbourCap) => {
136
+ const full = evaluateProjection(graph, {
137
+ format: 'yarramate/projection/v1',
138
+ id: 'ask-slice',
139
+ version: '0.0',
140
+ query: { subjects: [...seeds], relationships: 'connected' },
141
+ presentation: {
142
+ title,
143
+ description: `Connected neighbourhood of ${seeds.join(', ')}`,
144
+ },
145
+ }, profileContext);
146
+ if (neighbourCap === 0)
147
+ return { result: full };
148
+ const seedSet = new Set(seeds);
149
+ const relationshipIds = new Set(full.subjects
150
+ .filter(({ type }) => type === 'relationship')
151
+ .map(({ id }) => id));
152
+ const endpointsById = new Map();
153
+ for (const claim of full.claims) {
154
+ if (relationshipIds.has(claim.id) && 'ref' in claim.object) {
155
+ endpointsById.set(claim.id, [claim.subject, claim.object.ref]);
156
+ }
157
+ }
158
+ const neighboursOf = new Map(seeds.map((seed) => [seed, []]));
159
+ const affinity = new Map();
160
+ for (const [from, to] of endpointsById.values()) {
161
+ for (const [seed, neighbour] of [
162
+ [from, to],
163
+ [to, from],
164
+ ]) {
165
+ if (!seedSet.has(seed) || seedSet.has(neighbour))
166
+ continue;
167
+ const list = neighboursOf.get(seed);
168
+ if (list !== undefined && !list.includes(neighbour)) {
169
+ list.push(neighbour);
170
+ affinity.set(neighbour, (affinity.get(neighbour) ?? 0) + 1);
171
+ }
172
+ }
173
+ }
174
+ const keptNeighbours = new Set();
175
+ for (const list of neighboursOf.values()) {
176
+ const ordered = [...list].sort((left, right) => materialityRank(graph, profileContext, left) -
177
+ materialityRank(graph, profileContext, right) ||
178
+ (affinity.get(right) ?? 0) - (affinity.get(left) ?? 0) ||
179
+ left.localeCompare(right));
180
+ for (const neighbour of ordered.slice(0, neighbourCap)) {
181
+ keptNeighbours.add(neighbour);
182
+ }
183
+ }
184
+ const allNeighbours = new Set([...neighboursOf.values()].flat());
185
+ const omitted = allNeighbours.size - keptNeighbours.size;
186
+ if (omitted === 0)
187
+ return { result: full };
188
+ const keptConcepts = new Set([...seedSet, ...keptNeighbours]);
189
+ const keptRelationships = new Set([...endpointsById]
190
+ .filter(([, [from, to]]) => keptConcepts.has(from) && keptConcepts.has(to))
191
+ .map(([id]) => id));
192
+ const keptSubjects = new Set([...keptConcepts, ...keptRelationships]);
193
+ const keptDocuments = new Set([...keptConcepts].map((id) => id.slice(0, id.indexOf('#'))));
194
+ const result = {
195
+ ...full,
196
+ documents: full.documents.filter(({ id }) => keptDocuments.has(id)),
197
+ subjects: full.subjects.filter(({ id }) => keptSubjects.has(id)),
198
+ claims: full.claims.filter((claim) => (keptConcepts.has(claim.subject) && !relationshipIds.has(claim.id)) ||
199
+ keptRelationships.has(claim.subject) ||
200
+ keptRelationships.has(claim.id)),
201
+ };
202
+ return {
203
+ result,
204
+ neighbourhood: {
205
+ cap: neighbourCap,
206
+ kept: keptNeighbours.size,
207
+ omitted,
208
+ omittedBySeed: seeds.flatMap((seed) => {
209
+ const dropped = (neighboursOf.get(seed) ?? []).filter((neighbour) => !keptNeighbours.has(neighbour)).length;
210
+ return dropped > 0 ? [{ seed, omitted: dropped }] : [];
211
+ }),
212
+ },
213
+ };
214
+ };
215
+ // The honesty line, in the budgeted-ladder voice (ADR 0042): what was
216
+ // dropped is named, and so is the way to widen.
217
+ const neighbourhoodLine = (neighbourhood) => `[neighbours ${neighbourhood.cap}: ${neighbourhood.omitted} of ` +
218
+ `${neighbourhood.kept + neighbourhood.omitted} neighbours omitted — ` +
219
+ `raise --neighbours or pass --neighbours 0 for the full neighbourhood]`;
120
220
  export function runAskCommand(options, cwd) {
121
221
  let json = false;
122
222
  let subjects = false;
@@ -128,6 +228,7 @@ export function runAskCommand(options, cwd) {
128
228
  let compare;
129
229
  let changed;
130
230
  let budget;
231
+ let neighbours;
131
232
  let kindFilter;
132
233
  let statusFilter;
133
234
  let cataloguePath;
@@ -177,6 +278,7 @@ export function runAskCommand(options, cwd) {
177
278
  continue;
178
279
  }
179
280
  if (option === '--budget' ||
281
+ option === '--neighbours' ||
180
282
  option === '--kind' ||
181
283
  option === '--status' ||
182
284
  option === '--catalogue' ||
@@ -191,6 +293,12 @@ export function runAskCommand(options, cwd) {
191
293
  }
192
294
  budget = Number(value);
193
295
  }
296
+ else if (option === '--neighbours') {
297
+ if (neighbours !== undefined || !/^(0|[1-9][0-9]*)$/.test(value)) {
298
+ return { exitCode: 2, stdout: '', stderr: usage };
299
+ }
300
+ neighbours = Number(value);
301
+ }
194
302
  else if (option === '--changed') {
195
303
  if (changed !== undefined) {
196
304
  return { exitCode: 2, stdout: '', stderr: usage };
@@ -241,6 +349,7 @@ export function runAskCommand(options, cwd) {
241
349
  advise ||
242
350
  changed !== undefined ||
243
351
  budget !== undefined ||
352
+ neighbours !== undefined ||
244
353
  query.length === 0)) ||
245
354
  (query.length > 0 && exclusiveModes > 0) ||
246
355
  (changed !== undefined &&
@@ -248,7 +357,11 @@ export function runAskCommand(options, cwd) {
248
357
  ((kindFilter !== undefined || statusFilter !== undefined) && !subjects) ||
249
358
  (cataloguePath !== undefined && !open && !advise) ||
250
359
  (budget !== undefined &&
251
- (json || (query.length === 0 && !advise && changed === undefined)))) {
360
+ (json || (query.length === 0 && !advise && changed === undefined))) ||
361
+ (neighbours !== undefined &&
362
+ query.length === 0 &&
363
+ !advise &&
364
+ changed === undefined)) {
252
365
  return { exitCode: 2, stdout: '', stderr: usage };
253
366
  }
254
367
  try {
@@ -615,7 +728,7 @@ export function runAskCommand(options, cwd) {
615
728
  projections: workspace.projections.length,
616
729
  uncovered,
617
730
  };
618
- const evaluated = sliceProjection(graph, seeds, `Review slice ${changed}`, compilation.profileContext);
731
+ const { result: evaluated, neighbourhood } = sliceProjection(graph, seeds, `Review slice ${changed}`, compilation.profileContext, neighbours ?? defaultNeighbourCap);
619
732
  const result = {
620
733
  format: 'yarramate/ask-result/v1',
621
734
  workspace: workspace.id,
@@ -624,6 +737,7 @@ export function runAskCommand(options, cwd) {
624
737
  seeds,
625
738
  changed: derived.changed,
626
739
  coverage,
740
+ ...(neighbourhood === undefined ? {} : { neighbourhood }),
627
741
  result: evaluated,
628
742
  };
629
743
  if (changedIds.length === 0) {
@@ -637,6 +751,9 @@ export function runAskCommand(options, cwd) {
637
751
  `${plural(derived.changed.relationships.length, 'relationship')} changed (workspace ${workspace.id})`,
638
752
  '',
639
753
  rendered.trimEnd(),
754
+ ...(neighbourhood === undefined
755
+ ? []
756
+ : ['', neighbourhoodLine(neighbourhood)]),
640
757
  '',
641
758
  uncovered.length === 0
642
759
  ? `Review coverage: every changed subject appears in at least one of the ${coverage.projections} authored projections.`
@@ -657,6 +774,17 @@ export function runAskCommand(options, cwd) {
657
774
  : undefined;
658
775
  if (projectionCandidate !== undefined &&
659
776
  parseDocument(readFileSync(projectionCandidate, 'utf8')).get('format') === 'yarramate/projection/v1') {
777
+ // A projection file defines its own query; there is no seeded
778
+ // expansion to cap, so an explicit --neighbours is a contradiction
779
+ // rather than something to ignore silently.
780
+ if (neighbours !== undefined) {
781
+ return {
782
+ exitCode: 2,
783
+ stdout: '',
784
+ stderr: `--neighbours applies to seeded slices; ${soleTerm} is a ` +
785
+ 'projection that defines its own query\n',
786
+ };
787
+ }
660
788
  const loaded = loadProjection({
661
789
  path: soleTerm,
662
790
  source: readFileSync(projectionCandidate, 'utf8'),
@@ -772,7 +900,7 @@ export function runAskCommand(options, cwd) {
772
900
  lines.push('', note);
773
901
  return emit(result, `${lines.join('\n')}\n`);
774
902
  }
775
- const evaluated = sliceProjection(graph, resolution.seeds, topic, compilation.profileContext);
903
+ const { result: evaluated, neighbourhood } = sliceProjection(graph, resolution.seeds, topic, compilation.profileContext, neighbours ?? defaultNeighbourCap);
776
904
  if (!advise) {
777
905
  const result = {
778
906
  format: 'yarramate/ask-result/v1',
@@ -782,6 +910,7 @@ export function runAskCommand(options, cwd) {
782
910
  topic,
783
911
  seeds: resolution.seeds,
784
912
  matched: resolution.matched,
913
+ ...(neighbourhood === undefined ? {} : { neighbourhood }),
785
914
  result: evaluated,
786
915
  };
787
916
  const rendered = budget === undefined
@@ -794,7 +923,9 @@ export function runAskCommand(options, cwd) {
794
923
  : '') +
795
924
  `: ${resolution.seeds.join(', ')}\n\n`
796
925
  : '';
797
- return emit(result, `${header}${rendered}`);
926
+ return emit(result, neighbourhood === undefined
927
+ ? `${header}${rendered}`
928
+ : `${header}${rendered.trimEnd()}\n\n${neighbourhoodLine(neighbourhood)}\n`);
798
929
  }
799
930
  // --advise: the expert composition. The engine assembles ground
800
931
  // truth — slice, open questions, drift — and stops; the reading and
@@ -874,6 +1005,7 @@ export function runAskCommand(options, cwd) {
874
1005
  seeds: resolution.seeds,
875
1006
  matched: resolution.matched,
876
1007
  slice: brief,
1008
+ ...(neighbourhood === undefined ? {} : { neighbourhood }),
877
1009
  openQuestions,
878
1010
  ...(reconciliation === undefined ? {} : { reconciliation }),
879
1011
  };
@@ -884,6 +1016,9 @@ export function runAskCommand(options, cwd) {
884
1016
  '== Model slice ==',
885
1017
  '',
886
1018
  brief.trimEnd(),
1019
+ ...(neighbourhood === undefined
1020
+ ? []
1021
+ : ['', neighbourhoodLine(neighbourhood)]),
887
1022
  '',
888
1023
  '== Open questions touching this slice ==',
889
1024
  ];
package/dist/brief.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import type { ResolvedProfileContext } from './compiler.js';
2
2
  import type { ProjectionResult } from './projection.js';
3
+ export declare const coreLocalKind: (kind: string, lineages: ReadonlyMap<string, readonly string[]> | undefined) => string | undefined;
3
4
  export declare function renderBrief(result: ProjectionResult, profileContext?: ResolvedProfileContext, budgetTokens?: number): string;
package/dist/brief.js CHANGED
@@ -14,7 +14,9 @@ const claimReferences = (claims, subject, predicate) => claims.flatMap((claim) =
14
14
  : []);
15
15
  // Resolve a qualified kind to its nearest core-profile local id through
16
16
  // declared lineage, mirroring how `next` orients relationships (ADR 0048).
17
- const coreLocalKind = (kind, lineages) => {
17
+ // Exported so ask's neighbour cap ranks neighbours by the same reading
18
+ // the brief ranks paragraphs with (ADR 0070).
19
+ export const coreLocalKind = (kind, lineages) => {
18
20
  for (const candidate of [kind, ...(lineages?.get(kind) ?? [])]) {
19
21
  const separator = candidate.indexOf('#');
20
22
  if (separator !== -1 && candidate.startsWith('yarramate/core@')) {
@@ -7,7 +7,7 @@ export interface CliResult {
7
7
  export declare const isMainModule: (moduleUrl: string, entrypoint: string | undefined) => boolean;
8
8
  export declare const packageVersion: string;
9
9
  export declare const versionResult: (binary: string) => CliResult;
10
- export declare const usage = "Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <document-id>#<local-id>] [--catalogue <catalogue.yaml>] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> \"<free text>\" | <document-id>#<local-id> ... | <projection.yaml> [--budget <tokens>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise \"<topic>\" [--budget <tokens>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --where \"<free text>\" | <document-id>#<local-id> ... [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml>\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml> [--changed <git-range>]\n";
10
+ export declare const usage = "Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <document-id>#<local-id>] [--catalogue <catalogue.yaml>] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> \"<free text>\" | <document-id>#<local-id> ... | <projection.yaml> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise \"<topic>\" [--budget <tokens>] [--neighbours <n>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --where \"<free text>\" | <document-id>#<local-id> ... [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml>\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml> [--changed <git-range>]\n";
11
11
  export declare const diagnosticJson: (diagnostics: unknown) => string;
12
12
  export declare const checkResultJson: (ok: boolean, diagnostics: unknown, counted?: {
13
13
  readonly documents: number;
@@ -18,7 +18,7 @@ export declare const checkResultJson: (ok: boolean, diagnostics: unknown, counte
18
18
  readonly observations: number;
19
19
  readonly contradicted: number;
20
20
  }) => string;
21
- export declare const humanDiagnostics: (diagnostics: readonly Pick<Diagnostic, "path" | "line" | "column" | "code" | "message">[]) => string;
21
+ export declare const humanDiagnostics: (diagnostics: readonly Pick<Diagnostic, 'path' | 'line' | 'column' | 'code' | 'message'>[]) => string;
22
22
  export declare const sortDiagnostics: <T extends Diagnostic>(diagnostics: readonly T[]) => T[];
23
23
  export declare const resolveCliWorkspaceSources: (paths: readonly string[], cwd: string, options?: {
24
24
  readonly includeAdapterMappings?: boolean;
@@ -3,8 +3,7 @@ import { resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { parseDocument } from 'yaml';
5
5
  import { loadWorkspaceManifest } from './workspace.js';
6
- import packageManifest from '../package.json' with {
7
- type: 'json'
6
+ import packageManifest from '../package.json' with { type: 'json'
8
7
  };
9
8
  export const isMainModule = (moduleUrl, entrypoint) => {
10
9
  if (entrypoint === undefined)
@@ -23,7 +22,7 @@ export const versionResult = (binary) => ({
23
22
  stdout: `${binary} ${packageVersion}\n`,
24
23
  stderr: '',
25
24
  });
26
- export const usage = 'Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <document-id>#<local-id>] [--catalogue <catalogue.yaml>] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> "<free text>" | <document-id>#<local-id> ... | <projection.yaml> [--budget <tokens>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise "<topic>" [--budget <tokens>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --where "<free text>" | <document-id>#<local-id> ... [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml>\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml> [--changed <git-range>]\n';
25
+ export const usage = 'Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <document-id>#<local-id>] [--catalogue <catalogue.yaml>] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> "<free text>" | <document-id>#<local-id> ... | <projection.yaml> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise "<topic>" [--budget <tokens>] [--neighbours <n>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --where "<free text>" | <document-id>#<local-id> ... [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml>\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml> [--changed <git-range>]\n';
27
26
  export const diagnosticJson = (diagnostics) => `${JSON.stringify({
28
27
  format: 'yarramate/diagnostic-result/v1',
29
28
  diagnostics,
package/dist/compiler.js CHANGED
@@ -2,11 +2,9 @@ import Ajv2020Module from 'ajv/dist/2020.js';
2
2
  import { LineCounter, parseDocument } from 'yaml';
3
3
  import { conceptKinds, relationshipPolicies, } from './profile.js';
4
4
  import { closestCandidate, describeSchemaViolation, } from './source-document.js';
5
- import documentSchema from '../schema/yarramate-document.schema.json' with {
6
- type: 'json'
5
+ import documentSchema from '../schema/yarramate-document.schema.json' with { type: 'json'
7
6
  };
8
- import profileSchema from '../schema/yarramate-profile.schema.json' with {
9
- type: 'json'
7
+ import profileSchema from '../schema/yarramate-profile.schema.json' with { type: 'json'
10
8
  };
11
9
  const coreProfile = 'yarramate/core@0.1';
12
10
  const Ajv2020 = Ajv2020Module.default;
@@ -1,8 +1,7 @@
1
1
  import Ajv2020Module from 'ajv/dist/2020.js';
2
2
  import { LineCounter, parseDocument } from 'yaml';
3
3
  import { diagnosticOrder, loadSourceDocument, locateSourcePath, } from './source-document.js';
4
- import coreContractSchema from '../schema/yarramate-core-contract.schema.json' with {
5
- type: 'json'
4
+ import coreContractSchema from '../schema/yarramate-core-contract.schema.json' with { type: 'json'
6
5
  };
7
6
  const Ajv2020 = Ajv2020Module.default;
8
7
  const validateCoreContract = new Ajv2020({ allErrors: true }).compile(coreContractSchema);
package/dist/evidence.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import Ajv2020Module from 'ajv/dist/2020.js';
2
2
  import { diagnosticOrder, loadSourceDocument, locateSourcePath, } from './source-document.js';
3
- import evidenceSchema from '../schema/yarramate-evidence.schema.json' with {
4
- type: 'json'
3
+ import evidenceSchema from '../schema/yarramate-evidence.schema.json' with { type: 'json'
5
4
  };
6
5
  const Ajv2020 = Ajv2020Module.default;
7
6
  const validateEvidenceSchema = new Ajv2020({ allErrors: true }).compile(evidenceSchema);
@@ -1,7 +1,6 @@
1
1
  import Ajv2020Module from 'ajv/dist/2020.js';
2
2
  import { loadSourceDocument, locateSourcePath, } from './source-document.js';
3
- import catalogueSchema from '../schema/yarramate-question-catalogue.schema.json' with {
4
- type: 'json'
3
+ import catalogueSchema from '../schema/yarramate-question-catalogue.schema.json' with { type: 'json'
5
4
  };
6
5
  const Ajv2020 = Ajv2020Module.default;
7
6
  const validateCatalogue = new Ajv2020({ allErrors: true }).compile(catalogueSchema);
package/dist/profile.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export declare const layers: readonly ["motivation", "strategy", "business", "application", "technology", "physical", "implementation", "composite"];
2
- export declare const aspects: readonly ["motivation", "active-structure", "behavior", "passive-structure", "composite"];
1
+ export declare const layers: readonly ['motivation', 'strategy', 'business', 'application', 'technology', 'physical', 'implementation', 'composite'];
2
+ export declare const aspects: readonly ['motivation', 'active-structure', 'behavior', 'passive-structure', 'composite'];
3
3
  export type Layer = (typeof layers)[number];
4
4
  export type Aspect = (typeof aspects)[number];
5
5
  export interface ConceptKind {
@@ -14,7 +14,7 @@ export interface ConceptKind {
14
14
  readonly inspiredBy: string;
15
15
  }
16
16
  export declare const conceptKinds: readonly ConceptKind[];
17
- export declare const relationshipKinds: readonly ["composition", "aggregation", "assignment", "realization", "serving", "access", "influence", "association", "triggering", "flow", "specialization"];
17
+ export declare const relationshipKinds: readonly ['composition', 'aggregation', 'assignment', 'realization', 'serving', 'access', 'influence', 'association', 'triggering', 'flow', 'specialization'];
18
18
  export type RelationshipKind = (typeof relationshipKinds)[number];
19
19
  export interface RelationshipPolicy {
20
20
  readonly id: RelationshipKind;
@@ -1,7 +1,6 @@
1
1
  import Ajv2020Module from 'ajv/dist/2020.js';
2
2
  import { loadSourceDocument } from './source-document.js';
3
- import projectionSchema from '../schema/yarramate-projection.schema.json' with {
4
- type: 'json'
3
+ import projectionSchema from '../schema/yarramate-projection.schema.json' with { type: 'json'
5
4
  };
6
5
  const Ajv2020 = Ajv2020Module.default;
7
6
  const validateProjection = new Ajv2020({ allErrors: true }).compile(projectionSchema);
@@ -20,7 +20,7 @@ export type SourceDocumentResult<T> = {
20
20
  readonly diagnostics: readonly Diagnostic[];
21
21
  };
22
22
  export declare const diagnosticOrder: (left: Diagnostic, right: Diagnostic) => number;
23
- export declare const describeSchemaViolation: (error: Pick<ErrorObject, "keyword" | "message" | "params">) => string;
23
+ export declare const describeSchemaViolation: (error: Pick<ErrorObject, 'keyword' | 'message' | 'params'>) => string;
24
24
  export declare const closestCandidate: (value: string, candidates: Iterable<string>) => string | undefined;
25
25
  export declare function locateSourcePath(sourcePath: string, yaml: ReturnType<typeof parseDocument>, lineCounter: LineCounter, yamlPath: readonly (string | number)[], pointer: string): SourceLocation;
26
26
  export declare function loadSourceDocument<T>(source: WorkspaceSource, validate: ValidateFunction, schemaLabel: string): SourceDocumentResult<T>;
package/dist/workspace.js CHANGED
@@ -2,8 +2,7 @@ import { globSync, realpathSync, statSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, relative, resolve, sep, } from 'node:path';
3
3
  import Ajv2020Module from 'ajv/dist/2020.js';
4
4
  import { diagnosticOrder, loadSourceDocument, } from './source-document.js';
5
- import workspaceSchema from '../schema/yarramate-workspace.schema.json' with {
6
- type: 'json'
5
+ import workspaceSchema from '../schema/yarramate-workspace.schema.json' with { type: 'json'
7
6
  };
8
7
  const Ajv2020 = Ajv2020Module.default;
9
8
  const validateWorkspace = new Ajv2020({ allErrors: true }).compile(workspaceSchema);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "yarramate",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Tool-neutral semantic architecture engine and guided methodology",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/yarrasys/yarramate.git"
9
9
  },
10
- "homepage": "https://github.com/yarrasys/yarramate#readme",
10
+ "homepage": "https://yarramate.dev",
11
11
  "bugs": {
12
12
  "url": "https://github.com/yarrasys/yarramate/issues"
13
13
  },
@@ -108,9 +108,9 @@
108
108
  "yaml": "^2.8.1"
109
109
  },
110
110
  "devDependencies": {
111
- "@types/node": "^24.0.0",
111
+ "@types/node": "^26.1.2",
112
112
  "likec4": "^1.59.2",
113
- "typescript": "^5.9.0",
113
+ "typescript": "^7.0.2",
114
114
  "vitest": "^4.1.10"
115
115
  }
116
116
  }
@@ -15,13 +15,17 @@
15
15
  "addedConcepts",
16
16
  "addedRelationships",
17
17
  "updatedConcepts",
18
- "updatedRelationships"
18
+ "updatedRelationships",
19
+ "deletedConcepts",
20
+ "deletedRelationships"
19
21
  ],
20
22
  "properties": {
21
23
  "addedConcepts": { "type": "integer", "minimum": 0 },
22
24
  "addedRelationships": { "type": "integer", "minimum": 0 },
23
25
  "updatedConcepts": { "type": "integer", "minimum": 0 },
24
- "updatedRelationships": { "type": "integer", "minimum": 0 }
26
+ "updatedRelationships": { "type": "integer", "minimum": 0 },
27
+ "deletedConcepts": { "type": "integer", "minimum": 0 },
28
+ "deletedRelationships": { "type": "integer", "minimum": 0 }
25
29
  }
26
30
  },
27
31
  "documents": {
@@ -263,6 +263,9 @@
263
263
  }
264
264
  },
265
265
  "additionalProperties": false
266
+ },
267
+ "neighbourhood": {
268
+ "$ref": "#/$defs/neighbourhoodOmission"
266
269
  }
267
270
  },
268
271
  "additionalProperties": false
@@ -302,6 +305,9 @@
302
305
  "type": "string",
303
306
  "minLength": 1
304
307
  },
308
+ "neighbourhood": {
309
+ "$ref": "#/$defs/neighbourhoodOmission"
310
+ },
305
311
  "openQuestions": {
306
312
  "type": "array",
307
313
  "items": {
@@ -707,6 +713,52 @@
707
713
  }
708
714
  ],
709
715
  "$defs": {
716
+ "neighbourhoodOmission": {
717
+ "description": "Present only when the per-seed neighbour cap dropped part of the 1-hop expansion (ADR 0070): the cap in force, neighbours kept and omitted in total, and the per-seed omission counts. Absent means the slice is the complete connected neighbourhood.",
718
+ "type": "object",
719
+ "required": [
720
+ "cap",
721
+ "kept",
722
+ "omitted",
723
+ "omittedBySeed"
724
+ ],
725
+ "properties": {
726
+ "cap": {
727
+ "type": "integer",
728
+ "minimum": 1
729
+ },
730
+ "kept": {
731
+ "type": "integer",
732
+ "minimum": 0
733
+ },
734
+ "omitted": {
735
+ "type": "integer",
736
+ "minimum": 1
737
+ },
738
+ "omittedBySeed": {
739
+ "type": "array",
740
+ "items": {
741
+ "type": "object",
742
+ "required": [
743
+ "seed",
744
+ "omitted"
745
+ ],
746
+ "properties": {
747
+ "seed": {
748
+ "type": "string",
749
+ "minLength": 1
750
+ },
751
+ "omitted": {
752
+ "type": "integer",
753
+ "minimum": 1
754
+ }
755
+ },
756
+ "additionalProperties": false
757
+ }
758
+ }
759
+ },
760
+ "additionalProperties": false
761
+ },
710
762
  "conceptEntry": {
711
763
  "type": "object",
712
764
  "required": [
@@ -170,6 +170,18 @@
170
170
  }
171
171
  }
172
172
  },
173
+ "deleteTarget": {
174
+ "type": "object",
175
+ "additionalProperties": false,
176
+ "required": [
177
+ "id"
178
+ ],
179
+ "properties": {
180
+ "id": {
181
+ "$ref": "#/$defs/nonEmptyText"
182
+ }
183
+ }
184
+ },
173
185
  "operation": {
174
186
  "oneOf": [
175
187
  {
@@ -327,6 +339,46 @@
327
339
  }
328
340
  }
329
341
  }
342
+ },
343
+ {
344
+ "type": "object",
345
+ "additionalProperties": false,
346
+ "required": [
347
+ "op",
348
+ "document",
349
+ "concept"
350
+ ],
351
+ "properties": {
352
+ "op": {
353
+ "const": "delete-concept"
354
+ },
355
+ "document": {
356
+ "$ref": "#/$defs/nonEmptyText"
357
+ },
358
+ "concept": {
359
+ "$ref": "#/$defs/deleteTarget"
360
+ }
361
+ }
362
+ },
363
+ {
364
+ "type": "object",
365
+ "additionalProperties": false,
366
+ "required": [
367
+ "op",
368
+ "document",
369
+ "relationship"
370
+ ],
371
+ "properties": {
372
+ "op": {
373
+ "const": "delete-relationship"
374
+ },
375
+ "document": {
376
+ "$ref": "#/$defs/nonEmptyText"
377
+ },
378
+ "relationship": {
379
+ "$ref": "#/$defs/deleteTarget"
380
+ }
381
+ }
330
382
  }
331
383
  ]
332
384
  }
@@ -168,8 +168,12 @@ yarramate apply operations.yaml workspace.yaml
168
168
  ```
169
169
 
170
170
  The whole candidate workspace must compile or nothing is written.
171
- Update operations enrich only — scalars replace, lists append; removals
172
- stay Git edits.
171
+ Update operations enrich by default — scalars replace, lists append — and
172
+ retract explicitly with `remove: [<field> ...]`. Whole subjects leave
173
+ through `delete-concept` / `delete-relationship` (payload: the `id` only),
174
+ rejected while anything still references the target; delete the referring
175
+ relationships in the same batch. To descope, retire (`status: retired`)
176
+ instead — delete only when the history itself is noise.
173
177
 
174
178
  ## Ownership and constraints
175
179