@longtable/cli 0.1.55 → 0.1.57

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.
@@ -1,4 +1,4 @@
1
- import type { DecisionRecord, EvidenceRecord, InvocationRecord, LongTableQuestionObligation, ProviderKind, QuestionOption, HardStopScope, QuestionCommitmentFamily, QuestionEpistemicBasis, QuestionGenerationResult, QuestionOpportunity, QuestionSurface, QuestionPromptType, QuestionRecord, ResearchSpecificationChange, ResearchSpecificationPatch, ResearchSpecificationPatchSource, ResearchSpecificationReadiness, ResearchSpecificationRevision, ResearchState } from "@longtable/core";
1
+ import type { DecisionRecord, EvidenceRecord, InvocationRecord, InvocationStatus, InvocationSurface, LongTableQuestionObligation, PanelMemberResult, ProviderKind, QuestionOption, HardStopScope, QuestionCommitmentFamily, QuestionEpistemicBasis, QuestionGenerationResult, QuestionOpportunity, QuestionSurface, QuestionPromptType, QuestionRecord, ResearchSpecificationChange, ResearchSpecificationPatch, ResearchSpecificationPatchSource, ResearchSpecificationReadiness, ResearchSpecificationRevision, ResearchState } from "@longtable/core";
2
2
  import type { SetupPersistedOutput } from "@longtable/setup";
3
3
  import { type HardStopVerdict } from "@longtable/core";
4
4
  export type ProjectDisagreementPreference = "synthesis_only" | "show_on_conflict" | "always_visible";
@@ -269,6 +269,32 @@ export interface LongTableWorkspaceInspection {
269
269
  suggestion?: string;
270
270
  }>;
271
271
  }
272
+ export interface PanelResultRecordInput {
273
+ invocationId?: string;
274
+ status?: InvocationStatus;
275
+ surface?: InvocationSurface;
276
+ synthesis?: string;
277
+ conflictSummary?: string;
278
+ decisionPrompt?: string;
279
+ memberResults?: Array<Partial<PanelMemberResult> & {
280
+ role: string;
281
+ }>;
282
+ }
283
+ export interface PanelResultRecordOutput {
284
+ invocation: InvocationRecord;
285
+ evidenceRecords: EvidenceRecord[];
286
+ state: LongTableWorkspaceState;
287
+ }
288
+ export interface LongTableHandoffOutput {
289
+ id: string;
290
+ createdAt: string;
291
+ path: string;
292
+ content: string;
293
+ sourceEvidenceIds: string[];
294
+ pendingQuestionIds: string[];
295
+ proposedPatchIds: string[];
296
+ latestInvocationId?: string;
297
+ }
272
298
  export declare function loadWorkspaceState(context: LongTableProjectContext): Promise<LongTableWorkspaceState>;
273
299
  export declare function diffResearchSpecifications(before: ResearchSpecification | undefined, after: ResearchSpecification): ResearchSpecificationChange[];
