From 6de7c32fd19721dbab0dcad025ac22764b207fe4 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Thu, 3 Sep 2026 09:28:55 -0700 Subject: [PATCH] fix(webmcp): choose repository before agent work --- src/assets/js/webmcp.js | 94 +++++++++++++++++++++++++++++++++++------ src/webmcp/index.njk | 73 ++++++++++++++++---------------- tests/site.spec.mjs | 30 ++++++++++++- tests/webmcp.test.mjs | 17 +++++++- 4 files changed, 161 insertions(+), 53 deletions(-) diff --git a/src/assets/js/webmcp.js b/src/assets/js/webmcp.js index f93669a..a1f6aaf 100644 --- a/src/assets/js/webmcp.js +++ b/src/assets/js/webmcp.js @@ -2,7 +2,7 @@ import { createSkillRegistry } from './webmcp-registry.js'; // === MODULE_BUILD === // id: interdependency_webmcp_surface -// purpose: Register website-owned read-only WebMCP skill tools and bind exact human-selected skill + repository + request into one ephemeral browser-agent handoff. +// purpose: Register website-owned read-only WebMCP skill tools and bind exact human-selected repository + skill + request into one ephemeral browser-agent handoff. // entrypoint: /webmcp/ // tests: tests/webmcp.test.mjs // === END MODULE_BUILD === @@ -11,20 +11,26 @@ import { createSkillRegistry } from './webmcp-registry.js'; // network: same-origin GET of /assets/data/skill-registry.json plus read-only health GET to the website-owned Render MCP runtime // storage: none // user_data: human-entered handoff text exists only in page memory and is returned only when the browser agent invokes the explicit handoff tool -// operational_effects: none; skill/repository selection, handoff publication, registry inspection, and dependency resolution do not mutate repositories or external systems +// operational_effects: none; repository/skill selection, handoff publication, registry inspection, and dependency resolution do not mutate repositories or external systems // authority: skill-lib owns skill definitions; the website build observes public repository identities; external changes require separately authorized agent tools // === END BOUNDARIES === // === CONTRACTS === // id: webmcp_human_selection_is_exact_skill_and_repository_identity -// given: a human selects a presented skill and repository +// given: a human selects a repository and then a presented skill // then: the page records exact registered skill identity plus the repository's observed default-branch head without requiring typed machine identifiers // class: correctness // +// id: webmcp_repository_context_precedes_agent_work_selection +// given: no repository is selected or the selected repository changes +// then: skill controls remain disabled or the prior skill selection is invalidated so agent work is always chosen within the current repository context +// class: human_in_loop +// // id: webmcp_human_handoff_requires_explicit_send // given: a human has selected a skill and repository and supplied or accepted an ordinary-language intent // then: no agent handoff exists until submit; submit registers one page-session read-only `tiw_human_handoff` carrying skill closure, registry provenance, repository identity, and human intent // class: human_in_loop // === END CONTRACTS === +// Usage: on /webmcp/, choose a repository first, then choose a skill and describe the outcome. Changing the repository requires choosing the skill again; only explicit Send publishes the read-only page-session handoff. const REGISTRY_URL = '/assets/data/skill-registry.json'; const REMOTE_MCP_BASE = 'https://the-interdependency-mcp.onrender.com'; @@ -37,6 +43,7 @@ const remoteStatusElement = () => document.querySelector('[data-remote-mcp-statu const outputElement = () => document.querySelector('[data-webmcp-output]'); const selectedSkillElement = () => document.querySelector('[data-selected-skill]'); const selectedRepositoryElement = () => document.querySelector('[data-selected-repository]'); +const skillStageStatusElement = () => document.querySelector('[data-skill-stage-status]'); const handoffStatusElement = () => document.querySelector('[data-human-handoff-status]'); const modelContext = () => globalThis.document?.modelContext; @@ -124,12 +131,12 @@ async function publishHandoffTool(handoff) { await context.registerTool({ name: HANDOFF_TOOL_NAME, title: 'Human-sent Interdependency handoff', - description: 'The human explicitly selected a public Interdependency skill and repository and pressed Send. Read this before planning or changing anything. Returns exact skill and dependency closure, skill-registry provenance, observed repository identity/head, and the human requested outcome. Selection is instruction, not permission.', + description: 'The human explicitly selected a public Interdependency repository, then a skill, and pressed Send. Read this before planning or changing anything. Returns the observed repository identity/head, exact skill and dependency closure, skill-registry provenance, and the human requested outcome. Selection is instruction, not permission.', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, annotations: { readOnlyHint: true, untrustedContentHint: true }, execute: async () => jsonResult(currentHandoff || { ready: false, hmmm: 'human handoff was invalidated before invocation' }) }, { signal: controller.signal }); - setText(handoffStatusElement(), `Sent to browser agent context · ${handoff.skill.name} → ${handoff.target_repository.name}.`, 'implemented'); + setText(handoffStatusElement(), `Sent to browser agent context · ${handoff.target_repository.name} → ${handoff.skill.name}.`, 'implemented'); return true; } catch (error) { if (controller.signal.aborted) return false; @@ -145,6 +152,7 @@ function bindHumanCatalogue(registry) { const filterInput = document.querySelector('[data-human-skill-filter]'); const count = document.querySelector('[data-human-skill-count]'); const cards = [...document.querySelectorAll('[data-human-skill]')]; + const skillButtons = cards.map(card => card.querySelector('[data-select-skill]')).filter(Boolean); const selectedActions = [...document.querySelectorAll('[data-selected-action]')]; const repositorySelect = document.querySelector('[data-human-repository-target]'); const handoffForm = document.querySelector('[data-human-handoff-form]'); @@ -181,7 +189,43 @@ function bindHumanCatalogue(registry) { setText(selectedRepositoryElement(), `${repository.name} · ${repository.default_branch || 'branch hmmm'}@${head} · ${repository.status || 'status hmmm'}`); }; + const setSkillStageEnabled = repository => { + const enabled = Boolean(repository); + if (filterInput) filterInput.disabled = !enabled; + for (const skillButton of skillButtons) skillButton.disabled = !enabled; + setText( + skillStageStatusElement(), + enabled + ? `${repository.name} selected. Now choose how the agent should work in this repository.` + : 'Choose a repository first to enable its agent-work choices.', + enabled ? 'implemented' : 'hmmm' + ); + }; + + const clearSelectedSkill = () => { + if (!selectedName) return false; + const previousName = selectedName; + selectedName = ''; + for (const card of cards) { + card.dataset.selected = 'false'; + card.querySelector('[data-select-skill]')?.setAttribute('aria-pressed', 'false'); + } + setText(selectedSkillElement(), 'No skill selected.'); + for (const action of selectedActions) action.disabled = true; + if (previousName === 'fresh-making' && freshIntentIsAutomatic) { + intentInput.value = ''; + freshIntentIsAutomatic = false; + } + return true; + }; + const setSelected = (name, { updateUrl = true } = {}) => { + const repository = selectedRepository(repositorySelect); + if (!repository) { + setText(handoffStatusElement(), 'Choose a repository before choosing how the agent should work.', 'hmmm'); + repositorySelect?.focus(); + return false; + } const card = cards.find(candidate => candidate.dataset.skillName === name); if (!card) return false; @@ -202,7 +246,7 @@ function bindHumanCatalogue(registry) { setText(selectedSkillElement(), `${skill.name} · ${skill.kind} · ${skill.canonical_path}`); for (const action of selectedActions) action.disabled = false; - showResult('SELECTED SKILL', skill); + showResult('SELECTED REPOSITORY → SKILL', { target_repository: repository, skill }); if (name === 'fresh-making' && (!intentInput.value.trim() || freshIntentIsAutomatic)) { intentInput.value = FRESH_MAKING_INTENT; @@ -213,7 +257,7 @@ function bindHumanCatalogue(registry) { } updateSendEnabled(); - if (!currentHandoff) setText(handoffStatusElement(), 'Skill selected. Choose a repository, review the outcome, then press Send.', 'hmmm'); + if (!currentHandoff) setText(handoffStatusElement(), `${repository.name} and ${skill.name} selected. Review the outcome, then press Send.`, 'hmmm'); if (updateUrl) { const url = new URL(globalThis.location.href); @@ -229,13 +273,23 @@ function bindHumanCatalogue(registry) { repositorySelect?.addEventListener('change', () => { if (currentHandoff) clearPublishedHandoff('Repository selection changed. Press Send again before the agent receives a new handoff.'); + const skillWasCleared = clearSelectedSkill(); updateRepositoryDisplay(); - updateSendEnabled(); const repository = selectedRepository(repositorySelect); + setSkillStageEnabled(repository); + updateSendEnabled(); const url = new URL(globalThis.location.href); if (repository) url.searchParams.set('repo', repository.name); else url.searchParams.delete('repo'); + if (skillWasCleared) url.searchParams.delete('skill'); globalThis.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`); + setText( + handoffStatusElement(), + repository + ? `${repository.name} selected. Now choose how the agent should work in it.` + : 'Choose a repository first. Nothing is sent merely by selecting or typing.', + 'hmmm' + ); }); document.querySelector('[data-selected-action="inspect"]')?.addEventListener('click', () => { @@ -257,7 +311,7 @@ function bindHumanCatalogue(registry) { const repository = selectedRepository(repositorySelect); const intent = String(new FormData(event.currentTarget).get('intent') || '').trim(); if (!selectedName || !repository || !intent) { - setText(handoffStatusElement(), 'Select a skill and repository and provide an outcome before sending.', 'hmmm'); + setText(handoffStatusElement(), 'Select a repository, then a skill, and provide an outcome before sending.', 'hmmm'); updateSendEnabled(); return; } @@ -266,10 +320,10 @@ function bindHumanCatalogue(registry) { const handoff = { ready: true, sent_at: new Date().toISOString(), + target_repository: repository, skill, required_skills: registry.resolveSkillClosure({ name: selectedName }), skill_registry: registry.getRegistryStatus(), - target_repository: repository, human_request: intent, boundaries: { selection_is_instruction_not_permission: true, @@ -286,19 +340,31 @@ function bindHumanCatalogue(registry) { filterInput?.addEventListener('input', applyFilter); applyFilter(); - updateRepositoryDisplay(); const requestedUrl = new URL(globalThis.location.href); - const requestedSkill = requestedUrl.searchParams.get('skill'); - if (requestedSkill) setSelected(requestedSkill, { updateUrl: false }); const requestedRepo = requestedUrl.searchParams.get('repo'); if (requestedRepo && repositorySelect) { const option = [...repositorySelect.options].find(candidate => candidate.value === requestedRepo); if (option) { repositorySelect.value = requestedRepo; - updateRepositoryDisplay(); } } + updateRepositoryDisplay(); + const repository = selectedRepository(repositorySelect); + setSkillStageEnabled(repository); + + const requestedSkill = requestedUrl.searchParams.get('skill'); + if (requestedSkill && repository) { + if (!setSelected(requestedSkill, { updateUrl: false })) requestedUrl.searchParams.delete('skill'); + } else if (requestedSkill) { + requestedUrl.searchParams.delete('skill'); + } + if (requestedUrl.search !== globalThis.location.search) { + globalThis.history.replaceState(null, '', `${requestedUrl.pathname}${requestedUrl.search}${requestedUrl.hash}`); + } + if (repository && !selectedName) { + setText(handoffStatusElement(), `${repository.name} selected. Now choose how the agent should work in it.`, 'hmmm'); + } updateSendEnabled(); return { getSelectedName: () => selectedName, getSelectedRepository: () => selectedRepository(repositorySelect) }; diff --git a/src/webmcp/index.njk b/src/webmcp/index.njk index 510e933..d6e6451 100644 --- a/src/webmcp/index.njk +++ b/src/webmcp/index.njk @@ -8,7 +8,7 @@ webmcp: true

