@longtable/cli 0.1.32 → 0.1.34

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.
@@ -0,0 +1,329 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { loadProjectContextFromDirectory, loadWorkspaceState, pendingQuestionObligations } from "./index.js";
3
+ function safeString(value) {
4
+ return typeof value === "string" ? value : "";
5
+ }
6
+ function safeInteger(value) {
7
+ if (typeof value === "number" && Number.isInteger(value)) {
8
+ return value;
9
+ }
10
+ if (typeof value === "string" && /^\d+$/.test(value.trim())) {
11
+ return Number.parseInt(value.trim(), 10);
12
+ }
13
+ return null;
14
+ }
15
+ function readHookEventName(payload) {
16
+ const candidate = safeString(payload.hook_event_name ??
17
+ payload.hookEventName ??
18
+ payload.event ??
19
+ payload.name).trim();
20
+ if (candidate === "SessionStart" ||
21
+ candidate === "PreToolUse" ||
22
+ candidate === "PostToolUse" ||
23
+ candidate === "UserPromptSubmit" ||
24
+ candidate === "Stop") {
25
+ return candidate;
26
+ }
27
+ return null;
28
+ }
29
+ function readPromptText(payload) {
30
+ return safeString(payload.prompt ?? payload.user_prompt ?? payload.userPrompt).trim();
31
+ }
32
+ function readToolName(payload) {
33
+ return safeString(payload.tool_name ?? payload.toolName).trim();
34
+ }
35
+ function readCommandText(payload) {
36
+ const direct = safeString(payload.command ?? payload.input ?? payload.tool_input ?? payload.toolInput).trim();
37
+ if (direct) {
38
+ return direct;
39
+ }
40
+ const toolInput = payload.tool_input ?? payload.toolInput;
41
+ if (toolInput && typeof toolInput === "object") {
42
+ const objectInput = toolInput;
43
+ return safeString(objectInput.command ?? objectInput.input ?? objectInput.cmd).trim();
44
+ }
45
+ return "";
46
+ }
47
+ function readExitCode(payload) {
48
+ return safeInteger(payload.exit_code ?? payload.exitCode);
49
+ }
50
+ function readCombinedOutput(payload) {
51
+ return [
52
+ safeString(payload.stderr),
53
+ safeString(payload.stdout),
54
+ safeString(payload.output)
55
+ ].filter(Boolean).join("\n").trim();
56
+ }
57
+ function formatQuestionOptions(question) {
58
+ const options = question.prompt.options.map((option) => option.value);
59
+ if (question.prompt.allowOther) {
60
+ options.push("other");
61
+ }
62
+ return options.join("/");
63
+ }
64
+ function pendingRequiredQuestions(state) {
65
+ return (state.questionLog ?? []).filter((question) => question.status === "pending" && question.prompt.required);
66
+ }
67
+ function pendingObligations(state) {
68
+ return pendingQuestionObligations(state);
69
+ }
70
+ function activeInterviewHook(state) {
71
+ return [...(state.hooks ?? [])].reverse().find((hook) => hook.kind === "longtable_interview" &&
72
+ (hook.status === "pending" || hook.status === "active" || hook.status === "ready_to_confirm"));
73
+ }
74
+ function compactContextValue(value, maxLength = 160) {
75
+ const compact = value.replace(/\s+/g, " ").trim();
76
+ if (compact.length <= maxLength) {
77
+ return compact;
78
+ }
79
+ return `${compact.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
80
+ }
81
+ function buildAdditionalContextOutput(hookEventName, additionalContext) {
82
+ return {
83
+ hookSpecificOutput: {
84
+ hookEventName,
85
+ additionalContext
86
+ }
87
+ };
88
+ }
89
+ function buildBlockOutput(hookEventName, reason, additionalContext) {
90
+ return {
91
+ hookSpecificOutput: {
92
+ hookEventName,
93
+ permissionDecision: "deny",
94
+ permissionDecisionReason: reason,
95
+ additionalContext
96
+ }
97
+ };
98
+ }
99
+ function buildStopBlockOutput(reason) {
100
+ return {
101
+ decision: "block",
102
+ reason
103
+ };
104
+ }
105
+ function looksLikeClosurePrompt(prompt) {
106
+ const normalized = prompt.trim();
107
+ if (!normalized) {
108
+ return false;
109
+ }
110
+ return /\b(final|finalize|commit|ship|submit|revise|rewrite|draft|publish|implement|fix)\b/i.test(normalized)
111
+ || /최종|확정|커밋|제출|수정|초안|구현|진행|고쳐/.test(normalized);
112
+ }
113
+ function isStateChangingBash(command) {
114
+ const normalized = command.trim();
115
+ if (!normalized) {
116
+ return false;
117
+ }
118
+ return /\b(git\s+commit|npm\s+version|mv|cp|rm|sed\s+-i|perl\s+-i|tee|touch|mkdir|rmdir|apply_patch|patch)\b/.test(normalized)
119
+ || />\s*\S+/.test(normalized);
120
+ }
121
+ async function loadLongTableRuntime(startPath) {
122
+ const context = await loadProjectContextFromDirectory(startPath);
123
+ if (!context) {
124
+ return null;
125
+ }
126
+ const state = await loadWorkspaceState(context);
127
+ return { context, state };
128
+ }
129
+ function buildWorkspaceSummary(runtime, detail = "compact") {
130
+ const { context, state } = runtime;
131
+ if (detail === "compact") {
132
+ const primaryContext = state.firstResearchShape
133
+ ? `First research shape: ${compactContextValue(state.firstResearchShape.handle, 96)}.`
134
+ : `Current goal: ${compactContextValue(context.session.currentGoal, 120)}.`;
135
+ return [
136
+ "LongTable workspace detected; research context restored.",
137
+ primaryContext,
138
+ context.session.nextAction ? `Next action: ${compactContextValue(context.session.nextAction)}.` : "",
139
+ context.session.protectedDecision ? "Protected decision: active; full text is in `.longtable/` and `CURRENT.md`." : ""
140
+ ].filter(Boolean);
141
+ }
142
+ const lines = [
143
+ "LongTable workspace detected.",
144
+ `Current goal: ${context.session.currentGoal}.`,
145
+ context.session.currentBlocker ? `Current blocker: ${context.session.currentBlocker}.` : "",
146
+ context.session.protectedDecision ? `Protected decision: ${context.session.protectedDecision}.` : "",
147
+ state.firstResearchShape ? `First research shape: ${state.firstResearchShape.handle}.` : "",
148
+ context.session.nextAction ? `Next action: ${context.session.nextAction}.` : ""
149
+ ].filter(Boolean);
150
+ return lines;
151
+ }
152
+ function buildPendingQuestionContext(question) {
153
+ return [
154
+ `Required Researcher Checkpoint is still pending: ${question.prompt.question}`,
155
+ `Options: ${formatQuestionOptions(question)}`,
156
+ `Record it with longtable decide --question ${question.id} --answer <value> if you are outside MCP elicitation.`
157
+ ].join("\n");
158
+ }
159
+ function buildPendingObligationContext(obligation) {
160
+ return [
161
+ `Pending LongTable research obligation: ${obligation.prompt}`,
162
+ obligation.reason,
163
+ obligation.questionId
164
+ ? `This obligation is linked to question ${obligation.questionId}; answer it before treating the direction as settled.`
165
+ : "Resume the LongTable interview and let it ask the next research-facing checkpoint before settling the direction."
166
+ ].join("\n");
167
+ }
168
+ function buildActiveInterviewContext(hook) {
169
+ const turnCount = hook.turns?.length ?? 0;
170
+ return [
171
+ "A LongTable interview is currently active.",
172
+ `Interview status: ${hook.status}.`,
173
+ `Turns recorded: ${turnCount}.`,
174
+ "Do not finalize the research direction until the interview is either summarized into a First Research Shape or explicitly cleared."
175
+ ].join("\n");
176
+ }
177
+ function sessionStartContext(runtime) {
178
+ const blockingQuestion = pendingRequiredQuestions(runtime.state)[0];
179
+ const blockingObligation = pendingObligations(runtime.state)[0];
180
+ const interview = activeInterviewHook(runtime.state);
181
+ const needsDetailedSummary = Boolean(blockingQuestion || blockingObligation);
182
+ const sections = [buildWorkspaceSummary(runtime, needsDetailedSummary ? "full" : "compact").join("\n")];
183
+ if (blockingQuestion) {
184
+ sections.push(buildPendingQuestionContext(blockingQuestion));
185
+ }
186
+ else if (blockingObligation) {
187
+ sections.push(buildPendingObligationContext(blockingObligation));
188
+ }
189
+ else if (interview) {
190
+ sections.push(buildActiveInterviewContext(interview));
191
+ }
192
+ sections.push("Treat `.longtable/` state and `CURRENT.md` as the source of truth for this workspace.");
193
+ return sections.filter(Boolean).join("\n\n");
194
+ }
195
+ function userPromptSubmitContext(runtime, prompt) {
196
+ const blockingQuestion = pendingRequiredQuestions(runtime.state)[0];
197
+ if (blockingQuestion) {
198
+ return buildPendingQuestionContext(blockingQuestion);
199
+ }
200
+ const blockingObligation = pendingObligations(runtime.state)[0];
201
+ if (blockingObligation) {
202
+ return buildPendingObligationContext(blockingObligation);
203
+ }
204
+ const interview = activeInterviewHook(runtime.state);
205
+ if (interview) {
206
+ return buildActiveInterviewContext(interview);
207
+ }
208
+ if (runtime.context.session.protectedDecision && looksLikeClosurePrompt(prompt)) {
209
+ return [
210
+ `This workspace marks ${runtime.context.session.protectedDecision} as a protected decision.`,
211
+ "Before you settle it through drafting, revision, or closure, surface one researcher-facing checkpoint grounded in the current blocker or open questions."
212
+ ].join("\n");
213
+ }
214
+ return null;
215
+ }
216
+ function preToolUseOutput(runtime, payload) {
217
+ if (readToolName(payload) !== "Bash") {
218
+ return null;
219
+ }
220
+ const command = readCommandText(payload);
221
+ if (!isStateChangingBash(command)) {
222
+ return null;
223
+ }
224
+ const blockingQuestion = pendingRequiredQuestions(runtime.state)[0];
225
+ if (blockingQuestion) {
226
+ return buildBlockOutput("PreToolUse", "A required LongTable checkpoint is still pending before a state-changing Bash command.", buildPendingQuestionContext(blockingQuestion));
227
+ }
228
+ const blockingObligation = pendingObligations(runtime.state)[0];
229
+ if (blockingObligation) {
230
+ return buildBlockOutput("PreToolUse", "A LongTable research obligation is still pending before a state-changing Bash command.", buildPendingObligationContext(blockingObligation));
231
+ }
232
+ return null;
233
+ }
234
+ function postToolUseOutput(runtime, payload) {
235
+ if (readToolName(payload) !== "Bash") {
236
+ return null;
237
+ }
238
+ const command = readCommandText(payload);
239
+ const exitCode = readExitCode(payload);
240
+ const output = readCombinedOutput(payload);
241
+ const blockingQuestion = pendingRequiredQuestions(runtime.state)[0];
242
+ const blockingObligation = pendingObligations(runtime.state)[0];
243
+ if ((blockingQuestion || blockingObligation) && isStateChangingBash(command)) {
244
+ return buildBlockOutput("PostToolUse", "A state-changing Bash command completed while LongTable still had an unresolved checkpoint or obligation.", blockingQuestion
245
+ ? buildPendingQuestionContext(blockingQuestion)
246
+ : buildPendingObligationContext(blockingObligation));
247
+ }
248
+ if (exitCode !== null && exitCode !== 0 && output) {
249
+ return buildBlockOutput("PostToolUse", "The Bash command returned a non-zero exit code and should be reviewed before LongTable continues.", "Review the command output and explain what failed before retrying or continuing.");
250
+ }
251
+ return null;
252
+ }
253
+ function stopOutput(runtime) {
254
+ const blockingQuestion = pendingRequiredQuestions(runtime.state)[0];
255
+ if (blockingQuestion) {
256
+ return buildStopBlockOutput("A required LongTable Researcher Checkpoint is still pending.");
257
+ }
258
+ const blockingObligation = pendingObligations(runtime.state)[0];
259
+ if (blockingObligation) {
260
+ return buildStopBlockOutput("A LongTable research obligation is still pending.");
261
+ }
262
+ return null;
263
+ }
264
+ export async function dispatchCodexHook(payload, cwdOverride) {
265
+ const hookEventName = readHookEventName(payload);
266
+ if (!hookEventName) {
267
+ return null;
268
+ }
269
+ const runtime = await loadLongTableRuntime(cwdOverride ?? process.cwd());
270
+ if (!runtime) {
271
+ return null;
272
+ }
273
+ if (hookEventName === "SessionStart") {
274
+ return buildAdditionalContextOutput(hookEventName, sessionStartContext(runtime));
275
+ }
276
+ if (hookEventName === "UserPromptSubmit") {
277
+ const additionalContext = userPromptSubmitContext(runtime, readPromptText(payload));
278
+ return additionalContext
279
+ ? buildAdditionalContextOutput(hookEventName, additionalContext)
280
+ : null;
281
+ }
282
+ if (hookEventName === "PreToolUse") {
283
+ return preToolUseOutput(runtime, payload);
284
+ }
285
+ if (hookEventName === "PostToolUse") {
286
+ return postToolUseOutput(runtime, payload);
287
+ }
288
+ if (hookEventName === "Stop") {
289
+ return stopOutput(runtime);
290
+ }
291
+ return null;
292
+ }
293
+ async function readStdinJson() {
294
+ const chunks = [];
295
+ for await (const chunk of process.stdin) {
296
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
297
+ }
298
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
299
+ if (!raw) {
300
+ return {};
301
+ }
302
+ try {
303
+ const parsed = JSON.parse(raw);
304
+ return parsed && typeof parsed === "object" ? parsed : {};
305
+ }
306
+ catch {
307
+ return {};
308
+ }
309
+ }
310
+ export function isCodexNativeHookMainModule(moduleUrl, argv1) {
311
+ if (!argv1) {
312
+ return false;
313
+ }
314
+ return moduleUrl === pathToFileURL(argv1).href;
315
+ }
316
+ async function main() {
317
+ const payload = await readStdinJson();
318
+ const output = await dispatchCodexHook(payload);
319
+ if (output) {
320
+ process.stdout.write(JSON.stringify(output));
321
+ }
322
+ }
323
+ if (isCodexNativeHookMainModule(import.meta.url, process.argv[1])) {
324
+ main().catch((error) => {
325
+ const message = error instanceof Error ? error.message : String(error);
326
+ process.stderr.write(`${message}\n`);
327
+ process.exit(1);
328
+ });
329
+ }
@@ -1,4 +1,4 @@
1
- import type { DecisionRecord, InvocationRecord, ProviderKind, QuestionOption, QuestionSurface, QuestionRecord, ResearchState } from "@longtable/core";
1
+ import type { DecisionRecord, InvocationRecord, LongTableQuestionObligation, ProviderKind, QuestionOption, QuestionSurface, QuestionRecord, ResearchState } from "@longtable/core";
2
2
  import type { SetupPersistedOutput } from "@longtable/setup";
3
3
  export type ProjectDisagreementPreference = "synthesis_only" | "show_on_conflict" | "always_visible";
4
4
  export type StartInterviewSignal = "phenomenon" | "audience" | "artifact" | "evidence" | "assumption" | "decision_risk" | "voice";
@@ -141,6 +141,7 @@ export interface LongTableWorkspaceInspection {
141
141
  invocations: number;
142
142
  questions: number;
143
143
  pendingQuestions: number;
144
+ pendingObligations: number;
144
145
  answeredQuestions: number;
145
146
  decisions: number;
146
147
  };
@@ -161,6 +162,13 @@ export interface LongTableWorkspaceInspection {
161
162
  options: string[];
162
163
  required: boolean;
163
164
  }>;
165
+ pendingObligations?: Array<{
166
+ id: string;
167
+ kind: string;
168
+ prompt: string;
169
+ reason: string;
170
+ questionId?: string;
171
+ }>;
164
172
  recentDecisions?: Array<{
165
173
  id: string;
166
174
  checkpointKey: string;
@@ -213,6 +221,7 @@ export declare function summarizeLongTableInterview(options: {
213
221
  session: LongTableSessionRecord;
214
222
  }>;
215
223
  export declare function listBlockingWorkspaceQuestions(context: LongTableProjectContext): Promise<QuestionRecord[]>;
224
+ export declare function listBlockingWorkspaceObligations(context: LongTableProjectContext): Promise<LongTableQuestionObligation[]>;
216
225
  export declare function assertWorkspaceNotBlocked(context: LongTableProjectContext): Promise<void>;
217
226
  export declare function createWorkspaceFollowUpQuestions(options: {
218
227
  context: LongTableProjectContext;
@@ -252,6 +261,20 @@ export declare function answerWorkspaceQuestion(options: {
252
261
  decision: DecisionRecord;
253
262
  state: ResearchState;
254
263
  }>;
264
+ export declare function clearWorkspaceQuestion(options: {
265
+ context: LongTableProjectContext;
266
+ questionId: string;
267
+ reason: string;
268
+ }): Promise<{
269
+ question: QuestionRecord;
270
+ state: ResearchState;
271
+ }>;
272
+ export declare function repairWorkspaceStateConsistency(options: {
273
+ context: LongTableProjectContext;
274
+ }): Promise<{
275
+ state: ResearchState;
276
+ repaired: string[];
277
+ }>;
255
278
  export declare function createOrUpdateProjectWorkspace(options: {
256
279
  projectName: string;
257
280
  projectPath: string;
@@ -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() {
@@ -105,7 +106,7 @@ function buildResumeHint(session) {
105
106
  ? `I want to continue ${session.currentGoal}. The unresolved blocker is ${session.currentBlocker}.`
106
107
  : `I want to continue ${session.currentGoal}.`;
107
108
  }
108
- function buildCurrentGuide(project, session, recentInvocations = [], pendingQuestions = []) {
109
+ function buildCurrentGuide(project, session, recentInvocations = [], pendingQuestions = [], pendingObligations = []) {
109
110
  const locale = normalizeLocale(session.locale ?? project.locale);
110
111
  const openQuestions = session.openQuestions && session.openQuestions.length > 0
111
112
  ? session.openQuestions
@@ -156,6 +157,16 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
156
157
  "- 답변 기록: `longtable decide --question <id> --answer <value>`"
157
158
  ]
158
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
+ : []),
159
170
  "",
160
171
  "## 다시 시작 문장",
161
172
  `- "${resumeHint}"`,
@@ -219,6 +230,16 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
219
230
  "- Record an answer: `longtable decide --question <id> --answer <value>`"
220
231
  ]
221
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
+ : []),
222
243
  "",
223
244
  "## Restart Prompt",
224
245
  `- "${resumeHint}"`,
@@ -251,6 +272,7 @@ async function loadResearchState(stateFilePath) {
251
272
  workingState: parsed.workingState ?? {},
252
273
  hooks: parsed.hooks ?? [],
253
274
  ...(parsed.firstResearchShape ? { firstResearchShape: parsed.firstResearchShape } : {}),
275
+ questionObligations: parsed.questionObligations ?? [],
254
276
  inferredHypotheses: parsed.inferredHypotheses ?? [],
255
277
  openTensions: parsed.openTensions ?? [],
256
278
  decisionLog: parsed.decisionLog ?? [],
@@ -273,6 +295,17 @@ function recentPendingQuestions(state, limit = 3) {
273
295
  .slice(-limit)
274
296
  .reverse();
275
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
+ }
276
309
  function formatQuestionOptionValues(record) {
277
310
  const values = record.prompt.options.map((option) => option.value);
278
311
  if (record.prompt.allowOther) {
@@ -284,6 +317,7 @@ function summarizeWorkspaceInspection(context, state) {
284
317
  const questions = state.questionLog ?? [];
285
318
  const pendingQuestions = questions.filter((record) => record.status === "pending");
286
319
  const answeredQuestions = questions.filter((record) => record.status === "answered");
320
+ const pendingObligations = visiblePendingObligations(state);
287
321
  return {
288
322
  found: true,
289
323
  rootPath: context.project.projectPath,
@@ -310,6 +344,7 @@ function summarizeWorkspaceInspection(context, state) {
310
344
  invocations: (state.invocationLog ?? []).length,
311
345
  questions: questions.length,
312
346
  pendingQuestions: pendingQuestions.length,
347
+ pendingObligations: pendingObligations.length,
313
348
  answeredQuestions: answeredQuestions.length,
314
349
  decisions: (state.decisionLog ?? []).length
315
350
  },
@@ -330,6 +365,13 @@ function summarizeWorkspaceInspection(context, state) {
330
365
  options: formatQuestionOptionValues(record),
331
366
  required: record.prompt.required
332
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
+ })),
333
375
  recentDecisions: (state.decisionLog ?? []).slice(-5).reverse().map((record) => ({
334
376
  id: record.id,
335
377
  checkpointKey: record.checkpointKey,
@@ -499,7 +541,7 @@ async function removeLegacyRootFiles(projectPath) {
499
541
  }
500
542
  export async function syncCurrentWorkspaceView(context) {
501
543
  const state = await loadResearchState(context.stateFilePath);
502
- 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));
503
545
  await writeFile(context.currentFilePath, body, "utf8");
504
546
  return context.currentFilePath;
505
547
  }
@@ -717,6 +759,9 @@ function findQuestionForDecision(state, questionId) {
717
759
  }
718
760
  return pending.at(-1) ?? null;
719
761
  }
762
+ function findPendingQuestionForClear(state, questionId) {
763
+ return (state.questionLog ?? []).find((record) => record.id === questionId && record.status === "pending") ?? null;
764
+ }
720
765
  function pendingRequiredQuestions(state) {
721
766
  return (state.questionLog ?? []).filter((record) => record.status === "pending" && record.prompt.required);
722
767
  }
@@ -724,19 +769,36 @@ export async function listBlockingWorkspaceQuestions(context) {
724
769
  const state = await loadResearchState(context.stateFilePath);
725
770
  return pendingRequiredQuestions(state);
726
771
  }
772
+ export async function listBlockingWorkspaceObligations(context) {
773
+ const state = await loadResearchState(context.stateFilePath);
774
+ return pendingQuestionObligations(state);
775
+ }
727
776
  export async function assertWorkspaceNotBlocked(context) {
728
- const blocking = await listBlockingWorkspaceQuestions(context);
729
- if (blocking.length === 0) {
730
- return;
731
- }
732
- const first = blocking[0];
733
- const options = formatQuestionOptionValues(first).join("/");
734
- throw new Error([
735
- `LongTable is blocked by a required Researcher Checkpoint: ${first.id}`,
736
- first.prompt.question,
737
- `Options: ${options}`,
738
- `Record an answer with: longtable decide --question ${first.id} --answer <value>`
739
- ].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
+ }
740
802
  }
741
803
  function questionTitleForCheckpoint(family, checkpointKey) {
742
804
  if (checkpointKey === "knowledge_gap_probe") {
@@ -1080,9 +1142,10 @@ export async function createWorkspaceQuestion(options) {
1080
1142
  }
1081
1143
  };
1082
1144
  const updated = appendQuestionRecords(state, [question]);
1083
- 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");
1084
1147
  await syncCurrentWorkspaceView(options.context);
1085
- return { question, state: updated };
1148
+ return { question, state: withObligation };
1086
1149
  }
1087
1150
  function updateInvocationWithDecision(invocation, questionId, decisionId) {
1088
1151
  if (!invocation.panelResult?.linkedQuestionRecordIds.includes(questionId)) {
@@ -1214,7 +1277,8 @@ export async function answerWorkspaceQuestion(options) {
1214
1277
  questionLog: (state.questionLog ?? []).map((record) => record.id === question.id ? answeredQuestion : record),
1215
1278
  invocationLog: (state.invocationLog ?? []).map((record) => updateInvocationWithDecision(record, question.id, decision.id))
1216
1279
  };
1217
- const updated = appendDecisionToResearchState(withQuestion, decision);
1280
+ const withDecision = appendDecisionToResearchState(withQuestion, decision);
1281
+ const updated = resolveQuestionObligationByQuestionId(withDecision, question.id, decision.id);
1218
1282
  await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
1219
1283
  await syncCurrentWorkspaceView(options.context);
1220
1284
  return {
@@ -1223,6 +1287,105 @@ export async function answerWorkspaceQuestion(options) {
1223
1287
  state: updated
1224
1288
  };
1225
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
+ }
1226
1389
  export async function createOrUpdateProjectWorkspace(options) {
1227
1390
  const projectPath = resolve(options.projectPath);
1228
1391
  const metaDir = resolveMetaDir(projectPath);