claude-dev-env 8.95.1 → 8.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,8 @@ python scripts/render.py SCENE.json --out RUN/visual.html --links-only
10
10
  python scripts/verify.py RUN/visual.html --out RUN/proof
11
11
  ```
12
12
 
13
+ An issue URL captures a tracker epic, its sub-issues, and the pull requests their bodies link. Its scene renders in the same viewer, and each source opens the captured issue or pull request. The rendered scene draws every card in a state lane, grouped under its epic, and each card opens its own state diagram of what it fixes and how it is proven. `fixtures/tracker.scene.json` shows the shape.
14
+
13
15
  Flow diagrams, decision trees, dependency graphs, comparisons, bar charts, line charts, and scatter plots share the same source details. Numerical chart coordinates resolve JSON pointers against captured source files. The renderer does not supply an independent semantic code review.
14
16
 
15
17
  Try the synthetic fixture gallery from this directory:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pr-visual
3
- description: Read a pull request's summary and code, trace the changed behavior, and return an interactive visual. Use for "visualize this PR", "draw this change", "PR visual", "chart this PR", "graph this change", or a PR URL with a request for a visual. Accept any accessible GitHub PR.
3
+ description: Read a pull request's summary and code, trace the changed behavior, and return an interactive visual. Also draws a tracker epic's status from its issues and linked pull requests. Use for "visualize this PR", "draw this change", "PR visual", "chart this PR", "graph this change", a status catch-up on an epic, or a PR or issue URL with a request for a visual. Accept any accessible GitHub PR or issue.
4
4
  ---
5
5
 
6
6
  # PR visual
@@ -11,13 +11,15 @@ Turn a PR into a visual explanation of what its code changes. Return the display
11
11
 
12
12
  Accept a PR URL. Resolve a bare PR number only when the repository is known.
13
13
 
14
+ Accept an issue URL for a status catch-up on an epic or tracker. The collector captures the issue, its sub-issues two levels deep, and the pull requests their bodies link. Draw that scene in this same viewer, batched with the scenes of the pull requests it names. Every pr-visual request renders through `assets/viewer.html`; a status report never moves to another page template. A tracker scene always draws every card inside its state chart. Set `board` in the scene: `lanes` run top to bottom in flow order, and each lane names one scene node. A lane with `progress` (`no_pull`, `open_pull`, `merged_pull`) takes every open tracker card in that state, grouped under its parent epic. A lane with `groups` takes cards that each cite a captured record and link inside the repository, such as done, skipped, or follow-up work listed in an epic body. Every node sits in exactly one lane. `strip` drops title prefixes, `titles` gives plain wording by issue number, and `exclude` leaves out issues that are not work. `steps` names the issue body sections each card shows as its own state diagram, such as `Today` and `Fix`, with a label and colour; the step marked `inline` also prints on the card. `wording` swaps words or whole sentences in that text for plain ones. A cited card carries its `steps` itself. Clicking a card opens its diagram in the side panel, with a link to the card on GitHub. `verify.py` checks the card count, that each title fits its card, that the first card in each lane opens a diagram with one box per step, and that board and diagram text is 16px or larger at 7:1 contrast.
15
+
14
16
  Read the description, changed code, relevant callers, and tests. Build an interactive diagram with short labels, visual drilldowns, source details, and a summary-to-code comparison.
15
17
 
16
18
  Use the current agent for interpretation. The collector and renderer run locally. The shell command alone does not review code. Complete the whole workflow when this skill is invoked.
17
19
 
18
20
  ## Workflow
19
21
 
20
- 1. **Capture the PR.** Run `python <skill-root>/scripts/collect.py <PR-URL> --out <run-dir>/evidence`. It saves the PR description, open or closed state, base and head commits, changed paths, patches, and full base/head changed-file source. Use an authorized GitHub connector if gh is unavailable. Preserve the same snapshot fields. If capture fails or a patch is incomplete, fetch the missing source before interpretation. Do not invent a complete review from a partial diff.
22
+ 1. **Capture the PR or issue.** Run `python <skill-root>/scripts/collect.py <PR-or-issue-URL> --out <run-dir>/evidence`. An issue capture writes `issues/<n>.md`, `issues/<n>.json`, `pulls/<n>.json`, and `tracker.json` under `head/`; cite their lines, and chart `tracker.json` counts by JSON pointer. It saves the PR description, open or closed state, base and head commits, changed paths, patches, and full base/head changed-file source. Use an authorized GitHub connector if gh is unavailable. Preserve the same snapshot fields. If capture fails or a patch is incomplete, fetch the missing source before interpretation. Do not invent a complete review from a partial diff.
21
23
  2. **Trace the behavior.** Read the snapshot description and diff. Follow the code from its entry point to its output, including decisions, side effects, failure paths, and material callers. Fetch unchanged callers at the captured commit when needed. Read repository review instructions. Compare the summary with the code. Record material extra scope, contradictions, and limitations. Treat prose claims and past test reports as claims until supported by current source or observed execution.
22
24
  3. **Choose the format.** Read [the visual contract](references/visual-contract.md). Pick the structure the change needs. Use a state loop for a sampler, a decision tree for an admission gate, split lanes for two job paths, or an angle loop for capture. Keep the top dashboard to compact PR selectors. Embed quantitative charts inside the relevant flow-step cards and their expansions. Keep the review, charts, and evidence together on one Page. Use a chart when captured numerical data answers the question: bars for categories, lines for ordered observations, scatter for relationships. Read [format selection](references/formats.md). Keep the cream background, teal line icons, green progress paths, orange conditional paths, and generous spacing. Give every node a source-backed mini-diagram in a docked detail panel, related-step navigation, and collapsed exact explanation and sources. Keep the main map usable while the panel is open. Do not copy the shipping instructions into unrelated content.
23
25
  4. **Render the scene.** Write a scene JSON using [the scene contract](references/scene-contract.md). Attach immutable file-and-line evidence to every node. Render it with `python <skill-root>/scripts/render.py <scene.json> --out <run-dir>/visual.html`. For hosted display, add `--links-only` to omit full PR bodies and source excerpts while retaining the diagrams and immutable links. For a batch, pass multiple scene files to create a compact selector dashboard and flow reviews. Avoid a detached chart-demo gallery. Compare the generated diagram with the source once more. Fix misleading arrows, labels, implied guarantees, or omitted branches before continuing.
@@ -56,7 +58,7 @@ Return the displayed visual and a short link to the saved result. Mention a mate
56
58
  | `scripts/visual_config/` | Shared constants |
57
59
  | `assets/` | Standalone viewer and icon symbol definitions |
58
60
  | `references/` | Scene, visual, and format contracts |
59
- | `fixtures/` | Synthetic flow, bar, line, and scatter examples |
61
+ | `fixtures/` | Synthetic flow, bar, line, scatter, and tracker examples |
60
62
  | `scripts/test_render.py` | Evidence and numerical validation cases |
61
63
  | `scripts/test_collect.py` | Capture consistency and stale-head cases |
62
64
  | `scripts/test_metrics.py` | Embedded-chart provenance and passive literal extraction |
@@ -36,6 +36,13 @@
36
36
  [data-pr-visual] .detail-map .mini-label{font-size:12px;font-weight:700}[data-pr-visual] .detail-map .mini-edge{font-size:10px;fill:#64746e}
37
37
  [data-pr-visual] .detail-jumps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-bottom:18px}[data-pr-visual] .detail-jumps button{display:flex;flex-direction:column;align-items:center;gap:7px;background:#f2f5ea;border:1px solid #879b8e;border-radius:6px;padding:9px 4px;color:#17353f;font-size:11px;line-height:1.3}[data-pr-visual] .detail-jumps button span{color:#17353f}[data-pr-visual] .detail-jumps svg{width:24px;height:24px}
38
38
  [data-pr-visual] .exact-source{border-top:1px solid #d5dcd2;padding-top:14px;margin:18px 0}[data-pr-visual] .exact-source summary{font-size:13px;font-weight:700;color:#52695f;cursor:pointer}
39
+ [data-pr-visual] .band{fill:#fffef8;stroke:#d5dcd2;stroke-width:1.5}[data-pr-visual] .band.lime{fill:#f1f9db;stroke:#a4c357}[data-pr-visual] .band.orange{fill:#fcf1e4;stroke:#ca955e}[data-pr-visual] .band.green{fill:#eef5ea;stroke:#9db996}[data-pr-visual] .band.blue{fill:#e9f2f5;stroke:#8faeb9}
40
+ [data-pr-visual] [data-card]{cursor:pointer;outline:none}[data-pr-visual] .card-chip{fill:#fffef8;stroke:#aabdac;stroke-width:1.5}[data-pr-visual] [data-card]:hover .card-chip,[data-pr-visual] [data-card]:focus .card-chip{stroke:#217757;stroke-width:3}
41
+ [data-pr-visual] .drawer .card-state{font-size:16px;font-weight:700;color:#17353f;margin:0 0 14px}[data-pr-visual] .drawer .card-link{font-size:16px;font-weight:700;color:#17353f;margin:18px 0}
42
+ [data-pr-visual] .card-step{fill:#fffef8;stroke:#aabdac;stroke-width:1.5}[data-pr-visual] .card-step.orange{fill:#fcf1e4;stroke:#ca955e}[data-pr-visual] .card-step.lime{fill:#f1f9db;stroke:#a4c357}[data-pr-visual] .card-step.blue{fill:#e9f2f5;stroke:#8faeb9}[data-pr-visual] .card-step.green{fill:#eef5ea;stroke:#9db996}
43
+ [data-pr-visual] .card-flow .card-step-label{font-size:16px;font-weight:800;fill:#17353f}[data-pr-visual] .card-flow .card-step-text{font-size:16px;fill:#17353f}
44
+ [data-pr-visual] .board .card-text{font-size:16px;font-weight:700;fill:#17353f}[data-pr-visual] .board .card-build,[data-pr-visual] .board .lane-sub{font-size:16px;fill:#17353f}[data-pr-visual] .board .card-note{font-size:16px;font-weight:700;fill:#17353f}[data-pr-visual] .board .board-group{font-size:18px;font-weight:800;fill:#17353f;text-decoration:underline}
45
+ [data-pr-visual] .board .subtitle,[data-pr-visual] .board .micro,[data-pr-visual] .board .kicker,[data-pr-visual] .board .stamp,[data-pr-visual] .board .foot-label{font-size:16px;fill:#17353f}
39
46
  [data-pr-visual] .sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
40
47
  [data-pr-visual] button:focus-visible,[data-pr-visual] summary:focus-visible,[data-pr-visual] a:focus-visible{outline:3px solid #17353f;outline-offset:3px}
41
48
  [data-pr-visual] .toolbar button:focus-visible{outline-color:#d8f687}
@@ -49,7 +56,7 @@
49
56
  <script type="application/json" data-scenes>__SCENES__</script><script>
50
57
  (()=>{
51
58
  const root=document.currentScript.closest('[data-pr-visual]'),$=s=>root.querySelector(s),all=s=>[...root.querySelectorAll(s)],ns='http://www.w3.org/2000/svg';
52
- const scenes=JSON.parse($('[data-scenes]').textContent),icons=`__ICONS__`,prefix='prv-'+Math.random().toString(36).slice(2)+'-';
59
+ const scenes=JSON.parse($('[data-scenes]').textContent),isIssue=s=>s.pr.kind==='issue',refName=s=>isIssue(s)?'Issue':'PR',icons=`__ICONS__`,prefix='prv-'+Math.random().toString(36).slice(2)+'-';
53
60
  const canvas=$('.canvas'),tabs=$('.tabs'),drawer=$('.drawer'),workspace=$('.workspace'),content=$('.content');let active=0,zoom=1,lastFocus;
54
61
  canvas.id=prefix+'diagram';drawer.id=prefix+'details';
55
62
  function el(tag,attrs={},text){const n=document.createElementNS(ns,tag);Object.entries(attrs).forEach(([k,v])=>{if(k==='href'&&v.startsWith('#'))v='#'+prefix+v.slice(1);if(k==='marker-end')v=v.replace('#','#'+prefix);n.setAttribute(k,String(v))});if(text!==undefined)n.textContent=text;return n}
@@ -102,9 +109,32 @@ function showPanel(title,flow,paragraphs,sources=[],raw,related=[],trigger,chart
102
109
  if(related.length){content.append(dom('h3','Related steps'));const jumps=dom('div',undefined,'detail-jumps');related.forEach(n=>{const b=dom('button');b.dataset.detailTarget=n.id;const icon=el('svg',{viewBox:'0 0 48 48','aria-hidden':true});icon.append(el('use',{href:'#'+n.icon,width:48,height:48,class:'icon'}));b.append(icon,dom('span',n.label.join(' ')));b.onclick=()=>showNode(n.id);jumps.append(b)});content.append(jumps)}
103
110
  const exact=dom('details',undefined,'exact-source');exact.append(dom('summary','Explanation and sources'));paragraphs.forEach(v=>exact.append(dom('p',v)));
104
111
  sources.forEach(s=>{const a=dom('a',`${s.path} \u00b7 lines ${s.line}\u2013${s.end||s.line}`);a.href=s.url;a.target='_blank';a.rel='noopener noreferrer';exact.append(a);if(s.excerpt)exact.append(dom('pre',s.excerpt))});
105
- if(raw)exact.append(dom('h3','PR description at capture'),dom('pre',raw));content.append(exact);content.scrollTop=0;drawer.hidden=false;workspace.classList.add('open');$('.close').focus({preventScroll:true})}
112
+ if(raw)exact.append(dom('h3',`${refName(scenes[active])} description at capture`),dom('pre',raw));content.append(exact);content.scrollTop=0;drawer.hidden=false;workspace.classList.add('open');$('.close').focus({preventScroll:true})}
106
113
  function showNode(id){const s=scenes[active],n=s.nodes.find(n=>n.id===id);resetSelection();const trigger=all('[data-node]').find(g=>g.dataset.node===id);trigger.setAttribute('aria-expanded','true');const neighbors=new Set(s.edges.filter(e=>e.from===id||e.to===id).flatMap(e=>[e.from,e.to]));neighbors.delete(id);showPanel(n.label.join(' '),n.drilldown,n.detail,n.sources,undefined,s.nodes.filter(n=>neighbors.has(n.id)),trigger,n.chart)}
107
114
  $('.close').onclick=()=>close();root.addEventListener('keydown',e=>{if(e.key==='Escape'&&!drawer.hidden){e.preventDefault();close()}});
115
+ function drawNode(svg,n,subClass='micro',subGap=19){const g=el('g',{class:'node','data-node':n.id,tabindex:0,role:'button','aria-expanded':'false','aria-controls':drawer.id,'aria-label':n.label.join(' ')});g.append(el('title',{},'Open code evidence'));if(n.chart){stepCard(g,n)}else if(n.kind==='decision'){g.append(el('path',{d:`M${n.x} ${n.y-54} L${n.x+62} ${n.y} L${n.x} ${n.y+54} L${n.x-62} ${n.y} Z`,class:'diamond'}));lines(g,n.x,n.y-3,n.label,'node-label','middle',21)}else{g.append(el('circle',{cx:n.x,cy:n.y,r:n.r||43,class:`disc ${n.kind||''}`}));g.append(el('use',{href:`#${n.icon}`,x:n.x-22,y:n.y-22,width:44,height:44,class:'icon'}));lines(g,n.x,n.y+70,n.label,'node-label','middle')}if(n.sub&&!n.chart)lines(g,n.x,n.y+(n.label.length>1?121:99),n.sub,subClass,'middle',subGap);const show=()=>showNode(n.id);g.onclick=show;g.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();show()}};svg.append(g)}
116
+ const measure=document.createElement('canvas').getContext('2d');
117
+ function wrap(value,width,font){measure.font=font;const out=[];let line='';value.split(' ').forEach(w=>{const next=line?`${line} ${w}`:w;if(line&&measure.measureText(next).width>width){out.push(line);line=w}else line=next});if(line)out.push(line);return out}
118
+ function link(href){return el('a',{href,target:'_blank',rel:'noopener noreferrer'})}
119
+ function drawBoard(svg,s){
120
+ const rail=150,left=300,right=1404,gap=14,pad=14,step=22,columns=2,width=(right-left-gap*(columns-1))/columns;let y=184,previous;
121
+ s.board.lanes.forEach(lane=>{const n={...s.nodes.find(n=>n.id===lane.node),x:rail,y:y+72},band=el('rect',{x:24,y,width:1392,rx:18,class:`band ${n.kind||''}`});svg.append(band);
122
+ const nodeBottom=n.y+(n.sub?(n.label.length>1?121:99)+(n.sub.length-1)*21:70+(n.label.length-1)*23)+12;let cy=y+20;
123
+ lane.groups.forEach(g=>{const head=link(g.url);text(head,left,cy+20,`${g.title} \u00b7 ${g.cards.length}`,'board-group');svg.append(head);cy+=36;
124
+ for(let i=0;i<g.cards.length;i+=columns){const row=g.cards.slice(i,i+columns).map(card=>{const inline=(card.steps||[]).find(v=>v.inline);return {card,body:wrap(card.title,width-pad*2,'bold 16px Arial'),build:inline?wrap(`${inline.label}: ${inline.text}`,width-pad*2,'16px Arial'):[],note:card.note?wrap(card.note,width-pad*2,'bold 16px Arial'):[]}});const height=pad*2+Math.max(...row.map(r=>r.body.length+r.build.length+r.note.length))*step-4;
125
+ row.forEach((r,j)=>{const x=left+j*(width+gap),chip=el('g',{tabindex:0,role:'button','aria-label':`${r.card.title}. Open its details.`});chip.dataset.card=r.card.number||'cited';chip.append(el('title',{},'Open what this card builds'),el('rect',{x,y:cy,width,height,rx:8,class:'card-chip'}));let ty=cy+pad+14;lines(chip,x+pad,ty,r.body,'card-text','start',step);ty+=r.body.length*step;lines(chip,x+pad,ty,r.build,'card-build','start',step);ty+=r.build.length*step;lines(chip,x+pad,ty,r.note,'card-note','start',step);const show=()=>showCard(r.card,lane,chip);chip.onclick=show;chip.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();show()}};svg.append(chip)});cy+=height+10}cy+=10});
126
+ const bottom=Math.max(cy,nodeBottom)+12;band.setAttribute('height',bottom-y);
127
+ if(previous!==undefined)svg.append(el('path',{d:`M${rail} ${previous} L${rail} ${n.y-54}`,class:'wire','marker-end':'url(#arrow)'}));
128
+ drawNode(svg,n,'lane-sub',21);previous=nodeBottom;y=bottom+16});
129
+ return y}
130
+ function cardFlow(steps){const width=396,pad=14,step=22,flow=el('svg',{class:'card-flow',role:'img','aria-label':steps.map(v=>v.label).join(', then ')});let y=4;
131
+ steps.forEach((v,i)=>{const body=wrap(v.text,width-pad*2-4,'16px Arial'),height=pad*2+step+body.length*step;if(i)flow.append(el('path',{d:`M${width/2} ${y-30} L${width/2} ${y-4}`,class:'wire green','marker-end':'url(#arrowGreen)'}));
132
+ flow.append(el('rect',{x:2,y,width:width-4,height,rx:12,class:`card-step ${v.kind||''}`}));text(flow,pad+2,y+pad+15,v.label,'card-step-label');lines(flow,pad+2,y+pad+15+step,body,'card-step-text','start',step);y+=height+34});
133
+ flow.setAttribute('viewBox',`0 0 ${width} ${y-30}`);return flow}
134
+ function showCard(card,lane,trigger){const s=scenes[active],state=s.nodes.find(n=>n.id===lane.node);lastFocus=trigger;resetSelection();content.replaceChildren();$('[data-detail-title]').textContent=card.title;
135
+ content.append(dom('p',[state.label.join(' '),card.note].filter(Boolean).join(' \u00b7 '),'card-state'),cardFlow(card.steps?.length?card.steps:[{label:'Card',text:card.title}]));
136
+ const a=dom('a',card.number?`Open issue ${card.number} on GitHub`:'Open it on GitHub','card-link');a.href=card.url;a.target='_blank';a.rel='noopener noreferrer';content.append(a);
137
+ content.scrollTop=0;drawer.hidden=false;workspace.classList.add('open');$('.close').focus({preventScroll:true})}
108
138
  function chartNode(svg,n,x,y,bar){
109
139
  const g=el('g',{class:'node','data-node':n.id,tabindex:0,role:'button','aria-expanded':'false','aria-controls':drawer.id,'aria-label':`${n.label.join(' ')}: ${n.values.y}`});
110
140
  g.append(el('title',{},`${n.label.join(' ')}: ${n.values.y}. Open source evidence.`));
@@ -126,21 +156,21 @@ function drawChart(svg,s){
126
156
  if(chart.kind==='line'){const sorted=[...points].sort((a,b)=>a.n.values.x-b.n.values.x);svg.append(el('path',{d:sorted.map((p,i)=>`${i?'L':'M'}${p.x} ${p.y}`).join(' '),class:'wire green'}))}
127
157
  points.forEach(p=>{const width=Math.min(150,(right-left)/points.length*.6),bar=isBar?el('rect',{x:p.x-width/2,y:Math.min(p.y,y(0)),width,height:Math.max(2,Math.abs(y(0)-p.y)),rx:5,class:'disc green'}):undefined;chartNode(svg,p.n,p.x,p.y,bar)})
128
158
  }
129
- function draw(index){close(false);active=index;const s=scenes[index];[...tabs.children].forEach((b,i)=>{b.setAttribute('aria-selected',i===index);b.tabIndex=i===index?0:-1});canvas.replaceChildren();const svg=el('svg',{viewBox:'0 0 1440 1020',role:'img','aria-label':`${s.title}. ${s.subtitle}`});const desc=el('desc',{},s.alt);svg.append(desc);const defs=el('defs');defs.innerHTML=icons.replace(/id="([^"]+)"/g,(_,id)=>`id="${prefix}${id}"`);svg.append(defs,el('rect',{width:1440,height:1020,fill:'#f6f5ef'}));text(svg,40,54,s.title,'title');text(svg,42,86,s.subtitle,'subtitle');text(svg,1398,49,`PR #${s.pr.number} \u00b7 ${s.pr.merged?'MERGED':s.pr.state==='closed'?'CLOSED':s.pr.draft?'DRAFT':'OPEN'}`,'kicker','end');
130
- text(svg,42,128,s.before,'micro');text(svg,42,155,s.after,'micro');text(svg,1398,128,`${s.pr.changed_files} CHANGED FILES`,'kicker','end');
131
- if(s.chart){drawChart(svg,s)}else{
159
+ function draw(index){close(false);active=index;const s=scenes[index];[...tabs.children].forEach((b,i)=>{b.setAttribute('aria-selected',i===index);b.tabIndex=i===index?0:-1});canvas.replaceChildren();const svg=el('svg',{viewBox:'0 0 1440 1020',role:'img','aria-label':`${s.title}. ${s.subtitle}`});if(s.board)svg.classList.add('board');const desc=el('desc',{},s.alt);svg.append(desc);const defs=el('defs');defs.innerHTML=icons.replace(/id="([^"]+)"/g,(_,id)=>`id="${prefix}${id}"`);const backdrop=el('rect',{width:1440,height:1020,fill:'#f6f5ef'});svg.append(defs,backdrop);text(svg,40,54,s.title,'title');text(svg,42,86,s.subtitle,'subtitle');text(svg,1398,49,`${refName(s).toUpperCase()} #${s.pr.number} \u00b7 ${s.pr.merged?'MERGED':s.pr.state==='closed'?'CLOSED':s.pr.draft?'DRAFT':'OPEN'}`,'kicker','end');
160
+ text(svg,42,128,s.before,'micro');text(svg,42,155,s.after,'micro');text(svg,1398,128,`${s.pr.changed_files} ${isIssue(s)?'TRACKED ISSUES':'CHANGED FILES'}`,'kicker','end');
161
+ let footTop=835;if(s.chart){drawChart(svg,s)}else if(s.board){footTop=Math.max(835,drawBoard(svg,s)+10)}else{
132
162
  (s.groups||[]).forEach(g=>{svg.append(el('rect',{x:g.x,y:g.y,width:g.w,height:g.h,rx:20,class:'group'}));text(svg,g.x+g.w/2,g.y+27,g.label,'panel-label','middle')});
133
163
  const chartArrows=[];s.edges.forEach(e=>{const cls=e.kind||'',target=s.nodes.find(n=>n.id===e.to),path=el('path',{d:e.path,class:`wire ${cls}`});if(target.chart)chartArrows.push({path,target,cls});else path.setAttribute('marker-end',`url(#${prefix}${cls==='green'?'arrowGreen':cls==='orange'?'arrowOrange':'arrow'})`);svg.append(path);if(e.label)text(svg,e.x,e.y,e.label,`edge-label ${cls}`,'middle')});
134
- s.nodes.forEach(n=>{const g=el('g',{class:'node','data-node':n.id,tabindex:0,role:'button','aria-expanded':'false','aria-controls':drawer.id,'aria-label':n.label.join(' ')});g.append(el('title',{},'Open code evidence'));if(n.chart){stepCard(g,n)}else if(n.kind==='decision'){g.append(el('path',{d:`M${n.x} ${n.y-54} L${n.x+62} ${n.y} L${n.x} ${n.y+54} L${n.x-62} ${n.y} Z`,class:'diamond'}));lines(g,n.x,n.y-3,n.label,'node-label','middle',21)}else{g.append(el('circle',{cx:n.x,cy:n.y,r:n.r||43,class:`disc ${n.kind||''}`}));g.append(el('use',{href:`#${n.icon}`,x:n.x-22,y:n.y-22,width:44,height:44,class:'icon'}));lines(g,n.x,n.y+70,n.label,'node-label','middle')}if(n.sub&&!n.chart)lines(g,n.x,n.y+(n.label.length>1?121:99),n.sub,'micro','middle',19);const show=()=>showNode(n.id);g.onclick=show;g.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();show()}};svg.append(g)});
164
+ s.nodes.forEach(n=>drawNode(svg,n));
135
165
  chartArrows.forEach(({path,target,cls})=>cardArrow(svg,path,target,cls));
136
166
  }
