@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.
- package/dist/cli.js +195 -9
- package/dist/codex-hooks.d.ts +22 -0
- package/dist/codex-hooks.js +240 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/longtable-codex-native-hook.d.ts +4 -0
- package/dist/longtable-codex-native-hook.js +329 -0
- package/dist/project-session.d.ts +24 -1
- package/dist/project-session.js +180 -17
- package/dist/question-obligations.d.ts +22 -0
- package/dist/question-obligations.js +112 -0
- package/package.json +7 -7
|
@@ -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;
|
package/dist/project-session.js
CHANGED
|
@@ -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
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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);
|