@pellux/goodvibes-tui 1.13.0 → 1.14.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 (157) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +363 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +79 -0
  141. package/src/runtime/services.ts +38 -37
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/render-scheduler.ts +0 -90
@@ -0,0 +1,385 @@
1
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
+ import { PermissionManager, createPermissionConfigReader } from '@pellux/goodvibes-sdk/platform/permissions';
3
+ import type { PermissionCategory, PermissionCheckResult } from '@pellux/goodvibes-sdk/platform/permissions';
4
+ import { PolicyRuntimeState, createFeatureFlagManager } from '@/runtime/index.ts';
5
+ import type { FlagState } from '@/runtime/index.ts';
6
+ import { getProviderIdFromModel } from '../config/provider-model.ts';
7
+ import type { CliCommandOutput } from './types.ts';
8
+ import type { GoodVibesCliOutputFormat } from './types.ts';
9
+ import { buildHooksValidation } from './hooks-report.ts';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // `goodvibes doctor <explain|routing|hooks>` — the diagnostician subcommands.
13
+ // Each one explains a decision the platform already makes, reading the LIVE
14
+ // config and reusing the platform's OWN decision machinery. None of them
15
+ // changes config or fires a real action:
16
+ // explain — walks the real PermissionManager to say why a tool/command
17
+ // would be allowed / asked / denied under the current mode.
18
+ // routing — prints which model/provider serves which role, from live config.
19
+ // hooks — lists registered hooks, from where, and their validation status
20
+ // (reuses the hooks-validate core in hooks-report.ts).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export interface DoctorSubcommandOptions {
24
+ readonly subcommand: string;
25
+ readonly args: readonly string[];
26
+ readonly configManager: ConfigManager;
27
+ readonly workingDirectory: string;
28
+ readonly homeDirectory: string;
29
+ readonly outputFormat?: GoodVibesCliOutputFormat;
30
+ }
31
+
32
+ /** The tool names the platform's PermissionManager recognizes (from its TOOL_CATEGORIES/TOOL_CONFIG_KEYS). */
33
+ const KNOWN_TOOLS = new Set([
34
+ 'read', 'find', 'fetch', 'analyze', 'inspect', 'state', 'registry', 'goodvibes_context', 'repo_map',
35
+ 'write', 'edit', 'goodvibes_settings', 'exec', 'agent', 'delegate', 'workflow', 'mcp',
36
+ ]);
37
+ /** Tools whose primary display/decision argument is a filesystem path rather than a command. */
38
+ const PATH_TOOLS = new Set(['read', 'write', 'edit', 'find', 'fetch', 'analyze', 'inspect', 'state', 'registry']);
39
+
40
+ /** The permission-mode → human label map (matches the doctor/status wording). */
41
+ function permissionModeLabel(mode: unknown): string {
42
+ if (mode === 'prompt') return 'Ask before powerful actions';
43
+ if (mode === 'allow-all') return 'Allow everything';
44
+ if (mode === 'plan') return 'Plan only (read-only)';
45
+ if (mode === 'accept-edits') return 'Auto-accept file edits';
46
+ if (mode === 'custom') return 'Custom rules';
47
+ return String(mode ?? 'unknown');
48
+ }
49
+
50
+ /**
51
+ * Turn the `explain` argument list into the tool+args shape the
52
+ * PermissionManager evaluates. A bare recognized tool name is taken as that
53
+ * tool (with any remainder as its path/command arg); anything else is treated
54
+ * as a shell command run through the `exec` tool.
55
+ */
56
+ function resolveExplainTarget(args: readonly string[]): { tool: string; args: Record<string, unknown>; interpretation: string } {
57
+ const first = args[0] ?? '';
58
+ if (KNOWN_TOOLS.has(first)) {
59
+ const remainder = args.slice(1).join(' ').trim();
60
+ if (remainder.length === 0) {
61
+ return { tool: first, args: {}, interpretation: `recognized tool "${first}" (no target argument)` };
62
+ }
63
+ if (first === 'exec') {
64
+ return { tool: 'exec', args: { command: remainder }, interpretation: `tool "exec" running command: ${remainder}` };
65
+ }
66
+ if (PATH_TOOLS.has(first)) {
67
+ return { tool: first, args: { path: remainder }, interpretation: `tool "${first}" targeting path: ${remainder}` };
68
+ }
69
+ return { tool: first, args: { command: remainder }, interpretation: `tool "${first}" with argument: ${remainder}` };
70
+ }
71
+ const command = args.join(' ').trim();
72
+ return { tool: 'exec', args: { command }, interpretation: `shell command run through the "exec" tool: ${command}` };
73
+ }
74
+
75
+ /** The ordered permission layers the PermissionManager consults, for the explainer's narrative. */
76
+ const LAYER_NAMES: readonly string[] = [
77
+ 'Auto-approve override (behavior.autoApprove / --no-worries-just-vibes)',
78
+ 'Session permission mode (permissions.mode)',
79
+ 'Runtime policy engine (feature flag: permissions-policy-engine)',
80
+ 'Per-tool config rule (permissions.tools.<tool>)',
81
+ 'Default read auto-approve (prompt mode)',
82
+ 'Session approval cache (remembered [A] decisions)',
83
+ 'Human approval prompt (Human-in-the-Loop)',
84
+ ];
85
+
86
+ /**
87
+ * Which layer index (into LAYER_NAMES) actually decided, inferred from the
88
+ * PermissionManager's own sourceLayer + reasonCode. The verdict itself is the
89
+ * platform's; this only positions the marker in the narrative.
90
+ */
91
+ function decidedLayerIndex(result: PermissionCheckResult, mode: string, autoApprove: boolean, category: PermissionCategory): number {
92
+ switch (result.sourceLayer) {
93
+ case 'user_prompt': return 6;
94
+ case 'session_override': return 5;
95
+ case 'safety_check':
96
+ case 'managed_policy': return 2;
97
+ case 'runtime_mode': return 1;
98
+ case 'config_policy':
99
+ if (result.reasonCode === 'config_allow') {
100
+ if (autoApprove) return 0;
101
+ if (mode === 'custom') return 3;
102
+ if (category === 'read') return 4;
103
+ return 3;
104
+ }
105
+ return 3; // config_deny → custom per-tool rule
106
+ default: return 6;
107
+ }
108
+ }
109
+
110
+ /** The authoritative ALLOW / ASK / DENY verdict from a PermissionManager result. */
111
+ function verdictOf(result: PermissionCheckResult): 'ALLOW' | 'ASK' | 'DENY' {
112
+ if (result.sourceLayer === 'user_prompt') return 'ASK';
113
+ return result.approved ? 'ALLOW' : 'DENY';
114
+ }
115
+
116
+ /** A plain-language "because" line keyed off the platform's own reasonCode. */
117
+ function reasonExplanation(result: PermissionCheckResult, mode: string, toolKey: string): string {
118
+ switch (result.reasonCode) {
119
+ case 'config_allow':
120
+ if (mode === 'custom') return `The per-tool rule permissions.tools.${toolKey} is set to "allow".`;
121
+ if (result.sourceLayer === 'config_policy' && result.analysis.classification === 'read') return 'Read-class tools are auto-approved in the default "prompt" mode.';
122
+ return 'Auto-approve is active (behavior.autoApprove), so this call is approved without a prompt.';
123
+ case 'config_deny': return `The per-tool rule permissions.tools.${toolKey} is set to "deny".`;
124
+ case 'mode_allow_all':
125
+ if (mode === 'plan') return 'Plan mode allows read-class tools.';
126
+ if (mode === 'accept-edits') return 'Accept-edits mode allows read-class tools.';
127
+ return 'Permission mode is "allow-all" — every tool call is auto-approved.';
128
+ case 'mode_denied': return 'The active permission mode denies this tool class.';
129
+ case 'plan_mode': return 'Plan mode refuses mutating / execute / delegate tools and steers the model to present a plan instead.';
130
+ case 'mode_accept_edits': return 'Accept-edits mode auto-approves file write/edit tools.';
131
+ case 'managed_policy_allow': return 'A runtime policy rule allowed this call.';
132
+ case 'managed_policy_deny': return 'A runtime policy rule denied this call.';
133
+ case 'safety_guardrail': return 'A safety guardrail blocked this call.';
134
+ case 'session_cached_allow': return 'A remembered session decision ([A] allow-always) approves this call.';
135
+ case 'session_cached_deny': return 'A remembered session decision denies this call.';
136
+ case 'user_approved':
137
+ case 'user_denied': return 'This reaches a Human-in-the-Loop approval prompt — the outcome depends on your response at the prompt.';
138
+ default: return 'Decided by the platform permission machinery.';
139
+ }
140
+ }
141
+
142
+ /** Stable per-tool config key for the explain narrative (mirrors the SDK's TOOL_CONFIG_KEYS defaults). */
143
+ function toolConfigKey(tool: string): string {
144
+ const map: Record<string, string> = {
145
+ goodvibes_context: 'state', goodvibes_settings: 'write', repo_map: 'read',
146
+ };
147
+ return map[tool] ?? tool;
148
+ }
149
+
150
+ async function explain(options: DoctorSubcommandOptions): Promise<CliCommandOutput> {
151
+ const json = options.outputFormat === 'json';
152
+ const { args } = options;
153
+ if (args.length === 0) {
154
+ return { output: 'Usage: goodvibes doctor explain <tool-name | shell command>', exitCode: 2 };
155
+ }
156
+ const config = options.configManager;
157
+ const target = resolveExplainTarget(args);
158
+ const mode = String(config.get('permissions.mode') ?? 'prompt');
159
+ const autoApprove = config.get('behavior.autoApprove') === true;
160
+
161
+ // Reuse the platform's OWN PermissionManager. A requestPermission handler that
162
+ // returns a denial lets the machinery run to its real terminal layer without
163
+ // actually prompting; sourceLayer 'user_prompt' is then reported as ASK.
164
+ // Feature flags come from live config so the policy engine layer is walked
165
+ // exactly when the running app would walk it.
166
+ const featureFlags = createFeatureFlagManager();
167
+ featureFlags.loadFromConfig({ flags: (config.getCategory('featureFlags') as Record<string, FlagState>) ?? {} });
168
+ const policyEngineOn = featureFlags.isEnabled('permissions-policy-engine');
169
+ const policyRuntimeState = new PolicyRuntimeState();
170
+ const manager = new PermissionManager(
171
+ async () => ({ approved: false, remember: false }),
172
+ createPermissionConfigReader(config),
173
+ policyRuntimeState,
174
+ null,
175
+ featureFlags,
176
+ );
177
+
178
+ const category = manager.getCategory(target.tool, target.args);
179
+ const result = await manager.checkDetailed(target.tool, target.args);
180
+ const verdict = verdictOf(result);
181
+ const decidedIdx = decidedLayerIndex(result, mode, autoApprove, category);
182
+ const toolKey = toolConfigKey(target.tool);
183
+ const because = reasonExplanation(result, mode, toolKey);
184
+
185
+ if (json) {
186
+ return {
187
+ output: JSON.stringify({
188
+ subject: args.join(' '),
189
+ tool: target.tool,
190
+ category,
191
+ args: target.args,
192
+ mode,
193
+ autoApprove,
194
+ policyEngine: policyEngineOn ? 'enabled' : 'disabled',
195
+ verdict,
196
+ decidedLayer: LAYER_NAMES[decidedIdx],
197
+ sourceLayer: result.sourceLayer,
198
+ reasonCode: result.reasonCode,
199
+ because,
200
+ analysis: result.analysis,
201
+ }, null, 2),
202
+ exitCode: 0,
203
+ };
204
+ }
205
+
206
+ const a = result.analysis;
207
+ const lines: string[] = [
208
+ 'GoodVibes doctor — explain permission decision',
209
+ ` subject : ${args.join(' ')}`,
210
+ ` resolved as : ${target.interpretation}`,
211
+ ` tool : ${target.tool} (category: ${category})`,
212
+ ` args : ${JSON.stringify(target.args)}`,
213
+ '',
214
+ 'Current settings:',
215
+ ` mode : ${mode} (${permissionModeLabel(mode)})`,
216
+ ` autoApprove : ${autoApprove ? 'yes (behavior.autoApprove)' : 'no'}`,
217
+ ` policyEngine: ${policyEngineOn ? 'enabled' : 'disabled'} (feature flag permissions-policy-engine)`,
218
+ '',
219
+ 'Analysis (from the platform analyzer):',
220
+ ` risk : ${a.riskLevel.toUpperCase()} (${a.classification})`,
221
+ ...(a.surface || a.blastRadius ? [` surface : ${a.surface ?? 'generic'}${a.blastRadius ? ` radius=${a.blastRadius}` : ''}`] : []),
222
+ ` summary : ${a.summary}`,
223
+ ...a.reasons.slice(0, 3).map((r) => ` reason : ${r}`),
224
+ '',
225
+ 'Layers walked (priority order — the platform PermissionManager decides; this shows the order it walks):',
226
+ ...LAYER_NAMES.map((name, i) => ` ${i === decidedIdx ? '▶' : ' '} ${i + 1}. ${name}${i === decidedIdx ? ' ← DECIDED HERE' : ''}`),
227
+ '',
228
+ `Decision: ${verdict}`,
229
+ ` decided by : ${result.sourceLayer} (${result.reasonCode})`,
230
+ ` because : ${because}`,
231
+ ];
232
+ if (policyEngineOn) {
233
+ lines.push(' note : the runtime policy engine is enabled, but a one-shot diagnostic loads no promoted policy bundle, so its rule set is empty here.');
234
+ }
235
+ return { output: lines.join('\n'), exitCode: 0 };
236
+ }
237
+
238
+ /** One routed role: which provider+model the live config assigns to it, and the keys that carry it. */
239
+ interface RoutingRole {
240
+ readonly role: string;
241
+ readonly enabled: boolean | null;
242
+ readonly provider: string;
243
+ readonly model: string;
244
+ readonly keys: readonly string[];
245
+ readonly note?: string;
246
+ }
247
+
248
+ function buildRoutingRoles(config: ConfigManager): RoutingRole[] {
249
+ const mainModel = String(config.get('provider.model') ?? '');
250
+ const mainProvider = getProviderIdFromModel(mainModel);
251
+ const emptyProvider = (p: string): string => (p.length > 0 ? p : `(inherits main: ${mainProvider})`);
252
+
253
+ const toolProvider = String(config.get('tools.llmProvider') ?? '');
254
+ const toolModel = String(config.get('tools.llmModel') ?? '');
255
+ const helperProvider = String(config.get('helper.globalProvider') ?? '');
256
+ const helperModel = String(config.get('helper.globalModel') ?? '');
257
+ const ttsProvider = String(config.get('tts.llmProvider') ?? '');
258
+ const ttsModel = String(config.get('tts.llmModel') ?? '');
259
+
260
+ return [
261
+ {
262
+ role: 'Conversation (main model)',
263
+ enabled: null,
264
+ provider: mainProvider,
265
+ model: mainModel || '(unset)',
266
+ keys: ['provider.model', 'provider.reasoningEffort'],
267
+ note: `reasoningEffort=${String(config.get('provider.reasoningEffort') ?? '')}`,
268
+ },
269
+ {
270
+ role: 'Embeddings',
271
+ enabled: null,
272
+ provider: String(config.get('provider.embeddingProvider') ?? '') || `(inherits main: ${mainProvider})`,
273
+ model: '(provider default)',
274
+ keys: ['provider.embeddingProvider'],
275
+ },
276
+ {
277
+ role: 'Tool LLM (grunt tool work)',
278
+ enabled: config.get('tools.llmEnabled') === true,
279
+ provider: emptyProvider(toolProvider),
280
+ model: toolModel || '(unset)',
281
+ keys: ['tools.llmEnabled', 'tools.llmProvider', 'tools.llmModel'],
282
+ ...(config.get('tools.llmEnabled') === true ? {} : { note: 'disabled — no tool-LLM route active' }),
283
+ },
284
+ {
285
+ role: 'Helper (cache/compaction/commit-message/etc.)',
286
+ enabled: config.get('helper.enabled') === true,
287
+ provider: emptyProvider(helperProvider),
288
+ model: helperModel || '(unset)',
289
+ keys: ['helper.enabled', 'helper.globalProvider', 'helper.globalModel'],
290
+ ...(config.get('helper.enabled') === true ? {} : { note: 'disabled — helper tasks stay on the main model' }),
291
+ },
292
+ {
293
+ role: 'Narration LLM (TTS phrasing)',
294
+ enabled: null,
295
+ provider: emptyProvider(ttsProvider),
296
+ model: ttsModel || '(unset)',
297
+ keys: ['tts.llmProvider', 'tts.llmModel'],
298
+ note: `engine=${String(config.get('tts.provider') ?? '(unset)')}`,
299
+ },
300
+ ];
301
+ }
302
+
303
+ function routing(options: DoctorSubcommandOptions): CliCommandOutput {
304
+ const config = options.configManager;
305
+ const roles = buildRoutingRoles(config);
306
+ if (options.outputFormat === 'json') {
307
+ return { output: JSON.stringify({ roles }, null, 2), exitCode: 0 };
308
+ }
309
+ const lines: string[] = [
310
+ 'GoodVibes doctor — model / provider routing (live config)',
311
+ '',
312
+ ];
313
+ for (const role of roles) {
314
+ const state = role.enabled === null ? '' : role.enabled ? ' [on]' : ' [off]';
315
+ lines.push(` ${role.role}${state}`);
316
+ lines.push(` provider : ${role.provider}`);
317
+ lines.push(` model : ${role.model}`);
318
+ if (role.note) lines.push(` note : ${role.note}`);
319
+ lines.push(` from : ${role.keys.join(', ')}`);
320
+ lines.push('');
321
+ }
322
+ return { output: lines.join('\n').replace(/\n+$/, ''), exitCode: 0 };
323
+ }
324
+
325
+ function hooks(options: DoctorSubcommandOptions): CliCommandOutput {
326
+ const report = buildHooksValidation(options.configManager);
327
+ if (options.outputFormat === 'json') {
328
+ return {
329
+ output: JSON.stringify({
330
+ path: report.path,
331
+ present: report.present,
332
+ valid: report.valid,
333
+ reason: report.reason ?? null,
334
+ topLevelIssues: report.topLevelIssues,
335
+ hooks: report.checks.map((c) => ({ pattern: c.pattern, index: c.index, name: c.name, type: c.type, source: report.path, ok: c.ok, reason: c.reason ?? null, contract: c.contract ? { authority: c.contract.authority, executionMode: c.contract.executionMode } : null })),
336
+ chains: report.chains,
337
+ }, null, 2),
338
+ exitCode: report.valid ? 0 : 1,
339
+ };
340
+ }
341
+
342
+ const lines: string[] = [
343
+ 'GoodVibes doctor — registered hooks',
344
+ ` source file : ${report.path}`,
345
+ ];
346
+ if (!report.present) {
347
+ lines.push(' no hooks file present — nothing registered.');
348
+ return { output: lines.join('\n'), exitCode: 0 };
349
+ }
350
+ if (report.reason && report.checks.length === 0 && report.topLevelIssues.length === 0) {
351
+ lines.push(` FAIL: ${report.reason}`);
352
+ return { output: lines.join('\n'), exitCode: 1 };
353
+ }
354
+ lines.push(` registered : ${report.checks.length} hook(s), ${report.chains.accepted} chain(s) accepted`);
355
+ lines.push(` validation : ${report.passCount} pass, ${report.failCount} problem(s)`);
356
+ lines.push('');
357
+ for (const issue of report.topLevelIssues) lines.push(` FAIL (file): ${issue}`);
358
+ for (const check of report.checks) {
359
+ const status = check.ok ? 'PASS' : 'FAIL';
360
+ lines.push(` [${status}] ${check.pattern} #${check.index} ${check.name} (type: ${check.type})`);
361
+ lines.push(` from: ${report.path}`);
362
+ if (check.ok && check.contract) {
363
+ lines.push(` contract: ${check.contract.authority}/${check.contract.executionMode} (deny=${check.contract.canDeny ? 'yes' : 'no'})`);
364
+ } else if (!check.ok) {
365
+ lines.push(` reason: ${check.reason}`);
366
+ }
367
+ }
368
+ if (report.chains.declared > 0 || report.chains.accepted > 0) {
369
+ lines.push('');
370
+ lines.push(` chains: ${report.chains.declared} declared, ${report.chains.accepted} accepted by the loader`);
371
+ }
372
+ lines.push('');
373
+ lines.push(report.valid ? ' result: all registered hooks are valid.' : ` result: ${report.failCount} problem(s) found.`);
374
+ return { output: lines.join('\n'), exitCode: report.valid ? 0 : 1 };
375
+ }
376
+
377
+ /** Route a `doctor <sub>` invocation. Returns null when <sub> is not a doctor subcommand (caller renders the classic doctor report). */
378
+ export async function handleDoctorSubcommand(options: DoctorSubcommandOptions): Promise<CliCommandOutput | null> {
379
+ switch (options.subcommand) {
380
+ case 'explain': return explain(options);
381
+ case 'routing': return routing(options);
382
+ case 'hooks': return hooks(options);
383
+ default: return null;
384
+ }
385
+ }
@@ -12,6 +12,7 @@ import {
12
12
  applyRuntimeConfigValue,
13
13
  applyRuntimeFeatureFlagOverrides,
14
14
  buildCliStatusSnapshot,
15
+ handleDoctorSubcommand,
15
16
  handleGoodVibesCliCommand,
16
17
  parseGoodVibesCli,
17
18
  renderCliStatus,
@@ -21,7 +22,14 @@ import {
21
22
  renderGoodVibesVersion,
22
23
  renderOnboardingCliStatus,
23
24
  } from './index.ts';
24
- import { buildCliServicePosture } from './service-posture.ts';
25
+ import { buildCliServicePosture, getGoodVibesPackageRoot, resolveGoodVibesDaemonExecutable } from './service-posture.ts';
26
+ import { runInstallSelfCheck } from '../runtime/install-self-check.ts';
27
+ import { readPersistedWorkspaceTrust } from '../runtime/trust/workspace-trust.ts';
28
+ import { WorkspaceRegistrationManager } from '../runtime/trust/workspace-registration.ts';
29
+ import type { CliWorkspaceStatus, CliSandboxStatus, CliRelayStatus } from './status.ts';
30
+ import { detectSandboxAvailability, probeSandboxHost } from '@pellux/goodvibes-sdk/platform/tools/exec/sandbox';
31
+ import { createFeatureFlagManager } from '@/runtime/index.ts';
32
+ import type { FlagState } from '@/runtime/index.ts';
25
33
  import { ensureGoodvibesGitignore } from './ensure-goodvibes-gitignore.ts';
26
34
 
27
35
  type ShellEntrypointOwnership = {
@@ -126,6 +134,21 @@ export async function prepareShellCliRuntime(
126
134
  process.exit(2);
127
135
  }
128
136
 
137
+ if (cli.command === 'doctor' && ['explain', 'routing', 'hooks'].includes(cli.commandArgs[0] ?? '')) {
138
+ const result = await handleDoctorSubcommand({
139
+ subcommand: cli.commandArgs[0]!,
140
+ args: cli.commandArgs.slice(1),
141
+ configManager,
142
+ workingDirectory: bootstrapWorkingDir,
143
+ homeDirectory: bootstrapHomeDirectory,
144
+ outputFormat: cli.flags.outputFormat,
145
+ });
146
+ if (result) {
147
+ console.log(result.output);
148
+ process.exit(result.exitCode);
149
+ }
150
+ }
151
+
129
152
  if (cli.command === 'status' || cli.command === 'doctor' || (cli.command === 'onboarding' && cli.commandArgs[0] === 'status')) {
130
153
  const shellPaths = createShellPathService({
131
154
  workingDirectory: bootstrapWorkingDir,
@@ -140,6 +163,45 @@ export async function prepareShellCliRuntime(
140
163
  workingDirectory: bootstrapWorkingDir,
141
164
  homeDirectory: bootstrapHomeDirectory,
142
165
  });
166
+ // Read-only workspace posture for the report: the trust reader never
167
+ // persists (no grandfathering side effect), and registration resolve() is
168
+ // read-only, so `status`/`doctor` observe state without mutating it.
169
+ const trustView = readPersistedWorkspaceTrust(shellPaths);
170
+ const registrationEvaluation = await new WorkspaceRegistrationManager({ shellPaths }).evaluate();
171
+ const workspaceStatus: CliWorkspaceStatus = {
172
+ trustLevel: trustView.level,
173
+ trustGrandfathered: trustView.grandfathered,
174
+ registrationStatus: registrationEvaluation.status,
175
+ registrationRoot: registrationEvaluation.root,
176
+ registeredBy: registrationEvaluation.coveredBy,
177
+ viaWorktreeLink: registrationEvaluation.viaWorktreeLink,
178
+ registrationBroad: registrationEvaluation.broad,
179
+ };
180
+ // Honest per-command exec sandbox posture: the host probe (a bwrap spawn on
181
+ // Linux, a no-op elsewhere) is what makes "available" trustworthy.
182
+ const sandboxAvailability = detectSandboxAvailability(probeSandboxHost());
183
+ const sandboxFeatureFlags = createFeatureFlagManager();
184
+ sandboxFeatureFlags.loadFromConfig({
185
+ flags: (configManager.getCategory('featureFlags') as Record<string, FlagState>) ?? {},
186
+ });
187
+ const sandboxStatus: CliSandboxStatus = {
188
+ configEnabled: Boolean(configManager.getCategory('sandbox').enabled),
189
+ featureEnabled: sandboxFeatureFlags.isEnabled('exec-sandbox'),
190
+ available: sandboxAvailability.available,
191
+ backend: sandboxAvailability.backend,
192
+ reason: sandboxAvailability.reason,
193
+ networkIsolationGuaranteed: sandboxAvailability.networkIsolationGuaranteed,
194
+ };
195
+ // Reuses sandboxFeatureFlags (already loaded from the same featureFlags config category)
196
+ // for the relay-connect gate rather than constructing a second FeatureFlagManager.
197
+ const relayCategory = configManager.getCategory('relay');
198
+ const relayStatus: CliRelayStatus = {
199
+ configEnabled: relayCategory.enabled === true,
200
+ featureEnabled: sandboxFeatureFlags.isEnabled('relay-connect'),
201
+ url: String(relayCategory.url ?? ''),
202
+ rendezvousId: String(relayCategory.rendezvousId ?? ''),
203
+ requireStepUpForMutations: relayCategory.requireStepUpForMutations === true,
204
+ };
143
205
  const statusOptions = {
144
206
  configManager,
145
207
  workingDirectory: bootstrapWorkingDir,
@@ -154,8 +216,17 @@ export async function prepareShellCliRuntime(
154
216
  operatorTokenPresent: existsSync(operatorTokenPath),
155
217
  },
156
218
  service,
219
+ install: runInstallSelfCheck({
220
+ execPath: process.execPath,
221
+ packageRoot: getGoodVibesPackageRoot(),
222
+ daemon: resolveGoodVibesDaemonExecutable(),
223
+ fileExists: existsSync,
224
+ }),
157
225
  doctor: cli.command === 'doctor',
158
226
  outputFormat: cli.flags.outputFormat,
227
+ workspace: workspaceStatus,
228
+ sandbox: sandboxStatus,
229
+ relay: relayStatus,
159
230
  };
160
231
  const snapshot = buildCliStatusSnapshot(statusOptions);
161
232
  console.log(cli.command === 'onboarding'
package/src/cli/help.ts CHANGED
@@ -36,6 +36,9 @@ export function renderGoodVibesHelp(binary = 'goodvibes'): string {
36
36
  ' service Inspect/manage daemon service lifecycle',
37
37
  ' status Print config, provider, service, and onboarding posture',
38
38
  ' doctor Print status plus setup warnings',
39
+ ' doctor explain <target> Explain why a tool/command would be allowed/asked/denied',
40
+ ' doctor routing Print which model/provider serves which role',
41
+ ' doctor hooks List registered hooks, their source, and validation status',
39
42
  ' onboarding [status] Open onboarding in the TUI, or print onboarding status',
40
43
  ' models [provider] List/use/pin selectable models and recent model history',
41
44
  ' providers List/inspect/use provider config/auth posture',
@@ -48,9 +51,12 @@ export function renderGoodVibesHelp(binary = 'goodvibes'): string {
48
51
  ' surfaces Inspect/check/enable/disable browser/listener/external surfaces',
49
52
  ' listener test Test HTTP listener/webhook readiness',
50
53
  ' control-plane status Inspect daemon auth, local admin, tokens, and ports',
51
- ' bundle export|inspect|import',
52
- ' Move setup/profile/trust/support bundles',
54
+ ' support-bundle export|inspect|import',
55
+ ' Move a support snapshot (config + diagnostics + posture)',
53
56
  ' remote|bridge Inspect remote runner/node posture',
57
+ ' hooks validate Validate hooks.json against the hook loader schema',
58
+ ' plugin init|validate Scaffold a plugin or validate a plugin directory',
59
+ ' plugin bundles ... Browse/install/list SHA-256-pinned capability bundles',
54
60
  ' completion <shell> Generate shell completion script',
55
61
  ' help [command] Print this help or command-specific help',
56
62
  ' version Print version',
@@ -133,9 +139,9 @@ const COMMAND_HELP: Record<string, CommandHelp> = {
133
139
  examples: ['status', 'status --json'],
134
140
  },
135
141
  doctor: {
136
- usage: ['doctor', 'doctor --json'],
137
- summary: 'Print status plus actionable setup warnings with cause, impact, and next action.',
138
- examples: ['doctor', 'doctor --json'],
142
+ usage: ['doctor', 'doctor --json', 'doctor explain <tool|command>', 'doctor routing', 'doctor hooks'],
143
+ summary: 'Print status plus setup warnings, or explain a platform decision: explain (why a tool/command would be allowed/asked/denied under the current mode), routing (which model/provider serves which role), hooks (registered hooks, their source, and validation status).',
144
+ examples: ['doctor', 'doctor explain "rm -rf build"', 'doctor explain write ./src/app.ts', 'doctor routing', 'doctor hooks --json'],
139
145
  },
140
146
  providers: {
141
147
  usage: ['providers [list]', 'providers current', 'providers inspect <provider>', 'providers use <provider> [modelRegistryKey]'],
@@ -187,10 +193,10 @@ const COMMAND_HELP: Record<string, CommandHelp> = {
187
193
  summary: 'Inspect daemon control-plane bind posture, reachability, local auth, bootstrap credentials, and operator tokens.',
188
194
  examples: ['control-plane status', 'control-plane status --json'],
189
195
  },
190
- bundle: {
191
- usage: ['bundle export [path]', 'bundle inspect <path>', 'bundle import <path>'],
192
- summary: 'Export, inspect, or import setup/profile/trust/support bundle data.',
193
- examples: ['bundle export goodvibes-bundle.json', 'bundle inspect goodvibes-bundle.json'],
196
+ 'support-bundle': {
197
+ usage: ['support-bundle export [path]', 'support-bundle inspect <path>', 'support-bundle import <path>'],
198
+ summary: 'Export, inspect, or import a support snapshot (redacted config + diagnostics + posture). Aliases: bundle, bundles. Distinct from `plugin bundles` (capability bundles).',
199
+ examples: ['support-bundle export goodvibes-bundle.json', 'support-bundle inspect goodvibes-bundle.json'],
194
200
  },
195
201
  pair: {
196
202
  usage: ['pair', 'qrcode'],
@@ -222,6 +228,16 @@ const COMMAND_HELP: Record<string, CommandHelp> = {
222
228
  summary: 'Inspect remote runner/node posture and bridge readiness.',
223
229
  examples: ['remote', 'bridge'],
224
230
  },
231
+ hooks: {
232
+ usage: ['hooks validate'],
233
+ summary: 'Validate the configured hooks.json against the hook loader schema — per-hook pass/fail with plain reasons, nonzero exit if any hook is invalid.',
234
+ examples: ['hooks validate', 'hooks validate --json'],
235
+ },
236
+ plugin: {
237
+ usage: ['plugin init <name> [directory]', 'plugin validate <path>', 'plugin bundles browse <index-url-or-file>', 'plugin bundles install <ref> --sha256 <pin>', 'plugin bundles list'],
238
+ summary: 'Scaffold a minimal valid plugin (manifest.json + entry file), validate a plugin directory against the plugin loader checks, or browse/install/list SHA-256-pinned capability bundles from a governed marketplace index. Bundle installs are never unpinned — a missing or mismatched --sha256 is refused, not overridable.',
239
+ examples: ['plugin init my-plugin', 'plugin validate .goodvibes/plugins/my-plugin', 'plugin bundles browse ./marketplace-index.json', 'plugin bundles install ./bundle.json --sha256 <64-hex> --yes'],
240
+ },
225
241
  };
226
242
 
227
243
  const HELP_ALIASES: Record<string, string> = {
@@ -238,12 +254,16 @@ const HELP_ALIASES: Record<string, string> = {
238
254
  webhook: 'listener',
239
255
  controlplane: 'control-plane',
240
256
  cp: 'control-plane',
257
+ bundle: 'support-bundle',
258
+ bundles: 'support-bundle',
241
259
  qrcode: 'pair',
242
260
  qr: 'pair',
243
261
  daemon: 'serve',
244
262
  server: 'serve',
245
263
  services: 'service',
246
264
  bridge: 'remote',
265
+ hook: 'hooks',
266
+ plugins: 'plugin',
247
267
  };
248
268
 
249
269
  function normalizeHelpTopic(topic: string): string {
@@ -0,0 +1,74 @@
1
+ import { buildHooksValidation } from './hooks-report.ts';
2
+ import type { CliCommandOutput, CliCommandRuntime } from './types.ts';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // `goodvibes hooks validate` — validate the user's hooks.json against the
6
+ // hooks loader's REAL schema. The acceptance verdict comes from
7
+ // buildHooksValidation (src/cli/hooks-report.ts), which runs the SDK's own
8
+ // HookDispatcher.loadFromFile and getHookPointContract — the same machinery
9
+ // `goodvibes doctor hooks` reuses. This command only reads and reports.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export async function handleHooksCommand(runtime: CliCommandRuntime): Promise<CliCommandOutput> {
13
+ const [sub = 'validate'] = runtime.cli.commandArgs;
14
+ const json = runtime.cli.flags.outputFormat === 'json';
15
+ if (sub !== 'validate') {
16
+ return { output: 'Usage: goodvibes hooks validate', exitCode: 2 };
17
+ }
18
+
19
+ const report = buildHooksValidation(runtime.configManager);
20
+ const { path } = report;
21
+
22
+ if (!report.present) {
23
+ const text = [
24
+ 'GoodVibes hooks validation',
25
+ ` file: ${path}`,
26
+ ' no hooks file present — nothing to validate.',
27
+ ].join('\n');
28
+ return { output: json ? JSON.stringify({ path, present: false, hooks: [], valid: true }, null, 2) : text, exitCode: 0 };
29
+ }
30
+
31
+ if (report.reason && report.checks.length === 0 && report.topLevelIssues.length === 0) {
32
+ const text = ['GoodVibes hooks validation', ` file: ${path}`, ` FAIL: ${report.reason}`].join('\n');
33
+ return { output: json ? JSON.stringify({ path, present: true, valid: false, reason: report.reason }, null, 2) : text, exitCode: 1 };
34
+ }
35
+
36
+ const { checks, topLevelIssues, chains, passCount, failCount, valid } = report;
37
+
38
+ if (json) {
39
+ return {
40
+ output: JSON.stringify({
41
+ path,
42
+ present: true,
43
+ valid,
44
+ topLevelIssues,
45
+ hooks: checks.map((c) => ({ pattern: c.pattern, index: c.index, name: c.name, ok: c.ok, reason: c.reason ?? null })),
46
+ chains: { declared: chains.declared, accepted: chains.accepted },
47
+ }, null, 2),
48
+ exitCode: valid ? 0 : 1,
49
+ };
50
+ }
51
+
52
+ const lines: string[] = [
53
+ 'GoodVibes hooks validation',
54
+ ` file: ${path}`,
55
+ ` hooks: ${checks.length} total, ${passCount} pass, ${checks.length - passCount} fail`,
56
+ ];
57
+ for (const issue of topLevelIssues) lines.push(` FAIL: ${issue}`);
58
+ for (const check of checks) {
59
+ const label = `${check.pattern} #${check.index} ${check.name}`;
60
+ if (check.ok) {
61
+ const c = check.contract;
62
+ const detail = c ? ` (contract: ${c.authority}/${c.executionMode})` : '';
63
+ lines.push(` PASS ${label}${detail}`);
64
+ } else {
65
+ lines.push(` FAIL ${label} — ${check.reason}`);
66
+ }
67
+ }
68
+ if (chains.declared > 0 || chains.accepted > 0) {
69
+ lines.push(` chains: ${chains.declared} declared, ${chains.accepted} accepted by the loader`);
70
+ }
71
+ lines.push(valid ? ' result: all hooks are valid.' : ` result: ${failCount} problem(s) found.`);
72
+
73
+ return { output: lines.join('\n'), exitCode: valid ? 0 : 1 };
74
+ }