WebMCP Challenge · live public surface

The Interdependency WebMCP

-

The page is the provider. Human and browser agent use the same skill material and the same repository identity. The human chooses a skill, chooses a repository, and states the desired outcome; Send exposes that exact handoff to the browser agent.

+

The page is the provider. Human and browser agent use the same repository identity and skill material. The human chooses a repository, chooses how the agent should work in that repository, and states the desired outcome; Send exposes that exact handoff to the browser agent.

Remote MCP registry: https://the-interdependency-mcp.onrender.com/mcp

Open remote MCP health check

@@ -22,14 +22,39 @@ webmcp: true +
+

Shared human + agent repository material

+

1. Choose the repository

+

The repository establishes the agent's working context before any action or skill is selected. The list is generated from the current active public repositories in The-Interdependency, and the selected repository's exact observed default-branch head travels with the handoff. Selection grants no write permission.

+ + +
+ Selected repository + No repository selected. +
+
+

Shared human + agent skill material

-

1. Choose how the agent should work

+

2. Choose how the agent should work

+

Choose a repository first to enable its agent-work choices.

The public skill surface is deliberately narrow: msdmd and its metadata-block applications first, then the METAPAT meta skill, plus fresh-making for deterministic refresh of derived artifacts. Other skill-lib skills remain canonical in the source library rather than becoming competing definitions here.

