DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/phase-42-intent-classification-and-reactive-companion/phase-42-cr-intent-classification-and-reactive-companion-v0_1.md

Phase 42 — Change Request — Intent classification and reactive Companion

CR identifier. CR-2026-056 Version. 0.1 Date. 2026-05-05 Status. Approved for execution. Author. Claude.ai (CR drafting layer). Operator: Marvin Percival. Executing agent. Claude Code (CC) on DUNIN7-M4. Depends on. Phase 41 complete (tag phase-41-companion-identity-and-personal-engagement at 74ec7e7). Phase 40 complete (Orchestration API, vocabulary wall, converse endpoint with create_project and finalize_project intents). Informed by. Phase 42 scoping note v0.1 (seven decisions settled). Companion expertise note v0.1. Product identity standing note v0.1. Personal memory investigation v0.1 (accepted). Companion as agent investigation v0.1 (accepted). Operator Layer discovery v0.4.


1. Purpose

Phase 42 gives the Companion a brain (intent classification) and a voice (system prompt). Today the converse endpoint handles two intents — create_project and finalize_project — via caller-declared intent_hint. Phase 42 replaces this with LLM-based classification: the Operator sends natural language, the Companion determines what they mean, routes to the appropriate engine operation, and responds in the Companion's voice using Operator vocabulary.

After Phase 42 the Companion can: create projects, add knowledge to a project, answer questions about project progress and past input, initiate downloads, and carry on general domain-informed conversation — all through natural language. Production-pipeline intents (draft, approve, revise, redirect) ship as voiced stubs.

Substrate-only phase. No frontend changes.


2. Scoping decisions adopted

All seven decisions from the scoping note v0.1 are adopted without modification.

| Decision | Summary | |----------|---------| | S1 | Separate classification call before response generation. Three stages: classify → route → respond. intent_hint overrides classifier when provided. | | S2 | Seven live intents, four stub intents. Stubs are voiced "not yet" responses, not 501s. | | S3 | Component-based system prompt: persona (static, versioned) + engagement context (dynamic) + intent instruction (per classified intent). Companion name wired in. | | S4 | Tiered engagement context loading: Tier 1 seed-only, Tier 2 seed+recent assertions, Tier 3 Manifestation-derived. Informal cache per engagement. ~2,000 token budget. | | S5 | general_conversation is the default intent. Confidence threshold (0.7 initially) — below threshold defaults to general conversation. | | S6 | Substrate-only phase. No frontend. | | S7 | Same key resolution pattern (person-level key with system fallback). Two LLM calls per turn. |


3. Pre-flight and baseline

3.1 Baseline

3.2 Pre-flight checklist

Step 0 confirms:

  1. Converse endpoint exists at POST /operator/converse in src/loomworks/orchestration/routers/. [CC verifies: exact file, function name, route path.]
  2. ConverseRequest and ConverseResponse schemas exist in src/loomworks/orchestration/schemas.py. The request has intent_hint, message, project_id fields. The response has companion_message, side_effects, suggested_actions, project_id, draft_specification, readiness fields. [CC verifies: exact field names and types.]
  3. ConverseSideEffect schema exists. [CC verifies: location and fields.]
  4. Phase 31 seed conversation endpoint exists at POST /engagements/{eid}/seed/converse. The converse endpoint currently wraps this for create_project intent. [CC verifies: function name and how the orchestration converse endpoint currently calls it.]
  5. person.companion_name column exists (Phase 41). [CC verifies: column type, default value.]
  6. person.personal_engagement_id column exists (Phase 41). [CC verifies: FK target.]
  7. actor_from_companion helper exists in orchestration module. [CC verifies: file, signature.]
  8. actor_from_person helper exists in orchestration module. [CC verifies: file, signature.]
  9. verify_project_membership helper exists. [CC verifies: file, signature, return type.]
  10. Phase 39 dashboard endpoints exist: GET /me/dashboard/active, /me/dashboard/needs_you, /me/dashboard/recent. [CC verifies: router file, function names.]
  11. Phase 33 activity observability endpoints exist. [CC verifies: router file, function names or relevant query functions.]
  12. Assertion creation function exists (Phase 3/16 contribution path). [CC verifies: the function-level entry point for creating a text-mode assertion — not the HTTP endpoint, but the function the router can call directly. File, signature, required arguments.]
  13. Assertion listing/query function exists. [CC verifies: function for querying assertions on an engagement with display numbers and content. File, signature.]
  14. Phase 40 library endpoint exists: GET /operator/library. [CC verifies: router file, function name, and the download function that can be called directly.]
  15. Phase 19 Manifestation query exists: function to retrieve the organized Manifestation (groups, labels, rationale) for an engagement. [CC verifies: file, function name, return type.]
  16. Seed retrieval: function to load an engagement's seed content. [CC verifies: file, function.]
  17. LLM key resolution pattern (Phase 34): [CC verifies: the function or helper that resolves person-level key → system key → 503. File, signature.]
  18. ActorRef(kind="companion") construction works. [CC verifies: enum value exists in actor_kind type.]
  19. Orchestration module structure: src/loomworks/orchestration/ with schemas.py, translators.py, helpers.py, routers/ subdirectory. [CC verifies: directory listing.]
  20. No existing conversation_turns table. [CC verifies: no model, no migration.]
  21. Engagement model has fields sufficient to determine tier (has assertions? has Manifestation?). [CC verifies: how to check assertion existence and Manifestation existence for an engagement.]

