Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
- New layout `data/universes/{id}/index.json` for the universe builder, plus a type-level `data/universes/index.json` carrying `{ schemaVersion: 5, type, updatedAt, config: { runs } }`. Migration 034 (`scripts/migrations/034-split-universe-builder-to-per-uuid.js`) splits the legacy monolithic `data/universe-builder.json` (1.4MB / ~30 universes that previously rewrote in full on every mutation) into per-record files at boot. The legacy file is renamed to `universe-builder.json.bak-034` (not deleted) as a recovery path. Idempotent — partial-completion re-runs finish the split; full-completion re-runs are no-ops.
- **Cross-instance sync now gates on schema version.** New `server/lib/schemaVersions.js` is the single source of truth for the per-category storage-layout contract (`PORTOS_SCHEMA_VERSIONS = { universes: 5 }`); future category bumps add entries. Every outbound sync payload (federated peer push, 60s snapshot sync, share-bucket manifest) carries a `portosMeta { portosVersion, schemaVersions }` envelope. Receivers run `compareSchemaVersions(sender, local)`; when the sender is **ahead** on any category the receiver REJECTS — federated peer push returns 409 + `{ code: PEER_SYNC_SCHEMA_VERSION_AHEAD, context: { details: { ahead, behind, senderPortosVersion, receiverSchemaVersions } } }`, snapshot sync surfaces `blockedBySchema` on the peer record, share-bucket import skips the manifest with reason `portos-schema-ahead` and emits a `sharing:portos-schema-ahead` socket event. Senders persist the gap (`sub.blockedBySchema` on peer subscriptions, `peer.schemaGaps[category]` on peer records via `updatePeer`) and pause retries (5-minute cooldown; `peer:online` reconnect bypasses to re-probe in case the peer upgraded). Legacy senders without `portosMeta` pass through unchanged. The Instances page renders a new `<SchemaGapBadge>` per peer card explaining the gap in both directions: "Peer X is on an older PortOS — they need to update before we can sync universes" / "Peer X is on a newer PortOS — update PortOS to receive their universe updates." A "sender behind" mismatch DOES NOT gate — the existing sanitizer chain backfills older inputs.

- **Multiple, ordered code-review agents per task.** The custom-task form (`TaskAddForm`) and scheduled-task form (`ScheduleTab`) now expose an ordered multi-reviewer picker (new `client/src/components/cos/ReviewerPicker.jsx`) instead of a single reviewer dropdown: click reviewers to add them (run order = click order, numbered badges), reorder with ↑↓, remove with ✕, plus a **stop-mode** selector (`run all` / `stop on first fix` / `stop on first clean`) and a **reviewer-applies** toggle. Task metadata gains `reviewers: string[]` (ordered, replacing the single `reviewer`), `reviewStopMode`, and `reviewerApplies`; a read-side `normalizeReviewers()` (server `server/lib/validation.js` + client mirror in `constants.js`) keeps legacy single-`reviewer` tasks/schedules working. The agent prompts thread the list through as slashdo `--review-with a,b,c [--review-stop-on-findings|--review-stop-on-clean] [--reviewer-applies]` on the self-completion path, and the system-spawned review-loop follow-up agent runs each reviewer in the configured order (`buildReviewWithArgs()` builds the flag string; Copilot is pre-requested only when it *leads* the list — otherwise the follow-up requests it at its turn so it reviews the post-fix diff — and the follow-up still runs the CLI reviewers on non-GitHub forges where Copilot is unavailable).

