docs: plan first alpha product UX

This commit is contained in:
mirivlad 2026-07-12 16:57:23 +08:00
parent 139b2a67b7
commit a7563b6016
5 changed files with 1108 additions and 0 deletions

View File

@ -0,0 +1,240 @@
# Alpha Activity And Journal Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
**Goal:** Make Activity a durable, background-maintained session log that safely proposes Journal entries without creating a Дело automatically.
**Architecture:** Desktop adds a plugin background-service lifecycle, unsubscribe-capable event bridge, and append-only Activity log. The Activity plugin owns session reconstruction and watermarks; Journal receives an explicit reviewed candidate and requires a destination for unassigned sessions.
**Tech Stack:** Go, Wails, Svelte, plain JavaScript plugin bundles, Go tests, Node smoke tests, Playwright.
## Global Constraints
- Activity raw events are retained for 60 days, 10,000 events, or 8 MiB, whichever is reached first.
- Raw activity uses append/compaction, not settings.json rewrites.
- Session scope is workspaceId plus path cache or unassigned.
- Point-event duration is capped at 10 minutes per adjacent pair; gap over 20 minutes starts another session.
- Accepted/dismissed watermarks consume only the reviewed slice.
- Journal save is always an explicit user action.
---
### Task 1: Add background services, event unsubscription, and an append-only Activity log
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/plugin/plugin.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/plugin/plugin_test.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/events/bus.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app_test.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/storage/api.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/storage/api_test.go
- Create: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/plugin-host/BackgroundPluginHost.svelte
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/App.svelte
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/plugin-host/VerstakPluginAPI.js
- Modify: /home/mirivlad/git/verstak2/verstak-sdk/src/types.ts
- Modify: /home/mirivlad/git/verstak2/verstak-sdk/schemas/contributions.json
- Modify: /home/mirivlad/git/verstak2/verstak-sdk/schemas/manifest.json
- Modify: /home/mirivlad/git/verstak2/verstak-sdk/src/plugin-api.test.ts
**Interfaces:**
- Adds contributes.backgroundServices with id and component fields.
- Bus Subscribe returns subscription ID; Unsubscribe removes only that handler.
- Adds ActivityLogAppend, ActivityLogRead, ActivityLogDeleteWorkspace, and ActivityLogCompact backend methods.
- Background bundle registration exposes start(api) returning a cleanup function.
- [ ] **Step 1: Write failing manifest, bus, and storage tests**
Add a manifest validation test and isolated bus-unsubscribe test:
~~~go
subA := bus.Subscribe("browser.activity.domain", handlerA)
subB := bus.Subscribe("browser.activity.domain", handlerB)
bus.Unsubscribe("browser.activity.domain", subA)
bus.Publish(events.Event{Name: "browser.activity.domain"})
if gotA != 0 || gotB != 1 { t.Fatalf("wrong selective unsubscribe: %d %d", gotA, gotB) }
~~~
Add an append test asserting two appends create two NDJSON records and a
compaction test keeping only the newest record under a small test limit.
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-desktop
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/plugin ./internal/core/events ./internal/core/storage ./internal/api -run 'Test.*(Background|Unsubscribe|ActivityLog)' -count=1
~~~
Expected: FAIL because subscriptions have no IDs and the log API does not exist.
- [ ] **Step 3: Implement the platform boundary**
Define:
~~~go
type ContributionBackgroundService struct {
ID string `json:"id"`
Component string `json:"component"`
}
type ActivityLogRecord struct {
ActivityID string `json:"activityId"`
Payload json.RawMessage `json:"payload"`
}
~~~
Append NDJSON under plugin-data/verstak.activity/events.ndjson using a locked
append. Compact to the declared retention limits by rewriting a temporary file.
Keep only compact candidate state in plugin data. BackgroundPluginHost loads
enabled plugins with a background contribution once at app startup and calls the
registered start function; it calls cleanup on disable/reload/destroy.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/plugin ./internal/core/events ./internal/core/storage ./internal/api -count=1
git add internal/core/plugin internal/core/events internal/core/storage internal/api frontend/src/App.svelte frontend/src/lib/plugin-host
git commit -m "feat: add background activity runtime"
git -C /home/mirivlad/git/verstak2/verstak-sdk add src/types.ts schemas/contributions.json schemas/manifest.json src/plugin-api.test.ts
git -C /home/mirivlad/git/verstak2/verstak-sdk commit -m "feat: define plugin background services"
~~~
Expected: focused tests prove one subscription, selective cleanup, append, and compaction.
### Task 2: Rebuild Activity sessions and persistent candidate watermarks
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/activity/plugin.json
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/activity/frontend/src/index.js
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/activity/locales/en.json
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/activity/locales/ru.json
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/scripts/smoke-activity-plugin.js
**Interfaces:**
- Activity background service registers ActivityService.start(api).
- Event record includes activityId, scope, occurredAt, durationSeconds, and sessionId.
- Candidate contains sessionId, handledThrough, estimatedMinutes, dateSlices, and source activities.
- [ ] **Step 1: Write failing smoke scenarios**
Add three explicit scenarios:
~~~js
const nineMinutes = eventsAt('2026-07-12T10:00:00Z', '2026-07-12T10:09:00Z');
assert.equal(buildSessions(nineMinutes)[0].estimatedMinutes, 9);
const late = appendLateEvent(existingSession, eventAt('2026-07-12T09:58:00Z'));
assert.equal(late.sessionId, existingSession.sessionId);
assert.equal(candidateAfterDismiss.sourceActivityIds.includes('a'), false);
~~~
Also assert a 23:50 to 00:30 session has one session ID and date slices for both
local dates.
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-official-plugins
node scripts/smoke-activity-plugin.js
~~~
Expected: FAIL because Activity derives candidate IDs from first and last events.
- [ ] **Step 3: Implement background session state**
Replace view-mounted event recording with ActivityService.start(api). Persist
immutable generated session IDs and anchors in activity-state.json. Build scope
as either workspaceId plus root path or unassigned. Sum explicit browser
duration and only zero-duration adjacent point intervals, cap a point interval
at 10 minutes, split at 20 minutes, and cap a session at 120 minutes.
For accept or dismiss, store the ordered handledThrough watermark. New events
after the watermark require another 10 minutes before a new candidate appears;
late events at or before the watermark remain diagnostic only.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
node scripts/smoke-activity-plugin.js
./scripts/check.sh
git add plugins/activity scripts/smoke-activity-plugin.js
git commit -m "feat: persist activity sessions and watermarks"
~~~
Expected: session, late-event, dismissal, browser-duration, and midnight smoke tests pass.
### Task 3: Review candidates safely in Journal and expose sessions in UI
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/activity/frontend/src/index.js
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/journal/frontend/src/index.js
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/journal/locales/en.json
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/journal/locales/ru.json
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/scripts/smoke-journal-plugin.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/e2e/activity.spec.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/e2e/todo.spec.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/test/wails-mock.js
**Interfaces:**
- Journal candidate request accepts destinationWorkspaceId only for unassigned scope.
- Saved Journal entry stores sourceSessionId and handledThrough in addition to activity IDs.
- Activity UI has Sessions default and Events diagnostic view.
- [ ] **Step 1: Write failing E2E and smoke assertions**
Require an unassigned candidate to show a destination selector and reject Save
until an active workspace ID is selected:
~~~js
await page.getByRole('button', { name: /review/i }).click();
await expect(page.getByText(/choose.*дело/i)).toBeVisible();
await expect(page.getByRole('button', { name: /save/i })).toBeDisabled();
~~~
Require the mock WritePluginSetting path to retain sourceSessionId after reload.
Require Clear Activity and Journal Delete to show cancellation-safe confirmation
before changing stored rows.
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-desktop/frontend
npm run test:e2e -- activity.spec.js todo.spec.js
~~~
Expected: FAIL because the current mock discards settings and Journal only accepts path-scoped candidates.
- [ ] **Step 3: Implement review UI and local dates**
Render domain rows as hostname plus duration without URLs. Keep raw event
details in the Events tab. For unassigned scope, list active workspace IDs and
require a selection before handing the candidate to Journal. Preselect the
largest date slice, show both date slices, use local date conversion, and save
the session watermark with the Journal entry. Show a visible action if Journal
is disabled instead of silently dropping the request. Put a confirm/cancel
dialog in front of Clear Activity and Journal Delete; cancelling leaves the
append log, candidate state, and entry list untouched.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-official-plugins && node scripts/smoke-activity-plugin.js && node scripts/smoke-journal-plugin.js
cd /home/mirivlad/git/verstak2/verstak-desktop/frontend && npm run test:e2e -- activity.spec.js todo.spec.js
git -C /home/mirivlad/git/verstak2/verstak-official-plugins add plugins/activity plugins/journal scripts/smoke-activity-plugin.js scripts/smoke-journal-plugin.js
git -C /home/mirivlad/git/verstak2/verstak-official-plugins commit -m "feat: review activity sessions in journal"
git -C /home/mirivlad/git/verstak2/verstak-desktop add frontend/e2e/activity.spec.js frontend/e2e/todo.spec.js frontend/src/lib/test/wails-mock.js
git -C /home/mirivlad/git/verstak2/verstak-desktop commit -m "test: cover activity journal review flow"
~~~
Expected: candidate-to-Journal, local date, and persisted mock workflows pass.

