@bakers-build/addon-claude 0.0.0-stage → 0.1.0-next.38
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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/src/footprint.d.ts +22 -0
- package/dist/src/footprint.js +152 -0
- package/dist/src/index.d.ts +44 -0
- package/dist/src/index.js +217 -0
- package/dist/src/preset.d.ts +4 -0
- package/dist/src/preset.js +33 -0
- package/measure/agent-proxy.mjs +686 -0
- package/measure/claude-measure.mjs +113 -0
- package/measure/keytec-coding.json +36 -0
- package/measure/keytec-ops.json +30 -0
- package/measure/keytec-plan.json +32 -0
- package/measure/keytec-review.json +32 -0
- package/package.json +27 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keytec GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bakers-build/addon-claude
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
bakers.build agent plugin for the Claude CLI.
|
|
4
|
+
|
|
5
|
+
Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type AgentFootprint } from '@bakers-build/conductor/contract';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a Claude-Code JSONL transcript into an agent footprint (BB-132).
|
|
4
|
+
*
|
|
5
|
+
* This is Claude's transcript format specifically — the transcript is one JSON
|
|
6
|
+
* object per line. Assistant turns carry a `message.usage` token breakdown and
|
|
7
|
+
* `message.content` blocks (text / tool_use); genuine user prompts are user
|
|
8
|
+
* messages whose `message.content` is a plain string (tool results come back
|
|
9
|
+
* as an array and are ignored). Blank or malformed lines are skipped — never
|
|
10
|
+
* throws.
|
|
11
|
+
*
|
|
12
|
+
* `duration_s` is the run's wall-clock length: the span between the first and
|
|
13
|
+
* last entry that carries a parseable `timestamp` (BB-151). This makes the
|
|
14
|
+
* transcript the single source of the run length — no `started_at` re-read, no
|
|
15
|
+
* JSON:API timestamp round-trip to mis-parse. Fewer than two timestamps → 0.
|
|
16
|
+
*
|
|
17
|
+
* `active_s` is `duration_s` minus the time spent waiting on the human. Two
|
|
18
|
+
* gaps count as waiting: from the last assistant entry of a turn to the next
|
|
19
|
+
* genuine user prompt, and from an `AskUserQuestion`/`ExitPlanMode` tool_use
|
|
20
|
+
* to its tool_result. Only timestamped entries take part; no threshold.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseClaudeTranscript(jsonl: string): AgentFootprint;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { emptyAgentFootprint, } from '@bakers-build/conductor/contract';
|
|
2
|
+
/** Token buckets summed into the total; each defaults to 0 when absent. */
|
|
3
|
+
const TOKEN_KEYS = [
|
|
4
|
+
'input_tokens',
|
|
5
|
+
'output_tokens',
|
|
6
|
+
'cache_creation_input_tokens',
|
|
7
|
+
'cache_read_input_tokens',
|
|
8
|
+
];
|
|
9
|
+
/** Tools whose open call means the agent is waiting on the human's answer. */
|
|
10
|
+
const QUESTION_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode']);
|
|
11
|
+
/** Prefix of the user entry Claude Code writes when a background agent ends. */
|
|
12
|
+
const TASK_NOTIFICATION = '<task-notification>';
|
|
13
|
+
function num(value) {
|
|
14
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
15
|
+
}
|
|
16
|
+
function countWords(text) {
|
|
17
|
+
const trimmed = text.trim();
|
|
18
|
+
return trimmed === '' ? 0 : trimmed.split(/\s+/).length;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse a Claude-Code JSONL transcript into an agent footprint (BB-132).
|
|
22
|
+
*
|
|
23
|
+
* This is Claude's transcript format specifically — the transcript is one JSON
|
|
24
|
+
* object per line. Assistant turns carry a `message.usage` token breakdown and
|
|
25
|
+
* `message.content` blocks (text / tool_use); genuine user prompts are user
|
|
26
|
+
* messages whose `message.content` is a plain string (tool results come back
|
|
27
|
+
* as an array and are ignored). Blank or malformed lines are skipped — never
|
|
28
|
+
* throws.
|
|
29
|
+
*
|
|
30
|
+
* `duration_s` is the run's wall-clock length: the span between the first and
|
|
31
|
+
* last entry that carries a parseable `timestamp` (BB-151). This makes the
|
|
32
|
+
* transcript the single source of the run length — no `started_at` re-read, no
|
|
33
|
+
* JSON:API timestamp round-trip to mis-parse. Fewer than two timestamps → 0.
|
|
34
|
+
*
|
|
35
|
+
* `active_s` is `duration_s` minus the time spent waiting on the human. Two
|
|
36
|
+
* gaps count as waiting: from the last assistant entry of a turn to the next
|
|
37
|
+
* genuine user prompt, and from an `AskUserQuestion`/`ExitPlanMode` tool_use
|
|
38
|
+
* to its tool_result. Only timestamped entries take part; no threshold.
|
|
39
|
+
*/
|
|
40
|
+
export function parseClaudeTranscript(jsonl) {
|
|
41
|
+
const footprint = emptyAgentFootprint();
|
|
42
|
+
let minTs = Number.POSITIVE_INFINITY;
|
|
43
|
+
let maxTs = Number.NEGATIVE_INFINITY;
|
|
44
|
+
let waitMs = 0;
|
|
45
|
+
let lastAssistantTs;
|
|
46
|
+
// shortcut: parallel open questions are summed, not merged, so overlapping
|
|
47
|
+
// waits count twice (clamped at 0); merge intervals if parallel asks appear.
|
|
48
|
+
const openQuestions = new Map();
|
|
49
|
+
for (const raw of jsonl.split('\n')) {
|
|
50
|
+
const line = raw.trim();
|
|
51
|
+
if (line === '') {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
let entry;
|
|
55
|
+
try {
|
|
56
|
+
entry = JSON.parse(line);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// Track the run span across ANY timestamped entry (user, assistant,
|
|
62
|
+
// system, …) — not just the turns we meter for tokens.
|
|
63
|
+
let ts;
|
|
64
|
+
if (typeof entry.timestamp === 'string') {
|
|
65
|
+
const parsed = Date.parse(entry.timestamp);
|
|
66
|
+
if (Number.isFinite(parsed)) {
|
|
67
|
+
ts = parsed;
|
|
68
|
+
if (ts < minTs)
|
|
69
|
+
minTs = ts;
|
|
70
|
+
if (ts > maxTs)
|
|
71
|
+
maxTs = ts;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const message = entry.message;
|
|
75
|
+
if (!message || typeof message !== 'object') {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (ts !== undefined) {
|
|
79
|
+
waitMs += humanWait(entry.type, message.content, ts);
|
|
80
|
+
}
|
|
81
|
+
if (entry.type === 'assistant') {
|
|
82
|
+
footprint.agent_turns += 1;
|
|
83
|
+
const usage = message.usage ?? {};
|
|
84
|
+
for (const key of TOKEN_KEYS) {
|
|
85
|
+
footprint.tokens += num(usage[key]);
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(message.content)) {
|
|
88
|
+
for (const block of message.content) {
|
|
89
|
+
if (block &&
|
|
90
|
+
typeof block === 'object' &&
|
|
91
|
+
block.type === 'tool_use') {
|
|
92
|
+
footprint.tool_calls += 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (typeof message.model === 'string') {
|
|
97
|
+
footprint.model = message.model;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (entry.type === 'user' && typeof message.content === 'string') {
|
|
101
|
+
footprint.user_prompts += 1;
|
|
102
|
+
footprint.user_prompt_words += countWords(message.content);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (Number.isFinite(minTs) && Number.isFinite(maxTs) && maxTs > minTs) {
|
|
106
|
+
footprint.duration_s = Math.floor((maxTs - minTs) / 1000);
|
|
107
|
+
footprint.active_s = Math.max(0, Math.floor((maxTs - minTs - waitMs) / 1000));
|
|
108
|
+
}
|
|
109
|
+
return footprint;
|
|
110
|
+
/** Wait (ms) that ends at this entry; also tracks turn ends and open asks. */
|
|
111
|
+
function humanWait(type, content, ts) {
|
|
112
|
+
if (type === 'assistant') {
|
|
113
|
+
lastAssistantTs = ts;
|
|
114
|
+
for (const block of Array.isArray(content) ? content : []) {
|
|
115
|
+
const use = block;
|
|
116
|
+
if (use?.type === 'tool_use' &&
|
|
117
|
+
typeof use.id === 'string' &&
|
|
118
|
+
QUESTION_TOOLS.has(use.name)) {
|
|
119
|
+
openQuestions.set(use.id, ts);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
if (type !== 'user') {
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
let wait = 0;
|
|
128
|
+
if (typeof content === 'string' && content.startsWith(TASK_NOTIFICATION)) {
|
|
129
|
+
// A background agent finished: agent-side work, so it anchors the turn end.
|
|
130
|
+
lastAssistantTs = ts;
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
if (typeof content === 'string') {
|
|
134
|
+
if (lastAssistantTs !== undefined) {
|
|
135
|
+
wait = Math.max(0, ts - lastAssistantTs);
|
|
136
|
+
}
|
|
137
|
+
lastAssistantTs = undefined;
|
|
138
|
+
return wait;
|
|
139
|
+
}
|
|
140
|
+
for (const block of Array.isArray(content) ? content : []) {
|
|
141
|
+
const id = block?.tool_use_id;
|
|
142
|
+
const start = typeof id === 'string' ? openQuestions.get(id) : undefined;
|
|
143
|
+
if (start !== undefined) {
|
|
144
|
+
wait += Math.max(0, ts - start);
|
|
145
|
+
openQuestions.delete(id);
|
|
146
|
+
// The answer ends this wait; a prompt typed right after must not recount it.
|
|
147
|
+
lastAssistantTs = undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return wait;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type AgentFootprint, type AgentPlugin, type AgentRunIdentity, type BbAgent } from '@bakers-build/conductor/contract';
|
|
2
|
+
export { parseClaudeTranscript } from './footprint.js';
|
|
3
|
+
/**
|
|
4
|
+
* Encode an absolute cwd the way Claude Code names its per-project transcript
|
|
5
|
+
* directory under `~/.claude/projects/`: every non-alphanumeric char → `-`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function encodeClaudeProjectDir(cwd: string): string;
|
|
8
|
+
export interface ClaudeAgentOptions {
|
|
9
|
+
model?: string;
|
|
10
|
+
effort?: string;
|
|
11
|
+
args?: string[];
|
|
12
|
+
skip_permissions?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* When true, the launch runs through `measure/claude-measure.mjs`, which
|
|
15
|
+
* starts the logging proxy for the lifetime of this Claude process and
|
|
16
|
+
* applies the state-group settings shipped with the addon.
|
|
17
|
+
*/
|
|
18
|
+
measure?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare class ClaudeAgent implements BbAgent {
|
|
21
|
+
private readonly home;
|
|
22
|
+
readonly id = "claude";
|
|
23
|
+
private readonly args;
|
|
24
|
+
private readonly model;
|
|
25
|
+
private readonly effort;
|
|
26
|
+
private readonly skipPermissions;
|
|
27
|
+
private readonly measure;
|
|
28
|
+
private readonly overrides;
|
|
29
|
+
constructor(options?: ClaudeAgentOptions, home?: string);
|
|
30
|
+
launchCommand(prompt: string, run?: AgentRunIdentity): string;
|
|
31
|
+
/**
|
|
32
|
+
* `claude --session-id <uuid>` refuses to start when that session already
|
|
33
|
+
* exists (BB-473 review round 2), so the conductor checks this before
|
|
34
|
+
* pinning the run's own UUID — a re-dispatch of the same run would
|
|
35
|
+
* otherwise mint the very id its first attempt already wrote and fail to
|
|
36
|
+
* launch at all, silently (`claude` exits 0 on that error).
|
|
37
|
+
*/
|
|
38
|
+
sessionExists(worktreePath: string, sessionId: string): Promise<boolean>;
|
|
39
|
+
getRunLog(worktreePath: string, run?: AgentRunIdentity): Promise<string>;
|
|
40
|
+
parseFootprint(log: string): AgentFootprint;
|
|
41
|
+
}
|
|
42
|
+
/** Config-facing factory: `claudeAgent({ model: 'claude-opus-4-8' })`. */
|
|
43
|
+
export declare function claudeAgent(options?: ClaudeAgentOptions): AgentPlugin;
|
|
44
|
+
export default claudeAgent;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolveAgentCliOptions, validateOptionalBoolean, } from '@bakers-build/conductor/contract';
|
|
6
|
+
import { shellQuote } from '@bakers-build/core';
|
|
7
|
+
import { parseClaudeTranscript } from './footprint.js';
|
|
8
|
+
export { parseClaudeTranscript } from './footprint.js';
|
|
9
|
+
/** src/index.ts and dist/src/index.js both sit two levels below the package root. */
|
|
10
|
+
const MEASURE_WRAPPER = fileURLToPath(new URL(import.meta.url.includes('/dist/')
|
|
11
|
+
? '../../measure/claude-measure.mjs'
|
|
12
|
+
: '../measure/claude-measure.mjs', import.meta.url));
|
|
13
|
+
const MEASURE_FLAGS = [
|
|
14
|
+
'--setting-sources',
|
|
15
|
+
'project,local',
|
|
16
|
+
'--settings',
|
|
17
|
+
'.bakers/claude-measure.settings.json',
|
|
18
|
+
'--strict-mcp-config',
|
|
19
|
+
'--mcp-config',
|
|
20
|
+
'{"mcpServers":{}}',
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Encode an absolute cwd the way Claude Code names its per-project transcript
|
|
24
|
+
* directory under `~/.claude/projects/`: every non-alphanumeric char → `-`.
|
|
25
|
+
*/
|
|
26
|
+
export function encodeClaudeProjectDir(cwd) {
|
|
27
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
28
|
+
}
|
|
29
|
+
const CLAUDE_RESERVED = [
|
|
30
|
+
'--session-id',
|
|
31
|
+
'--dangerously-skip-permissions',
|
|
32
|
+
'--allow-dangerously-skip-permissions',
|
|
33
|
+
'--permission-mode',
|
|
34
|
+
];
|
|
35
|
+
const CLAUDE_KNOWN = [
|
|
36
|
+
{ kind: 'model', flags: ['--model'] },
|
|
37
|
+
{ kind: 'effort', flags: ['--effort'] },
|
|
38
|
+
];
|
|
39
|
+
export class ClaudeAgent {
|
|
40
|
+
home;
|
|
41
|
+
id = 'claude';
|
|
42
|
+
args;
|
|
43
|
+
model;
|
|
44
|
+
effort;
|
|
45
|
+
skipPermissions;
|
|
46
|
+
measure;
|
|
47
|
+
overrides;
|
|
48
|
+
constructor(options = {}, home = homedir()) {
|
|
49
|
+
this.home = home;
|
|
50
|
+
const cli = resolveAgentCliOptions(options, CLAUDE_RESERVED, CLAUDE_KNOWN);
|
|
51
|
+
this.args = cli.args;
|
|
52
|
+
this.model = cli.model;
|
|
53
|
+
this.effort = cli.effort;
|
|
54
|
+
this.overrides = cli.overrides;
|
|
55
|
+
this.skipPermissions =
|
|
56
|
+
validateOptionalBoolean(options.skip_permissions, 'skip_permissions') ??
|
|
57
|
+
true;
|
|
58
|
+
this.measure = validateOptionalBoolean(options.measure, 'measure') ?? false;
|
|
59
|
+
}
|
|
60
|
+
launchCommand(prompt, run) {
|
|
61
|
+
if (prompt === '') {
|
|
62
|
+
return 'claude';
|
|
63
|
+
}
|
|
64
|
+
const parts = this.measure
|
|
65
|
+
? ['node', shellQuote(MEASURE_WRAPPER), 'claude', shellQuote(prompt)]
|
|
66
|
+
: ['claude', shellQuote(prompt)];
|
|
67
|
+
if (this.model !== undefined && !this.overrides.model) {
|
|
68
|
+
parts.push('--model', shellQuote(this.model));
|
|
69
|
+
}
|
|
70
|
+
if (this.effort !== undefined && !this.overrides.effort) {
|
|
71
|
+
parts.push('--effort', shellQuote(this.effort));
|
|
72
|
+
}
|
|
73
|
+
if (this.skipPermissions) {
|
|
74
|
+
parts.push('--dangerously-skip-permissions');
|
|
75
|
+
}
|
|
76
|
+
// Pin the transcript's own session id to the run's UUID (BB-473), so
|
|
77
|
+
// `getRunLog` can read that ONE file directly instead of scanning the
|
|
78
|
+
// worktree-keyed directory by mtime — the scan is what let a
|
|
79
|
+
// resumed/re-dispatched/reused worktree finalise a foreign transcript.
|
|
80
|
+
if (run?.sessionId) {
|
|
81
|
+
parts.push('--session-id', run.sessionId);
|
|
82
|
+
}
|
|
83
|
+
parts.push(...this.args.map(shellQuote));
|
|
84
|
+
if (this.measure) {
|
|
85
|
+
parts.push(...MEASURE_FLAGS.map(shellQuote));
|
|
86
|
+
}
|
|
87
|
+
return parts.join(' ');
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `claude --session-id <uuid>` refuses to start when that session already
|
|
91
|
+
* exists (BB-473 review round 2), so the conductor checks this before
|
|
92
|
+
* pinning the run's own UUID — a re-dispatch of the same run would
|
|
93
|
+
* otherwise mint the very id its first attempt already wrote and fail to
|
|
94
|
+
* launch at all, silently (`claude` exits 0 on that error).
|
|
95
|
+
*/
|
|
96
|
+
async sessionExists(worktreePath, sessionId) {
|
|
97
|
+
const dir = join(this.home, '.claude', 'projects', encodeClaudeProjectDir(worktreePath));
|
|
98
|
+
try {
|
|
99
|
+
await stat(join(dir, `${sessionId}.jsonl`));
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async getRunLog(worktreePath, run) {
|
|
107
|
+
const dir = join(this.home, '.claude', 'projects', encodeClaudeProjectDir(worktreePath));
|
|
108
|
+
const startedAtMs = run?.startedAt !== undefined ? new Date(run.startedAt).getTime() : null;
|
|
109
|
+
// BB-473: when the run's own session id is known (pinned at launch via
|
|
110
|
+
// `--session-id`), read that file directly — no directory scan, so a
|
|
111
|
+
// foreign transcript (resumed run, re-dispatch, reused worktree) can
|
|
112
|
+
// never be selected. AC-5: when the pinned file is missing (a run
|
|
113
|
+
// dispatched before this change, or the agent never wrote it), fall
|
|
114
|
+
// through to the scan below — bounded by `run.startedAt` where known —
|
|
115
|
+
// instead of returning '' and turning a wrong log into no log at all.
|
|
116
|
+
//
|
|
117
|
+
// Round 4 (review round 3, Critical 1): the pinned file can also be
|
|
118
|
+
// *stale* rather than missing — a re-dispatch omits `--session-id` (see
|
|
119
|
+
// `sessionExists`) precisely because attempt 1's file already owns this
|
|
120
|
+
// name, so a direct read here would silently keep returning attempt 1's
|
|
121
|
+
// transcript forever. `startedAt` is re-stamped by the conductor on
|
|
122
|
+
// every dispatch (`markRunning`), so a pinned file older than this
|
|
123
|
+
// attempt's own start can only be a leftover from a prior attempt —
|
|
124
|
+
// reject it and fall through to the scan, which is bounded by the same
|
|
125
|
+
// timestamp and will find the fresh, unpinned file this attempt wrote.
|
|
126
|
+
// BB-473 review round 4 (Critical 2): when the run's deterministic
|
|
127
|
+
// attempt family is known, select the HIGHEST-numbered attempt whose
|
|
128
|
+
// file actually exists — an IDENTITY, not the `startedAt` window below,
|
|
129
|
+
// which a sibling session newer than the bound could still beat. Each
|
|
130
|
+
// family member names a distinct attempt, so no staleness check is
|
|
131
|
+
// needed: whichever member exists IS that attempt's own transcript.
|
|
132
|
+
if (run?.attemptFamily?.length) {
|
|
133
|
+
for (let i = run.attemptFamily.length - 1; i >= 0; i--) {
|
|
134
|
+
try {
|
|
135
|
+
return await readFile(join(dir, `${run.attemptFamily[i]}.jsonl`), 'utf8');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// try the next, lower-numbered attempt.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// AC-5: none of the family's files exist yet — a run dispatched before
|
|
142
|
+
// this change, or the agent never wrote its pinned file — so fall
|
|
143
|
+
// through to the scan below instead of returning ''.
|
|
144
|
+
}
|
|
145
|
+
else if (run?.sessionId) {
|
|
146
|
+
try {
|
|
147
|
+
const path = join(dir, `${run.sessionId}.jsonl`);
|
|
148
|
+
const isStale = startedAtMs !== null &&
|
|
149
|
+
Number.isFinite(startedAtMs) &&
|
|
150
|
+
(await stat(path)).mtimeMs < startedAtMs;
|
|
151
|
+
if (!isStale) {
|
|
152
|
+
return await readFile(path, 'utf8');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// fall through to the scan.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
let entries;
|
|
160
|
+
try {
|
|
161
|
+
entries = await readdir(dir);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
let latest = null;
|
|
167
|
+
for (const name of entries) {
|
|
168
|
+
if (!name.endsWith('.jsonl')) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const full = join(dir, name);
|
|
172
|
+
try {
|
|
173
|
+
const s = await stat(full);
|
|
174
|
+
if (startedAtMs !== null &&
|
|
175
|
+
Number.isFinite(startedAtMs) &&
|
|
176
|
+
s.mtimeMs < startedAtMs) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!latest || s.mtimeMs > latest.mtimeMs) {
|
|
180
|
+
latest = { path: full, mtimeMs: s.mtimeMs };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// entry vanished between readdir and stat — skip.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!latest) {
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
return await readFile(latest.path, 'utf8');
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return '';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
parseFootprint(log) {
|
|
198
|
+
return parseClaudeTranscript(log);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Config-facing factory: `claudeAgent({ model: 'claude-opus-4-8' })`. */
|
|
202
|
+
export function claudeAgent(options = {}) {
|
|
203
|
+
const agent = new ClaudeAgent(options);
|
|
204
|
+
return {
|
|
205
|
+
kind: 'agent',
|
|
206
|
+
id: 'claude',
|
|
207
|
+
requiredModules: [],
|
|
208
|
+
async createAgent() {
|
|
209
|
+
return agent;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// Default export: this module also exports the `ClaudeAgent` class, so the
|
|
214
|
+
// config resolver's auto-pick (no `export:`) sees 2 function exports and
|
|
215
|
+
// would otherwise throw "specify export" — see conductor/src/config.ts
|
|
216
|
+
// `loadNamedPlugin`. The default export makes the factory win.
|
|
217
|
+
export default claudeAgent;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import claudeAgent from './index.js';
|
|
2
|
+
export const agents = (acc, opts) => {
|
|
3
|
+
if (opts?.model) {
|
|
4
|
+
return [
|
|
5
|
+
...acc,
|
|
6
|
+
{
|
|
7
|
+
agent: claudeAgent(opts),
|
|
8
|
+
model: opts.model,
|
|
9
|
+
description: 'Custom Claude variant; suitability depends on the configured model.',
|
|
10
|
+
},
|
|
11
|
+
];
|
|
12
|
+
}
|
|
13
|
+
return [
|
|
14
|
+
...acc,
|
|
15
|
+
{
|
|
16
|
+
alias: 'opus',
|
|
17
|
+
description: 'Strong reasoning for ambiguous design, diagnosis and demanding implementation.',
|
|
18
|
+
model: 'opus',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
alias: 'sonnet',
|
|
22
|
+
description: 'Efficient implementation of well-specified changes and routine maintenance.',
|
|
23
|
+
model: 'sonnet',
|
|
24
|
+
},
|
|
25
|
+
].map((candidate) => 'agent' in candidate
|
|
26
|
+
? candidate
|
|
27
|
+
: {
|
|
28
|
+
...candidate,
|
|
29
|
+
agent: claudeAgent({ ...opts, model: candidate.model }),
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
// Agents are per machine: list this addon in ~/.bakers/machine.config.js addons[].
|
|
33
|
+
export const scopes = ['machine'];
|