fss-link 1.9.3 → 1.9.5

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.
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """Semantic Edit Layer Python structural-facts helper.
3
+
4
+ Protocol: reads a single JSON object from stdin, `{"source": "<full file text>"}`,
5
+ writes a single JSON object to stdout, never touches the filesystem.
6
+
7
+ On success: `{"ok": true, "entries": [...]}` — one entry per supported
8
+ construct, in source order, carrying only raw positional facts (line number
9
+ + UTF-8 byte column, matching ast.col_offset semantics exactly — conversion
10
+ to UTF-16 offsets happens on the TypeScript side). This script does no
11
+ shape/part/name filtering; that is the resolver's job, mirroring how the
12
+ TypeScript adapter keeps all disambiguation centralized.
13
+
14
+ On failure (source does not parse): `{"ok": false, "error": {"message",
15
+ "line", "col"}}`.
16
+
17
+ Boundary-finding for parameter lists and call-argument lists uses `ast`
18
+ node positions to bound a minimal raw-byte scan for the enclosing '(' / ')'
19
+ — never an unbounded scan across arbitrary expression content. The window
20
+ being scanned is always one that Python's grammar guarantees can contain
21
+ only whitespace/comments/a trailing comma (e.g. "end of last parameter's
22
+ default value" to "the closing paren"), so a paren inside a string,
23
+ f-string, or nested call default can never be mistaken for the list
24
+ boundary — those are always bounded on both sides by real ast node
25
+ positions first.
26
+ """
27
+ import ast
28
+ import json
29
+ import sys
30
+
31
+
32
+ def line_byte_starts(source_bytes):
33
+ starts = [0]
34
+ i = 0
35
+ n = len(source_bytes)
36
+ while i < n:
37
+ b = source_bytes[i]
38
+ if b == 0x0A: # \n
39
+ i += 1
40
+ starts.append(i)
41
+ elif b == 0x0D: # \r
42
+ i += 1
43
+ if i < n and source_bytes[i] == 0x0A:
44
+ i += 1
45
+ starts.append(i)
46
+ else:
47
+ i += 1
48
+ return starts
49
+
50
+
51
+ class Ctx:
52
+ def __init__(self, source_bytes, line_starts):
53
+ self.source_bytes = source_bytes
54
+ self.line_starts = line_starts
55
+ self.entries = []
56
+
57
+ def offset(self, line, col):
58
+ return self.line_starts[line - 1] + col
59
+
60
+ def pos(self, line, col):
61
+ return {"line": line, "col": col}
62
+
63
+ def find_forward(self, from_offset, char_byte):
64
+ idx = self.source_bytes.find(char_byte, from_offset)
65
+ if idx == -1:
66
+ return None
67
+ return idx
68
+
69
+ def offset_to_pos(self, offset):
70
+ # Reverse of `offset`: locate which line contains `offset`, then the
71
+ # byte column within that line. Line starts are sorted, so bisect.
72
+ lo, hi = 0, len(self.line_starts) - 1
73
+ while lo < hi:
74
+ mid = (lo + hi + 1) // 2
75
+ if self.line_starts[mid] <= offset:
76
+ lo = mid
77
+ else:
78
+ hi = mid - 1
79
+ return {"line": lo + 1, "col": offset - self.line_starts[lo]}
80
+
81
+
82
+ def node_start(ctx, node):
83
+ return ctx.pos(node.lineno, node.col_offset)
84
+
85
+
86
+ def node_end(ctx, node):
87
+ return ctx.pos(node.end_lineno, node.end_col_offset)
88
+
89
+
90
+ def span(ctx, node):
91
+ return {"start": node_start(ctx, node), "end": node_end(ctx, node)}
92
+
93
+
94
+ def find_paren_span(ctx, anchor_after_offset, right_entities):
95
+ """right_entities is a list of ast nodes (may be empty); the '(' is the
96
+ first one found after anchor_after_offset (safe: nothing but whitespace/
97
+ comments can precede it), the ')' is the first one found after the max
98
+ end-offset among right_entities (or right after '(' if there are none —
99
+ same safety argument)."""
100
+ open_idx = ctx.find_forward(anchor_after_offset, b"(")
101
+ if open_idx is None:
102
+ return None
103
+ ends = [ctx.offset(n.end_lineno, n.end_col_offset) for n in right_entities if n is not None]
104
+ right_anchor = max(ends) if ends else open_idx + 1
105
+ close_idx = ctx.find_forward(right_anchor, b")")
106
+ if close_idx is None:
107
+ return None
108
+ return {"start": ctx.offset_to_pos(open_idx), "end": ctx.offset_to_pos(close_idx + 1)}
109
+
110
+
111
+ def params_span(args_node):
112
+ left = list(args_node.posonlyargs) + list(args_node.args)
113
+ if args_node.vararg is not None:
114
+ left.append(args_node.vararg)
115
+ left += list(args_node.kwonlyargs)
116
+ if args_node.kwarg is not None:
117
+ left.append(args_node.kwarg)
118
+ right = list(left)
119
+ right += list(args_node.defaults)
120
+ right += [d for d in args_node.kw_defaults if d is not None]
121
+ return right
122
+
123
+
124
+ def parent_ref(scope_stack):
125
+ """The innermost scope_stack frame, or None at module level — attached
126
+ verbatim as each entry's own "parent" field. See the module docstring's
127
+ Phase 2 note: this is a stable (kind, name, start, end) reference,
128
+ keyed on the SAME start/end a class/function's own entry carries as its
129
+ keyword_start/end, so the TypeScript side can deterministically match a
130
+ "parent" reference back to its owning entry even when names repeat
131
+ (two same-named nested functions, two same-named classes, etc.) —
132
+ matching by (start, end) span, never by name alone."""
133
+ return scope_stack[-1] if scope_stack else None
134
+
135
+
136
+ def visit_function(ctx, node, kind, scope_stack):
137
+ name_start_offset = ctx.offset(node.lineno, node.col_offset)
138
+ keyword_bytes = ctx.source_bytes[name_start_offset:name_start_offset + 20]
139
+ # 'def '/'async def ' always precedes the name with exactly one space,
140
+ # per Python grammar — locate the name by finding 'def ' then skipping
141
+ # exactly one space, never by scanning for arbitrary identifiers.
142
+ def_idx = keyword_bytes.find(b"def ")
143
+ name_off = name_start_offset + def_idx + len(b"def ")
144
+ name_end_off = ctx.find_forward(name_off, b"(")
145
+ # trim trailing whitespace between name and '(' from the name span
146
+ while name_end_off is not None and name_end_off > name_off and ctx.source_bytes[name_end_off - 1] in (0x20, 0x09):
147
+ name_end_off -= 1
148
+ name_span = {"start": ctx.offset_to_pos(name_off), "end": ctx.offset_to_pos(name_end_off)} if name_end_off else None
149
+
150
+ decorators = node.decorator_list
151
+ decorator_start = ctx.pos(decorators[0].lineno, decorators[0].col_offset) if decorators else None
152
+
153
+ body = node.body
154
+ body_span = {"start": node_start(ctx, body[0]), "end": node_end(ctx, body[-1])} if body else None
155
+
156
+ params = find_paren_span(ctx, name_off, params_span(node.args))
157
+
158
+ ctx.entries.append({
159
+ "kind": kind,
160
+ "name": node.name,
161
+ "decorator_start": decorator_start,
162
+ "keyword_start": node_start(ctx, node),
163
+ "end": node_end(ctx, node),
164
+ "name_span": name_span,
165
+ "body": body_span,
166
+ "params": params,
167
+ "parent": parent_ref(scope_stack),
168
+ })
169
+
170
+
171
+ def visit_class(ctx, node, scope_stack):
172
+ name_start_offset = ctx.offset(node.lineno, node.col_offset)
173
+ class_bytes = ctx.source_bytes[name_start_offset:name_start_offset + 6]
174
+ class_idx = class_bytes.find(b"class ")
175
+ name_off = name_start_offset + class_idx + len(b"class ")
176
+ # name ends at the first of '(' (base classes), ':' (no bases), or whitespace
177
+ candidates = [ctx.find_forward(name_off, b"("), ctx.find_forward(name_off, b":")]
178
+ candidates = [c for c in candidates if c is not None]
179
+ name_end_off = min(candidates) if candidates else None
180
+ while name_end_off is not None and name_end_off > name_off and ctx.source_bytes[name_end_off - 1] in (0x20, 0x09):
181
+ name_end_off -= 1
182
+ name_span = {"start": ctx.offset_to_pos(name_off), "end": ctx.offset_to_pos(name_end_off)} if name_end_off else None
183
+
184
+ decorators = node.decorator_list
185
+ decorator_start = ctx.pos(decorators[0].lineno, decorators[0].col_offset) if decorators else None
186
+
187
+ body = node.body
188
+ body_span = {"start": node_start(ctx, body[0]), "end": node_end(ctx, body[-1])} if body else None
189
+
190
+ ctx.entries.append({
191
+ "kind": "class",
192
+ "name": node.name,
193
+ "decorator_start": decorator_start,
194
+ "keyword_start": node_start(ctx, node),
195
+ "end": node_end(ctx, node),
196
+ "name_span": name_span,
197
+ "body": body_span,
198
+ "parent": parent_ref(scope_stack),
199
+ })
200
+
201
+
202
+ def visit_assign(ctx, node, scope_stack):
203
+ if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
204
+ return
205
+ target = node.targets[0]
206
+ ctx.entries.append({
207
+ "kind": "assignment",
208
+ "name": target.id,
209
+ "whole": span(ctx, node),
210
+ "name_span": span(ctx, target),
211
+ "value": span(ctx, node.value),
212
+ "parent": parent_ref(scope_stack),
213
+ })
214
+
215
+
216
+ def visit_dict(ctx, node, scope_stack):
217
+ for key, value in zip(node.keys, node.values):
218
+ if key is None or not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
219
+ continue
220
+ ctx.entries.append({
221
+ "kind": "dictionary_entry",
222
+ "name": key.value,
223
+ "whole": {"start": node_start(ctx, key), "end": node_end(ctx, value)},
224
+ "name_span": span(ctx, key),
225
+ "value": span(ctx, value),
226
+ "parent": parent_ref(scope_stack),
227
+ })
228
+
229
+
230
+ def visit_import(ctx, node, scope_stack):
231
+ names = []
232
+ if isinstance(node, ast.ImportFrom):
233
+ if node.module:
234
+ names.append(node.module)
235
+ for alias in node.names:
236
+ names.append(alias.asname or alias.name)
237
+ else:
238
+ for alias in node.names:
239
+ names.append(alias.asname or alias.name)
240
+ ctx.entries.append({
241
+ "kind": "import",
242
+ "names": names,
243
+ "whole": span(ctx, node),
244
+ "parent": parent_ref(scope_stack),
245
+ })
246
+
247
+
248
+ def visit_compound(ctx, node, kind, scope_stack):
249
+ body = node.body
250
+ body_span = {"start": node_start(ctx, body[0]), "end": node_end(ctx, body[-1])} if body else None
251
+ ctx.entries.append({
252
+ "kind": kind,
253
+ "whole": span(ctx, node),
254
+ "body": body_span,
255
+ "parent": parent_ref(scope_stack),
256
+ })
257
+
258
+
259
+ def visit_call(ctx, node, scope_stack):
260
+ callee_end = ctx.offset(node.func.end_lineno, node.func.end_col_offset)
261
+ right = list(node.args) + [kw.value for kw in node.keywords]
262
+ args_span = find_paren_span(ctx, callee_end, right)
263
+ callee_start = ctx.offset(node.func.lineno, node.func.col_offset)
264
+ callee_text = ctx.source_bytes[callee_start:callee_end].decode("utf-8", errors="replace")
265
+ ctx.entries.append({
266
+ "kind": "call",
267
+ "callee_text": callee_text,
268
+ "args": args_span,
269
+ "parent": parent_ref(scope_stack),
270
+ })
271
+
272
+
273
+ COMPOUND_KINDS = {
274
+ ast.If: "if",
275
+ ast.For: "for",
276
+ ast.While: "while",
277
+ ast.Try: "try",
278
+ ast.With: "with",
279
+ }
280
+
281
+
282
+ def walk(ctx, node, in_class_body, scope_stack, body_scope_stack):
283
+ """Manual, source-ordered recursion (ast.walk is BFS, not source order).
284
+
285
+ Two contextual threads, deliberately kept separate (Phase 2 note, see
286
+ implementation plan §10.6):
287
+ - `in_class_body` — UNCHANGED from the original single-boolean
288
+ design, used only to decide "method" vs "function"/"async_function"
289
+ naming. Still gated on `field_name == "body"` exactly as before, so
290
+ its existing (narrow) behavior — a def nested inside a compound
291
+ statement (if/for/while/try/with) within a class body is still
292
+ classified "function", not "method" — is preserved byte-for-byte.
293
+ This is a generalization of the OLD mechanism's data shape, not a
294
+ fix to this pre-existing, out-of-scope limitation.
295
+ - `scope_stack`/`body_scope_stack` — NEW, for ancestor-chain
296
+ tracking. Unlike `in_class_body`, this propagates through EVERY
297
+ field of EVERY node type unchanged by default (so a function
298
+ nested inside an `if` inside a function/class still correctly
299
+ reports its true lexical ancestor) — a frame is pushed ONLY at the
300
+ exact point of recursing into a ClassDef/FunctionDef/
301
+ AsyncFunctionDef's own `body` field (never into decorator_list/
302
+ bases/args/returns, which evaluate in the ENCLOSING scope — the
303
+ critical correctness point the mission calls out explicitly).
304
+ `body_scope_stack` is `scope_stack` for every node that is not
305
+ itself a class/function (so its own `body` field, if it has one —
306
+ e.g. If/For/While/Try/With — does not push a new frame); it is
307
+ `scope_stack + [frame]` only when `dispatch` is handling a
308
+ ClassDef/FunctionDef/AsyncFunctionDef.
309
+ """
310
+ for field_name, field_value in ast.iter_fields(node):
311
+ stack_for_field = body_scope_stack if field_name == "body" else scope_stack
312
+ if isinstance(field_value, list):
313
+ for item in field_value:
314
+ if not isinstance(item, ast.AST):
315
+ continue
316
+ dispatch(ctx, item, in_class_body and field_name == "body", stack_for_field)
317
+ elif isinstance(field_value, ast.AST):
318
+ dispatch(ctx, field_value, False, stack_for_field)
319
+
320
+
321
+ def dispatch(ctx, node, in_class_body, scope_stack):
322
+ if isinstance(node, ast.AsyncFunctionDef):
323
+ visit_function(ctx, node, "method" if in_class_body else "async_function", scope_stack)
324
+ frame = {"kind": "method" if in_class_body else "async_function", "name": node.name,
325
+ "start": node_start(ctx, node), "end": node_end(ctx, node)}
326
+ walk(ctx, node, False, scope_stack, scope_stack + [frame])
327
+ elif isinstance(node, ast.FunctionDef):
328
+ visit_function(ctx, node, "method" if in_class_body else "function", scope_stack)
329
+ frame = {"kind": "method" if in_class_body else "function", "name": node.name,
330
+ "start": node_start(ctx, node), "end": node_end(ctx, node)}
331
+ walk(ctx, node, False, scope_stack, scope_stack + [frame])
332
+ elif isinstance(node, ast.ClassDef):
333
+ visit_class(ctx, node, scope_stack)
334
+ frame = {"kind": "class", "name": node.name,
335
+ "start": node_start(ctx, node), "end": node_end(ctx, node)}
336
+ walk(ctx, node, True, scope_stack, scope_stack + [frame])
337
+ elif isinstance(node, ast.Assign):
338
+ visit_assign(ctx, node, scope_stack)
339
+ walk(ctx, node, False, scope_stack, scope_stack)
340
+ elif isinstance(node, ast.Dict):
341
+ visit_dict(ctx, node, scope_stack)
342
+ walk(ctx, node, False, scope_stack, scope_stack)
343
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
344
+ visit_import(ctx, node, scope_stack)
345
+ elif isinstance(node, ast.Call):
346
+ visit_call(ctx, node, scope_stack)
347
+ walk(ctx, node, False, scope_stack, scope_stack)
348
+ elif type(node) in COMPOUND_KINDS:
349
+ visit_compound(ctx, node, COMPOUND_KINDS[type(node)], scope_stack)
350
+ walk(ctx, node, False, scope_stack, scope_stack)
351
+ else:
352
+ walk(ctx, node, False, scope_stack, scope_stack)
353
+
354
+
355
+ def main():
356
+ payload = json.loads(sys.stdin.read())
357
+ source = payload["source"]
358
+ try:
359
+ tree = ast.parse(source)
360
+ except SyntaxError as exc:
361
+ print(json.dumps({"ok": False, "error": {"message": str(exc.msg), "line": exc.lineno, "col": exc.offset}}))
362
+ return
363
+ source_bytes = source.encode("utf-8")
364
+ ctx = Ctx(source_bytes, line_byte_starts(source_bytes))
365
+ walk(ctx, tree, False, [], [])
366
+ print(json.dumps({"ok": True, "entries": ctx.entries}))
367
+
368
+
369
+ if __name__ == "__main__":
370
+ main()
@@ -70,9 +70,15 @@ cd /path/to/project/.fss-link/worktrees/fix-auth-bug && pip install -r requireme
70
70
  ```
71
71
  exit_worktree({ name: "fix-auth-bug", action: "keep" }) # Work in progress
72
72
  exit_worktree({ name: "fix-auth-bug", action: "remove" }) # Work committed
73
- exit_worktree({ name: "fix-auth-bug", action: "remove", discard_changes: true }) # Discard everything
73
+ exit_worktree({ name: "fix-auth-bug", action: "remove", discard_changes: true }) # Discard system metadata
74
+ exit_worktree({ name: "fix-auth-bug", action: "remove", discard_changes: true, confirm_destruction: true }) # Discard everything
74
75
  ```
