Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4Y2NW9XRNPVGBNGMBY8C949
anchor: '@file'
created: 2026-10-14T20:46:49Z
norm: '1'
sig: e3b0c44298fc1c14
body_hash: 47b647c28e30c9c9
raw_hash: 734a6028aafb423f
lines: 1-248
---

Written working answers are already shared and persisted before confirmation; the bar must name Record decision when it submits a text card.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4Y22V960BZBXCNHQFSSV7HB
anchor: fn read
created: 2026-10-14T20:36:25Z
norm: '1'
---

response:text questions use the existing custom CRDT string; options are candidate approaches and selecting them or changing to choices mode is invalid. Omitted response preserves legacy custom answers and choice behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4Y23FC9YN2V7VYTGHWKEZFQ
anchor: function read
created: 2026-10-14T20:36:46Z
norm: '1'
sig: 53a70e80e512af0e
body_hash: 993f39e09058001c
raw_hash: e7d7b8ed8401e9c9
lines: 105-168
---

response:text questions use the existing custom CRDT string; options are candidate approaches and selecting them or changing to choices mode is invalid. Omitted response preserves legacy custom answers and choice behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4Y1XZ9DG6XCT3BD76KF3JHX
anchor: fn textSplice
created: 2026-10-14T20:33:46Z
norm: '1'
---

Keep unchanged CRDT characters rather than replacing the entire answer; their identities let independent concurrent prose edits merge.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4Y2YBCZNSTZM445TRMG1CJ6
anchor: function identity
created: 2026-10-14T20:51:26Z
norm: '1'
sig: 6ccddf877d71f197
body_hash: 4419e4203bde5f72
raw_hash: e394b3a4e1032f41
lines: 14-24
---

The creation key is scoped to the document and actor. Retries return that identity even after the card is retitled or resolved; edited creation-form content must get a new key.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
id: 01M4ZH59XSDNTWZRVCR820786D
anchor: '@file'
created: 2026-10-15T10:19:09Z
norm: '1'
sig: e3b0c44298fc1c14
body_hash: 22ab904585c73cd7
raw_hash: 7da7fc222aa455dc
lines: 1-239
supersedes: 01M4Y2NW9XRNPVGBNGMBY8C949
---

Written answers share the same Save action and submission lifecycle as choice answers; draft persistence does not resolve a decision.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
id: 01M4Y453E7H0N1PFZG283QF0X3
anchor: '@file'
created: 2026-10-14T21:12:37Z
norm: '1'
sig: e3b0c44298fc1c14
body_hash: f96cb85c330188d4
raw_hash: b78bc213d87b9283
vouched: 2026-10-15T10:28:40Z
lines: 1-5857
---

Phone working-answer fields confirm through the shared bar; only the option composer may suppress it on focus because that composer owns its own confirm control.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
id: 01M4ZH59K0X3C334MM19VF7Z11
anchor: function read
created: 2026-10-15T10:19:09Z
norm: '1'
sig: 53a70e80e512af0e
body_hash: 993f39e09058001c
raw_hash: e7d7b8ed8401e9c9
lines: 105-168
supersedes: 01M4Y23FC9YN2V7VYTGHWKEZFQ
---

response:text questions use the existing custom CRDT string and have no options. Selecting options or changing to choices mode is invalid. Omitted response preserves legacy custom answers and choice behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4Y3410819GT9GBNV09MN4GZ
anchor: function textSplice
created: 2026-10-14T20:54:33Z
norm: '1'
sig: b29b0c3dbecf4d97
body_hash: ce7d63825165f5be
raw_hash: d216a4502796f8d8
lines: 2-18
---

