diff --git a/AGENTS.md b/AGENTS.md index 799c391..93b5053 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,3 +71,16 @@ body and hands them to the human (sessions cannot create Discussions). Only the development flow — Mind prompt → `/start_dev` → `/create_issue` → one issue per task → PR — opens issues here. Why: `PyAutoMind/policy/community_surface.md`. + + +## Shared standards + +Before changing a shared interface, consult the applicable +[organism standard](https://github.com/PyAutoLabs/PyAutoBrain/blob/main/docs/standards.md) +on demand, identify affected consumers, and validate their adoption. Change +generated guidance at its canonical source and regenerate. + +For board changes, follow the applicable sizing, navigation and orchestration +standards and reuse Brain’s shared components. Keep domain data, prompt meaning +and approval boundaries with the board’s owner. + diff --git a/insight/board.py b/insight/board.py index 35eff4d..1e309af 100644 --- a/insight/board.py +++ b/insight/board.py @@ -268,10 +268,17 @@ def render_html(views, now=None, campaign_data=None): ) + "
" + input_marker(views, data) - + campaigns.render_html(data) + + campaigns.render_html( + data, + work_links=[ + {"label": "PyAutoInsight", "href": REPO_URL}, + *({"label": v.instance.repo, "href": v.instance.github_url} for v in views), + ], + ) + "

Execution completion is separate from convergence and scientific acceptance. Cortex retains scientific conclusions. Evidence remains in project storage.

" + "".join(_detail(s, now) for s in views) + "
\n" ) diff --git a/insight/campaigns.py b/insight/campaigns.py index 5c71e09..c7cea68 100644 --- a/insight/campaigns.py +++ b/insight/campaigns.py @@ -164,7 +164,7 @@ def markdown(data: dict) -> str: return "\n".join(rows) -def render_html(data: dict) -> str: +def render_html(data: dict, work_links=()) -> str: def e(value): return html.escape(str(value), quote=True) @@ -201,11 +201,10 @@ def table(headers, rows): return ( marker(data) - + '
' - + theme().prompt_heading("insight") - + f'' - '' - f'

Last check-in: {e(data.get("last_checkin") or "not recorded yet")}.

' + + theme().orchestration_panel( + "insight", "", "", PROMPT, work_links=work_links, organ="insight" + ) + + f'

Last check-in: {e(data.get("last_checkin") or "not recorded yet")}.

' '

Active campaigns

