@ateam-ai/mcp 0.3.57 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools.js +309 -65
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai/mcp",
3
- "version": "0.3.57",
3
+ "version": "0.4.1",
4
4
  "mcpName": "io.github.ariekogan/ateam-mcp",
5
5
  "description": "A-Team MCP Server — build, validate, and deploy multi-agent solutions from any AI environment",
6
6
  "type": "module",
package/src/tools.js CHANGED
@@ -83,6 +83,89 @@ function _resolveDottedField(obj, dottedPath) {
83
83
  return { parent, leaf: parts[parts.length - 1] };
84
84
  }
85
85
 
86
+ // ─── Protected array fields (v0.4.0 sibling-loss guard) ─────────────
87
+ //
88
+ // Historically, `ateam_patch(target:"solution", updates:{ linked_skills:["only-one"] })`
89
+ // silently REPLACED the whole linked_skills array — wiping every other
90
+ // skill wired into the solution. Same footgun on ui_plugins, handoffs,
91
+ // grants, connectors, etc. The Solution Builder skill hit this in the
92
+ // wild and wiped a tenant's whole solution.
93
+ //
94
+ // From v0.4.0, ateam_patch REFUSES a bare array-replace on any of these
95
+ // fields unless the caller explicitly opts in via one of:
96
+ // updates: { _replace: true, linked_skills: [...] } // object-level
97
+ // updates: { linked_skills: [...], linked_skills_replace: true } // field-level
98
+ // To ADD or REMOVE without opt-in, use the _push / _delete / _update
99
+ // suffix pattern that has always been the correct form.
100
+ const SOLUTION_ARRAY_FIELDS = new Set([
101
+ 'linked_skills',
102
+ 'ui_plugins',
103
+ 'platform_connectors',
104
+ 'handoffs',
105
+ 'grants',
106
+ 'triggers',
107
+ 'notification_routes',
108
+ 'channels',
109
+ 'actor_types',
110
+ 'admin_roles',
111
+ 'plugins',
112
+ 'security_contracts',
113
+ 'connectors',
114
+ 'skills',
115
+ ]);
116
+
117
+ const SKILL_ARRAY_FIELDS = new Set([
118
+ 'tools',
119
+ 'connectors',
120
+ 'handoffs',
121
+ 'scenarios',
122
+ 'triggers',
123
+ 'notification_routes',
124
+ 'plugins',
125
+ 'bootstrap_tools',
126
+ ]);
127
+
128
+ // Returns { ok:false, ... } if the write would REPLACE a protected array
129
+ // without an explicit opt-in; returns null if the write is safe to proceed.
130
+ function _guardArrayReplace({ target, key, value, current, updates }) {
131
+ const knownFields = target === 'skill' ? SKILL_ARRAY_FIELDS : SOLUTION_ARRAY_FIELDS;
132
+ if (!knownFields.has(key)) return null;
133
+ if (!Array.isArray(value)) return null;
134
+ const currentArr = Array.isArray(current) ? current : [];
135
+ if (currentArr.length === 0) return null; // nothing to lose
136
+ if (updates && updates._replace === true) return null;
137
+ if (updates && updates[key + '_replace'] === true) return null;
138
+
139
+ // Compute what would be dropped so the error message is actionable.
140
+ const keyOf = (item) => (item && typeof item === 'object') ? (item.id ?? item.name ?? JSON.stringify(item)) : item;
141
+ const newKeys = new Set(value.map(keyOf));
142
+ const dropped = currentArr.map(keyOf).filter(k => !newKeys.has(k));
143
+ const kept = currentArr.map(keyOf).filter(k => newKeys.has(k));
144
+ const wouldAdd = value.map(keyOf).filter(k => !currentArr.map(keyOf).includes(k));
145
+
146
+ return {
147
+ ok: false,
148
+ phase: 'patch',
149
+ error:
150
+ `⚠️ REFUSED: bare-array replace on ${target}.${key} would drop ${dropped.length} sibling item(s): ` +
151
+ `[${dropped.slice(0, 8).join(', ')}${dropped.length > 8 ? ', ...' : ''}]. ` +
152
+ `This footgun wiped a whole solution in the wild — v0.4.0 refuses it by default. ` +
153
+ `\n\nWhat to do instead:` +
154
+ `\n • To ADD items: updates: { "${key}_push": ${JSON.stringify(wouldAdd)} }` +
155
+ (dropped.length ? `\n • To REMOVE items: updates: { "${key}_delete": ${JSON.stringify(dropped)} }` : '') +
156
+ `\n • To FULLY REPLACE (rare): updates: { "${key}": [...], "${key}_replace": true }` +
157
+ `\n • To replace many arrays in one call: updates: { _replace: true, "${key}": [...] }`,
158
+ dropped_ids: dropped,
159
+ kept_ids: kept,
160
+ would_add: wouldAdd,
161
+ safe_alternatives: {
162
+ push: { [`${key}_push`]: wouldAdd },
163
+ ...(dropped.length && { delete: { [`${key}_delete`]: dropped } }),
164
+ force_replace: { [key]: value, [`${key}_replace`]: true },
165
+ },
166
+ };
167
+ }
168
+
86
169
  // ─── Tool definitions ───────────────────────────────────────────────
