Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9a434a9
feat(cos): split worktree and PR configs into independent settings
atomantic Mar 21, 2026
96e422f
fix(cos): address Copilot review feedback for openPR feature
atomantic Mar 21, 2026
ce6d020
fix(cos): coerce boolean metadata flags to prevent string 'false' tru…
atomantic Mar 21, 2026
4a30b12
refactor(tests): import production helpers instead of inlining copies…
atomantic Mar 21, 2026
e0eaf22
fix(cos): preserve worktree on push failure in openPR mode instead of…
atomantic Mar 21, 2026
74b9b61
fix(cos): disable reviewLoop when openPR is enabled
atomantic Mar 21, 2026
b4e6f7c
fix(cos): robust boolean handling for task metadata round-trip
atomantic Mar 21, 2026
a3f0f94
fix(validation): remove .default(false) from appSchema booleans to pr…
atomantic Mar 21, 2026
bcee355
fix(cos): address round 3 Copilot review feedback
atomantic Mar 21, 2026
80d5327
fix(cos): address round 4 Copilot review feedback
atomantic Mar 21, 2026
a89d8a1
fix(cos): address round 5 Copilot review feedback
atomantic Mar 21, 2026
42f36a5
fix(cos): address round 6 Copilot review feedback
atomantic Mar 21, 2026
eeee896
fix(cos): normalize PR title and remove dead variable
atomantic Mar 21, 2026
a19ece7
fix(cos): address round 8 Copilot review feedback
atomantic Mar 21, 2026
d2a987e
fix(cos): persist explicit false for boolean flags and fix toggle sem…
atomantic Mar 21, 2026
5ebe84b
docs: clarify toggleMetadataField is global config, not per-task over…
atomantic Mar 21, 2026
fd78890
fix(cos): preserve metadata key casing in TASKS.md round-trip
atomantic Mar 21, 2026
004a736
fix(cos): normalize TASKS.md metadata keys for backward compatibility
atomantic Mar 21, 2026
3bcb3e4
fix(cos): use baseBranch for PR target, improve prompt and docs
atomantic Mar 21, 2026
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 @@ -17,6 +17,8 @@
- Personal Goals dashboard widget: shows top-level goals with progress bars, category icons, horizon labels, and stall detection (14+ days idle) — replaces broken CoS task-based goal widget that never rendered

## Changed
- Exported `cleanupAgentWorktree` from `subAgentSpawner.js` with dedicated test file covering the openPR completion path (push, PR creation, failure preservation, auto-merge fallback)
- Exported `isTruthyMeta` from `subAgentSpawner.js` and `applyAppWorktreeDefault` from `cos.js`; tests now import production helpers instead of duplicating their logic inline
- Consolidated 5 duplicate `timeAgo`/`formatTimeAgo` implementations into a single `timeAgo()` in `utils/formatters.js`
- Deduplicated `stripCodeFences` from insightsService — now imports from shared `lib/aiProvider.js`
- Added `MINUTE` time constant to `lib/fileUtils.js` alongside existing `HOUR`/`DAY`
Expand All @@ -40,6 +42,7 @@
- Chart date range comparison uses local date strings instead of UTC Date objects — fixes today/yesterday missing from 7-day chart
- Empty entry objects no longer accumulate in daily-log.json after moving the last item from a date
- README screenshot labels corrected to match actual screenshot content (all 6 were mislabeled)
- Zod `.default(false)` on `archived`, `defaultUseWorktree`, and `defaultOpenPR` in `appSchema` caused `appUpdateSchema.partial()` to inject `false` for omitted fields, silently overwriting stored `true` values during partial updates

## Removed
- Dead code: `euclideanDistance`, `averageVectors`, `similarityMatrix` from `vectorMath.js` (unused outside tests)
Expand Down
27 changes: 22 additions & 5 deletions client/src/components/apps/EditAppModal.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ChevronDown, ChevronUp, GitBranch } from 'lucide-react';
import { ChevronDown, ChevronUp, GitBranch, GitPullRequest } from 'lucide-react';
import IconPicker from '../IconPicker';
import * as api from '../../services/api';

