archgraph-argo 0.20.10 → 0.22.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.
@@ -23,6 +23,8 @@ Non-negotiable red lines (MUST). Never skip, simplify, or silently violate them;
23
23
  7. Retrieve KG-first and semantic-first. See `<QueryPriorityGuideline>`.
24
24
  8. Store content KG-first. See `<ContentStoragePolicy>`.
25
25
  9. Never duplicate: reuse is the default; a create blocked as an exact or semantic duplicate must be reused, or explicitly overridden with `onConflict: "allowDuplicate"` + justification. See `<GraphDeduplication>`.
26
+ 10. Reason critically: challenge the human partner with evidence — your native knowledge, the repository, and the intent graph — instead of agreeing by default; never silently comply with an unsound request. See `<CriticalReasoningGuideline>`.
27
+ 11. Never lose content silently: writes that reduce existing content must be lossless (merge/delta) or explicitly acknowledged (`acknowledgeLoss`), and destructive removals are tombstoned. See `<LosslessWrite>`.
26
28
  </CoreRules>
27
29
 
28
30
  <Ontology>
@@ -31,6 +33,15 @@ Your architecture is ArchiMate 3.2 plus ARGO extensions. Reference files live un
31
33
  2. Element/relationship type definitions: ~/.argo/schema/archimate3.2.md
32
34
  </Ontology>
33
35
 
36
+ <CriticalReasoningGuideline>
37
+ Be a critical peer, not a compliant assistant: your duty is to the evidence, not to the human partner's preferences. Do not be sycophantic.
38
+ 1. Evaluate before agreeing: never accept a question's premise, a request, or a proposal just because the human asserts it. First check it against evidence from three sources — your native model knowledge, the repository (code, docs, tests, history), and the intent graph. Endorsement without evidence is a failure.
39
+ 2. Challenge with evidence: when the evidence contradicts the human, say so plainly and cite it (a file path + line/commit, a graph element id, or a named fact), then offer the corrected alternative. Say what evidence would change your mind, so the challenge is falsifiable.
40
+ 3. Separate facts from preferences: facts (correctness, behavior, constraints, feasibility) are decided by evidence and MUST be contested when wrong; preferences (taste, priority, scope, risk appetite) belong to the human — accept them and state the trade-off instead of dressing a preference up as a fact.
41
+ 4. Do not cave to pressure: revise a conclusion only for new evidence or a better argument — never for repetition, tone, urgency, or authority. Agreeing to please is a defect, not politeness.
42
+ 5. Refuse the wrong part, offer the right path: when a request conflicts with evidence or a CoreRule, neither silently comply nor merely refuse — name the conflict with evidence and propose the compliant alternative.
43
+ </CriticalReasoningGuideline>
44
+
34
45
  <ExplorationGuideline>
35
46
  0. KG-first retrieval: for ANY retrieval, query the intent graph through ARGO MCP before searching files, code, or web. See `<QueryPriorityGuideline>`.
36
47
  1. Explore in small steps: keep each query shallow, then decide the next step from the result.
@@ -62,6 +73,14 @@ Never add what the graph already has. Reuse is the default; there is no reject m
62
73
  5. Updates are never gated. Never work around a duplicate by editing around it — reuse or update the existing object.
63
74
  </GraphDeduplication>
64
75
 
76
+ <LosslessWrite>
77
+ Never lose stored content silently. A write must never reduce existing content unless it is lossless (merge/delta) or you explicitly acknowledge the reduction; omission never means deletion.
78
+ 1. Structured fields merge: element testcases merge by `name`; view membership accepts a delta `{ add, remove }`; relationship attributes merge by `name`. Omitting an existing entry preserves it. Delete only with an explicit `op: "remove"` or an explicit delta remove.
79
+ 2. Scalar text is guarded: `description`, `statement`, `document`, `name`, `view_name` are full-value fields. A *reworded* line is not a loss; dropping prior lines, or losing structured tokens (ids, numbers, commit hashes, paths), is blocked unless you pass `acknowledgeLoss: true` (and `lossJustification` when the loss is major). Read the current value first and edit as a minimal diff.
80
+ 3. Destructive removals (`removeElement` / `removeRelationship` / `removeView`) require `acknowledgeLoss: true` — per mutation, or once for the whole set via the top-level `acknowledgeLoss` on `applySystemArchitectureMutation`; the full removed object is appended to the NDJSON tombstone ledger `design/KG/SystemArchitecture.tombstones.ndjson` (rotated by size) for recovery.
81
+ 4. Always read the `lossless` loss report in the response (preview and apply). It lists removed text lines / testcases / members / objects. If it is blocked, fix the mutation—do not retry blindly and do not disable the gate.
82
+ </LosslessWrite>
83
+
65
84
  <IntentArchitectureFirst>
66
85
  1. Before changing anything, find the matching architecture element in the graph.
67
86
  2. If it is missing, pick a View and create a reasonable element in it.
@@ -291,6 +291,29 @@ for (const tool of TOOLS) {
291
291
  }
292
292
  }
293
293
 
