Build Observability

Why Build Observability exists

When several agents (and humans) build a project in parallel worktrees, the reasoning behind the build evaporates. A decision gets made in a throwaway branch, a blocker is hit and worked around, a story is silently dropped — and none of it survives the squash-merge. The docs say one thing; the code drifts to another. Build Observability turns those ephemeral moments into a durable, queryable record, then audits the record against the planning docs.

What goes wrong without it

The classic failure mode is a worktree teardown that takes the ledger with it: an agent worktree's .scaffold/activity.jsonl is lost the moment the worktree is removed, unless it is harvested into the primary archive first. The harvester (src/observability/engine/harvester.ts) and scripts/teardown-agent-worktree.sh exist precisely to close this gap — see Harvest, recover & teardown.

What good build observability produces

The system ships 9 durable event types, 8 adapters fusing external signals, a 9-lens audit (A–I), and 5 active stall signals (a 6th reserved) on the "Needs Attention" surface.

Two subsystems, one config file. Build Observability and the separate knowledge-freshness system both read .scaffold/observability.yaml. This guide documents Build Observability; the shared config keys are reconciled in Config reference.

System map

One write path, one fusion point, two read paths. Agents write events to the append-only ledger; adapters synthesize events from git, GitHub, MMR and pipeline state; the synthesizer fuses them with correlation-id dedup; the engine API drives the audit lenses and the progress timeline; renderers emit terminal, markdown, dashboard and MMR-findings output.

#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#000000;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#666;stroke:#666;}#my-svg .marker.cross{stroke:#666;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#000000;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#000000;color:#000000;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#eee;stroke:#999;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#666!important;stroke-width:0;stroke:#666;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#666;stroke-width:1px;}#my-svg .flowchart-link{stroke:#666;fill:none;}#my-svg .edgeLabel{background-color:white;text-align:center;}#my-svg .edgeLabel p{background-color:white;}#my-svg .edgeLabel rect{opacity:0.5;background-color:white;fill:white;}#my-svg .labelBkg{background-color:rgba(255, 255, 255, 0.5);}#my-svg .cluster rect{fill:hsl(0, 0%, 98.9215686275%);stroke:#707070;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(-160, 0%, 93.3333333333%);border:1px solid #707070;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#000000;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:white;text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:white;padding:2px;}#my-svg .icon-shape .label rect,#my-svg .image-shape .label rect{opacity:0.5;background-color:white;fill:white;}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg .node .neo-node{stroke:#999;}#my-svg [data-look="neo"].node rect,#my-svg [data-look="neo"].cluster rect,#my-svg [data-look="neo"].node polygon{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node path{stroke:url(#my-svg-gradient);stroke-width:1px;}#my-svg [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node .neo-line path{stroke:#999;filter:none;}#my-svg [data-look="neo"].node circle{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node circle .state-start{fill:#000000;}#my-svg [data-look="neo"].icon-shape .icon{fill:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].icon-shape .icon-neo path{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}renderadapterswriteledger writerledger-writer.ts · 4 KiB capharvesterharvester.ts.scaffold/activity.jsonlappend-only · redactedgit · priority 3gh · priority 2mmr · priority 1state · priority 4tests · priority 5pipeline_docs · probebeads · probeaudit_history · probesynthesizercomposeReplay ·correlation_id dedup ·source priorityengine APIrunAudit · runProgresschecks lenses A–Iregistry · runner--fix loopplan · dispatch · verifyterminal · ANSImarkdown + sidecardashboard fragmentmmr-findings

The boxes labelled probe (pipeline_docs, beads, audit_history) contribute availability checks — and, for audit_history, trend data — but do not emit events into the replay timeline. The five remaining adapters (git, gh, mmr, state, tests) synthesize replay events that flow into the synthesizer.

The ledger

Every durable observation is one JSON object on one line of .scaffold/activity.jsonl. Writes are append-only and lock-guarded so parallel worktrees never corrupt the file; each event is capped at 4 KiB; and secrets and home-directory paths are scrubbed both when the event is written and again when output is rendered.

How a write happens

The nine event types

Each event carries a common envelope — event_id (ULID), worktree_id, actor_label, branch, task_id (string or null), type, ts (ISO-8601 UTC) — plus a type-specific payload. The CLI verb is scaffold observe event <type> ….

event_typepayload fieldskey constraints
task_claimedtask_title*, story_id, wave, unplannedif task_id is null, unplanned must be true
task_completedoutcome*, pr_number, commit_shaoutcome ∈ {pr_submitted, dropped, superseded}; pr_number required if pr_submitted
decision_recordedkey, summary, affects* (string[]), linkssummary ≤ 500 chars; consumed by Lens G
blocker_hitkind, summarykind ∈ {dependency, ambiguity, external, environment}; summary ≤ 500 chars
blocker_resolvedsummary, references (string[])closes a prior blocker_hit
pr_openedpr_number*positive integer
progress_heartbeatnote*note ≤ 200 chars; resets task_stale clock
finding_acknowledgedfinding_id, status, notetask_id must be null; status ∈ {acknowledged, open}; written by observe ack
knowledge_gap_signaltopic, source, project_id*, step_name, agent_excerpttopic kebab-case ≤ 80 chars; consumed by Lens I

* marks required fields. Allow-lists live in EVENT_PAYLOAD_KEYS at src/observability/engine/event-schemas.ts:3-13.

Naming note. The PR-opened event is pr_opened (past tense) in code, not pr_open as the CLAUDE.md prose abbreviates it. The ledger never emits pr_open.

Building a scaffold observe event command

--branch is required; --task-id is optional (it correlates the event to a claimed task) but must be omitted for finding_acknowledged (whose task_id must be null), and a task_claimed without one must pass --unplanned true. Payload keys are passed as --kebab-case flags, snake-cased before validation; unknown keys are dropped.