Keep unchanged CRDT characters intact when applying textarea edits so another collaborator’s concurrent prose edits can merge. Never split a UTF-16 surrogate pair at either splice boundary.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ in a local checkout and publishes captured evidence to Chopin's native views.
| Understand the system | [Architecture](docs/architecture.md) |
| Understand channel identity and access | [Repository channels](docs/channels.md) |
| Understand persistence | [Storage](docs/storage.md) |
| Capture and record open decisions | [Decisions](docs/decisions.md) |
| Review the hosted agent boundary | [Hosted agent](docs/hosted-agent.md) |
| Register durable background jobs | [Background jobs](docs/background-jobs.md) |
| Review experimental implementation handoff | [Implementation lifecycle](docs/implementation-lifecycle.md) |
Expand Down
31 changes: 30 additions & 1 deletion apps/server/src/agent/arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export type PositionedQuestion = {
header: string;
question: string;
description?: string;
response?: "text";
options: Array<{ label: string; description: string }>;
multiple: boolean;
blocks: BlockAddress[];
Expand All @@ -268,7 +269,15 @@ export function askPlan(raw: unknown): { revision: number; questions: Positioned
.map((raw, index) => {
let name = `questions[${index}]`;
let question = record(raw, name);
fields(question, ["header", "question", "description", "options", "multiple", "blocks"], [
fields(question, [
"header",
"question",
"description",
"response",
"options",
"multiple",
"blocks",
], [
"header",
"question",
"options",
Expand All @@ -283,6 +292,7 @@ export function askPlan(raw: unknown): { revision: number; questions: Positioned
header: question.header,
question: question.question,
...(Object.hasOwn(question, "description") ? { description: question.description } : {}),
...(Object.hasOwn(question, "response") ? { response: question.response } : {}),
options: question.options,
multiple: question.multiple,
}],
Expand All @@ -292,6 +302,7 @@ export function askPlan(raw: unknown): { revision: number; questions: Positioned
header: normalized.header,
question: normalized.question,
...(normalized.description === undefined ? {} : { description: normalized.description }),
...(normalized.response ? { response: normalized.response } : {}),
options: normalized.options.map(option => ({
label: option.label,
description: option.description,
Expand All @@ -304,6 +315,24 @@ export function askPlan(raw: unknown): { revision: number; questions: Positioned
return { revision, questions };
}

export function openDecision(raw: unknown) {
let args = record(raw, "open_decision arguments");
fields(
args,
["revision", "key", "question", "description"],
["revision", "key", "question"],
"open_decision arguments",
);
return {
revision: integer(args.revision, "revision"),
key: text(args.key, "key"),
question: text(args.question, "question"),
...(Object.hasOwn(args, "description")
? { description: args.description === "" ? "" : text(args.description, "description", 300) }
: {}),
};
}

const GRAPH_OPERATION_FIELDS = ["op", "id", "task", "ids"];

/** Every field a graph task carries; `tools.ts` advertises exactly these. */
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/agent/heading-harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ test("current Harness executes heading against durable memory before returning i
for (
let name of [
"edit_plan",
"open_decision",
"ask",
"anchor_plan",
"reply_comment",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/agent/job-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe("background Planner job scope", () => {
for (
let name of [
"edit_plan",
"open_decision",
"ask",
"anchor_plan",
"reply_comment",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/agent/job-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const JOB_TOOLS: Record<ConversationPlan.JobKind, string> = {

export const WRITE_TOOLS: ReadonlySet<string> = new Set([
"edit_plan",
"open_decision",
"ask",
"anchor_plan",
"reply_comment",
Expand Down
73 changes: 73 additions & 0 deletions apps/server/src/agent/open-decision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { expect, test } from "bun:test";
import { documentTools } from "./tools";
import { askPlan, openDecision } from "./arguments";
import { openPlan } from "../testing/plan";
import * as Service from "../plan/service";
import * as Questions from "../questions/service";
import { member } from "../questions/text-response.test-fixtures";
import type { DocumentRoom } from "./tools";

test("ask accepts a written decision without fabricated alternatives", () => {
let question = {
header: "Onboarding",
question: "How should onboarding work?",
response: "text" as const,
multiple: false,
options: [],
blocks: [],
};
expect(askPlan({ revision: 0, questions: [question] }).questions[0]).toEqual(question);
let { response: _response, ...choice } = question;
expect(() => askPlan({ revision: 0, questions: [choice] })).toThrow(
"requires at least one option",
);
expect(() =>
openDecision({ revision: 0, key: "onboarding", question: "How?", answer: "Use a tour" })
)
.toThrow("unexpected field");
});

test("open_decision returns an open card promptly, preserves one identity on retry and refuses stale work", async () => {
let fixture = await openPlan();
let anchors = 0;
let room: DocumentRoom = {
id: fixture.plan.id,
plan: fixture.plan,
server: fixture.server,
persist: () => Service.persist(fixture.plan),
exclusive: action => Service.exclusive(fixture.plan, action),
publish: async () => {},
anchors: () => {
anchors++;
},
changes: () => {},
};
let options = { toolCallId: "open-one", messages: [], context: { room } };
let input = { revision: 0, key: "onboarding", question: "How should onboarding work?" };
try {
let first = await documentTools.open_decision.execute!(input, options as never);
expect(JSON.parse(first as string)).toMatchObject({ id: expect.any(String), status: "open" });
expect(fixture.plan.records.size).toBe(1);
expect(anchors).toBe(1);
let again = await documentTools.open_decision.execute!(input, options as never);
expect(again).toEqual(first);
let stale = await documentTools.open_decision.execute!(
{ ...input, key: "new-decision" },
options as never,
);
expect(stale).toContain("changed");
expect(fixture.plan.records.size).toBe(1);
let id = JSON.parse(first as string).id as string;
let { ws } = member();
await Questions.discard(fixture.plan, fixture.server, fixture.plan.id, ws, {
kind: "question:discard",
ts: 0,
rid: "discard-one",
id,
});
let settled = await documentTools.open_decision.execute!(input, options as never);
expect(JSON.parse(settled as string)).toEqual({ id, status: "discarded" });
} finally {
await Service.close(fixture.plan);
}
});
1 change: 1 addition & 0 deletions apps/server/src/agent/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ test("document tool names and schemas remain available to the Planner", async ()
"create_research_workspace",
"assess_visual",
"edit_plan",
"open_decision",
"ask",
"read_implementation_graph",
"edit_implementation_graph",
Expand Down
45 changes: 42 additions & 3 deletions apps/server/src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,10 +431,48 @@ export const documentTools = {
}),
}),

open_decision: tool({
contextSchema: ownerContext,
description: "Capture a named human decision before alternatives or an answer are known. "
+ "Creates an open card with a shared working answer and returns immediately. "
+ "Use for choices such as how onboarding should work, not factual questions the "
+ "repository or research can settle. A recommendation is not agreement: only a "
+ "person pressing Save resolves this card. Reuse the same key when "
+ "retrying the same capture. Do not use ask for the same decision as well.",
inputSchema: jsonSchema({
type: "object",
properties: {
revision: { type: "integer", minimum: 0 },
key: {
type: "string",
pattern: "^[A-Za-z0-9_-]{8,64}$",
description: "Stable request key for this decision, such as onboarding-approach.",
},
question: { type: "string", minLength: 1, maxLength: 100 },
description: { type: "string", maxLength: 300 },
},
required: ["revision", "key", "question"],
additionalProperties: false,
}),
metadata: { skipPermission: true },
execute: (raw, { context: { room: context } }) =>
answer("open_decision", async () => {
let { revision, ...input } = Arguments.openDecision(raw);
let id = await Questions.capture(context.plan, context.server, context.id, input, {
revision,
});
context.anchors();
let status = Questions.meta(context.plan, context.plan.records.get(id)!).status;
return { id, status };
}),
}),

ask: tool({
contextSchema: ownerContext,
description: "Ask the people in the room one or more multiple-choice questions and wait for "
+ "their shared answer. Every question also accepts free text. Batch related "
description: "Ask the people in the room one or more decisions and wait for "
+ "their shared answer. Use response: text with options: [] for a written answer; "
+ "omit response for existing multiple-choice questions. Use open_decision "
+ "to capture a decision without waiting. Batch related "
+ "questions into one call. The questionnaire is recorded in the plan and the "
+ "answer is attributed to whoever gave it. If current_plan is present after "
+ "an answer, it has read_plan's revision, source, and blocks; edit from that "
Expand Down Expand Up @@ -469,9 +507,10 @@ export const documentTools = {
maxLength: 300,
description: "Optional supporting context shown only in the full decision card.",
},
response: { type: "string", enum: ["text"] },
options: {
type: "array",
minItems: 1,
minItems: 0,
maxItems: 20,
items: {
type: "object",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/harness/tool-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const PLANNER_TOOL_NAMES = [
"read_background_job",
"create_research_workspace",
"edit_plan",
"open_decision",
"ask",
"read_implementation_graph",
"edit_implementation_graph",
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,10 @@ async function receive(ws: Socket, raw: string): Promise<void> {
});
return;

case "question:create":
if (room.plan) await Questions.createDecision(room.plan, server, room.id, ws, frame);
return;

case "question:open":
if (room.plan) Questions.open(room.plan, ws, frame);
return;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/plan/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ function definitionShape(value: Question.Definition) {
question.question,
question.description,
question.multiple,
question.response,
question.options.map(option => [option.id, option.label, option.description]),
]);
}
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/questions/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function matches(record: Record, projections: Questionnaire[]): boolean {
&& projected.id === question.id && projected.header === question.header
&& projected.prompt === question.question && projected.description === question.description
&& projected.multiple === question.multiple
&& projected.response === question.response
&& projected.options.length === question.options.length
&& projected.options.every((option, index) => {
let expected = question.options[index]!;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/questions/record-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ export function unix(value: unknown): number {
}

export type Known = {
questions: Map<string, { multiple: boolean; verbatim: boolean }>;
questions: Map<string, { multiple: boolean; verbatim: boolean; response?: "text" }>;
options: Map<string, string>;
};
Loading
Loading