@longtable/cli 0.1.45 → 0.1.48

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.
@@ -163,7 +163,103 @@ function renderResearchSpecificationSummary(specification, locale) {
163
163
  }
164
164
  return lines;
165
165
  }
166
- function buildCurrentGuide(project, session, recentInvocations = [], pendingQuestions = [], pendingObligations = []) {
166
+ function renderResearchSpecificationStatus(session, locale) {
167
+ if (!session.firstResearchShape && !session.researchSpecification) {
168
+ return [];
169
+ }
170
+ const korean = locale === "ko";
171
+ if (!session.researchSpecification) {
172
+ return [
173
+ "",
174
+ korean ? "## Research Specification 상태" : "## Research Specification Status",
175
+ korean
176
+ ? "- 상태: First Research Shape는 있지만 Research Specification은 아직 없습니다."
177
+ : "- Status: First Research Shape exists, but Research Specification is missing.",
178
+ korean
179
+ ? "- 의미: First Research Shape는 짧은 핸들/재개 인덱스이며, 인터뷰 종료나 연구 명세 확정이 아닙니다."
180
+ : "- Meaning: First Research Shape is a short handle/resume index, not interview closure or a confirmed research specification.",
181
+ korean
182
+ ? "- 다음 프로토콜: 충분한 내용이 있으면 `summarize_research_specification`으로 preview를 만들고 `confirm_research_specification`으로 저장/한 질문 더/섹션 수정/열어두기를 확인합니다."
183
+ : "- Next protocol: when enough detail exists, run `summarize_research_specification` to create the preview, then `confirm_research_specification` to confirm, ask one more question, revise a section, or keep it open."
184
+ ];
185
+ }
186
+ const status = session.researchSpecification.confirmedAt
187
+ ? "confirmed"
188
+ : session.researchSpecification.status ?? "draft";
189
+ if (status === "confirmed") {
190
+ return [];
191
+ }
192
+ return [
193
+ "",
194
+ korean ? "## Research Specification 상태" : "## Research Specification Status",
195
+ korean
196
+ ? `- 상태: ${status}. Research Specification은 저장되어 있지만 아직 확정된 종료 지점이 아닙니다.`
197
+ : `- Status: ${status}. Research Specification exists, but it is not a confirmed closure point yet.`,
198
+ korean
199
+ ? "- 다음 프로토콜: 명세를 업데이트한 뒤 `confirm_research_specification`으로 다시 preview 확인을 받아야 합니다."
200
+ : "- Next protocol: update the specification, then return to `confirm_research_specification` for another preview confirmation."
201
+ ];
202
+ }
203
+ function renderResearchSpecificationAudit(state, locale) {
204
+ const korean = locale === "ko";
205
+ const revisions = (state.specRevisions ?? []).slice(-5).reverse();
206
+ const patches = (state.specPatches ?? []).slice(-5).reverse();
207
+ const evidenceRecords = state.evidenceRecords ?? [];
208
+ const unincorporated = evidenceRecords
209
+ .filter((record) => !record.incorporatedByRevisionId)
210
+ .slice(-5)
211
+ .reverse();
212
+ const specification = state.researchSpecification;
213
+ const sectionEvidence = Object.entries(specification?.sectionEvidence ?? {}).slice(0, 8);
214
+ if (revisions.length === 0 &&
215
+ patches.length === 0 &&
216
+ evidenceRecords.length === 0 &&
217
+ sectionEvidence.length === 0) {
218
+ return [];
219
+ }
220
+ return [
221
+ "",
222
+ korean ? "## Research Specification 감사" : "## Research Specification Audit",
223
+ ...(specification
224
+ ? [
225
+ `- ${korean ? "현재 버전" : "Current revision"}: ${specification.latestRevisionId ?? "unversioned"}`,
226
+ `- ${korean ? "상태" : "Status"}: ${specification.confirmedAt ? "confirmed" : specification.status ?? "draft"}`
227
+ ]
228
+ : []),
229
+ `- ${korean ? "원문 인터뷰 turn" : "Raw interview turns"}: ${(state.interviewTurns ?? []).length}`,
230
+ `- ${korean ? "근거 기록" : "Evidence records"}: ${evidenceRecords.length}`,
231
+ `- ${korean ? "spec patch" : "Spec patches"}: ${(state.specPatches ?? []).length}`,
232
+ `- ${korean ? "spec revision" : "Spec revisions"}: ${(state.specRevisions ?? []).length}`,
233
+ ...(revisions.length > 0
234
+ ? [
235
+ "",
236
+ korean ? "### 최근 명세 변경" : "### Recent Specification Changes",
237
+ ...revisions.map((revision) => `- v${revision.index} ${revision.title}: ${revision.changeSummary.slice(0, 3).join("; ")}`)
238
+ ]
239
+ : []),
240
+ ...(sectionEvidence.length > 0
241
+ ? [
242
+ "",
243
+ korean ? "### 근거 맵" : "### Evidence Map",
244
+ ...sectionEvidence.map(([path, ids]) => `- ${path}: ${ids.slice(-4).join(", ")}`)
245
+ ]
246
+ : []),
247
+ ...(unincorporated.length > 0
248
+ ? [
249
+ "",
250
+ korean ? "### 아직 반영되지 않은 근거" : "### Unincorporated Evidence",
251
+ ...unincorporated.map((record) => `- ${record.id} [${record.sourceKind}]: ${compactLine(record.summary, 120)}`)
252
+ ]
253
+ : []),
254
+ ...(patches.some((patch) => patch.status === "proposed")
255
+ ? [
256
+ "",
257
+ korean ? "- 대기 중인 spec patch가 있습니다. 적용하거나 거절해야 합니다." : "- Proposed spec patches are waiting to be applied or rejected."
258
+ ]
259
+ : [])
260
+ ];
261
+ }
262
+ function buildCurrentGuide(project, session, recentInvocations = [], pendingQuestions = [], pendingObligations = [], state = createEmptyResearchState()) {
167
263
  const locale = normalizeLocale(session.locale ?? project.locale);
168
264
  const openQuestions = session.openQuestions && session.openQuestions.length > 0
169
265
  ? session.openQuestions
@@ -191,6 +287,8 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
191
287
  `- 다음 액션: ${nextAction}`,
192
288
  `- 관점: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
193
289
  `- disagreement: ${session.disagreementPreference}`,
290
+ ...renderResearchSpecificationStatus(session, locale),
291
+ ...renderResearchSpecificationAudit(state, locale),
194
292
  "",
195
293
  "## 열린 질문",
196
294
  ...openQuestions.map((question) => `- ${question}`),
@@ -210,7 +308,7 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
210
308
  "## 대기 중인 결정 질문",
211
309
  ...pendingQuestions.map((record) => {
212
310
  const options = formatQuestionOptionValues(record).join("/");
213
- return `- ${record.id}: ${record.prompt.question} (${options})`;
311
+ return `- ${record.id}: ${record.prompt.question}${formatQuestionMetadata(record)} (${options})`;
214
312
  }),
215
313
  "- 답변 기록: `longtable decide --question <id> --answer <value>`"
216
314
  ]
@@ -266,6 +364,8 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
266
364
  `- Next action: ${nextAction}`,
267
365
  `- Perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
268
366
  `- Disagreement: ${session.disagreementPreference}`,
367
+ ...renderResearchSpecificationStatus(session, locale),
368
+ ...renderResearchSpecificationAudit(state, locale),
269
369
  "",
270
370
  "## Open Questions",
271
371
  ...openQuestions.map((question) => `- ${question}`),
@@ -285,7 +385,7 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
285
385
  "## Pending Decision Questions",
286
386
  ...pendingQuestions.map((record) => {
287
387
  const options = formatQuestionOptionValues(record).join("/");
288
- return `- ${record.id}: ${record.prompt.question} (${options})`;
388
+ return `- ${record.id}: ${record.prompt.question}${formatQuestionMetadata(record)} (${options})`;
289
389
  }),
290
390
  "- Record an answer: `longtable decide --question <id> --answer <value>`"
291
391
  ]
@@ -334,6 +434,10 @@ async function loadResearchState(stateFilePath) {
334
434
  hooks: parsed.hooks ?? [],
335
435
  ...(parsed.firstResearchShape ? { firstResearchShape: parsed.firstResearchShape } : {}),
336
436
  ...(parsed.researchSpecification ? { researchSpecification: parsed.researchSpecification } : {}),
437
+ interviewTurns: parsed.interviewTurns ?? [],
438
+ evidenceRecords: parsed.evidenceRecords ?? [],
439
+ specPatches: parsed.specPatches ?? [],
440
+ specRevisions: parsed.specRevisions ?? [],
337
441
  questionObligations: parsed.questionObligations ?? [],
338
442
  inferredHypotheses: parsed.inferredHypotheses ?? [],
339
443
  openTensions: parsed.openTensions ?? [],
@@ -375,6 +479,287 @@ function formatQuestionOptionValues(record) {
375
479
  }
376
480
  return values;
377
481
  }
482
+ function formatQuestionMetadata(record) {
483
+ const parts = [
484
+ record.commitmentFamily ? `commitment: ${record.commitmentFamily}` : "",
485
+ record.epistemicBasis ? `basis: ${record.epistemicBasis}` : ""
486
+ ].filter(Boolean);
487
+ return parts.length > 0 ? ` [${parts.join("; ")}]` : "";
488
+ }
489
+ function compactLine(value, limit = 160) {
490
+ const compacted = value.replace(/\s+/g, " ").trim();
491
+ return compacted.length > limit ? `${compacted.slice(0, limit - 1)}…` : compacted;
492
+ }
493
+ function asRecord(value) {
494
+ return value !== null && typeof value === "object" && !Array.isArray(value)
495
+ ? value
496
+ : null;
497
+ }
498
+ const SPEC_DIFF_IGNORED_PATHS = new Set([
499
+ "createdAt",
500
+ "updatedAt",
501
+ "latestRevisionId",
502
+ "sourceEvidenceIds",
503
+ "sectionEvidence"
504
+ ]);
505
+ function flattenSpecificationValue(value, prefix = "") {
506
+ const flattened = new Map();
507
+ const record = asRecord(value);
508
+ if (!record) {
509
+ if (prefix) {
510
+ flattened.set(prefix, value);
511
+ }
512
+ return flattened;
513
+ }
514
+ for (const [key, nested] of Object.entries(record)) {
515
+ const path = prefix ? `${prefix}.${key}` : key;
516
+ if (SPEC_DIFF_IGNORED_PATHS.has(path)) {
517
+ continue;
518
+ }
519
+ const nestedRecord = asRecord(nested);
520
+ if (nestedRecord) {
521
+ for (const [nestedPath, nestedValue] of flattenSpecificationValue(nestedRecord, path)) {
522
+ flattened.set(nestedPath, nestedValue);
523
+ }
524
+ continue;
525
+ }
526
+ flattened.set(path, nested);
527
+ }
528
+ return flattened;
529
+ }
530
+ function stableValue(value) {
531
+ return JSON.stringify(value ?? null);
532
+ }
533
+ export function diffResearchSpecifications(before, after) {
534
+ const beforeMap = before ? flattenSpecificationValue(before) : new Map();
535
+ const afterMap = flattenSpecificationValue(after);
536
+ const paths = new Set([...beforeMap.keys(), ...afterMap.keys()]);
537
+ const changes = [];
538
+ for (const path of [...paths].sort()) {
539
+ const previous = beforeMap.get(path);
540
+ const next = afterMap.get(path);
541
+ if (stableValue(previous) === stableValue(next)) {
542
+ continue;
543
+ }
544
+ const kind = previous === undefined
545
+ ? "set"
546
+ : next === undefined
547
+ ? "remove"
548
+ : "replace";
549
+ changes.push({
550
+ path,
551
+ kind,
552
+ summary: `${kind} ${path}`,
553
+ ...(previous !== undefined ? { before: previous } : {}),
554
+ ...(next !== undefined ? { after: next } : {})
555
+ });
556
+ }
557
+ return changes;
558
+ }
559
+ function cloneResearchSpecification(specification) {
560
+ return JSON.parse(JSON.stringify(specification));
561
+ }
562
+ function mergeStringLists(...lists) {
563
+ return [...new Set(lists.flatMap((list) => list ?? []).filter(Boolean))];
564
+ }
565
+ function requiredResearchSpecificationGaps(specification) {
566
+ const gaps = [];
567
+ if (!specification.researchDirection.question?.trim()) {
568
+ gaps.push("research question");
569
+ }
570
+ if (specification.constructOntology.coreConstructs.length === 0) {
571
+ gaps.push("construct map/core constructs");
572
+ }
573
+ if (specification.researchDirection.inclusionCriteria?.length === 0 &&
574
+ specification.researchDirection.exclusionCriteria?.length === 0) {
575
+ gaps.push("inclusion/exclusion rule");
576
+ }
577
+ if (specification.evidenceAccess.requiredSources?.length === 0 &&
578
+ specification.evidenceAccess.evidenceStandards?.length === 0) {
579
+ gaps.push("evidence boundary");
580
+ }
581
+ if (!specification.methodAnalysis.design?.trim() &&
582
+ specification.methodAnalysis.analysisOptions.length === 0) {
583
+ gaps.push("method commitment");
584
+ }
585
+ if (specification.openQuestions.length === 0 && specification.protectedDecisions.length === 0) {
586
+ gaps.push("unresolved decisions/protected decisions");
587
+ }
588
+ if (specification.evidenceAccess.accessRequirements?.length === 0 &&
589
+ specification.evidenceAccess.requiredSources?.length === 0) {
590
+ gaps.push("search/access assumptions");
591
+ }
592
+ return gaps;
593
+ }
594
+ function buildResearchSpecificationGapQuestion(gaps, timestamp, sourceEvidenceIds) {
595
+ return {
596
+ id: createId("question"),
597
+ createdAt: timestamp,
598
+ updatedAt: timestamp,
599
+ status: "pending",
600
+ commitmentFamily: "scope",
601
+ epistemicBasis: "project_state",
602
+ prompt: {
603
+ id: createId("prompt"),
604
+ checkpointKey: "research_specification_required_sections",
605
+ title: "Research Specification gaps",
606
+ question: `Which missing Research Specification section should LongTable resolve first? Missing: ${gaps.join(", ")}.`,
607
+ type: "single_choice",
608
+ options: [
609
+ { value: "ask_researcher", label: "Ask the researcher", description: "Pause and ask for the missing research commitment.", recommended: true },
610
+ { value: "mark_unresolved", label: "Mark unresolved", description: "Keep the gap visible as an unresolved decision." },
611
+ { value: "infer_from_evidence", label: "Infer from evidence", description: "Use existing evidence records and keep the inference explicit." },
612
+ { value: "defer", label: "Defer", description: "Do not treat the specification as complete yet." }
613
+ ],
614
+ allowOther: true,
615
+ otherLabel: "Other resolution",
616
+ required: true,
617
+ source: "checkpoint",
618
+ displayReason: `The current Research Specification is missing: ${gaps.join(", ")}.`,
619
+ rationale: [
620
+ "Research Specification is the required durable interview artifact.",
621
+ "Missing required sections can make later resume, screening, coding, or evidence decisions stale."
622
+ ],
623
+ preferredSurfaces: ["mcp_elicitation", "numbered"]
624
+ },
625
+ transportStatus: {
626
+ surface: "mcp_elicitation",
627
+ status: "not_attempted",
628
+ updatedAt: timestamp,
629
+ ...(sourceEvidenceIds.length > 0 ? { message: `Source evidence: ${sourceEvidenceIds.join(", ")}` } : {})
630
+ }
631
+ };
632
+ }
633
+ function appendSpecGapQuestionIfNeeded(state, specification, timestamp, sourceEvidenceIds) {
634
+ const gaps = requiredResearchSpecificationGaps(specification);
635
+ if (gaps.length === 0) {
636
+ return state;
637
+ }
638
+ const alreadyPending = (state.questionLog ?? []).some((record) => record.status === "pending" &&
639
+ record.prompt.checkpointKey === "research_specification_required_sections");
640
+ if (alreadyPending) {
641
+ return state;
642
+ }
643
+ return {
644
+ ...state,
645
+ questionLog: [
646
+ ...(state.questionLog ?? []),
647
+ buildResearchSpecificationGapQuestion(gaps, timestamp, sourceEvidenceIds)
648
+ ]
649
+ };
650
+ }
651
+ function changeSummaryForRevision(changes) {
652
+ if (changes.length === 0) {
653
+ return ["No substantive field changes; audit metadata refreshed."];
654
+ }
655
+ return changes.slice(0, 12).map((change) => change.summary);
656
+ }
657
+ export function applyResearchSpecificationAuditUpdate(state, options) {
658
+ const previous = state.researchSpecification;
659
+ const incomingEvidenceIds = mergeStringLists(options.patch?.sourceEvidenceIds, options.specification.sourceEvidenceIds, options.sourceEvidenceIds);
660
+ const sourceEvidenceIds = mergeStringLists(previous?.sourceEvidenceIds, incomingEvidenceIds);
661
+ const changes = diffResearchSpecifications(previous, options.specification)
662
+ .map((change) => ({
663
+ ...change,
664
+ ...(incomingEvidenceIds.length > 0 ? { evidenceRecordIds: incomingEvidenceIds } : {})
665
+ }));
666
+ const patchId = options.patch?.id ?? createId("spec_patch");
667
+ const revisionId = createId("spec_revision");
668
+ const patchTitle = options.title ?? options.patch?.title ?? `Research Specification update: ${options.specification.title}`;
669
+ const patchRationale = options.rationale ?? options.patch?.rationale;
670
+ const sectionEvidence = {
671
+ ...(previous?.sectionEvidence ?? {}),
672
+ ...(options.specification.sectionEvidence ?? {})
673
+ };
674
+ for (const change of changes) {
675
+ const fieldEvidenceIds = change.evidenceRecordIds ?? [];
676
+ if (fieldEvidenceIds.length > 0) {
677
+ sectionEvidence[change.path] = mergeStringLists(sectionEvidence[change.path], fieldEvidenceIds);
678
+ }
679
+ }
680
+ const specification = {
681
+ ...cloneResearchSpecification(options.specification),
682
+ updatedAt: options.timestamp,
683
+ latestRevisionId: revisionId,
684
+ sourceEvidenceIds,
685
+ sectionEvidence
686
+ };
687
+ const decision = options.decisionRecordId || options.createDecisionRecord === false
688
+ ? undefined
689
+ : {
690
+ id: createId("decision"),
691
+ timestamp: options.timestamp,
692
+ checkpointKey: "research_specification_auto_update",
693
+ level: "log_only",
694
+ mode: "commit",
695
+ summary: `Applied Research Specification update: ${patchTitle}`,
696
+ commitmentFamily: "scope",
697
+ epistemicBasis: "mixed",
698
+ rationale: patchRationale ?? "Automatically applied a source-mapped Research Specification update."
699
+ };
700
+ const decisionRecordId = options.decisionRecordId ?? decision?.id;
701
+ const revision = {
702
+ id: revisionId,
703
+ index: (state.specRevisions ?? []).length + 1,
704
+ createdAt: options.timestamp,
705
+ source: options.source,
706
+ title: patchTitle,
707
+ status: specification.status ?? "draft",
708
+ patchId,
709
+ ...(options.questionRecordId ? { questionRecordId: options.questionRecordId } : {}),
710
+ ...(decisionRecordId ? { decisionRecordId } : {}),
711
+ sourceEvidenceIds,
712
+ changeSummary: changeSummaryForRevision(changes),
713
+ specification
714
+ };
715
+ const patch = {
716
+ id: patchId,
717
+ createdAt: options.patch?.createdAt ?? options.timestamp,
718
+ updatedAt: options.timestamp,
719
+ status: "applied",
720
+ source: options.source,
721
+ title: patchTitle,
722
+ ...(patchRationale ? { rationale: patchRationale } : {}),
723
+ changes,
724
+ sourceEvidenceIds,
725
+ targetSpecification: specification,
726
+ appliedAt: options.timestamp,
727
+ appliedRevisionId: revision.id,
728
+ ...(options.questionRecordId ? { questionRecordId: options.questionRecordId } : {}),
729
+ ...(decisionRecordId ? { decisionRecordId } : {})
730
+ };
731
+ const incorporatedEvidence = (state.evidenceRecords ?? []).map((record) => sourceEvidenceIds.includes(record.id)
732
+ ? {
733
+ ...record,
734
+ incorporatedAt: options.timestamp,
735
+ incorporatedByPatchId: patch.id,
736
+ incorporatedByRevisionId: revision.id
737
+ }
738
+ : record);
739
+ const withDecision = decision ? appendDecisionToResearchState(state, decision) : state;
740
+ const previousPatches = withDecision.specPatches ?? [];
741
+ const specPatches = previousPatches.some((entry) => entry.id === patch.id)
742
+ ? previousPatches.map((entry) => entry.id === patch.id ? patch : entry)
743
+ : [...previousPatches, patch];
744
+ const nextState = {
745
+ ...withDecision,
746
+ researchSpecification: specification,
747
+ evidenceRecords: incorporatedEvidence,
748
+ specPatches,
749
+ specRevisions: [...(withDecision.specRevisions ?? []), revision],
750
+ workingState: {
751
+ ...withDecision.workingState,
752
+ researchSpecification: specification
753
+ }
754
+ };
755
+ return {
756
+ state: appendSpecGapQuestionIfNeeded(nextState, specification, options.timestamp, sourceEvidenceIds),
757
+ specification,
758
+ patch,
759
+ revision,
760
+ ...(decision ? { decision } : {})
761
+ };
762
+ }
378
763
  function summarizeWorkspaceInspection(context, state) {
379
764
  const questions = state.questionLog ?? [];
380
765
  const pendingQuestions = questions.filter((record) => record.status === "pending");
@@ -419,7 +804,11 @@ function summarizeWorkspaceInspection(context, state) {
419
804
  pendingQuestions: pendingQuestions.length,
420
805
  pendingObligations: pendingObligations.length,
421
806
  answeredQuestions: answeredQuestions.length,
422
- decisions: (state.decisionLog ?? []).length
807
+ decisions: (state.decisionLog ?? []).length,
808
+ interviewTurns: (state.interviewTurns ?? []).length,
809
+ evidenceRecords: (state.evidenceRecords ?? []).length,
810
+ specPatches: (state.specPatches ?? []).length,
811
+ specRevisions: (state.specRevisions ?? []).length
423
812
  },
424
813
  recentInvocations: recentInvocationRecords(state, 5).map((record) => ({
425
814
  id: record.id,
@@ -435,6 +824,8 @@ function summarizeWorkspaceInspection(context, state) {
435
824
  id: record.id,
436
825
  title: record.prompt.title,
437
826
  question: record.prompt.question,
827
+ ...(record.commitmentFamily ? { commitmentFamily: record.commitmentFamily } : {}),
828
+ ...(record.epistemicBasis ? { epistemicBasis: record.epistemicBasis } : {}),
438
829
  options: formatQuestionOptionValues(record),
439
830
  required: record.prompt.required
440
831
  })),
@@ -449,6 +840,8 @@ function summarizeWorkspaceInspection(context, state) {
449
840
  id: record.id,
450
841
  checkpointKey: record.checkpointKey,
451
842
  summary: record.summary,
843
+ ...(record.commitmentFamily ? { commitmentFamily: record.commitmentFamily } : {}),
844
+ ...(record.epistemicBasis ? { epistemicBasis: record.epistemicBasis } : {}),
452
845
  ...(record.selectedOption ? { selectedOption: record.selectedOption } : {}),
453
846
  timestamp: record.timestamp
454
847
  })),
@@ -495,9 +888,12 @@ function buildProjectAgentsMd(project, session) {
495
888
  "- Begin exploratory work with clarifying or tension questions before recommending a direction.",
496
889
  "- For `$longtable-interview`, ask one natural-language question at a time, reflect with `LongTable hears: ...`, record turns when MCP is available, and avoid early reader/reviewer or theory/method/measurement classification.",
497
890
  "- Do not summarize `$longtable-interview` because a fixed number of turns has passed; wait for content-based readiness around research object, focal uncertainty, boundary, evidence/material, protected decision, and next action.",
891
+ "- First Research Shape is a short handle/resume index, not the default closure point.",
498
892
  "- After the First Research Shape, create a Research Specification when the interview has enough detail to preserve scope, construct ontology, theory framing, coding rules, method options, evidence/access requirements, epistemic alignment, protected decisions, open questions, and next actions.",
893
+ "- If a confirmed First Research Shape exists without a Research Specification, continue directly into the next Research Specification question instead of asking shape-level continue/revise/restart questions.",
894
+ "- If the researcher chooses `ask_one_more` or `revise_section` at Research Specification confirmation, answer that gap and return to the Research Specification Preview before ending the interview.",
499
895
  "- Do not let unrelated pending Researcher Checkpoints interrupt `$longtable-interview`; mention them only as separate unresolved checkpoints unless the researcher is confirming, saving, or recording a research decision.",
500
- "- Use structured options only at the final First Research Shape confirmation or at true checkpoint boundaries.",
896
+ "- Use structured options at the final Research Specification confirmation, at explicit short-handle stop points, or at true checkpoint boundaries.",
501
897
  "- If you foreground role perspectives, disclose them with `LongTable consulted: ...`.",
502
898
  "- Keep one accountable synthesis, but do not hide meaningful disagreement.",
503
899
  ...(session.disagreementPreference === "always_visible"
@@ -631,19 +1027,92 @@ export async function syncCurrentWorkspaceView(context) {
631
1027
  ? { researchSpecification: context.session.researchSpecification ?? state.researchSpecification }
632
1028
  : {})
633
1029
  };
634
- const body = buildCurrentGuide(context.project, session, recentInvocationRecords(state), recentPendingQuestions(state), recentPendingObligations(state));
1030
+ const body = buildCurrentGuide(context.project, session, recentInvocationRecords(state), recentPendingQuestions(state), recentPendingObligations(state), state);
635
1031
  await writeFile(context.currentFilePath, body, "utf8");
636
1032
  return context.currentFilePath;
637
1033
  }
1034
+ function evidenceKindForInvocationRole(role) {
1035
+ const normalized = role?.toLowerCase() ?? "";
1036
+ if (normalized.includes("critic")) {
1037
+ return "critic";
1038
+ }
1039
+ if (normalized.includes("reviewer") || normalized.includes("review")) {
1040
+ return "reviewer";
1041
+ }
1042
+ return "panel";
1043
+ }
1044
+ function evidenceRecordsForInvocation(invocation, timestamp) {
1045
+ const records = [];
1046
+ if (invocation.panelResult) {
1047
+ for (const member of invocation.panelResult.memberResults) {
1048
+ if (!member.summary && (member.claims ?? []).length === 0 && (member.objections ?? []).length === 0) {
1049
+ continue;
1050
+ }
1051
+ records.push({
1052
+ id: createId("evidence"),
1053
+ createdAt: timestamp,
1054
+ sourceKind: evidenceKindForInvocationRole(member.role),
1055
+ sourceId: `${invocation.id}:${member.role}`,
1056
+ role: member.role,
1057
+ summary: compactLine(member.summary ?? [...(member.claims ?? []), ...(member.objections ?? [])].join(" ")),
1058
+ rawText: [
1059
+ member.summary ? `Summary: ${member.summary}` : "",
1060
+ member.claims?.length ? `Claims: ${member.claims.join("; ")}` : "",
1061
+ member.objections?.length ? `Objections: ${member.objections.join("; ")}` : "",
1062
+ member.openQuestions?.length ? `Open questions: ${member.openQuestions.join("; ")}` : ""
1063
+ ].filter(Boolean).join("\n"),
1064
+ linkedInvocationRecordIds: [invocation.id],
1065
+ linkedQuestionRecordIds: invocation.panelResult.linkedQuestionRecordIds,
1066
+ linkedDecisionRecordIds: invocation.panelResult.linkedDecisionRecordIds
1067
+ });
1068
+ }
1069
+ if (invocation.panelResult.synthesis || invocation.panelResult.conflictSummary) {
1070
+ records.push({
1071
+ id: createId("evidence"),
1072
+ createdAt: timestamp,
1073
+ sourceKind: "panel",
1074
+ sourceId: invocation.panelResult.id,
1075
+ summary: compactLine(invocation.panelResult.synthesis ?? invocation.panelResult.conflictSummary ?? "Panel result"),
1076
+ rawText: [
1077
+ invocation.panelResult.synthesis ? `Synthesis: ${invocation.panelResult.synthesis}` : "",
1078
+ invocation.panelResult.conflictSummary ? `Conflict: ${invocation.panelResult.conflictSummary}` : ""
1079
+ ].filter(Boolean).join("\n"),
1080
+ linkedInvocationRecordIds: [invocation.id],
1081
+ linkedQuestionRecordIds: invocation.panelResult.linkedQuestionRecordIds,
1082
+ linkedDecisionRecordIds: invocation.panelResult.linkedDecisionRecordIds
1083
+ });
1084
+ }
1085
+ return records;
1086
+ }
1087
+ if (invocation.status === "completed" && invocation.intent.prompt.trim()) {
1088
+ records.push({
1089
+ id: createId("evidence"),
1090
+ createdAt: timestamp,
1091
+ sourceKind: "invocation",
1092
+ sourceId: invocation.id,
1093
+ summary: compactLine(`${invocation.intent.kind}/${invocation.intent.mode}: ${invocation.intent.prompt}`),
1094
+ rawText: invocation.intent.prompt,
1095
+ linkedInvocationRecordIds: [invocation.id]
1096
+ });
1097
+ }
1098
+ return records;
1099
+ }
638
1100
  export async function appendInvocationRecordToWorkspace(context, invocation, questions = []) {
639
1101
  const state = await loadResearchState(context.stateFilePath);
640
1102
  const withInvocation = appendInvocationToResearchState(state, invocation);
641
1103
  const updated = questions.length > 0
642
1104
  ? appendQuestionRecords(withInvocation, questions)
643
1105
  : withInvocation;
644
- await writeFile(context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
1106
+ const evidenceRecords = evidenceRecordsForInvocation(invocation, nowIso());
1107
+ const withEvidence = evidenceRecords.length > 0
1108
+ ? {
1109
+ ...updated,
1110
+ evidenceRecords: [...(updated.evidenceRecords ?? []), ...evidenceRecords]
1111
+ }
1112
+ : updated;
1113
+ await writeFile(context.stateFilePath, JSON.stringify(withEvidence, null, 2), "utf8");
645
1114
  await syncCurrentWorkspaceView(context);
646
- return updated;
1115
+ return withEvidence;
647
1116
  }
648
1117
  function createId(prefix) {
649
1118
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -789,7 +1258,24 @@ export async function appendLongTableInterviewTurn(options) {
789
1258
  : [])
790
1259
  ]
791
1260
  };
792
- const updated = upsertHook(state, hook);
1261
+ const evidence = {
1262
+ id: createId("evidence"),
1263
+ createdAt: timestamp,
1264
+ sourceKind: "interview_turn",
1265
+ sourceId: turn.id,
1266
+ sourceHookId: existing.id,
1267
+ summary: compactLine(`Interview turn ${turn.index}: ${turn.answer}`),
1268
+ rawText: [
1269
+ `Question: ${turn.question}`,
1270
+ `Answer: ${turn.answer}`,
1271
+ turn.reflection ? `Reflection: ${turn.reflection}` : ""
1272
+ ].filter(Boolean).join("\n")
1273
+ };
1274
+ const updated = {
1275
+ ...upsertHook(state, hook),
1276
+ interviewTurns: [...(state.interviewTurns ?? []), turn],
1277
+ evidenceRecords: [...(state.evidenceRecords ?? []), evidence]
1278
+ };
793
1279
  await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
794
1280
  await syncCurrentWorkspaceView(options.context);
795
1281
  return { hook, turn, state: updated };
@@ -956,12 +1442,18 @@ export async function summarizeLongTableResearchSpecification(options) {
956
1442
  resumeHint: `I want to continue from the Research Specification: ${specification.title}.`
957
1443
  };
958
1444
  options.context.session = session;
959
- let updated = hook ? upsertHook(state, hook) : state;
960
- updated.researchSpecification = specification;
961
- updated.workingState = {
962
- ...updated.workingState,
963
- researchSpecification: specification
964
- };
1445
+ const sourceEvidenceIds = (state.evidenceRecords ?? [])
1446
+ .filter((record) => record.sourceHookId && record.sourceHookId === (existing?.id ?? sourceHookId))
1447
+ .map((record) => record.id);
1448
+ const audited = applyResearchSpecificationAuditUpdate(hook ? upsertHook(state, hook) : state, {
1449
+ specification,
1450
+ timestamp,
1451
+ source: "interview",
1452
+ title: `Research Specification draft: ${specification.title}`,
1453
+ rationale: "Stored or refreshed the required Research Specification from LongTable interview evidence.",
1454
+ sourceEvidenceIds
1455
+ });
1456
+ let updated = audited.state;
965
1457
  updated.narrativeTraces.push({
966
1458
  id: createId("narrative_trace"),
967
1459
  timestamp,
@@ -976,6 +1468,93 @@ export async function summarizeLongTableResearchSpecification(options) {
976
1468
  await syncCurrentWorkspaceView(options.context);
977
1469
  return { hook, specification, state: updated, session };
978
1470
  }
1471
+ export async function proposeResearchSpecificationPatch(options) {
1472
+ const state = await loadResearchState(options.context.stateFilePath);
1473
+ const timestamp = nowIso();
1474
+ const specification = normalizeResearchSpecification(options.specification, options.specification.sourceHookId ?? state.researchSpecification?.sourceHookId, timestamp);
1475
+ const sourceEvidenceIds = mergeStringLists(options.specification.sourceEvidenceIds, specification.sourceEvidenceIds, options.sourceEvidenceIds);
1476
+ const changes = diffResearchSpecifications(state.researchSpecification, specification)
1477
+ .map((change) => ({
1478
+ ...change,
1479
+ evidenceRecordIds: sourceEvidenceIds
1480
+ }));
1481
+ const patch = {
1482
+ id: createId("spec_patch"),
1483
+ createdAt: timestamp,
1484
+ updatedAt: timestamp,
1485
+ status: "proposed",
1486
+ source: options.source ?? "manual",
1487
+ title: `Proposed Research Specification update: ${specification.title}`,
1488
+ ...(options.rationale ? { rationale: options.rationale } : {}),
1489
+ changes,
1490
+ sourceEvidenceIds,
1491
+ targetSpecification: specification
1492
+ };
1493
+ const updated = {
1494
+ ...state,
1495
+ specPatches: [...(state.specPatches ?? []), patch]
1496
+ };
1497
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
1498
+ await syncCurrentWorkspaceView(options.context);
1499
+ return { patch, changes, state: updated };
1500
+ }
1501
+ export async function applyResearchSpecificationPatch(options) {
1502
+ const state = await loadResearchState(options.context.stateFilePath);
1503
+ const timestamp = nowIso();
1504
+ const storedPatch = options.patchId
1505
+ ? (state.specPatches ?? []).find((patch) => patch.id === options.patchId)
1506
+ : undefined;
1507
+ if (options.patchId && !storedPatch) {
1508
+ throw new Error(`No Research Specification patch found for ${options.patchId}.`);
1509
+ }
1510
+ const inputSpecification = options.specification ?? storedPatch?.targetSpecification;
1511
+ if (!inputSpecification) {
1512
+ throw new Error(options.patchId ? `No target Research Specification found for patch ${options.patchId}.` : "Research Specification is required when no patchId is supplied.");
1513
+ }
1514
+ const specification = normalizeResearchSpecification(inputSpecification, inputSpecification.sourceHookId ?? state.researchSpecification?.sourceHookId, timestamp);
1515
+ const audited = applyResearchSpecificationAuditUpdate(state, {
1516
+ specification,
1517
+ timestamp,
1518
+ source: options.source ?? storedPatch?.source ?? "manual",
1519
+ title: storedPatch?.title ?? `Applied Research Specification update: ${specification.title}`,
1520
+ rationale: options.rationale ?? storedPatch?.rationale,
1521
+ sourceEvidenceIds: mergeStringLists(storedPatch?.sourceEvidenceIds, options.sourceEvidenceIds),
1522
+ patch: storedPatch,
1523
+ questionRecordId: options.questionRecordId ?? storedPatch?.questionRecordId,
1524
+ decisionRecordId: options.decisionRecordId ?? storedPatch?.decisionRecordId
1525
+ });
1526
+ const session = {
1527
+ ...options.context.session,
1528
+ researchSpecification: audited.specification,
1529
+ lastUpdatedAt: timestamp,
1530
+ resumeHint: `I want to continue from the Research Specification: ${audited.specification.title}.`
1531
+ };
1532
+ options.context.session = session;
1533
+ await writeFile(options.context.sessionFilePath, JSON.stringify(session, null, 2), "utf8");
1534
+ await writeFile(options.context.stateFilePath, JSON.stringify(audited.state, null, 2), "utf8");
1535
+ await syncCurrentWorkspaceView(options.context);
1536
+ return {
1537
+ patch: audited.patch,
1538
+ revision: audited.revision,
1539
+ specification: audited.specification,
1540
+ state: audited.state,
1541
+ session,
1542
+ ...(audited.decision ? { decision: audited.decision } : {})
1543
+ };
1544
+ }
1545
+ export async function readResearchSpecificationHistory(context) {
1546
+ const state = await loadResearchState(context.stateFilePath);
1547
+ return {
1548
+ ...(state.researchSpecification ? { specification: state.researchSpecification } : {}),
1549
+ revisions: state.specRevisions ?? [],
1550
+ patches: state.specPatches ?? [],
1551
+ evidenceRecords: state.evidenceRecords ?? []
1552
+ };
1553
+ }
1554
+ export async function findUnincorporatedResearchEvidence(context) {
1555
+ const state = await loadResearchState(context.stateFilePath);
1556
+ return (state.evidenceRecords ?? []).filter((record) => !record.incorporatedByRevisionId);
1557
+ }
979
1558
  function findQuestionForDecision(state, questionId) {
980
1559
  const pending = (state.questionLog ?? []).filter((record) => record.status === "pending");
981
1560
  if (questionId) {
@@ -1668,6 +2247,70 @@ export function generateQuestionOpportunities(prompt, options = {}) {
1668
2247
  };
1669
2248
  }
1670
2249
  const FOLLOW_UP_PROMPT_PREFIX = "Follow-up prompt:";
2250
+ function compactMetadataText(parts) {
2251
+ return parts
2252
+ .flatMap((part) => Array.isArray(part) ? part : [part])
2253
+ .filter((part) => Boolean(part && part.trim()))
2254
+ .join(" ")
2255
+ .replace(/\s+/g, " ")
2256
+ .trim()
2257
+ .toLowerCase();
2258
+ }
2259
+ function textMatchesAny(text, patterns) {
2260
+ return patterns.some((pattern) => pattern.test(text));
2261
+ }
2262
+ const COMMITMENT_FAMILY_BY_CHECKPOINT = [
2263
+ [/product|meta_decision/, "product_policy"],
2264
+ [/research_question|research_direction|scope|boundary|inclusion|exclusion/, "scope"],
2265
+ [/theory|construct|conceptual/, "construct"],
2266
+ [/measurement|coding|codebook|extraction/, "coding"],
2267
+ [/method|analysis|panel_disagreement|team_debate|review/, "method"],
2268
+ [/evidence|scholarly_access|source_authority/, "evidence"],
2269
+ [/knowledge_gap|tacit_assumption|epistemic/, "epistemic_authority"]
2270
+ ];
2271
+ function inferCommitmentFamily(input) {
2272
+ const checkpointKey = (input.checkpointKey ?? "").toLowerCase();
2273
+ const matched = COMMITMENT_FAMILY_BY_CHECKPOINT.find(([pattern]) => pattern.test(checkpointKey));
2274
+ if (matched)
2275
+ return matched[1];
2276
+ if (input.triggerFamily === "meta_decision")
2277
+ return "product_policy";
2278
+ if (input.triggerFamily === "evidence")
2279
+ return "evidence";
2280
+ const text = compactMetadataText([input.title, input.question, input.prompt, input.rationale]);
2281
+ if (textMatchesAny(text, [/checkpoint policy/, /hook ux/, /product language/, /\breadme\b/, /제품 언어|체크포인트 정책|훅|리드미/])) {
2282
+ return "product_policy";
2283
+ }
2284
+ return undefined;
2285
+ }
2286
+ function inferEpistemicBasis(input) {
2287
+ const text = compactMetadataText([input.title, input.question, input.prompt, input.rationale]);
2288
+ const bases = [];
2289
+ if (textMatchesAny(text, [/\bresearcher\b/, /\bhuman\b/, /\byour judgment\b/, /\byour knowledge\b/, /연구자|인간|사람|너의\s*판단|당신의\s*판단|내\s*지식|사용자/])) {
2290
+ bases.push("researcher_knowledge");
2291
+ }
2292
+ if (textMatchesAny(text, [/\bproject state\b/, /\bworkspace\b/, /\bcurrent\.md\b/, /\.longtable\b/, /\bstate\.json\b/, /\bdataset\b/, /\bcodebook\b/, /\bcoding sheet\b/, /프로젝트\s*상태|워크스페이스|데이터셋|코드북|코딩\s*시트/])) {
2293
+ bases.push("project_state");
2294
+ }
2295
+ if (textMatchesAny(text, [/\bexternal evidence\b/, /\bliterature\b/, /\bpaper\b/, /\bpdf\b/, /\bsource\b/, /\bcitation\b/, /\breference\b/, /\bfull[- ]?text\b/, /외부\s*근거|문헌|논문|원문|전문|출처|인용|레퍼런스/])) {
2296
+ bases.push("external_evidence");
2297
+ }
2298
+ if (textMatchesAny(text, [/\bcodex\b/, /\bllm\b/, /\blanguage model\b/, /\bmodel judgment\b/, /\bai inference\b/, /\bassistant judgment\b/, /코덱스|언어\s*모델|모델\s*판단|AI\s*추론|LLM/])) {
2299
+ bases.push("ai_inference");
2300
+ }
2301
+ const unique = [...new Set(bases)];
2302
+ if (unique.length > 1)
2303
+ return "mixed";
2304
+ return unique[0];
2305
+ }
2306
+ function resolveQuestionRecordMetadata(input) {
2307
+ const commitmentFamily = input.commitmentFamily ?? inferCommitmentFamily(input);
2308
+ const epistemicBasis = input.epistemicBasis ?? inferEpistemicBasis(input);
2309
+ return {
2310
+ ...(commitmentFamily ? { commitmentFamily } : {}),
2311
+ ...(epistemicBasis ? { epistemicBasis } : {})
2312
+ };
2313
+ }
1671
2314
  function hasFollowUpPrompt(record, prompt) {
1672
2315
  return record.prompt.rationale.includes(`${FOLLOW_UP_PROMPT_PREFIX} ${prompt}`);
1673
2316
  }
@@ -1705,31 +2348,43 @@ export async function createWorkspaceFollowUpQuestions(options) {
1705
2348
  if (specsToCreate.length === 0) {
1706
2349
  return { questions: pendingMatches, state, created: false, alreadyAnswered: false };
1707
2350
  }
1708
- const questions = specsToCreate.map((spec) => ({
1709
- id: createId("question_record"),
1710
- createdAt,
1711
- updatedAt: createdAt,
1712
- status: "pending",
1713
- prompt: {
1714
- id: createId("question_prompt"),
1715
- checkpointKey: `follow_up_${spec.key}`,
2351
+ const questions = specsToCreate.map((spec) => {
2352
+ const checkpointKey = `follow_up_${spec.key}`;
2353
+ const rationale = [
2354
+ spec.whyNow,
2355
+ `Question kind: ${spec.kind}`,
2356
+ `Question confidence: ${spec.confidence}`,
2357
+ `${FOLLOW_UP_PROMPT_PREFIX} ${options.prompt}`
2358
+ ];
2359
+ const metadata = resolveQuestionRecordMetadata({
2360
+ checkpointKey,
1716
2361
  title: spec.title,
1717
2362
  question: spec.question,
1718
- type: "single_choice",
1719
- options: spec.options,
1720
- allowOther: true,
1721
- otherLabel: "Other",
1722
- required: options.required ?? spec.required,
1723
- source: "runtime_guidance",
1724
- rationale: [
1725
- spec.whyNow,
1726
- `Question kind: ${spec.kind}`,
1727
- `Question confidence: ${spec.confidence}`,
1728
- `${FOLLOW_UP_PROMPT_PREFIX} ${options.prompt}`
1729
- ],
1730
- preferredSurfaces: preferredSurfaces
1731
- }
1732
- }));
2363
+ prompt: options.prompt,
2364
+ rationale
2365
+ });
2366
+ return {
2367
+ id: createId("question_record"),
2368
+ createdAt,
2369
+ updatedAt: createdAt,
2370
+ status: "pending",
2371
+ ...metadata,
2372
+ prompt: {
2373
+ id: createId("question_prompt"),
2374
+ checkpointKey,
2375
+ title: spec.title,
2376
+ question: spec.question,
2377
+ type: "single_choice",
2378
+ options: spec.options,
2379
+ allowOther: true,
2380
+ otherLabel: "Other",
2381
+ required: options.required ?? spec.required,
2382
+ source: "runtime_guidance",
2383
+ rationale,
2384
+ preferredSurfaces: preferredSurfaces
2385
+ }
2386
+ };
2387
+ });
1733
2388
  const updated = appendQuestionRecords(state, questions);
1734
2389
  await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
1735
2390
  await syncCurrentWorkspaceView(options.context);
@@ -1743,16 +2398,35 @@ export async function createWorkspaceQuestion(options) {
1743
2398
  });
1744
2399
  const checkpointKey = options.checkpointKey ?? trigger.signal.checkpointKey;
1745
2400
  const createdAt = nowIso();
2401
+ const title = options.title ?? questionTitleForCheckpoint(trigger.family, checkpointKey);
2402
+ const questionText = options.question ?? questionTextForCheckpoint(trigger.family, options.prompt, checkpointKey);
2403
+ const rationale = [
2404
+ ...trigger.rationale,
2405
+ `Trigger family: ${trigger.family}.`,
2406
+ `Trigger confidence: ${trigger.confidence}.`,
2407
+ `Original prompt: ${options.prompt}`
2408
+ ];
2409
+ const metadata = resolveQuestionRecordMetadata({
2410
+ checkpointKey,
2411
+ triggerFamily: trigger.family,
2412
+ title,
2413
+ question: questionText,
2414
+ prompt: options.prompt,
2415
+ rationale,
2416
+ commitmentFamily: options.commitmentFamily,
2417
+ epistemicBasis: options.epistemicBasis
2418
+ });
1746
2419
  const question = {
1747
2420
  id: createId("question_record"),
1748
2421
  createdAt,
1749
2422
  updatedAt: createdAt,
1750
2423
  status: "pending",
2424
+ ...metadata,
1751
2425
  prompt: {
1752
2426
  id: createId("question_prompt"),
1753
2427
  checkpointKey,
1754
- title: options.title ?? questionTitleForCheckpoint(trigger.family, checkpointKey),
1755
- question: options.question ?? questionTextForCheckpoint(trigger.family, options.prompt, checkpointKey),
2428
+ title,
2429
+ question: questionText,
1756
2430
  type: "single_choice",
1757
2431
  options: options.questionOptions ?? optionsForCheckpointTrigger(trigger.family, checkpointKey),
1758
2432
  allowOther: true,
@@ -1760,12 +2434,7 @@ export async function createWorkspaceQuestion(options) {
1760
2434
  required: options.required ?? trigger.requiresQuestionBeforeClosure,
1761
2435
  source: "checkpoint",
1762
2436
  displayReason: options.displayReason ?? trigger.rationale[0],
1763
- rationale: [
1764
- ...trigger.rationale,
1765
- `Trigger family: ${trigger.family}.`,
1766
- `Trigger confidence: ${trigger.confidence}.`,
1767
- `Original prompt: ${options.prompt}`
1768
- ],
2437
+ rationale,
1769
2438
  preferredSurfaces: options.provider === "claude"
1770
2439
  ? ["native_structured", "numbered"]
1771
2440
  : ["mcp_elicitation", "numbered"]
@@ -1892,6 +2561,8 @@ export async function answerWorkspaceQuestion(options) {
1892
2561
  level: question.prompt.required ? "adaptive_required" : "recommended",
1893
2562
  mode: "commit",
1894
2563
  summary: `Answered ${question.prompt.title}: ${answer.selectedLabels.join(", ")}`,
2564
+ ...(question.commitmentFamily ? { commitmentFamily: question.commitmentFamily } : {}),
2565
+ ...(question.epistemicBasis ? { epistemicBasis: question.epistemicBasis } : {}),
1895
2566
  selectedOption: answer.selectedValues[0],
1896
2567
  ...(rationale ? { rationale } : {})
1897
2568
  };