scaffold observe event task_claimed --branch <branch> --task-id <id> \
  --task-title "<title>" [--story-id <id>] [--wave <n>] [--unplanned true]
scaffold observe event task_completed --branch <branch> --task-id <id> \
  --outcome pr_submitted --pr-number <n> [--commit-sha <sha>]
scaffold observe event decision_recorded --branch <branch> --task-id <id> \
  --key <key> --summary "<text>" --affects "src/**,docs/**" [--links "ADR-1,ADR-2"]
scaffold observe event blocker_hit --branch <branch> --task-id <id> \
  --kind dependency --summary "<text>"
scaffold observe event blocker_resolved --branch <branch> --task-id <id> \
  --summary "<text>" --references "ref1,ref2"
scaffold observe event pr_opened --branch <branch> --task-id <id> --pr-number <n>
scaffold observe event progress_heartbeat --branch <branch> --task-id <id> --note "<text>"
scaffold observe event finding_acknowledged --branch <branch> \
  --finding-id <id> --status acknowledged [--note "<text>"]
scaffold observe event knowledge_gap_signal --branch <branch> \
  --topic <kebab-slug> --source agent_search --project-id <sha256> \
  [--step-name <name>] [--agent-excerpt "<text>"]

finding_acknowledged is normally written by scaffold observe ack, not by hand.

Value coercion (src/cli/commands/observe.ts): --pr-number is parsed numeric (a non-number is dropped); --unplanned is boolean (true ⇒ true, anything else ⇒ false); --affects, --links, --references are comma-split into arrays; everything else stays a string. Exit codes: 0 written · 2 validation failed / payload > 4 KiB · 3 other error.

Redaction — write time and render time

Redaction runs twice. Write-time redaction (recursive over the payload tree) scrubs the event before it lands on disk; render-time redaction (redactEngineOutput / redactRendered) scrubs string values again before any report is shown, catching anything synthesized after the fact.

Order matters: a bare ghp_… matches the GitHub-token pattern, but inside a token=… pair the key/value rule wins first — so token=ghp_… redacts to [REDACTED:kv-secret], not [REDACTED:github-token] (leftmost-match alternation, src/observability/engine/redact.ts:16).

patternmatchesreplacement
AWS keyAKIA[0-9A-Z]{16}[REDACTED:aws-key]
GitHub tokengh[pousr]_[A-Za-z0-9]{36,}[REDACTED:github-token]
key/value secretkey matches secret|token|password|api[_-]?key[REDACTED:kv-secret] (key + separator preserved)
sensitive object keyobject key matches the same patternall descendant primitive values masked
home path/Users/<name>, /home/<name>, C:\Users\<name>~ (Windows keeps the drive: C:\~)

A redacted decision_recorded event on disk (one line, shown pretty-printed):

{
  "event_id":    "01H5ZABCDEFGHJKMNPQRSTVWX",
  "worktree_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "actor_label": "agent-alice",
  "branch":      "alice-feat/cache",
  "task_id":     "T-014",
  "type":        "decision_recorded",
  "ts":          "2026-05-04T12:00:00Z",
  "payload": {
    "key":     "cache-backend",
    "summary": "Switched to Redis; token=[REDACTED:kv-secret]",
    "affects": ["~/repo/src/cache/**"],
    "links":   ["ADR-021"]
  }
}

Worktree identity

Each worktree gets a stable identity on first write — .scaffold/identity.json holds worktree_id (a UUID), worktree_label (derived from the directory name, or primary), and created_at (src/observability/engine/identity.ts). The id is what lets the harvester tell one worktree's events from another's, and what stamps every event's worktree_id.

Adapters

The ledger only holds what agents chose to record. Adapters fill in the rest by synthesizing events from the surrounding tools — commits, PRs, MMR jobs, pipeline state, test runs. There are eight adapters (AdapterId at src/observability/engine/types.ts:69), but only five emit events into the timeline; the other three are availability probes / trend helpers.