137
- svg.append(el('line',{x1:40,y1:835,x2:1400,y2:835,stroke:'#d5dcd2','stroke-width':2}));s.notes.forEach((n,i)=>{const x=42+i*455;text(svg,x,876,n.label,'foot-label');lines(svg,x,907,n.text,'foot-text','start',23)});
138
- const when=new Date(s.pr.observed_at).toLocaleString('en-US',{timeZone:'America/New_York',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});text(svg,42,991,`Code reviewed \u00b7 ${when} ET \u00b7 ${s.pr.head.slice(0,10)}`,'stamp');text(svg,1398,991,'Click any symbol for code and source links.','micro','end');canvas.append(svg);const a=document.createElement('a');a.className='sr-only';a.href=s.pr.url;a.textContent=`Open PR ${s.pr.number}`;canvas.append(a);setZoom(1,true)}
167
+ const shift=footTop-835,foot=el('g',{transform:`translate(0 ${shift})`}),height=1020+shift;svg.setAttribute('viewBox',`0 0 1440 ${height}`);backdrop.setAttribute('height',height);svg.append(foot);foot.append(el('line',{x1:40,y1:835,x2:1400,y2:835,stroke:'#d5dcd2','stroke-width':2}));s.notes.forEach((n,i)=>{const x=42+i*455;text(foot,x,876,n.label,'foot-label');lines(foot,x,907,n.text,'foot-text','start',23)});
168
+ const when=new Date(s.pr.observed_at).toLocaleString('en-US',{timeZone:'America/New_York',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});text(foot,42,991,`${isIssue(s)?'Tracker read':'Code reviewed'} \u00b7 ${when} ET \u00b7 ${s.pr.head.slice(0,10)}`,'stamp');text(foot,1398,991,s.board?'Click a card to see what it builds.':'Click any symbol for code and source links.','micro','end');canvas.append(svg);const a=document.createElement('a');a.className='sr-only';a.href=s.pr.url;a.textContent=`Open ${refName(s)} ${s.pr.number}`;canvas.append(a);setZoom(1,true)}
139
169
 
