Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ Requires Node 20+, `git`, `sqlite3`, and an authenticated [`gh`](https://cli.git
CLI. `stacklore` reuses your existing `gh` credentials rather than asking for a
second token.

### As a `gh` extension

```bash
stacklore gh-install # then: gh stacklore save
```

This registers `gh stacklore` for your user. `gh extension install` is not used
because it requires the *repository* to be named `gh-stacklore`, and this one is
named `stacklore`; `gh-install` sidesteps that by writing the extension shim
itself, so it works for a global npm install or a source checkout alike. Re-run
it after upgrading Node or stacklore, and `stacklore gh-uninstall` to remove it.
A companion `gh-stacklore` repository, for the usual `gh extension install`
flow, is a planned follow-up; the `gh-stacklore` script at the root of this
repository is its entry point.

## Quick start

```bash
Expand Down Expand Up @@ -70,6 +85,8 @@ contents.
| `stacklore show [--pr N]` | Print stored lore for a pull request |
| `stacklore list` | List captured records for this repository |
| `stacklore status` | Show config, adapter availability, and stack position |
| `stacklore gh-install` | Register the CLI as a `gh stacklore` extension |
| `stacklore gh-uninstall` | Remove the `gh stacklore` extension |

| Flag | Description |
|---|---|
Expand All @@ -81,9 +98,12 @@ contents.

## How it works

1. **Resolve the stack.** GitHub exposes a `stack` object on the pull request
REST resource — stack number, position, size, and trunk. Standalone pull
requests are treated as a stack of one.
1. **Resolve the stack.** Stacks are read from GraphQL — `PullRequest.stackEntry`
yields the stack's number, size, trunk, and every member's position in one
round-trip. The REST pull request resource does *not* expose a `stack` field,
so GraphQL is the only source. When GitHub reports no native stack, the
topology is inferred by walking the chain of base branches (see below).
Standalone pull requests are treated as a stack of one.
2. **Capture per layer.** Each layer is a branch, so the adapter joins on
`branch` to find the sessions recorded against it.
3. **Redact.** Every prompt passes through the redaction engine before it is
Expand All @@ -103,6 +123,22 @@ which survives rebases, force-pushes, and squash merges:
records/<owner>/<repo>/pr-<number>.json
```

### Native stacks and inferred chains

GitHub only reports a native stack for pull requests created through its
stacking flow. A stack assembled by hand — each pull request targeting the one
below it — has a null `stackEntry`, so stacklore reconstructs the chain itself:
it walks base-to-head links from the trunk and stops at any branch point, since
a tree is not a stack. Inferred layers are marked `source: "inferred"` in the
record and labelled as such in `stacklore status`.

### Idempotence

`save` is safe to re-run. `capturedAt` moves on every capture, so records are
compared ignoring that field; an unchanged layer is not rewritten and the store
does not accumulate empty commits. The pull request comment is matched by a
hidden marker and updated in place rather than duplicated.

## Redaction

Secrets are removed before anything reaches the store:
Expand Down Expand Up @@ -136,6 +172,10 @@ sensitive and keep it private.
| Cursor | `cursor` | Planned |
| Codex CLI | `codex` | Planned |

The Copilot adapter reads `~/.copilot/session-store.db`. Set
`STACKLORE_COPILOT_DB` to point it elsewhere, which is useful for testing and
for machines that keep Copilot state outside the default location.

### Writing an adapter

Capture is deliberately narrow — given a branch, return the exchanges and files
Expand Down Expand Up @@ -176,10 +216,19 @@ const stack = await captureStack("acme/widgets", 42);
console.log(renderStackComment(stack, { current: 42 }));
```

Every module that shells out takes an optional `exec` runner, so `gh` and `git`
can be substituted in tests or wrapped by a host application:

```ts
await captureStack("acme/widgets", 42, { exec: myRunner });
```

## Status

Early. The record schema is versioned (`schemaVersion: 1`) and will migrate
rather than break.
Early, but the full path is exercised end-to-end against real GitHub resources,
and `gh`/`git` interactions are covered by unit tests with injected runners. The
record schema is versioned (`schemaVersion: 1`) and will migrate rather than
break.

## License

Expand Down
29 changes: 29 additions & 0 deletions gh-stacklore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
#
# GitHub CLI extension entry point.
#
# gh requires an extension's repository to be named `gh-<name>`, so this script
# exists for a `gh-stacklore` companion repository that mirrors this one. When
# stacklore is already installed, `stacklore gh-install` is the better route: it
# writes a shim with absolute paths and needs no checkout at all.
set -euo pipefail

here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"

# A source checkout that has been built runs directly from its own output, so a
# development version is never shadowed by a globally installed one.
if [[ -f "$here/dist/cli.js" ]]; then
exec node "$here/dist/cli.js" "$@"
fi

if command -v stacklore >/dev/null 2>&1; then
exec stacklore "$@"
fi

cat >&2 <<'MSG'
stacklore is not built or installed.

From a checkout: npm install && npm run build
Or install it: npm install -g stacklore && stacklore gh-install
MSG
exit 1
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types test/*.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
"prepublishOnly": "npm run build"
},
"devDependencies": {
Expand Down
41 changes: 27 additions & 14 deletions src/adapters/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,26 @@
* commit-SHA join would not.
*/

import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";

import type { Exchange } from "../types.js";
import type { CaptureAdapter, CaptureQuery, CaptureResult } from "./types.js";

const execFileAsync = promisify(execFile);
import { runCommand, type CommandRunner } from "../exec.ts";
import type { Exchange } from "../types.ts";
import type { CaptureAdapter, CaptureQuery, CaptureResult } from "./types.ts";

const DEFAULT_DB = join(homedir(), ".copilot", "session-store.db");

/**
* Location of the Copilot session store.
*
* Overridable so the adapter can be pointed at a fixture database in tests and
* end-to-end runs without touching the user's real session history.
*/
export function copilotDbPath(env: NodeJS.ProcessEnv = process.env): string {
return env.STACKLORE_COPILOT_DB || DEFAULT_DB;
}

interface SessionRow {
id: string;
branch: string;
Expand All @@ -28,12 +35,12 @@ interface SessionRow {
}

/** Run a query against the SQLite store and return JSON rows. */
async function query<T>(dbPath: string, sql: string): Promise<T[]> {
const { stdout } = await execFileAsync(
"sqlite3",
["-readonly", "-json", dbPath, sql],
{ maxBuffer: 64 * 1024 * 1024 },
);
async function query<T>(
exec: CommandRunner,
dbPath: string,
sql: string,
): Promise<T[]> {
const stdout = await exec("sqlite3", ["-readonly", "-json", dbPath, sql]);
const trimmed = stdout.trim();
if (!trimmed) return [];
return JSON.parse(trimmed) as T[];
Expand All @@ -47,15 +54,17 @@ function sqlString(value: string): string {
export class CopilotAdapter implements CaptureAdapter {
readonly name = "copilot";
readonly #dbPath: string;
readonly #exec: CommandRunner;

constructor(dbPath: string = DEFAULT_DB) {
constructor(dbPath: string = copilotDbPath(), exec: CommandRunner = runCommand) {
this.#dbPath = dbPath;
this.#exec = exec;
}

async isAvailable(): Promise<boolean> {
if (!existsSync(this.#dbPath)) return false;
try {
await execFileAsync("sqlite3", ["-version"]);
await this.#exec("sqlite3", ["-version"]);
return true;
} catch {
return false;
Expand All @@ -72,6 +81,7 @@ export class CopilotAdapter implements CaptureAdapter {
}

const sessions = await query<SessionRow>(
this.#exec,
this.#dbPath,
`SELECT id, branch, repository, summary
FROM sessions
Expand All @@ -90,6 +100,7 @@ export class CopilotAdapter implements CaptureAdapter {
assistant_response: string | null;
timestamp: string | null;
}>(
this.#exec,
this.#dbPath,
`SELECT turn_index, user_message, assistant_response, timestamp
FROM turns
Expand All @@ -100,6 +111,7 @@ export class CopilotAdapter implements CaptureAdapter {
);

const files = await query<{ file_path: string }>(
this.#exec,
this.#dbPath,
`SELECT DISTINCT file_path
FROM session_files
Expand Down Expand Up @@ -139,6 +151,7 @@ export class CopilotAdapter implements CaptureAdapter {
async linkedPullRequests(branch: string): Promise<number[]> {
if (!(await this.isAvailable())) return [];
const rows = await query<{ ref_value: string }>(
this.#exec,
this.#dbPath,
`SELECT DISTINCT r.ref_value
FROM session_refs r
Expand Down
8 changes: 4 additions & 4 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ export type {
CaptureAdapter,
CaptureQuery,
CaptureResult,
} from "./types.js";
export { CopilotAdapter } from "./copilot.js";
} from "./types.ts";
export { CopilotAdapter, copilotDbPath } from "./copilot.ts";

import type { CaptureAdapter } from "./types.js";
import { CopilotAdapter } from "./copilot.js";
import type { CaptureAdapter } from "./types.ts";
import { CopilotAdapter } from "./copilot.ts";

/** Adapters tried in order when none is explicitly selected. */
export function defaultAdapters(): CaptureAdapter[] {
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* for a new tool.
*/

import type { Exchange } from "../types.js";
import type { Exchange } from "../types.ts";

/** Raw, un-redacted capture for a single branch. Redaction happens downstream. */
export interface CaptureResult {
Expand Down
36 changes: 25 additions & 11 deletions src/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,31 @@
* redact, distill, and build a storable record.
*/

import { resolveAdapter, type CaptureAdapter } from "./adapters/index.js";
import { distillIntent } from "./distill.js";
import { redact, redactAll } from "./redact.js";
import { getStack, type PullRequestInfo } from "./stack.js";
import { resolveAdapter, type CaptureAdapter } from "./adapters/index.ts";
import { distillIntent } from "./distill.ts";
import { runCommand, type CommandRunner } from "./exec.ts";
import { redact, redactAll } from "./redact.ts";
import { getStack, type PullRequestInfo } from "./stack.ts";
import {
SCHEMA_VERSION,
type Exchange,
type LoreRecord,
type LoreStack,
type RedactionReport,
} from "./types.js";
} from "./types.ts";

export interface CaptureOptions {
repo: string;
adapter?: CaptureAdapter | null;
adapterName?: string;
/**
* Adapters to choose from when none is supplied. Injected by tests; defaults
* to every adapter built in.
*/
adapters?: CaptureAdapter[];
includeResponses?: boolean;
/** Subprocess runner. Injected by tests; defaults to a real `spawn`. */
exec?: CommandRunner;
}

function mergeReports(reports: RedactionReport[]): RedactionReport {
Expand All @@ -38,7 +46,8 @@ export async function captureLayer(
options: CaptureOptions,
): Promise<LoreRecord | null> {
const adapter =
options.adapter ?? (await resolveAdapter(undefined, options.adapterName));
options.adapter ??
(await resolveAdapter(options.adapters, options.adapterName));
if (!adapter) return null;

const captures = await adapter.capture({
Expand Down Expand Up @@ -113,14 +122,19 @@ export async function captureStack(
pr: number,
options: Omit<CaptureOptions, "repo"> = {},
): Promise<LoreStack> {
const members = await getStack(repo, pr);
const members = await getStack(repo, pr, options.exec ?? runCommand);
const adapter =
options.adapter ?? (await resolveAdapter(undefined, options.adapterName));
options.adapter ??
(await resolveAdapter(options.adapters, options.adapterName));

const layers: LoreRecord[] = [];
for (const member of members) {
const record = await captureLayer(member, { ...options, repo, adapter });
if (record) layers.push(record);
// When no adapter is available there is nothing to capture, and probing once
// per layer would repeat the same failing detection for every pull request.
if (adapter) {
for (const member of members) {
const record = await captureLayer(member, { ...options, repo, adapter });
if (record) layers.push(record);
}
}

const head = members.find((m) => m.number === pr) ?? members[0];
Expand Down
Loading
Loading