@timurproko/a1 0.1.8-dev.447 → 0.1.8-dev.457

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +9 -0
  2. package/dist/composition/owned-ui.js +11 -0
  3. package/dist/contracts/owned-ui/model.d.ts +10 -0
  4. package/dist/integrations/pi/components/shell-editor-autocomplete.js +1 -0
  5. package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +2 -0
  6. package/dist/integrations/pi/components/upstream/components/owned-editor.js +17 -0
  7. package/dist/integrations/pi/engine/adapter.d.ts +2 -0
  8. package/dist/integrations/pi/engine/adapter.js +25 -6
  9. package/dist/integrations/pi/engine/settings-effects.d.ts +1 -1
  10. package/dist/integrations/pi/engine/settings-effects.js +1 -1
  11. package/dist/integrations/pi/session-ui/quit-outro-effects.d.ts +29 -0
  12. package/dist/integrations/pi/session-ui/quit-outro-effects.js +242 -0
  13. package/dist/integrations/pi/session-ui/quit-outro.d.ts +38 -0
  14. package/dist/integrations/pi/session-ui/quit-outro.js +98 -0
  15. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +12 -1
  16. package/dist/integrations/pi/session-ui/session-shell.js +57 -5
  17. package/dist/integrations/pi/tui-runtime/adapter.d.ts +10 -2
  18. package/dist/integrations/pi/tui-runtime/adapter.js +53 -4
  19. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +7 -0
  20. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +12 -0
  21. package/dist/native/darwin-arm64/manifest.json +1 -1
  22. package/dist/native/linux-x64/manifest.json +1 -1
  23. package/dist/native/win32-x64/manifest.json +2 -2
  24. package/dist/native/win32-x64/process-guardian.exe +0 -0
  25. package/dist/ui/settings/declarations.d.ts +3 -1
  26. package/dist/ui/settings/declarations.js +22 -1
  27. package/dist/ui/settings/migrations.js +7 -0
  28. package/docs/ci-release-runbook.md +5 -5
  29. package/docs/local-worktree-cleanup.md +8 -4
  30. package/docs/manual-owned-ui-checkpoint.md +3 -2
  31. package/docs/openspec-archive-automation.md +21 -8
  32. package/docs/validation.md +4 -2
  33. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import type { OwnedUiPromptSuggestionGeneratorPort, OwnedUiSessionViewModel, OwnedUiViewportSettings, OwnedUiViewportSettingsPort, SuggestionDecision, SuggestionDiagnosticObserver } from "../../../contracts/owned-ui/index.js";
1
+ import type { OwnedUiPromptSuggestionGeneratorPort, OwnedUiSessionViewModel, OwnedUiViewportSettings, OwnedUiViewportSettingsPort, OwnedUiQuitOutroSettingsPort, SuggestionDecision, SuggestionDiagnosticObserver } from "../../../contracts/owned-ui/index.js";
2
2
  import type { PiTuiPointerSurface } from "../tui-runtime/contracts.js";
3
3
  import type { UiRouteHost } from "../../../ui/apps/contracts.js";
4
4
  import type { TranscriptViewportFrame, TranscriptViewportFrameDescriptor } from "../../../ui/components/transcript-viewport.js";
