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
12 changes: 7 additions & 5 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/hook/server/inbox-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ describe("plannotator inbox mcp (the stdio shim)", () => {
expect((await inboxStatus(box.dataDir)).state).toBe("running");
expect(existsSync(box.browserMarker)).toBe(false);

expect((await client.listTools()).tools.map((t) => t.name).sort()).toEqual(["read_thread", "resolve_message", "send_message", "wait_for_reply"]);
expect((await client.listTools()).tools.map((t) => t.name).sort()).toEqual(["list_decisions", "read_thread", "record_decision", "resolve_message", "send_message", "wait_for_reply"]);
const sent = (
await client.callTool({
name: "send_message",
Expand Down
2 changes: 1 addition & 1 deletion apps/hook/server/inbox-mcp-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import { inboxId } from "@plannotator/core/inbox-types";
import { inboxStatus, type InboxRegistryEntry } from "@plannotator/shared/inbox/registry";

const FILLED_TOOLS = new Set(["send_message", "read_thread", "wait_for_reply"]);
const FILLED_TOOLS = new Set(["send_message", "read_thread", "wait_for_reply", "list_decisions", "record_decision"]);
/** The 2026-07-28 per-request envelope's protocol-version key. */
const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion";

Expand Down
69 changes: 69 additions & 0 deletions packages/core/inbox-questions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type QuestionKind,
} from "./question-block";
import type {
InboxDecisionDraft,
InboxQuestion,
InboxQuestionFacts,
InboxQuestionRecord,
Expand Down Expand Up @@ -175,6 +176,8 @@ export function toInboxQuestion(record: InboxQuestionRecord, threadResolved: boo
sent_reply_id: record.sent_reply_id,
decision_id: record.decision_id,
message_id: record.message_id,
decision_recording: inboxDecisionRecording(record),
decision_draft: record.decision_draft ?? null,
};
}

Expand Down Expand Up @@ -302,3 +305,69 @@ function firstLine(body: string): string | null {
}
return null;
}

// ─────────────────────── Decisions drafted from an answer (step 3) ───────────────────────
//
// The words of a decision drafted from an answer, and whether a question
// records one. The window's decision card shows these words before Send, and
// the server records the same words at Send when the person left them as
// drafted. The approved Workspaces words (`inbox-decision-toggle`,
// 2026-10-06, item 4, "the answer first"): the statement is the answer, the
// Why line is "Asked by <agent>: <question>".

const oneDecisionLine = (value: string): string => value.replace(/\s+/gu, " ").trim();

/** "A", "A and B", "A, B and C". */
function joinWords(parts: readonly string[]): string {
if (parts.length <= 1) return parts.join("");
return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
}

/**
* The statement drafted from an answer: the chosen label(s) as a sentence
* ("Retry with the same idempotency key."), several joined with commas and
* "and", Other or a written answer as written. Null when the answer decides
* nothing: a Skip, or nothing picked or written.
*/
export function draftInboxDecisionText(answer: Pick<QuestionAnswer, "selected" | "other" | "text" | "skipped"> | null): string | null {
if (!answer || answer.skipped) return null;
const parts = [...answer.selected, ...(answer.other ? [answer.other] : []), ...(answer.text ? [answer.text] : [])]
.map(oneDecisionLine)
.filter((part) => part !== "");
if (parts.length === 0) return null;
const joined = joinWords(parts);
const sentence = `${joined.charAt(0).toUpperCase()}${joined.slice(1)}`;
return /[.!?…]$/u.test(sentence) ? sentence : `${sentence}.`;
}

/** The Why line: "Asked by <agent>: <the question>". */
export function draftInboxDecisionReason(askerName: string, prompt: string): string {
return `Asked by ${oneDecisionLine(askerName) || "An agent"}: ${oneDecisionLine(prompt)}`;
}

/**
* Whether Send records this question's answer as a decision: the person's
* switch when they touched it, else on exactly when the block says
* `Decision: when answered`.
*/
export function inboxDecisionRecording(record: Pick<InboxQuestionRecord, "decision_recording" | "decision_on_answer">): boolean {
return record.decision_recording ?? record.decision_on_answer;
}

/**
* The words a decision from this answer records: the card's edits where the
* person made them, the drafted words elsewhere. `text` is null when there
* is nothing to record (a Skip, nothing picked, and no edited statement).
*/
export function inboxDecisionWords(input: {
answer: Pick<QuestionAnswer, "selected" | "other" | "text" | "skipped"> | null;
prompt: string;
askerName: string;
draft: InboxDecisionDraft | null | undefined;
}): { text: string | null; reason: string } {
const edited = input.draft?.text?.trim() || null;
return {
text: input.answer?.skipped ? null : (edited ?? draftInboxDecisionText(input.answer)),
reason: input.draft?.reason ?? draftInboxDecisionReason(input.askerName, input.prompt),
};
}
102 changes: 99 additions & 3 deletions packages/core/inbox-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function ulid(now: number = Date.now()): string {
return timePart + randomPart;
}

export type InboxIdPrefix = "msg" | "prj" | "ses";
export type InboxIdPrefix = "msg" | "prj" | "ses" | "dec";

export function inboxId(prefix: InboxIdPrefix, now?: number): string {
return `${prefix}_${ulid(now)}`;
Expand All @@ -51,6 +51,7 @@ const ID_RE: Record<InboxIdPrefix, RegExp> = {
msg: /^msg_[0-9A-HJKMNP-TV-Z]{26}$/,
prj: /^prj_[0-9A-HJKMNP-TV-Z]{26}$/,
ses: /^ses_[0-9A-HJKMNP-TV-Z]{26}$/,
dec: /^dec_[0-9A-HJKMNP-TV-Z]{26}$/,
};

export function isInboxId(prefix: InboxIdPrefix, value: unknown): value is string {
Expand Down Expand Up @@ -130,6 +131,24 @@ export type InboxAuthor =
name: string | null;
};

/** The hosts whose names the Inbox knows, keyed by `author.host`. */
export const INBOX_HOST_NAMES: Readonly<Record<string, string>> = {
"claude-code": "Claude Code",
claude: "Claude Code",
pi: "Pi",
opencode: "OpenCode",
codex: "Codex",
cursor: "Cursor",
};

/** How the person sees an agent: the name it gave, else its host's name, else "An agent". */
export function inboxAgentName(author: { kind: string; host?: string | null; name?: string | null } | null | undefined): string {
if (!author || author.kind !== "agent") return "An agent";
if (author.name) return author.name;
if (author.host) return INBOX_HOST_NAMES[author.host] ?? author.host;
return "An agent";
}

/** A message or reply. A thread is its root message's id. */
export interface InboxMessage {
id: string;
Expand Down Expand Up @@ -215,6 +234,24 @@ export interface InboxQuestionRecord {
picked_at: string | null;
sent_reply_id: string | null;
decision_id: string | null;
/**
* The card's "Records a decision" switch, as the person set it. Absent
* until they touch it: then a `Decision: when answered` question records
* and any other does not (`inboxDecisionRecording`). Added in step 3.
*/
decision_recording?: boolean;
/** The decision card's words after Done; null or absent: the drafted words. Added in step 3. */
decision_draft?: InboxDecisionDraft | null;
}

/**
* The decision card's words as the person left them on Done. A null field
* was not edited and keeps following the drafted words (the answer, and
* "Asked by <agent>: <question>"), so a later pick still drafts anew.
*/
export interface InboxDecisionDraft {
text: string | null;
reason: string | null;
}

export type InboxQuestionState = "open" | "picked" | "sent" | "closed";
Expand Down Expand Up @@ -247,6 +284,10 @@ export interface InboxQuestion {
sent_reply_id: string | null;
decision_id: string | null;
message_id: string;
/** Whether Send records this answer as a decision (the card's switch, or its default). */
decision_recording: boolean;
/** The decision card's edited words, or null for the drafted ones. */
decision_draft: InboxDecisionDraft | null;
}

/** Workspaces' `NotificationQuestionSummary`: a row's questions, summed. */
Expand Down Expand Up @@ -327,15 +368,70 @@ export interface InboxThread {
messages: InboxMessageWire[];
}

// ─────────────────────────────── Decisions ───────────────────────────────

/**
* A decision's lifecycle (Workspaces' `project_decisions.state`): current
* holds; replaced points at its replacement; retired no longer holds.
*/
export type InboxDecisionState = "current" | "replaced" | "retired";

export interface InboxDecisionAgent {
host: string | null;
session: string | null;
name: string | null;
}

/** Where a decision came from. */
export interface InboxDecisionSource {
/**
* `answer`: recorded at Send from the person's answer to a question;
* `agent`: an agent's record_decision; `person`: the person wrote it (a
* replacement on the Decisions page).
*/
kind: "answer" | "agent" | "person";
/** The answered question (`<message id>/<key>`), for `answer`. */
question_id: string | null;
message_id: string | null;
thread_id: string | null;
/** The asking agent (`answer`) or the recording one (`agent`). */
agent: InboxDecisionAgent | null;
}

/**
* One standing decision in a project: Workspaces' decision fields (text,
* reason, source, state, version, replaces_id, replacement_id), so a later
* sync maps it. `version` counts changes to this record (a retire or a
* replace bumps it; a stale version is refused); a replacement is a new
* record at version 1 that names what it replaces.
*/
export interface InboxDecision {
id: string;
project_id: string;
text: string;
reason: string | null;
source: InboxDecisionSource;
state: InboxDecisionState;
version: number;
replaces_id: string | null;
replacement_id: string | null;
created_at: string;
/** When the state last changed (a retire or a replace); null while it was never changed. */
changed_at: string | null;
/** record_decision's idempotency key, when one was given. */
idempotency_key: string | null;
}

// ─────────────────────────────── Lines ───────────────────────────────

export type InboxRecordKind = "project" | "message" | "question";
export type InboxRecordKind = "project" | "message" | "question" | "decision";

/** One line of the store: a full snapshot of one record. */
export type InboxLine =
| { v: 1; seq: number; at: string; kind: "project"; id: string; record: InboxProject }
| { v: 1; seq: number; at: string; kind: "message"; id: string; record: InboxMessage }
| { v: 1; seq: number; at: string; kind: "question"; id: string; record: InboxQuestionRecord };
| { v: 1; seq: number; at: string; kind: "question"; id: string; record: InboxQuestionRecord }
| { v: 1; seq: number; at: string; kind: "decision"; id: string; record: InboxDecision };

// ─────────────────────────────── Health ───────────────────────────────

Expand Down
Loading
Loading