@pellux/goodvibes-tui 1.13.1 → 1.15.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 +104 -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 +412 -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 +109 -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,244 @@
1
+ // ---------------------------------------------------------------------------
2
+ // workstream-attempts.ts — the /workstream attempts surface for best-of-N.
3
+ //
4
+ // The engine runs a best-of-N work item as N sibling attempts in isolated
5
+ // worktrees and HOLDS the merge, exposing the candidates for a winner pick
6
+ // (orchestration engine.listHeldMergeGroups / proposeAttemptWinner /
7
+ // pickAttemptWinner — the same seam the fleet.attempts.* gateway verbs use).
8
+ // This module drives that seam from the /workstream command:
9
+ // • list — the held-merge groups awaiting a pick, with candidates.
10
+ // • diff <c> — one candidate's worktree diff in the existing DiffPanel.
11
+ // • judge — the OPTIONAL model proposal, clearly labelled a model
12
+ // judgment (scoredBy 'model'), with its reasons; never a pick.
13
+ // • pick <c> — behind a DiffPanel confirm; merges the winner and lets
14
+ // the engine clean the losers, then renders the receipt.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ import type {
18
+ AttemptCandidate,
19
+ AttemptJudgment,
20
+ AttemptPickResult,
21
+ HeldMergeGroup,
22
+ } from '@pellux/goodvibes-sdk/platform/orchestration';
23
+ import { AttemptError } from '@pellux/goodvibes-sdk/platform/orchestration';
24
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
25
+ import type { CommandContext } from '../command-registry.ts';
26
+ import type { WorkstreamCommandService } from '../../runtime/workstream-services.ts';
27
+ import { requirePanelManager } from './runtime-services.ts';
28
+ import { type AttemptGraphItem, renderDependentHoldLines } from './workstream-attempt-dependents.ts';
29
+
30
+ /** Resolve a workstream's items for dependent-hold display; null-safe against a stubbed engine. */
31
+ type ResolveWorkstreamItems = (workstreamId: string) => readonly AttemptGraphItem[] | null;
32
+
33
+ function shortId(id: string): string {
34
+ return id.length > 10 ? `${id.slice(0, 10)}…` : id;
35
+ }
36
+
37
+ /** Resolve a group by exact id or unambiguous id prefix. */
38
+ function resolveGroup(groups: readonly HeldMergeGroup[], ref: string): HeldMergeGroup | { error: string } {
39
+ const exact = groups.find((g) => g.groupId === ref);
40
+ if (exact) return exact;
41
+ const prefix = groups.filter((g) => g.groupId.startsWith(ref));
42
+ if (prefix.length === 1) return prefix[0]!;
43
+ if (prefix.length > 1) return { error: `Group ref "${ref}" is ambiguous (${prefix.length} matches). Use the full id.` };
44
+ return { error: `No held-merge group found: ${ref}. Run /workstream attempts list.` };
45
+ }
46
+
47
+ /** Resolve a candidate within a group by 1-based attempt number or itemId (prefix). */
48
+ function resolveCandidate(group: HeldMergeGroup, ref: string): AttemptCandidate | { error: string } {
49
+ if (/^\d+$/.test(ref)) {
50
+ const cand = group.candidates.find((c) => c.attemptIndex + 1 === Number(ref));
51
+ return cand ?? { error: `No attempt #${ref} in group ${shortId(group.groupId)} (1–${group.candidates.length}).` };
52
+ }
53
+ const byId = group.candidates.filter((c) => c.itemId === ref || c.itemId.startsWith(ref));
54
+ if (byId.length === 1) return byId[0]!;
55
+ if (byId.length > 1) return { error: `Candidate ref "${ref}" is ambiguous. Use the attempt number instead.` };
56
+ return { error: `No candidate "${ref}" in group ${shortId(group.groupId)}.` };
57
+ }
58
+
59
+ function candidateLine(c: AttemptCandidate): string {
60
+ const files = c.diff ? `${c.diff.files.length} file(s)` : 'no diff';
61
+ const state = c.state === 'held-merge' ? 'held (pick-ready)' : `failed${c.failureReason ? `: ${c.failureReason}` : ''}`;
62
+ return ` ${c.attemptIndex + 1}. [${state}] ${c.title} — ${files}, item ${shortId(c.itemId)}`;
63
+ }
64
+
65
+ function renderGroupList(groups: readonly HeldMergeGroup[], resolveItems?: ResolveWorkstreamItems): string {
66
+ if (groups.length === 0) {
67
+ return 'No best-of-N groups awaiting a pick. A worktree-isolated item with attempts>1 appears here once its siblings finish.';
68
+ }
69
+ const lines: string[] = ['Best-of-N groups (held for a winner pick):'];
70
+ for (const g of groups) {
71
+ lines.push(` ${shortId(g.groupId)} — "${g.sourceTitle}" (workstream ${shortId(g.workstreamId)}) ${g.ready ? 'READY' : 'still running'}${g.autoAccept ? ', auto-accept' : ''}`);
72
+ for (const c of g.candidates) lines.push(candidateLine(c));
73
+ if (g.judgment?.proposedWinnerItemId) {
74
+ lines.push(` judge proposal: item ${shortId(g.judgment.proposedWinnerItemId)} (model, advisory)`);
75
+ }
76
+ // Non-leaf best-of-N: name the dependents this group holds, so a group that
77
+ // gates downstream work reads as such (leaf groups add nothing here).
78
+ const items = resolveItems?.(g.workstreamId);
79
+ if (items) {
80
+ lines.push(...renderDependentHoldLines(items, g.candidates.map((c) => c.itemId)));
81
+ }
82
+ }
83
+ lines.push('Compare: /workstream attempts diff <group> <#> Judge: /workstream attempts judge <group> Pick: /workstream attempts pick <group> <#>');
84
+ return lines.join('\n');
85
+ }
86
+
87
+ /** The model proposal, CLEARLY labelled a model judgment (never ground truth), with reasons. */
88
+ function renderJudgment(group: HeldMergeGroup, judgment: AttemptJudgment): string {
89
+ const lines: string[] = [`Judge proposal for group ${shortId(group.groupId)} — "${group.sourceTitle}"`];
90
+ lines.push(' MODEL PROPOSAL (scoredBy: model) — advisory only; a human still confirms the pick.');
91
+ if (judgment.proposedWinnerItemId) {
92
+ const cand = group.candidates.find((c) => c.itemId === judgment.proposedWinnerItemId);
93
+ lines.push(` Proposed winner: ${cand ? `attempt ${cand.attemptIndex + 1} — ${cand.title}` : `item ${shortId(judgment.proposedWinnerItemId)}`}`);
94
+ } else {
95
+ lines.push(' Proposed winner: none — the judge declined to choose.');
96
+ }
97
+ if (judgment.model) lines.push(` Model: ${judgment.model}`);
98
+ if (judgment.reasons.length > 0) {
99
+ lines.push(' Reasons:');
100
+ for (const r of judgment.reasons) lines.push(` - ${r}`);
101
+ }
102
+ if (judgment.proposedWinnerItemId) {
103
+ const cand = group.candidates.find((c) => c.itemId === judgment.proposedWinnerItemId);
104
+ lines.push(` Accept it with: /workstream attempts pick ${shortId(group.groupId)} ${cand ? cand.attemptIndex + 1 : judgment.proposedWinnerItemId}`);
105
+ }
106
+ return lines.join('\n');
107
+ }
108
+
109
+ function renderPickReceipt(result: AttemptPickResult): string {
110
+ const losers = result.loserItemIds.length;
111
+ return [
112
+ `[Workstream] Winner picked for group ${shortId(result.groupId)}${result.auto ? ' (auto-accepted by judge)' : ''}.`,
113
+ ` Winner item ${shortId(result.winnerItemId)} merged through the integration lane.`,
114
+ ` ${losers} loser worktree(s) cleaned by the engine.`,
115
+ ].join('\n');
116
+ }
117
+
118
+ /**
119
+ * Handle `/workstream attempts …`. Returns true when it consumed the subcommand
120
+ * (so the main handler can stop), false when `attempts` was not the subcommand.
121
+ */
122
+ export async function handleAttemptsSubcommand(ctx: CommandContext, service: WorkstreamCommandService, args: readonly string[]): Promise<boolean> {
123
+ if (args[0] !== 'attempts') return false;
124
+ const action = args[1] ?? 'list';
125
+
126
+ let groups: HeldMergeGroup[];
127
+ try {
128
+ groups = await service.engine.listHeldMergeGroups();
129
+ } catch (err) {
130
+ ctx.print(`Could not read best-of-N groups: ${summarizeError(err)}`);
131
+ return true;
132
+ }
133
+
134
+ if (action === 'list') {
135
+ const resolveItems: ResolveWorkstreamItems = (workstreamId) => {
136
+ try {
137
+ return (service.engine.getWorkstream(workstreamId)?.items as readonly AttemptGraphItem[] | undefined) ?? null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ };
142
+ ctx.print(renderGroupList(groups, resolveItems));
143
+ return true;
144
+ }
145
+
146
+ const groupRef = args[2];
147
+ if (!groupRef) {
148
+ ctx.print(`Usage: /workstream attempts ${action} <group> ${action === 'list' ? '' : '<#>'}`.trim());
149
+ return true;
150
+ }
151
+ const group = resolveGroup(groups, groupRef);
152
+ if ('error' in group) {
153
+ ctx.print(group.error);
154
+ return true;
155
+ }
156
+
157
+ if (action === 'judge') {
158
+ try {
159
+ const judgment = await service.engine.proposeAttemptWinner(group.groupId);
160
+ ctx.print(renderJudgment(group, judgment));
161
+ } catch (err) {
162
+ ctx.print(err instanceof AttemptError
163
+ ? `No judge available: ${err.message}. Configure a judge model to get a proposal; you can still pick manually.`
164
+ : `Judge failed: ${summarizeError(err)}`);
165
+ }
166
+ return true;
167
+ }
168
+
169
+ if (action === 'diff') {
170
+ const candRef = args[3];
171
+ if (!candRef) {
172
+ ctx.print(`Candidates in group ${shortId(group.groupId)}:\n${group.candidates.map(candidateLine).join('\n')}\nView one: /workstream attempts diff ${shortId(group.groupId)} <#>`);
173
+ return true;
174
+ }
175
+ const cand = resolveCandidate(group, candRef);
176
+ if ('error' in cand) { ctx.print(cand.error); return true; }
177
+ if (!cand.diff || !cand.diff.unifiedDiff.trim()) {
178
+ ctx.print(`Attempt ${cand.attemptIndex + 1} has no viewable diff (${cand.state === 'failed' ? 'it failed' : 'its worktree was already cleaned'}).`);
179
+ return true;
180
+ }
181
+ const { DiffPanel } = await import('../../panels/diff-panel.ts');
182
+ const pm = requirePanelManager(ctx);
183
+ let panel = pm.getAllOpen().find((p) => p.id === 'diff');
184
+ if (!panel) { try { panel = pm.open('diff'); } catch { ctx.print('Could not open the diff panel.'); return true; } }
185
+ pm.activateById('diff');
186
+ if (!pm.isVisible()) pm.show();
187
+ ctx.focusPanels?.();
188
+ (panel as InstanceType<typeof DiffPanel>).loadRawDiff(cand.diff.unifiedDiff);
189
+ ctx.print(`Showing attempt ${cand.attemptIndex + 1} ("${cand.title}") diff in the diff panel.`);
190
+ ctx.renderRequest();
191
+ return true;
192
+ }
193
+
194
+ if (action === 'pick') {
195
+ const candRef = args[3];
196
+ if (!candRef) { ctx.print(`Usage: /workstream attempts pick ${shortId(group.groupId)} <#>`); return true; }
197
+ if (!group.ready) { ctx.print(`Group ${shortId(group.groupId)} is not ready — its siblings are still running. Wait until every attempt is terminal.`); return true; }
198
+ const cand = resolveCandidate(group, candRef);
199
+ if ('error' in cand) { ctx.print(cand.error); return true; }
200
+ if (cand.state !== 'held-merge') { ctx.print(`Attempt ${cand.attemptIndex + 1} failed — only a held (pick-ready) candidate can win.`); return true; }
201
+
202
+ const { DiffPanel } = await import('../../panels/diff-panel.ts');
203
+ const pm = requirePanelManager(ctx);
204
+ let panel = pm.getAllOpen().find((p) => p.id === 'diff');
205
+ if (!panel) { try { panel = pm.open('diff'); } catch { ctx.print('Could not open the diff panel to confirm.'); return true; } }
206
+ pm.activateById('diff');
207
+ if (!pm.isVisible()) pm.show();
208
+ ctx.focusPanels?.();
209
+ const diffPanel = panel as InstanceType<typeof DiffPanel>;
210
+ if (cand.diff?.unifiedDiff.trim()) diffPanel.loadRawDiff(cand.diff.unifiedDiff);
211
+ else diffPanel.showDiff(cand.title, '@@ pick @@\n (no diff to preview for this candidate)');
212
+
213
+ diffPanel.confirmOverlay.arm({
214
+ id: `${group.groupId}:${cand.itemId}`,
215
+ label: `Pick attempt ${cand.attemptIndex + 1} ("${cand.title}") — merge it, clean the ${group.candidates.length - 1} other worktree(s)`,
216
+ verb: 'Pick',
217
+ onConfirm: async () => {
218
+ try {
219
+ const result = await service.engine.pickAttemptWinner(group.groupId, cand.itemId);
220
+ pm.close('diff');
221
+ ctx.focusPrompt?.();
222
+ ctx.print(renderPickReceipt(result));
223
+ } catch (err) {
224
+ pm.close('diff');
225
+ ctx.focusPrompt?.();
226
+ ctx.print(err instanceof AttemptError ? `Pick refused: ${err.message}` : `Pick failed: ${summarizeError(err)}`);
227
+ }
228
+ ctx.renderRequest();
229
+ },
230
+ onCancel: () => {
231
+ pm.close('diff');
232
+ ctx.focusPrompt?.();
233
+ ctx.print('Pick cancelled — nothing merged, no worktree cleaned.');
234
+ ctx.renderRequest();
235
+ },
236
+ });
237
+ ctx.print(`Confirm the pick in the diff panel: Enter/y to merge attempt ${cand.attemptIndex + 1} and clean the losers, n/Esc to cancel.`);
238
+ ctx.renderRequest();
239
+ return true;
240
+ }
241
+
242
+ ctx.print(`Unknown attempts action "${action}". Use: list | diff <group> <#> | judge <group> | pick <group> <#>.`);
243
+ return true;
244
+ }
@@ -11,7 +11,12 @@
11
11
  // planning-runtime.ts), NOT a panel — a multi-phase proposal is too rich for
12
12
  // a one-line confirm overlay, and Pillar-3 doctrine keeps work visible in the
13
13
  // transcript. create -> approve -> launch is a real three-step flow (edit and
14
- // cancel apply to the pending proposal too): the engine's own createWorkstream
14
+ // cancel apply to the pending proposal too). The transcript IS the plan-review
15
+ // gate: between create and approve the proposal is reshapable in place —
16
+ // edit-item rewrites one item's brief, remove-item drops an item (and unlinks
17
+ // it from siblings' dependencies), move-item reorders authoring order, and
18
+ // each re-renders the whole proposal and clears any prior approval, so nothing
19
+ // launches until the reshaped plan is explicitly re-approved. The engine's own createWorkstream
15
20
  // immediately materializes a real, ticking-eligible Workstream with no
16
21
  // pre-creation "draft" concept, so approval happens on a TUI-held draft
17
22
  // (workstream-services.ts's WorkstreamDraft) before anything is created in
@@ -22,7 +27,10 @@
22
27
  import { AdaptivePlanner } from '@pellux/goodvibes-sdk/platform/core';
23
28
  import type { CreateWorkstreamInput, PhaseKind, PhaseRole, WorkItem, WorkItemState, Workstream, WorkstreamIsolation } from '@pellux/goodvibes-sdk/platform/orchestration';
24
29
  import type { WorkstreamCommandService, WorkstreamDraft, WorkstreamDraftProvenance } from '../../runtime/workstream-services.ts';
30
+ import { validateAttempts } from '../../runtime/workstream-attempts-validation.ts';
31
+ import { handleAttemptsSubcommand } from './workstream-attempts.ts';
25
32
  import type { CommandContext, CommandRegistry } from '../command-registry.ts';
33
+ import { describeOperatorRpcError, getOperatorRpc } from './operator-rpc.ts';
26
34
 
27
35
  // ---------------------------------------------------------------------------
28
36
  // Formatting helpers
@@ -53,6 +61,47 @@ function templateForPhaseKind(kind: PhaseKind): string {
53
61
  return kind === 'review' || kind === 'gate' ? 'general' : 'engineer';
54
62
  }
55
63
 
64
+ /**
65
+ * Pre-fan-out quota consultation for /workstream launch. Assesses whether the
66
+ * draft's work-item count (the fan-out this launch is about to create, worst
67
+ * case — items can run concurrently within a phase) is likely to exhaust the
68
+ * active provider's quota window, grounded in observed rate-limit signals
69
+ * (quota.fanout.get). Returns a printable warning + evidence when the daemon
70
+ * reports 'likely-exhausts'; returns null for 'unlikely'/'unknown' (no
71
+ * evidence of risk is not itself evidence of safety, so those verdicts never
72
+ * block) and whenever the check itself can't run (daemon unavailable/
73
+ * unreachable, or no active provider) — an infra gap in the quota check is
74
+ * not grounds to block launching work the operator already approved.
75
+ */
76
+ async function checkFanoutQuotaWarning(ctx: CommandContext, draft: WorkstreamDraft): Promise<string | null> {
77
+ const provider = ctx.session.runtime.provider;
78
+ if (!provider) return null;
79
+ const rpc = getOperatorRpc(ctx);
80
+ if (!rpc.available) return null;
81
+ const agentCount = Math.max(1, draft.proposal.workItems.length);
82
+ const assessment = await rpc.sdk.operator.invoke('quota.fanout.get', { provider, agentCount }).catch((error: unknown) => {
83
+ ctx.print(`[workstream launch] quota check could not run: ${describeOperatorRpcError(error)}`);
84
+ return null;
85
+ });
86
+ if (!assessment || assessment.verdict !== 'likely-exhausts') return null;
87
+ const ev = assessment.evidence;
88
+ const evidenceParts = [
89
+ `requested=${ev.requestedAgents}`,
90
+ `recentRateLimitCount=${ev.recentRateLimitCount}`,
91
+ ev.activeCooldownMs !== undefined ? `activeCooldownMs=${ev.activeCooldownMs}` : null,
92
+ ev.observedRemaining !== undefined ? `observedRemaining=${ev.observedRemaining}` : null,
93
+ ev.observedLimit !== undefined ? `observedLimit=${ev.observedLimit}` : null,
94
+ ].filter((part): part is string => part !== null);
95
+ return [
96
+ `[workstream launch] WARNING — ${provider} likely exhausts its quota window for ${agentCount} agent(s) fanning out.`,
97
+ ` reason: ${assessment.reason}`,
98
+ ` evidence: ${evidenceParts.join(' ')}`,
99
+ '',
100
+ `Launch anyway: /workstream launch ${draft.id} --force`,
101
+ 'Cancel: do nothing — the approved proposal stays ready to launch later.',
102
+ ].join('\n');
103
+ }
104
+
56
105
  /**
57
106
  * phase.role for a 'custom' phase is the free-text description passed to
58
107
  * `/workstream insert-phase` — it is purely COSMETIC. Neither
@@ -142,11 +191,21 @@ function formatItemDependencyNote(item: WorkItem, ws: Workstream): string {
142
191
  */
143
192
  function formatDraftItems(spec: CreateWorkstreamInput): string[] {
144
193
  const titleById = new Map(spec.items.map((it) => [it.id ?? it.title, it.title] as const));
145
- return spec.items.map((it, i) => {
194
+ const lines: string[] = [];
195
+ spec.items.forEach((it, i) => {
146
196
  const deps = it.dependsOn ?? [];
147
197
  const after = deps.length > 0 ? ` (after: ${deps.map((d) => titleById.get(d) ?? d).join(', ')})` : '';
148
- return ` ${i + 1}. ${it.title}${after}`;
198
+ const attempts = (it.attempts ?? 1) > 1 ? ` [best-of-${it.attempts}${it.autoAcceptWinner ? ', auto-accept winner' : ''}]` : '';
199
+ lines.push(` ${i + 1}. ${it.title}${after}${attempts}`);
200
+ // Show the brief (the instructions the item's agent runs) only when it says
201
+ // something the title doesn't — an edited brief (via /workstream edit-item)
202
+ // must be visible on the review surface, but a decomposition whose title and
203
+ // brief coincide would just repeat itself.
204
+ if (it.task.trim() && it.task.trim() !== it.title.trim()) {
205
+ lines.push(` brief: ${it.task.trim()}`);
206
+ }
149
207
  });
208
+ return lines;
150
209
  }
151
210
 
152
211
  function summarizeItemStates(ws: Workstream): string {
@@ -204,18 +263,31 @@ function renderDraftProposal(draft: WorkstreamDraft): string {
204
263
  });
205
264
  lines.push('Work items (ordinal order):');
206
265
  lines.push(...formatDraftItems(draft.spec));
266
+ // Best-of-N plan validation (workstream-attempts-validation.ts): surface the
267
+ // worktree/stable-id constraint breaks here so a violating plan is visible
268
+ // before approve, and blocked at launch. Non-leaf best-of-N (items with
269
+ // dependents) is allowed. Notes (e.g. the engine's attempts cap) are advisory.
270
+ const attemptsCheck = validateAttempts(draft.spec);
271
+ if (attemptsCheck.violations.length > 0) {
272
+ lines.push('Best-of-N: plan is INVALID and cannot launch until fixed:');
273
+ for (const v of attemptsCheck.violations) lines.push(` ✗ ${v}`);
274
+ } else if (attemptsCheck.hasAttempts) {
275
+ lines.push('Best-of-N: worktree + stable-id constraints satisfied — winners are chosen via /workstream attempts pick, and any dependents wait for the winner to be picked and merged.');
276
+ }
277
+ for (const n of attemptsCheck.notes) lines.push(` note: ${n}`);
207
278
  lines.push(
208
279
  draft.approved
209
280
  ? `Approved. Launch with: /workstream launch ${draft.id}`
210
281
  : `Approve with: /workstream approve ${draft.id}`,
211
282
  );
212
- lines.push(`Edit the task: /workstream edit ${draft.id} <new task...> Discard: /workstream cancel ${draft.id}`);
213
- // Honest limitation (see workstream-services.ts's REALITY-WINS doc): the
214
- // engine has no pre-creation draft concept, so this proposal is
215
- // process-lifetime, in-memory state only — never journaled like a launched
216
- // Workstream is. State that plainly, the same way an unsent chat draft
217
- // would be, rather than letting a restart silently make it vanish.
218
- lines.push('Note: not saved — lost if the TUI restarts before launch.');
283
+ lines.push(`Reshape: /workstream edit-item ${draft.id} <#> <brief...> | remove-item ${draft.id} <#> | move-item ${draft.id} <#> <pos>`);
284
+ lines.push(`Re-decompose from a new goal: /workstream edit ${draft.id} <new task...> Discard: /workstream cancel ${draft.id}`);
285
+ // Honest durability note (see workstream-services.ts's REALITY-WINS doc):
286
+ // the engine has no pre-launch draft concept, but the TUI journals this
287
+ // proposal to disk (.goodvibes/orchestration/drafts/) and reloads it at
288
+ // startup, so it survives a restart and is here to launch afterward — its
289
+ // approval state included.
290
+ lines.push('Note: saved to .goodvibes/orchestration/drafts/ — survives a restart until you launch or cancel it.');
219
291
  return lines.join('\n');
220
292
  }
221
293
 
@@ -285,18 +357,16 @@ function resolveWorkstream(service: WorkstreamCommandService, ref: string): Work
285
357
  }
286
358
 
287
359
  /**
288
- * approve/edit/launch all fail this way when `id` doesn't resolve to a held
289
- * draft. A bare "No pending proposal found" reads like a typo'd id even when
290
- * the real cause is that drafts are process-lifetime, in-memory-only state
291
- * (see renderDraftProposal's note and workstream-services.ts's REALITY-WINS
292
- * doc) and a TUI restart wiped every one of them. Only hint at that when the
293
- * service is holding zero drafts at all — with other drafts present, a typo
294
- * or stale id is the more honest guess and the plain message stays.
360
+ * approve/edit/launch/*-item all fail this way when `id` doesn't resolve to a
361
+ * held draft. Drafts are now journaled to disk and reloaded at startup (see
362
+ * renderDraftProposal's note and workstream-services.ts's REALITY-WINS doc), so
363
+ * a restart no longer silently wipes them — a missing id is a typo or a
364
+ * stale/already-launched proposal, and the plain message is the honest guess.
365
+ * `service` is kept in the signature so callers stay uniform and a future
366
+ * "did you mean <closest id>?" hint has somewhere to live.
295
367
  */
296
- function draftNotFoundMessage(service: WorkstreamCommandService, id: string): string {
297
- const base = `No pending proposal found: ${id}`;
298
- if (service.listDrafts().length > 0) return base;
299
- return `${base} (proposals aren't saved — a TUI restart since you created it would explain this; recreate it with /workstream create)`;
368
+ function draftNotFoundMessage(_service: WorkstreamCommandService, id: string): string {
369
+ return `No pending proposal found: ${id}`;
300
370
  }
301
371
 
302
372
  // ---------------------------------------------------------------------------
@@ -307,8 +377,8 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
307
377
  registry.register({
308
378
  name: 'workstream',
309
379
  description: 'Author and oversee multi-phase agent workstreams (orchestration engine)',
310
- usage: 'create [--isolation shared|worktree] <task...> | list | status [id] | insert-phase <id> <description...> | approve <id> | edit <id> [--isolation shared|worktree] <task...> | launch <id> | cancel <id>',
311
- argsHint: 'create [--isolation worktree] <task> | list | status [id] | approve | edit | launch | cancel',
380
+ usage: 'create [--isolation shared|worktree] <task...> | list | status [id] | insert-phase <id> <description...> | edit-item <id> <item#> <brief...> | remove-item <id> <item#> | move-item <id> <item#> <pos> | approve <id> | edit <id> [--isolation shared|worktree] <task...> | launch <id> [--force] | cancel <id> | attempts list|diff|judge|pick',
381
+ argsHint: 'create [--isolation worktree] <task> | list | status [id] | edit-item | remove-item | move-item | approve | edit | launch | cancel | attempts',
312
382
  handler: async (args, ctx: CommandContext) => {
313
383
  const service = ctx.session.workstreamEngine;
314
384
  if (!service) {
@@ -318,6 +388,22 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
318
388
 
319
389
  const sub = args[0];
320
390
 
391
+ // Best-of-N surface: /workstream attempts list|diff|judge|pick (workstream-attempts.ts).
392
+ if (await handleAttemptsSubcommand(ctx, service, args)) return;
393
+
394
+ /** Thread editItem/removeItem/moveItem's tri-state result to the transcript: a missing draft gets the restart-aware not-found message, a bad reference/argument gets its honest reason, and a success re-renders the whole reshaped proposal so the review surface always shows the current plan. */
395
+ const printItemEdit = (id: string, res: WorkstreamDraft | { error: string } | undefined): void => {
396
+ if (res === undefined) {
397
+ ctx.print(draftNotFoundMessage(service, id));
398
+ return;
399
+ }
400
+ if ('error' in res) {
401
+ ctx.print(res.error);
402
+ return;
403
+ }
404
+ ctx.print(renderDraftProposal(res));
405
+ };
406
+
321
407
  if (!sub || sub === 'list') {
322
408
  ctx.print(renderWorkstreamList(service));
323
409
  return;
@@ -419,10 +505,47 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
419
505
  return;
420
506
  }
421
507
 
422
- if (sub === 'launch') {
508
+ if (sub === 'edit-item') {
423
509
  const id = args[1];
510
+ const itemRef = args[2];
511
+ const brief = args.slice(3).join(' ').trim();
512
+ if (!id || !itemRef || !brief) {
513
+ ctx.print('Usage: /workstream edit-item <id> <item#> <new brief...>');
514
+ return;
515
+ }
516
+ printItemEdit(id, service.editItem(id, itemRef, brief));
517
+ return;
518
+ }
519
+
520
+ if (sub === 'remove-item') {
521
+ const id = args[1];
522
+ const itemRef = args[2];
523
+ if (!id || !itemRef) {
524
+ ctx.print('Usage: /workstream remove-item <id> <item#>');
525
+ return;
526
+ }
527
+ printItemEdit(id, service.removeItem(id, itemRef));
528
+ return;
529
+ }
530
+
531
+ if (sub === 'move-item') {
532
+ const id = args[1];
533
+ const itemRef = args[2];
534
+ const posRaw = args[3];
535
+ const position = Number(posRaw);
536
+ if (!id || !itemRef || posRaw === undefined || !Number.isInteger(position)) {
537
+ ctx.print('Usage: /workstream move-item <id> <item#> <new-position#>');
538
+ return;
539
+ }
540
+ printItemEdit(id, service.moveItem(id, itemRef, position));
541
+ return;
542
+ }
543
+
544
+ if (sub === 'launch') {
545
+ const force = args.includes('--force');
546
+ const id = args.find((arg, index) => index > 0 && arg !== '--force');
424
547
  if (!id) {
425
- ctx.print('Usage: /workstream launch <id>');
548
+ ctx.print('Usage: /workstream launch <id> [--force]');
426
549
  return;
427
550
  }
428
551
  const draft = service.getDraft(id);
@@ -434,6 +557,18 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
434
557
  ctx.print(`Proposal ${id} is not approved yet. Run /workstream approve ${id} first.`);
435
558
  return;
436
559
  }
560
+ const attemptsCheck = validateAttempts(draft.spec);
561
+ if (attemptsCheck.violations.length > 0) {
562
+ ctx.print(`Cannot launch ${id} — best-of-N plan constraints are violated:\n${attemptsCheck.violations.map((v) => ` - ${v}`).join('\n')}\nFix the plan (or drop the attempts) and re-approve.`);
563
+ return;
564
+ }
565
+ if (!force) {
566
+ const quotaWarning = await checkFanoutQuotaWarning(ctx, draft);
567
+ if (quotaWarning) {
568
+ ctx.print(quotaWarning);
569
+ return;
570
+ }
571
+ }
437
572
  const result = service.launchDraft(id);
438
573
  if (!result) {
439
574
  ctx.print(`Could not launch ${id}.`);
@@ -478,9 +613,12 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
478
613
  + ' /workstream list\n'
479
614
  + ' /workstream status [id]\n'
480
615
  + ' /workstream insert-phase <id> <description...>\n'
616
+ + ' /workstream edit-item <id> <item#> <new brief...>\n'
617
+ + ' /workstream remove-item <id> <item#>\n'
618
+ + ' /workstream move-item <id> <item#> <new-position#>\n'
481
619
  + ' /workstream approve <id>\n'
482
620
  + ' /workstream edit <id> [--isolation shared|worktree] <new task...>\n'
483
- + ' /workstream launch <id>\n'
621
+ + ' /workstream launch <id> [--force]\n'
484
622
  + ' /workstream cancel <id>',
485
623
  );
486
624
  },
@@ -1,13 +1,39 @@
1
1
  import { getPersistedWorktreeMeta, reviewWorktreeAttachments, summarizeWorktreeOwnership } from '@/runtime/index.ts';
2
+ import type { ManagedWorktreeMeta } from '@/runtime/index.ts';
2
3
  import type { CommandRegistry } from '../command-registry.ts';
3
4
  import { openCommandPanel, requireShellPaths } from './runtime-services.ts';
5
+ import { describeOperatorRpcError, getOperatorRpc } from './operator-rpc.ts';
6
+
7
+ /** Compact per-row setup-state tag for /worktree review — absent when setup has never run; failure stands out. */
8
+ export function formatSetupTag(setup: ManagedWorktreeMeta['setup']): string {
9
+ if (!setup) return '';
10
+ if (setup.state === 'failed') return ' setup:FAILED';
11
+ if (setup.state === 'succeeded') return ' setup:ok';
12
+ return ' setup:skipped';
13
+ }
14
+
15
+ /** Full setup-state block for /worktree inspect — the failing step + captured output when failed. */
16
+ export function formatSetupDetail(setup: ManagedWorktreeMeta['setup']): string[] {
17
+ if (!setup) return [' setup: never run'];
18
+ if (setup.state !== 'failed') return [` setup: ${setup.state}`];
19
+ const failingStep = setup.steps.find((step) => !step.ok);
20
+ return [
21
+ ` setup: FAILED — ${setup.error ?? 'unknown error'}`,
22
+ ...(failingStep
23
+ ? [
24
+ ` failing step (${failingStep.kind}): ${failingStep.label}${failingStep.exitCode !== undefined ? ` (exit ${failingStep.exitCode})` : ''}`,
25
+ ...(failingStep.output ? [` output: ${failingStep.output.slice(0, 2000)}`] : []),
26
+ ]
27
+ : []),
28
+ ];
29
+ }
4
30
 
5
31
  export function registerWorktreeRuntimeCommands(registry: CommandRegistry): void {
6
32
  registry.register({
7
33
  name: 'worktree',
8
34
  aliases: ['worktrees'],
9
35
  description: 'Review and manage orchestrator-owned git worktrees',
10
- usage: '[review|panel|inspect <path>|attach <path> <session|task> <id>|session <id>|task <id>|recover <session|task> <id>|pause <path>|resume <path>|keep <path>|discard <path>|cleanup <path>]',
36
+ usage: '[review|panel|inspect <path>|setup <path>|attach <path> <session|task> <id>|session <id>|task <id>|recover <session|task> <id>|pause <path>|resume <path>|keep <path>|discard <path>|cleanup <path>]',
11
37
  async handler(args, ctx) {
12
38
  const sub = (args[0] ?? 'review').toLowerCase();
13
39
  const runtime = ctx.workspace.worktreeRegistry;
@@ -32,6 +58,7 @@ export function registerWorktreeRuntimeCommands(registry: CommandRegistry): void
32
58
  return;
33
59
  }
34
60
  const nextSteps = [
61
+ record.setup?.state === 'failed' ? `/worktree setup ${record.path}` : null,
35
62
  record.state === 'paused' ? `/worktree resume ${record.path}` : null,
36
63
  record.state === 'discard' || record.state === 'pending-cleanup' ? `/worktree cleanup ${record.path}` : null,
37
64
  record.state === 'kept' ? `/worktree keep ${record.path}` : null,
@@ -47,10 +74,37 @@ export function registerWorktreeRuntimeCommands(registry: CommandRegistry): void
47
74
  ` session: ${record.sessionId ?? 'n/a'}`,
48
75
  ` task: ${record.taskId ?? 'n/a'}`,
49
76
  ` updated: ${new Date(record.updatedAt).toLocaleString()}`,
77
+ ...formatSetupDetail(record.setup),
50
78
  ...(nextSteps.length > 0 ? [' next:', ...nextSteps.map((step) => ` ${step}`)] : [' next: /worktree review']),
51
79
  ].join('\n'));
52
80
  return;
53
81
  }
82
+ if (sub === 'setup') {
83
+ const path = args[1];
84
+ if (!path) {
85
+ ctx.print('Usage: /worktree setup <path>');
86
+ return;
87
+ }
88
+ const rpc = getOperatorRpc(ctx);
89
+ if (!rpc.available) {
90
+ ctx.print(`[worktree setup] ${rpc.reason}`);
91
+ return;
92
+ }
93
+ ctx.print(`[worktree setup] Re-running cold-start setup for ${path}...`);
94
+ try {
95
+ const { setup } = await rpc.sdk.operator.invoke('worktrees.setup.run', { path });
96
+ if (setup.state === 'skipped') {
97
+ ctx.print('[worktree setup] skipped — no setup commands or carry-over globs are configured.');
98
+ } else if (setup.state === 'succeeded') {
99
+ ctx.print(`[worktree setup] succeeded (${setup.steps.length} step(s)).`);
100
+ } else {
101
+ ctx.print(formatSetupDetail(setup).join('\n'));
102
+ }
103
+ } catch (error) {
104
+ ctx.print(`[worktree setup] round-trip request failed: ${describeOperatorRpcError(error)}`);
105
+ }
106
+ return;
107
+ }
54
108
  if (sub === 'attach') {
55
109
  const path = args[1];
56
110
  const targetKind = args[2];
@@ -128,7 +182,7 @@ export function registerWorktreeRuntimeCommands(registry: CommandRegistry): void
128
182
  ` session attached: ${summary.sessionAttached} task attached: ${summary.taskAttached}`,
129
183
  ` agent owned: ${summary.agentOwned} orchestrator owned: ${summary.orchestratorOwned} manual: ${summary.manualOwned}`,
130
184
  ...rows.map((row) => (
131
- ` ${row.kind.padEnd(12)} ${row.state.padEnd(15)} ${row.branch.padEnd(22)} session=${(row.sessionId ?? '-').padEnd(12)} task=${(row.taskId ?? '-').padEnd(12)} ${row.path}`
185
+ ` ${row.kind.padEnd(12)} ${row.state.padEnd(15)} ${row.branch.padEnd(22)} session=${(row.sessionId ?? '-').padEnd(12)} task=${(row.taskId ?? '-').padEnd(12)} ${row.path}${formatSetupTag(row.setup)}`
132
186
  )),
133
187
  ].join('\n')
134
188
  : 'Worktree Review\n No worktrees discovered.');