finfocus
CLAUDE.md11 starsChanged 13 days agoApache-2.0View on GitHub
Guidance for Claude Code in this repository. It lists commands and the non-obvious rules that have caused real bugs. Feature behavior lives in specs/NNN-*/ and the code; this file points there instead of restating it. - Do not run git commit unless the user asks for a commit, directly or by invoking a workflow whose purpose is a commit (for example /pick-issue). - Always run make lint and make test before claiming success. - Do not modify .golangci.yml without explicit…
- Reads credentials
We split this CLAUDE.md into the rules it gives an agent
20 rules. About 0 are common, 0 are distinctive, and 20 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
20
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- could be automatic: a setting could enforce it
- Very specific: line 11
Always run
make lintandmake test: before claiming success. - Very specific: line 37
Every spec carries a Web UI Fit section.
- Very specific: line 50
Stop the server with an anchored pkill -f '^<path>/finfocus --web'.
- Very specific: line 79
Do not point GOLANGCI_LINT at $(HOME)/go/bin/golangci-lint: that copy can lag the pin and reject the current config.
- Very specific: line 83
After a rebase, run go vet ./...
- Very specific: line 140
Never write if got != want { t.Errorf(...) } or raw t.Fatalf.
- Very specific: line 151
Call t.Parallel() at the top level and in each t.Run, except:
- Very specific: line 165
Do not share a mutable fixture across subtests.
- Very specific: line 248
Test fixtures must carry the type only under newState, as Pulumi emits it.
- Very specific: line 304
Keep identifier handling and field_allowlist intact.
- Very specific: line 314
Agent skills: all finfocus skills live in agent-skills/ here, including cost-check and cost-drift.
- Very specific: line 339
Always cmd.Wait() after cmd.Process.Kill().
- Very specific: line 363
After GetPluginInfo (5s, request *proto.Empty), NewClient restarts the process once when advertised settings change the injected pairs.
- Very specific: line 455
Do not put --plain (or other accessibility flags) on the cost parent: cost history view has its own --plain.
- Very specific: line 527
Do not add a second credential-name list or a payload-specific redactor case.
- Very specific: line 580
After a preview adds rows, appendPreviewRows applies the dismissal delta to them, and OverviewPipeline.RefreshFallbackBudget re-evaluates a …
- Very specific: line 592
Only the global loader rewrites a legacy file to config.hujson.
- Very specific: line 606
Only a confirmed checksum mismatch is fatal.
- Very specific: line 662
Start over pulumi:created.
- Very specific: line 847
Run-rate prices with GetProjectedCostWithErrors and Monthly.
≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
Guidance for Claude Code in this repository. It lists commands and the
non-obvious rules that have caused real bugs. Feature behavior lives in
specs/NNN-*/ and the code; this file points there instead of restating it.
Critical Instructions
- Do not run
git commitunless the user asks for a commit, directly or by invoking a workflow whose purpose is a commit (for example/pick-issue). - Always run
make lintandmake testbefore claiming success. - Do not modify
.golangci.ymlwithout explicit approval. - The constitution (
.specify/memory/constitution.md) wins over any runtime mode instruction (learning, explanatory). Principle VI forbids TODOs and stubs. Record a conflict with/speckit.revisit.
Project Overview
FinFocus Core is a CLI and plugin host that calculates cloud costs from Pulumi
(and Terraform state) definitions: projected estimates, actual history, and
Kubernetes allocation, all through gRPC plugins. Sibling repos:
finfocus-spec (protos, pluginsdk) and the finfocus-plugin-* plugins
(aws-public, azure-public, ...). Cross-repo changes follow the constitution's
protocol. Plugin domain knowledge stays in the plugin; core and spec get
generic channels only.
Specs
Specs, plans, and tasks live in specs/NNN-*/, produced by Spec Kit
(.specify/ and the /speckit-* skills). It is the only pipeline: output
from other planning tools must become a Spec Kit feature folder before
implementation.
Web parity (constitution Principle VII)
A user-facing CLI feature is not complete until finfocus --web reaches it.
Every spec carries a Web UI Fit section. A feature with no web surface
records Not applicable and a reason there and is exempt from the rest.
Otherwise the internal/webui hook calls the same Go method as the CLI and
the contract names it, and completion files one UX-only web-ui issue.
Engine, API, or data-shape work never goes in that issue. Layout:
docs/src/content/docs/architecture/web-ui.md.
- To check a fit, run the server from a populated project such as
../finfocus-demoand render it with playwright-mcp. It uses an empty passphrase:PULUMI_CONFIG_PASSPHRASE='' finfocus --web --no-browser --port <port>. The MCP can write screenshots only under the repo (.playwright-mcp/), not the scratchpad. Stop the server with an anchoredpkill -f '^<path>/finfocus --web'.
Commands
make tools # Install the toolchain pinned in mise.toml
make build # bin/finfocus
make test # Unit tests: internal/, pkg/, and the plugin modules
make test-race
make test-integration # Slower; see Integration Tests below
make test-e2e # Needs AWS credentials and Pulumi
make test-e2e-kind # kind cluster: run-rate, then historical (Prometheus)
make lint # golangci-lint, markdownlint, actionlint
make validate # go mod tidy -diff, go vet
make docs-lint
make docs-serve # Astro site at http://localhost:4321/finfocus/
make check-plugin-boundaries
make test-kubernetes # Also test-scorer, test-prometheus (nested plugin modules)
make install-kubernetes # Also install-scorer, install-prometheus, install-recordergo test -run TestName ./internal/cli/
go test -race -shuffle=on -count=3 ./internal/<pkg>/... # Parallel-safety check
./bin/finfocus plugin list --output jsonmake lintchecks golangci-lint's version againstmise.tomland runsmise which golangci-lint. Install it withmise install(make tools). Do not pointGOLANGCI_LINTat$(HOME)/go/bin/golangci-lint: that copy can lag the pin and reject the current config.pkill golangci-lint || trueclears a parallel-runner conflict. Lint can run longer than five minutes.- After a rebase, run
go vet ./...before trustingmake test. A widened interface (for exampleengine.ResourcePricer) breaks test doubles that another merged PR added, with no git conflict.
Architecture
| Package | Role |
internal/cli/ | Cobra commands, routed through ax.Execute (ax-go) |
internal/engine/ | Cost orchestration, cluster allocation, rendering |
internal/pluginhost/ | gRPC plugin launch, connect, cleanup |
internal/registry/ | Discovery in <home>/plugins/<name>/<version>/, install |
internal/ingest/ | Pulumi plan and state parsing |
internal/stackload/ | Shared Pulumi state and preview snapshots, inventory, and safe input status |
internal/proto/ | Adapter: descriptors, pre-flight validation |
internal/router/ | Plugin routing with priority and fallback |
internal/analyzer/ | Pulumi Analyzer gRPC server |
internal/tui/ | Bubble Tea v2 / Lipgloss v2 (charm.land imports) |
internal/config/ | Two-tier config |
plugins/{kubernetes,scorer,prometheus}/ | Nested modules; never import core |
Entry point cmd/finfocus/main.go. Exit codes: 0, 1 (ax ExitInternal), and
the budget code from cost --exit-code (default 1), preserved through
ax.Execute by cli.toAxExitError. User-input errors (flag combinations,
malformed input files, bad dates) exit 2 through toValidationError
(ax.ExitValidation). Codes 3 and 4 (ExitNetwork, ExitAuth) are not used yet.
Configuration Resolution
- Project settings (config, dismissals):
--project-dir, thenFINFOCUS_PROJECT_DIR, then the nearestPulumi.yaml($PROJECT/.finfocus/), then~/.finfocus/. - Global resources (plugins, cache, logs):
FINFOCUS_HOME, thenPULUMI_HOME/finfocus, then~/.finfocus/. With none of those, it is./.finfocus(config.UsesWorkingDirFallback). - Project
config.hujsonoverrides global at the top-level key (shallow merge). The global loader migrates a legacyconfig.yamlto Hujson on first read; a legacy projectconfig.yamlis read as an overlay and left as it is.
Key Patterns
RunE, notRun.cmd.Printf, notfmt.Printf. Defer cleanup right after acquiring a resource. Dates accept2006-01-02and RFC3339.- Pre-flight validation (
internal/proto/,pluginsdkvalidators) returns a$0placeholder with aVALIDATION:note (plugin errors useERROR:) and still calls the plugin for valid resources. A resource withref.*tags and no SKU usesValidateProjectedCostRequestLenient. - Logging:
--debug>FINFOCUS_LOG_LEVEL/FINFOCUS_LOG_FORMAT> config > info/console.FINFOCUS_TRACE_IDinjects a trace id. ax.Executemounts--format json|human,--dry-run,--yes, and--idempotency-keyon every command, plus__schemaandmcp-server(--mcpis the stdio alias). A command must not redeclare--format; Cobra rejects it.
Testing
- Testify only (
requireto stop,assertto continue); seeAGENTS.md. Never writeif got != want { t.Errorf(...) }or rawt.Fatalf. - TUI changes: render the view and read it, then run
go test -run TestGolden ./internal/tui/...(UPDATE_GOLDEN=1to regenerate).assert.Containsalone is not enough. - Expected plugin failures: log expected errors with
t.Logf; fail only when the required error is absent.connection refused,context deadline exceeded, orEOFin logs of a passing test are normal. - Coverage targets (80%, 95% on critical paths) are in the constitution.
Parallel Tests
Call t.Parallel() at the top level and in each t.Run, except:
- Tests that touch process-wide state:
t.Setenv,t.Chdir,os.Setenv, package-level variables,config.ResetGlobalConfigForTest/SetGlobalConfig, or anything that builds a root command (NewRootCmd*sets the resolved project dir), including helpers such asstubHomeandWithEnv. - A test that writes a binary or script and then executes it. A concurrent
fork inherits the write fd and the exec fails with
text file busy(golang/go#22315). - Mark them
//nolint:paralleltest // <reason>abovefunc(or on thet.Run/forline for some subtests) only when the state is reached through a helper.paralleltestalready skips a test that callst.Setenvort.Chdiritself, andnolintlintthen fails the marker as unused; give that test a plain comment instead. - Do not share a mutable fixture across subtests; production code mutates
protos in place. In a parent with parallel subtests use
t.Cleanup, neverdefer.
E2E
test/e2e/is its own module and must run the realfinfocusbinary; never stub cost values. AWS E2E needsPULUMI_CONFIG_PASSPHRASEand~/.pulumi/binonPATH.- The run-rate half of
make test-e2e-kindinstalls aws-public into the real~/.finfocus.TestCostCluster_KindHistoricaluses a temp home: runtest/e2e/kind/setup.sh, applytest/e2e/kind/prometheus.yaml, thengo test -tags e2e_kind -run '^TestCostCluster_KindHistorical$' ./...fromtest/e2e(its own module; a bare run from the repo root skips it) withFINFOCUS_BINARYset. - Real-stack checks use
../finfocus-demo(stackdevin Pulumi Cloud, AWS creds from an ESC environment attached withpulumi config env add; it rewrites the trackedPulumi.dev.yaml; never commit that change, since the demo's CI has its own credentials).pulumi previewneeds no spend; web Cost reads deployed state plus history, while Estimate and Recommendations include preview-only creates through the shared inventory. Alwayspulumi destroyafterwards. Neverpkill -f <name>orpgrep -f <name>from a shell whose own command line contains the name: it matches and kills the shell. Anchor the pattern. - The aws-public router starts a per-region child
(
finfocus-plugin-aws-public-<region>) that can outlive the router when finfocus is SIGKILLed or the child leaves the plugin's process group. Checkpsfor orphans after local runs. - Remote-write fixtures need
storage.tsdb.out_of_order_time_windowin the Prometheus config and a window relative to now. A fixed date is rejected as out of bounds once a newer sample exists, and later falls out of the 15-day retention. make test-e2e-kindpinsE2E_AWS_PUBLIC_VERSION(Renovate bumps it).- Web plugin e2e uses the production seams
FINFOCUS_CATALOG_FILEandFINFOCUS_GITHUB_API(a stand-in catalog and GitHub API, not a supported custom registry).webEnvironmentstrips everyFINFOCUS_*value beforefixtureEnvis appended, so pass those two throughfixtureEnv.GITHUB_TOKENis not stripped. Do not mention the seams in user docs.
Integration Tests (test/integration/)
helpers.CLIHelper.Executemust go throughax.Execute; a barecli.NewRootCmd().Execute()lacks--format,--dry-run,--yes, and--idempotency-key.- Tests built with
helpers.NewCLIHelper(t)and noWithEnvread the real~/.finfocus/config.hujson. Compare the failure set with a pre-change baseline, not the count. - Setting
HOMEfor ago/makechild moves Go's caches whenGOPATHis not exported (CI); cleanup then fails on read-only module files. Pass the realGOMODCACHE/GOCACHE(seescaffold_build_test.go). Reproduce withenv -u GOPATH go test .... config.yamlfixtures are intentional legacy input for auto-migration.
Releases
- release-please manages
CHANGELOG.mdand versions; never edit them by hand. Afeatbelow 1.0 bumps the patch. - A new nested plugin goes into
release-please-config.jsonwithinitial-versionand its path in the root package'sexclude-paths. Do not add it to.release-please-manifest.json: a hand-written0.1.0makes the first release0.1.1. The release PR adds the entry. release-monorepo-plugin.ymlbuilds any<name>-vX.Y.Ztag fromplugins/<name>/cmd; a new plugin needs no workflow change. Plugin tag prefixes must not start withv.- Nested modules must pin the same
finfocus-specas the rootgo.mod; CI compares them. - release-please gives a commit to every package whose files it touches, and
BEGIN_COMMIT_OVERRIDErewrites it for all of them. Land afinfocus-specbump on its own asdeps: bump finfocus-spec to vX.Y.Zbefore the feature that needs it. Afeatthat also editsplugins/*/go.modbecomes a feature of every plugin (0.5.4: #1922 listed historical carbon in four plugins that only changedgo.mod).depsrenders under Dependencies; the plugins take the top-levelchangelog-sections, which hidedocsandrefactor. make install-<plugin>stamps the version from.release-please-manifest.json. A test that installs a plugin reads that file too; a hardcoded version fails only on the release PR, once the bump lands (TestCarbonPlugin, release 0.5.4).
Pulumi Integration
pulumi preview --jsonnests resources undernewState; ingestion must readinputsandtypethere or plugins getInvalidArgument. A step has no top-leveltype: callPulumiStep.ResourceType(), never readstep.Type(the overview showed no costs for preview-only resources until it did). Test fixtures must carry the type only undernewState, as Pulumi emits it.- A default
pulumi preview --jsonomits unchanged resources but still reports their count inchangeSummary.same;--show-sameslists them.internal/stackloadmerges state and preview foroverview,cost projected,estimate,recommendations, and web callbacks. Estimate and recommendations cover the live inventory, including unchanged state and preview-only creates. Projected budgets andsummary.totalMonthlyretain the plan total;finfocus.stackreports the separate stack total and safe completeness status.finfocus.emptyPlandepends only on priceable plan resources, even with state. Forecast retains the plan-origin total; actual costs use deployed inventory plus existing history enrichment (specs/1733-shared-stack-loader/). A saved preview whose URNs name another stack or project than the surrounding project's state is not merged (not_requested). Plan resources keep plan-scopedref.*tags (and cache keys); only the stack total reprices one whose refs resolve differently against the full inventory. Test fixtures must pair a preview and state from the same stack and project, or the loader now drops the state. parentis the stack, not a pricing reference. Cross-resource refs followpropertyDependenciesonly and becomeref.<property>.{urn,type,region,sku}tags (specs/618-cross-resource-refs/). The unknown sentinel04da6b54-80e4-46f7-96ec-b56ff0331ba9is never sent as a tag, SKU, or region. A referenced SKU is never copied into the child'sSku.- No credential or Pulumi secret reaches a plugin. One rule each:
config.IsCredentialName(whichengine.isCredentialKeycalls) /skipDottedSegmentfor names andhistory.IsPulumiSecretfor values. They apply to collapsed tags, dotted keys (specs/619-dotted-tag-keys/),ResourceDescriptor.attributes(specs/621-k8s-workload-projected-cost/), actual-cost tags, andEstimateCostattributes. Do not add a second rule. - Anything new sent to a plugin must be in the projected cache key; that is
why keys end in
/tags-,/refs-,/attrs-digests and feature suffixes (/pricing-spec,/growth-v1,/penv-,/profile-dev|prod|burst)./penv-is the digest of the clients that engine retained at construction. A settings or validate probe must not update another engine's key. An empty digest omits the suffix. Tests of the process-wide helper must not run in parallel and must callResetPluginEnvDigestsForTest. __-prefixed inputs (__defaults,__provider) are Pulumi internals; filter them from anything shown to users.- Analyzer import:
pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"(not.../proto/go/pulumirpc). analyzer servedoes not open stack ESC imports. Pulumi already put those variables in the process environment, and the stack is unknown untilConfigureStack. Explicitescconfiguration is still opened. The ESC client is the hand-built one ininternal/esc(see Plugin Host).
Plugins
- Recorder (reference/contract plugin):
FINFOCUS_RECORDER_OUTPUT_DIR(default./recorded_data),FINFOCUS_RECORDER_MOCK_RESPONSE. - Recommendation scorer (
plugins/scorer/): opt-in;SCORER_BACKEND=jev|clefdefaults to Jev.SCORER_API_KEYis the selected backend token; it never appears in logs, errors, tests, or files. The recommendation id never leaves the plugin (answers map back by position).priorityis always one record per request. Scores rank only; never gate an irreversible action on one. Hosted Jev sends data to TypeSafe AI; hosted Clef sends it to Cloudflare. Keep identifier handling andfield_allowlistintact. Clef requires a 32-hex-characterSCORER_CLEF_ACCOUNT_IDwhen a token is provided and only acceptsSCORER_MODEL=clef; its optionalSCORER_BASE_URLis an origin. Source install ismake install-scorer; the first public scorer registry release is pending. The legacyjevregistry entry is deprecated (deprecatedandreplacement: scorerininternal/registry/registry.json). It stays installable,plugin list --availableand the dashboard catalog say to use scorer, and install printsDeprecationNoticewithout migrating settings.SCORER_BACKEND=jevis the scorer backend, not that catalog entry.TYPESAFE_API_KEYandJEV_*are not read by scorer. Live tests requireSCORER_LIVE_TEST=1; agents must not run them. - Agent skills: all finfocus skills live in
agent-skills/here, includingcost-checkandcost-drift.rshade/agent-skillskeeps stubs that point to them.internal/cli/cost_skills_test.gopins both to the live flags and JSON fields. After editing a skill, rebuild its.skillzip (SKILL.md+references/) or the archive test fails.
Package-Specific Gotchas
Config singleton (internal/config/integration.go)
GetGlobalConfig()initializes the singleton on its first call, without the project overlay, andInitGlobalConfigWithProjectthen does nothing. Never call it (orGetDefaultOutputFormat,GetOutputFormat) while building a command or its flags: the project tier would be lost.--outputdefaults to""andresolveOutputFormatWithConfigfills it in at run time, afterPersistentPreRunE.TestNewRootCmdDoesNotInitializeConfigguards this.config getreadscost.budgets.global.amount,currency, andperiod. Flatcost.budgets.amountstill addresses that same global scope.
Plugin Host (internal/pluginhost/)
- Ports: allocate, hold, release, bind, retried by
StartWithRetry. A port stays inpendingPortsuntil its plugin binds. A skipped pending port's listener is closed at once: the readiness probe is a bare TCP dial, so a held listener would answer for another plugin. - Always
cmd.Wait()aftercmd.Process.Kill(). - Core never sets
PORT; plugins use--portorFINFOCUS_PLUGIN_PORT. NewClientinjects delivered settings beforeLauncher.Start. A descriptor key usesdescriptor.env. A key with no descriptor usespluginhost.GenericPluginEnv(FINFOCUS_PLUGIN_<NAME>_<KEY>). A parent value already set, including empty, is left as it is. SkipFINFOCUS_PLUGIN_PORT, the log-file variable, and the trace id. Do not inject from redactedpluginValueorconfig.effective(): the config loader shallow-replaces the wholepluginsobject.- ESC is the highest-precedence plugin environment source, and only for names
the plugin advertises or that
esc.plugins.<name>.variableslists.appendDeliveredEnvremoves an inheritedKEY=entry only for names inDelivered.Overrides. The projected cache key hashesname, the ESC reference, and the revision key, never the value. Theescconfig section is top-level becauseplugins.<name>is injected as environment. The selected stack comes from the Pulumi workspace file unless--stackis set.PULUMI_CREDENTIALS_PATHis a directory holdingcredentials.json, as in the Pulumi SDK. Workspace stack selection uses the same active backend as ESC auth (PULUMI_BACKEND_URL,backend.url,credentials.jsoncurrent) and never falls back to another backend's stack. A value is read at a pinned revision (tag to number,latestETag checked before and after the open); an unknown revision is an ESC failure. The ESC SDK client is hand-built (Servers=<backend>/api/esc, no redirects, token on the context). Open calls userecover()because a non-string trace panics the SDK. Never readGenericOpenAPIError.Body(). - After
GetPluginInfo(5s, request*proto.Empty),NewClientrestarts the process once when advertised settings change the injected pairs. That restart happens before the version check, so stderr from the new process can appear even when the client is then closed as incompatible. A second change does not restart again.Client.Closeis the func field fromstarted.idempotentClose(). - Secrets never appear in logs, errors, or another plugin's environment.
Credential names stay on
config.IsCredentialName. - Dashboard settings apply on the next
NewClient. The first reload must not close the overview pipeline's original clients. Later reloads close only the set the previous reload opened.closeReloadedruns beforepipe.Cleanup. Install and remove fail the operation when reopen fails. A settings save still returns 200 and logs the reopen error. - A dashboard HTTP request context has no plugin log writer. Plugin stderr is
then discarded when the process stderr is not a terminal. Short probes
(settings read, validate) use a child of the session context that is
canceled when the request ends. A reload that keeps clients uses the session
context itself.
exec.CommandContextkills the plugin when its launch context is canceled, so a request-scoped context must not own those clients.
CLI (internal/cli/)
- Overview TUI quit waits at most
overviewShutdownGrace(2s) for the enrichment goroutine, thenoverviewCleanupHandoff(100ms) for the plugin cleanup callback. The budget fetch's done channel is joined before that cleanup runs (Runjoins it itself). A plugin call that ignores cancel is abandoned after the grace period so quit cannot hang. The cleanup is published when the engine is built (newCleanupPublisher), so the abandon path still closes plugins before the process exits; a detached goroutine (runLateOverviewCleanup) covers a cleanup that appears later. - The overview hands off to
runWebSessiononly after plugin cleanup, through thestartWebSessionseam. The hint andwlive inside the interactive model, so non-interactive runs never see them. finfocus --webshuts down on SIGINT and SIGTERM (webui.Server.ServeUntilInterrupt, the single owner;Runis Listen plus that). That is the path that stops plugin children.server_sigterm_test.gois//go:build unixbecause Windows has nosyscall.Killor SIGTERM. The interrupt test signalsos.Interrupt. On SIGTERM, core SIGKILLs each plugin's whole process group (killProcessGroup), so a grandchild that stays in the plugin's group dies too;test/integration/web_sigterm_orphan_test.gopins that with a router-like fixture. SIGKILL of finfocus itself only deliversPdeathsigto the direct child, and a grandchild that leaves the group (Setsidor its ownSetpgid) survives either way. Those cases belong in the plugin.analyzer servealready handles both signals. Under WSL (WSL_DISTRO_NAMEorWSL_INTEROP),runWebSessionwaitswslBrowserOpenDelay(1.5s) before opening the browser. The Windows-side localhost relay refuses a freshly bound port for about a second, so an immediate open fails until the user reloads.--no-browserskips the wait.- The web session holds one
*stackload.Snapshot(webData.snapshot).setPipelineSourcepublishes it under the query gate after the pipeline loads and again after an on-demand preview, which replaces the pipeline's snapshot throughSnapshot.WithPlan. Estimate and Recommendations readDescriptors(); Cost readsActualDescriptors(). Web Overview appends plan-only creates throughengine.AppendPlanOnlyRows(the same stepMergeResourcesForOverviewuses) and prices only those new rows withEnrichOverviewRows; it does not only patch rows that already exist. cost estimate --modifysplits on the last colon before=, so a Pulumi URN selector keeps its colons. A selector matches the resource ID, the logical name (the last::segment), or the type, and every matching selector applies; on a conflicting property the more specific wins (ID over name over type).selectorKeysis the one definition of a match. A selector that matches nothing is a validation error (exit 2) that lists the plan's resources.--resource-typemust be a full token (package:module:Type) whose package matches--provider; a short name such asec2:Instanceis rejected.plugin listshows the highest semver of each plugin.--alllists every installed version, marks the one core opens (Active/ JSONactive), and starts older binaries only with--verbose. A non-semver directory name is a warning and is omitted from the default listing. Rows sort by name ascending, then installed version descending as semver. A version that is not semver uses ascending string order, and path breaks remaining ties. Table and JSON use that same order. Invalid-semver warnings go to stderr and do not change the exit code.analyzer serveprints only the port on stdout (Pulumi handshake); all logs go to stderr.- Tests leak into the real
~/.finfocusunless they sett.Setenv("FINFOCUS_HOME", t.TempDir()); this has produced tests that pass only after a sibling seeded the store. A test that fails alone (HOME=$(mktemp -d) go test -run Name) is order-dependent, not flaky. The same leak makesTestPluginListCmd_TableOutputUnchangedfail when several plugin versions are installed locally. - Reject an unknown
--outputbefore loading state; early returns on empty results otherwise exit 0 silently (seeconfig_routes.go,plugin_list.go). Return it throughunsupportedOutputError(ortoValidationError) so it exits 2. A new command with--outputgoes inoutputFormatCases(output_validation_test.go), or inoutputIsNotAFormatwhen the flag is a path;TestUnsupportedOutputCoversEveryCommandfails otherwise. config validateand everycostpre-run shareValidateConfigSource. The on-disk budget field iscost.budgets.global.amount; flatcost.budgets.amountis warned and ignored.- Do not put
--plain(or other accessibility flags) on thecostparent:cost history viewhas its own--plain. Resolve modes withtui.DetectResolvedOutputMode, neverDetectOutputModeFor, which re-readsNO_COLORand undoes an explicit--color. - Budget CLI flags (
--exit-on-threshold,--exit-code) areBudgetFlagOverrideson the command context, never written onto the global config; only a copy of the global scope receives them. - Budget notifications (
specs/625-budget-alert-notifications/): stderr only, never change stdout or the exit code. Only${FINFOCUS_NOTIFY_*}expands, and a project-sourced destination with${is skipped without a lookup. Sends are HTTPS only, follow no redirects, and time out after 10s. Failure reasons redact every variable value, URL, and secret-looking header, and never include a response body.ThresholdStatus.Notificationsisjson:"-"; masking is display-only becauseConfig.Savemarshals the struct. Tests swap the client withcli.SetNotificationClientForTest. - The overview docs page is
docs/src/content/docs/commands/overview.md. cost anomalies(specs/1923-cost-anomalies/) runs the overview pipeline and lists rows whoseCostDriftis set throughengine.SelectAnomalies, the one selector the CLI, MCP tool, and web handler share. It adds no rule of its own: the 10% threshold and the early, new, and deleted skips live inCalculateCostDrift. It reads state only unless--pulumi-jsonis given, and takes the overview's--filterkeys (type,status,provider; notag:).validateInfrastructureInputsexemptsoverviewandanomaliesby name so a state and a preview can be combined; a new command on the same pipeline needs the same exemption.- Overview plugin startup overlaps stack loading (
OverviewPipeline.Run, #692). The launch context is a child of the caller context.errgroup.WithContextcancels onWaitand would kill plugins (exec.CommandContext) before enrichment, so this overlap does not use it. A plugin error cancels the load; a load error cancels the launch. Cleanup runs beforeRunreturns when either side fails, and stays on the pipeline only when both succeed. Phase events stay 1..7; phase 5 is emitted when the open is joined. Phase 3 is the preview (run, plan read, or skipped) and is always emitted, so the TUIphaseNames, webPHASE_COUNT, andwebuipreviewPhase/enrichmentPhasemove with theOverviewPhase*numbers. The TUI shows the label each event carries, not only the default name. The web on-demand preview re-emits phase 3 as "Running pulumi preview..." and the session marks it done or failed.GetCurrentStackwrapsctx.Err()whenpulumi stack lsfails after that context is canceled. The process error issignal: killedand does not wrapcontext.Canceled, so overview would otherwise report the kill instead of the plugin failure that canceled the load. A load error whose context is still active stays a load error. - Web rows are installed by
Session.SetDatabefore enrichment, and drift is set only during enrichment. A handler that reads drift or actual cost fromSession.Rows()must wait forSession.IsReady()and answer503 not_readybefore that, or it reports an empty result for a stack that is still pricing (GET /api/overview/anomalies). The browser formats nothing: amounts and periods are display text fromviewmodel.BuildAnomalyDisplay. internal/webuimust not importcli,registry, orpluginhost. Plugin page callbacks live ininternal/cli/web_plugins.goandplugin_settings_web.go. Install progress is a per-operation SSE stream (snapshot,progress,done). Do not publish it on the overview bus: that bus runsRedactJSONand drops slow subscribers. Settings GET useswritePlainJSON. Secret values are absent from the body. Bool and enum plugin settings skipIsCredentialNameunless the descriptor setssecret. Plugins → Settings paints an ESC row inplugins.jsonly. The Status cell readsfrom ESC (<reference>)fromeffectiveSourceandescEnvironment, and the help reference is acodeelement. Do not put Markdown backticks in that sentence, and do not add a display string to the settings JSON. Data-keyed maps keep their keys throughRedactJSON: useengine.DataKeyedor the struct tagdatakey:"true"(trends, breakdowns, and the cost-summary groupings).RedactJSONredacts in one pass (redaction_walk.go): it walks the decoded JSON alongside the original Go value, and that value is the only thing that says a map is data-keyed. A data-keyed map's keys are never matched againstIsHiddenPropertyKey; its values still passhistory.IsPulumiSecretand recurse, and one that is itself a secret envelope is dropped. Property maps do not get the mark, soIsHiddenPropertyKeystill drops credential-like names. A Go value with no matching source (a type with its ownMarshalJSON, an unknown key) is treated as a property map. There is no restore step, so do not add one. Do not add a second credential-name list or a payload-specific redactor case.GET /api/recommendations/itemreads the last successful recommendations query cached on the session. It does not call the plugin again, because a plugin may mint a newRecommendation.IDon every call. That ID stays the dismissal id. A later successful query replaces the cache, and an id that was not in it is 404. The web UI has no dismiss action, so a dismissal made elsewhere shows only on the next query: dismissed items are absent from a default query and present, flaggedDismissed, withincludeDismissed. With no successful query yet, the endpoint runs the query once and caches it. With several tabs the last query wins.GET /api/recommendations/historyuses that same cache, then(*engine.Engine).GetRecommendationHistoryon the dashboard engine fromqueryEngineandloadDismissalStore. It does not open a second plugin set. A missing id is 400. An id outside the cache, or a row with an empty recommendation id, is 404 and is not sent to plugins. Warnings from the engine are returned with the events. A lookup error is 502history_unavailablewith a fixed message. The detail panel renders the events under aLifecyclesection after the detail loads, in the returned order, with raw timestamps and reason enums, and shows a warning, 404, or 502 in an in-panel alert line. A row with no recommendation id shows the empty state without a request, since the API would 404. Row ids change on every plugin call, and the page queries on bothreadyandsnapshot, so a detail click can 404 on a stale row; the next query fixes it. Real events need a dismissal recorded for the cached id (specs/1921-recommendation-history-web/). The overview EventSource checksGET /api/overview/budget(same-origin cookie, no bearer token) after a connection failure. A 401 stops retries and tells the user to open the link printed in the terminal.closeaborts that check. Other failures, and a check that takes over 5s, keep the capped backoff.OverviewStreammoves through explicit states (connecting,open,authenticating,reconnecting,backoff,failed,closed) only viadispatch.GET /api/estimate/baselinereturns a provider failure as HTTP 200 withstatus: "unavailable",error,code: "estimate_unavailable", redacteddisplay.propertieswith no deltas,pricingModesfromDiscoverPricingSpec, andresource. Never a 5xx that carries data. The editor must treat the marker as a failure, not a success. Request and server errors,400 invalid_pricing_mode, and recalculate failures stay a plain{error, code}4xx/5xx. Web overview learns state-only frompipe.OnStateOnly(Session.SetStateOnly). The three flags are onewebui.RunStatuson the session (browser:mergeRunStatusinapp.js); the snapshot and therun_statusevent carrystateOnly,previewLoaded, anddetectFailed(a boolean; the error text stays in the terminal log and never reaches the browser).POST /api/overview/previewis 409 unless the run is state-only and a preview has not been applied. A successful preview clears that status. A failed preview sends the browser only "Preview failed";runPreviewlogs the reason as a warning in the terminal. A preview whose added rows fail to price (appendPreviewRowsreturnserrPreviewPricing; a row hasErrororProjectedPricingFailed) is an error, not loaded, so Run preview can retry; an unpriced type that only reportsNO_COST_DATAis not a failure. Do not keep a second store beside those session fields.--pulumi-statewithout--pulumi-jsonis state-only too, like the TUI. After a preview adds rows,appendPreviewRowsapplies the dismissal delta to them, andOverviewPipeline.RefreshFallbackBudgetre-evaluates a config budget against the new total (plugin budgets are left alone).pluginMetadatadoes not copySettings, so the settings handler callsGetPluginInfoagain.- The dashboard Config page and
config setpatchconfig.hujsonthroughconfig.PatchFile. Do not callConfig.Savefor that write: it rewrites the struct fromNew(), folds environment variables into the file, and drops comments.baseVersionis the SHA-256 hex of the file bytes, orabsentwhen the file is missing. A legacy projectconfig.yamlis hashed as raw YAML and converted inside the patch, after that hash check, not before it. Only the global loader rewrites a legacy file toconfig.hujson.config setruns after that migration and passes an emptybaseVersion. A request the caller got wrong is aconfig.InvalidChangeError(400); any other write error is logged and returned as a generic 500, because it can carry paths. Both accept only keysconfig.LookupSettingknows: a new settable key needs aconfig.KnownSettingsentry (and adefaultValuecase), which also puts it on the Config page. That makes it a web surface for Principle VII.
Registry (internal/registry/)
Openlaunches up to 8 plugins concurrently; clients keepListLatestPluginsorder, sorted by name (router priority ties rely on it).- With
regioninplugin.metadata.json, discovery looks forfinfocus-plugin-<name>-<region>first. - Only a confirmed checksum mismatch is fatal;
--skip-checksumexists. The dashboard catalog path does not offer that flag. A missingchecksums.txtwarns and continues on the CLI and returnsErrChecksumUnavailableon the web path unless the person accepts it. The web planner reports a zero-asset release as a fallback so confirmation can name both versions. The confirmed install is an explicitname@versionof the fallback, soAcceptFallbackVersionstays unset. The installer still does not swap an explicit version on its own. FINFOCUS_CATALOG_FILEoverlays the embedded catalog forGetPluginandGetAllPluginEntries(read each call, not cached).ListPluginsFromRegistrystays on the embedded catalog.FINFOCUS_GITHUB_APIreplaces the GitHub API base. Both are test seams. A custom registry remains #1766.- Monorepo plugins (
tag_prefix: "kubernetes-"): "latest" is the highest prefixed stable semver, never/releases/latest; installs use the canonical version; the entry needsasset_hints.asset_prefix. - A latest release with no assets yet (release-please publishes before the
archives upload) is replaced by the newest earlier release with assets. An
explicit version is never swapped; it returns
*NoAssetsError(ErrReleaseHasNoAssets), which the CLI's version fallback still handles. DefaultPluginDir()isconfig.New().PluginDir(FINFOCUS_PLUGIN_DIR, thenplugin_dir, then the plugins directory under the resolved home). Install and discovery share it.
Router (internal/router/)
- A
$0.00result is valid and does not fall back; only nil/empty does. - Higher priority number wins. No routing config means no router (all plugins are queried).
- Provider means the billing cloud.
resourcetype.NormalizeProviderholds the only alias table (aws-native→aws,azure-native/azurerm→azure,google-native/google→gcp); never split a type token for a provider anywhere else.resource_typekeeps the package, soaws-native:*patterns still match.
Engine (internal/engine/)
hoursPerMonth = 730. Pricing-specper_dayuses 30 days (720 h). Do not "fix" one against the other: both are AWS's own conventions.- JSON/NDJSON error codes are
PLUGIN_ERROR,VALIDATION_ERROR,TIMEOUT_ERROR, andNO_COST_DATA(types.go);NO_COST_DATAis not an error. - A failed plugin call keeps the gRPC status as
<Code>: <message>(pluginRPCError). Only an empty projected result isno cost data available. Core does not readaws:regionfrom provider or stack config. A fixed-cost type with no SKU property, includingaws:ec2/natGateway:NatGateway, gets one fromskus.ResolveSKU. An empty SKU is rejected before pricing (resource.sku is required) and the row then shows no price. - Overview actual enrichment copies
ResourceIDintopulumi:cloudId, and fillspulumi:urnandpulumi:createdwhen those properties are empty, beforeGetActualCost. The row's own properties are not rewritten. Cost actual already receives those keys fromMapStateResource. Without the cloud id, a plugin can estimate a monthly figure instead of the measured window total the Cost page shows. The requestFromis clamped to the row'sCreatedAtwhen that falls inside the window (actualRequestStart), because plugins trust an explicit Start overpulumi:created; without it a resource made yesterday was charged for the whole month.ActualCost.Periodkeeps the display window. An imported row (External) is not clamped: itsCreatedAtis the import time. A clamped row recordsActualCost.BilledDays, and the delta extrapolation divides by that instead of the day of month.cost actualand the web Cost page get the same clamp per resource inactualFromPlugin(resourceActualStart, frompulumi:createdandpulumi:external); the result keeps the requestedStartDateandCostPeriod, andwindowDailyCostsrebuildsDailyCostsover that window (zero before the creation day), becausedistributeDailyCostsandAggregateResultsInternalindex the series fromStartDate. A resource created at or aftertogets a local $0 result and no plugin call (Start == Endfails SDK validation). A batch has one start, so a resource created inside the window skips the batch and goes to the per-resource path. - Projected diff (
cost projected): replacement steps sharing a URN are collapsed into oneupdate(collapseReplacements), or the old resource counts twice. Both sides use the same basis and the same plan-wideref.*resolution. Unpricedpulumi:types are left out. - Usage profile (
specs/630-usage-profile/):--profile, thenFINFOCUS_PROFILE, thencost.profileon the merged config (InitializedGlobalConfig).config.Newhas no project overlay, so estimate, overview, and the dashboard must not read the profile from it. An omittedcost.profileis no preference. A present empty value fails validation. The interactive recommendations view names the same profile. The environment variable is not copied into the config file. Opt-in engines are projected, recommendations, forecast, estimate, cluster run-rate, overview, the dashboard engine, andanalyzer serve.cost actualandcost history collectstay unspecified. The hourly-rate lookup inside actual cost passes UNSPECIFIED even when the engine has a profile. A set profile skips projectedBatchCostbecauseBatchCostRequesthas no field, and appends/profile-dev|prod|burstafter the other cache suffixes. Unspecified adds nothing. Do not set the profile insidenewEngineWithCache. ResourceExhaustedis a rate limit, not a reason to switch plugins. Projected and actual cost retry that same plugin (retryRateLimited, three attempts) before the fallback log. A wait uses the caller context.ErrorDetail.retry_after_seconds, when set, is the wait; otherwise the wait is bounded jitter. Each attempt still gets its ownperResourceTimeoutwhere that timeout already applied. A batch that returnsResourceExhausted, or per-resource code 8, is retried as a batch. Those resources are not sent back throughGetProjectedCostorGetActualCoston the rate-limited plugin. A deadline that fires during the wait stops the chain.priceChunkgives RPC-level and per-item rate limits one shared three-attempt budget, resends only the still-limited resources, and splits each retry by the smallestMaxBatchSizeseen. A later chunk's ordinary failure keeps earlier rate-limit markers: only the unfinished rows get an error, because callers ignore results whenexecuteBatchForPluginreturns one.- Cost history (
internal/history,cost history *): files are<project>@<stack>.history.db, separate from the resource-observationhistory.db. Both use one directory:FINFOCUS_HISTORY_DIR, thencost.history.directory, then<ResolveConfigDir()>/history. A project-tiercost.history.directoryis ignored with a warning when it is absolute, has.., or resolves (symlinks followed on the deepest existing ancestor, against the working directory) outside the project root, or when an entry namedhistory.db*or containing.history.db(including the.compact-*compaction temp) is a symlink or not a regular file (rechecked afterMkdirAll; a swap after the check is not covered). The gate takes the project dir that built the config (initHistoryFromConfig'sprojectDir; the analyzer passes its own, never the process-global one) and judges the overlay value fromconfig.ProjectHistoryDirectory; global tier and the env var are trusted. The working-dir fallback is made absolute.collectfails a checkpoint rather than store an inaccurate total (#549).internal/historymust not importengineoringest. The overview andcost actualinteractive detail views load one resource's series from that database only after Enter (internal/tui/detail). An empty stack or a missing database returnsdetail.ErrHistorySkippedand the section is omitted. Any other error stays inline;rretries that load and leaves the detail view open. Replies carry the model pointer and a generation so a late fetch cannot fill a newer view. History subcommands reuse the parent--stack.cost history exporttakescsv/ndjsonthrough the root--formatby rewriting it inArgsbeforeax.Executeresolves the mode.pulumi stack historyis read in pages of 100 until a page adds no new version; a short page is not the end, and the CLI's default page is only 10 updates. --explain,--pricing-spec-fallback, and estimate-TUI discovery callGetPricingSpec; disabled runs never call it, and each call has a 5s deadline. A$0rate is a price; a plugin must answerzero_costor an error for "no price". Tiers are graduated, and tiers that do not cover the whole quantity fall through to YAML.cost forecast(specs/626-cost-forecast/) does not changecost projectedtotals. A blank currency is USD.checkPluginSupportscaches per client, provider, type, region, SKU, feature, and attributes digest. SKU must stay in the key, or one SKU-less answer poisons every SKU in that region. The SDK's "not implemented" reason is fail-open, likeUnimplemented.InvalidArgumentfromSupportsis a decline: it is cached as unsupported, the reason is shown, and no pricing RPC follows.Unavailable,DeadlineExceeded, andCanceledstay fail-open and are not cached. Do not add a provider-name table here.- Plugin
expires_atsets a per-entry TTL (capped at 7 days; a past time skips caching). A plugin whose price depends on its own environment must sendexpires_at= now, because the cache key cannot see it. - Cache hits append "(cached)", with a leading space, to the adapter. A
corrupt cache is deleted and recreated.
cost.cache.lru_enabledwraps Bolt incache.TieredStore. - Budget health: OK < 80%, WARNING 80-89%, CRITICAL 90-100%, EXCEEDED > 100%; aggregation takes the worst.
- Recommendations:
Recommendation.IDis the dismissal id. The cache key hashes inputs plus dismissed ids;--include-dismissedstill sends excluded ids and adds/include-dismissed. cost recommendations history(specs/1729-recommendation-history/) merges pluginGetRecommendationHistoryevents with the local dismissal record. It queries every connected plugin, including when that id has no local record, and does not need a Pulumi plan.Unimplemented, an empty result, and any other plugin failure are logged and local history stays. A failure other thanUnimplementedalso returns one warning,<plugin>: recommendation history unavailable, with no plugin error text. The CLI renderer ignores that slice. Each plugin call usesperResourceTimeout. A plugin that hits that deadline, or that returnsCanceledorDeadlineExceededon its own, is skipped. Only the caller's context being canceled or past its deadline aborts the query. The read copies events before sorting and does not writedismissed.json.actoronconfig.LifecycleEventis omitted when empty, so older files load with no migration. A missing timestamp sorts after dated events. A timestamp or expiry that is present but invalid drops that event only. Events match on action, reason, note, time, and expiry; a named actor replaces a blank one, and different actors are both kept.- Scoring (
internal/scoring, off by default) pseudonymizes identifiers by default, only warns on failure, and never dismisses or hides anything. Scorer-only plugins are dropped from cost features beforeSupports. - Blast radius (
specs/1736-dependency-blast-radius/): the dependency graph lives onstackload.Snapshot(Graph(), rebuilt byWithPlan), andengine.ComputeBlastRadiusfeeds both the TUI andGET /api/overview/resource. Edges are Pulumi'sDependingOnlink set withoutparent:dependencies,propertyDependencies,deletedWith,replaceWith, andprovider(resourceLinksinstackload/graph.go). A provider reference is<urn>::<id>, cut at the last::; without the provider edge a cluster misses every workload deployed through it. Pulumi recordsdependsOna component as the component's members, so the graph has no parent walk.OverviewRowResult.ParentURNis cluster expansion, not dependencies. The total is one basis: it leaves out deleting, unpriced, and component dependents, and withholds itself on mixed currencies. Its copy (summary, left-out note, cost cell, status label) is filled byComputeBlastRadius(blast_radius_display.go); the TUI andoverview.jsrender those strings and never format a number (FR-011b). A dependent with no row takesTypeandStatusfrom its graph node; a preview step's component flag readsPulumiState.Custombefore thepulumi:prefix, which excludespulumi:providers:(providers are custom resources). The web publishes a preview's graph only afterwebData.commitPreviewprices its rows, so a failed preview keeps the earlier graph with the rows the session keeps.
Overview Field Semantics (internal/engine/overview_*.go)
| Field | Basis | Source |
MTDCost | Day 1 to today (spent so far) | Actual plugin |
MonthlyCost | Full 730 h month | Projected plugin |
ExtrapolatedMonthly | Calendar month from the MTD trend | CalculateCostDrift |
Delta (drift) | ExtrapolatedMonthly - MonthlyCost | CalculateCostDrift |
MTDCostandMonthlyCostare different units; never subtract them without extrapolating first.Population by status:
Status Actual Projected Drift PropertyDiffs Active yes yes maybe no Updating / Replacing yes yes maybe yes Creating no yes no no Deleting yes no no no - The Delta column always comes from
CalculateRowDelta(), which is status-aware; never compute it inline. Update/replace is projected(after) minus the baseline projected(current) (GetBaselineProjectedMonthlyCost), the same basis. It falls back to extrapolated actual only without a baseline, and shows no delta without property diffs. Creating is+projected, deleting is-extrapolatedActual, active is the drift delta. GetExtrapolatedActual()andForceExtrapolateActual()use 30 days (stable deltas);CalculateCostDrift()uses calendar days. Do not unify them.CostDrift == nilis normal: day 1-2, drift under 10%, new or deleted, or created under 3 days ago.- TUI diff values are truncated to 40 characters (
truncateDiffValue). State-only mode marks projected costs with*;pruns a preview and updates rows in place.
Cluster Allocation (internal/engine/cluster*.go, plugins/)
Specs 613-623 hold the details. Invariants:
- Core never interprets Kubernetes. Nodes arrive from
GetStatsas ordinary descriptors and are priced by the engine; grouping is string-map aggregation. - Run-rate prices with
GetProjectedCostWithErrorsandMonthly. A window (--from/--to) prices withGetWindowCostandTotalCostonly, labels the period withFormatWindow(notFormatPeriod, whichcost actualkeeps), and requires a source in historical mode (ErrStatsModeMismatch). - A
$0or missing price is unpriced (aws-public returns$0for unknown types). All nodes unpriced is fatal. A stats warning startingincomplete:setsIncomplete. - Conservation is enforced in core (
VerifyConservation, shared SDK rule) even though the conformance suite checks it too. - Nodes are keyed by cluster and node name; the priceable
idstays the Kubernetes node name. - The idle row stays at
$0:ValidateAllocateResponseneeds one per priced node. Fargate pods are their own priceables and never fold into idle. plugins/kubernetesandplugins/prometheusmust not import core or each other. Prometheus node identity copies the descriptor rules fromplugins/kubernetes/usage/nodes.go.- Prometheus collection (
plugins/prometheus/):- Take
max by (node)of kube-state-metrics capacity before integrating. Replicas export the same node twice, and a sum doubles capacity. - cAdvisor scraped from the kubelet often has no
nodelabel. The node then comes fromkube_pod_info, and matching must accept the empty label. - Clusters are discovered from
kube_node_labelsorkube_node_status_allocatablebefore any sum drops the label. - Retention shorter than the window is detected store-wide
(
promql.StoreStart), not per pod. A hole shorter than the 5m lookback is not detected. - The bearer token goes only to the configured scheme and host (a redirect elsewhere would receive it otherwise), and URLs in errors and logs drop userinfo and query.
- Take
- Pod label keys from kube-state-metrics are sanitized
(
app.kubernetes.io/namebecomesapp_kubernetes_io_name).
MCP Server (internal/cli/mcp.go, output_mode.go)
mcp-serverand--mcpcancel their context on SIGINT and SIGTERM (cancelOnShutdownSignal, installed only on those two entry points;mainstill passescontext.Background()). ax-gomcp.Servethen drains and returns nil, so the process exits 0 with deferred cleanup run. Do not move the handler intomain:analyzer serve,--web, and the TUI commands own their signals. Do not patch ax-go from this repo.mcp_server_signal_test.go(//go:build unix) pins this.- ax-go re-executes the shared root once per
tools/call, so persistent pre/post run per call. Dispatched calls reuse the server'sloggingSessionand never close it. - One mutex serializes every call; a blocking command stalls the server. That
is why
analyzer serveandcost history collectare excluded (mcpExcludedCommands,mcp.Exclude). Never useHiddento exclude a group: ax-go prunes the whole subtree. resolveOutputFormat: explicit--output, then--format, thenAGENT_MODE. It ignores TTY detection on purpose. New commands with an output flag must use it.- Flag values reset per call only on a real server; assert cross-call behavior in the integration test.
internal/cli/testdata/mcp/tools.goldenpins the tool list. A new command must be exposed or excluded on purpose (UPDATE_GOLDEN=1).- The MCP handshake rejects
dev/unknown; build test binaries with-ldflags -X .../pkg/version.version=....
Plugin Upgrade (internal/pluginupgrade/, agent-skills/finfocus-plugin-upgrade/)
- Bumping finfocus-spec needs a hop:
hops.gomust reachpluginsdk.SpecVersionand have areferences/to-vX.Y.Z.mdguide (tests enforce both), even for an additive release. Add the guide's row toSKILL.mdand rebuild the.skillzip (SKILL.md+references/). SpecVersionmust bev-prefixed, orGetPluginInforeturnsInternal.plugin.manifest.jsonis the flatregistry.Manifest, not the pluginsdk manifest;plugin validateneeds name and version to match the install dir.- Skill install runs
npx skills@1.7.0 addin an empty temp dir, never the plugin dir (its.npmrcandnode_modulescould run code), and writes through anos.Root. Every failure is a warning. Tests never run npx.
Analyzer language host (internal/analyzer/languagehost.go, cmd/finfocus/main.go)
filepath.Base(os.Args[0])selects the mode.pulumi-language-finfocus(and its.exeform) is the language host and is checked before the analyzer names.os.Executable()resolves symlinks, soRunPluginsetsArgs[0]topulumi-analyzer-policy-finfocus(plus.exeon Windows) or the process starts the CLI.- The analyzer runs in
RunPluginRequest.pwd(the Pulumi project) when that is an absolute directory, as on Pulumi before 3.270.0, and otherwise ininfo.programDirectory, the pack directory, which holds noPulumi.yaml. The working directory matters: relative project paths such ascost.history.directoryresolve against it.projectDirEnvalso exportspwdasFINFOCUS_PROJECT_DIR, so project-tier config,last-cost-summary.json, and cost history use the project's.finfocus/. An explicitFINFOCUS_PROJECT_DIRwins. Do not resolve the project insideanalyzer serve. RunAnalyzerServestops plugins beforegrpcServer.Stop()(Pulumi waits for the process) but closes history after it, and the server is built withgrpc.WaitForHandlers(true), soStopreturns only after in-flightAnalyzecalls have.BoltStore.CloseandIsEnabledare not synchronized.- Unix cancel sends SIGINT so
analyzer servecan close plugin process groups before it stops gRPC.GracefulStopwaits for Pulumi, and Pulumi waits for the process, so the signal path closes plugins and then callsStop. Windows cancel usesProcess.Killbecauseos.Interruptcannot be delivered.WaitDelayis 3s and the host's shutdown cap is 4s. SetupPolicyPackwrites both entries. Names go throughpolicyPackEntryName/PolicyPackEntryPath.- The opt-in integration test needs
FINFOCUS_TEST_PULUMIset to an absolute pulumi binary, not a mise shim (test/integration/analyzer_policy_pack_test.go).
Recent Changes
Active Technologies
- Go 1.27.3 (
go.mod), finfocus-spec v0.7.10 (pluginsdk,WithSettings), Cobra, ax-go, gRPC, zerolog, BoltDB (go.etcd.io/bbolt), Bubble Tea v2, testify;prometheus/client_golangand client-go in plugin modules only;github.com/pulumi/esc-sdk/sdkv0.14.0;github.com/rshade/goarima(statistical forecast; never tag the fork, pin a commit)
In this repository18 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .github/copilot-instructions.mdCopilot instructions
- pick-issueSkill
- promote-issueSkill
- speckit-analyzeSkill
- speckit-checklistSkill
- speckit-clarifySkill
- speckit-constitutionSkill
- speckit-convergeSkill
- speckit-implementSkill
- speckit-planSkill
- speckit-specifySkill
- speckit-tasksSkill
- speckit-taskstoissuesSkill
- agentic-workflow-designerSkill
- agentic-workflowsSkill
- code-reviewSkill
- jqschemaSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

