Constructdocs
section · guides

Learning loops

How Construct gets better the more you use it — every loop that captures session outcomes, research findings, and specialist performance, and feeds them back into the next session.

7 min read·guides / concepts / learning-loops

Construct should get better the more you use it. This page lists every learning loop, what state it's actually in, where the data lives, and how to turn pieces off if you need to.

What learning means here

A loop is closed when:

  1. Something gets observed during a session.
  2. The observation is persisted to durable storage.
  3. The next session can find it.
  4. The next session is measurably better because of it.

Anything less is a stub, not a loop.

Session Nagent does workObservereflect · outcomes · research.construct/observations/durable storage vectorizedSession N+1retrieves observations via hybrid search

Each loop below either closes this cycle end to end or is honestly labeled a stub.

Loops in place

Session-end auto-reflect (A1)

Every Stop event triggers lib/hooks/session-reflect.mjs. It reads the transcript, runs a deterministic extractor in lib/reflect/extractor.mjs, and writes one session-summary observation into .construct/observations/ via the shared observation store.

What gets captured: number of turns, tool counts (Bash×3, Edit×2), files touched, session duration, first line of the final assistant reply, and a structured meta block with session id and cwd.

Salience: instead of a flat confidence, the observation's confidence is a deterministic salience score in [0.05, 0.95] (lib/reflect/salience.mjs) derived from signals already collected — mutating tool calls weigh most, then bash, assistant turns, and files in scope, no LLM. A session that edited the tree is remembered strongly; a read-only exchange weakly. Because consolidation archives observations that are both older than archiveAfterDays and below archiveBelowConfidence (0.5), low-salience sessions age out of the live set first — the score is the retention signal, not a second mechanism. The same module's shouldStore is the extraction gate: a session with no durable signal at all (no mutation, no bash, barely any exchange) yields no observation.

The same salience score also drives supersede during consolidation (lib/engine/consolidate.mjs): when a cluster contains a tight restatement of a fact (cosine ≥ supersedeThreshold 0.97 against the cluster winner), only the highest-salience member stays live and the rest move to cold archive stamped with supersededBy — so search returns one current statement of a fact, not every past phrasing of it. The winner becomes the cluster representative; construct memory consolidate reports the superseded count, and --no-supersede disables it.

Contradiction is the inverse case (lib/engine/contradiction.mjs): two observations about the same subject that disagree — "sso login is supported" vs "sso login is not supported". A flipped claim drops cosine below the duplicate threshold, so consolidation scans a same-subject band (contradictionMinSimilarity 0.75 ≤ cosine < 0.97) instead of trusting cluster membership, and flags a pair when the cue-stripped claim words overlap (Jaccard ≥ 0.6) but the count of negation cues differs. Resolution inverts supersede: the most recent claim wins (the world changed, not the strongest-remembered one), and the older loser is archived with supersededReason: 'contradiction'. The O(n²) scan is bounded by contradictionScanMax (1500) and reports when skipped. A value swap with no negation cue ("RS256" vs "HS256") carries no polarity signal, so the heuristic abstains and defers to an optional contradictionJudge (lib/engine/contradiction-judge.mjs) — an offline-first second opinion backed by a local Ollama model, consulted only on the heuristic's misses and absent (so heuristic-only) when no model is running. --no-contradictions disables the whole pass.

Where it lives:

  • lib/hooks/session-reflect.mjs. The Stop hook. 500ms hard budget. Exit 0 on any error.
  • lib/reflect/extractor.mjs. Pure function. O(n) over transcript lines. No LLM call.
  • lib/reflect.mjs. runReflectAuto({ transcriptPath, cwd, sessionId, durationMs }) is the entry point.
  • .construct/observations/<id>.json. Per-observation record.
  • .construct/observations/index.json. Lightweight listing for fast filtering.
  • .construct/observations/vectors.json. Local embeddings (256-dim hashing-bow) when Postgres is not available.

How the loop closes: lib/hooks/session-start.mjs calls searchObservations and listObservations filtered by project, then injects a "Prior observations" block into the next session start. Search uses pgvector when present, BM25 plus local embeddings otherwise.

Opt out: CONSTRUCT_REFLECT_AUTO=off.

Skipped automatically: sessions carrying no durable signal — no mutation, no bash, and fewer than two assistant turns (shouldStore). Avoids polluting the store with one-line acknowledgements.

Manual reflect (existing)

construct reflect --target=internal --summary="..." still works for explicit captures. The auto hook does not replace it. Use it when you want to record a specific insight, not just a session rollup.

Research persistence (A2)

commands/understand/research.md produces FINDINGS, INFERENCES, GAPS, RECOMMENDATION. The output lands as .construct/knowledge/external/research/<slug>.md with frontmatter (kind: research-finding, topic, confidence, sources, accessedAt, expiresAt, profile). Schema validates confidence=confirmed against sources[]. Files cap at 50 KB. The standard sync path indexes them for retrieval. CLI: construct knowledge add --source=research --slug=<topic>. MCP: knowledge_add (requires confirm=true).

Specialist outcome capture (A3)

Every dispatched specialist writes a JSONL line to .construct/outcomes/<role>.jsonl via lib/outcomes/record.mjs. The agent-tracker hook stamps success, escalated, durationMs, profile. lib/outcomes/aggregate.mjs rolls up per-role success rate and 30-day trend into .construct/outcomes/_summary.json, and exposes outcomeBoost — a soft re-rank signal capped at plus or minus 0.05 that cannot invert a primary signal. Intake classification deliberately never consults it (lib/intake/classify.mjs stays deterministic); routing consumption is tracked in construct-4z0qq. Files rotate at 10k lines per role. MCP: outcomes_summary (read), outcomes_record (write, requires confirm=true).

