DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-124-open-engagement-intent-v0_2.md

Loomworks change request — Spoken "open X" and "close" intents (the Companion recognizes "open Goosey" / "open 7" as open-an-engagement and "close" as leave-the-engagement, setting/clearing current focus through the CR-2026-123 resolver and seam) — CR-2026-124 — v0.2

Version. 0.2 Date. 2026-06-26 Status. Change request — the sibling to CR-2026-123 (current-engagement spine, now merged at engine main 6f785f9, alembic 0095). Its Step 0 grounding is complete and confirmed (the dependency is satisfied; the classifier surface is grounded — see §2). v0.2 folds in a spoken "close" intent alongside "open X" (Operator decision). CR-2026-123 alone delivers handle resolution at the endpoint; CR-2026-123 + this CR = the full spoken loop — say "open Goosey" → focus sets → "commit held 14" lands in Goosey → "close" → focus clears. Markdown primary (technical consumer: Claude Code). Halt-before-push; per-step commits with the suite green at each commit; explicit-path staging (never git add -A); per-push explicit Operator authorization.

What changed from v0.1. Two changes. (1) Spoken "close" folded in (Operator decision). v0.1 named "close" as an out-of-scope possible follow-on; v0.2 builds it as a second new intent close_engagement alongside open_engagement. It clears focus through the existing set_current_engagement(engagement_id=None) seam — no resolution needed, since "close" names no handle. After "close," the next unqualified instruction with no focus → the Companion asks (the Decision-2 boundary already live in CR-2026-123). Cost: a second intent (so IntentLabel 23 → 25, not 24), a second handler branch, and anti-theft now covering both new intents. The one unverified risk: whether "close"/"done"/"leave" steal existing utterances — Step 1 anti-theft catches it; if it fires, that's a tuning halt. (2) Catch-order fix recorded (correctness, from Step 0). AmbiguousEngagementHandleError is a subclass of EngagementAddressError, so the open-engagement handler must except AmbiguousEngagementHandleError before except EngagementAddressError — otherwise every duplicate-title is swallowed as a plain miss and the "ask which one" path silently never fires. The existing endpoint (me_current_engagement.py) already does this; the handler mirrors it. Step 0 grounding (§2) is now confirmed-live, not to-be-confirmed, and the body reflects the real coordinates CC reported.

CR number (confirmed at Step 0). CR-2026-124 is free — change-requests/ highest is 123, no cr-2026-124* present. Filename, title, and branch stand as written.

Why this is a separate CR, not a step in CR-2026-123. The two pieces are different risk classes. Resolution (CR-2026-123) is a quiet engine lookup tested at the endpoint. This CR touches the Companion's live intent-classification surface — the machinery that decides what every utterance means. A change there can affect how other utterances classify, not just the new one. Isolating it in its own CR gives it its own Step 0 grounding and its own halt conditions, and keeps the resolution work (already once re-scoped) from carrying classifier risk. This separation is the Operator's decision (Option A now → full Option B via this sibling).

Dependency (satisfied). CR-2026-123 is merged — engine main 6f785f9, alembic 0095, the unified resolver and the /me/current-engagement endpoints present. Step 0 (§2) confirmed the resolver signature, the seam, and the error types this CR's handlers map. The hard dependency is met; this CR builds from 6f785f9.


Plain-language summary

What this does. CR-2026-123 made the engine able to resolve an engagement handle (a number, a name, an E-label) and set it as your current focus — but only through a direct endpoint call. This CR adds the spoken trigger: it teaches the Companion that when you say "open Goosey" (or "open 7", or "open E0007"), you mean take me into that engagement, and then it calls the resolver underneath to do it.

The picture. Today the Companion understands a fixed set of instruction kinds — contribute, propose, commit, retract, and so on (23 of them). None of them is "open an engagement." This CR adds one more: an open-engagement intent. When the classifier sees "open Goosey," it routes to a handler that hands "Goosey" to CR-2026-123's resolver, gets back the engagement, and sets your focus. After that, your unqualified instructions ("commit held 14") land there — which is the whole point of the spine.