View File

@ -0,0 +1,216 @@
# Alpha Browser Domain Activity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
**Goal:** Add explicitly consented, privacy-minimal active-tab domain timing to the browser extension and deliver immutable activity batches to Desktop.
**Architecture:** Manual captures remain unchanged. A new tracker stores mutable accumulators, immutable pending batches, and acknowledged IDs locally; Desktop accepts a separate authenticated activity endpoint and emits browser.activity.domain.
**Tech Stack:** Plain WebExtension JavaScript, Node tests, Go HTTP handler tests, shared JSON vectors.
## Global Constraints
- Passive tracking defaults to disabled and requires explicit informed consent.
- Track only normalized hostname plus duration for an active tab in a focused window.
- Never send URL, title, text, page content, keystrokes, or history.
- A pending batch is byte-for-byte immutable; acknowledge only its own ID.
- Discard ambiguous or negative clock deltas and gaps over 10 minutes.
- Use hostname-normalization-v1 for extension, Desktop receiver, exclusions, and bindings.
---
### Task 1: Define and test canonical hostname normalization
**Files:**
- Create: /home/mirivlad/git/verstak2/verstak-sdk/schemas/hostname-normalization-v1.json
- Create: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/hostname.js
- Create: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/hostname-normalization-v1.json
- Create: /home/mirivlad/git/verstak2/verstak-browser-extension/scripts/test-hostname.js
- Create: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/hostname/normalize.go
- Create: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/hostname/normalize_test.go
- Create: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/hostname/testdata/hostname-normalization-v1.json
**Interfaces:**
- Produces JavaScript normalizeHostnameV1(value, mode).
- Produces Go hostname.NormalizeV1(value string, mode Mode) (string, error).
- Mode is URLSource for HTTP(S) URLs and BareHost for binding/exclusion input.
- [ ] **Step 1: Write the shared vector corpus and failing tests**
Include vectors for Unicode/punycode, trailing dot, port stripping/rejection,
IPv4, bracketed IPv6, localhost, one-label internal names, malformed labels,
and an overlong name:
~~~json
[
{"mode":"bare","input":"пример.рф.","want":"xn--e1afmkfd.xn--p1ai"},
{"mode":"url","input":"https://Example.COM:8443/a","want":"example.com"},
{"mode":"bare","input":"example.com:8443","error":"port"},
{"mode":"bare","input":"[::1]","want":"::1"}
]
~~~
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-browser-extension
node scripts/test-hostname.js
cd /home/mirivlad/git/verstak2/verstak-desktop
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/hostname -count=1
~~~
Expected: both fail because the normalizers do not exist.
- [ ] **Step 3: Implement both normalizers**
Use the browser URL parser plus ASCII IDNA conversion in JavaScript. Use
net/url, net/netip, and golang.org/x/net/idna.Lookup.ToASCII in Go. Store
ASCII A-labels, lowercase names, no ports or IPv6 brackets, and reject malformed
or over-limit values.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-browser-extension && npm test
cd /home/mirivlad/git/verstak2/verstak-desktop && GOCACHE=/tmp/verstak-go-cache go test ./internal/core/hostname -count=1
cmp /home/mirivlad/git/verstak2/verstak-sdk/schemas/hostname-normalization-v1.json /home/mirivlad/git/verstak2/verstak-browser-extension/shared/hostname-normalization-v1.json
cmp /home/mirivlad/git/verstak2/verstak-sdk/schemas/hostname-normalization-v1.json /home/mirivlad/git/verstak2/verstak-desktop/internal/core/hostname/testdata/hostname-normalization-v1.json
git -C /home/mirivlad/git/verstak2/verstak-sdk add schemas/hostname-normalization-v1.json
git -C /home/mirivlad/git/verstak2/verstak-sdk commit -m "feat: define hostname normalization vectors"
git -C /home/mirivlad/git/verstak2/verstak-browser-extension add shared/hostname.js shared/hostname-normalization-v1.json scripts/test-hostname.js
git -C /home/mirivlad/git/verstak2/verstak-browser-extension commit -m "feat: normalize activity hostnames"
git -C /home/mirivlad/git/verstak2/verstak-desktop add internal/core/hostname
git -C /home/mirivlad/git/verstak2/verstak-desktop commit -m "feat: normalize browser hostnames"
~~~
Expected: every copied corpus passes in both implementations.
### Task 2: Add a separate authenticated Desktop activity receiver
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/browserreceiver/receiver.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/browserreceiver/receiver_test.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app_test.go
**Interfaces:**
- Accepts POST /activities with BrowserDomainActivity.
- Emits browser.activity.domain with normalized hostname, duration, ID, and optional workspaceId.
- Returns status accepted and activityId for first delivery and duplicate delivery.
- [ ] **Step 1: Write failing handler tests**
Create a valid immutable request and then retry the same ID:
~~~go
payload := "{\"schemaVersion\":1,\"activityId\":\"a-1\",\"hostname\":\"пример.рф\",\"durationSeconds\":300,\"startedAt\":\"2026-07-12T10:00:00Z\",\"endedAt\":\"2026-07-12T10:05:00Z\"}"
req := httptest.NewRequest(http.MethodPost, "/activities", strings.NewReader(payload))
req.Header.Set("X-Verstak-Receiver-Token", "pair-token")
~~~
Assert invalid tokens, URL-like hostnames, zero or oversize duration, and a
duplicate ID do not publish a second event.
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-desktop
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/browserreceiver -run Activity -count=1
~~~
Expected: FAIL because /activities is not routed.
- [ ] **Step 3: Implement the bounded idempotent route**
Add BrowserDomainActivity, a 30-day bounded receiver ID cache, and
handleActivity which invokes hostname.NormalizeV1. Do not reuse capture types
or persistence. In App, resolve an active exact hostname binding to workspaceId
and a path cache before Activity receives the event.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/browserreceiver ./internal/api -count=1
git add internal/core/browserreceiver/receiver.go internal/core/browserreceiver/receiver_test.go internal/api/app.go internal/api/app_test.go
git commit -m "feat: receive browser domain activity"
~~~
Expected: authentication, normalization, idempotency, and binding tests pass.
### Task 3: Build the opt-in extension tracker and immutable delivery queue
**Files:**
- Create: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/activity-tracker.js
- Create: /home/mirivlad/git/verstak2/verstak-browser-extension/scripts/test-activity-tracker.js
- Modify: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/api.js
- Modify: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/background.js
- Modify: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/popup/popup.html
- Modify: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/popup/popup.js
- Modify: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/popup/popup.css
- Modify: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/locales/en.json
- Modify: /home/mirivlad/git/verstak2/verstak-browser-extension/shared/locales/ru.json
- Modify: /home/mirivlad/git/verstak2/verstak-browser-extension/chromium/manifest.json
- Modify: /home/mirivlad/git/verstak2/verstak-browser-extension/firefox/manifest.json
**Interfaces:**
- Persists activeAccumulator, pendingBatches, and acknowledgedIds under verstak.activityTracker.
- Produces sendActivity(receiverUrl, token, immutablePayload).
- Settings include passiveActivityEnabled false and excludedDomains empty.
- [ ] **Step 1: Write failing state-machine tests**
Cover consent, active-tab-only timing, long-gap discard, and the A/B batch case:
~~~js
tracker.addElapsed('example.com', 600, start, end);
const batchA = tracker.freeze('example.com');
tracker.addElapsed('example.com', 300, laterStart, laterEnd);
assert.equal(tracker.pendingBatches[0].payload.durationSeconds, 600);
tracker.acknowledge(batchA.activityId);
assert.equal(tracker.activeAccumulator['example.com'].durationSeconds, 300);
~~~
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-browser-extension
node scripts/test-activity-tracker.js
~~~
Expected: FAIL because the state machine is absent.
- [ ] **Step 3: Implement tracker, disclosure, and listeners**
Implement the tracker as pure functions. In background.js, register
tabs.onActivated, tabs.onUpdated, windows.onFocusChanged, idle.onStateChanged,
and a five-minute alarm only while opted in. Set idle detection to 600 seconds.
On a gap over 600 seconds, clock rollback, lock, or browser startup, reset the
active checkpoint without adding time. Retry frozen batches oldest-first and
remove only matching IDs.
Show an unchecked Russian/English consent explanation in settings. Add windows,
alarms, and idle permissions to both manifests. Keep manual capture queue and
popup actions unchanged.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
npm test
node scripts/test-activity-tracker.js
git add shared chromium/manifest.json firefox/manifest.json scripts/test-activity-tracker.js
git commit -m "feat: add opt-in browser domain tracker"
~~~
Expected: tracker unit tests and existing extension tests pass.

View File

@ -0,0 +1,229 @@
# Alpha Browser Inbox Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
**Goal:** Preserve a manually captured link when it leaves the global Inbox, give Browser Inbox a usable archive, and make links durable across case lifecycle changes.
**Architecture:** A capture remains one canonical record. Its global archive state is independent of its workspaceId relation; domain bindings use the same stable identity. Browser Inbox owns capture UI and migration, while Desktop exposes a narrow URL-open capability for user-initiated links.
**Tech Stack:** Plain JavaScript plugin bundle, Go Wails API, Node smoke tests, Playwright.
## Global Constraints
- Only manual extension actions create Browser Inbox captures.
- Captures have at most one optional case assignment in this alpha.
- Remove from global Inbox archives; only Delete everywhere is permanent.
- Assignment and binding use workspaceId, never path as identity.
- Create .url files through files.write and never overwrite a name silently.
- URL opening is direct HTTP(S) opening, not a Linux .url association.
---
### Task 1: Migrate Browser Inbox records and implement archive state
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/browser-inbox/frontend/src/index.js
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/browser-inbox/locales/en.json
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/browser-inbox/locales/ru.json
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/scripts/smoke-browser-inbox-plugin.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app_test.go
**Interfaces:**
- Canonical capture fields: globalState, workspaceId, workspaceState, workspaceRootPath, workspaceTrashId.
- Domain binding value: workspaceId plus workspaceRootPath cache plus state.
- Mutations: archive, restore, assign, unassign, deleteEverywhere, migrate.
- [ ] **Step 1: Write failing migration and archive smoke tests**
Use a legacy path assignment and assert it resolves to a stable ID. Test the
original data-loss regression:
~~~js
await inbox.assignWorkspace('capture-1', activeWorkspace);
await inbox.archiveCapture('capture-1');
assert.equal(globalRows().some(row => row.captureId === 'capture-1'), false);
assert.equal(workspaceRows(activeWorkspace.workspaceId).some(row => row.captureId === 'capture-1'), true);
await inbox.restoreCapture('capture-1');
assert.equal(globalRows().some(row => row.captureId === 'capture-1'), true);
~~~
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-official-plugins
node scripts/smoke-browser-inbox-plugin.js
~~~
Expected: FAIL because archive and assignment are currently one mutable path field.
- [ ] **Step 3: Implement canonical state and idempotent migration**
Normalize every capture on read. Set legacy captures globalState active. Resolve
legacy paths through Desktop identities: active marker gives workspaceId,
otherwise set workspaceState unavailable. Store global captures once; workspace
views filter the canonical collection by workspaceId rather than duplicate
storage keys. Reject permanent deletion without an explicit confirm token from
the UI.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
node scripts/smoke-browser-inbox-plugin.js
./scripts/check.sh
git -C /home/mirivlad/git/verstak2/verstak-official-plugins add plugins/browser-inbox scripts/smoke-browser-inbox-plugin.js
git -C /home/mirivlad/git/verstak2/verstak-official-plugins commit -m "feat: preserve archived browser inbox captures"
git -C /home/mirivlad/git/verstak2/verstak-desktop add internal/api/app.go internal/api/app_test.go
git -C /home/mirivlad/git/verstak2/verstak-desktop commit -m "feat: migrate browser inbox relations"
~~~
Expected: migration, archive, restore, and assignment tests pass.
### Task 2: Handle rename, Trash, restore, purge, and archive UI
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/browser-inbox/frontend/src/index.js
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/scripts/smoke-browser-inbox-plugin.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/e2e/browser-inbox.spec.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/test/wails-mock.js
**Interfaces:**
- Receives workspace.renamed, workspace.trashed, workspace.restored, and workspace.purged.
- Status filter values are active, archive, all.
- Archive bulk action is restore; bulk permanent delete is not offered.
- [ ] **Step 1: Write failing lifecycle and UI tests**
Assert a rename changes only path cache, trash disables routing, restore restores
the same workspace ID, purge makes captures unassigned and bindings orphaned:
~~~js
await emitWorkspaceEvent('workspace.trashed', { workspaceId: 'w-1', trashId: 't-1' });
assert.equal(capture.workspaceState, 'trashed');
assert.equal(domainBinding('client.example').state, 'trashed');
await emitWorkspaceEvent('workspace.purged', { workspaceId: 'w-1', trashId: 't-1' });
assert.equal(capture.workspaceState, 'unassigned');
assert.equal(domainBinding('client.example').state, 'orphaned');
~~~
In Playwright, select Archive, restore one visible item, and bulk-restore only
the filtered archive rows. Also click Delete everywhere for an assigned capture,
assert the dialog names its assigned Дело, cancel, and assert that the global
and assigned rows still exist.
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-desktop/frontend
npm run test:e2e -- browser-inbox.spec.js
~~~
Expected: FAIL because the current UI has only processed filters and destructive Clear.
- [ ] **Step 3: Implement lifecycle and labels**
Subscribe once from the Browser Inbox background service. Update path caches on
rename only when workspaceId matches. On external unavailable, do not route to
the same path again. Add Active, Archive, and All filters; make search apply
inside the selected filter; show Archive badge inside a case; add Restore to
Inbox and visible filtered bulk restore. Rename Clear to an archive action with
count confirmation. Keep Delete everywhere separate and require a dialog that
names the assigned Дело; cancellation must perform no mutation.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-official-plugins && node scripts/smoke-browser-inbox-plugin.js
cd /home/mirivlad/git/verstak2/verstak-desktop/frontend && npm run test:e2e -- browser-inbox.spec.js
git -C /home/mirivlad/git/verstak2/verstak-official-plugins add plugins/browser-inbox scripts/smoke-browser-inbox-plugin.js
git -C /home/mirivlad/git/verstak2/verstak-official-plugins commit -m "feat: add browser inbox archive lifecycle"
git -C /home/mirivlad/git/verstak2/verstak-desktop add frontend/e2e/browser-inbox.spec.js frontend/src/lib/test/wails-mock.js
git -C /home/mirivlad/git/verstak2/verstak-desktop commit -m "test: cover browser inbox archive lifecycle"
~~~
Expected: archive filters, restore, rename, Trash, restore, and purge tests pass.
### Task 3: Save and open durable links without file-association reliance
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/permissions/registry.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app_test.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/plugin-host/VerstakPluginAPI.js
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/browser-inbox/plugin.json
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/plugins/browser-inbox/frontend/src/index.js
- Modify: /home/mirivlad/git/verstak2/verstak-official-plugins/scripts/smoke-browser-inbox-plugin.js
**Interfaces:**
- Permission and API: urls.openExternal(url string) for user-initiated HTTP(S) URLs only.
- SaveLinkInCase(captureId, workspaceId, filename) creates Links/name.url with InternetShortcut URL field.
- Collision response offers a proposed unique filename and never overwrites.
- [ ] **Step 1: Write failing API and plugin tests**
Add tests for invalid URL, readonly failure, unique filename, collision, and
direct opener argument:
~~~go
if errStr := app.OpenExternalURL("browser.plugin", "https://example.test/a"); errStr != "" {
t.Fatal(errStr)
}
if got := opener.Arguments[0]; got != "https://example.test/a" {
t.Fatalf("opened %q, want URL not .url path", got)
}
~~~
Add plugin smoke assertions for a title-derived name, Link fallback, and
proposal Link (2).url after a collision.
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-desktop
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run 'Test.*OpenExternalURL' -count=1
cd /home/mirivlad/git/verstak2/verstak-official-plugins
node scripts/smoke-browser-inbox-plugin.js
~~~
Expected: FAIL because only file-path external opening exists.
- [ ] **Step 3: Implement safe save and open**
Register urls.openExternal as dangerous. Validate HTTP(S), invoke the existing
OS opener with the URL string, and expose api.urls.openExternal. Browser Inbox
requests that permission, creates Links through files.createFolder and
files.writeText using:
~~~text
[InternetShortcut]
URL=https://example.test/
~~~
Sanitize a 96-character filename stem. On collision show an editable dialog
with proposed name (2).url and Cancel; leave capture state unchanged on errors.
When opening a saved link, parse and validate URL= before invoking the URL API.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-desktop && GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
cd /home/mirivlad/git/verstak2/verstak-official-plugins && node scripts/smoke-browser-inbox-plugin.js
git -C /home/mirivlad/git/verstak2/verstak-desktop add internal/core/permissions internal/api frontend/src/lib/plugin-host
git -C /home/mirivlad/git/verstak2/verstak-desktop commit -m "feat: open external URLs safely"
git -C /home/mirivlad/git/verstak2/verstak-official-plugins add plugins/browser-inbox scripts/smoke-browser-inbox-plugin.js
git -C /home/mirivlad/git/verstak2/verstak-official-plugins commit -m "feat: save browser inbox links safely"
~~~
Expected: URL API tests and link save/collision smoke tests pass.

