Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Every finding contains:
- severity and confidence;
- an estimated hour range.

Use the filters to focus on **All findings**, **High risk** or **Unreviewed**. Open **Review finding** to add an optional note and make a decision.
Use the filters to separate **Scope drift**, **Commercial risks**, **High risk** and **Unreviewed** items. A cancellation or payment signal is not a scope verdict; it is a follow-up queue for finance or sales. Open **Review finding** to add an optional note and make a decision.

### 6. Decide what happens next

Expand Down
5 changes: 3 additions & 2 deletions docs/implementation-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ The current slice is a responsive React/Vite local-first review workspace, a det
- No external UI library yet; the surface is small and the visual language is custom.
- Sources are normalized into `SourceDocument` records with a scope/messages/unknown kind and an explicit format.
- TXT/MD, RTF-in-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.
- The analyzer runs deterministic rules for new deliverables, acceptance criteria, extra revisions, unpriced commitments and commercial risks such as cancellation, payment, availability and customer delay.
- 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.
- “Run analysis” now recomputes findings, commercial-risk count, 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.
Expand All @@ -22,6 +22,7 @@ The current slice is a responsive React/Vite local-first review workspace, a det
- The pilot visual system remains the original warm paper/ink/orange workspace design; the page header and demo banner have explicit layout styles so pilot-state additions do not fall back to browser defaults.
- An initial order email can be used as the scope source: order/request-style filenames are scope candidates, and manually reclassified prose emails produce scope items without requiring Markdown bullets.
- RTF order exports saved with an `.md` extension are normalized locally; cancellation/reply email filenames stay communication candidates even when their quoted history mentions order totals.
- Commercial risks are intentionally separate from scope drift: they do not add fake delivery hours and use a follow-up action instead of a scope clause.

## Next technical slice

Expand Down
4 changes: 2 additions & 2 deletions docs/page-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ ScopeGuard is a local-first evidence review tool for small agencies. It compares

- project context and saved-local status;
- clear analysis state;
- share of messages with a scope basis and preliminary exposure hours;
- evidence list with filters for all, high risk and unreviewed;
- share of messages with a scope basis, preliminary exposure hours and commercial-risk count;
- evidence list with filters for all, scope drift, commercial risks, high risk and unreviewed;
- source list with add/drop affordance;
- local-first privacy reassurance;
- no legal verdict language.
Expand Down
2 changes: 2 additions & 0 deletions docs/product-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ Each finding supports:
Available filters:

- all findings;
- scope drift only;
- commercial risks only;
- high risk;
- unreviewed only.

Expand Down
90 changes: 82 additions & 8 deletions src/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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 FindingCategory = 'scope_drift' | 'commercial_risk'
export type CommercialRiskKind = 'cancellation' | 'payment_risk' | 'availability_risk' | 'customer_delay'

