@longtable/cli 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,11 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
+ import { execSync } from "node:child_process";
3
4
  import { dirname, join, resolve } from "node:path";
4
- import { createEmptyResearchState } from "@diverga/memory";
5
+ import { appendDecisionRecord as appendDecisionToResearchState, appendInvocationRecord as appendInvocationToResearchState, appendQuestionRecords, createEmptyResearchState } from "@longtable/memory";
6
+ import { classifyCheckpointTrigger } from "@longtable/checkpoints";
7
+ const CURRENT_FILE_NAME = "CURRENT.md";
8
+ const LEGACY_ROOT_FILES = ["LONGTABLE.md", "START-HERE.md", "NEXT-STEPS.md", "SESSION-SNAPSHOT.md"];
5
9
  function nowIso() {
6
10
  return new Date().toISOString();
7
11
  }
@@ -16,160 +20,303 @@ function slugify(input) {
16
20
  function resolveMetaDir(projectPath) {
17
21
  return join(projectPath, ".longtable");
18
22
  }
19
- function buildWorkspaceGuide(project, session) {
20
- const lines = [
21
- "# Long Table Workspace",
22
- "",
23
- "This directory is a Long Table research workspace.",
24
- "",
25
- "## Current project",
26
- `- Project: ${project.projectName}`,
27
- `- Goal right now: ${session.currentGoal}`,
28
- ...(session.currentBlocker ? [`- Current blocker: ${session.currentBlocker}`] : []),
29
- `- Disagreement visibility: ${session.disagreementPreference}`,
30
- `- Requested perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
31
- "",
32
- "## How Codex should behave here",
33
- "- Treat this as a researcher-facing Long Table session, not a generic coding task.",
34
- "- Ask clarifying or tension questions before closing too early.",
35
- "- If you foreground specific roles, disclose them with `Long Table consulted: ...`.",
36
- "- Keep one accountable synthesis, but keep disagreement visible by default when it matters.",
37
- "- For factual, current, or external claims, attach source links or local file references whenever possible.",
38
- "- If you cannot source a statement, label it as an inference or estimate.",
39
- "- Do not expose internal tool logs or process commentary in researcher-facing answers.",
40
- "",
41
- "## Session files",
42
- "- `.longtable/project.json` contains project-level metadata.",
43
- "- `.longtable/current-session.json` contains the current session goal and blocker."
44
- ];
45
- return lines.join("\n");
23
+ function resolveStateFilePath(projectPath) {
24
+ return join(resolveMetaDir(projectPath), "state.json");
46
25
  }
47
- function buildStartHereGuide(project, session) {
48
- const lines = [
49
- "# Start Here",
50
- "",
51
- `Project: ${project.projectName}`,
52
- "",
53
- "This workspace was created by Long Table for a single research project.",
54
- "",
55
- "## What to do now",
56
- "1. Open Codex in this directory.",
57
- "2. Start with your current goal, not a shell command.",
58
- "3. Let Long Table keep disagreement visible when it matters.",
59
- "4. If you ask for factual or current external information, expect sources or an explicit inference label.",
60
- "",
61
- "## Suggested first message",
62
- session.currentBlocker
63
- ? `"I want to work on ${session.currentGoal}. My current blocker is: ${session.currentBlocker}."`
64
- : `"I want to work on ${session.currentGoal}."`,
65
- "",
66
- "## Explicit Long Table invocation inside Codex",
67
- "- `lt explore: <question>`",
68
- "- `lt review: <claim or plan>`",
69
- "- `lt panel: <claim or plan>`",
70
- "- `lt editor: <draft or positioning question>`",
71
- "- `lt reviewer: <claim or section>`",
72
- "- `lt methods: <design, measure, or analysis plan>`",
73
- "- `lt commit: <decision that needs commitment>`",
74
- "",
75
- "## What Long Table already knows in this directory",
76
- `- Current goal: ${session.currentGoal}`,
77
- ...(session.currentBlocker ? [`- Current blocker: ${session.currentBlocker}`] : []),
78
- `- Requested perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
79
- `- Disagreement visibility: ${session.disagreementPreference}`,
80
- "",
81
- "## Files",
82
- "- `AGENTS.md` tells Codex how to behave in this workspace.",
83
- "- `.longtable/current-session.json` stores the current session goal and blocker.",
84
- "- `.longtable/project.json` stores the project-level record.",
85
- "- `NEXT-STEPS.md` stores the immediate next actions.",
86
- "- `SESSION-SNAPSHOT.md` stores the short resume snapshot."
87
- ];
88
- return lines.join("\n");
26
+ function resolveCurrentFilePath(projectPath) {
27
+ return join(projectPath, CURRENT_FILE_NAME);
28
+ }
29
+ function normalizeLocale(locale) {
30
+ if ((locale ?? "").toLowerCase().startsWith("ko")) {
31
+ return "ko";
32
+ }
33
+ return "en";
34
+ }
35
+ function resolveUserLocale() {
36
+ const envLocale = process.env.LC_ALL ??
37
+ process.env.LC_MESSAGES ??
38
+ process.env.LANG ??
39
+ "";
40
+ if (envLocale && !["c", "c.utf-8", "posix"].includes(envLocale.toLowerCase())) {
41
+ return normalizeLocale(envLocale);
42
+ }
43
+ if (process.platform === "darwin") {
44
+ try {
45
+ const appleLocale = execSync("defaults read -g AppleLocale", { encoding: "utf8" }).trim();
46
+ if (appleLocale) {
47
+ return normalizeLocale(appleLocale);
48
+ }
49
+ }
50
+ catch {
51
+ // Ignore and fall back to Intl below.
52
+ }
53
+ }
54
+ return normalizeLocale(Intl.DateTimeFormat().resolvedOptions().locale);
89
55
  }
90
- function buildNextStepsGuide(project, session) {
91
- const firstQuestion = session.currentBlocker
56
+ function buildFirstQuestion(session) {
57
+ return session.currentBlocker
92
58
  ? `What would reduce the uncertainty around "${session.currentBlocker}" first?`
93
59
  : `What is the first concrete question that would move "${session.currentGoal}" forward?`;
60
+ }
61
+ function buildOpenQuestions(session) {
62
+ const firstQuestion = buildFirstQuestion(session);
63
+ return session.currentBlocker
64
+ ? [
65
+ firstQuestion,
66
+ `What evidence would let you decide whether "${session.currentBlocker}" is a knowledge gap, a coding rule gap, or a data gap?`
67
+ ]
68
+ : [
69
+ firstQuestion,
70
+ `What would count as a good first outcome for "${session.currentGoal}" in this session?`
71
+ ];
72
+ }
73
+ function buildNextAction(session) {
74
+ return session.currentBlocker
75
+ ? `Open with the blocker, then ask LongTable to surface the first high-leverage uncertainty around "${session.currentBlocker}".`
76
+ : "Open with your current goal in one sentence, then ask LongTable for the first concrete research move.";
77
+ }
78
+ function buildResumeHint(session) {
79
+ return session.currentBlocker
80
+ ? `I want to continue ${session.currentGoal}. The unresolved blocker is ${session.currentBlocker}.`
81
+ : `I want to continue ${session.currentGoal}.`;
82
+ }
83
+ function buildCurrentGuide(project, session, recentInvocations = [], pendingQuestions = []) {
84
+ const locale = normalizeLocale(session.locale ?? project.locale);
85
+ const openQuestions = session.openQuestions && session.openQuestions.length > 0
86
+ ? session.openQuestions
87
+ : buildOpenQuestions(session);
88
+ const nextAction = session.nextAction ?? buildNextAction(session);
89
+ const resumeHint = session.resumeHint ?? buildResumeHint(session);
90
+ const suggestedPrompt = `lt explore: ${openQuestions[0]}`;
91
+ if (locale === "ko") {
92
+ return [
93
+ "# CURRENT",
94
+ "",
95
+ `Project: ${project.projectName}`,
96
+ "",
97
+ "이 파일은 `.longtable/current-session.json`과 `.longtable/state.json`에서 재생성되는 현재 작업 뷰입니다.",
98
+ "",
99
+ "## 지금 초점",
100
+ `- 현재 목표: ${session.currentGoal}`,
101
+ ...(session.currentBlocker ? [`- 현재 blocker: ${session.currentBlocker}`] : []),
102
+ `- 다음 액션: ${nextAction}`,
103
+ `- 관점: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
104
+ `- disagreement: ${session.disagreementPreference}`,
105
+ "",
106
+ "## 열린 질문",
107
+ ...openQuestions.map((question) => `- ${question}`),
108
+ ...(recentInvocations.length > 0
109
+ ? [
110
+ "",
111
+ "## 최근 LongTable 호출",
112
+ ...recentInvocations.map((record) => {
113
+ const roles = record.intent.roles.length > 0 ? record.intent.roles.join(", ") : "auto";
114
+ return `- ${record.intent.kind}/${record.intent.mode} via ${record.surface}: ${roles}`;
115
+ })
116
+ ]
117
+ : []),
118
+ ...(pendingQuestions.length > 0
119
+ ? [
120
+ "",
121
+ "## 대기 중인 결정 질문",
122
+ ...pendingQuestions.map((record) => {
123
+ const options = formatQuestionOptionValues(record).join("/");
124
+ return `- ${record.id}: ${record.prompt.question} (${options})`;
125
+ }),
126
+ "- 답변 기록: `longtable decide --question <id> --answer <value>`"
127
+ ]
128
+ : []),
129
+ "",
130
+ "## 다시 시작 문장",
131
+ `- "${resumeHint}"`,
132
+ "",
133
+ "## 빠른 시작",
134
+ "- 이 디렉토리에서 `codex`를 엽니다.",
135
+ `- 첫 메시지는 보통 \`${suggestedPrompt}\` 정도면 충분합니다.`,
136
+ "",
137
+ "## 증거 규칙",
138
+ "- 외부 사실이나 현재 정보는 source를 붙이거나 inference로 낮춥니다."
139
+ ].join("\n");
140
+ }
94
141
  return [
95
- "# Next Steps",
142
+ "# CURRENT",
96
143
  "",
97
144
  `Project: ${project.projectName}`,
98
145
  "",
99
- "## Immediate actions",
100
- "1. Open Codex in this directory.",
101
- "2. State your current goal in one sentence.",
102
- "3. Ask Long Table to surface disagreement before closing on a plan.",
103
- "",
104
- "## Suggested openings",
105
- `- ${session.currentBlocker ? `"I want to work on ${session.currentGoal}. My current blocker is ${session.currentBlocker}."` : `"I want to work on ${session.currentGoal}."`}`,
106
- `- "lt explore: ${firstQuestion}"`,
107
- `- "lt panel: Show me the strongest disagreement before I commit."`,
146
+ "This file is regenerated from `.longtable/current-session.json` and `.longtable/state.json`.",
108
147
  "",
109
- "## If you come back later",
110
- "- Read `SESSION-SNAPSHOT.md` first.",
111
- "- Continue from the unresolved blocker rather than restating the whole project.",
148
+ "## Focus Now",
149
+ `- Current goal: ${session.currentGoal}`,
150
+ ...(session.currentBlocker ? [`- Current blocker: ${session.currentBlocker}`] : []),
151
+ `- Next action: ${nextAction}`,
152
+ `- Perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
153
+ `- Disagreement: ${session.disagreementPreference}`,
112
154
  "",
113
- "## Evidence reminder",
114
- "- For factual or current external claims, Long Table should provide a source link or label the statement as inference."
115
- ].join("\n");
116
- }
117
- function buildSessionSnapshot(project, session) {
118
- return [
119
- "# Session Snapshot",
155
+ "## Open Questions",
156
+ ...openQuestions.map((question) => `- ${question}`),
157
+ ...(recentInvocations.length > 0
158
+ ? [
159
+ "",
160
+ "## Recent LongTable Invocations",
161
+ ...recentInvocations.map((record) => {
162
+ const roles = record.intent.roles.length > 0 ? record.intent.roles.join(", ") : "auto";
163
+ return `- ${record.intent.kind}/${record.intent.mode} via ${record.surface}: ${roles}`;
164
+ })
165
+ ]
166
+ : []),
167
+ ...(pendingQuestions.length > 0
168
+ ? [
169
+ "",
170
+ "## Pending Decision Questions",
171
+ ...pendingQuestions.map((record) => {
172
+ const options = formatQuestionOptionValues(record).join("/");
173
+ return `- ${record.id}: ${record.prompt.question} (${options})`;
174
+ }),
175
+ "- Record an answer: `longtable decide --question <id> --answer <value>`"
176
+ ]
177
+ : []),
120
178
  "",
121
- `Project: ${project.projectName}`,
122
- `Current goal: ${session.currentGoal}`,
123
- ...(session.currentBlocker ? [`Current blocker: ${session.currentBlocker}`] : []),
124
- `Requested perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
125
- `Disagreement visibility: ${session.disagreementPreference}`,
179
+ "## Restart Prompt",
180
+ `- "${resumeHint}"`,
126
181
  "",
127
- "## Resume prompt",
128
- session.currentBlocker
129
- ? `"I want to continue ${session.currentGoal}. The unresolved blocker is ${session.currentBlocker}."`
130
- : `"I want to continue ${session.currentGoal}."`,
182
+ "## Quick Start",
183
+ "- Open `codex` in this directory.",
184
+ `- A good first message is usually \`${suggestedPrompt}\`.`,
131
185
  "",
132
- "## Notes",
133
- "- This is a short orientation artifact, not a full transcript.",
134
- "- If factual or external claims matter, Long Table should cite sources or mark them as inference."
186
+ "## Evidence Rule",
187
+ "- External or current claims should carry a source link or be labeled as inference."
135
188
  ].join("\n");
136
189
  }
190
+ async function loadResearchState(stateFilePath) {
191
+ if (!existsSync(stateFilePath)) {
192
+ return createEmptyResearchState();
193
+ }
194
+ const parsed = JSON.parse(await readFile(stateFilePath, "utf8"));
195
+ return {
196
+ explicitState: parsed.explicitState ?? {},
197
+ workingState: parsed.workingState ?? {},
198
+ inferredHypotheses: parsed.inferredHypotheses ?? [],
199
+ openTensions: parsed.openTensions ?? [],
200
+ decisionLog: parsed.decisionLog ?? [],
201
+ invocationLog: parsed.invocationLog ?? [],
202
+ questionLog: parsed.questionLog ?? [],
203
+ artifactRecords: parsed.artifactRecords ?? [],
204
+ narrativeTraces: parsed.narrativeTraces ?? [],
205
+ ...(parsed.studyContract ? { studyContract: parsed.studyContract } : {})
206
+ };
207
+ }
208
+ function recentInvocationRecords(state, limit = 3) {
209
+ return (state.invocationLog ?? []).slice(-limit).reverse();
210
+ }
211
+ function recentPendingQuestions(state, limit = 3) {
212
+ return (state.questionLog ?? [])
213
+ .filter((record) => record.status === "pending")
214
+ .slice(-limit)
215
+ .reverse();
216
+ }
217
+ function formatQuestionOptionValues(record) {
218
+ const values = record.prompt.options.map((option) => option.value);
219
+ if (record.prompt.allowOther) {
220
+ values.push(record.prompt.otherLabel ? `other:${record.prompt.otherLabel}` : "other");
221
+ }
222
+ return values;
223
+ }
224
+ function summarizeWorkspaceInspection(context, state) {
225
+ const questions = state.questionLog ?? [];
226
+ const pendingQuestions = questions.filter((record) => record.status === "pending");
227
+ const answeredQuestions = questions.filter((record) => record.status === "answered");
228
+ return {
229
+ found: true,
230
+ rootPath: context.project.projectPath,
231
+ project: {
232
+ name: context.project.projectName,
233
+ path: context.project.projectPath,
234
+ field: context.project.globalSetupSummary.field,
235
+ careerStage: context.project.globalSetupSummary.careerStage,
236
+ checkpointIntensity: context.project.globalSetupSummary.checkpointIntensity
237
+ },
238
+ session: {
239
+ currentGoal: context.session.currentGoal,
240
+ ...(context.session.currentBlocker ? { currentBlocker: context.session.currentBlocker } : {}),
241
+ requestedPerspectives: context.session.requestedPerspectives,
242
+ disagreementPreference: context.session.disagreementPreference
243
+ },
244
+ files: {
245
+ project: context.projectFilePath,
246
+ session: context.sessionFilePath,
247
+ state: context.stateFilePath,
248
+ current: context.currentFilePath
249
+ },
250
+ counts: {
251
+ invocations: (state.invocationLog ?? []).length,
252
+ questions: questions.length,
253
+ pendingQuestions: pendingQuestions.length,
254
+ answeredQuestions: answeredQuestions.length,
255
+ decisions: (state.decisionLog ?? []).length
256
+ },
257
+ recentInvocations: recentInvocationRecords(state, 5).map((record) => ({
258
+ id: record.id,
259
+ kind: record.intent.kind,
260
+ mode: record.intent.mode,
261
+ surface: record.surface,
262
+ status: record.status,
263
+ roles: record.intent.roles,
264
+ linkedQuestions: record.panelResult?.linkedQuestionRecordIds.length ?? 0,
265
+ linkedDecisions: record.panelResult?.linkedDecisionRecordIds.length ?? 0
266
+ })),
267
+ pendingQuestions: pendingQuestions.slice(-5).reverse().map((record) => ({
268
+ id: record.id,
269
+ title: record.prompt.title,
270
+ question: record.prompt.question,
271
+ options: formatQuestionOptionValues(record),
272
+ required: record.prompt.required
273
+ })),
274
+ recentDecisions: (state.decisionLog ?? []).slice(-5).reverse().map((record) => ({
275
+ id: record.id,
276
+ checkpointKey: record.checkpointKey,
277
+ summary: record.summary,
278
+ ...(record.selectedOption ? { selectedOption: record.selectedOption } : {}),
279
+ timestamp: record.timestamp
280
+ }))
281
+ };
282
+ }
137
283
  function buildProjectAgentsMd(project, session) {
138
284
  return [
139
285
  "# AGENTS.md",
140
286
  "",
141
- "This directory is a Long Table research workspace.",
142
- "",
143
- "## Purpose",
144
- `- Project name: ${project.projectName}`,
145
- `- Current goal: ${session.currentGoal}`,
146
- ...(session.currentBlocker ? [`- Current blocker: ${session.currentBlocker}`] : []),
147
- `- Requested perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
148
- `- Disagreement visibility: ${session.disagreementPreference}`,
287
+ "This directory is a LongTable research workspace.",
149
288
  "",
150
- "## Research-facing behavior",
289
+ "## Runtime Contract",
151
290
  "- Treat researcher interaction as the primary task.",
152
- "- If the user message starts with `lt `, `longtable `, `long table `, or `롱테이블 ` followed by a directive and `:`, treat it as an explicit Long Table invocation.",
291
+ "- Read `.longtable/current-session.json` before giving substantial guidance.",
292
+ "- Use `.longtable/project.json` as stable project context.",
293
+ "- Use `.longtable/state.json` as layered working memory.",
294
+ "- Prefer `currentGoal`, `currentBlocker`, `nextAction`, and `openQuestions` over generic assumptions.",
295
+ "- Treat `AGENTS.md` as runtime guidance, not as the researcher-facing resume artifact.",
296
+ "",
297
+ "## Invocation Rules",
298
+ "- If the user message starts with `lt `, `longtable `, `long table `, or `롱테이블 ` followed by a directive and `:`, treat it as an explicit LongTable invocation.",
153
299
  "- Supported explicit directives are: explore, review, critique, draft, commit, panel, status, editor, reviewer, methods, theory, measurement, ethics, voice, venue.",
154
- "- For explicit Long Table invocations, do not begin by scanning the workspace. Use the current session files first and answer as Long Table immediately.",
155
- "- For general research requests in this workspace, prefer Long Table behavior before generic coding behavior.",
300
+ "- For explicit LongTable invocations, do not begin by scanning the workspace. Use the current session files first and answer as LongTable immediately.",
301
+ "- For general research requests in this workspace, prefer LongTable behavior before generic coding behavior.",
302
+ "",
303
+ "## Research Behavior",
156
304
  "- Begin exploratory work with clarifying or tension questions before recommending a direction.",
157
- "- If you foreground role perspectives, disclose them with `Long Table consulted: ...`.",
305
+ "- If you foreground role perspectives, disclose them with `LongTable consulted: ...`.",
158
306
  "- Keep one accountable synthesis, but do not hide meaningful disagreement.",
159
- "- For factual, current, or external claims, provide source links or file references when possible.",
160
- "- If a statement cannot be sourced, label it as an inference or estimate instead of presenting it as a fact.",
161
307
  ...(session.disagreementPreference === "always_visible"
162
- ? ["- In this workspace, panel disagreement should be visible by default rather than hidden behind a single synthesis."]
308
+ ? ["- Panel disagreement should be visible by default rather than hidden behind a single synthesis."]
163
309
  : []),
310
+ "- For factual, current, or external claims, provide source links or file references when possible.",
311
+ "- If a statement cannot be sourced, label it as an inference or estimate instead of presenting it as a fact.",
164
312
  "- Do not expose internal tool logs, file-search traces, or process commentary in the researcher-facing answer.",
165
313
  "",
166
- "## Session memory",
167
- "- Read `.longtable/current-session.json` before giving substantial guidance.",
168
- "- Use `.longtable/project.json` as the project-level context.",
169
- "- Prefer the current goal and blocker over generic assumptions.",
170
- "- Do not recursively search unrelated files unless the researcher explicitly asks for file analysis or code changes.",
171
- "",
172
314
  "## Scope",
315
+ `- Project: ${project.projectName}`,
316
+ `- Current goal: ${session.currentGoal}`,
317
+ ...(session.currentBlocker ? [`- Current blocker: ${session.currentBlocker}`] : []),
318
+ `- Requested perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
319
+ `- Disagreement visibility: ${session.disagreementPreference}`,
173
320
  "- These instructions apply to this directory and its children."
174
321
  ].join("\n");
175
322
  }
@@ -180,12 +327,18 @@ function buildStateSeed(project, session, setup) {
180
327
  careerStage: setup.profileSeed.careerStage,
181
328
  experienceLevel: setup.profileSeed.experienceLevel,
182
329
  projectName: project.projectName,
183
- currentGoal: session.currentGoal,
184
330
  disagreementPreference: session.disagreementPreference,
185
331
  requestedPerspectives: session.requestedPerspectives
186
332
  };
333
+ state.workingState = {
334
+ currentGoal: session.currentGoal,
335
+ ...(session.currentBlocker ? { currentBlocker: session.currentBlocker } : {}),
336
+ ...(session.nextAction ? { nextAction: session.nextAction } : {}),
337
+ openQuestions: session.openQuestions ?? [],
338
+ activeModes: session.activeModes ?? [],
339
+ ...(session.resumeHint ? { resumeHint: session.resumeHint } : {})
340
+ };
187
341
  if (session.currentBlocker) {
188
- state.explicitState.currentBlocker = session.currentBlocker;
189
342
  state.openTensions.push(session.currentBlocker);
190
343
  }
191
344
  if (setup.profileSeed.humanAuthorshipSignal) {
@@ -213,24 +366,259 @@ function buildStateSeed(project, session, setup) {
213
366
  }
214
367
  return JSON.stringify(state, null, 2);
215
368
  }
369
+ async function removeLegacyRootFiles(projectPath) {
370
+ await Promise.all(LEGACY_ROOT_FILES.map((file) => rm(join(projectPath, file), { force: true })));
371
+ }
372
+ export async function syncCurrentWorkspaceView(context) {
373
+ const state = await loadResearchState(context.stateFilePath);
374
+ const body = buildCurrentGuide(context.project, context.session, recentInvocationRecords(state), recentPendingQuestions(state));
375
+ await writeFile(context.currentFilePath, body, "utf8");
376
+ return context.currentFilePath;
377
+ }
378
+ export async function appendInvocationRecordToWorkspace(context, invocation, questions = []) {
379
+ const state = await loadResearchState(context.stateFilePath);
380
+ const withInvocation = appendInvocationToResearchState(state, invocation);
381
+ const updated = questions.length > 0
382
+ ? appendQuestionRecords(withInvocation, questions)
383
+ : withInvocation;
384
+ await writeFile(context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
385
+ await syncCurrentWorkspaceView(context);
386
+ return updated;
387
+ }
388
+ function createId(prefix) {
389
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
390
+ }
391
+ function findQuestionForDecision(state, questionId) {
392
+ const pending = (state.questionLog ?? []).filter((record) => record.status === "pending");
393
+ if (questionId) {
394
+ return pending.find((record) => record.id === questionId) ?? null;
395
+ }
396
+ return pending.at(-1) ?? null;
397
+ }
398
+ function pendingRequiredQuestions(state) {
399
+ return (state.questionLog ?? []).filter((record) => record.status === "pending" && record.prompt.required);
400
+ }
401
+ export async function listBlockingWorkspaceQuestions(context) {
402
+ const state = await loadResearchState(context.stateFilePath);
403
+ return pendingRequiredQuestions(state);
404
+ }
405
+ export async function assertWorkspaceNotBlocked(context) {
406
+ const blocking = await listBlockingWorkspaceQuestions(context);
407
+ if (blocking.length === 0) {
408
+ return;
409
+ }
410
+ const first = blocking[0];
411
+ const options = formatQuestionOptionValues(first).join("/");
412
+ throw new Error([
413
+ `LongTable is blocked by a required Researcher Checkpoint: ${first.id}`,
414
+ first.prompt.question,
415
+ `Options: ${options}`,
416
+ `Record an answer with: longtable decide --question ${first.id} --answer <value>`
417
+ ].join("\n"));
418
+ }
419
+ function questionTitleForCheckpoint(family) {
420
+ switch (family) {
421
+ case "meta_decision":
422
+ return "Meta-decision checkpoint";
423
+ case "submission":
424
+ return "Submission checkpoint";
425
+ case "commitment":
426
+ return "Research commitment checkpoint";
427
+ case "evidence":
428
+ return "Evidence checkpoint";
429
+ case "authorship":
430
+ return "Authorship checkpoint";
431
+ case "review":
432
+ return "Review checkpoint";
433
+ case "exploration":
434
+ return "Exploration checkpoint";
435
+ default:
436
+ return "Researcher Checkpoint";
437
+ }
438
+ }
439
+ function questionTextForCheckpoint(family, prompt) {
440
+ switch (family) {
441
+ case "meta_decision":
442
+ return "What should LongTable do before treating this platform decision as settled?";
443
+ case "submission":
444
+ return "What must happen before this work can move toward external release or submission?";
445
+ case "commitment":
446
+ return "What should LongTable treat as the human research commitment here?";
447
+ case "evidence":
448
+ return "How should LongTable handle the evidence risk before using this claim?";
449
+ case "authorship":
450
+ return "What should LongTable preserve before changing the researcher's voice or authorship trace?";
451
+ case "exploration":
452
+ return "What ambiguity should LongTable keep open before recommending a direction?";
453
+ default:
454
+ return `What should LongTable decide before proceeding with: ${prompt}`;
455
+ }
456
+ }
457
+ function optionsForCheckpointFamily(family) {
458
+ if (family === "evidence") {
459
+ return [
460
+ { value: "verify", label: "Verify evidence first", description: "Check whether the source supports the specific claim." },
461
+ { value: "limit", label: "Limit the claim", description: "Keep the point but narrow it to what the evidence can support." },
462
+ { value: "rewrite", label: "Rewrite without the claim", description: "Avoid relying on the uncertain citation or claim." },
463
+ { value: "defer", label: "Keep evidence risk open", description: "Do not settle the claim yet." }
464
+ ];
465
+ }
466
+ if (family === "meta_decision") {
467
+ return [
468
+ { value: "revise", label: "Revise the platform decision", description: "Change the term, policy, or README positioning before treating it as settled." },
469
+ { value: "evidence", label: "Gather implementation evidence first", description: "Inspect behavior or docs before committing the platform decision." },
470
+ { value: "proceed", label: "Proceed with current decision", description: "Accept the current platform framing and continue." },
471
+ { value: "defer", label: "Keep the decision open", description: "Do not make this platform language authoritative yet." }
472
+ ];
473
+ }
474
+ if (family === "submission") {
475
+ return [
476
+ { value: "review", label: "Review risk first", description: "Check claims, ethics, venue fit, or study contract before external release." },
477
+ { value: "evidence", label: "Verify evidence first", description: "Confirm source support before submission or public sharing." },
478
+ { value: "proceed", label: "Proceed toward submission", description: "Accept the remaining risk and continue." },
479
+ { value: "defer", label: "Do not submit yet", description: "Keep the submission decision open." }
480
+ ];
481
+ }
482
+ return [
483
+ { value: "revise", label: "Revise before proceeding", description: "Change the framing, design, or draft before treating this as settled." },
484
+ { value: "evidence", label: "Gather or verify evidence first", description: "Do not proceed until the relevant evidence is checked." },
485
+ { value: "proceed", label: "Proceed with current direction", description: "Accept the risk profile and continue." },
486
+ { value: "defer", label: "Keep this open", description: "Do not commit yet; keep the issue visible as an open tension." }
487
+ ];
488
+ }
489
+ export async function createWorkspaceQuestion(options) {
490
+ const state = await loadResearchState(options.context.stateFilePath);
491
+ const trigger = classifyCheckpointTrigger(options.prompt, {
492
+ unresolvedTensions: state.openTensions ?? [],
493
+ studyContract: state.studyContract
494
+ });
495
+ const createdAt = nowIso();
496
+ const question = {
497
+ id: createId("question_record"),
498
+ createdAt,
499
+ updatedAt: createdAt,
500
+ status: "pending",
501
+ prompt: {
502
+ id: createId("question_prompt"),
503
+ checkpointKey: trigger.signal.checkpointKey,
504
+ title: options.title ?? questionTitleForCheckpoint(trigger.family),
505
+ question: options.question ?? questionTextForCheckpoint(trigger.family, options.prompt),
506
+ type: "single_choice",
507
+ options: optionsForCheckpointFamily(trigger.family),
508
+ allowOther: true,
509
+ otherLabel: "Other decision",
510
+ required: options.required ?? trigger.requiresQuestionBeforeClosure,
511
+ source: "checkpoint",
512
+ rationale: [
513
+ ...trigger.rationale,
514
+ `Trigger family: ${trigger.family}.`,
515
+ `Trigger confidence: ${trigger.confidence}.`,
516
+ `Original prompt: ${options.prompt}`
517
+ ],
518
+ preferredSurfaces: options.provider === "claude"
519
+ ? ["native_structured", "numbered"]
520
+ : ["numbered", "native_structured"]
521
+ }
522
+ };
523
+ const updated = appendQuestionRecords(state, [question]);
524
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
525
+ await syncCurrentWorkspaceView(options.context);
526
+ return { question, state: updated };
527
+ }
528
+ function updateInvocationWithDecision(invocation, questionId, decisionId) {
529
+ if (!invocation.panelResult?.linkedQuestionRecordIds.includes(questionId)) {
530
+ return invocation;
531
+ }
532
+ const linkedDecisionRecordIds = invocation.panelResult.linkedDecisionRecordIds.includes(decisionId)
533
+ ? invocation.panelResult.linkedDecisionRecordIds
534
+ : [...invocation.panelResult.linkedDecisionRecordIds, decisionId];
535
+ return {
536
+ ...invocation,
537
+ updatedAt: nowIso(),
538
+ panelResult: {
539
+ ...invocation.panelResult,
540
+ updatedAt: nowIso(),
541
+ linkedDecisionRecordIds
542
+ }
543
+ };
544
+ }
545
+ export async function answerWorkspaceQuestion(options) {
546
+ const state = await loadResearchState(options.context.stateFilePath);
547
+ const question = findQuestionForDecision(state, options.questionId);
548
+ if (!question) {
549
+ throw new Error(options.questionId ? `No pending LongTable question found for ${options.questionId}.` : "No pending LongTable question was found.");
550
+ }
551
+ const option = question.prompt.options.find((candidate) => candidate.value === options.answer);
552
+ const explicitOther = options.answer === "other" && question.prompt.allowOther;
553
+ const answer = {
554
+ promptId: question.prompt.id,
555
+ selectedValues: [option?.value ?? "other"],
556
+ selectedLabels: [option?.label ?? (explicitOther ? question.prompt.otherLabel ?? "Other" : options.answer)],
557
+ ...(option || explicitOther ? {} : { otherText: options.answer }),
558
+ ...(options.rationale ? { rationale: options.rationale } : {}),
559
+ ...(options.provider ? { provider: options.provider } : {}),
560
+ surface: options.provider === "claude" ? "native_structured" : "numbered"
561
+ };
562
+ const timestamp = nowIso();
563
+ const decision = {
564
+ id: createId("decision"),
565
+ timestamp,
566
+ checkpointKey: question.prompt.checkpointKey ?? "manual",
567
+ level: question.prompt.required ? "adaptive_required" : "recommended",
568
+ mode: "commit",
569
+ summary: `Answered ${question.prompt.title}: ${answer.selectedLabels.join(", ")}`,
570
+ selectedOption: answer.selectedValues[0],
571
+ ...(options.rationale ? { rationale: options.rationale } : {})
572
+ };
573
+ const answeredQuestion = {
574
+ ...question,
575
+ updatedAt: timestamp,
576
+ status: "answered",
577
+ answer,
578
+ decisionRecordId: decision.id
579
+ };
580
+ const withQuestion = {
581
+ ...state,
582
+ questionLog: (state.questionLog ?? []).map((record) => record.id === question.id ? answeredQuestion : record),
583
+ invocationLog: (state.invocationLog ?? []).map((record) => updateInvocationWithDecision(record, question.id, decision.id))
584
+ };
585
+ const updated = appendDecisionToResearchState(withQuestion, decision);
586
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
587
+ await syncCurrentWorkspaceView(options.context);
588
+ return {
589
+ question: answeredQuestion,
590
+ decision,
591
+ state: updated
592
+ };
593
+ }
216
594
  export async function createOrUpdateProjectWorkspace(options) {
217
595
  const projectPath = resolve(options.projectPath);
218
596
  const metaDir = resolveMetaDir(projectPath);
219
597
  const sessionsDir = join(metaDir, "sessions");
220
598
  const projectFilePath = join(metaDir, "project.json");
221
599
  const sessionFilePath = join(metaDir, "current-session.json");
600
+ const stateFilePath = resolveStateFilePath(projectPath);
601
+ const currentFilePath = resolveCurrentFilePath(projectPath);
222
602
  const sessionId = slugify(`${options.projectName}-${Date.now()}`);
603
+ const locale = resolveUserLocale();
604
+ const timestamp = nowIso();
223
605
  await mkdir(projectPath, { recursive: true });
224
606
  await mkdir(metaDir, { recursive: true });
225
607
  await mkdir(sessionsDir, { recursive: true });
226
608
  const project = existsSync(projectFilePath)
227
- ? JSON.parse(await readFile(projectFilePath, "utf8"))
609
+ ? {
610
+ ...JSON.parse(await readFile(projectFilePath, "utf8")),
611
+ contractVersion: "workspace-v2",
612
+ locale
613
+ }
228
614
  : {
229
615
  schemaVersion: 1,
230
- product: "Long Table",
616
+ product: "LongTable",
231
617
  projectName: options.projectName,
232
618
  projectPath,
233
- createdAt: nowIso(),
619
+ createdAt: timestamp,
620
+ contractVersion: "workspace-v2",
621
+ locale,
234
622
  globalSetupSummary: {
235
623
  field: options.setup.profileSeed.field,
236
624
  careerStage: options.setup.profileSeed.careerStage,
@@ -250,30 +638,67 @@ export async function createOrUpdateProjectWorkspace(options) {
250
638
  const session = {
251
639
  schemaVersion: 1,
252
640
  id: sessionId,
253
- createdAt: nowIso(),
641
+ createdAt: timestamp,
642
+ lastUpdatedAt: timestamp,
254
643
  projectName: project.projectName,
255
644
  projectPath,
256
645
  currentGoal: options.currentGoal,
257
646
  ...(options.currentBlocker ? { currentBlocker: options.currentBlocker } : {}),
647
+ nextAction: buildNextAction({
648
+ schemaVersion: 1,
649
+ id: sessionId,
650
+ createdAt: timestamp,
651
+ projectName: project.projectName,
652
+ projectPath,
653
+ currentGoal: options.currentGoal,
654
+ ...(options.currentBlocker ? { currentBlocker: options.currentBlocker } : {}),
655
+ requestedPerspectives: options.requestedPerspectives,
656
+ disagreementPreference: options.disagreementPreference
657
+ }),
658
+ openQuestions: buildOpenQuestions({
659
+ schemaVersion: 1,
660
+ id: sessionId,
661
+ createdAt: timestamp,
662
+ projectName: project.projectName,
663
+ projectPath,
664
+ currentGoal: options.currentGoal,
665
+ ...(options.currentBlocker ? { currentBlocker: options.currentBlocker } : {}),
666
+ requestedPerspectives: options.requestedPerspectives,
667
+ disagreementPreference: options.disagreementPreference
668
+ }),
258
669
  requestedPerspectives: options.requestedPerspectives,
259
- disagreementPreference: options.disagreementPreference
670
+ disagreementPreference: options.disagreementPreference,
671
+ activeModes: ["explore"],
672
+ resumeHint: buildResumeHint({
673
+ schemaVersion: 1,
674
+ id: sessionId,
675
+ createdAt: timestamp,
676
+ projectName: project.projectName,
677
+ projectPath,
678
+ currentGoal: options.currentGoal,
679
+ ...(options.currentBlocker ? { currentBlocker: options.currentBlocker } : {}),
680
+ requestedPerspectives: options.requestedPerspectives,
681
+ disagreementPreference: options.disagreementPreference
682
+ }),
683
+ locale
260
684
  };
261
- await writeFile(projectFilePath, JSON.stringify(project, null, 2), "utf8");
262
- await writeFile(sessionFilePath, JSON.stringify(session, null, 2), "utf8");
263
- await writeFile(join(sessionsDir, `${sessionId}.json`), JSON.stringify(session, null, 2), "utf8");
264
- await writeFile(join(metaDir, "state.json"), buildStateSeed(project, session, options.setup), "utf8");
265
- await writeFile(join(projectPath, "LONGTABLE.md"), buildWorkspaceGuide(project, session), "utf8");
266
- await writeFile(join(projectPath, "START-HERE.md"), buildStartHereGuide(project, session), "utf8");
267
- await writeFile(join(projectPath, "NEXT-STEPS.md"), buildNextStepsGuide(project, session), "utf8");
268
- await writeFile(join(projectPath, "SESSION-SNAPSHOT.md"), buildSessionSnapshot(project, session), "utf8");
269
- await writeFile(join(projectPath, "AGENTS.md"), buildProjectAgentsMd(project, session), "utf8");
270
- return {
685
+ const context = {
271
686
  project,
272
687
  session,
273
688
  projectFilePath,
274
689
  sessionFilePath,
690
+ stateFilePath,
691
+ currentFilePath,
275
692
  metaDir
276
693
  };
694
+ await writeFile(projectFilePath, JSON.stringify(project, null, 2), "utf8");
695
+ await writeFile(sessionFilePath, JSON.stringify(session, null, 2), "utf8");
696
+ await writeFile(join(sessionsDir, `${sessionId}.json`), JSON.stringify(session, null, 2), "utf8");
697
+ await writeFile(stateFilePath, buildStateSeed(project, session, options.setup), "utf8");
698
+ await writeFile(join(projectPath, "AGENTS.md"), buildProjectAgentsMd(project, session), "utf8");
699
+ await syncCurrentWorkspaceView(context);
700
+ await removeLegacyRootFiles(projectPath);
701
+ return context;
277
702
  }
278
703
  export async function loadProjectContextFromDirectory(startPath) {
279
704
  let current = resolve(startPath);
@@ -282,11 +707,22 @@ export async function loadProjectContextFromDirectory(startPath) {
282
707
  const projectFilePath = join(metaDir, "project.json");
283
708
  const sessionFilePath = join(metaDir, "current-session.json");
284
709
  if (existsSync(projectFilePath) && existsSync(sessionFilePath)) {
710
+ const project = JSON.parse(await readFile(projectFilePath, "utf8"));
711
+ const session = JSON.parse(await readFile(sessionFilePath, "utf8"));
285
712
  return {
286
- project: JSON.parse(await readFile(projectFilePath, "utf8")),
287
- session: JSON.parse(await readFile(sessionFilePath, "utf8")),
713
+ project,
714
+ session: {
715
+ ...session,
716
+ locale: session.locale ?? project.locale ?? resolveUserLocale(),
717
+ openQuestions: session.openQuestions ?? buildOpenQuestions(session),
718
+ nextAction: session.nextAction ?? buildNextAction(session),
719
+ resumeHint: session.resumeHint ?? buildResumeHint(session),
720
+ activeModes: session.activeModes ?? ["explore"]
721
+ },
288
722
  projectFilePath,
289
723
  sessionFilePath,
724
+ stateFilePath: resolveStateFilePath(project.projectPath),
725
+ currentFilePath: resolveCurrentFilePath(project.projectPath),
290
726
  metaDir
291
727
  };
292
728
  }
@@ -297,10 +733,18 @@ export async function loadProjectContextFromDirectory(startPath) {
297
733
  current = parent;
298
734
  }
299
735
  }
736
+ export async function inspectProjectWorkspace(startPath) {
737
+ const context = await loadProjectContextFromDirectory(startPath);
738
+ if (!context) {
739
+ return { found: false };
740
+ }
741
+ const state = await loadResearchState(context.stateFilePath);
742
+ return summarizeWorkspaceInspection(context, state);
743
+ }
300
744
  export function renderProjectWorkspaceSummary(context) {
301
745
  return [
302
746
  "┌──────────────────────────────────────────────┐",
303
- "│ Long Table Project Workspace │",
747
+ "│ LongTable Project Workspace │",
304
748
  "└──────────────────────────────────────────────┘",
305
749
  `Project: ${context.project.projectName}`,
306
750
  `Path: ${context.project.projectPath}`,
@@ -312,9 +756,8 @@ export function renderProjectWorkspaceSummary(context) {
312
756
  "Created files:",
313
757
  `- ${context.projectFilePath}`,
314
758
  `- ${context.sessionFilePath}`,
315
- `- ${join(context.project.projectPath, "START-HERE.md")}`,
316
- `- ${join(context.project.projectPath, "NEXT-STEPS.md")}`,
317
- `- ${join(context.project.projectPath, "SESSION-SNAPSHOT.md")}`,
759
+ `- ${context.stateFilePath}`,
760
+ `- ${context.currentFilePath}`,
318
761
  `- ${join(context.project.projectPath, "AGENTS.md")}`
319
762
  ].join("\n");
320
763
  }