274
300
  export declare function applyResearchSpecificationAuditUpdate(state: LongTableWorkspaceState, options: {
@@ -291,6 +317,14 @@ export declare function applyResearchSpecificationAuditUpdate(state: LongTableWo
291
317
  };
292
318
  export declare function syncCurrentWorkspaceView(context: LongTableProjectContext): Promise<string>;
293
319
  export declare function appendInvocationRecordToWorkspace(context: LongTableProjectContext, invocation: InvocationRecord, questions?: QuestionRecord[]): Promise<LongTableWorkspaceState>;
320
+ export declare function recordPanelResultInWorkspace(options: {
321
+ context: LongTableProjectContext;
322
+ result: PanelResultRecordInput;
323
+ }): Promise<PanelResultRecordOutput>;
324
+ export declare function createWorkspaceHandoff(options: {
325
+ context: LongTableProjectContext;
326
+ outputPath?: string;
327
+ }): Promise<LongTableHandoffOutput>;
294
328
  export declare function beginLongTableInterview(options: {
295
329
  context: LongTableProjectContext;
296
330
  provider?: ProviderKind;
@@ -456,6 +490,7 @@ export declare function pruneWorkspaceQuestions(options: {
456
490
  }>;
457
491
  export declare function repairWorkspaceStateConsistency(options: {
458
492
  context: LongTableProjectContext;
493
+ dryRun?: boolean;
459
494
  }): Promise<{
460
495
  state: ResearchState;
461
496
  repaired: string[];
@@ -497,6 +497,98 @@ 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
+ "mcp_elicitation",
503
+ "numbered",
504
+ "terminal_selector",
505
+ "web_form"
506
+ ]);
507
+ function asStringArray(value) {
508
+ if (Array.isArray(value)) {
509
+ return value.filter((entry) => typeof entry === "string");
510
+ }
511
+ return typeof value === "string" ? [value] : [];
512
+ }
513
+ function legacyQuestionAnswerRecord(record) {
514
+ return asRecord(record.answer);
515
+ }
516
+ function labelForQuestionAnswerValue(record, value) {
517
+ const option = record.prompt.options.find((candidate) => candidate.value === value || candidate.label === value);
518
+ if (option) {
519
+ return option.label;
520
+ }
521
+ if (value === "other") {
522
+ return record.prompt.otherLabel ?? "Other";
523
+ }
524
+ return value;
525
+ }
526
+ function selectedValuesForQuestion(record) {
527
+ const answer = legacyQuestionAnswerRecord(record);
528
+ if (!answer) {
529
+ return [];
530
+ }
531
+ const directValues = asStringArray(answer.selectedValues);
532
+ if (directValues.length > 0) {
533
+ return directValues;
534
+ }
535
+ const legacyValues = [
536
+ ...asStringArray(answer.selectedValue),
537
+ ...asStringArray(answer.selected),
538
+ ...asStringArray(answer.selectedOption),
539
+ ...asStringArray(answer.answer),
540
+ ...asStringArray(answer.value)
541
+ ];
542
+ return uniqueStrings(legacyValues);
543
+ }
544
+ function selectedLabelsForQuestion(record, selectedValues) {
545
+ const answer = legacyQuestionAnswerRecord(record);
546
+ const directLabels = asStringArray(answer?.selectedLabels);
547
+ if (directLabels.length > 0) {
548
+ return directLabels;
549
+ }
550
+ return selectedValues.map((value) => labelForQuestionAnswerValue(record, value));
551
+ }
552
+ function legacyAnswerShapeWarnings(questions) {
553
+ return questions.flatMap((record) => {
554
+ if (record.status !== "answered" || !legacyQuestionAnswerRecord(record)) {
555
+ return [];
556
+ }
557
+ const selectedValues = asStringArray(legacyQuestionAnswerRecord(record)?.selectedValues);
558
+ const selectedLabels = asStringArray(legacyQuestionAnswerRecord(record)?.selectedLabels);
559
+ if (selectedValues.length > 0 && selectedLabels.length > 0) {
560
+ return [];
561
+ }
562
+ return [{
563
+ questionId: record.id,
564
+ ...(record.decisionRecordId ? { decisionRecordId: record.decisionRecordId } : {}),
565
+ issue: "Answered question uses a legacy answer shape that is missing selectedValues or selectedLabels.",
566
+ suggestion: "Run `longtable repair-state --cwd <project-path>` to normalize the answer without changing the recorded selection."
567
+ }];
568
+ });
569
+ }
570
+ function numericOtherAnswerWarnings(questions) {
571
+ return questions.flatMap((record) => {
572
+ if (record.status !== "answered" || !selectedValuesForQuestion(record).includes("other")) {
573
+ return [];
574
+ }
575
+ const answer = legacyQuestionAnswerRecord(record);
576
+ const raw = typeof answer?.otherText === "string"
577
+ ? answer.otherText
578
+ : selectedLabelsForQuestion(record, selectedValuesForQuestion(record))[0] ?? "";
579
+ if (!/^\d+$/.test(raw.trim())) {
580
+ return [];
581
+ }
582
+ const index = Number(raw.trim()) - 1;
583
+ const option = record.prompt.options[index];
584
+ return [{
585
+ questionId: record.id,
586
+ ...(record.decisionRecordId ? { decisionRecordId: record.decisionRecordId } : {}),
587
+ issue: `Numeric answer "${raw.trim()}" was stored as other text.`,
588
+ ...(option ? { suggestion: `Use "${option.value}" (${option.label}) for this checkpoint option.` } : {})
589
+ }];
590
+ });
591
+ }
500
592
  function compactLine(value, limit = 160) {
501
593
  const compacted = value.replace(/\s+/g, " ").trim();
502
594
  return compacted.length > limit ? `${compacted.slice(0, limit - 1)}…` : compacted;
@@ -854,22 +946,10 @@ function summarizeWorkspaceInspection(context, state) {
854
946
  ...(record.selectedOption ? { selectedOption: record.selectedOption } : {}),
855
947
  timestamp: record.timestamp
856
948
  })),
857
- answerWarnings: questions
858
- .filter((record) => record.status === "answered" && record.answer?.selectedValues.includes("other"))
859
- .flatMap((record) => {
860
- const raw = record.answer?.otherText ?? record.answer?.selectedLabels[0] ?? "";
861
- if (!/^\d+$/.test(raw.trim())) {
862
- return [];
863
- }
864
- const index = Number(raw.trim()) - 1;
865
- const option = record.prompt.options[index];
866
- return [{
867
- questionId: record.id,
868
- ...(record.decisionRecordId ? { decisionRecordId: record.decisionRecordId } : {}),
869
- issue: `Numeric answer "${raw.trim()}" was stored as other text.`,
870
- ...(option ? { suggestion: `Use "${option.value}" (${option.label}) for this checkpoint option.` } : {})
871
- }];
872
- })
949
+ answerWarnings: [
950
+ ...(legacyAnswerShapeWarnings(questions) ?? []),
951
+ ...(numericOtherAnswerWarnings(questions) ?? [])
952
+ ]
873
953
  };
874
954
  }
875
955
  function buildProjectAgentsMd(project, session) {
@@ -1070,7 +1150,8 @@ function evidenceRecordsForInvocation(invocation, timestamp) {
1070
1150
  member.summary ? `Summary: ${member.summary}` : "",
1071
1151
  member.claims?.length ? `Claims: ${member.claims.join("; ")}` : "",
1072
1152
  member.objections?.length ? `Objections: ${member.objections.join("; ")}` : "",
1073
- member.openQuestions?.length ? `Open questions: ${member.openQuestions.join("; ")}` : ""
1153
+ member.openQuestions?.length ? `Open questions: ${member.openQuestions.join("; ")}` : "",
1154
+ member.evidenceRefs?.length ? `Evidence refs: ${member.evidenceRefs.join("; ")}` : ""
1074
1155
  ].filter(Boolean).join("\n"),
1075
1156
  linkedInvocationRecordIds: [invocation.id],
1076
1157
  linkedQuestionRecordIds: invocation.panelResult.linkedQuestionRecordIds,
@@ -1125,6 +1206,314 @@ export async function appendInvocationRecordToWorkspace(context, invocation, que
1125
1206
  await syncCurrentWorkspaceView(context);
1126
1207
  return withEvidence;
1127
1208
  }
1209
+ function latestPanelInvocation(state) {
1210
+ return (state.invocationLog ?? [])
1211
+ .slice()
1212
+ .reverse()
1213
+ .find((record) => record.intent.kind === "panel" && record.panelResult);
1214
+ }
1215
+ function sanitizeStringArray(value) {
1216
+ if (!Array.isArray(value)) {
1217
+ return undefined;
1218
+ }
1219
+ const normalized = value.filter((entry) => typeof entry === "string");
1220
+ return normalized.length > 0 ? normalized : [];
1221
+ }
1222
+ function isInvocationStatus(value) {
1223
+ return (value === "planned" ||
1224
+ value === "running" ||
1225
+ value === "completed" ||
1226
+ value === "blocked" ||
1227
+ value === "degraded" ||
1228
+ value === "error");
1229
+ }
1230
+ function isInvocationSurface(value) {
1231
+ return (value === "native_parallel" ||
1232
+ value === "native_subagents" ||
1233
+ value === "native_workers" ||
1234
+ value === "generated_skill" ||
1235
+ value === "prompt_alias" ||
1236
+ value === "sequential_fallback" ||
1237
+ value === "file_backed_panel_debate" ||
1238
+ value === "file_backed_debate" ||
1239
+ value === "mcp_transport");
1240
+ }
1241
+ function sanitizePanelMemberResult(member, fallback) {
1242
+ const label = typeof member.label === "string" && member.label.trim()
1243
+ ? member.label
1244
+ : fallback?.label ?? member.role;
1245
+ const status = isInvocationStatus(member.status)
1246
+ ? member.status
1247
+ : fallback?.status ?? "completed";
1248
+ const claims = sanitizeStringArray(member.claims);
1249
+ const objections = sanitizeStringArray(member.objections);
1250
+ const openQuestions = sanitizeStringArray(member.openQuestions);
1251
+ const evidenceRefs = sanitizeStringArray(member.evidenceRefs);
1252
+ return {
1253
+ role: member.role,
1254
+ label,
1255
+ status,
1256
+ ...(typeof member.summary === "string" ? { summary: member.summary } : fallback?.summary ? { summary: fallback.summary } : {}),
1257
+ ...(claims !== undefined ? { claims } : fallback?.claims ? { claims: fallback.claims } : {}),
1258
+ ...(objections !== undefined ? { objections } : fallback?.objections ? { objections: fallback.objections } : {}),
1259
+ ...(openQuestions !== undefined ? { openQuestions } : fallback?.openQuestions ? { openQuestions: fallback.openQuestions } : {}),
1260
+ ...(evidenceRefs !== undefined ? { evidenceRefs } : fallback?.evidenceRefs ? { evidenceRefs: fallback.evidenceRefs } : {}),
1261
+ ...(typeof member.error === "string" ? { error: member.error } : fallback?.error ? { error: fallback.error } : {})
1262
+ };
1263
+ }
1264
+ function mergePanelMemberResults(existing, incoming = []) {
1265
+ const incomingByRole = new Map(incoming.map((member) => [member.role, member]));
1266
+ const merged = existing.map((member) => {
1267
+ const update = incomingByRole.get(member.role);
1268
+ if (!update) {
1269
+ return sanitizePanelMemberResult(member);
1270
+ }
1271
+ return sanitizePanelMemberResult(update, member);
1272
+ });
1273
+ const existingRoles = new Set(existing.map((member) => member.role));
1274
+ for (const update of incoming) {
1275
+ if (existingRoles.has(update.role)) {
1276
+ continue;
1277
+ }
1278
+ merged.push(sanitizePanelMemberResult(update));
1279
+ }
1280
+ return merged;
1281
+ }
1282
+ function removeEvidenceForInvocation(state, invocationId) {
1283
+ return (state.evidenceRecords ?? []).filter((record) => {
1284
+ const linked = record.linkedInvocationRecordIds ?? [];
1285
+ if (!linked.includes(invocationId)) {
1286
+ return true;
1287
+ }
1288
+ return !(record.sourceKind === "panel" || record.sourceId?.startsWith(`${invocationId}:`));
1289
+ });
1290
+ }
1291
+ export async function recordPanelResultInWorkspace(options) {
1292
+ const state = await loadResearchState(options.context.stateFilePath);
1293
+ const targetInvocation = options.result.invocationId
1294
+ ? (state.invocationLog ?? []).find((record) => record.id === options.result.invocationId)
1295
+ : latestPanelInvocation(state);
1296
+ if (!targetInvocation) {
1297
+ throw new Error(options.result.invocationId
1298
+ ? `No panel invocation found for ${options.result.invocationId}.`
1299
+ : "No panel invocation found to record.");
1300
+ }
1301
+ if (!targetInvocation.panelResult) {
1302
+ throw new Error(`Invocation ${targetInvocation.id} does not have a panel result.`);
1303
+ }
1304
+ if (options.result.status !== undefined && !isInvocationStatus(options.result.status)) {
1305
+ throw new Error(`Invalid panel result status: ${String(options.result.status)}.`);
1306
+ }
1307
+ if (options.result.surface !== undefined && !isInvocationSurface(options.result.surface)) {
1308
+ throw new Error(`Invalid panel result surface: ${String(options.result.surface)}.`);
1309
+ }
1310
+ const timestamp = nowIso();
1311
+ const status = options.result.status ?? "completed";
1312
+ const surface = options.result.surface ?? targetInvocation.panelResult.surface;
1313
+ const updatedInvocation = {
1314
+ ...targetInvocation,
1315
+ updatedAt: timestamp,
1316
+ status,
1317
+ surface,
1318
+ panelResult: {
1319
+ ...targetInvocation.panelResult,
1320
+ updatedAt: timestamp,
1321
+ status,
1322
+ surface,
1323
+ memberResults: mergePanelMemberResults(targetInvocation.panelResult.memberResults, options.result.memberResults),
1324
+ ...(normalizeOptionalString(options.result.synthesis) ? { synthesis: normalizeOptionalString(options.result.synthesis) } : {}),
1325
+ ...(normalizeOptionalString(options.result.conflictSummary) ? { conflictSummary: normalizeOptionalString(options.result.conflictSummary) } : {}),
1326
+ ...(normalizeOptionalString(options.result.decisionPrompt) ? { decisionPrompt: normalizeOptionalString(options.result.decisionPrompt) } : {})
1327
+ },
1328
+ degradationReason: surface === "native_subagents"
1329
+ ? "Provider-native subagent execution was recorded as session-dependent; sequential_fallback remains the required fallback."
1330
+ : surface === "native_workers"
1331
+ ? "LongTable-native worker outputs were recorded as structured panel evidence; runtime state remains under .longtable/panel-runs and hidden reasoning/raw tmux logs stay out of handoff."
1332
+ : targetInvocation.degradationReason
1333
+ };
1334
+ const evidenceRecords = evidenceRecordsForInvocation(updatedInvocation, timestamp);
1335
+ const updatedState = {
1336
+ ...state,
1337
+ invocationLog: (state.invocationLog ?? []).map((record) => record.id === updatedInvocation.id ? updatedInvocation : record),
1338
+ evidenceRecords: [...removeEvidenceForInvocation(state, updatedInvocation.id), ...evidenceRecords]
1339
+ };
1340
+ await writeFile(options.context.stateFilePath, JSON.stringify(updatedState, null, 2), "utf8");
1341
+ await syncCurrentWorkspaceView(options.context);
1342
+ return {
1343
+ invocation: updatedInvocation,
1344
+ evidenceRecords,
1345
+ state: updatedState
1346
+ };
1347
+ }
1348
+ function renderBulletList(values, empty) {
1349
+ return values.length > 0 ? values.map((value) => `- ${value}`) : [`- ${empty}`];
1350
+ }
1351
+ function formatInvocationLine(record) {
1352
+ const roles = record.intent.roles.length > 0 ? record.intent.roles.join(", ") : "auto";
1353
+ return `${record.intent.kind}/${record.intent.mode} via ${record.surface} (${record.status}); roles: ${roles}`;
1354
+ }
1355
+ function renderLatestPanelForHandoff(invocation) {
1356
+ if (!invocation?.panelResult) {
1357
+ return [
1358
+ "## Latest Panel Or Discussion",
1359
+ "- No panel invocation is recorded yet.",
1360
+ "- Start with `lt panel: <what needs review>` or `longtable panel --prompt \"...\" --json`."
1361
+ ];
1362
+ }
1363
+ const result = invocation.panelResult;
1364
+ const workerResultGuidance = result.surface === "native_workers"
1365
+ ? [
1366
+ "- Native worker note: role-worker outputs have been normalized into this `PanelResult`; use `longtable panel status --run <run_id>` for live worker status when a native worker run id is available.",
1367
+ "- Native worker handoff rule: preserve final summaries, claims, objections, open questions, and evidence references; do not paste hidden reasoning, raw tool traces, or tmux logs into the research handoff."
1368
+ ]
1369
+ : [];
1370
+ return [
1371
+ "## Latest Panel Or Discussion",
1372
+ `- Invocation: ${invocation.id}`,
1373
+ `- Record: ${formatInvocationLine(invocation)}`,
1374
+ `- Result status: ${result.status}`,
1375
+ `- Execution surface: ${result.surface}`,
1376
+ ...workerResultGuidance,
1377
+ ...(invocation.degradationReason ? [`- Fallback note: ${invocation.degradationReason}`] : []),
1378
+ ...(result.synthesis ? [`- Synthesis: ${result.synthesis}`] : []),
1379
+ ...(result.conflictSummary ? [`- Conflict summary: ${result.conflictSummary}`] : []),
1380
+ ...(result.decisionPrompt ? [`- Decision prompt: ${result.decisionPrompt}`] : []),
1381
+ "",
1382
+ "### Role Outputs",
1383
+ ...result.memberResults.map((member) => {
1384
+ const details = [
1385
+ member.summary,
1386
+ ...(member.claims ?? []).map((claim) => `claim: ${claim}`),
1387
+ ...(member.objections ?? []).map((objection) => `objection: ${objection}`),
1388
+ ...(member.openQuestions ?? []).map((question) => `open question: ${question}`),
1389
+ ...(member.evidenceRefs ?? []).map((ref) => `evidence: ${ref}`),
1390
+ ...(member.error ? [`error: ${member.error}`] : [])
1391
+ ].filter(Boolean);
1392
+ return `- ${member.label} (${member.role}): ${details.length > 0 ? compactLine(details.join("; "), 220) : member.status}`;
1393
+ }),
1394
+ ...(result.memberResults.some((member) => (member.evidenceRefs ?? []).length > 0)
1395
+ ? [
1396
+ "",
1397
+ "### Role Evidence References",
1398
+ ...result.memberResults.flatMap((member) => (member.evidenceRefs ?? []).map((ref) => `- ${member.label} (${member.role}): ${ref}`))
1399
+ ]
1400
+ : []),
1401
+ ...(result.status === "planned"
1402
+ ? [
1403
+ "",
1404
+ "### Missing Persistence Step",
1405
+ "- This panel is only planned. After the provider returns role outputs, record them with:",
1406
+ ` \`longtable panel record --invocation ${invocation.id} --result-file panel-result.json\``
1407
+ ]
1408
+ : [])
1409
+ ];
1410
+ }
1411
+ function renderWorkflowGuidance(context, latestInvocation) {
1412
+ const cwdFlag = `--cwd "${context.project.projectPath.replaceAll("\"", "\\\"")}"`;
1413
+ const recordCommand = latestInvocation?.panelResult
1414
+ ? `longtable panel record ${cwdFlag} --invocation ${latestInvocation.id} --result-file panel-result.json`
1415
+ : `longtable panel ${cwdFlag} --prompt "<panel question>" --json`;
1416
+ return [
1417
+ "## Continuation Workflow",
1418
+ "",
1419
+ "### Provider-Neutral Path",
1420
+ "Use this when OMX is not installed or when the researcher wants a plain CLI/native-agent workflow.",
1421
+ "",
1422
+ "1. Open the project in Codex or Claude Code.",
1423
+ "2. Use `$longtable-start` if no usable Research Specification exists; otherwise use `$longtable-interview` or `lt panel: ...` for the next bounded decision.",
1424
+ "3. When a panel or native worker run produces real role outputs, persist the structured result:",
1425
+ ` \`${recordCommand}\``,
1426
+ " Native worker outputs should be final role summaries only: summary, claims, objections, open questions, and evidence refs.",
1427
+ "4. Inspect unincorporated evidence:",
1428
+ ` \`longtable spec unincorporated ${cwdFlag}\``,
1429
+ "5. Propose a Research Specification patch before applying a changed research direction:",
1430
+ ` \`longtable spec propose ${cwdFlag} --spec-file updated-spec.json --rationale \"Panel/discussion handoff\"\``,
1431
+ "6. Apply only after the researcher confirms the decision:",
1432
+ ` \`longtable spec apply ${cwdFlag} --patch-id <spec_patch_id>\``,
1433
+ "",
1434
+ "### Optional OMX Path",
1435
+ "Use this only when OMX is installed. The handoff packet can be pasted into `$ralplan` for a plan/test-spec pass, then `$ralph` can execute the approved work until verification. LongTable should remain the research-state source of truth; OMX is only the execution loop.",
1436
+ "",
1437
+ "Suggested OMX prompt:",
1438
+ "```text",
1439
+ "$ralplan: Use the LongTable handoff below as the research-state contract. Produce a PRD/test-spec style execution plan, preserve unresolved panel disagreements, and do not change the Research Specification without a LongTable checkpoint.",
1440
+ "```",
1441
+ "",
1442
+ "Then, after the plan is accepted:",
1443
+ "```text",
1444
+ "$ralph: Execute the approved LongTable handoff plan. Verify artifacts, then record any panel evidence or spec patch through LongTable commands.",
1445
+ "```"
1446
+ ];
1447
+ }
1448
+ export async function createWorkspaceHandoff(options) {
1449
+ const state = await loadResearchState(options.context.stateFilePath);
1450
+ const createdAt = nowIso();
1451
+ const id = createId("handoff");
1452
+ const locale = normalizeLocale(options.context.session.locale ?? options.context.project.locale);
1453
+ const openQuestions = options.context.session.openQuestions && options.context.session.openQuestions.length > 0
1454
+ ? options.context.session.openQuestions
1455
+ : buildOpenQuestions(options.context.session);
1456
+ const nextAction = options.context.session.nextAction ?? buildNextAction(options.context.session);
1457
+ const latestInvocation = latestPanelInvocation(state);
1458
+ const pendingQuestions = (state.questionLog ?? []).filter((record) => record.status === "pending");
1459
+ const unincorporatedEvidence = (state.evidenceRecords ?? []).filter((record) => !record.incorporatedByRevisionId);
1460
+ const proposedPatches = (state.specPatches ?? []).filter((patch) => patch.status === "proposed");
1461
+ const outputPath = options.outputPath
1462
+ ? resolve(options.outputPath)
1463
+ : join(options.context.metaDir, "handoffs", `${id}-${slugify(options.context.session.researchSpecification?.title ?? options.context.project.projectName) || "longtable"}.md`);
1464
+ const content = [
1465
+ "# LongTable Handoff",
1466
+ "",
1467
+ `Generated: ${createdAt}`,
1468
+ `Project: ${options.context.project.projectName}`,
1469
+ `Project path: ${options.context.project.projectPath}`,
1470
+ "",
1471
+ "## Current Objective",
1472
+ `- Goal: ${options.context.session.currentGoal}`,
1473
+ ...(options.context.session.currentBlocker ? [`- Blocker: ${options.context.session.currentBlocker}`] : []),
1474
+ ...(options.context.session.protectedDecision ? [`- Protected decision: ${options.context.session.protectedDecision}`] : []),
1475
+ `- Next action: ${nextAction}`,
1476
+ "",
1477
+ "## Research Specification Status",
1478
+ ...(options.context.session.researchSpecification
1479
+ ? renderResearchSpecificationSummary(options.context.session.researchSpecification, locale)
1480
+ : [
1481
+ "- No Research Specification is available yet.",
1482
+ "- Start or resume `$longtable-start` before treating the project direction as settled."
1483
+ ]),
1484
+ "",
1485
+ ...renderLatestPanelForHandoff(latestInvocation),
1486
+ "",
1487
+ "## Pending Researcher Decisions",
1488
+ ...renderBulletList(pendingQuestions.map((record) => `${record.id}: ${record.prompt.question} (${formatQuestionOptionValues(record).join("/")})`), "No pending researcher decision questions."),
1489
+ "",
1490
+ "## Unincorporated Evidence",
1491
+ ...renderBulletList(unincorporatedEvidence.slice(-10).reverse().map((record) => `${record.id} [${record.sourceKind}]: ${compactLine(record.summary, 180)}`), "No unincorporated evidence records."),
1492
+ "",
1493
+ "## Proposed Specification Patches",
1494
+ ...renderBulletList(proposedPatches.map((patch) => `${patch.id}: ${patch.title} (${patch.changes.length} changes)`), "No proposed Research Specification patches."),
1495
+ "",
1496
+ "## Open Questions",
1497
+ ...renderBulletList(openQuestions, "No open questions recorded."),
1498
+ "",
1499
+ ...renderWorkflowGuidance(options.context, latestInvocation),
1500
+ "",
1501
+ "## Stop Condition",
1502
+ "- Stop when the next research decision is either confirmed by the researcher, preserved as an explicit open tension, or represented as a proposed Research Specification patch waiting for confirmation."
1503
+ ].join("\n");
1504
+ await mkdir(dirname(outputPath), { recursive: true });
1505
+ await writeFile(outputPath, `${content}\n`, "utf8");
1506
+ return {
1507
+ id,
1508
+ createdAt,
1509
+ path: outputPath,
1510
+ content,
1511
+ sourceEvidenceIds: unincorporatedEvidence.map((record) => record.id),
1512
+ pendingQuestionIds: pendingQuestions.map((record) => record.id),
1513
+ proposedPatchIds: proposedPatches.map((patch) => patch.id),
1514
+ ...(latestInvocation ? { latestInvocationId: latestInvocation.id } : {})
1515
+ };
1516
+ }
1128
1517
  function createId(prefix) {
1129
1518
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1130
1519
  }
@@ -2933,7 +3322,51 @@ export async function repairWorkspaceStateConsistency(options) {
2933
3322
  })
2934
3323
  };