75
76
 
77
+ ⚠️ **WARNING:** `discard_changes: true` is a destructive action. It will permanently delete all untracked content in the worktree.
78
+
79
+ - Without `confirm_destruction`: the tool cleans up its own artifacts (.fss-link/) and lists any remaining user files. You must decide whether to delete them manually or confirm destruction.
80
+ - With `confirm_destruction: true`: ALL untracked content is destroyed, including user files. Use only when you are certain no valuable untracked content exists.
81
+
76
82
  ## Quick Reference
77
83
 
78
84
  | Situation | Action |
@@ -81,14 +87,15 @@ exit_worktree({ name: "fix-auth-bug", action: "remove", discard_changes: true })
81
87
  | Already in a worktree | Do NOT call enter_worktree again |
82
88
  | Done, work in progress | `exit_worktree({ name, action: "keep" })` |
83
89
  | Done, work committed | `exit_worktree({ name, action: "remove" })` |
84
- | Done, discard everything | `exit_worktree({ name, action: "remove", discard_changes: true })` |
90
+ | Done, discard system metadata only | `exit_worktree({ name, action: "remove", discard_changes: true })` |
91
+ | Done, discard everything | `exit_worktree({ name, action: "remove", discard_changes: true, confirm_destruction: true })` |
85
92
 
