From 94cb497674cb89564b86fc3125c04814736f8eff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:05:16 +0300 Subject: [PATCH 01/14] Fix infinite recursion in tracedGenerate; add apps/api test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tracedGenerate (apps/api/src/gemini.ts:41) called itself at line 57 instead of ai.models.generateContent(params). Every LLM call unwound into RangeError: Maximum call stack size exceeded before any network I/O, so all ten exported functions were dead — /score, /coach, /diff and /improve with them. The recursion was type-valid, so tsc --noEmit and CI stayed green the whole time. apps/api had no test script and zero tests, which is exactly why this shipped. Add gemini.test.ts: seven tests that stub globalThis.fetch and assert a request actually reaches the transport, which is precisely what the recursion prevented. Verified as a real regression guard — reintroducing the self-call turns the suite red with RangeError rather than merely failing an assertion. Co-Authored-By: Claude Opus 5 --- apps/api/package.json | 3 +- apps/api/src/gemini.test.ts | 219 ++++++++++++++++++++++++++++++++++++ apps/api/src/gemini.ts | 2 +- 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/gemini.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index 4283732..1ccfa86 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsx --test src/gemini.test.ts" }, "dependencies": { "@google/genai": "^1.50.1", diff --git a/apps/api/src/gemini.test.ts b/apps/api/src/gemini.test.ts new file mode 100644 index 0000000..dbdb4bf --- /dev/null +++ b/apps/api/src/gemini.test.ts @@ -0,0 +1,219 @@ +// Integration tests for the Gemini wrapper — the 4,500-line backend's only +// path to an LLM, and until now the only major module in the repo with zero +// tests. That gap is exactly why the `tracedGenerate` self-recursion +// (gemini.ts:57) shipped to production: it was type-valid, so `tsc --noEmit` +// and CI stayed green while every single LLM call died with a RangeError +// before it ever reached the network. +// +// These tests stub `globalThis.fetch`, so nothing here touches the real +// Gemini API or needs a key that works. The stub is the whole point: it +// proves a request actually reaches the transport layer, which is precisely +// what infinite recursion prevents. +// +// Run: npm --workspace=apps/api test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DIMENSIONS } from '@trailhead/shared'; + +// GEMINI_API_KEY must be set BEFORE ./gemini.ts is evaluated — it throws at +// module scope when the key is absent. Static `import` declarations are +// hoisted above any statement in the module body, so this has to be a +// dynamic import after the assignment. The value is a placeholder: every +// request in this file is intercepted by the fetch stub, so it is never +// sent anywhere and is not a credential. +process.env.GEMINI_API_KEY ??= 'test-key-not-a-real-credential'; +const { extractTopic, overallScore, scorePrompt } = await import('./gemini.ts'); + +interface FetchCall { + url: string; + init: RequestInit | undefined; + body: unknown; +} + +/** + * Swap globalThis.fetch for a recorder that returns a canned Gemini + * generateContent response body. Returns the recorded calls plus a restore(). + */ +function withFetchStub( + responder: (call: FetchCall) => unknown, +): { calls: FetchCall[]; restore: () => void } { + const original = globalThis.fetch; + const calls: FetchCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + let body: unknown; + try { + body = typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body; + } catch { + body = init?.body; + } + const call: FetchCall = { url: String(input), init, body }; + calls.push(call); + const payload = responder(call); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof globalThis.fetch; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +/** Shape a Gemini generateContent response around a single text part. */ +function geminiTextResponse(text: string, usage?: Record) { + return { + candidates: [{ content: { role: 'model', parts: [{ text }] }, finishReason: 'STOP' }], + usageMetadata: usage ?? { + promptTokenCount: 100, + candidatesTokenCount: 50, + totalTokenCount: 150, + }, + }; +} + +const WELL_FORMED_SCORE = JSON.stringify({ + dimensions: { + goal_clarity: 8, + specificity: 6, + context_loading: 4, + constraint_articulation: 9, + output_specification: 3, + }, + missing: { + constraint_articulation: 'no libraries ruled out', + output_specification: 'no success criterion given', + }, +}); + +// --------------------------------------------------------------------------- +// The regression test for the blocker. Before the fix, `tracedGenerate` called +// itself instead of `ai.models.generateContent`, so this test would fail with +// "Maximum call stack size exceeded" and `calls.length` would be 0 — the +// request never left the process. +// --------------------------------------------------------------------------- +test('scorePrompt returns all five dimensions from a well-formed model response', async () => { + const stub = withFetchStub(() => geminiTextResponse(WELL_FORMED_SCORE)); + try { + const result = await scorePrompt({ prompt: 'fix the retry logic' }); + + // Five dimensions, all present, all integers in 0..10. + assert.equal( + Object.keys(result.dimensions).length, + 5, + 'expected exactly five scored dimensions', + ); + assert.equal(DIMENSIONS.length, 5); + for (const d of DIMENSIONS) { + const v = result.dimensions[d]; + assert.equal(typeof v, 'number', `${d} should be a number`); + assert.ok(Number.isInteger(v), `${d} should be an integer`); + assert.ok(v >= 0 && v <= 10, `${d}=${v} should be within 0..10`); + } + + assert.deepEqual(result.dimensions, { + goal_clarity: 8, + specificity: 6, + context_loading: 4, + constraint_articulation: 9, + output_specification: 3, + }); + + // The missing-hint block survives the isCleanHint filter. + assert.equal(result.missing.constraint_articulation, 'no libraries ruled out'); + assert.equal(result.missing.output_specification, 'no success criterion given'); + + // The point of the whole exercise: exactly one request actually reached + // the transport. Infinite recursion produces zero. + assert.equal(stub.calls.length, 1, 'expected exactly one HTTP request to Gemini'); + assert.match(stub.calls[0]!.url, /generativelanguage\.googleapis\.com/); + assert.match(stub.calls[0]!.url, /gemini-3-flash-preview/); + } finally { + stub.restore(); + } +}); + +test('scorePrompt sends the prompt and the five-dimension responseSchema on the wire', async () => { + const stub = withFetchStub(() => geminiTextResponse(WELL_FORMED_SCORE)); + try { + await scorePrompt({ prompt: 'add a webhook handler', file_path: 'src/api/hooks.ts' }); + const body = stub.calls[0]!.body as { + contents?: unknown; + generationConfig?: { responseSchema?: { properties?: { dimensions?: { required?: string[] } } } }; + }; + const wire = JSON.stringify(body); + assert.ok(wire.includes('add a webhook handler'), 'prompt must reach the model'); + assert.ok(wire.includes('src/api/hooks.ts'), 'file_path must reach the model'); + const required = body.generationConfig?.responseSchema?.properties?.dimensions?.required; + assert.deepEqual( + [...(required ?? [])].sort(), + [...DIMENSIONS].sort(), + 'responseSchema must require all five dimensions', + ); + } finally { + stub.restore(); + } +}); + +test('scorePrompt prepends team_context to the system instruction', async () => { + const stub = withFetchStub(() => geminiTextResponse(WELL_FORMED_SCORE)); + try { + await scorePrompt({ prompt: 'refactor the queue', team_context: 'TEAM CONVENTIONS: use pg pools' }); + const wire = JSON.stringify(stub.calls[0]!.body); + assert.ok(wire.includes('TEAM CONVENTIONS: use pg pools')); + } finally { + stub.restore(); + } +}); + +test('scorePrompt salvages the dimensions block from truncated JSON', async () => { + // The 2026-04-26 repetition failure mode: `missing` loops until + // maxOutputTokens, so the object never closes — but `dimensions` landed + // first and is intact. + const truncated = + '{\n "dimensions": {"goal_clarity": 7, "specificity": 2, "context_loading": 5, ' + + '"constraint_articulation": 6, "output_specification": 1},\n "missing": {"context_loading": "The prompt does not specify'; + const stub = withFetchStub(() => geminiTextResponse(truncated)); + try { + const result = await scorePrompt({ prompt: 'make it faster' }); + assert.equal(result.dimensions.goal_clarity, 7); + assert.equal(result.dimensions.output_specification, 1); + assert.equal(Object.keys(result.dimensions).length, 5); + } finally { + stub.restore(); + } +}); + +test('scorePrompt fails closed to all-zero dimensions on unparseable output', async () => { + const stub = withFetchStub(() => geminiTextResponse('I am afraid I cannot do that.')); + try { + const result = await scorePrompt({ prompt: 'do the thing' }); + for (const d of DIMENSIONS) assert.equal(result.dimensions[d], 0); + // Empty `missing` + all-zero is the fingerprint /coach uses to detect + // "the model failed" as distinct from "a genuinely terrible prompt". + assert.deepEqual(result.missing, {}); + } finally { + stub.restore(); + } +}); + +test('extractTopic reaches the network and returns the parsed topic', async () => { + const stub = withFetchStub(() => geminiTextResponse(JSON.stringify({ topic: 'retry' }))); + try { + assert.equal(await extractTopic('the fetch keeps failing on 503'), 'retry'); + assert.equal(stub.calls.length, 1); + } finally { + stub.restore(); + } +}); + +test('overallScore averages the five dimensions', () => { + assert.equal( + overallScore({ + goal_clarity: 8, + specificity: 6, + context_loading: 4, + constraint_articulation: 9, + output_specification: 3, + }), + 6, // 30 / 5 + ); +}); diff --git a/apps/api/src/gemini.ts b/apps/api/src/gemini.ts index e35da6d..134a5e0 100644 --- a/apps/api/src/gemini.ts +++ b/apps/api/src/gemini.ts @@ -54,7 +54,7 @@ async function tracedGenerate(params: GenContentParams): Promise }, }); try { - const resp = await tracedGenerate(params); + const resp = await ai.models.generateContent(params); if (gen) { const u = (resp as unknown as { usageMetadata?: { From 4696417166155fe8b6ba4b43354ca768426ab68f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:12:49 +0300 Subject: [PATCH 02/14] Surface /coach LLM failures; default auto-create off; drop leaked token; add LICENSE /coach used to answer a scoring failure with { proceed: true, text: '', overall: 0 }. The MCP coach tool renders an empty text on a proceed=true score-mode response as "(coach overall: 0/10 - no coaching needed)", byte-identical to what it prints for a flawless prompt. So whenever Gemini was down or returned unparseable output, the tool ran forever: never coaching, never erroring, reporting success the whole time. proceed stays true - a coaching sidecar outage must not block anyone's real work - but the response now carries degraded/error and a non-empty text saying what failed, and the MCP tool renders that instead of the success string. The helper moved to coach-degraded.ts so it can be tested without booting an HTTP listener and a Postgres pool; eight tests pin the contract. TRAILHEAD_AUTO_CREATE_TEAMS now defaults OFF (index.ts:81). It defaulted on, so any string any stranger sent as X-Team-Token silently provisioned a real tenant row - unauthenticated tenant creation and an unbounded write amplifier. Opt in with =true for open demo deploys. Delete scripts/{list-team-prompts,delete-prompt,find-prompt-to-delete}.mjs. Two embedded a live non-demo tenant token; all three were unreferenced one-offs. NOTE: the token remains in git history and MUST be rotated by the repo owner - that needs their account and cannot be done from here. Add MIT LICENSE (Copyright (c) 2026 Bogdan Truta). Its absence also hard -blocks vsce package. Manifest license fields follow in a later commit. Co-Authored-By: Claude Opus 5 --- LICENSE | 21 ++++++++ apps/api/package.json | 2 +- apps/api/src/coach-degraded.test.ts | 80 +++++++++++++++++++++++++++++ apps/api/src/coach-degraded.ts | 51 ++++++++++++++++++ apps/api/src/gemini.test.ts | 2 +- apps/api/src/index.ts | 42 +++++++-------- apps/mcp-server/src/tools.ts | 10 +++- packages/shared/types.ts | 12 +++++ scripts/delete-prompt.mjs | 74 -------------------------- scripts/find-prompt-to-delete.mjs | 77 --------------------------- scripts/list-team-prompts.mjs | 25 --------- 11 files changed, 193 insertions(+), 203 deletions(-) create mode 100644 LICENSE create mode 100644 apps/api/src/coach-degraded.test.ts create mode 100644 apps/api/src/coach-degraded.ts delete mode 100644 scripts/delete-prompt.mjs delete mode 100644 scripts/find-prompt-to-delete.mjs delete mode 100644 scripts/list-team-prompts.mjs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ad855ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bogdan Truta + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/api/package.json b/apps/api/package.json index 1ccfa86..13314c0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,7 +8,7 @@ "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", "typecheck": "tsc --noEmit", - "test": "tsx --test src/gemini.test.ts" + "test": "tsx --test src/gemini.test.ts src/coach-degraded.test.ts" }, "dependencies": { "@google/genai": "^1.50.1", diff --git a/apps/api/src/coach-degraded.test.ts b/apps/api/src/coach-degraded.test.ts new file mode 100644 index 0000000..f67153f --- /dev/null +++ b/apps/api/src/coach-degraded.test.ts @@ -0,0 +1,80 @@ +// Regression tests for the /coach silent-failure blocker. +// +// The bug these pin down: when scoring failed, /coach returned +// { proceed: true, text: '', overall: 0 }. The MCP coach tool renders an +// empty `text` on a proceed=true score-mode response as +// "(coach overall: 0/10 — no coaching needed)" — byte-identical to what it +// prints for a perfect prompt. The tool therefore ran indefinitely, never +// coaching and never erroring, and looked healthy the whole time. +// +// The contract asserted here is deliberately narrow and behavioural: +// 1. proceed stays true (an outage must not block the user's real work) +// 2. text is NON-EMPTY (this is what makes the failure visible) +// 3. degraded/error are set (this is what makes it machine-detectable) +// +// Run: npm --workspace=apps/api test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DIMENSIONS } from '@trailhead/shared'; +import type { DimensionScores } from '@trailhead/shared'; +import { degradedCoachResponse, degradeDetail } from './coach-degraded.ts'; + +const ZEROS = Object.fromEntries(DIMENSIONS.map((d) => [d, 0])) as DimensionScores; + +test('degraded response still proceeds — an outage must not block the user', () => { + const res = degradedCoachResponse('score', ZEROS, 'score_failed', new Error('boom')); + assert.equal(res.proceed, true); +}); + +test('degraded response never returns an empty text — that is the whole bug', () => { + for (const reason of ['score_failed', 'score_unparseable'] as const) { + const res = degradedCoachResponse('score', ZEROS, reason, new Error('boom')); + assert.notEqual(res.text, '', `${reason} must not produce an empty text`); + assert.ok(res.text.trim().length > 0); + // The MCP tool branches on `res.text && res.text.trim()` before it falls + // through to the "no coaching needed" string. A non-blank text is exactly + // what keeps it out of that branch. + assert.ok(!res.text.includes('no coaching needed')); + } +}); + +test('degraded response is machine-detectable via degraded + error', () => { + const res = degradedCoachResponse('score', ZEROS, 'score_unparseable'); + assert.equal(res.degraded, true); + assert.equal(res.error, 'score_unparseable'); +}); + +test('a healthy response is distinguishable from a degraded one', () => { + // The pre-fix shape: what a genuine "great prompt, nothing to coach" + // response looks like. It must not be confusable with the degraded shape. + const healthy = { proceed: true, text: '', degraded: undefined }; + const degraded = degradedCoachResponse('score', ZEROS, 'score_failed'); + assert.notEqual(Boolean(healthy.degraded), Boolean(degraded.degraded)); + assert.notEqual(healthy.text === '', degraded.text === ''); +}); + +test('the underlying error message is surfaced, not swallowed', () => { + const res = degradedCoachResponse('score', ZEROS, 'score_failed', new Error('429 quota exceeded')); + assert.match(res.text, /429 quota exceeded/); +}); + +test('degradeDetail handles non-Error throwables without producing "undefined"', () => { + assert.equal(degradeDetail('score_failed', 'plain string'), 'plain string'); + assert.equal(degradeDetail('score_failed', undefined), 'unknown error'); + assert.match(degradeDetail('score_unparseable'), /could not be parsed/); +}); + +test('the placeholder 0/10 is labelled as a placeholder, not a verdict', () => { + // A 0/10 that looks like a real score is its own kind of lie — it tells the + // user their prompt was terrible when in fact it was never read. + const res = degradedCoachResponse('score', ZEROS, 'score_failed'); + assert.equal(res.overall, 0); + assert.match(res.text, /placeholder/i); +}); + +test('mode is preserved so the caller can still branch on it', () => { + for (const mode of ['score', 'skip_reveal', 'augment'] as const) { + assert.equal(degradedCoachResponse(mode, ZEROS, 'score_failed').mode, mode); + } +}); diff --git a/apps/api/src/coach-degraded.ts b/apps/api/src/coach-degraded.ts new file mode 100644 index 0000000..9a5d217 --- /dev/null +++ b/apps/api/src/coach-degraded.ts @@ -0,0 +1,51 @@ +// The "coaching is unavailable this turn" response. +// +// Extracted from index.ts so it can be unit-tested: importing index.ts boots +// an HTTP listener and a Postgres pool, which a test has no business doing. +// +// Background. /coach used to handle a scoring failure by returning +// { proceed: true, overall: 0, dimensions: , missing: {}, text: '' }. +// The MCP coach tool renders an empty `text` on a proceed=true score-mode +// response as "(coach overall: 0/10 — no coaching needed)" — character for +// character what it prints for a flawless prompt. So when Gemini was down, +// or returned something unparseable, the tool ran forever: never coaching, +// never erroring, and reporting success. Silence is a worse failure than a +// crash, because nobody ever goes looking for it. +// +// The fix is not to stop failing open. `proceed` stays true on purpose: an +// outage in a coaching sidecar must never block someone's actual work. The +// fix is to stop failing *silent* — say plainly, in the field that gets +// relayed to the caller, that this turn was not scored and why. + +import type { CoachMode, CoachResponse, DimensionScores } from '@trailhead/shared'; + +export type DegradeReason = 'score_failed' | 'score_unparseable'; + +export function degradeDetail(reason: DegradeReason, err?: unknown): string { + if (reason === 'score_unparseable') { + return 'the model returned output that could not be parsed as a score'; + } + const msg = (err as { message?: string } | undefined)?.message; + return String(msg ?? err ?? 'unknown error'); +} + +export function degradedCoachResponse( + mode: CoachMode, + dimensions: DimensionScores, + reason: DegradeReason, + err?: unknown, +): CoachResponse { + return { + proceed: true, + mode, + overall: 0, + dimensions, + missing: {}, + degraded: true, + error: reason, + text: + `⚠️ Trailhead could not score this prompt, so it was not coached this turn ` + + `(${reason}: ${degradeDetail(reason, err)}). The 0/10 below is a placeholder, ` + + `not a judgement of the prompt. Proceed with the original prompt as written.`, + }; +} diff --git a/apps/api/src/gemini.test.ts b/apps/api/src/gemini.test.ts index dbdb4bf..6083dcc 100644 --- a/apps/api/src/gemini.test.ts +++ b/apps/api/src/gemini.test.ts @@ -40,7 +40,7 @@ function withFetchStub( ): { calls: FetchCall[]; restore: () => void } { const original = globalThis.fetch; const calls: FetchCall[] = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { let body: unknown; try { body = typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f8f7318..d829d2c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -56,6 +56,7 @@ import { renderTeachBlock, } from '@trailhead/scoring'; import { applyTeamNameIfPlaceholder, DEMO_TEAM_TOKEN, q, ensureTeam, upsertNode, wipeTeamData } from './db.ts'; +import { degradedCoachResponse } from './coach-degraded.ts'; import { acknowledgeProgress, extractTopic, @@ -74,11 +75,12 @@ import { bundleFromRequest, runJob } from './wiki-bootstrap-job.ts'; if (!process.env.DATABASE_URL) { console.error('DATABASE_URL not set'); process.exit(1); } if (!process.env.GEMINI_API_KEY) { console.error('GEMINI_API_KEY not set'); process.exit(1); } -// Multi-tenant policy. Defaults to ON for the hackathon-grade open demo -// posture: any X-Team-Token spawns its own teams row on first write. -// Production deploys should set TRAILHEAD_AUTO_CREATE_TEAMS=false and -// register teams explicitly. -const AUTO_CREATE_TEAMS = process.env.TRAILHEAD_AUTO_CREATE_TEAMS !== 'false'; +// Multi-tenant policy. Defaults to OFF: an unknown X-Team-Token is rejected +// with 401 rather than silently provisioning a tenant. It used to default ON, +// which meant any string anyone sent spawned a real teams row — unauthenticated +// tenant creation, and an unbounded write amplifier for anyone who found the +// host. Opt in with TRAILHEAD_AUTO_CREATE_TEAMS=true for open demo deploys. +const AUTO_CREATE_TEAMS = process.env.TRAILHEAD_AUTO_CREATE_TEAMS === 'true'; // Hono context typing — the auth middleware sets `team_token` so every // downstream handler can pull it via c.get('team_token') with type safety. @@ -469,17 +471,14 @@ app.post('/coach', async (c) => { }); scoreResult = { dimensions: result.dimensions, missing: result.missing as Record }; } catch (err) { - console.warn('[api] /coach scorePrompt failed', err); + // Fail-open on `proceed`, but NEVER fail silent. A coaching outage must + // not block the user's real work, so proceed stays true — but the caller + // is told plainly that this turn was not coached, and why. Returning + // text:'' here (the old behaviour) made an outage look identical to a + // perfect prompt, so the MCP tool ran indefinitely without ever coaching. + console.error('[api] /coach scorePrompt failed', err); const zeros = Object.fromEntries(DIMENSIONS.map((d) => [d, 0])) as DimensionScores; - const res: CoachResponse = { - proceed: true, - mode, - overall: 0, - dimensions: zeros, - missing: {}, - text: '', - }; - return c.json(res); + return c.json(degradedCoachResponse(mode, zeros, 'score_failed', err)); } const overall = overallScore(scoreResult.dimensions); @@ -493,15 +492,10 @@ app.post('/coach', async (c) => { const allZero = DIMENSIONS.every((d) => scoreResult.dimensions[d] === 0); const noMissing = Object.keys(scoreResult.missing).length === 0; if (allZero && noMissing) { - const res: CoachResponse = { - proceed: true, - mode, - overall: 0, - dimensions: scoreResult.dimensions, - missing: {}, - text: '', - }; - return c.json(res); + console.error('[api] /coach scorePrompt returned unparseable output (zero+empty fingerprint)'); + return c.json( + degradedCoachResponse(mode, scoreResult.dimensions, 'score_unparseable'), + ); } // 2. Skill_observation writes (same dedup as /score). diff --git a/apps/mcp-server/src/tools.ts b/apps/mcp-server/src/tools.ts index 0fac501..3a8ccaf 100644 --- a/apps/mcp-server/src/tools.ts +++ b/apps/mcp-server/src/tools.ts @@ -303,6 +303,8 @@ export function registerCoach(server: McpServer, client: ApiClient): void { dimensions: dimensionScoresSchema(), missing: z.record(z.string(), z.string()), text: z.string(), + degraded: z.boolean().optional(), + error: z.string().optional(), next_round_inputs: z .object({ original_prompt: z.string(), @@ -334,7 +336,13 @@ export function registerCoach(server: McpServer, client: ApiClient): void { // tells the LLM to read structuredContent (`proceed`, `text`); this // text is mostly for debug. let logText: string; - if (res.text && res.text.trim()) { + if (res.degraded) { + // Never let a scoring outage render as "no coaching needed" — that + // is exactly how it stayed invisible before. + logText = res.text?.trim() + ? res.text + : `(coach unavailable: ${res.error ?? 'unknown error'} — prompt was NOT scored)`; + } else if (res.text && res.text.trim()) { logText = res.text; } else if (res.proceed && res.mode === 'score') { logText = `(coach overall: ${res.overall}/10 — no coaching needed)`; diff --git a/packages/shared/types.ts b/packages/shared/types.ts index b93b4e3..2d5781f 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -298,6 +298,18 @@ export interface CoachResponse { missing: MissingHints; text: string; // fully rendered block to relay verbatim, may be '' + // Set when coaching could not be produced because the scoring LLM failed + // (request threw, or returned output we could not parse). `proceed` stays + // true — a coaching outage must not block the user's actual work — but the + // caller is told, in `text` and here, that this turn was NOT coached. + // + // Before this existed, that case returned { proceed: true, text: '', + // overall: 0 }, which the MCP tool rendered as "no coaching needed" — the + // exact same output as a flawless prompt. The tool ran forever, never + // coaching and never erroring. Never let a scoring failure be silent. + degraded?: boolean; + error?: string; // short machine-readable reason, e.g. 'score_failed' + // Populated when proceed=false. Echo the four fields back unchanged on the // next coach() call, with `prompt` set to original + user's reply. next_round_inputs?: CoachNextRoundInputs; diff --git a/scripts/delete-prompt.mjs b/scripts/delete-prompt.mjs deleted file mode 100644 index ac73bcb..0000000 --- a/scripts/delete-prompt.mjs +++ /dev/null @@ -1,74 +0,0 @@ -// One-off DELETE for the prompt(s) the user confirmed. -// Run from apps/api so node resolves `pg`: -// cd apps/api && node ../../scripts/delete-prompt.mjs - -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import pg from 'pg'; - -for (const candidate of ['.env', '../../.env', '../../../.env']) { - const p = resolve(process.cwd(), candidate); - if (existsSync(p)) { process.loadEnvFile(p); break; } -} - -const PROMPT_IDS = [ - '9fd05c5f-346f-42f5-8548-f087c251ad15', - '2e9bb4d8-1e1d-49c9-ba95-e92174fd69a5', -]; -const EXPECTED_TEAM = 'repo_dbab62ba8d72ca37'; -const EXPECTED_TEMPLATE_PREFIX = 'I want to improve the create case to output differently'; - -const { DATABASE_URL } = process.env; -if (!DATABASE_URL) { console.error('DATABASE_URL not set'); process.exit(1); } - -const pool = new pg.Pool({ connectionString: DATABASE_URL }); - -async function main() { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - const before = await client.query( - `SELECT p.id, p.template, n.team_token - FROM prompts p - JOIN nodes n ON n.id = p.node_id - WHERE p.id = ANY($1::uuid[])`, - [PROMPT_IDS], - ); - if (before.rowCount !== PROMPT_IDS.length) { - throw new Error(`expected ${PROMPT_IDS.length} rows, found ${before.rowCount}`); - } - for (const row of before.rows) { - if (row.team_token !== EXPECTED_TEAM) { - throw new Error(`team_token mismatch on ${row.id}: expected ${EXPECTED_TEAM}, got ${row.team_token}`); - } - if (!row.template.startsWith(EXPECTED_TEMPLATE_PREFIX)) { - throw new Error(`template prefix mismatch on ${row.id} — refusing to delete`); - } - } - - const del = await client.query( - `DELETE FROM prompts WHERE id = ANY($1::uuid[])`, - [PROMPT_IDS], - ); - console.log(`deleted ${del.rowCount} row(s) from prompts`); - - await client.query('COMMIT'); - - const after = await client.query( - `SELECT id FROM prompts WHERE id = ANY($1::uuid[])`, - [PROMPT_IDS], - ); - if (after.rowCount !== 0) throw new Error('rows still present after commit'); - console.log('verified: rows no longer present'); - } catch (e) { - await client.query('ROLLBACK').catch(() => {}); - throw e; - } finally { - client.release(); - } -} - -main() - .catch((e) => { console.error('FAILED:', e.message); process.exit(1); }) - .finally(() => pool.end()); diff --git a/scripts/find-prompt-to-delete.mjs b/scripts/find-prompt-to-delete.mjs deleted file mode 100644 index 74bae66..0000000 --- a/scripts/find-prompt-to-delete.mjs +++ /dev/null @@ -1,77 +0,0 @@ -// One-off: locate the prompt the user wants to delete. -// READ-ONLY. No DELETE happens here — that's a second script. -// -// Run from apps/api so node resolves `pg`: -// cd apps/api && node ../../scripts/find-prompt-to-delete.mjs - -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import pg from 'pg'; - -for (const candidate of ['.env', '../../.env', '../../../.env']) { - const p = resolve(process.cwd(), candidate); - if (existsSync(p)) { process.loadEnvFile(p); break; } -} - -const { DATABASE_URL } = process.env; -if (!DATABASE_URL) { - console.error('DATABASE_URL not set'); - process.exit(1); -} - -const pool = new pg.Pool({ connectionString: DATABASE_URL }); - -async function main() { - const teams = await pool.query( - `SELECT token, name FROM teams - WHERE LOWER(name) LIKE '%cs%weekly%' - OR LOWER(name) LIKE '%weekly%journal%' - OR LOWER(name) LIKE '%item%journal%' - OR LOWER(name) LIKE '%journal%' - ORDER BY name`, - ); - console.log(`\n=== Teams matching the description (${teams.rowCount}) ===`); - for (const r of teams.rows) console.log(` ${r.token} ${JSON.stringify(r.name)}`); - - const prompts = await pool.query( - `SELECT p.id, - p.template, - p.topic, - p.graduated_overall_score, - p.reuse_count, - p.created_at, - p.author_user_id, - n.path AS node_path, - n.team_token, - t.name AS team_name - FROM prompts p - JOIN nodes n ON n.id = p.node_id - JOIN teams t ON t.token = n.team_token - WHERE LOWER(p.template) LIKE '%improve the create case%' - OR LOWER(p.template) LIKE '%create case to output%' - OR LOWER(p.template) LIKE '%create_case%' - OR (LOWER(p.template) LIKE '%create case%' - AND (LOWER(p.template) LIKE '%output%' - OR LOWER(p.template) LIKE '%json%' - OR LOWER(p.template) LIKE '%csv%')) - ORDER BY p.created_at DESC`, - ); - - console.log(`\n=== Prompts matching the chain (${prompts.rowCount}) ===`); - for (const r of prompts.rows) { - console.log('---'); - console.log(` id ${r.id}`); - console.log(` team ${JSON.stringify(r.team_name)} (${r.team_token})`); - console.log(` node_path ${r.node_path}`); - console.log(` topic ${r.topic}`); - console.log(` score/reuse ${r.graduated_overall_score}/10 reuse=${r.reuse_count}`); - console.log(` created_at ${r.created_at.toISOString()}`); - console.log(` author ${r.author_user_id ?? '(null)'}`); - console.log(` template ${JSON.stringify(r.template).slice(0, 400)}${r.template.length > 400 ? '…' : ''}`); - } - console.log(''); -} - -main() - .catch((e) => { console.error(e); process.exit(1); }) - .finally(() => pool.end()); diff --git a/scripts/list-team-prompts.mjs b/scripts/list-team-prompts.mjs deleted file mode 100644 index b08ec27..0000000 --- a/scripts/list-team-prompts.mjs +++ /dev/null @@ -1,25 +0,0 @@ -// READ-ONLY: list every prompt for the CS-Weekly-Item-Journal team. -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import pg from 'pg'; - -for (const candidate of ['.env', '../../.env', '../../../.env']) { - const p = resolve(process.cwd(), candidate); - if (existsSync(p)) { process.loadEnvFile(p); break; } -} -const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); - -const rows = await pool.query( - `SELECT p.id, p.template, p.created_at, p.graduated_overall_score AS score, n.path - FROM prompts p - JOIN nodes n ON n.id = p.node_id - WHERE n.team_token = $1 - ORDER BY p.created_at DESC`, - ['repo_dbab62ba8d72ca37'], -); -console.log(`\n${rows.rowCount} prompt(s) for CS-Weekly-Item-Journal:\n`); -for (const r of rows.rows) { - console.log(` ${r.id} ${r.created_at.toISOString()} ${r.score}/10 path="${r.path}"`); - console.log(` ${JSON.stringify(r.template).slice(0, 220)}${r.template.length > 220 ? '…' : ''}`); -} -await pool.end(); From 475a75dd87c355ae87b7c1ad658a6d40e26db6ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:15:36 +0300 Subject: [PATCH 03/14] Add wiki markdown export (GET /wiki/export) Teams will not pour knowledge into a store they cannot get it back out of. Until now there was no export path at all, which makes the wiki a roach motel and a fair reason to refuse to adopt it. Export is both the trust signal and the backup story. GET /wiki/export returns the whole team wiki as one markdown document (text/markdown, content-disposition attachment). ?drafts=true includes draft learnings; ?format=json returns { filename, markdown } for browser clients that want to trigger their own download. The renderer (wiki-export.ts) is pure - nodes in, string out, injected clock - so the output contract is covered by fourteen real tests rather than a smoke test around a database. The case worth calling out: prompt templates routinely contain their own ``` blocks, so the fence width is computed from the longest backtick run in the body. A hardcoded three- backtick fence closes early and silently truncates the export mid- document, which is the kind of corruption nobody notices until they need the backup. The tree query moved to wiki-tree.ts so /wiki/tree and /wiki/export read the same rows through one query instead of two copies that drift. Co-Authored-By: Claude Opus 5 --- apps/api/package.json | 2 +- apps/api/src/index.ts | 132 ++++++--------------- apps/api/src/wiki-export.test.ts | 177 +++++++++++++++++++++++++++++ apps/api/src/wiki-export.ts | 189 +++++++++++++++++++++++++++++++ apps/api/src/wiki-tree.ts | 108 ++++++++++++++++++ 5 files changed, 512 insertions(+), 96 deletions(-) create mode 100644 apps/api/src/wiki-export.test.ts create mode 100644 apps/api/src/wiki-export.ts create mode 100644 apps/api/src/wiki-tree.ts diff --git a/apps/api/package.json b/apps/api/package.json index 13314c0..8463ab5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,7 +8,7 @@ "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", "typecheck": "tsc --noEmit", - "test": "tsx --test src/gemini.test.ts src/coach-degraded.test.ts" + "test": "tsx --test src/gemini.test.ts src/coach-degraded.test.ts src/wiki-export.test.ts" }, "dependencies": { "@google/genai": "^1.50.1", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d829d2c..0e475d7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -57,6 +57,8 @@ import { } from '@trailhead/scoring'; import { applyTeamNameIfPlaceholder, DEMO_TEAM_TOKEN, q, ensureTeam, upsertNode, wipeTeamData } from './db.ts'; import { degradedCoachResponse } from './coach-degraded.ts'; +import { loadWikiTree } from './wiki-tree.ts'; +import { exportFilename, renderWikiMarkdown } from './wiki-export.ts'; import { acknowledgeProgress, extractTopic, @@ -162,6 +164,7 @@ app.get('/', (c) => 'GET /skill-arc?user_id=&since=ISO', 'GET /team/metrics', 'GET /wiki/tree', + 'GET /wiki/export?drafts=&format=', 'POST /onboard/repo', 'POST /onboard/repo/full', 'GET /onboard/jobs/:id', @@ -1443,105 +1446,44 @@ app.get('/team/metrics', async (c) => { // learnings split into durable vs draft. Sort by path (prefix-friendly). app.get('/wiki/tree', async (c) => { + const nodes = await loadWikiTree(c.get('team_token')); + const res: WikiTreeResponse = { nodes }; + return c.json(res); +}); + +// ----- GET /wiki/export ----------------------------------------------------- +// Markdown export of the whole team wiki. Teams will not pour knowledge into +// a store they cannot get it back out of, so this is a trust signal as much +// as a backup story. +// +// GET /wiki/export -> text/markdown, as a download +// GET /wiki/export?drafts=true -> include draft learnings too +// GET /wiki/export?format=json -> { filename, markdown } for browser clients +app.get('/wiki/export', async (c) => { const teamToken = c.get('team_token'); - // Two queries instead of a three-way LEFT JOIN to avoid the cartesian - // row explosion (nodes × learnings × prompts). Run in parallel — the - // round-trip overhead is negligible at hackathon scale. - const [rows, promptRows] = await Promise.all([ - q<{ - node_id: string; - path: string; - body_md: string; - learning_id: string | null; - learning_body: string | null; - learning_status: 'draft' | 'durable' | null; - reinforcement_count: number | null; - }>( - `SELECT n.id AS node_id, n.path, n.body_md, - l.id AS learning_id, l.body AS learning_body, - l.status AS learning_status, l.reinforcement_count - FROM nodes n - LEFT JOIN learnings l ON l.node_id = n.id - WHERE n.team_token = $1 - ORDER BY n.path ASC, - COALESCE(l.reinforcement_count, 0) DESC`, - [teamToken], - ), - q<{ - path: string; - prompt_id: string; - template: string; - topic: string | null; - reuse_count: number; - author_user_id: string | null; - }>( - `SELECT n.path, - p.id AS prompt_id, - p.template, - p.topic, - p.reuse_count, - p.author_user_id - FROM prompts p - JOIN nodes n ON n.id = p.node_id - WHERE n.team_token = $1 - AND p.status = 'graduated' - ORDER BY n.path ASC, - p.reuse_count DESC, - p.created_at DESC`, - [teamToken], - ), + const [nodes, teamRows] = await Promise.all([ + loadWikiTree(teamToken), + q<{ name: string }>('SELECT name FROM teams WHERE token = $1', [teamToken]), ]); + const teamName = teamRows[0]?.name; + const now = new Date(); + const markdown = renderWikiMarkdown(nodes, { + teamName, + generatedAt: now, + includeDrafts: c.req.query('drafts') === 'true', + }); + const filename = exportFilename(teamName, now); - const byPath = new Map(); - for (const r of rows) { - let node = byPath.get(r.path); - if (!node) { - node = { - path: r.path, - body_md: r.body_md, - durable_learnings: [], - draft_learnings: [], - graduated_prompts: [], - }; - byPath.set(r.path, node); - } - if (r.learning_id && r.learning_body && r.learning_status) { - const learning: WikiTreeLearning = { - id: r.learning_id, - body: r.learning_body, - status: r.learning_status, - reinforcement_count: r.reinforcement_count ?? 0, - }; - if (r.learning_status === 'durable') node.durable_learnings.push(learning); - else node.draft_learnings.push(learning); - } + if (c.req.query('format') === 'json') { + return c.json({ filename, markdown }); } - for (const r of promptRows) { - // Fallback init covers the rare case where a node carries prompts but - // never appeared in the learnings query (shouldn't happen since the - // first query LEFT JOINs every node, but defense-in-depth). - let node = byPath.get(r.path); - if (!node) { - node = { - path: r.path, - body_md: '', - durable_learnings: [], - draft_learnings: [], - graduated_prompts: [], - }; - byPath.set(r.path, node); - } - node.graduated_prompts.push({ - id: r.prompt_id, - template: r.template, - topic: r.topic, - reuse_count: r.reuse_count, - author_user_id: r.author_user_id, - }); - } - - const res: WikiTreeResponse = { nodes: Array.from(byPath.values()) }; - return c.json(res); + return new Response(markdown, { + status: 200, + headers: { + 'content-type': 'text/markdown; charset=utf-8', + 'content-disposition': `attachment; filename="${filename}"`, + }, + }); }); // ----- GET /teams ------------------------------------------------------------ diff --git a/apps/api/src/wiki-export.test.ts b/apps/api/src/wiki-export.test.ts new file mode 100644 index 0000000..c271495 --- /dev/null +++ b/apps/api/src/wiki-export.test.ts @@ -0,0 +1,177 @@ +// Tests for the wiki → markdown export. +// +// The renderer is pure, so these are real tests of the output contract rather +// than smoke tests around a DB. The interesting cases are the ones where a +// naive implementation silently corrupts the export: prompt templates that +// contain their own fences, learnings with embedded newlines, and duplicate +// anchor slugs. +// +// Run: npm --workspace=apps/api test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { WikiTreeNode } from '@trailhead/shared'; +import { + exportFilename, + fence, + renderWikiMarkdown, + slugify, + sortNodes, +} from './wiki-export.ts'; + +const AT = new Date('2026-08-17T12:00:00.000Z'); + +function node(partial: Partial & { path: string }): WikiTreeNode { + return { + body_md: '', + durable_learnings: [], + draft_learnings: [], + graduated_prompts: [], + ...partial, + }; +} + +test('empty wiki renders a valid document, not a crash or a blank string', () => { + const md = renderWikiMarkdown([], { teamName: 'Acme', generatedAt: AT }); + assert.match(md, /^# Acme — Trailhead wiki export/); + assert.match(md, /_This wiki is empty\._/); +}); + +test('header tallies nodes, learnings and prompts', () => { + const md = renderWikiMarkdown( + [ + node({ + path: 'src/api/', + durable_learnings: [ + { id: '1', body: 'Always use the pg pool', status: 'durable', reinforcement_count: 3 }, + ], + graduated_prompts: [ + { id: 'p1', template: 'do a thing', topic: 'retry', reuse_count: 2, author_user_id: 'bo' }, + ], + }), + node({ path: 'src/web/' }), + ], + { generatedAt: AT }, + ); + assert.match(md, /2 nodes, 1 learning, 1 graduated prompt/); + assert.match(md, /Exported 2026-08-17T12:00:00\.000Z/); +}); + +test('drafts are excluded by default and included on request', () => { + const nodes = [ + node({ + path: 'a/', + draft_learnings: [{ id: 'd1', body: 'unconfirmed hunch', status: 'draft', reinforcement_count: 1 }], + }), + ]; + const without = renderWikiMarkdown(nodes, { generatedAt: AT }); + assert.ok(!without.includes('unconfirmed hunch')); + assert.ok(!without.includes('Draft learnings')); + + const with_ = renderWikiMarkdown(nodes, { generatedAt: AT, includeDrafts: true }); + assert.ok(with_.includes('unconfirmed hunch')); + assert.match(with_, /### Draft learnings/); +}); + +// --------------------------------------------------------------------------- +// The case a naive implementation gets wrong. Prompt templates routinely +// contain ``` blocks; a hardcoded three-backtick fence closes early and +// truncates the export mid-document. +// --------------------------------------------------------------------------- +test('a prompt containing a fenced code block does not terminate its own fence', () => { + const template = 'Rewrite this:\n```ts\nconst x = 1;\n```\nMake it faster.'; + const md = renderWikiMarkdown( + [node({ path: 'src/', graduated_prompts: [{ id: 'p', template, topic: null, reuse_count: 0, author_user_id: null }] })], + { generatedAt: AT }, + ); + assert.ok(md.includes(template), 'the template must survive verbatim'); + // The opening fence must be longer than the longest run inside the body. + assert.ok(md.includes('````\nRewrite this:'), 'expected a four-backtick fence'); +}); + +test('fence widens past the longest backtick run', () => { + assert.equal(fence('plain'), '```\nplain\n```'); + assert.equal(fence('a ``` b'), '````\na ``` b\n````'); + assert.equal(fence('a ````` b').split('\n')[0], '``````'); +}); + +test('fence does not double a trailing newline', () => { + assert.equal(fence('x\n'), '```\nx\n```'); +}); + +test('multi-line learnings are collapsed so they cannot break the bullet list', () => { + const md = renderWikiMarkdown( + [ + node({ + path: 'a/', + durable_learnings: [ + { id: '1', body: 'line one\n\nline two', status: 'durable', reinforcement_count: 1 }, + ], + }), + ], + { generatedAt: AT }, + ); + assert.ok(md.includes('- line one line two')); + assert.ok(!md.includes('- line one\n\nline two')); +}); + +test('reinforcement count is shown only when it is above one', () => { + const mk = (n: number) => + renderWikiMarkdown( + [node({ path: 'a/', durable_learnings: [{ id: '1', body: 'x', status: 'durable', reinforcement_count: n }] })], + { generatedAt: AT }, + ); + assert.ok(!mk(1).includes('reinforced')); + assert.ok(mk(4).includes('reinforced 4×')); +}); + +test('duplicate slugs get distinct anchors so the table of contents works', () => { + // 'src/api/' and 'src.api.' both slugify to 'src-api'. + const md = renderWikiMarkdown([node({ path: 'src/api/' }), node({ path: 'src.api.' })], { + generatedAt: AT, + }); + assert.ok(md.includes('')); + assert.ok(md.includes('')); + assert.ok(md.includes('(#src-api)')); + assert.ok(md.includes('(#src-api-1)')); +}); + +test('slugify never returns an empty anchor', () => { + assert.equal(slugify('///'), 'node'); + assert.equal(slugify('!!!'), 'node'); + assert.equal(slugify('src/api/auth.ts'), 'src-api-auth-ts'); +}); + +test('nodes are emitted in stable path order regardless of input order', () => { + const sorted = sortNodes([node({ path: 'b/' }), node({ path: 'a/' }), node({ path: 'a/c.ts' })]); + assert.deepEqual(sorted.map((n) => n.path), ['a/', 'a/c.ts', 'b/']); +}); + +test('node body_md is included verbatim', () => { + const md = renderWikiMarkdown([node({ path: 'a/', body_md: '## Handwritten\n\nSome prose.' })], { + generatedAt: AT, + }); + assert.ok(md.includes('## Handwritten')); + assert.ok(md.includes('Some prose.')); +}); + +test('prompt metadata is rendered when present and omitted when not', () => { + const withMeta = renderWikiMarkdown( + [node({ path: 'a/', graduated_prompts: [{ id: 'p', template: 't', topic: 'retry', reuse_count: 3, author_user_id: 'bo' }] })], + { generatedAt: AT }, + ); + assert.match(withMeta, /\*\*Prompt\*\* — topic: retry, reused 3×, by bo/); + + const bare = renderWikiMarkdown( + [node({ path: 'a/', graduated_prompts: [{ id: 'p', template: 't', topic: null, reuse_count: 0, author_user_id: null }] })], + { generatedAt: AT }, + ); + assert.ok(bare.includes('**Prompt**\n')); + assert.ok(!bare.includes('**Prompt** —')); +}); + +test('exportFilename is filesystem-safe and date-stamped', () => { + assert.equal(exportFilename('Acme Fintech', AT), 'acme-fintech-wiki-2026-08-17.md'); + assert.equal(exportFilename(undefined, AT), 'trailhead-wiki-2026-08-17.md'); + assert.ok(!exportFilename('a/b:c*d', AT).match(/[/:*]/)); +}); diff --git a/apps/api/src/wiki-export.ts b/apps/api/src/wiki-export.ts new file mode 100644 index 0000000..a9b384a --- /dev/null +++ b/apps/api/src/wiki-export.ts @@ -0,0 +1,189 @@ +// Wiki → Markdown export. +// +// Why this exists: a team pours accumulated knowledge into this wiki, and +// until now there was no way to get it back out. That makes the store a +// roach motel — knowledge checks in, it doesn't check out — which is a +// reasonable thing for a team to refuse to adopt. Export is both the trust +// signal and the backup story. +// +// Everything here is pure: nodes in, markdown string out. No DB, no network, +// no clock unless one is passed in. That is what makes it testable, and the +// tests are the spec. + +import type { WikiTreeNode } from '@trailhead/shared'; + +export interface WikiExportOptions { + /** Team display name for the document header. */ + teamName?: string; + /** Timestamp for the header. Injected so tests are deterministic. */ + generatedAt?: Date; + /** Include draft (un-reinforced) learnings. Default false — drafts are noise. */ + includeDrafts?: boolean; +} + +/** + * Fence a block of text so that any backticks inside it cannot terminate the + * fence early. CommonMark: a fenced block is closed only by a run of at least + * as many backticks as opened it, so we open with (longest inner run + 1), + * minimum 3. + * + * Prompt templates routinely contain ``` blocks, so a naive three-backtick + * fence would truncate the export mid-prompt and silently corrupt the output. + */ +export function fence(body: string, info = ''): string { + let longest = 0; + for (const run of body.match(/`+/g) ?? []) { + if (run.length > longest) longest = run.length; + } + const ticks = '`'.repeat(Math.max(3, longest + 1)); + // A trailing newline keeps the closing fence on its own line even when the + // body does not end in one. + const sep = body.endsWith('\n') ? '' : '\n'; + return `${ticks}${info}\n${body}${sep}${ticks}`; +} + +/** + * Turn a wiki path into a GitHub-style anchor slug, for the table of contents. + * Deliberately simple and deterministic; collisions get a numeric suffix from + * the caller. + */ +export function slugify(path: string): string { + return ( + path + .toLowerCase() + .replace(/[^a-z0-9/_. -]/g, '') + .replace(/[/_. ]+/g, '-') + .replace(/^-+|-+$/g, '') || 'node' + ); +} + +/** Sort nodes by path, folders (trailing '/') before files at the same level. */ +export function sortNodes(nodes: readonly WikiTreeNode[]): WikiTreeNode[] { + return [...nodes].sort((a, b) => a.path.localeCompare(b.path)); +} + +/** + * Render the whole team wiki as one self-contained markdown document. + */ +export function renderWikiMarkdown( + nodes: readonly WikiTreeNode[], + opts: WikiExportOptions = {}, +): string { + const { teamName, generatedAt, includeDrafts = false } = opts; + const sorted = sortNodes(nodes); + const out: string[] = []; + + out.push(`# ${teamName ? `${teamName} — ` : ''}Trailhead wiki export`); + out.push(''); + const stamp = generatedAt ? generatedAt.toISOString() : null; + const counts = tally(sorted, includeDrafts); + out.push( + `> ${counts.nodes} node${counts.nodes === 1 ? '' : 's'}, ` + + `${counts.learnings} learning${counts.learnings === 1 ? '' : 's'}, ` + + `${counts.prompts} graduated prompt${counts.prompts === 1 ? '' : 's'}` + + (stamp ? `. Exported ${stamp}.` : '.'), + ); + out.push(''); + + if (sorted.length === 0) { + out.push('_This wiki is empty._'); + out.push(''); + return out.join('\n'); + } + + // Table of contents. + out.push('## Contents'); + out.push(''); + const seen = new Map(); + const anchors: string[] = []; + for (const n of sorted) { + const base = slugify(n.path); + const dup = seen.get(base) ?? 0; + seen.set(base, dup + 1); + const anchor = dup === 0 ? base : `${base}-${dup}`; + anchors.push(anchor); + out.push(`- [${n.path}](#${anchor})`); + } + out.push(''); + + sorted.forEach((n, i) => { + out.push('---'); + out.push(''); + out.push(`## ${n.path}`); + out.push(''); + // An explicit anchor keeps the TOC links working regardless of how the + // renderer derives heading ids. + out.push(``); + out.push(''); + + if (n.body_md.trim()) { + out.push(n.body_md.trim()); + out.push(''); + } + + const durable = n.durable_learnings ?? []; + if (durable.length) { + out.push('### Durable learnings'); + out.push(''); + for (const l of durable) { + const rc = l.reinforcement_count ?? 0; + out.push(`- ${oneLine(l.body)}${rc > 1 ? ` _(reinforced ${rc}×)_` : ''}`); + } + out.push(''); + } + + const drafts = n.draft_learnings ?? []; + if (includeDrafts && drafts.length) { + out.push('### Draft learnings'); + out.push(''); + for (const l of drafts) { + out.push(`- ${oneLine(l.body)}`); + } + out.push(''); + } + + const prompts = n.graduated_prompts ?? []; + if (prompts.length) { + out.push('### Graduated prompts'); + out.push(''); + for (const p of prompts) { + const bits: string[] = []; + if (p.topic) bits.push(`topic: ${p.topic}`); + if (p.reuse_count) bits.push(`reused ${p.reuse_count}×`); + if (p.author_user_id) bits.push(`by ${p.author_user_id}`); + out.push(`**Prompt**${bits.length ? ` — ${bits.join(', ')}` : ''}`); + out.push(''); + out.push(fence(p.template)); + out.push(''); + } + } + }); + + return out.join('\n'); +} + +function tally(nodes: readonly WikiTreeNode[], includeDrafts: boolean) { + let learnings = 0; + let prompts = 0; + for (const n of nodes) { + learnings += (n.durable_learnings ?? []).length; + if (includeDrafts) learnings += (n.draft_learnings ?? []).length; + prompts += (n.graduated_prompts ?? []).length; + } + return { nodes: nodes.length, learnings, prompts }; +} + +/** + * Collapse a learning body to a single line so it cannot break out of the + * bullet it is rendered in. + */ +function oneLine(s: string): string { + return s.replace(/\s*\n\s*/g, ' ').trim(); +} + +/** Filename for the downloaded export. */ +export function exportFilename(teamName: string | undefined, at: Date): string { + const slug = slugify(teamName ?? 'trailhead') || 'trailhead'; + const day = at.toISOString().slice(0, 10); + return `${slug}-wiki-${day}.md`; +} diff --git a/apps/api/src/wiki-tree.ts b/apps/api/src/wiki-tree.ts new file mode 100644 index 0000000..5021d2c --- /dev/null +++ b/apps/api/src/wiki-tree.ts @@ -0,0 +1,108 @@ +// Loads a team's whole wiki tree. +// +// Extracted verbatim from the GET /wiki/tree handler so that the markdown +// export (wiki-export.ts) reads the same rows through the same query rather +// than growing a second, subtly-different copy that drifts. + +import type { WikiTreeLearning, WikiTreeNode } from '@trailhead/shared'; +import { q } from './db.ts'; + +export async function loadWikiTree(teamToken: string): Promise { + // Two queries instead of a three-way LEFT JOIN to avoid the cartesian + // row explosion (nodes × learnings × prompts). Run in parallel — the + // round-trip overhead is negligible at hackathon scale. + const [rows, promptRows] = await Promise.all([ + q<{ + node_id: string; + path: string; + body_md: string; + learning_id: string | null; + learning_body: string | null; + learning_status: 'draft' | 'durable' | null; + reinforcement_count: number | null; + }>( + `SELECT n.id AS node_id, n.path, n.body_md, + l.id AS learning_id, l.body AS learning_body, + l.status AS learning_status, l.reinforcement_count + FROM nodes n + LEFT JOIN learnings l ON l.node_id = n.id + WHERE n.team_token = $1 + ORDER BY n.path ASC, + COALESCE(l.reinforcement_count, 0) DESC`, + [teamToken], + ), + q<{ + path: string; + prompt_id: string; + template: string; + topic: string | null; + reuse_count: number; + author_user_id: string | null; + }>( + `SELECT n.path, + p.id AS prompt_id, + p.template, + p.topic, + p.reuse_count, + p.author_user_id + FROM prompts p + JOIN nodes n ON n.id = p.node_id + WHERE n.team_token = $1 + AND p.status = 'graduated' + ORDER BY n.path ASC, + p.reuse_count DESC, + p.created_at DESC`, + [teamToken], + ), + ]); + + const byPath = new Map(); + for (const r of rows) { + let node = byPath.get(r.path); + if (!node) { + node = { + path: r.path, + body_md: r.body_md, + durable_learnings: [], + draft_learnings: [], + graduated_prompts: [], + }; + byPath.set(r.path, node); + } + if (r.learning_id && r.learning_body && r.learning_status) { + const learning: WikiTreeLearning = { + id: r.learning_id, + body: r.learning_body, + status: r.learning_status, + reinforcement_count: r.reinforcement_count ?? 0, + }; + if (r.learning_status === 'durable') node.durable_learnings.push(learning); + else node.draft_learnings.push(learning); + } + } + for (const r of promptRows) { + // Fallback init covers the rare case where a node carries prompts but + // never appeared in the learnings query (shouldn't happen since the + // first query LEFT JOINs every node, but defense-in-depth). + let node = byPath.get(r.path); + if (!node) { + node = { + path: r.path, + body_md: '', + durable_learnings: [], + draft_learnings: [], + graduated_prompts: [], + }; + byPath.set(r.path, node); + } + node.graduated_prompts.push({ + id: r.prompt_id, + template: r.template, + topic: r.topic, + reuse_count: r.reuse_count, + author_user_id: r.author_user_id, + }); + } + + return Array.from(byPath.values()); +} From 47d68eb8ffe8bd45057b7ef0b0e3253d0414b3ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:16:56 +0300 Subject: [PATCH 04/14] Add hot-path DB indexes; correct schema.sql table count Read the actual query predicates in apps/api/src/index.ts against the indexes that existed: - `captures` had nothing beyond its primary key, yet GET /team/metrics filters it on (team_token, created_at) and the dashboard polls that every 30s per open viewer. Every poll was a sequential scan. - The /score dedup probe filters skill_observations on (team_token, user_id, dimension, prompt_hash, ts). The only index led with (team_token, dimension) and carried neither user_id nor prompt_hash, so it could not serve the probe - which runs once per dimension, five times per /score. - GET /skill-arc and the COUNT(DISTINCT user_id) in /team/metrics filter on (team_token, ts) and never on dimension, so they could not use that index either. - idx_nodes_team_token_path duplicated the UNIQUE (team_token, path) constraint on the same columns. Dropped. schema.sql:2 claimed "Six tables"; there are eight - the wiki_jobs and wiki_job_paths tables landed with rich bootstrap and the header was never updated. Co-Authored-By: Claude Opus 5 --- .../2026-08-17-hot-path-indexes.sql | 38 +++++++++++++++++++ packages/db/schema.sql | 30 ++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/db/migrations/2026-08-17-hot-path-indexes.sql diff --git a/packages/db/migrations/2026-08-17-hot-path-indexes.sql b/packages/db/migrations/2026-08-17-hot-path-indexes.sql new file mode 100644 index 0000000..8d08fe5 --- /dev/null +++ b/packages/db/migrations/2026-08-17-hot-path-indexes.sql @@ -0,0 +1,38 @@ +-- Hot-path indexes. Idempotent; safe to re-run. +-- +-- Apply: psql "$DATABASE_URL" -f packages/db/migrations/2026-08-17-hot-path-indexes.sql +-- +-- Three problems this fixes, all found by reading the actual query predicates +-- in apps/api/src/index.ts against the indexes that existed: +-- +-- 1. `captures` had no index beyond its primary key, yet GET /team/metrics +-- filters it on (team_token, created_at) and the dashboard polls that +-- endpoint every 30 seconds for every open viewer. Every poll was a full +-- sequential scan of the table. +-- +-- 2. The /score dedup probe filters skill_observations on +-- (team_token, user_id, dimension, prompt_hash, ts). The only index led +-- with (team_token, dimension) and carried neither user_id nor +-- prompt_hash, so it could not be used — and that probe runs once per +-- dimension, five times per /score call. +-- +-- 3. `idx_nodes_team_token_path` duplicated the UNIQUE (team_token, path) +-- constraint on the same table, which already maintains an index with the +-- same leading columns. Two indexes, one useful. +-- +-- Note on production: these are plain CREATE INDEX statements, which take an +-- ACCESS EXCLUSIVE-blocking-writes lock for the duration of the build. At the +-- current data volume that is milliseconds. On a large table, run these with +-- CREATE INDEX CONCURRENTLY instead (which cannot run inside a transaction +-- block, so it must be executed statement-by-statement). + +CREATE INDEX IF NOT EXISTS idx_captures_team_created + ON captures(team_token, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_skill_obs_dedup + ON skill_observations(team_token, user_id, dimension, prompt_hash, ts DESC); + +CREATE INDEX IF NOT EXISTS idx_skill_obs_team_ts + ON skill_observations(team_token, ts); + +DROP INDEX IF EXISTS idx_nodes_team_token_path; diff --git a/packages/db/schema.sql b/packages/db/schema.sql index 7f11dd7..0279d11 100644 --- a/packages/db/schema.sql +++ b/packages/db/schema.sql @@ -1,5 +1,8 @@ -- Trailhead schema — spec §4 (docs/superpowers/specs/2026-04-25-trailhead-design.md) --- Six tables. No events table (the Claude Code Stop hook replaces NOTIFY/LISTEN). +-- Eight tables: teams, nodes, learnings, prompts, captures, skill_observations, +-- wiki_jobs, wiki_job_paths. (The header said "six" from the original spec and +-- was never updated when the rich-bootstrap job tables landed.) +-- No events table (the Claude Code Stop hook replaces NOTIFY/LISTEN). -- -- Apply against your Neon database: -- psql "$DATABASE_URL" -f packages/db/schema.sql @@ -35,7 +38,11 @@ CREATE TABLE IF NOT EXISTS nodes ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (team_token, path) ); -CREATE INDEX IF NOT EXISTS idx_nodes_team_token_path ON nodes(team_token, path); +-- NOTE: no separate (team_token, path) index here. The UNIQUE constraint above +-- already creates one with identical leading columns, so a second was pure +-- write amplification and wasted space. Dropped — see +-- migrations/2026-08-17-hot-path-indexes.sql. +DROP INDEX IF EXISTS idx_nodes_team_token_path; -- Accumulated learnings (the "AI-managed" content) CREATE TABLE IF NOT EXISTS learnings ( @@ -79,6 +86,11 @@ CREATE TABLE IF NOT EXISTS captures ( scored_dimensions JSONB, -- {goal_clarity: 8, ...} created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- GET /team/metrics runs +-- SELECT ... FROM captures WHERE team_token = $1 AND created_at > $2 +-- and the dashboard polls it every 30s per open viewer. Without this the +-- table had no index beyond the PK, so every poll was a full sequential scan. +CREATE INDEX IF NOT EXISTS idx_captures_team_created ON captures(team_token, created_at DESC); -- Skill arc data — driven by real /score writes (every browser/VS Code prompt). -- prompt_hash backs the per-(user, dim, prompt-hash) 30s dedup window from @@ -95,6 +107,20 @@ CREATE TABLE IF NOT EXISTS skill_observations ( ); CREATE INDEX IF NOT EXISTS idx_skill_obs_team_token_dim_ts ON skill_observations(team_token, dimension, ts); +-- The 30s dedup NOT EXISTS probe in writeSkillObservations (apps/api/src/index.ts) +-- filters on (team_token, user_id, dimension, prompt_hash, ts). The index above +-- leads with (team_token, dimension) and carries neither user_id nor +-- prompt_hash, so it could not serve that probe — and the probe runs five times +-- (once per dimension) on every single /score. This index matches it column for +-- column. +CREATE INDEX IF NOT EXISTS idx_skill_obs_dedup + ON skill_observations(team_token, user_id, dimension, prompt_hash, ts DESC); + +-- GET /skill-arc (WHERE team_token AND ts > $, ORDER BY ts) and the +-- COUNT(DISTINCT user_id) in GET /team/metrics. Neither filters on dimension, +-- so neither could use idx_skill_obs_team_token_dim_ts. +CREATE INDEX IF NOT EXISTS idx_skill_obs_team_ts ON skill_observations(team_token, ts); + -- Cheap-insurance index for the GET /wiki/recent polling query. CREATE INDEX IF NOT EXISTS idx_learnings_last_seen_at ON learnings(last_seen_at DESC); From 83b008afd44543f96d0257e66f544d24f8d4c387 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:19:03 +0300 Subject: [PATCH 05/14] README honesty pass: correct six claims that describe a different product Each of these was checked against the code, not against the spec: - Score card "debounced 250 ms hits to /score" and the "5-second nudge / auto-sends as-is on timeout" describe behaviour that was deliberately removed. send-intercept.ts:17-21 lists both as gone. The extension now scores on send and never auto-fires a prompt. - Model names: README claimed Gemini 2.5 Flash and Gemini 2.5 Pro. packages/scoring/src/models.mjs uses gemini-3-flash-preview (score, topic, diff) and gemma-4-31b-it (extract). Nothing uses 2.5 Pro. - "Four hero tools" - tools.ts registers five (coach, wiki_lookup, wiki_save, wiki_bootstrap, wiki_proven_prompts). Added the missing row. - Cmd+Shift+K (vscode-ext/README.md:12): the manifest contributes no keybindings at all and one command, trailhead.refresh. The articulation scaffold was deferred during the original build and never written. Moved to an explicit "specced but never built" section. - dashboard/README.md:47 "All four routes prerender as static": there are five, and / is force-dynamic. - browser-ext/README.md:47 pointed at PINNED_CHROME.txt, which was never created and does not pin anything. Also noted in the MCP section that npx trailhead-mcp does not work - the package is private:true and neither name exists on npm. Co-Authored-By: Claude Opus 5 --- README.md | 45 +++++++++++++++++++++++--------------- apps/browser-ext/README.md | 14 +++++------- apps/dashboard/README.md | 6 +++-- apps/vscode-ext/README.md | 23 ++++++++++--------- 4 files changed, 50 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index ad95085..24a99de 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Endpoints implemented in `apps/api/src/index.ts`: | `GET /context?path=` | Ancestor walk: returns every wiki node whose path is a prefix of the file path, plus its durable learnings | | `GET /examples?path=` | Top graduated prompts for an ancestor of a file path | | `GET /wiki/recent?since=ISO` | Polling endpoint for the VS Code wiki-toast surface | -| `POST /diff` | Picks the closest graduated team prompt by topic + ancestry, scores both prompts, asks Gemini Pro to narrate the difference | +| `POST /diff` | Picks the closest graduated team prompt by topic + ancestry, scores both prompts, asks Gemini to narrate the difference | | `POST /improve` | Multi-turn Gemini-driven prompt rewrite, capped at 5 user replies | | `GET /skill-arc` | Time-series of per-dimension scores (powers the dashboard hero chart) | | `GET /team/metrics` | Snapshot: avg overall, reuse rate, durable count, draft count, active users | @@ -81,8 +81,10 @@ Endpoints implemented in `apps/api/src/index.ts`: | `GET /onboard/jobs/:id` | Per-path progress for a rich-bootstrap job | | `DELETE /team/data` | Wipes the requesting team's data; demo team is protected unless `TRAILHEAD_ALLOW_DEMO_RESET=true` | -LLM work runs through `apps/api/src/gemini.ts`: Gemini 2.5 Flash for scoring -(JSON-schema mode), Gemini 2.5 Pro for diff narration and rich bootstrap. +LLM work runs through `apps/api/src/gemini.ts`. Model assignments live in +`packages/scoring/src/models.mjs`: `gemini-3-flash-preview` for scoring +(JSON-schema mode), topic extraction and diff narration; `gemma-4-31b-it` for +async learning extraction, where latency is tolerable. Every Gemini call is instrumented with **Langfuse** when `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` are set — one trace per HTTP @@ -96,7 +98,7 @@ content-script host and pre-allowlists the deployed Railway API. Implemented widgets (`src/widgets/`): -- **Score card** under the textarea — debounced 250 ms hits to `/score`, +- **Score card** under the textarea — scores on send (not on keystroke), per-dimension bars, missing-dimension hints - **Score badge** on each user bubble - **Prompt diff panel** — "Compare to team" expands a `/diff` view inline @@ -104,9 +106,10 @@ Implemented widgets (`src/widgets/`): - **Wiki toast** — drops in when `/wiki/recent` polling sees a new learning - **Improve chat** — multi-turn rewrite using `/improve` - **Context pill + popup** — pick a wiki node to bias scoring -- **Send-intercept** — on send: `≥ 7` lets the native send fire; `< 7` shows a - 5-second nudge with *Have Claude clarify* / *Send as-is*; auto-sends as-is on - timeout. Fail-open on every API error. +- **Send-intercept** — on send: `≥ 7` lets the native send fire; `< 7` keeps the + score card up with *Improve* / *Send as-is* / *Edit* and waits for the user. + There is no timer and nothing is ever sent automatically. Fail-open on every + API error. Kill-switch: `chrome.storage.local.set({ 'trailhead.disabled': true })` halts the extension on next page load. @@ -120,9 +123,8 @@ toasts when `/wiki/recent` reports a new insight. ### `apps/mcp-server` — MCP server for Claude Code + Copilot Chat -STDIO MCP server distributed via `npx trailhead-mcp …`. Four hero tools -deliberately collapsed from a previous seven-tool surface so Copilot's tool -selector picks reliably: +STDIO MCP server. Five hero tools, deliberately collapsed from a previous +seven-tool surface so Copilot's tool selector picks reliably: | Tool | Routes to | |---|---| @@ -130,9 +132,15 @@ selector picks reliably: | `wiki_lookup` | `GET /context` + `GET /examples` (file-path based) and/or `GET /search` (query) | | `wiki_save` | `POST /wiki/propose` with server-side dedup | | `wiki_bootstrap` | `POST /onboard/repo` (skeleton) or `POST /onboard/repo/full` (rich, LLM-populated) | +| `wiki_proven_prompts` | `GET /prompts/proven` — the team's graduated prompts, filterable by score, path and topic | Plus a `ping` for health checks. +> **Not published to npm.** The package is `private: true` and neither +> `trailhead-mcp` nor `@trailhead/mcp-server` exists on the registry, so +> `npx trailhead-mcp` does not work. Run it from a clone — see +> [SELFHOSTING.md](SELFHOSTING.md). + CLI subcommands (`bin/cli.mjs`): - `trailhead-mcp init` — per-repo install. Writes `.mcp.json` + `CLAUDE.md` @@ -285,7 +293,7 @@ Single root `.env.example` — every surface reads from the same set. | Var | Used by | Notes | |---|---|---| | `DATABASE_URL` | api | Postgres connection string, `sslmode=require` | -| `GEMINI_API_KEY` | api | Gemini 2.5 Flash + 2.5 Pro | +| `GEMINI_API_KEY` | api | `gemini-3-flash-preview` + `gemma-4-31b-it` | | `LANGFUSE_PUBLIC_KEY` | api | Optional. Hosted Langfuse public key (`pk-lf-…`) | | `LANGFUSE_SECRET_KEY` | api | Optional. Hosted Langfuse secret key (`sk-lf-…`) | | `LANGFUSE_BASEURL` | api | Defaults to `https://cloud.langfuse.com` (EU). Use `https://us.cloud.langfuse.com` for US | @@ -317,11 +325,12 @@ Single root `.env.example` — every surface reads from the same set. ## How the pieces fit -1. Engineer types a prompt. Browser extension debounces 250 ms and hits - `/score`. The card mounts under the textarea with five per-dimension bars - and missing-dimension hints. -2. Below 7 → 5-second *Have Claude clarify* nudge, or fall back to *Send - as-is*. Each `/score` writes 5 `skill_observation` rows; the dashboard's +1. Engineer types a prompt and hits send. The extension intercepts the send and + calls `/score`. The card mounts under the textarea with five per-dimension + bars and missing-dimension hints. +2. `≥ 7` sends straight through. Below 7 the card stays up with *Improve* / + *Send as-is* / *Edit* and waits for an explicit choice — no timer, no + auto-send. Each `/score` writes 5 `skill_observation` rows; the dashboard's `/skill-arc` chart polls every 2 s, so the rightmost bucket climbs as the user prompts. 3. In Claude Code or Copilot Chat, the MCP server's `coach` tool is called @@ -346,7 +355,7 @@ The project was specced before it was built. Source of truth for *why*: - `docs/superpowers/specs/2026-04-25-trailhead-design.md` — master spec - `docs/superpowers/specs/2026-04-25-mcp-plugin-ux-design.md` — MCP install - story and four-tool surface + story and (then) four-tool surface - `docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md` — Claude.ai content-script architecture - `docs/superpowers/specs/2026-04-25-demo-completion-design.md` — dashboard @@ -368,7 +377,7 @@ contracts, builds, and tests. - **Backend:** Hono, TypeScript, Node 22, `@hono/node-server`, raw `pg` - **DB:** Postgres on Neon, no ORM -- **LLMs:** Gemini 2.5 Flash (scoring, JSON-schema mode), Gemini 2.5 Pro +- **LLMs:** `gemini-3-flash-preview` (scoring, JSON-schema mode), `gemma-4-31b-it` (diff narration, rich bootstrap) - **Observability:** Langfuse (hosted) — one trace per request, one generation per LLM call diff --git a/apps/browser-ext/README.md b/apps/browser-ext/README.md index b8b1707..95ddbd4 100644 --- a/apps/browser-ext/README.md +++ b/apps/browser-ext/README.md @@ -40,15 +40,13 @@ extension: ## Pinned demo Chrome (spec §19) -Lock the demo machine to a specific Chrome build. After verifying the -selectors at hour 6, capture `chrome://version` to: +The original plan was to lock the demo machine to a specific Chrome build by +capturing `chrome://version` into `apps/browser-ext/PINNED_CHROME.txt`. That +file was never created and no build is pinned — treat the selectors as +unverified against any particular Chrome version. -``` -apps/browser-ext/PINNED_CHROME.txt -``` - -Use a separate Chrome profile for the demo (`chrome://settings/manageProfile`) -so extension state doesn't drift. +Using a separate Chrome profile (`chrome://settings/manageProfile`) is still +worthwhile so extension state doesn't drift between runs. ## Smoke test diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 7f653d0..a9b9ccc 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -44,8 +44,10 @@ npm --workspace=apps/dashboard run build # ~5s, static export npm --workspace=apps/dashboard run typecheck # tsc --noEmit ``` -All four routes prerender as static (`○` in the build output) — they hydrate -on the client and SWR drives the live data. +There are five routes: `/`, `/onboarding`, `/skill-arc`, `/team` and `/wiki`. +Four prerender as static (`○` in the build output) and hydrate on the client +with SWR driving the live data. `/` is `export const dynamic = 'force-dynamic'` +(`ƒ` in the build output) because it fetches the team list per request. ## Deploy to Vercel diff --git a/apps/vscode-ext/README.md b/apps/vscode-ext/README.md index 5aea16d..4f8cf9e 100644 --- a/apps/vscode-ext/README.md +++ b/apps/vscode-ext/README.md @@ -1,19 +1,22 @@ # vscode-ext — VS Code extension The IDE-side coaching surface. Sidebar webview with score-card, team-anchored -examples, articulation scaffold, and the wiki-update toast that closes the -autonomous demo loop. +examples, and the wiki-update toast. **Tech:** TypeScript + VS Code API + WebView for sidebar. -**Surfaces (spec §7 A):** -- Sidebar webview — same score-card UI as browser-ext -- Pre-prompt panel — 2-3 team-anchored examples for current file path -- `Cmd+Shift+K` — articulation scaffold (3-field thinking helper) -- Post-prompt outcome rating widget (one keystroke) -- Polls `/wiki/propose` results → toast + wiki view refresh - (this is what makes the autonomous demo moment audience-visible - when Claude Code calls the MCP tool) +**Actually implemented:** +- Sidebar webview (`trailhead.coach`, in the Trailhead activity-bar container) + — same score-card UI as browser-ext +- Team-anchored examples for the current file path +- Outcome rating widget (`src/outcome-rating.ts`) +- Polls `/wiki/recent` → toast + wiki view refresh (`src/wiki-diff.ts`) +- One command: `trailhead.refresh` ("Trailhead: Refresh sidebar") + +**Specced but never built:** +- The `Cmd+Shift+K` articulation scaffold (the 3-field thinking helper). It was + deferred during the original build and never picked up. The manifest + contributes no keybindings at all, so the shortcut does nothing. **Talks to:** `apps/api` only (HTTP + polling). From 51953ed792f513cb24723e591911f753d2cd8f05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:19:53 +0300 Subject: [PATCH 06/14] Remove root clutter - c.txt was five space characters and referenced nowhere. - pitch-before-after.html (28 KB) is a one-off pitch artifact, unreferenced by any build or doc. Moved next to the other one in archive/ rather than deleted, since it is presentation history. Not done here: apps/landing-page/assets/logo-full.png is 488 KB and could be an order of magnitude smaller, but re-encoding a brand asset without the owner eyeballing the result is not a call to make from a script. Co-Authored-By: Claude Opus 5 --- pitch-before-after.html => archive/pitch-before-after.html | 0 c.txt | 1 - 2 files changed, 1 deletion(-) rename pitch-before-after.html => archive/pitch-before-after.html (100%) delete mode 100644 c.txt diff --git a/pitch-before-after.html b/archive/pitch-before-after.html similarity index 100% rename from pitch-before-after.html rename to archive/pitch-before-after.html diff --git a/c.txt b/c.txt deleted file mode 100644 index 81dd310..0000000 --- a/c.txt +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 41083b7a9c489dcb13d49493e50758ba86ab7020 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:21:27 +0300 Subject: [PATCH 07/14] Move duplicated response types into @trailhead/shared ExamplesItem/ExamplesResponse and WikiRecentItem/WikiRecentResponse were re-declared locally in both apps/mcp-server/src/api-client.ts and apps/vscode-ext/src/api.ts, while the neighbouring types in the very same import block came from @trailhead/shared. The local copies were field-identical to the server's definitions but had no compile-time link to them, so a server-side change would have typechecked cleanly on both sides and failed only at runtime. Both files now import the shared definitions and re-export the names, so existing importers are unaffected. Also added SearchItem/SearchResponse to shared and applied SearchResponse to the GET /search handler, which previously returned c.json({ items: rows }) with no declared contract at all - the one endpoint whose response shape nothing was checking. Co-Authored-By: Claude Opus 5 --- apps/api/src/index.ts | 4 ++- apps/mcp-server/src/api-client.ts | 57 ++++++++++++++----------------- apps/vscode-ext/src/api.ts | 27 +++++---------- packages/shared/types.ts | 9 +++++ 4 files changed, 46 insertions(+), 51 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0e475d7..3a25da1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -29,6 +29,7 @@ import type { ProvenPromptsResponse, ScoreRequest, ScoreResponse, + SearchResponse, SkillArcObservation, SkillArcResponse, TeamMetricsResponse, @@ -1209,7 +1210,8 @@ app.get('/search', async (c) => { [teamToken, pattern, ancestors, limit], ); - return c.json({ items: rows }); + const res: SearchResponse = { items: rows }; + return c.json(res); }); // ----- GET /wiki/recent?since=ISO -------------------------------------------- diff --git a/apps/mcp-server/src/api-client.ts b/apps/mcp-server/src/api-client.ts index 590fd7d..26ef94f 100644 --- a/apps/mcp-server/src/api-client.ts +++ b/apps/mcp-server/src/api-client.ts @@ -1,9 +1,18 @@ // Thin client for the Trailhead Hono API. Used by every MCP tool handler. // Reads URL + token from env so the same module works in MCP context (env // from .mcp.json) and in standalone tests (env from .env). +// These shapes are the API's response contract, so they live in +// @trailhead/shared next to the endpoint types rather than being re-declared +// here. They used to be local copies that happened to be field-identical to +// the server's — with nothing linking the two, so a server-side change would +// have compiled cleanly on both sides and broken only at runtime. import type { CoachRequest, CoachResponse, + ContextNode, + ContextResponse, + ExamplesItem, + ExamplesResponse, OnboardRepoFullRequest, OnboardRepoFullResponse, OnboardRepoRequest, @@ -11,43 +20,27 @@ import type { ProvenPromptsResponse, ScoreRequest, ScoreResponse, + SearchItem, + SearchResponse, WikiJobStatusResponse, WikiProposeRequest, WikiProposeResponse, + WikiRecentItem, + WikiRecentResponse, } from '@trailhead/shared'; -export interface ContextNode { - path: string; - body_md: string; - durable_learnings: { body: string; reinforcement_count: number }[]; -} -export interface ContextResponse { nodes: ContextNode[]; } - -export interface ExamplesItem { - template: string; - topic: string | null; - reuse_count: number; - node_path: string; -} -export interface ExamplesResponse { items: ExamplesItem[]; } - -export interface WikiRecentItem { - id: string; - node_path: string; - body: string; - status: 'draft' | 'durable'; - reinforcement_count: number; - last_seen_at: string; - created_at: string; -} -export interface WikiRecentResponse { items: WikiRecentItem[]; } - -export interface SearchItem { - kind: 'learning' | 'rule' | 'prompt'; - body: string; - node_path: string; -} -export interface SearchResponse { items: SearchItem[]; } +// Re-exported so existing importers of these names from './api-client.ts' +// keep working. +export type { + ContextNode, + ContextResponse, + ExamplesItem, + ExamplesResponse, + SearchItem, + SearchResponse, + WikiRecentItem, + WikiRecentResponse, +}; export interface ApiClientConfig { apiUrl: string; diff --git a/apps/vscode-ext/src/api.ts b/apps/vscode-ext/src/api.ts index 18ca34f..1bf4dba 100644 --- a/apps/vscode-ext/src/api.ts +++ b/apps/vscode-ext/src/api.ts @@ -1,29 +1,20 @@ // Tiny HTTP client used by the extension host. Matches the locked shapes from // packages/shared and the Phase-2 endpoints from the roadmap §2. +// Response shapes come from @trailhead/shared, where the API declares them. +// They were previously re-declared here as field-identical local copies with +// no compile-time link to the server, so drift would have been invisible. import type { + ExamplesItem, + ExamplesResponse, ScoreRequest, ScoreResponse, WikiProposeResponse, + WikiRecentItem, + WikiRecentResponse, } from '@trailhead/shared'; -export interface ExamplesItem { - template: string; - topic: string | null; - reuse_count: number; - node_path: string; -} -export interface ExamplesResponse { items: ExamplesItem[]; } - -export interface WikiRecentItem { - id: string; - node_path: string; - body: string; - status: 'draft' | 'durable'; - reinforcement_count: number; - last_seen_at: string; - created_at: string; -} -export interface WikiRecentResponse { items: WikiRecentItem[]; } +// Re-exported so existing importers of these names from './api.ts' keep working. +export type { ExamplesItem, ExamplesResponse, WikiRecentItem, WikiRecentResponse }; export interface ApiConfig { apiUrl: string; diff --git a/packages/shared/types.ts b/packages/shared/types.ts index 2d5781f..7871fb8 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -77,6 +77,15 @@ export interface ExamplesItem { } export interface ExamplesResponse { items: ExamplesItem[]; } +// GET /search?q=&scope= — free-text search across durable learnings, node +// rules and graduated prompts. `kind` says which of the three matched. +export interface SearchItem { + kind: 'learning' | 'rule' | 'prompt'; + body: string; + node_path: string; +} +export interface SearchResponse { items: SearchItem[]; } + // GET /prompts/proven — every graduated prompt for the team, with the // actual overall score from when /coach promoted it. "Proven" is the user- // facing framing; under the hood it's status='graduated' filtered by From c8f861271162897495515d344bd8af7bf41ecb34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:23:37 +0300 Subject: [PATCH 08/14] Check the .mjs sources and verify .d.mts declarations against them packages/scoring ships ten hand-written .d.mts declarations next to the .mjs implementations they describe. Two gaps: 1. tsconfig include was ["src/**/*.ts"], which matches neither .mts nor .mjs, so with no allowJs/checkJs the implementations were never typechecked by anything. Enabled allowJs + checkJs and widened the include. It found real implicit-any in the new test, which is the point. 2. Nothing compared a declaration to its implementation. TypeScript resolves importers to the .d.mts and never looks at the .mjs, so a declared export that does not exist gives every consumer `undefined` at runtime while the whole repo still typechecks green. declarations-match.test.mjs parses the value-level exports out of each .d.mts, imports the .mjs, and asserts the two sets match in both directions. All ten pairs are currently in sync. Verified it actually fails: adding a phantom `export declare const PHANTOM_MODEL` to models.d.mts turns it red with a message naming the file and the symbol. Co-Authored-By: Claude Opus 5 --- packages/scoring/package.json | 2 +- .../scoring/src/declarations-match.test.mjs | 94 +++++++++++++++++++ packages/scoring/tsconfig.json | 14 ++- 3 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 packages/scoring/src/declarations-match.test.mjs diff --git a/packages/scoring/package.json b/packages/scoring/package.json index 2ed29a6..85c7c38 100644 --- a/packages/scoring/package.json +++ b/packages/scoring/package.json @@ -20,7 +20,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "node --test src/normalize.test.mjs src/extract-prompt.test.mjs src/dedup-invariant.test.mjs" + "test": "node --test src/normalize.test.mjs src/extract-prompt.test.mjs src/dedup-invariant.test.mjs src/declarations-match.test.mjs" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/scoring/src/declarations-match.test.mjs b/packages/scoring/src/declarations-match.test.mjs new file mode 100644 index 0000000..d898989 --- /dev/null +++ b/packages/scoring/src/declarations-match.test.mjs @@ -0,0 +1,94 @@ +// Verifies that every hand-written .d.mts declaration matches the .mjs it +// claims to describe. +// +// Why this exists: the runtime helpers in this package are .mjs (so the Stop +// hook and other plain-ESM consumers can import them with no build step), and +// each one has a hand-written .d.mts sitting next to it. TypeScript resolves +// importers to the .d.mts and never compares it against the .mjs, so the two +// could disagree indefinitely and every typecheck in the repo would still pass +// — a declared export that doesn't exist gives consumers a value that is +// `undefined` at runtime with no compile-time warning anywhere. +// +// This test closes that gap the direct way: parse the value-level exports out +// of each declaration, import the implementation, and assert the two sets are +// equal in both directions. +// +// Run: npm --workspace=packages/scoring test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** + * Value-level exports declared in a .d.mts — the ones that must exist at + * runtime. `export interface` / `export type` are type-only and are + * deliberately excluded. + * + * @param {string} source + * @returns {Set} + */ +function declaredValueExports(source) { + const names = new Set(); + const patterns = [ + /^export\s+declare\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm, + /^export\s+declare\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm, + /^export\s+declare\s+class\s+([A-Za-z_$][\w$]*)/gm, + ]; + for (const re of patterns) { + for (const m of source.matchAll(re)) names.add(m[1]); + } + return names; +} + +const declarationFiles = readdirSync(HERE) + .filter((f) => f.endsWith('.d.mts')) + .sort(); + +test('the package actually has declaration files to check', () => { + // Guards against this whole suite silently passing if the files move. + assert.ok(declarationFiles.length >= 8, `found only ${declarationFiles.length} .d.mts files`); +}); + +for (const decl of declarationFiles) { + const impl = decl.replace(/\.d\.mts$/, '.mjs'); + + test(`${decl} matches ${impl}`, async () => { + const source = readFileSync(join(HERE, decl), 'utf8'); + const declared = declaredValueExports(source); + + const mod = await import(pathToFileURL(join(HERE, impl)).href); + const actual = new Set(Object.keys(mod)); + + const missing = [...declared].filter((n) => !actual.has(n)); + assert.deepEqual( + missing, + [], + `${decl} declares ${missing.join(', ')} but ${impl} does not export it — ` + + `consumers would get undefined at runtime with no type error`, + ); + + const undeclared = [...actual].filter((n) => !declared.has(n)); + assert.deepEqual( + undeclared, + [], + `${impl} exports ${undeclared.join(', ')} but ${decl} does not declare it — ` + + `the export is invisible to every TypeScript consumer`, + ); + }); +} + +test('models.mjs exports the model ids the API and README both cite', async () => { + // These strings are load-bearing: gemini.ts routes on them and the README + // documents them. A silent rename here is a production incident. + /** @type {Record} */ + const models = await import('./models.mjs'); + for (const k of ['SCORE_MODEL', 'TOPIC_MODEL', 'DIFF_MODEL', 'EXTRACT_MODEL']) { + const v = models[k]; + assert.equal(typeof v, 'string', `${k} must be a string`); + assert.ok(/** @type {string} */ (v).length > 0, `${k} must not be empty`); + } +}); diff --git a/packages/scoring/tsconfig.json b/packages/scoring/tsconfig.json index a6a0c40..0888379 100644 --- a/packages/scoring/tsconfig.json +++ b/packages/scoring/tsconfig.json @@ -3,7 +3,9 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "esModuleInterop": true, "allowSyntheticDefaultImports": true, "verbatimModuleSyntax": true, @@ -12,7 +14,13 @@ "skipLibCheck": true, "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true + "noEmit": true, + "allowJs": true, + "checkJs": true }, - "include": ["src/**/*.ts"] + "include": [ + "src/**/*.ts", + "src/**/*.mts", + "src/**/*.mjs" + ] } From 3e7d60781f7a892014c6f6f3e8b35cea1da12acb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:24:25 +0300 Subject: [PATCH 09/14] Landing page: ship production React instead of the development builds The public marketing page loaded react.development.js and react-dom.development.js - several times the size of the minified builds, running every dev-only invariant and warning path for every visitor. Documented the two limitations this does not fix: @babel/standalone still compiles the JSX in-browser on every load, and cdn.tailwindcss.com is a dev-time CDN Tailwind tells you not to ship. Both need a real build step, which also turns the Vercel deploy from "serve static files" into "run a build" - out of scope for this pass. Co-Authored-By: Claude Opus 5 --- apps/landing-page/index.html | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/landing-page/index.html b/apps/landing-page/index.html index 3b0276d..d1c2292 100644 --- a/apps/landing-page/index.html +++ b/apps/landing-page/index.html @@ -128,8 +128,21 @@ .accent-italic { font-style: italic; } - - + + + From 42c1c062c847f12ea2752b9329d1f22ff986af21 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:25:46 +0300 Subject: [PATCH 10/14] Add license: MIT to seven of the nine manifests Pairs with the MIT LICENSE added earlier. apps/vscode-ext and apps/mcp-server follow in the next commit - they are being edited concurrently for the self-hosting change. Co-Authored-By: Claude Opus 5 --- apps/api/package.json | 1 + apps/browser-ext/package.json | 51 ++++++++++++----------- apps/dashboard/package.json | 71 ++++++++++++++++---------------- package.json | 41 +++++++++--------- packages/score-card/package.json | 55 +++++++++++++------------ packages/scoring/package.json | 1 + packages/shared/package.json | 23 ++++++----- 7 files changed, 125 insertions(+), 118 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 8463ab5..4f5ac9c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,7 @@ { "name": "@trailhead/api", "version": "0.0.0", + "license": "MIT", "private": true, "type": "module", "main": "src/index.ts", diff --git a/apps/browser-ext/package.json b/apps/browser-ext/package.json index 784c30d..4dbfcc5 100644 --- a/apps/browser-ext/package.json +++ b/apps/browser-ext/package.json @@ -1,25 +1,26 @@ -{ - "name": "@trailhead/browser-ext", - "version": "0.0.1", - "private": true, - "type": "module", - "description": "LearnLoop browser extension — live 5-dimension score-card on Claude.ai", - "scripts": { - "build": "node esbuild.config.mjs", - "watch": "node esbuild.config.mjs --watch", - "typecheck": "tsc --noEmit", - "test": "npm run build && node --test --experimental-strip-types src/hash.test.mts src/augment.test.mts src/api.test.mts src/diff-parse.test.mts src/widgets/wiki-toast.test.mts test/bundle-load.test.mjs", - "smoke": "bash scripts/smoke.sh" - }, - "dependencies": { - "@trailhead/scoring": "*", - "@trailhead/score-card": "*", - "@trailhead/shared": "*" - }, - "devDependencies": { - "@types/chrome": "^0.0.287", - "@types/node": "^22.10.0", - "esbuild": "^0.24.0", - "typescript": "^5.7.2" - } -} +{ + "name": "@trailhead/browser-ext", + "version": "0.0.1", + "license": "MIT", + "private": true, + "type": "module", + "description": "LearnLoop browser extension — live 5-dimension score-card on Claude.ai", + "scripts": { + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test --experimental-strip-types src/hash.test.mts src/augment.test.mts src/api.test.mts src/diff-parse.test.mts src/widgets/wiki-toast.test.mts test/bundle-load.test.mjs", + "smoke": "bash scripts/smoke.sh" + }, + "dependencies": { + "@trailhead/scoring": "*", + "@trailhead/score-card": "*", + "@trailhead/shared": "*" + }, + "devDependencies": { + "@types/chrome": "^0.0.287", + "@types/node": "^22.10.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.2" + } +} diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 5265072..2d0cf0a 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,35 +1,36 @@ -{ - "name": "@trailhead/dashboard", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "next dev -p 3001", - "build": "next build", - "start": "next start -p 3001", - "typecheck": "tsc --noEmit", - "lint": "next lint" - }, - "dependencies": { - "@trailhead/shared": "*", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^0.469.0", - "next": "^15.1.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "recharts": "^2.15.0", - "swr": "^2.3.0", - "tailwind-merge": "^2.6.0" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.17", - "tailwindcss-animate": "^1.0.7", - "typescript": "^5.7.2" - } -} +{ + "name": "@trailhead/dashboard", + "version": "0.0.0", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -p 3001", + "typecheck": "tsc --noEmit", + "lint": "next lint" + }, + "dependencies": { + "@trailhead/shared": "*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.0", + "swr": "^2.3.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.2" + } +} diff --git a/package.json b/package.json index f723a7d..d2e5fcf 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,21 @@ -{ - "name": "trailhead", - "version": "0.0.0", - "private": true, - "description": "Trailhead — prompt-skill coach. PoliHack 2026-04-25. Spec: docs/superpowers/specs/2026-04-25-trailhead-design.md", - "workspaces": [ - "apps/*", - "packages/*" - ], - "scripts": { - "dev": "npm --workspace=apps/api run dev", - "start": "npm --workspace=apps/api run start", - "typecheck": "npm --workspaces --if-present run typecheck", - "test": "npm --workspaces --if-present run test", - "build": "npm --workspaces --if-present run build" - }, - "engines": { - "node": ">=22.6" - } -} +{ + "name": "trailhead", + "version": "0.0.0", + "license": "MIT", + "private": true, + "description": "Trailhead — prompt-skill coach. PoliHack 2026-04-25. Spec: docs/superpowers/specs/2026-04-25-trailhead-design.md", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev": "npm --workspace=apps/api run dev", + "start": "npm --workspace=apps/api run start", + "typecheck": "npm --workspaces --if-present run typecheck", + "test": "npm --workspaces --if-present run test", + "build": "npm --workspaces --if-present run build" + }, + "engines": { + "node": ">=22.6" + } +} diff --git a/packages/score-card/package.json b/packages/score-card/package.json index 005b909..b5ac81a 100644 --- a/packages/score-card/package.json +++ b/packages/score-card/package.json @@ -1,27 +1,28 @@ -{ - "name": "@trailhead/score-card", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./render": "./src/render.ts", - "./render-pure": "./src/render-pure.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "node --test --experimental-strip-types src/render-pure.test.mts" - }, - "dependencies": { - "@trailhead/shared": "*" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "typescript": "^5.7.2" - } -} +{ + "name": "@trailhead/score-card", + "version": "0.0.0", + "license": "MIT", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./render": "./src/render.ts", + "./render-pure": "./src/render-pure.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "node --test --experimental-strip-types src/render-pure.test.mts" + }, + "dependencies": { + "@trailhead/shared": "*" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.2" + } +} diff --git a/packages/scoring/package.json b/packages/scoring/package.json index 85c7c38..3d3f3d8 100644 --- a/packages/scoring/package.json +++ b/packages/scoring/package.json @@ -1,6 +1,7 @@ { "name": "@trailhead/scoring", "version": "0.0.0", + "license": "MIT", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/shared/package.json b/packages/shared/package.json index 1c6b897..51a294a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,11 +1,12 @@ -{ - "name": "@trailhead/shared", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./types.ts", - "types": "./types.ts", - "exports": { - ".": "./types.ts" - } -} +{ + "name": "@trailhead/shared", + "version": "0.0.0", + "license": "MIT", + "private": true, + "type": "module", + "main": "./types.ts", + "types": "./types.ts", + "exports": { + ".": "./types.ts" + } +} From 1c1167788de0a28da2c4988293f37b57877d61fa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:28:03 +0300 Subject: [PATCH 11/14] Narrow the bubble classifier and scope the DOM scan classifyBubble returned a role when a hint selector matched the node ITSELF *or* anything in its subtree (node.querySelector(sel)). The content script walks outermost-first and deliberately lets the outermost match win, so the first wrapper div that happened to contain a user message classified as a user bubble and swallowed the entire thread - every per-message widget (score badge, outcome rating, prompt diff) then mounted once, on the wrong element. classifyBubble now matches on the element itself only. walkBubblesIn queried every `div, article, li` in the subtree and asked each one. On a long conversation that is thousands of elements per mutation batch - i.e. on every streaming token. It now queries the hint selectors directly via BUBBLE_HINT_SELECTOR: same results, a fraction of the work, and no ambiguity about which element in a nesting chain is the bubble. Nine tests cover it, including the exact regression: a container that merely contains a user message, and a thread container holding both roles, must both classify as 'unknown'. classifyBubble only calls matches(), so the tests use a stub element rather than a DOM. (content.ts also picks up the initApiUrlState() call from the self-hosting change landing alongside this.) Co-Authored-By: Claude Opus 5 --- apps/browser-ext/package.json | 2 +- apps/browser-ext/src/content.ts | 24 +++-- .../src/selectors-classify.test.mts | 99 +++++++++++++++++++ apps/browser-ext/src/selectors.ts | 24 ++++- 4 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 apps/browser-ext/src/selectors-classify.test.mts diff --git a/apps/browser-ext/package.json b/apps/browser-ext/package.json index 4dbfcc5..3e94e4b 100644 --- a/apps/browser-ext/package.json +++ b/apps/browser-ext/package.json @@ -9,7 +9,7 @@ "build": "node esbuild.config.mjs", "watch": "node esbuild.config.mjs --watch", "typecheck": "tsc --noEmit", - "test": "npm run build && node --test --experimental-strip-types src/hash.test.mts src/augment.test.mts src/api.test.mts src/diff-parse.test.mts src/widgets/wiki-toast.test.mts test/bundle-load.test.mjs", + "test": "npm run build && node --test --experimental-strip-types src/hash.test.mts src/augment.test.mts src/api.test.mts src/diff-parse.test.mts src/selectors-classify.test.mts src/widgets/wiki-toast.test.mts test/bundle-load.test.mjs", "smoke": "bash scripts/smoke.sh" }, "dependencies": { diff --git a/apps/browser-ext/src/content.ts b/apps/browser-ext/src/content.ts index e2a32af..0f0f910 100644 --- a/apps/browser-ext/src/content.ts +++ b/apps/browser-ext/src/content.ts @@ -12,12 +12,18 @@ // TRAILHEAD_ERROR_TAG via the api.ts console.warn line). Anything else // bubbles to Claude.ai's own handler (spec §6.5). import { SELECTOR_RETRY_MS, TRAILHEAD_ERROR_TAG } from './config.ts'; -import { classifyBubble, resolveSelectors, type Selectors } from './selectors.ts'; +import { + BUBBLE_HINT_SELECTOR, + classifyBubble, + resolveSelectors, + type Selectors, +} from './selectors.ts'; import { injectStyles } from './styles.ts'; import { attachScoreCard } from './score-card.ts'; import { attachSendIntercept } from './send-intercept.ts'; import { startWikiToastLoop } from './widgets/wiki-toast.ts'; import { initCoachingState } from './coaching-state.ts'; +import { initApiUrlState } from './api-url-state.ts'; import { initTeamState } from './team-state.ts'; import { initContextState } from './context-state.ts'; import { initContextBundle } from './context-bundle.ts'; @@ -67,15 +73,15 @@ function tagBubble(node: HTMLElement, role: 'user' | 'assistant'): void { node.dataset.trailheadBubble = role; } +// Query the bubble hint selectors directly rather than testing every +// `div, article, li` on the page. On a long conversation that scan ran over +// thousands of elements per mutation batch, i.e. on every streaming token. function walkBubblesIn(root: Node, sel: Selectors): void { if (!(root instanceof Element)) return; - const candidates = root.matches('[data-trailhead-bubble], div, article, li') - ? [root] - : []; - for (const node of candidates) { - handleNode(node as HTMLElement, sel); + if (root.matches(BUBBLE_HINT_SELECTOR)) { + handleNode(root as HTMLElement, sel); } - for (const node of root.querySelectorAll('div, article, li')) { + for (const node of root.querySelectorAll(BUBBLE_HINT_SELECTOR)) { handleNode(node, sel); } } @@ -182,6 +188,10 @@ async function main(): Promise { // Subscribe to the coaching toggle so the popup switch takes effect // live — no page reload needed. initCoachingState(); + // Trailhead is self-hosted: the API base URL is user config, edited in + // the popup's "API server" row. Seed it before any fetch so requests go + // to the user's server rather than the localhost default. + initApiUrlState(); // Same pattern for the popup's Select-team dropdown — every fetch // after the user picks a team uses that team's X-Team-Token. initTeamState(); diff --git a/apps/browser-ext/src/selectors-classify.test.mts b/apps/browser-ext/src/selectors-classify.test.mts new file mode 100644 index 0000000..7030437 --- /dev/null +++ b/apps/browser-ext/src/selectors-classify.test.mts @@ -0,0 +1,99 @@ +// Regression tests for the bubble classifier. +// +// The bug: classifyBubble used to return a role when the selector matched the +// node ITSELF *or* anything in its subtree (`node.querySelector(sel)`). Since +// the content script walked the DOM outermost-first and deliberately let the +// outermost match win, the first wrapper div that happened to contain a user +// message classified as a user bubble — and swallowed the whole conversation. +// On claude.ai that wrapper is somewhere around the thread container, so in +// practice the entire thread got tagged as one bubble and every per-message +// widget (score badge, outcome rating, prompt diff) mounted once, on the wrong +// element. +// +// classifyBubble only calls `node.matches(...)`, so these tests use a tiny +// stub element rather than pulling in a DOM implementation. +// +// Run: npm --workspace=apps/browser-ext test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BUBBLE_HINT_SELECTOR, classifyBubble } from './selectors.ts'; + +/** + * Minimal Element stand-in. `matches` returns true for any selector in + * `selfMatches`; `querySelector` returns a truthy value for anything in + * `descendantMatches` — which is exactly the trap the old implementation fell + * into and the new one must ignore. + */ +function stubElement(selfMatches: string[], descendantMatches: string[] = []) { + return { + matches: (sel: string) => selfMatches.includes(sel), + querySelector: (sel: string) => (descendantMatches.includes(sel) ? {} : null), + } as unknown as Element; +} + +test('classifies a user bubble by its own attribute', () => { + assert.equal(classifyBubble(stubElement(['[data-testid="user-message"]'])), 'user'); +}); + +test('classifies an assistant bubble by its own attribute', () => { + assert.equal( + classifyBubble(stubElement(['[data-testid="assistant-message"]'])), + 'assistant', + ); +}); + +// --------------------------------------------------------------------------- +// The regression. A container that merely CONTAINS a user message is not a +// user bubble. Under the old implementation this returned 'user'. +// --------------------------------------------------------------------------- +test('a wrapper containing a user bubble is NOT itself classified as one', () => { + const wrapper = stubElement([], ['[data-testid="user-message"]']); + assert.equal( + classifyBubble(wrapper), + 'unknown', + 'a container that merely contains a user message must not be tagged as a bubble', + ); +}); + +test('a wrapper containing an assistant bubble is NOT itself classified as one', () => { + const wrapper = stubElement([], ['[data-testid="assistant-message"]']); + assert.equal(classifyBubble(wrapper), 'unknown'); +}); + +test('the thread container — which contains both roles — stays unknown', () => { + // This is the element that used to swallow the entire conversation. + const thread = stubElement([], [ + '[data-testid="user-message"]', + '[data-testid="assistant-message"]', + ]); + assert.equal(classifyBubble(thread), 'unknown'); +}); + +test('an unrelated element is unknown', () => { + assert.equal(classifyBubble(stubElement(['div.sidebar'])), 'unknown'); +}); + +test('user wins over assistant when an element somehow matches both', () => { + // Deterministic precedence matters: the outcome-rating widget looks + // backwards for the previous *user* bubble, so a flapping classification + // would attach ratings to the wrong message. + const both = stubElement(['[data-testid*="user"]', '[data-testid*="assistant"]']); + assert.equal(classifyBubble(both), 'user'); +}); + +test('classifyBubble tolerates an element without matches()', () => { + // Text nodes and some SVG elements reach this path in the wild. + const bare = {} as unknown as Element; + assert.equal(classifyBubble(bare), 'unknown'); +}); + +test('BUBBLE_HINT_SELECTOR is a valid non-empty selector list covering both roles', () => { + assert.ok(BUBBLE_HINT_SELECTOR.length > 0); + assert.ok(BUBBLE_HINT_SELECTOR.includes('[data-testid="user-message"]')); + assert.ok(BUBBLE_HINT_SELECTOR.includes('[data-testid="assistant-message"]')); + // Comma-joined so it can be handed straight to querySelectorAll. + const parts = BUBBLE_HINT_SELECTOR.split(','); + assert.ok(parts.length >= 12, `expected all hints, got ${parts.length}`); + for (const p of parts) assert.ok(p.trim().length > 0, 'no empty selector fragments'); +}); diff --git a/apps/browser-ext/src/selectors.ts b/apps/browser-ext/src/selectors.ts index 72dee6f..8b07288 100644 --- a/apps/browser-ext/src/selectors.ts +++ b/apps/browser-ext/src/selectors.ts @@ -130,12 +130,32 @@ const ASSISTANT_HINTS = [ '[class*="assistant-turn"]', ]; +/** + * Every selector that can identify a bubble, as one comma-joined list. + * + * The content script queries with this directly instead of walking every + * `div, article, li` on the page and asking each one whether it looks like a + * bubble. Same results, a fraction of the work, and no ambiguity about which + * element in a nesting chain is "the" bubble. + */ +export const BUBBLE_HINT_SELECTOR = [...USER_HINTS, ...ASSISTANT_HINTS].join(','); + +/** + * Classify an element that is already known to be a bubble candidate. + * + * Matches on the element ITSELF only. This used to also accept + * `node.querySelector(sel)` — a match anywhere in the subtree — which meant + * every ancestor of a user message classified as a user bubble, all the way up + * to the conversation container and `body`. Since the caller walked the DOM + * outermost-first and let the outermost match win, a single wrapper div + * swallowed the entire thread and got tagged as one giant "user bubble". + */ export function classifyBubble(node: Element): BubbleRole { for (const sel of USER_HINTS) { - if (node.matches?.(sel) || node.querySelector?.(sel)) return 'user'; + if (node.matches?.(sel)) return 'user'; } for (const sel of ASSISTANT_HINTS) { - if (node.matches?.(sel) || node.querySelector?.(sel)) return 'assistant'; + if (node.matches?.(sel)) return 'assistant'; } return 'unknown'; } From 9599e641d074ded9f7a98348da6c8b89b1aa654f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:36:04 +0300 Subject: [PATCH 12/14] Stop GET /teams leaking every tenant's token; make self-hosting the default ## GET /teams The endpoint was unauthenticated, returned every team on the server together with its token, and CORS is `*`. The team token is the only credential this system has - it grants read on the wiki (which summarises private source code) and write on everything - so one GET from any web page compromised every tenant at once. What paid for that was the browser popup's convenience of pre-filling a team dropdown before any token was configured. /teams now requires X-Team-Token and returns only the caller's own team, as { name, id } where id is a truncated SHA-256 of the token: opaque, stable, safe to render, and not replayable as a credential (index.ts:127, 1557-1567 in the old numbering). TeamSummary no longer carries `token` at all, so the type system enforces this at every call site. Client consequences, all deliberate: - The popup's pick-a-team list is gone; switching teams means entering that team's token, which the popup then resolves to a display name via the authenticated endpoint. Adopting a team you don't hold a token for is no longer possible, which is the point. - The popup's reachability probe moved from /teams to GET /, the actual unauthenticated status endpoint. - The dashboard shows the team its own NEXT_PUBLIC_TEAM_TOKEN resolves to, and no longer prints the token into the page. ## Self-hosting trailheadapi-production.up.railway.app is deleted and returns 404, and it was the hardcoded default in ten source files, so every client shipped pointing at a dead server. Rather than re-point at another host that can die, self-hosting is now the default: every surface defaults to http://localhost:3000 (the port apps/api actually listens on) and says so by name when it cannot reach it. The browser popup grows an "API server" row that shows and edits the URL. docker-compose.yml + apps/api/Dockerfile + SELFHOSTING.md bring up Postgres and the API together, schema auto-applied on first boot, so a stranger needs only a Gemini key. Both published ports bind to 127.0.0.1 deliberately: compose sets TRAILHEAD_AUTO_CREATE_TEAMS=true so `trailhead-mcp init`'s derived per-repo token is accepted, and that combination must not be reachable from the network. ## Also - license: MIT + repository on the last two manifests (all nine now). - contributes.viewsContainers used the "$(rocket)" codicon where VS Code requires a file path, which fails vsce package. Added a real SVG. - Dated roadmap/spec docs still cite the dead host; two contain copy-pasteable config, so all three now open with a note saying the host is gone. Co-Authored-By: Claude Opus 5 --- .dockerignore | 39 ++ .env.example | 102 +++-- SELFHOSTING.md | 180 +++++++++ apps/api/Dockerfile | 54 +++ apps/api/src/index.ts | 41 +- apps/browser-ext/manifest.json | 7 +- apps/browser-ext/scripts/smoke.sh | 28 +- apps/browser-ext/src/api-url-state.ts | 98 +++++ apps/browser-ext/src/api.ts | 35 +- apps/browser-ext/src/config.ts | 20 +- apps/browser-ext/src/context-bundle.ts | Bin 3880 -> 4234 bytes apps/browser-ext/src/popup/popup.html | 131 +++++++ apps/browser-ext/src/popup/popup.ts | 353 ++++++++++++++---- apps/dashboard/README.md | 37 +- apps/dashboard/src/app/page.tsx | 66 +++- apps/dashboard/src/lib/api.ts | 42 ++- apps/mcp-server/bin/cli.mjs | 7 +- apps/mcp-server/package.json | 66 ++-- apps/mcp-server/src/api-client.ts | 20 +- apps/mcp-server/src/api-url.d.mts | 11 + apps/mcp-server/src/api-url.mjs | 36 ++ apps/mcp-server/src/bootstrap-cli.ts | 6 +- apps/mcp-server/src/reset-cli.ts | 6 +- apps/mcp-server/src/smoke-test.mjs | 3 +- apps/mcp-server/src/verify-all-tools.mjs | 8 +- apps/vscode-ext/media/trailhead.svg | 10 + apps/vscode-ext/package.json | 154 ++++---- apps/vscode-ext/src/extension.ts | 57 ++- docker-compose.yml | 103 +++++ .../2026-04-25-roadmap-browser-extension.md | 5 + .../2026-04-25-roadmap-mcp-vscode-hook.md | 5 + ...2026-04-25-trailhead-browser-ext-design.md | 5 + packages/shared/types.ts | 16 +- 33 files changed, 1457 insertions(+), 294 deletions(-) create mode 100644 .dockerignore create mode 100644 SELFHOSTING.md create mode 100644 apps/api/Dockerfile create mode 100644 apps/browser-ext/src/api-url-state.ts create mode 100644 apps/mcp-server/src/api-url.d.mts create mode 100644 apps/mcp-server/src/api-url.mjs create mode 100644 apps/vscode-ext/media/trailhead.svg create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..99cc31b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Keep the build context small and reproducible. node_modules in particular is +# hundreds of MB and MUST NOT be copied — the image installs its own via +# `npm ci`, and a host copy would carry platform-specific binaries. +node_modules +**/node_modules + +# Build output — the API runs from source via tsx. +dist +**/dist +build +**/build +out +**/out +.next +**/.next +*.tsbuildinfo +apps/vscode-ext/*.vsix +apps/browser-ext/.plasmo + +# Secrets. GEMINI_API_KEY and friends are injected at runtime by compose, +# never baked into an image layer. +.env +.env.* +!.env.example + +# VCS / tooling / docs — irrelevant to the API image. +.git +.github +.claude +.superpowers +coverage +.cache +.turbo +.parcel-cache +archive +docs +*.log +Dockerfile +docker-compose.yml diff --git a/.env.example b/.env.example index e5be525..5330ac5 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,88 @@ -# Trailhead — root environment template. Copy to `.env` (which is gitignored). -# Every artifact (api, browser-ext, vscode-ext, mcp-server, dashboard) -# reads from this same set. Spec: docs/superpowers/specs/2026-04-25-trailhead-design.md - -# --- Postgres (Neon) ---------------------------------------------------------- -# 1. Sign in at https://console.neon.tech and create a project. -# 2. Copy the pooled connection string here (must include `sslmode=require`). -# 3. Apply schema: psql "$DATABASE_URL" -f packages/db/schema.sql -DATABASE_URL=postgresql://USER:PASSWORD@HOST.neon.tech/trailhead?sslmode=require - -# --- Hardcoded team token ----------------------------------------------------- -# Single shared secret. Every client sends this in the `X-Team-Token` header. -# Demo only — production swaps in Clerk per spec §3 / §16. -TEAM_TOKEN=trailhead_demo_acme_2026 +# Trailhead — root environment template. Copy to `.env` (which is gitignored): +# +# cp .env.example .env +# +# Then set GEMINI_API_KEY below and run `docker compose up`. Everything else +# here has a working default. See SELFHOSTING.md for the full walkthrough. +# +# Trailhead is self-hosted: there is no hosted API. Every client (browser-ext, +# vscode-ext, mcp-server, dashboard) defaults to http://localhost:3000, which +# is what `docker compose up` publishes. + +# ============================================================================= +# REQUIRED +# ============================================================================= -# --- Gemini ----------------------------------------------------------------- -# Required for /score (gemini-3-flash-preview, JSON schema mode) and /diff (gemini-3-flash-preview). -# Get one at https://aistudio.google.com/apikey. +# --- Gemini ------------------------------------------------------------------ +# The only value you must supply. Powers /score, /coach, /improve, /diff and +# the rich wiki bootstrap. The API refuses to start without it. +# Get one at https://aistudio.google.com/apikey GEMINI_API_KEY= +# ============================================================================= +# OPTIONAL — every value below has a working default +# ============================================================================= + +# --- Postgres ---------------------------------------------------------------- +# Leave DATABASE_URL unset to use the `postgres` service in docker-compose.yml. +# Compose builds the connection string from POSTGRES_USER / POSTGRES_PASSWORD / +# POSTGRES_DB and points the API at it automatically, and applies +# packages/db/schema.sql on the first boot of a fresh volume. +# +# Set it only to use an EXTERNAL database instead (Neon, RDS, ...). A hosted +# Postgres generally needs `?sslmode=require`. +# DATABASE_URL=postgresql://USER:PASSWORD@HOST/trailhead?sslmode=require +POSTGRES_USER=trailhead +POSTGRES_PASSWORD=trailhead +POSTGRES_DB=trailhead +# Host port for Postgres — change if 5432 is already in use. Used by psql and +# the scripts in packages/db (seed.mjs, migrate.mjs, check.mjs). +POSTGRES_PORT=5432 + +# --- API --------------------------------------------------------------------- +# Host port the API is published on. The container always listens on 3000; +# this is the host side of the mapping. Changing it means updating every +# client's API URL too (see SELFHOSTING.md). +PORT=3000 + +# Any unrecognised X-Team-Token spawns its own team row. The right default for +# a single-tenant self-host — teammates cloning the repo land in the same team +# with no admin step. Set to false to require teams be registered explicitly. +TRAILHEAD_AUTO_CREATE_TEAMS=true + +# Safety catch on DELETE /team/data for the seeded demo team. Set true only if +# you really want `trailhead-mcp reset` to be able to wipe it. +TRAILHEAD_ALLOW_DEMO_RESET=false + +# --- Team token -------------------------------------------------------------- +# The demo team's token, and the fallback the clients ship with. Real teams get +# a token derived from their git remote by `npx trailhead-mcp init` — this is +# only the demo/seed value. +TEAM_TOKEN=trailhead_demo_acme_2026 + # --- Langfuse (optional) ----------------------------------------------------- -# Hosted observability for every Gemini call made by the API. Keys live at -# https://cloud.langfuse.com → Settings → API Keys. When unset the API still -# works — tracing silently no-ops with a one-line warning at startup. +# Hosted observability for every Gemini call the API makes. Keys at +# https://cloud.langfuse.com -> Settings -> API Keys. When unset the API still +# works — tracing no-ops with a one-line warning at startup. LANGFUSE_PUBLIC_KEY= LANGFUSE_SECRET_KEY= # EU region (default). Use https://us.cloud.langfuse.com for the US region. LANGFUSE_BASEURL=https://cloud.langfuse.com -# --- API --------------------------------------------------------------------- -# Local dev only. Railway injects PORT automatically when deployed. -PORT=3000 +# ============================================================================= +# CLIENTS — only needed when running a client outside its default +# ============================================================================= + +# --- MCP server (apps/mcp-server) -------------------------------------------- +# Base URL of your API. `npx trailhead-mcp init` writes this into the generated +# MCP config. Unset -> the CLIs warn and fall back to http://localhost:3000. +# TRAILHEAD_API_URL=http://localhost:3000 +# Per-repo team token, normally auto-derived from the git remote. +# TRAILHEAD_TEAM_TOKEN= + +# --- Dashboard (apps/dashboard) ---------------------------------------------- +# Baked in at build time (NEXT_PUBLIC_*), so a deployed dashboard must set it +# before `next build`. Unset -> http://localhost:3000, and the Teams page says +# so explicitly rather than failing silently. +# NEXT_PUBLIC_API_URL=http://localhost:3000 +# NEXT_PUBLIC_TEAM_TOKEN=trailhead_demo_acme_2026 diff --git a/SELFHOSTING.md b/SELFHOSTING.md new file mode 100644 index 0000000..5d94bff --- /dev/null +++ b/SELFHOSTING.md @@ -0,0 +1,180 @@ +# Self-hosting Trailhead + +Trailhead is self-hosted. There is no hosted API and no account to sign up for — +you run the backend, and it is yours. The only external dependency is a Gemini +API key. + +Everything below assumes you are at the repo root. + +--- + +## Prerequisites + +| | | +|---|---| +| **Docker** | Docker Desktop, OrbStack, or Docker Engine with the Compose v2 plugin (`docker compose version`). | +| **A Gemini API key** | Free at . | +| **Node 22.6+** | Only needed to run the *clients* (browser extension, VS Code extension, MCP server, dashboard). The API itself runs entirely inside Docker. | + +--- + +## Start the API + +```bash +cp .env.example .env # then open .env and set GEMINI_API_KEY=... +docker compose up +``` + +That's it. On the first run Compose will: + +1. Start Postgres 16 on a named volume (`trailhead-pgdata`), so your data + survives restarts. +2. Apply `packages/db/schema.sql` automatically — Postgres runs anything in + `/docker-entrypoint-initdb.d` the first time it initialises a data + directory, so there is no manual `psql` step. +3. Wait for Postgres to pass `pg_isready`, then start the API. + +The API is then on ****. Check it: + +```bash +curl http://localhost:3000 +# {"name":"trailhead-api","status":"ok",...} +``` + +If `GEMINI_API_KEY` is missing, `docker compose up` stops immediately and tells +you so — it will not start a stack that cannot score a prompt. + +Run it in the background with `docker compose up -d`, and stop it with +`docker compose down`. + +### Configuration + +Every variable lives in `.env`, and every one except `GEMINI_API_KEY` has a +working default. See [`.env.example`](.env.example) for the annotated list. The +ones you are most likely to touch: + +| Variable | Default | Why you'd change it | +|---|---|---| +| `GEMINI_API_KEY` | *(required)* | — | +| `PORT` | `3000` | Something else already owns port 3000. Changing this means updating each client's API URL too. | +| `POSTGRES_PORT` | `5432` | You already run Postgres locally. | +| `DATABASE_URL` | *(the bundled Postgres)* | Use an external database (Neon, RDS) instead of the container. | +| `TRAILHEAD_AUTO_CREATE_TEAMS` | `true` | Set `false` to stop unknown team tokens from creating teams on the fly. | + +### Data management + +```bash +docker compose down # stop; data kept +docker compose down -v # stop and DELETE the database volume +docker compose logs -f api # follow API logs +docker compose up --build # rebuild the API image after changing its source +``` + +The schema is only applied to a *fresh* volume. `schema.sql` is idempotent, so +if you need to re-apply it to an existing database: + +```bash +docker compose exec -T postgres psql -U trailhead -d trailhead < packages/db/schema.sql +``` + +--- + +## Point the clients at it + +Every client defaults to `http://localhost:3000`, so if you kept the default +`PORT` there is nothing to configure. If you changed `PORT`, or the API runs on +another machine, substitute your URL below. + +### Browser extension + +```bash +npm install +npm run build --workspace=apps/browser-ext +``` + +Then in Chrome: `chrome://extensions` → enable **Developer mode** → **Load +unpacked** → select `apps/browser-ext/dist`. + +The extension popup has an **API server** row showing exactly which URL it is +talking to, with a live connection check. Edit it there and press **Save** — the +change takes effect immediately on any open Claude.ai tab, no reload needed. + +The manifest ships permission for `localhost` and `127.0.0.1`. Pointing the +extension at any other host triggers a one-time Chrome permission prompt when +you save. + +### VS Code extension + +Settings → search `trailhead.apiUrl` (or edit `settings.json`): + +```jsonc +{ + "trailhead.apiUrl": "http://localhost:3000" +} +``` + +If the API is unreachable, the extension raises a notification naming the +setting rather than showing an empty sidebar. + +### MCP server (Claude Code / Copilot) + +From the repo you want coached: + +```bash +npx trailhead-mcp init --api-url http://localhost:3000 +``` + +That writes `.mcp.json` (and `.vscode/mcp.json` for Copilot) with +`TRAILHEAD_API_URL` set, and derives a team token from your git remote so +teammates cloning the same repo land in the same team. You can also set +`TRAILHEAD_API_URL` in your environment instead. + +Then seed the wiki from the repo: + +```bash +npx trailhead-mcp bootstrap +``` + +### Dashboard + +```bash +npm run dev --workspace=apps/dashboard +``` + +Runs on and reads the API at `NEXT_PUBLIC_API_URL`, +defaulting to `http://localhost:3000`. To point elsewhere: + +```bash +NEXT_PUBLIC_API_URL=http://localhost:8080 npm run dev --workspace=apps/dashboard +``` + +`NEXT_PUBLIC_*` values are baked in at build time, so a **deployed** dashboard +must set `NEXT_PUBLIC_API_URL` before `next build`, and the API must be +reachable from the visitor's browser. When it isn't set, the Teams page says so +and names the variable. + +--- + +## Troubleshooting + +**`docker compose up` exits with "required variable GEMINI_API_KEY is missing"** +You skipped `cp .env.example .env`, or left the key blank. `.env` must be at the +repo root, next to `docker-compose.yml`. + +**API restarts in a loop / `DATABASE_URL not set`** +`DATABASE_URL` is set in your `.env` but points somewhere unreachable. Comment +it out to fall back to the bundled Postgres. + +**Port already in use** +Change `PORT` (API) or `POSTGRES_PORT` (Postgres) in `.env`, then re-run +`docker compose up`. Remember to update the clients if you changed `PORT`. + +**Clients show no data / "can't reach the API"** +Confirm `curl http://localhost:3000` answers. Each client names the exact +setting to fix in its own error message: the popup's **API server** row, +`trailhead.apiUrl`, `TRAILHEAD_API_URL`, or `NEXT_PUBLIC_API_URL`. + +**Schema changes didn't apply** +`docker-entrypoint-initdb.d` only runs on a fresh volume. Either +`docker compose down -v` (destroys data) or pipe `schema.sql` through `psql` as +shown above. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..15b1219 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,54 @@ +# Trailhead API image. +# +# Build context is the REPO ROOT (not apps/api) — this is an npm workspace +# monorepo, so the install needs the root package.json, the lockfile, and every +# workspace manifest before `npm ci` can reproduce the lockfile exactly. +# +# docker build -f apps/api/Dockerfile -t trailhead-api . +# +# The API runs TypeScript directly through tsx (see apps/api package.json +# "start"), so there is no compile step — but tsx is a devDependency, which is +# why the install must include dev deps. + +FROM node:22-alpine + +WORKDIR /app + +# --- Dependency layer ------------------------------------------------------- +# Manifests only, so this layer is cached until a package.json actually changes. +# `npm ci` refuses to run unless the lockfile matches the full workspace set, +# so every workspace manifest has to be present — even ones the API never +# imports. apps/landing-page has no package.json and is not a workspace. +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/ +COPY apps/browser-ext/package.json apps/browser-ext/ +COPY apps/dashboard/package.json apps/dashboard/ +COPY apps/mcp-server/package.json apps/mcp-server/ +COPY apps/vscode-ext/package.json apps/vscode-ext/ +COPY packages/score-card/package.json packages/score-card/ +COPY packages/scoring/package.json packages/scoring/ +COPY packages/shared/package.json packages/shared/ + +# --include=dev is explicit rather than implied: tsx is a devDependency and the +# runtime needs it. NODE_ENV is deliberately not set to production until after +# the install so npm doesn't silently prune it. +RUN npm ci --include=dev + +# --- Source layer ----------------------------------------------------------- +# Only what the API actually needs at runtime. packages/ carries the raw .ts +# that @trailhead/shared and @trailhead/scoring resolve to via the workspace +# symlinks npm ci created above. +COPY packages/ packages/ +COPY apps/api/ apps/api/ + +ENV NODE_ENV=production +# The container always listens on 3000; docker-compose.yml maps a host port to +# it. DATABASE_URL and GEMINI_API_KEY are injected by compose — the API throws +# at startup naming them if they are missing. +ENV PORT=3000 +EXPOSE 3000 + +# Run as the image's built-in non-root user. +USER node + +CMD ["npm", "--workspace=apps/api", "start"] diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3a25da1..14aa656 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -57,6 +57,7 @@ import { renderTeachBlock, } from '@trailhead/scoring'; import { applyTeamNameIfPlaceholder, DEMO_TEAM_TOKEN, q, ensureTeam, upsertNode, wipeTeamData } from './db.ts'; +import { createHash } from 'node:crypto'; import { degradedCoachResponse } from './coach-degraded.ts'; import { loadWikiTree } from './wiki-tree.ts'; import { exportFilename, renderWikiMarkdown } from './wiki-export.ts'; @@ -127,9 +128,11 @@ app.use('*', async (c, next) => { // existing clients carrying the old token continue to land on the demo team. app.use('*', async (c, next) => { if (c.req.method === 'OPTIONS' || c.req.path === '/') return next(); - // /teams is unauthenticated so the popup can populate a Select-team - // dropdown before any token is configured. - if (c.req.path === '/teams') return next(); + // /teams used to be exempt here so the popup could populate a Select-team + // dropdown before any token was configured. That exemption published every + // tenant's credential to the open internet. Clients now resolve their own + // team by sending the token they already hold; GET / remains the + // unauthenticated reachability probe. const token = c.req.header('x-team-token'); if (!token) return c.json({ error: 'unauthorized', detail: 'missing X-Team-Token' }, 401); const teamToken = await ensureTeam(token, { autoCreate: AUTO_CREATE_TEAMS }); @@ -143,6 +146,15 @@ app.use('*', async (c, next) => { await next(); }); +// A stable, opaque handle for a team that is safe to hand to a client. +// +// SHA-256 of the token, truncated to 16 hex chars. Not reversible, not +// replayable as an X-Team-Token, and stable across requests so it works as a +// React key or a client-side lookup handle. +function opaqueTeamId(token: string): string { + return createHash('sha256').update(token).digest('hex').slice(0, 16); +} + app.get('/', (c) => c.json({ name: 'trailhead-api', @@ -161,7 +173,7 @@ app.get('/', (c) => 'GET /wiki/recent?since=ISO', 'POST /diff', 'POST /improve', - 'GET /teams (unauthenticated)', + 'GET /teams (your team only; never returns tokens)', 'GET /skill-arc?user_id=&since=ISO', 'GET /team/metrics', 'GET /wiki/tree', @@ -1489,16 +1501,27 @@ app.get('/wiki/export', async (c) => { }); // ----- GET /teams ------------------------------------------------------------ -// Lists every team with a usable token. Unauthenticated (the popup needs to -// populate a Select-team dropdown before any token is configured). Demo -// simplicity: no per-user permission filter. +// Resolves the CALLER's team. Authenticated, and it never returns a token. +// +// This used to be unauthenticated and return every team on the server together +// with its token — with CORS `*`, so any web page could read it. The team token +// is the only credential in this system: it grants read on the wiki (which +// summarises private source code) and write on everything. A single unauth GET +// therefore compromised every tenant at once. The browser popup's convenience +// of pre-populating a team dropdown before any token was configured is what +// paid for that, and it is nowhere near worth the price. +// +// The response is a list of one so the TeamsListResponse shape (and every +// caller that maps over `teams`) keeps working. app.get('/teams', async (c) => { + const teamToken = c.get('team_token'); const rows = await q<{ name: string; token: string }>( - 'SELECT name, token FROM teams ORDER BY name ASC', + 'SELECT name, token FROM teams WHERE token = $1', + [teamToken], ); const teams: TeamSummary[] = rows.map((r) => ({ name: r.name, - token: r.token, + id: opaqueTeamId(r.token), })); const res: TeamsListResponse = { teams }; return c.json(res); diff --git a/apps/browser-ext/manifest.json b/apps/browser-ext/manifest.json index f50e39d..1a25c53 100644 --- a/apps/browser-ext/manifest.json +++ b/apps/browser-ext/manifest.json @@ -7,7 +7,12 @@ "permissions": ["storage"], "host_permissions": [ "https://claude.ai/*", - "https://trailheadapi-production.up.railway.app/*" + "http://localhost/*", + "http://127.0.0.1/*" + ], + "optional_host_permissions": [ + "http://*/*", + "https://*/*" ], "action": { "default_title": "LearnLoop", diff --git a/apps/browser-ext/scripts/smoke.sh b/apps/browser-ext/scripts/smoke.sh index bc515ad..3172b6e 100644 --- a/apps/browser-ext/scripts/smoke.sh +++ b/apps/browser-ext/scripts/smoke.sh @@ -1,21 +1,41 @@ #!/usr/bin/env bash # Live API smoke test (spec §7.4). Hand-runnable: covers /score, /capture, -# /diff, /wiki/recent against the deployed Railway endpoint with the demo -# team token. Pass --local to point at http://localhost:3000. +# /diff, /wiki/recent against a self-hosted Trailhead API with the demo team +# token. +# +# Trailhead has no hosted API — bring one up with `docker compose up` from the +# repo root (see SELFHOSTING.md). Set TRAILHEAD_API_URL to point elsewhere. set -euo pipefail -API_URL="${TRAILHEAD_API_URL:-https://trailheadapi-production.up.railway.app}" +DEFAULT_API_URL="http://localhost:3000" +API_URL="${TRAILHEAD_API_URL:-}" TOKEN="${TRAILHEAD_TEAM_TOKEN:-trailhead_demo_acme_2026}" if [[ "${1:-}" == "--local" ]]; then - API_URL="http://localhost:3000" + API_URL="$DEFAULT_API_URL" +fi + +if [[ -z "$API_URL" ]]; then + API_URL="$DEFAULT_API_URL" + echo "! TRAILHEAD_API_URL not set — defaulting to ${API_URL}" >&2 + echo " Start a self-hosted API with \`docker compose up\` from the repo root," >&2 + echo " or export TRAILHEAD_API_URL=." >&2 + echo >&2 fi if ! command -v curl >/dev/null 2>&1; then echo "smoke.sh requires curl" >&2 exit 1 fi + +# Fail loudly and early rather than emitting six confusing curl errors. +if ! curl -sSf -o /dev/null --max-time 5 "${API_URL}/teams" 2>/dev/null; then + echo "! Cannot reach a Trailhead API at ${API_URL}" >&2 + echo " Start one with \`docker compose up\` (see SELFHOSTING.md), or set" >&2 + echo " TRAILHEAD_API_URL to the base URL of your server." >&2 + exit 1 +fi JQ="cat" if command -v jq >/dev/null 2>&1; then JQ="jq ." diff --git a/apps/browser-ext/src/api-url-state.ts b/apps/browser-ext/src/api-url-state.ts new file mode 100644 index 0000000..3eea26f --- /dev/null +++ b/apps/browser-ext/src/api-url-state.ts @@ -0,0 +1,98 @@ +// Cached, synchronously-readable view of the popup-controlled API base URL. +// +// Mirrors team-state.ts: seeded from chrome.storage.local at init and kept +// live via chrome.storage.onChanged, so editing the URL in the popup takes +// effect on an already-open Claude.ai tab without a reload. +// +// Storage key: 'trailhead.apiUrl' — string. Falls back to DEFAULT_API_URL +// (a self-hosted API on this machine) when nothing is stored. Trailhead has +// no hosted API: see SELFHOSTING.md at the repo root. + +import { API_URL_KEY, DEFAULT_API_URL, TRAILHEAD_ERROR_TAG } from './config.ts'; + +let currentUrl = DEFAULT_API_URL; +const subscribers = new Set<() => void>(); + +/** Trim whitespace and any trailing slash so callers can append '/score' + * without producing a double slash. Returns '' for unusable input, which + * callers treat as "not configured". */ +export function normalizeApiUrl(raw: unknown): string { + if (typeof raw !== 'string') return ''; + const trimmed = raw.trim().replace(/\/+$/, ''); + if (!trimmed) return ''; + try { + const u = new URL(trimmed); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; + } catch { + return ''; + } + return trimmed; +} + +/** The API base URL every request should use, with no trailing slash. */ +export function getApiUrl(): string { + return currentUrl; +} + +/** True when the user has never edited the URL — i.e. we're on the built-in + * localhost default. Used to tailor the "can't reach the API" hint. */ +export function isDefaultApiUrl(): boolean { + return currentUrl === DEFAULT_API_URL; +} + +/** One actionable sentence naming exactly what the user must do. Logged on + * every network-level failure so a dead/unconfigured API is never silent. */ +export function apiUnreachableHint(): string { + return isDefaultApiUrl() + ? `${TRAILHEAD_ERROR_TAG} cannot reach the Trailhead API at ${currentUrl}. ` + + `Trailhead is self-hosted — start it with \`docker compose up\` (see SELFHOSTING.md), ` + + `or open the extension popup and set "API server" to your API's URL.` + : `${TRAILHEAD_ERROR_TAG} cannot reach the Trailhead API at ${currentUrl}. ` + + `Check that the server is running, or correct "API server" in the extension popup.`; +} + +export function subscribeApiUrl(cb: () => void): () => void { + subscribers.add(cb); + return () => { + subscribers.delete(cb); + }; +} + +function notify(): void { + for (const cb of subscribers) { + try { + cb(); + } catch { + /* swallow — one bad subscriber can't break the others */ + } + } +} + +export function initApiUrlState(): void { + try { + const get = (chrome as any)?.storage?.local?.get; + if (typeof get !== 'function') return; + get.call((chrome as any).storage.local, API_URL_KEY, (out: Record) => { + const stored = normalizeApiUrl(out?.[API_URL_KEY]); + if (stored) { + currentUrl = stored; + console.info(`${TRAILHEAD_ERROR_TAG} API URL loaded from storage: ${currentUrl}`); + } else { + console.info(`${TRAILHEAD_ERROR_TAG} API URL not configured — using default ${currentUrl}`); + } + }); + const onChanged = (chrome as any)?.storage?.onChanged?.addListener; + if (typeof onChanged !== 'function') return; + onChanged.call( + (chrome as any).storage.onChanged, + (changes: Record, area: string) => { + if (area !== 'local' || !(API_URL_KEY in changes)) return; + currentUrl = normalizeApiUrl(changes[API_URL_KEY]?.newValue) || DEFAULT_API_URL; + console.info(`${TRAILHEAD_ERROR_TAG} API URL changed → ${currentUrl}`); + notify(); + }, + ); + } catch { + // chrome.* unavailable — leave the default in place. + } +} diff --git a/apps/browser-ext/src/api.ts b/apps/browser-ext/src/api.ts index cd704c7..bd428a3 100644 --- a/apps/browser-ext/src/api.ts +++ b/apps/browser-ext/src/api.ts @@ -20,7 +20,8 @@ import type { ScoreResponse, WikiRecentResponse, } from '@trailhead/shared'; -import { API_URL, FETCH_TIMEOUT_MS, TRAILHEAD_ERROR_TAG } from './config.ts'; +import { FETCH_TIMEOUT_MS, TRAILHEAD_ERROR_TAG } from './config.ts'; +import { apiUnreachableHint, getApiUrl } from './api-url-state.ts'; import { getTeamToken } from './team-state.ts'; import { getContextPath } from './context-state.ts'; @@ -44,6 +45,20 @@ function withTimeout(ac: AbortController, ms: number): () => void { return () => clearTimeout(id); } +// Failure logging. A self-hosted API that isn't running (or is configured to +// the wrong host) fails at the network layer, which fetch reports as TypeError +// — distinct from a 4xx/5xx, which resolves normally and returns null. Those +// get the actionable "here is what to fix" message rather than an opaque +// stack, so an unconfigured API is never a silent no-op. +function logFailure(path: string, err: unknown): void { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (err instanceof TypeError) { + console.warn(`${apiUnreachableHint()} (request: ${path})`, err); + return; + } + console.warn(`${TRAILHEAD_ERROR_TAG} ${path} failed`, err); +} + function headers(): Record { return { 'Content-Type': 'application/json', @@ -59,7 +74,7 @@ async function call( const ac = abortPrev(key); const stop = withTimeout(ac, FETCH_TIMEOUT_MS); try { - const res = await fetch(`${API_URL}${path}`, { + const res = await fetch(`${getApiUrl()}${path}`, { method: init.method, headers: headers(), body: init.body !== undefined ? JSON.stringify(init.body) : undefined, @@ -70,9 +85,7 @@ async function call( } catch (err) { // Aborts and network errors share a single failure path. We log so the // demonstrator can `console.warn` to debug; we never re-throw. - if (!(err instanceof DOMException && err.name === 'AbortError')) { - console.warn(`${TRAILHEAD_ERROR_TAG} ${path} failed`, err); - } + logFailure(path, err); return null; } finally { stop(); @@ -113,7 +126,7 @@ export async function coach(body: CoachRequest): Promise { const ac = new AbortController(); const stop = setTimeout(() => ac.abort(), 25_000); try { - const res = await fetch(`${API_URL}/coach`, { + const res = await fetch(`${getApiUrl()}/coach`, { method: 'POST', headers: headers(), body: JSON.stringify(enriched), @@ -122,9 +135,7 @@ export async function coach(body: CoachRequest): Promise { if (!res.ok) return null; return (await res.json()) as CoachResponse; } catch (err) { - if (!(err instanceof DOMException && err.name === 'AbortError')) { - console.warn(`${TRAILHEAD_ERROR_TAG} /coach failed`, err); - } + logFailure('/coach', err); return null; } finally { clearTimeout(stop); @@ -140,7 +151,7 @@ export async function improve(body: ImproveRequest): Promise ac.abort(), 25_000); try { - const res = await fetch(`${API_URL}/improve`, { + const res = await fetch(`${getApiUrl()}/improve`, { method: 'POST', headers: headers(), body: JSON.stringify(enriched), @@ -149,9 +160,7 @@ export async function improve(body: ImproveRequest): Promise`V?Tl0hQ$K2M;Qxw4f&Z`WMh#WzRgrIX!V3A8lqtK`d2@<7E zA<@_K8*qw{qe;Tl<&1YQI6V<$9wtwG)-h0Ft+qoDrPwkj8>Oju0+-|@`k{M$I8zni zql(#75-RRZMLGc=NpLIkZHzAYo7r^Q>xciZ;0HDMgbxMgybn>SM>=9c3~st0s^DO% at^rMs%~E@}xmsLqTAj5PKs{dVta#rk!FCh? delta 51 zcmeBDTp_n1jETcBz%xEH$VX>#D3i(NvrHmv>`;--Z#a}#Cf9MRPWI!|*j&fQ!vp}D Cat}EG diff --git a/apps/browser-ext/src/popup/popup.html b/apps/browser-ext/src/popup/popup.html index bc546ed..dd12d75 100644 --- a/apps/browser-ext/src/popup/popup.html +++ b/apps/browser-ext/src/popup/popup.html @@ -316,6 +316,58 @@ transition: background 120ms var(--ease); } .team-dropdown li:hover { background: rgba(255,255,255,0.08); } + + /* Team-token editor. Replaced the pick-a-team list, which was built from + an unauthenticated /teams that handed out every tenant's token. */ + .team-dropdown li.team-editor, + .team-dropdown li.team-editor:hover { + display: block; + background: none; + cursor: default; + padding: 10px 12px; + } + .team-editor label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.6; + margin-bottom: 4px; + } + .team-editor input { + width: 100%; + box-sizing: border-box; + font: inherit; + font-size: 12px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.18); + background: rgba(0,0,0,0.25); + color: inherit; + } + .team-editor input:focus-visible { + outline: none; + border-color: rgba(255,255,255,0.45); + } + .team-editor button { + margin-top: 8px; + width: 100%; + font: inherit; + font-size: 12px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.18); + background: rgba(255,255,255,0.08); + color: inherit; + cursor: pointer; + } + .team-editor button:hover { background: rgba(255,255,255,0.14); } + .team-editor .team-hint { + margin: 8px 0 0; + font-size: 11px; + line-height: 1.4; + opacity: 0.55; + } .team-dropdown li.is-current { background: var(--accent-bg); color: var(--text); @@ -374,6 +426,65 @@ } .team-dropdown::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.20); } + /* ===== API server row ===== */ + .api-input-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + align-items: stretch; + } + .api-input { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; + color: var(--text); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; + min-width: 0; + width: 100%; + transition: border-color 150ms var(--ease), background 150ms var(--ease); + } + .api-input::placeholder { color: var(--text-faint); } + .api-input:hover { border-color: var(--border-strong); } + .api-input:focus { + outline: none; + background: var(--surface-2); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); + } + .api-save-btn { + padding: 0 12px; + font-size: 11.5px; + font-weight: 500; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + transition: background 120ms var(--ease), border-color 120ms var(--ease); + white-space: nowrap; + } + .api-save-btn:hover:not(:disabled) { + background: var(--accent-bg); + border-color: var(--accent-border); + } + .api-save-btn:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); + } + .api-status { + font-size: 10.5px; + line-height: 1.4; + color: var(--text-muted); + padding: 0 2px; + display: flex; + align-items: baseline; + gap: 5px; + } + .api-status.is-error { color: var(--bad); } + .api-status.is-ok { color: var(--good); } + /* ===== Footer hint ===== */ .hint { font-size: 11.5px; @@ -490,6 +601,26 @@ + +
+ +
+ + +
+
+
+
When off, the extension stops intercepting sends.
diff --git a/apps/browser-ext/src/popup/popup.ts b/apps/browser-ext/src/popup/popup.ts index 7e4256a..22c37e4 100644 --- a/apps/browser-ext/src/popup/popup.ts +++ b/apps/browser-ext/src/popup/popup.ts @@ -1,22 +1,35 @@ -// Popup script. Surfaces three controls: +// Popup script. Surfaces four controls: // - Coaching on/off switch +// - API server — the base URL of the self-hosted Trailhead API. Trailhead +// ships no hosted backend, so this is required config; it defaults to +// http://localhost:3000 (what `docker compose up` publishes) and is +// persisted to chrome.storage.local.. The content script +// watches that key, so a change lands on open tabs without a reload. // - Select context — fetches GET /wiki/tree and lets the user pick a // subtree root; persisted to chrome.storage.local. // so the content script prepends the rendered subtree to every send. -// - Select team — fetches GET /teams and persists the chosen team's -// X-Team-Token to chrome.storage.local.. +// - Select team — takes the team token and persists it to +// chrome.storage.local., then resolves the team's display +// name via the authenticated GET /teams. // -// Both pickers cache their first fetch in popup memory so re-opening the +// That last control used to be a dropdown listing every team on the server, +// built from an unauthenticated GET /teams that returned each team's token. +// Clicking a row adopted another tenant's credential, and merely opening the +// popup fetched all of them. The endpoint now authenticates and returns only +// the caller's own team, without a token, so switching teams means supplying +// the token for a team you are actually entitled to. +// +// The context picker caches its first fetch in popup memory so re-opening the // dropdown is instant. A team change implicitly invalidates the wiki tree // (different team → different nodes), so cachedTree is dropped on token // change. -import { API_URL } from '../config.ts'; +import { API_URL_KEY, DEFAULT_API_URL } from '../config.ts'; +import { normalizeApiUrl } from '../api-url-state.ts'; import { TEAM_TOKEN_KEY, TEAM_NAME_KEY } from '../team-state.ts'; import { CONTEXT_PATH_KEY } from '../context-state.ts'; import { TEAM_TOKEN as DEFAULT_TEAM_TOKEN } from '../config.ts'; import type { - TeamSummary, TeamsListResponse, WikiTreeNode, WikiTreeResponse, @@ -37,8 +50,15 @@ const contextStatusEl = document.getElementById('context-status') as HTMLDivElem const contextTreeEl = document.getElementById('context-tree') as HTMLUListElement; const hintEl = document.getElementById('coaching-hint') as HTMLDivElement; const toastEl = document.getElementById('toast') as HTMLDivElement; +const apiUrlInputEl = document.getElementById('api-url-input') as HTMLInputElement; +const apiUrlSaveEl = document.getElementById('api-url-save') as HTMLButtonElement; +const apiUrlStatusEl = document.getElementById('api-url-status') as HTMLDivElement; + +// Resolved API base URL for this popup session (no trailing slash). Seeded +// from storage when the popup opens; the Save button rewrites both this and +// storage. Every fetch below reads it rather than a build-time constant. +let apiUrl = DEFAULT_API_URL; -let cachedTeams: TeamSummary[] | null = null; let cachedTree: WikiTreeNode[] | null = null; // Tree cache is keyed by the team token under which it was fetched. A team // switch must drop the tree (different wiki) — we compare against this on @@ -86,6 +106,114 @@ async function setStoredTeamName(name: string): Promise { }); } +async function getStoredTeamName(): Promise { + return new Promise((resolve) => { + (chrome as any).storage.local.get(TEAM_NAME_KEY, (v: Record) => { + const stored = v[TEAM_NAME_KEY]; + resolve(typeof stored === 'string' && stored ? stored : null); + }); + }); +} + +// ----- API server ------------------------------------------------------------ + +async function getStoredApiUrl(): Promise { + return new Promise((resolve) => { + (chrome as any).storage.local.get(API_URL_KEY, (v: Record) => { + resolve(normalizeApiUrl(v?.[API_URL_KEY])); + }); + }); +} + +async function setStoredApiUrl(url: string): Promise { + return new Promise((resolve) => { + (chrome as any).storage.local.set({ [API_URL_KEY]: url }, () => resolve()); + }); +} + +function setApiStatus(text: string, kind: 'neutral' | 'ok' | 'error' = 'neutral'): void { + apiUrlStatusEl.textContent = text; + apiUrlStatusEl.classList.toggle('is-error', kind === 'error'); + apiUrlStatusEl.classList.toggle('is-ok', kind === 'ok'); +} + +// The manifest ships host_permissions for localhost / 127.0.0.1 only. Pointing +// the extension at a remote self-hosted API needs that origin granted, which +// MV3 exposes through optional_host_permissions. Requested from the Save click +// because chrome.permissions.request requires a user gesture. Returns true when +// we either hold the permission or can't tell — we let the fetch be the judge +// rather than blocking the user on a guess. +async function ensureHostPermission(url: string): Promise { + try { + const perms = (chrome as any)?.permissions; + if (!perms?.request || !perms?.contains) return true; + const origin = `${new URL(url).origin}/*`; + const has = await new Promise((resolve) => { + perms.contains({ origins: [origin] }, (r: boolean) => resolve(Boolean(r))); + }); + if (has) return true; + return await new Promise((resolve) => { + perms.request({ origins: [origin] }, (granted: boolean) => resolve(Boolean(granted))); + }); + } catch { + return true; + } +} + +// Cheap reachability probe against a no-auth endpoint. Turns "nothing works and +// I don't know why" into a one-line diagnosis naming the fix. +async function probeApi(): Promise { + setApiStatus(`Checking ${apiUrl}…`); + const ac = new AbortController(); + const timer = window.setTimeout(() => ac.abort(), 4000); + try { + // GET / is the API's unauthenticated status endpoint. (This used to probe + // /teams, which only worked because /teams required no auth — the very + // thing that leaked every tenant's token.) + const res = await fetch(`${apiUrl}/`, { signal: ac.signal }); + if (!res.ok) { + setApiStatus(`${apiUrl} responded HTTP ${res.status}.`, 'error'); + return; + } + setApiStatus(`Connected to ${apiUrl}`, 'ok'); + } catch { + setApiStatus( + apiUrl === DEFAULT_API_URL + ? `No API at ${apiUrl}. Trailhead is self-hosted — run \`docker compose up\` (see SELFHOSTING.md), or enter your server's URL above.` + : `Can't reach ${apiUrl}. Check the server is running, or correct the URL above.`, + 'error', + ); + } finally { + window.clearTimeout(timer); + } +} + +async function saveApiUrl(): Promise { + const next = normalizeApiUrl(apiUrlInputEl.value); + if (!next) { + setApiStatus('Enter a full base URL, e.g. http://localhost:3000', 'error'); + return; + } + apiUrlSaveEl.disabled = true; + try { + const granted = await ensureHostPermission(next); + if (!granted) { + setApiStatus(`Permission for ${next} denied — the extension can't call it.`, 'error'); + return; + } + await setStoredApiUrl(next); + apiUrl = next; + apiUrlInputEl.value = next; + // A different server means a different team and a different wiki. + cachedTree = null; + cachedTreeForToken = null; + showToast('API server saved'); + await probeApi(); + } finally { + apiUrlSaveEl.disabled = false; + } +} + async function getStoredContextPath(): Promise { return new Promise((resolve) => { (chrome as any).storage.local.get(CONTEXT_PATH_KEY, (v: Record) => { @@ -107,15 +235,37 @@ async function clearStoredContextPath(): Promise { }); } +/** + * Ask the API which team the stored token belongs to. + * + * GET /teams is authenticated and returns only the caller's own team. It used + * to be unauthenticated and return every team on the server *with its token*, + * which is what let this popup show a pick-a-team list — and also handed every + * tenant's credential to anyone who asked. Resolving your own team from the + * token you already hold is the same convenience without the giveaway. + */ +async function resolveTeamName(token: string): Promise { + try { + const res = await fetch(`${apiUrl}/teams`, { headers: { 'X-Team-Token': token } }); + if (!res.ok) return null; + const data = (await res.json()) as TeamsListResponse; + return data.teams?.[0]?.name ?? null; + } catch { + return null; + } +} + async function refreshCurrentTeamName(): Promise { const token = await getStoredToken(); - const team = cachedTeams?.find((t) => t.token === token); - // Backfill the cached display name whenever the popup discovers it via - // /teams — handles users who picked a team before this feature existed. - if (team) await setStoredTeamName(team.name); - currentTeamNameEl.textContent = team - ? team.name - : token === DEFAULT_TEAM_TOKEN ? 'Acme (default)' : token.slice(0, 16) + '…'; + const cached = await getStoredTeamName(); + currentTeamNameEl.textContent = + cached ?? (token === DEFAULT_TEAM_TOKEN ? 'Acme (default)' : token.slice(0, 16) + '…'); + + const name = await resolveTeamName(token); + if (name) { + await setStoredTeamName(name); + currentTeamNameEl.textContent = name; + } } async function refreshCurrentContextName(): Promise { @@ -123,52 +273,94 @@ async function refreshCurrentContextName(): Promise { currentContextNameEl.textContent = displayPath(path); } -function renderTeamList(teams: TeamSummary[], currentToken: string): void { - teamListEl.replaceChildren(); - for (const team of teams) { - const li = document.createElement('li'); - if (team.token === currentToken) { - li.classList.add('is-current'); - const check = document.createElement('span'); - check.className = 'check'; - check.textContent = '✓'; - li.appendChild(check); +/** + * Team switcher. + * + * This was a list of every team on the server, each row carrying that team's + * token, populated from an unauthenticated GET /teams. Clicking a row adopted + * someone else's credential. The endpoint no longer discloses tokens, so + * switching teams means entering the token for the team you are entitled to — + * which is what "switching teams" should always have meant. + */ +async function applyTeamToken(next: string): Promise { + const token = next.trim(); + if (!token) { + showTeamError('Enter a team token.'); + return; + } + const current = await getStoredToken(); + teamStatusEl.hidden = false; + teamStatusEl.classList.remove('is-error'); + teamStatusEl.textContent = 'Checking token…'; + + const name = await resolveTeamName(token); + if (!name) { + showTeamError(`${apiUrl} rejected that token, or is unreachable.`); + return; + } + + await setStoredToken(token); + // Persist the display name alongside the token so the in-page pill can show + // "Acme Fintech · Root" instead of just "Root". + await setStoredTeamName(name); + // A different team invalidates the wiki tree cache and any active context + // (the path may not exist for the new team). + if (token !== current) { + cachedTree = null; + cachedTreeForToken = null; + const oldPath = await getStoredContextPath(); + if (oldPath) { + await clearStoredContextPath(); + await refreshCurrentContextName(); } - const name = document.createElement('span'); - name.textContent = team.name; - li.appendChild(name); - li.addEventListener('click', async () => { - await setStoredToken(team.token); - // Persist the team's display name alongside the token so the - // in-page pill can show "Acme Fintech · Root" instead of just "Root". - await setStoredTeamName(team.name); - // Picking a different team invalidates the wiki tree cache and - // any active context (the path may not exist for the new team). - if (cachedTreeForToken !== team.token) { - cachedTree = null; - cachedTreeForToken = null; - } - const oldPath = await getStoredContextPath(); - if (oldPath) { - await clearStoredContextPath(); - await refreshCurrentContextName(); - } - cachedTeams && renderTeamList(cachedTeams, team.token); - await refreshCurrentTeamName(); - closeTeamDropdown(); - showToast(`Switched to ${team.name}`); - }); - teamListEl.appendChild(li); } - teamListEl.hidden = false; - teamStatusEl.hidden = true; + await refreshCurrentTeamName(); + closeTeamDropdown(); + showToast(`Switched to ${name}`); } -function showTeamLoading(): void { - teamStatusEl.textContent = 'Loading teams…'; - teamStatusEl.classList.remove('is-error'); - teamStatusEl.hidden = false; - teamListEl.hidden = true; +async function renderTeamEditor(): Promise { + teamListEl.replaceChildren(); + + const li = document.createElement('li'); + li.className = 'team-editor'; + + const label = document.createElement('label'); + label.textContent = 'Team token'; + label.htmlFor = 'team-token-input'; + li.appendChild(label); + + const input = document.createElement('input'); + input.id = 'team-token-input'; + input.type = 'text'; + input.spellcheck = false; + input.autocomplete = 'off'; + input.placeholder = 'e.g. repo_9d01… or trailhead_demo_acme_2026'; + input.value = await getStoredToken(); + li.appendChild(input); + + const save = document.createElement('button'); + save.type = 'button'; + save.textContent = 'Use this team'; + save.addEventListener('click', () => void applyTeamToken(input.value)); + li.appendChild(save); + + input.addEventListener('keydown', (e) => { + if ((e as KeyboardEvent).key === 'Enter') { + e.preventDefault(); + void applyTeamToken(input.value); + } + }); + + const hint = document.createElement('p'); + hint.className = 'team-hint'; + hint.textContent = + 'Your token is your team’s credential. `trailhead-mcp init` derives one per repo and writes it into your MCP config.'; + li.appendChild(hint); + + teamListEl.appendChild(li); + teamListEl.hidden = false; + teamStatusEl.hidden = true; } function showTeamError(msg: string): void { @@ -186,25 +378,7 @@ function closeTeamDropdown(): void { async function openTeamDropdown(): Promise { teamDropdownEl.hidden = false; selectTeamBtn.setAttribute('aria-expanded', 'true'); - if (cachedTeams) { - renderTeamList(cachedTeams, await getStoredToken()); - return; - } - showTeamLoading(); - try { - const res = await fetch(`${API_URL}/teams`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = (await res.json()) as TeamsListResponse; - if (!Array.isArray(data.teams) || data.teams.length === 0) { - showTeamError('No teams returned by the API.'); - return; - } - cachedTeams = data.teams; - renderTeamList(cachedTeams, await getStoredToken()); - } catch (err) { - console.warn('[trailhead-popup] /teams fetch failed', err); - showTeamError('Couldn’t load teams. Check the API.'); - } + await renderTeamEditor(); } // ----- Wiki context picker --------------------------------------------------- @@ -358,7 +532,7 @@ async function openContextDropdown(): Promise { } showContextLoading(); try { - const res = await fetch(`${API_URL}/wiki/tree`, { + const res = await fetch(`${apiUrl}/wiki/tree`, { headers: { 'X-Team-Token': token }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -372,7 +546,8 @@ async function openContextDropdown(): Promise { renderContextTree(cachedTree, await getStoredContextPath()); } catch (err) { console.warn('[trailhead-popup] /wiki/tree fetch failed', err); - showContextError('Couldn’t load wiki. Check the API.'); + showContextError(`Couldn’t reach ${apiUrl}. Check the API server below.`); + void probeApi(); } } @@ -391,12 +566,36 @@ function showToast(text: string, ms = 1600): void { } catch { render(true); } + // Resolve the API server first — the team/context fetches below depend on + // it, and the user must be able to SEE which server they're pointed at. + try { + const stored = await getStoredApiUrl(); + apiUrl = stored || DEFAULT_API_URL; + apiUrlInputEl.value = apiUrl; + if (!stored) { + setApiStatus(`Using the default ${DEFAULT_API_URL} — no server configured yet.`); + } + } catch { + apiUrlInputEl.value = apiUrl; + } + void probeApi(); // Show the current team + context in the button rows even before the // user opens either dropdown. await refreshCurrentTeamName(); await refreshCurrentContextName(); })(); +apiUrlSaveEl.addEventListener('click', () => { + void saveApiUrl(); +}); + +apiUrlInputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void saveApiUrl(); + } +}); + switchEl.addEventListener('click', async () => { try { const out = await new Promise>((resolve) => { diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index a9b9ccc..3a8505e 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -21,22 +21,28 @@ companion `2026-04-25-demo-completion-design.md` (A — full dashboard). From the repo root: +Trailhead is self-hosted: there is no hosted API. Bring one up first — +`docker compose up` from the repo root (see [`SELFHOSTING.md`](../../SELFHOSTING.md)) +— then start the dashboard: + ```bash -NEXT_PUBLIC_API_URL=https://trailheadapi-production.up.railway.app \ -NEXT_PUBLIC_TEAM_TOKEN=trailhead_demo_acme_2026 \ - npm --workspace=apps/dashboard run dev +npm --workspace=apps/dashboard run dev ``` Server runs on **http://localhost:3001** (port 3000 is the API). -To point at a local API instead: +`NEXT_PUBLIC_API_URL` defaults to `http://localhost:3000`, which is what +`docker compose up` publishes. To point at a different server: ```bash -NEXT_PUBLIC_API_URL=http://localhost:3000 \ +NEXT_PUBLIC_API_URL=https://trailhead.internal.example.com \ NEXT_PUBLIC_TEAM_TOKEN=trailhead_demo_acme_2026 \ npm --workspace=apps/dashboard run dev ``` +If the API is unreachable, the Teams page says so and names +`NEXT_PUBLIC_API_URL` explicitly rather than showing an empty list. + ## Build ```bash @@ -44,10 +50,15 @@ npm --workspace=apps/dashboard run build # ~5s, static export npm --workspace=apps/dashboard run typecheck # tsc --noEmit ``` -There are five routes: `/`, `/onboarding`, `/skill-arc`, `/team` and `/wiki`. -Four prerender as static (`○` in the build output) and hydrate on the client -with SWR driving the live data. `/` is `export const dynamic = 'force-dynamic'` -(`ƒ` in the build output) because it fetches the team list per request. +There are five app routes: `/`, `/onboarding`, `/skill-arc`, `/team` and +`/wiki`. All five are server-rendered on demand (`ƒ` in the build output) — +`/` because it declares `export const dynamic = 'force-dynamic'`, the other +four because they read `searchParams` (`?team=`), which opts a route out of +prerendering in Next 15. The only statically prerendered route is the +framework's own `/_not-found`, which is why the build reports 7 pages. + +SWR still drives the live data on the client; "dynamic" here means the initial +HTML is rendered per request, not that the data is fetched at build time. ## Deploy to Vercel @@ -55,8 +66,12 @@ One-time setup: 1. `npm i -g vercel` (or `npx vercel` per command) 2. From `apps/dashboard/`: `vercel link` — picks a project, writes `.vercel/` -3. Set env vars in the Vercel dashboard (or `vercel env add`): - - `NEXT_PUBLIC_API_URL=https://trailheadapi-production.up.railway.app` +3. Set env vars in the Vercel dashboard (or `vercel env add`). A deployed + dashboard **must** set `NEXT_PUBLIC_API_URL` — the `http://localhost:3000` + default only makes sense on a developer's machine, and a Vercel deployment + left unset will fail every request from the visitor's browser: + - `NEXT_PUBLIC_API_URL=https://` (must be + publicly reachable from the browser, and serve CORS for the dashboard origin) - `NEXT_PUBLIC_TEAM_TOKEN=trailhead_demo_acme_2026` Deploy: diff --git a/apps/dashboard/src/app/page.tsx b/apps/dashboard/src/app/page.tsx index 3711171..1a1eaf5 100644 --- a/apps/dashboard/src/app/page.tsx +++ b/apps/dashboard/src/app/page.tsx @@ -1,11 +1,22 @@ -// Team picker — server component. Fetches /teams at request time so -// every team auto-created via /onboard/repo or wiki_bootstrap shows up -// without a code change. Each card links into the team-aware detail -// pages via `?team=`. +// Team view — server component. +// +// This was a picker over every team on the server, built from an +// unauthenticated GET /teams that returned each team's token. That endpoint +// published every tenant's only credential, so it now authenticates and +// returns just the caller's team, without the token. +// +// The dashboard therefore shows the team its own NEXT_PUBLIC_TEAM_TOKEN +// resolves to. To view a different team, configure that team's token — which +// is the point: viewing a team's wiki should require holding its credential. import Link from 'next/link'; import type { TeamsListResponse, TeamSummary } from '@trailhead/shared'; -import { RESOLVED_API_URL } from '@/lib/api'; +import { + apiConfigHint, + DEFAULT_TEAM_TOKEN, + IS_API_URL_CONFIGURED, + RESOLVED_API_URL, +} from '@/lib/api'; export const dynamic = 'force-dynamic'; @@ -13,12 +24,14 @@ const DEMO_TOKEN = 'trailhead_demo_acme_2026'; const DEMO_DESCRIPTION = "Backend services in Postgres + Hono, webhooks via signed callbacks, PCI-scoped audit logging. Coaching seeded from the team's actual repo conventions."; -function describe(team: TeamSummary): string { - if (team.token === DEMO_TOKEN) return DEMO_DESCRIPTION; - if (team.token.startsWith('repo_local_')) { +// Described from the token this dashboard is configured with, since the API +// no longer discloses tokens. +function describe(token: string): string { + if (token === DEMO_TOKEN) return DEMO_DESCRIPTION; + if (token.startsWith('repo_local_')) { return 'Local-only repo (no git remote). Token persisted in .trailhead-team, gitignored. Wiki and skill arc are isolated to this machine.'; } - if (team.token.startsWith('repo_')) { + if (token.startsWith('repo_')) { return 'Repo-derived team (token = SHA-256 of git remote). Teammates cloning the same repo land in the same team automatically.'; } return 'Custom team token. Wiki, skill arc, and metrics are scoped to this token only.'; @@ -30,7 +43,11 @@ type LoadResult = async function loadTeams(): Promise { try { - const res = await fetch(`${RESOLVED_API_URL}/teams`, { cache: 'no-store' }); + // /teams is authenticated now — it resolves the caller's own team. + const res = await fetch(`${RESOLVED_API_URL}/teams`, { + cache: 'no-store', + headers: { 'X-Team-Token': DEFAULT_TEAM_TOKEN }, + }); if (!res.ok) { const body = await res.text().catch(() => ''); return { ok: false, error: `HTTP ${res.status} ${body.slice(0, 160)}` }; @@ -38,7 +55,10 @@ async function loadTeams(): Promise { const json = (await res.json()) as TeamsListResponse; return { ok: true, teams: json.teams }; } catch (err) { - return { ok: false, error: (err as Error).message ?? String(err) }; + // Network-layer failure: the self-hosted API isn't running, or + // NEXT_PUBLIC_API_URL points somewhere wrong. Say which, and name the + // variable — an opaque "fetch failed" here is what sends people hunting. + return { ok: false, error: `${apiConfigHint()} (${(err as Error).message ?? String(err)})` }; } } @@ -60,6 +80,18 @@ export default async function HomePage() {
No teams returned by the API. Check that {RESOLVED_API_URL}/teams is reachable. + {!IS_API_URL_CONFIGURED && ( +
+
NEXT_PUBLIC_API_URL is not set.
+

+ Trailhead is self-hosted — there is no default server. Start one + with docker compose up from the + repo root (see SELFHOSTING.md), + or set NEXT_PUBLIC_API_URL to + your server's base URL and rebuild. +

+
+ )} {!result.ok && (
{result.error} @@ -69,10 +101,12 @@ export default async function HomePage() { ) : (
{teams.map((team) => { - const qs = `?team=${encodeURIComponent(team.token)}`; + // The token comes from this dashboard's own configuration, not + // from the API response — the API no longer discloses it. + const qs = `?team=${encodeURIComponent(DEFAULT_TEAM_TOKEN)}`; return (
@@ -80,10 +114,12 @@ export default async function HomePage() {
{team.name}

- {describe(team)} + {describe(DEFAULT_TEAM_TOKEN)}

+ {/* The team token is a credential; it is not printed here. + `id` is an opaque digest, safe to show. */}
- token: {team.token} + id: {team.id}
diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index a09d6da..28573bf 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -17,9 +17,43 @@ import type { WikiTreeResponse, } from '@trailhead/shared'; -const RAW_API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'https://trailheadapi-production.up.railway.app'; +// Trailhead ships no hosted API — the backend is self-hosted, so +// NEXT_PUBLIC_API_URL is required config. The default matches the port +// apps/api listens on (PORT ?? 3000) and the port the root +// docker-compose.yml publishes, so a local `docker compose up` just works. +// +// Deliberately NOT a module-scope throw: `next build` evaluates this file +// while prerendering, and a hard failure there would break the build for +// anyone without env set. We fall back, record that we fell back, and fail +// loudly at request time instead (see assertConfigured / fetcher below). +export const DEFAULT_API_URL = 'http://localhost:3000'; + +const RAW_API_URL = process.env.NEXT_PUBLIC_API_URL ?? DEFAULT_API_URL; const API_URL = RAW_API_URL.replace(/\/$/, ''); +/** False when NEXT_PUBLIC_API_URL was never set and we're on the localhost + * default. Pages use it to explain a failure instead of showing a bare + * "fetch failed". */ +export const IS_API_URL_CONFIGURED = Boolean(process.env.NEXT_PUBLIC_API_URL); + +/** Actionable, self-contained message naming the exact variable to set. */ +export function apiConfigHint(): string { + return IS_API_URL_CONFIGURED + ? `Could not reach the Trailhead API at ${API_URL}. Check that the server is running and that NEXT_PUBLIC_API_URL is correct.` + : `Could not reach the Trailhead API at ${API_URL}. NEXT_PUBLIC_API_URL is not set, so the dashboard fell back to the local default. Trailhead is self-hosted — start the API with \`docker compose up\` from the repo root (see SELFHOSTING.md), or set NEXT_PUBLIC_API_URL to your server's base URL.`; +} + +// Wraps a fetch so a network-layer failure (server down, wrong host) becomes +// the actionable message above rather than an opaque TypeError. Non-2xx +// responses are the caller's business and pass straight through. +async function guardedFetch(input: string, init?: RequestInit): Promise { + try { + return await fetch(input, init); + } catch (err) { + throw new Error(apiConfigHint(), { cause: err }); + } +} + // Default fallback when no `?team=` param is present in the URL. Keeps the // existing demo-team links (`/wiki`, `/skill-arc`) working without changes. export const DEFAULT_TEAM_TOKEN = @@ -34,7 +68,7 @@ function headers(token: string): HeadersInit { // SWR-friendly fetcher. Throws on non-2xx so SWR's `error` channel fires. async function fetcher(path: string, token: string): Promise { - const res = await fetch(`${API_URL}${path}`, { headers: headers(token) }); + const res = await guardedFetch(`${API_URL}${path}`, { headers: headers(token) }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`trailhead-api ${path} ${res.status}: ${text.slice(0, 200)}`); @@ -44,7 +78,7 @@ async function fetcher(path: string, token: string): Promise { // Public team enumeration — no auth header required server-side. async function fetchListTeams(): Promise { - const res = await fetch(`${API_URL}/teams`); + const res = await guardedFetch(`${API_URL}/teams`); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`trailhead-api /teams ${res.status}: ${text.slice(0, 200)}`); @@ -81,7 +115,7 @@ export async function scorePrompt( token: string, args: { prompt: string; user_id: string; file_path?: string }, ): Promise { - const res = await fetch(`${API_URL}/score`, { + const res = await guardedFetch(`${API_URL}/score`, { method: 'POST', headers: headers(token), body: JSON.stringify(args), diff --git a/apps/mcp-server/bin/cli.mjs b/apps/mcp-server/bin/cli.mjs index 047e8f7..b8fa0b6 100644 --- a/apps/mcp-server/bin/cli.mjs +++ b/apps/mcp-server/bin/cli.mjs @@ -26,6 +26,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runInit } from './init.mjs'; import { deriveRepoToken } from '../src/token.mjs'; +import { resolveApiUrl } from '../src/api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const cmd = process.argv[2] ?? 'help'; @@ -50,10 +51,7 @@ function resolveToken({ cwd }) { if (cmd === 'init') { const cwd = process.cwd(); const { token, source, remoteUrl } = resolveToken({ cwd }); - const apiUrl = - flagValue('--api-url') ?? - process.env.TRAILHEAD_API_URL ?? - 'https://trailheadapi-production.up.railway.app'; + const apiUrl = resolveApiUrl(flagValue('--api-url')); const sourceLabel = { flag: '--team-token', @@ -64,6 +62,7 @@ if (cmd === 'init') { }[source]; console.log(`Token: ${token}`); console.log(`Source: ${sourceLabel}`); + console.log(`API: ${apiUrl}`); console.log(''); await runInit({ diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index dcf5472..adf5b44 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -1,32 +1,34 @@ -{ - "name": "@trailhead/mcp-server", - "version": "0.1.0", - "private": true, - "type": "module", - "main": "src/index.ts", - "bin": { - "trailhead-mcp": "bin/cli.mjs" - }, - "scripts": { - "dev": "tsx src/index.ts", - "start": "tsx src/index.ts", - "typecheck": "tsc --noEmit", - "test": "node --test bin/init.test.mjs bin/cli-smoke.test.mjs", - "smoke": "tsx src/smoke-test.mjs", - "verify": "tsx src/verify-all-tools.mjs", - "try": "tsx src/harness/try.ts", - "try:matrix": "tsx src/harness/matrix.ts" - }, - "dependencies": { - "@google/genai": "^1.50.1", - "@modelcontextprotocol/sdk": "^1.0.0", - "@trailhead/shared": "*", - "@trailhead/scoring": "*", - "zod": "^3.23.8" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "tsx": "^4.19.2", - "typescript": "^5.7.2" - } -} +{ + "name": "@trailhead/mcp-server", + "version": "0.1.0", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/Bogzx/LearnLoop.git", "directory": "apps/mcp-server" }, + "private": true, + "type": "module", + "main": "src/index.ts", + "bin": { + "trailhead-mcp": "bin/cli.mjs" + }, + "scripts": { + "dev": "tsx src/index.ts", + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit", + "test": "node --test bin/init.test.mjs bin/cli-smoke.test.mjs", + "smoke": "tsx src/smoke-test.mjs", + "verify": "tsx src/verify-all-tools.mjs", + "try": "tsx src/harness/try.ts", + "try:matrix": "tsx src/harness/matrix.ts" + }, + "dependencies": { + "@google/genai": "^1.50.1", + "@modelcontextprotocol/sdk": "^1.0.0", + "@trailhead/shared": "*", + "@trailhead/scoring": "*", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/mcp-server/src/api-client.ts b/apps/mcp-server/src/api-client.ts index 26ef94f..4a2e23c 100644 --- a/apps/mcp-server/src/api-client.ts +++ b/apps/mcp-server/src/api-client.ts @@ -147,7 +147,23 @@ export class ApiClient { export function clientFromEnv(): ApiClient { const apiUrl = process.env.TRAILHEAD_API_URL; const teamToken = process.env.TRAILHEAD_TEAM_TOKEN; - if (!apiUrl) throw new Error('TRAILHEAD_API_URL not set'); - if (!teamToken) throw new Error('TRAILHEAD_TEAM_TOKEN not set'); + // Trailhead ships no hosted API. The MCP server is launched by an agent + // host (Claude Code, Copilot) from a generated config, so an unset value + // here means that config is wrong — name the variable and the fix rather + // than failing with a bare "not set". + if (!apiUrl) { + throw new Error( + 'TRAILHEAD_API_URL is not set. Trailhead is self-hosted: start an API with ' + + '`docker compose up` from the repo root (see SELFHOSTING.md), then set ' + + 'TRAILHEAD_API_URL to its base URL (e.g. http://localhost:3000). ' + + '`npx trailhead-mcp init` writes this into your MCP config for you.', + ); + } + if (!teamToken) { + throw new Error( + 'TRAILHEAD_TEAM_TOKEN is not set. Run `npx trailhead-mcp init` in your repo to ' + + 'derive and wire one, or set it explicitly.', + ); + } return new ApiClient({ apiUrl, teamToken }); } diff --git a/apps/mcp-server/src/api-url.d.mts b/apps/mcp-server/src/api-url.d.mts new file mode 100644 index 0000000..cf9f00f --- /dev/null +++ b/apps/mcp-server/src/api-url.d.mts @@ -0,0 +1,11 @@ +export const DEFAULT_API_URL: string; + +export interface ResolveApiUrlOptions { + /** Suppress the "TRAILHEAD_API_URL not set" fallback warning. */ + quiet?: boolean; +} + +export function resolveApiUrl( + flagUrl?: string | undefined, + opts?: ResolveApiUrlOptions, +): string; diff --git a/apps/mcp-server/src/api-url.mjs b/apps/mcp-server/src/api-url.mjs new file mode 100644 index 0000000..496e7b0 --- /dev/null +++ b/apps/mcp-server/src/api-url.mjs @@ -0,0 +1,36 @@ +// Shared resolution of the Trailhead API base URL for every mcp-server CLI. +// +// Trailhead ships no hosted API — the backend is self-hosted, so +// TRAILHEAD_API_URL is required config. We fall back to the port apps/api +// listens on (PORT ?? 3000), which is also the port the root +// docker-compose.yml publishes, and warn loudly on every fallback so an +// unconfigured run is never silently mistaken for a configured one. +// +// The warning goes to stderr (console.warn), leaving CLI stdout — which the +// smoke tests assert against — untouched. + +export const DEFAULT_API_URL = 'http://localhost:3000'; + +/** Strip whitespace and any trailing slash so callers can append '/score'. */ +function normalize(url) { + return String(url).trim().replace(/\/+$/, ''); +} + +/** + * Resolve the API base URL from (in order) an explicit --api-url flag value, + * TRAILHEAD_API_URL, then the self-host default. Pass `{ quiet: true }` to + * suppress the fallback warning when the caller prints its own banner. + */ +export function resolveApiUrl(flagUrl, { quiet = false } = {}) { + const explicit = flagUrl || process.env.TRAILHEAD_API_URL; + if (explicit && String(explicit).trim()) return normalize(explicit); + if (!quiet) { + console.warn( + `! TRAILHEAD_API_URL is not set — falling back to ${DEFAULT_API_URL}\n` + + ' Trailhead is self-hosted; there is no hosted API to fall back to.\n' + + ' Start one with `docker compose up` from the repo root (see SELFHOSTING.md),\n' + + ' or set TRAILHEAD_API_URL (or pass --api-url ) to point at your server.', + ); + } + return DEFAULT_API_URL; +} diff --git a/apps/mcp-server/src/bootstrap-cli.ts b/apps/mcp-server/src/bootstrap-cli.ts index 0fec7c9..f7ed95e 100644 --- a/apps/mcp-server/src/bootstrap-cli.ts +++ b/apps/mcp-server/src/bootstrap-cli.ts @@ -37,6 +37,7 @@ import { } from './bootstrap.ts'; import type { WikiJobStatusResponse } from '@trailhead/shared'; import { deriveRepoToken } from './token.mjs'; +import { resolveApiUrl } from './api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -158,10 +159,7 @@ const explicitToken = flagValues.get('--team-token'); const tokenInfo = explicitToken ? { token: explicitToken, source: 'flag' as const, remoteUrl: undefined as string | undefined } : deriveRepoToken(cwd); -const apiUrl = - flagValues.get('--api-url') ?? - process.env.TRAILHEAD_API_URL ?? - 'https://trailheadapi-production.up.railway.app'; +const apiUrl = resolveApiUrl(flagValues.get('--api-url')); const seed = seedFromFiles ? readSeedRules(cwd) : {}; diff --git a/apps/mcp-server/src/reset-cli.ts b/apps/mcp-server/src/reset-cli.ts index 2d13d8b..f1539a6 100644 --- a/apps/mcp-server/src/reset-cli.ts +++ b/apps/mcp-server/src/reset-cli.ts @@ -12,6 +12,7 @@ import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; import { ApiClient } from './api-client.ts'; import { deriveRepoToken } from './token.mjs'; +import { resolveApiUrl } from './api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -61,10 +62,7 @@ const explicitToken = flagValues.get('--team-token'); const tokenInfo = explicitToken ? { token: explicitToken, source: 'flag' as const } : deriveRepoToken(cwd); -const apiUrl = - flagValues.get('--api-url') ?? - process.env.TRAILHEAD_API_URL ?? - 'https://trailheadapi-production.up.railway.app'; +const apiUrl = resolveApiUrl(flagValues.get('--api-url')); console.log(`API: ${apiUrl}`); console.log(`Token: ${tokenInfo.token}`); diff --git a/apps/mcp-server/src/smoke-test.mjs b/apps/mcp-server/src/smoke-test.mjs index c2a811b..92a182b 100644 --- a/apps/mcp-server/src/smoke-test.mjs +++ b/apps/mcp-server/src/smoke-test.mjs @@ -10,6 +10,7 @@ import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync } from 'node:fs'; import process from 'node:process'; +import { resolveApiUrl } from './api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SERVER = resolve(__dirname, 'index.ts'); @@ -25,7 +26,7 @@ for (const candidate of ['../../../.env', '../../.env', '.env']) { const env = { ...process.env, - TRAILHEAD_API_URL: process.env.TRAILHEAD_API_URL ?? 'https://trailheadapi-production.up.railway.app', + TRAILHEAD_API_URL: resolveApiUrl(), TRAILHEAD_TEAM_TOKEN: process.env.TRAILHEAD_TEAM_TOKEN ?? 'trailhead_demo_acme_2026', }; diff --git a/apps/mcp-server/src/verify-all-tools.mjs b/apps/mcp-server/src/verify-all-tools.mjs index 1c3f807..96601f2 100644 --- a/apps/mcp-server/src/verify-all-tools.mjs +++ b/apps/mcp-server/src/verify-all-tools.mjs @@ -1,9 +1,13 @@ -// Exercise every MCP hero tool against the live Railway API and report what +// Exercise every MCP hero tool against a running Trailhead API and report what // behaves correctly vs. what surfaces an upstream 404. Used as a verification // harness — not a unit test. +// +// Trailhead is self-hosted: bring the API up with `docker compose up` from the +// repo root (see SELFHOSTING.md), or point TRAILHEAD_API_URL at your server. import { spawn } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveApiUrl } from './api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SERVER = resolve(__dirname, 'index.ts'); @@ -11,7 +15,7 @@ const SERVER = resolve(__dirname, 'index.ts'); const child = spawn('npx', ['--yes', 'tsx', SERVER], { env: { ...process.env, - TRAILHEAD_API_URL: process.env.TRAILHEAD_API_URL ?? 'https://trailheadapi-production.up.railway.app', + TRAILHEAD_API_URL: resolveApiUrl(), TRAILHEAD_TEAM_TOKEN: process.env.TRAILHEAD_TEAM_TOKEN ?? 'trailhead_demo_acme_2026', }, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/apps/vscode-ext/media/trailhead.svg b/apps/vscode-ext/media/trailhead.svg new file mode 100644 index 0000000..24d3f0c --- /dev/null +++ b/apps/vscode-ext/media/trailhead.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/apps/vscode-ext/package.json b/apps/vscode-ext/package.json index b5fabcd..e12ecb9 100644 --- a/apps/vscode-ext/package.json +++ b/apps/vscode-ext/package.json @@ -1,76 +1,78 @@ -{ - "name": "trailhead-vscode", - "displayName": "Trailhead", - "description": "Prompt-skill coach in your sidebar — score-card, team-anchored examples, autonomous wiki updates.", - "version": "0.0.1", - "private": true, - "publisher": "trailhead", - "engines": { - "vscode": "^1.85.0" - }, - "categories": ["Other"], - "activationEvents": ["onView:trailhead.coach"], - "main": "./dist/extension.js", - "contributes": { - "viewsContainers": { - "activitybar": [ - { - "id": "trailhead", - "title": "Trailhead", - "icon": "$(rocket)" - } - ] - }, - "views": { - "trailhead": [ - { - "type": "webview", - "id": "trailhead.coach", - "name": "Coach" - } - ] - }, - "commands": [ - { - "command": "trailhead.refresh", - "title": "Trailhead: Refresh sidebar" - } - ], - "configuration": { - "title": "Trailhead", - "properties": { - "trailhead.apiUrl": { - "type": "string", - "default": "https://trailheadapi-production.up.railway.app", - "description": "Trailhead API base URL." - }, - "trailhead.teamToken": { - "type": "string", - "default": "trailhead_demo_acme_2026", - "description": "Team token sent in the X-Team-Token header." - }, - "trailhead.userId": { - "type": "string", - "default": "demo", - "description": "User ID stamped on score / capture writes." - } - } - } - }, - "scripts": { - "build": "node esbuild.config.mjs", - "watch": "node esbuild.config.mjs --watch", - "typecheck": "tsc --noEmit", - "test": "npm run build && node --test --experimental-strip-types src/paths.test.mts src/wiki-diff.test.mts test/bundle-load.test.mjs", - "vscode:prepublish": "node esbuild.config.mjs --production" - }, - "dependencies": { - "@trailhead/shared": "*" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "@types/vscode": "^1.85.0", - "esbuild": "^0.24.0", - "typescript": "^5.7.2" - } -} +{ + "name": "trailhead-vscode", + "displayName": "Trailhead", + "description": "Prompt-skill coach in your sidebar — score-card, team-anchored examples, autonomous wiki updates.", + "version": "0.0.1", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/Bogzx/LearnLoop.git", "directory": "apps/vscode-ext" }, + "private": true, + "publisher": "trailhead", + "engines": { + "vscode": "^1.85.0" + }, + "categories": ["Other"], + "activationEvents": ["onView:trailhead.coach"], + "main": "./dist/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "trailhead", + "title": "Trailhead", + "icon": "media/trailhead.svg" + } + ] + }, + "views": { + "trailhead": [ + { + "type": "webview", + "id": "trailhead.coach", + "name": "Coach" + } + ] + }, + "commands": [ + { + "command": "trailhead.refresh", + "title": "Trailhead: Refresh sidebar" + } + ], + "configuration": { + "title": "Trailhead", + "properties": { + "trailhead.apiUrl": { + "type": "string", + "default": "http://localhost:3000", + "description": "Base URL of your self-hosted Trailhead API. Trailhead ships no hosted backend — bring one up with `docker compose up` from the repo root (see SELFHOSTING.md), then point this at it. Defaults to the port that compose publishes." + }, + "trailhead.teamToken": { + "type": "string", + "default": "trailhead_demo_acme_2026", + "description": "Team token sent in the X-Team-Token header." + }, + "trailhead.userId": { + "type": "string", + "default": "demo", + "description": "User ID stamped on score / capture writes." + } + } + } + }, + "scripts": { + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test --experimental-strip-types src/paths.test.mts src/wiki-diff.test.mts test/bundle-load.test.mjs", + "vscode:prepublish": "node esbuild.config.mjs --production" + }, + "dependencies": { + "@trailhead/shared": "*" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/vscode": "^1.85.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.2" + } +} diff --git a/apps/vscode-ext/src/extension.ts b/apps/vscode-ext/src/extension.ts index 6143ee9..c5e2c35 100644 --- a/apps/vscode-ext/src/extension.ts +++ b/apps/vscode-ext/src/extension.ts @@ -13,15 +13,51 @@ import { activeFilePath, activeFolderPath } from './paths.ts'; import { getNonce, getWebviewHtml } from './webview.ts'; import { applyWikiSnapshot, diffWikiItems, maxSince } from './wiki-diff.ts'; +// Trailhead ships no hosted API — the backend is self-hosted, so `trailhead.apiUrl` +// is required config. This default matches the port apps/api listens on +// (PORT ?? 3000) and the port the root docker-compose.yml publishes. +const DEFAULT_API_URL = 'http://localhost:3000'; + function readConfig(): api.ApiConfig & { userId: string } { const cfg = vscode.workspace.getConfiguration('trailhead'); + const configured = (cfg.get('apiUrl') ?? '').trim().replace(/\/+$/, ''); return { - apiUrl: cfg.get('apiUrl') ?? 'https://trailheadapi-production.up.railway.app', + apiUrl: configured || DEFAULT_API_URL, teamToken: cfg.get('teamToken') ?? 'trailhead_demo_acme_2026', userId: cfg.get('userId') ?? 'demo', }; } +// A self-hosted API that isn't running fails at the network layer. Undici +// surfaces that as a TypeError / "fetch failed" — distinct from a 4xx, which +// resolves normally. Those get a visible, actionable notification instead of +// an empty sidebar, because silence here reads as "the extension is broken". +function isUnreachable(err: unknown): boolean { + if (err instanceof TypeError) return true; + const msg = err instanceof Error ? err.message : String(err); + return /fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|network/i.test(msg); +} + +// Latched so a 2s wiki poll against a down server doesn't produce a +// notification storm. Cleared by noteApiReachable() on the next success, so a +// server that goes away again re-notifies. +let apiUnreachableNotified = false; + +function noteApiReachable(): void { + apiUnreachableNotified = false; +} + +function notifyApiUnreachable(cfg: { apiUrl: string }, err: unknown): void { + if (!isUnreachable(err) || apiUnreachableNotified) return; + apiUnreachableNotified = true; + const isDefault = cfg.apiUrl === DEFAULT_API_URL; + const detail = isDefault + ? `Trailhead can't reach an API at ${cfg.apiUrl}. Trailhead is self-hosted: start one with \`docker compose up\` from the repo root (see SELFHOSTING.md), or set "trailhead.apiUrl" in Settings to your server's URL.` + : `Trailhead can't reach the API at ${cfg.apiUrl}. Check that the server is running, or correct "trailhead.apiUrl" in Settings.`; + // Optional-called: the bundle-load test stubs `vscode` with a minimal window. + vscode.window.showErrorMessage?.(detail); +} + class CoachViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'trailhead.coach'; @@ -77,9 +113,12 @@ class CoachViewProvider implements vscode.WebviewViewProvider { const cfg = readConfig(); try { const res = await api.examples(cfg, folder); + noteApiReachable(); this.view?.webview.postMessage({ type: 'examples', items: res.items }); - } catch { + } catch (e) { // /examples may not be deployed yet — show empty state, no toast spam. + // An unreachable server is a different problem and does get surfaced. + notifyApiUnreachable(cfg, e); this.view?.webview.postMessage({ type: 'examples', items: [] }); } } @@ -105,10 +144,14 @@ class CoachViewProvider implements vscode.WebviewViewProvider { ac.signal, ); if (ac.signal.aborted) return; + noteApiReachable(); this.view.webview.postMessage({ type: 'score', seq, payload: res }); } catch (e) { if (ac.signal.aborted) return; - const msg = (e as Error).message; + notifyApiUnreachable(cfg, e); + const msg = isUnreachable(e) + ? `can't reach the Trailhead API at ${cfg.apiUrl} — check "trailhead.apiUrl" in Settings` + : (e as Error).message; this.view.webview.postMessage({ type: 'score', seq, error: `score failed: ${msg}` }); } } @@ -133,8 +176,12 @@ class CoachViewProvider implements vscode.WebviewViewProvider { let res: api.WikiRecentResponse; try { res = await api.wikiRecent(cfg, this.wikiSince); - } catch { - return; // endpoint may not be deployed; fail silently + noteApiReachable(); + } catch (e) { + // Endpoint may not be deployed; that stays silent. An unreachable + // server surfaces once (latched) rather than every 2s poll. + notifyApiUnreachable(cfg, e); + return; } const toasts = diffWikiItems(this.wikiState, res.items); for (const t of toasts) { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..90c4f78 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,103 @@ +# Trailhead — self-hosted stack: Postgres + the API. +# +# cp .env.example .env # then put your Gemini key in it +# docker compose up +# +# That is the whole setup. The schema is applied to a fresh database +# automatically, and the API comes up on http://localhost:3000. +# See SELFHOSTING.md for pointing the clients at it. + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-trailhead} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-trailhead} + POSTGRES_DB: ${POSTGRES_DB:-trailhead} + volumes: + # Named volume: data survives `docker compose down`. Use + # `docker compose down -v` to wipe it (see SELFHOSTING.md). + - trailhead-pgdata:/var/lib/postgresql/data + # Anything in this directory runs, in filename order, the FIRST time the + # data directory is initialised — i.e. on a fresh volume only. That is + # how the schema gets applied without a manual psql step. schema.sql is + # idempotent (every CREATE uses IF NOT EXISTS) so re-running is safe. + - ./packages/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + healthcheck: + # The API waits on this, so it must mean "ready for queries", not just + # "process started". -U/-d avoid a false pass from the bootstrap phase. + test: + ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-trailhead} -d ${POSTGRES_DB:-trailhead}'] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + ports: + # Exposed for psql / the seed + migrate scripts in packages/db. Change + # POSTGRES_PORT if 5432 is already taken on your machine. + # + # Bound to 127.0.0.1 deliberately. A bare '5432:5432' publishes on every + # interface, which on a laptop means the database is reachable by anyone + # on the same café / office network. + - '127.0.0.1:${POSTGRES_PORT:-5432}:5432' + restart: unless-stopped + + api: + build: + # Root context: this is an npm workspace monorepo and the install needs + # the root lockfile plus every workspace manifest. + context: . + dockerfile: apps/api/Dockerfile + environment: + # Defaults to the postgres service above. Set DATABASE_URL in .env to + # point at an external database instead (Neon, RDS, ...). + DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-trailhead}:${POSTGRES_PASSWORD:-trailhead}@postgres:5432/${POSTGRES_DB:-trailhead}} + # The one thing a stranger must supply. `:?` fails the command up front + # with this message rather than building an image that dies on boot. + GEMINI_API_KEY: ${GEMINI_API_KEY:?set GEMINI_API_KEY in .env (cp .env.example .env) — get one at https://aistudio.google.com/apikey} + # Container-internal port. The host mapping below is what you change. + PORT: '3000' + # Any unknown X-Team-Token spawns its own team. + # + # The API's own default is FALSE (unauthenticated tenant creation is not + # something a reachable server should do). Compose overrides it to true + # because `trailhead-mcp init` derives a fresh per-repo token and expects + # the server to accept it — without this, local onboarding 401s. + # + # That trade is only safe because the port below is bound to 127.0.0.1. + # If you change that binding to expose this stack on a network, set this + # to false and register teams explicitly. + TRAILHEAD_AUTO_CREATE_TEAMS: ${TRAILHEAD_AUTO_CREATE_TEAMS:-true} + # Guard on DELETE /team/data for the seeded demo team. + TRAILHEAD_ALLOW_DEMO_RESET: ${TRAILHEAD_ALLOW_DEMO_RESET:-false} + # Optional tracing. Unset = silently disabled with a startup warning. + LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY:-} + LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY:-} + LANGFUSE_BASEURL: ${LANGFUSE_BASEURL:-https://cloud.langfuse.com} + ports: + # host:container — set PORT in .env to publish somewhere other than 3000. + # Every client default (browser ext, VS Code ext, MCP server, dashboard) + # is http://localhost:3000, so changing this means updating those too. + # + # Bound to 127.0.0.1: the API's only auth is a bearer team token, and + # with TRAILHEAD_AUTO_CREATE_TEAMS on (above) any token is accepted. That + # combination must not be reachable from the network. Drop the 127.0.0.1 + # prefix only together with TRAILHEAD_AUTO_CREATE_TEAMS=false. + - '127.0.0.1:${PORT:-3000}:3000' + depends_on: + postgres: + # Don't start until Postgres answers pg_isready — the API opens its + # pool and runs a startup migration check immediately. + condition: service_healthy + healthcheck: + # GET / is the API's status endpoint (no auth). wget ships with the + # busybox userland in node:22-alpine. + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:3000/'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + +volumes: + trailhead-pgdata: diff --git a/docs/roadmaps/2026-04-25-roadmap-browser-extension.md b/docs/roadmaps/2026-04-25-roadmap-browser-extension.md index fd876d9..4f70448 100644 --- a/docs/roadmaps/2026-04-25-roadmap-browser-extension.md +++ b/docs/roadmaps/2026-04-25-roadmap-browser-extension.md @@ -1,5 +1,10 @@ # Roadmap — Browser Extension on Claude.ai (Person B) +> **Historical document.** The host `trailheadapi-production.up.railway.app` +> referenced below is DELETED and returns 404. Trailhead is self-hosted now: +> bring an API up with `docker compose up` (see `SELFHOSTING.md`) and use +> `http://localhost:3000`. Do not copy the URLs below into a config. + **Date:** 2026-04-25 (PoliHack 24h hackathon) **Spec:** [`docs/superpowers/specs/2026-04-25-trailhead-design.md`](../superpowers/specs/2026-04-25-trailhead-design.md) — read §6 (Socratic Mode) first **Status as of writing:** unblocker complete (shared types locked, Hono stub deployed at `https://trailheadapi-production.up.railway.app`, CORS verified open) diff --git a/docs/roadmaps/2026-04-25-roadmap-mcp-vscode-hook.md b/docs/roadmaps/2026-04-25-roadmap-mcp-vscode-hook.md index 9d1ef61..e2b868a 100644 --- a/docs/roadmaps/2026-04-25-roadmap-mcp-vscode-hook.md +++ b/docs/roadmaps/2026-04-25-roadmap-mcp-vscode-hook.md @@ -1,5 +1,10 @@ # Roadmap — VS Code Extension + MCP Server (Person C) +> **Historical document.** The host `trailheadapi-production.up.railway.app` +> referenced below is DELETED and returns 404. Trailhead is self-hosted now: +> bring an API up with `docker compose up` (see `SELFHOSTING.md`) and use +> `http://localhost:3000`. Do not copy the URLs below into a config. + **Date:** 2026-04-25 (PoliHack 24h hackathon) **Spec:** [`docs/superpowers/specs/2026-04-25-trailhead-design.md`](../superpowers/specs/2026-04-25-trailhead-design.md) — read §3, §7, §8 first **Status as of writing:** unblocker complete (shared types locked, schema written, Hono stub deployed at `https://trailheadapi-production.up.railway.app`) diff --git a/docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md b/docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md index 6437a42..bbd3ec6 100644 --- a/docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md +++ b/docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md @@ -1,5 +1,10 @@ # Trailhead Browser Extension — Design Spec +> **Historical document.** The host `trailheadapi-production.up.railway.app` +> referenced below is DELETED and returns 404. Trailhead is self-hosted now: +> bring an API up with `docker compose up` (see `SELFHOSTING.md`) and use +> `http://localhost:3000`. Do not copy the URLs below into a config. + **Date:** 2026-04-25 **Scope:** `apps/browser-ext/` (new) + `packages/score-card/` (new shared lib) **Parent spec:** [`2026-04-25-trailhead-design.md`](./2026-04-25-trailhead-design.md) (read §2, §6, §13, §19 first) diff --git a/packages/shared/types.ts b/packages/shared/types.ts index 7871fb8..54564b0 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -334,12 +334,20 @@ export interface CoachResponse { // before any token is configured. Demo simplicity: no per-user // permission filter (the user explicitly asked for "all teams"). // -// Token is the team's primary key (the value the client sends as -// X-Team-Token). There is no separate UUID id since the -// 2026-04-26 token-as-team-key refactor. +// A team as described to a client. +// +// `token` is deliberately NOT here. The team token is the only credential this +// system has — it grants read on the wiki (which summarises private source +// code) and write everywhere — and GET /teams used to hand back every tenant's +// token, unauthenticated, with CORS `*`. One request to a public URL was a +// full compromise of every team on the server. +// +// `id` is an opaque, stable, non-reversible digest of the token. It is safe to +// display and to use as a React key or a lookup handle, and it cannot be +// replayed as an X-Team-Token. export interface TeamSummary { name: string; - token: string; + id: string; } export interface TeamsListResponse { teams: TeamSummary[]; From eda11640ed1de1e469cb010f4b551f8d68d047df Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 19:36:35 +0300 Subject: [PATCH 13/14] README: document the compose quick start, /wiki/export and the new /teams The /teams row still advertised "List all teams with tokens (unauth)" - now an accurate description of a fixed vulnerability. Also documents the markdown export endpoint and replaces "pre-allowlists the deployed Railway API" (which is deleted) with the localhost default. Co-Authored-By: Claude Opus 5 --- README.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 24a99de..da36da8 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Endpoints implemented in `apps/api/src/index.ts`: | Method + Path | What it does | |---|---| | `GET /` | Health + endpoint catalog (unauth) | -| `GET /teams` | List all teams with tokens (unauth, drives the dashboard team picker) | +| `GET /teams` | Resolves the caller's own team (authenticated). Never returns tokens — `{ name, id }` where `id` is an opaque digest | | `POST /score` | 5-dimension Gemini score; writes `skill_observation` rows with a 30 s per-dimension dedup window | | `POST /coach` | Stateless 3-round teach→reveal coaching loop | | `POST /capture` | Stores a `(prompt, response, outcome)` capture from any surface | @@ -76,6 +76,7 @@ Endpoints implemented in `apps/api/src/index.ts`: | `GET /skill-arc` | Time-series of per-dimension scores (powers the dashboard hero chart) | | `GET /team/metrics` | Snapshot: avg overall, reuse rate, durable count, draft count, active users | | `GET /wiki/tree` | Full node + learnings tree | +| `GET /wiki/export` | The whole team wiki as one markdown document (`?drafts=true`, `?format=json`) | | `POST /onboard/repo` | Bulk-upsert one node per path, idempotent, optional `initial_rules[path]` for seeding `body_md` | | `POST /onboard/repo/full` | Async rich bootstrap: accepts a folder + file bundle (capped at 16 MB / 2 000 files / 32 KB per file), enqueues a `wiki_jobs` row, three-pass Gemini fan-out via `setImmediate` | | `GET /onboard/jobs/:id` | Per-path progress for a rich-bootstrap job | @@ -94,7 +95,8 @@ Tracing silently no-ops when keys are missing. ### `apps/browser-ext` — Chrome MV3 extension for Claude.ai Vanilla TypeScript + esbuild. Manifest declares `https://claude.ai/*` as the -content-script host and pre-allowlists the deployed Railway API. +content-script host and allowlists `http://localhost/*` for a self-hosted API. +The popup's **API server** row shows and edits that URL. Implemented widgets (`src/widgets/`): @@ -228,6 +230,26 @@ docs/ ## Quick start +Trailhead is self-hosted. There is no hosted backend to sign up for — you run +the API, and every client points at it. + +### The short way: Docker + +Everything you need is Docker and a Gemini API key from +. + +```bash +cp .env.example .env # then put your Gemini key in it +docker compose up +# → API on http://localhost:3000, Postgres schema applied automatically +``` + +That is the whole setup. See [SELFHOSTING.md](SELFHOSTING.md) for pointing the +browser extension, VS Code extension, MCP server and dashboard at it, and for +running against an external database instead. + +### The long way: local Node + your own Postgres + Prerequisites: - Node `>= 22.6` From ac8e2bcb12243c26820e8304303856e63989e44e Mon Sep 17 00:00:00 2001 From: Bogzx <34788557+Bogzx@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:53:03 +0300 Subject: [PATCH 14/14] Review fixes: correct npx trailhead-mcp docs; drop dead unauth listTeams client The "npx trailhead-mcp does not work" disclaimer had been added in one place (README mcp-server section) but three other spots still told users to run it: the quick-start (cd into a target repo, then npx trailhead-mcp init), the deploy list, and the stack summary. The package is private/unpublished, so npx from a target repo hits the registry and fails. Replaced all three with the truthful clone-based invocation (node apps/mcp-server/bin/cli.mjs init), and corrected the stale "home page lists every team" line to match the now-authenticated, own-team-only endpoint. Also removed the dead listTeams/fetchListTeams from the dashboard client: nothing calls it (the home page does its own authenticated fetch), it sent no X-Team-Token so it would 401, and its comment still claimed the teams endpoint needs no auth -- the exact falsehood this PR set out to fix. Corrected the file header comment to match. Co-Authored-By: Claude Opus 5 --- README.md | 19 ++++++++++++------- apps/dashboard/src/lib/api.ts | 24 +++++++++--------------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index da36da8..6c00cd6 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,8 @@ CLI subcommands (`bin/cli.mjs`): App router, server components for the team list, SWR for the live charts. Pages (`src/app/`): -- `/` — team picker (lists every team returned by `/teams`) +- `/` — team view (shows the caller's own team; `GET /teams` is authenticated + and returns only the team the configured token resolves to) - `/skill-arc?team=…` — per-dimension team chart driven by `/skill-arc`, polls every 2 s during the demo - `/team?team=…` — L1→L2 metric cards from `/team/metrics` @@ -292,10 +293,12 @@ npm --workspace=@trailhead/browser-ext run build # VS Code extension — build, then F5 with apps/vscode-ext as the workspace npm --workspace=apps/vscode-ext run build -# MCP server — install into a target repo +# MCP server — install into a target repo. The package is unpublished +# (private: true), so `npx trailhead-mcp` does NOT work — invoke the CLI by +# path from this clone. It operates on the cwd, so cd into the target first. cd /path/to/your/repo -npx trailhead-mcp init -npx trailhead-mcp bootstrap +node /path/to/LearnLoop/apps/mcp-server/bin/cli.mjs init +node /path/to/LearnLoop/apps/mcp-server/bin/cli.mjs bootstrap ``` ### Workspace scripts @@ -340,7 +343,8 @@ Single root `.env.example` — every surface reads from the same set. . - **Browser extension** → loaded unpacked from `apps/browser-ext/dist/`. - **VS Code extension** → `vsce package` from `apps/vscode-ext/`. -- **MCP server** → distributed via `npx trailhead-mcp init` (per-repo wiring, +- **MCP server** → not published to npm (`private: true`). Wired into a repo by + running `apps/mcp-server/bin/cli.mjs init` from a clone (per-repo wiring, multi-tenant token derivation from the git remote). --- @@ -407,5 +411,6 @@ contracts, builds, and tests. TS + esbuild (extensions); React via CDN (landing page) - **MCP:** `@modelcontextprotocol/sdk`, STDIO transport - **Build:** npm workspaces; per-package `tsc` / `esbuild` -- **Hosts:** Railway (API), Vercel (dashboard + landing page), per-repo MCP - install via `npx trailhead-mcp` +- **Hosts:** self-hosted API (see SELFHOSTING.md; `railway.json` remains for + anyone who wants a Railway deploy), Vercel (dashboard + landing page), per-repo + MCP wired from a clone via `apps/mcp-server/bin/cli.mjs init` (unpublished) diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 28573bf..3f13a55 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -1,7 +1,8 @@ // Thin SWR-friendly client for the Trailhead API. Every fetcher takes a -// `token` so the dashboard can render any team's data — the team picker -// fetches /teams (no auth) and routes each team card to ?team=; -// downstream pages read that param and pass it into these calls. +// `token` and sends it as X-Team-Token — the whole API (including GET /teams) +// authenticates on that header. The home page resolves the caller's own team +// via GET /teams and routes each link to ?team=; downstream pages read +// that param and pass it into these calls. // // Tokens aren't secrets in this design — they're derived from public git // remotes. See master spec §3 for the trust model. @@ -12,7 +13,6 @@ import type { ScoreResponse, SkillArcResponse, TeamMetricsResponse, - TeamsListResponse, WikiRecentResponse, WikiTreeResponse, } from '@trailhead/shared'; @@ -76,21 +76,15 @@ async function fetcher(path: string, token: string): Promise { return (await res.json()) as T; } -// Public team enumeration — no auth header required server-side. -async function fetchListTeams(): Promise { - const res = await guardedFetch(`${API_URL}/teams`); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`trailhead-api /teams ${res.status}: ${text.slice(0, 200)}`); - } - return (await res.json()) as TeamsListResponse; -} - // Typed convenience wrappers. Each page imports the one it needs and // passes it as the SWR fetcher; this keeps useSWR generics inferred // without each page restating the path string. +// +// There is no listTeams() here: GET /teams is authenticated and returns only +// the caller's own team, so the home page fetches it directly with its +// configured token (see app/page.tsx) rather than through an unauthenticated +// enumeration helper. export const api = { - listTeams: (): Promise => fetchListTeams(), skillArc: (token: string, since?: string, userId?: string): Promise => { const params = new URLSearchParams(); if (since) params.set('since', since);