stagent 0.6.2 → 0.7.0

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.
Files changed (176) hide show
  1. package/README.md +21 -2
  2. package/dist/cli.js +272 -1
  3. package/docs/.coverage-gaps.json +66 -16
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/dashboard-kanban.md +13 -7
  6. package/docs/features/settings.md +15 -3
  7. package/docs/features/tables.md +122 -0
  8. package/docs/index.md +3 -2
  9. package/docs/journeys/developer.md +26 -16
  10. package/docs/journeys/personal-use.md +23 -9
  11. package/docs/journeys/power-user.md +40 -14
  12. package/docs/journeys/work-use.md +43 -15
  13. package/docs/manifest.json +27 -17
  14. package/package.json +3 -2
  15. package/src/app/api/chat/entities/search/route.ts +12 -3
  16. package/src/app/api/documents/[id]/route.ts +5 -1
  17. package/src/app/api/documents/[id]/versions/route.ts +53 -0
  18. package/src/app/api/documents/route.ts +5 -1
  19. package/src/app/api/projects/[id]/documents/route.ts +124 -0
  20. package/src/app/api/projects/[id]/route.ts +72 -3
  21. package/src/app/api/projects/__tests__/delete-project.test.ts +13 -0
  22. package/src/app/api/schedules/route.ts +19 -1
  23. package/src/app/api/snapshots/[id]/restore/route.ts +62 -0
  24. package/src/app/api/snapshots/[id]/route.ts +44 -0
  25. package/src/app/api/snapshots/route.ts +54 -0
  26. package/src/app/api/snapshots/settings/route.ts +67 -0
  27. package/src/app/api/tables/[id]/charts/[chartId]/route.ts +89 -0
  28. package/src/app/api/tables/[id]/charts/route.ts +72 -0
  29. package/src/app/api/tables/[id]/columns/route.ts +70 -0
  30. package/src/app/api/tables/[id]/export/route.ts +94 -0
  31. package/src/app/api/tables/[id]/history/route.ts +15 -0
  32. package/src/app/api/tables/[id]/import/route.ts +111 -0
  33. package/src/app/api/tables/[id]/route.ts +86 -0
  34. package/src/app/api/tables/[id]/rows/[rowId]/history/route.ts +32 -0
  35. package/src/app/api/tables/[id]/rows/[rowId]/route.ts +51 -0
  36. package/src/app/api/tables/[id]/rows/route.ts +101 -0
  37. package/src/app/api/tables/[id]/triggers/[triggerId]/route.ts +65 -0
  38. package/src/app/api/tables/[id]/triggers/route.ts +122 -0
  39. package/src/app/api/tables/route.ts +65 -0
  40. package/src/app/api/tables/templates/route.ts +92 -0
  41. package/src/app/api/tasks/[id]/route.ts +37 -2
  42. package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
  43. package/src/app/api/tasks/route.ts +8 -9
  44. package/src/app/api/workflows/[id]/documents/route.ts +209 -0
  45. package/src/app/api/workflows/[id]/execute/route.ts +6 -2
  46. package/src/app/api/workflows/[id]/route.ts +16 -3
  47. package/src/app/api/workflows/[id]/status/route.ts +18 -2
  48. package/src/app/api/workflows/route.ts +13 -2
  49. package/src/app/documents/page.tsx +5 -1
  50. package/src/app/layout.tsx +0 -1
  51. package/src/app/manifest.ts +3 -3
  52. package/src/app/projects/[id]/page.tsx +62 -2
  53. package/src/app/settings/page.tsx +2 -0
  54. package/src/app/tables/[id]/page.tsx +67 -0
  55. package/src/app/tables/page.tsx +21 -0
  56. package/src/app/tables/templates/page.tsx +19 -0
  57. package/src/components/chat/chat-table-result.tsx +139 -0
  58. package/src/components/documents/document-browser.tsx +1 -1
  59. package/src/components/documents/document-chip-bar.tsx +17 -1
  60. package/src/components/documents/document-detail-view.tsx +51 -0
  61. package/src/components/documents/document-grid.tsx +5 -0
  62. package/src/components/documents/document-table.tsx +4 -0
  63. package/src/components/documents/types.ts +3 -0
  64. package/src/components/projects/project-form-sheet.tsx +109 -2
  65. package/src/components/schedules/schedule-form.tsx +91 -1
  66. package/src/components/settings/data-management-section.tsx +17 -12
  67. package/src/components/settings/database-snapshots-section.tsx +469 -0
  68. package/src/components/shared/app-sidebar.tsx +2 -0
  69. package/src/components/shared/document-picker-sheet.tsx +486 -0
  70. package/src/components/tables/table-browser.tsx +234 -0
  71. package/src/components/tables/table-cell-editor.tsx +226 -0
  72. package/src/components/tables/table-chart-builder.tsx +288 -0
  73. package/src/components/tables/table-chart-view.tsx +146 -0
  74. package/src/components/tables/table-column-header.tsx +103 -0
  75. package/src/components/tables/table-column-sheet.tsx +331 -0
  76. package/src/components/tables/table-create-sheet.tsx +240 -0
  77. package/src/components/tables/table-detail-sheet.tsx +144 -0
  78. package/src/components/tables/table-detail-tabs.tsx +278 -0
  79. package/src/components/tables/table-grid.tsx +61 -0
  80. package/src/components/tables/table-history-tab.tsx +148 -0
  81. package/src/components/tables/table-import-wizard.tsx +542 -0
  82. package/src/components/tables/table-list-table.tsx +95 -0
  83. package/src/components/tables/table-relation-combobox.tsx +217 -0
  84. package/src/components/tables/table-spreadsheet.tsx +499 -0
  85. package/src/components/tables/table-template-gallery.tsx +162 -0
  86. package/src/components/tables/table-template-preview.tsx +219 -0
  87. package/src/components/tables/table-toolbar.tsx +79 -0
  88. package/src/components/tables/table-triggers-tab.tsx +446 -0
  89. package/src/components/tables/types.ts +6 -0
  90. package/src/components/tables/use-spreadsheet-keys.ts +171 -0
  91. package/src/components/tables/utils.ts +29 -0
  92. package/src/components/tasks/task-card.tsx +8 -1
  93. package/src/components/tasks/task-create-panel.tsx +111 -14
  94. package/src/components/tasks/task-detail-view.tsx +47 -0
  95. package/src/components/tasks/task-edit-dialog.tsx +103 -2
  96. package/src/components/workflows/workflow-form-view.tsx +207 -7
  97. package/src/components/workflows/workflow-kanban-card.tsx +8 -1
  98. package/src/components/workflows/workflow-list.tsx +90 -45
  99. package/src/components/workflows/workflow-status-view.tsx +168 -23
  100. package/src/instrumentation.ts +3 -0
  101. package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
  102. package/src/lib/agents/__tests__/claude-agent.test.ts +5 -1
  103. package/src/lib/agents/claude-agent.ts +3 -1
  104. package/src/lib/agents/profiles/registry.ts +6 -3
  105. package/src/lib/agents/runtime/anthropic-direct.ts +29 -0
  106. package/src/lib/agents/runtime/openai-direct.ts +29 -0
  107. package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
  108. package/src/lib/book/chapter-generator.ts +4 -19
  109. package/src/lib/book/chapter-mapping.ts +17 -0
  110. package/src/lib/book/content.ts +5 -16
  111. package/src/lib/book/update-detector.ts +3 -16
  112. package/src/lib/chat/engine.ts +1 -0
  113. package/src/lib/chat/stagent-tools.ts +2 -0
  114. package/src/lib/chat/system-prompt.ts +9 -1
  115. package/src/lib/chat/tool-catalog.ts +35 -0
  116. package/src/lib/chat/tools/settings-tools.ts +109 -0
  117. package/src/lib/chat/tools/table-tools.ts +955 -0
  118. package/src/lib/chat/tools/workflow-tools.ts +145 -2
  119. package/src/lib/constants/table-status.ts +68 -0
  120. package/src/lib/data/__tests__/clear.test.ts +1 -1
  121. package/src/lib/data/clear.ts +57 -0
  122. package/src/lib/data/seed-data/__tests__/profiles.test.ts +28 -23
  123. package/src/lib/data/seed-data/conversations.ts +350 -42
  124. package/src/lib/data/seed-data/documents.ts +564 -591
  125. package/src/lib/data/seed-data/learned-context.ts +101 -22
  126. package/src/lib/data/seed-data/notifications.ts +344 -70
  127. package/src/lib/data/seed-data/profile-test-results.ts +92 -11
  128. package/src/lib/data/seed-data/profiles.ts +144 -46
  129. package/src/lib/data/seed-data/projects.ts +50 -18
  130. package/src/lib/data/seed-data/repo-imports.ts +28 -13
  131. package/src/lib/data/seed-data/schedules.ts +208 -41
  132. package/src/lib/data/seed-data/table-templates.ts +234 -0
  133. package/src/lib/data/seed-data/tasks.ts +614 -116
  134. package/src/lib/data/seed-data/usage-ledger.ts +182 -103
  135. package/src/lib/data/seed-data/user-tables.ts +203 -0
  136. package/src/lib/data/seed-data/views.ts +52 -7
  137. package/src/lib/data/seed-data/workflows.ts +231 -84
  138. package/src/lib/data/seed.ts +55 -14
  139. package/src/lib/data/tables.ts +417 -0
  140. package/src/lib/db/bootstrap.ts +275 -0
  141. package/src/lib/db/index.ts +9 -0
  142. package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
  143. package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
  144. package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
  145. package/src/lib/db/migrations/0019_add_tables_feature.sql +160 -0
  146. package/src/lib/db/migrations/0020_add_table_triggers.sql +19 -0
  147. package/src/lib/db/migrations/0021_add_row_history.sql +15 -0
  148. package/src/lib/db/schema.ts +445 -0
  149. package/src/lib/docs/reader.ts +2 -3
  150. package/src/lib/documents/context-builder.ts +75 -2
  151. package/src/lib/documents/document-resolver.ts +119 -0
  152. package/src/lib/documents/processors/spreadsheet.ts +2 -1
  153. package/src/lib/schedules/scheduler.ts +31 -1
  154. package/src/lib/snapshots/auto-backup.ts +132 -0
  155. package/src/lib/snapshots/retention.ts +64 -0
  156. package/src/lib/snapshots/snapshot-manager.ts +429 -0
  157. package/src/lib/tables/computed.ts +61 -0
  158. package/src/lib/tables/context-builder.ts +139 -0
  159. package/src/lib/tables/formula-engine.ts +415 -0
  160. package/src/lib/tables/history.ts +115 -0
  161. package/src/lib/tables/import.ts +343 -0
  162. package/src/lib/tables/query-builder.ts +152 -0
  163. package/src/lib/tables/trigger-evaluator.ts +146 -0
  164. package/src/lib/tables/types.ts +141 -0
  165. package/src/lib/tables/validation.ts +119 -0
  166. package/src/lib/utils/app-root.ts +20 -0
  167. package/src/lib/utils/stagent-paths.ts +20 -0
  168. package/src/lib/validators/__tests__/task.test.ts +43 -10
  169. package/src/lib/validators/task.ts +7 -1
  170. package/src/lib/workflows/blueprints/registry.ts +3 -3
  171. package/src/lib/workflows/engine.ts +24 -8
  172. package/src/lib/workflows/types.ts +14 -0
  173. package/tsconfig.json +3 -1
  174. package/public/icon.svg +0 -13
  175. package/src/components/tasks/file-upload.tsx +0 -120
  176. /package/docs/features/{playbook.md → user-guide.md} +0 -0