87
170
 
88
171
  export const tools = [
@@ -428,24 +511,27 @@ export const tools = [
428
511
  core: true,
429
512
  description:
430
513
  "Surgically update ANY field in a skill or solution definition, redeploy, and optionally re-test — all in one step.\n\n" +
431
- "SUPPORTED OPERATIONS:\n" +
514
+ "⚠️ MERGE-BY-DEFAULT (v0.4.0) — Arrays are protected from silent replace. Bare array writes on solution.linked_skills / ui_plugins / platform_connectors / handoffs / grants / triggers (etc.) and skill.tools / connectors / handoffs / scenarios are REFUSED to prevent sibling loss. Add or remove items with the _push / _delete / _update suffixes; opt into a full-array replace only when you really mean it.\n\n" +
515
+ "OPERATIONS (safe by construction):\n" +
432
516
  "1. Scalar (dot notation): { \"problem.statement\": \"new value\", \"role.persona\": \"You are...\" }\n" +
433
517
  "2. Deep nested: { \"intents.thresholds.accept\": 0.9, \"policy.escalation.enabled\": true }\n" +
434
- "3. Array push: { \"tools_push\": [{ name: \"new_tool\", description: \"...\" }] }\n" +
435
- "4. Array delete: { \"tools_delete\": [\"tool_name\"] }\n" +
436
- "5. Array update: { \"tools_update\": [{ name: \"existing_tool\", description: \"updated\" }] }\n" +
437
- "6. Replace whole section: { \"role\": { persona: \"...\", goals: [...] } }\n\n" +
438
- "EXAMPLES:\n" +
439
- "- Change persona (full replace): updates: { \"role.persona\": \"You are a friendly assistant\" }\n" +
440
- "- Append to persona (don't replace): updates: { \"persona_append\": \"\\n\\nALWAYS respond in 2 sentences.\" }\n" +
518
+ "3. Array APPEND: { \"tools_push\": [{ name: \"new_tool\", description: \"...\" }] }\n" +
519
+ "4. Array REMOVE: { \"tools_delete\": [\"tool_name\"] }\n" +
520
+ "5. Array MODIFY-ONE: { \"tools_update\": [{ name: \"existing_tool\", description: \"updated\" }] }\n" +
521
+ "6. Full-array REPLACE (opt-in): { \"linked_skills\": [...], \"linked_skills_replace\": true } — or { _replace: true, ... } to opt every array in this call.\n\n" +
522
+ "SOLUTION-LEVEL EXAMPLES (target='solution'):\n" +
523
+ "- ADD a skill to the solution: updates: { \"linked_skills_push\": [\"my-new-skill\"] } NOT { linked_skills: [\"my-new-skill\"] } (that would REFUSE — it drops your other skills)\n" +
524
+ "- REMOVE a skill: updates: { \"linked_skills_delete\": [\"old-skill\"] }\n" +
525
+ "- ADD a UI plugin: updates: { \"ui_plugins_push\": [{ id: \"mcp:conn:panel\", ... }] }\n" +
526
+ "- ADD a handoff: updates: { \"handoffs_push\": [{ id: \"h1\", ... }] }\n\n" +
527
+ "SKILL-LEVEL EXAMPLES (target='skill' + skill_id):\n" +
528
+ "- Change persona: updates: { \"role.persona\": \"You are a friendly assistant\" }\n" +
529
+ "- Append to persona: updates: { \"persona_append\": \"\\n\\nALWAYS respond in 2 sentences.\" }\n" +
441
530
  "- Add a guardrail: updates: { \"policy.guardrails.never_push\": [\"Never share passwords\"] }\n" +
442
- "- Update problem: updates: { \"problem.statement\": \"...\", \"problem.goals\": [\"goal1\"] }\n" +
443
531
  "- Add a tool: updates: { \"tools_push\": [{ name: \"conn.tool\", description: \"...\", inputs: [...], output: {...} }] }\n" +
444
532
  "- Change intent: updates: { \"intents.supported_update\": [{ id: \"i1\", description: \"new desc\" }] }\n" +
445
- "- Force redeploy: updates: { \"_force_redeploy\": true }\n" +
446
- "- CREATE a new skill: target='skill', skill_id='my-new-skill', updates: { \"problem.statement\": \"...\", \"role.persona\": \"...\" }\n" +
447
- " If the skill doesn't exist yet, a default scaffold is created and the updates are applied on top. The skill is automatically added to the solution topology.\n\n" +
448
- "Use target='skill' + skill_id for skill fields. Use target='solution' for solution-level fields (linked_skills, platform_connectors, ui_plugins).",
533
+ "- CREATE a new skill: target='skill', skill_id='my-new-skill', updates: { \"problem.statement\": \"...\", \"role.persona\": \"...\" } — auto-scaffolded and added to solution topology.\n\n" +
534
+ "PREVIEW BEFORE WRITING: pass dry_run:true to see the diff (arrays_merged, arrays_replaced, dropped_ids, added_ids) without applying. Use this before any destructive-looking edit.",
449
535
  inputSchema: {
450
536
  type: "object",
451
537
  properties: {
@@ -473,6 +559,16 @@ export const tools = [
473
559
  type: "string",
474
560
  description: "Optional: re-test the skill after patching. Requires skill_id.",
475
561
  },
562
+ dry_run: {
563
+ type: "boolean",
564
+ description: "If true, apply the patch in memory and return the diff (arrays_merged, arrays_replaced, dropped_ids, added_ids, would_write_bytes) WITHOUT writing to GitHub or redeploying. Preview a change before committing to it.",
565
+ },
566
+ source: {
567
+ type: "string",
568
+ enum: ["github", "local"],
569
+ description:
570
+ "Where the solution/skill definition lives. 'github' (DEFAULT) — read from and write to the tenant's GitHub repo (GitHub is master; the normal path). 'local' — read from and write to the Builder FS store (no GitHub repo required). Use 'local' ONLY for a repo-less bootstrap tenant (e.g. freshly onboarded from a template, before GitHub is connected). This is a DEDICATED, EXPLICIT switch — never a fallback. Redeploy is local in both modes.",
571
+ },
476
572
  },
477
573
  required: ["solution_id", "target", "updates"],
478
574
  },
@@ -516,7 +612,9 @@ export const tools = [
516
612
  name: "ateam_delete_solution",
517
613
  core: true,
518
614
  description:
519
- "Delete a deployed solution and all its skills from A-Team. Use with caution this removes the solution from both the Skill Builder and A-Team Core. Useful for cleaning up test solutions or starting fresh.",
615
+ "⚠️ IRREVERSIBLE kills Mongo state, running MCP processes, and Builder FS for the whole solution and every skill. " +
616
+ "REQUIRES `confirm:true` AND `confirm_solution_id` echoing the solution id you're destroying (defeats typos and hallucinated ids). " +
617
+ "RECOVERY: the GitHub repo is untouched; `ateam_github_pull` rebuilds the solution from `main`. Prefer that over re-deploying from memory.",
520
618
  inputSchema: {
521
619
  type: "object",
522
620
  properties: {
@@ -524,15 +622,24 @@ export const tools = [
524
622
  type: "string",
525
623
  description: "The solution ID to delete",
526
624
  },
625
+ confirm: {
626
+ type: "boolean",
627
+ description: "REQUIRED. Must be exactly true. A missing/false value refuses the call with a recovery hint.",
628
+ },
629
+ confirm_solution_id: {
630
+ type: "string",
631
+ description: "REQUIRED. Must exactly equal `solution_id`. This defeats typos and hallucinated ids — you can't wipe a solution you couldn't spell.",
632
+ },
527
633
  },
528
- required: ["solution_id"],
634
+ required: ["solution_id", "confirm", "confirm_solution_id"],
529
635
  },
530
636
  },
531
637
  {
532
638
  name: "ateam_delete_skill",
533
639
  core: true,
534
640
  description:
535
- "Delete a single skill from a deployed solution. Removes the skill from A-Team Core (kills the running MCP process, unregisters from skill registry, deletes from Mongo), removes the skill from solution.skills[] and solution.linked_skills, and deletes the skill's files from Builder FS. Use this to drop a skill without tearing down the whole solution.",
641
+ "⚠️ IRREVERSIBLE in Core + Builder FS kills the running MCP process, unregisters from skill registry, deletes the Mongo record, drops from solution.skills[] and solution.linked_skills, and removes the skill's files from Builder FS. " +
642
+ "REQUIRES `confirm:true`. RECOVERY: the skill still lives in GitHub — `ateam_github_pull` rebuilds the whole solution (no per-skill restore path).",
536
643
  inputSchema: {
537
644
  type: "object",
538
645
  properties: {
@@ -544,15 +651,22 @@ export const tools = [
544
651
  type: "string",
545
652
  description: "The skill ID to remove (e.g. 'linkedin-agent')",
546
653
  },
654
+ confirm: {
655
+ type: "boolean",
656
+ description: "REQUIRED. Must be exactly true. A missing/false value refuses the call with a recovery hint.",
657
+ },
547
658
  },
548
- required: ["solution_id", "skill_id"],
659
+ required: ["solution_id", "skill_id", "confirm"],
549
660
  },
550
661
  },
551
662
  {
552
663
  name: "ateam_delete_connector",
553
664
  core: true,
554
665
  description:
555
- "Remove a connector from a deployed solution. Stops and deletes it from A-Team Core, removes references from the solution definition (grants, platform_connectors) and skill definitions (connectors array), and cleans up mcp-store files.",
666
+ "⚠️ CASCADING any skill whose engine.bootstrap_tools or tools[] name a tool from this connector will FAIL its next execution. " +
667
+ "Stops and deletes the connector from A-Team Core; drops references from the solution definition (grants, platform_connectors, ui_plugins ids starting `mcp:<connector-id>:*`) and skill definitions (connectors array); cleans up mcp-store files. " +
668
+ "GitHub source is preserved — a follow-up `ateam_build_and_run(github:true)` can resurrect. " +
669
+ "REQUIRES `confirm:true`.",
556
670
  inputSchema: {
557
671
  type: "object",
558
672
  properties: {
@@ -564,8 +678,12 @@ export const tools = [
564
678
  type: "string",
565
679
  description: "The connector ID to remove (e.g. 'device-mock-mcp')",
566
680
  },
681
+ confirm: {
682
+ type: "boolean",
683
+ description: "REQUIRED. Must be exactly true. A missing/false value refuses the call with a recovery hint.",
684
+ },
567
685
  },
568
- required: ["solution_id", "connector_id"],
686
+ required: ["solution_id", "connector_id", "confirm"],
569
687
  },
570
688
  },
571
689
 
@@ -2535,21 +2653,45 @@ const handlers = {
2535
2653
  // Updates → Redeploys → Optionally tests
2536
2654
  // One call replaces: ateam_update + ateam_redeploy
2537
2655
 
2538
- ateam_patch: async ({ solution_id, target, skill_id, updates, test_message }, sid) => {
2656
+ ateam_patch: async ({ solution_id, target, skill_id, updates, test_message, dry_run, source }, sid) => {
2539
2657
  const phases = [];
2540
2658
  let isNewSkill = false;
2541
-
2542
- // GitHub-first patch: read from GitHub → apply patch → write back → redeploy
2543
- // This ensures GitHub stays the single source of truth.
2544
-
2545
- // Phase 1: Read current state from GitHub (or create scaffold if new skill)
2659
+ const _diff = { arrays_merged: [], arrays_replaced: [], scalars_changed: [], sections_replaced: [] };
2660
+
2661
+ // Two backing stores, chosen EXPLICITLY by `source` (never inferred):
2662
+ // 'github' (default) — GitHub-first: read from GitHub → apply patch → write
2663
+ // back redeploy. GitHub stays the single source of truth.
2664
+ // 'local' — Builder-FS-first: read from and write to the Builder store for a
2665
+ // repo-less bootstrap tenant (freshly onboarded from a template, GitHub not
2666
+ // yet connected). GitHub is still master overall; local is a temporary
2667
+ // bootstrap until the tenant connects a repo (then local is pushed → GitHub).
2668
+ // Redeploy (Phase 4) is local (Builder FS → Core) in BOTH modes.
2669
+ const isLocal = source === "local";
2670
+
2671
+ // Phase 1: Read current state (or create scaffold if new skill)
2546
2672
  let current;
2547
2673
  const filePath = target === "skill" && skill_id
2548
2674
  ? `skills/${skill_id}/skill.json`
2549
2675
  : `solution.json`;
2550
2676
  try {
2551
- const readResult = await get(`/deploy/solutions/${solution_id}/github/read?path=${encodeURIComponent(filePath)}`, sid);
2552
- current = JSON.parse(readResult.content);
2677
+ if (isLocal) {
2678
+ // Read the raw definition from the Builder store — no GitHub repo needed.
2679
+ if (target === "skill" && skill_id) {
2680
+ const r = await get(`/deploy/solutions/${solution_id}/skills/${encodeURIComponent(skill_id)}`, sid);
2681
+ current = r.skill || r.definition || r;
2682
+ } else {
2683
+ // ?raw=1 → the agent-api returns the UNSTRIPPED solution (keeps
2684
+ // linked_skills/conversation) so _delete/_push operate on the real arrays.
2685
+ const r = await get(`/deploy/solutions/${solution_id}/definition?raw=1`, sid);
2686
+ current = r.solution || r;
2687
+ }
2688
+ if (!current || typeof current !== "object") {
2689
+ throw new Error(`Local ${filePath} not found (empty definition)`);
2690
+ }
2691
+ } else {
2692
+ const readResult = await get(`/deploy/solutions/${solution_id}/github/read?path=${encodeURIComponent(filePath)}`, sid);
2693
+ current = JSON.parse(readResult.content);
2694
+ }
2553
2695
  } catch (err) {
2554
2696
  // If it's a skill that doesn't exist yet, create a default scaffold.
2555
2697
  // This lets agents use ateam_patch to both CREATE and UPDATE skills —
@@ -2569,7 +2711,7 @@ const handlers = {
2569
2711
  role: { name: "", persona: "", goals: [], limitations: [], communication_style: { tone: "professional", verbosity: "concise" } },
2570
2712
  intents: { supported: [], thresholds: { accept: 0.8, clarify: 0.5, reject: 0.5 }, out_of_domain: { action: "redirect", message: "" } },
2571
2713
  tools: [],
2572
- policy: { guardrails: { never: [], always: [] }, approvals: [], workflows: [], escalation: { enabled: false, conditions: [], target: "" } },
2714
+ policy: { access: { requires_roles: [] }, guardrails: { never: [], always: [] }, approvals: [], workflows: [], escalation: { enabled: false, conditions: [], target: "" } },
2573
2715
  engine: { rv2: { max_iterations: 10, iteration_timeout_ms: 120000, allow_parallel_tools: false, on_max_iterations: "ask_user" }, hlr: { enabled: true, critic: { enabled: true, check_interval: 3, strictness: "medium" }, reflection: { enabled: true, depth: "shallow" }, replanning: { enabled: true, max_replans: 3 } }, autonomy: { level: "supervised" }, finalization_gate: { enabled: true, max_retries: 2 } },
2574
2716
  access_policy: { rules: [{ tools: ["*"], effect: "allow" }] },
2575
2717
  grant_mappings: [],
@@ -2581,7 +2723,7 @@ const handlers = {
2581
2723
  };
2582
2724
  phases.push({ phase: "read", status: "created_scaffold", skill_id });
2583
2725
  } else {
2584
- return { ok: false, phase: "read", error: `Failed to read ${filePath} from GitHub: ${err.message}` };
2726
+ return { ok: false, phase: "read", error: `Failed to read ${filePath} from ${isLocal ? "Builder store (local)" : "GitHub"}: ${err.message}` };
2585
2727
  }
2586
2728
  }
2587
2729
 
@@ -2656,9 +2798,30 @@ const handlers = {
2656
2798
  else arr.push(upd);
2657
2799
  }
2658
2800
  parent[leaf] = arr;
2801
+ } else if (key === "_replace" || key.endsWith("_replace")) {
2802
+ // Escape-hatch flags handled by the guard — skip them here so they
2803
+ // don't get written into the patched object as literal fields.
2804
+ continue;
2659
2805
  } else if (key.includes(".")) {
2660
2806
  // Dot notation: "role.persona", "intents.thresholds.accept"
2661
2807
  const parts = key.split(".");
2808
+ // Sibling-loss guard: if the leaf resolves to an existing non-empty
2809
+ // array and the incoming value is also an array, refuse the replace
2810
+ // unless the caller opted in. (Dot-notation is how many agents
2811
+ // accidentally hit this — e.g. updates:{ "linked_skills": ["one"] }
2812
+ // on target='solution'.)
2813
+ const leafKey = parts[parts.length - 1];
2814
+ let cursor = patched;
2815
+ for (let i = 0; i < parts.length - 1; i++) {
2816
+ if (!cursor || typeof cursor[parts[i]] !== 'object') { cursor = null; break; }
2817
+ cursor = cursor[parts[i]];
2818
+ }
2819
+ const currentLeaf = cursor && Object.prototype.hasOwnProperty.call(cursor, leafKey) ? cursor[leafKey] : undefined;
2820
+ const guardErr = _guardArrayReplace({ target, key: leafKey, value, current: currentLeaf, updates });
2821
+ if (guardErr) return guardErr;
2822
+ if (Array.isArray(value) && Array.isArray(currentLeaf)) _diff.arrays_replaced.push(key);
2823
+ else if (typeof value === 'object' && value !== null && !Array.isArray(value)) _diff.sections_replaced.push(key);
2824
+ else _diff.scalars_changed.push(key);
2662
2825
  let obj = patched;
2663
2826
  for (let i = 0; i < parts.length - 1; i++) {
2664
2827
  if (!obj[parts[i]] || typeof obj[parts[i]] !== "object") obj[parts[i]] = {};
@@ -2666,7 +2829,14 @@ const handlers = {
2666
2829
  }
2667
2830
  obj[parts[parts.length - 1]] = value;
2668
2831
  } else {
2669
- // Direct field replacement
2832
+ // Direct top-level field replacement. Sibling-loss guard: if this
2833
+ // names a known array field and would drop items, refuse unless the
2834
+ // caller passed _replace:true (object-level) or <field>_replace:true.
2835
+ const guardErr = _guardArrayReplace({ target, key, value, current: patched[key], updates });
2836
+ if (guardErr) return guardErr;
2837
+ if (Array.isArray(value) && Array.isArray(patched[key])) _diff.arrays_replaced.push(key);
2838
+ else if (typeof value === 'object' && value !== null && !Array.isArray(value)) _diff.sections_replaced.push(key);
2839
+ else _diff.scalars_changed.push(key);
2670
2840
  patched[key] = value;
2671
2841
  }
2672
2842
  }
@@ -2675,42 +2845,80 @@ const handlers = {
2675
2845
  return { ok: false, phase: "patch", error: `Failed to apply patch: ${err.message}` };
2676
2846
  }
2677
2847
 
2678
- // Phase 3: Write patched version back to GitHub
2848
+ // Dry-run: return diff + would-be after-state without writing to GitHub
2849
+ // or redeploying. Lets an agent preview any destructive-looking edit.
2850
+ if (dry_run) {
2851
+ return {
2852
+ ok: true,
2853
+ dry_run: true,
2854
+ target,
2855
+ solution_id,
2856
+ skill_id,
2857
+ phases,
2858
+ _diff,
2859
+ after_state: patched,
2860
+ would_write_bytes: JSON.stringify(patched, null, 2).length,
2861
+ hint: "No changes applied. Remove dry_run:true to commit + redeploy.",
2862
+ };
2863
+ }
2864
+
2865
+ // Phase 3: Write patched version back to the chosen store.
2679
2866
  try {
2680
2867
  const patchKeys = Object.keys(updates || {});
2681
2868
  const message = `Patch: ${target}${skill_id ? ` ${skill_id}` : ""} — ${patchKeys.join(", ")}`;
2682
- await post(`/deploy/solutions/${solution_id}/github/patch`, {
2683
- path: filePath,
2684
- content: JSON.stringify(patched, null, 2),
2685
- message,
2686
- }, sid, { timeoutMs: 30_000 });
2687
- phases.push({ phase: "github_write", status: "done" });
2869
+ if (isLocal) {
2870
+ // Write the FULL patched object to the Builder store. Top-level keys are
2871
+ // replaced (removals honored) — the client-side merge above already
2872
+ // resolved _push/_delete/_update, so we send the resolved object.
2873
+ const endpoint = (target === "skill" && skill_id)
2874
+ ? `/deploy/solutions/${solution_id}/skills/${encodeURIComponent(skill_id)}`
2875
+ : `/deploy/solutions/${solution_id}`;
2876
+ await patch(endpoint, { state_update: patched }, sid, { timeoutMs: 30_000 });
2877
+ phases.push({ phase: "local_write", status: "done" });
2878
+ } else {
2879
+ await post(`/deploy/solutions/${solution_id}/github/patch`, {
2880
+ path: filePath,
2881
+ content: JSON.stringify(patched, null, 2),
2882
+ message,
2883
+ }, sid, { timeoutMs: 30_000 });
2884
+ phases.push({ phase: "github_write", status: "done" });
2885
+ }
2688
2886
  } catch (err) {
2689
- return { ok: false, phase: "github_write", error: `Patch applied but failed to write to GitHub: ${err.message}`, phases };
2887
+ const store = isLocal ? "Builder store (local)" : "GitHub";
2888
+ return { ok: false, phase: isLocal ? "local_write" : "github_write", error: `Patch applied but failed to write to ${store}: ${err.message}`, phases };
2690
2889
  }
2691
2890
 
2692
2891
  // Phase 3b: If new skill, add it to solution.json topology (skills[], linked_skills)
2693
2892
  if (isNewSkill && skill_id) {
2694
2893
  try {
2695
- const solRead = await get(`/deploy/solutions/${solution_id}/github/read?path=solution.json`, sid);
2696
- const sol = JSON.parse(solRead.content);
2697
2894
  const skillEntry = { id: skill_id, name: patched.name || skill_id, role: "worker", description: patched.description || "", connectors: patched.connectors || [] };
2698
- // Add to skills[] if not already present
2699
- if (!sol.skills) sol.skills = [];
2700
- if (!sol.skills.find(s => s.id === skill_id)) {
2701
- sol.skills.push(skillEntry);
2702
- }
2703
- // Add to linked_skills if not already present
2704
- if (!sol.linked_skills) sol.linked_skills = [];
2705
- if (!sol.linked_skills.includes(skill_id)) {
2706
- sol.linked_skills.push(skill_id);
2895
+ if (isLocal) {
2896
+ // Local: _push the entries via the Builder store (dedup handled by the
2897
+ // store's _push — it updates in place if the id already exists).
2898
+ await patch(`/deploy/solutions/${solution_id}`, {
2899
+ state_update: { skills_push: [skillEntry], linked_skills_push: [skill_id] },
2900
+ }, sid, { timeoutMs: 30_000 });
2901
+ phases.push({ phase: "solution_topology", status: "done", added: skill_id });
2902
+ } else {
2903
+ const solRead = await get(`/deploy/solutions/${solution_id}/github/read?path=solution.json`, sid);
2904
+ const sol = JSON.parse(solRead.content);
2905
+ // Add to skills[] if not already present
2906
+ if (!sol.skills) sol.skills = [];
2907
+ if (!sol.skills.find(s => s.id === skill_id)) {
2908
+ sol.skills.push(skillEntry);
2909
+ }
2910
+ // Add to linked_skills if not already present
2911
+ if (!sol.linked_skills) sol.linked_skills = [];
2912
+ if (!sol.linked_skills.includes(skill_id)) {
2913
+ sol.linked_skills.push(skill_id);
2914
+ }
2915
+ await post(`/deploy/solutions/${solution_id}/github/patch`, {
2916
+ path: "solution.json",
2917
+ content: JSON.stringify(sol, null, 2),
2918
+ message: `Add skill "${skill_id}" to solution topology`,
2919
+ }, sid, { timeoutMs: 30_000 });
2920
+ phases.push({ phase: "solution_topology", status: "done", added: skill_id });
2707
2921
  }
2708
- await post(`/deploy/solutions/${solution_id}/github/patch`, {
2709
- path: "solution.json",
2710
- content: JSON.stringify(sol, null, 2),
2711
- message: `Add skill "${skill_id}" to solution topology`,
2712
- }, sid, { timeoutMs: 30_000 });
2713
- phases.push({ phase: "solution_topology", status: "done", added: skill_id });
2714
2922
  } catch (err) {
2715
2923
  // Non-fatal: skill.json was written, topology can be fixed manually
2716
2924
  phases.push({ phase: "solution_topology", status: "warning", error: err.message });
@@ -2756,19 +2964,23 @@ const handlers = {
2756
2964
  }
2757
2965
 
2758
2966
  const redeployOk = phases.some(p => p.phase === "redeploy" && p.status === "done");
2967
+ const store = isLocal ? "Builder store (local)" : "GitHub";
2759
2968
  return {
2760
2969
  ok: true,
2761
2970
  solution_id,
2762
- branch: 'main',
2971
+ source: isLocal ? "local" : "github",
2972
+ ...(isLocal ? {} : { branch: 'main' }),
2763
2973
  phases,
2764
2974
  patched: patched,
2765
2975
  ...(isNewSkill && { created_skill: skill_id }),
2766
2976
  ...(redeployResult && { redeploy: redeployResult }),
2767
2977
  ...(test_result && { test_result }),
2768
2978
  _status: redeployOk
2769
- ? '✅ Patched on GitHub + redeployed.'
2770
- : '⚠️ Patched on GitHub ✅ but redeploy timed out. Run: ateam_redeploy(solution_id' + (skill_id ? `, skill_id: "${skill_id}"` : '') + ')',
2771
- _next: 'Create a checkpoint before making more changes: ateam_github_promote(solution_id)',
2979
+ ? `✅ Patched on ${store} + redeployed.`
2980
+ : `⚠️ Patched on ${store} ✅ but redeploy timed out. Run: ateam_redeploy(solution_id` + (skill_id ? `, skill_id: "${skill_id}"` : '') + ')',
2981
+ _next: isLocal
2982
+ ? 'Local edit saved + redeployed. When the tenant connects a GitHub repo, the local state is pushed → GitHub (which then becomes master).'
2983
+ : 'Create a checkpoint before making more changes: ateam_github_promote(solution_id)',
2772
2984
  };
2773
2985
  },
2774
2986
 
@@ -3364,14 +3576,46 @@ const handlers = {
3364
3576
  ateam_github_list_versions: async ({ solution_id }, sid) =>
3365
3577
  get(`/deploy/solutions/${solution_id}/versions/dev`, sid),
3366
3578
 
3367
- ateam_delete_solution: async ({ solution_id }, sid) =>
3368
- del(`/deploy/solutions/${solution_id}`, sid),
3579
+ ateam_delete_solution: async ({ solution_id, confirm, confirm_solution_id }, sid) => {
3580
+ if (confirm !== true) {
3581
+ return {
3582
+ ok: false,
3583
+ error: "⚠️ REFUSED: ateam_delete_solution requires confirm:true. This is irreversible in Core + Builder FS. GitHub source is preserved — ateam_github_pull rebuilds from `main` if you already deleted by mistake.",
3584
+ recovery: "ateam_github_pull(solution_id, ref:'main')",
3585
+ };
3586
+ }
3587
+ if (confirm_solution_id !== solution_id) {
3588
+ return {
3589
+ ok: false,
3590
+ error: `⚠️ REFUSED: confirm_solution_id must exactly equal solution_id. Got confirm_solution_id="${confirm_solution_id}" but solution_id="${solution_id}". This check defeats typos and hallucinated ids — you should not be able to wipe a solution whose id you can't spell correctly.`,
3591
+ expected: solution_id,
3592
+ received: confirm_solution_id,
3593
+ };
3594
+ }
3595
+ return del(`/deploy/solutions/${solution_id}`, sid);
3596
+ },
3369
3597
 
3370
- ateam_delete_skill: async ({ solution_id, skill_id }, sid) =>
3371
- del(`/deploy/solutions/${solution_id}/skills/${skill_id}`, sid),
3598
+ ateam_delete_skill: async ({ solution_id, skill_id, confirm }, sid) => {
3599
+ if (confirm !== true) {
3600
+ return {
3601
+ ok: false,
3602
+ error: `⚠️ REFUSED: ateam_delete_skill requires confirm:true. Kills the running MCP process and deletes the skill from Core + Builder FS. GitHub source is preserved — ateam_github_pull rebuilds the whole solution.`,
3603
+ recovery: "ateam_github_pull(solution_id, ref:'main') — no per-skill restore path",
3604
+ };
3605
+ }
3606
+ return del(`/deploy/solutions/${solution_id}/skills/${skill_id}`, sid);
3607
+ },
3372
3608
 
3373
- ateam_delete_connector: async ({ solution_id, connector_id }, sid) =>
3374
- del(`/deploy/solutions/${solution_id}/connectors/${connector_id}`, sid),
3609
+ ateam_delete_connector: async ({ solution_id, connector_id, confirm }, sid) => {
3610
+ if (confirm !== true) {
3611
+ return {
3612
+ ok: false,
3613
+ error: `⚠️ REFUSED: ateam_delete_connector requires confirm:true. Cascading — any skill wired to this connector's tools will fail its next execution. GitHub source is preserved.`,
3614
+ recovery: "ateam_build_and_run(solution_id, github:true) can resurrect from GitHub",
3615
+ };
3616
+ }
3617
+ return del(`/deploy/solutions/${solution_id}/connectors/${connector_id}`, sid);
3618
+ },
3375
3619
 
3376
3620
  ateam_upload_connector: async ({ solution_id, connector_id, github, files, ref, replace }, sid) =>
3377
3621
  post(