/** * cli:support-report — run.ts (the report writer, pure of argv concerns). * * Turns a VERIFIED CLI failure into a transmissible support bundle under * `/.smartstack/support//`: * * report.md — the French report addressed to support@atlashub.ch * report.json — the same record, machine-readable * repro.md — how support re-runs the command on the bundle * runs/run-N.* — the raw captured outputs (secrets scrubbed) * inputs/ + manifest — what the CLI READ (spec `inputs[]`, scrubbed) * support-.zip (+ .sha256) — the emailable archive of all of the above * * FAIL-CLOSED by construction: classification happens HERE (lib/support-report * — the evidence rule), not in the model's head. A usage-error, environment, * unverified, flaky or coherent no-failure verdict is refused with guidance — * nothing is written. Four things are reportable: * - `cli-internal` a proven crash inside the deployed skills tree; * - `rule-contradiction` the envelope contradicting itself — a `dedupOf` * mirror finding in err while its primary is ok on * the same scope (mechanical, read from stdout); * - `disputed-usage-error` an argued `dispute` against a controlled refusal; * - `disputed-verdict` an argued `dispute` (≥ 80 chars) against a * coherent verdict, anchored on `disputedRuleIds` * that MUST all be present in the envelope. * * Version doctrine (the "maybe it's already fixed" gate): the report always * carries installed-CLI / latest-npm / socle / node / OS. When npm serves a * NEWER version, the report is stamped `pending-retest-after-update` and the * nextSteps tell the model to propose the update to the user and retest — a * report is only transmissible once the defect reproduces on the latest CLI. * * Deduplication: one defect = one fingerprint = one folder. A re-invocation * with the same fingerprint bumps `occurrences`/`lastSeen`, merges the * contradiction scopes / disputed rules, refreshes the inputs snapshot and * re-renders — it never spawns a second report. */ import { readFileSync } from 'node:fs' import { mkdir, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { findSmartStackStructure } from '../../../lib/detector.js' import { FileSystemError, directoryExists, fileExists, readJson, writeJson, writeText } from '../../../lib/fs.js' import { executeEnvelope, failExecute, type ExecuteEnvelope } from '../../../lib/output.js' import { readSocleVersion } from '../../../lib/socle-version.js' import { buildZip, collectInputs, renderReproMd, rewriteCommandForBundle, sha256Of, type BundleSummary, type ZipEntry, } from '../../../lib/support-bundle.js' import { classifyFailure, contradictionExcerpt, fingerprintOf, isNewerVersion, mergeContradictions, parseEnvelope, renderReportMd, scrubSecrets, verdictDisputeSignature, SUPPORT_EMAIL, type CapturedRun, type EvidenceKind, type FailureClass, type FailureVerdict, type ReportClassification, type ReportVersions, type RuleContradiction, type SupportReportRecord, } from '../../../lib/support-report.js' export const COMMAND = 'support-report' export const CLI_PACKAGE = '@atlashub/smartstack-cli' /** A verdict dispute must ARGUE — quote the rule contract and the doc line. */ export const VERDICT_DISPUTE_MIN_CHARS = 80 // `/support-report/cli/create/` — same arithmetic in the source tree // and in ~/.claude/skills (top-level skill: the installer's BA flatten never // shifts its depth). const SKILLS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') const CLAUDE_DIR = resolve(SKILLS_ROOT, '..') export interface SupportReportSpec { /** The client project the failing command ran against. */ projectPath: string /** The failing command, verbatim. */ command: string /** Captured executions (1..10). The evidence rule decides if they suffice. */ runs: CapturedRun[] /** The JSON spec that was passed to the failing CLI (scrubbed before write). */ spec?: unknown /** What the run was doing (phase, entity, skill). */ context?: string /** Why the model believes this is CLI-internal. */ analysis?: string /** * Substantive justification — against a CONTROLLED refusal (usage-error), * or, together with `disputedRuleIds`, against a coherent VERDICT. */ dispute?: string /** The rules a verdict dispute contests — each must appear in the envelope's findings. */ disputedRuleIds?: string[] /** Paths the failing CLI READ, relative to projectPath — bundled, scrubbed, zipped. */ inputs?: string[] } /** Injectable seams — tests never touch the network or the real ~/.claude. */ export interface SupportDeps { fetchLatestVersion?: (pkg: string) => Promise claudeDir?: string skillsRoot?: string now?: () => Date /** Bundle total cap override (tests exercise the over-cap path without 25 MiB fixtures). */ bundleTotalCapBytes?: number } export interface SupportRunReport { failureClass: FailureClass | 'disputed-usage-error' | 'disputed-verdict' evidence: EvidenceKind status?: SupportReportRecord['status'] fingerprint?: string reportDir?: string occurrences?: number duplicate?: boolean versions?: ReportVersions contradictions?: RuleContradiction[] bundle?: { zipPath: string sha256: string files: number skipped: number totalBytes: number overCap: boolean } } /** dist-tags.latest from the public registry — offline/blocked → null. */ export async function fetchLatestFromNpm(pkg: string): Promise { const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), 8000) try { const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg)}`, { signal: ctrl.signal }) if (!res.ok) return null const data = (await res.json()) as { 'dist-tags'?: Record } const latest = data['dist-tags']?.latest return typeof latest === 'string' ? latest : null } catch { return null } finally { clearTimeout(timer) } } function installedCliVersion(claudeDir: string): string { try { const manifest = JSON.parse(readFileSync(join(claudeDir, '.smartstack-manifest.json'), 'utf-8')) as { version?: string } return manifest.version || 'unknown' } catch { return 'unknown' } } async function webPackageVersion(projectPath: string): Promise { try { const structure = await findSmartStackStructure(projectPath) if (!structure.web) return 'unknown' const pkg = await readJson<{ dependencies?: Record; devDependencies?: Record }>( join(structure.web, 'package.json'), ) const raw = pkg.dependencies?.['@atlashub/smartstack'] ?? pkg.devDependencies?.['@atlashub/smartstack'] return raw ? raw.replace(/^[\^~]/, '') : 'unknown' } catch { return 'unknown' } } const EXCERPT_MAX = 4000 function capExcerpt(text: string): string { const scrubbed = scrubSecrets(text.trim()) return scrubbed.length > EXCERPT_MAX ? `${scrubbed.slice(0, EXCERPT_MAX)}\n… (tronqué — voir runs/)` : scrubbed } function excerptOf(run: CapturedRun | undefined): string { if (!run) return '' return capExcerpt(run.stderr.trim() ? run.stderr : run.stdout) } /** For a contradiction the excerpt is the involved findings, not the stdout head. */ function excerptFor(verdict: FailureVerdict, runs: CapturedRun[]): string { if (verdict.failureClass !== 'rule-contradiction') return excerptOf(runs[0]) const findings = runs.flatMap((r) => parseEnvelope(r.stdout)?.findings ?? []) return capExcerpt(contradictionExcerpt(findings, verdict.contradictions)) } type VerdictDispute = { ok: true; ruleIds: string[] } | { ok: false; errors: string[] } | null /** * The argued escape hatch on a COHERENT verdict. Attempted only on * `no-failure` when the caller supplied a dispute; refused with explicit * reasons unless it argues (≥ 80 chars) AND names rules the envelope actually * carries. Never widens the crash path: environment/unverified/flaky stay * refused whatever the dispute says. */ function resolveVerdictDispute(verdict: FailureVerdict, spec: SupportReportSpec): VerdictDispute { if (verdict.failureClass !== 'no-failure') return null if (spec.dispute === undefined && spec.disputedRuleIds === undefined) return null const errors: string[] = [] const dispute = spec.dispute ?? '' if (dispute.length < VERDICT_DISPUTE_MIN_CHARS) { errors.push( `verdict dispute rejected: "dispute" must argue in ≥ ${VERDICT_DISPUTE_MIN_CHARS} chars (quote the rule contract and the doc line it misreads) — got ${dispute.length}.`, ) } const ids = [...new Set(spec.disputedRuleIds ?? [])].sort() if (ids.length === 0) errors.push('verdict dispute rejected: "disputedRuleIds" must name ≥ 1 rule present in the envelope findings.') const seen = new Set(verdict.envelopeRuleIds) for (const id of ids) { if (!seen.has(id)) { errors.push(`verdict dispute rejected: ${id} is not in the envelope findings (seen: ${verdict.envelopeRuleIds.slice(0, 20).join(', ') || 'none'}).`) } } return errors.length > 0 ? { ok: false, errors } : { ok: true, ruleIds: ids } } interface SupportIndex { [fingerprint: string]: { firstSeen: string lastSeen: string occurrences: number reportDir: string status: SupportReportRecord['status'] command: string } } export async function run(spec: SupportReportSpec, deps: SupportDeps = {}): Promise> { const skillsRoot = deps.skillsRoot ?? SKILLS_ROOT const claudeDir = deps.claudeDir ?? CLAUDE_DIR const nowDate = (deps.now ?? (() => new Date()))() const now = nowDate.toISOString() if (!(await directoryExists(spec.projectPath))) { return failExecute(COMMAND, [`projectPath does not exist: ${spec.projectPath}`]) } // ---- 1. Classify — the evidence rule decides, never the caller. ---------- const verdict = classifyFailure(spec.runs, skillsRoot, spec.command) const disputedUsage = verdict.failureClass === 'usage-error' && Boolean(spec.dispute) const verdictDispute = resolveVerdictDispute(verdict, spec) const reportable = verdict.failureClass === 'cli-internal' || verdict.failureClass === 'rule-contradiction' || disputedUsage || verdictDispute?.ok === true if (!reportable) { const errors = [ `Not reportable: classified as \`${verdict.failureClass}\` (evidence: ${verdict.evidence}). No report was written.`, ...verdict.envelopeErrors.map((e) => `envelope error: ${e}`), ...(verdictDispute !== null && !verdictDispute.ok ? verdictDispute.errors : []), ] const refusal = failExecute(COMMAND, errors) refusal.report = { failureClass: verdict.failureClass, evidence: verdict.evidence } refusal.nextSteps = verdict.guidance return refusal } const classification: ReportClassification = disputedUsage ? 'disputed-usage-error' : verdictDispute?.ok ? 'disputed-verdict' : (verdict.failureClass as 'cli-internal' | 'rule-contradiction') const evidence: EvidenceKind = disputedUsage ? 'controlled-envelope' : verdictDispute?.ok ? 'argued-verdict-dispute' : verdict.evidence const fingerprint = verdict.fingerprint ?? fingerprintOf( spec.command, verdictDispute?.ok ? verdictDisputeSignature(verdictDispute.ruleIds) : (verdict.signatures[0] ?? '(no signature)'), skillsRoot, ) // ---- 2. Versions + the "maybe it's already fixed" gate. ------------------ const fetchLatest = deps.fetchLatestVersion ?? fetchLatestFromNpm const cliInstalled = installedCliVersion(claudeDir) const cliLatest = (await fetchLatest(CLI_PACKAGE)) ?? 'unreachable' const versions: ReportVersions = { cliInstalled, cliLatest, socle: readSocleVersion(spec.projectPath) ?? 'unknown', web: await webPackageVersion(spec.projectPath), node: process.version, os: `${process.platform} ${os.release()}`, } const updateAvailable = isNewerVersion(cliLatest, cliInstalled) const status: SupportReportRecord['status'] = updateAvailable ? 'pending-retest-after-update' : 'confirmed' // ---- 3. Inputs — collected BEFORE anything is written (a traversal refuses whole). -- const warnings: string[] = [] let collected: Awaited> | null = null if (spec.inputs !== undefined && spec.inputs.length > 0) { try { collected = await collectInputs({ projectPath: spec.projectPath, inputs: spec.inputs, ...(deps.bundleTotalCapBytes !== undefined ? { totalCapBytes: deps.bundleTotalCapBytes } : {}), }) } catch (e) { if (e instanceof FileSystemError) { return failExecute(COMMAND, [`inputs rejected: ${e.message} — every input must stay inside projectPath. Nothing was written.`]) } throw e } } const inputsSummary: BundleSummary | undefined = collected ? collected.summary.overCap ? { ...collected.summary, files: [], totalBytes: 0, skipped: [...collected.summary.skipped, ...collected.summary.files.map((f) => ({ relPath: f.relPath, reason: 'over-cap' as const }))], } : collected.summary : undefined const attach = collected !== null && !collected.summary.overCap if (collected?.summary.overCap) { warnings.push( `inputs exceed the ${collected.summary.cap} bytes bundle cap — NOTHING was attached; narrow inputs[] to what the CLI actually read (e.g. .smartstack/ba//).`, ) } // ---- 4. Deduplicate, then write the bundle. ------------------------------ const supportDir = join(spec.projectPath, '.smartstack', 'support') const indexPath = join(supportDir, 'index.json') const index: SupportIndex = (await fileExists(indexPath)) ? await readJson(indexPath) : {} const existing = index[fingerprint] const reportDir = existing ? existing.reportDir : join(supportDir, fingerprint) const reportJsonPath = join(reportDir, 'report.json') const disputedRuleIds = verdictDispute?.ok ? verdictDispute.ruleIds : undefined let record: SupportReportRecord if (existing && (await fileExists(reportJsonPath))) { record = await readJson(reportJsonPath) record.occurrences += 1 record.lastSeen = now record.status = status record.versions = versions if (verdict.contradictions.length > 0 || record.contradictions) { record.contradictions = mergeContradictions(record.contradictions ?? [], verdict.contradictions) } if (disputedRuleIds) record.disputedRuleIds = [...new Set([...(record.disputedRuleIds ?? []), ...disputedRuleIds])].sort() } else { record = { fingerprint, status, classification, evidence, command: scrubSecrets(spec.command), context: spec.context, analysis: spec.analysis, dispute: spec.dispute, disputedRuleIds, contradictions: verdict.contradictions.length > 0 ? verdict.contradictions : undefined, versions, runCount: spec.runs.length, occurrences: 1, firstSeen: now, lastSeen: now, spec: spec.spec === undefined ? undefined : scrubSecrets(typeof spec.spec === 'string' ? spec.spec : JSON.stringify(spec.spec, null, 2)), envelopeErrors: disputedUsage ? verdict.envelopeErrors : undefined, } } // The inputs snapshot is always the FRESH one — a stale copy would misdescribe the re-run. record.inputs = inputsSummary const zipFile = `support-${fingerprint}.zip` // No `bytes` yet: the copies rendered INTO the archive cannot know its size. record.zip = { file: zipFile } const inputsDir = join(reportDir, 'inputs') await rm(inputsDir, { recursive: true, force: true }) await mkdir(reportDir, { recursive: true }) const entries: ZipEntry[] = [] const archiveRoot = `support-${fingerprint}` const rewritten = rewriteCommandForBundle(spec.command, spec.projectPath) const repro = renderReproMd({ fingerprint, classification, command: record.command, rewrittenCommand: scrubSecrets(rewritten.command), rewritten: rewritten.rewritten, contradictions: record.contradictions, disputedRuleIds: record.disputedRuleIds, summary: inputsSummary ?? null, versions, }) if (attach && collected) { for (const f of collected.summary.files) { const content = collected.contents.get(f.relPath) ?? '' const target = join(inputsDir, ...f.relPath.split('/')) await mkdir(dirname(target), { recursive: true }) await writeFile(target, content, 'utf-8') entries.push({ path: `${archiveRoot}/inputs/${f.relPath}`, content }) } const manifest = JSON.stringify(inputsSummary, null, 2) await writeText(join(reportDir, 'inputs.manifest.json'), manifest) entries.push({ path: `${archiveRoot}/inputs.manifest.json`, content: manifest }) } const excerpt = excerptFor(verdict, spec.runs) for (const [i, r] of spec.runs.entries()) { const out = scrubSecrets(r.stdout) const err = scrubSecrets(r.stderr) await writeText(join(reportDir, 'runs', `run-${i + 1}.stdout.txt`), out) await writeText(join(reportDir, 'runs', `run-${i + 1}.stderr.txt`), err) entries.push({ path: `${archiveRoot}/runs/run-${i + 1}.stdout.txt`, content: out }) entries.push({ path: `${archiveRoot}/runs/run-${i + 1}.stderr.txt`, content: err }) } await writeText(join(reportDir, 'repro.md'), repro) entries.push({ path: `${archiveRoot}/repro.md`, content: repro }) // report.json / report.md are INSIDE the archive — render them before zipping, // so the zip's own size/hash live only beside it (.sha256 sidecar + envelope). const recordJson = JSON.stringify(record, null, 2) const reportMd = renderReportMd(record, excerpt) await writeJson(reportJsonPath, record) await writeText(join(reportDir, 'report.md'), reportMd) entries.push({ path: `${archiveRoot}/report.json`, content: recordJson }) entries.push({ path: `${archiveRoot}/report.md`, content: reportMd }) const zipBuffer = await buildZip(entries, { date: new Date(record.firstSeen) }) const zipPath = join(reportDir, zipFile) const sha256 = sha256Of(zipBuffer) await writeFile(zipPath, zipBuffer) await writeText(`${zipPath}.sha256`, `${sha256} ${zipFile}\n`) // The record on disk names the archive with its real size (the copies inside // the zip omit it — the archive cannot describe itself). record.zip = { file: zipFile, bytes: zipBuffer.length } await writeJson(reportJsonPath, record) await writeText(join(reportDir, 'report.md'), renderReportMd(record, excerpt)) index[fingerprint] = { firstSeen: record.firstSeen, lastSeen: record.lastSeen, occurrences: record.occurrences, reportDir, status, command: record.command, } await writeJson(indexPath, index) // ---- 5. Envelope — the model's marching orders. -------------------------- const nextSteps = updateAvailable ? [ `A NEWER CLI version exists (${cliLatest}, installed: ${cliInstalled}) — the defect may already be fixed.`, `PROPOSE the update to the user and WAIT for their approval: \`npm i -g ${CLI_PACKAGE}@latest\` then \`ss update\`.`, 'After updating, re-run the failing command. Fixed → tell the user, transmit nothing. Still failing → re-invoke this CLI with the fresh captures to confirm the report.', 'Do NOT transmit the report before that retest.', ] : [ `Tell the user the report is ready: send \`${zipPath}\` (report.md + report.json + repro.md + runs/${attach ? ' + inputs/' : ''}) to ${SUPPORT_EMAIL}.`, ...(classification === 'rule-contradiction' ? [ 'The audit err is NOT the client\'s to fix: two rules of the CLI disagree on a valid document. Record the blocker as `audit.rule-contradiction`, never edit the BA doc to silence the mirror, and continue the run.', ] : ['Skip the failing artifact and continue the run (`cli.runtime-error` doctrine) — never hand-write a replacement, never edit anything under ~/.claude.']), ...(collected?.summary.overCap ? ['Inputs were NOT attached (over the bundle cap): re-invoke with a narrower inputs[] so support can reproduce.'] : []), ] if (cliLatest === 'unreachable') warnings.push('npm registry unreachable — could not check for a newer CLI version.') return executeEnvelope(COMMAND, { data: { status, fingerprint, occurrences: record.occurrences, duplicate: Boolean(existing), updateAvailable, inputsBundled: attach ? (collected?.summary.files.length ?? 0) : 0, }, report: { failureClass: classification, evidence: record.evidence, status, fingerprint, reportDir, occurrences: record.occurrences, duplicate: Boolean(existing), versions, ...(record.contradictions ? { contradictions: record.contradictions } : {}), bundle: { zipPath, sha256, files: attach ? (collected?.summary.files.length ?? 0) : 0, skipped: inputsSummary?.skipped.length ?? 0, totalBytes: inputsSummary?.totalBytes ?? 0, overCap: collected?.summary.overCap ?? false, }, }, warnings, nextSteps, }) }