diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ddaed3..85a8228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,26 @@ observable behavior and compatibility, not every internal refactor. (`qoder, codex, claude, cursor, qwen, copilot, pi`) when it rejects an unsupported `--platform`, matching the session-analysis and asset-baseline gates. The existing error prefix and exit behavior are unchanged. +- Core Change Watch now requires framework-specific evidence before labeling + Rails or FastAPI, exposes bounded root Just recipes as statically discovered + unverified argv entrypoints, and keeps historical-only files out of current + recommended reads and action targets. +- Evidence bundles now discover and privacy-filter one frozen Session population + before either Session facts or lead analysis hydrates it. Versioned redacted + bindings fail closed on population, selection, or admission contradictions + while preserving bounded lead selection and explicit zero-signal filtering. +- Self-contained HTML reports now expose every fluency-dimension score track as + a labeled progressbar with a zero-to-100 range and the displayed rounded + score. Report validation rejects incomplete, duplicated, invalid, or + score-mismatched dimension progressbar contracts. +- Chinese self-contained HTML reports now use standards-based language + segmentation to keep bounded word-like phrases together while preserving + normal wrapping around Latin text, paths, URLs, and longer content. Runtimes + without segmentation support fall back to readable escaped text, and English + reports remain unchanged. +- HTML Evidence cards now display machine-owned Task Episode coverage from a + summary-facts companion, with legacy at-a-glance coverage retained only as a + compatibility fallback. ## 0.3.0 - 2026-07-27 diff --git a/docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md b/docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md new file mode 100644 index 0000000..40fc9a2 --- /dev/null +++ b/docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md @@ -0,0 +1,95 @@ +# Core Change Watch Profile And Current Paths + +## Traceability + +- Spec ID: 2026-07-30-core-change-watch-profile-and-current-paths +- Status: Implemented +- Issue: None; focused evidence-correctness maintenance discovered by a validated Better Harness report. + +## Intent + +Keep Core Change Watch's static project profile and current-work guidance +truthful. Framework labels must require framework-specific evidence, supported +root Just recipes must be visible without being executed, and current reads or +actions must not point to files that exist only in bounded Git history. + +## Acceptance Scenarios + +- AC-1: A Python project with FastAPI declared in a supported Python manifest + and generic `app/api` or `app/services` paths is identified as FastAPI, is not + identified as Rails, and receives no TypeScript-only path reason. A real + Rails fixture remains identified from Rails-specific evidence. +- AC-2: Public recipes in a root `justfile`, `Justfile`, or `.justfile` are + represented additively as entry candidates with `kind: "just-recipe"`, an + argv-style `command`, the source path, and `executionStatus: "unverified"`. + Parsing is static, bounded, handles LF and CRLF, ignores private recipes, and + never runs Just or a shell. +- AC-3: `recommendedReads` and every `followUpActions[].files` entry resolve to + a currently present tracked file or a currently present changed/untracked + file. Deleted historical paths remain available in raw history evidence but + are not projected as current work targets. +- AC-4: Focused Core Change Watch tests, repository documentation checks, and + the full package gate pass without a new dependency. + +## Non-Goals + +- Session lane/lead population binding or source-fingerprint changes. +- Renderer progress-track ARIA changes. +- Agent Work Loop score changes or synthetic Task Episodes. +- Executing Just recipes, shell-string dispatch, or proving command success. +- Adding dependencies or a general TOML/Just parser. +- Editing generated reports, installed plugin caches, Session content, Memory, + or user-home data. +- Rewriting or deleting valid raw Git-history evidence. + +## Plan And Tasks + +Allowed files, copied verbatim from the approved Worker package: + +1. `docs/specs/2026-07-30-core-change-watch-profile-and-current-paths.md` +2. `scripts/core-change-watch/project-profile.mjs` +3. `scripts/core-change-watch/core-candidates.mjs` +4. `scripts/core-change-watch/evidence-pack.mjs` +5. `test/core-change-watch.test.mjs` +6. `references/project-harness/core-change-watch.md` +7. `CHANGELOG.md` +8. `docs/better-harness-doc-links.mmd` + +Tasks: + +1. Add focused regression fixtures for AC-1 through AC-3 and preserve the + defect-specific failing run before production edits. +2. Tighten framework/path evidence and add bounded static Just recipe + projection without changing existing entry-candidate meanings. +3. Filter history-derived current-work projections through present repository + inventory while preserving the history profile. +4. Update the canonical Core Change Watch guidance and changelog, regenerate + the Markdown routing graph, and mark this spec Implemented only after all + acceptance evidence passes. +5. Run the change-traceability Review Readiness Check before commit or review. + +## Test And Review Evidence + +- The focused red run failed all three new regressions for the intended causes: + missing FastAPI evidence, missing Just recipe projection, and a deleted + historical path projected as current work. The same run passed 3/3 after the + implementation. +- The real `database-caching` projection reports only `fastapi`, exposes + `just sync`, `just api-dev`, `just health`, and `just check` with unverified + status, uses no TypeScript reason for `app/api`, and emits only readable + recommended/action paths. +- AC-1 to AC-3 red/green: + `node --test --test-name-pattern='framework-specific evidence|Just recipes|non-current historical paths' test/core-change-watch.test.mjs` +- Focused suite: `node --test test/core-change-watch.test.mjs` +- Real target projection: run `analyzeProjectProfile`, + `analyzeCoreCandidates`, and `buildEvidencePack` against `database-caching`; + assert FastAPI/no Rails, argv Just entries with unverified status, neutral + `app/api` reasons, and readable current-work paths. +- Documentation: regenerate the routing graph and run + `node --test test/doc-link-graph.test.mjs`. +- Package gate: `TMPDIR= npm run check` passed with 884/884 + tests plus npm-package and runtime-zip verification. The clean temp root + avoids unrelated `/tmp/CLAUDE.md` ancestor-instruction contamination. +- Risk review: verify additive output fields, cross-platform path handling, + preserved Rails and raw-history fixtures, no shell execution, no dependency + changes, and an explicit AI co-author marker at commit time. diff --git a/docs/specs/2026-07-30-html-cjk-line-breaking.md b/docs/specs/2026-07-30-html-cjk-line-breaking.md new file mode 100644 index 0000000..5586285 --- /dev/null +++ b/docs/specs/2026-07-30-html-cjk-line-breaking.md @@ -0,0 +1,95 @@ +# Keep Chinese phrases together in HTML reports + +## Traceability + +- Spec ID: 2026-07-30-html-cjk-line-breaking +- Status: Implemented +- Issue: None; focused follow-up to the reviewed HTML progressbar visual QA. + +## Intent + +Keep ordinary Chinese word-like phrases together when the self-contained HTML +report wraps headings and card copy. Segment visible `zh-CN` text with the +standards-based `Intl.Segmenter` API and emit bounded, nonvisual keep-together +spans so the result is deterministic across supported browsers. The generic +renderer must improve reviewed Chinese content without hardcoded phrases, +report-data mutation, runtime measurement, or a visual redesign. + +## Acceptance Scenarios + +- AC-1: A `zh-CN` HTML report uses `Intl.Segmenter` word segmentation for + visible text and wraps each ordinary all-Han word-like segment of at most + eight code points in ``. Representative phrases + including `口径`, `入口`, `结论`, and `命令` receive that deterministic markup. +- AC-2: Phrase markup remains scoped to visible Chinese report text. English + reports retain their current markup, wrapping behavior, and visible copy; + attributes, accessibility labels, copied prompts, and embedded report JSON + remain plain escaped text without phrase spans or invisible joiners. +- AC-3: Chinese headings, card titles, summaries, reasons, and ordinary + paragraphs continue to wrap between marked segments. Each keep-together span + is bounded to eight Han code points. Long tokens, paths, URLs, mixed Latin + text, and unrecognized segments remain escaped readable text and cannot gain + an unbounded no-wrap container. +- AC-4: The existing dimension progressbar role, label, range, rounded value, + count validation, CSS, score computation, report data, and artifact set remain + unchanged. +- AC-5: Focused HTML tests, the full package check, whitespace validation, and + the allowed-file audit pass. Before review completion, an environment with a + browser runtime inspects the same report at all three target widths. + +## Compatibility Boundary + +`Intl.Segmenter` is used only while generating the self-contained report. The +renderer accepts only `isWordLike` segments that consist entirely of Han code +points and are between two and eight code points long. All other segments use +the existing HTML escaping path. If `Intl.Segmenter` is absent or throws, the +entire value falls back to the same escaped readable text. CSS applies +`white-space: nowrap` only to the bounded phrase spans, leaving normal wrapping +available between spans and around Latin tokens, punctuation, paths, and URLs. + +## Non-goals + +- Hardcoding known report phrases or inserting word-joiner characters into + report data, visible copy, accessibility text, or copied prompts. +- Preventing ordinary Chinese paragraphs from wrapping or forcing a complete + sentence, heading, or card onto one line. +- Adding overflow, clipping, ellipsis, hidden content, fixed widths, custom + fonts, browser-specific JavaScript, dependencies, or generated artifacts. +- Changing English layout, scores, findings, dimensions, report schemas, + progressbar semantics, interactions, or artifact names. + +## Plan and Tasks + +1. Add a focused renderer regression for deterministic markup, escaping, + bounded long-token handling, mixed Latin/path/URL preservation, English + preservation, unavailable-segmenter fallback, and the four reviewed phrases. + Record the missing-markup failure before renderer implementation. (AC-1..AC-4) +2. Add one visible-text renderer using `Intl.Segmenter` and a bounded + `.cjk-phrase` keep-together rule. Route visible report copy through it while + leaving attributes, copied prompts, and serialized data unchanged. Remove + the insufficient browser-dependent `auto-phrase` contract. (AC-1..AC-4) +3. Update the public changelog and verify the exact reviewed Chinese report at + 375, 768, and 1280 pixels, including wrapping, overflow, clipping, console, + and preserved progressbar semantics. (AC-3..AC-5) + +## Test and Review Evidence + +- The initial focused test failed for the intended reason: the reviewed Chinese + phrases had no deterministic keep-together markup. It passed after the + standards-based segment renderer and bounded phrase span were added. +- Browser QA then exposed descendant-selector leakage: nested phrase spans in + the score orbit, metrics, and evidence grid inherited block, width, and label + typography rules. A focused regression failed on the missing direct-child + selector contract before those existing rules were scoped to their direct + label and value children; nested phrase spans now remain inline. +- The combined CJK and preserved progressbar suite passed 2/2: + `node --test --test-name-pattern='HTML CJK phrase|HTML dimension progressbar' test/harness-report-render-cli.test.mjs`. +- The target-owned HTML suite passed 6/6: + `node --test --test-name-pattern='HTML' test/harness-report-render-cli.test.mjs`. +- The coordinating PM ran the complete test suite serially with an isolated + temporary root: all 894 tests passed, followed by successful npm and runtime + zip package verification. This resolved the parallel temporary-ancestor + interference observed in two earlier Worker runs. +- The exact reviewed Chinese report retains its source scores, finding count, + progressbar count, embedded report data, and artifact set. Post-fix browser + review at 375, 768, and 1280 pixels remains the final visual gate. diff --git a/docs/specs/2026-07-30-html-dimension-progressbar-semantics.md b/docs/specs/2026-07-30-html-dimension-progressbar-semantics.md new file mode 100644 index 0000000..8f0bbfd --- /dev/null +++ b/docs/specs/2026-07-30-html-dimension-progressbar-semantics.md @@ -0,0 +1,76 @@ +# Give HTML dimension tracks progressbar semantics + +## Traceability + +- Spec ID: 2026-07-30-html-dimension-progressbar-semantics +- Status: Implemented +- Issue: None; focused accessibility-contract maintenance from a reviewed Better Harness finding. + +## Intent + +Make every fluency-dimension score track in the self-contained HTML report +expose the score that sighted readers already see to assistive technology. The +HTML validator must keep that semantic projection bound to the reviewed +dimension count and rounded score so incomplete or stale markup cannot pass +report validation. + +## Acceptance Scenarios + +- AC-1: Every rendered fluency-dimension card contains exactly one track with + `role="progressbar"`, a non-empty accessible label derived from the displayed + dimension label, `aria-valuemin="0"`, `aria-valuemax="100"`, and an + `aria-valuenow` equal to the integer score displayed beside that track. +- AC-2: The HTML report validator passes the canonical rendered dimension + tracks and rejects a missing or extra dimension progressbar, a progressbar + with a missing or invalid role, label, minimum, maximum, or current value, + and a current value that differs from the reviewed rounded score. +- AC-3: The renderer preserves existing CSS, layout, visible copy, dimension + ordering, score clamping and rounding, visual fill width, artifact names, + and generated report ownership. +- AC-4: The focused regression, full renderer CLI suite, documentation graph, + doc-link suite, full package check, whitespace check, and allowed-file audit + pass from the assigned repository state. + +## Non-goals + +- Redesigning the dimension cards, tracks, responsive layout, color, or CSS. +- Changing visible labels, summaries, localization, score computation, + clamping, rounding, or report data schemas. +- Editing generated reports, templates, installed plugin caches, dependencies, + custom SVG, or any report mode other than the canonical HTML renderer. +- Adding browser interaction, live-region announcements, or a new artifact. + +## Plan and Tasks + +1. Add one focused regression test that asserts the rendered per-dimension + progressbar contract and mutation-based validator rejection for missing, + invalid, count-mismatched, and score-mismatched semantics. Record the test + failing against the current renderer before implementation. (AC-1, AC-2) +2. Reuse the renderer's existing display label and rounded score when emitting + the complete progressbar attributes, without changing track CSS or fill + width. (AC-1, AC-3) +3. Extend `evaluateHtmlReport` to bind exactly one valid progressbar to each + reviewed dimension card and reject semantic count or score drift. (AC-2) +4. Update the public changelog, regenerate the Markdown routing graph required + for the new spec, run every assigned verification command, and mark this + spec Implemented only after the evidence passes. (AC-4) + +## Test and Review Evidence + +- The required red run failed for the intended contract reason: the current + renderer produced zero semantic progressbars for five reviewed dimensions + (`0 !== 5`). The same focused command passed after implementation: + `node --test --test-name-pattern='dimension progressbar' test/harness-report-render-cli.test.mjs`. +- The full renderer suite passed 15/15: + `node --test test/harness-report-render-cli.test.mjs`. +- Documentation graph generation and all six doc-link checks passed: + `node scripts/doc-link-graph/cli.mjs skills/better-harness && node --test test/doc-link-graph.test.mjs`. +- The complete package gate passed from a clean temporary root under + `/var/tmp` with 893 tests and successful npm/runtime-zip package verification: + `TMPDIR=/var/tmp/better-harness-t016.lxCc80 npm run check`. +- Scope and whitespace: + `git diff --check -- ` plus an allowed-file-only status audit. +- Risk review: the focused test removes and duplicates a progressbar, mutates + each semantic attribute class, and changes `aria-valuenow`; all mutations + fail validation while canonical output passes. The CSS and existing visual + fill-width expression are unchanged. diff --git a/docs/specs/2026-07-30-html-summaryfacts-episode-coverage.md b/docs/specs/2026-07-30-html-summaryfacts-episode-coverage.md new file mode 100644 index 0000000..d35b820 --- /dev/null +++ b/docs/specs/2026-07-30-html-summaryfacts-episode-coverage.md @@ -0,0 +1,60 @@ +# Preserve summary-facts Episode coverage in HTML + +## Traceability + +- Spec ID: 2026-07-30-html-summaryfacts-episode-coverage +- Status: Implemented +- Issue: None; focused renderer correctness repair found during final browser QA. + +## Intent + +Make the self-contained HTML Evidence section display the machine-owned Episode +coverage already embedded in a split findings plus summary-facts companion +render. The current renderer preserves those facts in report data but reads only +the legacy `atAGlance.coverage` projection for its visible cards, producing a +false zero when the canonical `evidenceBoundary.episodeCoverage` is present. + +## Acceptance Scenarios + +- AC-1: When `summary.evidenceBoundary.episodeCoverage` reports 14 total and 12 + edited Episodes, the visible HTML Evidence cards display 14 and 12. +- AC-2: Machine-owned `evidenceBoundary.episodeCoverage` takes precedence over + stale or absent legacy `atAGlance.coverage` values. +- AC-3: A legacy report that has only `atAGlance.coverage` keeps its existing + visible Episode counts. +- AC-4: No score, finding, schema, CSS, layout, CJK, interaction, or artifact + ownership behavior changes. +- AC-5: Focused renderer tests, the full HTML suite, the complete serial package + suite, package verification, whitespace checks, and final browser QA pass. + +## Non-goals + +- Changing evidence collection, Session population binding, Episode admission, + report scores, findings, or summary-facts schemas. +- Adding fallback inference from usage counts or visible prose. +- Editing generated reports or installed plugin caches. +- Redesigning the Evidence section or adding new UI. + +## Plan and Tasks + +1. Add a focused renderer regression for canonical summary-facts precedence and + legacy compatibility, and preserve its red result. (AC-1, AC-2, AC-3) +2. Read Episode coverage from the canonical machine-facts owner with the legacy + projection as a compatibility fallback. (AC-1, AC-2, AC-3, AC-4) +3. Run the assigned verification and record the evidence before marking this + spec Implemented. (AC-5) + +## Test and Review Evidence + +- The focused red run failed because the canonical 14/12 summary-facts values + rendered as the conflicting legacy 1/1 values. The unchanged focused command + passed after implementation: + `node --test --test-name-pattern='HTML evidence episode coverage' test/harness-report-render-cli.test.mjs`. +- The complete HTML-focused renderer suite passed 7/7: + `node --test --test-name-pattern='HTML' test/harness-report-render-cli.test.mjs`. +- The isolated serial package suite passed 895/895: + `TMPDIR=/var/tmp/better-harness-t025.dES6tD npm test -- --test-concurrency=1`. +- Package verification passed with 324 npm entries and 348 runtime-zip entries: + `TMPDIR=/var/tmp/better-harness-t025.dES6tD npm run pack:verify`. +- Final report rendering, three-width browser QA, scope, whitespace, and review + evidence are recorded in the contribution receipt before commit. diff --git a/docs/specs/2026-07-30-session-population-binding.md b/docs/specs/2026-07-30-session-population-binding.md new file mode 100644 index 0000000..18d7fe5 --- /dev/null +++ b/docs/specs/2026-07-30-session-population-binding.md @@ -0,0 +1,119 @@ +# Session Population Binding + +## Traceability + +- Spec ID: 2026-07-30-session-population-binding +- Status: Implemented +- Issue: None; focused evidence-integrity maintenance discovered by a validated Better Harness report. + +## Intent + +Make one evidence-bundle run derive Session facts and lead analysis from one +frozen, privacy-filtered Session population. The binding must prevent an active +Session from entering the lead after it was omitted from facts, without +persisting or exposing the private inventory or weakening bounded lead +selection. + +## Acceptance Scenarios + +- AC-1: The bundle discovers the frozen Session population once, removes Qoder + home-only sources and an exact explicit/provider active Session identity + before either lane hydrates it, and supplies the same eligible snapshot to + both collectors. With an explicit frozen `until`, exact identity exclusion + remains enabled while recency-window inference remains disabled. +- AC-2: Public output contains only bounded, versioned bindings: frozen scope + and omission policy/counts, eligible population count/fingerprint, Session + all-eligible selection count/fingerprint, and lead bounded selection + count/fingerprint with a parent-population assertion. It contains no raw + Session IDs, source paths, prompts, commands, content, Memories, user-home + payloads, reversible locators, or serialized snapshot. +- AC-3: Bundle validation fails closed with stable redacted error codes when a + binding is missing or mismatched, a selected fingerprint is not a subset of + its population, or admission counters do not reconcile. Session admission + satisfies `taskEpisodes = candidateEpisodes + noRequest + selfAnalysis + + lowSignal`, `candidateEpisodes = distinctRequests + duplicateRequests`, and + `distinctRequests = emittedCandidates + candidateBudget`. +- AC-4: Lead admission satisfies `projectedEpisodes = admittedEpisodes + + zeroSignalDiscardedEpisodes` and `admittedEpisodes = retained taskEpisodes`. + Selected fingerprints remain lane-specific for bounded strategies; cross-lane + Episode totals are compared only when selected and projection-policy + fingerprints match. Legitimate zero-signal filtering remains valid. +- AC-5: Existing scoring, lead stratification and limits, raw-data exclusions, + provider adapters, and standalone analysis commands remain unchanged. + +## Non-Goals + +- Reading or migrating raw Sessions, Memories, user-home content, private + plugin data, or unsanitized generated reports. +- Persisting the shared population snapshot or exposing private identity data. +- All-eligible lead hydration, larger limits, weaker stratification, synthetic + Task Episodes, or score/weight changes. +- Renderer accessibility work, Core Change Watch work, report edits, installed + plugin-cache changes, releases, merges, or production publication. +- Provider-adapter changes or a new dependency. + +## Plan And Tasks + +Allowed files, copied verbatim from the approved Worker package: + +1. `docs/specs/2026-07-30-session-population-binding.md` +2. `scripts/session-analysis/session-population.mjs` +3. `scripts/harness-analysis/evidence-bundle/contract.mjs` +4. `scripts/harness-analysis/evidence-bundle/index.mjs` +5. `scripts/harness-analysis/evidence-bundle/session-evidence.mjs` +6. `scripts/harness-analysis/report-run.mjs` +7. `scripts/harness-analysis/task-loop-source.mjs` +8. `test/session-population.test.mjs` +9. `test/better-harness-evidence-bundle.test.mjs` +10. `test/task-loop-source.test.mjs` +11. `CHANGELOG.md` +12. `docs/better-harness-doc-links.mmd` + +Tasks: + +1. Add sanitized fixtures for exact active-session exclusion, shared snapshot + reuse, binding mismatch, admission reconciliation, and zero-signal filtering. +2. Preserve a test-only red run that demonstrates active-session divergence and + missing fail-closed binding before production edits. +3. Implement the private population owner, public binding contract, shared + orchestration, and bounded lead projection without persistence or new data + authority. +4. Regenerate the Markdown routing graph, update the changelog, run focused and + full gates, and mark this spec Implemented only after evidence passes. +5. Perform Review Readiness over the no-issue rationale, Spec, Tests, Risk, AI, + Refs, generated files, and staged/unstaged boundaries before commit. + +## Test And Review Evidence + +- Red run: six focused tests failed for the intended missing owner, unshared + population, non-failing mismatch, and unavailable zero-signal accounting + before production edits. +- Focused binding tests: nine population, binding, active-session, count + contradiction, frozen-inventory, and zero-signal tests pass. +- Full Session/bundle/source compatibility: 61/61 tests pass across + `test/session-population.test.mjs`, `test/better-harness-evidence-bundle.test.mjs`, + `test/task-loop-source.test.mjs`, and `test/harness-report-run.test.mjs`. +- Real target: a normal-depth Codex bundle for `database-caching` completed with + both lanes available and the binding `bound`. It recorded exact active + identity availability, omitted one active Session, bound four eligible + Sessions to the same parent fingerprint, and reconciled four projected + Episodes as four explicit zero-signal discards and zero retained Episodes. +- Privacy serialization audit: the focused bundle fixture proves neither the + private inventory nor its Session IDs appear in serialized output; the real + target inspection printed only versioned bindings and aggregate counters. +- Review hardening: the lead source path now skips a second `sources` discovery + when a frozen population is supplied, and the privacy regression checks + specific fixture identities instead of rejecting macOS `/private/tmp` paths. +- Review validation: the focused Session/report suite passed 123/123 tests, the + repository gate passed 1006/1006 tests plus package verification, and browser + QA confirmed complete progressbar semantics with no console errors or page + overflow in the Chinese HTML fixture. +- Documentation: the canonical routing graph regenerated without drift and the + six documentation checks pass. +- Package gate: `TMPDIR= npm run check` passed 892/892 tests + plus npm-package (321 entries) and runtime-zip (345 entries) verification. +- Review Readiness: the diff is confined to the approved owner/test/spec/ + changelog files; the optional contract and generated graph files were not + changed. No dependency, provider adapter, scoring owner, generated report, + installed cache, raw identity, or private payload changed. The commit must + carry exactly one required Codex co-author marker. diff --git a/references/project-harness/core-change-watch.md b/references/project-harness/core-change-watch.md index fb08aa0..3f52fbb 100644 --- a/references/project-harness/core-change-watch.md +++ b/references/project-harness/core-change-watch.md @@ -12,6 +12,25 @@ the resulting boundary evidence-qualified in visible output: say "inferred core boundary" or "affected core files", and name concrete source paths when they support the claim. +## Project Profile And Current-Work Paths + +Framework labels require framework-specific manifest or convention evidence. +Generic directories such as `app/api` and `app/services` remain useful +language-neutral path signals, but they do not establish Rails, TypeScript, or +another framework by themselves. + +A root `justfile`, `Justfile`, or `.justfile` contributes bounded public recipe +entry candidates. These rows expose an argv-style command and retain +`executionStatus: "unverified"`; collection parses the file statically and does +not run Just or a shell. Private recipes remain outside the command surface. + +Raw bounded history may retain files that no longer exist because those paths +remain valid historical facts. Before `recommendedReads` and follow-up action +file lists are emitted, Core Change Watch intersects their candidates with the +currently present tracked inventory plus currently present changed/untracked +files. Do not interpret a historical hot path as a current edit target unless +it passes that projection boundary. + ## High-Impact Expansion Expand before synthesis for Core, platform, IDE plugin, LSP, auth, tool diff --git a/scripts/core-change-watch/core-candidates.mjs b/scripts/core-change-watch/core-candidates.mjs index 125dd29..3e4b8e9 100644 --- a/scripts/core-change-watch/core-candidates.mjs +++ b/scripts/core-change-watch/core-candidates.mjs @@ -40,7 +40,7 @@ const CORE_PATH_PATTERNS = [ { pattern: /^cmd\/[^/]+/i, score: 14, reason: "go command entrypoint" }, { pattern: /^src\/main\/java\//i, score: 24, reason: "java production source" }, { pattern: /^packages\/[^/]+/i, score: 16, reason: "workspace package" }, - { pattern: /^app\/api(\/|$)/i, score: 20, reason: "typescript api route" }, + { pattern: /^app\/api(\/|$)/i, score: 20, reason: "api route path" }, { pattern: /^src\/(controller|controllers|service|services|entity|repository|security)(\/|$)/i, score: 18, reason: "framework source path" }, { pattern: /^app\/(http|models|services|providers|policies|jobs|console|controllers|models)(\/|$)/i, score: 18, reason: "framework app path" }, { pattern: /^routes(\/|$)/i, score: 12, reason: "framework route path" }, @@ -48,6 +48,7 @@ const CORE_PATH_PATTERNS = [ ]; const FRAMEWORK_PATH_SIGNALS = [ + { framework: "fastapi", pattern: /^app\/api(\/|$)/, score: 18, reason: "fastapi route path" }, { framework: "laravel", pattern: /^app\/(Http|Models|Services|Providers|Policies|Jobs|Console)(\/|$)/, score: 22, reason: "laravel application path" }, { framework: "laravel", pattern: /^routes(\/|$)/, score: 14, reason: "laravel route path" }, { framework: "symfony", pattern: /^src\/(Controller|Entity|Service|Security|Repository|MessageHandler)(\/|$)/, score: 22, reason: "symfony application path" }, diff --git a/scripts/core-change-watch/evidence-pack.mjs b/scripts/core-change-watch/evidence-pack.mjs index f8c56d4..e251298 100644 --- a/scripts/core-change-watch/evidence-pack.mjs +++ b/scripts/core-change-watch/evidence-pack.mjs @@ -1,5 +1,8 @@ #!/usr/bin/env node +import { existsSync } from "node:fs"; +import path from "node:path"; + import { DEFAULT_MAX_COMMITS, SCHEMA_VERSION, @@ -8,6 +11,7 @@ import { isCli, isSupportingFile, languageFor, + listTrackedFiles, normalizeHistoryWindows, normalizeLanguages, option, @@ -23,8 +27,8 @@ import { analyzeDiffImpact } from "./diff-impact.mjs"; import { analyzeGitHistoryProfile } from "./git-history-profile.mjs"; import { analyzeProjectProfile } from "./project-profile.mjs"; -function addRead(reads, path, reason, priority = 50) { - if (!path) { +function addRead(reads, path, reason, priority = 50, currentPaths = null) { + if (!path || (currentPaths && !currentPaths.has(path))) { return; } const role = fileRoleFor(path); @@ -45,32 +49,32 @@ function addRead(reads, path, reason, priority = 50) { }); } -function recommendedReads(core, diff, profile, maxReads) { +function recommendedReads(core, diff, profile, maxReads, currentPaths) { const reads = new Map(); for (const file of diff.changedFiles) { - addRead(reads, file.path, "changed file", 100); + addRead(reads, file.path, "changed file", 100, currentPaths); } for (const hit of diff.coreHits) { - addRead(reads, hit.filePath, `changed core candidate ${hit.path}`, 95); + addRead(reads, hit.filePath, `changed core candidate ${hit.path}`, 95, currentPaths); } for (const hit of diff.hotHits) { - addRead(reads, hit.path, "changed hot file", 90); + addRead(reads, hit.path, "changed hot file", 90, currentPaths); } for (const candidate of core.candidates.slice(0, 8)) { for (const file of candidate.evidence.hotFiles.slice(0, 3)) { - addRead(reads, file.path, `hot file under ${candidate.path}`, 75); + addRead(reads, file.path, `hot file under ${candidate.path}`, 75, currentPaths); } for (const file of candidate.evidence.sourceFiles.slice(0, 2)) { - addRead(reads, file, `representative source under ${candidate.path}`, 55); + addRead(reads, file, `representative source under ${candidate.path}`, 55, currentPaths); } } for (const entry of profile.entryCandidates.slice(0, 5)) { - addRead(reads, entry.path, "entry candidate", 45); + addRead(reads, entry.path, "entry candidate", 45, currentPaths); } return [...reads.values()] @@ -78,12 +82,12 @@ function recommendedReads(core, diff, profile, maxReads) { .slice(0, maxReads); } -function actionFiles(files, limit = 8) { +function actionFiles(files, limit = 8, currentPaths = null) { const seen = new Set(); const result = []; for (const file of files) { const path = typeof file === "string" ? file : file?.path; - if (!path || seen.has(path)) { + if (!path || seen.has(path) || (currentPaths && !currentPaths.has(path))) { continue; } seen.add(path); @@ -99,29 +103,30 @@ function actionFiles(files, limit = 8) { return result; } -function supportingFilesByRole(history, diff, role) { +function supportingFilesByRole(history, diff, role, currentPaths) { const fromDiff = diff.companionHits?.filter((item) => item.role === role) ?? []; const fromHistory = history.supportingHotFiles?.filter((item) => item.role === role) ?? []; - return actionFiles([...fromDiff, ...fromHistory], 8); + return actionFiles([...fromDiff, ...fromHistory], 8, currentPaths); } -function buildFollowUpActions(core, diff, history, changeDrift, maxActions) { +function buildFollowUpActions(core, diff, history, changeDrift, maxActions, currentPaths) { const actions = []; const topCoreFiles = actionFiles(core.candidates.slice(0, 4).flatMap((candidate) => [ ...candidate.evidence.hotFiles, ...candidate.evidence.sourceFiles, - ]), 10); + ]), 10, currentPaths); const changedPrimaryFiles = actionFiles( diff.changedFiles.filter((file) => !file.supporting), 10, + currentPaths, ); - const affectedCoreFiles = actionFiles(diff.coreHits.map((hit) => hit.filePath), 10); - const localizationFiles = supportingFilesByRole(history, diff, "localization"); - const documentationFiles = supportingFilesByRole(history, diff, "documentation"); + const affectedCoreFiles = actionFiles(diff.coreHits.map((hit) => hit.filePath), 10, currentPaths); + const localizationFiles = supportingFilesByRole(history, diff, "localization", currentPaths); + const documentationFiles = supportingFilesByRole(history, diff, "documentation", currentPaths); const testFiles = actionFiles([ ...(history.supportingHotFiles?.filter((item) => item.role === "test" || item.role === "fixture") ?? []), ...diff.changedFiles.filter((item) => item.role === "test" || item.role === "fixture"), - ], 8); + ], 8, currentPaths); if (topCoreFiles.length > 0) { actions.push({ @@ -190,7 +195,7 @@ function buildFollowUpActions(core, diff, history, changeDrift, maxActions) { priority: finding.severity === "high" ? "high" : "medium", title: companionActionTitle(finding), reason: finding.evidence, - files: actionFiles([...finding.triggerFiles, ...finding.candidateCompanionFiles], 8), + files: actionFiles([...finding.triggerFiles, ...finding.candidateCompanionFiles], 8, currentPaths), passCheck: finding.action, }); } @@ -214,6 +219,14 @@ function buildFollowUpActions(core, diff, history, changeDrift, maxActions) { return actions.slice(0, maxActions); } +function currentWorkPaths(repoRoot, diff) { + const candidates = new Set([ + ...listTrackedFiles(repoRoot), + ...diff.changedFiles.map((file) => file.path), + ]); + return new Set([...candidates].filter((filePath) => existsSync(path.resolve(repoRoot, filePath)))); +} + function stageFilters(projectProfile, historyProfile, coreAnalysis, diffImpact) { return { projectProfile: projectProfile.filters, @@ -522,7 +535,15 @@ export async function buildEvidencePack(options = {}) { ignore: options.ignore, changedFiles: diffImpact.changedFiles, }); - const followUpActions = buildFollowUpActions(coreAnalysis, diffImpact, historyProfile, changeDrift, maxFollowUpActions); + const currentPaths = currentWorkPaths(repoRoot, diffImpact); + const followUpActions = buildFollowUpActions( + coreAnalysis, + diffImpact, + historyProfile, + changeDrift, + maxFollowUpActions, + currentPaths, + ); const filters = stageFilters(projectProfile, historyProfile, coreAnalysis, diffImpact); const pack = { schemaVersion: SCHEMA_VERSION, @@ -567,7 +588,7 @@ export async function buildEvidencePack(options = {}) { coreAnalysis, diffImpact, changeDrift, - recommendedReads: recommendedReads(coreAnalysis, diffImpact, projectProfile, maxRecommendedReads), + recommendedReads: recommendedReads(coreAnalysis, diffImpact, projectProfile, maxRecommendedReads, currentPaths), followUpActions, agentGuidance: buildAgentGuidance(historyProfile, coreAnalysis, filters), reviewMatrix: buildReviewMatrix(projectProfile, historyProfile, coreAnalysis, diffImpact, changeDrift, followUpActions), diff --git a/scripts/core-change-watch/project-profile.mjs b/scripts/core-change-watch/project-profile.mjs index a80812c..fcb5114 100644 --- a/scripts/core-change-watch/project-profile.mjs +++ b/scripts/core-change-watch/project-profile.mjs @@ -57,6 +57,8 @@ const SOURCE_FILES_PER_AGENT_INSTRUCTION = 250; const SIGNIFICANT_SOURCE_ROOT_FILES = 100; const SOURCE_LINE_METHOD = "tracked readable primary source files only; excludes tests, non-source assets, generated/dependency paths, and untracked files"; const SOURCE_LINE_SKIPPED_METHOD = "not measured by default; use --measure-source-lines to count tracked readable primary source files only"; +const ROOT_JUSTFILES = new Set(["justfile", "Justfile", ".justfile"]); +const MAX_JUST_RECIPES = 100; function scriptNames(manifest, filePath) { if (!manifest?.scripts || typeof manifest.scripts !== "object") { @@ -137,6 +139,41 @@ function entryScore(filePath) { return 0; } +function justRecipeScore(name) { + const conventional = new Map([ + ["default", 100], + ["sync", 99], + ["api-dev", 98], + ["health", 97], + ["check", 96], + ]); + return conventional.get(name) ?? 70; +} + +function justRecipeNames(text) { + const recipes = []; + let privateRecipe = false; + for (const line of String(text ?? "").split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (!/^\s/u.test(line) && /^\[[^\]]+\]$/u.test(trimmed)) { + privateRecipe = /(?:^|[\s,])private(?:[\s,]|$)/u.test(trimmed.slice(1, -1)); + continue; + } + const match = !/^\s/u.test(line) + ? /^@?([A-Za-z_][A-Za-z0-9_-]*)\b(?!\s*:=)[^:\r\n]*:(?:\s|$)/u.exec(line) + : null; + if (match) { + if (!privateRecipe && !match[1].startsWith("_")) recipes.push(match[1]); + privateRecipe = false; + if (recipes.length >= MAX_JUST_RECIPES) break; + continue; + } + if (!/^\s/u.test(line)) privateRecipe = false; + } + return [...new Set(recipes)]; +} + function corePathHint(filePath, analysisScope) { const localDirectory = analysisDirectoryFor(toAnalysisRelativePath(filePath, analysisScope)); if (/(^|\/)(core|auth|security|permission|permissions|crypto|session|payment|billing|domain|service|api)(\/|$)/i.test(localDirectory)) { @@ -313,6 +350,7 @@ function detectFrameworks(repoRoot, trackedFiles) { || firstManifestWithDependency(composerManifests, "symfony/http-kernel"); const codeIgniterManifest = firstManifestWithDependency(composerManifests, "codeigniter4/framework"); const railsGemfile = firstTextMatch(repoRoot, fileSet, /(^|\/)Gemfile$/, /\bgem\s+["']rails["']/); + const railsRoutes = manifestFiles(fileSet, /(^|\/)config\/routes\.rb$/)[0] ?? ""; const pythonManifestText = joinedManifestText(repoRoot, fileSet, /(^|\/)(requirements\.txt|pyproject\.toml|Pipfile)$/); const javaBuildText = joinedManifestText(repoRoot, fileSet, /(^|\/)(pom\.xml|build\.gradle|build\.gradle\.kts)$/); @@ -331,8 +369,11 @@ function detectFrameworks(repoRoot, trackedFiles) { if (codeIgniterManifest || fileSet.has("app/Config/Routes.php")) { addFrameworkSignal(signals, "codeigniter", codeIgniterManifest ? 70 : 30, codeIgniterManifest ? `${codeIgniterManifest}:codeigniter4/framework` : "app/Config/Routes.php"); } - if (railsGemfile || fileSet.has("config/routes.rb") || hasFileMatching(fileSet, /^app\/(models|controllers|services)\//)) { - addFrameworkSignal(signals, "rails", railsGemfile ? 75 : 35, railsGemfile ? `${railsGemfile}:rails` : "rails app paths"); + if (railsGemfile || railsRoutes) { + addFrameworkSignal(signals, "rails", railsGemfile ? 75 : 45, railsGemfile ? `${railsGemfile}:rails` : railsRoutes); + } + if (/\bfastapi\b/iu.test(pythonManifestText)) { + addFrameworkSignal(signals, "fastapi", 75, "python manifest:fastapi"); } if (/\bdjango\b/i.test(pythonManifestText) || fileSet.has("manage.py") || hasFileMatching(fileSet, /(^|\/)settings\.py$/)) { addFrameworkSignal(signals, "django", /\bdjango\b/i.test(pythonManifestText) ? 70 : 35, /\bdjango\b/i.test(pythonManifestText) ? "python manifest:django" : "manage.py/settings.py"); @@ -526,6 +567,10 @@ function buildProjectInfo({ path: item.path, language: item.language, score: item.score, + ...(item.kind ? { kind: item.kind } : {}), + ...(item.command ? { command: item.command } : {}), + ...(item.sourcePath ? { sourcePath: item.sourcePath } : {}), + ...(item.executionStatus ? { executionStatus: item.executionStatus } : {}), })), manifests: manifests.slice(0, 8), }; @@ -565,6 +610,23 @@ export async function analyzeProjectProfile(options = {}) { continue; } + if (!normalized.includes("/") && ROOT_JUSTFILES.has(normalized)) { + const recipes = justRecipeNames(safeReadText(repoRoot, normalized)); + manifests.push({ path: normalized, kind: "just", scripts: [...recipes].sort() }); + for (const recipe of recipes) { + entryCandidates.push({ + path: normalized, + language: "just", + score: justRecipeScore(recipe), + reasons: compactReasonList(["statically discovered root Just recipe"]), + kind: "just-recipe", + command: ["just", recipe], + sourcePath: normalized, + executionStatus: "unverified", + }); + } + } + if (isManifestFile(normalized)) { const manifest = readJsonFile(repoRoot, normalized); manifests.push({ diff --git a/scripts/harness-analysis/evidence-bundle/index.mjs b/scripts/harness-analysis/evidence-bundle/index.mjs index 208f46f..2390dcb 100644 --- a/scripts/harness-analysis/evidence-bundle/index.mjs +++ b/scripts/harness-analysis/evidence-bundle/index.mjs @@ -1,4 +1,5 @@ import { analyzeHarnessEvidence } from "../report-run.mjs"; +import { validateSessionPopulationBundle } from "../../session-analysis/session-population.mjs"; import { resolveWorkspaceTopology } from "../../workspace-topology/index.mjs"; import { collectAgentCustomize } from "./agent-customize.mjs"; import { @@ -11,7 +12,10 @@ import { unavailableLane, } from "./contract.mjs"; import { collectProjectHarness } from "./project-harness.mjs"; -import { collectSessionEvidence } from "./session-evidence.mjs"; +import { + collectSessionEvidence, + collectSessionPopulation, +} from "./session-evidence.mjs"; async function capture(owner, operation) { try { @@ -27,7 +31,7 @@ async function capture(owner, operation) { } } -async function collectLead(context, options, analyze) { +async function collectLead(context, options, analyze, sessionPopulation) { try { const data = await analyze({ workspace: context.workspace, @@ -36,6 +40,7 @@ async function collectLead(context, options, analyze) { since: context.window.since, until: context.window.until, format: "json", + sessionPopulation, "include-global-capabilities": context.authority.includeUserHome, ...(options["canvas-out"] ? { "canvas-out": options["canvas-out"] } : {}), ...(options["replace-canvas"] ? { "replace-canvas": options["replace-canvas"] } : {}), @@ -54,6 +59,54 @@ async function collectLead(context, options, analyze) { } } +function populationDiagnostics(population, sessionEvidence, lead) { + if (!population?.binding) { + return { status: "unavailable" }; + } + const session = sessionEvidence?.data + ? { + population: sessionEvidence.data.populationBinding, + selection: sessionEvidence.data.selectionBinding, + admission: sessionEvidence.data.admissionBinding, + } + : null; + const leadBinding = lead?.data?.sessionBinding ?? null; + const errors = validateSessionPopulationBundle({ + population: population.binding, + session, + lead: leadBinding, + }); + const sessionEligibleCount = Number(sessionEvidence?.data?.scope?.eligibleSessions ?? -1); + const sessionSelectedCount = Number(sessionEvidence?.data?.scope?.selectedSessions ?? -1); + const leadSelection = lead?.data?.summaryFacts?.evidenceBoundary?.manifest?.selection ?? {}; + if (sessionEligibleCount !== population.binding.eligible.count + || sessionSelectedCount !== session?.selection?.selected?.count) { + errors.push("Session public counts do not match its population binding"); + } + if (Number(leadSelection.eligibleCount ?? -1) !== population.binding.eligible.count + || Number(leadSelection.analyzedCount ?? -1) !== leadBinding?.selection?.selected?.count) { + errors.push("lead public counts do not match its population binding"); + } + const sessionAdmission = session?.admission ?? {}; + const leadAdmission = leadBinding?.admission ?? {}; + const comparable = session?.selection?.selected?.fingerprint === leadBinding?.selection?.selected?.fingerprint + && session?.selection?.projectionPolicyFingerprint === leadBinding?.selection?.projectionPolicyFingerprint; + return { + status: errors.length > 0 ? "conflict" : "bound", + population: population.binding, + sessionSelection: session?.selection ?? null, + leadSelection: leadBinding?.selection ?? null, + episodes: { + comparison: comparable ? "comparable" : "not-comparable-selection-or-policy", + sessionTaskEpisodes: Number(sessionAdmission.taskEpisodes ?? 0), + leadProjectedEpisodes: Number(leadAdmission.projectedEpisodes ?? 0), + leadRetainedEpisodes: Number(leadAdmission.retainedTaskEpisodes ?? 0), + leadZeroSignalDiscardedEpisodes: Number(leadAdmission.zeroSignalDiscardedEpisodes ?? 0), + }, + ...(errors.length > 0 ? { errorCodes: ["SESSION_POPULATION_BINDING_MISMATCH"] } : {}), + }; +} + export async function collectEvidenceBundle(options = {}, dependencies = {}) { const now = dependencies.now?.() ?? new Date(); const baseContext = freezeEvidenceBundleContext(options, now); @@ -73,12 +126,34 @@ export async function collectEvidenceBundle(options = {}, dependencies = {}) { const projectCollector = dependencies.collectProjectHarness ?? collectProjectHarness; const customizeCollector = dependencies.collectAgentCustomize ?? collectAgentCustomize; const leadAnalyzer = dependencies.analyzeHarnessEvidence ?? analyzeHarnessEvidence; - const [sessionEvidence, projectHarness, agentCustomize, lead] = await Promise.all([ - capture("session-evidence", () => sessionCollector(context, options, dependencies)), - capture("project-harness", () => projectCollector(context, options, dependencies)), - capture("agent-customize", () => customizeCollector(context, options, dependencies)), - collectLead(context, options, leadAnalyzer), + const populationCollector = dependencies.collectSessionPopulation ?? collectSessionPopulation; + const projectPromise = capture("project-harness", () => projectCollector(context, options, dependencies)); + const customizePromise = capture("agent-customize", () => customizeCollector(context, options, dependencies)); + const populationLane = await capture("session-population", async () => + availableLane(await populationCollector(context, options, dependencies))); + const sessionPopulation = laneIsAvailable(populationLane) ? populationLane.data : null; + const sessionPromise = sessionPopulation + ? capture("session-evidence", () => sessionCollector(context, options, { + ...dependencies, + sessionPopulation, + })) + : Promise.resolve(unavailableLane("session-evidence", populationLane.error)); + const leadPromise = sessionPopulation + ? collectLead(context, options, leadAnalyzer, sessionPopulation) + : Promise.resolve(unavailableLane("lead-analyzer", populationLane.error)); + let [sessionEvidence, projectHarness, agentCustomize, lead] = await Promise.all([ + sessionPromise, + projectPromise, + customizePromise, + leadPromise, ]); + const sessionPopulationBinding = populationDiagnostics(sessionPopulation, sessionEvidence, lead); + if (sessionPopulationBinding.status === "conflict") { + lead = unavailableLane("lead-analyzer", Object.assign( + new Error("Session population binding mismatch"), + { code: "SESSION_POPULATION_BINDING_MISMATCH" }, + )); + } const lanes = { sessionEvidence, projectHarness, agentCustomize }; const incompleteLanes = EVIDENCE_LANE_NAMES.filter((name) => !laneIsAvailable(lanes[name])); const unavailableLanes = EVIDENCE_LANE_NAMES.filter((name) => lanes[name]?.status === "unavailable"); @@ -108,6 +183,7 @@ export async function collectEvidenceBundle(options = {}, dependencies = {}) { topologyStatus: context.topology.status, topologyIncomplete, individualCommandsRemainDiagnostic: true, + sessionPopulationBinding, }, }; } diff --git a/scripts/harness-analysis/evidence-bundle/session-evidence.mjs b/scripts/harness-analysis/evidence-bundle/session-evidence.mjs index 9047d72..71681c8 100644 --- a/scripts/harness-analysis/evidence-bundle/session-evidence.mjs +++ b/scripts/harness-analysis/evidence-bundle/session-evidence.mjs @@ -1,4 +1,9 @@ import { createAnalyzer } from "../../session-analysis.mjs"; +import { + bindSessionSelection, + freezeSessionPopulation, + sessionAdmissionBinding, +} from "../../session-analysis/session-population.mjs"; import { availableLane } from "./contract.mjs"; const SOURCE_COVERAGE_STATES = new Set([ @@ -9,21 +14,60 @@ const SOURCE_COVERAGE_STATES = new Set([ "observed", ]); +function analyzerOptions(context, options = {}) { + return { + workspace: context.workspace, + platform: context.provider, + since: context.window.since, + until: context.window.until, + ...(options[`${context.provider}-home`] ? { [`${context.provider}-home`]: options[`${context.provider}-home`] } : {}), + }; +} + +export async function collectSessionPopulation(context, options = {}, dependencies = {}) { + const create = dependencies.createAnalyzer ?? createAnalyzer; + const analyzer = await create(context.provider); + const runOptions = analyzerOptions(context, options); + const discovery = await analyzer.analyze({ ...runOptions, command: "sources" }); + if (!Array.isArray(discovery?.sessions)) { + throw Object.assign(new Error("Session population discovery returned an invalid contract"), { + code: "INVALID_SESSION_POPULATION", + }); + } + const scope = { + ...(discovery.scope ?? {}), + platform: context.provider, + workspace: context.workspace, + since: context.window.since, + until: context.window.until, + }; + return freezeSessionPopulation({ + scope, + sessions: discovery.sessions, + discovery: { + scope, + sources: discovery.sources, + warnings: discovery.warnings, + }, + excludedSessionId: options["exclude-session-id"] ?? options.excludeSessionId, + providerSessionId: typeof analyzer.currentSessionId === "function" ? analyzer.currentSessionId() : null, + suppliedUntil: true, + }); +} + export async function collectSessionEvidence(context, options = {}, dependencies = {}) { const create = dependencies.createAnalyzer ?? createAnalyzer; const analyzer = await create(context.provider); + const population = dependencies.sessionPopulation ?? null; const data = await analyzer.analyze({ command: "facts", - workspace: context.workspace, - platform: context.provider, + ...analyzerOptions(context, options), selection: "all-eligible", - since: context.window.since, - until: context.window.until, limit: context.evidenceLimit, "episode-limit": context.evidenceLimit, + ...(population ? { sessionInventory: population.sessions } : {}), topology: context.topology, analysisScope: context.analysisScope, - ...(options[`${context.provider}-home`] ? { [`${context.provider}-home`]: options[`${context.provider}-home`] } : {}), }); if (data?.kind !== "session-core-facts" || !Array.isArray(data.candidates)) { throw Object.assign(new Error("session facts returned an invalid contract"), { @@ -39,5 +83,29 @@ export async function collectSessionEvidence(context, options = {}, dependencies const laneStatus = ["unobserved", "partial"].includes(sourceCoverageStatus) ? "partial" : "available"; - return availableLane(data, laneStatus); + if (!population) return availableLane(data, laneStatus); + const eligibleCount = Number(data.scope?.eligibleSessions ?? -1); + const selectedCount = Number(data.scope?.selectedSessions ?? -1); + if (eligibleCount !== population.binding.eligible.count + || selectedCount !== population.binding.eligible.count) { + throw Object.assign(new Error("Session facts do not match the frozen all-eligible population"), { + code: "SESSION_POPULATION_BINDING_MISMATCH", + }); + } + const selectionBinding = bindSessionSelection(population, population.sessions, { + strategy: "all-eligible", + projectionPolicy: "session-fact-candidates-v2", + }); + const boundData = { + ...data, + omitted: { + ...(data.omitted ?? {}), + activeSessions: population.binding.omission.activeSessions, + homeSessionOnly: population.binding.omission.homeSessionOnly, + }, + populationBinding: population.binding, + selectionBinding, + }; + boundData.admissionBinding = sessionAdmissionBinding(boundData, selectionBinding); + return availableLane(boundData, laneStatus); } diff --git a/scripts/harness-analysis/renderers/html.mjs b/scripts/harness-analysis/renderers/html.mjs index a98000a..482a3ee 100644 --- a/scripts/harness-analysis/renderers/html.mjs +++ b/scripts/harness-analysis/renderers/html.mjs @@ -11,6 +11,24 @@ function escapeHtml(value) { .replace(/'/gu, "'"); } +const CJK_PHRASE = /^\p{Script=Han}{2,8}$/u; + +function renderVisibleText(value, language) { + const text = String(value ?? ""); + if (language !== "zh-CN" || typeof Intl.Segmenter !== "function") return escapeHtml(text); + try { + const segments = new Intl.Segmenter("zh-CN", { granularity: "word" }).segment(text); + return [...segments].map(({ segment, isWordLike }) => { + const escaped = escapeHtml(segment); + return isWordLike && CJK_PHRASE.test(segment) + ? `${escaped}` + : escaped; + }).join(""); + } catch { + return escapeHtml(text); + } +} + function list(value) { return Array.isArray(value) ? value : []; } @@ -87,11 +105,11 @@ function stateTone(state) { return "muted"; } -function metric(label, value, note = "") { +function metric(label, value, note = "", language = "en") { return `
- ${escapeHtml(label)} - ${escapeHtml(value)} - ${note ? `${escapeHtml(note)}` : ""} + ${renderVisibleText(label, language)} + ${renderVisibleText(value, language)} + ${note ? `${renderVisibleText(note, language)}` : ""}
`; } @@ -120,11 +138,11 @@ function evidenceScoreLabel(summary, language) { return copy(language, "Loop Effectiveness", "循环有效性"); } -function section(id, eyebrow, title, body, note = "") { +function section(id, eyebrow, title, body, note = "", language = "en") { return `
-
${escapeHtml(eyebrow)}

