pirl-framework-cli 0.0.0-stage → 1.0.0

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +265 -2
  4. package/bundled-framework/files/.agents/evals/layer_record.py +438 -0
  5. package/bundled-framework/files/.agents/evals/tests/test_layer_record.py +264 -0
  6. package/bundled-framework/files/.agents/evals/tests/test_skills.py +210 -0
  7. package/bundled-framework/files/.agents/evals/tests/test_validate_layer_artifact.py +214 -0
  8. package/bundled-framework/files/.agents/evals/validate_layer_artifact.py +415 -0
  9. package/bundled-framework/files/.agents/instructions/implement.instructions.md +343 -0
  10. package/bundled-framework/files/.agents/instructions/layered-task-execution.instructions.md +370 -0
  11. package/bundled-framework/files/.agents/instructions/layout-verification.instructions.md +107 -0
  12. package/bundled-framework/files/.agents/instructions/plan.instructions.md +292 -0
  13. package/bundled-framework/files/.agents/instructions/review.instructions.md +236 -0
  14. package/bundled-framework/files/.agents/instructions/self-evaluation.instructions.md +146 -0
  15. package/bundled-framework/files/.agents/instructions/standalone-and-maintenance.instructions.md +230 -0
  16. package/bundled-framework/files/.agents/roles/backend-developer.role.md +45 -0
  17. package/bundled-framework/files/.agents/roles/business-analyst.role.md +55 -0
  18. package/bundled-framework/files/.agents/roles/code-analyst.role.md +63 -0
  19. package/bundled-framework/files/.agents/roles/code-reviewer.role.md +46 -0
  20. package/bundled-framework/files/.agents/roles/frontend-developer.role.md +51 -0
  21. package/bundled-framework/files/.agents/roles/process-auditor.role.md +62 -0
  22. package/bundled-framework/files/.agents/roles/solution-architect.role.md +53 -0
  23. package/bundled-framework/files/.agents/roles/technical-writer.role.md +68 -0
  24. package/bundled-framework/files/.agents/roles/ux-designer.role.md +46 -0
  25. package/bundled-framework/files/.skills/pirl-implement/SKILL.md +115 -0
  26. package/bundled-framework/files/.skills/pirl-implement/assets/implementation-record-template.md +22 -0
  27. package/bundled-framework/files/.skills/pirl-implement/references/implementation-record-format.md +41 -0
  28. package/bundled-framework/files/.skills/pirl-implement/scripts/add_implementation_record.py +58 -0
  29. package/bundled-framework/files/.skills/pirl-implement/scripts/new_layout_run.py +51 -0
  30. package/bundled-framework/files/.skills/pirl-plan/SKILL.md +100 -0
  31. package/bundled-framework/files/.skills/pirl-plan/assets/plan-template.md +48 -0
  32. package/bundled-framework/files/.skills/pirl-plan/references/plan-format.md +65 -0
  33. package/bundled-framework/files/.skills/pirl-plan/scripts/new_plan.py +93 -0
  34. package/bundled-framework/files/.skills/pirl-review/SKILL.md +106 -0
  35. package/bundled-framework/files/.skills/pirl-review/assets/review-template.md +92 -0
  36. package/bundled-framework/files/.skills/pirl-review/references/review-format.md +70 -0
  37. package/bundled-framework/files/.skills/pirl-review/scripts/new_review.py +83 -0
  38. package/bundled-framework/files/AGENTS.md +303 -0
  39. package/bundled-framework/files/CONTEXT.md +187 -0
  40. package/bundled-framework/files/LICENSE +21 -0
  41. package/bundled-framework/files/README.md +170 -0
  42. package/bundled-framework/files/gitignore.pirl-bundled +12 -0
  43. package/bundled-framework/manifest.json +45 -0
  44. package/dist/bin/main.js +128 -0
  45. package/dist/bin/pirl.js +6 -0
  46. package/dist/commands/dispatch.js +57 -0
  47. package/dist/commands/registry.js +39 -0
  48. package/dist/core/clipboard.js +70 -0
  49. package/dist/core/config.js +73 -0
  50. package/dist/core/exec.js +32 -0
  51. package/dist/core/paths.js +34 -0
  52. package/dist/core/targetRepo.js +22 -0
  53. package/dist/doctor/doctor.js +95 -0
  54. package/dist/engine/agentMerge.js +71 -0
  55. package/dist/engine/agents.js +67 -0
  56. package/dist/engine/attachments.js +251 -0
  57. package/dist/engine/claudeQuota.js +62 -0
  58. package/dist/engine/claudeRunner.js +118 -0
  59. package/dist/engine/claudeStream.js +205 -0
  60. package/dist/engine/hubData.js +176 -0
  61. package/dist/engine/permissions.js +67 -0
  62. package/dist/engine/pricing.js +24 -0
  63. package/dist/engine/protocol.js +98 -0
  64. package/dist/engine/run.js +527 -0
  65. package/dist/engine/runStore.js +67 -0
  66. package/dist/engine/types.js +3 -0
  67. package/dist/hub/framework.js +13 -0
  68. package/dist/hub/hub.js +125 -0
  69. package/dist/hub/services.js +38 -0
  70. package/dist/hub/update.js +261 -0
  71. package/dist/providers/claude.js +62 -0
  72. package/dist/providers/index.js +17 -0
  73. package/dist/providers/types.js +1 -0
  74. package/dist/ui/App.js +849 -0
  75. package/dist/ui/browse.js +144 -0
  76. package/dist/ui/components/Header.js +54 -0
  77. package/dist/ui/components/Input.js +76 -0
  78. package/dist/ui/components/Lines.js +50 -0
  79. package/dist/ui/components/Panels.js +73 -0
  80. package/dist/ui/content.js +225 -0
  81. package/dist/ui/frameworkConsole.js +541 -0
  82. package/dist/ui/inputEditor.js +86 -0
  83. package/dist/ui/layout.js +36 -0
  84. package/dist/ui/lines.js +15 -0
  85. package/dist/ui/listOverlay.js +97 -0
  86. package/dist/ui/runView.js +305 -0
  87. package/dist/ui/session.js +1 -0
  88. package/dist/ui/terminalInput.js +45 -0
  89. package/dist/ui/text.js +58 -0
  90. package/dist/ui/theme.js +139 -0
  91. package/dist/ui/ticker.js +48 -0
  92. package/package.json +67 -4
