@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.
- package/CHANGELOG.md +96 -0
- package/README.md +6 -6
- package/bin/goodvibes +6 -0
- package/bin/goodvibes-daemon +6 -0
- package/bin/launcher-support.js +34 -1
- package/docs/foundation-artifacts/operator-contract.json +8076 -2047
- package/package.json +15 -11
- package/scripts/postinstall.js +73 -48
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completion.ts +1 -1
- package/src/cli/completions/generate.ts +15 -3
- package/src/cli/doctor.ts +385 -0
- package/src/cli/entrypoint.ts +72 -1
- package/src/cli/help.ts +29 -9
- package/src/cli/hooks-command.ts +74 -0
- package/src/cli/hooks-report.ts +167 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/management.ts +13 -1
- package/src/cli/parser.ts +10 -2
- package/src/cli/plugin-bundles-command.ts +228 -0
- package/src/cli/plugin-command.ts +179 -0
- package/src/cli/service-posture.ts +10 -0
- package/src/cli/status.ts +275 -2
- package/src/cli/tui-startup.ts +119 -1
- package/src/cli/types.ts +3 -1
- package/src/config/index.ts +16 -0
- package/src/config/tui-extension-settings.ts +169 -0
- package/src/core/approval-alert.ts +15 -2
- package/src/core/compaction-receipt.ts +127 -0
- package/src/core/model-routing-chip.ts +82 -0
- package/src/core/permission-mode.ts +89 -0
- package/src/core/rewind-receipt.ts +83 -0
- package/src/core/rewind-turn-anchors.ts +160 -0
- package/src/core/scriptable-statusline.ts +141 -0
- package/src/core/session-auto-titler.ts +110 -0
- package/src/core/stream-event-wiring.ts +20 -0
- package/src/core/system-message-router.ts +1 -1
- package/src/core/terminal-notifier.ts +102 -0
- package/src/core/turn-event-wiring.ts +80 -1
- package/src/input/command-reference.ts +96 -0
- package/src/input/command-registry.ts +25 -0
- package/src/input/commands/channel-pairing.ts +245 -0
- package/src/input/commands/channel-runtime.ts +152 -6
- package/src/input/commands/checkin-runtime.ts +89 -0
- package/src/input/commands/checkpoint-runtime.ts +7 -179
- package/src/input/commands/ci-runtime.ts +207 -0
- package/src/input/commands/cost-attribution-format.ts +52 -0
- package/src/input/commands/cost-runtime.ts +44 -4
- package/src/input/commands/editor-runtime.ts +22 -0
- package/src/input/commands/flags-runtime.ts +328 -0
- package/src/input/commands/health-metrics-format.ts +67 -0
- package/src/input/commands/health-runtime.ts +55 -1
- package/src/input/commands/memory.ts +23 -0
- package/src/input/commands/operator-rpc.ts +95 -0
- package/src/input/commands/palette-runtime.ts +22 -0
- package/src/input/commands/permissions-provenance.ts +264 -0
- package/src/input/commands/permissions-runtime.ts +21 -0
- package/src/input/commands/planning-runtime.ts +28 -1
- package/src/input/commands/platform-access-runtime.ts +2 -97
- package/src/input/commands/principals-runtime.ts +212 -0
- package/src/input/commands/product-runtime.ts +43 -3
- package/src/input/commands/recall-files-config.ts +54 -0
- package/src/input/commands/recall-files-git.ts +57 -0
- package/src/input/commands/recall-files.ts +185 -0
- package/src/input/commands/recall-query.ts +8 -3
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/recall-shared.ts +13 -0
- package/src/input/commands/relay-runtime.ts +86 -0
- package/src/input/commands/review-runtime.ts +160 -0
- package/src/input/commands/rewind-runtime.ts +362 -0
- package/src/input/commands/schedule-nl.ts +147 -0
- package/src/input/commands/schedule-runtime.ts +29 -10
- package/src/input/commands/secret-runtime.ts +55 -0
- package/src/input/commands/session-content.ts +19 -8
- package/src/input/commands/session-workflow.ts +10 -0
- package/src/input/commands/update-runtime.ts +363 -0
- package/src/input/commands/workstream-attempt-dependents.ts +103 -0
- package/src/input/commands/workstream-attempts.ts +244 -0
- package/src/input/commands/workstream-runtime.ts +164 -26
- package/src/input/commands/worktree-runtime.ts +56 -2
- package/src/input/commands.ts +135 -80
- package/src/input/composer-capture.ts +67 -0
- package/src/input/composer-editor.ts +134 -0
- package/src/input/concealed-input.ts +94 -0
- package/src/input/exec-env-scrub-config.ts +84 -0
- package/src/input/feature-unit-layout.ts +142 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed-routes.ts +17 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-modal-routes.ts +5 -2
- package/src/input/handler-shortcuts.ts +37 -0
- package/src/input/handler.ts +24 -2
- package/src/input/keybindings.ts +11 -2
- package/src/input/onboarding/onboarding-feature-units.ts +536 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
- package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
- package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
- package/src/input/relay-settings-descriptions.ts +35 -0
- package/src/input/sandbox-exec-config.ts +107 -0
- package/src/input/settings-modal-adjustment.ts +4 -7
- package/src/input/settings-modal-data.ts +178 -8
- package/src/input/settings-modal-mutations.ts +41 -13
- package/src/input/settings-modal-types.ts +42 -5
- package/src/input/settings-modal.ts +67 -15
- package/src/input/submission-router.ts +4 -1
- package/src/input/worktree-setup-config.ts +93 -0
- package/src/main.ts +43 -43
- package/src/panels/builtin/development.ts +14 -0
- package/src/panels/diff-review-model.ts +189 -0
- package/src/panels/diff-review-panel.ts +395 -0
- package/src/panels/fleet-panel-format.ts +114 -2
- package/src/panels/fleet-panel.ts +65 -78
- package/src/panels/fleet-read-model.ts +91 -11
- package/src/panels/fleet-stop.ts +19 -4
- package/src/panels/modals/memory-modal.ts +11 -2
- package/src/permissions/hunk-selection.ts +86 -5
- package/src/permissions/prompt.ts +108 -7
- package/src/permissions/sandbox-exec-gate.ts +162 -0
- package/src/renderer/settings-modal-helpers.ts +21 -1
- package/src/renderer/settings-modal.ts +70 -44
- package/src/renderer/shell-surface.ts +23 -1
- package/src/renderer/system-message.ts +4 -0
- package/src/renderer/tool-result-summary.ts +95 -5
- package/src/renderer/ui-factory.ts +22 -0
- package/src/runtime/bootstrap-command-context.ts +15 -1
- package/src/runtime/bootstrap-command-parts.ts +14 -2
- package/src/runtime/bootstrap-core.ts +38 -38
- package/src/runtime/bootstrap-notifier-sync.ts +41 -0
- package/src/runtime/bootstrap-shell.ts +8 -0
- package/src/runtime/bootstrap.ts +21 -23
- package/src/runtime/context-accounting-source.ts +103 -0
- package/src/runtime/conversation-rewind-port.ts +130 -0
- package/src/runtime/fleet-needs-input-push.ts +70 -0
- package/src/runtime/install-self-check-startup.ts +36 -0
- package/src/runtime/install-self-check.ts +149 -0
- package/src/runtime/onboarding/fast-path.ts +93 -0
- package/src/runtime/process-lifecycle.ts +32 -24
- package/src/runtime/relay-reachability-bridge.ts +53 -0
- package/src/runtime/release-artifacts.ts +79 -0
- package/src/runtime/services.ts +33 -33
- package/src/runtime/trust/workspace-registration.ts +184 -0
- package/src/runtime/trust/workspace-trust.ts +202 -0
- package/src/runtime/ui-services.ts +2 -1
- package/src/runtime/update-check.ts +113 -0
- package/src/runtime/workspace-checkpointing.ts +32 -0
- package/src/runtime/workstream-attempts-validation.ts +77 -0
- package/src/runtime/workstream-draft-edits.ts +95 -0
- package/src/runtime/workstream-draft-store.ts +101 -0
- package/src/runtime/workstream-draft-types.ts +42 -0
- package/src/runtime/workstream-services.ts +70 -39
- package/src/shell/blocking-input.ts +29 -0
- package/src/shell/ui-openers.ts +65 -0
- package/src/verification/verification-ledger.ts +56 -2
- package/src/version.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
- package/src/runtime/gateway-verbs.ts +0 -47
- package/src/runtime/render-scheduler.ts +0 -90
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-registration.ts — first-open workspace REGISTRATION half.
|
|
3
|
+
*
|
|
4
|
+
* Companion to the per-workspace trust gate (workspace-trust.ts). Trust is
|
|
5
|
+
* TUI-local (a decision persisted under <cwd>/.goodvibes/tui); REGISTRATION is
|
|
6
|
+
* the platform-wide "which project roots has the operator opted GoodVibes into"
|
|
7
|
+
* registry, owned by the SDK.
|
|
8
|
+
*
|
|
9
|
+
* SINGLE SOURCE OF TRUTH. Resolution and mutation both go through the SDK's
|
|
10
|
+
* shared WorkspaceRegistrationStore — the exact class the daemon composes its
|
|
11
|
+
* `workspaces.*` gateway verbs over (control-plane/routes/register-gateway-verb-
|
|
12
|
+
* groups.ts constructs `new WorkspaceRegistrationStore({ path: resolveUserPath(
|
|
13
|
+
* 'control-plane', 'workspace-registrations.json'), homeDir: dirname(daemonState
|
|
14
|
+
* Dir), daemonStateDir })`). We point an in-process store at that identical path
|
|
15
|
+
* and roots, so whether the daemon's `workspaces.resolve` verb reads it over the
|
|
16
|
+
* wire or we read it in-process, both operate on ONE persisted document. This is
|
|
17
|
+
* not a divergent reimplementation: same SDK store class, same file, same
|
|
18
|
+
* root-guard. In-process (rather than an RPC to a composed daemon) because the
|
|
19
|
+
* first-open prompt renders synchronously at startup and must not depend on a
|
|
20
|
+
* daemon being up — the file is the shared source either way.
|
|
21
|
+
*
|
|
22
|
+
* Coverage semantics are the store's (implemented in the SDK, not here): coverage
|
|
23
|
+
* flows DOWN a registered root's subtree, a linked git worktree inherits its main
|
|
24
|
+
* repo's registration, and a remembered decline is subtree-scoped at the root it
|
|
25
|
+
* was asked. So the register half of the first-open prompt only appears when
|
|
26
|
+
* resolution says UNKNOWN; a covered or declined path never re-asks.
|
|
27
|
+
*/
|
|
28
|
+
import { dirname } from 'node:path';
|
|
29
|
+
import { parse as parsePath } from 'node:path';
|
|
30
|
+
import {
|
|
31
|
+
WorkspaceRegistrationStore,
|
|
32
|
+
WorkspaceRegistrationError,
|
|
33
|
+
normalizeWorkspaceRoot,
|
|
34
|
+
type WorkspaceCoverageStatus,
|
|
35
|
+
type RegisterWorkspaceResult,
|
|
36
|
+
} from '@pellux/goodvibes-sdk/platform/workspace';
|
|
37
|
+
|
|
38
|
+
/** The slice of ShellPathService this manager needs. */
|
|
39
|
+
export interface WorkspaceRegistrationShellPaths {
|
|
40
|
+
readonly workingDirectory: string;
|
|
41
|
+
readonly homeDirectory: string;
|
|
42
|
+
resolveUserPath(...segments: string[]): string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Filename of the shared control-plane registry document (matches the daemon composition). */
|
|
46
|
+
const REGISTRATIONS_FILE = 'workspace-registrations.json';
|
|
47
|
+
|
|
48
|
+
export interface WorkspaceRegistrationEvaluation {
|
|
49
|
+
/** Normalized absolute working directory the prompt would ask about. */
|
|
50
|
+
readonly root: string;
|
|
51
|
+
/** Coverage verdict from the shared store: covered / declined / unknown. */
|
|
52
|
+
readonly status: WorkspaceCoverageStatus;
|
|
53
|
+
/** The nearest registered root covering the path, or null. */
|
|
54
|
+
readonly coveredBy: string | null;
|
|
55
|
+
/** True when coverage was inherited through the git worktree→main-repo link. */
|
|
56
|
+
readonly viaWorktreeLink: boolean;
|
|
57
|
+
/** True when the root is one the store would refuse ($HOME, filesystem root, ~/.goodvibes). */
|
|
58
|
+
readonly broad: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Whether to OFFER the register half of the first-open prompt: only when the
|
|
61
|
+
* store says UNKNOWN and the root is registrable. Broad roots are never
|
|
62
|
+
* offered — the store refuses them, so we do not prompt for what would be
|
|
63
|
+
* refused. Covered / declined roots never re-ask.
|
|
64
|
+
*/
|
|
65
|
+
readonly offerRegister: boolean;
|
|
66
|
+
/** Human-readable justification from the resolver. */
|
|
67
|
+
readonly reason: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface WorkspaceRegistrationManagerOptions {
|
|
71
|
+
readonly shellPaths: WorkspaceRegistrationShellPaths;
|
|
72
|
+
/**
|
|
73
|
+
* Inject a pre-built store (e.g. a `:memory:` store for tests). Defaults to an
|
|
74
|
+
* in-process store at the shared control-plane path, configured identically to
|
|
75
|
+
* the daemon's.
|
|
76
|
+
*/
|
|
77
|
+
readonly store?: WorkspaceRegistrationStore;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class WorkspaceRegistrationManager {
|
|
81
|
+
private readonly store: WorkspaceRegistrationStore;
|
|
82
|
+
private readonly workingDirectory: string;
|
|
83
|
+
private readonly homeDir: string;
|
|
84
|
+
private readonly daemonStateDir: string;
|
|
85
|
+
|
|
86
|
+
constructor(options: WorkspaceRegistrationManagerOptions) {
|
|
87
|
+
const { shellPaths } = options;
|
|
88
|
+
this.workingDirectory = shellPaths.workingDirectory;
|
|
89
|
+
// Mirror the daemon's composition exactly (register-gateway-verb-groups.ts):
|
|
90
|
+
// the daemon state dir IS resolveUserPath() (~/.goodvibes); its parent is the
|
|
91
|
+
// home directory — both refused as broad roots by the same guard.
|
|
92
|
+
this.daemonStateDir = shellPaths.resolveUserPath();
|
|
93
|
+
this.homeDir = dirname(this.daemonStateDir);
|
|
94
|
+
this.store =
|
|
95
|
+
options.store ??
|
|
96
|
+
new WorkspaceRegistrationStore({
|
|
97
|
+
path: shellPaths.resolveUserPath('control-plane', REGISTRATIONS_FILE),
|
|
98
|
+
homeDir: this.homeDir,
|
|
99
|
+
daemonStateDir: this.daemonStateDir,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* isBroadRoot — mirrors the SDK store's broad-root refusal SET (filesystem
|
|
105
|
+
* root, home directory, daemon state dir) so the register half is never
|
|
106
|
+
* offered for a root the store would refuse. This is a UI-suppression predicate
|
|
107
|
+
* only; the store's `add` remains the enforcement authority (see register()),
|
|
108
|
+
* which is why register() also catches WorkspaceRegistrationError.
|
|
109
|
+
*/
|
|
110
|
+
private isBroadRoot(normalizedRoot: string): boolean {
|
|
111
|
+
if (normalizedRoot === parsePath(normalizedRoot).root) return true;
|
|
112
|
+
if (normalizedRoot === normalizeWorkspaceRoot(this.homeDir)) return true;
|
|
113
|
+
if (normalizedRoot === normalizeWorkspaceRoot(this.daemonStateDir)) return true;
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the current working directory against the shared registry and decide
|
|
119
|
+
* whether the register half of the first-open prompt should appear. Read-only —
|
|
120
|
+
* never writes, so it is safe to call from `status`/`doctor`.
|
|
121
|
+
*/
|
|
122
|
+
async evaluate(): Promise<WorkspaceRegistrationEvaluation> {
|
|
123
|
+
const resolution = await this.store.resolve(this.workingDirectory);
|
|
124
|
+
const broad = this.isBroadRoot(resolution.path);
|
|
125
|
+
return {
|
|
126
|
+
root: resolution.path,
|
|
127
|
+
status: resolution.status,
|
|
128
|
+
coveredBy: resolution.coveredBy,
|
|
129
|
+
viaWorktreeLink: resolution.viaWorktreeLink,
|
|
130
|
+
broad,
|
|
131
|
+
offerRegister: resolution.status === 'unknown' && !broad,
|
|
132
|
+
reason: resolution.reason,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Register the current working directory at its resolved project root. The
|
|
138
|
+
* store refuses broad roots (WorkspaceRegistrationError) — we surface that
|
|
139
|
+
* honestly rather than record a phantom registration.
|
|
140
|
+
*/
|
|
141
|
+
async register(label?: string): Promise<
|
|
142
|
+
| { readonly registered: true; readonly result: RegisterWorkspaceResult }
|
|
143
|
+
| { readonly registered: false; readonly refusedReason: string }
|
|
144
|
+
> {
|
|
145
|
+
try {
|
|
146
|
+
const result = await this.store.add(this.workingDirectory, label ? { label } : undefined);
|
|
147
|
+
return { registered: true, result };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error instanceof WorkspaceRegistrationError) {
|
|
150
|
+
return { registered: false, refusedReason: error.message };
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Remember a subtree-scoped decline at the current working directory. Idempotent. */
|
|
157
|
+
async decline(): Promise<{ readonly root: string; readonly alreadyDeclined: boolean }> {
|
|
158
|
+
return this.store.decline(this.workingDirectory);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The normalized working directory this manager asks about. */
|
|
162
|
+
getWorkingRoot(): string {
|
|
163
|
+
return normalizeWorkspaceRoot(this.workingDirectory);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A one-liner registration posture for `status`/`doctor`. Read-only.
|
|
168
|
+
*/
|
|
169
|
+
async describe(): Promise<string> {
|
|
170
|
+
const evaluation = await this.evaluate();
|
|
171
|
+
switch (evaluation.status) {
|
|
172
|
+
case 'covered':
|
|
173
|
+
return evaluation.viaWorktreeLink
|
|
174
|
+
? `registered (via worktree link to ${evaluation.coveredBy})`
|
|
175
|
+
: `registered (covered by ${evaluation.coveredBy})`;
|
|
176
|
+
case 'declined':
|
|
177
|
+
return 'not registered (registration declined for this directory)';
|
|
178
|
+
case 'unknown':
|
|
179
|
+
return evaluation.broad
|
|
180
|
+
? 'not registered (this root is too broad to register)'
|
|
181
|
+
: 'not registered (never asked)';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-trust.ts — per-workspace trust gate.
|
|
3
|
+
*
|
|
4
|
+
* The first time GoodVibes opens a workspace (a cwd / project root it has no
|
|
5
|
+
* prior state for), that workspace is "restricted": only read-category tools
|
|
6
|
+
* run; write, execute, and delegate tools are denied until the user marks the
|
|
7
|
+
* workspace trusted. This is NOT a parallel permission checker — the decision
|
|
8
|
+
* is consulted by the real permission machinery at its final ask layer (the
|
|
9
|
+
* requestPermission callback wired in bootstrap-core.ts), so it composes with,
|
|
10
|
+
* and cannot drift from, the existing PermissionManager layer chain.
|
|
11
|
+
*
|
|
12
|
+
* Trust is persisted per-workspace in <cwd>/.goodvibes/tui/trust.json. A
|
|
13
|
+
* workspace that already carries GoodVibes runtime state (prior sessions,
|
|
14
|
+
* checkpoints, state, memory, or a project onboarding marker) is grandfathered
|
|
15
|
+
* as trusted on first load, so the gate only ever prompts for genuinely new
|
|
16
|
+
* places.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { JsonFileStore } from '@pellux/goodvibes-sdk/platform/state';
|
|
21
|
+
import type { PermissionCategory } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
22
|
+
import type { PermissionPromptRequest, PermissionPromptDecision } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
23
|
+
|
|
24
|
+
type AskCallback = (request: PermissionPromptRequest) => Promise<PermissionPromptDecision>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wrap the permission machinery's final ask callback with the workspace trust
|
|
28
|
+
* gate: in an untrusted workspace, deny any non-read tool outright instead of
|
|
29
|
+
* asking. This runs inside PermissionManager's own layer chain (it IS the ask
|
|
30
|
+
* layer), so it composes with — and cannot drift from — the real machinery.
|
|
31
|
+
*/
|
|
32
|
+
export function trustGatedAsk(manager: Pick<WorkspaceTrustManager, 'isCategoryAllowed'>, ask: AskCallback): AskCallback {
|
|
33
|
+
return (request) =>
|
|
34
|
+
manager.isCategoryAllowed(request.category) ? ask(request) : Promise.resolve({ approved: false });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type WorkspaceTrustLevel = 'trusted' | 'restricted';
|
|
38
|
+
|
|
39
|
+
interface PersistedWorkspaceTrust {
|
|
40
|
+
level: WorkspaceTrustLevel;
|
|
41
|
+
decidedAt: string;
|
|
42
|
+
grandfathered?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TRUST_FILE = 'trust.json';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* detectPriorWorkspaceState — true if <workingDirectory>/.goodvibes already
|
|
49
|
+
* holds GoodVibes RUNTIME state from a prior session. Deliberately keyed on
|
|
50
|
+
* generated state (sessions/checkpoints/state/memory/onboarding marker), not on
|
|
51
|
+
* committed scaffolding like .goodvibes/agents or GOODVIBES.md, so a checked-out
|
|
52
|
+
* template does not read as "previously used". MUST be sampled before any
|
|
53
|
+
* service initializes the workspace this session (bootstrap-core samples it
|
|
54
|
+
* before createRuntimeServices), or this-run scaffolding would look like prior
|
|
55
|
+
* use.
|
|
56
|
+
*/
|
|
57
|
+
export function detectPriorWorkspaceState(workingDirectory: string): boolean {
|
|
58
|
+
const root = join(workingDirectory, '.goodvibes');
|
|
59
|
+
if (!existsSync(root)) return false;
|
|
60
|
+
|
|
61
|
+
const fileSignals = [
|
|
62
|
+
join(root, 'checkpoints', 'index.json'),
|
|
63
|
+
join(root, 'tui', 'onboarding-checked.json'),
|
|
64
|
+
join(root, 'goodvibes.json'),
|
|
65
|
+
];
|
|
66
|
+
for (const file of fileSignals) {
|
|
67
|
+
if (existsSync(file)) return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dirSignals = ['sessions', 'checkpoints', 'state', 'memory'];
|
|
71
|
+
for (const dir of dirSignals) {
|
|
72
|
+
const path = join(root, dir);
|
|
73
|
+
try {
|
|
74
|
+
if (existsSync(path) && statSync(path).isDirectory() && readdirSync(path).length > 0) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Unreadable — treat as absent rather than crash the gate.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface WorkspaceTrustPaths {
|
|
85
|
+
readonly projectGoodVibesRoot: string;
|
|
86
|
+
resolveProjectPath(...segments: string[]): string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read-only view of the persisted trust decision, for status/doctor reporting. */
|
|
90
|
+
export interface PersistedWorkspaceTrustView {
|
|
91
|
+
/** 'trusted' | 'restricted' when a decision exists; 'undecided' when none is persisted yet. */
|
|
92
|
+
readonly level: WorkspaceTrustLevel | 'undecided';
|
|
93
|
+
readonly grandfathered: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* readPersistedWorkspaceTrust — read <cwd>/.goodvibes/tui/trust.json WITHOUT the
|
|
98
|
+
* side effects of WorkspaceTrustManager.load() (which grandfathers and persists).
|
|
99
|
+
* Reporting surfaces (`status`/`doctor`) must never mutate trust state, so they
|
|
100
|
+
* use this pure reader instead of constructing a manager.
|
|
101
|
+
*/
|
|
102
|
+
export function readPersistedWorkspaceTrust(paths: WorkspaceTrustPaths): PersistedWorkspaceTrustView {
|
|
103
|
+
const file = paths.resolveProjectPath('tui', TRUST_FILE);
|
|
104
|
+
try {
|
|
105
|
+
if (!existsSync(file)) return { level: 'undecided', grandfathered: false };
|
|
106
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial<PersistedWorkspaceTrust>;
|
|
107
|
+
if (parsed.level === 'trusted' || parsed.level === 'restricted') {
|
|
108
|
+
return { level: parsed.level, grandfathered: parsed.grandfathered ?? false };
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Unreadable/corrupt — report undecided rather than crash the report.
|
|
112
|
+
}
|
|
113
|
+
return { level: 'undecided', grandfathered: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface WorkspaceTrustManagerOptions {
|
|
117
|
+
readonly shellPaths: WorkspaceTrustPaths;
|
|
118
|
+
/** Whether the workspace already had prior GoodVibes runtime state at startup. */
|
|
119
|
+
readonly hadPriorState: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class WorkspaceTrustManager {
|
|
123
|
+
private level: WorkspaceTrustLevel | null = null; // null = undecided (new place)
|
|
124
|
+
private grandfathered = false;
|
|
125
|
+
private loaded = false;
|
|
126
|
+
private readonly store: JsonFileStore<PersistedWorkspaceTrust>;
|
|
127
|
+
private readonly hadPriorState: boolean;
|
|
128
|
+
|
|
129
|
+
constructor(options: WorkspaceTrustManagerOptions) {
|
|
130
|
+
this.store = new JsonFileStore<PersistedWorkspaceTrust>(
|
|
131
|
+
options.shellPaths.resolveProjectPath('tui', TRUST_FILE),
|
|
132
|
+
);
|
|
133
|
+
this.hadPriorState = options.hadPriorState;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Load the persisted decision. If none exists, grandfather a workspace that
|
|
138
|
+
* already had prior GoodVibes state (persisting the decision so it is never
|
|
139
|
+
* re-evaluated); otherwise the workspace stays undecided and the gate treats
|
|
140
|
+
* it as restricted until the user chooses.
|
|
141
|
+
*/
|
|
142
|
+
async load(): Promise<void> {
|
|
143
|
+
if (this.loaded) return;
|
|
144
|
+
const persisted = await this.store.load().catch(() => null);
|
|
145
|
+
if (persisted && (persisted.level === 'trusted' || persisted.level === 'restricted')) {
|
|
146
|
+
this.level = persisted.level;
|
|
147
|
+
this.grandfathered = persisted.grandfathered ?? false;
|
|
148
|
+
this.loaded = true;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (this.hadPriorState) {
|
|
152
|
+
this.level = 'trusted';
|
|
153
|
+
this.grandfathered = true;
|
|
154
|
+
await this.persist();
|
|
155
|
+
}
|
|
156
|
+
this.loaded = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
isDecided(): boolean {
|
|
160
|
+
return this.level !== null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Undecided workspaces read as 'restricted' — the safe default before a choice. */
|
|
164
|
+
getLevel(): WorkspaceTrustLevel {
|
|
165
|
+
return this.level ?? 'restricted';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
isTrusted(): boolean {
|
|
169
|
+
return this.level === 'trusted';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
wasGrandfathered(): boolean {
|
|
173
|
+
return this.grandfathered;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async setLevel(level: WorkspaceTrustLevel): Promise<void> {
|
|
177
|
+
this.level = level;
|
|
178
|
+
this.grandfathered = false;
|
|
179
|
+
this.loaded = true;
|
|
180
|
+
await this.persist();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The restriction rule the permission machinery consults. Read tools always
|
|
185
|
+
* run (read-only exploration is the whole point of "restricted"); every other
|
|
186
|
+
* category (write / execute / delegate) is denied until the workspace is
|
|
187
|
+
* trusted.
|
|
188
|
+
*/
|
|
189
|
+
isCategoryAllowed(category: PermissionCategory): boolean {
|
|
190
|
+
if (this.isTrusted()) return true;
|
|
191
|
+
return category === 'read';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async persist(): Promise<void> {
|
|
195
|
+
if (this.level === null) return;
|
|
196
|
+
await this.store.save({
|
|
197
|
+
level: this.level,
|
|
198
|
+
decidedAt: new Date().toISOString(),
|
|
199
|
+
grandfathered: this.grandfathered,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -10,6 +10,7 @@ import { SessionUnionCache, type SessionReadFacade } from '@pellux/goodvibes-sdk
|
|
|
10
10
|
import type { ShellPathService } from '@/runtime/index.ts';
|
|
11
11
|
import type { HostServiceStatus } from '@/runtime/index.ts';
|
|
12
12
|
import type { SecretsManager } from '../config/secrets.ts';
|
|
13
|
+
import type { buildRelayExternalServiceMethods } from './relay-reachability-bridge.ts';
|
|
13
14
|
|
|
14
15
|
export interface UiEnvironmentServices {
|
|
15
16
|
readonly workingDirectory: string;
|
|
@@ -63,7 +64,7 @@ export interface UiPlatformServices {
|
|
|
63
64
|
readonly webhookNotifier: RuntimeServices['webhookNotifier'];
|
|
64
65
|
readonly focusTracker: RuntimeServices['focusTracker'];
|
|
65
66
|
readonly policyRuntimeState: RuntimeServices['policyRuntimeState'];
|
|
66
|
-
readonly externalServices?: {
|
|
67
|
+
readonly externalServices?: ReturnType<typeof buildRelayExternalServiceMethods> & {
|
|
67
68
|
inspect(): {
|
|
68
69
|
readonly daemonRunning: boolean;
|
|
69
70
|
readonly daemonPortInUse?: boolean;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for `/update`: version comparison, the latest-release-tag
|
|
3
|
+
* redirect lookup, and honest install-kind detection. No filesystem or
|
|
4
|
+
* process side effects here — everything takes its inputs as arguments so
|
|
5
|
+
* tests exercise it without touching the network or the host, matching the
|
|
6
|
+
* repo rule that tests never compare against the live build VERSION and
|
|
7
|
+
* never make live network calls.
|
|
8
|
+
*
|
|
9
|
+
* The self-update download/verify/swap orchestration that USES these lives
|
|
10
|
+
* in src/input/commands/update-runtime.ts; this module only decides "is
|
|
11
|
+
* there a newer version" and "can this install be swapped in place".
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export function normalizeVersion(version: string): string {
|
|
15
|
+
return version.replace(/^v/i, '').trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compares two version strings component-wise as dotted non-negative
|
|
20
|
+
* integers (a leading "v" is ignored, matching the "vX.Y.Z" release tag
|
|
21
|
+
* format). Returns -1/0/1 for a<b / a==b / a>b. Non-numeric or missing
|
|
22
|
+
* components are treated as 0, so "1.2" and "1.2.0" compare equal.
|
|
23
|
+
*/
|
|
24
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
|
|
25
|
+
const partsA = normalizeVersion(a).split('.');
|
|
26
|
+
const partsB = normalizeVersion(b).split('.');
|
|
27
|
+
const length = Math.max(partsA.length, partsB.length);
|
|
28
|
+
for (let i = 0; i < length; i++) {
|
|
29
|
+
const x = Number.parseInt(partsA[i] ?? '0', 10) || 0;
|
|
30
|
+
const y = Number.parseInt(partsB[i] ?? '0', 10) || 0;
|
|
31
|
+
if (x < y) return -1;
|
|
32
|
+
if (x > y) return 1;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extracts the release tag from a GitHub "/releases/latest" redirect
|
|
39
|
+
* Location header, e.g. "https://github.com/mgd34msu/goodvibes-tui/releases/tag/v1.13.2"
|
|
40
|
+
* -> "v1.13.2". Mirrors scripts/install.sh's own redirect-URL tag parsing
|
|
41
|
+
* (it takes everything after the final slash of the redirect target).
|
|
42
|
+
*/
|
|
43
|
+
export function parseReleaseTagFromLocation(location: string | null | undefined): string | null {
|
|
44
|
+
if (!location) return null;
|
|
45
|
+
const segments = location.split('/').filter((segment) => segment.length > 0);
|
|
46
|
+
const tag = segments[segments.length - 1];
|
|
47
|
+
return tag && tag.length > 0 ? tag : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Minimal shape of the subset of the fetch API this module needs, so tests inject a stub instead of the real network. */
|
|
51
|
+
export interface UpdateFetchLike {
|
|
52
|
+
(url: string, init?: { method?: string; redirect?: 'manual' | 'follow' | 'error' }): Promise<{
|
|
53
|
+
readonly ok: boolean;
|
|
54
|
+
readonly status: number;
|
|
55
|
+
readonly url: string;
|
|
56
|
+
readonly headers: { get(name: string): string | null };
|
|
57
|
+
text(): Promise<string>;
|
|
58
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolves the latest release tag the same way scripts/install.sh does:
|
|
64
|
+
* a HEAD request with redirects NOT followed, reading the tag out of the
|
|
65
|
+
* response's redirect Location header. Throws if no tag can be resolved —
|
|
66
|
+
* callers must not silently fall back to "already current".
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveLatestReleaseTag(fetchImpl: UpdateFetchLike, releasesLatestUrl: string): Promise<string> {
|
|
69
|
+
const response = await fetchImpl(releasesLatestUrl, { method: 'HEAD', redirect: 'manual' });
|
|
70
|
+
const location = response.headers.get('location');
|
|
71
|
+
const tag = parseReleaseTagFromLocation(location) ?? (response.url !== releasesLatestUrl ? parseReleaseTagFromLocation(response.url) : null);
|
|
72
|
+
if (!tag) {
|
|
73
|
+
throw new Error(`could not resolve the latest release tag from ${releasesLatestUrl} (no redirect Location header)`);
|
|
74
|
+
}
|
|
75
|
+
return tag;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How this running process was installed, detected honestly from
|
|
80
|
+
* process.execPath rather than assumed:
|
|
81
|
+
* - "binary": a standalone `bun build --compile` executable with no
|
|
82
|
+
* package-manager ancestry — the scripts/install.sh install path.
|
|
83
|
+
* Swappable in place.
|
|
84
|
+
* - "bun-global-package": running the vendored binary shipped inside an
|
|
85
|
+
* npm/bun-managed package install (execPath contains a "node_modules"
|
|
86
|
+
* path segment — true for both `bun add -g` and a local project
|
|
87
|
+
* dependency). Managed by the package manager; swapping the vendored
|
|
88
|
+
* file in place would fight the next `bun add -g` upgrade, so this is
|
|
89
|
+
* never swapped — the user re-runs their package manager instead.
|
|
90
|
+
* - "source": running directly via the `bun` interpreter (`bun run
|
|
91
|
+
* src/main.ts`), not a compiled binary at all.
|
|
92
|
+
*/
|
|
93
|
+
export type InstallKind = 'binary' | 'bun-global-package' | 'source';
|
|
94
|
+
|
|
95
|
+
export function detectInstallKind(execPath: string): InstallKind {
|
|
96
|
+
const segments = execPath.split(/[\\/]/);
|
|
97
|
+
const execName = (segments[segments.length - 1] ?? '').toLowerCase();
|
|
98
|
+
if (execName === 'bun' || execName === 'bun.exe') {
|
|
99
|
+
return 'source';
|
|
100
|
+
}
|
|
101
|
+
if (segments.includes('node_modules')) {
|
|
102
|
+
return 'bun-global-package';
|
|
103
|
+
}
|
|
104
|
+
return 'binary';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The exact command to tell the user to run instead of a swap, for each non-binary install kind. */
|
|
108
|
+
export function fallbackUpdateCommand(kind: Exclude<InstallKind, 'binary'>): string {
|
|
109
|
+
if (kind === 'bun-global-package') {
|
|
110
|
+
return 'bun add -g @pellux/goodvibes-tui';
|
|
111
|
+
}
|
|
112
|
+
return 'curl -fsSL https://goodvibes.sh/install.sh | sh';
|
|
113
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
|
|
2
|
+
import { withCheckpointGuardSettings } from '../config/tui-extension-settings.ts';
|
|
3
|
+
import type { ConfigManager } from '../config/index.ts';
|
|
4
|
+
import type { RuntimeEventBus } from '@/runtime/index.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* createWorkspaceCheckpointing — build the per-workspace checkpoint manager and
|
|
8
|
+
* kick off its initialization. Extracted from services.ts so the (load-bearing)
|
|
9
|
+
* failure-mode notes below live with the code they describe.
|
|
10
|
+
*/
|
|
11
|
+
export function createWorkspaceCheckpointing(opts: {
|
|
12
|
+
workspaceRoot: string;
|
|
13
|
+
runtimeBus: RuntimeEventBus;
|
|
14
|
+
configManager: ConfigManager;
|
|
15
|
+
}): WorkspaceCheckpointManager {
|
|
16
|
+
// Checkpoint root-guard from the user's `checkpoints.*` config (docs/configuration.md;
|
|
17
|
+
// inert until the platform SDK's checkpoint manager exposes the keys — see the reader's note).
|
|
18
|
+
const manager = new WorkspaceCheckpointManager(
|
|
19
|
+
withCheckpointGuardSettings({ workspaceRoot: opts.workspaceRoot, runtimeBus: opts.runtimeBus }, opts.configManager),
|
|
20
|
+
);
|
|
21
|
+
// Fire-and-forget: subscriptions go live immediately if init() succeeds.
|
|
22
|
+
// If it rejects, WorkspaceCheckpointManager caches that rejection on
|
|
23
|
+
// `initPromise` and never clears it, so every later call (create/list/
|
|
24
|
+
// diff/restore — each awaits init() first) re-throws the same error
|
|
25
|
+
// forever: checkpointing becomes entirely unavailable for the rest of the
|
|
26
|
+
// session, not "degraded to manual-only". The `.catch(() => {})` here only
|
|
27
|
+
// exists to prevent an unhandled rejection at startup; the checkpoint
|
|
28
|
+
// commands (checkpoint-runtime.ts) are what actually catch and report the
|
|
29
|
+
// failure to the user, on first use.
|
|
30
|
+
void manager.init().catch(() => {});
|
|
31
|
+
return manager;
|
|
32
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// workstream-attempts-validation.ts — plan-format validation for best-of-N.
|
|
3
|
+
//
|
|
4
|
+
// The plan format re-enables the per-item `attempts` field (SDK WorkItem/
|
|
5
|
+
// WorkItemSpec.attempts — best-of-N). A best-of-N item MAY be non-leaf: it may
|
|
6
|
+
// declare its own `dependsOn` (each sibling inherits it) and other items may
|
|
7
|
+
// depend on it — a dependent is held by the dependency gate until the group's
|
|
8
|
+
// winner is picked and merged, then resolves to that winner (SDK
|
|
9
|
+
// WorkItemSpec.attempts doc). The TUI enforces the engine's genuine constraints
|
|
10
|
+
// at plan validation rather than letting a launch silently drop the field:
|
|
11
|
+
// • WORKTREE isolation. Best-of-N needs isolated worktrees to run and compare
|
|
12
|
+
// the N sibling attempts; under `shared` isolation the value is ignored.
|
|
13
|
+
// • STABLE id when depended upon. A dependency edge references an id, so a
|
|
14
|
+
// best-of-N item that others depend on must carry a stable `id`; an
|
|
15
|
+
// anonymous best-of-N item stays a leaf by construction (nothing can name it).
|
|
16
|
+
// Values above the engine's MAX_ATTEMPTS (5) are clamped by the engine; the TUI
|
|
17
|
+
// surfaces that as a note so the preview never overstates the fan-out.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
import type { CreateWorkstreamInput } from '@pellux/goodvibes-sdk/platform/orchestration';
|
|
21
|
+
|
|
22
|
+
/** The engine's hard cap on sibling attempts per work item (SDK MAX_ATTEMPTS). */
|
|
23
|
+
export const MAX_ATTEMPTS = 5;
|
|
24
|
+
|
|
25
|
+
export interface AttemptsValidation {
|
|
26
|
+
/** True when any item requests best-of-N (attempts >= 2). */
|
|
27
|
+
readonly hasAttempts: boolean;
|
|
28
|
+
/** Hard rule breaks that must block a launch (worktree isolation + stable id when depended upon). */
|
|
29
|
+
readonly violations: readonly string[];
|
|
30
|
+
/** Non-blocking notes (e.g. an over-cap value the engine will clamp). */
|
|
31
|
+
readonly notes: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function itemKey(item: CreateWorkstreamInput['items'][number]): string {
|
|
35
|
+
return item.id ?? item.title;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate a draft spec's best-of-N usage against the worktree-isolation and
|
|
40
|
+
* stable-id (when depended upon) constraints. Non-leaf best-of-N is allowed.
|
|
41
|
+
* Pure — no I/O. `violations` are hard (block launch); `notes` are advisory.
|
|
42
|
+
*/
|
|
43
|
+
export function validateAttempts(spec: CreateWorkstreamInput): AttemptsValidation {
|
|
44
|
+
const violations: string[] = [];
|
|
45
|
+
const notes: string[] = [];
|
|
46
|
+
const worktree = spec.isolation === 'worktree';
|
|
47
|
+
|
|
48
|
+
// Which items are depended upon (so a best-of-N item that is a dependency
|
|
49
|
+
// target can be checked for the stable-id requirement).
|
|
50
|
+
const dependedUpon = new Set<string>();
|
|
51
|
+
for (const item of spec.items) {
|
|
52
|
+
for (const dep of item.dependsOn ?? []) dependedUpon.add(dep);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let hasAttempts = false;
|
|
56
|
+
for (const item of spec.items) {
|
|
57
|
+
const attempts = item.attempts ?? 1;
|
|
58
|
+
if (attempts < 2) continue;
|
|
59
|
+
hasAttempts = true;
|
|
60
|
+
const label = item.title;
|
|
61
|
+
|
|
62
|
+
if (!worktree) {
|
|
63
|
+
violations.push(`"${label}" requests ${attempts} attempts, but the workstream is not worktree-isolated — best-of-N needs isolated trees. Set --isolation worktree.`);
|
|
64
|
+
}
|
|
65
|
+
// Non-leaf best-of-N is allowed. The only structural requirement: if other
|
|
66
|
+
// items depend on this one, it must carry a stable id for the dependency
|
|
67
|
+
// edge to name — an anonymous best-of-N item cannot be a dependency target.
|
|
68
|
+
if (dependedUpon.has(itemKey(item)) && !item.id) {
|
|
69
|
+
violations.push(`another item depends on best-of-N item "${label}", but it has no stable id — give the item an id so the dependency can resolve to the winner (an anonymous best-of-N item cannot be a dependency target).`);
|
|
70
|
+
}
|
|
71
|
+
if (attempts > MAX_ATTEMPTS) {
|
|
72
|
+
notes.push(`"${label}" requests ${attempts} attempts — the engine caps best-of-N at ${MAX_ATTEMPTS}, so ${MAX_ATTEMPTS} siblings will run.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { hasAttempts, violations, notes };
|
|
77
|
+
}
|