pirl-framework-cli 0.0.0-stage → 1.0.1
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/CHANGELOG.md +78 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/bundled-framework/files/.agents/evals/layer_record.py +438 -0
- package/bundled-framework/files/.agents/evals/tests/test_layer_record.py +264 -0
- package/bundled-framework/files/.agents/evals/tests/test_skills.py +210 -0
- package/bundled-framework/files/.agents/evals/tests/test_validate_layer_artifact.py +214 -0
- package/bundled-framework/files/.agents/evals/validate_layer_artifact.py +415 -0
- package/bundled-framework/files/.agents/instructions/implement.instructions.md +343 -0
- package/bundled-framework/files/.agents/instructions/layered-task-execution.instructions.md +370 -0
- package/bundled-framework/files/.agents/instructions/layout-verification.instructions.md +107 -0
- package/bundled-framework/files/.agents/instructions/plan.instructions.md +292 -0
- package/bundled-framework/files/.agents/instructions/review.instructions.md +236 -0
- package/bundled-framework/files/.agents/instructions/self-evaluation.instructions.md +146 -0
- package/bundled-framework/files/.agents/instructions/standalone-and-maintenance.instructions.md +230 -0
- package/bundled-framework/files/.agents/roles/backend-developer.role.md +45 -0
- package/bundled-framework/files/.agents/roles/business-analyst.role.md +55 -0
- package/bundled-framework/files/.agents/roles/code-analyst.role.md +63 -0
- package/bundled-framework/files/.agents/roles/code-reviewer.role.md +46 -0
- package/bundled-framework/files/.agents/roles/frontend-developer.role.md +51 -0
- package/bundled-framework/files/.agents/roles/process-auditor.role.md +62 -0
- package/bundled-framework/files/.agents/roles/solution-architect.role.md +53 -0
- package/bundled-framework/files/.agents/roles/technical-writer.role.md +68 -0
- package/bundled-framework/files/.agents/roles/ux-designer.role.md +46 -0
- package/bundled-framework/files/.skills/pirl-implement/SKILL.md +115 -0
- package/bundled-framework/files/.skills/pirl-implement/assets/implementation-record-template.md +22 -0
- package/bundled-framework/files/.skills/pirl-implement/references/implementation-record-format.md +41 -0
- package/bundled-framework/files/.skills/pirl-implement/scripts/add_implementation_record.py +58 -0
- package/bundled-framework/files/.skills/pirl-implement/scripts/new_layout_run.py +51 -0
- package/bundled-framework/files/.skills/pirl-plan/SKILL.md +100 -0
- package/bundled-framework/files/.skills/pirl-plan/assets/plan-template.md +48 -0
- package/bundled-framework/files/.skills/pirl-plan/references/plan-format.md +65 -0
- package/bundled-framework/files/.skills/pirl-plan/scripts/new_plan.py +93 -0
- package/bundled-framework/files/.skills/pirl-review/SKILL.md +106 -0
- package/bundled-framework/files/.skills/pirl-review/assets/review-template.md +92 -0
- package/bundled-framework/files/.skills/pirl-review/references/review-format.md +70 -0
- package/bundled-framework/files/.skills/pirl-review/scripts/new_review.py +83 -0
- package/bundled-framework/files/AGENTS.md +303 -0
- package/bundled-framework/files/CONTEXT.md +187 -0
- package/bundled-framework/files/LICENSE +21 -0
- package/bundled-framework/files/README.md +170 -0
- package/bundled-framework/files/gitignore.pirl-bundled +12 -0
- package/bundled-framework/manifest.json +45 -0
- package/dist/bin/main.js +128 -0
- package/dist/bin/pirl.js +6 -0
- package/dist/commands/dispatch.js +57 -0
- package/dist/commands/registry.js +39 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/config.js +73 -0
- package/dist/core/exec.js +32 -0
- package/dist/core/paths.js +34 -0
- package/dist/core/targetRepo.js +22 -0
- package/dist/doctor/doctor.js +95 -0
- package/dist/engine/agentMerge.js +71 -0
- package/dist/engine/agents.js +67 -0
- package/dist/engine/attachments.js +251 -0
- package/dist/engine/claudeQuota.js +62 -0
- package/dist/engine/claudeRunner.js +118 -0
- package/dist/engine/claudeStream.js +205 -0
- package/dist/engine/hubData.js +176 -0
- package/dist/engine/permissions.js +67 -0
- package/dist/engine/pricing.js +24 -0
- package/dist/engine/protocol.js +98 -0
- package/dist/engine/run.js +527 -0
- package/dist/engine/runStore.js +67 -0
- package/dist/engine/types.js +3 -0
- package/dist/hub/framework.js +13 -0
- package/dist/hub/hub.js +125 -0
- package/dist/hub/services.js +38 -0
- package/dist/hub/update.js +261 -0
- package/dist/providers/claude.js +62 -0
- package/dist/providers/index.js +17 -0
- package/dist/providers/types.js +1 -0
- package/dist/ui/App.js +849 -0
- package/dist/ui/browse.js +144 -0
- package/dist/ui/components/Header.js +54 -0
- package/dist/ui/components/Input.js +76 -0
- package/dist/ui/components/Lines.js +50 -0
- package/dist/ui/components/Panels.js +73 -0
- package/dist/ui/content.js +225 -0
- package/dist/ui/frameworkConsole.js +541 -0
- package/dist/ui/inputEditor.js +86 -0
- package/dist/ui/layout.js +36 -0
- package/dist/ui/lines.js +15 -0
- package/dist/ui/listOverlay.js +97 -0
- package/dist/ui/runView.js +305 -0
- package/dist/ui/session.js +1 -0
- package/dist/ui/terminalInput.js +45 -0
- package/dist/ui/text.js +58 -0
- package/dist/ui/theme.js +139 -0
- package/dist/ui/ticker.js +48 -0
- package/package.json +67 -4
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import spawn from 'cross-spawn';
|
|
5
|
+
import { createStreamParser } from './claudeStream.js';
|
|
6
|
+
/**
|
|
7
|
+
* The built-in tools a run gets: everything the coordinator and the roles use. Every tool's definition is sent
|
|
8
|
+
* with every model request, and Claude Code's full set (with the user's MCP servers) is about 25k tokens of
|
|
9
|
+
* context, while these come to about 7k. MCP tools would be refused by the run's permission rules anyway.
|
|
10
|
+
*/
|
|
11
|
+
export const RUN_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'Agent'];
|
|
12
|
+
/**
|
|
13
|
+
* Runs the coordinator with the user's own, unmodified Claude Code. The prompt goes through stdin and
|
|
14
|
+
* the long arguments through files, so Vietnamese text and quotes survive Windows command lines.
|
|
15
|
+
* Claude Code 2.1.281 or later reads `--agents` from a file.
|
|
16
|
+
*/
|
|
17
|
+
export function claudeArgs(request, files) {
|
|
18
|
+
return [
|
|
19
|
+
'-p',
|
|
20
|
+
'--output-format',
|
|
21
|
+
'stream-json',
|
|
22
|
+
'--verbose',
|
|
23
|
+
'--append-system-prompt-file',
|
|
24
|
+
files.systemPrompt,
|
|
25
|
+
'--agents',
|
|
26
|
+
files.agents,
|
|
27
|
+
'--tools',
|
|
28
|
+
RUN_TOOLS.join(','),
|
|
29
|
+
'--strict-mcp-config',
|
|
30
|
+
// The framework's skills are plain files the coordinator reads; the user's own skills and commands are not loaded.
|
|
31
|
+
'--disable-slash-commands',
|
|
32
|
+
'--permission-mode',
|
|
33
|
+
'dontAsk',
|
|
34
|
+
'--permission-prompts',
|
|
35
|
+
'none',
|
|
36
|
+
'--allowedTools',
|
|
37
|
+
request.permissions.allow.join(','),
|
|
38
|
+
'--disallowedTools',
|
|
39
|
+
request.permissions.deny.join(','),
|
|
40
|
+
...request.addDirs.flatMap(dir => ['--add-dir', dir]),
|
|
41
|
+
...(request.model ? ['--model', request.model] : []),
|
|
42
|
+
...(request.effort ? ['--effort', request.effort] : []),
|
|
43
|
+
...(request.resumeSessionId ? ['--resume', request.resumeSessionId] : []),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
/** Provider processes still running; ended when PIRL CLI exits so none keeps working unseen. */
|
|
47
|
+
const running = new Set();
|
|
48
|
+
process.once('exit', () => {
|
|
49
|
+
for (const child of running)
|
|
50
|
+
child.kill('SIGTERM');
|
|
51
|
+
});
|
|
52
|
+
export function createClaudeRunner(command = 'claude') {
|
|
53
|
+
return {
|
|
54
|
+
start(request, onEvent) {
|
|
55
|
+
const dir = mkdtempSync(join(tmpdir(), 'pirl-run-'));
|
|
56
|
+
const files = { systemPrompt: join(dir, 'host.md'), agents: join(dir, 'agents.json') };
|
|
57
|
+
writeFileSync(files.systemPrompt, request.systemPrompt, 'utf8');
|
|
58
|
+
writeFileSync(files.agents, JSON.stringify(request.agents), 'utf8');
|
|
59
|
+
const child = spawn(command, claudeArgs(request, files), {
|
|
60
|
+
cwd: request.cwd,
|
|
61
|
+
// Background commands and subagents would keep the headless run open after its last turn.
|
|
62
|
+
env: { ...process.env, CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' },
|
|
63
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
64
|
+
windowsHide: true,
|
|
65
|
+
});
|
|
66
|
+
const parseStreamLine = createStreamParser();
|
|
67
|
+
running.add(child);
|
|
68
|
+
child.once('close', () => running.delete(child));
|
|
69
|
+
let buffer = '';
|
|
70
|
+
let stderr = '';
|
|
71
|
+
const emit = (event) => {
|
|
72
|
+
try {
|
|
73
|
+
onEvent(event);
|
|
74
|
+
}
|
|
75
|
+
catch { }
|
|
76
|
+
};
|
|
77
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk) => {
|
|
78
|
+
buffer += chunk;
|
|
79
|
+
let newline = buffer.indexOf('\n');
|
|
80
|
+
while (newline >= 0) {
|
|
81
|
+
const line = buffer.slice(0, newline).trim();
|
|
82
|
+
buffer = buffer.slice(newline + 1);
|
|
83
|
+
if (line)
|
|
84
|
+
for (const event of parseStreamLine(line))
|
|
85
|
+
emit(event);
|
|
86
|
+
newline = buffer.indexOf('\n');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk) => (stderr += chunk));
|
|
90
|
+
child.stdin?.on('error', () => { });
|
|
91
|
+
child.stdin?.end(request.prompt);
|
|
92
|
+
const done = new Promise(resolve => {
|
|
93
|
+
const finish = (code, error) => {
|
|
94
|
+
if (buffer.trim())
|
|
95
|
+
for (const event of parseStreamLine(buffer.trim()))
|
|
96
|
+
emit(event);
|
|
97
|
+
buffer = '';
|
|
98
|
+
rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
resolve({ code, stderr: error ?? stderr });
|
|
100
|
+
};
|
|
101
|
+
child.on('error', error => finish(null, error.message));
|
|
102
|
+
child.on('close', code => finish(code));
|
|
103
|
+
});
|
|
104
|
+
let stopping = false;
|
|
105
|
+
return {
|
|
106
|
+
done,
|
|
107
|
+
stop() {
|
|
108
|
+
if (stopping)
|
|
109
|
+
return;
|
|
110
|
+
stopping = true;
|
|
111
|
+
// SIGINT ends the turn cleanly so the session can be resumed; SIGTERM follows if it hangs.
|
|
112
|
+
child.kill('SIGINT');
|
|
113
|
+
setTimeout(() => child.exitCode === null && child.kill('SIGTERM'), 5_000).unref();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { costUsd } from './pricing.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns one line of `claude -p --output-format stream-json --verbose` into run events.
|
|
4
|
+
* Subagents are started with the Agent tool (named Task before Claude Code 2.1.63); their own messages
|
|
5
|
+
* carry `parent_tool_use_id`, the id of that Agent call, which is how activity is attributed to a role.
|
|
6
|
+
*/
|
|
7
|
+
const SPAWN_TOOLS = new Set(['Agent', 'Task']);
|
|
8
|
+
const short = (value, max = 80) => {
|
|
9
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
10
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
11
|
+
};
|
|
12
|
+
/** A plain-language line for one tool call, shown as the role's latest activity. */
|
|
13
|
+
export function describeTool(name, input = {}) {
|
|
14
|
+
const path = input['file_path'] ?? input['path'] ?? input['notebook_path'];
|
|
15
|
+
switch (name) {
|
|
16
|
+
case 'Read':
|
|
17
|
+
return `Đọc ${short(path)}`;
|
|
18
|
+
case 'Edit':
|
|
19
|
+
case 'MultiEdit':
|
|
20
|
+
return `Sửa ${short(path)}`;
|
|
21
|
+
case 'Write':
|
|
22
|
+
return `Ghi ${short(path)}`;
|
|
23
|
+
case 'Grep':
|
|
24
|
+
return `Tìm "${short(input['pattern'], 40)}"`;
|
|
25
|
+
case 'Glob':
|
|
26
|
+
return `Liệt kê ${short(input['pattern'], 60)}`;
|
|
27
|
+
case 'Bash':
|
|
28
|
+
return `Chạy ${short(input['command'])}`;
|
|
29
|
+
case 'TodoWrite':
|
|
30
|
+
return 'Cập nhật danh sách việc';
|
|
31
|
+
default:
|
|
32
|
+
return name;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A parser for one run's stream. It remembers each tool call by id, so a denial, which Claude Code reports
|
|
37
|
+
* with the tool's name only, can say which command was refused; and the tokens of each model request, which
|
|
38
|
+
* Claude Code reports several times while the request streams.
|
|
39
|
+
*/
|
|
40
|
+
export function createStreamParser() {
|
|
41
|
+
const state = { calls: new Map(), requests: new Map() };
|
|
42
|
+
return line => parseLine(line, state);
|
|
43
|
+
}
|
|
44
|
+
/** One line on its own, without the calls seen before it. */
|
|
45
|
+
export const parseStreamLine = (line) => parseLine(line, { calls: new Map(), requests: new Map() });
|
|
46
|
+
/**
|
|
47
|
+
* Tokens of one model request as they stream in, for the live count. A request's usage arrives with its start
|
|
48
|
+
* (input only), its end (`message_delta`, with the output) and its content blocks; each report holds totals so
|
|
49
|
+
* far, so only the growth since the last report is new. Some role requests never show their end in the
|
|
50
|
+
* stream; the result's `modelUsage` gives the exact totals when the provider call ends.
|
|
51
|
+
*/
|
|
52
|
+
function usageEvent(id, model, raw, requests) {
|
|
53
|
+
if (typeof id !== 'string' || !raw || typeof raw !== 'object')
|
|
54
|
+
return null;
|
|
55
|
+
const usage = raw;
|
|
56
|
+
const split = (usage['cache_creation'] ?? {});
|
|
57
|
+
const before = requests.get(id);
|
|
58
|
+
const read = (key, previous = 0) => Math.max(previous, Number(usage[key] ?? 0) || 0);
|
|
59
|
+
const now = {
|
|
60
|
+
model: typeof model === 'string' ? model : before?.model ?? 'unknown',
|
|
61
|
+
inputTokens: read('input_tokens', before?.inputTokens),
|
|
62
|
+
cacheWriteTokens: read('cache_creation_input_tokens', before?.cacheWriteTokens),
|
|
63
|
+
cacheReadTokens: read('cache_read_input_tokens', before?.cacheReadTokens),
|
|
64
|
+
outputTokens: read('output_tokens', before?.outputTokens),
|
|
65
|
+
cacheWrite1h: Math.max(before?.cacheWrite1h ?? 0, Number(split['ephemeral_1h_input_tokens'] ?? 0) || 0),
|
|
66
|
+
};
|
|
67
|
+
requests.set(id, now);
|
|
68
|
+
const added = {
|
|
69
|
+
inputTokens: now.inputTokens - (before?.inputTokens ?? 0),
|
|
70
|
+
cacheWriteTokens: now.cacheWriteTokens - (before?.cacheWriteTokens ?? 0),
|
|
71
|
+
cacheReadTokens: now.cacheReadTokens - (before?.cacheReadTokens ?? 0),
|
|
72
|
+
outputTokens: now.outputTokens - (before?.outputTokens ?? 0),
|
|
73
|
+
};
|
|
74
|
+
if (before && added.inputTokens + added.cacheWriteTokens + added.cacheReadTokens + added.outputTokens === 0)
|
|
75
|
+
return null;
|
|
76
|
+
const cacheWrite1h = now.cacheWrite1h - (before?.cacheWrite1h ?? 0);
|
|
77
|
+
return { type: 'usage', model: now.model, request: !before, costUsd: costUsd(now.model, { ...added, cacheWrite1h }), ...added };
|
|
78
|
+
}
|
|
79
|
+
/** The result's per-model totals: every request of the provider session, the roles' included. */
|
|
80
|
+
function usageTotals(data) {
|
|
81
|
+
const raw = data['modelUsage'];
|
|
82
|
+
if (!raw || typeof raw !== 'object')
|
|
83
|
+
return null;
|
|
84
|
+
const models = {};
|
|
85
|
+
for (const [model, value] of Object.entries(raw)) {
|
|
86
|
+
models[model] = {
|
|
87
|
+
inputTokens: Number(value['inputTokens'] ?? 0) || 0,
|
|
88
|
+
cacheWriteTokens: Number(value['cacheCreationInputTokens'] ?? 0) || 0,
|
|
89
|
+
cacheReadTokens: Number(value['cacheReadInputTokens'] ?? 0) || 0,
|
|
90
|
+
outputTokens: Number(value['outputTokens'] ?? 0) || 0,
|
|
91
|
+
costUsd: typeof value['costUSD'] === 'number' ? value['costUSD'] : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return Object.keys(models).length ? { type: 'usage-totals', models } : null;
|
|
95
|
+
}
|
|
96
|
+
const WINDOW_LABELS = { five_hour: '5 giờ', seven_day: 'Tuần', seven_day_opus: 'Tuần (Opus)', seven_day_sonnet: 'Tuần (Sonnet)' };
|
|
97
|
+
/** The plan's usage windows Claude Code reports with each provider call (`rate_limit_event`). */
|
|
98
|
+
function quotaEvent(data) {
|
|
99
|
+
const info = (data['rate_limit_info'] ?? {});
|
|
100
|
+
const windows = (info['unifiedWindows'] ?? {});
|
|
101
|
+
const entries = Object.entries(windows).filter(([, value]) => typeof value?.['utilization'] === 'number');
|
|
102
|
+
if (entries.length === 0 && typeof info['utilization'] === 'number' && typeof info['rateLimitType'] === 'string') {
|
|
103
|
+
entries.push([info['rateLimitType'], info]);
|
|
104
|
+
}
|
|
105
|
+
if (entries.length === 0)
|
|
106
|
+
return null;
|
|
107
|
+
return {
|
|
108
|
+
type: 'quota',
|
|
109
|
+
rows: entries.map(([kind, value]) => ({
|
|
110
|
+
kind,
|
|
111
|
+
label: WINDOW_LABELS[kind] ?? kind,
|
|
112
|
+
percent: value['utilization'] * 100,
|
|
113
|
+
resetsAt: typeof value['resetsAt'] === 'number' ? new Date(value['resetsAt'] * 1000).toISOString() : null,
|
|
114
|
+
})),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const isEvent = (event) => event !== null;
|
|
118
|
+
function parseLine(line, { calls, requests }) {
|
|
119
|
+
let data;
|
|
120
|
+
try {
|
|
121
|
+
data = JSON.parse(line);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const type = data['type'];
|
|
127
|
+
if (type === 'stream_event') {
|
|
128
|
+
const event = (data['event'] ?? {});
|
|
129
|
+
const message = (event['message'] ?? {});
|
|
130
|
+
if (event['type'] === 'message_start')
|
|
131
|
+
return [usageEvent(message['id'], message['model'], message['usage'], requests)].filter(isEvent);
|
|
132
|
+
if (event['type'] === 'message_delta')
|
|
133
|
+
return [usageEvent(data['api_message_id'], null, event['usage'], requests)].filter(isEvent);
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
if (type === 'rate_limit_event')
|
|
137
|
+
return [quotaEvent(data)].filter(isEvent);
|
|
138
|
+
if (type === 'system') {
|
|
139
|
+
const subtype = data['subtype'];
|
|
140
|
+
if (subtype === 'init' && typeof data['session_id'] === 'string')
|
|
141
|
+
return [{ type: 'session', sessionId: data['session_id'] }];
|
|
142
|
+
if (subtype === 'api_retry')
|
|
143
|
+
return [{ type: 'retry', attempt: Number(data['attempt'] ?? 0), error: String(data['error'] ?? 'unknown') }];
|
|
144
|
+
if (subtype === 'permission_denied') {
|
|
145
|
+
const id = typeof data['tool_use_id'] === 'string' ? data['tool_use_id'] : null;
|
|
146
|
+
return [{ type: 'denied', id, detail: (id && calls.get(id)) || short(data['tool_name'] ?? data['message'] ?? 'tool', 120) }];
|
|
147
|
+
}
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
if (type === 'result') {
|
|
151
|
+
const denials = Array.isArray(data['permission_denials']) ? data['permission_denials'] : [];
|
|
152
|
+
return [
|
|
153
|
+
...denials.map(denial => ({
|
|
154
|
+
type: 'denied',
|
|
155
|
+
id: typeof denial['tool_use_id'] === 'string' ? denial['tool_use_id'] : null,
|
|
156
|
+
detail: describeTool(String(denial['tool_name'] ?? ''), (denial['tool_input'] ?? {})),
|
|
157
|
+
})),
|
|
158
|
+
...[usageTotals(data)].filter(isEvent),
|
|
159
|
+
{
|
|
160
|
+
type: 'result',
|
|
161
|
+
// A failed run has no `result`; its reasons are in `errors`.
|
|
162
|
+
text: typeof data['result'] === 'string' ? data['result'] : Array.isArray(data['errors']) ? data['errors'].map(String).join('; ') : '',
|
|
163
|
+
isError: data['is_error'] === true || (typeof data['subtype'] === 'string' && data['subtype'] !== 'success'),
|
|
164
|
+
sessionId: typeof data['session_id'] === 'string' ? data['session_id'] : null,
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
if (type !== 'assistant' && type !== 'user')
|
|
169
|
+
return [];
|
|
170
|
+
const message = (data['message'] ?? {});
|
|
171
|
+
const blocks = Array.isArray(message['content']) ? message['content'] : [];
|
|
172
|
+
const parent = typeof data['parent_tool_use_id'] === 'string' ? data['parent_tool_use_id'] : null;
|
|
173
|
+
const events = [];
|
|
174
|
+
if (type === 'assistant') {
|
|
175
|
+
const usage = usageEvent(message['id'], message['model'], message['usage'], requests);
|
|
176
|
+
if (usage)
|
|
177
|
+
events.push(usage);
|
|
178
|
+
}
|
|
179
|
+
for (const block of blocks) {
|
|
180
|
+
if (type === 'assistant' && block.type === 'tool_use' && block.name) {
|
|
181
|
+
if (SPAWN_TOOLS.has(block.name) && block.id) {
|
|
182
|
+
events.push({
|
|
183
|
+
type: 'role-start',
|
|
184
|
+
toolUseId: block.id,
|
|
185
|
+
role: String(block.input?.['subagent_type'] ?? 'general-purpose'),
|
|
186
|
+
description: short(block.input?.['description'] ?? '', 100),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const text = describeTool(block.name, block.input);
|
|
191
|
+
if (block.id)
|
|
192
|
+
calls.set(block.id, text);
|
|
193
|
+
events.push({ type: 'activity', agent: parent, text });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else if (type === 'assistant' && block.type === 'text' && block.text && parent === null) {
|
|
197
|
+
events.push({ type: 'text', text: block.text });
|
|
198
|
+
}
|
|
199
|
+
else if (type === 'user' && block.type === 'tool_result' && block.tool_use_id) {
|
|
200
|
+
// Only the end of a subagent matters; other tool results are left out.
|
|
201
|
+
events.push({ type: 'role-end', toolUseId: block.tool_use_id, ok: block.is_error !== true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return events;
|
|
205
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { exec as defaultExec, succeeded } from '../core/exec.js';
|
|
4
|
+
import { parsePorcelain } from '../hub/hub.js';
|
|
5
|
+
/**
|
|
6
|
+
* What the layers leave in the Hub, read for /plans, /records and the checks after a run.
|
|
7
|
+
* Plan packages sit at the Hub root by default and records under .agents/evals/records/; both are ignored by
|
|
8
|
+
* Git and never leave the user's machine. Each is matched to the current target repository by its target_repo.
|
|
9
|
+
*/
|
|
10
|
+
export const PLAN_SET = /^[a-z0-9]+(?:-[a-z0-9]+)*_\d{8}-\d{6}$/;
|
|
11
|
+
export const RECORDS_DIR = '.agents/evals/records';
|
|
12
|
+
const GIT_FLAGS = ['-c', 'core.autocrlf=false', '-c', 'commit.gpgsign=false'];
|
|
13
|
+
export function readFrontmatter(text) {
|
|
14
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
15
|
+
const fields = {};
|
|
16
|
+
for (const line of match?.[1]?.split(/\r?\n/) ?? []) {
|
|
17
|
+
const field = /^([a-z_]+):\s*(.*)$/.exec(line);
|
|
18
|
+
if (field)
|
|
19
|
+
fields[field[1]] = unquote(field[2].trim());
|
|
20
|
+
}
|
|
21
|
+
return fields;
|
|
22
|
+
}
|
|
23
|
+
/** A quoted value as the framework's validator reads it: backslashes kept, so Windows paths stay intact. */
|
|
24
|
+
function unquote(value) {
|
|
25
|
+
if (value.length >= 2 && value[0] === "'" && value.endsWith("'"))
|
|
26
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
27
|
+
if (value.length >= 2 && value[0] === '"' && value.endsWith('"'))
|
|
28
|
+
return value.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
/** A target_repo value names the same repository as `target`: absolute, or relative to the Hub root. */
|
|
32
|
+
export function sameRepo(value, hub, target) {
|
|
33
|
+
if (!value)
|
|
34
|
+
return false;
|
|
35
|
+
const path = isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) ? value : join(hub, value);
|
|
36
|
+
const a = resolve(path).replace(/[\\/]+$/, '');
|
|
37
|
+
const b = resolve(target).replace(/[\\/]+$/, '');
|
|
38
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
39
|
+
}
|
|
40
|
+
const markdownFiles = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap(entry => entry.isDirectory() ? markdownFiles(join(dir, entry.name)) : entry.name.endsWith('.md') ? [join(dir, entry.name)] : []);
|
|
41
|
+
export function listPlanSets(hub) {
|
|
42
|
+
if (!existsSync(hub))
|
|
43
|
+
return [];
|
|
44
|
+
return readdirSync(hub, { withFileTypes: true })
|
|
45
|
+
.filter(entry => entry.isDirectory() && PLAN_SET.test(entry.name))
|
|
46
|
+
.map(entry => entry.name);
|
|
47
|
+
}
|
|
48
|
+
/** Plan packages at the Hub root whose tasks target `target`, newest first. */
|
|
49
|
+
export function readPlanPackages(hub, target) {
|
|
50
|
+
const packages = [];
|
|
51
|
+
for (const planSet of listPlanSets(hub)) {
|
|
52
|
+
const dir = join(hub, planSet);
|
|
53
|
+
const tasks = [];
|
|
54
|
+
const reports = [];
|
|
55
|
+
let createdAt = '';
|
|
56
|
+
let matches = false;
|
|
57
|
+
for (const file of markdownFiles(dir)) {
|
|
58
|
+
const fields = readFrontmatter(readFileSync(file, 'utf8'));
|
|
59
|
+
if (fields['schema'] === 'pirl-plan/v1') {
|
|
60
|
+
matches ||= sameRepo(fields['target_repo'], hub, target);
|
|
61
|
+
createdAt ||= fields['created_at'] ?? '';
|
|
62
|
+
tasks.push({ taskId: fields['task_id'] ?? '?', title: fields['title'] ?? '', status: fields['status'] ?? '?', file });
|
|
63
|
+
}
|
|
64
|
+
else if (fields['schema'] === 'pirl-review/v1') {
|
|
65
|
+
reports.push({ taskId: fields['task_id'] ?? '?', round: Number(fields['review_round'] ?? 0), verdict: fields['verdict'] ?? '?', file });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (matches) {
|
|
69
|
+
tasks.sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
70
|
+
reports.sort((a, b) => a.taskId.localeCompare(b.taskId) || a.round - b.round);
|
|
71
|
+
packages.push({ planSet, dir, createdAt, tasks, reports });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return packages.sort((a, b) => b.planSet.slice(-15).localeCompare(a.planSet.slice(-15)));
|
|
75
|
+
}
|
|
76
|
+
/** Self-evaluation records whose target_repo is `target`, newest first. Unreadable lines are skipped. */
|
|
77
|
+
export function readRecords(hub, target) {
|
|
78
|
+
const root = join(hub, RECORDS_DIR);
|
|
79
|
+
if (!existsSync(root))
|
|
80
|
+
return [];
|
|
81
|
+
const records = [];
|
|
82
|
+
for (const layer of readdirSync(root)) {
|
|
83
|
+
const dir = join(root, layer);
|
|
84
|
+
if (!statSync(dir).isDirectory())
|
|
85
|
+
continue;
|
|
86
|
+
for (const file of readdirSync(dir).filter(name => name.endsWith('.jsonl'))) {
|
|
87
|
+
for (const line of readFileSync(join(dir, file), 'utf8').split('\n')) {
|
|
88
|
+
if (!line.trim())
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const data = JSON.parse(line);
|
|
92
|
+
if (target && !sameRepo(data['target_repo'], hub, target))
|
|
93
|
+
continue;
|
|
94
|
+
const fixes = Array.isArray(data['fixes']) ? data['fixes'] : [];
|
|
95
|
+
records.push({
|
|
96
|
+
recordId: String(data['record_id'] ?? ''),
|
|
97
|
+
layer: String(data['layer'] ?? layer),
|
|
98
|
+
recordedAt: String(data['recorded_at'] ?? ''),
|
|
99
|
+
status: String(data['outcome']?.['status'] ?? ''),
|
|
100
|
+
summary: String(data['outcome']?.['summary'] ?? ''),
|
|
101
|
+
planSet: data['task']?.['plan_set'] ?? null,
|
|
102
|
+
fixes: fixes.filter((fix) => fix?.status === 'applied').length,
|
|
103
|
+
pending: fixes.filter((fix) => fix?.status === 'pending_user').length,
|
|
104
|
+
data,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return records.sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt));
|
|
112
|
+
}
|
|
113
|
+
export function countRecords(hub, layer) {
|
|
114
|
+
const dir = join(hub, RECORDS_DIR, layer);
|
|
115
|
+
if (!existsSync(dir))
|
|
116
|
+
return 0;
|
|
117
|
+
return readdirSync(dir)
|
|
118
|
+
.filter(name => name.endsWith('.jsonl'))
|
|
119
|
+
.reduce((sum, name) => sum + readFileSync(join(dir, name), 'utf8').split('\n').filter(line => line.trim()).length, 0);
|
|
120
|
+
}
|
|
121
|
+
/** Paths with uncommitted changes in a repository (the Hub, or the target after Implement). */
|
|
122
|
+
export async function changedPaths(repo, exec = defaultExec) {
|
|
123
|
+
const result = await exec('git', [...GIT_FLAGS, 'status', '--porcelain', '-z', '--untracked-files=all'], { cwd: repo, timeoutMs: 30_000 });
|
|
124
|
+
return succeeded(result) ? parsePorcelain(result.stdout) : [];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Commits framework improvements the user approved, with trailers that let /framework explain each change later.
|
|
128
|
+
* Uses the user's Git identity, or a neutral one when none is configured.
|
|
129
|
+
*/
|
|
130
|
+
export async function commitImprovements(hub, files, info, exec = defaultExec) {
|
|
131
|
+
const add = await exec('git', [...GIT_FLAGS, 'add', '--', ...files], { cwd: hub, timeoutMs: 30_000 });
|
|
132
|
+
if (!succeeded(add))
|
|
133
|
+
return { ok: false, error: add.stderr || add.error };
|
|
134
|
+
const name = await exec('git', ['config', 'user.name'], { cwd: hub });
|
|
135
|
+
const identity = succeeded(name) && name.stdout.trim() ? [] : ['-c', 'user.name=PIRL CLI user', '-c', 'user.email=pirl-cli@localhost'];
|
|
136
|
+
const message = [
|
|
137
|
+
`Framework improvement from a ${info.kind} run`,
|
|
138
|
+
'',
|
|
139
|
+
info.summary,
|
|
140
|
+
'',
|
|
141
|
+
...(info.recordId ? [`Pirl-Record: ${info.recordId}`] : []),
|
|
142
|
+
'Pirl-Source: auditor',
|
|
143
|
+
].join('\n');
|
|
144
|
+
const commit = await exec('git', [...GIT_FLAGS, ...identity, 'commit', '--quiet', '-m', message, '--', ...files], { cwd: hub, timeoutMs: 30_000 });
|
|
145
|
+
return succeeded(commit) ? { ok: true } : { ok: false, error: commit.stderr || commit.error };
|
|
146
|
+
}
|
|
147
|
+
/** Throws away improvements the user rejected: tracked files go back to the last commit, new files are removed. */
|
|
148
|
+
export async function discardImprovements(hub, files, exec = defaultExec) {
|
|
149
|
+
const tracked = [];
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
const known = await exec('git', ['ls-files', '--error-unmatch', '--', file], { cwd: hub });
|
|
152
|
+
if (succeeded(known))
|
|
153
|
+
tracked.push(file);
|
|
154
|
+
else
|
|
155
|
+
rmSync(join(hub, file), { force: true });
|
|
156
|
+
}
|
|
157
|
+
if (tracked.length === 0)
|
|
158
|
+
return { ok: true };
|
|
159
|
+
const restore = await exec('git', [...GIT_FLAGS, 'restore', '--source=HEAD', '--staged', '--worktree', '--', ...tracked], { cwd: hub, timeoutMs: 30_000 });
|
|
160
|
+
return succeeded(restore) ? { ok: true } : { ok: false, error: restore.stderr || restore.error };
|
|
161
|
+
}
|
|
162
|
+
/** Paths from the Plan's "Allowed paths" line, in backticks. A trailing slash or /** covers a whole directory. */
|
|
163
|
+
export function allowedPaths(planText) {
|
|
164
|
+
const line = planText.split('\n').find(text => /allowed paths:/i.test(text));
|
|
165
|
+
return line ? [...line.matchAll(/`([^`]+)`/g)].map(match => match[1].trim()).filter(Boolean) : [];
|
|
166
|
+
}
|
|
167
|
+
export function outsideAllowed(changed, allowed) {
|
|
168
|
+
if (allowed.length === 0)
|
|
169
|
+
return [];
|
|
170
|
+
const normalized = allowed.map(path => path.replace(/\\/g, '/').replace(/\/\*\*$/, '/').replace(/^\.\//, ''));
|
|
171
|
+
return changed.filter(file => {
|
|
172
|
+
const path = file.replace(/\\/g, '/');
|
|
173
|
+
return !normalized.some(allow => (allow.endsWith('/') ? path.startsWith(allow) : path === allow || path.startsWith(`${allow}/`)));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
export const relativeTo = (base, path) => relative(base, path) || '.';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code permission rules for one run (proposal section 7.2). The run uses permission mode `dontAsk`:
|
|
3
|
+
* anything not allowed here is refused, so no prompt can stall a run nobody can answer.
|
|
4
|
+
* Absolute paths are written as `//path`; Windows paths become `//d/Project/...`.
|
|
5
|
+
*/
|
|
6
|
+
export function ruleRoot(path) {
|
|
7
|
+
const posix = path.replace(/\\/g, '/');
|
|
8
|
+
const drive = /^([A-Za-z]):\/?(.*)$/.exec(posix);
|
|
9
|
+
const absolute = drive ? `/${drive[1].toLowerCase()}/${drive[2]}` : posix;
|
|
10
|
+
return `/${absolute.replace(/\/+$/, '')}`;
|
|
11
|
+
}
|
|
12
|
+
/** Commands the framework's tools need, from the Hub: record, validator, helper scripts, framework tests. */
|
|
13
|
+
export function pythonRules(python) {
|
|
14
|
+
const prefix = [python.command, ...python.args, '-B'].join(' ');
|
|
15
|
+
return [`Bash(${prefix} .agents/evals/*)`, `Bash(${prefix} .skills/*)`, `Bash(${prefix} -m unittest *)`];
|
|
16
|
+
}
|
|
17
|
+
const READ_TOOLS = ['Read', 'Grep', 'Glob', 'Agent', 'TodoWrite'];
|
|
18
|
+
const GIT_READ_SUBCOMMANDS = ['log', 'show', 'diff', 'status', 'ls-files', 'rev-parse', 'blame'];
|
|
19
|
+
const GIT_READ = GIT_READ_SUBCOMMANDS.map(command => `Bash(git ${command} *)`);
|
|
20
|
+
/** Read-only Git in another repository, as `git -C <path> log ...` with the path written exactly as given. */
|
|
21
|
+
const gitReadIn = (path) => GIT_READ_SUBCOMMANDS.map(command => `Bash(git -C ${path} ${command} *)`);
|
|
22
|
+
/**
|
|
23
|
+
* Tools that make a headless run wait: scheduled wakeups, timers and watches keep `claude -p` open until they
|
|
24
|
+
* fire, so a run would never end. Removed from every run; the runner also turns background work off.
|
|
25
|
+
*/
|
|
26
|
+
export const WAITING_TOOLS = ['ScheduleWakeup', 'CronCreate', 'CronDelete', 'Monitor', 'RemoteTrigger'];
|
|
27
|
+
/** Functional tests under FUT; the target repository's documentation says which one applies. */
|
|
28
|
+
export const TEST_RUNNERS = [
|
|
29
|
+
'npm test',
|
|
30
|
+
'npm run test',
|
|
31
|
+
'pnpm test',
|
|
32
|
+
'pnpm run test',
|
|
33
|
+
'yarn test',
|
|
34
|
+
'bun test',
|
|
35
|
+
'npx vitest',
|
|
36
|
+
'npx jest',
|
|
37
|
+
'pytest',
|
|
38
|
+
'python -m pytest',
|
|
39
|
+
'python3 -m pytest',
|
|
40
|
+
'py -m pytest',
|
|
41
|
+
'go test',
|
|
42
|
+
'cargo test',
|
|
43
|
+
'dotnet test',
|
|
44
|
+
'mvn test',
|
|
45
|
+
'gradle test',
|
|
46
|
+
'./gradlew test',
|
|
47
|
+
'php artisan test',
|
|
48
|
+
'vendor/bin/phpunit',
|
|
49
|
+
].map(command => `Bash(${command} *)`);
|
|
50
|
+
/** Never allowed in any run: the framework leaves commits, pushes and destructive Git operations to the user. */
|
|
51
|
+
const ALWAYS_DENY = ['git commit', 'git push', 'git reset', 'git clean', 'git checkout', 'git switch', 'git stash', 'git rebase', 'git merge', 'git tag', 'rm'].map(command => `Bash(${command} *)`);
|
|
52
|
+
export function permissionsFor(kind, paths, python) {
|
|
53
|
+
const hub = ruleRoot(paths.hub);
|
|
54
|
+
const target = paths.target ? ruleRoot(paths.target) : null;
|
|
55
|
+
const sameRoot = target === hub;
|
|
56
|
+
const allow = [...READ_TOOLS, ...GIT_READ, ...gitReadIn(paths.hub), ...(paths.target ? gitReadIn(paths.target) : []), ...(python ? pythonRules(python) : []), `Edit(${hub}/**)`];
|
|
57
|
+
const deny = [...WAITING_TOOLS, ...ALWAYS_DENY];
|
|
58
|
+
if (target && !sameRoot) {
|
|
59
|
+
if (kind === 'implement' || kind === 'chat')
|
|
60
|
+
allow.push(`Edit(${target}/**)`);
|
|
61
|
+
else
|
|
62
|
+
deny.push(`Edit(${target}/**)`);
|
|
63
|
+
}
|
|
64
|
+
if (kind === 'implement')
|
|
65
|
+
allow.push(...TEST_RUNNERS);
|
|
66
|
+
return { allow, deny };
|
|
67
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const PRICES = [
|
|
2
|
+
{ match: /fable|mythos/, price: { input: 10, write5m: 12.5, write1h: 20, read: 0.25, output: 50 } },
|
|
3
|
+
{ match: /opus-5-5/, price: { input: 4, write5m: 5, write1h: 8, read: 0.2, output: 20 } },
|
|
4
|
+
{ match: /opus/, price: { input: 5, write5m: 6.25, write1h: 10, read: 0.5, output: 25 } },
|
|
5
|
+
{ match: /sonnet-5-5/, price: { input: 2, write5m: 2.5, write1h: 4, read: 0.1, output: 10 } },
|
|
6
|
+
{ match: /sonnet/, price: { input: 2, write5m: 2.5, write1h: 4, read: 0.2, output: 10 } },
|
|
7
|
+
// Haiku 5.5 costs more for prompts over 100k tokens; a role's prompt rarely gets there.
|
|
8
|
+
{ match: /haiku-5-5/, price: { input: 0.1, write5m: 0.125, write1h: 0.2, read: 0.01, output: 0.5 } },
|
|
9
|
+
{ match: /haiku/, price: { input: 1, write5m: 1.25, write1h: 2, read: 0.1, output: 5 } },
|
|
10
|
+
];
|
|
11
|
+
export const priceOf = (model) => PRICES.find(entry => entry.match.test(model.toLowerCase()))?.price ?? null;
|
|
12
|
+
/** `cacheWrite1h` is the part of the cache writes kept for an hour; the rest is priced as 5-minute writes. */
|
|
13
|
+
export function costUsd(model, usage) {
|
|
14
|
+
const price = priceOf(model);
|
|
15
|
+
if (!price)
|
|
16
|
+
return null;
|
|
17
|
+
const long = Math.min(usage.cacheWrite1h ?? 0, usage.cacheWriteTokens);
|
|
18
|
+
return ((usage.inputTokens * price.input +
|
|
19
|
+
long * price.write1h +
|
|
20
|
+
(usage.cacheWriteTokens - long) * price.write5m +
|
|
21
|
+
usage.cacheReadTokens * price.read +
|
|
22
|
+
usage.outputTokens * price.output) /
|
|
23
|
+
1_000_000);
|
|
24
|
+
}
|