export type SourceDocument = {
id: string
Expand All @@ -18,6 +20,8 @@ export type SourceDocument = {

export type Finding = {
id: string
category: FindingCategory
riskKind?: CommercialRiskKind
type: string
title: string
excerpt: string
Expand All @@ -38,6 +42,7 @@ export type AnalysisResult = {
messagesWithScopeBasis: number
scopeCoverage: number
hoursAtRisk: string
commercialRiskCount: number
unsupportedSources: string[]
}

Expand All @@ -63,9 +68,11 @@ type MessageRecord = {

type RuleConfig = {
id: string
category: FindingCategory
riskKind?: CommercialRiskKind
type: string
pattern: string
titleStyle: 'subject_not_in_scope' | 'changes_expectation' | 'extra_revision' | 'unpriced_commitment'
titleStyle: 'subject_not_in_scope' | 'changes_expectation' | 'extra_revision' | 'unpriced_commitment' | 'commercial_risk'
severity: Finding['severity']
confidence: number
minHours: number
Expand Down Expand Up @@ -167,9 +174,11 @@ export function analyzeSources(sources: SourceDocument[]): AnalysisResult {
const scopeItems = scopeSources.flatMap((source) => extractScopeItems(source.id, source.content))
const messages = messageSources.flatMap((source) => extractMessages(source))
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 messagesWithScopeBasis = messages.filter((message) => rules.some((rule) => rule.pattern.test(message.text) && findScopeBasis(rule, message.text, scopeItems).status !== 'none')).length
const scopeFindings = findings.filter((finding) => finding.category === 'scope_drift')
const totalMin = scopeFindings.reduce((sum, finding) => sum + parseHours(finding.hours)[0], 0)
const totalMax = scopeFindings.reduce((sum, finding) => sum + parseHours(finding.hours)[1], 0)
const scopeRules = rules.filter((rule) => rule.category === 'scope_drift')
const messagesWithScopeBasis = messages.filter((message) => scopeRules.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 {
Expand All @@ -178,7 +187,8 @@ export function analyzeSources(sources: SourceDocument[]): AnalysisResult {
messagesCompared: messages.length,
messagesWithScopeBasis,
scopeCoverage: coverage,
hoursAtRisk: findings.length ? `${totalMin}–${totalMax}h` : '0h',
hoursAtRisk: scopeFindings.length ? `${totalMin}–${totalMax}h` : '0h',
commercialRiskCount: findings.filter((finding) => finding.category === 'commercial_risk').length,
unsupportedSources: sources.filter((source) => source.kind === 'unknown').map((source) => source.name),
}
}
Expand Down Expand Up @@ -380,19 +390,25 @@ function normalizeMessage(source: SourceDocument, message: ConnectorMessage, ind
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 []
if (rule.category === 'scope_drift') {
if (rule.id === 'unpriced_commitment' && message.role === 'client') return []
if (isNegatedSignal(rule, message.text)) return []
} else if (isNegatedCommercialSignal(rule, message.text)) {
return []
}

if (rule.category === 'commercial_risk') return [createCommercialFinding(rule, message)]
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)}`)}`,
category: 'scope_drift',
type: rule.type,
title: titleFromRule(rule.titleStyle, message.text),
excerpt: `“${message.text}”`,
excerpt: findingExcerpt(rule, message.text),
source: message.author ? `${message.source} · ${message.author}` : message.source,
scope: scopeMatch.label,
scopeMatch: scopeMatch.status,
Expand All @@ -405,6 +421,61 @@ function createFindings(message: MessageRecord, scopeItems: ScopeItem[]): Findin
})
}

function createCommercialFinding(rule: Rule, message: MessageRecord): Finding {
const riskKind = rule.riskKind ?? 'customer_delay'
return {
id: `SG-${stableHash(`${message.id}|${rule.id}|${normalizeForMatching(message.text)}`)}`,
category: 'commercial_risk',
riskKind,
type: rule.type,
title: commercialRiskTitle(riskKind),
excerpt: findingExcerpt(rule, message.text),
source: message.author ? `${message.source} · ${message.author}` : message.source,
scope: 'Commercial signal · Not a scope clause',
scopeMatch: 'none',
hours: '—',
confidence: rule.confidence,
severity: rule.severity,
reviewed: false,
decision: 'pending',
}
}

function commercialRiskTitle(kind: CommercialRiskKind): string {
if (kind === 'cancellation') return 'Order appears cancelled or paused'
if (kind === 'payment_risk') return 'Payment status may block the order'
if (kind === 'availability_risk') return 'Product availability may block delivery'
return 'Customer may be delaying the order'
}

function findingExcerpt(rule: Rule, text: string): string {
const match = rule.pattern.exec(text)
const matchIndex = match?.index ?? 0
const radiusBefore = rule.category === 'commercial_risk' ? 60 : 120
const radiusAfter = rule.category === 'commercial_risk' ? 140 : 220
const start = Math.max(0, matchIndex - radiusBefore)
const end = Math.min(text.length, matchIndex + (match?.[0].length ?? 0) + radiusAfter)
const prefix = start > 0 ? '…' : ''
const suffix = end < text.length ? '…' : ''
const excerpt = sanitizeEvidence(text.slice(start, end))
return `“${prefix}${excerpt}${suffix}”`
}

function sanitizeEvidence(value: string): string {
return value
.replace(/https?:\/\/\S+/gi, '[link]')
.replace(/[\w.+-]+@[\w.-]+\.[a-z]{2,}/gi, '[email]')
.replace(/(?:\+?\d[\d\s().-]{7,}\d)/g, '[phone]')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 360)
}

function isNegatedCommercialSignal(rule: Rule, text: string): boolean {
if (rule.riskKind !== 'cancellation') return false
return /\b(?:not|never|no)\s+cancell?ed?\b/i.test(text)
}

function findScopeBasis(rule: Rule, text: string, scopeItems: ScopeItem[]): { status: ScopeMatch; label: string } {
const normalizedText = normalizeForMatching(text)
const textTokens = new Set(tokensFrom(normalizedText))
Expand Down Expand Up @@ -463,6 +534,7 @@ function titleFromRule(style: RuleConfig['titleStyle'], text: string): string {
if (style === 'subject_not_in_scope') return `${extractSubject(text)} is not in the agreed scope`
if (style === 'changes_expectation') return `${extractSubject(text)} changes the delivery expectation`
if (style === 'extra_revision') return 'An additional revision round was requested'
if (style === 'commercial_risk') return 'A commercial risk signal was detected'
return 'A delivery commitment appears without a matching price'
}

Expand All @@ -482,9 +554,11 @@ function loadRules(text: string): Rule[] {

function isRuleConfig(value: Record<string, unknown>): value is Record<string, unknown> & RuleConfig {
return typeof value.id === 'string'
&& ['scope_drift', 'commercial_risk'].includes(String(value.category))
&& typeof value.type === 'string'
&& typeof value.pattern === 'string'
&& typeof value.titleStyle === 'string'
&& (value.category !== 'commercial_risk' || ['cancellation', 'payment_risk', 'availability_risk', 'customer_delay'].includes(String(value.riskKind)))
&& ['high', 'medium', 'low'].includes(String(value.severity))
&& typeof value.confidence === 'number'
&& typeof value.minHours === 'number'
Expand Down
16 changes: 15 additions & 1 deletion src/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,21 @@ function selectEmailText(contentType: string | undefined, transferEncoding: stri

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)))
const bytes: number[] = []
const normalized = value.replace(/=\r?\n/g, '')
for (let index = 0; index < normalized.length; index += 1) {
if (normalized[index] === '=' && /^[0-9A-F]{2}$/i.test(normalized.slice(index + 1, index + 3))) {
bytes.push(Number.parseInt(normalized.slice(index + 1, index + 3), 16))
index += 2
} else {
bytes.push(normalized.charCodeAt(index))
}
}
try {
return new TextDecoder('utf-8').decode(Uint8Array.from(bytes))
} catch {
return String.fromCharCode(...bytes)
}
}
if (/base64/i.test(encoding ?? '')) {
try {
Expand Down
Loading
Loading