2935
3324
  }
2936
- if (repaired.length > 0) {
3325
+ const timestamp = nowIso();
3326
+ const repairedQuestionLog = (updated.questionLog ?? []).map((record) => {
3327
+ if (record.status !== "answered") {
3328
+ return record;
3329
+ }
3330
+ const answer = legacyQuestionAnswerRecord(record);
3331
+ if (!answer) {
3332
+ return record;
3333
+ }
3334
+ const selectedValues = selectedValuesForQuestion(record);
3335
+ if (selectedValues.length === 0) {
3336
+ return record;
3337
+ }
3338
+ const selectedLabels = selectedLabelsForQuestion(record, selectedValues);
3339
+ const needsRepair = asStringArray(answer.selectedValues).length === 0 ||
3340
+ asStringArray(answer.selectedLabels).length === 0 ||
3341
+ typeof answer.promptId !== "string" ||
3342
+ !QUESTION_SURFACES.has(answer.surface);
3343
+ if (!needsRepair) {
3344
+ return record;
3345
+ }
3346
+ repaired.push(`normalized legacy answer shape for question ${record.id}`);
3347
+ const normalizedAnswer = {
3348
+ ...answer,
3349
+ promptId: typeof answer.promptId === "string" ? answer.promptId : record.prompt.id,
3350
+ selectedValues,
3351
+ selectedLabels,
3352
+ ...(typeof answer.otherText === "string" && answer.otherText.trim() ? { otherText: answer.otherText } : {}),
3353
+ ...(typeof answer.rationale === "string" && answer.rationale.trim() ? { rationale: answer.rationale } : {}),
3354
+ ...(answer.provider === "codex" || answer.provider === "claude" ? { provider: answer.provider } : {}),
3355
+ surface: QUESTION_SURFACES.has(answer.surface) ? answer.surface : "numbered"
3356
+ };
3357
+ return {
3358
+ ...record,
3359
+ updatedAt: timestamp,
3360
+ answer: normalizedAnswer
3361
+ };
3362
+ });
3363
+ if (repairedQuestionLog.some((record, index) => record !== (updated.questionLog ?? [])[index])) {
3364
+ updated = {
3365
+ ...updated,
3366
+ questionLog: repairedQuestionLog
3367
+ };
3368
+ }
3369
+ if (repaired.length > 0 && !options.dryRun) {
2937
3370
  await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
2938
3371
  await syncCurrentWorkspaceView(options.context);
2939
3372
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@longtable/cli",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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.55",
33
- "@longtable/core": "0.1.55",
34
- "@longtable/memory": "0.1.55",
35
- "@longtable/provider-claude": "0.1.55",
36
- "@longtable/provider-codex": "0.1.55",
37
- "@longtable/setup": "0.1.55"
32
+ "@longtable/checkpoints": "0.1.57",
33
+ "@longtable/core": "0.1.57",
34
+ "@longtable/memory": "0.1.57",
35
+ "@longtable/provider-claude": "0.1.57",
36
+ "@longtable/provider-codex": "0.1.57",
37
+ "@longtable/setup": "0.1.57"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^22.10.1",