294
+ // Lossless write gate acknowledgement params for the update/remove helpers
295
+ // (see lossless-write-gate.js). add* is purely additive and left alone.
296
+ const LOSS_ACK_INPUT_PROPS = Object.freeze({
297
+ acknowledgeLoss: {
298
+ type: 'boolean',
299
+ description: 'Set true to confirm an intentional content reduction: a text rewrite that drops prior segments, or a destructive removal. Without it the lossless write gate blocks the write and returns the exact removed content.',
300
+ },
301
+ lossJustification: {
302
+ type: 'string',
303
+ description: 'Required for MAJOR text loss (large removedChars or most of the value): the reason the prior content is being intentionally replaced.',
304
+ },
305
+ });
306
+ for (const tool of TOOLS) {
307
+ if (tool && tool.name && /^(update|remove)Architecture/.test(tool.name)
308
+ && tool.inputSchema && tool.inputSchema.properties) {
309
+ for (const [key, value] of Object.entries(LOSS_ACK_INPUT_PROPS)) {
310
+ if (!Object.prototype.hasOwnProperty.call(tool.inputSchema.properties, key)) {
311
+ tool.inputSchema.properties[key] = value;
312
+ }
313
+ }
314
+ }
315
+ }
316
+
294
317
  function intentElementContextInputSchema() {
295
318
  return {
296
319
  type: 'object',
@@ -318,6 +341,8 @@ function mutationInputSchema() {
318
341
  required: ['mutations'],
319
342
  properties: {
320
343
  architecturePath: { type: 'string', description: 'Default: design/KG/SystemArchitecture.json' },
344
+ acknowledgeLoss: { type: 'boolean', description: 'Batch-level loss acknowledgement: set true to confirm every intentional content reduction in this mutation set (a text rewrite that drops prior segments, or destructive removals). Counts for all mutations — one confirmation for a whole batch.' },
345
+ lossJustification: { type: 'string', description: 'Batch-level justification for MAJOR text loss in this mutation set.' },
321
346
  mutations: {
322
347
  type: 'array',
323
348
  minItems: 1,
@@ -348,6 +373,8 @@ function mutationInputSchema() {
348
373
  view_ids: { type: 'array', minItems: 1, items: { type: 'string' } },
349
374
  element_ids: { type: 'array', items: { type: 'string' } },
350
375
  relationship_ids: { type: 'array', items: { type: 'string' } },
376
+ acknowledgeLoss: { type: 'boolean', description: 'Set true to confirm an intentional content reduction: a text rewrite that drops prior segments, or a destructive removal. Without it the lossless write gate blocks the write and returns the exact removed content.' },
377
+ lossJustification: { type: 'string', description: 'Required for MAJOR text loss (large removedChars or most of the value): the reason the prior content is being intentionally replaced.' },
351
378
  },
352
379
  additionalProperties: false,
353
380
  },
@@ -0,0 +1,474 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Lossless Write Gate (无损写入门禁), framework-level.
5
+ *
6
+ * Invariant: NO SILENT LOSS. A write must never reduce stored content unless the
7
+ * caller explicitly acknowledges it (and, for major loss, justifies it). Two
8
+ * mechanisms make this possible:
9
+ *
10
+ * G1 structured fields MERGE (omission never means deletion): updateElement
11
+ * .testcases (by name), updateRelationship .attributes (by name),
12
+ * updateView membership (delta {add,remove}); explicit op:'remove' deletes.
13
+ * G2 scalar text GUARDED: description / statement / document / name / view_name
14
+ * are full-value fields; a value that drops prior segments is blocked unless
15
+ * the mutation carries acknowledgeLoss:true (and lossJustification for major
16
+ * loss). Pure additions/expansions are always allowed.
17
+ * G3 destructive removals ACKNOWLEDGED + RECOVERABLE: removeElement /
18
+ * removeRelationship / removeView require acknowledgeLoss:true and snapshot
19
+ * the full removed object into a tombstone ledger (nothing is truly lost).
20
+ * G5 every response carries a loss report (text/testcases/members/objects +
21
+ * the removed samples), on preview as well as apply.
22
+ *
23
+ * The gate is enforced at the single funnel buildMutationResult (apply +
24
+ * preview + all single-object helpers), so every MCP write interface is covered.
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ const LOSS_ACK_FIELD = 'acknowledgeLoss';
31
+ const LOSS_JUSTIFICATION_FIELD = 'lossJustification';
32
+
33
+ // A text loss is "major" (justification, not just acknowledgement, required)
34
+ // when it removes this many characters or this share of the original text.
35
+ const MAJOR_REMOVED_CHARS = 200;
36
+ const MAJOR_REMOVED_RATIO = 0.5;
37
+
38
+ // A changed segment is a "modification" (not a loss) when its token overlap with
39
+ // some new segment is at least this high — "reworded, not dropped".
40
+ const MODIFICATION_SIMILARITY = 0.5;
41
+
42
+ // Tokens that carry structured identity (ids, numbers, hashes, paths): if one is
43
+ // gone it is always a loss, even when the surrounding sentence was only reworded.
44
+ const TOKEN_RE = /[A-Za-z0-9_@#./\\:-]+|[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g;
45
+
46
+ // Full-value text fields per mutation type. Editing any of these can silently
47
+ // drop prior content, so they are loss-guarded.
48
+ const TEXT_FIELDS_BY_MUTATION = Object.freeze({
49
+ updateElement: Object.freeze(['description', 'name']),
50
+ updateRelationship: Object.freeze(['statement', 'name', 'description', 'document']),
51
+ updateView: Object.freeze(['view_name', 'description']),
52
+ });
53
+
54
+ function normalizeForCompare(value) {
55
+ return String(value === undefined || value === null ? '' : value)
56
+ .normalize('NFKC')
57
+ .replace(/\s+/g, ' ')
58
+ .trim();
59
+ }
60
+
61
+ function normalizeSegments(text) {
62
+ return String(text === undefined || text === null ? '' : text)
63
+ .split(/\r?\n/)
64
+ .map(normalizeForCompare)
65
+ .filter(segment => segment.length > 0);
66
+ }
67
+
68
+ function tokenize(segment) {
69
+ const matches = String(segment === undefined || segment === null ? '' : segment).match(TOKEN_RE);
70
+ return matches ? matches.map(token => token.toLowerCase()) : [];
71
+ }
72
+
73
+ function isStructuredToken(token) {
74
+ return /[0-9]/.test(token) || /[/\\]/.test(token) || /^[0-9a-f]{7,40}$/i.test(token);
75
+ }
76
+
77
+ // Sørensen–Dice coefficient over token sets (deterministic, no network).
78
+ function diceCoefficient(aTokens, bTokens) {
79
+ if (aTokens.length === 0 && bTokens.length === 0) return 1;
80
+ if (aTokens.length === 0 || bTokens.length === 0) return 0;
81
+ const a = new Set(aTokens);
82
+ const b = new Set(bTokens);
83
+ let intersection = 0;
84
+ for (const token of a) if (b.has(token)) intersection += 1;
85
+ return (2 * intersection) / (a.size + b.size);
86
+ }
87
+
88
+ // Deterministic loss detector. A changed segment is classified as a MODIFICATION
89
+ // (kept, just reworded) when it is similar enough to some new segment; otherwise
90
+ // it is a LOSS (dropped or substantially rewritten). Structured tokens (ids,
91
+ // numbers, hashes, paths) that disappear are always a loss. Pure additions and
92
+ // reorderings report nothing.
93
+ function detectTextLoss(oldText, newText) {
94
+ const oldSegments = normalizeSegments(oldText);
95
+ if (oldSegments.length === 0) {
96
+ return { removedSegments: [], modifiedSegments: [], removedChars: 0, oldChars: 0, ratio: 0, structuredTokensRemoved: [] };
97
+ }
98
+ const newSegments = normalizeSegments(newText);
99
+ const newNormalized = normalizeForCompare(newText);
100
+ const newTokenSet = new Set(newSegments.flatMap(tokenize));
101
+ const newSegmentTokens = newSegments.map(tokenize);
102
+ const removedSegments = [];
103
+ const modifiedSegments = [];
104
+ const structuredTokensRemoved = [];
105
+ for (const segment of oldSegments) {
106
+ const segmentTokens = tokenize(segment);
107
+ for (const token of segmentTokens) {
108
+ if (isStructuredToken(token) && !newTokenSet.has(token)) structuredTokensRemoved.push(token);
109
+ }
110
+ if (newNormalized.includes(segment)) continue; // kept verbatim
111
+ const best = newSegmentTokens.reduce((max, tokens) => Math.max(max, diceCoefficient(segmentTokens, tokens)), 0);
112
+ if (best >= MODIFICATION_SIMILARITY) modifiedSegments.push(segment);
113
+ else removedSegments.push(segment);
114
+ }
115
+ const removedChars = removedSegments.reduce((sum, segment) => sum + segment.length, 0);
116
+ const oldChars = oldSegments.reduce((sum, segment) => sum + segment.length, 0);
117
+ return {
118
+ removedSegments,
119
+ modifiedSegments,
120
+ removedChars,
121
+ oldChars,
122
+ ratio: oldChars > 0 ? removedChars / oldChars : 0,
123
+ structuredTokensRemoved: [...new Set(structuredTokensRemoved)],
124
+ };
125
+ }
126
+
127
+ // G1: merge testcases by their stable key (name). Untouched testcases survive;
128
+ // only an explicit {name, op:'remove'} deletes one.
129
+ function mergeTestcasesPatch(existing, patchEntries) {
130
+ if (!Array.isArray(patchEntries)) {
131
+ throw new Error('patch.testcases must be an array of { name, ... } entries');
132
+ }
133
+ const result = Array.isArray(existing) ? existing.map(entry => ({ ...entry })) : [];
134
+ for (const entry of patchEntries) {
135
+ if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name.trim() === '') {
136
+ throw new Error('patch.testcases entries must have a non-empty string name (the stable merge key)');
137
+ }
138
+ const index = result.findIndex(existingEntry => existingEntry.name === entry.name);
139
+ if (entry.op === 'remove') {
140
+ if (index >= 0) result.splice(index, 1);
141
+ continue;
142
+ }
143
+ const next = { ...entry };
144
+ delete next.op;
145
+ if (index >= 0) result[index] = next;
146
+ else result.push(next);
147
+ }
148
+ return result;
149
+ }
150
+
151
+ // G1: merge relationship attributes by name ({ name, description }). Unmentioned
152
+ // attributes survive; op:'remove' deletes by name.
153
+ function mergeRelationshipAttributesPatch(existing, patchEntries) {
154
+ if (!Array.isArray(patchEntries)) {
155
+ throw new Error('patch.attributes must be an array of { name, ... } entries');
156
+ }
157
+ const result = Array.isArray(existing) ? existing.map(entry => ({ ...entry })) : [];
158
+ for (const entry of patchEntries) {
159
+ if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name.trim() === '') {
160
+ throw new Error('patch.attributes entries must have a non-empty string name');
161
+ }
162
+ const index = result.findIndex(attr => attr.name === entry.name);
163
+ if (entry.op === 'remove') {
164
+ if (index >= 0) result.splice(index, 1);
165
+ continue;
166
+ }
167
+ const next = { name: entry.name };
168
+ for (const field of ['value', 'description', 'content']) {
169
+ if (Object.prototype.hasOwnProperty.call(entry, field)) next[field] = entry[field];
170
+ }
171
+ if (index >= 0) result[index] = next;
172
+ else result.push(next);
173
+ }
174
+ return result;
175
+ }
176
+
177
+ // G1: apply a view membership patch as either a full list (legacy replace) or a
178
+ // lossless delta { add, remove }. Returns the resulting list.
179
+ function applyViewMembershipPatch(current, patchValue) {
180
+ const base = Array.isArray(current) ? current : [];
181
+ if (Array.isArray(patchValue)) {
182
+ return { list: dedupe(patchValue), explicitRemove: null };
183
+ }
184
+ if (patchValue && typeof patchValue === 'object') {
185
+ const remove = Array.isArray(patchValue.remove) ? patchValue.remove : [];
186
+ const add = Array.isArray(patchValue.add) ? patchValue.add : [];
187
+ const kept = base.filter(id => !remove.includes(id));
188
+ return { list: dedupe([...kept, ...add]), explicitRemove: remove };
189
+ }
190
+ throw new Error('view membership patch must be an array or { add, remove }');
191
+ }
192
+
193
+ function dedupe(entries) {
194
+ const seen = new Set();
195
+ const result = [];
196
+ for (const entry of entries) {
197
+ if (seen.has(entry)) continue;
198
+ seen.add(entry);
199
+ result.push(entry);
200
+ }
201
+ return result;
202
+ }
203
+
204
+ function findById(entries, id) {
205
+ return Array.isArray(entries) ? entries.find(entry => entry && entry.id === id) : undefined;
206
+ }
207
+
208
+ function findView(views, viewId) {
209
+ return Array.isArray(views) ? views.find(view => view && view.view_id === viewId) : undefined;
210
+ }
211
+
212
+ function isAcknowledged(mutation, lossAck) {
213
+ return (Boolean(mutation) && mutation[LOSS_ACK_FIELD] === true)
214
+ || (Boolean(lossAck) && lossAck[LOSS_ACK_FIELD] === true);
215
+ }
216
+
217
+ function hasJustification(mutation, lossAck) {
218
+ const text = (mutation && mutation[LOSS_JUSTIFICATION_FIELD])
219
+ || (lossAck && lossAck[LOSS_JUSTIFICATION_FIELD]);
220
+ return typeof text === 'string' && text.trim() !== '';
221
+ }
222
+
223
+ // G5+enforcement: compute every content reduction a mutation set would cause,
224
+ // with per-item acknowledgement state, and whether the set must be blocked.
225
+ // `lossAck` is an optional batch-level acknowledgement (applySystemArchitectureMutation
226
+ // top level) that counts for every mutation in the set.
227
+ function buildLossReport({ baseDocument, mutations, nextDocument, lossAck }) {
228
+ const base = baseDocument || {};
229
+ const next = nextDocument || {};
230
+ const report = {
231
+ blocked: false,
232
+ acknowledged: true,
233
+ reasons: [],
234
+ text: [],
235
+ modifications: [],
236
+ membersRemoved: [],
237
+ testcasesRemoved: [],
238
+ attributesRemoved: [],
239
+ objectsRemoved: [],
240
+ };
241
+
242
+ const pushReason = (reason) => {
243
+ report.blocked = true;
244
+ report.acknowledged = false;
245
+ if (!report.reasons.includes(reason)) report.reasons.push(reason);
246
+ };
247
+
248
+ const guardText = (mutation, kind, id, field, oldValue, newValue) => {
249
+ const loss = detectTextLoss(oldValue, newValue);
250
+ if (loss.modifiedSegments.length > 0) {
251
+ // Reworded, not dropped: reported for transparency, never blocked.
252
+ report.modifications.push({ kind, id, field, modifiedLines: loss.modifiedSegments.length, sample: loss.modifiedSegments.slice(0, 3) });
253
+ }
254
+ const hasLoss = loss.removedSegments.length > 0 || loss.structuredTokensRemoved.length > 0;
255
+ if (!hasLoss) return;
256
+ // "Major" only when the removed volume is substantial; a large share of a
257
+ // tiny value is not major (avoids forcing justification for short edits).
258
+ const major = loss.removedChars > MAJOR_REMOVED_CHARS
259
+ || (loss.oldChars > MAJOR_REMOVED_CHARS && loss.ratio > MAJOR_REMOVED_RATIO);
260
+ const acknowledged = isAcknowledged(mutation, lossAck) && (!major || hasJustification(mutation, lossAck));
261
+ const item = {
262
+ kind,
263
+ id,
264
+ field,
265
+ removedChars: loss.removedChars,
266
+ removedLines: loss.removedSegments.length,
267
+ structuredTokensRemoved: loss.structuredTokensRemoved,
268
+ ratio: Number(loss.ratio.toFixed(3)),
269
+ major,
270
+ acknowledged,
271
+ removedSample: loss.removedSegments.slice(0, 3),
272
+ };
273
+ report.text.push(item);
274
+ if (!acknowledged) {
275
+ const why = major && !hasJustification(mutation, lossAck)
276
+ ? ' (major loss requires a non-empty lossJustification)'
277
+ : '';
278
+ const structured = loss.structuredTokensRemoved.length > 0
279
+ ? ` [structured tokens removed: ${loss.structuredTokensRemoved.slice(0, 5).join(', ')}]`
280
+ : '';
281
+ pushReason(
282
+ `Unacknowledged text loss: ${kind} '${id}' field '${field}' would remove ${loss.removedChars} char(s) / ${loss.removedSegments.length} line(s)${structured}${why}. `
283
+ + 'Read the current value first, then pass acknowledgeLoss:true (and lossJustification) to confirm an intentional rewrite, or use an additive edit that keeps the prior content.',
284
+ );
285
+ }
286
+ };
287
+
288
+ for (const mutation of mutations || []) {
289
+ if (mutation.type === 'updateElement' || mutation.type === 'updateRelationship') {
290
+ const collection = mutation.type === 'updateElement' ? 'elements' : 'relationships';
291
+ const kind = mutation.type === 'updateElement' ? 'element' : 'relationship';
292
+ const baseEntry = findById(base[collection], mutation.id);
293
+ const nextEntry = findById(next[collection], mutation.id);
294
+ if (!baseEntry || !nextEntry) continue;
295
+ for (const field of TEXT_FIELDS_BY_MUTATION[mutation.type]) {
296
+ guardText(mutation, kind, mutation.id, field, baseEntry[field], nextEntry[field]);
297
+ }
298
+ if (mutation.type === 'updateElement' && Array.isArray(baseEntry.testcases)) {
299
+ const nextNames = new Set((nextEntry.testcases || []).map(tc => tc && tc.name));
300
+ for (const tc of baseEntry.testcases) {
301
+ if (tc && tc.name && !nextNames.has(tc.name)) {
302
+ report.testcasesRemoved.push({ id: mutation.id, name: tc.name });
303
+ }
304
+ }
305
+ }
306
+ } else if (mutation.type === 'updateView') {
307
+ const viewId = mutation.view_id || mutation.id;
308
+ const baseView = findView(base.views, viewId);
309
+ const nextView = findView(next.views, viewId);
310
+ if (!baseView || !nextView) continue;
311
+ for (const field of TEXT_FIELDS_BY_MUTATION.updateView) {
312
+ guardText(mutation, 'view', viewId, field, baseView[field], nextView[field]);
313
+ }
314
+ const baseElements = new Set(baseView.included_elements || []);
315
+ const removedElements = [...baseElements].filter(id => !(nextView.included_elements || []).includes(id));
316
+ const baseRelationships = new Set(baseView.included_relationships || []);
317
+ const removedRelationships = [...baseRelationships].filter(id => !(nextView.included_relationships || []).includes(id));
318
+ if (removedElements.length > 0 || removedRelationships.length > 0) {
319
+ // A channel with no removals is trivially covered; a channel with removals
320
+ // is explicit only when patched via the delta { remove } form.
321
+ const elementsExplicit = removedElements.length === 0
322
+ || isExplicitRemovePatch(mutation.patch, 'included_elements');
323
+ const relationshipsExplicit = removedRelationships.length === 0
324
+ || isExplicitRemovePatch(mutation.patch, 'included_relationships');
325
+ const explicit = elementsExplicit && relationshipsExplicit;
326
+ const acknowledged = explicit || isAcknowledged(mutation, lossAck);
327
+ report.membersRemoved.push({
328
+ view_id: viewId,
329
+ elements: removedElements,
330
+ relationships: removedRelationships,
331
+ explicit,
332
+ acknowledged,
333
+ });
334
+ if (!acknowledged) {
335
+ pushReason(
336
+ `Unacknowledged membership removal in view '${viewId}': ${removedElements.length} element(s) and ${removedRelationships.length} relationship(s) would be dropped. `
337
+ + 'Use the delta form included_elements:{remove:[...]} for explicit removal, or pass acknowledgeLoss:true.',
338
+ );
339
+ }
340
+ }
341
+ } else if (mutation.type === 'removeElement' || mutation.type === 'removeRelationship' || mutation.type === 'removeView') {
342
+ const spec = {
343
+ removeElement: { collection: 'elements', id: mutation.id },
344
+ removeRelationship: { collection: 'relationships', id: mutation.id },
345
+ removeView: { collection: 'views', id: mutation.view_id },
346
+ }[mutation.type];
347
+ const id = spec.id;
348
+ const baseEntry = mutation.type === 'removeView'
349
+ ? findView(base.views, id)
350
+ : findById(base[spec.collection], id);
351
+ const stillPresent = mutation.type === 'removeView'
352
+ ? Boolean(findView(next.views, id))
353
+ : Boolean(findById(next[spec.collection], id));
354
+ if (!baseEntry || stillPresent) continue;
355
+ const acknowledged = isAcknowledged(mutation, lossAck);
356
+ report.objectsRemoved.push({
357
+ kind: mutation.type.replace(/^remove/, '').toLowerCase(),
358
+ id,
359
+ name: baseEntry.name || baseEntry.view_name,
360
+ acknowledged,
361
+ });
362
+ if (!acknowledged) {
363
+ pushReason(
364
+ `Unacknowledged destructive removal: ${mutation.type} '${id}' ('${baseEntry.name || baseEntry.view_name}') deletes the object permanently. `
365
+ + 'Pass acknowledgeLoss:true (a full tombstone snapshot is recorded for recovery).',
366
+ );
367
+ }
368
+ }
369
+ }
370
+
371
+ return report;
372
+ }
373
+
374
+ function isExplicitRemovePatch(patch, field) {
375
+ if (!patch || !Object.prototype.hasOwnProperty.call(patch, field)) return false;
376
+ const value = patch[field];
377
+ return Boolean(value) && !Array.isArray(value) && typeof value === 'object' && Array.isArray(value.remove);
378
+ }
379
+
380
+ // G3: append full removed objects to an NDJSON tombstone ledger. Each append is a
381
+ // single O(1) line write (no read/rewrite of prior content). When the active file
382
+ // exceeds the size cap (~5k entries) it is rotated aside (rename, O(1)) so the
383
+ // active file and its git diff stay bounded.
384
+ const TOMBSTONE_ACTIVE_BASENAME = 'SystemArchitecture.tombstones.ndjson';
385
+ const TOMBSTONE_ROTATE_BYTES = 2 * 1024 * 1024;
386
+
387
+ function tombstoneFilePath(graphAbsolutePath) {
388
+ return path.join(path.dirname(graphAbsolutePath), TOMBSTONE_ACTIVE_BASENAME);
389
+ }
390
+
391
+ function appendTombstones(graphAbsolutePath, entries, options = {}) {
392
+ const file = tombstoneFilePath(graphAbsolutePath);
393
+ const rotateBytes = Number.isFinite(options.rotateBytes) ? options.rotateBytes : TOMBSTONE_ROTATE_BYTES;
394
+ const list = Array.isArray(entries) ? entries : [];
395
+ if (list.length === 0) return { path: file, count: 0, rotatedTo: null };
396
+ let rotatedTo = null;
397
+ try {
398
+ if (rotateBytes > 0 && fs.statSync(file).size >= rotateBytes) {
399
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
400
+ rotatedTo = path.join(path.dirname(file), `SystemArchitecture.tombstones.${stamp}.ndjson`);
401
+ fs.renameSync(file, rotatedTo);
402
+ }
403
+ } catch {
404
+ // No active file yet (first append) — nothing to rotate.
405
+ }
406
+ const at = new Date().toISOString();
407
+ const lines = list.map(entry => JSON.stringify({
408
+ at,
409
+ op: entry.op || ('remove' + entry.kind),
410
+ kind: entry.kind,
411
+ id: entry.id,
412
+ object: entry.object,
413
+ }));
414
+ fs.appendFileSync(file, `${lines.join('\n')}\n`, 'utf8');
415
+ return { path: file, count: list.length, rotatedTo };
416
+ }
417
+
418
+ // Read back the NDJSON tombstone ledger (recovery / audit). Malformed lines are
419
+ // skipped rather than throwing, so a partially written tail never blocks reads.
420
+ function readTombstones(file) {
421
+ try {
422
+ return fs.readFileSync(file, 'utf8')
423
+ .split(/\r?\n/)
424
+ .filter(line => line.trim() !== '')
425
+ .map(line => {
426
+ try {
427
+ return JSON.parse(line);
428
+ } catch {
429
+ return null;
430
+ }
431
+ })
432
+ .filter(Boolean);
433
+ } catch {
434
+ return [];
435
+ }
436
+ }
437
+
438
+ // Collect the full base objects for every global removal in a mutation set.
439
+ function collectRemovedObjects(baseDocument, mutations, nextDocument) {
440
+ const removed = [];
441
+ for (const mutation of mutations || []) {
442
+ if (mutation.type === 'removeElement') {
443
+ const obj = findById((baseDocument || {}).elements, mutation.id);
444
+ if (obj && !findById((nextDocument || {}).elements, mutation.id)) removed.push({ op: mutation.type, kind: 'element', id: mutation.id, object: obj });
445
+ } else if (mutation.type === 'removeRelationship') {
446
+ const obj = findById((baseDocument || {}).relationships, mutation.id);
447
+ if (obj && !findById((nextDocument || {}).relationships, mutation.id)) removed.push({ op: mutation.type, kind: 'relationship', id: mutation.id, object: obj });
448
+ } else if (mutation.type === 'removeView') {
449
+ const obj = findView((baseDocument || {}).views, mutation.view_id);
450
+ if (obj && !findView((nextDocument || {}).views, mutation.view_id)) removed.push({ op: mutation.type, kind: 'view', id: mutation.view_id, object: obj });
451
+ }
452
+ }
453
+ return removed;
454
+ }
455
+
456
+ module.exports = {
457
+ LOSS_ACK_FIELD,
458
+ LOSS_JUSTIFICATION_FIELD,
459
+ MAJOR_REMOVED_CHARS,
460
+ MAJOR_REMOVED_RATIO,
461
+ TEXT_FIELDS_BY_MUTATION,
462
+ normalizeSegments,
463
+ detectTextLoss,
464
+ mergeTestcasesPatch,
465
+ mergeRelationshipAttributesPatch,
466
+ applyViewMembershipPatch,
467
+ buildLossReport,
468
+ appendTombstones,
469
+ readTombstones,
470
+ tombstoneFilePath,
471
+ TOMBSTONE_ACTIVE_BASENAME,
472
+ TOMBSTONE_ROTATE_BYTES,
473
+ collectRemovedObjects,
474
+ };
@@ -167,6 +167,8 @@ const {
167
167
  verifyArchitectureSync,
168
168
  } = require('./neo4j-system-architecture-store.js');
169
169
 
170
+ const losslessWriteGate = require('./lossless-write-gate.js');
171
+
170
172
  const HANDLED_MUTATION_TYPES = new Set([
171
173
  'addElement',
172
174
  'updateElement',
@@ -413,11 +415,32 @@ const WORKSPACE_ROOT_PARAM = Object.freeze({
413
415
  description:
414
416
  'Optional absolute workspace root for this call. When provided it is used as-is; otherwise the server launch directory is used.',
415
417
  });
418
+ // Lossless write gate: every update/remove helper accepts an explicit loss
419
+ // acknowledgement (see lossless-write-gate.js). add* is purely additive and is
420
+ // left alone. Without the acknowledgement a shrinking text edit or a destructive
421
+ // removal is blocked at the buildMutationResult funnel.
422
+ const LOSS_ACK_INPUT_PROPS = Object.freeze({
423
+ acknowledgeLoss: {
424
+ type: 'boolean',
425
+ description: 'Set true to confirm an intentional content reduction: a text rewrite that drops prior segments, or a destructive removal. Without it the lossless write gate blocks the write and returns the exact removed content.',
426
+ },
427
+ lossJustification: {
428
+ type: 'string',
429
+ description: 'Required for MAJOR text loss (large removedChars or most of the value): the reason the prior content is being intentionally replaced.',
430
+ },
431
+ });
432
+
416
433
  for (const tool of TOOLS) {
417
434
  const inputSchema = tool && tool.inputSchema;
418
- if (inputSchema && inputSchema.type === 'object' && inputSchema.properties) {
419
- if (!Object.prototype.hasOwnProperty.call(inputSchema.properties, 'workspaceRoot')) {
420
- inputSchema.properties.workspaceRoot = WORKSPACE_ROOT_PARAM;
435
+ if (!inputSchema || inputSchema.type !== 'object' || !inputSchema.properties) continue;
436
+ if (!Object.prototype.hasOwnProperty.call(inputSchema.properties, 'workspaceRoot')) {
437
+ inputSchema.properties.workspaceRoot = WORKSPACE_ROOT_PARAM;
438
+ }
439
+ if (tool.name && /^(update|remove)Architecture/.test(tool.name)) {
440
+ for (const [key, value] of Object.entries(LOSS_ACK_INPUT_PROPS)) {
441
+ if (!Object.prototype.hasOwnProperty.call(inputSchema.properties, key)) {
442
+ inputSchema.properties[key] = value;
443
+ }
421
444
  }
422
445
  }
423
446
  }
@@ -464,6 +487,8 @@ function mutationInputSchema() {
464
487
  required: ['mutations'],
465
488
  properties: {
466
489
  architecturePath: { type: 'string', description: `Default: ${DEFAULT_GRAPH_PATH}` },
490
+ acknowledgeLoss: { type: 'boolean', description: 'Batch-level loss acknowledgement: set true to confirm every intentional content reduction in this mutation set (a text rewrite that drops prior segments, or destructive removals). Counts for all mutations — one confirmation for a whole batch.' },
491
+ lossJustification: { type: 'string', description: 'Batch-level justification for MAJOR text loss in this mutation set.' },
467
492
  mutations: {
468
493
  type: 'array',
469
494
  minItems: 1,
@@ -483,6 +508,8 @@ function mutationInputSchema() {
483
508
  relationship_ids: { type: 'array', items: { type: 'string' } },
484
509
  onConflict: { type: 'string', enum: ['reuse', 'allowDuplicate'], description: 'Dedup policy for add* mutations. reuse (default): find-or-create — attach an existing exact-natural-key match instead of creating a duplicate; a same-type semantic near-duplicate also blocks creation unless allowDuplicate is set. allowDuplicate: create a new object even if a duplicate exists, requires a non-empty justification.' },
485
510
  justification: { type: 'string', description: 'Required when onConflict is allowDuplicate; recorded as the reason a semantically-equal duplicate is intentionally created.' },
511
+ acknowledgeLoss: { type: 'boolean', description: 'Set true to confirm an intentional content reduction: a text rewrite that drops prior segments, or a destructive removal. Without it the lossless write gate blocks the write and returns the exact removed content.' },
512
+ lossJustification: { type: 'string', description: 'Required for MAJOR text loss (large removedChars or most of the value): the reason the prior content is being intentionally replaced.' },
486
513
  },
487
514
  additionalProperties: false,
488
515
  },
@@ -1248,7 +1275,7 @@ function resolveDuplicateConflict(options, candidates) {
1248
1275
  return { action: 'create', justification: options.justification };
1249
1276
  }
1250
1277
 
1251
- function applyMutations(document, mutations) {
1278
+ function applyMutations(document, mutations, options = {}) {
1252
1279
  const nextDocument = clone(document);
1253
1280
  const touchedElementIds = new Set();
1254
1281
  const touchedRelationshipIds = new Set();
@@ -1323,6 +1350,14 @@ function applyMutations(document, mutations) {
1323
1350
  patch.attributes,
1324
1351
  );
1325
1352
  }
1353
+ if (Array.isArray(patch.testcases)) {
1354
+ // G1: merge testcases by name so an omitted testcase is preserved; only
1355
+ // an explicit { name, op:'remove' } deletes one.
1356
+ patch.testcases = losslessWriteGate.mergeTestcasesPatch(
1357
+ Array.isArray(element.testcases) ? element.testcases : [],
1358
+ patch.testcases,
1359
+ );
1360
+ }
1326
1361
  Object.assign(element, patch);
1327
1362
  if (patchesSubdiagramViews || patchesName) {
1328
1363
  reconcileSubdiagramViewsForElement(nextDocument, element);
@@ -1440,7 +1475,15 @@ function applyMutations(document, mutations) {
1440
1475
  requirePatchDoesNotChangeRelationshipIdentityOrType(mutation.id, mutation.patch);
1441
1476
  const oldSourceId = relationship.source_id;
1442
1477
  const oldTargetId = relationship.target_id;
1443
- Object.assign(relationship, clone(mutation.patch));
1478
+ const relationshipPatch = clone(mutation.patch);
1479
+ if (Array.isArray(relationshipPatch.attributes)) {
1480
+ // G1: merge relationship attributes by name (unmentioned preserved).
1481
+ relationshipPatch.attributes = losslessWriteGate.mergeRelationshipAttributesPatch(
1482
+ Array.isArray(relationship.attributes) ? relationship.attributes : [],
1483
+ relationshipPatch.attributes,
1484
+ );
1485
+ }
1486
+ Object.assign(relationship, relationshipPatch);
1444
1487
  for (const view of nextDocument.views) {
1445
1488
  if (!(view.included_relationships || []).includes(relationship.id)) {
1446
1489
  continue;
@@ -1547,11 +1590,12 @@ function applyMutations(document, mutations) {
1547
1590
  const oldParentId = view.parent_element_id;
1548
1591
  const oldViewId = view.view_id;
1549
1592
  const patch = clone(mutation.patch);
1550
- if (Array.isArray(patch.included_elements)) {
1551
- patch.included_elements = addUnique([], patch.included_elements);
1593
+ if (Object.prototype.hasOwnProperty.call(patch, 'included_elements')) {
1594
+ // G1: full list (legacy replace) or lossless delta { add, remove }.
1595
+ patch.included_elements = losslessWriteGate.applyViewMembershipPatch(view.included_elements || [], patch.included_elements).list;
1552
1596
  }
1553
- if (Array.isArray(patch.included_relationships)) {
1554
- patch.included_relationships = addUnique([], patch.included_relationships);
1597
+ if (Object.prototype.hasOwnProperty.call(patch, 'included_relationships')) {
1598
+ patch.included_relationships = losslessWriteGate.applyViewMembershipPatch(view.included_relationships || [], patch.included_relationships).list;
1555
1599
  }
1556
1600
  Object.assign(view, patch);
1557
1601
  if (oldParentId !== view.parent_element_id) {
@@ -1588,6 +1632,12 @@ function applyMutations(document, mutations) {
1588
1632
  touchedViewIds: Array.from(touchedViewIds),
1589
1633
  viewLimitCheckIds: Array.from(viewLimitCheckIds),
1590
1634
  mutationSummaries,
1635
+ lossReport: losslessWriteGate.buildLossReport({
1636
+ baseDocument: document,
1637
+ mutations,
1638
+ nextDocument,
1639
+ lossAck: options.lossAck,
1640
+ }),
1591
1641
  };
1592
1642
  }
1593
1643
 
@@ -1768,12 +1818,12 @@ function removeEntries(existing, removals) {
1768
1818
  return (Array.isArray(existing) ? existing : []).filter(entry => !removalSet.has(entry));
1769
1819
  }
1770
1820
 
1771
- async function buildMutationResult(context, mutations, write, dependencies) {
1821
+ async function buildMutationResult(context, mutations, write, dependencies, lossAck) {
1772
1822
  markPhase('mutation:' + (write ? 'apply' : 'preview'));
1773
1823
  const beforeSummary = summarizeDocument(context.document);
1774
1824
  let mutationResult;
1775
1825
  try {
1776
- mutationResult = applyMutations(context.document, mutations);
1826
+ mutationResult = applyMutations(context.document, mutations, { lossAck });
1777
1827
  } catch (error) {
1778
1828
  const errors = [String(error && error.message ? error.message : error)];
1779
1829
  const failed = {
@@ -1846,13 +1896,52 @@ async function buildMutationResult(context, mutations, write, dependencies) {
1846
1896
  }
1847
1897
  }
1848
1898
 
1849
- if (errors.length > 0 || semanticBlocked || !write) {
1899
+ // Lossless write gate: block any unacknowledged content reduction (text shrink,
1900
+ // membership drop, destructive removal). Preview reports it too (status failed)
1901
+ // so the loss is visible before writing.
1902
+ const lossReport = mutationResult.lossReport;
1903
+ const hasLoss = lossReport && (
1904
+ lossReport.text.length > 0
1905
+ || lossReport.membersRemoved.length > 0
1906
+ || lossReport.objectsRemoved.length > 0
1907
+ || lossReport.testcasesRemoved.length > 0
1908
+ );
1909
+ let losslessBlocked = false;
1910
+ if (hasLoss) {
1911
+ result.lossless = lossReport;
1912
+ if (errors.length === 0 && lossReport.blocked) {
1913
+ losslessBlocked = true;
1914
+ result.status = 'failed';
1915
+ result.after = beforeSummary;
1916
+ result.errors = lossReport.reasons;
1917
+ result.guidance = addUnique(result.guidance || [], [
1918
+ ...lossReport.reasons,
1919
+ 'Lossless write gate: read the current value first. For an intentional text rewrite pass acknowledgeLoss:true (and lossJustification for major loss); for a destructive removal pass acknowledgeLoss:true (a tombstone snapshot is recorded). Prefer lossless forms: testcases merge by name, view membership delta { add, remove }.',
1920
+ ]);
1921
+ }
1922
+ }
1923
+
1924
+ if (errors.length > 0 || semanticBlocked || losslessBlocked || !write) {
1850
1925
  return result;
1851
1926
  }
1852
1927
 
1853
1928
  writeGraph(context.graphPath.absolutePath, mutationResult.document);
1854
1929
  result.written = true;
1855
1930
 
1931
+ {
1932
+ const removedObjects = losslessWriteGate.collectRemovedObjects(context.document, mutations, mutationResult.document);
1933
+ if (removedObjects.length > 0) {
1934
+ try {
1935
+ const tomb = losslessWriteGate.appendTombstones(context.graphPath.absolutePath, removedObjects);
1936
+ result.tombstones = { status: 'passed', path: tomb.path, count: tomb.count };
1937
+ } catch (error) {
1938
+ const message = String(error && error.message ? error.message : error);
1939
+ result.tombstones = { status: 'failed', error: message };
1940
+ result.warnings = addUnique(result.warnings || [], ['tombstone snapshot failed (non-fatal): ' + message]);
1941
+ }
1942
+ }
1943
+ }
1944
+
1856
1945
  // WP2791: .qea projection parallel to the Neo4j trigger — non-fatal, best-effort,
1857
1946
  // but ALWAYS reported on the result (passed / failed / noop+reason) so a missing EA
1858
1947
  // update is never silent.
@@ -2543,12 +2632,12 @@ async function callTool(name, args = {}, dependencies = undefined) {
2543
2632
 
2544
2633
  if (name === 'previewSystemArchitectureMutation') {
2545
2634
  const context = await loadContext(args);
2546
- return toolResult(attachContextWarnings(await buildMutationResult(context, args.mutations, false, dependencies), context));
2635
+ return toolResult(attachContextWarnings(await buildMutationResult(context, args.mutations, false, dependencies, { acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }), context));
2547
2636
  }
2548
2637
 
2549
2638
  if (name === 'applySystemArchitectureMutation') {
2550
2639
  const context = await loadContext(args);
2551
- return mutationToolResult(attachContextWarnings(await buildMutationResult(context, args.mutations, true, dependencies), context), true);
2640
+ return mutationToolResult(attachContextWarnings(await buildMutationResult(context, args.mutations, true, dependencies, { acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }), context), true);
2552
2641
  }
2553
2642
 
2554
2643
  if (name === 'addArchitectureElement') {
@@ -2560,13 +2649,13 @@ async function callTool(name, args = {}, dependencies = undefined) {
2560
2649
  if (name === 'updateArchitectureElement') {
2561
2650
  const context = await loadContext(args);
2562
2651
  const write = !args.dryRun;
2563
- return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateElement', id: args.id, patch: args.patch }], write), context), write);
2652
+ return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateElement', id: args.id, patch: args.patch, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
2564
2653
  }
2565
2654
 
2566
2655
  if (name === 'removeArchitectureElement') {
2567
2656
  const context = await loadContext(args);
2568
2657
  const write = !args.dryRun;
2569
- return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeElement', id: args.id, view_ids: args.view_ids }], write), context), write);
2658
+ return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeElement', id: args.id, view_ids: args.view_ids, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
2570
2659
  }
2571
2660
 
2572
2661
  if (name === 'addArchitectureRelationship') {
@@ -2578,13 +2667,13 @@ async function callTool(name, args = {}, dependencies = undefined) {
2578
2667
  if (name === 'updateArchitectureRelationship') {
2579
2668
  const context = await loadContext(args);
2580
2669
  const write = !args.dryRun;
2581
- return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateRelationship', id: args.id, patch: args.patch }], write), context), write);
2670
+ return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateRelationship', id: args.id, patch: args.patch, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
2582
2671
  }
2583
2672
 
2584
2673
  if (name === 'removeArchitectureRelationship') {
2585
2674
  const context = await loadContext(args);
2586
2675
  const write = !args.dryRun;
2587
- return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeRelationship', id: args.id, view_ids: args.view_ids }], write), context), write);
2676
+ return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeRelationship', id: args.id, view_ids: args.view_ids, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
2588
2677
  }
2589
2678
 
2590
2679
  if (name === 'addArchitectureView') {
@@ -2596,13 +2685,13 @@ async function callTool(name, args = {}, dependencies = undefined) {
2596
2685
  if (name === 'updateArchitectureView') {
2597
2686
  const context = await loadContext(args);
2598
2687
  const write = !args.dryRun;
2599
- return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateView', view_id: args.view_id, patch: args.patch }], write), context), write);
2688
+ return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'updateView', view_id: args.view_id, patch: args.patch, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
2600
2689
  }
2601
2690
 
2602
2691
  if (name === 'removeArchitectureView') {
2603
2692
  const context = await loadContext(args);
2604
2693
  const write = !args.dryRun;
2605
- return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeView', view_id: args.view_id }], write), context), write);
2694
+ return mutationToolResult(attachContextWarnings(await buildMutationResult(context, [{ type: 'removeView', view_id: args.view_id, acknowledgeLoss: args.acknowledgeLoss, lossJustification: args.lossJustification }], write), context), write);
2606
2695
  }
2607
2696
 
2608
2697
  if (name === 'queryNeo4jGraph') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgraph-argo",
3
- "version": "0.20.10",
3
+ "version": "0.22.0",
4
4
  "description": "Deploy the ArchGraph ARGO toolchain, skills, and rules (schema, scripts, argo-init skill, global rule) with one command.",
5
5
  "license": "MIT",
6
6
  "bin": {