## Changed
- **Bumped the bundled `lib/slashdo` submodule v2.18.0 → v3.1.0.** v3.1.0's `/do:pr` / `/do:release` accept an ordered, comma-separated `--review-with` list plus `--review-stop-on-*` and `--reviewer-applies` flags — the slashdo capability this release wires into the task forms. Added `.claude/commands/do/` symlinks for the new `depfree` / `pr-better` / `scan` commands so the in-repo project slash-command set matches the vendored submodule.
- `data.sample/` renamed to `data.reference/` to reflect its actual role: the canonical reference / seed source consumed by `scripts/setup-data.js` (and by migrations that look up the post-migration shipped version of a file). The old name implied "optional samples"; the new name conveys "this is what `./data/` is bootstrapped from and compared against." All code, tests, and active docs (CLAUDE.md, README, docs/) updated; historical `.changelog/v*.md` entries kept as-is since they describe past releases. The `DATA_SAMPLE_DIR` constant in `server/index.js` is now `DATA_REFERENCE_DIR`; `sampleDir` in `setup-data.js` is now `referenceDir`.
- `server/services/universeBuilder.js` rewritten to consume `collectionStore` instead of `readJSONFile` + `atomicWrite` against a monolithic file. The per-entity `CURRENT_SCHEMA_VERSION = 4` (stamped INSIDE each universe record) is preserved unchanged — it describes record shape and is orthogonal to the new type-level `schemaVersion: 5` (storage layout). Cross-record state (`runs[]` history) moves to `data/universes/index.json` under `config.runs`. Per-record edits no longer serialize against unrelated universe edits — concurrent PATCHes on different universes now run in parallel.
- `server/services/dataSync.js` universe-sync category points at the new `data/universes/` directory. `readFingerprintMap` walks two levels deep so per-record edits invalidate the cache; the dir layout's `index.json` mtime catches add/remove/edit at every nesting level. `getUniverseSnapshot()` reads via `listUniverses({ includeDeleted: true })` so peer sync sees the same set tombstones included.
Expand Down
1 change: 1 addition & 0 deletions .claude/commands/do/depfree.md
1 change: 1 addition & 0 deletions .claude/commands/do/pr-better.md
1 change: 1 addition & 0 deletions .claude/commands/do/scan.md
150 changes: 150 additions & 0 deletions client/src/components/cos/ReviewerPicker.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useId } from 'react';
import { Plus, X, ChevronUp, ChevronDown } from 'lucide-react';
import {
REVIEWER_OPTIONS,
REVIEW_STOP_MODES,
DEFAULT_REVIEW_STOP_MODE
} from './constants';

const labelFor = (value) => REVIEWER_OPTIONS.find(o => o.value === value)?.label || value;

/**
* Ordered multi-reviewer picker. Click a reviewer to append it (run order =
* click order), reorder with the arrows, remove with ✕. Maps to slashdo's
* `--review-with a,b,c` plus the stop-mode / `--reviewer-applies` flags.
*
* Controlled: emits the full next shape via onChange so the parent can store
* `reviewers` / `reviewStopMode` / `reviewerApplies` however it persists them.
*/
export default function ReviewerPicker({
reviewers = [],
stopMode = DEFAULT_REVIEW_STOP_MODE,
reviewerApplies = false,
onChange,
disabled = false
}) {
const id = useId();
// Render the parent's list (de-duped, order-preserving) so display === stored
// state for valid input while staying robust to malformed/legacy duplicates —
// dupes would otherwise collide on the `key={value}` below and corrupt
// reorder/remove. An empty list shows the "defaults to Copilot" hint and lets
// the user clear copilot; the server/submit layer resolves [] → ['copilot'].
const selected = Array.isArray(reviewers) ? [...new Set(reviewers)] : [];
const available = REVIEWER_OPTIONS.filter(o => !selected.includes(o.value));
const hasNonCopilot = selected.some(r => r !== 'copilot');

const emit = (next) => onChange?.({
reviewers: selected,
stopMode,
reviewerApplies,
...next
});

const add = (value) => emit({ reviewers: [...selected, value] });
const remove = (value) => emit({ reviewers: selected.filter(r => r !== value) });
const move = (index, delta) => {
const target = index + delta;
if (target < 0 || target >= selected.length) return;
const next = [...selected];
[next[index], next[target]] = [next[target], next[index]];
emit({ reviewers: next });
};

return (
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-gray-500 mr-1">Reviewers (in order):</span>
{selected.map((value, index) => (
<span
key={value}
className="inline-flex items-center gap-1 pl-1.5 pr-1 py-0.5 bg-port-bg border border-port-border rounded text-xs text-gray-300"
title={REVIEWER_OPTIONS.find(o => o.value === value)?.description}
Comment thread
atomantic marked this conversation as resolved.
>
<span className="text-port-accent font-mono">{index + 1}.</span>
{labelFor(value)}
<button
type="button"
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
className="text-gray-500 hover:text-white disabled:opacity-30 disabled:hover:text-gray-500"
aria-label={`Move ${labelFor(value)} earlier`}
>
<ChevronUp size={12} />
</button>
<button
type="button"
disabled={disabled || index === selected.length - 1}
onClick={() => move(index, 1)}
className="text-gray-500 hover:text-white disabled:opacity-30 disabled:hover:text-gray-500"
aria-label={`Move ${labelFor(value)} later`}
>
<ChevronDown size={12} />
</button>
<button
type="button"
disabled={disabled}
onClick={() => remove(value)}
className="text-gray-500 hover:text-port-error"
aria-label={`Remove ${labelFor(value)}`}
>
<X size={12} />
</button>
</span>
))}
{selected.length === 0 && (
<span className="text-xs text-gray-600 italic">none — defaults to Copilot</span>
)}
</div>

{available.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-gray-600 mr-1">Add:</span>
{available.map(opt => (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => add(opt.value)}
title={opt.description}
className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-transparent border border-port-border rounded text-xs text-gray-400 hover:text-white hover:border-port-accent disabled:opacity-50"
>
<Plus size={11} />
{opt.label}
</button>
))}
</div>
)}