140
- scenes.forEach((s,i)=>{const b=dom('button');b.dataset.reviewCard=String(i);b.append(dom('span',`PR #${s.pr.number}`,'tab-number'),dom('span',s.tab,'tab-name'));b.id=prefix+`tab-${i}`;b.role='tab';b.setAttribute('aria-controls',canvas.id);b.onclick=()=>draw(i);b.onkeydown=e=>{if(e.key==='ArrowRight'||e.key==='ArrowLeft'){e.preventDefault();const next=(i+(e.key==='ArrowRight'?1:scenes.length-1))%scenes.length;draw(next);tabs.children[next].focus()}};tabs.append(b)});
170
+ scenes.forEach((s,i)=>{const b=dom('button');b.dataset.reviewCard=String(i);b.append(dom('span',`${refName(s)} #${s.pr.number}`,'tab-number'),dom('span',s.tab,'tab-name'));b.id=prefix+`tab-${i}`;b.role='tab';b.setAttribute('aria-controls',canvas.id);b.onclick=()=>draw(i);b.onkeydown=e=>{if(e.key==='ArrowRight'||e.key==='ArrowLeft'){e.preventDefault();const next=(i+(e.key==='ArrowRight'?1:scenes.length-1))%scenes.length;draw(next);tabs.children[next].focus()}};tabs.append(b)});
141
171
  function setZoom(value,fit=false){zoom=Math.max(.7,Math.min(2,value));canvas.style.width=fit?'':`${1440*zoom}px`;canvas.style.maxWidth=fit?'1540px':'none'}
142
172
  $('[data-plus]').onclick=()=>setZoom(zoom+.2);$('[data-minus]').onclick=()=>setZoom(zoom-.2);$('[data-fit]').onclick=()=>setZoom(1,true);
143
- $('[data-compare]').onclick=e=>showPanel('Summary vs code',{title:'Compare the claim with the implementation',steps:[{label:['PR','description'],icon:'list'},{label:['Trace the','code'],icon:'code'},{label:['Compare','behavior'],icon:'eye'}]},scenes[active].comparison,[],scenes[active].summary,scenes[active].nodes,e.currentTarget);
173
+ $('[data-compare]').onclick=e=>showPanel('Summary vs code',{title:'Compare the claim with the implementation',steps:[{label:[refName(scenes[active]),'description'],icon:'list'},{label:['Trace the','code'],icon:'code'},{label:['Compare','behavior'],icon:'eye'}]},scenes[active].comparison,[],scenes[active].summary,scenes[active].nodes,e.currentTarget);
144
174
  $('[data-review]').onclick=e=>showPanel('Review notes',{title:'Scope and evidence',steps:[{label:['Captured','source'],icon:'ledger'},{label:['Reviewed','path'],icon:'branches'},{label:['Scope and','limitations'],icon:'shield'}]},scenes[active].review,[],undefined,scenes[active].nodes,e.currentTarget);
145
175
  draw(0);
146
176
  })();