86
93
  ## Common Mistakes
87
94
 
88
95
  1. **Using relative paths** — Always use full `worktreePath` for every tool call
89
96
  2. **Creating nested worktrees** — Check path first; if it contains `.fss-link/worktrees/`, you're already in one
90
97
  3. **Running `git worktree add` manually** — Always use the `enter_worktree` tool
91
- 4. **Removing with uncommitted changes** — Commit or stash first, or use `discard_changes: true`
98
+ 4. **Removing with uncommitted changes** — Commit or stash first, or use `discard_changes: true` (system metadata only) or `confirm_destruction: true` (everything)
92
99
 
93
100
  ## Global Flags
94
101
 
@@ -7,7 +7,7 @@ Resume a previous conversation session by number, title, or session ID.
7
7
  ### Usage
8
8
 
9
9
  ```
10
- /resume # List recent sessions
10
+ /resume # Open session gallery dialog
11
11
  /resume <number> # Resume by list position
12
12
  /resume <title substring> # Resume by title match
13
13
  /resume <session-id> # Resume by exact session ID
@@ -17,23 +17,53 @@ Resume a previous conversation session by number, title, or session ID.
17
17
 
18
18
  `/sessions`, `/continue` — all three names invoke the same command.
19
19
 
20
- ### Listing Sessions
20
+ ### Interactive Gallery Dialog
21
21
 
22
- Running `/resume` with no arguments displays a numbered list of up to 50 recent sessions for the current project, sorted by most recently updated:
22
+ Running `/resume` with no arguments opens an interactive gallery dialog showing up to 10 recent sessions for the current project, sorted by most recently updated:
23
23
 
24
24
  ```
