@rockysoft/opencode-hybrid-workflow 0.0.0-stage → 0.3.2-beta.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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +252 -3
  3. package/THIRD_PARTY_NOTICES.md +18 -0
  4. package/assets/agents/backend-engineer.md +167 -0
  5. package/assets/agents/code-reviewer.md +129 -0
  6. package/assets/agents/database-engineer.md +162 -0
  7. package/assets/agents/devops-engineer.md +115 -0
  8. package/assets/agents/frontend-engineer.md +38 -0
  9. package/assets/agents/fullstack-architect.md +43 -0
  10. package/assets/agents/security-auditor.md +132 -0
  11. package/assets/agents/technical-writer.md +78 -0
  12. package/assets/agents/test-engineer.md +154 -0
  13. package/assets/rules.md +93 -0
  14. package/assets/skills/frontend-design/INSTALLATION.json +9 -0
  15. package/assets/skills/frontend-design/LICENSE.txt +177 -0
  16. package/assets/skills/frontend-design/SKILL.md +78 -0
  17. package/assets/skills/ui-ux-pro-max/INSTALLATION.json +10 -0
  18. package/assets/skills/ui-ux-pro-max/LICENSE +21 -0
  19. package/assets/skills/ui-ux-pro-max/SKILL.md +221 -0
  20. package/assets/skills/ui-ux-pro-max/data/app-interface.csv +33 -0
  21. package/assets/skills/ui-ux-pro-max/data/catalog-summary.json +78 -0
  22. package/assets/skills/ui-ux-pro-max/data/charts.csv +26 -0
  23. package/assets/skills/ui-ux-pro-max/data/colors.csv +193 -0
  24. package/assets/skills/ui-ux-pro-max/data/data-provenance.json +1348 -0
  25. package/assets/skills/ui-ux-pro-max/data/google-font-licenses.json +20185 -0
  26. package/assets/skills/ui-ux-pro-max/data/google-fonts.csv +1935 -0
  27. package/assets/skills/ui-ux-pro-max/data/icons.csv +106 -0
  28. package/assets/skills/ui-ux-pro-max/data/landing.csv +35 -0
  29. package/assets/skills/ui-ux-pro-max/data/motion.csv +18 -0
  30. package/assets/skills/ui-ux-pro-max/data/phosphor-icons-upstream.json +32582 -0
  31. package/assets/skills/ui-ux-pro-max/data/products.csv +193 -0
  32. package/assets/skills/ui-ux-pro-max/data/react-performance.csv +45 -0
  33. package/assets/skills/ui-ux-pro-max/data/stacks/angular.csv +51 -0
  34. package/assets/skills/ui-ux-pro-max/data/stacks/astro.csv +54 -0
  35. package/assets/skills/ui-ux-pro-max/data/stacks/avalonia.csv +57 -0
  36. package/assets/skills/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
  37. package/assets/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +60 -0
  38. package/assets/skills/ui-ux-pro-max/data/stacks/javafx.csv +76 -0
  39. package/assets/skills/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -0
  40. package/assets/skills/ui-ux-pro-max/data/stacks/laravel.csv +51 -0
  41. package/assets/skills/ui-ux-pro-max/data/stacks/nextjs.csv +62 -0
  42. package/assets/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +77 -0
  43. package/assets/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +68 -0
  44. package/assets/skills/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
  45. package/assets/skills/ui-ux-pro-max/data/stacks/react.csv +67 -0
  46. package/assets/skills/ui-ux-pro-max/data/stacks/shadcn.csv +69 -0
  47. package/assets/skills/ui-ux-pro-max/data/stacks/svelte.csv +56 -0
  48. package/assets/skills/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
  49. package/assets/skills/ui-ux-pro-max/data/stacks/threejs.csv +54 -0
  50. package/assets/skills/ui-ux-pro-max/data/stacks/uno.csv +60 -0
  51. package/assets/skills/ui-ux-pro-max/data/stacks/uwp.csv +56 -0
  52. package/assets/skills/ui-ux-pro-max/data/stacks/vue.csv +50 -0
  53. package/assets/skills/ui-ux-pro-max/data/stacks/winui.csv +60 -0
  54. package/assets/skills/ui-ux-pro-max/data/stacks/wpf.csv +57 -0
  55. package/assets/skills/ui-ux-pro-max/data/styles.csv +89 -0
  56. package/assets/skills/ui-ux-pro-max/data/typography.csv +75 -0
  57. package/assets/skills/ui-ux-pro-max/data/ui-reasoning.csv +193 -0
  58. package/assets/skills/ui-ux-pro-max/data/ux-guidelines.csv +120 -0
  59. package/assets/skills/ui-ux-pro-max/references/pro-rules.md +117 -0
  60. package/assets/skills/ui-ux-pro-max/references/quick-reference.md +256 -0
  61. package/assets/skills/ui-ux-pro-max/scripts/core.py +993 -0
  62. package/assets/skills/ui-ux-pro-max/scripts/design_system.py +1668 -0
  63. package/assets/skills/ui-ux-pro-max/scripts/reasoning_contract.py +123 -0
  64. package/assets/skills/ui-ux-pro-max/scripts/search.py +177 -0
  65. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-api.json +36 -0
  66. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-catalog.json +33 -0
  67. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-existing.csv +3 -0
  68. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-metadata.json +17 -0
  69. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-overrides.json +7 -0
  70. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/icons-curated.csv +4 -0
  71. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-core.json +23 -0
  72. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-package.json +13 -0
  73. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-react-exports.json +4 -0
  74. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-react-package.json +5 -0
  75. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-baseline.json +3366 -0
  76. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-cases.json +111 -0
  77. package/assets/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-thresholds.json +100 -0
  78. package/assets/skills/ui-ux-pro-max/scripts/tests/test_catalog_refresh.py +354 -0
  79. package/assets/skills/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py +78 -0
  80. package/assets/skills/ui-ux-pro-max/scripts/tests/test_core.py +343 -0
  81. package/assets/skills/ui-ux-pro-max/scripts/tests/test_core_data_quality.py +198 -0
  82. package/assets/skills/ui-ux-pro-max/scripts/tests/test_data_contracts.py +421 -0
  83. package/assets/skills/ui-ux-pro-max/scripts/tests/test_design_system_mode.py +233 -0
  84. package/assets/skills/ui-ux-pro-max/scripts/tests/test_design_system_stack.py +61 -0
  85. package/assets/skills/ui-ux-pro-max/scripts/tests/test_native_desktop_stack_freshness.py +179 -0
  86. package/assets/skills/ui-ux-pro-max/scripts/tests/test_relevance_evaluator.py +182 -0
  87. package/assets/skills/ui-ux-pro-max/scripts/tests/test_skill_script_paths.py +82 -0
  88. package/assets/skills/ui-ux-pro-max/scripts/tests/test_style_taxonomy.py +175 -0
  89. package/assets/skills/ui-ux-pro-max/scripts/tests/test_text_layout_resilience.py +120 -0
  90. package/assets/skills/ui-ux-pro-max/scripts/tests/test_web_stack_freshness.py +177 -0
  91. package/assets/skills/ui-ux-pro-max/scripts/validate_data.py +1095 -0
  92. package/assets/skills/web-design-guidelines/INSTALLATION.json +1 -0
  93. package/assets/skills/web-design-guidelines/SKILL.md +18 -0
  94. package/cli.mjs +91 -0
  95. package/core/controller.mjs +367 -0
  96. package/core/dashboard-state.js +48 -0
  97. package/core/dashboard.css +2 -0
  98. package/core/dashboard.html +12 -0
  99. package/core/dashboard.js +72 -0
  100. package/core/decision.mjs +22 -0
  101. package/core/design.mjs +139 -0
  102. package/core/evidence.mjs +82 -0
  103. package/core/frontend-policy.mjs +34 -0
  104. package/core/images.mjs +35 -0
  105. package/core/monitor-data.mjs +58 -0
  106. package/core/monitor.mjs +24 -0
  107. package/core/operation-lock.mjs +82 -0
  108. package/core/plugin.mjs +91 -0
  109. package/core/posix-lock.mjs +31 -0
  110. package/core/posix-lock.py +23 -0
  111. package/core/pricing.mjs +53 -0
  112. package/core/process-identity.mjs +19 -0
  113. package/core/research-tools.mjs +37 -0
  114. package/core/review-context.mjs +43 -0
  115. package/core/snapshot.mjs +95 -0
  116. package/core/telemetry.mjs +107 -0
  117. package/core/workers.mjs +192 -0
  118. package/core/workflow-policy.mjs +10 -0
  119. package/index.js +19 -0
  120. package/lib/commands.mjs +48 -0
  121. package/lib/config.mjs +63 -0
  122. package/lib/install.mjs +111 -0
  123. package/lib/native-browser.mjs +79 -0
  124. package/lib/opencode-reviewer.mjs +129 -0
  125. package/lib/platform.mjs +14 -0
  126. package/lib/reviewer-browser-proxy.mjs +61 -0
  127. package/lib/reviewer-browser.mjs +35 -0
  128. package/lib/reviewers.mjs +11 -0
  129. package/lib/select-reviewers.mjs +58 -0
  130. package/lib/settings-rpc.mjs +9 -0
  131. package/lib/settings-service.mjs +129 -0
  132. package/package.json +63 -6
  133. package/sidebar/reader.mjs +80 -0
  134. package/sidebar/settings.mjs +99 -0
  135. package/tui.js +303 -0
