@longtable/cli 0.1.43 → 0.1.45

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/README.md CHANGED
@@ -86,9 +86,18 @@ summary without starting a provider session.
86
86
  - `CURRENT.md`: human-facing current view regenerated from state
87
87
  - `.longtable/project.json`: stable project identity
88
88
  - `.longtable/current-session.json`: current session cursor
89
- - `.longtable/state.json`: layered memory state
89
+ - `.longtable/state.json`: layered memory state, including First Research
90
+ Shape and Research Specification when the interview has produced them
90
91
  - `.longtable/sessions/`: historical snapshots
91
92
 
93
+ `$longtable-interview` first stabilizes a short First Research Shape. When the
94
+ conversation is substantive enough, it should also preserve a Research
95
+ Specification covering scope, construct ontology, theory framing,
96
+ measurement/coding, method options, evidence/access requirements, epistemic
97
+ alignment, protected decisions, open questions, and next actions. `CURRENT.md`
98
+ renders that specification so later agents do not need to reconstruct the full
99
+ interview from memory.
100
+
92
101
  ## Why This Shape
93
102
 
94
103
  The CLI tries to keep the root simple for novice researchers while preserving enough structure for power users and downstream tooling.
@@ -140,6 +149,25 @@ That setup writes the MCP configuration and Codex elicitation approval needed
140
149
  for form-style checkpoint prompts. Without it, LongTable keeps the same
141
150
  `QuestionRecord` pending and falls back to numbered text.
142
151
 
152
+ ## Runtime Boundary
153
+
154
+ LongTable is not a replacement wrapper for Codex. Markdown docs and generated
155
+ skills are soft policy; hooks, MCP elicitation, CLI gates, and `.longtable/`
156
+ state are the enforcement layers.
157
+
158
+ LongTable should ask and stop before acting when the next step would change or
159
+ settle one of four high-risk research commitments:
160
+
161
+ 1. Research question or scope
162
+ 2. Theory frame or construct map
163
+ 3. Measurement, coding, or extraction standard
164
+ 4. Method design or analysis strategy
165
+
166
+ Low-risk reversible work should continue with visible assumptions instead of a
167
+ hook interruption. If human knowledge, AI inference, and durable project state
168
+ conflict, LongTable should prefer the most explicit durable state; if that state
169
+ is not explicit enough, it should ask the researcher for clarity.
170
+
143
171
  Explicit short forms are available when needed:
144
172
 
145
173
  ```text
@@ -241,16 +269,17 @@ deduplicates, ranks, and labels results as evidence cards. Some sources work
241
269
  without keys, some require a contact email, and some need API keys for reliable
242
270
  use.
243
271
 
244
- Publisher access is configured separately through environment variables and
245
- DOI probes. `longtable search setup` checks Elsevier, Springer Nature, Wiley,
246
- and Taylor & Francis credentials or TDM tokens without storing secrets.
272
+ Scholarly access is configured separately through `longtable access setup`.
273
+ It records readiness for metadata, OA full text, institutional access,
274
+ publisher API/TDM credentials, and manual PDFs without storing secrets.
275
+ Publisher probes cover Elsevier, Springer Nature, Wiley, and Taylor & Francis.
247
276
 
248
277
  Citation support should be checked explicitly. A reference can be useful as
249
278
  background while still failing to support the specific claim attached to it.
250
279
 
251
280
  ```bash
252
- longtable search setup
253
- longtable search probe --doi "10.1016/example" --publisher elsevier
281
+ longtable access setup
282
+ longtable access probe --doi "10.1016/example" --publisher elsevier
254
283
  longtable search --query "trust calibration measurement" --intent measurement
255
284
  longtable search --query "trust calibration measurement" --publisher-access --json
256
285
  longtable search --query "trust calibration citation support" --intent citation --record
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { dirname, join, resolve } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { classifyCheckpointTrigger } from "@longtable/checkpoints";
12
- import { assessSearchSourceCapabilities, buildResearchSearchIntent, buildSearchCapabilitySnapshot, parsePublisherTarget, probePublisherAccess, publisherConfigs, runResearchSearch, searchCapabilitySnapshotPath, summarizeConfiguredPublisherAccess } from "./search/index.js";
12
+ import { assessSearchSourceCapabilities, buildResearchSearchIntent, parsePublisherTarget, probePublisherAccess, publisherConfigs, runResearchSearch, SEARCH_SOURCES, summarizeConfiguredPublisherAccess } from "./search/index.js";
13
13
  import { buildProviderChoices, buildQuickSetupFlow, createPersistedSetupOutput, installRuntimeConfigFromStoredSetup, loadSetupOutput, renderInstallSummary, renderSetupSummary, resolveDefaultRuntimeConfigPath, resolveDefaultSetupPath, saveSetupOutput, saveSetupAndRuntimeConfig, serializeSetupOutput, writeRuntimeConfig } from "@longtable/setup";
14
14
  import { buildCodexSkillSpecs, buildCodexThinWrappedPrompt, installCodexSkills, listInstalledCodexSkills, renderQuestionRecordPrompt, removeCodexSkills, resolveCodexSkillsDir, runCodexThinWrapper } from "@longtable/provider-codex";
15
15
  import { buildClaudeSkillSpecs, installClaudeSkills, listInstalledClaudeSkills, renderQuestionRecordInput, removeClaudeSkills, resolveClaudeSkillsDir } from "@longtable/provider-claude";
@@ -109,10 +109,11 @@ function usage() {
109
109
  " longtable show [--json] [--path <file>]",
110
110
  " longtable install [--json] [--path <file>] [--runtime-path <file>]",
111
111
  " longtable mcp install [--provider codex|claude|all] [--write] [--checkpoint-ui off|interactive|strong] [--json] [--codex-config <path>] [--claude-settings <path>] [--package <spec>]",
112
+ " longtable access setup [--doi <doi>] [--json]",
113
+ " longtable access status [--json]",
114
+ " longtable access doctor [--doi <doi>] [--publisher auto|elsevier|springer_nature|wiley|taylor_francis|all] [--json]",
115
+ " longtable access probe --doi <doi> [--publisher auto|elsevier|springer_nature|wiley|taylor_francis] [--json]",
112
116
  " longtable search --query <text> [--intent literature|theory|measurement|citation|metadata|venue] [--field <text>] [--source all|crossref,arxiv,openalex,semantic_scholar,pubmed,eric,doaj,unpaywall] [--must <term[,term]>] [--exclude <term[,term]>] [--limit <n>] [--allow-partial] [--publisher-access] [--record] [--cwd <path>] [--json]",
113
- " longtable search setup [--doi <doi>] [--json]",
114
- " longtable search doctor [--doi <doi>] [--publisher auto|elsevier|springer_nature|wiley|taylor_francis|all] [--json]",
115
- " longtable search probe --doi <doi> [--publisher auto|elsevier|springer_nature|wiley|taylor_francis] [--json]",
116
117
  " longtable sentinel --prompt <text> [--cwd <path>] [--json] [--record]",
117
118
  " longtable team --prompt <text> [--role <role[,role]>] [--debate] [--rounds 3|5] [--cwd <path>] [--json]",
118
119
  " longtable ask [--prompt <text>] [--print] [--json] [--setup <path>] [--cwd <path>]",
@@ -154,7 +155,7 @@ function parseArgs(argv) {
154
155
  const values = {};
155
156
  let subcommand = maybeSubcommand;
156
157
  const modeCommand = command && VALID_MODES.has(command);
157
- const directCommand = command && ["init", "setup", "start", "resume", "doctor", "status", "audit", "roles", "show", "install", "mcp", "codex", "claude", "ask", "clarify", "question", "clear-question", "prune-questions", "panel", "decide", "sentinel", "team", "search"].includes(command);
158
+ const directCommand = command && ["init", "setup", "start", "resume", "doctor", "status", "audit", "roles", "show", "install", "mcp", "codex", "claude", "ask", "clarify", "question", "clear-question", "prune-questions", "panel", "decide", "sentinel", "team", "access", "search"].includes(command);
158
159
  let startIndex = 1;
159
160
  if (modeCommand) {
160
161
  subcommand = undefined;
@@ -163,7 +164,7 @@ function parseArgs(argv) {
163
164
  else if (command === "codex" || command === "claude" || command === "mcp") {
164
165
  startIndex = 2;
165
166
  }
166
- else if (command === "search" && maybeSubcommand && !maybeSubcommand.startsWith("--")) {
167
+ else if ((command === "access" || command === "search") && maybeSubcommand && !maybeSubcommand.startsWith("--")) {
167
168
  subcommand = maybeSubcommand;
168
169
  startIndex = 2;
169
170
  }
@@ -1789,6 +1790,11 @@ const QUESTION_AUDIT_FIXTURES = [
1789
1790
  prompt: "Protected decision closure pressure: measurement. User prompt: Implement the plan.",
1790
1791
  expectedKinds: ["research_commitment"]
1791
1792
  },
1793
+ {
1794
+ id: "scholarly_access_policy",
1795
+ prompt: "메타분석 논문들의 PDF와 full text를 수집해서 원문 기반으로 코딩해줘.",
1796
+ expectedKinds: ["evidence_risk"]
1797
+ },
1792
1798
  {
1793
1799
  id: "low_stakes_copyedit",
1794
1800
  prompt: "문장 끝 공백만 정리해줘.",
@@ -2347,6 +2353,86 @@ async function recordEvidenceRun(run, workingDirectory) {
2347
2353
  await syncCurrentWorkspaceView(context);
2348
2354
  return evidencePath;
2349
2355
  }
2356
+ function nowIso() {
2357
+ return new Date().toISOString();
2358
+ }
2359
+ function uniqueAccessRoutes(routes) {
2360
+ return [...new Set(routes)];
2361
+ }
2362
+ function accessReadinessPath(home = homedir()) {
2363
+ return join(home, ".longtable", "access-readiness.json");
2364
+ }
2365
+ function readAccessReadinessProfile() {
2366
+ const path = accessReadinessPath();
2367
+ if (!existsSync(path)) {
2368
+ return undefined;
2369
+ }
2370
+ try {
2371
+ return JSON.parse(readFileSync(path, "utf8"));
2372
+ }
2373
+ catch {
2374
+ return undefined;
2375
+ }
2376
+ }
2377
+ function hasPublisherCredentialSignal(records) {
2378
+ return records.some((record) => record.presentEnv.length > 0 || record.credentialStatus !== "missing");
2379
+ }
2380
+ function readinessPublisherRecord(record) {
2381
+ const safeRecord = { ...record };
2382
+ delete safeRecord.evidenceSnippet;
2383
+ return safeRecord;
2384
+ }
2385
+ function inferAccessRoutes(records) {
2386
+ const routes = ["metadata"];
2387
+ if (hasPublisherCredentialSignal(records)) {
2388
+ routes.push("publisher_tdm");
2389
+ }
2390
+ return routes;
2391
+ }
2392
+ function buildAccessReadinessProfile(options) {
2393
+ const publisherRecords = options.publisherRecords ?? summarizeConfiguredPublisherAccess(env);
2394
+ const routes = uniqueAccessRoutes(options.routes?.length ? options.routes : inferAccessRoutes(publisherRecords));
2395
+ const disabled = options.readiness === "disabled";
2396
+ const institutionalMode = options.institutionalAccessMode;
2397
+ const institutionalAccess = routes.includes("institutional")
2398
+ ? {
2399
+ available: true,
2400
+ mode: institutionalMode ?? "unknown",
2401
+ verified: false,
2402
+ note: "LongTable records institutional access readiness only. The researcher must complete VPN/proxy/library login directly."
2403
+ }
2404
+ : undefined;
2405
+ return {
2406
+ version: 1,
2407
+ updatedAt: nowIso(),
2408
+ readiness: options.readiness,
2409
+ metadataSources: [...SEARCH_SOURCES],
2410
+ routes: disabled ? [] : routes,
2411
+ ...(institutionalAccess ? { institutionalAccess } : {}),
2412
+ publisherTdm: disabled
2413
+ ? "deferred"
2414
+ : routes.includes("publisher_tdm")
2415
+ ? hasPublisherCredentialSignal(publisherRecords) ? "configured" : "unknown"
2416
+ : "not_configured",
2417
+ oaOnly: !disabled && routes.includes("oa_full_text") && !routes.includes("institutional") && !routes.includes("publisher_tdm"),
2418
+ manualPdfAllowed: !disabled && routes.includes("manual_pdf"),
2419
+ storesSecrets: false,
2420
+ requiresCheckpointBeforeSearch: options.readiness === "deferred",
2421
+ requiresCheckpointBeforeFullText: options.readiness !== "disabled",
2422
+ ...(disabled ? {} : {
2423
+ publisherAccess: {
2424
+ contactEmailPresent: Boolean(env.LONGTABLE_CONTACT_EMAIL?.trim()),
2425
+ records: publisherRecords.map(readinessPublisherRecord)
2426
+ }
2427
+ })
2428
+ };
2429
+ }
2430
+ async function saveAccessReadinessProfile(profile) {
2431
+ const profilePath = accessReadinessPath();
2432
+ await mkdir(dirname(profilePath), { recursive: true });
2433
+ await writeJsonFile(profilePath, profile);
2434
+ return profilePath;
2435
+ }
2350
2436
  function renderPublisherAccessRecord(record) {
2351
2437
  const envSummary = record.missingEnv.length > 0
2352
2438
  ? `missing ${record.missingEnv.join(", ")}`
@@ -2381,12 +2467,6 @@ function renderPublisherAccessRecords(title, records, capabilityPath) {
2381
2467
  }
2382
2468
  return lines.join("\n");
2383
2469
  }
2384
- async function saveSearchCapabilityRecords(records) {
2385
- const snapshotPath = searchCapabilitySnapshotPath();
2386
- await mkdir(dirname(snapshotPath), { recursive: true });
2387
- await writeJsonFile(snapshotPath, buildSearchCapabilitySnapshot(records, env));
2388
- return snapshotPath;
2389
- }
2390
2470
  async function probeAllPublishers(doi) {
2391
2471
  const records = [];
2392
2472
  for (const publisher of publisherConfigs()) {
@@ -2398,9 +2478,48 @@ async function probeAllPublishers(doi) {
2398
2478
  }
2399
2479
  return records;
2400
2480
  }
2401
- async function runSearchProbe(args) {
2481
+ function renderAccessReadinessProfile(profile, profilePath = accessReadinessPath()) {
2482
+ const lines = [
2483
+ "LongTable Scholarly Access Readiness",
2484
+ `- profile: ${profilePath}`,
2485
+ `- readiness: ${profile.readiness}`,
2486
+ `- routes: ${profile.routes.length > 0 ? profile.routes.join(", ") : "none"}`,
2487
+ `- metadata sources: ${profile.metadataSources.join(", ")}`,
2488
+ `- OA-only: ${profile.oaOnly ? "yes" : "no"}`,
2489
+ `- manual PDF allowed: ${profile.manualPdfAllowed ? "yes" : "no"}`,
2490
+ `- publisher/TDM: ${profile.publisherTdm}`,
2491
+ `- stores secrets: ${profile.storesSecrets ? "yes" : "no"}`,
2492
+ `- checkpoint before search: ${profile.requiresCheckpointBeforeSearch ? "yes" : "no"}`,
2493
+ `- checkpoint before full text: ${profile.requiresCheckpointBeforeFullText ? "yes" : "no"}`
2494
+ ];
2495
+ if (profile.institutionalAccess) {
2496
+ lines.push(`- institutional access: ${profile.institutionalAccess.mode}; verified: no`);
2497
+ lines.push(` note: ${profile.institutionalAccess.note}`);
2498
+ }
2499
+ if (profile.publisherAccess) {
2500
+ lines.push(`- contact email: ${profile.publisherAccess.contactEmailPresent ? "present" : "missing"}`);
2501
+ lines.push(`- publisher adapters: ${profile.publisherAccess.records.length}`);
2502
+ }
2503
+ return lines.join("\n");
2504
+ }
2505
+ function renderAccessDoctor(profile, records, profilePath) {
2506
+ const metadataCapabilities = assessSearchSourceCapabilities([...SEARCH_SOURCES], env);
2507
+ const lines = [
2508
+ "LongTable Scholarly Access Doctor",
2509
+ `- readiness profile: ${profile ? "present" : "missing"} (${profilePath})`,
2510
+ `- metadata sources: ${metadataCapabilities.map((capability) => `${capability.source}:${capability.enabled ? "available" : "needs_config"}`).join(", ")}`,
2511
+ "- institutional access: LongTable cannot verify VPN/proxy/SSO until the researcher logs in.",
2512
+ "- secrets: LongTable stores env var names and capability status only, never credential values.",
2513
+ renderPublisherAccessRecords("Publisher API/TDM adapters", records)
2514
+ ];
2515
+ if (!profile) {
2516
+ lines.push("- next: run `longtable access setup` before PDF collection or full-text extraction.");
2517
+ }
2518
+ return lines.join("\n");
2519
+ }
2520
+ async function runAccessProbe(args) {
2402
2521
  if (typeof args.doi !== "string" || !args.doi.trim()) {
2403
- throw new Error("`longtable search probe` requires --doi <doi>.");
2522
+ throw new Error("`longtable access probe` requires --doi <doi>.");
2404
2523
  }
2405
2524
  const publisher = parsePublisherTarget(args.publisher);
2406
2525
  const record = await probePublisherAccess({
@@ -2416,7 +2535,7 @@ async function runSearchProbe(args) {
2416
2535
  }
2417
2536
  return [record];
2418
2537
  }
2419
- async function runSearchDoctor(args) {
2538
+ async function collectAccessDoctorRecords(args) {
2420
2539
  let records;
2421
2540
  if (typeof args.doi === "string" && args.doi.trim()) {
2422
2541
  if (args.publisher === "all") {
@@ -2434,104 +2553,148 @@ async function runSearchDoctor(args) {
2434
2553
  else {
2435
2554
  records = summarizeConfiguredPublisherAccess(env);
2436
2555
  }
2437
- const snapshotPath = searchCapabilitySnapshotPath();
2438
- const snapshotExists = existsSync(snapshotPath);
2556
+ return records;
2557
+ }
2558
+ async function runAccessDoctor(args) {
2559
+ const records = await collectAccessDoctorRecords(args);
2560
+ const profilePath = accessReadinessPath();
2561
+ const profile = readAccessReadinessProfile();
2439
2562
  if (args.json === true) {
2440
2563
  console.log(JSON.stringify({
2441
- capabilityFile: snapshotPath,
2442
- capabilityFileExists: snapshotExists,
2564
+ readinessFile: profilePath,
2565
+ readinessFileExists: Boolean(profile),
2566
+ readiness: profile,
2567
+ metadataSources: assessSearchSourceCapabilities([...SEARCH_SOURCES], env),
2443
2568
  records
2444
2569
  }, null, 2));
2445
2570
  }
2446
2571
  else {
2447
- console.log(renderPublisherAccessRecords("LongTable Search Publisher Access Doctor", records, snapshotPath));
2448
- if (!snapshotExists) {
2449
- console.log("- saved capabilities: none yet; run `longtable search setup` to record non-secret capability status.");
2450
- }
2572
+ console.log(renderAccessDoctor(profile, records, profilePath));
2451
2573
  }
2452
2574
  return records;
2453
2575
  }
2454
- async function promptPublisherDoi(rl, label, defaultDoi) {
2455
- const prompt = defaultDoi
2456
- ? `${label} test DOI [${defaultDoi}, Enter to reuse, skip to skip]: `
2457
- : `${label} test DOI (Enter to skip): `;
2458
- const answer = (await rl.question(prompt)).trim();
2459
- if (!answer && defaultDoi) {
2460
- return defaultDoi;
2461
- }
2462
- if (!answer || /^skip$/i.test(answer)) {
2463
- return undefined;
2576
+ async function publisherRecordsForAccessSetup(args, routes) {
2577
+ const defaultDoi = typeof args.doi === "string" ? args.doi : undefined;
2578
+ if (!routes.includes("publisher_tdm")) {
2579
+ return summarizeConfiguredPublisherAccess(env);
2464
2580
  }
2465
- return answer;
2466
- }
2467
- async function runInteractiveSearchSetup(defaultDoi) {
2468
- const rl = createInterface({ input, output });
2469
- const records = [];
2470
- try {
2471
- console.log("LongTable publisher access setup");
2472
- console.log("LongTable does not store API keys or TDM tokens. It reads environment variables and records only non-secret capability results.");
2473
- console.log("");
2474
- for (const publisher of publisherConfigs()) {
2475
- console.log(`${publisher.label}`);
2476
- console.log(` required env: ${publisher.requiredEnv.join(", ")}`);
2477
- if (publisher.optionalEnv.length > 0) {
2478
- console.log(` optional env: ${publisher.optionalEnv.join(", ")}`);
2479
- }
2480
- console.log(` ${publisher.setupHint}`);
2481
- const doi = await promptPublisherDoi(rl, publisher.label, defaultDoi);
2482
- if (doi) {
2483
- records.push(await probePublisherAccess({
2484
- doi,
2485
- publisher: publisher.publisher,
2486
- env
2487
- }));
2488
- }
2489
- else {
2490
- const summary = summarizeConfiguredPublisherAccess(env)
2491
- .find((record) => record.publisher === publisher.publisher);
2492
- if (summary) {
2493
- records.push(summary);
2494
- }
2495
- }
2496
- console.log(renderPublisherAccessRecord(records[records.length - 1]));
2497
- console.log("");
2498
- }
2581
+ if (defaultDoi) {
2582
+ return probeAllPublishers(defaultDoi);
2499
2583
  }
2500
- finally {
2501
- rl.close();
2584
+ if (input.isTTY && output.isTTY && args.json !== true) {
2585
+ const doi = await promptText("Optional DOI for publisher/TDM probing. Leave blank to record env-var readiness only.", false);
2586
+ return doi
2587
+ ? await probeAllPublishers(doi)
2588
+ : summarizeConfiguredPublisherAccess(env);
2502
2589
  }
2503
- return records;
2590
+ return summarizeConfiguredPublisherAccess(env);
2504
2591
  }
2505
- async function runSearchSetup(args) {
2506
- const defaultDoi = typeof args.doi === "string" ? args.doi : undefined;
2507
- const records = input.isTTY && output.isTTY && args.json !== true
2508
- ? await runInteractiveSearchSetup(defaultDoi)
2509
- : defaultDoi
2510
- ? await probeAllPublishers(defaultDoi)
2511
- : summarizeConfiguredPublisherAccess(env);
2512
- const snapshotPath = await saveSearchCapabilityRecords(records);
2592
+ async function runInteractiveAccessSetup(args) {
2593
+ const readiness = await promptChoice("Scholarly Access Readiness\n\nWill this machine/account use scholarly search or full-text access?", [
2594
+ { id: "configured", label: "Configure now", description: "Record access capability without storing secrets." },
2595
+ { id: "deferred", label: "Ask later", description: "Defer setup and require an access checkpoint before search or extraction." },
2596
+ { id: "disabled", label: "Do not use", description: "This project will use metadata/manual notes without scholarly full-text access." }
2597
+ ]);
2598
+ if (readiness !== "configured") {
2599
+ return buildAccessReadinessProfile({ readiness, routes: [] });
2600
+ }
2601
+ const routeSelections = await promptMultiChoice("Select every scholarly access route that is available or intended. Secrets are never stored.", [
2602
+ { id: "metadata", label: "Open metadata", description: "Crossref, OpenAlex, Semantic Scholar, PubMed, ERIC, DOAJ, Unpaywall." },
2603
+ { id: "oa_full_text", label: "OA full text", description: "Use open-access PDF/full-text when legally available." },
2604
+ { id: "institutional", label: "Institutional access", description: "VPN, library proxy, or browser SSO handled by the researcher." },
2605
+ { id: "publisher_tdm", label: "Publisher API/TDM", description: "Use configured publisher API/TDM environment variables." },
2606
+ { id: "manual_pdf", label: "Manual PDFs", description: "Researcher supplies PDFs; LongTable organizes/probes allowed extraction." },
2607
+ { id: "unknown", label: "Unknown", description: "Keep access uncertain and require a checkpoint before full-text work." }
2608
+ ]);
2609
+ const routes = uniqueAccessRoutes(routeSelections.length > 0 ? routeSelections : ["metadata"]);
2610
+ const institutionalAccessMode = routes.includes("institutional")
2611
+ ? await promptChoice("How will institutional access be completed? The researcher handles login/MFA directly.", [
2612
+ { id: "vpn", label: "VPN", description: "Researcher connects through school or institutional VPN." },
2613
+ { id: "library_proxy", label: "Library proxy", description: "Researcher uses proxy links or library resolver." },
2614
+ { id: "browser_sso", label: "Browser SSO", description: "Researcher logs into library/publisher SSO in the browser." },
2615
+ { id: "unknown", label: "Unknown", description: "The route exists but is not yet specified." }
2616
+ ])
2617
+ : undefined;
2618
+ const publisherRecords = await publisherRecordsForAccessSetup(args, routes);
2619
+ return buildAccessReadinessProfile({
2620
+ readiness,
2621
+ routes,
2622
+ institutionalAccessMode,
2623
+ publisherRecords
2624
+ });
2625
+ }
2626
+ async function runAccessSetup(args) {
2627
+ const records = typeof args.doi === "string" && args.doi.trim()
2628
+ ? await probeAllPublishers(args.doi)
2629
+ : summarizeConfiguredPublisherAccess(env);
2630
+ const profile = input.isTTY && output.isTTY && args.json !== true
2631
+ ? await runInteractiveAccessSetup(args)
2632
+ : buildAccessReadinessProfile({
2633
+ readiness: "configured",
2634
+ routes: inferAccessRoutes(records),
2635
+ publisherRecords: records
2636
+ });
2637
+ const profilePath = await saveAccessReadinessProfile(profile);
2513
2638
  if (args.json === true) {
2514
2639
  console.log(JSON.stringify({
2515
- capabilityFile: snapshotPath,
2516
- snapshot: buildSearchCapabilitySnapshot(records, env)
2640
+ readinessFile: profilePath,
2641
+ profile
2517
2642
  }, null, 2));
2518
2643
  return;
2519
2644
  }
2520
- console.log(renderPublisherAccessRecords("LongTable Search Publisher Access Setup", records, snapshotPath));
2645
+ console.log(renderAccessReadinessProfile(profile, profilePath));
2521
2646
  }
2522
- async function runSearch(subcommand, args) {
2523
- if (subcommand === "probe") {
2524
- await runSearchProbe(args);
2647
+ async function runAccessStatus(args) {
2648
+ const profilePath = accessReadinessPath();
2649
+ const profile = readAccessReadinessProfile();
2650
+ if (args.json === true) {
2651
+ console.log(JSON.stringify({
2652
+ readinessFile: profilePath,
2653
+ readinessFileExists: Boolean(profile),
2654
+ readiness: profile
2655
+ }, null, 2));
2525
2656
  return;
2526
2657
  }
2527
- if (subcommand === "doctor" || subcommand === "status") {
2528
- await runSearchDoctor(args);
2658
+ if (!profile) {
2659
+ console.log([
2660
+ "LongTable Scholarly Access Readiness",
2661
+ `- profile: missing (${profilePath})`,
2662
+ "- next: run `longtable access setup` before PDF collection or full-text extraction."
2663
+ ].join("\n"));
2529
2664
  return;
2530
2665
  }
2666
+ console.log(renderAccessReadinessProfile(profile, profilePath));
2667
+ }
2668
+ async function runAccess(subcommand, args) {
2531
2669
  if (subcommand === "setup") {
2532
- await runSearchSetup(args);
2670
+ await runAccessSetup(args);
2671
+ return;
2672
+ }
2673
+ if (subcommand === "status") {
2674
+ await runAccessStatus(args);
2675
+ return;
2676
+ }
2677
+ if (subcommand === "doctor") {
2678
+ await runAccessDoctor(args);
2533
2679
  return;
2534
2680
  }
2681
+ if (subcommand === "probe") {
2682
+ await runAccessProbe(args);
2683
+ return;
2684
+ }
2685
+ if (!subcommand) {
2686
+ await runAccessStatus(args);
2687
+ return;
2688
+ }
2689
+ throw new Error(`Unknown access subcommand: ${subcommand}`);
2690
+ }
2691
+ function movedSearchAccessCommand(subcommand) {
2692
+ return new Error(`\`longtable search ${subcommand}\` has moved. Use \`longtable access ${subcommand}\`.`);
2693
+ }
2694
+ async function runSearch(subcommand, args) {
2695
+ if (subcommand === "setup" || subcommand === "doctor" || subcommand === "status" || subcommand === "probe") {
2696
+ throw movedSearchAccessCommand(subcommand);
2697
+ }
2535
2698
  if (subcommand) {
2536
2699
  throw new Error(`Unknown search subcommand: ${subcommand}`);
2537
2700
  }
@@ -3524,6 +3687,10 @@ async function main() {
3524
3687
  await runMcpSubcommand(subcommand, values);
3525
3688
  return;
3526
3689
  }
3690
+ if (command === "access") {
3691
+ await runAccess(subcommand, values);
3692
+ return;
3693
+ }
3527
3694
  if (command === "search") {
3528
3695
  await runSearch(subcommand, values);
3529
3696
  return;
@@ -131,6 +131,11 @@ function looksLikeResearchCommitmentPrompt(prompt) {
131
131
  /\b(change|revise|update|replace|reframe|modify|alter)\b/i.test(prompt) ||
132
132
  /바꾸|변경|수정|교체|전환|재설정/.test(prompt));
133
133
  }
134
+ function looksLikeAccessSensitiveResearchAction(prompt) {
135
+ const normalized = prompt.trim();
136
+ return /\b(pdf|full[- ]?text|tdm|publisher api|institutional access|library login|vpn|proxy|subscription|paper collection|source collection|corpus|download)\b/i.test(normalized)
137
+ || /PDF|원문|전문|기관\s*구독|기관구독|구독|VPN|프록시|도서관|라이브러리|TDM|논문\s*수집|문헌\s*수집|코퍼스|다운로드/.test(normalized);
138
+ }
134
139
  function looksLikeQuestionGenerationPrompt(prompt) {
135
140
  return /\b(needed questions?|necessary questions?|question generation|clarifying questions?|ask questions?)\b/i.test(prompt)
136
141
  || /필요한\s*질문|질문을\s*(모두|많이|생성)|질문\s*생성|물어봐|질문해/.test(prompt);
@@ -184,11 +189,12 @@ function shouldSurfaceInterviewContext(prompt) {
184
189
  return looksLikeExplicitInterviewPrompt(prompt) || looksLikeResearchStateConfirmationPrompt(prompt);
185
190
  }
186
191
  function shouldCreateRequiredQuestionsForPrompt(prompt) {
187
- return !looksLikeLongTableProductOrToolingPrompt(prompt) && looksLikeResearchCommitmentPrompt(prompt);
192
+ return !looksLikeLongTableProductOrToolingPrompt(prompt) &&
193
+ (looksLikeResearchCommitmentPrompt(prompt) || looksLikeAccessSensitiveResearchAction(prompt));
188
194
  }
189
195
  function shouldApplyProtectedDecisionClosure(runtime, prompt) {
190
196
  return Boolean(runtime.context.session.protectedDecision) &&
191
- shouldCreateRequiredQuestionsForPrompt(prompt) &&
197
+ looksLikeResearchCommitmentPrompt(prompt) &&
192
198
  !looksLikeQuestionGenerationPrompt(prompt) &&
193
199
  !looksLikeMultiCommitmentChangePrompt(prompt);
194
200
  }
@@ -34,6 +34,58 @@ export interface FirstResearchShape {
34
34
  sourceHookId?: string;
35
35
  confirmedAt?: string;
36
36
  }
37
+ export interface ResearchSpecification {
38
+ title: string;
39
+ status?: "draft" | "confirmed" | "deferred";
40
+ createdAt?: string;
41
+ updatedAt?: string;
42
+ sourceHookId?: string;
43
+ researchDirection: {
44
+ question?: string;
45
+ purpose: string;
46
+ scopeBoundary?: string;
47
+ inclusionCriteria?: string[];
48
+ exclusionCriteria?: string[];
49
+ };
50
+ constructOntology: {
51
+ coreConstructs: string[];
52
+ distinctions: string[];
53
+ termsToAvoidCollapsing?: string[];
54
+ };
55
+ theoryAndFraming: {
56
+ anchors: string[];
57
+ alternatives?: string[];
58
+ overreachRisks?: string[];
59
+ };
60
+ measurementCoding: {
61
+ variablesOrConstructs: string[];
62
+ evidenceTypes: string[];
63
+ codingRules: string[];
64
+ openStandards?: string[];
65
+ };
66
+ methodAnalysis: {
67
+ design?: string;
68
+ analysisOptions: string[];
69
+ dataSufficiencyCriteria?: string[];
70
+ unsettledChoices?: string[];
71
+ };
72
+ evidenceAccess: {
73
+ requiredSources?: string[];
74
+ accessRequirements?: string[];
75
+ evidenceStandards?: string[];
76
+ };
77
+ epistemicAlignment: {
78
+ researcherKnowledge?: string[];
79
+ projectStatePriority?: string[];
80
+ aiInferenceLimits?: string[];
81
+ conflictResolutionRule?: string;
82
+ };
83
+ protectedDecisions: string[];
84
+ openQuestions: string[];
85
+ nextActions: string[];
86
+ confidence: "low" | "medium" | "high";
87
+ confirmedAt?: string;
88
+ }
37
89
  export interface LongTableInterviewTurn {
38
90
  id: string;
39
91
  index: number;
@@ -59,6 +111,7 @@ export interface LongTableHookRun {
59
111
  provider?: ProviderKind;
60
112
  turns?: LongTableInterviewTurn[];
61
113
  firstResearchShape?: FirstResearchShape;
114
+ researchSpecification?: ResearchSpecification;
62
115
  qualityNotes?: string[];
63
116
  rationale?: string[];
64
117
  linkedQuestionRecordIds?: string[];
@@ -67,6 +120,7 @@ export interface LongTableHookRun {
67
120
  export type LongTableWorkspaceState = ResearchState & {
68
121
  hooks?: LongTableHookRun[];
69
122
  firstResearchShape?: FirstResearchShape;
123
+ researchSpecification?: ResearchSpecification;
70
124
  };
71
125
  export interface LongTableProjectRecord {
72
126
  schemaVersion: 1;
@@ -102,6 +156,7 @@ export interface LongTableSessionRecord {
102
156
  openQuestions?: string[];
103
157
  startInterview?: StartInterviewSession;
104
158
  firstResearchShape?: FirstResearchShape;
159
+ researchSpecification?: ResearchSpecification;
105
160
  requestedPerspectives: string[];
106
161
  disagreementPreference: ProjectDisagreementPreference;
107
162
  activeModes?: string[];
@@ -132,6 +187,11 @@ export interface LongTableWorkspaceInspection {
132
187
  currentBlocker?: string;
133
188
  requestedPerspectives: string[];
134
189
  disagreementPreference: ProjectDisagreementPreference;
190
+ researchSpecification?: {
191
+ title: string;
192
+ status: "draft" | "confirmed" | "deferred";
193
+ confidence: "low" | "medium" | "high";
194
+ };
135
195
  };
136
196
  files?: {
137
197
  project: string;
@@ -224,6 +284,16 @@ export declare function summarizeLongTableInterview(options: {
224
284
  state: LongTableWorkspaceState;
225
285
  session: LongTableSessionRecord;
226
286
  }>;
287
+ export declare function summarizeLongTableResearchSpecification(options: {
288
+ context: LongTableProjectContext;
289
+ hookId?: string;
290
+ specification: ResearchSpecification;
291
+ }): Promise<{
292
+ hook?: LongTableHookRun;
293
+ specification: ResearchSpecification;
294
+ state: LongTableWorkspaceState;
295
+ session: LongTableSessionRecord;
296
+ }>;
227
297
  export declare function listBlockingWorkspaceQuestions(context: LongTableProjectContext): Promise<QuestionRecord[]>;
228
298
  export declare function listBlockingWorkspaceObligations(context: LongTableProjectContext): Promise<LongTableQuestionObligation[]>;
229
299
  export declare function assertWorkspaceNotBlocked(context: LongTableProjectContext): Promise<void>;
@@ -94,6 +94,9 @@ function buildNextAction(session) {
94
94
  : "Open with your current goal in one sentence, then ask LongTable for the first concrete research move.";
95
95
  }
96
96
  function buildResumeHint(session) {
97
+ if (session.researchSpecification) {
98
+ return `I want to continue from the Research Specification: ${session.researchSpecification.title}.`;
99
+ }
97
100
  if (session.firstResearchShape) {
98
101
  return `I want to continue from the First Research Shape: ${session.firstResearchShape.handle}.`;
99
102
  }
@@ -106,6 +109,60 @@ function buildResumeHint(session) {
106
109
  ? `I want to continue ${session.currentGoal}. The unresolved blocker is ${session.currentBlocker}.`
107
110
  : `I want to continue ${session.currentGoal}.`;
108
111
  }
112
+ function renderResearchSpecificationSummary(specification, locale) {
113
+ const korean = locale === "ko";
114
+ const lines = [
115
+ "",
116
+ korean ? "## Research Specification" : "## Research Specification",
117
+ `- ${korean ? "제목" : "Title"}: ${specification.title}`,
118
+ `- ${korean ? "상태" : "Status"}: ${specification.confirmedAt ? "confirmed" : specification.status ?? "draft"}`,
119
+ `- ${korean ? "신뢰도" : "Confidence"}: ${specification.confidence}`
120
+ ];
121
+ if (specification.researchDirection.question) {
122
+ lines.push(`- ${korean ? "연구 질문" : "Question"}: ${specification.researchDirection.question}`);
123
+ }
124
+ lines.push(`- ${korean ? "목적" : "Purpose"}: ${specification.researchDirection.purpose}`);
125
+ if (specification.researchDirection.scopeBoundary) {
126
+ lines.push(`- ${korean ? "범위 경계" : "Scope boundary"}: ${specification.researchDirection.scopeBoundary}`);
127
+ }
128
+ if (specification.constructOntology.coreConstructs.length > 0) {
129
+ lines.push(`- ${korean ? "핵심 construct" : "Core constructs"}: ${specification.constructOntology.coreConstructs.join("; ")}`);
130
+ }
131
+ if (specification.constructOntology.distinctions.length > 0) {
132
+ lines.push(`- ${korean ? "구분해야 할 차이" : "Key distinctions"}: ${specification.constructOntology.distinctions.join("; ")}`);
133
+ }
134
+ if (specification.theoryAndFraming.anchors.length > 0) {
135
+ lines.push(`- ${korean ? "이론 앵커" : "Theory anchors"}: ${specification.theoryAndFraming.anchors.join("; ")}`);
136
+ }
137
+ if (specification.measurementCoding.codingRules.length > 0) {
138
+ lines.push(`- ${korean ? "코딩 규칙" : "Coding rules"}: ${specification.measurementCoding.codingRules.join("; ")}`);
139
+ }
140
+ if (specification.methodAnalysis.analysisOptions.length > 0) {
141
+ lines.push(`- ${korean ? "분석 옵션" : "Analysis options"}: ${specification.methodAnalysis.analysisOptions.join("; ")}`);
142
+ }
143
+ if (specification.evidenceAccess.requiredSources?.length) {
144
+ lines.push(`- ${korean ? "필요 근거원" : "Required sources"}: ${specification.evidenceAccess.requiredSources.join("; ")}`);
145
+ }
146
+ if (specification.evidenceAccess.accessRequirements?.length) {
147
+ lines.push(`- ${korean ? "Corpus and Access Plan" : "Corpus and Access Plan"}: ${specification.evidenceAccess.accessRequirements.join("; ")}`);
148
+ }
149
+ if (specification.evidenceAccess.evidenceStandards?.length) {
150
+ lines.push(`- ${korean ? "근거 기준" : "Evidence standards"}: ${specification.evidenceAccess.evidenceStandards.join("; ")}`);
151
+ }
152
+ if (specification.epistemicAlignment.conflictResolutionRule) {
153
+ lines.push(`- ${korean ? "충돌 조정 규칙" : "Conflict rule"}: ${specification.epistemicAlignment.conflictResolutionRule}`);
154
+ }
155
+ if (specification.protectedDecisions.length > 0) {
156
+ lines.push(...specification.protectedDecisions.map((decision) => `- ${korean ? "보호할 결정" : "Protected decision"}: ${decision}`));
157
+ }
158
+ if (specification.openQuestions.length > 0) {
159
+ lines.push(...specification.openQuestions.map((question) => `- ${korean ? "열린 질문" : "Open question"}: ${question}`));
160
+ }
161
+ if (specification.nextActions.length > 0) {
162
+ lines.push(...specification.nextActions.map((action) => `- ${korean ? "다음 행동" : "Next action"}: ${action}`));
163
+ }
164
+ return lines;
165
+ }
109
166
  function buildCurrentGuide(project, session, recentInvocations = [], pendingQuestions = [], pendingObligations = []) {
110
167
  const locale = normalizeLocale(session.locale ?? project.locale);
111
168
  const openQuestions = session.openQuestions && session.openQuestions.length > 0
@@ -129,6 +186,7 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
129
186
  ...(session.gapRisk ? [`- 공백/암묵지 위험: ${session.gapRisk}`] : []),
130
187
  ...(session.protectedDecision ? [`- 보호할 결정: ${session.protectedDecision}`] : []),
131
188
  ...(session.firstResearchShape ? [`- First Research Shape: ${session.firstResearchShape.handle}`] : []),
189
+ ...(session.researchSpecification ? [`- Research Specification: ${session.researchSpecification.title}`] : []),
132
190
  ...(session.startInterview ? [`- start interview: ${session.startInterview.summary}`] : []),
133
191
  `- 다음 액션: ${nextAction}`,
134
192
  `- 관점: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
@@ -179,6 +237,7 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
179
237
  ...session.firstResearchShape.openQuestions.map((question) => `- Open question: ${question}`)
180
238
  ]
181
239
  : []),
240
+ ...(session.researchSpecification ? renderResearchSpecificationSummary(session.researchSpecification, locale) : []),
182
241
  "",
183
242
  "## 빠른 시작",
184
243
  "- 이 디렉토리에서 `codex`를 엽니다.",
@@ -202,6 +261,7 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
202
261
  ...(session.gapRisk ? [`- Gap/tacit risk: ${session.gapRisk}`] : []),
203
262
  ...(session.protectedDecision ? [`- Protected decision: ${session.protectedDecision}`] : []),
204
263
  ...(session.firstResearchShape ? [`- First Research Shape: ${session.firstResearchShape.handle}`] : []),
264
+ ...(session.researchSpecification ? [`- Research Specification: ${session.researchSpecification.title}`] : []),
205
265
  ...(session.startInterview ? [`- Start interview: ${session.startInterview.summary}`] : []),
206
266
  `- Next action: ${nextAction}`,
207
267
  `- Perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
@@ -252,6 +312,7 @@ function buildCurrentGuide(project, session, recentInvocations = [], pendingQues
252
312
  ...session.firstResearchShape.openQuestions.map((question) => `- Open question: ${question}`)
253
313
  ]
254
314
  : []),
315
+ ...(session.researchSpecification ? renderResearchSpecificationSummary(session.researchSpecification, locale) : []),
255
316
  "",
256
317
  "## Quick Start",
257
318
  "- Open `codex` in this directory.",
@@ -272,6 +333,7 @@ async function loadResearchState(stateFilePath) {
272
333
  workingState: parsed.workingState ?? {},
273
334
  hooks: parsed.hooks ?? [],
274
335
  ...(parsed.firstResearchShape ? { firstResearchShape: parsed.firstResearchShape } : {}),
336
+ ...(parsed.researchSpecification ? { researchSpecification: parsed.researchSpecification } : {}),
275
337
  questionObligations: parsed.questionObligations ?? [],
276
338
  inferredHypotheses: parsed.inferredHypotheses ?? [],
277
339
  openTensions: parsed.openTensions ?? [],
@@ -332,7 +394,18 @@ function summarizeWorkspaceInspection(context, state) {
332
394
  currentGoal: context.session.currentGoal,
333
395
  ...(context.session.currentBlocker ? { currentBlocker: context.session.currentBlocker } : {}),
334
396
  requestedPerspectives: context.session.requestedPerspectives,
335
- disagreementPreference: context.session.disagreementPreference
397
+ disagreementPreference: context.session.disagreementPreference,
398
+ ...(context.session.researchSpecification
399
+ ? {
400
+ researchSpecification: {
401
+ title: context.session.researchSpecification.title,
402
+ status: context.session.researchSpecification.confirmedAt
403
+ ? "confirmed"
404
+ : context.session.researchSpecification.status ?? "draft",
405
+ confidence: context.session.researchSpecification.confidence
406
+ }
407
+ }
408
+ : {})
336
409
  },
337
410
  files: {
338
411
  project: context.projectFilePath,
@@ -422,6 +495,7 @@ function buildProjectAgentsMd(project, session) {
422
495
  "- Begin exploratory work with clarifying or tension questions before recommending a direction.",
423
496
  "- For `$longtable-interview`, ask one natural-language question at a time, reflect with `LongTable hears: ...`, record turns when MCP is available, and avoid early reader/reviewer or theory/method/measurement classification.",
424
497
  "- Do not summarize `$longtable-interview` because a fixed number of turns has passed; wait for content-based readiness around research object, focal uncertainty, boundary, evidence/material, protected decision, and next action.",
498
+ "- After the First Research Shape, create a Research Specification when the interview has enough detail to preserve scope, construct ontology, theory framing, coding rules, method options, evidence/access requirements, epistemic alignment, protected decisions, open questions, and next actions.",
425
499
  "- Do not let unrelated pending Researcher Checkpoints interrupt `$longtable-interview`; mention them only as separate unresolved checkpoints unless the researcher is confirming, saving, or recording a research decision.",
426
500
  "- Use structured options only at the final First Research Shape confirmation or at true checkpoint boundaries.",
427
501
  "- If you foreground role perspectives, disclose them with `LongTable consulted: ...`.",
@@ -441,6 +515,7 @@ function buildProjectAgentsMd(project, session) {
441
515
  ...(session.gapRisk ? [`- Gap/tacit risk: ${session.gapRisk}`] : []),
442
516
  ...(session.protectedDecision ? [`- Protected decision: ${session.protectedDecision}`] : []),
443
517
  ...(session.firstResearchShape ? [`- First Research Shape: ${session.firstResearchShape.handle}`] : []),
518
+ ...(session.researchSpecification ? [`- Research Specification: ${session.researchSpecification.title}`] : []),
444
519
  ...(session.startInterview ? [`- Start interview summary: ${session.startInterview.summary}`] : []),
445
520
  `- Requested perspectives: ${session.requestedPerspectives.length > 0 ? session.requestedPerspectives.join(", ") : "auto"}`,
446
521
  `- Disagreement visibility: ${session.disagreementPreference}`,
@@ -473,6 +548,10 @@ function buildStateSeed(project, session, setup) {
473
548
  state.firstResearchShape = session.firstResearchShape;
474
549
  state.workingState.firstResearchShape = session.firstResearchShape;
475
550
  }
551
+ if (session.researchSpecification) {
552
+ state.researchSpecification = session.researchSpecification;
553
+ state.workingState.researchSpecification = session.researchSpecification;
554
+ }
476
555
  if (session.currentBlocker) {
477
556
  state.openTensions.push(session.currentBlocker);
478
557
  }
@@ -543,7 +622,16 @@ async function removeLegacyRootFiles(projectPath) {
543
622
  }
544
623
  export async function syncCurrentWorkspaceView(context) {
545
624
  const state = await loadResearchState(context.stateFilePath);
546
- const body = buildCurrentGuide(context.project, context.session, recentInvocationRecords(state), recentPendingQuestions(state), recentPendingObligations(state));
625
+ const session = {
626
+ ...context.session,
627
+ ...(context.session.firstResearchShape ?? state.firstResearchShape
628
+ ? { firstResearchShape: context.session.firstResearchShape ?? state.firstResearchShape }
629
+ : {}),
630
+ ...(context.session.researchSpecification ?? state.researchSpecification
631
+ ? { researchSpecification: context.session.researchSpecification ?? state.researchSpecification }
632
+ : {})
633
+ };
634
+ const body = buildCurrentGuide(context.project, session, recentInvocationRecords(state), recentPendingQuestions(state), recentPendingObligations(state));
547
635
  await writeFile(context.currentFilePath, body, "utf8");
548
636
  return context.currentFilePath;
549
637
  }
@@ -772,6 +860,122 @@ export async function summarizeLongTableInterview(options) {
772
860
  await syncCurrentWorkspaceView(options.context);
773
861
  return { hook, shape, state: updated, session };
774
862
  }
863
+ function normalizeStringArray(values) {
864
+ return (values ?? []).map((value) => value.trim()).filter(Boolean);
865
+ }
866
+ function normalizeOptionalString(value) {
867
+ const trimmed = value?.trim();
868
+ return trimmed && trimmed.length > 0 ? trimmed : undefined;
869
+ }
870
+ function normalizeResearchSpecification(input, sourceHookId, timestamp) {
871
+ const title = input.title.trim();
872
+ if (!title) {
873
+ throw new Error("Research Specification title is required.");
874
+ }
875
+ const purpose = input.researchDirection.purpose.trim();
876
+ if (!purpose) {
877
+ throw new Error("Research Specification researchDirection.purpose is required.");
878
+ }
879
+ return {
880
+ title,
881
+ status: input.confirmedAt ? "confirmed" : input.status ?? "draft",
882
+ createdAt: input.createdAt ?? timestamp,
883
+ updatedAt: timestamp,
884
+ ...(sourceHookId ? { sourceHookId } : {}),
885
+ researchDirection: {
886
+ ...(normalizeOptionalString(input.researchDirection.question) ? { question: normalizeOptionalString(input.researchDirection.question) } : {}),
887
+ purpose,
888
+ ...(normalizeOptionalString(input.researchDirection.scopeBoundary) ? { scopeBoundary: normalizeOptionalString(input.researchDirection.scopeBoundary) } : {}),
889
+ inclusionCriteria: normalizeStringArray(input.researchDirection.inclusionCriteria),
890
+ exclusionCriteria: normalizeStringArray(input.researchDirection.exclusionCriteria)
891
+ },
892
+ constructOntology: {
893
+ coreConstructs: normalizeStringArray(input.constructOntology.coreConstructs),
894
+ distinctions: normalizeStringArray(input.constructOntology.distinctions),
895
+ termsToAvoidCollapsing: normalizeStringArray(input.constructOntology.termsToAvoidCollapsing)
896
+ },
897
+ theoryAndFraming: {
898
+ anchors: normalizeStringArray(input.theoryAndFraming.anchors),
899
+ alternatives: normalizeStringArray(input.theoryAndFraming.alternatives),
900
+ overreachRisks: normalizeStringArray(input.theoryAndFraming.overreachRisks)
901
+ },
902
+ measurementCoding: {
903
+ variablesOrConstructs: normalizeStringArray(input.measurementCoding.variablesOrConstructs),
904
+ evidenceTypes: normalizeStringArray(input.measurementCoding.evidenceTypes),
905
+ codingRules: normalizeStringArray(input.measurementCoding.codingRules),
906
+ openStandards: normalizeStringArray(input.measurementCoding.openStandards)
907
+ },
908
+ methodAnalysis: {
909
+ ...(normalizeOptionalString(input.methodAnalysis.design) ? { design: normalizeOptionalString(input.methodAnalysis.design) } : {}),
910
+ analysisOptions: normalizeStringArray(input.methodAnalysis.analysisOptions),
911
+ dataSufficiencyCriteria: normalizeStringArray(input.methodAnalysis.dataSufficiencyCriteria),
912
+ unsettledChoices: normalizeStringArray(input.methodAnalysis.unsettledChoices)
913
+ },
914
+ evidenceAccess: {
915
+ requiredSources: normalizeStringArray(input.evidenceAccess.requiredSources),
916
+ accessRequirements: normalizeStringArray(input.evidenceAccess.accessRequirements),
917
+ evidenceStandards: normalizeStringArray(input.evidenceAccess.evidenceStandards)
918
+ },
919
+ epistemicAlignment: {
920
+ researcherKnowledge: normalizeStringArray(input.epistemicAlignment.researcherKnowledge),
921
+ projectStatePriority: normalizeStringArray(input.epistemicAlignment.projectStatePriority),
922
+ aiInferenceLimits: normalizeStringArray(input.epistemicAlignment.aiInferenceLimits),
923
+ ...(normalizeOptionalString(input.epistemicAlignment.conflictResolutionRule)
924
+ ? { conflictResolutionRule: normalizeOptionalString(input.epistemicAlignment.conflictResolutionRule) }
925
+ : {})
926
+ },
927
+ protectedDecisions: normalizeStringArray(input.protectedDecisions),
928
+ openQuestions: normalizeStringArray(input.openQuestions),
929
+ nextActions: normalizeStringArray(input.nextActions),
930
+ confidence: input.confidence,
931
+ ...(input.confirmedAt ? { confirmedAt: input.confirmedAt } : {})
932
+ };
933
+ }
934
+ export async function summarizeLongTableResearchSpecification(options) {
935
+ const state = await loadResearchState(options.context.stateFilePath);
936
+ const sourceHookId = options.hookId
937
+ ?? options.specification.sourceHookId
938
+ ?? state.firstResearchShape?.sourceHookId;
939
+ const existing = sourceHookId
940
+ ? (state.hooks ?? []).find((hook) => hook.id === sourceHookId)
941
+ : activeInterviewHook(state);
942
+ const timestamp = nowIso();
943
+ const specification = normalizeResearchSpecification(options.specification, existing?.id ?? sourceHookId, timestamp);
944
+ const hook = existing
945
+ ? {
946
+ ...existing,
947
+ status: "ready_to_confirm",
948
+ updatedAt: timestamp,
949
+ researchSpecification: specification
950
+ }
951
+ : undefined;
952
+ const session = {
953
+ ...options.context.session,
954
+ lastUpdatedAt: timestamp,
955
+ researchSpecification: specification,
956
+ resumeHint: `I want to continue from the Research Specification: ${specification.title}.`
957
+ };
958
+ options.context.session = session;
959
+ let updated = hook ? upsertHook(state, hook) : state;
960
+ updated.researchSpecification = specification;
961
+ updated.workingState = {
962
+ ...updated.workingState,
963
+ researchSpecification: specification
964
+ };
965
+ updated.narrativeTraces.push({
966
+ id: createId("narrative_trace"),
967
+ timestamp,
968
+ source: "$longtable-interview",
969
+ traceType: "judgment",
970
+ summary: `Research Specification draft: ${specification.title}.`,
971
+ visibility: "explicit",
972
+ importance: specification.confidence
973
+ });
974
+ await writeFile(options.context.sessionFilePath, JSON.stringify(session, null, 2), "utf8");
975
+ await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
976
+ await syncCurrentWorkspaceView(options.context);
977
+ return { hook, specification, state: updated, session };
978
+ }
775
979
  function findQuestionForDecision(state, questionId) {
776
980
  const pending = (state.questionLog ?? []).filter((record) => record.status === "pending");
777
981
  if (questionId) {
@@ -1024,8 +1228,8 @@ function questionPriority(spec) {
1024
1228
  const requiredWeight = spec.kind === "research_commitment" || spec.required ? 20 : 0;
1025
1229
  return (byKey[spec.key] ?? 0) + confidenceWeight + requiredWeight;
1026
1230
  }
1027
- function followUpQuestionOptions(first, second, third, fourth) {
1028
- return [first, second, third, ...(fourth ? [fourth] : [])];
1231
+ function followUpQuestionOptions(first, second, third, ...rest) {
1232
+ return [first, second, third, ...rest];
1029
1233
  }
1030
1234
  export function buildQuestionOpportunitySpecs(prompt, options = {}) {
1031
1235
  const normalized = prompt.toLowerCase();
@@ -1090,8 +1294,26 @@ export function buildQuestionOpportunitySpecs(prompt, options = {}) {
1090
1294
  /\brandom[- ]?effects\b/i,
1091
1295
  /분석\s*계획|분석\s*방법|메타\s*분석|분석\s*(?:모형|모델)|통계\s*(?:모형|모델)|구조\s*방정식|경로\s*모형|조절효과|랜덤\s*효과/
1092
1296
  ]);
1297
+ const accessCue = includesAny(normalized, [
1298
+ /\b(pdf|full[- ]?text|tdm|publisher api|institutional access|library login|vpn|proxy|subscription|paper collection|source collection|corpus|download)\b/i,
1299
+ /PDF|원문|전문|기관\s*구독|기관구독|구독|VPN|프록시|도서관|라이브러리|TDM|논문\s*수집|문헌\s*수집|코퍼스|다운로드/
1300
+ ]);
1093
1301
  const decisionFamilyCount = [scopeCue, theoryCue, measurementCodingCue, methodCue, analysisCue]
1094
1302
  .filter(Boolean).length;
1303
+ if (accessCue) {
1304
+ push({
1305
+ key: "scholarly_access_policy",
1306
+ kind: "evidence_risk",
1307
+ title: "Scholarly access policy",
1308
+ question: "What scholarly access route should LongTable use before collecting PDFs, full text, or subscription-only evidence?",
1309
+ whyNow: "Full-text access decisions can change the corpus, inclusion bias, reproducibility, and TDM permission boundary.",
1310
+ options: followUpQuestionOptions({ value: "oa_only", label: "OA-only", description: "Use only open-access PDF or full text.", recommended: true }, { value: "institutional_access", label: "Institutional access", description: "Include VPN/proxy/library-login access after the researcher completes login." }, { value: "publisher_tdm", label: "Publisher API/TDM", description: "Use configured publisher API/TDM credentials and record entitlement checks." }, { value: "manual_pdf", label: "Manual PDFs", description: "Use PDFs supplied by the researcher and record provenance." }, { value: "metadata_only", label: "Metadata only", description: "Do not collect full text yet." }),
1311
+ confidence: "high",
1312
+ autoEligible: true,
1313
+ required: true,
1314
+ cues: ["scholarly_access", "full_text", "corpus"]
1315
+ });
1316
+ }
1095
1317
  if (decisionActionCue && decisionFamilyCount >= 2) {
1096
1318
  push({
1097
1319
  key: "research_direction_change_commitment",
@@ -1424,7 +1646,7 @@ export function buildQuestionOpportunitySpecs(prompt, options = {}) {
1424
1646
  }
1425
1647
  let selected = options.autoOnly === true ? specs.filter((spec) => spec.autoEligible) : specs;
1426
1648
  if (options.requiredOnly === true) {
1427
- selected = selected.filter((spec) => spec.kind === "research_commitment");
1649
+ selected = selected.filter((spec) => spec.kind === "research_commitment" || spec.required);
1428
1650
  }
1429
1651
  if (normalized.includes("protected decision closure pressure")) {
1430
1652
  selected = selected.filter((spec) => spec.key === "protected_decision_closure");
@@ -1,4 +1,4 @@
1
- import { type CrossrefTdmDiscovery, type EvidenceCard, type Publisher, type PublisherAccessRecord, type PublisherProbeInput, type PublisherProbeTarget, type SearchCapabilitySnapshot, type SearchFetch } from "./types.js";
1
+ import { type CrossrefTdmDiscovery, type EvidenceCard, type Publisher, type PublisherAccessRecord, type PublisherProbeInput, type PublisherProbeTarget, type SearchFetch } from "./types.js";
2
2
  interface PublisherConfig {
3
3
  publisher: Publisher;
4
4
  label: string;
@@ -12,8 +12,6 @@ export declare function discoverCrossrefTdm(doi: string, env?: Record<string, st
12
12
  export declare function publisherConfigs(): PublisherConfig[];
13
13
  export declare function probePublisherAccess(input: PublisherProbeInput): Promise<PublisherAccessRecord>;
14
14
  export declare function summarizeConfiguredPublisherAccess(env?: Record<string, string | undefined>): PublisherAccessRecord[];
15
- export declare function buildSearchCapabilitySnapshot(records: PublisherAccessRecord[], env?: Record<string, string | undefined>): SearchCapabilitySnapshot;
16
- export declare function searchCapabilitySnapshotPath(home?: string): string;
17
15
  export declare function enrichCardsWithPublisherAccess(input: {
18
16
  cards: EvidenceCard[];
19
17
  env?: Record<string, string | undefined>;
@@ -1,5 +1,3 @@
1
- import { join } from "node:path";
2
- import { homedir } from "node:os";
3
1
  import { PUBLISHERS } from "./types.js";
4
2
  const PUBLISHER_CONFIGS = {
5
3
  elsevier: {
@@ -505,17 +503,6 @@ export function summarizeConfiguredPublisherAccess(env = process.env) {
505
503
  });
506
504
  });
507
505
  }
508
- export function buildSearchCapabilitySnapshot(records, env = process.env) {
509
- return {
510
- version: 1,
511
- updatedAt: now(),
512
- contactEmailPresent: Boolean(env.LONGTABLE_CONTACT_EMAIL?.trim()),
513
- records
514
- };
515
- }
516
- export function searchCapabilitySnapshotPath(home = homedir()) {
517
- return join(home, ".longtable", "search-capabilities.json");
518
- }
519
506
  function bestAccessStatus(record) {
520
507
  if (record.entitlementStatus === "licensed_full_text_available" && record.collectionDepth === "licensed_snippet") {
521
508
  return "licensed_full_text_checked";
@@ -173,12 +173,6 @@ export interface PublisherAccessRecord {
173
173
  evidenceSnippet?: string;
174
174
  crossref?: CrossrefTdmDiscovery;
175
175
  }
176
- export interface SearchCapabilitySnapshot {
177
- version: 1;
178
- updatedAt: string;
179
- contactEmailPresent: boolean;
180
- records: PublisherAccessRecord[];
181
- }
182
176
  export interface PublisherProbeInput {
183
177
  doi: string;
184
178
  publisher?: PublisherProbeTarget;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@longtable/cli",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "private": false,
5
5
  "description": "Researcher-facing LongTable CLI",
6
6
  "type": "module",
@@ -29,12 +29,12 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^1.2.0",
32
- "@longtable/checkpoints": "0.1.43",
33
- "@longtable/core": "0.1.43",
34
- "@longtable/memory": "0.1.43",
35
- "@longtable/provider-claude": "0.1.43",
36
- "@longtable/provider-codex": "0.1.43",
37
- "@longtable/setup": "0.1.43"
32
+ "@longtable/checkpoints": "0.1.45",
33
+ "@longtable/core": "0.1.45",
34
+ "@longtable/memory": "0.1.45",
35
+ "@longtable/provider-claude": "0.1.45",
36
+ "@longtable/provider-codex": "0.1.45",
37
+ "@longtable/setup": "0.1.45"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^22.10.1",