@rockysoft/opencode-hybrid-workflow 0.9.0 → 0.9.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/core/plugin.mjs CHANGED
@@ -1,93 +1,93 @@
1
- import {isReviewerSession} from '../lib/opencode-reviewer.mjs';
2
- import {roleText} from '../lib/reviewers.mjs';
3
- import { readFileSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
5
- import path from 'node:path';
6
- import { WORKFLOW_POLICY } from './workflow-policy.mjs';
7
- import { Controller } from './controller.mjs';
8
- import { readDashboard } from './monitor-data.mjs';
9
- import { DESIGN_POLICY, recordDesignRequest } from './design.mjs';
10
- import { researchTool } from './research-tools.mjs';
11
-
12
- const here = path.dirname(fileURLToPath(import.meta.url));
13
- // V2's Plugin.define is an identity function. This plain plugin needs no installed dependencies.
14
- import { FRONTEND_COMMON, FRONTEND_BUILD } from './frontend-policy.mjs';
15
- export const readTools = /^(read|glob|grep|list|ls|skill|webfetch|websearch|todowrite|todoread|question|subagent|hybrid_checkpoint|hybrid_activity|context7_.+|serena_(find_symbol|find_referencing_symbols|get_symbols_overview|search_for_pattern|read_file|list_dir|find_file|read_memory|list_memories|initial_instructions|check_onboarding_performed)|playwright_browser_(snapshot|take_screenshot|console_messages|network_requests))$/;
16
- export function createPlugin(config, storage = config.runtimeDirectory ?? path.join(here, 'runtime')) { return {
17
- id: 'Hybrid-Workflow',
18
- async setup(ctx) {
19
- if (!config.enabled) return; // Inert until explicit later activation.
20
- const compatible = config.compatibleOpenCode ?? [config.targetOpenCode];
21
- if (!compatible.includes(ctx.app.version)) throw new Error(`Hybrid plugin compatibility check required: running OpenCode ${ctx.app.version}; verified versions: ${compatible.join(', ')}. Run the isolated compatibility probe before adding this version. Existing task history is preserved.`);
22
- const controllers = new Map();
23
- const forSession = async sessionID => {
24
- const result = await ctx.session.get({ sessionID });
25
- const session = result.data ?? result;
26
- const directory = session.location?.directory;
27
- if (typeof directory !== 'string') throw new Error('Cannot resolve session workspace; refusing to act on plugin-instance directory.');
28
- if (!controllers.has(directory)) controllers.set(directory, new Controller(directory, config.storageFor?.(directory) ?? storage, config));
29
- return controllers.get(directory);
30
- };
31
- await ctx.session.hook('prompt', async event => {
32
- if(await isReviewerSession(ctx,event.sessionID))return;
33
- const response = await ctx.session.get({sessionID:event.sessionID});
34
- const session = response.data ?? response;
35
- if (session.parentID) return; // Subagent assignments are not new human design requests.
36
- const text = event.prompt.text;
37
- if (typeof text === 'string') recordDesignRequest(await forSession(event.sessionID),text,event.messageID);
38
- });
39
- await ctx.session.hook('context', async event => {
40
- if(await isReviewerSession(ctx,event.sessionID))return;
41
- event.system.push({type:'text',text:roleText(DESIGN_POLICY)});
42
- event.system.push({type:'text',text:roleText(WORKFLOW_POLICY)});
43
- event.system.push({type:'text',text:`${FRONTEND_COMMON}\n${FRONTEND_BUILD}`});
44
- event.system.push({type:'text',text:'Hybrid recovery: completed negative/blocked code reviews allow corrections and evidence collection in fix; continue automatically and re-review, never claim acceptance. action=fix recovers a legacy completed review. New evidence in checks/images resumes paused plan/design; action=replan with concrete changed requirements opens architecture revision while preserving findings and history. action=inspect checks installed tools and subscription logins without model calls and is available during planning. Never edit bridge state or reset history. Playwright research outputs use plain filenames in .playwright-mcp/hybrid-evidence; this generated evidence is excluded from source snapshots. Navigation/resizing and screenshot capture are available while planning/designing. Supply actual PNGs to plan/design/review as images [{path,caption,artifactHash}], using status.currentArtifactHash for the code/reference being inspected. Recapture after code changes and include interaction evidence in checks. Both reviewers receive pixels; never substitute hashes or ask the user to attest images merely because they live outside Git.'});
45
- });
46
- await ctx.tool.transform(editor => editor.add({
47
- name: 'hybrid_checkpoint',
48
- description: 'High-level checkpoints. begin: mode cosmetic/small/ui/feature, brief, designMode none/existing/supplied/new and reference designEvidence for reuse/supplied designs. Use ui for existing-app visual redesigns preserving architecture/behavior; design directly. Feature plan precedes new design: configured lead authors, configured reviewers validate. At least one pass and zero medium/high/critical findings from all configured reviewers required. Negative review permits corrections; fix recovers legacy completed blocked reviews. plan/design accept new checks/images to resolve evidence pauses. replan accepts changed requirements in checks, retaining history/baseline. inspect checks dependencies/auth without model calls. Actual PNG images [{path,caption,artifactHash}] can accompany plan/design/review; use status.currentArtifactHash. Playwright outputs are in project/.playwright-mcp/hybrid-evidence. Follow-up reviews focus on changes and unresolved findings. Supply resolutions [{id,evidence}] for every blocking ID; incomplete explicit checklists return without spending calls. No code-line or screenshot-count quotas. browser: capture declared package.json hybrid.browser static frontend scenarios with disposable API fixtures; see docs/browser-evidence.md. Results attach to reviews automatically and do not grant approval. Builds need approval; source-only baseline capture can run earlier. For SSR/live backends retain existing integration tests and images/checks. Status is free.',
49
- options: { codemode: false },
50
- input: { type: 'object', additionalProperties: false, properties: {
51
- action: { type: 'string', enum: ['begin', 'plan', 'replan', 'design', 'verify', 'browser', 'review', 'fix', 'inspect', 'status', 'finish'] },
52
- mode: { type: 'string', enum: ['cosmetic', 'small', 'ui', 'feature'] }, brief: { type: 'string' }, checks: { type: 'string' },
53
- designMode: {type:'string',enum:['none','existing','supplied','new']}, designEvidence:{type:'string'},
54
- script: { type: 'string', enum: ['typecheck', 'lint', 'test', 'build'] },
55
- verificationMode:{type:'string',enum:['auto','full'],description:'auto honors a project deterministic Vitest input contract; full always executes the entire script. Targeted checks are partial coverage, never a full-suite pass.'},
56
- resolutions: {type:'array',items:{type:'object',additionalProperties:false,properties:{id:{type:'string'},evidence:{type:'string'}},required:['id','evidence']}},
57
- retryReason:{type:'string',description:'After repeated identical reviewer transport/auth/schema failure, describe new diagnostic/recovery evidence. Does not change review evidence, clear findings or grant approval.'},
58
- images: { type:'array', items:{type:'object',additionalProperties:false,properties:{path:{type:'string'},caption:{type:'string'},artifactHash:{type:'string'}},required:['path','caption','artifactHash']} },
59
- }, required: ['action'] },
60
- execute: async (input, context) => {
61
- const controller = await forSession(context.sessionID);
62
- let pendingProgress = false;
63
- const progress = async () => {
64
- if (!context.progress || pendingProgress) return;
65
- pendingProgress = true;
66
- try {
67
- const data = readDashboard(controller.folder, config), latest = data.calls.at(-1);
68
- await context.progress({ title: latest ? `${latest.who} · ${latest.label} · ${latest.status}` : 'Hybrid checkpoint',
69
- output: JSON.stringify({ phase: data.phase, totals: data.totals, latest: latest && { model: latest.resolvedModels, effort: latest.effort, action: latest.currentAction, usage: latest.usage } }) });
70
- } catch { /* Monitoring does not change checkpoint outcome. */ } finally { pendingProgress = false; }
71
- };
72
- const timer = setInterval(progress, 3000); timer.unref();
73
- try { const result = await controller.operation(input, context.signal, {sessionID:context.sessionID}); await progress(); return { content: JSON.stringify(result) }; }
74
- finally { clearInterval(timer); }
75
- },
76
- }));
77
- await ctx.tool.transform(editor => editor.add({
78
- name: 'hybrid_activity', description: 'Read-only reviewer activity, requested/resolved models, effort and CLI-reported token usage. Missing usage is unavailable, not zero. Usage cards are integrated in the sidebar.',
79
- options: { codemode: false }, input: { type: 'object', properties: {}, additionalProperties: false },
80
- execute: async (_input, context) => {
81
- const controller = await forSession(context.sessionID), data = readDashboard(controller.folder, config);
82
- return { content: JSON.stringify({ phase: data.phase, operationActive: data.operationActive, totals: data.totals,
83
- calls: data.calls.map(({ sequence, who, label, status, requestedModel, resolvedModels, effort, usage, verdict }) => ({ sequence, who, label, status, requestedModel, resolvedModels, effort, usage, verdict })) }) };
84
- },
85
- }));
86
- await ctx.tool.hook('execute.before', async event => {
87
- if(await isReviewerSession(ctx,event.sessionID))return;
88
- const controller=await forSession(event.sessionID);
89
- if(researchTool(event,controller.directory))return;
90
- if (!readTools.test(event.tool)) controller.assertWritable();
91
- });
92
- },
93
- }; }
1
+ import {isReviewerSession} from '../lib/opencode-reviewer.mjs';
2
+ import {roleText} from '../lib/reviewers.mjs';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+ import { WORKFLOW_POLICY } from './workflow-policy.mjs';
7
+ import { Controller } from './controller.mjs';
8
+ import { readDashboard } from './monitor-data.mjs';
9
+ import { DESIGN_POLICY, recordDesignRequest } from './design.mjs';
10
+ import { researchTool } from './research-tools.mjs';
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ // V2's Plugin.define is an identity function. This plain plugin needs no installed dependencies.
14
+ import { FRONTEND_COMMON, FRONTEND_BUILD } from './frontend-policy.mjs';
15
+ export const readTools = /^(read|glob|grep|list|ls|skill|webfetch|websearch|todowrite|todoread|question|subagent|hybrid_checkpoint|hybrid_activity|context7_.+|serena_(find_symbol|find_referencing_symbols|get_symbols_overview|search_for_pattern|read_file|list_dir|find_file|read_memory|list_memories|initial_instructions|check_onboarding_performed)|playwright_browser_(snapshot|take_screenshot|console_messages|network_requests))$/;
16
+ export function createPlugin(config, storage = config.runtimeDirectory ?? path.join(here, 'runtime')) { return {
17
+ id: 'Hybrid-Workflow',
18
+ async setup(ctx) {
19
+ if (!config.enabled) return; // Inert until explicit later activation.
20
+ const compatible = config.compatibleOpenCode ?? [config.targetOpenCode];
21
+ if (!compatible.includes(ctx.app.version)) throw new Error(`Hybrid plugin compatibility check required: running OpenCode ${ctx.app.version}; verified versions: ${compatible.join(', ')}. Run the isolated compatibility probe before adding this version. Existing task history is preserved.`);
22
+ const controllers = new Map();
23
+ const forSession = async sessionID => {
24
+ const result = await ctx.session.get({ sessionID });
25
+ const session = result.data ?? result;
26
+ const directory = session.location?.directory;
27
+ if (typeof directory !== 'string') throw new Error('Cannot resolve session workspace; refusing to act on plugin-instance directory.');
28
+ if (!controllers.has(directory)) controllers.set(directory, new Controller(directory, config.storageFor?.(directory) ?? storage, config));
29
+ return controllers.get(directory);
30
+ };
31
+ await ctx.session.hook('prompt', async event => {
32
+ if(await isReviewerSession(ctx,event.sessionID))return;
33
+ const response = await ctx.session.get({sessionID:event.sessionID});
34
+ const session = response.data ?? response;
35
+ if (session.parentID) return; // Subagent assignments are not new human design requests.
36
+ const text = event.prompt.text;
37
+ if (typeof text === 'string') recordDesignRequest(await forSession(event.sessionID),text,event.messageID);
38
+ });
39
+ await ctx.session.hook('context', async event => {
40
+ if(await isReviewerSession(ctx,event.sessionID))return;
41
+ event.system.push({type:'text',text:roleText(DESIGN_POLICY)});
42
+ event.system.push({type:'text',text:roleText(WORKFLOW_POLICY)});
43
+ event.system.push({type:'text',text:`${FRONTEND_COMMON}\n${FRONTEND_BUILD}`});
44
+ event.system.push({type:'text',text:'Hybrid recovery: completed negative/blocked code reviews allow corrections and evidence collection in fix; continue automatically and re-review, never claim acceptance. action=fix recovers a legacy completed review. New evidence in checks/images resumes paused plan/design; action=replan with concrete changed requirements opens architecture revision while preserving findings and history. action=inspect checks installed tools and CLI logins without model calls and is available during planning. Never edit bridge state or reset history. Playwright research outputs use plain filenames in .playwright-mcp/hybrid-evidence; this generated evidence is excluded from source snapshots. Navigation/resizing and screenshot capture are available while planning/designing. Supply actual PNGs to plan/design/review as images [{path,caption,artifactHash}], using status.currentArtifactHash for the code/reference being inspected. Recapture after code changes and include interaction evidence in checks. Both reviewers receive pixels; never substitute hashes or ask the user to attest images merely because they live outside Git.'});
45
+ });
46
+ await ctx.tool.transform(editor => editor.add({
47
+ name: 'hybrid_checkpoint',
48
+ description: 'High-level checkpoints. begin: mode cosmetic/small/ui/feature, brief, designMode none/existing/supplied/new and reference designEvidence for reuse/supplied designs. Use ui for existing-app visual redesigns preserving architecture/behavior; design directly. Feature plan precedes new design: configured lead authors, configured reviewers validate. At least one pass and zero medium/high/critical findings from all configured reviewers required. Negative review permits corrections; fix recovers legacy completed blocked reviews. plan/design accept new checks/images to resolve evidence pauses. replan accepts changed requirements in checks, retaining history/baseline. inspect checks dependencies/auth without model calls. Actual PNG images [{path,caption,artifactHash}] can accompany plan/design/review; use status.currentArtifactHash. Playwright outputs are in project/.playwright-mcp/hybrid-evidence. Follow-up reviews focus on changes and unresolved findings. Supply resolutions [{id,evidence}] for every blocking ID; incomplete explicit checklists return without spending calls. No code-line or screenshot-count quotas. browser: capture declared package.json hybrid.browser static frontend scenarios with disposable API fixtures; see docs/browser-evidence.md. Results attach to reviews automatically and do not grant approval. Builds need approval; source-only baseline capture can run earlier. For SSR/live backends retain existing integration tests and images/checks. Status is free.',
49
+ options: { codemode: false },
50
+ input: { type: 'object', additionalProperties: false, properties: {
51
+ action: { type: 'string', enum: ['begin', 'plan', 'replan', 'design', 'verify', 'browser', 'review', 'fix', 'inspect', 'status', 'finish'] },
52
+ mode: { type: 'string', enum: ['cosmetic', 'small', 'ui', 'feature'] }, brief: { type: 'string' }, checks: { type: 'string' },
53
+ designMode: {type:'string',enum:['none','existing','supplied','new']}, designEvidence:{type:'string'},
54
+ script: { type: 'string', enum: ['typecheck', 'lint', 'test', 'build'] },
55
+ verificationMode:{type:'string',enum:['auto','full'],description:'auto honors a project deterministic Vitest input contract; full always executes the entire script. Targeted checks are partial coverage, never a full-suite pass.'},
56
+ resolutions: {type:'array',items:{type:'object',additionalProperties:false,properties:{id:{type:'string'},evidence:{type:'string'}},required:['id','evidence']}},
57
+ retryReason:{type:'string',description:'After repeated identical reviewer transport/auth/schema failure, describe new diagnostic/recovery evidence. Does not change review evidence, clear findings or grant approval.'},
58
+ images: { type:'array', items:{type:'object',additionalProperties:false,properties:{path:{type:'string'},caption:{type:'string'},artifactHash:{type:'string'}},required:['path','caption','artifactHash']} },
59
+ }, required: ['action'] },
60
+ execute: async (input, context) => {
61
+ const controller = await forSession(context.sessionID);
62
+ let pendingProgress = false;
63
+ const progress = async () => {
64
+ if (!context.progress || pendingProgress) return;
65
+ pendingProgress = true;
66
+ try {
67
+ const data = readDashboard(controller.folder, config), latest = data.calls.at(-1);
68
+ await context.progress({ title: latest ? `${latest.who} · ${latest.label} · ${latest.status}` : 'Hybrid checkpoint',
69
+ output: JSON.stringify({ phase: data.phase, totals: data.totals, latest: latest && { model: latest.resolvedModels, effort: latest.effort, action: latest.currentAction, usage: latest.usage } }) });
70
+ } catch { /* Monitoring does not change checkpoint outcome. */ } finally { pendingProgress = false; }
71
+ };
72
+ const timer = setInterval(progress, 3000); timer.unref();
73
+ try { const result = await controller.operation(input, context.signal, {sessionID:context.sessionID}); await progress(); return { content: JSON.stringify(result) }; }
74
+ finally { clearInterval(timer); }
75
+ },
76
+ }));
77
+ await ctx.tool.transform(editor => editor.add({
78
+ name: 'hybrid_activity', description: 'Read-only reviewer activity, requested/resolved models, effort and CLI-reported token usage. Missing usage is unavailable, not zero. Usage cards are integrated in the sidebar.',
79
+ options: { codemode: false }, input: { type: 'object', properties: {}, additionalProperties: false },
80
+ execute: async (_input, context) => {
81
+ const controller = await forSession(context.sessionID), data = readDashboard(controller.folder, config);
82
+ return { content: JSON.stringify({ phase: data.phase, operationActive: data.operationActive, totals: data.totals,
83
+ calls: data.calls.map(({ sequence, who, label, status, requestedModel, resolvedModels, effort, usage, verdict }) => ({ sequence, who, label, status, requestedModel, resolvedModels, effort, usage, verdict })) }) };
84
+ },
85
+ }));
86
+ await ctx.tool.hook('execute.before', async event => {
87
+ if(await isReviewerSession(ctx,event.sessionID))return;
88
+ const controller=await forSession(event.sessionID);
89
+ if(researchTool(event,controller.directory))return;
90
+ if (!readTools.test(event.tool)) controller.assertWritable();
91
+ });
92
+ },
93
+ }; }
@@ -1,111 +1,111 @@
1
- import {reviewer} from '../lib/reviewers.mjs';
2
- import { writeFileSync, renameSync } from 'node:fs';
3
- import path from 'node:path';
4
- import {processIdentity} from './process-identity.mjs';
5
-
6
- const count = n => Number.isSafeInteger(n) && n >= 0 ? n : null;
7
- export function normalizeUsage(provider, raw) {
8
- if (!raw || typeof raw !== 'object') return null;
9
- const input = count(raw.input_tokens ?? raw.inputTokens);
10
- const output = count(raw.output_tokens ?? raw.outputTokens);
11
- const cached = count(raw.cached_input_tokens ?? raw.cache_read_input_tokens ?? raw.cacheReadInputTokens);
12
- const written = provider === 'astra' ? 0 : count(raw.cache_creation_input_tokens ?? raw.cacheCreationInputTokens);
13
- if (input === null || output === null) return null;
14
- // Codex input includes cache reads; Claude input excludes cache reads/writes.
15
- const total = provider === 'astra' ? input + output : cached === null || written === null ? null : input + cached + written + output;
16
- return { input, output, cacheRead: cached, cacheWrite: written, total,
17
- inputIncludesCache: provider === 'astra', reasoningOutput: count(raw.reasoning_output_tokens) };
18
- }
19
-
20
- export function stageLabel(stage) {
21
- if (stage === 'design-author') return 'Lead authors UI/UX design';
22
- if (stage === 'design-validate') return 'Review exact approved design';
23
- if (stage === 'plan-proposal') return 'Independent architecture proposal';
24
- if (stage === 'plan-synthesis') return 'Consolidate plan and address findings';
25
- if (stage === 'plan-validate') return 'Verify exact shared plan version';
26
- const match = /^plan-(\d+)-(independent|synthesis)$/.exec(stage);
27
- if (match) {
28
- const round = Number(match[1]);
29
- return round === 1 ? 'Round 1 · independent proposal' : match[2] === 'synthesis' ? `Round ${round} · reconcile and synthesize plan` : `Round ${round} · compare and challenge prior plans`;
30
- }
31
- return stage === 'review' ? 'Independent code and security review' : stage;
32
- }
33
-
34
- export class Telemetry {
35
- constructor(who, config, folder, metadata = {}) {
36
- const lane=reviewer(config,who);
37
- this.trace=metadata.trace;this.callSpan=metadata.callSpan;
38
- this.callSpan?.measure({adapter:lane.adapter,providerID:lane.providerID,model:lane.model,effort:lane.variant??lane.effort??'default'});
39
- this.file = path.join(folder, 'activity.json');
40
- this.value = { version: 1, who, adapter:lane.adapter, providerID:lane.providerID, requestedModel: lane.model,
41
- effort: lane.variant??lane.effort??'default',
42
- resolvedModels: [], stage: metadata.stage ?? 'diagnostic', label: stageLabel(metadata.stage ?? 'diagnostic'),
43
- call: metadata.call ?? null, parentPid: process.pid, status: 'preparing', startedAt: new Date().toISOString(),
44
- endedAt: null, currentAction: 'Checking subscription authentication', usage: null, usageByModel: {}, toolEvents: [], verdict: null };
45
- this.save();
46
- }
47
- save() {
48
- this.value.updatedAt = new Date().toISOString();
49
- try { writeFileSync(this.file + '.tmp', JSON.stringify(this.value)); renameSync(this.file + '.tmp', this.file); } catch { /* Observability must never approve/block a checkpoint. */ }
50
- }
51
- start(pid) { this.value.status = 'running'; this.value.workerPid = pid; this.value.workerIdentity=processIdentity(pid); this.value.currentAction = 'Model working'; this.save(); }
52
- prompt(text){this.callSpan?.measure({promptBodyChars:text.length,promptBodyBytes:Buffer.byteLength(text)});}
53
- event(event) {
54
- const v = this.value;
55
- if (!event || typeof event !== 'object') return;
56
- if (v.adapter === 'codex-cli') {
57
- if (event.type === 'turn.completed') {
58
- const usage = normalizeUsage('astra', event.usage);
59
- if (usage) {
60
- if (!v.usage) v.usage = usage;
61
- else for (const field of ['input','output','cacheRead','cacheWrite','total','reasoningOutput']) {
62
- v.usage[field] = v.usage[field] === null || usage[field] === null ? null : v.usage[field] + usage[field];
63
- }
64
- }
65
- }
66
- if (event.type?.startsWith('item.') && event.item?.type) {
67
- const labels = { web_search: 'Researching online documentation', command_execution: 'Inspecting with a read-only command', mcp_tool_call: 'Using a research tool', agent_message: 'Producing a report', reasoning: 'Evaluating the task', plan_update: 'Updating the plan' };
68
- if (labels[event.item.type]) v.currentAction = labels[event.item.type];
69
- }
70
- // Do not claim an alias is a resolved model when the CLI does not report one.
71
- if (typeof event.model === 'string') v.resolvedModels = [event.model.slice(0, 150)];
72
- } else {
73
- if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') v.resolvedModels = [event.model.slice(0, 150)];
74
- if (event.type === 'assistant') {
75
- if (typeof event.message?.model === 'string') v.resolvedModels = [...new Set([...v.resolvedModels, event.message.model.slice(0, 150)])];
76
- for (const block of event.message?.content ?? []) {
77
- if (block.type === 'tool_use') {
78
- const name = String(block.name ?? '').replace(/[^a-zA-Z0-9_.-]/g, '').slice(0, 80);
79
- v.currentAction = name ? `Using ${name}` : 'Using a research tool';
80
- v.toolEvents = [...v.toolEvents, { name, at: new Date().toISOString() }].slice(-20);
81
- }
82
- }
83
- }
84
- if (event.type === 'result') {
85
- v.usage = normalizeUsage('opus', event.usage);
86
- for (const [model, usage] of Object.entries(event.modelUsage ?? {})) {
87
- if (model.length <= 150) v.usageByModel[model] = normalizeUsage('opus', usage);
88
- }
89
- if (Object.keys(v.usageByModel).length) v.resolvedModels = Object.keys(v.usageByModel);
90
- }
91
- }
92
- this.save();
93
- }
94
- end(status, verdict = null) {
95
- this.value.status = status; this.value.verdict = verdict; this.value.endedAt = new Date().toISOString();
96
- this.value.currentAction = status === 'completed' ? 'Report produced' : status === 'cancelled' ? 'Cancelled; no approval' : 'Call failed; checkpoint remains closed';
97
- this.save();
98
- this.callSpan?.end({status,usage:this.value.usage});
99
- }
100
- }
101
-
102
- export function jsonLineReader(onEvent) {
103
- let pending = '';
104
- const emit = () => { if (pending.trim()) { try { onEvent(JSON.parse(pending)); } catch {} } pending = ''; };
105
- return { push(text) {
106
- for (const [i, part] of text.split('\n').entries()) {
107
- if (i > 0) emit();
108
- pending += part;
109
- }
110
- }, finish: emit };
111
- }
1
+ import {reviewer} from '../lib/reviewers.mjs';
2
+ import { writeFileSync, renameSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import {processIdentity} from './process-identity.mjs';
5
+
6
+ const count = n => Number.isSafeInteger(n) && n >= 0 ? n : null;
7
+ export function normalizeUsage(provider, raw) {
8
+ if (!raw || typeof raw !== 'object') return null;
9
+ const input = count(raw.input_tokens ?? raw.inputTokens);
10
+ const output = count(raw.output_tokens ?? raw.outputTokens);
11
+ const cached = count(raw.cached_input_tokens ?? raw.cache_read_input_tokens ?? raw.cacheReadInputTokens);
12
+ const written = provider === 'astra' ? 0 : count(raw.cache_creation_input_tokens ?? raw.cacheCreationInputTokens);
13
+ if (input === null || output === null) return null;
14
+ // Codex input includes cache reads; Claude input excludes cache reads/writes.
15
+ const total = provider === 'astra' ? input + output : cached === null || written === null ? null : input + cached + written + output;
16
+ return { input, output, cacheRead: cached, cacheWrite: written, total,
17
+ inputIncludesCache: provider === 'astra', reasoningOutput: count(raw.reasoning_output_tokens) };
18
+ }
19
+
20
+ export function stageLabel(stage) {
21
+ if (stage === 'design-author') return 'Lead authors UI/UX design';
22
+ if (stage === 'design-validate') return 'Review exact approved design';
23
+ if (stage === 'plan-proposal') return 'Independent architecture proposal';
24
+ if (stage === 'plan-synthesis') return 'Consolidate plan and address findings';
25
+ if (stage === 'plan-validate') return 'Verify exact shared plan version';
26
+ const match = /^plan-(\d+)-(independent|synthesis)$/.exec(stage);
27
+ if (match) {
28
+ const round = Number(match[1]);
29
+ return round === 1 ? 'Round 1 · independent proposal' : match[2] === 'synthesis' ? `Round ${round} · reconcile and synthesize plan` : `Round ${round} · compare and challenge prior plans`;
30
+ }
31
+ return stage === 'review' ? 'Independent code and security review' : stage;
32
+ }
33
+
34
+ export class Telemetry {
35
+ constructor(who, config, folder, metadata = {}) {
36
+ const lane=reviewer(config,who);
37
+ this.trace=metadata.trace;this.callSpan=metadata.callSpan;
38
+ this.callSpan?.measure({adapter:lane.adapter,providerID:lane.providerID,model:lane.model,effort:lane.variant??lane.effort??'default'});
39
+ this.file = path.join(folder, 'activity.json');
40
+ this.value = { version: 1, who, adapter:lane.adapter, providerID:lane.providerID, requestedModel: lane.model,
41
+ effort: lane.variant??lane.effort??'default',
42
+ resolvedModels: [], stage: metadata.stage ?? 'diagnostic', label: stageLabel(metadata.stage ?? 'diagnostic'),
43
+ call: metadata.call ?? null, parentPid: process.pid, status: 'preparing', startedAt: new Date().toISOString(),
44
+ endedAt: null, currentAction: 'Checking CLI authentication', usage: null, usageByModel: {}, toolEvents: [], verdict: null };
45
+ this.save();
46
+ }
47
+ save() {
48
+ this.value.updatedAt = new Date().toISOString();
49
+ try { writeFileSync(this.file + '.tmp', JSON.stringify(this.value)); renameSync(this.file + '.tmp', this.file); } catch { /* Observability must never approve/block a checkpoint. */ }
50
+ }
51
+ start(pid) { this.value.status = 'running'; this.value.workerPid = pid; this.value.workerIdentity=processIdentity(pid); this.value.currentAction = 'Model working'; this.save(); }
52
+ prompt(text){this.callSpan?.measure({promptBodyChars:text.length,promptBodyBytes:Buffer.byteLength(text)});}
53
+ event(event) {
54
+ const v = this.value;
55
+ if (!event || typeof event !== 'object') return;
56
+ if (v.adapter === 'codex-cli') {
57
+ if (event.type === 'turn.completed') {
58
+ const usage = normalizeUsage('astra', event.usage);
59
+ if (usage) {
60
+ if (!v.usage) v.usage = usage;
61
+ else for (const field of ['input','output','cacheRead','cacheWrite','total','reasoningOutput']) {
62
+ v.usage[field] = v.usage[field] === null || usage[field] === null ? null : v.usage[field] + usage[field];
63
+ }
64
+ }
65
+ }
66
+ if (event.type?.startsWith('item.') && event.item?.type) {
67
+ const labels = { web_search: 'Researching online documentation', command_execution: 'Inspecting with a read-only command', mcp_tool_call: 'Using a research tool', agent_message: 'Producing a report', reasoning: 'Evaluating the task', plan_update: 'Updating the plan' };
68
+ if (labels[event.item.type]) v.currentAction = labels[event.item.type];
69
+ }
70
+ // Do not claim an alias is a resolved model when the CLI does not report one.
71
+ if (typeof event.model === 'string') v.resolvedModels = [event.model.slice(0, 150)];
72
+ } else {
73
+ if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') v.resolvedModels = [event.model.slice(0, 150)];
74
+ if (event.type === 'assistant') {
75
+ if (typeof event.message?.model === 'string') v.resolvedModels = [...new Set([...v.resolvedModels, event.message.model.slice(0, 150)])];
76
+ for (const block of event.message?.content ?? []) {
77
+ if (block.type === 'tool_use') {
78
+ const name = String(block.name ?? '').replace(/[^a-zA-Z0-9_.-]/g, '').slice(0, 80);
79
+ v.currentAction = name ? `Using ${name}` : 'Using a research tool';
80
+ v.toolEvents = [...v.toolEvents, { name, at: new Date().toISOString() }].slice(-20);
81
+ }
82
+ }
83
+ }
84
+ if (event.type === 'result') {
85
+ v.usage = normalizeUsage('opus', event.usage);
86
+ for (const [model, usage] of Object.entries(event.modelUsage ?? {})) {
87
+ if (model.length <= 150) v.usageByModel[model] = normalizeUsage('opus', usage);
88
+ }
89
+ if (Object.keys(v.usageByModel).length) v.resolvedModels = Object.keys(v.usageByModel);
90
+ }
91
+ }
92
+ this.save();
93
+ }
94
+ end(status, verdict = null) {
95
+ this.value.status = status; this.value.verdict = verdict; this.value.endedAt = new Date().toISOString();
96
+ this.value.currentAction = status === 'completed' ? 'Report produced' : status === 'cancelled' ? 'Cancelled; no approval' : 'Call failed; checkpoint remains closed';
97
+ this.save();
98
+ this.callSpan?.end({status,usage:this.value.usage});
99
+ }
100
+ }
101
+
102
+ export function jsonLineReader(onEvent) {
103
+ let pending = '';
104
+ const emit = () => { if (pending.trim()) { try { onEvent(JSON.parse(pending)); } catch {} } pending = ''; };
105
+ return { push(text) {
106
+ for (const [i, part] of text.split('\n').entries()) {
107
+ if (i > 0) emit();
108
+ pending += part;
109
+ }
110
+ }, finish: emit };
111
+ }