Five emit, three probe. git, gh, mmr, state, tests each implement replayEvents() and appear in the source-priority chain. pipeline_docs (probes 9 planning-doc roles; 5 of them are the canonical-required set that gates available vs degraded), beads (task-tracker linkage), and audit_history (reads docs/audits/*.json for trends + lens-skip streaks) do not emit replay events.

adaptersource of truthemitscorrelation_id / dedup
gitgit log, git worktree list (30 s timeout)commitnone (null); per-SHA seen-set within the window
ghgh pr list --state {open,merged,closed} (needs auth)pr_opened, pr_merged, pr_closedpr:<n>:<state> — dedups against the ledger's own PR events
mmr.mmr/jobs/*/result.json (replay keyed on each job's completed_at)job_completednone
state.scaffold/state.json + services/*/state.jsonstep_completed, step_in_progressnone; per-step timestamp, mtime fallback
tests.scaffold/last-test-run.jsontest_run_completed, test_run_failednone
pipeline_docscandidate docs/*.md artifact paths— (probe: available / degraded / unavailable)n/a
beads.beads/ + bd CLI (≥ v1.0.0)— (task claim ↔ event-id linkage)n/a
audit_historydocs/audits/*.json (≤ 100 scanned)— (trends + lensSkippedStreaks)n/a

Source priority

When two events describe the same thing (same correlation_id), the synthesizer keeps the one from the most authoritative source. The order is fixed in SOURCE_PRIORITY at src/observability/engine/synthesizer.ts:254 — lower number wins, ties broken by earliest ts:

ranksourcewhy it wins
0ledgerthe agent said so — first-party intent
1mmrauthoritative build verdict
2ghGitHub PR state of record
3gitlocal commit history
4statepipeline step status
5testscached test run

The three probe-only adapters are intentionally absent from this table — they never produce a replay event to dedup.

The nine-lens audit

The audit reads the planning docs (PRD, stories, tech-stack, standards, design system, plan, playbook) into a document graph, then runs a set of independent lenses over the graph, the code, the ledger and adapter data. The design spec calls it the "eight-lens audit" (A–H); a ninth lens — I-knowledge-gaps — joined the suite after v3.26.0.

8 in the spec, 9 in the code. docs/superpowers/specs/2026-04-30-build-observability-design.md titles §3 "The Eight Audit Lenses" and its LENS_REGISTRY stops at H. The shipped src/observability/engine/checks/registry.ts:44 adds I-knowledge-gaps. Treat Lens I as the ninth lens that joined the suite — documented here as a first-class member.

Scope, order and skips

What feeds the doc-graph

Before any lens runs, the planning docs and the working tree are parsed into a single graph (src/observability/engine/doc-graph/index.ts). Every lens's "Reads:" line refers to nodes and edges built here:

sourceparser / detectorgraph nodes & edges
PRDparseFeaturesfeatures (with priority)
user-storiesparseStoriesstories, acceptance_criteria, feature_to_story
tech-stackparseSanctionedComponentscomponents (with layer)
coding-standards + tdd-standardsparseRulesrules
design-systemparseDesignTokenstokens (priority, category)
implementation-planparsePlanTasksplan_tasks, story_to_plan_task
implementation-playbookparsePlaybookTasksplaybook_tasks, playbook_task_to_story
decision docs (decisions.jsonl, docs/decisions/*.md)parseDecisionsdecisions
working treediscoverTests, discoverFilestests (with last_status), files, ac_to_test
source filesdetectCss/JsxTokenUses, detectComponentUsesfile_to_token_use, file_to_component_use

All nine at a glance

lensquestionscopeseveritiesships in
A-tddAre tests being skipped?codeP0 P1#331
B-ac-coverageIs every AC covered by a passing test?codeP0 P1#331
C-standardsAre coding standards being violated?codeP0–P3#332
D-stackUnsanctioned deps / out-of-layer use?codeP0 P1#332
E-designAd-hoc values bypassing design tokens?codeP0 P1#332
F-scopeHigh-priority work without a story/plan?codeP0 P1 P2#332
G-decisionsDecisions made but not documented?codeP0 P1 P2#332
H-cross-docAre the docs internally consistent?docsP0 P1 P2#331 · #336
I-knowledge-gapsRepeated needs with no KB entry?docsP1 P2#397 · #406

Lens by lens

A-tdd — TDD violations (src/observability/checks/lens-a-tdd.ts). Are there skipped tests? Reads: the doc-graph's discovered tests (with last_status), ACs, stories, and ac_to_test edges. Tunes: none. Severity: P0 skipped test on a must-priority story; P1 otherwise.

B-ac-coverage — AC completion (src/observability/checks/lens-b-ac-coverage.ts). Is every acceptance criterion covered by a passing test? Reads: ACs, tests, ac_to_test edges; optional tests adapter for live status. Tunes: none. Severity: P0 AC's test is failing; P1 AC has no test edge or unknown status.

C-standards — coding-standards drift (src/observability/checks/lens-c-standards.ts). Are coding standards being violated across files? Reads: graph rules + files. Tunes: lenses.C-standards.rule_overrides (per-rule severity), escalation_threshold (default 5 — more than N violations of a rule escalates to P1). (enforce_via_linter is accepted in config but not read by the lens.) Severity: rule-specific P0P3.

D-stack — tech-stack drift (src/observability/checks/lens-d-stack.ts). Unsanctioned dependencies, or components used outside their layer? Reads: file_to_component_use edges, components (with layer), and decision_recorded ledger events (which can sanction a dependency). Tunes: lenses.D-stack.path_to_layer (default 7 glob→layer mappings). Severity: P0 unsanctioned dependency; P1 out-of-layer use.

E-design — design-system drift (src/observability/checks/lens-e-design.ts). Are ad-hoc design values bypassing tokens? Reads: file_to_token_use edges, tokens (with priority + category). Tunes: lenses.E-design.ad_hoc_token_threshold (default 3), ui_glob (default src/components/**/*.tsx, consumed by the doc-graph builder). Severity: P1 ad-hoc count over threshold; P0 must-priority token category bypassed.

F-scope — missing scope (src/observability/checks/lens-f-scope.ts). Is high-priority work missing stories/plans, or are planned stories untouched? Reads: features, stories, plan-tasks and their edges; task_claimed ledger events; optional state adapter. Tunes: lenses.F-scope.untouched_story_grace_hours (default 168 = 7 days). Severity: P0 must-feature without story/plan; P1 should-feature; P2 planned-but-untouched story past grace.

G-decisions — undocumented decisions (src/observability/checks/lens-g-decisions.ts). Decisions made in the ledger/commits but not documented (or vice versa)? Reads: graph decisions, decision_recorded events, recent git commits (keyword scan), and D-stack's findings. Tunes: none effective (see note). Severity: P0 unsanctioned dep from D with no decision; P1 ledger↔doc mismatch; P2 decision-keyword commit with no event/doc.

Inert override. The spec advertises a lenses.G-decisions.keywords_file override and ships src/observability/checks/data/decision-keywords.txt, but loadKeywords() hard-codes its keyword list and never reads the config key or the file. The override and the bundled file are currently inert.

H-cross-doc — cross-doc inconsistency (src/observability/checks/lens-h-cross-doc.ts). Are features, stories, plans, playbook and decisions internally consistent? Reads: the full structural doc-graph + unresolved globs. Severity: P0 decision supersedes a non-existent decision, or a must-priority story uncovered by plan/playbook; P1 feature with no story / orphan story / should-priority uncovered story; P2 plan task not in playbook, unresolved glob.

Under --profile=full only, Lens H runs three extra checks via an LLM dispatcher (all silently return [] if the dispatcher fails, so the audit never breaks):

Security — the LLM dispatcher command is hard-coded. Lens H passes the literal claude -p (src/observability/checks/lens-h-cross-doc.ts:164) into the shared dispatcher (src/observability/engine/llm-dispatcher.ts, which runs it through a fixed subprocess). The command is deliberately not project-config-overridable, because the audit may run against untrusted third-party repos and a repo-controlled command would be arbitrary code execution. Only llm.timeout_s (and llm.parallel_checks) are configurable. Contrast the --fix dispatcher, which is configurable — see The --fix flow.

I-knowledge-gaps — the ninth lens (src/observability/checks/lens-i-knowledge-gaps.ts). What topics have agents repeatedly needed (over a 90-day window) with no knowledge-base entry? Reads: knowledge_gap_signal ledger events + synthetic signals scanned from tasks/lessons.md (src/observability/checks/lens-i-lessons-scanner.ts). Severity: P1 ≥5 signals from ≥3 projects; P2 ≥3 signals from ≥2 projects.

Lens I — --knowledge-root and existing-entry suppression. The lens resolves the knowledge directory in three tiers (src/observability/knowledge-index.ts): the --knowledge-root CLI flag → lenses.I-knowledge-gaps.knowledge_root in config → auto-detect by walking up for content/knowledge/. If a gap's topic slug already exists in the resolved knowledge index, the finding is suppressed (no P1/P2). If no root resolves, suppression is disabled and the lens warns once per audit. Shipped in #397 (lens + lessons scanner) and #406 (--knowledge-root + suppression).

The observe audit CLI

One command runs the lenses and reports findings. Its exit code is the verdict gate: 0 on a non-blocked verdict (or any mmr-findings run), 1 when blocked or when a --fix pass still failed, 3 on an audit error.

Every flag

flagvalues / defaulteffect
--scopecode · docs · all (default all)which lens set runs
--lens <id>repeatablerun exactly these lenses, overriding scope
--profilefast (default) · fullfull enables Lens H's LLM-graded sub-checks
--fixflagafter auditing, dispatch the fix loop for blocking findings
--fix-thresholdP0P3override the blocking cutoff for this run
--output=<path>pathoverride markdown destination (sidecar unaffected)
--renderdashboard-fragment-auditemit HTML to stdout; skip markdown + sidecar
--output-modemmr-findingsemit MMR Finding JSON to stdout (always exit 0); skip markdown + sidecar
--knowledge-root=<path>pathtier-1 knowledge dir for Lens I suppression
--json · --mask-paths · --show-acknowledged · --since-hoursflags / Nraw JSON output · redact paths · include acked findings · activity window
# A docs-only full audit including LLM-graded checks
scaffold observe audit --scope docs --profile full

# Audit, then dispatch a fix agent for each blocking finding
scaffold observe audit --fix --fix-threshold P1

# Lens I only, with an explicit knowledge root for suppression
scaffold observe audit --lens I-knowledge-gaps --knowledge-root content/knowledge

Verdict taxonomy

The engine computes exactly three verdicts (Verdict at src/observability/engine/types.ts:6):

verdictwhenexit
passno blocking findings, no skipped lenses0
degraded-passno blocking findings, but ≥1 lens skipped (missing required adapter)0
blocked≥1 open finding at or above fix_threshold1

"needs-user-decision" is not an engine verdict. The code-review workflow has a needs-user-decision outcome, but that is an MMR review verdict, not one the audit engine emits. The audit engine's universe is exactly the three above.

fix_threshold

A finding is blocking when its status is open and its severity is at or above the threshold (src/observability/engine/checks/fix-threshold.ts). Resolution order: --fix-threshold flag → .mmr.yaml (audit_fix_threshold / fix_threshold) → default P2. The threshold never hides findings — every finding is still reported; it only decides which ones count toward blocked.

How the counts are computed (src/observability/engine/checks/findings-aggregator.ts): blocking counts a finding only when status === 'open' and its severity is at or above fix_threshold; acknowledged counts only findings silenced by a finding_acknowledged ledger event (latest event per id wins); skipped findings (missing-adapter lenses) ignore acks entirely. The renderers' (blocking · ack · total) line is this tally.

Companion verb — scaffold observe ack

Acknowledge (or reopen) a finding by ID prefix; it writes a finding_acknowledged event the aggregator reads on the next audit.

arg / flageffect
<prefix-or-id>matched against all finding IDs in docs/audits/*.json; an ambiguous prefix exits 2
--status acknowledged|openacknowledged (default) silences it; open reopens it
--note "…"optional rationale recorded on the event

Branch is auto-derived (git rev-parse --abbrev-ref HEAD, fallback main); the event's task_id is null.

Exit codes across observe verbs

verb0123
auditnot blocked, or any mmr-findings runblocked, or --fix left a blockeraudit error (130 on SIGINT mid---fix, snapshot restored)
eventwrittenvalidation failed / payload > 4 KiBother error
ackwrittenno match / ambiguous prefixno audit sidecars / write error
progressokmarkdown/sidecar write error (with --output)
harvestharvested / recoveredno identity.json, or primary root is itself a worktree

Progress, replay & stall

scaffold observe progress shows what's in flight. With --replay it fuses the ledger with synthesized git/gh/mmr/state/tests events into one timeline; when scaffold observe progress runs it also evaluates stall detection and surfaces a "Needs Attention" panel (suppress with --no-stall-check).

Fusion — how the timeline is built

The synthesizer's composeReplay (src/observability/engine/synthesizer.ts:294) converts each ledger Event to a ReplayEvent, concatenates the adapter events, filters to the time window, then dedups:

So a PR that the agent recorded (pr_opened in the ledger) and that the gh adapter also reports collapses to the single ledger event, because ledger (0) outranks gh (2).

Stall detection — the six signals

When scaffold observe progress runs (unless --no-stall-check) the engine checks for staleness and builds needs_attention (src/observability/engine/stall.ts). The type declares six signals; five fire todaypr_review_stale is reserved (deferred pending a per-PR correlation_id on the mmr adapter, src/observability/engine/stall.ts:119). Thresholds accept 'Nh', 'Nd', or 'off' under stall.*:

signalfires whenconfig key · default
task_staletask claimed, no commit/heartbeat/completion sincestall.task_stale · 4h
pr_staleledger pr_opened recorded, PR not merged/closed sincestall.pr_stale · 48h
blocker_unaddressedblocker_hit with no blocker_resolvedstall.blocker_unaddressed · 2h
audit_findings_unresolvedopen finding past threshold age, severity at or above fix_thresholdstall.audit_findings_unresolved · 24h
lens_skipped_repeatedlya lens skipped on ≥3 consecutive auditshard-coded streak ≥ 3 (no config key)
pr_review_stale (reserved)— not yet emitted —stall.pr_review_stale · 24h (defined, unused)

Flags

flageffect
--replayinclude the fused timeline in the output
--no-stall-checksuppress the "Needs Attention" surface (empty needs_attention)
--render=dashboard-fragmentemit the progress HTML panel to stdout; skip markdown + sidecar
--output=<path> · --json · --since-hours Noverride markdown path · raw JSON · activity window (default 24)

Stall thresholds are configurable under stall: in .scaffold/observability.yaml — see Config reference.

Phase-boundary triggers

Completing a planning document should re-check the docs for drift — automatically, but never blocking the build. When a pipeline step at a phase boundary is marked complete, the state manager fires a docs audit and prints a one-line summary.

The hook

StateManager.markCompleted() is async (src/state/state-manager.ts:175): it saves state, then awaits runPhaseAudit({ primaryRoot, step }) for every completed step — the phase-boundary gate lives inside runPhaseAudit, which returns a no-op result unless the step is one of the six boundaries (src/observability/engine/phase-subsets.ts): user-stories, tech-stack, coding-standards, design-system, implementation-plan, implementation-playbook.

#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#000000;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#666;stroke:#666;}#my-svg .marker.cross{stroke:#666;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#000000;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#000000;color:#000000;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#eee;stroke:#999;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#666!important;stroke-width:0;stroke:#666;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#666;stroke-width:1px;}#my-svg .flowchart-link{stroke:#666;fill:none;}#my-svg .edgeLabel{background-color:white;text-align:center;}#my-svg .edgeLabel p{background-color:white;}#my-svg .edgeLabel rect{opacity:0.5;background-color:white;fill:white;}#my-svg .labelBkg{background-color:rgba(255, 255, 255, 0.5);}#my-svg .cluster rect{fill:hsl(0, 0%, 98.9215686275%);stroke:#707070;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(-160, 0%, 93.3333333333%);border:1px solid #707070;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#000000;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:white;text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:white;padding:2px;}#my-svg .icon-shape .label rect,#my-svg .image-shape .label rect{opacity:0.5;background-color:white;fill:white;}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg .node .neo-node{stroke:#999;}#my-svg [data-look="neo"].node rect,#my-svg [data-look="neo"].cluster rect,#my-svg [data-look="neo"].node polygon{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node path{stroke:url(#my-svg-gradient);stroke-width:1px;}#my-svg [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node .neo-line path{stroke:#999;filter:none;}#my-svg [data-look="neo"].node circle{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node circle .state-start{fill:#000000;}#my-svg [data-look="neo"].icon-shape .icon{fill:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].icon-shape .icon-neo path{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}state already saved auditerror never rolls it backscaffold completemarkCompleted (async)saveState setcompleted_atrunPhaseAuditH-cross-doc · docs · fastdocs/audits/id.md + .jsonPhaseAuditResult stdoutlinenon-gating

The audit is fixed to H-cross-doc, scope=docs, profile=fast (src/observability/engine/phase-audit.ts). It is non-gating: markCompleted catches any audit error and returns a result object, so a state transition never fails because of the audit.

The stdout line:

# normal
[audit] 3 findings (1 blocking, verdict=blocked) — see docs/audits/audit-…-fast-lens-H-cross-doc-….md
# detached (fire-and-forget)
[audit] dispatched in background (step: tech-stack)
# timed out
[audit] timed out after 60500ms — partial findings may not be written

Config & timestamps

keydefaulteffect
phase_audit.enabledtruemaster switch
phase_audit.timeout_s60race budget; over → timed_out
phase_audit.detachedfalsefire-and-forget when true

Step state (src/types/state.ts) now carries completed_at (set once, on first completion) and in_progress_started_at (set on first entry to in-progress). The state adapter's replayEvents prefers these per-step timestamps and only falls back to the file's mtime when they're absent — so the timeline reflects when each step actually transitioned, not when the file was last written.

MMR doc-conformance channel

The audit plugs into multi-model review as a built-in channel. scaffold observe audit --output-mode=mmr-findings emits findings in MMR's Finding shape (src/observability/renderers/mmr-findings.ts), always exiting 0 so the dispatcher captures stdout regardless of verdict. Only open findings are emitted (acknowledged and skipped are dropped).

Finding translation

The composite location<source_doc>::<lens_id>::<short_id> — gives each finding a stable identity across audit runs, so MMR can track the same defect over time.

MMR fieldderived from
severityfinding.severity (P0–P3)
location${source_doc || '(no-source-doc)'}::${lens_id}::${id.slice(0,8)}
description[doc-conformance/${lens_id}] ${title} + optional — ${description}
suggestionfix_hint?.prompt ?? fix_hint?.target ?? ''
categoryliteral 'doc-conformance'

A Lens-B engine Finding like this:

{
  "id": "3a8c1f0211223344", "lens_id": "B-ac-coverage", "severity": "P0",
  "title": "AC has failing test", "description": "Test refresh.spec.ts is failing.",
  "source_doc": "docs/user-stories.md#user-auth-1",
  "fix_hint": { "prompt": "Re-enable the test", "target": "src/auth/test.spec.ts" }
}

translates to this MMR Finding:

{
  "location":    "docs/user-stories.md#user-auth-1::B-ac-coverage::3a8c1f02",
  "severity":    "P0",
  "description": "[doc-conformance/B-ac-coverage] AC has failing test — Test refresh.spec.ts is failing.",
  "suggestion":  "Re-enable the test",
  "category":    "doc-conformance"
}

Enabling it

The doc-conformance channel is disabled by default. Enable it per-project in .mmr.yaml or for a single run with --channels=doc-conformance; if it's globally enabled you can opt a project out via channels_disabled: ["doc-conformance"].

mmr review --channels=doc-conformance --diff <(git diff main...HEAD) --sync --format json

Under the hood the built-in channel runs scaffold observe audit --profile=full --scope=all --output-mode=mmr-findings and parses its stdout. See the MMR guide (../mmr/index.md) for the channel architecture this plugs into.

The --fix flow

scaffold observe audit --fix doesn't just report blocking findings — it dispatches an agent to fix each one, verifies the fix with a single-lens re-audit, and writes a post-fix report. The loop is abort-aware: an interrupt un-stages what the loop staged and re-applies the original stash (see the limitation in Abort safety).

#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#000000;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#666;stroke:#666;}#my-svg .marker.cross{stroke:#666;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#000000;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#000000;color:#000000;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#eee;stroke:#999;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#666!important;stroke-width:0;stroke:#666;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#666;stroke-width:1px;}#my-svg .flowchart-link{stroke:#666;fill:none;}#my-svg .edgeLabel{background-color:white;text-align:center;}#my-svg .edgeLabel p{background-color:white;}#my-svg .edgeLabel rect{opacity:0.5;background-color:white;fill:white;}#my-svg .labelBkg{background-color:rgba(255, 255, 255, 0.5);}#my-svg .cluster rect{fill:hsl(0, 0%, 98.9215686275%);stroke:#707070;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(-160, 0%, 93.3333333333%);border:1px solid #707070;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#000000;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:white;text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:white;padding:2px;}#my-svg .icon-shape .label rect,#my-svg .image-shape .label rect{opacity:0.5;background-color:white;fill:white;}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg .node .neo-node{stroke:#999;}#my-svg [data-look="neo"].node rect,#my-svg [data-look="neo"].cluster rect,#my-svg [data-look="neo"].node polygon{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node path{stroke:url(#my-svg-gradient);stroke-width:1px;}#my-svg [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node .neo-line path{stroke:#999;filter:none;}#my-svg [data-look="neo"].node circle{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node circle .state-start{fill:#000000;}#my-svg [data-look="neo"].icon-shape .icon{fill:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].icon-shape .icon-neo path{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}1 · auditcollect findings + verdict2 · buildFixPlankeep open & threshold;order by severity then lens3 · dispatchFixAgentsubprocess, prompt onstdin4 · verifyre-run that one lens; retry 35 · post-fix reportdocs/audits/id-postfix.{md,json}

The five phases

Abort safety

Before dispatching, captureSnapshot (src/observability/engine/abort-snapshot.ts) records a git stash create SHA plus the set of pre-existing staged files. Newly staged paths are tracked per attempt. On SIGINT, restoreSnapshot un-stages the paths the loop recorded as newly staged and re-applies the original stash tree. Limitation: edits the dispatcher made but that the loop did not record as staged are not actively reverted and may need manual cleanup — recovery is scoped to the recorded staged paths plus the stash, not a guaranteed full reset.

Config — the fix: block

keydefaulteffect
fix.dispatcher_commandclaude -pthe agent command (configurable — see note)
fix.timeout_s300per-finding subprocess budget
fix.per_finding_max_attempts3verify retries before giving up

The fix dispatcher IS configurable — unlike Lens H's. fix.dispatcher_command is read from project config because the fix loop only runs against the maintainer's own repo, not untrusted third-party code (src/observability/engine/fix-agent-dispatcher.ts). This is the opposite of the Lens H LLM dispatcher, which hard-codes its command for untrusted-repo safety. CLAUDE.md's --fix note describing the dispatcher as "not project-config-overridable" conflates the two.

Exit code: 1 if any blocking finding survived the loop, else 0.

Harvest, recover & teardown

A worktree's ledger is local to that worktree. Harvest copies it into the primary repo's archive before the worktree is removed; recover sweeps up ledgers whose worktrees vanished without being harvested; teardown does the whole dance in one command.

#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#000000;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#666;stroke:#666;}#my-svg .marker.cross{stroke:#666;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#000000;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#000000;color:#000000;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#eee;stroke:#999;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#666!important;stroke-width:0;stroke:#666;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#666;stroke-width:1px;}#my-svg .flowchart-link{stroke:#666;fill:none;}#my-svg .edgeLabel{background-color:white;text-align:center;}#my-svg .edgeLabel p{background-color:white;}#my-svg .edgeLabel rect{opacity:0.5;background-color:white;fill:white;}#my-svg .labelBkg{background-color:rgba(255, 255, 255, 0.5);}#my-svg .cluster rect{fill:hsl(0, 0%, 98.9215686275%);stroke:#707070;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(-160, 0%, 93.3333333333%);border:1px solid #707070;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#000000;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:white;text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:white;padding:2px;}#my-svg .icon-shape .label rect,#my-svg .image-shape .label rect{opacity:0.5;background-color:white;fill:white;}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg .node .neo-node{stroke:#999;}#my-svg [data-look="neo"].node rect,#my-svg [data-look="neo"].cluster rect,#my-svg [data-look="neo"].node polygon{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node path{stroke:url(#my-svg-gradient);stroke-width:1px;}#my-svg [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node .neo-line path{stroke:#999;filter:none;}#my-svg [data-look="neo"].node circle{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node circle .state-start{fill:#000000;}#my-svg [data-look="neo"].icon-shape .icon{fill:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].icon-shape .icon-neo path{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}harvestrecover (worktree gone)worktree ledger.scaffold/activity.jsonlactive-archiveactivity-archive/active/id.jsonlmonthly archiveactivity-archive/YYYY-MM.jsonl

Commands

Empty ledgers are fine. If a worktree never wrote an activity.jsonl, harvest is a clean no-op and teardown proceeds.

Renderers

One EngineOutput, several surfaces. The terminal renderer is for humans at a prompt; the markdown renderer writes a durable report plus a machine-readable JSON sidecar; the dashboard-fragment renderer emits an HTML panel that scripts/generate-dashboard.sh injects at named anchors.

Default audit/progress runs write both a markdown report and a JSON sidecar: audits to docs/audits/<id>.{md,json}, progress to docs/build-status/<id>.{md,json} (src/observability/renderers/sidecar.ts). --render and --output-mode bypass the files and print to stdout instead.

The same blocked audit renders three ways. As terminal ANSI:

build observability — audit
(profile: fast · scope: all)

  [P0] AC has failing test
       lens: B-ac-coverage
       fix:  Re-enable the test

verdict: blocked
(blocking 1 · ack 0 · total 1)

As markdown (docs/audits/<id>.md):

# Build Observability — Audit

**Verdict:** blocked

## Summary
1 findings · 1 blocking (at or above P2) · 0 acknowledged · 0 skipped lenses

### [P0] B-ac-coverage — AC has failing test
`3a8c1f02` · *source:* `…#user-auth-1` · *confidence:* high

As a dashboard fragment, an HTML <section id="build-audit" data-verdict="blocked"> panel injected into the dashboard.

rendererfiledestinationnotes
terminalsrc/observability/renderers/terminal.tsstdoutANSI, "Needs Attention" banner + timeline
markdownsrc/observability/renderers/markdown.tsdocs/audits/ · docs/build-status/full findings, evidence, fix hints
sidecar JSONsrc/observability/renderers/sidecar.tsalongside the markdownredacted EngineOutput; read by the audit_history adapter
dashboard fragmentsrc/observability/renderers/dashboard.tsstdout → dashboardsections build-progress / build-audit injected by scripts/generate-dashboard.sh
mmr-findingssrc/observability/renderers/mmr-findings.tsstdoutMMR Finding shape

Config reference

All tunables live in .scaffold/observability.yaml. The file is optional — when absent, DEFAULT_CONFIG applies wholesale (src/observability/engine/checks/observability-config.ts). A malformed or unreadable file silently falls back to defaults. Merging is a deep merge where objects merge key-by-key but arrays and scalars replace wholesale — so setting disabled_lenses or rule_overrides overwrites the default rather than extending it.

The shipped example file is misleading. The repo's .scaffold/observability.yaml contains only knowledge_freshness.link_check.skip and a comment claiming "other observability features read configuration from elsewhere." In fact the code reads many more keys — the example file simply omits them because they all have working defaults. The defaults documented here come from the code, which is ground truth.

keytypedefaultread by
lenses.C-standards.enforce_via_linterbooleantrue(accepted, not read)
lenses.C-standards.rule_overridesmap<rule, P0–P3>{}lenses · Lens C
lenses.C-standards.escalation_thresholdnumber5lenses · Lens C
lenses.D-stack.path_to_layer{glob,layer}[]7 default mappingslenses · Lens D
lenses.E-design.ad_hoc_token_thresholdnumber3lenses · Lens E
lenses.E-design.ui_globglobsrc/components/**/*.tsxlenses · doc-graph builder
lenses.F-scope.untouched_story_grace_hoursnumber168lenses · Lens F
lenses.G-decisions.keywords_filepath— (inert)lenses · (no reader)
lenses.H-cross-doc.skip_phase_subsetsstring[]— (inert)lenses · (no reader)
lenses.I-knowledge-gaps.knowledge_rootpathauto-detectlenses · Lens I
disabled_lensesstring[][]lenses · runner
phase_audit.enabledbooleantruephase_audit
phase_audit.timeout_snumber60phase_audit
phase_audit.detachedbooleanfalsephase_audit
stall.task_stale'Nh'·'Nd'·'off'4hstall
stall.pr_stale'Nh'·'Nd'·'off'48hstall
stall.pr_review_stale'Nh'·'Nd'·'off'24h (reserved)stall (signal not yet emitted)
stall.blocker_unaddressed'Nh'·'Nd'·'off'2hstall
stall.audit_findings_unresolved'Nh'·'Nd'·'off'24hstall
llm.timeout_snumber60llm · Lens H dispatcher
llm.parallel_checksbooleanfalsellm · Lens H
fix.dispatcher_commandshell commandclaude -pfix · dispatcher
fix.timeout_snumber300fix
fix.per_finding_max_attemptsnumber3fix
knowledge_freshness.link_check.skipstring[][]knowledge-freshness (separate loader)

No llm.dispatcher_command, no F-scope.wave_budget. Two keys you might expect are deliberately absent. The Lens H LLM command is hard-coded (security) — there is no llm.dispatcher_command key. And there is no wave/phase-budget key at all: F-scope reads only untouched_story_grace_hours from this file, so the "wave budget" CLAUDE.md implies does not exist in the config.

Example:

lenses:
  C-standards:
    enforce_via_linter: true
    rule_overrides:
      no-console: P1
  E-design:
    ad_hoc_token_threshold: 5
disabled_lenses: []
phase_audit:
  enabled: true
  timeout_s: 60
stall:
  task_stale: '6h'
fix:
  per_finding_max_attempts: 3
# shared with the knowledge-freshness subsystem
knowledge_freshness:
  link_check:
    skip:
      - platform.openai.com

Where it lives

The whole system lives under src/observability/, plus the state-manager hook and two shell scripts.

filepurpose
src/observability/engine/ledger-writer.tsvalidate → redact → 4 KiB check → locked append
src/observability/engine/event-schemas.tsper-type payload allow-lists + validation
src/observability/engine/redact.tswrite-time + render-time secret/path scrubbing
src/observability/engine/identity.tsworktree identity.json
src/observability/engine/harvester.tsharvest worktree ledger + --recover rotation
src/observability/engine/synthesizer.tscomposeReplay: fuse + correlation_id dedup + source priority
src/observability/engine/api.tsrunAudit / runProgress; scope→lens; verdict
src/observability/engine/stall.tsthe stall signals + Needs-Attention surface
src/observability/engine/phase-audit.tsrunPhaseAudit + formatPhaseAuditLine
src/observability/engine/phase-subsets.tsPHASE_BOUNDARY_STEPS + isPhaseBoundary
src/observability/engine/fix-flow.tsthe five-phase --fix loop
src/observability/engine/fix-plan.tsbuildFixPlan: filter + order findings
src/observability/engine/fix-agent-dispatcher.tsconfigurable subprocess fix dispatcher
src/observability/engine/abort-snapshot.tscapture/restore git stash on SIGINT
src/observability/engine/llm-dispatcher.tshard-coded claude -p for Lens H full profile
src/observability/engine/types.tsVerdict, AdapterId, EngineOutput, Finding
src/observability/engine/checks/registry.tsLENS_REGISTRY (A–I) + implementations
src/observability/engine/checks/fix-threshold.tsresolve fix_threshold (default P2)
src/observability/engine/checks/findings-aggregator.tstally blocking/acked; compute verdict
src/observability/engine/checks/observability-config.tsDEFAULT_CONFIG + deep-merge loader
src/observability/engine/doc-graph/index.tsbuild the doc graph from planning docs
src/observability/checks/lens-a-tdd.tsA · skipped tests
src/observability/checks/lens-b-ac-coverage.tsB · AC ↔ test coverage
src/observability/checks/lens-c-standards.tsC · coding-standards drift
src/observability/checks/lens-d-stack.tsD · tech-stack drift
src/observability/checks/lens-e-design.tsE · design-system drift
src/observability/checks/lens-f-scope.tsF · missing scope
src/observability/checks/lens-g-decisions.tsG · undocumented decisions
src/observability/checks/lens-h-cross-doc.tsH · cross-doc consistency + LLM checks
src/observability/checks/lens-i-knowledge-gaps.tsI · knowledge gaps
src/observability/checks/lens-i-lessons-scanner.tsscan tasks/lessons.md for gap signals
src/observability/knowledge-index.tsresolveKnowledgeRoot (3 tiers) + index for Lens I
src/observability/renderers/terminal.tsANSI output
src/observability/renderers/markdown.tsmarkdown report
src/observability/renderers/dashboard.tsbuild-progress / build-audit HTML fragments
src/observability/renderers/sidecar.tsJSON sidecar + report-id derivation
src/observability/renderers/mmr-findings.tsMMR Finding shape
src/state/state-manager.tsasync markCompleted → runPhaseAudit hook
scripts/teardown-agent-worktree.shharvest → remove → delete branch
scripts/generate-dashboard.shinjects the dashboard fragments

The full design lives in docs/superpowers/specs/2026-04-30-build-observability-design.md.

Shipping history

Build Observability shipped as eight plans (Foundation → Fix-flow), released together as v3.26.0 — Build Observability on 2026-05-07, with Lens I and refinements following after.

plansubsystemsquashPRadded to the on-disk surface
spec + plansdesign doc + 8 plans5cf689c4#319docs/superpowers/specs + 8 plan files
1 · Foundationidentity, validation, redaction, ledger, harvester, adapters, synthesizer, API, CLI, renderers21124010 (+ #320–328)#320–329most of src/observability/engine, adapters, renderers; scaffold observe
2 · Audit MVPdoc-graph, checks framework, lenses A/B/H, audit+ack8eb70986#331engine/doc-graph, engine/checks, lens-a/b/h
3 · Full lens suitelenses C/D/E/F/G + registry + configbd014634#332lens-c…g, registry.ts, .scaffold/observability.yaml
4 · Renderers + historymarkdown, JSON sidecars, dashboard fragments, audit-history37f63ae4#333docs/build-status, docs/audits, audit-history adapter
5 · Replay + stallreplay timeline, stall detection, Lens G keyword scancdbfd1de#334synthesizer.composeReplay, stall.ts, Needs-Attention surfaces
6 · Phase triggersmarkCompleted async → runPhaseAuditff04a1a6#335phase-audit.ts, phase-subsets.ts, step timestamps
7 · MMR channeldoc-conformance channel + Lens H full-profile LLM checks8b723617#336mmr-findings.ts, llm-dispatcher.ts, Lens H sub-checks
8 · Fix flow + teardown--fix loop, harvest --recover, teardown script06fca3ef (v3.26.0)#337fix-flow.ts, fix-plan.ts, fix-agent-dispatcher.ts, abort-snapshot.ts, teardown-agent-worktree.sh
follow-onLens I — knowledge gaps + lessons scanner31e45f03#397lens-i-knowledge-gaps.ts, lens-i-lessons-scanner.ts
follow-onLens I — --knowledge-root + existing-entry suppression46a47037#406knowledge-index.ts resolver tiers
follow-onTypeScript cleanup (as never → typed isOneOf)362c7f93#411type-safety refactor across observability

Known divergences

The audit's own rule — "code is ground truth where it disagrees with the spec or the docs" — surfaced these mismatches. They are deferred findings to resolve in the code/docs: