@pellux/goodvibes-tui 1.24.2 → 1.26.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 (77) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/bin/goodvibes-daemon +24 -0
  4. package/docs/foundation-artifacts/operator-contract.json +18234 -12253
  5. package/package.json +8 -7
  6. package/src/cli/help.ts +10 -0
  7. package/src/cli/redaction.ts +68 -3
  8. package/src/config/goodvibes-home.ts +149 -0
  9. package/src/config/secret-config.ts +64 -8
  10. package/src/core/turn-event-wiring.ts +7 -16
  11. package/src/core/workstream-notification.ts +69 -0
  12. package/src/daemon/cli.ts +62 -7
  13. package/src/daemon/handlers/credentials.ts +15 -5
  14. package/src/daemon/handlers/index.ts +7 -6
  15. package/src/daemon/send/channels.ts +200 -0
  16. package/src/daemon/send/command.ts +332 -0
  17. package/src/daemon/send/composition.ts +99 -0
  18. package/src/daemon/send/failure-text.ts +93 -0
  19. package/src/daemon/send/inert-text.ts +225 -0
  20. package/src/daemon/send/stdin.ts +24 -0
  21. package/src/input/commands/calendar-runtime.ts +245 -0
  22. package/src/input/commands/config.ts +68 -2
  23. package/src/input/commands/connection-status.ts +258 -0
  24. package/src/input/commands/local-provider-runtime.ts +9 -1
  25. package/src/input/commands/mail-runtime.ts +267 -0
  26. package/src/input/commands/operator-runtime.ts +6 -1
  27. package/src/input/commands/payment-card-intake.ts +200 -0
  28. package/src/input/commands/profile-render.ts +250 -0
  29. package/src/input/commands/profile-runtime.ts +362 -0
  30. package/src/input/commands/profile-types.ts +381 -0
  31. package/src/input/commands/review-runtime.ts +13 -1
  32. package/src/input/commands.ts +11 -0
  33. package/src/input/composer-editor.ts +4 -0
  34. package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
  35. package/src/input/payments-config.ts +167 -0
  36. package/src/input/provider-key-intake.ts +10 -2
  37. package/src/input/settings-modal-commit.ts +146 -0
  38. package/src/input/settings-modal-connections.ts +102 -0
  39. package/src/input/settings-modal-data.ts +30 -0
  40. package/src/input/settings-modal-reset.ts +6 -2
  41. package/src/input/settings-modal-secrets.ts +9 -2
  42. package/src/input/settings-modal-types.ts +19 -3
  43. package/src/input/settings-modal.ts +54 -107
  44. package/src/main.ts +7 -2
  45. package/src/panels/modals/marketplace-modal.ts +5 -0
  46. package/src/panels/modals/skills-modal.ts +5 -0
  47. package/src/renderer/settings-modal-connections.ts +63 -0
  48. package/src/renderer/settings-modal-helpers.ts +6 -0
  49. package/src/renderer/settings-modal.ts +21 -1
  50. package/src/runtime/bootstrap-core.ts +1 -1
  51. package/src/runtime/bootstrap.ts +2 -2
  52. package/src/runtime/context.ts +9 -0
  53. package/src/runtime/credential-composition.ts +54 -0
  54. package/src/runtime/daemon-handler-composition.ts +0 -4
  55. package/src/runtime/install-self-check-startup.ts +23 -0
  56. package/src/runtime/mail-composition.ts +65 -0
  57. package/src/runtime/onboarding/types.ts +6 -1
  58. package/src/runtime/path-shadow-startup.ts +243 -0
  59. package/src/runtime/path-shadow.ts +418 -0
  60. package/src/runtime/reachability-notice.ts +100 -0
  61. package/src/runtime/runtime-services-types.ts +243 -0
  62. package/src/runtime/services.ts +30 -224
  63. package/src/shell/ui-openers.ts +7 -0
  64. package/src/verification/verification-ledger.ts +158 -0
  65. package/src/version.ts +1 -1
  66. package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
  67. package/src/daemon/handlers/calendar/ics.ts +0 -556
  68. package/src/daemon/handlers/calendar/index.ts +0 -316
  69. package/src/daemon/handlers/email/config.ts +0 -164
  70. package/src/daemon/handlers/email/imap-connector.ts +0 -441
  71. package/src/daemon/handlers/email/imap-parsing.ts +0 -499
  72. package/src/daemon/handlers/email/index.ts +0 -43
  73. package/src/daemon/handlers/email/read-handlers.ts +0 -80
  74. package/src/daemon/handlers/email/runtime.ts +0 -140
  75. package/src/daemon/handlers/email/smtp-connector.ts +0 -557
  76. package/src/daemon/handlers/email/validation.ts +0 -147
  77. package/src/daemon/handlers/email/write-handlers.ts +0 -133
