@wairon/cli 5.1.1-dev.33 → 5.1.1-dev.35

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/dist/index.js CHANGED
@@ -1113,7 +1113,7 @@ var init_defaults = __esm({
1113
1113
  copilot: ".github/prompts",
1114
1114
  codex: ".codex/agents"
1115
1115
  };
1116
- WAIRON_VERSION = "5.1.1-dev.33";
1116
+ WAIRON_VERSION = "5.1.1-dev.35";
1117
1117
  GITHUB_REPO = "SYW-Apps/Waffle-AIron";
1118
1118
  ARCHITECT_AGENT_ID = "agent-architect";
1119
1119
  ARCHITECT_TEMPLATE_ID = "architect";
@@ -18310,6 +18310,112 @@ function computeSpecTreeSignature(dirs) {
18310
18310
  }
18311
18311
  return parts.join("|");
18312
18312
  }
18313
+ function summarizeValue(value) {
18314
+ if (value === void 0) return "";
18315
+ const text = typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
18316
+ return text.length > 120 ? `${text.slice(0, 117)}...` : text;
18317
+ }
18318
+ function identityKeyOf(field, item) {
18319
+ if (item === null || typeof item !== "object") return null;
18320
+ const o = item;
18321
+ const str = (v) => typeof v === "string" && v.length > 0 ? v : null;
18322
+ switch (field) {
18323
+ case "dispatch":
18324
+ return str(o.capability);
18325
+ case "lifecycle":
18326
+ return `${String(o.phase)} ${String(o.component)} ${String(o.method)}`;
18327
+ case "emits":
18328
+ case "subscribesTo":
18329
+ return `${String(o.topic)} ${o.event === void 0 ? "" : String(o.event)}`;
18330
+ case "trustedLinks":
18331
+ return str(o.subsystem);
18332
+ case "allow":
18333
+ return str(o.code);
18334
+ // lint.allow
18335
+ case "findings":
18336
+ return str(o.code);
18337
+ // an interface method's findings
18338
+ case "invariants":
18339
+ return str(o.id);
18340
+ case "patterns":
18341
+ return str(o.id);
18342
+ case "boundaries":
18343
+ return str(o.name);
18344
+ case "globalRequirements":
18345
+ return str(o.description);
18346
+ case "databases":
18347
+ return str(o.id) ?? str(o.name);
18348
+ // A flow step's own arrays, addressed by the thing the author named them
18349
+ // for. Their `step` is a RELOCATABLE number, never an identity — a switch
18350
+ // case is the value it matches and a catch clause is the error it catches,
18351
+ // and both survive the renumbering that moves their target. (A parallel
18352
+ // arm falls to the default: named, it merges by name; unnamed, it carries
18353
+ // no identity at all and the list replaces wholesale, which is the same
18354
+ // rule everywhere else.)
18355
+ case "cases":
18356
+ return str(o.value);
18357
+ case "catches":
18358
+ return str(o.error);
18359
+ default:
18360
+ return str(o.name) ?? str(o.id);
18361
+ }
18362
+ }
18363
+ function stepFieldsFor(type) {
18364
+ const own = STEP_TYPE_FIELDS[type] ?? [];
18365
+ const allowed = /* @__PURE__ */ new Set([...STEP_COMMON_FIELDS, ...own]);
18366
+ for (const field of own) {
18367
+ const twin = STEP_LABEL_TWINS[field];
18368
+ if (twin) allowed.add(twin);
18369
+ }
18370
+ return allowed;
18371
+ }
18372
+ function reportIdentityOf(field, item) {
18373
+ if (item !== null && typeof item === "object") {
18374
+ const step = item.stepNumber;
18375
+ if (field === "narrative" && typeof step === "number") return `step ${step}`;
18376
+ }
18377
+ return identityKeyOf(field, item);
18378
+ }
18379
+ function specChanges(before, after, prefix = "") {
18380
+ const at = (key) => prefix ? `${prefix}.${key}` : key;
18381
+ const isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
18382
+ if (Array.isArray(before) && Array.isArray(after)) {
18383
+ if (after.length === 0 && before.length > 0) {
18384
+ return [{ path: prefix, change: "cleared", before: summarizeValue(before) }];
18385
+ }
18386
+ const identified = [...before, ...after].every((i) => reportIdentityOf(prefix.split(".").pop() ?? prefix, i) !== null);
18387
+ if (!identified) {
18388
+ return JSON.stringify(before) === JSON.stringify(after) ? [] : [{ path: prefix, change: "set", before: summarizeValue(before), after: summarizeValue(after) }];
18389
+ }
18390
+ const field = prefix.split(".").pop() ?? prefix;
18391
+ const keyOf = (i) => String(reportIdentityOf(field, i));
18392
+ const changes = [];
18393
+ for (const item of before) {
18394
+ const match = after.find((a) => keyOf(a) === keyOf(item));
18395
+ if (match === void 0) changes.push({ path: at(keyOf(item)), change: "removed", before: summarizeValue(item) });
18396
+ else changes.push(...specChanges(item, match, at(keyOf(item))));
18397
+ }
18398
+ for (const item of after) {
18399
+ if (!before.some((b) => keyOf(b) === keyOf(item))) {
18400
+ changes.push({ path: at(keyOf(item)), change: "added", after: summarizeValue(item) });
18401
+ }
18402
+ }
18403
+ return changes;
18404
+ }
18405
+ if (isPlain(before) && isPlain(after)) {
18406
+ const changes = [];
18407
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])) {
18408
+ const b = before[key];
18409
+ const a = after[key];
18410
+ if (b === void 0 && a !== void 0) changes.push({ path: at(key), change: "added", after: summarizeValue(a) });
18411
+ else if (b !== void 0 && a === void 0) changes.push({ path: at(key), change: "removed", before: summarizeValue(b) });
18412
+ else changes.push(...specChanges(b, a, at(key)));
18413
+ }
18414
+ return changes;
18415
+ }
18416
+ if (JSON.stringify(before) === JSON.stringify(after)) return [];
18417
+ return [{ path: prefix, change: "set", before: summarizeValue(before), after: summarizeValue(after) }];
18418
+ }
18313
18419
  function workspaceFor(rootDir) {
18314
18420
  const key = path16.resolve(rootDir);
18315
18421
  let ws = workspaces.get(key);
@@ -18538,7 +18644,7 @@ function findLegacySpecFiles() {
18538
18644
  function updateSpec(kind, id, delta, hooks) {
18539
18645
  return current().updateSpec(kind, id, delta, hooks);
18540
18646
  }
18541
- var fs11, path16, NO_ROOT_SUBSYSTEMS, SIGNATURE_TTL_MS, SpecWorkspace, workspaces;
18647
+ var fs11, path16, NO_ROOT_SUBSYSTEMS, SIGNATURE_TTL_MS, STEP_TYPE_FIELDS, STEP_COMMON_FIELDS, STEP_LABEL_TWINS, SpecWorkspace, workspaces;
18542
18648
  var init_specs2 = __esm({
18543
18649
  "src/core/specs.ts"() {
18544
18650
  "use strict";
@@ -18556,6 +18662,36 @@ var init_specs2 = __esm({
18556
18662
  init_diagram();
18557
18663
  NO_ROOT_SUBSYSTEMS = /* @__PURE__ */ new Set();
18558
18664
  SIGNATURE_TTL_MS = 2e3;
18665
+ STEP_TYPE_FIELDS = {
18666
+ local: [],
18667
+ call: ["targetComponent", "targetMethod", "auth", "detach"],
18668
+ register: ["targetComponent", "targetMethod"],
18669
+ dispatch: ["targetComponent", "capability", "auth", "detach"],
18670
+ branch: ["condition", "onTrueStep", "onFalseStep"],
18671
+ switch: ["on", "cases", "defaultStep"],
18672
+ loop: ["loopKind", "over", "condition", "endStep"],
18673
+ try: ["endStep", "catches", "finallyStep"],
18674
+ parallel: ["endStep", "branches"],
18675
+ jump: ["toStep"],
18676
+ return: ["outcome"],
18677
+ throw: ["error"]
18678
+ };
18679
+ STEP_COMMON_FIELDS = [
18680
+ "stepNumber",
18681
+ "label",
18682
+ "description",
18683
+ "type",
18684
+ "assertsGuarantees",
18685
+ "assertsInvariants"
18686
+ ];
18687
+ STEP_LABEL_TWINS = {
18688
+ onTrueStep: "onTrueLabel",
18689
+ onFalseStep: "onFalseLabel",
18690
+ defaultStep: "defaultLabel",
18691
+ endStep: "endLabel",
18692
+ finallyStep: "finallyLabel",
18693
+ toStep: "toLabel"
18694
+ };
18559
18695
  SpecWorkspace = class {
18560
18696
  constructor(rootDir) {
18561
18697
  this.cachedIndex = null;
@@ -19793,9 +19929,39 @@ var init_specs2 = __esm({
19793
19929
  // Granular delta updates (sdd_update_spec)
19794
19930
  // -------------------------------------------------------------------------
19795
19931
  /**
19796
- * Returns non-fatal notices about the merge (e.g. an insert whose position
19797
- * was a jump target, so the relocated jumps now bypass the inserted step) —
19798
- * empty when the merge had nothing to warn about.
19932
+ * Apply a delta to one stored spec, in this ORDER — the order is the contract,
19933
+ * because every stage below can see what the stage above left:
19934
+ *
19935
+ * 1. LOAD the stored spec for the addressed kind and id; a spec that does
19936
+ * not exist refuses, and a mis-selected `system` kind refuses by name.
19937
+ * 2. REFUSE unknown delta keys, whole. A key the level's schema does not
19938
+ * know is dropped on write, so the edit would report a success it never
19939
+ * performed.
19940
+ * 3. QUALIFY the delta's id-bearing references against the spec's namespace,
19941
+ * BEFORE the merge — post-merge a bare id is ambiguous.
19942
+ * 4. MERGE. Identity-keyed arrays upsert element by element and honour their
19943
+ * delete markers, at every depth. Narrative step deltas apply in ASCENDING
19944
+ * stepNumber order, each against the numbering the earlier entries of the
19945
+ * same delta left behind — delete step 3 and step 7 becomes step 6, so a
19946
+ * second delete written as 7 addresses what used to be step 8. That is why
19947
+ * a delete may restate the step's `label` or `description` as a guard, and
19948
+ * why labels are the reliable way to name a jump target. Inserts and
19949
+ * deletes relocate every jump field in the same narrative around them; a
19950
+ * step whose `type` changes is REBUILT for its new type, keeping its
19951
+ * description and label.
19952
+ * 5. UNSET the fields the delta names, at the level each `unset` sits in.
19953
+ * 6. RESOLVE symbolic `*Label` references against the MERGED numbering — after
19954
+ * the merge, so a delta may anchor on a label that only pre-existing steps
19955
+ * carry, and a label in the delta retargets the stored numeric jump it
19956
+ * twins. An unresolved reference aborts the whole update.
19957
+ * 7. COMPARE with what is stored, both read through the level's canonical
19958
+ * schema. Equal means nothing is written and `written` is false.
19959
+ * 8. GATE the merged spec through the caller's hook, then SAVE.
19960
+ *
19961
+ * Returns the change report, whose `notices` carry the non-fatal effects (an
19962
+ * insert whose position was a jump target, so the relocated jumps now bypass
19963
+ * the inserted step; the fields a retype dropped) — empty when the merge had
19964
+ * nothing to say.
19799
19965
  */
19800
19966
  updateSpec(kind, id, delta, hooks) {
19801
19967
  const notices = [];
@@ -19873,6 +20039,14 @@ var init_specs2 = __esm({
19873
20039
  return refs;
19874
20040
  };
19875
20041
  const normalizeIdentity = (k) => String(k ?? "").toLowerCase().replace(/[_\-\s]/g, "");
20042
+ const unsetFieldsOf = (item) => Array.isArray(item?.unset) ? item.unset.filter((f) => typeof f === "string") : [];
20043
+ const withUnset = (merged, deltaItem) => {
20044
+ const fields = unsetFieldsOf(deltaItem);
20045
+ const out = { ...merged };
20046
+ delete out.unset;
20047
+ for (const field of fields) delete out[field];
20048
+ return out;
20049
+ };
19876
20050
  const assertDeltaMarkers = (item, label) => {
19877
20051
  if (!item || typeof item !== "object") return;
19878
20052
  if ("remove" in item && typeof item.remove !== "boolean") {
@@ -19901,14 +20075,113 @@ var init_specs2 = __esm({
19901
20075
  );
19902
20076
  }
19903
20077
  };
19904
- const mergeNarrative = (existingSteps, deltaSteps) => {
20078
+ const NO_NESTED_MERGE = /* @__PURE__ */ new Set(["ext", "narrative", "unset"]);
20079
+ const mergeElement = (existing, deltaItem) => {
20080
+ const out = { ...existing, ...deltaItem };
20081
+ if (!existing || typeof existing !== "object" || !deltaItem || typeof deltaItem !== "object") return out;
20082
+ for (const [key, value] of Object.entries(deltaItem)) {
20083
+ if (NO_NESTED_MERGE.has(key) || !Array.isArray(value) || value.length === 0) continue;
20084
+ const before = existing[key];
20085
+ if (before !== void 0 && !Array.isArray(before)) continue;
20086
+ const stored = Array.isArray(before) ? before : [];
20087
+ if (!isIdentifiedArray(key, stored, value)) continue;
20088
+ out[key] = mergeIdentifiedArray(key, stored, value);
20089
+ }
20090
+ return out;
20091
+ };
20092
+ const retargetByLabel = (merged, deltaStep) => {
20093
+ const out = { ...merged };
20094
+ for (const [stepField, labelField] of Object.entries(STEP_LABEL_TWINS)) {
20095
+ if (deltaStep?.[labelField] !== void 0 && deltaStep?.[stepField] === void 0) delete out[stepField];
20096
+ }
20097
+ for (const key of ["cases", "catches"]) {
20098
+ const deltaEntries = deltaStep?.[key];
20099
+ if (!Array.isArray(deltaEntries) || !Array.isArray(out[key])) continue;
20100
+ const retargeted = new Set(
20101
+ deltaEntries.filter((e) => e?.label !== void 0 && e?.step === void 0).map((e) => identityKeyOf(key, e)).filter((k) => k !== null)
20102
+ );
20103
+ if (!retargeted.size) continue;
20104
+ out[key] = out[key].map((e) => {
20105
+ const k = identityKeyOf(key, e);
20106
+ if (k === null || !retargeted.has(k)) return e;
20107
+ const { step, ...rest } = e;
20108
+ return rest;
20109
+ });
20110
+ }
20111
+ return out;
20112
+ };
20113
+ const rebuildOnRetype = (stored, deltaStep, merged, methodName) => {
20114
+ const newType = merged?.type;
20115
+ if (typeof newType !== "string" || stored?.type === newType) return merged;
20116
+ if (!Object.prototype.hasOwnProperty.call(STEP_TYPE_FIELDS, newType)) return merged;
20117
+ const allowed = stepFieldsFor(newType);
20118
+ const stated = Object.keys(deltaStep).filter(
20119
+ (k) => k !== "type" && deltaStep[k] !== void 0 && !allowed.has(k)
20120
+ );
20121
+ if (stated.length) {
20122
+ throw new Error(
20123
+ `Refusing to retype narrative step ${stored.stepNumber} of "${methodName}" from "${stored.type}" to "${newType}": the same delta sets ${stated.map((f) => `"${f}"`).join(", ")}, which a ${newType} step cannot carry. Drop those keys, or retype to a step type that carries them.`
20124
+ );
20125
+ }
20126
+ const out = {};
20127
+ const dropped = [];
20128
+ for (const [key, value] of Object.entries(merged)) {
20129
+ if (allowed.has(key)) out[key] = value;
20130
+ else dropped.push(key);
20131
+ }
20132
+ if (dropped.length) {
20133
+ notices.push(
20134
+ `Narrative step ${stored.stepNumber} of "${methodName}" was retyped ${stored.type} -> ${newType}: ${dropped.join(", ")} dropped \u2014 a ${newType} step cannot carry ${dropped.length === 1 ? "it" : "them"}. Its description and label are kept.`
20135
+ );
20136
+ }
20137
+ return out;
20138
+ };
20139
+ const mergeNarrative = (methodName, existingSteps, deltaSteps) => {
19905
20140
  let steps = [...existingSteps];
19906
20141
  const sortedDeltas = [...deltaSteps].sort((a, b) => a.stepNumber - b.stepNumber);
19907
20142
  for (const deltaStep of sortedDeltas) {
19908
20143
  const stepNum = deltaStep.stepNumber;
20144
+ if (typeof stepNum !== "number") {
20145
+ throw new Error(
20146
+ `Refusing to update the narrative of "${methodName}": a step delta must carry the "stepNumber" it addresses, got ${JSON.stringify(stepNum ?? null)}.`
20147
+ );
20148
+ }
20149
+ if ("remove" in deltaStep && typeof deltaStep.remove !== "boolean") {
20150
+ throw new Error(
20151
+ `Refusing to update narrative step ${stepNum} of "${methodName}": "remove" must be the boolean true, got ${JSON.stringify(deltaStep.remove)}. A non-boolean is ignored, which would leave the step in place while reporting success.`
20152
+ );
20153
+ }
20154
+ if ("action" in deltaStep && deltaStep.action !== "insert" && deltaStep.action !== "delete") {
20155
+ throw new Error(
20156
+ `Refusing to update narrative step ${stepNum} of "${methodName}": unknown action ${JSON.stringify(deltaStep.action)} \u2014 expected "insert" or "delete", or no action at all to edit the step in place. An unknown verb is ignored, which would report success for an edit never made.`
20157
+ );
20158
+ }
20159
+ if ("captureJumps" in deltaStep && deltaStep.action !== "insert") {
20160
+ throw new Error(
20161
+ `Refusing to update narrative step ${stepNum} of "${methodName}": "captureJumps" only means anything on an inserted step \u2014 it decides whether jumps aimed at that number follow the shifted original or land on the new step. On any other delta it is ignored.`
20162
+ );
20163
+ }
19909
20164
  if (deltaStep.action === "delete" || deltaStep.remove === true) {
19910
20165
  const idx = steps.findIndex((s) => s.stepNumber === stepNum);
19911
20166
  if (idx !== -1) {
20167
+ const victim = steps[idx];
20168
+ for (const field of ["label", "description"]) {
20169
+ if (deltaStep[field] === void 0) continue;
20170
+ if (victim[field] !== deltaStep[field]) {
20171
+ throw new Error(
20172
+ `Refusing to delete narrative step ${stepNum}: the delta states ${field} ${JSON.stringify(deltaStep[field])}, but step ${stepNum} holds ${JSON.stringify(victim[field] ?? null)}. Step deltas apply in ascending order against the numbering earlier entries of the same delta left behind, so a later delete can address a step that has already moved.`
20173
+ );
20174
+ }
20175
+ }
20176
+ if (typeof victim.endStep === "number") {
20177
+ const body = steps.filter((s) => s.stepNumber > stepNum && s.stepNumber <= victim.endStep);
20178
+ if (body.length) {
20179
+ const span = body.length === 1 ? `step ${body[0].stepNumber}` : `steps ${body[0].stepNumber}-${body[body.length - 1].stepNumber}`;
20180
+ throw new Error(
20181
+ `Cannot delete narrative step ${stepNum}: it is a ${victim.type} header and ${span} ${body.length === 1 ? "is" : "are"} still its body. Deleting it would leave them in place with no ${victim.type} around them \u2014 a change of meaning nothing later reports. Dissolve the region first: retype step ${stepNum} to a type that carries no region (the region fields are dropped and reported), then delete it.`
20182
+ );
20183
+ }
20184
+ }
19912
20185
  const referrers = steps.filter((s) => s.stepNumber !== stepNum).map((s) => ({ n: s.stepNumber, refs: jumpRefsTo(s, stepNum) })).filter((r) => r.refs.length > 0);
19913
20186
  if (referrers.length) {
19914
20187
  throw new Error(
@@ -19921,6 +20194,11 @@ var init_specs2 = __esm({
19921
20194
  stepNum,
19922
20195
  -1
19923
20196
  ));
20197
+ } else {
20198
+ const present = steps.length ? ` \u2014 it has steps ${steps[0].stepNumber}-${steps[steps.length - 1].stepNumber}` : " \u2014 it has no steps";
20199
+ throw new Error(
20200
+ `Refusing to delete narrative step ${stepNum}: this narrative has no step ${stepNum}${present}. Step deltas apply in ascending order against the numbering earlier entries of the same delta left behind, so a later delete can address a step that has already moved.`
20201
+ );
19924
20202
  }
19925
20203
  } else if (deltaStep.action === "insert") {
19926
20204
  const capture = deltaStep.captureJumps === true;
@@ -19939,18 +20217,17 @@ var init_specs2 = __esm({
19939
20217
  capture
19940
20218
  ));
19941
20219
  const { action, remove, captureJumps, ...cleanStep } = deltaStep;
19942
- steps.push(cleanStep);
20220
+ steps.push(withUnset(cleanStep, deltaStep));
19943
20221
  } else {
19944
20222
  const idx = steps.findIndex((s) => s.stepNumber === stepNum);
19945
20223
  if (idx !== -1) {
19946
20224
  const { action, remove, ...cleanStep } = deltaStep;
19947
- steps[idx] = {
19948
- ...steps[idx],
19949
- ...cleanStep
19950
- };
20225
+ const stored = steps[idx];
20226
+ const merged = withUnset(retargetByLabel(mergeElement(stored, cleanStep), cleanStep), deltaStep);
20227
+ steps[idx] = rebuildOnRetype(stored, cleanStep, merged, methodName);
19951
20228
  } else {
19952
20229
  const { action, remove, ...cleanStep } = deltaStep;
19953
- steps.push(cleanStep);
20230
+ steps.push(withUnset(cleanStep, deltaStep));
19954
20231
  }
19955
20232
  }
19956
20233
  }
@@ -19977,20 +20254,19 @@ var init_specs2 = __esm({
19977
20254
  } else {
19978
20255
  const existingMethod = merged[idx];
19979
20256
  let narrative = existingMethod.narrative ? [...existingMethod.narrative] : [];
19980
- if (deltaMethod.narrative && Array.isArray(deltaMethod.narrative)) {
19981
- narrative = mergeNarrative(narrative, deltaMethod.narrative);
20257
+ if (Array.isArray(deltaMethod.narrative)) {
20258
+ narrative = deltaMethod.narrative.length === 0 ? [] : mergeNarrative(String(deltaMethod.name), narrative, deltaMethod.narrative);
19982
20259
  }
19983
20260
  const { narrative: _, ...cleanMethod } = deltaMethod;
19984
- merged[idx] = {
19985
- ...existingMethod,
19986
- ...cleanMethod,
20261
+ merged[idx] = withUnset({
20262
+ ...mergeElement(existingMethod, cleanMethod),
19987
20263
  ...deltaMethod.ext !== void 0 ? { ext: mergeExt(existingMethod.ext, deltaMethod.ext) } : {},
19988
20264
  narrative
19989
- };
20265
+ }, deltaMethod);
19990
20266
  }
19991
20267
  } else {
19992
20268
  assertUnmatchedIsAnAddition(deltaMethod, deltaMethod.name, merged.map((m) => m.name), "method");
19993
- merged.push(deltaMethod);
20269
+ merged.push(withUnset(deltaMethod, deltaMethod));
19994
20270
  }
19995
20271
  }
19996
20272
  return merged;
@@ -20004,18 +20280,14 @@ var init_specs2 = __esm({
20004
20280
  if (deltaItem.remove === true || deltaItem.action === "delete") {
20005
20281
  merged.splice(idx, 1);
20006
20282
  } else {
20007
- merged[idx] = {
20008
- ...merged[idx],
20009
- ...deltaItem,
20010
- ...deltaItem.ext !== void 0 ? { ext: mergeExt(merged[idx].ext, deltaItem.ext) } : {},
20011
- // An interface method's findings upsert and delete by code, like
20012
- // lint.allow; an empty list still clears them outright.
20013
- ...Array.isArray(deltaItem.findings) && deltaItem.findings.length > 0 ? { findings: mergeIdentifiedArray("findings", merged[idx].findings ?? [], deltaItem.findings) } : {}
20014
- };
20283
+ merged[idx] = withUnset({
20284
+ ...mergeElement(merged[idx], deltaItem),
20285
+ ...deltaItem.ext !== void 0 ? { ext: mergeExt(merged[idx].ext, deltaItem.ext) } : {}
20286
+ }, deltaItem);
20015
20287
  }
20016
20288
  } else {
20017
20289
  assertUnmatchedIsAnAddition(deltaItem, deltaItem.name, merged.map((i) => i.name), "entry");
20018
- merged.push(deltaItem);
20290
+ merged.push(withUnset(deltaItem, deltaItem));
20019
20291
  }
20020
20292
  }
20021
20293
  return merged;
@@ -20029,11 +20301,11 @@ var init_specs2 = __esm({
20029
20301
  if (deltaItem.remove === true || deltaItem.action === "delete") {
20030
20302
  merged.splice(idx, 1);
20031
20303
  } else {
20032
- merged[idx] = { ...merged[idx], ...deltaItem };
20304
+ merged[idx] = withUnset(mergeElement(merged[idx], deltaItem), deltaItem);
20033
20305
  }
20034
20306
  } else {
20035
20307
  assertUnmatchedIsAnAddition(deltaItem, keyOf(deltaItem), merged.map(keyOf), "entry");
20036
- merged.push(deltaItem);
20308
+ merged.push(withUnset(deltaItem, deltaItem));
20037
20309
  }
20038
20310
  }
20039
20311
  return merged.map(({ action, remove, ...item }) => item);
@@ -20048,52 +20320,15 @@ var init_specs2 = __esm({
20048
20320
  if (deltaItem.remove === true || deltaItem.action === "delete") {
20049
20321
  merged.splice(idx, 1);
20050
20322
  } else {
20051
- merged[idx] = {
20052
- ...merged[idx],
20053
- ...deltaItem
20054
- };
20323
+ merged[idx] = withUnset(mergeElement(merged[idx], deltaItem), deltaItem);
20055
20324
  }
20056
20325
  } else {
20057
20326
  assertUnmatchedIsAnAddition(deltaItem, piKey(deltaItem), merged.map(piKey), "publicInterface");
20058
- merged.push(deltaItem);
20327
+ merged.push(withUnset(deltaItem, deltaItem));
20059
20328
  }
20060
20329
  }
20061
20330
  return merged;
20062
20331
  };
20063
- const identityKeyOf = (field, item) => {
20064
- if (item === null || typeof item !== "object") return null;
20065
- const o = item;
20066
- const str = (v) => typeof v === "string" && v.length > 0 ? v : null;
20067
- switch (field) {
20068
- case "dispatch":
20069
- return str(o.capability);
20070
- case "lifecycle":
20071
- return `${String(o.phase)} ${String(o.component)} ${String(o.method)}`;
20072
- case "emits":
20073
- case "subscribesTo":
20074
- return `${String(o.topic)} ${o.event === void 0 ? "" : String(o.event)}`;
20075
- case "trustedLinks":
20076
- return str(o.subsystem);
20077
- case "allow":
20078
- return str(o.code);
20079
- // lint.allow
20080
- case "findings":
20081
- return str(o.code);
20082
- // an interface method's findings
20083
- case "invariants":
20084
- return str(o.id);
20085
- case "patterns":
20086
- return str(o.id);
20087
- case "boundaries":
20088
- return str(o.name);
20089
- case "globalRequirements":
20090
- return str(o.description);
20091
- case "databases":
20092
- return str(o.id) ?? str(o.name);
20093
- default:
20094
- return str(o.name) ?? str(o.id);
20095
- }
20096
- };
20097
20332
  const mergeIdentifiedArray = (field, existing, delta2) => {
20098
20333
  const merged = [...existing];
20099
20334
  for (const deltaItem of delta2) {
@@ -20103,7 +20338,7 @@ var init_specs2 = __esm({
20103
20338
  const isDelete = deltaItem?.remove === true || deltaItem?.action === "delete";
20104
20339
  if (idx !== -1) {
20105
20340
  if (isDelete) merged.splice(idx, 1);
20106
- else merged[idx] = { ...merged[idx], ...deltaItem };
20341
+ else merged[idx] = withUnset(mergeElement(merged[idx], deltaItem), deltaItem);
20107
20342
  } else {
20108
20343
  if (key !== null) {
20109
20344
  assertUnmatchedIsAnAddition(
@@ -20115,7 +20350,7 @@ var init_specs2 = __esm({
20115
20350
  } else if (isDelete) {
20116
20351
  throw new Error(`Refusing to delete a ${field} entry with no resolvable identity.`);
20117
20352
  }
20118
- merged.push(deltaItem);
20353
+ merged.push(withUnset(deltaItem, deltaItem));
20119
20354
  }
20120
20355
  }
20121
20356
  return merged.map((item) => item && typeof item === "object" ? (({ action, remove, ...rest }) => rest)(item) : item);
@@ -20124,6 +20359,7 @@ var init_specs2 = __esm({
20124
20359
  const mergeDelta = (existing, delta2) => {
20125
20360
  const res = { ...existing };
20126
20361
  for (const [key, value] of Object.entries(delta2)) {
20362
+ if (key === "unset") continue;
20127
20363
  if (value === void 0 || value === null) {
20128
20364
  continue;
20129
20365
  }
@@ -20151,7 +20387,7 @@ var init_specs2 = __esm({
20151
20387
  res[key] = value;
20152
20388
  }
20153
20389
  }
20154
- return res;
20390
+ return withUnset(res, delta2);
20155
20391
  };
20156
20392
  const qualifyDeltaRefs = (d) => {
20157
20393
  if (kind === "system") return d;
@@ -20205,9 +20441,9 @@ var init_specs2 = __esm({
20205
20441
  };
20206
20442
  const unsetFields = Array.isArray(delta.unset) ? delta.unset.filter((f) => typeof f === "string") : [];
20207
20443
  const { unset: _unset, ...mergeableDelta } = delta;
20444
+ const storedBefore = JSON.parse(JSON.stringify(result));
20208
20445
  const mergedResult = mergeDelta(result, qualifyDeltaRefs(mergeableDelta));
20209
20446
  for (const field of unsetFields) delete mergedResult[field];
20210
- mergedResult.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
20211
20447
  if (kind === "implementation" && Array.isArray(mergedResult.methods)) {
20212
20448
  const labelErrors = [];
20213
20449
  for (const m of mergedResult.methods) {
@@ -20218,6 +20454,24 @@ var init_specs2 = __esm({
20218
20454
  - ${labelErrors.join("\n- ")}`);
20219
20455
  }
20220
20456
  }
20457
+ const canonical = (spec) => {
20458
+ const parsed = deltaSchema.safeParse(spec);
20459
+ const value = { ...parsed.success ? parsed.data : spec };
20460
+ delete value.updatedAt;
20461
+ return value;
20462
+ };
20463
+ const changes = specChanges(canonical(storedBefore), canonical(mergedResult));
20464
+ if (changes.length === 0) {
20465
+ return {
20466
+ kind,
20467
+ id,
20468
+ written: false,
20469
+ changes,
20470
+ notices,
20471
+ summary: `No change to ${kind} "${id}" \u2014 the delta matches what is stored, so nothing was written.`
20472
+ };
20473
+ }
20474
+ mergedResult.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
20221
20475
  const gateNotices = hooks?.gate?.(kind, mergedResult);
20222
20476
  if (gateNotices?.length) notices.push(...gateNotices);
20223
20477
  const opts = {
@@ -20243,7 +20497,14 @@ var init_specs2 = __esm({
20243
20497
  notices.push(...this.saveTypeSpec(mergedResult));
20244
20498
  break;
20245
20499
  }
20246
- return notices;
20500
+ return {
20501
+ kind,
20502
+ id,
20503
+ written: true,
20504
+ changes,
20505
+ notices,
20506
+ summary: `Updated ${kind} "${id}": ${changes.length} change${changes.length === 1 ? "" : "s"}.`
20507
+ };
20247
20508
  }
20248
20509
  };
20249
20510
  workspaces = /* @__PURE__ */ new Map();
@@ -26084,6 +26345,7 @@ __export(src_exports, {
26084
26345
  skillExtendErrors: () => skillExtendErrors,
26085
26346
  skillsDirForTarget: () => skillsDirForTarget,
26086
26347
  snapshotSpecFiles: () => snapshotSpecFiles,
26348
+ specChanges: () => specChanges,
26087
26349
  specPathsInScope: () => specPathsInScope,
26088
26350
  splitNamespace: () => splitNamespace,
26089
26351
  stateIdEquals: () => stateIdEquals,
@@ -26804,6 +27066,7 @@ function loadProjectConfig4() {
26804
27066
  skillExtendErrors,
26805
27067
  skillsDirForTarget,
26806
27068
  snapshotSpecFiles,
27069
+ specChanges,
26807
27070
  specPathsInScope,
26808
27071
  splitNamespace,
26809
27072
  stateIdEquals,