Skill outcome attribution (A3-skills)

The skill-level twin of A3, phase 1 of the skill effectiveness pipeline. When an outcome recorded through lib/outcomes/record.mjs carries a sessionId (the agent-tracker hook passes the hook input's session_id; outcomes_record accepts session_id), lib/telemetry/skill-outcomes.mjs joins it against the sessionId column of ~/.cx/skill-calls.jsonl and appends one line per distinct skill loaded in that session to ~/.cx/skill-outcomes.jsonl (user scope, projectId-tagged, rotated via the skill-outcomes channel). lib/telemetry/skill-outcomes-aggregate.mjs rolls the log into per-skill success rates with a 30-day window and trend delta in ~/.cx/skill-outcomes-summary.json. CLI: construct skills quality (the local JSONL twin of the Postgres-only skills correlate-quality). Capture only: no router or classifier reads this signal yet, and CONSTRUCT_SKILL_TELEMETRY=off disables it alongside skill-call logging.

Prompt improvement (A4)

construct optimize <agent> (scripts/optimize.mjs) fetches the agent's low-scoring telemetry traces, diagnoses the top failure patterns with an LLM, and proposes a patch to the agent's role skill file (skills/roles/<role>.md, inlined into specialist prompts at sync time). The default run is a dry-run that only prints the diagnosis and patch. Nothing auto-applies: the weekly optimize-loop scheduled job and the session-end hook run dry-run only. --apply is the human gate — rate-limited to one apply per agent per 7 days, it writes a .bak backup (most recent 5 kept), appends the patch record to ~/.cx/prompt-history/<agent>.jsonl, integrity-checks the patched file, and runs construct sync so the change propagates; --rollback restores the latest backup and records the reversal in the same history. Apply/rollback are CLI-only by design. Registry mutations stay on the operator path.

Tool discoverability (Oracle)

Hosts and models routinely mis-name Construct's MCP tools — the call gateway under its former construct_call name, a long-tail tool invoked directly, or a name carrying a stray host prefix. recordToolNameMiss (lib/mcp/tool-recovery.mjs) logs every miss to .construct/observations/tool-name-misses.jsonl; recordToolFailure does the same for tool/command failures. summarizeToolNameMisses/summarizeToolFailures aggregate by name and back npm run learning:status. The Oracle read model (lib/oracle/read-model.mjs) now attaches that aggregation as toolDiscoverability, and once unrecovered misses cross a small threshold, synthesizeVerdict (lib/oracle/synthesize.mjs) raises a tool-discoverability gap naming the most-missed tools — surfaced in construct doctor, session prelude, and construct oracle gaps. The gap is verdict-only (lib/oracle/policy.mjs's VERDICT_ONLY_GAP_IDS): informational by design, it can never auto-raise a bead even at forced-high severity, matching ADR-0043's bounded-auto envelope. A name missed repeatedly is a documentation/alias candidate a human should look at, not something Oracle acts on unattended.

Evidence provenance in host runs

When a host run requires repository evidence, a tool request is not itself proof. Construct records a versioned immutable source only after the matching read, grep, or glob result completed successfully with a concrete target; a search/glob pattern alone is not a source. At turn close it stores a deterministic verdict: verified, partially_verified, uncited_evidence, insufficient_evidence, or not_applicable, with reason codes. A required-evidence answer is verified only when an exact recorded target or its structured repo:<target> identifier is cited in the answer; denied, failed, pending, unrelated, and hidden-layer tools never count.

Profile-aware learning

The PR #67 surfaces (profile lifecycle, intake taxonomies, departments) feed the same loops. Each observation, outcome, and research finding carries the active profile id, so construct scope health and learning_status filter cleanly. Profiles themselves are research artifacts: docs/guides/concepts/profile-lifecycle.md walks the discover → frame → architect → validate → promote chain.

Telemetry dashboard

npm run learning:status prints a one-screen table: active profile, observations (last 24h plus total), research findings indexed, per-role outcome rollup. Pass --aggregate to rebuild _summary.json first. Source: scripts/learning-status.mjs. MCP: learning_status returns the same dashboard as a structured object.

MCP exposure

Subagents talk to Construct through the construct-mcp stdio server, not the CLI. Every learning surface above is reachable as an MCP tool registered in lib/mcp/server.mjs:

ToolSurfaceMutating
scope_show, scope_list, scope_drafts, scope_healthProfile stateno
outcomes_summaryOutcome rollupsno
outcomes_recordOutcome captureyes (confirm=true)
knowledge_addResearch persistenceyes (confirm=true)
learning_statusDashboard mirrorno
sandbox_listIsolated environmentsno
scope_createDraft scaffoldyes (confirm=true)
scope_archiveCurated archiveyes (confirm=true, destructive)

optimize_apply and optimize_rollback stay CLI-only on purpose: prompt-version mutations belong to the operator, not to subagents.

Where to dig

  • Search: node bin/construct search "session" returns observations from the auto-reflect hook.
  • Inspect: node bin/construct memory shows the observation store.
  • Outcomes: cat .construct/outcomes/_summary.json after node scripts/learning-status.mjs --aggregate.
  • MCP tool list: node lib/mcp/server.mjs and send tools/list to see every learning surface exposed to subagents.