@@ -0,0 +1,381 @@
1
+ /**
2
+ * profile-types.ts
3
+ *
4
+ * Response shapes for the owner-profile control-plane verbs (`profile.*`), and
5
+ * the runtime checks that turn an untyped daemon response into one of them.
6
+ *
7
+ * ## The shapes come from the contract, not from a copy of it
8
+ *
9
+ * Every type below is derived from `OperatorMethodOutput<'profile.…'>` — the
10
+ * types generated from the SDK's own output schemas
11
+ * (`platform/control-plane/method-catalog-owner-profile.ts`). Nothing here
12
+ * restates a field name, so this file cannot drift from the contract: if a
13
+ * verb's shape changes, the checkers stop compiling against it.
14
+ *
15
+ * ## Why the responses are still checked at runtime
16
+ *
17
+ * A generated type is a compile-time claim about what the wire *should* carry,
18
+ * and nothing enforces it at runtime here. The operator client's
19
+ * `validateResponses` defaults on, but it only validates method ids with a
20
+ * matching exported Zod schema and `buildSchemaRegistry` silently skips the
21
+ * rest. Measured against the installed contracts package: 5 of 452 method ids
22
+ * have one, and no `profile.*` verb is among them. So response validation is
23
+ * not a safety net this command can lean on — for these verbs or for most
24
+ * others.
25
+ *
26
+ * So a daemon on a different version — or a 200 from something that is not this
27
+ * daemon at all — would otherwise reach a renderer and throw on a missing array,
28
+ * which in a slash command means a stack trace instead of an answer. Each `toX`
29
+ * function does the cast to `Record<string, unknown>` in ONE place, checks every
30
+ * property a renderer will read, and returns {@link MALFORMED} when anything is
31
+ * off. The command then prints an honest "this build does not recognise that
32
+ * response" line.
33
+ *
34
+ * A malformed *member* fails the whole response rather than being filtered out.
35
+ * Silently dropping a section or a line the checker did not recognise would be
36
+ * the same failure §4.4 forbids in the parser: content disappearing without
37
+ * anyone being told.
38
+ */
39
+ import type { OperatorMethodInput, OperatorMethodOutput } from '@pellux/goodvibes-sdk';
40
+
41
+ /** Returned by every checker when the response does not match the contract. */
42
+ export const MALFORMED: unique symbol = Symbol('malformed-profile-response');
43
+ export type Checked<T> = T | typeof MALFORMED;
44
+
45
+ /** The verbs this command calls. Typing the ids is what types the inputs. */
46
+ export type ProfileReadVerb = 'profile.read' | 'profile.provenance' | 'profile.status';
47
+ export type ProfileWriteVerb = 'profile.set' | 'profile.append' | 'profile.forget' | 'profile.undo';
48
+ export type ProfileVerb = ProfileReadVerb | ProfileWriteVerb;
49
+
50
+ /** The contract's own input shape for a verb, so a wrong field name will not compile. */
51
+ export type ProfileInput<TVerb extends ProfileVerb> = OperatorMethodInput<TVerb>;
52
+
53
+ /**
54
+ * Every key of every member of a union. `keyof` on a union yields only the keys
55
+ * the members SHARE, so a union target would be checked against that
56
+ * intersection and would wave through a key belonging to a sibling verb.
57
+ */
58
+ type UnionKeys<T> = T extends unknown ? keyof T : never;
59
+
60
+ /**
61
+ * Reject a key the contract does not declare, including on a body that is not a
62
+ * fresh object literal.
63
+ *
64
+ * DO NOT "simplify" this away to plain parameter typing. It is load-bearing,
65
+ * not a belt-and-braces backstop, and the measurements say which cases it
66
+ * carries alone. Four ways a stale key can arrive, all compiled against this
67
+ * repo's TypeScript with a `profile.forget`-shaped input:
68
+ *
69
+ * 1. written inline in a fresh literal — parameter typing already rejects
70
+ * 2. written inline BESIDE a spread — parameter typing already rejects
71
+ * 3. body built as a variable first — SLIPS PAST parameter typing
72
+ * 4. carried in by the spread source's type — SLIPS PAST parameter typing
73
+ *
74
+ * A spread literal is not uniformly unchecked, which is the easy thing to get
75
+ * wrong: anything written inline in one is still checked normally, and only what
76
+ * arrives THROUGH the spread escapes, because it belongs to the source's type
77
+ * rather than to the literal. Rows 3 and 4 are the two nothing else catches, and
78
+ * this guard rejects both with TS2345 — measured, not reasoned about.
79
+ *
80
+ * That is not hypothetical: `profile.forget` really did retire `lineIndex`, and
81
+ * a body built one line earlier would have carried it silently past a correctly
82
+ * typed parameter. Seeding inline literals proves only the cases someone thought
83
+ * to seed; this closes the shape.
84
+ *
85
+ * There is a second reason it earns its place here. The SDK's `invoke` is
86
+ * OVERLOADED — a typed signature plus a loose
87
+ * `invoke<T = unknown>(methodId: string, input?: Record<string, unknown>)`. At a
88
+ * bare call site a body the typed overload rejects does not raise an
89
+ * excess-property error at all; overload resolution simply falls through to the
90
+ * loose one and the result degrades to `unknown`. Any error is then incidental,
91
+ * downstream, and disappears entirely if the caller ignores or casts the result.
92
+ * `ProfileInvoke` is a single generic signature with no such fallback, so a bad
93
+ * body fails here, at the call, naming the offending key.
94
+ *
95
+ * Intersecting the parameter with this maps every undeclared key to `never`, so
96
+ * no real value satisfies it however the object was built. Distributed over the
97
+ * target union via {@link UnionKeys} so each member is checked rather than only
98
+ * the keys they have in common.
99
+ */
100
+ export type NoExcessKeys<TBody, TShape> = Record<Exclude<keyof TBody, UnionKeys<TShape>>, never>;
101
+
102
+ /** A body that conforms to a verb's declared input and carries nothing extra. */
103
+ export type ExactProfileInput<TVerb extends ProfileVerb, TBody> =
104
+ TBody & NoExcessKeys<TBody, ProfileInput<TVerb>>;
105
+
106
+ /** What `profile.read` answers: the whole document, by section. */
107
+ export type ProfileDocumentView = OperatorMethodOutput<'profile.read'>;
108
+
109
+ /**
110
+ * Load state — what `profile.status` answers, and the `state` half of a read.
111
+ *
112
+ * `kind` is `loaded` | `unavailable` | `disabled`. The counts belong to
113
+ * `loaded` and `reason` to `unavailable`. There is no value property anywhere
114
+ * in this shape, which is what makes the status output safe to show in a
115
+ * diagnostic context (§11.3).
116
+ */
117
+ export type ProfileStateView = OperatorMethodOutput<'profile.status'>;
118
+
119
+ /** What `profile.provenance` answers for one field. */
120
+ export type ProfileProvenanceReportView = OperatorMethodOutput<'profile.provenance'>;
121
+
122
+ /** What every write verb answers. `ok: false` always carries a reason. */
123
+ export type ProfileWriteResultView = OperatorMethodOutput<'profile.set'>;
124
+
125
+ /** One `## ` section, with the tier its content belongs to. */
126
+ export type ProfileSectionView = ProfileDocumentView['sections'][number];
127
+ /** One mechanical field. `valid: false` still carries the value — see §4.3. */
128
+ export type ProfileFieldView = ProfileSectionView['fields'][number];
129
+ /** One prose line, preserved as written. */
130
+ export type ProfileLineView = ProfileSectionView['prose'][number];
131
+ /** Where a line came from: which surface, when, and the owner's exact words. */
132
+ export type ProfileProvenanceView = NonNullable<ProfileFieldView['provenance']>;
133
+ /** A mechanical value that did not validate, and why. Never fails the file. */
134
+ export type ProfileInvalidFieldView = NonNullable<ProfileStateView['invalidFields']>[number];
135
+ /** A retained `<!-- was: … -->` predecessor, so a wrong correction is recoverable. */
136
+ export type ProfileSupersededView = ProfileProvenanceReportView['superseded'][number];
137
+ /** One thing a write did. Names the field; never repeats the value. */
138
+ export type ProfileChangeView = ProfileWriteResultView['changes'][number];
139
+
140
+ /**
141
+ * Compile-time proof of two things this file assumes: that all four write verbs
142
+ * really do answer one shape, and that `profile.status` really is the same
143
+ * `state` a read carries.
144
+ *
145
+ * One write checker serves four verbs and `toProfileState` checks both the
146
+ * status response and a read's `state`. If the contract ever splits them,
147
+ * `Exact` collapses to `never` and this assignment stops compiling, rather than
148
+ * the difference going unnoticed until a response renders wrong.
149
+ */
150
+ type Exact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
151
+ export const PROFILE_CONTRACT_SHAPES_AGREE: Exact<OperatorMethodOutput<'profile.append'>, ProfileWriteResultView>
152
+ & Exact<OperatorMethodOutput<'profile.forget'>, ProfileWriteResultView>
153
+ & Exact<OperatorMethodOutput<'profile.undo'>, ProfileWriteResultView>
154
+ & Exact<ProfileDocumentView['state'], ProfileStateView> = true;
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Primitive checks
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /** The single place an untyped response is narrowed to an indexable object. */
161
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
162
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
163
+ return value as unknown as Record<string, unknown>;
164
+ }
165
+
166
+ function isString(value: unknown): value is string {
167
+ return typeof value === 'string';
168
+ }
169
+
170
+ function isBoolean(value: unknown): value is boolean {
171
+ return typeof value === 'boolean';
172
+ }
173
+
174
+ function isFiniteNumber(value: unknown): value is number {
175
+ return typeof value === 'number' && Number.isFinite(value);
176
+ }
177
+
178
+ /** Absent and null both mean "not supplied"; anything else must be a string. */
179
+ function optionalString(value: unknown): Checked<string | undefined> {
180
+ if (value === undefined || value === null) return undefined;
181
+ return isString(value) ? value : MALFORMED;
182
+ }
183
+
184
+ function optionalBoolean(value: unknown): Checked<boolean | undefined> {
185
+ if (value === undefined || value === null) return undefined;
186
+ return isBoolean(value) ? value : MALFORMED;
187
+ }
188
+
189
+ function optionalNumber(value: unknown): Checked<number | undefined> {
190
+ if (value === undefined || value === null) return undefined;
191
+ return isFiniteNumber(value) ? value : MALFORMED;
192
+ }
193
+
194
+ /** An array whose every member checks out, or MALFORMED. Never a filtered subset. */
195
+ function checkedArray<T>(value: unknown, check: (entry: unknown) => Checked<T>): Checked<T[]> {
196
+ if (!Array.isArray(value)) return MALFORMED;
197
+ const out: T[] = [];
198
+ for (const entry of value) {
199
+ const parsed = check(entry);
200
+ if (parsed === MALFORMED) return MALFORMED;
201
+ out.push(parsed);
202
+ }
203
+ return out;
204
+ }
205
+
206
+ function checkedStringArray(value: unknown): Checked<string[]> {
207
+ return checkedArray(value, (entry) => (isString(entry) ? entry : MALFORMED));
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Response checks
212
+ // ---------------------------------------------------------------------------
213
+
214
+ function toProvenance(value: unknown): Checked<ProfileProvenanceView | undefined> {
215
+ if (value === undefined || value === null) return undefined;
216
+ const record = asRecord(value);
217
+ if (!record) return MALFORMED;
218
+ const { surface, date, said } = record;
219
+ if (!isString(surface) || !isString(date) || !isString(said)) return MALFORMED;
220
+ return { surface, date, said };
221
+ }
222
+
223
+ function toLine(value: unknown): Checked<ProfileLineView> {
224
+ const record = asRecord(value);
225
+ if (!record) return MALFORMED;
226
+ const { lineIndex, section, text } = record;
227
+ if (!isFiniteNumber(lineIndex) || !isString(section) || !isString(text)) return MALFORMED;
228
+ const provenance = toProvenance(record.provenance);
229
+ if (provenance === MALFORMED) return MALFORMED;
230
+ return { lineIndex, section, text, ...(provenance ? { provenance } : {}) };
231
+ }
232
+
233
+ function toField(value: unknown): Checked<ProfileFieldView> {
234
+ const record = asRecord(value);
235
+ if (!record) return MALFORMED;
236
+ const { fieldId, label, value: fieldValue, valid } = record;
237
+ if (!isString(fieldId) || !isString(label) || !isString(fieldValue) || !isBoolean(valid)) return MALFORMED;
238
+ const invalidReason = optionalString(record.invalidReason);
239
+ if (invalidReason === MALFORMED) return MALFORMED;
240
+ const provenance = toProvenance(record.provenance);
241
+ if (provenance === MALFORMED) return MALFORMED;
242
+ return {
243
+ fieldId,
244
+ label,
245
+ value: fieldValue,
246
+ valid,
247
+ ...(invalidReason === undefined ? {} : { invalidReason }),
248
+ ...(provenance ? { provenance } : {}),
249
+ };
250
+ }
251
+
252
+ function toSection(value: unknown): Checked<ProfileSectionView> {
253
+ const record = asRecord(value);
254
+ if (!record) return MALFORMED;
255
+ const { heading, tier } = record;
256
+ if (!isString(heading) || !isString(tier)) return MALFORMED;
257
+ const fields = checkedArray(record.fields, toField);
258
+ if (fields === MALFORMED) return MALFORMED;
259
+ const prose = checkedArray(record.prose, toLine);
260
+ if (prose === MALFORMED) return MALFORMED;
261
+ return { heading, tier, fields, prose };
262
+ }
263
+
264
+ function toInvalidField(value: unknown): Checked<ProfileInvalidFieldView> {
265
+ const record = asRecord(value);
266
+ if (!record) return MALFORMED;
267
+ const { fieldId, reason } = record;
268
+ if (!isString(fieldId) || !isString(reason)) return MALFORMED;
269
+ return { fieldId, reason };
270
+ }
271
+
272
+ /** `profile.status`, and the `state` half of `profile.read`. */
273
+ export function toProfileState(value: unknown): Checked<ProfileStateView> {
274
+ const record = asRecord(value);
275
+ if (!record) return MALFORMED;
276
+ const { kind, path } = record;
277
+ if (!isString(kind) || !isString(path)) return MALFORMED;
278
+ const exists = optionalBoolean(record.exists);
279
+ if (exists === MALFORMED) return MALFORMED;
280
+ const lineCount = optionalNumber(record.lineCount);
281
+ if (lineCount === MALFORMED) return MALFORMED;
282
+ const fieldCount = optionalNumber(record.fieldCount);
283
+ if (fieldCount === MALFORMED) return MALFORMED;
284
+ const proseLineCount = optionalNumber(record.proseLineCount);
285
+ if (proseLineCount === MALFORMED) return MALFORMED;
286
+ const reason = optionalString(record.reason);
287
+ if (reason === MALFORMED) return MALFORMED;
288
+ const sections = record.sections === undefined || record.sections === null
289
+ ? undefined
290
+ : checkedStringArray(record.sections);
291
+ if (sections === MALFORMED) return MALFORMED;
292
+ const invalidFields = record.invalidFields === undefined || record.invalidFields === null
293
+ ? undefined
294
+ : checkedArray(record.invalidFields, toInvalidField);
295
+ if (invalidFields === MALFORMED) return MALFORMED;
296
+ return {
297
+ kind,
298
+ path,
299
+ ...(exists === undefined ? {} : { exists }),
300
+ ...(lineCount === undefined ? {} : { lineCount }),
301
+ ...(fieldCount === undefined ? {} : { fieldCount }),
302
+ ...(proseLineCount === undefined ? {} : { proseLineCount }),
303
+ ...(sections === undefined ? {} : { sections }),
304
+ ...(invalidFields === undefined ? {} : { invalidFields }),
305
+ ...(reason === undefined ? {} : { reason }),
306
+ };
307
+ }
308
+
309
+ /** `profile.read`. */
310
+ export function toProfileDocument(value: unknown): Checked<ProfileDocumentView> {
311
+ const record = asRecord(value);
312
+ if (!record) return MALFORMED;
313
+ const state = toProfileState(record.state);
314
+ if (state === MALFORMED) return MALFORMED;
315
+ const sections = checkedArray(record.sections, toSection);
316
+ if (sections === MALFORMED) return MALFORMED;
317
+ return { state, sections };
318
+ }
319
+
320
+ function toSuperseded(value: unknown): Checked<ProfileSupersededView> {
321
+ const record = asRecord(value);
322
+ if (!record) return MALFORMED;
323
+ const { lineIndex, fieldId, section, text, value: supersededValue, supersededOn, previousLine } = record;
324
+ if (
325
+ !isFiniteNumber(lineIndex)
326
+ || !isString(fieldId)
327
+ || !isString(section)
328
+ || !isString(text)
329
+ || !isString(supersededValue)
330
+ || !isString(supersededOn)
331
+ || !isString(previousLine)
332
+ ) return MALFORMED;
333
+ const provenance = toProvenance(record.provenance);
334
+ if (provenance === MALFORMED) return MALFORMED;
335
+ return {
336
+ lineIndex,
337
+ fieldId,
338
+ section,
339
+ text,
340
+ value: supersededValue,
341
+ supersededOn,
342
+ previousLine,
343
+ ...(provenance ? { provenance } : {}),
344
+ };
345
+ }
346
+
347
+ /** `profile.provenance`. */
348
+ export function toProfileProvenanceReport(value: unknown): Checked<ProfileProvenanceReportView> {
349
+ const record = asRecord(value);
350
+ if (!record) return MALFORMED;
351
+ const { fieldId, present, handEdited } = record;
352
+ if (!isString(fieldId) || !isBoolean(present) || !isBoolean(handEdited)) return MALFORMED;
353
+ const provenance = toProvenance(record.provenance);
354
+ if (provenance === MALFORMED) return MALFORMED;
355
+ const superseded = checkedArray(record.superseded, toSuperseded);
356
+ if (superseded === MALFORMED) return MALFORMED;
357
+ return { fieldId, present, handEdited, ...(provenance ? { provenance } : {}), superseded };
358
+ }
359
+
360
+ function toChange(value: unknown): Checked<ProfileChangeView> {
361
+ const record = asRecord(value);
362
+ if (!record) return MALFORMED;
363
+ const { kind, section, label, superseded } = record;
364
+ if (!isString(kind) || !isString(section) || !isString(label) || !isBoolean(superseded)) return MALFORMED;
365
+ const fieldId = optionalString(record.fieldId);
366
+ if (fieldId === MALFORMED) return MALFORMED;
367
+ return { kind, section, label, superseded, ...(fieldId === undefined ? {} : { fieldId }) };
368
+ }
369
+
370
+ /** `profile.set`, `profile.append`, `profile.forget`, `profile.undo`. */
371
+ export function toProfileWriteResult(value: unknown): Checked<ProfileWriteResultView> {
372
+ const record = asRecord(value);
373
+ if (!record) return MALFORMED;
374
+ const { ok, disclosure } = record;
375
+ if (!isBoolean(ok) || !isString(disclosure)) return MALFORMED;
376
+ const reason = optionalString(record.reason);
377
+ if (reason === MALFORMED) return MALFORMED;
378
+ const changes = checkedArray(record.changes, toChange);
379
+ if (changes === MALFORMED) return MALFORMED;
380
+ return { ok, disclosure, changes, ...(reason === undefined ? {} : { reason }) };
381
+ }
@@ -26,7 +26,19 @@ interface RevertHunkResult {
26
26
  readonly refusal: { readonly reason: string } | null;
27
27
  }
28
28
 
29
- const INVOKE_CONTEXT = { context: { clientKind: 'tui' as const } };
29
+ /**
30
+ * The review UI's invoke context.
31
+ *
32
+ * `explicitUserRequest: true` is honest here and only here-shaped: this path
33
+ * runs because a person is looking at a hunk and pressed a key. Scheduled
34
+ * work, triggers and channel-driven work must never set it — the distinction
35
+ * is exactly "did the owner ask for this right now", and it is what lets a
36
+ * confirmation-gated verb tell an authorized action apart from one initiated
37
+ * by content.
38
+ */
39
+ const INVOKE_CONTEXT = {
40
+ context: { clientKind: 'tui' as const, metadata: { explicitUserRequest: true } },
41
+ };
30
42
 
31
43
  /** True for the honest 409 the revert verb throws when the hunk drifted since it was captured. */
32
44
  function isConflict(err: unknown): boolean {
@@ -11,6 +11,7 @@ import { registerConfigCommand } from './commands/config.ts';
11
11
  import { registerDiscoveryRuntimeCommands } from './commands/discovery-runtime.ts';
12
12
  import { registerPlanningRuntimeCommands } from './commands/planning-runtime.ts';
13
13
  import { registerSecretRuntimeCommands } from './commands/secret-runtime.ts';
14
+ import { registerPaymentCardCommands } from './commands/payment-card-intake.ts';
14
15
  import { registerScheduleRuntimeCommands } from './commands/schedule-runtime.ts';
15
16
  import { registerBranchRuntimeCommands } from './commands/branch-runtime.ts';
16
17
  import { registerOperatorRuntimeCommands } from './commands/operator-runtime.ts';
@@ -79,7 +80,10 @@ import { registerReviewRuntimeCommands } from './commands/review-runtime.ts';
79
80
  import { registerPaletteRuntimeCommands } from './commands/palette-runtime.ts';
80
81
  import { registerCiRuntimeCommands } from './commands/ci-runtime.ts';
81
82
  import { registerCheckinRuntimeCommands } from './commands/checkin-runtime.ts';
83
+ import { registerMailRuntimeCommands } from './commands/mail-runtime.ts';
84
+ import { registerCalendarRuntimeCommands } from './commands/calendar-runtime.ts';
82
85
  import { registerPrincipalsRuntimeCommands } from './commands/principals-runtime.ts';
86
+ import { registerProfileRuntimeCommands } from './commands/profile-runtime.ts';
83
87
 
84
88
  /**
85
89
  * A named group of built-in commands. The `category` label is the single
@@ -160,6 +164,7 @@ export const BUILTIN_COMMAND_GROUPS: readonly BuiltinCommandGroup[] = [
160
164
  { category: 'Discovery', register: registerDiscoveryRuntimeCommands },
161
165
  { category: 'Planning', register: registerPlanningRuntimeCommands },
162
166
  { category: 'Secrets', register: registerSecretRuntimeCommands },
167
+ { category: 'Payments', register: registerPaymentCardCommands },
163
168
  { category: 'Scheduling', register: registerScheduleRuntimeCommands },
164
169
  { category: 'Branches', register: registerBranchRuntimeCommands },
165
170
  { category: 'Session Content', register: registerSessionContentCommands },
@@ -173,7 +178,13 @@ export const BUILTIN_COMMAND_GROUPS: readonly BuiltinCommandGroup[] = [
173
178
  { category: 'Diff & Review', register: registerReviewRuntimeCommands },
174
179
  { category: 'CI', register: registerCiRuntimeCommands },
175
180
  { category: 'Check-in', register: registerCheckinRuntimeCommands },
181
+ { category: 'Mail & Calendar', register: registerMailRuntimeCommands },
182
+ { category: 'Mail & Calendar', register: registerCalendarRuntimeCommands },
176
183
  { category: 'Principals', register: registerPrincipalsRuntimeCommands },
184
+ // "Owner Profile", not "Profiles": the Profiles category above is
185
+ // /profile-sync's named-config-preset bundles, which have nothing to do with
186
+ // what the platform knows about the person who owns it.
187
+ { category: 'Owner Profile', register: registerProfileRuntimeCommands },
177
188
  { category: 'Policy', register: (registry) => registry.register(policyCommand) },
178
189
  { category: 'Providers', register: (registry) => registry.register(providerCommand) },
179
190
  { category: 'Eval', register: (registry) => registry.register(evalCommand) },
@@ -66,6 +66,10 @@ export function openComposerInEditor(deps: ComposerEditorDeps): void {
66
66
  return;
67
67
  }
68
68
  const draft = deps.readDraft();
69
+ // Real production scratch (runs on the end user's machine, not test
70
+ // infrastructure), so this stays rooted at the real OS temp dir rather
71
+ // than the test-only makeProjectTempDir helper. Cleaned up in the
72
+ // `finally` below on every exit path.
69
73
  const dir = mkdtempSync(join(tmpdir(), 'goodvibes-composer-'));
70
74
  const file = join(dir, 'COMPOSER.md');
71
75
  const spawn = deps.spawn ?? defaultSpawn;
@@ -52,6 +52,18 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
52
52
  acknowledged: controller.getBooleanFieldValue(fieldId, false),
53
53
  });
54
54
  };
55
+ // Every credential the first-run wizard captures is one the DAEMON executes
56
+ // with: model provider API keys (the daemon runs the model), the Cloudflare
57
+ // API token, and the masked fields of the external surfaces — all of which
58
+ // live under daemon-owned `surfaces.*` config paths. So they all go to the
59
+ // daemon tier, which is the one tier every surface AND the daemon read.
60
+ //
61
+ // 'project' — what this used to pass — filed them under whatever directory
62
+ // the wizard happened to be run from. Run the wizard in ~/code/foo and the
63
+ // daemon, which has no idea that directory exists, found nothing; run it
64
+ // again elsewhere and the first setup silently stopped applying. First-run
65
+ // setup is exactly the moment where "configure once, works everywhere" has
66
+ // to hold.
55
67
  const setSecret = (key: string, value: string): void => {
56
68
  if (value.length === 0) return;
57
69
  const medium = controller.getSelectedSecretMedium();
@@ -59,7 +71,7 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
59
71
  kind: 'set-secret',
60
72
  key,
61
73
  value,
62
- scope: 'project',
74
+ scope: 'daemon',
63
75
  medium,
64
76
  });
65
77
  };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * payments-config.ts — TUI-local synthetic settings for the payment card
3
+ * capability's card MATERIAL fields.
4
+ *
5
+ * The SDK's CONFIG_SCHEMA now has a full real `payments` section (28 keys:
6
+ * enabled, defaultCardId, currency, cvvHandling, the six budget keys,
7
+ * shipping.preferredTier, the fourteen billing/shipping address sub-fields
8
+ * (name, line1, line2, city, region, postalCode, country — each repeated for
9
+ * billingAddress and shippingAddress), the two window keys, and
10
+ * notifyChannels — see `@pellux/goodvibes-sdk/platform/config`). Every one of
11
+ * those keys reads and writes through the ordinary CONFIG_SCHEMA-driven path
12
+ * in settings-modal-data.ts's buildSettingGroups, exactly like `relay.*` or
13
+ * any other real SDK domain — no synthetic entry or dedicated store is needed
14
+ * for them, and this module builds none. (An earlier round of this module
15
+ * modeled billing/shipping address as two flat TUI-local strings; the SDK's
16
+ * own structured per-field keys superseded that the moment they shipped, so
17
+ * those two synthetic entries are gone from this file entirely.)
18
+ *
19
+ * What this module still builds synthetic entries for is the four card
20
+ * MATERIAL fields: number, expiry, CVV, cardholder name. Per the SDK's own
21
+ * design (see its schema-domain-payments.ts header and `platform/payments`'s
22
+ * method catalog), card material is deliberately never a config path at all:
23
+ * it lives write-only in the daemon secret store, named by keys the daemon's
24
+ * own `payments.cards.create` control-plane method derives internally. The
25
+ * SDK does not yet expose an equivalent config-level path for a single
26
+ * implicit card the way this app's `/payments card` flow models it, so these
27
+ * four fields are synthetic sub-keys under the SDK's real `payments`
28
+ * section — the same established pattern this codebase already uses for
29
+ * `tts.speed`, `behavior.notifyAfterSeconds`, etc.: a key one level under an
30
+ * EXISTING section that CONFIG_SCHEMA hasn't grown a scalar entry for yet.
31
+ *
32
+ * That one-level-under-an-existing-section shape matters mechanically: the
33
+ * real ConfigManager's dotted-path resolver only throws "Invalid config path"
34
+ * when an INTERMEDIATE segment is missing (e.g. `payments.card.number`, where
35
+ * `card` is not itself a section) — it does not throw for a final leaf that
36
+ * the schema hasn't declared (`payments.cardNumber`), the same tolerance
37
+ * `tts.speed` already relies on under `tts`. So these four keys are named
38
+ * FLAT (`payments.cardNumber`, not `payments.card.number`) specifically so
39
+ * `ConfigManager.get/setDynamic` accepts them — verified against the real
40
+ * ConfigManager (not the fake store this module used before this round): a
41
+ * flat sub-key persists to the real daemon-owned settings file precisely
42
+ * because `payments.` is a `DAEMON_OWNED_CONFIG_PREFIXES` entry, which is
43
+ * exactly what makes the daemon (and every other surface) able to see it
44
+ * after this TUI closes — the thing the local JSON store this module used to
45
+ * use never gave them.
46
+ *
47
+ * All four keys are secret-tier (see config/secret-config.ts's
48
+ * SECRET_CONFIG_KEYS). Primary entry for those is the concealed-input flow in
49
+ * commands/payment-card-intake.ts, itself gated on the SDK's
50
+ * `mayOfferCardEntryFlow` (card details may only be typed at a local
51
+ * terminal or the webui, never over a remote messaging surface — see that
52
+ * file's header); they are also reachable as ordinary secret-backed settings
53
+ * rows here, which is what makes them masked mid-edit — see
54
+ * renderer/settings-modal.ts's currentSettingValue().
55
+ *
56
+ * cvvHandling — real now, not built here — selects whether the CVV is stored
57
+ * (the default, per the 2026-07-27 payment-capability ruling) or requested at
58
+ * purchase time; see `CVV_PROMPT_TRADEOFF_WARNING`, imported directly from
59
+ * `@pellux/goodvibes-sdk/platform/payments` by every caller that needs it
60
+ * (renderer/settings-modal.ts, settings-modal.ts) — no local copy lives here
61
+ * or anywhere else in this app.
62
+ */
63
+
64
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
65
+ import type { ConfigKey } from '../config/index.ts';
66
+ import type { SettingEntry } from './settings-modal-types.ts';
67
+
68
+ /** Real ConfigManager's read surface — these four keys are defensive reads (see header comment). */
69
+ export type PaymentsConfigReader = Pick<ConfigManager, 'get'>;
70
+
71
+ export const PAYMENTS_CARD_NUMBER_CONFIG_KEY = 'payments.cardNumber' as ConfigKey;
72
+ export const PAYMENTS_CARD_EXPIRY_CONFIG_KEY = 'payments.cardExpiry' as ConfigKey;
73
+ export const PAYMENTS_CARD_CVV_CONFIG_KEY = 'payments.cardCvv' as ConfigKey;
74
+ export const PAYMENTS_CARD_CARDHOLDER_NAME_CONFIG_KEY = 'payments.cardholderName' as ConfigKey;
75
+
76
+ /** The real SDK schema key for the CVV-handling selector — no longer synthetic. */
77
+ export const PAYMENTS_CVV_HANDLING_CONFIG_KEY = 'payments.cvvHandling' as ConfigKey;
78
+
79
+ function readStringField(configManager: PaymentsConfigReader, key: ConfigKey): string {
80
+ // Defensive try/catch, same rationale as worktree-setup-config.ts's
81
+ // readWorktreeSetupList and this codebase's other one-level-under-a-real-
82
+ // section synthetic reads (tts.speed, behavior.notifyAfterSeconds): the real
83
+ // ConfigManager never throws for these particular keys (verified — see
84
+ // header comment), but degrading to empty rather than crashing the whole
85
+ // settings modal is the same posture every synthetic setting here takes
86
+ // toward an unexpected value.
87
+ try {
88
+ const raw = configManager.get(key);
89
+ return typeof raw === 'string' ? raw : '';
90
+ } catch {
91
+ return '';
92
+ }
93
+ }
94
+
95
+ function buildStringFieldEntry(
96
+ configManager: PaymentsConfigReader,
97
+ key: ConfigKey,
98
+ description: string,
99
+ ): SettingEntry {
100
+ const currentValue = readStringField(configManager, key);
101
+ return {
102
+ setting: { key, type: 'string', default: '', description },
103
+ currentValue,
104
+ isDefault: currentValue === '',
105
+ };
106
+ }
107
+
108
+ export function buildPaymentsCardNumberEntry(configManager: PaymentsConfigReader): SettingEntry {
109
+ return buildStringFieldEntry(
110
+ configManager,
111
+ PAYMENTS_CARD_NUMBER_CONFIG_KEY,
112
+ 'Payment card number. Stored through the secret manager at daemon scope; entering it here or via /payments card never shows the typed characters in plaintext.',
113
+ );
114
+ }
115
+
116
+ export function buildPaymentsCardExpiryEntry(configManager: PaymentsConfigReader): SettingEntry {
117
+ return buildStringFieldEntry(
118
+ configManager,
119
+ PAYMENTS_CARD_EXPIRY_CONFIG_KEY,
120
+ 'Payment card expiry (MM/YY). Stored through the secret manager at daemon scope, same handling as the card number.',
121
+ );
122
+ }
123
+
124
+ export function buildPaymentsCardCvvEntry(configManager: PaymentsConfigReader): SettingEntry {
125
+ return buildStringFieldEntry(
126
+ configManager,
127
+ PAYMENTS_CARD_CVV_CONFIG_KEY,
128
+ 'Payment card CVV. Stored through the secret manager at daemon scope: never logged, never rendered, never shown mid-edit, excluded from every export and diagnostic dump.',
129
+ );
130
+ }
131
+
132
+ export function buildPaymentsCardCardholderNameEntry(configManager: PaymentsConfigReader): SettingEntry {
133
+ return buildStringFieldEntry(
134
+ configManager,
135
+ PAYMENTS_CARD_CARDHOLDER_NAME_CONFIG_KEY,
136
+ 'Name on the payment card. Stored through the secret manager at daemon scope, same handling as the card number.',
137
+ );
138
+ }
139
+
140
+ /** All payments synthetic entries (the four card-material fields), in the order they should render. */
141
+ export function buildPaymentsSyntheticEntries(configManager: PaymentsConfigReader): SettingEntry[] {
142
+ return [
143
+ buildPaymentsCardNumberEntry(configManager),
144
+ buildPaymentsCardExpiryEntry(configManager),
145
+ buildPaymentsCardCvvEntry(configManager),
146
+ buildPaymentsCardCardholderNameEntry(configManager),
147
+ ];
148
+ }
149
+
150
+ /** Every payments synthetic config key this module owns, for refresh-loop membership checks. */
151
+ export const PAYMENTS_SYNTHETIC_CONFIG_KEYS: readonly ConfigKey[] = [
152
+ PAYMENTS_CARD_NUMBER_CONFIG_KEY,
153
+ PAYMENTS_CARD_EXPIRY_CONFIG_KEY,
154
+ PAYMENTS_CARD_CVV_CONFIG_KEY,
155
+ PAYMENTS_CARD_CARDHOLDER_NAME_CONFIG_KEY,
156
+ ];
157
+
158
+ export function isPaymentsSyntheticConfigKey(key: string): key is ConfigKey {
159
+ return (PAYMENTS_SYNTHETIC_CONFIG_KEYS as readonly string[]).includes(key);
160
+ }
161
+
162
+ /** Refresh one payments synthetic entry's currentValue/isDefault in place. */
163
+ export function refreshPaymentsSyntheticEntry(entry: SettingEntry, configManager: PaymentsConfigReader): void {
164
+ const currentValue = readStringField(configManager, entry.setting.key as ConfigKey);
165
+ entry.currentValue = currentValue;
166
+ entry.isDefault = currentValue === '';
167
+ }