Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion llp/0106-session-start-classification-hook.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,6 @@ governs, has the user classify the folder before work proceeds.**
consent-and-reversal perimeter; `hyp leave` disables it with the rest.
- The prompt copy is load-bearing (it is many users' first contact with the
class vocabulary) and should be pinned by tests like the other consent
surfaces.
surfaces. Its presentation is pinned too: the ask must render as a native
selection menu where the client has one (Extended-by:
[LLP 0113](./0113-classification-ask-menu-presentation.decision.md)).
68 changes: 68 additions & 0 deletions llp/0113-classification-ask-menu-presentation.decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# LLP 0113: the classification ask is a menu, not an open prompt

**Type:** Decision
**Status:** Accepted
**Systems:** Onboarding, Usage-Policy
**Author:** Phil / Claude
**Date:** 2026-07-20
**Related:** LLP 0103, LLP 0106

> The session-start classification question ([LLP 0106](./0106-session-start-classification-hook.decision.md))
> must reach the user as a **native selection menu** whenever the client
> environment provides one (in Claude Code, the `AskUserQuestion` tool);
> open-ended prose is a fallback for clients with no such tool, never a
> choice the assistant makes. The mandate lives in the shared consent copy,
> so the one-copy invariant of `classification.js` survives.
>
> @ref LLP 0106 [constrained-by] - pins the presentation half its "force is per-client mechanics" bullet left open.

## Context

LLP 0106 decided *when* the classification question is asked and *where the
answer lands*, and deliberately left presentation to per-client mechanics.
The Claude hook delivers the question as SessionStart `additionalContext`:
an instruction the assistant acts on. The copy said "ask the user", so
whether the user saw a real selectable menu (`AskUserQuestion`) or an
open-ended prose question depended on the model's judgment that turn.

On 2026-07-20 a fresh folder (`~/workspace/bingo`) got the prose form; the
user interrupted the turn before any options appeared and read the whole
exchange as "the menu never showed". A consent surface that renders
differently run to run is not predictable enough to be trusted, and prose
is strictly worse here: it is easier to interrupt, easier to answer
ambiguously, and it buries three fixed choices in free text.

## Decision

**The shared consent copy (`buildClassificationPrompt`) instructs the
assistant to present the three classes as a selection menu via the
environment's native question tool, naming `AskUserQuestion` for Claude
Code, and permits plain text only when no such tool exists.**

- The instruction lives in the one shared copy, phrased tool-neutrally with
the Claude tool named as the example. Clients without a menu tool (Codex
today) read the same copy and degrade to the plain-text ask, so the
"hooks differ only in delivery, never in decision or copy" invariant of
`classification.js` is untouched.
- The menu mandate is pinned by the consent-copy tests alongside the class
vocabulary and the verb lines (LLP 0106 consequences: the copy is
load-bearing).

## Alternatives rejected

- **Leave presentation to model judgment**: the status quo; nondeterministic
rendering of a consent surface is the observed failure.
- **Fork the copy per client** (menu copy for Claude, prose copy for Codex):
guarantees the wording per client but breaks the single pinned consent
surface for a difference the tool-neutral phrasing already absorbs.
- **A client-side TUI menu outside the session** (picker redux): retired
permanently by [LLP 0102](./0102-skill-replaces-enrollment-picker.decision.md);
not reopened here.

## Consequences

- Interactive Claude sessions in unclassified folders get a real
`AskUserQuestion` menu; an interrupted ask remains recoverable because the
folder stays unclassified and the next session asks again (LLP 0106).
- Codex behavior is unchanged in form; it inherits the menu instruction
only if its harness ever grows an equivalent tool.
10 changes: 7 additions & 3 deletions src/core/usage-policy/classification.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function verbArgvForClass(cls, targetPath) {
*
* No em dashes and `-` in runtime strings, per the repo style.
*
* @ref LLP 0113 [implements]: the copy itself mandates menu presentation, tool-neutral with the Claude tool named, so tool-less clients degrade to prose
* @param {{ cwd: string }} args
* @returns {string}
*/
Expand All @@ -105,9 +106,12 @@ export function buildClassificationPrompt({ cwd }) {
lines.push(` hyp ignore ${choice.flag} ${cwd}`)
}
lines.push('')
lines.push('Pick one with the user and run its command. If the user is unsure, the safe')
lines.push('choice is local-only (recorded here, never forwarded). This affects only')
lines.push('what HypAware records and forwards; it does not change your task.')
lines.push('Present these three choices as a selection menu using your environment\'s')
lines.push('native question tool (in Claude Code, the AskUserQuestion tool); do not ask')
lines.push('in open-ended text unless no such tool exists. Then run the chosen command.')
lines.push('If the user is unsure, the safe choice is local-only (recorded here, never')
lines.push('forwarded). This affects only what HypAware records and forwards; it does')
lines.push('not change your task.')
return lines.join('\n')
}

Expand Down
4 changes: 4 additions & 0 deletions test/core/usage-policy-classification.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ test('buildClassificationPrompt names the folder, all three classes, and each ve
assert.match(prompt, /hyp ignore --sync \/work\/secret-repo/)
assert.match(prompt, /hyp ignore --local-only \/work\/secret-repo/)
assert.match(prompt, /hyp ignore --private \/work\/secret-repo/)
// @ref LLP 0113 [tests]: the menu mandate is part of the pinned consent copy
assert.match(prompt, /selection menu/)
assert.match(prompt, /AskUserQuestion/)
assert.match(prompt, /unless no such tool exists/)
// Repo style: no em dashes anywhere in the consent copy.
assert.equal(prompt.includes('—'), false)
})
Expand Down