@@ -0,0 +1,31 @@
1
+ {
2
+ "number": 1,
3
+ "title": "Synthetic tracker epic",
4
+ "state": "open",
5
+ "state_reason": null,
6
+ "html_url": "https://github.com/example/project/issues/1",
7
+ "created_at": "2026-10-18T12:00:00Z",
8
+ "updated_at": "2026-10-19T12:00:00Z",
9
+ "closed_at": null,
10
+ "labels": [],
11
+ "linked_pulls": [
12
+ {
13
+ "number": 3,
14
+ "title": "Synthetic fix",
15
+ "state": "closed",
16
+ "draft": false,
17
+ "merged_at": "2026-10-19T12:00:00Z",
18
+ "closed_at": "2026-10-19T12:00:00Z",
19
+ "updated_at": "2026-10-19T12:00:00Z",
20
+ "html_url": "https://github.com/example/project/pull/3"
21
+ }
22
+ ],
23
+ "progress": "merged_pull",
24
+ "counts": {
25
+ "open": 2,
26
+ "closed": 0,
27
+ "merged_pull": 1,
28
+ "open_pull": 0,
29
+ "no_pull": 1
30
+ }
31
+ }
@@ -0,0 +1,5 @@
1
+ ## Synthetic tracker epic
2
+
3
+ Status: 2 tasks open, 1 with a pull request.
4
+
5
+ - Task one ships in https://github.com/example/project/pull/3
@@ -0,0 +1,31 @@
1
+ {
2
+ "number": 2,
3
+ "title": "Synthetic task with a fix",
4
+ "state": "open",
5
+ "state_reason": null,
6
+ "html_url": "https://github.com/example/project/issues/2",
7
+ "created_at": "2026-10-18T12:00:00Z",
8
+ "updated_at": "2026-10-19T12:00:00Z",
9
+ "closed_at": null,
10
+ "labels": [],
11
+ "linked_pulls": [
12
+ {
13
+ "number": 3,
14
+ "title": "Synthetic fix",
15
+ "state": "closed",
16
+ "draft": false,
17
+ "merged_at": "2026-10-19T12:00:00Z",
18
+ "closed_at": "2026-10-19T12:00:00Z",
19
+ "updated_at": "2026-10-19T12:00:00Z",
20
+ "html_url": "https://github.com/example/project/pull/3"
21
+ }
22
+ ],
23
+ "progress": "merged_pull",
24
+ "counts": {
25
+ "open": 0,
26
+ "closed": 0,
27
+ "merged_pull": 0,
28
+ "open_pull": 0,
29
+ "no_pull": 0
30
+ }
31
+ }
@@ -0,0 +1 @@
1
+ Synthetic task with a linked fix: https://github.com/example/project/pull/3
@@ -0,0 +1,20 @@
1
+ {
2
+ "number": 4,
3
+ "title": "Synthetic task without a fix",
4
+ "state": "open",
5
+ "state_reason": null,
6
+ "html_url": "https://github.com/example/project/issues/4",
7
+ "created_at": "2026-10-18T12:00:00Z",
8
+ "updated_at": "2026-10-19T12:00:00Z",
9
+ "closed_at": null,
10
+ "labels": [],
11
+ "linked_pulls": [],
12
+ "progress": "no_pull",
13
+ "counts": {
14
+ "open": 0,
15
+ "closed": 0,
16
+ "merged_pull": 0,
17
+ "open_pull": 0,
18
+ "no_pull": 0
19
+ }
20
+ }
@@ -0,0 +1,11 @@
1
+ Synthetic task with no linked fix.
2
+
3
+ ## Today
4
+ The synthetic task has no fix yet.
5
+
6
+ ## Fix
7
+ Build the synthetic fix
8
+ and link it.
9
+
10
+ ## Proof
11
+ A synthetic test shows the fix.
@@ -0,0 +1,10 @@
1
+ {
2
+ "number": 3,
3
+ "title": "Synthetic fix",
4
+ "state": "closed",
5
+ "draft": false,
6
+ "merged_at": "2026-10-19T12:00:00Z",
7
+ "closed_at": "2026-10-19T12:00:00Z",
8
+ "updated_at": "2026-10-19T12:00:00Z",
9
+ "html_url": "https://github.com/example/project/pull/3"
10
+ }
@@ -0,0 +1,86 @@
1
+ {
2
+ "number": 1,
3
+ "title": "Synthetic tracker epic",
4
+ "state": "open",
5
+ "state_reason": null,
6
+ "html_url": "https://github.com/example/project/issues/1",
7
+ "created_at": "2026-10-18T12:00:00Z",
8
+ "updated_at": "2026-10-19T12:00:00Z",
9
+ "closed_at": null,
10
+ "labels": [],
11
+ "linked_pulls": [
12
+ {
13
+ "number": 3,
14
+ "title": "Synthetic fix",
15
+ "state": "closed",
16
+ "draft": false,
17
+ "merged_at": "2026-10-19T12:00:00Z",
18
+ "closed_at": "2026-10-19T12:00:00Z",
19
+ "updated_at": "2026-10-19T12:00:00Z",
20
+ "html_url": "https://github.com/example/project/pull/3"
21
+ }
22
+ ],
23
+ "progress": "merged_pull",
24
+ "children": [
25
+ {
26
+ "number": 2,
27
+ "title": "Synthetic task with a fix",
28
+ "state": "open",
29
+ "state_reason": null,
30
+ "html_url": "https://github.com/example/project/issues/2",
31
+ "created_at": "2026-10-18T12:00:00Z",
32
+ "updated_at": "2026-10-19T12:00:00Z",
33
+ "closed_at": null,
34
+ "labels": [],
35
+ "linked_pulls": [
36
+ {
37
+ "number": 3,
38
+ "title": "Synthetic fix",
39
+ "state": "closed",
40
+ "draft": false,
41
+ "merged_at": "2026-10-19T12:00:00Z",
42
+ "closed_at": "2026-10-19T12:00:00Z",
43
+ "updated_at": "2026-10-19T12:00:00Z",
44
+ "html_url": "https://github.com/example/project/pull/3"
45
+ }
46
+ ],
47
+ "progress": "merged_pull",
48
+ "children": [],
49
+ "counts": {
50
+ "open": 0,
51
+ "closed": 0,
52
+ "merged_pull": 0,
53
+ "open_pull": 0,
54
+ "no_pull": 0
55
+ }
56
+ },
57
+ {
58
+ "number": 4,
59
+ "title": "Synthetic task without a fix",
60
+ "state": "open",
61
+ "state_reason": null,
62
+ "html_url": "https://github.com/example/project/issues/4",
63
+ "created_at": "2026-10-18T12:00:00Z",
64
+ "updated_at": "2026-10-19T12:00:00Z",
65
+ "closed_at": null,
66
+ "labels": [],
67
+ "linked_pulls": [],
68
+ "progress": "no_pull",
69
+ "children": [],
70
+ "counts": {
71
+ "open": 0,
72
+ "closed": 0,
73
+ "merged_pull": 0,
74
+ "open_pull": 0,
75
+ "no_pull": 0
76
+ }
77
+ }
78
+ ],
79
+ "counts": {
80
+ "open": 2,
81
+ "closed": 0,
82
+ "merged_pull": 1,
83
+ "open_pull": 0,
84
+ "no_pull": 1
85
+ }
86
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "schema_version": 1,
3
+ "kind": "issue",
4
+ "observed_at": "2026-10-19T12:00:00Z",
5
+ "repo": "example/project",
6
+ "number": 1,
7
+ "url": "https://github.com/example/project/issues/1",
8
+ "state": "open",
9
+ "head": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
10
+ "base": null,
11
+ "diff_base": null,
12
+ "metadata": {
13
+ "title": "Synthetic tracker epic",
14
+ "body": "Synthetic tracker for rendering verification only.",
15
+ "draft": false,
16
+ "merged": false,
17
+ "tracked_issues": 3
18
+ },
19
+ "files": []
20
+ }
@@ -0,0 +1,90 @@
1
+ {
2
+ "snapshot": "tracker/snapshot.json",
3
+ "tab": "Tracker",
4
+ "title": "Follow a synthetic tracker epic",
5
+ "subtitle": "Synthetic fixture: an epic, two tasks, and one linked fix.",
6
+ "before": "BEFORE: Task progress lives in issue bodies.",
7
+ "after": "AFTER: Each task links to its captured issue and fix.",
8
+ "alt": "A synthetic epic has two tasks. One task links a merged fix; the other has none yet.",
9
+ "nodes": [
10
+ {
11
+ "id": "epic",
12
+ "x": 300,
13
+ "y": 430,
14
+ "label": ["Synthetic", "epic"],
15
+ "sub": ["Synthetic", "branches"],
16
+ "icon": "list",
17
+ "kind": "blue",
18
+ "detail": ["Synthetic tracker data, not a project status."],
19
+ "sources": [{"path": "issues/1.md", "line": 3}],
20
+ "drilldown": {
21
+ "title": "Read the epic",
22
+ "steps": [
23
+ {"label": ["Epic", "body"], "icon": "ledger"},
24
+ {"label": ["Two", "tasks"], "icon": "list"}
25
+ ]
26
+ }
27
+ },
28
+ {
29
+ "id": "fixed",
30
+ "x": 700,
31
+ "y": 330,
32
+ "label": ["Task with", "a fix"],
33
+ "sub": ["Merged fix,", "task open"],
34
+ "icon": "merge",
35
+ "kind": "green",
36
+ "detail": ["The task body links a pull request that merged."],
37
+ "sources": [{"path": "issues/2.json", "line": 1, "end": 4}, {"path": "pulls/3.json", "line": 1, "end": 6}],
38
+ "drilldown": {
39
+ "title": "Follow the linked fix",
40
+ "steps": [
41
+ {"label": ["Task links", "a fix"], "icon": "github"},
42
+ {"label": ["Fix", "merged"], "icon": "merge", "kind": "green"}
43
+ ]
44
+ }
45
+ },
46
+ {
47
+ "id": "left",
48
+ "x": 700,
49
+ "y": 560,
50
+ "label": ["Task left", "to build"],
51
+ "sub": ["No fix", "yet"],
52
+ "icon": "flag",
53
+ "kind": "lime",
54
+ "detail": ["The task body links no pull request yet."],
55
+ "sources": [{"path": "tracker.json", "line": 79, "end": 85}],
56
+ "drilldown": {
57
+ "title": "Count what is left",
58
+ "steps": [
59
+ {"label": ["No linked", "fix"], "icon": "pause"},
60
+ {"label": ["Counted as", "left"], "icon": "flag"}
61
+ ]
62
+ }
63
+ }
64
+ ],
65
+ "edges": [
66
+ {"from": "epic", "to": "fixed", "path": "M340 412 L660 345", "kind": "green"},
67
+ {"from": "epic", "to": "left", "path": "M340 448 L660 545", "kind": "orange"}
68
+ ],
69
+ "notes": [
70
+ {"label": "SOURCE", "text": ["Captured issues", "Links to each record"]},
71
+ {"label": "INTERACTION", "text": ["Select a task", "Open its issue"]},
72
+ {"label": "SCOPE", "text": ["Synthetic data only", "No project claim"]}
73
+ ],
74
+ "board": {
75
+ "strip": ["^Synthetic "],
76
+ "wording": {"synthetic": "sample"},
77
+ "steps": [
78
+ {"heading": "Today", "label": "Today", "kind": "orange"},
79
+ {"heading": "Fix", "label": "Build", "kind": "lime", "inline": true},
80
+ {"heading": "Proof", "label": "Proof", "kind": "green"}
81
+ ],
82
+ "lanes": [
83
+ {"node": "epic", "groups": [{"title": "Follow-up branches", "cards": [{"title": "Task one follow-up", "source": {"path": "issues/1.md", "line": 5}}]}]},
84
+ {"node": "left", "progress": "no_pull"},
85
+ {"node": "fixed", "progress": "merged_pull"}
86
+ ]
87
+ },
88
+ "comparison": ["The fixture epic body and its captured tasks agree on one fix and one open task."],
89
+ "review": ["This fixture checks tracker rendering, not a project status."]
90
+ }
@@ -1,4 +1,4 @@
1
- """Capture PR metadata, patches, and immutable changed-file source through gh."""
1
+ """Capture PR source or tracker issue records as immutable evidence through gh."""
2
2
  import argparse
3
3
  import base64
4
4
  import concurrent.futures
@@ -67,7 +67,10 @@ def _capture_file(item: dict, metadata: dict, diff_base: str, destination: Path)
67
67
 
68
68
  def _save_snapshot(snapshot: dict, destination: Path) -> None:
69
69
  (destination / c.SNAPSHOT_NAME).write_text(json.dumps(snapshot, indent=c.JSON_INDENT), encoding="utf-8")
70
- all_sections = [c.DIFF_SECTION.format(filename=each_file["filename"], patch=each_file.get("patch", "Patch unavailable. Compare saved base and head source.")) for each_file in snapshot["files"]]
70
+ if snapshot.get("kind") == c.ISSUE_KIND:
71
+ all_sections = []
72
+ else:
73
+ all_sections = [c.DIFF_SECTION.format(filename=each_file["filename"], patch=each_file.get("patch", "Patch unavailable. Compare saved base and head source.")) for each_file in snapshot["files"]]
71
74
  (destination / c.DIFF_NAME).write_text(c.PARAGRAPH_BREAK.join(all_sections), encoding="utf-8")
72
75
  print(json.dumps({"pr": snapshot["number"], "snapshot": str(destination / c.SNAPSHOT_NAME), "state": snapshot["state"], "files": len(snapshot["files"])}), flush=True)
73
76
 
@@ -102,10 +105,122 @@ def collect(url: str, root: Path) -> None:
102
105
  _save_snapshot(snapshot, destination)
103
106
 
104
107
 
108
+ def _sub_issues(repo: str, number: int) -> list[dict]:
109
+ all_children = []
110
+ page = 1
111
+ while True:
112
+ batch = _api(c.SUB_ISSUES_ENDPOINT.format(repo=repo, number=number, size=c.PAGE_SIZE, page=page))
113
+ all_children.extend(batch)
114
+ if len(batch) < c.PAGE_SIZE:
115
+ break
116
+ page += 1
117
+ return all_children
118
+
119
+
120
+ def _write_capture(destination: Path, relative_path: str, raw_bytes: bytes) -> dict:
121
+ capture_path = destination / c.HEAD_SIDE / relative_path
122
+ capture_path.parent.mkdir(parents=True, exist_ok=True)
123
+ capture_path.write_bytes(raw_bytes)
124
+ return {"filename": relative_path, "captures": {c.HEAD_SIDE: {"path": f"{c.HEAD_SIDE}/{relative_path}", "sha256": hashlib.sha256(raw_bytes).hexdigest(), "bytes": len(raw_bytes)}}}
125
+
126
+
127
+ def _json_bytes(record: dict) -> bytes:
128
+ return (json.dumps(record, indent=c.JSON_INDENT) + c.NEWLINE).encode()
129
+
130
+
131
+ def _pull_progress(record: dict) -> str:
132
+ all_pulls = record["linked_pulls"]
133
+ if any(each_pull["merged_at"] for each_pull in all_pulls):
134
+ return "merged_pull"
135
+ if any(each_pull["state"] == "open" for each_pull in all_pulls):
136
+ return "open_pull"
137
+ return "no_pull"
138
+
139
+
140
+ def _child_counts(all_children: list[dict]) -> dict[str, int]:
141
+ all_progress = [each_child["progress"] for each_child in all_children]
142
+ return {
143
+ "open": sum(each_child["state"] == "open" for each_child in all_children),
144
+ "closed": sum(each_child["state"] == "closed" for each_child in all_children),
145
+ **{each_progress: all_progress.count(each_progress) for each_progress in c.ALL_PULL_PROGRESS},
146
+ }
147
+
148
+
149
+ def _capture_issue_tree(repo: str, issue: dict, depth: int, all_pulls_by_number: dict[int, dict], destination: Path, all_files: list[dict]) -> dict:
150
+ record = {each_field: issue.get(each_field) for each_field in c.ALL_ISSUE_FIELDS}
151
+ record["labels"] = [each_label["name"] for each_label in issue.get("labels", [])]
152
+ body = issue.get("body") or ""
153
+ linked_numbers = sorted({int(each_number) for each_number in re.findall(c.LINKED_PULL_PATTERN.format(repo=re.escape(repo)), body)})
154
+ for each_number in linked_numbers:
155
+ if each_number not in all_pulls_by_number:
156
+ pull = _api(c.PR_ENDPOINT.format(repo=repo, number=each_number))
157
+ all_pulls_by_number[each_number] = {each_field: pull.get(each_field) for each_field in c.ALL_PULL_FIELDS}
158
+ record["linked_pulls"] = [all_pulls_by_number[each_number] for each_number in linked_numbers]
159
+ record["progress"] = _pull_progress(record)
160
+ children = _sub_issues(repo, issue["number"]) if depth < c.SUB_ISSUE_DEPTH else []
161
+ record["children"] = [_capture_issue_tree(repo, each_child, depth + 1, all_pulls_by_number, destination, all_files) for each_child in children]
162
+ record["counts"] = _child_counts(record["children"])
163
+ all_files.append(_write_capture(destination, c.ISSUE_BODY_PATH.format(number=issue["number"]), (body + c.NEWLINE).encode()))
164
+ issue_record = {each_field: each_entry for each_field, each_entry in record.items() if each_field != "children"}
165
+ all_files.append(_write_capture(destination, c.ISSUE_RECORD_PATH.format(number=issue["number"]), _json_bytes(issue_record)))
166
+ return record
167
+
168
+
169
+ def _capture_tracker(repo: str, issue: dict, destination: Path) -> tuple[bytes, list[dict]]:
170
+ all_files: list[dict] = []
171
+ all_pulls_by_number: dict[int, dict] = {}
172
+ tracker_bytes = _json_bytes(_capture_issue_tree(repo, issue, 0, all_pulls_by_number, destination, all_files))
173
+ for each_number, each_pull in all_pulls_by_number.items():
174
+ all_files.append(_write_capture(destination, c.PULL_RECORD_PATH.format(number=each_number), _json_bytes(each_pull)))
175
+ all_files.append(_write_capture(destination, c.TRACKER_PATH, tracker_bytes))
176
+ return tracker_bytes, all_files
177
+
178
+
179
+ def _tracker_snapshot(url: str, repo: str, refreshed: dict, observed_at: str, tracker_bytes: bytes, all_files: list[dict]) -> dict:
180
+ tracked_count = sum(1 for each_file in all_files if re.fullmatch(c.CAPTURED_ISSUE_RECORD_PATTERN, each_file["filename"]))
181
+ metadata = {"title": refreshed["title"], "body": refreshed.get("body") or "", "draft": False, "merged": False, "tracked_issues": tracked_count}
182
+ return {"schema_version": 1, "kind": c.ISSUE_KIND, "observed_at": observed_at, "repo": repo, "number": refreshed["number"], "url": url, "state": refreshed["state"], "head": hashlib.sha256(tracker_bytes).hexdigest(), "base": None, "diff_base": None, "metadata": metadata, "files": all_files}
183
+
184
+
185
+ def collect_issue(url: str, root: Path) -> None:
186
+ """Save an issue, its sub-issues, and the pull requests their bodies link.
187
+
188
+ A tracker epic becomes the same kind of snapshot a pull request does::
189
+
190
+ issues/12.md the issue body, line by line
191
+ issues/12.json state, labels, linked pull requests, child counts
192
+ pulls/34.json each linked pull request's state
193
+ tracker.json the whole tree, for charts and citations
194
+
195
+ Args:
196
+ url: GitHub issue URL.
197
+ root: Directory receiving a folder for the captured issue.
198
+
199
+ Raises:
200
+ CaptureRunFatal: The issue changed during capture.
201
+ """
202
+ matched_url = re.fullmatch(c.ISSUE_URL_PATTERN, url)
203
+ if not matched_url:
204
+ raise CaptureRunFatal("Use a GitHub issue URL, https://github.com/owner/repo/issues/number")
205
+ repo, number = matched_url.groups()
206
+ endpoint = c.ISSUE_ENDPOINT.format(repo=repo, number=number)
207
+ issue = _api(endpoint)
208
+ observed_at = datetime.datetime.now(datetime.UTC).isoformat()
209
+ destination = root / f"issue-{number}" / observed_at.replace(":", "-")
210
+ tracker_bytes, all_files = _capture_tracker(repo, issue, destination)
211
+ refreshed = _api(endpoint)
212
+ if refreshed["updated_at"] != issue["updated_at"]:
213
+ raise CaptureRunFatal("Issue changed during capture. Rerun collection.")
214
+ _save_snapshot(_tracker_snapshot(url, repo, refreshed, observed_at, tracker_bytes, all_files), destination)
215
+
216
+
105
217
  if __name__ == "__main__":
106
218
  parser = argparse.ArgumentParser(description=__doc__)
107
219
  parser.add_argument("urls", nargs="+")
108
220
  parser.add_argument("--out", type=Path, default=Path(c.DEFAULT_EVIDENCE_DIRECTORY))
109
221
  arguments = parser.parse_args()
110
222
  for each_url in arguments.urls:
111
- collect(each_url, arguments.out)
223
+ if re.fullmatch(c.ISSUE_URL_PATTERN, each_url):
224
+ collect_issue(each_url, arguments.out)
225
+ else:
226
+ collect(each_url, arguments.out)
@@ -1,6 +1,7 @@
1
1
  """Validate evidence-backed diagram scenes and render a portable HTML visual."""
2
2
  import argparse
3
3
  import copy
4
+ import itertools
4
5
  import json
5
6
  import re
6
7
  from pathlib import Path
@@ -36,7 +37,7 @@ def _validate_graph(scene: dict) -> None:
36
37
  if not re.fullmatch(c.SVG_PATH_PATTERN, each_edge["path"]):
37
38
  raise RenderRunFatal("Invalid SVG path.")
38
39
  for each_node in scene["nodes"]:
39
- if not scene.get("chart") and (not c.MINIMUM_NODE_X <= each_node["x"] <= c.MAXIMUM_NODE_X or not c.MINIMUM_NODE_Y <= each_node["y"] <= c.MAXIMUM_NODE_Y):
40
+ if not scene.get("chart") and not scene.get("board") and (not c.MINIMUM_NODE_X <= each_node["x"] <= c.MAXIMUM_NODE_X or not c.MINIMUM_NODE_Y <= each_node["y"] <= c.MAXIMUM_NODE_Y):
40
41
  raise RenderRunFatal("Node lies outside diagram bounds.")
41
42
  if not each_node.get("sources"):
42
43
  raise RenderRunFatal("Every node needs source evidence.")
@@ -56,10 +57,117 @@ def _source_evidence(source: dict, snapshot: dict, snapshot_path: Path) -> None:
56
57
  if not 1 <= first <= last <= len(all_lines):
57
58
  raise RenderRunFatal("Invalid source line range.")
58
59
  source["excerpt"] = c.NEWLINE.join(c.SOURCE_LINE.format(number=each_index + first, line=each_line) for each_index, each_line in enumerate(all_lines[first - 1:last]))
60
+ if snapshot.get("kind") == c.ISSUE_KIND:
61
+ source["url"] = _captured_record_link(source["path"], snapshot)
62
+ return
59
63
  revision = snapshot.get("diff_base", snapshot[c.BASE_SIDE]) if side == c.BASE_SIDE else snapshot[c.HEAD_SIDE]