{selected.length >= 2 && (
<div className="flex items-center gap-2">
<label htmlFor={`${id}-stopmode`} className="text-xs text-gray-500">Stop mode:</label>
<select
id={`${id}-stopmode`}
value={stopMode}
disabled={disabled}
onChange={e => emit({ stopMode: e.target.value })}
className="px-1.5 py-0.5 bg-port-bg border border-port-border rounded text-xs text-gray-300 min-h-[28px]"
>
{REVIEW_STOP_MODES.map(m => (
<option key={m.value} value={m.value} title={m.description}>{m.label}</option>
))}
</select>
</div>
)}

{hasNonCopilot && (
<label htmlFor={`${id}-applies`} className="flex items-center gap-2 cursor-pointer select-none text-xs text-gray-500">
<input
id={`${id}-applies`}
type="checkbox"
checked={reviewerApplies}
disabled={disabled}
onChange={e => emit({ reviewerApplies: e.target.checked })}
className="w-3.5 h-3.5 rounded border-port-border bg-port-bg text-port-accent focus:ring-port-accent focus:ring-offset-0"
/>
Reviewer applies fixes (CLI edits the working tree; no effect on Copilot)
</label>
)}
</div>
);
}
74 changes: 74 additions & 0 deletions client/src/components/cos/ReviewerPicker.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ReviewerPicker from './ReviewerPicker';

describe('ReviewerPicker', () => {
it('renders the selected reviewers in order with numbered badges', () => {
render(<ReviewerPicker reviewers={['codex', 'gemini', 'copilot']} onChange={() => {}} />);
expect(screen.getByText('1.')).toBeInTheDocument();
expect(screen.getByText('2.')).toBeInTheDocument();
expect(screen.getByText('3.')).toBeInTheDocument();
// The not-yet-selected reviewer (claude) shows in the Add row.
expect(screen.getByRole('button', { name: /Claude/ })).toBeInTheDocument();
});

it('shows the empty-state hint when no reviewers are selected', () => {
render(<ReviewerPicker reviewers={[]} onChange={() => {}} />);
expect(screen.getByText(/none — defaults to Copilot/)).toBeInTheDocument();
});

it('de-dupes a malformed list with duplicates (order-preserving)', () => {
render(<ReviewerPicker reviewers={['codex', 'codex', 'gemini']} onChange={() => {}} />);
// Two distinct pills (badges 1 and 2), not three.
expect(screen.getByText('1.')).toBeInTheDocument();
expect(screen.getByText('2.')).toBeInTheDocument();
expect(screen.queryByText('3.')).not.toBeInTheDocument();
});

it('emits an empty list when the last reviewer is removed (server resolves to copilot)', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<ReviewerPicker reviewers={['copilot']} onChange={onChange} />);
await user.click(screen.getByLabelText('Remove Copilot'));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reviewers: [] }));
});

it('appends a reviewer in click order on add', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<ReviewerPicker reviewers={['copilot']} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /Codex/ }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reviewers: ['copilot', 'codex'] }));
});

