@timurproko/a1 0.1.8-dev.457 → 0.1.8-dev.459
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/dist/composition/owned-ui.js +2 -1
- package/dist/contracts/owned-ui/model.d.ts +3 -1
- package/dist/features/owned-ui/settings-app.d.ts +1 -0
- package/dist/features/owned-ui/settings-app.js +129 -17
- package/dist/integrations/pi/components/upstream/components/owned-editor.js +2 -2
- package/dist/integrations/pi/session-ui/session-shell.js +11 -3
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/ui/components/list-view.js +5 -6
- package/dist/ui/components/surface.d.ts +7 -2
- package/dist/ui/components/surface.js +13 -3
- package/dist/ui/settings/declarations.d.ts +1 -1
- package/dist/ui/settings/declarations.js +13 -2
- package/dist/ui/settings/migrations.js +13 -0
- package/docs/architecture/ui-reference-provenance.md +2 -2
- package/docs/local-worktree-cleanup.md +33 -6
- package/docs/openspec-archive-automation.md +1 -1
- package/package.json +1 -1
|
@@ -120,7 +120,8 @@ function quitOutroSettingsSnapshot(settings) {
|
|
|
120
120
|
const effect = settings.value("quitEffect");
|
|
121
121
|
const durationMs = settings.value("quitEffectDurationMs");
|
|
122
122
|
return {
|
|
123
|
-
|
|
123
|
+
enabled: settings.value("quitAnimation") !== false,
|
|
124
|
+
effect: effect === "dissolve" || effect === "starburst" || effect === "waves" ? effect : "fall",
|
|
124
125
|
durationMs: typeof durationMs === "number" ? durationMs : 800,
|
|
125
126
|
};
|
|
126
127
|
}
|
|
@@ -64,9 +64,11 @@ export interface OwnedUiViewportSettingsPort {
|
|
|
64
64
|
snapshot(): OwnedUiViewportSettings;
|
|
65
65
|
onChange(listener: (settings: OwnedUiViewportSettings) => void): () => void;
|
|
66
66
|
}
|
|
67
|
-
export type OwnedUiQuitEffect = "fall" | "dissolve" | "starburst" | "waves"
|
|
67
|
+
export type OwnedUiQuitEffect = "fall" | "dissolve" | "starburst" | "waves";
|
|
68
68
|
/** Profile-local quit outro choice, read at the moment bare A1 quits. */
|
|
69
69
|
export interface OwnedUiQuitOutroSettings {
|
|
70
|
+
/** False leaves the terminal immediately; the effect and duration are then ignored. */
|
|
71
|
+
readonly enabled: boolean;
|
|
70
72
|
readonly effect: OwnedUiQuitEffect;
|
|
71
73
|
/** Requested playback length; the player clamps it to its supported range. */
|
|
72
74
|
readonly durationMs: number;
|
|
@@ -14,6 +14,7 @@ export declare class SettingsApp implements UiApp {
|
|
|
14
14
|
readonly id = "settings";
|
|
15
15
|
constructor(session: OwnedUiSettingsSession);
|
|
16
16
|
onActivate(host: AppHostServices): void;
|
|
17
|
+
onClose(_host: AppHostServices): void;
|
|
17
18
|
render(rect: PaneRect, host: AppHostServices): readonly string[];
|
|
18
19
|
onInput(data: string, host: AppHostServices): PaneInputResult;
|
|
19
20
|
onMouse(event: PaneMouseEvent, _host: AppHostServices): PaneInputResult;
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { GLOBAL_SCOPE, LineInput, PLAIN_THEME, ShortcutRegistry, assertNoShortcutConflicts, blockJumpTarget, dialogValueColumn, numericValues, RAIL_COLUMNS, renderDialogPanel, renderEmptyState, renderGroupHeader, renderInputRow, renderListRow, renderNote, renderStatusLine, dialogRowAt, menuRowAt, regionAt, renderValueMenu, withScrollbarRail, stepperEnds, steppedValue, valueColumnFor, valueMenuFrame, blockRowSpan, displayWidth, handleLineInputKey, humanizeLabel, humanizeTitle, indexOfKey, isThumbRow, layoutList, moveSelection, rowKey, scrollForSelection, scrollbarGeometry, scrollbarWheelRows, selectableIndexes, } from "../../ui/components/index.js";
|
|
1
|
+
import { GLOBAL_SCOPE, LineInput, PLAIN_THEME, ShortcutRegistry, assertNoShortcutConflicts, blockJumpTarget, dialogValueColumn, numericValues, RAIL_COLUMNS, renderDialogPanel, renderEmptyState, renderGroupHeader, renderInputRow, renderListRow, renderNote, renderStatusLine, dialogRowAt, menuRowAt, regionAt, renderValueMenu, withScrollbarRail, stepperEnds, steppedValue, valueColumnFor, valueMenuFrame, blockRowSpan, displayWidth, handleLineInputKey, humanizeLabel, humanizeTitle, indexOfKey, isThumbRow, layoutList, moveSelection, rowKey, scrollForSelection, scrollForTrackPage, scrollbarGeometry, scrollbarPresentation, ScrollbarRails, scrollbarWheelRows, selectableIndexes, } from "../../ui/components/index.js";
|
|
2
2
|
import { SETTINGS_APP_ID, SETTINGS_ROUTE } from "./settings-route.js";
|
|
3
3
|
export { SETTINGS_APP_ID, SETTINGS_ROUTE } from "./settings-route.js";
|
|
4
4
|
const SCOPE = SETTINGS_APP_ID;
|
|
5
5
|
/** The panel a setting with parts opens: its own keys, its own hint. */
|
|
6
6
|
const DIALOG_SCOPE = `${SETTINGS_APP_ID}-parts`;
|
|
7
7
|
const SCROLLBAR_TOP_INSET = 1;
|
|
8
|
+
/** Identity of the settings list rail in the shared rail state. */
|
|
9
|
+
const RAIL_KEY = "settings";
|
|
10
|
+
// Compatibility: the transcript rail stays lit this long after a scroll, and repaints just after.
|
|
11
|
+
const SCROLL_LINGER_MS = 900;
|
|
12
|
+
const SCROLL_LINGER_REPAINT_MS = 925;
|
|
8
13
|
const SEARCH_PLACEHOLDER = "search settings";
|
|
9
14
|
/** What a structured value offers instead of printing itself. */
|
|
10
15
|
const CONFIGURE = "configure";
|
|
@@ -16,8 +21,10 @@ SETTINGS_SHORTCUTS.declare({ key: "shift+up", scope: SCOPE, description: "Previo
|
|
|
16
21
|
SETTINGS_SHORTCUTS.declare({ key: "shift+down", scope: SCOPE, description: "Next section", section: "Navigate", hint: { keys: "Shift+↑↓", does: "to jump" } }, "block-down");
|
|
17
22
|
SETTINGS_SHORTCUTS.declare({ key: "pageUp", scope: SCOPE, description: "Up a page", section: "Navigate" }, "page-up");
|
|
18
23
|
SETTINGS_SHORTCUTS.declare({ key: "pageDown", scope: SCOPE, description: "Down a page", section: "Navigate" }, "page-down");
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
// Compatibility: the same chords the transcript uses for its content boundaries; plain
|
|
25
|
+
// Home and End stay with the search input's cursor.
|
|
26
|
+
SETTINGS_SHORTCUTS.declare({ key: "ctrl+home", scope: SCOPE, description: "First setting", section: "Navigate" }, "first");
|
|
27
|
+
SETTINGS_SHORTCUTS.declare({ key: "ctrl+end", scope: SCOPE, description: "Last setting", section: "Navigate" }, "last");
|
|
21
28
|
SETTINGS_SHORTCUTS.declare({ key: "enter", scope: SCOPE, description: "Change value", section: "Change", hint: { keys: "Enter/Space", does: "to change" } }, "activate");
|
|
22
29
|
SETTINGS_SHORTCUTS.declare({ key: "space", scope: SCOPE, description: "Change value", section: "Change", hint: { keys: "Enter/Space", does: "to change" } }, "activate");
|
|
23
30
|
SETTINGS_SHORTCUTS.declare({ key: "left", scope: SCOPE, description: "Previous value", section: "Change", hint: { keys: "←→", does: "to adjust" } }, "previous-value");
|
|
@@ -39,8 +46,11 @@ const KEYS = {
|
|
|
39
46
|
"\u001b[C": "right",
|
|
40
47
|
"\u001b[5~": "pageUp",
|
|
41
48
|
"\u001b[6~": "pageDown",
|
|
42
|
-
|
|
43
|
-
"\u001b[
|
|
49
|
+
// Protocol: xterm modifier form, then the rxvt Ctrl form of the same chords.
|
|
50
|
+
"\u001b[1;5H": "ctrl+home",
|
|
51
|
+
"\u001b[1;5F": "ctrl+end",
|
|
52
|
+
"\u001b[7^": "ctrl+home",
|
|
53
|
+
"\u001b[8^": "ctrl+end",
|
|
44
54
|
"\u001b": "escape",
|
|
45
55
|
"\r": "enter",
|
|
46
56
|
"\n": "enter",
|
|
@@ -76,6 +86,14 @@ export class SettingsApp {
|
|
|
76
86
|
#hoverRegion = "label";
|
|
77
87
|
#frameRows = [];
|
|
78
88
|
#menuFrame = null;
|
|
89
|
+
// Invariant: rail hover and drag live in the shared keyed state, as the transcript's do.
|
|
90
|
+
#rails = new ScrollbarRails();
|
|
91
|
+
// Rationale: the rail as drawn in the last frame, or null when nothing can be pointed at.
|
|
92
|
+
#railFrame = null;
|
|
93
|
+
// Invariant: a scroll lights the rail until this time; the timer repaints once it has passed.
|
|
94
|
+
#activeUntil = 0;
|
|
95
|
+
#activityTimer;
|
|
96
|
+
#renderedScroll;
|
|
79
97
|
constructor(session) {
|
|
80
98
|
this.#session = session;
|
|
81
99
|
}
|
|
@@ -85,6 +103,10 @@ export class SettingsApp {
|
|
|
85
103
|
host.requestRender();
|
|
86
104
|
});
|
|
87
105
|
}
|
|
106
|
+
onClose(_host) {
|
|
107
|
+
this.#clearActivityTimer();
|
|
108
|
+
this.#rails.clear();
|
|
109
|
+
}
|
|
88
110
|
render(rect, host) {
|
|
89
111
|
const theme = host.theme ?? PLAIN_THEME;
|
|
90
112
|
this.#interruptArmed = host.interruptArmed;
|
|
@@ -102,7 +124,17 @@ export class SettingsApp {
|
|
|
102
124
|
this.#reveal = undefined;
|
|
103
125
|
const layout = layoutList(rows, bodyHeight, this.#scroll);
|
|
104
126
|
this.#scroll = layout.scroll;
|
|
105
|
-
const
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
// Rationale: every way of scrolling ends in this frame, so a moved list is noticed here
|
|
129
|
+
// once rather than at each wheel, drag, key, and search branch.
|
|
130
|
+
if (this.#renderedScroll !== undefined && this.#renderedScroll !== layout.scroll)
|
|
131
|
+
this.#noteScrollActivity(host, now);
|
|
132
|
+
this.#renderedScroll = layout.scroll;
|
|
133
|
+
// Invariant: auto and always keep the rail columns while the list fits, so a revealed
|
|
134
|
+
// rail never reflows the rows; hidden gives the columns back to the rows.
|
|
135
|
+
const appearance = this.#scrollbarAppearance();
|
|
136
|
+
const reservesRail = appearance !== "hidden";
|
|
137
|
+
const contentWidth = reservesRail ? Math.max(0, rect.width - RAIL_COLUMNS) : rect.width;
|
|
106
138
|
const valueColumn = this.#valueColumn(rows);
|
|
107
139
|
const geometry = scrollbarGeometry({
|
|
108
140
|
contentLength: rows.length,
|
|
@@ -110,6 +142,22 @@ export class SettingsApp {
|
|
|
110
142
|
scroll: layout.scroll,
|
|
111
143
|
trackHeight: Math.max(0, bodyHeight - SCROLLBAR_TOP_INSET),
|
|
112
144
|
});
|
|
145
|
+
const presentation = scrollbarPresentation({
|
|
146
|
+
geometry,
|
|
147
|
+
appearance,
|
|
148
|
+
style: this.#scrollbarStyle(),
|
|
149
|
+
hovered: this.#rails.isHovered(RAIL_KEY),
|
|
150
|
+
dragging: this.#rails.isDragging(RAIL_KEY),
|
|
151
|
+
activeUntil: this.#activeUntil,
|
|
152
|
+
now,
|
|
153
|
+
});
|
|
154
|
+
this.#railFrame = reservesRail && geometry !== null
|
|
155
|
+
? {
|
|
156
|
+
rail: { key: RAIL_KEY, column: rect.width, rowStart: SCROLLBAR_TOP_INSET, trackHeight: geometry.trackHeight },
|
|
157
|
+
geometry,
|
|
158
|
+
page: layout.visible,
|
|
159
|
+
}
|
|
160
|
+
: null;
|
|
113
161
|
const body = [];
|
|
114
162
|
this.#frameRows = [];
|
|
115
163
|
if (rows.length === 0) {
|
|
@@ -140,8 +188,9 @@ export class SettingsApp {
|
|
|
140
188
|
}
|
|
141
189
|
const withRail = withScrollbarRail(body.slice(0, bodyHeight), geometry, contentWidth, theme, {
|
|
142
190
|
topInset: SCROLLBAR_TOP_INSET,
|
|
191
|
+
presentation,
|
|
143
192
|
});
|
|
144
|
-
return this.#withMenu([...withRail, ...footer], selected, layout, valueColumn, theme, rect);
|
|
193
|
+
return this.#withMenu([...withRail, ...footer], selected, layout, valueColumn, theme, rect, reservesRail ? RAIL_COLUMNS : 0);
|
|
145
194
|
}
|
|
146
195
|
onInput(data, host) {
|
|
147
196
|
if (this.#structured !== null)
|
|
@@ -278,13 +327,16 @@ export class SettingsApp {
|
|
|
278
327
|
this.#scroll = Math.max(0, this.#scroll + (event.kind === "wheel-down" ? distance : -distance));
|
|
279
328
|
return { consumed: true };
|
|
280
329
|
}
|
|
330
|
+
const rail = this.#railPointer(event);
|
|
331
|
+
if (rail.owned)
|
|
332
|
+
return { consumed: true };
|
|
281
333
|
const row = this.#frameRows.find(candidate => candidate.screenRow === event.row - 1);
|
|
282
334
|
const previousKey = this.#hoverKey;
|
|
283
335
|
const previousRegion = this.#hoverRegion;
|
|
284
336
|
if (row === undefined) {
|
|
285
337
|
this.#hoverKey = null;
|
|
286
338
|
this.#hoverRegion = "label";
|
|
287
|
-
return { consumed: event.kind !== "motion", render: previousKey !== null };
|
|
339
|
+
return { consumed: event.kind !== "motion", render: previousKey !== null || rail.changed };
|
|
288
340
|
}
|
|
289
341
|
this.#hoverKey = row.key;
|
|
290
342
|
this.#hoverRegion = regionAt(row, event.column);
|
|
@@ -303,9 +355,55 @@ export class SettingsApp {
|
|
|
303
355
|
}
|
|
304
356
|
return { consumed: true };
|
|
305
357
|
}
|
|
306
|
-
const changed = previousKey !== this.#hoverKey || previousRegion !== this.#hoverRegion;
|
|
358
|
+
const changed = previousKey !== this.#hoverKey || previousRegion !== this.#hoverRegion || rail.changed;
|
|
307
359
|
return { consumed: event.kind !== "motion", render: changed };
|
|
308
360
|
}
|
|
361
|
+
// Rationale: the rail takes its share of a pointer report first: hover, a thumb drag, or a
|
|
362
|
+
// track page. Owned means the list must not see the report; changed means the rail looks different.
|
|
363
|
+
#railPointer(event) {
|
|
364
|
+
const frame = this.#railFrame;
|
|
365
|
+
const wasHovered = this.#rails.isHovered(RAIL_KEY);
|
|
366
|
+
if (frame === null) {
|
|
367
|
+
this.#rails.clear();
|
|
368
|
+
return { owned: false, changed: wasHovered };
|
|
369
|
+
}
|
|
370
|
+
const pointer = { column: event.column, row: event.row - 1 };
|
|
371
|
+
if (this.#rails.isDragging(RAIL_KEY)) {
|
|
372
|
+
// Invariant: a drag keeps the pointer wherever it goes until the button comes up.
|
|
373
|
+
if (event.kind === "release")
|
|
374
|
+
this.#rails.endDrag();
|
|
375
|
+
else {
|
|
376
|
+
const target = this.#rails.dragTo(frame.rail, frame.geometry, pointer);
|
|
377
|
+
if (target !== null)
|
|
378
|
+
this.#scroll = target;
|
|
379
|
+
}
|
|
380
|
+
return { owned: true, changed: true };
|
|
381
|
+
}
|
|
382
|
+
const over = this.#rails.notePointer([frame.rail], pointer) !== null;
|
|
383
|
+
if (!over)
|
|
384
|
+
return { owned: false, changed: wasHovered };
|
|
385
|
+
// Invariant: the rail is not a row: pointing at it lights nothing in the list.
|
|
386
|
+
this.#hoverKey = null;
|
|
387
|
+
this.#hoverRegion = "label";
|
|
388
|
+
if (event.kind === "press" && !this.#rails.beginDrag(frame.rail, frame.geometry, pointer)) {
|
|
389
|
+
this.#scroll = scrollForTrackPage(frame.geometry, pointer.row - frame.rail.rowStart, this.#scroll, frame.page);
|
|
390
|
+
}
|
|
391
|
+
return { owned: true, changed: true };
|
|
392
|
+
}
|
|
393
|
+
#noteScrollActivity(host, now) {
|
|
394
|
+
this.#activeUntil = Math.max(this.#activeUntil, now + SCROLL_LINGER_MS);
|
|
395
|
+
this.#clearActivityTimer();
|
|
396
|
+
this.#activityTimer = setTimeout(() => {
|
|
397
|
+
this.#activityTimer = undefined;
|
|
398
|
+
host.requestRender();
|
|
399
|
+
}, SCROLL_LINGER_REPAINT_MS);
|
|
400
|
+
this.#activityTimer.unref?.();
|
|
401
|
+
}
|
|
402
|
+
#clearActivityTimer() {
|
|
403
|
+
if (this.#activityTimer !== undefined)
|
|
404
|
+
clearTimeout(this.#activityTimer);
|
|
405
|
+
this.#activityTimer = undefined;
|
|
406
|
+
}
|
|
309
407
|
#openMenu(rows, selected) {
|
|
310
408
|
const row = rows[selected];
|
|
311
409
|
if (row === undefined || row.kind !== "element")
|
|
@@ -348,6 +446,7 @@ export class SettingsApp {
|
|
|
348
446
|
// thing under the pointer, so it stops looking like it.
|
|
349
447
|
this.#hoverKey = null;
|
|
350
448
|
this.#hoverRegion = "label";
|
|
449
|
+
this.#rails.clear();
|
|
351
450
|
this.#structured = { entry, flags: entry.flags.map(flag => flag.key), index: 0, record };
|
|
352
451
|
}
|
|
353
452
|
#structuredKey(data) {
|
|
@@ -410,13 +509,15 @@ export class SettingsApp {
|
|
|
410
509
|
if (input === null)
|
|
411
510
|
return { consumed: false };
|
|
412
511
|
const key = KEYS[data];
|
|
413
|
-
|
|
512
|
+
// Rationale: the boundary chords jump through the results; plain Home and End stay with
|
|
513
|
+
// the search cursor, which the shared line input moves below.
|
|
514
|
+
if (key === "ctrl+home" || key === "ctrl+end") {
|
|
414
515
|
const rows = this.#rows();
|
|
415
516
|
const selectable = selectableIndexes(rows);
|
|
416
|
-
const target = key === "end" ? selectable.at(-1) : selectable[0];
|
|
517
|
+
const target = key === "ctrl+end" ? selectable.at(-1) : selectable[0];
|
|
417
518
|
if (target !== undefined) {
|
|
418
519
|
this.#select(rows, target);
|
|
419
|
-
if (key === "home")
|
|
520
|
+
if (key === "ctrl+home")
|
|
420
521
|
this.#scroll = 0;
|
|
421
522
|
}
|
|
422
523
|
return { consumed: true };
|
|
@@ -502,11 +603,22 @@ export class SettingsApp {
|
|
|
502
603
|
return this.#pending.get(`${entry.backend}:${entry.id}`) ?? entry.value;
|
|
503
604
|
}
|
|
504
605
|
#scrollbarSpeed() {
|
|
606
|
+
const value = this.#scrollSetting("scrollbarSpeed");
|
|
607
|
+
return isScrollbarSpeed(value) ? value : "normal";
|
|
608
|
+
}
|
|
609
|
+
#scrollbarAppearance() {
|
|
610
|
+
const value = this.#scrollSetting("scrollbarAppearance");
|
|
611
|
+
return value === "always" || value === "hidden" ? value : "auto";
|
|
612
|
+
}
|
|
613
|
+
#scrollbarStyle() {
|
|
614
|
+
return this.#scrollSetting("scrollbarStyle") === "thick" ? "thick" : "thin";
|
|
615
|
+
}
|
|
616
|
+
// Invariant: a Scroll setting reads as the screen shows it: an accepted value first, then the source.
|
|
617
|
+
#scrollSetting(id) {
|
|
505
618
|
const entry = this.#session.sections()
|
|
506
619
|
.flatMap(section => section.entries)
|
|
507
|
-
.find(candidate => candidate.backend === "a1" && candidate.id ===
|
|
508
|
-
|
|
509
|
-
return isScrollbarSpeed(value) ? value : "normal";
|
|
620
|
+
.find(candidate => candidate.backend === "a1" && candidate.id === id);
|
|
621
|
+
return entry === undefined ? this.#session.value(id) : this.#shownValue(entry);
|
|
510
622
|
}
|
|
511
623
|
#jump(rows, target) {
|
|
512
624
|
this.#select(rows, target);
|
|
@@ -590,7 +702,7 @@ export class SettingsApp {
|
|
|
590
702
|
...(typeof shown === "number" && entry.editable ? { stepper: stepperEnds(range, shown) } : {}),
|
|
591
703
|
};
|
|
592
704
|
}
|
|
593
|
-
#withMenu(lines, _selected, _layout, valueColumn, theme, rect) {
|
|
705
|
+
#withMenu(lines, _selected, _layout, valueColumn, theme, rect, reservedRight) {
|
|
594
706
|
const menu = this.#menu;
|
|
595
707
|
const anchor = menu === null ? undefined : this.#frameRows.find(candidate => candidate.key === menu.anchorKey);
|
|
596
708
|
if (menu === null || anchor === undefined) {
|
|
@@ -605,7 +717,7 @@ export class SettingsApp {
|
|
|
605
717
|
const frame = valueMenuFrame(state, { screenRow: anchor.screenRow, valueColumn }, {
|
|
606
718
|
bodyHeight: lines.length - this.#footerHeight,
|
|
607
719
|
surfaceWidth: rect.width,
|
|
608
|
-
reservedRight
|
|
720
|
+
reservedRight,
|
|
609
721
|
});
|
|
610
722
|
this.#menuFrame = frame;
|
|
611
723
|
return renderValueMenu(lines, state, frame, theme);
|
|
@@ -44,12 +44,12 @@ export function createOwnedEditorClass(Base) {
|
|
|
44
44
|
&& this.getText().length === 0
|
|
45
45
|
&& !this.isShowingAutocomplete();
|
|
46
46
|
}
|
|
47
|
-
/** True when autocomplete is searching one
|
|
47
|
+
/** True when autocomplete is searching one space-free slash command that is the editor's only content. */
|
|
48
48
|
isTopLevelCommandSearch() {
|
|
49
49
|
if (!this.isShowingAutocomplete())
|
|
50
50
|
return false;
|
|
51
51
|
const text = this.getText();
|
|
52
|
-
if (
|
|
52
|
+
if (!/^\/\S*$/.test(text))
|
|
53
53
|
return false;
|
|
54
54
|
const cursor = this.getCursor();
|
|
55
55
|
return cursor.line === 0 && cursor.col === text.length;
|
|
@@ -1394,6 +1394,10 @@ export class OwnedUiSessionShell {
|
|
|
1394
1394
|
attempt(() => this.#unsubscribe());
|
|
1395
1395
|
attempt(() => this.#dialogHandle?.hide());
|
|
1396
1396
|
attempt(() => this.#extensionBridge.dispose());
|
|
1397
|
+
// Invariant: from here to the leave nothing but the outro paints. A throttled frame the
|
|
1398
|
+
// renderer still has queued would otherwise land during the stop-time input drain and
|
|
1399
|
+
// flash the prompt and footer, whether or not an effect plays.
|
|
1400
|
+
attempt(() => this.#freezeQuitPresentation());
|
|
1397
1401
|
await this.#playQuitOutro(outroFrame);
|
|
1398
1402
|
// Invariant: terminal restoration precedes any potentially stalled backend teardown. The
|
|
1399
1403
|
// fullscreen leave preserves the screen: the pinned runtime never dumps its final document
|
|
@@ -1415,8 +1419,8 @@ export class OwnedUiSessionShell {
|
|
|
1415
1419
|
if (!this.runtime.active || this.runtime.mode !== "fullscreen")
|
|
1416
1420
|
return null;
|
|
1417
1421
|
try {
|
|
1418
|
-
const { effect, durationMs } = outro.snapshot();
|
|
1419
|
-
if (
|
|
1422
|
+
const { enabled, effect, durationMs } = outro.snapshot();
|
|
1423
|
+
if (!enabled)
|
|
1420
1424
|
return null;
|
|
1421
1425
|
const viewport = this.runtime.viewport();
|
|
1422
1426
|
return { rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows, settings: { effect, durationMs } };
|
|
@@ -1425,6 +1429,11 @@ export class OwnedUiSessionShell {
|
|
|
1425
1429
|
return null;
|
|
1426
1430
|
}
|
|
1427
1431
|
}
|
|
1432
|
+
#freezeQuitPresentation() {
|
|
1433
|
+
if (!this.#customViewport || !this.runtime.active || this.runtime.mode !== "fullscreen")
|
|
1434
|
+
return;
|
|
1435
|
+
this.runtime.freezePresentation();
|
|
1436
|
+
}
|
|
1428
1437
|
// Rationale: any failure here only skips the effect; restoration always follows.
|
|
1429
1438
|
async #playQuitOutro(capture) {
|
|
1430
1439
|
const outro = this.#quitOutro;
|
|
@@ -1436,7 +1445,6 @@ export class OwnedUiSessionShell {
|
|
|
1436
1445
|
const frame = captureQuitOutroFrame(capture.rows, capture.columns, capture.height);
|
|
1437
1446
|
if (frame === null || !this.runtime.active)
|
|
1438
1447
|
return;
|
|
1439
|
-
this.runtime.freezePresentation();
|
|
1440
1448
|
await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
|
|
1441
1449
|
write: data => this.runtime.writeControl(data),
|
|
1442
1450
|
...(outro.now === undefined ? {} : { now: outro.now }),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"platform": "darwin",
|
|
6
6
|
"architecture": "arm64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-17T13:26:08.181Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian",
|
|
11
11
|
"sha256": "9db1726bbe3fc2e8292f2ead1e7e9d0fd4d7d0dc9217b372582bf354b0f565dc",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"platform": "linux",
|
|
6
6
|
"architecture": "x64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-17T13:26:00.541Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian",
|
|
11
11
|
"sha256": "d8cda6b0c7cb36c0cc41e90802aceebf6c02eaebbc08a83d7d963d2e918dbd7a",
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
"platform": "win32",
|
|
6
6
|
"architecture": "x64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-17T13:27:04.398Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian.exe",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "f7e5072d6fddb8e0672d8274699e8e47f515d98776ba3bd79b00391fcd38e667",
|
|
12
12
|
"size": 177664
|
|
13
13
|
},
|
|
14
14
|
"provenance": {
|
|
Binary file
|
|
@@ -29,14 +29,13 @@ export function renderListRow(row, state, valueColumn, width, theme) {
|
|
|
29
29
|
? `${theme.fg("accent", cursor)}${theme.fg("accent", labelPadded)}`
|
|
30
30
|
: `${cursor}${theme.plain(labelPadded)}`;
|
|
31
31
|
const gap = Math.max(2, valueColumn - displayWidth(leftRaw));
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
32
|
+
// Rationale: a declared difference from pinned SettingsList, which paints the selected
|
|
33
|
+
// value in the accent too. Here only the cursor and label carry the selection; the
|
|
34
|
+
// value reads the same on every row, and pointer hover brightens it without moving
|
|
35
|
+
// the keyboard selection.
|
|
35
36
|
const valueHovered = state.hovered && state.region !== "label";
|
|
36
37
|
const stepper = row.stepper !== undefined && valueHovered;
|
|
37
|
-
const value =
|
|
38
|
-
? theme.fg("accent", row.value)
|
|
39
|
-
: valueHovered ? theme.plain(row.value) : theme.fg("muted", row.value);
|
|
38
|
+
const value = valueHovered ? theme.plain(row.value) : theme.fg("muted", row.value);
|
|
40
39
|
const minus = stepper
|
|
41
40
|
? row.stepper?.lower === true
|
|
42
41
|
? state.region === "minus" ? theme.plain("- ") : theme.fg("dim", "- ")
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { type ScrollbarGeometry } from "./scrollbar.js";
|
|
1
|
+
import { type ScrollbarGeometry, type ScrollbarPresentation } from "./scrollbar.js";
|
|
2
2
|
import type { UiTheme } from "./theme.js";
|
|
3
3
|
/** Columns the rail occupies: its own, plus the gap before it. */
|
|
4
4
|
export declare const RAIL_COLUMNS = 2;
|
|
5
5
|
export interface RailOptions {
|
|
6
6
|
/** Rows at the top the rail does not run beside, such as a sticky header. */
|
|
7
7
|
readonly topInset?: number;
|
|
8
|
+
/** How the rail shows. Absent draws the thin rail whenever the content overflows. */
|
|
9
|
+
readonly presentation?: ScrollbarPresentation;
|
|
8
10
|
}
|
|
9
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Draws the rail beside each row, padding the rows to a common width first.
|
|
13
|
+
* A presentation that reserves no space returns the rows untouched.
|
|
14
|
+
*/
|
|
10
15
|
export declare function withScrollbarRail(lines: readonly string[], geometry: ScrollbarGeometry | null, contentWidth: number, theme: UiTheme, options?: RailOptions): readonly string[];
|
|
11
16
|
/**
|
|
12
17
|
* What a list shows instead of rows: a mark and a line, both quiet, sitting in
|
|
@@ -3,13 +3,23 @@ import { displayWidth } from "./text.js";
|
|
|
3
3
|
// Rationale: shared list chrome belongs here rather than in individual screens.
|
|
4
4
|
/** Columns the rail occupies: its own, plus the gap before it. */
|
|
5
5
|
export const RAIL_COLUMNS = 2;
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Draws the rail beside each row, padding the rows to a common width first.
|
|
8
|
+
* A presentation that reserves no space returns the rows untouched.
|
|
9
|
+
*/
|
|
7
10
|
export function withScrollbarRail(lines, geometry, contentWidth, theme, options = {}) {
|
|
8
11
|
const inset = options.topInset ?? 0;
|
|
12
|
+
const presentation = options.presentation
|
|
13
|
+
?? { visible: geometry !== null, reservesSpace: true, trackGlyph: "│", thumbGlyph: "│" };
|
|
14
|
+
if (!presentation.reservesSpace)
|
|
15
|
+
return lines;
|
|
16
|
+
const drawn = presentation.visible && geometry !== null;
|
|
9
17
|
return lines.map((line, offset) => {
|
|
10
|
-
const cell = offset < inset ||
|
|
18
|
+
const cell = offset < inset || !drawn
|
|
11
19
|
? " "
|
|
12
|
-
: isThumbRow(geometry, offset - inset)
|
|
20
|
+
: isThumbRow(geometry, offset - inset)
|
|
21
|
+
? theme.fg("accent", presentation.thumbGlyph)
|
|
22
|
+
: theme.fg("dim", presentation.trackGlyph);
|
|
13
23
|
return `${pad(line, contentWidth)} ${cell}`;
|
|
14
24
|
});
|
|
15
25
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const OWNED_UI_SETTINGS_VERSION =
|
|
1
|
+
export declare const OWNED_UI_SETTINGS_VERSION = 6;
|
|
2
2
|
export type OwnedUiSettingValue = string | number | boolean;
|
|
3
3
|
export type OwnedUiSettingApplication = "live" | "restart";
|
|
4
4
|
export interface OwnedUiSettingDeclaration {
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
export const OWNED_UI_SETTINGS_VERSION =
|
|
1
|
+
export const OWNED_UI_SETTINGS_VERSION = 6;
|
|
2
2
|
const MAX_ID_LENGTH = 64;
|
|
3
3
|
const ID_PATTERN = /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)*$/;
|
|
4
|
+
/** Declared first so the settings screen opens on it; sections follow first-declaration order. */
|
|
5
|
+
const GENERIC_SECTION = Object.freeze({ id: "generic", title: "Generic" });
|
|
4
6
|
const SCROLL_SECTION = Object.freeze({ id: "scroll", title: "Scroll" });
|
|
5
7
|
const QUIT_SECTION = Object.freeze({ id: "quit", title: "Quit" });
|
|
6
8
|
/** Playback lengths the quit outro offers, in milliseconds. */
|
|
7
9
|
export const QUIT_EFFECT_DURATIONS_MS = Object.freeze(Array.from({ length: 18 }, (_, index) => 300 + index * 100));
|
|
8
10
|
export const OWNED_UI_SETTING_DECLARATIONS = Object.freeze([
|
|
11
|
+
Object.freeze({
|
|
12
|
+
id: "quitAnimation",
|
|
13
|
+
label: "Exit animation",
|
|
14
|
+
section: GENERIC_SECTION,
|
|
15
|
+
description: "Play the quit effect when the session quits. Off returns to the terminal immediately.",
|
|
16
|
+
application: "live",
|
|
17
|
+
defaultValue: true,
|
|
18
|
+
allowedValues: Object.freeze([true, false]),
|
|
19
|
+
}),
|
|
9
20
|
Object.freeze({
|
|
10
21
|
id: "scrollbarAppearance",
|
|
11
22
|
label: "Scrollbar mode",
|
|
@@ -58,7 +69,7 @@ export const OWNED_UI_SETTING_DECLARATIONS = Object.freeze([
|
|
|
58
69
|
description: "Animation played over the last screen when the session quits.",
|
|
59
70
|
application: "live",
|
|
60
71
|
defaultValue: "fall",
|
|
61
|
-
allowedValues: Object.freeze(["fall", "dissolve", "starburst", "waves"
|
|
72
|
+
allowedValues: Object.freeze(["fall", "dissolve", "starburst", "waves"]),
|
|
62
73
|
}),
|
|
63
74
|
Object.freeze({
|
|
64
75
|
id: "quitEffectDurationMs",
|
|
@@ -36,6 +36,19 @@ export const OWNED_UI_SETTINGS_MIGRATIONS = Object.freeze([
|
|
|
36
36
|
return { ...values };
|
|
37
37
|
},
|
|
38
38
|
}),
|
|
39
|
+
Object.freeze({
|
|
40
|
+
to: 6,
|
|
41
|
+
description: "Move the disabled quit effect into the exit-animation toggle.",
|
|
42
|
+
migrate(values) {
|
|
43
|
+
// Invariant: a profile that chose off keeps quitting without an animation; the
|
|
44
|
+
// effect it stored is no longer a choice, so it resolves to the default.
|
|
45
|
+
if (values.quitEffect !== "off")
|
|
46
|
+
return { ...values };
|
|
47
|
+
const migrated = { ...values, quitAnimation: false };
|
|
48
|
+
delete migrated.quitEffect;
|
|
49
|
+
return migrated;
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
39
52
|
]);
|
|
40
53
|
export function assertOwnedUiSettingsMigrations(migrations, currentVersion = OWNED_UI_SETTINGS_VERSION) {
|
|
41
54
|
const firstProduced = currentVersion - migrations.length + 1;
|
|
@@ -29,7 +29,7 @@ its own `core` facade layer; A1 is a product, so the port adapts imports and kee
|
|
|
29
29
|
| `ui-components/list-block.ts` — sticky scroll | `settings/impl.ts` — `stickyHeaderGroup`, `topPaddingRows`, `visibleRowCountAt`, `clampScrollForView` | Same reservation arithmetic and two-pass reveal. |
|
|
30
30
|
| `ui-components/mouse.ts` | `core/panes/sgr-mouse.ts` | Same SGR decoding and per-call regex reset; A1 emits its own event shape. |
|
|
31
31
|
| `ui-components/mouse.ts` — tracking sequences | `core/host/pi/providers/host-bridge-surface.ts` | Mouse modes only. A1 does not take the alternate screen, because the Pi TUI owns the screen A1 renders through. |
|
|
32
|
-
| `features/owned-ui/settings-app.ts` — section layout, pointer controls, and scrolling | `settings/impl.ts` — `settingsValueColumn`, block navigation, sticky sections, and pointer hit regions | Setting discovery is A1's own A1/Agent section model. Section navigation and pointer-only numeric controls follow the A1 reference; explicit `/` search, ruled shared input, shortcut-derived hints, hidden description rows, configured wheel cadence, and distinct floating scalar menus are reviewed owned interactions. |
|
|
32
|
+
| `features/owned-ui/settings-app.ts` — section layout, pointer controls, and scrolling | `settings/impl.ts` — `settingsValueColumn`, block navigation, sticky sections, and pointer hit regions | Setting discovery is A1's own A1/Agent section model. Section navigation and pointer-only numeric controls follow the A1 reference; explicit `/` search, ruled shared input, shortcut-derived hints, hidden description rows, configured wheel cadence, a rail presented through the shared scrollbar settings, `Ctrl+Home`/`Ctrl+End` boundary jumps, and distinct floating scalar menus are reviewed owned interactions. |
|
|
33
33
|
|
|
34
34
|
## Ported from the pinned engine
|
|
35
35
|
|
|
@@ -39,7 +39,7 @@ its own `core` facade layer; A1 is a product, so the port adapts imports and kee
|
|
|
39
39
|
| `pi-engine-adapter/adapter.ts`, `engine/workflows.ts`, and `pi-session-ui/session-shell.ts` — interactive command outcomes | Pi 0.84.2 `modes/interactive/interactive-mode.ts`, `core/model-resolver.ts`, and `config.ts` at commit `914cf1472e715297caa30db4b9535d534a9eb718` | Preserves route-specific status/warning/error semantics, authentication partial-success sequences, stored-credential logout wording, fork/clone empty states, import context/recovery, share URL and failure/cancellation behavior, and post-login warning lifetime. A1 deliberately returns recoverable failed workflow results instead of adopting Pi's process-owning shutdown/exit for fatal `/new`, `/resume`, and `/import` outcomes. Focused outcome evidence is in `test/integrations/pi/engine/workflows.test.ts` and `test/integrations/pi/session-ui/session-shell.test.ts`; terminal-cell geometry remains a separate milestone. |
|
|
40
40
|
| `pi-engine-adapter/settings-integration.ts` — `SETTING_LABELS` | pinned Pi settings selector | Labels and descriptions transcribed so an owned screen reads as the vanilla route words it. Ids are mapped from the selector kebab-case to the exposed camelCase keys. |
|
|
41
41
|
| `pi-engine/session-integration.ts` and `pi-components/shell-footer-status.ts` — steering queue | pinned Pi interactive mode `onSubmit` and `updatePendingMessagesDisplay` | Steering/follow-up uses `prompt(..., { streamingBehavior })`, allowing Pi to emit the accepted user row, while remaining steering rows preserve Pi's opening spacer, dim `Steering:` labels, dequeue hint, and order before `Working`. |
|
|
42
|
-
| `ui-components/list-view.ts`, `dialog-panel.ts`, `value-menu.ts`, and `features/owned-ui/settings-app.ts` — setting presentation | pinned Pi `SettingsSelectorComponent`, Pi TUI `SettingsList`, `SelectList`, and `Input` at `0.84.2` | Cursor
|
|
42
|
+
| `ui-components/list-view.ts`, `dialog-panel.ts`, `value-menu.ts`, and `features/owned-ui/settings-app.ts` — setting presentation | pinned Pi `SettingsSelectorComponent`, Pi TUI `SettingsList`, `SelectList`, and `Input` at `0.84.2` | Cursor and selected label accents, unselected muted values, the 30-column label cap, dialog styling, notices, and narrow-width geometry retain pinned semantics. Scalar-menu placement and input remain shared, while the muted, hover-brightened selected value, A1/Agent grouping, pointer steppers, explicit `/` search, ruled shared input, shortcut-derived status hints, suppressed selected descriptions, `scrollbarSpeed`-driven wheel movement, a list rail that follows `scrollbarAppearance` and `scrollbarStyle` with transcript-style hover and drag, `Ctrl+Home`/`Ctrl+End` boundary jumps, and the dark floating menu with lighter active row and effective-value check mark are declared product-owned differences. Independent row evidence: `test/features/owned-ui/pinned-settings-presentation-parity.test.ts`; owned interaction evidence: `test/features/owned-ui/settings-app.test.ts`, `test/ui/components/value-menu.test.ts`, and `test/composition/settings-route-host.test.ts`. |
|
|
43
43
|
| `features/owned-ui/project-trust-prompt.ts` — pre-resource selector | pinned Pi `cli/startup-ui.ts`, `cli/project-trust.ts`, and `core/project-trust.ts` at commit `914cf1472e715297caa30db4b9535d534a9eb718` | Uses a fixed, dependency-bounded A1 startup selector rather than importing private CLI modules. It preserves selected-option accent, navigation/accept/reject/cancel semantics, fail-closed behavior, raw-mode restoration, clearing, cursor restoration, and parent-screen restoration before diagnostics. |
|
|
44
44
|
| `pi-session-ui/session-shell-root.ts` and `session-shell.ts` — fullscreen exit | pinned Pi `InteractiveMode.formatResumeCommand()` and shutdown output at commit `914cf1472e715297caa30db4b9535d534a9eb718` | Re-renders authoritative transcript components with semantic SGR intact, excludes inline-image control payloads and fullscreen-only chrome, restores the terminal first, then emits pinned dim `To resume this session:` wording with `a1`, compact session id, and conditional quoted `--session-dir`. |
|
|
45
45
|
| `pi-tui-runtime/input-presentation-coordinator.ts` and custom-viewport dock reuse | pinned Pi TUI `TuiBase` input dispatch and immediate-render pending guard at `0.84.2` | Preserves each original terminal delivery and invokes the existing Pi handlers exactly once in order. Bare A1 alone drains finite-grammar text/edit/navigation bursts in one immediate event-loop opportunity so Pi's existing pending guard paints the newest state once; effectful, unknown, protocol, paste, and extension-owned input remains an immediate barrier. Geometry-stable dock frames reuse A1's established transcript viewport, while uncertain geometry and replacement-surface damage still fail closed. Independent evidence is under `test/support/input-responsiveness/`. |
|
|
@@ -4,9 +4,35 @@ Local cleanup completes the delivery order in [the archive runbook](openspec-arc
|
|
|
4
4
|
|
|
5
5
|
The implementation is repository tooling, not part of the installed A1 product. It requires Node, Git, and GitHub read access; the explicit closed-unmerged discard operation additionally requires authenticated permission to delete its exact remote topic ref. No product build, dependency installation, interactive UI, or OS-service provisioning is needed. It supports this repository's `origin` on github.com, via HTTPS or SSH.
|
|
6
6
|
|
|
7
|
+
## Hand-off and sweep: the ordinary agent path
|
|
8
|
+
|
|
9
|
+
The delivering agent's session usually ends before the maintainer merges, so cleanup is split into two commands that never need the same session. When the validated PR is handed to the maintainer, and again after any repair push, the owning agent parks the worktree from the primary checkout:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node scripts/governance/local-worktree-cleanup.mjs handoff --repo D:/Git/a1 --path D:/Git/a1/.worktrees/example-task --change example-change --pr 123
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`handoff` registers the exact worktree if it is not registered yet (or reclaims the existing released entry), records its current HEAD and branch, applies the central disposable policy, and releases it. It evaluates nothing, deletes nothing, and does not enable the queue. That release is the candidate-scoped cleanup authorization for this worktree and its topic ref, exercised only once the merge, archive, validation, and remote-ref gates verify later; it never becomes discard authority for a PR that is later closed unmerged. It is idempotent per worktree: repeating it updates the recorded head instead of adding a second registration. Tracked, staged, unstaged, or untracked content outside the disposable roots blocks with `worktree-content` and the affected paths, because such content means unpushed work; unknown ignored content is judged at removal time, as it is for `complete`. A primary, foreign, replaced, or branch-changed path blocks with a named reason. A worktree that a session registered with the low-level `register` command and still owns is released by `handoff` (or `complete`) when `LOCAL_CLEANUP_OWNER_TOKEN` holds that registration's token; without it both block with `owned-worktree`, and a separate `release` is needed.
|
|
16
|
+
|
|
17
|
+
Every delivery session then starts, before it creates a worktree, with one bounded sweep from the primary checkout, and runs it again when it verifies or is told of a merge:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
node scripts/governance/local-worktree-cleanup.mjs sweep --repo D:/Git/a1
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`sweep` evaluates every released registration in one pass under the queue limits (100 registrations, 500 remote requests, a durable round-robin cursor) with a 180-second elapsed budget, since each merged candidate costs three evidence loads, and completes each candidate whose PR is verified merged using exactly the `complete` safeguards. It needs no `enable` and starts no process: its authority is each candidate's release. A candidate whose PR is open, draft, or not yet finalized reports `pending`; a candidate whose PR closed without merge reports `awaiting-discard` and is never touched; blockers report their exact reason. An old stop sentinel does not prevent a sweep, but `disable` run while a sweep is executing stops it before its next destructive step. If another session holds the mutation lock the sweep reports `mutation-busy` and the agent relays it as deferred rather than waiting. The JSON report carries a `lines` array with one relayable line per candidate and pruned branch, for example `#458 close-absent-and-skewed-cleanup: removed [worktree-removed, local-ref-removed]`. Pending or blocked results never delay the new delivery.
|
|
24
|
+
|
|
25
|
+
### Accepted ancestry
|
|
26
|
+
|
|
27
|
+
Since the `OpenSpec finalization` workflow pushes its commit onto the PR branch after the agent's last push, the registered head is normally one commit behind the merged head. Cleanup therefore accepts a registered head, live worktree HEAD, or local topic-ref tip that equals the merged PR head or is one of its ancestors on GitHub, verified with the compare API: every commit reachable from such a tip is reachable from a head the maintainer accepted, so nothing is lost. A tip that holds a commit outside the merged head, or a commit GitHub does not know, still blocks with `candidate-head-association`, `worktree-identity-changed`, or `local-ref-advanced`, and the branch attachment must still be the registered one. Ref deletion reads the tip immediately before deleting, requires it to be accepted, and uses it as the compare-and-delete expectation. Ancestry of `develop` is never used, because the repository squash-merges.
|
|
28
|
+
|
|
29
|
+
### Merged branch pruning
|
|
30
|
+
|
|
31
|
+
The sweep also prunes local topic branches that have no live registration, such as the branch left behind when a worktree was deleted by hand. A branch is deleted only when all of these hold: its name follows `type/short-description` and is not `develop` or another protected or reserved name; no worktree has it checked out; the same-repository pull-request lookup by that head ref name returns at least one PR merged into `develop` and no open PR; the tip equals or is an ancestor of the most recent such merged PR's head; and `origin` no longer has the ref. Deletion is compare-and-delete against the tip reread immediately before. Everything else is reported and kept with `branch-no-pull-request`, `branch-open-pull-request`, `branch-closed-pull-request`, `branch-unmerged-commits`, `branch-checked-out`, or `branch-remote-present`. Branch pruning never deletes remote refs, worktrees, or registrations, and a preview never prunes.
|
|
32
|
+
|
|
7
33
|
## Standard completed-delivery command
|
|
8
34
|
|
|
9
|
-
After authorized merge, accepted/archive verification, and remote topic-ref removal, the owning agent runs one command from the primary checkout:
|
|
35
|
+
The exact-candidate form remains for a session that is still alive at merge time or wants to finish one candidate by name. After authorized merge, accepted/archive verification, and remote topic-ref removal, the owning agent runs one command from the primary checkout:
|
|
10
36
|
|
|
11
37
|
```bash
|
|
12
38
|
node scripts/governance/local-worktree-cleanup.mjs complete \
|
|
@@ -20,7 +46,7 @@ node scripts/governance/local-worktree-cleanup.mjs complete \
|
|
|
20
46
|
|
|
21
47
|
The central disposable policy is `node_modules`, `dist`, `.builds`, `.artifacts`, `native/process-guardian/target`, and `native/terminal-host/target`. The `.artifacts` root is the repository's ignored generated-artifact root (finalization and validation reports, packed candidates, agent-written logs and diffs); the two native roots contain repository-generated Cargo output. Registrations that still name `.artifacts/openspec-archive` or `.artifacts/validation` stay valid and are widened to the root on the next `complete`. Each encountered path must be ignored and stay inside the exact worktree with no link, special file, or nested repository boundary. Authority is component-exact: near matches such as `.artifacts-user` or `artifacts`, arbitrary `target` directories, and sibling native projects remain blocking. Tracked/staged/unstaged/untracked content and every unknown ignored path still block. A tracked regular `.gitmodules` file alone is ordinary content; actual nested `.git` metadata, gitlinks, configured submodules, and submodule changes block. Ordinary content and these approved generated roots are traversed under separate finite entry allowances, so a normal dependency installation does not consume the ordinary source-tree allowance; both allowances retain the same deadline and content-boundary checks. Once those checks pass, cleanup deletes the declared disposable roots itself with a bounded retry for transient Windows sharing violations before handing the worktree to Git, so non-force Git removal only has to delete tracked content. A root that stays locked after the retry budget reports `blocked` with `disposable-path-locked` and the root's path; the worktree, its `.git` pointer, and its journal are untouched, so stop the process holding the handle and rerun the same command.
|
|
22
48
|
|
|
23
|
-
Agents do not manually remove generated content, call `git worktree remove`, or delete the local branch after delivery. The JSON result is authoritative: report success only for `removed` or verified `already-absent`; otherwise retain the worktree and report the exact blocker. Legacy roles can supply separate `--source-pr`, `--candidate-pr`, and `--role` values.
|
|
49
|
+
Agents do not manually remove generated content, call `git worktree remove`, or delete the local branch after delivery. The JSON result is authoritative: report success only for `removed` or verified `already-absent`; otherwise retain the worktree and report the exact blocker. A handed-off entry whose worktree was deleted by hand completes through its journal, and its branch is deleted under the accepted-ancestry rule. Legacy roles can supply separate `--source-pr`, `--candidate-pr`, and `--role` values.
|
|
24
50
|
|
|
25
51
|
## Explicit closed-unmerged discard
|
|
26
52
|
|
|
@@ -54,7 +80,7 @@ Remote reads use `GH_TOKEN`/`GITHUB_TOKEN`, otherwise existing `gh auth token --
|
|
|
54
80
|
|
|
55
81
|
## Explicit queue/watch enablement
|
|
56
82
|
|
|
57
|
-
|
|
83
|
+
Neither `handoff`, `sweep`, nor the exact-candidate `complete` command enables the persistent queue; `sweep` scans released registrations under its own authority, while `once`/`watch` remain the separately enabled background route. Only after the maintainer separately authorizes queue/watch activation, use the reviewed tool in the primary/stable checkout outside `.worktrees/`:
|
|
58
84
|
|
|
59
85
|
```bash
|
|
60
86
|
node scripts/governance/local-worktree-cleanup.mjs enable --repo D:/Git/a1
|
|
@@ -123,14 +149,15 @@ A session crash does not release ownership. After separately confirming that the
|
|
|
123
149
|
node scripts/governance/local-worktree-cleanup.mjs recover --repo D:/Git/a1 --id REGISTRATION_ID --generation CURRENT_GENERATION --confirm-stopped
|
|
124
150
|
```
|
|
125
151
|
|
|
126
|
-
Recovery never releases or deletes. The generation must still match. A Git worktree lock remains a veto. A leftover `mutation.lock` from a
|
|
152
|
+
Recovery never releases or deletes. The generation must still match. A Git worktree lock remains a veto. A leftover `mutation.lock` from a killed cleanup process is evicted only on proof: the holder writes its PID and refreshes a heartbeat in the lock every five seconds, and a later `handoff`, `sweep`, `complete`, `discard`, claim, or queue pass evicts the lock only when that heartbeat (or, for an old lock without one, the file's modification time) is more than two minutes old and the PID no longer exists. A live, unprobeable, or own PID keeps the lock, as does a fresh or unreadable-but-fresh file, and the operation reports `mutation-busy`. Each eviction is journaled as `lock-evicted-<time>-<id>.json` beside `state.json` with the evicted record; it releases no ownership and advances no journal step. A lock that stays `mutation-busy` therefore has a live holder: wait for it or stop that process first.
|
|
127
153
|
|
|
128
154
|
## Outcomes and interruption handling
|
|
129
155
|
|
|
130
156
|
State, journals, stop controls, and execution reports live in `<git-common-dir>/local-worktree-cleanup/`, outside removable checkouts. `status` omits owner-token hashes. Reports contain identities, local blockers, performed steps, and coverage, never file contents or credentials. Completed reports are retained for 30 days subject to a 10 MiB cap; unresolved queue/journal records are not evicted.
|
|
131
157
|
|
|
132
158
|
- `eligible`: preview passed the gates; nothing was removed.
|
|
133
|
-
- `pending`: archive/ref prerequisites have not finished.
|
|
159
|
+
- `pending`: archive/ref prerequisites have not finished, or the handed-off PR is still open.
|
|
160
|
+
- `awaiting-discard`: the handed-off PR closed without merge; only the explicit `discard` command may act.
|
|
134
161
|
- `blocked`: ownership, content, path, provenance, authentication, or identity needs attention.
|
|
135
162
|
- `unmanaged`: no local registration; no automatic adoption.
|
|
136
163
|
- `removed`: worktree and eligible local-ref operations were verified.
|
|
@@ -138,7 +165,7 @@ State, journals, stop controls, and execution reports live in `<git-common-dir>/
|
|
|
138
165
|
- `partial`: a destructive step began but all cleanup could not be verified; the same command resumes it.
|
|
139
166
|
- `deferred`: a bounded pass or concurrent mutation owner prevented evaluation.
|
|
140
167
|
|
|
141
|
-
Non-force Git removal is the only operation that deletes tracked content from an intact worktree. Local topic-ref deletion then compares the
|
|
168
|
+
Non-force Git removal is the only operation that deletes tracked content from an intact worktree. Local topic-ref deletion then compares the tip read immediately before, which must be the journaled head or an accepted ancestor of the merged head, and refuses refs checked out elsewhere. `develop`, primary/current directories, changed heads, and active sessions are protected. Normal removal retires its own Git worktree registration; unrelated stale/missing registrations are never globally pruned.
|
|
142
169
|
|
|
143
170
|
A released worktree whose directory was deleted by hand before cleanup ran is finished through its journal rather than failing on the missing directory: the same merge/archive evidence is verified, Git may hold no registration for the path or only this candidate's own dangling one, that registration is retired, the step is recorded as `worktree-already-absent`, and the unchanged local topic ref is deleted under the usual compare-and-delete rule. A `complete` invocation for an absent path that was never registered blocks with `worktree-absent-unregistered`, because there is no journaled head to compare the ref against.
|
|
144
171
|
|
|
@@ -15,7 +15,7 @@ Version-1 and version-2 deliveries and their existing comments, acceptance PRs,
|
|
|
15
15
|
5. **Ready and automated finalization:** mark the PR ready. The trusted `OpenSpec finalization` workflow reconciles current `develop`, conservatively synchronizes all deltas, moves the active change into its dated archive, stages the conditional acceptance manifest, commits that to the same branch with the archive App identity, and writes the emitted paths into the body's implementation fence. Running the [finalization command](#finalization-command) locally first is optional and yields the same bytes.
|
|
16
16
|
6. **Validate:** one normal exact-head workflow validates the finalized head: implementation, synchronized specs, archive, manifest, tasks/evidence, exact PR-body list, and every selected product/governance scope before emitting the stable protected aggregate. A new commit or acceptance-list change re-finalizes automatically when needed and requires full renewed validation; no lifecycle body edit or second workflow run is required.
|
|
17
17
|
7. **Manual merge accepts:** after the stable protected aggregate succeeds, an authorized human reviews and manually merges the exact validated head. That single action means the listed scenarios are accepted and explicitly authorizes integration. Auto-merge, merge queue, Apps, bots, and documentation reconciliation are forbidden.
|
|
18
|
-
8. **Verify and clean:** trusted post-merge policy derives `Archived` and reports `accepted-and-archived` from committed bytes and immutable GitHub provenance without editing the accepted PR body. It publishes no lifecycle branch or PR. Shared exact-head remote cleanup may delete the unchanged topic ref; local cleanup remains separately ownership-controlled.
|
|
18
|
+
8. **Verify and clean:** trusted post-merge policy derives `Archived` and reports `accepted-and-archived` from committed bytes and immutable GitHub provenance without editing the accepted PR body. It publishes no lifecycle branch or PR. Shared exact-head remote cleanup may delete the unchanged topic ref; local cleanup remains separately ownership-controlled: the agent parks the worktree with `handoff` at step 7, and the next session's `sweep` removes it once the merge, archive, and remote-ref evidence verify (see [local cleanup](local-worktree-cleanup.md)).
|
|
19
19
|
|
|
20
20
|
The implementation, synchronized canonical specs, conditional acceptance record, and archive therefore reach `develop` atomically. Closing the PR unmerged integrates none of them.
|
|
21
21
|
|
package/package.json
CHANGED