${escapeHtml(title)}

- ${note ? `

${escapeHtml(note)}

` : ""} +
${renderVisibleText(eyebrow, language)}

${renderVisibleText(title, language)}

+ ${note ? `

${renderVisibleText(note, language)}

` : ""}
${body}
`; @@ -133,17 +151,19 @@ function section(id, eyebrow, title, body, note = "") { function renderDimensionGrid(summary, language) { const dimensions = list(summary?.dimensions); if (dimensions.length === 0) { - return `

${copy(language, "No reviewed dimension scores are available.", "暂无已复核的维度分数。")}

`; + return `

${renderVisibleText(copy(language, "No reviewed dimension scores are available.", "暂无已复核的维度分数。"), language)}

`; } return `
${dimensions.map((row, index) => { const score = clamp(row.score); + const roundedScore = Math.round(score); + const label = row.label ?? row.id ?? `Dimension ${index + 1}`; const state = row.state ?? copy(language, "Reviewed", "已复核"); return `
-
${escapeHtml(row.label ?? row.id ?? `Dimension ${index + 1}`)}${escapeHtml(state)}
-
${Math.round(score)}/ 100
-
-

${escapeHtml(row.summary ?? row.scoreReason ?? "")}

+
${renderVisibleText(label, language)}${renderVisibleText(state, language)}
+
${roundedScore}/ 100
+
+

${renderVisibleText(row.summary ?? row.scoreReason ?? "", language)}

`; }).join("\n")}
`; @@ -179,12 +199,12 @@ function renderActivity(summary, language) { return `
- + ${dates.length > 0 ? `
${escapeHtml(dates[0])}${escapeHtml(dates.at(-1))}
` : ""}
- ${metric(copy(language, "Sessions reviewed", "已分析会话"), `${formatNumber(analyzed, language)} / ${formatNumber(eligible, language)}`, copy(language, "all-eligible usage census", "全量合格用量普查"))} - ${metric(copy(language, "Long-session leads", "长会话线索"), formatNumber(longCount, language), copy(language, `longest ${formatNumber(longest, language)} min`, `最长 ${formatNumber(longest, language)} 分钟`))} + ${metric(copy(language, "Sessions reviewed", "已分析会话"), `${formatNumber(analyzed, language)} / ${formatNumber(eligible, language)}`, copy(language, "all-eligible usage census", "全量合格用量普查"), language)} + ${metric(copy(language, "Long-session leads", "长会话线索"), formatNumber(longCount, language), copy(language, `longest ${formatNumber(longest, language)} min`, `最长 ${formatNumber(longest, language)} 分钟`), language)}
`; } @@ -197,22 +217,22 @@ function renderUsageLists(summary, language) { const renderRows = (rows, labelKey) => { const top = rows.slice().sort((a, b) => number(b.total ?? b.responseCount) - number(a.total ?? a.responseCount)).slice(0, 6); const max = Math.max(1, ...top.map((row) => number(row.total ?? row.responseCount))); - if (top.length === 0) return `