it('reorders with the up arrow', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<ReviewerPicker reviewers={['codex', 'gemini', 'copilot']} onChange={onChange} />);
await user.click(screen.getByLabelText('Move Gemini earlier'));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reviewers: ['gemini', 'codex', 'copilot'] }));
});

it('removes a reviewer', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<ReviewerPicker reviewers={['codex', 'copilot']} onChange={onChange} />);
await user.click(screen.getByLabelText('Remove Codex'));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reviewers: ['copilot'] }));
});

it('shows the stop-mode select only for 2+ reviewers', () => {
const { rerender } = render(<ReviewerPicker reviewers={['codex']} onChange={() => {}} />);
expect(screen.queryByText('Stop mode:')).not.toBeInTheDocument();
rerender(<ReviewerPicker reviewers={['codex', 'gemini']} onChange={() => {}} />);
expect(screen.getByText('Stop mode:')).toBeInTheDocument();
});

it('shows the reviewer-applies toggle only when a non-copilot reviewer is present', () => {
const { rerender } = render(<ReviewerPicker reviewers={['copilot']} onChange={() => {}} />);
expect(screen.queryByText(/Reviewer applies fixes/)).not.toBeInTheDocument();
rerender(<ReviewerPicker reviewers={['codex']} onChange={() => {}} />);
expect(screen.getByText(/Reviewer applies fixes/)).toBeInTheDocument();
});
});
34 changes: 20 additions & 14 deletions client/src/components/cos/TaskAddForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import * as api from '../../services/api';
import { processScreenshotUploads, processAttachmentUploads } from '../../utils/fileUpload';
import { formatBytes } from '../../utils/formatters';
import { filterSelectableModels, isTuiProvider, isCliProvider } from '../../utils/providers';
import { REVIEWER_OPTIONS, DEFAULT_REVIEWER } from './constants';
import { DEFAULT_REVIEWERS, DEFAULT_REVIEW_STOP_MODE } from './constants';
import ReviewerPicker from './ReviewerPicker';

const isCodexProvider = (provider) => {
if (!provider) return false;
Expand All @@ -24,7 +25,9 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
const [openPR, setOpenPR] = useState(false);
const [simplify, setSimplify] = useState(true);
const [reviewLoop, setReviewLoop] = useState(false);
const [reviewer, setReviewer] = useState(DEFAULT_REVIEWER);
const [reviewers, setReviewers] = useState(DEFAULT_REVIEWERS);
const [reviewStopMode, setReviewStopMode] = useState(DEFAULT_REVIEW_STOP_MODE);
const [reviewerApplies, setReviewerApplies] = useState(false);
const [createJiraTicket, setCreateJiraTicket] = useState(false);
const [screenshots, setScreenshots] = useState([]);
const [attachments, setAttachments] = useState([]);
Expand Down Expand Up @@ -221,7 +224,9 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
openPR,
simplify,
reviewLoop,
reviewer: reviewLoop ? reviewer : undefined,
reviewers: reviewLoop ? reviewers : undefined,
reviewStopMode: reviewLoop ? reviewStopMode : undefined,
reviewerApplies: reviewLoop ? reviewerApplies : undefined,
screenshots: screenshots.length > 0 ? screenshots.map(s => s.path) : undefined,
attachments: attachments.length > 0 ? attachments.map(a => ({
filename: a.filename,
Expand Down Expand Up @@ -454,17 +459,18 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
</span>
</label>
{reviewLoop && (
<select
value={reviewer}
onChange={(e) => setReviewer(e.target.value)}
className="px-1.5 py-0.5 bg-port-bg border border-port-border rounded text-xs text-gray-300 min-h-[28px]"
title="Override the default reviewer for the review loop (--review-with)"
aria-label="Reviewer for review loop"
>
{REVIEWER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value} title={opt.description}>{opt.label}</option>
))}
</select>
<div className="basis-full mt-1">
<ReviewerPicker
reviewers={reviewers}
stopMode={reviewStopMode}
reviewerApplies={reviewerApplies}
onChange={({ reviewers: r, stopMode, reviewerApplies: ra }) => {
setReviewers(r);
setReviewStopMode(stopMode);
setReviewerApplies(ra);
}}
/>
</div>
)}
{appHasJira && (
<label className="flex items-center gap-2 cursor-pointer select-none whitespace-nowrap py-1">
Expand Down
Loading