60
64
  source["url"] = c.SOURCE_LINK.format(repo=snapshot["repo"], revision=revision, path=quote(source["path"], safe="/"), first=first, last=last)
61
65
 
62
66
 
67
+ def _captured_record_link(relative_path: str, snapshot: dict) -> str:
68
+ if matched_issue := re.fullmatch(c.CAPTURED_ISSUE_PATTERN, relative_path):
69
+ return c.ISSUE_LINK.format(repo=snapshot["repo"], number=matched_issue.group(1))
70
+ if matched_pull := re.fullmatch(c.CAPTURED_PULL_PATTERN, relative_path):
71
+ return c.PULL_LINK.format(repo=snapshot["repo"], number=matched_pull.group(1))
72
+ if relative_path == c.TRACKER_PATH:
73
+ return snapshot["url"]
74
+ raise RenderRunFatal("Tracker evidence must cite a captured issue, pull request, or tracker file.")
75
+
76
+
77
+ def _plain_title(record: dict, settings: dict) -> str:
78
+ title = settings.get("titles", {}).get(str(record["number"]), record["title"])
79
+ for each_pattern in settings.get("strip", []):
80
+ title = re.sub(each_pattern, "", title, count=1)
81
+ return title
82
+
83
+
84
+ def _issue_steps(number: int, snapshot_path: Path, settings: dict) -> list[dict]:
85
+ all_lines = (snapshot_path.parent / c.HEAD_SIDE / c.ISSUE_BODY_PATH.format(number=number)).read_text(encoding="utf-8").splitlines()
86
+ all_steps = []
87
+ for each_step in settings.get("steps", []):
88
+ heading = c.SECTION_HEADING.format(heading=each_step["heading"])
89
+ if heading not in all_lines:
90
+ continue
91
+ all_paragraph_lines = list(itertools.takewhile(str.strip, all_lines[all_lines.index(heading) + 1:]))
92
+ text = c.SPACE_SEPARATOR.join(all_paragraph_lines)
93
+ for each_word, each_plain_word in settings.get("wording", {}).items():
94
+ text = re.sub(c.WHOLE_PHRASE_PATTERN.format(phrase=re.escape(each_word)), each_plain_word, text)
95
+ all_steps.append({**{each_field: each_setting for each_field, each_setting in each_step.items() if each_field != "heading"}, "text": text})
96
+ return all_steps
97
+
98
+
99
+ def _tracker_card(issue: dict, repo: str, settings: dict, snapshot_path: Path) -> dict:
100
+ progress = issue["progress"]
101
+ all_pulls = [each_pull for each_pull in issue["linked_pulls"] if (each_pull["merged_at"] if progress == "merged_pull" else each_pull["state"] == "open")]
102
+ pulls = c.PULL_REFERENCE_SEPARATOR.join(c.PULL_REFERENCE.format(number=each_pull["number"]) for each_pull in all_pulls)
103
+ return {
104
+ "number": issue["number"],
105
+ "title": _plain_title(issue, settings),
106
+ "url": c.ISSUE_LINK.format(repo=repo, number=issue["number"]),
107
+ "note": c.ALL_CARD_NOTES[progress].format(pulls=pulls),
108
+ "steps": _issue_steps(issue["number"], snapshot_path, settings),
109
+ }
110
+
111
+
112
+ def _tracker_groups(record: dict, progress: str, repo: str, settings: dict, snapshot_path: Path) -> list[dict]:
113
+ all_tasks = [each_child for each_child in record["children"] if not each_child["children"] and each_child["state"] == "open" and each_child["progress"] == progress and each_child["number"] not in settings.get("exclude", [])]
114
+ all_groups = [{"title": _plain_title(record, settings), "url": c.ISSUE_LINK.format(repo=repo, number=record["number"]), "cards": [_tracker_card(each_task, repo, settings, snapshot_path) for each_task in all_tasks]}] if all_tasks else []
115
+ for each_child in record["children"]:
116
+ all_groups.extend(_tracker_groups(each_child, progress, repo, settings, snapshot_path))
117
+ return all_groups
118
+
119
+
120
+ def _cited_group(group: dict, snapshot: dict, snapshot_path: Path) -> dict:
121
+ for each_card in group["cards"]:
122
+ _source_evidence(each_card["source"], snapshot, snapshot_path)
123
+ each_card.setdefault("url", each_card["source"]["url"])
124
+ each_card.setdefault("note", "")
125
+ if not each_card["url"].startswith(c.REPOSITORY_LINK.format(repo=snapshot["repo"])):
126
+ raise RenderRunFatal("A board card must link inside the tracked repository.")
127
+ group.setdefault("url", snapshot["url"])
128
+ return group
129
+
130
+
131
+ def _board_lane(lane: dict, tracker: dict, settings: dict, snapshot: dict, snapshot_path: Path) -> dict:
132
+ if "progress" in lane:
133
+ all_groups = _tracker_groups(tracker, lane["progress"], snapshot["repo"], settings, snapshot_path)
134
+ else:
135
+ all_groups = [_cited_group(each_group, snapshot, snapshot_path) for each_group in lane.get("groups", [])]
136
+ return {"node": lane["node"], "groups": all_groups, "count": sum(len(each_group["cards"]) for each_group in all_groups)}
137
+
138
+
139
+ def _prepare_board(scene: dict, snapshot: dict, snapshot_path: Path) -> None:
140
+ """Place every tracker card in the state lane its scene names.
141
+
142
+ The flow runs top to bottom. Each lane is one state node with its cards
143
+ beside it, grouped under their parent epic::
144
+
145
+ (Not started) Convert . 2 [task 12] [task 13]
146
+ |
147
+ (Fixed) Icons . 1 [task 14 Merged in pull request 20]
148
+
149
+ Args:
150
+ scene: Scene with ``board`` settings: ``lanes`` in flow order, each
151
+ naming a scene ``node`` and either a tracker ``progress``
152
+ (``no_pull``, ``open_pull``, ``merged_pull``) or cited ``groups``
153
+ of cards; ``strip`` title prefix patterns, ``titles`` keyed by
154
+ issue number, ``exclude`` issue numbers that are not work,
155
+ ``steps`` naming the body sections each card diagram shows,
156
+ and ``wording`` swaps for that text.
157
+ snapshot: Captured tracker snapshot.
158
+ snapshot_path: Snapshot file the tracker tree sits beside.
159
+
160
+ Raises:
161
+ RenderRunFatal: A node lacks exactly one lane, or a card leaves the repository.
162
+ """
163
+ settings = scene["board"]
164
+ tracker = json.loads((snapshot_path.parent / c.HEAD_SIDE / c.TRACKER_PATH).read_text(encoding="utf-8"))
165
+ if sorted(each_lane["node"] for each_lane in settings["lanes"]) != sorted(each_node["id"] for each_node in scene["nodes"]):
166
+ raise RenderRunFatal("Every board node needs exactly one lane.")
167
+ all_lanes = [_board_lane(each_lane, tracker, settings, snapshot, snapshot_path) for each_lane in settings["lanes"]]
168
+ scene["board"] = {"lanes": all_lanes, "count": sum(each_lane["count"] for each_lane in all_lanes)}
169
+
170
+
63
171
  def _prepare_sources(scene: dict, snapshot: dict, snapshot_path: Path) -> None:
64
172
  for each_node in scene["nodes"]:
65
173
  for each_source in each_node["sources"]:
@@ -82,10 +190,13 @@ def prepare(path: Path) -> dict:
82
190
  snapshot_path = (path.parent / scene["snapshot"]).resolve()
83
191
  snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
84
192
  scene["pr"] = {each_key: snapshot[each_key] for each_key in ("number", "url", "repo", "state", "head", "base", "observed_at")}
85
- scene["pr"].update(draft=snapshot["metadata"]["draft"], merged=snapshot["metadata"].get("merged", False), changed_files=len(snapshot["files"]), title=snapshot["metadata"]["title"])
193
+ is_issue = snapshot.get("kind") == c.ISSUE_KIND
194
+ scene["pr"].update(kind=snapshot.get("kind", "pull"), draft=snapshot["metadata"]["draft"], merged=snapshot["metadata"].get("merged", False), changed_files=snapshot["metadata"]["tracked_issues"] if is_issue else len(snapshot["files"]), title=snapshot["metadata"]["title"])
86
195
  scene["summary"] = snapshot["metadata"]["body"] or "No PR description."
87
196
  _validate_graph(scene)
88
197
  _prepare_sources(scene, snapshot, snapshot_path)
198
+ if is_issue and scene.get("board"):
199
+ _prepare_board(scene, snapshot, snapshot_path)
89
200
  prepare_chart(scene, snapshot_path)
90
201
  prepare_embedded_charts(scene, snapshot_path)
91
202
  return scene
@@ -96,6 +207,10 @@ def _omit_private_payload(scene: dict) -> None:
96
207
  for each_node in scene["nodes"]:
97
208
  for each_source in each_node["sources"]:
98
209
  each_source.pop("excerpt", None)
210
+ for each_lane in scene.get("board", {}).get("lanes", []):
211
+ for each_group in each_lane["groups"]:
212
+ for each_card in each_group["cards"]:
213
+ each_card.get("source", {}).pop("excerpt", None)
99
214
 
100
215
 
101
216
  def render(all_scenes: list[dict], artifact_path: Path, is_links_only: bool = False) -> None:
@@ -56,3 +56,51 @@ def test_incomplete_changed_file_list_fails(monkeypatch: pytest.MonkeyPatch) ->
56
56
  monkeypatch.setattr(collect, "_api", Mock(return_value=[]))
57
57
  with pytest.raises(collect.CaptureRunFatal, match="incomplete"):
58
58
  collect._changed_files(REPOSITORY, "1", 1)
