diff --git a/.gitignore b/.gitignore index 21bacb3..2b87786 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist .env .env.* !.env.example +.local/ +AGENTS.md diff --git a/README.md b/README.md index 7ffc575..83e4a9b 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ The target end-user experience is: 5. Review findings and decide whether each item is in scope or needs a change request. 6. Export or share the review with sales, finance and delivery. -The hosted pilot is live as a private early browser release. It is local-first: uploaded content is processed in the browser and the current pilot does not persist projects or decisions after a page reload. Contributors can run the local development surface below. +The hosted pilot is live as a private early browser release. It is local-first: uploaded content is processed in the browser and the current pilot saves the active workspace on this device only. It does not provide shared team storage or sign-in yet. Contributors can run the local development surface below. ### 1. Clone and start the local app @@ -89,11 +89,11 @@ http://127.0.0.1:5173/ ### 2. Choose a project -The current UI includes a small project switcher for the product surface. The active demo project is **Acme launch site**. The other project names are visual placeholders for the future multi-project workspace. +The current UI uses one local project at a time. **New local project** clears the current device-local workspace and lets you name the next pilot project; shared multi-project workspaces are not enabled yet. ### 3. Add the source of truth -Use **Add a source** to upload a scope document and communication export. The current supported formats are: +Use **Add a source** to upload a scope document and communication export. You can remove a source or correct its classification before running the analysis. The current supported formats are: | Format | Typical use | Current support | | --- | --- | --- | @@ -169,7 +169,7 @@ Click **Run analysis** after both source types are loaded. ScopeGuard then: - extracts bullet points from the scope source; - extracts messages from the communication source; - applies the configured rules; -- calculates the current finding list, scope coverage, message count and estimated hours at risk. +- calculates the current finding list, the share of messages with a scope basis, message count and preliminary exposure hours. The analysis is deterministic: the same source text and the same rules produce the same findings. @@ -188,7 +188,7 @@ Use the filters to focus on **All findings**, **High risk** or **Unreviewed**. O ### 6. Decide what happens next -Use **Mark in scope** when the request is genuinely covered by the agreement. Use **Create change request** when it needs clarification, a price, a new estimate or formal approval. The decision is currently held in the running app state; persistent project storage is part of the next technical slice. +Use **Mark in scope** when the request is genuinely covered by the agreement. Use **Create change request** when it needs clarification, a price, a new estimate or formal approval. Decisions and notes are saved in this browser only; shared project storage is part of the next technical slice. ## The demo scenario @@ -230,7 +230,7 @@ Example: pattern: "dashboard|partner portal|mobile app" titleStyle: subject_not_in_scope severity: high - confidence: 94 + confidence: 94 # rule signal, not a measured probability minHours: 32 maxHours: 40 scopeTerms: @@ -262,15 +262,15 @@ The intended operating habit is simple: review the queue before accepting new wo - Source files are parsed locally in the browser in this prototype. - No client content is sent to an external API by the current implementation. -- The app does not yet save projects, uploaded files, review notes or decisions across a page reload. The “Saved locally” label is part of the current product surface, not a completed persistence layer. +- The app saves the current project, uploaded files, review notes and decisions in versioned browser storage on this device. It is not a shared backend and should not be treated as team history or backup. - PDF and DOCX files are recognized but intentionally rejected until extraction adapters are added. - Direct Slack, Gmail and WhatsApp integrations are not implemented yet; export adapters are available and live pilot connectors are the next open-source slice. Facebook Messenger, additional mailbox providers, CRM and ERP integrations are not part of the open-source pilot. - The JSON parser supports common Telegram, Facebook Messenger and Slack-style arrays and objects with a `messages` array. It looks for fields such as `text`, `message`, `body` or `content`, including Telegram's array-of-text-fragments format. - WhatsApp text exports are treated as message sources when their filename includes `WhatsApp`, `chat` or a similar channel hint. - Scope extraction currently focuses on Markdown-style headings and bullet or numbered list items. -- Rule matching is lexical and deterministic. It does not yet understand full contractual context, negation, speaker authority or conversation history. -- “Scope coverage” is currently a simple prototype metric based on message count versus extracted scope-item count. It should not be interpreted as a percentage of contractual compliance. -- The export button and project switcher are visual product-surface placeholders in this slice. +- Rule matching is lexical and deterministic. The pilot now handles basic negation, sender role hints, included/excluded clauses, stable finding IDs and multiple rule matches, but it does not understand full contractual context or conversation history. +- “With scope basis” is the share of parsed messages for which at least one configured rule found a related scope clause. It is not a percentage of contractual compliance. +- Direct API integrations, shared workspaces and project switching remain outside this pilot slice. ## Technical shape @@ -320,7 +320,7 @@ The next useful steps are: 1. Add PDF and DOCX extraction adapters. 2. Add rule-level tests with labelled scope-drift fixtures. -3. Persist projects, sources, findings, notes and decisions in local SQLite or another explicit local store. +3. Add shared authenticated workspaces and audit history; device-local persistence is already available for the pilot. 4. Turn a `Change request` decision into an editable, exportable draft. 5. Improve clause matching and conversation context. 6. Add optional live OAuth/API connectors for Slack, Gmail and WhatsApp after the export-based pilot. diff --git a/docs/implementation-notes.md b/docs/implementation-notes.md index 44f343b..00ee7a6 100644 --- a/docs/implementation-notes.md +++ b/docs/implementation-notes.md @@ -10,17 +10,20 @@ The current slice is a responsive React/Vite local-first review workspace, a det - Sources are normalized into `SourceDocument` records with a scope/messages/unknown kind and an explicit format. - TXT/MD, EML and common Telegram, WhatsApp, Facebook Messenger and Slack JSON exports are parsed locally in `src/analysis.ts`. - The analyzer runs deterministic rules for new deliverables, acceptance criteria, extra revisions and unpriced commitments. -- Rule patterns, confidence, severity and hour ranges are configured in `src/rules.yaml` and parsed at build time. -- “Run analysis” now recomputes findings, coverage, message count and hours at risk from the current sources. +- Rule patterns, rule signal strength, severity and hour ranges are configured in `src/rules.yaml` and validated at build time. +- “Run analysis” now recomputes findings, the percentage of messages with a scope basis, message count and preliminary exposure from the current sources. - PDF/DOCX are rejected with an explicit next-adapter message instead of pretending they were parsed. CSV/XLSX and CRM/ERP records are deferred to private add-ons; Slack/Gmail/WhatsApp connectors are the next open-source pilot slice. - Findings are explicitly evidence-backed and use “potential” language. - A reviewer can add a note, mark a finding in scope, create a change request decision, reopen it, and use the unreviewed filter as a queue. - A first-run onboarding tour explains the scope, source upload, analysis, evidence review, decisions and local-first privacy model. The tour can be skipped, replayed from the help button and navigated with the keyboard. +- The current local workspace is persisted in browser storage under a versioned key, including sources, findings, analysis metrics, review notes and decisions. This is intentionally device-local and does not replace a shared backend. +- Review reports and approved change requests can be exported as Markdown files without sending project data to an external service. +- Pilot safeguards include a 10 MB source limit, required scope/message source validation, source reclassification and removal, stable finding IDs, basic negation and sender-role handling, included/excluded scope matching, and multipart email/plain-text Slack fallbacks. ## Next technical slice 1. Add PDF/DOCX extraction adapters. 2. Add rule-level tests against a labelled fixture set. -3. Persist projects, sources, review decisions and findings in local SQLite. -4. Turn the change-request decision into an editable/exportable draft. -5. Build and test the open-source Slack, Gmail and WhatsApp pilot connectors. +3. Add a shared server-side workspace and explicit authentication for team use. +4. Turn the change-request export into an editable, provider-specific draft. +5. Build and test live Slack, Gmail and WhatsApp connectors behind the approved privacy boundary. diff --git a/docs/page-strategy.md b/docs/page-strategy.md index d4ec0bc..33953ae 100644 --- a/docs/page-strategy.md +++ b/docs/page-strategy.md @@ -16,7 +16,7 @@ ScopeGuard is a local-first evidence review tool for small agencies. It compares - project context and saved-local status; - clear analysis state; -- amount of scope coverage and hours at risk; +- share of messages with a scope basis and preliminary exposure hours; - evidence list with filters for all, high risk and unreviewed; - source list with add/drop affordance; - local-first privacy reassurance; diff --git a/docs/product-status.md b/docs/product-status.md index fbd3246..302751a 100644 --- a/docs/product-status.md +++ b/docs/product-status.md @@ -25,7 +25,7 @@ The product has two distinct surfaces: 1. **Hosted user-facing app** — for finance, sales, account and delivery teams. It must work in a browser with a shared link, sign-in and no terminal usage. 2. **Open-source repository** — for developers, contributors and technical operators who inspect, extend or self-host the core. -Finance and sales users are not expected to use GitHub, Node.js or npm. The hosted browser pilot is now available at https://scopeguard.atvbox.chatgpt.site. The current release is private, local-first and intentionally limited: it does not yet persist projects, decisions or team history. +Finance and sales users are not expected to use GitHub, Node.js or npm. The hosted browser pilot is now available at https://scopeguard.atvbox.chatgpt.site. The current release is private, local-first and intentionally limited: it persists the active workspace on the current device, but does not yet provide shared projects, authentication or team history. ## 2. How the current MVP works @@ -100,7 +100,7 @@ Each finding contains: - source; - closest scope clause; - severity; -- confidence; +- rule signal strength; - estimated hours at risk. ### Human-in-the-loop review @@ -204,7 +204,7 @@ This is the next commercial layer after the Slack/Gmail/WhatsApp pilot. - Direct Slack, Gmail and WhatsApp integrations are planned as the next open-source pilot slice; export adapters are already available. - Multi-user invites, persistent workspaces and team access controls. -- Persistent project, source and review storage. +- Shared persistent project, source and review storage. - Backend and authentication. - Persistent storage after a page reload. - Team collaboration. @@ -216,7 +216,7 @@ This is the next commercial layer after the Slack/Gmail/WhatsApp pilot. - CRM/ERP functionality in the open-source repository. - Billing and enterprise controls. -The current `Saved locally` label is part of the product surface; a complete persistence layer is not connected yet. +The current `Saved on this device` label reflects versioned browser persistence. It is intentionally not a shared backend or a backup system. ## 8. Technical status diff --git a/reports/browser-qa.md b/reports/browser-qa.md index 80b1672..e635d0a 100644 --- a/reports/browser-qa.md +++ b/reports/browser-qa.md @@ -40,7 +40,42 @@ Date: 2026-08-02 ## Remaining prototype limitations -- Export report, help, workspace switcher, account menu and new project are visual placeholders. +- Shared workspace switching, account menus and team collaboration are intentionally outside the local pilot; export report, help and new local project are functional. - Uploaded source content and review decisions remain in React state and are not persisted across a page reload. - Direct Telegram, WhatsApp Business and Facebook Messenger integrations are not implemented; the current workflow uses exported files. - PDF/DOCX extraction adapters are still pending. +# ScopeGuard browser QA + +Date: 2026-08-02 + +## Scope + +- Route: `/` +- Local URL: `http://127.0.0.1:4173/` +- Desktop viewport: 1280 × 720 +- Mobile viewport: iPhone 13 emulation + +## Checks completed + +- Production build starts and serves the application. +- Desktop render shows the workspace, evidence cards, source panel and onboarding tour. +- Mobile render shows the responsive brand bar, breadcrumbs, action buttons and onboarding card without a blank page. +- Initial demo workspace shows four findings. +- Two source classification controls are present. +- The onboarding tour can be skipped. +- `Run analysis` completes and returns `Analysis complete`. +- Console errors: none during the smoke test. +- Source validation and source management are covered by unit tests; browser automation used the demo workspace for the smoke test. + +## Result + +PASS for the local pilot slice. The application is ready for a controlled export-based pilot, with the documented limitations that analysis remains deterministic and local, shared workspaces are not available, and PDF/DOCX extraction is not enabled. + +## Frontend pilot flow + +- Demo mode is visibly labelled and offers a direct path to an empty project. +- A named local project can be created from the sidebar. +- Two in-memory test files (scope TXT + Slack JSON) were uploaded through the real file input. +- The source list showed both files, the status changed to `Ready to analyse`, and the analysis completed with one finding. +- No console errors occurred during the full flow. +- The onboarding dialog receives focus when opened and remains dismissible with `Escape`. diff --git a/src/analysis.ts b/src/analysis.ts index b3b7dcc..c423ab8 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -1,10 +1,11 @@ import { parse } from 'yaml' import rulesText from './rules.yaml?raw' import { detectPilotChannel, parsePilotExport } from './connectors' -import type { PilotChannel } from './connectors' +import type { ConnectorMessage, MessageRole, PilotChannel } from './connectors' export type SourceKind = 'scope' | 'messages' | 'unknown' export type SourceFormat = 'txt' | 'md' | 'eml' | 'json' | 'pdf' | 'docx' | 'unknown' +export type ScopeMatch = 'included' | 'excluded' | 'ambiguous' | 'none' export type SourceDocument = { id: string @@ -22,6 +23,7 @@ export type Finding = { excerpt: string source: string scope: string + scopeMatch: ScopeMatch hours: string confidence: number severity: 'high' | 'medium' | 'low' @@ -33,11 +35,17 @@ export type AnalysisResult = { findings: Finding[] scopeItemsCount: number messagesCompared: number + messagesWithScopeBasis: number scopeCoverage: number hoursAtRisk: string unsupportedSources: string[] } +export type SourceValidation = { + errors: string[] + warnings: string[] +} + type ScopeItem = { id: string section: string @@ -49,6 +57,8 @@ type MessageRecord = { id: string text: string source: string + author?: string + role?: MessageRole } type RuleConfig = { @@ -63,28 +73,11 @@ type RuleConfig = { scopeTerms: string[] } -type Rule = { - type: string - pattern: RegExp - titleStyle: RuleConfig['titleStyle'] - severity: Finding['severity'] - confidence: number - minHours: number - maxHours: number - scopeTerms: string[] -} +type Rule = Omit & { pattern: RegExp } -const rulesDocument = parse(rulesText) as { rules: RuleConfig[] } -const rules: Rule[] = rulesDocument.rules.map((config) => ({ - type: config.type, - pattern: new RegExp(config.pattern, 'i'), - titleStyle: config.titleStyle, - severity: config.severity, - confidence: config.confidence, - minHours: config.minHours, - maxHours: config.maxHours, - scopeTerms: config.scopeTerms, -})) +export const MAX_SOURCE_BYTES = 10 * 1024 * 1024 + +const rules = loadRules(rulesText) export const demoSources: SourceDocument[] = [ { @@ -106,7 +99,7 @@ export const demoSources: SourceDocument[] = [ ## SOW §2.2 — Excluded work - No partner dashboard -- No analytics implementation`, +- No analytics implementation or funnel tracking`, }, { id: 'demo-messages', @@ -126,13 +119,19 @@ export const demoSources: SourceDocument[] = [ export async function parseSourceFile(file: File): Promise { const format = getFormat(file.name) + if (file.size > MAX_SOURCE_BYTES) { + throw new Error(`${file.name}: file is larger than 10 MB. Export a smaller date range for the pilot.`) + } + if (format === 'unknown') { + throw new Error(`${file.name}: unsupported file type. Use TXT, MD, EML or JSON for the pilot.`) + } if (format === 'pdf' || format === 'docx') { - throw new Error(`${file.name}: PDF/DOCX extraction is the next adapter; use TXT, MD, EML or JSON for now.`) + throw new Error(`${file.name}: PDF/DOCX extraction is not enabled yet; use TXT, MD, EML or JSON for now.`) } const content = await file.text() return { - id: `${file.name}-${file.lastModified}-${file.size}`, + id: `${file.name}-${stableHash(content)}`, name: file.name, kind: inferKind(file.name, content), format, @@ -141,20 +140,43 @@ export async function parseSourceFile(file: File): Promise { } } +export function validateSources(sources: SourceDocument[]): SourceValidation { + const errors: string[] = [] + const warnings: string[] = [] + const scopeSources = sources.filter((source) => source.kind === 'scope') + const messageSources = sources.filter((source) => source.kind === 'messages') + const unknownSources = sources.filter((source) => source.kind === 'unknown') + + if (!scopeSources.length) errors.push('Add at least one scope document (SOW, contract or brief).') + if (!messageSources.length) errors.push('Add at least one communication export from Slack, email or WhatsApp.') + if (unknownSources.length) warnings.push(`Unclassified sources will not be analysed: ${unknownSources.map((source) => source.name).join(', ')}.`) + + if (scopeSources.length && !scopeSources.some((source) => extractScopeItems(source.id, source.content).length)) { + errors.push('The scope document has no readable bullet points. Use headings with bullet or numbered deliverables.') + } + if (messageSources.length && !messageSources.some((source) => extractMessages(source).length)) { + errors.push('The communication export has no readable messages. Check the export format or reclassify the source.') + } + + return { errors, warnings } +} + export function analyzeSources(sources: SourceDocument[]): AnalysisResult { const scopeSources = sources.filter((source) => source.kind === 'scope') const messageSources = sources.filter((source) => source.kind === 'messages') - const scopeItems = scopeSources.flatMap((source) => extractScopeItems(source.content)) + const scopeItems = scopeSources.flatMap((source) => extractScopeItems(source.id, source.content)) const messages = messageSources.flatMap((source) => extractMessages(source)) - const findings = messages.flatMap((message, index) => createFindings(message, index, scopeItems)) + const findings = messages.flatMap((message) => createFindings(message, scopeItems)) const totalMin = findings.reduce((sum, finding) => sum + parseHours(finding.hours)[0], 0) const totalMax = findings.reduce((sum, finding) => sum + parseHours(finding.hours)[1], 0) - const coverage = scopeItems.length === 0 ? 0 : Math.min(100, Math.round((messages.length / scopeItems.length) * 100)) + const messagesWithScopeBasis = messages.filter((message) => rules.some((rule) => rule.pattern.test(message.text) && findScopeBasis(rule, message.text, scopeItems).status !== 'none')).length + const coverage = messages.length === 0 ? 0 : Math.round((messagesWithScopeBasis / messages.length) * 100) return { findings, scopeItemsCount: scopeItems.length, messagesCompared: messages.length, + messagesWithScopeBasis, scopeCoverage: coverage, hoursAtRisk: findings.length ? `${totalMin}–${totalMax}h` : '0h', unsupportedSources: sources.filter((source) => source.kind === 'unknown').map((source) => source.name), @@ -163,12 +185,13 @@ export function analyzeSources(sources: SourceDocument[]): AnalysisResult { function inferKind(name: string, content: string): SourceKind { const lowerName = name.toLowerCase() - if (detectPilotChannel(name, content)) return 'messages' - if (/slack|email|message|messenger|telegram|whatsapp|facebook|meta|thread|chat|linear|jira/.test(lowerName)) return 'messages' - if (/(^|[-_.])(sow|scope|contract|agreement|brief|proposal)([-_.]|$)/.test(lowerName)) return 'scope' - if (content.match(/(^|\n)##?\s*(included|excluded|scope|deliverables)/i)) return 'scope' - if (content.match(/(^|\n)\s*(from|subject|date):/i)) return 'messages' - if (looksLikeMessageExport(content)) return 'messages' + const scopeNameSignal = /(^|[-_.])(sow|scope|contract|agreement|brief|proposal)([-_.]|$)/.test(lowerName) + const scopeContentSignal = /(^|\n)\s*#{1,6}\s*(included|excluded|scope|deliverables|assumptions|requirements)/im.test(content) + const messageNameSignal = /slack|email|message|messenger|telegram|whatsapp|facebook|meta|thread|chat|linear|jira/.test(lowerName) + const messageContentSignal = looksLikeMessageExport(content) || /(^|\n)\s*(from|subject|date):/im.test(content) + + if (scopeNameSignal || scopeContentSignal) return 'scope' + if (messageNameSignal || messageContentSignal || detectPilotChannel(name, content)) return 'messages' return 'unknown' } @@ -193,9 +216,9 @@ function getFormat(name: string): SourceFormat { return 'unknown' } -function extractScopeItems(content: string): ScopeItem[] { +function extractScopeItems(sourceId: string, content: string): ScopeItem[] { let currentSection = 'Scope' - let itemNumber = 0 + const occurrences = new Map() return content.split(/\r?\n/).flatMap((line) => { const trimmed = line.trim() @@ -207,82 +230,115 @@ function extractScopeItems(content: string): ScopeItem[] { } const bullet = trimmed.match(/^(?:[-*•]|\d+[.)])\s+(.+)$/) if (!bullet || bullet[1].length < 5) return [] - itemNumber += 1 + const text = bullet[1].trim() + const key = normalizeForMatching(text) + const occurrence = occurrences.get(key) ?? 0 + occurrences.set(key, occurrence + 1) return [{ - id: `scope-${itemNumber}`, + id: `${sourceId}-scope-${stableHash(`${key}|${occurrence}`)}`, section: currentSection, - text: bullet[1].trim(), - excluded: /excluded|no |not included|out of scope/i.test(currentSection) || /^(no |not included|excluded)/i.test(bullet[1]), + text, + excluded: /excluded|no |not included|out of scope/i.test(currentSection) || /^(no |not included|excluded)/i.test(text), }] }) } function extractMessages(source: SourceDocument): MessageRecord[] { - if (source.channel) return parsePilotExport(source.channel, source.name, source.content).map((message, index) => ({ - id: `${source.id}-${index}`, - text: message.text, - source: message.source, - })) - if (source.format === 'json') return parseJsonMessages(source) - if (source.format === 'eml') return parseEmail(source) + const channel = source.channel ?? (source.format === 'json' ? 'slack' : source.format === 'eml' ? 'gmail' : undefined) + if (channel) return parsePilotExport(channel, source.name, source.content).map((message, index) => normalizeMessage(source, message, index)) - return source.content.split(/\n\s*\n|\r?\n/).map((text, index) => text.trim()).filter(Boolean).map((text, index) => ({ - id: `${source.id}-${index}`, + return source.content.split(/\n\s*\n|\r?\n/).map((text) => text.trim()).filter(Boolean).map((text, index) => ({ + id: `${source.id}-${stableHash(`${normalizeForMatching(text)}|${index}`)}`, text, source: `${formatSourceName(source.name)} · message ${index + 1}`, })) } -function parseJsonMessages(source: SourceDocument): MessageRecord[] { - try { - const parsed: unknown = JSON.parse(source.content) - const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.messages) ? parsed.messages : [] - return entries.flatMap((entry, index) => { - if (typeof entry === 'string') return [{ id: `${source.id}-${index}`, text: entry, source: `${formatSourceName(source.name)} · message ${index + 1}` }] - if (!isRecord(entry)) return [] - const text = firstText(entry, ['text', 'message', 'body', 'content']) - if (!text) return [] - const channel = firstText(entry, ['channel', 'source', 'type']) - const date = firstText(entry, ['date', 'ts', 'timestamp']) - return [{ id: `${source.id}-${index}`, text, source: [channel, date].filter(Boolean).join(' · ') || `${formatSourceName(source.name)} · message ${index + 1}` }] - }) - } catch { - return [] +function normalizeMessage(source: SourceDocument, message: ConnectorMessage, index: number): MessageRecord { + const text = message.text.trim() + return { + id: `${source.id}-${message.id ?? stableHash(`${normalizeForMatching(text)}|${index}`)}`, + text, + source: message.source || `${formatSourceName(source.name)} · message ${index + 1}`, + author: message.author, + role: message.role, } } -function parseEmail(source: SourceDocument): MessageRecord[] { - const lines = source.content.split(/\r?\n/) - const subject = lines.find((line) => /^subject:/i.test(line))?.replace(/^subject:\s*/i, '').trim() - const date = lines.find((line) => /^date:/i.test(line))?.replace(/^date:\s*/i, '').trim() - const bodyStart = lines.findIndex((line) => line.trim() === '') - const body = lines.slice(bodyStart + 1).join('\n').trim() - return body ? [{ id: source.id, text: subject ? `${subject}: ${body}` : body, source: [formatSourceName(source.name), date].filter(Boolean).join(' · ') }] : [] -} - -function createFindings(message: MessageRecord, index: number, scopeItems: ScopeItem[]): Finding[] { - const rule = rules.find((candidate) => candidate.pattern.test(message.text)) - if (!rule) return [] - const scope = findScopeBasis(rule, message.text, scopeItems) - return [{ - id: `SG-${String(14 - index).padStart(3, '0')}`, - type: rule.type, - title: titleFromRule(rule.titleStyle, message.text), - excerpt: `“${message.text}”`, - source: message.source, - scope, - hours: `${rule.minHours}–${rule.maxHours}h`, - confidence: rule.confidence, - severity: rule.severity, - reviewed: false, - decision: 'pending', - }] -} - -function findScopeBasis(rule: Rule, text: string, scopeItems: ScopeItem[]): string { - const terms = [...rule.scopeTerms, ...text.toLowerCase().split(/\W+/).filter((word) => word.length > 5)] - const match = scopeItems.find((item) => terms.some((term) => item.text.toLowerCase().includes(term))) - return match ? `${match.section} · ${match.text}` : 'No matching clause found' +function createFindings(message: MessageRecord, scopeItems: ScopeItem[]): Finding[] { + return rules.flatMap((rule) => { + if (!rule.pattern.test(message.text)) return [] + if (rule.id === 'unpriced_commitment' && message.role === 'client') return [] + if (isNegatedSignal(rule, message.text)) return [] + + const scopeMatch = findScopeBasis(rule, message.text, scopeItems) + if (rule.id === 'new_deliverable' && scopeMatch.status === 'included') return [] + + const confidence = adjustedConfidence(rule, scopeMatch.status) + const severity = adjustedSeverity(rule, scopeMatch.status) + return [{ + id: `SG-${stableHash(`${message.id}|${rule.id}|${normalizeForMatching(message.text)}`)}`, + type: rule.type, + title: titleFromRule(rule.titleStyle, message.text), + excerpt: `“${message.text}”`, + source: message.author ? `${message.source} · ${message.author}` : message.source, + scope: scopeMatch.label, + scopeMatch: scopeMatch.status, + hours: `${rule.minHours}–${rule.maxHours}h`, + confidence, + severity, + reviewed: false, + decision: 'pending', + }] + }) +} + +function findScopeBasis(rule: Rule, text: string, scopeItems: ScopeItem[]): { status: ScopeMatch; label: string } { + const normalizedText = normalizeForMatching(text) + const textTokens = new Set(tokensFrom(normalizedText)) + const candidates = scopeItems.map((item) => { + const itemText = normalizeForMatching(`${item.section} ${item.text}`) + const itemTokens = new Set(tokensFrom(itemText)) + let score = 0 + + for (const term of rule.scopeTerms) { + const variants = expandTerm(term) + const textMatches = variants.some((variant) => hasTerm(normalizedText, variant)) + const itemMatches = variants.some((variant) => hasTerm(itemText, variant)) + if (textMatches && itemMatches) score += 5 + } + for (const token of textTokens) { + if (token.length > 4 && itemTokens.has(token)) score += 1 + } + if (score > 0 && item.excluded) score += 0.25 + return { item, score } + }).filter((candidate) => candidate.score > 0) + + if (!candidates.length) return { status: 'none', label: 'No matching clause found' } + candidates.sort((left, right) => right.score - left.score || Number(right.item.excluded) - Number(left.item.excluded)) + const best = candidates[0] + const tied = candidates.filter((candidate) => Math.abs(candidate.score - best.score) < 0.01) + const status: ScopeMatch = tied.length > 1 ? 'ambiguous' : best.item.excluded ? 'excluded' : 'included' + const prefix = status === 'excluded' ? 'Excluded' : status === 'included' ? 'Included' : 'Ambiguous' + return { status, label: `${prefix} · ${best.item.section} · ${best.item.text}` } +} + +function adjustedConfidence(rule: Rule, scopeMatch: ScopeMatch): number { + const adjustment = scopeMatch === 'none' ? -15 : scopeMatch === 'ambiguous' ? -20 : scopeMatch === 'included' ? -8 : 0 + return Math.max(35, Math.min(99, rule.confidence + adjustment)) +} + +function adjustedSeverity(rule: Rule, scopeMatch: ScopeMatch): Finding['severity'] { + if (rule.id === 'unpriced_commitment' && scopeMatch === 'included') return 'low' + if (scopeMatch === 'ambiguous' && rule.severity === 'high') return 'medium' + return rule.severity +} + +function isNegatedSignal(rule: Rule, text: string): boolean { + const match = rule.pattern.exec(text) + if (!match || match.index === undefined) return false + const context = text.slice(Math.max(0, match.index - 60), match.index + match[0].length + 80) + return /\b(?:not|no|never|won't|wouldn't|isn't|aren't|don't|do not|out of scope|not going to|will not)\b/i.test(context) } function extractSubject(text: string): string { @@ -298,24 +354,63 @@ function titleFromRule(style: RuleConfig['titleStyle'], text: string): string { return 'A delivery commitment appears without a matching price' } -function formatSourceName(name: string): string { - return name.replace(/\.[^.]+$/, '') +function loadRules(text: string): Rule[] { + try { + const parsed: unknown = parse(text) + if (!isRecord(parsed) || !Array.isArray(parsed.rules)) throw new Error('rules must contain a rules list') + return parsed.rules.map((value, index) => { + if (!isRecord(value) || !isRuleConfig(value)) throw new Error(`rule ${index + 1} is invalid`) + return { ...value, pattern: new RegExp(value.pattern, 'i') } + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown YAML error' + throw new Error(`ScopeGuard rules could not be loaded: ${message}`) + } } -function firstText(record: Record, keys: string[]): string | undefined { - for (const key of keys) { - const value = record[key] - if (typeof value === 'string' && value.trim()) return value.trim() - if (Array.isArray(value)) { - const text = value.map((part) => { - if (typeof part === 'string') return part - if (isRecord(part) && typeof part.text === 'string') return part.text - return '' - }).join('').trim() - if (text) return text - } +function isRuleConfig(value: Record): value is Record & RuleConfig { + return typeof value.id === 'string' + && typeof value.type === 'string' + && typeof value.pattern === 'string' + && typeof value.titleStyle === 'string' + && ['high', 'medium', 'low'].includes(String(value.severity)) + && typeof value.confidence === 'number' + && typeof value.minHours === 'number' + && typeof value.maxHours === 'number' + && Array.isArray(value.scopeTerms) + && value.scopeTerms.every((term) => typeof term === 'string') +} + +function expandTerm(term: string): string[] { + const normalized = normalizeForMatching(term) + const aliases: Record = { + analytics: ['analytics', 'track', 'tracked', 'tracking'], + tracking: ['analytics', 'track', 'tracked', 'tracking'], + revision: ['revision', 'revisions', 'pass', 'round', 'rounds'], + round: ['revision', 'revisions', 'pass', 'round', 'rounds'], } - return undefined + return aliases[normalized] ?? [normalized] +} + +function hasTerm(text: string, term: string): boolean { + const normalized = normalizeForMatching(term) + return normalized ? new RegExp(`(?:^|\\s)${escapeRegExp(normalized)}(?:$|\\s)`).test(text) : false +} + +function normalizeForMatching(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim() +} + +function tokensFrom(value: string): string[] { + return value.split(' ').filter((token) => token.length > 2 && !['the', 'and', 'for', 'with', 'this', 'that', 'from', 'into', 'will', 'can'].includes(token)) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function formatSourceName(name: string): string { + return name.replace(/\.[^.]+$/, '') } function isRecord(value: unknown): value is Record { @@ -326,3 +421,12 @@ function parseHours(value: string): [number, number] { const matches = value.match(/(\d+)–(\d+)/) return matches ? [Number(matches[1]), Number(matches[2])] : [0, 0] } + +function stableHash(value: string): string { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} diff --git a/src/connectors.ts b/src/connectors.ts index 25d26e2..372e3b5 100644 --- a/src/connectors.ts +++ b/src/connectors.ts @@ -1,37 +1,80 @@ export type PilotChannel = 'slack' | 'gmail' | 'whatsapp' +export type MessageRole = 'client' | 'team' | 'unknown' export type ConnectorMessage = { + id?: string text: string source: string + author?: string + role?: MessageRole } export function detectPilotChannel(name: string, content: string): PilotChannel | undefined { const lowerName = name.toLowerCase() if (/slack/.test(lowerName)) return 'slack' if (/whatsapp/.test(lowerName)) return 'whatsapp' - if (/gmail|email|inbox|takeout|mbox/.test(lowerName) || /^(from|subject|date):/im.test(content)) return 'gmail' + if (/\.eml$|gmail|email|inbox|takeout|mbox/.test(lowerName) || /^(from|subject|date):/im.test(content)) return 'gmail' if (/^\[?\d{1,4}[./-]\d{1,2}[./-]\d{1,4},\s+\d{1,2}:\d{2}/m.test(content)) return 'whatsapp' return undefined } export function parsePilotExport(channel: PilotChannel, name: string, content: string): ConnectorMessage[] { - if (channel === 'slack') return parseJsonMessages(name, content) + if (channel === 'slack') return parseSlack(name, content) if (channel === 'gmail') return parseEmail(name, content) return parseWhatsApp(name, content) } +function parseSlack(name: string, content: string): ConnectorMessage[] { + const jsonMessages = parseJsonMessages(name, content) + if (jsonMessages.length) return jsonMessages + + const linePattern = /^\s*(?:\[)?(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}[^|]*)\|\s*([^|]+)\|\s*(.+)$/ + const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) + const structured = lines.flatMap((line, index) => { + const match = line.match(linePattern) + if (!match) return [] + return [{ + id: stableHash(`${match[1]}|${match[2]}|${match[3]}|${index}`), + text: match[3].trim(), + source: `${formatSourceName(name)} · ${match[1].trim()}`, + author: match[2].trim(), + role: inferMessageRole(match[2]), + }] + }) + if (structured.length) return structured + + return lines.map((text, index) => ({ + id: stableHash(`${text}|${index}`), + text, + source: `${formatSourceName(name)} · message ${index + 1}`, + })) +} + function parseJsonMessages(name: string, content: string): ConnectorMessage[] { try { const parsed: unknown = JSON.parse(content) const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.messages) ? parsed.messages : [] + const occurrences = new Map() return entries.flatMap((entry, index) => { - if (typeof entry === 'string') return [{ text: entry.trim(), source: `${formatSourceName(name)} · message ${index + 1}` }] + if (typeof entry === 'string' && entry.trim()) { + const key = entry.trim() + return [{ id: stableMessageId(key, occurrences), text: key, source: `${formatSourceName(name)} · message ${index + 1}` }] + } if (!isRecord(entry)) return [] const text = firstText(entry, ['text', 'message', 'body', 'content']) if (!text) return [] const channel = firstText(entry, ['channel', 'source', 'type']) const date = firstText(entry, ['date', 'ts', 'timestamp']) - return [{ text, source: [channel, date].filter(Boolean).join(' · ') || `${formatSourceName(name)} · message ${index + 1}` }] + const author = firstText(entry, ['user', 'author', 'sender', 'from', 'name']) + const explicitId = firstText(entry, ['id', 'message_id', 'messageId']) + const id = explicitId || stableMessageId(`${text}|${channel ?? ''}|${date ?? ''}`, occurrences) + return [{ + id, + text, + source: [channel, date].filter(Boolean).join(' · ') || `${formatSourceName(name)} · message ${index + 1}`, + author, + role: inferMessageRole(author), + }] }) } catch { return [] @@ -39,25 +82,54 @@ function parseJsonMessages(name: string, content: string): ConnectorMessage[] { } function parseEmail(name: string, content: string): ConnectorMessage[] { - const lines = content.split(/\r?\n/) - const subject = lines.find((line) => /^subject:/i.test(line))?.replace(/^subject:\s*/i, '').trim() - const date = lines.find((line) => /^date:/i.test(line))?.replace(/^date:\s*/i, '').trim() - const bodyStart = lines.findIndex((line) => line.trim() === '') - const body = lines.slice(bodyStart + 1).join('\n').trim() - if (!body) return [] - return [{ text: subject ? `${subject}: ${body}` : body, source: [formatSourceName(name), date].filter(Boolean).join(' · ') }] + const { headers, body } = splitMimePart(content) + const subject = headers.subject + const date = headers.date + const author = headers.from + const text = selectEmailText(headers['content-type'], headers['content-transfer-encoding'], body) + if (!text) return [] + + const boundary = headers['content-type']?.match(/boundary\s*=\s*"?([^";]+)"?/i)?.[1] + if (boundary) { + const parts = content.split(`--${boundary}`).map((part) => splitMimePart(part.replace(/^\s*\n/, ''))) + const preferred = parts.find((part) => /^text\/plain/i.test(part.headers['content-type'] ?? '')) + ?? parts.find((part) => /^text\/html/i.test(part.headers['content-type'] ?? '')) + if (preferred) { + const preferredText = selectEmailText(preferred.headers['content-type'], preferred.headers['content-transfer-encoding'], preferred.body) + if (preferredText) return [emailMessage(name, subject, date, author, preferredText)] + } + } + + return [emailMessage(name, subject, date, author, text)] +} + +function emailMessage(name: string, subject: string | undefined, date: string | undefined, author: string | undefined, body: string): ConnectorMessage { + const text = [subject, body].filter(Boolean).join(': ').trim() + return { + id: stableHash(`${subject ?? ''}|${date ?? ''}|${body}`), + text, + source: [formatSourceName(name), date].filter(Boolean).join(' · '), + author, + role: inferMessageRole(author), + } } function parseWhatsApp(name: string, content: string): ConnectorMessage[] { const messages: ConnectorMessage[] = [] - const messageStart = /^(?:\[)?\d{1,4}[./-]\d{1,2}[./-]\d{1,4},\s+\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AP]M)?(?:\])?\s*(?:-\s*)?([^:]+):\s*(.*)$/i + const messageStart = /^(?:\[)?\d{1,4}[./-]\d{1,4}[./-]\d{1,4},\s+\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AP]M)?(?:\])?\s*(?:[-–]\s*)?([^:]+):\s*(.*)$/i let current: ConnectorMessage | undefined - for (const line of content.split(/\r?\n/)) { + for (const [index, line] of content.split(/\r?\n/).entries()) { const match = line.match(messageStart) if (match) { if (current?.text) messages.push(current) - current = { text: match[2].trim(), source: `${formatSourceName(name)} · ${match[1].trim()}` } + current = { + id: stableHash(`${match[1]}|${match[2]}|${index}`), + text: match[2].trim(), + source: `${formatSourceName(name)} · ${match[1].trim()}`, + author: match[1].trim(), + role: inferMessageRole(match[1]), + } } else if (current && line.trim()) { current.text = `${current.text}\n${line.trim()}`.trim() } @@ -67,11 +139,55 @@ function parseWhatsApp(name: string, content: string): ConnectorMessage[] { if (messages.length) return messages return content.split(/\n\s*\n|\r?\n/).map((text, index) => text.trim()).filter(Boolean).map((text, index) => ({ + id: stableHash(`${text}|${index}`), text, source: `${formatSourceName(name)} · message ${index + 1}`, })) } +function splitMimePart(content: string): { headers: Record; body: string } { + const lines = content.replace(/\r\n/g, '\n').split('\n') + const headerLines: string[] = [] + let bodyStart = lines.findIndex((line) => line.trim() === '') + if (bodyStart < 0) bodyStart = lines.length + for (const line of lines.slice(0, bodyStart)) { + if (/^[ \t]/.test(line) && headerLines.length) headerLines[headerLines.length - 1] += line.trim() + else headerLines.push(line) + } + const headers: Record = {} + for (const line of headerLines) { + const separator = line.indexOf(':') + if (separator <= 0) continue + headers[line.slice(0, separator).trim().toLowerCase()] = line.slice(separator + 1).trim() + } + return { headers, body: lines.slice(bodyStart + 1).join('\n').trim() } +} + +function selectEmailText(contentType: string | undefined, transferEncoding: string | undefined, body: string): string { + const decoded = decodeTransferEncoding(body, transferEncoding) + if (/^text\/html/i.test(contentType ?? '')) return stripHtml(decoded) + return decoded.trim() +} + +function decodeTransferEncoding(value: string, encoding: string | undefined): string { + if (/quoted-printable/i.test(encoding ?? '')) { + return value.replace(/=\r?\n/g, '').replace(/=([0-9A-F]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))) + } + if (/base64/i.test(encoding ?? '')) { + try { + const binary = globalThis.atob(value.replace(/\s+/g, '')) + return new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))) + } catch { + return value + } + } + return value +} + +function stripHtml(value: string): string { + return value.replace(//gi, '').replace(//gi, '').replace(//gi, '\n').replace(/<[^>]+>/g, ' ').replace(/ /gi, ' ').replace(/&/gi, '&').replace(/\s+/g, ' ').trim() +} + function firstText(record: Record, keys: string[]): string | undefined { for (const key of keys) { const value = record[key] @@ -88,6 +204,29 @@ function firstText(record: Record, keys: string[]): string | un return undefined } +function inferMessageRole(author: string | undefined): MessageRole { + const value = author?.toLowerCase() ?? '' + if (!value) return 'unknown' + if (/client|customer|buyer|prospect/.test(value)) return 'client' + if (/studio|agency|team|owner|designer|developer|manager|account/.test(value)) return 'team' + return 'unknown' +} + +function stableMessageId(value: string, occurrences: Map): string { + const occurrence = occurrences.get(value) ?? 0 + occurrences.set(value, occurrence + 1) + return stableHash(`${value}|${occurrence}`) +} + +function stableHash(value: string): string { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + function formatSourceName(name: string): string { return name.replace(/\.[^.]+$/, '') } diff --git a/src/main.tsx b/src/main.tsx index b1811d4..d672d1f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,30 +1,44 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent } from 'react' import { createRoot } from 'react-dom/client' -import { analyzeSources, demoSources, parseSourceFile } from './analysis' +import { analyzeSources, demoSources, parseSourceFile, validateSources } from './analysis' import type { Finding, SourceDocument } from './analysis' +import { buildChangeRequest, buildReviewReport } from './report' +import { loadWorkspace, saveWorkspace } from './storage' import './styles.css' -const projectNames = ['Acme launch site', 'Northstar rebrand', 'Wavelength app'] const initialAnalysis = analyzeSources(demoSources) const tourStorageKey = 'scopeguard-onboarding-complete' +const persistedWorkspace = loadWorkspace() function App() { - const [activeProject, setActiveProject] = useState(projectNames[0]) + const [activeProject, setActiveProject] = useState(persistedWorkspace?.projectName ?? 'Acme launch site') const [filter, setFilter] = useState<'all' | 'high' | 'unreviewed'>('all') - const [findings, setFindings] = useState(initialAnalysis.findings) - const [sources, setSources] = useState(demoSources) - const [analysis, setAnalysis] = useState(initialAnalysis) + const [findings, setFindings] = useState(persistedWorkspace?.findings ?? initialAnalysis.findings) + const [sources, setSources] = useState(persistedWorkspace?.sources ?? demoSources) + const [analysis, setAnalysis] = useState(persistedWorkspace?.analysis ?? initialAnalysis) const [isAnalysing, setIsAnalysing] = useState(false) const [analysisReady, setAnalysisReady] = useState(true) const [sourceError, setSourceError] = useState(null) const [reviewingId, setReviewingId] = useState(null) - const [reviewNotes, setReviewNotes] = useState>({}) + const [reviewNotes, setReviewNotes] = useState>(persistedWorkspace?.reviewNotes ?? {}) const [isDragging, setIsDragging] = useState(false) const [isTourOpen, setIsTourOpen] = useState(() => !hasCompletedTour()) const [tourStep, setTourStep] = useState(0) + const [savedAt, setSavedAt] = useState(persistedWorkspace?.savedAt ?? null) + const [exportNotice, setExportNotice] = useState(null) + const [isCreatingProject, setIsCreatingProject] = useState(false) + const [draftProjectName, setDraftProjectName] = useState('') const fileInput = useRef(null) + const changeRequestFindings = useMemo(() => analysisReady ? findings.filter((finding) => finding.decision === 'change_request') : [], [analysisReady, findings]) + const isDemoWorkspace = sources.length === demoSources.length && sources.every((source, index) => source.id === demoSources[index]?.id) + + useEffect(() => { + setSavedAt(saveWorkspace({ projectName: activeProject, sources, findings, analysis, reviewNotes })) + }, [activeProject, analysis, findings, reviewNotes, sources]) + const visibleFindings = useMemo(() => { + if (!analysisReady) return [] if (filter === 'high') return findings.filter((finding) => finding.severity === 'high') if (filter === 'unreviewed') return findings.filter((finding) => !finding.reviewed) return findings @@ -41,7 +55,14 @@ function App() { })) const nextSources = parsed.flatMap((result) => result.source ? [result.source] : []) const errors = parsed.flatMap((result) => result.error ? [result.error] : []) - if (nextSources.length) setSources((current) => [...current, ...nextSources]) + if (nextSources.length) { + setSources((current) => { + const isDemoWorkspace = current.length === demoSources.length && current.every((source, index) => source.id === demoSources[index]?.id) + const baseSources = isDemoWorkspace ? [] : current + const existingIds = new Set(baseSources.map((source) => source.id)) + return [...baseSources, ...nextSources.filter((source) => !existingIds.has(source.id))] + }) + } setSourceError(errors.length ? errors.join(' ') : null) setAnalysisReady(false) } @@ -52,10 +73,12 @@ function App() { } const runAnalysis = () => { - if (sources.length < 2) { - setSourceError('Add one scope document and one communication export before running analysis.') + const validation = validateSources(sources) + if (validation.errors.length) { + setSourceError(validation.errors.join(' ')) return } + setSourceError(validation.warnings.length ? validation.warnings.join(' ') : null) setIsAnalysing(true) setAnalysisReady(false) window.setTimeout(() => { @@ -71,6 +94,36 @@ function App() { }, 350) } + const removeSource = (id: string) => { + setSources((current) => current.filter((source) => source.id !== id)) + setAnalysisReady(false) + setReviewingId(null) + } + + const reclassifySource = (id: string, kind: SourceDocument['kind']) => { + setSources((current) => current.map((source) => source.id === id ? { ...source, kind } : source)) + setAnalysisReady(false) + setReviewingId(null) + } + + const startNewProject = () => { + setDraftProjectName('') + setIsCreatingProject(true) + } + + const createProject = () => { + const name = draftProjectName.trim() || 'Untitled pilot project' + setActiveProject(name) + setSources([]) + setFindings([]) + setAnalysis(analyzeSources([])) + setReviewNotes({}) + setSourceError(null) + setReviewingId(null) + setAnalysisReady(false) + setIsCreatingProject(false) + } + const openReview = (id: string) => { setReviewingId((current) => current === id ? null : id) } @@ -89,6 +142,23 @@ function App() { setReviewingId(id) } + const exportReport = () => { + if (!analysisReady) return + downloadText(`${toFileSlug(activeProject)}-scopeguard-report.md`, buildReviewReport(activeProject, sources, analysis, findings)) + showExportNotice('Report downloaded') + } + + const exportChangeRequests = () => { + const content = changeRequestFindings.map((finding) => buildChangeRequest(finding, activeProject, reviewNotes[finding.id])).join('\n\n---\n\n') + downloadText(`${toFileSlug(activeProject)}-change-requests.md`, content) + showExportNotice(`${changeRequestFindings.length} change request${changeRequestFindings.length === 1 ? '' : 's'} downloaded`) + } + + const showExportNotice = (message: string) => { + setExportNotice(message) + window.setTimeout(() => setExportNotice(null), 2400) + } + return (
Projects/{activeProject}
-
Saved locally
+
{savedAt ? 'Saved on this device' : 'Session only'}{exportNotice && {exportNotice}}
-

