diff --git a/openspec/changes/agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07/proposal.md b/openspec/changes/agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07/proposal.md new file mode 100644 index 0000000..cef6012 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07/proposal.md @@ -0,0 +1,15 @@ +## Why + +- Active Agents now has bundled semantic icons for OpenSpec files, but unassigned changed rows can still fall back to the generic warning icon when the only extra signal is a delta label such as `Updated`. +- That makes `spec.md`, `proposal.md`, and `tasks.md` look visually identical in the tree right where operators want quick scan contrast. + +## What Changes + +- Keep semantic OpenSpec icons for unassigned delta-only rows so `proposal.md`, `tasks.md`, and `spec.md` stay visually distinct. +- Reserve the generic warning icon for real risk states only: protected-branch edits, foreign locks, or explicit lock warnings. +- Add focused regression coverage for delta-only unassigned OpenSpec changes. + +## Impact + +- Affected surfaces: `vscode/guardex-active-agents/extension.js`, `templates/vscode/guardex-active-agents/extension.js`, and `test/vscode-active-agents-session-state.test.js`. +- Risk stays narrow: icon-selection behavior only, with existing warning states preserved for real conflicts/locks. diff --git a/openspec/changes/agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07/specs/vscode-active-agents-provider-icons/spec.md b/openspec/changes/agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07/specs/vscode-active-agents-provider-icons/spec.md new file mode 100644 index 0000000..d2eed93 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07/specs/vscode-active-agents-provider-icons/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Changed OpenSpec rows keep semantic file icons + +The Active Agents tree SHALL keep semantic OpenSpec file icons for changed rows when the row only carries delta metadata and no real warning state. + +#### Scenario: Delta-only proposal, tasks, and spec rows keep semantic icons + +- **GIVEN** an unassigned Active Agents change row points at `proposal.md`, `tasks.md`, or `spec.md` +- **AND** the row only carries normal change metadata such as `deltaLabel: Updated` +- **WHEN** the tree renders that row +- **THEN** the row keeps the bundled semantic icon that matches the shipped file-icon manifest +- **AND** the description still surfaces the delta label + +#### Scenario: Warning states still override semantic file icons + +- **GIVEN** an Active Agents change row is on a protected branch, has a foreign lock, or carries a lock warning +- **WHEN** the tree renders that row +- **THEN** the row continues to use the generic warning icon instead of a semantic workflow file icon diff --git a/openspec/changes/agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07/tasks.md b/openspec/changes/agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07/tasks.md new file mode 100644 index 0000000..12cf40c --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when all of the following are true: + +- Every checkbox below is checked. +- Focused Active Agents regression coverage passes. +- Cleanup records the final PR URL plus `MERGED` evidence, or a `BLOCKED:` line explains why finish could not complete. + +Handoff: 2026-04-23 codex owns branch `agent/codex/active-agents-openspec-change-icons-2026-04-23-17-07`, the Active Agents live/template unassigned-change icon rule, focused tests, and this OpenSpec change for distinct `spec.md` / `proposal.md` / `tasks.md` visuals in changed rows. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope for semantic OpenSpec icons in changed Active Agents rows. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-provider-icons/spec.md`. + +## 2. Implementation + +- [x] 2.1 Limit generic warning icons to real lock/protected-branch risk instead of delta-only rows. +- [x] 2.2 Keep the live/template extension sources mirrored. +- [x] 2.3 Add focused regression coverage for delta-only unassigned `proposal.md`, `tasks.md`, and `spec.md` nodes. + +## 3. Verification + +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. Passed on `2026-04-23` (`52/52` tests passed). +- [x] 3.2 Run `openspec validate agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07 --type change --strict`. Result: `Change 'agent-codex-active-agents-openspec-change-icons-2026-04-23-17-07' is valid`. +- [x] 3.3 Run `openspec validate --specs`. Result: `No items found to validate.` + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run `gx branch finish --branch "agent/codex/active-agents-openspec-change-icons-2026-04-23-17-07" --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record the PR URL and final `MERGED` state in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree and branch refs are gone after cleanup. + +BLOCKED: `gx branch finish --branch "agent/codex/active-agents-openspec-change-icons-2026-04-23-17-07" --base main --via-pr --wait-for-merge --cleanup` pushed the branch and opened PR `#394` (`https://github.com/recodeee/gitguardex/pull/394`), but cleanup could not complete because GitHub rejected `gh pr merge 394 --squash --admin --delete-branch` with `New changes require approval from someone other than the last pusher. 4 of 4 required status checks are queued. (mergePullRequest)`. Current PR state: `OPEN`; cleanup/worktree-prune confirmation is still pending review plus CI. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 6546080..82a88af 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -696,6 +696,14 @@ function changeRiskBadges(change) { ].filter(Boolean)); } +function changeNeedsWarningIcon(change) { + return Boolean( + change?.protectedBranch + || change?.hasForeignLock + || (!change?.hasForeignLock && change?.lockOwnerBranch), + ); +} + function buildSessionCardDescription(session) { const provider = resolveSessionProvider(session); const statusAgentLabel = `${sessionStatusLabel(session)}: ${session.agentName || 'agent'}`; @@ -2796,7 +2804,7 @@ function buildUnassignedChangeNodes(changes) { return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, { label: compactRelativePath(change.relativePath), description: buildUnassignedChangeDescription(change), - iconId: changeRiskBadges(change).length > 0 ? 'warning' : undefined, + iconId: changeNeedsWarningIcon(change) ? 'warning' : undefined, })); } diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index c1340b9..63f35d2 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -3664,3 +3664,101 @@ test('active-agents extension uses bundled OpenSpec icons in Active Agents tree subscription.dispose?.(); } }); + +test('active-agents extension keeps semantic OpenSpec icons for delta-only unassigned changes', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-openspec-unassigned-icons-')); + initGitRepo(tempRoot); + fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8'); + runGit(tempRoot, ['add', 'tracked.txt']); + runGit(tempRoot, ['commit', '-m', 'baseline']); + runGit(tempRoot, ['checkout', '-b', 'agent/codex/unassigned-root']); + const branch = 'agent/codex/live-task'; + const worktreePath = path.join( + tempRoot, + '.omx', + 'agent-worktrees', + 'agent__codex__live-task', + ); + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + runGit(tempRoot, ['worktree', 'add', '-b', branch, worktreePath]); + const changeDir = path.join(tempRoot, 'openspec', 'changes', 'icon-pass'); + const proposalPath = path.join(changeDir, 'proposal.md'); + const tasksPath = path.join(changeDir, 'tasks.md'); + const specPath = path.join(changeDir, 'specs', 'active-agents-icons', 'spec.md'); + fs.mkdirSync(path.dirname(specPath), { recursive: true }); + fs.writeFileSync(proposalPath, 'proposal\n', 'utf8'); + fs.writeFileSync(tasksPath, 'tasks\n', 'utf8'); + fs.writeFileSync(specPath, 'spec\n', 'utf8'); + writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch, + taskName: 'live-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + state: 'working', + })); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => []; + let repoChanges = [ + { + relativePath: 'openspec/changes/icon-pass/proposal.md', + absolutePath: proposalPath, + statusLabel: 'A', + statusText: 'Added', + }, + { + relativePath: 'openspec/changes/icon-pass/tasks.md', + absolutePath: tasksPath, + statusLabel: 'A', + statusText: 'Added', + }, + { + relativePath: 'openspec/changes/icon-pass/specs/active-agents-icons/spec.md', + absolutePath: specPath, + statusLabel: 'A', + statusText: 'Added', + }, + ]; + const mockSessionSchema = { + ...sessionSchema, + readActiveSessions: () => sessionSchema.readActiveSessions(tempRoot, { includeStale: true }), + readRepoChanges: () => repoChanges, + }; + const extension = loadExtensionWithMockVscode(vscode, mockSessionSchema); + const context = { subscriptions: [] }; + + extension.activate(context); + const provider = registrations.providers[0].provider; + await provider.getChildren(); + await flushAsyncWork(); + + repoChanges = repoChanges.map((change) => ({ + ...change, + statusLabel: 'M', + statusText: 'Modified', + })); + const [repoItem] = await provider.getChildren(); + const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes'); + const unassignedItems = await provider.getChildren(unassignedSection); + assert.equal(unassignedItems.length, 3); + + const proposalItem = unassignedItems.find((item) => item.label === 'openspec/.../proposal.md'); + const tasksItem = unassignedItems.find((item) => item.label === 'openspec/.../tasks.md'); + const specItem = unassignedItems.find((item) => item.label === 'openspec/.../spec.md'); + assert.ok(proposalItem); + assert.ok(tasksItem); + assert.ok(specItem); + assert.equal(proposalItem.description, 'M · Updated'); + assert.equal(tasksItem.description, 'M · Updated'); + assert.equal(specItem.description, 'M · Updated'); + assertBundledIcon(proposalItem, 'openspec.svg'); + assertBundledIcon(tasksItem, 'plan.svg'); + assertBundledIcon(specItem, 'spec.svg'); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 6546080..82a88af 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -696,6 +696,14 @@ function changeRiskBadges(change) { ].filter(Boolean)); } +function changeNeedsWarningIcon(change) { + return Boolean( + change?.protectedBranch + || change?.hasForeignLock + || (!change?.hasForeignLock && change?.lockOwnerBranch), + ); +} + function buildSessionCardDescription(session) { const provider = resolveSessionProvider(session); const statusAgentLabel = `${sessionStatusLabel(session)}: ${session.agentName || 'agent'}`; @@ -2796,7 +2804,7 @@ function buildUnassignedChangeNodes(changes) { return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, { label: compactRelativePath(change.relativePath), description: buildUnassignedChangeDescription(change), - iconId: changeRiskBadges(change).length > 0 ? 'warning' : undefined, + iconId: changeNeedsWarningIcon(change) ? 'warning' : undefined, })); }