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