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
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]
### Added
- `gunk.app` "How this works" on-demand analysis (T-10.14): a single quiet
disclosure on the module page opens an AI-written walkthrough of the module's
design — a plain-language summary, the data flow (input → transform →
output), the key functions, what it touches, and its honest limits. The long
form of the T-10.8 input signature. **Decision (recorded in the schema v7
comment): generated app-side and cached on first request**, not at engine
extraction — the engine extractor makes no LLM call and the manual-approve
path is pure Swift with no engine, so an engine-only cache would leave every
manually-approved and older module permanently unanalyzed; generating in the
app (where the user's provider/model/key already live) is one mechanism that
covers every module. The analysis is cached in a new **app-only, additive
schema v7** (`module_analyses`, keyed per module, upsert-in-place); opening
reads the cache and is **instant** — a live model call never happens at view
time. Unanalyzed/older modules show a quiet "Not analyzed yet" with a single
on-demand "Analyze this module" action (the refining-loop rule — a model is
never auto-summoned on page open). Mono is used only for the code references
inside the analysis. A `GUNK_DEBUG_HOW_IT_WORKS=closed|open|missing`
screenshot hook stages the states. New `ModuleAnalysisComposer`,
`LiveModuleAnalysisGenerator`, store methods, and tests across `StoreTests`,
`BrowseModelTests`, and `ModuleAnalysisComposerTests`. v7 is app-only with
**no** `mcp/src/schema/v7.sql` — the gunk-mcp and TS-engine migrators pin
`LATEST_VERSION = 4` and early-return, and every read uses explicit column
lists, so v7 is invisible to them (parity check unaffected). Build + tests
green (257 passing, 1 sandbox-availability skip).
- `gunk.app` UI-module detection (T-10.13, deferred scope): the runnability
classifier now flags a module as a `ui-module` not-runnable-here class from
**entrypoint shape** (a safe entrypoint ending in
Expand All @@ -27,6 +51,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
tests green (243 passing, 1 sandbox-availability skip).

### Removed
- `gunk.app` phase-10 close-out (T-10.15): deleted the orphaned legacy
`RunConsoleView` — superseded by `RunConsoleStageView` (the module-run-v2
presentation) when the run console v2 landed in T-10.9, yet never
instantiated anywhere (`rg` confirmed zero references, including the
screenshot hooks, which live on the still-used `RunConsoleModel`). T-10.13
had even updated its deferred-label copy — editing dead code, the same "kept
for reuse" trap Phase 9 flagged. Its file was renamed to
`RunConsoleModel.swift` to match its sole surviving content;
`RunConsoleModel` and the `GUNK_DEBUG_RUN_CONSOLE` staging are intact. The
inline `ModuleDetailView` was already gone (T-10.4 moved its capabilities
onto the page; only docstrings still name it). ADR-0017 (MCP run tool) moved
to **Accepted** (implemented in T-10.12) and both Phase 10 ADRs (0016/0017)
are now linked from the roadmap; Phase 10 roadmap items checked off and
`docs/retros/phase-10.md` written. Regression pass at 960×600 and default
width across the module page and every run state confirmed the toolbox-v2
constraints (graphite surfaces, mono only for paths/code/terminal, accent
green only on earned meaning, glass on the controls layer) and the
two-surfaces rule (smoke run ≠ extraction inspector) hold. Schema-parity
check still passes (v0–v4 only; v5/v6/v7 are app-only). Build + tests green
(257 passing, 1 sandbox-availability skip).
- `gunk.app` phase-9 close-out (T-9.7): removed the orphaned `ProviderBadge`
component. T-9.2 (#165) reworked it into a brand "token" and kept it "for
reuse", but the card switched to `ProviderWatermark` and the list row to
Expand Down
80 changes: 79 additions & 1 deletion app/Sources/GunkApp/Models/BrowseModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,11 @@ final class BrowseModel {
typealias ExtractGunk = @MainActor (Gunk) throws -> Void
typealias ReclassifySource = @MainActor (Int64) throws -> Void
typealias LoadRunTraces = @MainActor () -> [RunTrace]
/// The "How this works" generation seam (T-10.14). Injected so tests drive it
/// with a canned analysis; the production default resolves the user's
/// provider/model/key and calls the model. Only ever invoked by an explicit
/// developer action — never on page open.
typealias GenerateAnalysis = @MainActor (ModuleAnalysisInput) async throws -> GeneratedAnalysis

private let store: Store
/// The auto-accept gate the approval queue is computed from
Expand All @@ -352,6 +357,7 @@ final class BrowseModel {
/// so the smoke-run orchestration is testable with a canned executor; the
/// production default wraps runs in `sandbox-exec` (ADR-0016).
private let smokeRunner: SmokeRunner
private let generateAnalysisClosure: GenerateAnalysis

private(set) var sections: [BrowseSection] = []
private(set) var approvalQueue: [BrowseItem] = []
Expand All @@ -377,6 +383,13 @@ final class BrowseModel {
private var traceModuleRecords: [Int64: BrowseTraceModuleRecord] = [:]
private var selfContainmentByGunkId: [Int64: BrowseSelfContainmentResult] = [:]
private var buildVerificationByBundlePath: [String: BrowseBuildVerificationResult] = [:]
/// Cached "How this works" analyses (T-10.14), loaded from the store once per
/// refresh so `analysis(for:)` is a pure lookup — the disclosure opens
/// instantly and never triggers a model call.
private var analysisByGunkId: [Int64: ModuleAnalysis] = [:]
/// Modules whose analysis is generating right now, so the disclosure can show
/// a quiet "Analyzing…" state without a second concurrent request.
private(set) var analyzingGunkIds: Set<Int64> = []
/// Trace-derived provenance, used only as the fallback when a module has no
/// durable stored value (T-9.2). Shared resolution with `ProvenanceBackfill`.
private var traceProvenance = RunTraceProvenanceIndex(traces: [])
Expand All @@ -389,7 +402,8 @@ final class BrowseModel {
loadRunTraces: @escaping LoadRunTraces = {
RunTraceStore().recentTraces(limit: 250)
},
smokeRunner: SmokeRunner = SmokeRunner()
smokeRunner: SmokeRunner = SmokeRunner(),
generateAnalysis: GenerateAnalysis? = nil
) {
self.store = store
self.confidenceThreshold = confidenceThreshold
Expand All @@ -402,12 +416,16 @@ final class BrowseModel {
self.reclassifySource = reclassifySource
self.loadRunTraces = loadRunTraces
self.smokeRunner = smokeRunner
self.generateAnalysisClosure = generateAnalysis ?? { input in
try await LiveModuleAnalysisGenerator.generate(input: input)
}
}

func refresh() {
do {
let items = try loadItems()
self.items = items
analysisByGunkId = try store.listModuleAnalyses()
indexTraces(loadRunTraces(), items: items)
approvalQueue = items
.filter(isPendingApproval)
Expand Down Expand Up @@ -568,6 +586,66 @@ final class BrowseModel {
)
}

// MARK: - "How this works" analysis (T-10.14)

/// The cached "How this works" analysis for a module, or `nil` when none has
/// been generated yet. A pure lookup over the cache loaded at refresh — the
/// disclosure opens instantly and never blocks on a live model call.
func analysis(for gunkId: Int64) -> ModuleAnalysis? {
analysisByGunkId[gunkId]
}

/// Whether a module's analysis is being generated right now (drives the quiet
/// "Analyzing…" state).
func isAnalyzing(_ gunkId: Int64) -> Bool {
analyzingGunkIds.contains(gunkId)
}

/// Assembles the generation input from what the detail already carries — the
/// long-form sibling of `inputSignature(for:)`, reading the same module
/// signals (no new store state).
func analysisInput(for detail: BrowseModuleDetail) -> ModuleAnalysisInput {
ModuleAnalysisInput(
name: detail.item.gunk.name,
purpose: detail.item.gunk.purpose,
language: detail.item.gunk.language,
entrypoints: detail.entrypoints,
requirements: detail.requirements,
ownedFiles: detail.ownedFiles,
callItSnippets: callItSnippets(for: detail)
)
}

/// Generate (or regenerate) a module's analysis on **explicit** demand, then
/// cache it (schema v7) and update the in-memory cache so the next open is
/// instant. Only ever called from a developer action — never on page open.
/// Returns `nil` and surfaces an error on failure, leaving any prior cache
/// untouched.
@discardableResult
func generateAnalysis(for detail: BrowseModuleDetail) async -> ModuleAnalysis? {
let gunkId = detail.item.gunk.id
guard !analyzingGunkIds.contains(gunkId) else {
return analysisByGunkId[gunkId]
}

analyzingGunkIds.insert(gunkId)
defer { analyzingGunkIds.remove(gunkId) }

do {
let generated = try await generateAnalysisClosure(analysisInput(for: detail))
let stored = try store.upsertModuleAnalysis(
gunkId: gunkId,
content: generated.content,
model: generated.model
)
analysisByGunkId[gunkId] = stored
return stored
} catch {
errorMessage = error.localizedDescription
return nil
}
}

/// The runnability classification (T-10.2) for a module, computed up front so
/// the page offers a Run button only for `.terminalRunnable` modules and
/// renders an honest, neutral "runnable here: not yet" state for everything
Expand Down
97 changes: 97 additions & 0 deletions app/Sources/GunkApp/Models/ModuleAnalysis.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Foundation

/// The "How this works" analysis (T-10.14): the **long form** of the T-10.8
/// input signature. Where the input signature answers "what do I feed it",
/// this answers "how is it built" — data flow in → transform → out, the key
/// functions, what it touches, and its honest limits.
///
/// Pure and derivation-free at the value level: the text is generated once by a
/// model (`ModuleAnalysisComposer` builds the prompt + schema) and then cached
/// in the store (schema v7). Opening the disclosure reads the cache, so it is
/// instant and never blocks on a live model call at view time.

/// The generated content alone — the part a model writes and we serialize into
/// the store's `content` column. Kept separate from the metadata
/// (`model`/`generatedAt`) so the composer's output type carries no clock or
/// provider concerns.
struct ModuleAnalysisContent: Equatable, Sendable, Codable {
/// A one- or two-sentence plain-language summary of what the module does.
let summary: String
/// The data flow as ordered steps: input → transform → output. Each step is
/// a short prose line (the view numbers them).
let dataFlow: [String]
/// The key functions/symbols a reader should know, each a code reference
/// (mono) paired with a plain-prose role.
let keyFunctions: [AnalysisFunction]
/// What the module touches — files, packages, env, network, the filesystem —
/// stated as facts, not warnings.
let touches: [String]
/// Honest limits / caveats: what it does *not* handle, assumptions it makes.
let limits: [String]

/// Whether there is anything worth showing. A model that returns nothing
/// useful should be treated as "not analyzed" rather than rendering an empty
/// shell.
var isEmpty: Bool {
summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& dataFlow.isEmpty
&& keyFunctions.isEmpty
&& touches.isEmpty
&& limits.isEmpty
}
}

/// One key function in the analysis: a code reference (`name`, rendered mono)
/// and the plain-language `role` it plays.
struct AnalysisFunction: Equatable, Sendable, Codable {
/// The symbol/function name — a code reference, so the view renders it mono.
let name: String
/// What the function does, in plain prose.
let role: String
}

/// A cached analysis as the store returns it: the generated `content` plus the
/// metadata for the honesty footer ("Analyzed with <model>").
struct ModuleAnalysis: Equatable, Sendable {
let content: ModuleAnalysisContent
/// The model that wrote it (`nil` for a debug/canned analysis).
let model: String?
/// When it was generated (epoch ms), matching the store's clock.
let generatedAt: Int64
}

extension ModuleAnalysis {
/// A canned analysis used only by the `GUNK_DEBUG_HOW_IT_WORKS=open`
/// screenshot hook, so the open state can be captured without a real model
/// call or a seeded store row.
static let sample = ModuleAnalysis(
content: ModuleAnalysisContent(
summary: "Converts an EPUB e-book into clean, chapter-split Markdown — "
+ "unzipping the container, walking the spine in reading order, and "
+ "flattening each XHTML document to text.",
dataFlow: [
"Reads an .epub path from the first positional argument.",
"Unzips the container and parses content.opf for the spine order.",
"Converts each spine document from XHTML to Markdown.",
"Writes one Markdown file per chapter to the output directory.",
],
keyFunctions: [
AnalysisFunction(name: "convert(path:)", role: "The entrypoint — orchestrates the whole pipeline."),
AnalysisFunction(name: "readSpine(_:)", role: "Parses the OPF manifest into an ordered chapter list."),
AnalysisFunction(name: "xhtmlToMarkdown(_:)", role: "Flattens one XHTML document to Markdown."),
],
touches: [
"Reads the input .epub file and the output directory.",
"Depends on `ebooklib` and `beautifulsoup4`.",
"No network access.",
],
limits: [
"Assumes a well-formed EPUB 2/3 spine; malformed archives are skipped, not repaired.",
"Drops embedded fonts and most CSS — text and structure only.",
"Images are referenced by path, not inlined.",
]
),
model: "claude-sonnet-4",
generatedAt: 0
)
}
Loading
Loading