Skip to content

Commit 8a22b4a

Browse files
rsalusclaude
andauthored
fix: resolve delegation readiness bugs and DRY violation in track refs (#1028, #1029, #1031) (#1035)
* fix: resolve delegation readiness bugs and DRY violation in track references (#1028, #1029, #1031) - Add state.patched handler to DelegationReadinessView so plan approval via set action is recognized (not just workflow.transition events) - Remove quality.queried from readiness blockers — circular dependency since prepare_delegation emits the gate event after the check passes - Replace inline set call payloads in track references with describe calls to make describe the single source of truth for state schemas Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: move marketplace manifest to org-level .github repo The marketplace.json is an org-level concern, not an exarchos concern. Moved to lvlup-sw/.github/claude-plugins/marketplace.json so adding new plugins doesn't require commits to exarchos. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle dot-path notation in state.patched plan approval check The set action emits state.patched with raw input.updates, so dot-path keys like "planReview.approved" are preserved as-is. Handle both nested object form and dot-path form in the readiness view handler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update marketplace install path to lvlup-sw/.github The marketplace manifest moved to the org-level .github repo. Update installation guide and README with the new path, add migration instructions for existing users, and cross-reference axiom plugin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle plan approval revocation in state.patched handler The handler now reacts to both true and false values for planReview.approved, clearing readiness when approval is revoked. Adds regression tests for both dot-path and nested revocation forms. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3066269 commit 8a22b4a

11 files changed

Lines changed: 248 additions & 261 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 0 additions & 55 deletions
This file was deleted.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ When context compaction hits (or you close your laptop and come back Monday), ru
4242

4343
```bash
4444
# From the Claude Code marketplace
45-
/plugin marketplace add lvlup-sw/exarchos
45+
/plugin marketplace add lvlup-sw/.github
4646
/plugin install exarchos@lvlup-sw
4747
```
4848

documentation/guide/installation.md

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,25 @@
55
This is the recommended path. Two commands, nothing to configure.
66

77
```bash
8-
# Add the plugin from the lvlup-sw marketplace
9-
/plugin marketplace add lvlup-sw/exarchos
8+
# Add the lvlup-sw marketplace
9+
/plugin marketplace add lvlup-sw/.github
1010

11-
# Install it
11+
# Install Exarchos
1212
/plugin install exarchos@lvlup-sw
1313
```
1414

15-
This installs:
15+
The lvlup-sw marketplace is hosted in the [lvlup-sw/.github](https://github.com/lvlup-sw/.github) org repo. All LevelUp Software plugins are listed there.
16+
17+
### Other plugins from lvlup-sw
18+
19+
```bash
20+
# Backend code quality skills
21+
/plugin install axiom@lvlup-sw
22+
```
23+
24+
### What gets installed
25+
26+
Exarchos installs:
1627

1728
- The Exarchos MCP server (workflow state, event log, team coordination)
1829
- All workflow commands (`/exarchos:ideate`, `/exarchos:debug`, `/exarchos:refactor`, etc.)
@@ -21,6 +32,23 @@ This installs:
2132

2233
No additional configuration required. The plugin handles MCP server registration and command setup automatically.
2334

35+
## Migrating from an earlier install
36+
37+
If you previously added the marketplace with `/plugin marketplace add lvlup-sw/exarchos`, it pointed at a `marketplace.json` that no longer exists in the exarchos repo. To migrate:
38+
39+
```bash
40+
# Remove the old marketplace reference
41+
/plugin marketplace remove lvlup-sw
42+
43+
# Add the new one from the org repo
44+
/plugin marketplace add lvlup-sw/.github
45+
46+
# Update your installed plugins
47+
/plugin marketplace update
48+
```
49+
50+
Your installed plugins remain intact. Only the marketplace pointer changes.
51+
2452
## Dev companion (optional)
2553

2654
The dev companion adds three additional MCP servers for code analysis and documentation lookup:

servers/exarchos-mcp/src/orchestrate/prepare-delegation.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ function readyDelegationReadiness(): DelegationReadinessState {
106106
function notReadyDelegationReadiness(): DelegationReadinessState {
107107
return {
108108
ready: false,
109-
blockers: ['plan not approved', 'no task.assigned events found — emit task.assigned events for each task via exarchos_event before calling prepare_delegation', 'quality signals not queried'],
109+
blockers: ['plan not approved', 'no task.assigned events found — emit task.assigned events for each task via exarchos_event before calling prepare_delegation'],
110110
plan: { approved: false, taskCount: 0 },
111111
quality: { queried: false, gatePassRate: null, regressions: [] },
112112
worktrees: { expected: 0, ready: 0, failed: [] },
@@ -439,7 +439,7 @@ describe('handlePrepareDelegation', () => {
439439
const state = notReadyWorkflowState();
440440
const drState: DelegationReadinessState = {
441441
ready: false,
442-
blockers: ['plan not approved', 'worktrees pending', 'quality signals not queried'],
442+
blockers: ['plan not approved', 'worktrees pending'],
443443
plan: { approved: false, taskCount: 0 },
444444
quality: { queried: false, gatePassRate: null, regressions: [] },
445445
worktrees: { expected: 2, ready: 0, failed: [] },
@@ -463,7 +463,6 @@ describe('handlePrepareDelegation', () => {
463463
expect.stringContaining('worktrees'),
464464
);
465465
expect(data.readiness.blockers).toContain('plan not approved');
466-
expect(data.readiness.blockers).toContain('quality signals not queried');
467466
});
468467

469468
it('handlePrepareDelegation_WithoutNativeIsolation_IncludesAllBlockers', async () => {

servers/exarchos-mcp/src/views/delegation-readiness-view.test.ts

Lines changed: 129 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('DelegationReadinessView', () => {
2929
expect(state.ready).toBe(false);
3030
expect(state.blockers).toContain('plan not approved');
3131
expect(state.blockers).toContain('no task.assigned events found — emit task.assigned events for each task via exarchos_event before calling prepare_delegation');
32-
expect(state.blockers).toContain('quality signals not queried');
32+
expect(state.blockers).not.toContain('quality signals not queried');
3333
expect(state.plan).toEqual({ approved: false, taskCount: 0 });
3434
expect(state.quality).toEqual({
3535
queried: false,
@@ -214,7 +214,108 @@ describe('DelegationReadinessView', () => {
214214
});
215215
});
216216

217-
// ─── T7: All conditions met → ready ───────────────────────────────────────
217+
// ─── T7: state.patched ──────────────────────────────────────────────────
218+
219+
describe('apply - state.patched', () => {
220+
it('Apply_StatePatched_PlanReviewApproved_SetsPlanApproved', () => {
221+
const state = delegationReadinessProjection.init();
222+
const event = makeEvent('state.patched', {
223+
featureId: 'feat-1',
224+
fields: ['planReview'],
225+
patch: { planReview: { approved: true } },
226+
});
227+
228+
const next = delegationReadinessProjection.apply(state, event);
229+
230+
expect(next.plan.approved).toBe(true);
231+
expect(next.blockers).not.toContain('plan not approved');
232+
});
233+
234+
it('Apply_StatePatched_DotPathPlanReviewApproved_SetsPlanApproved', () => {
235+
const state = delegationReadinessProjection.init();
236+
const event = makeEvent('state.patched', {
237+
featureId: 'feat-1',
238+
fields: ['planReview.approved'],
239+
patch: { 'planReview.approved': true },
240+
});
241+
242+
const next = delegationReadinessProjection.apply(state, event);
243+
244+
expect(next.plan.approved).toBe(true);
245+
expect(next.blockers).not.toContain('plan not approved');
246+
});
247+
248+
it('Apply_StatePatched_PlanReviewApprovedFalse_ClearsPlanApproved', () => {
249+
let state = delegationReadinessProjection.init();
250+
251+
// First approve
252+
state = delegationReadinessProjection.apply(state, makeEvent('state.patched', {
253+
featureId: 'feat-1',
254+
fields: ['planReview.approved'],
255+
patch: { 'planReview.approved': true },
256+
}, 1));
257+
expect(state.plan.approved).toBe(true);
258+
259+
// Then revoke
260+
state = delegationReadinessProjection.apply(state, makeEvent('state.patched', {
261+
featureId: 'feat-1',
262+
fields: ['planReview.approved'],
263+
patch: { 'planReview.approved': false },
264+
}, 2));
265+
266+
expect(state.plan.approved).toBe(false);
267+
expect(state.blockers).toContain('plan not approved');
268+
});
269+
270+
it('Apply_StatePatched_NestedPlanReviewFalse_ClearsPlanApproved', () => {
271+
let state = delegationReadinessProjection.init();
272+
273+
// First approve via nested form
274+
state = delegationReadinessProjection.apply(state, makeEvent('state.patched', {
275+
featureId: 'feat-1',
276+
fields: ['planReview'],
277+
patch: { planReview: { approved: true } },
278+
}, 1));
279+
expect(state.plan.approved).toBe(true);
280+
281+
// Then revoke via nested form
282+
state = delegationReadinessProjection.apply(state, makeEvent('state.patched', {
283+
featureId: 'feat-1',
284+
fields: ['planReview'],
285+
patch: { planReview: { approved: false } },
286+
}, 2));
287+
288+
expect(state.plan.approved).toBe(false);
289+
expect(state.blockers).toContain('plan not approved');
290+
});
291+
292+
it('Apply_StatePatched_UnrelatedField_DoesNotChangePlan', () => {
293+
const state = delegationReadinessProjection.init();
294+
const event = makeEvent('state.patched', {
295+
featureId: 'feat-1',
296+
fields: ['brief'],
297+
patch: { brief: { problem: 'some problem' } },
298+
});
299+
300+
const next = delegationReadinessProjection.apply(state, event);
301+
302+
expect(next.plan.approved).toBe(false);
303+
});
304+
305+
it('Apply_StatePatched_NoPatch_ReturnsUnchanged', () => {
306+
const state = delegationReadinessProjection.init();
307+
const event = makeEvent('state.patched', {
308+
featureId: 'feat-1',
309+
fields: [],
310+
});
311+
312+
const next = delegationReadinessProjection.apply(state, event);
313+
314+
expect(next).toBe(state);
315+
});
316+
});
317+
318+
// ─── T8: All conditions met → ready ───────────────────────────────────────
218319

219320
describe('apply - readiness computation', () => {
220321
it('Apply_AllConditionsMet_SetsReadyTrue', () => {
@@ -241,14 +342,32 @@ describe('DelegationReadinessView', () => {
241342
taskId: 'task-1',
242343
}, 3));
243344

244-
// Quality signal
245-
state = delegationReadinessProjection.apply(state, makeEvent('gate.executed', {
246-
gateName: 'plan-coverage',
247-
layer: 'validation',
248-
passed: true,
249-
duration: 500,
250-
details: {},
251-
}, 4));
345+
expect(state.ready).toBe(true);
346+
expect(state.blockers).toEqual([]);
347+
});
348+
349+
it('Apply_PlanApprovedViaStatePatch_WithTaskAndWorktree_SetsReady', () => {
350+
let state = delegationReadinessProjection.init();
351+
352+
// Approve plan via state.patched (instead of workflow.transition)
353+
state = delegationReadinessProjection.apply(state, makeEvent('state.patched', {
354+
featureId: 'feat-1',
355+
fields: ['planReview'],
356+
patch: { planReview: { approved: true } },
357+
}, 1));
358+
359+
// Assign a task
360+
state = delegationReadinessProjection.apply(state, makeEvent('task.assigned', {
361+
taskId: 'task-1',
362+
title: 'Implement feature A',
363+
worktree: '/tmp/wt-1',
364+
}, 2));
365+
366+
// Worktree created
367+
state = delegationReadinessProjection.apply(state, makeEvent('worktree.created', {
368+
worktreePath: '/tmp/wt-1',
369+
taskId: 'task-1',
370+
}, 3));
252371

253372
expect(state.ready).toBe(true);
254373
expect(state.blockers).toEqual([]);
@@ -283,15 +402,6 @@ describe('DelegationReadinessView', () => {
283402
taskId: 'task-1',
284403
}, 4));
285404

286-
// Quality signal
287-
state = delegationReadinessProjection.apply(state, makeEvent('gate.executed', {
288-
gateName: 'plan-coverage',
289-
layer: 'validation',
290-
passed: true,
291-
duration: 500,
292-
details: {},
293-
}, 5));
294-
295405
expect(state.ready).toBe(false);
296406
expect(state.blockers).toContain('1 worktrees pending');
297407
});

servers/exarchos-mcp/src/views/delegation-readiness-view.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,6 @@ function computeBlockers(state: Omit<DelegationReadinessState, 'ready' | 'blocke
3939
blockers.push('no task.assigned events found — emit task.assigned events for each task via exarchos_event before calling prepare_delegation');
4040
}
4141

42-
if (!state.quality.queried) {
43-
blockers.push('quality signals not queried');
44-
}
45-
4642
const pendingWorktrees = state.worktrees.expected - state.worktrees.ready;
4743
if (state.worktrees.expected > 0 && pendingWorktrees > 0) {
4844
blockers.push(`${pendingWorktrees} worktrees pending`);
@@ -64,8 +60,7 @@ function isReady(state: Omit<DelegationReadinessState, 'ready' | 'blockers'>): b
6460
state.plan.approved &&
6561
state.worktrees.ready >= state.worktrees.expected &&
6662
state.worktrees.expected > 0 &&
67-
state.worktrees.failed.length === 0 &&
68-
state.quality.queried
63+
state.worktrees.failed.length === 0
6964
);
7065
}
7166

@@ -199,12 +194,40 @@ function handleWorktreeBaseline(
199194
return state;
200195
}
201196

197+
function handleStatePatched(
198+
state: DelegationReadinessState,
199+
event: WorkflowEvent,
200+
): DelegationReadinessState {
201+
const data = event.data as { patch?: Record<string, unknown> } | undefined;
202+
if (!data?.patch) return state;
203+
204+
// Resolve approved value from nested or dot-path form
205+
const planReview = data.patch.planReview as { approved?: boolean } | undefined;
206+
const dotPathValue = data.patch['planReview.approved'];
207+
208+
const approved = typeof dotPathValue === 'boolean'
209+
? dotPathValue
210+
: typeof planReview?.approved === 'boolean'
211+
? planReview.approved
212+
: undefined;
213+
214+
if (approved !== undefined && approved !== state.plan.approved) {
215+
return withReadiness({
216+
plan: { ...state.plan, approved },
217+
quality: state.quality,
218+
worktrees: state.worktrees,
219+
});
220+
}
221+
222+
return state;
223+
}
224+
202225
// ─── Projection ────────────────────────────────────────────────────────────
203226

204227
export const delegationReadinessProjection: ViewProjection<DelegationReadinessState> = {
205228
init: (): DelegationReadinessState => ({
206229
ready: false,
207-
blockers: ['plan not approved', 'no task.assigned events found — emit task.assigned events for each task via exarchos_event before calling prepare_delegation', 'quality signals not queried'],
230+
blockers: ['plan not approved', 'no task.assigned events found — emit task.assigned events for each task via exarchos_event before calling prepare_delegation'],
208231
plan: { approved: false, taskCount: 0 },
209232
quality: { queried: false, gatePassRate: null, regressions: [] },
210233
worktrees: { expected: 0, ready: 0, failed: [] },
@@ -228,6 +251,10 @@ export const delegationReadinessProjection: ViewProjection<DelegationReadinessSt
228251
// Handle event types not in the schema enum via string comparison
229252
const eventType = event.type as string;
230253

254+
if (eventType === 'state.patched') {
255+
return handleStatePatched(view, event);
256+
}
257+
231258
if (eventType === 'worktree.created') {
232259
return handleWorktreeCreated(view, event);
233260
}

0 commit comments

Comments
 (0)