59
+
60
+
61
+ ISSUE_URL = "https://github.com/example/project/issues/10"
62
+ EPIC = {"number": 10, "title": "Synthetic epic", "state": "open", "updated_at": "2026-10-19T12:00:00Z", "labels": [], "body": "Tasks below."}
63
+ TASK_WITH_FIX = {"number": 11, "title": "Task with a fix", "state": "open", "updated_at": "2026-10-19T12:00:00Z", "labels": [], "body": "Fix: https://github.com/example/project/pull/20"}
64
+ TASK_IN_FLIGHT = {"number": 12, "title": "Task in flight", "state": "open", "updated_at": "2026-10-19T12:00:00Z", "labels": [], "body": "Fix: https://github.com/example/project/pull/21"}
65
+ TASK_LEFT = {"number": 13, "title": "Task left", "state": "closed", "updated_at": "2026-10-19T12:00:00Z", "labels": [], "body": None}
66
+ FINISHED_PULL = {"number": 20, "title": "Finished fix", "state": "closed", "draft": False, "merged_at": "2026-10-19T11:00:00Z"}
67
+ OPEN_PULL = {"number": 21, "title": "Open fix", "state": "open", "draft": True, "merged_at": None}
68
+
69
+
70
+ def _tracker_github(refreshed_epic: dict) -> Mock:
71
+ responses = {
72
+ "repos/example/project/issues/10": [EPIC, refreshed_epic],
73
+ "repos/example/project/issues/10/sub_issues?per_page=100&page=1": [[TASK_WITH_FIX, TASK_IN_FLIGHT, TASK_LEFT]],
74
+ "repos/example/project/issues/11/sub_issues?per_page=100&page=1": [[]],
75
+ "repos/example/project/issues/12/sub_issues?per_page=100&page=1": [[]],
76
+ "repos/example/project/issues/13/sub_issues?per_page=100&page=1": [[]],
77
+ "repos/example/project/pulls/20": [FINISHED_PULL],
78
+ "repos/example/project/pulls/21": [OPEN_PULL],
79
+ }
80
+ return Mock(side_effect=lambda endpoint, *options: responses[endpoint].pop(0))
81
+
82
+
83
+ def test_issue_capture_counts_each_task_by_its_linked_pull_request(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
84
+ """An epic snapshot records each task's fix state and hashes every captured record."""
85
+ monkeypatch.setattr(collect, "_api", _tracker_github(EPIC))
86
+ collect.collect_issue(ISSUE_URL, tmp_path)
87
+ destination = next((tmp_path / "issue-10").iterdir())
88
+ snapshot = json.loads((destination / "snapshot.json").read_text())
89
+ tracker = json.loads((destination / "head/tracker.json").read_text())
90
+ assert tracker["counts"] == {"open": 2, "closed": 1, "merged_pull": 1, "open_pull": 1, "no_pull": 1}
91
+ assert [each_child["progress"] for each_child in tracker["children"]] == ["merged_pull", "open_pull", "no_pull"]
92
+ assert snapshot["kind"] == "issue"
93
+ assert snapshot["metadata"]["tracked_issues"] == 4
94
+ assert (destination / "head/issues/11.md").read_text() == TASK_WITH_FIX["body"] + "\n"
95
+ assert json.loads((destination / "head/pulls/21.json").read_text())["draft"] is True
96
+ for each_file in snapshot["files"]:
97
+ assert hashlib.sha256((destination / each_file["captures"]["head"]["path"]).read_bytes()).hexdigest() == each_file["captures"]["head"]["sha256"]
98
+
99
+
100
+ def test_edited_issue_never_saves_a_tracker_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
101
+ """An epic edited during capture requires another capture before rendering."""
102
+ edited = {**EPIC, "updated_at": "2026-10-19T12:05:00Z"}
103
+ monkeypatch.setattr(collect, "_api", _tracker_github(edited))
104
+ with pytest.raises(collect.CaptureRunFatal, match="changed during capture"):
105
+ collect.collect_issue(ISSUE_URL, tmp_path)
106
+ assert not list(tmp_path.rglob("snapshot.json"))
@@ -1,6 +1,7 @@
1
1
  """Exercise numerical provenance, evidence boundaries, and portable rendering."""
2
2
  import copy
3
3
  import json
4
+ import shutil
4
5
  import sys
5
6
  from pathlib import Path
6
7
 
@@ -75,3 +76,86 @@ def test_embedded_text_cannot_close_script(tmp_path: Path) -> None:
75
76
  artifact_path = tmp_path / "visual.html"
76
77
  render([scene], artifact_path)
77
78
  assert scene["summary"] not in artifact_path.read_text()
79
+
80
+
81
+ def test_tracker_scene_links_each_record_to_its_issue_or_pull_request(tmp_path: Path) -> None:
82
+ """Tracker evidence opens the issue or pull request a captured record came from."""
83
+ scene = prepare(FIXTURES / "tracker.scene.json")
84
+ render([scene], tmp_path / "visual.html")
85
+ all_links = [each_source["url"] for each_node in scene["nodes"] for each_source in each_node["sources"]]
86
+ assert all_links == ["https://github.com/example/project/issues/1", "https://github.com/example/project/issues/2", "https://github.com/example/project/pull/3", "https://github.com/example/project/issues/1"]
87
+ assert scene["pr"]["kind"] == "issue"
88
+ assert scene["pr"]["changed_files"] == 3
89
+
90
+
91
+ def test_tracker_scene_rejects_evidence_outside_captured_records(tmp_path: Path) -> None:
92
+ """A tracker source must name a captured issue, pull request, or the tracker tree."""
93
+ tracker_copy = tmp_path / "tracker"
94
+ shutil.copytree(FIXTURES / "tracker", tracker_copy)
95
+ (tracker_copy / "head/notes.txt").write_text("synthetic\n")
96
+ snapshot = json.loads((tracker_copy / "snapshot.json").read_text())
97
+ with pytest.raises(RenderRunFatal, match="Tracker evidence"):
98
+ _source_evidence({"path": "notes.txt", "line": 1}, snapshot, tracker_copy / "snapshot.json")
99
+
100
+
101
+ def test_tracker_scene_places_every_card_in_its_state_lane(tmp_path: Path) -> None:
102
+ """Each card lands once in the lane for its state, under its epic, with its issue link."""
103
+ scene = prepare(FIXTURES / "tracker.scene.json")
104
+ render([scene], tmp_path / "visual.html", is_links_only=True)
105
+ branches, left, fixed = scene["board"]["lanes"]
106
+ assert [each_lane["node"] for each_lane in scene["board"]["lanes"]] == ["epic", "left", "fixed"]
107
+ assert [(each_card["title"], each_card["note"]) for each_card in left["groups"][0]["cards"]] == [("task without a fix", "")]
108
+ assert [(each_card["title"], each_card["note"]) for each_card in fixed["groups"][0]["cards"]] == [("task with a fix", "Merged in pull request 3")]
109
+ assert fixed["groups"][0]["title"] == "tracker epic"
110
+ assert fixed["groups"][0]["cards"][0]["url"] == "https://github.com/example/project/issues/2"
111
+ assert branches["groups"][0]["cards"][0]["url"] == "https://github.com/example/project/issues/1"
112
+ assert scene["board"]["count"] == 3
113
+ assert left["groups"][0]["cards"][0]["steps"] == [
114
+ {"label": "Today", "kind": "orange", "text": "The sample task has no fix yet."},
115
+ {"label": "Build", "kind": "lime", "inline": True, "text": "Build the sample fix and link it."},
116
+ {"label": "Proof", "kind": "green", "text": "A sample test shows the fix."},
117
+ ]
118
+ assert fixed["groups"][0]["cards"][0]["steps"] == []
119
+ assert "Task one ships" not in (tmp_path / "visual.html").read_text()
120
+
121
+
122
+ def _board_scene(tmp_path: Path) -> dict:
123
+ shutil.copytree(FIXTURES / "tracker", tmp_path / "tracker")
124
+ return json.loads((FIXTURES / "tracker.scene.json").read_text())
125
+
126
+
127
+ def _prepare_copy(tmp_path: Path, scene: dict) -> dict:
128
+ (tmp_path / "tracker.scene.json").write_text(json.dumps(scene))
129
+ return prepare(tmp_path / "tracker.scene.json")
130
+
131
+
132
+ def test_tracker_cited_card_rejects_a_link_outside_the_repository(tmp_path: Path) -> None:
133
+ """A cited board card cannot point away from the tracked repository."""
134
+ scene = _board_scene(tmp_path)
135
+ scene["board"]["lanes"][0]["groups"][0]["cards"][0]["url"] = "https://example.com/elsewhere"
136
+ with pytest.raises(RenderRunFatal, match="tracked repository"):
137
+ _prepare_copy(tmp_path, scene)
138
+
139
+
140
+ def test_tracker_board_rejects_a_node_with_no_lane(tmp_path: Path) -> None:
141
+ """Every state node on a board sits in exactly one lane."""
142
+ scene = _board_scene(tmp_path)
143
+ scene["board"]["lanes"].pop()
144
+ with pytest.raises(RenderRunFatal, match="exactly one lane"):
145
+ _prepare_copy(tmp_path, scene)
146
+
147
+
148
+ def test_tracker_scene_leaves_out_excluded_issues(tmp_path: Path) -> None:
149
+ """An issue the scene names as not a work item stays off the board."""
150
+ scene = _board_scene(tmp_path)
151
+ scene["board"]["exclude"] = [4]
152
+ left_lane = _prepare_copy(tmp_path, scene)["board"]["lanes"][1]
153
+ assert left_lane["groups"] == [] and left_lane["count"] == 0
154
+
155
+
156
+ def test_tracker_scene_uses_the_plain_title_a_scene_gives(tmp_path: Path) -> None:
157
+ """A card shows the plain title a scene gives for an issue number."""
158
+ scene = _board_scene(tmp_path)
159
+ scene["board"]["titles"] = {"4": "Build the missing fix"}
160
+ left_lane = _prepare_copy(tmp_path, scene)["board"]["lanes"][1]
161
+ assert [each_card["title"] for each_card in left_lane["groups"][0]["cards"]] == ["Build the missing fix"]
@@ -51,6 +51,35 @@ def _toolbar_details(root: Locator, node_count: int) -> None:
51
51
  root.get_by_role("button", name=c.CLOSE_BUTTON_LABEL).click()
52
52
 
53
53
 
54
+ def _check_board(root: Locator, scene: dict) -> None:
55
+ all_cards = root.locator(c.BOARD_CARD_SELECTOR)
56
+ if not scene.get("board"):
57
+ assert all_cards.count() == 0
58
+ return
59
+ assert all_cards.count() == scene["board"]["count"]
60
+ assert not all_cards.evaluate_all(c.CARD_FIT_EXPRESSION)
61
+ _check_board_text(root, c.CANVAS_TEXT_SELECTOR)
62
+ first_card = 0
63
+ for each_lane in scene["board"]["lanes"]:
64
+ if not each_lane["count"]:
65
+ continue
66
+ card = each_lane["groups"][0]["cards"][0]
67
+ all_cards.nth(first_card).click()
68
+ assert root.locator(c.DETAIL_TITLE_SELECTOR).inner_text() == card["title"]
69
+ assert root.locator(c.CARD_STEP_SELECTOR).count() == max(1, len(card.get("steps", [])))
70
+ _check_board_text(root, c.CARD_FLOW_TEXT_SELECTOR)
71
+ root.get_by_role("button", name=c.CLOSE_BUTTON_LABEL).click()
72
+ assert root.locator(c.DRAWER_SELECTOR).is_hidden()
73
+ first_card += each_lane["count"]
74
+
75
+
76
+ def _check_board_text(root: Locator, selector: str) -> None:
77
+ background = _luminance(c.PAGE_BACKGROUND_COLOR)
78
+ for each_text in root.locator(selector).evaluate_all(c.BOARD_TEXT_EXPRESSION):
79
+ assert each_text["size"] >= c.MINIMUM_BOARD_FONT_SIZE, each_text
80
+ assert (background + c.CONTRAST_OFFSET) / (_luminance(each_text["color"]) + c.CONTRAST_OFFSET) >= c.MINIMUM_BOARD_CONTRAST, each_text
81
+
82
+
54
83
  def _keyboard_and_zoom(page: Page, root: Locator) -> None:
55
84
  first_node = root.locator(c.NODE_SELECTOR).first
56
85
  first_node.focus()
@@ -66,11 +95,15 @@ def _keyboard_and_zoom(page: Page, root: Locator) -> None:
66
95
 
67
96
  def _scene_screenshots(page: Page, root: Locator, scene: dict, proof_directory: Path, scene_index: int) -> None:
68
97
  page.screenshot(path=str(proof_directory / c.PR_SCREENSHOT_NAME.format(number=scene["pr"]["number"], index=scene_index)), full_page=True)
98
+ if scene.get("board"):
99
+ root.locator(c.WORKSPACE_SELECTOR).evaluate(c.UNCLIP_WORKSPACE_EXPRESSION)
100
+ root.locator(c.CANVAS_SELECTOR).screenshot(path=str(proof_directory / c.BOARD_SCREENSHOT_NAME.format(number=scene["pr"]["number"], index=scene_index)))
101
+ root.locator(c.WORKSPACE_SELECTOR).evaluate(c.RECLIP_WORKSPACE_EXPRESSION)
69
102
  all_nodes = root.locator(c.NODE_SELECTOR)
70
103
  all_nodes.first.click()
71
104
  page.screenshot(path=str(proof_directory / c.DETAIL_SCREENSHOT_NAME.format(number=scene["pr"]["number"], index=scene_index)), full_page=True)
72
105
  if all_nodes.count() > 1:
73
- all_nodes.nth(1).click()
106
+ root.locator(c.NODE_TARGET_SELECTOR.format(target=scene["nodes"][1]["id"])).click()
74
107
  assert root.locator(c.DETAIL_TITLE_SELECTOR).inner_text() == c.SPACE_SEPARATOR.join(scene["nodes"][1]["label"])
75
108
  page.keyboard.press("Escape")
76
109
 
@@ -78,17 +111,18 @@ def _scene_screenshots(page: Page, root: Locator, scene: dict, proof_directory:
78
111
  def _verify_scene(page: Page, root: Locator, scene: dict, scene_index: int, proof_directory: Path) -> dict:
79
112
  root.get_by_role("tab").nth(scene_index).click()
80
113
  all_nodes = root.locator(c.NODE_SELECTOR)
81
- for each_index, each_node in enumerate(scene["nodes"]):
82
- all_nodes.nth(each_index).click()
114
+ for each_node in scene["nodes"]:
115
+ root.locator(c.NODE_TARGET_SELECTOR.format(target=each_node["id"])).click()
83
116
  _check_node(root, each_node)
84
117
  _navigate_related(root)
85
118
  page.keyboard.press("Escape")
86
119
  assert root.locator(c.DRAWER_SELECTOR).is_hidden()
87
120
  assert root.locator(c.SELECTED_NODE_SELECTOR).count() == 0
88
121
  _toolbar_details(root, len(scene["nodes"]))
122
+ _check_board(root, scene)
89
123
  _keyboard_and_zoom(page, root)
90
124
  _scene_screenshots(page, root, scene, proof_directory, scene_index)
91
- return {"pr": scene["pr"]["number"], "nodes_checked": all_nodes.count(), "docked_panel": "pass", "mini_diagrams": "pass", "related_navigation": "pass", "collapsed_sources": "pass", "keyboard_and_zoom": "pass"}
125
+ return {"pr": scene["pr"]["number"], "nodes_checked": all_nodes.count(), "docked_panel": "pass", "mini_diagrams": "pass", "related_navigation": "pass", "collapsed_sources": "pass", "keyboard_and_zoom": "pass", "board_cards": scene["board"]["count"] if scene.get("board") else "none"}
92
126
 
93
127
 
94
128
  def _dashboard(page: Page, root: Locator, all_scenes: list[dict], proof_directory: Path) -> None:
@@ -12,6 +12,30 @@ HEAD_SIDE = "head"
12
12
  BASE_SIDE = "base"
13
13
  PR_URL_PATTERN = r"https://github\.com/([\w.-]+/[\w.-]+)/pull/([1-9]\d*)/?"
14
14
  PR_ENDPOINT = "repos/{repo}/pulls/{number}"
15
+ ISSUE_URL_PATTERN = r"https://github\.com/([\w.-]+/[\w.-]+)/issues/([1-9]\d*)/?"
16
+ ISSUE_ENDPOINT = "repos/{repo}/issues/{number}"
17
+ SUB_ISSUES_ENDPOINT = "repos/{repo}/issues/{number}/sub_issues?per_page={size}&page={page}"
18
+ LINKED_PULL_PATTERN = r"https://github\.com/{repo}/pull/([1-9]\d*)"
19
+ ISSUE_KIND = "issue"
20
+ SUB_ISSUE_DEPTH = 2
21
+ ISSUE_RECORD_PATH = "issues/{number}.json"
22
+ ISSUE_BODY_PATH = "issues/{number}.md"
23
+ SECTION_HEADING = "## {heading}"
24
+ WHOLE_PHRASE_PATTERN = r"(?<!\w){phrase}(?!\w)"
25
+ PULL_RECORD_PATH = "pulls/{number}.json"
26
+ TRACKER_PATH = "tracker.json"
27
+ ISSUE_LINK = "https://github.com/{repo}/issues/{number}"
28
+ PULL_LINK = "https://github.com/{repo}/pull/{number}"
29
+ REPOSITORY_LINK = "https://github.com/{repo}/"
30
+ CAPTURED_ISSUE_PATTERN = r"issues/([1-9]\d*)\.(?:json|md)"
31
+ CAPTURED_ISSUE_RECORD_PATTERN = r"issues/([1-9]\d*)\.json"
32
+ CAPTURED_PULL_PATTERN = r"pulls/([1-9]\d*)\.json"
33
+ ALL_ISSUE_FIELDS = ("number", "title", "state", "state_reason", "html_url", "created_at", "updated_at", "closed_at")
34
+ ALL_PULL_PROGRESS = ("merged_pull", "open_pull", "no_pull")
35
+ ALL_CARD_NOTES = {"no_pull": "", "open_pull": "Open in pull request {pulls}", "merged_pull": "Merged in pull request {pulls}"}
36
+ PULL_REFERENCE = "{number}"
37
+ PULL_REFERENCE_SEPARATOR = ", "
38
+ ALL_PULL_FIELDS =("number", "title", "state", "draft", "merged_at", "closed_at", "updated_at", "html_url")
15
39
  COMPARE_ENDPOINT = "repos/{repo}/compare/{base}...{head}"
16
40
  FILES_ENDPOINT = "repos/{repo}/pulls/{number}/files?per_page={size}&page={page}"
17
41
  CONTENTS_ENDPOINT = "repos/{repo}/contents/{path}?ref={revision}"
@@ -37,11 +61,23 @@ ROOT_SELECTOR = "[data-pr-visual]"
37
61
  DRAWER_SELECTOR = ".drawer"
38
62
  STAGE_SELECTOR = ".stage"
39
63
  CANVAS_SELECTOR = ".canvas"
64
+ WORKSPACE_SELECTOR = ".workspace"
65
+ UNCLIP_WORKSPACE_EXPRESSION = "(n)=>{n.style.height='auto';n.firstElementChild.style.overflow='visible'}"
66
+ RECLIP_WORKSPACE_EXPRESSION = "(n)=>{n.style.height='';n.firstElementChild.style.overflow=''}"
40
67
  EXACT_SELECTOR = ".exact-source"
41
68
  DISCLOSURE_SELECTOR = ".exact-source summary"
42
69
  DETAIL_TITLE_SELECTOR = "[data-detail-title]"
43
70
  NODE_SELECTOR = "[data-node]"
44
71
  JUMP_SELECTOR = "[data-detail-target]"
72
+ BOARD_CARD_SELECTOR = "[data-card]"
73
+ CANVAS_TEXT_SELECTOR = ".canvas svg text"
74
+ CARD_FLOW_TEXT_SELECTOR = ".drawer .card-flow text"
75
+ CARD_STEP_SELECTOR = ".drawer .card-flow rect"
76
+ MINIMUM_BOARD_FONT_SIZE = 16
77
+ MINIMUM_BOARD_CONTRAST = 7
78
+ PAGE_BACKGROUND_COLOR = "rgb(246, 245, 239)"
79
+ BOARD_TEXT_EXPRESSION = "(nodes)=>nodes.map(n=>({size:parseFloat(getComputedStyle(n).fontSize),color:getComputedStyle(n).fill}))"
80
+ CARD_FIT_EXPRESSION = "(cards)=>cards.filter(a=>{const r=a.querySelector('rect').getBBox();return [...a.querySelectorAll('text')].some(t=>{const b=t.getBBox();return b.x<r.x||b.x+b.width>r.x+r.width||b.y<r.y||b.y+b.height>r.y+r.height})}).map(a=>a.textContent)"
45
81
  DATA_SCENES_SELECTOR = "[data-scenes]"
46
82
  CANVAS_WIDTH_EXPRESSION = "(n)=>n.style.width"
47
83
  DISCLOSURE_OPEN_EXPRESSION = "(n)=>n.open"
@@ -56,6 +92,7 @@ NODE_TARGET_SELECTOR = '[data-node="{target}"]'
56
92
  SELECTED_NODE_SELECTOR = '[data-node][aria-expanded=true]'
57
93
  PR_SCREENSHOT_NAME = "scene-{index}-pr-{number}.png"
58
94
  DETAIL_SCREENSHOT_NAME = "scene-{index}-pr-{number}-detail.png"
95
+ BOARD_SCREENSHOT_NAME = "scene-{index}-pr-{number}-board.png"
59
96
  MOBILE_SCREENSHOT_NAME = "mobile-detail.png"
60
97
  REPORT_NAME = "verification.json"
61
98
  DEFAULT_PROOF_DIRECTORY = "proof"
@@ -63,7 +100,7 @@ SPACE_SEPARATOR = " "
63
100
  ZOOM_BUTTON_LABEL = "Zoom in"
64
101
  CLOSE_BUTTON_LABEL = "Close details"
65
102
  ALL_TOOLBAR_DETAIL_LABELS = ("Summary vs code", "Review notes")
66
- OVERFLOW_EXPRESSION = "(nodes)=>nodes.filter(n=>{const b=n.getBBox(); return b.x<0||b.x+b.width>1440||b.y<0||b.y+b.height>1020}).map(n=>n.textContent)"
103
+ OVERFLOW_EXPRESSION = "(nodes)=>nodes.filter(n=>{const b=n.getBBox(),v=n.ownerSVGElement.viewBox.baseVal; return b.x<0||b.x+b.width>v.width||b.y<0||b.y+b.height>v.height}).map(n=>n.textContent)"
67
104
  MAIN_FRAGMENT_PATTERN = r"<main data-pr-visual>.*?</main>"
68
105
  STYLE_FRAGMENT_PATTERN = r"<style>.*?</style>"
69
106
  HOST_FIXTURE_HTML = '<div class="toolbar" id="host-toolbar">Host toolbar</div>'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.95.1",
3
+ "version": "8.96.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {