Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ Or provision the workspace-level `Human Handoff` issue template from the version
LINEAR_API_KEY=lin_api_… npx human-handoff-linear sync-template
```

Ensure selected Linear teams have the `human-handoff` issue label:

```bash
npx human-handoff-linear setup --team GRV
```

### [`llm-cost-attribution`](packages/llm-cost-attribution)

Per-issue token, turn, and quota analytics for Claude Code and Codex CLI sessions. Reads the CLIs' own session JSONLs — no custom telemetry pipeline required.
Expand Down
18 changes: 17 additions & 1 deletion docs/architecture/use-case-catalog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Use Case Catalog

The canonical catalog of every application-layer use case in `llm-cost-attribution`:
The canonical catalog of every application-layer use case in Groove packages:
its actor, goal, entities/values, ports, primary adapters, and the inward boundary
rule it relies on. Each entry names its `Current implementation` so the catalog and
the source stay traceable.
Expand All @@ -16,6 +16,22 @@ must stay aligned with the modules cataloged here.
**Convention:** every PR that adds or changes a use case updates this catalog in the
same PR.

## human-handoff-linear

### EnsureHumanHandoffLabels
Actor: Operator
Goal: Ensure selected Linear teams each have a `human-handoff` issue label so the project's single Human Handoff issue is discoverable by agents, filters, and humans.
Inputs: Linear team refs as keys or UUIDs, or all accessible teams; IssueLabelSpec; dry-run mode.
Outputs: LabelEnsureResult per selected team: already present, would create, or created.
Entities / values: LinearTeamRef, IssueLabelSpec, LabelEnsureResult.
Ports: LinearWorkspace.
Primary adapters: Linear GraphQL workspace adapter, CLI team selector parser.
Current implementation: `packages/human-handoff-linear/src/use-cases/ensure-human-handoff-labels.mjs`

Boundary rule: `EnsureHumanHandoffLabels` imports no CLI, process, fetch, HTTP, or GraphQL code. The Linear API adapter lives at the package edge in `src/adapters/linear-graphql-workspace.mjs`.

## llm-cost-attribution

### ForecastIssueCost
Actor: Operator
Goal: Forecast token, turn, $ API-equivalent, and Codex quota cost for one issue from historical issues with the same size and model.
Expand Down
90 changes: 67 additions & 23 deletions packages/human-handoff-linear/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# human-handoff-linear

Linear workflow primitives for installing and maintaining the Human Handoff
issue template used by autonomous project workflows.
issue template and team labels used by autonomous project workflows.

Today the package ships two real Linear commands:
Today the package ships three real Linear commands:

- `doctor` — read-only auth and viewer/organization check.
- `setup` — ensure selected Linear teams have the `human-handoff` issue label.
- `sync-template` — provision the workspace-level `Human Handoff` issue
template idempotently: create it if missing, update its body when the
bundled markdown drifts, report no-change when already in sync.

`setup` and `bootstrap-project` remain scaffold-only and perform no Linear
mutations; later issues will wire them to the same `LinearWorkspace` adapter.
`bootstrap-project` remains scaffold-only and performs no Linear mutations;
later issues will wire it to the same `LinearWorkspace` adapter.

## Requirements

Expand All @@ -21,16 +22,14 @@ mutations; later issues will wire them to the same `LinearWorkspace` adapter.

## Auth

The CLI reads the API key from the `LINEAR_API_KEY` environment variable.
The CLI reads the API key from `LINEAR_API_KEY` or `LINEAR_API_TOKEN`.

```bash
export LINEAR_API_KEY=lin_api_
export LINEAR_API_KEY=lin_api_...
```

If the variable is unset and you run from an interactive terminal, the CLI
prompts for the key without echoing it. Pass `--no-prompt` to disable that
fallback (use in CI, where there is no TTY anyway). The key is never logged,
written to disk, or echoed back.
The `doctor` command can also prompt for the key in an interactive terminal.
The key is never logged, written to disk, or echoed back.

## CLI

Expand All @@ -40,13 +39,52 @@ npx human-handoff-linear --help

Subcommands:

- `doctor` — validate the Linear API token by fetching the current viewer and
- `setup` - ensure selected Linear teams have the `human-handoff` issue label.
- `doctor` - validate the Linear API token by fetching the current viewer and
workspace. Read-only: never creates or updates templates, labels, issues, or
relations.
- `sync-template` — create or update the workspace-level `Human Handoff` issue
template idempotently. Pass `--dry-run` to plan without writing.
- `setup`, `bootstrap-project` — scaffold-only today; reserved command surfaces
that later issues will wire to real Linear mutations.
- `bootstrap-project` — scaffold-only today; reserved command surface that
later issues will wire to real Linear mutations.

### `setup`

```bash
npx human-handoff-linear setup --team GRV --team WEB
```

The command accepts team keys or Linear team UUIDs. Existing labels are detected
case-insensitively, so `Human-Handoff` satisfies the requirement and will not be
duplicated.

To preview changes:

```bash
npx human-handoff-linear setup --team GRV,WEB --dry-run
```

To ensure every Linear team visible to the API key:

```bash
npx human-handoff-linear setup --all-teams
```

Default label spec:

| Field | Default |
| --- | --- |
| Name | `human-handoff` |
| Color | `#f59e0b` |
| Description | `Marks the project issue where human-only blockers are tracked.` |

