From 550ab1884cce3a0c04a931b05ed20db9865eec6c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 13:11:30 +0000 Subject: [PATCH] feat: append Product Design composition pointer on bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When skills/product-design/SKILL.md exists, SessionStart, OpenCode, Pi, and Kimi append the same short delivery-layer pointer: process skills stay primary, Product Design is secondary, conflicts keep process gates. Co-authored-by: 悟空非空也 <1390128154@qq.com> --- .kimi-plugin/plugin.json | 2 +- .kimi-plugin/sync-skill-instructions.sh | 17 +- .opencode/plugins/wukong-code.js | 18 +- .pi/extensions/wukong-code.ts | 20 +- docs/testing.md | 3 +- hooks/session-start | 27 +- scripts/test.sh | 1 + .../product-design-composition-pointer.md | 1 + .../test-product-design-bootstrap-pointer.sh | 346 ++++++++++++++++++ tests/hooks/test-session-start.sh | 9 + tests/hooks/test-tool-mapping-canonical.sh | 30 +- tests/kimi/test-plugin-manifest.sh | 18 +- tests/opencode/test-bootstrap-caching.mjs | 6 + tests/pi/test-pi-extension.mjs | 8 + tests/test-automation/test-test-runner.sh | 2 + 15 files changed, 491 insertions(+), 17 deletions(-) create mode 100644 skills/using-wukong-code/references/product-design-composition-pointer.md create mode 100755 tests/hooks/test-product-design-bootstrap-pointer.sh diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json index 7a48b6f..32574b1 100644 --- a/.kimi-plugin/plugin.json +++ b/.kimi-plugin/plugin.json @@ -23,7 +23,7 @@ "sessionStart": { "skill": "using-wukong-code" }, - "skillInstructions": "Kimi Code tool mapping for Wukong Code skills:\n\n- When a Wukong Code skill says to ask the user, ask clarifying questions, ask one question at a time, present multiple-choice options, use the terminal for a question, or wait for the user's choice, call Kimi Code's `AskUserQuestion` tool. Do not render those choices as plain assistant text unless `AskUserQuestion` is unavailable or the session is in auto permission mode.\n- For `AskUserQuestion`, provide 1 question with 2-4 concrete options when possible. Put the recommended option first and suffix its label with `(Recommended)`.\n- When a Wukong Code skill refers to `TodoWrite`, use Kimi Code's `TodoList` tool.\n- When a Wukong Code skill says `Task tool (general-purpose)` or asks you to dispatch an implementer/reviewer subagent, use Kimi Code's `Agent` tool with a Kimi subagent type. Do not pass `general-purpose` as `subagent_type`.\n- For implementation, code review, spec review, quality review, and filled Wukong Code subagent prompt templates, call `Agent` with `subagent_type: \"coder\"`, paste the fully filled prompt into `prompt`, and provide a short `description`.\n- For read-only codebase exploration that would take several searches, use `Agent` with `subagent_type: \"explore\"`.\n- For read-only planning or architecture design, use `Agent` with `subagent_type: \"plan\"`.\n- Keep dependent Wukong Code subagent steps sequential. Use multiple `Agent` calls, or `run_in_background: true` only when the work is independent and background agents are available.\n- When a Wukong Code skill refers to the `Skill` tool, use Kimi Code's native `Skill` tool.\n- Use Kimi Code's `Read`, `Write`, `Edit`, `Bash`, `Grep`, `Glob`, `FetchURL`, `WebSearch`, and MCP tools by their actual exposed names.\n- When a skill asks to search file contents, use `Grep`; when it asks to find files by path or pattern, use `Glob`; when it asks to fetch a URL, use `FetchURL`; when it asks to search the web, use `WebSearch`.", + "skillInstructions": "Kimi Code tool mapping for Wukong Code skills:\n\n- When a Wukong Code skill says to ask the user, ask clarifying questions, ask one question at a time, present multiple-choice options, use the terminal for a question, or wait for the user's choice, call Kimi Code's `AskUserQuestion` tool. Do not render those choices as plain assistant text unless `AskUserQuestion` is unavailable or the session is in auto permission mode.\n- For `AskUserQuestion`, provide 1 question with 2-4 concrete options when possible. Put the recommended option first and suffix its label with `(Recommended)`.\n- When a Wukong Code skill refers to `TodoWrite`, use Kimi Code's `TodoList` tool.\n- When a Wukong Code skill says `Task tool (general-purpose)` or asks you to dispatch an implementer/reviewer subagent, use Kimi Code's `Agent` tool with a Kimi subagent type. Do not pass `general-purpose` as `subagent_type`.\n- For implementation, code review, spec review, quality review, and filled Wukong Code subagent prompt templates, call `Agent` with `subagent_type: \"coder\"`, paste the fully filled prompt into `prompt`, and provide a short `description`.\n- For read-only codebase exploration that would take several searches, use `Agent` with `subagent_type: \"explore\"`.\n- For read-only planning or architecture design, use `Agent` with `subagent_type: \"plan\"`.\n- Keep dependent Wukong Code subagent steps sequential. Use multiple `Agent` calls, or `run_in_background: true` only when the work is independent and background agents are available.\n- When a Wukong Code skill refers to the `Skill` tool, use Kimi Code's native `Skill` tool.\n- Use Kimi Code's `Read`, `Write`, `Edit`, `Bash`, `Grep`, `Glob`, `FetchURL`, `WebSearch`, and MCP tools by their actual exposed names.\n- When a skill asks to search file contents, use `Grep`; when it asks to find files by path or pattern, use `Glob`; when it asks to fetch a URL, use `FetchURL`; when it asks to search the web, use `WebSearch`.\n\nProcess skills stay primary; Product Design is secondary. Conflicts keep the process gates. See `references/wukong-product-design-composition.md`.", "interface": { "displayName": "Wukong Code", "shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents", diff --git a/.kimi-plugin/sync-skill-instructions.sh b/.kimi-plugin/sync-skill-instructions.sh index 2516c95..820908c 100755 --- a/.kimi-plugin/sync-skill-instructions.sh +++ b/.kimi-plugin/sync-skill-instructions.sh @@ -1,24 +1,35 @@ #!/usr/bin/env bash # Copy skills/using-wukong-code/references/kimi-tools.md into -# .kimi-plugin/plugin.json skillInstructions. Edit the markdown file, then run -# this script. Do not hand-edit the mapping string in plugin.json. +# .kimi-plugin/plugin.json skillInstructions. When +# skills/product-design/SKILL.md exists, append the composition pointer after +# the mapping. Edit the markdown files, then run this script. Do not +# hand-edit the mapping string in plugin.json. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" MANIFEST="$REPO_ROOT/.kimi-plugin/plugin.json" TOOLS="$REPO_ROOT/skills/using-wukong-code/references/kimi-tools.md" +POINTER="$REPO_ROOT/skills/using-wukong-code/references/product-design-composition-pointer.md" +PD_SKILL="$REPO_ROOT/skills/product-design/SKILL.md" -python3 - "$MANIFEST" "$TOOLS" <<'PY' +python3 - "$MANIFEST" "$TOOLS" "$POINTER" "$PD_SKILL" <<'PY' import json import sys from pathlib import Path manifest_path = Path(sys.argv[1]) tools_path = Path(sys.argv[2]) +pointer_path = Path(sys.argv[3]) +pd_skill_path = Path(sys.argv[4]) text = tools_path.read_text(encoding="utf-8") if text.endswith("\n"): text = text[:-1] +if pd_skill_path.is_file() and pointer_path.is_file(): + pointer = pointer_path.read_text(encoding="utf-8") + if pointer.endswith("\n"): + pointer = pointer[:-1] + text = f"{text}\n\n{pointer}" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["skillInstructions"] = text manifest_path.write_text( diff --git a/.opencode/plugins/wukong-code.js b/.opencode/plugins/wukong-code.js index dc0b2f6..da2e39a 100644 --- a/.opencode/plugins/wukong-code.js +++ b/.opencode/plugins/wukong-code.js @@ -40,6 +40,20 @@ const extractAndStripFrontmatter = (content) => { return { frontmatter, content: body }; }; +export const productDesignCompositionPointer = (skillsDir) => { + const skillPath = path.join(skillsDir, 'product-design', 'SKILL.md'); + const pointerPath = path.join( + skillsDir, + 'using-wukong-code', + 'references', + 'product-design-composition-pointer.md' + ); + if (!fs.existsSync(skillPath) || !fs.existsSync(pointerPath)) { + return ''; + } + return fs.readFileSync(pointerPath, 'utf8').replace(/\s+$/, ''); +}; + // Normalize a path: trim whitespace, expand ~, resolve to absolute const normalizePath = (p, homeDir) => { if (!p || typeof p !== 'string') return null; @@ -86,6 +100,8 @@ export const WukongCodePlugin = async ({ client, directory }) => { const fullContent = fs.readFileSync(skillPath, 'utf8'); const { content } = extractAndStripFrontmatter(fullContent); const toolMapping = fs.readFileSync(toolsPath, 'utf8').replace(/\s+$/, ''); + const pointer = productDesignCompositionPointer(wukongCodeSkillsDir); + const pointerBlock = pointer ? `\n\n${pointer}` : ''; _bootstrapCache = ` You have wukong-code. @@ -94,7 +110,7 @@ You have wukong-code. ${content} -${toolMapping} +${toolMapping}${pointerBlock} `; return _bootstrapCache; diff --git a/.pi/extensions/wukong-code.ts b/.pi/extensions/wukong-code.ts index cbbb92b..e811ebb 100644 --- a/.pi/extensions/wukong-code.ts +++ b/.pi/extensions/wukong-code.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -64,6 +64,8 @@ function getBootstrapContent(): string | null { const skillContent = readFileSync(bootstrapSkillPath, "utf8"); const body = stripFrontmatter(skillContent); const toolMapping = readFileSync(piToolsPath, "utf8").replace(/\s+$/, ""); + const pointer = productDesignCompositionPointer(skillsDir); + const pointerBlock = pointer ? `\n\n${pointer}` : ""; cachedBootstrap = `${EXTREMELY_IMPORTANT_MARKER} ${BOOTSTRAP_MARKER} @@ -73,7 +75,7 @@ The using-wukong-code skill content is included below and is already loaded for ${body} -${toolMapping} +${toolMapping}${pointerBlock} `; return cachedBootstrap; } catch { @@ -82,6 +84,20 @@ ${toolMapping} } } +export function productDesignCompositionPointer(targetSkillsDir: string): string { + const skillPath = resolve(targetSkillsDir, "product-design", "SKILL.md"); + const pointerPath = resolve( + targetSkillsDir, + "using-wukong-code", + "references", + "product-design-composition-pointer.md", + ); + if (!existsSync(skillPath) || !existsSync(pointerPath)) { + return ""; + } + return readFileSync(pointerPath, "utf8").replace(/\s+$/, ""); +} + export function stripFrontmatter(content: string): string { const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); return (match ? match[1] : content).trim(); diff --git a/docs/testing.md b/docs/testing.md index 482d0ad..88f6128 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -56,7 +56,8 @@ Live in `tests/`. Currently: - `tests/brainstorm-server/` — node test suite for the brainstorm server JS code. - `tests/hooks/test-session-start.sh` — SessionStart JSON shapes (Claude / Cursor / Copilot / Codex). Language-router cases are not here. - `tests/hooks/test-language-router.sh` — Codex `UserPromptSubmit` language-router cases. -- `tests/hooks/test-tool-mapping-canonical.sh` — injected mapping / `skillInstructions` must equal `references/-tools.md`. New `*-tools.md` files fail until classified. `tests/claude-code/` stays out of the core gate. +- `tests/hooks/test-tool-mapping-canonical.sh` — injected mapping / `skillInstructions` must equal `references/-tools.md` (Kimi may append the Product Design composition pointer when that skill exists). New `*-tools.md` files fail until classified. `tests/claude-code/` stays out of the core gate. +- `tests/hooks/test-product-design-bootstrap-pointer.sh` — SessionStart / OpenCode / Pi / Kimi append the composition pointer only when `skills/product-design/SKILL.md` exists. - `tests/opencode/` — bash tests for OpenCode plugin loading, bootstrap caching, and tool registration. - `tests/codex-plugin-sync/` — bash sync verification. - `tests/kimi/` — bash/Python checks for Kimi plugin manifest wiring. diff --git a/hooks/session-start b/hooks/session-start index 5c285cf..1fcec17 100755 --- a/hooks/session-start +++ b/hooks/session-start @@ -37,6 +37,25 @@ read_harness_tools() { cat "$path" } +product_design_skill_path() { + printf '%s' "$1/skills/product-design/SKILL.md" +} + +product_design_composition_pointer_path() { + printf '%s' "$1/skills/using-wukong-code/references/product-design-composition-pointer.md" +} + +# Delivery-layer only. Inject when the Product Design router exists; do not +# rewrite using-wukong-code Scope routing. +read_product_design_composition_pointer() { + local plugin_root="$1" + local skill_path pointer_path + skill_path="$(product_design_skill_path "$plugin_root")" + pointer_path="$(product_design_composition_pointer_path "$plugin_root")" + [[ -f "$skill_path" && -f "$pointer_path" ]] || return 1 + cat "$pointer_path" +} + detect_session_harness() { if [[ -n "${CURSOR_PLUGIN_ROOT:-}" ]]; then printf '%s' cursor @@ -82,7 +101,13 @@ if mapping_raw="$(read_harness_tools "$PLUGIN_ROOT" "$detected_harness")"; then mapping_escaped=$(escape_for_json "$mapping_raw") mapping_escaped="\n\n${mapping_escaped}" fi -session_context="\nYou have wukong-code.\n\n**Below is the full content of your 'wukong-code:using-wukong-code' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**\n\n${using_wukong_code_escaped}${mapping_escaped}\n" +pointer_escaped="" +if pointer_raw="$(read_product_design_composition_pointer "$PLUGIN_ROOT")"; then + pointer_raw="${pointer_raw%$'\n'}" + pointer_escaped=$(escape_for_json "$pointer_raw") + pointer_escaped="\n\n${pointer_escaped}" +fi +session_context="\nYou have wukong-code.\n\n**Below is the full content of your 'wukong-code:using-wukong-code' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**\n\n${using_wukong_code_escaped}${mapping_escaped}${pointer_escaped}\n" # Output context injection as JSON. # Cursor hooks expect additional_context (snake_case). diff --git a/scripts/test.sh b/scripts/test.sh index 4aa333e..8ccec75 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -73,6 +73,7 @@ run_core() { run bash tests/hooks/test-session-start.sh run bash tests/hooks/test-language-router.sh run bash tests/hooks/test-tool-mapping-canonical.sh + run bash tests/hooks/test-product-design-bootstrap-pointer.sh run bash tests/opencode/run-tests.sh run bash tests/kimi/run-tests.sh run bash tests/cursor/run-tests.sh diff --git a/skills/using-wukong-code/references/product-design-composition-pointer.md b/skills/using-wukong-code/references/product-design-composition-pointer.md new file mode 100644 index 0000000..8e30081 --- /dev/null +++ b/skills/using-wukong-code/references/product-design-composition-pointer.md @@ -0,0 +1 @@ +Process skills stay primary; Product Design is secondary. Conflicts keep the process gates. See `references/wukong-product-design-composition.md`. diff --git a/tests/hooks/test-product-design-bootstrap-pointer.sh b/tests/hooks/test-product-design-bootstrap-pointer.sh new file mode 100755 index 0000000..8e7e471 --- /dev/null +++ b/tests/hooks/test-product-design-bootstrap-pointer.sh @@ -0,0 +1,346 @@ +#!/usr/bin/env bash +# Delivery-layer contract: injectors append the Product Design composition +# pointer only when skills/product-design/SKILL.md exists. using-wukong-code +# Scope routing stays unchanged. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +HOOK="$REPO_ROOT/hooks/session-start" +SKILL="$REPO_ROOT/skills/using-wukong-code/SKILL.md" +POINTER="$REPO_ROOT/skills/using-wukong-code/references/product-design-composition-pointer.md" +OPENCODE_PLUGIN="$REPO_ROOT/.opencode/plugins/wukong-code.js" +PI_EXTENSION="$REPO_ROOT/.pi/extensions/wukong-code.ts" +KIMI_MANIFEST="$REPO_ROOT/.kimi-plugin/plugin.json" +KIMI_SYNC="$REPO_ROOT/.kimi-plugin/sync-skill-instructions.sh" +KIMI_TOOLS="$REPO_ROOT/skills/using-wukong-code/references/kimi-tools.md" + +FAILURES=0 + +pass() { + echo " [PASS] $1" +} + +fail() { + echo " [FAIL] $1" >&2 + FAILURES=$((FAILURES + 1)) +} + +# shellcheck source=../../hooks/session-start +WUKONG_SESSION_START_LIB_ONLY=1 source "$HOOK" + +POINTER_TEXT="$(cat "$POINTER")" +POINTER_TEXT="${POINTER_TEXT%$'\n'}" + +if [[ ${#POINTER_TEXT} -gt 280 ]]; then + fail "pointer is longer than 280 characters (${#POINTER_TEXT})" +else + pass "pointer stays short (${#POINTER_TEXT} characters)" +fi + +if grep -q '|' "$POINTER"; then + fail "pointer must not contain a markdown table" +else + pass "pointer is not a second Scope table" +fi + +for required in \ + "Process skills stay primary" \ + "Product Design is secondary" \ + "keep the process gates" \ + "wukong-product-design-composition.md" +do + if [[ "$POINTER_TEXT" == *"$required"* ]]; then + pass "pointer names $required" + else + fail "pointer is missing $required" + fi +done + +for forbidden in \ + "Let's build" \ + "Let's make" \ + "react todo" \ + "ordinary UI" \ + "invoke product-design" \ + "use product-design first" \ + "human partner" +do + if grep -Fiq "$forbidden" "$POINTER"; then + fail "pointer must not contain $forbidden" + else + pass "pointer omits $forbidden" + fi +done + +if grep -Eq 'product-design|Product Design' "$SKILL"; then + fail "using-wukong-code SKILL.md must not grow a Product Design Scope table" +else + pass "using-wukong-code SKILL.md still has no Product Design routing" +fi + +if grep -Fq '"Let'\''s build X" → wukong-code:brainstorming first' "$SKILL"; then + pass "Let's build X still routes to brainstorming" +else + fail "using-wukong-code lost the brainstorming-first Let's build X route" +fi + +if pointer_live="$(read_product_design_composition_pointer "$REPO_ROOT")"; then + pointer_live="${pointer_live%$'\n'}" + if [[ "$pointer_live" == "$POINTER_TEXT" ]]; then + pass "session-start helper returns the pointer when Product Design exists" + else + fail "session-start helper returned unexpected pointer text" + fi +else + fail "session-start helper missed the pointer on the live tree" +fi + +session_start_context() { + local home="$1" + shift + local output + if ! output="$(env -i PATH="${PATH:-}" HOME="$home" "$@" 2>&1)"; then + printf '%s' "" + return 1 + fi + printf '%s' "$output" | node -e ' +const fs = require("fs"); +const payload = JSON.parse(fs.readFileSync(0, "utf8")); +const context = payload.additional_context + || payload.additionalContext + || (payload.hookSpecificOutput && payload.hookSpecificOutput.additionalContext) + || ""; +process.stdout.write(context); +' +} + +assert_context_has_pointer() { + local label="$1" + local context="$2" + if [[ "$context" != *"$POINTER_TEXT"* ]]; then + fail "$label is missing the Product Design pointer" + return + fi + local brainstorm_at pointer_at + brainstorm_at="$(awk -v needle="Let's build X" 'index($0, needle){print NR; exit}' <<<"$context")" + pointer_at="$(awk -v needle="$POINTER_TEXT" 'index($0, needle){print NR; exit}' <<<"$context")" + if [[ -n "$brainstorm_at" && -n "$pointer_at" && "$pointer_at" -gt "$brainstorm_at" ]]; then + pass "$label appends the pointer after brainstorming-first Scope routing" + else + fail "$label did not keep the pointer after using-wukong-code Scope routing" + fi +} + +live_home="$(mktemp -d)" +if live_context="$(session_start_context "$live_home" CLAUDE_PLUGIN_ROOT="$REPO_ROOT" bash "$HOOK")"; then + assert_context_has_pointer "live SessionStart" "$live_context" +else + fail "live SessionStart exited non-zero" +fi +rm -rf "$live_home" + +make_session_fixture() { + local tmp="$1" + local with_pd="$2" + mkdir -p "$tmp/home" "$tmp/skills/using-wukong-code/references" "$tmp/hooks" + cp "$HOOK" "$tmp/hooks/session-start" + chmod +x "$tmp/hooks/session-start" + cp "$SKILL" "$tmp/skills/using-wukong-code/SKILL.md" + cp "$POINTER" "$tmp/skills/using-wukong-code/references/product-design-composition-pointer.md" + if [[ "$with_pd" == "1" ]]; then + mkdir -p "$tmp/skills/product-design" + printf '%s\n' "---" "name: product-design" "---" "fixture" > "$tmp/skills/product-design/SKILL.md" + fi +} + +absent_tmp="$(mktemp -d)" +make_session_fixture "$absent_tmp" 0 +if ! read_product_design_composition_pointer "$absent_tmp"; then + pass "session-start helper is empty when Product Design is absent" +else + fail "session-start helper leaked a pointer without Product Design" +fi +if absent_context="$(session_start_context "$absent_tmp/home" CLAUDE_PLUGIN_ROOT="$absent_tmp" bash "$absent_tmp/hooks/session-start")"; then + if [[ "$absent_context" == *"$POINTER_TEXT"* ]]; then + fail "SessionStart fixture without Product Design still injected the pointer" + else + pass "SessionStart fixture without Product Design omits the pointer" + fi +else + fail "SessionStart fixture without Product Design exited non-zero" +fi +rm -rf "$absent_tmp" + +present_tmp="$(mktemp -d)" +make_session_fixture "$present_tmp" 1 +if present_context="$(session_start_context "$present_tmp/home" CLAUDE_PLUGIN_ROOT="$present_tmp" bash "$present_tmp/hooks/session-start")"; then + if [[ "$present_context" == *"$POINTER_TEXT"* ]]; then + pass "SessionStart fixture with Product Design injects the pointer" + else + fail "SessionStart fixture with Product Design missed the pointer" + fi +else + fail "SessionStart fixture with Product Design exited non-zero" +fi +rm -rf "$present_tmp" + +node --experimental-strip-types --input-type=module - \ + "$REPO_ROOT" "$OPENCODE_PLUGIN" "$PI_EXTENSION" "$POINTER_TEXT" <<'JS' +import os from 'node:os'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [repoRoot, pluginPath, piPath, pointerText] = process.argv.slice(2); +const skillsDir = join(repoRoot, 'skills'); + +const opencodeMod = await import(pathToFileURL(pluginPath).href); +if (opencodeMod.productDesignCompositionPointer(skillsDir) !== pointerText) { + throw new Error('OpenCode helper missed the live Product Design pointer'); +} + +const missingRoot = mkdtempSync(join(os.tmpdir(), 'wukong-pd-pointer-opencode-')); +mkdirSync(join(missingRoot, 'using-wukong-code', 'references'), { recursive: true }); +writeFileSync( + join(missingRoot, 'using-wukong-code', 'references', 'product-design-composition-pointer.md'), + `${pointerText}\n`, +); +if (opencodeMod.productDesignCompositionPointer(missingRoot) !== '') { + throw new Error('OpenCode helper leaked a pointer without Product Design'); +} + +const plugin = await opencodeMod.WukongCodePlugin({ client: {}, directory: '.' }); +const transform = plugin['experimental.chat.messages.transform']; +const output = { + messages: [{ + info: { role: 'user' }, + parts: [{ type: 'text', text: "Let's make a react todo list" }], + }], +}; +await transform({}, output); +const bootstrap = output.messages[0].parts.find( + (part) => part.type === 'text' && part.text.includes('EXTREMELY_IMPORTANT') +)?.text || ''; +if (!bootstrap.includes(pointerText)) { + throw new Error('OpenCode injected bootstrap is missing the Product Design pointer'); +} +if (!bootstrap.includes("Let's build X")) { + throw new Error('OpenCode injected bootstrap lost the brainstorming-first Scope line'); +} +if (bootstrap.indexOf(pointerText) < bootstrap.indexOf("Let's build X")) { + throw new Error('OpenCode pointer is not after using-wukong-code Scope routing'); +} + +const piMod = await import(pathToFileURL(piPath).href + `?pointer=${Date.now()}`); +if (piMod.productDesignCompositionPointer(skillsDir) !== pointerText) { + throw new Error('Pi helper missed the live Product Design pointer'); +} +if (piMod.productDesignCompositionPointer(missingRoot) !== '') { + throw new Error('Pi helper leaked a pointer without Product Design'); +} +rmSync(missingRoot, { recursive: true, force: true }); + +const handlers = new Map(); +piMod.default({ + on(event, handler) { + handlers.set(event, handler); + }, +}); +await handlers.get('session_start')({ type: 'session_start' }, {}); +const piResult = await handlers.get('context')({ + type: 'context', + messages: [{ + role: 'user', + content: [{ type: 'text', text: "Let's make a react todo list" }], + timestamp: 1, + }], +}, {}); +const piText = piResult.messages[0].content[0].text; +if (!piText.includes(pointerText)) { + throw new Error('Pi injected bootstrap is missing the Product Design pointer'); +} +if (!piText.includes("Let's build X")) { + throw new Error('Pi injected bootstrap lost the brainstorming-first Scope line'); +} +if (piText.indexOf(pointerText) < piText.indexOf("Let's build X")) { + throw new Error('Pi pointer is not after using-wukong-code Scope routing'); +} + +console.log('injectors-pointer'); +JS +pass "OpenCode live bootstrap appends the pointer after Scope routing" +pass "OpenCode helper is empty when Product Design is absent" +pass "Pi live bootstrap appends the pointer after Scope routing" +pass "Pi helper is empty when Product Design is absent" + +kimi_tools="$(cat "$KIMI_TOOLS")" +kimi_tools="${kimi_tools%$'\n'}" +kimi_expected="${kimi_tools}"$'\n\n'"${POINTER_TEXT}" +kimi_live="$(python3 - "$KIMI_MANIFEST" <<'PY' +import json +import sys +from pathlib import Path + +print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["skillInstructions"], end="") +PY +)" +if [[ "$kimi_live" == "$kimi_expected" ]]; then + pass "Kimi skillInstructions is mapping plus the pointer" +else + fail "Kimi skillInstructions is not mapping plus the pointer" +fi + +kimi_fixture="$(mktemp -d)" +mkdir -p "$kimi_fixture/.kimi-plugin" \ + "$kimi_fixture/skills/using-wukong-code/references" +cp "$KIMI_SYNC" "$kimi_fixture/.kimi-plugin/sync-skill-instructions.sh" +chmod +x "$kimi_fixture/.kimi-plugin/sync-skill-instructions.sh" +cp "$KIMI_MANIFEST" "$kimi_fixture/.kimi-plugin/plugin.json" +cp "$KIMI_TOOLS" "$kimi_fixture/skills/using-wukong-code/references/kimi-tools.md" +cp "$POINTER" "$kimi_fixture/skills/using-wukong-code/references/product-design-composition-pointer.md" +if ! bash "$kimi_fixture/.kimi-plugin/sync-skill-instructions.sh"; then + fail "Kimi sync exited non-zero without Product Design" +else + kimi_absent="$(python3 - "$kimi_fixture/.kimi-plugin/plugin.json" <<'PY' +import json +import sys +from pathlib import Path + +print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["skillInstructions"], end="") +PY +)" + if [[ "$kimi_absent" == "$kimi_tools" ]]; then + pass "Kimi sync omits the pointer when Product Design is absent" + else + fail "Kimi sync leaked a pointer without Product Design" + fi +fi + +mkdir -p "$kimi_fixture/skills/product-design" +printf '%s\n' "---" "name: product-design" "---" "fixture" > "$kimi_fixture/skills/product-design/SKILL.md" +if ! bash "$kimi_fixture/.kimi-plugin/sync-skill-instructions.sh"; then + fail "Kimi sync exited non-zero with Product Design" +else + kimi_present="$(python3 - "$kimi_fixture/.kimi-plugin/plugin.json" <<'PY' +import json +import sys +from pathlib import Path + +print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["skillInstructions"], end="") +PY +)" + if [[ "$kimi_present" == "$kimi_expected" ]]; then + pass "Kimi sync appends the pointer when Product Design exists" + else + fail "Kimi sync missed the pointer when Product Design exists" + fi +fi +rm -rf "$kimi_fixture" + +if [[ "$FAILURES" -gt 0 ]]; then + echo "STATUS: FAILED ($FAILURES failure(s))" + exit 1 +fi + +echo "STATUS: PASSED" diff --git a/tests/hooks/test-session-start.sh b/tests/hooks/test-session-start.sh index 6121194..2d887a0 100755 --- a/tests/hooks/test-session-start.sh +++ b/tests/hooks/test-session-start.sh @@ -218,6 +218,15 @@ assert_command_output \ CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \ bash "$HOOK_UNDER_TEST" +assert_command_output \ + "SessionStart appends the Product Design composition pointer after bootstrap" \ + "nested" \ + "Process skills stay primary; Product Design is secondary." \ + "" \ + "$fm_home" \ + CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \ + bash "$HOOK_UNDER_TEST" + legacy_home="$(make_home legacy-warning-removed)" mkdir -p "$legacy_home/.config/wukong-code/skills" assert_command_output \ diff --git a/tests/hooks/test-tool-mapping-canonical.sh b/tests/hooks/test-tool-mapping-canonical.sh index fc05199..67bb4c0 100755 --- a/tests/hooks/test-tool-mapping-canonical.sh +++ b/tests/hooks/test-tool-mapping-canonical.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Contract: each harness mapping has one source of truth — # skills/using-wukong-code/references/-tools.md — and injectors wrap -# that file instead of keeping a handwritten table. +# that file instead of keeping a handwritten table. Kimi skillInstructions +# may append the Product Design composition pointer after that mapping. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -90,7 +91,7 @@ skill_raw="$(cat "$SKILL")" export WUKONG_BASH_STRIPPED_BODY WUKONG_BASH_STRIPPED_BODY="$(strip_yaml_frontmatter "$skill_raw")" -python3 - "$KIMI_MANIFEST" "$REFERENCES/kimi-tools.md" <<'PY' +python3 - "$KIMI_MANIFEST" "$REFERENCES/kimi-tools.md" "$REFERENCES/product-design-composition-pointer.md" "$REPO_ROOT/skills/product-design/SKILL.md" <<'PY' import json import sys from pathlib import Path @@ -99,12 +100,24 @@ manifest = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) tools = Path(sys.argv[2]).read_text(encoding="utf-8") if tools.endswith("\n"): tools = tools[:-1] +pointer = Path(sys.argv[3]).read_text(encoding="utf-8") +if pointer.endswith("\n"): + pointer = pointer[:-1] +pd_skill = Path(sys.argv[4]) +expected = tools +if pd_skill.is_file(): + expected = f"{tools}\n\n{pointer}" instructions = manifest.get("skillInstructions") -if instructions != tools: - raise SystemExit("skillInstructions does not equal references/kimi-tools.md") +if instructions != expected: + raise SystemExit( + "skillInstructions must equal references/kimi-tools.md " + "plus the Product Design pointer when that skill exists" + ) +if not str(instructions).startswith(tools): + raise SystemExit("skillInstructions lost the kimi-tools.md prefix") print("kimi-equal") PY -pass "Kimi skillInstructions equals references/kimi-tools.md" +pass "Kimi skillInstructions equals kimi-tools.md plus optional PD pointer" node --experimental-strip-types --input-type=module - "$REPO_ROOT" "$OPENCODE_PLUGIN" "$PI_EXTENSION" "$SKILL" <<'JS' import fs from 'node:fs'; @@ -231,6 +244,8 @@ assert_shape_a_injects_mapping() { cp "$HOOK" "$tmp/hooks/session-start" chmod +x "$tmp/hooks/session-start" cp "$SKILL" "$tmp/skills/using-wukong-code/SKILL.md" + cp "$REFERENCES/product-design-composition-pointer.md" \ + "$tmp/skills/using-wukong-code/references/product-design-composition-pointer.md" printf '%s\n' "$token" > "$tmp/skills/using-wukong-code/references/${harness}-tools.md" local context @@ -244,6 +259,11 @@ assert_shape_a_injects_mapping() { else fail "Shape A $harness SessionStart did not inject ${harness}-tools.md" fi + if [[ "$context" == *"Process skills stay primary; Product Design is secondary."* ]]; then + fail "Shape A $harness SessionStart injected the PD pointer without product-design" + else + pass "Shape A $harness SessionStart omits the PD pointer when Product Design is absent" + fi rm -rf "$tmp" } diff --git a/tests/kimi/test-plugin-manifest.sh b/tests/kimi/test-plugin-manifest.sh index 00fe34f..0a207d3 100755 --- a/tests/kimi/test-plugin-manifest.sh +++ b/tests/kimi/test-plugin-manifest.sh @@ -33,15 +33,27 @@ instructions = manifest.get("skillInstructions") if not isinstance(instructions, str) or not instructions.strip(): raise AssertionError("skillInstructions must be a non-empty string") -tools_path = manifest_path.parents[1] / "skills/using-wukong-code/references/kimi-tools.md" +repo = manifest_path.parents[1] +tools_path = repo / "skills/using-wukong-code/references/kimi-tools.md" +pointer_path = repo / "skills/using-wukong-code/references/product-design-composition-pointer.md" +pd_skill = repo / "skills/product-design/SKILL.md" tools = tools_path.read_text(encoding="utf-8") if tools.endswith("\n"): tools = tools[:-1] -if instructions != tools: +expected = tools +if pd_skill.is_file() and pointer_path.is_file(): + pointer = pointer_path.read_text(encoding="utf-8") + if pointer.endswith("\n"): + pointer = pointer[:-1] + expected = f"{tools}\n\n{pointer}" +if instructions != expected: raise AssertionError( "skillInstructions must equal skills/using-wukong-code/references/kimi-tools.md " - "(run .kimi-plugin/sync-skill-instructions.sh after editing the reference file)" + "plus the Product Design pointer when that skill exists " + "(run .kimi-plugin/sync-skill-instructions.sh after editing the reference files)" ) +if not instructions.startswith(tools): + raise AssertionError("skillInstructions lost the kimi-tools.md prefix") for token in [ "AskUserQuestion", diff --git a/tests/opencode/test-bootstrap-caching.mjs b/tests/opencode/test-bootstrap-caching.mjs index ab7f802..3592f00 100644 --- a/tests/opencode/test-bootstrap-caching.mjs +++ b/tests/opencode/test-bootstrap-caching.mjs @@ -54,6 +54,9 @@ const result = { mapsSubagentToTask: bootstrapText(firstOutput).includes('`task` with `subagent_type: "general"`'), mapsMutationToApplyPatch: bootstrapText(firstOutput).includes('`apply_patch`'), mapsCanonicalToolsFile: bootstrapContainsCanonicalTools(firstOutput), + hasProductDesignPointer: bootstrapText(firstOutput).includes( + 'Process skills stay primary; Product Design is secondary.' + ), firstReadCount: afterFirst.readCount, secondReadCount: afterSecond.readCount, firstExistsCount: afterFirst.existsCount, @@ -156,6 +159,9 @@ function assertPresentBootstrap(result) { if (!result.mapsCanonicalToolsFile) { failures.push('expected OpenCode bootstrap to contain references/opencode-tools.md verbatim'); } + if (!result.hasProductDesignPointer) { + failures.push('expected OpenCode bootstrap to append the Product Design composition pointer'); + } if (result.bootstrapStartsWithFrontmatter) { failures.push('expected stripped bootstrap body not to start with YAML frontmatter ---'); } diff --git a/tests/pi/test-pi-extension.mjs b/tests/pi/test-pi-extension.mjs index e8b1c3f..86efc33 100644 --- a/tests/pi/test-pi-extension.mjs +++ b/tests/pi/test-pi-extension.mjs @@ -91,6 +91,14 @@ test('startup context injects the bootstrap as one user message until agent_end' true, 'injected mapping must equal references/pi-tools.md', ); + assert.match( + textOf(result.messages[0]), + /Process skills stay primary; Product Design is secondary\./, + ); + assert.match( + textOf(result.messages[0]), + /wukong-product-design-composition\.md/, + ); assert.equal(result.messages[1], originalMessages[0]); const repeatedProviderRequest = await context({ type: 'context', messages: originalMessages }, {}); diff --git a/tests/test-automation/test-test-runner.sh b/tests/test-automation/test-test-runner.sh index 8f2745f..8c68fff 100755 --- a/tests/test-automation/test-test-runner.sh +++ b/tests/test-automation/test-test-runner.sh @@ -77,6 +77,7 @@ for test_path in \ tests/hooks/test-session-start.sh \ tests/hooks/test-language-router.sh \ tests/hooks/test-tool-mapping-canonical.sh \ + tests/hooks/test-product-design-bootstrap-pointer.sh \ tests/opencode/run-tests.sh \ tests/kimi/run-tests.sh \ tests/cursor/run-tests.sh \ @@ -157,6 +158,7 @@ tests/skills/test-gemini-retirement.sh tests/hooks/test-session-start.sh tests/hooks/test-language-router.sh tests/hooks/test-tool-mapping-canonical.sh +tests/hooks/test-product-design-bootstrap-pointer.sh tests/opencode/run-tests.sh tests/kimi/run-tests.sh tests/cursor/run-tests.sh