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.
Files changed (60) hide show
  1. package/README.md +223 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. 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
+ };