Override the defaults when a workspace needs different label metadata:

```bash
npx human-handoff-linear setup --team GRV \
--color '#d97706' \
--description 'Tracks human-only project blockers.'
```

### `sync-template`

Expand Down Expand Up @@ -106,42 +144,48 @@ import {
createDoctorUseCase,
createLinearGraphqlWorkspace,
createSyncTemplateUseCase,
ensureHumanHandoffLabels,
} from 'human-handoff-linear';

const workspace = createLinearGraphqlWorkspace({ apiKey: process.env.LINEAR_API_KEY });
const result = await ensureHumanHandoffLabels({
workspace,
teamRefs: ['GRV'],
});

const reporter = { info: console.log, error: console.error };

// Read-only auth check
const doctor = createDoctorUseCase({
reporter,
secretReader: { read: (name) => process.env[name] },
workspace,
workspaceFactory: ({ apiKey }) => createLinearGraphqlWorkspace({ apiKey }),
});
const auth = await doctor();
if (!auth.ok) process.exit(1);

// Idempotent template sync
const templateBody = await readFile('./templates/human-handoff-issue-body.md', 'utf8');
const sync = createSyncTemplateUseCase({ reporter, templateBody, workspace });
const result = await sync({ dryRun: false });
const syncResult = await sync({ dryRun: false });
// → { action: 'create' | 'update' | 'no-change', templateId, mutationsPerformed, ... }
```

Ports are plain objects:

- `ConsoleReporter` receives `info`, `error`, and optional `verbose` messages.
- `SecretReader` resolves secrets such as `LINEAR_API_KEY`.
- `LinearWorkspace` adapter boundary for Linear workspace operations.
- `ConsoleReporter` - receives `info`, `error`, and optional `verbose` messages.
- `SecretReader` - resolves secrets such as `LINEAR_API_KEY`.
- `LinearWorkspace` - adapter boundary for Linear workspace operations.

The full LinearWorkspace surface (`getViewer`, `listTeams`, `listLabels`,
`createLabel`, `getTemplate`, `createTemplate`, `updateTemplate`,
`createIssue`, `createRelation`) is implemented by
`createLinearGraphqlWorkspace`. Later mutating commands build on these
methods; they do not implement their own GraphQL.
`createLinearGraphqlWorkspace`. Mutating commands build on these methods; they
do not implement their own GraphQL.

Core use-case modules do not read environment variables, call `fetch`, or
exit the process. Those responsibilities stay in CLI/adapter code (enforced
by `tests/boundary.test.mjs`).
Core use-case modules do not read environment variables, call `fetch`, or exit
the process. Those responsibilities stay in CLI/adapter code (enforced by
`tests/boundary.test.mjs`).

## Template

Expand Down
19 changes: 19 additions & 0 deletions packages/human-handoff-linear/docs/use-cases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# human-handoff-linear Use Cases

### EnsureHumanHandoffLabels
Actor: Operator
Goal: Ensure each selected Linear team has exactly one issue label named
`human-handoff`, without duplicating existing labels whose names only differ by
case.
Inputs: Team refs as keys or UUIDs, or all accessible teams; IssueLabelSpec;
dry-run mode.
Outputs: LabelEnsureResult per team: already present, would create, or created.
Entities / values: LinearTeamRef, IssueLabelSpec, LabelEnsureResult.
Ports: LinearWorkspace.
Primary adapters: Linear GraphQL workspace adapter, CLI team selector parser.
Current implementation: `packages/human-handoff-linear/src/use-cases/ensure-human-handoff-labels.mjs`

Boundary rule: the use case imports no CLI, process, fetch, HTTP, or GraphQL
code. Linear API access is confined to
`src/adapters/linear-graphql-workspace.mjs`; CLI flag names are confined to
`src/cli/run-cli.mjs`.
1 change: 1 addition & 0 deletions packages/human-handoff-linear/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"bin/",
"src/",
"templates/",
"docs/",
"README.md"
],
"engines": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from '../errors.mjs';

export const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql';
const PAGE_SIZE = 100;

/**
* @param {object} opts
Expand Down Expand Up @@ -122,26 +123,49 @@ export function createLinearGraphqlWorkspace({
},

async listTeams() {
const data = await graphql(`query Teams { teams(first: 100) { nodes { id key name } } }`);
return data?.teams?.nodes?.map((t) => ({ id: t.id, key: t.key, name: t.name })) ?? [];
const teams = [];
for (let after; ;) {
const data = await graphql(
`query Teams($first: Int!, $after: String) {
teams(first: $first, after: $after) {
nodes { id key name }
pageInfo { hasNextPage endCursor }
}
}`,
{ first: PAGE_SIZE, after },
);
const page = data?.teams;
teams.push(...(page?.nodes?.map((t) => ({ id: t.id, key: t.key, name: t.name })) ?? []));
if (page?.pageInfo?.hasNextPage !== true) break;
after = page.pageInfo.endCursor;
}
return teams;
},

async listLabels({ teamId } = {}) {
const data = await graphql(
`query Labels($teamId: ID) {
issueLabels(first: 250, filter: { team: { id: { eq: $teamId } } }) {
nodes { id name color teamId description }
}
}`,
{ teamId: teamId ?? null },
);
return data?.issueLabels?.nodes?.map((l) => ({
id: l.id,
name: l.name,
color: l.color,
teamId: l.teamId,
description: l.description ?? null,
})) ?? [];
const labels = [];
for (let after; ;) {
const data = await graphql(
`query Labels($teamId: ID, $first: Int!, $after: String) {
issueLabels(first: $first, after: $after, filter: { team: { id: { eq: $teamId } } }) {
nodes { id name color teamId description }
pageInfo { hasNextPage endCursor }
}
}`,
{ teamId: teamId ?? null, first: PAGE_SIZE, after },
);
const page = data?.issueLabels;
labels.push(...(page?.nodes?.map((l) => ({
id: l.id,
name: l.name,
color: l.color,
teamId: l.teamId,
description: l.description ?? null,
})) ?? []));
if (page?.pageInfo?.hasNextPage !== true) break;
after = page.pageInfo.endCursor;
}
return labels;
},

async createLabel({ teamId, name, color, description } = {}) {
Expand Down
Loading
Loading