pi-odd-worktree 0.0.0-stage → 0.1.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 +223 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T8/S12 model-facing half: compact, agent-useful result text for the four
|
|
3
|
+
// own worktree tools this adapter formats.
|
|
4
|
+
//
|
|
5
|
+
// The extension's raw text prints one line per fact (for a listing:
|
|
6
|
+
// `worktree list: <name> <branch> thread=<id> issues=<csv> odd=<csv>` per
|
|
7
|
+
// checkout). That shape spends the agent's context on thread ids and on the
|
|
8
|
+
// ODD documents a checkout HAPPENS TO HOLD. This module builds the
|
|
9
|
+
// model-facing text from the SAME `details` object execute() already
|
|
10
|
+
// returns, so:
|
|
11
|
+
//
|
|
12
|
+
// * `details` (and every other result field) is preserved byte for byte —
|
|
13
|
+
// the UI card and runtime callers keep their contract;
|
|
14
|
+
// * only the text content is replaced, and non-text content (images and
|
|
15
|
+
// anything else) survives in place;
|
|
16
|
+
// * an error, partial, malformed or unrecognized result is returned
|
|
17
|
+
// UNCHANGED — a failure's prose is its diagnostics, and this module
|
|
18
|
+
// never turns "not enough structured evidence" into an invented
|
|
19
|
+
// success.
|
|
20
|
+
//
|
|
21
|
+
// Text style (chosen once, used everywhere): short `label: value` rows with
|
|
22
|
+
// a stable order, plus one `·`-separated row per checkout for listings, whose
|
|
23
|
+
// invariant cells are declared in the header line. No key=value dumps, no
|
|
24
|
+
// quoted path prose, no repeated title, no truncation: values are emitted
|
|
25
|
+
// whole. A listing row is `name · branch · path`, followed by ONE state cell
|
|
26
|
+
// only when the row is not the ordinary "registered and live" case — `missing`
|
|
27
|
+
// (the path exists but git no longer lists the checkout) or `unregistered` (a
|
|
28
|
+
// live checkout the registry does not own) — so a reader still tells those
|
|
29
|
+
// apart while nothing repeats what the row already says (S6).
|
|
30
|
+
//
|
|
31
|
+
// Free of side effects: no filesystem, network, registry, process or timing
|
|
32
|
+
// access, so it can run inside any execute() wrapper.
|
|
33
|
+
//
|
|
34
|
+
// NO ISSUES, NO ODD (S1): a worktree carries neither an issue list nor an ODD
|
|
35
|
+
// document any more, so no worktree display mentions them. The registry still
|
|
36
|
+
// stores those legacy records (preserved bytes), but `worktree_list` no longer
|
|
37
|
+
// publishes them in its text or its details, and this module would ignore
|
|
38
|
+
// `details.worktrees[].issues`, `.assignedOdd` and `.odd` if a producer ever
|
|
39
|
+
// supplied them again.
|
|
40
|
+
//
|
|
41
|
+
// The create tool is deliberately NOT in this list: it writes its own short
|
|
42
|
+
// ordered text (`created:`/`path:`/`codegraph:` plus one `warning:` row each),
|
|
43
|
+
// and the retired create-or-update builder that predated it is gone with the
|
|
44
|
+
// old contract.
|
|
45
|
+
|
|
46
|
+
const WORKTREE_MODEL_TOOLS = [
|
|
47
|
+
'worktree_list',
|
|
48
|
+
'worktree_close',
|
|
49
|
+
'worktree_session_open',
|
|
50
|
+
'worktree_warp',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// ANSI SGR/OSC-8 sequences and C0/C1 controls (multi-line structure is
|
|
54
|
+
// irrelevant here: every value is folded onto one line).
|
|
55
|
+
const ANSI_PATTERN = /\x1b\[[0-9;]*m|\x1b\]8;;.*?\x07/g;
|
|
56
|
+
// eslint-disable-next-line no-control-regex
|
|
57
|
+
const CONTROL_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
58
|
+
|
|
59
|
+
const LIST_LEGEND = 'name · branch · path';
|
|
60
|
+
|
|
61
|
+
// S6: the trailing state cell is optional and self-describing, so the legend
|
|
62
|
+
// declares only the three cells every row carries. Existence is implied by the
|
|
63
|
+
// listing itself — a checkout whose path is gone is pruned before it can be
|
|
64
|
+
// listed — so a normal state is never worth a cell, and `agent` (the ordinary
|
|
65
|
+
// registered worktree) is never worth one either. `principal` repeats the row's
|
|
66
|
+
// own name, `unregistered` repeats its state, and an existence spelling in
|
|
67
|
+
// `kind` repeats what the listing already proves, so all three collapse away
|
|
68
|
+
// while a kind the row does NOT already state still prints.
|
|
69
|
+
const ORDINARY_STATUS = new Set(['', 'live', 'exists']);
|
|
70
|
+
const ORDINARY_KIND = new Set(['', 'agent']);
|
|
71
|
+
|
|
72
|
+
// One-line, escape-free, whitespace-folded value. Branch names and paths are
|
|
73
|
+
// untrusted input: a raw escape or newline must never reshape a row.
|
|
74
|
+
function clean(value) {
|
|
75
|
+
if (typeof value !== 'string') return '';
|
|
76
|
+
return value.replace(ANSI_PATTERN, '').replace(CONTROL_PATTERN, '').replace(/\s+/g, ' ').trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function asRecord(value) {
|
|
80
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Mirrors lib/worktree-git.js slugifyBranch and the extension's slugPart, so
|
|
85
|
+
// the model text names the same identifier `warp`/`close` accept.
|
|
86
|
+
function slugify(value) {
|
|
87
|
+
const slug = clean(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
88
|
+
return slug === '' ? 'worktree' : slug;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function baseName(value) {
|
|
92
|
+
const text = clean(value).replace(/\/+$/, '');
|
|
93
|
+
if (text.length === 0) return '';
|
|
94
|
+
const parts = text.split('/');
|
|
95
|
+
return parts[parts.length - 1];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Warnings are already the summarized, agent-useful form the extension
|
|
99
|
+
// produced (failed hook steps, pruned stale records, dropped legacy
|
|
100
|
+
// associations). They are kept verbatim, deduplicated, and never replaced by
|
|
101
|
+
// raw hook/command output. `pruneError` is a bare reason string, so it is
|
|
102
|
+
// added only when no warning already carries it; a FAILED creation-hook step
|
|
103
|
+
// is added the same way from its own detail line, so a hook failure stays
|
|
104
|
+
// visible even if the warning list ever stops summarizing it. Skipped steps
|
|
105
|
+
// and stdout are never copied: they cost context and the warnings already say
|
|
106
|
+
// what matters.
|
|
107
|
+
function warningRows(details) {
|
|
108
|
+
const rows = [];
|
|
109
|
+
const seen = [];
|
|
110
|
+
// A warning the extension already summarized is kept once, verbatim.
|
|
111
|
+
const pushNew = (text) => {
|
|
112
|
+
if (text.length === 0 || seen.includes(text)) return;
|
|
113
|
+
seen.push(text);
|
|
114
|
+
rows.push(`warning: ${text}`);
|
|
115
|
+
};
|
|
116
|
+
// A bare reason field is dropped when a warning already carries it.
|
|
117
|
+
const pushDerived = (text) => {
|
|
118
|
+
if (text.length === 0 || seen.some((known) => known.includes(text))) return;
|
|
119
|
+
pushNew(text);
|
|
120
|
+
};
|
|
121
|
+
const warnings = Array.isArray(details.warnings) ? details.warnings : [];
|
|
122
|
+
for (const warning of warnings) pushNew(clean(warning));
|
|
123
|
+
pushDerived(clean(details.pruneError));
|
|
124
|
+
const hooks = asRecord(details.hooks);
|
|
125
|
+
const steps = Array.isArray(hooks.steps) ? hooks.steps : [];
|
|
126
|
+
for (const step of steps) {
|
|
127
|
+
const record = asRecord(step);
|
|
128
|
+
if (record.status !== 'failed') continue;
|
|
129
|
+
pushDerived(clean(record.detail));
|
|
130
|
+
}
|
|
131
|
+
return rows;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function listRows(details) {
|
|
135
|
+
if (!Array.isArray(details.worktrees)) return null;
|
|
136
|
+
const entries = details.worktrees;
|
|
137
|
+
const count = typeof details.count === 'number' && Number.isFinite(details.count)
|
|
138
|
+
? details.count
|
|
139
|
+
: entries.length;
|
|
140
|
+
const rows = [entries.length > 0 ? `worktrees: ${count} (${LIST_LEGEND})` : `worktrees: ${count}`];
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
const row = asRecord(entry);
|
|
143
|
+
const name = clean(row.name) || baseName(row.path);
|
|
144
|
+
const branch = clean(row.branch);
|
|
145
|
+
const target = clean(row.path);
|
|
146
|
+
const status = clean(row.status);
|
|
147
|
+
// S6: what changes how the row must be read stays; the ordinary case does
|
|
148
|
+
// not spend a cell saying so.
|
|
149
|
+
const state = ORDINARY_STATUS.has(status) ? '' : status;
|
|
150
|
+
const kind = clean(row.kind);
|
|
151
|
+
const kindCell = ORDINARY_KIND.has(kind) || ORDINARY_STATUS.has(kind) || kind === name || kind === state
|
|
152
|
+
? ''
|
|
153
|
+
: kind;
|
|
154
|
+
const cells = [name, branch, target, kindCell, state].filter((cell) => cell.length > 0);
|
|
155
|
+
if (cells.length === 0) continue;
|
|
156
|
+
// S1: the row IS the checkout's identity. Issues and ODD documents are not
|
|
157
|
+
// part of a worktree any more, so they never reach a listing row even when
|
|
158
|
+
// the details still carry the preserved legacy fields.
|
|
159
|
+
rows.push(cells.join(' · '));
|
|
160
|
+
}
|
|
161
|
+
rows.push(...warningRows(details));
|
|
162
|
+
return rows;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function closeRows(details) {
|
|
166
|
+
const branch = clean(details.branch);
|
|
167
|
+
const target = clean(details.path);
|
|
168
|
+
if (branch.length === 0 && target.length === 0) return null;
|
|
169
|
+
const name = branch.length > 0 ? slugify(branch) : baseName(target);
|
|
170
|
+
const rows = ['outcome: removed'];
|
|
171
|
+
if (name.length > 0) rows.push(`name: ${name}`);
|
|
172
|
+
if (branch.length > 0) rows.push(`branch: ${branch}`);
|
|
173
|
+
if (target.length > 0) rows.push(`path: ${target}`);
|
|
174
|
+
rows.push(`branch state: ${details.branchKept === false ? 'deleted' : 'kept'}`);
|
|
175
|
+
// A pre-removal warp is part of the outcome: the caller's session is no
|
|
176
|
+
// longer where it was, so the destination must stay visible.
|
|
177
|
+
const warp = asRecord(details.warp);
|
|
178
|
+
const warpTarget = [clean(warp.name), clean(warp.path)].filter((part) => part.length > 0).join(' · ');
|
|
179
|
+
if (warpTarget.length > 0) rows.push(`session: warped to ${warpTarget}`);
|
|
180
|
+
// A skipped or partially reconciled Herdr workspace never fails the close,
|
|
181
|
+
// so its status (and the notice explaining it) has to reach the agent.
|
|
182
|
+
const workspace = asRecord(details.herdrWorkspace);
|
|
183
|
+
const workspaceStatus = clean(workspace.status);
|
|
184
|
+
if (workspaceStatus.length > 0) {
|
|
185
|
+
const notice = clean(workspace.notice);
|
|
186
|
+
rows.push(notice.length > 0 ? `herdr workspace: ${workspaceStatus} · ${notice}` : `herdr workspace: ${workspaceStatus}`);
|
|
187
|
+
}
|
|
188
|
+
rows.push(...warningRows(details));
|
|
189
|
+
return rows;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sessionOpenRows(details) {
|
|
193
|
+
const worktree = clean(details.worktree);
|
|
194
|
+
const session = clean(details.sessionId);
|
|
195
|
+
const target = clean(details.path);
|
|
196
|
+
if (worktree.length === 0 && session.length === 0 && target.length === 0) return null;
|
|
197
|
+
const rows = [`outcome: ${details.resumed === true ? 'resumed' : 'opened'}`];
|
|
198
|
+
if (worktree.length > 0) rows.push(`worktree: ${worktree}`);
|
|
199
|
+
const branch = clean(details.branch);
|
|
200
|
+
if (branch.length > 0) rows.push(`branch: ${branch}`);
|
|
201
|
+
if (target.length > 0) rows.push(`path: ${target}`);
|
|
202
|
+
// The session id, workspace and pane are the routing ids a caller needs to
|
|
203
|
+
// reach the session it just started; the agent name names the agent.
|
|
204
|
+
if (session.length > 0) rows.push(`session: ${session}`);
|
|
205
|
+
const workspace = clean(details.workspaceId);
|
|
206
|
+
if (workspace.length > 0) rows.push(`workspace: ${workspace}`);
|
|
207
|
+
const pane = clean(details.paneId);
|
|
208
|
+
if (pane.length > 0) rows.push(`pane: ${pane}`);
|
|
209
|
+
const agent = clean(details.agentName);
|
|
210
|
+
if (agent.length > 0) rows.push(`agent: ${agent}`);
|
|
211
|
+
rows.push(...warningRows(details));
|
|
212
|
+
return rows;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function warpRows(details) {
|
|
216
|
+
const name = clean(details.name);
|
|
217
|
+
const target = clean(details.path);
|
|
218
|
+
if (name.length === 0 && target.length === 0) return null;
|
|
219
|
+
const rows = [`outcome: ${details.alreadyThere === true ? 'already there' : 'moved'}`];
|
|
220
|
+
const source = [clean(details.fromName), clean(details.from)].filter((part) => part.length > 0).join(' · ');
|
|
221
|
+
if (source.length > 0) rows.push(`from: ${source}`);
|
|
222
|
+
// `to` carries the continuation target: the name the next call accepts and
|
|
223
|
+
// the absolute path the session now works in.
|
|
224
|
+
const destination = [name, target].filter((part) => part.length > 0).join(' · ');
|
|
225
|
+
if (destination.length > 0) rows.push(`to: ${destination}`);
|
|
226
|
+
const branch = clean(details.branch);
|
|
227
|
+
if (branch.length > 0) rows.push(`branch: ${branch}`);
|
|
228
|
+
rows.push(...warningRows(details));
|
|
229
|
+
return rows;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ROW_BUILDERS = {
|
|
233
|
+
worktree_list: listRows,
|
|
234
|
+
worktree_close: closeRows,
|
|
235
|
+
worktree_session_open: sessionOpenRows,
|
|
236
|
+
worktree_warp: warpRows,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Replace every text entry with ONE formatted entry at the position of the
|
|
240
|
+
// first one, keeping non-text entries (images, …) untouched and in order. The
|
|
241
|
+
// caller guarantees at least one text entry (hasTextContent below).
|
|
242
|
+
function rebuildContent(content, text) {
|
|
243
|
+
const entries = [];
|
|
244
|
+
let placed = false;
|
|
245
|
+
for (const entry of content) {
|
|
246
|
+
if (entry && entry.type === 'text' && typeof entry.text === 'string') {
|
|
247
|
+
if (!placed) {
|
|
248
|
+
entries.push({ ...entry, text });
|
|
249
|
+
placed = true;
|
|
250
|
+
}
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
entries.push(entry);
|
|
254
|
+
}
|
|
255
|
+
return entries;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function hasTextContent(content) {
|
|
259
|
+
return content.some((entry) => entry && entry.type === 'text' && typeof entry.text === 'string');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Format one own worktree tool result for the model.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} toolName One of WORKTREE_MODEL_TOOLS.
|
|
266
|
+
* @param {object} result The tool result ({content, details, isError?}).
|
|
267
|
+
* @returns {object} The same result with the text content replaced when the
|
|
268
|
+
* tool is known, the result is a successful structured one, and the details
|
|
269
|
+
* carry enough evidence; otherwise the INPUT UNCHANGED (same reference).
|
|
270
|
+
*/
|
|
271
|
+
function formatWorktreeModelResult(toolName, result) {
|
|
272
|
+
if (typeof toolName !== 'string' || !Object.prototype.hasOwnProperty.call(ROW_BUILDERS, toolName)) {
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
if (result === null || typeof result !== 'object' || Array.isArray(result)) return result;
|
|
276
|
+
// A failure's prose IS its diagnostics, and a partial result is not
|
|
277
|
+
// settled: both are returned untouched rather than reformatted.
|
|
278
|
+
if (result.isError === true || result.isPartial === true) return result;
|
|
279
|
+
const content = result.content;
|
|
280
|
+
if (!Array.isArray(content) || !hasTextContent(content)) return result;
|
|
281
|
+
const details = result.details;
|
|
282
|
+
if (details === null || typeof details !== 'object' || Array.isArray(details)) return result;
|
|
283
|
+
const rows = ROW_BUILDERS[toolName](details);
|
|
284
|
+
if (rows === null || rows.length === 0) return result;
|
|
285
|
+
return { ...result, content: rebuildContent(content, rows.join('\n')) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
formatWorktreeModelResult,
|
|
290
|
+
WORKTREE_MODEL_TOOLS,
|
|
291
|
+
};
|