@@ -0,0 +1,91 @@
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: 'personal.hybrid-checkpoints',
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. Status is free.',
49
+ options: { codemode: false },
50
+ input: { type: 'object', additionalProperties: false, properties: {
51
+ action: { type: 'string', enum: ['begin', 'plan', 'replan', 'design', 'verify', '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
+ resolutions: {type:'array',items:{type:'object',additionalProperties:false,properties:{id:{type:'string'},evidence:{type:'string'}},required:['id','evidence']}},
56
+ images: { type:'array', items:{type:'object',additionalProperties:false,properties:{path:{type:'string'},caption:{type:'string'},artifactHash:{type:'string'}},required:['path','caption','artifactHash']} },
57
+ }, required: ['action'] },
58
+ execute: async (input, context) => {
59
+ const controller = await forSession(context.sessionID);
60
+ let pendingProgress = false;
61
+ const progress = async () => {
62
+ if (!context.progress || pendingProgress) return;
63
+ pendingProgress = true;
64
+ try {
65
+ const data = readDashboard(controller.folder, config), latest = data.calls.at(-1);
66
+ await context.progress({ title: latest ? `${latest.who} · ${latest.label} · ${latest.status}` : 'Hybrid checkpoint',
67
+ output: JSON.stringify({ phase: data.phase, totals: data.totals, latest: latest && { model: latest.resolvedModels, effort: latest.effort, action: latest.currentAction, usage: latest.usage } }) });
68
+ } catch { /* Monitoring does not change checkpoint outcome. */ } finally { pendingProgress = false; }
69
+ };
70
+ const timer = setInterval(progress, 3000); timer.unref();
71
+ try { const result = await controller.operation(input, context.signal, {sessionID:context.sessionID}); await progress(); return { content: JSON.stringify(result) }; }
72
+ finally { clearInterval(timer); }
73
+ },
74
+ }));
75
+ await ctx.tool.transform(editor => editor.add({
76
+ 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.',
77
+ options: { codemode: false }, input: { type: 'object', properties: {}, additionalProperties: false },
78
+ execute: async (_input, context) => {
79
+ const controller = await forSession(context.sessionID), data = readDashboard(controller.folder, config);
80
+ return { content: JSON.stringify({ phase: data.phase, operationActive: data.operationActive, totals: data.totals,
81
+ calls: data.calls.map(({ sequence, who, label, status, requestedModel, resolvedModels, effort, usage, verdict }) => ({ sequence, who, label, status, requestedModel, resolvedModels, effort, usage, verdict })) }) };
82
+ },
83
+ }));
84
+ await ctx.tool.hook('execute.before', async event => {
85
+ if(await isReviewerSession(ctx,event.sessionID))return;
86
+ const controller=await forSession(event.sessionID);
87
+ if(researchTool(event,controller.directory))return;
88
+ if (!readTools.test(event.tool)) controller.assertWritable();
89
+ });
90
+ },
91
+ }; }
@@ -0,0 +1,31 @@
1
+ import {spawn} from 'node:child_process';
2
+ import {fileURLToPath} from 'node:url';
3
+ import {commandSpec} from '../lib/commands.mjs';
4
+ import {commandEnvironment} from '../lib/platform.mjs';
5
+
6
+ // macOS has neither Windows named pipes nor Linux abstract Unix sockets.
7
+ // Use an OS advisory lock through Python (already a design-research prerequisite).
8
+ export async function acquirePosixMutex(file){
9
+ const python=commandSpec('python3');
10
+ const child=spawn(python.executable,[...python.args,'-I','-u',fileURLToPath(new URL('./posix-lock.py',import.meta.url)),file+'.mutex'],{
11
+ env:commandEnvironment(),shell:false,windowsHide:true,stdio:['pipe','pipe','pipe'],
12
+ });
13
+ let stderr='',failure;
14
+ child.stderr.on('data',data=>{stderr=(stderr+data).slice(-2000);});
15
+ child.stdin.on('error',()=>{});
16
+ child.on('error',error=>{failure=error;});
17
+ const closed=new Promise(resolve=>child.once('close',code=>resolve(code)));
18
+ async function release(){
19
+ child.stdin.end();
20
+ const timer=setTimeout(()=>child.kill('SIGKILL'),3000);
21
+ try{await closed;}finally{clearTimeout(timer);}
22
+ }
23
+ try{
24
+ await new Promise((resolve,reject)=>{
25
+ let output='';const timer=setTimeout(()=>reject(Error('Checkpoint mutex could not start. Check Python 3 in Hybrid diagnostics.')),15000);
26
+ child.stdout.on('data',data=>{output+=data;if(output==='locked\n'){clearTimeout(timer);resolve();}});
27
+ closed.then(code=>{clearTimeout(timer);reject(failure??Error(code===73?'Another checkpoint operation is active. Wait for it to finish; history is preserved.':`Checkpoint mutex failed: ${stderr.trim()||'Python exited '+code}`));});
28
+ });
29
+ return release;
30
+ }catch(error){await release();throw error;}
31
+ }
@@ -0,0 +1,23 @@
1
+ """Kernel-held checkpoint mutex. The inode persists; the lock never does.
2
+
3
+ Do not unlink this file: unlinking a locked inode permits two simultaneous owners.
4
+ EOF on the parent-owned pipe (including a parent crash) releases the descriptor.
5
+ """
6
+ import errno
7
+ import fcntl
8
+ import os
9
+ import stat
10
+ import sys
11
+
12
+ fd = os.open(sys.argv[1], os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
13
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
14
+ raise RuntimeError("Checkpoint mutex must be a regular file")
15
+ try:
16
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
17
+ except OSError as error:
18
+ if error.errno in (errno.EACCES, errno.EAGAIN):
19
+ sys.exit(73)
20
+ raise
21
+ print("locked", flush=True)
22
+ sys.stdin.buffer.read()
23
+ os.close(fd)
@@ -0,0 +1,53 @@
1
+ // Standard USD API rates per million tokens, verified 2026-10-27.
2
+ export const pricingInfo = {
3
+ date: '2026-10-27',
4
+ sources: [
5
+ { name: 'OpenAI Astra pricing', url: 'https://developers.openai.com/api/docs/models/gpt-6-astra' },
6
+ { name: 'Anthropic pricing', url: 'https://platform.claude.com/docs/en/about-claude/pricing' },
7
+ ],
8
+ basis: 'USD, standard API rates. Astra: $10 input / $1 cache read / $50 output per million tokens. Opus 5.5: $4 input / $0.20 cache read / $20 output; cache writes $5 (5m) or $8 (1h). Haiku 5.5: $0.10 input / $0.01 cache read / $0.50 output below 100K prompt tokens; 5x above. Ranges cover missing per-request context sizes or cache-write lifetimes. Tool-service fees are not included. Subscription payments are separate.',
9
+ };
10
+ const valid = n => typeof n === 'number' && Number.isFinite(n) && n >= 0;
11
+ export function reportedUsage(call) {
12
+ const parts = Object.values(call.usageByModel ?? {});
13
+ const fields = ['input','output','cacheRead','cacheWrite','total'];
14
+ if (!parts.length || !parts.every(u => u && fields.every(k => valid(u[k])))) return call.usage;
15
+ const summed = Object.fromEntries(fields.map(key => [key, parts.reduce((n,u) => n+u[key],0)]));
16
+ // Claude's headline usage may cover the main model only; modelUsage also includes helpers.
17
+ if (valid(call.usage?.total) && summed.total < call.usage.total) return call.usage;
18
+ return { ...summed, inputIncludesCache: parts[0].inputIncludesCache, reasoningOutput: null };
19
+ }
20
+ function priceUsage(model, u, includesCache) {
21
+ if (!u || !['input','output','cacheRead','cacheWrite'].every(k => valid(u[k]))) return null;
22
+ const input = includesCache ? u.input - u.cacheRead : u.input;
23
+ if (input < 0) return null;
24
+ const prompt = input + u.cacheRead + u.cacheWrite;
25
+ let low, high;
26
+ if (model === 'gpt-6-astra') {
27
+ low = input * 10 + u.cacheRead + u.cacheWrite * 12.5 + u.output * 50;
28
+ // Aggregated usage cannot establish whether any individual request exceeded 272K.
29
+ high = prompt > 272000 ? (input * 10 + u.cacheRead + u.cacheWrite * 25) * 2 + u.output * 75 : input * 10 + u.cacheRead + u.cacheWrite * 25 + u.output * 50;
30
+ } else if (model === 'claude-opus-5-5') {
31
+ low = input * 4 + u.cacheRead * .2 + u.cacheWrite * 5 + u.output * 20;
32
+ high = input * 4 + u.cacheRead * .2 + u.cacheWrite * 8 + u.output * 20;
33
+ } else if (model === 'claude-haiku-5-5') {
34
+ low = input * .1 + u.cacheRead * .01 + u.cacheWrite * .125 + u.output * .5;
35
+ high = (input * .1 + u.cacheRead * .01 + u.cacheWrite * .2 + u.output * .5) * (prompt > 100000 ? 5 : 1);
36
+ } else return null;
37
+ return { low: low / 1e6, high: high / 1e6 };
38
+ }
39
+ export function priceCall(call) {
40
+ if(valid(call.apiCost))return {low:call.apiCost,high:call.apiCost};
41
+ const entries = Object.entries(call.usageByModel ?? {});
42
+ if (entries.length) {
43
+ // Never price a mixed-model aggregate entirely at the primary model's rate.
44
+ const results = entries.map(([model, usage]) => priceUsage(model, usage, usage?.inputIncludesCache ?? call.who==='astra'));
45
+ if (results.some(value => !value)) return null;
46
+ const partsTotal = entries.reduce((sum, [, usage]) => sum + (usage?.total ?? NaN), 0);
47
+ if (!Number.isFinite(partsTotal) || (valid(call.usage?.total) && partsTotal < call.usage.total)) return null;
48
+ return { low: results.reduce((sum, p) => sum + p.low, 0), high: results.reduce((sum, p) => sum + p.high, 0) };
49
+ }
50
+ if (call.resolvedModels?.length > 1) return null;
51
+ const model = call.resolvedModels?.[0] ?? call.requestedModel;
52
+ return priceUsage(model, call.usage, call.usage?.inputIncludesCache ?? call.who==='astra');
53
+ }
@@ -0,0 +1,19 @@
1
+ import {spawnSync} from 'node:child_process';
2
+ import path from 'node:path';
3
+ export function processIdentity(pid) {
4
+ if(!Number.isInteger(pid)||pid<1)return null;
5
+ const windows=process.platform==='win32';
6
+ const result=windows?spawnSync(path.join(process.env.SystemRoot??'C:/Windows','System32/WindowsPowerShell/v1.0/powershell.exe'),
7
+ ['-NoLogo','-NoProfile','-NonInteractive','-Command',`try { [Diagnostics.Process]::GetProcessById(${pid}).StartTime.ToUniversalTime().Ticks } catch { exit 1 }`],{encoding:'utf8',windowsHide:true,timeout:5000}):
8
+ spawnSync('ps',['-p',String(pid),'-o','lstart='],{encoding:'utf8',timeout:5000});
9
+ const value=result.stdout?.trim();return result.status===0&&value?value:null;
10
+ }
11
+ let selfIdentity;
12
+ export function ownIdentity(){return selfIdentity??=(processIdentity(process.pid));}
13
+ export function sameProcess(pid,identity) {
14
+ if(!Number.isInteger(pid)||pid<1)return true;
15
+ try{process.kill(pid,0);}catch(error){if(error.code==='ESRCH')return false;return true;}
16
+ if(!identity)return true;
17
+ const current=pid===process.pid?ownIdentity():processIdentity(pid);
18
+ return current===null||current===identity; // Unknown is never silently treated as dead.
19
+ }
@@ -0,0 +1,37 @@
1
+ import {mkdirSync,realpathSync,lstatSync,existsSync} from 'node:fs';
2
+ import {randomUUID} from 'node:crypto';
3
+ import path from 'node:path';
4
+
5
+ const outputs=/^playwright_browser_(snapshot|take_screenshot|console_messages|network_requests)$/;
6
+ const navigation=/^playwright_browser_(navigate|resize|wait_for)$/;
7
+ export function researchTool(event,directory) {
8
+ const input=event.input??{};
9
+ if(outputs.test(event.tool)) {
10
+ // These apparent reads can write files. Always confine those outputs, including during planning.
11
+ const root=path.join(realpathSync(directory),'.playwright-mcp','hybrid-evidence');
12
+ mkdirSync(root,{recursive:true});
13
+ if(realpathSync(root)!==root)throw new Error('Evidence output directory must not contain symbolic links.');
14
+ const extension=event.tool.endsWith('take_screenshot')?'.png':'.txt';
15
+ const supplied=input.filename;
16
+ if(supplied!==undefined&&typeof supplied!=='string')throw new Error('Evidence filename must be text.');
17
+ const file=supplied?path.resolve(root,supplied):path.join(root,`${randomUUID()}${extension}`);
18
+ const relative=path.relative(root,file);
19
+ if(!relative||relative.startsWith('..'+path.sep)||relative==='..'||path.isAbsolute(relative)||path.extname(file).toLowerCase()!==extension)
20
+ throw new Error(`Save this research output under ${root}, with extension ${extension}. Source files cannot be overwritten by evidence tools.`);
21
+ // Flat files keep containment check and actual write independent of untrusted subdirectories.
22
+ if(path.dirname(file)!==root || (existsSync(file)&&(lstatSync(file).isSymbolicLink()||!lstatSync(file).isFile()||lstatSync(file).nlink>1)))throw new Error('Use a plain filename in the evidence output folder.');
23
+ event.input={...input,filename:file,...(extension==='.png'?{type:'png'}:{})};return true;
24
+ }
25
+ if(navigation.test(event.tool)) {
26
+ if(event.tool.endsWith('navigate')) {
27
+ let url;try{url=new URL(input.url);}catch{throw new Error('Use an HTTP(S) preview or documentation URL.');}
28
+ if(!['http:','https:'].includes(url.protocol))throw new Error('Use an HTTP(S) preview or documentation URL.');
29
+ }
30
+ return true;
31
+ }
32
+ if(event.tool==='serena_activate_project') {
33
+ if(typeof input.project!=='string'||realpathSync(path.resolve(directory,input.project))!==realpathSync(directory))throw new Error('Activate the current project for research.');
34
+ return true;
35
+ }
36
+ return false;
37
+ }
@@ -0,0 +1,43 @@
1
+ import {lanes} from '../lib/reviewers.mjs';
2
+ import { delta, hash } from './snapshot.mjs';
3
+ import { isMajor } from './decision.mjs';
4
+
5
+ export function blockingChecklist(feedback = []) {
6
+ const items = new Map();
7
+ for (const review of feedback) for (const finding of review.findings ?? []) {
8
+ if (!isMajor(finding)) continue;
9
+ const item = items.get(finding.id) ?? { id: finding.id, requirements: [] };
10
+ item.requirements.push({ reviewer: review.who, severity: finding.severity, category: finding.category,
11
+ consequence: finding.consequence, resolution: finding.resolution });
12
+ items.set(finding.id, item);
13
+ }
14
+ return [...items.values()];
15
+ }
16
+
17
+ export function reviewContract(state) {
18
+ return hash(JSON.stringify([state.brief, state.plan, state.planningClarification, state.designApproval,
19
+ state.designEvidence, state.recoveries]));
20
+ }
21
+
22
+ export function followupContext(state, current, images) {
23
+ const previous = state.reviewCoverage;
24
+ if (!previous || previous.contract !== reviewContract(state) || !state.measureFrom ||
25
+ previous.artifactHash !== state.measureFrom.fingerprint || state.reviewFeedback?.length !== (state.reviewerSettings?lanes(state.reviewerSettings).length:2)) return null;
26
+ const oldImages = new Set(previous.images.map(i => i.sha256));
27
+ const changes = delta(state.measureFrom, current);
28
+ return { kind: changes.changed.length ? 'focused-correction' : 'evidence-follow-up',
29
+ previousArtifactHash: previous.artifactHash, previousReports: state.reviewFeedback,
30
+ changesSinceReview: changes, requiredFindings: blockingChecklist(state.reviewFeedback),
31
+ changedImages: images.filter(i => !oldImages.has(i.manifest.sha256)).map(i => i.manifest.path),
32
+ instruction: 'Verify the open checklist and this delta, including impacted dependencies. Earlier review coverage is context, never automatic approval. Return all still-open findings and a fresh verdict for the current artifact. Do not repeat unrelated review or introduce unrequested acceptance scope.' };
33
+ }
34
+
35
+ // Structural preflight only: it never asserts a finding has been fixed. Reviewers
36
+ // independently judge the cited evidence. Legacy calls without this field remain usable.
37
+ export function missingResolutions(feedback, resolutions) {
38
+ if (resolutions === undefined) return [];
39
+ if (!Array.isArray(resolutions) || resolutions.some(r => !r || typeof r.id !== 'string' || !r.id.trim() ||
40
+ typeof r.evidence !== 'string' || !r.evidence.trim())) throw new Error('Each resolution needs a finding id and concrete evidence.');
41
+ const supplied = new Set(resolutions.map(r => r.id));
42
+ return blockingChecklist(feedback).filter(f => !supplied.has(f.id));
43
+ }
@@ -0,0 +1,95 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, lstatSync, realpathSync, openSync, closeSync, readSync, readlinkSync, existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ export const hash = value => createHash('sha256').update(value).digest('hex');
8
+ const secret = /(^|\/)(\.env(?:\..*)?|.*\.(pem|key|p12|pfx)|credentials?(?:\..*)?|auth\.json|\.npmrc|\.netrc|\.mcp\.json|id_(rsa|ed25519|ecdsa)|opencode\.jsonc?)$|(^|\/)(\.aws|\.ssh|\.azure|\.kube|\.gcloud|\.docker|\.claude|\.codex|\.opencode)\//i;
9
+ const secretContent = /\b(?:sk-ant-[a-zA-Z0-9_-]{20,}|sk-[a-zA-Z0-9_-]{30,}|gh[pousr]_[a-zA-Z0-9]{20,}|AKIA[A-Z0-9]{16})\b|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/;
10
+ const generated = /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$/;
11
+ export function initializeProject(directory) {
12
+ const root = realpathSync(directory);
13
+ // Begin may initialize this explicit project, never an enclosing repository or a user's home.
14
+ let ancestor = root;
15
+ while (true) {
16
+ if (existsSync(path.join(ancestor, '.git'))) return;
17
+ const parent = path.dirname(ancestor); if (parent === ancestor) break; ancestor = parent;
18
+ }
19
+ if (root === path.parse(root).root || root.toLowerCase() === realpathSync(homedir()).toLowerCase()) throw new Error('Select a dedicated project folder before beginning.');
20
+ execFileSync('git', ['-C',root,'init','--quiet'], {windowsHide:true});
21
+ }
22
+ function inspectFile(file, name) {
23
+ const digest = createHash('sha256'), fd = openSync(file,'r'), buffer = Buffer.alloc(65536);
24
+ let binary = false, sensitive = secret.test(name), previous = '';
25
+ try { let count; while ((count = readSync(fd,buffer,0,buffer.length,null))) {
26
+ const chunk=buffer.subarray(0,count); digest.update(chunk); binary ||= chunk.includes(0);
27
+ const text=previous+chunk.toString('utf8'); sensitive ||= secretContent.test(text); previous=text.slice(-512);
28
+ } } finally { closeSync(fd); }
29
+ return {hash:digest.digest('hex'), kind:sensitive?'sensitive':binary?'binary':'text'};
30
+ }
31
+ export function snapshot(directory) {
32
+ const root = realpathSync(directory);
33
+ const top = realpathSync(execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', windowsHide: true }).trim());
34
+ if (root.toLowerCase() !== top.toLowerCase()) throw new Error(`Open the Git repository root for hybrid checkpoints: ${top}`);
35
+ const names = [...new Set(execFileSync('git', ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { encoding: 'utf8', windowsHide: true, maxBuffer: Infinity }).split('\0').filter(Boolean))].sort();
36
+ const files = Object.create(null);
37
+ for (const name of names) {
38
+ // Dedicated plugin output, never implementation source; capturing evidence must
39
+ // not change the artifact being reviewed. Only flat PNG/TXT outputs are written here.
40
+ if(/^\.playwright-mcp\/hybrid-evidence\/[^/]+\.(png|txt)$/i.test(name))continue;
41
+ const absolute = path.resolve(root, name);
42
+ if (!absolute.startsWith(root + path.sep)) throw new Error('Out-of-root Git path.');
43
+ // Git can enumerate through Windows directory junctions. Inventory the link itself,
44
+ // never copy its target or make removing that link impossible through the gate.
45
+ let prefix=root, link=false;
46
+ for(const part of path.relative(root,absolute).split(path.sep)) {
47
+ prefix=path.join(prefix,part);if(!existsSync(prefix))break;
48
+ if(lstatSync(prefix).isSymbolicLink()) {
49
+ const key=path.relative(root,prefix).split(path.sep).join('/');
50
+ files[key]={hash:hash(readlinkSync(prefix)),text:null,kind:'link',hidden:true,generated:false,note:'Link target is not traversed or transmitted.'};link=true;break;
51
+ }
52
+ }
53
+ if(link)continue;
54
+ let info;
55
+ try { info = lstatSync(absolute); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
56
+ if (!info.isFile() || info.isSymbolicLink()) {
57
+ const target=info.isSymbolicLink()?readlinkSync(absolute):'directory/submodule';
58
+ files[name]={hash:hash(target),text:null,kind:'link',hidden:true,generated:false,note:'Link/submodule is inventoried, never traversed. Inspect its intended target separately.'}; continue;
59
+ }
60
+ const resolved = realpathSync(absolute);
61
+ if (!resolved.startsWith(root + path.sep)) throw new Error(`File resolves outside project: ${name}`);
62
+ const inspected=inspectFile(absolute,name);let hidden=inspected.kind!=='text', text=null;
63
+ if(!hidden) {
64
+ const data=readFileSync(absolute);if(hash(data)!==inspected.hash)throw new Error(`File changed while being captured: ${name}. Retry after the write finishes.`);
65
+ text=data.toString('utf8');
66
+ if(hash(Buffer.from(text))!==inspected.hash){inspected.kind='binary';hidden=true;text=null;}
67
+ }
68
+ files[name] = { ...inspected, bytes:info.size, text, generated: generated.test(name), hidden };
69
+ }
70
+ return { root, files, fingerprint: hash(JSON.stringify(Object.entries(files).map(([n, f]) => [n, f.hash]))) };
71
+ }
72
+
73
+ export function delta(before, after) {
74
+ const changed = [];
75
+ let lines = 0;
76
+ for (const name of [...new Set([...Object.keys(before.files), ...Object.keys(after.files)])].sort()) {
77
+ const a = before.files[name], b = after.files[name];
78
+ if (a?.hash === b?.hash) continue;
79
+ if (a?.hidden || b?.hidden) {
80
+ changed.push({file:name,kind:b?.kind??a?.kind??'opaque',beforeHash:a?.hash??null,afterHash:b?.hash??null,bytes:b?.bytes??0,before:'',after:'',
81
+ note:'Non-text or sensitive change. Contents are not transmitted. Do not infer correctness from hashes; request applicable screenshots, safe configuration evidence or asset checks. Credentials must never be included.'}); continue;
82
+ }
83
+ const oldLines = a?.text?.split('\n') ?? [], newLines = b?.text?.split('\n') ?? [];
84
+ let first = 0, lastA = oldLines.length, lastB = newLines.length;
85
+ while (first < lastA && first < lastB && oldLines[first] === newLines[first]) first++;
86
+ while (lastA > first && lastB > first && oldLines[lastA - 1] === newLines[lastB - 1]) { lastA--; lastB--; }
87
+ // Conservative changed-span count, not an exact Git line count: scattered edits may checkpoint early.
88
+ if (!(a?.generated || b?.generated)) lines += lastA - first + lastB - first;
89
+ changed.push({ file: name, generated: !!(a?.generated || b?.generated), startLine: Math.max(0, first - 3) + 1,
90
+ before: oldLines.slice(Math.max(0, first - 3), lastA + 3).join('\n'),
91
+ after: newLines.slice(Math.max(0, first - 3), lastB + 3).join('\n'),
92
+ note: 'Changed span with 3 context lines. Complete current text is in the captured source tree.' });
93
+ }
94
+ return { lines, changed };
95
+ }
@@ -0,0 +1,107 @@
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.file = path.join(folder, 'activity.json');
38
+ this.value = { version: 1, who, adapter:lane.adapter, providerID:lane.providerID, requestedModel: lane.model,
39
+ effort: lane.variant??lane.effort??'default',
40
+ resolvedModels: [], stage: metadata.stage ?? 'diagnostic', label: stageLabel(metadata.stage ?? 'diagnostic'),
41
+ call: metadata.call ?? null, parentPid: process.pid, status: 'preparing', startedAt: new Date().toISOString(),
42
+ endedAt: null, currentAction: 'Checking subscription authentication', usage: null, usageByModel: {}, toolEvents: [], verdict: null };
43
+ this.save();
44
+ }
45
+ save() {
46
+ this.value.updatedAt = new Date().toISOString();
47
+ try { writeFileSync(this.file + '.tmp', JSON.stringify(this.value)); renameSync(this.file + '.tmp', this.file); } catch { /* Observability must never approve/block a checkpoint. */ }
48
+ }
49
+ start(pid) { this.value.status = 'running'; this.value.workerPid = pid; this.value.workerIdentity=processIdentity(pid); this.value.currentAction = 'Model working'; this.save(); }
50
+ event(event) {
51
+ const v = this.value;
52
+ if (!event || typeof event !== 'object') return;
53
+ if (v.adapter === 'codex-cli') {
54
+ if (event.type === 'turn.completed') {
55
+ const usage = normalizeUsage('astra', event.usage);
56
+ if (usage) {
57
+ if (!v.usage) v.usage = usage;
58
+ else for (const field of ['input','output','cacheRead','cacheWrite','total','reasoningOutput']) {
59
+ v.usage[field] = v.usage[field] === null || usage[field] === null ? null : v.usage[field] + usage[field];
60
+ }
61
+ }
62
+ }
63
+ if (event.type?.startsWith('item.') && event.item?.type) {
64
+ 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' };
65
+ if (labels[event.item.type]) v.currentAction = labels[event.item.type];
66
+ }
67
+ // Do not claim an alias is a resolved model when the CLI does not report one.
68
+ if (typeof event.model === 'string') v.resolvedModels = [event.model.slice(0, 150)];
69
+ } else {
70
+ if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') v.resolvedModels = [event.model.slice(0, 150)];
71
+ if (event.type === 'assistant') {
72
+ if (typeof event.message?.model === 'string') v.resolvedModels = [...new Set([...v.resolvedModels, event.message.model.slice(0, 150)])];
73
+ for (const block of event.message?.content ?? []) {
74
+ if (block.type === 'tool_use') {
75
+ const name = String(block.name ?? '').replace(/[^a-zA-Z0-9_.-]/g, '').slice(0, 80);
76
+ v.currentAction = name ? `Using ${name}` : 'Using a research tool';
77
+ v.toolEvents = [...v.toolEvents, { name, at: new Date().toISOString() }].slice(-20);
78
+ }
79
+ }
80
+ }
81
+ if (event.type === 'result') {
82
+ v.usage = normalizeUsage('opus', event.usage);
83
+ for (const [model, usage] of Object.entries(event.modelUsage ?? {})) {
84
+ if (model.length <= 150) v.usageByModel[model] = normalizeUsage('opus', usage);
85
+ }
86
+ if (Object.keys(v.usageByModel).length) v.resolvedModels = Object.keys(v.usageByModel);
87
+ }
88
+ }
89
+ this.save();
90
+ }
91
+ end(status, verdict = null) {
92
+ this.value.status = status; this.value.verdict = verdict; this.value.endedAt = new Date().toISOString();
93
+ this.value.currentAction = status === 'completed' ? 'Report produced' : status === 'cancelled' ? 'Cancelled; no approval' : 'Call failed; checkpoint remains closed';
94
+ this.save();
95
+ }
96
+ }
97
+
98
+ export function jsonLineReader(onEvent) {
99
+ let pending = '';
100
+ const emit = () => { if (pending.trim()) { try { onEvent(JSON.parse(pending)); } catch {} } pending = ''; };
101
+ return { push(text) {
102
+ for (const [i, part] of text.split('\n').entries()) {
103
+ if (i > 0) emit();
104
+ pending += part;
105
+ }
106
+ }, finish: emit };
107
+ }