@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/cli/index.js CHANGED
@@ -65,7 +65,7 @@ var init_defaults = __esm({
65
65
  copilot: ".github/prompts",
66
66
  codex: ".codex/agents"
67
67
  };
68
- WAIRON_VERSION = "5.1.1-dev.33";
68
+ WAIRON_VERSION = "5.1.1-dev.35";
69
69
  GITHUB_REPO = "SYW-Apps/Waffle-AIron";
70
70
  SUPPORTED_ALIASES = ["wai"];
71
71
  SCAN_EXCLUDE_DIRS = /* @__PURE__ */ new Set([
@@ -23143,6 +23143,7 @@ __export(specs_exports, {
23143
23143
  setExecutionTier: () => setExecutionTier,
23144
23144
  setProjectType: () => setProjectType,
23145
23145
  snapshotSpecFiles: () => snapshotSpecFiles,
23146
+ specChanges: () => specChanges,
23146
23147
  specPathsInScope: () => specPathsInScope,
23147
23148
  updateSpec: () => updateSpec,
23148
23149
  upsertPackSelection: () => upsertPackSelection,
@@ -23526,6 +23527,112 @@ function computeSpecTreeSignature(dirs) {
23526
23527
  }
23527
23528
  return parts.join("|");
23528
23529
  }
23530
+ function summarizeValue(value) {
23531
+ if (value === void 0) return "";
23532
+ const text2 = typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
23533
+ return text2.length > 120 ? `${text2.slice(0, 117)}...` : text2;
23534
+ }
23535
+ function identityKeyOf(field, item) {
23536
+ if (item === null || typeof item !== "object") return null;
23537
+ const o = item;
23538
+ const str = (v) => typeof v === "string" && v.length > 0 ? v : null;
23539
+ switch (field) {
23540
+ case "dispatch":
23541
+ return str(o.capability);
23542
+ case "lifecycle":
23543
+ return `${String(o.phase)} ${String(o.component)} ${String(o.method)}`;
23544
+ case "emits":
23545
+ case "subscribesTo":
23546
+ return `${String(o.topic)} ${o.event === void 0 ? "" : String(o.event)}`;
23547
+ case "trustedLinks":
23548
+ return str(o.subsystem);
23549
+ case "allow":
23550
+ return str(o.code);
23551
+ // lint.allow
23552
+ case "findings":
23553
+ return str(o.code);
23554
+ // an interface method's findings
23555
+ case "invariants":
23556
+ return str(o.id);
23557
+ case "patterns":
23558
+ return str(o.id);
23559
+ case "boundaries":
23560
+ return str(o.name);
23561
+ case "globalRequirements":
23562
+ return str(o.description);
23563
+ case "databases":
23564
+ return str(o.id) ?? str(o.name);
23565
+ // A flow step's own arrays, addressed by the thing the author named them
23566
+ // for. Their `step` is a RELOCATABLE number, never an identity — a switch
23567
+ // case is the value it matches and a catch clause is the error it catches,
23568
+ // and both survive the renumbering that moves their target. (A parallel
23569
+ // arm falls to the default: named, it merges by name; unnamed, it carries
23570
+ // no identity at all and the list replaces wholesale, which is the same
23571
+ // rule everywhere else.)
23572
+ case "cases":
23573
+ return str(o.value);
23574
+ case "catches":
23575
+ return str(o.error);
23576
+ default:
23577
+ return str(o.name) ?? str(o.id);
23578
+ }
23579
+ }
23580
+ function stepFieldsFor(type) {
23581
+ const own = STEP_TYPE_FIELDS[type] ?? [];
23582
+ const allowed = /* @__PURE__ */ new Set([...STEP_COMMON_FIELDS, ...own]);
23583
+ for (const field of own) {
23584
+ const twin = STEP_LABEL_TWINS[field];
23585
+ if (twin) allowed.add(twin);
23586
+ }
23587
+ return allowed;
23588
+ }
23589
+ function reportIdentityOf(field, item) {
23590
+ if (item !== null && typeof item === "object") {
23591
+ const step = item.stepNumber;
23592
+ if (field === "narrative" && typeof step === "number") return `step ${step}`;
23593
+ }
23594
+ return identityKeyOf(field, item);
23595
+ }
23596
+ function specChanges(before, after, prefix = "") {
23597
+ const at = (key) => prefix ? `${prefix}.${key}` : key;
23598
+ const isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
23599
+ if (Array.isArray(before) && Array.isArray(after)) {
23600
+ if (after.length === 0 && before.length > 0) {
23601
+ return [{ path: prefix, change: "cleared", before: summarizeValue(before) }];
23602
+ }
23603
+ const identified = [...before, ...after].every((i) => reportIdentityOf(prefix.split(".").pop() ?? prefix, i) !== null);
23604
+ if (!identified) {
23605
+ return JSON.stringify(before) === JSON.stringify(after) ? [] : [{ path: prefix, change: "set", before: summarizeValue(before), after: summarizeValue(after) }];
23606
+ }
23607
+ const field = prefix.split(".").pop() ?? prefix;
23608
+ const keyOf = (i) => String(reportIdentityOf(field, i));
23609
+ const changes = [];
23610
+ for (const item of before) {
23611
+ const match = after.find((a) => keyOf(a) === keyOf(item));
23612
+ if (match === void 0) changes.push({ path: at(keyOf(item)), change: "removed", before: summarizeValue(item) });
23613
+ else changes.push(...specChanges(item, match, at(keyOf(item))));
23614
+ }
23615
+ for (const item of after) {
23616
+ if (!before.some((b) => keyOf(b) === keyOf(item))) {
23617
+ changes.push({ path: at(keyOf(item)), change: "added", after: summarizeValue(item) });
23618
+ }
23619
+ }
23620
+ return changes;
23621
+ }
23622
+ if (isPlain(before) && isPlain(after)) {
23623
+ const changes = [];
23624
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])) {
23625
+ const b = before[key];
23626
+ const a = after[key];
23627
+ if (b === void 0 && a !== void 0) changes.push({ path: at(key), change: "added", after: summarizeValue(a) });
23628
+ else if (b !== void 0 && a === void 0) changes.push({ path: at(key), change: "removed", before: summarizeValue(b) });
23629
+ else changes.push(...specChanges(b, a, at(key)));
23630
+ }
23631
+ return changes;
23632
+ }
23633
+ if (JSON.stringify(before) === JSON.stringify(after)) return [];
23634
+ return [{ path: prefix, change: "set", before: summarizeValue(before), after: summarizeValue(after) }];
23635
+ }
23529
23636
  function workspaceFor(rootDir) {
23530
23637
  const key = path23.resolve(rootDir);
23531
23638
  let ws = workspaces.get(key);
@@ -23754,7 +23861,7 @@ function findLegacySpecFiles() {
23754
23861
  function updateSpec(kind, id, delta, hooks) {
23755
23862
  return current().updateSpec(kind, id, delta, hooks);
23756
23863
  }
23757
- var fs17, path23, NO_ROOT_SUBSYSTEMS, SIGNATURE_TTL_MS, SpecWorkspace, workspaces;
23864
+ var fs17, path23, NO_ROOT_SUBSYSTEMS, SIGNATURE_TTL_MS, STEP_TYPE_FIELDS, STEP_COMMON_FIELDS, STEP_LABEL_TWINS, SpecWorkspace, workspaces;
23758
23865
  var init_specs2 = __esm({
23759
23866
  "src/core/specs.ts"() {
23760
23867
  "use strict";
@@ -23772,6 +23879,36 @@ var init_specs2 = __esm({
23772
23879
  init_diagram();
23773
23880
  NO_ROOT_SUBSYSTEMS = /* @__PURE__ */ new Set();
23774
23881
  SIGNATURE_TTL_MS = 2e3;
23882
+ STEP_TYPE_FIELDS = {
23883
+ local: [],
23884
+ call: ["targetComponent", "targetMethod", "auth", "detach"],
23885
+ register: ["targetComponent", "targetMethod"],
23886
+ dispatch: ["targetComponent", "capability", "auth", "detach"],
23887
+ branch: ["condition", "onTrueStep", "onFalseStep"],
23888
+ switch: ["on", "cases", "defaultStep"],
23889
+ loop: ["loopKind", "over", "condition", "endStep"],
23890
+ try: ["endStep", "catches", "finallyStep"],
23891
+ parallel: ["endStep", "branches"],
23892
+ jump: ["toStep"],
23893
+ return: ["outcome"],
23894
+ throw: ["error"]
23895
+ };
23896
+ STEP_COMMON_FIELDS = [
23897
+ "stepNumber",
23898
+ "label",
23899
+ "description",
23900
+ "type",
23901
+ "assertsGuarantees",
23902
+ "assertsInvariants"
23903
+ ];
23904
+ STEP_LABEL_TWINS = {
23905
+ onTrueStep: "onTrueLabel",
23906
+ onFalseStep: "onFalseLabel",
23907
+ defaultStep: "defaultLabel",
23908
+ endStep: "endLabel",
23909
+ finallyStep: "finallyLabel",
23910
+ toStep: "toLabel"
23911
+ };
23775
23912
  SpecWorkspace = class {
23776
23913
  constructor(rootDir) {
23777
23914
  this.cachedIndex = null;
@@ -25009,9 +25146,39 @@ var init_specs2 = __esm({
25009
25146
  // Granular delta updates (sdd_update_spec)
25010
25147
  // -------------------------------------------------------------------------
25011
25148
  /**
25012
- * Returns non-fatal notices about the merge (e.g. an insert whose position
25013
- * was a jump target, so the relocated jumps now bypass the inserted step) —
25014
- * empty when the merge had nothing to warn about.
25149
+ * Apply a delta to one stored spec, in this ORDER — the order is the contract,
25150
+ * because every stage below can see what the stage above left:
25151
+ *
25152
+ * 1. LOAD the stored spec for the addressed kind and id; a spec that does
25153
+ * not exist refuses, and a mis-selected `system` kind refuses by name.
25154
+ * 2. REFUSE unknown delta keys, whole. A key the level's schema does not
25155
+ * know is dropped on write, so the edit would report a success it never
25156
+ * performed.
25157
+ * 3. QUALIFY the delta's id-bearing references against the spec's namespace,
25158
+ * BEFORE the merge — post-merge a bare id is ambiguous.
25159
+ * 4. MERGE. Identity-keyed arrays upsert element by element and honour their
25160
+ * delete markers, at every depth. Narrative step deltas apply in ASCENDING
25161
+ * stepNumber order, each against the numbering the earlier entries of the
25162
+ * same delta left behind — delete step 3 and step 7 becomes step 6, so a
25163
+ * second delete written as 7 addresses what used to be step 8. That is why
25164
+ * a delete may restate the step's `label` or `description` as a guard, and
25165
+ * why labels are the reliable way to name a jump target. Inserts and
25166
+ * deletes relocate every jump field in the same narrative around them; a
25167
+ * step whose `type` changes is REBUILT for its new type, keeping its
25168
+ * description and label.
25169
+ * 5. UNSET the fields the delta names, at the level each `unset` sits in.
25170
+ * 6. RESOLVE symbolic `*Label` references against the MERGED numbering — after
25171
+ * the merge, so a delta may anchor on a label that only pre-existing steps
25172
+ * carry, and a label in the delta retargets the stored numeric jump it
25173
+ * twins. An unresolved reference aborts the whole update.
25174
+ * 7. COMPARE with what is stored, both read through the level's canonical
25175
+ * schema. Equal means nothing is written and `written` is false.
25176
+ * 8. GATE the merged spec through the caller's hook, then SAVE.
25177
+ *
25178
+ * Returns the change report, whose `notices` carry the non-fatal effects (an
25179
+ * insert whose position was a jump target, so the relocated jumps now bypass
25180
+ * the inserted step; the fields a retype dropped) — empty when the merge had
25181
+ * nothing to say.
25015
25182
  */
25016
25183
  updateSpec(kind, id, delta, hooks) {
25017
25184
  const notices = [];
@@ -25089,6 +25256,14 @@ var init_specs2 = __esm({
25089
25256
  return refs;
25090
25257
  };
25091
25258
  const normalizeIdentity = (k) => String(k ?? "").toLowerCase().replace(/[_\-\s]/g, "");
25259
+ const unsetFieldsOf = (item) => Array.isArray(item?.unset) ? item.unset.filter((f) => typeof f === "string") : [];
25260
+ const withUnset = (merged, deltaItem) => {
25261
+ const fields = unsetFieldsOf(deltaItem);
25262
+ const out = { ...merged };
25263
+ delete out.unset;
25264
+ for (const field of fields) delete out[field];
25265
+ return out;
25266
+ };
25092
25267
  const assertDeltaMarkers = (item, label) => {
25093
25268
  if (!item || typeof item !== "object") return;
25094
25269
  if ("remove" in item && typeof item.remove !== "boolean") {
@@ -25117,14 +25292,113 @@ var init_specs2 = __esm({
25117
25292
  );
25118
25293
  }
25119
25294
  };
25120
- const mergeNarrative = (existingSteps, deltaSteps) => {
25295
+ const NO_NESTED_MERGE = /* @__PURE__ */ new Set(["ext", "narrative", "unset"]);
25296
+ const mergeElement = (existing, deltaItem) => {
25297
+ const out = { ...existing, ...deltaItem };
25298
+ if (!existing || typeof existing !== "object" || !deltaItem || typeof deltaItem !== "object") return out;
25299
+ for (const [key, value] of Object.entries(deltaItem)) {
25300
+ if (NO_NESTED_MERGE.has(key) || !Array.isArray(value) || value.length === 0) continue;
25301
+ const before = existing[key];
25302
+ if (before !== void 0 && !Array.isArray(before)) continue;
25303
+ const stored = Array.isArray(before) ? before : [];
25304
+ if (!isIdentifiedArray(key, stored, value)) continue;
25305
+ out[key] = mergeIdentifiedArray(key, stored, value);
25306
+ }
25307
+ return out;
25308
+ };
25309
+ const retargetByLabel = (merged, deltaStep) => {
25310
+ const out = { ...merged };
25311
+ for (const [stepField, labelField] of Object.entries(STEP_LABEL_TWINS)) {
25312
+ if (deltaStep?.[labelField] !== void 0 && deltaStep?.[stepField] === void 0) delete out[stepField];
25313
+ }
25314
+ for (const key of ["cases", "catches"]) {
25315
+ const deltaEntries = deltaStep?.[key];
25316
+ if (!Array.isArray(deltaEntries) || !Array.isArray(out[key])) continue;
25317
+ const retargeted = new Set(
25318
+ deltaEntries.filter((e) => e?.label !== void 0 && e?.step === void 0).map((e) => identityKeyOf(key, e)).filter((k) => k !== null)
25319
+ );
25320
+ if (!retargeted.size) continue;
25321
+ out[key] = out[key].map((e) => {
25322
+ const k = identityKeyOf(key, e);
25323
+ if (k === null || !retargeted.has(k)) return e;
25324
+ const { step, ...rest } = e;
25325
+ return rest;
25326
+ });
25327
+ }
25328
+ return out;
25329
+ };
25330
+ const rebuildOnRetype = (stored, deltaStep, merged, methodName) => {
25331
+ const newType = merged?.type;
25332
+ if (typeof newType !== "string" || stored?.type === newType) return merged;
25333
+ if (!Object.prototype.hasOwnProperty.call(STEP_TYPE_FIELDS, newType)) return merged;
25334
+ const allowed = stepFieldsFor(newType);
25335
+ const stated = Object.keys(deltaStep).filter(
25336
+ (k) => k !== "type" && deltaStep[k] !== void 0 && !allowed.has(k)
25337
+ );
25338
+ if (stated.length) {
25339
+ throw new Error(
25340
+ `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.`
25341
+ );
25342
+ }
25343
+ const out = {};
25344
+ const dropped = [];
25345
+ for (const [key, value] of Object.entries(merged)) {
25346
+ if (allowed.has(key)) out[key] = value;
25347
+ else dropped.push(key);
25348
+ }
25349
+ if (dropped.length) {
25350
+ notices.push(
25351
+ `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.`
25352
+ );
25353
+ }
25354
+ return out;
25355
+ };
25356
+ const mergeNarrative = (methodName, existingSteps, deltaSteps) => {
25121
25357
  let steps = [...existingSteps];
25122
25358
  const sortedDeltas = [...deltaSteps].sort((a, b) => a.stepNumber - b.stepNumber);
25123
25359
  for (const deltaStep of sortedDeltas) {
25124
25360
  const stepNum = deltaStep.stepNumber;
25361
+ if (typeof stepNum !== "number") {
25362
+ throw new Error(
25363
+ `Refusing to update the narrative of "${methodName}": a step delta must carry the "stepNumber" it addresses, got ${JSON.stringify(stepNum ?? null)}.`
25364
+ );
25365
+ }
25366
+ if ("remove" in deltaStep && typeof deltaStep.remove !== "boolean") {
25367
+ throw new Error(
25368
+ `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.`
25369
+ );
25370
+ }
25371
+ if ("action" in deltaStep && deltaStep.action !== "insert" && deltaStep.action !== "delete") {
25372
+ throw new Error(
25373
+ `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.`
25374
+ );
25375
+ }
25376
+ if ("captureJumps" in deltaStep && deltaStep.action !== "insert") {
25377
+ throw new Error(
25378
+ `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.`
25379
+ );
25380
+ }
25125
25381
  if (deltaStep.action === "delete" || deltaStep.remove === true) {
25126
25382
  const idx = steps.findIndex((s) => s.stepNumber === stepNum);
25127
25383
  if (idx !== -1) {
25384
+ const victim = steps[idx];
25385
+ for (const field of ["label", "description"]) {
25386
+ if (deltaStep[field] === void 0) continue;
25387
+ if (victim[field] !== deltaStep[field]) {
25388
+ throw new Error(
25389
+ `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.`
25390
+ );
25391
+ }
25392
+ }
25393
+ if (typeof victim.endStep === "number") {
25394
+ const body = steps.filter((s) => s.stepNumber > stepNum && s.stepNumber <= victim.endStep);
25395
+ if (body.length) {
25396
+ const span = body.length === 1 ? `step ${body[0].stepNumber}` : `steps ${body[0].stepNumber}-${body[body.length - 1].stepNumber}`;
25397
+ throw new Error(
25398
+ `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.`
25399
+ );
25400
+ }
25401
+ }
25128
25402
  const referrers = steps.filter((s) => s.stepNumber !== stepNum).map((s) => ({ n: s.stepNumber, refs: jumpRefsTo(s, stepNum) })).filter((r) => r.refs.length > 0);
25129
25403
  if (referrers.length) {
25130
25404
  throw new Error(
@@ -25137,6 +25411,11 @@ var init_specs2 = __esm({
25137
25411
  stepNum,
25138
25412
  -1
25139
25413
  ));
25414
+ } else {
25415
+ const present = steps.length ? ` \u2014 it has steps ${steps[0].stepNumber}-${steps[steps.length - 1].stepNumber}` : " \u2014 it has no steps";
25416
+ throw new Error(
25417
+ `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.`
25418
+ );
25140
25419
  }
25141
25420
  } else if (deltaStep.action === "insert") {
25142
25421
  const capture = deltaStep.captureJumps === true;
@@ -25155,18 +25434,17 @@ var init_specs2 = __esm({
25155
25434
  capture
25156
25435
  ));
25157
25436
  const { action, remove: remove2, captureJumps, ...cleanStep } = deltaStep;
25158
- steps.push(cleanStep);
25437
+ steps.push(withUnset(cleanStep, deltaStep));
25159
25438
  } else {
25160
25439
  const idx = steps.findIndex((s) => s.stepNumber === stepNum);
25161
25440
  if (idx !== -1) {
25162
25441
  const { action, remove: remove2, ...cleanStep } = deltaStep;
25163
- steps[idx] = {
25164
- ...steps[idx],
25165
- ...cleanStep
25166
- };
25442
+ const stored = steps[idx];
25443
+ const merged = withUnset(retargetByLabel(mergeElement(stored, cleanStep), cleanStep), deltaStep);
25444
+ steps[idx] = rebuildOnRetype(stored, cleanStep, merged, methodName);
25167
25445
  } else {
25168
25446
  const { action, remove: remove2, ...cleanStep } = deltaStep;
25169
- steps.push(cleanStep);
25447
+ steps.push(withUnset(cleanStep, deltaStep));
25170
25448
  }
25171
25449
  }
25172
25450
  }
@@ -25193,20 +25471,19 @@ var init_specs2 = __esm({
25193
25471
  } else {
25194
25472
  const existingMethod = merged[idx];
25195
25473
  let narrative = existingMethod.narrative ? [...existingMethod.narrative] : [];
25196
- if (deltaMethod.narrative && Array.isArray(deltaMethod.narrative)) {
25197
- narrative = mergeNarrative(narrative, deltaMethod.narrative);
25474
+ if (Array.isArray(deltaMethod.narrative)) {
25475
+ narrative = deltaMethod.narrative.length === 0 ? [] : mergeNarrative(String(deltaMethod.name), narrative, deltaMethod.narrative);
25198
25476
  }
25199
25477
  const { narrative: _, ...cleanMethod } = deltaMethod;
25200
- merged[idx] = {
25201
- ...existingMethod,
25202
- ...cleanMethod,
25478
+ merged[idx] = withUnset({
25479
+ ...mergeElement(existingMethod, cleanMethod),
25203
25480
  ...deltaMethod.ext !== void 0 ? { ext: mergeExt(existingMethod.ext, deltaMethod.ext) } : {},
25204
25481
  narrative
25205
- };
25482
+ }, deltaMethod);
25206
25483
  }
25207
25484
  } else {
25208
25485
  assertUnmatchedIsAnAddition(deltaMethod, deltaMethod.name, merged.map((m) => m.name), "method");
25209
- merged.push(deltaMethod);
25486
+ merged.push(withUnset(deltaMethod, deltaMethod));
25210
25487
  }
25211
25488
  }
25212
25489
  return merged;
@@ -25220,18 +25497,14 @@ var init_specs2 = __esm({
25220
25497
  if (deltaItem.remove === true || deltaItem.action === "delete") {
25221
25498
  merged.splice(idx, 1);
25222
25499
  } else {
25223
- merged[idx] = {
25224
- ...merged[idx],
25225
- ...deltaItem,
25226
- ...deltaItem.ext !== void 0 ? { ext: mergeExt(merged[idx].ext, deltaItem.ext) } : {},
25227
- // An interface method's findings upsert and delete by code, like
25228
- // lint.allow; an empty list still clears them outright.
25229
- ...Array.isArray(deltaItem.findings) && deltaItem.findings.length > 0 ? { findings: mergeIdentifiedArray("findings", merged[idx].findings ?? [], deltaItem.findings) } : {}
25230
- };
25500
+ merged[idx] = withUnset({
25501
+ ...mergeElement(merged[idx], deltaItem),
25502
+ ...deltaItem.ext !== void 0 ? { ext: mergeExt(merged[idx].ext, deltaItem.ext) } : {}
25503
+ }, deltaItem);
25231
25504
  }
25232
25505
  } else {
25233
25506
  assertUnmatchedIsAnAddition(deltaItem, deltaItem.name, merged.map((i) => i.name), "entry");
25234
- merged.push(deltaItem);
25507
+ merged.push(withUnset(deltaItem, deltaItem));
25235
25508
  }
25236
25509
  }
25237
25510
  return merged;
@@ -25245,11 +25518,11 @@ var init_specs2 = __esm({
25245
25518
  if (deltaItem.remove === true || deltaItem.action === "delete") {
25246
25519
  merged.splice(idx, 1);
25247
25520
  } else {
25248
- merged[idx] = { ...merged[idx], ...deltaItem };
25521
+ merged[idx] = withUnset(mergeElement(merged[idx], deltaItem), deltaItem);
25249
25522
  }
25250
25523
  } else {
25251
25524
  assertUnmatchedIsAnAddition(deltaItem, keyOf(deltaItem), merged.map(keyOf), "entry");
25252
- merged.push(deltaItem);
25525
+ merged.push(withUnset(deltaItem, deltaItem));
25253
25526
  }
25254
25527
  }
25255
25528
  return merged.map(({ action, remove: remove2, ...item }) => item);
@@ -25264,52 +25537,15 @@ var init_specs2 = __esm({
25264
25537
  if (deltaItem.remove === true || deltaItem.action === "delete") {
25265
25538
  merged.splice(idx, 1);
25266
25539
  } else {
25267
- merged[idx] = {
25268
- ...merged[idx],
25269
- ...deltaItem
25270
- };
25540
+ merged[idx] = withUnset(mergeElement(merged[idx], deltaItem), deltaItem);
25271
25541
  }
25272
25542
  } else {
25273
25543
  assertUnmatchedIsAnAddition(deltaItem, piKey(deltaItem), merged.map(piKey), "publicInterface");
25274
- merged.push(deltaItem);
25544
+ merged.push(withUnset(deltaItem, deltaItem));
25275
25545
  }
25276
25546
  }
25277
25547
  return merged;
25278
25548
  };
25279
- const identityKeyOf = (field, item) => {
25280
- if (item === null || typeof item !== "object") return null;
25281
- const o = item;
25282
- const str = (v) => typeof v === "string" && v.length > 0 ? v : null;
25283
- switch (field) {
25284
- case "dispatch":
25285
- return str(o.capability);
25286
- case "lifecycle":
25287
- return `${String(o.phase)} ${String(o.component)} ${String(o.method)}`;
25288
- case "emits":
25289
- case "subscribesTo":
25290
- return `${String(o.topic)} ${o.event === void 0 ? "" : String(o.event)}`;
25291
- case "trustedLinks":
25292
- return str(o.subsystem);
25293
- case "allow":
25294
- return str(o.code);
25295
- // lint.allow
25296
- case "findings":
25297
- return str(o.code);
25298
- // an interface method's findings
25299
- case "invariants":
25300
- return str(o.id);
25301
- case "patterns":
25302
- return str(o.id);
25303
- case "boundaries":
25304
- return str(o.name);
25305
- case "globalRequirements":
25306
- return str(o.description);
25307
- case "databases":
25308
- return str(o.id) ?? str(o.name);
25309
- default:
25310
- return str(o.name) ?? str(o.id);
25311
- }
25312
- };
25313
25549
  const mergeIdentifiedArray = (field, existing, delta2) => {
25314
25550
  const merged = [...existing];
25315
25551
  for (const deltaItem of delta2) {
@@ -25319,7 +25555,7 @@ var init_specs2 = __esm({
25319
25555
  const isDelete = deltaItem?.remove === true || deltaItem?.action === "delete";
25320
25556
  if (idx !== -1) {
25321
25557
  if (isDelete) merged.splice(idx, 1);
25322
- else merged[idx] = { ...merged[idx], ...deltaItem };
25558
+ else merged[idx] = withUnset(mergeElement(merged[idx], deltaItem), deltaItem);
25323
25559
  } else {
25324
25560
  if (key !== null) {
25325
25561
  assertUnmatchedIsAnAddition(
@@ -25331,7 +25567,7 @@ var init_specs2 = __esm({
25331
25567
  } else if (isDelete) {
25332
25568
  throw new Error(`Refusing to delete a ${field} entry with no resolvable identity.`);
25333
25569
  }
25334
- merged.push(deltaItem);
25570
+ merged.push(withUnset(deltaItem, deltaItem));
25335
25571
  }
25336
25572
  }
25337
25573
  return merged.map((item) => item && typeof item === "object" ? (({ action, remove: remove2, ...rest }) => rest)(item) : item);
@@ -25340,6 +25576,7 @@ var init_specs2 = __esm({
25340
25576
  const mergeDelta = (existing, delta2) => {
25341
25577
  const res = { ...existing };
25342
25578
  for (const [key, value] of Object.entries(delta2)) {
25579
+ if (key === "unset") continue;
25343
25580
  if (value === void 0 || value === null) {
25344
25581
  continue;
25345
25582
  }
@@ -25367,7 +25604,7 @@ var init_specs2 = __esm({
25367
25604
  res[key] = value;
25368
25605
  }
25369
25606
  }
25370
- return res;
25607
+ return withUnset(res, delta2);
25371
25608
  };
25372
25609
  const qualifyDeltaRefs = (d) => {
25373
25610
  if (kind === "system") return d;
@@ -25421,9 +25658,9 @@ var init_specs2 = __esm({
25421
25658
  };
25422
25659
  const unsetFields = Array.isArray(delta.unset) ? delta.unset.filter((f) => typeof f === "string") : [];
25423
25660
  const { unset: _unset, ...mergeableDelta } = delta;
25661
+ const storedBefore = JSON.parse(JSON.stringify(result));
25424
25662
  const mergedResult = mergeDelta(result, qualifyDeltaRefs(mergeableDelta));
25425
25663
  for (const field of unsetFields) delete mergedResult[field];
25426
- mergedResult.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
25427
25664
  if (kind === "implementation" && Array.isArray(mergedResult.methods)) {
25428
25665
  const labelErrors = [];
25429
25666
  for (const m of mergedResult.methods) {
@@ -25434,6 +25671,24 @@ var init_specs2 = __esm({
25434
25671
  - ${labelErrors.join("\n- ")}`);
25435
25672
  }
25436
25673
  }
25674
+ const canonical = (spec) => {
25675
+ const parsed = deltaSchema.safeParse(spec);
25676
+ const value = { ...parsed.success ? parsed.data : spec };
25677
+ delete value.updatedAt;
25678
+ return value;
25679
+ };
25680
+ const changes = specChanges(canonical(storedBefore), canonical(mergedResult));
25681
+ if (changes.length === 0) {
25682
+ return {
25683
+ kind,
25684
+ id,
25685
+ written: false,
25686
+ changes,
25687
+ notices,
25688
+ summary: `No change to ${kind} "${id}" \u2014 the delta matches what is stored, so nothing was written.`
25689
+ };
25690
+ }
25691
+ mergedResult.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
25437
25692
  const gateNotices = hooks?.gate?.(kind, mergedResult);
25438
25693
  if (gateNotices?.length) notices.push(...gateNotices);
25439
25694
  const opts = {
@@ -25459,7 +25714,14 @@ var init_specs2 = __esm({
25459
25714
  notices.push(...this.saveTypeSpec(mergedResult));
25460
25715
  break;
25461
25716
  }
25462
- return notices;
25717
+ return {
25718
+ kind,
25719
+ id,
25720
+ written: true,
25721
+ changes,
25722
+ notices,
25723
+ summary: `Updated ${kind} "${id}": ${changes.length} change${changes.length === 1 ? "" : "s"}.`
25724
+ };
25463
25725
  }
25464
25726
  };
25465
25727
  workspaces = /* @__PURE__ */ new Map();
@@ -26302,7 +26564,7 @@ var init_status = __esm({
26302
26564
  // src/core/authoring.ts
26303
26565
  function candidateOptions() {
26304
26566
  try {
26305
- const config = projectConfigRepository.load();
26567
+ const config = loadProjectConfig2();
26306
26568
  return config ? { rules: config.rules, projectType: config.projectType } : {};
26307
26569
  } catch {
26308
26570
  return {};
@@ -26335,7 +26597,7 @@ var init_authoring = __esm({
26335
26597
  init_specs2();
26336
26598
  init_validation();
26337
26599
  init_candidate();
26338
- init_project_config();
26600
+ init_core();
26339
26601
  }
26340
26602
  });
26341
26603
 
@@ -26425,6 +26687,16 @@ function json(value) {
26425
26687
  function errText(message) {
26426
26688
  return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
26427
26689
  }
26690
+ function renderChangeReport(report2) {
26691
+ const lines = [report2.summary];
26692
+ for (const change of report2.changes) {
26693
+ const to = change.after === void 0 ? "" : `: ${JSON.stringify(change.after)}`;
26694
+ const from = change.before === void 0 ? "" : ` (was ${JSON.stringify(change.before)})`;
26695
+ lines.push(`- ${change.path} ${change.change}${to}${from}`);
26696
+ }
26697
+ if (report2.notices.length) lines.push("", "NOTICE:", ...report2.notices.map((n) => `- ${n}`));
26698
+ return lines.join("\n");
26699
+ }
26428
26700
  function captureBuildStamp(entryPath) {
26429
26701
  try {
26430
26702
  const s = fs20.statSync(entryPath);
@@ -27621,17 +27893,12 @@ NOTICE:
27621
27893
  inputSchema: {
27622
27894
  kind: import_zod11.z.enum(["system", "subsystem", "component", "interface", "implementation", "type"]).describe("The spec kind to update (system = the singleton L0 \u2014 vision, boundaries, globalRequirements, databases, and publicInterfaces: the project gateway surface, each entry {id, name, subsystem, component, type, details, audience: project|department|instance|partner|external}; id is informational)"),
27623
27895
  id: import_zod11.z.string().describe("The ID of the spec to update (namespaced if needed)"),
27624
- delta: import_zod11.z.record(import_zod11.z.any()).describe(`The partial fields to merge into the spec. ARRAYS UPSERT, they do not replace: an array whose elements carry an identity is merged element-by-element, so a delta naming ONE element leaves the others intact. Identity is "name" or "id" by default, and per field: dispatch by "capability", lifecycle by phase+component+method, emits/subscribesTo by topic+event, trustedLinks by "subsystem", invariants and patterns by "id", lint.allow by "code", an interface method's findings by "code", boundaries by "name", globalRequirements by "description". Add "action: 'delete'" (or "remove: true") alongside that identity to REMOVE an element \u2014 including a stale lint allow. Arrays of plain STRINGS (owns, dependsOn, guarantees) carry no per-element identity and are replaced wholesale; pass [] to clear any array outright. To REMOVE an optional field entirely, list it in "unset": e.g. {"unset": ["basePath", "variant"]} \u2014 passing null/undefined means "no change" (they are skipped), and writing "" would leave the field present but empty, which is a different and usually wrong spec. Unsetting a required field is refused by schema validation, which names it. For narrative steps, match by "stepNumber" and use "action: 'insert'" (shifts subsequent steps up) or "action: 'delete'" (shifts subsequent steps down and removes it). Renumbering RELOCATES every flow jump field (onTrueStep/onFalseStep/cases.step/defaultStep/endStep/catches.step/finallyStep/toStep) in the same narrative; deleting a step that is a jump target is rejected until the referrers are retargeted. Inserting AT a jump target relocates those jumps past the inserted step by default (a NOTICE is returned) \u2014 add "captureJumps": true on the inserted step to retarget entry jumps onto it (loop/try endStep region tails always relocate with the body and are never captured). Reference ids in deltas may use LOCAL names \u2014 they are qualified against the spec's namespace exactly as the loader would. Per-spec lint suppression: set "lint: { allow: [{ code, reason }] }" to silence a WARNING code on this spec only (errors always surface; stale allows are flagged).`)
27896
+ delta: import_zod11.z.record(import_zod11.z.any()).describe(`The partial fields to merge into the spec. ARRAYS UPSERT, they do not replace: an array whose elements carry an identity is merged element-by-element, so a delta naming ONE element leaves the others intact. Identity is "name" or "id" by default, and per field: dispatch by "capability", lifecycle by phase+component+method, emits/subscribesTo by topic+event, trustedLinks by "subsystem", invariants and patterns by "id", lint.allow by "code", an interface method's findings by "code", boundaries by "name", globalRequirements by "description", switch cases by "value", try catches by "error". Identity merging applies at EVERY depth, including an array INSIDE an element (a method's params, a step's catches). Add "action: 'delete'" (or "remove: true") alongside that identity to REMOVE an element \u2014 including a stale lint allow. Arrays of plain STRINGS (owns, dependsOn, guarantees) carry no per-element identity and are replaced wholesale; pass [] to clear any array outright. To REMOVE an optional field entirely, list it in "unset": e.g. {"unset": ["basePath", "variant"]} \u2014 passing null/undefined means "no change" (they are skipped), and writing "" would leave the field present but empty, which is a different and usually wrong spec. Unsetting a required field is refused by schema validation, which names it. For narrative steps, match by "stepNumber" and use "action: 'insert'" (shifts subsequent steps up) or "action: 'delete'" (shifts subsequent steps down and removes it). Step entries apply in ASCENDING stepNumber order, each against the numbering the earlier entries of the SAME delta left behind \u2014 delete step 3 and step 7 becomes step 6 \u2014 so prefer labels, and restate the step's "label" or "description" on a delete to have it checked against the step actually addressed. Renumbering RELOCATES every flow jump field (onTrueStep/onFalseStep/cases.step/defaultStep/endStep/catches.step/finallyStep/toStep) in the same narrative. A delete is REJECTED when the narrative has no such step, when a jump still targets it (retarget the referrers first), when a restated label/description does not match, or when it is a loop/try/parallel header whose body would be left standing (retype the header first to dissolve the region, then delete it). Changing a step's "type" REBUILDS it for the new type: its description and label are kept and every field the new type cannot carry is dropped (returned as a NOTICE); a delta that retypes AND sets such a field is refused. A step delta is also refused when it carries a marker the merge does not recognise: a non-boolean "remove", an "action" that is neither "insert" nor "delete", a "captureJumps" outside an insert, or no "stepNumber" to address. Inserting AT a jump target relocates those jumps past the inserted step by default (a NOTICE is returned) \u2014 add "captureJumps": true on the inserted step to retarget entry jumps onto it (loop/try endStep region tails always relocate with the body and are never captured). Every jump field has a "*Label" twin (toLabel, onFalseLabel, endLabel, \u2026, and "label" on a cases/catches entry) resolved against step labels AFTER the merge, so a delta may anchor on a label only pre-existing steps carry; a label the delta supplies REPLACES the stored number it twins, while setting the number and its label together in one delta is refused as a contradiction. Reference ids in deltas may use LOCAL names \u2014 they are qualified against the spec's namespace exactly as the loader would. Per-spec lint suppression: set "lint: { allow: [{ code, reason }] }" to silence a WARNING code on this spec only (errors always surface; stale allows are flagged).`)
27625
27897
  }
27626
27898
  },
27627
27899
  ({ kind, id, delta }) => {
27628
27900
  try {
27629
- const notices = updateSpecGated(kind, id, delta);
27630
- const noticeBlock = notices.length ? `
27631
-
27632
- NOTICE:
27633
- - ${notices.join("\n- ")}` : "";
27634
- return text(`Successfully updated ${kind} spec "${id}".${noticeBlock}`);
27901
+ return text(renderChangeReport(updateSpecGated(kind, id, delta)));
27635
27902
  } catch (e) {
27636
27903
  return errText(String(e));
27637
27904
  }