feat(gns2): Gitea-Nervous-System v2.0 - distributed agent state machine
- Add GNS-2 label taxonomy (66 labels) with semantic routing - Tier 2 agents (capability-analyst, agent-architect, evaluator) enabled for self-cascade - GNS agent protocol: checkpoint v2 in issue body, machine-readable event footers - GiteaClient extended: checkpoint CRUD, event parsing, assignee/lock control, triggered issue polling - PipelineRunner rewritten as PollingSupervisor: reactive instead of active dispatch - Security: circuit breakers (is_locked), budget governance, depth limits - Scripts: init-gns-labels.py, validate-gns-agents.py - Milestone #67 + 7 phase issues (#99-#105) tracking evolution Refs: Milestone #67, Issues #99-#105
This commit is contained in:
@@ -86,6 +86,8 @@ export interface Issue {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
html_url?: string
|
||||
is_locked?: boolean
|
||||
milestone?: Milestone | null
|
||||
}
|
||||
|
||||
export interface CreateIssueOptions {
|
||||
@@ -517,8 +519,192 @@ export class GiteaClient {
|
||||
)
|
||||
}
|
||||
|
||||
async setIssueMilestone(issueNumber: number, milestoneId: number | null): Promise<Issue> {
|
||||
return this.updateIssue(issueNumber, { milestone: milestoneId ?? 0 })
|
||||
// ==================== Issue Assignees ====================
|
||||
|
||||
async getAssignee(issueNumber: number): Promise<string | null> {
|
||||
const issue = await this.getIssue(issueNumber)
|
||||
return issue.assignees && issue.assignees.length > 0 ? issue.assignees[0].login : null
|
||||
}
|
||||
|
||||
async setAssignee(issueNumber: number, assignee: string | null): Promise<Issue> {
|
||||
return this.updateIssue(issueNumber, { assignees: assignee ? [assignee] : [] })
|
||||
}
|
||||
|
||||
// ==================== Issue Lock / Circuit Breaker ====================
|
||||
|
||||
async lockIssue(issueNumber: number): Promise<Issue> {
|
||||
return this.updateIssue(issueNumber, { is_locked: true })
|
||||
}
|
||||
|
||||
async unlockIssue(issueNumber: number): Promise<Issue> {
|
||||
return this.updateIssue(issueNumber, { is_locked: false })
|
||||
}
|
||||
|
||||
async isLocked(issueNumber: number): Promise<boolean> {
|
||||
const issue = await this.getIssue(issueNumber)
|
||||
return issue.is_locked || false
|
||||
}
|
||||
|
||||
// ==================== GNS-2 Checkpoint Protocol ====================
|
||||
|
||||
private CHECKPOINT_PATTERN = /## GNS Checkpoint\s*```yaml\s*([\s\S]*?)```/
|
||||
|
||||
async getCheckpoint(issueNumber: number): Promise<any | null> {
|
||||
const issue = await this.getIssue(issueNumber)
|
||||
const match = this.CHECKPOINT_PATTERN.exec(issue.body)
|
||||
if (!match) return null
|
||||
try {
|
||||
// Simple YAML-like parsing - in production use a YAML parser
|
||||
const yaml = match[1]
|
||||
const lines = yaml.split('\n').filter(l => l.trim() && !l.trim().startsWith('#'))
|
||||
const result: any = {}
|
||||
let current: any = result
|
||||
let indentStack: { obj: any; indent: number }[] = [{ obj: result, indent: -1 }]
|
||||
|
||||
for (const line of lines) {
|
||||
const indent = line.search(/\S/)
|
||||
const trimmed = line.trim()
|
||||
const [key, ...valParts] = trimmed.split(':')
|
||||
const val = valParts.join(':').trim()
|
||||
|
||||
while (indentStack.length > 1 && indent <= indentStack[indentStack.length - 1].indent) {
|
||||
indentStack.pop()
|
||||
}
|
||||
current = indentStack[indentStack.length - 1].obj
|
||||
|
||||
if (val === '') {
|
||||
// Nested object
|
||||
const newObj: any = {}
|
||||
current[key.trim()] = newObj
|
||||
indentStack.push({ obj: newObj, indent: indent })
|
||||
} else if (val.startsWith('[') && val.endsWith(']')) {
|
||||
// Array
|
||||
current[key.trim()] = val.slice(1, -1).split(',').map(s => s.trim())
|
||||
} else if (val === 'true' || val === 'false') {
|
||||
current[key.trim()] = val === 'true'
|
||||
} else if (!isNaN(Number(val))) {
|
||||
current[key.trim()] = Number(val)
|
||||
} else {
|
||||
current[key.trim()] = val
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async updateCheckpoint(issueNumber: number, checkpoint: any): Promise<Issue> {
|
||||
const issue = await this.getIssue(issueNumber)
|
||||
const yamlBlock = `## GNS Checkpoint\n\`\`\`yaml\n${this.toYaml(checkpoint)}\n\`\`\``
|
||||
|
||||
let newBody: string
|
||||
if (this.CHECKPOINT_PATTERN.test(issue.body)) {
|
||||
newBody = issue.body.replace(this.CHECKPOINT_PATTERN, yamlBlock)
|
||||
} else {
|
||||
newBody = issue.body + '\n\n' + yamlBlock
|
||||
}
|
||||
|
||||
return this.updateIssue(issueNumber, { body: newBody })
|
||||
}
|
||||
|
||||
private toYaml(obj: any, indent = 0): string {
|
||||
const spaces = ' '.repeat(indent)
|
||||
let result = ''
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
if (val === null || val === undefined) {
|
||||
result += `${spaces}${key}:\n`
|
||||
} else if (Array.isArray(val)) {
|
||||
if (val.length === 0) {
|
||||
result += `${spaces}${key}: []\n`
|
||||
} else {
|
||||
result += `${spaces}${key}:\n`
|
||||
for (const item of val) {
|
||||
if (typeof item === 'object') {
|
||||
result += `${spaces}- ${this.toYaml(item, indent + 1).trimStart()}`
|
||||
} else {
|
||||
result += `${spaces}- ${item}\n`
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof val === 'object') {
|
||||
result += `${spaces}${key}:\n`
|
||||
result += this.toYaml(val, indent + 1)
|
||||
} else {
|
||||
result += `${spaces}${key}: ${val}\n`
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async clearCheckpoint(issueNumber: number): Promise<Issue> {
|
||||
const issue = await this.getIssue(issueNumber)
|
||||
const newBody = issue.body.replace(this.CHECKPOINT_PATTERN, '')
|
||||
return this.updateIssue(issueNumber, { body: newBody })
|
||||
}
|
||||
|
||||
// ==================== GNS-2 Event Parsing ====================
|
||||
|
||||
private GNS_EVENT_PATTERN = /<!-- GNS_EVENT:\s*({[\s\S]*?})\s*-->/g
|
||||
|
||||
async getGNSEvents(issueNumber: number): Promise<any[]> {
|
||||
const comments = await this.getComments(issueNumber)
|
||||
const events: any[] = []
|
||||
|
||||
for (const comment of comments) {
|
||||
let match
|
||||
while ((match = this.GNS_EVENT_PATTERN.exec(comment.body)) !== null) {
|
||||
try {
|
||||
events.push(JSON.parse(match[1]))
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
async getLastGNSEvent(issueNumber: number): Promise<any | null> {
|
||||
const events = await this.getGNSEvents(issueNumber)
|
||||
return events.length > 0 ? events[events.length - 1] : null
|
||||
}
|
||||
|
||||
// ==================== Polling: Triggered Issues ====================
|
||||
|
||||
async getTriggeredIssues(options?: {
|
||||
labels?: string[]
|
||||
assignee?: string
|
||||
milestone?: number
|
||||
updated_after?: string
|
||||
is_locked?: boolean
|
||||
}): Promise<Issue[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('state', 'open')
|
||||
|
||||
if (options?.labels) {
|
||||
params.set('labels', options.labels.join(','))
|
||||
}
|
||||
if (options?.assignee) {
|
||||
params.set('assignee', options.assignee)
|
||||
}
|
||||
if (options?.milestone) {
|
||||
params.set('milestone', String(options.milestone))
|
||||
}
|
||||
if (options?.updated_after) {
|
||||
params.set('since', options.updated_after)
|
||||
}
|
||||
|
||||
const issues = await this.request<Issue[]>(
|
||||
'GET',
|
||||
`/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/issues?${params.toString()}`
|
||||
)
|
||||
|
||||
if (options?.is_locked !== undefined) {
|
||||
return issues.filter(i => (i.is_locked || false) === options.is_locked)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +1,42 @@
|
||||
// kilocode_change - integrated module
|
||||
// Pipeline runner - orchestrates agent workflow with Gitea logging
|
||||
// Pipeline runner - GNS-2 Polling Supervisor for distributed agent workflow
|
||||
|
||||
import type { AgentRole } from "./index"
|
||||
import { decideRouting, formatAgentTag, type IssueContext, type RoutingDecision } from "./router"
|
||||
import { type IssueStatus } from "./workflow"
|
||||
import {
|
||||
saveEfficiencyScore,
|
||||
type EfficiencyScore,
|
||||
hasLowScore,
|
||||
findPromptOptimizationTargets
|
||||
} from "./prompt-loader"
|
||||
import {
|
||||
calculateOverallScore,
|
||||
generateRecommendations,
|
||||
type AgentPerformance,
|
||||
type EvaluationResult
|
||||
} from "./evaluator"
|
||||
import {
|
||||
GiteaClient,
|
||||
logPipelineStep,
|
||||
logAgentPerformance,
|
||||
detectRepository
|
||||
} from "./gitea-client"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
|
||||
export interface PipelineConfig {
|
||||
giteaToken?: string
|
||||
giteaApiUrl?: string
|
||||
efficiencyThreshold?: number
|
||||
autoLog?: boolean
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
export interface PipelineRunOptions {
|
||||
issueNumber: number
|
||||
initialStatus?: IssueStatus
|
||||
files?: string[]
|
||||
testResults?: { passed: number; failed: number }
|
||||
milestone?: number
|
||||
}
|
||||
|
||||
export interface PipelineResult {
|
||||
success: boolean
|
||||
finalAgent: AgentRole | null
|
||||
finalAgent: string | null
|
||||
finalStatus: string
|
||||
agentsUsed: AgentRole[]
|
||||
agentsUsed: string[]
|
||||
totalSteps: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface Checkpoint {
|
||||
issueNumber: number
|
||||
phase: string
|
||||
agentName: string
|
||||
filesModified: string[]
|
||||
status: string
|
||||
timestamp: string
|
||||
nextAgent: string | null
|
||||
}
|
||||
|
||||
export class PipelineRunner {
|
||||
export class PollingSupervisor {
|
||||
private client: GiteaClient
|
||||
private efficiencyThreshold: number
|
||||
private autoLog: boolean
|
||||
private initialized: boolean = false
|
||||
private pollInterval: number
|
||||
|
||||
constructor(config: PipelineConfig = {}) {
|
||||
this.client = new GiteaClient({
|
||||
@@ -71,6 +45,7 @@ export class PipelineRunner {
|
||||
})
|
||||
this.efficiencyThreshold = config.efficiencyThreshold ?? 7
|
||||
this.autoLog = config.autoLog ?? true
|
||||
this.pollInterval = config.pollIntervalMs ?? 30000 // 30 seconds
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
@@ -81,240 +56,236 @@ export class PipelineRunner {
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
async run(options: PipelineRunOptions): Promise<PipelineResult> {
|
||||
/**
|
||||
* GNS-2 Polling Supervisor
|
||||
*
|
||||
* Instead of actively dispatching agents in a while-loop,
|
||||
* the supervisor periodically polls Gitea for issues that
|
||||
* need attention based on labels, assignees, and comments.
|
||||
*/
|
||||
async supervise(options: PipelineRunOptions): Promise<PipelineResult> {
|
||||
await this.initialize()
|
||||
|
||||
const agentsUsed: AgentRole[] = []
|
||||
const agentsUsed: string[] = []
|
||||
const errors: string[] = []
|
||||
let currentStatus: IssueStatus = options.initialStatus ?? "new"
|
||||
let currentAgent: AgentRole | null = null
|
||||
let steps = 0
|
||||
const maxSteps = 20 // Prevent infinite loops
|
||||
|
||||
let ctx: IssueContext = await this.buildIssueContext(options)
|
||||
|
||||
const maxSteps = 100 // Safety limit
|
||||
|
||||
// Main polling loop
|
||||
while (steps < maxSteps) {
|
||||
steps++
|
||||
|
||||
const decision = decideRouting(ctx)
|
||||
|
||||
if (!decision.nextAgent) {
|
||||
break
|
||||
// Check if issue is locked (circuit breaker)
|
||||
const isLocked = await this.client.isLocked(options.issueNumber)
|
||||
if (isLocked) {
|
||||
await this.logEvent(options.issueNumber, '🔒', 'Issue locked by circuit breaker. Manual review required.')
|
||||
return {
|
||||
success: false,
|
||||
finalAgent: null,
|
||||
finalStatus: 'blocked',
|
||||
agentsUsed,
|
||||
totalSteps: steps,
|
||||
errors: [...errors, 'Issue locked by circuit breaker']
|
||||
}
|
||||
}
|
||||
|
||||
currentAgent = decision.nextAgent
|
||||
agentsUsed.push(currentAgent)
|
||||
// Get current issue state
|
||||
const issue = await this.client.getIssue(options.issueNumber)
|
||||
const checkpoint = await this.client.getCheckpoint(options.issueNumber)
|
||||
const lastEvent = await this.client.getLastGNSEvent(options.issueNumber)
|
||||
|
||||
if (this.autoLog) {
|
||||
await logPipelineStep(
|
||||
this.client,
|
||||
// Check if workflow is complete
|
||||
if (issue.state === 'closed') {
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
finalAgent: lastEvent?.agent || null,
|
||||
finalStatus: 'completed',
|
||||
agentsUsed,
|
||||
totalSteps: steps,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
// Check budget exhaustion
|
||||
if (checkpoint?.budget?.remaining !== undefined && checkpoint.budget.remaining <= 0) {
|
||||
await this.client.addLabels(options.issueNumber, ['budget::exhausted'])
|
||||
await this.client.lockIssue(options.issueNumber)
|
||||
await this.logEvent(options.issueNumber, '💰', 'Budget exhausted. Issue locked.')
|
||||
return {
|
||||
success: false,
|
||||
finalAgent: lastEvent?.agent || null,
|
||||
finalStatus: 'budget_exhausted',
|
||||
agentsUsed,
|
||||
totalSteps: steps,
|
||||
errors: [...errors, 'Budget exhausted']
|
||||
}
|
||||
}
|
||||
|
||||
// Determine next action based on issue state
|
||||
const nextAction = await this.determineNextAction(issue, checkpoint, lastEvent)
|
||||
|
||||
if (nextAction.type === 'invoke_agent') {
|
||||
const agentName = nextAction.agent!
|
||||
if (!agentsUsed.includes(agentName)) {
|
||||
agentsUsed.push(agentName)
|
||||
}
|
||||
|
||||
await this.logEvent(
|
||||
options.issueNumber,
|
||||
`${formatAgentTag(currentAgent)}`,
|
||||
"started",
|
||||
decision.instructions
|
||||
'🚀',
|
||||
`Invoking ${agentName} (depth: ${checkpoint?.depth || 0}, budget: ${checkpoint?.budget?.remaining || 'unknown'})`
|
||||
)
|
||||
|
||||
// Update assignee to target agent
|
||||
await this.client.setAssignee(options.issueNumber, agentName)
|
||||
|
||||
// In GNS-2, the agent itself will read the issue and act
|
||||
// The supervisor just marks that the agent has been triggered
|
||||
// The agent should respond by posting a comment
|
||||
|
||||
} else if (nextAction.type === 'wait') {
|
||||
// Wait for agent to respond
|
||||
await new Promise(resolve => setTimeout(resolve, this.pollInterval))
|
||||
continue
|
||||
|
||||
} else if (nextAction.type === 'stuck') {
|
||||
// Issue hasn't been updated in a while
|
||||
await this.logEvent(options.issueNumber, '⏰', 'Process appears stuck. Last activity older than threshold.')
|
||||
errors.push('Process stuck')
|
||||
|
||||
} else if (nextAction.type === 'complete') {
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
finalAgent: lastEvent?.agent || null,
|
||||
finalStatus: 'completed',
|
||||
agentsUsed,
|
||||
totalSteps: steps,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
currentStatus = decision.status as IssueStatus
|
||||
await this.client.setStatus(options.issueNumber, currentStatus)
|
||||
|
||||
ctx = await this.buildIssueContext(options)
|
||||
// Wait before next poll
|
||||
await new Promise(resolve => setTimeout(resolve, this.pollInterval))
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
finalAgent: currentAgent,
|
||||
finalStatus: currentStatus,
|
||||
success: false,
|
||||
finalAgent: null,
|
||||
finalStatus: 'max_steps_reached',
|
||||
agentsUsed,
|
||||
totalSteps: steps,
|
||||
errors,
|
||||
errors: [...errors, `Max steps (${maxSteps}) reached`],
|
||||
}
|
||||
}
|
||||
|
||||
private async buildIssueContext(options: PipelineRunOptions): Promise<IssueContext> {
|
||||
const issue = await this.client.getIssue(options.issueNumber)
|
||||
const comments = await this.client.getComments(options.issueNumber)
|
||||
/**
|
||||
* Determine what to do next based on issue state
|
||||
*/
|
||||
private async determineNextAction(
|
||||
issue: any,
|
||||
checkpoint: any | null,
|
||||
lastEvent: any | null
|
||||
): Promise<{ type: 'invoke_agent' | 'wait' | 'stuck' | 'complete'; agent?: string }> {
|
||||
|
||||
return {
|
||||
status: issue.labels.find(l => l.name.startsWith("status:"))?.name.replace("status: ", "") ?? "new",
|
||||
labels: issue.labels.map(l => l.name),
|
||||
checklists: this.parseChecklists(issue.body),
|
||||
comments: comments.map(c => c.body),
|
||||
files: options.files ?? [],
|
||||
testResults: options.testResults,
|
||||
const now = new Date()
|
||||
const lastUpdated = new Date(issue.updated_at)
|
||||
const minutesSinceUpdate = (now.getTime() - lastUpdated.getTime()) / 60000
|
||||
|
||||
// If issue was just updated and it's not by the supervisor, wait
|
||||
if (minutesSinceUpdate < 1) {
|
||||
return { type: 'wait' }
|
||||
}
|
||||
|
||||
// If no checkpoint exists, this is a new issue
|
||||
if (!checkpoint) {
|
||||
return { type: 'invoke_agent', agent: 'requirement-refiner' }
|
||||
}
|
||||
|
||||
// If last event specifies next_agent, invoke them
|
||||
if (lastEvent?.next_agent) {
|
||||
// Check if next agent has already responded
|
||||
const comments = await this.client.getComments(issue.number)
|
||||
const hasResponded = comments.some(
|
||||
c => c.user?.login === lastEvent.next_agent ||
|
||||
c.body.includes(`## 🔄 ${lastEvent.next_agent}`)
|
||||
)
|
||||
|
||||
if (!hasResponded) {
|
||||
return { type: 'invoke_agent', agent: lastEvent.next_agent }
|
||||
}
|
||||
}
|
||||
|
||||
// Check status labels for routing
|
||||
const statusLabels = issue.labels.filter((l: any) => l.name.startsWith('status::'))
|
||||
const status = statusLabels[0]?.name.replace('status::', '') || 'new'
|
||||
|
||||
// Map status to agent (fallback when checkpoint/event doesn't specify)
|
||||
const statusToAgent: Record<string, string> = {
|
||||
'new': 'requirement-refiner',
|
||||
'planned': 'history-miner',
|
||||
'researching': 'system-analyst',
|
||||
'designed': 'sdet-engineer',
|
||||
'testing': 'lead-developer',
|
||||
'implementing': 'code-skeptic',
|
||||
'reviewing': 'performance-engineer',
|
||||
'fixing': 'the-fixer',
|
||||
'releasing': 'release-manager',
|
||||
'evaluated': 'evaluator',
|
||||
'completed': 'orchestrator',
|
||||
}
|
||||
|
||||
const nextAgent = statusToAgent[status]
|
||||
if (nextAgent && status !== 'completed') {
|
||||
return { type: 'invoke_agent', agent: nextAgent }
|
||||
}
|
||||
|
||||
// If completed or no next agent, mark as complete
|
||||
if (status === 'completed') {
|
||||
return { type: 'complete' }
|
||||
}
|
||||
|
||||
// If stuck for more than 10 minutes
|
||||
if (minutesSinceUpdate > 10) {
|
||||
return { type: 'stuck' }
|
||||
}
|
||||
|
||||
return { type: 'wait' }
|
||||
}
|
||||
|
||||
private parseChecklists(body: string): { completed: number; total: number } {
|
||||
const lines = body.split("\n")
|
||||
const checkItems = lines.filter(l => l.match(/- \[[ x]\]/i))
|
||||
const completed = checkItems.filter(l => l.match(/- \[x\]/i)).length
|
||||
|
||||
return { completed, total: checkItems.length }
|
||||
}
|
||||
|
||||
async logEvaluation(
|
||||
issueNumber: number,
|
||||
performances: AgentPerformance[],
|
||||
iterations: number,
|
||||
durationHours: number
|
||||
): Promise<void> {
|
||||
/**
|
||||
* Poll multiple issues for a milestone
|
||||
*/
|
||||
async superviseMilestone(milestoneId: number): Promise<PipelineResult[]> {
|
||||
await this.initialize()
|
||||
|
||||
const agents: Record<string, number> = {}
|
||||
for (const perf of performances) {
|
||||
agents[perf.agent] = perf.score
|
||||
}
|
||||
|
||||
const result: EvaluationResult = {
|
||||
issue: issueNumber,
|
||||
date: new Date().toISOString(),
|
||||
agents,
|
||||
iterations,
|
||||
duration_hours: durationHours,
|
||||
summary: calculateOverallScore(performances).toString(),
|
||||
recommendations: generateRecommendations({
|
||||
issue: issueNumber,
|
||||
date: new Date().toISOString(),
|
||||
agents,
|
||||
iterations,
|
||||
duration_hours: durationHours,
|
||||
summary: "",
|
||||
recommendations: [],
|
||||
}),
|
||||
}
|
||||
|
||||
await saveEfficiencyScore({
|
||||
issue: result.issue,
|
||||
date: result.date,
|
||||
agents: result.agents,
|
||||
iterations: result.iterations,
|
||||
duration_hours: result.duration_hours,
|
||||
const triggered = await this.client.getTriggeredIssues({
|
||||
milestone: milestoneId,
|
||||
labels: ['status::new', 'status::planned', 'status::researching', 'status::designed', 'status::testing'],
|
||||
is_locked: false,
|
||||
})
|
||||
|
||||
const results: PipelineResult[] = []
|
||||
for (const issue of triggered) {
|
||||
const result = await this.supervise({ issueNumber: issue.number, milestone: milestoneId })
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private async logEvent(issueNumber: number, emoji: string, message: string): Promise<void> {
|
||||
if (this.autoLog) {
|
||||
const overallScore = calculateOverallScore(performances)
|
||||
const scoreEmoji = overallScore >= 8 ? "🟢" : overallScore >= 5 ? "🟡" : "🔴"
|
||||
|
||||
let comment = `## ${scoreEmoji} Pipeline Evaluation Report
|
||||
|
||||
**Issue**: #${issueNumber}
|
||||
**Overall Score**: ${overallScore}/10
|
||||
**Duration**: ${durationHours.toFixed(1)}h
|
||||
**Iterations**: ${iterations}
|
||||
|
||||
### Agent Scores
|
||||
|
||||
| Agent | Score |
|
||||
|-------|-------|
|
||||
`
|
||||
for (const perf of performances) {
|
||||
const emoji = perf.score >= 8 ? "🟢" : perf.score >= 5 ? "🟡" : "🔴"
|
||||
comment += `| ${emoji} ${perf.agent} | ${perf.score}/10 |\n`
|
||||
}
|
||||
|
||||
if (result.recommendations.length > 0) {
|
||||
comment += `\n### Recommendations\n\n`
|
||||
for (const rec of result.recommendations) {
|
||||
comment += `- ${rec}\n`
|
||||
}
|
||||
}
|
||||
|
||||
await this.client.createComment(issueNumber, { body: comment })
|
||||
|
||||
const lowScorers = performances.filter(p => p.score < this.efficiencyThreshold)
|
||||
if (lowScorers.length > 0) {
|
||||
const targets = lowScorers.map(p => `@${p.agent}`).join(", ")
|
||||
await this.client.createComment(issueNumber, {
|
||||
body: `⚠️ **Prompt Optimization Needed**\n\nThe following agents scored below ${this.efficiencyThreshold}/10: ${targets}\n\nConsider running prompt optimization after this issue is closed.`
|
||||
})
|
||||
}
|
||||
await this.client.createComment(issueNumber, {
|
||||
body: `${emoji} **Supervisor**: ${message}\n\n\`\`\`\nTimestamp: ${new Date().toISOString()}\n\`\`\``
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async checkForDuplicates(issueNumber: number, keywords: string[]): Promise<{
|
||||
hasDuplicates: boolean
|
||||
relatedIssues: number[]
|
||||
}> {
|
||||
await this.initialize()
|
||||
|
||||
const recentComments = await this.client.getComments(issueNumber)
|
||||
const minedIssues: number[] = []
|
||||
|
||||
for (const keyword of keywords) {
|
||||
for (const comment of recentComments) {
|
||||
const matches = comment.body.matchAll(/#(\d+)/g)
|
||||
for (const match of matches) {
|
||||
const num = parseInt(match[1], 10)
|
||||
if (num !== issueNumber && !minedIssues.includes(num)) {
|
||||
minedIssues.push(num)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasDuplicates: minedIssues.length > 0,
|
||||
relatedIssues: minedIssues,
|
||||
}
|
||||
}
|
||||
|
||||
async saveCheckpoint(checkpoint: Checkpoint): Promise<void> {
|
||||
// Ensure the checkpoints directory exists
|
||||
const checkpointDir = path.join(process.cwd(), '.kilo', 'logs', 'checkpoints');
|
||||
if (!fs.existsSync(checkpointDir)) {
|
||||
fs.mkdirSync(checkpointDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Save the checkpoint as JSON
|
||||
const filename = `${checkpoint.issueNumber}-${checkpoint.phase}.json`;
|
||||
const filepath = path.join(checkpointDir, filename);
|
||||
|
||||
fs.writeFileSync(filepath, JSON.stringify(checkpoint, null, 2));
|
||||
}
|
||||
|
||||
async loadCheckpoint(issueNumber: number): Promise<Checkpoint | null> {
|
||||
const checkpointDir = path.join(process.cwd(), '.kilo', 'logs', 'checkpoints');
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(checkpointDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the latest checkpoint file for this issue
|
||||
const files = fs.readdirSync(checkpointDir);
|
||||
const issueFiles = files.filter(file =>
|
||||
file.startsWith(`${issueNumber}-`) && file.endsWith('.json')
|
||||
);
|
||||
|
||||
if (issueFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by modification time to get the latest
|
||||
const sortedFiles = issueFiles.sort((a, b) => {
|
||||
const statA = fs.statSync(path.join(checkpointDir, a));
|
||||
const statB = fs.statSync(path.join(checkpointDir, b));
|
||||
return statB.mtime.getTime() - statA.mtime.getTime();
|
||||
});
|
||||
|
||||
const latestFile = sortedFiles[0];
|
||||
const filepath = path.join(checkpointDir, latestFile);
|
||||
|
||||
const content = fs.readFileSync(filepath, 'utf8');
|
||||
return JSON.parse(content) as Checkpoint;
|
||||
}
|
||||
|
||||
async resumeFromCheckpoint(issueNumber: number): Promise<string | null> {
|
||||
const checkpoint = await this.loadCheckpoint(issueNumber);
|
||||
return checkpoint ? checkpoint.nextAgent : null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPipelineRunner(config?: PipelineConfig): Promise<PipelineRunner> {
|
||||
const runner = new PipelineRunner(config)
|
||||
await runner.initialize()
|
||||
return runner
|
||||
export async function createPollingSupervisor(config?: PipelineConfig): Promise<PollingSupervisor> {
|
||||
const supervisor = new PollingSupervisor(config)
|
||||
await supervisor.initialize()
|
||||
return supervisor
|
||||
}
|
||||
|
||||
export { GiteaClient }
|
||||
export { GiteaClient }
|
||||
|
||||
Reference in New Issue
Block a user