What you get, end to end (this CR + CR-2026-123):

The care-point. This is a live-classifier change, and v0.2 adds two intents, not one. The risk isn't either new intent misfiring on its own — it's a new intent stealing utterances that should classify as something else (the bigger worry for "close"/"done"/"leave", which are common words), or existing intents shadowing the new ones. The tests pin both directions: the new intents classify correctly, and a sample of existing intents still classify as themselves. If "close" steals an existing utterance, Step 1's anti-theft fails and the example set or placement is tuned before the handler is built.


1. Scope

In scope.

  1. Two new intentsopen_engagement and close_engagement, added to IntentLabel (23 → 25). Both fit the snake_case verb_noun convention Step 0 confirmed.
  2. The classifier learns both — the prompt gains an open-engagement block (examples: "open Goosey", "open 7", "open E0007", "go to Goosey", "take me into Goosey") and a close-engagement block (examples: "close", "leave", "exit", "done here", "I'm done"). Example sets tuned at build against the prompt's existing structure; the close examples get extra anti-theft attention since the words are common.
  3. Two intent-instruction .md filesopen_engagement.md and close_engagement.md, matching the existing per-intent instruction-file pattern (intent_instructions/<intent>.md, plain Operator vocabulary).
  4. Two router handler branches in route_intent:
  1. Spoken disambiguation (open path) — when resolve_engagement_handle raises the ambiguous-handle error (duplicate title), the handler surfaces the candidates as a spoken question ("You have two called Notes — the one numbered 3 or the one numbered 8?") rather than guessing. Catch-order: except AmbiguousEngagementHandleError before except EngagementAddressError (the ambiguous error subclasses the plain miss — see §3 Step 2).
  2. Spoken not-found (open path) — plain miss → "I don't see an engagement called that", no fallback.
  3. Enumeration-guard bumpstest_phase_54 (test_intent_label_includes_new_intents, ~line 587, assert len(args) == 23) and test_phase_55 (line 144, assert len(args) == 23) updated 23 → 25. The nearby test_intent_classifier_md_carries_new_taxonomy_blocks (test_phase_54) is checked when the prompt blocks are added.

Out of scope.


2. Step 0 — classifier-surface grounding (COMPLETE, confirmed live)

Step 0 was run read-only against engine main 6f785f9 (the CR-2026-123 merge). No halt conditions triggered; the classifier surface matches this CR's assumptions. Confirmed coordinates the build uses:

  1. Dependency — satisfied. resolve_engagement_handle at src/loomworks/api/handle_resolver.py:

async def resolve_engagement_handle(*, person_id: UUID, personal_engagement_id: Optional[UUID], handle: str, db: AsyncSession) -> UUID. Success → bare UUID. Plain miss → EngagementAddressError(ValueError) with a human-readable reason. Duplicate title → AmbiguousEngagementHandleError carrying .handle: str and .candidates: list[EngagementHandleCandidate] (each candidate: engagement_id: UUID, display_identifier: str|None (E####), operator_sequence_number: int|None). Catch-order (critical): AmbiguousEngagementHandleError is a subclass of EngagementAddressError — handlers must except AmbiguousEngagementHandleError before except EngagementAddressError. The existing endpoint me_current_engagement.py does exactly this; mirror it. The clear seam: set_current_engagement(*, person_id, engagement_id, db) in persons/current_engagement.pyengagement_id=None clears.

  1. IntentLabelsrc/loomworks/orchestration/classifier.py:48, a Literal[...]. Authoritative count via typing.get_args = 23 (a naive string scan returns 24 — it catches the comment substring ready_for_review; get_args, which the guards use, is the truth). Convention: snake_case, predominantly verb_noun. Adding open_engagement and close_engagement → 25.
  2. Classifier promptsrc/loomworks/orchestration/prompts/intent_classifier.md (304 lines), loaded via load_classifier_prompt() (classifier.py:266). Two insertion points: (a) an intent block among the action intents (<label> at col 0 → 2-space description → Examples: - "..." bullets); (b) the PARAMETER EXTRACTION section (~line 280), one line per param-bearing intent — open_engagement needs a handle-token extraction entry (analogue: commit_assertion: extract held_reference …), e.g. open_engagement: extract engagement_reference (the handle verbatim — name / number / E-label). close_engagement is param-free, so no extraction entry.
  3. Per-intent instruction .mdsrc/loomworks/orchestration/prompts/intent_instructions/<intent>.md, plain Operator-vocabulary prose. Close analogues: commit_assertion.md (5 lines) / add_knowledge.md.
  4. Router handler (declare-and-register) — dispatch is an if intent == "…" chain in route_intent (router.py:2675); each intent → an async def _route_<name>(...); tail falls through to _route_stub(intent) (:2895). route_intent already receives person: Principal, host_account: HostAccount | None, message, db, and classified.extracted_parameters — so the handlers have person.id, host_account.personal_engagement_id (for the resolver), and the handle token. No new plumbing.
  5. Enumeration guards (exact, confirmed)tests/test_phase_54_seed_commit_from_brief.py test_intent_label_includes_new_intents, assert len(args) == 23 (~line 587, under the "CR-2026-121 … brings the total to 23" comment); tests/test_phase_55_engagement_creation_assistance.py:144, assert len(args) == 23. Both → 25. The nearby test_intent_classifier_md_carries_new_taxonomy_blocks (test_phase_54) checks the prompt's taxonomy blocks — verify it when the two blocks are added.
  6. Branch base + baseline — base 6f785f9 (= main = origin/main); suite baseline 2966 passed / 46 skipped; alembic head 0095. No schema change in this CR (classifier-only: 2 IntentLabel members + 2 .md + router branches + tests) — no migration.

Step 0 archival commit (when the build starts). Archive this CR (v0.2) as the branch base off 6f785f9 and record the baseline. Only Step 0 commit. Do not push.


3. Steps (each its own commit; suite green at each; halt before push)

Branch off the CR-2026-123 merge commit. Branch name: cr-2026-124-open-engagement-intent (rename if the number changed at Step 0). Explicit-path staging at every commit.

Step 1 — Add the intent + classifier + instruction (one commit)

The two labels, the classifier's knowledge of both, and their instruction files land together so the classifier is coherent at the commit.

(a) Add both labels — open_engagement and close_engagement — to IntentLabel (classifier.py:48), 23 → 25, snake_case verb_noun.

(b) Add two intent blocks to intent_classifier.md: open-engagement (examples: "open Goosey", "open 7", "open E0007", "go to Goosey", "take me into Goosey") and close-engagement (examples: "close", "leave", "exit", "done here", "I'm done"). Add the PARAMETER EXTRACTION line for open_engagement (extract the handle token); close_engagement is param-free (no extraction line).

(c) Author two instruction files — intent_instructions/open_engagement.md and intent_instructions/close_engagement.md — plain Operator vocabulary, matching the analogue shape.

(d) Bump both enumeration guards 23 → 25 (test_phase_54 ~587; test_phase_55:144). Verify the nearby test_intent_classifier_md_carries_new_taxonomy_blocks accepts the two new blocks.

(e) Tests. New: "open Goosey" / "open 7" / "open E0007" / "go to Goosey" → open_engagement; "close" / "leave" / "done here" → close_engagement. Anti-theft (both directions, extra weight on close): a sample of existing intents (commit, contribute, retract, save, tune, create) still classify as themselves — neither new intent captures them. Pay special attention to whether "close"/"done"/"leave" collide with any existing intent's phrasings; if they do, tune the examples/placement here, before the handlers. Full suite green (baseline + new).

Commit (one): CR-2026-124 step 1: add open_engagement + close_engagement intents + classifier + instructions (IntentLabel 23->25). Do not push.

Halt condition. If adding either intent shifts any existing intent's classification (anti-theft fails), stop and report — the example set or placement is tuned before the handlers are built, not after. The "close" collision is the expected risk; a halt here is a normal tuning loop, not a failure.

Step 2 — The two handlers: open resolves+sets, close clears (one commit)

(a) Add two branches to the route_intent dispatch chain (router.py:2675), each an async def _route_<name>(...), following the existing if intent == "…" pattern.

(b) _route_open_engagement. Extract the handle token from classified.extracted_parameters, call resolve_engagement_handle(person_id=person.id, personal_engagement_id=host_account.personal_engagement_id, handle=<token>, db=db), and:

(c) _route_close_engagement. No token, no resolution. Call set_current_engagement(person_id=person.id, engagement_id=None, db=db) to clear → respond plainly ("Closed — you're not in an engagement now"). After this, the next unqualified turn with null focus → the Companion asks (the Decision-2 boundary already live in CR-2026-123; this handler does not re-implement it).

(d) Authorisation: the open handler sets focus only for the calling person and only to an engagement they're a member of — the resolver + the seam's membership-verify already enforce this. Close clears only the caller's own focus. No new auth surface (if one seems needed, halt).

(e) Tests. Open: "open Goosey" → resolves → focus set → confirmation; "open 7" → sequence-7 engagement; "open 0" / "open Personal" → Personal; "open <nonexistent>" → plain not-found, no focus change; "open <duplicate-title>" → spoken candidates, focus unchanged until a pick; person-scoping holds. Close: "close" → focus cleared → confirmation; a following unqualified turn → asks (no Personal fallback). Catch-order test: a duplicate-title open hits the ambiguous path, not the plain-miss path (pins the subclass ordering).

(f) Suite + DB-truth. Full suite green. DB-truth in playground_dev: "open Goosey" sets host_account.current_engagement_id and a following omitted-project_id converse turn resolves to Goosey; "close" clears it and the next omitted-project_id turn asks.

Commit (one): CR-2026-124 step 2: open/close handlers (resolve+set, clear) with ambiguous-before-miss catch order. Do not push.

Halt condition. If a handler cannot call the resolver/seam with the Step 0 signatures; if focus-set/clear needs an auth surface beyond the existing membership-verify; if the ambiguous path cannot suspend focus-setting pending a pick; or if the catch-order cannot be expressed (ambiguous before miss) — stop and report.

Step 3 — Full-suite regression + database-truth (no commit)

Run the complete suite. Verify against playground_dev the full spoken loop:

  1. Say "open Goosey" → focus set → "commit held 14" (omitted project_id) lands in Goosey.
  2. "open 7" → focus to sequence-7 engagement.
  3. "open E0007" → focus (delegated resolution).
  4. "open 0" → Personal.
  5. "open <duplicate-title>" → spoken candidates question; focus unchanged; a follow-up pick sets it (and confirms ambiguous-before-miss catch order).
  6. "open <nonexistent>" → plain not-found; focus unchanged.
  7. "close" → focus cleared → next unqualified turn asks, no Personal fallback.
  8. Classifier anti-theft: a sample of existing intents still classify as themselves; "close"/"done"/"leave" do not steal them.

Halt and report. Verification-only — no commit.


4. What must not happen in this CR


5. Halt and report

After Step 3, halt before any push. Report: the two new IntentLabel members and the 23→25 enumeration-guard bumps; the classifier example sets added (open + close); the anti-theft result (which existing intents were sampled, all held — and whether "close"/"done"/"leave" needed tuning); the open handler's three response paths (success / plain miss / ambiguous-candidates) and the ambiguous-before-miss catch-order test; the close handler's clear + ask-on-next-turn; the full-suite counts; and the end-to-end database-truth walk (the full spoken loop: open → commit lands → close → asks). On the Operator's review and explicit authorisation, push CR-2026-124. With both CRs pushed, the full spoken open/close loop is live.


6. Reversibility

Two commits, each independently revertible to a green suite. Step 1 (intent + classifier) reverts as one commit; Step 2 (handler) as one. No schema change in this CR (confirmed at Step 0), so no migration to unwind. The anti-theft tests guarantee a revert restores the prior classification behaviour exactly.


DUNIN7 — Done In Seven LLC — Miami, Florida Spoken "open X" and "close" intents — change request — CR-2026-124 — v0.2 — 2026-06-26