📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-05-29 08:33:53 +00:00
parent fdb52f1e96
commit 06e0d13d57
1615 changed files with 232858 additions and 0 deletions
@@ -0,0 +1,243 @@
#!/usr/bin/env bun
/**
* detect.ts - Detect available project tools
*
* Quick pre-flight check for sitrep service availability.
* Returns JSON with boolean availability for each tool.
*
* Usage:
* ./detect.ts # JSON output
* ./detect.ts --format=text # Human-readable output
*/
import { parseArgs } from "node:util";
/**
* Result of detecting available project tools.
*/
interface DetectResult {
/** Whether Graphite CLI is available and initialized */
graphite: boolean;
/** Whether GitHub CLI is available and authenticated */
github: boolean;
/** Whether Linear MCP is available */
linear: boolean;
/** Whether Beads issue tracking is initialized */
beads: boolean;
/** Human-readable status details for each tool */
details: {
graphite?: string;
github?: string;
linear?: string;
beads?: string;
};
}
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
format: { type: "string", short: "f", default: "json" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(`
detect.ts - Detect available project tools for sitrep
Usage:
./detect.ts [options]
Options:
-f, --format <fmt> Output format: json, text [default: json]
-h, --help Show this help
Output:
JSON object with boolean availability for each service:
- graphite: gt CLI installed and initialized
- github: gh CLI installed and authenticated
- linear: Linear MCP available (checks for mcp tools)
- beads: .beads/ directory exists in current project
`);
process.exit(0);
}
/**
* Checks if a command exists in PATH.
* @param cmd - Command name to check
* @returns True if command is available
*/
async function commandExists(cmd: string): Promise<boolean> {
const proc = Bun.spawn(["command", "-v", cmd], {
stdout: "pipe",
stderr: "pipe",
});
await proc.exited;
return proc.exitCode === 0;
}
/**
* Runs a command and captures output.
* @param cmd - Command and arguments array
* @returns Object with success status and combined output
*/
async function runCommand(
cmd: string[],
): Promise<{ success: boolean; output: string }> {
const proc = Bun.spawn(cmd, {
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
await proc.exited;
return {
success: proc.exitCode === 0,
output: stdout || stderr,
};
}
/**
* Detects Graphite CLI availability and initialization status.
* @returns Availability and detail message
*/
async function detectGraphite(): Promise<{ available: boolean; detail?: string }> {
if (!(await commandExists("gt"))) {
return { available: false, detail: "gt CLI not installed" };
}
// Check if initialized in this repo
const { success } = await runCommand(["gt", "state"]);
if (!success) {
return { available: false, detail: "gt not initialized in this repo" };
}
return { available: true, detail: "gt CLI ready" };
}
/**
* Detects GitHub CLI availability and authentication status.
* @returns Availability and detail message
*/
async function detectGitHub(): Promise<{ available: boolean; detail?: string }> {
if (!(await commandExists("gh"))) {
return { available: false, detail: "gh CLI not installed" };
}
// Check auth status
const { success, output } = await runCommand(["gh", "auth", "status"]);
if (!success) {
return { available: false, detail: "gh not authenticated" };
}
// Extract account info if available
const match = output.match(/Logged in to .+ as (\S+)/);
const user = match ? match[1] : "authenticated";
return { available: true, detail: `gh CLI ready (${user})` };
}
/**
* Detects Linear MCP availability.
* @returns Availability and detail message
*/
async function detectLinear(): Promise<{ available: boolean; detail?: string }> {
// Linear detection is tricky - we check for the MCP tool availability
// In Claude Code context, this would be detected via tool availability
// For script context, we check if claude CLI exists and has linear configured
if (!(await commandExists("claude"))) {
return { available: false, detail: "claude CLI not installed" };
}
// We can't easily detect MCP availability from a script
// Return unknown/check-at-runtime
return { available: false, detail: "Linear MCP - check at runtime" };
}
/**
* Detects Beads issue tracking initialization.
* @returns Availability and detail message
*/
async function detectBeads(): Promise<{ available: boolean; detail?: string }> {
const beadsDir = Bun.file(".beads/metadata.json");
const exists = await beadsDir.exists();
if (!exists) {
return { available: false, detail: ".beads/ not initialized" };
}
return { available: true, detail: "beads initialized" };
}
/**
* Detects all available project tools in parallel.
* @returns Detection results for all tools
*/
async function detect(): Promise<DetectResult> {
const [graphite, github, linear, beads] = await Promise.all([
detectGraphite(),
detectGitHub(),
detectLinear(),
detectBeads(),
]);
return {
graphite: graphite.available,
github: github.available,
linear: linear.available,
beads: beads.available,
details: {
graphite: graphite.detail,
github: github.detail,
linear: linear.detail,
beads: beads.detail,
},
};
}
/**
* Formats detection results as human-readable text.
* @param result - Detection results to format
* @returns Formatted text output
*/
function formatText(result: DetectResult): string {
const lines: string[] = ["PROJECT TOOLS", ""];
const status = (available: boolean) => (available ? "✓" : "✗");
lines.push(`${status(result.graphite)} Graphite: ${result.details.graphite}`);
lines.push(`${status(result.github)} GitHub: ${result.details.github}`);
lines.push(`${status(result.linear)} Linear: ${result.details.linear}`);
lines.push(`${status(result.beads)} Beads: ${result.details.beads}`);
const available = [
result.graphite && "graphite",
result.github && "github",
result.linear && "linear",
result.beads && "beads",
].filter(Boolean);
lines.push("");
lines.push(
available.length > 0
? `Available: ${available.join(", ")}`
: "No tools detected",
);
return lines.join("\n");
}
async function main() {
const result = await detect();
if (values.format === "text") {
console.log(formatText(result));
} else {
console.log(JSON.stringify(result, null, 2));
}
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});
@@ -0,0 +1,195 @@
#!/usr/bin/env bun
/**
* Beads gatherer for status
*
* Collects local issue data from .beads/ directory
* - Stats overview
* - In-progress work
* - Ready items (unblocked)
* - Blocked items with dependencies
* - Recently closed (filtered by time)
*/
import { parseArgs } from "node:util";
import { filterByTime, parseTimeConstraint } from "../lib/time";
import type {
BeadsData,
BeadsIssue,
BeadsStats,
GathererResult,
} from "../lib/types";
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
time: { type: "string", short: "t", default: "24h" },
workspace: { type: "string", short: "w" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(`
beads-gatherer.ts - Gather beads issue data
Usage:
./beads-gatherer.ts [options]
Options:
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
-w, --workspace <path> Workspace root [default: current directory]
-h, --help Show this help
Output:
JSON GathererResult with BeadsData
`);
process.exit(0);
}
/**
* Result of running a bd CLI command.
*/
interface BdOutput<T> {
success: boolean;
data?: T;
error?: string;
}
/**
* Runs a bd CLI command and parses JSON output.
* @param args - Arguments to pass to bd
* @returns Parsed output or error
*/
async function runBd<T>(args: string[]): Promise<BdOutput<T>> {
const workspaceArgs = values.workspace
? ["--workspace-root", values.workspace]
: [];
const proc = Bun.spawn(["bd", ...workspaceArgs, ...args, "--json"], {
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
return {
success: false,
error: stderr || `bd exited with code ${exitCode}`,
};
}
try {
const data = JSON.parse(stdout);
return { success: true, data };
} catch {
return { success: false, error: `Failed to parse bd output: ${stdout}` };
}
}
/**
* Checks if Beads is initialized in workspace.
* @returns True if .beads directory exists
*/
async function checkBeadsAvailable(): Promise<boolean> {
// Check if .beads directory exists
const beadsDir = values.workspace ? `${values.workspace}/.beads` : ".beads";
const file = Bun.file(`${beadsDir}/issues.db`);
return file.exists();
}
/**
* Gathers Beads issue tracking data.
* @returns Gatherer result with Beads data
*/
async function gatherBeadsData(): Promise<GathererResult<BeadsData>> {
const timestamp = new Date().toISOString();
// Check if beads is available
const available = await checkBeadsAvailable();
if (!available) {
return {
source: "beads",
status: "unavailable",
reason: "Beads not initialized (.beads/ directory not found)",
timestamp,
};
}
// Parse time constraint
const timeValue = values.time ?? "24h";
let timeMs: number;
try {
timeMs = parseTimeConstraint(timeValue);
} catch (e) {
return {
source: "beads",
status: "error",
error: e instanceof Error ? e.message : "Invalid time constraint",
timestamp,
};
}
// Gather data in parallel
const [
statsResult,
inProgressResult,
readyResult,
blockedResult,
closedResult,
] = await Promise.all([
runBd<BeadsStats>(["stats"]),
runBd<BeadsIssue[]>(["list", "--status=in_progress", "--limit=10"]),
runBd<BeadsIssue[]>(["ready", "--limit=10"]),
runBd<BeadsIssue[]>(["blocked"]),
runBd<BeadsIssue[]>(["list", "--status=closed", "--limit=20"]),
]);
// Check for fatal errors (stats should always work if beads is available)
if (!statsResult.success) {
return {
source: "beads",
status: "error",
error: statsResult.error || "Failed to get beads stats",
timestamp,
};
}
// Build result, handling partial failures gracefully
const stats = statsResult.data ?? {
total: 0,
open: 0,
in_progress: 0,
blocked: 0,
closed: 0,
};
const inProgress = inProgressResult.success
? (inProgressResult.data ?? [])
: [];
const ready = readyResult.success ? (readyResult.data ?? []) : [];
const blocked = blockedResult.success ? (blockedResult.data ?? []) : [];
const closed = closedResult.success ? (closedResult.data ?? []) : [];
// Filter closed issues by time constraint (client-side)
const recentlyClosed = filterByTime(closed, timeMs);
return {
source: "beads",
status: "success",
data: {
stats,
inProgress,
ready,
blocked,
recentlyClosed,
},
timestamp,
};
}
// Main execution
const result = await gatherBeadsData();
console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,282 @@
#!/usr/bin/env bun
/**
* GitHub gatherer for status
*
* Collects data via `gh` CLI:
* - Open PRs with CI status and review decisions
* - Recent workflow runs
*/
import { parseArgs } from "node:util";
import { parseTimeConstraint, toCutoffDate } from "../lib/time";
import type {
GathererResult,
GitHubData,
GitHubPR,
GitHubWorkflowRun,
} from "../lib/types";
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
time: { type: "string", short: "t", default: "24h" },
repo: { type: "string", short: "r" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(`
github-gatherer.ts - Gather GitHub PR and CI data
Usage:
./github-gatherer.ts [options]
Options:
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
-r, --repo <owner/repo> Repository [default: current repo]
-h, --help Show this help
Output:
JSON GathererResult with GitHubData
`);
process.exit(0);
}
/**
* Result of running a gh CLI command.
*/
interface GhOutput<T> {
success: boolean;
data?: T;
error?: string;
}
/**
* Runs a gh CLI command and parses JSON output.
* @param args - Arguments to pass to gh
* @returns Parsed output or error
*/
async function runGh<T>(args: string[]): Promise<GhOutput<T>> {
const repoArgs = values.repo ? ["-R", values.repo] : [];
const proc = Bun.spawn(["gh", ...repoArgs, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
return {
success: false,
error: stderr || `gh exited with code ${exitCode}`,
};
}
try {
const data = JSON.parse(stdout);
return { success: true, data };
} catch {
return { success: false, error: `Failed to parse gh output: ${stdout}` };
}
}
/**
* Checks if gh CLI is installed.
* @returns True if gh is available
*/
async function checkGhAvailable(): Promise<boolean> {
const proc = Bun.spawn(["which", "gh"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
return exitCode === 0;
}
/**
* Checks if gh CLI is authenticated.
* @returns True if authenticated
*/
async function checkGhAuth(): Promise<boolean> {
const proc = Bun.spawn(["gh", "auth", "status"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
return exitCode === 0;
}
/**
* Gets the current repository name (owner/repo format).
* @returns Repository name or null if not in a repo
*/
async function getRepoName(): Promise<string | null> {
if (values.repo) return values.repo;
const proc = Bun.spawn(
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
{
stdout: "pipe",
stderr: "pipe",
},
);
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
if (exitCode !== 0) return null;
return stdout.trim();
}
// GraphQL query for PRs with status checks
const PR_QUERY_FIELDS = [
"number",
"title",
"state",
"isDraft",
"author",
"updatedAt",
"url",
"headRefName",
"statusCheckRollup",
"reviewDecision",
].join(",");
/**
* Gathers GitHub data including PRs and workflow runs.
* @returns Gatherer result with GitHub data
*/
async function gatherGitHubData(): Promise<GathererResult<GitHubData>> {
const timestamp = new Date().toISOString();
// Check gh CLI availability
const ghAvailable = await checkGhAvailable();
if (!ghAvailable) {
return {
source: "github",
status: "unavailable",
reason: "gh CLI not installed",
timestamp,
};
}
// Check authentication
const ghAuth = await checkGhAuth();
if (!ghAuth) {
return {
source: "github",
status: "unavailable",
reason: "gh CLI not authenticated (run: gh auth login)",
timestamp,
};
}
// Get repo name
const repo = await getRepoName();
if (!repo) {
return {
source: "github",
status: "unavailable",
reason: "Not in a GitHub repository",
timestamp,
};
}
// Parse time constraint
const timeValue = values.time ?? "24h";
let timeMs: number;
try {
timeMs = parseTimeConstraint(timeValue);
} catch (e) {
return {
source: "github",
status: "error",
error: e instanceof Error ? e.message : "Invalid time constraint",
timestamp,
};
}
const cutoff = toCutoffDate(timeMs);
const _cutoffDate = cutoff.toISOString().split("T")[0]; // YYYY-MM-DD for search
// Gather data in parallel
const [prsResult, runsResult] = await Promise.all([
// Get open PRs (no date filter needed - we want all open)
runGh<GitHubPR[]>([
"pr",
"list",
"--state=open",
"--json",
PR_QUERY_FIELDS,
"--limit=20",
]),
// Get recent workflow runs
runGh<GitHubWorkflowRun[]>([
"run",
"list",
"--json",
"name,status,conclusion,createdAt,url",
"--limit=20",
]),
]);
if (!prsResult.success && !runsResult.success) {
return {
source: "github",
status: "error",
error:
prsResult.error || runsResult.error || "Failed to fetch GitHub data",
timestamp,
};
}
// Transform PR data to match our types
const openPRs: GitHubPR[] = (prsResult.data || []).map(
(pr: Record<string, unknown>) => ({
number: pr.number as number,
title: pr.title as string,
state: pr.state as "OPEN" | "CLOSED" | "MERGED",
isDraft: pr.isDraft as boolean,
author: pr.author as { login: string },
updatedAt: pr.updatedAt as string,
url: pr.url as string,
headRefName: pr.headRefName as string,
statusCheckRollup: pr.statusCheckRollup as GitHubPR["statusCheckRollup"],
reviewDecision: pr.reviewDecision as GitHubPR["reviewDecision"],
}),
);
// Filter workflow runs by time
const allRuns = runsResult.data || [];
const recentRuns: GitHubWorkflowRun[] = allRuns
.filter(
(run: Record<string, unknown>) =>
new Date(run.createdAt as string) >= cutoff,
)
.map((run: Record<string, unknown>) => ({
name: run.name as string,
status: run.status as string,
conclusion: run.conclusion as string | null,
createdAt: run.createdAt as string,
url: run.url as string,
}));
return {
source: "github",
status: "success",
data: {
repo,
openPRs,
recentRuns,
},
timestamp,
};
}
// Main execution
const result = await gatherGitHubData();
console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,375 @@
#!/usr/bin/env bun
/**
* Graphite gatherer for status
*
* Collects stack and branch data via `gt` CLI:
* - Stack structure and hierarchy
* - Branch PR status
* - Restack/submit needs
* - Recent commits via git
*/
import { parseArgs } from "node:util";
import { parseTimeConstraint, toGitSince } from "../lib/time";
import type {
GathererResult,
GraphiteBranch,
GraphiteData,
} from "../lib/types";
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
time: { type: "string", short: "t", default: "24h" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(`
graphite-gatherer.ts - Gather Graphite stack data
Usage:
./graphite-gatherer.ts [options]
Options:
-t, --time <constraint> Time constraint for commits (24h, 7d, 2w) [default: 24h]
-h, --help Show this help
Output:
JSON GathererResult with GraphiteData
`);
process.exit(0);
}
/**
* Result of running a shell command.
*/
interface CmdOutput {
success: boolean;
stdout: string;
stderr: string;
}
/**
* Runs a shell command and captures output.
* @param cmd - Command to run
* @param args - Arguments to pass
* @returns Command output
*/
async function runCmd(cmd: string, args: string[]): Promise<CmdOutput> {
const proc = Bun.spawn([cmd, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
return { success: exitCode === 0, stdout, stderr };
}
/**
* Checks if Graphite CLI is installed.
* @returns True if gt is available
*/
async function checkGtAvailable(): Promise<boolean> {
const result = await runCmd("which", ["gt"]);
return result.success;
}
/**
* Checks if current directory is a git repository.
* @returns True if in a git repo
*/
async function checkGitRepo(): Promise<boolean> {
const result = await runCmd("git", ["rev-parse", "--git-dir"]);
return result.success;
}
/**
* Gets Graphite stack state from gt CLI.
* @returns Stack state or null on failure
*/
async function getGtState(): Promise<{
branches: GraphiteBranch[];
stacks: string[][];
currentBranch: string;
trunk: string;
} | null> {
// Get structured state from gt
const result = await runCmd("gt", ["log", "--json"]);
if (!result.success) {
// Try alternate: gt state
const stateResult = await runCmd("gt", ["state"]);
if (!stateResult.success) return null;
// Parse text output as fallback
return parseGtStateText(stateResult.stdout);
}
try {
const data = JSON.parse(result.stdout);
return parseGtLogJson(data);
} catch {
return null;
}
}
/**
* Parses JSON output from gt log command.
* @param data - Raw JSON data
* @returns Structured Graphite state
*/
function parseGtLogJson(data: unknown): {
branches: GraphiteBranch[];
stacks: string[][];
currentBranch: string;
trunk: string;
} {
// gt log --json returns array of branch entries
const entries = Array.isArray(data) ? data : [];
const branchMap = new Map<string, GraphiteBranch>();
let currentBranch = "main";
const trunk = "main";
for (const entry of entries) {
const branch: GraphiteBranch = {
name: entry.branch || entry.name || "",
prNumber: entry.pr?.number,
prStatus: mapPrState(entry.pr?.state, entry.pr?.isDraft),
prUrl: entry.pr?.url,
parent: entry.parent,
children: [],
isCurrent: entry.isCurrent || entry.current || false,
needsRestack: entry.needsRestack || false,
needsSubmit: entry.needsSubmit || false,
commitCount: entry.commitCount || entry.commits?.length || 0,
};
if (branch.isCurrent) {
currentBranch = branch.name;
}
branchMap.set(branch.name, branch);
}
// Build children relationships
for (const branch of branchMap.values()) {
if (branch.parent && branchMap.has(branch.parent)) {
branchMap.get(branch.parent)?.children.push(branch.name);
}
}
// Build stacks (branches that share a root)
const stacks = buildStacks(branchMap, trunk);
return {
branches: Array.from(branchMap.values()),
stacks,
currentBranch,
trunk,
};
}
/**
* Parses text output from gt state command (fallback).
* @param text - Raw text output
* @returns Structured Graphite state
*/
function parseGtStateText(text: string): {
branches: GraphiteBranch[];
stacks: string[][];
currentBranch: string;
trunk: string;
} {
// Fallback parser for text output
const lines = text.split("\n").filter((l) => l.trim());
const branches: GraphiteBranch[] = [];
let currentBranch = "main";
for (const line of lines) {
// Look for branch indicators like "◉ branch-name" or "○ branch-name"
const match = line.match(/[◉○●◐]\s+(\S+)/);
if (match) {
const name = match[1];
const isCurrent = line.includes("◉") || line.includes("●");
if (isCurrent) currentBranch = name;
branches.push({
name,
children: [],
isCurrent,
needsRestack: line.includes("restack"),
needsSubmit: line.includes("submit"),
commitCount: 0,
});
}
}
return {
branches,
stacks: branches.length > 0 ? [branches.map((b) => b.name)] : [],
currentBranch,
trunk: "main",
};
}
/**
* Maps PR state from API to internal status.
* @param state - API state string
* @param isDraft - Whether PR is a draft
* @returns Normalized status
*/
function mapPrState(
state?: string,
isDraft?: boolean,
): "draft" | "open" | "ready" | "merged" | "closed" | undefined {
if (!state) return undefined;
if (isDraft) return "draft";
switch (state.toLowerCase()) {
case "open":
return "open";
case "merged":
return "merged";
case "closed":
return "closed";
default:
return "open";
}
}
/**
* Builds stack arrays from branch relationships.
* @param branchMap - Map of branch names to branch data
* @param trunk - Trunk branch name
* @returns Array of stacks (each stack is array of branch names)
*/
function buildStacks(
branchMap: Map<string, GraphiteBranch>,
trunk: string,
): string[][] {
const stacks: string[][] = [];
const visited = new Set<string>();
// Find root branches (parent is trunk or undefined)
const roots = Array.from(branchMap.values()).filter(
(b) => !b.parent || b.parent === trunk || !branchMap.has(b.parent),
);
for (const root of roots) {
if (visited.has(root.name)) continue;
const stack: string[] = [];
const queue = [root.name];
while (queue.length > 0) {
const name = queue.shift();
if (!name || visited.has(name)) continue;
visited.add(name);
stack.push(name);
const branch = branchMap.get(name);
if (branch) {
queue.push(...branch.children);
}
}
if (stack.length > 0) {
stacks.push(stack);
}
}
return stacks;
}
/**
* Gets count of recent commits within time window.
* @param timeMs - Time window in milliseconds
* @returns Number of commits
*/
async function getRecentCommits(timeMs: number): Promise<number> {
const since = toGitSince(timeMs);
const result = await runCmd("git", ["log", `--since=${since}`, "--oneline"]);
if (!result.success) return 0;
return result.stdout.split("\n").filter((l) => l.trim()).length;
}
/**
* Gathers Graphite stack and branch data.
* @returns Gatherer result with Graphite data
*/
async function gatherGraphiteData(): Promise<GathererResult<GraphiteData>> {
const timestamp = new Date().toISOString();
// Check prerequisites
const gtAvailable = await checkGtAvailable();
if (!gtAvailable) {
return {
source: "graphite",
status: "unavailable",
reason: "gt CLI not installed",
timestamp,
};
}
const isGitRepo = await checkGitRepo();
if (!isGitRepo) {
return {
source: "graphite",
status: "unavailable",
reason: "Not in a git repository",
timestamp,
};
}
// Parse time constraint
const timeValue = values.time ?? "24h";
let timeMs: number;
try {
timeMs = parseTimeConstraint(timeValue);
} catch (e) {
return {
source: "graphite",
status: "error",
error: e instanceof Error ? e.message : "Invalid time constraint",
timestamp,
};
}
// Get graphite state
const state = await getGtState();
if (!state) {
return {
source: "graphite",
status: "error",
error: "Failed to parse gt output",
timestamp,
};
}
// Get recent commit count (informational)
const _recentCommits = await getRecentCommits(timeMs);
return {
source: "graphite",
status: "success",
data: {
currentBranch: state.currentBranch,
trunk: state.trunk,
branches: state.branches,
stacks: state.stacks,
},
timestamp,
};
}
// Main execution
const result = await gatherGraphiteData();
console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,253 @@
#!/usr/bin/env bun
/**
* Linear gatherer for status
*
* Collects Linear issue data via Claude CLI headless mode (MCP)
* - Checks if Linear MCP is configured
* - Queries recent issues via claude --print
*/
import { homedir } from "node:os";
import { parseArgs } from "node:util";
import { parseTimeConstraint, toISOPeriod } from "../lib/time";
import type { GathererResult, LinearData, LinearIssue } from "../lib/types";
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
time: { type: "string", short: "t", default: "24h" },
team: { type: "string" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(`
linear-gatherer.ts - Gather Linear issue data
Usage:
./linear-gatherer.ts [options]
Options:
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
--team <team-key> Linear team key to filter by
-h, --help Show this help
Output:
JSON GathererResult with LinearData
Note:
Requires Linear MCP to be configured in Claude settings.
Uses 'claude --print' headless mode to query via MCP.
`);
process.exit(0);
}
/**
* Checks if Linear MCP is configured in Claude settings.
* @returns True if Linear MCP is configured
*/
async function checkLinearMCPConfigured(): Promise<boolean> {
// Check both user and project settings
const settingsPaths = [
`${homedir()}/.claude/settings.json`,
`${homedir()}/.claude/settings.local.json`,
".claude/settings.json",
".claude/settings.local.json",
];
for (const path of settingsPaths) {
const file = Bun.file(path);
if (await file.exists()) {
try {
const content = await file.json();
// Check for Linear in mcpServers
if (content.mcpServers) {
const servers = Object.keys(content.mcpServers);
if (servers.some((s) => s.toLowerCase().includes("linear"))) {
return true;
}
}
} catch {
// Continue checking other files
}
}
}
return false;
}
/**
* Checks if Claude CLI is installed.
* @returns True if claude is available
*/
async function checkClaudeCliAvailable(): Promise<boolean> {
const proc = Bun.spawn(["which", "claude"], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
return exitCode === 0;
}
/**
* Queries Linear issues via Claude CLI headless mode.
* @param timeMs - Time window in milliseconds
* @param team - Optional team key to filter by
* @returns Array of issues or null on failure
*/
async function queryLinearViaClaude(
timeMs: number,
team?: string,
): Promise<LinearIssue[] | null> {
const _period = toISOPeriod(timeMs);
// Build the prompt for Claude
const teamFilter = team ? ` for team ${team}` : "";
const prompt = `Use the Linear MCP tools to list issues updated in the last ${Math.round(timeMs / (60 * 60 * 1000))} hours${teamFilter}.
Return ONLY a JSON array of issues with this structure (no other text):
[
{
"identifier": "TEAM-123",
"title": "Issue title",
"state": { "name": "In Progress", "type": "started" },
"priority": 2,
"assignee": { "name": "Person Name" },
"labels": [{ "name": "label1" }],
"createdAt": "ISO date",
"updatedAt": "ISO date",
"url": "https://linear.app/..."
}
]
If no issues found, return an empty array [].`;
try {
const proc = Bun.spawn(
[
"claude",
"--print",
prompt,
"--output-format",
"json",
"--max-turns",
"3",
],
{
stdout: "pipe",
stderr: "pipe",
timeout: 60000, // 60 second timeout
},
);
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
return null;
}
// Try to parse the response
// Claude's JSON output may include a wrapper, extract the issues array
const parsed = JSON.parse(stdout);
// Handle different response formats
if (Array.isArray(parsed)) {
return parsed as LinearIssue[];
}
// If wrapped in a result object
if (parsed.result && Array.isArray(parsed.result)) {
return parsed.result as LinearIssue[];
}
// If wrapped in content
if (parsed.content) {
const content =
typeof parsed.content === "string"
? parsed.content
: JSON.stringify(parsed.content);
// Try to extract JSON array from content
const match = content.match(/\[[\s\S]*\]/);
if (match) {
return JSON.parse(match[0]) as LinearIssue[];
}
}
return [];
} catch {
return null;
}
}
/**
* Gathers Linear issue data via MCP.
* @returns Gatherer result with Linear data
*/
async function gatherLinearData(): Promise<GathererResult<LinearData>> {
const timestamp = new Date().toISOString();
// Check if Linear MCP is configured
const mcpConfigured = await checkLinearMCPConfigured();
if (!mcpConfigured) {
return {
source: "linear",
status: "unavailable",
reason: "Linear MCP not configured in Claude settings",
timestamp,
};
}
// Check if Claude CLI is available
const claudeAvailable = await checkClaudeCliAvailable();
if (!claudeAvailable) {
return {
source: "linear",
status: "unavailable",
reason: "Claude CLI not installed",
timestamp,
};
}
// Parse time constraint
const timeValue = values.time ?? "24h";
let timeMs: number;
try {
timeMs = parseTimeConstraint(timeValue);
} catch (e) {
return {
source: "linear",
status: "error",
error: e instanceof Error ? e.message : "Invalid time constraint",
timestamp,
};
}
// Query Linear via Claude
const issues = await queryLinearViaClaude(timeMs, values.team);
if (issues === null) {
return {
source: "linear",
status: "error",
error: "Failed to query Linear via Claude CLI",
timestamp,
};
}
return {
source: "linear",
status: "success",
data: {
team: values.team,
issues,
},
timestamp,
};
}
// Main execution
const result = await gatherLinearData();
console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,117 @@
/**
* Time parsing utilities for status gatherers
*/
const TIME_UNITS: Record<string, number> = {
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000,
};
/**
* Parse time constraint string to milliseconds
* @example parseTimeConstraint("24h") → 86400000
* @example parseTimeConstraint("7d") → 604800000
* @example parseTimeConstraint("2w") → 1209600000
*/
export function parseTimeConstraint(input: string): number {
const match = input.match(/^(\d+)([hdw])$/i);
if (!match) {
throw new Error(
`Invalid time constraint: ${input}. Use format like "24h", "7d", or "2w"`,
);
}
const [, value, unit] = match;
const multiplier = TIME_UNITS[unit.toLowerCase()];
if (!multiplier) {
throw new Error(`Unknown time unit: ${unit}`);
}
return parseInt(value, 10) * multiplier;
}
/**
* Get cutoff Date from milliseconds offset
*/
export function toCutoffDate(ms: number): Date {
return new Date(Date.now() - ms);
}
/**
* Convert to git --since format
* @example toGitSince(86400000) → "2024-12-21T12:00:00"
*/
export function toGitSince(ms: number): string {
return toCutoffDate(ms).toISOString().replace("Z", "");
}
/**
* Convert to ISO 8601 duration for Linear
* @example toISOPeriod(86400000) → "-P1D" (1 day)
* @example toISOPeriod(604800000) → "-P7D" (7 days)
*/
export function toISOPeriod(ms: number): string {
const hours = ms / (60 * 60 * 1000);
if (hours < 24) {
return `-PT${Math.round(hours)}H`;
}
const days = Math.round(hours / 24);
return `-P${days}D`;
}
/**
* Convert to human-readable relative time
* @example toRelativeTime(new Date(Date.now() - 3600000)) → "1 hour ago"
*/
export function toRelativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const ms = Date.now() - d.getTime();
if (ms < 60 * 1000) return "just now";
if (ms < 60 * 60 * 1000) {
const mins = Math.floor(ms / (60 * 1000));
return `${mins} minute${mins === 1 ? "" : "s"} ago`;
}
if (ms < 24 * 60 * 60 * 1000) {
const hours = Math.floor(ms / (60 * 60 * 1000));
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
}
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
return `${days} day${days === 1 ? "" : "s"} ago`;
}
/**
* Filter items by updated_at field within time window
*/
export function filterByTime<
T extends { updated_at?: string; updatedAt?: string },
>(items: T[], ms: number): T[] {
const cutoff = toCutoffDate(ms);
return items.filter((item) => {
const updatedAt = item.updated_at || item.updatedAt;
if (!updatedAt) return false;
return new Date(updatedAt) >= cutoff;
});
}
/**
* Format time constraint for display
* @example formatTimeConstraint("24h") → "last 24 hours"
*/
export function formatTimeConstraint(input: string): string {
const match = input.match(/^(\d+)([hdw])$/i);
if (!match) return input;
const [, value, unit] = match;
const num = parseInt(value, 10);
switch (unit.toLowerCase()) {
case "h":
return `last ${num} hour${num === 1 ? "" : "s"}`;
case "d":
return `last ${num} day${num === 1 ? "" : "s"}`;
case "w":
return `last ${num} week${num === 1 ? "" : "s"}`;
default:
return input;
}
}
@@ -0,0 +1,178 @@
/**
* Shared types for status gatherers
*/
/** Possible states for a gatherer operation. */
export type GathererStatus = "success" | "unavailable" | "error";
/**
* Result returned by a status gatherer.
* @typeParam T - Type of gathered data
*/
export interface GathererResult<T = unknown> {
/** Source identifier */
source: string;
/** Operation status */
status: GathererStatus;
/** Gathered data (present when success) */
data?: T;
/** Error message (present when error) */
error?: string;
/** Unavailability reason (present when unavailable) */
reason?: string;
/** ISO timestamp of when data was gathered */
timestamp: string;
}
/**
* Issue from Beads local issue tracking.
*/
export interface BeadsIssue {
id: string;
title: string;
description?: string;
status: "open" | "in_progress" | "blocked" | "closed";
issue_type: "bug" | "feature" | "task" | "epic" | "chore";
priority: 0 | 1 | 2 | 3 | 4;
assignee?: string;
labels: string[];
created_at: string;
updated_at: string;
closed_at?: string;
dependency_count: number;
dependent_count: number;
}
/**
* Aggregated statistics for Beads issues.
*/
export interface BeadsStats {
total: number;
open: number;
in_progress: number;
blocked: number;
closed: number;
ready: number;
average_lead_time?: number;
}
/**
* Data gathered from Beads issue tracker.
*/
export interface BeadsData {
stats: BeadsStats;
inProgress: BeadsIssue[];
ready: BeadsIssue[];
blocked: BeadsIssue[];
recentlyClosed: BeadsIssue[];
}
/**
* Pull request from GitHub.
*/
export interface GitHubPR {
number: number;
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
isDraft: boolean;
author: { login: string };
updatedAt: string;
url: string;
headRefName: string;
statusCheckRollup?: {
state: "SUCCESS" | "FAILURE" | "PENDING" | "EXPECTED";
contexts?: Array<{
name: string;
state: string;
conclusion?: string;
}>;
};
reviewDecision?: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
}
/**
* GitHub Actions workflow run.
*/
export interface GitHubWorkflowRun {
name: string;
status: string;
conclusion: string | null;
createdAt: string;
url: string;
}
/**
* Data gathered from GitHub.
*/
export interface GitHubData {
repo: string;
openPRs: GitHubPR[];
recentRuns: GitHubWorkflowRun[];
}
/**
* Branch in a Graphite stack.
*/
export interface GraphiteBranch {
name: string;
prNumber?: number;
prStatus?: "draft" | "open" | "ready" | "merged" | "closed";
prUrl?: string;
parent?: string;
children: string[];
isCurrent: boolean;
needsRestack: boolean;
needsSubmit: boolean;
commitCount: number;
}
/**
* Data gathered from Graphite.
*/
export interface GraphiteData {
currentBranch: string;
trunk: string;
branches: GraphiteBranch[];
stacks: string[][]; // Each stack as array of branch names
}
/**
* Issue from Linear.
*/
export interface LinearIssue {
identifier: string;
title: string;
state: {
name: string;
type: string;
};
priority: number;
assignee?: { name: string };
labels: Array<{ name: string }>;
createdAt: string;
updatedAt: string;
url: string;
}
/**
* Data gathered from Linear.
*/
export interface LinearData {
team?: string;
issues: LinearIssue[];
}
/**
* Aggregated status report result.
*/
export interface SitrepResult {
timeConstraint: string;
timestamp: string;
sources: string[];
results: {
graphite?: GathererResult<GraphiteData>;
github?: GathererResult<GitHubData>;
linear?: GathererResult<LinearData>;
beads?: GathererResult<BeadsData>;
};
}
@@ -0,0 +1,346 @@
#!/usr/bin/env bun
/**
* sitrep.ts - Status report orchestrator
*
* Entry point for gathering status data from multiple sources.
* Runs gatherers in parallel, aggregates results, outputs JSON or text.
*
* Usage:
* ./sitrep.ts # All sources, 24h default
* ./sitrep.ts -t 7d # All sources, last 7 days
* ./sitrep.ts -s github,beads # Specific sources only
* ./sitrep.ts -t 24h -s graphite # Combined
* ./sitrep.ts --format=text # Human-readable output
*/
import { parseArgs } from "node:util";
import { formatTimeConstraint, toRelativeTime } from "./lib/time";
import type {
BeadsData,
GathererResult,
GitHubData,
GraphiteData,
LinearData,
SitrepResult,
} from "./lib/types";
const SOURCES = ["graphite", "github", "linear", "beads"] as const;
/** Available status data sources. */
type Source = (typeof SOURCES)[number];
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
time: { type: "string", short: "t", default: "24h" },
sources: { type: "string", short: "s" },
format: { type: "string", short: "f", default: "json" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(`
sitrep.ts - Generate status report across multiple sources
Usage:
./sitrep.ts [options]
Options:
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
-s, --sources <list> Comma-separated sources: graphite,github,linear,beads,all
[default: auto-detect available]
-f, --format <fmt> Output format: json, text [default: json]
-h, --help Show this help
Examples:
./sitrep.ts # All available sources, last 24 hours
./sitrep.ts -t 7d # Last 7 days
./sitrep.ts -s github,beads # Only GitHub and Beads
./sitrep.ts --format=text # Human-readable output
Sources:
graphite - Stack structure, branches, PR status (requires gt CLI)
github - Open PRs, CI status, workflow runs (requires gh CLI)
linear - Issues from Linear (requires Linear MCP in Claude settings)
beads - Local issues from .beads/ directory
`);
process.exit(0);
}
// Get script directory for running gatherers
const scriptDir = import.meta.dir;
/**
* Runs a gatherer script for a specific source.
* @param source - Source to gather data from
* @returns Gatherer result with data or error
*/
async function runGatherer<T>(source: Source): Promise<GathererResult<T>> {
const gathererPath = `${scriptDir}/gatherers/${source}-gatherer.ts`;
const timeValue = values.time ?? "24h";
const args = [gathererPath, "-t", timeValue];
const proc = Bun.spawn(["bun", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
return {
source,
status: "error",
error: stderr || `Gatherer exited with code ${exitCode}`,
timestamp: new Date().toISOString(),
};
}
try {
return JSON.parse(stdout) as GathererResult<T>;
} catch {
return {
source,
status: "error",
error: `Failed to parse gatherer output: ${stdout.slice(0, 200)}`,
timestamp: new Date().toISOString(),
};
}
}
/**
* Parses source list from command line arguments.
* @returns Array of validated source names
*/
function parseSources(): Source[] {
if (!values.sources || values.sources === "all") {
return [...SOURCES];
}
const requested = values.sources
.split(",")
.map((s) => s.trim().toLowerCase());
const valid: Source[] = [];
for (const s of requested) {
if (SOURCES.includes(s as Source)) {
valid.push(s as Source);
} else {
console.error(`Warning: Unknown source "${s}", skipping`);
}
}
return valid.length > 0 ? valid : [...SOURCES];
}
/**
* Gathers status data from all specified sources in parallel.
* @param sources - Sources to gather data from
* @returns Aggregated sitrep result
*/
async function gatherAll(sources: Source[]): Promise<SitrepResult> {
const timestamp = new Date().toISOString();
// Run all gatherers in parallel
const promises = sources.map(async (source) => {
switch (source) {
case "graphite":
return { source, result: await runGatherer<GraphiteData>(source) };
case "github":
return { source, result: await runGatherer<GitHubData>(source) };
case "linear":
return { source, result: await runGatherer<LinearData>(source) };
case "beads":
return { source, result: await runGatherer<BeadsData>(source) };
}
});
const settled = await Promise.allSettled(promises);
// Build results object
const results: SitrepResult["results"] = {};
for (const item of settled) {
if (item.status === "fulfilled") {
const { source, result } = item.value;
results[source] = result;
}
}
return {
timeConstraint: values.time ?? "24h",
timestamp,
sources,
results,
};
}
/**
* Formats sitrep result as human-readable text report.
* @param result - Sitrep result to format
* @returns Formatted text output
*/
function formatTextReport(result: SitrepResult): string {
const lines: string[] = [];
const timeLabel = formatTimeConstraint(result.timeConstraint);
lines.push(`SITREP — ${timeLabel}`);
lines.push(`Generated: ${new Date(result.timestamp).toLocaleString()}`);
lines.push("");
// Graphite section
if (result.results.graphite) {
const g = result.results.graphite;
if (g.status === "success" && g.data) {
const data = g.data as GraphiteData;
lines.push(
`📊 GRAPHITE (${data.stacks.length} stacks, ${data.branches.length} branches)`,
);
lines.push(` Current: ${data.currentBranch}`);
for (const branch of data.branches) {
const current = branch.isCurrent ? " ●" : "";
const pr = branch.prNumber ? ` PR #${branch.prNumber}` : "";
const status = branch.prStatus ? ` [${branch.prStatus}]` : "";
const flags: string[] = [];
if (branch.needsRestack) flags.push("needs restack");
if (branch.needsSubmit) flags.push("needs submit");
const flagStr = flags.length > 0 ? ` (${flags.join(", ")})` : "";
lines.push(` ${branch.name}${current}${pr}${status}${flagStr}`);
}
lines.push("");
} else if (g.status === "unavailable") {
lines.push(`📊 GRAPHITE: ${g.reason}`);
lines.push("");
}
}
// GitHub section
if (result.results.github) {
const gh = result.results.github;
if (gh.status === "success" && gh.data) {
const data = gh.data as GitHubData;
lines.push(`🔀 GITHUB (${data.openPRs.length} open PRs)`);
lines.push(` Repo: ${data.repo}`);
for (const pr of data.openPRs) {
const draft = pr.isDraft ? " [draft]" : "";
const ci = pr.statusCheckRollup?.state
? ` CI: ${pr.statusCheckRollup.state.toLowerCase()}`
: "";
const review = pr.reviewDecision
? ` Review: ${pr.reviewDecision.toLowerCase()}`
: "";
const time = toRelativeTime(pr.updatedAt);
lines.push(` #${pr.number}: ${pr.title}${draft}`);
lines.push(` ${ci}${review}${time}`);
}
if (data.recentRuns.length > 0) {
const failed = data.recentRuns.filter(
(r) => r.conclusion === "failure",
).length;
const passed = data.recentRuns.filter(
(r) => r.conclusion === "success",
).length;
lines.push(` Workflow runs: ${passed} passed, ${failed} failed`);
}
lines.push("");
} else if (gh.status === "unavailable") {
lines.push(`🔀 GITHUB: ${gh.reason}`);
lines.push("");
}
}
// Linear section
if (result.results.linear) {
const lin = result.results.linear;
if (lin.status === "success" && lin.data) {
const data = lin.data as LinearData;
lines.push(`📋 LINEAR (${data.issues.length} issues)`);
for (const issue of data.issues.slice(0, 10)) {
const assignee = issue.assignee ? ` @${issue.assignee.name}` : "";
const time = toRelativeTime(issue.updatedAt);
lines.push(` ${issue.identifier}: ${issue.title}`);
lines.push(` [${issue.state.name}]${assignee}${time}`);
}
lines.push("");
} else if (lin.status === "unavailable") {
lines.push(`📋 LINEAR: ${lin.reason}`);
lines.push("");
}
}
// Beads section
if (result.results.beads) {
const b = result.results.beads;
if (b.status === "success" && b.data) {
const data = b.data as BeadsData;
const { stats } = data;
lines.push(
`📝 BEADS (${stats.total} total, ${stats.open} open, ${stats.in_progress} active, ${stats.blocked} blocked)`,
);
if (data.inProgress.length > 0) {
lines.push(" In Progress:");
for (const issue of data.inProgress) {
const time = toRelativeTime(issue.updated_at);
lines.push(` ${issue.id}: ${issue.title}${time}`);
}
}
if (data.ready.length > 0) {
lines.push(" Ready to Work:");
for (const issue of data.ready.slice(0, 5)) {
const priority = ["", "🔴", "🟠", "🟡", "⚪"][issue.priority] || "";
lines.push(` ${priority} ${issue.id}: ${issue.title}`);
}
}
if (data.blocked.length > 0) {
lines.push(` Blocked (${data.blocked.length}):`);
for (const issue of data.blocked.slice(0, 3)) {
lines.push(`${issue.id}: ${issue.title}`);
}
}
if (data.recentlyClosed.length > 0) {
lines.push(` Recently Closed (${data.recentlyClosed.length}):`);
for (const issue of data.recentlyClosed.slice(0, 3)) {
const time = toRelativeTime(issue.closed_at || issue.updated_at);
lines.push(`${issue.id}: ${issue.title}${time}`);
}
}
lines.push("");
} else if (b.status === "unavailable") {
lines.push(`📝 BEADS: ${b.reason}`);
lines.push("");
}
}
return lines.join("\n");
}
// Main execution
async function main() {
const sources = parseSources();
const result = await gatherAll(sources);
if (values.format === "text") {
console.log(formatTextReport(result));
} else {
console.log(JSON.stringify(result, null, 2));
}
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});