Questions

Status: current reference for the implemented queue-only question subsystem. The schema and guarded lifecycle described below are implemented; the final NOT in scope section records rejected or unbuilt ideas.

Design and implementation history (not operating instructions): docs/implemented-plans/questions-end-to-end.md.

Purpose

A question card is the agent borrowing authority it doesn't have — epistemic authority ("is this true / what do you prefer?") or decision authority ("what do I do with this item?"), usually co-occurring. Two things follow from that framing:

Every answer therefore has up to two products:

Never auto-answer. A question that goes unanswered ages out of the active view (see Aging below), but the system never synthesizes a response on the boxholder's behalf — see Prior art in the plan for why (Claude Code's AskUserQuestion timeout default and LangGraph's indefinite-wait interrupts were both considered; this system follows the latter).

Card shape

Schema: src/schemas/question.ts (QuestionSchema, type question).

---
memo: >                      # why you're asking — so the boxholder can
                              # answer without looking anything up
prompt: "…"                  # the actual question
input:
  type: select                # select | text | confirm
  options:                    # select only — at least 2, unique ids and
    - { id: finance, label: "Finance" }   # case-insensitively-unique labels
    - { id: _other, label: "None of these — write a directive" }
learning:                    # optional
  sink: guide                 # guide | briefing | personality
  ref: /_config/finance.guide.card   # optional; for sink briefing MUST be the
                                      # root briefing (only it compiles into
                                      # the box AGENTS.md — see below)
  proposal: >
    Receipts photographed at intake belong in _content/finance/receipts/.
directive: "Move the held file into _content/finance/receipts/."
context:
  - { ref: /_content/inbox/triaged/_unsure/receipt.pdf }
asked-at: 2026-07-10T09:00:00-07:00   # set by the creating template
expires-after: P30D          # optional ISO-8601 duration override
---

Filled in by the answer/dismiss/expire transitions, never by the asker: answer: { text, selected? }, answered-at, answered-via (web | cli), dismissed-at, expired-at. The card has no status field: its state is derived by questionState (question.ts) — answered-at means answered, else dismissed-at dismissed, else expired-at expired, else pending. A superRefine (refineQuestionLifecycle) allows at most one of the three timestamps, requires answer with answered-at, and rejects answer or answered-via without it. Re-answering a dismissed or expired question clears its dismissed-at/expired-at as part of the transition.

Input types and the option-id scheme

Where questions live

All question cards live in _bookkeeping/questions/. This is the only place the system looks for questions: getSystemState (src/core/state.ts) globs _bookkeeping/questions/**/*.question.card to build the question list that feeds the header badge, the questions page, and the aging sweep. A question card written anywhere else (e.g. inside a capture session's .attach/ scope, which scan-import used to do) is invisible to all of that — no page, no badge, no notification, no aging. If a question needs to reference material that lives elsewhere (an attach-scope file, a held inbox item), that's what context: [{ref, text?}] is for — the card itself stays in _bookkeeping/questions/.

Lifecycle

pending ──answer──> answered
   │                    ^
   ├──dismiss──> dismissed ──answer──┘
   │                                 │
   └──(30d)──> expired ──answer──────┘

Before asking something new, agents are expected to check _bookkeeping/questions/ including answered/dismissed/expired cards, not just pending ones — see "The decision rule for agents" below.

Aging: nudge, then expire — never auto-answer

Implemented in src/core/question-aging.ts (ageQuestions), run from the same bbx finalize call site as the pending-question notification sweep (checkPendingQuestionsAndNotify, src/core/question-alert.ts) — both ride bbx finalize's existing per-wakeup cadence.

The answer round-trip

Answering, dismissing, and expiring all go through the same guarded transition helper: withQuestionTransition in src/core/question-transition.ts.

Why a guard, not just withCardLock. withCardLock (src/lib/card-lock.ts) only serializes read-modify-write within one Node process; CLI answer, web answer, bbx finalize, and the scheduler are different processes. withQuestionTransition composes two locks:

  1. The cross-process file lock (src/lib/file-lock.ts, one lock file per question under .beebox/question-locks/) serializes across processes.
  2. withCardLock serializes overlapping in-process callers on top.

After both locks are held, the card is re-read and its state re-checked against the caller's allowedStates — so a transition that lost a race (another process already committed a different state) sees the winner's committed state and is rejected with a clear error, rather than clobbering it.

One commit, write-order matters. Inside the guard, the caller's plan returns a list of writes plus a commit message/trailers. applyAndCommit writes every file, then commits them all in one stageAndCommitPaths call. For answer, the plan lists the follow-up job file before the answered card — the card's new timestamp is the commit point, so the companion job must already exist on disk when it lands. A crash between the two writes leaves job + still-pending-question (harmless — answering again just creates a second job), never the unrecoverable answered-card-with-no-job state that existed before this guard.

Rollback on commit failure. The filesystem writes aren't atomic with the git commit. If stageAndCommitPaths fails, applyAndCommit unstages the paths and restores every touched file to its pre-write snapshot (or deletes it, if it didn't exist before) — so a failed transition leaves the card exactly as it was on disk and stays retryable, instead of stranding an answered-on-disk card that rejects retries.

State guards per transition:

CommandAllowed fromSetsFollow-up job
answer (src/core/commands/answer.ts)pending, expired, dismissedanswer, answered-at, answered-via; removes dismissed-at, expired-atYes
dismiss (src/core/commands/dismiss.ts)pendingdismissed-atNo
aging sweep's expire (src/core/question-aging.ts)pendingexpired-atNo

Follow-up job: the two-product contract

Answering creates a question-followup-job card (src/schemas/question-followup-job.ts) carrying question-ref, directive, the resolved answer text, and (when present) the question's learning passthrough. Its instructions tell the executing agent to do, in order:

  1. Execute directive: using answer:.
  2. If learning: is present, record it in learning.sink as a basis: user-stated belief — quote the answer, ref the question card (same evidence discipline as the retrospective's integrate step). A "no" is also learning: record the decline against the proposal rather than dropping it. For sink briefing, only the root briefing compiles into any agent's context (compileBriefings only compiles the root — directory briefings are an explicit TODO, src/core/docs-gen/compile/core.ts:74-101), so learning.ref must resolve to the root briefing even if it names something else; the job instructions say to resolve and note the substitution.
  3. If learning: is absent, still ask whether the answer generalizes — many answers are precedent even when the asker didn't declare a destination. Record it if it does; skip if it's genuinely a one-off (the common, correct outcome — not a failure).

This is where learning.sink/basis: user-stated actually lands: the follow-up job is the only writer of the belief, and it always writes with that source tag, never a higher-confidence one — a question answer is a boxholder statement, which is exactly what user-stated means in the retro evidence model.

Surfacing

The decision rule for agents

Where an agent is deciding whether to ask, and how, is the operative question — not a separate policy from the lifecycle above. Stated in the QUESTIONS section of src/core/agent-guide/guide.md, which is always-on agent-guide context:

Figuring it out

A question the person hands off, or one that waited long or was put away, is settled by an agent with two box skills (docs/implemented-plans/questions-later-and-figure-it-out.md):

"Decide for me" on the card puts the question away and starts a background chat (actions.figureOut, through deliverUserMessage); the chat is in the chat list. The daily question-review procedure takes questions put away three or more days ago and questions pending fourteen or more days, five per run, and ends each one decided or asked again. When the person later answers a decided question, the follow-up reconciles: it reverses what it can and says what it could not.

NOT in scope

See docs/implemented-plans/questions-end-to-end.md for the full rationale, prior art, and failure-mode analysis behind these decisions.