agents-relay 1.0.140 → 1.0.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ export function samePlannerChildDefinition(left, right) {
2
+ return JSON.stringify({ id: left.id, input: left.input, output: left.output, review: left.review ?? null, priority: left.priority, storySize: left.storySize ?? 'M', projectName: left.projectName, agentName: left.agentName, agentGraph: left.agentGraph, dependencies: left.dependencies, capabilities: left.capabilities, adapter: left.adapter, routing: left.routing, continuation: left.continuation ?? null, maxAttempts: left.maxAttempts, timeoutMs: left.timeoutMs }) === JSON.stringify({ id: right.id, input: right.input, output: right.output, review: right.review ?? null, priority: right.priority, storySize: right.storySize ?? 'M', projectName: right.projectName, agentName: right.agentName, agentGraph: right.agentGraph, dependencies: right.dependencies, capabilities: right.capabilities, adapter: right.adapter, routing: right.routing, continuation: right.continuation ?? null, maxAttempts: right.maxAttempts, timeoutMs: right.timeoutMs });
3
+ }
package/dist/planner.js CHANGED
@@ -47,7 +47,7 @@ export function planningSpec(task) {
47
47
  throw new Error(`Initial task ${task.id} requires an output contract`);
48
48
  if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
49
49
  throw new Error(`Initial task ${task.id} requires a supported adapter`);
50
- return { id: task.id, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
50
+ return { id: task.id, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
51
51
  }
52
52
  function record(value, field = 'result') {
53
53
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -88,6 +88,27 @@ function workerRequirements(value, field) {
88
88
  }
89
89
  return { capabilities, runtime: optional('runtime'), taskKind: optional('taskKind'), trustDomain: optional('trustDomain'), region: optional('region'), labels };
90
90
  }
91
+ function reviewMetadata(value, field) {
92
+ if (value === undefined || value === null)
93
+ return undefined;
94
+ const root = record(value, field);
95
+ if (root.role !== 'chair' && root.role !== 'specialist')
96
+ throw new Error(`Planner ${field}.role must be chair or specialist`);
97
+ const targetValue = record(root.target, `${field}.target`);
98
+ const artifact = optionalString(targetValue.artifact, `${field}.target.artifact`);
99
+ const version = optionalString(targetValue.version, `${field}.target.version`);
100
+ if (!artifact || !version)
101
+ throw new Error(`Planner ${field}.target requires artifact and version`);
102
+ const dimension = optionalString(root.dimension, `${field}.dimension`);
103
+ const selectionReason = optionalString(root.selectionReason, `${field}.selectionReason`);
104
+ if (root.role === 'specialist' && (!dimension || !selectionReason)) {
105
+ throw new Error(`Planner ${field} specialist tasks require dimension and selectionReason`);
106
+ }
107
+ const round = root.round === undefined ? undefined : root.round;
108
+ if (round !== undefined && (!Number.isInteger(round) || Number(round) < 1))
109
+ throw new Error(`Planner ${field}.round must be a positive integer`);
110
+ return { role: root.role, target: { artifact, version }, dimension, selectionReason, round: round === undefined ? undefined : Number(round) };
111
+ }
91
112
  function rawValue(value) {
92
113
  if (typeof value === 'string')
93
114
  return JSON.stringify(value);
@@ -185,6 +206,7 @@ export function parsePlannerResult(value) {
185
206
  const route = routing(task.routing, 'next_tasks[' + index + '].routing');
186
207
  const requirements = workerRequirements(task.workerRequirements ?? task.requirements, 'next_tasks[' + index + '].workerRequirements');
187
208
  const agentGraph = optionalString(task.agentGraph, `next_tasks[${index}].agentGraph`);
209
+ const review = reviewMetadata(task.review, `next_tasks[${index}].review`);
188
210
  if (agentGraph) {
189
211
  try {
190
212
  parseAgentGraph(agentGraph);
@@ -200,7 +222,7 @@ export function parsePlannerResult(value) {
200
222
  throw new Error(`Planner next_tasks[${index}].timeoutMs is invalid`);
201
223
  return {
202
224
  id, input: task.input, priority, storySize,
203
- projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'), agentGraph,
225
+ projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'), agentGraph, review,
204
226
  dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
205
227
  adapter: adapter, output: output, routing: route,
206
228
  continuation: task.continuation, maxAttempts, timeoutMs
@@ -153,6 +153,15 @@ export class ReconcilerRuntimeBase {
153
153
  registerExecutionOwner(jobId, taskId, executionId) {
154
154
  activeExecutionOwners.set(executionEventSource(jobId, taskId, executionId), this);
155
155
  }
156
+ clearTerminalTimer(taskId, executionId) {
157
+ const live = this.live.get(taskId);
158
+ if (!live || live.executionId !== executionId)
159
+ return;
160
+ const timer = this.terminalTimers.get(taskId);
161
+ if (timer)
162
+ clearTimeout(timer);
163
+ this.terminalTimers.delete(taskId);
164
+ }
156
165
  clearExecution(taskId, executionId) {
157
166
  const live = this.live.get(taskId);
158
167
  if (!executionId || live?.executionId === executionId) {
@@ -243,6 +252,8 @@ export class ReconcilerRuntimeBase {
243
252
  const task = job.tasks.find(item => item.id === taskId);
244
253
  if (!task || task.executionId !== executionId)
245
254
  return;
255
+ if (task.adapter !== 'chatgpt')
256
+ this.clearTerminalTimer(taskId, executionId);
246
257
  await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
247
258
  }
248
259
  async timeoutTask(jobId, taskId, executionId) {
@@ -15,6 +15,8 @@ import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
15
15
  import { applyHistoricalJobCompatibilityRepair } from './workflow/runtime.js';
16
16
  import { pendingTroubleshooterTasks } from './troubleshooter.js';
17
17
  import { prepareManagedExecutionWorktree } from './workspace.js';
18
+ import { createReviewChairExecutionPlan } from './review-chair.js';
19
+ import { samePlannerChildDefinition } from './planner-child.js';
18
20
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
19
21
  export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
20
22
  export async function managedWorkerInput(job, task, context) {
@@ -22,6 +24,9 @@ export async function managedWorkerInput(job, task, context) {
22
24
  return task.input;
23
25
  const selectedAgent = task.agentName && context ? await agentContractPrompt(context, task.agentName) : '';
24
26
  const selectedGraph = task.agentGraph && context ? await agentGraphPrompt(context, task.agentGraph) : '';
27
+ const chairPlan = task.review?.role === 'chair' && context
28
+ ? createReviewChairExecutionPlan(context.agents, task.review.target)
29
+ : null;
25
30
  if (!task.output)
26
31
  throw new Error(`Task ${task.id} requires an explicit output contract`);
27
32
  if (!task.executionId)
@@ -41,7 +46,7 @@ Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
41
46
 
42
47
  Retrieve the durable job and task from GitHub before doing the work. Use whatever GitHub access this environment provides: the URLs, a GitHub integration/tool, or GitHub CLI/API. GitHub is the source of truth for task intent and declared output.
43
48
 
44
- ${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? selectedAgent + '\n\n' : ''}${selectedGraph ? selectedGraph + '\n\n' : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
49
+ ${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? selectedAgent + '\n\n' : ''}${selectedGraph ? selectedGraph + '\n\n' : ''}${chairPlan ? `[Review Chair execution plan]\n${JSON.stringify(chairPlan, null, 2)}\n\nUse this runtime-discovered panel to create sibling durable specialist Tasks. Preserve each specialistTasks entry as review metadata, keep the target immutable, and synthesize structured results with synthesizeReview semantics before returning the unified revision plan.\n\n` : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
45
50
 
46
51
  [Output contract]
47
52
  ${output}
@@ -542,9 +547,6 @@ export class Reconciler extends ReconcilerReviewBase {
542
547
  plannerChild(job, planner, spec, executionContext) {
543
548
  validateAgentSelection(executionContext, spec.agentName, spec.agentGraph);
544
549
  const now = new Date().toISOString();
545
- return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: (spec.dependencies?.length ?? 0) > 0 ? 'DRAFT' : 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
550
+ return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: (spec.dependencies?.length ?? 0) > 0 ? 'DRAFT' : 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
546
551
  }
547
552
  }
548
- function samePlannerChildDefinition(left, right) {
549
- return JSON.stringify({ id: left.id, input: left.input, output: left.output, priority: left.priority, storySize: left.storySize ?? 'M', projectName: left.projectName, agentName: left.agentName, agentGraph: left.agentGraph, dependencies: left.dependencies, capabilities: left.capabilities, adapter: left.adapter, routing: left.routing, continuation: left.continuation ?? null, maxAttempts: left.maxAttempts, timeoutMs: left.timeoutMs }) === JSON.stringify({ id: right.id, input: right.input, output: right.output, priority: right.priority, storySize: right.storySize ?? 'M', projectName: right.projectName, agentName: right.agentName, agentGraph: right.agentGraph, dependencies: right.dependencies, capabilities: right.capabilities, adapter: right.adapter, routing: right.routing, continuation: right.continuation ?? null, maxAttempts: right.maxAttempts, timeoutMs: right.timeoutMs });
550
- }
@@ -0,0 +1,134 @@
1
+ function text(value, field) {
2
+ if (typeof value !== 'string' || value.trim().length === 0)
3
+ throw new Error(`Review ${field} must be a non-empty string`);
4
+ return value.trim();
5
+ }
6
+ function target(value) {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value))
8
+ throw new Error('Review target must be an object');
9
+ const record = value;
10
+ return { artifact: text(record.artifact, 'target.artifact'), version: text(record.version, 'target.version') };
11
+ }
12
+ function severity(value) {
13
+ if (value === undefined || value === null)
14
+ return undefined;
15
+ if (typeof value !== 'string' || !/^P[0-3]$/.test(value))
16
+ throw new Error('Review finding severity must be P0, P1, P2, or P3');
17
+ return value;
18
+ }
19
+ export function parseReviewFinding(value, index = 0) {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value))
21
+ throw new Error(`Review finding ${index} must be an object`);
22
+ const record = value;
23
+ const verdict = record.verdict;
24
+ if (verdict !== 'pass' && verdict !== 'concern' && verdict !== 'fail' && verdict !== 'blocked')
25
+ throw new Error(`Review finding ${index}.verdict is invalid`);
26
+ return {
27
+ id: record.id === undefined ? undefined : text(record.id, `finding ${index}.id`),
28
+ reviewer: text(record.reviewer, `finding ${index}.reviewer`),
29
+ dimension: text(record.dimension, `finding ${index}.dimension`),
30
+ verdict,
31
+ summary: text(record.summary, `finding ${index}.summary`),
32
+ severity: severity(record.severity),
33
+ location: record.location === undefined ? undefined : text(record.location, `finding ${index}.location`),
34
+ recommendation: record.recommendation === undefined ? undefined : text(record.recommendation, `finding ${index}.recommendation`),
35
+ evidence: record.evidence === undefined ? undefined : text(record.evidence, `finding ${index}.evidence`),
36
+ };
37
+ }
38
+ export function parseSpecialistReview(value) {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value))
40
+ throw new Error('Specialist review must be an object');
41
+ const record = value;
42
+ const verdict = record.verdict;
43
+ if (verdict !== 'pass' && verdict !== 'concern' && verdict !== 'fail' && verdict !== 'blocked')
44
+ throw new Error('Specialist review verdict is invalid');
45
+ if (!Array.isArray(record.findings))
46
+ throw new Error('Specialist review findings must be an array');
47
+ const parsedTarget = target(record.target);
48
+ const findings = record.findings.map((item, index) => parseReviewFinding(item, index));
49
+ for (const finding of findings) {
50
+ if (finding.reviewer !== text(record.reviewer, 'reviewer') || finding.dimension !== text(record.dimension, 'dimension')) {
51
+ throw new Error('Specialist review findings must preserve reviewer and dimension attribution');
52
+ }
53
+ }
54
+ return { reviewer: text(record.reviewer, 'reviewer'), dimension: text(record.dimension, 'dimension'), target: parsedTarget, verdict, findings };
55
+ }
56
+ function candidateFromAgent(agent) {
57
+ return { agent: agent.name, dimensions: [...agent.capabilities], description: agent.description };
58
+ }
59
+ /** Selects roles from the resolved visible catalog; it never selects workers or models. */
60
+ export function selectReviewPanel(candidates, dimensions, max = 5) {
61
+ if (!Number.isInteger(max) || max < 1)
62
+ throw new Error('Review panel max must be a positive integer');
63
+ const normalized = candidates.map(candidate => 'name' in candidate ? candidateFromAgent(candidate) : candidate);
64
+ const selected = [];
65
+ const usedAgents = new Set();
66
+ for (const dimension of [...dimensions].map((item, index) => ({ item, index })).sort((a, b) => (a.item.priority ?? 99) - (b.item.priority ?? 99) || a.index - b.index).map(item => item.item)) {
67
+ const match = normalized
68
+ .filter(candidate => !usedAgents.has(candidate.agent))
69
+ .map(candidate => ({ candidate, score: candidate.dimensions.some(item => item.toLowerCase() === dimension.name.toLowerCase()) ? 0 : 1 }))
70
+ .sort((a, b) => a.score - b.score)[0];
71
+ if (!match || match.score > 0)
72
+ continue;
73
+ selected.push({ agent: match.candidate.agent, dimension: dimension.name, reason: `Visible agent capability matches the ${dimension.name} review dimension${match.candidate.description ? `: ${match.candidate.description}` : ''}.` });
74
+ usedAgents.add(match.candidate.agent);
75
+ if (selected.length >= max)
76
+ break;
77
+ }
78
+ return selected;
79
+ }
80
+ export function specialistReviewTaskMetadata(targetValue, member, round = 1) {
81
+ return { role: 'specialist', target: { ...targetValue }, dimension: member.dimension, selectionReason: member.reason, round };
82
+ }
83
+ export function createSpecialistReviewTasks(tasks, targetValue, panel, round = 1) {
84
+ const existing = new Set(tasks.filter(task => task.review?.role === 'specialist').map(task => `${task.review?.target.artifact}@${task.review?.target.version}:${task.review?.dimension}:${task.agentName ?? ''}`));
85
+ return panel
86
+ .filter(member => !existing.has(`${targetValue.artifact}@${targetValue.version}:${member.dimension}:${member.agent}`))
87
+ .map(member => specialistReviewTaskMetadata(targetValue, member, round));
88
+ }
89
+ /** Builds the durable-task inputs used by the live Review Chair worker path. */
90
+ export function createReviewChairExecutionPlan(agents, targetValue, max = 5) {
91
+ const dimensions = [...new Set(agents.flatMap(agent => agent.capabilities))]
92
+ .map(name => ({ name }))
93
+ .filter(dimension => dimension.name !== 'model');
94
+ const panel = selectReviewPanel(agents, dimensions, max);
95
+ return { target: { ...targetValue }, panel, specialistTasks: createSpecialistReviewTasks([], targetValue, panel) };
96
+ }
97
+ function findingKey(finding) { return `${finding.location ?? ''}|${finding.summary.toLowerCase().replace(/\s+/g, ' ').trim()}`; }
98
+ function priority(finding) { return finding.severity ?? (finding.verdict === 'blocked' ? 'P0' : finding.verdict === 'fail' ? 'P1' : 'P2'); }
99
+ /** Deterministically merges independent specialist output into one executor-facing plan. */
100
+ export function synthesizeReview(targetValue, panel, reviews) {
101
+ const mismatched = reviews.find(review => review.target.artifact !== targetValue.artifact || review.target.version !== targetValue.version);
102
+ if (mismatched)
103
+ throw new Error(`Specialist review ${mismatched.reviewer} targets ${mismatched.target.artifact}@${mismatched.target.version}, expected ${targetValue.artifact}@${targetValue.version}`);
104
+ const byKey = new Map();
105
+ for (const review of reviews)
106
+ for (const finding of review.findings) {
107
+ const key = findingKey(finding);
108
+ const group = byKey.get(key) ?? [];
109
+ group.push(finding);
110
+ byKey.set(key, group);
111
+ }
112
+ const usedIds = new Set();
113
+ const mergedGroups = [...byKey.values()].map((group, index) => {
114
+ const preferredId = group[0].id ?? `finding-${index + 1}`;
115
+ let id = preferredId;
116
+ let suffix = 2;
117
+ while (usedIds.has(id))
118
+ id = `${preferredId}-${suffix++}`;
119
+ usedIds.add(id);
120
+ return { group, id };
121
+ });
122
+ const findings = mergedGroups.map(({ group, id }) => ({
123
+ ...group[0],
124
+ id,
125
+ attributions: group.map(item => ({ reviewer: item.reviewer, dimension: item.dimension, ...(item.id ? { findingId: item.id } : {}) })),
126
+ }));
127
+ const conflicts = mergedGroups.filter(({ group }) => new Set(group.map(item => item.verdict)).size > 1).map(({ group, id }) => ({ findingIds: [id], resolution: `Prioritize the more severe disposition and preserve all specialist attribution: ${group.map(item => `${item.reviewer}=${item.verdict}`).join(', ')}.` }));
128
+ const actions = findings.filter(finding => finding.verdict !== 'pass').map(finding => ({ priority: priority(finding), action: finding.recommendation ?? `Address: ${finding.summary}`, findingIds: [finding.id ?? findingKey(finding)], rationale: finding.evidence ?? `Raised by ${finding.reviewer} for ${finding.dimension}.` })).sort((a, b) => a.priority.localeCompare(b.priority) || a.action.localeCompare(b.action));
129
+ const covered = new Set(reviews.map(review => review.dimension));
130
+ const missingDimensions = panel.map(member => member.dimension).filter(dimension => !covered.has(dimension));
131
+ const unresolved = findings.filter(finding => finding.verdict === 'blocked');
132
+ return { target: { ...targetValue }, panel: panel.map(member => ({ ...member })), findings, conflicts, actions, unresolved, needsAdditionalSpecialist: missingDimensions.length > 0, missingDimensions };
133
+ }
134
+ function findingsIndex(group) { return group.map(item => item.reviewer).sort().join('-').toLowerCase().replace(/[^a-z0-9-]+/g, '-') || 'unknown'; }
@@ -6,7 +6,7 @@ function immutableTaskDefinition(task) {
6
6
  return {
7
7
  jobId: task.jobId, id: task.id, priority: task.priority, storySize: task.storySize ?? 'M', projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph,
8
8
  kind: task.kind ?? 'work', planningDecisionId: task.planningDecisionId, planningSource: task.planningSource, planningRound: task.planningRound, plannerGuidance: task.plannerGuidance, manualPlanning: task.manualPlanning, parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, workerSelection: task.workerSelection, adapter: task.adapter,
9
- input: task.input, attachments: task.attachments, output: task.output, routing, chatgptTabClosePolicy: task.chatgptTabClosePolicy ?? 'after-start', continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
9
+ input: task.input, attachments: task.attachments, output: task.output, review: task.review, routing, chatgptTabClosePolicy: task.chatgptTabClosePolicy ?? 'after-start', continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
10
10
  };
11
11
  }
12
12
  export function sameTaskDefinition(left, right) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.140",
3
+ "version": "1.0.142",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -66,6 +66,7 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
66
66
  - Otherwise return only the next useful executable batch from the graph-derived Task boundaries.
67
67
  - Do not repeat completed work or recreate an existing Task unless replacement work is explicitly needed.
68
68
  - Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
69
+ - When a Job needs multi-perspective review, use the visible `review-chair` agent or an Agent Graph to select specialist roles dynamically. Keep specialist Tasks as independent siblings against the same immutable target/version, and leave worker/model/provider routing to Relay.
69
70
  - A Task describes what an agent must accomplish; never emit a shell command as a worker Task.
70
71
  - Use dependencies only for Task IDs inside the current Job, and only when execution order actually matters. Never encode cross-Job or cross-repository dependencies in `dependencies`.
71
72
  - When the current Job is a prerequisite for another durable Job/Task, emit that dependent in top-level `unblocks`. The current blocker Job owns this authoritative reverse relation. Dependent-side `blockedBy` is optional observability metadata and is not the source of truth. Use `resolutionMode: "merge"` when blocker PR merge is sufficient; use `"verified"` when publication, deployment, runtime health, human approval, or another post-merge fact must be verified.
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: review-chair
3
+ description: Select a non-redundant specialist review panel for one immutable target and synthesize one attributed revision plan.
4
+ capabilities: review-orchestration, structured-synthesis
5
+ delegation: relay-child-task
6
+ restrictions: no-artifact-edits, no-worker-model-provider-selection, preserve-final-review
7
+ runtime: codex
8
+ ---
9
+
10
+ # Review Chair Agent
11
+
12
+ You own the review panel for exactly one declared immutable target/version. You
13
+ are a review orchestrator, not an artifact editor and not the final approval
14
+ gate.
15
+
16
+ ## Contract
17
+
18
+ 1. Read the Job objective, acceptance criteria, audience/platform, context, and
19
+ the exact target `{ artifact, version }`. Reject or report any specialist
20
+ result that targets a different version.
21
+ 2. Derive only the review dimensions that matter for this target. Discover
22
+ candidates from the resolved visible-agent catalog at execution time; never
23
+ use a hard-coded reviewer list. Select a small non-redundant panel (normally
24
+ 2-5 agents) and record each agent, dimension, and evidence-based reason.
25
+ 3. Create normal durable specialist Tasks with `parentTaskId` lineage. Every
26
+ specialist Task must carry the same immutable target/version. Independent
27
+ specialists may be siblings and may run in parallel when dependencies allow.
28
+ Select agent identities/roles only; worker, adapter, provider, and model
29
+ routing remains Relay's responsibility.
30
+ 4. Require structured specialist results with reviewer, dimension, verdict,
31
+ findings, severity/priority, target location when useful, recommendation,
32
+ and evidence/rationale. Preserve attribution.
33
+ 5. Deduplicate overlapping findings, make disagreements explicit, resolve them
34
+ against the Job objective, and produce one prioritized P0/P1/P2/P3 revision
35
+ plan. The executor receives this unified plan, never competing edit lists.
36
+ 6. If a missing expertise dimension or unresolved blocker remains, record it and
37
+ request an additional specialist Task before declaring synthesis complete.
38
+ Preserve prior trade-off rationale across revision rounds when available.
39
+
40
+ ## Output
41
+
42
+ Return a structured synthesis containing: target; selected panel and reasons;
43
+ merged findings; conflicts and resolutions; prioritized actions with attribution;
44
+ unresolved/blocking issues; and whether another specialist round is needed.
45
+
46
+ The Review Chair must not modify the reviewed artifact, select a worker/model/
47
+ provider, merge or approve the Job, or replace the existing SHA-bound final
48
+ reviewer. A revised artifact is a new target/version and starts a new review
49
+ round.
@@ -150,4 +150,17 @@ A zero-task autonomous Job stays idle under ordinary reconciliation, but an expl
150
150
 
151
151
  Planner-produced Tasks obey this same contract, including explicit `storySize`.
152
152
 
153
+ ### Optional Review Chair Tasks
154
+
155
+ Projects may use the shared `review-chair` agent as a normal Task or Agent
156
+ Graph node. The chair discovers specialist agent roles from the resolved
157
+ visible-agent catalog, records selection reasons, and creates sibling durable
158
+ specialist Tasks carrying the same immutable review target/version. Their
159
+ structured findings are synthesized into one attributed revision plan. A chair
160
+ may add a specialist when synthesis exposes a missing dimension. It never
161
+ chooses worker/model/provider, edits the artifact, or replaces the existing
162
+ SHA-bound final reviewer. For example, `video-director + market + style ->
163
+ review-chair -> unified revision plan` is a project pattern, not a hard-coded
164
+ Relay reviewer list.
165
+
153
166
  A scheduled autonomous job adds cron/timezone/next-run and missed-run/concurrency policy to the trusted job marker, plus the `agents-relay:autonomous` label and one bounded time-of-day `agents-relay:next-run:*` index label. The GitHub heartbeat discovers only indexed due candidates, validates the marker's exact `nextRunAt`, claims the slot idempotently, advances the durable next run, and pulses `agents-relay:schedule-wake`. GitHub webhook delivery then wakes local Relay for planner/worker execution. While the schedule is active, the durable Job remains `ACTIVE`; between runs its derived runtime status is `WAITING`. Scheduled recurring Jobs do not enter one-shot final-review/auto-merge after each satisfied run; older-cycle failures are historical evidence and do not block a later slot.