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.
Files changed (60) hide show
  1. package/README.md +234 -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,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
+ };