@pellux/goodvibes-tui 0.20.3 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +3 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +662 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/help.ts +4 -2
  11. package/src/cli/management-commands.ts +1 -1
  12. package/src/cli/management.ts +1 -8
  13. package/src/cli/parser.ts +14 -18
  14. package/src/cli/service-command.ts +1 -1
  15. package/src/cli/surface-command.ts +1 -1
  16. package/src/cli/tui-startup.ts +72 -10
  17. package/src/cli/types.ts +12 -3
  18. package/src/cli-flags.ts +1 -0
  19. package/src/config/atomic-write.ts +70 -0
  20. package/src/config/read-versioned.ts +115 -0
  21. package/src/core/conversation-rendering.ts +49 -15
  22. package/src/core/conversation.ts +101 -16
  23. package/src/core/format-user-error.ts +192 -0
  24. package/src/core/stream-event-wiring.ts +144 -0
  25. package/src/core/stream-stall-watchdog.ts +103 -0
  26. package/src/core/system-message-router.ts +5 -1
  27. package/src/export/cost-utils.ts +71 -0
  28. package/src/export/gist-uploader.ts +136 -0
  29. package/src/input/command-registry.ts +31 -1
  30. package/src/input/commands/control-room-runtime.ts +5 -5
  31. package/src/input/commands/experience-runtime.ts +5 -4
  32. package/src/input/commands/knowledge.ts +1 -1
  33. package/src/input/commands/local-auth-runtime.ts +27 -5
  34. package/src/input/commands/local-setup.ts +4 -6
  35. package/src/input/commands/memory-product-runtime.ts +8 -6
  36. package/src/input/commands/operator-panel-runtime.ts +1 -1
  37. package/src/input/commands/operator-runtime.ts +3 -10
  38. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  39. package/src/input/commands/recall-review.ts +26 -2
  40. package/src/input/commands/services-runtime.ts +2 -2
  41. package/src/input/commands/session-workflow.ts +3 -3
  42. package/src/input/commands/share-runtime.ts +99 -12
  43. package/src/input/commands/tts-runtime.ts +30 -4
  44. package/src/input/commands.ts +2 -2
  45. package/src/input/delete-key-policy.ts +46 -0
  46. package/src/input/feed-context-factory.ts +2 -0
  47. package/src/input/handler-feed.ts +3 -0
  48. package/src/input/handler-interactions.ts +2 -15
  49. package/src/input/handler-modal-routes.ts +91 -12
  50. package/src/input/handler-modal-token-routes.ts +3 -0
  51. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  52. package/src/input/handler-onboarding.ts +55 -69
  53. package/src/input/handler-types.ts +163 -0
  54. package/src/input/handler.ts +5 -2
  55. package/src/input/input-history.ts +76 -6
  56. package/src/input/model-picker-filter.ts +265 -0
  57. package/src/input/model-picker-items.ts +208 -0
  58. package/src/input/model-picker.ts +92 -325
  59. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  60. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  61. package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
  62. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
  63. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  64. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  65. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  66. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  67. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  68. package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
  69. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  70. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  71. package/src/input/settings-modal-data.ts +304 -0
  72. package/src/input/settings-modal-mutations.ts +154 -0
  73. package/src/input/settings-modal.ts +182 -220
  74. package/src/main.ts +57 -57
  75. package/src/panels/builtin/agent.ts +4 -1
  76. package/src/panels/builtin/development.ts +4 -1
  77. package/src/panels/confirm-state.ts +27 -12
  78. package/src/panels/cost-tracker-panel.ts +23 -67
  79. package/src/panels/eval-panel.ts +10 -9
  80. package/src/panels/knowledge-panel.ts +3 -5
  81. package/src/panels/local-auth-panel.ts +124 -4
  82. package/src/panels/project-planning-panel.ts +42 -4
  83. package/src/panels/search-focus.ts +11 -5
  84. package/src/panels/subscription-panel.ts +33 -25
  85. package/src/panels/types.ts +28 -1
  86. package/src/panels/wrfc-panel.ts +224 -41
  87. package/src/renderer/agent-detail-modal.ts +11 -10
  88. package/src/renderer/code-block.ts +10 -2
  89. package/src/renderer/compositor.ts +18 -4
  90. package/src/renderer/context-inspector.ts +1 -5
  91. package/src/renderer/diff.ts +94 -21
  92. package/src/renderer/markdown.ts +29 -13
  93. package/src/renderer/settings-modal-helpers.ts +1 -1
  94. package/src/renderer/settings-modal.ts +77 -8
  95. package/src/renderer/syntax-highlighter.ts +10 -3
  96. package/src/renderer/term-caps.ts +318 -0
  97. package/src/renderer/theme.ts +158 -0
  98. package/src/renderer/tool-call.ts +12 -2
  99. package/src/renderer/ui-factory.ts +50 -6
  100. package/src/runtime/bootstrap-command-context.ts +1 -0
  101. package/src/runtime/bootstrap-command-parts.ts +14 -0
  102. package/src/runtime/bootstrap-core.ts +121 -13
  103. package/src/runtime/bootstrap.ts +2 -0
  104. package/src/runtime/onboarding/apply.ts +4 -6
  105. package/src/runtime/onboarding/index.ts +1 -0
  106. package/src/runtime/onboarding/markers.ts +42 -49
  107. package/src/runtime/onboarding/progress.ts +148 -0
  108. package/src/runtime/onboarding/state.ts +133 -55
  109. package/src/runtime/onboarding/types.ts +20 -0
  110. package/src/runtime/services.ts +21 -0
  111. package/src/runtime/wrfc-persistence.ts +237 -0
  112. package/src/shell/blocking-input.ts +20 -5
  113. package/src/tools/wrfc-agent-guard.ts +64 -3
  114. package/src/utils/format-elapsed.ts +30 -0
  115. package/src/utils/terminal-width.ts +45 -0
  116. package/src/version.ts +1 -1
  117. package/src/work-plans/work-plan-store.ts +4 -6
  118. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -29,7 +29,59 @@ import { registerBootstrapHookBridge } from '@/runtime/index.ts';