View File

@ -0,0 +1,207 @@
# Alpha Shell UX Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
**Goal:** Make the Desktop overview scoped and actionable, hide diagnostic plugin IDs by default, and remove alpha UI strings that expose implementation noise.
**Architecture:** The shell consumes stable workspace IDs and plugin capabilities rather than hard-coded namespaces. Overview builds deterministic case-scoped projections; debug display is a presentation preference plus a session-only command-line override.
**Tech Stack:** Svelte, Wails app settings, Go tests, Playwright E2E, existing i18n catalogs.
## Global Constraints
- Default UI labels are Russian and use Дела, Входящие, Активности, Журнал.
- A normal tab never shows a plugin ID.
- Settings Debug Show plugin IDs defaults false; Desktop --debug enables it only for that run.
- Overview never leaks an unscoped event into every case.
- Todo entries are included only if todo.workspace capability is loaded.
- No action opens or creates a Дело implicitly.
---
### Task 1: Add effective debug display setting and human plugin tab labels
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/main.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/appsettings/manager.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/appsettings/manager_test.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app_test.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/shell/WorkspaceHost.svelte
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/plugin-manager/PluginManager.svelte
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/i18n/catalogs/en.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/i18n/catalogs/ru.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/e2e/plugin-api-bridge.spec.js
**Interfaces:**
- Persisted app setting showPluginIds bool defaults false.
- GetAppSettings returns effectiveShowPluginIds and UpdateAppSettings accepts showPluginIds.
- Runtime --debug sets effectiveShowPluginIds true without persisting it.
- [ ] **Step 1: Write failing app-setting and E2E tests**
Add a Go test proving the setting preserves theme and language, plus a browser
test proving normal mode has no raw ID and debug mode has one:
~~~js
await expect(page.locator('[role="tab"]').filter({ hasText: 'verstak.' })).toHaveCount(0);
await mockAppSettings({ showPluginIds: true });
await expect(page.locator('[role="tab"]').filter({ hasText: 'verstak.default-editor' })).toHaveCount(1);
~~~
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-desktop
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/appsettings ./internal/api -run 'Test.*PluginIds' -count=1
cd frontend && npm run test:e2e -- plugin-api-bridge.spec.js
~~~
Expected: FAIL because no display preference exists.
- [ ] **Step 3: Implement presentation-only debug state**
Parse --debug before Wails startup and keep it in App runtime state. Merge it
with the persisted bool when returning effective settings. Render the localized
manifest title normally; append the plugin ID only when effectiveShowPluginIds
is true. Add the Debug settings checkbox and localized explanatory copy.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/appsettings ./internal/api -count=1
cd frontend && npm run test:e2e -- plugin-api-bridge.spec.js
git add main.go internal/core/appsettings internal/api frontend/src frontend/e2e/plugin-api-bridge.spec.js
git commit -m "feat: hide plugin IDs outside debug mode"
~~~
Expected: normal and debug labels are both verified.
### Task 2: Implement deterministic case-scoped Overview
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/shell/TodaySurface.svelte
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/shell/WorkspaceHost.svelte
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/i18n/catalogs/en.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/i18n/catalogs/ru.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/e2e/ux-today.spec.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/e2e/ux-p0.spec.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/test/wails-mock.js
**Interfaces:**
- Every overview row has workspaceId and lastMeaningfulAt.
- Needs attention maximum is five; Continue work maximum is four; Recent changes maximum is eight.
- Hidden recommendation state is keyed by entity ID and invalidated by later meaningful change.
- [ ] **Step 1: Write failing scope and ranking tests**
Add fixture events for two workspace IDs plus an unscoped event:
~~~js
await seedOverview({
selectedWorkspaceId: 'client-a',
events: [
{ activityId: 'a', workspaceId: 'client-a', type: 'note.saved', occurredAt: now },
{ activityId: 'b', workspaceId: 'client-b', type: 'file.changed', occurredAt: now },
{ activityId: 'global', type: 'workspace.selected', occurredAt: now }
]
});
await expect(page.getByText(/client-b/i)).toHaveCount(0);
await expect(page.locator('[data-overview-section="continue"] [data-overview-item]')).toHaveCount(4);
~~~
Also assert no selection shows only the select/create prompt and up to five
unassigned active Inbox captures.
- [ ] **Step 2: Run red**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-desktop/frontend
npm run test:e2e -- ux-today.spec.js ux-p0.spec.js
~~~
Expected: FAIL because rowsFor currently accepts untagged events for every workspace.
- [ ] **Step 3: Implement exact overview projections**
Replace path-only filtering with workspaceId equality. Build:
1. Needs attention: ready Journal candidates, active unprocessed captures, then
urgent Todo rows only if todo.workspace capability exists; take five.
2. Continue work: distinct entities updated in 14 days; sort by
lastMeaningfulAt descending and the documented type tie-break; take four.
3. Recent changes: note save/create, file create/rename or last change per
file, saved link, and Journal create in seven days; take eight.
Persist Hide recommendation without changing underlying data. Use localized
empty states and remove English technical strings.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
npm run test:e2e -- ux-today.spec.js ux-p0.spec.js
git add src/lib/shell/TodaySurface.svelte src/lib/shell/WorkspaceHost.svelte src/lib/i18n frontend/e2e/ux-today.spec.js frontend/e2e/ux-p0.spec.js src/lib/test/wails-mock.js
git commit -m "feat: make overview case-scoped and actionable"
~~~
Expected: limits, scope, empty state, and recommendation hide tests pass.
### Task 3: Verify alpha UX as a user sees it
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/e2e/ux-followup.spec.js
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/scripts/check-gui.sh
**Interfaces:**
- E2E fixture uses effectiveShowPluginIds false by default.
- GUI checker accepts a --debug-labels case.
- [ ] **Step 1: Write the final failing alpha scenarios**
Add a scenario that creates a capture, assigns it, archives it globally, opens
the assigned case, and verifies the capture is still present. Add a session
candidate path to Journal and assert normal tab labels are human-readable.
~~~js
await archiveGlobalCapture('capture-1');
await openWorkspace('client-a');
await expect(page.getByTestId('browser-capture-capture-1')).toBeVisible();
await expect(page.getByRole('tab', { name: /verstak\./i })).toHaveCount(0);
~~~
- [ ] **Step 2: Run red**
Run:
~~~bash
npm run test:e2e -- ux-followup.spec.js
~~~
Expected: FAIL until the prior plans are integrated.
- [ ] **Step 3: Add deterministic GUI smoke commands**
Extend check-gui.sh to start an isolated test vault, run normal and --debug
screens, and save screenshots under frontend/e2e-results. The script must not
read or mutate the user's normal vault.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
npm run test:e2e -- ux-followup.spec.js
GOCACHE=/tmp/verstak-go-cache ./scripts/check-gui.sh
git add frontend/e2e/ux-followup.spec.js scripts/check-gui.sh
git commit -m "test: cover first alpha UX smoke flow"
~~~
Expected: E2E and isolated GUI smoke pass.

View File

@ -0,0 +1,216 @@
# Alpha Workspace Identity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
**Goal:** Give every managed Дело a durable UUID identity and publish lifecycle events that let alpha features follow a case across rename, Trash, restore, and path reuse.
**Architecture:** The filesystem remains the source of truth for whether a case folder exists. A UUID marker inside each case is the relation identity; Desktop metadata indexes that marker and paths remain presentation caches.
**Tech Stack:** Go, Wails bindings, JSON metadata, Go tests.
## Global Constraints
- A new Дело is created only by an explicit user action.
- workspaceId is UUID v4; a path is never relation identity.
- The marker is .verstak/workspace.json inside the case.
- Existing writable cases migrate without data loss; non-writable cases are view-only.
- Use TDD and make one commit per task.
---
### Task 1: Store and resolve durable workspace identities
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/workspace/manager.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/workspace/manager_test.go
**Interfaces:**
- Produces Workspace.ID string and Metadata.WorkspaceID string.
- Produces func (m *Manager) EnsureWorkspaceIdentity(name string) (WorkspaceIdentity, error).
- Produces func (m *Manager) ListWorkspaceIdentities() ([]WorkspaceIdentity, error).
- [ ] **Step 1: Write the failing identity tests**
Add tests proving creation writes a UUID marker, rename retains it, and a newly
created folder after external removal gets another UUID:
~~~go
ws, err := m.CreateWorkspace("Clients", "default")
if err != nil { t.Fatal(err) }
first, err := m.EnsureWorkspaceIdentity(ws.Name)
if err != nil || first.WorkspaceID == "" { t.Fatalf("identity = %+v, %v", first, err) }
if err := m.RenameWorkspace("Clients", "Clients-2026"); err != nil { t.Fatal(err) }
renamed, _ := m.EnsureWorkspaceIdentity("Clients-2026")
if renamed.WorkspaceID != first.WorkspaceID { t.Fatal("rename changed workspace ID") }
~~~
- [ ] **Step 2: Run the focused test to verify it fails**
Run:
~~~bash
cd /home/mirivlad/git/verstak2/verstak-desktop
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/workspace -run 'Test.*Workspace.*Identity' -count=1
~~~
Expected: FAIL because WorkspaceIdentity and EnsureWorkspaceIdentity do not exist.
- [ ] **Step 3: Write the minimal marker implementation**
Add these types and use them in CreateWorkspace, GetWorkspaceMetadata, and
workspace listing:
~~~go
type WorkspaceIdentity struct {
WorkspaceID string `json:"workspaceId"`
RootPath string `json:"rootPath"`
State string `json:"state"`
}
const workspaceIdentityRelativePath = ".verstak/workspace.json"
func (m *Manager) EnsureWorkspaceIdentity(name string) (WorkspaceIdentity, error) {
// Validate the folder, read the in-folder marker, and atomically create
// a UUID-v4 marker only when absent and writable.
}
~~~
Write the same ID to central metadata only as an index. Never recover an ID from
stale path-keyed metadata when the in-folder marker is absent.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/workspace -count=1
git add internal/core/workspace/manager.go internal/core/workspace/manager_test.go
git commit -m "feat: add durable workspace identities"
~~~
Expected: tests pass and the commit contains only workspace identity changes.
### Task 2: Expose identity and lifecycle events through Desktop
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app_test.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/src/App.svelte
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/wailsjs/go/api/App.d.ts
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/frontend/wailsjs/go/models.ts
**Interfaces:**
- Produces ListWorkspaceIdentities() ([]workspace.WorkspaceIdentity, string).
- Lifecycle payloads include workspaceId and workspaceRootPath; Trash also includes trashId.
- Produces workspace.restored and workspace.purged events.
- [ ] **Step 1: Add failing API tests**
Extend TestWorkspaceAPIPublishesLifecycleEvents:
~~~go
if got := received["workspace.created"]["workspaceId"]; got == "" {
t.Fatal("workspace.created must include workspaceId")
}
if got := received["workspace.renamed"]["workspaceId"]; got != createdID {
t.Fatalf("rename ID = %v, want %s", got, createdID)
}
~~~
Add restore and purge tests around RestoreVaultTrash and DeleteVaultTrash that
assert the original UUID and trash ID are emitted.
- [ ] **Step 2: Run red**
Run:
~~~bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run 'TestWorkspaceAPI.*Lifecycle' -count=1
~~~
Expected: FAIL because lifecycle payloads only identify a path.
- [ ] **Step 3: Publish complete identity lifecycle**
Resolve the marker before publishing create, rename, selected, and trashed
events. Detect workspace Trash metadata in RestoreVaultTrash and
DeleteVaultTrash, then publish:
~~~go
map[string]interface{}{
"operation": "restore",
"workspaceId": identity.WorkspaceID,
"workspaceRootPath": restoredRoot,
"trashId": trashID,
}
~~~
Change App.svelte workspace-node conversion to preserve workspace.id while using
rootPath only for selection and display.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api ./internal/core/workspace -count=1
git add internal/api/app.go internal/api/app_test.go frontend/src/App.svelte frontend/wailsjs/go/api/App.d.ts frontend/wailsjs/go/models.ts
git commit -m "feat: publish workspace identity lifecycle"
~~~
Expected: focused API and workspace tests pass.
### Task 3: Migrate legacy references and repair duplicate IDs
**Files:**
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/workspace/manager.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/core/workspace/manager_test.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app.go
- Modify: /home/mirivlad/git/verstak2/verstak-desktop/internal/api/app_test.go
**Interfaces:**
- Produces RepairWorkspaceIdentity(keepRootPath, regenerateRootPath string) string.
- Identity states are active, unavailable, or duplicate.
- Later Inbox and Activity plans consume workspaceId through ListWorkspaceIdentities.
- [ ] **Step 1: Write failing duplicate and legacy tests**
Create two folders with copied marker data and assert neither is an active
relation target. Create a legacy path-keyed capture fixture and assert a
resolved path gets an ID while a missing path becomes unavailable:
~~~go
if errStr := app.RepairWorkspaceIdentity("Original", "Copied"); errStr != "" {
t.Fatal(errStr)
}
if originalID == copiedID { t.Fatal("repair must issue a new ID") }
~~~
- [ ] **Step 2: Run red**
Run:
~~~bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/workspace ./internal/api -run 'Test.*(Duplicate|Legacy).*Workspace' -count=1
~~~
Expected: FAIL because copied markers are accepted as ordinary folders.
- [ ] **Step 3: Implement deterministic repair**
Group active roots by marker UUID during identity listing. Mark a group with
more than one root duplicate and exclude it from assignment targets.
RepairWorkspaceIdentity verifies the duplicate, keeps the first marker, creates
a new marker for the second root, and moves no relation data.
- [ ] **Step 4: Run green and commit**
Run:
~~~bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/workspace ./internal/api -count=1
git add internal/core/workspace/manager.go internal/core/workspace/manager_test.go internal/api/app.go internal/api/app_test.go
git commit -m "feat: repair duplicate workspace identities"
~~~
Expected: legacy, path-reuse, and duplicate-ID tests pass.