mintree 0.4.8 → 0.4.10
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.
- package/README.md +42 -2
- package/dist/commands/dashboard.js +48 -20
- package/dist/commands/worktree/create.js +1 -1
- package/dist/commands/worktree/work.d.ts +1 -1
- package/dist/commands/worktree/work.js +11 -7
- package/dist/lib/branch.d.ts +25 -2
- package/dist/lib/branch.js +68 -0
- package/dist/lib/metadata.d.ts +3 -0
- package/dist/lib/metadata.js +11 -0
- package/dist/lib/promptTemplate.d.ts +18 -0
- package/dist/lib/promptTemplate.js +15 -0
- package/dist/lib/providers/linear.js +2 -0
- package/dist/lib/providers/types.d.ts +1 -0
- package/dist/lib/worktreeCreate.js +29 -4
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -126,6 +126,32 @@ chmod 600 ~/.mintree/credentials.json
|
|
|
126
126
|
|
|
127
127
|
The key goes straight into the `Authorization` header (no `Bearer` prefix). `mintree doctor` validates the key, resolves the viewer, and pings each configured team when `provider === "linear"`.
|
|
128
128
|
|
|
129
|
+
### Launch behaviour (optional)
|
|
130
|
+
|
|
131
|
+
Two top-level keys in `.mintree/metadata.json` tune how mintree launches Claude — both apply to GitHub and Linear repos alike:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"version": 1,
|
|
136
|
+
"provider": "linear",
|
|
137
|
+
"issues": {},
|
|
138
|
+
"defaultPermissionMode": "auto",
|
|
139
|
+
"promptTemplate": "Trabajá en el ticket {{id}} ({{title}}). Abrí {{url}} para el contexto completo y seguí las convenciones del repo.",
|
|
140
|
+
"linear": { "workspaceSlug": "my-team", "teams": [{ "key": "FE" }] }
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- **`defaultPermissionMode`** (`"default"` | `"auto"`): the Claude `--permission-mode` mintree uses when it launches a session — from the dashboard (`w` / `↵`), `worktree work`, or `worktree create --work`. Omitted (or `"default"`) keeps the stricter default mode; `"auto"` starts every session with auto-accept on. The `--permission-mode` / `-m` CLI flag still overrides it per launch.
|
|
145
|
+
- **`promptTemplate`**: the initial message seeded into the dashboard's `w` overlay (the text Claude receives as its first prompt). It replaces mintree's built-in default and supports these placeholders, substituted per issue:
|
|
146
|
+
|
|
147
|
+
| Placeholder | Replaced with |
|
|
148
|
+
|-------------|-------------------------------------------------------|
|
|
149
|
+
| `{{id}}` | Issue id — `100` (GitHub) or `FE-123` (Linear) |
|
|
150
|
+
| `{{title}}` | Issue title |
|
|
151
|
+
| `{{url}}` | Issue URL (GitHub issue page / Linear issue link) |
|
|
152
|
+
|
|
153
|
+
It's a single line on purpose — the overlay's prompt field is one line, and you can still edit it before launching. When omitted, mintree falls back to its provider-aware default (`gh issue view` for GitHub, the bare id + URL for Linear).
|
|
154
|
+
|
|
129
155
|
---
|
|
130
156
|
|
|
131
157
|
## Daily flow
|
|
@@ -159,6 +185,9 @@ Same building blocks, scriptable from any shell:
|
|
|
159
185
|
mintree worktree create feat/100-validar-patente
|
|
160
186
|
mintree worktree create feat/FE-123-validar-patente --work --prompt "empezar FE-123"
|
|
161
187
|
|
|
188
|
+
# On a Linear repo you can pass the issue's own Linear branch name
|
|
189
|
+
mintree worktree create martinmineo/val-68-landing-publica --work
|
|
190
|
+
|
|
162
191
|
# Resume Claude in the worktree you're currently inside
|
|
163
192
|
# (the worktree dir is the bare issue id)
|
|
164
193
|
cd .mintree/worktrees/FE-123
|
|
@@ -178,7 +207,7 @@ mintree worktree clean # sweep worktrees whose PR is mer
|
|
|
178
207
|
|
|
179
208
|
## Branch convention
|
|
180
209
|
|
|
181
|
-
mintree enforces:
|
|
210
|
+
By default mintree enforces:
|
|
182
211
|
|
|
183
212
|
```
|
|
184
213
|
<type>/<issue>-<kebab-desc>
|
|
@@ -199,6 +228,17 @@ Examples: `feat/42-validacion-patente`, `fix/55-selfie-upload-timeout`, `feat/FE
|
|
|
199
228
|
|
|
200
229
|
When the dashboard's `w` overlay opens, it suggests a kebab description capped at 5 words. If your repo has a `docs/conventions/git-workflow.md`, `CONTRIBUTING.md`, or `.claude/skills/` directory, mintree mentions it on the overlay so you can verify the suggestion against your project's rules — then edit the description to match.
|
|
201
230
|
|
|
231
|
+
### Linear repos: branches come from Linear
|
|
232
|
+
|
|
233
|
+
Many Linear-tracked repos follow the branch name Linear suggests — `<user>/<team>-<n>-<desc>`, e.g. `martinmineo/val-68-landing-publica` — rather than the `<type>/<issue>-<desc>` convention above. That value is the issue's `branchName` (a.k.a. gitBranchName), and it depends on the branch-name prefix configured in your Linear workspace.
|
|
234
|
+
|
|
235
|
+
So **when `provider` is `linear` and the issue has a `branchName`, mintree uses it verbatim** instead of synthesising a `<type>/<issue>-<desc>` branch:
|
|
236
|
+
|
|
237
|
+
- In the dashboard's `w` overlay, the "new branch" mode shows the Linear branch (read-only, labelled `from Linear`) and skips the type/description fields.
|
|
238
|
+
- From the CLI, you can pass the Linear branch directly: `mintree worktree create martinmineo/val-68-landing-publica`. mintree finds the Linear identifier (`val-68`) by matching it against your configured `linear.teams[].key`, and normalises it to the canonical `VAL-68`.
|
|
239
|
+
|
|
240
|
+
The worktree directory is still the **bare, upper-case issue id** (`VAL-68`) regardless of the branch name, matching the GitHub case. The `<type>/<issue>-<desc>` convention is still accepted on Linear repos too — it's used as a fallback when an issue has no `branchName`. GitHub repos are unaffected: they always use the convention and reject Linear-style branches.
|
|
241
|
+
|
|
202
242
|
---
|
|
203
243
|
|
|
204
244
|
## What gets stored where
|
|
@@ -216,7 +256,7 @@ When the dashboard's `w` overlay opens, it suggests a kebab description capped a
|
|
|
216
256
|
└── init.sh # opt-in. Runs in the new worktree post-create (copy .env, install deps, …)
|
|
217
257
|
```
|
|
218
258
|
|
|
219
|
-
The worktree directory is named after the bare issue id (`100`, `FE-123`); the branch keeps
|
|
259
|
+
The worktree directory is named after the bare issue id (`100`, `FE-123`, `VAL-68`); the branch keeps its full name — `<type>/<issue>-<desc>` for the convention, or Linear's own `<user>/<team>-<n>-<desc>` on Linear repos.
|
|
220
260
|
|
|
221
261
|
`metadata.json` is gitignored because the `session_id` is local to your machine — sharing it would only generate noise. The `provider` and `linear.*` keys can be re-derived from a Linear workspace if needed; sharing them would just leak local config preference.
|
|
222
262
|
|
|
@@ -14,6 +14,7 @@ import { runCreate, runCreateDetached, } from "../lib/worktreeCreate.js";
|
|
|
14
14
|
import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
|
|
15
15
|
import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
|
|
16
16
|
import { readMetadata } from "../lib/metadata.js";
|
|
17
|
+
import { renderPromptTemplate } from "../lib/promptTemplate.js";
|
|
17
18
|
import { createProvider } from "../lib/providers/index.js";
|
|
18
19
|
import { loadDashboard } from "../lib/dashboard.js";
|
|
19
20
|
import { priorityDisplay } from "../lib/priority.js";
|
|
@@ -121,11 +122,18 @@ function kebabize(title) {
|
|
|
121
122
|
* Default prompt seeded into the overlay's Prompt field when the user opens
|
|
122
123
|
* `w` for an issue. Single-line on purpose — `ink-text-input` is one-line,
|
|
123
124
|
* so multi-line templates render weirdly when the user tabs in to edit.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
125
|
+
*
|
|
126
|
+
* When the repo configures a `promptTemplate` in `.mintree/metadata.json`,
|
|
127
|
+
* it wins: the `{{id}}`, `{{title}}` and `{{url}}` placeholders are rendered
|
|
128
|
+
* and the result seeds the field. Otherwise we fall back to the built-in,
|
|
129
|
+
* provider-aware default: GitHub issues get the `#<n>` + `gh issue view`
|
|
130
|
+
* form; Linear issues (id like `FE-123`) get the bare id + the issue URL,
|
|
131
|
+
* since `gh` can't read Linear and `#` isn't Linear's notation.
|
|
127
132
|
*/
|
|
128
|
-
function defaultPromptForIssue(id, title, url) {
|
|
133
|
+
function defaultPromptForIssue(id, title, url, template) {
|
|
134
|
+
if (template) {
|
|
135
|
+
return renderPromptTemplate(template, { id, title, url });
|
|
136
|
+
}
|
|
129
137
|
const isTeamPrefixed = /^[A-Z][A-Z0-9_]*-\d+$/.test(id);
|
|
130
138
|
if (isTeamPrefixed) {
|
|
131
139
|
return `Empezá a trabajar el ticket ${id} (${title}). Abrí ${url} para leer el contexto completo y seguí las convenciones del repo.`;
|
|
@@ -204,14 +212,22 @@ function RemoveOverlayView({ overlay }) {
|
|
|
204
212
|
function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
|
|
205
213
|
const labelWidth = 14;
|
|
206
214
|
const isNewBranch = overlay.branchMode === "new";
|
|
215
|
+
const isLinearBranch = overlay.linearBranch !== null;
|
|
216
|
+
// type/desc only apply to the convention "new branch" path — hidden both
|
|
217
|
+
// for detached ("current") mode and for the Linear-branchName case.
|
|
218
|
+
const showTypeDesc = isNewBranch && !isLinearBranch;
|
|
207
219
|
const detachedDesc = kebabize(overlay.issue.issue.title) || `issue-${overlay.issue.issue.id}`;
|
|
208
|
-
const branchPreview = isNewBranch
|
|
209
|
-
?
|
|
210
|
-
:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
220
|
+
const branchPreview = !isNewBranch
|
|
221
|
+
? `detached @ ${overlay.currentBranch ?? "(unknown)"}`
|
|
222
|
+
: isLinearBranch
|
|
223
|
+
? overlay.linearBranch
|
|
224
|
+
: `${overlay.type}/${overlay.issue.issue.id}-${overlay.desc}`;
|
|
225
|
+
const dirPreview = !isNewBranch
|
|
226
|
+
? `${overlay.issue.issue.id}-${detachedDesc}`
|
|
227
|
+
: isLinearBranch
|
|
228
|
+
? overlay.issue.issue.id
|
|
229
|
+
: `${overlay.issue.issue.id}-${overlay.desc}`;
|
|
230
|
+
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && isLinearBranch && (_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Branch name:" }) }), _jsx(Text, { color: "green", children: overlay.linearBranch }), _jsx(Text, { dimColor: true, children: " (from Linear)" })] })), showTypeDesc && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isLinearBranch ? (_jsx(Text, { dimColor: true, children: "Branch name comes from Linear (the issue's suggested `branchName`). The worktree dir is the bare issue id." })) : isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), showTypeDesc && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.steps.length > 0 && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: overlay.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(CreateStepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))) })), overlay.pending && (_jsxs(Box, { marginTop: overlay.steps.length > 0 ? 0 : 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
|
|
215
231
|
}
|
|
216
232
|
function CreateStepIcon({ kind }) {
|
|
217
233
|
if (kind === "ok")
|
|
@@ -964,6 +980,13 @@ export default function Dashboard() {
|
|
|
964
980
|
if (state.phase !== "ready")
|
|
965
981
|
return;
|
|
966
982
|
const root = findMainRepoRoot();
|
|
983
|
+
// On a Linear repo, prefer the branch Linear suggests for the issue
|
|
984
|
+
// (its `branchName`) over the synthesised `<type>/<issue>-<desc>` form —
|
|
985
|
+
// that's the convention those repos actually follow. Falls back to the
|
|
986
|
+
// convention form when the issue has no branchName.
|
|
987
|
+
const meta = root ? readMetadata(root) : undefined;
|
|
988
|
+
const provider = meta?.provider;
|
|
989
|
+
const linearBranch = provider === "linear" && issue.issue.branchName ? issue.issue.branchName : null;
|
|
967
990
|
setState({
|
|
968
991
|
...state,
|
|
969
992
|
overlay: {
|
|
@@ -973,7 +996,8 @@ export default function Dashboard() {
|
|
|
973
996
|
currentBranch: root ? getCurrentBranch(root) : null,
|
|
974
997
|
type: "feat",
|
|
975
998
|
desc: kebabize(issue.issue.title) || `issue-${issue.issue.id}`,
|
|
976
|
-
|
|
999
|
+
linearBranch,
|
|
1000
|
+
prompt: defaultPromptForIssue(issue.issue.id, issue.issue.title, issue.issue.url, meta?.promptTemplate),
|
|
977
1001
|
field: "branchMode",
|
|
978
1002
|
error: null,
|
|
979
1003
|
conventionDoc: root ? findBranchConventionDoc(root) : null,
|
|
@@ -1001,11 +1025,13 @@ export default function Dashboard() {
|
|
|
1001
1025
|
handleRemoveOverlayInput(input, key, overlay);
|
|
1002
1026
|
return;
|
|
1003
1027
|
}
|
|
1004
|
-
// In "current" branch mode
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1028
|
+
// In "current" branch mode (detached) and in the Linear-branchName case
|
|
1029
|
+
// we skip type+desc fields entirely — they have no meaning when the
|
|
1030
|
+
// branch is fixed (detached HEAD, or Linear's own `branchName`). Tab
|
|
1031
|
+
// cycles branchMode ⇄ prompt only.
|
|
1032
|
+
const skipTypeDesc = overlay.branchMode === "current" || overlay.linearBranch !== null;
|
|
1007
1033
|
if (key.tab) {
|
|
1008
|
-
const order =
|
|
1034
|
+
const order = skipTypeDesc
|
|
1009
1035
|
? ["branchMode", "prompt"]
|
|
1010
1036
|
: ["branchMode", "type", "desc", "prompt"];
|
|
1011
1037
|
const i = order.indexOf(overlay.field);
|
|
@@ -1068,8 +1094,9 @@ export default function Dashboard() {
|
|
|
1068
1094
|
if (state.phase !== "ready")
|
|
1069
1095
|
return;
|
|
1070
1096
|
// Validate first so we don't flash a spinner just to immediately show
|
|
1071
|
-
// a sync-fail message.
|
|
1072
|
-
|
|
1097
|
+
// a sync-fail message. A Linear-branch create needs no desc (the branch
|
|
1098
|
+
// is Linear's `branchName`), so only the convention path requires it.
|
|
1099
|
+
if (overlay.branchMode === "new" && !overlay.linearBranch && !overlay.desc.trim()) {
|
|
1073
1100
|
setState({
|
|
1074
1101
|
...state,
|
|
1075
1102
|
overlay: { ...overlay, error: "Description is required." },
|
|
@@ -1124,8 +1151,9 @@ export default function Dashboard() {
|
|
|
1124
1151
|
});
|
|
1125
1152
|
}
|
|
1126
1153
|
else {
|
|
1127
|
-
|
|
1128
|
-
|
|
1154
|
+
// Linear repos with a `branchName` use it verbatim; everyone else
|
|
1155
|
+
// synthesises the `<type>/<issue>-<desc>` convention branch.
|
|
1156
|
+
const branch = overlay.linearBranch ?? `${overlay.type}/${issueId}-${overlay.desc.trim()}`;
|
|
1129
1157
|
result = await runCreate(branch, {
|
|
1130
1158
|
work: true,
|
|
1131
1159
|
progress: { onStep, onPending },
|
|
@@ -13,7 +13,7 @@ export const description = "Create a worktree for an issue branch";
|
|
|
13
13
|
export const args = z.tuple([
|
|
14
14
|
z.string().describe(argument({
|
|
15
15
|
name: "branch",
|
|
16
|
-
description: "Branch in `<type>/<issue>-<kebab-desc>` format (e.g. feat/100-claude-md-inicial)",
|
|
16
|
+
description: "Branch in `<type>/<issue>-<kebab-desc>` format (e.g. feat/100-claude-md-inicial). On a Linear repo you can instead pass the issue's Linear branch name (e.g. martinmineo/val-68-landing-publica).",
|
|
17
17
|
})),
|
|
18
18
|
]);
|
|
19
19
|
export const options = z.object({
|
|
@@ -3,7 +3,7 @@ export declare const description = "Launch Claude in the current worktree (creat
|
|
|
3
3
|
export declare const options: z.ZodObject<{
|
|
4
4
|
prompt: z.ZodOptional<z.ZodString>;
|
|
5
5
|
promptFile: z.ZodOptional<z.ZodString>;
|
|
6
|
-
permissionMode: z.
|
|
6
|
+
permissionMode: z.ZodOptional<z.ZodEnum<{
|
|
7
7
|
default: "default";
|
|
8
8
|
auto: "auto";
|
|
9
9
|
}>>;
|
|
@@ -8,7 +8,7 @@ import { randomUUID } from "crypto";
|
|
|
8
8
|
import { readFileSync, unlinkSync } from "fs";
|
|
9
9
|
import * as path from "path";
|
|
10
10
|
import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getCurrentBranch, pathExists, } from "../../lib/git.js";
|
|
11
|
-
import { getSessionId, setSessionId } from "../../lib/metadata.js";
|
|
11
|
+
import { getSessionId, setSessionId, readMetadata } from "../../lib/metadata.js";
|
|
12
12
|
import { launchClaude, PERMISSION_MODES } from "../../lib/claude.js";
|
|
13
13
|
export const description = "Launch Claude in the current worktree (creates or resumes a session)";
|
|
14
14
|
export const options = z.object({
|
|
@@ -26,13 +26,13 @@ export const options = z.object({
|
|
|
26
26
|
})),
|
|
27
27
|
permissionMode: z
|
|
28
28
|
.enum(PERMISSION_MODES)
|
|
29
|
-
.
|
|
29
|
+
.optional()
|
|
30
30
|
.describe(option({
|
|
31
|
-
description: `Claude --permission-mode (one of: ${PERMISSION_MODES.join(", ")})
|
|
31
|
+
description: `Claude --permission-mode (one of: ${PERMISSION_MODES.join(", ")}). Defaults to metadata.defaultPermissionMode, else "default".`,
|
|
32
32
|
alias: "m",
|
|
33
33
|
})),
|
|
34
34
|
});
|
|
35
|
-
function resolve(cwd) {
|
|
35
|
+
function resolve(cwd, flagPermissionMode) {
|
|
36
36
|
const repoRoot = findMainRepoRoot(cwd);
|
|
37
37
|
if (!repoRoot) {
|
|
38
38
|
return {
|
|
@@ -93,6 +93,9 @@ function resolve(cwd) {
|
|
|
93
93
|
setSessionId(repoRoot, issueId, sessionId);
|
|
94
94
|
resume = false;
|
|
95
95
|
}
|
|
96
|
+
// Effective permission mode: explicit `--permission-mode` flag wins, else
|
|
97
|
+
// the repo's `metadata.defaultPermissionMode`, else the stricter "default".
|
|
98
|
+
const permissionMode = flagPermissionMode ?? readMetadata(repoRoot).defaultPermissionMode ?? "default";
|
|
96
99
|
return {
|
|
97
100
|
ok: true,
|
|
98
101
|
data: {
|
|
@@ -103,6 +106,7 @@ function resolve(cwd) {
|
|
|
103
106
|
issueId,
|
|
104
107
|
sessionId,
|
|
105
108
|
resume,
|
|
109
|
+
permissionMode,
|
|
106
110
|
},
|
|
107
111
|
};
|
|
108
112
|
}
|
|
@@ -118,7 +122,7 @@ export default function Work({ options }) {
|
|
|
118
122
|
});
|
|
119
123
|
return;
|
|
120
124
|
}
|
|
121
|
-
const result = resolve(process.cwd());
|
|
125
|
+
const result = resolve(process.cwd(), options.permissionMode);
|
|
122
126
|
if (!result.ok) {
|
|
123
127
|
setState({ phase: "error", message: result.message, hint: result.hint });
|
|
124
128
|
return;
|
|
@@ -151,7 +155,7 @@ export default function Work({ options }) {
|
|
|
151
155
|
}
|
|
152
156
|
try {
|
|
153
157
|
const child = launchClaude({
|
|
154
|
-
permissionMode:
|
|
158
|
+
permissionMode: resolved.permissionMode,
|
|
155
159
|
sessionId: resolved.sessionId,
|
|
156
160
|
resume: resolved.resume,
|
|
157
161
|
prompt: effectivePrompt,
|
|
@@ -184,7 +188,7 @@ export default function Work({ options }) {
|
|
|
184
188
|
const { resolved } = state;
|
|
185
189
|
const sessionShort = resolved.sessionId.slice(0, 8);
|
|
186
190
|
const action = resolved.resume ? "resuming" : "starting";
|
|
187
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree work" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", resolved.branch ?? `detached @ ${resolved.worktreeDirName}`] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "session: " }), _jsxs(Text, { children: [sessionShort, "\u2026"] }), _jsxs(Text, { dimColor: true, children: [" (", action, ")"] })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "permission-mode: " }), _jsx(Text, { children:
|
|
191
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree work" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", resolved.branch ?? `detached @ ${resolved.worktreeDirName}`] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "session: " }), _jsxs(Text, { children: [sessionShort, "\u2026"] }), _jsxs(Text, { dimColor: true, children: [" (", action, ")"] })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "permission-mode: " }), _jsx(Text, { children: resolved.permissionMode })] }), options.prompt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "initial prompt: " }), _jsxs(Text, { children: ["\"", truncate(options.prompt, 60), "\""] })] })), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "cwd: " }), _jsx(Text, { dimColor: true, children: resolved.worktreePath })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }) })] }));
|
|
188
192
|
}
|
|
189
193
|
function truncate(s, max) {
|
|
190
194
|
if (s.length <= max)
|
package/dist/lib/branch.d.ts
CHANGED
|
@@ -22,10 +22,10 @@ export declare const ALLOWED_TYPES: readonly ["feat", "fix", "docs", "chore", "r
|
|
|
22
22
|
export type BranchType = (typeof ALLOWED_TYPES)[number];
|
|
23
23
|
export type ParsedBranch = {
|
|
24
24
|
branch: string;
|
|
25
|
-
type: BranchType;
|
|
26
25
|
issueId: string;
|
|
27
|
-
desc: string;
|
|
28
26
|
worktreeDirName: string;
|
|
27
|
+
type?: BranchType;
|
|
28
|
+
desc?: string;
|
|
29
29
|
};
|
|
30
30
|
export type ParseError = {
|
|
31
31
|
error: string;
|
|
@@ -33,3 +33,26 @@ export type ParseError = {
|
|
|
33
33
|
};
|
|
34
34
|
export declare function parseBranch(branch: string): ParsedBranch | ParseError;
|
|
35
35
|
export declare function isParseError(result: ParsedBranch | ParseError): result is ParseError;
|
|
36
|
+
/**
|
|
37
|
+
* Pulls the Linear issue identifier (`<TEAM>-<n>`) out of a Linear-style
|
|
38
|
+
* branch name. Linear's `branchName` looks like `<user>/<team>-<n>-<slug>`
|
|
39
|
+
* (or just `<team>-<n>-<slug>` when the workspace has no user prefix), with
|
|
40
|
+
* the identifier lower-cased. We locate `<team>-<digits>` as a delimited token
|
|
41
|
+
* and return it upper-cased so it matches the canonical issue id everywhere
|
|
42
|
+
* else in mintree.
|
|
43
|
+
*
|
|
44
|
+
* `teamKeys` narrows the search to the repo's configured teams (the robust
|
|
45
|
+
* path); when empty — a mis-configured repo — we fall back to the first
|
|
46
|
+
* `<letters>-<digits>` token, which is still correct for the common single-
|
|
47
|
+
* identifier branch shape.
|
|
48
|
+
*/
|
|
49
|
+
export declare function extractLinearIssueId(branch: string, teamKeys: string[]): string | null;
|
|
50
|
+
/**
|
|
51
|
+
* Resolves a Linear `branchName` into a ParsedBranch. The branch is kept
|
|
52
|
+
* verbatim (only trimmed) — git refs are case-sensitive and Linear's value is
|
|
53
|
+
* the exact ref GitHub/Linear will link against, so we must not rewrite it.
|
|
54
|
+
* Only the extracted `issueId` is normalised to upper-case. The worktree dir
|
|
55
|
+
* is the bare issue id (`VAL-68`), matching the convention path and the
|
|
56
|
+
* dir-name recovery regexes elsewhere.
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseLinearBranch(rawBranch: string, teamKeys: string[]): ParsedBranch | ParseError;
|
package/dist/lib/branch.js
CHANGED
|
@@ -78,3 +78,71 @@ export function parseBranch(branch) {
|
|
|
78
78
|
export function isParseError(result) {
|
|
79
79
|
return "error" in result;
|
|
80
80
|
}
|
|
81
|
+
// Chars that make a string an invalid (or risky) git ref. Linear's
|
|
82
|
+
// `branchName` never contains these, but this entry point is also reachable
|
|
83
|
+
// from the CLI (`mintree worktree create <branch>`), so junk input is rejected
|
|
84
|
+
// rather than handed to `git worktree add`.
|
|
85
|
+
const GIT_REF_INVALID = /[\s~^:?*[\]\\]/;
|
|
86
|
+
function escapeRegex(s) {
|
|
87
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Pulls the Linear issue identifier (`<TEAM>-<n>`) out of a Linear-style
|
|
91
|
+
* branch name. Linear's `branchName` looks like `<user>/<team>-<n>-<slug>`
|
|
92
|
+
* (or just `<team>-<n>-<slug>` when the workspace has no user prefix), with
|
|
93
|
+
* the identifier lower-cased. We locate `<team>-<digits>` as a delimited token
|
|
94
|
+
* and return it upper-cased so it matches the canonical issue id everywhere
|
|
95
|
+
* else in mintree.
|
|
96
|
+
*
|
|
97
|
+
* `teamKeys` narrows the search to the repo's configured teams (the robust
|
|
98
|
+
* path); when empty — a mis-configured repo — we fall back to the first
|
|
99
|
+
* `<letters>-<digits>` token, which is still correct for the common single-
|
|
100
|
+
* identifier branch shape.
|
|
101
|
+
*/
|
|
102
|
+
export function extractLinearIssueId(branch, teamKeys) {
|
|
103
|
+
const keys = teamKeys.filter((k) => k.length > 0);
|
|
104
|
+
// The identifier must sit on a token boundary (start, or after `/ _ -`) and
|
|
105
|
+
// be followed by one (end, or before `/ _ -`) so we don't match a team key
|
|
106
|
+
// buried inside a slug word.
|
|
107
|
+
const keyAlt = keys.length > 0 ? keys.map(escapeRegex).join("|") : "[A-Za-z][A-Za-z0-9_]*";
|
|
108
|
+
const re = new RegExp(`(?:^|[/_-])(${keyAlt})-(\\d+)(?=$|[/_-])`, "i");
|
|
109
|
+
const m = re.exec(branch);
|
|
110
|
+
if (!m || !m[1] || !m[2])
|
|
111
|
+
return null;
|
|
112
|
+
return `${m[1].toUpperCase()}-${m[2]}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Resolves a Linear `branchName` into a ParsedBranch. The branch is kept
|
|
116
|
+
* verbatim (only trimmed) — git refs are case-sensitive and Linear's value is
|
|
117
|
+
* the exact ref GitHub/Linear will link against, so we must not rewrite it.
|
|
118
|
+
* Only the extracted `issueId` is normalised to upper-case. The worktree dir
|
|
119
|
+
* is the bare issue id (`VAL-68`), matching the convention path and the
|
|
120
|
+
* dir-name recovery regexes elsewhere.
|
|
121
|
+
*/
|
|
122
|
+
export function parseLinearBranch(rawBranch, teamKeys) {
|
|
123
|
+
const branch = rawBranch.trim();
|
|
124
|
+
if (!branch) {
|
|
125
|
+
return {
|
|
126
|
+
error: "Empty branch name",
|
|
127
|
+
hint: "Linear should provide a `branchName` like `martinmineo/val-68-landing-publica`.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (GIT_REF_INVALID.test(branch) || branch.startsWith("/") || branch.endsWith("/")) {
|
|
131
|
+
return {
|
|
132
|
+
error: `Invalid branch name: ${rawBranch}`,
|
|
133
|
+
hint: "A Linear branch must be a valid git ref (no spaces or ~^:?*[]\\).",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const issueId = extractLinearIssueId(branch, teamKeys);
|
|
137
|
+
if (!issueId) {
|
|
138
|
+
const hint = teamKeys.length > 0
|
|
139
|
+
? `Expected a Linear identifier for one of: ${teamKeys.join(", ")} (e.g. ${teamKeys[0]}-123). Got \`${branch}\`.`
|
|
140
|
+
: `Expected a Linear identifier like \`team-123\` somewhere in the branch. Got \`${branch}\`.`;
|
|
141
|
+
return { error: `Could not find a Linear issue id in branch \`${branch}\``, hint };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
branch,
|
|
145
|
+
issueId,
|
|
146
|
+
worktreeDirName: issueId,
|
|
147
|
+
};
|
|
148
|
+
}
|
package/dist/lib/metadata.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PermissionMode } from "./claude.js";
|
|
1
2
|
export type IssueMeta = {
|
|
2
3
|
base_branch?: string;
|
|
3
4
|
session_id?: string;
|
|
@@ -26,6 +27,8 @@ export type Metadata = {
|
|
|
26
27
|
issues: Record<string, IssueMeta>;
|
|
27
28
|
project?: ProjectMeta;
|
|
28
29
|
linear?: LinearMeta;
|
|
30
|
+
defaultPermissionMode?: PermissionMode;
|
|
31
|
+
promptTemplate?: string;
|
|
29
32
|
};
|
|
30
33
|
export declare function readMetadata(repoRoot: string): Metadata;
|
|
31
34
|
export declare function writeMetadata(repoRoot: string, data: Metadata): void;
|
package/dist/lib/metadata.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import { getMetadataPath } from "./git.js";
|
|
3
|
+
import { PERMISSION_MODES } from "./claude.js";
|
|
3
4
|
const EMPTY = { version: 1, issues: {} };
|
|
4
5
|
function sanitizeProvider(raw) {
|
|
5
6
|
if (raw === "github" || raw === "linear")
|
|
6
7
|
return raw;
|
|
7
8
|
return undefined;
|
|
8
9
|
}
|
|
10
|
+
function sanitizePermissionMode(raw) {
|
|
11
|
+
return PERMISSION_MODES.includes(raw) ? raw : undefined;
|
|
12
|
+
}
|
|
13
|
+
function sanitizePromptTemplate(raw) {
|
|
14
|
+
return typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
|
|
15
|
+
}
|
|
9
16
|
function sanitizeLinearTeam(raw) {
|
|
10
17
|
if (typeof raw !== "object" || raw === null)
|
|
11
18
|
return undefined;
|
|
@@ -77,6 +84,8 @@ export function readMetadata(repoRoot) {
|
|
|
77
84
|
const project = sanitizeProject(parsed.project);
|
|
78
85
|
const provider = sanitizeProvider(parsed.provider);
|
|
79
86
|
const linear = sanitizeLinear(parsed.linear);
|
|
87
|
+
const defaultPermissionMode = sanitizePermissionMode(parsed.defaultPermissionMode);
|
|
88
|
+
const promptTemplate = sanitizePromptTemplate(parsed.promptTemplate);
|
|
80
89
|
return {
|
|
81
90
|
version: 1,
|
|
82
91
|
issues: typeof parsed.issues === "object" && parsed.issues !== null
|
|
@@ -85,6 +94,8 @@ export function readMetadata(repoRoot) {
|
|
|
85
94
|
...(provider ? { provider } : {}),
|
|
86
95
|
...(project ? { project } : {}),
|
|
87
96
|
...(linear ? { linear } : {}),
|
|
97
|
+
...(defaultPermissionMode ? { defaultPermissionMode } : {}),
|
|
98
|
+
...(promptTemplate ? { promptTemplate } : {}),
|
|
88
99
|
};
|
|
89
100
|
}
|
|
90
101
|
catch {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variables available to a `promptTemplate` in `.mintree/metadata.json`.
|
|
3
|
+
* Kept intentionally small — the template seeds Claude's first message, it
|
|
4
|
+
* doesn't need the whole issue object.
|
|
5
|
+
*/
|
|
6
|
+
export type PromptVars = {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
url: string;
|
|
10
|
+
};
|
|
11
|
+
export declare const PROMPT_PLACEHOLDERS: readonly ["{{id}}", "{{title}}", "{{url}}"];
|
|
12
|
+
/**
|
|
13
|
+
* Renders a `promptTemplate` by substituting the `{{id}}`, `{{title}}` and
|
|
14
|
+
* `{{url}}` placeholders with the issue's values. Whitespace inside the braces
|
|
15
|
+
* is tolerated (`{{ id }}`). Unknown placeholders are left untouched so a typo
|
|
16
|
+
* is visible in the launched prompt instead of silently vanishing.
|
|
17
|
+
*/
|
|
18
|
+
export declare function renderPromptTemplate(template: string, vars: PromptVars): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Placeholder tokens a user can drop into their `promptTemplate`. Documented
|
|
2
|
+
// here so the README and any future `init`/help output stay in sync.
|
|
3
|
+
export const PROMPT_PLACEHOLDERS = ["{{id}}", "{{title}}", "{{url}}"];
|
|
4
|
+
/**
|
|
5
|
+
* Renders a `promptTemplate` by substituting the `{{id}}`, `{{title}}` and
|
|
6
|
+
* `{{url}}` placeholders with the issue's values. Whitespace inside the braces
|
|
7
|
+
* is tolerated (`{{ id }}`). Unknown placeholders are left untouched so a typo
|
|
8
|
+
* is visible in the launched prompt instead of silently vanishing.
|
|
9
|
+
*/
|
|
10
|
+
export function renderPromptTemplate(template, vars) {
|
|
11
|
+
return template
|
|
12
|
+
.replace(/\{\{\s*id\s*\}\}/g, vars.id)
|
|
13
|
+
.replace(/\{\{\s*title\s*\}\}/g, vars.title)
|
|
14
|
+
.replace(/\{\{\s*url\s*\}\}/g, vars.url);
|
|
15
|
+
}
|
|
@@ -271,6 +271,7 @@ const BOOTSTRAP_QUERY = /* GraphQL */ `
|
|
|
271
271
|
title
|
|
272
272
|
description
|
|
273
273
|
url
|
|
274
|
+
branchName
|
|
274
275
|
priority
|
|
275
276
|
createdAt
|
|
276
277
|
updatedAt
|
|
@@ -334,6 +335,7 @@ function mapIssueToProviderIssue(wi) {
|
|
|
334
335
|
// value) to null so the dashboard treats it the same as GitHub's
|
|
335
336
|
// no-priority rows.
|
|
336
337
|
priority: wi.priority && wi.priority > 0 ? wi.priority : null,
|
|
338
|
+
...(wi.branchName && wi.branchName.length > 0 ? { branchName: wi.branchName } : {}),
|
|
337
339
|
};
|
|
338
340
|
}
|
|
339
341
|
export class LinearProvider {
|
|
@@ -2,9 +2,32 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { execSync } from "child_process";
|
|
5
|
-
import { parseBranch, isParseError } from "./branch.js";
|
|
5
|
+
import { parseBranch, parseLinearBranch, isParseError, } from "./branch.js";
|
|
6
6
|
import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getInitScriptPath, getDefaultBranch, getCurrentBranch, branchExists, remoteBranchExists, fetchRemote, worktreeForBranch, addWorktree, pathExists, isExecutable, } from "./git.js";
|
|
7
|
-
import { upsertIssue } from "./metadata.js";
|
|
7
|
+
import { readMetadata, upsertIssue } from "./metadata.js";
|
|
8
|
+
/**
|
|
9
|
+
* Resolves the branch arg into a ParsedBranch, choosing the parser by
|
|
10
|
+
* provider. The convention parser (`<type>/<issue>-<desc>`) is tried first for
|
|
11
|
+
* every provider — a Linear repo can still use convention branches. When that
|
|
12
|
+
* fails AND the repo is on the Linear provider, we fall back to parsing the
|
|
13
|
+
* arg as a Linear `branchName` (`<user>/<team>-<n>-<slug>`), keyed off the
|
|
14
|
+
* configured team keys. GitHub repos never reach the Linear branch, so their
|
|
15
|
+
* behaviour is unchanged.
|
|
16
|
+
*/
|
|
17
|
+
function resolveCreateBranch(repoRoot, branchArg) {
|
|
18
|
+
const conv = parseBranch(branchArg);
|
|
19
|
+
if (!isParseError(conv))
|
|
20
|
+
return conv;
|
|
21
|
+
const meta = readMetadata(repoRoot);
|
|
22
|
+
if (meta.provider === "linear" && meta.linear) {
|
|
23
|
+
const teamKeys = meta.linear.teams.map((t) => t.key);
|
|
24
|
+
const linear = parseLinearBranch(branchArg, teamKeys);
|
|
25
|
+
// On a Linear repo the Linear-branch error is the more useful one to
|
|
26
|
+
// surface, so return it whether it parsed or not.
|
|
27
|
+
return linear;
|
|
28
|
+
}
|
|
29
|
+
return conv;
|
|
30
|
+
}
|
|
8
31
|
function tryRunInitScript(scriptPath, worktreePath, repoRoot) {
|
|
9
32
|
if (!pathExists(scriptPath))
|
|
10
33
|
return { ran: false };
|
|
@@ -72,7 +95,7 @@ export async function runCreate(branchArg, opts) {
|
|
|
72
95
|
hint: "Run `mintree init` first.",
|
|
73
96
|
};
|
|
74
97
|
}
|
|
75
|
-
const parsed =
|
|
98
|
+
const parsed = resolveCreateBranch(root, branchArg);
|
|
76
99
|
if (isParseError(parsed)) {
|
|
77
100
|
return { ok: false, message: parsed.error, hint: parsed.hint };
|
|
78
101
|
}
|
|
@@ -100,7 +123,9 @@ export async function runCreate(branchArg, opts) {
|
|
|
100
123
|
pushStep({
|
|
101
124
|
kind: "ok",
|
|
102
125
|
label: "parsed branch",
|
|
103
|
-
detail:
|
|
126
|
+
detail: parsed.type
|
|
127
|
+
? `type=${parsed.type}, issue=${parsed.issueId}, desc=${parsed.desc}`
|
|
128
|
+
: `issue=${parsed.issueId}, branch=${parsed.branch}`,
|
|
104
129
|
});
|
|
105
130
|
await nextFrame(progress);
|
|
106
131
|
// Fetch before resolving refs so the worktree forks from fresh code, not a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mintree",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
4
4
|
"description": "Issue-driven git worktrees + Claude Code sessions for repos with an opinionated SDD+TDD flow.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Martin Mineo <mmineo@canarytechnologies.com>",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"start": "node dist/cli.js",
|
|
35
35
|
"lint": "eslint source",
|
|
36
36
|
"lint:fix": "eslint source --fix",
|
|
37
|
-
"format": "prettier --write source"
|
|
37
|
+
"format": "prettier --write source",
|
|
38
|
+
"test": "node --import tsx --test test/*.test.ts"
|
|
38
39
|
},
|
|
39
40
|
"files": [
|
|
40
41
|
"dist",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"eslint-config-prettier": "^10.1.8",
|
|
59
60
|
"eslint-plugin-prettier": "^5.5.5",
|
|
60
61
|
"prettier": "^3.7.4",
|
|
62
|
+
"tsx": "^4.22.4",
|
|
61
63
|
"typescript": "^5.7.0"
|
|
62
64
|
},
|
|
63
65
|
"overrides": {
|