${copy(language, "No attributed rows were retained.", "没有保留已归属数据。")}

`; + if (top.length === 0) return `

${renderVisibleText(copy(language, "No attributed rows were retained.", "没有保留已归属数据。"), language)}

`; return `
${top.map((row) => { const value = number(row.total ?? row.responseCount); - return `
${escapeHtml(row[labelKey] ?? row.name ?? "Unknown")}${formatNumber(value, language)}
`; + return `
${renderVisibleText(row[labelKey] ?? row.name ?? "Unknown", language)}${formatNumber(value, language)}
`; }).join("")}
`; }; return `
-

${copy(language, "Model usage", "模型使用")}

${renderRows(modelRows, "model")}
-

${copy(language, "Skill usage", "Skill 使用")}

${renderRows(skillRows, "name")}
+

${renderVisibleText(copy(language, "Model usage", "模型使用"), language)}

${renderRows(modelRows, "model")}
+

${renderVisibleText(copy(language, "Skill usage", "Skill 使用"), language)}

${renderRows(skillRows, "name")}
`; } function renderFindings(reportData, language) { const dimensions = new Map(list(reportData.summary?.dimensions).map((row) => [row.id, row.label ?? row.id])); const findings = list(reportData.findings); - if (findings.length === 0) return `

${copy(language, "No reviewed findings were retained.", "没有保留已复核 finding。")}

