npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@robotostudio/senku

v0.2.0

Published

Estimate dev hours per Linear ticket from git history + AI. A stateless library plus an interactive CLI; runs on Node and on Trigger.dev.

Readme

Senku

"I'll count every second... and never lose track of time."

Estimate developer hours per Linear ticket from git history + AI. Point it at one or more repos, give it a date range, and it reconstructs who worked on what — ticket by ticket — by scraping merged PRs and direct commits, matching them to Linear tickets, and asking OpenAI for an hours estimate per ticket.

It ships two ways:

  • A stateless libraryimport { analyze } from "@robotostudio/senku". Every input is a function argument: no config files, no prompts, no env reads, no writes to your cwd. Clone-with-token is built in, so it runs cleanly on a worker (Trigger.dev, a sandbox, a serverless function — see examples/trigger.dev/).
  • An interactive CLIsenku (or bun run start from a checkout). Walks you through picking repos, a date range, the authors to track, and a model, then prints a table and writes a JSON report.

Why "Senku"?

In Dr. Stone, Senku Ishigami was petrified for 3,700 years and stayed conscious the whole time by counting every second — 116,427,065,530 of them — because that count was the foundation for rebuilding civilization. He refused to let a single moment go untracked. This tool does the same for developer time: it digs through git history and refuses to let any work go unaccounted for.

What it does

  1. (Optionally) clones each repo with full history — no working tree, just the git objects.
  2. Scrapes git for merged PRs (across all branches, including staging workflows) and direct commits in a date range.
  3. Extracts Linear ticket IDs from branch names, PR titles, and commit messages.
  4. Fetches ticket details from Linear (title, description, story points, status).
  5. Sends ticket data + git activity to OpenAI for a per-ticket hours estimate (with a heuristic fallback).
  6. Returns a structured RunOutput — totals by repo, by ticket, and by user.

Library

npm install @robotostudio/senku
# or: bun add @robotostudio/senku

Requires Node 20+ and the git binary on PATH. (analyze() calls assertGitAvailable() first and throws an actionable error if git is missing — including the one-liner to add to trigger.config.ts.)

import { analyze } from "@robotostudio/senku";

const result = await analyze({
  repos: [
    { url: "https://github.com/your-org/web.git", token: process.env.GITHUB_TOKEN },
    { url: "https://github.com/your-org/api.git", token: process.env.GITHUB_TOKEN },
    // ...or an absolute path to a repo already on disk:
    // "/Users/me/work/web",
  ],
  linearApiKey: process.env.LINEAR_API_KEY!,
  openaiApiKey: process.env.OPENAI_API_KEY!,
  dateRange: { from: "2026-04-01", to: "2026-04-30" }, // Date objects work too
  // users: ["Jane Dev"],  // optional — omit to include everyone with commits in range
  // model: "gpt-4o",      // optional — this is the default
  // org: "your-org",      // optional label
  onProgress: (msg) => console.log(msg),
});

console.log(result.totalHours, result.repos, result.userSummary);

URL repos are cloned to a temp directory (full history, no checkout), scraped, and deleted before analyze() resolves. Path-string repos are used in place and never modified. Tokens are sent via an http.extraHeader on the clone invocation only — never written to .git/config, never logged.

If a target org enforces SAML SSO, the PAT has to be authorized for that org (GitHub → Settings → Developer settings → Personal access tokens → your token → Configure SSO) — analyze() surfaces the GitHub message and a hint when that's the cause. A repo that fails to clone (bad token, not found, SSO not authorized) is collected into RunOutput.cloneErrors and the run continues with the rest; if every repo fails, analyze() throws.

AnalyzeParams

