diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fde9660..e9492e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,8 @@ jobs: test -f "$DIR/SKILL.md" || { echo "::error::SKILL.md not installed"; exit 1; } test -f "$DIR/scripts/construct.mjs" || { echo "::error::engine not bundled — skills add dropped scripts/"; exit 1; } test -d "$DIR/references" || { echo "::error::references/ not bundled"; exit 1; } + test -f "$DIR/docker-compose.yml" || { echo "::error::docker-compose.yml not bundled — semantic stack inoperable as-installed"; exit 1; } + test -f "$DIR/docker/searxng/settings.yml" || { echo "::error::searxng settings not bundled — SearXNG mount would fail"; exit 1; } node "$DIR/scripts/construct.mjs" --help > /dev/null || { echo "::error::installed engine does not run"; exit 1; } echo "install-bundle: skill installed whole and the engine runs." diff --git a/.releaserc.json b/.releaserc.json index b3b2c48..df1a55c 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -19,7 +19,7 @@ [ "@semantic-release/git", { - "assets": ["package.json", "src/types.ts", "skills/construct/SKILL.md", "CHANGELOG.md", "scripts/construct.mjs", "skills/construct/scripts/construct.mjs"], + "assets": ["package.json", "src/types.ts", "skills/construct/SKILL.md", "CHANGELOG.md", "scripts/construct.mjs", "skills/construct/scripts/construct.mjs", "skills/construct/docker-compose.yml", "skills/construct/docker/searxng/settings.yml"], "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } ], diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b6e188c..a4d3f87 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -15,9 +15,9 @@ CI verifies the committed bundle is reproducible (`pnpm run check:build`). ## Pipeline ``` -init → (interview, agent-driven) → research → analyze → render → check → review → verify -brief.json evidence/ gap report SRD tree + SRD.json report claim-support build referee - + BUILD-PLAN.json +init → [brainstorm] → (interview, agent-driven) → research → analyze → render → check → review → verify +brief.json brainstorm.json evidence/ gap report SRD tree + SRD.json report claim-support build referee + + BUILD-PLAN.json ``` ### `init` @@ -25,6 +25,20 @@ Writes a `brief.json` skeleton (`src/brief.ts`). The brief is a passive schema store — the analog of reconstruct's `plan.json`. The interview that fills it is agent-driven (`references/interview-playbook.md`). +### `brainstorm` (`src/brainstorm.ts`) +The optional DIVERGENT step before the convergent interview. `brainstorm` +scaffolds `brainstorm.json` + `BRAINSTORM.md` — a board of `{ id, angle, title, +status, target? }` ideas across six angles (reframe/segment/feature/ +differentiator/anti-goal/wildcard). The agent generates ideas WITH the user and +marks each `proposed|kept|parked|rejected`. `brainstorm --merge` folds every +**kept** idea into the brief by its `target` (featureWishlist/competitors/goals/ +nonGoals/candidateTech/openQuestions) and every **parked** idea into +`openQuestions` (a gate-blocking 🧠). `mergeBrainstorm` is pure and idempotent — +an idea carries `mergedAt` once folded and is skipped forever after; a +goals↔nonGoals conflict or a targetless kept idea warns and is left unstamped so +it can be resolved and re-merged. `check` warns (never gates) while any idea is +still `proposed`. + ### `research` (`src/research/`) Runs the selected angles concurrently (`registry.ts::runAngles`), optionally rescoring by semantic similarity, then assigns stable `[E#]` ids and writes the diff --git a/README.md b/README.md index 081c376..54b3c92 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ npx skills add maxgfr/construct ``` node scripts/construct.mjs init --idea "a self-hosted read-it-later app" --out ./readpile +# optional: diverge first, then fold kept ideas into the brief +node scripts/construct.mjs brainstorm --out ./readpile # scaffold BRAINSTORM.md +node scripts/construct.mjs brainstorm --out ./readpile --merge # kept → brief.json # …fill ./readpile/brief.json via the interview… node scripts/construct.mjs research --out ./readpile --angles market,oss,tech node scripts/construct.mjs analyze --out ./readpile # what's thin? drill it diff --git a/package.json b/package.json index 746dc7a..0e2a12a 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "lint:fix": "biome check --write .", "format": "biome format --write .", "verify:bundle": "node scripts/verify-skill-bundle.mjs", - "check:build": "tsup && node scripts/copy-bundle.mjs && git diff --exit-code -- scripts/construct.mjs skills/construct/scripts/construct.mjs && node scripts/verify-skill-bundle.mjs", + "check:build": "tsup && node scripts/copy-bundle.mjs && git diff --exit-code -- scripts/construct.mjs skills/construct/scripts/construct.mjs skills/construct/docker-compose.yml skills/construct/docker/searxng/settings.yml && node scripts/verify-skill-bundle.mjs", "demo": "rm -rf /tmp/construct-demo && mkdir -p /tmp/construct-demo/evidence && cp tests/fixtures/sample-brief.json /tmp/construct-demo/brief.json && cp tests/fixtures/sample-evidence.json /tmp/construct-demo/evidence/evidence.json && node scripts/construct.mjs render --out /tmp/construct-demo --level complex --merge && node scripts/construct.mjs check --out /tmp/construct-demo", "release": "semantic-release" }, diff --git a/scripts/construct.mjs b/scripts/construct.mjs index 542e52b..06d66bf 100755 --- a/scripts/construct.mjs +++ b/scripts/construct.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node // src/cli.ts -import { resolve as resolve3, join as join14 } from "path"; -import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs"; +import { resolve as resolve3, join as join15 } from "path"; +import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs"; import { pathToFileURL, fileURLToPath as fileURLToPath2 } from "url"; import { realpathSync } from "fs"; @@ -10,6 +10,7 @@ import { realpathSync } from "fs"; var VERSION = "1.9.3"; var ALL_SOURCE_KINDS = ["market", "oss", "docs", "so", "issue", "pr"]; var BRIEF_SCHEMA_VERSION = 1; +var BRAINSTORM_SCHEMA_VERSION = 1; var SRD_SCHEMA_VERSION = 1; var REQUIRED_NFR = { light: ["performance", "security", "reliability"], @@ -248,13 +249,37 @@ var PRIORITIES = ["must", "should", "could"]; function slugId(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60); } +var KNOWN_CONSTRAINT_KEYS = ["budget", "timeline", "team", "compliance"]; +function normalizeConstraints(raw, line2, arr, warn) { + const c = raw ?? {}; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const k of Object.keys(c)) { + if (!KNOWN_CONSTRAINT_KEYS.includes(k)) { + warn( + `constraints.${k} is not a recognized constraint (known: ${KNOWN_CONSTRAINT_KEYS.join(", ")}) \u2014 ignored; fold it into the nearest field or openQuestions.` + ); + } + } + } + return { + budget: line2(c.budget), + timeline: line2(c.timeline), + team: line2(c.team), + compliance: arr(c.compliance, "constraints.compliance") + }; +} function normalizeBrief(data, warn = () => { }) { const d = data ?? {}; - const line = (v) => typeof v === "string" ? v.replace(/\s+/g, " ").trim() : void 0; + const line2 = (v) => typeof v === "string" ? v.replace(/\s+/g, " ").trim() : void 0; const arr = (v, field) => { if (v === void 0 || v === null) return []; if (!Array.isArray(v)) { + if (typeof v === "string") { + const s = v.replace(/\s+/g, " ").trim(); + warn(`${field}: expected an array \u2014 coerced the bare string into a one-element array.`); + return s ? [s] : []; + } warn(`${field} is not an array \u2014 ignored.`); return []; } @@ -270,8 +295,8 @@ function normalizeBrief(data, warn = () => { const out = []; const seen = /* @__PURE__ */ new Set(); d.modules.forEach((m, i) => { - const rawId = line(m?.id); - const rawName = line(m?.name); + const rawId = line2(m?.id); + const rawName = line2(m?.name); const id = slugId(rawId || rawName || ""); if (!id) { warn(`modules[${i}] has no usable id or name \u2014 dropped.`); @@ -283,7 +308,7 @@ function normalizeBrief(data, warn = () => { } seen.add(id); const def = { id, name: rawName || id }; - const description = line(m.description); + const description = line2(m.description); if (description) def.description = description; const deps = arr(m.dependsOn, `modules[${i}].dependsOn`).map(slugId); if (deps.length) def.dependsOn = deps; @@ -314,7 +339,7 @@ function normalizeBrief(data, warn = () => { warn("featureWishlist is not an array \u2014 ignored."); } else if (Array.isArray(d.featureWishlist)) { d.featureWishlist.forEach((f, i) => { - const title = line(f?.title); + const title = line2(f?.title); if (!title) { warn(`featureWishlist[${i}] has no usable title \u2014 dropped.`); return; @@ -325,13 +350,13 @@ function normalizeBrief(data, warn = () => { priority = void 0; } let module; - const rawModule = line(f.module); + const rawModule = line2(f.module); if (rawModule) { const slug = slugId(rawModule); if (moduleIds.has(slug)) module = slug; else warn(`featureWishlist[${i}].module "${rawModule}" names no declared module \u2014 dropped.`); } - features.push({ title, priority, notes: line(f.notes), ...module ? { module } : {} }); + features.push({ title, priority, notes: line2(f.notes), ...module ? { module } : {} }); }); } let design; @@ -343,9 +368,9 @@ function normalizeBrief(data, warn = () => { const out = {}; const platforms = arr(dd.platforms, "design.platforms"); const referenceSystems = arr(dd.referenceSystems, "design.referenceSystems"); - const brand = line(dd.brandConstraints); - const a11y = line(dd.accessibilityTarget); - const tone = line(dd.tone); + const brand = line2(dd.brandConstraints); + const a11y = line2(dd.accessibilityTarget); + const tone = line2(dd.tone); if (platforms.length) out.platforms = platforms; if (referenceSystems.length) out.referenceSystems = referenceSystems; if (brand) out.brandConstraints = brand; @@ -356,21 +381,16 @@ function normalizeBrief(data, warn = () => { } return { schemaVersion: typeof d.schemaVersion === "number" ? d.schemaVersion : BRIEF_SCHEMA_VERSION, - idea: line(d.idea) ?? "", + idea: line2(d.idea) ?? "", product: { - name: line(d.product?.name), - problem: line(d.product?.problem), + name: line2(d.product?.name), + problem: line2(d.product?.problem), users: arr(d.product?.users, "product.users"), - valueProp: line(d.product?.valueProp) + valueProp: line2(d.product?.valueProp) }, goals: arr(d.goals, "goals"), nonGoals: arr(d.nonGoals, "nonGoals"), - constraints: { - budget: line(d.constraints?.budget), - timeline: line(d.constraints?.timeline), - team: line(d.constraints?.team), - compliance: arr(d.constraints?.compliance, "constraints.compliance") - }, + constraints: normalizeConstraints(d.constraints, line2, arr, warn), candidateTech: arr(d.candidateTech, "candidateTech"), competitors: arr(d.competitors, "competitors"), ossSeeds: arr(d.ossSeeds, "ossSeeds"), @@ -415,2537 +435,2868 @@ function validateBrief(brief) { return { ok: errors.length === 0, errors, warnings }; } -// src/research/registry.ts -import { join as join6 } from "path"; +// src/brainstorm.ts +import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs"; +import { join as join2 } from "path"; -// src/research/fetch.ts -var UA = "construct/0.x (+https://github.com/maxgfr/construct)"; -var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"; -function transient(status) { - return status === 0 || status === 429 || status >= 500; -} -async function httpGet(url, opts = {}) { - const retries = opts.retries ?? 1; - const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); - let last = { ok: false, status: 0, body: "", contentType: "", error: "unreached" }; - for (let attempt = 0; attempt <= retries; attempt++) { - last = await httpGetOnce(url, opts); - if (last.ok || !transient(last.status)) return last; - if (attempt === retries) break; - const retryAfterS = Number(last.retryAfter); - const delay = last.status === 429 && Number.isFinite(retryAfterS) && retryAfterS > 0 ? Math.min(retryAfterS * 1e3, RETRY_AFTER_CAP_MS) : RETRY_BASE_DELAY_MS * 2 ** attempt + Math.random() * RETRY_JITTER_MS; - await sleep(delay); +// src/templates.ts +var BRAINSTORM_ANGLE_ORDER = [ + { angle: "reframe", label: "Problem reframes" }, + { angle: "segment", label: "User segments" }, + { angle: "feature", label: "Feature ideas" }, + { angle: "differentiator", label: "Differentiators" }, + { angle: "anti-goal", label: "Anti-goals & risks" }, + { angle: "wildcard", label: "Wildcards" } +]; +function renderBrainstormMd(b) { + const out = []; + out.push(`# Brainstorm \u2014 ${b.idea || "(idea)"}`); + out.push(""); + out.push( + `Divergent ideas for this product. Mark each idea's \`status\` in \`brainstorm.json\` (**proposed** \u2192 **kept** / **parked** / **rejected**); give every **kept** idea a \`target\` (featureWishlist \xB7 competitors \xB7 nonGoals \xB7 goals \xB7 candidateTech \xB7 openQuestions), then run \`construct brainstorm --out --merge\` to fold them into brief.json. **Parked** ideas become \u{1F9E0} open questions that BLOCK the structural gate until resolved.` + ); + out.push(""); + for (const { angle, label } of BRAINSTORM_ANGLE_ORDER) { + const ideas = b.ideas.filter((i) => i.angle === angle); + if (!ideas.length) continue; + out.push(`## ${label}`); + out.push(""); + for (const i of ideas) { + const tgt = i.target ? ` \u2192 ${i.target}${i.priority ? ` (${i.priority})` : ""}` : ""; + const notes = i.notes ? ` \u2014 ${i.notes}` : ""; + const mergedMark = i.mergedAt ? " \u2713merged" : ""; + out.push(`- **[${i.status}]** ${i.id} \u2014 ${i.title}${tgt}${notes}${mergedMark}`); + } + out.push(""); } - return last; + if (!b.ideas.length) out.push("_No ideas yet \u2014 generate some with the AI (references/brainstorm-playbook.md)._"); + return out.join("\n"); } -async function httpGetOnce(url, opts) { - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? HTTP_GET_TIMEOUT_MS); - try { - const res = await fetch(url, { - signal: ctrl.signal, - redirect: "follow", - headers: { "user-agent": UA, accept: opts.accept ?? "*/*", ...opts.headers ?? {} } - }); - const buf = Buffer.from(await res.arrayBuffer()); - const max = opts.maxBytes ?? 4 * 1024 * 1024; - return { - ok: res.ok, - status: res.status, - body: buf.subarray(0, max).toString("utf8"), - contentType: res.headers.get("content-type") ?? "", - retryAfter: res.headers.get("retry-after") ?? void 0 - }; - } catch (e) { - return { ok: false, status: 0, body: "", contentType: "", error: e.message }; - } finally { - clearTimeout(t); - } +function cite(ids) { + if (!ids || ids.length === 0) return ""; + return " " + ids.map((id) => `[${id}]`).join(""); } -async function httpJson(method, url, body, opts = {}) { - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? HTTP_JSON_TIMEOUT_MS); - try { - const res = await fetch(url, { - method, - signal: ctrl.signal, - headers: { "content-type": "application/json", accept: "application/json", "user-agent": UA }, - body: body === void 0 ? void 0 : JSON.stringify(body) - }); - const text = await res.text(); - let data; - try { - data = text ? JSON.parse(text) : void 0; - } catch { - data = text; - } - return { ok: res.ok, status: res.status, data }; - } catch (e) { - return { ok: false, status: 0, data: void 0, error: e.message }; - } finally { - clearTimeout(t); - } +function cell(s) { + return String(s).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim(); } -var NAMED = { - amp: "&", - lt: "<", - gt: ">", - quot: '"', - apos: "'", - nbsp: " ", - mdash: "\u2014", - ndash: "\u2013", - hellip: "\u2026", - copy: "\xA9" -}; -function htmlToText(html) { - let s = html; - s = s.replace(//g, " "); - s = s.replace(/<(script|style|noscript|head|nav|footer|svg)[\s\S]*?<\/\1>/gi, " "); - s = s.replace(/<\/(p|div|section|article|li|tr|td|th|ul|ol|h[1-6]|pre|blockquote)>/gi, "\n"); - s = s.replace(/<(p|div|section|article|li|tr|td|th|ul|ol|h[1-6]|pre|blockquote|table)\b[^>]*>/gi, "\n"); - s = s.replace(/<(br|hr)\s*\/?>/gi, "\n"); - s = s.replace(/<[^>]+>/g, " "); - s = s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp|mdash|ndash|hellip|copy);/gi, (m, g) => { - if (g[0] === "#") { - const n = g[1] === "x" || g[1] === "X" ? parseInt(g.slice(2), 16) : Number(g.slice(1)); - try { - return Number.isFinite(n) ? String.fromCodePoint(n) : " "; - } catch { - return " "; - } - } - return NAMED[g.toLowerCase()] ?? m; - }); - s = s.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n"); - return s.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).join("\n"); +function slugTitle(s) { + return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "decision"; } -async function fetchAndExtract(url) { - let res = await httpGet(url, { accept: "text/html,text/plain,*/*" }); - if (!res.ok && (res.status === 403 || res.status === 429)) { - res = await httpGet(url, { - accept: "text/html,application/xhtml+xml,*/*", - headers: { "user-agent": BROWSER_UA, "accept-language": "en-US,en;q=0.9" } - }); - } - if (!res.ok) { - return { text: "", note: `Could not fetch ${url} (status ${res.status}${res.error ? ", " + res.error : ""}).` }; - } - const isHtml = /html/i.test(res.contentType) || /^\s* `- ${i}`).join("\n"); } -function excerptsFromText(text, url, title, source, question, perSource) { - const lines = text.split("\n"); - const questions = (Array.isArray(question) ? question : [question]).filter((q) => q.trim()); - const kwSets = questions.map((q) => keywords(q).map((k) => k.toLowerCase())); - const hits = []; - for (let i = 0; i < lines.length; i++) { - const low = lines[i].toLowerCase(); - let cov = 0; - for (const kws of kwSets) { - let c = 0; - for (const kw of kws) if (low.includes(kw)) c++; - if (kws.length && c > cov) cov = c; - } - if (cov > 0) hits.push({ idx: i, cov }); - } - hits.sort((a, b) => b.cov - a.cov || a.idx - b.idx); - const items = []; - const ranges = []; - const take = hits.length ? hits : [{ idx: 0, cov: 0 }]; - const perDoc = Math.min(2, Math.max(1, perSource)); - for (const h of take) { - if (items.length >= perDoc) break; - const start = Math.max(0, h.idx - 3); - const end = Math.min(lines.length, h.idx + 12); - if (ranges.some((r) => start < r.end && end > r.start)) continue; - ranges.push({ start, end }); - const snippet = lines.slice(start, end).join("\n").slice(0, 1500); - if (!snippet.trim()) continue; - items.push({ - source, - // Disambiguate the second+ excerpt of one page by its line range, so two - // excerpts of the same URL don't render identical titles. - title: items.length === 0 ? title : `${title} (lines ${start + 1}\u2013${end})`, - ref: url, - location: `${url}#~${start + 1}`, - score: Number((h.cov + 1).toFixed(3)), - snippet, - url - }); +function renderVision(srd) { + const p = srd.product; + return [ + `# Vision`, + ``, + `**Product:** ${p.name}`, + ``, + `## Problem`, + p.problem, + ``, + `## Target users`, + bullets(p.users, "No users captured."), + ``, + `## Value proposition`, + p.valueProp, + ``, + `## Success metrics`, + bullets(p.metrics, "Define a measurable launch success metric."), + `` + ].join("\n"); +} +function renderScope(srd) { + const lines = [ + `# Scope`, + ``, + `## In scope`, + bullets(srd.scope.inScope, "No in-scope items captured."), + ``, + `## Out of scope`, + bullets(srd.scope.outOfScope, "Nothing explicitly excluded yet."), + ``, + `## Assumptions`, + bullets(srd.scope.assumptions, "No assumptions recorded."), + `` + ]; + if (srd.openQuestions.length) { + lines.push(`## Open decisions`, ``); + for (const q of srd.openQuestions) lines.push(`> \u{1F9E0} **Decide:** ${q}`, ``); } - return items; + return lines.join("\n"); } - -// src/research/web.ts -var SEARXNG_BASE = process.env.CONSTRUCT_SEARXNG || "http://localhost:8888"; -async function viaSearxng(query2, n) { - const url = `${SEARXNG_BASE.replace(/\/$/, "")}/search?q=${encodeURIComponent(query2)}&format=json`; - const r = await httpGet(url, { accept: "application/json", timeoutMs: SEARXNG_TIMEOUT_MS, retries: 0 }); - if (!r.ok) return null; - try { - const data = JSON.parse(r.body); - const urls = (data.results ?? []).map((x) => x.url).filter(Boolean); - return urls.slice(0, n); - } catch { - return null; +function renderFRBlock(fr) { + const out = [`## ${fr.id} \u2014 ${fr.title} _(${fr.priority})_${cite(fr.rationaleEvidence)}`, ``]; + out.push(fr.description); + out.push(``); + out.push(`**Acceptance criteria:**`); + for (const a of fr.acceptance) { + out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); } + out.push(``); + const trace = [ + `NFRs: ${fr.nfrs.length ? fr.nfrs.join(", ") : "\u2014"}`, + `entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`, + `interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}` + ].join(" \xB7 "); + out.push(`_Traceability \u2014 ${trace}_`); + out.push(``); + return out; } -async function viaDuckDuckGo(query2, n) { - const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query2)}`; - const r = await httpGet(url, { accept: "text/html", timeoutMs: DDG_TIMEOUT_MS }); - if (!r.ok || !r.body) return null; - const urls = []; - const tagRe = /]*\bresult__a\b[^>]*>/g; - let m; - while ((m = tagRe.exec(r.body)) && urls.length < n) { - const href0 = /\bhref="([^"]+)"/.exec(m[0]); - if (!href0) continue; - let href = href0[1]; - const uddg = /[?&]uddg=([^&]+)/.exec(href); - if (uddg) { - try { - href = decodeURIComponent(uddg[1]); - } catch { - } - } - if (/^https?:\/\//.test(href) && !/duckduckgo\.com/.test(href)) urls.push(href); +function renderFunctional(srd) { + if (srd.modules?.length) return renderFunctionalIndex(srd); + return renderFunctionalFull(srd); +} +function renderFunctionalFull(srd) { + const out = [`# Functional requirements`, ``]; + if (!srd.functional.length) out.push(`_No functional requirements defined._`, ``); + for (const fr of srd.functional) out.push(...renderFRBlock(fr)); + return out.join("\n"); +} +function renderFunctionalIndex(srd) { + const out = [`# Functional requirements`, ``]; + out.push(`_This SRD is partitioned into module PRDs \u2014 the full requirement blocks (description,`); + out.push(`acceptance criteria, traceability) live in each module's PRD under [../prd/](../prd/README.md)._`, ``); + out.push(`| Requirement | Title | Priority | Module | PRD |`); + out.push(`|---|---|---|---|---|`); + for (const fr of srd.functional) { + const link = fr.module ? `[../prd/${fr.module}/PRD.md](../prd/${fr.module}/PRD.md)` : "\u2014"; + out.push(`| ${fr.id} | ${cell(fr.title)} | ${fr.priority} | ${fr.module ?? "\u2014"} | ${link} |`); } - return urls.length ? urls : null; + out.push(``); + return out.join("\n"); } -async function discover(query2, engine, n) { - const notes = []; - if (engine === "searxng" || engine === "auto") { - const s = await viaSearxng(query2, n); - if (s?.length) return { urls: s, via: "searxng", notes }; - if (engine === "searxng") { - notes.push(s === null ? `SearXNG unreachable at ${SEARXNG_BASE}. Run \`construct semantic up\`.` : "SearXNG returned no results."); - } +function renderModulePRD(srd, m) { + const frs = srd.functional.filter((f) => f.module === m.id); + const others = (srd.modules ?? []).filter((o) => o.id !== m.id); + const frIdSet = new Set(frs.map((f) => f.id)); + const out = [`# PRD \u2014 ${m.name}`, ``]; + out.push(`_Module \`${m.id}\` \xB7 ${srd.product.name} \xB7 ${frs.length} requirement(s)_`, ``); + if (m.description) out.push(m.description, ``); + out.push( + `**Global context:** [Vision](../../00-overview/VISION.md) \xB7 [Scope](../../00-overview/SCOPE.md) \xB7 [Non-functional requirements](../../requirements/NON-FUNCTIONAL.md) \xB7 [Data model](../../architecture/DATA-MODEL.md) \xB7 [Interfaces](../../architecture/INTERFACES.md) \xB7 [Traceability](../../TRACEABILITY.md)`, + `` + ); + out.push(`## Scope`, ``); + out.push(`**In scope:** ${frs.length ? frs.map((f) => f.id).join(", ") : "\u2014"}.`, ``); + if (others.length) { + out.push(`**Out of scope** (owned by other modules): ${others.map((o) => `[${o.name}](../${o.id}/PRD.md)`).join(", ")}.`, ``); } - if (engine === "ddg" || engine === "auto") { - const d = await viaDuckDuckGo(query2, n); - if (d?.length) return { urls: d, via: "duckduckgo", notes }; - if (engine === "ddg") notes.push("DuckDuckGo returned no results."); + out.push(`## Requirements`, ``); + if (!frs.length) out.push(`_No requirements assigned to this module._`, ``); + for (const fr of frs) out.push(...renderFRBlock(fr)); + const nfrIds = new Set(frs.flatMap((f) => f.nfrs)); + const nfrs = srd.nonFunctional.filter((n) => nfrIds.has(n.id)); + out.push(`## Non-functional requirements`, ``); + if (nfrs.length) { + out.push(`_Applying to this module's requirements \u2014 full statements in [NON-FUNCTIONAL.md](../../requirements/NON-FUNCTIONAL.md)._`, ``); + out.push(`| NFR | Category | Metric |`, `|---|---|---|`); + for (const n of nfrs) out.push(`| ${n.id} | ${cell(n.category)} | ${cell(n.metric ?? "\u2014")} |`); + } else { + out.push(`_None linked._`); } - if (engine === "claude" || engine === "auto") { - notes.push( - "No keyless engine returned results. Use your built-in WebSearch to find URLs, then ground them with `construct web --url --out `." - ); + out.push(``); + const entities = srd.architecture.dataModel.filter((e) => e.referencedByFRs.some((id) => frIdSet.has(id))); + out.push(`## Data model (module slice)`, ``); + if (entities.length) { + out.push(`| Entity | Referenced by |`, `|---|---|`); + for (const e of entities) out.push(`| ${cell(e.name)} | ${e.referencedByFRs.filter((id) => frIdSet.has(id)).join(", ")} |`); + } else { + out.push(`_No entities touch this module yet._`); } - return { urls: [], via: "none", notes }; -} -async function webFetchUrls(urls, question, perSource, source = "market", fetchAll = false) { - const items = []; - const notes = []; - const toFetch = fetchAll ? urls : urls.slice(0, Math.max(1, Math.ceil(perSource / 2))); - for (const url of toFetch) { - const { text, note } = await fetchAndExtract(url); - if (note) notes.push(note); - if (!text) continue; - const ex = excerptsFromText(text, url, `${labelFor(source)} \u2014 ${url}`, source, question, perSource); - items.push( - ...ex.length ? ex : [ - { - source, - title: `${labelFor(source)} \u2014 ${url}`, - ref: url, - location: url, - score: 0, - snippet: text.slice(0, 800), - url - } - ] - ); + out.push(``); + const ifaces = srd.architecture.interfaces.filter((i) => i.relatedFRs.some((id) => frIdSet.has(id))); + out.push(`## Interfaces (module slice)`, ``); + if (ifaces.length) { + out.push(`| Interface | Kind | Related |`, `|---|---|---|`); + for (const i of ifaces) out.push(`| ${cell(i.name)} | ${i.kind} | ${i.relatedFRs.filter((id) => frIdSet.has(id)).join(", ")} |`); + } else { + out.push(`_No interfaces touch this module yet._`); } - return { items, notes }; + out.push(``); + out.push(`## Dependencies`, ``); + const declared = m.dependsOn.map((dep) => { + const d = others.find((o) => o.id === dep); + return d ? `[${d.name}](../${d.id}/PRD.md)` : dep; + }); + const shared = []; + for (const o of others) { + const oSet = new Set(o.frIds); + const names = entities.filter((e) => e.referencedByFRs.some((id) => oSet.has(id))).map((e) => e.name); + if (names.length) shared.push(`shares ${names.join(", ")} with [${o.name}](../${o.id}/PRD.md)`); + } + if (!declared.length && !shared.length) out.push(`_None._`); + if (declared.length) out.push(`- **Declared:** depends on ${declared.join(", ")}.`); + for (const s of shared) out.push(`- **Derived (shared data):** ${s}.`); + out.push(``); + return out.join("\n"); } -function labelFor(source) { - return source === "docs" ? "Docs" : source === "oss" ? "OSS" : "Web"; +function renderModulePrdIndex(srd) { + const out = [`# Module PRDs`, ``]; + out.push(`One PRD per product module, rendered from SRD.json. Cross-module docs (vision, scope,`); + out.push(`NFRs, architecture, ADRs, traceability) live at the SRD root; the cross-module requirement`); + out.push(`index is [../requirements/FUNCTIONAL.md](../requirements/FUNCTIONAL.md).`, ``); + out.push(`| Module | PRD | Requirements | Depends on |`); + out.push(`|---|---|---|---|`); + for (const m of srd.modules ?? []) { + out.push(`| ${cell(m.name)} | [${m.id}/PRD.md](${m.id}/PRD.md) | ${m.frIds.join(", ") || "\u2014"} | ${m.dependsOn.join(", ") || "\u2014"} |`); + } + out.push(``); + return out.join("\n"); } - -// src/research/market.ts -async function marketAngle(ctx) { - const b = ctx.brief; - const query2 = ctx.query || [b.idea, b.competitors.join(" "), "competitors alternatives market"].filter(Boolean).join(" ").trim(); - const items = []; - const notes = []; - const pinned = ctx.marketUrls ?? []; - const questions = [query2, ...b.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`.trim())].filter(Boolean); - if (pinned.length) { - const f = await webFetchUrls(pinned, questions.length ? questions : pinned.join(" "), ctx.perSource, "market", true); - items.push(...f.items.slice(0, ctx.perSource)); - notes.push(`Pinned ${pinned.length} market URL(s) via --url.`, ...f.notes); +function renderFeaturePRD(fr, srd) { + const out = [`# PRD ${fr.id} \u2014 ${fr.title}${cite(fr.rationaleEvidence)}`, ``]; + out.push(`_Priority: ${fr.priority}_ \xB7 _Product: ${srd.product.name}_`, ``); + out.push(`## Context`, ``, srd.product.problem, ``); + out.push(`## Feature`, ``, fr.description, ``); + out.push(`## Acceptance criteria`, ``); + for (const a of fr.acceptance) { + out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); } - if (!query2) { - if (items.length) return [{ source: "market", items, notes }]; - return [{ source: "market", items: [], notes: ["No idea/competitors to search the market for."] }]; + out.push(``, `## Non-functional requirements`, ``); + if (!fr.nfrs.length) out.push(`_None linked._`); + for (const id of fr.nfrs) { + const nfr = srd.nonFunctional.find((n) => n.id === id); + out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` \u2014 metric: ${nfr.metric}` : ""}` : `- **${id}**`); } - const budget = ctx.perSource - items.length; - if (budget > 0) { - const { urls, via, notes: discoveryNotes } = await discover(query2, ctx.webEngine, budget); - if (urls.length === 0) { - notes.push(`Market discovery via ${via}.`, ...discoveryNotes); - } else { - const fetched = await webFetchUrls(urls, questions, budget, "market"); - items.push(...fetched.items); - notes.push(`Market discovery via ${via} for "${query2}".`, ...discoveryNotes, ...fetched.notes); - } + out.push(``, `## Data & interfaces`, ``); + out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`); + out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}`); + out.push(``, `## Grounding`, ``); + out.push( + fr.rationaleEvidence.length ? `Evidence:${cite(fr.rationaleEvidence)} \u2014 see ../../evidence/EVIDENCE.md.` : `_Ungrounded \u2014 see the grounding report (construct check)._` + ); + out.push(``); + return out.join("\n"); +} +function renderPRDIndex(srd) { + const out = [`# PRDs \u2014 one per functional requirement`, ``]; + out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`); + out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``); + out.push(`| PRD | Priority | Title |`); + out.push(`|---|---|---|`); + for (const fr of srd.functional) { + const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`; + out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`); } - return [{ source: "market", items, notes }]; + out.push(``); + return out.join("\n"); } - -// src/clone.ts -import { existsSync as existsSync2, statSync, mkdirSync as mkdirSync2, readdirSync, rmSync } from "fs"; -import { resolve, join as join2, basename } from "path"; -import { tmpdir } from "os"; -function cacheRoot() { - return join2(tmpdir(), "construct"); +function renderNonFunctional(srd) { + const out = [`# Non-functional requirements`, ``]; + if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``); + for (const n of srd.nonFunctional) { + out.push(`## ${n.id} \u2014 ${n.category}${cite(n.rationaleEvidence)}`); + out.push(``); + out.push(n.statement); + if (n.metric) out.push(``, `- **Metric:** ${n.metric}`); + out.push(``); + } + return out.join("\n"); } -function resolveRepo(raw) { - const trimmed = raw.trim(); - if (trimmed) { - const asPath = resolve(trimmed); - if (existsSync2(asPath) && statSync(asPath).isDirectory()) { - return { - raw: trimmed, - host: "local", - isLocal: true, - slug: "local-" + slugify(basename(asPath) + "-" + asPath) - }; +function renderSystemContext(srd) { + return [`# System context`, ``, srd.architecture.context, ``].join("\n"); +} +function renderDataModel(srd) { + const out = [`# Data model`, ``]; + const entities = srd.architecture.dataModel; + if (!entities.length) { + out.push(`_No entities defined yet. Enrich during authoring: list entities, their attributes, and which functional requirements reference each._`, ``); + return out.join("\n"); + } + out.push(`_Seeded by inference from the brief \u2014 verify each entity and extend attributes during authoring._`, ``); + for (const e of entities) { + out.push(`## ${e.name}`); + out.push(``); + if (e.attributes.length) { + out.push(`| Attribute | Type |`, `|---|---|`); + for (const a of e.attributes) out.push(`| ${cell(a.name)} | ${cell(a.type)} |`); } + out.push(``, `_Referenced by: ${e.referencedByFRs.length ? e.referencedByFRs.join(", ") : "\u2014"}_`, ``); } - let host; - let path; - const scp = /^git@([^:]+):(.+)$/.exec(trimmed); - const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed); - const hostPath = /^([a-z0-9.-]+\.[a-z]{2,})\/(.+)$/i.exec(trimmed); - if (scp) { - host = scp[1]; - path = scp[2]; - } else if (url) { - host = url[1]; - path = url[2]; - } else if (hostPath) { - host = hostPath[1]; - path = hostPath[2]; - } else if (/^[\w.-]+\/[\w.-]+$/.test(trimmed)) { - host = "github.com"; - path = trimmed; - } else { - return { raw: trimmed, host: "generic", isLocal: false, slug: slugify(trimmed) || "seed" }; + return out.join("\n"); +} +function renderInterfaces(srd) { + const out = [`# Interfaces`, ``]; + const ifaces = srd.architecture.interfaces; + if (!ifaces.length) { + out.push(`_No interfaces defined yet. Enrich during authoring: list the API/event/UI/CLI surfaces and the functional requirements each serves._`, ``); + return out.join("\n"); } - host = host.toLowerCase(); - path = path.replace(/\.git$/, "").replace(/\/+$/, ""); - const segments = path.split("/").filter(Boolean); - const repo = segments.length ? segments[segments.length - 1] : void 0; - const owner = segments.length > 1 ? segments.slice(0, -1).join("/") : void 0; - const cloneUrl = /^https?:\/\//i.test(trimmed) || scp ? trimmed.replace(/\/+$/, "") : `https://${host}/${path}.git`; - const webUrl = `https://${host}/${path}`; - return { - raw: trimmed, - host, - owner, - repo, - cloneUrl: cloneUrl.endsWith(".git") ? cloneUrl : `${cloneUrl}.git`, - webUrl, - isLocal: false, - slug: slugify(`${host}/${path}`) - }; + out.push(`_Seeded by inference from the brief \u2014 verify each surface and define its contract during authoring._`, ``); + for (const i of ifaces) { + out.push(`## ${i.name} _(${i.kind})_`, ``, i.summary, ``, `_Related: ${i.relatedFRs.length ? i.relatedFRs.join(", ") : "\u2014"}_`, ``); + } + return out.join("\n"); } -function ensureClone(ref, opts = {}) { - if (ref.isLocal) return resolve(ref.raw); - const dir = join2(cacheRoot(), ref.slug); - const alreadyCloned = existsSync2(join2(dir, ".git")); - if (alreadyCloned && !opts.refresh) return dir; - if (alreadyCloned && opts.refresh) { - sh("git", ["-C", dir, "fetch", "--depth", "1", "origin"], { timeoutMs: GIT_FETCH_TIMEOUT_MS }); - sh("git", ["-C", dir, "reset", "--hard", "FETCH_HEAD"], { timeoutMs: GIT_RESET_TIMEOUT_MS }); - return dir; +function renderADR(adr) { + const out = [ + `# ${adr.id}. ${adr.title}`, + ``, + `- **Status:** ${adr.status}`, + ``, + `## Context`, + adr.context, + ``, + `## Decision`, + `${adr.decision}${cite(adr.evidence)}`, + ``, + `## Consequences`, + adr.consequences, + `` + ]; + if (adr.alternatives) out.push(`## Alternatives considered`, adr.alternatives, ``); + return out.join("\n"); +} +function renderLandscape(srd) { + const out = [`# Competitive landscape`, ``, `## Competitors`, ``]; + if (srd.competitive.competitors.length) { + out.push(`| Product | Note | Evidence |`, `|---|---|---|`); + for (const c of srd.competitive.competitors) { + const ev = c.evidence.length ? c.evidence.map((id) => `[${id}]`).join("") : "_ungrounded_"; + out.push(`| ${cell(c.name)} | ${cell(c.note)} | ${ev} |`); + } + } else { + out.push(`_No competitors captured. Use the market research angle to discover them._`); } - mkdirSync2(cacheRoot(), { recursive: true }); - const args = ["clone", "--depth", "1", "--filter=blob:none"]; - if (opts.branch) args.push("--branch", opts.branch); - args.push(ref.cloneUrl, dir); - const res = sh("git", args, { timeoutMs: GIT_CLONE_TIMEOUT_MS }); - if (!res.ok) { - if (res.missing) { - throw new Error(`git is not installed or not on PATH \u2014 cannot clone ${ref.cloneUrl}`); + out.push(``, `## Comparable open-source projects`, ``); + if (srd.competitive.oss.length) { + out.push(`| Project | Note | Evidence |`, `|---|---|---|`); + for (const o of srd.competitive.oss) { + const name = o.url ? `[${cell(o.name)}](${o.url})` : cell(o.name); + const ev = o.evidence.length ? o.evidence.map((id) => `[${id}]`).join("") : "_ungrounded_"; + out.push(`| ${name} | ${cell(o.note)} | ${ev} |`); } - if (existsSync2(dir)) { - try { - rmSync(dir, { recursive: true, force: true }); - } catch (e) { - throw new Error(`could not remove the partial clone at ${dir} before retrying: ${e.message} \u2014 delete it manually and re-run`); - } + } else { + out.push(`_No OSS prior art captured. Use the oss research angle to mine comparable projects._`); + } + out.push(``); + return out.join("\n"); +} +function renderBuildPlan(srd) { + const out = [`# Build plan`, ``]; + for (const m of srd.buildPlan) { + out.push(`## ${m.title}`, ``, m.outcome, ``); + out.push(`- **Requirements:** ${m.frIds.length ? m.frIds.join(", ") : "\u2014"}`); + if (m.risks.length) { + out.push(`- **Risks:**`); + for (const r of m.risks) out.push(` - ${r}`); } - const fallback = sh("git", ["clone", "--depth", "1", ...opts.branch ? ["--branch", opts.branch] : [], ref.cloneUrl, dir], { - timeoutMs: GIT_CLONE_TIMEOUT_MS - }); - if (!fallback.ok) { - throw new Error( - [ - `git clone failed for ${ref.cloneUrl}`, - ` attempt 1 (--filter=blob:none): ${res.stderr.trim() || `exit ${res.status}`}`, - ` attempt 2 (no filter): ${fallback.stderr.trim() || `exit ${fallback.status}`}` - ].join("\n") - ); + out.push(``); + } + return out.join("\n"); +} +function renderTraceability(srd) { + const design = !!srd.design; + const modules = !!srd.modules?.length; + const cols = ["Requirement", ...modules ? ["Module"] : [], "NFRs", "ADRs", "Entities", "Interfaces", ...design ? ["Components", "Screens"] : []]; + const out = [`# Traceability matrix`, ``, `| ${cols.join(" | ")} |`, `|${cols.map(() => "---").join("|")}|`]; + for (const r of srd.traceability) { + const cells = [ + r.fr, + ...modules ? [r.module ?? "\u2014"] : [], + r.nfrs.join(", ") || "\u2014", + r.adrs.join(", ") || "\u2014", + r.entities.join(", ") || "\u2014", + r.interfaces.join(", ") || "\u2014" + ]; + if (design) { + cells.push((r.components ?? []).map(cell).join(", ") || "\u2014"); + cells.push((r.screens ?? []).map(cell).join(", ") || "\u2014"); } + out.push(`| ${cells.join(" | ")} |`); } - if (!existsSync2(dir) || readdirSync(dir).length === 0) { - throw new Error(`clone produced an empty tree at ${dir}`); + out.push(``); + return out.join("\n"); +} +function renderDesignPrinciples(ds) { + return [ + `# Design principles`, + ``, + bullets(ds.principles, "No design principles captured."), + ``, + `## Content & voice`, + ``, + bullets(ds.contentVoice, "No content guidelines captured."), + `` + ].join("\n"); +} +function renderDesignTokens(ds) { + const out = [`# Design tokens`, ``, `_${DESIGN_TOKENS_SEEDED_BANNER}_`, ``]; + const cats = [...new Set(ds.tokens.map((t) => t.category))]; + for (const cat of cats) { + const toks = ds.tokens.filter((t) => t.category === cat); + out.push(`## ${cell(cat)}`, ``, `| Token | Value | Notes |`, `|---|---|---|`); + for (const t of toks) out.push(`| ${cell(t.name)} | ${cell(t.value)} | ${cell(t.note ?? "")} |`); + out.push(``); } - return dir; + out.push("> The machine-readable token set is in `design/design-tokens.json`.", ``); + return out.join("\n"); +} +function renderDesignTokensJson(ds) { + const obj = {}; + for (const t of ds.tokens) { + (obj[t.category] ??= {})[t.name] = t.value; + } + return JSON.stringify(obj, null, 2); +} +function renderComponents(ds) { + const out = [`# Components`, ``]; + if (!ds.components.length) { + out.push(`_No components defined yet. Enrich during authoring: name each component, its states and the requirements it realises._`, ``); + return out.join("\n"); + } + out.push(`_Seeded from the functional requirements \u2014 verify each component and its states during authoring._`, ``); + for (const c of ds.components) { + out.push(`## ${c.name}${cite(c.evidence)}`, ``, c.purpose, ``); + out.push(`- **States:** ${c.states.join(", ") || "\u2014"}`); + out.push(`- **Realises:** ${c.relatedFRs.length ? c.relatedFRs.join(", ") : "\u2014"}`, ``); + } + return out.join("\n"); +} +function renderScreens(ds) { + const out = [`# Screens & flows`, ``, `## Screens`, ``]; + if (ds.screens.length) { + out.push(`| Screen | Purpose | Requirements |`, `|---|---|---|`); + for (const s of ds.screens) out.push(`| ${cell(s.name)} | ${cell(s.purpose)} | ${s.relatedFRs.join(", ") || "\u2014"} |`); + } else { + out.push(`_No screens defined._`); + } + out.push(``, `## User flows`, ``); + if (ds.flows.length) { + for (const f of ds.flows) { + out.push(`### ${f.name}${f.frIds.length ? ` _(${f.frIds.join(", ")})_` : ""}`, ``); + f.steps.forEach((step, i) => out.push(`${i + 1}. ${step}`)); + out.push(``); + } + } else { + out.push(`_No user flows defined._`); + } + return out.join("\n"); +} +function renderAccessibility(ds) { + const a = ds.accessibility; + const out = [`# Accessibility`, ``, `**Target standard:** ${a.standard}`, ``]; + if (!a.requirements.length) { + out.push(`_No accessibility requirements defined._`, ``); + return out.join("\n"); + } + for (const r of a.requirements) { + out.push(`## ${r.id} \u2014 ${r.statement}`, ``, `**Acceptance criteria:**`); + for (const c of r.acceptance) out.push(`- **Given** ${c.given} **When** ${c.when} **Then** ${c.then}`); + out.push(``); + } + return out.join("\n"); +} +function renderMergeBundle(srd) { + const parts = [ + `# Software Requirements Document \u2014 ${srd.product.name}`, + ``, + `_Level: ${srd.level} \xB7 generated: ${srd.generatedAt}_`, + ``, + renderVision(srd), + renderScope(srd), + // Always the full FR blocks: the bundle is the one-file reading copy, so it + // must stay complete even when FUNCTIONAL.md is an index (modules mode). + renderFunctionalFull(srd), + renderNonFunctional(srd), + renderSystemContext(srd), + renderDataModel(srd), + renderInterfaces(srd), + `# Architecture decisions`, + ``, + ...srd.architecture.adrs.map(renderADR), + ...srd.design ? [ + `# Design system`, + ``, + renderDesignPrinciples(srd.design), + renderDesignTokens(srd.design), + renderComponents(srd.design), + renderScreens(srd.design), + renderAccessibility(srd.design) + ] : [], + renderLandscape(srd), + renderBuildPlan(srd), + renderTraceability(srd) + ]; + return parts.join("\n"); } -// src/walk.ts -import { readdirSync as readdirSync2, lstatSync, readFileSync as readFileSync2 } from "fs"; -import { join as join3, relative, sep, extname } from "path"; -var IGNORE_DIRS = /* @__PURE__ */ new Set([ - ".git", - "node_modules", - ".pnpm", - "bower_components", - "vendor", - "dist", - "build", - "out", - "target", - ".next", - ".nuxt", - ".svelte-kit", - ".turbo", - "coverage", - "__pycache__", - ".venv", - "venv", - ".tox", - ".mypy_cache", - ".pytest_cache", - ".gradle", - ".idea", - ".vscode", - ".cache", - "tmp", - ".construct", - "Pods", - "DerivedData", - ".terraform", - "elm-stuff", - ".dart_tool" -]); -var LOCKFILES = /* @__PURE__ */ new Set([ - "package-lock.json", - "npm-shrinkwrap.json", - "yarn.lock", - "pnpm-lock.yaml", - "bun.lockb", - "composer.lock", - "cargo.lock", - "poetry.lock", - "pipfile.lock", - "gemfile.lock", - "go.sum", - "flake.lock", - "packages.lock.json", - "podfile.lock", - "mix.lock" -]); -var BINARY_EXT = /* @__PURE__ */ new Set([ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".bmp", - ".ico", - ".icns", - ".svg", - ".pdf", - ".zip", - ".gz", - ".tar", - ".tgz", - ".bz2", - ".xz", - ".7z", - ".rar", - ".jar", - ".war", - ".class", - ".so", - ".dylib", - ".dll", - ".exe", - ".bin", - ".o", - ".a", - ".wasm", - ".woff", - ".woff2", - ".ttf", - ".otf", - ".eot", - ".mp3", - ".mp4", - ".mov", - ".avi", - ".webm", - ".wav", - ".flac", - ".ogg", - ".lock", - ".min.js", - ".map" -]); -function walk(root, opts = {}) { - const maxFileBytes = opts.maxFileBytes ?? 1024 * 1024; - const maxFiles = opts.maxFiles ?? 2e4; - const out = []; - const stack = [root]; - while (stack.length) { - if (out.length >= maxFiles) break; - const dir = stack.pop(); - let entries; - try { - entries = readdirSync2(dir); - } catch { - continue; - } - for (const name of entries) { - if (out.length >= maxFiles) break; - const abs = join3(dir, name); - let st; - try { - st = lstatSync(abs); - } catch { - continue; - } - if (st.isDirectory()) { - if (IGNORE_DIRS.has(name)) continue; - stack.push(abs); - continue; - } - if (!st.isFile()) continue; - if (st.size > maxFileBytes) continue; - if (LOCKFILES.has(name.toLowerCase())) continue; - const ext = extname(name).toLowerCase(); - if (BINARY_EXT.has(ext)) continue; - if (name.endsWith(".min.js") || name.endsWith(".min.css")) continue; - out.push({ rel: relative(root, abs).split(sep).join("/"), abs, size: st.size, ext }); - } - } - return out; +// src/brainstorm.ts +var ANGLES = ["reframe", "segment", "feature", "differentiator", "anti-goal", "wildcard"]; +var STATUSES = ["proposed", "kept", "parked", "rejected"]; +var TARGETS = ["featureWishlist", "competitors", "nonGoals", "goals", "candidateTech", "openQuestions"]; +function brainstormPath(runDir) { + return join2(runDir, "brainstorm.json"); +} +function initBrainstorm(idea, now) { + return { schemaVersion: BRAINSTORM_SCHEMA_VERSION, idea: idea.trim(), createdAt: now, ideas: [] }; +} +function saveBrainstorm(runDir, b) { + mkdirSync2(runDir, { recursive: true }); + const path = brainstormPath(runDir); + writeFileSync2(path, JSON.stringify(b, null, 2)); + return path; } -function readText(abs) { +function writeBrainstormMd(runDir, b) { + mkdirSync2(runDir, { recursive: true }); + const path = join2(runDir, "BRAINSTORM.md"); + const md = renderBrainstormMd(b); + writeFileSync2(path, md.endsWith("\n") ? md : md + "\n"); + return path; +} +function brainstormCounts(b) { + const counts = { proposed: 0, kept: 0, parked: 0, rejected: 0 }; + for (const i of b.ideas) if (counts[i.status] !== void 0) counts[i.status]++; + return counts; +} +var line = (v) => typeof v === "string" ? v.replace(/\s+/g, " ").trim() : void 0; +function loadBrainstorm(runDir, warn = () => { +}) { + const path = brainstormPath(runDir); + if (!existsSync2(path)) return void 0; + let data; try { - const buf = readFileSync2(abs); - const head = buf.subarray(0, 4096); - if (head.includes(0)) return ""; - return buf.toString("utf8"); - } catch { - return ""; + data = JSON.parse(readFileSync2(path, "utf8")); + } catch (e) { + throw new Error(`brainstorm.json is unreadable: ${e.message}`); } -} - -// src/providers/github.ts -function toItems(raw, kind) { - return (raw ?? []).filter((it) => it && typeof it === "object").map((it) => { - const body = String(it.body ?? "").replace(/\r/g, "").trim().slice(0, 1200); - const labels = (it.labels ?? []).map((l) => typeof l === "string" ? l : l.name).filter(Boolean).join(", "); - const state = it.draft ? "draft" : it.state; - return { - source: kind, - title: `#${it.number} ${it.title} [${state}]`, - ref: `${kind}#${it.number}`, - location: it.html_url, - score: Number(it.score ?? 0), - snippet: `state: ${state}` + (labels ? ` \xB7 labels: ${labels}` : "") + ` \xB7 comments: ${it.comments ?? 0} \xB7 updated: ${it.updated_at ?? "?"} - -` + (body || "(no description)"), - url: it.html_url, - meta: { number: it.number, state, isPR: !!it.pull_request } - }; + const d = data ?? {}; + const used = /* @__PURE__ */ new Set(); + let seq = 0; + const nextId = () => { + do { + seq++; + } while (used.has(`B-${String(seq).padStart(3, "0")}`)); + const id = `B-${String(seq).padStart(3, "0")}`; + used.add(id); + return id; + }; + const rawIdeas = Array.isArray(d.ideas) ? d.ideas : []; + if (!Array.isArray(d.ideas) && d.ideas !== void 0) warn("brainstorm.ideas is not an array \u2014 ignored."); + for (const raw of rawIdeas) { + const id = line(raw?.id); + if (id && /^B-\d{3,}$/.test(id)) used.add(id); + } + const ideas = []; + rawIdeas.forEach((raw, i) => { + const r = raw ?? {}; + const title = line(r.title); + if (!title) { + warn(`brainstorm.ideas[${i}] has no usable title \u2014 dropped.`); + return; + } + let id = line(r.id); + if (!id || !/^B-\d{3,}$/.test(id)) id = nextId(); + let angle = r.angle; + if (!ANGLES.includes(angle)) { + if (r.angle !== void 0) warn(`brainstorm ${id}: angle "${String(r.angle)}" is not recognized \u2014 treated as wildcard.`); + angle = "wildcard"; + } + let status = r.status; + if (!STATUSES.includes(status)) { + if (r.status !== void 0) warn(`brainstorm ${id}: status "${String(r.status)}" is not recognized \u2014 treated as proposed.`); + status = "proposed"; + } + let target = r.target; + if (target !== void 0 && !TARGETS.includes(target)) { + warn(`brainstorm ${id}: target "${String(r.target)}" is not recognized \u2014 removed.`); + target = void 0; + } + const idea = { id, angle, title, status }; + const notes = line(r.notes); + if (notes) idea.notes = notes; + if (target) idea.target = target; + const priority = r.priority; + if (priority === "must" || priority === "should" || priority === "could") idea.priority = priority; + const mergedAt = line(r.mergedAt); + if (mergedAt) idea.mergedAt = mergedAt; + ideas.push(idea); }); + return { + schemaVersion: typeof d.schemaVersion === "number" ? d.schemaVersion : BRAINSTORM_SCHEMA_VERSION, + idea: line(d.idea) ?? "", + createdAt: line(d.createdAt) ?? "", + ...line(d.updatedAt) ? { updatedAt: line(d.updatedAt) } : {}, + ideas + }; } -function apiBase(host) { - return /(^|\.)github\.com$/i.test(host) ? "https://api.github.com" : `https://${host}/api/v3`; -} -function ghUsable(host) { - return have("gh") && /(^|\.)github\.com$/i.test(host); -} -var canonCache = /* @__PURE__ */ new Map(); -async function canonicalRepo(ref) { - const fallback = { owner: ref.owner, repo: ref.repo }; - if (!/github/i.test(ref.host)) return fallback; - const key = `${ref.host}/${ref.owner}/${ref.repo}`; - const cached = canonCache.get(key); - if (cached) return cached; - let resolved = fallback; - const parse = (full) => { - const i = full.indexOf("/"); - return i > 0 ? { owner: full.slice(0, i), repo: full.slice(i + 1) } : fallback; +var norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim(); +function mergeBrainstorm(briefIn, brainstormIn, now, warn = () => { +}) { + const brief = JSON.parse(JSON.stringify(briefIn)); + const brainstorm = JSON.parse(JSON.stringify(brainstormIn)); + let merged = 0; + let parkedFolded = 0; + let skipped = 0; + const appendUnique = (list, value) => { + if (list.some((x) => norm(x) === norm(value))) return false; + list.push(value); + return true; }; - if (ghUsable(ref.host)) { - const r = sh("gh", ["api", `repos/${ref.owner}/${ref.repo}`, "--jq", ".full_name"]); - if (r.ok && r.stdout.includes("/")) resolved = parse(r.stdout.trim()); - } else { - const r = await httpGet(`${apiBase(ref.host)}/repos/${ref.owner}/${ref.repo}`, { accept: "application/vnd.github+json" }); - if (r.ok) { - try { - const full = JSON.parse(r.body)?.full_name; - if (typeof full === "string" && full.includes("/")) resolved = parse(full); - } catch { - } + for (const idea of brainstorm.ideas) { + if (idea.mergedAt) continue; + if (idea.status === "parked") { + appendUnique(brief.openQuestions, `Parked idea ${idea.id}: ${idea.title}`); + idea.mergedAt = now; + parkedFolded++; + continue; } - } - canonCache.set(key, resolved); - return resolved; -} -async function query(ref, terms, kind, perSource) { - const q = `repo:${ref.owner}/${ref.repo} type:${kind} ${terms.join(" ")}`.trim(); - if (ghUsable(ref.host)) { - const res = sh("gh", ["api", "-X", "GET", "search/issues", "-f", `q=${q}`, "-f", `per_page=${perSource}`, "-f", "sort=updated", "-f", "order=desc"]); - if (res.ok) { - try { - return { items: toItems(JSON.parse(res.stdout).items, kind) }; - } catch { + if (idea.status !== "kept") continue; + if (!idea.target) { + warn(`brainstorm ${idea.id} "${idea.title}" is kept but has no target \u2014 set one (featureWishlist, competitors, \u2026) and re-merge.`); + skipped++; + continue; + } + if (idea.target === "featureWishlist") { + const exists = brief.featureWishlist.some((f) => norm(f.title) === norm(idea.title)); + if (exists) { + warn(`brainstorm ${idea.id} "${idea.title}" is already in the wishlist \u2014 skipped.`); + } else { + brief.featureWishlist.push({ title: idea.title, priority: idea.priority ?? "could", ...idea.notes ? { notes: idea.notes } : {} }); } + idea.mergedAt = now; + merged++; + continue; + } + if (idea.target === "goals" && brief.nonGoals.some((g) => norm(g) === norm(idea.title))) { + warn(`brainstorm ${idea.id} "${idea.title}" conflicts with an existing nonGoal \u2014 NOT merged; resolve it in brief.json first.`); + skipped++; + continue; + } + if (idea.target === "nonGoals" && brief.goals.some((g) => norm(g) === norm(idea.title))) { + warn(`brainstorm ${idea.id} "${idea.title}" conflicts with an existing goal \u2014 NOT merged; resolve it in brief.json first.`); + skipped++; + continue; } + const value = idea.target === "openQuestions" && idea.notes ? `${idea.title} \u2014 ${idea.notes}` : idea.title; + const list = brief[idea.target]; + if (!appendUnique(list, value)) warn(`brainstorm ${idea.id} "${idea.title}" is already in ${idea.target} \u2014 skipped.`); + idea.mergedAt = now; + merged++; } - const url = `${apiBase(ref.host)}/search/issues?q=${encodeURIComponent(q)}&per_page=${perSource}&sort=updated&order=desc`; - const r = await httpGet(url, { accept: "application/vnd.github+json" }); - if (!r.ok) { - const hint = r.status === 422 ? `query rejected (422) for repo:${ref.owner}/${ref.repo} \u2014 the repo may be moved/renamed/private, or the query had no valid terms` : `status ${r.status}; run \`gh auth login\` for higher-rate access`; - return { items: [], error: `GitHub ${kind} search unavailable (${hint}).` }; + brainstorm.updatedAt = now; + const proposed = brainstorm.ideas.filter((i) => i.status === "proposed").length; + return { brief, brainstorm, merged, parkedFolded, skipped, proposed }; +} + +// src/research/registry.ts +import { join as join7 } from "path"; + +// src/research/fetch.ts +var UA = "construct/0.x (+https://github.com/maxgfr/construct)"; +var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"; +function transient(status) { + return status === 0 || status === 429 || status >= 500; +} +async function httpGet(url, opts = {}) { + const retries = opts.retries ?? 1; + const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + let last = { ok: false, status: 0, body: "", contentType: "", error: "unreached" }; + for (let attempt = 0; attempt <= retries; attempt++) { + last = await httpGetOnce(url, opts); + if (last.ok || !transient(last.status)) return last; + if (attempt === retries) break; + const retryAfterS = Number(last.retryAfter); + const delay = last.status === 429 && Number.isFinite(retryAfterS) && retryAfterS > 0 ? Math.min(retryAfterS * 1e3, RETRY_AFTER_CAP_MS) : RETRY_BASE_DELAY_MS * 2 ** attempt + Math.random() * RETRY_JITTER_MS; + await sleep(delay); } + return last; +} +async function httpGetOnce(url, opts) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? HTTP_GET_TIMEOUT_MS); try { - return { items: toItems(JSON.parse(r.body).items, kind) }; - } catch { - return { items: [], error: `GitHub ${kind} search returned an unparseable response.` }; + const res = await fetch(url, { + signal: ctrl.signal, + redirect: "follow", + headers: { "user-agent": UA, accept: opts.accept ?? "*/*", ...opts.headers ?? {} } + }); + const buf = Buffer.from(await res.arrayBuffer()); + const max = opts.maxBytes ?? 4 * 1024 * 1024; + return { + ok: res.ok, + status: res.status, + body: buf.subarray(0, max).toString("utf8"), + contentType: res.headers.get("content-type") ?? "", + retryAfter: res.headers.get("retry-after") ?? void 0 + }; + } catch (e) { + return { ok: false, status: 0, body: "", contentType: "", error: e.message }; + } finally { + clearTimeout(t); } } -var github = { - name: "github", - matches: (host) => /(^|\.)github\.com$/i.test(host) || /github/i.test(host), - async search(ref0, question, kind, perSource) { - if (!ref0.owner || !ref0.repo) { - return { items: [], notes: ["No owner/repo resolved; cannot query GitHub issues/PRs."] }; - } - const canon = await canonicalRepo(ref0); - const ref = { ...ref0, owner: canon.owner, repo: canon.repo }; - const ranked = rankedKeywords(question); - if (ranked.length === 0) return { items: [], notes: [`No keywords to search ${kind}s.`] }; - let lastError; - for (const terms of uniqueAttempts([ranked.slice(0, 3), ranked.slice(0, 2)])) { - const { items, error } = await query(ref, terms, kind, perSource * 2); - if (error) lastError = error; - if (items.length) return { items: rerank(items, ranked).slice(0, perSource), notes: [] }; - } - const seen = /* @__PURE__ */ new Map(); - for (const t of ranked.slice(0, 4)) { - const { items, error } = await query(ref, [t], kind, perSource * 2); - if (error) lastError = error; - for (const it of items) if (!seen.has(it.ref)) seen.set(it.ref, it); +async function httpJson(method, url, body, opts = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? HTTP_JSON_TIMEOUT_MS); + try { + const res = await fetch(url, { + method, + signal: ctrl.signal, + headers: { "content-type": "application/json", accept: "application/json", "user-agent": UA }, + body: body === void 0 ? void 0 : JSON.stringify(body) + }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : void 0; + } catch { + data = text; } - const merged = rerank([...seen.values()], ranked).slice(0, perSource); - if (merged.length) return { items: merged, notes: [] }; - return { items: [], notes: lastError ? [lastError] : [`No ${kind}s matched the question.`] }; + return { ok: res.ok, status: res.status, data }; + } catch (e) { + return { ok: false, status: 0, data: void 0, error: e.message }; + } finally { + clearTimeout(t); } -}; -function rerank(items, ranked) { - const terms = ranked.map((t) => t.toLowerCase()); - const coverage = (it) => { - const hay = `${it.title} ${it.snippet}`.toLowerCase(); - let c = 0; - for (const t of terms) if (hay.includes(t)) c++; - return c; - }; - return items.map((it) => ({ it, c: coverage(it), s: it.score })).sort((a, b) => b.c - a.c || b.s - a.s).map((x) => x.it); } -function uniqueAttempts(lists) { - const seen = /* @__PURE__ */ new Set(); - const out = []; - for (const l of lists) { - const key = l.join("\0"); - if (l.length && !seen.has(key)) { - seen.add(key); - out.push(l); +var NAMED = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + mdash: "\u2014", + ndash: "\u2013", + hellip: "\u2026", + copy: "\xA9" +}; +function htmlToText(html) { + let s = html; + s = s.replace(//g, " "); + s = s.replace(/<(script|style|noscript|head|nav|footer|svg)[\s\S]*?<\/\1>/gi, " "); + s = s.replace(/<\/(p|div|section|article|li|tr|td|th|ul|ol|h[1-6]|pre|blockquote)>/gi, "\n"); + s = s.replace(/<(p|div|section|article|li|tr|td|th|ul|ol|h[1-6]|pre|blockquote|table)\b[^>]*>/gi, "\n"); + s = s.replace(/<(br|hr)\s*\/?>/gi, "\n"); + s = s.replace(/<[^>]+>/g, " "); + s = s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp|mdash|ndash|hellip|copy);/gi, (m, g) => { + if (g[0] === "#") { + const n = g[1] === "x" || g[1] === "X" ? parseInt(g.slice(2), 16) : Number(g.slice(1)); + try { + return Number.isFinite(n) ? String.fromCodePoint(n) : " "; + } catch { + return " "; + } } + return NAMED[g.toLowerCase()] ?? m; + }); + s = s.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n"); + return s.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).join("\n"); +} +var CONSENT_PATTERNS = [ + /\bcookies?\b/i, + /\bconsent\b/i, + /\bgdpr\b/i, + /\bccpa\b/i, + /accept all\b/i, + /reject all\b/i, + /manage (?:preferences|choices|cookies|settings)/i, + /privacy (?:policy|preferences|choices)/i, + /tracking technolog/i, + /advertising partners/i, + /legitimate interest/i +]; +function stripConsentBoilerplate(text) { + let dropped = 0; + const kept = text.split("\n").filter((line2) => { + const hits = CONSENT_PATTERNS.reduce((n, re) => n + (re.test(line2) ? 1 : 0), 0); + const isBanner = hits >= 2 || hits === 1 && line2.trim().length < 120; + if (isBanner) dropped++; + return !isBanner; + }); + return { text: kept.join("\n"), dropped }; +} +function metaDescriptionOf(html) { + const m = /]+name=["']description["'][^>]*content=["']([^"']+)["']/i.exec(html) || /]+content=["']([^"']+)["'][^>]*name=["']description["']/i.exec(html) || /]+property=["']og:description["'][^>]*content=["']([^"']+)["']/i.exec(html); + const d = m?.[1]?.replace(/\s+/g, " ").trim(); + return d || void 0; +} +async function fetchAndExtract(url) { + let res = await httpGet(url, { accept: "text/html,text/plain,*/*" }); + if (!res.ok && (res.status === 403 || res.status === 429)) { + res = await httpGet(url, { + accept: "text/html,application/xhtml+xml,*/*", + headers: { "user-agent": BROWSER_UA, "accept-language": "en-US,en;q=0.9" } + }); } - return out; + if (!res.ok) { + return { text: "", note: `Could not fetch ${url} (status ${res.status}${res.error ? ", " + res.error : ""}).` }; + } + const isHtml = /html/i.test(res.contentType) || /^\s* /gitlab/i.test(host), - async search(ref, question, kind, perSource) { - if (!ref.owner || !ref.repo) { - return { items: [], notes: ["No project path resolved; cannot query GitLab issues/MRs."] }; - } - const kw = rankedKeywords(question).slice(0, 4); - if (kw.length === 0) { - return { items: [], notes: [`No keywords to search GitLab ${kind === "issue" ? "issues" : "merge requests"}.`] }; - } - const proj = encodeURIComponent(`${ref.owner}/${ref.repo}`); - const path = kind === "issue" ? "issues" : "merge_requests"; - const search = encodeURIComponent(kw.join(" ")); - const url = `https://${ref.host}/api/v4/projects/${proj}/${path}?search=${search}&per_page=${perSource}&order_by=updated_at&sort=desc`; - const r = await httpGet(url, { accept: "application/json" }); - if (!r.ok) { - return { items: [], notes: [`GitLab ${kind} search unavailable (status ${r.status}).`] }; - } - try { - const arr = JSON.parse(r.body); - if (!Array.isArray(arr)) return { items: [], notes: [`GitLab ${kind} search returned no array.`] }; - const marker = kind === "issue" ? "#" : "!"; - const items = arr.filter((it) => it && typeof it === "object").map((it) => { - const num = it.iid ?? it.id; - const body = String(it.description ?? "").replace(/\r/g, "").trim().slice(0, 1200); - return { - source: kind, - title: `${marker}${num} ${it.title} [${it.state}]`, - ref: `${kind}#${num}`, - location: it.web_url, - score: 0, - snippet: `state: ${it.state} \xB7 updated: ${it.updated_at ?? "?"} - -${body || "(no description)"}`, - url: it.web_url, - meta: { iid: num, state: it.state } - }; - }); - return { items, notes: [] }; - } catch { - return { items: [], notes: [`GitLab ${kind} search returned an unparseable response.`] }; +function excerptsFromText(text, url, title, source, question, perSource) { + const lines = text.split("\n"); + const questions = (Array.isArray(question) ? question : [question]).filter((q) => q.trim()); + const kwSets = questions.map((q) => keywords(q).map((k) => k.toLowerCase())); + const hits = []; + for (let i = 0; i < lines.length; i++) { + const low = lines[i].toLowerCase(); + let cov = 0; + for (const kws of kwSets) { + let c = 0; + for (const kw of kws) if (low.includes(kw)) c++; + if (kws.length && c > cov) cov = c; } + if (cov > 0) hits.push({ idx: i, cov }); } -}; - -// src/providers/generic.ts -var generic = { - name: "generic", - matches: () => true, - async search(ref, _question, kind) { - return { - items: [], - notes: [`No public ${kind} API for host "${ref.host}". The code was cloned and indexed; issues/PRs are not retrievable for this host.`] - }; + hits.sort((a, b) => b.cov - a.cov || a.idx - b.idx); + const items = []; + const ranges = []; + const take = hits.length ? hits : [{ idx: 0, cov: 0 }]; + const perDoc = Math.min(2, Math.max(1, perSource)); + for (const h of take) { + if (items.length >= perDoc) break; + const start = Math.max(0, h.idx - 3); + const end = Math.min(lines.length, h.idx + 12); + if (ranges.some((r) => start < r.end && end > r.start)) continue; + ranges.push({ start, end }); + const snippet = lines.slice(start, end).join("\n").slice(0, 1500); + if (!snippet.trim()) continue; + items.push({ + source, + // Disambiguate the second+ excerpt of one page by its line range, so two + // excerpts of the same URL don't render identical titles. + title: items.length === 0 ? title : `${title} (lines ${start + 1}\u2013${end})`, + ref: url, + location: `${url}#~${start + 1}`, + score: Number((h.cov + 1).toFixed(3)), + snippet, + url, + // cov=0 means no line matched the question — this is the top-of-page + // fallback, likely boilerplate. Flag it so review/analyze down-weight it. + ...h.cov === 0 ? { meta: { lowSignal: true } } : {} + }); } -}; - -// src/providers/registry.ts -var PROVIDERS = [github, gitlab]; -function providerFor(host) { - return PROVIDERS.find((p) => p.matches(host)) ?? generic; + return items; } -// src/research/oss.ts -var NON_REPO_OWNERS = /* @__PURE__ */ new Set([ - "topics", - "search", - "collections", - "trending", - "explore", - "marketplace", - "sponsors", - "features", - "about", - "pricing", - "login", - "join", - "signup", - "settings", - "notifications", - "issues", - "pulls", - "orgs", - "apps", - "blog", - "site", - "enterprise", - "customer-stories", - "security", - "readme", - "events", - "dashboard", - "groups", - "users", - "help", - "projects", - "-" -]); -function canonicalRepoUrl(url) { - const m = /^(https?:\/\/(?:github|gitlab)\.com\/([A-Za-z0-9._-]+)\/[A-Za-z0-9._-]+)/i.exec(url); - if (!m || NON_REPO_OWNERS.has(m[2].toLowerCase())) return void 0; - return m[1].replace(/\.git$/, ""); -} -function normalizeSeed(raw) { - const s = raw.trim(); - if (!s) return void 0; - if (/^https?:\/\/github\.com\//i.test(s)) return canonicalRepoUrl(s); - const ref = resolveRepo(s); - return ref.isLocal || ref.owner && ref.repo ? s : void 0; +// src/research/web.ts +var SEARXNG_BASE = process.env.CONSTRUCT_SEARXNG || "http://localhost:8888"; +async function viaSearxng(query2, n) { + const url = `${SEARXNG_BASE.replace(/\/$/, "")}/search?q=${encodeURIComponent(query2)}&format=json`; + const r = await httpGet(url, { accept: "application/json", timeoutMs: SEARXNG_TIMEOUT_MS, retries: 0 }); + if (!r.ok) return null; + try { + const data = JSON.parse(r.body); + const urls = (data.results ?? []).map((x) => x.url).filter(Boolean); + return urls.slice(0, n); + } catch { + return null; + } } -function languageHistogram(files) { - const counts = /* @__PURE__ */ new Map(); - for (const f of files) { - const ext = f.ext.replace(/^\./, ""); - if (!ext) continue; - counts.set(ext, (counts.get(ext) ?? 0) + 1); +async function viaDuckDuckGo(query2, n) { + const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query2)}`; + const r = await httpGet(url, { accept: "text/html", timeoutMs: DDG_TIMEOUT_MS }); + if (!r.ok || !r.body) return null; + const urls = []; + const tagRe = /]*\bresult__a\b[^>]*>/g; + let m; + while ((m = tagRe.exec(r.body)) && urls.length < n) { + const href0 = /\bhref="([^"]+)"/.exec(m[0]); + if (!href0) continue; + let href = href0[1]; + const uddg = /[?&]uddg=([^&]+)/.exec(href); + if (uddg) { + try { + href = decodeURIComponent(uddg[1]); + } catch { + } + } + if (/^https?:\/\//.test(href) && !/duckduckgo\.com/.test(href)) urls.push(href); } - return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + return urls.length ? urls : null; } -async function ossAngle(ctx) { +async function discover(query2, engine, n) { const notes = []; - let seeds = [...new Set(ctx.brief.ossSeeds.map(normalizeSeed).filter((x) => !!x))]; - if (seeds.length === 0) { - const q2 = `${ctx.query || ctx.brief.idea} open source github`; - const d = await discover(q2, ctx.webEngine, ctx.perSource); - notes.push(`OSS discovery via ${d.via} for "${q2}".`, ...d.notes); - seeds = [...new Set(d.urls.map(canonicalRepoUrl).filter((x) => !!x))]; + if (engine === "searxng" || engine === "auto") { + const s = await viaSearxng(query2, n); + if (s?.length) return { urls: s, via: "searxng", notes }; + if (engine === "searxng") { + notes.push(s === null ? `SearXNG unreachable at ${SEARXNG_BASE}. Run \`construct semantic up\`.` : "SearXNG returned no results."); + } } - seeds = seeds.slice(0, 3); - if (seeds.length === 0) { - return [{ source: "oss", items: [], notes: [...notes, "No comparable OSS projects found."] }]; + if (engine === "ddg" || engine === "auto") { + const d = await viaDuckDuckGo(query2, n); + if (d?.length) return { urls: d, via: "duckduckgo", notes }; + if (engine === "ddg") notes.push("DuckDuckGo returned no results."); } - const ossItems = []; - const issueItems = []; - const prItems = []; - const q = ctx.query || ctx.brief.idea; - for (const seed of seeds) { - const ref = resolveRepo(seed); - let dir; - try { - dir = ensureClone(ref, { refresh: ctx.refresh }); - } catch (e) { - notes.push(`Could not clone ${ref.raw}: ${e.message}`); - } - const repoLabel = ref.owner && ref.repo ? `${ref.owner}/${ref.repo}` : ref.slug; - if (dir) { - const files = walk(dir); - const langs = languageHistogram(files).slice(0, 6).map(([e, c]) => `${e}:${c}`).join(", "); - let snippet = `Languages: ${langs || "n/a"} \xB7 files: ${files.length}.`; - const readme = files.find((f) => /^readme(\.|$)/i.test(f.rel)) ?? files.find((f) => /(^|\/)readme\./i.test(f.rel)); - if (readme) { - const text = readText(readme.abs); - const ex = excerptsFromText(text, ref.webUrl ?? ref.raw, repoLabel, "oss", q, 1); - if (ex[0]) snippet += ` - -${ex[0].snippet}`; + if (engine === "claude" || engine === "auto") { + notes.push( + "No keyless engine returned results. Use your built-in WebSearch to find URLs, then ground them with `construct web --url --out `." + ); + } + return { urls: [], via: "none", notes }; +} +async function webFetchUrls(urls, question, perSource, source = "market", fetchAll = false) { + const items = []; + const notes = []; + const toFetch = fetchAll ? urls : urls.slice(0, Math.max(1, Math.ceil(perSource / 2))); + for (const url of toFetch) { + const { text, note, metaDescription } = await fetchAndExtract(url); + if (note) notes.push(note); + if (!text) continue; + const ex = excerptsFromText(text, url, `${labelFor(source)} \u2014 ${url}`, source, question, perSource); + if (ex.length) { + for (const item of ex) { + if (item.meta?.lowSignal && metaDescription) item.snippet = metaDescription; } - ossItems.push({ - source: "oss", - title: `${repoLabel} \u2014 prior art`, - ref: repoLabel, - location: ref.webUrl, - score: files.length, - snippet, - url: ref.webUrl + items.push(...ex); + } else { + items.push({ + source, + title: `${labelFor(source)} \u2014 ${url}`, + ref: url, + location: url, + score: 0, + snippet: metaDescription ?? text.slice(0, 800), + url, + meta: { lowSignal: true } }); } - if (ref.owner && ref.repo) { - const provider = providerFor(ref.host); - const iss = await provider.search(ref, q, "issue", ctx.perSource); - issueItems.push(...iss.items); - notes.push(...iss.notes); - const prs = await provider.search(ref, q, "pr", ctx.perSource); - prItems.push(...prs.items); - notes.push(...prs.notes); - } } - return [ - { source: "oss", items: ossItems, notes }, - { source: "issue", items: issueItems, notes: [] }, - { source: "pr", items: prItems, notes: [] } - ]; + return { items, notes }; } - -// src/research/stackoverflow.ts -async function stackoverflow(question, perSource) { - const kws = rankedKeywords(question).slice(0, 5).join(" "); - if (!kws) return { source: "so", items: [], notes: ["No keywords to search StackOverflow."] }; - const q = encodeURIComponent(kws); - const pat = process.env.STACK_PAT ? `&access_token=${process.env.STACK_PAT}` : ""; - const url = `https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${q}&site=stackoverflow&filter=withbody&pagesize=${perSource}${pat}`; - const r = await httpGet(url, { accept: "application/json" }); - if (!r.ok) { - return { source: "so", items: [], notes: [`StackOverflow search unavailable (status ${r.status}).`] }; - } - try { - const data = JSON.parse(r.body); - const items = (data.items ?? []).map((it) => { - const body = htmlToText(String(it.body ?? "")).slice(0, 1200); - const accepted = it.is_answered ? "answered" : "unanswered"; - return { - source: "so", - title: htmlToText(String(it.title ?? "(question)")).slice(0, 160), - ref: `so:${it.question_id}`, - location: it.link, - score: Number(it.score ?? 0), - snippet: `score: ${it.score ?? 0} \xB7 ${accepted} \xB7 answers: ${it.answer_count ?? 0}` + (it.tags?.length ? ` \xB7 tags: ${it.tags.slice(0, 6).join(", ")}` : "") + ` - -${body || "(no body)"}`, - url: it.link, - meta: { questionId: it.question_id, isAnswered: it.is_answered, answerCount: it.answer_count } - }; - }); - const notes = data.quota_remaining !== void 0 && data.quota_remaining < 20 ? [`StackExchange anonymous quota low (${data.quota_remaining} left).`] : []; - if (items.length === 0) notes.push("No StackOverflow questions matched."); - return { source: "so", items, notes }; - } catch { - return { source: "so", items: [], notes: ["StackOverflow search returned an unparseable response."] }; - } +function labelFor(source) { + return source === "docs" ? "Docs" : source === "oss" ? "OSS" : "Web"; } -// src/research/tech.ts -async function techAngle(ctx) { - const allTechs = ctx.brief.candidateTech; - const techs = allTechs.slice(0, 3); - const ideaKw = ctx.query || ctx.brief.idea; - const docItems = []; - const docNotes = []; - if (allTechs.length > techs.length) { - docNotes.push( - `Only the first ${techs.length} of ${allTechs.length} candidate technologies were grounded; skipped: ${allTechs.slice(techs.length).join(", ")}. Drill them with \`construct tech --out --q ""\`.` - ); - } - if (ctx.docsUrls?.length) { - const direct = await webFetchUrls(ctx.docsUrls, ideaKw, ctx.perSource, "docs", true); - docItems.push(...direct.items); - docNotes.push(`Grounded ${ctx.docsUrls.length} docs URL(s) passed via --docs-url.`, ...direct.notes); +// src/research/market.ts +async function marketAngle(ctx) { + const b = ctx.brief; + const query2 = ctx.query || [b.idea, b.competitors.join(" "), "competitors alternatives market"].filter(Boolean).join(" ").trim(); + const items = []; + const notes = []; + const pinned = ctx.marketUrls ?? []; + const questions = [query2, ...b.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`.trim())].filter(Boolean); + if (pinned.length) { + const f = await webFetchUrls(pinned, questions.length ? questions : pinned.join(" "), ctx.perSource, "market", true); + items.push(...f.items.slice(0, ctx.perSource)); + notes.push(`Pinned ${pinned.length} market URL(s) via --url.`, ...f.notes); } - for (const tech of techs) { - const q = `${tech} official documentation`; - const { urls, via, notes } = await discover(q, ctx.webEngine, ctx.perSource); - docNotes.push(`Docs discovery for "${tech}" via ${via}.`, ...notes); - if (!urls.length) continue; - const fetched = await webFetchUrls(urls.slice(0, 1), `${tech} ${ideaKw}`, ctx.perSource, "docs"); - docItems.push(...fetched.items); - docNotes.push(...fetched.notes); + if (!query2) { + if (items.length) return [{ source: "market", items, notes }]; + return [{ source: "market", items: [], notes: ["No idea/competitors to search the market for."] }]; } - if (techs.length === 0 && !ctx.docsUrls?.length) docNotes.push("No candidate technologies in the brief \u2014 nothing to ground feasibility against."); - const topKw = rankedKeywords(ideaKw)[0] ?? ""; - const soItems = []; - const soNotes = []; - const seen = /* @__PURE__ */ new Set(); - const per = Math.max(2, Math.ceil(ctx.perSource / Math.max(1, techs.length))); - for (const tech of techs) { - const q = `${tech} ${topKw}`.trim(); - const r = await stackoverflow(q, per); - for (const it of r.items) { - if (!seen.has(it.ref)) { - seen.add(it.ref); - soItems.push(it); - } + const budget = ctx.perSource - items.length; + if (budget > 0) { + const { urls, via, notes: discoveryNotes } = await discover(query2, ctx.webEngine, budget); + if (urls.length === 0) { + notes.push(`Market discovery via ${via}.`, ...discoveryNotes); + } else { + const fetched = await webFetchUrls(urls, questions, budget, "market"); + items.push(...fetched.items); + notes.push(`Market discovery via ${via} for "${query2}".`, ...discoveryNotes, ...fetched.notes); } - soNotes.push(...r.notes); } - if (techs.length === 0) soNotes.push("No candidate technologies to search StackOverflow for."); - return [ - { source: "docs", items: docItems, notes: docNotes }, - { source: "so", items: soItems, notes: soNotes } - ]; + return [{ source: "market", items, notes }]; } -// src/research/semantic.ts -import { existsSync as existsSync3 } from "fs"; -import { join as join4, dirname } from "path"; -import { fileURLToPath } from "url"; -var OLLAMA = (process.env.CONSTRUCT_OLLAMA || "http://localhost:11434").replace(/\/$/, ""); -var EMBED_MODEL = process.env.CONSTRUCT_EMBED_MODEL || "nomic-embed-text"; -function cosine(a, b) { - if (a.length === 0 || a.length !== b.length) return 0; - let dot = 0; - let na = 0; - let nb = 0; - for (let i = 0; i < a.length; i++) { - dot += a[i] * b[i]; - na += a[i] * a[i]; - nb += b[i] * b[i]; - } - if (na === 0 || nb === 0) return 0; - const r = dot / (Math.sqrt(na) * Math.sqrt(nb)); - return Number.isFinite(r) ? r : 0; +// src/clone.ts +import { existsSync as existsSync3, statSync, mkdirSync as mkdirSync3, readdirSync, rmSync } from "fs"; +import { resolve, join as join3, basename } from "path"; +import { tmpdir } from "os"; +function cacheRoot() { + return join3(tmpdir(), "construct"); } -async function reachable(base, path = "/") { - const r = await httpGet(base + path, { timeoutMs: REACHABLE_TIMEOUT_MS }); - return r.ok; -} -async function embed(text) { - const r = await httpJson("POST", `${OLLAMA}/api/embeddings`, { model: EMBED_MODEL, prompt: text.slice(0, 4e3) }, { timeoutMs: EMBED_TIMEOUT_MS }); - const v = r.ok ? r.data?.embedding : void 0; - return Array.isArray(v) && v.length ? v : null; -} -async function semanticRescore(results, query2) { - const unchanged = (why) => ({ - available: false, - results, - notes: [`Semantic mode unavailable (${why}); kept lexical ranking.`] - }); - if (!await reachable(OLLAMA, "/api/tags")) { - return unchanged(`Ollama not reachable at ${OLLAMA} \u2014 run \`construct semantic up\``); - } - const qv = await embed(query2); - if (!qv) return unchanged(`could not embed the query (is the '${EMBED_MODEL}' model pulled?)`); - const out = []; - let failures = 0; - for (const r of results) { - const items = []; - for (const it of r.items) { - const v = await embed(`${it.title} -${it.snippet}`); - if (v) { - items.push({ ...it, score: Number(cosine(qv, v).toFixed(4)), meta: { ...it.meta ?? {}, semantic: true } }); - } else { - failures++; - items.push({ ...it, score: -1, meta: { ...it.meta ?? {}, semantic: false } }); - } +function resolveRepo(raw) { + const trimmed = raw.trim(); + if (trimmed) { + const asPath = resolve(trimmed); + if (existsSync3(asPath) && statSync(asPath).isDirectory()) { + return { + raw: trimmed, + host: "local", + isLocal: true, + slug: "local-" + slugify(basename(asPath) + "-" + asPath) + }; } - out.push({ ...r, items }); - } - const notes = [`Semantic rescoring via Ollama + ${EMBED_MODEL} (local).`]; - if (failures) notes.push(`${failures} item(s) could not be embedded; ranked last.`); - return { available: true, results: out, notes }; -} -function composeFile() { - const here = dirname(fileURLToPath(import.meta.url)); - for (const cand of [join4(here, "..", "docker-compose.yml"), join4(here, "docker-compose.yml")]) { - if (existsSync3(cand)) return cand; - } - return join4(here, "..", "docker-compose.yml"); -} -function semanticControl(action) { - if (!["up", "down", "status"].includes(action)) { - return { message: `construct semantic: unknown action "${action}" (use: up | down | status)`, code: 1 }; - } - if (!have("docker")) { - return { message: "construct semantic: docker not found. Install Docker, then retry. See references/semantic-setup.md.", code: 1 }; - } - const file = composeFile(); - if (action === "down") { - const r = sh("docker", ["compose", "-f", file, "--profile", "all", "down"], { timeoutMs: COMPOSE_DOWN_TIMEOUT_MS }); - return { message: r.ok ? "construct semantic: stack stopped." : `construct semantic: down failed. -${r.stderr}`, code: r.ok ? 0 : 1 }; } - if (action === "status") { - const r = sh("docker", ["compose", "-f", file, "ps"], { timeoutMs: COMPOSE_PS_TIMEOUT_MS }); - return { message: r.ok ? r.stdout || "construct semantic: no services running." : `construct semantic: status failed. -${r.stderr}`, code: 0 }; + let host; + let path; + const scp = /^git@([^:]+):(.+)$/.exec(trimmed); + const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed); + const hostPath = /^([a-z0-9.-]+\.[a-z]{2,})\/(.+)$/i.exec(trimmed); + if (scp) { + host = scp[1]; + path = scp[2]; + } else if (url) { + host = url[1]; + path = url[2]; + } else if (hostPath) { + host = hostPath[1]; + path = hostPath[2]; + } else if (/^[\w.-]+\/[\w.-]+$/.test(trimmed)) { + host = "github.com"; + path = trimmed; + } else { + return { raw: trimmed, host: "generic", isLocal: false, slug: slugify(trimmed) || "seed" }; } - const up = sh("docker", ["compose", "-f", file, "--profile", "all", "up", "-d"], { timeoutMs: COMPOSE_UP_TIMEOUT_MS }); - if (!up.ok) return { message: `construct semantic: up failed. -${up.stderr}`, code: 1 }; - const pull = sh("docker", ["compose", "-f", file, "exec", "-T", "ollama", "ollama", "pull", EMBED_MODEL], { timeoutMs: OLLAMA_PULL_TIMEOUT_MS }); - const lines = [ - "construct semantic: stack is up (Qdrant :6333 \xB7 Ollama :11434 \xB7 SearXNG :8888).", - pull.ok ? ` model: ${EMBED_MODEL} ready` : ` model: pull '${EMBED_MODEL}' yourself: docker compose -f ${file} exec ollama ollama pull ${EMBED_MODEL}`, - " use: construct research --out --angles market,oss,tech,semantic --semantic" - ]; - return { message: lines.join("\n"), code: 0 }; -} - -// src/research/dossier.ts -import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs"; -import { join as join5 } from "path"; -var SOURCE_ORDER = ["market", "oss", "docs", "so", "issue", "pr"]; -var SOURCE_LABEL = { - market: "Market & competitors", - oss: "Open-source prior art", - docs: "Technology documentation", - so: "StackOverflow", - issue: "Issues (prior art)", - pr: "Pull / Merge Requests (prior art)" -}; -function rank(s) { - const i = SOURCE_ORDER.indexOf(s); - return i < 0 ? 99 : i; -} -function assignIds(results) { - const flat = results.flatMap((r) => r.items); - flat.sort((a, b) => rank(a.source) - rank(b.source) || b.score - a.score || a.ref.localeCompare(b.ref)); - return flat.map((it, i) => ({ id: `E${i + 1}`, ...it })); + host = host.toLowerCase(); + path = path.replace(/\.git$/, "").replace(/\/+$/, ""); + const segments = path.split("/").filter(Boolean); + const repo = segments.length ? segments[segments.length - 1] : void 0; + const owner = segments.length > 1 ? segments.slice(0, -1).join("/") : void 0; + const cloneUrl = /^https?:\/\//i.test(trimmed) || scp ? trimmed.replace(/\/+$/, "") : `https://${host}/${path}.git`; + const webUrl = `https://${host}/${path}`; + return { + raw: trimmed, + host, + owner, + repo, + cloneUrl: cloneUrl.endsWith(".git") ? cloneUrl : `${cloneUrl}.git`, + webUrl, + isLocal: false, + slug: slugify(`${host}/${path}`) + }; } -function renderEvidenceMarkdown(evidence, meta) { - const out = []; - out.push(`# Evidence dossier`); - out.push(""); - out.push(`**Idea:** ${meta.idea}`); - if (meta.query) out.push(`**Query:** ${meta.query}`); - out.push(`**Angles:** ${meta.angles.join(", ")} \xB7 **semantic:** ${meta.semantic ? "on" : "off"} \xB7 **built:** ${meta.builtAt}`); - out.push(""); - out.push( - `> Ground the SRD's requirements and decisions in this evidence. Cite items by id, e.g. \`[E1]\`. Grounding is advisory \u2014 \`construct check\` reports coverage but never fails on it. Still: prefer a cited claim to a guessed one.` - ); - out.push(""); - if (evidence.length === 0) { - out.push(`_No evidence was retrieved. Broaden the query, add angles, or check connectivity._`); +function ensureClone(ref, opts = {}) { + if (ref.isLocal) return resolve(ref.raw); + const dir = join3(cacheRoot(), ref.slug); + const alreadyCloned = existsSync3(join3(dir, ".git")); + if (alreadyCloned && !opts.refresh) return dir; + if (alreadyCloned && opts.refresh) { + sh("git", ["-C", dir, "fetch", "--depth", "1", "origin"], { timeoutMs: GIT_FETCH_TIMEOUT_MS }); + sh("git", ["-C", dir, "reset", "--hard", "FETCH_HEAD"], { timeoutMs: GIT_RESET_TIMEOUT_MS }); + return dir; } - for (const source of SOURCE_ORDER) { - const items = evidence.filter((e) => e.source === source); - if (items.length === 0) continue; - out.push(`## ${SOURCE_LABEL[source]}`); - out.push(""); - for (const it of items) { - out.push(`### [${it.id}] ${it.title}`); - const meta1 = [`ref: \`${it.ref}\``, it.location ? `loc: \`${it.location}\`` : "", `score: ${it.score}`].filter(Boolean).join(" \xB7 "); - out.push(meta1); - if (it.url) out.push(`url: ${it.url}`); - out.push(""); - out.push("```"); - out.push(it.snippet); - out.push("```"); - out.push(""); + mkdirSync3(cacheRoot(), { recursive: true }); + const args = ["clone", "--depth", "1", "--filter=blob:none"]; + if (opts.branch) args.push("--branch", opts.branch); + args.push(ref.cloneUrl, dir); + const res = sh("git", args, { timeoutMs: GIT_CLONE_TIMEOUT_MS }); + if (!res.ok) { + if (res.missing) { + throw new Error(`git is not installed or not on PATH \u2014 cannot clone ${ref.cloneUrl}`); } - } - if (meta.notes.length) { - out.push(`## Retrieval notes`); - out.push(""); - for (const n of meta.notes) out.push(`- ${n}`); - out.push(""); - } - return out.join("\n"); -} -function writeDossier(dir, evidence, meta) { - mkdirSync3(dir, { recursive: true }); - const evidenceJson = join5(dir, "evidence.json"); - const evidenceMd = join5(dir, "EVIDENCE.md"); - const metaJson = join5(dir, "meta.json"); - writeFileSync2(evidenceJson, JSON.stringify(evidence, null, 2)); - writeFileSync2(evidenceMd, renderEvidenceMarkdown(evidence, meta)); - writeFileSync2(metaJson, JSON.stringify(meta, null, 2)); - return { dir, evidenceJson, evidenceMd, metaJson }; -} - -// src/research/registry.ts -var HANDLERS = { - market: marketAngle, - oss: ossAngle, - tech: techAngle -}; -var ANGLE_SOURCE = { - market: "market", - oss: "oss", - tech: "docs" -}; -async function runAngles(ctx) { - const active = ctx.angles.filter((a) => a !== "semantic"); - const settled = await Promise.all( - active.map(async (a) => { + if (existsSync3(dir)) { try { - return await HANDLERS[a](ctx); + rmSync(dir, { recursive: true, force: true }); } catch (e) { - return [{ source: ANGLE_SOURCE[a], items: [], notes: [`${a} angle failed: ${e.message}`] }]; + throw new Error(`could not remove the partial clone at ${dir} before retrying: ${e.message} \u2014 delete it manually and re-run`); } - }) - ); - let results = settled.flat(); - const notes = []; - if (ctx.semantic || ctx.angles.includes("semantic")) { - const q = ctx.query || ctx.brief.idea; - const s = await semanticRescore(results, q); - results = s.results; - notes.push(...s.notes); + } + const fallback = sh("git", ["clone", "--depth", "1", ...opts.branch ? ["--branch", opts.branch] : [], ref.cloneUrl, dir], { + timeoutMs: GIT_CLONE_TIMEOUT_MS + }); + if (!fallback.ok) { + throw new Error( + [ + `git clone failed for ${ref.cloneUrl}`, + ` attempt 1 (--filter=blob:none): ${res.stderr.trim() || `exit ${res.status}`}`, + ` attempt 2 (no filter): ${fallback.stderr.trim() || `exit ${fallback.status}`}` + ].join("\n") + ); + } } - return { results, notes }; -} -async function runResearch(ctx, builtAt) { - const { results, notes } = await runAngles(ctx); - const capped = results.map((r) => ({ - ...r, - items: [...r.items].sort((a, b) => b.score - a.score).slice(0, ctx.perSource) - })); - const evidence = assignIds(capped); - const presentSources = [...new Set(evidence.map((e) => e.source))]; - const meta = { - idea: ctx.brief.idea, - angles: ctx.angles, - query: ctx.query || void 0, - sources: presentSources, - semantic: ctx.semantic || ctx.angles.includes("semantic"), - evidenceCount: evidence.length, - builtAt, - notes: [...capped.flatMap((r) => r.notes), ...notes] - }; - const dir = join6(ctx.runDir, "evidence"); - const paths = writeDossier(dir, evidence, meta); - return { dir, evidence, meta, paths }; + if (!existsSync3(dir) || readdirSync(dir).length === 0) { + throw new Error(`clone produced an empty tree at ${dir}`); + } + return dir; } -// src/render.ts -import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "fs"; -import { join as join9, dirname as dirname2 } from "path"; - -// src/srd.ts -import { join as join7 } from "path"; -function srdManifestPath(runDir) { - return join7(runDir, "SRD.json"); -} -function pad3(n) { - return String(n).padStart(3, "0"); -} -function pad4(n) { - return String(n).padStart(4, "0"); -} -var GROUND_REQUIREMENT = ["market", "oss", "docs", "so", "issue", "pr"]; -var GROUND_QUALITY = ["oss", "docs", "so", "issue", "pr"]; -function matchEvidence(text, evidence, n, onlySources) { - const kws = keywords(text).map((k) => k.toLowerCase()); - if (kws.length === 0) return []; - const need = Math.min(2, kws.length); - const ratioFloor = 0.34; - const scored = evidence.filter((e) => !onlySources || onlySources.includes(e.source)).map((e) => { - const hay = new Set(keywords(`${e.title} ${e.snippet}`).map((k) => k.toLowerCase())); - let cov = 0; - for (const kw of kws) if (hay.has(kw)) cov++; - return { id: e.id, key: e.url || `${e.source}:${e.ref}`, cov, ratio: cov / kws.length, score: e.score }; - }).filter((x) => x.cov >= need && x.ratio >= ratioFloor).sort((a, b) => b.cov - a.cov || b.ratio - a.ratio || b.score - a.score || a.id.localeCompare(b.id)); - const seen = /* @__PURE__ */ new Set(); - const out = []; - for (const x of scored) { - if (seen.has(x.key)) continue; - seen.add(x.key); - out.push(x.id); - if (out.length >= n) break; - } - return out; -} -var NFR_SIGNALS = { - privacy: /privac|gdpr|personal data|consent|self[- ]?host|own (your|the) data|no account/i, - accessibility: /accessib|a11y|screen reader|wcag|keyboard/i, - security: /auth|login|password|secret|token|encrypt|credential|account/i, - performance: /fast|latenc|speed|sub-?second|under \d+ ?(s|sec|second|ms|minute)/i, - reliability: /reliab|availab|recover|double-?book|never|busy|conflict|sync/i, - observability: /log|metric|trace|monitor|audit/i, - usability: /usab|onboard|guest|no account|widget|embed|reminder/i, - cost: /cost|budget|cheap|self[- ]?host/i, - i18n: /locale|i18n|timezone|language|translat/i -}; -var INTEGRATION_RE = /\b(?:calendar|caldav|google|ical|ics|sync|webhook|email|smtp|sms|widget|iframe|embed|oauth|payment|api)s?\b/i; -var PERSIST_RE = /persist|store|database|datastore|save|record|booking|event|schedul|inventory|history/i; -var NFR_TEMPLATES = { - performance: { - statement: "The system responds to primary user actions without perceptible delay under expected load.", - metric: "p95 latency < 300 ms for core interactions at expected concurrency." - }, - security: { - statement: "User data and credentials are protected in transit and at rest, with least-privilege access.", - metric: "All endpoints authenticated/authorized; secrets never logged; dependencies scanned in CI." - }, - reliability: { - statement: "The system degrades gracefully and recovers from transient failures without data loss.", - metric: "Monthly availability \u2265 99.9%; no data loss on a single-node failure." - }, - usability: { - statement: "A new user can complete the primary task without external help.", - metric: "\u2265 80% task-completion rate in unmoderated usability testing." - }, - observability: { - statement: "Operators can diagnose failures from logs, metrics and traces without reproducing locally.", - metric: "Structured logs + metrics on every request; alert on error-rate and latency SLO breach." - }, - cost: { - statement: "Running cost scales sub-linearly with usage and stays within the stated budget.", - metric: "Cost per active user tracked; infra cost within the budget constraint." - }, - scalability: { - statement: "The system scales horizontally to handle growth without re-architecture.", - metric: "Throughput scales near-linearly to 10\xD7 the launch load." - }, - accessibility: { - statement: "The interface is usable with assistive technology and meets recognised accessibility guidelines.", - metric: "WCAG 2.1 AA conformance on primary flows." - }, - privacy: { - statement: "Personal data is collected lawfully, minimised, and removable on request.", - metric: "Data-retention policy enforced; export/delete available for user data." - }, - i18n: { - statement: "The product supports multiple locales without code changes.", - metric: "All user-facing copy externalised; locale switch covers core flows." - }, - maintainability: { - statement: "The codebase is testable, documented, and changeable by a new contributor.", - metric: "Test coverage gate in CI; onboarding to first PR within a day." - } -}; -function nfrFor(category) { - const key = category.toLowerCase().trim(); - return NFR_TEMPLATES[key] ?? { - statement: `The system meets the "${category}" quality expectation defined for this product.`, - metric: `A measurable target for "${category}" is agreed and tracked.` - }; -} -function priorityOf(p) { - return p === "must" || p === "should" || p === "could" ? p : "should"; -} -var FEATURE_VERBS = /* @__PURE__ */ new Set([ - "create", - "add", - "manage", - "book", - "view", - "send", - "track", - "sync", - "edit", - "delete", - "list", - "share", - "export", - "import", - "search", - "save", - "read", - "tag", - "organize", - "organise", - "schedule", - "upload", - "download", - "browse", - "filter", - "sort", - "archive", - "publish", - "invite", - "assign", - "stream" +// src/walk.ts +import { readdirSync as readdirSync2, lstatSync, readFileSync as readFileSync3 } from "fs"; +import { join as join4, relative, sep, extname } from "path"; +var IGNORE_DIRS = /* @__PURE__ */ new Set([ + ".git", + "node_modules", + ".pnpm", + "bower_components", + "vendor", + "dist", + "build", + "out", + "target", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + "coverage", + "__pycache__", + ".venv", + "venv", + ".tox", + ".mypy_cache", + ".pytest_cache", + ".gradle", + ".idea", + ".vscode", + ".cache", + "tmp", + ".construct", + "Pods", + "DerivedData", + ".terraform", + "elm-stuff", + ".dart_tool" ]); -var NON_ENTITY_WORDS = /* @__PURE__ */ new Set(["search", "login", "signup", "support", "setup", "offline", "online", "mobile", "desktop", "full", "text", "user", "users"]); -function singularize(w) { - if (/ies$/.test(w)) return w.slice(0, -3) + "y"; - if (/(?:ches|shes|xes|zes|ses)$/.test(w)) return w.slice(0, -2); - if (/s$/.test(w) && !/(?:ss|us|is)$/.test(w)) return w.slice(0, -1); - return w; -} -function titleCase(w) { - return w ? w[0].toUpperCase() + w.slice(1) : w; -} -function entityTokens(title, exclude) { - const words = keywords(title).map((w) => w.toLowerCase()); - const verbLed = words.length > 0 && FEATURE_VERBS.has(words[0]); - const rest = verbLed ? words.slice(1) : words; - const tokens = rest.filter((w) => w.length >= 3 && !FEATURE_VERBS.has(w) && !NON_ENTITY_WORDS.has(w) && !/(?:ed|ing)$/.test(w)).map(singularize).filter((w) => !exclude.has(w)); - return { tokens, verbLed }; -} -function inferEntities(brief, functional) { - const exclude = new Set( - [...brief.competitors, ...brief.candidateTech, brief.product.name ?? ""].flatMap((s) => keywords(s).map((w) => singularize(w.toLowerCase()))) - ); - const perFr = functional.map((fr) => ({ fr, ...entityTokens(fr.title, exclude) })); - const freq = /* @__PURE__ */ new Map(); - for (const p of perFr) for (const t of new Set(p.tokens)) freq.set(t, (freq.get(t) ?? 0) + 1); - const chosen = /* @__PURE__ */ new Set(); - for (const [t, n] of freq) if (n >= 2) chosen.add(t); - for (const p of perFr) { - if (p.verbLed && p.fr.priority === "must" && p.tokens[0]) chosen.add(p.tokens[0]); - } - const names = [...chosen].sort((a, b) => freq.get(b) - freq.get(a) || a.localeCompare(b)).slice(0, 8); - const entities = names.map((n) => { - const name = titleCase(n); - const refs = perFr.filter((p) => p.tokens.includes(n)).map((p) => p.fr.id); - return { - name, - attributes: [ - { name: "id", type: "identifier" }, - { name: "createdAt", type: "timestamp" } - ], - referencedByFRs: refs - }; - }); - for (const fr of functional) { - fr.entities = entities.filter((e) => e.referencedByFRs.includes(fr.id)).map((e) => e.name); - } - return entities; -} -var BOUNDARY_DEFS = [ - // Word boundaries matter: a bare /ical|ics/ substring-matches "historical" - // and "metrics", /stripe/ matches "pinstripe", /embed/ matches "Embedded" and - // /google/ matches "googled" — each hallucinating an integration into an - // unrelated product. Every token is bounded (optional plural) for that reason. - { re: /\b(?:calendar|caldav|ical|ics)\b/i, label: "calendar systems (CalDAV/iCal)", name: "Calendar Integration", kind: "api" }, - { re: /\bgoogles?\b/i, label: "Google APIs", name: "Google API Integration", kind: "api" }, - { re: /\b(?:email|smtp)s?\b/i, label: "an email/SMTP provider", name: "Email Delivery", kind: "api" }, - { re: /\b(?:sms|twilio)s?\b/i, label: "an SMS provider", name: "SMS Delivery", kind: "api" }, - { re: /\b(?:widget|iframe|embed)s?\b/i, label: "external host sites (embed/iframe)", name: "Embeddable Widget", kind: "ui" }, - { re: /\b(?:payment|stripe|billing)s?\b/i, label: "a payments provider", name: "Payments Integration", kind: "api" }, - { re: /\bwebhooks?\b/i, label: "outbound webhooks", name: "Outbound Webhooks", kind: "event" }, - { re: /\b(?:browser extension|chrome extension|firefox add-?on)s?\b/i, label: "a browser extension", name: "Browser Extension", kind: "ui" } -]; -function boundaryHaystack(brief) { - return `${brief.idea} ${brief.candidateTech.join(" ")} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; -} -function detectBoundaries(brief) { - const haystack = boundaryHaystack(brief); - return BOUNDARY_DEFS.filter((b) => b.re.test(haystack)); -} -function inferInterfaces(brief, functional) { +var LOCKFILES = /* @__PURE__ */ new Set([ + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lockb", + "composer.lock", + "cargo.lock", + "poetry.lock", + "pipfile.lock", + "gemfile.lock", + "go.sum", + "flake.lock", + "packages.lock.json", + "podfile.lock", + "mix.lock" +]); +var BINARY_EXT = /* @__PURE__ */ new Set([ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".ico", + ".icns", + ".svg", + ".pdf", + ".zip", + ".gz", + ".tar", + ".tgz", + ".bz2", + ".xz", + ".7z", + ".rar", + ".jar", + ".war", + ".class", + ".so", + ".dylib", + ".dll", + ".exe", + ".bin", + ".o", + ".a", + ".wasm", + ".woff", + ".woff2", + ".ttf", + ".otf", + ".eot", + ".mp3", + ".mp4", + ".mov", + ".avi", + ".webm", + ".wav", + ".flac", + ".ogg", + ".lock", + ".min.js", + ".map" +]); +function walk(root, opts = {}) { + const maxFileBytes = opts.maxFileBytes ?? 1024 * 1024; + const maxFiles = opts.maxFiles ?? 2e4; const out = []; - for (const b of detectBoundaries(brief)) { - const related = functional.filter((fr) => b.re.test(`${fr.title} ${fr.description}`)).map((fr) => fr.id); - out.push({ - name: b.name, - kind: b.kind, - summary: `Boundary with ${b.label}. Define the contract (operations, data, failure modes) during authoring.`, - relatedFRs: related - }); - } - if (brief.product.users?.length) { - out.push({ - name: "Web App", - kind: "ui", - summary: `The primary user-facing surface through which ${brief.product.users.join(", ")} use the product.`, - relatedFRs: functional.map((f) => f.id) - }); - } - for (const fr of functional) { - fr.interfaces = out.filter((i) => i.relatedFRs.includes(fr.id)).map((i) => i.name); - } - return out; -} -function buildSRD(brief, evidence, opts) { - const level = opts.level; - const productName = brief.product.name || titleFromIdea(brief.idea); - const compliance = brief.constraints.compliance ?? []; - const selfHost = /self[- ]?host|privacy|gdpr|own (your|the) data/i.test(`${brief.idea} ${brief.product.valueProp ?? ""}`) || compliance.length > 0; - const timeGoal = timeTokenFromGoals(brief.goals); - const categories = []; - for (const c of REQUIRED_NFR[level]) if (!categories.includes(c)) categories.push(c); - for (const c of brief.nfrPriorities) { - const k = c.toLowerCase().trim(); - if (k && !categories.includes(k)) categories.push(k); - } - const nonFunctional = categories.map((cat, i) => { - const t = nfrFor(cat); - const metric = specialiseMetric(cat, t.metric, { compliance, selfHost, timeGoal, budget: brief.constraints.budget }); - const statement = specialiseStatement(cat, t.statement, { compliance, selfHost }); - return { - id: `NFR-${pad3(i + 1)}`, - category: cat, - statement, - metric, - // Ground over the *specialised* text + distinctive brief facts (CalDAV, - // GDPR…), restricted to authoritative sources (no marketing pages). - rationaleEvidence: matchEvidence(`${cat} ${statement} ${brief.candidateTech.join(" ")} ${compliance.join(" ")}`, evidence, 1, GROUND_QUALITY) - }; - }); - const coreNfrIds = nonFunctional.filter((n) => REQUIRED_NFR.light.includes(n.category.toLowerCase())).map((n) => n.id); - const adrs = []; - const stack = brief.candidateTech.length ? brief.candidateTech.join(", ") : "a stack to be selected"; - adrs.push({ - id: "", - title: "Primary technology stack", - status: brief.candidateTech.length ? "accepted" : "proposed", - context: `Building "${productName}" requires a stack that fits the team (${brief.constraints.team || "to be defined"}) and timeline (${brief.constraints.timeline || "to be defined"}).`, - decision: `Adopt ${stack} as the primary stack for the initial build.`, - consequences: `The team commits to ${stack}; hiring, tooling and operational knowledge align to it. Revisit if a hard requirement is unmet.`, - alternatives: brief.candidateTech.length ? "No explicit alternative stack was provided in the brief; evaluate one comparable option before locking this in." : "Alternative stacks were considered but not selected.", - evidence: matchEvidence(`${stack} architecture stack`, evidence, 2, ["docs", "oss", "so"]) - }); - if (selfHost) { - adrs.push({ - id: "", - title: "Self-hosting and data-ownership model", - status: "accepted", - context: `"${productName}" is positioned as privacy-first / self-hostable${compliance.length ? ` and must satisfy: ${compliance.join(", ")}` : ""}.`, - decision: "Ship as a self-hostable deployment where the host owns all data; no user data is sent to a third-party service by default.", - consequences: "Data residency and compliance become the host's responsibility (a feature, not a liability); the product must run with no mandatory external dependencies and document its data flows.", - alternatives: "A hosted multi-tenant SaaS was considered but rejected as it conflicts with the privacy/data-ownership value proposition.", - evidence: matchEvidence(`self-host privacy data ownership ${compliance.join(" ")}`, evidence, 2, GROUND_QUALITY) - }); - } - const integrates = brief.featureWishlist.some((f) => INTEGRATION_RE.test(`${f.title} ${f.notes ?? ""}`)) || INTEGRATION_RE.test(brief.idea); - if (level === "complex" && (PERSIST_RE.test(briefText(brief)) || integrates)) { - adrs.push({ - id: "", - title: "Data persistence and integration approach", - status: "proposed", - context: `"${productName}" must persist state and integrate with external services (${brief.candidateTech.filter((t) => INTEGRATION_RE.test(t)).join(", ") || "calendar/email and similar"}) reliably.`, - decision: "Use a single primary datastore with explicit, versioned integration boundaries for each external service.", - consequences: "A clear data-ownership model; integrations are testable in isolation behind an adapter. Cross-service consistency must be designed explicitly.", - alternatives: "A polyglot-persistence or event-sourced approach was considered; deferred until scale demands it.", - evidence: matchEvidence(`${brief.candidateTech.join(" ")} database persistence integration`, evidence, 2, ["docs", "oss", "so"]) - }); - } - adrs.forEach((a, i) => a.id = pad4(i + 1)); - const stackAdrId = adrs[0].id; - const dataAdr = adrs.find((a) => /persistence|integration/i.test(a.title)); - const privacyAdr = adrs.find((a) => /self-hosting|data-ownership/i.test(a.title)); - const functional = brief.featureWishlist.map((f, i) => { - const priority = priorityOf(f.priority); - const text = `${f.title} ${f.notes ?? ""}`; - const touchesIntegration = INTEGRATION_RE.test(text); - const outcome = concreteOutcome(f.title, f.notes); - const acceptance = [ - { - given: `${productName} is available to a user`, - when: `they ${lowerFirst(f.title)}`, - then: outcome - }, - ...level === "complex" ? [failurePath(f.title, touchesIntegration)] : [] - ]; - const nfrs = [...coreNfrIds]; - for (const n of nonFunctional) { - if (coreNfrIds.includes(n.id)) continue; - const sig = NFR_SIGNALS[n.category.toLowerCase()]; - if (sig?.test(text)) nfrs.push(n.id); + const stack = [root]; + while (stack.length) { + if (out.length >= maxFiles) break; + const dir = stack.pop(); + let entries; + try { + entries = readdirSync2(dir); + } catch { + continue; + } + for (const name of entries) { + if (out.length >= maxFiles) break; + const abs = join4(dir, name); + let st; + try { + st = lstatSync(abs); + } catch { + continue; + } + if (st.isDirectory()) { + if (IGNORE_DIRS.has(name)) continue; + stack.push(abs); + continue; + } + if (!st.isFile()) continue; + if (st.size > maxFileBytes) continue; + if (LOCKFILES.has(name.toLowerCase())) continue; + const ext = extname(name).toLowerCase(); + if (BINARY_EXT.has(ext)) continue; + if (name.endsWith(".min.js") || name.endsWith(".min.css")) continue; + out.push({ rel: relative(root, abs).split(sep).join("/"), abs, size: st.size, ext }); } + } + return out; +} +function readText(abs) { + try { + const buf = readFileSync3(abs); + const head = buf.subarray(0, 4096); + if (head.includes(0)) return ""; + return buf.toString("utf8"); + } catch { + return ""; + } +} + +// src/providers/github.ts +function toItems(raw, kind) { + return (raw ?? []).filter((it) => it && typeof it === "object").map((it) => { + const body = String(it.body ?? "").replace(/\r/g, "").trim().slice(0, 1200); + const labels = (it.labels ?? []).map((l) => typeof l === "string" ? l : l.name).filter(Boolean).join(", "); + const state = it.draft ? "draft" : it.state; return { - id: `FR-${pad3(i + 1)}`, - title: f.title, - description: f.notes?.trim() || `The product lets a user ${lowerFirst(f.title)}.`, - priority, - acceptance, - rationaleEvidence: matchEvidence(text, evidence, 2, GROUND_REQUIREMENT), - entities: [], - interfaces: [], - nfrs, - unresolved: false, - ...f.module ? { module: f.module } : {} + source: kind, + title: `#${it.number} ${it.title} [${state}]`, + ref: `${kind}#${it.number}`, + location: it.html_url, + score: Number(it.score ?? 0), + snippet: `state: ${state}` + (labels ? ` \xB7 labels: ${labels}` : "") + ` \xB7 comments: ${it.comments ?? 0} \xB7 updated: ${it.updated_at ?? "?"} + +` + (body || "(no description)"), + url: it.html_url, + meta: { number: it.number, state, isPR: !!it.pull_request } }; }); - const modules = brief.modules?.length ? brief.modules.map((m) => ({ - id: m.id, - name: m.name, - ...m.description ? { description: m.description } : {}, - frIds: functional.filter((f) => f.module === m.id).map((f) => f.id), - dependsOn: m.dependsOn ?? [] - })) : void 0; - const dataModel = inferEntities(brief, functional); - const interfaces = inferInterfaces(brief, functional); - const evById = new Map(evidence.map((e) => [e.id, e])); - const competitors = brief.competitors.map((name) => { - const ev = matchEvidence(name, evidence, 2, ["market"]); - return { name, note: noteFrom(ev, evById) || `Comparable product / alternative to "${productName}".`, evidence: ev }; - }); - const ossByKey = /* @__PURE__ */ new Map(); - const keyOf = (s) => { - try { - return resolveRepo(s).slug; - } catch { - return s.toLowerCase(); - } +} +function apiBase(host) { + return /(^|\.)github\.com$/i.test(host) ? "https://api.github.com" : `https://${host}/api/v3`; +} +function ghUsable(host) { + return have("gh") && /(^|\.)github\.com$/i.test(host); +} +var canonCache = /* @__PURE__ */ new Map(); +async function canonicalRepo(ref) { + const fallback = { owner: ref.owner, repo: ref.repo }; + if (!/github/i.test(ref.host)) return fallback; + const key = `${ref.host}/${ref.owner}/${ref.repo}`; + const cached = canonCache.get(key); + if (cached) return cached; + let resolved = fallback; + const parse = (full) => { + const i = full.indexOf("/"); + return i > 0 ? { owner: full.slice(0, i), repo: full.slice(i + 1) } : fallback; }; - for (const seed of brief.ossSeeds) { - const ref = resolveRepo(seed); - const label = ref.owner && ref.repo ? `${ref.owner}/${ref.repo}` : seed; - const ev = matchEvidence(`${ref.owner ?? ""} ${ref.repo ?? ""}`.trim() || seed, evidence, 2, ["oss", "issue", "pr"]); - ossByKey.set(keyOf(seed), { - name: label, - url: ref.webUrl ?? (/^https?:/.test(seed) ? seed : void 0), - note: noteFrom(ev, evById) || "Seed OSS project mined for prior art.", - evidence: ev - }); + if (ghUsable(ref.host)) { + const r = sh("gh", ["api", `repos/${ref.owner}/${ref.repo}`, "--jq", ".full_name"]); + if (r.ok && r.stdout.includes("/")) resolved = parse(r.stdout.trim()); + } else { + const r = await httpGet(`${apiBase(ref.host)}/repos/${ref.owner}/${ref.repo}`, { accept: "application/vnd.github+json" }); + if (r.ok) { + try { + const full = JSON.parse(r.body)?.full_name; + if (typeof full === "string" && full.includes("/")) resolved = parse(full); + } catch { + } + } } - for (const e of evidence.filter((x) => x.source === "oss")) { - const k = keyOf(e.ref); - if (ossByKey.has(k)) { - if (!ossByKey.get(k).evidence.includes(e.id)) ossByKey.get(k).evidence.push(e.id); - continue; + canonCache.set(key, resolved); + return resolved; +} +async function query(ref, terms, kind, perSource) { + const q = `repo:${ref.owner}/${ref.repo} type:${kind} ${terms.join(" ")}`.trim(); + if (ghUsable(ref.host)) { + const res = sh("gh", ["api", "-X", "GET", "search/issues", "-f", `q=${q}`, "-f", `per_page=${perSource}`, "-f", "sort=updated", "-f", "order=desc"]); + if (res.ok) { + try { + return { items: toItems(JSON.parse(res.stdout).items, kind) }; + } catch { + } } - ossByKey.set(k, { - name: e.title.replace(/ —.*$/, ""), - url: e.url, - note: firstSentence(e.snippet) || "Comparable open-source project (prior art).", - evidence: [e.id] - }); } - const oss = [...ossByKey.values()]; - const buildPlan = buildMilestones(functional, brief, evidence, evById); - const design = opts.design ? buildDesignSystem(brief, functional) : void 0; - const traceability = functional.map((fr) => { - const text = `${fr.title} ${fr.description}`; - const adrIds = [stackAdrId]; - if (dataAdr && (PERSIST_RE.test(text) || INTEGRATION_RE.test(text))) adrIds.push(dataAdr.id); - if (privacyAdr && NFR_SIGNALS.privacy.test(text)) adrIds.push(privacyAdr.id); - const row = { fr: fr.id, nfrs: fr.nfrs, adrs: adrIds, entities: fr.entities, interfaces: fr.interfaces }; - if (design) { - row.components = design.components.filter((c) => c.relatedFRs.includes(fr.id)).map((c) => c.name); - row.screens = design.screens.filter((s) => s.relatedFRs.includes(fr.id)).map((s) => s.name); + const url = `${apiBase(ref.host)}/search/issues?q=${encodeURIComponent(q)}&per_page=${perSource}&sort=updated&order=desc`; + const r = await httpGet(url, { accept: "application/vnd.github+json" }); + if (!r.ok) { + const hint = r.status === 422 ? `query rejected (422) for repo:${ref.owner}/${ref.repo} \u2014 the repo may be moved/renamed/private, or the query had no valid terms` : `status ${r.status}; run \`gh auth login\` for higher-rate access`; + return { items: [], error: `GitHub ${kind} search unavailable (${hint}).` }; + } + try { + return { items: toItems(JSON.parse(r.body).items, kind) }; + } catch { + return { items: [], error: `GitHub ${kind} search returned an unparseable response.` }; + } +} +var github = { + name: "github", + matches: (host) => /(^|\.)github\.com$/i.test(host) || /github/i.test(host), + async search(ref0, question, kind, perSource) { + if (!ref0.owner || !ref0.repo) { + return { items: [], notes: ["No owner/repo resolved; cannot query GitHub issues/PRs."] }; } - if (fr.module) row.module = fr.module; - return row; - }); - const referenced = /* @__PURE__ */ new Set(); - for (const fr of functional) fr.rationaleEvidence.forEach((id) => referenced.add(id)); - for (const n of nonFunctional) n.rationaleEvidence.forEach((id) => referenced.add(id)); - for (const a of adrs) a.evidence.forEach((id) => referenced.add(id)); - for (const c of competitors) c.evidence.forEach((id) => referenced.add(id)); - for (const o of oss) o.evidence.forEach((id) => referenced.add(id)); - for (const m of buildPlan) (m.risks ?? []).forEach((r) => citationsIn(r).forEach((id) => referenced.add(id))); - const evidenceIndex = [...referenced].sort((a, b) => evNum(a) - evNum(b)); - return { - schemaVersion: SRD_SCHEMA_VERSION, - level, - generatedAt: opts.generatedAt, - product: { - name: productName, - problem: brief.product.problem || brief.goals[0] || `Address the need described by: ${brief.idea}`, - valueProp: brief.product.valueProp || `Deliver ${brief.idea} better than existing options.`, - users: brief.product.users?.length ? brief.product.users : ["primary user"], - metrics: brief.goals.length ? brief.goals : ["Define a measurable launch success metric."] - }, - scope: { - inScope: brief.featureWishlist.filter((f) => priorityOf(f.priority) !== "could").map((f) => f.title), - outOfScope: brief.nonGoals, - assumptions: deriveAssumptions(brief) - }, - functional, - ...modules ? { modules } : {}, - nonFunctional, - architecture: { context: contextProse(productName, brief), dataModel, interfaces, adrs }, - competitive: { competitors, oss }, - buildPlan, - traceability, - openQuestions: brief.openQuestions, - evidenceIndex, - ...design ? { design } : {} + const canon = await canonicalRepo(ref0); + const ref = { ...ref0, owner: canon.owner, repo: canon.repo }; + const ranked = rankedKeywords(question); + if (ranked.length === 0) return { items: [], notes: [`No keywords to search ${kind}s.`] }; + let lastError; + for (const terms of uniqueAttempts([ranked.slice(0, 3), ranked.slice(0, 2)])) { + const { items, error } = await query(ref, terms, kind, perSource * 2); + if (error) lastError = error; + if (items.length) return { items: rerank(items, ranked).slice(0, perSource), notes: [] }; + } + const seen = /* @__PURE__ */ new Map(); + for (const t of ranked.slice(0, 4)) { + const { items, error } = await query(ref, [t], kind, perSource * 2); + if (error) lastError = error; + for (const it of items) if (!seen.has(it.ref)) seen.set(it.ref, it); + } + const merged = rerank([...seen.values()], ranked).slice(0, perSource); + if (merged.length) return { items: merged, notes: [] }; + return { items: [], notes: lastError ? [lastError] : [`No ${kind}s matched the question.`] }; + } +}; +function rerank(items, ranked) { + const terms = ranked.map((t) => t.toLowerCase()); + const coverage = (it) => { + const hay = `${it.title} ${it.snippet}`.toLowerCase(); + let c = 0; + for (const t of terms) if (hay.includes(t)) c++; + return c; }; + return items.map((it) => ({ it, c: coverage(it), s: it.score })).sort((a, b) => b.c - a.c || b.s - a.s).map((x) => x.it); } -function deriveA11yStandard(brief) { - const explicit = brief.design?.accessibilityTarget?.trim(); - if (explicit) return explicit; - const hay = `${(brief.constraints.compliance ?? []).join(" ")} ${brief.nfrPriorities.join(" ")}`.toLowerCase(); - if (/\brgaa\b/.test(hay)) return "RGAA 4.1 (aligned to WCAG 2.2 AA)"; - if (/\b508\b|section 508/.test(hay)) return "Section 508 (WCAG 2.0 AA)"; - if (/en\s?301\s?549/.test(hay)) return "EN 301 549 (WCAG 2.1 AA)"; - return "WCAG 2.2 AA"; +function uniqueAttempts(lists) { + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const l of lists) { + const key = l.join("\0"); + if (l.length && !seen.has(key)) { + seen.add(key); + out.push(l); + } + } + return out; } -function buildPrinciples(brief) { - const hay = `${brief.idea} ${brief.product.valueProp ?? ""} ${brief.product.problem ?? ""} ${brief.nfrPriorities.join(" ")} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; - const out = []; - if (/self[- ]?host|privac|gdpr|own (your|the) data|no account/i.test(hay)) { - out.push("Privacy by default \u2014 the UI never surfaces or transmits data the user did not choose to share."); + +// src/providers/gitlab.ts +var gitlab = { + name: "gitlab", + matches: (host) => /gitlab/i.test(host), + async search(ref, question, kind, perSource) { + if (!ref.owner || !ref.repo) { + return { items: [], notes: ["No project path resolved; cannot query GitLab issues/MRs."] }; + } + const kw = rankedKeywords(question).slice(0, 4); + if (kw.length === 0) { + return { items: [], notes: [`No keywords to search GitLab ${kind === "issue" ? "issues" : "merge requests"}.`] }; + } + const proj = encodeURIComponent(`${ref.owner}/${ref.repo}`); + const path = kind === "issue" ? "issues" : "merge_requests"; + const search = encodeURIComponent(kw.join(" ")); + const url = `https://${ref.host}/api/v4/projects/${proj}/${path}?search=${search}&per_page=${perSource}&order_by=updated_at&sort=desc`; + const r = await httpGet(url, { accept: "application/json" }); + if (!r.ok) { + return { items: [], notes: [`GitLab ${kind} search unavailable (status ${r.status}).`] }; + } + try { + const arr = JSON.parse(r.body); + if (!Array.isArray(arr)) return { items: [], notes: [`GitLab ${kind} search returned no array.`] }; + const marker = kind === "issue" ? "#" : "!"; + const items = arr.filter((it) => it && typeof it === "object").map((it) => { + const num = it.iid ?? it.id; + const body = String(it.description ?? "").replace(/\r/g, "").trim().slice(0, 1200); + return { + source: kind, + title: `${marker}${num} ${it.title} [${it.state}]`, + ref: `${kind}#${num}`, + location: it.web_url, + score: 0, + snippet: `state: ${it.state} \xB7 updated: ${it.updated_at ?? "?"} + +${body || "(no description)"}`, + url: it.web_url, + meta: { iid: num, state: it.state } + }; + }); + return { items, notes: [] }; + } catch { + return { items: [], notes: [`GitLab ${kind} search returned an unparseable response.`] }; + } } - if (/fast|speed|sub-?second|latenc|instant|under \d/i.test(hay)) { - out.push("Perceived performance first \u2014 optimistic UI, skeletons over spinners, immediate feedback on every action."); +}; + +// src/providers/generic.ts +var generic = { + name: "generic", + matches: () => true, + async search(ref, _question, kind) { + return { + items: [], + notes: [`No public ${kind} API for host "${ref.host}". The code was cloned and indexed; issues/PRs are not retrievable for this host.`] + }; } - out.push("Accessible to everyone \u2014 every flow works with the keyboard and assistive technology, by construction."); - out.push("Consistency over novelty \u2014 reuse tokens and components before inventing new ones."); - out.push("Progressive disclosure \u2014 show the essential first; reveal complexity only on demand."); - out.push("Clear over clever \u2014 plain language, obvious affordances, honest empty and error states."); - return out.slice(0, 5); +}; + +// src/providers/registry.ts +var PROVIDERS = [github, gitlab]; +function providerFor(host) { + return PROVIDERS.find((p) => p.matches(host)) ?? generic; } -function seedTokens(brief) { - const brand = brief.design?.brandConstraints?.trim(); - const byCategory = { - color: [ - { category: "color", name: "color.bg", value: "#ffffff", note: brand ? `Adjust to brand: ${brand}` : "Primary surface" }, - { category: "color", name: "color.fg", value: "#111827", note: "Primary text" }, - { category: "color", name: "color.primary", value: "#2563eb", note: "Primary action / brand accent" }, - { category: "color", name: "color.danger", value: "#dc2626", note: "Destructive / error" }, - { category: "color", name: "color.muted", value: "#6b7280", note: "Secondary text / borders" } - ], - typography: [ - { category: "typography", name: "font.sans", value: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }, - { category: "typography", name: "font.mono", value: "ui-monospace, SFMono-Regular, Menlo, monospace" }, - { category: "typography", name: "scale.body", value: "16px / 1.5" }, - { category: "typography", name: "scale.h1", value: "32px / 1.25" }, - { category: "typography", name: "scale.small", value: "13px / 1.4" } - ], - spacing: [ - { category: "spacing", name: "space.1", value: "4px" }, - { category: "spacing", name: "space.2", value: "8px" }, - { category: "spacing", name: "space.3", value: "12px" }, - { category: "spacing", name: "space.4", value: "16px" }, - { category: "spacing", name: "space.6", value: "24px" }, - { category: "spacing", name: "space.8", value: "32px" } - ], - radius: [ - { category: "radius", name: "radius.sm", value: "4px" }, - { category: "radius", name: "radius.md", value: "8px" }, - { category: "radius", name: "radius.lg", value: "12px" } - ], - elevation: [ - { category: "elevation", name: "shadow.sm", value: "0 1px 2px rgba(0,0,0,0.06)" }, - { category: "elevation", name: "shadow.md", value: "0 4px 12px rgba(0,0,0,0.10)" } - ], - motion: [ - { category: "motion", name: "motion.fast", value: "120ms ease-out" }, - { category: "motion", name: "motion.base", value: "200ms ease-out" } - ] - }; - return DESIGN_TOKEN_CATEGORIES.flatMap((c) => byCategory[c]); + +// src/research/oss.ts +var NON_REPO_OWNERS = /* @__PURE__ */ new Set([ + "topics", + "search", + "collections", + "trending", + "explore", + "marketplace", + "sponsors", + "features", + "about", + "pricing", + "login", + "join", + "signup", + "settings", + "notifications", + "issues", + "pulls", + "orgs", + "apps", + "blog", + "site", + "enterprise", + "customer-stories", + "security", + "readme", + "events", + "dashboard", + "groups", + "users", + "help", + "projects", + "-" +]); +function canonicalRepoUrl(url) { + const m = /^(https?:\/\/(?:github|gitlab)\.com\/([A-Za-z0-9._-]+)\/[A-Za-z0-9._-]+)/i.exec(url); + if (!m || NON_REPO_OWNERS.has(m[2].toLowerCase())) return void 0; + return m[1].replace(/\.git$/, ""); } -var COMPONENT_DEFS = [ - { name: "App Shell & Navigation", purpose: "Overall layout, navigation and routing chrome that frames every screen.", re: /.*/ }, - { name: "Button & Actions", purpose: "Primary, secondary and destructive action controls with loading/disabled states.", re: /.*/ }, - { - name: "Form & Input", - purpose: "Labelled inputs with inline validation and accessible error messaging.", - re: /save|add|create|edit|import|tag|organi[sz]e|login|sign|submit|upload|compose|write|configure|invite/i - }, - { - name: "List & Collection", - purpose: "Paginated/virtualised lists of saved items with selection and bulk actions.", - re: /list|search|browse|organi[sz]e|tag|feed|library|archive|history|result|collection|inbox/i - }, - { name: "Detail View", purpose: "The focused reading/detail surface for a single item.", re: /read|view|open|detail|article|item|show|preview|document/i }, - { name: "Search & Filter", purpose: "Query input, filters and ranked results with empty/no-match handling.", re: /search|filter|find|query|sort|facet/i }, - { name: "Feedback & Notifications", purpose: "Toasts, banners and inline status for success, error and async progress.", re: /.*/ }, - { name: "Empty & Error States", purpose: "First-run, no-data and failure states that teach the next action.", re: /.*/ } -]; -function buildComponents(functional) { - const out = []; - for (const def of COMPONENT_DEFS) { - const relatedFRs = functional.filter((fr) => def.re.test(`${fr.title} ${fr.description}`)).map((fr) => fr.id); - if (relatedFRs.length === 0) continue; - out.push({ name: def.name, purpose: def.purpose, states: [...COMPONENT_STATES], relatedFRs, evidence: [] }); +function normalizeSeed(raw) { + const s = raw.trim(); + if (!s) return void 0; + if (/^https?:\/\/github\.com\//i.test(s)) return canonicalRepoUrl(s); + const ref = resolveRepo(s); + return ref.isLocal || ref.owner && ref.repo ? s : void 0; +} +function languageHistogram(files) { + const counts = /* @__PURE__ */ new Map(); + for (const f of files) { + const ext = f.ext.replace(/^\./, ""); + if (!ext) continue; + counts.set(ext, (counts.get(ext) ?? 0) + 1); } - return out; + return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); } -function buildScreens(functional) { - const inScope = functional.filter((fr) => fr.priority !== "could"); - const mustIds = functional.filter((fr) => fr.priority === "must").map((fr) => fr.id); - const screens = [ - { name: "Home / Dashboard", purpose: "The landing surface after sign-in; entry point to the primary tasks.", relatedFRs: mustIds } - ]; - for (const fr of inScope) { - screens.push({ name: `${fr.title}`, purpose: `Where a user can ${lowerFirst(fr.title)}.`, relatedFRs: [fr.id] }); +async function ossAngle(ctx) { + const notes = []; + let seeds = [...new Set(ctx.brief.ossSeeds.map(normalizeSeed).filter((x) => !!x))]; + if (seeds.length === 0) { + const q2 = `${ctx.query || ctx.brief.idea} open source github`; + const d = await discover(q2, ctx.webEngine, ctx.perSource); + notes.push(`OSS discovery via ${d.via} for "${q2}".`, ...d.notes); + seeds = [...new Set(d.urls.map(canonicalRepoUrl).filter((x) => !!x))]; + } + seeds = seeds.slice(0, 3); + if (seeds.length === 0) { + return [{ source: "oss", items: [], notes: [...notes, "No comparable OSS projects found."] }]; + } + const ossItems = []; + const issueItems = []; + const prItems = []; + const q = ctx.query || ctx.brief.idea; + for (const seed of seeds) { + const ref = resolveRepo(seed); + let dir; + try { + dir = ensureClone(ref, { refresh: ctx.refresh }); + } catch (e) { + notes.push(`Could not clone ${ref.raw}: ${e.message}`); + } + const repoLabel = ref.owner && ref.repo ? `${ref.owner}/${ref.repo}` : ref.slug; + if (dir) { + const files = walk(dir); + const langs = languageHistogram(files).slice(0, 6).map(([e, c]) => `${e}:${c}`).join(", "); + let snippet = `Languages: ${langs || "n/a"} \xB7 files: ${files.length}.`; + const readme = files.find((f) => /^readme(\.|$)/i.test(f.rel)) ?? files.find((f) => /(^|\/)readme\./i.test(f.rel)); + if (readme) { + const text = readText(readme.abs); + const ex = excerptsFromText(text, ref.webUrl ?? ref.raw, repoLabel, "oss", q, 1); + if (ex[0]) snippet += ` + +${ex[0].snippet}`; + } + ossItems.push({ + source: "oss", + title: `${repoLabel} \u2014 prior art`, + ref: repoLabel, + location: ref.webUrl, + score: files.length, + snippet, + url: ref.webUrl + }); + } + if (ref.owner && ref.repo) { + const provider = providerFor(ref.host); + const iss = await provider.search(ref, q, "issue", ctx.perSource); + issueItems.push(...iss.items); + notes.push(...iss.notes); + const prs = await provider.search(ref, q, "pr", ctx.perSource); + prItems.push(...prs.items); + notes.push(...prs.notes); + } } - screens.push({ name: "Settings & Account", purpose: "Preferences, data export/delete and account management.", relatedFRs: [] }); - return screens; + return [ + { source: "oss", items: ossItems, notes }, + { source: "issue", items: issueItems, notes: [] }, + { source: "pr", items: prItems, notes: [] } + ]; } -function buildFlows(functional) { - const must = functional.filter((fr) => fr.priority === "must"); - const flows = [ - { - name: "First-run onboarding", - steps: ["Arrive at an empty, explanatory first-run state", "Complete the minimal setup", "Reach the dashboard ready to act"], - frIds: must.map((fr) => fr.id) + +// src/research/stackoverflow.ts +function soTagFor(tech) { + return tech.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9.+-]/g, "").replace(/^-+|-+$/g, ""); +} +async function soQuery(q, perSource, tag) { + const pat = process.env.STACK_PAT ? `&access_token=${process.env.STACK_PAT}` : ""; + const tagged = tag ? `&tagged=${encodeURIComponent(tag)}` : ""; + const url = `https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${q}&site=stackoverflow&filter=withbody&pagesize=${perSource}${tagged}${pat}`; + const r = await httpGet(url, { accept: "application/json" }); + return { ok: r.ok, status: r.status, body: r.body, url }; +} +async function stackoverflow(question, perSource, opts = {}) { + const kws = rankedKeywords(question).slice(0, 5).join(" "); + if (!kws) return { source: "so", items: [], notes: ["No keywords to search StackOverflow."] }; + const q = encodeURIComponent(kws); + const notes = []; + let r = await soQuery(q, perSource, opts.tag); + if (!r.ok) { + return { source: "so", items: [], notes: [`StackOverflow search unavailable (status ${r.status}).`] }; + } + let data; + try { + data = JSON.parse(r.body); + } catch { + return { source: "so", items: [], notes: ["StackOverflow search returned an unparseable response."] }; + } + if (opts.tag && (data.items ?? []).length === 0) { + r = await soQuery(q, perSource, void 0); + if (r.ok) { + try { + data = JSON.parse(r.body); + notes.push(`No tagged:${opts.tag} results \u2014 retried without the tag.`); + } catch { + } } - ]; - for (const fr of must) { - flows.push({ - name: `${fr.title} \u2014 happy path`, - steps: ["Navigate to the relevant screen", `Perform: ${lowerFirst(fr.title)}`, "Receive clear confirmation of the outcome"], - frIds: [fr.id] + } + const wantKws = new Set(keywords(question).map((k) => k.toLowerCase())); + const items = []; + let filtered = 0; + for (const raw of data.items ?? []) { + const it = raw; + const title = htmlToText(String(it.title ?? "(question)")).slice(0, 160); + const tags = Array.isArray(it.tags) ? it.tags : []; + if (wantKws.size) { + const hay = new Set(keywords(`${title} ${tags.join(" ")}`).map((k) => k.toLowerCase())); + const overlaps = [...wantKws].some((k) => hay.has(k)) || tags.some((t) => wantKws.has(t.toLowerCase())); + if (!overlaps) { + filtered++; + continue; + } + } + const body = htmlToText(String(it.body ?? "")).slice(0, 1200); + const accepted = it.is_answered ? "answered" : "unanswered"; + items.push({ + source: "so", + title, + ref: `so:${it.question_id}`, + location: it.link, + score: Number(it.score ?? 0), + snippet: `score: ${it.score ?? 0} \xB7 ${accepted} \xB7 answers: ${it.answer_count ?? 0}` + (tags.length ? ` \xB7 tags: ${tags.slice(0, 6).join(", ")}` : "") + ` + +${body || "(no body)"}`, + url: it.link, + meta: { questionId: it.question_id, isAnswered: it.is_answered, answerCount: it.answer_count } }); } - return flows; + if (filtered) notes.push(`Filtered ${filtered} off-topic StackOverflow result(s) (no keyword overlap with the query).`); + if (data.quota_remaining !== void 0 && data.quota_remaining < 20) notes.push(`StackExchange anonymous quota low (${data.quota_remaining} left).`); + if (items.length === 0) notes.push("No StackOverflow questions matched."); + return { source: "so", items, notes }; } -function a11yRequirements() { - const defs = [ - { - statement: "Every interactive control is fully keyboard operable.", - given: "a user navigates with the keyboard only", - when: "they tab through any flow", - then: "every interactive control is reachable, operable and follows a logical focus order" - }, - { - statement: "Focus is always visible.", - given: "an element receives keyboard focus", - when: "the user is navigating", - then: "a visible focus indicator is shown and meets the non-text contrast minimum" - }, - { - statement: "Colour contrast meets the target standard.", - given: "any text or essential UI element", - when: "it is rendered in any supported theme", - then: "contrast meets the target (\u2265 4.5:1 for body text, \u2265 3:1 for large text and UI)" - }, - { - statement: "Every control and image exposes an accessible name.", - given: "a form control, icon-only button or meaningful image", - when: "it is read by assistive technology", - then: "it exposes a programmatic label/name and images carry meaningful alt text (decorative images are hidden)" - }, - { - statement: "Structure and async changes are conveyed semantically.", - given: "a screen is parsed by a screen reader", - when: "the user explores it", - then: "headings, landmarks and roles convey the structure and live regions announce asynchronous changes" - }, - { - statement: "Reduced motion and zoom are respected.", - given: "a user prefers reduced motion or zooms to 200%", - when: "they use the product", - then: "non-essential motion is reduced or disabled and content reflows without loss of content or function" + +// src/research/tech.ts +async function techAngle(ctx) { + const allTechs = ctx.brief.candidateTech; + const techs = allTechs.slice(0, 3); + const ideaKw = ctx.query || ctx.brief.idea; + const docItems = []; + const docNotes = []; + if (allTechs.length > techs.length) { + docNotes.push( + `Only the first ${techs.length} of ${allTechs.length} candidate technologies were grounded; skipped: ${allTechs.slice(techs.length).join(", ")}. Drill them with \`construct tech --out --q ""\`.` + ); + } + if (ctx.docsUrls?.length) { + const direct = await webFetchUrls(ctx.docsUrls, ideaKw, ctx.perSource, "docs", true); + docItems.push(...direct.items); + docNotes.push(`Grounded ${ctx.docsUrls.length} docs URL(s) passed via --docs-url.`, ...direct.notes); + } + for (const tech of techs) { + const q = `${tech} official documentation`; + const { urls, via, notes } = await discover(q, ctx.webEngine, ctx.perSource); + docNotes.push(`Docs discovery for "${tech}" via ${via}.`, ...notes); + if (!urls.length) continue; + const fetched = await webFetchUrls(urls.slice(0, 1), `${tech} ${ideaKw}`, ctx.perSource, "docs"); + docItems.push(...fetched.items); + docNotes.push(...fetched.notes); + } + if (techs.length === 0 && !ctx.docsUrls?.length) docNotes.push("No candidate technologies in the brief \u2014 nothing to ground feasibility against."); + const topKw = rankedKeywords(ideaKw)[0] ?? ""; + const soItems = []; + const soNotes = []; + const seen = /* @__PURE__ */ new Set(); + const per = Math.max(2, Math.ceil(ctx.perSource / Math.max(1, techs.length))); + for (const tech of techs) { + const q = `${tech} ${topKw}`.trim(); + const r = await stackoverflow(q, per, { tag: soTagFor(tech) }); + for (const it of r.items) { + if (!seen.has(it.ref)) { + seen.add(it.ref); + soItems.push(it); + } } - ]; - return defs.map((d, i) => ({ - id: `A11Y-${pad3(i + 1)}`, - statement: d.statement, - acceptance: [{ given: d.given, when: d.when, then: d.then }] - })); -} -function buildContentVoice(brief) { - const tone = brief.design?.tone?.trim(); + soNotes.push(...r.notes); + } + if (techs.length === 0) soNotes.push("No candidate technologies to search StackOverflow for."); return [ - tone ? `Voice & tone: ${tone}.` : "Voice & tone: clear, concise and human \u2014 plain language over jargon.", - "Label actions with the outcome the user gets, not the system operation behind it.", - "Error messages state what happened, why, and the next step \u2014 never blame the user.", - "Empty states teach the first useful action; success states confirm exactly what changed." + { source: "docs", items: docItems, notes: docNotes }, + { source: "so", items: soItems, notes: soNotes } ]; } -function buildDesignSystem(brief, functional) { - return { - principles: buildPrinciples(brief), - tokens: seedTokens(brief), - components: buildComponents(functional), - screens: buildScreens(functional), - flows: buildFlows(functional), - accessibility: { standard: deriveA11yStandard(brief), requirements: a11yRequirements() }, - contentVoice: buildContentVoice(brief) - }; + +// src/research/semantic.ts +import { existsSync as existsSync4 } from "fs"; +import { join as join5, dirname } from "path"; +import { fileURLToPath } from "url"; +var OLLAMA = (process.env.CONSTRUCT_OLLAMA || "http://localhost:11434").replace(/\/$/, ""); +var EMBED_MODEL = process.env.CONSTRUCT_EMBED_MODEL || "nomic-embed-text"; +function cosine(a, b) { + if (a.length === 0 || a.length !== b.length) return 0; + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + if (na === 0 || nb === 0) return 0; + const r = dot / (Math.sqrt(na) * Math.sqrt(nb)); + return Number.isFinite(r) ? r : 0; } -function concreteOutcome(title, notes) { - const n = (notes ?? "").trim(); - const q = /\b(?:within|at least|at most|no more than|up to|under)\s+\d[^.;,]{0,60}/i.exec(n); - const m = /\b(?:never|always|so that|so it|must|should|guarantee[sd]?|ensure[sd]?|without)\b\s+([^.;,]{4,})/i.exec(n); - if (m?.[1]) { - const clause = m[1].split(/[;,]/)[0].trim().replace(/\s+/g, " "); - if (clause.length > 3 && (/\d/.test(clause) || !q)) return `the action succeeds and ${lowerFirst(clause)}`; +async function reachable(base, path = "/") { + const r = await httpGet(base + path, { timeoutMs: REACHABLE_TIMEOUT_MS }); + return r.ok; +} +async function embed(text) { + const r = await httpJson("POST", `${OLLAMA}/api/embeddings`, { model: EMBED_MODEL, prompt: text.slice(0, 4e3) }, { timeoutMs: EMBED_TIMEOUT_MS }); + const v = r.ok ? r.data?.embedding : void 0; + return Array.isArray(v) && v.length ? v : null; +} +async function semanticRescore(results, query2) { + const unchanged = (why) => ({ + available: false, + results, + notes: [`Semantic mode unavailable (${why}); kept lexical ranking.`] + }); + if (!await reachable(OLLAMA, "/api/tags")) { + return unchanged(`Ollama not reachable at ${OLLAMA} \u2014 run \`construct semantic up\``); } - const t = /\bin under [^.;,]+/i.exec(n); - if (t) return `the action completes ${t[0].trim().replace(/\s+/g, " ")}`; - if (q) return `the outcome honours the stated bound: ${q[0].trim().replace(/\s+/g, " ").toLowerCase()}`; - return `the result of "${title.toLowerCase()}" is persisted and visible to the user`; + const qv = await embed(query2); + if (!qv) return unchanged(`could not embed the query (is the '${EMBED_MODEL}' model pulled?)`); + const out = []; + let failures = 0; + for (const r of results) { + const items = []; + for (const it of r.items) { + const v = await embed(`${it.title} +${it.snippet}`); + if (v) { + items.push({ ...it, score: Number(cosine(qv, v).toFixed(4)), meta: { ...it.meta ?? {}, semantic: true } }); + } else { + failures++; + items.push({ ...it, score: -1, meta: { ...it.meta ?? {}, semantic: false } }); + } + } + out.push({ ...r, items }); + } + const notes = [`Semantic rescoring via Ollama + ${EMBED_MODEL} (local).`]; + if (failures) notes.push(`${failures} item(s) could not be embedded; ranked last.`); + return { available: true, results: out, notes }; } -function failurePath(title, integration) { - if (integration) { - return { - given: `the external service required by "${title.toLowerCase()}" is unreachable or rejects the request`, - when: `a user performs the action`, - then: `the system surfaces a clear, specific error and makes no partial or inconsistent change` - }; +function composeFile() { + const here = dirname(fileURLToPath(import.meta.url)); + for (const cand of [join5(here, "..", "docker-compose.yml"), join5(here, "docker-compose.yml"), join5(here, "..", "..", "docker-compose.yml")]) { + if (existsSync4(cand)) return cand; } - return { - given: `a user submits invalid or missing input for "${title.toLowerCase()}"`, - when: `the action is submitted`, - then: `the system rejects it with a clear, actionable error and no side effects` - }; + return null; } -function specialiseMetric(cat, base, ctx) { - const c = cat.toLowerCase(); - if ((c === "performance" || c === "usability") && ctx.timeGoal) { - return `${base} Honour the product goal: ${ctx.timeGoal}.`; +function semanticControl(action, composeFilePath = composeFile()) { + if (!["up", "down", "status"].includes(action)) { + return { message: `construct semantic: unknown action "${action}" (use: up | down | status)`, code: 1 }; + } + if (!have("docker")) { + return { message: "construct semantic: docker not found. Install Docker, then retry. See references/semantic-setup.md.", code: 1 }; + } + if (!composeFilePath) { + return { + message: "construct semantic: docker-compose.yml not found next to the bundle \u2014 reinstall the skill (`npx skills add maxgfr/construct`), or run from the repo. See references/semantic-setup.md.", + code: 1 + }; } - if ((c === "privacy" || c === "security") && ctx.compliance.length) { - return `${base} Comply with: ${ctx.compliance.join(", ")}.`; + const file = composeFilePath; + if (action === "down") { + const r = sh("docker", ["compose", "-f", file, "--profile", "all", "down"], { timeoutMs: COMPOSE_DOWN_TIMEOUT_MS }); + return { message: r.ok ? "construct semantic: stack stopped." : `construct semantic: down failed. +${r.stderr}`, code: r.ok ? 0 : 1 }; } - if (c === "cost" && ctx.budget) { - return `${base} Stay within the stated budget: ${ctx.budget}.`; + if (action === "status") { + const r = sh("docker", ["compose", "-f", file, "ps"], { timeoutMs: COMPOSE_PS_TIMEOUT_MS }); + return { message: r.ok ? r.stdout || "construct semantic: no services running." : `construct semantic: status failed. +${r.stderr}`, code: 0 }; } - return base; + const up = sh("docker", ["compose", "-f", file, "--profile", "all", "up", "-d"], { timeoutMs: COMPOSE_UP_TIMEOUT_MS }); + if (!up.ok) return { message: `construct semantic: up failed. +${up.stderr}`, code: 1 }; + const pull = sh("docker", ["compose", "-f", file, "exec", "-T", "ollama", "ollama", "pull", EMBED_MODEL], { timeoutMs: OLLAMA_PULL_TIMEOUT_MS }); + const lines = [ + "construct semantic: stack is up (Qdrant :6333 \xB7 Ollama :11434 \xB7 SearXNG :8888).", + pull.ok ? ` model: ${EMBED_MODEL} ready` : ` model: pull '${EMBED_MODEL}' yourself: docker compose -f ${file} exec ollama ollama pull ${EMBED_MODEL}`, + " use: construct research --out --angles market,oss,tech,semantic --semantic" + ]; + return { message: lines.join("\n"), code: 0 }; } -function specialiseStatement(cat, base, ctx) { - const c = cat.toLowerCase(); - if ((c === "privacy" || c === "security") && ctx.selfHost) { - return `${base} No personal data leaves the self-hosted instance unless the host configures it.`; - } - return base; + +// src/research/dossier.ts +import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "fs"; +import { join as join6 } from "path"; +var SOURCE_ORDER = ["market", "oss", "docs", "so", "issue", "pr"]; +var SOURCE_LABEL = { + market: "Market & competitors", + oss: "Open-source prior art", + docs: "Technology documentation", + so: "StackOverflow", + issue: "Issues (prior art)", + pr: "Pull / Merge Requests (prior art)" +}; +function rank(s) { + const i = SOURCE_ORDER.indexOf(s); + return i < 0 ? 99 : i; } -function buildMilestones(functional, _brief, evidence, evById) { - const groups = [ - { key: "must", title: "M1 \u2014 Walking skeleton (must-haves)", outcome: "A usable end-to-end slice covering every must-have requirement." }, - { key: "should", title: "M2 \u2014 Rounded product (should-haves)", outcome: "The product is complete enough for real users." }, - { key: "could", title: "M3 \u2014 Enhancements (could-haves)", outcome: "Nice-to-have capabilities that differentiate the product." } - ]; - const priorPitfalls = evidence.filter((e) => e.source === "issue" || e.source === "pr"); +function assignIds(results) { + const flat = results.flatMap((r) => r.items); + flat.sort((a, b) => rank(a.source) - rank(b.source) || b.score - a.score || a.ref.localeCompare(b.ref)); + return flat.map((it, i) => ({ id: `E${i + 1}`, ...it })); +} +function renderEvidenceMarkdown(evidence, meta) { const out = []; - for (const g of groups) { - const frs = functional.filter((f) => f.priority === g.key); - if (frs.length === 0) continue; - const risks = []; - const text = frs.map((f) => `${f.title} ${f.description}`).join(" "); - const matched = matchEvidence(text, priorPitfalls, 2); - for (const id of matched) { - const e = evById.get(id); - if (e) risks.push(`Prior art shows a related pitfall: ${firstSentence(e.title)} [${id}]`); + out.push(`# Evidence dossier`); + out.push(""); + out.push(`**Idea:** ${meta.idea}`); + if (meta.query) out.push(`**Query:** ${meta.query}`); + out.push(`**Angles:** ${meta.angles.join(", ")} \xB7 **semantic:** ${meta.semantic ? "on" : "off"} \xB7 **built:** ${meta.builtAt}`); + out.push(""); + out.push( + `> Ground the SRD's requirements and decisions in this evidence. Cite items by id, e.g. \`[E1]\`. Grounding is advisory \u2014 \`construct check\` reports coverage but never fails on it. Still: prefer a cited claim to a guessed one.` + ); + out.push(""); + if (evidence.length === 0) { + out.push(`_No evidence was retrieved. Broaden the query, add angles, or check connectivity._`); + } + for (const source of SOURCE_ORDER) { + const items = evidence.filter((e) => e.source === source); + if (items.length === 0) continue; + out.push(`## ${SOURCE_LABEL[source]}`); + out.push(""); + for (const it of items) { + out.push(`### [${it.id}] ${it.title}`); + const meta1 = [`ref: \`${it.ref}\``, it.location ? `loc: \`${it.location}\`` : "", `score: ${it.score}`].filter(Boolean).join(" \xB7 "); + out.push(meta1); + if (it.url) out.push(`url: ${it.url}`); + out.push(""); + out.push("```"); + out.push(it.snippet); + out.push("```"); + out.push(""); } - out.push({ title: g.title, outcome: g.outcome, frIds: frs.map((f) => f.id), risks }); } - if (out.length === 0) out.push({ title: "M1 \u2014 Initial build", outcome: "Deliver the first usable version.", frIds: functional.map((f) => f.id), risks: [] }); - return out; + if (meta.notes.length) { + out.push(`## Retrieval notes`); + out.push(""); + for (const n of meta.notes) out.push(`- ${n}`); + out.push(""); + } + return out.join("\n"); } -function deriveAssumptions(brief) { - const a = []; - if (brief.constraints.team) a.push(`The team is: ${brief.constraints.team}.`); - if (brief.constraints.timeline) a.push(`The timeline is: ${brief.constraints.timeline}.`); - if (brief.constraints.budget) a.push(`The budget is: ${brief.constraints.budget}.`); - if (brief.constraints.compliance?.length) a.push(`Compliance applies: ${brief.constraints.compliance.join(", ")}.`); - if (a.length === 0) a.push("No hard constraints were captured; revisit budget, timeline and team before committing."); - return a; +function writeDossier(dir, evidence, meta) { + mkdirSync4(dir, { recursive: true }); + const evidenceJson = join6(dir, "evidence.json"); + const evidenceMd = join6(dir, "EVIDENCE.md"); + const metaJson = join6(dir, "meta.json"); + writeFileSync3(evidenceJson, JSON.stringify(evidence, null, 2)); + writeFileSync3(evidenceMd, renderEvidenceMarkdown(evidence, meta)); + writeFileSync3(metaJson, JSON.stringify(meta, null, 2)); + return { dir, evidenceJson, evidenceMd, metaJson }; } -function contextProse(name, brief) { - const actors = brief.product.users?.length ? brief.product.users : ["users"]; - const boundaries = detectBoundaries(brief).map((b) => b.label); - const stack = brief.candidateTech.length ? ` Built on ${brief.candidateTech.join(", ")}.` : ""; - const ext = boundaries.length ? ` It integrates with: ${boundaries.join("; ")}.` : ""; - return `"${name}" serves ${actors.join(", ")}.${stack}${ext} Each integration boundary is owned by an ADR and detailed in INTERFACES.md during authoring.`; + +// src/research/registry.ts +var HANDLERS = { + market: marketAngle, + oss: ossAngle, + tech: techAngle +}; +var ANGLE_SOURCE = { + market: "market", + oss: "oss", + tech: "docs" +}; +async function runAngles(ctx) { + const active = ctx.angles.filter((a) => a !== "semantic"); + const settled = await Promise.all( + active.map(async (a) => { + try { + return await HANDLERS[a](ctx); + } catch (e) { + return [{ source: ANGLE_SOURCE[a], items: [], notes: [`${a} angle failed: ${e.message}`] }]; + } + }) + ); + let results = settled.flat(); + const notes = []; + if (ctx.semantic || ctx.angles.includes("semantic")) { + const q = ctx.query || ctx.brief.idea; + const s = await semanticRescore(results, q); + results = s.results; + notes.push(...s.notes); + } + return { results, notes }; } -function noteFrom(ids, evById) { - for (const id of ids) { - const e = evById.get(id); - const s = e ? firstSentence(e.snippet) : ""; - if (s) return s; +async function runResearch(ctx, builtAt) { + const { results, notes } = await runAngles(ctx); + const capped = results.map((r) => ({ + ...r, + items: [...r.items].sort((a, b) => b.score - a.score).slice(0, ctx.perSource) + })); + const evidence = assignIds(capped); + const presentSources = [...new Set(evidence.map((e) => e.source))]; + const meta = { + idea: ctx.brief.idea, + angles: ctx.angles, + query: ctx.query || void 0, + sources: presentSources, + semantic: ctx.semantic || ctx.angles.includes("semantic"), + evidenceCount: evidence.length, + builtAt, + notes: [...capped.flatMap((r) => r.notes), ...notes] + }; + const dir = join7(ctx.runDir, "evidence"); + const paths = writeDossier(dir, evidence, meta); + return { dir, evidence, meta, paths }; +} + +// src/render.ts +import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5, rmSync as rmSync2 } from "fs"; +import { join as join10, dirname as dirname2 } from "path"; + +// src/srd.ts +import { join as join8 } from "path"; +function srdManifestPath(runDir) { + return join8(runDir, "SRD.json"); +} +function pad3(n) { + return String(n).padStart(3, "0"); +} +function pad4(n) { + return String(n).padStart(4, "0"); +} +var GROUND_REQUIREMENT = ["market", "oss", "docs", "so", "issue", "pr"]; +var GROUND_QUALITY = ["oss", "docs", "so", "issue", "pr"]; +function matchEvidence(text, evidence, n, onlySources) { + const kws = keywords(text).map((k) => k.toLowerCase()); + if (kws.length === 0) return []; + const need = Math.min(2, kws.length); + const ratioFloor = 0.34; + const scored = evidence.filter((e) => !onlySources || onlySources.includes(e.source)).map((e) => { + const hay = new Set(keywords(`${e.title} ${e.snippet}`).map((k) => k.toLowerCase())); + let cov = 0; + for (const kw of kws) if (hay.has(kw)) cov++; + return { id: e.id, key: e.url || `${e.source}:${e.ref}`, cov, ratio: cov / kws.length, score: e.score }; + }).filter((x) => x.cov >= need && x.ratio >= ratioFloor).sort((a, b) => b.cov - a.cov || b.ratio - a.ratio || b.score - a.score || a.id.localeCompare(b.id)); + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const x of scored) { + if (seen.has(x.key)) continue; + seen.add(x.key); + out.push(x.id); + if (out.length >= n) break; } - return void 0; + return out; } -function firstSentence(s) { - const clean = s.replace(/\s+/g, " ").trim(); - if (!clean) return ""; - const m = /^(.{1,200}?[.!?])(\s|$)/.exec(clean); - return (m ? m[1] : clean.slice(0, 160)).trim(); +function mentionsEntity(name, e) { + const phrase = name.trim().toLowerCase().replace(/\s+/g, " "); + if (!phrase) return false; + const hay = `${e.title} ${e.snippet}`.toLowerCase().replace(/\s+/g, " "); + const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(? `${f.title} ${f.notes ?? ""}`).join(" ")}`; -} -function citationsIn(s) { - const out = []; - const re = /\[(E\d+)\]/g; - let m; - while (m = re.exec(s)) out.push(m[1]); - return out; -} -function titleFromIdea(idea) { - const first = idea.split(/[.,;:]/)[0]?.trim() || idea.trim(); - return first.length > 40 ? first.slice(0, 40).trim() : first || "The Product"; -} -function lowerFirst(s) { - const t = s.trim(); - return t ? t[0].toLowerCase() + t.slice(1) : t; +}; +function nfrFor(category) { + const key = category.toLowerCase().trim(); + return NFR_TEMPLATES[key] ?? { + statement: `The system meets the "${category}" quality expectation defined for this product.`, + metric: `A measurable target for "${category}" is agreed and tracked.` + }; } -function evNum(id) { - const m = /^E(\d+)$/.exec(id); - return m ? Number(m[1]) : 1e9; +function priorityOf(p) { + return p === "must" || p === "should" || p === "could" ? p : "should"; } - -// src/plan.ts -import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs"; -import { join as join8 } from "path"; -function buildPlanPath(runDir) { - return join8(runDir, "BUILD-PLAN.json"); +var FEATURE_VERBS = /* @__PURE__ */ new Set([ + "create", + "add", + "manage", + "book", + "view", + "send", + "track", + "sync", + "edit", + "delete", + "list", + "share", + "export", + "import", + "search", + "save", + "read", + "tag", + "organize", + "organise", + "schedule", + "upload", + "download", + "browse", + "filter", + "sort", + "archive", + "publish", + "invite", + "assign", + "stream" +]); +var NON_ENTITY_WORDS = /* @__PURE__ */ new Set(["search", "login", "signup", "support", "setup", "offline", "online", "mobile", "desktop", "full", "text", "user", "users"]); +var ADJECTIVAL_PREFIXES = /* @__PURE__ */ new Set([ + "multi", + "auto", + "self", + "cross", + "pre", + "post", + "non", + "anti", + "semi", + "meta", + "mini", + "micro", + "macro", + "mono", + "dual", + "poly", + "omni", + "pseudo", + "quasi", + "ultra", + "hyper", + "super", + "sub", + "inter", + "intra", + "extra" +]); +function singularize(w) { + if (/ies$/.test(w)) return w.slice(0, -3) + "y"; + if (/(?:ches|shes|xes|zes|ses)$/.test(w)) return w.slice(0, -2); + if (/s$/.test(w) && !/(?:ss|us|is)$/.test(w)) return w.slice(0, -1); + return w; } -function milestoneLabel(title) { - return title.split("\u2014")[0].trim() || title.trim(); +function titleCase(w) { + return w ? w[0].toUpperCase() + w.slice(1) : w; } -function pad32(n) { - return String(n).padStart(3, "0"); +function entityTokens(title, exclude) { + const words = keywords(title).map((w) => w.toLowerCase()); + const verbLed = words.length > 0 && FEATURE_VERBS.has(words[0]); + const rest = verbLed ? words.slice(1) : words; + const tokens = rest.filter((w) => w.length >= 3 && !FEATURE_VERBS.has(w) && !NON_ENTITY_WORDS.has(w) && !ADJECTIVAL_PREFIXES.has(w) && !/(?:ed|ing)$/.test(w)).map(singularize).filter((w) => !exclude.has(w)); + return { tokens, verbLed }; } -function derivePlan(srd) { - const frById = new Map(srd.functional.map((f) => [f.id, f])); - const ordered = []; - const seen = /* @__PURE__ */ new Set(); - for (const m of srd.buildPlan) { - for (const frId of m.frIds) { - if (frById.has(frId) && !seen.has(frId)) { - ordered.push({ frId, milestone: milestoneLabel(m.title) }); - seen.add(frId); - } - } - } - for (const f of srd.functional) { - if (!seen.has(f.id)) ordered.push({ frId: f.id, milestone: "M1" }); - } - const tasks = [ - { - id: "T-000", - title: "Project skeleton \u2014 repo layout, test harness, CI", - milestone: ordered[0]?.milestone ?? "M1", - frIds: [], - acceptance: [], - dependsOn: [], - artifacts: [], - tests: [], - verify: { commands: [] }, - status: "todo" - } - ]; - ordered.forEach(({ frId, milestone }, i) => { - const fr = frById.get(frId); - const dependsOn = ["T-000"]; - if (fr.entities.length) { - for (let j = 0; j < i; j++) { - const prev = ordered[j]; - if (prev.milestone === milestone) continue; - const prevFr = frById.get(prev.frId); - if (prevFr.entities.some((e) => fr.entities.includes(e))) { - dependsOn.push(`T-${pad32(j + 1)}`); - break; - } - } - } - tasks.push({ - id: `T-${pad32(i + 1)}`, - title: `${fr.id} \u2014 ${fr.title}`, - milestone, - ...fr.module ? { module: fr.module } : {}, - frIds: [fr.id], - acceptance: fr.acceptance.map((_, idx) => ({ frId: fr.id, index: idx })), - dependsOn, - artifacts: [], - tests: [], - verify: { commands: [] }, - status: "todo" - }); - }); - if (srd.design) { - tasks.push({ - id: `T-${pad32(ordered.length + 1)}`, - title: "Design foundation \u2014 design tokens, base components, accessibility baseline", - milestone: ordered[0]?.milestone ?? "M1", - frIds: [], - acceptance: [], - dependsOn: ["T-000"], - artifacts: [], - tests: [], - verify: { commands: [] }, - status: "todo" - }); +function inferEntities(brief, functional) { + const exclude = new Set( + [...brief.competitors, ...brief.candidateTech, brief.product.name ?? ""].flatMap((s) => keywords(s).map((w) => singularize(w.toLowerCase()))) + ); + const perFr = functional.map((fr) => ({ fr, ...entityTokens(fr.title, exclude) })); + const freq = /* @__PURE__ */ new Map(); + for (const p of perFr) for (const t of new Set(p.tokens)) freq.set(t, (freq.get(t) ?? 0) + 1); + const chosen = /* @__PURE__ */ new Set(); + for (const [t, n] of freq) if (n >= 2) chosen.add(t); + for (const p of perFr) { + if (p.verbLed && p.fr.priority === "must" && p.tokens[0]) chosen.add(p.tokens[0]); } - return { - schemaVersion: BUILD_PLAN_SCHEMA_VERSION, - product: srd.product.name, - generatedAt: srd.generatedAt, - conventions: { frTagPattern: "FR-\\d{3}", testCommand: null, appDir: null }, - tasks - }; -} -var STATUSES = ["todo", "in-progress", "done"]; -function taskKey(t) { - return t.frIds.length ? `fr:${t.title.replace(/^FR-\d+\s*—\s*/, "").trim().toLowerCase()}` : `title:${t.title.trim().toLowerCase()}`; -} -function mergePlan(prev, next) { - if (!prev) return next; - const prevByKey = new Map(prev.tasks.map((t) => [taskKey(t), t])); - const tasks = next.tasks.map((t) => { - const old = prevByKey.get(taskKey(t)); - if (!old) return t; + const names = [...chosen].sort((a, b) => freq.get(b) - freq.get(a) || a.localeCompare(b)).slice(0, 8); + const entities = names.map((n) => { + const name = titleCase(n); + const refs = perFr.filter((p) => p.tokens.includes(n)).map((p) => p.fr.id); return { - ...t, - artifacts: Array.isArray(old.artifacts) ? old.artifacts : t.artifacts, - tests: Array.isArray(old.tests) ? old.tests : t.tests, - verify: old.verify && Array.isArray(old.verify.commands) ? old.verify : t.verify, - status: STATUSES.includes(old.status) ? old.status : t.status - }; - }); - return { - ...next, - conventions: { - frTagPattern: next.conventions.frTagPattern, - testCommand: prev.conventions?.testCommand ?? null, - appDir: prev.conventions?.appDir ?? null - }, - tasks - }; -} -function readyFrontier(plan) { - const done = new Set(plan.tasks.filter((t) => t.status === "done").map((t) => t.id)); - const tasks = plan.tasks.map((t) => ({ - id: t.id, - milestone: t.milestone, - status: t.status, - dependsOn: t.dependsOn, - ready: t.status !== "done" && t.dependsOn.every((d) => done.has(d)) - })); - return { - product: plan.product, - done: done.size, - total: plan.tasks.length, - tasks, - frontier: tasks.filter((t) => t.ready).map((t) => t.id), - blocked: tasks.filter((t) => t.status !== "done" && !t.ready).map((t) => ({ id: t.id, waitingOn: t.dependsOn.filter((d) => !done.has(d)) })) - }; -} -function loadPlan(runDir) { - const path = buildPlanPath(runDir); - if (!existsSync4(path)) return null; - try { - const data = JSON.parse(readFileSync3(path, "utf8")); - return data && typeof data === "object" && Array.isArray(data.tasks) ? data : null; - } catch { - return null; + name, + attributes: [ + { name: "id", type: "identifier" }, + { name: "createdAt", type: "timestamp" } + ], + referencedByFRs: refs + }; + }); + for (const fr of functional) { + fr.entities = entities.filter((e) => e.referencedByFRs.includes(fr.id)).map((e) => e.name); } + return entities; } -function writePlan(runDir, plan) { - const path = buildPlanPath(runDir); - writeFileSync3(path, JSON.stringify(plan, null, 2) + "\n"); - return path; -} - -// src/templates.ts -function cite(ids) { - if (!ids || ids.length === 0) return ""; - return " " + ids.map((id) => `[${id}]`).join(""); -} -function cell(s) { - return String(s).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim(); -} -function slugTitle(s) { - return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "decision"; -} -function bullets(items, empty) { - if (!items.length) return `_${empty}_`; - return items.map((i) => `- ${i}`).join("\n"); +var BOUNDARY_DEFS = [ + // Word boundaries matter: a bare /ical|ics/ substring-matches "historical" + // and "metrics", /stripe/ matches "pinstripe", /embed/ matches "Embedded" and + // /google/ matches "googled" — each hallucinating an integration into an + // unrelated product. Every token is bounded (optional plural) for that reason. + { re: /\b(?:calendar|caldav|ical|ics)\b/i, label: "calendar systems (CalDAV/iCal)", name: "Calendar Integration", kind: "api" }, + { re: /\bgoogles?\b/i, label: "Google APIs", name: "Google API Integration", kind: "api" }, + { re: /\b(?:email|smtp)s?\b/i, label: "an email/SMTP provider", name: "Email Delivery", kind: "api" }, + { re: /\b(?:sms|twilio)s?\b/i, label: "an SMS provider", name: "SMS Delivery", kind: "api" }, + { re: /\b(?:widget|iframe|embed)s?\b/i, label: "external host sites (embed/iframe)", name: "Embeddable Widget", kind: "ui" }, + { re: /\b(?:payment|stripe|billing)s?\b/i, label: "a payments provider", name: "Payments Integration", kind: "api" }, + { re: /\bwebhooks?\b/i, label: "outbound webhooks", name: "Outbound Webhooks", kind: "event" }, + { re: /\b(?:browser extension|chrome extension|firefox add-?on)s?\b/i, label: "a browser extension", name: "Browser Extension", kind: "ui" } +]; +function boundaryHaystack(brief) { + return `${brief.idea} ${brief.candidateTech.join(" ")} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; } -function renderVision(srd) { - const p = srd.product; - return [ - `# Vision`, - ``, - `**Product:** ${p.name}`, - ``, - `## Problem`, - p.problem, - ``, - `## Target users`, - bullets(p.users, "No users captured."), - ``, - `## Value proposition`, - p.valueProp, - ``, - `## Success metrics`, - bullets(p.metrics, "Define a measurable launch success metric."), - `` - ].join("\n"); +function detectBoundaries(brief) { + const haystack = boundaryHaystack(brief); + return BOUNDARY_DEFS.filter((b) => b.re.test(haystack)); } -function renderScope(srd) { - const lines = [ - `# Scope`, - ``, - `## In scope`, - bullets(srd.scope.inScope, "No in-scope items captured."), - ``, - `## Out of scope`, - bullets(srd.scope.outOfScope, "Nothing explicitly excluded yet."), - ``, - `## Assumptions`, - bullets(srd.scope.assumptions, "No assumptions recorded."), - `` - ]; - if (srd.openQuestions.length) { - lines.push(`## Open decisions`, ``); - for (const q of srd.openQuestions) lines.push(`> \u{1F9E0} **Decide:** ${q}`, ``); +function inferInterfaces(brief, functional) { + const out = []; + for (const b of detectBoundaries(brief)) { + const related = functional.filter((fr) => b.re.test(`${fr.title} ${fr.description}`)).map((fr) => fr.id); + out.push({ + name: b.name, + kind: b.kind, + summary: `Boundary with ${b.label}. Define the contract (operations, data, failure modes) during authoring.`, + relatedFRs: related + }); } - return lines.join("\n"); -} -function renderFRBlock(fr) { - const out = [`## ${fr.id} \u2014 ${fr.title} _(${fr.priority})_${cite(fr.rationaleEvidence)}`, ``]; - out.push(fr.description); - out.push(``); - out.push(`**Acceptance criteria:**`); - for (const a of fr.acceptance) { - out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); + if (brief.product.users?.length) { + out.push({ + name: "Web App", + kind: "ui", + summary: `The primary user-facing surface through which ${brief.product.users.join(", ")} use the product.`, + relatedFRs: functional.map((f) => f.id) + }); + } + for (const fr of functional) { + fr.interfaces = out.filter((i) => i.relatedFRs.includes(fr.id)).map((i) => i.name); } - out.push(``); - const trace = [ - `NFRs: ${fr.nfrs.length ? fr.nfrs.join(", ") : "\u2014"}`, - `entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`, - `interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}` - ].join(" \xB7 "); - out.push(`_Traceability \u2014 ${trace}_`); - out.push(``); return out; } -function renderFunctional(srd) { - if (srd.modules?.length) return renderFunctionalIndex(srd); - return renderFunctionalFull(srd); -} -function renderFunctionalFull(srd) { - const out = [`# Functional requirements`, ``]; - if (!srd.functional.length) out.push(`_No functional requirements defined._`, ``); - for (const fr of srd.functional) out.push(...renderFRBlock(fr)); - return out.join("\n"); -} -function renderFunctionalIndex(srd) { - const out = [`# Functional requirements`, ``]; - out.push(`_This SRD is partitioned into module PRDs \u2014 the full requirement blocks (description,`); - out.push(`acceptance criteria, traceability) live in each module's PRD under [../prd/](../prd/README.md)._`, ``); - out.push(`| Requirement | Title | Priority | Module | PRD |`); - out.push(`|---|---|---|---|---|`); - for (const fr of srd.functional) { - const link = fr.module ? `[../prd/${fr.module}/PRD.md](../prd/${fr.module}/PRD.md)` : "\u2014"; - out.push(`| ${fr.id} | ${cell(fr.title)} | ${fr.priority} | ${fr.module ?? "\u2014"} | ${link} |`); +function buildSRD(brief, evidence, opts) { + const level = opts.level; + const productName = brief.product.name || titleFromIdea(brief.idea); + const compliance = brief.constraints.compliance ?? []; + const selfHost = /self[- ]?host|privacy|gdpr|own (your|the) data/i.test(`${brief.idea} ${brief.product.valueProp ?? ""}`) || compliance.length > 0; + const timeGoal = timeTokenFromGoals(brief.goals); + const categories = []; + for (const c of REQUIRED_NFR[level]) if (!categories.includes(c)) categories.push(c); + for (const c of brief.nfrPriorities) { + const k = c.toLowerCase().trim(); + if (k && !categories.includes(k)) categories.push(k); } - out.push(``); - return out.join("\n"); -} -function renderModulePRD(srd, m) { - const frs = srd.functional.filter((f) => f.module === m.id); - const others = (srd.modules ?? []).filter((o) => o.id !== m.id); - const frIdSet = new Set(frs.map((f) => f.id)); - const out = [`# PRD \u2014 ${m.name}`, ``]; - out.push(`_Module \`${m.id}\` \xB7 ${srd.product.name} \xB7 ${frs.length} requirement(s)_`, ``); - if (m.description) out.push(m.description, ``); - out.push( - `**Global context:** [Vision](../../00-overview/VISION.md) \xB7 [Scope](../../00-overview/SCOPE.md) \xB7 [Non-functional requirements](../../requirements/NON-FUNCTIONAL.md) \xB7 [Data model](../../architecture/DATA-MODEL.md) \xB7 [Interfaces](../../architecture/INTERFACES.md) \xB7 [Traceability](../../TRACEABILITY.md)`, - `` - ); - out.push(`## Scope`, ``); - out.push(`**In scope:** ${frs.length ? frs.map((f) => f.id).join(", ") : "\u2014"}.`, ``); - if (others.length) { - out.push(`**Out of scope** (owned by other modules): ${others.map((o) => `[${o.name}](../${o.id}/PRD.md)`).join(", ")}.`, ``); + const nonFunctional = categories.map((cat, i) => { + const t = nfrFor(cat); + const metric = specialiseMetric(cat, t.metric, { compliance, selfHost, timeGoal, budget: brief.constraints.budget }); + const statement = specialiseStatement(cat, t.statement, { compliance, selfHost }); + return { + id: `NFR-${pad3(i + 1)}`, + category: cat, + statement, + metric, + // Ground over the *specialised* text + distinctive brief facts (CalDAV, + // GDPR…), restricted to authoritative sources (no marketing pages). + rationaleEvidence: matchEvidence(`${cat} ${statement} ${brief.candidateTech.join(" ")} ${compliance.join(" ")}`, evidence, 1, GROUND_QUALITY) + }; + }); + const coreNfrIds = nonFunctional.filter((n) => REQUIRED_NFR.light.includes(n.category.toLowerCase())).map((n) => n.id); + const adrs = []; + const stack = brief.candidateTech.length ? brief.candidateTech.join(", ") : "a stack to be selected"; + adrs.push({ + id: "", + title: "Primary technology stack", + status: brief.candidateTech.length ? "accepted" : "proposed", + context: `Building "${productName}" requires a stack that fits the team (${brief.constraints.team || "to be defined"}) and timeline (${brief.constraints.timeline || "to be defined"}).`, + decision: `Adopt ${stack} as the primary stack for the initial build.`, + consequences: `The team commits to ${stack}; hiring, tooling and operational knowledge align to it. Revisit if a hard requirement is unmet.`, + alternatives: brief.candidateTech.length ? "No explicit alternative stack was provided in the brief; evaluate one comparable option before locking this in." : "Alternative stacks were considered but not selected.", + evidence: matchEvidence(`${stack} architecture stack`, evidence, 2, ["docs", "oss", "so"]) + }); + if (selfHost) { + adrs.push({ + id: "", + title: "Self-hosting and data-ownership model", + status: "accepted", + context: `"${productName}" is positioned as privacy-first / self-hostable${compliance.length ? ` and must satisfy: ${compliance.join(", ")}` : ""}.`, + decision: "Ship as a self-hostable deployment where the host owns all data; no user data is sent to a third-party service by default.", + consequences: "Data residency and compliance become the host's responsibility (a feature, not a liability); the product must run with no mandatory external dependencies and document its data flows.", + alternatives: "A hosted multi-tenant SaaS was considered but rejected as it conflicts with the privacy/data-ownership value proposition.", + evidence: matchEvidence(`self-host privacy data ownership ${compliance.join(" ")}`, evidence, 2, GROUND_QUALITY) + }); } - out.push(`## Requirements`, ``); - if (!frs.length) out.push(`_No requirements assigned to this module._`, ``); - for (const fr of frs) out.push(...renderFRBlock(fr)); - const nfrIds = new Set(frs.flatMap((f) => f.nfrs)); - const nfrs = srd.nonFunctional.filter((n) => nfrIds.has(n.id)); - out.push(`## Non-functional requirements`, ``); - if (nfrs.length) { - out.push(`_Applying to this module's requirements \u2014 full statements in [NON-FUNCTIONAL.md](../../requirements/NON-FUNCTIONAL.md)._`, ``); - out.push(`| NFR | Category | Metric |`, `|---|---|---|`); - for (const n of nfrs) out.push(`| ${n.id} | ${cell(n.category)} | ${cell(n.metric ?? "\u2014")} |`); - } else { - out.push(`_None linked._`); + const integrates = brief.featureWishlist.some((f) => INTEGRATION_RE.test(`${f.title} ${f.notes ?? ""}`)) || INTEGRATION_RE.test(brief.idea); + if (level === "complex" && (PERSIST_RE.test(briefText(brief)) || integrates)) { + adrs.push({ + id: "", + title: "Data persistence and integration approach", + status: "proposed", + context: `"${productName}" must persist state and integrate with external services (${brief.candidateTech.filter((t) => INTEGRATION_RE.test(t)).join(", ") || "calendar/email and similar"}) reliably.`, + decision: "Use a single primary datastore with explicit, versioned integration boundaries for each external service.", + consequences: "A clear data-ownership model; integrations are testable in isolation behind an adapter. Cross-service consistency must be designed explicitly.", + alternatives: "A polyglot-persistence or event-sourced approach was considered; deferred until scale demands it.", + evidence: matchEvidence(`${brief.candidateTech.join(" ")} database persistence integration`, evidence, 2, ["docs", "oss", "so"]) + }); } - out.push(``); - const entities = srd.architecture.dataModel.filter((e) => e.referencedByFRs.some((id) => frIdSet.has(id))); - out.push(`## Data model (module slice)`, ``); - if (entities.length) { - out.push(`| Entity | Referenced by |`, `|---|---|`); - for (const e of entities) out.push(`| ${cell(e.name)} | ${e.referencedByFRs.filter((id) => frIdSet.has(id)).join(", ")} |`); - } else { - out.push(`_No entities touch this module yet._`); + adrs.forEach((a, i) => a.id = pad4(i + 1)); + const stackAdrId = adrs[0].id; + const dataAdr = adrs.find((a) => /persistence|integration/i.test(a.title)); + const privacyAdr = adrs.find((a) => /self-hosting|data-ownership/i.test(a.title)); + const functional = brief.featureWishlist.map((f, i) => { + const priority = priorityOf(f.priority); + const text = `${f.title} ${f.notes ?? ""}`; + const touchesIntegration = INTEGRATION_RE.test(text); + const outcome = concreteOutcome(f.title, f.notes); + const acceptance = [ + { + given: `${productName} is available to a user`, + when: `they ${lowerFirst(f.title)}`, + then: outcome + }, + ...level === "complex" ? [failurePath(f.title, touchesIntegration)] : [] + ]; + const nfrs = [...coreNfrIds]; + for (const n of nonFunctional) { + if (coreNfrIds.includes(n.id)) continue; + const sig = NFR_SIGNALS[n.category.toLowerCase()]; + if (sig?.test(text)) nfrs.push(n.id); + } + return { + id: `FR-${pad3(i + 1)}`, + title: f.title, + description: f.notes?.trim() || `The product lets a user ${lowerFirst(f.title)}.`, + priority, + acceptance, + rationaleEvidence: matchEvidence(text, evidence, 2, GROUND_REQUIREMENT), + entities: [], + interfaces: [], + nfrs, + unresolved: false, + ...f.module ? { module: f.module } : {} + }; + }); + const modules = brief.modules?.length ? brief.modules.map((m) => ({ + id: m.id, + name: m.name, + ...m.description ? { description: m.description } : {}, + frIds: functional.filter((f) => f.module === m.id).map((f) => f.id), + dependsOn: m.dependsOn ?? [] + })) : void 0; + const dataModel = inferEntities(brief, functional); + const interfaces = inferInterfaces(brief, functional); + const evById = new Map(evidence.map((e) => [e.id, e])); + const competitors = brief.competitors.map((name) => { + const ev = matchEvidence( + name, + evidence.filter((e) => mentionsEntity(name, e)), + 2, + ["market"] + ); + return { name, note: noteFrom(ev, evById) || `Comparable product / alternative to "${productName}".`, evidence: ev }; + }); + const ossByKey = /* @__PURE__ */ new Map(); + const keyOf = (s) => { + try { + return resolveRepo(s).slug; + } catch { + return s.toLowerCase(); + } + }; + for (const seed of brief.ossSeeds) { + const ref = resolveRepo(seed); + const label = ref.owner && ref.repo ? `${ref.owner}/${ref.repo}` : seed; + const ev = matchEvidence(`${ref.owner ?? ""} ${ref.repo ?? ""}`.trim() || seed, evidence, 2, ["oss", "issue", "pr"]); + ossByKey.set(keyOf(seed), { + name: label, + url: ref.webUrl ?? (/^https?:/.test(seed) ? seed : void 0), + note: noteFrom(ev, evById) || "Seed OSS project mined for prior art.", + evidence: ev + }); } - out.push(``); - const ifaces = srd.architecture.interfaces.filter((i) => i.relatedFRs.some((id) => frIdSet.has(id))); - out.push(`## Interfaces (module slice)`, ``); - if (ifaces.length) { - out.push(`| Interface | Kind | Related |`, `|---|---|---|`); - for (const i of ifaces) out.push(`| ${cell(i.name)} | ${i.kind} | ${i.relatedFRs.filter((id) => frIdSet.has(id)).join(", ")} |`); - } else { - out.push(`_No interfaces touch this module yet._`); + for (const e of evidence.filter((x) => x.source === "oss")) { + const k = keyOf(e.ref); + if (ossByKey.has(k)) { + if (!ossByKey.get(k).evidence.includes(e.id)) ossByKey.get(k).evidence.push(e.id); + continue; + } + ossByKey.set(k, { + name: e.title.replace(/ —.*$/, ""), + url: e.url, + note: firstSentence(e.snippet) || "Comparable open-source project (prior art).", + evidence: [e.id] + }); } - out.push(``); - out.push(`## Dependencies`, ``); - const declared = m.dependsOn.map((dep) => { - const d = others.find((o) => o.id === dep); - return d ? `[${d.name}](../${d.id}/PRD.md)` : dep; + const oss = [...ossByKey.values()]; + const buildPlan = buildMilestones(functional, brief, evidence, evById); + const design = opts.design ? buildDesignSystem(brief, functional) : void 0; + const traceability = functional.map((fr) => { + const text = `${fr.title} ${fr.description}`; + const adrIds = [stackAdrId]; + if (dataAdr && (PERSIST_RE.test(text) || INTEGRATION_RE.test(text))) adrIds.push(dataAdr.id); + if (privacyAdr && NFR_SIGNALS.privacy.test(text)) adrIds.push(privacyAdr.id); + const row = { fr: fr.id, nfrs: fr.nfrs, adrs: adrIds, entities: fr.entities, interfaces: fr.interfaces }; + if (design) { + row.components = design.components.filter((c) => c.relatedFRs.includes(fr.id)).map((c) => c.name); + row.screens = design.screens.filter((s) => s.relatedFRs.includes(fr.id)).map((s) => s.name); + } + if (fr.module) row.module = fr.module; + return row; }); - const shared = []; - for (const o of others) { - const oSet = new Set(o.frIds); - const names = entities.filter((e) => e.referencedByFRs.some((id) => oSet.has(id))).map((e) => e.name); - if (names.length) shared.push(`shares ${names.join(", ")} with [${o.name}](../${o.id}/PRD.md)`); - } - if (!declared.length && !shared.length) out.push(`_None._`); - if (declared.length) out.push(`- **Declared:** depends on ${declared.join(", ")}.`); - for (const s of shared) out.push(`- **Derived (shared data):** ${s}.`); - out.push(``); - return out.join("\n"); + const referenced = /* @__PURE__ */ new Set(); + for (const fr of functional) fr.rationaleEvidence.forEach((id) => referenced.add(id)); + for (const n of nonFunctional) n.rationaleEvidence.forEach((id) => referenced.add(id)); + for (const a of adrs) a.evidence.forEach((id) => referenced.add(id)); + for (const c of competitors) c.evidence.forEach((id) => referenced.add(id)); + for (const o of oss) o.evidence.forEach((id) => referenced.add(id)); + for (const m of buildPlan) (m.risks ?? []).forEach((r) => citationsIn(r).forEach((id) => referenced.add(id))); + const evidenceIndex = [...referenced].sort((a, b) => evNum(a) - evNum(b)); + return { + schemaVersion: SRD_SCHEMA_VERSION, + level, + generatedAt: opts.generatedAt, + product: { + name: productName, + problem: brief.product.problem || brief.goals[0] || `Address the need described by: ${brief.idea}`, + valueProp: brief.product.valueProp || `Deliver ${brief.idea} better than existing options.`, + users: brief.product.users?.length ? brief.product.users : ["primary user"], + metrics: brief.goals.length ? brief.goals : ["Define a measurable launch success metric."] + }, + scope: { + inScope: brief.featureWishlist.filter((f) => priorityOf(f.priority) !== "could").map((f) => f.title), + outOfScope: brief.nonGoals, + assumptions: deriveAssumptions(brief) + }, + functional, + ...modules ? { modules } : {}, + nonFunctional, + architecture: { context: contextProse(productName, brief), dataModel, interfaces, adrs }, + competitive: { competitors, oss }, + buildPlan, + traceability, + openQuestions: brief.openQuestions, + evidenceIndex, + ...design ? { design } : {} + }; } -function renderModulePrdIndex(srd) { - const out = [`# Module PRDs`, ``]; - out.push(`One PRD per product module, rendered from SRD.json. Cross-module docs (vision, scope,`); - out.push(`NFRs, architecture, ADRs, traceability) live at the SRD root; the cross-module requirement`); - out.push(`index is [../requirements/FUNCTIONAL.md](../requirements/FUNCTIONAL.md).`, ``); - out.push(`| Module | PRD | Requirements | Depends on |`); - out.push(`|---|---|---|---|`); - for (const m of srd.modules ?? []) { - out.push(`| ${cell(m.name)} | [${m.id}/PRD.md](${m.id}/PRD.md) | ${m.frIds.join(", ") || "\u2014"} | ${m.dependsOn.join(", ") || "\u2014"} |`); - } - out.push(``); - return out.join("\n"); +function deriveA11yStandard(brief) { + const explicit = brief.design?.accessibilityTarget?.trim(); + if (explicit) return explicit; + const hay = `${(brief.constraints.compliance ?? []).join(" ")} ${brief.nfrPriorities.join(" ")}`.toLowerCase(); + if (/\brgaa\b/.test(hay)) return "RGAA 4.1 (aligned to WCAG 2.2 AA)"; + if (/\b508\b|section 508/.test(hay)) return "Section 508 (WCAG 2.0 AA)"; + if (/en\s?301\s?549/.test(hay)) return "EN 301 549 (WCAG 2.1 AA)"; + return "WCAG 2.2 AA"; } -function renderFeaturePRD(fr, srd) { - const out = [`# PRD ${fr.id} \u2014 ${fr.title}${cite(fr.rationaleEvidence)}`, ``]; - out.push(`_Priority: ${fr.priority}_ \xB7 _Product: ${srd.product.name}_`, ``); - out.push(`## Context`, ``, srd.product.problem, ``); - out.push(`## Feature`, ``, fr.description, ``); - out.push(`## Acceptance criteria`, ``); - for (const a of fr.acceptance) { - out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); +function buildPrinciples(brief) { + const hay = `${brief.idea} ${brief.product.valueProp ?? ""} ${brief.product.problem ?? ""} ${brief.nfrPriorities.join(" ")} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; + const out = []; + if (/self[- ]?host|privac|gdpr|own (your|the) data|no account/i.test(hay)) { + out.push("Privacy by default \u2014 the UI never surfaces or transmits data the user did not choose to share."); } - out.push(``, `## Non-functional requirements`, ``); - if (!fr.nfrs.length) out.push(`_None linked._`); - for (const id of fr.nfrs) { - const nfr = srd.nonFunctional.find((n) => n.id === id); - out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` \u2014 metric: ${nfr.metric}` : ""}` : `- **${id}**`); + if (/fast|speed|sub-?second|latenc|instant|under \d/i.test(hay)) { + out.push("Perceived performance first \u2014 optimistic UI, skeletons over spinners, immediate feedback on every action."); } - out.push(``, `## Data & interfaces`, ``); - out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`); - out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}`); - out.push(``, `## Grounding`, ``); - out.push( - fr.rationaleEvidence.length ? `Evidence:${cite(fr.rationaleEvidence)} \u2014 see ../../evidence/EVIDENCE.md.` : `_Ungrounded \u2014 see the grounding report (construct check)._` - ); - out.push(``); - return out.join("\n"); + out.push("Accessible to everyone \u2014 every flow works with the keyboard and assistive technology, by construction."); + out.push("Consistency over novelty \u2014 reuse tokens and components before inventing new ones."); + out.push("Progressive disclosure \u2014 show the essential first; reveal complexity only on demand."); + out.push("Clear over clever \u2014 plain language, obvious affordances, honest empty and error states."); + return out.slice(0, 5); } -function renderPRDIndex(srd) { - const out = [`# PRDs \u2014 one per functional requirement`, ``]; - out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`); - out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``); - out.push(`| PRD | Priority | Title |`); - out.push(`|---|---|---|`); - for (const fr of srd.functional) { - const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`; - out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`); +function seedTokens(brief) { + const brand = brief.design?.brandConstraints?.trim(); + const byCategory = { + color: [ + { category: "color", name: "color.bg", value: "#ffffff", note: brand ? `Adjust to brand: ${brand}` : "Primary surface" }, + { category: "color", name: "color.fg", value: "#111827", note: "Primary text" }, + { category: "color", name: "color.primary", value: "#2563eb", note: "Primary action / brand accent" }, + { category: "color", name: "color.danger", value: "#dc2626", note: "Destructive / error" }, + { category: "color", name: "color.muted", value: "#6b7280", note: "Secondary text / borders" } + ], + typography: [ + { category: "typography", name: "font.sans", value: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }, + { category: "typography", name: "font.mono", value: "ui-monospace, SFMono-Regular, Menlo, monospace" }, + { category: "typography", name: "scale.body", value: "16px / 1.5" }, + { category: "typography", name: "scale.h1", value: "32px / 1.25" }, + { category: "typography", name: "scale.small", value: "13px / 1.4" } + ], + spacing: [ + { category: "spacing", name: "space.1", value: "4px" }, + { category: "spacing", name: "space.2", value: "8px" }, + { category: "spacing", name: "space.3", value: "12px" }, + { category: "spacing", name: "space.4", value: "16px" }, + { category: "spacing", name: "space.6", value: "24px" }, + { category: "spacing", name: "space.8", value: "32px" } + ], + radius: [ + { category: "radius", name: "radius.sm", value: "4px" }, + { category: "radius", name: "radius.md", value: "8px" }, + { category: "radius", name: "radius.lg", value: "12px" } + ], + elevation: [ + { category: "elevation", name: "shadow.sm", value: "0 1px 2px rgba(0,0,0,0.06)" }, + { category: "elevation", name: "shadow.md", value: "0 4px 12px rgba(0,0,0,0.10)" } + ], + motion: [ + { category: "motion", name: "motion.fast", value: "120ms ease-out" }, + { category: "motion", name: "motion.base", value: "200ms ease-out" } + ] + }; + return DESIGN_TOKEN_CATEGORIES.flatMap((c) => byCategory[c]); +} +var COMPONENT_DEFS = [ + { name: "App Shell & Navigation", purpose: "Overall layout, navigation and routing chrome that frames every screen.", re: /.*/ }, + { name: "Button & Actions", purpose: "Primary, secondary and destructive action controls with loading/disabled states.", re: /.*/ }, + { + name: "Form & Input", + purpose: "Labelled inputs with inline validation and accessible error messaging.", + re: /save|add|create|edit|import|tag|organi[sz]e|login|sign|submit|upload|compose|write|configure|invite/i + }, + { + name: "List & Collection", + purpose: "Paginated/virtualised lists of saved items with selection and bulk actions.", + re: /list|search|browse|organi[sz]e|tag|feed|library|archive|history|result|collection|inbox/i + }, + { name: "Detail View", purpose: "The focused reading/detail surface for a single item.", re: /read|view|open|detail|article|item|show|preview|document/i }, + { name: "Search & Filter", purpose: "Query input, filters and ranked results with empty/no-match handling.", re: /search|filter|find|query|sort|facet/i }, + { name: "Feedback & Notifications", purpose: "Toasts, banners and inline status for success, error and async progress.", re: /.*/ }, + { name: "Empty & Error States", purpose: "First-run, no-data and failure states that teach the next action.", re: /.*/ } +]; +function buildComponents(functional) { + const out = []; + for (const def of COMPONENT_DEFS) { + const relatedFRs = functional.filter((fr) => def.re.test(`${fr.title} ${fr.description}`)).map((fr) => fr.id); + if (relatedFRs.length === 0) continue; + out.push({ name: def.name, purpose: def.purpose, states: [...COMPONENT_STATES], relatedFRs, evidence: [] }); } - out.push(``); - return out.join("\n"); + return out; } -function renderNonFunctional(srd) { - const out = [`# Non-functional requirements`, ``]; - if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``); - for (const n of srd.nonFunctional) { - out.push(`## ${n.id} \u2014 ${n.category}${cite(n.rationaleEvidence)}`); - out.push(``); - out.push(n.statement); - if (n.metric) out.push(``, `- **Metric:** ${n.metric}`); - out.push(``); +function buildScreens(functional) { + const inScope = functional.filter((fr) => fr.priority !== "could"); + const mustIds = functional.filter((fr) => fr.priority === "must").map((fr) => fr.id); + const screens = [ + { name: "Home / Dashboard", purpose: "The landing surface after sign-in; entry point to the primary tasks.", relatedFRs: mustIds } + ]; + for (const fr of inScope) { + screens.push({ name: `${fr.title}`, purpose: `Where a user can ${lowerFirst(fr.title)}.`, relatedFRs: [fr.id] }); } - return out.join("\n"); -} -function renderSystemContext(srd) { - return [`# System context`, ``, srd.architecture.context, ``].join("\n"); + screens.push({ name: "Settings & Account", purpose: "Preferences, data export/delete and account management.", relatedFRs: [] }); + return screens; } -function renderDataModel(srd) { - const out = [`# Data model`, ``]; - const entities = srd.architecture.dataModel; - if (!entities.length) { - out.push(`_No entities defined yet. Enrich during authoring: list entities, their attributes, and which functional requirements reference each._`, ``); - return out.join("\n"); - } - out.push(`_Seeded by inference from the brief \u2014 verify each entity and extend attributes during authoring._`, ``); - for (const e of entities) { - out.push(`## ${e.name}`); - out.push(``); - if (e.attributes.length) { - out.push(`| Attribute | Type |`, `|---|---|`); - for (const a of e.attributes) out.push(`| ${cell(a.name)} | ${cell(a.type)} |`); +function buildFlows(functional) { + const must = functional.filter((fr) => fr.priority === "must"); + const flows = [ + { + name: "First-run onboarding", + steps: ["Arrive at an empty, explanatory first-run state", "Complete the minimal setup", "Reach the dashboard ready to act"], + frIds: must.map((fr) => fr.id) } - out.push(``, `_Referenced by: ${e.referencedByFRs.length ? e.referencedByFRs.join(", ") : "\u2014"}_`, ``); + ]; + for (const fr of must) { + flows.push({ + name: `${fr.title} \u2014 happy path`, + steps: ["Navigate to the relevant screen", `Perform: ${lowerFirst(fr.title)}`, "Receive clear confirmation of the outcome"], + frIds: [fr.id] + }); } - return out.join("\n"); + return flows; } -function renderInterfaces(srd) { - const out = [`# Interfaces`, ``]; - const ifaces = srd.architecture.interfaces; - if (!ifaces.length) { - out.push(`_No interfaces defined yet. Enrich during authoring: list the API/event/UI/CLI surfaces and the functional requirements each serves._`, ``); - return out.join("\n"); - } - out.push(`_Seeded by inference from the brief \u2014 verify each surface and define its contract during authoring._`, ``); - for (const i of ifaces) { - out.push(`## ${i.name} _(${i.kind})_`, ``, i.summary, ``, `_Related: ${i.relatedFRs.length ? i.relatedFRs.join(", ") : "\u2014"}_`, ``); - } - return out.join("\n"); +function a11yRequirements() { + const defs = [ + { + statement: "Every interactive control is fully keyboard operable.", + given: "a user navigates with the keyboard only", + when: "they tab through any flow", + then: "every interactive control is reachable, operable and follows a logical focus order" + }, + { + statement: "Focus is always visible.", + given: "an element receives keyboard focus", + when: "the user is navigating", + then: "a visible focus indicator is shown and meets the non-text contrast minimum" + }, + { + statement: "Colour contrast meets the target standard.", + given: "any text or essential UI element", + when: "it is rendered in any supported theme", + then: "contrast meets the target (\u2265 4.5:1 for body text, \u2265 3:1 for large text and UI)" + }, + { + statement: "Every control and image exposes an accessible name.", + given: "a form control, icon-only button or meaningful image", + when: "it is read by assistive technology", + then: "it exposes a programmatic label/name and images carry meaningful alt text (decorative images are hidden)" + }, + { + statement: "Structure and async changes are conveyed semantically.", + given: "a screen is parsed by a screen reader", + when: "the user explores it", + then: "headings, landmarks and roles convey the structure and live regions announce asynchronous changes" + }, + { + statement: "Reduced motion and zoom are respected.", + given: "a user prefers reduced motion or zooms to 200%", + when: "they use the product", + then: "non-essential motion is reduced or disabled and content reflows without loss of content or function" + } + ]; + return defs.map((d, i) => ({ + id: `A11Y-${pad3(i + 1)}`, + statement: d.statement, + acceptance: [{ given: d.given, when: d.when, then: d.then }] + })); } -function renderADR(adr) { - const out = [ - `# ${adr.id}. ${adr.title}`, - ``, - `- **Status:** ${adr.status}`, - ``, - `## Context`, - adr.context, - ``, - `## Decision`, - `${adr.decision}${cite(adr.evidence)}`, - ``, - `## Consequences`, - adr.consequences, - `` +function buildContentVoice(brief) { + const tone = brief.design?.tone?.trim(); + return [ + tone ? `Voice & tone: ${tone}.` : "Voice & tone: clear, concise and human \u2014 plain language over jargon.", + "Label actions with the outcome the user gets, not the system operation behind it.", + "Error messages state what happened, why, and the next step \u2014 never blame the user.", + "Empty states teach the first useful action; success states confirm exactly what changed." ]; - if (adr.alternatives) out.push(`## Alternatives considered`, adr.alternatives, ``); - return out.join("\n"); } -function renderLandscape(srd) { - const out = [`# Competitive landscape`, ``, `## Competitors`, ``]; - if (srd.competitive.competitors.length) { - out.push(`| Product | Note | Evidence |`, `|---|---|---|`); - for (const c of srd.competitive.competitors) { - const ev = c.evidence.length ? c.evidence.map((id) => `[${id}]`).join("") : "_ungrounded_"; - out.push(`| ${cell(c.name)} | ${cell(c.note)} | ${ev} |`); - } - } else { - out.push(`_No competitors captured. Use the market research angle to discover them._`); +function buildDesignSystem(brief, functional) { + return { + principles: buildPrinciples(brief), + tokens: seedTokens(brief), + components: buildComponents(functional), + screens: buildScreens(functional), + flows: buildFlows(functional), + accessibility: { standard: deriveA11yStandard(brief), requirements: a11yRequirements() }, + contentVoice: buildContentVoice(brief) + }; +} +function concreteOutcome(title, notes) { + const n = (notes ?? "").trim(); + const q = /\b(?:within|at least|at most|no more than|up to|under)\s+\d[^.;,]{0,60}/i.exec(n); + const m = /\b(?:never|always|so that|so it|must|should|guarantee[sd]?|ensure[sd]?|without)\b\s+([^.;,]{4,})/i.exec(n); + if (m?.[1]) { + const clause = m[1].split(/[;,]/)[0].trim().replace(/\s+/g, " "); + if (clause.length > 3 && (/\d/.test(clause) || !q)) return `the action succeeds and ${lowerFirst(clause)}`; } - out.push(``, `## Comparable open-source projects`, ``); - if (srd.competitive.oss.length) { - out.push(`| Project | Note | Evidence |`, `|---|---|---|`); - for (const o of srd.competitive.oss) { - const name = o.url ? `[${cell(o.name)}](${o.url})` : cell(o.name); - const ev = o.evidence.length ? o.evidence.map((id) => `[${id}]`).join("") : "_ungrounded_"; - out.push(`| ${name} | ${cell(o.note)} | ${ev} |`); - } - } else { - out.push(`_No OSS prior art captured. Use the oss research angle to mine comparable projects._`); + const t = /\bin under [^.;,]+/i.exec(n); + if (t) return `the action completes ${t[0].trim().replace(/\s+/g, " ")}`; + if (q) return `the outcome honours the stated bound: ${q[0].trim().replace(/\s+/g, " ").toLowerCase()}`; + return `the result of "${title.toLowerCase()}" is persisted and visible to the user`; +} +function failurePath(title, integration) { + if (integration) { + return { + given: `the external service required by "${title.toLowerCase()}" is unreachable or rejects the request`, + when: `a user performs the action`, + then: `the system surfaces a clear, specific error and makes no partial or inconsistent change` + }; } - out.push(``); - return out.join("\n"); + return { + given: `a user submits invalid or missing input for "${title.toLowerCase()}"`, + when: `the action is submitted`, + then: `the system rejects it with a clear, actionable error and no side effects` + }; } -function renderBuildPlan(srd) { - const out = [`# Build plan`, ``]; - for (const m of srd.buildPlan) { - out.push(`## ${m.title}`, ``, m.outcome, ``); - out.push(`- **Requirements:** ${m.frIds.length ? m.frIds.join(", ") : "\u2014"}`); - if (m.risks.length) { - out.push(`- **Risks:**`); - for (const r of m.risks) out.push(` - ${r}`); - } - out.push(``); +function specialiseMetric(cat, base, ctx) { + const c = cat.toLowerCase(); + if ((c === "performance" || c === "usability") && ctx.timeGoal) { + return `${base} Honour the product goal: ${ctx.timeGoal}.`; } - return out.join("\n"); + if ((c === "privacy" || c === "security") && ctx.compliance.length) { + return `${base} Comply with: ${ctx.compliance.join(", ")}.`; + } + if (c === "cost" && ctx.budget) { + return `${base} Stay within the stated budget: ${ctx.budget}.`; + } + return base; } -function renderTraceability(srd) { - const design = !!srd.design; - const modules = !!srd.modules?.length; - const cols = ["Requirement", ...modules ? ["Module"] : [], "NFRs", "ADRs", "Entities", "Interfaces", ...design ? ["Components", "Screens"] : []]; - const out = [`# Traceability matrix`, ``, `| ${cols.join(" | ")} |`, `|${cols.map(() => "---").join("|")}|`]; - for (const r of srd.traceability) { - const cells = [ - r.fr, - ...modules ? [r.module ?? "\u2014"] : [], - r.nfrs.join(", ") || "\u2014", - r.adrs.join(", ") || "\u2014", - r.entities.join(", ") || "\u2014", - r.interfaces.join(", ") || "\u2014" - ]; - if (design) { - cells.push((r.components ?? []).map(cell).join(", ") || "\u2014"); - cells.push((r.screens ?? []).map(cell).join(", ") || "\u2014"); +function specialiseStatement(cat, base, ctx) { + const c = cat.toLowerCase(); + if ((c === "privacy" || c === "security") && ctx.selfHost) { + return `${base} No personal data leaves the self-hosted instance unless the host configures it.`; + } + return base; +} +function buildMilestones(functional, _brief, evidence, evById) { + const groups = [ + { key: "must", title: "M1 \u2014 Walking skeleton (must-haves)", outcome: "A usable end-to-end slice covering every must-have requirement." }, + { key: "should", title: "M2 \u2014 Rounded product (should-haves)", outcome: "The product is complete enough for real users." }, + { key: "could", title: "M3 \u2014 Enhancements (could-haves)", outcome: "Nice-to-have capabilities that differentiate the product." } + ]; + const priorPitfalls = evidence.filter((e) => e.source === "issue" || e.source === "pr"); + const out = []; + for (const g of groups) { + const frs = functional.filter((f) => f.priority === g.key); + if (frs.length === 0) continue; + const risks = []; + const text = frs.map((f) => `${f.title} ${f.description}`).join(" "); + const matched = matchEvidence(text, priorPitfalls, 2); + for (const id of matched) { + const e = evById.get(id); + if (e) risks.push(`Prior art shows a related pitfall: ${firstSentence(e.title)} [${id}]`); } - out.push(`| ${cells.join(" | ")} |`); + out.push({ title: g.title, outcome: g.outcome, frIds: frs.map((f) => f.id), risks }); } - out.push(``); - return out.join("\n"); + if (out.length === 0) out.push({ title: "M1 \u2014 Initial build", outcome: "Deliver the first usable version.", frIds: functional.map((f) => f.id), risks: [] }); + return out; } -function renderDesignPrinciples(ds) { - return [ - `# Design principles`, - ``, - bullets(ds.principles, "No design principles captured."), - ``, - `## Content & voice`, - ``, - bullets(ds.contentVoice, "No content guidelines captured."), - `` - ].join("\n"); +function deriveAssumptions(brief) { + const a = []; + if (brief.constraints.team) a.push(`The team is: ${brief.constraints.team}.`); + if (brief.constraints.timeline) a.push(`The timeline is: ${brief.constraints.timeline}.`); + if (brief.constraints.budget) a.push(`The budget is: ${brief.constraints.budget}.`); + if (brief.constraints.compliance?.length) a.push(`Compliance applies: ${brief.constraints.compliance.join(", ")}.`); + if (a.length === 0) a.push("No hard constraints were captured; revisit budget, timeline and team before committing."); + return a; } -function renderDesignTokens(ds) { - const out = [`# Design tokens`, ``, `_${DESIGN_TOKENS_SEEDED_BANNER}_`, ``]; - const cats = [...new Set(ds.tokens.map((t) => t.category))]; - for (const cat of cats) { - const toks = ds.tokens.filter((t) => t.category === cat); - out.push(`## ${cell(cat)}`, ``, `| Token | Value | Notes |`, `|---|---|---|`); - for (const t of toks) out.push(`| ${cell(t.name)} | ${cell(t.value)} | ${cell(t.note ?? "")} |`); - out.push(``); - } - out.push("> The machine-readable token set is in `design/design-tokens.json`.", ``); - return out.join("\n"); +function contextProse(name, brief) { + const actors = brief.product.users?.length ? brief.product.users : ["users"]; + const boundaries = detectBoundaries(brief).map((b) => b.label); + const stack = brief.candidateTech.length ? ` Built on ${brief.candidateTech.join(", ")}.` : ""; + const ext = boundaries.length ? ` It integrates with: ${boundaries.join("; ")}.` : ""; + return `"${name}" serves ${actors.join(", ")}.${stack}${ext} Each integration boundary is owned by an ADR and detailed in INTERFACES.md during authoring.`; } -function renderDesignTokensJson(ds) { - const obj = {}; - for (const t of ds.tokens) { - (obj[t.category] ??= {})[t.name] = t.value; +function noteFrom(ids, evById) { + for (const id of ids) { + const e = evById.get(id); + const s = e ? firstSentence(e.snippet) : ""; + if (s) return s; } - return JSON.stringify(obj, null, 2); + return void 0; } -function renderComponents(ds) { - const out = [`# Components`, ``]; - if (!ds.components.length) { - out.push(`_No components defined yet. Enrich during authoring: name each component, its states and the requirements it realises._`, ``); - return out.join("\n"); - } - out.push(`_Seeded from the functional requirements \u2014 verify each component and its states during authoring._`, ``); - for (const c of ds.components) { - out.push(`## ${c.name}${cite(c.evidence)}`, ``, c.purpose, ``); - out.push(`- **States:** ${c.states.join(", ") || "\u2014"}`); - out.push(`- **Realises:** ${c.relatedFRs.length ? c.relatedFRs.join(", ") : "\u2014"}`, ``); +function firstSentence(s) { + const clean = s.replace(/\s+/g, " ").trim(); + if (!clean) return ""; + const m = /^(.{1,200}?[.!?])(\s|$)/.exec(clean); + return (m ? m[1] : clean.slice(0, 160)).trim(); +} +function timeTokenFromGoals(goals) { + for (const g of goals) { + const m = /\b(?:in |under |within )?(\d+)\s*(seconds?|secs?|minutes?|mins?|hours?)\b/i.exec(g); + if (m) return `complete the primary task in under ${m[1]} ${m[2].toLowerCase()}`; } - return out.join("\n"); + return void 0; } -function renderScreens(ds) { - const out = [`# Screens & flows`, ``, `## Screens`, ``]; - if (ds.screens.length) { - out.push(`| Screen | Purpose | Requirements |`, `|---|---|---|`); - for (const s of ds.screens) out.push(`| ${cell(s.name)} | ${cell(s.purpose)} | ${s.relatedFRs.join(", ") || "\u2014"} |`); - } else { - out.push(`_No screens defined._`); +function briefText(brief) { + return `${brief.idea} ${brief.product.problem ?? ""} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; +} +function citationsIn(s) { + const out = []; + const re = /\[(E\d+)\]/g; + let m; + while (m = re.exec(s)) out.push(m[1]); + return out; +} +function titleFromIdea(idea) { + const first = idea.split(/[.,;:]/)[0]?.trim() || idea.trim(); + return first.length > 40 ? first.slice(0, 40).trim() : first || "The Product"; +} +function lowerFirst(s) { + const t = s.trim(); + return t ? t[0].toLowerCase() + t.slice(1) : t; +} +function evNum(id) { + const m = /^E(\d+)$/.exec(id); + return m ? Number(m[1]) : 1e9; +} + +// src/plan.ts +import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs"; +import { join as join9 } from "path"; +function buildPlanPath(runDir) { + return join9(runDir, "BUILD-PLAN.json"); +} +function milestoneLabel(title) { + return title.split("\u2014")[0].trim() || title.trim(); +} +function pad32(n) { + return String(n).padStart(3, "0"); +} +function derivePlan(srd) { + const frById = new Map(srd.functional.map((f) => [f.id, f])); + const ordered = []; + const seen = /* @__PURE__ */ new Set(); + for (const m of srd.buildPlan) { + for (const frId of m.frIds) { + if (frById.has(frId) && !seen.has(frId)) { + ordered.push({ frId, milestone: milestoneLabel(m.title) }); + seen.add(frId); + } + } } - out.push(``, `## User flows`, ``); - if (ds.flows.length) { - for (const f of ds.flows) { - out.push(`### ${f.name}${f.frIds.length ? ` _(${f.frIds.join(", ")})_` : ""}`, ``); - f.steps.forEach((step, i) => out.push(`${i + 1}. ${step}`)); - out.push(``); + for (const f of srd.functional) { + if (!seen.has(f.id)) ordered.push({ frId: f.id, milestone: "M1" }); + } + const tasks = [ + { + id: "T-000", + title: "Project skeleton \u2014 repo layout, test harness, CI", + milestone: ordered[0]?.milestone ?? "M1", + frIds: [], + acceptance: [], + dependsOn: [], + artifacts: [], + tests: [], + verify: { commands: [] }, + status: "todo" } - } else { - out.push(`_No user flows defined._`); + ]; + ordered.forEach(({ frId, milestone }, i) => { + const fr = frById.get(frId); + const dependsOn = ["T-000"]; + if (fr.entities.length) { + for (let j = 0; j < i; j++) { + const prev = ordered[j]; + if (prev.milestone === milestone) continue; + const prevFr = frById.get(prev.frId); + if (prevFr.entities.some((e) => fr.entities.includes(e))) { + dependsOn.push(`T-${pad32(j + 1)}`); + break; + } + } + } + tasks.push({ + id: `T-${pad32(i + 1)}`, + title: `${fr.id} \u2014 ${fr.title}`, + milestone, + ...fr.module ? { module: fr.module } : {}, + frIds: [fr.id], + acceptance: fr.acceptance.map((_, idx) => ({ frId: fr.id, index: idx })), + dependsOn, + artifacts: [], + tests: [], + verify: { commands: [] }, + status: "todo" + }); + }); + if (srd.design) { + tasks.push({ + id: `T-${pad32(ordered.length + 1)}`, + title: "Design foundation \u2014 design tokens, base components, accessibility baseline", + milestone: ordered[0]?.milestone ?? "M1", + frIds: [], + acceptance: [], + dependsOn: ["T-000"], + artifacts: [], + tests: [], + verify: { commands: [] }, + status: "todo" + }); } - return out.join("\n"); + return { + schemaVersion: BUILD_PLAN_SCHEMA_VERSION, + product: srd.product.name, + generatedAt: srd.generatedAt, + conventions: { frTagPattern: "FR-\\d{3}", testCommand: null, appDir: null }, + tasks + }; } -function renderAccessibility(ds) { - const a = ds.accessibility; - const out = [`# Accessibility`, ``, `**Target standard:** ${a.standard}`, ``]; - if (!a.requirements.length) { - out.push(`_No accessibility requirements defined._`, ``); - return out.join("\n"); - } - for (const r of a.requirements) { - out.push(`## ${r.id} \u2014 ${r.statement}`, ``, `**Acceptance criteria:**`); - for (const c of r.acceptance) out.push(`- **Given** ${c.given} **When** ${c.when} **Then** ${c.then}`); - out.push(``); +var STATUSES2 = ["todo", "in-progress", "done"]; +function taskKey(t) { + return t.frIds.length ? `fr:${t.title.replace(/^FR-\d+\s*—\s*/, "").trim().toLowerCase()}` : `title:${t.title.trim().toLowerCase()}`; +} +function mergePlan(prev, next) { + if (!prev) return next; + const prevByKey = new Map(prev.tasks.map((t) => [taskKey(t), t])); + const tasks = next.tasks.map((t) => { + const old = prevByKey.get(taskKey(t)); + if (!old) return t; + return { + ...t, + artifacts: Array.isArray(old.artifacts) ? old.artifacts : t.artifacts, + tests: Array.isArray(old.tests) ? old.tests : t.tests, + verify: old.verify && Array.isArray(old.verify.commands) ? old.verify : t.verify, + status: STATUSES2.includes(old.status) ? old.status : t.status + }; + }); + return { + ...next, + conventions: { + frTagPattern: next.conventions.frTagPattern, + testCommand: prev.conventions?.testCommand ?? null, + appDir: prev.conventions?.appDir ?? null + }, + tasks + }; +} +function readyFrontier(plan) { + const done = new Set(plan.tasks.filter((t) => t.status === "done").map((t) => t.id)); + const tasks = plan.tasks.map((t) => ({ + id: t.id, + milestone: t.milestone, + status: t.status, + dependsOn: t.dependsOn, + ready: t.status !== "done" && t.dependsOn.every((d) => done.has(d)) + })); + return { + product: plan.product, + done: done.size, + total: plan.tasks.length, + tasks, + frontier: tasks.filter((t) => t.ready).map((t) => t.id), + blocked: tasks.filter((t) => t.status !== "done" && !t.ready).map((t) => ({ id: t.id, waitingOn: t.dependsOn.filter((d) => !done.has(d)) })) + }; +} +function loadPlan(runDir) { + const path = buildPlanPath(runDir); + if (!existsSync5(path)) return null; + try { + const data = JSON.parse(readFileSync4(path, "utf8")); + return data && typeof data === "object" && Array.isArray(data.tasks) ? data : null; + } catch { + return null; } - return out.join("\n"); } -function renderMergeBundle(srd) { - const parts = [ - `# Software Requirements Document \u2014 ${srd.product.name}`, - ``, - `_Level: ${srd.level} \xB7 generated: ${srd.generatedAt}_`, - ``, - renderVision(srd), - renderScope(srd), - // Always the full FR blocks: the bundle is the one-file reading copy, so it - // must stay complete even when FUNCTIONAL.md is an index (modules mode). - renderFunctionalFull(srd), - renderNonFunctional(srd), - renderSystemContext(srd), - renderDataModel(srd), - renderInterfaces(srd), - `# Architecture decisions`, - ``, - ...srd.architecture.adrs.map(renderADR), - ...srd.design ? [ - `# Design system`, - ``, - renderDesignPrinciples(srd.design), - renderDesignTokens(srd.design), - renderComponents(srd.design), - renderScreens(srd.design), - renderAccessibility(srd.design) - ] : [], - renderLandscape(srd), - renderBuildPlan(srd), - renderTraceability(srd) - ]; - return parts.join("\n"); +function writePlan(runDir, plan) { + const path = buildPlanPath(runDir); + writeFileSync4(path, JSON.stringify(plan, null, 2) + "\n"); + return path; } // src/render.ts function writeFile(out, rel, content, files) { - const abs = join9(out, rel); - mkdirSync4(dirname2(abs), { recursive: true }); - writeFileSync4(abs, content.endsWith("\n") ? content : content + "\n"); + const abs = join10(out, rel); + mkdirSync5(dirname2(abs), { recursive: true }); + writeFileSync5(abs, content.endsWith("\n") ? content : content + "\n"); files.push(rel); } function renderSRD(brief, evidence, opts) { const wantDesign = opts.level === "complex" && !opts.noDesign; const srd = buildSRD(brief, evidence, { level: opts.level, generatedAt: opts.generatedAt, design: wantDesign }); + return emitSRD(srd, { out: opts.out, merge: opts.merge, prd: opts.prd }); +} +function renderFromSRD(runDir, opts) { + const manifest = srdManifestPath(runDir); + if (!existsSync6(manifest)) { + throw new Error(`No SRD.json in ${runDir} \u2014 render the SRD first (construct render), then edit it and re-run with --from-srd.`); + } + let srd; + try { + srd = JSON.parse(readFileSync5(manifest, "utf8")); + } catch (e) { + throw new Error(`SRD.json is unreadable: ${e.message}`); + } + if (!Array.isArray(srd.functional) || !Array.isArray(srd.nonFunctional) || !srd.architecture || !Array.isArray(srd.architecture.adrs)) { + throw new Error(`SRD.json in ${runDir} is not a valid SRD manifest (missing functional/nonFunctional/architecture).`); + } + return emitSRD(srd, { out: runDir, merge: opts.merge, prd: opts.prd }); +} +function emitSRD(srd, opts) { const files = []; const out = opts.out; - rmSync2(join9(out, "architecture", "decisions"), { recursive: true, force: true }); - rmSync2(join9(out, "design"), { recursive: true, force: true }); - rmSync2(join9(out, "prd"), { recursive: true, force: true }); + rmSync2(join10(out, "architecture", "decisions"), { recursive: true, force: true }); + rmSync2(join10(out, "design"), { recursive: true, force: true }); + rmSync2(join10(out, "prd"), { recursive: true, force: true }); writeFile(out, "00-overview/VISION.md", renderVision(srd), files); writeFile(out, "00-overview/SCOPE.md", renderScope(srd), files); writeFile(out, "requirements/FUNCTIONAL.md", renderFunctional(srd), files); - rmSync2(join9(out, "requirements", "prd"), { recursive: true, force: true }); + rmSync2(join10(out, "requirements", "prd"), { recursive: true, force: true }); if (opts.prd) { for (const fr of srd.functional) { writeFile(out, `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`, renderFeaturePRD(fr, srd), files); @@ -2978,19 +3329,245 @@ function renderSRD(brief, evidence, opts) { writeFile(out, "design/SCREENS.md", renderScreens(srd.design), files); writeFile(out, "design/ACCESSIBILITY.md", renderAccessibility(srd.design), files); } - writeFileSync4(srdManifestPath(out), JSON.stringify(srd, null, 2) + "\n"); + writeFileSync5(srdManifestPath(out), JSON.stringify(srd, null, 2) + "\n"); files.push("SRD.json"); if (opts.merge) { writeFile(out, "SRD.md", renderMergeBundle(srd), files); } else { - rmSync2(join9(out, "SRD.md"), { force: true }); + rmSync2(join10(out, "SRD.md"), { force: true }); } return { dir: out, files, srd }; } // src/check.ts -import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync3, statSync as statSync2 } from "fs"; -import { join as join10, relative as relative2, sep as sep2 } from "path"; +import { existsSync as existsSync8, readFileSync as readFileSync7, readdirSync as readdirSync3, statSync as statSync2 } from "fs"; +import { join as join12, relative as relative2, sep as sep2 } from "path"; + +// src/review.ts +import { existsSync as existsSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs"; +import { join as join11 } from "path"; +var VALID_VERDICTS = ["supported", "partial", "refuted", "unsupported"]; +function loadEvidence(path) { + if (!existsSync7(path)) return []; + try { + const data = JSON.parse(readFileSync6(path, "utf8")); + return Array.isArray(data) ? data.filter( + (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" + ) : []; + } catch { + return []; + } +} +function srdClaims(srd) { + const out = []; + for (const f of srd.functional) { + const ac = f.acceptance.map((a) => `${a.given} / ${a.when} / ${a.then}`).join("; "); + out.push({ id: f.id, kind: "FR", text: `${f.title}: ${f.description}${ac ? " \u2014 " + ac : ""}`, ev: f.rationaleEvidence }); + } + for (const n of srd.nonFunctional) { + out.push({ id: n.id, kind: "NFR", text: `${n.category}: ${n.statement}${n.metric ? ` (${n.metric})` : ""}`, ev: n.rationaleEvidence }); + } + for (const a of srd.architecture.adrs) { + out.push({ id: `ADR-${a.id}`, kind: "ADR", text: `${a.title}: ${a.decision}`, ev: a.evidence }); + } + srd.competitive.competitors.forEach((c, i) => out.push({ id: `COMP-${i + 1}`, kind: "competitor", text: `${c.name}: ${c.note}`, ev: c.evidence })); + srd.competitive.oss.forEach((o, i) => out.push({ id: `OSS-${i + 1}`, kind: "oss", text: `${o.name}: ${o.note}`, ev: o.evidence })); + return out; +} +function claimDigest(snippet, claim, cap = 600) { + if (snippet.length <= cap) return snippet; + const kws = keywords(claim).map((k) => k.toLowerCase()); + if (!kws.length) return snippet.slice(0, cap); + const step = 150; + let best = 0; + let bestCov = -1; + for (let start = 0; start === 0 || start + cap / 2 < snippet.length; start += step) { + const w = snippet.slice(start, start + cap).toLowerCase(); + let cov = 0; + for (const kw of kws) if (w.includes(kw)) cov++; + if (cov >= bestCov) { + bestCov = cov; + best = start; + } + } + return (best > 0 ? "\u2026 " : "") + snippet.slice(best, best + cap).trim(); +} +function runReview(runDir, opts = {}) { + const manifest = srdManifestPath(runDir); + if (!existsSync7(manifest)) throw new Error(`No SRD.json in ${runDir} \u2014 render the SRD first (construct render).`); + let srd; + try { + srd = JSON.parse(readFileSync6(manifest, "utf8")); + } catch (e) { + throw new Error(`SRD.json is unreadable: ${e.message}`); + } + const evidence = loadEvidence(join11(runDir, "evidence", "evidence.json")); + const byId = new Map(evidence.map((e) => [e.id, e])); + const pairs = []; + for (const c of srdClaims(srd)) { + for (const id of [...new Set(c.ev)]) { + const e = byId.get(id); + if (!e) continue; + const digest = claimDigest(e.snippet || e.title || e.ref, c.text); + pairs.push({ + claimId: c.id, + kind: c.kind, + claim: c.text.trim().slice(0, 400), + evidenceId: id, + source: e.source, + // A low-signal snippet (no keyword-matched excerpt — likely boilerplate) + // is flagged so the judge adjudicates it skeptically instead of granting + // "supported" on the URL alone. + digest: e.meta?.lowSignal ? `[low-signal snippet \u2014 no keyword-matched excerpt; adjudicate skeptically] ${digest}` : digest, + score: e.score + }); + } + } + const max = opts.maxReview === void 0 ? Number.POSITIVE_INFINITY : Math.max(1, Math.floor(opts.maxReview)); + const sorted = pairs.length > max ? pairs.slice().sort((a, b) => b.score - a.score || a.claimId.localeCompare(b.claimId) || a.evidenceId.localeCompare(b.evidenceId)) : pairs; + const kept = sorted.slice(0, Math.min(sorted.length, max)); + const dropped = sorted.slice(kept.length); + const worklist = { run: runDir, pairs: kept.map(({ score, ...rest }) => rest) }; + const todo = { + run: runDir, + pairs: worklist.pairs.map((p) => ({ ...p, verdict: null, note: "" })) + }; + writeFileSync6(join11(runDir, "VERIFY.todo.json"), JSON.stringify(todo, null, 2)); + writeFileSync6(join11(runDir, "VERIFY.md"), renderWorklistMd(worklist, pairs.length, dropped)); + return worklist; +} +function renderWorklistMd(wl, total, dropped) { + const out = []; + out.push(`# Claim-support review worklist`); + out.push(""); + out.push( + `For each pair, open the cited evidence and judge whether it **supports** the claim. In \`VERIFY.todo.json\`, set each \`verdict\` to one of supported \xB7 partial \xB7 refuted \xB7 unsupported, add a short \`note\`, save it (e.g. as \`verdicts.json\`), then run \`construct review --apply verdicts.json --out \`.` + ); + if (dropped.length) { + out.push(""); + out.push(`> **DROPPED (--max-review): ${dropped.length} of ${total} pair(s) are NOT in this worklist and will NOT be adjudicated.**`); + out.push(`> Their claims can pass \`check --semantic\` without their evidence ever being judged.`); + out.push(`> Re-run \`construct review\` without --max-review to review everything. Dropped:`); + for (const d of dropped) out.push(`> - ${d.claimId} \xB7 ${d.evidenceId} (${d.source})`); + } + out.push(""); + for (const p of wl.pairs) { + out.push(`## ${p.claimId} \xB7 ${p.evidenceId} (${p.source})`); + out.push(`**Claim (${p.kind}):** ${p.claim}`); + out.push(`**Cited evidence:** ${p.digest}`); + out.push(`**Verdict:** _____ \xB7 **Note:** _____`); + out.push(""); + } + return out.join("\n"); +} +function applyVerdicts(runDir, verdictsPath) { + if (!existsSync7(verdictsPath)) throw new Error(`verdicts file not found: ${verdictsPath}`); + let raw; + try { + raw = JSON.parse(readFileSync6(verdictsPath, "utf8")); + } catch (e) { + throw new Error(`verdicts file is not valid JSON (${verdictsPath}): ${e.message}`); + } + const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && Array.isArray(raw.pairs) ? raw.pairs : null; + if (list === null) { + throw new Error(`verdicts file must be a JSON array of verdicts or an object with a "pairs" array (${verdictsPath}).`); + } + const verdicts = []; + const seen = /* @__PURE__ */ new Set(); + const key = (claimId, evidenceId) => `${claimId}::${evidenceId}`; + for (const v of list) { + if (!v || typeof v.claimId !== "string" || typeof v.evidenceId !== "string") continue; + const verdict = VALID_VERDICTS.includes(v.verdict) ? v.verdict : void 0; + verdicts.push({ + claimId: v.claimId, + kind: v.kind, + claim: typeof v.claim === "string" ? v.claim : "", + evidenceId: v.evidenceId, + source: v.source, + digest: typeof v.digest === "string" ? v.digest : "", + verdict, + note: typeof v.note === "string" ? v.note : "" + }); + seen.add(key(v.claimId, v.evidenceId)); + } + const todoPath = join11(runDir, "VERIFY.todo.json"); + if (existsSync7(todoPath)) { + try { + const todo = JSON.parse(readFileSync6(todoPath, "utf8")); + for (const p of todo.pairs ?? []) { + if (!p || typeof p.claimId !== "string" || typeof p.evidenceId !== "string") continue; + if (seen.has(key(p.claimId, p.evidenceId))) continue; + verdicts.push({ + claimId: p.claimId, + kind: p.kind, + claim: p.claim ?? "", + evidenceId: p.evidenceId, + source: p.source, + digest: p.digest ?? "", + verdict: void 0, + note: "" + }); + seen.add(key(p.claimId, p.evidenceId)); + } + } catch { + } + } + const result = reduceVerdicts(verdicts); + writeFileSync6(join11(runDir, "VERIFY.json"), JSON.stringify({ ...result, verdicts }, null, 2)); + return result; +} +function reduceVerdicts(verdicts) { + const counts = { supported: 0, partial: 0, refuted: 0, unsupported: 0 }; + for (const v of verdicts) if (v.verdict && counts[v.verdict] !== void 0) counts[v.verdict]++; + const byClaim = /* @__PURE__ */ new Map(); + for (const v of verdicts) { + const group = byClaim.get(v.claimId) ?? []; + group.push(v); + byClaim.set(v.claimId, group); + } + const failures = []; + const unadjudicated = []; + for (const [claimId, group] of byClaim) { + const adjudicated = group.filter((g) => !!g.verdict); + if (adjudicated.length < group.length) unadjudicated.push(claimId); + const refuted = adjudicated.find((g) => g.verdict === "refuted"); + const hasSupport = adjudicated.some((g) => g.verdict === "supported" || g.verdict === "partial"); + if (refuted) { + failures.push({ claimId, evidenceId: refuted.evidenceId, verdict: "refuted", note: refuted.note }); + } else if (adjudicated.length === group.length && adjudicated.length > 0 && !hasSupport) { + const u = adjudicated.find((g) => g.verdict === "unsupported") ?? adjudicated[0]; + failures.push({ claimId, evidenceId: u.evidenceId, verdict: u.verdict, note: u.note }); + } + } + return { + ok: failures.length === 0, + pairs: verdicts.length, + adjudicated: verdicts.filter((v) => !!v.verdict).length, + supported: counts.supported, + partial: counts.partial, + refuted: counts.refuted, + unsupported: counts.unsupported, + failures, + unadjudicated + }; +} +function formatReviewReport(r) { + const lines = []; + lines.push(`construct review: ${r.adjudicated}/${r.pairs} pair(s) adjudicated`); + lines.push(` supported: ${r.supported} \xB7 partial: ${r.partial} \xB7 refuted: ${r.refuted} \xB7 unsupported: ${r.unsupported}`); + for (const f of r.failures.slice(0, 12)) { + lines.push(` \u2717 ${f.claimId} (${f.evidenceId}): ${f.verdict}${f.note ? " \u2014 " + f.note : ""}`); + } + if (r.unadjudicated.length) { + lines.push(` \u26A0 ${r.unadjudicated.length} claim(s) not fully adjudicated: ${r.unadjudicated.join(", ")}`); + } + lines.push( + !r.ok ? ` \u2717 some claims are refuted or unsupported` : r.unadjudicated.length ? ` \u2713 no refuted or unsupported claims (${r.unadjudicated.length} still unadjudicated \u2014 see above)` : ` \u2713 every grounded claim is backed by its cited evidence` + ); + return lines.join("\n"); +} + +// src/check.ts var DESIGN_REQUIRED_FILES = [ "design/PRINCIPLES.md", "design/DESIGN-TOKENS.md", @@ -3021,7 +3598,7 @@ function mdFiles(runDir) { continue; } for (const name of entries) { - const abs = join10(dir, name); + const abs = join12(dir, name); let st; try { st = statSync2(abs); @@ -3039,13 +3616,23 @@ function mdFiles(runDir) { } return out.sort(); } -function loadEvidence(runDir) { - const path = join10(runDir, "evidence", "evidence.json"); - if (!existsSync5(path)) { +function countProposedIdeas(runDir) { + const p = join12(runDir, "brainstorm.json"); + if (!existsSync8(p)) return 0; + try { + const data = JSON.parse(readFileSync7(p, "utf8")); + return Array.isArray(data.ideas) ? data.ideas.filter((i) => i && i.status === "proposed").length : 0; + } catch { + return 0; + } +} +function loadEvidence2(runDir) { + const path = join12(runDir, "evidence", "evidence.json"); + if (!existsSync8(path)) { return { evidence: [], note: `No evidence/evidence.json \u2014 grounding coverage is 0 (run \`construct research\` to ground the SRD).` }; } try { - const data = JSON.parse(readFileSync4(path, "utf8")); + const data = JSON.parse(readFileSync7(path, "utf8")); const evidence = Array.isArray(data) ? data.filter( (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" ) : []; @@ -3093,28 +3680,46 @@ function computeCoverage(srd, evidence) { } var TEMPLATED_THEN_RE = /is persisted and visible to the user$/; var TEMPLATED_METRIC_RE = /^A measurable target for "/; -function applySemantic(runDir, result) { - const p = join10(runDir, "VERIFY.json"); - if (!existsSync5(p)) { - result.structural.warnings.push("--semantic: no VERIFY.json \u2014 run `construct review` then `review --apply ` first; semantic gate skipped."); +function applySemantic(runDir, result, allowUnverified) { + const p = join12(runDir, "VERIFY.json"); + const skip = (reason, hint) => { + if (allowUnverified) { + result.structural.warnings.push(`--semantic: ${reason} \u2014 ${hint}; semantic gate skipped (--allow-unverified).`); + } else { + result.semanticError = `${reason} \u2014 ${hint}, or pass --allow-unverified to degrade this to a warning.`; + result.ok = false; + } + }; + if (!existsSync8(p)) { + skip("no VERIFY.json", "run `construct review` then `review --apply ` first"); return; } + let sem; try { - const sem = JSON.parse(readFileSync4(p, "utf8")); - result.semantic = sem; - if (!sem.ok) result.ok = false; - if (sem.unadjudicated?.length) { - result.structural.warnings.push(`${sem.unadjudicated.length} claim(s) not fully adjudicated by review.`); - } + sem = JSON.parse(readFileSync7(p, "utf8")); } catch (e) { - result.structural.warnings.push(`--semantic: VERIFY.json is unreadable (${e.message}).`); + skip(`VERIFY.json is unreadable (${e.message})`, "re-run `review --apply ` to regenerate it"); + return; + } + if (!Array.isArray(sem.verdicts)) { + skip("VERIFY.json carries no verdicts[] (legacy or hand-edited)", "re-run `review --apply ` to regenerate it"); + return; + } + const reduced = reduceVerdicts(sem.verdicts); + if (reduced.ok !== sem.ok) { + result.structural.warnings.push("VERIFY.json's persisted summary disagreed with its verdicts \u2014 recomputed at check time."); + } + result.semantic = { ...reduced, verdicts: sem.verdicts }; + if (!reduced.ok) result.ok = false; + if (reduced.unadjudicated.length) { + result.structural.warnings.push(`${reduced.unadjudicated.length} claim(s) not fully adjudicated by review.`); } } function checkDesign(runDir, srd, errors, warnings) { const ds = srd.design; if (!ds) return; for (const f of DESIGN_REQUIRED_FILES) { - if (!existsSync5(join10(runDir, f))) errors.push(`Missing required design file: ${f} (re-render at --level complex).`); + if (!existsSync8(join12(runDir, f))) errors.push(`Missing required design file: ${f} (re-render at --level complex).`); } const frIds = new Set(srd.functional.map((f) => f.id)); if (ds.components.length === 0) errors.push("Design system has no components \u2014 a complex SRD's design must name its UI components."); @@ -3136,8 +3741,8 @@ function checkDesign(runDir, srd, errors, warnings) { for (const r of ds.accessibility.requirements) { if (!r.acceptance.length) errors.push(`Accessibility requirement ${r.id} has no acceptance criteria.`); } - const tokenDoc = join10(runDir, "design", "DESIGN-TOKENS.md"); - if (existsSync5(tokenDoc) && readFileSync4(tokenDoc, "utf8").includes(DESIGN_TOKENS_SEEDED_BANNER)) { + const tokenDoc = join12(runDir, "design", "DESIGN-TOKENS.md"); + if (existsSync8(tokenDoc) && readFileSync7(tokenDoc, "utf8").includes(DESIGN_TOKENS_SEEDED_BANNER)) { warnings.push("Design tokens are still seeded defaults \u2014 replace them with the product's real brand values (see references/design-system-authoring.md)."); } } @@ -3145,11 +3750,11 @@ function checkModules(runDir, srd, errors, warnings) { const mods = srd.modules; if (!mods?.length) return; const moduleIds = new Set(mods.map((m) => m.id)); - if (!existsSync5(join10(runDir, "prd", "README.md"))) { + if (!existsSync8(join12(runDir, "prd", "README.md"))) { errors.push(`Missing required module-PRD index: prd/README.md (re-render).`); } for (const m of mods) { - if (!existsSync5(join10(runDir, "prd", m.id, "PRD.md"))) { + if (!existsSync8(join12(runDir, "prd", m.id, "PRD.md"))) { errors.push(`Missing required module PRD: prd/${m.id}/PRD.md (re-render).`); } for (const dep of m.dependsOn) { @@ -3180,22 +3785,22 @@ function checkRun(runDir, opts = {}) { resolved: [] }; for (const f of REQUIRED_FILES) { - if (!existsSync5(join10(runDir, f))) errors.push(`Missing required file: ${f} (run \`construct render --out ${runDir}\`).`); + if (!existsSync8(join12(runDir, f))) errors.push(`Missing required file: ${f} (run \`construct render --out ${runDir}\`).`); } const manifest = srdManifestPath(runDir); - if (!existsSync5(manifest)) { + if (!existsSync8(manifest)) { errors.push(`No SRD.json in ${runDir} \u2014 render the SRD first.`); return { ok: false, structural: { ok: false, errors, warnings }, coverage: emptyCoverage }; } let srd; try { - srd = JSON.parse(readFileSync4(manifest, "utf8")); + srd = JSON.parse(readFileSync7(manifest, "utf8")); } catch (e) { errors.push(`SRD.json is unreadable: ${e.message}`); return { ok: false, structural: { ok: false, errors, warnings }, coverage: emptyCoverage }; } for (const rel of mdFiles(runDir)) { - const text = readFileSync4(join10(runDir, rel), "utf8"); + const text = readFileSync7(join12(runDir, rel), "utf8"); if (DECISION_RE.test(text)) errors.push(`Unresolved decision (\u{1F9E0}) in ${rel} \u2014 resolve it before the SRD is complete.`); else if (PLACEHOLDER_RE.test(text)) warnings.push(`Possible leftover placeholder (TODO/TBD/FIXME) in ${rel} \u2014 confirm it is intentional.`); } @@ -3239,15 +3844,19 @@ function checkRun(runDir, opts = {}) { checkModules(runDir, srd, errors, warnings); const templatedThen = srd.functional.reduce((n, fr) => n + fr.acceptance.filter((a) => TEMPLATED_THEN_RE.test(a.then)).length, 0); if (templatedThen) { - warnings.push( - `${templatedThen} acceptance criteria are still renderer-templated \u2014 sharpen them into observable, bounded outcomes (see references/acceptance-criteria.md).` - ); + const msg = `${templatedThen} acceptance criteria are still renderer-templated \u2014 sharpen them into observable, bounded outcomes (see references/acceptance-criteria.md).`; + if (srd.level === "complex") errors.push(msg); + else warnings.push(msg); } const templatedMetrics = srd.nonFunctional.filter((n) => n.metric && TEMPLATED_METRIC_RE.test(n.metric)).length; if (templatedMetrics) { warnings.push(`${templatedMetrics} NFR metric(s) are still generic placeholders \u2014 set measurable targets (see references/acceptance-criteria.md).`); } - const { evidence, note } = loadEvidence(runDir); + const proposedIdeas = countProposedIdeas(runDir); + if (proposedIdeas > 0) { + warnings.push(`brainstorm: ${proposedIdeas} idea(s) still 'proposed' \u2014 adjudicate (kept/parked/rejected) and run \`construct brainstorm --merge\`.`); + } + const { evidence, note } = loadEvidence2(runDir); if (note) warnings.push(note); const coverage = computeCoverage(srd, evidence); if (coverage.dangling.length) { @@ -3263,7 +3872,12 @@ function checkRun(runDir, opts = {}) { } const ok = structuralOk && (grounding?.ok ?? true); const result = { ok, structural: { ok: structuralOk, errors, warnings }, coverage, grounding }; - if (opts.semantic) applySemantic(runDir, result); + if (opts.semantic) { + applySemantic(runDir, result, opts.allowUnverified ?? false); + } else if (coverage.resolved.length > 0) { + const citedClaims = coverage.frGrounded + coverage.nfrGrounded + coverage.adrGrounded; + result.semanticSkipped = { citedClaims, verifyExists: existsSync8(join12(runDir, "VERIFY.json")) }; + } return result; } function pct(part, total) { @@ -3294,6 +3908,21 @@ function formatCheckReport(r, runDir) { g.ok ? ` \u2713 PASS \u2014 ${g.actualPct}% of groundable claims are grounded (threshold ${g.threshold}%)` : ` \u2717 FAIL \u2014 ${g.actualPct}% of groundable claims are grounded, below the ${g.threshold}% threshold` ); } + if (r.semanticSkipped) { + const s = r.semanticSkipped; + lines.push(``); + lines.push(`Semantic gate: SKIPPED`); + lines.push(` \u26A0 ${s.citedClaims} cited claim(s) were never adversarially verified \u2014 a citation`); + lines.push( + s.verifyExists ? ` proves nothing until reviewed. A VERIFY.json exists \u2014 re-run with --semantic to gate on it.` : ` proves nothing until reviewed. Run \`construct review --out \`, adjudicate the` + ); + if (!s.verifyExists) lines.push(` worklist, then \`construct check --semantic\`.`); + } + if (r.semanticError) { + lines.push(``); + lines.push(`Semantic claim-support gate (--semantic):`); + lines.push(` \u2717 FAIL \u2014 ${r.semanticError}`); + } if (r.semantic) { const s = r.semantic; lines.push(``); @@ -3308,13 +3937,13 @@ function formatCheckReport(r, runDir) { } // src/analyze.ts -import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs"; -import { join as join11 } from "path"; -function loadEvidence2(runDir) { - const path = join11(runDir, "evidence", "evidence.json"); - if (!existsSync6(path)) return []; +import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs"; +import { join as join13 } from "path"; +function loadEvidence3(runDir) { + const path = join13(runDir, "evidence", "evidence.json"); + if (!existsSync9(path)) return []; try { - const data = JSON.parse(readFileSync5(path, "utf8")); + const data = JSON.parse(readFileSync8(path, "utf8")); return Array.isArray(data) ? data.filter( (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" ) : []; @@ -3323,10 +3952,10 @@ function loadEvidence2(runDir) { } } function loadMetaNotes(runDir) { - const path = join11(runDir, "evidence", "meta.json"); - if (!existsSync6(path)) return []; + const path = join13(runDir, "evidence", "meta.json"); + if (!existsSync9(path)) return []; try { - const meta = JSON.parse(readFileSync5(path, "utf8")); + const meta = JSON.parse(readFileSync8(path, "utf8")); return Array.isArray(meta.notes) ? meta.notes.filter((n) => typeof n === "string") : []; } catch { return []; @@ -3337,7 +3966,7 @@ function featureText(f) { } function analyzeRun(runDir) { const brief = loadBrief(runDir); - const evidence = loadEvidence2(runDir); + const evidence = loadEvidence3(runDir); const notes = loadMetaNotes(runDir); const drill = (cmd, q) => `construct ${cmd} --out ${runDir} --q "${q.replace(/"/g, "'")}"`; const bySource = {}; @@ -3345,6 +3974,10 @@ function analyzeRun(runDir) { if (evidence.length === 0) { notes.push("No evidence dossier \u2014 run `construct research` first; everything below will render ungrounded."); } + const lowSignal = evidence.filter((e) => e.meta?.lowSignal).length; + if (lowSignal) { + notes.push(`${lowSignal} low-signal snippet(s) in the dossier \u2014 likely boilerplate; re-drill with a sharper --q or a better --docs-url.`); + } const suggestions = []; const ungroundedFeatures = brief.featureWishlist.filter((f) => matchEvidence(featureText(f), evidence, 1, GROUND_REQUIREMENT).length === 0).map((f) => ({ title: f.title, priority: f.priority ?? "should" })); for (const f of ungroundedFeatures) suggestions.push(drill("web", f.title)); @@ -3397,8 +4030,8 @@ function formatGapReport(r, runDir) { } // src/verify.ts -import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs"; -import { isAbsolute, join as join12, resolve as resolve2 } from "path"; +import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs"; +import { isAbsolute, join as join14, resolve as resolve2 } from "path"; var TEST_FILE_RE = /\.(test|spec)\.[^./]+$|_(test|spec)\.[^./]+$|(^|\/)test_[^/]+\.[^./]+$/i; var TEST_SUFFIX_RE = /(^|\/)[^/]*[A-Z]\w*Tests?\.(java|kt|kts|cs|scala|groovy)$/; var TEST_DIR_RE = /(^|\/)(tests?|__tests__|spec|specs|e2e)\//i; @@ -3436,7 +4069,7 @@ function verifyRun(runDir, opts = {}) { const warnings = []; const frTestCoverage = []; const planPath = buildPlanPath(runDir); - if (!existsSync7(planPath)) { + if (!existsSync10(planPath)) { errors.push(`No BUILD-PLAN.json in ${runDir} \u2014 render the SRD first (construct render).`); return { ok: false, errors, warnings, frTestCoverage }; } @@ -3450,13 +4083,13 @@ function verifyRun(runDir, opts = {}) { return { ok: false, errors, warnings, frTestCoverage }; } const manifest = srdManifestPath(runDir); - if (!existsSync7(manifest)) { + if (!existsSync10(manifest)) { errors.push(`No SRD.json in ${runDir} \u2014 the plan cannot be verified against a missing SRD.`); return { ok: false, errors, warnings, frTestCoverage }; } let srd; try { - srd = JSON.parse(readFileSync6(manifest, "utf8")); + srd = JSON.parse(readFileSync9(manifest, "utf8")); } catch (e) { errors.push(`SRD.json is unreadable: ${e.message}`); return { ok: false, errors, warnings, frTestCoverage }; @@ -3500,13 +4133,13 @@ function verifyRun(runDir, opts = {}) { const ok2 = errors.length === 0; return { ok: ok2, errors, warnings, frTestCoverage }; } - if (!existsSync7(appDir)) { + if (!existsSync10(appDir)) { errors.push(`App directory does not exist: ${appDir}.`); return { ok: false, errors, warnings, frTestCoverage }; } for (const t of doneTasks) { for (const rel of [...t.artifacts, ...t.tests]) { - if (!existsSync7(join12(appDir, rel))) errors.push(`${t.id} is done but its declared file is missing: ${rel}.`); + if (!existsSync10(join14(appDir, rel))) errors.push(`${t.id} is done but its declared file is missing: ${rel}.`); } if (t.frIds.length && t.tests.length === 0) { warnings.push(`${t.id} is done but declares no tests \u2014 record the test files that exercise ${t.frIds.join(", ")}.`); @@ -3593,220 +4226,6 @@ function formatVerifyReport(r, runDir) { return lines.join("\n"); } -// src/review.ts -import { existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs"; -import { join as join13 } from "path"; -var REVIEW_MAX = 40; -var VALID_VERDICTS = ["supported", "partial", "refuted", "unsupported"]; -function loadEvidence3(path) { - if (!existsSync8(path)) return []; - try { - const data = JSON.parse(readFileSync7(path, "utf8")); - return Array.isArray(data) ? data.filter( - (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" - ) : []; - } catch { - return []; - } -} -function srdClaims(srd) { - const out = []; - for (const f of srd.functional) { - const ac = f.acceptance.map((a) => `${a.given} / ${a.when} / ${a.then}`).join("; "); - out.push({ id: f.id, kind: "FR", text: `${f.title}: ${f.description}${ac ? " \u2014 " + ac : ""}`, ev: f.rationaleEvidence }); - } - for (const n of srd.nonFunctional) { - out.push({ id: n.id, kind: "NFR", text: `${n.category}: ${n.statement}${n.metric ? ` (${n.metric})` : ""}`, ev: n.rationaleEvidence }); - } - for (const a of srd.architecture.adrs) { - out.push({ id: `ADR-${a.id}`, kind: "ADR", text: `${a.title}: ${a.decision}`, ev: a.evidence }); - } - srd.competitive.competitors.forEach((c, i) => out.push({ id: `COMP-${i + 1}`, kind: "competitor", text: `${c.name}: ${c.note}`, ev: c.evidence })); - srd.competitive.oss.forEach((o, i) => out.push({ id: `OSS-${i + 1}`, kind: "oss", text: `${o.name}: ${o.note}`, ev: o.evidence })); - return out; -} -function claimDigest(snippet, claim, cap = 600) { - if (snippet.length <= cap) return snippet; - const kws = keywords(claim).map((k) => k.toLowerCase()); - if (!kws.length) return snippet.slice(0, cap); - const step = 150; - let best = 0; - let bestCov = -1; - for (let start = 0; start === 0 || start + cap / 2 < snippet.length; start += step) { - const w = snippet.slice(start, start + cap).toLowerCase(); - let cov = 0; - for (const kw of kws) if (w.includes(kw)) cov++; - if (cov >= bestCov) { - bestCov = cov; - best = start; - } - } - return (best > 0 ? "\u2026 " : "") + snippet.slice(best, best + cap).trim(); -} -function runReview(runDir, opts = {}) { - const manifest = srdManifestPath(runDir); - if (!existsSync8(manifest)) throw new Error(`No SRD.json in ${runDir} \u2014 render the SRD first (construct render).`); - let srd; - try { - srd = JSON.parse(readFileSync7(manifest, "utf8")); - } catch (e) { - throw new Error(`SRD.json is unreadable: ${e.message}`); - } - const evidence = loadEvidence3(join13(runDir, "evidence", "evidence.json")); - const byId = new Map(evidence.map((e) => [e.id, e])); - const pairs = []; - for (const c of srdClaims(srd)) { - for (const id of [...new Set(c.ev)]) { - const e = byId.get(id); - if (!e) continue; - pairs.push({ - claimId: c.id, - kind: c.kind, - claim: c.text.trim().slice(0, 400), - evidenceId: id, - source: e.source, - digest: claimDigest(e.snippet || e.title || e.ref, c.text), - score: e.score - }); - } - } - const max = Math.max(1, Math.floor(opts.maxReview ?? REVIEW_MAX)); - const kept = pairs.length > max ? pairs.slice().sort((a, b) => b.score - a.score || a.claimId.localeCompare(b.claimId) || a.evidenceId.localeCompare(b.evidenceId)).slice(0, max) : pairs; - const worklist = { run: runDir, pairs: kept.map(({ score, ...rest }) => rest) }; - const todo = { - run: runDir, - pairs: worklist.pairs.map((p) => ({ ...p, verdict: null, note: "" })) - }; - writeFileSync5(join13(runDir, "VERIFY.todo.json"), JSON.stringify(todo, null, 2)); - writeFileSync5(join13(runDir, "VERIFY.md"), renderWorklistMd(worklist, pairs.length, kept.length)); - return worklist; -} -function renderWorklistMd(wl, total, kept) { - const out = []; - out.push(`# Claim-support review worklist`); - out.push(""); - out.push( - `For each pair, open the cited evidence and judge whether it **supports** the claim. In \`VERIFY.todo.json\`, set each \`verdict\` to one of supported \xB7 partial \xB7 refuted \xB7 unsupported, add a short \`note\`, save it (e.g. as \`verdicts.json\`), then run \`construct review --apply verdicts.json --out \`.` - ); - if (kept < total) out.push(` -_Showing ${kept} of ${total} pair(s) \u2014 capped at the highest-score evidence._`); - out.push(""); - for (const p of wl.pairs) { - out.push(`## ${p.claimId} \xB7 ${p.evidenceId} (${p.source})`); - out.push(`**Claim (${p.kind}):** ${p.claim}`); - out.push(`**Cited evidence:** ${p.digest}`); - out.push(`**Verdict:** _____ \xB7 **Note:** _____`); - out.push(""); - } - return out.join("\n"); -} -function applyVerdicts(runDir, verdictsPath) { - if (!existsSync8(verdictsPath)) throw new Error(`verdicts file not found: ${verdictsPath}`); - let raw; - try { - raw = JSON.parse(readFileSync7(verdictsPath, "utf8")); - } catch (e) { - throw new Error(`verdicts file is not valid JSON (${verdictsPath}): ${e.message}`); - } - const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && Array.isArray(raw.pairs) ? raw.pairs : null; - if (list === null) { - throw new Error(`verdicts file must be a JSON array of verdicts or an object with a "pairs" array (${verdictsPath}).`); - } - const verdicts = []; - const seen = /* @__PURE__ */ new Set(); - const key = (claimId, evidenceId) => `${claimId}::${evidenceId}`; - for (const v of list) { - if (!v || typeof v.claimId !== "string" || typeof v.evidenceId !== "string") continue; - const verdict = VALID_VERDICTS.includes(v.verdict) ? v.verdict : void 0; - verdicts.push({ - claimId: v.claimId, - kind: v.kind, - claim: typeof v.claim === "string" ? v.claim : "", - evidenceId: v.evidenceId, - source: v.source, - digest: typeof v.digest === "string" ? v.digest : "", - verdict, - note: typeof v.note === "string" ? v.note : "" - }); - seen.add(key(v.claimId, v.evidenceId)); - } - const todoPath = join13(runDir, "VERIFY.todo.json"); - if (existsSync8(todoPath)) { - try { - const todo = JSON.parse(readFileSync7(todoPath, "utf8")); - for (const p of todo.pairs ?? []) { - if (!p || typeof p.claimId !== "string" || typeof p.evidenceId !== "string") continue; - if (seen.has(key(p.claimId, p.evidenceId))) continue; - verdicts.push({ - claimId: p.claimId, - kind: p.kind, - claim: p.claim ?? "", - evidenceId: p.evidenceId, - source: p.source, - digest: p.digest ?? "", - verdict: void 0, - note: "" - }); - seen.add(key(p.claimId, p.evidenceId)); - } - } catch { - } - } - const result = reduceVerdicts(verdicts); - writeFileSync5(join13(runDir, "VERIFY.json"), JSON.stringify({ ...result, verdicts }, null, 2)); - return result; -} -function reduceVerdicts(verdicts) { - const counts = { supported: 0, partial: 0, refuted: 0, unsupported: 0 }; - for (const v of verdicts) if (v.verdict && counts[v.verdict] !== void 0) counts[v.verdict]++; - const byClaim = /* @__PURE__ */ new Map(); - for (const v of verdicts) { - const group = byClaim.get(v.claimId) ?? []; - group.push(v); - byClaim.set(v.claimId, group); - } - const failures = []; - const unadjudicated = []; - for (const [claimId, group] of byClaim) { - const adjudicated = group.filter((g) => !!g.verdict); - if (adjudicated.length < group.length) unadjudicated.push(claimId); - const refuted = adjudicated.find((g) => g.verdict === "refuted"); - const hasSupport = adjudicated.some((g) => g.verdict === "supported" || g.verdict === "partial"); - if (refuted) { - failures.push({ claimId, evidenceId: refuted.evidenceId, verdict: "refuted", note: refuted.note }); - } else if (adjudicated.length === group.length && adjudicated.length > 0 && !hasSupport) { - const u = adjudicated.find((g) => g.verdict === "unsupported") ?? adjudicated[0]; - failures.push({ claimId, evidenceId: u.evidenceId, verdict: u.verdict, note: u.note }); - } - } - return { - ok: failures.length === 0, - pairs: verdicts.length, - adjudicated: verdicts.filter((v) => !!v.verdict).length, - supported: counts.supported, - partial: counts.partial, - refuted: counts.refuted, - unsupported: counts.unsupported, - failures, - unadjudicated - }; -} -function formatReviewReport(r) { - const lines = []; - lines.push(`construct review: ${r.adjudicated}/${r.pairs} pair(s) adjudicated`); - lines.push(` supported: ${r.supported} \xB7 partial: ${r.partial} \xB7 refuted: ${r.refuted} \xB7 unsupported: ${r.unsupported}`); - for (const f of r.failures.slice(0, 12)) { - lines.push(` \u2717 ${f.claimId} (${f.evidenceId}): ${f.verdict}${f.note ? " \u2014 " + f.note : ""}`); - } - if (r.unadjudicated.length) { - lines.push(` \u26A0 ${r.unadjudicated.length} claim(s) not fully adjudicated: ${r.unadjudicated.join(", ")}`); - } - lines.push( - !r.ok ? ` \u2717 some claims are refuted or unsupported` : r.unadjudicated.length ? ` \u2713 no refuted or unsupported claims (${r.unadjudicated.length} still unadjudicated \u2014 see above)` : ` \u2713 every grounded claim is backed by its cited evidence` - ); - return lines.join("\n"); -} - // src/cli.ts var HELP = `construct v${VERSION} Turn a product idea into a grounded, buildable SRD suite. Interview \u2192 research @@ -3815,11 +4234,13 @@ check. Grounding is advisory; structural completeness is enforced. Usage: construct init --idea "" [--out ] + construct brainstorm --out [--merge] [--json] construct research --out [--angles market,oss,tech,semantic] [--q ""] [--url ] [--semantic] construct analyze --out [--json] construct web|oss|tech|so --out [--q ""] [--url ] [--seeds ] construct render --out [--level light|complex] [--merge] [--no-design] [--prd] - construct check --out [--min-grounding <0-100>] [--semantic] [--json] + construct render --out --from-srd [--merge] [--prd] + construct check --out [--min-grounding <0-100>] [--semantic [--allow-unverified]] [--json] construct review --out [--apply ] [--max-review N] [--json] construct verify --out [--app ] [--run-tests] [--strict] [--json] construct status --out [--json] @@ -3827,6 +4248,9 @@ Usage: Commands: init Scaffold a run folder + brief.json (fill it via the interview). + brainstorm Divergent ideation BEFORE the interview: scaffold a board of + candidate ideas (brainstorm.json + BRAINSTORM.md). --merge folds + kept ideas into brief.json (parked \u2192 \u{1F9E0} openQuestions). research Gather evidence across angles into /evidence (a dossier). analyze Report what is thin (gaps that will render ungrounded) + drill commands. web Drill the market/web angle. oss Drill OSS prior-art mining. @@ -3836,6 +4260,9 @@ Commands: (design/: principles, tokens, components, screens, accessibility); --no-design opts out. --prd also emits requirements/prd/ \u2014 one standalone PRD per functional requirement + an index. + --from-srd re-emits the tree from an edited SRD.json WITHOUT + rebuilding it (the enrich\u2192re-render path; keeps markdown in sync + with the gated manifest). check Hard structural gate + advisory grounding-coverage report. --semantic also folds in the review verdicts (fails on a claim its cited evidence does not support). @@ -3859,7 +4286,13 @@ Options: --level light | complex (default: light) --min-grounding For 'check': fail unless \u2265 n% of claims are grounded (opt-in) --semantic For 'check': fold in the 'review' claim-support verdicts + (fail-closed: no/unreadable VERIFY.json fails the check) + --allow-unverified For 'check --semantic': degrade a missing/unreadable + VERIFY.json to a warning instead of failing --apply For 'review': consume an adjudicated verdicts file + gate + --max-review For 'review': cap the worklist at the n highest-score + pairs (default: review ALL cited pairs; dropped pairs + are named in VERIFY.md) --app For 'verify': the built app directory (default: conventions.appDir) --run-tests For 'verify': also execute testCommand + per-task verify commands --strict For 'verify': a built must-have FR with no referencing test FAILS @@ -3880,7 +4313,22 @@ Workflow: construct render --out ./my-idea --level complex # writes the SRD tree construct check --out ./my-idea # structural gate + coverage report `; -var COMMANDS = /* @__PURE__ */ new Set(["init", "research", "analyze", "web", "oss", "tech", "so", "render", "check", "verify", "review", "status", "semantic"]); +var COMMANDS = /* @__PURE__ */ new Set([ + "init", + "brainstorm", + "research", + "analyze", + "web", + "oss", + "tech", + "so", + "render", + "check", + "verify", + "review", + "status", + "semantic" +]); var VALUE_FLAGS = /* @__PURE__ */ new Set([ "idea", "out", @@ -3900,7 +4348,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([ "apply", "max-review" ]); -var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd"]); +var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd", "allow-unverified", "from-srd"]); function fail(message) { process.stderr.write(`construct: ${message} `); @@ -4049,6 +4497,53 @@ async function main() { ); return; } + case "brainstorm": { + const out = requireOut(p); + const brief = loadBrief(out, warnBrief); + if (p.bools.has("merge")) { + const b2 = loadBrainstorm(out, warnBrief); + if (!b2) fail(`no brainstorm.json in ${out} \u2014 run \`construct brainstorm --out ${out}\` first to scaffold one.`); + const r = mergeBrainstorm(brief, b2, (/* @__PURE__ */ new Date()).toISOString(), warnBrief); + saveBrief(out, r.brief); + saveBrainstorm(out, r.brainstorm); + writeBrainstormMd(out, r.brainstorm); + if (p.bools.has("json")) { + process.stdout.write(JSON.stringify({ merged: r.merged, parkedFolded: r.parkedFolded, skipped: r.skipped, proposed: r.proposed }, null, 2) + "\n"); + return; + } + process.stderr.write( + [ + `construct: merged brainstorm \u2192 brief.json`, + ` merged: ${r.merged} kept idea(s) folded into the brief`, + ` parked: ${r.parkedFolded} parked idea(s) \u2192 openQuestions (\u{1F9E0} \u2014 resolve before check passes)`, + ` skipped: ${r.skipped} kept idea(s) not merged (no target / conflict \u2014 see warnings)`, + ` proposed: ${r.proposed} idea(s) still awaiting a decision`, + ` next: construct research --out ${out}` + ].join("\n") + "\n" + ); + return; + } + let b = loadBrainstorm(out, warnBrief); + if (!b) { + b = initBrainstorm(brief.idea, (/* @__PURE__ */ new Date()).toISOString()); + saveBrainstorm(out, b); + } + writeBrainstormMd(out, b); + if (p.bools.has("json")) { + process.stdout.write(JSON.stringify(b, null, 2) + "\n"); + return; + } + const c = brainstormCounts(b); + process.stderr.write( + [ + `construct: brainstorm board at ${join15(out, "BRAINSTORM.md")}`, + ` ideas: ${b.ideas.length} (${c.kept} kept \xB7 ${c.parked} parked \xB7 ${c.proposed} proposed \xB7 ${c.rejected} rejected)`, + ` next: generate ideas WITH the user (references/brainstorm-playbook.md), mark statuses in`, + ` brainstorm.json, then: construct brainstorm --out ${out} --merge` + ].join("\n") + "\n" + ); + return; + } case "research": { const out = requireOut(p); const angles = p.values.angles ? parseAngles(p.values.angles) : DEFAULT_ANGLES; @@ -4103,6 +4598,20 @@ async function main() { } case "render": { const out = requireOut(p); + if (p.bools.has("from-srd")) { + if (p.values.level) process.stderr.write("construct: --level is ignored with --from-srd (the manifest's level is authoritative).\n"); + if (p.bools.has("no-design")) + process.stderr.write("construct: --no-design is ignored with --from-srd (re-run a full render to change the design subtree).\n"); + const r2 = renderFromSRD(out, { merge: p.bools.has("merge"), prd: p.bools.has("prd") }); + process.stderr.write( + [ + `construct: re-emitted the SRD tree from ${join15(out, "SRD.json")}`, + ` files: ${r2.files.length} (${r2.srd.functional.length} FR \xB7 ${r2.srd.nonFunctional.length} NFR \xB7 ${r2.srd.architecture.adrs.length} ADR)`, + ` next: construct check --out ${out}` + ].join("\n") + "\n" + ); + return; + } const brief = loadBrief(out, warnBrief); const v = validateBrief(brief); if (!v.ok) fail(`brief is incomplete: @@ -4123,7 +4632,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); `construct: rendered the ${level} SRD for "${brief.idea}"`, ` files: ${r.files.length} (${r.srd.functional.length} FR \xB7 ${r.srd.nonFunctional.length} NFR \xB7 ${r.srd.architecture.adrs.length} ADR)`, ...design ? [` design: ${design.components.length} components \xB7 ${design.tokens.length} tokens \xB7 a11y ${design.accessibility.standard}`] : [], - ` manifest: ${join14(out, "SRD.json")}`, + ` manifest: ${join15(out, "SRD.json")}`, ` next: construct check --out ${out}` ].join("\n") + "\n" ); @@ -4149,7 +4658,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); fail("invalid --min-grounding (expected a number between 0 and 100)"); } } - const res = checkRun(out, { minGrounding, semantic: p.bools.has("semantic") }); + const res = checkRun(out, { minGrounding, semantic: p.bools.has("semantic"), allowUnverified: p.bools.has("allow-unverified") }); if (p.bools.has("json")) { process.stdout.write(JSON.stringify(res, null, 2) + "\n"); } else { @@ -4167,8 +4676,11 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); if (!res.ok) process.exit(1); return; } - const maxReview = p.values["max-review"] ? Number(p.values["max-review"]) : REVIEW_MAX; - if (!Number.isFinite(maxReview) || maxReview <= 0) fail("invalid --max-review"); + let maxReview; + if (p.values["max-review"] !== void 0) { + maxReview = Number(p.values["max-review"]); + if (p.values["max-review"].trim() === "" || !Number.isFinite(maxReview) || maxReview <= 0) fail("invalid --max-review"); + } const wl = runReview(out, { maxReview }); if (p.bools.has("json")) { process.stdout.write(JSON.stringify(wl, null, 2) + "\n"); @@ -4203,11 +4715,17 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); process.stdout.write(JSON.stringify(plan ? readyFrontier(plan) : null, null, 2) + "\n"); return; } - const has = (rel) => existsSync9(join14(out, rel)) ? "\u2713" : "\xB7"; + const has = (rel) => existsSync11(join15(out, rel)) ? "\u2713" : "\xB7"; const planLine = plan ? ` \u2713 BUILD-PLAN.json (build: ${plan.tasks.filter((t) => t.status === "done").length}/${plan.tasks.length} tasks done)` : ` \xB7 BUILD-PLAN.json (build plan)`; + const bs = loadBrainstorm(out); + const bsLine = bs ? (() => { + const c = brainstormCounts(bs); + return ` \u2713 brainstorm.json (${c.kept} kept \xB7 ${c.parked} parked \xB7 ${c.proposed} proposed \xB7 ${c.rejected} rejected)`; + })() : ` \xB7 brainstorm.json (optional divergence)`; process.stdout.write( [ `construct status: ${out}`, + bsLine, ` ${has("brief.json")} brief.json`, ` ${has("evidence/evidence.json")} evidence/evidence.json (research)`, ` ${has("SRD.json")} SRD.json (render)`, @@ -4227,10 +4745,10 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); } } function loadEvidence4(runDir) { - const path = join14(runDir, "evidence", "evidence.json"); - if (!existsSync9(path)) return []; + const path = join15(runDir, "evidence", "evidence.json"); + if (!existsSync11(path)) return []; try { - const data = JSON.parse(readFileSync8(path, "utf8")); + const data = JSON.parse(readFileSync10(path, "utf8")); return Array.isArray(data) ? data.filter(isEvidenceItem) : []; } catch { return []; diff --git a/scripts/copy-bundle.mjs b/scripts/copy-bundle.mjs index d67cb3e..9149f42 100644 --- a/scripts/copy-bundle.mjs +++ b/scripts/copy-bundle.mjs @@ -10,10 +10,22 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const source = join(root, "scripts", "construct.mjs"); -const targets = [join(root, "skills", "construct", "scripts", "construct.mjs")]; +const skill = join(root, "skills", "construct"); -for (const target of targets) { +// The engine bundle + the optional semantic Docker stack (compose file and its +// SearXNG settings). All three must ship INSIDE the skill dir so the installed +// skill is self-contained — the compose lives at skills/construct/ so it is +// `../docker-compose.yml` from the bundle, and its `./docker/searxng` bind mount +// resolves to the shipped sibling. +const pairs = [ + ["scripts/construct.mjs", "scripts/construct.mjs"], + ["docker-compose.yml", "docker-compose.yml"], + ["docker/searxng/settings.yml", "docker/searxng/settings.yml"], +]; + +for (const [from, to] of pairs) { + const source = join(root, from); + const target = join(skill, to); mkdirSync(dirname(target), { recursive: true }); copyFileSync(source, target); console.log(`copy-bundle: ${source} -> ${target}`); diff --git a/scripts/verify-skill-bundle.mjs b/scripts/verify-skill-bundle.mjs index 19bc0fd..a9bcd51 100644 --- a/scripts/verify-skill-bundle.mjs +++ b/scripts/verify-skill-bundle.mjs @@ -74,6 +74,19 @@ else readFileSync(rootEngine).equals(readFileSync(pkgEngine)) ? ok(`embedded engine skills/${name}/${engine} is byte-identical to ${engine}`) : bad(`skills/${name}/${engine} differs from ${engine} — run \`node scripts/copy-bundle.mjs\` and commit`); +// 5. The optional semantic Docker stack ships inside the skill, byte-identical +// to the repo-root source (else `construct semantic up|down|status` and the +// --semantic embedding rescore cannot run from the installed layout). +for (const rel of ["docker-compose.yml", "docker/searxng/settings.yml"]) { + const rootFile = join(root, rel); + const pkgFile = join(skillDir, rel); + if (!existsSync(rootFile)) bad(`missing ${rel} at repo root`); + else if (!existsSync(pkgFile)) bad(`missing skills/${name}/${rel} — run \`node scripts/copy-bundle.mjs\``); + else readFileSync(rootFile).equals(readFileSync(pkgFile)) + ? ok(`semantic stack skills/${name}/${rel} is byte-identical to ${rel}`) + : bad(`skills/${name}/${rel} differs from ${rel} — run \`node scripts/copy-bundle.mjs\` and commit`); +} + if (errors.length) { console.error(`\nverify-skill-bundle: ${errors.length} problem(s) — the published skill would not install correctly.`); process.exit(1); diff --git a/skills/construct/SKILL.md b/skills/construct/SKILL.md index a208080..83bc9b1 100644 --- a/skills/construct/SKILL.md +++ b/skills/construct/SKILL.md @@ -1,6 +1,6 @@ --- name: construct -description: "Use when the user wants to turn a product idea into a serious, buildable requirements document (an SRD/PRD) — or build the app from one. Triggers: write an SRD or PRD, spec out a product, write/define requirements, product specification, idea to spec, build from spec, one PRD per module, PRD folder. construct interviews the user, grounds every major decision in real research — competitors and market signal, comparable open-source projects and their issues/PRs, candidate-tech docs and StackOverflow pitfalls — then renders a complete SRD suite: vision, scope, functional requirements with Given/When/Then acceptance criteria, NFRs, data model, interfaces, ADRs, competitive landscape, build plan, traceability. Modules mode renders one PRD per module (prd//PRD.md); render --prd emits one PRD per requirement. A hard structural gate plus an advisory grounding report validate it; for building, it emits a BUILD-PLAN.json task DAG and construct verify referees the app against the SRD." +description: "Use when the user wants to turn a product idea into a serious, buildable requirements document (an SRD/PRD) — or build the app from one. Triggers: write an SRD or PRD, spec out a product, write/define requirements, idea to spec, brainstorm an idea, build from spec, one PRD per module, PRD folder. construct interviews the user, grounds every major decision in real research — competitors and market signal, comparable open-source projects and their issues/PRs, candidate-tech docs and StackOverflow pitfalls — then renders a complete SRD suite: vision, scope, functional requirements with Given/When/Then acceptance criteria, NFRs, data model, interfaces, ADRs, competitive landscape, build plan, traceability. Modules mode renders one PRD per module (prd//PRD.md); render --prd emits one PRD per requirement. A hard structural gate plus an advisory grounding report validate it; for building, it emits a BUILD-PLAN.json task DAG and construct verify referees the app against the SRD." license: MIT metadata: version: 1.9.3 @@ -27,6 +27,11 @@ One committed, dependency-free bundle: `node scripts/construct.mjs `. No `npm install`, no API keys. Run `--help` for the full surface. Key commands: - `init --idea "" --out ` — scaffold a run folder + `brief.json`. +- `brainstorm --out [--merge] [--json]` — optional DIVERGENT step before + the interview: scaffold a board of candidate ideas (`brainstorm.json` + + `BRAINSTORM.md`), then `--merge` folds every **kept** idea into `brief.json` + and every **parked** idea into `openQuestions` (a gate-blocking 🧠). Idempotent. + See `references/brainstorm-playbook.md`. - `research --out [--angles market,oss,tech,semantic] [--q ""] [--semantic]` — gather evidence across angles into `/evidence/` (an `EVIDENCE.md` + `evidence.json` dossier with `[E#]` ids). Default angles: `market,oss,tech`. @@ -68,6 +73,13 @@ No `npm install`, no API keys. Run `--help` for the full surface. Key commands: You are invoked once and expected to return a complete, grounded SRD. Drive the loop to completion; only pause to ask the user a real decision. +0. **Brainstorm — optional, divergent, before the interview.** When the user + can't yet articulate a crisp idea, or wants to explore options first, run + `construct brainstorm --out ` (after `init`) and generate candidate + ideas WITH the user across the six angles, then `--merge` the kept ones into + `brief.json`. Skip it when the user already knows what they want. Follow + `references/brainstorm-playbook.md`. + 1. **Interview the user — one question at a time.** Establish the product before researching. Follow `references/interview-playbook.md`: problem, target users, core value, must/should/could features, constraints (budget, timeline, @@ -233,6 +245,7 @@ See `references/semantic-setup.md`. ## References +- `references/brainstorm-playbook.md` — the optional divergent step: generating candidate ideas across six angles and merging the kept ones into the brief. - `references/interview-playbook.md` — how to elicit the brief, one question at a time. - `references/research-playbook.md` — picking angles and digging deeper to "good enough". - `references/orchestration.md` — the three-tier dynamic-workflow model and the subagent patterns: research fan-out, red team, judge panel, claim-support review fan-out, build fan-out (and the one-writer rule). diff --git a/skills/construct/docker-compose.yml b/skills/construct/docker-compose.yml new file mode 100644 index 0000000..1c8f548 --- /dev/null +++ b/skills/construct/docker-compose.yml @@ -0,0 +1,73 @@ +# Optional, fully-local, no-API-key stack for construct's semantic mode and web +# search. Start it with `construct semantic up` (or `docker compose --profile all +# up -d`). The published bundle stays dependency-free — it only speaks HTTP to +# these containers on localhost; nothing here is required for Tier-1 retrieval. +# +# Profiles let you start subsets: +# --profile semantic → qdrant + ollama (vector search) +# --profile search → searxng (web discovery) +# --profile all → everything +name: construct + +services: + # Vector database — Apache-2.0, self-hosted, no key. + qdrant: + image: qdrant/qdrant:v1.18.2 + container_name: construct-qdrant + ports: + - "6333:6333" + volumes: + - construct_qdrant:/qdrant/storage + restart: unless-stopped + profiles: ["semantic", "all"] + healthcheck: + # The image ships no curl/wget — probe the REST port over bash's /dev/tcp. + test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/6333' || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + # Local embedding server — no key, no data leaves the machine. Pull the model + # once: `docker compose exec ollama ollama pull nomic-embed-text` + # (`construct semantic up` does this for you). + ollama: + image: ollama/ollama:0.30.7 + container_name: construct-ollama + ports: + - "11434:11434" + volumes: + - construct_ollama:/root/.ollama + restart: unless-stopped + profiles: ["semantic", "all"] + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + # Self-hosted metasearch for keyless web discovery. JSON output is enabled in + # docker/searxng/settings.yml so the engine can be queried programmatically. + searxng: + image: searxng/searxng:2026.6.11-a1490676e + container_name: construct-searxng + ports: + - "8888:8080" + environment: + - SEARXNG_BASE_URL=http://localhost:8888/ + volumes: + - ./docker/searxng:/etc/searxng:rw + restart: unless-stopped + profiles: ["search", "all"] + healthcheck: + # busybox wget is in the image; /healthz answers on the container port. + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + +volumes: + construct_qdrant: + construct_ollama: diff --git a/skills/construct/docker/searxng/settings.yml b/skills/construct/docker/searxng/settings.yml new file mode 100644 index 0000000..fb54f1d --- /dev/null +++ b/skills/construct/docker/searxng/settings.yml @@ -0,0 +1,17 @@ +# Minimal SearXNG config for construct's keyless web discovery. The important +# bit is enabling the JSON output format so `construct web` can query it +# programmatically (`/search?format=json`). Change the secret_key for anything +# beyond local use. +use_default_settings: true + +server: + secret_key: "construct-dev-secret-change-me" + limiter: false + image_proxy: false + +search: + safe_search: 0 + autocomplete: "" + formats: + - html + - json diff --git a/skills/construct/references/brainstorm-playbook.md b/skills/construct/references/brainstorm-playbook.md new file mode 100644 index 0000000..a023218 --- /dev/null +++ b/skills/construct/references/brainstorm-playbook.md @@ -0,0 +1,86 @@ +# Brainstorm playbook — diverging before you converge + +The interview (`references/interview-playbook.md`) is **convergent**: it elicits +decisions the user already holds and records them. Brainstorm is the optional +**divergent** step that comes first — it *generates* candidate ideas WITH the +user, so the brief starts from a considered set of options instead of the first +thing that came to mind. + +The engine persists and merges; **you** run the session. + +## When to run it + +- **Scope gate escape hatch.** The interview's scope gate says "no articulable + idea → help them get to one first." That help is this playbook: run + `construct brainstorm` and diverge until a real product shape emerges. +- **The user wants to explore.** "I have a rough idea but want to think it + through", "what else could this be?", "help me brainstorm" → start here, + then hand off to the interview once the wishlist has taken shape. +- **Skip it** when the user already knows exactly what they want — go straight + to the interview. Brainstorm is never mandatory. + +## The commands + +- `construct brainstorm --out ` scaffolds `brainstorm.json` + a + `BRAINSTORM.md` board (needs an initialized run — `init` first; the idea is + seeded from `brief.idea`). Re-running it re-renders the board from the JSON + without clobbering your ideas. +- `construct brainstorm --out --merge` folds every **kept** idea into + `brief.json` by its `target`, and every **parked** idea into `openQuestions`. + Idempotent — an already-merged idea is never folded twice. + +## Running the session + +Generate ideas **one angle at a time**, 3–5 per angle, WITH the user — propose, +let them react, and recommend a status for each. The six angles, in order: + +1. **reframe** — different framings of the problem itself ("is this really about + X, or about Y?"). +2. **segment** — distinct user segments this could serve (each may want a + different product). +3. **feature** — concrete capabilities the product could have. +4. **differentiator** — what would make it stand out vs the alternatives. +5. **anti-goal** — things it should deliberately NOT do (these protect scope). +6. **wildcard** — deliberately unconventional swings; most get rejected, a few + reframe everything. + +Write each idea into `brainstorm.json` as +`{ id, angle, title, notes?, status, target?, priority? }`. Assign ids +sequentially (`B-001`, `B-002`, …). + +## Adjudicating — the statuses + +Every idea carries a `status`: + +- **proposed** — generated, not yet decided. `construct check` warns while any + remain (advisory — it never gates). +- **kept** — fold it into the brief on `--merge`. A kept idea MUST have a + `target` (see below), or the merge warns and skips it (retryable — set a + target and re-merge). +- **parked** — a real idea, deferred. On `--merge` it becomes an `openQuestions` + entry — which renders as a **🧠 Decide callout that BLOCKS the structural + gate** until resolved. Park deliberately: you are committing the team to + decide it before the SRD can pass `check`. +- **rejected** — considered and dropped. Left untouched; never merged. + +## Targets — where a kept idea lands + +Set `target` on every kept idea: + +| target | brief field | note | +|---|---|---| +| `featureWishlist` | a new feature | honours `priority` (default `could`) | +| `competitors` | market angle seed | | +| `goals` | an outcome | conflicts with an existing nonGoal → skipped | +| `nonGoals` | an anti-goal | conflicts with an existing goal → skipped | +| `candidateTech` | a stack/service to evaluate | | +| `openQuestions` | a deferred decision (🧠) | `title — notes` if notes exist | + +Merges dedupe case-insensitively by title, so re-running is safe. + +## Handing off + +Once the wishlist has taken shape, `--merge`, then switch to +`references/interview-playbook.md` to fill in the remaining brief fields +(problem, users, valueProp, constraints, …) and resolve any 🧠 the parked ideas +introduced. From there the normal loop continues: research → render → check. diff --git a/skills/construct/references/interview-playbook.md b/skills/construct/references/interview-playbook.md index dcd8cf2..e860953 100644 --- a/skills/construct/references/interview-playbook.md +++ b/skills/construct/references/interview-playbook.md @@ -21,8 +21,12 @@ Check the fit before question 1; a wrong fit wastes the whole loop. - **Several products in one ask.** Scope to ONE: name the split, recommend which to spec first, park the rest. One run = one product. - **No articulable idea.** `init` needs a one-liner. If the user can't state - the problem in a sentence, help them get to one first — don't start a run - on "an AI thing". + the problem in a sentence, help them get to one first — run + `construct brainstorm` and diverge until a real shape emerges + (`references/brainstorm-playbook.md`). Don't start a run on "an AI thing". +- **Wants to explore first.** Even with a one-liner, a user who wants to think + through options before committing should start with + `construct brainstorm` (divergent), then return here (convergent). ## Pruning the interview @@ -69,6 +73,23 @@ Check the fit before question 1; a wrong fit wastes the whole loop. These render as `🧠 Decide:` callouts and **block the structural gate** until resolved — so only put real, deferred decisions here. +## Field types + +Several brief fields are **arrays of strings** — write them as JSON arrays, not +prose: + +- `product.users`, `goals`, `nonGoals`, `candidateTech`, `competitors`, + `ossSeeds`, `nfrPriorities`, `openQuestions`, +- `constraints.compliance`, `design.platforms`, `design.referenceSystems`. + +A bare string in one of these is **coerced** into a one-element array with a +warning (so nothing is lost), but authoring the array directly is clearer: +`"users": ["solo devs", "small teams"]`, not `"users": "solo devs and teams"`. + +`constraints` reads **only** `budget`, `timeline`, `team`, `compliance` — any +other key (e.g. `constraints.deployment`) is dropped with a named warning. Fold +a stray constraint into the nearest recognized field or into `openQuestions`. + ## Tips - Prefer multiple-choice or "A or B?" phrasing; it's easier to answer. diff --git a/skills/construct/references/orchestration.md b/skills/construct/references/orchestration.md index 97efceb..9cb9422 100644 --- a/skills/construct/references/orchestration.md +++ b/skills/construct/references/orchestration.md @@ -126,8 +126,9 @@ serial reduce (merge fragments → `review --apply`) → deterministic gate (`check --semantic`).* `construct review --out ` mechanises grounding adjudication: it pairs every -grounded SRD claim with each cited `[E#]` item's snippet (capped at -`--max-review`, default 40 highest-score) and writes the worklist to +grounded SRD claim with each cited `[E#]` item's snippet (EVERY cited pair by +default; `--max-review N` caps at the N highest-score pairs and names the +dropped ones in VERIFY.md) and writes the worklist to `VERIFY.todo.json` + `VERIFY.md`. **Each pair is independent** — the ideal fan-out unit, and the engine already accepts the sharded-then-merged shape. @@ -210,9 +211,11 @@ the failure modes live in `references/build-playbook.md`. evidence conflicts, or the decision is hard to reverse). Hard cap: ≤2 panels per SRD; if a third ADR seems contested, take it to the user as a question instead. -- Claim-support (pattern 4): cheap per branch; cap = `--max-review` (default - 40). Batch pairs to keep fan-out modest. Always worth one pass over the - load-bearing FRs/ADRs before presenting. +- Claim-support (pattern 4): cheap per branch; every cited pair is in the + worklist by default (`--max-review N` caps explicitly — the dropped pairs + are named in VERIFY.md and stay unadjudicated). Batch pairs to keep fan-out + modest. Always worth one pass over the load-bearing FRs/ADRs before + presenting. - Build (pattern 5): one agent per ready task, bounded by the frontier width minus the shared-file tasks you serialise. Speedup only — never run the milestone gate (`verify --run-tests --strict`) until the whole frontier is diff --git a/skills/construct/references/semantic-setup.md b/skills/construct/references/semantic-setup.md index cb68e67..427b3a0 100644 --- a/skills/construct/references/semantic-setup.md +++ b/skills/construct/references/semantic-setup.md @@ -29,6 +29,12 @@ node scripts/construct.mjs semantic status # docker compose ps node scripts/construct.mjs semantic down # stops everything ``` +The `docker-compose.yml` and its `docker/searxng/settings.yml` **ship inside the +installed skill** (next to the bundle), so `construct semantic up|down|status` +works from the install directory — no repo checkout needed. If the engine can't +find the compose file it says so explicitly (reinstall via `npx skills add +maxgfr/construct`) rather than emitting a raw docker error. + `semantic up` runs `docker compose --profile all up -d` then `ollama pull nomic-embed-text`. Start a subset directly: diff --git a/skills/construct/references/srd-authoring.md b/skills/construct/references/srd-authoring.md index 98ce9ce..a0dadf7 100644 --- a/skills/construct/references/srd-authoring.md +++ b/skills/construct/references/srd-authoring.md @@ -42,10 +42,17 @@ is to raise it from "structurally complete" to "actually good." ## Keep the model and the tree in sync -`SRD.json` is the source of truth the `check` reads for structure. If you edit a -rendered `.md` by hand (e.g. add entities), mirror the change into `SRD.json` -(or re-render from an enriched brief). The structural gate verifies references -close: every `FR.entities/interfaces/nfrs` must name something that exists. +`SRD.json` is the source of truth the `check` reads for structure. **The +recommended enrich loop is to edit `SRD.json` directly, then run +`construct render --out --from-srd`** — this re-emits the whole markdown +tree from the edited manifest WITHOUT rebuilding it from the brief, so the +human-facing `.md` and the gated manifest never drift. (Editing a rendered +`.md` by hand instead means mirroring the change back into `SRD.json` yourself, +or the next plain `render` overwrites it.) `--from-srd` normalises the JSON on +its first pass (parse→stringify), so a second run is a byte-identical no-op; it +honours `--merge` and `--prd` and preserves BUILD-PLAN progress. The structural +gate verifies references close: every `FR.entities/interfaces/nfrs` must name +something that exists. **Modules mode.** When the brief declares `modules`, the full FR blocks live in `prd//PRD.md` and `requirements/FUNCTIONAL.md` is only the cross-module diff --git a/skills/construct/scripts/construct.mjs b/skills/construct/scripts/construct.mjs index 542e52b..06d66bf 100755 --- a/skills/construct/scripts/construct.mjs +++ b/skills/construct/scripts/construct.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node // src/cli.ts -import { resolve as resolve3, join as join14 } from "path"; -import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs"; +import { resolve as resolve3, join as join15 } from "path"; +import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs"; import { pathToFileURL, fileURLToPath as fileURLToPath2 } from "url"; import { realpathSync } from "fs"; @@ -10,6 +10,7 @@ import { realpathSync } from "fs"; var VERSION = "1.9.3"; var ALL_SOURCE_KINDS = ["market", "oss", "docs", "so", "issue", "pr"]; var BRIEF_SCHEMA_VERSION = 1; +var BRAINSTORM_SCHEMA_VERSION = 1; var SRD_SCHEMA_VERSION = 1; var REQUIRED_NFR = { light: ["performance", "security", "reliability"], @@ -248,13 +249,37 @@ var PRIORITIES = ["must", "should", "could"]; function slugId(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60); } +var KNOWN_CONSTRAINT_KEYS = ["budget", "timeline", "team", "compliance"]; +function normalizeConstraints(raw, line2, arr, warn) { + const c = raw ?? {}; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const k of Object.keys(c)) { + if (!KNOWN_CONSTRAINT_KEYS.includes(k)) { + warn( + `constraints.${k} is not a recognized constraint (known: ${KNOWN_CONSTRAINT_KEYS.join(", ")}) \u2014 ignored; fold it into the nearest field or openQuestions.` + ); + } + } + } + return { + budget: line2(c.budget), + timeline: line2(c.timeline), + team: line2(c.team), + compliance: arr(c.compliance, "constraints.compliance") + }; +} function normalizeBrief(data, warn = () => { }) { const d = data ?? {}; - const line = (v) => typeof v === "string" ? v.replace(/\s+/g, " ").trim() : void 0; + const line2 = (v) => typeof v === "string" ? v.replace(/\s+/g, " ").trim() : void 0; const arr = (v, field) => { if (v === void 0 || v === null) return []; if (!Array.isArray(v)) { + if (typeof v === "string") { + const s = v.replace(/\s+/g, " ").trim(); + warn(`${field}: expected an array \u2014 coerced the bare string into a one-element array.`); + return s ? [s] : []; + } warn(`${field} is not an array \u2014 ignored.`); return []; } @@ -270,8 +295,8 @@ function normalizeBrief(data, warn = () => { const out = []; const seen = /* @__PURE__ */ new Set(); d.modules.forEach((m, i) => { - const rawId = line(m?.id); - const rawName = line(m?.name); + const rawId = line2(m?.id); + const rawName = line2(m?.name); const id = slugId(rawId || rawName || ""); if (!id) { warn(`modules[${i}] has no usable id or name \u2014 dropped.`); @@ -283,7 +308,7 @@ function normalizeBrief(data, warn = () => { } seen.add(id); const def = { id, name: rawName || id }; - const description = line(m.description); + const description = line2(m.description); if (description) def.description = description; const deps = arr(m.dependsOn, `modules[${i}].dependsOn`).map(slugId); if (deps.length) def.dependsOn = deps; @@ -314,7 +339,7 @@ function normalizeBrief(data, warn = () => { warn("featureWishlist is not an array \u2014 ignored."); } else if (Array.isArray(d.featureWishlist)) { d.featureWishlist.forEach((f, i) => { - const title = line(f?.title); + const title = line2(f?.title); if (!title) { warn(`featureWishlist[${i}] has no usable title \u2014 dropped.`); return; @@ -325,13 +350,13 @@ function normalizeBrief(data, warn = () => { priority = void 0; } let module; - const rawModule = line(f.module); + const rawModule = line2(f.module); if (rawModule) { const slug = slugId(rawModule); if (moduleIds.has(slug)) module = slug; else warn(`featureWishlist[${i}].module "${rawModule}" names no declared module \u2014 dropped.`); } - features.push({ title, priority, notes: line(f.notes), ...module ? { module } : {} }); + features.push({ title, priority, notes: line2(f.notes), ...module ? { module } : {} }); }); } let design; @@ -343,9 +368,9 @@ function normalizeBrief(data, warn = () => { const out = {}; const platforms = arr(dd.platforms, "design.platforms"); const referenceSystems = arr(dd.referenceSystems, "design.referenceSystems"); - const brand = line(dd.brandConstraints); - const a11y = line(dd.accessibilityTarget); - const tone = line(dd.tone); + const brand = line2(dd.brandConstraints); + const a11y = line2(dd.accessibilityTarget); + const tone = line2(dd.tone); if (platforms.length) out.platforms = platforms; if (referenceSystems.length) out.referenceSystems = referenceSystems; if (brand) out.brandConstraints = brand; @@ -356,21 +381,16 @@ function normalizeBrief(data, warn = () => { } return { schemaVersion: typeof d.schemaVersion === "number" ? d.schemaVersion : BRIEF_SCHEMA_VERSION, - idea: line(d.idea) ?? "", + idea: line2(d.idea) ?? "", product: { - name: line(d.product?.name), - problem: line(d.product?.problem), + name: line2(d.product?.name), + problem: line2(d.product?.problem), users: arr(d.product?.users, "product.users"), - valueProp: line(d.product?.valueProp) + valueProp: line2(d.product?.valueProp) }, goals: arr(d.goals, "goals"), nonGoals: arr(d.nonGoals, "nonGoals"), - constraints: { - budget: line(d.constraints?.budget), - timeline: line(d.constraints?.timeline), - team: line(d.constraints?.team), - compliance: arr(d.constraints?.compliance, "constraints.compliance") - }, + constraints: normalizeConstraints(d.constraints, line2, arr, warn), candidateTech: arr(d.candidateTech, "candidateTech"), competitors: arr(d.competitors, "competitors"), ossSeeds: arr(d.ossSeeds, "ossSeeds"), @@ -415,2537 +435,2868 @@ function validateBrief(brief) { return { ok: errors.length === 0, errors, warnings }; } -// src/research/registry.ts -import { join as join6 } from "path"; +// src/brainstorm.ts +import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs"; +import { join as join2 } from "path"; -// src/research/fetch.ts -var UA = "construct/0.x (+https://github.com/maxgfr/construct)"; -var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"; -function transient(status) { - return status === 0 || status === 429 || status >= 500; -} -async function httpGet(url, opts = {}) { - const retries = opts.retries ?? 1; - const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); - let last = { ok: false, status: 0, body: "", contentType: "", error: "unreached" }; - for (let attempt = 0; attempt <= retries; attempt++) { - last = await httpGetOnce(url, opts); - if (last.ok || !transient(last.status)) return last; - if (attempt === retries) break; - const retryAfterS = Number(last.retryAfter); - const delay = last.status === 429 && Number.isFinite(retryAfterS) && retryAfterS > 0 ? Math.min(retryAfterS * 1e3, RETRY_AFTER_CAP_MS) : RETRY_BASE_DELAY_MS * 2 ** attempt + Math.random() * RETRY_JITTER_MS; - await sleep(delay); +// src/templates.ts +var BRAINSTORM_ANGLE_ORDER = [ + { angle: "reframe", label: "Problem reframes" }, + { angle: "segment", label: "User segments" }, + { angle: "feature", label: "Feature ideas" }, + { angle: "differentiator", label: "Differentiators" }, + { angle: "anti-goal", label: "Anti-goals & risks" }, + { angle: "wildcard", label: "Wildcards" } +]; +function renderBrainstormMd(b) { + const out = []; + out.push(`# Brainstorm \u2014 ${b.idea || "(idea)"}`); + out.push(""); + out.push( + `Divergent ideas for this product. Mark each idea's \`status\` in \`brainstorm.json\` (**proposed** \u2192 **kept** / **parked** / **rejected**); give every **kept** idea a \`target\` (featureWishlist \xB7 competitors \xB7 nonGoals \xB7 goals \xB7 candidateTech \xB7 openQuestions), then run \`construct brainstorm --out --merge\` to fold them into brief.json. **Parked** ideas become \u{1F9E0} open questions that BLOCK the structural gate until resolved.` + ); + out.push(""); + for (const { angle, label } of BRAINSTORM_ANGLE_ORDER) { + const ideas = b.ideas.filter((i) => i.angle === angle); + if (!ideas.length) continue; + out.push(`## ${label}`); + out.push(""); + for (const i of ideas) { + const tgt = i.target ? ` \u2192 ${i.target}${i.priority ? ` (${i.priority})` : ""}` : ""; + const notes = i.notes ? ` \u2014 ${i.notes}` : ""; + const mergedMark = i.mergedAt ? " \u2713merged" : ""; + out.push(`- **[${i.status}]** ${i.id} \u2014 ${i.title}${tgt}${notes}${mergedMark}`); + } + out.push(""); } - return last; + if (!b.ideas.length) out.push("_No ideas yet \u2014 generate some with the AI (references/brainstorm-playbook.md)._"); + return out.join("\n"); } -async function httpGetOnce(url, opts) { - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? HTTP_GET_TIMEOUT_MS); - try { - const res = await fetch(url, { - signal: ctrl.signal, - redirect: "follow", - headers: { "user-agent": UA, accept: opts.accept ?? "*/*", ...opts.headers ?? {} } - }); - const buf = Buffer.from(await res.arrayBuffer()); - const max = opts.maxBytes ?? 4 * 1024 * 1024; - return { - ok: res.ok, - status: res.status, - body: buf.subarray(0, max).toString("utf8"), - contentType: res.headers.get("content-type") ?? "", - retryAfter: res.headers.get("retry-after") ?? void 0 - }; - } catch (e) { - return { ok: false, status: 0, body: "", contentType: "", error: e.message }; - } finally { - clearTimeout(t); - } +function cite(ids) { + if (!ids || ids.length === 0) return ""; + return " " + ids.map((id) => `[${id}]`).join(""); } -async function httpJson(method, url, body, opts = {}) { - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? HTTP_JSON_TIMEOUT_MS); - try { - const res = await fetch(url, { - method, - signal: ctrl.signal, - headers: { "content-type": "application/json", accept: "application/json", "user-agent": UA }, - body: body === void 0 ? void 0 : JSON.stringify(body) - }); - const text = await res.text(); - let data; - try { - data = text ? JSON.parse(text) : void 0; - } catch { - data = text; - } - return { ok: res.ok, status: res.status, data }; - } catch (e) { - return { ok: false, status: 0, data: void 0, error: e.message }; - } finally { - clearTimeout(t); - } +function cell(s) { + return String(s).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim(); } -var NAMED = { - amp: "&", - lt: "<", - gt: ">", - quot: '"', - apos: "'", - nbsp: " ", - mdash: "\u2014", - ndash: "\u2013", - hellip: "\u2026", - copy: "\xA9" -}; -function htmlToText(html) { - let s = html; - s = s.replace(//g, " "); - s = s.replace(/<(script|style|noscript|head|nav|footer|svg)[\s\S]*?<\/\1>/gi, " "); - s = s.replace(/<\/(p|div|section|article|li|tr|td|th|ul|ol|h[1-6]|pre|blockquote)>/gi, "\n"); - s = s.replace(/<(p|div|section|article|li|tr|td|th|ul|ol|h[1-6]|pre|blockquote|table)\b[^>]*>/gi, "\n"); - s = s.replace(/<(br|hr)\s*\/?>/gi, "\n"); - s = s.replace(/<[^>]+>/g, " "); - s = s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp|mdash|ndash|hellip|copy);/gi, (m, g) => { - if (g[0] === "#") { - const n = g[1] === "x" || g[1] === "X" ? parseInt(g.slice(2), 16) : Number(g.slice(1)); - try { - return Number.isFinite(n) ? String.fromCodePoint(n) : " "; - } catch { - return " "; - } - } - return NAMED[g.toLowerCase()] ?? m; - }); - s = s.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n"); - return s.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).join("\n"); +function slugTitle(s) { + return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "decision"; } -async function fetchAndExtract(url) { - let res = await httpGet(url, { accept: "text/html,text/plain,*/*" }); - if (!res.ok && (res.status === 403 || res.status === 429)) { - res = await httpGet(url, { - accept: "text/html,application/xhtml+xml,*/*", - headers: { "user-agent": BROWSER_UA, "accept-language": "en-US,en;q=0.9" } - }); - } - if (!res.ok) { - return { text: "", note: `Could not fetch ${url} (status ${res.status}${res.error ? ", " + res.error : ""}).` }; - } - const isHtml = /html/i.test(res.contentType) || /^\s* `- ${i}`).join("\n"); } -function excerptsFromText(text, url, title, source, question, perSource) { - const lines = text.split("\n"); - const questions = (Array.isArray(question) ? question : [question]).filter((q) => q.trim()); - const kwSets = questions.map((q) => keywords(q).map((k) => k.toLowerCase())); - const hits = []; - for (let i = 0; i < lines.length; i++) { - const low = lines[i].toLowerCase(); - let cov = 0; - for (const kws of kwSets) { - let c = 0; - for (const kw of kws) if (low.includes(kw)) c++; - if (kws.length && c > cov) cov = c; - } - if (cov > 0) hits.push({ idx: i, cov }); - } - hits.sort((a, b) => b.cov - a.cov || a.idx - b.idx); - const items = []; - const ranges = []; - const take = hits.length ? hits : [{ idx: 0, cov: 0 }]; - const perDoc = Math.min(2, Math.max(1, perSource)); - for (const h of take) { - if (items.length >= perDoc) break; - const start = Math.max(0, h.idx - 3); - const end = Math.min(lines.length, h.idx + 12); - if (ranges.some((r) => start < r.end && end > r.start)) continue; - ranges.push({ start, end }); - const snippet = lines.slice(start, end).join("\n").slice(0, 1500); - if (!snippet.trim()) continue; - items.push({ - source, - // Disambiguate the second+ excerpt of one page by its line range, so two - // excerpts of the same URL don't render identical titles. - title: items.length === 0 ? title : `${title} (lines ${start + 1}\u2013${end})`, - ref: url, - location: `${url}#~${start + 1}`, - score: Number((h.cov + 1).toFixed(3)), - snippet, - url - }); +function renderVision(srd) { + const p = srd.product; + return [ + `# Vision`, + ``, + `**Product:** ${p.name}`, + ``, + `## Problem`, + p.problem, + ``, + `## Target users`, + bullets(p.users, "No users captured."), + ``, + `## Value proposition`, + p.valueProp, + ``, + `## Success metrics`, + bullets(p.metrics, "Define a measurable launch success metric."), + `` + ].join("\n"); +} +function renderScope(srd) { + const lines = [ + `# Scope`, + ``, + `## In scope`, + bullets(srd.scope.inScope, "No in-scope items captured."), + ``, + `## Out of scope`, + bullets(srd.scope.outOfScope, "Nothing explicitly excluded yet."), + ``, + `## Assumptions`, + bullets(srd.scope.assumptions, "No assumptions recorded."), + `` + ]; + if (srd.openQuestions.length) { + lines.push(`## Open decisions`, ``); + for (const q of srd.openQuestions) lines.push(`> \u{1F9E0} **Decide:** ${q}`, ``); } - return items; + return lines.join("\n"); } - -// src/research/web.ts -var SEARXNG_BASE = process.env.CONSTRUCT_SEARXNG || "http://localhost:8888"; -async function viaSearxng(query2, n) { - const url = `${SEARXNG_BASE.replace(/\/$/, "")}/search?q=${encodeURIComponent(query2)}&format=json`; - const r = await httpGet(url, { accept: "application/json", timeoutMs: SEARXNG_TIMEOUT_MS, retries: 0 }); - if (!r.ok) return null; - try { - const data = JSON.parse(r.body); - const urls = (data.results ?? []).map((x) => x.url).filter(Boolean); - return urls.slice(0, n); - } catch { - return null; +function renderFRBlock(fr) { + const out = [`## ${fr.id} \u2014 ${fr.title} _(${fr.priority})_${cite(fr.rationaleEvidence)}`, ``]; + out.push(fr.description); + out.push(``); + out.push(`**Acceptance criteria:**`); + for (const a of fr.acceptance) { + out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); } + out.push(``); + const trace = [ + `NFRs: ${fr.nfrs.length ? fr.nfrs.join(", ") : "\u2014"}`, + `entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`, + `interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}` + ].join(" \xB7 "); + out.push(`_Traceability \u2014 ${trace}_`); + out.push(``); + return out; } -async function viaDuckDuckGo(query2, n) { - const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query2)}`; - const r = await httpGet(url, { accept: "text/html", timeoutMs: DDG_TIMEOUT_MS }); - if (!r.ok || !r.body) return null; - const urls = []; - const tagRe = /]*\bresult__a\b[^>]*>/g; - let m; - while ((m = tagRe.exec(r.body)) && urls.length < n) { - const href0 = /\bhref="([^"]+)"/.exec(m[0]); - if (!href0) continue; - let href = href0[1]; - const uddg = /[?&]uddg=([^&]+)/.exec(href); - if (uddg) { - try { - href = decodeURIComponent(uddg[1]); - } catch { - } - } - if (/^https?:\/\//.test(href) && !/duckduckgo\.com/.test(href)) urls.push(href); +function renderFunctional(srd) { + if (srd.modules?.length) return renderFunctionalIndex(srd); + return renderFunctionalFull(srd); +} +function renderFunctionalFull(srd) { + const out = [`# Functional requirements`, ``]; + if (!srd.functional.length) out.push(`_No functional requirements defined._`, ``); + for (const fr of srd.functional) out.push(...renderFRBlock(fr)); + return out.join("\n"); +} +function renderFunctionalIndex(srd) { + const out = [`# Functional requirements`, ``]; + out.push(`_This SRD is partitioned into module PRDs \u2014 the full requirement blocks (description,`); + out.push(`acceptance criteria, traceability) live in each module's PRD under [../prd/](../prd/README.md)._`, ``); + out.push(`| Requirement | Title | Priority | Module | PRD |`); + out.push(`|---|---|---|---|---|`); + for (const fr of srd.functional) { + const link = fr.module ? `[../prd/${fr.module}/PRD.md](../prd/${fr.module}/PRD.md)` : "\u2014"; + out.push(`| ${fr.id} | ${cell(fr.title)} | ${fr.priority} | ${fr.module ?? "\u2014"} | ${link} |`); } - return urls.length ? urls : null; + out.push(``); + return out.join("\n"); } -async function discover(query2, engine, n) { - const notes = []; - if (engine === "searxng" || engine === "auto") { - const s = await viaSearxng(query2, n); - if (s?.length) return { urls: s, via: "searxng", notes }; - if (engine === "searxng") { - notes.push(s === null ? `SearXNG unreachable at ${SEARXNG_BASE}. Run \`construct semantic up\`.` : "SearXNG returned no results."); - } +function renderModulePRD(srd, m) { + const frs = srd.functional.filter((f) => f.module === m.id); + const others = (srd.modules ?? []).filter((o) => o.id !== m.id); + const frIdSet = new Set(frs.map((f) => f.id)); + const out = [`# PRD \u2014 ${m.name}`, ``]; + out.push(`_Module \`${m.id}\` \xB7 ${srd.product.name} \xB7 ${frs.length} requirement(s)_`, ``); + if (m.description) out.push(m.description, ``); + out.push( + `**Global context:** [Vision](../../00-overview/VISION.md) \xB7 [Scope](../../00-overview/SCOPE.md) \xB7 [Non-functional requirements](../../requirements/NON-FUNCTIONAL.md) \xB7 [Data model](../../architecture/DATA-MODEL.md) \xB7 [Interfaces](../../architecture/INTERFACES.md) \xB7 [Traceability](../../TRACEABILITY.md)`, + `` + ); + out.push(`## Scope`, ``); + out.push(`**In scope:** ${frs.length ? frs.map((f) => f.id).join(", ") : "\u2014"}.`, ``); + if (others.length) { + out.push(`**Out of scope** (owned by other modules): ${others.map((o) => `[${o.name}](../${o.id}/PRD.md)`).join(", ")}.`, ``); } - if (engine === "ddg" || engine === "auto") { - const d = await viaDuckDuckGo(query2, n); - if (d?.length) return { urls: d, via: "duckduckgo", notes }; - if (engine === "ddg") notes.push("DuckDuckGo returned no results."); + out.push(`## Requirements`, ``); + if (!frs.length) out.push(`_No requirements assigned to this module._`, ``); + for (const fr of frs) out.push(...renderFRBlock(fr)); + const nfrIds = new Set(frs.flatMap((f) => f.nfrs)); + const nfrs = srd.nonFunctional.filter((n) => nfrIds.has(n.id)); + out.push(`## Non-functional requirements`, ``); + if (nfrs.length) { + out.push(`_Applying to this module's requirements \u2014 full statements in [NON-FUNCTIONAL.md](../../requirements/NON-FUNCTIONAL.md)._`, ``); + out.push(`| NFR | Category | Metric |`, `|---|---|---|`); + for (const n of nfrs) out.push(`| ${n.id} | ${cell(n.category)} | ${cell(n.metric ?? "\u2014")} |`); + } else { + out.push(`_None linked._`); } - if (engine === "claude" || engine === "auto") { - notes.push( - "No keyless engine returned results. Use your built-in WebSearch to find URLs, then ground them with `construct web --url --out `." - ); + out.push(``); + const entities = srd.architecture.dataModel.filter((e) => e.referencedByFRs.some((id) => frIdSet.has(id))); + out.push(`## Data model (module slice)`, ``); + if (entities.length) { + out.push(`| Entity | Referenced by |`, `|---|---|`); + for (const e of entities) out.push(`| ${cell(e.name)} | ${e.referencedByFRs.filter((id) => frIdSet.has(id)).join(", ")} |`); + } else { + out.push(`_No entities touch this module yet._`); } - return { urls: [], via: "none", notes }; -} -async function webFetchUrls(urls, question, perSource, source = "market", fetchAll = false) { - const items = []; - const notes = []; - const toFetch = fetchAll ? urls : urls.slice(0, Math.max(1, Math.ceil(perSource / 2))); - for (const url of toFetch) { - const { text, note } = await fetchAndExtract(url); - if (note) notes.push(note); - if (!text) continue; - const ex = excerptsFromText(text, url, `${labelFor(source)} \u2014 ${url}`, source, question, perSource); - items.push( - ...ex.length ? ex : [ - { - source, - title: `${labelFor(source)} \u2014 ${url}`, - ref: url, - location: url, - score: 0, - snippet: text.slice(0, 800), - url - } - ] - ); + out.push(``); + const ifaces = srd.architecture.interfaces.filter((i) => i.relatedFRs.some((id) => frIdSet.has(id))); + out.push(`## Interfaces (module slice)`, ``); + if (ifaces.length) { + out.push(`| Interface | Kind | Related |`, `|---|---|---|`); + for (const i of ifaces) out.push(`| ${cell(i.name)} | ${i.kind} | ${i.relatedFRs.filter((id) => frIdSet.has(id)).join(", ")} |`); + } else { + out.push(`_No interfaces touch this module yet._`); } - return { items, notes }; + out.push(``); + out.push(`## Dependencies`, ``); + const declared = m.dependsOn.map((dep) => { + const d = others.find((o) => o.id === dep); + return d ? `[${d.name}](../${d.id}/PRD.md)` : dep; + }); + const shared = []; + for (const o of others) { + const oSet = new Set(o.frIds); + const names = entities.filter((e) => e.referencedByFRs.some((id) => oSet.has(id))).map((e) => e.name); + if (names.length) shared.push(`shares ${names.join(", ")} with [${o.name}](../${o.id}/PRD.md)`); + } + if (!declared.length && !shared.length) out.push(`_None._`); + if (declared.length) out.push(`- **Declared:** depends on ${declared.join(", ")}.`); + for (const s of shared) out.push(`- **Derived (shared data):** ${s}.`); + out.push(``); + return out.join("\n"); } -function labelFor(source) { - return source === "docs" ? "Docs" : source === "oss" ? "OSS" : "Web"; +function renderModulePrdIndex(srd) { + const out = [`# Module PRDs`, ``]; + out.push(`One PRD per product module, rendered from SRD.json. Cross-module docs (vision, scope,`); + out.push(`NFRs, architecture, ADRs, traceability) live at the SRD root; the cross-module requirement`); + out.push(`index is [../requirements/FUNCTIONAL.md](../requirements/FUNCTIONAL.md).`, ``); + out.push(`| Module | PRD | Requirements | Depends on |`); + out.push(`|---|---|---|---|`); + for (const m of srd.modules ?? []) { + out.push(`| ${cell(m.name)} | [${m.id}/PRD.md](${m.id}/PRD.md) | ${m.frIds.join(", ") || "\u2014"} | ${m.dependsOn.join(", ") || "\u2014"} |`); + } + out.push(``); + return out.join("\n"); } - -// src/research/market.ts -async function marketAngle(ctx) { - const b = ctx.brief; - const query2 = ctx.query || [b.idea, b.competitors.join(" "), "competitors alternatives market"].filter(Boolean).join(" ").trim(); - const items = []; - const notes = []; - const pinned = ctx.marketUrls ?? []; - const questions = [query2, ...b.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`.trim())].filter(Boolean); - if (pinned.length) { - const f = await webFetchUrls(pinned, questions.length ? questions : pinned.join(" "), ctx.perSource, "market", true); - items.push(...f.items.slice(0, ctx.perSource)); - notes.push(`Pinned ${pinned.length} market URL(s) via --url.`, ...f.notes); +function renderFeaturePRD(fr, srd) { + const out = [`# PRD ${fr.id} \u2014 ${fr.title}${cite(fr.rationaleEvidence)}`, ``]; + out.push(`_Priority: ${fr.priority}_ \xB7 _Product: ${srd.product.name}_`, ``); + out.push(`## Context`, ``, srd.product.problem, ``); + out.push(`## Feature`, ``, fr.description, ``); + out.push(`## Acceptance criteria`, ``); + for (const a of fr.acceptance) { + out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); } - if (!query2) { - if (items.length) return [{ source: "market", items, notes }]; - return [{ source: "market", items: [], notes: ["No idea/competitors to search the market for."] }]; + out.push(``, `## Non-functional requirements`, ``); + if (!fr.nfrs.length) out.push(`_None linked._`); + for (const id of fr.nfrs) { + const nfr = srd.nonFunctional.find((n) => n.id === id); + out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` \u2014 metric: ${nfr.metric}` : ""}` : `- **${id}**`); } - const budget = ctx.perSource - items.length; - if (budget > 0) { - const { urls, via, notes: discoveryNotes } = await discover(query2, ctx.webEngine, budget); - if (urls.length === 0) { - notes.push(`Market discovery via ${via}.`, ...discoveryNotes); - } else { - const fetched = await webFetchUrls(urls, questions, budget, "market"); - items.push(...fetched.items); - notes.push(`Market discovery via ${via} for "${query2}".`, ...discoveryNotes, ...fetched.notes); - } + out.push(``, `## Data & interfaces`, ``); + out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`); + out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}`); + out.push(``, `## Grounding`, ``); + out.push( + fr.rationaleEvidence.length ? `Evidence:${cite(fr.rationaleEvidence)} \u2014 see ../../evidence/EVIDENCE.md.` : `_Ungrounded \u2014 see the grounding report (construct check)._` + ); + out.push(``); + return out.join("\n"); +} +function renderPRDIndex(srd) { + const out = [`# PRDs \u2014 one per functional requirement`, ``]; + out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`); + out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``); + out.push(`| PRD | Priority | Title |`); + out.push(`|---|---|---|`); + for (const fr of srd.functional) { + const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`; + out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`); } - return [{ source: "market", items, notes }]; + out.push(``); + return out.join("\n"); } - -// src/clone.ts -import { existsSync as existsSync2, statSync, mkdirSync as mkdirSync2, readdirSync, rmSync } from "fs"; -import { resolve, join as join2, basename } from "path"; -import { tmpdir } from "os"; -function cacheRoot() { - return join2(tmpdir(), "construct"); +function renderNonFunctional(srd) { + const out = [`# Non-functional requirements`, ``]; + if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``); + for (const n of srd.nonFunctional) { + out.push(`## ${n.id} \u2014 ${n.category}${cite(n.rationaleEvidence)}`); + out.push(``); + out.push(n.statement); + if (n.metric) out.push(``, `- **Metric:** ${n.metric}`); + out.push(``); + } + return out.join("\n"); } -function resolveRepo(raw) { - const trimmed = raw.trim(); - if (trimmed) { - const asPath = resolve(trimmed); - if (existsSync2(asPath) && statSync(asPath).isDirectory()) { - return { - raw: trimmed, - host: "local", - isLocal: true, - slug: "local-" + slugify(basename(asPath) + "-" + asPath) - }; +function renderSystemContext(srd) { + return [`# System context`, ``, srd.architecture.context, ``].join("\n"); +} +function renderDataModel(srd) { + const out = [`# Data model`, ``]; + const entities = srd.architecture.dataModel; + if (!entities.length) { + out.push(`_No entities defined yet. Enrich during authoring: list entities, their attributes, and which functional requirements reference each._`, ``); + return out.join("\n"); + } + out.push(`_Seeded by inference from the brief \u2014 verify each entity and extend attributes during authoring._`, ``); + for (const e of entities) { + out.push(`## ${e.name}`); + out.push(``); + if (e.attributes.length) { + out.push(`| Attribute | Type |`, `|---|---|`); + for (const a of e.attributes) out.push(`| ${cell(a.name)} | ${cell(a.type)} |`); } + out.push(``, `_Referenced by: ${e.referencedByFRs.length ? e.referencedByFRs.join(", ") : "\u2014"}_`, ``); } - let host; - let path; - const scp = /^git@([^:]+):(.+)$/.exec(trimmed); - const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed); - const hostPath = /^([a-z0-9.-]+\.[a-z]{2,})\/(.+)$/i.exec(trimmed); - if (scp) { - host = scp[1]; - path = scp[2]; - } else if (url) { - host = url[1]; - path = url[2]; - } else if (hostPath) { - host = hostPath[1]; - path = hostPath[2]; - } else if (/^[\w.-]+\/[\w.-]+$/.test(trimmed)) { - host = "github.com"; - path = trimmed; - } else { - return { raw: trimmed, host: "generic", isLocal: false, slug: slugify(trimmed) || "seed" }; + return out.join("\n"); +} +function renderInterfaces(srd) { + const out = [`# Interfaces`, ``]; + const ifaces = srd.architecture.interfaces; + if (!ifaces.length) { + out.push(`_No interfaces defined yet. Enrich during authoring: list the API/event/UI/CLI surfaces and the functional requirements each serves._`, ``); + return out.join("\n"); } - host = host.toLowerCase(); - path = path.replace(/\.git$/, "").replace(/\/+$/, ""); - const segments = path.split("/").filter(Boolean); - const repo = segments.length ? segments[segments.length - 1] : void 0; - const owner = segments.length > 1 ? segments.slice(0, -1).join("/") : void 0; - const cloneUrl = /^https?:\/\//i.test(trimmed) || scp ? trimmed.replace(/\/+$/, "") : `https://${host}/${path}.git`; - const webUrl = `https://${host}/${path}`; - return { - raw: trimmed, - host, - owner, - repo, - cloneUrl: cloneUrl.endsWith(".git") ? cloneUrl : `${cloneUrl}.git`, - webUrl, - isLocal: false, - slug: slugify(`${host}/${path}`) - }; + out.push(`_Seeded by inference from the brief \u2014 verify each surface and define its contract during authoring._`, ``); + for (const i of ifaces) { + out.push(`## ${i.name} _(${i.kind})_`, ``, i.summary, ``, `_Related: ${i.relatedFRs.length ? i.relatedFRs.join(", ") : "\u2014"}_`, ``); + } + return out.join("\n"); } -function ensureClone(ref, opts = {}) { - if (ref.isLocal) return resolve(ref.raw); - const dir = join2(cacheRoot(), ref.slug); - const alreadyCloned = existsSync2(join2(dir, ".git")); - if (alreadyCloned && !opts.refresh) return dir; - if (alreadyCloned && opts.refresh) { - sh("git", ["-C", dir, "fetch", "--depth", "1", "origin"], { timeoutMs: GIT_FETCH_TIMEOUT_MS }); - sh("git", ["-C", dir, "reset", "--hard", "FETCH_HEAD"], { timeoutMs: GIT_RESET_TIMEOUT_MS }); - return dir; +function renderADR(adr) { + const out = [ + `# ${adr.id}. ${adr.title}`, + ``, + `- **Status:** ${adr.status}`, + ``, + `## Context`, + adr.context, + ``, + `## Decision`, + `${adr.decision}${cite(adr.evidence)}`, + ``, + `## Consequences`, + adr.consequences, + `` + ]; + if (adr.alternatives) out.push(`## Alternatives considered`, adr.alternatives, ``); + return out.join("\n"); +} +function renderLandscape(srd) { + const out = [`# Competitive landscape`, ``, `## Competitors`, ``]; + if (srd.competitive.competitors.length) { + out.push(`| Product | Note | Evidence |`, `|---|---|---|`); + for (const c of srd.competitive.competitors) { + const ev = c.evidence.length ? c.evidence.map((id) => `[${id}]`).join("") : "_ungrounded_"; + out.push(`| ${cell(c.name)} | ${cell(c.note)} | ${ev} |`); + } + } else { + out.push(`_No competitors captured. Use the market research angle to discover them._`); } - mkdirSync2(cacheRoot(), { recursive: true }); - const args = ["clone", "--depth", "1", "--filter=blob:none"]; - if (opts.branch) args.push("--branch", opts.branch); - args.push(ref.cloneUrl, dir); - const res = sh("git", args, { timeoutMs: GIT_CLONE_TIMEOUT_MS }); - if (!res.ok) { - if (res.missing) { - throw new Error(`git is not installed or not on PATH \u2014 cannot clone ${ref.cloneUrl}`); + out.push(``, `## Comparable open-source projects`, ``); + if (srd.competitive.oss.length) { + out.push(`| Project | Note | Evidence |`, `|---|---|---|`); + for (const o of srd.competitive.oss) { + const name = o.url ? `[${cell(o.name)}](${o.url})` : cell(o.name); + const ev = o.evidence.length ? o.evidence.map((id) => `[${id}]`).join("") : "_ungrounded_"; + out.push(`| ${name} | ${cell(o.note)} | ${ev} |`); } - if (existsSync2(dir)) { - try { - rmSync(dir, { recursive: true, force: true }); - } catch (e) { - throw new Error(`could not remove the partial clone at ${dir} before retrying: ${e.message} \u2014 delete it manually and re-run`); - } + } else { + out.push(`_No OSS prior art captured. Use the oss research angle to mine comparable projects._`); + } + out.push(``); + return out.join("\n"); +} +function renderBuildPlan(srd) { + const out = [`# Build plan`, ``]; + for (const m of srd.buildPlan) { + out.push(`## ${m.title}`, ``, m.outcome, ``); + out.push(`- **Requirements:** ${m.frIds.length ? m.frIds.join(", ") : "\u2014"}`); + if (m.risks.length) { + out.push(`- **Risks:**`); + for (const r of m.risks) out.push(` - ${r}`); } - const fallback = sh("git", ["clone", "--depth", "1", ...opts.branch ? ["--branch", opts.branch] : [], ref.cloneUrl, dir], { - timeoutMs: GIT_CLONE_TIMEOUT_MS - }); - if (!fallback.ok) { - throw new Error( - [ - `git clone failed for ${ref.cloneUrl}`, - ` attempt 1 (--filter=blob:none): ${res.stderr.trim() || `exit ${res.status}`}`, - ` attempt 2 (no filter): ${fallback.stderr.trim() || `exit ${fallback.status}`}` - ].join("\n") - ); + out.push(``); + } + return out.join("\n"); +} +function renderTraceability(srd) { + const design = !!srd.design; + const modules = !!srd.modules?.length; + const cols = ["Requirement", ...modules ? ["Module"] : [], "NFRs", "ADRs", "Entities", "Interfaces", ...design ? ["Components", "Screens"] : []]; + const out = [`# Traceability matrix`, ``, `| ${cols.join(" | ")} |`, `|${cols.map(() => "---").join("|")}|`]; + for (const r of srd.traceability) { + const cells = [ + r.fr, + ...modules ? [r.module ?? "\u2014"] : [], + r.nfrs.join(", ") || "\u2014", + r.adrs.join(", ") || "\u2014", + r.entities.join(", ") || "\u2014", + r.interfaces.join(", ") || "\u2014" + ]; + if (design) { + cells.push((r.components ?? []).map(cell).join(", ") || "\u2014"); + cells.push((r.screens ?? []).map(cell).join(", ") || "\u2014"); } + out.push(`| ${cells.join(" | ")} |`); } - if (!existsSync2(dir) || readdirSync(dir).length === 0) { - throw new Error(`clone produced an empty tree at ${dir}`); + out.push(``); + return out.join("\n"); +} +function renderDesignPrinciples(ds) { + return [ + `# Design principles`, + ``, + bullets(ds.principles, "No design principles captured."), + ``, + `## Content & voice`, + ``, + bullets(ds.contentVoice, "No content guidelines captured."), + `` + ].join("\n"); +} +function renderDesignTokens(ds) { + const out = [`# Design tokens`, ``, `_${DESIGN_TOKENS_SEEDED_BANNER}_`, ``]; + const cats = [...new Set(ds.tokens.map((t) => t.category))]; + for (const cat of cats) { + const toks = ds.tokens.filter((t) => t.category === cat); + out.push(`## ${cell(cat)}`, ``, `| Token | Value | Notes |`, `|---|---|---|`); + for (const t of toks) out.push(`| ${cell(t.name)} | ${cell(t.value)} | ${cell(t.note ?? "")} |`); + out.push(``); } - return dir; + out.push("> The machine-readable token set is in `design/design-tokens.json`.", ``); + return out.join("\n"); +} +function renderDesignTokensJson(ds) { + const obj = {}; + for (const t of ds.tokens) { + (obj[t.category] ??= {})[t.name] = t.value; + } + return JSON.stringify(obj, null, 2); +} +function renderComponents(ds) { + const out = [`# Components`, ``]; + if (!ds.components.length) { + out.push(`_No components defined yet. Enrich during authoring: name each component, its states and the requirements it realises._`, ``); + return out.join("\n"); + } + out.push(`_Seeded from the functional requirements \u2014 verify each component and its states during authoring._`, ``); + for (const c of ds.components) { + out.push(`## ${c.name}${cite(c.evidence)}`, ``, c.purpose, ``); + out.push(`- **States:** ${c.states.join(", ") || "\u2014"}`); + out.push(`- **Realises:** ${c.relatedFRs.length ? c.relatedFRs.join(", ") : "\u2014"}`, ``); + } + return out.join("\n"); +} +function renderScreens(ds) { + const out = [`# Screens & flows`, ``, `## Screens`, ``]; + if (ds.screens.length) { + out.push(`| Screen | Purpose | Requirements |`, `|---|---|---|`); + for (const s of ds.screens) out.push(`| ${cell(s.name)} | ${cell(s.purpose)} | ${s.relatedFRs.join(", ") || "\u2014"} |`); + } else { + out.push(`_No screens defined._`); + } + out.push(``, `## User flows`, ``); + if (ds.flows.length) { + for (const f of ds.flows) { + out.push(`### ${f.name}${f.frIds.length ? ` _(${f.frIds.join(", ")})_` : ""}`, ``); + f.steps.forEach((step, i) => out.push(`${i + 1}. ${step}`)); + out.push(``); + } + } else { + out.push(`_No user flows defined._`); + } + return out.join("\n"); +} +function renderAccessibility(ds) { + const a = ds.accessibility; + const out = [`# Accessibility`, ``, `**Target standard:** ${a.standard}`, ``]; + if (!a.requirements.length) { + out.push(`_No accessibility requirements defined._`, ``); + return out.join("\n"); + } + for (const r of a.requirements) { + out.push(`## ${r.id} \u2014 ${r.statement}`, ``, `**Acceptance criteria:**`); + for (const c of r.acceptance) out.push(`- **Given** ${c.given} **When** ${c.when} **Then** ${c.then}`); + out.push(``); + } + return out.join("\n"); +} +function renderMergeBundle(srd) { + const parts = [ + `# Software Requirements Document \u2014 ${srd.product.name}`, + ``, + `_Level: ${srd.level} \xB7 generated: ${srd.generatedAt}_`, + ``, + renderVision(srd), + renderScope(srd), + // Always the full FR blocks: the bundle is the one-file reading copy, so it + // must stay complete even when FUNCTIONAL.md is an index (modules mode). + renderFunctionalFull(srd), + renderNonFunctional(srd), + renderSystemContext(srd), + renderDataModel(srd), + renderInterfaces(srd), + `# Architecture decisions`, + ``, + ...srd.architecture.adrs.map(renderADR), + ...srd.design ? [ + `# Design system`, + ``, + renderDesignPrinciples(srd.design), + renderDesignTokens(srd.design), + renderComponents(srd.design), + renderScreens(srd.design), + renderAccessibility(srd.design) + ] : [], + renderLandscape(srd), + renderBuildPlan(srd), + renderTraceability(srd) + ]; + return parts.join("\n"); } -// src/walk.ts -import { readdirSync as readdirSync2, lstatSync, readFileSync as readFileSync2 } from "fs"; -import { join as join3, relative, sep, extname } from "path"; -var IGNORE_DIRS = /* @__PURE__ */ new Set([ - ".git", - "node_modules", - ".pnpm", - "bower_components", - "vendor", - "dist", - "build", - "out", - "target", - ".next", - ".nuxt", - ".svelte-kit", - ".turbo", - "coverage", - "__pycache__", - ".venv", - "venv", - ".tox", - ".mypy_cache", - ".pytest_cache", - ".gradle", - ".idea", - ".vscode", - ".cache", - "tmp", - ".construct", - "Pods", - "DerivedData", - ".terraform", - "elm-stuff", - ".dart_tool" -]); -var LOCKFILES = /* @__PURE__ */ new Set([ - "package-lock.json", - "npm-shrinkwrap.json", - "yarn.lock", - "pnpm-lock.yaml", - "bun.lockb", - "composer.lock", - "cargo.lock", - "poetry.lock", - "pipfile.lock", - "gemfile.lock", - "go.sum", - "flake.lock", - "packages.lock.json", - "podfile.lock", - "mix.lock" -]); -var BINARY_EXT = /* @__PURE__ */ new Set([ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".bmp", - ".ico", - ".icns", - ".svg", - ".pdf", - ".zip", - ".gz", - ".tar", - ".tgz", - ".bz2", - ".xz", - ".7z", - ".rar", - ".jar", - ".war", - ".class", - ".so", - ".dylib", - ".dll", - ".exe", - ".bin", - ".o", - ".a", - ".wasm", - ".woff", - ".woff2", - ".ttf", - ".otf", - ".eot", - ".mp3", - ".mp4", - ".mov", - ".avi", - ".webm", - ".wav", - ".flac", - ".ogg", - ".lock", - ".min.js", - ".map" -]); -function walk(root, opts = {}) { - const maxFileBytes = opts.maxFileBytes ?? 1024 * 1024; - const maxFiles = opts.maxFiles ?? 2e4; - const out = []; - const stack = [root]; - while (stack.length) { - if (out.length >= maxFiles) break; - const dir = stack.pop(); - let entries; - try { - entries = readdirSync2(dir); - } catch { - continue; - } - for (const name of entries) { - if (out.length >= maxFiles) break; - const abs = join3(dir, name); - let st; - try { - st = lstatSync(abs); - } catch { - continue; - } - if (st.isDirectory()) { - if (IGNORE_DIRS.has(name)) continue; - stack.push(abs); - continue; - } - if (!st.isFile()) continue; - if (st.size > maxFileBytes) continue; - if (LOCKFILES.has(name.toLowerCase())) continue; - const ext = extname(name).toLowerCase(); - if (BINARY_EXT.has(ext)) continue; - if (name.endsWith(".min.js") || name.endsWith(".min.css")) continue; - out.push({ rel: relative(root, abs).split(sep).join("/"), abs, size: st.size, ext }); - } - } - return out; +// src/brainstorm.ts +var ANGLES = ["reframe", "segment", "feature", "differentiator", "anti-goal", "wildcard"]; +var STATUSES = ["proposed", "kept", "parked", "rejected"]; +var TARGETS = ["featureWishlist", "competitors", "nonGoals", "goals", "candidateTech", "openQuestions"]; +function brainstormPath(runDir) { + return join2(runDir, "brainstorm.json"); +} +function initBrainstorm(idea, now) { + return { schemaVersion: BRAINSTORM_SCHEMA_VERSION, idea: idea.trim(), createdAt: now, ideas: [] }; +} +function saveBrainstorm(runDir, b) { + mkdirSync2(runDir, { recursive: true }); + const path = brainstormPath(runDir); + writeFileSync2(path, JSON.stringify(b, null, 2)); + return path; } -function readText(abs) { +function writeBrainstormMd(runDir, b) { + mkdirSync2(runDir, { recursive: true }); + const path = join2(runDir, "BRAINSTORM.md"); + const md = renderBrainstormMd(b); + writeFileSync2(path, md.endsWith("\n") ? md : md + "\n"); + return path; +} +function brainstormCounts(b) { + const counts = { proposed: 0, kept: 0, parked: 0, rejected: 0 }; + for (const i of b.ideas) if (counts[i.status] !== void 0) counts[i.status]++; + return counts; +} +var line = (v) => typeof v === "string" ? v.replace(/\s+/g, " ").trim() : void 0; +function loadBrainstorm(runDir, warn = () => { +}) { + const path = brainstormPath(runDir); + if (!existsSync2(path)) return void 0; + let data; try { - const buf = readFileSync2(abs); - const head = buf.subarray(0, 4096); - if (head.includes(0)) return ""; - return buf.toString("utf8"); - } catch { - return ""; + data = JSON.parse(readFileSync2(path, "utf8")); + } catch (e) { + throw new Error(`brainstorm.json is unreadable: ${e.message}`); } -} - -// src/providers/github.ts -function toItems(raw, kind) { - return (raw ?? []).filter((it) => it && typeof it === "object").map((it) => { - const body = String(it.body ?? "").replace(/\r/g, "").trim().slice(0, 1200); - const labels = (it.labels ?? []).map((l) => typeof l === "string" ? l : l.name).filter(Boolean).join(", "); - const state = it.draft ? "draft" : it.state; - return { - source: kind, - title: `#${it.number} ${it.title} [${state}]`, - ref: `${kind}#${it.number}`, - location: it.html_url, - score: Number(it.score ?? 0), - snippet: `state: ${state}` + (labels ? ` \xB7 labels: ${labels}` : "") + ` \xB7 comments: ${it.comments ?? 0} \xB7 updated: ${it.updated_at ?? "?"} - -` + (body || "(no description)"), - url: it.html_url, - meta: { number: it.number, state, isPR: !!it.pull_request } - }; + const d = data ?? {}; + const used = /* @__PURE__ */ new Set(); + let seq = 0; + const nextId = () => { + do { + seq++; + } while (used.has(`B-${String(seq).padStart(3, "0")}`)); + const id = `B-${String(seq).padStart(3, "0")}`; + used.add(id); + return id; + }; + const rawIdeas = Array.isArray(d.ideas) ? d.ideas : []; + if (!Array.isArray(d.ideas) && d.ideas !== void 0) warn("brainstorm.ideas is not an array \u2014 ignored."); + for (const raw of rawIdeas) { + const id = line(raw?.id); + if (id && /^B-\d{3,}$/.test(id)) used.add(id); + } + const ideas = []; + rawIdeas.forEach((raw, i) => { + const r = raw ?? {}; + const title = line(r.title); + if (!title) { + warn(`brainstorm.ideas[${i}] has no usable title \u2014 dropped.`); + return; + } + let id = line(r.id); + if (!id || !/^B-\d{3,}$/.test(id)) id = nextId(); + let angle = r.angle; + if (!ANGLES.includes(angle)) { + if (r.angle !== void 0) warn(`brainstorm ${id}: angle "${String(r.angle)}" is not recognized \u2014 treated as wildcard.`); + angle = "wildcard"; + } + let status = r.status; + if (!STATUSES.includes(status)) { + if (r.status !== void 0) warn(`brainstorm ${id}: status "${String(r.status)}" is not recognized \u2014 treated as proposed.`); + status = "proposed"; + } + let target = r.target; + if (target !== void 0 && !TARGETS.includes(target)) { + warn(`brainstorm ${id}: target "${String(r.target)}" is not recognized \u2014 removed.`); + target = void 0; + } + const idea = { id, angle, title, status }; + const notes = line(r.notes); + if (notes) idea.notes = notes; + if (target) idea.target = target; + const priority = r.priority; + if (priority === "must" || priority === "should" || priority === "could") idea.priority = priority; + const mergedAt = line(r.mergedAt); + if (mergedAt) idea.mergedAt = mergedAt; + ideas.push(idea); }); + return { + schemaVersion: typeof d.schemaVersion === "number" ? d.schemaVersion : BRAINSTORM_SCHEMA_VERSION, + idea: line(d.idea) ?? "", + createdAt: line(d.createdAt) ?? "", + ...line(d.updatedAt) ? { updatedAt: line(d.updatedAt) } : {}, + ideas + }; } -function apiBase(host) { - return /(^|\.)github\.com$/i.test(host) ? "https://api.github.com" : `https://${host}/api/v3`; -} -function ghUsable(host) { - return have("gh") && /(^|\.)github\.com$/i.test(host); -} -var canonCache = /* @__PURE__ */ new Map(); -async function canonicalRepo(ref) { - const fallback = { owner: ref.owner, repo: ref.repo }; - if (!/github/i.test(ref.host)) return fallback; - const key = `${ref.host}/${ref.owner}/${ref.repo}`; - const cached = canonCache.get(key); - if (cached) return cached; - let resolved = fallback; - const parse = (full) => { - const i = full.indexOf("/"); - return i > 0 ? { owner: full.slice(0, i), repo: full.slice(i + 1) } : fallback; +var norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim(); +function mergeBrainstorm(briefIn, brainstormIn, now, warn = () => { +}) { + const brief = JSON.parse(JSON.stringify(briefIn)); + const brainstorm = JSON.parse(JSON.stringify(brainstormIn)); + let merged = 0; + let parkedFolded = 0; + let skipped = 0; + const appendUnique = (list, value) => { + if (list.some((x) => norm(x) === norm(value))) return false; + list.push(value); + return true; }; - if (ghUsable(ref.host)) { - const r = sh("gh", ["api", `repos/${ref.owner}/${ref.repo}`, "--jq", ".full_name"]); - if (r.ok && r.stdout.includes("/")) resolved = parse(r.stdout.trim()); - } else { - const r = await httpGet(`${apiBase(ref.host)}/repos/${ref.owner}/${ref.repo}`, { accept: "application/vnd.github+json" }); - if (r.ok) { - try { - const full = JSON.parse(r.body)?.full_name; - if (typeof full === "string" && full.includes("/")) resolved = parse(full); - } catch { - } + for (const idea of brainstorm.ideas) { + if (idea.mergedAt) continue; + if (idea.status === "parked") { + appendUnique(brief.openQuestions, `Parked idea ${idea.id}: ${idea.title}`); + idea.mergedAt = now; + parkedFolded++; + continue; } - } - canonCache.set(key, resolved); - return resolved; -} -async function query(ref, terms, kind, perSource) { - const q = `repo:${ref.owner}/${ref.repo} type:${kind} ${terms.join(" ")}`.trim(); - if (ghUsable(ref.host)) { - const res = sh("gh", ["api", "-X", "GET", "search/issues", "-f", `q=${q}`, "-f", `per_page=${perSource}`, "-f", "sort=updated", "-f", "order=desc"]); - if (res.ok) { - try { - return { items: toItems(JSON.parse(res.stdout).items, kind) }; - } catch { + if (idea.status !== "kept") continue; + if (!idea.target) { + warn(`brainstorm ${idea.id} "${idea.title}" is kept but has no target \u2014 set one (featureWishlist, competitors, \u2026) and re-merge.`); + skipped++; + continue; + } + if (idea.target === "featureWishlist") { + const exists = brief.featureWishlist.some((f) => norm(f.title) === norm(idea.title)); + if (exists) { + warn(`brainstorm ${idea.id} "${idea.title}" is already in the wishlist \u2014 skipped.`); + } else { + brief.featureWishlist.push({ title: idea.title, priority: idea.priority ?? "could", ...idea.notes ? { notes: idea.notes } : {} }); } + idea.mergedAt = now; + merged++; + continue; + } + if (idea.target === "goals" && brief.nonGoals.some((g) => norm(g) === norm(idea.title))) { + warn(`brainstorm ${idea.id} "${idea.title}" conflicts with an existing nonGoal \u2014 NOT merged; resolve it in brief.json first.`); + skipped++; + continue; + } + if (idea.target === "nonGoals" && brief.goals.some((g) => norm(g) === norm(idea.title))) { + warn(`brainstorm ${idea.id} "${idea.title}" conflicts with an existing goal \u2014 NOT merged; resolve it in brief.json first.`); + skipped++; + continue; } + const value = idea.target === "openQuestions" && idea.notes ? `${idea.title} \u2014 ${idea.notes}` : idea.title; + const list = brief[idea.target]; + if (!appendUnique(list, value)) warn(`brainstorm ${idea.id} "${idea.title}" is already in ${idea.target} \u2014 skipped.`); + idea.mergedAt = now; + merged++; } - const url = `${apiBase(ref.host)}/search/issues?q=${encodeURIComponent(q)}&per_page=${perSource}&sort=updated&order=desc`; - const r = await httpGet(url, { accept: "application/vnd.github+json" }); - if (!r.ok) { - const hint = r.status === 422 ? `query rejected (422) for repo:${ref.owner}/${ref.repo} \u2014 the repo may be moved/renamed/private, or the query had no valid terms` : `status ${r.status}; run \`gh auth login\` for higher-rate access`; - return { items: [], error: `GitHub ${kind} search unavailable (${hint}).` }; + brainstorm.updatedAt = now; + const proposed = brainstorm.ideas.filter((i) => i.status === "proposed").length; + return { brief, brainstorm, merged, parkedFolded, skipped, proposed }; +} + +// src/research/registry.ts +import { join as join7 } from "path"; + +// src/research/fetch.ts +var UA = "construct/0.x (+https://github.com/maxgfr/construct)"; +var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"; +function transient(status) { + return status === 0 || status === 429 || status >= 500; +} +async function httpGet(url, opts = {}) { + const retries = opts.retries ?? 1; + const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + let last = { ok: false, status: 0, body: "", contentType: "", error: "unreached" }; + for (let attempt = 0; attempt <= retries; attempt++) { + last = await httpGetOnce(url, opts); + if (last.ok || !transient(last.status)) return last; + if (attempt === retries) break; + const retryAfterS = Number(last.retryAfter); + const delay = last.status === 429 && Number.isFinite(retryAfterS) && retryAfterS > 0 ? Math.min(retryAfterS * 1e3, RETRY_AFTER_CAP_MS) : RETRY_BASE_DELAY_MS * 2 ** attempt + Math.random() * RETRY_JITTER_MS; + await sleep(delay); } + return last; +} +async function httpGetOnce(url, opts) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? HTTP_GET_TIMEOUT_MS); try { - return { items: toItems(JSON.parse(r.body).items, kind) }; - } catch { - return { items: [], error: `GitHub ${kind} search returned an unparseable response.` }; + const res = await fetch(url, { + signal: ctrl.signal, + redirect: "follow", + headers: { "user-agent": UA, accept: opts.accept ?? "*/*", ...opts.headers ?? {} } + }); + const buf = Buffer.from(await res.arrayBuffer()); + const max = opts.maxBytes ?? 4 * 1024 * 1024; + return { + ok: res.ok, + status: res.status, + body: buf.subarray(0, max).toString("utf8"), + contentType: res.headers.get("content-type") ?? "", + retryAfter: res.headers.get("retry-after") ?? void 0 + }; + } catch (e) { + return { ok: false, status: 0, body: "", contentType: "", error: e.message }; + } finally { + clearTimeout(t); } } -var github = { - name: "github", - matches: (host) => /(^|\.)github\.com$/i.test(host) || /github/i.test(host), - async search(ref0, question, kind, perSource) { - if (!ref0.owner || !ref0.repo) { - return { items: [], notes: ["No owner/repo resolved; cannot query GitHub issues/PRs."] }; - } - const canon = await canonicalRepo(ref0); - const ref = { ...ref0, owner: canon.owner, repo: canon.repo }; - const ranked = rankedKeywords(question); - if (ranked.length === 0) return { items: [], notes: [`No keywords to search ${kind}s.`] }; - let lastError; - for (const terms of uniqueAttempts([ranked.slice(0, 3), ranked.slice(0, 2)])) { - const { items, error } = await query(ref, terms, kind, perSource * 2); - if (error) lastError = error; - if (items.length) return { items: rerank(items, ranked).slice(0, perSource), notes: [] }; - } - const seen = /* @__PURE__ */ new Map(); - for (const t of ranked.slice(0, 4)) { - const { items, error } = await query(ref, [t], kind, perSource * 2); - if (error) lastError = error; - for (const it of items) if (!seen.has(it.ref)) seen.set(it.ref, it); +async function httpJson(method, url, body, opts = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? HTTP_JSON_TIMEOUT_MS); + try { + const res = await fetch(url, { + method, + signal: ctrl.signal, + headers: { "content-type": "application/json", accept: "application/json", "user-agent": UA }, + body: body === void 0 ? void 0 : JSON.stringify(body) + }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : void 0; + } catch { + data = text; } - const merged = rerank([...seen.values()], ranked).slice(0, perSource); - if (merged.length) return { items: merged, notes: [] }; - return { items: [], notes: lastError ? [lastError] : [`No ${kind}s matched the question.`] }; + return { ok: res.ok, status: res.status, data }; + } catch (e) { + return { ok: false, status: 0, data: void 0, error: e.message }; + } finally { + clearTimeout(t); } -}; -function rerank(items, ranked) { - const terms = ranked.map((t) => t.toLowerCase()); - const coverage = (it) => { - const hay = `${it.title} ${it.snippet}`.toLowerCase(); - let c = 0; - for (const t of terms) if (hay.includes(t)) c++; - return c; - }; - return items.map((it) => ({ it, c: coverage(it), s: it.score })).sort((a, b) => b.c - a.c || b.s - a.s).map((x) => x.it); } -function uniqueAttempts(lists) { - const seen = /* @__PURE__ */ new Set(); - const out = []; - for (const l of lists) { - const key = l.join("\0"); - if (l.length && !seen.has(key)) { - seen.add(key); - out.push(l); +var NAMED = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + mdash: "\u2014", + ndash: "\u2013", + hellip: "\u2026", + copy: "\xA9" +}; +function htmlToText(html) { + let s = html; + s = s.replace(//g, " "); + s = s.replace(/<(script|style|noscript|head|nav|footer|svg)[\s\S]*?<\/\1>/gi, " "); + s = s.replace(/<\/(p|div|section|article|li|tr|td|th|ul|ol|h[1-6]|pre|blockquote)>/gi, "\n"); + s = s.replace(/<(p|div|section|article|li|tr|td|th|ul|ol|h[1-6]|pre|blockquote|table)\b[^>]*>/gi, "\n"); + s = s.replace(/<(br|hr)\s*\/?>/gi, "\n"); + s = s.replace(/<[^>]+>/g, " "); + s = s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp|mdash|ndash|hellip|copy);/gi, (m, g) => { + if (g[0] === "#") { + const n = g[1] === "x" || g[1] === "X" ? parseInt(g.slice(2), 16) : Number(g.slice(1)); + try { + return Number.isFinite(n) ? String.fromCodePoint(n) : " "; + } catch { + return " "; + } } + return NAMED[g.toLowerCase()] ?? m; + }); + s = s.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n"); + return s.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).join("\n"); +} +var CONSENT_PATTERNS = [ + /\bcookies?\b/i, + /\bconsent\b/i, + /\bgdpr\b/i, + /\bccpa\b/i, + /accept all\b/i, + /reject all\b/i, + /manage (?:preferences|choices|cookies|settings)/i, + /privacy (?:policy|preferences|choices)/i, + /tracking technolog/i, + /advertising partners/i, + /legitimate interest/i +]; +function stripConsentBoilerplate(text) { + let dropped = 0; + const kept = text.split("\n").filter((line2) => { + const hits = CONSENT_PATTERNS.reduce((n, re) => n + (re.test(line2) ? 1 : 0), 0); + const isBanner = hits >= 2 || hits === 1 && line2.trim().length < 120; + if (isBanner) dropped++; + return !isBanner; + }); + return { text: kept.join("\n"), dropped }; +} +function metaDescriptionOf(html) { + const m = /]+name=["']description["'][^>]*content=["']([^"']+)["']/i.exec(html) || /]+content=["']([^"']+)["'][^>]*name=["']description["']/i.exec(html) || /]+property=["']og:description["'][^>]*content=["']([^"']+)["']/i.exec(html); + const d = m?.[1]?.replace(/\s+/g, " ").trim(); + return d || void 0; +} +async function fetchAndExtract(url) { + let res = await httpGet(url, { accept: "text/html,text/plain,*/*" }); + if (!res.ok && (res.status === 403 || res.status === 429)) { + res = await httpGet(url, { + accept: "text/html,application/xhtml+xml,*/*", + headers: { "user-agent": BROWSER_UA, "accept-language": "en-US,en;q=0.9" } + }); } - return out; + if (!res.ok) { + return { text: "", note: `Could not fetch ${url} (status ${res.status}${res.error ? ", " + res.error : ""}).` }; + } + const isHtml = /html/i.test(res.contentType) || /^\s* /gitlab/i.test(host), - async search(ref, question, kind, perSource) { - if (!ref.owner || !ref.repo) { - return { items: [], notes: ["No project path resolved; cannot query GitLab issues/MRs."] }; - } - const kw = rankedKeywords(question).slice(0, 4); - if (kw.length === 0) { - return { items: [], notes: [`No keywords to search GitLab ${kind === "issue" ? "issues" : "merge requests"}.`] }; - } - const proj = encodeURIComponent(`${ref.owner}/${ref.repo}`); - const path = kind === "issue" ? "issues" : "merge_requests"; - const search = encodeURIComponent(kw.join(" ")); - const url = `https://${ref.host}/api/v4/projects/${proj}/${path}?search=${search}&per_page=${perSource}&order_by=updated_at&sort=desc`; - const r = await httpGet(url, { accept: "application/json" }); - if (!r.ok) { - return { items: [], notes: [`GitLab ${kind} search unavailable (status ${r.status}).`] }; - } - try { - const arr = JSON.parse(r.body); - if (!Array.isArray(arr)) return { items: [], notes: [`GitLab ${kind} search returned no array.`] }; - const marker = kind === "issue" ? "#" : "!"; - const items = arr.filter((it) => it && typeof it === "object").map((it) => { - const num = it.iid ?? it.id; - const body = String(it.description ?? "").replace(/\r/g, "").trim().slice(0, 1200); - return { - source: kind, - title: `${marker}${num} ${it.title} [${it.state}]`, - ref: `${kind}#${num}`, - location: it.web_url, - score: 0, - snippet: `state: ${it.state} \xB7 updated: ${it.updated_at ?? "?"} - -${body || "(no description)"}`, - url: it.web_url, - meta: { iid: num, state: it.state } - }; - }); - return { items, notes: [] }; - } catch { - return { items: [], notes: [`GitLab ${kind} search returned an unparseable response.`] }; +function excerptsFromText(text, url, title, source, question, perSource) { + const lines = text.split("\n"); + const questions = (Array.isArray(question) ? question : [question]).filter((q) => q.trim()); + const kwSets = questions.map((q) => keywords(q).map((k) => k.toLowerCase())); + const hits = []; + for (let i = 0; i < lines.length; i++) { + const low = lines[i].toLowerCase(); + let cov = 0; + for (const kws of kwSets) { + let c = 0; + for (const kw of kws) if (low.includes(kw)) c++; + if (kws.length && c > cov) cov = c; } + if (cov > 0) hits.push({ idx: i, cov }); } -}; - -// src/providers/generic.ts -var generic = { - name: "generic", - matches: () => true, - async search(ref, _question, kind) { - return { - items: [], - notes: [`No public ${kind} API for host "${ref.host}". The code was cloned and indexed; issues/PRs are not retrievable for this host.`] - }; + hits.sort((a, b) => b.cov - a.cov || a.idx - b.idx); + const items = []; + const ranges = []; + const take = hits.length ? hits : [{ idx: 0, cov: 0 }]; + const perDoc = Math.min(2, Math.max(1, perSource)); + for (const h of take) { + if (items.length >= perDoc) break; + const start = Math.max(0, h.idx - 3); + const end = Math.min(lines.length, h.idx + 12); + if (ranges.some((r) => start < r.end && end > r.start)) continue; + ranges.push({ start, end }); + const snippet = lines.slice(start, end).join("\n").slice(0, 1500); + if (!snippet.trim()) continue; + items.push({ + source, + // Disambiguate the second+ excerpt of one page by its line range, so two + // excerpts of the same URL don't render identical titles. + title: items.length === 0 ? title : `${title} (lines ${start + 1}\u2013${end})`, + ref: url, + location: `${url}#~${start + 1}`, + score: Number((h.cov + 1).toFixed(3)), + snippet, + url, + // cov=0 means no line matched the question — this is the top-of-page + // fallback, likely boilerplate. Flag it so review/analyze down-weight it. + ...h.cov === 0 ? { meta: { lowSignal: true } } : {} + }); } -}; - -// src/providers/registry.ts -var PROVIDERS = [github, gitlab]; -function providerFor(host) { - return PROVIDERS.find((p) => p.matches(host)) ?? generic; + return items; } -// src/research/oss.ts -var NON_REPO_OWNERS = /* @__PURE__ */ new Set([ - "topics", - "search", - "collections", - "trending", - "explore", - "marketplace", - "sponsors", - "features", - "about", - "pricing", - "login", - "join", - "signup", - "settings", - "notifications", - "issues", - "pulls", - "orgs", - "apps", - "blog", - "site", - "enterprise", - "customer-stories", - "security", - "readme", - "events", - "dashboard", - "groups", - "users", - "help", - "projects", - "-" -]); -function canonicalRepoUrl(url) { - const m = /^(https?:\/\/(?:github|gitlab)\.com\/([A-Za-z0-9._-]+)\/[A-Za-z0-9._-]+)/i.exec(url); - if (!m || NON_REPO_OWNERS.has(m[2].toLowerCase())) return void 0; - return m[1].replace(/\.git$/, ""); -} -function normalizeSeed(raw) { - const s = raw.trim(); - if (!s) return void 0; - if (/^https?:\/\/github\.com\//i.test(s)) return canonicalRepoUrl(s); - const ref = resolveRepo(s); - return ref.isLocal || ref.owner && ref.repo ? s : void 0; +// src/research/web.ts +var SEARXNG_BASE = process.env.CONSTRUCT_SEARXNG || "http://localhost:8888"; +async function viaSearxng(query2, n) { + const url = `${SEARXNG_BASE.replace(/\/$/, "")}/search?q=${encodeURIComponent(query2)}&format=json`; + const r = await httpGet(url, { accept: "application/json", timeoutMs: SEARXNG_TIMEOUT_MS, retries: 0 }); + if (!r.ok) return null; + try { + const data = JSON.parse(r.body); + const urls = (data.results ?? []).map((x) => x.url).filter(Boolean); + return urls.slice(0, n); + } catch { + return null; + } } -function languageHistogram(files) { - const counts = /* @__PURE__ */ new Map(); - for (const f of files) { - const ext = f.ext.replace(/^\./, ""); - if (!ext) continue; - counts.set(ext, (counts.get(ext) ?? 0) + 1); +async function viaDuckDuckGo(query2, n) { + const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query2)}`; + const r = await httpGet(url, { accept: "text/html", timeoutMs: DDG_TIMEOUT_MS }); + if (!r.ok || !r.body) return null; + const urls = []; + const tagRe = /]*\bresult__a\b[^>]*>/g; + let m; + while ((m = tagRe.exec(r.body)) && urls.length < n) { + const href0 = /\bhref="([^"]+)"/.exec(m[0]); + if (!href0) continue; + let href = href0[1]; + const uddg = /[?&]uddg=([^&]+)/.exec(href); + if (uddg) { + try { + href = decodeURIComponent(uddg[1]); + } catch { + } + } + if (/^https?:\/\//.test(href) && !/duckduckgo\.com/.test(href)) urls.push(href); } - return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + return urls.length ? urls : null; } -async function ossAngle(ctx) { +async function discover(query2, engine, n) { const notes = []; - let seeds = [...new Set(ctx.brief.ossSeeds.map(normalizeSeed).filter((x) => !!x))]; - if (seeds.length === 0) { - const q2 = `${ctx.query || ctx.brief.idea} open source github`; - const d = await discover(q2, ctx.webEngine, ctx.perSource); - notes.push(`OSS discovery via ${d.via} for "${q2}".`, ...d.notes); - seeds = [...new Set(d.urls.map(canonicalRepoUrl).filter((x) => !!x))]; + if (engine === "searxng" || engine === "auto") { + const s = await viaSearxng(query2, n); + if (s?.length) return { urls: s, via: "searxng", notes }; + if (engine === "searxng") { + notes.push(s === null ? `SearXNG unreachable at ${SEARXNG_BASE}. Run \`construct semantic up\`.` : "SearXNG returned no results."); + } } - seeds = seeds.slice(0, 3); - if (seeds.length === 0) { - return [{ source: "oss", items: [], notes: [...notes, "No comparable OSS projects found."] }]; + if (engine === "ddg" || engine === "auto") { + const d = await viaDuckDuckGo(query2, n); + if (d?.length) return { urls: d, via: "duckduckgo", notes }; + if (engine === "ddg") notes.push("DuckDuckGo returned no results."); } - const ossItems = []; - const issueItems = []; - const prItems = []; - const q = ctx.query || ctx.brief.idea; - for (const seed of seeds) { - const ref = resolveRepo(seed); - let dir; - try { - dir = ensureClone(ref, { refresh: ctx.refresh }); - } catch (e) { - notes.push(`Could not clone ${ref.raw}: ${e.message}`); - } - const repoLabel = ref.owner && ref.repo ? `${ref.owner}/${ref.repo}` : ref.slug; - if (dir) { - const files = walk(dir); - const langs = languageHistogram(files).slice(0, 6).map(([e, c]) => `${e}:${c}`).join(", "); - let snippet = `Languages: ${langs || "n/a"} \xB7 files: ${files.length}.`; - const readme = files.find((f) => /^readme(\.|$)/i.test(f.rel)) ?? files.find((f) => /(^|\/)readme\./i.test(f.rel)); - if (readme) { - const text = readText(readme.abs); - const ex = excerptsFromText(text, ref.webUrl ?? ref.raw, repoLabel, "oss", q, 1); - if (ex[0]) snippet += ` - -${ex[0].snippet}`; + if (engine === "claude" || engine === "auto") { + notes.push( + "No keyless engine returned results. Use your built-in WebSearch to find URLs, then ground them with `construct web --url --out `." + ); + } + return { urls: [], via: "none", notes }; +} +async function webFetchUrls(urls, question, perSource, source = "market", fetchAll = false) { + const items = []; + const notes = []; + const toFetch = fetchAll ? urls : urls.slice(0, Math.max(1, Math.ceil(perSource / 2))); + for (const url of toFetch) { + const { text, note, metaDescription } = await fetchAndExtract(url); + if (note) notes.push(note); + if (!text) continue; + const ex = excerptsFromText(text, url, `${labelFor(source)} \u2014 ${url}`, source, question, perSource); + if (ex.length) { + for (const item of ex) { + if (item.meta?.lowSignal && metaDescription) item.snippet = metaDescription; } - ossItems.push({ - source: "oss", - title: `${repoLabel} \u2014 prior art`, - ref: repoLabel, - location: ref.webUrl, - score: files.length, - snippet, - url: ref.webUrl + items.push(...ex); + } else { + items.push({ + source, + title: `${labelFor(source)} \u2014 ${url}`, + ref: url, + location: url, + score: 0, + snippet: metaDescription ?? text.slice(0, 800), + url, + meta: { lowSignal: true } }); } - if (ref.owner && ref.repo) { - const provider = providerFor(ref.host); - const iss = await provider.search(ref, q, "issue", ctx.perSource); - issueItems.push(...iss.items); - notes.push(...iss.notes); - const prs = await provider.search(ref, q, "pr", ctx.perSource); - prItems.push(...prs.items); - notes.push(...prs.notes); - } } - return [ - { source: "oss", items: ossItems, notes }, - { source: "issue", items: issueItems, notes: [] }, - { source: "pr", items: prItems, notes: [] } - ]; + return { items, notes }; } - -// src/research/stackoverflow.ts -async function stackoverflow(question, perSource) { - const kws = rankedKeywords(question).slice(0, 5).join(" "); - if (!kws) return { source: "so", items: [], notes: ["No keywords to search StackOverflow."] }; - const q = encodeURIComponent(kws); - const pat = process.env.STACK_PAT ? `&access_token=${process.env.STACK_PAT}` : ""; - const url = `https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${q}&site=stackoverflow&filter=withbody&pagesize=${perSource}${pat}`; - const r = await httpGet(url, { accept: "application/json" }); - if (!r.ok) { - return { source: "so", items: [], notes: [`StackOverflow search unavailable (status ${r.status}).`] }; - } - try { - const data = JSON.parse(r.body); - const items = (data.items ?? []).map((it) => { - const body = htmlToText(String(it.body ?? "")).slice(0, 1200); - const accepted = it.is_answered ? "answered" : "unanswered"; - return { - source: "so", - title: htmlToText(String(it.title ?? "(question)")).slice(0, 160), - ref: `so:${it.question_id}`, - location: it.link, - score: Number(it.score ?? 0), - snippet: `score: ${it.score ?? 0} \xB7 ${accepted} \xB7 answers: ${it.answer_count ?? 0}` + (it.tags?.length ? ` \xB7 tags: ${it.tags.slice(0, 6).join(", ")}` : "") + ` - -${body || "(no body)"}`, - url: it.link, - meta: { questionId: it.question_id, isAnswered: it.is_answered, answerCount: it.answer_count } - }; - }); - const notes = data.quota_remaining !== void 0 && data.quota_remaining < 20 ? [`StackExchange anonymous quota low (${data.quota_remaining} left).`] : []; - if (items.length === 0) notes.push("No StackOverflow questions matched."); - return { source: "so", items, notes }; - } catch { - return { source: "so", items: [], notes: ["StackOverflow search returned an unparseable response."] }; - } +function labelFor(source) { + return source === "docs" ? "Docs" : source === "oss" ? "OSS" : "Web"; } -// src/research/tech.ts -async function techAngle(ctx) { - const allTechs = ctx.brief.candidateTech; - const techs = allTechs.slice(0, 3); - const ideaKw = ctx.query || ctx.brief.idea; - const docItems = []; - const docNotes = []; - if (allTechs.length > techs.length) { - docNotes.push( - `Only the first ${techs.length} of ${allTechs.length} candidate technologies were grounded; skipped: ${allTechs.slice(techs.length).join(", ")}. Drill them with \`construct tech --out --q ""\`.` - ); - } - if (ctx.docsUrls?.length) { - const direct = await webFetchUrls(ctx.docsUrls, ideaKw, ctx.perSource, "docs", true); - docItems.push(...direct.items); - docNotes.push(`Grounded ${ctx.docsUrls.length} docs URL(s) passed via --docs-url.`, ...direct.notes); +// src/research/market.ts +async function marketAngle(ctx) { + const b = ctx.brief; + const query2 = ctx.query || [b.idea, b.competitors.join(" "), "competitors alternatives market"].filter(Boolean).join(" ").trim(); + const items = []; + const notes = []; + const pinned = ctx.marketUrls ?? []; + const questions = [query2, ...b.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`.trim())].filter(Boolean); + if (pinned.length) { + const f = await webFetchUrls(pinned, questions.length ? questions : pinned.join(" "), ctx.perSource, "market", true); + items.push(...f.items.slice(0, ctx.perSource)); + notes.push(`Pinned ${pinned.length} market URL(s) via --url.`, ...f.notes); } - for (const tech of techs) { - const q = `${tech} official documentation`; - const { urls, via, notes } = await discover(q, ctx.webEngine, ctx.perSource); - docNotes.push(`Docs discovery for "${tech}" via ${via}.`, ...notes); - if (!urls.length) continue; - const fetched = await webFetchUrls(urls.slice(0, 1), `${tech} ${ideaKw}`, ctx.perSource, "docs"); - docItems.push(...fetched.items); - docNotes.push(...fetched.notes); + if (!query2) { + if (items.length) return [{ source: "market", items, notes }]; + return [{ source: "market", items: [], notes: ["No idea/competitors to search the market for."] }]; } - if (techs.length === 0 && !ctx.docsUrls?.length) docNotes.push("No candidate technologies in the brief \u2014 nothing to ground feasibility against."); - const topKw = rankedKeywords(ideaKw)[0] ?? ""; - const soItems = []; - const soNotes = []; - const seen = /* @__PURE__ */ new Set(); - const per = Math.max(2, Math.ceil(ctx.perSource / Math.max(1, techs.length))); - for (const tech of techs) { - const q = `${tech} ${topKw}`.trim(); - const r = await stackoverflow(q, per); - for (const it of r.items) { - if (!seen.has(it.ref)) { - seen.add(it.ref); - soItems.push(it); - } + const budget = ctx.perSource - items.length; + if (budget > 0) { + const { urls, via, notes: discoveryNotes } = await discover(query2, ctx.webEngine, budget); + if (urls.length === 0) { + notes.push(`Market discovery via ${via}.`, ...discoveryNotes); + } else { + const fetched = await webFetchUrls(urls, questions, budget, "market"); + items.push(...fetched.items); + notes.push(`Market discovery via ${via} for "${query2}".`, ...discoveryNotes, ...fetched.notes); } - soNotes.push(...r.notes); } - if (techs.length === 0) soNotes.push("No candidate technologies to search StackOverflow for."); - return [ - { source: "docs", items: docItems, notes: docNotes }, - { source: "so", items: soItems, notes: soNotes } - ]; + return [{ source: "market", items, notes }]; } -// src/research/semantic.ts -import { existsSync as existsSync3 } from "fs"; -import { join as join4, dirname } from "path"; -import { fileURLToPath } from "url"; -var OLLAMA = (process.env.CONSTRUCT_OLLAMA || "http://localhost:11434").replace(/\/$/, ""); -var EMBED_MODEL = process.env.CONSTRUCT_EMBED_MODEL || "nomic-embed-text"; -function cosine(a, b) { - if (a.length === 0 || a.length !== b.length) return 0; - let dot = 0; - let na = 0; - let nb = 0; - for (let i = 0; i < a.length; i++) { - dot += a[i] * b[i]; - na += a[i] * a[i]; - nb += b[i] * b[i]; - } - if (na === 0 || nb === 0) return 0; - const r = dot / (Math.sqrt(na) * Math.sqrt(nb)); - return Number.isFinite(r) ? r : 0; +// src/clone.ts +import { existsSync as existsSync3, statSync, mkdirSync as mkdirSync3, readdirSync, rmSync } from "fs"; +import { resolve, join as join3, basename } from "path"; +import { tmpdir } from "os"; +function cacheRoot() { + return join3(tmpdir(), "construct"); } -async function reachable(base, path = "/") { - const r = await httpGet(base + path, { timeoutMs: REACHABLE_TIMEOUT_MS }); - return r.ok; -} -async function embed(text) { - const r = await httpJson("POST", `${OLLAMA}/api/embeddings`, { model: EMBED_MODEL, prompt: text.slice(0, 4e3) }, { timeoutMs: EMBED_TIMEOUT_MS }); - const v = r.ok ? r.data?.embedding : void 0; - return Array.isArray(v) && v.length ? v : null; -} -async function semanticRescore(results, query2) { - const unchanged = (why) => ({ - available: false, - results, - notes: [`Semantic mode unavailable (${why}); kept lexical ranking.`] - }); - if (!await reachable(OLLAMA, "/api/tags")) { - return unchanged(`Ollama not reachable at ${OLLAMA} \u2014 run \`construct semantic up\``); - } - const qv = await embed(query2); - if (!qv) return unchanged(`could not embed the query (is the '${EMBED_MODEL}' model pulled?)`); - const out = []; - let failures = 0; - for (const r of results) { - const items = []; - for (const it of r.items) { - const v = await embed(`${it.title} -${it.snippet}`); - if (v) { - items.push({ ...it, score: Number(cosine(qv, v).toFixed(4)), meta: { ...it.meta ?? {}, semantic: true } }); - } else { - failures++; - items.push({ ...it, score: -1, meta: { ...it.meta ?? {}, semantic: false } }); - } +function resolveRepo(raw) { + const trimmed = raw.trim(); + if (trimmed) { + const asPath = resolve(trimmed); + if (existsSync3(asPath) && statSync(asPath).isDirectory()) { + return { + raw: trimmed, + host: "local", + isLocal: true, + slug: "local-" + slugify(basename(asPath) + "-" + asPath) + }; } - out.push({ ...r, items }); - } - const notes = [`Semantic rescoring via Ollama + ${EMBED_MODEL} (local).`]; - if (failures) notes.push(`${failures} item(s) could not be embedded; ranked last.`); - return { available: true, results: out, notes }; -} -function composeFile() { - const here = dirname(fileURLToPath(import.meta.url)); - for (const cand of [join4(here, "..", "docker-compose.yml"), join4(here, "docker-compose.yml")]) { - if (existsSync3(cand)) return cand; - } - return join4(here, "..", "docker-compose.yml"); -} -function semanticControl(action) { - if (!["up", "down", "status"].includes(action)) { - return { message: `construct semantic: unknown action "${action}" (use: up | down | status)`, code: 1 }; - } - if (!have("docker")) { - return { message: "construct semantic: docker not found. Install Docker, then retry. See references/semantic-setup.md.", code: 1 }; - } - const file = composeFile(); - if (action === "down") { - const r = sh("docker", ["compose", "-f", file, "--profile", "all", "down"], { timeoutMs: COMPOSE_DOWN_TIMEOUT_MS }); - return { message: r.ok ? "construct semantic: stack stopped." : `construct semantic: down failed. -${r.stderr}`, code: r.ok ? 0 : 1 }; } - if (action === "status") { - const r = sh("docker", ["compose", "-f", file, "ps"], { timeoutMs: COMPOSE_PS_TIMEOUT_MS }); - return { message: r.ok ? r.stdout || "construct semantic: no services running." : `construct semantic: status failed. -${r.stderr}`, code: 0 }; + let host; + let path; + const scp = /^git@([^:]+):(.+)$/.exec(trimmed); + const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed); + const hostPath = /^([a-z0-9.-]+\.[a-z]{2,})\/(.+)$/i.exec(trimmed); + if (scp) { + host = scp[1]; + path = scp[2]; + } else if (url) { + host = url[1]; + path = url[2]; + } else if (hostPath) { + host = hostPath[1]; + path = hostPath[2]; + } else if (/^[\w.-]+\/[\w.-]+$/.test(trimmed)) { + host = "github.com"; + path = trimmed; + } else { + return { raw: trimmed, host: "generic", isLocal: false, slug: slugify(trimmed) || "seed" }; } - const up = sh("docker", ["compose", "-f", file, "--profile", "all", "up", "-d"], { timeoutMs: COMPOSE_UP_TIMEOUT_MS }); - if (!up.ok) return { message: `construct semantic: up failed. -${up.stderr}`, code: 1 }; - const pull = sh("docker", ["compose", "-f", file, "exec", "-T", "ollama", "ollama", "pull", EMBED_MODEL], { timeoutMs: OLLAMA_PULL_TIMEOUT_MS }); - const lines = [ - "construct semantic: stack is up (Qdrant :6333 \xB7 Ollama :11434 \xB7 SearXNG :8888).", - pull.ok ? ` model: ${EMBED_MODEL} ready` : ` model: pull '${EMBED_MODEL}' yourself: docker compose -f ${file} exec ollama ollama pull ${EMBED_MODEL}`, - " use: construct research --out --angles market,oss,tech,semantic --semantic" - ]; - return { message: lines.join("\n"), code: 0 }; -} - -// src/research/dossier.ts -import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs"; -import { join as join5 } from "path"; -var SOURCE_ORDER = ["market", "oss", "docs", "so", "issue", "pr"]; -var SOURCE_LABEL = { - market: "Market & competitors", - oss: "Open-source prior art", - docs: "Technology documentation", - so: "StackOverflow", - issue: "Issues (prior art)", - pr: "Pull / Merge Requests (prior art)" -}; -function rank(s) { - const i = SOURCE_ORDER.indexOf(s); - return i < 0 ? 99 : i; -} -function assignIds(results) { - const flat = results.flatMap((r) => r.items); - flat.sort((a, b) => rank(a.source) - rank(b.source) || b.score - a.score || a.ref.localeCompare(b.ref)); - return flat.map((it, i) => ({ id: `E${i + 1}`, ...it })); + host = host.toLowerCase(); + path = path.replace(/\.git$/, "").replace(/\/+$/, ""); + const segments = path.split("/").filter(Boolean); + const repo = segments.length ? segments[segments.length - 1] : void 0; + const owner = segments.length > 1 ? segments.slice(0, -1).join("/") : void 0; + const cloneUrl = /^https?:\/\//i.test(trimmed) || scp ? trimmed.replace(/\/+$/, "") : `https://${host}/${path}.git`; + const webUrl = `https://${host}/${path}`; + return { + raw: trimmed, + host, + owner, + repo, + cloneUrl: cloneUrl.endsWith(".git") ? cloneUrl : `${cloneUrl}.git`, + webUrl, + isLocal: false, + slug: slugify(`${host}/${path}`) + }; } -function renderEvidenceMarkdown(evidence, meta) { - const out = []; - out.push(`# Evidence dossier`); - out.push(""); - out.push(`**Idea:** ${meta.idea}`); - if (meta.query) out.push(`**Query:** ${meta.query}`); - out.push(`**Angles:** ${meta.angles.join(", ")} \xB7 **semantic:** ${meta.semantic ? "on" : "off"} \xB7 **built:** ${meta.builtAt}`); - out.push(""); - out.push( - `> Ground the SRD's requirements and decisions in this evidence. Cite items by id, e.g. \`[E1]\`. Grounding is advisory \u2014 \`construct check\` reports coverage but never fails on it. Still: prefer a cited claim to a guessed one.` - ); - out.push(""); - if (evidence.length === 0) { - out.push(`_No evidence was retrieved. Broaden the query, add angles, or check connectivity._`); +function ensureClone(ref, opts = {}) { + if (ref.isLocal) return resolve(ref.raw); + const dir = join3(cacheRoot(), ref.slug); + const alreadyCloned = existsSync3(join3(dir, ".git")); + if (alreadyCloned && !opts.refresh) return dir; + if (alreadyCloned && opts.refresh) { + sh("git", ["-C", dir, "fetch", "--depth", "1", "origin"], { timeoutMs: GIT_FETCH_TIMEOUT_MS }); + sh("git", ["-C", dir, "reset", "--hard", "FETCH_HEAD"], { timeoutMs: GIT_RESET_TIMEOUT_MS }); + return dir; } - for (const source of SOURCE_ORDER) { - const items = evidence.filter((e) => e.source === source); - if (items.length === 0) continue; - out.push(`## ${SOURCE_LABEL[source]}`); - out.push(""); - for (const it of items) { - out.push(`### [${it.id}] ${it.title}`); - const meta1 = [`ref: \`${it.ref}\``, it.location ? `loc: \`${it.location}\`` : "", `score: ${it.score}`].filter(Boolean).join(" \xB7 "); - out.push(meta1); - if (it.url) out.push(`url: ${it.url}`); - out.push(""); - out.push("```"); - out.push(it.snippet); - out.push("```"); - out.push(""); + mkdirSync3(cacheRoot(), { recursive: true }); + const args = ["clone", "--depth", "1", "--filter=blob:none"]; + if (opts.branch) args.push("--branch", opts.branch); + args.push(ref.cloneUrl, dir); + const res = sh("git", args, { timeoutMs: GIT_CLONE_TIMEOUT_MS }); + if (!res.ok) { + if (res.missing) { + throw new Error(`git is not installed or not on PATH \u2014 cannot clone ${ref.cloneUrl}`); } - } - if (meta.notes.length) { - out.push(`## Retrieval notes`); - out.push(""); - for (const n of meta.notes) out.push(`- ${n}`); - out.push(""); - } - return out.join("\n"); -} -function writeDossier(dir, evidence, meta) { - mkdirSync3(dir, { recursive: true }); - const evidenceJson = join5(dir, "evidence.json"); - const evidenceMd = join5(dir, "EVIDENCE.md"); - const metaJson = join5(dir, "meta.json"); - writeFileSync2(evidenceJson, JSON.stringify(evidence, null, 2)); - writeFileSync2(evidenceMd, renderEvidenceMarkdown(evidence, meta)); - writeFileSync2(metaJson, JSON.stringify(meta, null, 2)); - return { dir, evidenceJson, evidenceMd, metaJson }; -} - -// src/research/registry.ts -var HANDLERS = { - market: marketAngle, - oss: ossAngle, - tech: techAngle -}; -var ANGLE_SOURCE = { - market: "market", - oss: "oss", - tech: "docs" -}; -async function runAngles(ctx) { - const active = ctx.angles.filter((a) => a !== "semantic"); - const settled = await Promise.all( - active.map(async (a) => { + if (existsSync3(dir)) { try { - return await HANDLERS[a](ctx); + rmSync(dir, { recursive: true, force: true }); } catch (e) { - return [{ source: ANGLE_SOURCE[a], items: [], notes: [`${a} angle failed: ${e.message}`] }]; + throw new Error(`could not remove the partial clone at ${dir} before retrying: ${e.message} \u2014 delete it manually and re-run`); } - }) - ); - let results = settled.flat(); - const notes = []; - if (ctx.semantic || ctx.angles.includes("semantic")) { - const q = ctx.query || ctx.brief.idea; - const s = await semanticRescore(results, q); - results = s.results; - notes.push(...s.notes); + } + const fallback = sh("git", ["clone", "--depth", "1", ...opts.branch ? ["--branch", opts.branch] : [], ref.cloneUrl, dir], { + timeoutMs: GIT_CLONE_TIMEOUT_MS + }); + if (!fallback.ok) { + throw new Error( + [ + `git clone failed for ${ref.cloneUrl}`, + ` attempt 1 (--filter=blob:none): ${res.stderr.trim() || `exit ${res.status}`}`, + ` attempt 2 (no filter): ${fallback.stderr.trim() || `exit ${fallback.status}`}` + ].join("\n") + ); + } } - return { results, notes }; -} -async function runResearch(ctx, builtAt) { - const { results, notes } = await runAngles(ctx); - const capped = results.map((r) => ({ - ...r, - items: [...r.items].sort((a, b) => b.score - a.score).slice(0, ctx.perSource) - })); - const evidence = assignIds(capped); - const presentSources = [...new Set(evidence.map((e) => e.source))]; - const meta = { - idea: ctx.brief.idea, - angles: ctx.angles, - query: ctx.query || void 0, - sources: presentSources, - semantic: ctx.semantic || ctx.angles.includes("semantic"), - evidenceCount: evidence.length, - builtAt, - notes: [...capped.flatMap((r) => r.notes), ...notes] - }; - const dir = join6(ctx.runDir, "evidence"); - const paths = writeDossier(dir, evidence, meta); - return { dir, evidence, meta, paths }; + if (!existsSync3(dir) || readdirSync(dir).length === 0) { + throw new Error(`clone produced an empty tree at ${dir}`); + } + return dir; } -// src/render.ts -import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "fs"; -import { join as join9, dirname as dirname2 } from "path"; - -// src/srd.ts -import { join as join7 } from "path"; -function srdManifestPath(runDir) { - return join7(runDir, "SRD.json"); -} -function pad3(n) { - return String(n).padStart(3, "0"); -} -function pad4(n) { - return String(n).padStart(4, "0"); -} -var GROUND_REQUIREMENT = ["market", "oss", "docs", "so", "issue", "pr"]; -var GROUND_QUALITY = ["oss", "docs", "so", "issue", "pr"]; -function matchEvidence(text, evidence, n, onlySources) { - const kws = keywords(text).map((k) => k.toLowerCase()); - if (kws.length === 0) return []; - const need = Math.min(2, kws.length); - const ratioFloor = 0.34; - const scored = evidence.filter((e) => !onlySources || onlySources.includes(e.source)).map((e) => { - const hay = new Set(keywords(`${e.title} ${e.snippet}`).map((k) => k.toLowerCase())); - let cov = 0; - for (const kw of kws) if (hay.has(kw)) cov++; - return { id: e.id, key: e.url || `${e.source}:${e.ref}`, cov, ratio: cov / kws.length, score: e.score }; - }).filter((x) => x.cov >= need && x.ratio >= ratioFloor).sort((a, b) => b.cov - a.cov || b.ratio - a.ratio || b.score - a.score || a.id.localeCompare(b.id)); - const seen = /* @__PURE__ */ new Set(); - const out = []; - for (const x of scored) { - if (seen.has(x.key)) continue; - seen.add(x.key); - out.push(x.id); - if (out.length >= n) break; - } - return out; -} -var NFR_SIGNALS = { - privacy: /privac|gdpr|personal data|consent|self[- ]?host|own (your|the) data|no account/i, - accessibility: /accessib|a11y|screen reader|wcag|keyboard/i, - security: /auth|login|password|secret|token|encrypt|credential|account/i, - performance: /fast|latenc|speed|sub-?second|under \d+ ?(s|sec|second|ms|minute)/i, - reliability: /reliab|availab|recover|double-?book|never|busy|conflict|sync/i, - observability: /log|metric|trace|monitor|audit/i, - usability: /usab|onboard|guest|no account|widget|embed|reminder/i, - cost: /cost|budget|cheap|self[- ]?host/i, - i18n: /locale|i18n|timezone|language|translat/i -}; -var INTEGRATION_RE = /\b(?:calendar|caldav|google|ical|ics|sync|webhook|email|smtp|sms|widget|iframe|embed|oauth|payment|api)s?\b/i; -var PERSIST_RE = /persist|store|database|datastore|save|record|booking|event|schedul|inventory|history/i; -var NFR_TEMPLATES = { - performance: { - statement: "The system responds to primary user actions without perceptible delay under expected load.", - metric: "p95 latency < 300 ms for core interactions at expected concurrency." - }, - security: { - statement: "User data and credentials are protected in transit and at rest, with least-privilege access.", - metric: "All endpoints authenticated/authorized; secrets never logged; dependencies scanned in CI." - }, - reliability: { - statement: "The system degrades gracefully and recovers from transient failures without data loss.", - metric: "Monthly availability \u2265 99.9%; no data loss on a single-node failure." - }, - usability: { - statement: "A new user can complete the primary task without external help.", - metric: "\u2265 80% task-completion rate in unmoderated usability testing." - }, - observability: { - statement: "Operators can diagnose failures from logs, metrics and traces without reproducing locally.", - metric: "Structured logs + metrics on every request; alert on error-rate and latency SLO breach." - }, - cost: { - statement: "Running cost scales sub-linearly with usage and stays within the stated budget.", - metric: "Cost per active user tracked; infra cost within the budget constraint." - }, - scalability: { - statement: "The system scales horizontally to handle growth without re-architecture.", - metric: "Throughput scales near-linearly to 10\xD7 the launch load." - }, - accessibility: { - statement: "The interface is usable with assistive technology and meets recognised accessibility guidelines.", - metric: "WCAG 2.1 AA conformance on primary flows." - }, - privacy: { - statement: "Personal data is collected lawfully, minimised, and removable on request.", - metric: "Data-retention policy enforced; export/delete available for user data." - }, - i18n: { - statement: "The product supports multiple locales without code changes.", - metric: "All user-facing copy externalised; locale switch covers core flows." - }, - maintainability: { - statement: "The codebase is testable, documented, and changeable by a new contributor.", - metric: "Test coverage gate in CI; onboarding to first PR within a day." - } -}; -function nfrFor(category) { - const key = category.toLowerCase().trim(); - return NFR_TEMPLATES[key] ?? { - statement: `The system meets the "${category}" quality expectation defined for this product.`, - metric: `A measurable target for "${category}" is agreed and tracked.` - }; -} -function priorityOf(p) { - return p === "must" || p === "should" || p === "could" ? p : "should"; -} -var FEATURE_VERBS = /* @__PURE__ */ new Set([ - "create", - "add", - "manage", - "book", - "view", - "send", - "track", - "sync", - "edit", - "delete", - "list", - "share", - "export", - "import", - "search", - "save", - "read", - "tag", - "organize", - "organise", - "schedule", - "upload", - "download", - "browse", - "filter", - "sort", - "archive", - "publish", - "invite", - "assign", - "stream" +// src/walk.ts +import { readdirSync as readdirSync2, lstatSync, readFileSync as readFileSync3 } from "fs"; +import { join as join4, relative, sep, extname } from "path"; +var IGNORE_DIRS = /* @__PURE__ */ new Set([ + ".git", + "node_modules", + ".pnpm", + "bower_components", + "vendor", + "dist", + "build", + "out", + "target", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + "coverage", + "__pycache__", + ".venv", + "venv", + ".tox", + ".mypy_cache", + ".pytest_cache", + ".gradle", + ".idea", + ".vscode", + ".cache", + "tmp", + ".construct", + "Pods", + "DerivedData", + ".terraform", + "elm-stuff", + ".dart_tool" ]); -var NON_ENTITY_WORDS = /* @__PURE__ */ new Set(["search", "login", "signup", "support", "setup", "offline", "online", "mobile", "desktop", "full", "text", "user", "users"]); -function singularize(w) { - if (/ies$/.test(w)) return w.slice(0, -3) + "y"; - if (/(?:ches|shes|xes|zes|ses)$/.test(w)) return w.slice(0, -2); - if (/s$/.test(w) && !/(?:ss|us|is)$/.test(w)) return w.slice(0, -1); - return w; -} -function titleCase(w) { - return w ? w[0].toUpperCase() + w.slice(1) : w; -} -function entityTokens(title, exclude) { - const words = keywords(title).map((w) => w.toLowerCase()); - const verbLed = words.length > 0 && FEATURE_VERBS.has(words[0]); - const rest = verbLed ? words.slice(1) : words; - const tokens = rest.filter((w) => w.length >= 3 && !FEATURE_VERBS.has(w) && !NON_ENTITY_WORDS.has(w) && !/(?:ed|ing)$/.test(w)).map(singularize).filter((w) => !exclude.has(w)); - return { tokens, verbLed }; -} -function inferEntities(brief, functional) { - const exclude = new Set( - [...brief.competitors, ...brief.candidateTech, brief.product.name ?? ""].flatMap((s) => keywords(s).map((w) => singularize(w.toLowerCase()))) - ); - const perFr = functional.map((fr) => ({ fr, ...entityTokens(fr.title, exclude) })); - const freq = /* @__PURE__ */ new Map(); - for (const p of perFr) for (const t of new Set(p.tokens)) freq.set(t, (freq.get(t) ?? 0) + 1); - const chosen = /* @__PURE__ */ new Set(); - for (const [t, n] of freq) if (n >= 2) chosen.add(t); - for (const p of perFr) { - if (p.verbLed && p.fr.priority === "must" && p.tokens[0]) chosen.add(p.tokens[0]); - } - const names = [...chosen].sort((a, b) => freq.get(b) - freq.get(a) || a.localeCompare(b)).slice(0, 8); - const entities = names.map((n) => { - const name = titleCase(n); - const refs = perFr.filter((p) => p.tokens.includes(n)).map((p) => p.fr.id); - return { - name, - attributes: [ - { name: "id", type: "identifier" }, - { name: "createdAt", type: "timestamp" } - ], - referencedByFRs: refs - }; - }); - for (const fr of functional) { - fr.entities = entities.filter((e) => e.referencedByFRs.includes(fr.id)).map((e) => e.name); - } - return entities; -} -var BOUNDARY_DEFS = [ - // Word boundaries matter: a bare /ical|ics/ substring-matches "historical" - // and "metrics", /stripe/ matches "pinstripe", /embed/ matches "Embedded" and - // /google/ matches "googled" — each hallucinating an integration into an - // unrelated product. Every token is bounded (optional plural) for that reason. - { re: /\b(?:calendar|caldav|ical|ics)\b/i, label: "calendar systems (CalDAV/iCal)", name: "Calendar Integration", kind: "api" }, - { re: /\bgoogles?\b/i, label: "Google APIs", name: "Google API Integration", kind: "api" }, - { re: /\b(?:email|smtp)s?\b/i, label: "an email/SMTP provider", name: "Email Delivery", kind: "api" }, - { re: /\b(?:sms|twilio)s?\b/i, label: "an SMS provider", name: "SMS Delivery", kind: "api" }, - { re: /\b(?:widget|iframe|embed)s?\b/i, label: "external host sites (embed/iframe)", name: "Embeddable Widget", kind: "ui" }, - { re: /\b(?:payment|stripe|billing)s?\b/i, label: "a payments provider", name: "Payments Integration", kind: "api" }, - { re: /\bwebhooks?\b/i, label: "outbound webhooks", name: "Outbound Webhooks", kind: "event" }, - { re: /\b(?:browser extension|chrome extension|firefox add-?on)s?\b/i, label: "a browser extension", name: "Browser Extension", kind: "ui" } -]; -function boundaryHaystack(brief) { - return `${brief.idea} ${brief.candidateTech.join(" ")} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; -} -function detectBoundaries(brief) { - const haystack = boundaryHaystack(brief); - return BOUNDARY_DEFS.filter((b) => b.re.test(haystack)); -} -function inferInterfaces(brief, functional) { +var LOCKFILES = /* @__PURE__ */ new Set([ + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lockb", + "composer.lock", + "cargo.lock", + "poetry.lock", + "pipfile.lock", + "gemfile.lock", + "go.sum", + "flake.lock", + "packages.lock.json", + "podfile.lock", + "mix.lock" +]); +var BINARY_EXT = /* @__PURE__ */ new Set([ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".ico", + ".icns", + ".svg", + ".pdf", + ".zip", + ".gz", + ".tar", + ".tgz", + ".bz2", + ".xz", + ".7z", + ".rar", + ".jar", + ".war", + ".class", + ".so", + ".dylib", + ".dll", + ".exe", + ".bin", + ".o", + ".a", + ".wasm", + ".woff", + ".woff2", + ".ttf", + ".otf", + ".eot", + ".mp3", + ".mp4", + ".mov", + ".avi", + ".webm", + ".wav", + ".flac", + ".ogg", + ".lock", + ".min.js", + ".map" +]); +function walk(root, opts = {}) { + const maxFileBytes = opts.maxFileBytes ?? 1024 * 1024; + const maxFiles = opts.maxFiles ?? 2e4; const out = []; - for (const b of detectBoundaries(brief)) { - const related = functional.filter((fr) => b.re.test(`${fr.title} ${fr.description}`)).map((fr) => fr.id); - out.push({ - name: b.name, - kind: b.kind, - summary: `Boundary with ${b.label}. Define the contract (operations, data, failure modes) during authoring.`, - relatedFRs: related - }); - } - if (brief.product.users?.length) { - out.push({ - name: "Web App", - kind: "ui", - summary: `The primary user-facing surface through which ${brief.product.users.join(", ")} use the product.`, - relatedFRs: functional.map((f) => f.id) - }); - } - for (const fr of functional) { - fr.interfaces = out.filter((i) => i.relatedFRs.includes(fr.id)).map((i) => i.name); - } - return out; -} -function buildSRD(brief, evidence, opts) { - const level = opts.level; - const productName = brief.product.name || titleFromIdea(brief.idea); - const compliance = brief.constraints.compliance ?? []; - const selfHost = /self[- ]?host|privacy|gdpr|own (your|the) data/i.test(`${brief.idea} ${brief.product.valueProp ?? ""}`) || compliance.length > 0; - const timeGoal = timeTokenFromGoals(brief.goals); - const categories = []; - for (const c of REQUIRED_NFR[level]) if (!categories.includes(c)) categories.push(c); - for (const c of brief.nfrPriorities) { - const k = c.toLowerCase().trim(); - if (k && !categories.includes(k)) categories.push(k); - } - const nonFunctional = categories.map((cat, i) => { - const t = nfrFor(cat); - const metric = specialiseMetric(cat, t.metric, { compliance, selfHost, timeGoal, budget: brief.constraints.budget }); - const statement = specialiseStatement(cat, t.statement, { compliance, selfHost }); - return { - id: `NFR-${pad3(i + 1)}`, - category: cat, - statement, - metric, - // Ground over the *specialised* text + distinctive brief facts (CalDAV, - // GDPR…), restricted to authoritative sources (no marketing pages). - rationaleEvidence: matchEvidence(`${cat} ${statement} ${brief.candidateTech.join(" ")} ${compliance.join(" ")}`, evidence, 1, GROUND_QUALITY) - }; - }); - const coreNfrIds = nonFunctional.filter((n) => REQUIRED_NFR.light.includes(n.category.toLowerCase())).map((n) => n.id); - const adrs = []; - const stack = brief.candidateTech.length ? brief.candidateTech.join(", ") : "a stack to be selected"; - adrs.push({ - id: "", - title: "Primary technology stack", - status: brief.candidateTech.length ? "accepted" : "proposed", - context: `Building "${productName}" requires a stack that fits the team (${brief.constraints.team || "to be defined"}) and timeline (${brief.constraints.timeline || "to be defined"}).`, - decision: `Adopt ${stack} as the primary stack for the initial build.`, - consequences: `The team commits to ${stack}; hiring, tooling and operational knowledge align to it. Revisit if a hard requirement is unmet.`, - alternatives: brief.candidateTech.length ? "No explicit alternative stack was provided in the brief; evaluate one comparable option before locking this in." : "Alternative stacks were considered but not selected.", - evidence: matchEvidence(`${stack} architecture stack`, evidence, 2, ["docs", "oss", "so"]) - }); - if (selfHost) { - adrs.push({ - id: "", - title: "Self-hosting and data-ownership model", - status: "accepted", - context: `"${productName}" is positioned as privacy-first / self-hostable${compliance.length ? ` and must satisfy: ${compliance.join(", ")}` : ""}.`, - decision: "Ship as a self-hostable deployment where the host owns all data; no user data is sent to a third-party service by default.", - consequences: "Data residency and compliance become the host's responsibility (a feature, not a liability); the product must run with no mandatory external dependencies and document its data flows.", - alternatives: "A hosted multi-tenant SaaS was considered but rejected as it conflicts with the privacy/data-ownership value proposition.", - evidence: matchEvidence(`self-host privacy data ownership ${compliance.join(" ")}`, evidence, 2, GROUND_QUALITY) - }); - } - const integrates = brief.featureWishlist.some((f) => INTEGRATION_RE.test(`${f.title} ${f.notes ?? ""}`)) || INTEGRATION_RE.test(brief.idea); - if (level === "complex" && (PERSIST_RE.test(briefText(brief)) || integrates)) { - adrs.push({ - id: "", - title: "Data persistence and integration approach", - status: "proposed", - context: `"${productName}" must persist state and integrate with external services (${brief.candidateTech.filter((t) => INTEGRATION_RE.test(t)).join(", ") || "calendar/email and similar"}) reliably.`, - decision: "Use a single primary datastore with explicit, versioned integration boundaries for each external service.", - consequences: "A clear data-ownership model; integrations are testable in isolation behind an adapter. Cross-service consistency must be designed explicitly.", - alternatives: "A polyglot-persistence or event-sourced approach was considered; deferred until scale demands it.", - evidence: matchEvidence(`${brief.candidateTech.join(" ")} database persistence integration`, evidence, 2, ["docs", "oss", "so"]) - }); - } - adrs.forEach((a, i) => a.id = pad4(i + 1)); - const stackAdrId = adrs[0].id; - const dataAdr = adrs.find((a) => /persistence|integration/i.test(a.title)); - const privacyAdr = adrs.find((a) => /self-hosting|data-ownership/i.test(a.title)); - const functional = brief.featureWishlist.map((f, i) => { - const priority = priorityOf(f.priority); - const text = `${f.title} ${f.notes ?? ""}`; - const touchesIntegration = INTEGRATION_RE.test(text); - const outcome = concreteOutcome(f.title, f.notes); - const acceptance = [ - { - given: `${productName} is available to a user`, - when: `they ${lowerFirst(f.title)}`, - then: outcome - }, - ...level === "complex" ? [failurePath(f.title, touchesIntegration)] : [] - ]; - const nfrs = [...coreNfrIds]; - for (const n of nonFunctional) { - if (coreNfrIds.includes(n.id)) continue; - const sig = NFR_SIGNALS[n.category.toLowerCase()]; - if (sig?.test(text)) nfrs.push(n.id); + const stack = [root]; + while (stack.length) { + if (out.length >= maxFiles) break; + const dir = stack.pop(); + let entries; + try { + entries = readdirSync2(dir); + } catch { + continue; + } + for (const name of entries) { + if (out.length >= maxFiles) break; + const abs = join4(dir, name); + let st; + try { + st = lstatSync(abs); + } catch { + continue; + } + if (st.isDirectory()) { + if (IGNORE_DIRS.has(name)) continue; + stack.push(abs); + continue; + } + if (!st.isFile()) continue; + if (st.size > maxFileBytes) continue; + if (LOCKFILES.has(name.toLowerCase())) continue; + const ext = extname(name).toLowerCase(); + if (BINARY_EXT.has(ext)) continue; + if (name.endsWith(".min.js") || name.endsWith(".min.css")) continue; + out.push({ rel: relative(root, abs).split(sep).join("/"), abs, size: st.size, ext }); } + } + return out; +} +function readText(abs) { + try { + const buf = readFileSync3(abs); + const head = buf.subarray(0, 4096); + if (head.includes(0)) return ""; + return buf.toString("utf8"); + } catch { + return ""; + } +} + +// src/providers/github.ts +function toItems(raw, kind) { + return (raw ?? []).filter((it) => it && typeof it === "object").map((it) => { + const body = String(it.body ?? "").replace(/\r/g, "").trim().slice(0, 1200); + const labels = (it.labels ?? []).map((l) => typeof l === "string" ? l : l.name).filter(Boolean).join(", "); + const state = it.draft ? "draft" : it.state; return { - id: `FR-${pad3(i + 1)}`, - title: f.title, - description: f.notes?.trim() || `The product lets a user ${lowerFirst(f.title)}.`, - priority, - acceptance, - rationaleEvidence: matchEvidence(text, evidence, 2, GROUND_REQUIREMENT), - entities: [], - interfaces: [], - nfrs, - unresolved: false, - ...f.module ? { module: f.module } : {} + source: kind, + title: `#${it.number} ${it.title} [${state}]`, + ref: `${kind}#${it.number}`, + location: it.html_url, + score: Number(it.score ?? 0), + snippet: `state: ${state}` + (labels ? ` \xB7 labels: ${labels}` : "") + ` \xB7 comments: ${it.comments ?? 0} \xB7 updated: ${it.updated_at ?? "?"} + +` + (body || "(no description)"), + url: it.html_url, + meta: { number: it.number, state, isPR: !!it.pull_request } }; }); - const modules = brief.modules?.length ? brief.modules.map((m) => ({ - id: m.id, - name: m.name, - ...m.description ? { description: m.description } : {}, - frIds: functional.filter((f) => f.module === m.id).map((f) => f.id), - dependsOn: m.dependsOn ?? [] - })) : void 0; - const dataModel = inferEntities(brief, functional); - const interfaces = inferInterfaces(brief, functional); - const evById = new Map(evidence.map((e) => [e.id, e])); - const competitors = brief.competitors.map((name) => { - const ev = matchEvidence(name, evidence, 2, ["market"]); - return { name, note: noteFrom(ev, evById) || `Comparable product / alternative to "${productName}".`, evidence: ev }; - }); - const ossByKey = /* @__PURE__ */ new Map(); - const keyOf = (s) => { - try { - return resolveRepo(s).slug; - } catch { - return s.toLowerCase(); - } +} +function apiBase(host) { + return /(^|\.)github\.com$/i.test(host) ? "https://api.github.com" : `https://${host}/api/v3`; +} +function ghUsable(host) { + return have("gh") && /(^|\.)github\.com$/i.test(host); +} +var canonCache = /* @__PURE__ */ new Map(); +async function canonicalRepo(ref) { + const fallback = { owner: ref.owner, repo: ref.repo }; + if (!/github/i.test(ref.host)) return fallback; + const key = `${ref.host}/${ref.owner}/${ref.repo}`; + const cached = canonCache.get(key); + if (cached) return cached; + let resolved = fallback; + const parse = (full) => { + const i = full.indexOf("/"); + return i > 0 ? { owner: full.slice(0, i), repo: full.slice(i + 1) } : fallback; }; - for (const seed of brief.ossSeeds) { - const ref = resolveRepo(seed); - const label = ref.owner && ref.repo ? `${ref.owner}/${ref.repo}` : seed; - const ev = matchEvidence(`${ref.owner ?? ""} ${ref.repo ?? ""}`.trim() || seed, evidence, 2, ["oss", "issue", "pr"]); - ossByKey.set(keyOf(seed), { - name: label, - url: ref.webUrl ?? (/^https?:/.test(seed) ? seed : void 0), - note: noteFrom(ev, evById) || "Seed OSS project mined for prior art.", - evidence: ev - }); + if (ghUsable(ref.host)) { + const r = sh("gh", ["api", `repos/${ref.owner}/${ref.repo}`, "--jq", ".full_name"]); + if (r.ok && r.stdout.includes("/")) resolved = parse(r.stdout.trim()); + } else { + const r = await httpGet(`${apiBase(ref.host)}/repos/${ref.owner}/${ref.repo}`, { accept: "application/vnd.github+json" }); + if (r.ok) { + try { + const full = JSON.parse(r.body)?.full_name; + if (typeof full === "string" && full.includes("/")) resolved = parse(full); + } catch { + } + } } - for (const e of evidence.filter((x) => x.source === "oss")) { - const k = keyOf(e.ref); - if (ossByKey.has(k)) { - if (!ossByKey.get(k).evidence.includes(e.id)) ossByKey.get(k).evidence.push(e.id); - continue; + canonCache.set(key, resolved); + return resolved; +} +async function query(ref, terms, kind, perSource) { + const q = `repo:${ref.owner}/${ref.repo} type:${kind} ${terms.join(" ")}`.trim(); + if (ghUsable(ref.host)) { + const res = sh("gh", ["api", "-X", "GET", "search/issues", "-f", `q=${q}`, "-f", `per_page=${perSource}`, "-f", "sort=updated", "-f", "order=desc"]); + if (res.ok) { + try { + return { items: toItems(JSON.parse(res.stdout).items, kind) }; + } catch { + } } - ossByKey.set(k, { - name: e.title.replace(/ —.*$/, ""), - url: e.url, - note: firstSentence(e.snippet) || "Comparable open-source project (prior art).", - evidence: [e.id] - }); } - const oss = [...ossByKey.values()]; - const buildPlan = buildMilestones(functional, brief, evidence, evById); - const design = opts.design ? buildDesignSystem(brief, functional) : void 0; - const traceability = functional.map((fr) => { - const text = `${fr.title} ${fr.description}`; - const adrIds = [stackAdrId]; - if (dataAdr && (PERSIST_RE.test(text) || INTEGRATION_RE.test(text))) adrIds.push(dataAdr.id); - if (privacyAdr && NFR_SIGNALS.privacy.test(text)) adrIds.push(privacyAdr.id); - const row = { fr: fr.id, nfrs: fr.nfrs, adrs: adrIds, entities: fr.entities, interfaces: fr.interfaces }; - if (design) { - row.components = design.components.filter((c) => c.relatedFRs.includes(fr.id)).map((c) => c.name); - row.screens = design.screens.filter((s) => s.relatedFRs.includes(fr.id)).map((s) => s.name); + const url = `${apiBase(ref.host)}/search/issues?q=${encodeURIComponent(q)}&per_page=${perSource}&sort=updated&order=desc`; + const r = await httpGet(url, { accept: "application/vnd.github+json" }); + if (!r.ok) { + const hint = r.status === 422 ? `query rejected (422) for repo:${ref.owner}/${ref.repo} \u2014 the repo may be moved/renamed/private, or the query had no valid terms` : `status ${r.status}; run \`gh auth login\` for higher-rate access`; + return { items: [], error: `GitHub ${kind} search unavailable (${hint}).` }; + } + try { + return { items: toItems(JSON.parse(r.body).items, kind) }; + } catch { + return { items: [], error: `GitHub ${kind} search returned an unparseable response.` }; + } +} +var github = { + name: "github", + matches: (host) => /(^|\.)github\.com$/i.test(host) || /github/i.test(host), + async search(ref0, question, kind, perSource) { + if (!ref0.owner || !ref0.repo) { + return { items: [], notes: ["No owner/repo resolved; cannot query GitHub issues/PRs."] }; } - if (fr.module) row.module = fr.module; - return row; - }); - const referenced = /* @__PURE__ */ new Set(); - for (const fr of functional) fr.rationaleEvidence.forEach((id) => referenced.add(id)); - for (const n of nonFunctional) n.rationaleEvidence.forEach((id) => referenced.add(id)); - for (const a of adrs) a.evidence.forEach((id) => referenced.add(id)); - for (const c of competitors) c.evidence.forEach((id) => referenced.add(id)); - for (const o of oss) o.evidence.forEach((id) => referenced.add(id)); - for (const m of buildPlan) (m.risks ?? []).forEach((r) => citationsIn(r).forEach((id) => referenced.add(id))); - const evidenceIndex = [...referenced].sort((a, b) => evNum(a) - evNum(b)); - return { - schemaVersion: SRD_SCHEMA_VERSION, - level, - generatedAt: opts.generatedAt, - product: { - name: productName, - problem: brief.product.problem || brief.goals[0] || `Address the need described by: ${brief.idea}`, - valueProp: brief.product.valueProp || `Deliver ${brief.idea} better than existing options.`, - users: brief.product.users?.length ? brief.product.users : ["primary user"], - metrics: brief.goals.length ? brief.goals : ["Define a measurable launch success metric."] - }, - scope: { - inScope: brief.featureWishlist.filter((f) => priorityOf(f.priority) !== "could").map((f) => f.title), - outOfScope: brief.nonGoals, - assumptions: deriveAssumptions(brief) - }, - functional, - ...modules ? { modules } : {}, - nonFunctional, - architecture: { context: contextProse(productName, brief), dataModel, interfaces, adrs }, - competitive: { competitors, oss }, - buildPlan, - traceability, - openQuestions: brief.openQuestions, - evidenceIndex, - ...design ? { design } : {} + const canon = await canonicalRepo(ref0); + const ref = { ...ref0, owner: canon.owner, repo: canon.repo }; + const ranked = rankedKeywords(question); + if (ranked.length === 0) return { items: [], notes: [`No keywords to search ${kind}s.`] }; + let lastError; + for (const terms of uniqueAttempts([ranked.slice(0, 3), ranked.slice(0, 2)])) { + const { items, error } = await query(ref, terms, kind, perSource * 2); + if (error) lastError = error; + if (items.length) return { items: rerank(items, ranked).slice(0, perSource), notes: [] }; + } + const seen = /* @__PURE__ */ new Map(); + for (const t of ranked.slice(0, 4)) { + const { items, error } = await query(ref, [t], kind, perSource * 2); + if (error) lastError = error; + for (const it of items) if (!seen.has(it.ref)) seen.set(it.ref, it); + } + const merged = rerank([...seen.values()], ranked).slice(0, perSource); + if (merged.length) return { items: merged, notes: [] }; + return { items: [], notes: lastError ? [lastError] : [`No ${kind}s matched the question.`] }; + } +}; +function rerank(items, ranked) { + const terms = ranked.map((t) => t.toLowerCase()); + const coverage = (it) => { + const hay = `${it.title} ${it.snippet}`.toLowerCase(); + let c = 0; + for (const t of terms) if (hay.includes(t)) c++; + return c; }; + return items.map((it) => ({ it, c: coverage(it), s: it.score })).sort((a, b) => b.c - a.c || b.s - a.s).map((x) => x.it); } -function deriveA11yStandard(brief) { - const explicit = brief.design?.accessibilityTarget?.trim(); - if (explicit) return explicit; - const hay = `${(brief.constraints.compliance ?? []).join(" ")} ${brief.nfrPriorities.join(" ")}`.toLowerCase(); - if (/\brgaa\b/.test(hay)) return "RGAA 4.1 (aligned to WCAG 2.2 AA)"; - if (/\b508\b|section 508/.test(hay)) return "Section 508 (WCAG 2.0 AA)"; - if (/en\s?301\s?549/.test(hay)) return "EN 301 549 (WCAG 2.1 AA)"; - return "WCAG 2.2 AA"; +function uniqueAttempts(lists) { + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const l of lists) { + const key = l.join("\0"); + if (l.length && !seen.has(key)) { + seen.add(key); + out.push(l); + } + } + return out; } -function buildPrinciples(brief) { - const hay = `${brief.idea} ${brief.product.valueProp ?? ""} ${brief.product.problem ?? ""} ${brief.nfrPriorities.join(" ")} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; - const out = []; - if (/self[- ]?host|privac|gdpr|own (your|the) data|no account/i.test(hay)) { - out.push("Privacy by default \u2014 the UI never surfaces or transmits data the user did not choose to share."); + +// src/providers/gitlab.ts +var gitlab = { + name: "gitlab", + matches: (host) => /gitlab/i.test(host), + async search(ref, question, kind, perSource) { + if (!ref.owner || !ref.repo) { + return { items: [], notes: ["No project path resolved; cannot query GitLab issues/MRs."] }; + } + const kw = rankedKeywords(question).slice(0, 4); + if (kw.length === 0) { + return { items: [], notes: [`No keywords to search GitLab ${kind === "issue" ? "issues" : "merge requests"}.`] }; + } + const proj = encodeURIComponent(`${ref.owner}/${ref.repo}`); + const path = kind === "issue" ? "issues" : "merge_requests"; + const search = encodeURIComponent(kw.join(" ")); + const url = `https://${ref.host}/api/v4/projects/${proj}/${path}?search=${search}&per_page=${perSource}&order_by=updated_at&sort=desc`; + const r = await httpGet(url, { accept: "application/json" }); + if (!r.ok) { + return { items: [], notes: [`GitLab ${kind} search unavailable (status ${r.status}).`] }; + } + try { + const arr = JSON.parse(r.body); + if (!Array.isArray(arr)) return { items: [], notes: [`GitLab ${kind} search returned no array.`] }; + const marker = kind === "issue" ? "#" : "!"; + const items = arr.filter((it) => it && typeof it === "object").map((it) => { + const num = it.iid ?? it.id; + const body = String(it.description ?? "").replace(/\r/g, "").trim().slice(0, 1200); + return { + source: kind, + title: `${marker}${num} ${it.title} [${it.state}]`, + ref: `${kind}#${num}`, + location: it.web_url, + score: 0, + snippet: `state: ${it.state} \xB7 updated: ${it.updated_at ?? "?"} + +${body || "(no description)"}`, + url: it.web_url, + meta: { iid: num, state: it.state } + }; + }); + return { items, notes: [] }; + } catch { + return { items: [], notes: [`GitLab ${kind} search returned an unparseable response.`] }; + } } - if (/fast|speed|sub-?second|latenc|instant|under \d/i.test(hay)) { - out.push("Perceived performance first \u2014 optimistic UI, skeletons over spinners, immediate feedback on every action."); +}; + +// src/providers/generic.ts +var generic = { + name: "generic", + matches: () => true, + async search(ref, _question, kind) { + return { + items: [], + notes: [`No public ${kind} API for host "${ref.host}". The code was cloned and indexed; issues/PRs are not retrievable for this host.`] + }; } - out.push("Accessible to everyone \u2014 every flow works with the keyboard and assistive technology, by construction."); - out.push("Consistency over novelty \u2014 reuse tokens and components before inventing new ones."); - out.push("Progressive disclosure \u2014 show the essential first; reveal complexity only on demand."); - out.push("Clear over clever \u2014 plain language, obvious affordances, honest empty and error states."); - return out.slice(0, 5); +}; + +// src/providers/registry.ts +var PROVIDERS = [github, gitlab]; +function providerFor(host) { + return PROVIDERS.find((p) => p.matches(host)) ?? generic; } -function seedTokens(brief) { - const brand = brief.design?.brandConstraints?.trim(); - const byCategory = { - color: [ - { category: "color", name: "color.bg", value: "#ffffff", note: brand ? `Adjust to brand: ${brand}` : "Primary surface" }, - { category: "color", name: "color.fg", value: "#111827", note: "Primary text" }, - { category: "color", name: "color.primary", value: "#2563eb", note: "Primary action / brand accent" }, - { category: "color", name: "color.danger", value: "#dc2626", note: "Destructive / error" }, - { category: "color", name: "color.muted", value: "#6b7280", note: "Secondary text / borders" } - ], - typography: [ - { category: "typography", name: "font.sans", value: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }, - { category: "typography", name: "font.mono", value: "ui-monospace, SFMono-Regular, Menlo, monospace" }, - { category: "typography", name: "scale.body", value: "16px / 1.5" }, - { category: "typography", name: "scale.h1", value: "32px / 1.25" }, - { category: "typography", name: "scale.small", value: "13px / 1.4" } - ], - spacing: [ - { category: "spacing", name: "space.1", value: "4px" }, - { category: "spacing", name: "space.2", value: "8px" }, - { category: "spacing", name: "space.3", value: "12px" }, - { category: "spacing", name: "space.4", value: "16px" }, - { category: "spacing", name: "space.6", value: "24px" }, - { category: "spacing", name: "space.8", value: "32px" } - ], - radius: [ - { category: "radius", name: "radius.sm", value: "4px" }, - { category: "radius", name: "radius.md", value: "8px" }, - { category: "radius", name: "radius.lg", value: "12px" } - ], - elevation: [ - { category: "elevation", name: "shadow.sm", value: "0 1px 2px rgba(0,0,0,0.06)" }, - { category: "elevation", name: "shadow.md", value: "0 4px 12px rgba(0,0,0,0.10)" } - ], - motion: [ - { category: "motion", name: "motion.fast", value: "120ms ease-out" }, - { category: "motion", name: "motion.base", value: "200ms ease-out" } - ] - }; - return DESIGN_TOKEN_CATEGORIES.flatMap((c) => byCategory[c]); + +// src/research/oss.ts +var NON_REPO_OWNERS = /* @__PURE__ */ new Set([ + "topics", + "search", + "collections", + "trending", + "explore", + "marketplace", + "sponsors", + "features", + "about", + "pricing", + "login", + "join", + "signup", + "settings", + "notifications", + "issues", + "pulls", + "orgs", + "apps", + "blog", + "site", + "enterprise", + "customer-stories", + "security", + "readme", + "events", + "dashboard", + "groups", + "users", + "help", + "projects", + "-" +]); +function canonicalRepoUrl(url) { + const m = /^(https?:\/\/(?:github|gitlab)\.com\/([A-Za-z0-9._-]+)\/[A-Za-z0-9._-]+)/i.exec(url); + if (!m || NON_REPO_OWNERS.has(m[2].toLowerCase())) return void 0; + return m[1].replace(/\.git$/, ""); } -var COMPONENT_DEFS = [ - { name: "App Shell & Navigation", purpose: "Overall layout, navigation and routing chrome that frames every screen.", re: /.*/ }, - { name: "Button & Actions", purpose: "Primary, secondary and destructive action controls with loading/disabled states.", re: /.*/ }, - { - name: "Form & Input", - purpose: "Labelled inputs with inline validation and accessible error messaging.", - re: /save|add|create|edit|import|tag|organi[sz]e|login|sign|submit|upload|compose|write|configure|invite/i - }, - { - name: "List & Collection", - purpose: "Paginated/virtualised lists of saved items with selection and bulk actions.", - re: /list|search|browse|organi[sz]e|tag|feed|library|archive|history|result|collection|inbox/i - }, - { name: "Detail View", purpose: "The focused reading/detail surface for a single item.", re: /read|view|open|detail|article|item|show|preview|document/i }, - { name: "Search & Filter", purpose: "Query input, filters and ranked results with empty/no-match handling.", re: /search|filter|find|query|sort|facet/i }, - { name: "Feedback & Notifications", purpose: "Toasts, banners and inline status for success, error and async progress.", re: /.*/ }, - { name: "Empty & Error States", purpose: "First-run, no-data and failure states that teach the next action.", re: /.*/ } -]; -function buildComponents(functional) { - const out = []; - for (const def of COMPONENT_DEFS) { - const relatedFRs = functional.filter((fr) => def.re.test(`${fr.title} ${fr.description}`)).map((fr) => fr.id); - if (relatedFRs.length === 0) continue; - out.push({ name: def.name, purpose: def.purpose, states: [...COMPONENT_STATES], relatedFRs, evidence: [] }); +function normalizeSeed(raw) { + const s = raw.trim(); + if (!s) return void 0; + if (/^https?:\/\/github\.com\//i.test(s)) return canonicalRepoUrl(s); + const ref = resolveRepo(s); + return ref.isLocal || ref.owner && ref.repo ? s : void 0; +} +function languageHistogram(files) { + const counts = /* @__PURE__ */ new Map(); + for (const f of files) { + const ext = f.ext.replace(/^\./, ""); + if (!ext) continue; + counts.set(ext, (counts.get(ext) ?? 0) + 1); } - return out; + return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); } -function buildScreens(functional) { - const inScope = functional.filter((fr) => fr.priority !== "could"); - const mustIds = functional.filter((fr) => fr.priority === "must").map((fr) => fr.id); - const screens = [ - { name: "Home / Dashboard", purpose: "The landing surface after sign-in; entry point to the primary tasks.", relatedFRs: mustIds } - ]; - for (const fr of inScope) { - screens.push({ name: `${fr.title}`, purpose: `Where a user can ${lowerFirst(fr.title)}.`, relatedFRs: [fr.id] }); +async function ossAngle(ctx) { + const notes = []; + let seeds = [...new Set(ctx.brief.ossSeeds.map(normalizeSeed).filter((x) => !!x))]; + if (seeds.length === 0) { + const q2 = `${ctx.query || ctx.brief.idea} open source github`; + const d = await discover(q2, ctx.webEngine, ctx.perSource); + notes.push(`OSS discovery via ${d.via} for "${q2}".`, ...d.notes); + seeds = [...new Set(d.urls.map(canonicalRepoUrl).filter((x) => !!x))]; + } + seeds = seeds.slice(0, 3); + if (seeds.length === 0) { + return [{ source: "oss", items: [], notes: [...notes, "No comparable OSS projects found."] }]; + } + const ossItems = []; + const issueItems = []; + const prItems = []; + const q = ctx.query || ctx.brief.idea; + for (const seed of seeds) { + const ref = resolveRepo(seed); + let dir; + try { + dir = ensureClone(ref, { refresh: ctx.refresh }); + } catch (e) { + notes.push(`Could not clone ${ref.raw}: ${e.message}`); + } + const repoLabel = ref.owner && ref.repo ? `${ref.owner}/${ref.repo}` : ref.slug; + if (dir) { + const files = walk(dir); + const langs = languageHistogram(files).slice(0, 6).map(([e, c]) => `${e}:${c}`).join(", "); + let snippet = `Languages: ${langs || "n/a"} \xB7 files: ${files.length}.`; + const readme = files.find((f) => /^readme(\.|$)/i.test(f.rel)) ?? files.find((f) => /(^|\/)readme\./i.test(f.rel)); + if (readme) { + const text = readText(readme.abs); + const ex = excerptsFromText(text, ref.webUrl ?? ref.raw, repoLabel, "oss", q, 1); + if (ex[0]) snippet += ` + +${ex[0].snippet}`; + } + ossItems.push({ + source: "oss", + title: `${repoLabel} \u2014 prior art`, + ref: repoLabel, + location: ref.webUrl, + score: files.length, + snippet, + url: ref.webUrl + }); + } + if (ref.owner && ref.repo) { + const provider = providerFor(ref.host); + const iss = await provider.search(ref, q, "issue", ctx.perSource); + issueItems.push(...iss.items); + notes.push(...iss.notes); + const prs = await provider.search(ref, q, "pr", ctx.perSource); + prItems.push(...prs.items); + notes.push(...prs.notes); + } } - screens.push({ name: "Settings & Account", purpose: "Preferences, data export/delete and account management.", relatedFRs: [] }); - return screens; + return [ + { source: "oss", items: ossItems, notes }, + { source: "issue", items: issueItems, notes: [] }, + { source: "pr", items: prItems, notes: [] } + ]; } -function buildFlows(functional) { - const must = functional.filter((fr) => fr.priority === "must"); - const flows = [ - { - name: "First-run onboarding", - steps: ["Arrive at an empty, explanatory first-run state", "Complete the minimal setup", "Reach the dashboard ready to act"], - frIds: must.map((fr) => fr.id) + +// src/research/stackoverflow.ts +function soTagFor(tech) { + return tech.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9.+-]/g, "").replace(/^-+|-+$/g, ""); +} +async function soQuery(q, perSource, tag) { + const pat = process.env.STACK_PAT ? `&access_token=${process.env.STACK_PAT}` : ""; + const tagged = tag ? `&tagged=${encodeURIComponent(tag)}` : ""; + const url = `https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${q}&site=stackoverflow&filter=withbody&pagesize=${perSource}${tagged}${pat}`; + const r = await httpGet(url, { accept: "application/json" }); + return { ok: r.ok, status: r.status, body: r.body, url }; +} +async function stackoverflow(question, perSource, opts = {}) { + const kws = rankedKeywords(question).slice(0, 5).join(" "); + if (!kws) return { source: "so", items: [], notes: ["No keywords to search StackOverflow."] }; + const q = encodeURIComponent(kws); + const notes = []; + let r = await soQuery(q, perSource, opts.tag); + if (!r.ok) { + return { source: "so", items: [], notes: [`StackOverflow search unavailable (status ${r.status}).`] }; + } + let data; + try { + data = JSON.parse(r.body); + } catch { + return { source: "so", items: [], notes: ["StackOverflow search returned an unparseable response."] }; + } + if (opts.tag && (data.items ?? []).length === 0) { + r = await soQuery(q, perSource, void 0); + if (r.ok) { + try { + data = JSON.parse(r.body); + notes.push(`No tagged:${opts.tag} results \u2014 retried without the tag.`); + } catch { + } } - ]; - for (const fr of must) { - flows.push({ - name: `${fr.title} \u2014 happy path`, - steps: ["Navigate to the relevant screen", `Perform: ${lowerFirst(fr.title)}`, "Receive clear confirmation of the outcome"], - frIds: [fr.id] + } + const wantKws = new Set(keywords(question).map((k) => k.toLowerCase())); + const items = []; + let filtered = 0; + for (const raw of data.items ?? []) { + const it = raw; + const title = htmlToText(String(it.title ?? "(question)")).slice(0, 160); + const tags = Array.isArray(it.tags) ? it.tags : []; + if (wantKws.size) { + const hay = new Set(keywords(`${title} ${tags.join(" ")}`).map((k) => k.toLowerCase())); + const overlaps = [...wantKws].some((k) => hay.has(k)) || tags.some((t) => wantKws.has(t.toLowerCase())); + if (!overlaps) { + filtered++; + continue; + } + } + const body = htmlToText(String(it.body ?? "")).slice(0, 1200); + const accepted = it.is_answered ? "answered" : "unanswered"; + items.push({ + source: "so", + title, + ref: `so:${it.question_id}`, + location: it.link, + score: Number(it.score ?? 0), + snippet: `score: ${it.score ?? 0} \xB7 ${accepted} \xB7 answers: ${it.answer_count ?? 0}` + (tags.length ? ` \xB7 tags: ${tags.slice(0, 6).join(", ")}` : "") + ` + +${body || "(no body)"}`, + url: it.link, + meta: { questionId: it.question_id, isAnswered: it.is_answered, answerCount: it.answer_count } }); } - return flows; + if (filtered) notes.push(`Filtered ${filtered} off-topic StackOverflow result(s) (no keyword overlap with the query).`); + if (data.quota_remaining !== void 0 && data.quota_remaining < 20) notes.push(`StackExchange anonymous quota low (${data.quota_remaining} left).`); + if (items.length === 0) notes.push("No StackOverflow questions matched."); + return { source: "so", items, notes }; } -function a11yRequirements() { - const defs = [ - { - statement: "Every interactive control is fully keyboard operable.", - given: "a user navigates with the keyboard only", - when: "they tab through any flow", - then: "every interactive control is reachable, operable and follows a logical focus order" - }, - { - statement: "Focus is always visible.", - given: "an element receives keyboard focus", - when: "the user is navigating", - then: "a visible focus indicator is shown and meets the non-text contrast minimum" - }, - { - statement: "Colour contrast meets the target standard.", - given: "any text or essential UI element", - when: "it is rendered in any supported theme", - then: "contrast meets the target (\u2265 4.5:1 for body text, \u2265 3:1 for large text and UI)" - }, - { - statement: "Every control and image exposes an accessible name.", - given: "a form control, icon-only button or meaningful image", - when: "it is read by assistive technology", - then: "it exposes a programmatic label/name and images carry meaningful alt text (decorative images are hidden)" - }, - { - statement: "Structure and async changes are conveyed semantically.", - given: "a screen is parsed by a screen reader", - when: "the user explores it", - then: "headings, landmarks and roles convey the structure and live regions announce asynchronous changes" - }, - { - statement: "Reduced motion and zoom are respected.", - given: "a user prefers reduced motion or zooms to 200%", - when: "they use the product", - then: "non-essential motion is reduced or disabled and content reflows without loss of content or function" + +// src/research/tech.ts +async function techAngle(ctx) { + const allTechs = ctx.brief.candidateTech; + const techs = allTechs.slice(0, 3); + const ideaKw = ctx.query || ctx.brief.idea; + const docItems = []; + const docNotes = []; + if (allTechs.length > techs.length) { + docNotes.push( + `Only the first ${techs.length} of ${allTechs.length} candidate technologies were grounded; skipped: ${allTechs.slice(techs.length).join(", ")}. Drill them with \`construct tech --out --q ""\`.` + ); + } + if (ctx.docsUrls?.length) { + const direct = await webFetchUrls(ctx.docsUrls, ideaKw, ctx.perSource, "docs", true); + docItems.push(...direct.items); + docNotes.push(`Grounded ${ctx.docsUrls.length} docs URL(s) passed via --docs-url.`, ...direct.notes); + } + for (const tech of techs) { + const q = `${tech} official documentation`; + const { urls, via, notes } = await discover(q, ctx.webEngine, ctx.perSource); + docNotes.push(`Docs discovery for "${tech}" via ${via}.`, ...notes); + if (!urls.length) continue; + const fetched = await webFetchUrls(urls.slice(0, 1), `${tech} ${ideaKw}`, ctx.perSource, "docs"); + docItems.push(...fetched.items); + docNotes.push(...fetched.notes); + } + if (techs.length === 0 && !ctx.docsUrls?.length) docNotes.push("No candidate technologies in the brief \u2014 nothing to ground feasibility against."); + const topKw = rankedKeywords(ideaKw)[0] ?? ""; + const soItems = []; + const soNotes = []; + const seen = /* @__PURE__ */ new Set(); + const per = Math.max(2, Math.ceil(ctx.perSource / Math.max(1, techs.length))); + for (const tech of techs) { + const q = `${tech} ${topKw}`.trim(); + const r = await stackoverflow(q, per, { tag: soTagFor(tech) }); + for (const it of r.items) { + if (!seen.has(it.ref)) { + seen.add(it.ref); + soItems.push(it); + } } - ]; - return defs.map((d, i) => ({ - id: `A11Y-${pad3(i + 1)}`, - statement: d.statement, - acceptance: [{ given: d.given, when: d.when, then: d.then }] - })); -} -function buildContentVoice(brief) { - const tone = brief.design?.tone?.trim(); + soNotes.push(...r.notes); + } + if (techs.length === 0) soNotes.push("No candidate technologies to search StackOverflow for."); return [ - tone ? `Voice & tone: ${tone}.` : "Voice & tone: clear, concise and human \u2014 plain language over jargon.", - "Label actions with the outcome the user gets, not the system operation behind it.", - "Error messages state what happened, why, and the next step \u2014 never blame the user.", - "Empty states teach the first useful action; success states confirm exactly what changed." + { source: "docs", items: docItems, notes: docNotes }, + { source: "so", items: soItems, notes: soNotes } ]; } -function buildDesignSystem(brief, functional) { - return { - principles: buildPrinciples(brief), - tokens: seedTokens(brief), - components: buildComponents(functional), - screens: buildScreens(functional), - flows: buildFlows(functional), - accessibility: { standard: deriveA11yStandard(brief), requirements: a11yRequirements() }, - contentVoice: buildContentVoice(brief) - }; + +// src/research/semantic.ts +import { existsSync as existsSync4 } from "fs"; +import { join as join5, dirname } from "path"; +import { fileURLToPath } from "url"; +var OLLAMA = (process.env.CONSTRUCT_OLLAMA || "http://localhost:11434").replace(/\/$/, ""); +var EMBED_MODEL = process.env.CONSTRUCT_EMBED_MODEL || "nomic-embed-text"; +function cosine(a, b) { + if (a.length === 0 || a.length !== b.length) return 0; + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + if (na === 0 || nb === 0) return 0; + const r = dot / (Math.sqrt(na) * Math.sqrt(nb)); + return Number.isFinite(r) ? r : 0; } -function concreteOutcome(title, notes) { - const n = (notes ?? "").trim(); - const q = /\b(?:within|at least|at most|no more than|up to|under)\s+\d[^.;,]{0,60}/i.exec(n); - const m = /\b(?:never|always|so that|so it|must|should|guarantee[sd]?|ensure[sd]?|without)\b\s+([^.;,]{4,})/i.exec(n); - if (m?.[1]) { - const clause = m[1].split(/[;,]/)[0].trim().replace(/\s+/g, " "); - if (clause.length > 3 && (/\d/.test(clause) || !q)) return `the action succeeds and ${lowerFirst(clause)}`; +async function reachable(base, path = "/") { + const r = await httpGet(base + path, { timeoutMs: REACHABLE_TIMEOUT_MS }); + return r.ok; +} +async function embed(text) { + const r = await httpJson("POST", `${OLLAMA}/api/embeddings`, { model: EMBED_MODEL, prompt: text.slice(0, 4e3) }, { timeoutMs: EMBED_TIMEOUT_MS }); + const v = r.ok ? r.data?.embedding : void 0; + return Array.isArray(v) && v.length ? v : null; +} +async function semanticRescore(results, query2) { + const unchanged = (why) => ({ + available: false, + results, + notes: [`Semantic mode unavailable (${why}); kept lexical ranking.`] + }); + if (!await reachable(OLLAMA, "/api/tags")) { + return unchanged(`Ollama not reachable at ${OLLAMA} \u2014 run \`construct semantic up\``); } - const t = /\bin under [^.;,]+/i.exec(n); - if (t) return `the action completes ${t[0].trim().replace(/\s+/g, " ")}`; - if (q) return `the outcome honours the stated bound: ${q[0].trim().replace(/\s+/g, " ").toLowerCase()}`; - return `the result of "${title.toLowerCase()}" is persisted and visible to the user`; + const qv = await embed(query2); + if (!qv) return unchanged(`could not embed the query (is the '${EMBED_MODEL}' model pulled?)`); + const out = []; + let failures = 0; + for (const r of results) { + const items = []; + for (const it of r.items) { + const v = await embed(`${it.title} +${it.snippet}`); + if (v) { + items.push({ ...it, score: Number(cosine(qv, v).toFixed(4)), meta: { ...it.meta ?? {}, semantic: true } }); + } else { + failures++; + items.push({ ...it, score: -1, meta: { ...it.meta ?? {}, semantic: false } }); + } + } + out.push({ ...r, items }); + } + const notes = [`Semantic rescoring via Ollama + ${EMBED_MODEL} (local).`]; + if (failures) notes.push(`${failures} item(s) could not be embedded; ranked last.`); + return { available: true, results: out, notes }; } -function failurePath(title, integration) { - if (integration) { - return { - given: `the external service required by "${title.toLowerCase()}" is unreachable or rejects the request`, - when: `a user performs the action`, - then: `the system surfaces a clear, specific error and makes no partial or inconsistent change` - }; +function composeFile() { + const here = dirname(fileURLToPath(import.meta.url)); + for (const cand of [join5(here, "..", "docker-compose.yml"), join5(here, "docker-compose.yml"), join5(here, "..", "..", "docker-compose.yml")]) { + if (existsSync4(cand)) return cand; } - return { - given: `a user submits invalid or missing input for "${title.toLowerCase()}"`, - when: `the action is submitted`, - then: `the system rejects it with a clear, actionable error and no side effects` - }; + return null; } -function specialiseMetric(cat, base, ctx) { - const c = cat.toLowerCase(); - if ((c === "performance" || c === "usability") && ctx.timeGoal) { - return `${base} Honour the product goal: ${ctx.timeGoal}.`; +function semanticControl(action, composeFilePath = composeFile()) { + if (!["up", "down", "status"].includes(action)) { + return { message: `construct semantic: unknown action "${action}" (use: up | down | status)`, code: 1 }; + } + if (!have("docker")) { + return { message: "construct semantic: docker not found. Install Docker, then retry. See references/semantic-setup.md.", code: 1 }; + } + if (!composeFilePath) { + return { + message: "construct semantic: docker-compose.yml not found next to the bundle \u2014 reinstall the skill (`npx skills add maxgfr/construct`), or run from the repo. See references/semantic-setup.md.", + code: 1 + }; } - if ((c === "privacy" || c === "security") && ctx.compliance.length) { - return `${base} Comply with: ${ctx.compliance.join(", ")}.`; + const file = composeFilePath; + if (action === "down") { + const r = sh("docker", ["compose", "-f", file, "--profile", "all", "down"], { timeoutMs: COMPOSE_DOWN_TIMEOUT_MS }); + return { message: r.ok ? "construct semantic: stack stopped." : `construct semantic: down failed. +${r.stderr}`, code: r.ok ? 0 : 1 }; } - if (c === "cost" && ctx.budget) { - return `${base} Stay within the stated budget: ${ctx.budget}.`; + if (action === "status") { + const r = sh("docker", ["compose", "-f", file, "ps"], { timeoutMs: COMPOSE_PS_TIMEOUT_MS }); + return { message: r.ok ? r.stdout || "construct semantic: no services running." : `construct semantic: status failed. +${r.stderr}`, code: 0 }; } - return base; + const up = sh("docker", ["compose", "-f", file, "--profile", "all", "up", "-d"], { timeoutMs: COMPOSE_UP_TIMEOUT_MS }); + if (!up.ok) return { message: `construct semantic: up failed. +${up.stderr}`, code: 1 }; + const pull = sh("docker", ["compose", "-f", file, "exec", "-T", "ollama", "ollama", "pull", EMBED_MODEL], { timeoutMs: OLLAMA_PULL_TIMEOUT_MS }); + const lines = [ + "construct semantic: stack is up (Qdrant :6333 \xB7 Ollama :11434 \xB7 SearXNG :8888).", + pull.ok ? ` model: ${EMBED_MODEL} ready` : ` model: pull '${EMBED_MODEL}' yourself: docker compose -f ${file} exec ollama ollama pull ${EMBED_MODEL}`, + " use: construct research --out --angles market,oss,tech,semantic --semantic" + ]; + return { message: lines.join("\n"), code: 0 }; } -function specialiseStatement(cat, base, ctx) { - const c = cat.toLowerCase(); - if ((c === "privacy" || c === "security") && ctx.selfHost) { - return `${base} No personal data leaves the self-hosted instance unless the host configures it.`; - } - return base; + +// src/research/dossier.ts +import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "fs"; +import { join as join6 } from "path"; +var SOURCE_ORDER = ["market", "oss", "docs", "so", "issue", "pr"]; +var SOURCE_LABEL = { + market: "Market & competitors", + oss: "Open-source prior art", + docs: "Technology documentation", + so: "StackOverflow", + issue: "Issues (prior art)", + pr: "Pull / Merge Requests (prior art)" +}; +function rank(s) { + const i = SOURCE_ORDER.indexOf(s); + return i < 0 ? 99 : i; } -function buildMilestones(functional, _brief, evidence, evById) { - const groups = [ - { key: "must", title: "M1 \u2014 Walking skeleton (must-haves)", outcome: "A usable end-to-end slice covering every must-have requirement." }, - { key: "should", title: "M2 \u2014 Rounded product (should-haves)", outcome: "The product is complete enough for real users." }, - { key: "could", title: "M3 \u2014 Enhancements (could-haves)", outcome: "Nice-to-have capabilities that differentiate the product." } - ]; - const priorPitfalls = evidence.filter((e) => e.source === "issue" || e.source === "pr"); +function assignIds(results) { + const flat = results.flatMap((r) => r.items); + flat.sort((a, b) => rank(a.source) - rank(b.source) || b.score - a.score || a.ref.localeCompare(b.ref)); + return flat.map((it, i) => ({ id: `E${i + 1}`, ...it })); +} +function renderEvidenceMarkdown(evidence, meta) { const out = []; - for (const g of groups) { - const frs = functional.filter((f) => f.priority === g.key); - if (frs.length === 0) continue; - const risks = []; - const text = frs.map((f) => `${f.title} ${f.description}`).join(" "); - const matched = matchEvidence(text, priorPitfalls, 2); - for (const id of matched) { - const e = evById.get(id); - if (e) risks.push(`Prior art shows a related pitfall: ${firstSentence(e.title)} [${id}]`); + out.push(`# Evidence dossier`); + out.push(""); + out.push(`**Idea:** ${meta.idea}`); + if (meta.query) out.push(`**Query:** ${meta.query}`); + out.push(`**Angles:** ${meta.angles.join(", ")} \xB7 **semantic:** ${meta.semantic ? "on" : "off"} \xB7 **built:** ${meta.builtAt}`); + out.push(""); + out.push( + `> Ground the SRD's requirements and decisions in this evidence. Cite items by id, e.g. \`[E1]\`. Grounding is advisory \u2014 \`construct check\` reports coverage but never fails on it. Still: prefer a cited claim to a guessed one.` + ); + out.push(""); + if (evidence.length === 0) { + out.push(`_No evidence was retrieved. Broaden the query, add angles, or check connectivity._`); + } + for (const source of SOURCE_ORDER) { + const items = evidence.filter((e) => e.source === source); + if (items.length === 0) continue; + out.push(`## ${SOURCE_LABEL[source]}`); + out.push(""); + for (const it of items) { + out.push(`### [${it.id}] ${it.title}`); + const meta1 = [`ref: \`${it.ref}\``, it.location ? `loc: \`${it.location}\`` : "", `score: ${it.score}`].filter(Boolean).join(" \xB7 "); + out.push(meta1); + if (it.url) out.push(`url: ${it.url}`); + out.push(""); + out.push("```"); + out.push(it.snippet); + out.push("```"); + out.push(""); } - out.push({ title: g.title, outcome: g.outcome, frIds: frs.map((f) => f.id), risks }); } - if (out.length === 0) out.push({ title: "M1 \u2014 Initial build", outcome: "Deliver the first usable version.", frIds: functional.map((f) => f.id), risks: [] }); - return out; + if (meta.notes.length) { + out.push(`## Retrieval notes`); + out.push(""); + for (const n of meta.notes) out.push(`- ${n}`); + out.push(""); + } + return out.join("\n"); } -function deriveAssumptions(brief) { - const a = []; - if (brief.constraints.team) a.push(`The team is: ${brief.constraints.team}.`); - if (brief.constraints.timeline) a.push(`The timeline is: ${brief.constraints.timeline}.`); - if (brief.constraints.budget) a.push(`The budget is: ${brief.constraints.budget}.`); - if (brief.constraints.compliance?.length) a.push(`Compliance applies: ${brief.constraints.compliance.join(", ")}.`); - if (a.length === 0) a.push("No hard constraints were captured; revisit budget, timeline and team before committing."); - return a; +function writeDossier(dir, evidence, meta) { + mkdirSync4(dir, { recursive: true }); + const evidenceJson = join6(dir, "evidence.json"); + const evidenceMd = join6(dir, "EVIDENCE.md"); + const metaJson = join6(dir, "meta.json"); + writeFileSync3(evidenceJson, JSON.stringify(evidence, null, 2)); + writeFileSync3(evidenceMd, renderEvidenceMarkdown(evidence, meta)); + writeFileSync3(metaJson, JSON.stringify(meta, null, 2)); + return { dir, evidenceJson, evidenceMd, metaJson }; } -function contextProse(name, brief) { - const actors = brief.product.users?.length ? brief.product.users : ["users"]; - const boundaries = detectBoundaries(brief).map((b) => b.label); - const stack = brief.candidateTech.length ? ` Built on ${brief.candidateTech.join(", ")}.` : ""; - const ext = boundaries.length ? ` It integrates with: ${boundaries.join("; ")}.` : ""; - return `"${name}" serves ${actors.join(", ")}.${stack}${ext} Each integration boundary is owned by an ADR and detailed in INTERFACES.md during authoring.`; + +// src/research/registry.ts +var HANDLERS = { + market: marketAngle, + oss: ossAngle, + tech: techAngle +}; +var ANGLE_SOURCE = { + market: "market", + oss: "oss", + tech: "docs" +}; +async function runAngles(ctx) { + const active = ctx.angles.filter((a) => a !== "semantic"); + const settled = await Promise.all( + active.map(async (a) => { + try { + return await HANDLERS[a](ctx); + } catch (e) { + return [{ source: ANGLE_SOURCE[a], items: [], notes: [`${a} angle failed: ${e.message}`] }]; + } + }) + ); + let results = settled.flat(); + const notes = []; + if (ctx.semantic || ctx.angles.includes("semantic")) { + const q = ctx.query || ctx.brief.idea; + const s = await semanticRescore(results, q); + results = s.results; + notes.push(...s.notes); + } + return { results, notes }; } -function noteFrom(ids, evById) { - for (const id of ids) { - const e = evById.get(id); - const s = e ? firstSentence(e.snippet) : ""; - if (s) return s; +async function runResearch(ctx, builtAt) { + const { results, notes } = await runAngles(ctx); + const capped = results.map((r) => ({ + ...r, + items: [...r.items].sort((a, b) => b.score - a.score).slice(0, ctx.perSource) + })); + const evidence = assignIds(capped); + const presentSources = [...new Set(evidence.map((e) => e.source))]; + const meta = { + idea: ctx.brief.idea, + angles: ctx.angles, + query: ctx.query || void 0, + sources: presentSources, + semantic: ctx.semantic || ctx.angles.includes("semantic"), + evidenceCount: evidence.length, + builtAt, + notes: [...capped.flatMap((r) => r.notes), ...notes] + }; + const dir = join7(ctx.runDir, "evidence"); + const paths = writeDossier(dir, evidence, meta); + return { dir, evidence, meta, paths }; +} + +// src/render.ts +import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5, rmSync as rmSync2 } from "fs"; +import { join as join10, dirname as dirname2 } from "path"; + +// src/srd.ts +import { join as join8 } from "path"; +function srdManifestPath(runDir) { + return join8(runDir, "SRD.json"); +} +function pad3(n) { + return String(n).padStart(3, "0"); +} +function pad4(n) { + return String(n).padStart(4, "0"); +} +var GROUND_REQUIREMENT = ["market", "oss", "docs", "so", "issue", "pr"]; +var GROUND_QUALITY = ["oss", "docs", "so", "issue", "pr"]; +function matchEvidence(text, evidence, n, onlySources) { + const kws = keywords(text).map((k) => k.toLowerCase()); + if (kws.length === 0) return []; + const need = Math.min(2, kws.length); + const ratioFloor = 0.34; + const scored = evidence.filter((e) => !onlySources || onlySources.includes(e.source)).map((e) => { + const hay = new Set(keywords(`${e.title} ${e.snippet}`).map((k) => k.toLowerCase())); + let cov = 0; + for (const kw of kws) if (hay.has(kw)) cov++; + return { id: e.id, key: e.url || `${e.source}:${e.ref}`, cov, ratio: cov / kws.length, score: e.score }; + }).filter((x) => x.cov >= need && x.ratio >= ratioFloor).sort((a, b) => b.cov - a.cov || b.ratio - a.ratio || b.score - a.score || a.id.localeCompare(b.id)); + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const x of scored) { + if (seen.has(x.key)) continue; + seen.add(x.key); + out.push(x.id); + if (out.length >= n) break; } - return void 0; + return out; } -function firstSentence(s) { - const clean = s.replace(/\s+/g, " ").trim(); - if (!clean) return ""; - const m = /^(.{1,200}?[.!?])(\s|$)/.exec(clean); - return (m ? m[1] : clean.slice(0, 160)).trim(); +function mentionsEntity(name, e) { + const phrase = name.trim().toLowerCase().replace(/\s+/g, " "); + if (!phrase) return false; + const hay = `${e.title} ${e.snippet}`.toLowerCase().replace(/\s+/g, " "); + const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(? `${f.title} ${f.notes ?? ""}`).join(" ")}`; -} -function citationsIn(s) { - const out = []; - const re = /\[(E\d+)\]/g; - let m; - while (m = re.exec(s)) out.push(m[1]); - return out; -} -function titleFromIdea(idea) { - const first = idea.split(/[.,;:]/)[0]?.trim() || idea.trim(); - return first.length > 40 ? first.slice(0, 40).trim() : first || "The Product"; -} -function lowerFirst(s) { - const t = s.trim(); - return t ? t[0].toLowerCase() + t.slice(1) : t; +}; +function nfrFor(category) { + const key = category.toLowerCase().trim(); + return NFR_TEMPLATES[key] ?? { + statement: `The system meets the "${category}" quality expectation defined for this product.`, + metric: `A measurable target for "${category}" is agreed and tracked.` + }; } -function evNum(id) { - const m = /^E(\d+)$/.exec(id); - return m ? Number(m[1]) : 1e9; +function priorityOf(p) { + return p === "must" || p === "should" || p === "could" ? p : "should"; } - -// src/plan.ts -import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs"; -import { join as join8 } from "path"; -function buildPlanPath(runDir) { - return join8(runDir, "BUILD-PLAN.json"); +var FEATURE_VERBS = /* @__PURE__ */ new Set([ + "create", + "add", + "manage", + "book", + "view", + "send", + "track", + "sync", + "edit", + "delete", + "list", + "share", + "export", + "import", + "search", + "save", + "read", + "tag", + "organize", + "organise", + "schedule", + "upload", + "download", + "browse", + "filter", + "sort", + "archive", + "publish", + "invite", + "assign", + "stream" +]); +var NON_ENTITY_WORDS = /* @__PURE__ */ new Set(["search", "login", "signup", "support", "setup", "offline", "online", "mobile", "desktop", "full", "text", "user", "users"]); +var ADJECTIVAL_PREFIXES = /* @__PURE__ */ new Set([ + "multi", + "auto", + "self", + "cross", + "pre", + "post", + "non", + "anti", + "semi", + "meta", + "mini", + "micro", + "macro", + "mono", + "dual", + "poly", + "omni", + "pseudo", + "quasi", + "ultra", + "hyper", + "super", + "sub", + "inter", + "intra", + "extra" +]); +function singularize(w) { + if (/ies$/.test(w)) return w.slice(0, -3) + "y"; + if (/(?:ches|shes|xes|zes|ses)$/.test(w)) return w.slice(0, -2); + if (/s$/.test(w) && !/(?:ss|us|is)$/.test(w)) return w.slice(0, -1); + return w; } -function milestoneLabel(title) { - return title.split("\u2014")[0].trim() || title.trim(); +function titleCase(w) { + return w ? w[0].toUpperCase() + w.slice(1) : w; } -function pad32(n) { - return String(n).padStart(3, "0"); +function entityTokens(title, exclude) { + const words = keywords(title).map((w) => w.toLowerCase()); + const verbLed = words.length > 0 && FEATURE_VERBS.has(words[0]); + const rest = verbLed ? words.slice(1) : words; + const tokens = rest.filter((w) => w.length >= 3 && !FEATURE_VERBS.has(w) && !NON_ENTITY_WORDS.has(w) && !ADJECTIVAL_PREFIXES.has(w) && !/(?:ed|ing)$/.test(w)).map(singularize).filter((w) => !exclude.has(w)); + return { tokens, verbLed }; } -function derivePlan(srd) { - const frById = new Map(srd.functional.map((f) => [f.id, f])); - const ordered = []; - const seen = /* @__PURE__ */ new Set(); - for (const m of srd.buildPlan) { - for (const frId of m.frIds) { - if (frById.has(frId) && !seen.has(frId)) { - ordered.push({ frId, milestone: milestoneLabel(m.title) }); - seen.add(frId); - } - } - } - for (const f of srd.functional) { - if (!seen.has(f.id)) ordered.push({ frId: f.id, milestone: "M1" }); - } - const tasks = [ - { - id: "T-000", - title: "Project skeleton \u2014 repo layout, test harness, CI", - milestone: ordered[0]?.milestone ?? "M1", - frIds: [], - acceptance: [], - dependsOn: [], - artifacts: [], - tests: [], - verify: { commands: [] }, - status: "todo" - } - ]; - ordered.forEach(({ frId, milestone }, i) => { - const fr = frById.get(frId); - const dependsOn = ["T-000"]; - if (fr.entities.length) { - for (let j = 0; j < i; j++) { - const prev = ordered[j]; - if (prev.milestone === milestone) continue; - const prevFr = frById.get(prev.frId); - if (prevFr.entities.some((e) => fr.entities.includes(e))) { - dependsOn.push(`T-${pad32(j + 1)}`); - break; - } - } - } - tasks.push({ - id: `T-${pad32(i + 1)}`, - title: `${fr.id} \u2014 ${fr.title}`, - milestone, - ...fr.module ? { module: fr.module } : {}, - frIds: [fr.id], - acceptance: fr.acceptance.map((_, idx) => ({ frId: fr.id, index: idx })), - dependsOn, - artifacts: [], - tests: [], - verify: { commands: [] }, - status: "todo" - }); - }); - if (srd.design) { - tasks.push({ - id: `T-${pad32(ordered.length + 1)}`, - title: "Design foundation \u2014 design tokens, base components, accessibility baseline", - milestone: ordered[0]?.milestone ?? "M1", - frIds: [], - acceptance: [], - dependsOn: ["T-000"], - artifacts: [], - tests: [], - verify: { commands: [] }, - status: "todo" - }); +function inferEntities(brief, functional) { + const exclude = new Set( + [...brief.competitors, ...brief.candidateTech, brief.product.name ?? ""].flatMap((s) => keywords(s).map((w) => singularize(w.toLowerCase()))) + ); + const perFr = functional.map((fr) => ({ fr, ...entityTokens(fr.title, exclude) })); + const freq = /* @__PURE__ */ new Map(); + for (const p of perFr) for (const t of new Set(p.tokens)) freq.set(t, (freq.get(t) ?? 0) + 1); + const chosen = /* @__PURE__ */ new Set(); + for (const [t, n] of freq) if (n >= 2) chosen.add(t); + for (const p of perFr) { + if (p.verbLed && p.fr.priority === "must" && p.tokens[0]) chosen.add(p.tokens[0]); } - return { - schemaVersion: BUILD_PLAN_SCHEMA_VERSION, - product: srd.product.name, - generatedAt: srd.generatedAt, - conventions: { frTagPattern: "FR-\\d{3}", testCommand: null, appDir: null }, - tasks - }; -} -var STATUSES = ["todo", "in-progress", "done"]; -function taskKey(t) { - return t.frIds.length ? `fr:${t.title.replace(/^FR-\d+\s*—\s*/, "").trim().toLowerCase()}` : `title:${t.title.trim().toLowerCase()}`; -} -function mergePlan(prev, next) { - if (!prev) return next; - const prevByKey = new Map(prev.tasks.map((t) => [taskKey(t), t])); - const tasks = next.tasks.map((t) => { - const old = prevByKey.get(taskKey(t)); - if (!old) return t; + const names = [...chosen].sort((a, b) => freq.get(b) - freq.get(a) || a.localeCompare(b)).slice(0, 8); + const entities = names.map((n) => { + const name = titleCase(n); + const refs = perFr.filter((p) => p.tokens.includes(n)).map((p) => p.fr.id); return { - ...t, - artifacts: Array.isArray(old.artifacts) ? old.artifacts : t.artifacts, - tests: Array.isArray(old.tests) ? old.tests : t.tests, - verify: old.verify && Array.isArray(old.verify.commands) ? old.verify : t.verify, - status: STATUSES.includes(old.status) ? old.status : t.status - }; - }); - return { - ...next, - conventions: { - frTagPattern: next.conventions.frTagPattern, - testCommand: prev.conventions?.testCommand ?? null, - appDir: prev.conventions?.appDir ?? null - }, - tasks - }; -} -function readyFrontier(plan) { - const done = new Set(plan.tasks.filter((t) => t.status === "done").map((t) => t.id)); - const tasks = plan.tasks.map((t) => ({ - id: t.id, - milestone: t.milestone, - status: t.status, - dependsOn: t.dependsOn, - ready: t.status !== "done" && t.dependsOn.every((d) => done.has(d)) - })); - return { - product: plan.product, - done: done.size, - total: plan.tasks.length, - tasks, - frontier: tasks.filter((t) => t.ready).map((t) => t.id), - blocked: tasks.filter((t) => t.status !== "done" && !t.ready).map((t) => ({ id: t.id, waitingOn: t.dependsOn.filter((d) => !done.has(d)) })) - }; -} -function loadPlan(runDir) { - const path = buildPlanPath(runDir); - if (!existsSync4(path)) return null; - try { - const data = JSON.parse(readFileSync3(path, "utf8")); - return data && typeof data === "object" && Array.isArray(data.tasks) ? data : null; - } catch { - return null; + name, + attributes: [ + { name: "id", type: "identifier" }, + { name: "createdAt", type: "timestamp" } + ], + referencedByFRs: refs + }; + }); + for (const fr of functional) { + fr.entities = entities.filter((e) => e.referencedByFRs.includes(fr.id)).map((e) => e.name); } + return entities; } -function writePlan(runDir, plan) { - const path = buildPlanPath(runDir); - writeFileSync3(path, JSON.stringify(plan, null, 2) + "\n"); - return path; -} - -// src/templates.ts -function cite(ids) { - if (!ids || ids.length === 0) return ""; - return " " + ids.map((id) => `[${id}]`).join(""); -} -function cell(s) { - return String(s).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim(); -} -function slugTitle(s) { - return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "decision"; -} -function bullets(items, empty) { - if (!items.length) return `_${empty}_`; - return items.map((i) => `- ${i}`).join("\n"); +var BOUNDARY_DEFS = [ + // Word boundaries matter: a bare /ical|ics/ substring-matches "historical" + // and "metrics", /stripe/ matches "pinstripe", /embed/ matches "Embedded" and + // /google/ matches "googled" — each hallucinating an integration into an + // unrelated product. Every token is bounded (optional plural) for that reason. + { re: /\b(?:calendar|caldav|ical|ics)\b/i, label: "calendar systems (CalDAV/iCal)", name: "Calendar Integration", kind: "api" }, + { re: /\bgoogles?\b/i, label: "Google APIs", name: "Google API Integration", kind: "api" }, + { re: /\b(?:email|smtp)s?\b/i, label: "an email/SMTP provider", name: "Email Delivery", kind: "api" }, + { re: /\b(?:sms|twilio)s?\b/i, label: "an SMS provider", name: "SMS Delivery", kind: "api" }, + { re: /\b(?:widget|iframe|embed)s?\b/i, label: "external host sites (embed/iframe)", name: "Embeddable Widget", kind: "ui" }, + { re: /\b(?:payment|stripe|billing)s?\b/i, label: "a payments provider", name: "Payments Integration", kind: "api" }, + { re: /\bwebhooks?\b/i, label: "outbound webhooks", name: "Outbound Webhooks", kind: "event" }, + { re: /\b(?:browser extension|chrome extension|firefox add-?on)s?\b/i, label: "a browser extension", name: "Browser Extension", kind: "ui" } +]; +function boundaryHaystack(brief) { + return `${brief.idea} ${brief.candidateTech.join(" ")} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; } -function renderVision(srd) { - const p = srd.product; - return [ - `# Vision`, - ``, - `**Product:** ${p.name}`, - ``, - `## Problem`, - p.problem, - ``, - `## Target users`, - bullets(p.users, "No users captured."), - ``, - `## Value proposition`, - p.valueProp, - ``, - `## Success metrics`, - bullets(p.metrics, "Define a measurable launch success metric."), - `` - ].join("\n"); +function detectBoundaries(brief) { + const haystack = boundaryHaystack(brief); + return BOUNDARY_DEFS.filter((b) => b.re.test(haystack)); } -function renderScope(srd) { - const lines = [ - `# Scope`, - ``, - `## In scope`, - bullets(srd.scope.inScope, "No in-scope items captured."), - ``, - `## Out of scope`, - bullets(srd.scope.outOfScope, "Nothing explicitly excluded yet."), - ``, - `## Assumptions`, - bullets(srd.scope.assumptions, "No assumptions recorded."), - `` - ]; - if (srd.openQuestions.length) { - lines.push(`## Open decisions`, ``); - for (const q of srd.openQuestions) lines.push(`> \u{1F9E0} **Decide:** ${q}`, ``); +function inferInterfaces(brief, functional) { + const out = []; + for (const b of detectBoundaries(brief)) { + const related = functional.filter((fr) => b.re.test(`${fr.title} ${fr.description}`)).map((fr) => fr.id); + out.push({ + name: b.name, + kind: b.kind, + summary: `Boundary with ${b.label}. Define the contract (operations, data, failure modes) during authoring.`, + relatedFRs: related + }); } - return lines.join("\n"); -} -function renderFRBlock(fr) { - const out = [`## ${fr.id} \u2014 ${fr.title} _(${fr.priority})_${cite(fr.rationaleEvidence)}`, ``]; - out.push(fr.description); - out.push(``); - out.push(`**Acceptance criteria:**`); - for (const a of fr.acceptance) { - out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); + if (brief.product.users?.length) { + out.push({ + name: "Web App", + kind: "ui", + summary: `The primary user-facing surface through which ${brief.product.users.join(", ")} use the product.`, + relatedFRs: functional.map((f) => f.id) + }); + } + for (const fr of functional) { + fr.interfaces = out.filter((i) => i.relatedFRs.includes(fr.id)).map((i) => i.name); } - out.push(``); - const trace = [ - `NFRs: ${fr.nfrs.length ? fr.nfrs.join(", ") : "\u2014"}`, - `entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`, - `interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}` - ].join(" \xB7 "); - out.push(`_Traceability \u2014 ${trace}_`); - out.push(``); return out; } -function renderFunctional(srd) { - if (srd.modules?.length) return renderFunctionalIndex(srd); - return renderFunctionalFull(srd); -} -function renderFunctionalFull(srd) { - const out = [`# Functional requirements`, ``]; - if (!srd.functional.length) out.push(`_No functional requirements defined._`, ``); - for (const fr of srd.functional) out.push(...renderFRBlock(fr)); - return out.join("\n"); -} -function renderFunctionalIndex(srd) { - const out = [`# Functional requirements`, ``]; - out.push(`_This SRD is partitioned into module PRDs \u2014 the full requirement blocks (description,`); - out.push(`acceptance criteria, traceability) live in each module's PRD under [../prd/](../prd/README.md)._`, ``); - out.push(`| Requirement | Title | Priority | Module | PRD |`); - out.push(`|---|---|---|---|---|`); - for (const fr of srd.functional) { - const link = fr.module ? `[../prd/${fr.module}/PRD.md](../prd/${fr.module}/PRD.md)` : "\u2014"; - out.push(`| ${fr.id} | ${cell(fr.title)} | ${fr.priority} | ${fr.module ?? "\u2014"} | ${link} |`); +function buildSRD(brief, evidence, opts) { + const level = opts.level; + const productName = brief.product.name || titleFromIdea(brief.idea); + const compliance = brief.constraints.compliance ?? []; + const selfHost = /self[- ]?host|privacy|gdpr|own (your|the) data/i.test(`${brief.idea} ${brief.product.valueProp ?? ""}`) || compliance.length > 0; + const timeGoal = timeTokenFromGoals(brief.goals); + const categories = []; + for (const c of REQUIRED_NFR[level]) if (!categories.includes(c)) categories.push(c); + for (const c of brief.nfrPriorities) { + const k = c.toLowerCase().trim(); + if (k && !categories.includes(k)) categories.push(k); } - out.push(``); - return out.join("\n"); -} -function renderModulePRD(srd, m) { - const frs = srd.functional.filter((f) => f.module === m.id); - const others = (srd.modules ?? []).filter((o) => o.id !== m.id); - const frIdSet = new Set(frs.map((f) => f.id)); - const out = [`# PRD \u2014 ${m.name}`, ``]; - out.push(`_Module \`${m.id}\` \xB7 ${srd.product.name} \xB7 ${frs.length} requirement(s)_`, ``); - if (m.description) out.push(m.description, ``); - out.push( - `**Global context:** [Vision](../../00-overview/VISION.md) \xB7 [Scope](../../00-overview/SCOPE.md) \xB7 [Non-functional requirements](../../requirements/NON-FUNCTIONAL.md) \xB7 [Data model](../../architecture/DATA-MODEL.md) \xB7 [Interfaces](../../architecture/INTERFACES.md) \xB7 [Traceability](../../TRACEABILITY.md)`, - `` - ); - out.push(`## Scope`, ``); - out.push(`**In scope:** ${frs.length ? frs.map((f) => f.id).join(", ") : "\u2014"}.`, ``); - if (others.length) { - out.push(`**Out of scope** (owned by other modules): ${others.map((o) => `[${o.name}](../${o.id}/PRD.md)`).join(", ")}.`, ``); + const nonFunctional = categories.map((cat, i) => { + const t = nfrFor(cat); + const metric = specialiseMetric(cat, t.metric, { compliance, selfHost, timeGoal, budget: brief.constraints.budget }); + const statement = specialiseStatement(cat, t.statement, { compliance, selfHost }); + return { + id: `NFR-${pad3(i + 1)}`, + category: cat, + statement, + metric, + // Ground over the *specialised* text + distinctive brief facts (CalDAV, + // GDPR…), restricted to authoritative sources (no marketing pages). + rationaleEvidence: matchEvidence(`${cat} ${statement} ${brief.candidateTech.join(" ")} ${compliance.join(" ")}`, evidence, 1, GROUND_QUALITY) + }; + }); + const coreNfrIds = nonFunctional.filter((n) => REQUIRED_NFR.light.includes(n.category.toLowerCase())).map((n) => n.id); + const adrs = []; + const stack = brief.candidateTech.length ? brief.candidateTech.join(", ") : "a stack to be selected"; + adrs.push({ + id: "", + title: "Primary technology stack", + status: brief.candidateTech.length ? "accepted" : "proposed", + context: `Building "${productName}" requires a stack that fits the team (${brief.constraints.team || "to be defined"}) and timeline (${brief.constraints.timeline || "to be defined"}).`, + decision: `Adopt ${stack} as the primary stack for the initial build.`, + consequences: `The team commits to ${stack}; hiring, tooling and operational knowledge align to it. Revisit if a hard requirement is unmet.`, + alternatives: brief.candidateTech.length ? "No explicit alternative stack was provided in the brief; evaluate one comparable option before locking this in." : "Alternative stacks were considered but not selected.", + evidence: matchEvidence(`${stack} architecture stack`, evidence, 2, ["docs", "oss", "so"]) + }); + if (selfHost) { + adrs.push({ + id: "", + title: "Self-hosting and data-ownership model", + status: "accepted", + context: `"${productName}" is positioned as privacy-first / self-hostable${compliance.length ? ` and must satisfy: ${compliance.join(", ")}` : ""}.`, + decision: "Ship as a self-hostable deployment where the host owns all data; no user data is sent to a third-party service by default.", + consequences: "Data residency and compliance become the host's responsibility (a feature, not a liability); the product must run with no mandatory external dependencies and document its data flows.", + alternatives: "A hosted multi-tenant SaaS was considered but rejected as it conflicts with the privacy/data-ownership value proposition.", + evidence: matchEvidence(`self-host privacy data ownership ${compliance.join(" ")}`, evidence, 2, GROUND_QUALITY) + }); } - out.push(`## Requirements`, ``); - if (!frs.length) out.push(`_No requirements assigned to this module._`, ``); - for (const fr of frs) out.push(...renderFRBlock(fr)); - const nfrIds = new Set(frs.flatMap((f) => f.nfrs)); - const nfrs = srd.nonFunctional.filter((n) => nfrIds.has(n.id)); - out.push(`## Non-functional requirements`, ``); - if (nfrs.length) { - out.push(`_Applying to this module's requirements \u2014 full statements in [NON-FUNCTIONAL.md](../../requirements/NON-FUNCTIONAL.md)._`, ``); - out.push(`| NFR | Category | Metric |`, `|---|---|---|`); - for (const n of nfrs) out.push(`| ${n.id} | ${cell(n.category)} | ${cell(n.metric ?? "\u2014")} |`); - } else { - out.push(`_None linked._`); + const integrates = brief.featureWishlist.some((f) => INTEGRATION_RE.test(`${f.title} ${f.notes ?? ""}`)) || INTEGRATION_RE.test(brief.idea); + if (level === "complex" && (PERSIST_RE.test(briefText(brief)) || integrates)) { + adrs.push({ + id: "", + title: "Data persistence and integration approach", + status: "proposed", + context: `"${productName}" must persist state and integrate with external services (${brief.candidateTech.filter((t) => INTEGRATION_RE.test(t)).join(", ") || "calendar/email and similar"}) reliably.`, + decision: "Use a single primary datastore with explicit, versioned integration boundaries for each external service.", + consequences: "A clear data-ownership model; integrations are testable in isolation behind an adapter. Cross-service consistency must be designed explicitly.", + alternatives: "A polyglot-persistence or event-sourced approach was considered; deferred until scale demands it.", + evidence: matchEvidence(`${brief.candidateTech.join(" ")} database persistence integration`, evidence, 2, ["docs", "oss", "so"]) + }); } - out.push(``); - const entities = srd.architecture.dataModel.filter((e) => e.referencedByFRs.some((id) => frIdSet.has(id))); - out.push(`## Data model (module slice)`, ``); - if (entities.length) { - out.push(`| Entity | Referenced by |`, `|---|---|`); - for (const e of entities) out.push(`| ${cell(e.name)} | ${e.referencedByFRs.filter((id) => frIdSet.has(id)).join(", ")} |`); - } else { - out.push(`_No entities touch this module yet._`); + adrs.forEach((a, i) => a.id = pad4(i + 1)); + const stackAdrId = adrs[0].id; + const dataAdr = adrs.find((a) => /persistence|integration/i.test(a.title)); + const privacyAdr = adrs.find((a) => /self-hosting|data-ownership/i.test(a.title)); + const functional = brief.featureWishlist.map((f, i) => { + const priority = priorityOf(f.priority); + const text = `${f.title} ${f.notes ?? ""}`; + const touchesIntegration = INTEGRATION_RE.test(text); + const outcome = concreteOutcome(f.title, f.notes); + const acceptance = [ + { + given: `${productName} is available to a user`, + when: `they ${lowerFirst(f.title)}`, + then: outcome + }, + ...level === "complex" ? [failurePath(f.title, touchesIntegration)] : [] + ]; + const nfrs = [...coreNfrIds]; + for (const n of nonFunctional) { + if (coreNfrIds.includes(n.id)) continue; + const sig = NFR_SIGNALS[n.category.toLowerCase()]; + if (sig?.test(text)) nfrs.push(n.id); + } + return { + id: `FR-${pad3(i + 1)}`, + title: f.title, + description: f.notes?.trim() || `The product lets a user ${lowerFirst(f.title)}.`, + priority, + acceptance, + rationaleEvidence: matchEvidence(text, evidence, 2, GROUND_REQUIREMENT), + entities: [], + interfaces: [], + nfrs, + unresolved: false, + ...f.module ? { module: f.module } : {} + }; + }); + const modules = brief.modules?.length ? brief.modules.map((m) => ({ + id: m.id, + name: m.name, + ...m.description ? { description: m.description } : {}, + frIds: functional.filter((f) => f.module === m.id).map((f) => f.id), + dependsOn: m.dependsOn ?? [] + })) : void 0; + const dataModel = inferEntities(brief, functional); + const interfaces = inferInterfaces(brief, functional); + const evById = new Map(evidence.map((e) => [e.id, e])); + const competitors = brief.competitors.map((name) => { + const ev = matchEvidence( + name, + evidence.filter((e) => mentionsEntity(name, e)), + 2, + ["market"] + ); + return { name, note: noteFrom(ev, evById) || `Comparable product / alternative to "${productName}".`, evidence: ev }; + }); + const ossByKey = /* @__PURE__ */ new Map(); + const keyOf = (s) => { + try { + return resolveRepo(s).slug; + } catch { + return s.toLowerCase(); + } + }; + for (const seed of brief.ossSeeds) { + const ref = resolveRepo(seed); + const label = ref.owner && ref.repo ? `${ref.owner}/${ref.repo}` : seed; + const ev = matchEvidence(`${ref.owner ?? ""} ${ref.repo ?? ""}`.trim() || seed, evidence, 2, ["oss", "issue", "pr"]); + ossByKey.set(keyOf(seed), { + name: label, + url: ref.webUrl ?? (/^https?:/.test(seed) ? seed : void 0), + note: noteFrom(ev, evById) || "Seed OSS project mined for prior art.", + evidence: ev + }); } - out.push(``); - const ifaces = srd.architecture.interfaces.filter((i) => i.relatedFRs.some((id) => frIdSet.has(id))); - out.push(`## Interfaces (module slice)`, ``); - if (ifaces.length) { - out.push(`| Interface | Kind | Related |`, `|---|---|---|`); - for (const i of ifaces) out.push(`| ${cell(i.name)} | ${i.kind} | ${i.relatedFRs.filter((id) => frIdSet.has(id)).join(", ")} |`); - } else { - out.push(`_No interfaces touch this module yet._`); + for (const e of evidence.filter((x) => x.source === "oss")) { + const k = keyOf(e.ref); + if (ossByKey.has(k)) { + if (!ossByKey.get(k).evidence.includes(e.id)) ossByKey.get(k).evidence.push(e.id); + continue; + } + ossByKey.set(k, { + name: e.title.replace(/ —.*$/, ""), + url: e.url, + note: firstSentence(e.snippet) || "Comparable open-source project (prior art).", + evidence: [e.id] + }); } - out.push(``); - out.push(`## Dependencies`, ``); - const declared = m.dependsOn.map((dep) => { - const d = others.find((o) => o.id === dep); - return d ? `[${d.name}](../${d.id}/PRD.md)` : dep; + const oss = [...ossByKey.values()]; + const buildPlan = buildMilestones(functional, brief, evidence, evById); + const design = opts.design ? buildDesignSystem(brief, functional) : void 0; + const traceability = functional.map((fr) => { + const text = `${fr.title} ${fr.description}`; + const adrIds = [stackAdrId]; + if (dataAdr && (PERSIST_RE.test(text) || INTEGRATION_RE.test(text))) adrIds.push(dataAdr.id); + if (privacyAdr && NFR_SIGNALS.privacy.test(text)) adrIds.push(privacyAdr.id); + const row = { fr: fr.id, nfrs: fr.nfrs, adrs: adrIds, entities: fr.entities, interfaces: fr.interfaces }; + if (design) { + row.components = design.components.filter((c) => c.relatedFRs.includes(fr.id)).map((c) => c.name); + row.screens = design.screens.filter((s) => s.relatedFRs.includes(fr.id)).map((s) => s.name); + } + if (fr.module) row.module = fr.module; + return row; }); - const shared = []; - for (const o of others) { - const oSet = new Set(o.frIds); - const names = entities.filter((e) => e.referencedByFRs.some((id) => oSet.has(id))).map((e) => e.name); - if (names.length) shared.push(`shares ${names.join(", ")} with [${o.name}](../${o.id}/PRD.md)`); - } - if (!declared.length && !shared.length) out.push(`_None._`); - if (declared.length) out.push(`- **Declared:** depends on ${declared.join(", ")}.`); - for (const s of shared) out.push(`- **Derived (shared data):** ${s}.`); - out.push(``); - return out.join("\n"); + const referenced = /* @__PURE__ */ new Set(); + for (const fr of functional) fr.rationaleEvidence.forEach((id) => referenced.add(id)); + for (const n of nonFunctional) n.rationaleEvidence.forEach((id) => referenced.add(id)); + for (const a of adrs) a.evidence.forEach((id) => referenced.add(id)); + for (const c of competitors) c.evidence.forEach((id) => referenced.add(id)); + for (const o of oss) o.evidence.forEach((id) => referenced.add(id)); + for (const m of buildPlan) (m.risks ?? []).forEach((r) => citationsIn(r).forEach((id) => referenced.add(id))); + const evidenceIndex = [...referenced].sort((a, b) => evNum(a) - evNum(b)); + return { + schemaVersion: SRD_SCHEMA_VERSION, + level, + generatedAt: opts.generatedAt, + product: { + name: productName, + problem: brief.product.problem || brief.goals[0] || `Address the need described by: ${brief.idea}`, + valueProp: brief.product.valueProp || `Deliver ${brief.idea} better than existing options.`, + users: brief.product.users?.length ? brief.product.users : ["primary user"], + metrics: brief.goals.length ? brief.goals : ["Define a measurable launch success metric."] + }, + scope: { + inScope: brief.featureWishlist.filter((f) => priorityOf(f.priority) !== "could").map((f) => f.title), + outOfScope: brief.nonGoals, + assumptions: deriveAssumptions(brief) + }, + functional, + ...modules ? { modules } : {}, + nonFunctional, + architecture: { context: contextProse(productName, brief), dataModel, interfaces, adrs }, + competitive: { competitors, oss }, + buildPlan, + traceability, + openQuestions: brief.openQuestions, + evidenceIndex, + ...design ? { design } : {} + }; } -function renderModulePrdIndex(srd) { - const out = [`# Module PRDs`, ``]; - out.push(`One PRD per product module, rendered from SRD.json. Cross-module docs (vision, scope,`); - out.push(`NFRs, architecture, ADRs, traceability) live at the SRD root; the cross-module requirement`); - out.push(`index is [../requirements/FUNCTIONAL.md](../requirements/FUNCTIONAL.md).`, ``); - out.push(`| Module | PRD | Requirements | Depends on |`); - out.push(`|---|---|---|---|`); - for (const m of srd.modules ?? []) { - out.push(`| ${cell(m.name)} | [${m.id}/PRD.md](${m.id}/PRD.md) | ${m.frIds.join(", ") || "\u2014"} | ${m.dependsOn.join(", ") || "\u2014"} |`); - } - out.push(``); - return out.join("\n"); +function deriveA11yStandard(brief) { + const explicit = brief.design?.accessibilityTarget?.trim(); + if (explicit) return explicit; + const hay = `${(brief.constraints.compliance ?? []).join(" ")} ${brief.nfrPriorities.join(" ")}`.toLowerCase(); + if (/\brgaa\b/.test(hay)) return "RGAA 4.1 (aligned to WCAG 2.2 AA)"; + if (/\b508\b|section 508/.test(hay)) return "Section 508 (WCAG 2.0 AA)"; + if (/en\s?301\s?549/.test(hay)) return "EN 301 549 (WCAG 2.1 AA)"; + return "WCAG 2.2 AA"; } -function renderFeaturePRD(fr, srd) { - const out = [`# PRD ${fr.id} \u2014 ${fr.title}${cite(fr.rationaleEvidence)}`, ``]; - out.push(`_Priority: ${fr.priority}_ \xB7 _Product: ${srd.product.name}_`, ``); - out.push(`## Context`, ``, srd.product.problem, ``); - out.push(`## Feature`, ``, fr.description, ``); - out.push(`## Acceptance criteria`, ``); - for (const a of fr.acceptance) { - out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); +function buildPrinciples(brief) { + const hay = `${brief.idea} ${brief.product.valueProp ?? ""} ${brief.product.problem ?? ""} ${brief.nfrPriorities.join(" ")} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; + const out = []; + if (/self[- ]?host|privac|gdpr|own (your|the) data|no account/i.test(hay)) { + out.push("Privacy by default \u2014 the UI never surfaces or transmits data the user did not choose to share."); } - out.push(``, `## Non-functional requirements`, ``); - if (!fr.nfrs.length) out.push(`_None linked._`); - for (const id of fr.nfrs) { - const nfr = srd.nonFunctional.find((n) => n.id === id); - out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` \u2014 metric: ${nfr.metric}` : ""}` : `- **${id}**`); + if (/fast|speed|sub-?second|latenc|instant|under \d/i.test(hay)) { + out.push("Perceived performance first \u2014 optimistic UI, skeletons over spinners, immediate feedback on every action."); } - out.push(``, `## Data & interfaces`, ``); - out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`); - out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}`); - out.push(``, `## Grounding`, ``); - out.push( - fr.rationaleEvidence.length ? `Evidence:${cite(fr.rationaleEvidence)} \u2014 see ../../evidence/EVIDENCE.md.` : `_Ungrounded \u2014 see the grounding report (construct check)._` - ); - out.push(``); - return out.join("\n"); + out.push("Accessible to everyone \u2014 every flow works with the keyboard and assistive technology, by construction."); + out.push("Consistency over novelty \u2014 reuse tokens and components before inventing new ones."); + out.push("Progressive disclosure \u2014 show the essential first; reveal complexity only on demand."); + out.push("Clear over clever \u2014 plain language, obvious affordances, honest empty and error states."); + return out.slice(0, 5); } -function renderPRDIndex(srd) { - const out = [`# PRDs \u2014 one per functional requirement`, ``]; - out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`); - out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``); - out.push(`| PRD | Priority | Title |`); - out.push(`|---|---|---|`); - for (const fr of srd.functional) { - const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`; - out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`); +function seedTokens(brief) { + const brand = brief.design?.brandConstraints?.trim(); + const byCategory = { + color: [ + { category: "color", name: "color.bg", value: "#ffffff", note: brand ? `Adjust to brand: ${brand}` : "Primary surface" }, + { category: "color", name: "color.fg", value: "#111827", note: "Primary text" }, + { category: "color", name: "color.primary", value: "#2563eb", note: "Primary action / brand accent" }, + { category: "color", name: "color.danger", value: "#dc2626", note: "Destructive / error" }, + { category: "color", name: "color.muted", value: "#6b7280", note: "Secondary text / borders" } + ], + typography: [ + { category: "typography", name: "font.sans", value: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }, + { category: "typography", name: "font.mono", value: "ui-monospace, SFMono-Regular, Menlo, monospace" }, + { category: "typography", name: "scale.body", value: "16px / 1.5" }, + { category: "typography", name: "scale.h1", value: "32px / 1.25" }, + { category: "typography", name: "scale.small", value: "13px / 1.4" } + ], + spacing: [ + { category: "spacing", name: "space.1", value: "4px" }, + { category: "spacing", name: "space.2", value: "8px" }, + { category: "spacing", name: "space.3", value: "12px" }, + { category: "spacing", name: "space.4", value: "16px" }, + { category: "spacing", name: "space.6", value: "24px" }, + { category: "spacing", name: "space.8", value: "32px" } + ], + radius: [ + { category: "radius", name: "radius.sm", value: "4px" }, + { category: "radius", name: "radius.md", value: "8px" }, + { category: "radius", name: "radius.lg", value: "12px" } + ], + elevation: [ + { category: "elevation", name: "shadow.sm", value: "0 1px 2px rgba(0,0,0,0.06)" }, + { category: "elevation", name: "shadow.md", value: "0 4px 12px rgba(0,0,0,0.10)" } + ], + motion: [ + { category: "motion", name: "motion.fast", value: "120ms ease-out" }, + { category: "motion", name: "motion.base", value: "200ms ease-out" } + ] + }; + return DESIGN_TOKEN_CATEGORIES.flatMap((c) => byCategory[c]); +} +var COMPONENT_DEFS = [ + { name: "App Shell & Navigation", purpose: "Overall layout, navigation and routing chrome that frames every screen.", re: /.*/ }, + { name: "Button & Actions", purpose: "Primary, secondary and destructive action controls with loading/disabled states.", re: /.*/ }, + { + name: "Form & Input", + purpose: "Labelled inputs with inline validation and accessible error messaging.", + re: /save|add|create|edit|import|tag|organi[sz]e|login|sign|submit|upload|compose|write|configure|invite/i + }, + { + name: "List & Collection", + purpose: "Paginated/virtualised lists of saved items with selection and bulk actions.", + re: /list|search|browse|organi[sz]e|tag|feed|library|archive|history|result|collection|inbox/i + }, + { name: "Detail View", purpose: "The focused reading/detail surface for a single item.", re: /read|view|open|detail|article|item|show|preview|document/i }, + { name: "Search & Filter", purpose: "Query input, filters and ranked results with empty/no-match handling.", re: /search|filter|find|query|sort|facet/i }, + { name: "Feedback & Notifications", purpose: "Toasts, banners and inline status for success, error and async progress.", re: /.*/ }, + { name: "Empty & Error States", purpose: "First-run, no-data and failure states that teach the next action.", re: /.*/ } +]; +function buildComponents(functional) { + const out = []; + for (const def of COMPONENT_DEFS) { + const relatedFRs = functional.filter((fr) => def.re.test(`${fr.title} ${fr.description}`)).map((fr) => fr.id); + if (relatedFRs.length === 0) continue; + out.push({ name: def.name, purpose: def.purpose, states: [...COMPONENT_STATES], relatedFRs, evidence: [] }); } - out.push(``); - return out.join("\n"); + return out; } -function renderNonFunctional(srd) { - const out = [`# Non-functional requirements`, ``]; - if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``); - for (const n of srd.nonFunctional) { - out.push(`## ${n.id} \u2014 ${n.category}${cite(n.rationaleEvidence)}`); - out.push(``); - out.push(n.statement); - if (n.metric) out.push(``, `- **Metric:** ${n.metric}`); - out.push(``); +function buildScreens(functional) { + const inScope = functional.filter((fr) => fr.priority !== "could"); + const mustIds = functional.filter((fr) => fr.priority === "must").map((fr) => fr.id); + const screens = [ + { name: "Home / Dashboard", purpose: "The landing surface after sign-in; entry point to the primary tasks.", relatedFRs: mustIds } + ]; + for (const fr of inScope) { + screens.push({ name: `${fr.title}`, purpose: `Where a user can ${lowerFirst(fr.title)}.`, relatedFRs: [fr.id] }); } - return out.join("\n"); -} -function renderSystemContext(srd) { - return [`# System context`, ``, srd.architecture.context, ``].join("\n"); + screens.push({ name: "Settings & Account", purpose: "Preferences, data export/delete and account management.", relatedFRs: [] }); + return screens; } -function renderDataModel(srd) { - const out = [`# Data model`, ``]; - const entities = srd.architecture.dataModel; - if (!entities.length) { - out.push(`_No entities defined yet. Enrich during authoring: list entities, their attributes, and which functional requirements reference each._`, ``); - return out.join("\n"); - } - out.push(`_Seeded by inference from the brief \u2014 verify each entity and extend attributes during authoring._`, ``); - for (const e of entities) { - out.push(`## ${e.name}`); - out.push(``); - if (e.attributes.length) { - out.push(`| Attribute | Type |`, `|---|---|`); - for (const a of e.attributes) out.push(`| ${cell(a.name)} | ${cell(a.type)} |`); +function buildFlows(functional) { + const must = functional.filter((fr) => fr.priority === "must"); + const flows = [ + { + name: "First-run onboarding", + steps: ["Arrive at an empty, explanatory first-run state", "Complete the minimal setup", "Reach the dashboard ready to act"], + frIds: must.map((fr) => fr.id) } - out.push(``, `_Referenced by: ${e.referencedByFRs.length ? e.referencedByFRs.join(", ") : "\u2014"}_`, ``); + ]; + for (const fr of must) { + flows.push({ + name: `${fr.title} \u2014 happy path`, + steps: ["Navigate to the relevant screen", `Perform: ${lowerFirst(fr.title)}`, "Receive clear confirmation of the outcome"], + frIds: [fr.id] + }); } - return out.join("\n"); + return flows; } -function renderInterfaces(srd) { - const out = [`# Interfaces`, ``]; - const ifaces = srd.architecture.interfaces; - if (!ifaces.length) { - out.push(`_No interfaces defined yet. Enrich during authoring: list the API/event/UI/CLI surfaces and the functional requirements each serves._`, ``); - return out.join("\n"); - } - out.push(`_Seeded by inference from the brief \u2014 verify each surface and define its contract during authoring._`, ``); - for (const i of ifaces) { - out.push(`## ${i.name} _(${i.kind})_`, ``, i.summary, ``, `_Related: ${i.relatedFRs.length ? i.relatedFRs.join(", ") : "\u2014"}_`, ``); - } - return out.join("\n"); +function a11yRequirements() { + const defs = [ + { + statement: "Every interactive control is fully keyboard operable.", + given: "a user navigates with the keyboard only", + when: "they tab through any flow", + then: "every interactive control is reachable, operable and follows a logical focus order" + }, + { + statement: "Focus is always visible.", + given: "an element receives keyboard focus", + when: "the user is navigating", + then: "a visible focus indicator is shown and meets the non-text contrast minimum" + }, + { + statement: "Colour contrast meets the target standard.", + given: "any text or essential UI element", + when: "it is rendered in any supported theme", + then: "contrast meets the target (\u2265 4.5:1 for body text, \u2265 3:1 for large text and UI)" + }, + { + statement: "Every control and image exposes an accessible name.", + given: "a form control, icon-only button or meaningful image", + when: "it is read by assistive technology", + then: "it exposes a programmatic label/name and images carry meaningful alt text (decorative images are hidden)" + }, + { + statement: "Structure and async changes are conveyed semantically.", + given: "a screen is parsed by a screen reader", + when: "the user explores it", + then: "headings, landmarks and roles convey the structure and live regions announce asynchronous changes" + }, + { + statement: "Reduced motion and zoom are respected.", + given: "a user prefers reduced motion or zooms to 200%", + when: "they use the product", + then: "non-essential motion is reduced or disabled and content reflows without loss of content or function" + } + ]; + return defs.map((d, i) => ({ + id: `A11Y-${pad3(i + 1)}`, + statement: d.statement, + acceptance: [{ given: d.given, when: d.when, then: d.then }] + })); } -function renderADR(adr) { - const out = [ - `# ${adr.id}. ${adr.title}`, - ``, - `- **Status:** ${adr.status}`, - ``, - `## Context`, - adr.context, - ``, - `## Decision`, - `${adr.decision}${cite(adr.evidence)}`, - ``, - `## Consequences`, - adr.consequences, - `` +function buildContentVoice(brief) { + const tone = brief.design?.tone?.trim(); + return [ + tone ? `Voice & tone: ${tone}.` : "Voice & tone: clear, concise and human \u2014 plain language over jargon.", + "Label actions with the outcome the user gets, not the system operation behind it.", + "Error messages state what happened, why, and the next step \u2014 never blame the user.", + "Empty states teach the first useful action; success states confirm exactly what changed." ]; - if (adr.alternatives) out.push(`## Alternatives considered`, adr.alternatives, ``); - return out.join("\n"); } -function renderLandscape(srd) { - const out = [`# Competitive landscape`, ``, `## Competitors`, ``]; - if (srd.competitive.competitors.length) { - out.push(`| Product | Note | Evidence |`, `|---|---|---|`); - for (const c of srd.competitive.competitors) { - const ev = c.evidence.length ? c.evidence.map((id) => `[${id}]`).join("") : "_ungrounded_"; - out.push(`| ${cell(c.name)} | ${cell(c.note)} | ${ev} |`); - } - } else { - out.push(`_No competitors captured. Use the market research angle to discover them._`); +function buildDesignSystem(brief, functional) { + return { + principles: buildPrinciples(brief), + tokens: seedTokens(brief), + components: buildComponents(functional), + screens: buildScreens(functional), + flows: buildFlows(functional), + accessibility: { standard: deriveA11yStandard(brief), requirements: a11yRequirements() }, + contentVoice: buildContentVoice(brief) + }; +} +function concreteOutcome(title, notes) { + const n = (notes ?? "").trim(); + const q = /\b(?:within|at least|at most|no more than|up to|under)\s+\d[^.;,]{0,60}/i.exec(n); + const m = /\b(?:never|always|so that|so it|must|should|guarantee[sd]?|ensure[sd]?|without)\b\s+([^.;,]{4,})/i.exec(n); + if (m?.[1]) { + const clause = m[1].split(/[;,]/)[0].trim().replace(/\s+/g, " "); + if (clause.length > 3 && (/\d/.test(clause) || !q)) return `the action succeeds and ${lowerFirst(clause)}`; } - out.push(``, `## Comparable open-source projects`, ``); - if (srd.competitive.oss.length) { - out.push(`| Project | Note | Evidence |`, `|---|---|---|`); - for (const o of srd.competitive.oss) { - const name = o.url ? `[${cell(o.name)}](${o.url})` : cell(o.name); - const ev = o.evidence.length ? o.evidence.map((id) => `[${id}]`).join("") : "_ungrounded_"; - out.push(`| ${name} | ${cell(o.note)} | ${ev} |`); - } - } else { - out.push(`_No OSS prior art captured. Use the oss research angle to mine comparable projects._`); + const t = /\bin under [^.;,]+/i.exec(n); + if (t) return `the action completes ${t[0].trim().replace(/\s+/g, " ")}`; + if (q) return `the outcome honours the stated bound: ${q[0].trim().replace(/\s+/g, " ").toLowerCase()}`; + return `the result of "${title.toLowerCase()}" is persisted and visible to the user`; +} +function failurePath(title, integration) { + if (integration) { + return { + given: `the external service required by "${title.toLowerCase()}" is unreachable or rejects the request`, + when: `a user performs the action`, + then: `the system surfaces a clear, specific error and makes no partial or inconsistent change` + }; } - out.push(``); - return out.join("\n"); + return { + given: `a user submits invalid or missing input for "${title.toLowerCase()}"`, + when: `the action is submitted`, + then: `the system rejects it with a clear, actionable error and no side effects` + }; } -function renderBuildPlan(srd) { - const out = [`# Build plan`, ``]; - for (const m of srd.buildPlan) { - out.push(`## ${m.title}`, ``, m.outcome, ``); - out.push(`- **Requirements:** ${m.frIds.length ? m.frIds.join(", ") : "\u2014"}`); - if (m.risks.length) { - out.push(`- **Risks:**`); - for (const r of m.risks) out.push(` - ${r}`); - } - out.push(``); +function specialiseMetric(cat, base, ctx) { + const c = cat.toLowerCase(); + if ((c === "performance" || c === "usability") && ctx.timeGoal) { + return `${base} Honour the product goal: ${ctx.timeGoal}.`; } - return out.join("\n"); + if ((c === "privacy" || c === "security") && ctx.compliance.length) { + return `${base} Comply with: ${ctx.compliance.join(", ")}.`; + } + if (c === "cost" && ctx.budget) { + return `${base} Stay within the stated budget: ${ctx.budget}.`; + } + return base; } -function renderTraceability(srd) { - const design = !!srd.design; - const modules = !!srd.modules?.length; - const cols = ["Requirement", ...modules ? ["Module"] : [], "NFRs", "ADRs", "Entities", "Interfaces", ...design ? ["Components", "Screens"] : []]; - const out = [`# Traceability matrix`, ``, `| ${cols.join(" | ")} |`, `|${cols.map(() => "---").join("|")}|`]; - for (const r of srd.traceability) { - const cells = [ - r.fr, - ...modules ? [r.module ?? "\u2014"] : [], - r.nfrs.join(", ") || "\u2014", - r.adrs.join(", ") || "\u2014", - r.entities.join(", ") || "\u2014", - r.interfaces.join(", ") || "\u2014" - ]; - if (design) { - cells.push((r.components ?? []).map(cell).join(", ") || "\u2014"); - cells.push((r.screens ?? []).map(cell).join(", ") || "\u2014"); +function specialiseStatement(cat, base, ctx) { + const c = cat.toLowerCase(); + if ((c === "privacy" || c === "security") && ctx.selfHost) { + return `${base} No personal data leaves the self-hosted instance unless the host configures it.`; + } + return base; +} +function buildMilestones(functional, _brief, evidence, evById) { + const groups = [ + { key: "must", title: "M1 \u2014 Walking skeleton (must-haves)", outcome: "A usable end-to-end slice covering every must-have requirement." }, + { key: "should", title: "M2 \u2014 Rounded product (should-haves)", outcome: "The product is complete enough for real users." }, + { key: "could", title: "M3 \u2014 Enhancements (could-haves)", outcome: "Nice-to-have capabilities that differentiate the product." } + ]; + const priorPitfalls = evidence.filter((e) => e.source === "issue" || e.source === "pr"); + const out = []; + for (const g of groups) { + const frs = functional.filter((f) => f.priority === g.key); + if (frs.length === 0) continue; + const risks = []; + const text = frs.map((f) => `${f.title} ${f.description}`).join(" "); + const matched = matchEvidence(text, priorPitfalls, 2); + for (const id of matched) { + const e = evById.get(id); + if (e) risks.push(`Prior art shows a related pitfall: ${firstSentence(e.title)} [${id}]`); } - out.push(`| ${cells.join(" | ")} |`); + out.push({ title: g.title, outcome: g.outcome, frIds: frs.map((f) => f.id), risks }); } - out.push(``); - return out.join("\n"); + if (out.length === 0) out.push({ title: "M1 \u2014 Initial build", outcome: "Deliver the first usable version.", frIds: functional.map((f) => f.id), risks: [] }); + return out; } -function renderDesignPrinciples(ds) { - return [ - `# Design principles`, - ``, - bullets(ds.principles, "No design principles captured."), - ``, - `## Content & voice`, - ``, - bullets(ds.contentVoice, "No content guidelines captured."), - `` - ].join("\n"); +function deriveAssumptions(brief) { + const a = []; + if (brief.constraints.team) a.push(`The team is: ${brief.constraints.team}.`); + if (brief.constraints.timeline) a.push(`The timeline is: ${brief.constraints.timeline}.`); + if (brief.constraints.budget) a.push(`The budget is: ${brief.constraints.budget}.`); + if (brief.constraints.compliance?.length) a.push(`Compliance applies: ${brief.constraints.compliance.join(", ")}.`); + if (a.length === 0) a.push("No hard constraints were captured; revisit budget, timeline and team before committing."); + return a; } -function renderDesignTokens(ds) { - const out = [`# Design tokens`, ``, `_${DESIGN_TOKENS_SEEDED_BANNER}_`, ``]; - const cats = [...new Set(ds.tokens.map((t) => t.category))]; - for (const cat of cats) { - const toks = ds.tokens.filter((t) => t.category === cat); - out.push(`## ${cell(cat)}`, ``, `| Token | Value | Notes |`, `|---|---|---|`); - for (const t of toks) out.push(`| ${cell(t.name)} | ${cell(t.value)} | ${cell(t.note ?? "")} |`); - out.push(``); - } - out.push("> The machine-readable token set is in `design/design-tokens.json`.", ``); - return out.join("\n"); +function contextProse(name, brief) { + const actors = brief.product.users?.length ? brief.product.users : ["users"]; + const boundaries = detectBoundaries(brief).map((b) => b.label); + const stack = brief.candidateTech.length ? ` Built on ${brief.candidateTech.join(", ")}.` : ""; + const ext = boundaries.length ? ` It integrates with: ${boundaries.join("; ")}.` : ""; + return `"${name}" serves ${actors.join(", ")}.${stack}${ext} Each integration boundary is owned by an ADR and detailed in INTERFACES.md during authoring.`; } -function renderDesignTokensJson(ds) { - const obj = {}; - for (const t of ds.tokens) { - (obj[t.category] ??= {})[t.name] = t.value; +function noteFrom(ids, evById) { + for (const id of ids) { + const e = evById.get(id); + const s = e ? firstSentence(e.snippet) : ""; + if (s) return s; } - return JSON.stringify(obj, null, 2); + return void 0; } -function renderComponents(ds) { - const out = [`# Components`, ``]; - if (!ds.components.length) { - out.push(`_No components defined yet. Enrich during authoring: name each component, its states and the requirements it realises._`, ``); - return out.join("\n"); - } - out.push(`_Seeded from the functional requirements \u2014 verify each component and its states during authoring._`, ``); - for (const c of ds.components) { - out.push(`## ${c.name}${cite(c.evidence)}`, ``, c.purpose, ``); - out.push(`- **States:** ${c.states.join(", ") || "\u2014"}`); - out.push(`- **Realises:** ${c.relatedFRs.length ? c.relatedFRs.join(", ") : "\u2014"}`, ``); +function firstSentence(s) { + const clean = s.replace(/\s+/g, " ").trim(); + if (!clean) return ""; + const m = /^(.{1,200}?[.!?])(\s|$)/.exec(clean); + return (m ? m[1] : clean.slice(0, 160)).trim(); +} +function timeTokenFromGoals(goals) { + for (const g of goals) { + const m = /\b(?:in |under |within )?(\d+)\s*(seconds?|secs?|minutes?|mins?|hours?)\b/i.exec(g); + if (m) return `complete the primary task in under ${m[1]} ${m[2].toLowerCase()}`; } - return out.join("\n"); + return void 0; } -function renderScreens(ds) { - const out = [`# Screens & flows`, ``, `## Screens`, ``]; - if (ds.screens.length) { - out.push(`| Screen | Purpose | Requirements |`, `|---|---|---|`); - for (const s of ds.screens) out.push(`| ${cell(s.name)} | ${cell(s.purpose)} | ${s.relatedFRs.join(", ") || "\u2014"} |`); - } else { - out.push(`_No screens defined._`); +function briefText(brief) { + return `${brief.idea} ${brief.product.problem ?? ""} ${brief.featureWishlist.map((f) => `${f.title} ${f.notes ?? ""}`).join(" ")}`; +} +function citationsIn(s) { + const out = []; + const re = /\[(E\d+)\]/g; + let m; + while (m = re.exec(s)) out.push(m[1]); + return out; +} +function titleFromIdea(idea) { + const first = idea.split(/[.,;:]/)[0]?.trim() || idea.trim(); + return first.length > 40 ? first.slice(0, 40).trim() : first || "The Product"; +} +function lowerFirst(s) { + const t = s.trim(); + return t ? t[0].toLowerCase() + t.slice(1) : t; +} +function evNum(id) { + const m = /^E(\d+)$/.exec(id); + return m ? Number(m[1]) : 1e9; +} + +// src/plan.ts +import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs"; +import { join as join9 } from "path"; +function buildPlanPath(runDir) { + return join9(runDir, "BUILD-PLAN.json"); +} +function milestoneLabel(title) { + return title.split("\u2014")[0].trim() || title.trim(); +} +function pad32(n) { + return String(n).padStart(3, "0"); +} +function derivePlan(srd) { + const frById = new Map(srd.functional.map((f) => [f.id, f])); + const ordered = []; + const seen = /* @__PURE__ */ new Set(); + for (const m of srd.buildPlan) { + for (const frId of m.frIds) { + if (frById.has(frId) && !seen.has(frId)) { + ordered.push({ frId, milestone: milestoneLabel(m.title) }); + seen.add(frId); + } + } } - out.push(``, `## User flows`, ``); - if (ds.flows.length) { - for (const f of ds.flows) { - out.push(`### ${f.name}${f.frIds.length ? ` _(${f.frIds.join(", ")})_` : ""}`, ``); - f.steps.forEach((step, i) => out.push(`${i + 1}. ${step}`)); - out.push(``); + for (const f of srd.functional) { + if (!seen.has(f.id)) ordered.push({ frId: f.id, milestone: "M1" }); + } + const tasks = [ + { + id: "T-000", + title: "Project skeleton \u2014 repo layout, test harness, CI", + milestone: ordered[0]?.milestone ?? "M1", + frIds: [], + acceptance: [], + dependsOn: [], + artifacts: [], + tests: [], + verify: { commands: [] }, + status: "todo" } - } else { - out.push(`_No user flows defined._`); + ]; + ordered.forEach(({ frId, milestone }, i) => { + const fr = frById.get(frId); + const dependsOn = ["T-000"]; + if (fr.entities.length) { + for (let j = 0; j < i; j++) { + const prev = ordered[j]; + if (prev.milestone === milestone) continue; + const prevFr = frById.get(prev.frId); + if (prevFr.entities.some((e) => fr.entities.includes(e))) { + dependsOn.push(`T-${pad32(j + 1)}`); + break; + } + } + } + tasks.push({ + id: `T-${pad32(i + 1)}`, + title: `${fr.id} \u2014 ${fr.title}`, + milestone, + ...fr.module ? { module: fr.module } : {}, + frIds: [fr.id], + acceptance: fr.acceptance.map((_, idx) => ({ frId: fr.id, index: idx })), + dependsOn, + artifacts: [], + tests: [], + verify: { commands: [] }, + status: "todo" + }); + }); + if (srd.design) { + tasks.push({ + id: `T-${pad32(ordered.length + 1)}`, + title: "Design foundation \u2014 design tokens, base components, accessibility baseline", + milestone: ordered[0]?.milestone ?? "M1", + frIds: [], + acceptance: [], + dependsOn: ["T-000"], + artifacts: [], + tests: [], + verify: { commands: [] }, + status: "todo" + }); } - return out.join("\n"); + return { + schemaVersion: BUILD_PLAN_SCHEMA_VERSION, + product: srd.product.name, + generatedAt: srd.generatedAt, + conventions: { frTagPattern: "FR-\\d{3}", testCommand: null, appDir: null }, + tasks + }; } -function renderAccessibility(ds) { - const a = ds.accessibility; - const out = [`# Accessibility`, ``, `**Target standard:** ${a.standard}`, ``]; - if (!a.requirements.length) { - out.push(`_No accessibility requirements defined._`, ``); - return out.join("\n"); - } - for (const r of a.requirements) { - out.push(`## ${r.id} \u2014 ${r.statement}`, ``, `**Acceptance criteria:**`); - for (const c of r.acceptance) out.push(`- **Given** ${c.given} **When** ${c.when} **Then** ${c.then}`); - out.push(``); +var STATUSES2 = ["todo", "in-progress", "done"]; +function taskKey(t) { + return t.frIds.length ? `fr:${t.title.replace(/^FR-\d+\s*—\s*/, "").trim().toLowerCase()}` : `title:${t.title.trim().toLowerCase()}`; +} +function mergePlan(prev, next) { + if (!prev) return next; + const prevByKey = new Map(prev.tasks.map((t) => [taskKey(t), t])); + const tasks = next.tasks.map((t) => { + const old = prevByKey.get(taskKey(t)); + if (!old) return t; + return { + ...t, + artifacts: Array.isArray(old.artifacts) ? old.artifacts : t.artifacts, + tests: Array.isArray(old.tests) ? old.tests : t.tests, + verify: old.verify && Array.isArray(old.verify.commands) ? old.verify : t.verify, + status: STATUSES2.includes(old.status) ? old.status : t.status + }; + }); + return { + ...next, + conventions: { + frTagPattern: next.conventions.frTagPattern, + testCommand: prev.conventions?.testCommand ?? null, + appDir: prev.conventions?.appDir ?? null + }, + tasks + }; +} +function readyFrontier(plan) { + const done = new Set(plan.tasks.filter((t) => t.status === "done").map((t) => t.id)); + const tasks = plan.tasks.map((t) => ({ + id: t.id, + milestone: t.milestone, + status: t.status, + dependsOn: t.dependsOn, + ready: t.status !== "done" && t.dependsOn.every((d) => done.has(d)) + })); + return { + product: plan.product, + done: done.size, + total: plan.tasks.length, + tasks, + frontier: tasks.filter((t) => t.ready).map((t) => t.id), + blocked: tasks.filter((t) => t.status !== "done" && !t.ready).map((t) => ({ id: t.id, waitingOn: t.dependsOn.filter((d) => !done.has(d)) })) + }; +} +function loadPlan(runDir) { + const path = buildPlanPath(runDir); + if (!existsSync5(path)) return null; + try { + const data = JSON.parse(readFileSync4(path, "utf8")); + return data && typeof data === "object" && Array.isArray(data.tasks) ? data : null; + } catch { + return null; } - return out.join("\n"); } -function renderMergeBundle(srd) { - const parts = [ - `# Software Requirements Document \u2014 ${srd.product.name}`, - ``, - `_Level: ${srd.level} \xB7 generated: ${srd.generatedAt}_`, - ``, - renderVision(srd), - renderScope(srd), - // Always the full FR blocks: the bundle is the one-file reading copy, so it - // must stay complete even when FUNCTIONAL.md is an index (modules mode). - renderFunctionalFull(srd), - renderNonFunctional(srd), - renderSystemContext(srd), - renderDataModel(srd), - renderInterfaces(srd), - `# Architecture decisions`, - ``, - ...srd.architecture.adrs.map(renderADR), - ...srd.design ? [ - `# Design system`, - ``, - renderDesignPrinciples(srd.design), - renderDesignTokens(srd.design), - renderComponents(srd.design), - renderScreens(srd.design), - renderAccessibility(srd.design) - ] : [], - renderLandscape(srd), - renderBuildPlan(srd), - renderTraceability(srd) - ]; - return parts.join("\n"); +function writePlan(runDir, plan) { + const path = buildPlanPath(runDir); + writeFileSync4(path, JSON.stringify(plan, null, 2) + "\n"); + return path; } // src/render.ts function writeFile(out, rel, content, files) { - const abs = join9(out, rel); - mkdirSync4(dirname2(abs), { recursive: true }); - writeFileSync4(abs, content.endsWith("\n") ? content : content + "\n"); + const abs = join10(out, rel); + mkdirSync5(dirname2(abs), { recursive: true }); + writeFileSync5(abs, content.endsWith("\n") ? content : content + "\n"); files.push(rel); } function renderSRD(brief, evidence, opts) { const wantDesign = opts.level === "complex" && !opts.noDesign; const srd = buildSRD(brief, evidence, { level: opts.level, generatedAt: opts.generatedAt, design: wantDesign }); + return emitSRD(srd, { out: opts.out, merge: opts.merge, prd: opts.prd }); +} +function renderFromSRD(runDir, opts) { + const manifest = srdManifestPath(runDir); + if (!existsSync6(manifest)) { + throw new Error(`No SRD.json in ${runDir} \u2014 render the SRD first (construct render), then edit it and re-run with --from-srd.`); + } + let srd; + try { + srd = JSON.parse(readFileSync5(manifest, "utf8")); + } catch (e) { + throw new Error(`SRD.json is unreadable: ${e.message}`); + } + if (!Array.isArray(srd.functional) || !Array.isArray(srd.nonFunctional) || !srd.architecture || !Array.isArray(srd.architecture.adrs)) { + throw new Error(`SRD.json in ${runDir} is not a valid SRD manifest (missing functional/nonFunctional/architecture).`); + } + return emitSRD(srd, { out: runDir, merge: opts.merge, prd: opts.prd }); +} +function emitSRD(srd, opts) { const files = []; const out = opts.out; - rmSync2(join9(out, "architecture", "decisions"), { recursive: true, force: true }); - rmSync2(join9(out, "design"), { recursive: true, force: true }); - rmSync2(join9(out, "prd"), { recursive: true, force: true }); + rmSync2(join10(out, "architecture", "decisions"), { recursive: true, force: true }); + rmSync2(join10(out, "design"), { recursive: true, force: true }); + rmSync2(join10(out, "prd"), { recursive: true, force: true }); writeFile(out, "00-overview/VISION.md", renderVision(srd), files); writeFile(out, "00-overview/SCOPE.md", renderScope(srd), files); writeFile(out, "requirements/FUNCTIONAL.md", renderFunctional(srd), files); - rmSync2(join9(out, "requirements", "prd"), { recursive: true, force: true }); + rmSync2(join10(out, "requirements", "prd"), { recursive: true, force: true }); if (opts.prd) { for (const fr of srd.functional) { writeFile(out, `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`, renderFeaturePRD(fr, srd), files); @@ -2978,19 +3329,245 @@ function renderSRD(brief, evidence, opts) { writeFile(out, "design/SCREENS.md", renderScreens(srd.design), files); writeFile(out, "design/ACCESSIBILITY.md", renderAccessibility(srd.design), files); } - writeFileSync4(srdManifestPath(out), JSON.stringify(srd, null, 2) + "\n"); + writeFileSync5(srdManifestPath(out), JSON.stringify(srd, null, 2) + "\n"); files.push("SRD.json"); if (opts.merge) { writeFile(out, "SRD.md", renderMergeBundle(srd), files); } else { - rmSync2(join9(out, "SRD.md"), { force: true }); + rmSync2(join10(out, "SRD.md"), { force: true }); } return { dir: out, files, srd }; } // src/check.ts -import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync3, statSync as statSync2 } from "fs"; -import { join as join10, relative as relative2, sep as sep2 } from "path"; +import { existsSync as existsSync8, readFileSync as readFileSync7, readdirSync as readdirSync3, statSync as statSync2 } from "fs"; +import { join as join12, relative as relative2, sep as sep2 } from "path"; + +// src/review.ts +import { existsSync as existsSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs"; +import { join as join11 } from "path"; +var VALID_VERDICTS = ["supported", "partial", "refuted", "unsupported"]; +function loadEvidence(path) { + if (!existsSync7(path)) return []; + try { + const data = JSON.parse(readFileSync6(path, "utf8")); + return Array.isArray(data) ? data.filter( + (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" + ) : []; + } catch { + return []; + } +} +function srdClaims(srd) { + const out = []; + for (const f of srd.functional) { + const ac = f.acceptance.map((a) => `${a.given} / ${a.when} / ${a.then}`).join("; "); + out.push({ id: f.id, kind: "FR", text: `${f.title}: ${f.description}${ac ? " \u2014 " + ac : ""}`, ev: f.rationaleEvidence }); + } + for (const n of srd.nonFunctional) { + out.push({ id: n.id, kind: "NFR", text: `${n.category}: ${n.statement}${n.metric ? ` (${n.metric})` : ""}`, ev: n.rationaleEvidence }); + } + for (const a of srd.architecture.adrs) { + out.push({ id: `ADR-${a.id}`, kind: "ADR", text: `${a.title}: ${a.decision}`, ev: a.evidence }); + } + srd.competitive.competitors.forEach((c, i) => out.push({ id: `COMP-${i + 1}`, kind: "competitor", text: `${c.name}: ${c.note}`, ev: c.evidence })); + srd.competitive.oss.forEach((o, i) => out.push({ id: `OSS-${i + 1}`, kind: "oss", text: `${o.name}: ${o.note}`, ev: o.evidence })); + return out; +} +function claimDigest(snippet, claim, cap = 600) { + if (snippet.length <= cap) return snippet; + const kws = keywords(claim).map((k) => k.toLowerCase()); + if (!kws.length) return snippet.slice(0, cap); + const step = 150; + let best = 0; + let bestCov = -1; + for (let start = 0; start === 0 || start + cap / 2 < snippet.length; start += step) { + const w = snippet.slice(start, start + cap).toLowerCase(); + let cov = 0; + for (const kw of kws) if (w.includes(kw)) cov++; + if (cov >= bestCov) { + bestCov = cov; + best = start; + } + } + return (best > 0 ? "\u2026 " : "") + snippet.slice(best, best + cap).trim(); +} +function runReview(runDir, opts = {}) { + const manifest = srdManifestPath(runDir); + if (!existsSync7(manifest)) throw new Error(`No SRD.json in ${runDir} \u2014 render the SRD first (construct render).`); + let srd; + try { + srd = JSON.parse(readFileSync6(manifest, "utf8")); + } catch (e) { + throw new Error(`SRD.json is unreadable: ${e.message}`); + } + const evidence = loadEvidence(join11(runDir, "evidence", "evidence.json")); + const byId = new Map(evidence.map((e) => [e.id, e])); + const pairs = []; + for (const c of srdClaims(srd)) { + for (const id of [...new Set(c.ev)]) { + const e = byId.get(id); + if (!e) continue; + const digest = claimDigest(e.snippet || e.title || e.ref, c.text); + pairs.push({ + claimId: c.id, + kind: c.kind, + claim: c.text.trim().slice(0, 400), + evidenceId: id, + source: e.source, + // A low-signal snippet (no keyword-matched excerpt — likely boilerplate) + // is flagged so the judge adjudicates it skeptically instead of granting + // "supported" on the URL alone. + digest: e.meta?.lowSignal ? `[low-signal snippet \u2014 no keyword-matched excerpt; adjudicate skeptically] ${digest}` : digest, + score: e.score + }); + } + } + const max = opts.maxReview === void 0 ? Number.POSITIVE_INFINITY : Math.max(1, Math.floor(opts.maxReview)); + const sorted = pairs.length > max ? pairs.slice().sort((a, b) => b.score - a.score || a.claimId.localeCompare(b.claimId) || a.evidenceId.localeCompare(b.evidenceId)) : pairs; + const kept = sorted.slice(0, Math.min(sorted.length, max)); + const dropped = sorted.slice(kept.length); + const worklist = { run: runDir, pairs: kept.map(({ score, ...rest }) => rest) }; + const todo = { + run: runDir, + pairs: worklist.pairs.map((p) => ({ ...p, verdict: null, note: "" })) + }; + writeFileSync6(join11(runDir, "VERIFY.todo.json"), JSON.stringify(todo, null, 2)); + writeFileSync6(join11(runDir, "VERIFY.md"), renderWorklistMd(worklist, pairs.length, dropped)); + return worklist; +} +function renderWorklistMd(wl, total, dropped) { + const out = []; + out.push(`# Claim-support review worklist`); + out.push(""); + out.push( + `For each pair, open the cited evidence and judge whether it **supports** the claim. In \`VERIFY.todo.json\`, set each \`verdict\` to one of supported \xB7 partial \xB7 refuted \xB7 unsupported, add a short \`note\`, save it (e.g. as \`verdicts.json\`), then run \`construct review --apply verdicts.json --out \`.` + ); + if (dropped.length) { + out.push(""); + out.push(`> **DROPPED (--max-review): ${dropped.length} of ${total} pair(s) are NOT in this worklist and will NOT be adjudicated.**`); + out.push(`> Their claims can pass \`check --semantic\` without their evidence ever being judged.`); + out.push(`> Re-run \`construct review\` without --max-review to review everything. Dropped:`); + for (const d of dropped) out.push(`> - ${d.claimId} \xB7 ${d.evidenceId} (${d.source})`); + } + out.push(""); + for (const p of wl.pairs) { + out.push(`## ${p.claimId} \xB7 ${p.evidenceId} (${p.source})`); + out.push(`**Claim (${p.kind}):** ${p.claim}`); + out.push(`**Cited evidence:** ${p.digest}`); + out.push(`**Verdict:** _____ \xB7 **Note:** _____`); + out.push(""); + } + return out.join("\n"); +} +function applyVerdicts(runDir, verdictsPath) { + if (!existsSync7(verdictsPath)) throw new Error(`verdicts file not found: ${verdictsPath}`); + let raw; + try { + raw = JSON.parse(readFileSync6(verdictsPath, "utf8")); + } catch (e) { + throw new Error(`verdicts file is not valid JSON (${verdictsPath}): ${e.message}`); + } + const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && Array.isArray(raw.pairs) ? raw.pairs : null; + if (list === null) { + throw new Error(`verdicts file must be a JSON array of verdicts or an object with a "pairs" array (${verdictsPath}).`); + } + const verdicts = []; + const seen = /* @__PURE__ */ new Set(); + const key = (claimId, evidenceId) => `${claimId}::${evidenceId}`; + for (const v of list) { + if (!v || typeof v.claimId !== "string" || typeof v.evidenceId !== "string") continue; + const verdict = VALID_VERDICTS.includes(v.verdict) ? v.verdict : void 0; + verdicts.push({ + claimId: v.claimId, + kind: v.kind, + claim: typeof v.claim === "string" ? v.claim : "", + evidenceId: v.evidenceId, + source: v.source, + digest: typeof v.digest === "string" ? v.digest : "", + verdict, + note: typeof v.note === "string" ? v.note : "" + }); + seen.add(key(v.claimId, v.evidenceId)); + } + const todoPath = join11(runDir, "VERIFY.todo.json"); + if (existsSync7(todoPath)) { + try { + const todo = JSON.parse(readFileSync6(todoPath, "utf8")); + for (const p of todo.pairs ?? []) { + if (!p || typeof p.claimId !== "string" || typeof p.evidenceId !== "string") continue; + if (seen.has(key(p.claimId, p.evidenceId))) continue; + verdicts.push({ + claimId: p.claimId, + kind: p.kind, + claim: p.claim ?? "", + evidenceId: p.evidenceId, + source: p.source, + digest: p.digest ?? "", + verdict: void 0, + note: "" + }); + seen.add(key(p.claimId, p.evidenceId)); + } + } catch { + } + } + const result = reduceVerdicts(verdicts); + writeFileSync6(join11(runDir, "VERIFY.json"), JSON.stringify({ ...result, verdicts }, null, 2)); + return result; +} +function reduceVerdicts(verdicts) { + const counts = { supported: 0, partial: 0, refuted: 0, unsupported: 0 }; + for (const v of verdicts) if (v.verdict && counts[v.verdict] !== void 0) counts[v.verdict]++; + const byClaim = /* @__PURE__ */ new Map(); + for (const v of verdicts) { + const group = byClaim.get(v.claimId) ?? []; + group.push(v); + byClaim.set(v.claimId, group); + } + const failures = []; + const unadjudicated = []; + for (const [claimId, group] of byClaim) { + const adjudicated = group.filter((g) => !!g.verdict); + if (adjudicated.length < group.length) unadjudicated.push(claimId); + const refuted = adjudicated.find((g) => g.verdict === "refuted"); + const hasSupport = adjudicated.some((g) => g.verdict === "supported" || g.verdict === "partial"); + if (refuted) { + failures.push({ claimId, evidenceId: refuted.evidenceId, verdict: "refuted", note: refuted.note }); + } else if (adjudicated.length === group.length && adjudicated.length > 0 && !hasSupport) { + const u = adjudicated.find((g) => g.verdict === "unsupported") ?? adjudicated[0]; + failures.push({ claimId, evidenceId: u.evidenceId, verdict: u.verdict, note: u.note }); + } + } + return { + ok: failures.length === 0, + pairs: verdicts.length, + adjudicated: verdicts.filter((v) => !!v.verdict).length, + supported: counts.supported, + partial: counts.partial, + refuted: counts.refuted, + unsupported: counts.unsupported, + failures, + unadjudicated + }; +} +function formatReviewReport(r) { + const lines = []; + lines.push(`construct review: ${r.adjudicated}/${r.pairs} pair(s) adjudicated`); + lines.push(` supported: ${r.supported} \xB7 partial: ${r.partial} \xB7 refuted: ${r.refuted} \xB7 unsupported: ${r.unsupported}`); + for (const f of r.failures.slice(0, 12)) { + lines.push(` \u2717 ${f.claimId} (${f.evidenceId}): ${f.verdict}${f.note ? " \u2014 " + f.note : ""}`); + } + if (r.unadjudicated.length) { + lines.push(` \u26A0 ${r.unadjudicated.length} claim(s) not fully adjudicated: ${r.unadjudicated.join(", ")}`); + } + lines.push( + !r.ok ? ` \u2717 some claims are refuted or unsupported` : r.unadjudicated.length ? ` \u2713 no refuted or unsupported claims (${r.unadjudicated.length} still unadjudicated \u2014 see above)` : ` \u2713 every grounded claim is backed by its cited evidence` + ); + return lines.join("\n"); +} + +// src/check.ts var DESIGN_REQUIRED_FILES = [ "design/PRINCIPLES.md", "design/DESIGN-TOKENS.md", @@ -3021,7 +3598,7 @@ function mdFiles(runDir) { continue; } for (const name of entries) { - const abs = join10(dir, name); + const abs = join12(dir, name); let st; try { st = statSync2(abs); @@ -3039,13 +3616,23 @@ function mdFiles(runDir) { } return out.sort(); } -function loadEvidence(runDir) { - const path = join10(runDir, "evidence", "evidence.json"); - if (!existsSync5(path)) { +function countProposedIdeas(runDir) { + const p = join12(runDir, "brainstorm.json"); + if (!existsSync8(p)) return 0; + try { + const data = JSON.parse(readFileSync7(p, "utf8")); + return Array.isArray(data.ideas) ? data.ideas.filter((i) => i && i.status === "proposed").length : 0; + } catch { + return 0; + } +} +function loadEvidence2(runDir) { + const path = join12(runDir, "evidence", "evidence.json"); + if (!existsSync8(path)) { return { evidence: [], note: `No evidence/evidence.json \u2014 grounding coverage is 0 (run \`construct research\` to ground the SRD).` }; } try { - const data = JSON.parse(readFileSync4(path, "utf8")); + const data = JSON.parse(readFileSync7(path, "utf8")); const evidence = Array.isArray(data) ? data.filter( (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" ) : []; @@ -3093,28 +3680,46 @@ function computeCoverage(srd, evidence) { } var TEMPLATED_THEN_RE = /is persisted and visible to the user$/; var TEMPLATED_METRIC_RE = /^A measurable target for "/; -function applySemantic(runDir, result) { - const p = join10(runDir, "VERIFY.json"); - if (!existsSync5(p)) { - result.structural.warnings.push("--semantic: no VERIFY.json \u2014 run `construct review` then `review --apply ` first; semantic gate skipped."); +function applySemantic(runDir, result, allowUnverified) { + const p = join12(runDir, "VERIFY.json"); + const skip = (reason, hint) => { + if (allowUnverified) { + result.structural.warnings.push(`--semantic: ${reason} \u2014 ${hint}; semantic gate skipped (--allow-unverified).`); + } else { + result.semanticError = `${reason} \u2014 ${hint}, or pass --allow-unverified to degrade this to a warning.`; + result.ok = false; + } + }; + if (!existsSync8(p)) { + skip("no VERIFY.json", "run `construct review` then `review --apply ` first"); return; } + let sem; try { - const sem = JSON.parse(readFileSync4(p, "utf8")); - result.semantic = sem; - if (!sem.ok) result.ok = false; - if (sem.unadjudicated?.length) { - result.structural.warnings.push(`${sem.unadjudicated.length} claim(s) not fully adjudicated by review.`); - } + sem = JSON.parse(readFileSync7(p, "utf8")); } catch (e) { - result.structural.warnings.push(`--semantic: VERIFY.json is unreadable (${e.message}).`); + skip(`VERIFY.json is unreadable (${e.message})`, "re-run `review --apply ` to regenerate it"); + return; + } + if (!Array.isArray(sem.verdicts)) { + skip("VERIFY.json carries no verdicts[] (legacy or hand-edited)", "re-run `review --apply ` to regenerate it"); + return; + } + const reduced = reduceVerdicts(sem.verdicts); + if (reduced.ok !== sem.ok) { + result.structural.warnings.push("VERIFY.json's persisted summary disagreed with its verdicts \u2014 recomputed at check time."); + } + result.semantic = { ...reduced, verdicts: sem.verdicts }; + if (!reduced.ok) result.ok = false; + if (reduced.unadjudicated.length) { + result.structural.warnings.push(`${reduced.unadjudicated.length} claim(s) not fully adjudicated by review.`); } } function checkDesign(runDir, srd, errors, warnings) { const ds = srd.design; if (!ds) return; for (const f of DESIGN_REQUIRED_FILES) { - if (!existsSync5(join10(runDir, f))) errors.push(`Missing required design file: ${f} (re-render at --level complex).`); + if (!existsSync8(join12(runDir, f))) errors.push(`Missing required design file: ${f} (re-render at --level complex).`); } const frIds = new Set(srd.functional.map((f) => f.id)); if (ds.components.length === 0) errors.push("Design system has no components \u2014 a complex SRD's design must name its UI components."); @@ -3136,8 +3741,8 @@ function checkDesign(runDir, srd, errors, warnings) { for (const r of ds.accessibility.requirements) { if (!r.acceptance.length) errors.push(`Accessibility requirement ${r.id} has no acceptance criteria.`); } - const tokenDoc = join10(runDir, "design", "DESIGN-TOKENS.md"); - if (existsSync5(tokenDoc) && readFileSync4(tokenDoc, "utf8").includes(DESIGN_TOKENS_SEEDED_BANNER)) { + const tokenDoc = join12(runDir, "design", "DESIGN-TOKENS.md"); + if (existsSync8(tokenDoc) && readFileSync7(tokenDoc, "utf8").includes(DESIGN_TOKENS_SEEDED_BANNER)) { warnings.push("Design tokens are still seeded defaults \u2014 replace them with the product's real brand values (see references/design-system-authoring.md)."); } } @@ -3145,11 +3750,11 @@ function checkModules(runDir, srd, errors, warnings) { const mods = srd.modules; if (!mods?.length) return; const moduleIds = new Set(mods.map((m) => m.id)); - if (!existsSync5(join10(runDir, "prd", "README.md"))) { + if (!existsSync8(join12(runDir, "prd", "README.md"))) { errors.push(`Missing required module-PRD index: prd/README.md (re-render).`); } for (const m of mods) { - if (!existsSync5(join10(runDir, "prd", m.id, "PRD.md"))) { + if (!existsSync8(join12(runDir, "prd", m.id, "PRD.md"))) { errors.push(`Missing required module PRD: prd/${m.id}/PRD.md (re-render).`); } for (const dep of m.dependsOn) { @@ -3180,22 +3785,22 @@ function checkRun(runDir, opts = {}) { resolved: [] }; for (const f of REQUIRED_FILES) { - if (!existsSync5(join10(runDir, f))) errors.push(`Missing required file: ${f} (run \`construct render --out ${runDir}\`).`); + if (!existsSync8(join12(runDir, f))) errors.push(`Missing required file: ${f} (run \`construct render --out ${runDir}\`).`); } const manifest = srdManifestPath(runDir); - if (!existsSync5(manifest)) { + if (!existsSync8(manifest)) { errors.push(`No SRD.json in ${runDir} \u2014 render the SRD first.`); return { ok: false, structural: { ok: false, errors, warnings }, coverage: emptyCoverage }; } let srd; try { - srd = JSON.parse(readFileSync4(manifest, "utf8")); + srd = JSON.parse(readFileSync7(manifest, "utf8")); } catch (e) { errors.push(`SRD.json is unreadable: ${e.message}`); return { ok: false, structural: { ok: false, errors, warnings }, coverage: emptyCoverage }; } for (const rel of mdFiles(runDir)) { - const text = readFileSync4(join10(runDir, rel), "utf8"); + const text = readFileSync7(join12(runDir, rel), "utf8"); if (DECISION_RE.test(text)) errors.push(`Unresolved decision (\u{1F9E0}) in ${rel} \u2014 resolve it before the SRD is complete.`); else if (PLACEHOLDER_RE.test(text)) warnings.push(`Possible leftover placeholder (TODO/TBD/FIXME) in ${rel} \u2014 confirm it is intentional.`); } @@ -3239,15 +3844,19 @@ function checkRun(runDir, opts = {}) { checkModules(runDir, srd, errors, warnings); const templatedThen = srd.functional.reduce((n, fr) => n + fr.acceptance.filter((a) => TEMPLATED_THEN_RE.test(a.then)).length, 0); if (templatedThen) { - warnings.push( - `${templatedThen} acceptance criteria are still renderer-templated \u2014 sharpen them into observable, bounded outcomes (see references/acceptance-criteria.md).` - ); + const msg = `${templatedThen} acceptance criteria are still renderer-templated \u2014 sharpen them into observable, bounded outcomes (see references/acceptance-criteria.md).`; + if (srd.level === "complex") errors.push(msg); + else warnings.push(msg); } const templatedMetrics = srd.nonFunctional.filter((n) => n.metric && TEMPLATED_METRIC_RE.test(n.metric)).length; if (templatedMetrics) { warnings.push(`${templatedMetrics} NFR metric(s) are still generic placeholders \u2014 set measurable targets (see references/acceptance-criteria.md).`); } - const { evidence, note } = loadEvidence(runDir); + const proposedIdeas = countProposedIdeas(runDir); + if (proposedIdeas > 0) { + warnings.push(`brainstorm: ${proposedIdeas} idea(s) still 'proposed' \u2014 adjudicate (kept/parked/rejected) and run \`construct brainstorm --merge\`.`); + } + const { evidence, note } = loadEvidence2(runDir); if (note) warnings.push(note); const coverage = computeCoverage(srd, evidence); if (coverage.dangling.length) { @@ -3263,7 +3872,12 @@ function checkRun(runDir, opts = {}) { } const ok = structuralOk && (grounding?.ok ?? true); const result = { ok, structural: { ok: structuralOk, errors, warnings }, coverage, grounding }; - if (opts.semantic) applySemantic(runDir, result); + if (opts.semantic) { + applySemantic(runDir, result, opts.allowUnverified ?? false); + } else if (coverage.resolved.length > 0) { + const citedClaims = coverage.frGrounded + coverage.nfrGrounded + coverage.adrGrounded; + result.semanticSkipped = { citedClaims, verifyExists: existsSync8(join12(runDir, "VERIFY.json")) }; + } return result; } function pct(part, total) { @@ -3294,6 +3908,21 @@ function formatCheckReport(r, runDir) { g.ok ? ` \u2713 PASS \u2014 ${g.actualPct}% of groundable claims are grounded (threshold ${g.threshold}%)` : ` \u2717 FAIL \u2014 ${g.actualPct}% of groundable claims are grounded, below the ${g.threshold}% threshold` ); } + if (r.semanticSkipped) { + const s = r.semanticSkipped; + lines.push(``); + lines.push(`Semantic gate: SKIPPED`); + lines.push(` \u26A0 ${s.citedClaims} cited claim(s) were never adversarially verified \u2014 a citation`); + lines.push( + s.verifyExists ? ` proves nothing until reviewed. A VERIFY.json exists \u2014 re-run with --semantic to gate on it.` : ` proves nothing until reviewed. Run \`construct review --out \`, adjudicate the` + ); + if (!s.verifyExists) lines.push(` worklist, then \`construct check --semantic\`.`); + } + if (r.semanticError) { + lines.push(``); + lines.push(`Semantic claim-support gate (--semantic):`); + lines.push(` \u2717 FAIL \u2014 ${r.semanticError}`); + } if (r.semantic) { const s = r.semantic; lines.push(``); @@ -3308,13 +3937,13 @@ function formatCheckReport(r, runDir) { } // src/analyze.ts -import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs"; -import { join as join11 } from "path"; -function loadEvidence2(runDir) { - const path = join11(runDir, "evidence", "evidence.json"); - if (!existsSync6(path)) return []; +import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs"; +import { join as join13 } from "path"; +function loadEvidence3(runDir) { + const path = join13(runDir, "evidence", "evidence.json"); + if (!existsSync9(path)) return []; try { - const data = JSON.parse(readFileSync5(path, "utf8")); + const data = JSON.parse(readFileSync8(path, "utf8")); return Array.isArray(data) ? data.filter( (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" ) : []; @@ -3323,10 +3952,10 @@ function loadEvidence2(runDir) { } } function loadMetaNotes(runDir) { - const path = join11(runDir, "evidence", "meta.json"); - if (!existsSync6(path)) return []; + const path = join13(runDir, "evidence", "meta.json"); + if (!existsSync9(path)) return []; try { - const meta = JSON.parse(readFileSync5(path, "utf8")); + const meta = JSON.parse(readFileSync8(path, "utf8")); return Array.isArray(meta.notes) ? meta.notes.filter((n) => typeof n === "string") : []; } catch { return []; @@ -3337,7 +3966,7 @@ function featureText(f) { } function analyzeRun(runDir) { const brief = loadBrief(runDir); - const evidence = loadEvidence2(runDir); + const evidence = loadEvidence3(runDir); const notes = loadMetaNotes(runDir); const drill = (cmd, q) => `construct ${cmd} --out ${runDir} --q "${q.replace(/"/g, "'")}"`; const bySource = {}; @@ -3345,6 +3974,10 @@ function analyzeRun(runDir) { if (evidence.length === 0) { notes.push("No evidence dossier \u2014 run `construct research` first; everything below will render ungrounded."); } + const lowSignal = evidence.filter((e) => e.meta?.lowSignal).length; + if (lowSignal) { + notes.push(`${lowSignal} low-signal snippet(s) in the dossier \u2014 likely boilerplate; re-drill with a sharper --q or a better --docs-url.`); + } const suggestions = []; const ungroundedFeatures = brief.featureWishlist.filter((f) => matchEvidence(featureText(f), evidence, 1, GROUND_REQUIREMENT).length === 0).map((f) => ({ title: f.title, priority: f.priority ?? "should" })); for (const f of ungroundedFeatures) suggestions.push(drill("web", f.title)); @@ -3397,8 +4030,8 @@ function formatGapReport(r, runDir) { } // src/verify.ts -import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs"; -import { isAbsolute, join as join12, resolve as resolve2 } from "path"; +import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs"; +import { isAbsolute, join as join14, resolve as resolve2 } from "path"; var TEST_FILE_RE = /\.(test|spec)\.[^./]+$|_(test|spec)\.[^./]+$|(^|\/)test_[^/]+\.[^./]+$/i; var TEST_SUFFIX_RE = /(^|\/)[^/]*[A-Z]\w*Tests?\.(java|kt|kts|cs|scala|groovy)$/; var TEST_DIR_RE = /(^|\/)(tests?|__tests__|spec|specs|e2e)\//i; @@ -3436,7 +4069,7 @@ function verifyRun(runDir, opts = {}) { const warnings = []; const frTestCoverage = []; const planPath = buildPlanPath(runDir); - if (!existsSync7(planPath)) { + if (!existsSync10(planPath)) { errors.push(`No BUILD-PLAN.json in ${runDir} \u2014 render the SRD first (construct render).`); return { ok: false, errors, warnings, frTestCoverage }; } @@ -3450,13 +4083,13 @@ function verifyRun(runDir, opts = {}) { return { ok: false, errors, warnings, frTestCoverage }; } const manifest = srdManifestPath(runDir); - if (!existsSync7(manifest)) { + if (!existsSync10(manifest)) { errors.push(`No SRD.json in ${runDir} \u2014 the plan cannot be verified against a missing SRD.`); return { ok: false, errors, warnings, frTestCoverage }; } let srd; try { - srd = JSON.parse(readFileSync6(manifest, "utf8")); + srd = JSON.parse(readFileSync9(manifest, "utf8")); } catch (e) { errors.push(`SRD.json is unreadable: ${e.message}`); return { ok: false, errors, warnings, frTestCoverage }; @@ -3500,13 +4133,13 @@ function verifyRun(runDir, opts = {}) { const ok2 = errors.length === 0; return { ok: ok2, errors, warnings, frTestCoverage }; } - if (!existsSync7(appDir)) { + if (!existsSync10(appDir)) { errors.push(`App directory does not exist: ${appDir}.`); return { ok: false, errors, warnings, frTestCoverage }; } for (const t of doneTasks) { for (const rel of [...t.artifacts, ...t.tests]) { - if (!existsSync7(join12(appDir, rel))) errors.push(`${t.id} is done but its declared file is missing: ${rel}.`); + if (!existsSync10(join14(appDir, rel))) errors.push(`${t.id} is done but its declared file is missing: ${rel}.`); } if (t.frIds.length && t.tests.length === 0) { warnings.push(`${t.id} is done but declares no tests \u2014 record the test files that exercise ${t.frIds.join(", ")}.`); @@ -3593,220 +4226,6 @@ function formatVerifyReport(r, runDir) { return lines.join("\n"); } -// src/review.ts -import { existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs"; -import { join as join13 } from "path"; -var REVIEW_MAX = 40; -var VALID_VERDICTS = ["supported", "partial", "refuted", "unsupported"]; -function loadEvidence3(path) { - if (!existsSync8(path)) return []; - try { - const data = JSON.parse(readFileSync7(path, "utf8")); - return Array.isArray(data) ? data.filter( - (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" - ) : []; - } catch { - return []; - } -} -function srdClaims(srd) { - const out = []; - for (const f of srd.functional) { - const ac = f.acceptance.map((a) => `${a.given} / ${a.when} / ${a.then}`).join("; "); - out.push({ id: f.id, kind: "FR", text: `${f.title}: ${f.description}${ac ? " \u2014 " + ac : ""}`, ev: f.rationaleEvidence }); - } - for (const n of srd.nonFunctional) { - out.push({ id: n.id, kind: "NFR", text: `${n.category}: ${n.statement}${n.metric ? ` (${n.metric})` : ""}`, ev: n.rationaleEvidence }); - } - for (const a of srd.architecture.adrs) { - out.push({ id: `ADR-${a.id}`, kind: "ADR", text: `${a.title}: ${a.decision}`, ev: a.evidence }); - } - srd.competitive.competitors.forEach((c, i) => out.push({ id: `COMP-${i + 1}`, kind: "competitor", text: `${c.name}: ${c.note}`, ev: c.evidence })); - srd.competitive.oss.forEach((o, i) => out.push({ id: `OSS-${i + 1}`, kind: "oss", text: `${o.name}: ${o.note}`, ev: o.evidence })); - return out; -} -function claimDigest(snippet, claim, cap = 600) { - if (snippet.length <= cap) return snippet; - const kws = keywords(claim).map((k) => k.toLowerCase()); - if (!kws.length) return snippet.slice(0, cap); - const step = 150; - let best = 0; - let bestCov = -1; - for (let start = 0; start === 0 || start + cap / 2 < snippet.length; start += step) { - const w = snippet.slice(start, start + cap).toLowerCase(); - let cov = 0; - for (const kw of kws) if (w.includes(kw)) cov++; - if (cov >= bestCov) { - bestCov = cov; - best = start; - } - } - return (best > 0 ? "\u2026 " : "") + snippet.slice(best, best + cap).trim(); -} -function runReview(runDir, opts = {}) { - const manifest = srdManifestPath(runDir); - if (!existsSync8(manifest)) throw new Error(`No SRD.json in ${runDir} \u2014 render the SRD first (construct render).`); - let srd; - try { - srd = JSON.parse(readFileSync7(manifest, "utf8")); - } catch (e) { - throw new Error(`SRD.json is unreadable: ${e.message}`); - } - const evidence = loadEvidence3(join13(runDir, "evidence", "evidence.json")); - const byId = new Map(evidence.map((e) => [e.id, e])); - const pairs = []; - for (const c of srdClaims(srd)) { - for (const id of [...new Set(c.ev)]) { - const e = byId.get(id); - if (!e) continue; - pairs.push({ - claimId: c.id, - kind: c.kind, - claim: c.text.trim().slice(0, 400), - evidenceId: id, - source: e.source, - digest: claimDigest(e.snippet || e.title || e.ref, c.text), - score: e.score - }); - } - } - const max = Math.max(1, Math.floor(opts.maxReview ?? REVIEW_MAX)); - const kept = pairs.length > max ? pairs.slice().sort((a, b) => b.score - a.score || a.claimId.localeCompare(b.claimId) || a.evidenceId.localeCompare(b.evidenceId)).slice(0, max) : pairs; - const worklist = { run: runDir, pairs: kept.map(({ score, ...rest }) => rest) }; - const todo = { - run: runDir, - pairs: worklist.pairs.map((p) => ({ ...p, verdict: null, note: "" })) - }; - writeFileSync5(join13(runDir, "VERIFY.todo.json"), JSON.stringify(todo, null, 2)); - writeFileSync5(join13(runDir, "VERIFY.md"), renderWorklistMd(worklist, pairs.length, kept.length)); - return worklist; -} -function renderWorklistMd(wl, total, kept) { - const out = []; - out.push(`# Claim-support review worklist`); - out.push(""); - out.push( - `For each pair, open the cited evidence and judge whether it **supports** the claim. In \`VERIFY.todo.json\`, set each \`verdict\` to one of supported \xB7 partial \xB7 refuted \xB7 unsupported, add a short \`note\`, save it (e.g. as \`verdicts.json\`), then run \`construct review --apply verdicts.json --out \`.` - ); - if (kept < total) out.push(` -_Showing ${kept} of ${total} pair(s) \u2014 capped at the highest-score evidence._`); - out.push(""); - for (const p of wl.pairs) { - out.push(`## ${p.claimId} \xB7 ${p.evidenceId} (${p.source})`); - out.push(`**Claim (${p.kind}):** ${p.claim}`); - out.push(`**Cited evidence:** ${p.digest}`); - out.push(`**Verdict:** _____ \xB7 **Note:** _____`); - out.push(""); - } - return out.join("\n"); -} -function applyVerdicts(runDir, verdictsPath) { - if (!existsSync8(verdictsPath)) throw new Error(`verdicts file not found: ${verdictsPath}`); - let raw; - try { - raw = JSON.parse(readFileSync7(verdictsPath, "utf8")); - } catch (e) { - throw new Error(`verdicts file is not valid JSON (${verdictsPath}): ${e.message}`); - } - const list = Array.isArray(raw) ? raw : raw && typeof raw === "object" && Array.isArray(raw.pairs) ? raw.pairs : null; - if (list === null) { - throw new Error(`verdicts file must be a JSON array of verdicts or an object with a "pairs" array (${verdictsPath}).`); - } - const verdicts = []; - const seen = /* @__PURE__ */ new Set(); - const key = (claimId, evidenceId) => `${claimId}::${evidenceId}`; - for (const v of list) { - if (!v || typeof v.claimId !== "string" || typeof v.evidenceId !== "string") continue; - const verdict = VALID_VERDICTS.includes(v.verdict) ? v.verdict : void 0; - verdicts.push({ - claimId: v.claimId, - kind: v.kind, - claim: typeof v.claim === "string" ? v.claim : "", - evidenceId: v.evidenceId, - source: v.source, - digest: typeof v.digest === "string" ? v.digest : "", - verdict, - note: typeof v.note === "string" ? v.note : "" - }); - seen.add(key(v.claimId, v.evidenceId)); - } - const todoPath = join13(runDir, "VERIFY.todo.json"); - if (existsSync8(todoPath)) { - try { - const todo = JSON.parse(readFileSync7(todoPath, "utf8")); - for (const p of todo.pairs ?? []) { - if (!p || typeof p.claimId !== "string" || typeof p.evidenceId !== "string") continue; - if (seen.has(key(p.claimId, p.evidenceId))) continue; - verdicts.push({ - claimId: p.claimId, - kind: p.kind, - claim: p.claim ?? "", - evidenceId: p.evidenceId, - source: p.source, - digest: p.digest ?? "", - verdict: void 0, - note: "" - }); - seen.add(key(p.claimId, p.evidenceId)); - } - } catch { - } - } - const result = reduceVerdicts(verdicts); - writeFileSync5(join13(runDir, "VERIFY.json"), JSON.stringify({ ...result, verdicts }, null, 2)); - return result; -} -function reduceVerdicts(verdicts) { - const counts = { supported: 0, partial: 0, refuted: 0, unsupported: 0 }; - for (const v of verdicts) if (v.verdict && counts[v.verdict] !== void 0) counts[v.verdict]++; - const byClaim = /* @__PURE__ */ new Map(); - for (const v of verdicts) { - const group = byClaim.get(v.claimId) ?? []; - group.push(v); - byClaim.set(v.claimId, group); - } - const failures = []; - const unadjudicated = []; - for (const [claimId, group] of byClaim) { - const adjudicated = group.filter((g) => !!g.verdict); - if (adjudicated.length < group.length) unadjudicated.push(claimId); - const refuted = adjudicated.find((g) => g.verdict === "refuted"); - const hasSupport = adjudicated.some((g) => g.verdict === "supported" || g.verdict === "partial"); - if (refuted) { - failures.push({ claimId, evidenceId: refuted.evidenceId, verdict: "refuted", note: refuted.note }); - } else if (adjudicated.length === group.length && adjudicated.length > 0 && !hasSupport) { - const u = adjudicated.find((g) => g.verdict === "unsupported") ?? adjudicated[0]; - failures.push({ claimId, evidenceId: u.evidenceId, verdict: u.verdict, note: u.note }); - } - } - return { - ok: failures.length === 0, - pairs: verdicts.length, - adjudicated: verdicts.filter((v) => !!v.verdict).length, - supported: counts.supported, - partial: counts.partial, - refuted: counts.refuted, - unsupported: counts.unsupported, - failures, - unadjudicated - }; -} -function formatReviewReport(r) { - const lines = []; - lines.push(`construct review: ${r.adjudicated}/${r.pairs} pair(s) adjudicated`); - lines.push(` supported: ${r.supported} \xB7 partial: ${r.partial} \xB7 refuted: ${r.refuted} \xB7 unsupported: ${r.unsupported}`); - for (const f of r.failures.slice(0, 12)) { - lines.push(` \u2717 ${f.claimId} (${f.evidenceId}): ${f.verdict}${f.note ? " \u2014 " + f.note : ""}`); - } - if (r.unadjudicated.length) { - lines.push(` \u26A0 ${r.unadjudicated.length} claim(s) not fully adjudicated: ${r.unadjudicated.join(", ")}`); - } - lines.push( - !r.ok ? ` \u2717 some claims are refuted or unsupported` : r.unadjudicated.length ? ` \u2713 no refuted or unsupported claims (${r.unadjudicated.length} still unadjudicated \u2014 see above)` : ` \u2713 every grounded claim is backed by its cited evidence` - ); - return lines.join("\n"); -} - // src/cli.ts var HELP = `construct v${VERSION} Turn a product idea into a grounded, buildable SRD suite. Interview \u2192 research @@ -3815,11 +4234,13 @@ check. Grounding is advisory; structural completeness is enforced. Usage: construct init --idea "" [--out ] + construct brainstorm --out [--merge] [--json] construct research --out [--angles market,oss,tech,semantic] [--q ""] [--url ] [--semantic] construct analyze --out [--json] construct web|oss|tech|so --out [--q ""] [--url ] [--seeds ] construct render --out [--level light|complex] [--merge] [--no-design] [--prd] - construct check --out [--min-grounding <0-100>] [--semantic] [--json] + construct render --out --from-srd [--merge] [--prd] + construct check --out [--min-grounding <0-100>] [--semantic [--allow-unverified]] [--json] construct review --out [--apply ] [--max-review N] [--json] construct verify --out [--app ] [--run-tests] [--strict] [--json] construct status --out [--json] @@ -3827,6 +4248,9 @@ Usage: Commands: init Scaffold a run folder + brief.json (fill it via the interview). + brainstorm Divergent ideation BEFORE the interview: scaffold a board of + candidate ideas (brainstorm.json + BRAINSTORM.md). --merge folds + kept ideas into brief.json (parked \u2192 \u{1F9E0} openQuestions). research Gather evidence across angles into /evidence (a dossier). analyze Report what is thin (gaps that will render ungrounded) + drill commands. web Drill the market/web angle. oss Drill OSS prior-art mining. @@ -3836,6 +4260,9 @@ Commands: (design/: principles, tokens, components, screens, accessibility); --no-design opts out. --prd also emits requirements/prd/ \u2014 one standalone PRD per functional requirement + an index. + --from-srd re-emits the tree from an edited SRD.json WITHOUT + rebuilding it (the enrich\u2192re-render path; keeps markdown in sync + with the gated manifest). check Hard structural gate + advisory grounding-coverage report. --semantic also folds in the review verdicts (fails on a claim its cited evidence does not support). @@ -3859,7 +4286,13 @@ Options: --level light | complex (default: light) --min-grounding For 'check': fail unless \u2265 n% of claims are grounded (opt-in) --semantic For 'check': fold in the 'review' claim-support verdicts + (fail-closed: no/unreadable VERIFY.json fails the check) + --allow-unverified For 'check --semantic': degrade a missing/unreadable + VERIFY.json to a warning instead of failing --apply For 'review': consume an adjudicated verdicts file + gate + --max-review For 'review': cap the worklist at the n highest-score + pairs (default: review ALL cited pairs; dropped pairs + are named in VERIFY.md) --app For 'verify': the built app directory (default: conventions.appDir) --run-tests For 'verify': also execute testCommand + per-task verify commands --strict For 'verify': a built must-have FR with no referencing test FAILS @@ -3880,7 +4313,22 @@ Workflow: construct render --out ./my-idea --level complex # writes the SRD tree construct check --out ./my-idea # structural gate + coverage report `; -var COMMANDS = /* @__PURE__ */ new Set(["init", "research", "analyze", "web", "oss", "tech", "so", "render", "check", "verify", "review", "status", "semantic"]); +var COMMANDS = /* @__PURE__ */ new Set([ + "init", + "brainstorm", + "research", + "analyze", + "web", + "oss", + "tech", + "so", + "render", + "check", + "verify", + "review", + "status", + "semantic" +]); var VALUE_FLAGS = /* @__PURE__ */ new Set([ "idea", "out", @@ -3900,7 +4348,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([ "apply", "max-review" ]); -var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd"]); +var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd", "allow-unverified", "from-srd"]); function fail(message) { process.stderr.write(`construct: ${message} `); @@ -4049,6 +4497,53 @@ async function main() { ); return; } + case "brainstorm": { + const out = requireOut(p); + const brief = loadBrief(out, warnBrief); + if (p.bools.has("merge")) { + const b2 = loadBrainstorm(out, warnBrief); + if (!b2) fail(`no brainstorm.json in ${out} \u2014 run \`construct brainstorm --out ${out}\` first to scaffold one.`); + const r = mergeBrainstorm(brief, b2, (/* @__PURE__ */ new Date()).toISOString(), warnBrief); + saveBrief(out, r.brief); + saveBrainstorm(out, r.brainstorm); + writeBrainstormMd(out, r.brainstorm); + if (p.bools.has("json")) { + process.stdout.write(JSON.stringify({ merged: r.merged, parkedFolded: r.parkedFolded, skipped: r.skipped, proposed: r.proposed }, null, 2) + "\n"); + return; + } + process.stderr.write( + [ + `construct: merged brainstorm \u2192 brief.json`, + ` merged: ${r.merged} kept idea(s) folded into the brief`, + ` parked: ${r.parkedFolded} parked idea(s) \u2192 openQuestions (\u{1F9E0} \u2014 resolve before check passes)`, + ` skipped: ${r.skipped} kept idea(s) not merged (no target / conflict \u2014 see warnings)`, + ` proposed: ${r.proposed} idea(s) still awaiting a decision`, + ` next: construct research --out ${out}` + ].join("\n") + "\n" + ); + return; + } + let b = loadBrainstorm(out, warnBrief); + if (!b) { + b = initBrainstorm(brief.idea, (/* @__PURE__ */ new Date()).toISOString()); + saveBrainstorm(out, b); + } + writeBrainstormMd(out, b); + if (p.bools.has("json")) { + process.stdout.write(JSON.stringify(b, null, 2) + "\n"); + return; + } + const c = brainstormCounts(b); + process.stderr.write( + [ + `construct: brainstorm board at ${join15(out, "BRAINSTORM.md")}`, + ` ideas: ${b.ideas.length} (${c.kept} kept \xB7 ${c.parked} parked \xB7 ${c.proposed} proposed \xB7 ${c.rejected} rejected)`, + ` next: generate ideas WITH the user (references/brainstorm-playbook.md), mark statuses in`, + ` brainstorm.json, then: construct brainstorm --out ${out} --merge` + ].join("\n") + "\n" + ); + return; + } case "research": { const out = requireOut(p); const angles = p.values.angles ? parseAngles(p.values.angles) : DEFAULT_ANGLES; @@ -4103,6 +4598,20 @@ async function main() { } case "render": { const out = requireOut(p); + if (p.bools.has("from-srd")) { + if (p.values.level) process.stderr.write("construct: --level is ignored with --from-srd (the manifest's level is authoritative).\n"); + if (p.bools.has("no-design")) + process.stderr.write("construct: --no-design is ignored with --from-srd (re-run a full render to change the design subtree).\n"); + const r2 = renderFromSRD(out, { merge: p.bools.has("merge"), prd: p.bools.has("prd") }); + process.stderr.write( + [ + `construct: re-emitted the SRD tree from ${join15(out, "SRD.json")}`, + ` files: ${r2.files.length} (${r2.srd.functional.length} FR \xB7 ${r2.srd.nonFunctional.length} NFR \xB7 ${r2.srd.architecture.adrs.length} ADR)`, + ` next: construct check --out ${out}` + ].join("\n") + "\n" + ); + return; + } const brief = loadBrief(out, warnBrief); const v = validateBrief(brief); if (!v.ok) fail(`brief is incomplete: @@ -4123,7 +4632,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); `construct: rendered the ${level} SRD for "${brief.idea}"`, ` files: ${r.files.length} (${r.srd.functional.length} FR \xB7 ${r.srd.nonFunctional.length} NFR \xB7 ${r.srd.architecture.adrs.length} ADR)`, ...design ? [` design: ${design.components.length} components \xB7 ${design.tokens.length} tokens \xB7 a11y ${design.accessibility.standard}`] : [], - ` manifest: ${join14(out, "SRD.json")}`, + ` manifest: ${join15(out, "SRD.json")}`, ` next: construct check --out ${out}` ].join("\n") + "\n" ); @@ -4149,7 +4658,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); fail("invalid --min-grounding (expected a number between 0 and 100)"); } } - const res = checkRun(out, { minGrounding, semantic: p.bools.has("semantic") }); + const res = checkRun(out, { minGrounding, semantic: p.bools.has("semantic"), allowUnverified: p.bools.has("allow-unverified") }); if (p.bools.has("json")) { process.stdout.write(JSON.stringify(res, null, 2) + "\n"); } else { @@ -4167,8 +4676,11 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); if (!res.ok) process.exit(1); return; } - const maxReview = p.values["max-review"] ? Number(p.values["max-review"]) : REVIEW_MAX; - if (!Number.isFinite(maxReview) || maxReview <= 0) fail("invalid --max-review"); + let maxReview; + if (p.values["max-review"] !== void 0) { + maxReview = Number(p.values["max-review"]); + if (p.values["max-review"].trim() === "" || !Number.isFinite(maxReview) || maxReview <= 0) fail("invalid --max-review"); + } const wl = runReview(out, { maxReview }); if (p.bools.has("json")) { process.stdout.write(JSON.stringify(wl, null, 2) + "\n"); @@ -4203,11 +4715,17 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); process.stdout.write(JSON.stringify(plan ? readyFrontier(plan) : null, null, 2) + "\n"); return; } - const has = (rel) => existsSync9(join14(out, rel)) ? "\u2713" : "\xB7"; + const has = (rel) => existsSync11(join15(out, rel)) ? "\u2713" : "\xB7"; const planLine = plan ? ` \u2713 BUILD-PLAN.json (build: ${plan.tasks.filter((t) => t.status === "done").length}/${plan.tasks.length} tasks done)` : ` \xB7 BUILD-PLAN.json (build plan)`; + const bs = loadBrainstorm(out); + const bsLine = bs ? (() => { + const c = brainstormCounts(bs); + return ` \u2713 brainstorm.json (${c.kept} kept \xB7 ${c.parked} parked \xB7 ${c.proposed} proposed \xB7 ${c.rejected} rejected)`; + })() : ` \xB7 brainstorm.json (optional divergence)`; process.stdout.write( [ `construct status: ${out}`, + bsLine, ` ${has("brief.json")} brief.json`, ` ${has("evidence/evidence.json")} evidence/evidence.json (research)`, ` ${has("SRD.json")} SRD.json (render)`, @@ -4227,10 +4745,10 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); } } function loadEvidence4(runDir) { - const path = join14(runDir, "evidence", "evidence.json"); - if (!existsSync9(path)) return []; + const path = join15(runDir, "evidence", "evidence.json"); + if (!existsSync11(path)) return []; try { - const data = JSON.parse(readFileSync8(path, "utf8")); + const data = JSON.parse(readFileSync10(path, "utf8")); return Array.isArray(data) ? data.filter(isEvidenceItem) : []; } catch { return []; diff --git a/src/analyze.ts b/src/analyze.ts index a53ac61..34f6d9d 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -54,6 +54,11 @@ export function analyzeRun(runDir: string): GapReport { notes.push("No evidence dossier — run `construct research` first; everything below will render ungrounded."); } + const lowSignal = evidence.filter((e) => e.meta?.lowSignal).length; + if (lowSignal) { + notes.push(`${lowSignal} low-signal snippet(s) in the dossier — likely boilerplate; re-drill with a sharper --q or a better --docs-url.`); + } + const suggestions: string[] = []; // Features with no evidence the renderer could cite (same matcher, same diff --git a/src/brainstorm.ts b/src/brainstorm.ts new file mode 100644 index 0000000..ebf0677 --- /dev/null +++ b/src/brainstorm.ts @@ -0,0 +1,208 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { BRAINSTORM_SCHEMA_VERSION } from "./types.js"; +import { renderBrainstormMd } from "./templates.js"; +import type { Brainstorm, BrainstormAngle, BrainstormIdea, BrainstormStatus, BrainstormTarget, Brief } from "./types.js"; + +// The brainstorm is the optional DIVERGENT companion to the brief: candidate +// ideas the user keeps/parks/rejects, then `--merge` folds the kept ones into +// the brief. The engine owns persistence + the deterministic merge; the agent +// runs the session (references/brainstorm-playbook.md). + +const ANGLES: BrainstormAngle[] = ["reframe", "segment", "feature", "differentiator", "anti-goal", "wildcard"]; +const STATUSES: BrainstormStatus[] = ["proposed", "kept", "parked", "rejected"]; +const TARGETS: BrainstormTarget[] = ["featureWishlist", "competitors", "nonGoals", "goals", "candidateTech", "openQuestions"]; +// String-array brief fields a kept idea can append its title to. +const STRING_TARGETS: Exclude[] = ["competitors", "nonGoals", "goals", "candidateTech", "openQuestions"]; + +export function brainstormPath(runDir: string): string { + return join(runDir, "brainstorm.json"); +} + +export function initBrainstorm(idea: string, now: string): Brainstorm { + return { schemaVersion: BRAINSTORM_SCHEMA_VERSION, idea: idea.trim(), createdAt: now, ideas: [] }; +} + +export function saveBrainstorm(runDir: string, b: Brainstorm): string { + mkdirSync(runDir, { recursive: true }); + const path = brainstormPath(runDir); + writeFileSync(path, JSON.stringify(b, null, 2)); + return path; +} + +// Regenerate the human-facing board from brainstorm.json (the source of truth). +export function writeBrainstormMd(runDir: string, b: Brainstorm): string { + mkdirSync(runDir, { recursive: true }); + const path = join(runDir, "BRAINSTORM.md"); + const md = renderBrainstormMd(b); + writeFileSync(path, md.endsWith("\n") ? md : md + "\n"); + return path; +} + +export function brainstormCounts(b: Brainstorm): Record { + const counts: Record = { proposed: 0, kept: 0, parked: 0, rejected: 0 }; + for (const i of b.ideas) if (counts[i.status] !== undefined) counts[i.status]++; + return counts; +} + +const line = (v: unknown): string | undefined => (typeof v === "string" ? v.replace(/\s+/g, " ").trim() : undefined); + +// Load + normalize a brainstorm.json defensively (mirrors normalizeBrief): +// invalid enums are coerced with a warning, title-less ideas are dropped, +// missing ids are assigned the next free B-### — a hand-edited file never +// crashes the merge. +export function loadBrainstorm(runDir: string, warn: (msg: string) => void = () => {}): Brainstorm | undefined { + const path = brainstormPath(runDir); + if (!existsSync(path)) return undefined; + let data: unknown; + try { + data = JSON.parse(readFileSync(path, "utf8")); + } catch (e) { + throw new Error(`brainstorm.json is unreadable: ${(e as Error).message}`); + } + const d = (data ?? {}) as Partial; + const used = new Set(); + let seq = 0; + const nextId = (): string => { + do { + seq++; + } while (used.has(`B-${String(seq).padStart(3, "0")}`)); + const id = `B-${String(seq).padStart(3, "0")}`; + used.add(id); + return id; + }; + // First pass: reserve every already-valid id so a later auto-id can't collide. + const rawIdeas = Array.isArray(d.ideas) ? d.ideas : []; + if (!Array.isArray(d.ideas) && d.ideas !== undefined) warn("brainstorm.ideas is not an array — ignored."); + for (const raw of rawIdeas) { + const id = line((raw as { id?: unknown })?.id); + if (id && /^B-\d{3,}$/.test(id)) used.add(id); + } + + const ideas: BrainstormIdea[] = []; + rawIdeas.forEach((raw, i) => { + const r = (raw ?? {}) as unknown as Record; + const title = line(r.title); + if (!title) { + warn(`brainstorm.ideas[${i}] has no usable title — dropped.`); + return; + } + let id = line(r.id); + if (!id || !/^B-\d{3,}$/.test(id)) id = nextId(); + let angle = r.angle as BrainstormAngle; + if (!ANGLES.includes(angle)) { + if (r.angle !== undefined) warn(`brainstorm ${id}: angle "${String(r.angle)}" is not recognized — treated as wildcard.`); + angle = "wildcard"; + } + let status = r.status as BrainstormStatus; + if (!STATUSES.includes(status)) { + if (r.status !== undefined) warn(`brainstorm ${id}: status "${String(r.status)}" is not recognized — treated as proposed.`); + status = "proposed"; + } + let target = r.target as BrainstormTarget | undefined; + if (target !== undefined && !TARGETS.includes(target)) { + warn(`brainstorm ${id}: target "${String(r.target)}" is not recognized — removed.`); + target = undefined; + } + const idea: BrainstormIdea = { id, angle, title, status }; + const notes = line(r.notes); + if (notes) idea.notes = notes; + if (target) idea.target = target; + const priority = r.priority; + if (priority === "must" || priority === "should" || priority === "could") idea.priority = priority; + const mergedAt = line(r.mergedAt); + if (mergedAt) idea.mergedAt = mergedAt; + ideas.push(idea); + }); + + return { + schemaVersion: typeof d.schemaVersion === "number" ? d.schemaVersion : BRAINSTORM_SCHEMA_VERSION, + idea: line(d.idea) ?? "", + createdAt: line(d.createdAt) ?? "", + ...(line(d.updatedAt) ? { updatedAt: line(d.updatedAt) } : {}), + ideas, + }; +} + +export interface MergeResult { + brief: Brief; + brainstorm: Brainstorm; + merged: number; // kept ideas folded into a brief field + parkedFolded: number; // parked ideas folded into openQuestions + skipped: number; // kept ideas that could not merge (no target / conflict) + proposed: number; // ideas still awaiting adjudication +} + +const norm = (s: string) => s.toLowerCase().replace(/\s+/g, " ").trim(); + +// Deterministically fold a brainstorm's kept/parked ideas into a brief. Pure, +// idempotent (an idea with `mergedAt` is skipped forever), tolerant-with-warnings. +// Returns fresh brief + brainstorm objects; the caller persists them. +export function mergeBrainstorm(briefIn: Brief, brainstormIn: Brainstorm, now: string, warn: (msg: string) => void = () => {}): MergeResult { + // Clone so the function stays pure (no mutation of the caller's objects). + const brief: Brief = JSON.parse(JSON.stringify(briefIn)); + const brainstorm: Brainstorm = JSON.parse(JSON.stringify(brainstormIn)); + let merged = 0; + let parkedFolded = 0; + let skipped = 0; + + const appendUnique = (list: string[], value: string): boolean => { + if (list.some((x) => norm(x) === norm(value))) return false; + list.push(value); + return true; + }; + + for (const idea of brainstorm.ideas) { + if (idea.mergedAt) continue; // already folded — idempotence marker + + if (idea.status === "parked") { + appendUnique(brief.openQuestions, `Parked idea ${idea.id}: ${idea.title}`); + idea.mergedAt = now; // stamp regardless — the content is now present (or already was) + parkedFolded++; + continue; + } + + if (idea.status !== "kept") continue; // proposed/rejected untouched + + if (!idea.target) { + warn(`brainstorm ${idea.id} "${idea.title}" is kept but has no target — set one (featureWishlist, competitors, …) and re-merge.`); + skipped++; + continue; // NOT stamped → a later merge retries once a target is set + } + + if (idea.target === "featureWishlist") { + const exists = brief.featureWishlist.some((f) => norm(f.title) === norm(idea.title)); + if (exists) { + warn(`brainstorm ${idea.id} "${idea.title}" is already in the wishlist — skipped.`); + } else { + brief.featureWishlist.push({ title: idea.title, priority: idea.priority ?? "could", ...(idea.notes ? { notes: idea.notes } : {}) }); + } + idea.mergedAt = now; + merged++; + continue; + } + + // String-array targets. A goals↔nonGoals conflict blocks the merge WITHOUT + // stamping, so the user can resolve it and re-merge. + if (idea.target === "goals" && brief.nonGoals.some((g) => norm(g) === norm(idea.title))) { + warn(`brainstorm ${idea.id} "${idea.title}" conflicts with an existing nonGoal — NOT merged; resolve it in brief.json first.`); + skipped++; + continue; + } + if (idea.target === "nonGoals" && brief.goals.some((g) => norm(g) === norm(idea.title))) { + warn(`brainstorm ${idea.id} "${idea.title}" conflicts with an existing goal — NOT merged; resolve it in brief.json first.`); + skipped++; + continue; + } + + const value = idea.target === "openQuestions" && idea.notes ? `${idea.title} — ${idea.notes}` : idea.title; + const list = brief[idea.target as (typeof STRING_TARGETS)[number]] as string[]; + if (!appendUnique(list, value)) warn(`brainstorm ${idea.id} "${idea.title}" is already in ${idea.target} — skipped.`); + idea.mergedAt = now; + merged++; + } + + brainstorm.updatedAt = now; + const proposed = brainstorm.ideas.filter((i) => i.status === "proposed").length; + return { brief, brainstorm, merged, parkedFolded, skipped, proposed }; +} diff --git a/src/brief.ts b/src/brief.ts index de90eb9..0f4f29f 100644 --- a/src/brief.ts +++ b/src/brief.ts @@ -65,6 +65,35 @@ function slugId(s: string): string { .slice(0, 60); } +const KNOWN_CONSTRAINT_KEYS = ["budget", "timeline", "team", "compliance"] as const; + +// Rebuild the constraints object from only the four recognized keys, and warn — +// naming the offender — when the brief carries any other key (a common mistake, +// e.g. `constraints.deployment`, that would otherwise vanish silently). +function normalizeConstraints( + raw: Brief["constraints"] | undefined, + line: (v: unknown) => string | undefined, + arr: (v: unknown, field: string) => string[], + warn: (msg: string) => void, +): Brief["constraints"] { + const c = (raw ?? {}) as Record; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const k of Object.keys(c)) { + if (!(KNOWN_CONSTRAINT_KEYS as readonly string[]).includes(k)) { + warn( + `constraints.${k} is not a recognized constraint (known: ${KNOWN_CONSTRAINT_KEYS.join(", ")}) — ignored; fold it into the nearest field or openQuestions.`, + ); + } + } + } + return { + budget: line(c.budget), + timeline: line(c.timeline), + team: line(c.team), + compliance: arr(c.compliance, "constraints.compliance"), + }; +} + // Coerce a parsed object into a Brief, tolerating missing arrays/objects so a // hand-edited brief never crashes the renderer. Tolerance must not mean silent // loss: anything dropped or rewritten is reported through `warn`. @@ -76,6 +105,13 @@ export function normalizeBrief(data: unknown, warn: (msg: string) => void = () = const arr = (v: unknown, field: string): string[] => { if (v === undefined || v === null) return []; if (!Array.isArray(v)) { + // A bare string is a common brief mistake — coerce it into a one-element + // array (with a warning) rather than dropping the user's real value. + if (typeof v === "string") { + const s = v.replace(/\s+/g, " ").trim(); + warn(`${field}: expected an array — coerced the bare string into a one-element array.`); + return s ? [s] : []; + } warn(`${field} is not an array — ignored.`); return []; } @@ -194,12 +230,7 @@ export function normalizeBrief(data: unknown, warn: (msg: string) => void = () = }, goals: arr(d.goals, "goals"), nonGoals: arr(d.nonGoals, "nonGoals"), - constraints: { - budget: line(d.constraints?.budget), - timeline: line(d.constraints?.timeline), - team: line(d.constraints?.team), - compliance: arr(d.constraints?.compliance, "constraints.compliance"), - }, + constraints: normalizeConstraints(d.constraints, line, arr, warn), candidateTech: arr(d.candidateTech, "candidateTech"), competitors: arr(d.competitors, "competitors"), ossSeeds: arr(d.ossSeeds, "ossSeeds"), diff --git a/src/check.ts b/src/check.ts index 08f34d3..08e0e05 100644 --- a/src/check.ts +++ b/src/check.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import type { Stats } from "node:fs"; import { join, relative, sep } from "node:path"; +import { reduceVerdicts } from "./review.js"; import { srdManifestPath } from "./srd.js"; import { REQUIRED_NFR, DESIGN_TOKEN_CATEGORIES, DESIGN_TOKENS_SEEDED_BANNER } from "./types.js"; import type { CheckResult, SRD, EvidenceItem, CoverageReport, ClaimVerifyResult } from "./types.js"; @@ -68,6 +69,20 @@ function mdFiles(runDir: string): string[] { return out.sort(); } +// Count brainstorm ideas still awaiting a decision (advisory only). Reads +// brainstorm.json directly so check has no dependency on the brainstorm module's +// merge logic; a missing/corrupt file just yields 0. +function countProposedIdeas(runDir: string): number { + const p = join(runDir, "brainstorm.json"); + if (!existsSync(p)) return 0; + try { + const data = JSON.parse(readFileSync(p, "utf8")) as { ideas?: { status?: string }[] }; + return Array.isArray(data.ideas) ? data.ideas.filter((i) => i && i.status === "proposed").length : 0; + } catch { + return 0; + } +} + function loadEvidence(runDir: string): { evidence: EvidenceItem[]; note?: string } { const path = join(runDir, "evidence", "evidence.json"); if (!existsSync(path)) { @@ -141,22 +156,45 @@ const TEMPLATED_METRIC_RE = /^A measurable target for "/; // Fold the resolved claim-support record (VERIFY.json, written by `review // --apply`) into a check result when `--semantic` is requested. Strictly // additive: it can only ADD a failure (a refuted/unsupported claim), never relax -// the structural gate. Missing VERIFY.json warns (run `review` first), never fails. -function applySemantic(runDir: string, result: CheckResult): void { +// the structural gate. FAIL-CLOSED: passing `--semantic` asserts the support +// gate actually engaged, so a missing/unreadable/verdict-less VERIFY.json fails +// the check unless `--allow-unverified` degrades it to the advisory warning. +// The verdict itself is re-reduced from `verdicts[]` on every check — the +// persisted summary is never trusted, so a stale or hand-tampered `ok` can not +// green-light the gate. +function applySemantic(runDir: string, result: CheckResult, allowUnverified: boolean): void { const p = join(runDir, "VERIFY.json"); + const skip = (reason: string, hint: string) => { + if (allowUnverified) { + result.structural.warnings.push(`--semantic: ${reason} — ${hint}; semantic gate skipped (--allow-unverified).`); + } else { + result.semanticError = `${reason} — ${hint}, or pass --allow-unverified to degrade this to a warning.`; + result.ok = false; + } + }; if (!existsSync(p)) { - result.structural.warnings.push("--semantic: no VERIFY.json — run `construct review` then `review --apply ` first; semantic gate skipped."); + skip("no VERIFY.json", "run `construct review` then `review --apply ` first"); return; } + let sem: ClaimVerifyResult; try { - const sem = JSON.parse(readFileSync(p, "utf8")) as ClaimVerifyResult; - result.semantic = sem; - if (!sem.ok) result.ok = false; - if (sem.unadjudicated?.length) { - result.structural.warnings.push(`${sem.unadjudicated.length} claim(s) not fully adjudicated by review.`); - } + sem = JSON.parse(readFileSync(p, "utf8")) as ClaimVerifyResult; } catch (e) { - result.structural.warnings.push(`--semantic: VERIFY.json is unreadable (${(e as Error).message}).`); + skip(`VERIFY.json is unreadable (${(e as Error).message})`, "re-run `review --apply ` to regenerate it"); + return; + } + if (!Array.isArray(sem.verdicts)) { + skip("VERIFY.json carries no verdicts[] (legacy or hand-edited)", "re-run `review --apply ` to regenerate it"); + return; + } + const reduced = reduceVerdicts(sem.verdicts); + if (reduced.ok !== sem.ok) { + result.structural.warnings.push("VERIFY.json's persisted summary disagreed with its verdicts — recomputed at check time."); + } + result.semantic = { ...reduced, verdicts: sem.verdicts }; + if (!reduced.ok) result.ok = false; + if (reduced.unadjudicated.length) { + result.structural.warnings.push(`${reduced.unadjudicated.length} claim(s) not fully adjudicated by review.`); } } @@ -238,7 +276,7 @@ function checkModules(runDir: string, srd: SRD, errors: string[], warnings: stri // advisory coverage report itself never flips `ok`). With `opts.semantic`, ALSO // folds in the VERIFY.json claim-support verdicts (fails on a refuted/unsupported // claim) — additive: plain `check` (no opts) is byte-for-byte unchanged. -export function checkRun(runDir: string, opts: { minGrounding?: number; semantic?: boolean } = {}): CheckResult { +export function checkRun(runDir: string, opts: { minGrounding?: number; semantic?: boolean; allowUnverified?: boolean } = {}): CheckResult { const errors: string[] = []; const warnings: string[] = []; @@ -338,19 +376,28 @@ export function checkRun(runDir: string, opts: { minGrounding?: number; semantic // Module partition (only when present) — additive structural gate. checkModules(runDir, srd, errors, warnings); - // Advisory: criteria/metrics still carrying the renderer's own template - // phrasing — complete but not yet sharpened into something testable. + // Criteria/metrics still carrying the renderer's own template phrasing — + // complete but not yet sharpened into something testable. A complex SRD + // certifies build-readiness, so surviving templates HARD-FAIL there; at + // light they stay an advisory nudge. const templatedThen = srd.functional.reduce((n, fr) => n + fr.acceptance.filter((a) => TEMPLATED_THEN_RE.test(a.then)).length, 0); if (templatedThen) { - warnings.push( - `${templatedThen} acceptance criteria are still renderer-templated — sharpen them into observable, bounded outcomes (see references/acceptance-criteria.md).`, - ); + const msg = `${templatedThen} acceptance criteria are still renderer-templated — sharpen them into observable, bounded outcomes (see references/acceptance-criteria.md).`; + if (srd.level === "complex") errors.push(msg); + else warnings.push(msg); } const templatedMetrics = srd.nonFunctional.filter((n) => n.metric && TEMPLATED_METRIC_RE.test(n.metric)).length; if (templatedMetrics) { warnings.push(`${templatedMetrics} NFR metric(s) are still generic placeholders — set measurable targets (see references/acceptance-criteria.md).`); } + // Advisory: an unmerged brainstorm still has undecided ideas. Never gates — + // brainstorm is optional and pre-brief. + const proposedIdeas = countProposedIdeas(runDir); + if (proposedIdeas > 0) { + warnings.push(`brainstorm: ${proposedIdeas} idea(s) still 'proposed' — adjudicate (kept/parked/rejected) and run \`construct brainstorm --merge\`.`); + } + // Advisory grounding coverage. const { evidence, note } = loadEvidence(runDir); if (note) warnings.push(note); @@ -373,7 +420,14 @@ export function checkRun(runDir: string, opts: { minGrounding?: number; semantic const ok = structuralOk && (grounding?.ok ?? true); const result: CheckResult = { ok, structural: { ok: structuralOk, errors, warnings }, coverage, grounding }; - if (opts.semantic) applySemantic(runDir, result); + if (opts.semantic) { + applySemantic(runDir, result, opts.allowUnverified ?? false); + } else if (coverage.resolved.length > 0) { + // Citations exist but the support gate never engaged — surface it loudly + // (advisory): a citation proves nothing until the review adjudicates it. + const citedClaims = coverage.frGrounded + coverage.nfrGrounded + coverage.adrGrounded; + result.semanticSkipped = { citedClaims, verifyExists: existsSync(join(runDir, "VERIFY.json")) }; + } return result; } @@ -408,6 +462,23 @@ export function formatCheckReport(r: CheckResult, runDir: string): string { : ` ✗ FAIL — ${g.actualPct}% of groundable claims are grounded, below the ${g.threshold}% threshold`, ); } + if (r.semanticSkipped) { + const s = r.semanticSkipped; + lines.push(``); + lines.push(`Semantic gate: SKIPPED`); + lines.push(` ⚠ ${s.citedClaims} cited claim(s) were never adversarially verified — a citation`); + lines.push( + s.verifyExists + ? ` proves nothing until reviewed. A VERIFY.json exists — re-run with --semantic to gate on it.` + : ` proves nothing until reviewed. Run \`construct review --out \`, adjudicate the`, + ); + if (!s.verifyExists) lines.push(` worklist, then \`construct check --semantic\`.`); + } + if (r.semanticError) { + lines.push(``); + lines.push(`Semantic claim-support gate (--semantic):`); + lines.push(` ✗ FAIL — ${r.semanticError}`); + } if (r.semantic) { const s = r.semantic; lines.push(``); diff --git a/src/cli.ts b/src/cli.ts index b8f9ddf..a6ba092 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { VERSION, ALL_SOURCE_KINDS } from "./types.js"; import type { Angle, ResearchContext, WebEngine, EvidenceItem, DossierMeta, Level, SourceResult, SourceKind } from "./types.js"; import { slugify } from "./util.js"; import { initBrief, saveBrief, loadBrief, validateBrief } from "./brief.js"; +import { initBrainstorm, loadBrainstorm, saveBrainstorm, mergeBrainstorm, brainstormCounts, writeBrainstormMd } from "./brainstorm.js"; import { runResearch } from "./research/registry.js"; import { marketAngle } from "./research/market.js"; import { ossAngle } from "./research/oss.js"; @@ -13,11 +14,11 @@ import { techAngle } from "./research/tech.js"; import { stackoverflow } from "./research/stackoverflow.js"; import { webFetchUrls } from "./research/web.js"; import { assignIds, renderEvidenceMarkdown } from "./research/dossier.js"; -import { renderSRD } from "./render.js"; +import { renderSRD, renderFromSRD } from "./render.js"; import { checkRun, formatCheckReport } from "./check.js"; import { analyzeRun, formatGapReport } from "./analyze.js"; import { verifyRun, formatVerifyReport } from "./verify.js"; -import { runReview, applyVerdicts, formatReviewReport, REVIEW_MAX } from "./review.js"; +import { runReview, applyVerdicts, formatReviewReport } from "./review.js"; import { loadPlan, readyFrontier } from "./plan.js"; import { semanticControl } from "./research/semantic.js"; @@ -28,11 +29,13 @@ check. Grounding is advisory; structural completeness is enforced. Usage: construct init --idea "" [--out ] + construct brainstorm --out [--merge] [--json] construct research --out [--angles market,oss,tech,semantic] [--q ""] [--url ] [--semantic] construct analyze --out [--json] construct web|oss|tech|so --out [--q ""] [--url ] [--seeds ] construct render --out [--level light|complex] [--merge] [--no-design] [--prd] - construct check --out [--min-grounding <0-100>] [--semantic] [--json] + construct render --out --from-srd [--merge] [--prd] + construct check --out [--min-grounding <0-100>] [--semantic [--allow-unverified]] [--json] construct review --out [--apply ] [--max-review N] [--json] construct verify --out [--app ] [--run-tests] [--strict] [--json] construct status --out [--json] @@ -40,6 +43,9 @@ Usage: Commands: init Scaffold a run folder + brief.json (fill it via the interview). + brainstorm Divergent ideation BEFORE the interview: scaffold a board of + candidate ideas (brainstorm.json + BRAINSTORM.md). --merge folds + kept ideas into brief.json (parked → 🧠 openQuestions). research Gather evidence across angles into /evidence (a dossier). analyze Report what is thin (gaps that will render ungrounded) + drill commands. web Drill the market/web angle. oss Drill OSS prior-art mining. @@ -49,6 +55,9 @@ Commands: (design/: principles, tokens, components, screens, accessibility); --no-design opts out. --prd also emits requirements/prd/ — one standalone PRD per functional requirement + an index. + --from-srd re-emits the tree from an edited SRD.json WITHOUT + rebuilding it (the enrich→re-render path; keeps markdown in sync + with the gated manifest). check Hard structural gate + advisory grounding-coverage report. --semantic also folds in the review verdicts (fails on a claim its cited evidence does not support). @@ -72,7 +81,13 @@ Options: --level light | complex (default: light) --min-grounding For 'check': fail unless ≥ n% of claims are grounded (opt-in) --semantic For 'check': fold in the 'review' claim-support verdicts + (fail-closed: no/unreadable VERIFY.json fails the check) + --allow-unverified For 'check --semantic': degrade a missing/unreadable + VERIFY.json to a warning instead of failing --apply For 'review': consume an adjudicated verdicts file + gate + --max-review For 'review': cap the worklist at the n highest-score + pairs (default: review ALL cited pairs; dropped pairs + are named in VERIFY.md) --app For 'verify': the built app directory (default: conventions.appDir) --run-tests For 'verify': also execute testCommand + per-task verify commands --strict For 'verify': a built must-have FR with no referencing test FAILS @@ -94,7 +109,22 @@ Workflow: construct check --out ./my-idea # structural gate + coverage report `; -const COMMANDS = new Set(["init", "research", "analyze", "web", "oss", "tech", "so", "render", "check", "verify", "review", "status", "semantic"]); +const COMMANDS = new Set([ + "init", + "brainstorm", + "research", + "analyze", + "web", + "oss", + "tech", + "so", + "render", + "check", + "verify", + "review", + "status", + "semantic", +]); const VALUE_FLAGS = new Set([ "idea", "out", @@ -114,7 +144,7 @@ const VALUE_FLAGS = new Set([ "apply", "max-review", ]); -const BOOL_FLAGS = new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd"]); +const BOOL_FLAGS = new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd", "allow-unverified", "from-srd"]); function fail(message: string): never { process.stderr.write(`construct: ${message}\n`); @@ -291,6 +321,57 @@ async function main(): Promise { return; } + case "brainstorm": { + const out = requireOut(p); + // Brainstorm sits on top of the brief (it seeds the idea from it and folds + // ideas back into it) — require an initialized run. + const brief = loadBrief(out, warnBrief); + if (p.bools.has("merge")) { + const b = loadBrainstorm(out, warnBrief); + if (!b) fail(`no brainstorm.json in ${out} — run \`construct brainstorm --out ${out}\` first to scaffold one.`); + const r = mergeBrainstorm(brief, b!, new Date().toISOString(), warnBrief); + saveBrief(out, r.brief); + saveBrainstorm(out, r.brainstorm); + writeBrainstormMd(out, r.brainstorm); + if (p.bools.has("json")) { + process.stdout.write(JSON.stringify({ merged: r.merged, parkedFolded: r.parkedFolded, skipped: r.skipped, proposed: r.proposed }, null, 2) + "\n"); + return; + } + process.stderr.write( + [ + `construct: merged brainstorm → brief.json`, + ` merged: ${r.merged} kept idea(s) folded into the brief`, + ` parked: ${r.parkedFolded} parked idea(s) → openQuestions (🧠 — resolve before check passes)`, + ` skipped: ${r.skipped} kept idea(s) not merged (no target / conflict — see warnings)`, + ` proposed: ${r.proposed} idea(s) still awaiting a decision`, + ` next: construct research --out ${out}`, + ].join("\n") + "\n", + ); + return; + } + // Scaffold or re-render the board (never clobbers existing ideas). + let b = loadBrainstorm(out, warnBrief); + if (!b) { + b = initBrainstorm(brief.idea, new Date().toISOString()); + saveBrainstorm(out, b); + } + writeBrainstormMd(out, b); + if (p.bools.has("json")) { + process.stdout.write(JSON.stringify(b, null, 2) + "\n"); + return; + } + const c = brainstormCounts(b); + process.stderr.write( + [ + `construct: brainstorm board at ${join(out, "BRAINSTORM.md")}`, + ` ideas: ${b.ideas.length} (${c.kept} kept · ${c.parked} parked · ${c.proposed} proposed · ${c.rejected} rejected)`, + ` next: generate ideas WITH the user (references/brainstorm-playbook.md), mark statuses in`, + ` brainstorm.json, then: construct brainstorm --out ${out} --merge`, + ].join("\n") + "\n", + ); + return; + } + case "research": { const out = requireOut(p); const angles = p.values.angles ? parseAngles(p.values.angles) : DEFAULT_ANGLES; @@ -348,6 +429,22 @@ async function main(): Promise { case "render": { const out = requireOut(p); + // --from-srd re-emits the tree from an edited SRD.json without rebuilding + // it from the brief + evidence — the enrich→re-render path. + if (p.bools.has("from-srd")) { + if (p.values.level) process.stderr.write("construct: --level is ignored with --from-srd (the manifest's level is authoritative).\n"); + if (p.bools.has("no-design")) + process.stderr.write("construct: --no-design is ignored with --from-srd (re-run a full render to change the design subtree).\n"); + const r = renderFromSRD(out, { merge: p.bools.has("merge"), prd: p.bools.has("prd") }); + process.stderr.write( + [ + `construct: re-emitted the SRD tree from ${join(out, "SRD.json")}`, + ` files: ${r.files.length} (${r.srd.functional.length} FR · ${r.srd.nonFunctional.length} NFR · ${r.srd.architecture.adrs.length} ADR)`, + ` next: construct check --out ${out}`, + ].join("\n") + "\n", + ); + return; + } const brief = loadBrief(out, warnBrief); const v = validateBrief(brief); if (!v.ok) fail(`brief is incomplete:\n${v.errors.map((e) => " - " + e).join("\n")}`); @@ -397,7 +494,7 @@ async function main(): Promise { fail("invalid --min-grounding (expected a number between 0 and 100)"); } } - const res = checkRun(out, { minGrounding, semantic: p.bools.has("semantic") }); + const res = checkRun(out, { minGrounding, semantic: p.bools.has("semantic"), allowUnverified: p.bools.has("allow-unverified") }); if (p.bools.has("json")) { process.stdout.write(JSON.stringify(res, null, 2) + "\n"); } else { @@ -416,8 +513,11 @@ async function main(): Promise { if (!res.ok) process.exit(1); return; } - const maxReview = p.values["max-review"] ? Number(p.values["max-review"]) : REVIEW_MAX; - if (!Number.isFinite(maxReview) || maxReview <= 0) fail("invalid --max-review"); + let maxReview: number | undefined; + if (p.values["max-review"] !== undefined) { + maxReview = Number(p.values["max-review"]); + if (p.values["max-review"].trim() === "" || !Number.isFinite(maxReview) || maxReview <= 0) fail("invalid --max-review"); + } const wl = runReview(out, { maxReview }); if (p.bools.has("json")) { process.stdout.write(JSON.stringify(wl, null, 2) + "\n"); @@ -460,9 +560,17 @@ async function main(): Promise { const planLine = plan ? ` ✓ BUILD-PLAN.json (build: ${plan.tasks.filter((t) => t.status === "done").length}/${plan.tasks.length} tasks done)` : ` · BUILD-PLAN.json (build plan)`; + const bs = loadBrainstorm(out); + const bsLine = bs + ? (() => { + const c = brainstormCounts(bs); + return ` ✓ brainstorm.json (${c.kept} kept · ${c.parked} parked · ${c.proposed} proposed · ${c.rejected} rejected)`; + })() + : ` · brainstorm.json (optional divergence)`; process.stdout.write( [ `construct status: ${out}`, + bsLine, ` ${has("brief.json")} brief.json`, ` ${has("evidence/evidence.json")} evidence/evidence.json (research)`, ` ${has("SRD.json")} SRD.json (render)`, diff --git a/src/render.ts b/src/render.ts index e3ec613..409c34e 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { join, dirname } from "node:path"; import { buildSRD, srdManifestPath } from "./srd.js"; import { derivePlan, mergePlan, loadPlan, writePlan } from "./plan.js"; @@ -63,6 +63,36 @@ export function renderSRD(brief: Brief, evidence: EvidenceItem[], opts: RenderOp // Design renders at complex unless opted out; light never renders it. const wantDesign = opts.level === "complex" && !opts.noDesign; const srd = buildSRD(brief, evidence, { level: opts.level, generatedAt: opts.generatedAt, design: wantDesign }); + return emitSRD(srd, { out: opts.out, merge: opts.merge, prd: opts.prd }); +} + +// Re-emit the SRD tree from an already-built (or hand-edited) SRD manifest, +// WITHOUT rebuilding it from a brief + evidence. This is the enrich→re-render +// path: an author sharpens SRD.json (the gated source of truth) and re-renders +// the human-facing markdown tree from it, so the two never drift. +export function renderFromSRD(runDir: string, opts: { merge: boolean; prd: boolean }): RenderResult { + const manifest = srdManifestPath(runDir); + if (!existsSync(manifest)) { + throw new Error(`No SRD.json in ${runDir} — render the SRD first (construct render), then edit it and re-run with --from-srd.`); + } + let srd: SRD; + try { + srd = JSON.parse(readFileSync(manifest, "utf8")) as SRD; + } catch (e) { + throw new Error(`SRD.json is unreadable: ${(e as Error).message}`); + } + // Light shape guards so a corrupt manifest fails with a domain message + // instead of a raw TypeError deep inside a template. + if (!Array.isArray(srd.functional) || !Array.isArray(srd.nonFunctional) || !srd.architecture || !Array.isArray(srd.architecture.adrs)) { + throw new Error(`SRD.json in ${runDir} is not a valid SRD manifest (missing functional/nonFunctional/architecture).`); + } + return emitSRD(srd, { out: runDir, merge: opts.merge, prd: opts.prd }); +} + +// Write the full SRD tree from a built SRD model. Pure apart from the +// filesystem write — shared by renderSRD (build then emit) and renderFromSRD +// (load then emit). +function emitSRD(srd: SRD, opts: { out: string; merge: boolean; prd?: boolean }): RenderResult { const files: string[] = []; const out = opts.out; diff --git a/src/research/fetch.ts b/src/research/fetch.ts index 47a782f..964b4eb 100644 --- a/src/research/fetch.ts +++ b/src/research/fetch.ts @@ -169,9 +169,55 @@ export function htmlToText(html: string): string { .join("\n"); } +// Consent/cookie-banner boilerplate that survives htmlToText (it lives in body +//
/, not