santree 0.6.2 → 0.7.0
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 +7 -7
- package/dist/commands/dashboard.js +465 -54
- package/dist/commands/issue/setup.d.ts +2 -0
- package/dist/commands/issue/setup.js +108 -0
- package/dist/commands/issue/switch.d.ts +1 -0
- package/dist/commands/issue/switch.js +2 -2
- package/dist/commands/worktree/work.js +1 -1
- package/dist/lib/ai.js +4 -0
- package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
- package/dist/lib/dashboard/DetailPanel.js +24 -3
- package/dist/lib/dashboard/IssueList.d.ts +2 -0
- package/dist/lib/dashboard/IssueList.js +9 -1
- package/dist/lib/dashboard/Overlays.d.ts +2 -1
- package/dist/lib/dashboard/Overlays.js +17 -3
- package/dist/lib/dashboard/data.d.ts +2 -0
- package/dist/lib/dashboard/data.js +56 -54
- package/dist/lib/dashboard/types.d.ts +80 -2
- package/dist/lib/dashboard/types.js +97 -1
- package/dist/lib/multiplexer/cmux.js +0 -15
- package/dist/lib/multiplexer/none.js +0 -3
- package/dist/lib/multiplexer/tmux.js +0 -8
- package/dist/lib/multiplexer/types.d.ts +0 -1
- package/dist/lib/session-signal.d.ts +5 -3
- package/dist/lib/session-signal.js +5 -22
- package/dist/lib/trackers/config.js +1 -1
- package/dist/lib/trackers/index.d.ts +11 -0
- package/dist/lib/trackers/index.js +26 -0
- package/dist/lib/trackers/local/frontmatter.d.ts +12 -0
- package/dist/lib/trackers/local/frontmatter.js +91 -0
- package/dist/lib/trackers/local/index.d.ts +2 -0
- package/dist/lib/trackers/local/index.js +102 -0
- package/dist/lib/trackers/local/store.d.ts +30 -0
- package/dist/lib/trackers/local/store.js +203 -0
- package/dist/lib/trackers/types.d.ts +26 -1
- package/package.json +1 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getSantreeDir, readAllMetadata, writeAllMetadata } from "../../metadata.js";
|
|
4
|
+
import { parseFrontmatter, serializeFrontmatter } from "./frontmatter.js";
|
|
5
|
+
// On-disk layout: one Markdown file per issue under `.santree/issues/`,
|
|
6
|
+
// named `<ID>.md` (e.g. `LOCAL-1.md`). `.santree/issues/` is NOT in
|
|
7
|
+
// .gitignore (which only excludes worktrees/metadata.json/session-states),
|
|
8
|
+
// so issue files are version-controlled by default — the whole point of the
|
|
9
|
+
// built-in tracker.
|
|
10
|
+
//
|
|
11
|
+
// Comments are intentionally not modeled in v1: Local issues always carry
|
|
12
|
+
// `comments: []`. The Issue type already permits an empty array.
|
|
13
|
+
export const ID_PREFIX = "LOCAL";
|
|
14
|
+
const FILE_RE = /^LOCAL-(\d+)\.md$/;
|
|
15
|
+
export function getIssuesDir(repoRoot) {
|
|
16
|
+
return path.join(getSantreeDir(repoRoot), "issues");
|
|
17
|
+
}
|
|
18
|
+
export function ensureIssuesDir(repoRoot) {
|
|
19
|
+
const dir = getIssuesDir(repoRoot);
|
|
20
|
+
if (!fs.existsSync(dir)) {
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
function issueFilePath(repoRoot, identifier) {
|
|
26
|
+
return path.join(getIssuesDir(repoRoot), `${identifier}.md`);
|
|
27
|
+
}
|
|
28
|
+
/** Map a Linear-style numeric priority to a human label. 0 == no priority. */
|
|
29
|
+
export function priorityLabel(priority) {
|
|
30
|
+
switch (priority) {
|
|
31
|
+
case 1:
|
|
32
|
+
return "Urgent";
|
|
33
|
+
case 2:
|
|
34
|
+
return "High";
|
|
35
|
+
case 3:
|
|
36
|
+
return "Medium";
|
|
37
|
+
case 4:
|
|
38
|
+
return "Low";
|
|
39
|
+
default:
|
|
40
|
+
return "None";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function recordToIssue(data, body) {
|
|
44
|
+
const identifier = typeof data["id"] === "string" ? data["id"] : null;
|
|
45
|
+
if (!identifier || !FILE_RE.test(`${identifier}.md`))
|
|
46
|
+
return null;
|
|
47
|
+
const title = typeof data["title"] === "string" ? data["title"] : String(data["title"] ?? "");
|
|
48
|
+
const priority = typeof data["priority"] === "number" ? data["priority"] : 0;
|
|
49
|
+
const labels = Array.isArray(data["labels"]) ? data["labels"] : [];
|
|
50
|
+
const stateName = typeof data["state"] === "string" ? data["state"] : "Todo";
|
|
51
|
+
const stateType = typeof data["stateType"] === "string" ? data["stateType"] : "unstarted";
|
|
52
|
+
const description = body.trim() === "" ? null : body;
|
|
53
|
+
return {
|
|
54
|
+
identifier,
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
url: "", // Local issues have no web URL — the dashboard hides the [o] action.
|
|
58
|
+
priority,
|
|
59
|
+
priorityLabel: typeof data["priorityLabel"] === "string" ? data["priorityLabel"] : priorityLabel(priority),
|
|
60
|
+
state: { name: stateName, type: stateType },
|
|
61
|
+
labels,
|
|
62
|
+
projectId: null,
|
|
63
|
+
projectName: null,
|
|
64
|
+
comments: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function issueToRecord(issue, createdAt) {
|
|
68
|
+
return {
|
|
69
|
+
data: {
|
|
70
|
+
id: issue.identifier,
|
|
71
|
+
title: issue.title,
|
|
72
|
+
state: issue.state.name,
|
|
73
|
+
stateType: issue.state.type,
|
|
74
|
+
priority: issue.priority,
|
|
75
|
+
priorityLabel: issue.priorityLabel,
|
|
76
|
+
labels: issue.labels,
|
|
77
|
+
createdAt,
|
|
78
|
+
},
|
|
79
|
+
body: issue.description ?? "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Read every well-formed issue file. Malformed files are skipped, never
|
|
83
|
+
* fatal. Returned newest-first by creation time (falling back to numeric ID). */
|
|
84
|
+
export function listIssues(repoRoot) {
|
|
85
|
+
const dir = getIssuesDir(repoRoot);
|
|
86
|
+
if (!fs.existsSync(dir))
|
|
87
|
+
return [];
|
|
88
|
+
let names;
|
|
89
|
+
try {
|
|
90
|
+
names = fs.readdirSync(dir);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
const m = name.match(FILE_RE);
|
|
98
|
+
if (!m)
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
const raw = fs.readFileSync(path.join(dir, name), "utf-8");
|
|
102
|
+
const { data, body } = parseFrontmatter(raw);
|
|
103
|
+
const issue = recordToIssue(data, body);
|
|
104
|
+
if (!issue)
|
|
105
|
+
continue;
|
|
106
|
+
const createdAt = typeof data["createdAt"] === "string" ? data["createdAt"] : "";
|
|
107
|
+
out.push({ issue, createdAt, num: Number(m[1]) });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Skip unreadable / corrupt file.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
out.sort((a, b) => {
|
|
114
|
+
if (a.createdAt && b.createdAt && a.createdAt !== b.createdAt) {
|
|
115
|
+
return b.createdAt.localeCompare(a.createdAt);
|
|
116
|
+
}
|
|
117
|
+
return b.num - a.num;
|
|
118
|
+
});
|
|
119
|
+
return out.map((o) => o.issue);
|
|
120
|
+
}
|
|
121
|
+
export function readIssue(repoRoot, identifier) {
|
|
122
|
+
const file = issueFilePath(repoRoot, identifier);
|
|
123
|
+
if (!fs.existsSync(file))
|
|
124
|
+
return null;
|
|
125
|
+
try {
|
|
126
|
+
const { data, body } = parseFrontmatter(fs.readFileSync(file, "utf-8"));
|
|
127
|
+
return recordToIssue(data, body);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Write (create or overwrite) an issue file. `createdAt` preserves the
|
|
134
|
+
* original timestamp on edits; pass a fresh ISO string when creating. */
|
|
135
|
+
export function writeIssue(repoRoot, issue, createdAt) {
|
|
136
|
+
ensureIssuesDir(repoRoot);
|
|
137
|
+
const { data, body } = issueToRecord(issue, createdAt);
|
|
138
|
+
fs.writeFileSync(issueFilePath(repoRoot, issue.identifier), serializeFrontmatter(data, body));
|
|
139
|
+
}
|
|
140
|
+
/** Return the original `createdAt` for an existing issue, or "" if unknown. */
|
|
141
|
+
export function readCreatedAt(repoRoot, identifier) {
|
|
142
|
+
const file = issueFilePath(repoRoot, identifier);
|
|
143
|
+
if (!fs.existsSync(file))
|
|
144
|
+
return "";
|
|
145
|
+
try {
|
|
146
|
+
const { data } = parseFrontmatter(fs.readFileSync(file, "utf-8"));
|
|
147
|
+
return typeof data["createdAt"] === "string" ? data["createdAt"] : "";
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function deleteIssueFile(repoRoot, identifier) {
|
|
154
|
+
const file = issueFilePath(repoRoot, identifier);
|
|
155
|
+
if (!fs.existsSync(file))
|
|
156
|
+
return false;
|
|
157
|
+
try {
|
|
158
|
+
fs.unlinkSync(file);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function maxExistingNum(repoRoot) {
|
|
166
|
+
const dir = getIssuesDir(repoRoot);
|
|
167
|
+
let max = 0;
|
|
168
|
+
if (fs.existsSync(dir)) {
|
|
169
|
+
try {
|
|
170
|
+
for (const name of fs.readdirSync(dir)) {
|
|
171
|
+
const m = name.match(FILE_RE);
|
|
172
|
+
if (m)
|
|
173
|
+
max = Math.max(max, Number(m[1]));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// fall through with max = 0
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return max;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Allocate the next issue ID and persist the high-water mark so numbers are
|
|
184
|
+
* monotonic and never recycled: deleting LOCAL-3 then creating again yields
|
|
185
|
+
* LOCAL-4, not LOCAL-3 — a stale local `feature/LOCAL-3-*` branch/worktree
|
|
186
|
+
* can't collide with a reused ID.
|
|
187
|
+
*
|
|
188
|
+
* The counter lives in `.santree/metadata.json` (`_local.lastId`), which is
|
|
189
|
+
* git-ignored and therefore per-machine. That's exactly the right scope: the
|
|
190
|
+
* collision we're avoiding is with *local* worktrees/branches. On a fresh
|
|
191
|
+
* clone metadata.json is absent, so the counter rebuilds from the committed
|
|
192
|
+
* issue files (max existing) — correct, since a fresh clone has no stale
|
|
193
|
+
* local worktrees.
|
|
194
|
+
*/
|
|
195
|
+
export function allocateId(repoRoot) {
|
|
196
|
+
const all = readAllMetadata(repoRoot);
|
|
197
|
+
const local = all["_local"] ?? {};
|
|
198
|
+
const last = typeof local.lastId === "number" ? local.lastId : 0;
|
|
199
|
+
const next = Math.max(last, maxExistingNum(repoRoot)) + 1;
|
|
200
|
+
all["_local"] = { ...local, lastId: next };
|
|
201
|
+
writeAllMetadata(repoRoot, all);
|
|
202
|
+
return `${ID_PREFIX}-${next}`;
|
|
203
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type IssueTrackerKind = "linear" | "github";
|
|
1
|
+
export type IssueTrackerKind = "linear" | "github" | "local";
|
|
2
2
|
export interface Comment {
|
|
3
3
|
author: string;
|
|
4
4
|
body: string;
|
|
@@ -39,6 +39,22 @@ export type IssueTrackerResult<T> = {
|
|
|
39
39
|
reason: "unauthenticated" | "not-found" | "network";
|
|
40
40
|
message?: string;
|
|
41
41
|
};
|
|
42
|
+
/** Fields accepted when creating a new issue. Only the built-in Local tracker
|
|
43
|
+
* supports mutation today (see `IssueTracker.canMutate`). */
|
|
44
|
+
export interface NewIssueInput {
|
|
45
|
+
title: string;
|
|
46
|
+
description: string;
|
|
47
|
+
priority?: number;
|
|
48
|
+
labels?: string[];
|
|
49
|
+
}
|
|
50
|
+
/** Partial patch for an existing issue. Omitted fields are left unchanged. */
|
|
51
|
+
export interface IssuePatch {
|
|
52
|
+
title?: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
priority?: number;
|
|
55
|
+
labels?: string[];
|
|
56
|
+
state?: State;
|
|
57
|
+
}
|
|
42
58
|
export interface IssueTracker {
|
|
43
59
|
readonly kind: IssueTrackerKind;
|
|
44
60
|
readonly displayName: string;
|
|
@@ -49,4 +65,13 @@ export interface IssueTracker {
|
|
|
49
65
|
cleanupCache(identifier: string): void;
|
|
50
66
|
listAssigned(repoRoot: string): Promise<IssueTrackerResult<AssignedIssue[]>>;
|
|
51
67
|
getIssue(identifier: string, repoRoot: string): Promise<IssueTrackerResult<Issue>>;
|
|
68
|
+
/** When true, the tracker implements createIssue/updateIssue/deleteIssue.
|
|
69
|
+
* Read-only trackers (Linear, GitHub) leave this undefined; UI surfaces
|
|
70
|
+
* gate every mutation path on `tracker.canMutate === true` (feature
|
|
71
|
+
* detection — never a `kind === "local"` string check, per the
|
|
72
|
+
* no-tracker-conditionals-outside-the-factory policy). */
|
|
73
|
+
readonly canMutate?: boolean;
|
|
74
|
+
createIssue?(input: NewIssueInput, repoRoot: string): Promise<IssueTrackerResult<Issue>>;
|
|
75
|
+
updateIssue?(identifier: string, patch: IssuePatch, repoRoot: string): Promise<IssueTrackerResult<Issue>>;
|
|
76
|
+
deleteIssue?(identifier: string, repoRoot: string): Promise<IssueTrackerResult<void>>;
|
|
52
77
|
}
|