@calltelemetry/openclaw-linear 0.9.14 → 0.9.16
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.
- package/README.md +104 -48
- package/index.ts +57 -0
- package/openclaw.plugin.json +44 -2
- package/package.json +1 -1
- package/prompts.yaml +3 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +37 -50
- package/src/agent/agent.test.ts +1 -1
- package/src/agent/agent.ts +39 -6
- package/src/api/linear-api.test.ts +188 -1
- package/src/api/linear-api.ts +114 -5
- package/src/infra/multi-repo.test.ts +127 -1
- package/src/infra/multi-repo.ts +74 -6
- package/src/infra/tmux-runner.ts +599 -0
- package/src/infra/tmux.ts +158 -0
- package/src/infra/token-refresh-timer.ts +44 -0
- package/src/pipeline/active-session.ts +19 -1
- package/src/pipeline/artifacts.ts +42 -0
- package/src/pipeline/dispatch-state.ts +3 -0
- package/src/pipeline/guidance.test.ts +53 -0
- package/src/pipeline/guidance.ts +38 -0
- package/src/pipeline/memory-search.ts +40 -0
- package/src/pipeline/pipeline.ts +184 -17
- package/src/pipeline/retro.ts +231 -0
- package/src/pipeline/webhook.test.ts +8 -8
- package/src/pipeline/webhook.ts +408 -29
- package/src/tools/claude-tool.ts +68 -10
- package/src/tools/cli-shared.ts +50 -2
- package/src/tools/code-tool.ts +230 -150
- package/src/tools/codex-tool.ts +61 -9
- package/src/tools/gemini-tool.ts +61 -10
- package/src/tools/steering-tools.ts +176 -0
- package/src/tools/tools.test.ts +47 -15
- package/src/tools/tools.ts +17 -4
- package/src/__test__/smoke-linear-api.test.ts +0 -847
package/src/pipeline/pipeline.ts
CHANGED
|
@@ -49,6 +49,80 @@ import {
|
|
|
49
49
|
import { resolveWatchdogConfig } from "../agent/watchdog.js";
|
|
50
50
|
import { emitDiagnostic } from "../infra/observability.js";
|
|
51
51
|
import { renderTemplate } from "../infra/template.js";
|
|
52
|
+
import { getWorktreeStatus, createPullRequest } from "../infra/codex-worktree.js";
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Linear issue state transitions (best-effort)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Transition a Linear issue to a target state by type (e.g., "started", "completed", "triage").
|
|
60
|
+
* Resolves the team's workflow states and finds a matching state.
|
|
61
|
+
* All transitions are best-effort — failures are logged as warnings, never thrown.
|
|
62
|
+
*/
|
|
63
|
+
async function transitionLinearIssueState(
|
|
64
|
+
linearApi: LinearAgentApi,
|
|
65
|
+
issueId: string,
|
|
66
|
+
teamId: string | undefined,
|
|
67
|
+
targetStateType: string,
|
|
68
|
+
opts?: { preferredStateName?: string },
|
|
69
|
+
logger?: { warn: (msg: string) => void; info: (msg: string) => void },
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
if (!teamId) {
|
|
72
|
+
logger?.warn(`[linear-state] no teamId — skipping state transition to ${targetStateType}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const states = await linearApi.getTeamStates(teamId);
|
|
77
|
+
// Prefer a specific state name if provided, otherwise match by type
|
|
78
|
+
let target = opts?.preferredStateName
|
|
79
|
+
? states.find(s => s.name === opts.preferredStateName) ?? states.find(s => s.type === targetStateType)
|
|
80
|
+
: states.find(s => s.type === targetStateType);
|
|
81
|
+
if (target) {
|
|
82
|
+
await linearApi.updateIssue(issueId, { stateId: target.id });
|
|
83
|
+
logger?.info(`[linear-state] ${issueId} → ${target.name} (type=${target.type})`);
|
|
84
|
+
} else {
|
|
85
|
+
logger?.warn(`[linear-state] no state with type="${targetStateType}" found for team ${teamId}`);
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
logger?.warn(`[linear-state] failed to transition ${issueId} to ${targetStateType}: ${err}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Transition a Linear issue to "In Review" if it exists, otherwise "Done".
|
|
94
|
+
* Used after audit pass when a PR was created.
|
|
95
|
+
*/
|
|
96
|
+
async function transitionToReviewOrDone(
|
|
97
|
+
linearApi: LinearAgentApi,
|
|
98
|
+
issueId: string,
|
|
99
|
+
teamId: string | undefined,
|
|
100
|
+
hasPr: boolean,
|
|
101
|
+
logger?: { warn: (msg: string) => void; info: (msg: string) => void },
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
if (!teamId) return;
|
|
104
|
+
try {
|
|
105
|
+
const states = await linearApi.getTeamStates(teamId);
|
|
106
|
+
if (hasPr) {
|
|
107
|
+
// Prefer "In Review" state; fall back to any "started" state named "In Review", then "completed"
|
|
108
|
+
const inReview = states.find(s => s.name === "In Review")
|
|
109
|
+
?? states.find(s => s.type === "started" && s.name.toLowerCase().includes("review"));
|
|
110
|
+
if (inReview) {
|
|
111
|
+
await linearApi.updateIssue(issueId, { stateId: inReview.id });
|
|
112
|
+
logger?.info(`[linear-state] ${issueId} → ${inReview.name} (PR created)`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// No PR or no "In Review" state — mark as Done
|
|
117
|
+
const done = states.find(s => s.type === "completed");
|
|
118
|
+
if (done) {
|
|
119
|
+
await linearApi.updateIssue(issueId, { stateId: done.id });
|
|
120
|
+
logger?.info(`[linear-state] ${issueId} → ${done.name} (completed)`);
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger?.warn(`[linear-state] failed to transition ${issueId} to review/done: ${err}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
52
126
|
|
|
53
127
|
// ---------------------------------------------------------------------------
|
|
54
128
|
// Prompt loading
|
|
@@ -218,7 +292,7 @@ export interface IssueContext {
|
|
|
218
292
|
export function buildWorkerTask(
|
|
219
293
|
issue: IssueContext,
|
|
220
294
|
worktreePath: string,
|
|
221
|
-
opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown>; guidance?: string },
|
|
295
|
+
opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown>; guidance?: string; teamContext?: string },
|
|
222
296
|
): { system: string; task: string } {
|
|
223
297
|
const prompts = loadPrompts(opts?.pluginConfig, worktreePath);
|
|
224
298
|
const vars: Record<string, string> = {
|
|
@@ -233,6 +307,7 @@ export function buildWorkerTask(
|
|
|
233
307
|
? `\n---\n## IMPORTANT — Workspace Guidance (MUST follow)\nThe workspace owner has set the following mandatory instructions. You MUST incorporate these into your response:\n\n${opts.guidance.slice(0, 2000)}\n---`
|
|
234
308
|
: "",
|
|
235
309
|
projectContext: buildProjectContext(opts?.pluginConfig),
|
|
310
|
+
teamContext: opts?.teamContext ?? "",
|
|
236
311
|
};
|
|
237
312
|
|
|
238
313
|
let task = renderTemplate(prompts.worker.task, vars);
|
|
@@ -253,7 +328,7 @@ export function buildAuditTask(
|
|
|
253
328
|
issue: IssueContext,
|
|
254
329
|
worktreePath: string,
|
|
255
330
|
pluginConfig?: Record<string, unknown>,
|
|
256
|
-
opts?: { guidance?: string },
|
|
331
|
+
opts?: { guidance?: string; teamContext?: string },
|
|
257
332
|
): { system: string; task: string } {
|
|
258
333
|
const prompts = loadPrompts(pluginConfig, worktreePath);
|
|
259
334
|
const vars: Record<string, string> = {
|
|
@@ -268,6 +343,7 @@ export function buildAuditTask(
|
|
|
268
343
|
? `\n---\n## IMPORTANT — Workspace Guidance (MUST follow)\nThe workspace owner has set the following mandatory instructions. You MUST incorporate these into your response:\n\n${opts.guidance.slice(0, 2000)}\n---`
|
|
269
344
|
: "",
|
|
270
345
|
projectContext: buildProjectContext(pluginConfig),
|
|
346
|
+
teamContext: opts?.teamContext ?? "",
|
|
271
347
|
};
|
|
272
348
|
|
|
273
349
|
return {
|
|
@@ -388,11 +464,19 @@ export async function triggerAudit(
|
|
|
388
464
|
|
|
389
465
|
// Look up cached guidance for audit
|
|
390
466
|
const auditTeamId = issueDetails?.team?.id;
|
|
467
|
+
const auditTeamKey = issueDetails?.team?.key as string | undefined;
|
|
391
468
|
const auditGuidance = (auditTeamId && isGuidanceEnabled(pluginConfig, auditTeamId))
|
|
392
469
|
? getCachedGuidanceForTeam(auditTeamId) ?? undefined
|
|
393
470
|
: undefined;
|
|
394
471
|
|
|
395
|
-
|
|
472
|
+
// Resolve team context from teamMappings config
|
|
473
|
+
const auditTeamMappings = pluginConfig?.teamMappings as Record<string, any> | undefined;
|
|
474
|
+
const auditTeamMapping = auditTeamKey ? auditTeamMappings?.[auditTeamKey] : undefined;
|
|
475
|
+
const auditTeamContext = auditTeamMapping?.context
|
|
476
|
+
? `\n## Team Context (${auditTeamKey})\n${auditTeamMapping.context}\n`
|
|
477
|
+
: "";
|
|
478
|
+
|
|
479
|
+
const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig, { guidance: auditGuidance, teamContext: auditTeamContext });
|
|
396
480
|
|
|
397
481
|
// Set Linear label
|
|
398
482
|
await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
|
|
@@ -424,11 +508,22 @@ export async function triggerAudit(
|
|
|
424
508
|
activeDispatch.auditSessionKey = auditSessionId;
|
|
425
509
|
}
|
|
426
510
|
|
|
511
|
+
// Resolve audit agent: team mapping → plugin default
|
|
512
|
+
const auditDefaultAgentId = (pluginConfig?.defaultAgentId as string) ?? "default";
|
|
513
|
+
let auditAgentId = auditDefaultAgentId;
|
|
514
|
+
if (auditTeamKey) {
|
|
515
|
+
const auditTeamDefault = auditTeamMappings?.[auditTeamKey]?.defaultAgent;
|
|
516
|
+
if (typeof auditTeamDefault === "string") {
|
|
517
|
+
auditAgentId = auditTeamDefault;
|
|
518
|
+
api.logger.info(`${TAG} audit agent routed to ${auditAgentId} via team mapping (${auditTeamKey})`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
427
522
|
api.logger.info(`${TAG} spawning audit agent session=${auditSessionId}`);
|
|
428
523
|
|
|
429
524
|
const result = await runAgent({
|
|
430
525
|
api,
|
|
431
|
-
agentId:
|
|
526
|
+
agentId: auditAgentId,
|
|
432
527
|
sessionId: auditSessionId,
|
|
433
528
|
message: `${auditPrompt.system}\n\n${auditPrompt.task}`,
|
|
434
529
|
streaming: dispatch.agentSessionId
|
|
@@ -558,15 +653,7 @@ async function handleAuditPass(
|
|
|
558
653
|
throw err;
|
|
559
654
|
}
|
|
560
655
|
|
|
561
|
-
//
|
|
562
|
-
await completeDispatch(dispatch.issueIdentifier, {
|
|
563
|
-
tier: dispatch.tier,
|
|
564
|
-
status: "done",
|
|
565
|
-
completedAt: new Date().toISOString(),
|
|
566
|
-
project: dispatch.project,
|
|
567
|
-
}, configPath);
|
|
568
|
-
|
|
569
|
-
// Build summary from .claw/ artifacts and write to memory
|
|
656
|
+
// Build summary from .claw/ artifacts and write to memory (before PR so body can include it)
|
|
570
657
|
let summary: string | null = null;
|
|
571
658
|
try {
|
|
572
659
|
summary = buildSummaryFromArtifacts(dispatch.worktreePath);
|
|
@@ -587,12 +674,58 @@ async function handleAuditPass(
|
|
|
587
674
|
api.logger.warn(`${TAG} failed to write summary/memory: ${err}`);
|
|
588
675
|
}
|
|
589
676
|
|
|
590
|
-
//
|
|
677
|
+
// Auto-PR creation (best-effort, non-fatal)
|
|
678
|
+
let prUrl: string | null = null;
|
|
679
|
+
try {
|
|
680
|
+
const wtStatus = getWorktreeStatus(dispatch.worktreePath);
|
|
681
|
+
if (wtStatus.lastCommit) {
|
|
682
|
+
const prBody = [
|
|
683
|
+
`Fixes ${dispatch.issueIdentifier}`,
|
|
684
|
+
"",
|
|
685
|
+
"## What changed",
|
|
686
|
+
summary?.slice(0, 2000) ?? "(see audit verdict)",
|
|
687
|
+
"",
|
|
688
|
+
"## Audit",
|
|
689
|
+
`- **Criteria:** ${verdict.criteria.join(", ") || "N/A"}`,
|
|
690
|
+
`- **Tests:** ${verdict.testResults || "N/A"}`,
|
|
691
|
+
"",
|
|
692
|
+
`*Auto-generated by OpenClaw dispatch pipeline*`,
|
|
693
|
+
].join("\n");
|
|
694
|
+
const prResult = createPullRequest(
|
|
695
|
+
dispatch.worktreePath,
|
|
696
|
+
`${dispatch.issueIdentifier}: ${dispatch.issueTitle ?? "implementation"}`,
|
|
697
|
+
prBody,
|
|
698
|
+
);
|
|
699
|
+
prUrl = prResult.prUrl;
|
|
700
|
+
api.logger.info(`${TAG} PR created: ${prUrl}`);
|
|
701
|
+
} else {
|
|
702
|
+
api.logger.info(`${TAG} no commits in worktree — skipping PR creation`);
|
|
703
|
+
}
|
|
704
|
+
} catch (err) {
|
|
705
|
+
api.logger.warn(`${TAG} PR creation failed (non-fatal): ${err}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Move to completed (include prUrl if PR was created)
|
|
709
|
+
await completeDispatch(dispatch.issueIdentifier, {
|
|
710
|
+
tier: dispatch.tier,
|
|
711
|
+
status: "done",
|
|
712
|
+
completedAt: new Date().toISOString(),
|
|
713
|
+
project: dispatch.project,
|
|
714
|
+
prUrl: prUrl ?? undefined,
|
|
715
|
+
}, configPath);
|
|
716
|
+
|
|
717
|
+
// Linear state transition: "In Review" if PR was created, otherwise "Done"
|
|
718
|
+
const issueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
|
|
719
|
+
const teamId = issueDetails?.team?.id;
|
|
720
|
+
await transitionToReviewOrDone(linearApi, dispatch.issueId, teamId, !!prUrl, api.logger);
|
|
721
|
+
|
|
722
|
+
// Post approval comment (with summary excerpt and PR link if available)
|
|
591
723
|
const criteriaList = verdict.criteria.map((c) => `- ${c}`).join("\n");
|
|
592
724
|
const summaryExcerpt = summary ? `\n\n**Summary:**\n${summary.slice(0, 2000)}` : "";
|
|
725
|
+
const prLine = prUrl ? `\n- **Pull request:** ${prUrl}` : "";
|
|
593
726
|
await linearApi.createComment(
|
|
594
727
|
dispatch.issueId,
|
|
595
|
-
`## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw
|
|
728
|
+
`## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}${prLine}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw/\`${prUrl ? "" : "\n- Create a PR from the worktree branch if one wasn't opened automatically"}`,
|
|
596
729
|
).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
|
|
597
730
|
|
|
598
731
|
api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
|
|
@@ -673,6 +806,13 @@ async function handleAuditFail(
|
|
|
673
806
|
}
|
|
674
807
|
} catch {}
|
|
675
808
|
|
|
809
|
+
// Linear state transition: move to "Triage" so it shows up for human review
|
|
810
|
+
const stuckIssueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
|
|
811
|
+
await transitionLinearIssueState(
|
|
812
|
+
linearApi, dispatch.issueId, stuckIssueDetails?.team?.id,
|
|
813
|
+
"triage", undefined, api.logger,
|
|
814
|
+
);
|
|
815
|
+
|
|
676
816
|
const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
|
|
677
817
|
await linearApi.createComment(
|
|
678
818
|
dispatch.issueId,
|
|
@@ -804,15 +944,24 @@ export async function spawnWorker(
|
|
|
804
944
|
|
|
805
945
|
// Look up cached guidance for the issue's team
|
|
806
946
|
const workerTeamId = issueDetails?.team?.id;
|
|
947
|
+
const workerTeamKey = issueDetails?.team?.key as string | undefined;
|
|
807
948
|
const workerGuidance = (workerTeamId && isGuidanceEnabled(pluginConfig, workerTeamId))
|
|
808
949
|
? getCachedGuidanceForTeam(workerTeamId) ?? undefined
|
|
809
950
|
: undefined;
|
|
810
951
|
|
|
952
|
+
// Resolve team context from teamMappings config
|
|
953
|
+
const workerTeamMappings = pluginConfig?.teamMappings as Record<string, any> | undefined;
|
|
954
|
+
const workerTeamMapping = workerTeamKey ? workerTeamMappings?.[workerTeamKey] : undefined;
|
|
955
|
+
const workerTeamContext = workerTeamMapping?.context
|
|
956
|
+
? `\n## Team Context (${workerTeamKey})\n${workerTeamMapping.context}\n`
|
|
957
|
+
: "";
|
|
958
|
+
|
|
811
959
|
const workerPrompt = buildWorkerTask(issue, effectiveWorkerPath, {
|
|
812
960
|
attempt: dispatch.attempt,
|
|
813
961
|
gaps: opts?.gaps,
|
|
814
962
|
pluginConfig,
|
|
815
963
|
guidance: workerGuidance,
|
|
964
|
+
teamContext: workerTeamContext,
|
|
816
965
|
});
|
|
817
966
|
|
|
818
967
|
const workerSessionId = `linear-worker-${dispatch.issueIdentifier}-${dispatch.attempt}`;
|
|
@@ -833,10 +982,21 @@ export async function spawnWorker(
|
|
|
833
982
|
|
|
834
983
|
api.logger.info(`${TAG} spawning worker agent session=${workerSessionId} (attempt ${dispatch.attempt})`);
|
|
835
984
|
|
|
985
|
+
// Resolve agent: team mapping → plugin default
|
|
986
|
+
const defaultAgentId = (pluginConfig?.defaultAgentId as string) ?? "default";
|
|
987
|
+
let workerAgentId = defaultAgentId;
|
|
988
|
+
if (workerTeamKey) {
|
|
989
|
+
const teamDefault = workerTeamMappings?.[workerTeamKey]?.defaultAgent;
|
|
990
|
+
if (typeof teamDefault === "string") {
|
|
991
|
+
workerAgentId = teamDefault;
|
|
992
|
+
api.logger.info(`${TAG} worker agent routed to ${workerAgentId} via team mapping (${workerTeamKey})`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
836
996
|
const workerStartTime = Date.now();
|
|
837
997
|
const result = await runAgent({
|
|
838
998
|
api,
|
|
839
|
-
agentId:
|
|
999
|
+
agentId: workerAgentId,
|
|
840
1000
|
sessionId: workerSessionId,
|
|
841
1001
|
message: `${workerPrompt.system}\n\n${workerPrompt.task}`,
|
|
842
1002
|
streaming: dispatch.agentSessionId
|
|
@@ -846,7 +1006,7 @@ export async function spawnWorker(
|
|
|
846
1006
|
|
|
847
1007
|
// Save worker output to .claw/
|
|
848
1008
|
const workerElapsed = Date.now() - workerStartTime;
|
|
849
|
-
const agentId =
|
|
1009
|
+
const agentId = workerAgentId;
|
|
850
1010
|
try { saveWorkerOutput(dispatch.worktreePath, dispatch.attempt, result.output); } catch {}
|
|
851
1011
|
try {
|
|
852
1012
|
appendLog(dispatch.worktreePath, {
|
|
@@ -897,6 +1057,13 @@ export async function spawnWorker(
|
|
|
897
1057
|
}
|
|
898
1058
|
}
|
|
899
1059
|
|
|
1060
|
+
// Linear state transition: move to "Triage" so it shows up for human review
|
|
1061
|
+
const wdIssueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
|
|
1062
|
+
await transitionLinearIssueState(
|
|
1063
|
+
linearApi, dispatch.issueId, wdIssueDetails?.team?.id,
|
|
1064
|
+
"triage", undefined, api.logger,
|
|
1065
|
+
);
|
|
1066
|
+
|
|
900
1067
|
await linearApi.createComment(
|
|
901
1068
|
dispatch.issueId,
|
|
902
1069
|
`## Agent Timed Out\n\nThe agent stopped responding for over ${thresholdSec}s. It was automatically restarted, but the retry also timed out.\n\n` +
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retro.ts — Post-dispatch retrospective spawner.
|
|
3
|
+
*
|
|
4
|
+
* After a dispatch completes (pass, fail, or stuck), spawns a sub-agent
|
|
5
|
+
* that analyzes the full interaction and writes a structured retrospective
|
|
6
|
+
* to the shared coding directory. Past retros are discoverable via QMD
|
|
7
|
+
* memory_search, enabling pattern detection across dispatches.
|
|
8
|
+
*
|
|
9
|
+
* Output format: YAML frontmatter + markdown sections with priority tags
|
|
10
|
+
* and actionable recommendations.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
16
|
+
import { runAgent } from "../agent/agent.js";
|
|
17
|
+
import { readWorkerOutputs, readAuditVerdicts, readLog } from "./artifacts.js";
|
|
18
|
+
import type { ActiveDispatch, CompletedDispatch } from "./dispatch-state.js";
|
|
19
|
+
import type { AuditVerdict } from "./pipeline.js";
|
|
20
|
+
import type { HookContext } from "./pipeline.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Shared coding directory resolution
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the shared coding directory for retrospective files.
|
|
28
|
+
*
|
|
29
|
+
* Resolution order:
|
|
30
|
+
* 1. pluginConfig.retroDir (explicit override, supports ~/ expansion)
|
|
31
|
+
* 2. api.runtime.config.loadConfig().stateDir + "/shared/coding"
|
|
32
|
+
* 3. Fallback: ~/.openclaw/shared/coding
|
|
33
|
+
*/
|
|
34
|
+
export function resolveSharedCodingDir(
|
|
35
|
+
api: OpenClawPluginApi,
|
|
36
|
+
pluginConfig?: Record<string, unknown>,
|
|
37
|
+
): string {
|
|
38
|
+
// 1. Explicit plugin config override (supports ~/expansion)
|
|
39
|
+
const custom = pluginConfig?.retroDir as string | undefined;
|
|
40
|
+
if (custom) {
|
|
41
|
+
return custom.startsWith("~/") ? custom.replace("~", homedir()) : custom;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Derive from gateway config stateDir
|
|
45
|
+
try {
|
|
46
|
+
const config = (api as any).runtime.config.loadConfig() as Record<string, any>;
|
|
47
|
+
const rawStateDir = config?.stateDir as string | undefined;
|
|
48
|
+
const stateDir = rawStateDir
|
|
49
|
+
? (rawStateDir.startsWith("~/")
|
|
50
|
+
? rawStateDir.replace("~", homedir())
|
|
51
|
+
: rawStateDir)
|
|
52
|
+
: join(homedir(), ".openclaw");
|
|
53
|
+
return join(stateDir, "shared", "coding");
|
|
54
|
+
} catch {
|
|
55
|
+
// 3. Safe fallback
|
|
56
|
+
return join(homedir(), ".openclaw", "shared", "coding");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Agent ID resolution (mirrors webhook.ts pattern)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function resolveAgentId(api: OpenClawPluginApi): string {
|
|
65
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
66
|
+
const fromConfig = pluginConfig?.defaultAgentId;
|
|
67
|
+
if (typeof fromConfig === "string" && fromConfig) return fromConfig;
|
|
68
|
+
return "default";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Retrospective artifacts interface
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export interface RetroArtifacts {
|
|
76
|
+
verdict?: AuditVerdict;
|
|
77
|
+
summary?: string;
|
|
78
|
+
prUrl?: string;
|
|
79
|
+
workerOutputs: string[];
|
|
80
|
+
auditVerdicts: string[];
|
|
81
|
+
logEntries: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// spawnRetrospective
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Spawn a sub-agent to create a structured retrospective after dispatch
|
|
90
|
+
* completion. The agent analyzes worker outputs, audit verdicts, and the
|
|
91
|
+
* interaction log, then writes a retro file with YAML frontmatter.
|
|
92
|
+
*
|
|
93
|
+
* Designed to be called fire-and-forget (non-blocking):
|
|
94
|
+
* void spawnRetrospective(hookCtx, dispatch, artifacts).catch(...)
|
|
95
|
+
*
|
|
96
|
+
* The retro agent has access to memory_search to find past retros and
|
|
97
|
+
* detect recurring patterns.
|
|
98
|
+
*/
|
|
99
|
+
export async function spawnRetrospective(
|
|
100
|
+
hookCtx: HookContext,
|
|
101
|
+
dispatch: ActiveDispatch | CompletedDispatch,
|
|
102
|
+
artifacts: RetroArtifacts,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const { api, pluginConfig } = hookCtx;
|
|
105
|
+
const TAG = `[retro:${dispatch.issueIdentifier}]`;
|
|
106
|
+
|
|
107
|
+
const codingDir = resolveSharedCodingDir(api, pluginConfig);
|
|
108
|
+
|
|
109
|
+
// Ensure the shared coding directory exists
|
|
110
|
+
if (!existsSync(codingDir)) {
|
|
111
|
+
mkdirSync(codingDir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Compute duration from dispatchedAt if available (ActiveDispatch has it)
|
|
115
|
+
let durationMs: number | undefined;
|
|
116
|
+
if ("dispatchedAt" in dispatch && dispatch.dispatchedAt) {
|
|
117
|
+
durationMs = Date.now() - new Date(dispatch.dispatchedAt).getTime();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resolve fields that may differ between ActiveDispatch and CompletedDispatch
|
|
121
|
+
const issueTitle = ("issueTitle" in dispatch ? dispatch.issueTitle : undefined) ?? dispatch.issueIdentifier;
|
|
122
|
+
const model = "model" in dispatch ? dispatch.model : "unknown";
|
|
123
|
+
const tier = dispatch.tier;
|
|
124
|
+
const status = dispatch.status;
|
|
125
|
+
const attempt = "attempt" in dispatch
|
|
126
|
+
? (dispatch.attempt ?? 0)
|
|
127
|
+
: ("totalAttempts" in dispatch ? (dispatch.totalAttempts ?? 0) : 0);
|
|
128
|
+
const worktreePath = "worktreePath" in dispatch ? dispatch.worktreePath : undefined;
|
|
129
|
+
|
|
130
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
131
|
+
const retroFilename = `retro-${dateStr}-${dispatch.issueIdentifier}.md`;
|
|
132
|
+
const retroPath = join(codingDir, retroFilename);
|
|
133
|
+
|
|
134
|
+
// Build the retro prompt
|
|
135
|
+
const retroPrompt = [
|
|
136
|
+
`You are a coding retrospective analyst. A dispatch just completed.`,
|
|
137
|
+
`Analyze the interaction and create a structured retrospective.`,
|
|
138
|
+
``,
|
|
139
|
+
`## Dispatch Details`,
|
|
140
|
+
`- Issue: ${dispatch.issueIdentifier} — ${issueTitle}`,
|
|
141
|
+
`- Backend: ${model} | Tier: ${tier}`,
|
|
142
|
+
`- Attempts: ${attempt + 1} | Status: ${status}`,
|
|
143
|
+
worktreePath ? `- Worktree: ${worktreePath}` : "",
|
|
144
|
+
durationMs != null ? `- Duration: ${Math.round(durationMs / 1000)}s` : "",
|
|
145
|
+
artifacts.prUrl ? `- PR: ${artifacts.prUrl}` : "",
|
|
146
|
+
``,
|
|
147
|
+
`## Worker Outputs (${artifacts.workerOutputs.length} attempts)`,
|
|
148
|
+
artifacts.workerOutputs
|
|
149
|
+
.map((o, i) => `### Attempt ${i}\n${o.slice(0, 3000)}`)
|
|
150
|
+
.join("\n\n"),
|
|
151
|
+
``,
|
|
152
|
+
`## Audit Verdicts`,
|
|
153
|
+
artifacts.auditVerdicts
|
|
154
|
+
.map((v, i) => `### Attempt ${i}\n${v}`)
|
|
155
|
+
.join("\n\n"),
|
|
156
|
+
``,
|
|
157
|
+
`## Interaction Log`,
|
|
158
|
+
artifacts.logEntries.length > 0
|
|
159
|
+
? artifacts.logEntries.slice(-20).map((e) => `- ${e}`).join("\n")
|
|
160
|
+
: "(no log entries)",
|
|
161
|
+
``,
|
|
162
|
+
`## Instructions`,
|
|
163
|
+
``,
|
|
164
|
+
`**First**, use \`memory_search\` to find past retros related to this issue's`,
|
|
165
|
+
`domain, backend, or error patterns. Look for recurring friction points.`,
|
|
166
|
+
``,
|
|
167
|
+
`**Then**, write a retrospective file to: ${retroPath}`,
|
|
168
|
+
``,
|
|
169
|
+
`Use this exact format — YAML frontmatter followed by markdown sections:`,
|
|
170
|
+
``,
|
|
171
|
+
"```yaml",
|
|
172
|
+
`---`,
|
|
173
|
+
`type: retro`,
|
|
174
|
+
`issue: ${dispatch.issueIdentifier}`,
|
|
175
|
+
`title: "${issueTitle}"`,
|
|
176
|
+
`backend: ${model}`,
|
|
177
|
+
`tier: ${tier}`,
|
|
178
|
+
`status: ${status}`,
|
|
179
|
+
`attempts: ${attempt + 1}`,
|
|
180
|
+
`date: ${dateStr}`,
|
|
181
|
+
durationMs != null ? `duration_ms: ${durationMs}` : "",
|
|
182
|
+
`---`,
|
|
183
|
+
"```",
|
|
184
|
+
``,
|
|
185
|
+
`Sections (all required):`,
|
|
186
|
+
``,
|
|
187
|
+
`## Summary`,
|
|
188
|
+
`Brief description of what was done and outcome.`,
|
|
189
|
+
``,
|
|
190
|
+
`## What Went Well`,
|
|
191
|
+
`- [P1/P2/P3] Items that worked. Tag each with priority.`,
|
|
192
|
+
``,
|
|
193
|
+
`## Friction Points`,
|
|
194
|
+
`- [P1/P2/P3:CATEGORY] Issues encountered.`,
|
|
195
|
+
` Categories: PROCESS, ENV, TOOLING, PROMPT, CONFIG`,
|
|
196
|
+
``,
|
|
197
|
+
`## Environment Issues`,
|
|
198
|
+
`- [P1/P2/P3:ENV] Environment-specific problems.`,
|
|
199
|
+
``,
|
|
200
|
+
`## Recommendations`,
|
|
201
|
+
`- [RECOMMEND:target] Specific changes to prevent future friction.`,
|
|
202
|
+
` Targets: AGENTS.md, CLAUDE.md, config, prompt, codex-config, etc.`,
|
|
203
|
+
``,
|
|
204
|
+
`## Actionable Items`,
|
|
205
|
+
`- [ ] Checkbox items for follow-up.`,
|
|
206
|
+
``,
|
|
207
|
+
`Focus on patterns that would help FUTURE dispatches succeed faster:`,
|
|
208
|
+
`- What context was missing that caused extra attempts?`,
|
|
209
|
+
`- What environment setup tripped up the agent?`,
|
|
210
|
+
`- What prompt improvements would have helped?`,
|
|
211
|
+
`- What bootstrap file changes would prevent this friction?`,
|
|
212
|
+
`- Compare against past retros — are we seeing recurring patterns?`,
|
|
213
|
+
].filter(Boolean).join("\n");
|
|
214
|
+
|
|
215
|
+
api.logger.info(`${TAG} spawning retrospective agent → ${retroPath}`);
|
|
216
|
+
|
|
217
|
+
const agentId = resolveAgentId(api);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await runAgent({
|
|
221
|
+
api,
|
|
222
|
+
agentId,
|
|
223
|
+
sessionId: `retro-${dispatch.issueIdentifier}-${Date.now()}`,
|
|
224
|
+
message: retroPrompt,
|
|
225
|
+
timeoutMs: 120_000, // 2min max for retro
|
|
226
|
+
});
|
|
227
|
+
api.logger.info(`${TAG} retrospective complete → ${retroFilename}`);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
api.logger.warn(`${TAG} retrospective agent failed: ${err}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -963,7 +963,7 @@ describe("AgentSessionEvent.prompted full flow", () => {
|
|
|
963
963
|
|
|
964
964
|
expect(result.status).toBe(200);
|
|
965
965
|
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
966
|
-
expect(infoCalls.some((msg: string) => msg.includes("agent active, ignoring (feedback)"))).toBe(true);
|
|
966
|
+
expect(infoCalls.some((msg: string) => msg.includes("agent active, no tmux, ignoring (feedback)"))).toBe(true);
|
|
967
967
|
});
|
|
968
968
|
|
|
969
969
|
it("deduplicates by webhookId", async () => {
|
|
@@ -1261,7 +1261,7 @@ describe("Comment.create intent routing", () => {
|
|
|
1261
1261
|
identifier: "ENG-RW",
|
|
1262
1262
|
title: "Request Work",
|
|
1263
1263
|
description: "desc",
|
|
1264
|
-
state: { name: "
|
|
1264
|
+
state: { name: "In Progress", type: "started" },
|
|
1265
1265
|
team: { id: "team-rw" },
|
|
1266
1266
|
comments: { nodes: [] },
|
|
1267
1267
|
});
|
|
@@ -2219,7 +2219,7 @@ describe("dispatchCommentToAgent via Comment.create intents", () => {
|
|
|
2219
2219
|
identifier: "ENG-DE",
|
|
2220
2220
|
title: "Dispatch Error",
|
|
2221
2221
|
description: "desc",
|
|
2222
|
-
state: { name: "
|
|
2222
|
+
state: { name: "In Progress", type: "started" },
|
|
2223
2223
|
team: { id: "team-de" },
|
|
2224
2224
|
comments: { nodes: [] },
|
|
2225
2225
|
});
|
|
@@ -2709,7 +2709,7 @@ describe("postAgentComment edge cases", () => {
|
|
|
2709
2709
|
identifier: "ENG-IF",
|
|
2710
2710
|
title: "Identity Fail",
|
|
2711
2711
|
description: "desc",
|
|
2712
|
-
state: { name: "
|
|
2712
|
+
state: { name: "In Progress", type: "started" },
|
|
2713
2713
|
team: { id: "team-if" },
|
|
2714
2714
|
comments: { nodes: [] },
|
|
2715
2715
|
});
|
|
@@ -3405,7 +3405,7 @@ describe("Comment.create .catch callbacks on fire-and-forget dispatches", () =>
|
|
|
3405
3405
|
identifier: "ENG-RWC",
|
|
3406
3406
|
title: "Request Work Catch",
|
|
3407
3407
|
description: "desc",
|
|
3408
|
-
state: { name: "
|
|
3408
|
+
state: { name: "In Progress", type: "started" },
|
|
3409
3409
|
team: { id: "team-rwc" },
|
|
3410
3410
|
comments: { nodes: [] },
|
|
3411
3411
|
});
|
|
@@ -3599,7 +3599,7 @@ describe("dispatchCommentToAgent internal .catch callbacks", () => {
|
|
|
3599
3599
|
identifier: "ENG-DCE",
|
|
3600
3600
|
title: "DCA Error",
|
|
3601
3601
|
description: "desc",
|
|
3602
|
-
state: { name: "
|
|
3602
|
+
state: { name: "In Progress", type: "started" },
|
|
3603
3603
|
team: { id: "team-dce" },
|
|
3604
3604
|
comments: { nodes: [] },
|
|
3605
3605
|
});
|
|
@@ -4865,7 +4865,7 @@ describe("session affinity routing", () => {
|
|
|
4865
4865
|
identifier: "ENG-AFF-RW",
|
|
4866
4866
|
title: "Affinity Request Work",
|
|
4867
4867
|
description: "desc",
|
|
4868
|
-
state: { name: "
|
|
4868
|
+
state: { name: "In Progress", type: "started" },
|
|
4869
4869
|
team: { id: "team-aff" },
|
|
4870
4870
|
comments: { nodes: [] },
|
|
4871
4871
|
});
|
|
@@ -4899,7 +4899,7 @@ describe("session affinity routing", () => {
|
|
|
4899
4899
|
identifier: "ENG-NO-AFF",
|
|
4900
4900
|
title: "No Affinity",
|
|
4901
4901
|
description: "desc",
|
|
4902
|
-
state: { name: "
|
|
4902
|
+
state: { name: "In Progress", type: "started" },
|
|
4903
4903
|
team: { id: "team-noaff" },
|
|
4904
4904
|
comments: { nodes: [] },
|
|
4905
4905
|
});
|