`; + if (findings.length === 0) return `

${renderVisibleText(copy(language, "No reviewed findings were retained.", "没有保留已复核 finding。"), language)}

`; return `
${findings.map((row, index) => { const prompt = findingPromptParts(row.aiFixPrompt, language); const expected = textLines(row.expectedOutput ?? row.expectedOutcome); @@ -227,27 +247,27 @@ function renderFindings(reportData, language) { return `
- ${escapeHtml(row.severity ?? "Low")} - ${refs[0] ? `${escapeHtml(refs[0])}` : ""} + ${renderVisibleText(row.severity ?? "Low", language)} + ${refs[0] ? `${renderVisibleText(refs[0], language)}` : ""}
-

${escapeHtml(title)}

-

${escapeHtml(row.reason ?? "")}

+

${renderVisibleText(title, language)}

+

${renderVisibleText(row.reason ?? "", language)}

- - + +
-
${copy(language, "Finding details", "Finding 详情")}

${escapeHtml(title)}

- ${escapeHtml(row.severity ?? "Low")} +
${renderVisibleText(copy(language, "Finding details", "Finding 详情"), language)}

${renderVisibleText(title, language)}

+ ${renderVisibleText(row.severity ?? "Low", language)}
-
${copy(language, "Cause", "原因")}

${escapeHtml(row.reason ?? "")}

- ${expected.length ? `
${copy(language, "Expected Output", "预期结果")}
    ${expected.map((item) => `
  1. ${escapeHtml(item)}
  2. `).join("")}
` : ""} - ${prompt.checks.length ? `
${copy(language, "Acceptance Checks", "验收检查")}
    ${prompt.checks.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
` : ""} +
${renderVisibleText(copy(language, "Cause", "原因"), language)}

${renderVisibleText(row.reason ?? "", language)}

+ ${expected.length ? `
${renderVisibleText(copy(language, "Expected Output", "预期结果"), language)}
    ${expected.map((item) => `
  1. ${renderVisibleText(item, language)}
  2. `).join("")}
` : ""} + ${prompt.checks.length ? `
${renderVisibleText(copy(language, "Acceptance Checks", "验收检查"), language)}
    ${prompt.checks.map((item) => `
  • ${renderVisibleText(item, language)}
  • `).join("")}
` : ""}
- - + +
`; @@ -258,20 +278,20 @@ function renderSuggestions(summary, language) { const suggestions = list(summary?.suggestions); if (suggestions.length === 0) return ""; return `
-
${copy(language, "Suggestions", "建议")}

${copy(language, "Useful next experiments", "值得尝试的下一步")}

${copy(language, "Advisory only · no AI Fix action", "仅供参考 · 不包含 AI 修复动作")}

+
${renderVisibleText(copy(language, "Suggestions", "建议"), language)}

${renderVisibleText(copy(language, "Useful next experiments", "值得尝试的下一步"), language)}

${renderVisibleText(copy(language, "Advisory only · no AI Fix action", "仅供参考 · 不包含 AI 修复动作"), language)}

${suggestions.map((row, index) => { const prerequisites = textLines(row.prerequisites); const blockedBy = textLines(row.blockedBy); return `
-
${escapeHtml(suggestionKindLabel(row.kind, language))}${escapeHtml(row.confidence)}
-

${escapeHtml(row.title ?? row.id ?? copy(language, "Suggestion", "建议"))}

-

${escapeHtml(row.reason)}

+
${renderVisibleText(suggestionKindLabel(row.kind, language), language)}${renderVisibleText(row.confidence, language)}
+

${renderVisibleText(row.title ?? row.id ?? copy(language, "Suggestion", "建议"), language)}

+

${renderVisibleText(row.reason, language)}

-
${copy(language, "Owner", "负责人")}
${escapeHtml(row.owner)}
-
${copy(language, "Next step", "下一步")}
${escapeHtml(row.nextStep)}
-
${copy(language, "Validation", "验证")}
${escapeHtml(row.validation)}
- ${prerequisites.length ? `
${copy(language, "Prerequisites", "前置条件")}
${prerequisites.map(escapeHtml).join(" · ")}
` : ""} - ${blockedBy.length ? `
${copy(language, "Blocked by", "阻塞项")}
${blockedBy.map(escapeHtml).join(" · ")}
` : ""} +
${renderVisibleText(copy(language, "Owner", "负责人"), language)}
${renderVisibleText(row.owner, language)}
+
${renderVisibleText(copy(language, "Next step", "下一步"), language)}
${renderVisibleText(row.nextStep, language)}
+
${renderVisibleText(copy(language, "Validation", "验证"), language)}
${renderVisibleText(row.validation, language)}
+ ${prerequisites.length ? `
${renderVisibleText(copy(language, "Prerequisites", "前置条件"), language)}
${prerequisites.map((item) => renderVisibleText(item, language)).join(" · ")}
` : ""} + ${blockedBy.length ? `
${renderVisibleText(copy(language, "Blocked by", "阻塞项"), language)}
${blockedBy.map((item) => renderVisibleText(item, language)).join(" · ")}
` : ""}
`; }).join("\n")}
@@ -284,18 +304,18 @@ function renderCustomize(summary, language) { const rows = list(practice.coverageRows); const bySurface = new Map(rows.map((row) => [row.surface, row])); const surfaces = [...new Set([...inspected, ...rows.map((row) => row.surface).filter(Boolean)])]; - if (surfaces.length === 0) return `

${copy(language, "No project customization inventory was retained.", "没有保留项目自定义能力清单。")}

`; + if (surfaces.length === 0) return `

${renderVisibleText(copy(language, "No project customization inventory was retained.", "没有保留项目自定义能力清单。"), language)}

`; return `
${surfaces.map((surface) => { const row = bySurface.get(surface) ?? {}; const scopes = list(row.scopes); const count = row.count; - return `
${escapeHtml(String(surface).slice(0, 1).toUpperCase())}

${escapeHtml(surface)}

${scopes.length ? escapeHtml(scopes.join(" · ")) : copy(language, "Inspected", "已检查")}${count !== undefined ? ` · ${formatNumber(count, language)}` : ""}

`; + return `
${renderVisibleText(String(surface).slice(0, 1).toUpperCase(), language)}

${renderVisibleText(surface, language)}

${scopes.length ? renderVisibleText(scopes.join(" · "), language) : renderVisibleText(copy(language, "Inspected", "已检查"), language)}${count !== undefined ? ` · ${formatNumber(count, language)}` : ""}

`; }).join("")}
`; } function renderEvidence(summary, language) { const boundary = summary?.evidenceBoundary ?? {}; - const coverage = summary?.atAGlance?.coverage ?? {}; + const coverage = boundary?.episodeCoverage ?? summary?.atAGlance?.coverage ?? {}; const selection = boundary?.manifest?.selection ?? coverage?.selection ?? {}; const learning = summary?.learningCapture ?? {}; const facts = [ @@ -307,11 +327,11 @@ function renderEvidence(summary, language) { [copy(language, "Learning state", "学习状态"), learning.state ?? "—"], ]; const gaps = textLines(boundary.sourceGaps); - return `
${facts.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join("")}
- ${gaps.length ? `
${copy(language, "Source gaps", "来源缺口")}
    ${gaps.map((gap) => `
  • ${escapeHtml(gap)}
  • `).join("")}
` : ""} -

${copy(language, + return `

${facts.map(([label, value]) => `
${renderVisibleText(label, language)}${renderVisibleText(value, language)}
`).join("")}
+ ${gaps.length ? `
${renderVisibleText(copy(language, "Source gaps", "来源缺口"), language)}
    ${gaps.map((gap) => `
  • ${renderVisibleText(gap, language)}
  • `).join("")}
` : ""} +

${renderVisibleText(copy(language, "Activity totals describe volume, not quality or savings. Fluency conclusions come from the reviewed task sample and remain bounded by the retained evidence.", - "活动总量只描述用量,不直接代表质量、效率或节省。流畅度结论来自已复核任务样本,并受已保留证据边界约束。")}

`; + "活动总量只描述用量,不直接代表质量、效率或节省。流畅度结论来自已复核任务样本,并受已保留证据边界约束。"), language)}

`; } function renderHtmlBody(reportData) { @@ -335,24 +355,24 @@ function renderHtmlBody(reportData) { return `
- ${copy(language, "Harness Insights · Codex HTML", "Harness 洞察 · Codex HTML")} -

${escapeHtml(projectName)}

-

${escapeHtml(overview)}

-
${escapeHtml(summary.modelId ?? "Harness")}${copy(language, "Evidence-bound", "证据有界")}
+ ${renderVisibleText(copy(language, "Harness Insights · Codex HTML", "Harness 洞察 · Codex HTML"), language)} +

${renderVisibleText(projectName, language)}

+

${renderVisibleText(overview, language)}

+
${renderVisibleText(summary.modelId ?? "Harness", language)}${renderVisibleText(copy(language, "Evidence-bound", "证据有界"), language)}
-
${dimensions.length}${copy(language, "reviewed dimensions", "独立复核维度")}
+
${dimensions.length}${renderVisibleText(copy(language, "reviewed dimensions", "独立复核维度"), language)}
- ${metric(evidenceScoreLabel(summary, language), `${effectiveness} / 100`, copy(language, "Changes after later task outcomes", "等待后续任务结果后更新"))} - ${metric(copy(language, "Asset Health / Repair Progress", "资产健康 / 修复进度"), `${progress.score} / 100`, copy(language, `${progress.verified} verified · ${progress.partial} partial · ${progress.pending} pending`, `${progress.verified} 项已验证 · ${progress.partial} 项部分完成 · ${progress.pending} 项待处理`))} - ${metric(copy(language, "Sessions analyzed", "会话样本"), `${formatNumber(analyzed, language)} / ${formatNumber(eligible, language)}`, selection.confidence ?? "")} - ${metric(copy(language, "Findings", "待处理 Finding"), findings.length, `${high} High · ${medium} Medium`)} + ${metric(evidenceScoreLabel(summary, language), `${effectiveness} / 100`, copy(language, "Changes after later task outcomes", "等待后续任务结果后更新"), language)} + ${metric(copy(language, "Asset Health / Repair Progress", "资产健康 / 修复进度"), `${progress.score} / 100`, copy(language, `${progress.verified} verified · ${progress.partial} partial · ${progress.pending} pending`, `${progress.verified} 项已验证 · ${progress.partial} 项部分完成 · ${progress.pending} 项待处理`), language)} + ${metric(copy(language, "Sessions analyzed", "会话样本"), `${formatNumber(analyzed, language)} / ${formatNumber(eligible, language)}`, selection.confidence ?? "", language)} + ${metric(copy(language, "Findings", "待处理 Finding"), findings.length, `${high} High · ${medium} Medium`, language)}
- ${section("fluency", copy(language, "01 · Readiness", "01 · 就绪度"), copy(language, "Five-dimension fluency", "五维流畅度"), renderDimensionGrid(summary, language), copy(language, "Scores and states come from the reviewed source.", "分数与状态来自已复核 source。"))} - ${hasUsage ? section("activity", copy(language, "02 · Signals", "02 · 信号"), copy(language, "Project usage", "项目用量"), `${renderActivity(summary, language)}${renderUsageLists(summary, language)}`, copy(language, "Volume is context, not an outcome claim.", "用量是背景,不是结果结论。")) : ""} - ${section("findings", copy(language, "03 · Action", "03 · 行动"), copy(language, "Findings and recommendations", "Finding 与建议"), `${renderFindings(reportData, language)}${suggestions.length ? renderSuggestions(summary, language) : ""}`, taskLoopReport ? copy(language, `${findings.length} findings · ${suggestions.length} suggestions`, `${findings.length} 条 finding · ${suggestions.length} 条建议`) : copy(language, `${findings.length} reviewed items`, `${findings.length} 条已复核项`))} - ${section("customize", copy(language, "04 · Capability", "04 · 能力"), copy(language, "Agent Customize", "Agent 自定义"), renderCustomize(summary, language), copy(language, "Inspected project and authorized host surfaces.", "已检查的项目与授权宿主能力面。"))} - ${section("methodology", copy(language, "05 · Boundary", "05 · 边界"), copy(language, "Evidence and methodology", "证据与方法"), renderEvidence(summary, language), copy(language, "Reader-safe evidence only.", "仅使用读者安全证据。"))}`; + ${section("fluency", copy(language, "01 · Readiness", "01 · 就绪度"), copy(language, "Five-dimension fluency", "五维流畅度"), renderDimensionGrid(summary, language), copy(language, "Scores and states come from the reviewed source.", "分数与状态来自已复核 source。"), language)} + ${hasUsage ? section("activity", copy(language, "02 · Signals", "02 · 信号"), copy(language, "Project usage", "项目用量"), `${renderActivity(summary, language)}${renderUsageLists(summary, language)}`, copy(language, "Volume is context, not an outcome claim.", "用量是背景,不是结果结论。"), language) : ""} + ${section("findings", copy(language, "03 · Action", "03 · 行动"), copy(language, "Findings and recommendations", "Finding 与建议"), `${renderFindings(reportData, language)}${suggestions.length ? renderSuggestions(summary, language) : ""}`, taskLoopReport ? copy(language, `${findings.length} findings · ${suggestions.length} suggestions`, `${findings.length} 条 finding · ${suggestions.length} 条建议`) : copy(language, `${findings.length} reviewed items`, `${findings.length} 条已复核项`), language)} + ${section("customize", copy(language, "04 · Capability", "04 · 能力"), copy(language, "Agent Customize", "Agent 自定义"), renderCustomize(summary, language), copy(language, "Inspected project and authorized host surfaces.", "已检查的项目与授权宿主能力面。"), language)} + ${section("methodology", copy(language, "05 · Boundary", "05 · 边界"), copy(language, "Evidence and methodology", "证据与方法"), renderEvidence(summary, language), copy(language, "Reader-safe evidence only.", "仅使用读者安全证据。"), language)}`; } export function renderHtml(reportData) { @@ -369,6 +389,7 @@ export function renderHtml(reportData) { :root { color-scheme: dark; --bg:#111315; --panel:#1a1d20; --panel-2:#21252a; --line:#31363d; --text:#f5f7fa; --muted:#9aa3ad; --blue:#6cb8ff; --blue-2:#1b6ca8; --orange:#ff9a5a; --green:#53d69b; --red:#ff6b6b; } * { box-sizing:border-box; } html { background:var(--bg); scroll-behavior:smooth; } + .cjk-phrase { white-space:nowrap; } body { margin:0; min-width:320px; font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--text); background:radial-gradient(circle at 12% -10%,#22364a 0,transparent 34rem),var(--bg); line-height:1.55; } .sr-only { position:absolute!important; width:1px!important; height:1px!important; padding:0!important; margin:-1px!important; overflow:hidden!important; clip:rect(0,0,0,0)!important; white-space:nowrap!important; border:0!important; } body::before { content:""; position:fixed; inset:0; pointer-events:none; opacity:.22; background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px); background-size:28px 28px; } @@ -381,12 +402,12 @@ export function renderHtml(reportData) { .hero-tags { display:flex; flex-wrap:wrap; gap:8px; } .score-orbit { width:174px; aspect-ratio:1; display:grid; place-content:center; text-align:center; border:14px solid #343a41; border-top-color:var(--blue); border-right-color:var(--blue-2); border-radius:50%; background:#1a2026; box-shadow:inset 0 0 0 1px var(--line); } .score-orbit strong { font-size:44px; line-height:1; } - .score-orbit span { width:110px; margin-top:7px; color:var(--muted); font-size:12px; } + .score-orbit > span { width:110px; margin-top:7px; color:var(--muted); font-size:12px; } .pill { display:inline-flex; align-items:center; min-height:26px; padding:3px 10px; border-radius:999px; color:#d4dae0; background:#30353b; font-size:12px; font-weight:700; } .pill.accent { color:#c9e8ff; background:#164e75; }.pill.good { color:#c9f9e5; background:#165c43; }.pill.warning { color:#ffe1c8; background:#754322; }.pill.danger { color:#ffd4d4; background:#762e31; }.pill.muted,.pill.neutral { color:#c8cdd3; background:#34383d; } .metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin:18px 0 54px; } .metric { min-height:126px; padding:22px 24px; border:1px solid var(--line); border-radius:20px; background:rgba(30,34,38,.9); } - .metric span,.metric small { display:block; color:var(--muted); }.metric strong { display:block; margin:5px 0 2px; font-size:34px; line-height:1.1; }.metric small { font-size:12px; } + .metric > span,.metric > small { display:block; color:var(--muted); }.metric > strong { display:block; margin:5px 0 2px; font-size:34px; line-height:1.1; }.metric > small { font-size:12px; } .section { margin-top:64px; scroll-margin-top:24px; }.section-heading { display:flex; justify-content:space-between; gap:30px; align-items:end; margin-bottom:20px; }.section-heading h2 { margin:5px 0 0; font-size:30px; letter-spacing:-.025em; }.section-heading > p { max-width:420px; margin:0; color:var(--muted); text-align:right; font-size:14px; } .dimension-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:12px; }.dimension-card { min-height:230px; padding:19px; border:1px solid var(--line); border-radius:19px; background:linear-gradient(180deg,var(--panel-2),var(--panel)); }.dimension-top { display:flex; min-height:52px; justify-content:space-between; gap:8px; align-items:flex-start; font-weight:700; }.score-line { display:flex; align-items:baseline; gap:4px; margin-top:14px; }.score-line strong { font-size:34px; }.score-line span { color:var(--muted); font-size:12px; }.track { height:8px; margin:8px 0 16px; overflow:hidden; border-radius:999px; background:#30353a; }.track i { display:block; height:100%; border-radius:inherit; background:linear-gradient(90deg,var(--blue-2),var(--blue)); }.dimension-card p { margin:0; color:var(--muted); font-size:13px; } .activity-layout { display:grid; grid-template-columns:minmax(0,1.8fr) minmax(230px,.7fr); gap:14px; }.activity-panel,.subpanel,.finding,.custom-grid article,.evidence-grid,.evidence-note { border:1px solid var(--line); border-radius:19px; background:var(--panel); }.activity-panel { padding:22px; overflow:hidden; }.heatmap { display:grid; grid-template-rows:repeat(7,13px); grid-auto-flow:column; grid-auto-columns:13px; gap:4px; min-height:115px; overflow-x:auto; padding-bottom:8px; }.heat-cell { border-radius:3px; background:#2b3035; }.heat-cell.l1{background:#174d70}.heat-cell.l2{background:#206f9e}.heat-cell.l3{background:#319bd1}.heat-cell.l4{background:#70c9ff}.heat-legend { display:flex; justify-content:space-between; color:var(--muted); font-size:11px; }.stacked-metrics { display:grid; gap:14px; }.stacked-metrics .metric { min-height:0; } @@ -394,7 +415,7 @@ export function renderHtml(reportData) { .finding-list { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }.finding-card { min-width:0; min-height:220px; display:flex; flex-direction:column; padding:18px; border:1px solid var(--line); border-radius:19px; background:linear-gradient(180deg,#1c2329,var(--panel)); }.finding-card-main { display:grid; gap:10px; }.finding-meta { display:flex; flex-wrap:wrap; gap:7px; }.finding-card h3 { min-height:2.8em; margin:0; display:-webkit-box; overflow:hidden; font-size:17px; line-height:1.4; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.finding-preview { margin:0; color:#c7ced6; display:-webkit-box; overflow:hidden; font-size:13px; line-height:1.55; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.finding-actions,.dialog-actions { display:flex; flex-wrap:wrap; justify-content:space-between; gap:10px; margin-top:auto; padding-top:16px; }.action-button { min-height:38px; padding:8px 13px; border:1px solid transparent; border-radius:10px; color:var(--text); background:#30363d; font:inherit; font-size:13px; font-weight:700; cursor:pointer; }.action-button:hover { filter:brightness(1.12); }.action-button:focus-visible { outline:2px solid var(--blue); outline-offset:2px; }.action-button.secondary { color:#cceaff; border-color:#286d9b; background:#164e75; }.action-button.ghost { color:#d4dae0; border-color:var(--line); background:transparent; }.finding-dialog,.manual-copy-dialog { width:min(880px,calc(100vw - 32px)); max-height:calc(100vh - 32px); overflow:auto; padding:24px; border:1px solid var(--line); border-radius:20px; color:var(--text); background:var(--panel); box-shadow:0 30px 90px rgba(0,0,0,.55); }.finding-dialog::backdrop,.manual-copy-dialog::backdrop { background:rgba(4,8,12,.72); }.dialog-heading { display:flex; justify-content:space-between; gap:18px; align-items:flex-start; }.dialog-heading h3 { margin:6px 0 0; font-size:24px; }.dialog-section { margin-top:18px; }.dialog-section p { margin:7px 0 0; color:#d1d7de; }.dialog-section ol,.dialog-section ul { margin:8px 0 0; padding-left:22px; color:#d1d7de; }.manual-copy-dialog textarea { width:100%; min-height:260px; margin-top:14px; padding:14px; resize:vertical; border:1px solid var(--line); border-radius:12px; color:var(--text); background:#11161b; font:13px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; }.no-js .finding-actions,.no-js .manual-copy-dialog { display:none!important; }.no-js .finding-dialog:not([open]) { display:block; position:static; width:auto; max-height:none; margin-top:16px; box-shadow:none; } .suggestion-block { margin-top:28px; padding-top:24px; border-top:1px solid var(--line); }.suggestion-heading { display:flex; justify-content:space-between; gap:20px; align-items:end; margin-bottom:14px; }.suggestion-heading h3 { margin:4px 0 0; font-size:20px; }.suggestion-heading p { margin:0; color:var(--muted); font-size:12px; }.suggestion-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:12px; }.suggestion { padding:18px; border:1px solid var(--line); border-radius:17px; background:linear-gradient(180deg,#1b252d,var(--panel)); }.suggestion-top { display:flex; justify-content:space-between; gap:8px; }.suggestion h3 { margin:13px 0 7px; font-size:16px; }.suggestion > p { color:#c7ced6; font-size:13px; }.suggestion dl { display:grid; gap:9px; margin:14px 0 0; }.suggestion dl div { display:grid; gap:2px; }.suggestion dt { color:var(--muted); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }.suggestion dd { margin:0; color:#d7dde4; font-size:12px; } .custom-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:12px; }.custom-grid article { display:flex; gap:14px; align-items:center; padding:18px; }.custom-grid h3 { margin:0; font-size:15px; }.custom-grid p { margin:3px 0 0; color:var(--muted); font-size:12px; }.custom-mark { display:grid; flex:0 0 38px; aspect-ratio:1; place-content:center; border-radius:12px; color:#d9efff; background:#164e75; font-weight:800; } - .evidence-grid { display:grid; grid-template-columns:repeat(3,1fr); overflow:hidden; }.evidence-grid div { padding:20px; border-right:1px solid var(--line); border-bottom:1px solid var(--line); }.evidence-grid span,.evidence-grid strong { display:block; }.evidence-grid span { color:var(--muted); font-size:12px; }.evidence-grid strong { margin-top:4px; }.evidence-note { margin-top:14px; padding:20px; }.method-note,.empty { color:var(--muted); }.method-note { max-width:800px; margin:18px 0 0; font-size:13px; } + .evidence-grid { display:grid; grid-template-columns:repeat(3,1fr); overflow:hidden; }.evidence-grid div { padding:20px; border-right:1px solid var(--line); border-bottom:1px solid var(--line); }.evidence-grid > div > span,.evidence-grid > div > strong { display:block; }.evidence-grid > div > span { color:var(--muted); font-size:12px; }.evidence-grid > div > strong { margin-top:4px; }.evidence-note { margin-top:14px; padding:20px; }.method-note,.empty { color:var(--muted); }.method-note { max-width:800px; margin:18px 0 0; font-size:13px; } footer { margin-top:64px; padding-top:20px; border-top:1px solid var(--line); color:var(--muted); font-size:12px; } @media (max-width:1000px) { .finding-list{grid-template-columns:repeat(2,minmax(0,1fr))} } @media (max-width:900px) { .metrics,.dimension-grid{grid-template-columns:repeat(2,1fr)}.activity-layout,.two-column{grid-template-columns:1fr}.evidence-grid{grid-template-columns:repeat(2,1fr)} } @@ -407,19 +428,19 @@ export function renderHtml(reportData) {
${renderHtmlBody(reportData)} -
${copy(reportData.language, "Generated from one reviewed Harness source · self-contained Codex HTML", "由同一份已复核 Harness source 生成 · 自包含 Codex HTML")}
+
${renderVisibleText(copy(reportData.language, "Generated from one reviewed Harness source · self-contained Codex HTML", "由同一份已复核 Harness source 生成 · 自包含 Codex HTML"), language)}
- ${copy(reportData.language, "Manual fallback", "手动复制")} -

${copy(reportData.language, "Copy the AI Fix prompt", "复制 AI 修复提示词")}

+ ${renderVisibleText(copy(reportData.language, "Manual fallback", "手动复制"), language)} +

${renderVisibleText(copy(reportData.language, "Copy the AI Fix prompt", "复制 AI 修复提示词"), language)}

-

${copy(reportData.language, "Automatic copy was blocked. The exact prompt is selected below.", "自动复制被阻止,下面已选中完整提示词。")}

+

${renderVisibleText(copy(reportData.language, "Automatic copy was blocked. The exact prompt is selected below.", "自动复制被阻止,下面已选中完整提示词。"), language)}

- +
@@ -516,8 +537,47 @@ export function evaluateHtmlReport(htmlText, reportData) { errors.push(`report.html suggestion row count ${suggestionRows} does not match reviewed suggestions ${list(reportData?.summary?.suggestions).length}`); } const dimensionRows = (text.match(/data-dimension-id=/gu) ?? []).length; - if (dimensionRows !== list(reportData?.summary?.dimensions).length) { - errors.push(`report.html dimension row count ${dimensionRows} does not match reviewed dimensions ${list(reportData?.summary?.dimensions).length}`); + const dimensions = list(reportData?.summary?.dimensions); + if (dimensionRows !== dimensions.length) { + errors.push(`report.html dimension row count ${dimensionRows} does not match reviewed dimensions ${dimensions.length}`); + } + const dimensionTracks = text.match(/
]*>/gu) ?? []; + if (dimensionTracks.length !== dimensions.length) { + errors.push(`report.html dimension progressbar count ${dimensionTracks.length} does not match reviewed dimensions ${dimensions.length}`); + } + for (const [index, dimension] of dimensions.entries()) { + const dimensionId = escapeHtml(dimension?.id ?? index); + const dimensionMarker = `data-dimension-id="${dimensionId}"`; + const markerCount = text.split(dimensionMarker).length - 1; + if (markerCount !== 1) { + errors.push(`report.html dimension ${dimensionId} card binding count ${markerCount} does not match expected 1`); + continue; + } + const cardStart = text.indexOf(dimensionMarker); + const cardEnd = text.indexOf("", cardStart); + const cardText = cardEnd < 0 ? "" : text.slice(cardStart, cardEnd); + const cardTracks = cardText.match(/
]*>/gu) ?? []; + if (cardTracks.length !== 1) { + errors.push(`report.html dimension ${dimensionId} progressbar count ${cardTracks.length} does not match expected 1`); + continue; + } + const attributes = Object.fromEntries( + [...cardTracks[0].matchAll(/([\w-]+)="([^"]*)"/gu)].map((match) => [match[1], match[2]]), + ); + const roundedScore = Math.round(clamp(dimension?.score)); + const label = dimension?.label ?? dimension?.id ?? `Dimension ${index + 1}`; + const expectedAttributes = { + role: "progressbar", + "aria-label": `${escapeHtml(label)} ${roundedScore} of 100`, + "aria-valuemin": "0", + "aria-valuemax": "100", + "aria-valuenow": String(roundedScore), + }; + for (const [attribute, expected] of Object.entries(expectedAttributes)) { + if (attributes[attribute] !== expected) { + errors.push(`report.html dimension ${dimensionId} ${attribute} ${attributes[attribute] ?? "missing"} does not match expected ${expected}`); + } + } } if (text.length < 5000) warnings.push("report.html is unexpectedly small for the self-contained visual contract"); return { diff --git a/scripts/harness-analysis/report-run.mjs b/scripts/harness-analysis/report-run.mjs index 2dbcd02..607876f 100644 --- a/scripts/harness-analysis/report-run.mjs +++ b/scripts/harness-analysis/report-run.mjs @@ -152,9 +152,9 @@ export async function analyzeHarnessEvidence(options = {}) { code: "CANVAS_REPLACE_REQUIRES_OUTPUT", }); } - const source = options.sourceInput - ? clone(options.sourceInput) - : (await createTaskLoopSourceFromSessions({ + const sourceResult = options.sourceInput + ? { source: clone(options.sourceInput), sessionBinding: options.sessionBinding ?? null } + : await createTaskLoopSourceFromSessions({ workspace, platform, language, @@ -170,10 +170,12 @@ export async function analyzeHarnessEvidence(options = {}) { qwenHome: options["qwen-home"], copilotHome: options["copilot-home"], piHome: options["pi-home"], + sessionPopulation: options.sessionPopulation, workbuddyHome: options["workbuddy-home"], topology: options.topology, analysisScope: options.analysisScope, - })).source; + }); + const source = sourceResult.source; const sourceErrors = validateHarnessReportSource(source); if (sourceErrors.length > 0) { throw Object.assign(new Error(sourceErrors.join("; ")), { @@ -198,6 +200,7 @@ export async function analyzeHarnessEvidence(options = {}) { schemaVersion: HARNESS_ANALYSIS_EVIDENCE_SCHEMA_VERSION, evidence, summaryFacts, + ...(sourceResult.sessionBinding ? { sessionBinding: sourceResult.sessionBinding } : {}), ...(canvasPath ? { canvasPath, canvasReplaced } : {}), }; } diff --git a/scripts/harness-analysis/task-loop-source.mjs b/scripts/harness-analysis/task-loop-source.mjs index 81ab835..867d084 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -22,6 +22,11 @@ import { buildTaskEpisodes, stableFingerprint } from "../session-analysis/episod import { buildObservationManifest } from "../session-analysis/observation-manifest.mjs"; import { sanitizePrivateReviewText } from "../session-analysis/privacy-safe-text.mjs"; import { sessionAnalysisRef } from "../session-analysis/session-ref.mjs"; +import { + bindSessionSelection, + leadAdmissionBinding, + sessionPopulationDiscovery, +} from "../session-analysis/session-population.mjs"; import { selectSessions } from "../session-analysis/selection.mjs"; import { assertSessionSelectionBinding, @@ -1092,8 +1097,12 @@ export async function createTaskLoopSourceFromSessions(options = {}) { topology: options.topology, analysisScope: options.analysisScope, }; - const discovery = await analyzer.analyze({ ...analyzerOptions, command: "sources" }); - const sessionInventory = Object.freeze(discovery.sessions.map((session) => Object.freeze(structuredClone(session)))); + const population = options.sessionPopulation ?? null; + const discovery = population + ? sessionPopulationDiscovery(population) + : await analyzer.analyze({ ...analyzerOptions, command: "sources" }); + const inventorySource = population?.sessions ?? discovery.sessions; + const sessionInventory = Object.freeze(inventorySource.map((session) => Object.freeze(structuredClone(session)))); if (selectionProfile) { assertSessionSelectionBinding(selectionProfile, selectionPlan, { eligibleCount: sessionInventory.length }); } @@ -1234,7 +1243,24 @@ export async function createTaskLoopSourceFromSessions(options = {}) { memoryInventory: practiceInventory?.memories ?? { included: false, categories: [] }, }); assertStandardUsageComplete(source, selected, includeUsage); - return { source, selection: selected }; + if (!population) return { source, selection: selected }; + const selectionBinding = bindSessionSelection(population, selected.sessions, { + strategy: selected.strategy, + projectionPolicy: "lead-report-signal-v1", + }); + const admittedEpisodes = Number(source.sessionEvents?.candidateEpisodeCount ?? 0); + const zeroSignalDiscardedEpisodes = Number(source.sessionEvents?.discardedEpisodeCount ?? 0); + const sessionBinding = { + population: population.binding, + selection: selectionBinding, + admission: leadAdmissionBinding({ + projectedEpisodes: admittedEpisodes + zeroSignalDiscardedEpisodes, + admittedEpisodes, + zeroSignalDiscardedEpisodes, + retainedTaskEpisodes: source.taskEpisodes.length, + }, selectionBinding), + }; + return { source, selection: selected, sessionBinding }; } async function main(argv = process.argv.slice(2)) { diff --git a/scripts/session-analysis/session-population.mjs b/scripts/session-analysis/session-population.mjs new file mode 100644 index 0000000..3878cb4 --- /dev/null +++ b/scripts/session-analysis/session-population.mjs @@ -0,0 +1,259 @@ +import { stableFingerprint } from "./episode-contract.mjs"; +import { + createFactsRunContext, + prepareFactsSessionInventory, +} from "./session-core-facts.mjs"; + +export const SESSION_POPULATION_BINDING_SCHEMA_VERSION = 1; +export const SESSION_SELECTION_BINDING_SCHEMA_VERSION = 1; +export const SESSION_ADMISSION_BINDING_SCHEMA_VERSION = 1; + +const PRIVATE_POPULATION_IDS = new WeakMap(); +const PRIVATE_POPULATION_DISCOVERY = new WeakMap(); + +function rows(value) { + return Array.isArray(value) ? value : []; +} + +function count(value) { + const numeric = Number(value ?? 0); + return Number.isInteger(numeric) && numeric >= 0 ? numeric : 0; +} + +function sessionIds(sessions) { + return [...new Set(rows(sessions).map((session) => String(session?.sessionId ?? "").trim()).filter(Boolean))].sort(); +} + +function samePopulation(left, right) { + return left?.schemaVersion === right?.schemaVersion + && left?.kind === right?.kind + && left?.scopeFingerprint === right?.scopeFingerprint + && left?.policyFingerprint === right?.policyFingerprint + && left?.eligible?.count === right?.eligible?.count + && left?.eligible?.fingerprint === right?.eligible?.fingerprint + && left?.omission?.exactIdentityAvailable === right?.omission?.exactIdentityAvailable + && left?.omission?.activeSessions === right?.omission?.activeSessions + && left?.omission?.homeSessionOnly === right?.omission?.homeSessionOnly + && left?.omission?.recencyInference === right?.omission?.recencyInference; +} + +function bindingError(code, message) { + return Object.assign(new Error(message), { code }); +} + +export function freezeSessionPopulation({ + scope = {}, + sessions = [], + discovery = null, + excludedSessionId = null, + providerSessionId = null, + suppliedUntil = scope.until !== undefined && scope.until !== null && scope.until !== "", + startedAt = Date.now(), +} = {}) { + const platform = String(scope.platform ?? "unknown"); + const factsContext = createFactsRunContext({ + workspace: scope.workspace, + since: scope.since, + ...(suppliedUntil ? { until: scope.until } : {}), + ...(excludedSessionId ? { "exclude-session-id": excludedSessionId } : {}), + _factsStartedAt: startedAt, + }, platform, providerSessionId); + const prepared = prepareFactsSessionInventory(rows(sessions), factsContext); + const frozenSessions = Object.freeze(prepared.sessions.map((session) => Object.freeze(structuredClone(session)))); + const ids = sessionIds(frozenSessions); + const population = { + sessions: frozenSessions, + binding: Object.freeze({ + schemaVersion: SESSION_POPULATION_BINDING_SCHEMA_VERSION, + kind: "session-population-binding", + scopeFingerprint: stableFingerprint({ + platform, + workspace: scope.workspace ?? null, + since: scope.since ?? null, + until: scope.until ?? null, + }), + policyFingerprint: stableFingerprint({ + platform, + qoderHomeOnlyExcluded: platform === "qoder", + exactIdentityAvailable: Boolean(factsContext.excludedSessionId), + recencyInference: suppliedUntil ? "disabled-frozen-until" : "enabled-unfrozen-until", + }), + omission: Object.freeze({ + exactIdentityAvailable: Boolean(factsContext.excludedSessionId), + activeSessions: count(prepared.omitted.activeSessions), + homeSessionOnly: count(prepared.omitted.homeSessionOnly), + recencyInference: suppliedUntil ? "disabled-frozen-until" : "enabled-unfrozen-until", + }), + eligible: Object.freeze({ + count: ids.length, + fingerprint: stableFingerprint(ids), + }), + }), + }; + PRIVATE_POPULATION_IDS.set(population, new Set(ids)); + PRIVATE_POPULATION_DISCOVERY.set(population, Object.freeze({ + scope: Object.freeze(structuredClone(discovery?.scope ?? scope)), + sources: Object.freeze(rows(discovery?.sources).map((source) => Object.freeze(structuredClone(source)))), + warnings: Object.freeze(rows(discovery?.warnings).map((warning) => Object.freeze(structuredClone(warning)))), + })); + return Object.freeze(population); +} + +export function sessionPopulationDiscovery(population) { + const discovery = PRIVATE_POPULATION_DISCOVERY.get(population); + if (!discovery) { + throw bindingError("INVALID_SESSION_POPULATION", "discovery requires a private frozen Session population"); + } + return discovery; +} + +export function bindSessionSelection(population, selectedSessions = [], { + strategy = "stratified", + projectionPolicy = "session-projection-v1", +} = {}) { + const eligibleIds = PRIVATE_POPULATION_IDS.get(population); + if (!eligibleIds) { + throw bindingError("INVALID_SESSION_POPULATION", "selection requires a private frozen Session population"); + } + const selectedIds = sessionIds(selectedSessions); + if (selectedIds.some((id) => !eligibleIds.has(id))) { + throw bindingError( + "SESSION_SELECTION_OUTSIDE_POPULATION", + "selected Session population is not a subset of the frozen eligible population", + ); + } + return Object.freeze({ + schemaVersion: SESSION_SELECTION_BINDING_SCHEMA_VERSION, + kind: "session-selection-binding", + parentPopulationFingerprint: population.binding.eligible.fingerprint, + strategy: String(strategy), + selected: Object.freeze({ + count: selectedIds.length, + fingerprint: stableFingerprint(selectedIds), + }), + projectionPolicyFingerprint: stableFingerprint(String(projectionPolicy)), + }); +} + +export function sessionAdmissionBinding(data = {}, selection = {}) { + const admission = data.admission ?? {}; + const omitted = data.omitted ?? {}; + return Object.freeze({ + schemaVersion: SESSION_ADMISSION_BINDING_SCHEMA_VERSION, + kind: "session-admission-binding", + projectionPolicyFingerprint: selection.projectionPolicyFingerprint, + taskEpisodes: count(admission.taskEpisodes), + candidateEpisodes: count(admission.candidateEpisodes), + distinctRequests: count(admission.distinctRequests), + emittedCandidates: count(admission.emittedCandidates), + noRequest: count(omitted.noRequest), + selfAnalysis: count(omitted.selfAnalysis), + lowSignal: count(omitted.lowSignal), + duplicateRequests: count(omitted.duplicateRequests), + candidateBudget: count(omitted.candidateBudget), + }); +} + +export function leadAdmissionBinding(data = {}, selection = {}) { + return Object.freeze({ + schemaVersion: SESSION_ADMISSION_BINDING_SCHEMA_VERSION, + kind: "lead-admission-binding", + projectionPolicyFingerprint: selection.projectionPolicyFingerprint, + projectedEpisodes: count(data.projectedEpisodes), + admittedEpisodes: count(data.admittedEpisodes), + zeroSignalDiscardedEpisodes: count(data.zeroSignalDiscardedEpisodes), + retainedTaskEpisodes: count(data.retainedTaskEpisodes), + }); +} + +function populationErrors(expected, owner, binding) { + if (!binding || binding.kind !== "session-population-binding") { + return [`${owner} population binding is missing`]; + } + return samePopulation(expected, binding) ? [] : [`${owner} population binding does not match the frozen population`]; +} + +function selectionErrors(population, owner, selection) { + const errors = []; + if (!selection || selection.kind !== "session-selection-binding") { + return [`${owner} selection binding is missing`]; + } + if (selection.parentPopulationFingerprint !== population?.eligible?.fingerprint) { + errors.push(`${owner} selection is not bound to the frozen eligible population`); + } + if (count(selection.selected?.count) > count(population?.eligible?.count)) { + errors.push(`${owner} selected count exceeds the eligible population`); + } + if (selection.strategy === "all-eligible" + && (selection.selected?.count !== population?.eligible?.count + || selection.selected?.fingerprint !== population?.eligible?.fingerprint)) { + errors.push(`${owner} all-eligible selection does not equal the eligible population`); + } + return errors; +} + +function sessionAdmissionErrors(admission) { + if (!admission || admission.kind !== "session-admission-binding") { + return ["Session admission binding is missing"]; + } + const errors = []; + if (admission.taskEpisodes !== admission.candidateEpisodes + + admission.noRequest + admission.selfAnalysis + admission.lowSignal) { + errors.push("Session task Episode admission does not reconcile"); + } + if (admission.candidateEpisodes !== admission.distinctRequests + admission.duplicateRequests) { + errors.push("Session candidate Episode admission does not reconcile"); + } + if (admission.distinctRequests !== admission.emittedCandidates + admission.candidateBudget) { + errors.push("Session distinct-request admission does not reconcile"); + } + return errors; +} + +function leadAdmissionErrors(admission) { + if (!admission || admission.kind !== "lead-admission-binding") { + return ["lead admission binding is missing"]; + } + const errors = []; + if (admission.projectedEpisodes !== admission.admittedEpisodes + admission.zeroSignalDiscardedEpisodes) { + errors.push("lead projected Episode admission does not reconcile"); + } + if (admission.admittedEpisodes !== admission.retainedTaskEpisodes) { + errors.push("lead retained Episode admission does not reconcile"); + } + return errors; +} + +export function validateSessionPopulationBundle({ population, session, lead } = {}) { + const errors = [ + ...populationErrors(population, "Session", session?.population), + ...populationErrors(population, "lead", lead?.population), + ...selectionErrors(population, "Session", session?.selection), + ...selectionErrors(population, "lead", lead?.selection), + ...sessionAdmissionErrors(session?.admission), + ...leadAdmissionErrors(lead?.admission), + ]; + if (session?.admission?.projectionPolicyFingerprint !== session?.selection?.projectionPolicyFingerprint) { + errors.push("Session admission policy does not match its selection binding"); + } + if (lead?.admission?.projectionPolicyFingerprint !== lead?.selection?.projectionPolicyFingerprint) { + errors.push("lead admission policy does not match its selection binding"); + } + if (session?.selection?.selected?.fingerprint === lead?.selection?.selected?.fingerprint + && session?.selection?.projectionPolicyFingerprint === lead?.selection?.projectionPolicyFingerprint + && session?.admission?.taskEpisodes !== lead?.admission?.projectedEpisodes) { + errors.push("comparable Session and lead Episode totals do not match"); + } + return errors; +} + +export function assertSessionPopulationBundle(bindings) { + const errors = validateSessionPopulationBundle(bindings); + if (errors.length > 0) { + throw Object.assign(new Error(errors.join("; ")), { + code: "SESSION_POPULATION_BINDING_MISMATCH", + errors, + }); + } + return bindings; +} diff --git a/test/agent-lint.test.mjs b/test/agent-lint.test.mjs index 72343ff..05953ac 100644 --- a/test/agent-lint.test.mjs +++ b/test/agent-lint.test.mjs @@ -334,8 +334,11 @@ test("Claude and Codex instruction ledgers keep their active filenames separate" claude.graph.entrypoints.map((entrypoint) => entrypoint.relativePath), ["CLAUDE.md", ".claude/CLAUDE.md", "CLAUDE.local.md", ".claude/rules/security.md", "pkg/CLAUDE.md"], ); + const claudeSources = claude.hostInstructionReview.sources; assert.deepEqual( - claude.hostInstructionReview.sources.map((source) => source.relativePath), + claudeSources + .filter((source) => source.scope !== "ancestor") + .map((source) => source.relativePath), [ "user:CLAUDE.md", ".claude/CLAUDE.md", @@ -345,7 +348,14 @@ test("Claude and Codex instruction ledgers keep their active filenames separate" "CLAUDE.local.md", ], ); - assert.equal(claude.hostInstructionReview.sources.some((source) => source.relativePath.includes("AGENTS")), false); + assert.equal( + claudeSources + .filter((source) => source.scope === "ancestor") + .every((source) => source.sourceKind === "claude-ancestor" + && source.relativePath === "ancestor:CLAUDE.md"), + true, + ); + assert.equal(claudeSources.some((source) => source.relativePath.includes("AGENTS")), false); assert.deepEqual( codex.graph.entrypoints.map((entrypoint) => entrypoint.relativePath), ["AGENTS.md", "pkg/AGENTS.md"], diff --git a/test/better-harness-evidence-bundle.test.mjs b/test/better-harness-evidence-bundle.test.mjs index d159c37..0b16130 100644 --- a/test/better-harness-evidence-bundle.test.mjs +++ b/test/better-harness-evidence-bundle.test.mjs @@ -14,6 +14,101 @@ import { EVIDENCE_BUNDLE_HELP } from "../scripts/harness-analysis/evidence-bundl const NOW = new Date("2026-07-24T08:00:00.000Z"); +const POPULATION_BINDING = Object.freeze({ + schemaVersion: 1, + kind: "session-population-binding", + scopeFingerprint: "1111111111111111", + policyFingerprint: "2222222222222222", + omission: { + exactIdentityAvailable: true, + activeSessions: 1, + homeSessionOnly: 0, + recencyInference: "disabled-frozen-until", + }, + eligible: { count: 1, fingerprint: "3333333333333333" }, +}); + +const SESSION_SELECTION_BINDING = Object.freeze({ + schemaVersion: 1, + kind: "session-selection-binding", + parentPopulationFingerprint: POPULATION_BINDING.eligible.fingerprint, + strategy: "all-eligible", + selected: { count: 1, fingerprint: "3333333333333333" }, + projectionPolicyFingerprint: "4444444444444444", +}); + +const LEAD_SELECTION_BINDING = Object.freeze({ + ...SESSION_SELECTION_BINDING, + strategy: "stratified", + projectionPolicyFingerprint: "5555555555555555", +}); + +function sessionFacts(overrides = {}) { + return { + kind: "session-core-facts", + candidates: [], + scope: { eligibleSessions: 1, selectedSessions: 1 }, + admission: { + taskEpisodes: 1, + candidateEpisodes: 1, + distinctRequests: 1, + emittedCandidates: 1, + }, + omitted: { + noRequest: 0, + selfAnalysis: 0, + lowSignal: 0, + duplicateRequests: 0, + candidateBudget: 0, + activeSessions: 1, + homeSessionOnly: 0, + }, + populationBinding: POPULATION_BINDING, + selectionBinding: SESSION_SELECTION_BINDING, + admissionBinding: { + schemaVersion: 1, + kind: "session-admission-binding", + projectionPolicyFingerprint: SESSION_SELECTION_BINDING.projectionPolicyFingerprint, + taskEpisodes: 1, + candidateEpisodes: 1, + distinctRequests: 1, + emittedCandidates: 1, + noRequest: 0, + selfAnalysis: 0, + lowSignal: 0, + duplicateRequests: 0, + candidateBudget: 0, + }, + ...overrides, + }; +} + +function leadEvidence(overrides = {}) { + return { + evidence: "bounded", + summaryFacts: { + evidenceBoundary: { + manifest: { selection: { eligibleCount: 1, analyzedCount: 1 } }, + episodeCoverage: { episodeCount: 0 }, + }, + }, + sessionBinding: { + population: POPULATION_BINDING, + selection: LEAD_SELECTION_BINDING, + admission: { + schemaVersion: 1, + kind: "lead-admission-binding", + projectedEpisodes: 1, + admittedEpisodes: 0, + zeroSignalDiscardedEpisodes: 1, + retainedTaskEpisodes: 0, + projectionPolicyFingerprint: LEAD_SELECTION_BINDING.projectionPolicyFingerprint, + }, + }, + ...overrides, + }; +} + test("evidence-bundle help advertises WorkBuddy and its isolated home override", () => { assert.match(EVIDENCE_BUNDLE_HELP, /pi, or workbuddy/u); assert.match(EVIDENCE_BUNDLE_HELP, /--workbuddy-home /u); @@ -54,13 +149,21 @@ function topologyResolution(workspace = ".", status = "complete") { } function dependencies(overrides = {}) { + const population = Object.freeze({ + sessions: Object.freeze([{ sessionId: "eligible-session" }]), + binding: POPULATION_BINDING, + }); return { now: () => NOW, resolveWorkspaceTopology: async ({ workspace }) => topologyResolution(workspace), - collectSessionEvidence: async () => availableLane({ kind: "session-core-facts", candidates: [] }), + collectSessionPopulation: async () => population, + collectSessionEvidence: async (_context, _options, received) => { + assert.equal(received.sessionPopulation, population); + return availableLane(sessionFacts()); + }, collectProjectHarness: async () => availableLane({ kind: "core-change-watch-evidence-pack" }), collectAgentCustomize: async () => availableLane({ kind: "agent-asset-baseline", status: "complete" }), - analyzeHarnessEvidence: async () => ({ evidence: "bounded", summaryFacts: { dimensions: [] } }), + analyzeHarnessEvidence: async () => leadEvidence(), ...overrides, }; } @@ -105,7 +208,7 @@ test("evidence bundle resolves topology once and shares the frozen binding with }, collectSessionEvidence: async (context) => { received.push(context.topology); - return availableLane({ kind: "session-core-facts", candidates: [] }); + return availableLane(sessionFacts()); }, collectProjectHarness: async (context) => { received.push(context.topology); @@ -117,7 +220,7 @@ test("evidence bundle resolves topology once and shares the frozen binding with }, analyzeHarnessEvidence: async (options) => { received.push(options.topology); - return { evidence: "bounded", summaryFacts: { dimensions: [] } }; + return leadEvidence(); }, })); @@ -354,6 +457,107 @@ test("Pi agentCustomize lane routes the provider and isolated config paths", asy assert.equal(received["include-user-home"], true); }); +test("shared Session population excludes the active session before both lanes hydrate", async () => { + const population = Object.freeze({ + sessions: Object.freeze([{ sessionId: "eligible-session" }]), + binding: POPULATION_BINDING, + }); + let lanePopulation; + let leadPopulation; + const result = await collectEvidenceBundle({ + workspace: ".", + platform: "codex", + depth: "normal", + }, dependencies({ + collectSessionPopulation: async () => population, + collectSessionEvidence: async (_context, _options, received) => { + lanePopulation = received.sessionPopulation; + return availableLane(sessionFacts()); + }, + analyzeHarnessEvidence: async (options) => { + leadPopulation = options.sessionPopulation; + return leadEvidence(); + }, + })); + + assert.equal(lanePopulation, population); + assert.equal(leadPopulation, population); + assert.equal(result.status, "complete"); + assert.equal(result.diagnostics.sessionPopulationBinding.status, "bound"); + assert.equal(result.diagnostics.sessionPopulationBinding.population.eligible.count, 1); + assert.doesNotMatch(JSON.stringify(result), /eligible-session/u); +}); + +test("Session population conflict fails closed with a redacted stable code", async () => { + const result = await collectEvidenceBundle({ workspace: ".", platform: "codex" }, dependencies({ + analyzeHarnessEvidence: async () => leadEvidence({ + sessionBinding: { + ...leadEvidence().sessionBinding, + population: { + ...POPULATION_BINDING, + eligible: { count: 2, fingerprint: "6666666666666666" }, + }, + }, + }), + })); + + assert.equal(result.status, "failed"); + assert.equal(result.lead.status, "unavailable"); + assert.equal(result.lead.error.code, "SESSION_POPULATION_BINDING_MISMATCH"); + assert.equal(result.diagnostics.sessionPopulationBinding.status, "conflict"); + const serialized = JSON.stringify(result); + assert.doesNotMatch(serialized, /eligible-session/u); + assert.doesNotMatch(serialized, /"sessionId"/u); +}); + +test("Session population conflict rejects lead counts that contradict its binding", async () => { + const result = await collectEvidenceBundle({ workspace: ".", platform: "codex" }, dependencies({ + analyzeHarnessEvidence: async () => leadEvidence({ + summaryFacts: { + evidenceBoundary: { + manifest: { selection: { eligibleCount: 2, analyzedCount: 1 } }, + episodeCoverage: { episodeCount: 0 }, + }, + }, + }), + })); + + assert.equal(result.status, "failed"); + assert.equal(result.lead.error.code, "SESSION_POPULATION_BINDING_MISMATCH"); + assert.equal(result.diagnostics.sessionPopulationBinding.status, "conflict"); +}); + +test("Session facts reject counts that contradict the shared all-eligible population", async () => { + await assert.rejects( + collectSessionEvidence(freezeEvidenceBundleContext({ workspace: ".", platform: "codex" }, NOW), {}, { + sessionPopulation: { + sessions: [{ sessionId: "eligible-session" }], + binding: POPULATION_BINDING, + }, + createAnalyzer: async () => ({ + analyze: async () => sessionFacts({ + scope: { eligibleSessions: 1, selectedSessions: 0 }, + }), + }), + }), + (error) => error?.code === "SESSION_POPULATION_BINDING_MISMATCH", + ); +}); + +test("zero-signal Episode admission remains valid inside one bound population", async () => { + const result = await collectEvidenceBundle({ workspace: ".", platform: "codex" }, dependencies()); + + assert.equal(result.status, "complete"); + assert.equal(result.lead.status, "available"); + assert.deepEqual(result.diagnostics.sessionPopulationBinding.episodes, { + comparison: "not-comparable-selection-or-policy", + sessionTaskEpisodes: 1, + leadProjectedEpisodes: 1, + leadRetainedEpisodes: 0, + leadZeroSignalDiscardedEpisodes: 1, + }); +}); + test("WorkBuddy agentCustomize lane routes the provider and isolated config paths", async () => { const context = freezeEvidenceBundleContext({ workspace: ".", diff --git a/test/core-change-watch.test.mjs b/test/core-change-watch.test.mjs index 6cba292..0f4e8fd 100644 --- a/test/core-change-watch.test.mjs +++ b/test/core-change-watch.test.mjs @@ -89,6 +89,99 @@ test("project profile identifies language, manifest, source root, and entry sign } }); +test("framework-specific evidence distinguishes FastAPI from generic Rails and TypeScript paths", async () => { + const repo = await makeRepo({ + "pyproject.toml": "[project]\nname = \"fastapi-service\"\ndependencies = [\"fastapi>=0.116\"]\n", + "app/api/routes.py": "def route(): return True\n", + "app/services/search.py": "def search(): return []\n", + }); + + try { + const profile = await analyzeProjectProfile({ cwd: repo }); + const core = await analyzeCoreCandidates({ cwd: repo, profile, maxCommits: 20 }); + + assert.ok(profile.frameworks.some((item) => item.name === "fastapi" && item.confidence === "high")); + assert.equal(profile.frameworks.some((item) => item.name === "rails"), false); + assert.equal( + core.candidates + .filter((item) => item.path === "app/api" || item.path.startsWith("app/api/")) + .some((item) => item.reasons.some((reason) => /typescript/iu.test(reason))), + false, + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test("Just recipes are projected statically as unverified argv entry candidates", async () => { + const repo = await makeRepo({ + "Justfile": [ + "default:", + " @just --list", + "sync:", + " uv sync", + "api-dev *args=\"\":", + " uv run uvicorn app.main:app {{args}}", + "health:", + " curl http://localhost:7102/health", + "[private]", + "secret-maintenance:", + " echo hidden", + "check: sync", + " just test", + "", + ].join("\r\n"), + "app/main.py": "def main(): return True\n", + }); + + try { + const profile = await analyzeProjectProfile({ cwd: repo }); + const entries = profile.projectInfo.entryCandidates.filter((item) => item.kind === "just-recipe"); + + for (const recipe of ["default", "sync", "api-dev", "health", "check"]) { + const entry = entries.find((item) => item.command?.join(" ") === `just ${recipe}`); + assert.ok(entry, recipe); + assert.equal(entry.sourcePath, "Justfile"); + assert.equal(entry.executionStatus, "unverified"); + } + assert.equal(entries.some((item) => item.command?.includes("secret-maintenance")), false); + assert.deepEqual( + profile.manifests.find((item) => item.path === "Justfile")?.scripts, + ["api-dev", "check", "default", "health", "sync"], + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test("non-current historical paths stay in history but leave current reads and actions", async () => { + const repo = await makeRepo({ + "src/core/current.ts": "export const current = true;\n", + "src/core/legacy.ts": "export const legacy = 0;\n", + }); + + try { + for (let index = 1; index <= 4; index += 1) { + await writeFixtureFile(repo, "src/core/legacy.ts", `export const legacy = ${index};\n`); + git(repo, ["add", "."]); + git(repo, ["commit", "-q", "-m", `touch legacy ${index}`]); + } + git(repo, ["rm", "-q", "src/core/legacy.ts"]); + git(repo, ["commit", "-q", "-m", "remove legacy source"]); + await writeFixtureFile(repo, "src/core/new-service.ts", "export const added = true;\n"); + + const pack = await buildEvidencePack({ cwd: repo, baseRef: "HEAD", maxCommits: 30 }); + const actionPaths = pack.followUpActions.flatMap((action) => action.files ?? []).map((item) => item.path); + + assert.ok(pack.historyProfile.hotFiles.some((item) => item.path === "src/core/legacy.ts")); + assert.equal(pack.recommendedReads.some((item) => item.path === "src/core/legacy.ts"), false); + assert.equal(actionPaths.includes("src/core/legacy.ts"), false); + assert.ok(pack.recommendedReads.some((item) => item.path === "src/core/new-service.ts")); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + test("project profile summarizes project identity, scale, and AGENTS instruction density", async () => { const repo = await makeRepo({ "package.json": JSON.stringify({ diff --git a/test/harness-report-render-cli.test.mjs b/test/harness-report-render-cli.test.mjs index e108036..4c76159 100644 --- a/test/harness-report-render-cli.test.mjs +++ b/test/harness-report-render-cli.test.mjs @@ -830,6 +830,183 @@ test("HTML mode mirrors the reviewed Agent Work Loop reader sections without Can }); }); +test("HTML dimension progressbar semantics stay complete and score-bound", () => { + // Given: a canonical reviewed report with five fluency dimensions. + const reportData = { + ...sampleFindings(), + language: "en", + target: { name: "render-fixture", path: "/tmp/render-fixture" }, + }; + + // When: the report is rendered and its dimension progressbars are inspected. + const html = renderHtml(reportData); + const progressbars = html.match(/
]*>/gu) ?? []; + + // Then: every displayed rounded score has one complete semantic contract. + assert.equal(progressbars.length, reportData.summary.dimensions.length); + for (const dimension of reportData.summary.dimensions) { + const score = Math.round(dimension.score); + assert.ok(progressbars.includes( + `
`, + )); + } + assert.equal(evaluateHtmlReport(html, reportData).status, "pass"); + + const firstProgressbar = progressbars[0] ?? ""; + const firstProgressbarMarkup = html.match(/
]*>]*><\/i><\/div>/u)?.[0] ?? ""; + assert.notEqual(firstProgressbarMarkup, ""); + const mutateFirstProgressbar = (pattern, replacement) => html.replace( + firstProgressbar, + firstProgressbar.replace(pattern, replacement), + ); + const mutations = [ + ["missing progressbar", html.replace(firstProgressbarMarkup, "")], + ["extra progressbar", html.replace(firstProgressbarMarkup, `${firstProgressbarMarkup}${firstProgressbarMarkup}`)], + ["missing role", mutateFirstProgressbar(' role="progressbar"', "")], + ["invalid role", mutateFirstProgressbar('role="progressbar"', 'role="meter"')], + ["missing label", mutateFirstProgressbar(/ aria-label="[^"]+"/u, "")], + ["invalid label", mutateFirstProgressbar(/aria-label="[^"]+"/u, 'aria-label=""')], + ["invalid minimum", mutateFirstProgressbar('aria-valuemin="0"', 'aria-valuemin="1"')], + ["invalid maximum", mutateFirstProgressbar('aria-valuemax="100"', 'aria-valuemax="99"')], + ["invalid current value", mutateFirstProgressbar(/aria-valuenow="[^"]+"/u, 'aria-valuenow="invalid"')], + ["score mismatch", mutateFirstProgressbar('aria-valuenow="62"', 'aria-valuenow="61"')], + ]; + + for (const [label, mutatedHtml] of mutations) { + assert.equal( + evaluateHtmlReport(mutatedHtml, reportData).status, + "fail", + `${label} mutation must fail validation`, + ); + } +}); + +test("HTML evidence episode coverage preserves canonical summary facts and legacy fallback", () => { + // Given: machine-owned summary facts and a conflicting legacy projection. + const fixture = sampleFindings(); + const reportData = { + ...fixture, + language: "en", + target: { name: "render-fixture", path: "/tmp/render-fixture" }, + summary: { + ...fixture.summary, + evidenceBoundary: { + episodeCoverage: { + episodeCount: 14, + editedEpisodeCount: 12, + }, + }, + atAGlance: { + coverage: { + episodeCount: 1, + editedEpisodeCount: 1, + }, + }, + }, + }; + + // When: canonical and legacy reports are rendered. + const canonicalHtml = renderHtml(reportData); + const legacyHtml = renderHtml({ + ...reportData, + summary: { + ...reportData.summary, + evidenceBoundary: undefined, + atAGlance: { + coverage: { + episodeCount: 7, + editedEpisodeCount: 5, + }, + }, + }, + }); + + // Then: machine facts win, while legacy-only input remains readable. + assert.match(canonicalHtml, /Task episodes<\/span>14<\/strong>/u); + assert.match(canonicalHtml, /Edited episodes<\/span>12<\/strong>/u); + assert.match(legacyHtml, /Task episodes<\/span>7<\/strong>/u); + assert.match(legacyHtml, /Edited episodes<\/span>5<\/strong>/u); +}); + +test("HTML CJK phrase breaking emits bounded deterministic markup without changing report data", () => { + // Given: reviewed phrases plus escaping, long-token, path, URL, and mixed-Latin boundaries. + const fixture = sampleFindings(); + const reportData = { + ...fixture, + language: "zh-CN", + target: { name: "render-fixture", path: "/tmp/render-fixture" }, + summary: { + ...fixture.summary, + overview: "同一证据包给出两套互相冲突的口径,并保留 recommendedReads 与 app/api/example.py。", + dimensions: fixture.summary.dimensions.map((dimension, index) => ({ + ...dimension, + label: ["任务理解", "可控执行", "改动验证", "可靠交付", "经验沉淀"][index], + summary: index === 1 + ? "项目具有工作入口,但画像没有投影这些命令,代理仍需猜测验证路线。" + : dimension.summary, + })), + }, + findings: fixture.findings.map((finding, index) => ({ + ...finding, + title: index === 0 ? "同一证据包给出相反结论" : "项目画像漏掉工作入口", + reason: index === 0 + ? "安全