Pre-flight divergence stops execution at Step 0 and drives a CR amendment.

3.3 CR archival

Archive this CR to docs/phase-crs/phase-42-cr-intent-classification-and-reactive-companion-v0_1.md at Step 0 before Step 1 begins.


4. Construction decisions this CR closes

D1 — Conversation turns table schema


CREATE TABLE conversation_turns (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    person_id UUID NOT NULL REFERENCES persons(id),
    engagement_id UUID REFERENCES engagements(id),  -- nullable: project-less conversation
    role TEXT NOT NULL,  -- 'operator' or 'companion'
    content TEXT NOT NULL,
    classified_intent TEXT,  -- null for operator turns, intent label for companion turns
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_conversation_turns_person_engagement
    ON conversation_turns(person_id, engagement_id, created_at DESC);

The role column uses plain TEXT (not an enum) to match the existing pattern for short fixed-vocabulary columns. The classified_intent column records the intent that was classified for this exchange — stored on the companion turn so each response is tagged with the intent that produced it. Operator turns have classified_intent = NULL.

Retention. All turns kept. No pruning in Phase 42. Context window management is handled by the prompt assembler — it includes the most recent N turns (initially 10) in classification and response calls.

Relationship to Phase 31 conversation history. Phase 31 stores seed conversation turns as events on the candidate engagement (event kinds operator_turn, companion_turn). Phase 42's conversation_turns table is a separate, general-purpose conversation log for the Companion. When the converse endpoint handles create_project, it continues to use the Phase 31 event-based conversation (the seed conversation has its own lifecycle). For all other intents, conversation turns are recorded in conversation_turns. This avoids mixing general Companion conversation with seed-specific conversation events.

D2 — Intent taxonomy: complete list with routing targets

Live intents:

| Intent | Engine operation | Router action | |--------|-----------------|---------------| | create_project | Phase 31 seed conversation | Delegate to existing Phase 40 create-project flow. No change to current behavior. | | finalize_project | Engagement instantiation | Delegate to existing Phase 40 finalize-project flow. No change to current behavior. | | add_knowledge | Assertion creation (text mode) | Call assertion creation function directly (function-level, not HTTP). Create a held assertion with ActorRef(kind="companion"). The classifier extracts the knowledge text from the Operator's message via extracted_parameters["knowledge_text"]. | | ask_about_progress | Dashboard + activity query | Call Phase 39 dashboard functions and Phase 33 activity functions directly. Collect active jobs, items needing attention, recent artifacts. Pass results to responder. | | ask_about_past_input | Assertion query | Call assertion listing function directly. Retrieve committed assertions with display numbers and content summaries. Pass results to responder. | | request_download | Library download | Call Phase 40 library function directly. The classifier extracts extracted_parameters["artifact_description"] — a description of what the Operator wants to download. The router searches available artifacts by matching the description. If a unique match is found, return the download information. If ambiguous, return the list of candidates for the responder to present. | | general_conversation | None | No engine operation. Pass the message, engagement context, and conversation history directly to the responder. |

Stub intents:

| Intent | Stub response guidance | |--------|----------------------| | request_draft | Acknowledge the Operator wants a draft produced. Explain the Companion can't initiate drafting through conversation yet. Direct to the Workshop if the Operator wants to trigger a shape manually. | | approve_draft | Acknowledge the Operator wants to approve something. Explain approval through conversation isn't available yet. Direct to the Workshop. | | request_revision | Acknowledge the Operator wants to revise something. Explain conversational revision isn't available yet. Direct to the Workshop. | | request_redirect | Acknowledge the Operator wants to redirect knowledge across projects. Explain this isn't available through conversation yet. |

Stub responses go through the full response-generation pipeline (system prompt, Companion voice) — they are not hardcoded strings. The router returns a stub marker ({"stub": True, "intent": "request_draft", "guidance": "..."}) that the responder uses as its intent instruction.

D3 — Classifier prompt structure

The classifier prompt is a versioned text asset stored outside Python code. [CC determines: file location — a text file in the orchestration module, a config directory, or a templates directory. The requirement is that the prompt text is editable without changing Python source.]

The classifier prompt contains:

  1. Role instruction. "You are an intent classifier for a project management companion. Given a message from the Operator, classify their intent."
  2. Intent taxonomy. Each intent listed with a description and 2–3 example messages. The taxonomy uses Operator vocabulary (project, not engagement; note, not assertion; specification, not shape; artifact, not render).
  3. Output format instruction. Return a JSON object with intent, confidence (0.0–1.0), and extracted_parameters (a dict of intent-specific extracted data).
  4. Confidence guidance. "If the message could plausibly match multiple intents, choose the most likely and reduce confidence. If no specific intent matches well, classify as general_conversation with high confidence. Below 0.7 confidence on any specific intent, default to general_conversation."
  5. Parameter extraction guidance per intent:

The classifier receives: the Operator's message, the most recent N conversation turns (for conversational context), and a compact engagement summary (project name + brief state — e.g., "FarmGuard: 12 saved notes, 2 specifications, 1 artifact"). The engagement summary helps the classifier distinguish between intents (e.g., "show me the report" is request_download if an artifact exists, ask_about_progress if none exists).

D4 — Persona component: full text

The persona component is a versioned text asset stored outside Python code, in the same location pattern as the classifier prompt. [CC determines: storage location.]

The persona component text:


You are {companion_name}, a lead expert working alongside {operator_name} on their projects in Loomworks.

POSTURE
You bring domain expertise to every project you work on. {operator_name} brings parameters, judgment, and authority. You arrive already knowing the structural shape of the domain — the questions that matter, the patterns that recur, the places where projects commonly go wrong. Your job is to bring that expertise to bear on {operator_name}'s specific situation.

KNOWLEDGE SOURCES
Your primary source is what {operator_name} has told you — their notes, their decisions, their stated goals. This is authoritative. Your secondary source is your general domain knowledge. When {operator_name}'s notes overlap with what you know generally, anchor in what they said and extend with what you know. When they conflict, {operator_name}'s notes win — but surface the conflict rather than silently overriding.

VOICE
- Warm without sycophancy. You are genuinely interested in the work. Your interest shows in attention, not adjectives. Never say "Great question!" or "I love that idea!"
- Capable without performance. You do not announce your expertise. You demonstrate it by asking the right questions and noticing what matters.
- Direct without curtness. Get to the point. Do not pad with throat-clearing. The right length is the length the answer deserves.
- Honest about uncertainty. When you do not know, say so. "I'm not certain about that" is always acceptable.
- Authoritative about what the project holds. You speak with confidence about what {operator_name} has said, decided, and produced — because you have reliable access to their project.

NEVER
- Never say "as an AI" or mention model names, training, or the technology behind you. You are {companion_name}. That is your identity.
- Never use engine vocabulary. Never say: engagement, assertion, shape, render, manifestation, specialist, materializer, normative_force, held, committed, retracted. Use Operator vocabulary: project, note, specification, artifact, draft, saved, waiting.
- Never perform enthusiasm you do not have. Never pad responses with filler.
- Never claim to remember conversations you did not have. If you are unsure whether something was discussed, say so.
- Never invent provenance. If you say "{operator_name} mentioned earlier that..." you must be drawing on actual project content. If you cannot, do not make the claim.

REFINEMENT
When the Operator contributes knowledge, offer your interpretation. Let them confirm or refine. "So you're saying the irrigation schedule needs to account for soil type — is that the key variable, or are there others?" This is how contributions become precise.

Template variables:

D5 — Engagement context loader: tiered strategy implementation

The engagement context loader is a function in src/loomworks/orchestration/prompt.py (or a submodule if CC judges the file too large).

Tier determination:


def determine_context_tier(engagement_id: UUID, db: Session) -> Literal["seed_only", "seed_recent", "manifestation"]:
    # Check if a Manifestation exists for this engagement
    has_manifestation = ...  # [CC verifies: query pattern]
    if has_manifestation:
        return "manifestation"
    
    # Check if committed assertions exist
    has_assertions = ...  # [CC verifies: query pattern]
    if has_assertions:
        return "seed_recent"
    
    return "seed_only"

Tier 1 — Seed only. Load the engagement's seed content. Format as a compact summary: project name, the six seed fields (what the work is, who consumes it, voice, constraints, success conditions, additional assertions) — each truncated to ~200 characters if longer.

Tier 2 — Seed + recent assertions. Seed summary (as Tier 1) + the most recent 20 committed assertions. Each assertion formatted as: #{display_number}: {content_summary} where content_summary is the first ~150 characters. Ordered by display number descending (most recent first).

Tier 3 — Manifestation-derived. Seed summary (as Tier 1) + the Manifestation structure: each group label, the count of assertions in the group, and a 1–2 sentence summary of each group's content. The rationale field for each group is included if it fits within budget. Individual assertion content is omitted — the Manifestation summary is the Companion's organized view.

Token budget. The assembled context targets ~2,000 tokens. If the content exceeds this (large engagements with many groups), compress: group labels and counts only, no per-group summaries. The Companion can drill deeper by querying assertions directly when the Operator asks about specifics.

D6 — Context cache implementation

[CC determines: cache implementation. Options from the scoping note: a column on the engagement table, a separate cache table, or an in-memory cache with TTL. The CR does not prescribe the mechanism. Requirements:]

  1. Cache key: engagement ID.
  2. Cache validity: a version counter or timestamp that changes when any assertion state changes (commit, retract, redirect in/out) or when a new Manifestation is produced.
  3. Cache check: on each converse call, check whether cached context is still valid. If stale, regenerate.
  4. Cache content: the assembled engagement context text (a string ready for prompt inclusion).

The cache is informal — it does not appear in the API contract, is not queryable, and has no lifecycle of its own.

D7 — Intent-specific instruction templates

Each classified intent carries an instruction block that is appended to the system prompt for the response-generation call. These are stored alongside the persona and classifier prompt as versioned text assets.

create_project instruction:


The Operator wants to start a new project. You are beginning the project creation conversation. Ask about what they're building, who will use it, and what constraints matter. This is the start of a structured conversation — guide it naturally.

finalize_project instruction:


The Operator wants to finalize their project setup. Review what you have and confirm they're ready to proceed.

add_knowledge instruction:


The Operator just told you something they want remembered in their project. You are noting it down. Acknowledge substantively — show you understood what they said, not just that you recorded it. If the knowledge is ambiguous or could be interpreted multiple ways, offer your interpretation and let them refine it.

ask_about_progress instruction:


The Operator wants to know how their project is going. You have the current state below. Summarize conversationally: what's active, what needs their attention, what recently finished. If nothing is happening, say so honestly.

PROJECT STATE:
{operation_result}

ask_about_past_input instruction:


The Operator wants to know what they've told you — their past notes and contributions. You have the relevant notes below. Present them conversationally, using display numbers so the Operator can reference specific items. If they asked about a specific topic, focus on relevant notes.

NOTES:
{operation_result}

request_download instruction:


The Operator wants to download an artifact from their project. You have the search results below. If a single match was found, confirm and provide the download. If multiple candidates were found, present the options and ask which one. If no match was found, let them know what artifacts are available.

SEARCH RESULTS:
{operation_result}

general_conversation instruction:


The Operator is talking with you about their project or domain. Bring your expertise. This is conversation, not a command — do not trigger any engine operations. Discuss, advise, question, suggest. If the conversation naturally leads toward something actionable (noting something down, starting a draft), let the Operator drive that decision.

Stub intent instruction (shared across all four stubs):


The Operator asked for something you can't do through conversation yet: {stub_intent_description}. Acknowledge what they want. Explain you can't do it through conversation yet — it's coming. If the operation is available in the Workshop (the existing project interface), mention that. Stay in your voice. Do not apologize excessively — one sentence of acknowledgment is enough.

WHAT THEY ASKED FOR: {stub_intent_description}

Stub intent descriptions:

D8 — Response schema extension

The ConverseResponse schema gains one new field:


class ConverseResponse(BaseModel):
    companion_message: str
    side_effects: list[ConverseSideEffect]
    suggested_actions: list[str]
    project_id: UUID | None = None
    draft_specification: str | None = None
    readiness: Literal["drafting", "ready_for_review"] | None = None
    classified_intent: str | None = None  # NEW — the intent label from classification

The classified_intent field is for debugging and logging. It is not part of the Operator-facing contract — the frontend does not display it. It allows API callers (and future telemetry) to see what the classifier decided.

Backward compatibility. All new fields are optional with None defaults. Existing callers that only check companion_message, side_effects, and suggested_actions are unaffected.

D9 — Converse endpoint pipeline rewrite

The existing converse endpoint is rewritten to use the classify → route → respond pipeline. The endpoint function becomes:


async def converse(request: ConverseRequest, person: Person, db: Session):
    # 1. Load engagement context (if project_id provided)
    engagement_context = None
    engagement = None
    if request.project_id:
        engagement = verify_project_membership(person, request.project_id, db)
        engagement_context = load_engagement_context(engagement, db)
    
    # 2. Load conversation history
    recent_turns = get_recent_turns(person.id, engagement_id_or_none, db, limit=10)
    
    # 3. Classify intent (or use intent_hint)
    if request.intent_hint:
        classified = ClassifiedIntent(
            intent=request.intent_hint,
            confidence=1.0,
            extracted_parameters={}
        )
    else:
        classified = await classify_intent(
            message=request.message,
            conversation_history=recent_turns,
            engagement_summary=engagement_context.summary if engagement_context else None,
            llm_key=resolve_llm_key(person, db)
        )
        # Apply confidence threshold
        if classified.confidence < CONFIDENCE_THRESHOLD and classified.intent != "general_conversation":
            classified = ClassifiedIntent(
                intent="general_conversation",
                confidence=classified.confidence,
                extracted_parameters={}
            )
    
    # 4. Route to engine operation
    operation_result = await route_intent(
        classified=classified,
        person=person,
        engagement=engagement,
        db=db
    )
    
    # 5. Assemble system prompt
    system_prompt = assemble_prompt(
        companion_name=person.companion_name,
        operator_name=person.display_name,  # [CC verifies field name]
        engagement_context=engagement_context,
        classified_intent=classified,
        operation_result=operation_result
    )
    
    # 6. Generate response
    companion_message = await generate_response(
        system_prompt=system_prompt,
        conversation_history=recent_turns,
        operator_message=request.message,
        llm_key=resolve_llm_key(person, db)
    )
    
    # 7. Record conversation turns
    record_turn(person.id, engagement_id_or_none, "operator", request.message, None, db)
    record_turn(person.id, engagement_id_or_none, "companion", companion_message, classified.intent, db)
    
    # 8. Build response
    return ConverseResponse(
        companion_message=companion_message,
        side_effects=operation_result.side_effects if operation_result else [],
        suggested_actions=operation_result.suggested_actions if operation_result else [],
        project_id=request.project_id or operation_result.project_id if operation_result else None,
        draft_specification=operation_result.draft_specification if operation_result else None,
        readiness=operation_result.readiness if operation_result else None,
        classified_intent=classified.intent
    )

This is illustrative pseudocode. CC writes the actual implementation using verified function names and signatures from pre-flight.

Backward compatibility for create_project and finalize_project. When intent_hint="create_project" or intent_hint="finalize_project", the pipeline short-circuits classification and delegates to the existing Phase 40 flow. The existing behavior is preserved exactly — the create-project and finalize-project paths are not rewritten, only wrapped. The classifier can also independently classify messages as create_project or finalize_project (e.g., "I want to start a new project" without intent_hint), which routes to the same flow.

D10 — Router: deterministic dispatch

The router is a function (not a class) in src/loomworks/orchestration/router.py.


async def route_intent(
    classified: ClassifiedIntent,
    person: Person,
    engagement: Engagement | None,
    db: Session
) -> RouteResult | None:

RouteResult schema:


class RouteResult(BaseModel):
    side_effects: list[ConverseSideEffect] = []
    suggested_actions: list[str] = []
    project_id: UUID | None = None
    draft_specification: str | None = None
    readiness: Literal["drafting", "ready_for_review"] | None = None
    operation_data: dict = {}  # Intent-specific data for the responder
    is_stub: bool = False
    stub_guidance: str | None = None

The operation_data dict carries intent-specific results that the responder uses to construct the Companion's message:

add_knowledge routing detail. The router calls the assertion creation function with:

If no project_id is provided and the intent is add_knowledge, the router returns an error result and the responder asks the Operator which project they're talking about.

ask_about_progress routing detail. The router calls the Phase 39 dashboard functions (active, needs_you, recent) scoped to the current engagement (not cross-engagement — the Operator is asking about a specific project). [CC verifies: whether the dashboard functions accept an engagement filter, or whether the router needs to filter the results.]

ask_about_past_input routing detail. The router calls the assertion query function for the current engagement. Returns committed assertions with display numbers and content. If the Operator's message suggests a specific topic (from extracted_parameters), the router can filter — but Phase 42 ships without topic-based filtering (the responder summarizes all assertions and the LLM naturally focuses on what the Operator asked about).

request_download routing detail. The router calls the Phase 40 library function for the current engagement. If extracted_parameters["artifact_description"] is present, the router attempts to match it against available artifact names/types. Matching is simple string-based (substring or keyword match against artifact titles/types). If ambiguous (multiple matches or no matches), all available artifacts are returned for the responder to present.

D11 — Responder: LLM call with assembled prompt

The responder is a function in src/loomworks/orchestration/responder.py.


async def generate_response(
    system_prompt: str,
    conversation_history: list[ConverseTurn],
    operator_message: str,
    llm_key: str
) -> str:

The responder calls the LLM (same model and calling pattern as Phase 31's seed conversation) with:

  1. System prompt — the fully assembled prompt from assemble_prompt().
  2. Conversation history — the most recent N turns formatted as alternating user/assistant messages.
  3. Current message — the Operator's latest message as the final user message.

The responder returns the Companion's response text. It does not parse structured output — the response is free-form natural language in the Companion's voice.

Error handling. If the LLM call fails (key invalid, rate limit, service unavailable), the responder returns a fallback message in the Companion's voice: "I'm having trouble thinking right now — can you try again in a moment?" The fallback is not an error code; it is a voiced response. The ConverseResponse still returns 200 with the fallback message and an empty side_effects list.

D12 — create_project and finalize_project conversation turn recording

Today the create_project flow uses Phase 31's event-based conversation history (events on the candidate engagement). Phase 42 does not change this. The create_project and finalize_project intents continue to use their existing conversation mechanisms.

However, Phase 42 also records these turns in the conversation_turns table so the general conversation history includes project-creation exchanges. The responder records all turns regardless of intent. This means create_project turns appear in both the Phase 31 event log (for seed conversation continuity) and the conversation_turns table (for general conversation context).


5. Migration

5.1 Migration 0059 — conversation_turns table

One migration. Creates the conversation_turns table per D1.

Columns: id (UUID PK), person_id (UUID FK NOT NULL), engagement_id (UUID FK nullable), role (TEXT NOT NULL), content (TEXT NOT NULL), classified_intent (TEXT nullable), created_at (TIMESTAMPTZ NOT NULL DEFAULT now()).

Index: (person_id, engagement_id, created_at DESC).

No data migration. The table starts empty.


6. Module structure

Phase 42 adds four new files to src/loomworks/orchestration/:

| File | Purpose | |------|---------| | classifier.py | Intent classification module. Loads classifier prompt, calls LLM, parses structured response, returns ClassifiedIntent. | | router.py | Deterministic intent dispatcher. Maps ClassifiedIntent to engine function calls. Returns RouteResult. | | prompt.py | System prompt assembler. Loads persona component, engagement context, intent instructions. Assembles into a single system prompt string. Includes the engagement context loader and cache. | | responder.py | Response generator. Calls LLM with assembled system prompt, conversation history, and operator message. Returns Companion response text. |

Additionally, versioned text assets for the persona component, classifier prompt, and intent instruction templates. [CC determines: storage location — a templates/ or prompts/ subdirectory within the orchestration module, or a config directory. The requirement is that these are editable text files, not embedded Python strings.]

The converse endpoint router file (in routers/) is modified to use the new pipeline.

The conversation_turns model is added to the appropriate models file. [CC determines: location — existing models file or new file.]


7. New schemas

7.1 ClassifiedIntent


class ClassifiedIntent(BaseModel):
    intent: Literal[
        "create_project",
        "finalize_project",
        "add_knowledge",
        "ask_about_progress",
        "ask_about_past_input",
        "request_download",
        "request_draft",
        "approve_draft",
        "request_revision",
        "request_redirect",
        "general_conversation",
    ]
    confidence: float  # 0.0–1.0
    extracted_parameters: dict  # Intent-specific extracted data

7.2 ClassificationInput


class ClassificationInput(BaseModel):
    message: str
    conversation_history: list[dict]  # Simplified turn dicts for the classifier
    engagement_summary: str | None
    project_id: UUID | None

7.3 RouteResult

Per D10.

7.4 ConverseTurn (database model)

Per D1. The SQLAlchemy model for the conversation_turns table.

7.5 ConverseResponse extension

Per D8. One new optional field: classified_intent.


8. Vocabulary-wall compliance

All new schemas in the orchestration module use Operator vocabulary. The existing vocabulary-wall enforcement test (Phase 40) must continue to pass after Phase 42.

Specific checks:

The classified_intent field on ConverseResponse carries the intent label (e.g., "add_knowledge"), which uses Operator vocabulary. It does not expose engine terms.


9. Constants and configuration

| Constant | Initial value | Location | |----------|---------------|----------| | CONFIDENCE_THRESHOLD | 0.7 | classifier.py or orchestration config | | MAX_CONVERSATION_TURNS_IN_CONTEXT | 10 | prompt.py or orchestration config | | MAX_ASSERTIONS_IN_CONTEXT | 20 | prompt.py (Tier 2 loading) | | ENGAGEMENT_CONTEXT_TOKEN_BUDGET | 2000 | prompt.py (approximate, not strict) |

These are module-level constants, not environment variables. They are tuned by experience and changed via code.


10. Error handling

| Condition | Behavior | |-----------|----------| | LLM call fails (classification) | Default to general_conversation with confidence 0.0. Log the failure. Proceed to response generation. | | LLM call fails (response generation) | Return voiced fallback: "I'm having trouble thinking right now — can you try again in a moment?" Return 200 with empty side_effects. Log the failure. | | add_knowledge with no project_id | Responder asks the Operator which project they're referring to. No assertion created. | | request_download with no matching artifact | Responder tells the Operator no matching artifact was found and lists what's available. | | ask_about_progress with no project_id | Responder asks the Operator which project they want to know about. Alternatively, the router could call the cross-engagement dashboard — [CC determines: whether to scope progress queries to a single project or allow cross-engagement. Single-project is simpler and consistent with Phase 42's "one project at a time" scope.] | | Classifier returns invalid JSON | Treat as classification failure. Default to general_conversation. Log. | | Engagement not found or person not a member | Existing verify_project_membership returns 404. No change. |


11. What this CR does not build


12. Test suite

12.1 Classification tests (test_classifier.py)

  1. test_classify_create_project — Message: "I want to start a new project." → intent create_project, confidence ≥ 0.7.
  2. test_classify_create_project_alt — Message: "Let's build something new for tracking expenses." → intent create_project.
  3. test_classify_add_knowledge — Message: "The irrigation system needs to handle three soil types: sandy, clay, and loam." → intent add_knowledge, extracted_parameters["knowledge_text"] contains the substantive content.
  4. test_classify_add_knowledge_alt — Message: "Remember that the deadline is March 15th." → intent add_knowledge.
  5. test_classify_ask_about_progress — Message: "How's the project going?" → intent ask_about_progress.
  6. test_classify_ask_about_progress_alt — Message: "What's been happening since last week?" → intent ask_about_progress.
  7. test_classify_ask_about_past_input — Message: "What have I told you about the pricing model?" → intent ask_about_past_input.
  8. test_classify_ask_about_past_input_alt — Message: "Can you show me my notes?" → intent ask_about_past_input.
  9. test_classify_request_download — Message: "I need to download the report." → intent request_download.
  10. test_classify_request_download_alt — Message: "Can I get the latest artifact?" → intent request_download.
  11. test_classify_finalize_project — Message: "I think the project setup looks good, let's finalize it." → intent finalize_project.
  12. test_classify_general_conversation — Message: "What do you think about adding a glossary section?" → intent general_conversation.
  13. test_classify_general_conversation_default — Message: "hmm" → intent general_conversation (ambiguous, defaults).
  14. test_classify_stub_request_draft — Message: "Can you draft the specification for me?" → intent request_draft.
  15. test_classify_stub_approve_draft — Message: "The draft looks good, approve it." → intent approve_draft.
  16. test_classify_stub_request_revision — Message: "The report needs updating." → intent request_revision.
  17. test_classify_stub_request_redirect — Message: "Move those notes to the other project." → intent request_redirect.
  18. test_classify_confidence_threshold — Ambiguous message → confidence below 0.7 → defaults to general_conversation.
  19. test_classify_intent_hint_overrides — When intent_hint is provided, classifier is not called. Verify by checking that no LLM call is made (mock the LLM, assert not called).

Note on LLM-dependent tests. Classification tests call the LLM. They require a valid key (system config or test fixture). Tests should use the system key fallback pattern. If no key is available in the test environment, these tests should be skipped (not failed) with a clear skip reason. [CC determines: the skip mechanism — pytest marker, environment variable check, or fixture-level skip.]

12.2 Router tests (test_router.py)

  1. test_route_add_knowledge_creates_assertion — Route add_knowledge intent with engagement context → assertion created in held state with ActorRef(kind="companion"). Verify assertion exists, has correct content, is held.
  2. test_route_add_knowledge_no_project — Route add_knowledge intent with no project_id → error result (no assertion created).
  3. test_route_ask_about_progress — Route ask_about_progressoperation_data contains dashboard-derived content.
  4. test_route_ask_about_past_input — Route ask_about_past_input with assertions in engagement → operation_data["assertions"] is non-empty with display numbers.
  5. test_route_ask_about_past_input_empty — Route ask_about_past_input with no assertions → operation_data["assertions"] is empty list.
  6. test_route_request_download — Route request_download with a matching artifact → operation_data["match"] is "unique".
  7. test_route_request_download_no_match — Route request_download with no matching artifact → operation_data["match"] is "none".
  8. test_route_general_conversation — Route general_conversation → returns None (no engine operation).
  9. test_route_stub_intent — Route request_draftRouteResult with is_stub=True and stub_guidance set.
  10. test_route_create_project_delegates — Route create_project → delegates to existing Phase 40 flow. [CC determines: how to verify delegation — mock the Phase 40 function and assert it was called.]
  11. test_route_finalize_project_delegates — Route finalize_project → delegates to existing Phase 40 flow.

12.3 Prompt assembly tests (test_prompt.py)

  1. test_persona_loads — Persona component loads from file. Contains no engine vocabulary terms. Contains {companion_name} and {operator_name} template variables.
  2. test_persona_engine_vocabulary_absent — Scan persona text for forbidden terms (engagement, assertion, shape, render, manifestation, specialist, materializer, normative_force, held, committed, retracted). None found.
  3. test_engagement_context_tier1 — Engagement with no assertions, no Manifestation → Tier 1 context (seed content only).
  4. test_engagement_context_tier2 — Engagement with committed assertions, no Manifestation → Tier 2 context (seed + recent assertions with display numbers).
  5. test_engagement_context_tier3 — Engagement with Manifestation → Tier 3 context (seed + Manifestation structure with group labels).
  6. test_prompt_assembly_contains_companion_name — Assembled prompt contains the person's companion_name.
  7. test_prompt_assembly_contains_intent_instruction — Assembled prompt contains the intent-specific instruction for the classified intent.
  8. test_context_cache_hit — Load context, load again without changes → cache hit (no re-query).
  9. test_context_cache_invalidation — Load context, commit a new assertion, load again → cache miss (re-query). New context includes the new assertion.

12.4 Conversation history tests (test_conversation_turns.py)

  1. test_record_and_retrieve_turns — Record an operator turn and a companion turn. Retrieve recent turns. Verify ordering, content, role.
  2. test_turns_scoped_to_person_and_engagement — Two persons each record turns on different engagements. Each person's query returns only their own turns on the correct engagement.
  3. test_turns_limit — Record 15 turns. Retrieve with limit=10 → most recent 10 returned.
  4. test_turns_null_engagement — Record turns with no engagement (project-less conversation). Retrieve by person + null engagement. Verify returned correctly.

12.5 Integration tests (test_converse_integration.py)

  1. test_converse_add_knowledge_roundtrip — Call POST /operator/converse with message "Remember that the soil pH should be between 6.0 and 7.0." → classified intent add_knowledge → held assertion created → Companion message acknowledges the knowledge substantively. Verify classified_intent in response.
  2. test_converse_ask_about_progress_roundtrip — Call converse with "How's the project coming along?" → Companion message describes project state.
  3. test_converse_ask_about_past_input_roundtrip — Create some assertions on an engagement. Call converse with "What have I told you so far?" → Companion message references the assertions.
  4. test_converse_general_conversation_roundtrip — Call converse with "What do you think about including a troubleshooting section?" → general_conversation → Companion message is domain-informed conversation with no engine side effects. Verify no assertion created, no shape triggered.
  5. test_converse_stub_intent_roundtrip — Call converse with "Can you draft the report for me?" → request_draft stub → Companion message acknowledges the request and explains it's not available yet. Verify response is 200 (not 501).
  6. test_converse_intent_hint_backward_compat — Call converse with intent_hint="create_project" → existing Phase 40 flow runs. Verify response matches pre-Phase-42 behavior.
  7. test_converse_no_project_general — Call converse with no project_id and a general message → general_conversation → Companion responds without project context.
  8. test_converse_turns_recorded — Call converse. Query conversation_turns → operator turn and companion turn recorded.

12.6 Vocabulary-wall regression (test_vocabulary_wall.py)

  1. test_vocabulary_wall_passes_with_phase42_schemas — The existing Phase 40 vocabulary-wall test continues to pass with any new schemas added in Phase 42. [CC determines: whether this is a new test or an extension of the existing test. If the existing test scans the orchestration module automatically, no new test is needed — just verify it passes.]

Expected test count: ~35–45 new tests (consistent with scoping note estimate). The exact count depends on CC's judgment about test granularity and LLM-dependent test handling.


13. Execution steps

| Step | What | Auto/checkpoint | |------|------|-----------------| | 0 | Pre-flight: verify baseline (Section 3.2). Archive CR. Create branch phase-42-intent-classification-and-reactive-companion. | Auto | | 1 | Migration 0059: conversation_turns table (Section 5.1). Verify migration applies cleanly and all existing tests pass. | Auto | | 2 | Conversation turns model and CRUD: SQLAlchemy model, record_turn() and get_recent_turns() functions. Tests §12.4. | Auto | | 3 | Persona component and intent instruction templates: create versioned text assets (D4, D7). Verify no engine vocabulary. Tests §12.3 items 31–32. | Auto | | 4 | Engagement context loader: tiered loading function with cache (D5, D6). Tests §12.3 items 33–39. | Auto | | 5 | Intent classifier: classification module with prompt, LLM call, structured output parsing (D3). Tests §12.1. | Auto | | 6 | Intent router: deterministic dispatch to engine operations, stub markers (D10). Tests §12.2. | Auto | | 7 | Response generator: LLM call with system prompt assembly (D11). Prompt assembly function (D4 template rendering + D5 context + D7 intent instruction). Tests §12.3 items 36–37. | Auto | | 8 | Converse endpoint rewrite: wire classifier → router → responder pipeline. Preserve intent_hint backward compatibility (D9). Integration tests §12.5. Vocabulary-wall regression §12.6. | Auto | | A | Checkpoint A — Operator confirms. Full test suite green. Tag substrate repo: phase-42-intent-classification-and-reactive-companion. | Checkpoint |

Auto-mode posture: Steps 0–8 auto-mode-proceed. Checkpoint A halts for Operator confirmation and tag.


14. Post-CR state (expected)


15. Residues anticipated


16. Kickoff prompt for the Claude Code session


Read the Change Request document at the path I supply below. This is
CR-2026-056 v0.1, the Phase 42 Change Request. You are the executing
agent named in the CR.

CR path: ~/Downloads/phase-42-cr-intent-classification-and-reactive-companion-v0_1.md

Phase 42 gives the Companion a brain (intent classification) and a
voice (system prompt). The converse endpoint is rewritten to use a
classify → route → respond pipeline.

Key points:
  - Four new modules in src/loomworks/orchestration/: classifier.py,
    router.py, prompt.py, responder.py.
  - Versioned text assets for persona, classifier prompt, and intent
    instruction templates — stored outside Python code.
  - One migration: conversation_turns table (0059).
  - Seven live intents, four voiced stubs. Stubs go through the full
    response pipeline (Companion voice, not 501s).
  - Engagement context loader with three tiers: seed-only,
    seed+recent assertions, Manifestation-derived. Informal cache.
  - intent_hint backward compatibility: if provided, skips classifier.
  - Two LLM calls per turn: one for classification, one for response.
  - Classification tests are LLM-dependent — skip if no key available.
  - ~35–45 new substrate tests.

Code baseline: at HEAD after Phase 41 (tag at 74ec7e7). 1,519 tests,
2 skipped, Alembic 0058.

Run pre-flight (Step 0) per Section 3.2. The Step 0 checklist has
21 verification items. Pre-flight divergence stops execution and
drives a CR amendment.

Per Section 13, eight steps with one checkpoint. Auto-mode posture:
Steps 0–8 auto-mode-proceed; Checkpoint A halts for Operator
to confirm and tag.

DUNIN7 — Done In Seven LLC — Miami, Florida Phase 42 CR — Intent classification and reactive Companion — v0.1 — 2026-05-05