- + Presented skills shown
@@ -50,7 +75,7 @@ webmcp: true {% else %}

Depends on: none declared

{% endif %} -

+

Read canonical SKILL.md

{% endif %} @@ -68,7 +93,7 @@ webmcp: true Description

{{ skill.description }}

-

+

Read canonical SKILL.md

{% endif %} @@ -87,7 +112,7 @@ webmcp: true Description

{{ skill.description }}

-

+

Read canonical SKILL.md

{% endif %} @@ -100,30 +125,6 @@ webmcp: true
-
-

Shared human + agent repository material

-

2. Choose the repository

-

The list is generated from the current active public repositories in The-Interdependency. The selected repository's exact observed default-branch head travels with the handoff. Selection grants no write permission.

- - -
- Selected repository - No repository selected. -
-
-

Human → browser agent

3. State the outcome and send

@@ -132,24 +133,24 @@ webmcp: true
-

-

Choose a skill and repository first. Nothing is sent merely by selecting or typing.

+

+

Choose a repository first, then a skill. Nothing is sent merely by selecting or typing.

-
Select a skill and repository. No internal skill or repository identifier needs to be typed.
+
Select a repository first, then a skill. No internal repository or skill identifier needs to be typed.

Same material, meaningful human selection

-
skill-lib + current repository projection
+  
current repository projection + skill-lib
                  ↓