@@ -0,0 +1,98 @@
1
+ const SKILL = {
2
+ plan: '.skills/pirl-plan/SKILL.md',
3
+ implement: '.skills/pirl-implement/SKILL.md',
4
+ review: '.skills/pirl-review/SKILL.md',
5
+ };
6
+ const LAYER_NAME = { plan: 'Plan', implement: 'Implement', review: 'Review' };
7
+ export function hostInstructions(context) {
8
+ const python = context.python ? [context.python.command, ...context.python.args].join(' ') : 'python';
9
+ const lines = [
10
+ '# PIRL CLI host',
11
+ '',
12
+ 'You are the coordinator of the PIRL framework, run by PIRL CLI, a terminal UI, in non-interactive mode.',
13
+ `- The Hub (framework root) is your working directory: ${context.hub}. Read AGENTS.md there first and follow it, with CONTEXT.md for every answer.`,
14
+ context.target
15
+ ? `- The target repository is ${context.target}. Write it as an absolute path wherever a Plan or report records target_repo.`
16
+ : '- No target repository: the user started PIRL CLI outside a Git repository.',
17
+ `- Run every Python command of the framework as \`${python} -B ...\` from the Hub root (for example \`${python} -B .agents/evals/layer_record.py start\`). Other spellings are refused.`,
18
+ '- Run each framework command as one plain command from the Hub root: no `cd`, no heredoc, no pipe, no `&&`. Pass JSON through a file, for example `layer_record.py append --input .tmp/pirl-run/record.json`.',
19
+ context.target
20
+ ? `- Read the target repository's Git history with \`git -C ${context.target} log ...\` (also show, diff, status, ls-files, rev-parse, blame), the path written exactly like that.`
21
+ : '',
22
+ '- Temporary files go under `.tmp/pirl-run/` in the Hub; PIRL CLI deletes that directory when the run ends, so do not delete them yourself. Nothing outside the Hub and the target repository is writable; `/tmp` is refused.',
23
+ '- Run subagents in the foreground and wait for their results in this turn. Do not start background work or schedule wakeups: the run ends when your reply ends.',
24
+ '- The run\'s permissions are fixed by PIRL CLI and cannot change during the run. Never ask the user to grant a permission; when a command is refused, use the allowed form above, or say what could not be done.',
25
+ '- Write your replies to the user in the language of the user\'s request; the framework\'s files (Plans, reports, records) keep the language their format requires.',
26
+ ];
27
+ if (context.kind === 'chat') {
28
+ lines.push('- No layer was opened. The user typed text without a slash command: answer a question, do a standalone task or framework maintenance, per AGENTS.md "Choosing a layer".', '- If the user\'s words ask for a layer (create a Plan, implement a Plan, review), do not run it. End your reply with the slash command the user should type, in a fenced block:', '```pirl-suggest', '/pirl-plan <the request, restated>', '```');
29
+ if (context.recentOutputs?.length)
30
+ lines.push(`- Outputs of earlier runs in this session: ${context.recentOutputs.join('; ')}.`);
31
+ }
32
+ else {
33
+ const kind = context.kind;
34
+ lines.push(`- The user opened the ${LAYER_NAME[kind]} layer with /pirl-${kind}; that is their explicit request for this layer and for delegating its roles to agents. Run the layer through \`${SKILL[kind]}\`. Do not open any other layer.`, `- Delegate each role to the subagent of the same name (business-analyst, code-analyst, …), with the Agent tool. Run at most ${context.maxParallelRoles} role subagents at the same time.`);
35
+ }
36
+ lines.push('- Nobody can answer a prompt while you run. When a decision only the user can make is missing, finish the parts that do not depend on it, then end your reply with the questions in a fenced block; PIRL CLI shows them and resumes this session with the answers:', '```pirl-questions', '[{"id": "Q1", "question": "…", "options": ["…", "…"], "recommended": 0}]', '```', '- Do not commit, push or switch branches anywhere. Leave framework improvements uncommitted in the Hub: PIRL CLI shows them and asks the user whether to commit.', '- Some commands are refused by the run\'s permissions. When one is refused, do not retry it; say what could not be done.');
37
+ if (context.kind !== 'chat') {
38
+ lines.push('- At the very end of your final reply, after the closing block of CONTEXT.md, add the outputs of this run in a fenced block (paths absolute):', '```pirl-result', '{"plan_set": "<package directory or null>", "plan_file": "<Plan file or null>", "report": "<Review report or null>", "record_id": "<record id or null>"}', '```');
39
+ }
40
+ return lines.filter(line => line !== '').join('\n');
41
+ }
42
+ const blockPattern = (name) => new RegExp('```' + name + '\\s*\\n([\\s\\S]*?)\\n?```', 'g');
43
+ /** Text of the last fenced block with this name, or null. */
44
+ export function lastBlock(text, name) {
45
+ const matches = [...text.matchAll(blockPattern(name))];
46
+ return matches.length > 0 ? matches.at(-1)[1].trim() : null;
47
+ }
48
+ /** The reply as the user should read it: the machine-readable blocks are taken out. */
49
+ export function stripBlocks(text) {
50
+ return ['pirl-questions', 'pirl-result', 'pirl-suggest']
51
+ .reduce((current, name) => current.replace(blockPattern(name), ''), text)
52
+ .replace(/\n{3,}/g, '\n\n')
53
+ .trim();
54
+ }
55
+ export function parseQuestions(text) {
56
+ const block = lastBlock(text, 'pirl-questions');
57
+ if (!block)
58
+ return [];
59
+ try {
60
+ const data = JSON.parse(block);
61
+ if (!Array.isArray(data))
62
+ return [];
63
+ return data
64
+ .filter((item) => !!item && typeof item === 'object' && typeof item['question'] === 'string')
65
+ .map((item, index) => ({
66
+ id: typeof item['id'] === 'string' ? item['id'] : `Q${index + 1}`,
67
+ question: String(item['question']),
68
+ options: Array.isArray(item['options']) ? item['options'].map(String) : [],
69
+ recommended: typeof item['recommended'] === 'number' ? item['recommended'] : undefined,
70
+ }));
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ }
76
+ export function parseOutputs(text) {
77
+ const empty = { planSet: null, planFile: null, report: null, recordId: null };
78
+ const block = lastBlock(text, 'pirl-result');
79
+ if (!block)
80
+ return empty;
81
+ try {
82
+ const data = JSON.parse(block);
83
+ const value = (key) => (typeof data[key] === 'string' && data[key] !== 'null' && data[key] !== '' ? data[key] : null);
84
+ return { planSet: value('plan_set'), planFile: value('plan_file'), report: value('report'), recordId: value('record_id') };
85
+ }
86
+ catch {
87
+ return empty;
88
+ }
89
+ }
90
+ export const parseSuggestion = (text) => lastBlock(text, 'pirl-suggest')?.split('\n')[0]?.trim() || null;
91
+ /** The message that resumes the session once the user has answered every question. */
92
+ export function answersMessage(questions, answers) {
93
+ return [
94
+ 'Câu trả lời của người dùng cho các câu hỏi trong khối pirl-questions:',
95
+ ...questions.map((question, index) => `- ${question.id} (${question.question}): ${answers[index] ?? '(không trả lời)'}`),
96
+ 'Ghi các câu trả lời này đúng nguyên văn vào output của run như AGENTS.md yêu cầu, rồi tiếp tục.',
97
+ ].join('\n');
98
+ }
@@ -0,0 +1,527 @@
1
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, relative } from 'node:path';
3
+ import { exec as defaultExec, succeeded } from '../core/exec.js';
4
+ import { roleAgents } from './agents.js';
5
+ import { allowedPaths, changedPaths, commitImprovements, countRecords, discardImprovements, listPlanSets, outsideAllowed, readFrontmatter, } from './hubData.js';
6
+ import { permissionsFor } from './permissions.js';
7
+ import { answersMessage, hostInstructions, parseOutputs, parseQuestions, parseSuggestion, stripBlocks } from './protocol.js';
8
+ import { emptyUsage, LAYER_LABELS } from './types.js';
9
+ /** Entries kept in memory for the TUI; the log file keeps every entry. Bounded so a long run costs the same to draw. */
10
+ export const LOG_LIMIT = 200;
11
+ const MAX_REPAIRS = 2;
12
+ /** One line for the log: the run's tokens so far by kind, and their price at the Claude API list price. */
13
+ function usageDetail(usage) {
14
+ if (usage.cacheReadTokens + usage.cacheWriteTokens + usage.inputTokens + usage.outputTokens === 0)
15
+ return null;
16
+ const cost = usage.costUsd === null ? '' : `, ≈ $${usage.costUsd.toFixed(3)} theo giá API`;
17
+ return `Token đến giờ: ${usage.cacheReadTokens} đọc cache, ${usage.cacheWriteTokens} ghi cache, ${usage.outputTokens} ra, ${usage.inputTokens} vào mới${cost} (${usage.models.join(', ')})`;
18
+ }
19
+ /**
20
+ * One layer run, or one chat message, from start to the user's decision on framework improvements.
21
+ * The provider does the reasoning; this class does every deterministic step around it: the permission rules,
22
+ * the validator gate, the check that process-auditor wrote exactly one record, the Allowed paths check
23
+ * after Implement, and the commit question for improvements.
24
+ */
25
+ export class RunController {
26
+ deps;
27
+ state;
28
+ listeners = new Set();
29
+ handle = null;
30
+ exec;
31
+ repairs = 0;
32
+ before = { hubChanges: [], records: 0, planSets: [], targetChanges: [] };
33
+ resumedBefore;
34
+ stopped = false;
35
+ /** The usage before the current provider call, and the provider session's totals reported so far. */
36
+ callBase = null;
37
+ key;
38
+ firstStartedAt;
39
+ providerTotals = {};
40
+ deniedIds = new Set();
41
+ logStream = null;
42
+ now;
43
+ constructor(deps, id, kind, request, resume) {
44
+ this.deps = deps;
45
+ this.exec = deps.exec ?? defaultExec;
46
+ this.now = deps.now ?? Date.now;
47
+ const startedAt = this.now();
48
+ this.key = resume?.key ?? `${logStamp(new Date(startedAt))}-${kind}-${id}`;
49
+ this.firstStartedAt = resume?.startedAt ?? new Date(startedAt).toISOString();
50
+ const logFile = deps.logDir ? join(deps.logDir, `${logStamp(new Date(startedAt))}-${kind}-${id}.log`) : null;
51
+ this.state = {
52
+ id,
53
+ kind,
54
+ request,
55
+ status: 'running',
56
+ sessionId: resume?.sessionId ?? null,
57
+ roles: [],
58
+ activity: null,
59
+ questions: resume?.questions ?? [],
60
+ answers: [],
61
+ reply: null,
62
+ outputs: { planSet: null, planFile: null, report: null, recordId: null },
63
+ checks: {},
64
+ improvements: null,
65
+ suggestion: null,
66
+ denials: [],
67
+ usage: emptyUsage(),
68
+ quota: { before: null, after: null },
69
+ error: null,
70
+ startedAt,
71
+ log: [],
72
+ logCount: 0,
73
+ logFile,
74
+ };
75
+ if (resume?.status === 'waiting' && resume.questions?.length)
76
+ this.state.status = 'waiting';
77
+ this.resumedBefore = resume?.before;
78
+ this.providerTotals = resume?.providerTotals ?? {};
79
+ this.firstPrompt = resume ? resume.prompt ?? 'Tiếp tục run bị dừng từ chỗ đã dừng.' : request;
80
+ // Attachments go with the user's own words: a new request or a chat message, not a "continue".
81
+ if (deps.attachments?.text && (!resume || resume.prompt))
82
+ this.firstPrompt = `${this.firstPrompt}\n\n${deps.attachments.text}`;
83
+ }
84
+ /** What the first call sends: the request, a chat message into an existing session, or "continue". */
85
+ firstPrompt;
86
+ /** What /resume needs to continue this run after PIRL CLI was closed. */
87
+ persisted() {
88
+ return {
89
+ key: this.key,
90
+ startedAt: this.firstStartedAt,
91
+ savedAt: new Date(this.now()).toISOString(),
92
+ kind: this.state.kind,
93
+ request: this.state.request,
94
+ sessionId: this.state.sessionId,
95
+ status: this.state.status,
96
+ questions: this.state.questions,
97
+ before: this.before,
98
+ providerTotals: this.providerTotals,
99
+ };
100
+ }
101
+ subscribe(listener) {
102
+ this.listeners.add(listener);
103
+ return () => this.listeners.delete(listener);
104
+ }
105
+ update(patch, entry) {
106
+ if (entry) {
107
+ const line = { at: this.now() - this.state.startedAt, ...entry };
108
+ patch = { ...patch, log: [...this.state.log.slice(-(LOG_LIMIT - 1)), line], logCount: this.state.logCount + 1 };
109
+ this.writeLog(line);
110
+ }
111
+ this.state = { ...this.state, ...patch };
112
+ if (patch.status && TERMINAL.includes(patch.status))
113
+ this.closeLog();
114
+ for (const listener of this.listeners)
115
+ listener(this.state);
116
+ }
117
+ /** Appends to the log file; a log that cannot be written never stops the run. */
118
+ writeLog(entry) {
119
+ if (!this.state.logFile)
120
+ return;
121
+ try {
122
+ if (!this.logStream) {
123
+ mkdirSync(dirname(this.state.logFile), { recursive: true });
124
+ this.logStream = createWriteStream(this.state.logFile, { flags: 'a', encoding: 'utf8' });
125
+ this.logStream.on('error', () => {
126
+ this.logStream = null;
127
+ });
128
+ }
129
+ this.logStream.write(`${formatElapsed(entry.at)} ${entry.who.padEnd(18)} ${entry.text}\n`);
130
+ }
131
+ catch {
132
+ this.logStream = null;
133
+ }
134
+ }
135
+ closeLog() {
136
+ this.logStream?.end();
137
+ this.logStream = null;
138
+ }
139
+ get layer() {
140
+ return this.state.kind === 'chat' ? null : this.state.kind;
141
+ }
142
+ /** Records what is in the Hub and the target before the provider runs, to tell this run's changes apart. */
143
+ async snapshot() {
144
+ if (this.resumedBefore) {
145
+ // A resumed run compares against the state before it first started, not before the resume.
146
+ this.before = this.resumedBefore;
147
+ return;
148
+ }
149
+ const layer = this.layer;
150
+ this.before = {
151
+ hubChanges: await changedPaths(this.deps.hub, this.exec),
152
+ records: layer ? countRecords(this.deps.hub, layer) : 0,
153
+ planSets: listPlanSets(this.deps.hub),
154
+ targetChanges: this.deps.target && this.deps.target !== this.deps.hub ? await changedPaths(this.deps.target, this.exec) : [],
155
+ };
156
+ }
157
+ async start() {
158
+ this.update({}, { who: 'pirl', text: `${this.state.sessionId ? 'Chạy tiếp' : 'Bắt đầu'} ${LAYER_LABELS[this.state.kind]}: ${this.state.request.split('\n')[0].slice(0, 120)}` });
159
+ await this.guard(async () => {
160
+ await this.snapshot();
161
+ if (this.state.status === 'waiting')
162
+ return; // resumed with unanswered questions: wait for the user
163
+ await this.send(this.firstPrompt);
164
+ });
165
+ }
166
+ /** Any unexpected error ends the run as failed with its message, instead of leaving it running forever. */
167
+ async guard(work) {
168
+ try {
169
+ await work();
170
+ }
171
+ catch (error) {
172
+ this.handle = null;
173
+ const message = error instanceof Error ? error.message : String(error);
174
+ this.update({ status: 'failed', error: message }, { who: 'pirl', text: `Lỗi: ${message}`, tone: 'error' });
175
+ }
176
+ }
177
+ /** Answers to the questions, in order; resumes the provider session once all are given. */
178
+ async answer(answers) {
179
+ if (this.state.status !== 'waiting')
180
+ return;
181
+ const questions = this.state.questions;
182
+ this.update({ answers, status: 'running', questions: [] });
183
+ await this.guard(() => this.send(answersMessage(questions, answers)));
184
+ }
185
+ stop() {
186
+ this.stopped = true;
187
+ this.handle?.stop();
188
+ if (!this.handle)
189
+ this.update({ status: 'stopped' }, { who: 'pirl', text: 'Đã dừng' });
190
+ }
191
+ async send(prompt) {
192
+ // Stopped while the run was still preparing (the snapshot can take a moment): start no provider at all.
193
+ if (this.stopped) {
194
+ if (this.state.status !== 'stopped')
195
+ this.update({ status: 'stopped' }, { who: 'pirl', text: 'Đã dừng' });
196
+ return;
197
+ }
198
+ const deps = this.deps;
199
+ const request = {
200
+ cwd: deps.hub,
201
+ addDirs: [...(deps.target && deps.target !== deps.hub ? [deps.target] : []), ...(deps.attachments?.dirs ?? [])],
202
+ prompt,
203
+ systemPrompt: hostInstructions({
204
+ kind: this.state.kind,
205
+ hub: deps.hub,
206
+ target: deps.target,
207
+ python: deps.python,
208
+ maxParallelRoles: deps.maxParallelRoles,
209
+ recentOutputs: deps.recentOutputs,
210
+ }),
211
+ agents: deps.agents ?? roleAgents(deps.hub, undefined, deps.roleModel, deps.target && deps.target !== deps.hub ? deps.target : null, deps.effort),
212
+ permissions: permissionsFor(this.state.kind, { hub: deps.hub, target: deps.target }, deps.python),
213
+ resumeSessionId: this.state.sessionId ?? undefined,
214
+ model: deps.model,
215
+ effort: deps.effort,
216
+ };
217
+ let result = null;
218
+ // The first meters are read while the provider starts up, before its first model request.
219
+ if (this.state.usage.calls === 0)
220
+ void this.readQuota('before');
221
+ this.update({ status: 'running', error: null, usage: { ...this.state.usage, calls: this.state.usage.calls + 1 } });
222
+ this.callBase = this.state.usage;
223
+ this.handle = deps.runner.start(request, event => {
224
+ if (event.type === 'result')
225
+ result = event;
226
+ this.apply(event);
227
+ });
228
+ const exit = await this.handle.done;
229
+ this.handle = null;
230
+ void this.readQuota('after');
231
+ const spent = usageDetail(this.state.usage);
232
+ if (spent)
233
+ this.update({}, { who: 'pirl', text: spent });
234
+ if (this.stopped) {
235
+ this.update({ status: 'stopped', roles: this.state.roles.map(role => (role.state === 'running' ? { ...role, state: 'failed' } : role)) }, { who: 'pirl', text: 'Đã dừng theo yêu cầu', tone: 'warning' });
236
+ return;
237
+ }
238
+ const final = result;
239
+ if (!final || final.isError) {
240
+ const detail = final?.text || exit.stderr.trim().split('\n').slice(-3).join(' ') || `mã thoát ${exit.code}`;
241
+ this.update({ status: 'failed', error: detail, reply: final?.text ? stripBlocks(final.text) : this.state.reply }, { who: 'pirl', text: `Lỗi: ${detail.slice(0, 200)}`, tone: 'error' });
242
+ return;
243
+ }
244
+ await this.afterReply(final.text);
245
+ }
246
+ /**
247
+ * Replaces the live count of the provider call with its exact totals. They cover the whole provider session,
248
+ * so the totals reported by its earlier calls are taken off. The request count stays the live one.
249
+ */
250
+ applyTotals(models) {
251
+ const base = this.callBase ?? this.state.usage;
252
+ const usage = { ...base, requests: this.state.usage.requests, models: [...base.models] };
253
+ const keys = ['inputTokens', 'cacheWriteTokens', 'cacheReadTokens', 'outputTokens'];
254
+ for (const [model, total] of Object.entries(models)) {
255
+ const before = this.providerTotals[model];
256
+ for (const key of keys)
257
+ usage[key] += Math.max(0, total[key] - (before?.[key] ?? 0));
258
+ const cost = total.costUsd === null ? null : Math.max(0, total.costUsd - (before?.costUsd ?? 0));
259
+ usage.costUsd = usage.costUsd === null || cost === null ? null : usage.costUsd + cost;
260
+ if (!usage.models.includes(model))
261
+ usage.models.push(model);
262
+ }
263
+ this.providerTotals = models;
264
+ this.update({ usage });
265
+ }
266
+ async readQuota(when) {
267
+ if (!this.deps.quota)
268
+ return;
269
+ let rows = null;
270
+ try {
271
+ rows = await this.deps.quota();
272
+ }
273
+ catch { }
274
+ if (rows)
275
+ this.update({ quota: { ...this.state.quota, [when]: rows } });
276
+ }
277
+ roleName(toolUseId) {
278
+ return toolUseId ? this.state.roles.find(role => role.toolUseId === toolUseId)?.role ?? null : null;
279
+ }
280
+ apply(event) {
281
+ switch (event.type) {
282
+ case 'session':
283
+ // A new provider session starts its totals from zero.
284
+ if (this.state.sessionId && event.sessionId !== this.state.sessionId)
285
+ this.providerTotals = {};
286
+ if (event.sessionId !== this.state.sessionId)
287
+ this.update({ sessionId: event.sessionId }, { who: 'pirl', text: `Session ${event.sessionId.slice(0, 8)}` });
288
+ break;
289
+ case 'role-start':
290
+ this.update({ roles: [...this.state.roles, { toolUseId: event.toolUseId, role: event.role, description: event.description, state: 'running', activity: null }] }, { who: event.role, text: `bắt đầu: ${event.description}` });
291
+ break;
292
+ case 'role-end': {
293
+ const role = this.roleName(event.toolUseId);
294
+ if (role) {
295
+ this.update({ roles: this.state.roles.map(item => (item.toolUseId === event.toolUseId ? { ...item, state: event.ok ? 'done' : 'failed' } : item)) }, { who: role, text: event.ok ? 'xong' : 'kết thúc với lỗi', tone: event.ok ? 'success' : 'error' });
296
+ }
297
+ break;
298
+ }
299
+ case 'activity': {
300
+ const role = this.roleName(event.agent);
301
+ if (role) {
302
+ this.update({ roles: this.state.roles.map(item => (item.toolUseId === event.agent ? { ...item, activity: event.text } : item)) }, { who: role, text: event.text });
303
+ }
304
+ else {
305
+ this.update({ activity: event.text }, { who: 'coordinator', text: event.text });
306
+ }
307
+ break;
308
+ }
309
+ case 'retry': {
310
+ const text = `Provider lỗi tạm thời (${event.error}), thử lại lần ${event.attempt}`;
311
+ this.update({ activity: text }, { who: 'pirl', text, tone: 'warning' });
312
+ break;
313
+ }
314
+ case 'denied':
315
+ // Claude Code reports a refusal while it happens and again in the result: count it once.
316
+ if (event.id ? this.deniedIds.has(event.id) : this.state.denials.includes(event.detail))
317
+ break;
318
+ if (event.id)
319
+ this.deniedIds.add(event.id);
320
+ this.update({ denials: [...this.state.denials, event.detail] }, { who: 'pirl', text: `Chặn (ngoài quyền của run): ${event.detail}`, tone: 'warning' });
321
+ break;
322
+ case 'usage': {
323
+ const usage = this.state.usage;
324
+ this.update({
325
+ usage: {
326
+ calls: usage.calls,
327
+ requests: usage.requests + (event.request ? 1 : 0),
328
+ inputTokens: usage.inputTokens + event.inputTokens,
329
+ cacheWriteTokens: usage.cacheWriteTokens + event.cacheWriteTokens,
330
+ cacheReadTokens: usage.cacheReadTokens + event.cacheReadTokens,
331
+ outputTokens: usage.outputTokens + event.outputTokens,
332
+ costUsd: usage.costUsd === null || event.costUsd === null ? null : usage.costUsd + event.costUsd,
333
+ models: usage.models.includes(event.model) ? usage.models : [...usage.models, event.model],
334
+ },
335
+ });
336
+ break;
337
+ }
338
+ case 'usage-totals':
339
+ this.applyTotals(event.models);
340
+ break;
341
+ case 'quota':
342
+ // The windows as they stand now; the level before the run comes only from the reading taken before it.
343
+ this.update({ quota: { ...this.state.quota, after: event.rows } });
344
+ break;
345
+ case 'result':
346
+ this.update({ sessionId: event.sessionId ?? this.state.sessionId });
347
+ break;
348
+ case 'text':
349
+ break;
350
+ }
351
+ }
352
+ async afterReply(text) {
353
+ const questions = parseQuestions(text);
354
+ const outputs = parseOutputs(text);
355
+ this.update({
356
+ reply: stripBlocks(text),
357
+ outputs: {
358
+ planSet: outputs.planSet ?? this.state.outputs.planSet,
359
+ planFile: outputs.planFile ?? this.state.outputs.planFile,
360
+ report: outputs.report ?? this.state.outputs.report,
361
+ recordId: outputs.recordId ?? this.state.outputs.recordId,
362
+ },
363
+ suggestion: this.state.kind === 'chat' ? parseSuggestion(text) : null,
364
+ roles: this.state.roles.map(role => (role.state === 'running' ? { ...role, state: 'done' } : role)),
365
+ });
366
+ if (questions.length > 0) {
367
+ this.update({ status: 'waiting', questions, answers: [] }, { who: 'pirl', text: `Chờ bạn trả lời ${questions.length} câu hỏi`, tone: 'warning' });
368
+ return;
369
+ }
370
+ if (this.layer) {
371
+ this.update({ status: 'checking' }, { who: 'pirl', text: 'Coordinator xong; kiểm tra kết quả' });
372
+ const repair = await this.checkLayer();
373
+ if (repair && this.repairs < (this.deps.maxRepairs ?? MAX_REPAIRS)) {
374
+ this.repairs += 1;
375
+ this.update({}, { who: 'pirl', text: `Gửi coordinator sửa (lần ${this.repairs})`, tone: 'warning' });
376
+ await this.send(repair);
377
+ return;
378
+ }
379
+ }
380
+ await this.findImprovements();
381
+ }
382
+ resolveHubPath(path) {
383
+ if (!path)
384
+ return null;
385
+ const full = isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) ? path : join(this.deps.hub, path);
386
+ return existsSync(full) ? full : null;
387
+ }
388
+ async python(args) {
389
+ const python = this.deps.python;
390
+ if (!python)
391
+ return { code: null, stdout: '', stderr: '', error: 'Không tìm thấy Python' };
392
+ return this.exec(python.command, [...python.args, '-B', ...args], { cwd: this.deps.hub, timeoutMs: 120_000 });
393
+ }
394
+ /** The checks the layer's skill ends with, done by the CLI itself. Returns a repair message, or null. */
395
+ async checkLayer() {
396
+ const layer = this.layer;
397
+ const checks = {};
398
+ const repairs = [];
399
+ // Validator: the Plan package after Plan, the Plan file after Implement, the report after Review.
400
+ const newPlanSets = listPlanSets(this.deps.hub).filter(name => !this.before.planSets.includes(name));
401
+ const target = layer === 'plan'
402
+ ? this.resolveHubPath(this.state.outputs.planSet) ?? (newPlanSets.length === 1 ? join(this.deps.hub, newPlanSets[0]) : null)
403
+ : layer === 'implement'
404
+ ? this.resolveHubPath(this.state.outputs.planFile)
405
+ : this.resolveHubPath(this.state.outputs.report);
406
+ if (!target) {
407
+ checks.validator = { state: 'skipped', detail: 'không xác định được file kết quả của run' };
408
+ }
409
+ else {
410
+ const result = await this.python(['.agents/evals/validate_layer_artifact.py', '--kind', layer === 'review' ? 'review' : 'plan', '--path', target]);
411
+ if (succeeded(result))
412
+ checks.validator = { state: 'pass', detail: relative(this.deps.hub, target) || target };
413
+ else {
414
+ const detail = (result.stdout || result.stderr || result.error || '').trim().slice(0, 1500);
415
+ checks.validator = { state: 'fail', detail };
416
+ repairs.push(`Validator báo lỗi cho ${target}:\n${detail}\nSửa các lỗi này rồi chạy lại validator cho tới khi PASS.`);
417
+ }
418
+ if (layer === 'plan' && !this.state.outputs.planSet)
419
+ this.update({ outputs: { ...this.state.outputs, planSet: target } });
420
+ }
421
+ // Self-evaluation: exactly one new record for this layer, and the records still pass `check`.
422
+ const added = countRecords(this.deps.hub, layer) - this.before.records;
423
+ const check = await this.python(['.agents/evals/layer_record.py', 'check']);
424
+ if (added < 1) {
425
+ checks.record = { state: 'fail', detail: 'process-auditor chưa ghi record cho run này' };
426
+ repairs.push('Bước tự đánh giá chưa xong: process-auditor chưa ghi record nào cho run này. Hoàn tất bước đó theo skill của layer.');
427
+ }
428
+ else if (!succeeded(check)) {
429
+ checks.record = { state: 'fail', detail: (check.stdout || check.stderr).trim().slice(0, 800) };
430
+ repairs.push(`\`layer_record.py check\` báo lỗi:\n${checks.record.detail}\nSửa record cho đúng.`);
431
+ }
432
+ else {
433
+ checks.record = { state: 'pass', detail: added > 1 ? `${added} record mới` : '1 record mới' };
434
+ }
435
+ // Implement may change only the Plan's Allowed paths in the target repository.
436
+ if (layer === 'implement' && this.deps.target && this.deps.target !== this.deps.hub) {
437
+ const planFile = this.resolveHubPath(this.state.outputs.planFile);
438
+ const allowed = planFile ? allowedPaths(readFileSync(planFile, 'utf8')) : [];
439
+ const changed = (await changedPaths(this.deps.target, this.exec)).filter(path => !this.before.targetChanges.includes(path));
440
+ const outside = outsideAllowed(changed, allowed);
441
+ checks.paths =
442
+ allowed.length === 0
443
+ ? { state: 'skipped', detail: 'Plan không có dòng Allowed paths' }
444
+ : outside.length === 0
445
+ ? { state: 'pass', detail: `${changed.length} file thay đổi, đều trong Allowed paths` }
446
+ : { state: 'fail', detail: `Ngoài Allowed paths: ${outside.join(', ')}` };
447
+ }
448
+ const labels = { validator: 'Validator', record: 'Record', paths: 'Allowed paths' };
449
+ for (const [key, check] of Object.entries(checks)) {
450
+ const verdict = check.state === 'pass' ? 'PASS' : check.state === 'fail' ? 'FAIL' : 'bỏ qua';
451
+ this.update({}, { who: 'pirl', text: `${labels[key]}: ${verdict}${check.detail ? ` (${check.detail.split('\n')[0].slice(0, 120)})` : ''}`, tone: check.state === 'pass' ? 'success' : check.state === 'fail' ? 'error' : undefined });
452
+ }
453
+ this.update({ checks });
454
+ return repairs.length > 0 ? repairs.join('\n\n') : null;
455
+ }
456
+ /** Framework files this run changed in the Hub; files already changed before the run are left alone. */
457
+ async findImprovements() {
458
+ // The run's temporary files (.tmp/pirl-run/) are removed here, as the host instructions promise the coordinator.
459
+ rmSync(join(this.deps.hub, '.tmp', 'pirl-run'), { recursive: true, force: true });
460
+ const now = await changedPaths(this.deps.hub, this.exec);
461
+ const files = now.filter(path => !this.before.hubChanges.includes(path));
462
+ if (files.length === 0) {
463
+ this.update({ status: 'done' }, { who: 'pirl', text: 'Xong', tone: 'success' });
464
+ return;
465
+ }
466
+ this.update({ status: 'checking', activity: 'Chạy test của framework trên các cải tiến' }, { who: 'pirl', text: `${files.length} file framework thay đổi; chạy test của framework` });
467
+ const tests = await this.frameworkTests();
468
+ this.update({ status: 'improvements', improvements: { files, state: 'pending', tests } }, tests.state === 'fail'
469
+ ? { who: 'pirl', text: `Test framework: FAIL; cải tiến này không được commit (${tests.detail?.split('\n')[0]?.slice(0, 120) ?? ''})`, tone: 'error' }
470
+ : { who: 'pirl', text: `Test framework: ${tests.state === 'pass' ? 'PASS' : 'bỏ qua'}; chờ bạn quyết định commit`, tone: 'warning' });
471
+ }
472
+ /** The framework's own tests on the Hub as the run left it: a broken framework must never be committed to main. */
473
+ async frameworkTests() {
474
+ if (!this.deps.python)
475
+ return { state: 'skipped', detail: 'Không tìm thấy Python' };
476
+ const result = await this.python(['-m', 'unittest', 'discover', '-s', '.agents/evals/tests', '-p', 'test_*.py']);
477
+ if (succeeded(result))
478
+ return { state: 'pass' };
479
+ const output = `${result.stderr}\n${result.stdout}`.trim().split('\n');
480
+ const failures = output.filter(line => /^(FAIL|ERROR):/.test(line));
481
+ return { state: 'fail', detail: (failures.length ? failures : output.slice(-5)).join('\n') || result.error || `mã thoát ${result.code}` };
482
+ }
483
+ /** The user's decision on this run's framework improvements. */
484
+ async resolveImprovements(choice) {
485
+ const pending = this.state.improvements;
486
+ if (!pending || pending.state !== 'pending')
487
+ return;
488
+ if (choice === 'commit' && pending.tests?.state === 'fail') {
489
+ this.update({ improvements: { ...pending, error: 'Test của framework đang lỗi: không commit một framework hỏng vào Hub. Chọn bỏ cải tiến, hoặc giữ để sửa tay.' } });
490
+ return;
491
+ }
492
+ if (choice === 'keep') {
493
+ this.update({ status: 'done', improvements: { ...pending, state: 'kept' } }, { who: 'pirl', text: 'Giữ cải tiến, chưa commit. Xong', tone: 'success' });
494
+ return;
495
+ }
496
+ const result = choice === 'commit'
497
+ ? await commitImprovements(this.deps.hub, pending.files, { kind: this.state.kind, recordId: this.state.outputs.recordId, summary: this.state.request.slice(0, 200) }, this.exec)
498
+ : await discardImprovements(this.deps.hub, pending.files, this.exec);
499
+ if (!result.ok) {
500
+ this.update({ improvements: { ...pending, error: result.error ?? 'lỗi không rõ' } });
501
+ return;
502
+ }
503
+ this.update({ status: 'done', improvements: { files: pending.files, state: choice === 'commit' ? 'committed' : 'discarded' } }, { who: 'pirl', text: `${choice === 'commit' ? 'Đã commit' : 'Đã bỏ'} ${pending.files.length} file cải tiến. Xong`, tone: 'success' });
504
+ }
505
+ /** Task ID and title of the Plan file this run produced or worked on, for the run block. */
506
+ planSummary() {
507
+ const file = this.resolveHubPath(this.state.outputs.planFile);
508
+ if (!file)
509
+ return null;
510
+ const fields = readFrontmatter(readFileSync(file, 'utf8'));
511
+ return `${fields['task_id'] ?? ''} ${fields['title'] ?? ''}`.trim() || null;
512
+ }
513
+ }
514
+ const TERMINAL = ['done', 'failed', 'stopped'];
515
+ /** mm:ss since the run started, or h:mm:ss after an hour. */
516
+ export function formatElapsed(ms) {
517
+ const total = Math.max(0, Math.floor(ms / 1000));
518
+ const hours = Math.floor(total / 3600);
519
+ const minutes = Math.floor((total % 3600) / 60);
520
+ const seconds = total % 60;
521
+ const two = (value) => String(value).padStart(2, '0');
522
+ return hours > 0 ? `${hours}:${two(minutes)}:${two(seconds)}` : `${two(minutes)}:${two(seconds)}`;
523
+ }
524
+ const logStamp = (date) => {
525
+ const two = (value) => String(value).padStart(2, '0');
526
+ return `${date.getFullYear()}${two(date.getMonth() + 1)}${two(date.getDate())}-${two(date.getHours())}${two(date.getMinutes())}${two(date.getSeconds())}`;
527
+ };