25
- Recent sessions:
25
+ ┌──────────────────────────────────────────────────────────┐
26
+ │ ↩ Resume — select a session to resume │
27
+ │ Search: auth │
28
+ │ │
29
+ │ [1] Fix the login bug 2h ago 5 turns │
30
+ │ ▶ [2] Refactor auth module 1d ago 8 turns │
31
+ │ [3] Add dark mode feature 3d ago 12 turns │
32
+ │ │
33
+ │ ↑↓ navigate · Enter confirm · Esc cancel · type to search│
34
+ └──────────────────────────────────────────────────────────┘
35
+ ```
26
36
 
27
- 1. Implement auth middleware for API endpoints 2 hours ago 14 turns
28
- 2. Fix login bug on password reset flow 1 day ago 8 turns
29
- 3. Set up CI pipeline with GitHub Actions 3 days ago 22 turns
37
+ Each row displays: session number, title (truncated at 55 chars), relative time, turn count, and model name (if available).
30
38
 
31
- Type a number to resume, or /resume <partial title> to match by name.
32
- ```
39
+ ### Navigation & Search
40
+
41
+ | Key | Action |
42
+ |-----|--------|
43
+ | `↑` / `↓` | Move selection up/down |
44
+ | `Enter` | Confirm selection and resume |
45
+ | `Esc` (first press) | Clear the search filter |
46
+ | `Esc` (second press) | Close the dialog without resuming |
47
+ | `Backspace` | Delete one character from the search filter |
48
+ | Any printable character | Type into the search filter box |
49
+
50
+ ### Search
51
+
52
+ The dialog has a working text filter. Type any characters to narrow the visible sessions:
53
+
54
+ - **Case-insensitive substring match** on session title
55
+ - Selection resets to the top (index 0) when the filter changes
56
+ - Shows "No sessions match" if the filter produces zero results
57
+ - First `Esc` clears the filter and restores the full list; second `Esc` closes the dialog
58
+
59
+ **Limitations:**
60
+ - Single-term only — multi-word queries like "auth middleware" won't work as two separate terms
61
+ - Searches only session titles, not message content
62
+ - No ranking or scoring — just `includes` substring match
33
63
 