-human reads skill + chooses repo
+human chooses repo + reads applicable skill
                  ↓
           explicit SEND
                  ↓
       tiw_human_handoff
                  ↓
-agent receives exact skill + closure + repo head + human request
+agent receives exact repo head + skill + closure + human request
                  ↓
 agent's separately authorized change tools

For fresh-making, choosing the skill automatically supplies the standard make-fresh request; the human may edit it. The browser handoff itself remains read-only and page-session-only. Actual repository changes require the agent's separately authorized repository, filesystem, deployment, or other tools.

diff --git a/tests/site.spec.mjs b/tests/site.spec.mjs index 783fc13..d5f6399 100644 --- a/tests/site.spec.mjs +++ b/tests/site.spec.mjs @@ -43,6 +43,34 @@ test('primary public routes render meaningful headings', async ({ page }) => { } }); +test('WebMCP establishes repository context before agent-work selection', async ({ page }) => { + await page.goto('/webmcp/'); + + const repository = page.locator('[data-human-repository-target]'); + const skillFilter = page.locator('[data-human-skill-filter]'); + const freshMaking = page.locator('[data-human-skill][data-skill-name="fresh-making"] [data-select-skill]'); + const selectedSkill = page.locator('[data-selected-skill]'); + const intent = page.locator('[data-human-handoff-intent]'); + const send = page.locator('[data-human-handoff-send]'); + + await expect(skillFilter).toBeDisabled(); + await expect(freshMaking).toBeDisabled(); + await repository.selectOption('skill-lib'); + await expect(skillFilter).toBeEnabled(); + await expect(freshMaking).toBeEnabled(); + + await freshMaking.click(); + await expect(selectedSkill).toContainText('fresh-making'); + await expect(intent).toHaveValue(/minimal affected closure/); + await expect(send).toBeEnabled(); + + await repository.selectOption('ucns'); + await expect(selectedSkill).toHaveText('No skill selected.'); + await expect(intent).toHaveValue(''); + await expect(send).toBeDisabled(); + await expect(page).not.toHaveURL(/[?&]skill=/); +}); + test('founder-authored origin text is the public threshold and has one human continuation into the Way', async ({ page }) => { await page.goto('/'); await expect(page.locator('.awakening-splash')).toBeVisible(); @@ -277,4 +305,4 @@ test('Research is study-only and exposes provisional citation gaps', async ({ pa await expect(page.locator('body')).toContainText('support no-qualifying-study-found'); await expect(page.locator('body')).toContainText('dissent no-qualifying-study-found'); await expect(page.locator('body')).not.toContainText('TeamSTEPPS'); -}); \ No newline at end of file +}); diff --git a/tests/webmcp.test.mjs b/tests/webmcp.test.mjs index fc95b35..56313cd 100644 --- a/tests/webmcp.test.mjs +++ b/tests/webmcp.test.mjs @@ -110,6 +110,13 @@ test('human selection carries exact skill and repository identity without typed assert.match(source, /searchParams\.set\('skill', skill\.name\)/); assert.match(source, /searchParams\.set\('repo', repository\.name\)/); assert.match(source, /history\.replaceState/); + assert.match(source, /webmcp_repository_context_precedes_agent_work_selection/); + assert.match(source, /Choose a repository before choosing how the agent should work/); + assert.match(source, /const skillWasCleared = clearSelectedSkill\(\)/); + assert.ok( + source.indexOf("searchParams.get('repo')") < source.indexOf("searchParams.get('skill')"), + 'URL restoration must establish repository context before restoring a skill selection' + ); }); test('fresh-making supplies an editable default refresh request and still requires explicit Send', async () => { @@ -148,8 +155,14 @@ test('dedicated WebMCP route presents collapsible skill cards, every generated r assert.match(page, /]+data-human-handoff-intent/); assert.match(page, /data-human-handoff-send disabled/); assert.match(page, /Nothing is sent merely by selecting or typing/); - assert.match(page, /Send skill \+ repository \+ request to agent/); - assert.match(page, /No internal skill or repository identifier needs to be typed/); + assert.match(page, /Send repository \+ skill \+ request to agent/); + assert.match(page, /No internal repository or skill identifier needs to be typed/); + assert.match(page, /data-human-skill-filter[^>]+disabled/); + assert.match(page, /data-select-skill[^>]+disabled/); + const repositoryStep = page.indexOf('1. Choose the repository'); + const skillStep = page.indexOf('2. Choose how the agent should work'); + const outcomeStep = page.indexOf('3. State the outcome and send'); + assert.ok(repositoryStep >= 0 && repositoryStep < skillStep && skillStep < outcomeStep); assert.match(layout, /\{% if webmcp %\}