| Field | Type | Required | Default | Notes | | --- | --- | --- | --- | --- | | repos | Array<{ url: string; token?: string } \| string> | yes | — | A { url, token? }, or a string. A string with a URL scheme (https://, git@, ssh://) is cloned (with githubToken) & deleted; any other string is an on-disk path used in place. Must be non-empty. | | linearApiKey | string | yes | — | Linear personal API key (lin_api_...). Resolves ticket IDs found in commit/PR messages. | | openaiApiKey | string | yes | — | OpenAI API key (sk-...). Used for the AI hours estimate. | | githubToken | string | no | — | Default GitHub PAT applied to any URL repo without its own token — set it once instead of per-repo. Sent via http.extraHeader on the clone only; never persisted or logged. | | dateRange | { from: Date \| string; to: Date \| string } | yes | — | Inclusive commit window. Strings are parsed (ISO-8601), so the params survive JSON serialization. | | model | string | no | "gpt-4o" | OpenAI model name. | | users | string[] | no | all authors in range | Git author names/emails to include (case-insensitive). | | onlyAuthenticatedUser | boolean | no | false | Restrict the analysis to the token-owner's commits/PRs (resolved via GitHub GET /user). Mutually exclusive with users; requires a token (githubToken or a per-repo token). | | org | string | no | "senku" | Label — surfaces as org in the result. | | onProgress | (message: string) => void | no | — | Progress callback (human-readable status lines). | | logger | pino.Logger | no | a silent logger | For internal diagnostics. | | skipEstimation | boolean | no | false | When true, skips the AI estimation step. Every TicketEntry is returned with estimatedHours: 0, confidence: "low", and reasoning: "estimation skipped". Useful for callers that run their own estimation and only need the git + Linear data. |

Returns a RunOutput:

interface RunOutput {
  org: string;
  generatedAt: string;            // ISO timestamp
  dateRange: { from: string; to: string };
  totalHours: number;
  repos: Array<{
    repo: string;
    totalHours: number;
    tickets: Array<{
      ticketId: string;
      ticket: LinearTicket | null;  // null = ID found but not in Linear
      prs: PRData[];
      users: string[];
      repo: string;
      estimatedHours: number;
      confidence: "high" | "medium" | "low";
      reasoning: string;
    }>;
  }>;
  userSummary: Array<{ user: string; totalHours: number; ticketCount: number }>;
}

Lower-level building blocks

analyze() is the one-shot path. To compose the pipeline yourself, the pieces are exported too: cloneRepo, scrapeRepo, getRepoName, getAuthors, checkRepoHealth, assertGitAvailable, discoverRepos, runPipeline, aggregateByTicket, estimateTicketHours, createLinearClient, fetchTickets, extractFromPR — plus all the types.

dailyDigest() — daily per-user GitHub API ingest

For automated, cron-scheduled work attribution (Trigger.dev, Vercel Cron, scheduled lambdas), use dailyDigest() instead of analyze(). It reads the GitHub API directly — no local clone required — and is designed for serverless environments.

import { dailyDigest } from "@robotostudio/senku";

const result = await dailyDigest({
  users: [
    {
      login: "alice",
      githubToken: process.env.GITHUB_TOKEN_ALICE,
      linearApiKey: process.env.LINEAR_API_KEY_WORKSPACE_A,
      repos: [{ slug: "org-a/repo-1" }, { slug: "org-a/repo-2" }],
    },
    {
      login: "bob",
      githubToken: process.env.GITHUB_TOKEN_BOB,
      linearApiKey: process.env.LINEAR_API_KEY_WORKSPACE_B,
      repos: [{ slug: "org-b/repo-1" }],
    },
  ],
  openaiApiKey: process.env.OPENAI_API_KEY,
  since: "2026-05-21T00:00:00Z",
  until: "2026-05-28T23:59:59Z",
  org: "daily-digest-example",
});

// result.users[0].output has the same shape as analyze() RunOutput
for (const user of result.users) {
  console.log(`${user.login}: ${user.output.totalHours}h`);
}

Key differences from analyze():

| Aspect | analyze() | dailyDigest() | |--------|-----------|-----------------| | Data source | Local git clone (git log --all) | GitHub API (REST + GraphQL) | | Token model | One githubToken → all repos | Per-user githubToken (org-scoped) | | Attribution window | Commit window (from/to dates on commits) | Activity window — when work landed on GitHub (PR updated / commit pushed), not originally written | | Use case | Historical analysis, deep dives | Daily crons, per-user aggregation, serverless tasks | | Rate limit | None (local) | 5,000 API calls/hour per GitHub token |

When to use which:

  • Use analyze() — One-off historical windows, all repos in one org, you control the GitHub token, want exact commit history.
  • Use dailyDigest() — Daily crons, different GitHub orgs need different tokens, running in serverless (Trigger.dev), working with open PRs that haven't been merged yet.
  • Use both — Combine in a morning digest: yesterday's dailyDigest() for quick per-user rollup, weekly analyze() for ticket-by-ticket detail.

See docs/digest-context.md for domain terminology (attribution, work unit, user definitions).

CLI

From a checkout:

git clone https://github.com/robotostudio/senku.git
cd senku
bun install
bun run start

Or, once installed as a package, the senku bin is on your PATH.

Prerequisites: Bun (for bun run start from a checkout) or Node 20+ (for the installed senku bin), a Linear API key, an OpenAI API key, and local clones of the repos you want to analyze.

On first run the CLI prompts for your Linear and OpenAI keys and saves them to ~/.senku/.env (mode 600). Environment variables take precedence over that file. To reconfigure, edit ~/.senku/.env or delete it to re-trigger setup.

 senku

  Config: ~/.senku/.env
  Organization name › tray
  How to select repos? › Scan a directory
  Parent directory to scan › ~/work
  Select repos to analyze › website-hyperion, website-hyperion-agent
  Date range › Last 30 days
  Select users to track › aayushroboto (176 commits), SameerJSRS (94 commits), ...
  OpenAI model › gpt-4o (recommended)
  Start analysis? › Yes

  trayio/website-hyperion: 97 PRs from git log
  Found 59 unique ticket IDs
  Fetched 48/59 Linear tickets
  Estimating hours for 48 resolved tickets via AI...
  Pipeline complete

  JSON output: ./senku-tray-2026-04-30.json
  Log: ~/.senku/logs/2026-04-30T14-30-00-tray.log

Repos can be local directories (scanned or entered by path) or cloned from GitHub URLs — for the clone option you give a personal access token (needed for private repos) and choose whether to track everyone with commits in range or just yourself (identified via the token, the CLI equivalent of analyze({ onlyAuthenticatedUser: true })).

The CLI writes ./senku-{org}-{date}.json (same shape as RunOutput), prints a color-coded table, and logs full debug output to ~/.senku/logs/.

Before scanning, it warns about each repo's health: not on the default branch, behind remote (suggests git pull), uncommitted changes (suggests git stash), or a stale fetch (>24h, suggests git fetch).

Run it on Trigger.dev

examples/trigger.dev/ is a ready-to-deploy task that wraps analyze(). Full walkthrough is in that folder's README; the essentials:

Install git in the image. senku shells out to git, which the Trigger.dev build image doesn't ship — trigger.config.ts adds it (forget this and the task fails fast with git is not available on PATH...):

import { additionalPackages } from "@trigger.dev/build/extensions/core";
import { defineConfig } from "@trigger.dev/sdk/v3";

export default defineConfig({
  project: "<your-project-ref>",
  runtime: "node",
  maxDuration: 3600,
  build: { extensions: [additionalPackages({ packages: ["git"] })] },
});

Authenticate. Three different tokens are in play — don't mix them up:

| Token | Authenticates | Where it goes | Where to get it | | --- | --- | --- | --- | | Trigger.dev Personal Access Token (tr_pat_…) | the trigger.dev CLI — i.e. you, running dev / deploy | npx trigger.dev login (interactive, browser), or TRIGGER_ACCESS_TOKEN=tr_pat_… for CI/headless | Trigger.dev dashboard → account menu → Personal Access Tokens | | Trigger.dev project secret key (tr_dev_… / tr_prod_…) | @trigger.dev/sdk when your app enqueues a run with tasks.trigger(...) | TRIGGER_SECRET_KEY env var in your app (the trigger.dev dev worker handles this for you) | Trigger.dev dashboard → Project settings → API keys | | GitHub PAT (ghp_… or fine-grained, Contents: read) | cloning a private repo inside the task | the token of each repo, or the top-level githubToken, in the analyze() payload — not in any Trigger.dev config (or read process.env.GITHUB_TOKEN in the task) | GitHub → Settings → Developer settings → Personal access tokens |

So yes — a Personal Access Token is the headless auth path: TRIGGER_ACCESS_TOKEN=tr_pat_… npx trigger.dev@latest deploy (no browser). Interactively, npx trigger.dev@latest login does the same via a browser.

Test it.

  • Against the local dev workernpx trigger.dev@latest dev: your task code runs on your machine, Trigger.dev orchestrates it. Fire senku-analyze from the dashboard's Test tab (paste a JSON payload) or from a script with tasks.trigger(...). Caveat: git is on your machine here, so this won't catch a missing build extension.
  • On Trigger.dev's infranpx trigger.dev@latest deploy, then trigger it the same way. This is the one that proves additionalPackages({ packages: ["git"] }) works in the deployed image.

Example payload (Trigger.dev round-trips it as JSON, so dates are ISO strings):

{
  "repos": ["https://github.com/your-org/web.git", "https://github.com/your-org/api.git"],
  "githubToken": "ghp_…",            // applies to all the repos above (omit for public repos)
  "linearApiKey": "lin_api_…",
  "openaiApiKey": "sk-…",
  "dateRange": { "from": "2026-04-01", "to": "2026-04-30" },
  "onlyAuthenticatedUser": true       // optional — just your commits; omit (or pass "users") for others
}

Trigger it from your app:

import { tasks } from "@trigger.dev/sdk/v3";
import type { senkuAnalyze } from "./trigger/senku-analyze";

await tasks.trigger<typeof senkuAnalyze>("senku-analyze", {
  repos: ["https://github.com/your-org/web.git"],
  githubToken: process.env.GITHUB_TOKEN,
  linearApiKey: process.env.LINEAR_API_KEY,
  openaiApiKey: process.env.OPENAI_API_KEY,
  dateRange: { from: "2026-04-01", to: "2026-04-30" },
});
// needs TRIGGER_SECRET_KEY set — tr_dev_… while the dev worker is up, tr_prod_… for the deployed task

How it works

Git analysis

  • Uses git log --all --merges to find PRs across all branches (main, staging, feature branches).
  • Handles staging workflows: PRs merged into a staging branch and then into main are still captured.
  • Filters out sync merges (merge main into feature-branch) — they inflate line counts but aren't work.
  • Filters out deployment merges — the sub-PRs are captured individually.
  • Detects the actual code author even when someone else clicked "Merge" on GitHub.
  • Cloning (when given a URL) uses --no-checkout — only the git objects are fetched, no working tree.

Ticket extraction

  • Pulls Linear ticket IDs from branch names, PR titles, and commit messages ([A-Za-z]{2,}-\d+).
  • Each PR maps to exactly one primary ticket (from the branch name) to avoid double-counting.

AI estimation

  • OpenAI (default gpt-4o) via the Vercel AI SDK with a structured (Zod) schema.
  • Concurrent API calls for speed; the system prompt includes team-capacity context.
  • Discounts auto-generated code (typegen, schema regeneration).
  • If the total exceeds team capacity it scales proportionally; if the AI call fails it falls back to a heuristic (0.5h/commit + 1h/PR).

Confidence levels

  • high — 3+ meaningful commits, ticket has a description, clear scope.
  • medium — 1–2 commits or ambiguous scope.
  • low — only merge activity, no ticket description, or heuristic fallback.

Unmapped tickets

IDs that don't resolve in Linear get estimatedHours: 0 and are flagged for manual review (regex false positives, IDs from other tools, deleted/archived tickets).

Development

bun install
bun run dev            # CLI in watch mode
bun test               # unit tests
bun run test:coverage  # unit tests + coverage gate (97% lines / 100% functions)
bun run check          # Biome lint + format check
bun run typecheck      # tsc --noEmit
bun run build          # tsc -p tsconfig.build.json → dist/ (library + CLI)
bun run test:integration   # hits the real Linear + OpenAI APIs; needs LINEAR_API_KEY / OPENAI_API_KEY

License

MIT