@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,72 @@
1
+ import { captureTimeline, captureTotals, visibleCalls, countedCalls, summarize } from './dashboard-state.js';
2
+ const $ = id => document.getElementById(id);
3
+ const number = value => value == null ? 'Unavailable' : value.toLocaleString('en-US');
4
+ const money = value => value == null ? 'Cost unavailable' : Math.abs(value.high-value.low)<.005 ? `$${value.low.toFixed(2)}` : `$${value.low.toFixed(2)}–$${value.high.toFixed(2)}`;
5
+ let currentData;
6
+ let scope;
7
+ let resets = {};
8
+ function resetKey(kind) { return `hybrid-activity-v1:${scope}:${kind}`; }
9
+ function loadResets(data) {
10
+ const nextScope = JSON.stringify([data.project, data.task]);
11
+ if (scope === nextScope) return;
12
+ scope = nextScope; resets = {};
13
+ for (const kind of ['timeline','totals']) {
14
+ try { const value=JSON.parse(localStorage.getItem(resetKey(kind))); if(value && typeof value==='object' && typeof value.at==='string') resets[kind]=value; } catch { /* Bad or unavailable storage never blocks live data. */ }
15
+ }
16
+ }
17
+ function changeReset(kind, restore = false) {
18
+ if (!currentData?.task) return;
19
+ const value = restore ? null : kind==='timeline' ? captureTimeline(currentData.calls) : captureTotals(currentData.calls);
20
+ resets[kind]=value;
21
+ let saved=true;
22
+ try { if(value) localStorage.setItem(resetKey(kind),JSON.stringify(value)); else localStorage.removeItem(resetKey(kind)); } catch { saved=false; }
23
+ render(currentData);
24
+ $('notice').textContent=(restore ? `${kind==='timeline'?'Timeline':'Totals'} restored.` : kind==='timeline' ? 'Finished calls hidden. Active calls and totals are unchanged.' : 'Totals reset. Timeline is unchanged; further usage from active calls will count.') + (saved ? '' : ' Browser storage is unavailable; this change lasts only until reload.');
25
+ }
26
+ for (const kind of ['timeline','totals']) {
27
+ $(`clear-${kind}`).addEventListener('click',()=>changeReset(kind));
28
+ $(`restore-${kind}`).addEventListener('click',()=>changeReset(kind,true));
29
+ }
30
+ function el(tag, text, className) { const node = document.createElement(tag); if (text != null) node.textContent = text; if (className) node.className = className; return node; }
31
+ function detail(title,text,key) { const d=el('details'); d.dataset.key=key; d.append(el('summary',title),el('pre',text || 'Not available')); return d; }
32
+ function duration(call) { if (!call.startedAt) return 'Duration unavailable'; const end = call.endedAt ? Date.parse(call.endedAt) : ['running','preparing','untracked-active'].includes(call.status) ? Date.now() : NaN; if (!Number.isFinite(end)) return 'End time unavailable'; const seconds=Math.max(0,Math.floor((end-Date.parse(call.startedAt))/1000));return `${Math.floor(seconds/60)}m ${seconds%60}s`; }
33
+ function render(data) {
34
+ currentData=data; loadResets(data);
35
+ const calls=visibleCalls(data.calls,resets.timeline);
36
+ const totals=summarize(countedCalls(data.calls,resets.totals),data.efforts ?? {});
37
+ for (const kind of ['timeline','totals']) {
38
+ $(`clear-${kind}`).disabled=!data.task;
39
+ $(`restore-${kind}`).hidden=!resets[kind];
40
+ $(`${kind}-since`).textContent=resets[kind] ? `${kind==='totals'?'Counting from':'Cleared'} ${new Date(resets[kind].at).toLocaleString()}` : kind==='totals'?'All recorded calls':'';
41
+ }
42
+ const open = new Set([...document.querySelectorAll('details[open][data-key]')].map(node=>node.dataset.key));
43
+ $('project').textContent=data.project || 'Coding workspace'; $('phase').textContent=data.phase;
44
+ $('phase-note').textContent=data.operationActive ? 'A bridge operation is active.' : data.lockPresent ? 'An operation lock remains, but its process is not running. Inspect before continuing.' : 'No bridge operation is active. The implementation model may be working in OpenCode.';
45
+ if(data.decision) $('phase-note').textContent += ` ${data.decision.reason} Blocking: ${data.decision.major.length}. Advisory: ${data.decision.advisory.length}.`;
46
+ if(data.pauseReason) $('phase-note').textContent += ` ${data.pauseReason}`;
47
+ $('totals').replaceChildren(...totals.map(total=>{const box=el('article',null,'metric');box.append(el('h2',`${total.who==='astra'?'Astra':'Opus'} · ${total.effort || 'configured'} effort`),el('div',total.total==null?'Usage unavailable':number(total.total)+' tokens','number'),el('div',money(total.cost),'cost'),el('small',`${total.measuredCalls} of ${total.calls} calls measured · ${total.pricedCalls} priced${total.measuredCalls<total.calls || total.pricedCalls<total.calls?' · partial total':''}`),el('small',total.models.join(' · ') || 'No calls counted'),el('small',`Input: ${number(total.input)}`),el('small',`Output: ${number(total.output)}`),el('small',`Cache: ${number(total.cache)}`));return box;}));
48
+ $('pricing-basis').textContent=(data.pricing?.basis || 'Rates unavailable.') + (data.pricing?.date ? ` Verified ${data.pricing.date}.` : '');
49
+ $('pricing-sources').replaceChildren(...(data.pricing?.sources ?? []).map(source=>{const link=el('a',source.name);link.href=source.url;link.target='_blank';link.rel='noopener noreferrer';return link;}));
50
+ $('call-count').textContent=`${calls.length} visible of ${data.calls.length} calls · ${data.rounds ?? 0} planning rounds completed`;
51
+ $('timeline').replaceChildren(...calls.map(call=>{
52
+ const card=el('article',null,`call ${call.status}`);const head=el('div',null,'call-head');
53
+ head.append(el('h3',`${String(call.sequence).padStart(2,'0')} · ${call.who==='astra'?'Astra':'Opus'} — ${call.label}`),el('span',call.verdict || call.status,`badge ${call.verdict || call.status}`));card.append(head);
54
+ card.append(el('div',`${call.resolvedModels.join(', ') || call.requestedModel+' (requested; resolved identity unavailable)'} · ${call.effort} effort · ${duration(call)} · ${call.peerReportsReceived} peer reports supplied`,'meta'),el('p',call.currentAction,'action'));
55
+ card.append(el('p',call.usage ? `Processed ${number(call.usage.total)} · input ${number(call.usage.input)}${call.usage.inputIncludesCache?' (includes cache reads)':''} · output ${number(call.usage.output)} · cache read ${number(call.usage.cacheRead)} · cache write ${number(call.usage.cacheWrite)}` : 'Token usage unavailable — not captured by this call, or the CLI has not reported it yet.','usage'));
56
+ card.append(el('p',money(call.cost),'call-cost'));
57
+ if(call.report)card.append(detail(`Report · ${call.verdict}`,call.report,call.id+'report'));
58
+ if(call.artifactHash)card.append(el('small',`Reviewed artifact: ${call.artifactHash}`));
59
+ if(call.findings.length)card.append(detail('Classified findings',JSON.stringify(call.findings,null,2),call.id+'findings'));
60
+ card.append(detail('Brief and clarifications actually supplied',`${call.brief || 'No saved brief'}\n\nClarification:\n${call.clarification || 'None forwarded'}`,call.id+'brief'));
61
+ if(call.toolEvents.length)card.append(detail('Observed tool activity (names only)',call.toolEvents.map(tool=>`${new Date(tool.at).toLocaleTimeString()} · ${tool.name}`).join('\n'),call.id+'tools'));
62
+ return card;
63
+ }));
64
+ if (!calls.length) $('timeline').append(el('p','No visible calls. New calls will appear here; cleared history can be restored.','muted'));
65
+ for(const node of document.querySelectorAll('details[data-key]')) node.open=open.has(node.dataset.key);
66
+ $('plan').querySelector('pre').textContent=data.plan || 'No agreed plan has been saved yet.';
67
+ $('connection').textContent='● Live · refreshes every 2 seconds';$('updated').textContent='Updated '+new Date(data.updatedAt).toLocaleTimeString();$('error').textContent='';
68
+ }
69
+ async function poll(){try{const response=await fetch('/api/status',{cache:'no-store'});if(!response.ok)throw Error(`Monitor returned ${response.status}`);render(await response.json());}catch(error){$('connection').textContent='Disconnected';$('error').textContent=error.message+' — last displayed data may be stale.';}finally{setTimeout(poll,2000);}}
70
+ poll();
71
+
72
+
@@ -0,0 +1,22 @@
1
+ // A verdict is not a vote that can erase another reviewer's material finding.
2
+ export const findingSchema = {
3
+ type: 'object', additionalProperties: false,
4
+ properties: {
5
+ id: { type: 'string' }, severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
6
+ category: { type: 'string', enum: ['defect', 'evidence', 'preference'] },
7
+ summary: { type: 'string' }, evidence: { type: 'string' }, consequence: { type: 'string' },
8
+ resolution: { type: 'string' },
9
+ }, required: ['id', 'severity', 'category', 'summary', 'evidence', 'consequence', 'resolution'],
10
+ };
11
+ // Keep the existing export/state key compatible; this is the blocking threshold.
12
+ export const isMajor = finding => ['critical', 'high', 'medium'].includes(finding.severity);
13
+ export function decide(astra, opus) {
14
+ const results=[astra,opus].filter(Boolean);
15
+ const findings = results.flatMap((result, i) => result.findings.map(f => ({ ...f, reviewer: i ? 'opus' : 'astra' })));
16
+ const major = findings.filter(isMajor);
17
+ const approved = major.length === 0 && results.some(result => result.verdict === 'pass') &&
18
+ results.every(result => result.verdict !== 'blocked');
19
+ return { approved, major, advisory: findings.filter(f => !isMajor(f)), policy: 'medium-blocking-v2',
20
+ reason: approved ? 'At least one approval; no configured reviewer has unresolved medium, high or critical findings.' :
21
+ major.length ? 'Resolve the listed medium, high or critical findings, then verify the same revised artifact.' : 'Neither reviewer approved; consolidate the bounded tradeoffs.' };
22
+ }
@@ -0,0 +1,139 @@
1
+ import {lead,feedback} from '../lib/reviewers.mjs';
2
+ import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { hash, snapshot } from './snapshot.mjs';
5
+ import { decide } from './decision.mjs';
6
+ import { runProcess } from './workers.mjs';
7
+ import { captureImages } from './images.mjs';
8
+
9
+ export const DESIGN_POLICY = `UI/UX ownership: the implementation model must not invent visual direction or interaction design. Claude/Opus authors new design decisions and Astra reviews them. This applies to new projects, substantial features without a design, and later redesign/improve-UI requests. Small/medium additions can reuse an established design system and supplied Figma/Claude/local designs can be implemented faithfully, with concrete reference evidence. Escalate material gaps instead of filling them with your own design. Before edits call hybrid_checkpoint design when design is pending; do not bypass through subagents. Read the returned approved design document and give it to frontend-engineer. Existing/supplied exceptions require designMode and designEvidence at begin. Architecture planning does not authorize new visual decisions.`;
10
+
11
+ export function designIntent(text = '') {
12
+ // This is an admission safety net, not a substitute for the semantic routing rule.
13
+ const value = text.toLowerCase().replace(/\b(?:do not|don't|without|no)\s+(?:re-?design|redesign|redisign)\b/g, 'preserve');
14
+ const supplied = /\b(figma|supplied design|provided design|claude design|design file|reference design)\b/.test(value) && /\b(implement|match|follow|reproduce|replicate)\b/.test(value);
15
+ if (/\b(choose|invent|decide|pick|propose)\b.{0,60}\b(design|layout|palette|typography|interaction|font|visual direction)\b/.test(value)) return 'new';
16
+ if (/\b(re-?design|redisign|restyle|rebrand)\b/.test(value) ||
17
+ /\b(improve|better|polish|modernize|refresh)\b.{0,55}\b(ui|ux|design|interface|look|visuals?)\b/.test(value) ||
18
+ /\b(ui|ux|design|interface)\b.{0,40}\b(better|professional|polish|improve)\b/.test(value) ||
19
+ /\b(make|give)\b.{0,50}\b(look|feel)\b.{0,30}\b(professional|modern|premium|better)\b/.test(value)) return 'new';
20
+ if (supplied && !/\b(invent|choose|new design decisions)\b/.test(value)) return 'supplied';
21
+ if (/\b(follow|match|reuse|using)\b.{0,45}\b(existing|current|established)\b.{0,30}\b(pattern|design system|component)\b/.test(value)) return 'existing';
22
+ if (/\b(add|create|build|design)\b.{0,65}\b(new)\b.{0,30}\b(flow|interface|interaction|page|screen|form)\b/.test(value)) return 'new';
23
+ if (/\b(create|build|new|design)\b.{0,65}\b(website|landing page|portfolio|dashboard|design system)\b/.test(value)) return 'new';
24
+ return 'none';
25
+ }
26
+
27
+ export function recordDesignRequest(controller, text, messageID) {
28
+ if (designIntent(text) !== 'new') return;
29
+ if (typeof messageID !== 'string' || !messageID) throw new Error('Cannot track design request without a message identity.');
30
+ const directory = path.join(controller.folder, 'design-requests'); mkdirSync(directory, {recursive:true});
31
+ const file = path.join(directory, hash(String(messageID)) + '.json');
32
+ try { writeFileSync(file, JSON.stringify({ text, at: new Date().toISOString() }), {flag:'wx'}); }
33
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
34
+ }
35
+ export function requests(controller) {
36
+ const directory = path.join(controller.folder, 'design-requests');
37
+ if (!existsSync(directory)) return [];
38
+ return readdirSync(directory).filter(n=>/^[a-f0-9]{64}\.json$/.test(n)).sort().map(name=>{
39
+ const value = JSON.parse(readFileSync(path.join(directory,name),'utf8'));
40
+ return {id:name, ...value};
41
+ }).sort((a,b)=>a.at.localeCompare(b.at)||a.id.localeCompare(b.id));
42
+ }
43
+ export function pendingRequests(controller, state) {
44
+ const acknowledged = new Set(state?.designAcknowledged ?? []);
45
+ return requests(controller).filter(r=>!acknowledged.has(r.id));
46
+ }
47
+ export function designPending(controller, state) {
48
+ return pendingRequests(controller,state).length > 0 || (state?.designMode === 'new' && !state.designApproval);
49
+ }
50
+ export function assertDesign(controller, state) {
51
+ if (designPending(controller,state)) throw new Error('UI/UX design pending: call hybrid_checkpoint design. Claude designs; Astra reviews before implementation.');
52
+ if (state.designApproval && (!state.designPath || hash(readFileSync(state.designPath,'utf8')) !== state.designApproval.hash)) throw new Error('Approved design changed or is missing. Revalidate design before implementation.');
53
+ }
54
+ export function beginDesign(controller, input, previous) {
55
+ if (!input.designMode) throw new Error('Every task needs explicit designMode none/existing/supplied/new. Use none for backend-only or exact mechanical changes, existing for faithful pattern reuse, supplied for provided design, new for design decisions.');
56
+ const detected = designIntent(input.brief);
57
+ const mode = detected === 'new' || input.designMode === 'new' ? 'new' : detected !== 'none' ? detected : input.designMode;
58
+ if (!['none','existing','supplied','new'].includes(mode)) throw new Error('designMode must be none/existing/supplied/new.');
59
+ if (['existing','supplied'].includes(mode) && (typeof input.designEvidence !== 'string' || input.designEvidence.trim().length < 20)) throw new Error('Existing/supplied design requires concrete reference paths/URLs and the patterns to follow in designEvidence.');
60
+ if (input.designEvidence !== undefined && (typeof input.designEvidence !== 'string')) throw new Error('Design evidence must be text.');
61
+ return {designMode:mode, designEvidence:input.designEvidence ?? '', designAcknowledged:previous?.designAcknowledged ?? []};
62
+ }
63
+
64
+ async function skillEvidence(controller, brief, compact) {
65
+ const settings = controller.config.design;
66
+ if (!settings?.skillsDirectory || !settings.pythonExecutable) throw new Error('Design skill configuration missing; implementation remains blocked.');
67
+ const skills = {};
68
+ for (const name of ['frontend-design','ui-ux-pro-max','web-design-guidelines']) {
69
+ skills[name] = readFileSync(path.join(settings.skillsDirectory,name,'SKILL.md'),'utf8');
70
+ }
71
+ const script = path.join(settings.skillsDirectory,'ui-ux-pro-max','scripts','search.py');
72
+ // Fixed local, read-only search command; no shell, model-selected executable, or persistence flags.
73
+ const args = ['-B',script,...(compact ? ['--domain','ux'] : ['--design-system']),'--',brief.slice(0,350)];
74
+ const search = await runProcess(settings.pythonExecutable,args,controller.folder,'',30000);
75
+ return {skills, search, searchMode:compact?'ux':'design-system', guidance:'Use these skill instructions and actual local search results. Opus chooses the design, not the search catalog or implementation model. Fetch current web-design-guidelines using WebFetch. Do not claim to have run a native skill tool in this isolated reviewer environment.'};
76
+ }
77
+
78
+ export async function design(state, input, signal) {
79
+ if (!['implement','fix','review','design'].includes(state.phase)) throw new Error('Complete architecture planning first; design can then run before implementation.');
80
+ if (state.mode === 'feature') this.assertPlan(state);
81
+ if (typeof input.checks !== 'undefined' && (typeof input.checks !== 'string')) throw new Error('Design evidence must be concise text.');
82
+ if (!designPending(this,state) && !input.checks?.trim() && !input.images?.length && state.designApproval) return this.status();
83
+ const pending = pendingRequests(this,state);
84
+ const brief = pending.length ? pending.map(r=>r.text).join('\n\n') : state.brief;
85
+ const evidence = {brief, userDirections:pending, plan:state.plan, referenceEvidence:state.designEvidence ?? '', supplied:input.checks ?? ''};
86
+ const tree = snapshot(this.directory);
87
+ const images=captureImages(this.directory,input.images,tree.fingerprint);
88
+ evidence.visualEvidence=images.map(i=>i.manifest);
89
+ const evidenceHash = hash(JSON.stringify([evidence,tree.fingerprint]));
90
+ if (state.designPause?.evidenceHash === evidenceHash) throw new Error('Design requires new evidence or a clarified requirement: '+state.designPause.reason);
91
+ this.checkpoint(state,'design');
92
+ state.designResumePhase ??= ['fix','review'].includes(state.phase) ? 'fix' : 'implement';
93
+ if(state.mode==='cosmetic') state.mode='small';
94
+ state.phase='design'; this.save(state);
95
+ const skills = await (this.designEvidence ?? skillEvidence)(this,brief,state.mode === 'small');
96
+ if (state.designEvidenceHash !== evidenceHash) { state.designNeedsRevision=true; state.designEvidenceHash=evidenceHash; delete state.designPause; }
97
+ while (true) {
98
+ if(signal?.aborted) throw new Error('Design cancelled; approval remains closed.');
99
+ if (!state.designCandidate || state.designNeedsRevision) {
100
+ const authored = await this.ask(state,lead(this.config),'design-author',{
101
+ ...evidence, skills, previousDesign:state.designCandidate?.text ?? null, findings:state.designFeedback ?? [],
102
+ instruction: 'YOU own UI/UX design. Return a complete implementable design document (a concise direction for a small task). Decide audience, information hierarchy, user flows, layout, tokens/palette, typography roles, components, responsive rules, interaction/loading/empty/error states, accessibility and motion. Preserve supplied constraints. Use the design skills and research results; explain rationale. Give implementation instructions and risk-based acceptance criteria for changed component families and representative states. Do not prescribe all states at all widths by default. Name the risk each required check covers. Identify unresolved essential reference gaps; never delegate design choices to the implementation model. Do not implement code. For this author stage verdict/pass is only your assessment; independent validation follows.'
103
+ },signal,images,tree);
104
+ if (authored.verdict === 'blocked') {
105
+ state.designFeedback=[{who:lead(this.config),...authored}];
106
+ state.designPause={evidenceHash,reason:'Claude needs the evidence listed in its design report.'};this.save(state);return this.status();
107
+ }
108
+ const nextHash=hash(authored.report);
109
+ if (state.designCandidate?.hash===nextHash && state.designLastEvidence===evidenceHash) {
110
+ state.designPause={evidenceHash,reason:'Claude returned the same rejected design without new evidence.'}; this.save(state); return this.status();
111
+ }
112
+ state.designCandidate={text:authored.report,hash:nextHash}; state.designAuthorFindings=authored.findings; state.designNeedsRevision=false; this.save(state);
113
+ }
114
+ const candidate=state.designCandidate;
115
+ const version=hash(candidate.hash+evidenceHash);
116
+ if ((state.designRejected ?? []).includes(version)) {
117
+ state.designPause={evidenceHash,reason:'The same rejected design/evidence returned; resolve the concrete disagreement.'}; this.save(state);return this.status();
118
+ }
119
+ const file=path.join(this.folder,state.id,`design-${candidate.hash}.md`);
120
+ mkdirSync(path.dirname(file),{recursive:true});
121
+ if(existsSync(file) && hash(readFileSync(file,'utf8'))!==candidate.hash) throw new Error('Design artifact changed.');
122
+ writeFileSync(file,candidate.text);
123
+ const packet={...evidence,skills,candidate:candidate.text,artifactHash:candidate.hash,authorFindings:state.designAuthorFindings ?? [],priorFindings:state.designFeedback ?? [],
124
+ instruction:'Review this exact Claude-authored design. Astra challenges usability, accessibility, consistency, completeness and requirements; Claude retains aesthetic ownership. No substitute design based solely on taste. Validate medium/high/critical findings against evidence. Minor preferences are low/info. No implementation yet.'};
125
+ const [astra,opus]=await this.pair(state,'design-validate',packet,signal,images,tree);
126
+ if(snapshot(this.directory).fingerprint!==tree.fingerprint || hash(readFileSync(file,'utf8'))!==candidate.hash) throw new Error('Source or design changed during review; approval remains closed.');
127
+ state.designDecision=decide(astra,opus);state.designFeedback=feedback(astra,opus);state.designLastEvidence=evidenceHash;
128
+ if(state.designDecision.approved) {
129
+ state.designMode='new';state.designPath=file;state.designApproval={hash:candidate.hash,evidenceHash,at:new Date().toISOString()};
130
+ state.designAcknowledged=[...new Set([...(state.designAcknowledged??[]),...pending.map(r=>r.id)])];
131
+ state.phase=state.designResumePhase ?? 'implement';delete state.designResumePhase;state.checkpoint=null;delete state.designPause;this.save(state);return this.status();
132
+ }
133
+ state.designRejected=[...(state.designRejected??[]),version];state.designNeedsRevision=true;
134
+ if(astra.verdict==='blocked'||opus?.verdict==='blocked') {
135
+ state.designPause={evidenceHash,reason:'Gather the reviewers’ missing evidence and call design with updated checks.'};this.save(state);return this.status();
136
+ }
137
+ this.save(state);
138
+ }
139
+ }
@@ -0,0 +1,82 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { hash } from './snapshot.mjs';
4
+
5
+ // Size chooses transport, never whether a review is allowed. All content stays
6
+ // in the captured workspace; no model-generated summary replaces evidence.
7
+ export function prepareEvidence(folder, packet, tree, inlineChars = 400000) {
8
+ let textID = 0;
9
+ const readableText = (text, name) => {
10
+ const target = path.join(folder, name);
11
+ mkdirSync(path.dirname(target), { recursive: true }); writeFileSync(target, text);
12
+ const ref = { path: name, chars: text.length, sha256: hash(text) };
13
+ if (text.split('\n').some(line => line.length > 1000)) {
14
+ // Read tools may truncate a single long line. These JSONL records each
15
+ // contain <=200 UTF-16 code units; concatenate text values in order for
16
+ // the exact original. The raw original is always retained too.
17
+ const chunks = [];
18
+ for (let start = 0; start < text.length; start += 200)
19
+ chunks.push(JSON.stringify({ offset: start, text: text.slice(start, start + 200) }));
20
+ writeFileSync(target + '.chunks.jsonl', chunks.join('\n') + '\n');
21
+ ref.readableChunks = name + '.chunks.jsonl';
22
+ }
23
+ return ref;
24
+ };
25
+ const externalize = value => {
26
+ if (typeof value === 'string' && (value.length > 1000 || value.includes('\n')))
27
+ return { $evidenceText: readableText(value, `evidence/text/${textID++}.txt`) };
28
+ if (Array.isArray(value)) return value.map(externalize);
29
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k,v]) => [k,externalize(v)]));
30
+ return value;
31
+ };
32
+ const save = (name, value) => {
33
+ const text = JSON.stringify(value, null, 2);
34
+ const target = path.join(folder, name);
35
+ mkdirSync(path.dirname(target), { recursive: true });
36
+ writeFileSync(target, text);
37
+ return { path: name, chars: text.length, sha256: hash(text) };
38
+ };
39
+ save('packet.json', packet);
40
+ const source = Object.fromEntries(Object.entries(tree.files)
41
+ .filter(([, file]) => file.text !== null && !file.generated).map(([name, file]) => [name, file.text]));
42
+ const inventory = [];
43
+ for (const [name, file] of Object.entries(tree.files)) {
44
+ if (file.text === null) {
45
+ inventory.push({file:name,kind:file.kind??'opaque',bytes:file.bytes??null,sha256:file.hash,path:null,
46
+ evidence:'Contents withheld: binary, link or sensitive file. Metadata is not proof of correctness. Request appropriate safe checks or PNG evidence; never request credentials.'});continue;
47
+ }
48
+ const target = path.join(folder, 'source', name);
49
+ mkdirSync(path.dirname(target), { recursive: true }); writeFileSync(target, file.text);
50
+ const accessible = file.text.split('\n').some(line => line.length > 1000)
51
+ ? readableText(file.text, `evidence/source-text/${inventory.length}.txt`) : null;
52
+ inventory.push({ file: name, path: `source/${name}`, chars: file.text.length, sha256: hash(file.text), generated: !!file.generated,
53
+ ...(accessible ? { readableChunks: accessible.readableChunks } : {}) });
54
+ }
55
+ const generatedInventory = inventory.filter(f => f.generated).map(f => ({ ...f,
56
+ evidence: packet.changes?.changed.some(item => item.file === f.file)
57
+ ? 'Exact changed spans in packet.changes; full file in source/.'
58
+ : 'Body not inlined; unchanged or planning-only. Full file in source/.' }));
59
+ const inline = JSON.stringify({ packet, source, generatedInventory, opaqueInventory:inventory.filter(f=>f.path===null) });
60
+ if (inline.length <= inlineChars) return { mode: 'inline', text: inline };
61
+
62
+ const sections = [];
63
+ for (const [key, value] of Object.entries(packet)) {
64
+ if (value === undefined) continue;
65
+ // Numeric filenames keep untrusted field names out of filesystem paths.
66
+ if (key === 'changes') {
67
+ const changes = value.changed.map((change, i) => {
68
+ const { before, after, ...metadata } = change;
69
+ return { file: change.file, ...save(`evidence/changes/${i}.json`, { ...externalize(metadata),
70
+ before: { $evidenceText: readableText(before, `evidence/changes/${i}.before.txt`) },
71
+ after: { $evidenceText: readableText(after, `evidence/changes/${i}.after.txt`) } }) };
72
+ });
73
+ sections.push({ field: key, ...save('evidence/changes/index.json', { lines: value.lines, changes }) });
74
+ } else sections.push({ field: key, ...save(`evidence/sections/${sections.length}.json`, externalize(value)) });
75
+ }
76
+ const index = { transport: 'file-backed', artifactHash: packet.artifactHash ?? null,
77
+ sourceFingerprint: tree.fingerprint, packet: 'packet.json', sections,
78
+ source: save('evidence/source-index.json', inventory) };
79
+ save('evidence/index.json', index);
80
+ return { mode: 'file-backed', text: JSON.stringify({ transport: 'file-backed',
81
+ index: 'evidence/index.json', artifactHash: index.artifactHash, sourceFingerprint: tree.fingerprint }) };
82
+ }
@@ -0,0 +1,34 @@
1
+ // Shared by the native OpenCode roles and the isolated premium CLI prompts.
2
+ // These are semantic engineering criteria, not another quota or workflow gate.
3
+ export const FRONTEND_COMMON = `Frontend engineering standard:
4
+ Apply to affected frontend work, using the actual framework, installed versions and repository conventions. Follow the user's requirements. Choose established patterns for the problem and verify relevant current official docs; do not treat "best practices" as a fixed stack, dependency list or folder template. Visual design approval does not approve code architecture.
5
+ Separate independently changing responsibilities: rendering/composition, interaction state/transitions, domain calculations/validation, persistence/transport and navigation/lifecycle. Keep trivial local state with its UI. Extract substantial responsibilities behind narrow typed contracts; do not create layers without a concrete benefit. File length alone is never a defect or a quota. Moving a large component wholesale into one giant hook, or splitting JSX while leaving coupled state/ref/effect orchestration untouched, is not separation of concerns.
6
+ Give each datum and state transition one owner. Derive values rather than mirror them across state, refs and effects. Use the framework's native form/data primitives where adequate; use a reducer or explicit state model when related transitions genuinely require it, without mandating a state-machine library. Refs can hold imperative handles or justified latest-value access but must not become a parallel hidden state store. Keep pure rules independently testable. Effects synchronize external systems with cleanup; do not build chains of effects for derived state or event-driven business logic.
7
+ Keep substantial storage/recovery and request-result handling behind focused interfaces. A coordinating hook may connect those pieces but must not also become the domain engine, storage adapter and rendering implementation. Components communicate through explicit props/events or an appropriate shared owner; do not reach into unrelated components through DOM IDs to change their state.
8
+ For asynchronous UI, define response ownership across resubmission, record changes, unmount, custom links and browser Back/Forward. Verify the framework's real navigation lifecycle. A stale result must not resurrect discarded input, update a different record or redirect after departure. Cleanup/cancellation must not falsely imply a server mutation was rolled back. Share safe validation/limits across client and server; preserve server authority and keep server-only code out of client imports.
9
+ Use checks proportional to behavior: pure transition/rule tests and targeted integration/browser checks for the actual lifecycle, failure and concurrency risks. Passing lint/types/build or many tests does not establish responsibility boundaries. Review the current affected feature and its dependencies cumulatively at meaningful milestones so incremental fixes do not hide structural drift. Do not impose a new checkpoint on cosmetic changes or rewrite unrelated legacy code.`;
10
+
11
+ export const FRONTEND_PLAN = `Frontend planning responsibilities:
12
+ For a substantive interactive feature, include a compact responsibility map in the existing plan: module/component or existing owner -> responsibility -> state it owns -> inputs/events/results -> dependencies. Specify server/client boundaries and shared contracts. Identify the owner of save/retry, recovery and navigation effects when applicable, with key transitions/invariants and focused acceptance scenarios. Name intended extraction boundaries before implementation; "build the editor component" alone is not sufficient for a complex workflow. Reuse established boundaries for small/medium extensions; a simple component needs only a brief rationale, not an extra document or architecture ceremony. Unbuilt UI screenshots and later visual-design documents are not prerequisites for architecture approval. Return decisions and ownership to the builder; do not implement.`;
13
+
14
+ export const FRONTEND_BUILD = `Frontend implementation responsibilities:
15
+ These duties apply to Build and implementation specialists. Read-only architects and reviewers must not implement; they assess the corresponding contracts and evidence.
16
+ Before substantive edits, read the approved architecture/design or existing conventions and identify the responsibilities this change affects. Implement those boundaries as the feature grows, rather than deferring separation until the app is finished. Maintain narrow typed interfaces and a single state owner; preserve behavior and other contributors' work. If the approved plan concentrates incompatible responsibilities, request a focused plan amendment instead of blindly reproducing it. The builder may make ordinary code-organization choices within the plan, but new UI/UX decisions still belong to Claude with Astra review. On handoff, report actual module responsibilities, important state/lifecycle ownership, checks performed and justified exceptions. Do not claim structural quality merely because files got shorter.`;
17
+
18
+ export const FRONTEND_REVIEW = `Frontend review responsibilities:
19
+ For frontend planning, verify the responsibility/state ownership map and contracts are concrete enough to implement. For code review, inspect the actual affected feature, not only the latest tiny patch; trace one important user action through UI state, effects, transport/recovery and navigation, as applicable. Briefly report the responsibilities and source paths inspected, lifecycle checks/evidence and any justified exceptions. Findings need exact code/plan evidence, a concrete consequence and a focused remedy.
20
+ Treat materially entangled responsibilities, duplicated authoritative rules or uncontrolled state/effect coordination as maintainability/correctness findings even when tests pass, when evidence shows a realistic regression risk or forces unrelated behavior changes together. Use medium for material maintainability defects; high/critical require corresponding impact. Do not classify such evidence as cosmetic merely because the current happy path works. Conversely, file length, number of hooks, naming taste, preferred folder layouts or optional abstractions alone do not justify medium findings. Accept cohesive simple components. Report using the configured verdict schema; these criteria do not add workflow gates or numeric work quotas.`;
21
+
22
+ export const ROLE_FRONTEND_RULES = {
23
+ 'frontend-engineer': `${FRONTEND_COMMON}\n\n${FRONTEND_BUILD}`,
24
+ 'fullstack-architect': `${FRONTEND_PLAN}\n\nUse the frontend engineering standard: separate substantial rendering, state transitions, pure rules, persistence and lifecycle responsibilities; avoid giant-component or giant-hook plans and duplicated state. Select proportionate patterns, not mandatory layers. Include stale-response and browser-history cases where async editing is part of scope.`,
25
+ 'code-reviewer': `${FRONTEND_COMMON}\n\n${FRONTEND_REVIEW}`,
26
+ 'test-engineer': `Frontend behavior verification: test the specified state/lifecycle contracts, not private hook wiring or file counts. Use pure tests for nontrivial rules/transitions and integration/browser tests for crossings between UI, requests, recovery and navigation. Where applicable test resubmission, record change, unmount, browser Back/Forward and custom links while a response is delayed; verify stale responses do not restore discarded data or redirect the departed user. Shared validation boundaries need relevant boundary cases. Distinguish cancellation of client effects from cancellation of a server commit. Run only checks justified by this feature; trivial visual tweaks need no new test suite. Report untested boundaries honestly and return structural concerns to the parent.`,
27
+ 'security-auditor': `Frontend security boundaries: check client/server import boundaries, safe shared validation contracts, server-side authorization, and identity ownership across async responses, record/account changes and local recovery. Verify stale state cannot cross users/records, expose sensitive data or weaken server checks. Code organization matters when it creates a concrete security/data-isolation consequence; report that evidence and impact, not a cosmetic file-size finding. Remain read-only and within the actual stack/scope.`,
28
+ 'backend-engineer': `Frontend integration contracts: own authoritative server validation, authorization and domain rules. Expose narrow typed request/result contracts and safe shared constraints that frontend code can consume without importing server-only dependencies. Preserve raw form input separately from validated domain data when needed. Document committed success, validation errors, conflict/retry and uncertain transport outcomes so the frontend does not reimplement business policy or falsely treat client cancellation as server rollback. Implement only the assigned server scope using the actual project stack.`,
29
+ };
30
+
31
+ export const GLOBAL_FRONTEND_ROUTING = `## Frontend responsibility ownership
32
+
33
+ Apply the role-specific frontend engineering instructions before and during substantive frontend work. Build/Plan must hand off concrete module/state ownership and acceptance scenarios, not merely a screen name. The architect defines boundaries; frontend-engineer implements them; code-reviewer examines cumulative feature structure and actual lifecycle behavior; test-engineer verifies relevant boundary cases. Small cohesive components remain simple. No line-count limits, mandatory libraries or extra review rounds. Material coupling is an engineering finding when supported by evidence, even if lint and tests pass. The isolated premium bridge receives the same criteria explicitly; native agent files alone do not configure its reviewers.
34
+ `;
@@ -0,0 +1,35 @@
1
+ import { readFileSync, lstatSync, realpathSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { hash } from './snapshot.mjs';
5
+
6
+ const within = (root, file) => { const r=path.relative(root,file); return r && !r.startsWith('..'+path.sep) && r!=='..' && !path.isAbsolute(r); };
7
+ export function captureImages(directory, images = [], artifactHash) {
8
+ if (!Array.isArray(images)) throw new Error('Supply an array of explicit PNG screenshots.');
9
+ const roots = [path.resolve(directory), path.join(tmpdir(),'opencode')].map(logical=>({logical,real:existsSync(logical)?realpathSync(logical):null}));
10
+ return images.map((item,i) => {
11
+ if (!item || typeof item.path !== 'string' || typeof item.caption !== 'string' || !item.caption.trim() || item.artifactHash !== artifactHash)
12
+ throw new Error('Each screenshot needs path, caption and artifactHash matching status.currentArtifactHash. Capture screenshots of that code version.');
13
+ const absolute = path.resolve(directory,item.path);
14
+ if (!roots.some(root => within(root.logical,absolute)||(root.real&&within(root.real,absolute)))) throw new Error('Screenshots must be inside this project or the dedicated temp/opencode directory.');
15
+ const info=lstatSync(absolute), resolved=realpathSync(absolute);
16
+ if (!info.isFile() || info.isSymbolicLink() || !roots.some(root => root.real&&within(root.real,resolved))) throw new Error('Screenshot links/outside paths are not allowed.');
17
+ if (path.extname(resolved).toLowerCase()!=='.png') throw new Error('Use PNG screenshots; all supplied images are retained.');
18
+ const data=readFileSync(resolved);
19
+ if (data.length<33 || data.subarray(0,8).toString('hex')!=='89504e470d0a1a0a' || data.toString('ascii',12,16)!=='IHDR') throw new Error('Invalid PNG screenshot.');
20
+ const width=data.readUInt32BE(16),height=data.readUInt32BE(20);
21
+ if (!width || !height) throw new Error('Invalid PNG dimensions.');
22
+ return { data, manifest: { path:`images/${i}.png`, caption:item.caption, sha256:hash(data), width,height,artifactHash,
23
+ provenance:'Captured bytes; code-version association and caption supplied by implementer, not independently verified.' } };
24
+ });
25
+ }
26
+ export function saveImages(folder, captured) {
27
+ if (captured.length) {
28
+ mkdirSync(path.join(folder,'images'),{recursive:true});
29
+ writeFileSync(path.join(folder,'images','manifest.json'),JSON.stringify(captured.map(i=>i.manifest),null,2));
30
+ }
31
+ return captured.map(({data,manifest}) => {
32
+ if(hash(data)!==manifest.sha256) throw new Error('Captured image changed before delivery.');
33
+ const target=path.join(folder,manifest.path);mkdirSync(path.dirname(target),{recursive:true});writeFileSync(target,data);return target;
34
+ });
35
+ }
@@ -0,0 +1,58 @@
1
+ import { readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { stageLabel } from './telemetry.mjs';
4
+ import { priceCall, pricingInfo, reportedUsage } from './pricing.mjs';
5
+ import { summarize } from './dashboard-state.js';
6
+
7
+ function json(file) {
8
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
9
+ }
10
+ function alive(pid) { if (!Number.isInteger(pid) || pid < 1) return false; try { process.kill(pid, 0); return true; } catch { return false; } }
11
+ function inside(root, target) {
12
+ try { const relative = path.relative(realpathSync(root), realpathSync(target)); return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative); } catch { return false; }
13
+ }
14
+ export function readDashboard(projectFolder, config) {
15
+ const state = json(path.join(projectFolder, 'state.json'));
16
+ if (!state) return { phase: 'not-started', calls: [], totals: [], updatedAt: new Date().toISOString() };
17
+ if (!/^[a-f0-9-]{36}$/i.test(state.id)) throw new Error('Invalid task ID');
18
+ const taskFolder = path.join(projectFolder, state.id);
19
+ const lock = json(path.join(projectFolder, 'operation.lock'));
20
+ const operationActive = !!lock && alive(lock.pid);
21
+ let names = [];
22
+ try { if (inside(projectFolder, taskFolder)) names = readdirSync(taskFolder).filter(name => /^\d+-(astra|opus)-[a-z0-9-]+$/.test(name)).sort((a,b) => parseInt(a) - parseInt(b)); } catch {}
23
+ const calls = names.map((name, index) => {
24
+ const folder = path.join(taskFolder, name);
25
+ if (!inside(projectFolder, folder)) return null;
26
+ const [, sequence, who, stage] = /^(\d+)-(astra|opus)-(.+)$/.exec(name);
27
+ const get = name => inside(projectFolder, path.join(folder, name)) ? json(path.join(folder, name)) : null;
28
+ const report = get('report.json'), packet = get('packet.json'), activity = get('activity.json');
29
+ const legacyActive = !report && index === names.length - 1 && operationActive;
30
+ const configured = config[who === 'astra' ? 'codex' : 'claude']??{};
31
+ let status = report ? 'completed' : legacyActive ? 'untracked-active' : 'unknown';
32
+ if (activity) {
33
+ status = activity.status;
34
+ if (['preparing','running'].includes(status) && (!alive(activity.parentPid) || Date.now() - Date.parse(activity.updatedAt) > 30000)) status = 'stale';
35
+ }
36
+ let startedAt = activity?.startedAt;
37
+ if (!startedAt) { try { startedAt = statSync(path.join(folder, 'packet.json')).mtime.toISOString(); } catch {} }
38
+ return { id: name, sequence: Number(sequence), who, stage, label: stageLabel(stage), status,
39
+ requestedModel: activity?.requestedModel ?? configured.model, resolvedModels: activity?.resolvedModels ?? [], effort: activity?.effort ?? configured.effort,
40
+ modelEvidence: activity ? 'CLI telemetry (resolved identity when reported)' : 'Configured model; historical CLI identity unavailable',
41
+ startedAt, endedAt: activity?.endedAt ?? null, currentAction: activity?.currentAction ?? (legacyActive ? 'Bridge operation active; detailed telemetry unavailable' : report ? 'Saved report available' : 'No completion evidence'),
42
+ apiCost:activity?.apiCost,usage: activity?.usage ?? null, usageByModel: activity?.usageByModel ?? {}, toolEvents: activity?.toolEvents ?? [],
43
+ verdict: report?.verdict ?? activity?.verdict ?? null, report: typeof report?.report === 'string' ? report.report : null,
44
+ findings: Array.isArray(report?.findings) ? report.findings : [],
45
+ brief: packet?.brief ?? null, clarification: packet?.clarification?.text ?? null,
46
+ artifactHash: packet?.artifactHash ?? null,
47
+ peerReportsReceived: (packet?.previous?.length ?? 0) + (packet?.astra ? 1 : 0) +
48
+ (packet?.priorFindings?.length ?? 0) + (packet?.proposals ? Object.keys(packet.proposals).length : 0),
49
+ };
50
+ }).filter(Boolean);
51
+ for (const call of calls) { call.usage = reportedUsage(call); call.cost = priceCall(call); }
52
+ const efforts = { astra: config.codex?.effort, opus: config.claude?.effort };
53
+ const totals = summarize(calls, efforts);
54
+ return { project: state.baseline?.root, task: state.id, phase: state.phase, brief: state.brief, rounds: state.rounds,
55
+ operationActive, lockPresent: !!lock, calls, totals, efforts, pricing: pricingInfo, decision: state.decision ?? null,
56
+ pauseReason: state.pauseReason ?? null, checkpoint: state.checkpoint ?? null, updatedAt: new Date().toISOString(),
57
+ plan: typeof state.plan === 'string' ? state.plan : '', clarification: state.planningClarification?.text ?? null };
58
+ }
@@ -0,0 +1,24 @@
1
+ import http from 'node:http';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+ import { readDashboard } from './monitor-data.mjs';
6
+ const here = path.dirname(fileURLToPath(import.meta.url));
7
+ export function createMonitor(projectFolder, config) {
8
+ return http.createServer((req, res) => {
9
+ const origin = `http://127.0.0.1:${res.socket.localPort}`;
10
+ const expectedHost = `127.0.0.1:${res.socket.localPort}`;
11
+ res.setHeader('Cache-Control','no-store'); res.setHeader('X-Content-Type-Options','nosniff');
12
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
13
+ if (req.headers.host !== expectedHost || (req.headers.origin && req.headers.origin !== origin) || req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end('Local origin required'); return; }
14
+ if (req.method !== 'GET') { res.writeHead(405); res.end(); return; }
15
+ const route = req.url?.split('?')[0];
16
+ try {
17
+ if (route === '/api/status') { res.setHeader('Content-Type','application/json'); res.end(JSON.stringify(readDashboard(projectFolder, config))); return; }
18
+ if (route === '/health') { res.setHeader('Content-Type','application/json'); res.end(JSON.stringify({ service: 'hybrid-activity-monitor', projectFolder, pid: process.pid })); return; }
19
+ const assets = { '/': ['dashboard.html','text/html'], '/app.js':['dashboard.js','text/javascript'], '/dashboard-state.js':['dashboard-state.js','text/javascript'], '/style.css':['dashboard.css','text/css'] };
20
+ if (!assets[route]) { res.writeHead(404); res.end(); return; }
21
+ const [file,type] = assets[route]; res.setHeader('Content-Type', type + '; charset=utf-8'); res.end(readFileSync(path.join(here,file)));
22
+ } catch { res.writeHead(503); res.end('Report data temporarily unavailable; retry shortly.'); }
23
+ });
24
+ }
@@ -0,0 +1,82 @@
1
+ import {createServer} from 'node:net';
2
+ import {existsSync,readFileSync,writeFileSync,renameSync,unlinkSync,readdirSync} from 'node:fs';
3
+ import path from 'node:path';
4
+ import {hash} from './snapshot.mjs';
5
+ import {sameProcess,ownIdentity} from './process-identity.mjs';
6
+ import {acquirePosixMutex} from './posix-lock.mjs';
7
+
8
+ const pending=new Set();
9
+ const busy=()=>new Error('Another checkpoint operation is active. Wait for it to finish; history is preserved.');
10
+ const orphanFile=file=>file+'.unreaped.json';
11
+ function orphanAlive(file) {
12
+ if(!existsSync(orphanFile(file)))return false;
13
+ const value=JSON.parse(readFileSync(orphanFile(file),'utf8'));return (value.records ?? [value]).some(r=>r.nativeSessionID||r.descendantsUnconfirmed===true||sameProcess(r.pid,r.identity));
14
+ }
15
+ export function retainUnreaped(file,error){
16
+ const target=orphanFile(file), previous=existsSync(target)?JSON.parse(readFileSync(target,'utf8')):null;
17
+ const records=previous?(previous.records??[previous]):[];
18
+ const record={nativeSessionID:error.nativeSessionID,pid:error.unreapedPid,identity:error.workerIdentity,descendantsUnconfirmed:!!error.descendantsUnconfirmed,at:new Date().toISOString()};
19
+ if(!records.some(r=>r.nativeSessionID===record.nativeSessionID&&r.pid===record.pid&&r.identity===record.identity&&r.descendantsUnconfirmed===record.descendantsUnconfirmed))records.push(record);
20
+ writeFileSync(target,JSON.stringify({records}));
21
+ }
22
+ export const operationBusy=file=>pending.has(file)||existsSync(file)||orphanAlive(file);
23
+ export function alive(pid) {
24
+ if(!Number.isInteger(pid)||pid<1) return true; // Unknown owner must not be mistaken for a dead one.
25
+ try{process.kill(pid,0);return true;}catch(error){return error.code!=='ESRCH';}
26
+ }
27
+ function activeChildren(folder,owner) {
28
+ // Only manifests created by the bridge; never inspect arbitrary source paths.
29
+ for(const task of readdirSync(folder,{withFileTypes:true})) {
30
+ if(!task.isDirectory()||!/^[a-f0-9-]{36}$/i.test(task.name))continue;
31
+ for(const call of readdirSync(path.join(folder,task.name),{withFileTypes:true})) {
32
+ if(!call.isDirectory()||!/^\d+-(astra|opus)-/.test(call.name))continue;
33
+ const file=path.join(folder,task.name,call.name,'activity.json');if(!existsSync(file))continue;
34
+ const value=JSON.parse(readFileSync(file,'utf8'));
35
+ // Terminal workers have exited; old PID numbers are not live process identities.
36
+ if(value.parentPid!==owner.pid || Date.parse(value.startedAt)<Date.parse(owner.started) || (value.endedAt&&!value.workerExitUnconfirmed))continue;
37
+ if(value.descendantsUnconfirmed||(value.workerPid&&sameProcess(value.workerPid,value.workerIdentity)))return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+ async function listen(server,address) {
43
+ await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(address,()=>{server.removeListener('error',reject);resolve();});});
44
+ }
45
+ export async function acquireOperation(file,resolveNative,{platform=process.platform}={}) {
46
+ if(pending.has(file))throw busy();pending.add(file);
47
+ let releaseMutex,owned=false;
48
+ try {
49
+ if(!['win32','linux'].includes(platform))releaseMutex=await acquirePosixMutex(file);
50
+ else {
51
+ const suffix=hash(path.resolve(file)).slice(0,30);
52
+ const address=platform==='win32'?`\\\\.\\pipe\\opencode-hybrid-${suffix}`:`\0opencode-hybrid-${suffix}`;
53
+ const server=createServer(socket=>socket.destroy());
54
+ try{await listen(server,address);}catch{throw busy();}
55
+ releaseMutex=()=>new Promise(resolve=>server.close(resolve));
56
+ }
57
+ if(resolveNative)await resolveNative(file);
58
+ if(orphanAlive(file))throw new Error('Previous worker termination is unconfirmed. A known live worker must exit; unknown descendant processes require process-tree diagnosis before recovery. No approval was recorded.');
59
+ if(existsSync(orphanFile(file)))renameSync(orphanFile(file),orphanFile(file)+'.resolved-'+Date.now());
60
+ if(existsSync(file)) {
61
+ const owner=JSON.parse(readFileSync(file,'utf8'));
62
+ if(sameProcess(owner.pid,owner.identity)||activeChildren(path.dirname(file),owner))throw busy();
63
+ renameSync(file,`${file}.interrupted-${Date.now()}`);
64
+ }
65
+ writeFileSync(file,JSON.stringify({pid:process.pid,identity:ownIdentity(),started:new Date().toISOString()}),{flag:'wx'});owned=true;
66
+ return async()=>{try{if(owned)unlinkSync(file);}finally{try{await releaseMutex();}finally{pending.delete(file);}}};
67
+ } catch(error) {
68
+ try{if(releaseMutex)await releaseMutex();}finally{pending.delete(file);}throw error;
69
+ }
70
+ }
71
+
72
+ export async function resolveNativeGuards(file,inspect) {
73
+ const target=orphanFile(file);if(!existsSync(target))return;
74
+ const value=JSON.parse(readFileSync(target,'utf8')),records=value.records??[value],remaining=[];
75
+ for(const record of records){
76
+ let done=false;
77
+ if(record.nativeSessionID)try{const session=await inspect(record.nativeSessionID);done=!!session.time?.idle&&['succeeded','failed','interrupted'].includes(session.outcome);}catch{}
78
+ if(!done)remaining.push(record);
79
+ else if(record.pid||record.descendantsUnconfirmed)remaining.push({...record,nativeSessionID:undefined});
80
+ }
81
+ if(JSON.stringify(remaining)!==JSON.stringify(records)){const temporary=target+'.tmp';writeFileSync(temporary,JSON.stringify({records:remaining}));renameSync(temporary,target);}
82
+ }