superlab 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.
package/bin/superlab.cjs CHANGED
@@ -30,6 +30,7 @@ function printHelp() {
30
30
  Usage:
31
31
  superlab init [--target <dir>] [--platform codex|claude|both|all] [--lang en|zh] [--force]
32
32
  superlab install [--target <dir>] [--platform codex|claude|both|all] [--lang en|zh] [--force]
33
+ superlab paper attach-template --path <dir> [--target <dir>]
33
34
  superlab update [--target <dir>]
34
35
  superlab update --all-projects
35
36
  superlab version [--target <dir>] [--global|--project]
@@ -42,6 +43,7 @@ Usage:
42
43
  Commands:
43
44
  init Initialize /lab commands, skills, templates, and scripts in a target
44
45
  install Backward-compatible alias for init
46
+ paper Attach and validate a user-provided LaTeX template directory
45
47
  update Refresh an initialized project or all registered projects
46
48
  version Show installed CLI version and project asset version
47
49
  handoff Print the minimal context handoff bundle for a project
@@ -171,6 +173,38 @@ function parseContextArgs(argv) {
171
173
  };
172
174
  }
173
175
 
176
+ function parsePaperArgs(argv) {
177
+ const [action, ...rest] = argv;
178
+ if (action !== "attach-template") {
179
+ throw new Error(`Unknown paper action: ${action || "(missing)"}`);
180
+ }
181
+
182
+ const options = {
183
+ action,
184
+ targetDir: process.cwd(),
185
+ templatePath: null,
186
+ };
187
+
188
+ for (let index = 0; index < rest.length; index += 1) {
189
+ const value = rest[index];
190
+ if (value === "--target") {
191
+ options.targetDir = path.resolve(rest[index + 1]);
192
+ index += 1;
193
+ } else if (value === "--path") {
194
+ options.templatePath = path.resolve(rest[index + 1]);
195
+ index += 1;
196
+ } else {
197
+ throw new Error(`Unknown option: ${value}`);
198
+ }
199
+ }
200
+
201
+ if (!options.templatePath) {
202
+ throw new Error("Missing required option: --path <dir>");
203
+ }
204
+
205
+ return options;
206
+ }
207
+
174
208
  function printVersion(options) {
175
209
  const lines = [];
176
210
  if (!options.projectOnly) {
@@ -231,6 +265,20 @@ function listFilesRecursive(dir) {
231
265
  return files;
232
266
  }
233
267
 
268
+ function listDirectoriesRecursive(dir) {
269
+ if (!fs.existsSync(dir)) {
270
+ return [];
271
+ }
272
+ const dirs = [];
273
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
274
+ const fullPath = path.join(dir, entry.name);
275
+ if (entry.isDirectory()) {
276
+ dirs.push(fullPath, ...listDirectoriesRecursive(fullPath));
277
+ }
278
+ }
279
+ return dirs;
280
+ }
281
+
234
282
  function readWorkflowConfig(targetDir) {
235
283
  const configPath = path.join(targetDir, ".lab", "config", "workflow.json");
236
284
  try {
@@ -246,6 +294,288 @@ function readWorkflowConfig(targetDir) {
246
294
  }
247
295
  }
248
296
 
297
+ function writeWorkflowConfig(targetDir, config) {
298
+ const configPath = path.join(targetDir, ".lab", "config", "workflow.json");
299
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
300
+ }
301
+
302
+ function storedProjectPath(targetDir, candidatePath) {
303
+ const relative = path.relative(targetDir, candidatePath);
304
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
305
+ return relative.split(path.sep).join("/");
306
+ }
307
+ return candidatePath;
308
+ }
309
+
310
+ function resolveProjectPath(targetDir, configuredPath) {
311
+ if (!configuredPath || typeof configuredPath !== "string") {
312
+ return null;
313
+ }
314
+ return path.isAbsolute(configuredPath)
315
+ ? configuredPath
316
+ : path.resolve(targetDir, configuredPath);
317
+ }
318
+
319
+ function latexTemplateSignals(templateRoot) {
320
+ return listFilesRecursive(templateRoot).filter((filePath) =>
321
+ [".tex", ".cls", ".sty", ".bst"].includes(path.extname(filePath))
322
+ );
323
+ }
324
+
325
+ function validatePaperTemplateRoot(targetDir, config) {
326
+ const issues = [];
327
+ if (!config || !config.paper_template_root) {
328
+ return issues;
329
+ }
330
+
331
+ if (typeof config.paper_template_root !== "string" || config.paper_template_root.trim() === "") {
332
+ return issues;
333
+ }
334
+
335
+ const templateRoot = resolveProjectPath(targetDir, config.paper_template_root);
336
+ if (!fs.existsSync(templateRoot)) {
337
+ issues.push("paper template root does not exist");
338
+ return issues;
339
+ }
340
+ if (!fs.statSync(templateRoot).isDirectory()) {
341
+ issues.push("paper template root must be a directory");
342
+ return issues;
343
+ }
344
+ if (latexTemplateSignals(templateRoot).length === 0) {
345
+ issues.push("paper template root does not look like a LaTeX template");
346
+ }
347
+ return issues;
348
+ }
349
+
350
+ function extractLabeledValue(text, labels) {
351
+ for (const label of labels) {
352
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
353
+ const match = text.match(new RegExp(`^\\s*-\\s*${escaped}:[ \\t]*([^\\n\\r]+?)[ \\t]*$`, "im"));
354
+ if (match && match[1]) {
355
+ return match[1].trim();
356
+ }
357
+ }
358
+ return "";
359
+ }
360
+
361
+ function validateDataDecisions(targetDir) {
362
+ const filePath = path.join(targetDir, ".lab", "context", "data-decisions.md");
363
+ if (!fs.existsSync(filePath)) {
364
+ return [];
365
+ }
366
+
367
+ const text = fs.readFileSync(filePath, "utf8");
368
+ const requiredFields = [
369
+ { name: "Approved datasets", labels: ["Approved datasets", "已批准数据集"] },
370
+ {
371
+ name: "Classic public benchmarks",
372
+ labels: ["Classic public benchmarks", "Classic benchmarks", "经典公开 benchmark", "经典 benchmark"],
373
+ },
374
+ {
375
+ name: "Recent strong public benchmarks",
376
+ labels: [
377
+ "Recent strong public benchmarks",
378
+ "Latest benchmarks",
379
+ "近期强公开 benchmark",
380
+ "最新 benchmark",
381
+ ],
382
+ },
383
+ {
384
+ name: "Claim-specific benchmarks",
385
+ labels: ["Claim-specific benchmarks", "专项 benchmark", "Claim-specific benchmark"],
386
+ },
387
+ { name: "Why this package is fair", labels: ["Why this package is fair", "为什么这个组合是公平的"] },
388
+ {
389
+ name: "Official benchmark or dataset pages",
390
+ labels: ["Official benchmark or dataset pages", "官方 benchmark 或数据集页面"],
391
+ },
392
+ {
393
+ name: "Download plan",
394
+ labels: ["Download plan", "下载计划"],
395
+ },
396
+ {
397
+ name: "Why the selected download sources are trusted",
398
+ labels: ["Why the selected download sources are trusted", "为什么这些下载来源可信"],
399
+ },
400
+ { name: "Dataset years", labels: ["Dataset years", "数据集年份"] },
401
+ {
402
+ name: "Papers that used the approved datasets",
403
+ labels: ["Papers that used the approved datasets", "使用过已批准数据集的论文", "使用过该数据集的论文"],
404
+ },
405
+ { name: "Split policy", labels: ["Split policy", "切分策略"] },
406
+ { name: "License or access notes", labels: ["License or access notes", "许可或访问说明"] },
407
+ {
408
+ name: "Why classic public benchmarks are included",
409
+ labels: [
410
+ "Why classic public benchmarks are included",
411
+ "Why classic benchmarks are included",
412
+ "为什么纳入经典公开 benchmark",
413
+ "为什么纳入经典 benchmark",
414
+ ],
415
+ },
416
+ {
417
+ name: "Why recent strong public benchmarks are included",
418
+ labels: [
419
+ "Why recent strong public benchmarks are included",
420
+ "Why latest benchmarks are included",
421
+ "为什么纳入近期强公开 benchmark",
422
+ "为什么纳入最新 benchmark",
423
+ ],
424
+ },
425
+ {
426
+ name: "Why claim-specific benchmarks are included or why none are needed",
427
+ labels: [
428
+ "Why claim-specific benchmarks are included or why none are needed",
429
+ "为什么纳入专项 benchmark 或为什么不需要",
430
+ ],
431
+ },
432
+ {
433
+ name: "Why this benchmark mix is representative",
434
+ labels: ["Why this benchmark mix is representative", "为什么这个 benchmark 组合具有代表性"],
435
+ },
436
+ {
437
+ name: "Canonical baselines",
438
+ labels: ["Canonical baselines", "Classic comparison methods", "规范基线", "经典对比方法"],
439
+ },
440
+ {
441
+ name: "Representative papers for canonical baselines",
442
+ labels: ["Representative papers for canonical baselines", "规范基线的代表论文"],
443
+ },
444
+ {
445
+ name: "Why these canonical baselines are included",
446
+ labels: [
447
+ "Why these canonical baselines are included",
448
+ "Why these classic baselines are included",
449
+ "为什么纳入这些规范基线",
450
+ "为什么纳入这些经典基线",
451
+ ],
452
+ },
453
+ {
454
+ name: "Strong historical baselines",
455
+ labels: ["Strong historical baselines", "强历史基线"],
456
+ },
457
+ {
458
+ name: "Representative papers for strong historical baselines",
459
+ labels: ["Representative papers for strong historical baselines", "强历史基线的代表论文"],
460
+ },
461
+ {
462
+ name: "Why these strong historical baselines are included",
463
+ labels: ["Why these strong historical baselines are included", "为什么纳入这些强历史基线"],
464
+ },
465
+ {
466
+ name: "Recent strong public methods",
467
+ labels: [
468
+ "Recent strong public methods",
469
+ "Recent strong or SOTA comparison methods",
470
+ "近期强公开方法",
471
+ "近期强基线或 SOTA 对比方法",
472
+ ],
473
+ },
474
+ {
475
+ name: "Representative papers for recent strong public methods",
476
+ labels: ["Representative papers for recent strong public methods", "近期强公开方法的代表论文"],
477
+ },
478
+ {
479
+ name: "Why these recent strong public methods are included",
480
+ labels: [
481
+ "Why these recent strong public methods are included",
482
+ "Why these recent or SOTA baselines are included",
483
+ "为什么纳入这些近期强公开方法",
484
+ "为什么纳入这些近期或 SOTA 基线",
485
+ ],
486
+ },
487
+ {
488
+ name: "Closest prior work",
489
+ labels: ["Closest prior work", "最接近的前作"],
490
+ },
491
+ {
492
+ name: "Representative papers for closest prior work",
493
+ labels: ["Representative papers for closest prior work", "最接近前作的代表论文"],
494
+ },
495
+ {
496
+ name: "Why this closest prior work is included or why none qualifies",
497
+ labels: [
498
+ "Why this closest prior work is included or why none qualifies",
499
+ "为什么纳入这个最接近的前作或为什么没有合格项",
500
+ ],
501
+ },
502
+ {
503
+ name: "Comparison methods",
504
+ labels: ["Comparison methods", "对比方法"],
505
+ },
506
+ {
507
+ name: "Comparison representative papers",
508
+ labels: ["Comparison representative papers", "对比方法代表论文"],
509
+ },
510
+ {
511
+ name: "Why these comparison methods are fair",
512
+ labels: ["Why these comparison methods are fair", "为什么这些对比方法是公平的"],
513
+ },
514
+ ];
515
+
516
+ const activationFields = [
517
+ ["Approved datasets", "已批准数据集"],
518
+ ["Classic public benchmarks", "Classic benchmarks", "经典公开 benchmark", "经典 benchmark"],
519
+ ["Recent strong public benchmarks", "Latest benchmarks", "近期强公开 benchmark", "最新 benchmark"],
520
+ ["Claim-specific benchmarks", "专项 benchmark"],
521
+ ["Dataset years", "数据集年份"],
522
+ ];
523
+ const hasStartedDatasetDecision = activationFields.some(
524
+ (labels) => extractLabeledValue(text, labels) !== ""
525
+ );
526
+
527
+ if (!hasStartedDatasetDecision) {
528
+ return [];
529
+ }
530
+
531
+ const missing = requiredFields
532
+ .filter((field) => extractLabeledValue(text, field.labels) === "")
533
+ .map((field) => field.name);
534
+
535
+ if (missing.length === 0) {
536
+ return [];
537
+ }
538
+
539
+ return [`missing dataset rationale fields: ${missing.join(", ")}`];
540
+ }
541
+
542
+ function attachPaperTemplate({ targetDir, templatePath }) {
543
+ const configPath = path.join(targetDir, ".lab", "config", "workflow.json");
544
+ if (!fs.existsSync(configPath)) {
545
+ throw new Error(`No workflow config found in ${targetDir}. Run 'superlab init' first.`);
546
+ }
547
+ if (!fs.existsSync(templatePath)) {
548
+ throw new Error(`Template path does not exist: ${templatePath}`);
549
+ }
550
+ const stats = fs.statSync(templatePath);
551
+ if (!stats.isDirectory()) {
552
+ throw new Error(`Template path must be a directory: ${templatePath}`);
553
+ }
554
+
555
+ const normalizedTemplatePath = path.resolve(templatePath);
556
+ const normalizedLabRoot = path.resolve(targetDir, ".lab");
557
+ const relativeToLab = path.relative(normalizedLabRoot, normalizedTemplatePath);
558
+ if (!relativeToLab.startsWith("..") && !path.isAbsolute(relativeToLab)) {
559
+ throw new Error("Template path must not point inside .lab");
560
+ }
561
+ if (latexTemplateSignals(normalizedTemplatePath).length === 0) {
562
+ throw new Error("Template path does not look like a LaTeX template directory.");
563
+ }
564
+
565
+ const { config, issues } = readWorkflowConfig(targetDir);
566
+ if (issues.length > 0 || !config) {
567
+ throw new Error(`Cannot attach template because workflow.json is invalid in ${targetDir}.`);
568
+ }
569
+
570
+ config.paper_template_root = storedProjectPath(targetDir, normalizedTemplatePath);
571
+ writeWorkflowConfig(targetDir, config);
572
+ return {
573
+ storedPath: config.paper_template_root,
574
+ warning:
575
+ "Template files are treated as user-owned and may already contain upstream or local modifications. Superlab will not rewrite them by default.",
576
+ };
577
+ }
578
+
249
579
  function validateWorkflowConfig(config) {
250
580
  const issues = [];
251
581
  if (!config || typeof config !== "object") {
@@ -260,6 +590,22 @@ function validateWorkflowConfig(config) {
260
590
  if (config.paper_format !== "latex") {
261
591
  issues.push("unsupported paper_format");
262
592
  }
593
+ if (typeof config.results_root !== "string" || config.results_root.trim() === "") {
594
+ issues.push("invalid results_root");
595
+ } else {
596
+ const normalized = config.results_root.replace(/\\/g, "/");
597
+ if (normalized === ".lab" || normalized.startsWith(".lab/")) {
598
+ issues.push("results_root must not point inside .lab");
599
+ }
600
+ }
601
+ if (typeof config.figures_root !== "string" || config.figures_root.trim() === "") {
602
+ issues.push("invalid figures_root");
603
+ } else {
604
+ const normalized = config.figures_root.replace(/\\/g, "/");
605
+ if (normalized === ".lab" || normalized.startsWith(".lab/")) {
606
+ issues.push("figures_root must not point inside .lab");
607
+ }
608
+ }
263
609
  if (typeof config.deliverables_root !== "string" || config.deliverables_root.trim() === "") {
264
610
  issues.push("invalid deliverables_root");
265
611
  } else {
@@ -268,6 +614,54 @@ function validateWorkflowConfig(config) {
268
614
  issues.push("deliverables_root must not point inside .lab");
269
615
  }
270
616
  }
617
+ if (
618
+ config.paper_template_root !== undefined &&
619
+ typeof config.paper_template_root !== "string"
620
+ ) {
621
+ issues.push("invalid paper_template_root");
622
+ } else if (typeof config.paper_template_root === "string" && config.paper_template_root.trim() !== "") {
623
+ const normalized = config.paper_template_root.replace(/\\/g, "/");
624
+ if (normalized === ".lab" || normalized.startsWith(".lab/")) {
625
+ issues.push("paper_template_root must not point inside .lab");
626
+ }
627
+ }
628
+ return issues;
629
+ }
630
+
631
+ function validateProjectRoots(targetDir, config) {
632
+ const issues = [];
633
+ if (!config || typeof config !== "object") {
634
+ return issues;
635
+ }
636
+
637
+ for (const key of ["results_root", "figures_root"]) {
638
+ if (typeof config[key] !== "string" || config[key].trim() === "") {
639
+ continue;
640
+ }
641
+ const normalized = config[key].replace(/\\/g, "/");
642
+ if (normalized === ".lab" || normalized.startsWith(".lab/")) {
643
+ continue;
644
+ }
645
+ const rootPath = resolveProjectPath(targetDir, config[key]);
646
+ if (!fs.existsSync(rootPath)) {
647
+ issues.push(`${key} does not exist`);
648
+ continue;
649
+ }
650
+ if (!fs.statSync(rootPath).isDirectory()) {
651
+ issues.push(`${key} must be a directory`);
652
+ }
653
+ }
654
+
655
+ const changeRoot = path.join(targetDir, ".lab", "changes");
656
+ const misplacedRunDirs = listDirectoriesRecursive(changeRoot)
657
+ .filter((dirPath) => path.basename(dirPath) === "runs")
658
+ .map((dirPath) => path.relative(targetDir, dirPath));
659
+ if (misplacedRunDirs.length > 0) {
660
+ issues.push(
661
+ `run outputs should not live under .lab/changes/*/runs; use results_root instead: ${misplacedRunDirs.join(", ")}`
662
+ );
663
+ }
664
+
271
665
  return issues;
272
666
  }
273
667
 
@@ -277,7 +671,7 @@ function validateDeliverables(targetDir, config) {
277
671
  return issues;
278
672
  }
279
673
 
280
- const deliverablesRoot = path.join(targetDir, config.deliverables_root);
674
+ const deliverablesRoot = resolveProjectPath(targetDir, config.deliverables_root);
281
675
  const paperDir = path.join(deliverablesRoot, "paper");
282
676
  const mainTexPath = path.join(paperDir, "main.tex");
283
677
  const mainMdPath = path.join(paperDir, "main.md");
@@ -313,6 +707,7 @@ function printDoctor(options) {
313
707
  ".lab/context/decisions.md",
314
708
  ".lab/context/evidence-index.md",
315
709
  ".lab/context/open-questions.md",
710
+ ".lab/context/data-decisions.md",
316
711
  ".lab/context/terminology-lock.md",
317
712
  ".lab/context/summary.md",
318
713
  ".lab/context/next-action.md",
@@ -323,6 +718,9 @@ function printDoctor(options) {
323
718
  const { config, issues: configReadIssues } = readWorkflowConfig(options.targetDir);
324
719
  const configIssues = configReadIssues.concat(validateWorkflowConfig(config));
325
720
  const deliverableIssues = validateDeliverables(options.targetDir, config);
721
+ const templateIssues = validatePaperTemplateRoot(options.targetDir, config);
722
+ const dataDecisionIssues = validateDataDecisions(options.targetDir);
723
+ const rootIssues = validateProjectRoots(options.targetDir, config);
326
724
 
327
725
  if (projectInfo.status === "missing") {
328
726
  console.log("status: missing");
@@ -331,7 +729,14 @@ function printDoctor(options) {
331
729
  return;
332
730
  }
333
731
 
334
- if (missing.length > 0 || configIssues.length > 0 || deliverableIssues.length > 0) {
732
+ if (
733
+ missing.length > 0 ||
734
+ configIssues.length > 0 ||
735
+ deliverableIssues.length > 0 ||
736
+ templateIssues.length > 0 ||
737
+ dataDecisionIssues.length > 0 ||
738
+ rootIssues.length > 0
739
+ ) {
335
740
  console.log("status: degraded");
336
741
  console.log(`target: ${options.targetDir}`);
337
742
  console.log(`project: ${projectInfo.package_version}`);
@@ -339,7 +744,8 @@ function printDoctor(options) {
339
744
  console.log(`language: ${projectInfo.lang}`);
340
745
  console.log(`missing: ${missing.length > 0 ? missing.join(", ") : "none"}`);
341
746
  console.log(`config: ${configIssues.length > 0 ? configIssues.join(" | ") : "none"}`);
342
- console.log(`outputs: ${deliverableIssues.length > 0 ? deliverableIssues.join(" | ") : "none"}`);
747
+ const outputIssues = deliverableIssues.concat(templateIssues, dataDecisionIssues, rootIssues);
748
+ console.log(`outputs: ${outputIssues.length > 0 ? outputIssues.join(" | ") : "none"}`);
343
749
  return;
344
750
  }
345
751
 
@@ -405,7 +811,7 @@ async function main() {
405
811
  return;
406
812
  }
407
813
 
408
- if (!["init", "install", "update", "version", "handoff", "doctor", "context"].includes(command)) {
814
+ if (!["init", "install", "paper", "update", "version", "handoff", "doctor", "context"].includes(command)) {
409
815
  throw new Error(`Unknown command: ${command}`);
410
816
  }
411
817
 
@@ -441,6 +847,18 @@ async function main() {
441
847
  return;
442
848
  }
443
849
 
850
+ if (command === "paper") {
851
+ const options = parsePaperArgs(rest);
852
+ const result = attachPaperTemplate({
853
+ targetDir: options.targetDir,
854
+ templatePath: options.templatePath,
855
+ });
856
+ console.log(`paper template attached in ${options.targetDir}`);
857
+ console.log(`template: ${result.storedPath}`);
858
+ console.log(`warning: ${result.warning}`);
859
+ return;
860
+ }
861
+
444
862
  if (command === "update") {
445
863
  const options = parseUpdateArgs(rest);
446
864
  if (options.allProjects) {