PROJECT REVIEW · 14 MAY 2026

+

PROJECT REVIEW · LOCAL PILOT

Keep the work
inside the lines.

ScopeGuard compares what was agreed with what is now being asked — before the margin disappears.

-
+
{changeRequestFindings.length > 0 && }
+ {isDemoWorkspace &&
Demo data

The workspace starts with example files. Upload real exports to replace them, or start an empty local project.

} +
-
{analysisReady ? '✓' : '…'}
{analysisReady ? 'Analysis complete' : 'Ready to analyse'}{analysisReady ? `Compared ${analysis.messagesCompared} messages against ${analysis.scopeItemsCount} scope items` : `${sources.length} source${sources.length === 1 ? '' : 's'} loaded · Run analysis to compare them`}
-
Scope coverage{analysis.scopeCoverage}%
Hours at risk{analysis.hoursAtRisk}
Unreviewed{findings.filter((finding) => !finding.reviewed).length}
+
{analysisReady ? '✓' : '…'}
{analysisReady ? 'Analysis complete' : sources.length ? 'Ready to analyse' : 'Add project sources'}{analysisReady ? `Compared ${analysis.messagesCompared} messages against ${analysis.scopeItemsCount} scope items` : `${sources.length} source${sources.length === 1 ? '' : 's'} loaded · Run analysis after adding both source types`}
+
With scope basis{analysisReady ? `${analysis.scopeCoverage}%` : '—'}
Preliminary exposure{analysisReady ? analysis.hoursAtRisk : '—'}
Unreviewed{analysisReady ? findings.filter((finding) => !finding.reviewed).length : '—'}
-

EVIDENCE REVIEW

Potential scope drift

{(['all', 'high', 'unreviewed'] as const).map((item) => )}
+

EVIDENCE REVIEW

Potential scope drift

{(['all', 'high', 'unreviewed'] as const).map((item) => )}
{visibleFindings.map((finding) => )} - {!visibleFindings.length &&
Nothing waiting here.Every finding in this view has been reviewed.
} + {!visibleFindings.length &&
{!analysisReady ? 'Sources changed — run analysis.' : findings.length ? 'Nothing waiting here.' : 'No potential scope drift found.'}{!analysisReady ? 'Previous findings are hidden until the current source set is analysed.' : findings.length ? 'Every finding in this view has been reviewed.' : 'Add another communication export if you want to check more conversations.'}
}