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.
- package/bundle/fss-link-main.js +1827 -1479
- package/bundle/python_ast_helper.py +370 -0
- package/bundle/worktrees/SKILL.md +10 -3
- package/docs/SLASH-COMMANDS.md +46 -16
- package/docs/TOOLS.md +4 -1
- package/package.json +5 -4
- package/scripts/build_package.js +29 -3
- package/scripts/copy_bundle_assets.js +19 -0
- package/bundle/fss-link-main.js.bak-20260629 +0 -4468
|
@@ -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
|
|
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
|
|
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
|
|
package/docs/SLASH-COMMANDS.md
CHANGED
|
@@ -7,7 +7,7 @@ Resume a previous conversation session by number, title, or session ID.
|
|
|
7
7
|
### Usage
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
/resume #
|
|
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
|
-
###
|
|
20
|
+
### Interactive Gallery Dialog
|
|
21
21
|
|
|
22
|
-
Running `/resume` with no arguments
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
+
### Selection Methods (Text Input)
|
|
35
65
|
|
|
36
|
-
|
|
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 **
|
|
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
|
|
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 |
|
|
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 |
|
|
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"`,
|
|
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
|
+
"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.
|
|
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": "^
|
|
193
|
+
"vite": "^6.3.5",
|
|
193
194
|
"vitest": "^4.1.7",
|
|
194
195
|
"xml2js": "^0.6.0",
|
|
195
196
|
"yargs": "^17.7.2",
|
package/scripts/build_package.js
CHANGED
|
@@ -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
|
-
|
|
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/');
|