mintree 0.5.9 → 0.5.12
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 +25 -2
- package/dist/lib/claude.js +12 -12
- package/dist/lib/metadata.d.ts +1 -0
- package/dist/lib/metadata.js +33 -0
- package/dist/lib/terminal.d.ts +9 -8
- package/dist/lib/terminal.js +20 -22
- package/dist/lib/worktreeCreate.js +67 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -161,6 +161,29 @@ Three top-level keys in `.mintree/metadata.json` tune how mintree launches Claud
|
|
|
161
161
|
|
|
162
162
|
When omitted, mintree uses a built-in default that asks Claude to orchestrate the selected tickets with minimal intervention — parallelising via subagents unless dependencies force sequential work, creating a worktree per ticket with mintree, using the repo's skills, and moving each ticket to *in progress* on start and closing it when done.
|
|
163
163
|
|
|
164
|
+
### Linking gitignored files into worktrees (optional)
|
|
165
|
+
|
|
166
|
+
Git worktrees don't share **untracked** files: a new worktree is a fresh working directory, so gitignored config like `.env` lives only in your main checkout and is **absent** in every worktree mintree creates. That breaks per-worktree tooling that needs it — e.g. running an E2E suite that reads `.env` for staging credentials.
|
|
167
|
+
|
|
168
|
+
The `linkFiles` top-level key (valid on GitHub and Linear repos) lists repo-root-relative paths that mintree **symlinks** into each new worktree, right after creating it:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"version": 1,
|
|
173
|
+
"provider": "linear",
|
|
174
|
+
"issues": {},
|
|
175
|
+
"linkFiles": [".env"],
|
|
176
|
+
"linear": { "workspaceSlug": "my-team", "teams": [{ "key": "FE" }] }
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- **Symlink, not copy** — one source of truth. Rotate a credential in the main `.env` and every worktree sees it; no re-copying. `worktree remove` deletes the link, never the original file.
|
|
181
|
+
- **Best-effort, never fatal** — an entry that doesn't exist in the repo root is skipped, and so is one whose target is already present in the worktree (e.g. a tracked file). Both show up as `skip` steps in the create log.
|
|
182
|
+
- **Runs before `.mintree/init.sh`** — so the post-create hook (if any) can rely on the linked files being there.
|
|
183
|
+
- **Sandboxed paths** — entries must be repo-root-relative; absolute paths and `..` escapes are dropped on read, so a stray `metadata.json` can't make mintree link something outside the worktree.
|
|
184
|
+
|
|
185
|
+
This applies to `worktree create` (CLI), the dashboard `w` overlay, and the detached-worktree flow alike. For more involved per-worktree setup (installing deps, copying templated files), use `.mintree/init.sh` instead — see [What gets stored where](#what-gets-stored-where).
|
|
186
|
+
|
|
164
187
|
---
|
|
165
188
|
|
|
166
189
|
## Daily flow
|
|
@@ -276,7 +299,7 @@ The worktree directory is still the **bare, upper-case issue id** (`VAL-68`) reg
|
|
|
276
299
|
│ └── FE-123/ # Linear form: <TEAM-digits>
|
|
277
300
|
├── session-states/ # gitignored
|
|
278
301
|
│ └── 100.json # live state written by Claude hooks (active/waiting/idle/exited)
|
|
279
|
-
└── init.sh # opt-in. Runs in the new worktree post-create (
|
|
302
|
+
└── init.sh # opt-in. Runs in the new worktree post-create (install deps, scaffold, …)
|
|
280
303
|
```
|
|
281
304
|
|
|
282
305
|
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.
|
|
@@ -292,7 +315,7 @@ Linear authentication lives in `~/.mintree/credentials.json` (user-scoped, not p
|
|
|
292
315
|
- **Sessions persist by issue**: each issue gets a UUID stored in `metadata.json`. Subsequent `worktree work` calls pass `--resume <uuid>` so Claude reopens the same conversation.
|
|
293
316
|
- **Live state** (optional): the four hooks installed by `mintree helpers session-signal install` write the current Claude state to `.mintree/session-states/<issue>.json` on every prompt / stop / notification / session-end. The dashboard reads those files to colour each row in real time.
|
|
294
317
|
- **Remote Control** (optional): `mintree doctor` checks `~/.claude.json` for `remoteControlAtStartup: true`. Enabling it lets you continue a local session from a different device.
|
|
295
|
-
- **iTerm2
|
|
318
|
+
- **iTerm2 session badge** (automatic): when you launch on [iTerm2](https://iterm2.com), mintree sets the terminal **badge** — the large translucent label drawn over the session — to the session name (the worktree issue id like `VAL-68`, or the orchestrator's name) so each tab stays identifiable at a glance. It uses the badge rather than the tab title because Claude Code overwrites the title while it runs; the badge is independent of it and persists for the whole session, then clears on exit. No-op on other terminals (detected via `TERM_PROGRAM` / `LC_TERMINAL`).
|
|
296
319
|
|
|
297
320
|
---
|
|
298
321
|
|
package/dist/lib/claude.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execSync, spawn } from "child_process";
|
|
|
2
2
|
import { existsSync, writeFileSync } from "fs";
|
|
3
3
|
import { homedir, tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import {
|
|
5
|
+
import { setITermBadge, clearITermBadge } from "./terminal.js";
|
|
6
6
|
export const PERMISSION_MODES = ["default", "auto"];
|
|
7
7
|
/**
|
|
8
8
|
* Resolves the absolute path of the Claude Code CLI binary, or null if not on
|
|
@@ -75,17 +75,17 @@ export function launchClaude(options) {
|
|
|
75
75
|
if (options.prompt && options.prompt.length > 0) {
|
|
76
76
|
args.push("--", promptArg(options.prompt));
|
|
77
77
|
}
|
|
78
|
-
// Label the session
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
|
|
78
|
+
// Label the session with an iTerm2 badge before handing over the TTY. The
|
|
79
|
+
// badge survives Claude overwriting the terminal title, so the tab stays
|
|
80
|
+
// identifiable (worktree issue id, or orchestrator name) while it runs.
|
|
81
|
+
// No-op outside iTerm2.
|
|
82
|
+
const badge = options.remoteControlName;
|
|
83
|
+
if (badge)
|
|
84
|
+
setITermBadge(badge);
|
|
85
85
|
const child = spawn(bin, args, { stdio: "inherit", cwd: options.cwd });
|
|
86
|
-
// Clear the
|
|
87
|
-
// that regains the TTY.
|
|
88
|
-
if (
|
|
89
|
-
child.on("exit", () =>
|
|
86
|
+
// Clear the badge once Claude exits so the badge doesn't linger on the
|
|
87
|
+
// shell that regains the TTY.
|
|
88
|
+
if (badge)
|
|
89
|
+
child.on("exit", () => clearITermBadge());
|
|
90
90
|
return child;
|
|
91
91
|
}
|
package/dist/lib/metadata.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type Metadata = {
|
|
|
30
30
|
defaultPermissionMode?: PermissionMode;
|
|
31
31
|
promptTemplate?: string;
|
|
32
32
|
orchestratorPromptTemplate?: string;
|
|
33
|
+
linkFiles?: string[];
|
|
33
34
|
};
|
|
34
35
|
export declare function readMetadata(repoRoot: string): Metadata;
|
|
35
36
|
export declare function writeMetadata(repoRoot: string, data: Metadata): void;
|
package/dist/lib/metadata.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
2
3
|
import { getMetadataPath } from "./git.js";
|
|
3
4
|
import { PERMISSION_MODES } from "./claude.js";
|
|
4
5
|
const EMPTY = { version: 1, issues: {} };
|
|
@@ -16,6 +17,36 @@ function sanitizePromptTemplate(raw) {
|
|
|
16
17
|
function sanitizeOrchestratorPromptTemplate(raw) {
|
|
17
18
|
return typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Keeps only safe, repo-root-relative paths. Drops non-strings, blanks,
|
|
22
|
+
* absolute paths and any entry that escapes the repo root via `..` — a
|
|
23
|
+
* malicious / fat-fingered `metadata.json` must never make mintree symlink
|
|
24
|
+
* something outside the worktree. Normalises and de-dupes the survivors.
|
|
25
|
+
*/
|
|
26
|
+
function sanitizeLinkFiles(raw) {
|
|
27
|
+
if (!Array.isArray(raw))
|
|
28
|
+
return undefined;
|
|
29
|
+
const out = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const v of raw) {
|
|
32
|
+
if (typeof v !== "string")
|
|
33
|
+
continue;
|
|
34
|
+
const trimmed = v.trim();
|
|
35
|
+
if (trimmed.length === 0 || path.isAbsolute(trimmed))
|
|
36
|
+
continue;
|
|
37
|
+
const norm = path.normalize(trimmed);
|
|
38
|
+
if (norm === ".." ||
|
|
39
|
+
norm.startsWith(`..${path.sep}`) ||
|
|
40
|
+
norm.includes(`${path.sep}..${path.sep}`)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (seen.has(norm))
|
|
44
|
+
continue;
|
|
45
|
+
seen.add(norm);
|
|
46
|
+
out.push(norm);
|
|
47
|
+
}
|
|
48
|
+
return out.length > 0 ? out : undefined;
|
|
49
|
+
}
|
|
19
50
|
function sanitizeLinearTeam(raw) {
|
|
20
51
|
if (typeof raw !== "object" || raw === null)
|
|
21
52
|
return undefined;
|
|
@@ -90,6 +121,7 @@ export function readMetadata(repoRoot) {
|
|
|
90
121
|
const defaultPermissionMode = sanitizePermissionMode(parsed.defaultPermissionMode);
|
|
91
122
|
const promptTemplate = sanitizePromptTemplate(parsed.promptTemplate);
|
|
92
123
|
const orchestratorPromptTemplate = sanitizeOrchestratorPromptTemplate(parsed.orchestratorPromptTemplate);
|
|
124
|
+
const linkFiles = sanitizeLinkFiles(parsed.linkFiles);
|
|
93
125
|
return {
|
|
94
126
|
version: 1,
|
|
95
127
|
issues: typeof parsed.issues === "object" && parsed.issues !== null
|
|
@@ -101,6 +133,7 @@ export function readMetadata(repoRoot) {
|
|
|
101
133
|
...(defaultPermissionMode ? { defaultPermissionMode } : {}),
|
|
102
134
|
...(promptTemplate ? { promptTemplate } : {}),
|
|
103
135
|
...(orchestratorPromptTemplate ? { orchestratorPromptTemplate } : {}),
|
|
136
|
+
...(linkFiles ? { linkFiles } : {}),
|
|
104
137
|
};
|
|
105
138
|
}
|
|
106
139
|
catch {
|
package/dist/lib/terminal.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/** True when the active terminal is iTerm2 (also detected through tmux). */
|
|
2
2
|
export declare function isITerm(): boolean;
|
|
3
3
|
/**
|
|
4
|
-
* Builds the raw
|
|
5
|
-
* so it can be unit-tested; an empty string clears the
|
|
6
|
-
* iTerm2
|
|
4
|
+
* Builds the raw iTerm2 SetBadgeFormat escape sequence for `text`. Pure (no I/O)
|
|
5
|
+
* so it can be unit-tested; an empty string clears the badge. The badge format
|
|
6
|
+
* supports `\(...)` interpolation in iTerm2, but plain ids/labels carry no
|
|
7
|
+
* parens so the base64-encoded literal renders verbatim.
|
|
7
8
|
*/
|
|
8
|
-
export declare function
|
|
9
|
-
/** Sets the iTerm2
|
|
10
|
-
export declare function
|
|
11
|
-
/** Clears the iTerm2
|
|
12
|
-
export declare function
|
|
9
|
+
export declare function buildBadgeSequence(text: string): string;
|
|
10
|
+
/** Sets the iTerm2 badge to `text`. No-op outside iTerm2 or with empty text. */
|
|
11
|
+
export declare function setITermBadge(text: string): void;
|
|
12
|
+
/** Clears the iTerm2 badge. No-op outside iTerm2. */
|
|
13
|
+
export declare function clearITermBadge(): void;
|
package/dist/lib/terminal.js
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
// iTerm2
|
|
1
|
+
// iTerm2 badge integration.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// icon name / tab title is a separate slot it leaves alone. An earlier version
|
|
10
|
-
// used the iTerm2 badge instead, but the badge font scales to fill a
|
|
11
|
-
// profile-controlled box and rendered enormous for short labels, with no
|
|
12
|
-
// per-session escape to shrink it. The tab title is small and unobtrusive.
|
|
3
|
+
// Claude Code overwrites the terminal title (OSC 0/2) while it runs and exposes
|
|
4
|
+
// no way to disable that or pin a custom title, so a tab title we set before
|
|
5
|
+
// launching wouldn't survive. The iTerm2 *badge* — a large translucent label
|
|
6
|
+
// drawn over the session — is independent of the title and Claude never touches
|
|
7
|
+
// it, so it's the one reliable way to identify a mintree session at a glance
|
|
8
|
+
// (e.g. the worktree issue id `VAL-68`, or `orchestrator-VAL-12_BE-16`).
|
|
13
9
|
//
|
|
14
10
|
// Everything here is a no-op outside iTerm2, so callers can invoke it
|
|
15
11
|
// unconditionally.
|
|
@@ -29,12 +25,14 @@ function wrapForTmux(seq) {
|
|
|
29
25
|
return `${ESC}Ptmux;${doubled}${ESC}\\`;
|
|
30
26
|
}
|
|
31
27
|
/**
|
|
32
|
-
* Builds the raw
|
|
33
|
-
* so it can be unit-tested; an empty string clears the
|
|
34
|
-
* iTerm2
|
|
28
|
+
* Builds the raw iTerm2 SetBadgeFormat escape sequence for `text`. Pure (no I/O)
|
|
29
|
+
* so it can be unit-tested; an empty string clears the badge. The badge format
|
|
30
|
+
* supports `\(...)` interpolation in iTerm2, but plain ids/labels carry no
|
|
31
|
+
* parens so the base64-encoded literal renders verbatim.
|
|
35
32
|
*/
|
|
36
|
-
export function
|
|
37
|
-
|
|
33
|
+
export function buildBadgeSequence(text) {
|
|
34
|
+
const b64 = Buffer.from(text, "utf8").toString("base64");
|
|
35
|
+
return wrapForTmux(`${ESC}]1337;SetBadgeFormat=${b64}${BEL}`);
|
|
38
36
|
}
|
|
39
37
|
function writeToTty(seq) {
|
|
40
38
|
// We only call this right before spawning Claude / right after it exits,
|
|
@@ -43,15 +41,15 @@ function writeToTty(seq) {
|
|
|
43
41
|
if (process.stdout.isTTY)
|
|
44
42
|
process.stdout.write(seq);
|
|
45
43
|
}
|
|
46
|
-
/** Sets the iTerm2
|
|
47
|
-
export function
|
|
44
|
+
/** Sets the iTerm2 badge to `text`. No-op outside iTerm2 or with empty text. */
|
|
45
|
+
export function setITermBadge(text) {
|
|
48
46
|
if (!isITerm() || !text)
|
|
49
47
|
return;
|
|
50
|
-
writeToTty(
|
|
48
|
+
writeToTty(buildBadgeSequence(text));
|
|
51
49
|
}
|
|
52
|
-
/** Clears the iTerm2
|
|
53
|
-
export function
|
|
50
|
+
/** Clears the iTerm2 badge. No-op outside iTerm2. */
|
|
51
|
+
export function clearITermBadge() {
|
|
54
52
|
if (!isITerm())
|
|
55
53
|
return;
|
|
56
|
-
writeToTty(
|
|
54
|
+
writeToTty(buildBadgeSequence(""));
|
|
57
55
|
}
|
|
@@ -49,6 +49,59 @@ function tryRunInitScript(scriptPath, worktreePath, repoRoot) {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Symlinks each `metadata.linkFiles` entry from the main repo into the freshly
|
|
54
|
+
* created worktree. Git worktrees don't share untracked files, so gitignored
|
|
55
|
+
* config like `.env` is absent in a new worktree; this links it in so the
|
|
56
|
+
* worktree's tooling finds the same secrets/config as the main checkout.
|
|
57
|
+
*
|
|
58
|
+
* A symlink (not a copy) keeps a single source of truth — rotating a credential
|
|
59
|
+
* in the main `.env` is seen by every worktree, and `worktree remove` deletes
|
|
60
|
+
* only the link. Entries are repo-root-relative (already validated by
|
|
61
|
+
* `sanitizeLinkFiles`). Each entry is best-effort: a missing source or an
|
|
62
|
+
* already-present target is skipped, never fatal.
|
|
63
|
+
*/
|
|
64
|
+
function linkFilesIntoWorktree(repoRoot, worktreePath, linkFiles, pushStep) {
|
|
65
|
+
for (const rel of linkFiles) {
|
|
66
|
+
const source = path.join(repoRoot, rel);
|
|
67
|
+
const target = path.join(worktreePath, rel);
|
|
68
|
+
if (!pathExists(source)) {
|
|
69
|
+
pushStep({ kind: "skip", label: `skipped link ${rel}`, detail: "not present in repo root" });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// lstat (not pathExists) so an existing symlink/file/dir already at the
|
|
73
|
+
// target — e.g. a tracked file git checked out — counts as present and
|
|
74
|
+
// we don't clobber it.
|
|
75
|
+
let targetTaken = false;
|
|
76
|
+
try {
|
|
77
|
+
fs.lstatSync(target);
|
|
78
|
+
targetTaken = true;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
targetTaken = false;
|
|
82
|
+
}
|
|
83
|
+
if (targetTaken) {
|
|
84
|
+
pushStep({
|
|
85
|
+
kind: "skip",
|
|
86
|
+
label: `skipped link ${rel}`,
|
|
87
|
+
detail: "already present in worktree",
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
93
|
+
fs.symlinkSync(source, target);
|
|
94
|
+
pushStep({ kind: "ok", label: `linked ${rel}`, detail: `→ ${source}` });
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
pushStep({
|
|
98
|
+
kind: "warn",
|
|
99
|
+
label: `failed to link ${rel}`,
|
|
100
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
52
105
|
/**
|
|
53
106
|
* Stashes a `--prompt` value into a temp file so the shell wrapper can hand
|
|
54
107
|
* it back to `worktree work` via `--prompt-file`. Plain stdout markers can't
|
|
@@ -207,6 +260,13 @@ export async function runCreate(branchArg, opts) {
|
|
|
207
260
|
upsertIssue(root, parsed.issueId, base ? { base_branch: base } : {});
|
|
208
261
|
pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${parsed.issueId}` });
|
|
209
262
|
await nextFrame(progress);
|
|
263
|
+
// Link gitignored config (e.g. .env) before init.sh, so the hook can rely
|
|
264
|
+
// on those files being present.
|
|
265
|
+
const linkFiles = readMetadata(root).linkFiles;
|
|
266
|
+
if (linkFiles && linkFiles.length > 0) {
|
|
267
|
+
linkFilesIntoWorktree(root, worktreePath, linkFiles, pushStep);
|
|
268
|
+
await nextFrame(progress);
|
|
269
|
+
}
|
|
210
270
|
const initShPath = getInitScriptPath(root);
|
|
211
271
|
if (pathExists(initShPath)) {
|
|
212
272
|
progress?.onPending?.("Running .mintree/init.sh...");
|
|
@@ -362,6 +422,13 @@ export async function runCreateDetached(opts) {
|
|
|
362
422
|
upsertIssue(root, opts.issueId, { base_branch: currentBranch });
|
|
363
423
|
pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${opts.issueId}` });
|
|
364
424
|
await nextFrame(progress);
|
|
425
|
+
// Link gitignored config (e.g. .env) before init.sh, so the hook can rely
|
|
426
|
+
// on those files being present.
|
|
427
|
+
const linkFiles = readMetadata(root).linkFiles;
|
|
428
|
+
if (linkFiles && linkFiles.length > 0) {
|
|
429
|
+
linkFilesIntoWorktree(root, worktreePath, linkFiles, pushStep);
|
|
430
|
+
await nextFrame(progress);
|
|
431
|
+
}
|
|
365
432
|
const initShPath = getInitScriptPath(root);
|
|
366
433
|
if (pathExists(initShPath)) {
|
|
367
434
|
progress?.onPending?.("Running .mintree/init.sh...");
|
package/package.json
CHANGED