29
29
  import { registerBootstrapRuntimeEvents } from '@/runtime/index.ts';
30
30
  import { createRuntimeServices, type RuntimeServices } from './services.ts';
31
31
  import { createUiRuntimeServices, type UiRuntimeServices } from './ui-services.ts';
32
+ import { join } from 'node:path';
32
33
  import { installWrfcAgentToolGuard } from '../tools/wrfc-agent-guard.ts';
34
+ import { createWrfcPersistence, type WrfcPersistence } from './wrfc-persistence.ts';
35
+ import type { SystemMessagePriority } from '../panels/system-messages-panel.ts';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Pre-router buffer
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const PRE_ROUTER_BUFFER_MAX = 100;
42
+
43
+ type BufferedWrfcMessage = {
44
+ readonly message: string;
45
+ readonly priority: SystemMessagePriority;
46
+ };
47
+
48
+ /**
49
+ * Small bounded queue that accumulates WRFC system messages emitted before
50
+ * the SystemMessageRouter is attached. On attach the queue flushes in order.
51
+ * If the queue overflows (> PRE_ROUTER_BUFFER_MAX), the oldest entries are
52
+ * dropped and a summary message is prepended to the first flushed message.
53
+ */
54
+ export class WrfcPreRouterBuffer {
55
+ private readonly queue: BufferedWrfcMessage[] = [];
56
+ private overflowCount = 0;
57
+
58
+ push(message: string, priority: SystemMessagePriority): void {
59
+ if (this.queue.length >= PRE_ROUTER_BUFFER_MAX) {
60
+ this.queue.shift();
61
+ this.overflowCount++;
62
+ }
63
+ this.queue.push({ message, priority });
64
+ }
65
+
66
+ flush(router: import('../core/system-message-router.ts').SystemMessageRouter): void {
67
+ const dropped = this.overflowCount;
68
+ const pending = this.queue.splice(0);
69
+ this.overflowCount = 0;
70
+ if (dropped > 0) {
71
+ router.wrfc(
72
+ `[WRFC] Pre-router buffer overflowed: ${dropped} earliest message${dropped !== 1 ? 's' : ''} were dropped`,
73
+ 'low',
74
+ );
75
+ }
76
+ for (const item of pending) {
77
+ router.wrfc(item.message, item.priority);
78
+ }
79
+ }
80
+
81
+ get size(): number {
82
+ return this.queue.length;
83
+ }
84
+ }
33
85
 