@@ -23,7 +23,10 @@ import {
23
23
  MessageSquareMore,
24
24
  FileText,
25
25
  Paperclip,
26
+ ArrowRight,
27
+ FolderKanban,
26
28
  } from "lucide-react";
29
+ import { Checkbox } from "@/components/ui/checkbox";
27
30
  import ReactMarkdown from "react-markdown";
28
31
  import remarkGfm from "remark-gfm";
29
32
  import { toast } from "sonner";
@@ -72,6 +75,13 @@ interface WorkflowStatusData {
72
75
  swarmConfig?: SwarmConfig;
73
76
  stepDocuments?: Record<string, DocumentInfo[]>;
74
77
  parentDocuments?: DocumentInfo[];
78
+ runNumber?: number;
79
+ runHistory?: Array<{
80
+ runNumber: number | null;
81
+ taskCount: number;
82
+ completedCount: number;
83
+ failedCount: number;
84
+ }>;
75
85
  }
76
86
 
77
87
  interface WorkflowStatusViewProps {
@@ -315,6 +325,23 @@ export function WorkflowStatusView({ workflowId }: WorkflowStatusViewProps) {
315
325
  <p className="text-sm text-muted-foreground mt-1">
316
326
  {patternLabels[data.pattern] ?? data.pattern}
317
327
  </p>
328
+ <div className="flex items-center gap-2 mt-1">
329
+ {data.projectId && (
330
+ <Badge
331
+ variant="outline"
332
+ className="text-xs cursor-pointer hover:bg-accent gap-1"
333
+ onClick={() => router.push(`/projects/${data.projectId}`)}
334
+ >
335
+ <FolderKanban className="h-3 w-3" />
336
+ Project
337
+ </Badge>
338
+ )}
339
+ {data.runNumber != null && data.runNumber > 0 && (
340
+ <Badge variant="outline" className="text-xs font-normal">
341
+ Run #{data.runNumber}
342
+ </Badge>
343
+ )}
344
+ </div>
318
345
  </div>
319
346
  </div>
320
347
  <div className="flex items-center gap-2">
@@ -330,56 +357,53 @@ export function WorkflowStatusView({ workflowId }: WorkflowStatusViewProps) {
330
357
  </Button>
331
358
  )}
332
359
 
333
- {/* Edit — draft only */}
334
- {data.status === "draft" && hasDefinition && (
360
+ {/* Edit — draft, completed, or failed */}
361
+ {["draft", "completed", "failed"].includes(data.status) && hasDefinition && (
335
362
  <Button
336
- variant="ghost"
337
- size="icon"
338
- className="h-7 w-7"
339
- aria-label="Edit workflow"
363
+ variant="outline"
364
+ size="sm"
340
365
  onClick={() => router.push(`/workflows/${workflowId}/edit`)}
341
366
  >
342
- <Pencil className="h-3.5 w-3.5" />
367
+ <Pencil className="h-3.5 w-3.5 mr-1.5" />
368
+ Edit
343
369
  </Button>
344
370
  )}
345
371
 
346
372
  {/* Clone — always available */}
347
373
  {hasDefinition && (
348
374
  <Button
349
- variant="ghost"
350
- size="icon"
351
- className="h-7 w-7"
352
- aria-label="Clone workflow"
375
+ variant="outline"
376
+ size="sm"
353
377
  onClick={() => router.push(`/workflows/${workflowId}/edit?clone=true`)}
354
378
  >
355
- <Copy className="h-3.5 w-3.5" />
379
+ <Copy className="h-3.5 w-3.5 mr-1.5" />
380
+ Clone
356
381
  </Button>
357
382
  )}
358
383
 
359
384
  {/* Re-run — completed/failed only */}
360
385
  {(data.status === "completed" || data.status === "failed") && (
361
386
  <Button
362
- variant="ghost"
363
- size="icon"
364
- className="h-7 w-7"
365
- aria-label="Re-run workflow"
387
+ variant="outline"
388
+ size="sm"
366
389
  onClick={handleRerun}
367
390
  disabled={executing}
368
391
  >
369
- <RotateCcw className="h-3.5 w-3.5" />
392
+ <RotateCcw className="h-3.5 w-3.5 mr-1.5" />
393
+ Re-run
370
394
  </Button>
371
395
  )}
372
396
 
373
397
  {/* Delete — not active */}
374
398
  {data.status !== "active" && (
375
399
  <Button
376
- variant="ghost"
377
- size="icon"
378
- className="h-7 w-7 text-destructive"
379
- aria-label="Delete workflow"
400
+ variant="outline"
401
+ size="sm"
402
+ className="text-destructive hover:text-destructive"
380
403
  onClick={() => setConfirmDelete(true)}
381
404
  >
382
- <Trash2 className="h-3.5 w-3.5" />
405
+ <Trash2 className="h-3.5 w-3.5 mr-1.5" />
406
+ Delete
383
407
  </Button>
384
408
  )}
385
409
  </div>
@@ -520,7 +544,7 @@ export function WorkflowStatusView({ workflowId }: WorkflowStatusViewProps) {
520
544
  ) : (
521
545
  <div className="space-y-3">
522
546
  {data.steps.map((step, index) => (
523
- <div key={step.id} className="flex items-start gap-3">
547
+ <div key={`${step.id}-${index}`} className="flex items-start gap-3">
524
548
  <div className="mt-0.5 flex flex-col items-center">
525
549
  {stepStatusIcons[step.state.status] ?? stepStatusIcons.pending}
526
550
  {index < data.steps.length - 1 && (
@@ -614,6 +638,11 @@ export function WorkflowStatusView({ workflowId }: WorkflowStatusViewProps) {
614
638
  />
615
639
  )}
616
640
 
641
+ {/* Output Dock — chain into new workflow */}
642
+ {data.status === "completed" && hasStepDocs && (
643
+ <OutputDock stepDocuments={data.stepDocuments!} steps={data.steps} />
644
+ )}
645
+
617
646
  {/* Delete confirmation */}
618
647
  <ConfirmDialog
619
648
  open={confirmDelete}
@@ -627,3 +656,119 @@ export function WorkflowStatusView({ workflowId }: WorkflowStatusViewProps) {
627
656
  </div>
628
657
  );
629
658
  }
659
+
660
+ /** Output Dock — selectable output documents for chaining into a new workflow */
661
+ function OutputDock({
662
+ stepDocuments,
663
+ steps,
664
+ }: {
665
+ stepDocuments: Record<string, DocumentInfo[]>;
666
+ steps: StepWithState[];
667
+ }) {
668
+ const router = useRouter();
669
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
670
+
671
+ // Flatten all output documents
672
+ const allOutputDocs = Object.entries(stepDocuments).flatMap(
673
+ ([taskId, docs]) => {
674
+ const step = steps.find((s) => s.state.taskId === taskId);
675
+ return docs.map((doc) => ({
676
+ ...doc,
677
+ stepName: step?.name ?? "Unknown Step",
678
+ }));
679
+ }
680
+ );
681
+
682
+ if (allOutputDocs.length === 0) return null;
683
+
684
+ function toggleDoc(id: string) {
685
+ setSelectedIds((prev) => {
686
+ const next = new Set(prev);
687
+ if (next.has(id)) next.delete(id);
688
+ else next.add(id);
689
+ return next;
690
+ });
691
+ }
692
+
693
+ function selectAll() {
694
+ setSelectedIds(new Set(allOutputDocs.map((d) => d.id)));
695
+ }
696
+
697
+ function chainIntoNewWorkflow() {
698
+ if (selectedIds.size === 0) return;
699
+ const params = new URLSearchParams({
700
+ inputDocs: [...selectedIds].join(","),
701
+ });
702
+ router.push(`/workflows/new?${params}`);
703
+ }
704
+
705
+ return (
706
+ <Card>
707
+ <CardHeader className="pb-3">
708
+ <div className="flex items-center justify-between">
709
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
710
+ <ArrowRight className="h-4 w-4" />
711
+ Chain Output Documents
712
+ </CardTitle>
713
+ <Button
714
+ variant="ghost"
715
+ size="sm"
716
+ onClick={selectAll}
717
+ className="text-xs"
718
+ >
719
+ Select All
720
+ </Button>
721
+ </div>
722
+ <p className="text-xs text-muted-foreground">
723
+ Select output documents to use as inputs in a new workflow
724
+ </p>
725
+ </CardHeader>
726
+ <CardContent>
727
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-4">
728
+ {allOutputDocs.map((doc) => {
729
+ const isChecked = selectedIds.has(doc.id);
730
+ return (
731
+ <div
732
+ key={doc.id}
733
+ role="button"
734
+ tabIndex={0}
735
+ onClick={() => toggleDoc(doc.id)}
736
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleDoc(doc.id); } }}
737
+ className={`flex items-center gap-3 p-3 rounded-lg text-left transition-colors border cursor-pointer ${
738
+ isChecked
739
+ ? "bg-accent/50 border-accent"
740
+ : "hover:bg-muted/50 border-border/50"
741
+ }`}
742
+ >
743
+ <Checkbox
744
+ checked={isChecked}
745
+ onCheckedChange={() => toggleDoc(doc.id)}
746
+ />
747
+ <FileText className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
748
+ <div className="flex-1 min-w-0">
749
+ <p className="text-sm font-medium truncate">
750
+ {doc.originalName}
751
+ </p>
752
+ <p className="text-xs text-muted-foreground truncate">
753
+ {doc.stepName}
754
+ </p>
755
+ </div>
756
+ </div>
757
+ );
758
+ })}
759
+ </div>
760
+
761
+ {selectedIds.size > 0 && (
762
+ <Button
763
+ onClick={chainIntoNewWorkflow}
764
+ className="w-full gap-2"
765
+ size="sm"
766
+ >
767
+ <ArrowRight className="h-4 w-4" />
768
+ Chain {selectedIds.size} Document{selectedIds.size !== 1 ? "s" : ""} Into New Workflow
769
+ </Button>
770
+ )}
771
+ </CardContent>
772
+ </Card>
773
+ );
774
+ }
@@ -6,5 +6,8 @@ export async function register() {
6
6
 
7
7
  const { startChannelPoller } = await import("@/lib/channels/poller");
8
8
  startChannelPoller();
9
+
10
+ const { startAutoBackup } = await import("@/lib/snapshots/auto-backup");
11
+ startAutoBackup();
9
12
  }
10
13
  }
@@ -40,6 +40,7 @@ describe("npx safety: no process.cwd() for app-internal asset resolution", () =>
40
40
  const ALLOWED_FILES = [
41
41
  "bin/cli.ts", // CLI entrypoint defines cwd context
42
42
  "src/lib/environment/workspace-context.ts", // defines getLaunchCwd fallback
43
+ "src/lib/utils/app-root.ts", // validated fallback when import.meta.dirname is virtual
43
44
  "drizzle.config.ts", // build-time config
44
45
  ];
45
46
 
@@ -50,8 +51,13 @@ describe("npx safety: no process.cwd() for app-internal asset resolution", () =>
50
51
  const DANGEROUS_PATTERNS = [
51
52
  /process\.cwd\(\)\s*,\s*["'](?:public|docs|book|ai-native-notes|src)\b/,
52
53
  /process\.cwd\(\)\s*,\s*["'].*?\.(?:png|ico|svg|jpg|md|json)["']/,
54
+ // Catch bare process.cwd() in book/docs/profiles modules (even without join)
55
+ /process\.cwd\(\)/,
53
56
  ];
54
57
 
58
+ // Files in these directories are NEVER allowed to use process.cwd()
59
+ const STRICT_DIRS = ["src/lib/book/", "src/lib/docs/", "src/lib/agents/profiles/"];
60
+
55
61
  it("server-side code does not use process.cwd() for internal asset paths", () => {
56
62
  const srcFiles = collectFiles(join(PROJECT_ROOT, "src"));
57
63
  const binFiles = collectFiles(join(PROJECT_ROOT, "bin"));
@@ -69,10 +75,19 @@ describe("npx safety: no process.cwd() for app-internal asset resolution", () =>
69
75
 
70
76
  const content = readFileSync(filePath, "utf-8");
71
77
  const lines = content.split("\n");
78
+ const isStrictDir = STRICT_DIRS.some((d) => relative.startsWith(d));
72
79
 
73
80
  for (let i = 0; i < lines.length; i++) {
74
81
  const line = lines[i];
75
- for (const pattern of DANGEROUS_PATTERNS) {
82
+ // In strict dirs, ANY process.cwd() in code (not comments) is a violation
83
+ const trimmed = line.trim();
84
+ const isComment = trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
85
+ if (isStrictDir && !isComment && /process\.cwd\(\)/.test(line)) {
86
+ violations.push({ file: relative, line: i + 1, text: line.trim() });
87
+ continue;
88
+ }
89
+ // Elsewhere, only flag process.cwd() combined with app-internal paths
90
+ for (const pattern of DANGEROUS_PATTERNS.slice(0, 2)) {
76
91
  if (pattern.test(line)) {
77
92
  violations.push({ file: relative, line: i + 1, text: line.trim() });
78
93
  }
@@ -119,7 +134,7 @@ describe("npx safety: no process.cwd() for app-internal asset resolution", () =>
119
134
 
120
135
  it("icon assets referenced in metadata exist in public/", () => {
121
136
  const publicDir = join(PROJECT_ROOT, "public");
122
- const requiredIcons = ["stagent-s-64.png", "stagent-s-128.png", "icon.svg"];
137
+ const requiredIcons = ["stagent-s-64.png", "stagent-s-128.png"];
123
138
 
124
139
  const missing = requiredIcons.filter((name) => {
125
140
  try {
@@ -32,6 +32,8 @@ const {
32
32
  update: vi.fn().mockReturnValue({ set: mockSet }),
33
33
  insert: vi.fn().mockReturnValue({ values: mockValues }),
34
34
  };
35
+ // .where() must return a thenable with .all() for both async and sync query patterns
36
+ mockWhere.mockReturnValue({ then: (fn: (v: unknown[]) => void) => fn([]), all: () => [] });
35
37
  mockFrom.mockReturnValue({ where: mockWhere });
36
38
  mockSet.mockReturnValue({ where: mockSetWhere });
37
39
  mockValues.mockResolvedValue(undefined);
@@ -77,7 +79,8 @@ const {
77
79
  });
78
80
 
79
81
  vi.mock("@/lib/db", () => ({ db: mockDb }));
80
- vi.mock("@/lib/db/schema", () => ({
82
+ vi.mock("@/lib/db/schema", async (importOriginal) => ({
83
+ ...(await importOriginal<typeof import("@/lib/db/schema")>()),
81
84
  tasks: {
82
85
  id: "id",
83
86
  status: "status",
@@ -181,6 +184,7 @@ beforeEach(() => {
181
184
  vi.resetAllMocks();
182
185
  // Re-establish mock chains after clearAllMocks
183
186
  mockDb.select.mockReturnValue({ from: mockFrom });
187
+ mockWhere.mockReturnValue({ then: (fn: (v: unknown[]) => void) => fn([]), all: () => [] });
184
188
  mockFrom.mockReturnValue({ where: mockWhere });
185
189
  mockDb.update.mockReturnValue({ set: mockSet });
186
190
  mockSet.mockReturnValue({ where: mockSetWhere });
@@ -6,6 +6,7 @@ import { setExecution, removeExecution } from "./execution-manager";
6
6
  import { MAX_RESUME_COUNT, DEFAULT_MAX_TURNS, DEFAULT_MAX_BUDGET_USD } from "@/lib/constants/task-status";
7
7
  import { getAuthEnv, updateAuthStatus } from "@/lib/settings/auth";
8
8
  import { buildDocumentContext } from "@/lib/documents/context-builder";
9
+ import { buildTableContext } from "@/lib/tables/context-builder";
9
10
  import {
10
11
  buildTaskOutputInstructions,
11
12
  prepareTaskOutputDirectory,
@@ -369,6 +370,7 @@ export async function buildTaskQueryContext(
369
370
  const profileInstructions = payload?.instructions ?? "";
370
371
  const basePrompt = task.description || task.title;
371
372
  const docContext = await buildDocumentContext(task.id);
373
+ const tableContext = await buildTableContext(task.id);
372
374
  const outputInstructions = buildTaskOutputInstructions(task.id);
373
375
  const learnedCtx = getActiveLearnedContext(profileId);
374
376
  const learnedCtxBlock = learnedCtx
@@ -394,7 +396,7 @@ export async function buildTaskQueryContext(
394
396
  : "";
395
397
 
396
398
  // F1: Separate system instructions from user content
397
- const systemInstructions = [worktreeNote, profileInstructions, learnedCtxBlock, docContext, outputInstructions]
399
+ const systemInstructions = [worktreeNote, profileInstructions, learnedCtxBlock, docContext, tableContext, outputInstructions]
398
400
  .filter(Boolean)
399
401
  .join("\n\n");
400
402
 
@@ -15,11 +15,14 @@ import { eq, and } from "drizzle-orm";
15
15
  * Builtins ship inside the repo at src/lib/agents/profiles/builtins/.
16
16
  * At runtime they are copied (if missing) to ~/.claude/skills/ so users
17
17
  * can customize them without touching source.
18
- * Uses import.meta.dirname (not process.cwd()) so it works under npx.
18
+ * Uses getAppRoot + known subpath because Turbopack compiles import.meta.dirname
19
+ * to a virtual /ROOT/ path that doesn't exist on the filesystem.
19
20
  */
21
+ import { getAppRoot } from "@/lib/utils/app-root";
22
+
20
23
  const BUILTINS_DIR = path.resolve(
21
- import.meta.dirname ?? __dirname,
22
- "builtins"
24
+ getAppRoot(import.meta.dirname, 4),
25
+ "src", "lib", "agents", "profiles", "builtins"
23
26
  );
24
27
 
25
28
  function getBuiltinsDir(): string {
@@ -39,6 +39,11 @@ import {
39
39
  recordUsageLedgerEntry,
40
40
  resolveUsageActivityType,
41
41
  } from "@/lib/usage/ledger";
42
+ import {
43
+ scanTaskOutputDocuments,
44
+ prepareTaskOutputDirectory,
45
+ buildTaskOutputInstructions,
46
+ } from "@/lib/documents/output-scanner";
42
47
 
43
48
  // ── SDK lazy import ──────────────────────────────────────────────────
44
49
 
@@ -283,6 +288,14 @@ async function executeAnthropicDirectTask(taskId: string, isResume = false): Pro
283
288
  .where(eq(tasks.id, taskId));
284
289
 
285
290
  const ctx = await buildTaskQueryContext(task, agentProfileId);
291
+
292
+ // Prepare output directory so the agent can write output files
293
+ if (!isResume) {
294
+ await prepareTaskOutputDirectory(taskId);
295
+ }
296
+ const outputInstructions = buildTaskOutputInstructions(taskId);
297
+ ctx.systemInstructions = `${ctx.systemInstructions}\n\n${outputInstructions}`;
298
+
286
299
  const apiKey = await getAnthropicApiKey();
287
300
  const sdk = await getAnthropicSDK();
288
301
  const client = new sdk.default({ apiKey });
@@ -431,6 +444,22 @@ async function executeAnthropicDirectTask(taskId: string, isResume = false): Pro
431
444
  timestamp: new Date(),
432
445
  });
433
446
 
447
+ // Scan output directory for generated documents
448
+ try {
449
+ await scanTaskOutputDocuments(taskId);
450
+ } catch (error) {
451
+ await db.insert(agentLogs).values({
452
+ id: crypto.randomUUID(),
453
+ taskId,
454
+ agentType: agentProfileId,
455
+ event: "output_scan_failed",
456
+ payload: JSON.stringify({
457
+ error: error instanceof Error ? error.message : String(error),
458
+ }),
459
+ timestamp: new Date(),
460
+ });
461
+ }
462
+
434
463
  // Record usage
435
464
  await recordUsageLedgerEntry({
436
465
  taskId,
@@ -39,6 +39,11 @@ import {
39
39
  recordUsageLedgerEntry,
40
40
  resolveUsageActivityType,
41
41
  } from "@/lib/usage/ledger";
42
+ import {
43
+ scanTaskOutputDocuments,
44
+ prepareTaskOutputDirectory,
45
+ buildTaskOutputInstructions,
46
+ } from "@/lib/documents/output-scanner";
42
47
 
43
48
  // ── SDK lazy import ──────────────────────────────────────────────────
44
49
 
@@ -209,6 +214,14 @@ async function executeOpenAIDirectTask(taskId: string, isResume = false): Promis
209
214
  .where(eq(tasks.id, taskId));
210
215
 
211
216
  const ctx = await buildTaskQueryContext(task, agentProfileId);
217
+
218
+ // Prepare output directory so the agent can write output files
219
+ if (!isResume) {
220
+ await prepareTaskOutputDirectory(taskId);
221
+ }
222
+ const outputInstructions = buildTaskOutputInstructions(taskId);
223
+ ctx.systemInstructions = `${ctx.systemInstructions}\n\n${outputInstructions}`;
224
+
212
225
  const apiKey = await getOpenAIApiKeyValue();
213
226
  const sdk = await getOpenAISDK();
214
227
  const client = new sdk.default({ apiKey });
@@ -347,6 +360,22 @@ async function executeOpenAIDirectTask(taskId: string, isResume = false): Promis
347
360
  timestamp: new Date(),
348
361
  });
349
362
 
363
+ // Scan output directory for generated documents
364
+ try {
365
+ await scanTaskOutputDocuments(taskId);
366
+ } catch (error) {
367
+ await db.insert(agentLogs).values({
368
+ id: crypto.randomUUID(),
369
+ taskId,
370
+ agentType: agentProfileId,
371
+ event: "output_scan_failed",
372
+ payload: JSON.stringify({
373
+ error: error instanceof Error ? error.message : String(error),
374
+ }),
375
+ timestamp: new Date(),
376
+ });
377
+ }
378
+
350
379
  await recordUsageLedgerEntry({
351
380
  taskId,
352
381
  workflowId: task.workflowId ?? null,
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { existsSync, readFileSync, readdirSync } from "fs";
3
+ import { join, resolve } from "path";
4
+ import { CHAPTER_SLUGS, CHAPTER_MAPPING } from "../chapter-mapping";
5
+
6
+ const PROJECT_ROOT = resolve(__dirname, "..", "..", "..", "..");
7
+ const CHAPTERS_DIR = join(PROJECT_ROOT, "book", "chapters");
8
+
9
+ describe("book chapter slug consistency", () => {
10
+ it("CHAPTER_SLUGS is the only slug map (no duplicates in other files)", () => {
11
+ const bookDir = resolve(__dirname, "..");
12
+ const files = readdirSync(bookDir).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
13
+ const violations: Array<{ file: string; line: number; text: string }> = [];
14
+
15
+ for (const file of files) {
16
+ if (file === "chapter-mapping.ts") continue; // canonical source
17
+ const content = readFileSync(join(bookDir, file), "utf-8");
18
+ const lines = content.split("\n");
19
+
20
+ for (let i = 0; i < lines.length; i++) {
21
+ // Detect inline slug maps: object literals with "ch-N": "ch-N-..." patterns
22
+ if (/["']ch-\d+["']\s*:\s*["']ch-\d+-/.test(lines[i])) {
23
+ violations.push({ file, line: i + 1, text: lines[i].trim() });
24
+ }
25
+ }
26
+ }
27
+
28
+ expect(
29
+ violations,
30
+ `Found duplicate slug maps outside chapter-mapping.ts (use CHAPTER_SLUGS import instead):\n${violations
31
+ .map((v) => ` ${v.file}:${v.line} → ${v.text}`)
32
+ .join("\n")}`
33
+ ).toEqual([]);
34
+ });
35
+
36
+ it("every CHAPTER_SLUGS entry has a corresponding markdown file", () => {
37
+ const missing: string[] = [];
38
+
39
+ for (const [chapterId, slug] of Object.entries(CHAPTER_SLUGS)) {
40
+ const filePath = join(CHAPTERS_DIR, `${slug}.md`);
41
+ if (!existsSync(filePath)) {
42
+ missing.push(`${chapterId} → book/chapters/${slug}.md`);
43
+ }
44
+ }
45
+
46
+ expect(
47
+ missing,
48
+ `Chapter markdown files missing for slug entries:\n ${missing.join("\n ")}`
49
+ ).toEqual([]);
50
+ });
51
+
52
+ it("no orphan chapter files exist without a CHAPTER_SLUGS entry", () => {
53
+ const validSlugs = new Set(Object.values(CHAPTER_SLUGS));
54
+ const files = readdirSync(CHAPTERS_DIR).filter((f) => f.endsWith(".md"));
55
+ const orphans: string[] = [];
56
+
57
+ for (const file of files) {
58
+ const slug = file.replace(/\.md$/, "");
59
+ if (!validSlugs.has(slug)) {
60
+ orphans.push(file);
61
+ }
62
+ }
63
+
64
+ expect(
65
+ orphans,
66
+ `Orphan chapter files found (not in CHAPTER_SLUGS — stale or misnamed):\n ${orphans.join("\n ")}`
67
+ ).toEqual([]);
68
+ });
69
+
70
+ it("CHAPTER_SLUGS and CHAPTER_MAPPING cover the same chapter IDs", () => {
71
+ const slugIds = new Set(Object.keys(CHAPTER_SLUGS));
72
+ const mappingIds = new Set(Object.keys(CHAPTER_MAPPING));
73
+
74
+ const inSlugsOnly = [...slugIds].filter((id) => !mappingIds.has(id));
75
+ const inMappingOnly = [...mappingIds].filter((id) => !slugIds.has(id));
76
+
77
+ expect(inSlugsOnly, `In CHAPTER_SLUGS but not CHAPTER_MAPPING`).toEqual([]);
78
+ expect(inMappingOnly, `In CHAPTER_MAPPING but not CHAPTER_SLUGS`).toEqual([]);
79
+ });
80
+ });
@@ -1,7 +1,8 @@
1
1
  import { readFileSync, existsSync } from "fs";
2
2
  import { join } from "path";
3
- import { CHAPTER_MAPPING } from "./chapter-mapping";
3
+ import { CHAPTER_MAPPING, CHAPTER_SLUGS } from "./chapter-mapping";
4
4
  import { getChapter } from "./content";
5
+ import { getAppRoot } from "../utils/app-root";
5
6
 
6
7
  /** Shared context gathered from disk for prompt assembly */
7
8
  interface ChapterContext {
@@ -29,10 +30,9 @@ export function gatherChapterContext(chapterId: string): ChapterContext {
29
30
 
30
31
  const mapping = CHAPTER_MAPPING[chapterId];
31
32
  const sourceDocSlugs = mapping?.docs ?? [];
32
- const slug = chapterIdToSlug(chapterId);
33
+ const slug = CHAPTER_SLUGS[chapterId] ?? chapterId;
33
34
 
34
- // Resolve paths relative to source file, not cwd (npx-safe)
35
- const appRoot = join(import.meta.dirname ?? __dirname, "..", "..", "..");
35
+ const appRoot = getAppRoot(import.meta.dirname, 3);
36
36
 
37
37
  // Read the current chapter markdown (if it exists)
38
38
  const chapterMdPath = join(appRoot, "book", "chapters", `${slug}.md`);
@@ -179,18 +179,3 @@ export function buildChapterRegenerationPrompt(chapterId: string): string {
179
179
  return sections.join("\n");
180
180
  }
181
181
 
182
- /** Map chapter ID to markdown filename slug */
183
- function chapterIdToSlug(chapterId: string): string {
184
- const slugMap: Record<string, string> = {
185
- "ch-1": "ch-1-project-management",
186
- "ch-2": "ch-2-task-execution",
187
- "ch-3": "ch-3-document-processing",
188
- "ch-4": "ch-4-workflow-orchestration",
189
- "ch-5": "ch-5-scheduled-intelligence",
190
- "ch-6": "ch-6-agent-self-improvement",
191
- "ch-7": "ch-7-multi-agent-swarms",
192
- "ch-8": "ch-8-human-in-the-loop",
193
- "ch-9": "ch-9-autonomous-organization",
194
- };
195
- return slugMap[chapterId] ?? chapterId;
196
- }
@@ -1,5 +1,22 @@
1
1
  /** Maps book chapters to Playbook feature docs and journeys */
2
2
 
3
+ /**
4
+ * Single source of truth: chapter ID → markdown filename slug.
5
+ * Used by content loader, chapter generator, and update detector.
6
+ * Slug corresponds to files in book/chapters/ (without .md extension).
7
+ */
8
+ export const CHAPTER_SLUGS: Record<string, string> = {
9
+ "ch-1": "ch-1-project-management",
10
+ "ch-2": "ch-2-task-execution",
11
+ "ch-3": "ch-3-document-processing",
12
+ "ch-4": "ch-4-workflow-orchestration",
13
+ "ch-5": "ch-5-scheduled-intelligence",
14
+ "ch-6": "ch-6-agent-self-improvement",
15
+ "ch-7": "ch-7-multi-agent-swarms",
16
+ "ch-8": "ch-8-human-in-the-loop",
17
+ "ch-9": "ch-9-autonomous-organization",
18
+ };
19
+
3
20
  interface ChapterMapping {
4
21
  docs: string[];
5
22
  journey?: string;