pi-odd-worktree 0.0.0-stage → 1.0.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 +234 -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,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// External opener (key-consistency K3/S5): `o` in the odd viewer opens the
|
|
3
|
+
// focused document through `$VISUAL`/`$EDITOR`.
|
|
4
|
+
//
|
|
5
|
+
// Fixed argv, no shell, injectable spawn, honest result: the promise never
|
|
6
|
+
// rejects — a missing editor or a failed spawn resolves
|
|
7
|
+
// `{ ok: false, error }` naming what is wrong, so the caller shows it
|
|
8
|
+
// instead of failing silently. Success resolves `{ ok: true, editor }`.
|
|
9
|
+
|
|
10
|
+
const OPEN_TIMEOUT_MS = 15000;
|
|
11
|
+
|
|
12
|
+
function resolveEditor(opts = {}) {
|
|
13
|
+
if (typeof opts.editor === 'string' && opts.editor.trim() !== '') return opts.editor.trim();
|
|
14
|
+
let env = null;
|
|
15
|
+
try {
|
|
16
|
+
env = opts.env || process.env;
|
|
17
|
+
} catch {
|
|
18
|
+
env = null;
|
|
19
|
+
}
|
|
20
|
+
for (const name of ['VISUAL', 'EDITOR']) {
|
|
21
|
+
let value = null;
|
|
22
|
+
try {
|
|
23
|
+
value = env ? env[name] : null;
|
|
24
|
+
} catch {
|
|
25
|
+
value = null;
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function splitCommand(command) {
|
|
33
|
+
return String(command).trim().split(/\s+/).filter((part) => part.length > 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function shortError(err) {
|
|
37
|
+
if (err === null || err === undefined) return 'unknown error';
|
|
38
|
+
if (typeof err === 'string') return err.split('\n')[0];
|
|
39
|
+
return String((err && err.message) || err).split('\n')[0];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function openExternal(target, opts = {}) {
|
|
43
|
+
if (typeof target !== 'string' || target.trim() === '') {
|
|
44
|
+
return { ok: false, error: 'nothing to open: no document is focused' };
|
|
45
|
+
}
|
|
46
|
+
const editor = resolveEditor(opts);
|
|
47
|
+
if (!editor) {
|
|
48
|
+
return { ok: false, error: 'no editor configured: set $VISUAL or $EDITOR to open documents outside Pi' };
|
|
49
|
+
}
|
|
50
|
+
const parts = splitCommand(editor);
|
|
51
|
+
const bin = parts[0];
|
|
52
|
+
const prefix = parts.slice(1);
|
|
53
|
+
let spawnImpl = opts.spawnImpl;
|
|
54
|
+
if (typeof spawnImpl !== 'function') {
|
|
55
|
+
try {
|
|
56
|
+
spawnImpl = require('node:child_process').spawn;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { ok: false, error: `cannot open ${target}: child_process is unavailable` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let child = null;
|
|
62
|
+
try {
|
|
63
|
+
// Fixed argv: the editor binary plus its own flags plus the target as
|
|
64
|
+
// one element. Never a shell string, so no quoting or injection.
|
|
65
|
+
child = spawnImpl(bin, [...prefix, target], { stdio: 'inherit', windowsHide: true });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { ok: false, error: `cannot open ${target} with ${bin}: ${shortError(err)}` };
|
|
68
|
+
}
|
|
69
|
+
if (!child || typeof child.on !== 'function' || typeof child.once !== 'function') {
|
|
70
|
+
return { ok: false, error: `cannot open ${target} with ${bin}: the editor did not start` };
|
|
71
|
+
}
|
|
72
|
+
// A detached editor must not hold the session open waiting on it.
|
|
73
|
+
try {
|
|
74
|
+
if (typeof child.unref === 'function') child.unref();
|
|
75
|
+
} catch {
|
|
76
|
+
// Best effort only; the result below still reports honestly.
|
|
77
|
+
}
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let settled = false;
|
|
80
|
+
const finish = (value) => {
|
|
81
|
+
if (settled) return;
|
|
82
|
+
settled = true;
|
|
83
|
+
try {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
} catch {
|
|
86
|
+
// A dead timer never blocks the result.
|
|
87
|
+
}
|
|
88
|
+
resolve(value);
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
finish({ ok: false, error: `cannot open ${target} with ${bin}: the editor did not start` });
|
|
92
|
+
}, OPEN_TIMEOUT_MS);
|
|
93
|
+
try {
|
|
94
|
+
child.once('error', (err) => {
|
|
95
|
+
finish({ ok: false, error: `cannot open ${target} with ${bin}: ${shortError(err)}` });
|
|
96
|
+
});
|
|
97
|
+
child.once('spawn', () => {
|
|
98
|
+
finish({ ok: true, editor: bin });
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
finish({ ok: false, error: `cannot open ${target} with ${bin}: ${shortError(err)}` });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { openExternal, resolveEditor };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// S1: ONE decision for the preview scroll bindings the full-screen readers
|
|
3
|
+
// share, and ONE honest guard about the byte the terminal actually sends.
|
|
4
|
+
//
|
|
5
|
+
// The default pair is ctrl+j/k — the user asked for it to be printed whatever
|
|
6
|
+
// the terminal reports — and the legend names it in both readers. ctrl+shift+j/k
|
|
7
|
+
// and the printable Shift+J/K stay working alternatives for a host that only
|
|
8
|
+
// negotiates the extended keyboard protocol or only delivers printable keys;
|
|
9
|
+
// their hints leave the row so the footer does not list every alias.
|
|
10
|
+
//
|
|
11
|
+
// The one thing that must never move is Enter: a legacy terminal sends the same
|
|
12
|
+
// LF byte for ctrl+j and Enter, so a bare newline is Enter and is refused before
|
|
13
|
+
// any host matcher or decoder can claim it as a scroll key.
|
|
14
|
+
//
|
|
15
|
+
// Everything here is pure: the host's TUI helpers are injected, no module
|
|
16
|
+
// state exists, and a hostile or partial host degrades to the bounded raw
|
|
17
|
+
// table below rather than throwing inside a key handler.
|
|
18
|
+
|
|
19
|
+
const ACTIONS = Object.freeze({
|
|
20
|
+
PAGE_DOWN: 'page-down',
|
|
21
|
+
PAGE_UP: 'page-up',
|
|
22
|
+
HALF_DOWN: 'half-down',
|
|
23
|
+
HALF_UP: 'half-up',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// The default pair, then the shifted alternate. Hosts spell the modifier order
|
|
27
|
+
// both ways (`ctrl+shift+j` and `shift+ctrl+j`), so the shifted entry carries
|
|
28
|
+
// both names; no rule ever matches a bare `j`/`k`, which move the list
|
|
29
|
+
// selection.
|
|
30
|
+
const RULES = [
|
|
31
|
+
{ action: ACTIONS.PAGE_DOWN, names: ['ctrl+j'] },
|
|
32
|
+
{ action: ACTIONS.PAGE_UP, names: ['ctrl+k'] },
|
|
33
|
+
// An extended-protocol terminal distinguishes ctrl+j/k from Enter and also
|
|
34
|
+
// reports the shifted pair; either one pages.
|
|
35
|
+
{ action: ACTIONS.PAGE_DOWN, names: ['ctrl+shift+j', 'shift+ctrl+j'] },
|
|
36
|
+
{ action: ACTIONS.PAGE_UP, names: ['ctrl+shift+k', 'shift+ctrl+k'] },
|
|
37
|
+
{ action: ACTIONS.PAGE_DOWN, names: ['pagedown', 'pageDown'] },
|
|
38
|
+
{ action: ACTIONS.PAGE_UP, names: ['pageup', 'pageUp'] },
|
|
39
|
+
// The printable Shift+J/K fallback: the same full-page action, so a legacy
|
|
40
|
+
// terminal keeps a working way to page the preview.
|
|
41
|
+
{ action: ACTIONS.PAGE_DOWN, names: ['shift+j', 'J'] },
|
|
42
|
+
{ action: ACTIONS.PAGE_UP, names: ['shift+k', 'K'] },
|
|
43
|
+
// The distinct Vim pair stays a HALF page and never stands in for the
|
|
44
|
+
// primary binding.
|
|
45
|
+
{ action: ACTIONS.HALF_DOWN, names: ['ctrl+d'] },
|
|
46
|
+
{ action: ACTIONS.HALF_UP, names: ['ctrl+u'] },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// Bare LF/CR mean Enter on a legacy terminal — and that terminal reports the
|
|
50
|
+
// same byte as ctrl+j — so neither may page. VT is the legacy ctrl+k byte,
|
|
51
|
+
// which those terminals cannot tell apart from a control character either.
|
|
52
|
+
// This guard runs BEFORE the host matcher, so no matcher can claim a bare
|
|
53
|
+
// newline and Enter keeps its confirm/comment meaning.
|
|
54
|
+
const FORBIDDEN_RAW = new Set(['\n', '\r', '\x0b']);
|
|
55
|
+
|
|
56
|
+
// Bounded fallback for exactly these keys, for hosts that inject no helpers or
|
|
57
|
+
// an incomplete one. Modifiers other than the ones listed here never resolve.
|
|
58
|
+
const RAW_ACTIONS = new Map([
|
|
59
|
+
['\x1b[6~', ACTIONS.PAGE_DOWN],
|
|
60
|
+
['\x1b[5~', ACTIONS.PAGE_UP],
|
|
61
|
+
['\x04', ACTIONS.HALF_DOWN],
|
|
62
|
+
['\x15', ACTIONS.HALF_UP],
|
|
63
|
+
['J', ACTIONS.PAGE_DOWN],
|
|
64
|
+
['K', ACTIONS.PAGE_UP],
|
|
65
|
+
['\x1b[27;6;106~', ACTIONS.PAGE_DOWN],
|
|
66
|
+
['\x1b[27;6;107~', ACTIONS.PAGE_UP],
|
|
67
|
+
]);
|
|
68
|
+
// Extended sequences carrying a press or repeat suffix: CSI-u (modifier 5 is
|
|
69
|
+
// ctrl, 6 is shift+ctrl), the extended page keys, and ctrl+d/ctrl+u.
|
|
70
|
+
const RAW_PATTERNS = [
|
|
71
|
+
{ pattern: /^\x1b\[106;5(?::[12])?u$/, action: ACTIONS.PAGE_DOWN },
|
|
72
|
+
{ pattern: /^\x1b\[107;5(?::[12])?u$/, action: ACTIONS.PAGE_UP },
|
|
73
|
+
{ pattern: /^\x1b\[106;6(?::[12])?u$/, action: ACTIONS.PAGE_DOWN },
|
|
74
|
+
{ pattern: /^\x1b\[107;6(?::[12])?u$/, action: ACTIONS.PAGE_UP },
|
|
75
|
+
{ pattern: /^\x1b\[27;5;106~$/, action: ACTIONS.PAGE_DOWN },
|
|
76
|
+
{ pattern: /^\x1b\[27;5;107~$/, action: ACTIONS.PAGE_UP },
|
|
77
|
+
{ pattern: /^\x1b\[57422(?:;1)?u$/, action: ACTIONS.PAGE_DOWN },
|
|
78
|
+
{ pattern: /^\x1b\[57421(?:;1)?u$/, action: ACTIONS.PAGE_UP },
|
|
79
|
+
{ pattern: /^\x1b\[100;5(?::[12])?u$/, action: ACTIONS.HALF_DOWN },
|
|
80
|
+
{ pattern: /^\x1b\[117;5(?::[12])?u$/, action: ACTIONS.HALF_UP },
|
|
81
|
+
];
|
|
82
|
+
// A release never acts: `:3` is the CSI-u event type for one, and xterm's
|
|
83
|
+
// modifyOtherKeys carries the same suffix after the code.
|
|
84
|
+
const RELEASE_RAW = /^(\x1b\[\d+(?:;\d+)*:3u|\x1b\[\d+(?:;\d+)+;3~)$/;
|
|
85
|
+
|
|
86
|
+
// Names compare case-insensitively for multi-character ids (`pageDown` vs
|
|
87
|
+
// `pagedown`), while a single letter stays exact so `j` can never answer for
|
|
88
|
+
// the `J` scroll key.
|
|
89
|
+
function sameName(left, right) {
|
|
90
|
+
const a = String(left);
|
|
91
|
+
const b = String(right);
|
|
92
|
+
if (a.length === 1 || b.length === 1) return a === b;
|
|
93
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function actionForName(name) {
|
|
97
|
+
const text = String(name);
|
|
98
|
+
if (text === '') return null;
|
|
99
|
+
for (const rule of RULES) {
|
|
100
|
+
for (const candidate of rule.names) {
|
|
101
|
+
if (sameName(text, candidate)) return rule.action;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function released(helpers, data) {
|
|
108
|
+
try {
|
|
109
|
+
if (helpers && typeof helpers.isKeyRelease === 'function' && helpers.isKeyRelease(data) === true) return true;
|
|
110
|
+
} catch {
|
|
111
|
+
// A throwing classifier never claims a release; the raw check below decides.
|
|
112
|
+
}
|
|
113
|
+
return RELEASE_RAW.test(data);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The host's own classifier answers first: its symbolic names are what the
|
|
117
|
+
// terminal actually reported, and they are more reliable than any decoder we
|
|
118
|
+
// could keep here. A single-letter candidate is never offered to the host
|
|
119
|
+
// matcher — host matchers fold letter case, so asking about `J` would make a
|
|
120
|
+
// lowercase `j` scroll — while `parseKey` still reads a real `J` exactly.
|
|
121
|
+
function actionFromHost(helpers, data) {
|
|
122
|
+
try {
|
|
123
|
+
if (helpers && typeof helpers.matchesKey === 'function') {
|
|
124
|
+
for (const rule of RULES) {
|
|
125
|
+
for (const name of rule.names) {
|
|
126
|
+
if (name.length === 1) continue;
|
|
127
|
+
if (helpers.matchesKey(data, name) === true) return rule.action;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Fall through to the parser, then to the bounded table.
|
|
133
|
+
}
|
|
134
|
+
let parsed = null;
|
|
135
|
+
try {
|
|
136
|
+
if (helpers && typeof helpers.parseKey === 'function') parsed = helpers.parseKey(data);
|
|
137
|
+
} catch {
|
|
138
|
+
parsed = null;
|
|
139
|
+
}
|
|
140
|
+
return typeof parsed === 'string' ? actionForName(parsed) : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function actionFromRaw(data) {
|
|
144
|
+
const exact = RAW_ACTIONS.get(data);
|
|
145
|
+
if (exact) return exact;
|
|
146
|
+
for (const entry of RAW_PATTERNS) {
|
|
147
|
+
if (entry.pattern.test(data)) return entry.action;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The preview action one raw key payload asks for.
|
|
154
|
+
*
|
|
155
|
+
* @param {object|null} helpers the host TUI helpers (matchesKey/parseKey/
|
|
156
|
+
* isKeyRelease/isKittyProtocolActive); any of them may be missing.
|
|
157
|
+
* @param {unknown} data the payload `handleInput` received.
|
|
158
|
+
* @returns {'page-down'|'page-up'|'half-down'|'half-up'|null} the action, or
|
|
159
|
+
* null when this payload is not a preview scroll key.
|
|
160
|
+
*/
|
|
161
|
+
function resolveScrollAction(helpers, data) {
|
|
162
|
+
if (typeof data !== 'string' || data === '') return null;
|
|
163
|
+
if (FORBIDDEN_RAW.has(data)) return null;
|
|
164
|
+
if (released(helpers, data)) return null;
|
|
165
|
+
return actionFromHost(helpers, data) || actionFromRaw(data);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The footer entries for the scroll keys the readers share.
|
|
170
|
+
*
|
|
171
|
+
* The legend always names ctrl+j/k: it is the default pair the user asked to
|
|
172
|
+
* see, and the alternates (ctrl+shift+j/k, PgUp/PgDn, J/K, ctrl+d/u) keep
|
|
173
|
+
* working without a token. Only the first entry reaches the one collapsed
|
|
174
|
+
* `scroll` token, so the footer stays a single labelled target.
|
|
175
|
+
*
|
|
176
|
+
* @returns {Array<{text: string, short: string, priority: number}>} legend
|
|
177
|
+
* entries in the shape the one-row footer compacts and drops from.
|
|
178
|
+
*/
|
|
179
|
+
function previewScrollKeys() {
|
|
180
|
+
const primary = { text: 'ctrl+j/k scroll', short: 'ctrl+j/k', priority: 52 };
|
|
181
|
+
return [
|
|
182
|
+
primary,
|
|
183
|
+
{ text: 'PgUp/PgDn', short: 'PgUp/Dn', priority: 24 },
|
|
184
|
+
{ text: 'ctrl+d/u half', short: 'ctrl+d/u', priority: 12 },
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
ACTIONS,
|
|
190
|
+
resolveScrollAction,
|
|
191
|
+
previewScrollKeys,
|
|
192
|
+
};
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Automatic session naming from the ODD document being written
|
|
3
|
+
// (odd/tasks/session-auto-name.md, D2-D7).
|
|
4
|
+
//
|
|
5
|
+
// Pure derivation, no I/O: the caller passes what a tool call touched, a
|
|
6
|
+
// reader for the touched document when it has one, and whether that call was
|
|
7
|
+
// a write, and gets back either the label to write or `null` for "leave the
|
|
8
|
+
// session alone".
|
|
9
|
+
//
|
|
10
|
+
// The channel is the session NAME (`pi.setSessionName`), because Pi 1.3.0
|
|
11
|
+
// exposes no subject/alias write (D7): the session record carries a single
|
|
12
|
+
// `name`. The never-clobber guarantee (P3) therefore cannot come from a
|
|
13
|
+
// channel choice, so it comes from PATTERN: the plugin may write only an
|
|
14
|
+
// empty name or a name it can account for as its own (see
|
|
15
|
+
// `isPluginSessionName`). Anything else was typed by a human and is
|
|
16
|
+
// untouchable. The admitted consequence (D7) is that a human who names a
|
|
17
|
+
// session exactly like a label the plugin would write owns a name the plugin
|
|
18
|
+
// may later update.
|
|
19
|
+
//
|
|
20
|
+
// The label head is the document's own first level-1 heading (D2: the honest
|
|
21
|
+
// source for what the session is about), and the filename slug is only the
|
|
22
|
+
// fallback for a document with no readable heading.
|
|
23
|
+
//
|
|
24
|
+
// Input rels are the canonical `odd/<rel>` names the worktree capture already
|
|
25
|
+
// produces, never raw filesystem paths: mapping a path to a document is path
|
|
26
|
+
// logic that lives in one place already.
|
|
27
|
+
|
|
28
|
+
// Document shape that names a session: `odd/tasks/<feature>.md`, where the
|
|
29
|
+
// feature may be nested (`odd/tasks/perf/plan.md`).
|
|
30
|
+
const ODD_TASKS_PREFIX = 'odd/tasks/';
|
|
31
|
+
|
|
32
|
+
// Separator between the label head and the branch discriminator (D4). The
|
|
33
|
+
// spaces make it a shape a human hand-typing a name rarely produces, and
|
|
34
|
+
// they are what distinguishes the label from a human `fix@home`.
|
|
35
|
+
const BRANCH_SEPARATOR = ' @ ';
|
|
36
|
+
|
|
37
|
+
// A heading-sized label beyond this loses the rest of the words, cut at the
|
|
38
|
+
// last word boundary so the name stays readable at a glance.
|
|
39
|
+
const LABEL_MAX_CHARS = 48;
|
|
40
|
+
|
|
41
|
+
// gentle-pi's ODD phase tool (L3). Its result says WHICH phase the session
|
|
42
|
+
// moved to, never WHICH feature it is about, so the phase trigger cannot
|
|
43
|
+
// derive anything: it re-applies what the session last derived from a write.
|
|
44
|
+
// It is observable only as a `tool_result`, not a host event.
|
|
45
|
+
const ODD_PHASE_TOOL_NAME = 'gentle_odd_phase';
|
|
46
|
+
|
|
47
|
+
// A fallback feature is one or more `/`-separated segments using the
|
|
48
|
+
// characters a document filename can carry, without whitespace. A branch is
|
|
49
|
+
// any whitespace-free token — permissive on purpose, because a branch with an
|
|
50
|
+
// unusual-but-legal character (say `#1`) must still be recognized as OUR
|
|
51
|
+
// label on the next event, or a resumed session would freeze on it forever.
|
|
52
|
+
const FEATURE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
53
|
+
const BRANCH_TOKEN = /^\S+$/;
|
|
54
|
+
|
|
55
|
+
// The tools whose call WRITES a document. Reading is context gathering: it
|
|
56
|
+
// would make the name jump to whatever was opened last (P1), so `read` is
|
|
57
|
+
// deliberately absent.
|
|
58
|
+
const WRITE_TOOL_NAMES = new Set(['write', 'edit']);
|
|
59
|
+
|
|
60
|
+
// The last ODD label each session applied, keyed by session id. In-module so
|
|
61
|
+
// the phase trigger stays testable without the extension, and bounded so a
|
|
62
|
+
// long-lived process that sees many sessions cannot grow without limit; only
|
|
63
|
+
// the most recent sessions can still be alive and resumed.
|
|
64
|
+
const SESSION_MEMORY_LIMIT = 100;
|
|
65
|
+
const lastLabelBySession = new Map();
|
|
66
|
+
|
|
67
|
+
function sessionKey(sessionId) {
|
|
68
|
+
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rememberLabel(sessionId, entry) {
|
|
72
|
+
const key = sessionKey(sessionId);
|
|
73
|
+
if (key === null) return;
|
|
74
|
+
// Re-inserting moves the session to the young end of the insertion order,
|
|
75
|
+
// so eviction always drops the least recently written session.
|
|
76
|
+
lastLabelBySession.delete(key);
|
|
77
|
+
lastLabelBySession.set(key, entry);
|
|
78
|
+
while (lastLabelBySession.size > SESSION_MEMORY_LIMIT) {
|
|
79
|
+
const oldest = lastLabelBySession.keys().next().value;
|
|
80
|
+
lastLabelBySession.delete(oldest);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rememberedLabel(sessionId) {
|
|
85
|
+
const key = sessionKey(sessionId);
|
|
86
|
+
if (key === null) return null;
|
|
87
|
+
return lastLabelBySession.get(key) || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isWriteToolName(name) {
|
|
91
|
+
return typeof name === 'string' && WRITE_TOOL_NAMES.has(name);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// `<feature>` for an `odd/tasks/<feature>.md` rel, else null. Rejects empty
|
|
95
|
+
// segments, nested `.`/`..`, and any name carrying whitespace or characters a
|
|
96
|
+
// feature document cannot have.
|
|
97
|
+
function featureFromOddRel(rel) {
|
|
98
|
+
if (typeof rel !== 'string' || rel.length === 0) return null;
|
|
99
|
+
if (!rel.startsWith(ODD_TASKS_PREFIX)) return null;
|
|
100
|
+
const tail = rel.slice(ODD_TASKS_PREFIX.length);
|
|
101
|
+
if (!tail.endsWith('.md')) return null;
|
|
102
|
+
const feature = tail.slice(0, -'.md'.length);
|
|
103
|
+
if (feature.length === 0) return null;
|
|
104
|
+
const segments = feature.split('/');
|
|
105
|
+
for (const segment of segments) {
|
|
106
|
+
if (segment === '.' || segment === '..' || !FEATURE_SEGMENT.test(segment)) return null;
|
|
107
|
+
}
|
|
108
|
+
return feature;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The first `odd/tasks/<feature>.md` document of the call, or null. The
|
|
112
|
+
// first one wins because a multi-document write has no natural order.
|
|
113
|
+
function firstTaskDocument(rels) {
|
|
114
|
+
if (!Array.isArray(rels)) return null;
|
|
115
|
+
for (const rel of rels) {
|
|
116
|
+
const feature = featureFromOddRel(rel);
|
|
117
|
+
if (feature !== null) return { rel, feature };
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Cut to `max` characters at the last word boundary inside the cut, so a
|
|
123
|
+
// truncated label ends with a whole word. A single word longer than `max`
|
|
124
|
+
// has no boundary and is cut hard; anything short stays untouched.
|
|
125
|
+
function truncateLabel(text, max) {
|
|
126
|
+
if (text.length <= max) return text;
|
|
127
|
+
const slice = text.slice(0, max);
|
|
128
|
+
const lastSpace = slice.lastIndexOf(' ');
|
|
129
|
+
return lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The document's first level-1 ATX heading (`# ...`), trimmed with inner
|
|
133
|
+
// whitespace collapsed and truncated for the label, or null when the text
|
|
134
|
+
// carries none. A deeper heading (`## ...`) is a section title, not the
|
|
135
|
+
// document's own heading, and never matches.
|
|
136
|
+
function headingFromText(text) {
|
|
137
|
+
if (typeof text !== 'string' || text.length === 0) return null;
|
|
138
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
139
|
+
const line = rawLine.trim();
|
|
140
|
+
// ATX level-1 needs whitespace after the `#`; `##` and deeper are
|
|
141
|
+
// section titles, and `#hashtag` is prose, not a heading.
|
|
142
|
+
if (!/^#[ \t]/.test(line)) continue;
|
|
143
|
+
const body = line.slice(1).trim();
|
|
144
|
+
if (body.length === 0) continue;
|
|
145
|
+
return truncateLabel(body.replace(/\s+/g, ' '), LABEL_MAX_CHARS);
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The label head for a document: its heading when there is one, else the
|
|
151
|
+
// filename slug. Deterministic for the same document, which is what keeps
|
|
152
|
+
// repeated events idempotent.
|
|
153
|
+
function labelHeadForDocument(feature, text) {
|
|
154
|
+
const heading = headingFromText(text);
|
|
155
|
+
return heading !== null ? heading : feature;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Read the touched document through the caller's reader. Any failure — a
|
|
159
|
+
// reader that throws, a missing file, a non-string result — is "no heading"
|
|
160
|
+
// and falls back to the slug, never an error the naming path must handle.
|
|
161
|
+
function readDocumentText(readDocument, rel) {
|
|
162
|
+
if (typeof readDocument !== 'function') return null;
|
|
163
|
+
try {
|
|
164
|
+
const text = readDocument(rel);
|
|
165
|
+
return typeof text === 'string' ? text : null;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// D4: the branch suffix is what keeps two concurrent forks of the same
|
|
172
|
+
// feature apart, since the worktree branch is already unique. A branch the
|
|
173
|
+
// caller cannot supply (an odd registry entry without one) degrades to the
|
|
174
|
+
// bare label head, which is still a label the plugin owns.
|
|
175
|
+
function sessionNameForFeature(feature, branch) {
|
|
176
|
+
if (typeof branch === 'string' && BRANCH_TOKEN.test(branch)) {
|
|
177
|
+
return `${feature}${BRANCH_SEPARATOR}${branch}`;
|
|
178
|
+
}
|
|
179
|
+
return feature;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isFeatureName(name) {
|
|
183
|
+
if (typeof name !== 'string' || name.length === 0) return false;
|
|
184
|
+
const segments = name.split('/');
|
|
185
|
+
return segments.every((segment) => segment !== '.' && segment !== '..' && FEATURE_SEGMENT.test(segment));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The ownership test (D7), widened for heading labels: a name counts as the
|
|
189
|
+
// plugin's own when ANY of these holds.
|
|
190
|
+
// (a) it is the label this session already applied (the memory below),
|
|
191
|
+
// which is what lets the phase trigger reuse it;
|
|
192
|
+
// (b) it ends with ` @ <branch>` for THIS worktree's branch — the property
|
|
193
|
+
// that survives a resume without in-memory state, since the branch
|
|
194
|
+
// suffix is applied on every write;
|
|
195
|
+
// (c) it is the plain feature slug from the filename, i.e. what earlier
|
|
196
|
+
// versions wrote before heading labels existed;
|
|
197
|
+
// (d) it is the document's current heading.
|
|
198
|
+
// Called with no context it keeps the original contract: exactly the two
|
|
199
|
+
// shapes earlier versions wrote (`<feature>` and `<feature> @ <branch>`).
|
|
200
|
+
function isPluginSessionName(name, context) {
|
|
201
|
+
if (typeof name !== 'string' || name.length === 0) return false;
|
|
202
|
+
const { branch, feature, documentLabel, appliedLabel } = context || {};
|
|
203
|
+
if (typeof appliedLabel === 'string' && appliedLabel.length > 0 && name === appliedLabel) return true;
|
|
204
|
+
if (typeof documentLabel === 'string' && documentLabel.length > 0 && name === documentLabel) return true;
|
|
205
|
+
const at = name.indexOf(BRANCH_SEPARATOR);
|
|
206
|
+
if (at === -1) {
|
|
207
|
+
if (typeof feature === 'string' && feature.length > 0 && name === feature) return true;
|
|
208
|
+
return isFeatureName(name);
|
|
209
|
+
}
|
|
210
|
+
const head = name.slice(0, at);
|
|
211
|
+
const tail = name.slice(at + BRANCH_SEPARATOR.length);
|
|
212
|
+
if (head.length === 0 || !BRANCH_TOKEN.test(tail)) return false;
|
|
213
|
+
if (typeof branch === 'string' && branch.length > 0 && tail === branch) return true;
|
|
214
|
+
return isFeatureName(head);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// May the plugin write over `name`? An unnamed session (empty, or no name
|
|
218
|
+
// entry at all — `getSessionName()` returns undefined) is adoptable; every
|
|
219
|
+
// other name must be the plugin's own.
|
|
220
|
+
function canAdoptSessionName(name, context) {
|
|
221
|
+
if (name === undefined || name === null) return true;
|
|
222
|
+
if (typeof name !== 'string') return false;
|
|
223
|
+
return name.length === 0 || isPluginSessionName(name, context);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The label for a tool call that touched `rels`, or null when the call is
|
|
227
|
+
// not a WRITE to an `odd/tasks/<feature>.md` document. No ownership is
|
|
228
|
+
// decided here; `planSessionRename` owns that.
|
|
229
|
+
function autoNameForToolCall({ rels, isWrite, branch, readDocument } = {}) {
|
|
230
|
+
if (isWrite !== true) return null;
|
|
231
|
+
const match = firstTaskDocument(rels);
|
|
232
|
+
if (match === null) return null;
|
|
233
|
+
const head = labelHeadForDocument(match.feature, readDocumentText(readDocument, match.rel));
|
|
234
|
+
return sessionNameForFeature(head, branch);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The single decision the write wiring needs. Returns the name to write, or
|
|
238
|
+
// null for "touch nothing", which covers three cases: the call did not write
|
|
239
|
+
// an ODD task document, the current name was chosen by a human, and the
|
|
240
|
+
// derived label is already the current name — the last one is what makes
|
|
241
|
+
// running this on every pipeline event idempotent instead of a stream of host
|
|
242
|
+
// writes. The derived label is remembered per session whether or not it was
|
|
243
|
+
// written, so a later phase transition can re-apply it.
|
|
244
|
+
function planSessionRename({ rels, isWrite, branch, currentName, sessionId, readDocument } = {}) {
|
|
245
|
+
if (isWrite !== true) return null;
|
|
246
|
+
const match = firstTaskDocument(rels);
|
|
247
|
+
if (match === null) return null;
|
|
248
|
+
const head = labelHeadForDocument(match.feature, readDocumentText(readDocument, match.rel));
|
|
249
|
+
const label = sessionNameForFeature(head, branch);
|
|
250
|
+
const previous = rememberedLabel(sessionId);
|
|
251
|
+
const adoptable = canAdoptSessionName(currentName, {
|
|
252
|
+
branch,
|
|
253
|
+
feature: match.feature,
|
|
254
|
+
documentLabel: head,
|
|
255
|
+
appliedLabel: previous === null ? undefined : previous.label,
|
|
256
|
+
});
|
|
257
|
+
rememberLabel(sessionId, { label, head, branch });
|
|
258
|
+
if (!adoptable || label === currentName) return null;
|
|
259
|
+
return label;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The phase trigger: an ODD phase transition with no feature of its own
|
|
263
|
+
// re-applies the label this session last derived from a write. Returns null
|
|
264
|
+
// for "touch nothing" when the session never wrote an ODD document, when the
|
|
265
|
+
// current name is a human's, or when the label is already applied.
|
|
266
|
+
function planPhaseSessionRename({ sessionId, branch, currentName } = {}) {
|
|
267
|
+
const previous = rememberedLabel(sessionId);
|
|
268
|
+
if (previous === null) return null;
|
|
269
|
+
if (!canAdoptSessionName(currentName, { branch, appliedLabel: previous.label })) return null;
|
|
270
|
+
const effectiveBranch = typeof branch === 'string' && branch.length > 0 ? branch : previous.branch;
|
|
271
|
+
const label = sessionNameForFeature(previous.head, effectiveBranch);
|
|
272
|
+
if (label === currentName) return null;
|
|
273
|
+
rememberLabel(sessionId, { label, head: previous.head, branch: effectiveBranch });
|
|
274
|
+
return label;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = {
|
|
278
|
+
ODD_TASKS_PREFIX,
|
|
279
|
+
BRANCH_SEPARATOR,
|
|
280
|
+
LABEL_MAX_CHARS,
|
|
281
|
+
ODD_PHASE_TOOL_NAME,
|
|
282
|
+
autoNameForToolCall,
|
|
283
|
+
featureFromOddRel,
|
|
284
|
+
headingFromText,
|
|
285
|
+
isPluginSessionName,
|
|
286
|
+
canAdoptSessionName,
|
|
287
|
+
isWriteToolName,
|
|
288
|
+
labelHeadForDocument,
|
|
289
|
+
planSessionRename,
|
|
290
|
+
planPhaseSessionRename,
|
|
291
|
+
sessionNameForFeature,
|
|
292
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Session worktree registration for the worktree tools (customize-command
|
|
3
|
+
// T41/S50).
|
|
4
|
+
//
|
|
5
|
+
// gentle-shell registers a host tool named `session_worktree_register`
|
|
6
|
+
// (extensions/gentle-shell.ts:1875, default `direct` exposure so it is
|
|
7
|
+
// callable while active): it validates the path through its per-session
|
|
8
|
+
// SessionWorktreeRegistry (same-git-clone check, current-session check)
|
|
9
|
+
// and appends a `gentle-pi.session-worktree/v1` custom entry to the
|
|
10
|
+
// session file, which is what puts the checkout inside the `/gentle:changes`
|
|
11
|
+
// scope (`sessionChangeRoots` unions those entries with captured evidence).
|
|
12
|
+
// Merely creating a checkout elsewhere never updates the creating
|
|
13
|
+
// session's scope — the registry only auto-registers the session's own
|
|
14
|
+
// cwd at `session_start` — so the create tool calls this explicitly.
|
|
15
|
+
//
|
|
16
|
+
// The host API is `ctx.executeTool(name, args)` (Pi 1.3.0,
|
|
17
|
+
// dist/core/extensions/types.d.ts:280): it never rejects for tool
|
|
18
|
+
// failures (unknown tool, validation error, blocked call and thrown
|
|
19
|
+
// errors all come back as `isError: true`), and `ctx.tools`
|
|
20
|
+
// (types.d.ts:271) lists the tools it can call. Availability therefore
|
|
21
|
+
// means: `ctx.executeTool` is a function AND `ctx.tools` names the
|
|
22
|
+
// tool. Everything routes through the calling tool's context, so it is
|
|
23
|
+
// injectable in tests via a ctx double and no gentle-shell module is
|
|
24
|
+
// ever imported here.
|
|
25
|
+
|
|
26
|
+
const SESSION_WORKTREE_TOOL = 'session_worktree_register';
|
|
27
|
+
|
|
28
|
+
// True only when the host tool is actually available on this context:
|
|
29
|
+
// callable through `ctx.executeTool` and listed in `ctx.tools`. A
|
|
30
|
+
// context without a tool list (older doubles) counts as unavailable.
|
|
31
|
+
function isSessionWorktreeToolAvailable(ctx) {
|
|
32
|
+
try {
|
|
33
|
+
if (!ctx || typeof ctx.executeTool !== 'function') return false;
|
|
34
|
+
const tools = ctx.tools;
|
|
35
|
+
if (!Array.isArray(tools)) return false;
|
|
36
|
+
return tools.some((tool) => tool && tool.name === SESSION_WORKTREE_TOOL);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Register `checkoutPath` on the current session. Resolves to true only
|
|
43
|
+
// when the host tool ran and reported success; every other outcome —
|
|
44
|
+
// unavailable tool, empty path, `isError`, thrown error — resolves to
|
|
45
|
+
// false and never rejects, so the caller can skip silently.
|
|
46
|
+
async function registerSessionWorktree(ctx, checkoutPath) {
|
|
47
|
+
if (!isSessionWorktreeToolAvailable(ctx)) return false;
|
|
48
|
+
if (typeof checkoutPath !== 'string' || checkoutPath.length === 0) return false;
|
|
49
|
+
try {
|
|
50
|
+
const outcome = await ctx.executeTool(SESSION_WORKTREE_TOOL, { path: checkoutPath });
|
|
51
|
+
if (!outcome || outcome.isError === true) return false;
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
SESSION_WORKTREE_TOOL,
|
|
60
|
+
isSessionWorktreeToolAvailable,
|
|
61
|
+
registerSessionWorktree,
|
|
62
|
+
};
|