@pellux/goodvibes-agent 1.9.1 → 1.10.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 (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +12 -1
  3. package/dist/package/main.js +36712 -28803
  4. package/dist/package/{web-tree-sitter-jbz042ba.wasm → web-tree-sitter-e011xaqr.wasm} +0 -0
  5. package/docs/README.md +1 -1
  6. package/docs/connected-host.md +1 -1
  7. package/docs/getting-started.md +1 -1
  8. package/docs/tools-and-commands.md +1 -0
  9. package/package.json +3 -3
  10. package/release/live-verification/live-verification.json +11 -11
  11. package/release/live-verification/live-verification.md +12 -12
  12. package/src/agent/email/email-service.ts +1 -1
  13. package/src/agent/email/imap-client.ts +4 -4
  14. package/src/agent/email/smtp-client.ts +5 -5
  15. package/src/cli/config-overrides.ts +29 -14
  16. package/src/cli/entrypoint.ts +12 -4
  17. package/src/cli/launch-auto-update.ts +218 -0
  18. package/src/cli/relay-command.ts +4 -4
  19. package/src/cli/service-posture.ts +2 -2
  20. package/src/cli/tui-startup.ts +31 -0
  21. package/src/cli/workspaces-command.ts +5 -1
  22. package/src/cli-flags.ts +1 -1
  23. package/src/config/index.ts +1 -1
  24. package/src/config/update-settings.ts +45 -0
  25. package/src/config/workspace-registration.ts +214 -15
  26. package/src/input/commands/runtime-services.ts +0 -5
  27. package/src/input/commands/update-runtime.ts +313 -0
  28. package/src/input/commands.ts +2 -0
  29. package/src/input/feed-context-factory.ts +2 -2
  30. package/src/input/handler-feed.ts +8 -8
  31. package/src/input/handler.ts +1 -1
  32. package/src/input/mcp-workspace.ts +5 -1
  33. package/src/input/panel-paste-flood-guard.ts +1 -1
  34. package/src/input/settings-modal-types.ts +11 -5
  35. package/src/input/settings-modal.ts +56 -61
  36. package/src/main.ts +19 -20
  37. package/src/renderer/activity-sidebar.ts +53 -4
  38. package/src/renderer/settings-modal-helpers.ts +2 -0
  39. package/src/renderer/settings-modal.ts +37 -25
  40. package/src/runtime/bootstrap-core.ts +16 -5
  41. package/src/runtime/bootstrap-external-services.ts +107 -11
  42. package/src/runtime/bootstrap-hook-bridge.ts +2 -2
  43. package/src/runtime/bootstrap-shell.ts +4 -4
  44. package/src/runtime/bootstrap.ts +34 -12
  45. package/src/runtime/connected-host-autostart.ts +269 -0
  46. package/src/runtime/daemon-receipts.ts +66 -0
  47. package/src/runtime/diagnostics/panels/index.ts +0 -2
  48. package/src/runtime/feature-enablement.ts +176 -0
  49. package/src/runtime/index.ts +12 -29
  50. package/src/runtime/memory-spine-adoption.ts +18 -0
  51. package/src/runtime/onboarding/apply.ts +50 -37
  52. package/src/runtime/onboarding/types.ts +2 -2
  53. package/src/runtime/onboarding/verify.ts +22 -4
  54. package/src/runtime/release-artifacts.ts +113 -0
  55. package/src/runtime/services.ts +228 -19
  56. package/src/runtime/session-spine-rest-transport.ts +84 -4
  57. package/src/runtime/ui-services.ts +1 -1
  58. package/src/runtime/update-check.ts +64 -0
  59. package/src/shell/ui-openers.ts +8 -0
  60. package/src/tools/agent-harness-metadata.ts +8 -0
  61. package/src/tools/agent-harness-setup-connected-host.ts +1 -1
  62. package/src/tools/agent-harness-setup-posture.ts +1 -1
  63. package/src/version.ts +1 -1
  64. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  65. package/src/runtime/surface-feature-flags.ts +0 -100
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Connected-host receipt delivery.
3
+ *
4
+ * The daemon keeps honesty receipts ("updated from X to Y", "restarted
5
+ * after a crash at HH:MM", settings-migration notes) and delivers the
6
+ * undelivered ones ONLY to a `/status` read that opts in with
7
+ * `?receipts=consume` — a plain `/status` read is receipt-neutral. Delivery is
8
+ * destructive at the daemon (served exactly once to the consuming reader), so
9
+ * whichever read consumes them must render them or they are lost. The agent's
10
+ * consuming reader is a single `?receipts=consume` read issued once per attach
11
+ * (createSpineReceiptConsumer in session-spine-rest-transport.ts, wired through
12
+ * services.consumeDaemonReceipts); the frequent liveness keepalive probe stays
13
+ * plain and never touches receipts. Every consumed payload is pushed here.
14
+ *
15
+ * This feed buffers receipts captured before the renderer exists (the first
16
+ * consuming read can fire during boot, before the render sink attaches) and
17
+ * delivers each receipt exactly once (dedupe by id) as soon as — and whenever —
18
+ * a delivery sink is attached.
19
+ */
20
+
21
+ /** One daemon honesty receipt, as served on the /status body. */
22
+ export interface DaemonReceipt {
23
+ readonly id: string;
24
+ readonly text: string;
25
+ readonly at: number;
26
+ }
27
+
28
+ /** Parse the receipts array off a /status response body; [] when absent/malformed. */
29
+ export function extractDaemonReceipts(body: unknown): DaemonReceipt[] {
30
+ if (typeof body !== 'object' || body === null) return [];
31
+ const receipts = (body as { receipts?: unknown }).receipts;
32
+ if (!Array.isArray(receipts)) return [];
33
+ const parsed: DaemonReceipt[] = [];
34
+ for (const entry of receipts) {
35
+ if (typeof entry !== 'object' || entry === null) continue;
36
+ const { id, text, at } = entry as { id?: unknown; text?: unknown; at?: unknown };
37
+ if (typeof id !== 'string' || id.length === 0 || typeof text !== 'string' || text.length === 0) continue;
38
+ parsed.push({ id, text, at: typeof at === 'number' ? at : Date.now() });
39
+ }
40
+ return parsed;
41
+ }
42
+
43
+ export class AgentDaemonReceiptFeed {
44
+ private readonly buffered: DaemonReceipt[] = [];
45
+ private readonly seen = new Set<string>();
46
+ private deliver: ((receipt: DaemonReceipt) => void) | null = null;
47
+
48
+ /** Ingest receipts from a /status read; buffers until a sink attaches. */
49
+ push(receipts: readonly DaemonReceipt[]): void {
50
+ for (const receipt of receipts) {
51
+ if (this.seen.has(receipt.id)) continue;
52
+ this.seen.add(receipt.id);
53
+ if (this.deliver) {
54
+ this.deliver(receipt);
55
+ } else {
56
+ this.buffered.push(receipt);
57
+ }
58
+ }
59
+ }
60
+
61
+ /** Attach the rendering sink; anything captured earlier flushes immediately. */
62
+ attach(deliver: (receipt: DaemonReceipt) => void): void {
63
+ this.deliver = deliver;
64
+ for (const receipt of this.buffered.splice(0)) deliver(receipt);
65
+ }
66
+ }
@@ -17,7 +17,5 @@ export type { PolicyDiagnosticsSnapshot } from './policy.ts';
17
17
  export { ToolContractsPanel } from '@/runtime/index.ts';
18
18
  export { TransportPanel } from '@/runtime/index.ts';
19
19
  export type { TransportPanelSnapshot } from '@/runtime/index.ts';
20
- export { OpsPanel } from './ops.ts';
21
- export type { OpsAuditEntry } from './ops.ts';
22
20
  export { SecurityPanel } from '@/runtime/index.ts';
23
21
  export type { SecurityPanelSnapshot } from '@/runtime/index.ts';
@@ -0,0 +1,176 @@
1
+ /**
2
+ * feature-enablement.ts — explicit feature on/off requests over the dissolved
3
+ * feature model.
4
+ *
5
+ * The SDK dissolved the feature-flag category: every capability is enabled
6
+ * through a first-class settings key in its natural domain (FEATURE_SETTINGS
7
+ * describes each feature's binding). This module turns an EXPLICIT
8
+ * present-tense request — "enable feature X" from the CLI or an onboarding
9
+ * plan — into the domain-key write that honestly fulfills it:
10
+ * - boolean bindings write the key true/false;
11
+ * - enum bindings write the canonical enabled value (first enabledValues
12
+ * entry) or a schema-honest disabled value;
13
+ * - constant bindings have no separate off switch, so the request fails with
14
+ * the real settings keys to configure instead.
15
+ *
16
+ * This intentionally differs from the SDK's load-time MIGRATION of persisted
17
+ * legacy featureFlags entries (migrateLegacyFeatureToggles preserves
18
+ * historical AND-of-both-switches ambiguity); an explicit request has no such
19
+ * ambiguity — the operator wants the feature on or off now.
20
+ *
21
+ * Legacy `featureFlags` / `featureFlags.<id>` set-config keys from stored
22
+ * onboarding plans are accepted and expanded here so old plans keep working
23
+ * against the domain keys.
24
+ */
25
+ import {
26
+ FEATURE_SETTINGS,
27
+ deriveFeatureState,
28
+ getFeatureSettingsBinding,
29
+ } from '@/runtime/index.ts';
30
+ import type { FeatureSetting } from '@/runtime/index.ts';
31
+ import { CONFIG_SCHEMA } from '@pellux/goodvibes-sdk/platform/config';
32
+ import type { ConfigKey, ConfigManager, ConfigSetting } from '../config/index.ts';
33
+
34
+ export type LegacyFeatureConfigKey = 'featureFlags' | `featureFlags.${string}`;
35
+
36
+ export type FeatureEnablementRequest = 'enabled' | 'disabled';
37
+
38
+ /** One domain-key write that fulfills an explicit feature request. */
39
+ export interface FeatureEnablementWrite {
40
+ readonly key: ConfigKey;
41
+ readonly value: unknown;
42
+ }
43
+
44
+ const FEATURE_SETTINGS_BY_ID: ReadonlyMap<string, FeatureSetting> = new Map(
45
+ FEATURE_SETTINGS.map((feature) => [feature.id, feature]),
46
+ );
47
+
48
+ const CONFIG_SCHEMA_BY_KEY: ReadonlyMap<string, ConfigSetting> = new Map(
49
+ CONFIG_SCHEMA.map((setting) => [setting.key, setting]),
50
+ );
51
+
52
+ function isRecord(value: unknown): value is Record<string, unknown> {
53
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
54
+ }
55
+
56
+ export function isLegacyFeatureConfigKey(key: string): key is LegacyFeatureConfigKey {
57
+ return key === 'featureFlags' || key.startsWith('featureFlags.');
58
+ }
59
+
60
+ export function isFeatureEnablementRequest(value: unknown): value is FeatureEnablementRequest {
61
+ return value === 'enabled' || value === 'disabled';
62
+ }
63
+
64
+ export function getFeatureSetting(featureId: string): FeatureSetting | null {
65
+ return FEATURE_SETTINGS_BY_ID.get(featureId) ?? null;
66
+ }
67
+
68
+ function knownFeatureIdsHint(): string {
69
+ return FEATURE_SETTINGS.map((feature) => feature.id).join(', ');
70
+ }
71
+
72
+ /** An enum key's honest "disabled" value: the schema default when it is not an enabled value, else the first non-enabled option. */
73
+ function resolveEnumDisabledValue(feature: FeatureSetting): string {
74
+ const enabledValues = feature.enablement.enabledValues ?? [];
75
+ const schema = CONFIG_SCHEMA_BY_KEY.get(feature.enablement.key);
76
+ const schemaDefault = typeof schema?.default === 'string' ? schema.default : undefined;
77
+ if (schemaDefault !== undefined && !enabledValues.includes(schemaDefault)) return schemaDefault;
78
+ const candidate = (schema?.enumValues ?? []).find((value) => !enabledValues.includes(value));
79
+ if (candidate !== undefined) return candidate;
80
+ throw new Error(
81
+ `Feature "${feature.id}" cannot be disabled through ${feature.enablement.key}: `
82
+ + 'every allowed value keeps it enabled.',
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The domain-key write that fulfills an explicit enable/disable request for
88
+ * one feature. Throws with real guidance for unknown ids and for constant
89
+ * bindings (whose own domain keys govern activation directly).
90
+ */
91
+ export function resolveFeatureEnablementWrite(
92
+ featureId: string,
93
+ desired: FeatureEnablementRequest,
94
+ ): FeatureEnablementWrite {
95
+ const feature = FEATURE_SETTINGS_BY_ID.get(featureId);
96
+ if (!feature) {
97
+ throw new Error(`Unknown feature "${featureId}". Known features: ${knownFeatureIdsHint()}.`);
98
+ }
99
+ const { key, kind, enabledValues } = feature.enablement;
100
+ if (kind === 'boolean') {
101
+ return { key, value: desired === 'enabled' };
102
+ }
103
+ if (kind === 'enum') {
104
+ if (desired === 'enabled') {
105
+ const canonical = enabledValues?.[0];
106
+ if (canonical === undefined) {
107
+ throw new Error(`Feature "${featureId}" has no enabled value declared for ${key}.`);
108
+ }
109
+ return { key, value: canonical };
110
+ }
111
+ return { key, value: resolveEnumDisabledValue(feature) };
112
+ }
113
+ // constant: always available; its associated domain keys govern activation.
114
+ throw new Error(
115
+ `Feature "${featureId}" is always available and has no separate on/off switch; `
116
+ + `configure its settings directly: ${feature.settings.join(', ')}.`,
117
+ );
118
+ }
119
+
120
+ /** Whether a feature is currently enabled, derived from its bound domain settings key. */
121
+ export function isFeatureEnabledInConfig(
122
+ config: Pick<ConfigManager, 'get'>,
123
+ featureId: string,
124
+ ): boolean {
125
+ const binding = getFeatureSettingsBinding(featureId);
126
+ if (!binding) return false;
127
+ return deriveFeatureState(binding, config.get(binding.key)) === 'enabled';
128
+ }
129
+
130
+ /**
131
+ * Expand a legacy `featureFlags` / `featureFlags.<id>` set-config value into
132
+ * the domain-key writes that fulfill it. Validates shape with the historical
133
+ * error wording; unknown ids and constant bindings throw with real guidance.
134
+ */
135
+ export function expandLegacyFeatureConfigValue(
136
+ key: LegacyFeatureConfigKey,
137
+ value: unknown,
138
+ ): readonly FeatureEnablementWrite[] {
139
+ if (key === 'featureFlags') {
140
+ if (!isRecord(value)) throw new Error('featureFlags expects an object value.');
141
+ const writes: FeatureEnablementWrite[] = [];
142
+ for (const [featureId, state] of Object.entries(value)) {
143
+ if (featureId.trim().length === 0) throw new Error('featureFlags cannot contain an empty feature id.');
144
+ if (!isFeatureEnablementRequest(state)) {
145
+ throw new Error(`featureFlags.${featureId} expects enabled or disabled.`);
146
+ }
147
+ writes.push(resolveFeatureEnablementWrite(featureId, state));
148
+ }
149
+ return writes;
150
+ }
151
+
152
+ const featureId = key.slice('featureFlags.'.length);
153
+ if (featureId.trim().length === 0) throw new Error('featureFlags requires a feature id.');
154
+ if (!isFeatureEnablementRequest(value)) {
155
+ throw new Error(`Config key ${key} expects enabled or disabled.`);
156
+ }
157
+ return [resolveFeatureEnablementWrite(featureId, value)];
158
+ }
159
+
160
+ /**
161
+ * The feature ids a legacy set-config operation addresses (for verification:
162
+ * each id's DERIVED state must match the requested one after apply).
163
+ */
164
+ export function legacyFeatureConfigTargets(
165
+ key: LegacyFeatureConfigKey,
166
+ value: unknown,
167
+ ): ReadonlyArray<{ readonly featureId: string; readonly desired: FeatureEnablementRequest }> {
168
+ if (key === 'featureFlags') {
169
+ if (!isRecord(value)) return [];
170
+ return Object.entries(value)
171
+ .filter((entry): entry is [string, FeatureEnablementRequest] => isFeatureEnablementRequest(entry[1]))
172
+ .map(([featureId, desired]) => ({ featureId, desired }));
173
+ }
174
+ const featureId = key.slice('featureFlags.'.length);
175
+ return isFeatureEnablementRequest(value) ? [{ featureId, desired: value }] : [];
176
+ }
@@ -13,10 +13,6 @@ import {
13
13
  shell,
14
14
  transport,
15
15
  } from '@pellux/goodvibes-sdk/platform/runtime';
16
- import {
17
- createFeatureFlagManager as createSdkFeatureFlagManager,
18
- FEATURE_FLAG_MAP,
19
- } from '@pellux/goodvibes-sdk/platform/runtime/state';
20
16
  import { existsSync, statSync } from 'node:fs';
21
17
  import { join, resolve } from 'node:path';
22
18
  import type {
@@ -26,7 +22,6 @@ import type {
26
22
  shell as Shell,
27
23
  transport as Transport,
28
24
  } from '@pellux/goodvibes-sdk/platform/runtime';
29
- import type { FeatureFlagManager, FlagConfig } from '@pellux/goodvibes-sdk/platform/runtime/state';
30
25
 
31
26
  // Local runtime entry points.
32
27
  export { bootstrapRuntime } from './bootstrap.ts';
@@ -35,22 +30,10 @@ export type { BootstrapContext } from './bootstrap.ts';
35
30
  export { createUiRuntimeServices } from './ui-services.ts';
36
31
  export type { UiRuntimeServices } from './ui-services.ts';
37
32
 
38
- export function createFeatureFlagManager(): FeatureFlagManager {
39
- const manager = createSdkFeatureFlagManager();
40
- const originalLoadFromConfig = manager.loadFromConfig.bind(manager);
41
-
42
- manager.loadFromConfig = (config: FlagConfig): void => {
43
- originalLoadFromConfig({
44
- flags: Object.fromEntries(
45
- Object.entries(config.flags).filter(([id]) => FEATURE_FLAG_MAP.has(id)),
46
- ) as FlagConfig['flags'],
47
- });
48
- };
49
-
50
- return manager;
51
- }
52
-
53
- // Public runtime exports.
33
+ // Public runtime exports. createFeatureFlagManager comes straight from the
34
+ // SDK state surface below: gate states are seeded from domain settings keys
35
+ // via deriveFeatureStates (exported there too), so the old local wrapper that
36
+ // filtered unknown persisted featureFlags-category ids has nothing to filter.
54
37
  export * from '@pellux/goodvibes-sdk/platform/runtime/state';
55
38
  export * from '@pellux/goodvibes-sdk/platform/runtime/store';
56
39
  export * from '@pellux/goodvibes-sdk/platform/runtime/ui';
@@ -101,9 +84,10 @@ export const registerHostRuntimeEvents = bootstrap.registerHostRuntimeEvents;
101
84
  // Shared adopt-or-spawn policy (daemon-adoption-policy.ts): probes the
102
85
  // configured host/port, band-checks any GoodVibes daemon found there, and
103
86
  // only ever ADOPTS a compatible one — Agent passes `adoptOnly: true` at the
104
- // call site (bootstrap.ts) so it never spawns or embeds a daemon itself. This
105
- // replaces a local stub that hard-declared every daemon 'external' without
106
- // probing or version-checking it (an unsafe divergence, resolved SDK-side).
87
+ // call site (bootstrap-external-services.ts) so it never spawns or embeds a
88
+ // daemon itself. A daemon that is INSTALLED on this machine but stopped is
89
+ // handled separately at boot: one start through the platform service manager,
90
+ // then a fresh adopt-only probe (see runtime/connected-host-autostart.ts).
107
91
  export const startHostServices = bootstrap.startHostServices;
108
92
  export const startExternalServices = bootstrap.startHostServices;
109
93
  export const registerBootstrapHookBridge = bootstrap.registerBootstrapHookBridge;
@@ -223,10 +207,10 @@ export type RemoteRuntimeEventsOptions = Transport.RemoteRuntimeEventsOptions;
223
207
  export type RemoteRuntimeEvents = Transport.RemoteRuntimeEvents;
224
208
  export type SerializedRuntimeEnvelope = Transport.SerializedRuntimeEnvelope;
225
209
 
226
- // Operations compatibility aliases.
227
- export const OpsControlPlane = operations.OpsControlPlane;
228
- export const OpsIllegalActionError = operations.OpsIllegalActionError;
229
- export const OpsTargetNotFoundError = operations.OpsTargetNotFoundError;
210
+ // Operations compatibility aliases. (OpsControlPlane and its error classes
211
+ // are deliberately NOT re-exported: the Agent never constructs the ops
212
+ // intervention plane connected-host tasks are read-only by product policy,
213
+ // with mutations routed to /workplan and /delegate.)
230
214
  export const ToolContractVerifier = operations.ToolContractVerifier;
231
215
  export const McpLifecycleManager = operations.McpLifecycleManager;
232
216
  export const McpPermissionManager = operations.McpPermissionManager;
@@ -354,7 +338,6 @@ export type DistributedRuntimeSnapshotStore = Operations.DistributedRuntimeSnaps
354
338
  export type RemoteRunnerRegistry = Operations.RemoteRunnerRegistry;
355
339
  export type RemoteSupervisor = Operations.RemoteSupervisor;
356
340
  export type DistributedRuntimeManager = Operations.DistributedRuntimeManager;
357
- export type OpsControlPlane = Operations.OpsControlPlane;
358
341
  export type RuntimeTransitionResult = Operations.RuntimeTransitionResult;
359
342
  export type RetentionClass = Operations.RetentionClass;
360
343
  export type RetentionClassConfig = Operations.RetentionClassConfig;
@@ -22,6 +22,18 @@ export interface MemorySpineAdoptionOptions {
22
22
  /** The existing daemon-adoption signal — reuse services.sessionSpineClient.probeReachability() in production. */
23
23
  readonly probeReachability: () => Promise<'unknown' | 'online' | 'offline'>;
24
24
  readonly deactivateReason?: string;
25
+ /**
26
+ * Called exactly on the transition INTO adoption (reachable AND not yet
27
+ * active) — i.e. once per (re)attach to a daemon, at boot and again whenever a
28
+ * daemon reappears after a loss. The agent wires this to the single
29
+ * `?receipts=consume` /status read the daemon delivers its one-shot honesty
30
+ * receipts to (see services.consumeDaemonReceipts / daemon-receipts.ts):
31
+ * reusing the existing adoption edge instead of inventing a second signal, per
32
+ * the same reuse this reconciler already documents for reachability. Best
33
+ * effort — its rejection is swallowed here so a failed receipt read can never
34
+ * undo the adoption that just happened.
35
+ */
36
+ readonly onAttach?: () => void | Promise<void>;
25
37
  }
26
38
 
27
39
  /**
@@ -42,6 +54,12 @@ export async function reconcileMemorySpineAdoption(options: MemorySpineAdoptionO
42
54
  const reachable = (await options.probeReachability()) === 'online';
43
55
  if (reachable && !options.memorySpineClient.active) {
44
56
  options.memorySpineClient.activate(options.transport);
57
+ if (options.onAttach) {
58
+ // Adoption already happened synchronously above; a receipt-read failure
59
+ // must not propagate and make the caller log a false "reachability
60
+ // recheck failed", nor undo the adoption.
61
+ await Promise.resolve().then(() => options.onAttach!()).catch(() => {});
62
+ }
45
63
  return;
46
64
  }
47
65
  if (!reachable && options.memorySpineClient.active) {
@@ -14,11 +14,9 @@ import { AgentPersonaRegistry, assertNoSecretLikeText } from '../../agent/person
14
14
  import { AgentRoutineRegistry } from '../../agent/routine-registry.ts';
15
15
  import { AgentSkillRegistry } from '../../agent/skill-registry.ts';
16
16
  import {
17
- isFeatureFlagConfigKey,
18
- mergeFeatureFlagConfigValue,
19
- readFeatureFlagConfigValue,
20
- type FeatureFlagConfigKey,
21
- } from '../surface-feature-flags.ts';
17
+ expandLegacyFeatureConfigValue,
18
+ isLegacyFeatureConfigKey,
19
+ } from '../feature-enablement.ts';
22
20
  import {
23
21
  getOnboardingRuntimeStatePath,
24
22
  readOnboardingRuntimeState,
@@ -50,25 +48,13 @@ function isMalformedGoodVibesSecretReferenceValue(value: string): boolean {
50
48
  return normalized.startsWith('goodvibes://') && !isGoodVibesSecretReferenceValue(normalized);
51
49
  }
52
50
 
53
- function validateFeatureFlagConfigValue(operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>): boolean {
54
- if (!isFeatureFlagConfigKey(operation.key)) return false;
55
-
56
- if (operation.key === 'featureFlags') {
57
- if (!isPlainObject(operation.value)) throw new Error('featureFlags expects an object value.');
58
- for (const [flagId, state] of Object.entries(operation.value)) {
59
- if (flagId.trim().length === 0) throw new Error('featureFlags cannot contain an empty feature id.');
60
- if (state !== 'enabled' && state !== 'disabled') {
61
- throw new Error(`featureFlags.${flagId} expects enabled or disabled.`);
62
- }
63
- }
64
- return true;
65
- }
66
-
67
- const flagId = operation.key.slice('featureFlags.'.length);
68
- if (flagId.trim().length === 0) throw new Error('featureFlags requires a feature id.');
69
- if (operation.value !== 'enabled' && operation.value !== 'disabled') {
70
- throw new Error(`Config key ${operation.key} expects enabled or disabled.`);
71
- }
51
+ function validateLegacyFeatureConfigValue(operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>): boolean {
52
+ if (!isLegacyFeatureConfigKey(operation.key)) return false;
53
+ // Throws with real guidance on malformed shapes, unknown feature ids, and
54
+ // always-available (constant-binding) features. The legacy featureFlags
55
+ // namespace dissolved onto domain settings keys; the expansion below is
56
+ // exactly what apply will write.
57
+ expandLegacyFeatureConfigValue(operation.key, operation.value);
72
58
  return true;
73
59
  }
74
60
 
@@ -77,7 +63,7 @@ function validateConfigValue(operation: Extract<OnboardingApplyOperation, { kind
77
63
  throw new Error(`Config key ${operation.key} only accepts goodvibes://secrets/... secret references.`);
78
64
  }
79
65
 
80
- if (validateFeatureFlagConfigValue(operation)) return;
66
+ if (validateLegacyFeatureConfigValue(operation)) return;
81
67
 
82
68
  const schema = CONFIG_SCHEMA.find((entry) => entry.key === operation.key);
83
69
  if (!schema) {
@@ -252,6 +238,21 @@ function applyConfigOperation(
252
238
  if ((operation.scope ?? 'global') === 'project') {
253
239
  const path = deps.shellPaths.resolveProjectPath(GOODVIBES_AGENT_SURFACE_ROOT, 'settings.json');
254
240
  const existing = readJsonObject(path);
241
+ if (isLegacyFeatureConfigKey(operation.key)) {
242
+ // Legacy featureFlags keys dissolved onto domain settings keys: persist
243
+ // the translated domain-key writes, never a featureFlags record.
244
+ const writes = expandLegacyFeatureConfigValue(operation.key, operation.value);
245
+ let updated = existing;
246
+ for (const write of writes) {
247
+ updated = setNestedValue(updated, write.key, write.value);
248
+ }
249
+ writeJsonObject(path, updated);
250
+ deps.config.load();
251
+ return {
252
+ kind: operation.kind,
253
+ summary: `Persisted ${writes.map((write) => write.key).join(', ')} (legacy ${operation.key}) in project onboarding settings.`,
254
+ };
255
+ }
255
256
  const updated = setNestedValue(existing, operation.key, operation.value);
256
257
  writeJsonObject(path, updated);
257
258
  deps.config.load();
@@ -262,11 +263,17 @@ function applyConfigOperation(
262
263
  };
263
264
  }
264
265
 
265
- if (isFeatureFlagConfigKey(operation.key)) {
266
- mergeFeatureFlagConfigValue(deps.config, operation.key, operation.value);
267
- } else {
268
- deps.config.setDynamic(operation.key, operation.value);
266
+ if (isLegacyFeatureConfigKey(operation.key)) {
267
+ const writes = expandLegacyFeatureConfigValue(operation.key, operation.value);
268
+ for (const write of writes) {
269
+ deps.config.setDynamic(write.key, write.value);
270
+ }
271
+ return {
272
+ kind: operation.kind,
273
+ summary: `Updated ${writes.map((write) => write.key).join(', ')} (legacy ${operation.key}) in global onboarding settings.`,
274
+ };
269
275
  }
276
+ deps.config.setDynamic(operation.key, operation.value);
270
277
  return {
271
278
  kind: operation.kind,
272
279
  summary: `Updated ${operation.key} in global onboarding settings.`,
@@ -319,15 +326,21 @@ async function buildRollbackAction(
319
326
  );
320
327
  }
321
328
 
322
- const previous = isFeatureFlagConfigKey(operation.key)
323
- ? readFeatureFlagConfigValue(deps.config, operation.key)
324
- : deps.config.get(operation.key);
329
+ const operationKey = operation.key;
330
+ if (isLegacyFeatureConfigKey(operationKey)) {
331
+ // Snapshot the domain settings keys the translated writes will touch and
332
+ // restore those exact values — no featureFlags category exists to merge.
333
+ const writes = expandLegacyFeatureConfigValue(operationKey, operation.value);
334
+ const previousValues = writes.map((write) => ({ key: write.key, previous: deps.config.get(write.key) }));
335
+ return () => {
336
+ for (const entry of previousValues) {
337
+ deps.config.setDynamic(entry.key, entry.previous);
338
+ }
339
+ };
340
+ }
341
+ const previous = deps.config.get(operationKey);
325
342
  return () => {
326
- if (isFeatureFlagConfigKey(operation.key)) {
327
- mergeFeatureFlagConfigValue(deps.config, operation.key, previous ?? 'disabled');
328
- } else {
329
- deps.config.setDynamic(operation.key, previous);
330
- }
343
+ deps.config.setDynamic(operationKey, previous);
331
344
  };
332
345
  }
333
346
 
@@ -1,6 +1,6 @@
1
1
  import type { ConfigManager, ConfigKey, GoodVibesConfig } from '../../config/index.ts';
2
2
  import type { SecretsManager, SecretRecord, SecretStorageReview } from '../../config/secrets.ts';
3
- import type { FeatureFlagConfigKey } from '../surface-feature-flags.ts';
3
+ import type { LegacyFeatureConfigKey } from '../feature-enablement.ts';
4
4
  import type { LocalAuthSnapshot, UserAuthManager } from '@pellux/goodvibes-sdk/platform/security';
5
5
  import type { ShellPathService } from '@/runtime/index.ts';
6
6
  import type {
@@ -226,7 +226,7 @@ export interface OnboardingStepDerivationState {
226
226
  export type OnboardingApplyOperation =
227
227
  | {
228
228
  readonly kind: 'set-config';
229
- readonly key: ConfigKey | FeatureFlagConfigKey;
229
+ readonly key: ConfigKey | LegacyFeatureConfigKey;
230
230
  readonly value: unknown;
231
231
  readonly scope?: 'global' | 'project';
232
232
  }
@@ -6,7 +6,7 @@ import { AgentPersonaRegistry } from '../../agent/persona-registry.ts';
6
6
  import { AgentRoutineRegistry } from '../../agent/routine-registry.ts';
7
7
  import { readAgentRuntimeProfileSelection, resolveAgentRuntimeProfileHome } from '../../agent/runtime-profile.ts';
8
8
  import { AgentSkillRegistry } from '../../agent/skill-registry.ts';
9
- import { isFeatureFlagConfigKey, readFeatureFlagConfigValue } from '../surface-feature-flags.ts';
9
+ import { isFeatureEnabledInConfig, isLegacyFeatureConfigKey, legacyFeatureConfigTargets } from '../feature-enablement.ts';
10
10
  import { readOnboardingRuntimeState } from './state.ts';
11
11
  import type {
12
12
  OnboardingApplyOperation,
@@ -51,9 +51,27 @@ function verifyConfigOperation(
51
51
  deps: OnboardingVerificationDependencies,
52
52
  operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>,
53
53
  ): OnboardingVerificationItem {
54
- const actual = isFeatureFlagConfigKey(operation.key)
55
- ? readFeatureFlagConfigValue(deps.config, operation.key)
56
- : deps.config.get(operation.key);
54
+ if (isLegacyFeatureConfigKey(operation.key)) {
55
+ // Legacy featureFlags keys dissolved onto domain settings keys: verify the
56
+ // DERIVED enablement of every addressed feature, not a category readback.
57
+ const targets = legacyFeatureConfigTargets(operation.key, operation.value);
58
+ const mismatched = targets.filter((target) => (
59
+ (isFeatureEnabledInConfig(deps.config, target.featureId) ? 'enabled' : 'disabled') !== target.desired
60
+ ));
61
+ const ok = targets.length > 0 && mismatched.length === 0;
62
+ return {
63
+ id: `config:${operation.key}`,
64
+ status: ok ? 'pass' : 'fail',
65
+ message: ok
66
+ ? `${operation.key} matches the requested onboarding value (derived from the domain settings keys).`
67
+ : `${operation.key} does not match the requested onboarding value${
68
+ mismatched.length > 0 ? ` (mismatched: ${mismatched.map((target) => target.featureId).join(', ')})` : ''
69
+ }.`,
70
+ target: operation.key,
71
+ };
72
+ }
73
+
74
+ const actual = deps.config.get(operation.key);
57
75
  const ok = isDeepEqual(actual, operation.value);
58
76
 
59
77
  return {