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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`;
Expand Down Expand Up @@ -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,
}));
}

Expand Down
98 changes: 98 additions & 0 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
}
});
10 changes: 9 additions & 1 deletion vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`;
Expand Down Expand Up @@ -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,
}));
}

Expand Down
Loading