Expand All @@ -16,7 +16,8 @@ export default function EditAppModal({ app, onClose, onSave }) {
startCommands: (app.startCommands || []).join('\n'),
pm2ProcessNames: (app.pm2ProcessNames || []).join(', '),
editorCommand: app.editorCommand || 'code .',
defaultUseWorktree: app.defaultUseWorktree || false,
defaultOpenPR: app.defaultOpenPR || false,
defaultUseWorktree: app.defaultUseWorktree || app.defaultOpenPR || false,
jiraEnabled: app.jira?.enabled || false,
jiraInstanceId: app.jira?.instanceId || '',
jiraProjectKey: app.jira?.projectKey || '',
Expand Down Expand Up @@ -90,7 +91,8 @@ export default function EditAppModal({ app, onClose, onSave }) {
? formData.pm2ProcessNames.split(',').map(s => s.trim()).filter(Boolean)
: undefined,
editorCommand: formData.editorCommand || undefined,
defaultUseWorktree: formData.defaultUseWorktree,
defaultUseWorktree: formData.defaultUseWorktree || formData.defaultOpenPR,
defaultOpenPR: formData.defaultOpenPR,
jira: formData.jiraEnabled ? {
enabled: true,
instanceId: formData.jiraInstanceId || undefined,
Expand Down Expand Up @@ -242,11 +244,26 @@ export default function EditAppModal({ app, onClose, onSave }) {
<input
type="checkbox"
checked={formData.defaultUseWorktree}
onChange={e => setFormData({ ...formData, defaultUseWorktree: e.target.checked })}
onChange={e => {
const updates = { defaultUseWorktree: e.target.checked };
if (!e.target.checked) updates.defaultOpenPR = false;
setFormData(prev => ({ ...prev, ...updates }));
}}
className="rounded border-port-border bg-port-bg text-port-accent focus:ring-port-accent"
/>
<GitBranch size={14} className="text-emerald-400" />
<span className="text-sm text-white" title="When checked, new tasks default to working in an isolated git worktree on a feature branch, then opening a PR. When unchecked, agents commit directly to the default branch.">Default to Worktree + PR for new tasks</span>
<span className="text-sm text-white" title="When checked, new tasks default to working in an isolated git worktree on a feature branch. When unchecked, agents commit directly to the default branch.">Default to Worktree for new tasks</span>
</label>
<label className="flex items-center gap-2 cursor-pointer ml-6">
<input
type="checkbox"
checked={formData.defaultOpenPR}
disabled={!formData.defaultUseWorktree}
onChange={e => setFormData(prev => ({ ...prev, defaultOpenPR: e.target.checked }))}
className="rounded border-port-border bg-port-bg text-port-accent focus:ring-port-accent disabled:opacity-40"
/>
<GitPullRequest size={14} className="text-blue-400" />
<span className={`text-sm ${formData.defaultUseWorktree ? 'text-white' : 'text-gray-600'}`} title="When checked, agents open a PR to the default branch. When unchecked with worktree enabled, agents auto-merge to the default branch on completion.">Default to Open PR for new tasks</span>
</label>

{/* JIRA Integration Section */}
Expand Down
53 changes: 40 additions & 13 deletions client/src/components/cos/TaskAddForm.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Plus, Image, X, ChevronDown, ChevronRight, Sparkles, Loader2, Paperclip, FileText, Zap, Bookmark, Ticket, GitBranch, Wand2, RefreshCw } from 'lucide-react';
import { Plus, Image, X, ChevronDown, ChevronRight, Sparkles, Loader2, Paperclip, FileText, Zap, Bookmark, Ticket, GitBranch, GitPullRequest, Wand2, RefreshCw } from 'lucide-react';
import toast from 'react-hot-toast';
import * as api from '../../services/api';
import { processScreenshotUploads, processAttachmentUploads } from '../../utils/fileUpload';
Expand All @@ -11,6 +11,7 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
const [enhancePrompt, setEnhancePrompt] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [useWorktree, setUseWorktree] = useState(false);
const [openPR, setOpenPR] = useState(false);
const [simplify, setSimplify] = useState(true);
const [reviewLoop, setReviewLoop] = useState(false);
const [createJiraTicket, setCreateJiraTicket] = useState(false);
Expand Down Expand Up @@ -45,11 +46,15 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
);
const appHasJira = selectedApp?.jira?.enabled;

// Auto-toggle JIRA and worktree checkboxes when app selection changes
// Auto-toggle JIRA, worktree, and PR checkboxes when app selection changes
useEffect(() => {
const app = apps?.find(a => a.id === newTask.app);
const defaultOpenPR = !!app?.defaultOpenPR;
const defaultUseWorktree = !!app?.defaultUseWorktree || defaultOpenPR;
setCreateJiraTicket(!!app?.jira?.enabled);
setUseWorktree(!!app?.defaultUseWorktree);
setUseWorktree(defaultUseWorktree);
setOpenPR(defaultOpenPR);
if (defaultOpenPR) setReviewLoop(false);
}, [newTask.app, apps]);

// Get models for selected provider
Expand Down Expand Up @@ -182,10 +187,11 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
model: newTask.model || undefined,
provider: newTask.provider || undefined,
app: newTask.app || undefined,
createJiraTicket: createJiraTicket || undefined,
useWorktree: useWorktree || undefined,
simplify: simplify || undefined,
reviewLoop: reviewLoop || undefined,
createJiraTicket,
useWorktree,
openPR,
simplify,
reviewLoop,
screenshots: screenshots.length > 0 ? screenshots.map(s => s.path) : undefined,
attachments: attachments.length > 0 ? attachments.map(a => ({
filename: a.filename,
Expand All @@ -211,6 +217,7 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
setEnhancePrompt(false);
setCreateJiraTicket(false);
setUseWorktree(false);
setOpenPR(false);
setSimplify(true);
setReviewLoop(false);
setExpanded(false);
Expand Down Expand Up @@ -377,12 +384,31 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
<input
type="checkbox"
checked={useWorktree}
onChange={(e) => setUseWorktree(e.target.checked)}
onChange={(e) => {
setUseWorktree(e.target.checked);
if (!e.target.checked) setOpenPR(false);
}}
className="w-4 h-4 rounded border-port-border bg-port-bg text-port-accent focus:ring-port-accent focus:ring-offset-0"
/>
<span className="flex items-center gap-1.5 text-sm text-gray-400" title="Work in an isolated git worktree on a feature branch, then open a PR. If unchecked, commits directly to the default branch.">
<span className="flex items-center gap-1.5 text-sm text-gray-400" title="Work in an isolated git worktree on a feature branch. If unchecked, commits directly to the default branch.">
<GitBranch size={14} className="text-emerald-400" />
Worktree + PR
Worktree
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none whitespace-nowrap min-h-[44px]">
<input
type="checkbox"
checked={openPR}
disabled={!useWorktree}
onChange={(e) => {
setOpenPR(e.target.checked);
if (e.target.checked) setReviewLoop(false);
}}
className="w-4 h-4 rounded border-port-border bg-port-bg text-port-accent focus:ring-port-accent focus:ring-offset-0 disabled:opacity-40"
/>
<span className={`flex items-center gap-1.5 text-sm ${useWorktree ? 'text-gray-400' : 'text-gray-600'}`} title="Open a pull request to the default branch. If unchecked with worktree enabled, auto-merges on completion.">
<GitPullRequest size={14} className={useWorktree ? 'text-blue-400' : 'text-gray-600'} />
Open PR
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none whitespace-nowrap min-h-[44px]">
Expand All @@ -401,11 +427,12 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
<input
type="checkbox"
checked={reviewLoop}
disabled={openPR}
onChange={(e) => setReviewLoop(e.target.checked)}
className="w-4 h-4 rounded border-port-border bg-port-bg text-port-accent focus:ring-port-accent focus:ring-offset-0"
className="w-4 h-4 rounded border-port-border bg-port-bg text-port-accent focus:ring-port-accent focus:ring-offset-0 disabled:opacity-40"
/>
<span className="flex items-center gap-1.5 text-sm text-gray-400">
<RefreshCw size={14} className="text-amber-400" />
<span className={`flex items-center gap-1.5 text-sm ${openPR ? 'text-gray-600' : 'text-gray-400'}`} title={openPR ? 'Review Loop is incompatible with Open PR — the PR is created after the agent exits, so there is no PR to iterate on during the run.' : 'After the agent opens a PR during its run, keep iterating on review feedback until checks pass.'}>
<RefreshCw size={14} className={openPR ? 'text-gray-600' : 'text-amber-400'} />
Review Loop
</span>
</label>
Expand Down
27 changes: 24 additions & 3 deletions client/src/components/cos/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,18 @@ export const STATE_MESSAGES = {
ideating: "Analyzing options...",
};

// Agent option toggles for task metadata (useWorktree, simplify, reviewLoop)
// Agent option toggles for task metadata (useWorktree, openPR, simplify, reviewLoop)
export const AGENT_OPTIONS = [
{ field: 'useWorktree', label: 'Worktree + PR', shortLabel: 'WT', description: 'Work in an isolated git worktree on a feature branch, then open a PR. If unchecked, commits directly to the default branch.' },
{ field: 'useWorktree', label: 'Worktree', shortLabel: 'WT', description: 'Work in an isolated git worktree on a feature branch. If unchecked, commits directly to the default branch.' },
{ field: 'openPR', label: 'Open PR', shortLabel: 'PR', description: 'Open a pull request to the default branch (implies worktree). If unchecked with worktree enabled, auto-merges to the default branch on completion.' },
{ field: 'simplify', label: 'Run /simplify', shortLabel: '/s', description: 'Review code for reuse and quality before committing' },
{ field: 'reviewLoop', label: 'Review Loop', shortLabel: 'RL', description: 'After opening a PR, run review feedback loop until checks pass and PR is approved' }
{ field: 'reviewLoop', label: 'Review Loop', shortLabel: 'RL', description: 'After the agent opens a PR during its run, keep iterating on review feedback until checks pass. Only applies when Open PR is not enabled (manual PR creation by agent).' }
];

// Compute new taskMetadata after toggling a field in a per-app override.
// Returns null when all overrides are cleared (inherit everything).
// Enforces invariant: openPR implies useWorktree (turning on openPR forces
// useWorktree on; turning off useWorktree forces openPR off).
export function toggleAppMetadataOverride(overrideMetadata, globalMetadata, field) {
const current = overrideMetadata || {};
const newMeta = { ...current };
Expand All @@ -67,6 +70,24 @@ export function toggleAppMetadataOverride(overrideMetadata, globalMetadata, fiel
const effective = overrideMetadata?.[field] ?? globalMetadata?.[field] ?? false;
newMeta[field] = !effective;
}

const resolve = (f) => newMeta[f] ?? globalMetadata?.[f] ?? false;

// Apply useWorktree-off rule first (based on the toggled field's new value)
if (field === 'useWorktree' && !newMeta.useWorktree && newMeta.useWorktree !== undefined) {
// User explicitly toggled worktree off — force openPR off
newMeta.openPR = false;
} else if (field === 'openPR' && resolve('openPR') && !resolve('useWorktree')) {
// User toggled openPR on — force useWorktree on
newMeta.useWorktree = true;
}

// Clean entries that match the global value (revert to inherit)
for (const key of Object.keys(newMeta)) {
if (newMeta[key] === (globalMetadata?.[key] ?? false)) {
delete newMeta[key];
}
}
return Object.keys(newMeta).length ? newMeta : null;
}

Expand Down
14 changes: 13 additions & 1 deletion client/src/components/cos/tabs/ScheduleTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,22 @@ const INTERVAL_DESCRIPTIONS = {
custom: 'Custom interval'
};

// Toggle a global taskMetadata field, enforcing the openPR→useWorktree invariant.
function toggleMetadataField(metadata, field) {
const current = metadata || {};
const newMeta = { ...current, [field]: !current[field] };
const active = Object.fromEntries(Object.entries(newMeta).filter(([, v]) => v));
// openPR requires useWorktree
if (newMeta.openPR && !newMeta.useWorktree) {
newMeta.useWorktree = true;
}
// useWorktree off means openPR must be off
if (newMeta.useWorktree === false && newMeta.openPR) {
newMeta.openPR = false;
}
// This is the GLOBAL taskMetadata config (the app-level default itself), not a per-task-type
// override. Only persist true values; absent keys default to disabled. Per-task-type overrides
// are handled separately by toggleAppMetadataOverride which supports tri-state (inherit/true/false).
const active = Object.fromEntries(Object.entries(newMeta).filter(([, v]) => v === true));
return Object.keys(active).length ? active : null;
}

Expand Down
14 changes: 10 additions & 4 deletions server/lib/taskParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,21 @@ function escapeNewlines(value) {

/**
* Parse metadata line (indented under task)
* Format: - Key: Value
* Format: - key: Value
* Keys are written in camelCase (e.g., openPR, useWorktree, reviewLoop).
* Legacy Title-Case keys (e.g., Context, App) are accepted and normalized
* to camelCase by lowercasing the first character.
*/
function parseMetadataLine(line) {
const match = line.match(/^\s+-\s*(\w+):\s*(.+)$/);
if (!match) return null;

// Normalize key: lowercase first character to handle legacy Title-Case keys (Context→context,
// App→app) while preserving camelCase keys (openPR stays openPR, useWorktree stays useWorktree)
const rawKey = match[1];
const key = rawKey.charAt(0).toLowerCase() + rawKey.slice(1);
return {
key: match[1].toLowerCase(),
key,
value: unescapeNewlines(match[2].trim())
};
}
Expand Down Expand Up @@ -255,9 +262,8 @@ export function generateTasksMarkdown(tasks, includeApprovalFlags = false) {

// Add metadata (escape newlines in values for single-line storage)
for (const [key, value] of Object.entries(task.metadata)) {
const capitalizedKey = key.charAt(0).toUpperCase() + key.slice(1);
const escapedValue = escapeNewlines(String(value));
lines.push(` - ${capitalizedKey}: ${escapedValue}`);
lines.push(` - ${key}: ${escapedValue}`);
}
}

Expand Down
22 changes: 20 additions & 2 deletions server/lib/taskParser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,29 @@ describe('Task Parser', () => {
const tasks = parseTasksMarkdown(markdown);

expect(tasks).toHaveLength(1);
// Legacy Title-Case keys are normalized to camelCase (Context→context)
expect(tasks[0].metadata.context).toBe('User reported issue');
expect(tasks[0].metadata.app).toBe('my-app');
expect(tasks[0].metadata.model).toBe('claude-sonnet');
});

it('should preserve camelCase metadata keys', () => {
const markdown = `# Tasks

## Pending
- [ ] #task-001 | HIGH | Fix the bug
- openPR: true
- useWorktree: true
- reviewLoop: false`;

const tasks = parseTasksMarkdown(markdown);

expect(tasks).toHaveLength(1);
expect(tasks[0].metadata.openPR).toBe('true');
expect(tasks[0].metadata.useWorktree).toBe('true');
expect(tasks[0].metadata.reviewLoop).toBe('false');
});

it('should handle empty content', () => {
const tasks = parseTasksMarkdown('');
expect(tasks).toHaveLength(0);
Expand Down Expand Up @@ -261,8 +279,8 @@ describe('Task Parser', () => {

const markdown = generateTasksMarkdown(tasks);

expect(markdown).toContain('- Context: Some context');
expect(markdown).toContain('- App: my-app');
expect(markdown).toContain('- context: Some context');
expect(markdown).toContain('- app: my-app');
});

it('should escape newlines in metadata values for round-trip preservation', () => {
Expand Down
7 changes: 4 additions & 3 deletions server/lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,15 @@ export const appSchema = z.object({
appIconPath: z.string().nullable().optional(), // Absolute path to detected app icon image
editorCommand: z.string().optional(),
description: z.string().optional(),
archived: z.boolean().optional().default(false),
archived: z.boolean().optional(),
pm2Home: z.string().optional(), // Custom PM2_HOME path for apps that run in their own PM2 instance
disabledTaskTypes: z.array(z.string()).optional(), // Legacy: migrated to taskTypeOverrides
taskTypeOverrides: z.record(z.object({
enabled: z.boolean().optional(),
interval: z.string().nullable().optional()
})).optional(), // Per-task overrides: { [taskType]: { enabled, interval } }
defaultUseWorktree: z.boolean().optional().default(false),
defaultUseWorktree: z.boolean().optional(),
defaultOpenPR: z.boolean().optional(),
jira: jiraConfigSchema.optional().nullable(),
datadog: datadogConfigSchema.optional().nullable()
});
Expand Down Expand Up @@ -542,7 +543,7 @@ export function validateRequest(schema, data) {
// TASK METADATA SANITIZATION
// =============================================================================

const ALLOWED_TASK_METADATA_KEYS = ['useWorktree', 'simplify', 'reviewLoop'];
const ALLOWED_TASK_METADATA_KEYS = ['useWorktree', 'openPR', 'simplify', 'reviewLoop'];

/**
* Sanitize taskMetadata to only allowed agent-option keys with boolean values.
Expand Down
Loading
Loading