@longtable/cli 0.1.58 → 0.1.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
- import { execSync } from "node:child_process";
4
+ import { execSync, spawnSync } from "node:child_process";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { createRequire } from "node:module";
7
7
  import { stdin as input, stdout as output, cwd, env, exit } from "node:process";
@@ -157,7 +157,7 @@ function usage() {
157
157
  " longtable sentinel --prompt <text> [--cwd <path>] [--json] [--record]",
158
158
  " longtable ask [--prompt <text>] [--print] [--json] [--setup <path>] [--cwd <path>]",
159
159
  " longtable clarify --prompt <task-context> [--provider codex|claude] [--required|--advisory] [--print] [--cwd <path>] [--json] [--force]",
160
- " longtable question --prompt <decision-context> [--title <text>] [--text <question>] [--provider codex|claude] [--required|--advisory] [--print] [--cwd <path>] [--json]",
160
+ " longtable question (--prompt <decision-context> | --question <id>) [--title <text>] [--text <question>] [--provider codex|claude] [--required|--advisory] [--surface tmux_popup|terminal_selector|numbered] [--print] [--cwd <path>] [--json]",
161
161
  " longtable clear-question --question <id> --reason <text> [--cwd <path>] [--json]",
162
162
  " longtable panel [--prompt <text>] [--role <role[,role]>] [--mode review|critique|draft|commit] [--visibility synthesis_only|show_on_conflict|always_visible] [--provider codex|claude] [--native-workers|--native-subagents] [--wait [ms]] [--print] [--json] [--setup <path>] [--cwd <path>]",
163
163
  " longtable panel status --run <panel_run_id> [--wait [ms]] [--cwd <path>] [--json]",
@@ -166,7 +166,7 @@ function usage() {
166
166
  " longtable panel resume --run <panel_run_id> [--wait [ms]] [--cwd <path>] [--json]",
167
167
  " longtable panel record [--invocation <id>] --result-file <json> [--surface sequential_fallback|native_subagents|native_workers] [--cwd <path>] [--json]",
168
168
  " longtable handoff [--cwd <path>] [--output <file>] [--print] [--json]",
169
- " longtable decide [--question <id>] --answer <value-or-text> [--rationale <text>] [--provider codex|claude] [--cwd <path>] [--json]",
169
+ " longtable decide [--question <id>] --answer <value-or-text> [--rationale <text>] [--provider codex|claude] [--surface tmux_popup|mcp_elicitation|terminal_selector|numbered] [--cwd <path>] [--json]",
170
170
  " longtable explore|review|critique|draft|commit|submit [--prompt <text>] [--role <role[,role]>] [--panel] [--show-conflicts] [--show-deliberation] [--print] [--json] [--stage <stage>] [--setup <path>] [--cwd <path>]",
171
171
  " longtable codex persist-init [--answers-json <json> | --stdin | full setup flags] [--install-skills] [--install-prompts] [--json]",
172
172
  " longtable codex install-skills [--surface compact|full] [--dir <path>]",
@@ -299,6 +299,27 @@ function parseSkillSurface(args) {
299
299
  }
300
300
  throw new Error("Invalid --surface value. Use compact or full.");
301
301
  }
302
+ const QUESTION_SURFACE_VALUES = [
303
+ "native_structured",
304
+ "tmux_popup",
305
+ "mcp_elicitation",
306
+ "numbered",
307
+ "terminal_selector",
308
+ "web_form"
309
+ ];
310
+ function parseQuestionSurface(value) {
311
+ if (value === undefined || value === false) {
312
+ return undefined;
313
+ }
314
+ if (value === true) {
315
+ throw new Error(`Invalid --surface value. Use one of: ${QUESTION_SURFACE_VALUES.join(", ")}.`);
316
+ }
317
+ const normalized = value.trim();
318
+ if (QUESTION_SURFACE_VALUES.includes(normalized)) {
319
+ return normalized;
320
+ }
321
+ throw new Error(`Invalid --surface value. Use one of: ${QUESTION_SURFACE_VALUES.join(", ")}.`);
322
+ }
302
323
  function stripWrappingQuotes(value) {
303
324
  const trimmed = value.trim();
304
325
  if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
@@ -2324,6 +2345,149 @@ function commandAvailable(command) {
2324
2345
  return false;
2325
2346
  }
2326
2347
  }
2348
+ function isRecord(value) {
2349
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
2350
+ }
2351
+ function stringArrayValue(value) {
2352
+ if (Array.isArray(value)) {
2353
+ return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
2354
+ }
2355
+ return typeof value === "string" && value.trim().length > 0 ? [value] : [];
2356
+ }
2357
+ function stringValue(value) {
2358
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
2359
+ }
2360
+ function currentTmuxReturnPane() {
2361
+ const explicit = stringValue(env.OMX_QUESTION_RETURN_PANE);
2362
+ if (explicit) {
2363
+ return explicit;
2364
+ }
2365
+ return stringValue(env.TMUX_PANE);
2366
+ }
2367
+ function codexTmuxPopupAvailable() {
2368
+ return Boolean(currentTmuxReturnPane() && commandAvailable("omx"));
2369
+ }
2370
+ function buildOmxQuestionInput(record) {
2371
+ const options = record.prompt.options.map((option) => ({
2372
+ label: option.label,
2373
+ value: option.value,
2374
+ ...(option.description ? { description: option.description } : {})
2375
+ }));
2376
+ const freeText = record.prompt.type === "free_text";
2377
+ const type = record.prompt.type === "multi_choice" ? "multi-answerable" : "single-answerable";
2378
+ return {
2379
+ header: "LongTable",
2380
+ question: record.prompt.question,
2381
+ questions: [{
2382
+ id: record.id,
2383
+ question: record.prompt.question,
2384
+ options,
2385
+ allow_other: freeText ? true : record.prompt.allowOther,
2386
+ other_label: freeText ? "Answer" : record.prompt.otherLabel ?? "Other",
2387
+ type,
2388
+ multi_select: record.prompt.type === "multi_choice"
2389
+ }],
2390
+ options,
2391
+ allow_other: freeText ? true : record.prompt.allowOther,
2392
+ other_label: freeText ? "Answer" : record.prompt.otherLabel ?? "Other",
2393
+ type,
2394
+ multi_select: record.prompt.type === "multi_choice",
2395
+ source: "longtable",
2396
+ session_id: record.id
2397
+ };
2398
+ }
2399
+ function readOmxAnswerPayload(payload) {
2400
+ const root = isRecord(payload) ? payload : {};
2401
+ const answers = Array.isArray(root.answers) ? root.answers : [];
2402
+ for (const entry of answers) {
2403
+ if (!isRecord(entry)) {
2404
+ continue;
2405
+ }
2406
+ if (entry.answer !== undefined) {
2407
+ return entry.answer;
2408
+ }
2409
+ return entry;
2410
+ }
2411
+ if (root.answer !== undefined) {
2412
+ return root.answer;
2413
+ }
2414
+ return root;
2415
+ }
2416
+ function questionAnswerFromOmxPayload(record, payload) {
2417
+ const answer = readOmxAnswerPayload(payload);
2418
+ if (typeof answer === "string") {
2419
+ return answer;
2420
+ }
2421
+ if (Array.isArray(answer)) {
2422
+ return stringArrayValue(answer);
2423
+ }
2424
+ if (!isRecord(answer)) {
2425
+ throw new Error("OMX question returned an answer payload LongTable cannot parse.");
2426
+ }
2427
+ if (record.prompt.type === "free_text") {
2428
+ const freeText = [
2429
+ stringValue(answer.other_text),
2430
+ stringValue(answer.otherText),
2431
+ stringValue(answer.text),
2432
+ stringValue(answer.value),
2433
+ stringArrayValue(answer.selected_values).join("\n"),
2434
+ stringArrayValue(answer.selectedValues).join("\n")
2435
+ ].find((entry) => entry && entry.trim().length > 0);
2436
+ if (freeText) {
2437
+ return freeText;
2438
+ }
2439
+ }
2440
+ const selectedValues = [
2441
+ ...stringArrayValue(answer.selected_values),
2442
+ ...stringArrayValue(answer.selectedValues)
2443
+ ];
2444
+ const otherText = stringValue(answer.other_text) ?? stringValue(answer.otherText) ?? stringValue(answer.text);
2445
+ if (answer.kind === "other" && otherText) {
2446
+ return { selectedValues: ["other"], otherText };
2447
+ }
2448
+ if (selectedValues.length > 0) {
2449
+ return otherText
2450
+ ? { selectedValues: selectedValues.includes("other") ? selectedValues : [...selectedValues, "other"], otherText }
2451
+ : selectedValues;
2452
+ }
2453
+ const valueValues = stringArrayValue(answer.value);
2454
+ if (valueValues.length > 0) {
2455
+ return valueValues.length === 1 ? valueValues[0] : valueValues;
2456
+ }
2457
+ if (otherText) {
2458
+ return otherText;
2459
+ }
2460
+ throw new Error("OMX question did not include an answer value.");
2461
+ }
2462
+ function invokeOmxQuestionPopup(record) {
2463
+ const returnPane = currentTmuxReturnPane();
2464
+ if (!returnPane || !commandAvailable("omx")) {
2465
+ throw new Error("LongTable tmux_popup transport requires an attached tmux pane and the `omx` command.");
2466
+ }
2467
+ const child = spawnSync("omx", ["question", "--input", JSON.stringify(buildOmxQuestionInput(record)), "--json"], {
2468
+ cwd: cwd(),
2469
+ env: {
2470
+ ...env,
2471
+ OMX_QUESTION_RETURN_PANE: returnPane
2472
+ },
2473
+ encoding: "utf8",
2474
+ stdio: ["ignore", "pipe", "pipe"]
2475
+ });
2476
+ if (child.error) {
2477
+ throw child.error;
2478
+ }
2479
+ if (child.status !== 0) {
2480
+ const detail = [String(child.stderr ?? "").trim(), String(child.stdout ?? "").trim()].filter(Boolean).join("\n");
2481
+ throw new Error(`LongTable tmux_popup transport failed${detail ? `: ${detail}` : "."}`);
2482
+ }
2483
+ const raw = String(child.stdout ?? "").trim();
2484
+ try {
2485
+ return raw ? JSON.parse(raw) : {};
2486
+ }
2487
+ catch {
2488
+ throw new Error(`LongTable tmux_popup transport returned non-JSON output: ${raw.slice(0, 200)}`);
2489
+ }
2490
+ }
2327
2491
  function parseWaitMs(value) {
2328
2492
  if (value === undefined || value === false) {
2329
2493
  return undefined;
@@ -3393,11 +3557,51 @@ async function runSpec(subcommand, args) {
3393
3557
  }
3394
3558
  throw new Error(`Unknown spec subcommand: ${command}`);
3395
3559
  }
3560
+ function questionUsesKorean(record) {
3561
+ return /[가-힣]/.test([
3562
+ record.prompt.title,
3563
+ record.prompt.question,
3564
+ record.prompt.displayReason ?? "",
3565
+ ...record.prompt.options.flatMap((option) => [option.label, option.description ?? ""]),
3566
+ record.prompt.otherLabel ?? ""
3567
+ ].join("\n"));
3568
+ }
3569
+ function renderQuestionDecisionCard(record) {
3570
+ const korean = questionUsesKorean(record);
3571
+ const lines = [
3572
+ korean ? "LongTable 결정 카드" : "LongTable Decision Card",
3573
+ `${korean ? "체크포인트" : "Checkpoint"}: ${record.prompt.title}`,
3574
+ `${korean ? "무엇이 걸렸나" : "What is blocked"}: ${record.prompt.displayReason ?? record.prompt.rationale[0] ?? record.prompt.title}`,
3575
+ `${korean ? "지금 결정할 것" : "Decision needed"}: ${record.prompt.question}`,
3576
+ korean ? "선택지:" : "Choices:"
3577
+ ];
3578
+ record.prompt.options.forEach((option, index) => {
3579
+ const recommended = option.recommended ? (korean ? " (추천)" : " (recommended)") : "";
3580
+ lines.push(`${index + 1}. ${option.label}${recommended}`);
3581
+ if (option.description) {
3582
+ lines.push(` ${option.description}`);
3583
+ }
3584
+ lines.push(` ${korean ? "기록값" : "Record value"}: ${option.value}`);
3585
+ });
3586
+ if (record.prompt.allowOther) {
3587
+ lines.push(`${record.prompt.options.length + 1}. ${record.prompt.otherLabel ?? (korean ? "직접 입력" : "Other")}`);
3588
+ lines.push(` ${korean ? "기록값" : "Record value"}: other`);
3589
+ }
3590
+ return lines.join("\n");
3591
+ }
3396
3592
  async function runQuestion(args) {
3397
3593
  const workingDirectory = typeof args.cwd === "string" ? args.cwd : cwd();
3398
- const prompt = await resolvePrompt(typeof args.prompt === "string" ? args.prompt : undefined);
3399
- if (!prompt) {
3400
- throw new Error("A decision context is required. Pass --prompt <text>.");
3594
+ const requestedSurface = parseQuestionSurface(args.surface);
3595
+ if (requestedSurface &&
3596
+ requestedSurface !== "tmux_popup" &&
3597
+ requestedSurface !== "terminal_selector" &&
3598
+ requestedSurface !== "numbered") {
3599
+ throw new Error("The LongTable question CLI can render tmux_popup, terminal_selector, or numbered surfaces. Use the MCP or provider-native runtime for other surfaces.");
3600
+ }
3601
+ const questionId = typeof args.question === "string" ? args.question.trim() : "";
3602
+ const prompt = questionId ? undefined : await resolvePrompt(typeof args.prompt === "string" ? args.prompt : undefined);
3603
+ if (!prompt && !questionId) {
3604
+ throw new Error("A decision context or pending question id is required. Pass --prompt <text> or --question <id>.");
3401
3605
  }
3402
3606
  const context = await loadProjectContextFromDirectory(workingDirectory);
3403
3607
  if (!context) {
@@ -3405,17 +3609,73 @@ async function runQuestion(args) {
3405
3609
  }
3406
3610
  const provider = args.provider === "claude" ? "claude" : args.provider === "codex" ? "codex" : undefined;
3407
3611
  const required = args.required === true ? true : args.advisory === true ? false : undefined;
3408
- const result = await createWorkspaceQuestion({
3409
- context,
3410
- prompt,
3411
- title: typeof args.title === "string" ? args.title : undefined,
3412
- question: typeof args.text === "string" ? args.text : undefined,
3413
- provider,
3414
- required
3415
- });
3612
+ let result;
3613
+ if (questionId) {
3614
+ const state = await loadWorkspaceState(context);
3615
+ const question = state.questionLog.find((record) => record.id === questionId && record.status === "pending");
3616
+ if (!question) {
3617
+ throw new Error(`No pending LongTable question found for ${questionId}.`);
3618
+ }
3619
+ result = { question, state };
3620
+ }
3621
+ else {
3622
+ result = await createWorkspaceQuestion({
3623
+ context,
3624
+ prompt: prompt ?? "",
3625
+ title: typeof args.title === "string" ? args.title : undefined,
3626
+ question: typeof args.text === "string" ? args.text : undefined,
3627
+ provider,
3628
+ required
3629
+ });
3630
+ }
3416
3631
  const transport = provider === "claude"
3417
3632
  ? renderQuestionRecordInput(result.question)
3418
3633
  : renderQuestionRecordPrompt(result.question);
3634
+ const shouldTryTmuxPopup = requestedSurface === "tmux_popup" ||
3635
+ (requestedSurface === undefined && provider === "codex" && codexTmuxPopupAvailable() && args.print !== true && args.json !== true);
3636
+ if (shouldTryTmuxPopup) {
3637
+ try {
3638
+ const popupPayload = invokeOmxQuestionPopup(result.question);
3639
+ const answer = questionAnswerFromOmxPayload(result.question, popupPayload);
3640
+ const decision = await answerWorkspaceQuestion({
3641
+ context,
3642
+ questionId: result.question.id,
3643
+ answer,
3644
+ provider,
3645
+ surface: "tmux_popup"
3646
+ });
3647
+ if (args.json === true) {
3648
+ console.log(JSON.stringify({
3649
+ question: decision.question,
3650
+ decision: decision.decision,
3651
+ transport: {
3652
+ surface: "tmux_popup",
3653
+ status: "accepted"
3654
+ },
3655
+ files: {
3656
+ state: context.stateFilePath,
3657
+ current: context.currentFilePath
3658
+ }
3659
+ }, null, 2));
3660
+ return;
3661
+ }
3662
+ console.log("LongTable checkpoint decision recorded");
3663
+ console.log(`- question: ${decision.question.id}`);
3664
+ console.log(`- decision: ${decision.decision.id}`);
3665
+ console.log(`- surface: tmux_popup`);
3666
+ console.log(`- answer: ${decision.decision.selectedOption ?? "recorded"}`);
3667
+ console.log(`- state: ${context.stateFilePath}`);
3668
+ console.log(`- current: ${context.currentFilePath}`);
3669
+ return;
3670
+ }
3671
+ catch (error) {
3672
+ if (requestedSurface === "tmux_popup") {
3673
+ throw error;
3674
+ }
3675
+ const message = error instanceof Error ? error.message : String(error);
3676
+ console.error(`LongTable tmux_popup transport unavailable; falling back. ${message}`);
3677
+ }
3678
+ }
3419
3679
  if (args.json === true) {
3420
3680
  console.log(JSON.stringify({
3421
3681
  question: result.question,
@@ -3437,7 +3697,7 @@ async function runQuestion(args) {
3437
3697
  }
3438
3698
  return;
3439
3699
  }
3440
- if (isInteractiveTerminal()) {
3700
+ if ((requestedSurface === undefined || requestedSurface === "terminal_selector") && isInteractiveTerminal()) {
3441
3701
  console.log(renderBrandBanner("LongTable", "Researcher Checkpoint"));
3442
3702
  console.log("");
3443
3703
  const answer = await promptChoice(renderQuestionHeader(1, 1, result.question.prompt.title, result.question.prompt.question), questionRecordToChoices(result.question));
@@ -3457,16 +3717,11 @@ async function runQuestion(args) {
3457
3717
  console.log(`- current: ${context.currentFilePath}`);
3458
3718
  return;
3459
3719
  }
3460
- const optionValues = [
3461
- ...result.question.prompt.options.map((option) => option.value),
3462
- ...(result.question.prompt.allowOther ? ["other"] : [])
3463
- ];
3464
3720
  console.log(result.question.prompt.required ? "LongTable required Researcher Checkpoint recorded" : "LongTable advisory Researcher Checkpoint recorded");
3465
3721
  console.log(`- question: ${result.question.id}`);
3466
3722
  console.log(`- checkpoint: ${result.question.prompt.checkpointKey ?? "manual"}`);
3467
- console.log(`- prompt: ${result.question.prompt.question}`);
3468
- console.log(`- options: ${optionValues.join("/")}`);
3469
- console.log(`- answer: longtable decide --question ${result.question.id} --answer <value>`);
3723
+ console.log(renderQuestionDecisionCard(result.question));
3724
+ console.log(`- answer: longtable decide --question ${result.question.id} --answer <value> --surface numbered`);
3470
3725
  console.log(`- current: ${context.currentFilePath}`);
3471
3726
  }
3472
3727
  async function runClearQuestion(args) {
@@ -3928,12 +4183,14 @@ async function runDecide(args) {
3928
4183
  throw new Error("No LongTable project workspace was found here. Run this inside a project or pass --cwd.");
3929
4184
  }
3930
4185
  const provider = args.provider === "claude" ? "claude" : args.provider === "codex" ? "codex" : undefined;
4186
+ const surface = parseQuestionSurface(args.surface);
3931
4187
  const result = await answerWorkspaceQuestion({
3932
4188
  context,
3933
4189
  questionId: typeof args.question === "string" ? args.question : undefined,
3934
4190
  answer,
3935
4191
  rationale: typeof args.rationale === "string" ? args.rationale : undefined,
3936
- provider
4192
+ provider,
4193
+ ...(surface ? { surface } : {})
3937
4194
  });
3938
4195
  if (args.json === true) {
3939
4196
  console.log(JSON.stringify({
package/dist/debate.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { buildInvocationIntent, buildPanelPlan } from "./panel.js";
2
+ import { buildInvocationIntent, buildPanelDecisionContext, buildPanelPlan } from "./panel.js";
3
3
  import { getPersonaDefinition, parsePersonaKey } from "./personas.js";
4
4
  function nowIso() {
5
5
  return new Date().toISOString();
@@ -167,6 +167,10 @@ function buildSynthesis(plan, artifactPath, kind) {
167
167
  const labels = plan.members.map((member) => member.label);
168
168
  const highSensitivity = plan.checkpointSensitivity === "high";
169
169
  const runLabel = kind === "debate" ? "panel debate" : "panel review";
170
+ const decisionContext = buildPanelDecisionContext(plan.prompt);
171
+ const localizedRunLabel = decisionContext.language === "ko"
172
+ ? (kind === "debate" ? "패널 토론" : "패널 리뷰")
173
+ : runLabel;
170
174
  return {
171
175
  artifactPath,
172
176
  summary: `The ${runLabel} completed across ${labels.join(", ")}. It should slow closure by turning role disagreement into an explicit researcher decision.`,
@@ -187,13 +191,16 @@ function buildSynthesis(plan, artifactPath, kind) {
187
191
  "Choose whether the debate should affect the current artifact, the research design, or only the decision log."
188
192
  ],
189
193
  recommendedCheckpoint: highSensitivity
190
- ? `The ${runLabel} surfaced high-sensitivity disagreement. What should LongTable treat as the next human decision before closure?`
191
- : `The ${runLabel} surfaced role disagreement. Should LongTable revise, verify evidence, proceed, or keep the tension open?`
194
+ ? (decisionContext.language === "ko"
195
+ ? `${localizedRunLabel}에서 ${decisionContext.focus} 대한 높은 민감도의 불일치가 드러났습니다. ${decisionContext.decisionQuestion}`
196
+ : `The ${runLabel} surfaced high-sensitivity disagreement about ${decisionContext.focus}. ${decisionContext.decisionQuestion}`)
197
+ : decisionContext.decisionQuestion
192
198
  };
193
199
  }
194
200
  export function createTeamDebateQuestionRecord(run, provider) {
195
201
  const createdAt = nowIso();
196
202
  const isDebate = run.interactionDepth === "debated";
203
+ const decisionContext = buildPanelDecisionContext(run.prompt);
197
204
  return {
198
205
  id: createId("question_record"),
199
206
  createdAt,
@@ -202,51 +209,32 @@ export function createTeamDebateQuestionRecord(run, provider) {
202
209
  prompt: {
203
210
  id: createId("question_prompt"),
204
211
  checkpointKey: "panel_debate_next_decision",
205
- title: isDebate ? "Panel debate follow-up decision" : "Panel review follow-up decision",
212
+ title: decisionContext.language === "ko"
213
+ ? (isDebate ? "패널 토론 후속 결정" : "패널 리뷰 후속 결정")
214
+ : (isDebate ? "Panel debate follow-up decision" : "Panel review follow-up decision"),
206
215
  question: run.synthesis.recommendedCheckpoint,
207
216
  type: "single_choice",
208
- options: [
209
- {
210
- value: "revise",
211
- label: "Revise before proceeding",
212
- description: "Use the debate result to revise the claim, design, or draft first."
213
- },
214
- {
215
- value: "evidence",
216
- label: "Gather or verify evidence first",
217
- description: "Check source, data, or local artifact support before proceeding."
218
- },
219
- {
220
- value: "proceed",
221
- label: "Proceed with current direction",
222
- description: "Accept the risk profile and continue with the current direction."
223
- },
224
- {
225
- value: "defer",
226
- label: "Keep this open",
227
- description: "Do not commit yet; keep the debate issue visible as an open tension."
228
- }
229
- ],
217
+ options: decisionContext.options,
230
218
  allowOther: true,
231
- otherLabel: "Other decision",
219
+ otherLabel: decisionContext.otherLabel,
232
220
  required: run.roles.some((member) => {
233
221
  const key = parsePersonaKey(member.role);
234
222
  return key ? getPersonaDefinition(key).checkpointSensitivity === "high" : false;
235
223
  }),
236
224
  source: "runtime_guidance",
237
- displayReason: isDebate
238
- ? "Role rebuttals and convergence should connect to an explicit researcher decision."
239
- : "Cross-review created role disagreement that should connect to an explicit researcher decision.",
225
+ displayReason: decisionContext.displayReason,
240
226
  rationale: [
241
227
  "LongTable panel orchestration is a research harness surface, not a substitute for researcher judgment.",
242
228
  isDebate
243
229
  ? "The fixed debate rounds created disagreement that should connect to an explicit researcher decision."
244
230
  : "The cross-review round created disagreement that should connect to an explicit researcher decision.",
231
+ `Panel decision focus: ${decisionContext.focus}.`,
232
+ "Panel follow-up choices are compact by default; unlisted decisions should use Other.",
245
233
  `LongTable panel run: ${run.id}.`
246
234
  ],
247
235
  preferredSurfaces: provider === "claude"
248
236
  ? ["native_structured", "numbered"]
249
- : ["mcp_elicitation", "numbered"]
237
+ : ["tmux_popup", "mcp_elicitation", "numbered"]
250
238
  }
251
239
  };
252
240
  }
@@ -57,12 +57,41 @@ function readCombinedOutput(payload) {
57
57
  safeString(payload.output)
58
58
  ].filter(Boolean).join("\n").trim();
59
59
  }
60
- function formatQuestionOptions(question) {
61
- const options = question.prompt.options.map((option) => option.value);
60
+ function questionUsesKorean(question) {
61
+ return /[가-힣]/.test([
62
+ question.prompt.title,
63
+ question.prompt.question,
64
+ question.prompt.displayReason ?? "",
65
+ ...question.prompt.options.flatMap((option) => [option.label, option.description ?? ""]),
66
+ question.prompt.otherLabel ?? ""
67
+ ].join("\n"));
68
+ }
69
+ function formatQuestionChoices(question) {
70
+ const korean = questionUsesKorean(question);
71
+ const options = question.prompt.options.flatMap((option, index) => {
72
+ const recommended = option.recommended ? (korean ? " (추천)" : " (recommended)") : "";
73
+ return [
74
+ `${index + 1}. ${option.label}${recommended}`,
75
+ ...(option.description ? [` ${option.description}`] : []),
76
+ ` ${korean ? "기록값" : "Record value"}: ${option.value}`
77
+ ];
78
+ });
62
79
  if (question.prompt.allowOther) {
63
- options.push("other");
80
+ options.push(`${question.prompt.options.length + 1}. ${question.prompt.otherLabel ?? (korean ? "직접 입력" : "Other")}`);
81
+ options.push(` ${korean ? "기록값" : "Record value"}: other`);
64
82
  }
65
- return options.join("/");
83
+ return options.join("\n");
84
+ }
85
+ function buildQuestionDecisionCard(question) {
86
+ const korean = questionUsesKorean(question);
87
+ return [
88
+ korean ? "LongTable 결정 카드" : "LongTable Decision Card",
89
+ `${korean ? "체크포인트" : "Checkpoint"}: ${question.prompt.title}`,
90
+ `${korean ? "무엇이 걸렸나" : "What is blocked"}: ${question.prompt.displayReason ?? question.prompt.rationale[0] ?? question.prompt.title}`,
91
+ `${korean ? "지금 결정할 것" : "Decision needed"}: ${question.prompt.question}`,
92
+ korean ? "선택지:" : "Choices:",
93
+ formatQuestionChoices(question)
94
+ ].join("\n");
66
95
  }
67
96
  function pendingRequiredQuestions(state) {
68
97
  return (state.questionLog ?? []).filter((question) => question.status === "pending" && question.prompt.required);
@@ -271,8 +300,8 @@ function buildWorkspaceSummary(runtime, detail = "compact") {
271
300
  }
272
301
  function buildPendingQuestionContext(question) {
273
302
  return [
274
- `Required Researcher Checkpoint is still pending: ${question.prompt.question}`,
275
- `Options: ${formatQuestionOptions(question)}`,
303
+ "Required Researcher Checkpoint is still pending.",
304
+ buildQuestionDecisionCard(question),
276
305
  `Record it with longtable decide --question ${question.id} --answer <value> if you are outside MCP elicitation.`,
277
306
  "Do not choose or record an answer unless the researcher explicitly provides the selection."
278
307
  ].join("\n");
@@ -291,8 +320,7 @@ function buildGeneratedQuestionsContext(questions, created) {
291
320
  : `LongTable found ${questions.length} pending Researcher Checkpoint${questions.length === 1 ? "" : "s"} for this prompt.`
292
321
  ];
293
322
  for (const question of questions) {
294
- lines.push(`- ${question.prompt.title}: ${question.prompt.question}`);
295
- lines.push(` Options: ${formatQuestionOptions(question)}`);
323
+ lines.push(buildQuestionDecisionCard(question));
296
324
  lines.push(` Record it with longtable decide --question ${question.id} --answer <value> if you are outside MCP elicitation.`);
297
325
  }
298
326
  lines.push("Do not choose or record answers for these checkpoints unless the researcher explicitly provides the selections.");
package/dist/panel.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { CheckpointSensitivity, InteractionMode, InvocationIntent, InvocationRecord, PanelPlan, PanelResult, PanelVisibility, QuestionRecord, ProviderKind, InvocationSurface, RoleKey } from "@longtable/core";
1
+ import type { CheckpointSensitivity, InteractionMode, InvocationIntent, InvocationRecord, PanelPlan, PanelResult, PanelVisibility, QuestionOption, QuestionRecord, ProviderKind, InvocationSurface, RoleKey } from "@longtable/core";
2
2
  import { type CanonicalPersona } from "./personas.js";
3
+ import { type OutputLanguage } from "./persona-router.js";
3
4
  export interface BuildPanelPlanOptions {
4
5
  prompt: string;
5
6
  mode?: InteractionMode;
@@ -18,6 +19,16 @@ export interface PanelFallback {
18
19
  questionRecord: QuestionRecord;
19
20
  prompt: string;
20
21
  }
22
+ interface PanelDecisionContext {
23
+ language: OutputLanguage;
24
+ focus: string;
25
+ blockerSummary: string;
26
+ decisionQuestion: string;
27
+ displayReason: string;
28
+ options: QuestionOption[];
29
+ otherLabel: string;
30
+ }
31
+ export declare function buildPanelDecisionContext(prompt: string): PanelDecisionContext;
21
32
  export declare function buildPanelPlan(options: BuildPanelPlanOptions): PanelPlan;
22
33
  export declare function buildInvocationIntent(options: {
23
34
  prompt: string;
@@ -41,3 +52,4 @@ export declare function renderSequentialFallbackPrompt(plan: PanelPlan): string;
41
52
  export declare function buildPanelFallback(options: BuildPanelPlanOptions): PanelFallback;
42
53
  export declare function renderPanelSummary(plan: PanelPlan): string;
43
54
  export declare function listDefaultPanelRoles(): CanonicalPersona[];
55
+ export {};
package/dist/panel.js CHANGED
@@ -61,6 +61,336 @@ function memberForRole(role, explicitRoles, routedRoles) {
61
61
  required: DEFAULT_PANEL_ROLES.includes(role) || explicitRoles.includes(role)
62
62
  };
63
63
  }
64
+ function firstPromptClause(prompt) {
65
+ const projectContextMatch = prompt.match(/(?:^|\n)LongTable project context\n[\s\S]*?\n\n([\s\S]+)$/);
66
+ const sourcePrompt = projectContextMatch?.[1] ?? prompt;
67
+ const normalized = sourcePrompt.replace(/\s+/g, " ").trim();
68
+ const sentenceEnd = normalized.search(/[.!?]\s/);
69
+ const first = sentenceEnd >= 0 ? normalized.slice(0, sentenceEnd + 1) : normalized;
70
+ return first
71
+ .replace(/^(please\s+)?(run\s+)?(a\s+)?(structured\s+)?(panel\s+)?(review|critique|evaluate|assess|inspect)\s+/i, "")
72
+ .replace(/^(whether|if|of)\s+/i, "")
73
+ .replace(/^the\s+/i, "")
74
+ .trim();
75
+ }
76
+ function conciseFocusFromPrompt(prompt) {
77
+ const focus = firstPromptClause(prompt)
78
+ .replace(/[.:;,\s]+$/g, "")
79
+ .trim();
80
+ if (!focus) {
81
+ return "the reviewed issue";
82
+ }
83
+ return focus.length > 96 ? `${focus.slice(0, 93).trim()}...` : focus;
84
+ }
85
+ function languageText(language, en, ko) {
86
+ return language === "ko" ? ko : en;
87
+ }
88
+ function withFocus(template, focus) {
89
+ return template.replace(/\{focus\}/g, focus);
90
+ }
91
+ function shouldIncludeDeferOption(prompt) {
92
+ return /\b(defer|postpone|not decide|keep open|open tension|later|uncertain|unresolved)\b/i.test(prompt)
93
+ || /보류|미루|나중|열린\s*쟁점|열어\s*두|불확실|미해결/.test(prompt);
94
+ }
95
+ const PANEL_DECISION_COPY = {
96
+ manuscript_spine: {
97
+ focus: {
98
+ en: "the manuscript table/figure spine",
99
+ ko: "원고의 표/그림 spine"
100
+ },
101
+ blockerSummary: {
102
+ en: "The panel concern is that the current manuscript shell does not yet carry its argument through a useful table/figure spine.",
103
+ ko: "패널이 멈춘 이유는 현재 원고 shell이 논문 주장을 운반할 표/그림 spine을 충분히 보여주지 못했기 때문입니다."
104
+ },
105
+ recommendedValue: "revise",
106
+ labels: {
107
+ revise: { en: "Revise manuscript table/figure spine", ko: "원고 표/그림 spine을 다시 설계한다" },
108
+ evidence: { en: "Verify table/figure evidence", ko: "표/그림 근거를 먼저 확인한다" },
109
+ proceed: { en: "Proceed with current manuscript direction", ko: "현재 원고 방향을 유지하고 표/그림만 보강한다" },
110
+ defer: { en: "Keep manuscript spine concern open", ko: "원고 spine 우려를 열린 쟁점으로 둔다" }
111
+ },
112
+ descriptions: {
113
+ revise: {
114
+ en: "Use the panel result to redesign the table/figure spine before drafting the manuscript.",
115
+ ko: "패널 결과를 반영해 원고를 쓰기 전에 표/그림 spine을 다시 잡습니다."
116
+ },
117
+ evidence: {
118
+ en: "Check which tables, figures, analyses, and citations can actually support the manuscript claim.",
119
+ ko: "어떤 표, 그림, 분석, 인용이 실제로 원고 주장을 지탱할 수 있는지 먼저 확인합니다."
120
+ },
121
+ proceed: {
122
+ en: "Keep the current manuscript direction and only strengthen the missing table/figure pieces.",
123
+ ko: "현재 원고 방향은 유지하고 부족한 표/그림 요소만 보강합니다."
124
+ },
125
+ defer: {
126
+ en: "Do not settle the manuscript spine yet; keep the concern visible as an open issue.",
127
+ ko: "아직 원고 spine을 확정하지 않고 이 우려를 열린 쟁점으로 남깁니다."
128
+ }
129
+ }
130
+ },
131
+ manuscript_argument: {
132
+ focus: {
133
+ en: "the manuscript or draft argument",
134
+ ko: "원고 또는 초안의 논지"
135
+ },
136
+ blockerSummary: {
137
+ en: "The panel concern is that the manuscript direction could settle before the argument, evidence, and reviewer risk are aligned.",
138
+ ko: "패널이 멈춘 이유는 논지, 근거, 리뷰어 리스크가 정렬되기 전에 원고 방향이 확정될 수 있기 때문입니다."
139
+ },
140
+ recommendedValue: "revise",
141
+ labels: {
142
+ revise: { en: "Revise manuscript argument", ko: "원고 논지를 먼저 수정한다" },
143
+ evidence: { en: "Verify manuscript evidence", ko: "원고 근거를 먼저 확인한다" },
144
+ proceed: { en: "Proceed with current manuscript direction", ko: "현재 원고 방향으로 진행한다" },
145
+ defer: { en: "Keep manuscript concern open", ko: "원고 우려를 열린 쟁점으로 둔다" }
146
+ },
147
+ descriptions: {
148
+ revise: {
149
+ en: "Use the panel result to revise the claim, structure, or draft before proceeding.",
150
+ ko: "패널 결과를 반영해 주장, 구조, 초안을 먼저 수정합니다."
151
+ },
152
+ evidence: {
153
+ en: "Check source, data, artifact, or citation support for the manuscript direction before proceeding.",
154
+ ko: "현재 원고 방향을 지탱할 자료, 데이터, 산출물, 인용 근거를 먼저 확인합니다."
155
+ },
156
+ proceed: {
157
+ en: "Accept the visible risk profile and continue with the current manuscript direction.",
158
+ ko: "드러난 위험을 감수하고 현재 원고 방향으로 계속 진행합니다."
159
+ },
160
+ defer: {
161
+ en: "Do not commit yet; keep the manuscript issue visible as an open tension.",
162
+ ko: "아직 확정하지 않고 원고 쟁점을 열린 긴장으로 남깁니다."
163
+ }
164
+ }
165
+ },
166
+ measurement: {
167
+ focus: {
168
+ en: "the measurement or coding decision",
169
+ ko: "측정 또는 코딩 결정"
170
+ },
171
+ blockerSummary: {
172
+ en: "The panel concern is that measurement or coding rules decide what will count as evidence later.",
173
+ ko: "패널이 멈춘 이유는 측정 또는 코딩 규칙이 이후 무엇을 근거로 볼지 결정하기 때문입니다."
174
+ },
175
+ recommendedValue: "revise",
176
+ labels: {
177
+ revise: { en: "Revise measurement/coding plan", ko: "측정/코딩 계획을 수정한다" },
178
+ evidence: { en: "Verify measurement validity evidence", ko: "측정 타당도 근거를 확인한다" },
179
+ proceed: { en: "Proceed with current measurement/coding plan", ko: "현재 측정/코딩 계획으로 진행한다" },
180
+ defer: { en: "Keep measurement/coding concern open", ko: "측정/코딩 우려를 열린 쟁점으로 둔다" }
181
+ },
182
+ descriptions: {
183
+ revise: {
184
+ en: "Use the panel result to revise the variables, coding rules, or construct boundary before proceeding.",
185
+ ko: "패널 결과를 반영해 변수, 코딩 규칙, 구성개념 경계를 먼저 수정합니다."
186
+ },
187
+ evidence: {
188
+ en: "Check validity, reliability, source, or coding evidence before treating the plan as settled.",
189
+ ko: "타당도, 신뢰도, 출처, 코딩 근거를 확인한 뒤 계획을 확정합니다."
190
+ },
191
+ proceed: {
192
+ en: "Accept the visible measurement risk and continue with the current plan.",
193
+ ko: "드러난 측정 위험을 감수하고 현재 계획으로 계속 진행합니다."
194
+ },
195
+ defer: {
196
+ en: "Do not commit yet; keep the measurement or coding issue visible as an open tension.",
197
+ ko: "아직 확정하지 않고 측정 또는 코딩 쟁점을 열린 긴장으로 남깁니다."
198
+ }
199
+ }
200
+ },
201
+ method: {
202
+ focus: {
203
+ en: "the method or analysis design",
204
+ ko: "방법 또는 분석 설계"
205
+ },
206
+ blockerSummary: {
207
+ en: "The panel concern is that the method or analysis choice could become a hard-to-reverse research commitment.",
208
+ ko: "패널이 멈춘 이유는 방법 또는 분석 선택이 되돌리기 어려운 연구 결정이 될 수 있기 때문입니다."
209
+ },
210
+ recommendedValue: "revise",
211
+ labels: {
212
+ revise: { en: "Revise method/analysis plan", ko: "방법/분석 계획을 수정한다" },
213
+ evidence: { en: "Verify method/analysis evidence", ko: "방법/분석 근거를 확인한다" },
214
+ proceed: { en: "Proceed with current method/analysis plan", ko: "현재 방법/분석 계획으로 진행한다" },
215
+ defer: { en: "Keep method/analysis concern open", ko: "방법/분석 우려를 열린 쟁점으로 둔다" }
216
+ },
217
+ descriptions: {
218
+ revise: {
219
+ en: "Use the panel result to revise the design, model, sample, or analysis plan before proceeding.",
220
+ ko: "패널 결과를 반영해 설계, 모형, 표본, 분석 계획을 먼저 수정합니다."
221
+ },
222
+ evidence: {
223
+ en: "Check data, model, method, or artifact support before treating the plan as settled.",
224
+ ko: "데이터, 모형, 방법, 산출물 근거를 확인한 뒤 계획을 확정합니다."
225
+ },
226
+ proceed: {
227
+ en: "Accept the visible method risk and continue with the current plan.",
228
+ ko: "드러난 방법론적 위험을 감수하고 현재 계획으로 계속 진행합니다."
229
+ },
230
+ defer: {
231
+ en: "Do not commit yet; keep the method or analysis issue visible as an open tension.",
232
+ ko: "아직 확정하지 않고 방법 또는 분석 쟁점을 열린 긴장으로 남깁니다."
233
+ }
234
+ }
235
+ },
236
+ evidence: {
237
+ focus: {
238
+ en: "the evidence or source standard",
239
+ ko: "근거 또는 출처 기준"
240
+ },
241
+ blockerSummary: {
242
+ en: "The panel concern is that LongTable could proceed before the source, artifact, or citation support is explicit.",
243
+ ko: "패널이 멈춘 이유는 출처, 산출물, 인용 근거가 명시되기 전에 LongTable이 진행할 수 있기 때문입니다."
244
+ },
245
+ recommendedValue: "evidence",
246
+ labels: {
247
+ revise: { en: "Revise evidence standard", ko: "근거 기준을 수정한다" },
248
+ evidence: { en: "Verify source/citation support", ko: "출처/인용 근거를 확인한다" },
249
+ proceed: { en: "Proceed with current evidence boundary", ko: "현재 근거 경계로 진행한다" },
250
+ defer: { en: "Keep evidence concern open", ko: "근거 우려를 열린 쟁점으로 둔다" }
251
+ },
252
+ descriptions: {
253
+ revise: {
254
+ en: "Use the panel result to revise what will count as adequate evidence.",
255
+ ko: "패널 결과를 반영해 충분한 근거의 기준을 먼저 수정합니다."
256
+ },
257
+ evidence: {
258
+ en: "Check source, data, artifact, or citation support before proceeding.",
259
+ ko: "진행하기 전에 출처, 데이터, 산출물, 인용 근거를 확인합니다."
260
+ },
261
+ proceed: {
262
+ en: "Accept the visible evidence risk and continue with the current boundary.",
263
+ ko: "드러난 근거 위험을 감수하고 현재 경계로 계속 진행합니다."
264
+ },
265
+ defer: {
266
+ en: "Do not commit yet; keep the evidence issue visible as an open tension.",
267
+ ko: "아직 확정하지 않고 근거 쟁점을 열린 긴장으로 남깁니다."
268
+ }
269
+ }
270
+ },
271
+ theory: {
272
+ focus: {
273
+ en: "the theory or conceptual frame",
274
+ ko: "이론 또는 개념 프레임"
275
+ },
276
+ blockerSummary: {
277
+ en: "The panel concern is that the conceptual frame could settle before its distinctions and limits are explicit.",
278
+ ko: "패널이 멈춘 이유는 개념 구분과 한계가 명확해지기 전에 이론 프레임이 확정될 수 있기 때문입니다."
279
+ },
280
+ recommendedValue: "revise",
281
+ labels: {
282
+ revise: { en: "Revise theory/conceptual frame", ko: "이론/개념 프레임을 수정한다" },
283
+ evidence: { en: "Verify theory support", ko: "이론 근거를 확인한다" },
284
+ proceed: { en: "Proceed with current theory frame", ko: "현재 이론 프레임으로 진행한다" },
285
+ defer: { en: "Keep theory concern open", ko: "이론 우려를 열린 쟁점으로 둔다" }
286
+ },
287
+ descriptions: {
288
+ revise: {
289
+ en: "Use the panel result to revise the conceptual frame, distinctions, or scope before proceeding.",
290
+ ko: "패널 결과를 반영해 개념 프레임, 구분, 범위를 먼저 수정합니다."
291
+ },
292
+ evidence: {
293
+ en: "Check theory, literature, concept, or citation support before treating the frame as settled.",
294
+ ko: "이론, 문헌, 개념, 인용 근거를 확인한 뒤 프레임을 확정합니다."
295
+ },
296
+ proceed: {
297
+ en: "Accept the visible theory risk and continue with the current frame.",
298
+ ko: "드러난 이론적 위험을 감수하고 현재 프레임으로 계속 진행합니다."
299
+ },
300
+ defer: {
301
+ en: "Do not commit yet; keep the theory issue visible as an open tension.",
302
+ ko: "아직 확정하지 않고 이론 쟁점을 열린 긴장으로 남깁니다."
303
+ }
304
+ }
305
+ },
306
+ generic: {
307
+ focus: {
308
+ en: "the reviewed issue",
309
+ ko: "검토된 쟁점"
310
+ },
311
+ blockerSummary: {
312
+ en: "The panel concern is that LongTable has reached a decision point that should belong to the researcher.",
313
+ ko: "패널이 멈춘 이유는 LongTable이 연구자가 직접 정해야 할 결정 지점에 도달했기 때문입니다."
314
+ },
315
+ recommendedValue: "revise",
316
+ labels: {
317
+ revise: { en: "Revise reviewed issue", ko: "검토된 쟁점을 수정한다" },
318
+ evidence: { en: "Verify evidence first", ko: "근거를 먼저 확인한다" },
319
+ proceed: { en: "Proceed with current direction", ko: "현재 방향으로 진행한다" },
320
+ defer: { en: "Keep issue open", ko: "쟁점을 열린 상태로 둔다" }
321
+ },
322
+ descriptions: {
323
+ revise: {
324
+ en: "Use the panel result to revise {focus} before proceeding.",
325
+ ko: "패널 결과를 반영해 {focus}을/를 먼저 수정합니다."
326
+ },
327
+ evidence: {
328
+ en: "Check source, data, artifact, or citation support for {focus} before proceeding.",
329
+ ko: "{focus}을/를 지탱할 출처, 데이터, 산출물, 인용 근거를 먼저 확인합니다."
330
+ },
331
+ proceed: {
332
+ en: "Accept the visible risk profile for {focus} and continue.",
333
+ ko: "{focus}에 대해 드러난 위험을 감수하고 계속 진행합니다."
334
+ },
335
+ defer: {
336
+ en: "Do not commit yet; keep {focus} visible as an open tension.",
337
+ ko: "아직 확정하지 않고 {focus}을/를 열린 긴장으로 남깁니다."
338
+ }
339
+ }
340
+ }
341
+ };
342
+ function decisionDomainFromPrompt(prompt) {
343
+ const normalized = prompt.toLowerCase();
344
+ if (/get[-_ ]?journal|table\/figure|table and figure|figure spine|table spine|manuscript shell|journal manuscript/.test(normalized)) {
345
+ return "manuscript_spine";
346
+ }
347
+ if (/\bmanuscript\b|\bdraft\b|\bpaper\b|\barticle\b|\bsection\b|\bsubmission\b/.test(normalized)) {
348
+ return "manuscript_argument";
349
+ }
350
+ if (/\bmeasurement\b|\bmeasure\b|\bscale\b|\bconstruct\b|\bcoding\b|측정|척도|구성개념|코딩/.test(normalized)) {
351
+ return "measurement";
352
+ }
353
+ if (/\bmethod\b|\bdesign\b|\banalysis\b|\bmodel\b|\bsample\b|\bidentification\b|방법|설계|분석|표본/.test(normalized)) {
354
+ return "method";
355
+ }
356
+ if (/\bevidence\b|\bsource\b|\bcitation\b|\breference\b|\bpdf\b|\bcorpus\b|근거|인용|문헌|자료/.test(normalized)) {
357
+ return "evidence";
358
+ }
359
+ if (/\btheory\b|\bframework\b|\bontology\b|\bconcept\b|이론|개념|온톨로지/.test(normalized)) {
360
+ return "theory";
361
+ }
362
+ return "generic";
363
+ }
364
+ function panelDecisionOptions(copy, language, focus, includeDefer) {
365
+ const values = includeDefer
366
+ ? ["revise", "evidence", "defer"]
367
+ : ["revise", "evidence", "proceed"];
368
+ return values
369
+ .sort((left, right) => Number(right === copy.recommendedValue) - Number(left === copy.recommendedValue))
370
+ .map((value) => ({
371
+ value,
372
+ label: copy.labels[value][language],
373
+ description: withFocus(copy.descriptions[value][language], focus),
374
+ ...(value === copy.recommendedValue ? { recommended: true } : {})
375
+ }));
376
+ }
377
+ export function buildPanelDecisionContext(prompt) {
378
+ const language = detectOutputLanguage(prompt);
379
+ const domain = decisionDomainFromPrompt(prompt);
380
+ const copy = PANEL_DECISION_COPY[domain];
381
+ const domainFocus = copy.focus[language];
382
+ const focus = domain === "generic" ? conciseFocusFromPrompt(prompt) || domainFocus : domainFocus;
383
+ const blockerSummary = copy.blockerSummary[language];
384
+ return {
385
+ language,
386
+ focus,
387
+ blockerSummary,
388
+ decisionQuestion: languageText(language, `What should LongTable treat as the next human decision for ${focus} after this panel review?`, `이 패널 리뷰 이후 LongTable이 ${focus}에 대해 다음 인간 결정으로 기록해야 할 것은 무엇인가요?`),
389
+ displayReason: languageText(language, `${blockerSummary} This should be resolved by the researcher before LongTable treats the direction as settled.`, `${blockerSummary} LongTable이 방향을 확정하기 전에 연구자가 직접 선택해야 합니다.`),
390
+ options: panelDecisionOptions(copy, language, focus, shouldIncludeDeferOption(prompt)),
391
+ otherLabel: languageText(language, "Other decision", "직접 입력")
392
+ };
393
+ }
64
394
  export function buildPanelPlan(options) {
65
395
  const mode = options.mode ?? "review";
66
396
  const explicitRoles = unique([...(options.roles ?? []), ...parseRoleFlag(options.roleFlag)]);
@@ -112,6 +442,7 @@ export function buildInvocationIntent(options) {
112
442
  }
113
443
  export function createPlannedPanelQuestionRecord(plan, provider) {
114
444
  const createdAt = nowIso();
445
+ const decisionContext = buildPanelDecisionContext(plan.prompt);
115
446
  return {
116
447
  id: createId("question_record"),
117
448
  createdAt,
@@ -120,43 +451,24 @@ export function createPlannedPanelQuestionRecord(plan, provider) {
120
451
  prompt: {
121
452
  id: createId("question_prompt"),
122
453
  checkpointKey: "panel_next_decision",
123
- title: "Panel follow-up decision",
124
- question: "What should LongTable treat as the next human decision after this panel review?",
454
+ title: languageText(decisionContext.language, "Panel follow-up decision", "패널 후속 결정"),
455
+ question: decisionContext.decisionQuestion,
125
456
  type: "single_choice",
126
- options: [
127
- {
128
- value: "revise",
129
- label: "Revise before proceeding",
130
- description: "Use the panel result to revise the claim, design, or draft first."
131
- },
132
- {
133
- value: "evidence",
134
- label: "Gather or verify evidence first",
135
- description: "Do not proceed until the relevant evidence or citation support is checked."
136
- },
137
- {
138
- value: "proceed",
139
- label: "Proceed with current direction",
140
- description: "Accept the risk profile and continue with the current direction."
141
- },
142
- {
143
- value: "defer",
144
- label: "Keep this open",
145
- description: "Do not commit yet; keep the panel issue visible as an open tension."
146
- }
147
- ],
457
+ options: decisionContext.options,
148
458
  allowOther: true,
149
- otherLabel: "Other decision",
459
+ otherLabel: decisionContext.otherLabel,
150
460
  required: plan.checkpointSensitivity === "high",
151
461
  source: "runtime_guidance",
152
462
  rationale: [
153
463
  "Panel review creates disagreement or risk visibility that should connect to an explicit researcher decision.",
464
+ `Panel decision focus: ${decisionContext.focus}.`,
465
+ "Panel follow-up choices are compact by default; unlisted decisions should use Other.",
154
466
  `Panel checkpoint sensitivity: ${plan.checkpointSensitivity}.`
155
467
  ],
156
- displayReason: "Panel review can surface role disagreement that should not be collapsed without researcher approval.",
468
+ displayReason: decisionContext.displayReason,
157
469
  preferredSurfaces: provider === "claude"
158
470
  ? ["native_structured", "numbered"]
159
- : ["mcp_elicitation", "numbered"]
471
+ : ["tmux_popup", "mcp_elicitation", "numbered"]
160
472
  }
161
473
  };
162
474
  }
@@ -497,6 +497,14 @@ function formatQuestionMetadata(record) {
497
497
  ].filter(Boolean);
498
498
  return parts.length > 0 ? ` [${parts.join("; ")}]` : "";
499
499
  }
500
+ const QUESTION_SURFACES = new Set([
501
+ "native_structured",
502
+ "tmux_popup",
503
+ "mcp_elicitation",
504
+ "numbered",
505
+ "terminal_selector",
506
+ "web_form"
507
+ ]);
500
508
  function compactLine(value, limit = 160) {
501
509
  const compacted = value.replace(/\s+/g, " ").trim();
502
510
  return compacted.length > limit ? `${compacted.slice(0, limit - 1)}…` : compacted;
@@ -506,6 +514,15 @@ function asRecord(value) {
506
514
  ? value
507
515
  : null;
508
516
  }
517
+ function asStringArray(value) {
518
+ if (Array.isArray(value)) {
519
+ return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
520
+ }
521
+ return typeof value === "string" && value.trim().length > 0 ? [value] : [];
522
+ }
523
+ function isQuestionSurfaceValue(value) {
524
+ return typeof value === "string" && QUESTION_SURFACES.has(value);
525
+ }
509
526
  const SPEC_DIFF_IGNORED_PATHS = new Set([
510
527
  "createdAt",
511
528
  "updatedAt",
@@ -602,10 +619,10 @@ function buildResearchSpecificationGapQuestion(gaps, timestamp, sourceEvidenceId
602
619
  "Research Specification is the required durable interview artifact.",
603
620
  "Missing required sections can make later resume, screening, coding, or evidence decisions stale."
604
621
  ],
605
- preferredSurfaces: ["mcp_elicitation", "numbered"]
622
+ preferredSurfaces: ["tmux_popup", "mcp_elicitation", "numbered"]
606
623
  },
607
624
  transportStatus: {
608
- surface: "mcp_elicitation",
625
+ surface: "tmux_popup",
609
626
  status: "not_attempted",
610
627
  updatedAt: timestamp,
611
628
  ...(sourceEvidenceIds.length > 0 ? { message: `Source evidence: ${sourceEvidenceIds.join(", ")}` } : {})
@@ -2682,7 +2699,7 @@ export async function createWorkspaceFollowUpQuestions(options) {
2682
2699
  const createdAt = nowIso();
2683
2700
  const preferredSurfaces = options.provider === "claude"
2684
2701
  ? ["native_structured", "terminal_selector", "numbered"]
2685
- : ["mcp_elicitation", "terminal_selector", "numbered"];
2702
+ : ["tmux_popup", "mcp_elicitation", "terminal_selector", "numbered"];
2686
2703
  const specs = buildQuestionOpportunitySpecs(options.prompt, {
2687
2704
  includeFallback: options.force === true ? true : options.auto !== true,
2688
2705
  autoOnly: options.auto === true,
@@ -2795,7 +2812,7 @@ export async function createWorkspaceQuestion(options) {
2795
2812
  rationale,
2796
2813
  preferredSurfaces: options.provider === "claude"
2797
2814
  ? ["native_structured", "numbered"]
2798
- : ["mcp_elicitation", "numbered"]
2815
+ : ["tmux_popup", "mcp_elicitation", "numbered"]
2799
2816
  }
2800
2817
  };
2801
2818
  const updated = appendQuestionRecords(state, [question]);
@@ -2998,6 +3015,15 @@ function normalizeQuestionAnswerSelection(question, rawAnswer) {
2998
3015
  ...(inlineRationale ? { inlineRationale } : {})
2999
3016
  };
3000
3017
  }
3018
+ function selectedLabelsForValues(question, selectedValues) {
3019
+ return selectedValues.map((value) => {
3020
+ if (value === "other") {
3021
+ return question.prompt.otherLabel ?? "Other";
3022
+ }
3023
+ const option = question.prompt.options.find((candidate) => candidate.value === value);
3024
+ return option?.label ?? value;
3025
+ });
3026
+ }
3001
3027
  export async function answerWorkspaceQuestion(options) {
3002
3028
  const state = await loadResearchState(options.context.stateFilePath);
3003
3029
  const question = findQuestionForDecision(state, options.questionId);
@@ -3036,7 +3062,12 @@ export async function answerWorkspaceQuestion(options) {
3036
3062
  updatedAt: timestamp,
3037
3063
  status: "answered",
3038
3064
  answer,
3039
- decisionRecordId: decision.id
3065
+ decisionRecordId: decision.id,
3066
+ transportStatus: {
3067
+ surface: answer.surface,
3068
+ status: "accepted",
3069
+ updatedAt: timestamp
3070
+ }
3040
3071
  };
3041
3072
  const withQuestion = {
3042
3073
  ...state,
@@ -3242,6 +3273,58 @@ export async function repairWorkspaceStateConsistency(options) {
3242
3273
  })
3243
3274
  };
3244
3275
  }
3276
+ const repairTimestamp = nowIso();
3277
+ const repairedQuestionLog = (updated.questionLog ?? []).map((record) => {
3278
+ const answerRecord = asRecord(record.answer);
3279
+ if (!answerRecord) {
3280
+ return record;
3281
+ }
3282
+ const selectedValues = [
3283
+ ...asStringArray(answerRecord.selectedValues),
3284
+ ...asStringArray(answerRecord.selectedValue),
3285
+ ...asStringArray(answerRecord.selectedOptions),
3286
+ ...asStringArray(answerRecord.value)
3287
+ ];
3288
+ if (selectedValues.length === 0) {
3289
+ return record;
3290
+ }
3291
+ const selectedLabels = asStringArray(answerRecord.selectedLabels);
3292
+ const needsRepair = typeof answerRecord.promptId !== "string" ||
3293
+ selectedLabels.length === 0 ||
3294
+ !isQuestionSurfaceValue(answerRecord.surface);
3295
+ if (!needsRepair) {
3296
+ return record;
3297
+ }
3298
+ repaired.push(`normalized legacy answer shape for question ${record.id}`);
3299
+ const normalizedAnswer = {
3300
+ promptId: typeof answerRecord.promptId === "string" ? answerRecord.promptId : record.prompt.id,
3301
+ selectedValues: uniqueStrings(selectedValues),
3302
+ selectedLabels: selectedLabels.length > 0
3303
+ ? selectedLabels
3304
+ : selectedLabelsForValues(record, uniqueStrings(selectedValues)),
3305
+ ...(typeof answerRecord.otherText === "string" && answerRecord.otherText.trim()
3306
+ ? { otherText: answerRecord.otherText }
3307
+ : {}),
3308
+ ...(typeof answerRecord.rationale === "string" && answerRecord.rationale.trim()
3309
+ ? { rationale: answerRecord.rationale }
3310
+ : {}),
3311
+ ...(answerRecord.provider === "codex" || answerRecord.provider === "claude"
3312
+ ? { provider: answerRecord.provider }
3313
+ : {}),
3314
+ surface: isQuestionSurfaceValue(answerRecord.surface) ? answerRecord.surface : "numbered"
3315
+ };
3316
+ return {
3317
+ ...record,
3318
+ updatedAt: repairTimestamp,
3319
+ answer: normalizedAnswer
3320
+ };
3321
+ });
3322
+ if (repairedQuestionLog.some((record, index) => record !== (updated.questionLog ?? [])[index])) {
3323
+ updated = {
3324
+ ...updated,
3325
+ questionLog: repairedQuestionLog
3326
+ };
3327
+ }
3245
3328
  if (repaired.length > 0) {
3246
3329
  await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
3247
3330
  await syncCurrentWorkspaceView(options.context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@longtable/cli",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
4
4
  "private": false,
5
5
  "description": "Researcher-facing LongTable CLI",
6
6
  "type": "module",
@@ -29,12 +29,12 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^1.2.0",
32
- "@longtable/checkpoints": "0.1.58",
33
- "@longtable/core": "0.1.58",
34
- "@longtable/memory": "0.1.58",
35
- "@longtable/provider-claude": "0.1.58",
36
- "@longtable/provider-codex": "0.1.58",
37
- "@longtable/setup": "0.1.58"
32
+ "@longtable/checkpoints": "0.1.59",
33
+ "@longtable/core": "0.1.59",
34
+ "@longtable/memory": "0.1.59",
35
+ "@longtable/provider-claude": "0.1.59",
36
+ "@longtable/provider-codex": "0.1.59",
37
+ "@longtable/setup": "0.1.59"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^22.10.1",