diff --git a/hooks/scripts/__tests__/user-prompt-context.test.mjs b/hooks/scripts/__tests__/user-prompt-context.test.mjs new file mode 100644 index 0000000..43ebff5 --- /dev/null +++ b/hooks/scripts/__tests__/user-prompt-context.test.mjs @@ -0,0 +1,65 @@ +import { describe, test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// M0-8 / SC7: the UserPromptSubmit hook must point the voice-profile hint at the +// REAL artefact in the EXTERNAL data root (getDataRoot('voice-samples')), not the +// in-plugin placeholder. This hook is a procedural stdin->stdout CLI with no +// exports, so we test it end-to-end as a subprocess: pipe the prompt JSON, stub +// LINKEDIN_STUDIO_DATA + STATE_FILE, and inspect the injected systemMessage. +const hookPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'user-prompt-context.mjs'); + +function runHook(prompt, env) { + const out = execFileSync('node', [hookPath], { + input: JSON.stringify({ prompt }), + env: { ...process.env, ...env }, + encoding: 'utf-8', + }); + return JSON.parse(out); +} + +describe('user-prompt-context — voice hint points at external data root (M0-8/SC7)', () => { + let dataDir; + + afterEach(() => { + if (dataDir && existsSync(dataDir)) rmSync(dataDir, { recursive: true, force: true }); + dataDir = undefined; + }); + + test('real external voice present → systemMessage references the external voice file', () => { + dataDir = mkdtempSync(join(tmpdir(), 'lis-upc-')); + mkdirSync(join(dataDir, 'voice-samples'), { recursive: true }); + writeFileSync(join(dataDir, 'voice-samples', 'authentic-voice-samples.md'), '# My Voice\n\nreal content\n', 'utf-8'); + + const result = runHook('write a linkedin post', { + LINKEDIN_STUDIO_DATA: dataDir, + STATE_FILE: join(dataDir, 'no-state.local.md'), + }); + + assert.ok(result.systemMessage, 'a LinkedIn prompt must produce an enrichment systemMessage'); + assert.ok( + result.systemMessage.includes(join(dataDir, 'voice-samples', 'authentic-voice-samples.md')), + 'voice hint must point at the external voice file, not the in-plugin placeholder', + ); + }); + + test('external voice absent → no voice-profile hint injected (graceful degradation)', () => { + dataDir = mkdtempSync(join(tmpdir(), 'lis-upc-')); + // no voice-samples dir/file seeded under the external root + + const result = runHook('write a linkedin post', { + LINKEDIN_STUDIO_DATA: dataDir, + STATE_FILE: join(dataDir, 'no-state.local.md'), + }); + + assert.ok(result.systemMessage, 'still enriches (scorecard reminder), just without the voice hint'); + assert.ok( + !result.systemMessage.includes('Voice Profile:'), + 'no voice hint when the external voice artefact is absent', + ); + }); +}); diff --git a/hooks/scripts/user-prompt-context.mjs b/hooks/scripts/user-prompt-context.mjs index 66938a4..b47d8d7 100644 --- a/hooks/scripts/user-prompt-context.mjs +++ b/hooks/scripts/user-prompt-context.mjs @@ -13,13 +13,10 @@ // 0 - Always allow (informational hook) import { readFileSync, existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { getDataRoot, getStateFile } from './data-root.mjs'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const pluginRoot = join(__dirname, '..', '..'); -const home = process.env.HOME || process.env.USERPROFILE || ''; -const stateFile = join(home, '.claude', 'linkedin-studio.local.md'); +const stateFile = getStateFile(); // Read stdin JSON let input; @@ -98,10 +95,10 @@ if (!isLinkedin) { // === Build context enrichment === let context = '**LinkedIn Context Enrichment (auto-injected):**\n\n'; -// 1. Voice profile reference -const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md'); +// 1. Voice profile reference — the REAL artefact in the external data root (SC7). +const voiceFile = join(getDataRoot('voice-samples'), 'authentic-voice-samples.md'); if (existsSync(voiceFile)) { - context += '**Voice Profile:** Read `assets/voice-samples/authentic-voice-samples.md` for tone matching.\n\n'; + context += `**Voice Profile:** Read \`${voiceFile}\` for tone matching.\n\n`; } // 2-4. State file data