@calltelemetry/openclaw-linear 0.9.15 → 0.9.17
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 +7 -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 +225 -0
- package/src/infra/tmux.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/pipeline.ts +184 -17
- package/src/pipeline/webhook.test.ts +1 -1
- package/src/pipeline/webhook.ts +271 -30
- 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/planner-tools.ts +1 -0
- package/src/tools/steering-tools.ts +141 -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/infra/multi-repo.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* multi-repo.ts — Multi-repo resolution for dispatches spanning multiple git repos.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Four-tier resolution:
|
|
5
5
|
* 1. Issue body markers: <!-- repos: api, frontend --> or [repos: api, frontend]
|
|
6
6
|
* 2. Linear labels: repo:api, repo:frontend
|
|
7
|
-
* 3.
|
|
7
|
+
* 3. Team mapping: teamMappings[teamKey].repos from plugin config
|
|
8
|
+
* 4. Config default: Falls back to single codexBaseRepo
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { existsSync, statSync } from "node:fs";
|
|
@@ -18,7 +19,55 @@ export interface RepoConfig {
|
|
|
18
19
|
|
|
19
20
|
export interface RepoResolution {
|
|
20
21
|
repos: RepoConfig[];
|
|
21
|
-
source: "issue_body" | "labels" | "config_default";
|
|
22
|
+
source: "issue_body" | "labels" | "team_mapping" | "config_default";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Enriched repo entry — filesystem path + optional GitHub identity.
|
|
27
|
+
* Supports both plain string paths (backward compat) and objects.
|
|
28
|
+
*/
|
|
29
|
+
export interface RepoEntry {
|
|
30
|
+
path: string;
|
|
31
|
+
github?: string; // "owner/repo" format
|
|
32
|
+
hostname?: string; // defaults to "github.com"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse the repos config, normalizing both string and object formats.
|
|
37
|
+
* String values become { path: value }, objects pass through.
|
|
38
|
+
*/
|
|
39
|
+
export function getRepoEntries(pluginConfig?: Record<string, unknown>): Record<string, RepoEntry> {
|
|
40
|
+
const repos = pluginConfig?.repos as Record<string, string | Record<string, unknown>> | undefined;
|
|
41
|
+
if (!repos) return {};
|
|
42
|
+
const result: Record<string, RepoEntry> = {};
|
|
43
|
+
for (const [name, value] of Object.entries(repos)) {
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
result[name] = { path: value };
|
|
46
|
+
} else if (value && typeof value === "object") {
|
|
47
|
+
result[name] = {
|
|
48
|
+
path: (value as any).path as string,
|
|
49
|
+
github: (value as any).github as string | undefined,
|
|
50
|
+
hostname: (value as any).hostname as string | undefined,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build candidate repositories for Linear's issueRepositorySuggestions API.
|
|
59
|
+
* Extracts GitHub identity from enriched repo entries.
|
|
60
|
+
*/
|
|
61
|
+
export function buildCandidateRepositories(
|
|
62
|
+
pluginConfig?: Record<string, unknown>,
|
|
63
|
+
): Array<{ hostname: string; repositoryFullName: string }> {
|
|
64
|
+
const entries = getRepoEntries(pluginConfig);
|
|
65
|
+
return Object.values(entries)
|
|
66
|
+
.filter(e => e.github)
|
|
67
|
+
.map(e => ({
|
|
68
|
+
hostname: e.hostname ?? "github.com",
|
|
69
|
+
repositoryFullName: e.github!,
|
|
70
|
+
}));
|
|
22
71
|
}
|
|
23
72
|
|
|
24
73
|
/**
|
|
@@ -28,6 +77,7 @@ export function resolveRepos(
|
|
|
28
77
|
description: string | null | undefined,
|
|
29
78
|
labels: string[],
|
|
30
79
|
pluginConfig?: Record<string, unknown>,
|
|
80
|
+
teamKey?: string,
|
|
31
81
|
): RepoResolution {
|
|
32
82
|
// 1. Check issue body for repo markers
|
|
33
83
|
// Match: <!-- repos: name1, name2 --> or [repos: name1, name2]
|
|
@@ -62,7 +112,21 @@ export function resolveRepos(
|
|
|
62
112
|
return { repos, source: "labels" };
|
|
63
113
|
}
|
|
64
114
|
|
|
65
|
-
// 3.
|
|
115
|
+
// 3. Team mapping: teamMappings[teamKey].repos
|
|
116
|
+
if (teamKey) {
|
|
117
|
+
const teamMappings = pluginConfig?.teamMappings as Record<string, Record<string, unknown>> | undefined;
|
|
118
|
+
const teamRepoNames = teamMappings?.[teamKey]?.repos as string[] | undefined;
|
|
119
|
+
if (teamRepoNames && teamRepoNames.length > 0) {
|
|
120
|
+
const repoMap = getRepoMap(pluginConfig);
|
|
121
|
+
const repos = teamRepoNames.map(name => ({
|
|
122
|
+
name,
|
|
123
|
+
path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
|
|
124
|
+
}));
|
|
125
|
+
return { repos, source: "team_mapping" };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 4. Config default: single repo
|
|
66
130
|
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? path.join(homedir(), "ai-workspace");
|
|
67
131
|
return {
|
|
68
132
|
repos: [{ name: "default", path: baseRepo }],
|
|
@@ -71,8 +135,12 @@ export function resolveRepos(
|
|
|
71
135
|
}
|
|
72
136
|
|
|
73
137
|
function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, string> {
|
|
74
|
-
const
|
|
75
|
-
|
|
138
|
+
const entries = getRepoEntries(pluginConfig);
|
|
139
|
+
const result: Record<string, string> = {};
|
|
140
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
141
|
+
result[name] = entry.path;
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
76
144
|
}
|
|
77
145
|
|
|
78
146
|
function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { execSync, spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { mkdirSync, createWriteStream } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import type { ActivityContent, LinearAgentApi } from "../api/linear-api.js";
|
|
6
|
+
import type { CliResult, OnProgressUpdate } from "../tools/cli-shared.js";
|
|
7
|
+
import { formatActivityLogLine, createProgressEmitter } from "../tools/cli-shared.js";
|
|
8
|
+
import { InactivityWatchdog } from "../agent/watchdog.js";
|
|
9
|
+
import { shellEscape } from "./tmux.js";
|
|
10
|
+
|
|
11
|
+
export interface TmuxSession {
|
|
12
|
+
sessionName: string;
|
|
13
|
+
backend: string;
|
|
14
|
+
issueIdentifier: string;
|
|
15
|
+
issueId: string;
|
|
16
|
+
steeringMode: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunInTmuxOptions {
|
|
20
|
+
issueId: string;
|
|
21
|
+
issueIdentifier: string;
|
|
22
|
+
sessionName: string;
|
|
23
|
+
command: string;
|
|
24
|
+
cwd: string;
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
watchdogMs: number;
|
|
27
|
+
logPath: string;
|
|
28
|
+
mapEvent: (event: any) => ActivityContent | null;
|
|
29
|
+
linearApi?: LinearAgentApi;
|
|
30
|
+
agentSessionId?: string;
|
|
31
|
+
steeringMode: "stdin-pipe" | "one-shot";
|
|
32
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void };
|
|
33
|
+
onUpdate?: OnProgressUpdate;
|
|
34
|
+
progressHeader: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Track active tmux sessions by issueId
|
|
38
|
+
const activeSessions = new Map<string, TmuxSession>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the active tmux session for a given issueId, or null if none.
|
|
42
|
+
*/
|
|
43
|
+
export function getActiveTmuxSession(issueId: string): TmuxSession | null {
|
|
44
|
+
return activeSessions.get(issueId) ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run a command inside a tmux session with pipe-pane streaming to a JSONL log.
|
|
49
|
+
* Monitors the log file for events and streams them to Linear.
|
|
50
|
+
*/
|
|
51
|
+
export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
52
|
+
const {
|
|
53
|
+
issueId,
|
|
54
|
+
issueIdentifier,
|
|
55
|
+
sessionName,
|
|
56
|
+
command,
|
|
57
|
+
cwd,
|
|
58
|
+
timeoutMs,
|
|
59
|
+
watchdogMs,
|
|
60
|
+
logPath,
|
|
61
|
+
mapEvent,
|
|
62
|
+
linearApi,
|
|
63
|
+
agentSessionId,
|
|
64
|
+
steeringMode,
|
|
65
|
+
logger,
|
|
66
|
+
onUpdate,
|
|
67
|
+
progressHeader,
|
|
68
|
+
} = opts;
|
|
69
|
+
|
|
70
|
+
// Ensure log directory exists
|
|
71
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
72
|
+
|
|
73
|
+
// Register active session
|
|
74
|
+
const session: TmuxSession = {
|
|
75
|
+
sessionName,
|
|
76
|
+
backend: sessionName.split("-").slice(-2, -1)[0] ?? "unknown",
|
|
77
|
+
issueIdentifier,
|
|
78
|
+
issueId,
|
|
79
|
+
steeringMode,
|
|
80
|
+
};
|
|
81
|
+
activeSessions.set(issueId, session);
|
|
82
|
+
|
|
83
|
+
const progress = createProgressEmitter({ header: progressHeader, onUpdate });
|
|
84
|
+
progress.emitHeader();
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
// Create tmux session running the command, piping output to logPath
|
|
88
|
+
const tmuxCmd = [
|
|
89
|
+
`tmux new-session -d -s ${shellEscape(sessionName)} -c ${shellEscape(cwd)}`,
|
|
90
|
+
`${shellEscape(command)} 2>&1 | tee ${shellEscape(logPath)}`,
|
|
91
|
+
].join(" ");
|
|
92
|
+
|
|
93
|
+
execSync(tmuxCmd, { stdio: "ignore", timeout: 10_000 });
|
|
94
|
+
|
|
95
|
+
// Tail the log file and process JSONL events
|
|
96
|
+
return await new Promise<CliResult>((resolve) => {
|
|
97
|
+
const tail = spawn("tail", ["-f", "-n", "+1", logPath], {
|
|
98
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let killed = false;
|
|
102
|
+
let killedByWatchdog = false;
|
|
103
|
+
const collectedMessages: string[] = [];
|
|
104
|
+
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
killed = true;
|
|
107
|
+
cleanup("timeout");
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
|
|
110
|
+
const watchdog = new InactivityWatchdog({
|
|
111
|
+
inactivityMs: watchdogMs,
|
|
112
|
+
label: `tmux:${sessionName}`,
|
|
113
|
+
logger,
|
|
114
|
+
onKill: () => {
|
|
115
|
+
killedByWatchdog = true;
|
|
116
|
+
killed = true;
|
|
117
|
+
cleanup("inactivity_timeout");
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
watchdog.start();
|
|
121
|
+
|
|
122
|
+
function cleanup(reason: string) {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
watchdog.stop();
|
|
125
|
+
tail.kill();
|
|
126
|
+
|
|
127
|
+
// Kill the tmux session
|
|
128
|
+
try {
|
|
129
|
+
execSync(`tmux kill-session -t ${shellEscape(sessionName)}`, {
|
|
130
|
+
stdio: "ignore",
|
|
131
|
+
timeout: 5_000,
|
|
132
|
+
});
|
|
133
|
+
} catch { /* session may already be gone */ }
|
|
134
|
+
|
|
135
|
+
activeSessions.delete(issueId);
|
|
136
|
+
|
|
137
|
+
const output = collectedMessages.join("\n\n") || "(no output)";
|
|
138
|
+
|
|
139
|
+
if (reason === "inactivity_timeout") {
|
|
140
|
+
logger.warn(`tmux session ${sessionName} killed by inactivity watchdog`);
|
|
141
|
+
resolve({
|
|
142
|
+
success: false,
|
|
143
|
+
output: `Agent killed by inactivity watchdog (no I/O for ${Math.round(watchdogMs / 1000)}s). Partial output:\n${output}`,
|
|
144
|
+
error: "inactivity_timeout",
|
|
145
|
+
});
|
|
146
|
+
} else if (reason === "timeout") {
|
|
147
|
+
logger.warn(`tmux session ${sessionName} timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
148
|
+
resolve({
|
|
149
|
+
success: false,
|
|
150
|
+
output: `Agent timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
|
|
151
|
+
error: "timeout",
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
// Normal completion
|
|
155
|
+
resolve({ success: true, output });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const rl = createInterface({ input: tail.stdout! });
|
|
160
|
+
rl.on("line", (line) => {
|
|
161
|
+
if (!line.trim()) return;
|
|
162
|
+
watchdog.tick();
|
|
163
|
+
|
|
164
|
+
let event: any;
|
|
165
|
+
try {
|
|
166
|
+
event = JSON.parse(line);
|
|
167
|
+
} catch {
|
|
168
|
+
collectedMessages.push(line);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Collect text for output
|
|
173
|
+
if (event.type === "assistant") {
|
|
174
|
+
const content = event.message?.content;
|
|
175
|
+
if (Array.isArray(content)) {
|
|
176
|
+
for (const block of content) {
|
|
177
|
+
if (block.type === "text" && block.text) {
|
|
178
|
+
collectedMessages.push(block.text);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Stream to Linear
|
|
185
|
+
const activity = mapEvent(event);
|
|
186
|
+
if (activity) {
|
|
187
|
+
if (linearApi && agentSessionId) {
|
|
188
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
189
|
+
logger.warn(`Failed to emit tmux activity: ${err}`);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
progress.push(formatActivityLogLine(activity));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Detect completion
|
|
196
|
+
if (event.type === "result") {
|
|
197
|
+
cleanup("done");
|
|
198
|
+
rl.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Handle tail process ending (tmux session completed)
|
|
203
|
+
tail.on("close", () => {
|
|
204
|
+
if (!killed) {
|
|
205
|
+
cleanup("done");
|
|
206
|
+
}
|
|
207
|
+
rl.close();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
tail.on("error", (err) => {
|
|
211
|
+
logger.error(`tmux tail error: ${err}`);
|
|
212
|
+
cleanup("error");
|
|
213
|
+
rl.close();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
} catch (err) {
|
|
217
|
+
activeSessions.delete(issueId);
|
|
218
|
+
logger.error(`runInTmux failed: ${err}`);
|
|
219
|
+
return {
|
|
220
|
+
success: false,
|
|
221
|
+
output: `Failed to start tmux session: ${err}`,
|
|
222
|
+
error: String(err),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if tmux is available on the system.
|
|
5
|
+
*/
|
|
6
|
+
export function isTmuxAvailable(): boolean {
|
|
7
|
+
try {
|
|
8
|
+
execSync("tmux -V", { stdio: "ignore" });
|
|
9
|
+
return true;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a deterministic tmux session name from issue identifier, backend, and index.
|
|
17
|
+
*/
|
|
18
|
+
export function buildSessionName(identifier: string, backend: string, index: number): string {
|
|
19
|
+
// tmux session names can't contain dots or colons — sanitize
|
|
20
|
+
const safe = `${identifier}-${backend}-${index}`.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
21
|
+
return `claw-${safe}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Escape a string for safe shell interpolation (single-quote wrapping).
|
|
26
|
+
*/
|
|
27
|
+
export function shellEscape(value: string): string {
|
|
28
|
+
// Wrap in single quotes, escaping any embedded single quotes
|
|
29
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Capture the last N lines from a tmux session pane.
|
|
34
|
+
*/
|
|
35
|
+
export function capturePane(sessionName: string, lines: number): string {
|
|
36
|
+
try {
|
|
37
|
+
return execSync(
|
|
38
|
+
`tmux capture-pane -t ${shellEscape(sessionName)} -p -S -${lines}`,
|
|
39
|
+
{ encoding: "utf8", timeout: 5_000 },
|
|
40
|
+
).trimEnd();
|
|
41
|
+
} catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* active-session.ts — Idempotent registry of active Linear agent sessions.
|
|
3
3
|
*
|
|
4
4
|
* When the pipeline starts work on an issue, it registers the session here.
|
|
5
|
-
* Any tool (
|
|
5
|
+
* Any tool (cli_codex, cli_claude, etc.) can look up the active session for the current
|
|
6
6
|
* issue to stream activities without relying on the LLM agent to pass params.
|
|
7
7
|
*
|
|
8
8
|
* This runs in the gateway process. Tool execution also happens in the gateway,
|
|
@@ -96,6 +96,24 @@ export function getCurrentSession(): ActiveSession | null {
|
|
|
96
96
|
return null;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Look up the most recent active session for a given agent ID.
|
|
101
|
+
* When multiple sessions exist for the same agent, returns the most
|
|
102
|
+
* recently started one. This is the primary lookup for tool execution
|
|
103
|
+
* contexts where the agent ID is known but the issue isn't.
|
|
104
|
+
*/
|
|
105
|
+
export function getActiveSessionByAgentId(agentId: string): ActiveSession | null {
|
|
106
|
+
let best: ActiveSession | null = null;
|
|
107
|
+
for (const session of sessions.values()) {
|
|
108
|
+
if (session.agentId === agentId) {
|
|
109
|
+
if (!best || session.startedAt > best.startedAt) {
|
|
110
|
+
best = session;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return best;
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
/**
|
|
100
118
|
* Hydrate the in-memory session Map from dispatch-state.json.
|
|
101
119
|
* Called on startup by the dispatch service to restore sessions
|
|
@@ -307,3 +307,45 @@ export function resolveOrchestratorWorkspace(
|
|
|
307
307
|
return join(home, ".openclaw", "workspace");
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Read worker output artifacts for all attempts.
|
|
313
|
+
*/
|
|
314
|
+
export function readWorkerOutputs(worktreePath: string, maxAttempt: number): string[] {
|
|
315
|
+
const outputs: string[] = [];
|
|
316
|
+
for (let i = 0; i <= maxAttempt; i++) {
|
|
317
|
+
try {
|
|
318
|
+
outputs.push(readFileSync(join(clawDir(worktreePath), `worker-${i}.md`), "utf-8"));
|
|
319
|
+
} catch {
|
|
320
|
+
outputs.push("(not found)");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return outputs;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Read audit verdict artifacts for all attempts.
|
|
328
|
+
*/
|
|
329
|
+
export function readAuditVerdicts(worktreePath: string, maxAttempt: number): string[] {
|
|
330
|
+
const verdicts: string[] = [];
|
|
331
|
+
for (let i = 0; i <= maxAttempt; i++) {
|
|
332
|
+
try {
|
|
333
|
+
verdicts.push(readFileSync(join(clawDir(worktreePath), `audit-${i}.json`), "utf-8"));
|
|
334
|
+
} catch {
|
|
335
|
+
verdicts.push("(not found)");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return verdicts;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Read the interaction log entries.
|
|
343
|
+
*/
|
|
344
|
+
export function readLog(worktreePath: string): string[] {
|
|
345
|
+
try {
|
|
346
|
+
const raw = readFileSync(join(clawDir(worktreePath), "log.jsonl"), "utf-8");
|
|
347
|
+
return raw.trim().split("\n").filter(Boolean);
|
|
348
|
+
} catch {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -69,6 +69,8 @@ export interface CompletedDispatch {
|
|
|
69
69
|
prUrl?: string;
|
|
70
70
|
project?: string;
|
|
71
71
|
totalAttempts?: number;
|
|
72
|
+
worktreePath?: string;
|
|
73
|
+
cleanedUp?: boolean;
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/** Maps session keys to dispatch context for agent_end hook lookup */
|
|
@@ -370,6 +372,7 @@ export async function completeDispatch(
|
|
|
370
372
|
prUrl: result.prUrl,
|
|
371
373
|
project: active?.project ?? result.project,
|
|
372
374
|
totalAttempts: active?.attempt ?? 0,
|
|
375
|
+
worktreePath: active?.worktreePath,
|
|
373
376
|
};
|
|
374
377
|
await writeDispatchState(filePath, data);
|
|
375
378
|
} finally {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getCachedGuidanceForTeam,
|
|
7
7
|
formatGuidanceAppendix,
|
|
8
8
|
isGuidanceEnabled,
|
|
9
|
+
resolveGuidance,
|
|
9
10
|
_resetGuidanceCacheForTesting,
|
|
10
11
|
} from "./guidance.js";
|
|
11
12
|
|
|
@@ -220,3 +221,55 @@ describe("isGuidanceEnabled", () => {
|
|
|
220
221
|
expect(isGuidanceEnabled({ enableGuidance: true }, undefined)).toBe(true);
|
|
221
222
|
});
|
|
222
223
|
});
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// resolveGuidance
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
describe("resolveGuidance", () => {
|
|
230
|
+
it("extracts guidance from webhook payload and caches it", () => {
|
|
231
|
+
const result = resolveGuidance("team-1", { guidance: "Use TypeScript." });
|
|
232
|
+
expect(result).toBe("Use TypeScript.");
|
|
233
|
+
// Should also be cached now
|
|
234
|
+
expect(getCachedGuidanceForTeam("team-1")).toBe("Use TypeScript.");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("falls back to cache when payload has no guidance", () => {
|
|
238
|
+
cacheGuidanceForTeam("team-1", "Cached guidance");
|
|
239
|
+
const result = resolveGuidance("team-1", {});
|
|
240
|
+
expect(result).toBe("Cached guidance");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("returns null when no guidance anywhere", () => {
|
|
244
|
+
const result = resolveGuidance("team-1", {});
|
|
245
|
+
expect(result).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns null when guidance is disabled for team", () => {
|
|
249
|
+
cacheGuidanceForTeam("team-1", "Should be ignored");
|
|
250
|
+
const config = { teamGuidanceOverrides: { "team-1": false } };
|
|
251
|
+
const result = resolveGuidance("team-1", { guidance: "Direct guidance" }, config);
|
|
252
|
+
expect(result).toBeNull();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns null when teamId is undefined and no payload guidance", () => {
|
|
256
|
+
const result = resolveGuidance(undefined, {});
|
|
257
|
+
expect(result).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("extracts from payload even with undefined teamId", () => {
|
|
261
|
+
const result = resolveGuidance(undefined, { guidance: "Global guidance" });
|
|
262
|
+
expect(result).toBe("Global guidance");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("returns null when payload is null and cache is empty", () => {
|
|
266
|
+
const result = resolveGuidance("team-1", null);
|
|
267
|
+
expect(result).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("uses cached guidance when payload is null", () => {
|
|
271
|
+
cacheGuidanceForTeam("team-1", "Cached");
|
|
272
|
+
const result = resolveGuidance("team-1", null);
|
|
273
|
+
expect(result).toBe("Cached");
|
|
274
|
+
});
|
|
275
|
+
});
|
package/src/pipeline/guidance.ts
CHANGED
|
@@ -127,6 +127,44 @@ export function formatGuidanceAppendix(guidance: string | null): string {
|
|
|
127
127
|
].join("\n");
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Proactive resolution (webhook → cache → null)
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve guidance for a team through all available sources.
|
|
136
|
+
* Chain: webhook payload → cache → null
|
|
137
|
+
*
|
|
138
|
+
* This replaces ad-hoc cache lookups with a single resolution function.
|
|
139
|
+
* When the cache expires and no webhook guidance is available, returns null.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveGuidance(
|
|
142
|
+
teamId: string | undefined,
|
|
143
|
+
payload: Record<string, unknown> | null,
|
|
144
|
+
pluginConfig?: Record<string, unknown>,
|
|
145
|
+
): string | null {
|
|
146
|
+
// Check if guidance is enabled for this team
|
|
147
|
+
if (!isGuidanceEnabled(pluginConfig, teamId)) return null;
|
|
148
|
+
|
|
149
|
+
// 1. Try extracting from webhook payload (freshest source)
|
|
150
|
+
if (payload) {
|
|
151
|
+
const extracted = extractGuidance(payload);
|
|
152
|
+
if (extracted.guidance) {
|
|
153
|
+
// Cache for future Comment webhook paths
|
|
154
|
+
if (teamId) cacheGuidanceForTeam(teamId, extracted.guidance);
|
|
155
|
+
return extracted.guidance;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Try cache
|
|
160
|
+
if (teamId) {
|
|
161
|
+
const cached = getCachedGuidanceForTeam(teamId);
|
|
162
|
+
if (cached) return cached;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
130
168
|
// ---------------------------------------------------------------------------
|
|
131
169
|
// Config toggle
|
|
132
170
|
// ---------------------------------------------------------------------------
|