@longtable/cli 0.1.31 → 0.1.33

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.
@@ -4,6 +4,7 @@ import { execSync } from "node:child_process";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { appendDecisionRecord as appendDecisionToResearchState, appendInvocationRecord as appendInvocationToResearchState, appendQuestionRecords, createEmptyResearchState } from "@longtable/memory";
6
6
  import { classifyCheckpointTrigger } from "@longtable/checkpoints";
7
+ import { ensureRequiredQuestionObligation, pendingQuestionObligations, resolveQuestionObligationByQuestionId } from "./question-obligations.js";
7
8
  const CURRENT_FILE_NAME = "CURRENT.md";
8
9
  const LEGACY_ROOT_FILES = ["LONGTABLE.md", "START-HERE.md", "NEXT-STEPS.md", "SESSION-SNAPSHOT.md"];
9
10
  function nowIso() {
@@ -54,6 +55,9 @@ function resolveUserLocale() {
54
55
  return normalizeLocale(Intl.DateTimeFormat().resolvedOptions().locale);
55
56
  }
56
57
  function buildFirstQuestion(session) {
58
+ if (session.firstResearchShape?.openQuestions?.[0]) {
59
+ return session.firstResearchShape.openQuestions[0];
60
+ }
57
61
  return session.currentBlocker
58
62
  ? `Where does "${session.currentBlocker}" show up most concretely in the scene, material, or evidence?`
59
63
  : `What scene, case, text, data, or draft would make "${session.currentGoal}" easiest to inspect first?`;
@@ -63,20 +67,23 @@ function buildOpenQuestions(session) {
63
67
  if (session.startInterview) {
64
68
  return [
65
69
  firstQuestion,
66
- `What would a reader need to understand differently if "${session.currentGoal}" becomes a strong research project?`
70
+ `What still feels hardest to name or make concrete in "${session.currentGoal}"?`
67
71
  ];
68
72
  }
69
73
  return session.currentBlocker
70
74
  ? [
71
75
  firstQuestion,
72
- `What would a reader need to understand differently if "${session.currentBlocker}" becomes a strong research problem?`
76
+ `What would give "${session.currentBlocker}" a usable first research handle without forcing a final research question yet?`
73
77
  ]
74
78
  : [
75
79
  firstQuestion,
76
- `What would count as a good first outcome for "${session.currentGoal}" in this session?`
80
+ `What would give this project a usable first research handle without pretending the question is settled?`
77
81
  ];
78
82
  }
79
83
  function buildNextAction(session) {
84
+ if (session.firstResearchShape) {
85
+ return session.firstResearchShape.nextAction;
86
+ }
80
87
  if (session.startInterview) {
81
88
  return session.currentBlocker
82
89
  ? `Begin from the start-interview brief, then make "${session.currentBlocker}" concrete with one scene, source, case, or dataset.`
@@ -87,6 +94,9 @@ function buildNextAction(session) {
87
94
  : "Open with your current goal in one sentence, then ask LongTable for the first concrete research move.";
88
95
  }
89
96
  function buildResumeHint(session) {
97
+ if (session.firstResearchShape) {
98
+ return `I want to continue from the First Research Shape: ${session.firstResearchShape.handle}.`;
99
+ }
90
100
  if (session.startInterview) {
91
101
  return session.currentBlocker
92
102
  ? `I want to continue from the LongTable start interview. The first unresolved issue is ${session.currentBlocker}.`
@@ -96,7 +106,7 @@ function buildResumeHint(session) {
96
106
  ? `I want to continue ${session.currentGoal}. The unresolved blocker is ${session.currentBlocker}.`
97
107
  : `I want to continue ${session.currentGoal}.`;
98
108
  }
99
- function buildCurrentGuide(project, session, recentInvocations = [], pendingQuestions = []) {
109
+ function buildCurrentGuide(project, session, recentInvocations = [], pendingQuestions = [], pendingObligations = []) {
100
110
  const locale = normalizeLocale(session.locale ?? project.locale);
101
111
  const openQuestions = session.openQuestions && session.openQuestions.length > 0
102
112
  ? session.openQuestions
@@ -118,6 +128,7 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
118
128
  ...(session.researchObject ? [`- 연구 객체: ${session.researchObject}`] : []),
119
129
  ...(session.gapRisk ? [`- 공백/암묵지 위험: ${session.gapRisk}`] : []),
120
130
  ...(session.protectedDecision ? [`- 보호할 결정: ${session.protectedDecision}`] : []),
131
+ ...(session.firstResearchShape ? [`- First Research Shape: ${session.firstResearchShape.handle}`] : []),
121
132
  ...(session.startInterview ? [`- start interview: ${session.startInterview.summary}`] : []),
122
133
  `- 다음 액션: ${nextAction}`,
123
134
  `- 관점: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
@@ -146,13 +157,32 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
146
157
  "- 답변 기록: `longtable decide --question <id> --answer <value>`"
147
158
  ]
148
159
  : []),
160
+ ...(pendingObligations.length > 0
161
+ ? [
162
+ "",
163
+ "## 대기 중인 연구 의무",
164
+ ...pendingObligations.map((obligation) => {
165
+ const linked = obligation.questionId ? ` [question: ${obligation.questionId}]` : "";
166
+ return `- ${obligation.prompt}${linked}: ${obligation.reason}`;
167
+ })
168
+ ]
169
+ : []),
149
170
  "",
150
171
  "## 다시 시작 문장",
151
172
  `- "${resumeHint}"`,
173
+ ...(session.firstResearchShape
174
+ ? [
175
+ "",
176
+ "## First Research Shape",
177
+ `- Handle: ${session.firstResearchShape.handle}`,
178
+ `- Confidence: ${session.firstResearchShape.confidence}`,
179
+ ...session.firstResearchShape.openQuestions.map((question) => `- Open question: ${question}`)
180
+ ]
181
+ : []),
152
182
  "",
153
183
  "## 빠른 시작",
154
184
  "- 이 디렉토리에서 `codex`를 엽니다.",
155
- `- 첫 메시지는 보통 \`${suggestedPrompt}\` 정도면 충분합니다.`,
185
+ `- 첫 메시지는 보통 \`${session.firstResearchShape ? suggestedPrompt : "$longtable-interview"}\` 정도면 충분합니다.`,
156
186
  "",
157
187
  "## 증거 규칙",
158
188
  "- 외부 사실이나 현재 정보는 source를 붙이거나 inference로 낮춥니다."
@@ -171,6 +201,7 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
171
201
  ...(session.researchObject ? [`- Research object: ${session.researchObject}`] : []),
172
202
  ...(session.gapRisk ? [`- Gap/tacit risk: ${session.gapRisk}`] : []),
173
203
  ...(session.protectedDecision ? [`- Protected decision: ${session.protectedDecision}`] : []),
204
+ ...(session.firstResearchShape ? [`- First Research Shape: ${session.firstResearchShape.handle}`] : []),
174
205
  ...(session.startInterview ? [`- Start interview: ${session.startInterview.summary}`] : []),
175
206
  `- Next action: ${nextAction}`,
176
207
  `- Perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
@@ -199,13 +230,32 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
199
230
  "- Record an answer: `longtable decide --question <id> --answer <value>`"
200
231
  ]
201
232
  : []),
233
+ ...(pendingObligations.length > 0
234
+ ? [
235
+ "",
236
+ "## Pending Research Obligations",
237
+ ...pendingObligations.map((obligation) => {
238
+ const linked = obligation.questionId ? ` [question: ${obligation.questionId}]` : "";
239
+ return `- ${obligation.prompt}${linked}: ${obligation.reason}`;
240
+ })
241
+ ]
242
+ : []),
202
243
  "",
203
244
  "## Restart Prompt",
204
245
  `- "${resumeHint}"`,
246
+ ...(session.firstResearchShape
247
+ ? [
248
+ "",
249
+ "## First Research Shape",
250
+ `- Handle: ${session.firstResearchShape.handle}`,
251
+ `- Confidence: ${session.firstResearchShape.confidence}`,
252
+ ...session.firstResearchShape.openQuestions.map((question) => `- Open question: ${question}`)
253
+ ]
254
+ : []),
205
255
  "",
206
256
  "## Quick Start",
207
257
  "- Open `codex` in this directory.",
208
- `- A good first message is usually \`${suggestedPrompt}\`.`,
258
+ `- A good first message is usually \`${session.firstResearchShape ? suggestedPrompt : "$longtable-interview"}\`.`,
209
259
  "",
210
260
  "## Evidence Rule",
211
261
  "- External or current claims should carry a source link or be labeled as inference."
@@ -220,6 +270,9 @@ async function loadResearchState(stateFilePath) {
220
270
  ...parsed,
221
271
  explicitState: parsed.explicitState ?? {},
222
272
  workingState: parsed.workingState ?? {},
273
+ hooks: parsed.hooks ?? [],
274
+ ...(parsed.firstResearchShape ? { firstResearchShape: parsed.firstResearchShape } : {}),
275
+ questionObligations: parsed.questionObligations ?? [],
223
276
  inferredHypotheses: parsed.inferredHypotheses ?? [],
224
277
  openTensions: parsed.openTensions ?? [],
225
278
  decisionLog: parsed.decisionLog ?? [],
@@ -242,6 +295,17 @@ function recentPendingQuestions(state, limit = 3) {
242
295
  .slice(-limit)
243
296
  .reverse();
244
297
  }
298
+ function visiblePendingObligations(state) {
299
+ const pendingQuestionIds = new Set((state.questionLog ?? [])
300
+ .filter((record) => record.status === "pending")
301
+ .map((record) => record.id));
302
+ return pendingQuestionObligations(state).filter((obligation) => !obligation.questionId || !pendingQuestionIds.has(obligation.questionId));
303
+ }
304
+ function recentPendingObligations(state, limit = 3) {
305
+ return visiblePendingObligations(state)
306
+ .slice(-limit)
307
+ .reverse();
308
+ }
245
309
  function formatQuestionOptionValues(record) {
246
310
  const values = record.prompt.options.map((option) => option.value);
247
311
  if (record.prompt.allowOther) {
@@ -253,6 +317,7 @@ function summarizeWorkspaceInspection(context, state) {
253
317
  const questions = state.questionLog ?? [];
254
318
  const pendingQuestions = questions.filter((record) => record.status === "pending");
255
319
  const answeredQuestions = questions.filter((record) => record.status === "answered");
320
+ const pendingObligations = visiblePendingObligations(state);
256
321
  return {
257
322
  found: true,
258
323
  rootPath: context.project.projectPath,
@@ -279,6 +344,7 @@ function summarizeWorkspaceInspection(context, state) {
279
344
  invocations: (state.invocationLog ?? []).length,
280
345
  questions: questions.length,
281
346
  pendingQuestions: pendingQuestions.length,
347
+ pendingObligations: pendingObligations.length,
282
348
  answeredQuestions: answeredQuestions.length,
283
349
  decisions: (state.decisionLog ?? []).length
284
350
  },
@@ -299,6 +365,13 @@ function summarizeWorkspaceInspection(context, state) {
299
365
  options: formatQuestionOptionValues(record),
300
366
  required: record.prompt.required
301
367
  })),
368
+ pendingObligations: pendingObligations.slice(-5).reverse().map((obligation) => ({
369
+ id: obligation.id,
370
+ kind: obligation.kind,
371
+ prompt: obligation.prompt,
372
+ reason: obligation.reason,
373
+ ...(obligation.questionId ? { questionId: obligation.questionId } : {})
374
+ })),
302
375
  recentDecisions: (state.decisionLog ?? []).slice(-5).reverse().map((record) => ({
303
376
  id: record.id,
304
377
  checkpointKey: record.checkpointKey,
@@ -339,13 +412,16 @@ function buildProjectAgentsMd(project, session) {
339
412
  "- Treat `AGENTS.md` as runtime guidance, not as the researcher-facing resume artifact.",
340
413
  "",
341
414
  "## Invocation Rules",
415
+ "- If the user message starts with `$longtable-interview`, run the LongTable interview flow before generic research advice.",
342
416
  "- If the user message starts with `lt `, `longtable `, `long table `, or `롱테이블 ` followed by a directive and `:`, treat it as an explicit LongTable invocation.",
343
- "- Supported explicit directives are: explore, review, critique, draft, commit, panel, status, editor, reviewer, methods, theory, measurement, ethics, voice, venue.",
417
+ "- Supported explicit directives are: interview, explore, review, critique, draft, commit, panel, status, editor, reviewer, methods, theory, measurement, ethics, voice, venue.",
344
418
  "- For explicit LongTable invocations, do not begin by scanning the workspace. Use the current session files first and answer as LongTable immediately.",
345
419
  "- For general research requests in this workspace, prefer LongTable behavior before generic coding behavior.",
346
420
  "",
347
421
  "## Research Behavior",
348
422
  "- Begin exploratory work with clarifying or tension questions before recommending a direction.",
423
+ "- For `$longtable-interview`, ask one natural-language question at a time, reflect with `LongTable hears: ...`, and avoid early reader/reviewer or theory/method/measurement classification.",
424
+ "- Use structured options only at the final First Research Shape confirmation or at true checkpoint boundaries.",
349
425
  "- If you foreground role perspectives, disclose them with `LongTable consulted: ...`.",
350
426
  "- Keep one accountable synthesis, but do not hide meaningful disagreement.",
351
427
  ...(session.disagreementPreference === "always_visible"
@@ -362,6 +438,7 @@ function buildProjectAgentsMd(project, session) {
362
438
  ...(session.researchObject ? [`- Research object: ${session.researchObject}`] : []),
363
439
  ...(session.gapRisk ? [`- Gap/tacit risk: ${session.gapRisk}`] : []),
364
440
  ...(session.protectedDecision ? [`- Protected decision: ${session.protectedDecision}`] : []),
441
+ ...(session.firstResearchShape ? [`- First Research Shape: ${session.firstResearchShape.handle}`] : []),
365
442
  ...(session.startInterview ? [`- Start interview summary: ${session.startInterview.summary}`] : []),
366
443
  `- Requested perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
367
444
  `- Disagreement visibility: ${session.disagreementPreference}`,
@@ -390,6 +467,10 @@ function buildStateSeed(project, session, setup) {
390
467
  ...(session.startInterview ? { startInterview: session.startInterview } : {}),
391
468
  ...(session.resumeHint ? { resumeHint: session.resumeHint } : {})
392
469
  };
470
+ if (session.firstResearchShape) {
471
+ state.firstResearchShape = session.firstResearchShape;
472
+ state.workingState.firstResearchShape = session.firstResearchShape;
473
+ }
393
474
  if (session.currentBlocker) {
394
475
  state.openTensions.push(session.currentBlocker);
395
476
  }
@@ -460,7 +541,7 @@ async function removeLegacyRootFiles(projectPath) {
460
541
  }
461
542
  export async function syncCurrentWorkspaceView(context) {
462
543
  const state = await loadResearchState(context.stateFilePath);
463
- const body = buildCurrentGuide(context.project, context.session, recentInvocationRecords(state), recentPendingQuestions(state));
544
+ const body = buildCurrentGuide(context.project, context.session, recentInvocationRecords(state), recentPendingQuestions(state), recentPendingObligations(state));
464
545
  await writeFile(context.currentFilePath, body, "utf8");
465
546
  return context.currentFilePath;
466
547
  }
@@ -477,6 +558,200 @@ export async function appendInvocationRecordToWorkspace(context, invocation, que
477
558
  function createId(prefix) {
478
559
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
479
560
  }
561
+ function normalizeInterviewQuality(answer, quality) {
562
+ if (quality) {
563
+ return quality;
564
+ }
565
+ const trimmed = answer.trim();
566
+ const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
567
+ if (trimmed.length < 12 || wordCount < 3) {
568
+ return "thin";
569
+ }
570
+ if (trimmed.length > 80 || wordCount >= 12) {
571
+ return "rich";
572
+ }
573
+ return "usable";
574
+ }
575
+ function defaultFollowUpQuestion(answer) {
576
+ const trimmed = answer.trim();
577
+ if (trimmed.length < 12) {
578
+ return "Say one more sentence about where this problem appears or why it matters before LongTable tries to classify it.";
579
+ }
580
+ return "What concrete scene, case, material, text, dataset, or decision would make that problem easier to inspect first?";
581
+ }
582
+ function depthForInterview(turns = []) {
583
+ const usableTurns = turns.filter((turn) => turn.quality !== "thin").length;
584
+ if (usableTurns >= 3) {
585
+ return "ready_to_summarize";
586
+ }
587
+ if (usableTurns >= 1) {
588
+ return "forming_first_handle";
589
+ }
590
+ return "gathering_context";
591
+ }
592
+ function activeInterviewHook(state, hookId) {
593
+ const hooks = state.hooks ?? [];
594
+ if (hookId) {
595
+ return hooks.find((hook) => hook.id === hookId);
596
+ }
597
+ return [...hooks].reverse().find((hook) => hook.kind === "longtable_interview" &&
598
+ (hook.status === "pending" || hook.status === "active" || hook.status === "ready_to_confirm"));
599
+ }
600
+ function upsertHook(state, hook) {
601
+ const hooks = state.hooks ?? [];
602
+ const existingIndex = hooks.findIndex((candidate) => candidate.id === hook.id);
603
+ const nextHooks = existingIndex >= 0
604
+ ? hooks.map((candidate) => candidate.id === hook.id ? hook : candidate)
605
+ : [...hooks, hook];
606
+ return {
607
+ ...state,
608
+ hooks: nextHooks
609
+ };
610
+ }
611
+ export async function beginLongTableInterview(options) {
612
+ const state = await loadResearchState(options.context.stateFilePath);
613
+ const existing = activeInterviewHook(state);
614
+ if (existing) {
615
+ return { hook: existing, state };
616
+ }
617
+ const timestamp = nowIso();
618
+ const hook = {
619
+ id: createId("hook_interview"),
620
+ kind: "longtable_interview",
621
+ status: "active",
622
+ createdAt: timestamp,
623
+ updatedAt: timestamp,
624
+ targetOutcome: "first_research_handle",
625
+ depth: "gathering_context",
626
+ provider: options.provider,
627
+ turns: [],
628
+ qualityNotes: [],
629
+ rationale: [
630
+ "Official LongTable research start surface is provider-native `$longtable-interview`, not the CLI start questionnaire.",
631
+ "The hook keeps early research ambiguity open until a first research handle can be summarized."
632
+ ]
633
+ };
634
+ let updated = upsertHook(state, hook);
635
+ updated.workingState = {
636
+ ...updated.workingState,
637
+ activeInterviewHookId: hook.id,
638
+ interviewSurface: "$longtable-interview",
639
+ ...(options.openingQuestion ? { interviewOpeningQuestion: options.openingQuestion } : {}),
640
+ ...(options.seedAnswer ? { interviewSeedAnswer: options.seedAnswer } : {})
641
+ };
642
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
643
+ await syncCurrentWorkspaceView(options.context);
644
+ return { hook, state: updated };
645
+ }
646
+ export async function appendLongTableInterviewTurn(options) {
647
+ const state = await loadResearchState(options.context.stateFilePath);
648
+ const existing = activeInterviewHook(state, options.hookId);
649
+ if (!existing) {
650
+ throw new Error("No active LongTable interview hook was found. Run begin_interview first.");
651
+ }
652
+ const quality = normalizeInterviewQuality(options.answer, options.quality);
653
+ const needsFollowUp = options.needsFollowUp ?? quality === "thin";
654
+ const followUpQuestion = needsFollowUp
655
+ ? options.followUpQuestion ?? defaultFollowUpQuestion(options.answer)
656
+ : options.followUpQuestion;
657
+ const timestamp = nowIso();
658
+ const turns = existing.turns ?? [];
659
+ const turn = {
660
+ id: createId("interview_turn"),
661
+ index: turns.length + 1,
662
+ createdAt: timestamp,
663
+ question: options.question.trim(),
664
+ answer: options.answer.trim(),
665
+ ...(options.reflection?.trim() ? { reflection: options.reflection.trim() } : {}),
666
+ quality,
667
+ needsFollowUp,
668
+ ...(followUpQuestion?.trim() ? { followUpQuestion: followUpQuestion.trim() } : {}),
669
+ ...(options.rationale && options.rationale.length > 0 ? { rationale: options.rationale } : {})
670
+ };
671
+ const nextTurns = [...turns, turn];
672
+ const depth = depthForInterview(nextTurns);
673
+ const hook = {
674
+ ...existing,
675
+ status: depth === "ready_to_summarize" ? "ready_to_confirm" : "active",
676
+ updatedAt: timestamp,
677
+ depth,
678
+ turns: nextTurns,
679
+ qualityNotes: [
680
+ ...(existing.qualityNotes ?? []),
681
+ ...(needsFollowUp ? [`Turn ${turn.index} needs follow-up: ${followUpQuestion}`] : [])
682
+ ]
683
+ };
684
+ const updated = upsertHook(state, hook);
685
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
686
+ await syncCurrentWorkspaceView(options.context);
687
+ return { hook, turn, state: updated };
688
+ }
689
+ export async function summarizeLongTableInterview(options) {
690
+ const state = await loadResearchState(options.context.stateFilePath);
691
+ const existing = activeInterviewHook(state, options.hookId);
692
+ if (!existing) {
693
+ throw new Error("No active LongTable interview hook was found. Run begin_interview first.");
694
+ }
695
+ const timestamp = nowIso();
696
+ const shape = {
697
+ ...options.shape,
698
+ handle: options.shape.handle.trim(),
699
+ currentGoal: options.shape.currentGoal.trim(),
700
+ openQuestions: options.shape.openQuestions.map((question) => question.trim()).filter(Boolean),
701
+ nextAction: options.shape.nextAction.trim(),
702
+ sourceHookId: existing.id
703
+ };
704
+ const hook = {
705
+ ...existing,
706
+ status: "ready_to_confirm",
707
+ updatedAt: timestamp,
708
+ depth: "ready_to_summarize",
709
+ firstResearchShape: shape
710
+ };
711
+ const session = {
712
+ ...options.context.session,
713
+ lastUpdatedAt: timestamp,
714
+ currentGoal: shape.currentGoal,
715
+ ...(shape.currentBlocker ? { currentBlocker: shape.currentBlocker } : {}),
716
+ ...(shape.researchObject ? { researchObject: shape.researchObject } : {}),
717
+ ...(shape.gapRisk ? { gapRisk: shape.gapRisk } : {}),
718
+ ...(shape.protectedDecision ? { protectedDecision: shape.protectedDecision } : {}),
719
+ nextAction: shape.nextAction,
720
+ openQuestions: shape.openQuestions,
721
+ firstResearchShape: shape,
722
+ resumeHint: `I want to continue from the First Research Shape: ${shape.handle}.`
723
+ };
724
+ options.context.session = session;
725
+ let updated = upsertHook(state, hook);
726
+ updated.firstResearchShape = shape;
727
+ updated.workingState = {
728
+ ...updated.workingState,
729
+ currentGoal: shape.currentGoal,
730
+ ...(shape.currentBlocker ? { currentBlocker: shape.currentBlocker } : {}),
731
+ ...(shape.researchObject ? { researchObject: shape.researchObject } : {}),
732
+ ...(shape.gapRisk ? { gapRisk: shape.gapRisk } : {}),
733
+ ...(shape.protectedDecision ? { protectedDecision: shape.protectedDecision } : {}),
734
+ openQuestions: shape.openQuestions,
735
+ nextAction: shape.nextAction,
736
+ firstResearchShape: shape
737
+ };
738
+ if (shape.currentBlocker && !updated.openTensions.includes(shape.currentBlocker)) {
739
+ updated.openTensions.push(shape.currentBlocker);
740
+ }
741
+ updated.narrativeTraces.push({
742
+ id: createId("narrative_trace"),
743
+ timestamp,
744
+ source: "$longtable-interview",
745
+ traceType: "judgment",
746
+ summary: `First Research Shape: ${shape.handle}.`,
747
+ visibility: "explicit",
748
+ importance: shape.confidence
749
+ });
750
+ await writeFile(options.context.sessionFilePath, JSON.stringify(session, null, 2), "utf8");
751
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
752
+ await syncCurrentWorkspaceView(options.context);
753
+ return { hook, shape, state: updated, session };
754
+ }
480
755
  function findQuestionForDecision(state, questionId) {
481
756
  const pending = (state.questionLog ?? []).filter((record) => record.status === "pending");
482
757
  if (questionId) {
@@ -484,6 +759,9 @@ function findQuestionForDecision(state, questionId) {
484
759
  }
485
760
  return pending.at(-1) ?? null;
486
761
  }
762
+ function findPendingQuestionForClear(state, questionId) {
763
+ return (state.questionLog ?? []).find((record) => record.id === questionId && record.status === "pending") ?? null;
764
+ }
487
765
  function pendingRequiredQuestions(state) {
488
766
  return (state.questionLog ?? []).filter((record) => record.status === "pending" && record.prompt.required);
489
767
  }
@@ -491,19 +769,36 @@ export async function listBlockingWorkspaceQuestions(context) {
491
769
  const state = await loadResearchState(context.stateFilePath);
492
770
  return pendingRequiredQuestions(state);
493
771
  }
772
+ export async function listBlockingWorkspaceObligations(context) {
773
+ const state = await loadResearchState(context.stateFilePath);
774
+ return pendingQuestionObligations(state);
775
+ }
494
776
  export async function assertWorkspaceNotBlocked(context) {
495
- const blocking = await listBlockingWorkspaceQuestions(context);
496
- if (blocking.length === 0) {
497
- return;
498
- }
499
- const first = blocking[0];
500
- const options = formatQuestionOptionValues(first).join("/");
501
- throw new Error([
502
- `LongTable is blocked by a required Researcher Checkpoint: ${first.id}`,
503
- first.prompt.question,
504
- `Options: ${options}`,
505
- `Record an answer with: longtable decide --question ${first.id} --answer <value>`
506
- ].join("\n"));
777
+ const [blockingQuestions, blockingObligations] = await Promise.all([
778
+ listBlockingWorkspaceQuestions(context),
779
+ listBlockingWorkspaceObligations(context)
780
+ ]);
781
+ if (blockingQuestions.length > 0) {
782
+ const first = blockingQuestions[0];
783
+ const options = formatQuestionOptionValues(first).join("/");
784
+ throw new Error([
785
+ `LongTable is blocked by a required Researcher Checkpoint: ${first.id}`,
786
+ first.prompt.question,
787
+ `Options: ${options}`,
788
+ `Record an answer with: longtable decide --question ${first.id} --answer <value>`
789
+ ].join("\n"));
790
+ }
791
+ if (blockingObligations.length > 0) {
792
+ const first = blockingObligations[0];
793
+ throw new Error([
794
+ `LongTable is blocked by a pending research obligation: ${first.id}`,
795
+ first.prompt,
796
+ first.reason,
797
+ ...(first.questionId
798
+ ? [`If a question was already issued, answer it with: longtable decide --question ${first.questionId} --answer <value>`]
799
+ : ["Resume the LongTable interview and answer the next researcher-facing checkpoint before proceeding."])
800
+ ].join("\n"));
801
+ }
507
802
  }
508
803
  function questionTitleForCheckpoint(family, checkpointKey) {
509
804
  if (checkpointKey === "knowledge_gap_probe") {
@@ -847,9 +1142,10 @@ export async function createWorkspaceQuestion(options) {
847
1142
  }
848
1143
  };
849
1144
  const updated = appendQuestionRecords(state, [question]);
850
- await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
1145
+ const withObligation = ensureRequiredQuestionObligation(updated, question);
1146
+ await writeFile(options.context.stateFilePath, JSON.stringify(withObligation, null, 2), "utf8");
851
1147
  await syncCurrentWorkspaceView(options.context);
852
- return { question, state: updated };
1148
+ return { question, state: withObligation };
853
1149
  }
854
1150
  function updateInvocationWithDecision(invocation, questionId, decisionId) {
855
1151
  if (!invocation.panelResult?.linkedQuestionRecordIds.includes(questionId)) {
@@ -981,7 +1277,8 @@ export async function answerWorkspaceQuestion(options) {
981
1277
  questionLog: (state.questionLog ?? []).map((record) => record.id === question.id ? answeredQuestion : record),
982
1278
  invocationLog: (state.invocationLog ?? []).map((record) => updateInvocationWithDecision(record, question.id, decision.id))
983
1279
  };
984
- const updated = appendDecisionToResearchState(withQuestion, decision);
1280
+ const withDecision = appendDecisionToResearchState(withQuestion, decision);
1281
+ const updated = resolveQuestionObligationByQuestionId(withDecision, question.id, decision.id);
985
1282
  await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
986
1283
  await syncCurrentWorkspaceView(options.context);
987
1284
  return {
@@ -990,6 +1287,105 @@ export async function answerWorkspaceQuestion(options) {
990
1287
  state: updated
991
1288
  };
992
1289
  }
1290
+ export async function clearWorkspaceQuestion(options) {
1291
+ const state = await loadResearchState(options.context.stateFilePath);
1292
+ const question = findPendingQuestionForClear(state, options.questionId);
1293
+ if (!question) {
1294
+ throw new Error(`No pending LongTable question found for ${options.questionId}.`);
1295
+ }
1296
+ const timestamp = nowIso();
1297
+ const clearedQuestion = {
1298
+ ...question,
1299
+ updatedAt: timestamp,
1300
+ status: "cleared",
1301
+ clearedReason: options.reason.trim()
1302
+ };
1303
+ const withQuestion = {
1304
+ ...state,
1305
+ questionLog: (state.questionLog ?? []).map((record) => record.id === question.id ? clearedQuestion : record)
1306
+ };
1307
+ const updated = resolveQuestionObligationByQuestionId(withQuestion, question.id, undefined, "cleared");
1308
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
1309
+ await syncCurrentWorkspaceView(options.context);
1310
+ return {
1311
+ question: clearedQuestion,
1312
+ state: updated
1313
+ };
1314
+ }
1315
+ export async function repairWorkspaceStateConsistency(options) {
1316
+ const state = await loadResearchState(options.context.stateFilePath);
1317
+ const repaired = [];
1318
+ const hooks = state.hooks ?? [];
1319
+ const hookMatchedByHandle = state.firstResearchShape?.confirmedAt
1320
+ ? hooks.find((hook) => hook.kind === "longtable_interview" &&
1321
+ hook.firstResearchShape?.handle === state.firstResearchShape?.handle)
1322
+ : undefined;
1323
+ const confirmedShape = state.firstResearchShape?.confirmedAt
1324
+ ? {
1325
+ ...state.firstResearchShape,
1326
+ ...(state.firstResearchShape.sourceHookId
1327
+ ? {}
1328
+ : hookMatchedByHandle?.id
1329
+ ? { sourceHookId: hookMatchedByHandle.id }
1330
+ : {})
1331
+ }
1332
+ : undefined;
1333
+ let updated = state;
1334
+ if (confirmedShape && confirmedShape.sourceHookId && !state.firstResearchShape?.sourceHookId) {
1335
+ repaired.push(`restored sourceHookId ${confirmedShape.sourceHookId} on confirmed first research shape`);
1336
+ updated = {
1337
+ ...updated,
1338
+ firstResearchShape: confirmedShape,
1339
+ workingState: {
1340
+ ...updated.workingState,
1341
+ firstResearchShape: confirmedShape
1342
+ }
1343
+ };
1344
+ }
1345
+ if (confirmedShape?.sourceHookId) {
1346
+ const hooks = (updated.hooks ?? []).map((hook) => {
1347
+ if (hook.id === confirmedShape.sourceHookId &&
1348
+ hook.kind === "longtable_interview" &&
1349
+ hook.status !== "confirmed") {
1350
+ repaired.push(`confirmed interview hook ${hook.id}`);
1351
+ return {
1352
+ ...hook,
1353
+ status: "confirmed",
1354
+ updatedAt: nowIso(),
1355
+ firstResearchShape: confirmedShape
1356
+ };
1357
+ }
1358
+ return hook;
1359
+ });
1360
+ updated = {
1361
+ ...updated,
1362
+ hooks
1363
+ };
1364
+ }
1365
+ if (confirmedShape?.sourceHookId) {
1366
+ updated = {
1367
+ ...updated,
1368
+ questionObligations: (updated.questionObligations ?? []).map((obligation) => {
1369
+ if (obligation.kind === "first_research_shape_confirmation" &&
1370
+ obligation.status === "pending" &&
1371
+ obligation.sourceHookId === confirmedShape.sourceHookId) {
1372
+ repaired.push(`cleared first research shape obligation ${obligation.id}`);
1373
+ return {
1374
+ ...obligation,
1375
+ status: "satisfied",
1376
+ updatedAt: nowIso()
1377
+ };
1378
+ }
1379
+ return obligation;
1380
+ })
1381
+ };
1382
+ }
1383
+ if (repaired.length > 0) {
1384
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
1385
+ await syncCurrentWorkspaceView(options.context);
1386
+ }
1387
+ return { state: updated, repaired };
1388
+ }
993
1389
  export async function createOrUpdateProjectWorkspace(options) {
994
1390
  const projectPath = resolve(options.projectPath);
995
1391
  const metaDir = resolveMetaDir(projectPath);
@@ -1175,6 +1571,7 @@ export function renderProjectWorkspaceSummary(context) {
1175
1571
  `Goal: ${context.session.currentGoal}`,
1176
1572
  ...(context.session.currentBlocker ? [`Blocker: ${context.session.currentBlocker}`] : []),
1177
1573
  ...(context.session.researchObject ? [`Working object: ${context.session.researchObject}`] : []),
1574
+ ...(context.session.firstResearchShape ? [`First Research Shape: ${context.session.firstResearchShape.handle}`] : []),
1178
1575
  ...(context.session.startInterview ? [`Start interview: ${context.session.startInterview.summary}`] : []),
1179
1576
  "└───────────────────────────────────────────────────────┘",
1180
1577
  "",
@@ -29,18 +29,18 @@ function promptSpec() {
29
29
  argumentHint: "[project context or current uncertainty]",
30
30
  body: [
31
31
  "You are LongTable setup guidance inside Codex.",
32
- "Treat `longtable init` as deprecated. Prefer `longtable setup --provider codex` for runtime permissions, then `longtable start` for the project interview.",
32
+ "Treat `longtable init` as deprecated. Prefer `longtable setup --provider codex` for runtime permissions, then `$longtable-interview` inside Codex for the project interview.",
33
33
  "Ask exactly one setup question at a time.",
34
34
  "Use numbered choices and include a concise Why / What you get / Tradeoff for each option.",
35
35
  "Do not move to the next question until the researcher answers the current one.",
36
- "Runtime setup covers only: provider, install scope, runtime surfaces, intervention strength, and whether to create a workspace now.",
36
+ "Runtime setup covers only: provider, install scope, runtime surfaces, intervention strength, and whether to show interview launch steps.",
37
37
  "Do not ask for field, career stage, experience level, authorship signal, weakest domain, or panel preference during runtime setup.",
38
- "Project start covers: research object, gap/tacit risk, and protected decision area.",
38
+ "Project interview covers: the first research handle, early uncertainty, first inspectable material, and final structured confirmation.",
39
39
  "After collecting runtime answers, summarize the proposed setup and output the exact `longtable setup --provider codex ...` command.",
40
- "If the researcher is ready to create a project workspace, output the exact `longtable start ...` command separately.",
40
+ "If the researcher is ready to create a project workspace, tell them to open Codex in the research folder and invoke `$longtable-interview`.",
41
41
  "If the researcher asks you to stay inside Codex, keep the conversation in numbered form and do not prematurely close.",
42
42
  "Frame setup as permission and intervention calibration, not a researcher profile interview.",
43
- "Do not pretend setup is the full project-start interview. The project-start interview happens in `longtable start`.",
43
+ "Do not pretend setup is the full project-start interview. The project-start interview happens in `$longtable-interview`.",
44
44
  "Treat any slash-command arguments as context for why setup is being done now."
45
45
  ]
46
46
  },