34
86
  export interface BootstrapCoreState {
35
87
  readonly userSessionId: string;
@@ -62,6 +114,11 @@ export interface BootstrapCoreState {
62
114
  readonly requestRender: () => void;
63
115
  readonly setRenderRequest: (fn: () => void) => void;
64
116
  readonly runtimeSessionIdRef: { value: string };
117
+ /**
118
+ * WRFC chain persistence — call `rehydrate()` once after the SystemMessageRouter
119
+ * is wired so interrupted chains from a previous process are surfaced to the operator.
120
+ */
121
+ readonly wrfcPersistence: WrfcPersistence;
65
122
  }
66
123
 
67
124
  export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
@@ -222,9 +279,9 @@ export async function initializeBootstrapCore(
222
279
  overflowHandler: services.overflowHandler,
223
280
  changeTracker: services.sessionChangeTracker,
224
281
  });
225
- installWrfcAgentToolGuard(toolRegistry, {
226
- getLastUserMessage: () => conversation.getLastUserMessage(),
227
- });
282
+ // Note: installWrfcAgentToolGuard is called after routeOrBuffer is defined
283
+ // (further below) so the onTrace callback can route guard decisions through
284
+ // the pre-router buffer.
228
285
  services.agentOrchestrator.setDependencies({
229
286
  surfaceRoot: 'tui',
230
287
  fileCache,
@@ -305,7 +362,26 @@ export async function initializeBootstrapCore(
305
362
  void approvalBroker.start();
306
363
  void sharedSessionBroker.start();
307
364
  const runtimeSessionIdRef = { value: userSessionId };
308
- const systemMessageRouterRef: { value: SystemMessageRouter | null } = { value: null };
365
+ const wrfcBuffer = new WrfcPreRouterBuffer();
366
+ // Smart ref: setting .value auto-flushes the pre-router buffer so events
367
+ // buffered before the SystemMessageRouter attaches are not permanently lost.
368
+ const systemMessageRouterRef = ((): { value: SystemMessageRouter | null } => {
369
+ let _value: SystemMessageRouter | null = null;
370
+ const ref = {} as { value: SystemMessageRouter | null };
371
+ Object.defineProperty(ref, 'value', {
372
+ get(): SystemMessageRouter | null { return _value; },
373
+ set(router: SystemMessageRouter | null): void {
374
+ _value = router;
375
+ if (router && wrfcBuffer.size > 0) {
376
+ wrfcBuffer.flush(router);
377
+ requestRender();
378
+ }
379
+ },
380
+ enumerable: true,
381
+ configurable: true,
382
+ });
383
+ return ref;
384
+ })();
309
385
  const conversationFollowUpRef: { value: ((item: ConversationFollowUpItem) => void) | null } = { value: null };
310
386
  const { unsubs: runtimeUnsubs, agentStatusIntervalRef } = registerBootstrapRuntimeEvents({
311
387
  runtimeBus,
@@ -318,21 +394,45 @@ export async function initializeBootstrapCore(
318
394
  wrfcController: services.wrfcController,
319
395
  });
320
396
 
397
+ // ── WRFC chain persistence ──────────────────────────────────────────────────────────
398
+ const wrfcPersistence = createWrfcPersistence({
399
+ snapshotPath: join(workingDir, '.goodvibes', 'tui', 'wrfc-chains.json'),
400
+ getSystemMessageRouter: () => systemMessageRouterRef.value,
401
+ controller: services.wrfcController,
402
+ });
403
+ runtimeUnsubs.push(...wrfcPersistence.attach(runtimeBus));
404
+ // Flush any debounced snapshot on clean shutdown so final chain state is
405
+ // never silently dropped during a SIGINT/teardown (250ms debounce window).
406
+ bootstrapUnsubs.push(() => wrfcPersistence.flush());
407
+
321
408
  // ── TUI-specific WRFC constraint-propagation event subscriptions (SDK 0.23.0) ──
322
409
  // These supplement the SDK's registerBootstrapRuntimeEvents which handles the
323
410
  // core WORKFLOW_REVIEW_COMPLETED / WORKFLOW_CHAIN_CREATED messages.
324
411
  // The SDK does not surface constraint-specific system messages; the TUI layer
325
412
  // adds them here so operators can observe constraint enumeration and violations
326
413
  // in the SystemMessagesPanel and main conversation.
414
+ //
415
+ // Pre-router buffering: events that arrive before the SystemMessageRouter is
416
+ // attached are held in wrfcBuffer (bounded, 100 entries). When the router is
417
+ // set on systemMessageRouterRef, the smart setter flushes the buffer in order.
418
+ // If the buffer overflows, the oldest entries are dropped and a summary message
419
+ // is prepended to the first flushed batch.
420
+ const routeOrBuffer = (message: string, priority: SystemMessagePriority): void => {
421
+ const router = systemMessageRouterRef.value;
422
+ if (router) {
423
+ router.wrfc(message, priority);
424
+ } else {
425
+ wrfcBuffer.push(message, priority);
426
+ }
427
+ };
428
+
327
429
  runtimeUnsubs.push(
328
430
  runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_CONSTRAINTS_ENUMERATED' }>>(
329
431
  'WORKFLOW_CONSTRAINTS_ENUMERATED',
330
432
  ({ payload }) => {
331
- const router = systemMessageRouterRef.value;
332
- if (!router) return;
333
433
  const count = payload.constraints.length;
334
434
  if (count > 0) {
335
- router.wrfc(
435
+ routeOrBuffer(
336
436
  `[WRFC] Engineer enumerated ${count} constraint${count !== 1 ? 's' : ''} for chain ${payload.chainId.slice(0, 12)}`,
337
437
  'low',
338
438
  );
@@ -345,11 +445,9 @@ export async function initializeBootstrapCore(
345
445
  runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_FIX_ATTEMPTED' }>>(
346
446
  'WORKFLOW_FIX_ATTEMPTED',
347
447
  ({ payload }) => {
348
- const router = systemMessageRouterRef.value;
349
- if (!router) return;
350
448
  const targetIds = payload.targetConstraintIds;
351
449
  if (targetIds && targetIds.length > 0) {
352
- router.wrfc(
450
+ routeOrBuffer(
353
451
  `[WRFC] Fix #${payload.attempt} targeting ${targetIds.length} constraint${targetIds.length !== 1 ? 's' : ''} on chain ${payload.chainId.slice(0, 12)}`,
354
452
  'low',
355
453
  );
@@ -362,11 +460,9 @@ export async function initializeBootstrapCore(
362
460
  runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_REVIEW_COMPLETED' }>>(
363
461
  'WORKFLOW_REVIEW_COMPLETED',
364
462
  ({ payload }) => {
365
- const router = systemMessageRouterRef.value;
366
- if (!router) return;
367
463
  const unsatisfied = payload.unsatisfiedConstraintIds;
368
464
  if (!payload.passed && unsatisfied && unsatisfied.length > 0) {
369
- router.wrfc(
465
+ routeOrBuffer(
370
466
  `[WRFC] ✗ Chain ${payload.chainId.slice(0, 12)}: ${unsatisfied.length} constraint violation${unsatisfied.length !== 1 ? 's' : ''} forced failure`,
371
467
  'high',
372
468
  );
@@ -376,6 +472,17 @@ export async function initializeBootstrapCore(
376
472
  ),
377
473
  );
378
474
 
475
+ // Wire the WRFC agent-guard with the onTrace callback so routing decisions are
476
+ // observable via the same routeOrBuffer path as WORKFLOW_* events.
477
+ // Placed here (after routeOrBuffer is defined) so the closure is fully wired.
478
+ installWrfcAgentToolGuard(toolRegistry, {
479
+ getLastUserMessage: () => conversation.getLastUserMessage(),
480
+ onTrace: ({ kind, reason, task }) => {
481
+ const shortTask = task.length > 80 ? `${task.slice(0, 77)}...` : task;
482
+ routeOrBuffer(`[WRFC] Guard: ${reason} — task: "${shortTask}" (${kind})`, 'low');
483
+ },
484
+ });
485
+
379
486
  // Subscribe to companion main-chat messages received from the daemon's HTTP layer.
380
487
  // The daemon emits COMPANION_MESSAGE_RECEIVED on the runtime bus when a companion
381
488
  // POST /api/sessions/:id/messages with kind='message' arrives.
@@ -535,5 +642,6 @@ export async function initializeBootstrapCore(
535
642
  renderRequestRef.value = fn;
536
643
  },
537
644
  runtimeSessionIdRef,
645
+ wrfcPersistence,
538
646
  };
539
647
  }
@@ -180,6 +180,7 @@ export async function bootstrapRuntime(
180
180
  requestRender,
181
181
  setRenderRequest,
182
182
  runtimeSessionIdRef,
183
+ wrfcPersistence,
183
184
  } = await initializeBootstrapCore(stdout, options, (limit) => controlPlaneRecentEventsRef.value(limit));
184
185
  const providerRegistry = services.providerRegistry;
185
186
  const {
@@ -285,6 +286,7 @@ export async function bootstrapRuntime(
285
286
  });
286
287
  const systemMessageRouter = shell.systemMessageRouter;
287
288
  systemMessageRouterRef.value = systemMessageRouter;
289
+ wrfcPersistence.rehydrate();
288
290
  const commandRegistry = shell.commandRegistry;
289
291
  const commandContext = shell.commandContext;
290
292
  const gitStatusProvider = shell.gitStatusProvider;
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { dirname } from 'node:path';
1
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import { atomicWriteFileSync } from '../../config/atomic-write.ts';
3
3
  import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config';
4
4
  import { CONFIG_SCHEMA, DEFAULT_CONFIG } from '../../config/index.ts';
5
5
  import type { FeatureFlagConfigKey } from '../surface-feature-flags.ts';
@@ -34,8 +34,7 @@ function readJsonObject(path: string): Record<string, unknown> {
34
34
  }
35
35
 
36
36
  function writeJsonObject(path: string, payload: Record<string, unknown>): void {
37
- mkdirSync(dirname(path), { recursive: true });
38
- writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
37
+ atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
39
38
  }
40
39
 
41
40
  function setNestedValue(root: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> {
@@ -76,8 +75,7 @@ function restoreFile(path: string, previous: string | null, reload?: () => void)
76
75
  if (previous === null) {
77
76
  if (existsSync(path)) unlinkSync(path);
78
77
  } else {
79
- mkdirSync(dirname(path), { recursive: true });
80
- writeFileSync(path, previous, 'utf-8');
78
+ atomicWriteFileSync(path, previous, { mkdirp: true });
81
79
  }
82
80
  reload?.();
83
81
  }
@@ -5,3 +5,4 @@ export * from './apply.ts';
5
5
  export * from './verify.ts';
6
6
  export * from './markers.ts';
7
7
  export * from './state.ts';
8
+ export * from './progress.ts';
@@ -1,5 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { dirname } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { atomicWriteFileSync } from '@/config/atomic-write.ts';
3
+ import { readVersioned } from '@/config/read-versioned.ts';
4
+
3
5
  import type { ShellPathService } from '@/runtime/index.ts';
4
6
  import type {
5
7
  OnboardingCheckMarkerPayload,
@@ -25,50 +27,34 @@ function resolveMarkerPath(
25
27
  : shellPaths.resolveUserPath('tui', ONBOARDING_CHECK_MARKER_FILE);
26
28
  }
27
29
 
28
- function isObject(value: unknown): value is Record<string, unknown> {
29
- return typeof value === 'object' && value !== null;
30
- }
31
-
32
- function isOnboardingMode(value: unknown): value is OnboardingCheckMarkerPayload['mode'] {
33
- return value === 'new' || value === 'edit' || value === 'reopen';
34
- }
35
-
36
30
  function isCheckMarkerPayload(value: unknown): value is OnboardingCheckMarkerPayload {
37
- return isObject(value)
38
- && value.version === 1
39
- && typeof value.checkedAt === 'number'
40
- && Number.isFinite(value.checkedAt)
41
- && typeof value.updatedAt === 'number'
42
- && Number.isFinite(value.updatedAt)
43
- && typeof value.source === 'string'
44
- && (value.mode === undefined || isOnboardingMode(value.mode))
45
- && (value.workspaceRoot === undefined || typeof value.workspaceRoot === 'string');
31
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
32
+ const v = value as Record<string, unknown>;
33
+ return (
34
+ v['version'] === 1
35
+ && typeof v['checkedAt'] === 'number'
36
+ && Number.isFinite(v['checkedAt'] as number)
37
+ && typeof v['updatedAt'] === 'number'
38
+ && Number.isFinite(v['updatedAt'] as number)
39
+ && typeof v['source'] === 'string'
40
+ && (v['mode'] === undefined || v['mode'] === 'new' || v['mode'] === 'edit' || v['mode'] === 'reopen')
41
+ && (v['workspaceRoot'] === undefined || typeof v['workspaceRoot'] === 'string')
42
+ );
46
43
  }
47
44
 
48
45
  function buildMissingMarkerState(
49
46
  scope: OnboardingStateScope,
50
47
  path: string,
51
48
  ): OnboardingCheckMarkerState {
52
- return {
53
- scope,
54
- path,
55
- exists: false,
56
- payload: null,
57
- };
49
+ return { scope, path, exists: false, payload: null };
58
50
  }
59
51
 
60
- function buildParseErrorState(
52
+ function buildErrorMarkerState(
61
53
  scope: OnboardingStateScope,
62
54
  path: string,
63
55
  parseError: string,
64
56
  ): OnboardingCheckMarkerState {
65
- return {
66
- scope,
67
- path,
68
- exists: true,
69
- payload: null,
70
- parseError,
71
- };
57
+ return { scope, path, exists: true, payload: null, parseError };
72
58
  }
73
59
 
74
60
  function pickEffectiveMarker(
@@ -91,24 +77,32 @@ export function readOnboardingCheckMarker(
91
77
  scope: OnboardingStateScope = 'user',
92
78
  ): OnboardingCheckMarkerState {
93
79
  const path = resolveMarkerPath(shellPaths, scope);
94
- if (!existsSync(path)) return buildMissingMarkerState(scope, path);
95
-
96
- try {
97
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
98
- if (!isCheckMarkerPayload(parsed)) {
99
- return buildParseErrorState(scope, path, 'Invalid onboarding check marker payload.');
100
- }
101
80
 
102
- return {
81
+ const parsed = readVersioned<OnboardingCheckMarkerPayload & { version: number }>(
82
+ path,
83
+ { currentVersion: 1, onUnknown: 'quarantine' },
84
+ );
85
+
86
+ if (parsed === null) {
87
+ // readVersioned returns null for: missing, corrupt JSON, or unrecognised
88
+ // version (renamed to <path>.unrecognized).
89
+ const nowExists = existsSync(path);
90
+ const quarantined = existsSync(`${path}.unrecognized`);
91
+ if (!nowExists && !quarantined) return buildMissingMarkerState(scope, path);
92
+ return buildErrorMarkerState(
103
93
  scope,
104
94
  path,
105
- exists: true,
106
- payload: parsed,
107
- };
108
- } catch (error) {
109
- const parseError = error instanceof Error ? error.message : String(error);
110
- return buildParseErrorState(scope, path, parseError);
95
+ quarantined
96
+ ? 'Unrecognised or corrupt marker file; quarantined.'
97
+ : 'Invalid onboarding check marker payload.',
98
+ );
99
+ }
100
+
101
+ if (!isCheckMarkerPayload(parsed)) {
102
+ return buildErrorMarkerState(scope, path, 'Invalid onboarding check marker payload.');
111
103
  }
104
+
105
+ return { scope, path, exists: true, payload: parsed };
112
106
  }
113
107
 
114
108
  export function readOnboardingCheckMarkers(
@@ -140,8 +134,7 @@ export function writeOnboardingCheckMarker(
140
134
  ...(options.workspaceRoot ? { workspaceRoot: options.workspaceRoot } : {}),
141
135
  };
142
136
 
143
- mkdirSync(dirname(path), { recursive: true });
144
- writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
137
+ atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
145
138
 
146
139
  return readOnboardingCheckMarker(shellPaths, scope);
147
140
  }
@@ -0,0 +1,148 @@
1
+ import { existsSync, unlinkSync } from 'node:fs';
2
+ import { atomicWriteFileSync } from '@/config/atomic-write.ts';
3
+ import { readVersioned } from '@/config/read-versioned.ts';
4
+
5
+ import type {
6
+ OnboardingMode,
7
+ OnboardingShellPaths,
8
+ WizardProgressPayload,
9
+ WizardProgressState,
10
+ } from './types.ts';
11
+
12
+ const WIZARD_PROGRESS_FILE = 'onboarding-progress.json';
13
+
14
+ function resolveProgressPath(shellPaths: OnboardingShellPaths): string {
15
+ return shellPaths.resolveUserPath('tui', WIZARD_PROGRESS_FILE);
16
+ }
17
+
18
+ function isWizardProgressPayload(value: unknown): value is WizardProgressPayload {
19
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
20
+ const v = value as Record<string, unknown>;
21
+ if (v['version'] !== 1) return false;
22
+ if (typeof v['savedAt'] !== 'number' || !Number.isFinite(v['savedAt'] as number)) return false;
23
+ const mode = v['mode'];
24
+ if (mode !== 'new' && mode !== 'edit' && mode !== 'reopen') return false;
25
+ if (typeof v['stepIndex'] !== 'number' || !Number.isFinite(v['stepIndex'] as number)) return false;
26
+ if (!Array.isArray(v['toggleState'])) return false;
27
+ if (!Array.isArray(v['radioState'])) return false;
28
+ if (!Array.isArray(v['textState'])) return false;
29
+ return true;
30
+ }
31
+
32
+ /**
33
+ * Path to the wizard progress file (user-scoped, in ~/.config/goodvibes/tui/).
34
+ */
35
+ export function getWizardProgressPath(shellPaths: OnboardingShellPaths): string {
36
+ return resolveProgressPath(shellPaths);
37
+ }
38
+
39
+ /**
40
+ * Read the persisted wizard progress, if any.
41
+ *
42
+ * Returns a WizardProgressState with exists=false when no progress file is
43
+ * present. Returns exists=true, payload=null with a parseError when the file
44
+ * is present but unreadable or schema-mismatched (the bad file is left in
45
+ * place so the caller can decide whether to delete it).
46
+ */
47
+ export function readWizardProgress(shellPaths: OnboardingShellPaths): WizardProgressState {
48
+ const path = resolveProgressPath(shellPaths);
49
+
50
+ const parsed = readVersioned<WizardProgressPayload & { version: number }>(
51
+ path,
52
+ { currentVersion: 1, onUnknown: 'quarantine' },
53
+ );
54
+
55
+ if (parsed === null) {
56
+ const nowExists = existsSync(path);
57
+ const quarantined = existsSync(`${path}.unrecognized`);
58
+ if (!nowExists && !quarantined) return { path, exists: false, payload: null };
59
+ return {
60
+ path,
61
+ exists: true,
62
+ payload: null,
63
+ parseError: quarantined
64
+ ? 'Unrecognised or corrupt wizard progress file; quarantined.'
65
+ : 'Invalid wizard progress payload.',
66
+ };
67
+ }
68
+
69
+ if (!isWizardProgressPayload(parsed)) {
70
+ return { path, exists: true, payload: null, parseError: 'Invalid wizard progress payload.' };
71
+ }
72
+
73
+ return { path, exists: true, payload: parsed };
74
+ }
75
+
76
+ export interface WriteWizardProgressOptions {
77
+ readonly mode: OnboardingMode;
78
+ readonly stepIndex: number;
79
+ readonly toggleState: ReadonlyArray<readonly [string, boolean]>;
80
+ readonly radioState: ReadonlyArray<readonly [string, string]>;
81
+ readonly textState: ReadonlyArray<readonly [string, string]>;
82
+ readonly clock?: () => number;
83
+ }
84
+
85
+ /**
86
+ * Atomically persist wizard progress to disk.
87
+ *
88
+ * Uses atomicWriteFileSync (write-to-tmp + rename) so a crash mid-write
89
+ * never leaves a torn file. The file is user-scoped so it survives across
90
+ * project switches and is shared with the resume-prompt check at startup.
91
+ *
92
+ * Masked (password) fields are deliberately excluded from the serialised
93
+ * textState by the caller — this function accepts whatever is passed in and
94
+ * does NOT filter. Callers must strip sensitive fields before calling.
95
+ */
96
+ export function writeWizardProgress(
97
+ shellPaths: OnboardingShellPaths,
98
+ options: WriteWizardProgressOptions,
99
+ ): void {
100
+ const path = resolveProgressPath(shellPaths);
101
+ const payload: WizardProgressPayload = {
102
+ version: 1,
103
+ savedAt: (options.clock ?? Date.now)(),
104
+ mode: options.mode,
105
+ stepIndex: options.stepIndex,
106
+ toggleState: options.toggleState,
107
+ radioState: options.radioState,
108
+ textState: options.textState,
109
+ };
110
+ atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
111
+ }
112
+
113
+ /**
114
+ * Delete the wizard progress file (best-effort; ignores ENOENT).
115
+ *
116
+ * Called after a successful apply so the resume prompt is not shown on
117
+ * next startup.
118
+ */
119
+ export function deleteWizardProgress(shellPaths: OnboardingShellPaths): void {
120
+ const path = resolveProgressPath(shellPaths);
121
+ try { unlinkSync(path); } catch { /* best-effort: file may not exist */ }
122
+ }
123
+
124
+ /**
125
+ * Returns true when an in-progress wizard session was interrupted and is
126
+ * still recent enough to reopen on startup (the caller reopens the wizard
127
+ * at the saved step so the user can continue or dismiss it).
128
+ *
129
+ * A progress file is considered resumable when:
130
+ * - it exists and can be parsed (payload !== null)
131
+ * - it is less than PROGRESS_MAX_AGE_MS old (default: 7 days)
132
+ *
133
+ * Negative age (future-dated `savedAt`) is treated as non-resumable: it
134
+ * indicates clock skew or a tampered file and is safer to reject than to
135
+ * open a wizard with unknown-age state.
136
+ */
137
+ const PROGRESS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
138
+
139
+ export function hasResumableWizardProgress(
140
+ shellPaths: OnboardingShellPaths,
141
+ options: { now?: number; state?: WizardProgressState } = {},
142
+ ): boolean {
143
+ const state = options.state ?? readWizardProgress(shellPaths);
144
+ if (!state.payload) return false;
145
+ const age = (options.now ?? Date.now()) - state.payload.savedAt;
146
+ // age < 0 means savedAt is in the future (clock skew / tampered file) — treat as non-resumable.
147
+ return age >= 0 && age < PROGRESS_MAX_AGE_MS;
148
+ }