' + table( [ diff --git a/insight/theme.py b/insight/theme.py index c790ccd..95d32aa 100644 --- a/insight/theme.py +++ b/insight/theme.py @@ -21,4 +21,4 @@ def theme(): CSS = "\nmain{padding-bottom:48px}\nh2,h3{color:var(--accent)}h2{border-bottom:1px solid var(--line);padding-bottom:4px;margin-top:32px}\na{color:var(--accent)}\n.lede{color:var(--muted)}\n.tablewrap{overflow-x:auto}\ntable{border-collapse:collapse;width:100%;font-size:14px}\nth,td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}\ntd.num{text-align:right;white-space:nowrap}\n.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.muted{color:var(--muted)}\n.notions{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;margin:8px 0}\n.notions div{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px 12px}\n.notions b{display:block;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:1px}\n.stats{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}\n.stats div{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px 14px}\n.stats b{display:block;font-size:20px;color:var(--accent)}.stats span{font-size:12px;color:var(--muted)}\nbutton[data-copy]{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;text-align:left;\nbackground:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;\npadding:3px 6px;cursor:pointer;word-break:break-all}\nbutton.copied{border-color:var(--ok)}\n.checkin textarea{display:block;width:100%;font:inherit;font-size:14px;line-height:1.5;background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:12px;margin:8px 0;resize:vertical}\n#copy-checkin{background:var(--accent);color:var(--bg);border:0;border-radius:6px;padding:10px 16px;cursor:pointer;font-weight:600}\n#copy-status{margin-left:12px}\ncode{color:var(--accent);word-break:break-all}\n" -JS = "\nvar copyCheckin=document.getElementById('copy-checkin');\nif(copyCheckin){copyCheckin.addEventListener('click',async function(){\n var field=document.getElementById('checkin-prompt'), status=document.getElementById('copy-status');\n try {\n if(navigator.clipboard && window.isSecureContext){await navigator.clipboard.writeText(field.value)}\n else {field.focus();field.select();if(!document.execCommand('copy')){throw new Error('copy unavailable')}}\n status.textContent='Copied';\n } catch(error){field.focus();field.select();status.textContent='Select and copy the prompt above.'}\n});}\n\ndocument.querySelectorAll('button[data-copy]').forEach(function(b){\n b.addEventListener('click',function(){\n var t=b.getAttribute('data-copy');\n var done=function(){b.classList.add('copied');setTimeout(function(){b.classList.remove('copied')},1200)};\n if(navigator.clipboard){navigator.clipboard.writeText(t).then(done,function(){})}\n });\n});\ndocument.querySelectorAll('[data-age-from]').forEach(function(el){\n var t=Date.parse(el.getAttribute('data-age-from'));\n if(!isNaN(t)){el.textContent=' · '+Math.max(0,Math.floor((Date.now()-t)/864e5))+' d today';}\n});\n" +JS = "\ndocument.querySelectorAll('button[data-copy]').forEach(function(b){\n b.addEventListener('click',function(){\n var t=b.getAttribute('data-copy');\n var done=function(){b.classList.add('copied');setTimeout(function(){b.classList.remove('copied')},1200)};\n if(navigator.clipboard){navigator.clipboard.writeText(t).then(done,function(){})}\n });\n});\ndocument.querySelectorAll('[data-age-from]').forEach(function(el){\n var t=Date.parse(el.getAttribute('data-age-from'));\n if(!isNaN(t)){el.textContent=' · '+Math.max(0,Math.floor((Date.now()-t)/864e5))+' d today';}\n});\n" diff --git a/tests/test_board.py b/tests/test_board.py index 4645fb0..f445630 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -24,7 +24,7 @@ def test_controlroom_order_and_domain_labels(registry_file, fake_mind): s = view(registry_file, fake_mind) html = board.render_html([s], now="2026-10-04T00:00:00Z") assert ( - html.index('id="checkin-prompt"') + html.index('id="orchestration-insight-prompt"') < html.index('id="campaigns"') < html.index('id="tasks"') < html.index('id="evidence"') diff --git a/tests/test_campaigns.py b/tests/test_campaigns.py index 5116c3d..086ef1d 100644 --- a/tests/test_campaigns.py +++ b/tests/test_campaigns.py @@ -10,8 +10,8 @@ import pytest import yaml -from insight import campaigns -from insight.theme import JS +from insight import board, campaigns +from insight.theme import theme def copied_ledger(tmp_path): @@ -70,20 +70,42 @@ def test_closed_tasks_hidden_but_parked_stays_visible(): @pytest.mark.parametrize("mode", ["clipboard", "fallback", "rejected", "failed-fallback"]) -def test_copy_button_copies_edited_text_or_selects_manual_fallback(mode): +def test_checkin_copies_complete_shared_preview_or_selects_manual_fallback(mode): node = os.environ.get("CODEX_PRIMARY_RUNTIME_NODE") or shutil.which("node") if not node: pytest.fail("Node runtime required to verify actual copy behavior") harness = r""" const assert=require('node:assert/strict'); let callback,copied=null,focused=false,selected=false; -let field={value:'USER EDITED: focus seed 4',focus(){focused=true},select(){selected=true}}; -let status={textContent:''}; +let field={value:'',defaultValue:'BASE\n\nWork on GitHub:\n- Trusted: https://github.com/example/work',focus(){focused=true},select(){selected=true}}; +let status={textContent:''},details={open:false},direction={value:'focus seed 4\nUnicode α'}; let mode=MODE; -global.document={getElementById(id){return id==='copy-checkin'?{addEventListener(_,cb){callback=cb}}:id==='checkin-prompt'?field:status},querySelectorAll(){return []},execCommand(command){assert.equal(command,'copy');if(mode==='failed-fallback')return false;copied=field.value;return true}}; -global.window={isSecureContext:mode!=='fallback'&&mode!=='failed-fallback'}; -Object.defineProperty(global,'navigator',{value:{clipboard:{async writeText(t){if(mode==='rejected')throw Error('denied');copied=t}}},configurable:true}); +let panel={querySelector(s){return s==='[data-orchestration-prompt]'?field:s==='[data-orchestration-direction]'?direction:s==='[data-orchestration-preview]'?details:status}}; +let button={closest(){return panel}}; +global.document={addEventListener(event,cb){if(event==='click')callback=cb},execCommand(){if(mode==='failed-fallback'||mode==='rejected')return false;copied=field.value;return true}}; +global.guardPrompt=()=>true; +Object.defineProperty(global,'navigator',{value:{clipboard:{async writeText(t){if(mode!=='clipboard')throw Error('denied');copied=t}}},configurable:true}); SCRIPT -(async()=>{await callback(); if(mode==='rejected'||mode==='failed-fallback'){assert.equal(copied,null);assert.ok(focused&&selected);assert.match(status.textContent,/Select and copy/)}else{assert.equal(copied,field.value);assert.equal(status.textContent,'Copied')}})().catch(e=>{console.error(e);process.exitCode=1}); -""".replace("MODE", json.dumps(mode)).replace("SCRIPT", JS) +(async()=>{await callback({target:{closest(){return button}}});assert.ok(field.value.startsWith(field.defaultValue));assert.ok(field.value.endsWith(direction.value));if(mode==='rejected'||mode==='failed-fallback'){assert.equal(copied,null);assert.ok(focused&&selected&&details.open);assert.match(status.textContent,/copy it manually/)}else{assert.equal(copied,field.value);assert.match(status.textContent,/Prompt copied/);if(mode==='fallback')assert.ok(focused&&selected&&details.open)}})().catch(e=>{console.error(e);process.exitCode=1}); +""".replace("MODE", json.dumps(mode)).replace("SCRIPT", theme().ORCHESTRATION_JS) subprocess.run([node, "-e", harness], check=True, capture_output=True, text=True) + + +def test_shared_panel_keeps_owner_prompt_and_all_trusted_destinations(): + import html + import re + + links = [ + {"label": "First project", "href": "https://github.com/example/first"}, + {"label": "Second project", "href": "https://github.com/example/second"}, + ] + page = campaigns.render_html(campaigns.load(), work_links=links) + preview = html.unescape( + re.search(r'data-orchestration-prompt readonly rows="8">(.*?)', page, re.S)[1] + ) + assert preview.startswith(campaigns.PROMPT + "\n\nWork on GitHub:\n") + for link in links: + assert f"- {link['label']}: {link['href']}" in preview + assert page.count("data-orchestration-panel") == 1 + assert "data-orchestration-direction" in page + assert "orchestrationSync" in board.render_html([])