34
- Titles are truncated at 55 characters. The list is scoped to the current project (identified by project hash).
64
+ ### Selection Methods (Text Input)
35
65
 
36
- ### Selection Methods
66
+ You can still use text input to select sessions without opening the gallery:
37
67
 
38
68
  | Method | Example | Behavior |
39
69
  |--------|---------|----------|
@@ -50,7 +80,7 @@ Titles are truncated at 55 characters. The list is scoped to the current project
50
80
 
51
81
  ### Tab Completion
52
82
 
53
- Pressing Tab while typing `/resume <partial>` shows completions for session titles that **start with** the partial text (`startsWith`). This is intentionally stricter than the action's own matching (`includes`) tab completion may show fewer options than what `/resume <text>` would actually match.
83
+ Pressing Tab while typing `/resume <partial>` shows completions for session titles that **contain** the partial text (`includes`, case-insensitive). Empty input (bare `/resume `) returns no completions this prevents the autocomplete dropdown from intercepting Enter, which would block the gallery dialog from opening.
54
84
 
55
85
  ### What Happens When You Resume
56
86
 
@@ -176,11 +206,11 @@ The dialog displays project-level information including:
176
206
 
177
207
  | Aspect | Welcome Back Dialog | `/resume` |
178
208
  |--------|---------------------|-----------|
179
- | **Trigger** | Automatic on project launch | Manual slash command |
180
- | **Scope** | Project-level (one choice) | Per-session picker (up to 50) |
209
+ | **Trigger** | Automatic on project launch | Manual slash command (opens gallery) |
210
+ | **Scope** | Project-level (one choice) | Per-session picker (up to 10 in gallery) |
181
211
  | **Data source** | `ProjectSummaryParser` (analysis of project state) | `sessions.db` (direct session records) |
182
- | **Selection** | Continue / Restart / Cancel | Number, title substring, or session ID |
212
+ | **Selection** | Continue / Restart / Cancel | Gallery dialog or text input (number/title/ID) |
183
213
  | **Context** | Smart summary built from project analysis | Full conversation history restored verbatim |
184
- | **Search** | None — single decision point | Substring search across session titles |
214
+ | **Search** | None — single decision point | Title substring search in gallery dialog |
185
215
 
186
216
  The Welcome Back dialog is best for quick decisions at project launch. `/resume` is for browsing and selecting from multiple saved sessions within an active conversation.
package/docs/TOOLS.md CHANGED
@@ -408,10 +408,13 @@ Exits a worktree previously created by `enter_worktree`.
408
408
  |-----------|------|-------------|
409
409
  | `name` | string | Slug of the worktree to exit (must match the name used in `enter_worktree`). |
410
410
  | `action` | string | `"keep"` preserves the worktree on disk; `"remove"` deletes it and its branch. |
411
- | `discard_changes` | boolean | When `action="remove"`, must be true to delete a worktree with uncommitted changes. |
411
+ | `discard_changes` | boolean | When `action="remove"`, bypasses the dirty-worktree check. Automatically cleans up system metadata (.fss-link/). Lists remaining user files. |
412
+ | `confirm_destruction` | boolean | When `action="remove"` and `discard_changes=true`, destroys ALL untracked content including user files. Requires `discard_changes=true`. |
412
413
 
413
414
  - `action='keep'` — preserves the worktree directory and branch on disk so it can be revisited later
414
415
  - `action='remove'` — deletes the worktree directory and branch. Refuses to run if the worktree contains uncommitted changes unless `discard_changes: true` is set
416
+ - `discard_changes: true` — cleans up system metadata (.fss-link/). If other untracked files remain, lists them and refuses to proceed unless `confirm_destruction: true`
417
+ - `confirm_destruction: true` — destroys ALL untracked content. Requires `discard_changes: true`. Use with caution.
415
418
  - Only invoke when the user explicitly asks to leave or clean up a worktree
416
419
 
417
420
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fss-link",
3
- "version": "1.9.3",
3
+ "version": "1.9.5",
4
4
  "engines": {
5
5
  "node": ">=20.0.0"
6
6
  },
@@ -13,7 +13,7 @@
13
13
  "url": "git+https://github.com/FSSCoding/fss-link.git"
14
14
  },
15
15
  "config": {
16
- "sandboxImageUri": "ghcr.io/fsscoding/fss-link:1.9.3"
16
+ "sandboxImageUri": "ghcr.io/fsscoding/fss-link:1.9.4"
17
17
  },
