@pellux/goodvibes-tui 1.13.1 → 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 (158) hide show
  1. package/CHANGELOG.md +96 -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 +33 -33
  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/gateway-verbs.ts +0 -47
  158. package/src/runtime/render-scheduler.ts +0 -90
@@ -0,0 +1,95 @@
1
+ // ---------------------------------------------------------------------------
2
+ // workstream-draft-edits.ts — pure item edits for a not-yet-launched proposal
3
+ //
4
+ // The plan-review gate (see /workstream, input/commands/workstream-runtime.ts)
5
+ // lets a human reshape a decomposed proposal BEFORE anything is spent: rewrite
6
+ // one item's brief, drop an item, or reorder items. These are pure functions
7
+ // over the launchable CreateWorkstreamInput (@pellux/goodvibes-sdk/platform/
8
+ // orchestration) — no engine calls, no I/O — so the same spec that is edited
9
+ // here is byte-for-byte what launch hands createWorkstream. Extracted from
10
+ // workstream-services.ts to keep that construction module lean (its own
11
+ // header explains the 800-line-cap discipline) and to give the edits a clean
12
+ // unit-test target.
13
+ //
14
+ // HONEST SCOPE (stated so the review surface never overstates what an edit
15
+ // does):
16
+ // - editItemBrief rewrites an item's `task` (the instructions its agent
17
+ // runs), never its `title` (the short label). The engineer/review phase
18
+ // template is unchanged — a brief is WHAT the item does, not HOW the
19
+ // pipeline runs it.
20
+ // - removeItem also strips the removed item's id from every other item's
21
+ // `dependsOn`, so the spec never carries a dangling dependency into
22
+ // fromPlanProposal's assembly (which would otherwise reject it). It
23
+ // refuses to remove the last item — a workstream needs at least one.
24
+ // - moveItem reorders the AUTHORING order (the ordinals shown in the review
25
+ // and the tie-break when the engine has a free capacity slot and several
26
+ // claimable items). It never rewrites dependencies, so it cannot create a
27
+ // cycle; the engine still schedules strictly by dependency + capacity, not
28
+ // by array position. Reordering is a presentation/authoring act, not a
29
+ // scheduling override — and is documented as such where it is offered.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ import type { CreateWorkstreamInput, WorkItemSpec } from '@pellux/goodvibes-sdk/platform/orchestration';
33
+
34
+ /** Success carries the rewritten spec; failure carries an honest, user-facing reason. */
35
+ export type DraftEditResult = { readonly spec: CreateWorkstreamInput } | { readonly error: string };
36
+
37
+ /**
38
+ * Resolve a user-typed item reference to a 0-based index. A reference is
39
+ * either the 1-based ordinal shown in the proposal (the primary handle) or an
40
+ * item id / unique id-prefix (the stable handle that survives reordering). An
41
+ * ambiguous or unmatched reference resolves to undefined so the caller can
42
+ * refuse honestly rather than editing the wrong item.
43
+ */
44
+ export function resolveItemIndex(items: readonly WorkItemSpec[], ref: string): number | undefined {
45
+ const trimmed = ref.trim();
46
+ const ordinal = Number(trimmed);
47
+ if (Number.isInteger(ordinal) && ordinal >= 1 && ordinal <= items.length) return ordinal - 1;
48
+ const matches = items
49
+ .map((item, index) => ({ item, index }))
50
+ .filter(({ item }) => item.id !== undefined && item.id.startsWith(trimmed));
51
+ return matches.length === 1 ? matches[0]!.index : undefined;
52
+ }
53
+
54
+ function unresolvedError(items: readonly WorkItemSpec[], ref: string): string {
55
+ return `No draft item matches "${ref}". Use a number from 1 to ${items.length}, or an item id.`;
56
+ }
57
+
58
+ /** Rewrite one item's brief (its `task`). Empty/whitespace briefs are refused. */
59
+ export function editItemBrief(spec: CreateWorkstreamInput, ref: string, brief: string): DraftEditResult {
60
+ const index = resolveItemIndex(spec.items, ref);
61
+ if (index === undefined) return { error: unresolvedError(spec.items, ref) };
62
+ const trimmed = brief.trim();
63
+ if (!trimmed) return { error: 'A new brief cannot be empty.' };
64
+ const items = spec.items.map((item, i) => (i === index ? { ...item, task: trimmed } : item));
65
+ return { spec: { ...spec, items } };
66
+ }
67
+
68
+ /** Drop one item and strip its id from every sibling's dependsOn. Refuses to empty the plan. */
69
+ export function removeItemFromSpec(spec: CreateWorkstreamInput, ref: string): DraftEditResult {
70
+ const index = resolveItemIndex(spec.items, ref);
71
+ if (index === undefined) return { error: unresolvedError(spec.items, ref) };
72
+ if (spec.items.length <= 1) return { error: 'Cannot remove the last item — a workstream needs at least one.' };
73
+ const removedId = spec.items[index]!.id;
74
+ const items = spec.items
75
+ .filter((_, i) => i !== index)
76
+ .map((item) =>
77
+ removedId && item.dependsOn?.includes(removedId)
78
+ ? { ...item, dependsOn: item.dependsOn.filter((dep) => dep !== removedId) }
79
+ : item,
80
+ );
81
+ return { spec: { ...spec, items } };
82
+ }
83
+
84
+ /** Reorder one item to a new 1-based authoring position. Never touches dependencies. */
85
+ export function moveItemInSpec(spec: CreateWorkstreamInput, ref: string, toPosition: number): DraftEditResult {
86
+ const index = resolveItemIndex(spec.items, ref);
87
+ if (index === undefined) return { error: unresolvedError(spec.items, ref) };
88
+ if (!Number.isInteger(toPosition) || toPosition < 1 || toPosition > spec.items.length) {
89
+ return { error: `Position must be a whole number from 1 to ${spec.items.length}.` };
90
+ }
91
+ const items = [...spec.items];
92
+ const [moved] = items.splice(index, 1);
93
+ items.splice(toPosition - 1, 0, moved!);
94
+ return { spec: { ...spec, items } };
95
+ }
@@ -0,0 +1,101 @@
1
+ // ---------------------------------------------------------------------------
2
+ // workstream-draft-store.ts — durable journal for not-yet-launched proposals
3
+ //
4
+ // A /workstream proposal (WorkstreamDraft, workstream-services.ts) is TUI-owned
5
+ // state the OrchestrationEngine has no concept of — its only creation entry
6
+ // point materializes a real, ticking workstream with no pre-launch "draft"
7
+ // stage (see that module's REALITY-WINS header). Earlier waves therefore held
8
+ // drafts in memory only, and a restart between create and launch silently lost
9
+ // them. This store journals each draft to disk so the plan-review gate survives
10
+ // a restart and is resumable: the same reshaped, approved proposal is still
11
+ // there to launch afterward.
12
+ //
13
+ // This is TUI-side persistence, NOT an engine feature — it sits ALONGSIDE the
14
+ // engine's own .goodvibes/orchestration/<id>.json workstream snapshots, under a
15
+ // drafts/ subdirectory, and is loaded by the command facade at construction the
16
+ // same way the engine's resumeAllFromDisk() reloads live workstreams. A draft
17
+ // is a pure-data object (id/task/spec/gate/proposal/provenance/approved), so a
18
+ // plain JSON round-trip is lossless.
19
+ //
20
+ // Every operation is guarded and NEVER throws: a missing directory, an
21
+ // unreadable or malformed file, or a write failure degrades to "that draft
22
+ // isn't there" rather than crashing session startup — mirroring the engine
23
+ // persistence layer's own quarantine-don't-propagate discipline.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import type { WorkstreamDraft } from './workstream-draft-types.ts';
29
+
30
+ export interface WorkstreamDraftStore {
31
+ /** Every valid draft on disk, oldest first. A directory that doesn't exist yet ⇒ []. Malformed files are skipped, never fatal. */
32
+ loadAll(): WorkstreamDraft[];
33
+ /** Write (or overwrite) one draft's snapshot. Silently no-ops on any I/O failure — a lost journal write must never break the in-memory flow. */
34
+ save(draft: WorkstreamDraft): void;
35
+ /** Delete one draft's snapshot (on launch or cancel). Missing file ⇒ no-op. */
36
+ remove(id: string): void;
37
+ }
38
+
39
+ /** Draft ids are `wsd_<hex>`; this refuses anything else so a crafted id can never escape the drafts directory. */
40
+ function isSafeId(id: string): boolean {
41
+ return /^[A-Za-z0-9_-]{1,64}$/.test(id);
42
+ }
43
+
44
+ function isDraftShape(value: unknown): value is WorkstreamDraft {
45
+ if (typeof value !== 'object' || value === null) return false;
46
+ const d = value as Record<string, unknown>;
47
+ return (
48
+ typeof d.id === 'string'
49
+ && typeof d.task === 'string'
50
+ && typeof d.approved === 'boolean'
51
+ && typeof d.createdAt === 'number'
52
+ && typeof d.spec === 'object' && d.spec !== null
53
+ && Array.isArray((d.spec as Record<string, unknown>).items)
54
+ );
55
+ }
56
+
57
+ export function createWorkstreamDraftStore(projectRoot: string): WorkstreamDraftStore {
58
+ const dir = join(projectRoot, '.goodvibes', 'orchestration', 'drafts');
59
+ const fileFor = (id: string): string => join(dir, `${id}.json`);
60
+
61
+ return {
62
+ loadAll(): WorkstreamDraft[] {
63
+ let entries: string[];
64
+ try {
65
+ entries = readdirSync(dir);
66
+ } catch {
67
+ return []; // directory not created yet, or unreadable — nothing to resume
68
+ }
69
+ const drafts: WorkstreamDraft[] = [];
70
+ for (const entry of entries) {
71
+ if (!entry.endsWith('.json')) continue;
72
+ try {
73
+ const parsed: unknown = JSON.parse(readFileSync(join(dir, entry), 'utf8'));
74
+ if (isDraftShape(parsed) && isSafeId(parsed.id)) drafts.push(parsed);
75
+ } catch {
76
+ // A malformed or half-written snapshot is skipped, never fatal.
77
+ }
78
+ }
79
+ return drafts.sort((a, b) => a.createdAt - b.createdAt);
80
+ },
81
+ save(draft: WorkstreamDraft): void {
82
+ if (!isSafeId(draft.id)) return;
83
+ try {
84
+ mkdirSync(dir, { recursive: true });
85
+ writeFileSync(fileFor(draft.id), JSON.stringify(draft), 'utf8');
86
+ } catch {
87
+ // A failed journal write leaves the in-memory draft intact and usable;
88
+ // only its restart-durability is lost, which is strictly better than
89
+ // failing the edit the user just made.
90
+ }
91
+ },
92
+ remove(id: string): void {
93
+ if (!isSafeId(id)) return;
94
+ try {
95
+ rmSync(fileFor(id), { force: true });
96
+ } catch {
97
+ // Already gone, or unremovable — nothing more to do.
98
+ }
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,42 @@
1
+ // ---------------------------------------------------------------------------
2
+ // workstream-draft-types.ts — the not-yet-launched proposal's data shape
3
+ //
4
+ // Extracted from workstream-services.ts purely to give the durable draft store
5
+ // (workstream-draft-store.ts) a type to persist WITHOUT importing back into the
6
+ // service construction module — that back-edge would form an import cycle the
7
+ // architecture check rejects. workstream-services.ts re-exports both types, so
8
+ // every existing importer (the /workstream command, the tests) keeps importing
9
+ // them from there unchanged.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import type { CreateWorkstreamInput } from '@pellux/goodvibes-sdk/platform/orchestration';
13
+ import type { DecompositionGate, PlanProposal } from '@pellux/goodvibes-sdk/platform/core';
14
+
15
+ /**
16
+ * Honest provenance for how a draft's decomposition was produced. Derived from
17
+ * the SDK decomposition service's outcome so the draft render can state plainly
18
+ * whether a planning agent decomposed the goal, or the heuristic path did (and
19
+ * if so, why).
20
+ */
21
+ export interface WorkstreamDraftProvenance {
22
+ readonly kind: 'agent' | 'heuristic-configured' | 'gate-declined' | 'fallback';
23
+ readonly itemCount: number;
24
+ readonly agentCostUsd?: number | undefined;
25
+ readonly agentTokens?: number | undefined;
26
+ readonly elapsedMs?: number | undefined;
27
+ readonly fallbackReason?: string | undefined;
28
+ }
29
+
30
+ /** A not-yet-launched /workstream proposal. TUI-owned (the engine has no draft concept) and journaled to disk via workstream-draft-store.ts so it survives a restart. See workstream-services.ts's header doc. */
31
+ export interface WorkstreamDraft {
32
+ readonly id: string;
33
+ task: string;
34
+ spec: CreateWorkstreamInput;
35
+ readonly gate: DecompositionGate;
36
+ /** The engine-agnostic decomposition proposal (model- or heuristic-produced). */
37
+ proposal: PlanProposal;
38
+ /** How that proposal came to be, for honest rendering. */
39
+ provenance: WorkstreamDraftProvenance;
40
+ approved: boolean;
41
+ readonly createdAt: number;
42
+ }
@@ -17,15 +17,20 @@
17
17
  // (input/commands/workstream-runtime.ts) needs a create -> propose -> approve
18
18
  // -> launch flow (Pillar-3 doctrine: render the plan in the transcript before
19
19
  // spending anything, mirroring /plan's approve step). REALITY-WINS DIVERGENCE
20
- // from the wo703 design brief: the brief recommended an "engine-owned/
21
- // journal-backed" draft so `/workstream status` could re-render a pending
22
- // proposal after a restart; the real engine (verified against the linked SDK
23
- // build) exposes no pre-creation draft concept at all, so WorkstreamDraft
24
- // below is process-lifetime-only state, owned by this module's facade
25
- // instance (constructed once, threaded onto CommandContext) — never a
26
- // module-level ambient global. A draft that existed only in memory is lost on
27
- // restart, same as an unsent chat draft; that is a real, stated limitation,
28
- // not a bug.
20
+ // from the wo703 design brief: the brief recommended an ENGINE-owned draft; the
21
+ // real engine (verified against the linked SDK build) exposes no pre-creation
22
+ // draft concept at all, so WorkstreamDraft below is TUI-owned state, held on
23
+ // this module's facade instance (constructed once, threaded onto
24
+ // CommandContext) — never a module-level ambient global. Durability is
25
+ // likewise TUI-owned: rather than leaving the brief's restart requirement
26
+ // unmet, the facade journals every draft to disk through
27
+ // workstream-draft-store.ts (a drafts/ subdirectory ALONGSIDE the engine's own
28
+ // workstream snapshots) and reloads them at construction, so a create / reshape
29
+ // / approve done before a restart is still here to launch afterward. The engine
30
+ // gains no draft concept; the TUI persists its OWN facade state. A journal
31
+ // write that fails degrades to in-memory-only for that one draft — never a
32
+ // crash — and the store never resurrects a launched draft (its snapshot is
33
+ // removed the moment the engine takes ownership).
29
34
  // ---------------------------------------------------------------------------
30
35
 
31
36
  import {
@@ -40,7 +45,6 @@ export type { OrchestrationEngine } from '@pellux/goodvibes-sdk/platform/orchest
40
45
  import {
41
46
  AdaptivePlanner,
42
47
  decomposeGoal,
43
- type DecompositionGate,
44
48
  type DecompositionServiceConfig,
45
49
  type DecomposeGoalResult,
46
50
  type PlannerInputs,
@@ -51,6 +55,8 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
51
55
  import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
52
56
  import type { RuntimeEventBus } from '@/runtime/index.ts';
53
57
  import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
58
+ import { editItemBrief, moveItemInSpec, removeItemFromSpec } from './workstream-draft-edits.ts';
59
+ import { createWorkstreamDraftStore } from './workstream-draft-store.ts';
54
60
 
55
61
  export interface WorkstreamServicesDeps {
56
62
  readonly agentManager: Pick<AgentManager, 'spawn' | 'getStatus' | 'cancel' | 'registerCancellationSignal' | 'releaseCancellationSignal'>;
@@ -60,34 +66,11 @@ export interface WorkstreamServicesDeps {
60
66
  readonly projectRoot: string;
61
67
  }
62
68
 
63
- /**
64
- * Honest provenance for how a draft's decomposition was produced. Derived from
65
- * the SDK decomposition service's outcome so the draft render can state plainly
66
- * whether a planning agent decomposed the goal, or the heuristic path did (and
67
- * if so, why).
68
- */
69
- export interface WorkstreamDraftProvenance {
70
- readonly kind: 'agent' | 'heuristic-configured' | 'gate-declined' | 'fallback';
71
- readonly itemCount: number;
72
- readonly agentCostUsd?: number | undefined;
73
- readonly agentTokens?: number | undefined;
74
- readonly elapsedMs?: number | undefined;
75
- readonly fallbackReason?: string | undefined;
76
- }
77
-
78
- /** A not-yet-launched /workstream proposal. See this file's header doc for why it lives here rather than on the engine. */
79
- export interface WorkstreamDraft {
80
- readonly id: string;
81
- task: string;
82
- spec: CreateWorkstreamInput;
83
- readonly gate: DecompositionGate;
84
- /** The engine-agnostic decomposition proposal (model- or heuristic-produced). */
85
- proposal: PlanProposal;
86
- /** How that proposal came to be, for honest rendering. */
87
- provenance: WorkstreamDraftProvenance;
88
- approved: boolean;
89
- readonly createdAt: number;
90
- }
69
+ // WorkstreamDraft + WorkstreamDraftProvenance live in workstream-draft-types.ts
70
+ // (so the durable store can persist them without an import cycle) and are
71
+ // re-exported here so every existing importer keeps its import site unchanged.
72
+ export type { WorkstreamDraft, WorkstreamDraftProvenance } from './workstream-draft-types.ts';
73
+ import type { WorkstreamDraft, WorkstreamDraftProvenance } from './workstream-draft-types.ts';
91
74
 
92
75
  /** `ctx.session.workstreamEngine`'s real shape: the live engine plus the draft-proposal bookkeeping the engine itself has no concept of. */
93
76
  export interface WorkstreamCommandService {
@@ -98,6 +81,16 @@ export interface WorkstreamCommandService {
98
81
  listDrafts(): WorkstreamDraft[];
99
82
  /** Re-derive a held draft's spec + decomposition from a new task string. Clears any prior approval — an edit must be re-approved. `isolation` omitted ⇒ keeps the draft's current choice (an edit that only changes the task text must not silently reset isolation back to shared). */
100
83
  editDraft(id: string, task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft | undefined>;
84
+ /**
85
+ * Plan-review-gate item edits over a held draft's launchable spec (see
86
+ * workstream-draft-edits.ts). Each returns the updated draft on success,
87
+ * `{ error }` with an honest user-facing reason on a bad reference/argument,
88
+ * or `undefined` when no draft with that id is held. Every successful edit
89
+ * clears approval — a reshaped plan must be re-approved before launch.
90
+ */
91
+ editItem(id: string, itemRef: string, brief: string): WorkstreamDraft | { error: string } | undefined;
92
+ removeItem(id: string, itemRef: string): WorkstreamDraft | { error: string } | undefined;
93
+ moveItem(id: string, itemRef: string, toPosition: number): WorkstreamDraft | { error: string } | undefined;
101
94
  approveDraft(id: string): WorkstreamDraft | undefined;
102
95
  removeDraft(id: string): boolean;
103
96
  /** Materialize an approved draft into a real, running Workstream (engine.createWorkstream + start), then drop the draft. Null when the draft is missing or not approved. */
@@ -187,6 +180,13 @@ function createWorkstreamCommandService(
187
180
  ): WorkstreamCommandService {
188
181
  const drafts = new Map<string, WorkstreamDraft>();
189
182
 
183
+ // TUI-side draft journal (workstream-draft-store.ts). Load every persisted
184
+ // proposal at construction so a create/reshape/approve done before a restart
185
+ // is still here to launch afterward — the plan-review gate survives restart,
186
+ // exactly as the live-workstream snapshots do via resumeAllFromDisk().
187
+ const store = createWorkstreamDraftStore(projectRoot);
188
+ for (const persisted of store.loadAll()) drafts.set(persisted.id, persisted);
189
+
190
190
  /**
191
191
  * Run the SDK decomposition service: it spawns a bounded, read-only planning
192
192
  * agent (which surfaces in the fleet like any agent — kill/steer reach it,
@@ -238,6 +238,27 @@ function createWorkstreamCommandService(
238
238
  return fromChainSpec({ id: `item-${crypto.randomUUID().slice(0, 8)}`, task }, configManager);
239
239
  }
240
240
 
241
+ /**
242
+ * Apply a pure item edit (workstream-draft-edits.ts) to a held draft's spec.
243
+ * Threads the three outcomes straight through: `undefined` (no such draft) so
244
+ * the command layer can print its not-found message, `{ error }`
245
+ * (a bad reference/argument) verbatim, or the mutated draft. A successful edit
246
+ * clears approval — a reshaped plan must be re-approved before it can launch.
247
+ */
248
+ function applyItemEdit(
249
+ id: string,
250
+ edit: (spec: CreateWorkstreamInput) => import('./workstream-draft-edits.ts').DraftEditResult,
251
+ ): WorkstreamDraft | { error: string } | undefined {
252
+ const draft = drafts.get(id);
253
+ if (!draft) return undefined;
254
+ const result = edit(draft.spec);
255
+ if ('error' in result) return { error: result.error };
256
+ draft.spec = result.spec;
257
+ draft.approved = false;
258
+ store.save(draft);
259
+ return draft;
260
+ }
261
+
241
262
  return {
242
263
  engine,
243
264
  async proposeDraft(task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft> {
@@ -254,6 +275,7 @@ function createWorkstreamCommandService(
254
275
  createdAt: Date.now(),
255
276
  };
256
277
  drafts.set(draft.id, draft);
278
+ store.save(draft);
257
279
  return draft;
258
280
  },
259
281
  getDraft: (id) => drafts.get(id),
@@ -268,21 +290,30 @@ function createWorkstreamCommandService(
268
290
  draft.proposal = result.proposal;
269
291
  draft.provenance = toProvenance(result);
270
292
  draft.approved = false;
293
+ store.save(draft);
271
294
  return draft;
272
295
  },
296
+ editItem: (id, itemRef, brief) => applyItemEdit(id, (spec) => editItemBrief(spec, itemRef, brief)),
297
+ removeItem: (id, itemRef) => applyItemEdit(id, (spec) => removeItemFromSpec(spec, itemRef)),
298
+ moveItem: (id, itemRef, toPosition) => applyItemEdit(id, (spec) => moveItemInSpec(spec, itemRef, toPosition)),
273
299
  approveDraft(id) {
274
300
  const draft = drafts.get(id);
275
301
  if (!draft) return undefined;
276
302
  draft.approved = true;
303
+ store.save(draft); // approval must survive restart too — a resumed approved draft launches straight away
277
304
  return draft;
278
305
  },
279
- removeDraft: (id) => drafts.delete(id),
306
+ removeDraft(id) {
307
+ store.remove(id);
308
+ return drafts.delete(id);
309
+ },
280
310
  launchDraft(id) {
281
311
  const draft = drafts.get(id);
282
312
  if (!draft || !draft.approved) return null;
283
313
  const workstream = engine.createWorkstream(draft.spec);
284
314
  engine.start(workstream.id);
285
315
  drafts.delete(id);
316
+ store.remove(id); // launched: the engine now owns it (its own journal), so drop the draft snapshot
286
317
  return { workstreamId: workstream.id };
287
318
  },
288
319
  };
@@ -12,8 +12,25 @@ export type PendingPermissionState = PermissionRequest & {
12
12
  hunkState?: HunkSelectionState;
13
13
  /** True once the user pressed `d` to expand a condensed low-risk card. (UX-B 2b.) */
14
14
  detailsExpanded?: boolean;
15
+ /**
16
+ * Epoch ms when the prompt first appeared. Keystrokes that arrive within
17
+ * APPROVAL_INPUT_DEBOUNCE_MS of this are swallowed (not interpreted as an
18
+ * approve/deny response) so text typed ahead — before the user saw the
19
+ * prompt — cannot accidentally answer it.
20
+ */
21
+ openedAt?: number;
22
+ /** Requester attribution ("session abc12345" / a named agent) shown on the prompt; absent when unknown. */
23
+ requestedBy?: string;
15
24
  };
16
25
 
26
+ /**
27
+ * Settle window after a permission prompt appears during which buffered
28
+ * keystrokes are ignored rather than interpreted as the response. 350ms is long
29
+ * enough to absorb type-ahead from before the prompt rendered, short enough not
30
+ * to feel laggy to a user reacting to the prompt itself.
31
+ */
32
+ export const APPROVAL_INPUT_DEBOUNCE_MS = 350;
33
+
17
34
  export type BlockingInputHandlerOptions = {
18
35
  data: string;
19
36
  pendingPermission: PendingPermissionState | null;
@@ -58,6 +75,8 @@ export type BlockingInputHandlerOptions = {
58
75
  * posture is not restored.
59
76
  */
60
77
  reopenPanels?: (snapshot: SessionSnapshot) => void;
78
+ /** Injectable clock for the approval-input debounce; defaults to Date.now(). Tests only. */
79
+ now?: number;
61
80
  };
62
81
 
63
82
  export type BlockingInputHandlerResult = {
@@ -89,6 +108,16 @@ export function handleBlockingShellInput(
89
108
  if (pendingPermission) {
90
109
  const req = pendingPermission;
91
110
 
111
+ // Input debounce: swallow any key that arrives within the settle window
112
+ // after the prompt appeared, so type-ahead text from before the user saw
113
+ // the prompt cannot answer it (neither approve, deny, nor navigate). The
114
+ // prompt stays open; re-render and consume the key.
115
+ const now = options.now ?? Date.now();
116
+ if (req.openedAt !== undefined && now - req.openedAt < APPROVAL_INPUT_DEBOUNCE_MS) {
117
+ render();
118
+ return { handled: true, pendingPermission, recoveryPending };
119
+ }
120
+
92
121
  if (req.hunkState) {
93
122
  const { state, commit } = applyHunkKey(req.hunkState, data);
94
123
  if (commit === 'apply') {
@@ -14,6 +14,7 @@ import type { MemoryEmbeddingProviderRegistry } from '@pellux/goodvibes-sdk/plat
14
14
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
15
15
  import type { EmbeddingProviderPickerEntry, ModelPickerTargetInfo } from '../input/model-picker.ts';
16
16
  import type { SelectionItem } from '../input/selection-modal.ts';
17
+ import { categorizeBuiltinCommands } from '../input/commands.ts';
17
18
  import { syncServiceSettingToPlatform } from './service-settings-sync.ts';
18
19
  import { setActiveThemeMode } from '../renderer/theme.ts';
19
20
  import { THEME_MODE_CONFIG_KEY, coerceThemeModeSetting } from '../renderer/theme-mode-config.ts';
@@ -41,6 +42,23 @@ type WireShellUiOpenersOptions = {
41
42
  render: () => void;
42
43
  };
43
44
 
45
+ let commandCategoryMapCache: Map<string, string> | null = null;
46
+
47
+ /**
48
+ * Map each built-in command name to its reference category, memoized. Drives
49
+ * the command palette's category grouping from the same single source of truth
50
+ * as the generated docs (categorizeBuiltinCommands), so the palette is always
51
+ * registry-derived and never a hand-maintained list.
52
+ */
53
+ function getCommandCategoryMap(): Map<string, string> {
54
+ if (!commandCategoryMapCache) {
55
+ commandCategoryMapCache = new Map(
56
+ categorizeBuiltinCommands().map((entry) => [entry.command.name, entry.category]),
57
+ );
58
+ }
59
+ return commandCategoryMapCache;
60
+ }
61
+
44
62
  /**
45
63
  * Derive the configuredVia tier for a provider.
46
64
  * Tier order mirrors SDK provider-routes.ts: env → secrets → subscription → undefined.
@@ -457,6 +475,53 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
457
475
  render();
458
476
  };
459
477
 
478
+ commandContext.openCommandPalette = () => {
479
+ // The palette is built live from the command registry — never a hardcoded
480
+ // list — so it can never drift from the real command set. Category labels
481
+ // come from the same source the generated docs use (getCommandCategoryMap).
482
+ const registry = input.commandRegistry;
483
+ if (!registry) return;
484
+ const categoryByName = getCommandCategoryMap();
485
+ const items: SelectionItem[] = registry
486
+ .getAll()
487
+ .slice()
488
+ .sort((a, b) => a.name.localeCompare(b.name))
489
+ .map((cmd) => {
490
+ const argsHint = cmd.argsHint ?? cmd.usage;
491
+ const detailParts: string[] = [];
492
+ if (cmd.description) detailParts.push(cmd.description);
493
+ if (argsHint) detailParts.push(`(args: ${argsHint})`);
494
+ if ((cmd.aliases ?? []).length > 0) detailParts.push(`[aka ${(cmd.aliases ?? []).map((a) => `/${a}`).join(', ')}]`);
495
+ return {
496
+ id: cmd.name,
497
+ label: `/${cmd.name}`,
498
+ detail: detailParts.join(' '),
499
+ category: categoryByName.get(cmd.name) ?? 'Other',
500
+ primaryAction: 'select' as const,
501
+ };
502
+ });
503
+ commandContext.openSelection?.(
504
+ 'Command Palette',
505
+ items,
506
+ { allowSearch: true, primaryVerbLabel: 'Run' },
507
+ (result) => {
508
+ if (!result) return;
509
+ const cmd = registry.get(result.item.id);
510
+ if (!cmd) return;
511
+ // Pre-fill the composer with the command (and a trailing space so the
512
+ // inline args hint shows what the command wants). Keyboard-first: focus
513
+ // returns to the composer with command mode armed so the user fills any
514
+ // args and presses Enter — matching tab-completion's fill behavior.
515
+ input.prompt = `/${cmd.name} `;
516
+ input.cursorPos = input.prompt.length;
517
+ input.commandMode = true;
518
+ input.autocomplete?.reset();
519
+ commandContext.focusPrompt?.();
520
+ render();
521
+ },
522
+ );
523
+ };
524
+
460
525
  commandContext.focusPanels = () => {
461
526
  panelManager.focusPanels();
462
527
  render();
@@ -27,6 +27,60 @@ export interface VerificationLedger {
27
27
  };
28
28
  }
29
29
 
30
+ /**
31
+ * Baseline count of `Settings schema and persistence` items with real local
32
+ * behavior verification, as measured when this ledger was first authored. New
33
+ * schema keys added since then are accounted for explicitly (see
34
+ * FEATURE_KNOB_LOCAL_SETTINGS) rather than folded into this number, so the
35
+ * baseline stays an auditable fixed point.
36
+ */
37
+ const SETTINGS_BEHAVIOR_BASELINE = 184;
38
+
39
+ /**
40
+ * Config keys promoted into CONFIG_SCHEMA for the flag-gated feature knobs
41
+ * (SDK commit f5c4af31, "config: add schema keys for flag-gated feature
42
+ * knobs"). Adding these 28 keys raised the settings inventory `total` without a
43
+ * matching rise in behavior-verified coverage, which dropped
44
+ * `localBehaviorPercent` below its floor. Each key is a local, non-external
45
+ * setting whose full persistence behavior — schema default, set → write →
46
+ * reload round-trip, and reset-to-default — is exercised by
47
+ * `src/test/verification/feature-knob-settings-persistence.test.ts`. They are
48
+ * therefore genuinely behavior-verified locally and counted as such here.
49
+ */
50
+ export const FEATURE_KNOB_LOCAL_SETTINGS = [
51
+ 'provider.optimizerMode',
52
+ 'provider.optimizerPinnedModel',
53
+ 'permissions.divergenceThreshold',
54
+ 'permissions.maxDivergenceRecords',
55
+ 'tools.overflowSpillBackend',
56
+ 'notifications.burstWindowMs',
57
+ 'notifications.burstThreshold',
58
+ 'notifications.burstCooldownMs',
59
+ 'fetch.sanitizeMode',
60
+ 'fetch.trustedHosts',
61
+ 'fetch.blockedHosts',
62
+ 'security.tokenAudit.rotationCadenceDays',
63
+ 'security.tokenAudit.rotationWarningDays',
64
+ 'security.tokenAudit.managed',
65
+ 'integrations.delivery.maxRetries',
66
+ 'integrations.delivery.initialDelayMs',
67
+ 'integrations.delivery.maxDelayMs',
68
+ 'integrations.delivery.maxDlqSize',
69
+ 'integrations.delivery.sloEnforced',
70
+ 'policy.bundleSource',
71
+ 'policy.bundlePath',
72
+ 'agents.passiveInjection.budgetTokens',
73
+ 'agents.passiveInjection.relevanceFloor',
74
+ 'agents.passiveInjection.codeLimit',
75
+ 'agents.contextCompactThreshold',
76
+ 'runtime.toolBudget.maxMs',
77
+ 'runtime.toolBudget.maxTokens',
78
+ 'runtime.toolBudget.maxCostUsd',
79
+ ] as const;
80
+
81
+ /** Settings with real local behavior verification: the authored baseline plus the newly persistence-tested feature-knob keys. */
82
+ const SETTINGS_BEHAVIOR_VERIFIED = SETTINGS_BEHAVIOR_BASELINE + FEATURE_KNOB_LOCAL_SETTINGS.length;
83
+
30
84
  const EXTERNAL_SLASH_COMMANDS = new Set([
31
85
  'auth',
32
86
  'bridge',
@@ -137,8 +191,8 @@ export function buildVerificationLedger(root: string): VerificationLedger {
137
191
  area: 'Settings schema and persistence',
138
192
  total: settings,
139
193
  localSignalVerified: settings,
140
- localBehaviorVerified: 184,
141
- externalOutcomeRequired: settings - 184,
194
+ localBehaviorVerified: SETTINGS_BEHAVIOR_VERIFIED,
195
+ externalOutcomeRequired: settings - SETTINGS_BEHAVIOR_VERIFIED,
142
196
  notes: 'Every schema setting can be validated for schema/default/load/write/location; external side effects remain separate.',
143
197
  },
144
198
  {
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.13.1';
9
+ let _version = '1.14.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;