@longtable/cli 0.1.9 → 0.1.11

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.
@@ -2,7 +2,8 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { execSync } from "node:child_process";
4
4
  import { dirname, join, resolve } from "node:path";
5
- import { createEmptyResearchState } from "@longtable/memory";
5
+ import { appendDecisionRecord as appendDecisionToResearchState, appendInvocationRecord as appendInvocationToResearchState, appendQuestionRecords, createEmptyResearchState } from "@longtable/memory";
6
+ import { classifyCheckpointTrigger } from "@longtable/checkpoints";
6
7
  const CURRENT_FILE_NAME = "CURRENT.md";
7
8
  const LEGACY_ROOT_FILES = ["LONGTABLE.md", "START-HERE.md", "NEXT-STEPS.md", "SESSION-SNAPSHOT.md"];
8
9
  function nowIso() {
@@ -79,7 +80,7 @@ function buildResumeHint(session) {
79
80
  ? `I want to continue ${session.currentGoal}. The unresolved blocker is ${session.currentBlocker}.`
80
81
  : `I want to continue ${session.currentGoal}.`;
81
82
  }
82
- function buildCurrentGuide(project, session) {
83
+ function buildCurrentGuide(project, session, recentInvocations = [], pendingQuestions = []) {
83
84
  const locale = normalizeLocale(session.locale ?? project.locale);
84
85
  const openQuestions = session.openQuestions && session.openQuestions.length > 0
85
86
  ? session.openQuestions
@@ -104,6 +105,27 @@ function buildCurrentGuide(project, session) {
104
105
  "",
105
106
  "## 열린 질문",
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
+ : []),
107
129
  "",
108
130
  "## 다시 시작 문장",
109
131
  `- "${resumeHint}"`,
@@ -132,6 +154,27 @@ function buildCurrentGuide(project, session) {
132
154
  "",
133
155
  "## Open Questions",
134
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
+ : []),
135
178
  "",
136
179
  "## Restart Prompt",
137
180
  `- "${resumeHint}"`,
@@ -144,6 +187,102 @@ function buildCurrentGuide(project, session) {
144
187
  "- External or current claims should carry a source link or be labeled as inference."
145
188
  ].join("\n");
146
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
+ export async function loadWorkspaceState(context) {
209
+ return loadResearchState(context.stateFilePath);
210
+ }
211
+ function recentInvocationRecords(state, limit = 3) {
212
+ return (state.invocationLog ?? []).slice(-limit).reverse();
213
+ }
214
+ function recentPendingQuestions(state, limit = 3) {
215
+ return (state.questionLog ?? [])
216
+ .filter((record) => record.status === "pending")
217
+ .slice(-limit)
218
+ .reverse();
219
+ }
220
+ function formatQuestionOptionValues(record) {
221
+ const values = record.prompt.options.map((option) => option.value);
222
+ if (record.prompt.allowOther) {
223
+ values.push(record.prompt.otherLabel ? `other:${record.prompt.otherLabel}` : "other");
224
+ }
225
+ return values;
226
+ }
227
+ function summarizeWorkspaceInspection(context, state) {
228
+ const questions = state.questionLog ?? [];
229
+ const pendingQuestions = questions.filter((record) => record.status === "pending");
230
+ const answeredQuestions = questions.filter((record) => record.status === "answered");
231
+ return {
232
+ found: true,
233
+ rootPath: context.project.projectPath,
234
+ project: {
235
+ name: context.project.projectName,
236
+ path: context.project.projectPath,
237
+ field: context.project.globalSetupSummary.field,
238
+ careerStage: context.project.globalSetupSummary.careerStage,
239
+ checkpointIntensity: context.project.globalSetupSummary.checkpointIntensity
240
+ },
241
+ session: {
242
+ currentGoal: context.session.currentGoal,
243
+ ...(context.session.currentBlocker ? { currentBlocker: context.session.currentBlocker } : {}),
244
+ requestedPerspectives: context.session.requestedPerspectives,
245
+ disagreementPreference: context.session.disagreementPreference
246
+ },
247
+ files: {
248
+ project: context.projectFilePath,
249
+ session: context.sessionFilePath,
250
+ state: context.stateFilePath,
251
+ current: context.currentFilePath
252
+ },
253
+ counts: {
254
+ invocations: (state.invocationLog ?? []).length,
255
+ questions: questions.length,
256
+ pendingQuestions: pendingQuestions.length,
257
+ answeredQuestions: answeredQuestions.length,
258
+ decisions: (state.decisionLog ?? []).length
259
+ },
260
+ recentInvocations: recentInvocationRecords(state, 5).map((record) => ({
261
+ id: record.id,
262
+ kind: record.intent.kind,
263
+ mode: record.intent.mode,
264
+ surface: record.surface,
265
+ status: record.status,
266
+ roles: record.intent.roles,
267
+ linkedQuestions: record.panelResult?.linkedQuestionRecordIds.length ?? 0,
268
+ linkedDecisions: record.panelResult?.linkedDecisionRecordIds.length ?? 0
269
+ })),
270
+ pendingQuestions: pendingQuestions.slice(-5).reverse().map((record) => ({
271
+ id: record.id,
272
+ title: record.prompt.title,
273
+ question: record.prompt.question,
274
+ options: formatQuestionOptionValues(record),
275
+ required: record.prompt.required
276
+ })),
277
+ recentDecisions: (state.decisionLog ?? []).slice(-5).reverse().map((record) => ({
278
+ id: record.id,
279
+ checkpointKey: record.checkpointKey,
280
+ summary: record.summary,
281
+ ...(record.selectedOption ? { selectedOption: record.selectedOption } : {}),
282
+ timestamp: record.timestamp
283
+ }))
284
+ };
285
+ }
147
286
  function buildProjectAgentsMd(project, session) {
148
287
  return [
149
288
  "# AGENTS.md",
@@ -234,10 +373,227 @@ async function removeLegacyRootFiles(projectPath) {
234
373
  await Promise.all(LEGACY_ROOT_FILES.map((file) => rm(join(projectPath, file), { force: true })));
235
374
  }
236
375
  export async function syncCurrentWorkspaceView(context) {
237
- const body = buildCurrentGuide(context.project, context.session);
376
+ const state = await loadResearchState(context.stateFilePath);
377
+ const body = buildCurrentGuide(context.project, context.session, recentInvocationRecords(state), recentPendingQuestions(state));
238
378
  await writeFile(context.currentFilePath, body, "utf8");
239
379
  return context.currentFilePath;
240
380
  }
381
+ export async function appendInvocationRecordToWorkspace(context, invocation, questions = []) {
382
+ const state = await loadResearchState(context.stateFilePath);
383
+ const withInvocation = appendInvocationToResearchState(state, invocation);
384
+ const updated = questions.length > 0
385
+ ? appendQuestionRecords(withInvocation, questions)
386
+ : withInvocation;
387
+ await writeFile(context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
388
+ await syncCurrentWorkspaceView(context);
389
+ return updated;
390
+ }
391
+ function createId(prefix) {
392
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
393
+ }
394
+ function findQuestionForDecision(state, questionId) {
395
+ const pending = (state.questionLog ?? []).filter((record) => record.status === "pending");
396
+ if (questionId) {
397
+ return pending.find((record) => record.id === questionId) ?? null;
398
+ }
399
+ return pending.at(-1) ?? null;
400
+ }
401
+ function pendingRequiredQuestions(state) {
402
+ return (state.questionLog ?? []).filter((record) => record.status === "pending" && record.prompt.required);
403
+ }
404
+ export async function listBlockingWorkspaceQuestions(context) {
405
+ const state = await loadResearchState(context.stateFilePath);
406
+ return pendingRequiredQuestions(state);
407
+ }
408
+ export async function assertWorkspaceNotBlocked(context) {
409
+ const blocking = await listBlockingWorkspaceQuestions(context);
410
+ if (blocking.length === 0) {
411
+ return;
412
+ }
413
+ const first = blocking[0];
414
+ const options = formatQuestionOptionValues(first).join("/");
415
+ throw new Error([
416
+ `LongTable is blocked by a required Researcher Checkpoint: ${first.id}`,
417
+ first.prompt.question,
418
+ `Options: ${options}`,
419
+ `Record an answer with: longtable decide --question ${first.id} --answer <value>`
420
+ ].join("\n"));
421
+ }
422
+ function questionTitleForCheckpoint(family) {
423
+ switch (family) {
424
+ case "meta_decision":
425
+ return "Meta-decision checkpoint";
426
+ case "submission":
427
+ return "Submission checkpoint";
428
+ case "commitment":
429
+ return "Research commitment checkpoint";
430
+ case "evidence":
431
+ return "Evidence checkpoint";
432
+ case "authorship":
433
+ return "Authorship checkpoint";
434
+ case "review":
435
+ return "Review checkpoint";
436
+ case "exploration":
437
+ return "Exploration checkpoint";
438
+ default:
439
+ return "Researcher Checkpoint";
440
+ }
441
+ }
442
+ function questionTextForCheckpoint(family, prompt) {
443
+ switch (family) {
444
+ case "meta_decision":
445
+ return "What should LongTable do before treating this platform decision as settled?";
446
+ case "submission":
447
+ return "What must happen before this work can move toward external release or submission?";
448
+ case "commitment":
449
+ return "What should LongTable treat as the human research commitment here?";
450
+ case "evidence":
451
+ return "How should LongTable handle the evidence risk before using this claim?";
452
+ case "authorship":
453
+ return "What should LongTable preserve before changing the researcher's voice or authorship trace?";
454
+ case "exploration":
455
+ return "What ambiguity should LongTable keep open before recommending a direction?";
456
+ default:
457
+ return `What should LongTable decide before proceeding with: ${prompt}`;
458
+ }
459
+ }
460
+ function optionsForCheckpointFamily(family) {
461
+ if (family === "evidence") {
462
+ return [
463
+ { value: "verify", label: "Verify evidence first", description: "Check whether the source supports the specific claim." },
464
+ { value: "limit", label: "Limit the claim", description: "Keep the point but narrow it to what the evidence can support." },
465
+ { value: "rewrite", label: "Rewrite without the claim", description: "Avoid relying on the uncertain citation or claim." },
466
+ { value: "defer", label: "Keep evidence risk open", description: "Do not settle the claim yet." }
467
+ ];
468
+ }
469
+ if (family === "meta_decision") {
470
+ return [
471
+ { value: "revise", label: "Revise the platform decision", description: "Change the term, policy, or README positioning before treating it as settled." },
472
+ { value: "evidence", label: "Gather implementation evidence first", description: "Inspect behavior or docs before committing the platform decision." },
473
+ { value: "proceed", label: "Proceed with current decision", description: "Accept the current platform framing and continue." },
474
+ { value: "defer", label: "Keep the decision open", description: "Do not make this platform language authoritative yet." }
475
+ ];
476
+ }
477
+ if (family === "submission") {
478
+ return [
479
+ { value: "review", label: "Review risk first", description: "Check claims, ethics, venue fit, or study contract before external release." },
480
+ { value: "evidence", label: "Verify evidence first", description: "Confirm source support before submission or public sharing." },
481
+ { value: "proceed", label: "Proceed toward submission", description: "Accept the remaining risk and continue." },
482
+ { value: "defer", label: "Do not submit yet", description: "Keep the submission decision open." }
483
+ ];
484
+ }
485
+ return [
486
+ { value: "revise", label: "Revise before proceeding", description: "Change the framing, design, or draft before treating this as settled." },
487
+ { value: "evidence", label: "Gather or verify evidence first", description: "Do not proceed until the relevant evidence is checked." },
488
+ { value: "proceed", label: "Proceed with current direction", description: "Accept the risk profile and continue." },
489
+ { value: "defer", label: "Keep this open", description: "Do not commit yet; keep the issue visible as an open tension." }
490
+ ];
491
+ }
492
+ export async function createWorkspaceQuestion(options) {
493
+ const state = await loadResearchState(options.context.stateFilePath);
494
+ const trigger = classifyCheckpointTrigger(options.prompt, {
495
+ unresolvedTensions: state.openTensions ?? [],
496
+ studyContract: state.studyContract
497
+ });
498
+ const createdAt = nowIso();
499
+ const question = {
500
+ id: createId("question_record"),
501
+ createdAt,
502
+ updatedAt: createdAt,
503
+ status: "pending",
504
+ prompt: {
505
+ id: createId("question_prompt"),
506
+ checkpointKey: trigger.signal.checkpointKey,
507
+ title: options.title ?? questionTitleForCheckpoint(trigger.family),
508
+ question: options.question ?? questionTextForCheckpoint(trigger.family, options.prompt),
509
+ type: "single_choice",
510
+ options: optionsForCheckpointFamily(trigger.family),
511
+ allowOther: true,
512
+ otherLabel: "Other decision",
513
+ required: options.required ?? trigger.requiresQuestionBeforeClosure,
514
+ source: "checkpoint",
515
+ rationale: [
516
+ ...trigger.rationale,
517
+ `Trigger family: ${trigger.family}.`,
518
+ `Trigger confidence: ${trigger.confidence}.`,
519
+ `Original prompt: ${options.prompt}`
520
+ ],
521
+ preferredSurfaces: options.provider === "claude"
522
+ ? ["native_structured", "numbered"]
523
+ : ["numbered", "native_structured"]
524
+ }
525
+ };
526
+ const updated = appendQuestionRecords(state, [question]);
527
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
528
+ await syncCurrentWorkspaceView(options.context);
529
+ return { question, state: updated };
530
+ }
531
+ function updateInvocationWithDecision(invocation, questionId, decisionId) {
532
+ if (!invocation.panelResult?.linkedQuestionRecordIds.includes(questionId)) {
533
+ return invocation;
534
+ }
535
+ const linkedDecisionRecordIds = invocation.panelResult.linkedDecisionRecordIds.includes(decisionId)
536
+ ? invocation.panelResult.linkedDecisionRecordIds
537
+ : [...invocation.panelResult.linkedDecisionRecordIds, decisionId];
538
+ return {
539
+ ...invocation,
540
+ updatedAt: nowIso(),
541
+ panelResult: {
542
+ ...invocation.panelResult,
543
+ updatedAt: nowIso(),
544
+ linkedDecisionRecordIds
545
+ }
546
+ };
547
+ }
548
+ export async function answerWorkspaceQuestion(options) {
549
+ const state = await loadResearchState(options.context.stateFilePath);
550
+ const question = findQuestionForDecision(state, options.questionId);
551
+ if (!question) {
552
+ throw new Error(options.questionId ? `No pending LongTable question found for ${options.questionId}.` : "No pending LongTable question was found.");
553
+ }
554
+ const option = question.prompt.options.find((candidate) => candidate.value === options.answer);
555
+ const explicitOther = options.answer === "other" && question.prompt.allowOther;
556
+ const answer = {
557
+ promptId: question.prompt.id,
558
+ selectedValues: [option?.value ?? "other"],
559
+ selectedLabels: [option?.label ?? (explicitOther ? question.prompt.otherLabel ?? "Other" : options.answer)],
560
+ ...(option || explicitOther ? {} : { otherText: options.answer }),
561
+ ...(options.rationale ? { rationale: options.rationale } : {}),
562
+ ...(options.provider ? { provider: options.provider } : {}),
563
+ surface: options.provider === "claude" ? "native_structured" : "numbered"
564
+ };
565
+ const timestamp = nowIso();
566
+ const decision = {
567
+ id: createId("decision"),
568
+ timestamp,
569
+ checkpointKey: question.prompt.checkpointKey ?? "manual",
570
+ level: question.prompt.required ? "adaptive_required" : "recommended",
571
+ mode: "commit",
572
+ summary: `Answered ${question.prompt.title}: ${answer.selectedLabels.join(", ")}`,
573
+ selectedOption: answer.selectedValues[0],
574
+ ...(options.rationale ? { rationale: options.rationale } : {})
575
+ };
576
+ const answeredQuestion = {
577
+ ...question,
578
+ updatedAt: timestamp,
579
+ status: "answered",
580
+ answer,
581
+ decisionRecordId: decision.id
582
+ };
583
+ const withQuestion = {
584
+ ...state,
585
+ questionLog: (state.questionLog ?? []).map((record) => record.id === question.id ? answeredQuestion : record),
586
+ invocationLog: (state.invocationLog ?? []).map((record) => updateInvocationWithDecision(record, question.id, decision.id))
587
+ };
588
+ const updated = appendDecisionToResearchState(withQuestion, decision);
589
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
590
+ await syncCurrentWorkspaceView(options.context);
591
+ return {
592
+ question: answeredQuestion,
593
+ decision,
594
+ state: updated
595
+ };
596
+ }
241
597
  export async function createOrUpdateProjectWorkspace(options) {
242
598
  const projectPath = resolve(options.projectPath);
243
599
  const metaDir = resolveMetaDir(projectPath);
@@ -380,6 +736,14 @@ export async function loadProjectContextFromDirectory(startPath) {
380
736
  current = parent;
381
737
  }
382
738
  }
739
+ export async function inspectProjectWorkspace(startPath) {
740
+ const context = await loadProjectContextFromDirectory(startPath);
741
+ if (!context) {
742
+ return { found: false };
743
+ }
744
+ const state = await loadResearchState(context.stateFilePath);
745
+ return summarizeWorkspaceInspection(context, state);
746
+ }
383
747
  export function renderProjectWorkspaceSummary(context) {
384
748
  return [
385
749
  "┌──────────────────────────────────────────────┐",
@@ -35,8 +35,8 @@ function promptSpec() {
35
35
  "Do not move to the next question until the researcher answers the current one.",
36
36
  "Quickstart covers: provider, field, career stage, experience level, checkpoint intensity, and human authorship signal.",
37
37
  "Interview also covers: preferred entry mode, weakest domain, and panel visibility preference.",
38
- "After collecting all answers, summarize the proposed setup and then output both: 1) the exact `longtable codex persist-init ... --install-prompts` command and 2) a strict JSON object with keys provider, flow, field, careerStage, experienceLevel, preferredCheckpointIntensity, and optional humanAuthorshipSignal, preferredEntryMode, weakestDomain, panelPreference.",
39
- "If the user prefers paste-based setup, tell them they can pipe the JSON into `longtable codex persist-init --stdin --install-prompts`.",
38
+ "After collecting all answers, summarize the proposed setup and then output both: 1) the exact `longtable codex persist-init ... --install-skills` command and 2) a strict JSON object with keys provider, flow, field, careerStage, experienceLevel, preferredCheckpointIntensity, and optional humanAuthorshipSignal, preferredEntryMode, weakestDomain, panelPreference.",
39
+ "If the user prefers paste-based setup, tell them they can pipe the JSON into `longtable codex persist-init --stdin --install-skills`.",
40
40
  "If the researcher asks you to stay inside Codex, keep the conversation in numbered form and do not prematurely close.",
41
41
  "Frame the setup like a short researcher interview, not a bare config form.",
42
42
  "Do not pretend that this is the full project-start interview. The real project-start interview happens in `longtable start`.",
@@ -154,7 +154,7 @@ function promptSpec() {
154
154
  argumentHint: "[optional concern]",
155
155
  body: [
156
156
  "You are LongTable status mode.",
157
- "Inspect whether setup and runtime artifacts appear to exist under `~/.longtable/` and whether LongTable prompt aliases appear to be installed under `~/.codex/prompts/`.",
157
+ "Inspect whether setup and runtime artifacts appear to exist under `~/.longtable/` and whether LongTable skills appear to be installed under `~/.codex/skills/`.",
158
158
  "Summarize what is configured, what is missing, and the next minimal action.",
159
159
  "Treat any slash-command arguments as the user's concern."
160
160
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@longtable/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "private": false,
5
5
  "description": "Researcher-facing LongTable CLI",
6
6
  "type": "module",
@@ -28,9 +28,12 @@
28
28
  "typecheck": "tsc -p tsconfig.json --noEmit"
29
29
  },
30
30
  "dependencies": {
31
- "@longtable/memory": "0.1.9",
32
- "@longtable/provider-codex": "0.1.9",
33
- "@longtable/setup": "0.1.9"
31
+ "@longtable/checkpoints": "0.1.11",
32
+ "@longtable/core": "0.1.11",
33
+ "@longtable/memory": "0.1.11",
34
+ "@longtable/provider-claude": "0.1.11",
35
+ "@longtable/provider-codex": "0.1.11",
36
+ "@longtable/setup": "0.1.11"
34
37
  },
35
38
  "devDependencies": {
36
39
  "@types/node": "^22.10.1",