@@ -47,6 +47,17 @@ export interface OwnedUiSessionShellOptions {
47
47
  readonly sessionLayout?: "pinned" | "custom-viewport";
48
48
  /** Live profile-local settings, supplied only to the bare-A1 composition. */
49
49
  readonly viewportSettings?: OwnedUiViewportSettingsPort;
50
+ /**
51
+ * Quit outro settings and seams, supplied only to the bare-A1 composition.
52
+ * `interactive` states whether the terminal can show the animation; production
53
+ * passes stdout's TTY state, tests opt in explicitly.
54
+ */
55
+ readonly quitOutro?: OwnedUiQuitOutroSettingsPort & {
56
+ readonly interactive: boolean;
57
+ readonly now?: () => number;
58
+ readonly sleep?: (ms: number) => Promise<void>;
59
+ readonly seed?: number;
60
+ };
50
61
  /** Optional platform seam; production uses A1's system clipboard adapter. */
51
62
  readonly clipboard?: OwnedUiClipboardPort;
52
63
  /** Owned response-copy transport and payload-free diagnostic seams. Comparison profiles ignore them. */
@@ -55,6 +55,7 @@ export class OwnedUiSessionShell {
55
55
  #responseCopy;
56
56
  #unbindClipboardWriter;
57
57
  #damageTerminal;
58
+ #quitOutro;
58
59
  #streamPresentation;
59
60
  #removeViewportPreInput;
60
61
  #unsubscribeSettings;
@@ -250,6 +251,7 @@ export class OwnedUiSessionShell {
250
251
  runtime = new PiTuiRuntimeAdapter(runtimeOptions);
251
252
  this.runtime = runtime;
252
253
  this.#damageTerminal = damageTerminal ?? null;
254
+ this.#quitOutro = options.quitOutro;
253
255
  const presentationInterval = options.streamPresentation?.intervalMs ?? STREAM_PRESENTATION_INTERVAL_MS;
254
256
  streamPresentation = options.streamPresentation?.scheduler === undefined
255
257
  ? new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval)
@@ -282,7 +284,9 @@ export class OwnedUiSessionShell {
282
284
  this.runtime.setClearOnShrink(initialPiSettings.clearOnShrink);
283
285
  this.#terminalProgressEnabled = initialPiSettings.showTerminalProgress;
284
286
  this.#fullscreenExitOutput = initialPiSettings.fullscreenExitOutput;
285
- this.#unbindShutdownSettings = this.backend.bindSettingsOwner("shutdown", {
287
+ // Invariant: bare A1 prints only the resume hint at exit, so the pinned exit-output
288
+ // choice is hidden there and cannot be bound; the comparison profile binds and honors it.
289
+ this.#unbindShutdownSettings = this.backend.settingsProductMode === "bare" ? () => { } : this.backend.bindSettingsOwner("shutdown", {
286
290
  fullscreenExitOutput: { apply() { } },
287
291
  });
288
292
  this.#unbindTerminalSettings = this.backend.bindSettingsOwner("terminal", {
@@ -1350,6 +1354,8 @@ export class OwnedUiSessionShell {
1350
1354
  }
1351
1355
  async #dispose() {
1352
1356
  this.#disposed = true;
1357
+ // Invariant: the outro frame is what the terminal shows now, before any cleanup writes.
1358
+ const outroFrame = this.#captureQuitOutroFrame();
1353
1359
  this.#responseCopy?.dispose();
1354
1360
  this.#cancelWaitingImages();
1355
1361
  const failures = [];
@@ -1373,11 +1379,13 @@ export class OwnedUiSessionShell {
1373
1379
  let fullscreenExitText = "";
1374
1380
  attempt(() => {
1375
1381
  const exitMode = this.backend.disposed ? this.#fullscreenExitOutput : this.backend.pinnedSettingsSnapshot().fullscreenExitOutput;
1376
- const exitTranscript = this.root.exitTranscript(this.runtime.viewport().columns);
1377
1382
  const resume = this.backend.currentSessionResumeMetadata();
1378
1383
  const resumeHint = resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
1384
+ // Invariant: bare A1 leaves only the hint behind; the pinned comparison profile still
1385
+ // honors fullscreenExitOutput, including the styled transcript.
1379
1386
  fullscreenExitText = this.runtime.mode !== "fullscreen" ? ""
1380
- : exitMode === "resume-hint" ? resumeHint : [exitTranscript, resumeHint].filter(Boolean).join("\n\n");
1387
+ : this.#customViewport || exitMode === "resume-hint" ? resumeHint
1388
+ : [this.root.exitTranscript(this.runtime.viewport().columns), resumeHint].filter(Boolean).join("\n\n");
1381
1389
  });
1382
1390
  attempt(() => this.#unbindClipboardWriter());
1383
1391
  attempt(() => this.#unbindPiSettings());
@@ -1386,8 +1394,11 @@ export class OwnedUiSessionShell {
1386
1394
  attempt(() => this.#unsubscribe());
1387
1395
  attempt(() => this.#dialogHandle?.hide());
1388
1396
  attempt(() => this.#extensionBridge.dispose());
1389
- // Invariant: terminal restoration precedes any potentially stalled backend teardown.
1390
- await this.runtime.dispose().catch(error => failures.push(error));
1397
+ await this.#playQuitOutro(outroFrame);
1398
+ // Invariant: terminal restoration precedes any potentially stalled backend teardown. The
1399
+ // fullscreen leave preserves the screen: the pinned runtime never dumps its final document
1400
+ // into the parent terminal, so only the configured exit text follows the leave.
1401
+ await this.runtime.dispose({ preserveScreen: this.runtime.mode === "fullscreen" }).catch(error => failures.push(error));
1391
1402
  await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
1392
1403
  await boundedCleanup(() => pasteCleanup).catch(error => failures.push(error));
1393
1404
  await boundedCleanup(() => this.backend.unbindExtensionUi()).catch(error => failures.push(error));
@@ -1396,6 +1407,47 @@ export class OwnedUiSessionShell {
1396
1407
  if (fullscreenExitText.length > 0)
1397
1408
  this.runtime.writeAfterStop(`${fullscreenExitText}\n`);
1398
1409
  }
1410
+ // Invariant: the snapshot is synchronous; the outro module itself loads only at quit.
1411
+ #captureQuitOutroFrame() {
1412
+ const outro = this.#quitOutro;
1413
+ if (outro === undefined || !outro.interactive || !this.#customViewport || this.#damageTerminal === null)
1414
+ return null;
1415
+ if (!this.runtime.active || this.runtime.mode !== "fullscreen")
1416
+ return null;
1417
+ try {
1418
+ const { effect, durationMs } = outro.snapshot();
1419
+ if (effect === "off")
1420
+ return null;
1421
+ const viewport = this.runtime.viewport();
1422
+ return { rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows, settings: { effect, durationMs } };
1423
+ }
1424
+ catch {
1425
+ return null;
1426
+ }
1427
+ }
1428
+ // Rationale: any failure here only skips the effect; restoration always follows.
1429
+ async #playQuitOutro(capture) {
1430
+ const outro = this.#quitOutro;
1431
+ if (capture === null || outro === undefined || !this.runtime.active)
1432
+ return;
1433
+ try {
1434
+ // Rationale: the effects stay off the startup graph; quit is the only time they load.
1435
+ const { captureQuitOutroFrame, playQuitOutro } = await import("./quit-outro.js");
1436
+ const frame = captureQuitOutroFrame(capture.rows, capture.columns, capture.height);
1437
+ if (frame === null || !this.runtime.active)
1438
+ return;
1439
+ this.runtime.freezePresentation();
1440
+ await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
1441
+ write: data => this.runtime.writeControl(data),
1442
+ ...(outro.now === undefined ? {} : { now: outro.now }),
1443
+ ...(outro.sleep === undefined ? {} : { sleep: outro.sleep }),
1444
+ ...(outro.seed === undefined ? {} : { seed: outro.seed }),
1445
+ });
1446
+ }
1447
+ catch {
1448
+ // Rationale: a failed or interrupted effect must never hold the terminal; restoration follows.
1449
+ }
1450
+ }
1399
1451
  #settleStoppedLifecycle() {
1400
1452
  void this.dispose().then(() => this.#resolveStoppedLifecycle(), () => this.#resolveStoppedLifecycle());
1401
1453
  }
@@ -26,10 +26,18 @@ export declare class PiTuiRuntimeAdapter {
26
26
  hasFocusedOverlay(): boolean;
27
27
  /**
28
28
  * Writes a terminal control sequence. Used to enable and disable mouse
29
- * reporting while an A1-owned application is presented, and for nothing else:
30
- * the transparent and pinned paths never call it.
29
+ * reporting while an A1-owned application is presented and to paint the quit
30
+ * outro while presentation is frozen, and for nothing else: the transparent
31
+ * and pinned paths never call it.
31
32
  */
32
33
  writeControl(data: string): void;
34
+ /**
35
+ * Drops every pinned frame write until the runtime stops. The quit outro owns
36
+ * the alternate screen between the last presented frame and the leave; the
37
+ * renderer keeps scheduling but nothing it paints reaches the terminal.
38
+ */
39
+ freezePresentation(): void;
40
+ get presentationFrozen(): boolean;
33
41
  addPreInputListener(listener: PiTuiPreInputListener): () => void;
34
42
  addInputListener(listener: PiTuiInputListener): () => void;
35
43
  switchMode(mode: "regular" | "fullscreen"): boolean;
@@ -120,15 +120,20 @@ export class PiTuiRuntimeAdapter {
120
120
  #stopPromise;
121
121
  #rootDisposed = false;
122
122
  #terminalProgress = false;
123
+ #presentationFrozen = false;
123
124
  constructor(options) {
124
125
  this.#root = options.root;
125
126
  this.#overlayGeometry = options.onOverlayGeometry === undefined ? undefined : new OverlayGeometryTracker(options.onOverlayGeometry);
126
127
  this.#terminal = options.terminal ?? new ProcessTerminal();
127
128
  this.#inputDiagnostics = options.inputDiagnostics;
128
129
  this.#diagnosticNow = options.inputDiagnostics?.now ?? (() => performance.now());
130
+ // Invariant: the pinned renderer's frames pass through this gate, while writeControl and
131
+ // the stop sequence reach the terminal directly. Freezing drops frames without touching
132
+ // the renderer, so a scheduled repaint cannot land on top of the quit outro.
133
+ const gatedTerminal = frozenGateTerminal(this.#terminal, () => this.#presentationFrozen);
129
134
  const tracedTerminal = options.inputDiagnostics === undefined
130
- ? this.#terminal
131
- : diagnosticTerminal(this.#terminal, phase => this.#traceRuntimePhase(phase));
135
+ ? gatedTerminal
136
+ : diagnosticTerminal(gatedTerminal, phase => this.#traceRuntimePhase(phase));
132
137
  const decoratedTerminal = options.decorateTerminal?.(tracedTerminal) ?? tracedTerminal;
133
138
  const coordination = options.inputCoordination ?? (options.inputDiagnostics === undefined
134
139
  ? undefined
@@ -297,13 +302,26 @@ export class PiTuiRuntimeAdapter {
297
302
  }
298
303
  /**
299
304
  * Writes a terminal control sequence. Used to enable and disable mouse
300
- * reporting while an A1-owned application is presented, and for nothing else:
301
- * the transparent and pinned paths never call it.
305
+ * reporting while an A1-owned application is presented and to paint the quit
306
+ * outro while presentation is frozen, and for nothing else: the transparent
307
+ * and pinned paths never call it.
302
308
  */
303
309
  writeControl(data) {
304
310
  this.#assertRunning("control sequence");
305
311
  this.#terminal.write(data);
306
312
  }
313
+ /**
314
+ * Drops every pinned frame write until the runtime stops. The quit outro owns
315
+ * the alternate screen between the last presented frame and the leave; the
316
+ * renderer keeps scheduling but nothing it paints reaches the terminal.
317
+ */
318
+ freezePresentation() {
319
+ this.#assertRunning("presentation freeze");
320
+ this.#presentationFrozen = true;
321
+ }
322
+ get presentationFrozen() {
323
+ return this.#presentationFrozen;
324
+ }
307
325
  addPreInputListener(listener) {
308
326
  if (this.#preInputListeners.has(listener))
309
327
  throw new TypeError("Pi TUI pre-input listener is already registered");
@@ -433,6 +451,9 @@ export class PiTuiRuntimeAdapter {
433
451
  }
434
452
  try {
435
453
  const stopOptions = options.preserveScreen === undefined ? undefined : { preserveScreen: options.preserveScreen };
454
+ // Invariant: the gate opens only for the synchronous stop sequence, so no render
455
+ // scheduled during the outro can slip in before the alternate screen is left.
456
+ this.#presentationFrozen = false;
436
457
  this.#tui.stop(stopOptions);
437
458
  }
438
459
  catch (error) {
@@ -566,6 +587,7 @@ export class PiTuiRuntimeAdapter {
566
587
  throw new Error(`Pi TUI ${operation} requires a running runtime`);
567
588
  }
568
589
  #restoreAfterFailedStart() {
590
+ this.#presentationFrozen = false;
569
591
  try {
570
592
  this.#tui.stop();
571
593
  }
@@ -581,6 +603,7 @@ export class PiTuiRuntimeAdapter {
581
603
  this.#terminalProgress = false;
582
604
  }
583
605
  #bestEffortTerminalRestore() {
606
+ this.#presentationFrozen = false;
584
607
  this.#clearTerminalProgress();
585
608
  try {
586
609
  if (this.mode === "fullscreen")
@@ -676,6 +699,32 @@ function diagnosticTerminal(terminal, trace) {
676
699
  setProgress: active => terminal.setProgress(active),
677
700
  };
678
701
  }
702
+ function frozenGateTerminal(terminal, frozen) {
703
+ return {
704
+ get columns() { return terminal.columns; },
705
+ get rows() { return terminal.rows; },
706
+ get kittyProtocolActive() { return terminal.kittyProtocolActive; },
707
+ start: (onInput, onResize) => terminal.start(onInput, onResize),
708
+ stop: () => terminal.stop(),
709
+ drainInput: (maxMs, idleMs) => terminal.drainInput(maxMs, idleMs),
710
+ write: data => { if (!frozen())
711
+ terminal.write(data); },
712
+ moveBy: lines => { if (!frozen())
713
+ terminal.moveBy(lines); },
714
+ hideCursor: () => { if (!frozen())
715
+ terminal.hideCursor(); },
716
+ showCursor: () => { if (!frozen())
717
+ terminal.showCursor(); },
718
+ clearLine: () => { if (!frozen())
719
+ terminal.clearLine(); },
720
+ clearFromCursor: () => { if (!frozen())
721
+ terminal.clearFromCursor(); },
722
+ clearScreen: () => { if (!frozen())
723
+ terminal.clearScreen(); },
724
+ setTitle: title => terminal.setTitle(title),
725
+ setProgress: active => terminal.setProgress(active),
726
+ };
727
+ }
679
728
  function preInputTerminal(terminal, route, frameMouse) {
680
729
  let mouse;
681
730
  return {
@@ -63,6 +63,13 @@ export declare class DamageAwareTerminalAdapter implements PiTuiTerminalPort {
63
63
  get kittyProtocolActive(): boolean;
64
64
  get lastDecision(): PiTuiDamageDecision;
65
65
  get hyperlinkCleanupPending(): boolean;
66
+ /**
67
+ * The rows as last forwarded to the terminal, top to bottom, with styling
68
+ * intact. A row the adapter has not seen since its last invalidation is empty.
69
+ * The quit outro animates over this snapshot because it is what the terminal
70
+ * shows, not what a fresh render would produce.
71
+ */
72
+ presentedRows(): readonly string[];
66
73
  /** Latches the former link rows, including rows whose replacement contains no link. */
67
74
  requestHyperlinkCleanup(rows?: readonly number[]): void;
68
75
  arm(descriptor: PiTuiDamageFrameDescriptor, safety: PiTuiDamageFrameSafety): void;
@@ -40,6 +40,18 @@ export class DamageAwareTerminalAdapter {
40
40
  get kittyProtocolActive() { return this.inner.kittyProtocolActive; }
41
41
  get lastDecision() { return this.#decision; }
42
42
  get hyperlinkCleanupPending() { return this.#cleanupRevision > this.#cleanedRevision; }
43
+ /**
44
+ * The rows as last forwarded to the terminal, top to bottom, with styling
45
+ * intact. A row the adapter has not seen since its last invalidation is empty.
46
+ * The quit outro animates over this snapshot because it is what the terminal
47
+ * shows, not what a fresh render would produce.
48
+ */
49
+ presentedRows() {
50
+ const presented = [];
51
+ for (let row = 1; row <= this.rows; row += 1)
52
+ presented.push(this.#rows.get(row) ?? "");
53
+ return presented;
54
+ }
43
55
  /** Latches the former link rows, including rows whose replacement contains no link. */
44
56
  requestHyperlinkCleanup(rows) {
45
57
  this.#cleanupRevision += 1;
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-17T07:00:33.413Z",
8
+ "builtAt": "2026-09-17T10:19:32.993Z",
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-17T07:00:55.073Z",
8
+ "builtAt": "2026-09-17T10:19:33.794Z",
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-17T07:01:11.858Z",
8
+ "builtAt": "2026-09-17T10:20:20.745Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "41a34706be8b325fe7bc342a0e8d62e55962addf7cd22c2a6b37553a9b8c579c",
11
+ "sha256": "049f0659bfe5d506a900d07e219c6a96480e8495dede8375c7ea5f9e5dc1990d",
12
12
  "size": 177664
13
13
  },
14
14
  "provenance": {
@@ -1,4 +1,4 @@
1
- export declare const OWNED_UI_SETTINGS_VERSION = 4;
1
+ export declare const OWNED_UI_SETTINGS_VERSION = 5;
2
2
  export type OwnedUiSettingValue = string | number | boolean;
3
3
  export type OwnedUiSettingApplication = "live" | "restart";
4
4
  export interface OwnedUiSettingDeclaration {
@@ -15,6 +15,8 @@ export interface OwnedUiSettingDeclaration {
15
15
  readonly defaultValue: OwnedUiSettingValue;
16
16
  readonly allowedValues: readonly OwnedUiSettingValue[];
17
17
  }
18
+ /** Playback lengths the quit outro offers, in milliseconds. */
19
+ export declare const QUIT_EFFECT_DURATIONS_MS: readonly number[];
18
20
  export declare const OWNED_UI_SETTING_DECLARATIONS: readonly OwnedUiSettingDeclaration[];
19
21
  export declare function assertOwnedUiSettingDeclarations(declarations: readonly OwnedUiSettingDeclaration[]): void;
20
22
  export declare function findOwnedUiSettingDeclaration(declarations: readonly OwnedUiSettingDeclaration[], id: string): OwnedUiSettingDeclaration | null;
@@ -1,7 +1,10 @@
1
- export const OWNED_UI_SETTINGS_VERSION = 4;
1
+ export const OWNED_UI_SETTINGS_VERSION = 5;
2
2
  const MAX_ID_LENGTH = 64;
3
3
  const ID_PATTERN = /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)*$/;
4
4
  const SCROLL_SECTION = Object.freeze({ id: "scroll", title: "Scroll" });
5
+ const QUIT_SECTION = Object.freeze({ id: "quit", title: "Quit" });
6
+ /** Playback lengths the quit outro offers, in milliseconds. */
7
+ export const QUIT_EFFECT_DURATIONS_MS = Object.freeze(Array.from({ length: 18 }, (_, index) => 300 + index * 100));
5
8
  export const OWNED_UI_SETTING_DECLARATIONS = Object.freeze([
6
9
  Object.freeze({
7
10
  id: "scrollbarAppearance",
@@ -48,6 +51,24 @@ export const OWNED_UI_SETTING_DECLARATIONS = Object.freeze([
48
51
  defaultValue: 100,
49
52
  allowedValues: Object.freeze([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]),
50
53
  }),
54
+ Object.freeze({
55
+ id: "quitEffect",
56
+ label: "Effect",
57
+ section: QUIT_SECTION,
58
+ description: "Animation played over the last screen when the session quits.",
59
+ application: "live",
60
+ defaultValue: "fall",
61
+ allowedValues: Object.freeze(["fall", "dissolve", "starburst", "waves", "off"]),
62
+ }),
63
+ Object.freeze({
64
+ id: "quitEffectDurationMs",
65
+ label: "Duration",
66
+ section: QUIT_SECTION,
67
+ description: "Milliseconds the quit animation plays before the terminal is restored.",
68
+ application: "live",
69
+ defaultValue: 800,
70
+ allowedValues: QUIT_EFFECT_DURATIONS_MS,
71
+ }),
51
72
  Object.freeze({
52
73
  id: "promptSuggestions",
53
74
  label: "Prompt suggestions",
@@ -29,6 +29,13 @@ export const OWNED_UI_SETTINGS_MIGRATIONS = Object.freeze([
29
29
  return { ...values };
30
30
  },
31
31
  }),
32
+ Object.freeze({
33
+ to: 5,
34
+ description: "Introduce the quit outro effect and duration with prototype defaults.",
35
+ migrate(values) {
36
+ return { ...values };
37
+ },
38
+ }),
32
39
  ]);
33
40
  export function assertOwnedUiSettingsMigrations(migrations, currentVersion = OWNED_UI_SETTINGS_VERSION) {
34
41
  const firstProduced = currentVersion - migrations.length + 1;
@@ -58,9 +58,9 @@ effect of stable publication, not a trigger.
58
58
 
59
59
  Ordinary type, architecture, unit/contract, and dist checks always run for code changes. Changed-file documentation and rendering run as independent parallel jobs. Rendered shell/component changes select `smoke`; viewport, scheduler, terminal adapter, evidence harness, package identity, and selector changes select `full`; unrelated changes select `none`. The aggregate accepts a skipped modular job only when the current selector requested the skip.
60
60
 
61
- Integration owners declare one cadence in `config/integration-owners.json`. Impact mode selects affected `pull-request` owners; an invalidator, unknown operational path, or manual Development dispatch selects every `pull-request` owner. `exhaustive` owners are never silently skipped or reported as passed: impact and aggregate evidence list them as cadence-deferred, and malformed cadence blocks selection. Full regression and nightly/stable release still execute both cadence classes.
61
+ Integration owners declare one cadence in `config/integration-owners.json`. Impact mode selects affected `pull-request` owners: a changed production path selects its coarse owner, a changed test selects its owner, and a changed file under `test/support/` or `test/fixtures/` selects the owners of the retained tests that import it directly or through other support files (the selection lists those tests as its `shared-support` reason). A support file that no retained test imports, or a test tree the scanner cannot read, falls back to every owner the shared rule declares and records `shared-support-declared`; an invalidator, unknown operational path, or manual Development dispatch selects every `pull-request` owner. An implementation-bound PR body does not force conservative selection; it only removes the documentation-only and version-only shortcuts. The modular matrix itself is derived from the selection by `scripts/release/validation-matrix.mjs`, so inactive jobs are not scheduled at all rather than checking out and exiting early. `exhaustive` owners are never silently skipped or reported as passed: impact and aggregate evidence list them as cadence-deferred, and malformed cadence blocks selection. Full regression and nightly/stable release still execute both cadence classes.
62
62
 
63
- The real three-release `update-predecessor` scenario is exhaustive because four fresh npm installations dominated recent PR critical paths. PR validation retains deterministic predecessor command, lifecycle, fault, fixture, materialization, warmup, package, and update contracts. This permits a real published-history incompatibility to reach `develop` before nightly detects it; nightly failure still blocks publication. For a high-risk release/update change, explicitly dispatch `.github/workflows/full-regression.yml` before merge instead of adding the exhaustive owner back to ordinary Development.
63
+ The real three-release `update-predecessor` scenario is exhaustive because four fresh npm installations dominated recent PR critical paths, and `update-performance` is exhaustive because its assertion is wall-clock timing on a shared runner; the update path's deterministic contracts stay on pull requests through `pi-release-resume` and `package-contracts`. PR validation retains deterministic predecessor command, lifecycle, fault, fixture, materialization, warmup, package, and update contracts. This permits a real published-history incompatibility or update slowdown to reach `develop` before the next exhaustive run detects it. `.github/workflows/full-regression.yml` runs every night at `02:47 UTC` against the `develop` tip with every owner and enforced budgets, independent of publication, so such a regression shows as a failed Full regression run the next morning; nightly publication's own complete validation still blocks publication. For a high-risk release/update change, dispatch Full regression before merge instead of adding an exhaustive owner back to ordinary Development.
64
64
 
65
65
  Development outcomes report each owner/scope invocation separately while sharing authenticated build/package preparation. The aggregate reports setup, scope, job, aggregate-processing, total runner, and runner-critical-path durations. The acceptance targets are at most eight minutes of runner critical path and five minutes for one PR-required scope; an over-target result remains unmet without retries, timeout increases, workload reduction, or mutable installation caches. Hosted queue time is reported separately when available and is not counted as test execution.
66
66
 
@@ -86,9 +86,9 @@ Rendering evidence captures each selected producer/mode/workload matrix once and
86
86
 
87
87
  ## Resource-sensitive fast validation
88
88
 
89
- The authoritative fast-tier declaration identifies tests that repeatedly create temporary repositories, launch child processes, mutate storage, or coordinate release cohorts. The planner removes those files from the parallel remainder and runs each exactly once in its own `vitest-fast-resource-sensitive-*` process with file parallelism disabled. Per-file process isolation prevents one resource-heavy file from consuming another file's unchanged five-second test budget; no timeout override or retry is added. Pull-request, development-package, and complete release plans use the same partition on every platform.
89
+ The authoritative fast-tier declaration identifies tests that repeatedly create temporary repositories, launch child processes, mutate storage, or coordinate release cohorts. The planner removes those files from the parallel remainder and runs them exactly once in one serial `vitest-fast-resource-sensitive` process with file parallelism disabled, on an isolated runner that no other partition shares. One process instead of one per file removes about twenty cold starts from the partition; each file still gets a fresh module context. Pull-request, development-package, and complete release plans use the same partition on every platform.
90
90
 
91
- The partition retains Vitest's five-second default test timeout. It does not add a test, suite, platform, or workflow timeout, and a failure is not retried or converted to success. If a serialized test still approaches five seconds, use `scripts/release/report-resource-sensitive-validation.mjs` to record repeated per-file and test-body timing, then optimize repository setup, subprocess count, storage operations, or release fixtures. Do not increase a timeout to create margin.
91
+ The partition runs under an explicit `--testTimeout=30000`, the same hang bound the other explicit fast-tier invocations use. That bound is a hang detector, not a performance gate: shared Windows runners vary by a factor of two or more for identical work, and a fixed five-second wall-clock limit failed passing suites on runner noise. Per-test durations remain in the reporter evidence; `scripts/release/report-resource-sensitive-validation.mjs` records repeated executions and lists every test body above five seconds under `slowTests` so a real slowdown is visible without failing the pull request. A failure is still not retried or converted to success, and the bound is not raised to create margin.
92
92
 
93
93
  Inspect the partition without running tests:
94
94
 
@@ -147,7 +147,7 @@ validation; malformed, mixed, renamed, stale, or unavailable inputs fail closed.
147
147
  A legitimate generated baseline update remains outside the allowlist and follows the
148
148
  manually accepted mixed/code path.
149
149
 
150
- A new implementation-bound specification starts as OpenSpec-only artifacts in one normally named draft PR. Explicit approval to implement continues in that same worktree, branch, history, and PR; the plan does not merge first. Approved refinements reconcile planning before code. After implementation, the same branch conservatively synchronizes deltas and stages the dated archive plus conditional acceptance manifest. Add one to three plain implementation-specific bullets under `## Acceptance`, mark the finalized PR ready, and require exact-head CI. Auto-merge remains disabled: an authorized maintainer's manual merge accepts the listed scenarios and atomically integrates implementation, specs, and archive. No acceptance or archive follow-up PR is created. Closing an unmerged draft integrates nothing; cleanup still needs separate approval. See [delivery and archive handoff](openspec-archive-automation.md) for commands and legacy compatibility.
150
+ A new implementation-bound specification starts as OpenSpec-only artifacts in one normally named draft PR. Explicit approval to implement continues in that same worktree, branch, history, and PR; the plan does not merge first. Approved refinements reconcile planning before code. After implementation, add one to three plain implementation-specific bullets under `## Acceptance` and mark the PR ready; the trusted `OpenSpec finalization` workflow conservatively synchronizes deltas, stages the dated archive plus conditional acceptance manifest, and commits them to the same branch, and exact-head CI validates that head. Auto-merge remains disabled: an authorized maintainer's manual merge accepts the listed scenarios and atomically integrates implementation, specs, and archive. No acceptance or archive follow-up PR is created. Closing an unmerged draft integrates nothing; cleanup still needs separate approval. See [delivery and archive handoff](openspec-archive-automation.md) for commands and legacy compatibility.
151
151
 
152
152
  ## Numbered development previews
153
153
 
@@ -18,7 +18,7 @@ node scripts/governance/local-worktree-cleanup.mjs complete \
18
18
 
19
19
  `complete` is explicit cleanup authorization for that exact candidate. It creates and releases an exact registration when needed, applies the repository-owned generated-path policy, verifies live merge/archive/CI/ref evidence, evaluates only that candidate, uses journaled non-force Git removal, deletes only the unchanged local topic ref, and leaves persistent watcher authority unchanged. Repeating it reports the completed candidate as already absent. Existing conflicting ownership, identity drift, unavailable evidence, or unknown content remains blocking.
20
20
 
21
- The central disposable policy is `node_modules`, `dist`, `.builds`, `.artifacts/openspec-archive`, `.artifacts/validation`, `native/process-guardian/target`, and `native/terminal-host/target`. The artifact roots contain repository-generated finalization and validation reports; the two native roots contain repository-generated Cargo output. 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: `.artifacts`, sibling directories such as `.artifacts/other`, near matches such as `.artifacts/validation-user`, 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.
21
+ 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
22
 
23
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.
24
24
 
@@ -135,12 +135,16 @@ State, journals, stop controls, and execution reports live in `<git-common-dir>/
135
135
  - `unmanaged`: no local registration; no automatic adoption.
136
136
  - `removed`: worktree and eligible local-ref operations were verified.
137
137
  - `already-absent`: a completed journal's path/ref are still absent.
138
- - `partial`: a destructive step began but all cleanup could not be verified.
138
+ - `partial`: a destructive step began but all cleanup could not be verified; the same command resumes it.
139
139
  - `deferred`: a bounded pass or concurrent mutation owner prevented evaluation.
140
140
 
141
- Non-force Git removal is the only worktree deletion operation. Local topic-ref deletion then compares the exact old SHA 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.
141
+ Non-force Git removal is the only operation that deletes tracked content from an intact worktree. Local topic-ref deletion then compares the exact old SHA 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
142
 
143
- Windows file locks can leave a directory after Git removes part of a worktree. `partial` retains that residue and its journal; there is no `rm -rf`, force discard, or automatic directory-repair fallback. Stop the locking process and review the exact residual data before separately authorized manual repair. A recreated path is not the old checkout. If a prior run fully removed the worktree and only ref cleanup remains, a subsequent enabled pass rechecks evidence and safely resumes the branch-only step.
143
+ 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
+
145
+ Post-merge provenance accepts a `merged` timeline event whose time is within five seconds of the pull request's `merged_at`; GitHub stamps the two from different services and has reported them one second apart. The single-event, human-actor, no-App, and same-commit checks are unchanged.
146
+
147
+ Windows file locks can interrupt Git after it has already unlinked the `.git` pointer and part of the tree. That pass reports `partial` with `git-operation-failed` and keeps the journal at `remove-intent`; rerunning the same command resumes it. If the exact registered worktree is still intact, the retry re-inspects it and repeats non-force Git removal. Otherwise the retry verifies the residue: Git must no longer list the path as a valid worktree, the residue may contain no `.git` entry, link, special file, or nested repository, and every remaining regular file must either sit below a declared disposable root or match the exact tracked path and blob hash of the journaled head (`git hash-object` with the repository's filters). Only residue verified that way is removed, with the same bounded retry, after which this candidate's own dangling Git registration is retired and ref cleanup continues. A residue with any unknown, changed, or boundary-violating path reports `residual-content` and lists the offending paths for manual review; a residue that is still locked reports `residual-locked` and is retried on a later pass. `git worktree prune` is never run. A recreated or foreign worktree at the path reports `residual-or-reused-path` and needs a new registration. If a prior run fully removed the worktree and only ref cleanup remains, a subsequent pass rechecks evidence and safely resumes the branch-only step.
144
148
 
145
149
  Ownership is cooperative: managed sessions must claim before use. It cannot police arbitrary external editors. Remote ref checks and local deletion also are not one distributed transaction; refs are read immediately before destructive steps, and uncertain identities always block.
146
150
 
@@ -57,8 +57,9 @@ Use equivalent values in bare A1 and the Pi comparison profile. Exercise both a
57
57
  - [ ] Trust startup: from an undecided project, compare selected Trust/Do not trust rows, arrow navigation, Enter, Escape/Ctrl+C, clearing, cursor state, and restoration. No project extension/theme/prompt/skill may run before selection, and a fail-closed diagnostic must appear once on the restored parent rather than above a blank fullscreen frame.
58
58
  - [ ] Terminal lifecycle: toggle hardware cursor, clear-on-shrink, and terminal progress; resize smaller/larger; open/close selectors; select and copy transcript text; verify no duplicate rows, stale OSC progress, leaked mouse mode, misplaced cursor, or broken parent input.
59
59
  - [ ] Images: in Kitty or iTerm2 verify inline width and clipping; in Windows Terminal verify the textual fallback and absence of image protocol bytes without hiding `showImages`.
60
- - [ ] Fullscreen exit `transcript`: verify the parent is restored before styled user, assistant Markdown, thinking, tool, notice, warning, error, and spacing rows are printed. No overlay, draft, animation, scrollbar, or inline-image payload may appear.
61
- - [ ] Fullscreen exit `resume-hint`: verify only dim `To resume this session:` plus `a1 --session <compact-id>` is printed for the default directory. A custom directory must place quoted `--session-dir <dir>` before `--session`; the raw default `.jsonl` path must never print.
60
+ - [ ] Bare A1 quit (`/quit` and the second `Ctrl+C`): the configured quit effect plays over the last frame on the alternate screen, the terminal is restored once, and the parent shows only its earlier scrollback plus the dim `To resume this session:` hint. No frame rows, transcript rows, editor box, or footer may remain. With `/settings` → Quit → Effect set to `off`, the leave is immediate and the parent output is identical.
61
+ - [ ] Fullscreen exit `transcript` (`a1 pi` only): verify the parent is restored before styled user, assistant Markdown, thinking, tool, notice, warning, error, and spacing rows are printed. No overlay, draft, animation, scrollbar, or inline-image payload may appear.
62
+ - [ ] Fullscreen exit `resume-hint` (`a1 pi`) and bare A1: verify only dim `To resume this session:` plus `a1 --session <compact-id>` is printed for the default directory. A custom directory must place quoted `--session-dir <dir>` before `--session`; the raw default `.jsonl` path must never print.
62
63
 
63
64
  Record acceptance with:
64
65
 
@@ -12,8 +12,8 @@ Version-1 and version-2 deliveries and their existing comments, acceptance PRs,
12
12
  2. **Same-PR implementation:** continue after explicit approval in the same worktree, branch, history, draft PR, and phase-free body. Reconcile approved refinements in proposal, design, deltas, and tasks before corresponding code edits.
13
13
  3. **Complete evidence:** finish implementation, required tests/evidence, substantive tasks, and explicit known-gap disposition. CI success is objective evidence, not acceptance.
14
14
  4. **Plain acceptance list:** keep the body phase-free and add final `## Acceptance` with one to three concise implementation-specific behavior-and-result bullets. Do not use checkboxes, generic review/CI/approval/archive statements, URLs, mentions, or automated-test inventory.
15
- 5. **In-branch finalization:** reconcile current `origin/develop`, conservatively synchronize all deltas, move the active change into its dated archive, and stage the conditional acceptance manifest in the same branch.
16
- 6. **Ready and validate:** mark the finalized PR ready without changing its body lifecycle marker because none exists. One normal exact-head workflow validates the 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, acceptance-list change, or advanced target requires full renewed validation; no lifecycle body edit or second workflow run is required.
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
+ 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
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.
19
19
 
@@ -69,7 +69,7 @@ Do not add a quoted phase line or a routine `Validation` section listing command
69
69
  </details>
70
70
  ```
71
71
 
72
- Keep acceptance absent during proposal review so unfinished intent is not mistaken for final acceptance criteria. Keep the finalized phase-free body unchanged through exact-head validation, maintainer review, and authorized manual merge. The same workflow run validates its finalized delivery record and applicable product/governance scopes before the stable protected aggregate succeeds. Do not add a lifecycle body edit or start a second validation run. After manual merge, trusted verification derives `Archived`; do not rewrite the accepted body.
72
+ Keep acceptance absent during proposal review so unfinished intent is not mistaken for final acceptance criteria. The finalization workflow rewrites only the implementation fence; keep the rest of the phase-free body unchanged through exact-head validation, maintainer review, and authorized manual merge. The same workflow run validates its finalized delivery record and applicable product/governance scopes before the stable protected aggregate succeeds. Do not add a lifecycle body edit or start a second validation run. After manual merge, trusted verification derives `Archived`; do not rewrite the accepted body.
73
73
 
74
74
  ## Version-3 implementation metadata
75
75
 
@@ -129,9 +129,22 @@ The committed conditional manifest contains the same ordered text. Trusted polic
129
129
 
130
130
  If the body list changes, candidate validation reruns and compares it with the committed manifest. If the head changes, all prior exact-head CI is stale. If only the body changes to disagree with the manifest, integration remains blocked until the list and committed candidate agree again. No lifecycle body edit is needed after green CI.
131
131
 
132
+ ## Automated finalization
133
+
134
+ The `OpenSpec finalization` workflow (`.github/workflows/openspec-finalization.yml`) runs on `pull_request_target` for `synchronize`, `ready_for_review`, `reopened`, and `edited` events of every non-draft PR targeting `develop`, one event at a time per PR. It checks out default-branch policy, installs the pinned tooling without hooks, and runs `scripts/governance/publish-openspec-finalization.mjs --pr <n>`; the PR head enters that process only as the `openspec/` tree the pinned OpenSpec engine reads. Drafts, closed, legacy, and unassociated PRs are skipped. For a version-3 candidate it reconciles the head to its finalized form:
135
+
136
+ - **Active and current:** ordinary finalization with today's UTC date; one `docs(openspec): finalize <change>` commit.
137
+ - **Finalized, valid, and current:** nothing is pushed; the run reports `already-finalized`.
138
+ - **Finalized but drifted:** a later commit edited the archived tasks, evidence, design, or deltas, or the acceptance list changed. Finalization reruns from the archived form under the same archive date; one `docs(openspec): refinalize <change>` commit replaces the manifest and resynchronized specs.
139
+ - **Behind `develop`:** a restore commit returns the archive to its active form with the merge-base's spec bytes, a merge of `develop` follows, and finalization runs against the new tip. A merge conflict outside `openspec/` stops the run with `finalization-merge-conflict`; rebase or merge `develop` yourself and push.
140
+
141
+ The commit is pushed with a lease on the head the run read, so a developer push in between makes the run report `retry` and the next event finishes the work. Only after the push does the workflow `PATCH` the body fence, and only when the body is unchanged since it was read. The workflow's own push and body edit trigger further runs that report `already-finalized`. Every pushed commit is either a merge of the exact `develop` tip or confined to the change's active path, its archive path, and its declared canonical specs.
142
+
143
+ Because the branch gains commits from the archive App, pull before pushing. A rebase that drops them is harmless: the next push is reconciled from whatever the head contains. Do not revert a finalization commit to make a fix; push the fix and let the workflow re-finalize. When the workflow fails, its summary names the finalization code (`tasks-incomplete`, `acceptance-*`, `delivery-known-gaps`, `openspec-operation`, `finalization-merge-conflict`, ...), nothing is pushed, and `Finalized delivery validation` on the unfinalized head reports that automated finalization is pending.
144
+
132
145
  ## Finalization command
133
146
 
134
- Finalization has inspection mode by default and an explicit `--write` mode. It never commits, pushes, edits GitHub, marks a PR ready, or merges. Use a temporary body file so the operation can update exact version-3 paths without mutating remote PR state:
147
+ The local command remains available for inspection or when a developer prefers to finalize before marking the PR ready; the workflow then verifies the head and pushes nothing. It has inspection mode by default and an explicit `--write` mode. It never commits, pushes, edits GitHub, marks a PR ready, or merges. Use a temporary body file so the operation can update exact version-3 paths without mutating remote PR state:
135
148
 
136
149
  ```bash
137
150
  git fetch origin develop
@@ -164,7 +177,7 @@ Use repeated `--known-gap "exact disposition"` only for an actually reviewed exp
164
177
 
165
178
  The operation validates the active change strictly, requires complete substantive tasks, runs the pinned OpenSpec archive/synchronization engine in isolation, verifies the resulting canonical specs, retains every archive artifact, computes deterministic content digests, writes `acceptance.md`, and applies only the allowed OpenSpec diff. Repeating it against identical finalized inputs is verification-only and byte-stable.
166
179
 
167
- Before finalization, canonical specs must still equal the selected target. If `develop` advances, rebase/reconcile and regenerate. To refine a finalized but unmerged change, restore the active artifacts with ordinary branch history, update plan/code coherently, and rerun finalization; never hand-edit only the synchronized spec or archive copy.
180
+ Before a first finalization, canonical specs must still equal the selected target. On an already finalized head the command re-finalizes from the archived form: it resets the synchronized specs to the target's bytes, reapplies the deltas, and rewrites the manifest under the same archive path, reporting `refinalized` (or `would-refinalize` without `--write`). If `develop` advances, merge or rebase onto it and rerun; the archive engine, not a hand edit, must produce the synchronized spec and archive copy.
168
181
 
169
182
  ## Conditional acceptance and derived receipt
170
183
 
@@ -183,11 +196,11 @@ Unavailable, stale, automatic, unauthorized, conflicting, or contradictory prove
183
196
 
184
197
  ## CI and automation ownership
185
198
 
186
- Normal Development CI remains complete for the implementation. An archive-shaped final diff does not select documentation-only validation because the authoritative version-3 association remains implementation-bound. The trusted acceptance-policy job validates finalization from base-controlled policy while ordinary impact selection retains all applicable product and governance owners.
199
+ Normal Development CI remains complete for the implementation. An archive-shaped final diff does not select documentation-only validation because the authoritative version-3 association remains implementation-bound. The trusted acceptance-policy job validates finalization from base-controlled policy while ordinary impact selection retains all applicable product and governance owners. A ready head that still holds the active change fails that job with an `Awaiting automated finalization` notice pointing at the finalization workflow; the head the workflow pushes receives its own complete validation, and `Development validation` cancels the superseded run.
187
200
 
188
201
  `pull_request` body edits rerun required CI. The ordinary finalized phase-free run exposes the stable protected aggregate directly; it does not wait for a lifecycle body edit. Documentation auto-merge's trusted owner also reevaluates lifecycle association and disables any armed merge. Every publication entry point explicitly refuses version 3.
189
202
 
190
- The OpenSpec archive workflow remains default-branch trusted. For version 3 it uses read-only contents, PR, and Actions access to report the integrated result; App credentials are unnecessary and are not minted. For legacy candidates it retains its existing scoped App publication behavior.
203
+ The OpenSpec archive workflow remains default-branch trusted. For version 3 it uses read-only contents, PR, and Actions access to report the integrated result; App credentials are unnecessary and are not minted for post-merge verification. For legacy candidates it retains its existing scoped App publication behavior. The OpenSpec finalization workflow is the one version-3 user of the archive App: it mints a short-lived installation token with `contents: write` and `pull_requests: write`, pushes only to the candidate's own branch under a lease, edits only the body fence, and revokes the token when the run ends.
191
204
 
192
205
  ## Status and audit
193
206
 
@@ -230,7 +243,7 @@ This bootstrap policy itself finishes under deployed version-2 authority. The fi
230
243
 
231
244
  - draft planning remaining unmerged;
232
245
  - explicit plan approval and same-PR implementation;
233
- - current-target finalization and exact-head CI;
246
+ - automated current-target finalization and exact-head CI;
234
247
  - authorized human manual merge with plain acceptance scenarios;
235
248
  - integrated canonical specs/archive and no generated acceptance/archive PR;
236
249
  - read-only `accepted-and-archived` verification and exact-head branch cleanup;