18
18
  "scripts": {
19
19
  "start": "node scripts/start.js",
@@ -28,7 +28,7 @@
28
28
  "build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
29
29
  "build:packages": "npm run build --workspaces",
30
30
  "build:sandbox": "node scripts/build_sandbox.js --skip-npm-install-build",
31
- "bundle": "npm run generate && node scripts/prebundle-sync-dist.js && node esbuild.config.js && node scripts/copy_bundle_assets.js",
31
+ "bundle": "npm run generate && node scripts/prebundle-sync-dist.js && node esbuild.config.js && node scripts/copy_bundle_assets.js && node bundle/fss-link.js --version",
32
32
  "prepublishOnly": "node scripts/check-publish.js",
33
33
  "test": "npm run test --workspaces --if-present",
34
34
  "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
@@ -95,6 +95,7 @@
95
95
  "eslint-plugin-react": "^7.37.5",
96
96
  "eslint-plugin-react-hooks": "^5.2.0",
97
97
  "eslint-plugin-vitest": "^0.5.4",
98
+ "fast-check": "^4.9.0",
98
99
  "globals": "^16.0.0",
99
100
  "ink": "^6.8.0",
100
101
  "ink-testing-library": "^4.0.0",
@@ -189,7 +190,7 @@
189
190
  "turndown": "^7.2.0",
190
191
  "turndown-plugin-gfm": "^1.0.2",
191
192
  "undici": "^7.10.0",
192
- "vite": "^8.0.14",
193
+ "vite": "^6.3.5",
193
194
  "vitest": "^4.1.7",
194
195
  "xml2js": "^0.6.0",
195
196
  "yargs": "^17.7.2",
@@ -18,8 +18,8 @@
18
18
  // limitations under the License.
19
19
 
20
20
  import { execSync } from 'child_process';
21
- import { writeFileSync, rmSync } from 'fs';
22
- import { join } from 'path';
21
+ import { writeFileSync, rmSync, readFileSync } from 'fs';
22
+ import { join, resolve } from 'path';
23
23
 
24
24
  if (!process.cwd().includes('packages')) {
25
25
  console.error('must be invoked from a package directory');
@@ -31,7 +31,33 @@ if (!process.cwd().includes('packages')) {
31
31
  // leaves that cache out of sync with what's actually on disk, which surfaces
32
32
  // as TS5055 "Cannot write file ... because it would overwrite input file".
33
33
  // Same fix already applied in prebundle-sync-dist.js for the bundle path.
34
- rmSync(join(process.cwd(), 'dist'), { recursive: true, force: true });
34
+ //
35
+ // `tsc --build` also follows tsconfig.json project `references` and rebuilds
36
+ // those referenced packages too (e.g. packages/cli references ../core) — so
37
+ // their dist/ must be cleaned as well, not just this package's own. Missing
38
+ // this was the actual cause of TS5055 surfacing specifically on the
39
+ // *referenced* package's files: this package's own dist was cleaned, but the
40
+ // referenced package's stale dist/.tsbuildinfo was not, so tsc tried to
41
+ // rebuild it against out-of-sync cached state.
42
+ function cleanDistRecursive(pkgDir, seen = new Set()) {
43
+ const resolved = resolve(pkgDir);
44
+ if (seen.has(resolved)) return;
45
+ seen.add(resolved);
46
+
47
+ rmSync(join(resolved, 'dist'), { recursive: true, force: true });
48
+
49
+ let tsconfig;
50
+ try {
51
+ tsconfig = JSON.parse(readFileSync(join(resolved, 'tsconfig.json'), 'utf-8'));
52
+ } catch {
53
+ return;
54
+ }
55
+ for (const ref of tsconfig.references ?? []) {
56
+ cleanDistRecursive(resolve(resolved, ref.path), seen);
57
+ }
58
+ }
59
+
60
+ cleanDistRecursive(process.cwd());
35
61
 
36
62
  // build typescript files
37
63
  execSync('tsc --build', { stdio: 'inherit' });
@@ -64,4 +64,23 @@ if (skillFiles.length > 0) {
64
64
  console.log(`Bundled skills copied to bundle/ (${skillFiles.length} file(s))`);
65
65
  }
66
66
 
67
+ // Copy the Python SEL adapter's subprocess helper alongside the bundled
68
+ // JS. pythonAdapter.ts resolves it via
69
+ // `new URL('./python_ast_helper.py', import.meta.url)` — since esbuild
70
+ // concatenates every module into one output file, import.meta.url for ALL
71
+ // bundled code resolves to bundle/fss-link-main.js's own location at
72
+ // runtime (not each source file's original directory), so the helper must
73
+ // live at the bundle root, not mirrored under tools/semanticEditAdapters/.
74
+ const pythonHelperSrc = join(
75
+ root,
76
+ 'packages',
77
+ 'core',
78
+ 'src',
79
+ 'tools',
80
+ 'semanticEditAdapters',
81
+ 'python_ast_helper.py',
82
+ );
83
+ copyFileSync(pythonHelperSrc, join(bundleDir, 'python_ast_helper.py'));
84
+ console.log('Python SEL adapter helper copied to bundle/python_ast_helper.py');
85
+
67
86
  console.log('Assets copied to bundle/');