diff --git a/apps/web/console/README.md b/apps/web/console/README.md
index c4a80ff..60d105a 100644
--- a/apps/web/console/README.md
+++ b/apps/web/console/README.md
@@ -20,11 +20,13 @@ Current scope:
- authenticated shell entry only after `/v1/me` succeeds
- access and refresh tokens are held in React memory only
- left navigation with Contracts, Delivery Readiness, and Proof product
- surfaces; Contracts consumes authenticated, organization-scoped, read-only
- Contract endpoints: `GET /v1/contracts`, `GET /v1/contracts/{id}`, and
- `GET /v1/contracts/{id}/current-draft`; it renders a contract rail/list plus
- selected aggregate detail, current draft body when available, and read-only
- Organization / Project / Repository context metadata from
+ surfaces; the current authenticated Contracts entry renders the imported
+ `apps/web/demo-change-packet-ru` demo contracts page shell, isolated from
+ the rest of the Console CSS, and backs its Contract rows/detail/current draft
+ data with the existing authenticated read-only Contract endpoints:
+ `GET /v1/contracts`, `GET /v1/contracts/{id}`, and
+ `GET /v1/contracts/{id}/current-draft`, plus read-only Organization /
+ Project / Repository context metadata from
`GET /v1/organizations/{organization_id}/repository-context`;
Delivery Readiness consumes read-only `GET /v1/qualification-feed`
while authenticated and renders Qualification / Clarification / Contract /
@@ -78,17 +80,18 @@ Delivery rule:
`draft_contract` controls; linked contract cards expose `Open contract`
navigation only, which loads the selected contract through
`GET /v1/contracts/{id}`
-- The Contracts surface consumes authenticated, organization-scoped read-only
- Contract discovery at
+- The current Contracts entry is the imported RU demo contracts UI backed by
+ authenticated, organization-scoped read-only Contract discovery at
`GET /v1/contracts?project_id=&repo_binding_id=&goal_id=&state=&limit=`,
loads `GET /v1/contracts?limit=50` by default, supports state and
- repo-binding filters plus manual refresh, keeps manual ID lookup as a
- secondary fallback, and shows selected detail through authenticated,
+ repo-binding filters plus manual refresh, and shows selected detail through
+ authenticated,
organization-scoped, read-only `GET /v1/contracts/{id}` as the compact public
Contract aggregate only
-- The Contracts surface also uses `/v1/me` to determine `organization_id` and
- reads `GET /v1/organizations/{organization_id}/repository-context` for a
- compact metadata-only context panel. If the selected Contract
+- The Contracts surface also uses `/v1/me` to determine
+ `organization_id` and reads
+ `GET /v1/organizations/{organization_id}/repository-context` for a compact
+ metadata-only context panel. If the selected Contract
`repo_binding_id` matches a returned context, that context is shown; if no
Contract is selected, the first Organization repository context is shown; if
the selected binding is absent from the response, the Contract stays visible
@@ -204,9 +207,10 @@ wait/cursor semantics, SSE, WebSocket, a daemon, or an event stream.
main user flow.
- Delivery Readiness shows qualification state and handoff to Contracts, not
lifecycle controls.
-- The Contracts surface is read-only, lists contracts from discovery, supports
- state and repo-binding filtering plus manual refresh, and can show selected
- detail or a manual ID lookup result.
+- The current imported RU demo Contracts shell is read-only, lists contracts
+ from backend discovery, supports state and repo-binding filtering plus manual
+ refresh, and shows selected detail/current draft from the backend-backed
+ selected Contract state.
D-0091 display behavior:
- Delivery Readiness cards show one frontend-projected primary status instead
diff --git a/apps/web/console/src/App.css b/apps/web/console/src/App.css
index a5562f3..c11e913 100644
--- a/apps/web/console/src/App.css
+++ b/apps/web/console/src/App.css
@@ -175,6 +175,15 @@ select:focus-visible {
border: 0;
}
+.demoContractsOverlay {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ min-width: 320px;
+ min-height: 100vh;
+ background: var(--bg);
+}
+
.brand {
display: flex;
align-items: center;
diff --git a/apps/web/console/src/App.test.tsx b/apps/web/console/src/App.test.tsx
index 1e24c03..640d413 100644
--- a/apps/web/console/src/App.test.tsx
+++ b/apps/web/console/src/App.test.tsx
@@ -305,6 +305,15 @@ function expectNoWorkflowMutationRequests() {
))).toBe(false);
}
+async function findDemoContractsShadowRoot() {
+ await waitFor(() => {
+ const host = document.querySelector('.demoContractsHost') as HTMLDivElement | null;
+ expect(host?.shadowRoot).toBeTruthy();
+ });
+
+ return (document.querySelector('.demoContractsHost') as HTMLDivElement).shadowRoot as ShadowRoot;
+}
+
async function setLocale(locale: 'en' | 'ru') {
await i18n.changeLanguage(locale);
document.documentElement.lang = locale;
@@ -1502,6 +1511,54 @@ describe('App', () => {
);
});
+ it('renders backend contract data inside the imported contracts page shell', async () => {
+ await loginSuccessfully('ru', 'owner', [
+ contractResponse({
+ id: 'backend-live-contract',
+ goal_id: 'backend-live-goal',
+ repo_binding_id: 'backend-live-repo-binding',
+ state: 'ready_for_approval',
+ current_seed_id: 'backend-live-seed',
+ current_draft_id: 'backend-live-draft',
+ updated_at: '2026-05-09T10:15:00Z',
+ }),
+ ], {
+ id: 'backend-live-draft',
+ contract_id: 'backend-live-contract',
+ contract_seed_id: 'backend-live-seed',
+ goal_id: 'backend-live-goal',
+ repo_binding_id: 'backend-live-repo-binding',
+ title: 'Backend live draft title',
+ intent_summary: 'Backend live draft intent summary.',
+ proposed_scope: ['Backend live scope item'],
+ proposed_non_goals: ['No browser lifecycle mutation'],
+ proposed_acceptance_criteria: ['Backend page shows current draft'],
+ proposed_expected_checks: ['Backend read-only smoke'],
+ proposed_proof_expectations: ['Backend proof expectation text'],
+ state: 'ready_for_approval',
+ }, [
+ repositoryContextRecord({
+ repoBindingId: 'backend-live-repo-binding',
+ repositoryFullName: 'heurema/backend-live',
+ projectDisplayName: 'Backend Live Project',
+ }),
+ ]);
+
+ const shadowRoot = await findDemoContractsShadowRoot();
+
+ await waitFor(() => {
+ expect(shadowRoot.textContent).toContain('backend-live-contract');
+ expect(shadowRoot.textContent).toContain('Backend live draft title');
+ });
+ expect(shadowRoot.textContent).toContain('heurema/backend-live');
+ expect(shadowRoot.textContent).toContain('Backend live draft intent summary.');
+ expect(shadowRoot.textContent).toContain('Backend live scope item');
+ expect(shadowRoot.textContent).toContain('No browser lifecycle mutation');
+ expect(shadowRoot.textContent).toContain('Read-only API');
+ expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain('/v1/contracts/backend-live-contract/current-draft');
+ expectNoWorkflowMutationRequests();
+ });
+
it('does not call current draft detail when the selected Contract has no current_draft_id', async () => {
await loginSuccessfully('en', 'owner', [
contractResponse({
diff --git a/apps/web/console/src/App.tsx b/apps/web/console/src/App.tsx
index 82e0649..778aa15 100644
--- a/apps/web/console/src/App.tsx
+++ b/apps/web/console/src/App.tsx
@@ -50,6 +50,7 @@ import type { ContractDraftResponse, ContractDraftSourceRef } from './contractDr
import { READINESS_DISPLAY_LANES, projectReadinessDisplay, sortReadinessItems } from './readinessDisplay';
import { formatCalmTimestamp } from './uiTime';
import StartPage from './StartPage';
+import DemoContractsPage from './DemoContractsPage';
import './App.css';
@@ -1929,6 +1930,35 @@ function ConsoleApp() {
)}
+ {screen === 'console' && activeSurface === 'contracts' ? (
+
+ {
+ void refreshContractsSurface(true);
+ },
+ onRepoBindingFilterChange: setContractListRepoBindingFilter,
+ onStateFilterChange: setContractListStateFilter,
+ }}
+ />
+
+ ) : null}
+
{isDrawerOpen ? (
<>
diff --git a/apps/web/console/src/DemoContractsPage.css b/apps/web/console/src/DemoContractsPage.css
new file mode 100644
index 0000000..0c5814b
--- /dev/null
+++ b/apps/web/console/src/DemoContractsPage.css
@@ -0,0 +1,2202 @@
+:root {
+ --bg: oklch(0.175 0.008 55);
+ --bg-2: oklch(0.205 0.008 55);
+ --panel: oklch(0.225 0.007 55);
+ --panel-2: oklch(0.255 0.007 55);
+ --panel-3: oklch(0.285 0.007 55);
+ --line: oklch(0.305 0.006 55);
+ --line-soft: oklch(0.255 0.006 55);
+ --ink: oklch(0.9 0.008 75);
+ --ink-2: oklch(0.78 0.01 72);
+ --mute: oklch(0.62 0.01 70);
+ --mute-2: oklch(0.48 0.01 70);
+ --amber: oklch(0.76 0.095 72);
+ --amber-soft: oklch(0.42 0.055 72);
+ --mauve: oklch(0.7 0.035 330);
+ --mauve-soft: oklch(0.38 0.025 330);
+ --pass: oklch(0.72 0.065 115);
+ --pass-soft: oklch(0.38 0.04 115);
+ --block: oklch(0.62 0.095 28);
+ --block-soft: oklch(0.38 0.055 28);
+
+ --mono: 'JetBrains Mono', ui-monospace, menlo, monospace;
+ --sans: 'Inter', system-ui, sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ min-height: 100%;
+}
+
+html,
+body {
+ overflow-x: auto;
+}
+
+body {
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: 12.5px;
+ line-height: 1.45;
+ -webkit-font-smoothing: antialiased;
+ font-feature-settings: 'ss01', 'cv11';
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+button {
+ border: 0;
+ background: none;
+ color: inherit;
+}
+
+code {
+ font-family: var(--mono);
+}
+
+.app-shell {
+ min-height: 100vh;
+ background: var(--bg);
+}
+
+.app {
+ display: grid;
+ grid-template-columns: 208px minmax(0, 1fr) 380px;
+ grid-template-rows: 52px minmax(0, 1fr) 220px;
+ height: 100vh;
+ min-width: 1180px;
+ min-height: 780px;
+}
+
+.topbar {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: 208px minmax(0, 1fr) 380px;
+ align-items: center;
+ border-bottom: 1px solid var(--line);
+ background: var(--bg);
+}
+
+.brand {
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 16px;
+ border-right: 1px solid var(--line);
+ background: var(--bg-2);
+}
+
+.mark {
+ width: 18px;
+ height: 18px;
+ position: relative;
+}
+
+.mark::before,
+.mark::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: var(--ink-2);
+ border-radius: 1px;
+}
+
+.mark::before {
+ top: 3px;
+}
+
+.mark::after {
+ bottom: 3px;
+}
+
+.mark span {
+ position: absolute;
+ top: 8px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: var(--mauve);
+ border-radius: 1px;
+}
+
+.brand .name {
+ font-size: 11.5px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ color: var(--ink);
+ text-transform: uppercase;
+}
+
+.dot {
+ color: var(--mute-2);
+ font-weight: 400;
+}
+
+.brand-muted {
+ color: var(--mute);
+}
+
+.meters {
+ height: 36px;
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ min-width: 0;
+ margin: 0 24px;
+ padding: 7px 18px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: color-mix(in oklab, var(--panel) 90%, transparent);
+ box-shadow: inset 0 1px 0 color-mix(in oklab, white 4%, transparent);
+}
+
+.meter {
+ flex: 1 1 0;
+ min-width: 120px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ white-space: nowrap;
+}
+
+.label {
+ font-family: var(--mono);
+ font-size: 10.5px;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--mute);
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.val {
+ margin-left: auto;
+ flex-shrink: 0;
+ font-family: var(--mono);
+ font-size: 10.5px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ color: var(--ink);
+}
+
+.bar {
+ height: 3px;
+ background: var(--panel-2);
+ border-radius: 1.5px;
+ overflow: hidden;
+}
+
+.bar > i {
+ display: block;
+ height: 100%;
+ border-radius: 1.5px;
+ background: var(--mute-2);
+ transition: width 600ms cubic-bezier(0.2, 0.7, 0.2, 1), background-color 400ms;
+}
+
+.meter.amber .bar > i {
+ background: var(--amber);
+}
+
+.meter.mauve .bar > i {
+ background: var(--mauve);
+}
+
+.meter.pass .bar > i {
+ background: var(--pass);
+}
+
+.bar.bar-pass > i {
+ background: var(--pass);
+}
+
+.bar.bar-amber > i {
+ background: var(--amber);
+}
+
+.bar.bar-block > i {
+ background: var(--block);
+}
+
+.topbar-state {
+ height: 36px;
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
+ align-items: center;
+ gap: 10px;
+ margin-right: 20px;
+ padding: 0;
+}
+
+.state-chip {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 100%;
+ padding: 6px 11px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: var(--panel);
+ min-width: 0;
+ white-space: nowrap;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.state-chip .k {
+ flex: 0 0 auto;
+ color: var(--mute-2);
+ opacity: 0.8;
+}
+
+.state-chip .v {
+ min-width: 0;
+ color: var(--ink);
+ font-weight: 500;
+ letter-spacing: 0.06em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rail {
+ grid-column: 1;
+ grid-row: 2 / 4;
+ border-right: 1px solid var(--line);
+ background: var(--bg-2);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 0;
+}
+
+.rail-main {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 12px 0 24px;
+ scrollbar-width: thin;
+}
+
+.group-label {
+ padding: 10px 18px 4px;
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--mute-2);
+ opacity: 0.8;
+}
+
+.rail-section {
+ padding: 0 18px 8px;
+}
+
+.surface-switcher {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 0 12px 10px;
+}
+
+.surface-switch {
+ width: 100%;
+ padding: 9px 10px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: var(--ink-2);
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 160ms, background-color 160ms, color 160ms;
+}
+
+.surface-switch:hover {
+ border-color: var(--line-soft);
+ background: color-mix(in oklab, var(--panel) 66%, transparent);
+}
+
+.surface-switch.active {
+ border-color: color-mix(in oklab, var(--mauve) 48%, var(--line));
+ background: color-mix(in oklab, var(--mauve) 10%, var(--panel));
+ color: var(--ink);
+}
+
+.select-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.select-label {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--mute);
+}
+
+.select-wrap select {
+ width: 100%;
+ padding: 9px 10px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: var(--panel);
+ color: var(--ink);
+ outline: none;
+}
+
+.native-select {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+}
+
+.live-refresh-button {
+ width: 100%;
+ min-height: 34px;
+ margin-top: 10px;
+ border: 1px solid color-mix(in oklab, var(--mauve) 42%, var(--line));
+ border-radius: 4px;
+ background: color-mix(in oklab, var(--mauve) 10%, var(--panel));
+ color: var(--ink);
+ cursor: pointer;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.live-refresh-button:hover {
+ border-color: color-mix(in oklab, var(--mauve) 62%, var(--line));
+ background: color-mix(in oklab, var(--mauve) 16%, var(--panel-2));
+}
+
+.live-error {
+ margin-top: 10px;
+ padding: 8px 9px;
+ border: 1px solid var(--block-soft);
+ border-radius: 4px;
+ color: var(--block);
+ background: color-mix(in oklab, var(--block) 8%, transparent);
+ font-size: 11px;
+ line-height: 1.35;
+}
+
+.repo-select {
+ position: relative;
+}
+
+.repo-select-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ width: 100%;
+ min-height: 36px;
+ padding: 8px 10px;
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+ background:
+ linear-gradient(180deg, color-mix(in oklab, var(--panel-2) 64%, transparent), color-mix(in oklab, var(--panel) 92%, transparent));
+ color: var(--ink);
+ cursor: pointer;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ text-align: left;
+ transition: border-color 160ms, background-color 160ms, box-shadow 160ms;
+}
+
+.repo-select-trigger span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.repo-select-trigger:hover,
+.repo-select-trigger.open {
+ border-color: color-mix(in oklab, var(--mauve) 45%, var(--line));
+ background: color-mix(in oklab, var(--mauve) 8%, var(--panel));
+}
+
+.repo-select-trigger:focus-visible,
+.repo-select-option:focus-visible {
+ border-color: color-mix(in oklab, var(--mauve) 62%, var(--line));
+ box-shadow: 0 0 0 2px color-mix(in oklab, var(--mauve) 18%, transparent);
+ outline: none;
+}
+
+.repo-select-trigger i {
+ width: 7px;
+ height: 7px;
+ flex: 0 0 auto;
+ border-right: 1px solid var(--mute);
+ border-bottom: 1px solid var(--mute);
+ transform: translateY(-2px) rotate(45deg);
+ transition: transform 160ms, border-color 160ms;
+}
+
+.repo-select-trigger.open i {
+ border-color: var(--mauve);
+ transform: translateY(2px) rotate(225deg);
+}
+
+.repo-select-menu {
+ position: absolute;
+ z-index: 20;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 6px;
+ border: 1px solid color-mix(in oklab, var(--mauve) 35%, var(--line));
+ border-radius: 4px;
+ background: color-mix(in oklab, var(--bg-2) 96%, black);
+ box-shadow:
+ 0 14px 34px color-mix(in oklab, black 44%, transparent),
+ inset 0 1px 0 color-mix(in oklab, white 5%, transparent);
+}
+
+.repo-select-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 9px;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ color: var(--ink-2);
+ cursor: pointer;
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.04em;
+ text-align: left;
+}
+
+.repo-select-option:hover,
+.repo-select-option.active {
+ border-color: var(--line-soft);
+ background: color-mix(in oklab, var(--panel-2) 82%, transparent);
+ color: var(--ink);
+}
+
+.repo-select-option.active {
+ border-color: color-mix(in oklab, var(--mauve) 40%, var(--line));
+}
+
+.repo-select-option span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.repo-select-option b {
+ flex: 0 0 auto;
+ color: var(--mute-2);
+ font-size: 8.5px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.rail-note {
+ margin-top: 8px;
+ color: var(--mute);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.repo-hint-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 7px;
+ margin-top: 10px;
+ color: var(--mute);
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.04em;
+}
+
+.surface-context {
+ margin-top: 4px;
+}
+
+.surface-context-title {
+ margin-top: 5px;
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.surface-control {
+ margin-top: 12px;
+}
+
+.surface-mini-list,
+.surface-filter-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 0;
+}
+
+.surface-mini-list {
+ padding: 4px 12px 18px;
+}
+
+.surface-mini-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 9px;
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+ background: transparent;
+ color: var(--ink-2);
+ text-align: left;
+ cursor: pointer;
+}
+
+.surface-mini-row span,
+.surface-mini-row b {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.surface-mini-row span {
+ flex: 1 1 auto;
+}
+
+.surface-mini-row b {
+ flex: 0 1 auto;
+ text-align: right;
+}
+
+.surface-mini-row.active {
+ border-color: var(--mauve-soft);
+ background: color-mix(in oklab, var(--mauve) 8%, var(--panel));
+}
+
+.surface-mini-row span,
+.surface-filter-list span {
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.04em;
+}
+
+.surface-mini-row b {
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 9.5px;
+ font-weight: 500;
+}
+
+.surface-filter-list span {
+ display: inline-flex;
+ width: fit-content;
+ padding: 3px 8px;
+ border: 1px solid var(--line-soft);
+ border-radius: 999px;
+ color: var(--ink-2);
+}
+
+.shelf-tools {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 0 12px 8px;
+}
+
+.shelf-tools input {
+ width: 100%;
+ min-height: 32px;
+ padding: 7px 9px;
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+ background: color-mix(in oklab, var(--panel) 82%, transparent);
+ color: var(--ink);
+ outline: none;
+}
+
+.shelf-tools input:focus {
+ border-color: color-mix(in oklab, var(--mauve) 38%, var(--line));
+ background: color-mix(in oklab, var(--panel-2) 78%, transparent);
+}
+
+.shelf-tools input::placeholder {
+ color: var(--mute-2);
+}
+
+.filter-chip-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.filter-chip-row span {
+ display: inline-flex;
+ padding: 2px 7px;
+ border: 1px solid var(--line-soft);
+ border-radius: 999px;
+ color: var(--mute);
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.contract-list {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 4px 12px 20px;
+ overflow: auto;
+}
+
+.rail-empty {
+ padding: 8px 9px;
+ border: 1px dashed var(--line-soft);
+ border-radius: 4px;
+ color: var(--mute-2);
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.04em;
+}
+
+.contract-row {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 160ms, background-color 160ms, transform 160ms;
+}
+
+.contract-row.compact {
+ gap: 5px;
+ padding: 8px 10px;
+}
+
+.contract-row:hover {
+ border-color: var(--line);
+ background: color-mix(in oklab, var(--panel) 72%, transparent);
+}
+
+.contract-row.active {
+ border-color: color-mix(in oklab, var(--mauve) 50%, var(--line));
+ background: linear-gradient(180deg, color-mix(in oklab, var(--mauve) 8%, transparent), transparent);
+}
+
+.contract-row-top {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+}
+
+.contract-id {
+ min-width: 0;
+ overflow: hidden;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--mute);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.contract-title {
+ color: var(--ink);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ line-height: 1.25;
+ overflow-wrap: anywhere;
+}
+
+.contract-row.compact .contract-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.contract-summary {
+ display: -webkit-box;
+ overflow: hidden;
+ color: var(--mute);
+ font-size: 11px;
+ line-height: 1.4;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+}
+
+.contract-row-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.repo-badge,
+.contract-owner {
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.04em;
+}
+
+.repo-badge {
+ padding: 2px 7px;
+ border: 1px solid var(--mauve-soft);
+ border-radius: 999px;
+ color: var(--mauve);
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.contract-owner {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--mute-2);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.contract-compact-meta {
+ overflow: hidden;
+ color: var(--mute-2);
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.04em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.case {
+ flex: 0 0 auto;
+ margin-top: 0;
+ margin-bottom: 0;
+ padding: 12px 18px 10px;
+ border-top: 1px solid var(--line-soft);
+ opacity: 0.7;
+}
+
+.case .k {
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--mute-2);
+ opacity: 0.8;
+}
+
+.case .v {
+ margin-top: 3px;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.02em;
+ color: var(--ink-2);
+}
+
+.case .sub {
+ margin-top: 5px;
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.02em;
+ color: var(--mute-2);
+ opacity: 0.8;
+}
+
+.canvas {
+ grid-column: 2;
+ grid-row: 2;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+ overflow: auto;
+ padding: 20px 24px 16px;
+ background: var(--bg);
+}
+
+.spine {
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: var(--panel);
+}
+
+.spine-head,
+.obj-head,
+.panel-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.spine-head .t,
+.obj-head .t,
+.panel-head .t {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--mute);
+}
+
+.spine-head .id,
+.panel-head .id {
+ margin-top: 3px;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--mute-2);
+}
+
+.tags,
+.chip-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.tag,
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ padding: 2px 8px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--panel-2);
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-2);
+ white-space: nowrap;
+ max-width: 100%;
+}
+
+.tag.mauve,
+.status-pill.mauve {
+ color: var(--mauve);
+ border-color: var(--mauve-soft);
+}
+
+.tag.amber,
+.status-pill.amber {
+ color: var(--amber);
+ border-color: var(--amber-soft);
+}
+
+.tag.pass,
+.status-pill.pass {
+ color: var(--pass);
+ border-color: var(--pass-soft);
+}
+
+.tag.block,
+.status-pill.block {
+ color: var(--block);
+ border-color: var(--block-soft);
+}
+
+.body {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ position: relative;
+ padding: 20px 10px 14px;
+}
+
+.stage {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 0 3px;
+}
+
+.node {
+ position: relative;
+ z-index: 2;
+ width: 12px;
+ height: 12px;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+ background: var(--panel-2);
+ transition: all 280ms;
+}
+
+.node::after {
+ content: '';
+ position: absolute;
+ inset: 3px;
+ border-radius: 50%;
+ background: transparent;
+ transition: background 280ms;
+}
+
+.connector {
+ position: absolute;
+ top: 6px;
+ left: 50%;
+ right: -50%;
+ height: 1px;
+ background: var(--line);
+ z-index: 1;
+}
+
+.stage:last-child .connector {
+ display: none;
+}
+
+.stage .name {
+ font-family: var(--mono);
+ min-height: 24px;
+ font-size: 8.6px;
+ line-height: 1.22;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ text-align: center;
+ color: var(--mute);
+ overflow-wrap: anywhere;
+}
+
+.stage .meta {
+ min-height: 12px;
+ font-family: var(--mono);
+ font-size: 8.5px;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--mute-2);
+}
+
+.stage.done .node {
+ background: var(--pass-soft);
+ border-color: var(--pass);
+}
+
+.stage.done .node::after {
+ background: var(--pass);
+}
+
+.stage.done .name {
+ color: var(--ink-2);
+}
+
+.stage.done .connector {
+ background: var(--pass-soft);
+}
+
+.stage.active .node {
+ background: var(--mauve-soft);
+ border-color: var(--mauve);
+ box-shadow:
+ 0 0 0 5px color-mix(in oklab, var(--mauve) 18%, transparent),
+ 0 0 0 1px color-mix(in oklab, var(--mauve) 40%, transparent);
+}
+
+.stage.active .node::after {
+ background: var(--mauve);
+}
+
+.stage.active .name {
+ color: var(--ink);
+}
+
+.stage.active .meta {
+ color: var(--mauve);
+}
+
+.active-summary {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 8px 14px;
+ border-top: 1px dashed var(--line-soft);
+ min-width: 0;
+}
+
+.active-summary .marker {
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--mauve);
+ opacity: 0.85;
+}
+
+.active-summary .stage-name {
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-2);
+}
+
+.facts {
+ display: flex;
+ gap: 0;
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.02em;
+ color: var(--mute);
+}
+
+.facts .f {
+ padding: 0 10px;
+ border-left: 1px solid var(--line-soft);
+}
+
+.facts .f:first-child {
+ padding-left: 0;
+ border-left: 0;
+}
+
+.facts .f b {
+ color: var(--ink-2);
+ font-weight: 500;
+}
+
+.facts .f.pass b {
+ color: var(--pass);
+}
+
+.object {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ flex-direction: column;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: var(--panel);
+}
+
+.object-title {
+ margin-top: 4px;
+ color: var(--ink);
+ font-size: 16px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.obj-body {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 18px 20px 22px;
+}
+
+.stage-content {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.surface-canvas {
+ gap: 0;
+}
+
+.surface-object {
+ min-height: 100%;
+}
+
+.surface-intro {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.compat-line,
+.section-tagline {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--mute);
+}
+
+.section-tagline {
+ color: var(--mauve);
+}
+
+.stage-title {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 600;
+ line-height: 1.15;
+ color: var(--ink);
+}
+
+.detail-grid {
+ display: grid;
+ gap: 12px;
+}
+
+.detail-grid.two-up {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.repo-card-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.repo-readiness-card,
+.proof-feed-row {
+ width: 100%;
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+ background: color-mix(in oklab, var(--panel-2) 84%, transparent);
+ text-align: left;
+}
+
+.repo-readiness-card {
+ display: flex;
+ flex-direction: column;
+ gap: 13px;
+ padding: 14px;
+ cursor: pointer;
+ transition: border-color 160ms, background-color 160ms, transform 160ms;
+}
+
+.repo-readiness-card:hover,
+.repo-readiness-card.active,
+.proof-feed-row:hover,
+.proof-feed-row.active {
+ border-color: color-mix(in oklab, var(--mauve) 45%, var(--line));
+ background: linear-gradient(180deg, color-mix(in oklab, var(--mauve) 7%, var(--panel-2)), var(--panel-2));
+}
+
+.repo-readiness-card:hover,
+.proof-feed-row:hover {
+ transform: translateY(-1px);
+}
+
+.repo-card-head,
+.proof-row-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.repo-score {
+ margin-top: 4px;
+ color: var(--ink);
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.readiness-card-grid,
+.proof-meta-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.add-repository-card {
+ justify-content: space-between;
+ cursor: default;
+}
+
+.proof-feed-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.proof-feed-row {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px;
+ cursor: pointer;
+ transition: border-color 160ms, background-color 160ms, transform 160ms;
+}
+
+.proof-row-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.proof-summary {
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.45;
+}
+
+.filter-row {
+ margin-top: 2px;
+}
+
+.proof-archive-line {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.03em;
+}
+
+.detail-block {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 14px;
+ border: 1px solid color-mix(in oklab, var(--line) 82%, white 5%);
+ border-radius: 4px;
+ background:
+ linear-gradient(180deg, color-mix(in oklab, var(--panel-3) 72%, var(--mauve) 5%), color-mix(in oklab, var(--panel-2) 92%, black 3%));
+ box-shadow:
+ inset 0 1px 0 color-mix(in oklab, white 5%, transparent),
+ 0 10px 24px color-mix(in oklab, black 14%, transparent);
+ overflow: hidden;
+}
+
+.detail-block::before {
+ content: '';
+ position: absolute;
+ inset: 0 0 auto;
+ height: 1px;
+ background: linear-gradient(90deg, color-mix(in oklab, var(--mauve) 56%, transparent), transparent 68%);
+ opacity: 0.85;
+ pointer-events: none;
+}
+
+.hero-block {
+ background: linear-gradient(180deg, color-mix(in oklab, var(--mauve) 5%, var(--panel-2)), var(--panel-2));
+}
+
+.detail-kicker {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--mute);
+ overflow-wrap: anywhere;
+}
+
+.detail-copy,
+.panel-copy {
+ margin: 0;
+ color: var(--ink-2);
+ font-size: 13px;
+ line-height: 1.55;
+ text-wrap: pretty;
+}
+
+.bullet-list {
+ margin: 0;
+ padding-left: 16px;
+ color: var(--ink-2);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.key-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px 16px;
+ margin: 0;
+}
+
+.key-grid > div {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.key-grid dt {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--mute);
+}
+
+.key-grid dd {
+ margin: 0;
+ color: var(--ink-2);
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+
+.compact-grid {
+ gap: 10px 12px;
+}
+
+.panel-note {
+ padding: 10px 12px;
+ border: 1px dashed color-mix(in oklab, var(--mauve) 30%, var(--line));
+ border-radius: 4px;
+ background: color-mix(in oklab, var(--panel-3) 76%, var(--bg));
+ box-shadow: inset 0 1px 0 color-mix(in oklab, white 4%, transparent);
+ color: var(--ink-2);
+ line-height: 1.5;
+}
+
+.strong-note {
+ border-color: var(--mauve-soft);
+}
+
+.inline-actions,
+.decision-actions,
+.control-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.primary-button,
+.ghost-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ padding: 0 14px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: transform 120ms, background-color 120ms, border-color 120ms;
+}
+
+.primary-button:hover,
+.ghost-button:hover,
+.contract-row:hover {
+ transform: translateY(-1px);
+}
+
+.primary-button {
+ border: 1px solid color-mix(in oklab, var(--mauve) 55%, var(--line));
+ background: color-mix(in oklab, var(--mauve) 16%, var(--panel-2));
+ color: var(--ink);
+}
+
+.primary-button.small {
+ min-height: 32px;
+}
+
+.ghost-button {
+ border: 1px solid var(--line);
+ background: var(--panel);
+ color: var(--ink-2);
+}
+
+.ghost-button.danger {
+ border-color: var(--block-soft);
+ color: var(--block);
+}
+
+.primary-button:disabled,
+.ghost-button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.clarification-stack,
+.work-item-list,
+.verify-list,
+.activity-list,
+.inspector-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.clarification-card,
+.work-item-card,
+.verify-row,
+.evidence-card,
+.panel-card,
+.activity-row {
+ border-radius: 4px;
+}
+
+.clarification-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px;
+ border: 1px solid var(--line-soft);
+ background: color-mix(in oklab, var(--panel-2) 84%, transparent);
+}
+
+.clarification-card.resolved {
+ border-color: color-mix(in oklab, var(--pass) 28%, var(--line));
+}
+
+.clarification-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--mute);
+}
+
+.clarification-q {
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.clarification-a,
+.clarification-note {
+ color: var(--ink-2);
+ line-height: 1.55;
+}
+
+.clarification-foot {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--amber);
+}
+
+.clarification-foot.resolved {
+ color: var(--pass);
+}
+
+.work-item-card {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid var(--line-soft);
+ background: color-mix(in oklab, var(--panel-2) 84%, transparent);
+}
+
+.work-item-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.work-item-id {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--mute);
+}
+
+.work-item-title {
+ margin-top: 4px;
+ color: var(--ink);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.work-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.evidence-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.evidence-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px;
+ border: 1px solid var(--line-soft);
+ background: color-mix(in oklab, var(--panel-2) 84%, transparent);
+}
+
+.evidence-value {
+ color: var(--ink);
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.evidence-value.mauve {
+ color: var(--mauve);
+}
+
+.evidence-value.amber {
+ color: var(--amber);
+}
+
+.evidence-value.pass {
+ color: var(--pass);
+}
+
+.verify-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 16px;
+ padding: 14px;
+ border: 1px solid var(--line-soft);
+ background: color-mix(in oklab, var(--panel-2) 84%, transparent);
+}
+
+.verify-main {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.verify-criterion {
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.45;
+}
+
+.verify-support {
+ color: var(--ink-2);
+ line-height: 1.55;
+}
+
+.verify-side {
+ min-width: 104px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: flex-end;
+}
+
+.approval-state-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.sidepanel {
+ grid-column: 3;
+ grid-row: 2;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-height: 0;
+ padding: 20px 20px 16px 0;
+ overflow: auto;
+ scrollbar-width: thin;
+}
+
+.sidepanel .panel-card {
+ flex: 0 0 auto;
+}
+
+.panel-card {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ border: 1px solid var(--line);
+ background: var(--panel);
+ overflow: hidden;
+}
+
+.panel-card > :not(.panel-head) {
+ padding-left: 14px;
+ padding-right: 14px;
+}
+
+.panel-card > :last-child {
+ padding-bottom: 14px;
+}
+
+.readiness-block {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.checklist-block {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.check-row,
+.inspector-row,
+.activity-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.check-row {
+ padding: 0 0 2px;
+ color: var(--ink-2);
+}
+
+.check-value.pass {
+ color: var(--pass);
+}
+
+.check-value.mauve {
+ color: var(--mauve);
+}
+
+.check-value.amber {
+ color: var(--amber);
+}
+
+.top-gap {
+ margin-top: 2px;
+}
+
+.selection-strip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding-top: 2px;
+}
+
+.selection-strip span {
+ padding: 3px 7px;
+ border: 1px solid var(--line-soft);
+ border-radius: 999px;
+ color: var(--mute);
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.inspector-card {
+ opacity: 0.92;
+}
+
+.inspector-card .panel-head {
+ padding-top: 9px;
+ padding-bottom: 9px;
+}
+
+.inspector-row {
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.inspector-row:last-child {
+ padding-bottom: 0;
+ border-bottom: 0;
+}
+
+.inspector-term {
+ color: var(--ink);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.inspector-note {
+ margin-top: 4px;
+ color: var(--mute);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.inspector-foot {
+ padding-top: 2px;
+ color: var(--mute-2);
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.04em;
+}
+
+.compact-card .key-grid {
+ gap: 10px 12px;
+}
+
+.proof-detail-card {
+ gap: 9px;
+}
+
+.proof-detail-card .detail-block {
+ gap: 7px;
+ padding: 10px 12px;
+}
+
+.proof-detail-card .bullet-list {
+ gap: 5px;
+}
+
+.proof-detail-card .proof-archive-line {
+ padding-top: 2px;
+}
+
+.bottompanel {
+ grid-column: 2 / 4;
+ grid-row: 3;
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.85fr);
+ gap: 16px;
+ padding: 0 20px 20px 24px;
+ min-height: 0;
+}
+
+.activity-card,
+.control-card {
+ min-height: 0;
+}
+
+.activity-list {
+ overflow: auto;
+ padding-bottom: 6px;
+}
+
+.activity-row {
+ align-items: center;
+ padding: 0 14px 10px;
+}
+
+.activity-ts {
+ width: 70px;
+ flex-shrink: 0;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ color: var(--mute-2);
+}
+
+.activity-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.activity-kind {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-2);
+}
+
+.activity-note {
+ margin-top: 3px;
+ color: var(--mute);
+ line-height: 1.45;
+}
+
+.control-card {
+ display: flex;
+ flex-direction: column;
+}
+
+.control-copy {
+ color: var(--ink-2);
+ line-height: 1.6;
+}
+
+.control-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ color: var(--mute);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+@media (max-width: 1380px) {
+ .app {
+ grid-template-columns: 208px minmax(0, 1fr) 340px;
+ }
+
+ .topbar {
+ grid-template-columns: 208px minmax(0, 1fr) 340px;
+ }
+
+ .detail-grid.two-up,
+ .evidence-grid,
+ .work-grid,
+ .key-grid,
+ .repo-card-grid,
+ .readiness-card-grid,
+ .proof-meta-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .bottompanel {
+ grid-template-columns: 1fr;
+ }
+}
+
+.mobile-companion {
+ width: 100%;
+ min-height: 100dvh;
+ max-width: 760px;
+ margin: 0 auto;
+ padding: calc(18px + env(safe-area-inset-top)) 14px calc(56px + env(safe-area-inset-bottom));
+ background:
+ radial-gradient(circle at top left, color-mix(in oklab, var(--mauve) 12%, transparent), transparent 34%),
+ var(--bg);
+ overflow-x: hidden;
+}
+
+.mobile-hero,
+.mobile-card {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: color-mix(in oklab, var(--panel) 94%, transparent);
+ box-shadow: inset 0 1px 0 color-mix(in oklab, white 4%, transparent);
+}
+
+.mobile-hero {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 18px 16px;
+}
+
+.mobile-brand {
+ width: fit-content;
+ padding: 4px 9px;
+ border: 1px solid var(--mauve-soft);
+ border-radius: 999px;
+ color: var(--mauve);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.mobile-hero h1,
+.mobile-card h2 {
+ margin: 0;
+ color: var(--ink);
+ line-height: 1.15;
+}
+
+.mobile-hero h1 {
+ font-size: clamp(24px, 7vw, 34px);
+ letter-spacing: -0.03em;
+}
+
+.mobile-hero p {
+ margin: 0;
+ color: var(--ink-2);
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.mobile-hero span,
+.mobile-action-note {
+ color: var(--mute);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.mobile-segmented {
+ position: sticky;
+ top: env(safe-area-inset-top);
+ z-index: 5;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 4px;
+ margin: 12px 0;
+ padding: 4px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: color-mix(in oklab, var(--bg-2) 96%, black);
+ box-shadow: 0 10px 28px color-mix(in oklab, black 24%, transparent);
+}
+
+.mobile-segmented button {
+ min-width: 0;
+ min-height: 44px;
+ padding: 0 8px;
+ border-radius: 999px;
+ color: var(--ink-2);
+ cursor: pointer;
+ font-family: var(--mono);
+ font-size: 9.5px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.mobile-segmented button.active {
+ background: color-mix(in oklab, var(--mauve) 16%, var(--panel-2));
+ color: var(--ink);
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--mauve) 42%, var(--line));
+}
+
+.mobile-surface {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.mobile-card {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px;
+}
+
+.mobile-card-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.mobile-card h2 {
+ margin-top: 3px;
+ font-size: 18px;
+ letter-spacing: -0.01em;
+}
+
+.mobile-card-kicker {
+ color: var(--mauve);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ overflow-wrap: anywhere;
+}
+
+.mobile-stat-grid,
+.mobile-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.mobile-stat,
+.mobile-facts > div {
+ min-width: 0;
+ padding: 10px;
+ border: 1px solid var(--line-soft);
+ border-radius: 8px;
+ background: color-mix(in oklab, var(--panel-2) 70%, transparent);
+}
+
+.mobile-stat {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.mobile-stat span,
+.mobile-facts dt {
+ color: var(--mute);
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.mobile-stat b,
+.mobile-facts dd {
+ min-width: 0;
+ margin: 0;
+ overflow-wrap: anywhere;
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.35;
+}
+
+.mobile-stat.pass b {
+ color: var(--pass);
+}
+
+.mobile-stat.amber b {
+ color: var(--amber);
+}
+
+.mobile-stat.block b {
+ color: var(--block);
+}
+
+.mobile-stat.mauve b {
+ color: var(--mauve);
+}
+
+.mobile-queue {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.mobile-queue-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-items: center;
+ width: 100%;
+ min-height: 48px;
+ padding: 11px 12px;
+ border: 1px solid var(--line-soft);
+ border-radius: 8px;
+ background: color-mix(in oklab, var(--panel-2) 70%, transparent);
+ color: var(--ink-2);
+ text-align: left;
+}
+
+.mobile-queue-row.active {
+ border-color: color-mix(in oklab, var(--mauve) 48%, var(--line));
+ background: linear-gradient(180deg, color-mix(in oklab, var(--mauve) 9%, var(--panel-2)), var(--panel-2));
+ color: var(--ink);
+}
+
+.mobile-queue-row span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font-size: 13px;
+ line-height: 1.35;
+}
+
+.mobile-stage-line {
+ padding: 10px 12px;
+ border: 1px dashed var(--mauve-soft);
+ border-radius: 8px;
+ color: var(--ink-2);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+}
+
+.mobile-detail-stack {
+ gap: 8px;
+}
+
+.mobile-detail {
+ border: 1px solid var(--line-soft);
+ border-radius: 8px;
+ background: color-mix(in oklab, var(--panel-2) 70%, transparent);
+ overflow: hidden;
+}
+
+.mobile-detail summary {
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+ color: var(--ink);
+ cursor: pointer;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.mobile-detail p {
+ margin: 0;
+ padding: 0 12px 12px;
+ color: var(--ink-2);
+ font-size: 13px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+
+.mobile-action-row {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.mobile-action-row .secondary {
+ grid-column: 1 / -1;
+}
+
+.mobile-safe-button {
+ width: 100%;
+ min-height: 44px;
+ padding: 0 12px;
+}
+
+.mobile-decision-card {
+ padding-bottom: calc(14px + env(safe-area-inset-bottom));
+}
+
+.mobile-decision-card .status-pill {
+ letter-spacing: 0.02em;
+ text-transform: none;
+}
+
+.mobile-action-note {
+ margin: 0;
+}
+
+@media (max-width: 899px) {
+ html,
+ body {
+ width: 100%;
+ overflow-x: hidden;
+ }
+
+ body {
+ font-size: 13px;
+ }
+
+ .app-shell {
+ min-width: 0;
+ }
+}
+
+@media (max-width: 430px) {
+ .mobile-companion {
+ padding-left: 12px;
+ padding-right: 12px;
+ }
+
+ .mobile-stat-grid,
+ .mobile-facts {
+ grid-template-columns: 1fr;
+ }
+
+ .mobile-card {
+ padding: 13px;
+ }
+}
diff --git a/apps/web/console/src/DemoContractsPage.tsx b/apps/web/console/src/DemoContractsPage.tsx
new file mode 100644
index 0000000..9b7075d
--- /dev/null
+++ b/apps/web/console/src/DemoContractsPage.tsx
@@ -0,0 +1,3065 @@
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+
+import type { ContractDraftResponse } from './contractDraftClient';
+import type { ContractResponse } from './contractDetailClient';
+import type { OrganizationRepositoryContextResponse, RepositoryContextRecord } from './repositoryContextClient';
+import demoContractsCss from './DemoContractsPage.css?raw';
+
+const demoContractsShadowCss = demoContractsCss
+ .replace(':root {', ':host {')
+ .replace('html,\nbody,\n#root {', ':host,\n#root {')
+ .replace('html,\nbody {', ':host {')
+ .replace('body {', ':host {');
+
+type StepIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
+type ActiveSurface = 'contracts' | 'readiness' | 'proof';
+type RepoId = 'trialops-demo' | 'billing-api' | 'frontend-console';
+type ContractRepoId = string;
+type RepoFilter = string;
+type ApprovalState = 'pending' | 'accepted' | 'rework' | 'blocked';
+type Tone = 'mauve' | 'amber' | 'pass' | 'block';
+
+interface Stage {
+ id: string;
+ name: string;
+}
+
+interface RepoContext {
+ repo: string;
+ bound: 'да' | 'нет';
+ init: string;
+ docsIndexed: number;
+ readiness: number;
+ scanStatus: string;
+ testsStatus: string;
+ ciStatus: string;
+ ownersRulesStatus: string;
+ proofSurfaceStatus: string;
+ recommendedMode: string;
+ checklist: Array<{ label: string; value: string; tone: Tone }>;
+ runtimePolicy: string;
+ runtimes: string[];
+}
+
+interface ClarificationCard {
+ ref: string;
+ question: string;
+ answer: string;
+ note: string;
+}
+
+interface WorkItem {
+ id: string;
+ title: string;
+ lane: 'внешний рантайм' | 'ручной шаг' | 'проверка/подтверждение';
+ scope: string;
+ status: string;
+ proofObligation: string;
+}
+
+interface EvidenceItem {
+ label: string;
+ value: string;
+ tone: Tone;
+}
+
+interface VerificationRow {
+ criterion: string;
+ support: string;
+ outcome: string;
+}
+
+interface ContractRecord {
+ id: string;
+ title: string;
+ repo: ContractRepoId;
+ repoFilterValue?: string;
+ backendState?: ContractResponse['state'];
+ currentDraftId?: string;
+ updatedAt?: string;
+ backendContract?: ContractResponse;
+ owner: string;
+ scopeSurface: string;
+ summary: string;
+ defaultStep: StepIndex;
+ goal: string;
+ intakeNotes: string[];
+ inScope: string[];
+ outOfScope: string[];
+ acceptance: string[];
+ proofExpectations: string[];
+ policyNote: string;
+ clarifications: ClarificationCard[];
+ workItems: WorkItem[];
+ evidence: EvidenceItem[];
+ verification: VerificationRow[];
+ changed: string[];
+ unchanged: string[];
+ trust: string[];
+ howToVerify: string[];
+ activity: Record>;
+}
+
+type LiveContractListLoadStatus = 'idle' | 'loading' | 'loaded' | 'error';
+type LiveContractLoadStatus = 'idle' | 'loading' | 'loaded' | 'not_found' | 'error';
+type LiveContractDraftLoadStatus = 'idle' | 'loading' | 'loaded' | 'no_draft' | 'unavailable' | 'error';
+type LiveRepositoryContextLoadStatus = 'idle' | 'loading' | 'loaded' | 'error';
+
+export interface DemoContractsLiveData {
+ contracts: ContractResponse[];
+ selectedContract: ContractResponse | null;
+ selectedDraft: ContractDraftResponse | null;
+ contractListLoadStatus: LiveContractListLoadStatus;
+ contractListError: string;
+ contractLoadStatus: LiveContractLoadStatus;
+ contractError: string;
+ contractDraftLoadStatus: LiveContractDraftLoadStatus;
+ contractDraftError: string;
+ repositoryContext: OrganizationRepositoryContextResponse | null;
+ repositoryContextLoadStatus: LiveRepositoryContextLoadStatus;
+ repositoryContextError: string;
+ repoBindingFilter: string;
+ stateFilter: ContractResponse['state'] | 'all';
+ onContractSelect: (contract: ContractResponse) => void;
+ onRefresh: () => void;
+ onRepoBindingFilterChange: (repoBindingId: string) => void;
+ onStateFilterChange: (state: ContractResponse['state'] | 'all') => void;
+}
+
+interface ProofFeedItem {
+ id: string;
+ contractId: string;
+ repo: ContractRepoId;
+ proofStatus: string;
+ decisionStatus: string;
+ humanApproval: string;
+ linkedEvidence: string;
+ criteriaCoverage: string;
+ summary: string;
+ tone: Tone;
+ changed: string[];
+ unchanged: string[];
+ verified: string[];
+ decisionTrail: string[];
+ archiveLine: string;
+}
+
+interface MobileContractQueueItem {
+ id: string;
+ title: string;
+ status: string;
+ tone: Tone;
+ stage: string;
+ stageProgress: string;
+ policy: string;
+ humanDecision: string;
+ repo: ContractRepoId;
+ detail: {
+ changePacket: string;
+ evidence: string;
+ projectContext: string;
+ decisionTrail: string;
+ };
+}
+
+interface MobileRepoQueueItem {
+ repo: RepoId;
+ readiness: string;
+ status: string;
+ tone: Tone;
+}
+
+interface MobileProofQueueItem {
+ id: string;
+ contractId: string;
+ status: string;
+ coverage: string;
+ tone: Tone;
+}
+
+const STAGES: Stage[] = [
+ { id: 'goal-intake', name: 'Входящий запрос' },
+ { id: 'clarification', name: 'Уточнения' },
+ { id: 'working-contract', name: 'Рабочий контракт' },
+ { id: 'work-items', name: 'Задачи' },
+ { id: 'execution-evidence', name: 'Артефакты выполнения' },
+ { id: 'verification', name: 'Проверка' },
+ { id: 'proof', name: 'Пакет подтверждения' },
+ { id: 'решение', name: 'Решение' },
+];
+
+const REPO_OPTIONS: Array<{ value: RepoFilter; label: string }> = [
+ { value: 'trialops-demo', label: 'trialops-demo' },
+ { value: 'billing-api', label: 'billing-api' },
+ { value: 'all', label: 'Все репозитории' },
+];
+
+const WORKSPACE_REPOS: RepoId[] = ['trialops-demo', 'billing-api', 'frontend-console'];
+
+const REPO_CONTEXTS: Record = {
+ 'trialops-demo': {
+ repo: 'trialops-demo',
+ bound: 'да',
+ init: 'завершено',
+ docsIndexed: 12,
+ readiness: 72,
+ scanStatus: 'контекст просканирован',
+ testsStatus: 'тесты найдены',
+ ciStatus: 'подключено',
+ ownersRulesStatus: 'правила найдены',
+ proofSurfaceStatus: 'доступно',
+ recommendedMode: 'локальное ограниченное выполнение',
+ checklist: [
+ { label: 'Тесты', value: 'найдены', tone: 'pass' },
+ { label: 'CI', value: 'подключено', tone: 'mauve' },
+ { label: 'Правила агента', value: 'есть', tone: 'pass' },
+ ],
+ runtimePolicy: 'только локально',
+ runtimes: ['Codex CLI', 'Claude Code', 'ручной шаг'],
+ },
+ 'billing-api': {
+ repo: 'billing-api',
+ bound: 'да',
+ init: 'завершено',
+ docsIndexed: 7,
+ readiness: 58,
+ scanStatus: 'контекст просканирован частично',
+ testsStatus: 'тесты частично найдены',
+ ciStatus: 'подключено',
+ ownersRulesStatus: 'владельцы не найдены',
+ proofSurfaceStatus: 'доступно после согласования',
+ recommendedMode: 'нужно ручное согласование',
+ checklist: [
+ { label: 'Тесты', value: 'частично', tone: 'amber' },
+ { label: 'CI', value: 'подключено', tone: 'pass' },
+ { label: 'Владельцы/правила', value: 'нет', tone: 'amber' },
+ ],
+ runtimePolicy: 'нужно ручное согласование',
+ runtimes: ['Codex CLI', 'ручной шаг', 'канал проверки без записи'],
+ },
+ 'frontend-console': {
+ repo: 'frontend-console',
+ bound: 'нет',
+ init: 'ожидает',
+ docsIndexed: 0,
+ readiness: 41,
+ scanStatus: 'контекст еще не сканировали',
+ testsStatus: 'неизвестно',
+ ciStatus: 'неизвестно',
+ ownersRulesStatus: 'правила не настроены',
+ proofSurfaceStatus: 'не готово',
+ recommendedMode: 'нужна настройка',
+ checklist: [
+ { label: 'Тесты', value: 'неизвестно', tone: 'amber' },
+ { label: 'CI', value: 'неизвестно', tone: 'amber' },
+ { label: 'Владельцы/правила', value: 'ожидает', tone: 'amber' },
+ ],
+ runtimePolicy: 'нужна настройка',
+ runtimes: ['ручная настройка', 'нужна инициализация'],
+ },
+};
+
+const LIVE_ALL_REPOSITORIES_FILTER = 'all';
+const LIVE_CONTRACT_STATE_OPTIONS: Array<{ value: ContractResponse['state'] | 'all'; label: string }> = [
+ { value: 'all', label: 'Все состояния' },
+ { value: 'seeded', label: 'Seeded' },
+ { value: 'draft', label: 'Draft' },
+ { value: 'ready_for_approval', label: 'Ready for approval' },
+ { value: 'approved', label: 'Approved' },
+];
+
+const EMPTY_LIVE_CONTRACT: ContractRecord = {
+ id: 'Нет контрактов',
+ title: 'Backend не вернул контракты',
+ repo: 'backend',
+ repoFilterValue: LIVE_ALL_REPOSITORIES_FILTER,
+ owner: 'read-only backend',
+ scopeSurface: 'GET /v1/contracts',
+ summary: 'Контракты появятся здесь после создания через существующий CLI/API flow.',
+ defaultStep: 0,
+ goal: 'Показать честное пустое состояние backend-backed Contracts страницы.',
+ intakeNotes: ['GET /v1/contracts вернул пустой список.', 'Локальные demo-контракты скрыты, чтобы не выдавать мок-данные за backend state.'],
+ inScope: ['Read-only discovery', 'Repository context metadata', 'Current draft detail when linked'],
+ outOfScope: ['Workflow mutation controls', 'Execution', 'Gate', 'Proof generation'],
+ acceptance: ['Пустое состояние видно без подмены backend данных моками.'],
+ proofExpectations: ['Проверить backend response и отсутствие mutation calls.'],
+ policyNote: 'Console остается read-only для Contract workflow.',
+ clarifications: [
+ {
+ ref: 'backend-state',
+ question: 'Почему нет строк?',
+ answer: 'Backend не вернул Contract aggregate для текущей Organization/filter.',
+ note: 'Создание контрактов остается за CLI/API workflow, не за этой страницей.',
+ },
+ ],
+ workItems: [],
+ evidence: [{ label: 'Источник', value: 'GET /v1/contracts?limit=50', tone: 'mauve' }],
+ verification: [{ criterion: 'Не показывать мок как real data', support: 'Пустое состояние backend list', outcome: 'Покрыто' }],
+ changed: ['Текущая Contracts страница подключена к read-only backend discovery.'],
+ unchanged: ['Workflow mutation controls не добавлены.', 'Runner/gate/proof по-прежнему вне этой страницы.'],
+ trust: ['Данные берутся из authenticated backend endpoints.', 'Пустой backend список остается пустым на UI.'],
+ howToVerify: ['Проверить network calls `/v1/contracts` и `/repository-context`.'],
+ activity: {
+ 0: [{ kind: 'contract.discovery.empty', note: 'Backend discovery завершился без Contract rows', tone: 'amber' }],
+ },
+};
+
+function makeLiveRepoOptions(repositoryContext: OrganizationRepositoryContextResponse | null) {
+ const contexts = repositoryContext?.contexts ?? [];
+ return [
+ { value: LIVE_ALL_REPOSITORIES_FILTER, label: 'Все репозитории' },
+ ...contexts.map((context) => ({
+ value: context.repo_binding.id,
+ label: context.repo_binding.repository_full_name || context.repo_binding.id,
+ })),
+ ];
+}
+
+function makeLiveRepoContexts(repositoryContext: OrganizationRepositoryContextResponse | null) {
+ const contexts = repositoryContext?.contexts ?? [];
+ return contexts.reduce>((acc, context) => {
+ acc[context.repo_binding.id] = mapRepositoryContext(context);
+ return acc;
+ }, {});
+}
+
+function mapRepositoryContext(context: RepositoryContextRecord): RepoContext {
+ return {
+ repo: context.repo_binding.repository_full_name || context.repo_binding.id,
+ bound: context.repo_binding.state === 'active' ? 'да' : 'нет',
+ init: context.repo_binding.state,
+ docsIndexed: 0,
+ readiness: context.repo_binding.state === 'active' ? 64 : 32,
+ scanStatus: `provider: ${context.repo_binding.provider || 'unknown'}`,
+ testsStatus: 'metadata-only',
+ ciStatus: 'metadata-only',
+ ownersRulesStatus: 'metadata-only',
+ proofSurfaceStatus: 'недоступно в Console view',
+ recommendedMode: context.repo_binding.access_mode || 'metadata_only',
+ checklist: [
+ { label: 'Project', value: context.project.display_name || context.project.slug || context.project.id, tone: 'mauve' },
+ { label: 'RepoBinding', value: context.repo_binding.state || 'unknown', tone: context.repo_binding.state === 'active' ? 'pass' : 'amber' },
+ { label: 'Access mode', value: context.repo_binding.access_mode || 'metadata_only', tone: 'amber' },
+ ],
+ runtimePolicy: 'metadata-only · без provider authorization, checkout, execution, gate или proof',
+ runtimes: [
+ context.repo_binding.default_branch ? `default: ${context.repo_binding.default_branch}` : 'default branch unknown',
+ context.repo_binding.workflow_base_branch ? `workflow base: ${context.repo_binding.workflow_base_branch}` : 'workflow base unknown',
+ context.repo_binding.path_scope ? `path: ${context.repo_binding.path_scope}` : 'path scope unknown',
+ ],
+ };
+}
+
+function liveContractRepoLabel(contract: ContractResponse, repositoryContext: OrganizationRepositoryContextResponse | null) {
+ const match = repositoryContext?.contexts.find((context) => context.repo_binding.id === contract.repo_binding_id);
+ return match?.repo_binding.repository_full_name || contract.repo_binding_id;
+}
+
+function mapLiveContract(
+ contract: ContractResponse,
+ repositoryContext: OrganizationRepositoryContextResponse | null,
+ selectedDraft: ContractDraftResponse | null
+): ContractRecord {
+ const draft = selectedDraft?.contract_id === contract.id ? selectedDraft : null;
+ const stateLabel = contractStateLabel(contract.state);
+ const defaultStep = stepForContractState(contract.state);
+ const repo = liveContractRepoLabel(contract, repositoryContext);
+ const title = draft?.title || `Контракт ${shortContractId(contract.id)}`;
+ const intent = draft?.intent_summary || `Backend Contract aggregate для Goal ${contract.goal_id}.`;
+ const proposedScope = normalizeLiveList(draft?.proposed_scope, [`RepoBinding ${contract.repo_binding_id}`, `Goal ${contract.goal_id}`]);
+ const proposedNonGoals = normalizeLiveList(draft?.proposed_non_goals, ['Execution, gate и proof не отображаются этим read-only view.']);
+ const proposedAcceptance = normalizeLiveList(draft?.proposed_acceptance_criteria, ['Публичный Contract aggregate загружен из backend.']);
+ const proposedProof = normalizeLiveList(draft?.proposed_proof_expectations, ['Проверить read-only API response и UI state.']);
+ const expectedChecks = normalizeLiveList(draft?.proposed_expected_checks, ['Console typecheck/test/build']);
+
+ return {
+ id: contract.id,
+ title,
+ repo,
+ repoFilterValue: contract.repo_binding_id,
+ backendState: contract.state,
+ currentDraftId: contract.current_draft_id,
+ updatedAt: contract.updated_at,
+ backendContract: contract,
+ owner: `backend · ${stateLabel}`,
+ scopeSurface: draft ? 'current draft · read-only' : 'contract aggregate · read-only',
+ summary: intent,
+ defaultStep,
+ goal: intent,
+ intakeNotes: [
+ `Contract state: ${stateLabel}`,
+ `Goal: ${contract.goal_id}`,
+ `RepoBinding: ${contract.repo_binding_id}`,
+ ],
+ inScope: proposedScope,
+ outOfScope: proposedNonGoals,
+ acceptance: proposedAcceptance,
+ proofExpectations: proposedProof,
+ policyNote: 'Данные загружены через authenticated read-only Console endpoints. Workflow mutation controls не включены.',
+ clarifications: [
+ {
+ ref: 'contract-state',
+ question: 'Какой backend state у выбранного Contract?',
+ answer: stateLabel,
+ note: `updated_at: ${formatLiveDate(contract.updated_at)}`,
+ },
+ {
+ ref: 'repo-binding',
+ question: 'К какому repository context привязан Contract?',
+ answer: repo,
+ note: contract.repo_binding_id,
+ },
+ {
+ ref: 'current-draft',
+ question: 'Есть ли current draft?',
+ answer: contract.current_draft_id ? contract.current_draft_id : 'Нет linked current_draft_id',
+ note: draft ? 'Draft body загружен из /current-draft.' : 'Draft detail не загружен или отсутствует.',
+ },
+ ],
+ workItems: [
+ {
+ id: 'READ-01',
+ title: 'Read-only Contract aggregate',
+ lane: 'проверка/подтверждение',
+ scope: `GET /v1/contracts/${contract.id}`,
+ status: 'Загружено',
+ proofObligation: 'Не выполнять workflow mutation из Console.',
+ },
+ {
+ id: 'READ-02',
+ title: 'Current draft detail',
+ lane: 'проверка/подтверждение',
+ scope: contract.current_draft_id ? `GET /v1/contracts/${contract.id}/current-draft` : 'current_draft_id absent',
+ status: draft ? 'Загружено' : 'Недоступно',
+ proofObligation: 'Показывать только read-only draft fields.',
+ },
+ ],
+ evidence: [
+ { label: 'Contract ID', value: contract.id, tone: 'mauve' },
+ { label: 'State', value: stateLabel, tone: contractStateTone(contract.state) },
+ { label: 'Updated', value: formatLiveDate(contract.updated_at), tone: 'pass' },
+ { label: 'Current draft', value: contract.current_draft_id || 'нет', tone: contract.current_draft_id ? 'pass' : 'amber' },
+ ],
+ verification: expectedChecks.map((check) => ({
+ criterion: check,
+ support: draft ? 'proposed_expected_checks из current draft' : 'fallback Console verification',
+ outcome: 'Read-only',
+ })),
+ changed: proposedScope,
+ unchanged: proposedNonGoals,
+ trust: [
+ 'Bearer token хранится только в React memory.',
+ 'Страница читает backend Contract state и не пишет lifecycle state.',
+ 'Repository context metadata не означает provider authorization или checkout readiness.',
+ ],
+ howToVerify: [
+ 'Проверить `/v1/contracts?limit=50` после login.',
+ 'Выбрать Contract row и проверить `/v1/contracts/{id}`.',
+ 'Если есть current_draft_id, проверить `/v1/contracts/{id}/current-draft`.',
+ ],
+ activity: makeLiveActivity(contract, draft),
+ };
+}
+
+function normalizeLiveList(values: string[] | undefined, fallback: string[]) {
+ return Array.isArray(values) && values.length > 0 ? values : fallback;
+}
+
+function contractStateLabel(state: ContractResponse['state']) {
+ return state === 'ready_for_approval'
+ ? 'Ready for approval'
+ : state.charAt(0).toUpperCase() + state.slice(1);
+}
+
+function contractStateTone(state: ContractResponse['state']): Tone {
+ return state === 'approved' ? 'pass' : state === 'ready_for_approval' ? 'amber' : 'mauve';
+}
+
+function stepForContractState(state: ContractResponse['state']): StepIndex {
+ return state === 'seeded' ? 1 : state === 'draft' ? 2 : state === 'ready_for_approval' ? 6 : 7;
+}
+
+function shortContractId(id: string) {
+ return id.length > 12 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id;
+}
+
+function formatLiveDate(value: string | undefined) {
+ if (!value) {
+ return 'unknown';
+ }
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return value;
+ }
+ return parsed.toLocaleString('ru-RU', {
+ day: '2-digit',
+ month: 'short',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function makeLiveActivity(contract: ContractResponse, draft: ContractDraftResponse | null) {
+ const stateLabel = contractStateLabel(contract.state);
+ return {
+ 0: [{ kind: 'contract.loaded', note: `Contract ${contract.id} загружен из backend`, tone: 'mauve' as Tone }],
+ 1: [{ kind: 'contract.state', note: `Lifecycle state: ${stateLabel}`, tone: contractStateTone(contract.state) }],
+ 2: [{ kind: 'contract.detail', note: `Goal ${contract.goal_id} · RepoBinding ${contract.repo_binding_id}`, tone: 'mauve' as Tone }],
+ 3: [{ kind: 'contract.readonly', note: 'Workflow mutation controls в Console не включены', tone: 'amber' as Tone }],
+ 4: [{ kind: 'contract.updated', note: `Updated ${formatLiveDate(contract.updated_at)}`, tone: 'pass' as Tone }],
+ 5: [{ kind: 'contract.checks', note: draft ? 'Expected checks получены из current draft' : 'Current draft недоступен', tone: draft ? 'pass' as Tone : 'amber' as Tone }],
+ 6: [{ kind: 'contract.proof', note: draft ? 'Proof expectations получены из current draft' : 'Proof/gate остаются недоступны', tone: draft ? 'pass' as Tone : 'amber' as Tone }],
+ 7: [{ kind: 'contract.decision', note: contract.state === 'approved' ? 'Contract approved в backend' : 'Human approval через Console не выполняется', tone: contract.state === 'approved' ? 'pass' as Tone : 'amber' as Tone }],
+ };
+}
+
+function mapMobileContractQueueItem(contract: ContractRecord): MobileContractQueueItem {
+ const stateLabel = contract.backendState ? contractStateLabel(contract.backendState) : getStatus(contract.defaultStep, 'pending');
+ const stage = STAGES[contract.defaultStep]?.name ?? 'Контракт';
+
+ return {
+ id: contract.id,
+ title: contract.title,
+ status: stateLabel,
+ tone: contract.backendState ? contractStateTone(contract.backendState) : getStatusTone(stateLabel),
+ stage,
+ stageProgress: contract.currentDraftId ? 'current draft linked' : `${contract.defaultStep + 1}/${STAGES.length}`,
+ policy: contract.policyNote,
+ humanDecision: contract.backendState === 'approved' ? 'Approved in backend' : 'Read-only in Console',
+ repo: contract.repo,
+ detail: {
+ changePacket: contract.summary,
+ evidence: contract.evidence.map((item) => `${item.label}: ${item.value}`).join(' · ') || 'Backend evidence unavailable',
+ projectContext: contract.goal,
+ decisionTrail: contract.trust.join(' · '),
+ },
+ };
+}
+
+const CONTRACTS: ContractRecord[] = [
+ {
+ id: 'C-0147',
+ title: 'Ручное решение',
+ repo: 'trialops-demo',
+ owner: 'Vitaly · продукт и поставка',
+ scopeSurface: 'демо-оболочка · пакет изменения',
+ summary: 'Показать детали контракта по выбранному репозиторию и отдельный ручной шаг решения, не расширяя демо-оболочку.',
+ defaultStep: 0,
+ goal:
+ 'Показать, как один запрос по репозиторию проходит путь от формулировки до пакета подтверждения и ручного решения без бэкенд, роутинг и реальных интеграций.',
+ intakeNotes: [
+ 'Репозиторий задает контекст, но главным рабочим объектом остается контракт.',
+ 'Пакет изменения остается понятным человеку и привязан к одному выбранному контракту.',
+ 'Контекст проекта должен быть отдельно от цепочки контракта.',
+ ],
+ inScope: [
+ 'Переключатель репозиториев: trialops-demo, billing-api и режим Все репозитории.',
+ 'Список контрактов с бейджами репозиториев и детальной карточкой выбранного контракта.',
+ 'Явные стадии: рабочий контракт, задачи, артефакты выполнения, проверка, пакет подтверждения и решение.',
+ 'Отдельный блок контекста проекта: привязка репозитория, готовность, политика и рантаймы.',
+ ],
+ outOfScope: [
+ 'Бэкенд, API-вызовы, авторизация, роутинг, хранение и серверная логика.',
+ 'Реальное сканирование репозитория, выполнение рантайма или генерация подтверждения.',
+ 'Отдельный агрегированный дашборд или чат-ориентированная рабочая область.',
+ ],
+ acceptance: [
+ 'Выбранный репозиторий по умолчанию фильтрует список контрактов; Все репозитории работают только как обзор.',
+ 'Карточка выбранного контракта всегда показывает связанный репозиторий.',
+ 'Готовность проекта видна вне цепочки контракта.',
+ 'Финальная стадия требует явного решения человека перед статусом Принято.',
+ ],
+ proofExpectations: [
+ 'Показать, какие критерии покрыты и какими демо-артефактами.',
+ 'Показать, что изменилось и что осталось без изменений в демо-оболочке.',
+ 'Оставить сценарий проверяемым, но не выдавать его за реальное выполнение.',
+ ],
+ policyNote:
+ 'Унаследованный контекст проекта: правила найдены, политика только локально, весь сценарий остается внутри демо-оболочки.',
+ clarifications: [
+ {
+ ref: 'переключатель репозитория',
+ question: 'Какие представления репозитория нужны в демо-оболочке?',
+ answer: 'trialops-demo, billing-api и Все репозитории',
+ note: 'По умолчанию открыт trialops-demo; Все репозитории — обзорный режим.',
+ },
+ {
+ ref: 'главный объект',
+ question: 'Что является главным рабочим объектом?',
+ answer: 'Контракт, а не общий пакет изменения без привязки к репозиторию',
+ note: 'Детальная карточка может оставаться пакетом изменения для выбранного контракта.',
+ },
+ {
+ ref: 'контекст проекта',
+ question: 'Где живут привязка репозитория и готовность?',
+ answer: 'Вне цепочки контракта, в постоянном боковом блоке',
+ note: 'Готовность к поставке уходит из верхней панели в контекст репозитория.',
+ },
+ {
+ ref: 'задачи',
+ question: 'Как показать ограниченные задачи?',
+ answer: 'Как задачи с типом, областью, статусом и обязанностью по подтверждению',
+ note: 'Минимум одна задача должна быть явно ручной.',
+ },
+ {
+ ref: 'ручное решение',
+ question: 'Что нужно перед финальным результатом?',
+ answer: 'Явное решение человека: принять, отправить на доработку или заблокировать',
+ note: 'Ожидание решения и Принято должны визуально отличаться.',
+ },
+ ],
+ workItems: [
+ {
+ id: 'WI-01',
+ title: 'Привязка оболочки к репозиторию',
+ lane: 'внешний рантайм',
+ scope: 'src/App.tsx · верхняя панель, левый список, карточка выбранного контракта',
+ status: 'В области',
+ proofObligation: 'Заметка по затронутой поверхности и отметка состояния контракта в центральной панели',
+ },
+ {
+ id: 'WI-02',
+ title: 'Проверка текста ручного решения',
+ lane: 'ручной шаг',
+ scope: 'Текст решения и блок проверки',
+ status: 'Только вручную',
+ proofObligation: 'Ручной шаг отмечен выполненным в пакете артефактов',
+ },
+ {
+ id: 'WI-03',
+ title: 'Пересборка проверки и подтверждения',
+ lane: 'проверка/подтверждение',
+ scope: 'Матрица покрытия критериев и сводка подтверждения',
+ status: 'В очереди',
+ proofObligation: 'Матрица покрытия критериев артефактами',
+ },
+ ],
+ evidence: [
+ { label: 'Исполнитель', value: 'Codex CLI в локальной рабочей области вне Goalrail', tone: 'mauve' },
+ { label: 'Чекпоинт синхронизирован', value: 'Контракт C-0147 · пакет v3 синхронизирован обратно в демо-оболочку', tone: 'pass' },
+ { label: 'Измененные файлы / область', value: 'src/App.tsx, src/App.css · только демо-оболочка', tone: 'pass' },
+ { label: 'Отметки', value: 'Снимки состояний + отметки привязки критериев', tone: 'mauve' },
+ { label: 'Ручной шаг выполнен', value: 'Текст ручного решения проверен оператором', tone: 'amber' },
+ { label: 'Артефакт приложен', value: 'Сводка изменения · заметки подтверждения · инструкции повтора', tone: 'pass' },
+ ],
+ verification: [
+ {
+ criterion: 'Переключатель репозитория фильтрует контракты без отдельного дашборд',
+ support: 'Состояние фильтра и карточка выбранного контракта сохраняются в центральной панели',
+ outcome: 'Покрыто',
+ },
+ {
+ criterion: 'Готовность проекта находится вне цепочки контракта',
+ support: 'Постоянная боковая панель с контекстом проекта, метрика готовности и чеклист',
+ outcome: 'Покрыто',
+ },
+ {
+ criterion: 'Задачи показывают тип, область, статус и обязанность по подтверждению',
+ support: 'Три задачи, включая ручной шаг',
+ outcome: 'Покрыто',
+ },
+ {
+ criterion: 'Решение человека явно требуется перед финальным решением',
+ support: 'Стадия решения с действиями принять, вернуть на доработку и заблокировать',
+ outcome: 'Покрыто',
+ },
+ ],
+ changed: [
+ 'В оболочку добавлены переключатель репозиториев и список контрактов с привязкой к репозиторию.',
+ 'Один выбранный контракт управляет центральной карточкой и пакетом изменения.',
+ 'Контекст проекта вынесен из потока и теперь содержит готовность к поставке.',
+ 'Финальное решение теперь требует видимого ручного шага.',
+ ],
+ unchanged: [
+ 'Нет бэкенд, API-вызовы, роутинг, авторизация, хранение или серверная логика.',
+ 'Нет реального сканирования репозитория, выполнение рантайма или синхронизация интеграций.',
+ 'Нет редизайна сверх ограниченной перестройки оболочки и текстовых правок.',
+ ],
+ trust: [
+ 'Все данные остаются в локальных мок-константах и состоянии интерфейса.',
+ 'Артефакты выполнения описаны как результат внешнего рантайма, а не как выполнение внутри Goalrail.',
+ 'Подтверждение показывает измененную и нетронутую область, чтобы не было расползания области.',
+ 'Финальный результат по-прежнему ждет решения человека.',
+ ],
+ howToVerify: [
+ 'Проверить сводку изменений в карточке выбранного контракта.',
+ 'Проверить список затронутых файлов в артефактах выполнения.',
+ 'Пройти состояние интерфейса через каждую стадию сценария.',
+ 'Подтвердить каждый критерий приемки на стадиях проверки и подтверждения.',
+ ],
+ activity: {
+ 0: [
+ { kind: 'goal.intake', note: 'Контракт создан для trialops-demo', tone: 'mauve' },
+ { kind: 'repo.bound', note: 'Контекст репозитория закреплен за trialops-demo', tone: 'pass' },
+ ],
+ 1: [
+ { kind: 'clarification.answered', note: '5 уточнений свернуты во входные данные контракта', tone: 'mauve' },
+ ],
+ 2: [
+ { kind: 'contract.drafted', note: 'Цель, область, критерии и ожидания по подтверждению собраны', tone: 'mauve' },
+ ],
+ 3: [
+ { kind: 'work-items.ready', note: 'Внешний рантайм, ручной шаг и каналы проверки и подтверждения объявлены', tone: 'pass' },
+ ],
+ 4: [
+ { kind: 'evidence.synced', note: 'Отметки внешнего рантайма прикреплены к пакету контракта', tone: 'pass' },
+ ],
+ 5: [
+ { kind: 'verification.covered', note: 'Критерии сопоставлены с именованными отметками артефактов', tone: 'pass' },
+ ],
+ 6: [
+ { kind: 'proof.ready', note: 'Измененная и неизменная область собраны для проверки', tone: 'pass' },
+ ],
+ 7: [
+ { kind: 'decision.pending', note: 'Решение человека требуется перед статусом Принято', tone: 'amber' },
+ ],
+ },
+ },
+ {
+ id: 'C-0148',
+ title: 'Фильтры CSV-экспорта',
+ repo: 'trialops-demo',
+ owner: 'Masha · руководитель поставки',
+ scopeSurface: 'окно экспорта · текст чипов фильтра',
+ summary: 'Выполнение идет, каналы подтверждения еще собирают отметки.',
+ defaultStep: 4,
+ goal: 'Сделать фильтры CSV-экспорта явными в демо-оболочке без изменения транспорта экспорта или хранения.',
+ intakeNotes: ['Контракт остается привязанным к trialops-demo.', 'Текст экспорта требует ручной проверки.'],
+ inScope: ['Чипы фильтров', 'Сводка выбора', 'Заметка о готовности экспорта'],
+ outOfScope: ['Генерация CSV', 'Хранилище', 'Фоновые задачи'],
+ acceptance: ['Выбранные фильтры можно проверить', 'Заметка по ручной проверке названия видна'],
+ proofExpectations: ['Отметка для измененной области', 'Ручная проверка текста'],
+ policyNote: 'Унаследованный контекст проекта: локально, только демо без реального рантайма экспорта.',
+ clarifications: [
+ {
+ ref: 'filters',
+ question: 'Какие фильтры важны в демо?',
+ answer: 'Владелец, период и состояние',
+ note: 'В области только поверхность интерфейса.',
+ },
+ {
+ ref: 'naming',
+ question: 'Кто согласует текст экспорта?',
+ answer: 'Ручной проверяющий',
+ note: 'Название остается ручным шагом.',
+ },
+ ],
+ workItems: [
+ {
+ id: 'WI-11',
+ title: 'Оболочка фильтров CSV-экспорта',
+ lane: 'внешний рантайм',
+ scope: 'Строка чипы фильтра и полоса сводки',
+ status: 'В работе',
+ proofObligation: 'Снимок состояния',
+ },
+ {
+ id: 'WI-12',
+ title: 'Согласование текста',
+ lane: 'ручной шаг',
+ scope: 'Понятный человеку лейбл экспорта',
+ status: 'Только вручную',
+ proofObligation: 'Заметка о согласовании оператора',
+ },
+ ],
+ evidence: [
+ { label: 'Исполнитель', value: 'Codex CLI', tone: 'mauve' },
+ { label: 'Чекпоинт синхронизирован', value: 'Фикстура состояния фильтров экспорта', tone: 'pass' },
+ ],
+ verification: [
+ { criterion: 'Фильтры остаются проверяемыми', support: 'Сводка фильтров в интерфейсе', outcome: 'Частично' },
+ ],
+ changed: ['Оформление фильтров в оболочке'],
+ unchanged: ['Нет экспорт бэкенд'],
+ trust: ['Только мок-поверхность'],
+ howToVerify: ['Проверить сводку фильтров'],
+ activity: {
+ 0: [{ kind: 'goal.intake', note: 'Запрос по фильтрам CSV-экспорта записан', tone: 'mauve' }],
+ 4: [{ kind: 'execution.running', note: 'Отметки еще прикрепляются', tone: 'amber' }],
+ },
+ },
+ {
+ id: 'C-0151',
+ title: 'Правка цены переключателя',
+ repo: 'trialops-demo',
+ owner: 'Nika · продуктовые операции',
+ scopeSurface: 'текст панели цены',
+ summary: 'Пакет подтверждения готов и ждет решения человека.',
+ defaultStep: 7,
+ goal: 'Поправить текст цены переключателя в демо-оболочке и удержать релиз до ручного решения.',
+ intakeNotes: ['Демо-обновление только в интерфейсе.'],
+ inScope: ['Текст панели цены'],
+ outOfScope: ['Логика биллинга'],
+ acceptance: ['Текст можно проверить'],
+ proofExpectations: ['Заметка о решении'],
+ policyNote: 'Ручное решение остается обязательным перед принятием результата.',
+ clarifications: [
+ {
+ ref: 'текст',
+ question: 'Кто согласует формулировку?',
+ answer: 'Ручной проверяющий',
+ note: 'Нет автоматического принятия.',
+ },
+ ],
+ workItems: [
+ {
+ id: 'WI-21',
+ title: 'Правка текста',
+ lane: 'ручной шаг',
+ scope: 'Лейблы цены',
+ status: 'Ждет решения',
+ proofObligation: 'Решение проверяющего',
+ },
+ ],
+ evidence: [{ label: 'Артефакт приложен', value: 'Заметка проверки текста', tone: 'pass' }],
+ verification: [{ criterion: 'Текст изменен только в области', support: 'Заметка проверки', outcome: 'Покрыто' }],
+ changed: ['Только текст цены'],
+ unchanged: ['Поведение биллинга'],
+ trust: ['Ручная точка принятия остается активной'],
+ howToVerify: ['Прочитать изменение текста'],
+ activity: {
+ 0: [{ kind: 'goal.intake', note: 'Запрос по тексту цены поставлен в очередь', tone: 'mauve' }],
+ 7: [{ kind: 'decision.pending', note: 'Решение проверяющего еще открыто', tone: 'amber' }],
+ },
+ },
+ {
+ id: 'C-0149',
+ title: 'Усиление журнала аудита',
+ repo: 'billing-api',
+ owner: 'Roma · платформа',
+ scopeSurface: 'рамка отметки аудита',
+ summary: 'Пакет подтверждения собран и готов к проверке.',
+ defaultStep: 6,
+ goal: 'Показать журнал подтверждения аудита для контракта billing-api без намека на реальные серверные записи.',
+ intakeNotes: ['Контекст репозитория — billing-api.'],
+ inScope: ['Сводка подтверждения аудита', 'Лейблы отметки'],
+ outOfScope: ['Записи в базе данных'],
+ acceptance: ['Пакет подтверждения показывает, что изменилось и что не изменилось'],
+ proofExpectations: ['Объяснение доверия'],
+ policyNote: 'В этом демо billing-api сохраняет ту же политику локального рантайма.',
+ clarifications: [
+ {
+ ref: 'аудит',
+ question: 'Что должно показать подтверждение?',
+ answer: 'Измененная область, неизменная область и причины доверия',
+ note: 'В демо нет живого потока аудита.',
+ },
+ ],
+ workItems: [
+ {
+ id: 'WI-31',
+ title: 'Сводка подтверждения аудита',
+ lane: 'проверка/подтверждение',
+ scope: 'Текст пакета подтверждения',
+ status: 'Подтверждение готово',
+ proofObligation: 'Именованные причины доверия',
+ },
+ ],
+ evidence: [{ label: 'Отметки', value: 'Карта области аудита + заметка подтверждения', tone: 'pass' }],
+ verification: [{ criterion: 'Подтверждение можно проверить', support: 'Сводка подтверждения', outcome: 'Покрыто' }],
+ changed: ['Оформление подтверждения аудита'],
+ unchanged: ['Нет платежного рантайма'],
+ trust: ['Подтверждение называет неизменную область'],
+ howToVerify: ['Проверить сводку подтверждения'],
+ activity: {
+ 0: [{ kind: 'goal.intake', note: 'Запрос на усиление журнала аудита записан', tone: 'mauve' }],
+ 6: [{ kind: 'proof.ready', note: 'Пакет подтверждения готов к проверке', tone: 'pass' }],
+ },
+ },
+ {
+ id: 'C-0150',
+ title: 'Правка синхронизации лидов',
+ repo: 'billing-api',
+ owner: 'Ira · операции роста',
+ scopeSurface: 'лейблы статуса синхронизации',
+ summary: 'Контракт активен, задачи ограничены, но выполнение еще не начато.',
+ defaultStep: 3,
+ goal: 'Уточнить и ограничить правку синхронизации лидов в billing-api демо-канале без создания реальной интеграции синхронизации.',
+ intakeNotes: ['Контракт все еще активен.'],
+ inScope: ['Лейблы статуса', 'Текст ручной проверки'],
+ outOfScope: ['Реальная синхронизация CRM'],
+ acceptance: ['Лейблы статуса понятны'],
+ proofExpectations: ['Чеклист проверки'],
+ policyNote: 'В этом демо нет интеграционного рантайма.',
+ clarifications: [
+ {
+ ref: 'синхронизация',
+ question: 'Ожидается ли реальная интеграция?',
+ answer: 'Нет, только мок-обновление оболочки',
+ note: 'Выполнение остается только локально.',
+ },
+ ],
+ workItems: [
+ {
+ id: 'WI-41',
+ title: 'Уточнение области',
+ lane: 'внешний рантайм',
+ scope: 'Оболочка лейблов статуса',
+ status: 'Активно',
+ proofObligation: 'Сводка затронутой области',
+ },
+ ],
+ evidence: [{ label: 'Чекпоинт синхронизирован', value: 'Только план задач', tone: 'amber' }],
+ verification: [{ criterion: 'Область остается ограниченной', support: 'План задач', outcome: 'Частично' }],
+ changed: ['Только план задач'],
+ unchanged: ['Нет синхронизации CRM'],
+ trust: ['Область контракта явно задана'],
+ howToVerify: ['Проверить задачи'],
+ activity: {
+ 0: [{ kind: 'goal.intake', note: 'Запрос на правку синхронизации лидов записан', tone: 'mauve' }],
+ 3: [{ kind: 'work-items.ready', note: 'Каналы выполнения подготовлены', tone: 'pass' }],
+ },
+ },
+];
+
+const PROOF_FEED: ProofFeedItem[] = [
+ {
+ id: 'PF-0147',
+ contractId: 'C-0147',
+ repo: 'trialops-demo',
+ proofStatus: 'Ждет решения',
+ decisionStatus: 'Точка принятия готова',
+ humanApproval: 'Ждет решения человека',
+ linkedEvidence: '5 связанных проверок',
+ criteriaCoverage: '5/5 критериев покрыты',
+ summary: 'Контрактная оболочка готов к финальному решению оператора.',
+ tone: 'amber',
+ changed: [
+ 'Список контрактов и выбранный пакет изменения привязаны к репозиторию.',
+ 'Контекст проекта остается вне цепочки контракта.',
+ 'Действие решение явно стоит перед финальным приемка.',
+ ],
+ unchanged: ['Нет бэкенд, роутинг, авторизация, хранение или реальной интеграционной работы.', 'Привязка репозитория не перенесена в цепочки контракта.'],
+ verified: ['Критерии приемки сопоставлены с отметками артефактов.', 'Затронутая область остается ограниченной демо-оболочкой.', 'Решение человека остается в ожидании.'],
+ decisionTrail: ['contract.drafted', 'evidence.synced', 'verification.covered', 'decision.pending'],
+ archiveLine: 'архив://мок/C-0147 · хеш gr_pf_0147_a91c',
+ },
+ {
+ id: 'PF-0148',
+ contractId: 'C-0148',
+ repo: 'trialops-demo',
+ proofStatus: 'Сбор артефактов',
+ decisionStatus: 'Вердикта пока нет',
+ humanApproval: 'Не готово',
+ linkedEvidence: '2 связанные проверки',
+ criteriaCoverage: '2/5 критериев покрыты',
+ summary: 'Поверхность фильтров CSV-экспорта имеет частичные отметки и открытую ручную проверку текста.',
+ tone: 'mauve',
+ changed: ['Оформление чип фильтра проверяется.', 'Отметка сводки выбора есть.'],
+ unchanged: ['Нет генерации CSV, хранения или фоновых задач.'],
+ verified: ['Заметка по области есть.', 'Ручная проверка названия еще открыта.'],
+ decisionTrail: ['contract.active', 'execution.running', 'evidence.partial'],
+ archiveLine: 'архив://мок/C-0148 · черновик хеша ожидает',
+ },
+ {
+ id: 'PF-0082',
+ contractId: 'C-0082',
+ repo: 'billing-api',
+ proofStatus: 'Принято',
+ decisionStatus: 'Принято',
+ humanApproval: 'Принято',
+ linkedEvidence: '6 связанных проверок',
+ criteriaCoverage: 'в архиве',
+ summary: 'Текст отметки аудита биллинга принят и архивирован без изменения области рантайма.',
+ tone: 'pass',
+ changed: ['Формулировки отметки аудита и лейблы архив подтверждения уточнены.'],
+ unchanged: ['Платежный рантайм, база данных и поведение платежей не менялись.'],
+ verified: ['Архив подтверждения приложен.', 'Проверки области и целостности прошли.', 'Решение человека записано.'],
+ decisionTrail: ['verification.covered', 'proof.archived', 'decision.accepted'],
+ archiveLine: 'архив://мок/C-0082 · хеш gr_pf_0082_f43b',
+ },
+ {
+ id: 'PF-0091',
+ contractId: 'C-0091',
+ repo: 'billing-api',
+ proofStatus: 'Заблокировано',
+ decisionStatus: 'Ошибка целостности',
+ humanApproval: 'Проверяющий заблокировал',
+ linkedEvidence: '3 связанные проверки',
+ criteriaCoverage: 'ошибка целостности',
+ summary: 'Изменение синхронизации лидов заблокировано: пакет артефактов не совпал с заявленной областью.',
+ tone: 'block',
+ changed: ['Лейблы статуса синхронизации вынесены на проверку.'],
+ unchanged: ['Синхронизация CRM и поведение billing-api не затронуты.'],
+ verified: ['Канал целостности не пройден.', 'Проверяющий заблокировал пакет.', 'Нужна доработка до архивного подтверждения.'],
+ decisionTrail: ['evidence.synced', 'integrity.failed', 'decision.blocked'],
+ archiveLine: 'архив://мок/C-0091 · заблокированный хеш gr_pf_0091_b7d0',
+ },
+];
+
+const MOBILE_CONTRACT_QUEUE: MobileContractQueueItem[] = [
+ {
+ id: 'C-0147',
+ title: 'Ручное решение',
+ status: 'Активно',
+ tone: 'mauve',
+ stage: 'Входящий запрос',
+ stageProgress: '1/8',
+ policy: 'только локально',
+ humanDecision: 'ждет решения',
+ repo: 'trialops-demo',
+ detail: {
+ changePacket: 'Ручное решение показано как локальный демо-сценарий.',
+ evidence: 'Критерии приемки и отметки затронутой области в очереди.',
+ projectContext: 'У trialops-demo есть правила и доступна поверхность подтверждения.',
+ decisionTrail: 'Входящий запрос открыт. Решение человека ждет подтверждения.',
+ },
+ },
+ {
+ id: 'C-0148',
+ title: 'Фильтры CSV-экспорта',
+ status: 'В работе',
+ tone: 'amber',
+ stage: 'Артефакты выполнения',
+ stageProgress: '5/8',
+ policy: 'только локально',
+ humanDecision: 'не готово',
+ repo: 'trialops-demo',
+ detail: {
+ changePacket: 'Текст чип фильтра проверяется в мок-оболочка.',
+ evidence: 'Есть две отметки; ручная проверка текста еще открыта.',
+ projectContext: 'trialops-demo остается выбранным контекстом репозитория.',
+ decisionTrail: 'Выполнение идет. Сбор артефактов не завершен.',
+ },
+ },
+ {
+ id: 'C-0151',
+ title: 'Правка цены переключателя',
+ status: 'Ждет решения',
+ tone: 'amber',
+ stage: 'Решение',
+ stageProgress: '8/8',
+ policy: 'только локально',
+ humanDecision: 'ждет решения',
+ repo: 'trialops-demo',
+ detail: {
+ changePacket: 'Текст цены переключателя готов к ручной проверке.',
+ evidence: 'Заметка проверки текста прикреплена к пакету подтверждения.',
+ projectContext: 'Поведение биллинга и интеграция не входят в область.',
+ decisionTrail: 'Подтверждение готово. Финальное решение нельзя принять одним тапом на телефоне.',
+ },
+ },
+];
+
+const MOBILE_REPO_QUEUE: MobileRepoQueueItem[] = [
+ { repo: 'trialops-demo', readiness: '72/100', status: 'Готово', tone: 'pass' },
+ { repo: 'billing-api', readiness: '58/100', status: 'Частично', tone: 'amber' },
+ { repo: 'frontend-console', readiness: '41/100', status: 'Настройка', tone: 'block' },
+];
+
+const MOBILE_PROOF_QUEUE: MobileProofQueueItem[] = [
+ { id: 'PF-0147', contractId: 'C-0147', status: 'Ждет решения', coverage: '5/5', tone: 'amber' },
+ { id: 'PF-0148', contractId: 'C-0148', status: 'Сбор артефактов', coverage: '2/5', tone: 'mauve' },
+ { id: 'PF-0082', contractId: 'C-0082', status: 'Принято', coverage: 'архив', tone: 'pass' },
+ { id: 'PF-0091', contractId: 'C-0091', status: 'Заблокировано', coverage: 'ошибка целостности', tone: 'block' },
+];
+
+const INITIAL_STEPS = Object.fromEntries(CONTRACTS.map((contract) => [contract.id, contract.defaultStep])) as Record;
+const INITIAL_APPROVALS = Object.fromEntries(
+ CONTRACTS.map((contract) => [contract.id, contract.defaultStep >= 7 ? 'pending' : 'pending']),
+) as Record;
+
+const CLARIFICATION_DELAYS = [240, 520, 820, 1120, 1400] as const;
+const EVIDENCE_DELAY = 220;
+const VERIFICATION_DELAY = 240;
+
+function cx(...tokens: Array) {
+ return tokens.filter(Boolean).join(' ');
+}
+
+function getStatus(step: StepIndex, approval: ApprovalState) {
+ if (approval === 'accepted') return 'Принято';
+ if (approval === 'blocked') return 'Заблокировано';
+ if (approval === 'rework') return 'Нужна доработка';
+ if (step >= 7) return 'Ждет решения';
+ if (step >= 6) return 'Подтверждение готово';
+ if (step >= 4) return 'В работе';
+ return 'Активно';
+}
+
+function getStatusTone(status: string): Tone {
+ if (status === 'Принято' || status === 'Подтверждение готово') return 'pass';
+ if (status === 'Ждет решения' || status === 'Нужна доработка') return 'amber';
+ if (status === 'Заблокировано') return 'block';
+ return 'mauve';
+}
+
+function getReadinessTone(score: number): Tone {
+ if (score >= 70) return 'pass';
+ if (score >= 50) return 'amber';
+ return 'block';
+}
+
+function getCompactOwner(owner: string) {
+ return owner.split('·')[0]?.trim() ?? owner;
+}
+
+function getShelfStatus(status: string) {
+ if (status === 'Ждет решения') return 'Решение';
+ if (status === 'Нужна доработка') return 'Доработка';
+ if (status === 'Подтверждение готово') return 'Пакет подтверждения';
+ return status;
+}
+
+function getCompactReadinessSignal(value: string) {
+ return value
+ .replace(/^docs\/context scan /, '')
+ .replace(/^context scan /, '')
+ .replace(/ not started$/, ' not started');
+}
+
+const ACTIVITY_KIND_LABELS: Record = {
+ 'clarification.answered': 'уточнения закрыты',
+ 'context.scan': 'контекст просканирован',
+ 'contract.active': 'контракт активен',
+ 'contract.drafted': 'контракт собран',
+ 'contract.selected': 'контракт выбран',
+ 'decision.accepted': 'решение принято',
+ 'decision.blocked': 'решение заблокировано',
+ 'decision.pending': 'решение ожидает',
+ 'decision.state': 'состояние решения',
+ 'evidence.partial': 'артефакты частично',
+ 'evidence.synced': 'артефакты синхронизированы',
+ 'execution.running': 'выполнение идет',
+ 'goal.intake': 'запрос принят',
+ 'integrity.failed': 'целостность нарушена',
+ 'mode.recommended': 'режим выбран',
+ 'proof.archived': 'подтверждение в архиве',
+ 'proof.ready': 'подтверждение готово',
+ 'repo.bound': 'репозиторий привязан',
+ 'repo.context': 'контекст репозитория',
+ 'repo.selected': 'репозиторий выбран',
+ 'verification.covered': 'проверка покрыта',
+ 'work-items.ready': 'задачи готовы',
+};
+
+function formatActivityKind(kind: string) {
+ return ACTIVITY_KIND_LABELS[kind] ?? kind;
+}
+
+function matchesQuery(values: string[], query: string) {
+ const normalizedQuery = query.trim().toLowerCase();
+
+ if (!normalizedQuery) return true;
+
+ return values.some((value) => value.toLowerCase().includes(normalizedQuery));
+}
+
+function getMeters(step: StepIndex, approval: ApprovalState) {
+ const contractPercent = [18, 42, 68, 76, 84, 90, 96, approval === 'accepted' ? 100 : 96][step];
+ const executionPercent = [0, 0, 16, 42, 76, 82, 86, 86][step];
+ const proofPercent = [0, 0, 10, 18, 42, 72, 90, approval === 'accepted' ? 100 : 92][step];
+
+ return {
+ contract: { percent: contractPercent, label: STAGES[Math.min(step, 2)].name },
+ execution: {
+ percent: executionPercent,
+ label: step < 4 ? 'В очереди' : step < 6 ? 'Отметки получены' : 'Готово к проверке',
+ },
+ proof: {
+ percent: proofPercent,
+ label: approval === 'accepted' ? 'Принято' : step >= 7 ? 'Ждет решения' : step >= 6 ? 'Подтверждение готово' : 'Черновик',
+ },
+ };
+}
+
+function getStepSummary(step: StepIndex) {
+ return (
+ [
+ 'Показать путь одного запроса по репозиторию через сценарий контракта.',
+ 'Свести открытые вопросы к ограниченным ответам внутри контракта.',
+ 'Зафиксировать рабочий контракт до начала прогресса по задачам.',
+ 'Показать тип, область, статус и обязанность по подтверждению для каждой задачи.',
+ 'Артефакты выполнения собираются вне Goalrail и синхронизируются обратно.',
+ 'Проверка связывает критерии с артефактами вместо общего текста статуса.',
+ 'Подтверждение показывает, что изменилось, что не изменилось и почему этому можно доверять.',
+ 'Решение человека определяет финальный итог для контракта.',
+ ] as const
+ )[step];
+}
+
+function getActivity(contract: ContractRecord, step: StepIndex, approval: ApprovalState) {
+ const timeline = [
+ { ts: '09:42:08', kind: 'contract.selected', note: `${contract.id} закреплен в центральной карточке`, tone: 'mauve' as Tone },
+ { ts: '09:42:12', kind: 'repo.context', note: `Контекст репозитория ${contract.repo} загружен`, tone: 'pass' as Tone },
+ ];
+
+ for (let index = 0; index <= step; index += 1) {
+ const stageEvents = contract.activity[index] ?? [];
+ stageEvents.forEach((event, eventIndex) => {
+ timeline.push({
+ ts: `09:${43 + index}:${String(8 + eventIndex * 7).padStart(2, '0')}`,
+ kind: event.kind,
+ note: event.note,
+ tone: event.tone,
+ });
+ });
+ }
+
+ if (step >= 7) {
+ timeline.push({
+ ts: '09:50:12',
+ kind: 'decision.state',
+ note:
+ approval === 'accepted'
+ ? 'Решение человека записано · результат принят'
+ : approval === 'blocked'
+ ? 'Проверяющий заблокировал пакет'
+ : approval === 'rework'
+ ? 'Проверяющий запросил доработку'
+ : 'Ждет решения человека перед финальным итогом',
+ tone: approval === 'accepted' ? 'pass' : approval === 'blocked' ? 'block' : 'amber',
+ });
+ }
+
+ return timeline;
+}
+
+function ListBlock({ title, items }: { title: string; items: string[] }) {
+ return (
+
+ {title}
+
+ {items.map((item) => (
+ {item}
+ ))}
+
+
+ );
+}
+
+function useMobileCompanionBreakpoint() {
+ const getMatches = () => (typeof window === 'undefined' ? false : window.matchMedia('(max-width: 899px)').matches);
+ const [isMobileCompanion, setIsMobileCompanion] = useState(getMatches);
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(max-width: 899px)');
+ const handleChange = () => setIsMobileCompanion(mediaQuery.matches);
+
+ handleChange();
+
+ if (typeof mediaQuery.addEventListener === 'function') {
+ mediaQuery.addEventListener('change', handleChange);
+ return () => mediaQuery.removeEventListener('change', handleChange);
+ }
+
+ mediaQuery.addListener(handleChange);
+ return () => mediaQuery.removeListener(handleChange);
+ }, []);
+
+ return isMobileCompanion;
+}
+
+function MobileStat({ label, value, tone = 'mauve' }: { label: string; value: string; tone?: Tone }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function MobileDetailSection({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
+
+function MobileContractsSurface({
+ selectedContract,
+ contracts = MOBILE_CONTRACT_QUEUE,
+ contractsLabel = '3 активных контракта',
+ repositoryLabel = 'trialops-demo',
+ modeLabel = 'демо',
+ onSelectContract,
+}: {
+ selectedContract: MobileContractQueueItem;
+ contracts?: MobileContractQueueItem[];
+ contractsLabel?: string;
+ repositoryLabel?: string;
+ modeLabel?: string;
+ onSelectContract: (contractId: string) => void;
+}) {
+ return (
+
+
+ Контекст
+
+
+ 0 ? 'pass' : 'amber'} />
+
+
+
+
+
+
+
+
+
Очередь контрактов
+
Выберите для проверки
+
+
{modeLabel}
+
+
+ {contracts.map((contract) => (
+ onSelectContract(contract.id)}
+ >
+ {contract.id} · {contract.title} · {contract.status}
+
+ ))}
+
+
+
+
+ Выбранный контракт
+ {selectedContract.title}
+
+
+
репозиторий
+ {selectedContract.repo}
+
+
+
текущая стадия
+ {selectedContract.stage}
+
+
+
политика
+ {selectedContract.policy}
+
+
+
решение человека
+ {selectedContract.humanDecision}
+
+
+ Стадия: {selectedContract.stage} · {selectedContract.stageProgress}
+
+
+
+ {selectedContract.detail.changePacket}
+ {selectedContract.detail.evidence}
+ {selectedContract.detail.projectContext}
+ {selectedContract.detail.decisionTrail}
+
+
+ );
+}
+
+function MobileReadinessSurface({ selectedRepo, onSelectRepo }: { selectedRepo: RepoId; onSelectRepo: (repo: RepoId) => void }) {
+ const context = REPO_CONTEXTS[selectedRepo];
+
+ return (
+
+
+
+
+
+
+
Очередь репозиториев
+
Готовность репозитория
+
+
проверка
+
+
+ {MOBILE_REPO_QUEUE.map((repo) => (
+ onSelectRepo(repo.repo)}
+ >
+ {repo.repo} · {repo.readiness} · {repo.status}
+
+ ))}
+
+
+
+
+ Детали репозитория
+ {context.repo}
+
+
+
готовность
+ {context.readiness}/100
+
+
+
инициализация
+ {context.init}
+
+
+
документов
+ {context.docsIndexed}
+
+
+
тесты
+ {getCompactReadinessSignal(context.testsStatus)}
+
+
+
CI
+ {context.ciStatus}
+
+
+
Правила агента
+ {context.ownersRulesStatus.replace('Правила агента ', '')}
+
+
+
поверхность подтверждения
+ {context.proofSurfaceStatus}
+
+
+
рекомендованный режим
+ {context.recommendedMode}
+
+
+
+
+
+ Действия
+
+
+ Анализировать
+
+
+ Сканировать контекст
+
+
+ Добавить репозиторий
+
+
+ Настройку лучше делать на десктопе. Демо-кнопки ничего не подключают и не меняют.
+
+
+ );
+}
+
+function MobileProofSurface({
+ selectedProof,
+ onSelectProof,
+}: {
+ selectedProof: ProofFeedItem;
+ onSelectProof: (proofId: string) => void;
+}) {
+ const proofDecisionReady = selectedProof.contractId === 'C-0147' && selectedProof.criteriaCoverage.startsWith('5/5');
+ const archivedProof = selectedProof.proofStatus === 'Принято';
+ const decisionRestriction = archivedProof ? 'Архивное подтверждение: только чтение' : 'Покрытие критериев неполное';
+
+ return (
+
+
+
+
+
Очередь пакетов подтверждения
+
Проверка решения
+
+
безопасная проверка
+
+
+ {MOBILE_PROOF_QUEUE.map((proof) => (
+ onSelectProof(proof.id)}
+ >
+ {proof.contractId} · {proof.status} · {proof.coverage}
+
+ ))}
+
+
+
+
+ Выбранный пакет подтверждения
+ {selectedProof.contractId}
+
+
+
контракт
+ {selectedProof.contractId}
+
+
+
репозиторий
+ {selectedProof.repo}
+
+
+
статус подтверждения
+ {selectedProof.proofStatus}
+
+
+
покрытие критериев
+ {selectedProof.criteriaCoverage}
+
+
+
решение человека
+ {selectedProof.contractId === 'C-0147' ? 'Ожидает' : selectedProof.humanApproval}
+
+
+
+
+
+ {selectedProof.changed[0]}
+ {selectedProof.verified[0]}
+ {selectedProof.linkedEvidence}
+ {selectedProof.decisionTrail.slice(0, 3).map(formatActivityKind).join(' · ')}
+ {selectedProof.archiveLine}
+
+
+
+ Действие
+ {proofDecisionReady ? (
+ <>
+
+ Проверить решение
+
+ Только безопасная проверка. Финальное решение нельзя принять одним тапом на телефоне.
+ >
+ ) : (
+ <>
+ Решение недоступно
+ {decisionRestriction}
+ >
+ )}
+
+
+ );
+}
+
+function MobileCompanionPreview({ liveContracts }: { liveContracts?: DemoContractsLiveData }) {
+ const hasLiveContracts = Boolean(liveContracts);
+ const [mobileSurface, setMobileSurface] = useState(() => (liveContracts ? 'contracts' : 'proof'));
+ const [selectedMobileContractId, setSelectedMobileContractId] = useState(() => liveContracts?.selectedContract?.id ?? 'C-0147');
+ const [selectedMobileRepo, setSelectedMobileRepo] = useState('trialops-demo');
+ const [selectedMobileProofId, setSelectedMobileProofId] = useState('PF-0147');
+
+ const liveRepoLabel = liveContracts?.repositoryContext?.contexts[0]?.repo_binding.repository_full_name ?? 'backend';
+ const liveContractRecords = useMemo(() => {
+ if (!liveContracts) {
+ return [];
+ }
+
+ return liveContracts.contracts.map((contract) => (
+ mapLiveContract(contract, liveContracts.repositoryContext, liveContracts.selectedDraft)
+ ));
+ }, [liveContracts]);
+ const mobileContracts = hasLiveContracts
+ ? (liveContractRecords.length > 0 ? liveContractRecords.map(mapMobileContractQueueItem) : [mapMobileContractQueueItem(EMPTY_LIVE_CONTRACT)])
+ : MOBILE_CONTRACT_QUEUE;
+ const selectedMobileContract = mobileContracts.find((contract) => contract.id === selectedMobileContractId) ?? mobileContracts[0];
+ const selectedMobileProof = PROOF_FEED.find((proof) => proof.id === selectedMobileProofId) ?? PROOF_FEED[0];
+
+ useEffect(() => {
+ if (!hasLiveContracts) {
+ return;
+ }
+
+ const nextContractId = liveContracts?.selectedContract?.id ?? mobileContracts[0]?.id;
+ if (nextContractId && !mobileContracts.some((contract) => contract.id === selectedMobileContractId)) {
+ setSelectedMobileContractId(nextContractId);
+ }
+ }, [hasLiveContracts, liveContracts?.selectedContract?.id, mobileContracts, selectedMobileContractId]);
+
+ const handleMobileContractSelect = (contractId: string) => {
+ setSelectedMobileContractId(contractId);
+ const selectedBackendContract = liveContractRecords.find((contract) => contract.id === contractId)?.backendContract;
+ if (selectedBackendContract) {
+ liveContracts?.onContractSelect(selectedBackendContract);
+ }
+ };
+
+ return (
+
+
+ Goalrail
+ Goalrail
+ Короткий режим проверки: контракты, готовность и подтверждения.
+ Полная консоль оператора доступна на десктопе.
+
+
+
+ setMobileSurface('contracts')}>
+ Контракты
+
+ setMobileSurface('readiness')}>
+ Готовность
+
+ setMobileSurface('proof')}>
+ Итог
+
+
+
+ {mobileSurface === 'contracts' ? (
+
+ ) : mobileSurface === 'readiness' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function renderStageContent({
+ contract,
+ projectContext,
+ step,
+ approval,
+ visibleClarifications,
+ visibleEvidence,
+ visibleVerification,
+ onAdvance,
+ onDecision,
+}: {
+ contract: ContractRecord;
+ projectContext: RepoContext;
+ step: StepIndex;
+ approval: ApprovalState;
+ visibleClarifications: number;
+ visibleEvidence: number;
+ visibleVerification: number;
+ onAdvance: () => void;
+ onDecision: (decision: ApprovalState) => void;
+}) {
+ if (step === 0) {
+ return (
+
+
Входящий запрос
+
+
+ Входящий запрос
+ {contract.title}
+ {contract.goal}
+
+ {contract.intakeNotes.map((note) => (
+ {note}
+ ))}
+
+
+
+
+ Пакет запроса
+
+
+
Репозиторий
+ {contract.repo}
+
+
+
Контракт
+ {contract.id}
+
+
+
Раздел
+ {contract.scopeSurface}
+
+
+
Политика
+ {projectContext.runtimePolicy}
+
+
+
+ Привязка репозитория уже есть, но это контекст проекта , а не стадия pipeline.
+
+
+
+
+ );
+ }
+
+ if (step === 1) {
+ return (
+
+
Уточнения · {contract.clarifications.length} из {contract.clarifications.length}
+
+ {contract.clarifications.map((card, index) => {
+ const pinned = index < visibleClarifications;
+
+ return (
+
+
+ Q{index + 1}
+ {card.ref}
+
+ {card.question}
+ {card.answer}
+ {card.note}
+ {pinned ? 'Ответ закреплен в контракте' : 'Ожидает закрепления в контракте'}
+
+ );
+ })}
+
+
+ );
+ }
+
+ if (step === 2) {
+ return (
+
+
Рабочий контракт · черновик v3
+
+ Цель
+ {contract.goal}
+
+
+
+
+
+
+
+
+
+
+ Контекст проекта / политика
+ {contract.policyNote}
+
+
+ Зафиксировать контракт
+
+
+ Утвердить контракт
+
+
+
+
+ );
+ }
+
+ if (step === 3) {
+ return (
+
+
Задачи
+
+ {contract.workItems.map((item) => (
+
+
+
+
{item.id}
+
{item.title}
+
+
{item.status}
+
+
+
+
Тип
+ {item.lane}
+
+
+
Область / раздел
+ {item.scope}
+
+
+
Статус
+ {item.status}
+
+
+
Обязанность по подтверждению
+ {item.proofObligation}
+
+
+
+ ))}
+
+
+ );
+ }
+
+ if (step === 4) {
+ return (
+
+
Артефакты выполнения
+
+ Выполнение прошло вне Goalrail . Goalrail сохраняет синхронизированные артефакты для выбранного контракта, а не чат-лог.
+
+
+ {contract.evidence.slice(0, visibleEvidence).map((item) => (
+
+ {item.label}
+ {item.value}
+
+ ))}
+
+
+ );
+ }
+
+ if (step === 5) {
+ return (
+
+
Проверка
+
+ {contract.verification.slice(0, visibleVerification).map((row) => (
+
+
+
{row.criterion}
+
{row.support}
+
+
+
+ ))}
+
+
+ );
+ }
+
+ if (step === 6) {
+ return (
+
+
Пакет подтверждения
+
+
+
+
+
+
+ );
+ }
+
+ const approvalLabel =
+ approval === 'accepted'
+ ? 'Принято'
+ : approval === 'blocked'
+ ? 'Заблокировано'
+ : approval === 'rework'
+ ? 'Нужна доработка'
+ : 'Ждет решения';
+
+ return (
+
+
Решение
+
+
Состояние решения
+
{approvalLabel}
+
+
+
+
+
+
+
+
+
+
+ Ручное решение
+
+ onDecision('accepted')}>
+ Принять результат
+
+ onDecision('rework')}>
+ Вернуть на доработку
+
+ onDecision('blocked')}>
+ Заблокировать
+
+
+
+
+ );
+}
+
+function DeliveryReadinessSurface({ selectedRepo, onSelectRepo }: { selectedRepo: RepoId; onSelectRepo: (repo: RepoId) => void }) {
+ return (
+
+
+
+
+
Готовность
+
Настройка репозитория и режим работы
+
+
+ Раздел рабочей области
+ Только демо
+
+
+
+
+
+
Готовность · уровень репозитория и проекта
+
+ Этот раздел показывает подключенные репозитории, сигналы готовности, действия настройки и рекомендованный режим работы. Это не
+ стадия цепочки контракта.
+
+
+
+
+ {WORKSPACE_REPOS.map((repo) => {
+ const context = REPO_CONTEXTS[repo];
+
+ return (
+
onSelectRepo(repo)}
+ >
+
+
+
{context.repo}
+
{context.readiness}/100 готовность
+
+
{context.init}
+
+
+
+
+
Сканирование
+ {context.scanStatus}
+
+
+
Тесты
+ {context.testsStatus}
+
+
+
CI
+ {context.ciStatus}
+
+
+
Владельцы/правила
+ {context.ownersRulesStatus}
+
+
+
Поверхность подтверждения
+ {context.proofSurfaceStatus}
+
+
+
Режим
+ {context.recommendedMode}
+
+
+
+ );
+ })}
+
+
+
+
+
Добавить репозиторий
+
Подключить следующий репозиторий
+
+
Действие настройки
+
+ Подключить репозиторий, выполнить инициализацию, просканировать контекст и посчитать готовность.
+
+ Добавить репозиторий
+
+
+
+
+
+
+ );
+}
+
+function DeliveryReadinessInspector({ selectedRepo }: { selectedRepo: RepoId }) {
+ const context = REPO_CONTEXTS[selectedRepo];
+
+ return (
+
+
+
+
Детали репозитория
+
{context.repo}
+
+
+
+
+
Готовность
+ {context.readiness}/100
+
+
+
Инициализация
+ {context.init}
+
+
+
Документов
+ {context.docsIndexed}
+
+
+
Режим
+ {context.recommendedMode}
+
+
+
+
+
+
Готовность
+
{context.readiness}/100
+
+
+
+
+
+
+
+
Сигналы готовности
+
+ Сканирование контекста
+ {getCompactReadinessSignal(context.scanStatus)}
+
+ {context.checklist.map((item) => (
+
+ {item.label}
+ {item.value}
+
+ ))}
+
+ Поверхность подтверждения
+ {context.proofSurfaceStatus}
+
+
+
+ Демо-действия
+
+
+ Анализировать
+
+
+ Запустить инициализацию
+
+
+ Сканировать контекст
+
+
+
+
+
+
+
Граница раздела
+
Только настройка и готовность
+
+
+ Добавление репозитория относится к готовности. Оно не открывает реальную интеграцию и не становится шагом потока контракта.
+
+
+
+ );
+}
+
+function DeliveryReadinessBottomPanel({ selectedRepo }: { selectedRepo: RepoId }) {
+ const context = REPO_CONTEXTS[selectedRepo];
+
+ return (
+
+
+
+
Активность готовности
+
Демо-события настройки
+
+
+ {[
+ ['09:31:02', 'repo.selected', `${context.repo} выбран для деталей готовности`, 'mauve'],
+ ['09:31:12', 'context.scan', context.scanStatus, getReadinessTone(context.readiness)],
+ ['09:31:22', 'mode.recommended', context.recommendedMode, getReadinessTone(context.readiness)],
+ ].map(([ts, kind, note, tone]) => (
+
+
{ts}
+
+
{formatActivityKind(kind)}
+
{note}
+
+
{tone === 'pass' ? 'готово' : tone === 'block' ? 'настройка' : 'проверка'}
+
+ ))}
+
+
+
+
+
+
Управление разделом
+
Нет реальных интеграций
+
+
+ Анализ, инициализация, сканирование контекста и добавление репозитория — демо-действия. Они не вызывают бэкенд и не меняют постоянное состояние.
+
+
+ Репозиторий: {context.repo}
+ Готовность: {context.readiness}/100
+
+
+
+ );
+}
+
+function ProofFeedSurface({ selectedProofId, onSelectProof }: { selectedProofId: string; onSelectProof: (proofId: string) => void }) {
+ return (
+
+
+
+
+
Подтверждения
+
Артефакты и решения по контрактам
+
+
+ Все репозитории по умолчанию
+ Не чат-лог
+
+
+
+
+
+
Подтверждения · обзор по контрактам и репозиториям
+
+ Область по умолчанию — все репозитории. Чипы репозиториев остаются статическими демо-контролы, чтобы раздел читался как контроль подтверждений на уровне рабочей области.
+
+
+ Все репозитории
+ trialops-demo
+ billing-api
+ frontend-console
+
+
+
+
+ {PROOF_FEED.map((item) => (
+
onSelectProof(item.id)}
+ >
+
+
+ {item.contractId}
+ {item.repo}
+
+
{item.proofStatus}
+
+ {item.summary}
+
+
+
Решение
+ {item.decisionStatus}
+
+
+
Решение человека
+ {item.humanApproval}
+
+
+
Артефакты
+ {item.linkedEvidence}
+
+
+
Покрытие
+ {item.criteriaCoverage}
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function ProofFeedInspector({ selectedProof }: { selectedProof: ProofFeedItem }) {
+ return (
+
+ );
+}
+
+function ProofFeedBottomPanel({ selectedProof }: { selectedProof: ProofFeedItem }) {
+ return (
+
+
+
+
Активность ленты подтверждений
+
Только артефакты и решения
+
+
+ {selectedProof.decisionTrail.map((entry, index) => (
+
+
10:{String(12 + index).padStart(2, '0')}:04
+
+
{formatActivityKind(entry)}
+
{selectedProof.contractId} · {selectedProof.repo}
+
+
{selectedProof.tone === 'block' ? 'блок' : selectedProof.tone === 'pass' ? 'готово' : 'событие'}
+
+ ))}
+
+
+
+
+
+
Управление лентой
+
Статическая область
+
+
+ Чипы репозиториев — только демо. Разбор статусов находится слева в очереди подтверждений, поэтому лента остается между контрактами и между репозиториями.
+
+
+ Подтверждение: {selectedProof.contractId}
+ Область по умолчанию: все репозитории
+
+
+
+ );
+}
+
+function DesktopConsole({ liveContracts }: { liveContracts?: DemoContractsLiveData }) {
+ const hasLiveContracts = Boolean(liveContracts);
+ const [activeSurface, setActiveSurface] = useState('contracts');
+ const [repoFilter, setRepoFilter] = useState(() => (liveContracts ? LIVE_ALL_REPOSITORIES_FILTER : 'trialops-demo'));
+ const [repoSelectorOpen, setRepoSelectorOpen] = useState(false);
+ const [contractSearch, setContractSearch] = useState('');
+ const [repoSearch, setRepoSearch] = useState('');
+ const [proofSearch, setProofSearch] = useState('');
+ const [selectedContractId, setSelectedContractId] = useState('C-0147');
+ const [selectedReadinessRepo, setSelectedReadinessRepo] = useState('trialops-demo');
+ const [selectedProofId, setSelectedProofId] = useState(PROOF_FEED[0].id);
+ const [contractSteps, setContractSteps] = useState>(INITIAL_STEPS);
+ const [approvalStates, setApprovalStates] = useState>(INITIAL_APPROVALS);
+ const [visibleClarifications, setVisibleClarifications] = useState(0);
+ const [visibleEvidence, setVisibleEvidence] = useState(0);
+ const [visibleVerification, setVisibleVerification] = useState(0);
+
+ const liveRepoOptions = useMemo(() => makeLiveRepoOptions(liveContracts?.repositoryContext ?? null), [liveContracts?.repositoryContext]);
+ const liveRepoContexts = useMemo(() => makeLiveRepoContexts(liveContracts?.repositoryContext ?? null), [liveContracts?.repositoryContext]);
+ const liveContractRecords = useMemo(() => {
+ if (!liveContracts) {
+ return [];
+ }
+
+ return liveContracts.contracts.map((contract) => (
+ mapLiveContract(contract, liveContracts.repositoryContext, liveContracts.selectedDraft)
+ ));
+ }, [liveContracts]);
+ const contractSource = hasLiveContracts ? liveContractRecords : CONTRACTS;
+ const repoOptions = hasLiveContracts ? liveRepoOptions : REPO_OPTIONS;
+ const selectedLiveContractId = liveContracts?.selectedContract?.id;
+ const selectedLiveContract = selectedLiveContractId
+ ? liveContractRecords.find((contract) => contract.id === selectedLiveContractId)
+ : null;
+
+ const repoScopedContracts = useMemo(() => {
+ return repoFilter === LIVE_ALL_REPOSITORIES_FILTER
+ ? contractSource
+ : contractSource.filter((contract) => (contract.repoFilterValue ?? contract.repo) === repoFilter);
+ }, [contractSource, repoFilter]);
+
+ const visibleContracts = useMemo(() => {
+ return repoScopedContracts.filter((contract) =>
+ matchesQuery([contract.id, contract.title, contract.repo, contract.owner, contract.summary, getStatus(contractSteps[contract.id] ?? contract.defaultStep, approvalStates[contract.id] ?? 'pending')], contractSearch),
+ );
+ }, [approvalStates, contractSearch, contractSteps, repoScopedContracts]);
+
+ const visibleRepos = useMemo(() => {
+ return WORKSPACE_REPOS.filter((repo) => {
+ const context = REPO_CONTEXTS[repo];
+ return matchesQuery([context.repo, context.init, context.scanStatus, context.testsStatus, context.ciStatus, context.ownersRulesStatus, context.recommendedMode], repoSearch);
+ });
+ }, [repoSearch]);
+
+ const visibleProofs = useMemo(() => {
+ return PROOF_FEED.filter((item) =>
+ matchesQuery([item.contractId, item.repo, item.proofStatus, item.decisionStatus, item.humanApproval, item.criteriaCoverage, item.summary], proofSearch),
+ );
+ }, [proofSearch]);
+
+ useEffect(() => {
+ if (hasLiveContracts) {
+ if (selectedLiveContractId) {
+ setSelectedContractId(selectedLiveContractId);
+ return;
+ }
+ setSelectedContractId(repoScopedContracts[0]?.id ?? EMPTY_LIVE_CONTRACT.id);
+ return;
+ }
+
+ if (!repoScopedContracts.some((contract) => contract.id === selectedContractId)) {
+ const nextContract = repoScopedContracts[0];
+ if (nextContract) {
+ setSelectedContractId(nextContract.id);
+ return;
+ }
+
+ setSelectedContractId(CONTRACTS[0].id);
+ }
+ }, [hasLiveContracts, repoScopedContracts, selectedContractId, selectedLiveContractId]);
+
+ useEffect(() => {
+ if (selectedLiveContractId) {
+ setSelectedContractId(selectedLiveContractId);
+ }
+ }, [selectedLiveContractId]);
+
+ useEffect(() => {
+ if (hasLiveContracts) {
+ setRepoFilter(liveContracts?.repoBindingFilter ?? LIVE_ALL_REPOSITORIES_FILTER);
+ }
+ }, [hasLiveContracts, liveContracts?.repoBindingFilter]);
+
+ const selectedContract = useMemo(() => {
+ if (selectedLiveContract) {
+ return selectedLiveContract;
+ }
+ return contractSource.find((contract) => contract.id === selectedContractId)
+ ?? (hasLiveContracts ? EMPTY_LIVE_CONTRACT : CONTRACTS[0]);
+ }, [contractSource, hasLiveContracts, selectedContractId, selectedLiveContract]);
+
+ const step = contractSteps[selectedContract.id] ?? selectedContract.defaultStep;
+ const approval = approvalStates[selectedContract.id] ?? 'pending';
+ const selectedStatus = getStatus(step, approval);
+ const projectContext =
+ (selectedContract.repoFilterValue ? liveRepoContexts[selectedContract.repoFilterValue] : undefined)
+ ?? REPO_CONTEXTS[selectedContract.repo as RepoId]
+ ?? Object.values(liveRepoContexts)[0]
+ ?? REPO_CONTEXTS['trialops-demo'];
+ const meters = getMeters(step, approval);
+ const activity = useMemo(() => getActivity(selectedContract, step, approval), [selectedContract, step, approval]);
+ const selectedProof = useMemo(() => PROOF_FEED.find((item) => item.id === selectedProofId) ?? PROOF_FEED[0], [selectedProofId]);
+ const averageReadiness = Math.round(WORKSPACE_REPOS.reduce((sum, repo) => sum + REPO_CONTEXTS[repo].readiness, 0) / WORKSPACE_REPOS.length);
+ const acceptedProofs = PROOF_FEED.filter((item) => item.proofStatus === 'Принято').length;
+ const blockedProofs = PROOF_FEED.filter((item) => item.proofStatus === 'Заблокировано').length;
+ const topbarMeters =
+ activeSurface === 'contracts'
+ ? [
+ { tone: 'amber' as Tone, label: 'Контракт', value: meters.contract.label, percent: meters.contract.percent },
+ { tone: 'mauve' as Tone, label: 'Выполнение', value: meters.execution.label, percent: meters.execution.percent },
+ { tone: 'pass' as Tone, label: 'Подтверждение', value: meters.proof.label, percent: meters.proof.percent },
+ ]
+ : activeSurface === 'readiness'
+ ? [
+ { tone: 'mauve' as Tone, label: 'Рабочая область', value: `${WORKSPACE_REPOS.length} репозитория`, percent: 100 },
+ { tone: 'amber' as Tone, label: 'Готовность', value: `${averageReadiness}/100 среднее`, percent: averageReadiness },
+ { tone: 'pass' as Tone, label: 'Настройка', value: 'Только демо-действия', percent: 72 },
+ ]
+ : [
+ { tone: 'mauve' as Tone, label: 'Подтверждения', value: 'Все репозитории', percent: 100 },
+ { tone: 'amber' as Tone, label: 'Ожидают', value: '2 активных', percent: 50 },
+ { tone: 'pass' as Tone, label: 'Решения', value: `${acceptedProofs} принято · ${blockedProofs} блок`, percent: 70 },
+ ];
+
+ useEffect(() => {
+ if (step === 1) {
+ setVisibleClarifications(0);
+ const timers = CLARIFICATION_DELAYS.slice(0, selectedContract.clarifications.length).map((delay, index) =>
+ window.setTimeout(() => {
+ setVisibleClarifications(index + 1);
+ }, delay),
+ );
+
+ return () => {
+ timers.forEach((timer) => window.clearTimeout(timer));
+ };
+ }
+
+ setVisibleClarifications(step > 1 ? selectedContract.clarifications.length : 0);
+
+ return undefined;
+ }, [selectedContract, step]);
+
+ useEffect(() => {
+ if (step === 4) {
+ setVisibleEvidence(0);
+ const timers = selectedContract.evidence.map((_, index) =>
+ window.setTimeout(() => {
+ setVisibleEvidence(index + 1);
+ }, (index + 1) * EVIDENCE_DELAY),
+ );
+
+ return () => {
+ timers.forEach((timer) => window.clearTimeout(timer));
+ };
+ }
+
+ setVisibleEvidence(step > 4 ? selectedContract.evidence.length : 0);
+
+ return undefined;
+ }, [selectedContract, step]);
+
+ useEffect(() => {
+ if (step === 5) {
+ setVisibleVerification(0);
+ const timers = selectedContract.verification.map((_, index) =>
+ window.setTimeout(() => {
+ setVisibleVerification(index + 1);
+ }, (index + 1) * VERIFICATION_DELAY),
+ );
+
+ return () => {
+ timers.forEach((timer) => window.clearTimeout(timer));
+ };
+ }
+
+ setVisibleVerification(step > 5 ? selectedContract.verification.length : 0);
+
+ return undefined;
+ }, [selectedContract, step]);
+
+ const setStepForSelected = (nextStep: StepIndex) => {
+ setContractSteps((current) => ({ ...current, [selectedContract.id]: nextStep }));
+ };
+
+ const goNext = () => {
+ if (step < 7) {
+ setStepForSelected((step + 1) as StepIndex);
+ }
+ };
+
+ const goBack = () => {
+ if (step > 0) {
+ setStepForSelected((step - 1) as StepIndex);
+ }
+ };
+
+ const resetSelected = () => {
+ setContractSteps((current) => ({ ...current, [selectedContract.id]: selectedContract.defaultStep }));
+ setApprovalStates((current) => ({ ...current, [selectedContract.id]: 'pending' }));
+ };
+
+ const handleDecision = (decision: ApprovalState) => {
+ setApprovalStates((current) => ({ ...current, [selectedContract.id]: decision }));
+ setStepForSelected(7);
+ };
+
+ const selectedRepoOption = repoOptions.find((option) => option.value === repoFilter) ?? repoOptions[0];
+
+ const handleRepoFilterSelect = (nextRepoFilter: RepoFilter) => {
+ setRepoFilter(nextRepoFilter);
+ if (hasLiveContracts) {
+ liveContracts?.onRepoBindingFilterChange(nextRepoFilter);
+ }
+ setRepoSelectorOpen(false);
+ };
+
+ const primaryActionLabel =
+ step === 0
+ ? 'Начать'
+ : step === 1
+ ? 'К контракту'
+ : step === 2
+ ? 'Зафиксировать контракт'
+ : step === 3
+ ? 'К артефактам'
+ : step === 4
+ ? 'К проверке'
+ : step === 5
+ ? 'К подтверждению'
+ : step === 6
+ ? 'К решению'
+ : approval === 'accepted'
+ ? 'Повторить'
+ : null;
+
+ const activeSurfaceLabel = activeSurface === 'contracts' ? 'Контракты' : activeSurface === 'readiness' ? 'Готовность' : 'Подтверждения';
+ const topbarStateLabel = activeSurface === 'contracts' ? 'Статус' : activeSurface === 'readiness' ? 'Репозиторий' : 'Область';
+ const topbarStateValue = activeSurface === 'contracts' ? selectedStatus : activeSurface === 'readiness' ? selectedReadinessRepo : 'Все репозитории';
+
+ return (
+
+
+
+
+
+
+
Рабочая область
+
+ setActiveSurface('contracts')}
+ >
+ Контракты
+
+ setActiveSurface('readiness')}
+ >
+ Готовность
+
+ setActiveSurface('proof')}
+ >
+ Подтверждения
+
+
+
+
Контекст раздела
+
+
+ {activeSurface === 'contracts' ? 'Контракты' : activeSurface === 'readiness' ? 'Готовность' : 'Подтверждения'}
+
+
+ {activeSurface === 'contracts'
+ ? repoFilter === 'all'
+ ? 'Активная работа по всем репозиториям'
+ : 'Работа по выбранному репозиторию'
+ : activeSurface === 'readiness'
+ ? 'Настройка репозитория'
+ : 'Артефакты по репозиториям'}
+
+
+ {activeSurface === 'contracts' ? (
+
+
+
Репозиторий
+
+
setRepoSelectorOpen((open) => !open)}
+ >
+ {selectedRepoOption.label}
+
+
+ {repoSelectorOpen ? (
+
+ {repoOptions.map((option) => (
+ handleRepoFilterSelect(option.value)}
+ >
+ {option.label}
+
+ {option.value === LIVE_ALL_REPOSITORIES_FILTER
+ ? 'все'
+ : contractSource.filter((contract) => (contract.repoFilterValue ?? contract.repo) === option.value).length}
+
+
+ ))}
+
+ ) : null}
+
+
+ {hasLiveContracts ? (
+
+ Статус
+ liveContracts?.onStateFilterChange(event.target.value as ContractResponse['state'] | 'all')}
+ >
+ {LIVE_CONTRACT_STATE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ ) : null}
+ {hasLiveContracts ? (
+
+ {liveContracts?.contractListLoadStatus === 'loading' ? 'Обновляем' : 'Обновить'}
+
+ ) : null}
+
+ {hasLiveContracts ? (
+ <>
+ Backend discovery · {liveContracts?.contracts.length ?? 0} контрактов
+ {liveContracts?.repositoryContext?.contexts.length ?? 0} repository context
+ Read-only endpoints · без mutation controls
+ >
+ ) : (
+ <>
+ trialops-demo · 3 контракта
+ billing-api · 2 контракта
+ Все репозитории доступны
+ >
+ )}
+
+ {hasLiveContracts && liveContracts?.contractListError ? (
+
{liveContracts.contractListError}
+ ) : null}
+
+ ) : null}
+
+
+ {activeSurface === 'contracts' ? (
+ <>
+
Активные контракты
+
+
setContractSearch(event.target.value)} />
+
+ Активно
+ В работе
+ Решение
+
+
+
+ {visibleContracts.map((contract) => {
+ const contractStep = contractSteps[contract.id] ?? contract.defaultStep;
+ const contractApproval = approvalStates[contract.id] ?? 'pending';
+ const status = getStatus(contractStep, contractApproval);
+ const isSelected = contract.id === selectedContract.id;
+
+ return (
+
{
+ setSelectedContractId(contract.id);
+ if (hasLiveContracts && contract.backendContract) {
+ liveContracts?.onContractSelect(contract.backendContract);
+ }
+ }}
+ >
+
+ {contract.id}
+ {getShelfStatus(status)}
+
+ {contract.title}
+ {isSelected ? {contract.summary}
: null}
+
+ {isSelected ? (
+ <>
+ {contract.repo}
+ {contract.owner}
+ >
+ ) : (
+
+ {contract.repo} · {getCompactOwner(contract.owner)}
+
+ )}
+
+
+ );
+ })}
+ {visibleContracts.length === 0 ? (
+
+ {hasLiveContracts && liveContracts?.contractListLoadStatus === 'loading'
+ ? 'Загружаем контракты из backend'
+ : hasLiveContracts && liveContracts?.contractListLoadStatus === 'error'
+ ? 'Backend contract discovery недоступен'
+ : hasLiveContracts
+ ? 'Backend не вернул контракты по текущему фильтру'
+ : 'Контракты не найдены'}
+
+ ) : null}
+
+ >
+ ) : activeSurface === 'readiness' ? (
+ <>
+
Репозитории
+
+
setRepoSearch(event.target.value)} />
+
+ Готово
+ Частично
+ Настройка
+
+
+
+ {visibleRepos.map((repo) => (
+
setSelectedReadinessRepo(repo)}
+ >
+ {repo}
+ {REPO_CONTEXTS[repo].readiness}/100
+
+ ))}
+ {visibleRepos.length === 0 ?
Репозитории не найдены
: null}
+
+ >
+ ) : (
+ <>
+
Очередь пакетов подтверждения
+
+
setProofSearch(event.target.value)} />
+
+ Ожидают
+ Принято
+ Заблокировано
+
+
+
+ {visibleProofs.map((item) => (
+
setSelectedProofId(item.id)}
+ >
+ {item.contractId}
+ {item.proofStatus}
+
+ ))}
+ {visibleProofs.length === 0 ?
Пакеты подтверждения не найдены
: null}
+
+ >
+ )}
+
+
+
+
Режим
+
{hasLiveContracts ? 'Backend-backed Contracts' : 'Демо-разделы рабочей области'}
+
+ {hasLiveContracts
+ ? 'Read-only API · без workflow mutation controls'
+ : 'Только локальное демо-состояние · без бэкенда · без роутинга'}
+
+
+
+
+ {activeSurface === 'contracts' ? (
+ <>
+
+
+
+
+
Контракт {selectedContract.id} · пакет изменения
+
Цепочка изменения · cp-{selectedContract.id.slice(2).toLowerCase()}
+
+
+ {selectedContract.repo}
+ {selectedStatus}
+
+
+
+
+ {STAGES.map((stage, index) => {
+ const stateClass = step > index ? 'done' : step === index ? 'active' : '';
+
+ return (
+
+
+
+
{stage.name}
+
{step > index ? 'готово' : step === index ? 'сейчас' : 'ждет'}
+
+ );
+ })}
+
+
+
+
Активная стадия
+
{STAGES[step].name}
+
+
+ Репозиторий {selectedContract.repo}
+
+
+ Контракт {selectedContract.id}
+
+
+ Статус {selectedStatus}
+
+
+
+
+
+
+
+
+
Выбранный контракт
+
{selectedContract.id} · {selectedContract.title}
+
+
+ {selectedContract.scopeSurface}
+ {selectedContract.owner}
+
+
+
+ {renderStageContent({
+ contract: selectedContract,
+ projectContext,
+ step,
+ approval,
+ visibleClarifications,
+ visibleEvidence,
+ visibleVerification,
+ onAdvance: goNext,
+ onDecision: handleDecision,
+ })}
+
+
+
+
+
+
+
+
Контекст проекта
+
Репозиторий {projectContext.repo}
+
+
+
+
Репозиторий
+ {projectContext.repo}
+
+
+
Привязан
+ {projectContext.bound}
+
+
+
Инициализация
+ {projectContext.init}
+
+
+
Документов
+ {projectContext.docsIndexed}
+
+
+
+
+
+
Готовность
+
{projectContext.readiness}/100
+
+
+
+
+
+
+
+
Чеклист готовности
+ {projectContext.checklist.map((item) => (
+
+ {item.label}
+ {item.value}
+
+ ))}
+
+
+ Политика рантайма
+ {projectContext.runtimePolicy}
+ Доступные рантаймы
+
+ {projectContext.runtimes.map((runtime) => (
+
+ {runtime}
+
+ ))}
+
+
+
+ {selectedContract.id}
+ {STAGES[step].name}
+ {selectedStatus}
+
+
+
+
+
+
Уточнения
+
Главные вводные · {selectedContract.clarifications.length} всего
+
+
+ {selectedContract.clarifications.slice(0, 3).map((card, index) => {
+ const resolved = step > 1 || index < visibleClarifications;
+ return (
+
+
+
{card.ref}
+
{card.answer}
+
+
{resolved ? 'Закрыто' : 'Открыто'}
+
+ );
+ })}
+
+ {selectedContract.clarifications.length > 3 ? (
+ {selectedContract.clarifications.length - 3} вводных остаются в деталях контракта.
+ ) : null}
+
+
+
+
+
+
+
Активность рабочей области
+
Не чат-лог · только события контракта
+
+
+ {activity.map((entry, index) => (
+
+
{entry.ts}
+
+
{formatActivityKind(entry.kind)}
+
{entry.note}
+
+
{entry.tone === 'pass' ? 'готово' : entry.tone === 'block' ? 'блок' : entry.tone === 'amber' ? 'проверка' : 'событие'}
+
+ ))}
+
+
+
+
+
+
{hasLiveContracts ? 'Backend endpoints' : 'Управление стадией'}
+
{hasLiveContracts ? 'Read-only surface' : 'Только демо-проход'}
+
+
+ {hasLiveContracts
+ ? 'Страница читает list/detail/current-draft/repository-context endpoints и не выполняет lifecycle mutations.'
+ : getStepSummary(step)}
+
+
+ Репозиторий: {repoFilter === 'all' ? 'Все репозитории' : repoFilter}
+ Репозиторий карточки: {selectedContract.repo}
+
+
+ {hasLiveContracts ? (
+
+ Обновить из backend
+
+ ) : (
+ <>
+
+ Назад
+
+ {primaryActionLabel ? (
+
+ {primaryActionLabel}
+
+ ) : null}
+
+ Сбросить
+
+ >
+ )}
+
+
+
+ >
+ ) : activeSurface === 'readiness' ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+function DemoContractsApp({ liveContracts }: { liveContracts?: DemoContractsLiveData }) {
+ const isMobileCompanion = useMobileCompanionBreakpoint();
+
+ return isMobileCompanion ? : ;
+}
+
+export default function DemoContractsPage({ liveContracts }: { liveContracts?: DemoContractsLiveData }) {
+ const hostRef = useRef(null);
+ const [shadowRoot, setShadowRoot] = useState(null);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host) {
+ return;
+ }
+
+ setShadowRoot(host.shadowRoot ?? host.attachShadow({ mode: 'open' }));
+ }, []);
+
+ return (
+ <>
+
+ {shadowRoot
+ ? createPortal(
+ <>
+
+
+
+
+ >,
+ shadowRoot
+ )
+ : null}
+ >
+ );
+}
diff --git a/docs/ops/COMPONENTS.yaml b/docs/ops/COMPONENTS.yaml
index f008794..40b4ec0 100644
--- a/docs/ops/COMPONENTS.yaml
+++ b/docs/ops/COMPONENTS.yaml
@@ -228,7 +228,7 @@ components:
- apps/web/pilot-intake-ru/server/cmd/goalrail-pilot-intake-ru
- apps/web/pilot-intake-ru/server/internal/pilotlead
public_claim_allowed: false
- notes: "Shared web rules live under apps/web. Current web surfaces remain prototype/public packaging surfaces only; no Goalrail goal-to-proof product loop is implemented yet. apps/web/pilot-intake-ru is the business-first RU pilot landing for pilot.goalrail.ru per D-0055 with ИИ-кодинг без хаоса, safe 2-week пилот ИИ-разработки, repository readiness, project context, controlled tasks, and verified result. The landing keeps D-0047 boundaries in force except for D-0056's narrow lead-capture endpoint, D-0058 daily digest, D-0059 Resend mail transport when configured, D-0061 notification-status retry semantics, and D-0065 retention/minimization guardrails. The repo source for that endpoint/digest is a landing-owned Go sidecar under apps/web/pilot-intake-ru/server per D-0062, not the core apps/server product API, and the prior PHP source is no longer an active repo runtime. Operator-managed deployment and public-live smoke status for the RU pilot landing is recorded in docs/ops/PILOT_INTAKE_RU_DEPLOYMENT_WIRING.md and docs/ops/STATUS.md. apps/web/console is now the single canonical multilingual EN/RU console source with static i18next resources, existing server login / optional first-login password change / `/v1/me` / logout flow, VITE_GOALRAIL_API_BASE_URL build-time API base URL support, in-memory access and refresh tokens only, no cookies, no token/profile/session browser-storage persistence, no locale browser-storage persistence, `goalrail.console.theme` as the only localStorage key, an ops-style Contracts surface that consumes authenticated, organization-scoped, read-only `GET /v1/contracts?limit=50` discovery by default, renders a compact contract rail/list with state and repo-binding filtering, manual refresh, selected aggregate detail through authenticated, organization-scoped, read-only `GET /v1/contracts/{id}` with one lifecycle status, linked ids, calm timestamps, and current draft body through authenticated, organization-scoped, read-only `GET /v1/contracts/{id}/current-draft` when `current_draft_id` is present, plus a metadata-only Organization / Project / Repository context panel from `GET /v1/organizations/{organization_id}/repository-context` that prefers the selected Contract `repo_binding_id` match or otherwise shows the first Organization context / honest empty or missing-binding state, honest task/execution/gate/runner/proof unavailability copy, and secondary explicit `contract_id` lookup, Delivery Readiness polling read-only `GET /v1/qualification-feed?limit=50` while authenticated into Qualification / Clarification / Contract / Blocked lanes as read-only backend state with no Goal/Contract workflow mutation controls and linked-contract `Open contract` navigation through `GET /v1/contracts/{id}`, structured empty Proof surface, bottom-left Settings utility, Appearance theme picker, a public English `/start` route at `https://goalrail.dev/start` with same-origin assistant input for `POST /api/start-chat` plus static guided fallback, API-backed Organization Users list/create/edit/temporary-password reset using `/v1/me` organization context plus the ADR-0027 routes, and read-only Settings / Repository metadata using `GET /v1/organizations/{organization_id}/repository-context`. Users uses backend-aligned roles `owner`, `admin`, `member`, and `viewer`; `observer` is not a target role. User state is active/inactive, `must_change_password` is shown as credential status, generated temporary passwords are shown once from the create/reset response without browser storage persistence, and Settings / Users blocks self owner demotion, self membership deactivation, and self temporary-password reset while warning on edits to other owners. Settings / Repository shows server-owned Organization, active Project, and active RepoBinding metadata only; it does not claim provider authorization, checkout, readiness, proof, execution, or runner state. The console consumes `GET /v1/contracts`, `GET /v1/contracts/{id}`, `GET /v1/contracts/{id}/current-draft`, `GET /v1/organizations/{organization_id}/repository-context`, and `GET /v1/qualification-feed` as authenticated read-only surfaces and does not call continuation, clarification answer continuation, or contract creation; open clarification questions are rendered as read-only backend state, linked Contract cards expose only `Open contract` navigation through existing read-only `GET /v1/contracts/{id}`, and the Contracts rail/list keeps visible rows on transient discovery errors and keeps selected detail visible when active filters exclude it. The console source still does not include local demo contracts, prefilled contract records, Delivery Readiness clarification answer forms, or Delivery Readiness Goal/Contract workflow mutation controls. The main console deployment is now live at `https://goalrail.dev` with API base URL `https://api.goalrail.dev` through the external `11me/infra` Flux GitOps path; `/start` relies on external infra SPA fallback, and live smoke and routing evidence are recorded in docs/ops/CONSOLE_MAIN_DEPLOYMENT_WIRING.md. The old apps/web/console-ru workspace source has been removed, and `https://console.goalrail.ru/` remains a separate legacy RU static deployment. The demo sandbox remains separate at `https://demo.goalrail.dev`. The live main API currently relies on nginx ingress CORS as a temporary bridge because the pinned goalrail-server image predates the app-level CORS implementation; later infra cleanup should pin a post-PR-#120 server image, enable app-level CORS, and remove ingress CORS annotations in one change. This component map keeps web_surface as prototype/public packaging only and does not use the component status to claim a mature Goalrail web product loop. No public registration, SSO, invite/reset email, self-service password reset, password reset email delivery, SaaS onboarding, organization creation API, analytics, provider integration, repository catalog, runner, gate, proof, or backend beyond the pilot narrow endpoint/digest/mail-transport slice, existing core server auth, Organization user-management endpoints, read-only repository-context endpoint, read-only Contract discovery/list/detail, read-only current ContractDraft detail backend, selected Contract current-draft rendering in the console, read-only qualification feed polling in the console, linked-contract navigation in the console, start-assistant worker route, and current deployment routing exists."
+ notes: "Shared web rules live under apps/web. Current web surfaces remain prototype/public packaging surfaces only; no Goalrail goal-to-proof product loop is implemented yet. apps/web/pilot-intake-ru remains the business-first RU pilot landing with the narrow lead-capture/digest/mail-transport exceptions recorded in docs/ops/PILOT_INTAKE_RU_DEPLOYMENT_WIRING.md and docs/ops/STATUS.md. apps/web/console is the single canonical multilingual EN/RU console source with existing server login / optional first-login password change / `/v1/me` / logout flow, VITE_GOALRAIL_API_BASE_URL support, in-memory access and refresh tokens only, no cookies, no token/profile/session browser-storage persistence, no locale browser-storage persistence, and `goalrail.console.theme` as the only localStorage key. The current Contracts entry renders the imported RU demo contracts shell from apps/web/demo-change-packet-ru after login and backs rows, state and repo-binding filters, selected detail, current draft body, and repository context with authenticated read-only `GET /v1/contracts`, `GET /v1/contracts/{id}`, `GET /v1/contracts/{id}/current-draft`, and `GET /v1/organizations/{organization_id}/repository-context`; it does not expose Contract lifecycle mutation controls. Delivery Readiness still polls read-only `GET /v1/qualification-feed?limit=50` while authenticated into Qualification / Clarification / Contract / Blocked lanes as read-only backend state with linked-contract `Open contract` navigation through `GET /v1/contracts/{id}` and no Goal/Contract workflow mutation controls. The console also provides a structured empty Proof surface, Settings / Appearance, API-backed Organization Users list/create/edit/temporary-password reset using `/v1/me` organization context plus ADR-0027 routes, read-only Settings / Repository metadata, and the public `/start` route. The main console deployment is live at `https://goalrail.dev` with API base URL `https://api.goalrail.dev`; `https://console.goalrail.ru/` remains a separate legacy RU static deployment, and the demo sandbox remains separate at `https://demo.goalrail.dev`. This component map keeps web_surface as prototype/public packaging only and does not use the component status to claim a mature Goalrail web product loop. No public registration, SSO, invite/reset email, self-service password reset, password reset email delivery, SaaS onboarding, organization creation API, analytics, provider integration, repository catalog, runner, gate, proof, or backend beyond the pilot narrow endpoint/digest/mail-transport slice, existing core server auth, Organization user-management endpoints, read-only repository-context endpoint, read-only Contract discovery/list/detail, read-only current ContractDraft detail backend, selected Contract current-draft rendering in the console, read-only qualification feed polling in the console, linked-contract navigation in the console, start-assistant worker route, and current deployment routing exists."
cli_surface:
name: CLI Surface
status: prototype
diff --git a/docs/ops/NEXT.md b/docs/ops/NEXT.md
index 41679a8..bfaa17b 100644
--- a/docs/ops/NEXT.md
+++ b/docs/ops/NEXT.md
@@ -193,26 +193,17 @@
Delivery Readiness cards now use one frontend-projected primary status,
D-0091 display priority, and calm browser-local timestamps while preserving
read-only clarification question text and context.
-- The Console Contracts surface now consumes authenticated,
- organization-scoped, read-only Contract discovery:
- `GET /v1/contracts?project_id=&repo_binding_id=&goal_id=&state=&limit=`.
- It loads `GET /v1/contracts?limit=50` by default, renders a compact
- contract rail/list with selected aggregate detail, supports state and
- repo-binding filtering plus manual refresh, keeps manual ID lookup as a
- secondary fallback, and does not create contracts, recompute readiness,
- create plans, or drive lifecycle transitions. Selected Contract detail uses
- authenticated, organization-scoped,
- read-only `GET /v1/contracts/{id}` and presents the public Contract aggregate
- with one lifecycle status, linked ids, calm timestamps, and the current draft
- body through read-only `GET /v1/contracts/{id}/current-draft` when
- `current_draft_id` is present. The same Contracts surface also reads
- `/v1/me` organization context and
- `GET /v1/organizations/{organization_id}/repository-context` to show a
- metadata-only Organization / Project / Repository context panel, preferring
- the selected Contract `repo_binding_id` match, falling back to the first
- Organization context when no Contract is selected, and showing honest empty
- or missing-binding states. Task, execution, gate, runner, and proof data
- remain unavailable in that view.
+- The Console Contracts entry now renders the imported RU demo contracts page
+ shell from `apps/web/demo-change-packet-ru` after authentication, isolated
+ from the rest of the Console CSS, and backs its contract rows/detail/current
+ draft state with authenticated, organization-scoped read-only Contract
+ discovery / detail endpoints:
+ `GET /v1/contracts?project_id=&repo_binding_id=&goal_id=&state=&limit=`,
+ `GET /v1/contracts/{id}`, and
+ `GET /v1/contracts/{id}/current-draft`, plus metadata-only repository context
+ from `GET /v1/organizations/{organization_id}/repository-context`. It remains
+ a read-only Console surface and does not expose Contract lifecycle mutation
+ controls.
- Known qualification-feed gap: the read model starts at promoted Goals. A
received-only IntakeRecord from a partial `intake -> promote` failure will
not appear in Console yet; current CLI `work start` treats that as a
diff --git a/docs/ops/STATUS.md b/docs/ops/STATUS.md
index 584c125..ab531af 100644
--- a/docs/ops/STATUS.md
+++ b/docs/ops/STATUS.md
@@ -208,7 +208,7 @@ The project currently has:
- planned flow / eval structure
- reference screens
- shared web stack rules under `apps/web/`
-- one canonical multilingual console source under `apps/web/console` with EN/RU static i18next resources, existing server auth endpoints for login, optional first-login password change, `/v1/me`, logout, in-memory tokens only, no cookies or token/profile/session browser-storage persistence, `goalrail.console.theme` as the only browser-storage key, no locale persistence, a Contracts surface that consumes read-only `GET /v1/contracts?limit=50` discovery by default, renders a compact contract rail/list with state and repo-binding filtering, manual refresh, selected aggregate detail through `GET /v1/contracts/{id}` plus current draft body through `GET /v1/contracts/{id}/current-draft` when linked, secondary manual contract-by-ID lookup, and a read-only Organization / Project / Repository context panel from `GET /v1/organizations/{organization_id}/repository-context` that prefers the selected Contract `repo_binding_id` match or otherwise shows the first Organization repository context / honest empty or missing-binding state, a Delivery Readiness surface polling read-only `GET /v1/qualification-feed` into Qualification / Clarification / Contract / Blocked lanes with one primary status per card, D-0091 display priority, calm browser-local timestamps, read-only clarification question text/context, no Goal/Contract workflow mutation controls, and linked-contract `Open contract` navigation through `GET /v1/contracts/{id}`, structured empty Proof surface, bottom-left Settings utility, Appearance theme picker, public English `/start`, API-backed Organization Users list/create/edit plus temporary-password reset using `/v1/me` organization context and the ADR-0027 Organization user-management routes, and read-only Settings / Repository metadata using `/v1/me` organization context plus `GET /v1/organizations/{organization_id}/repository-context`; selected Contract detail presents the public aggregate with one lifecycle status, linked ids, calm timestamps, and current draft title/body fields when available, while task, execution, gate, runner, and proof data remain unavailable in that view; the completed Console Goal / Contract read-only tranche is checkpointed in `docs/ops/CONSOLE_READONLY_GOAL_CONTRACT_CHECKPOINT.md`; temporary passwords are shown only from the immediate create/reset response and are not persisted in browser storage; Repository context data is metadata-only Project / RepoBinding visibility and does not claim provider authorization, checkout, readiness, proof, execution, or runner state; the main deployment is live at `https://goalrail.dev` with API base URL `https://api.goalrail.dev` through `11me/infra` Flux GitOps, while the old `apps/web/console-ru` workspace source has been removed and live `https://console.goalrail.ru/` remains separate
+- one canonical multilingual console source under `apps/web/console` with EN/RU static i18next resources, existing server auth endpoints for login, optional first-login password change, `/v1/me`, logout, in-memory tokens only, no cookies or token/profile/session browser-storage persistence, `goalrail.console.theme` as the only browser-storage key, no locale persistence, a Contracts entry that renders the imported RU demo contracts page shell from `apps/web/demo-change-packet-ru` after login backed by authenticated read-only Contract discovery/detail/current-draft endpoints and metadata-only repository context; the completed Console Goal / Contract read-only tranche is checkpointed in `docs/ops/CONSOLE_READONLY_GOAL_CONTRACT_CHECKPOINT.md`; temporary passwords are shown only from the immediate create/reset response and are not persisted in browser storage; Repository context data is metadata-only Project / RepoBinding visibility and does not claim provider authorization, checkout, readiness, proof, execution, or runner state; the main deployment is live at `https://goalrail.dev` with API base URL `https://api.goalrail.dev` through `11me/infra` Flux GitOps, while the old `apps/web/console-ru` workspace source has been removed and live `https://console.goalrail.ru/` remains separate
- a separate public-edge start assistant Worker under `apps/workers/start-assistant` owns live same-origin `POST https://goalrail.dev/api/start-chat`; it answers from the public KB revision `263075db460d762fe7fa1f09d30709bc68e8eb5c` through OpenAI Responses API file_search, has an operator-triggered GitHub Actions public KB sync path for future revisions, and keeps repo scan, file upload, code execution, analytics, cookies, sessions, CRM, browser OpenAI keys, chat history, and core `apps/server` ownership out of scope
- local change-packet demo prototypes under `apps/web/demo-change-packet` and `apps/web/demo-change-packet-ru`
- a business-first RU pilot landing under `apps/web/pilot-intake-ru` for `ИИ-кодинг без хаоса`: a mostly static Founding Pilot page for a safe 2-week пилот ИИ-разработки on one product area, with illustrative repository readiness / controlled task / pilot result cards, a D-0056 minimal `POST /api/pilot-lead` email lead endpoint with local JSONL notification status, retry after `notification_failed`, in-flight `received` / `pending` rows blocked as duplicate submissions, duplicate suppression for successfully notified, legacy processed, and in-flight rows, no user-agent storage for new lead records, a landing-owned repo-side Go sidecar for the endpoint/digest/purge command under `apps/web/pilot-intake-ru/server`, server-installed daily previous-day digest at 07:00 GMT+3 when leads exist plus direct mailto fallback, no analytics, no tracking, no IP logging, no cookies, no sessions, no fingerprinting, no CRM, no Google Sheets, no repo integration, no runtime execution, no persistence beyond local JSONL lead log, no chat UI, no file upload, and no model selector; the previous 5-step technical walkthrough is demoted to internal / technical demo or checkpoint status in git history per D-0055.
@@ -503,7 +503,7 @@ The project currently has:
- `.punk/publishing.local.toml` is the ignored local-only manual-bootstrap pointer; resolver/runtime implementation is pending
- `.goalrail/flows/` and `.goalrail/evals/` exist as planned future structure, not executable product surfaces
- `apps/web/` is now the shared namespace for frontend resources and stack rules
-- `apps/web/console` is the canonical multilingual EN/RU console source with real auth API login, optional first-login password change, `/v1/me`, logout, neutral internal role/status/surface IDs, runtime i18next language switching, no locale storage, an ops-style Contracts surface that consumes read-only `GET /v1/contracts?limit=50` discovery by default, renders a compact contract rail/list with state and repo-binding filtering, manual refresh, selected aggregate detail through `GET /v1/contracts/{id}` plus current draft body through `GET /v1/contracts/{id}/current-draft` when linked, and secondary explicit `contract_id` lookup, Delivery Readiness polling read-only `GET /v1/qualification-feed?limit=50` while authenticated and rendering Qualification / Clarification / Contract / Blocked lanes as read-only backend state with one primary status per card, D-0091 display priority, calm browser-local timestamps, read-only clarification question text/context, no Goal/Contract workflow mutation controls, linked-contract `Open contract` navigation through `GET /v1/contracts/{id}`, structured empty Proof surface, bottom-left Settings utility, Appearance theme picker, local-only theme preference under `goalrail.console.theme`, API-backed Organization Users list/create/edit using `/v1/me` organization context plus the ADR-0027 routes, read-only Settings / Repository Project + RepoBinding metadata backed by `GET /v1/organizations/{organization_id}/repository-context`, and public English `/start` backed by static guided fallback plus same-origin start assistant route; selected Contract detail presents the public aggregate with one lifecycle status, linked ids, calm timestamps, and current draft fields when available, not task, execution, gate, runner, or proof data; the main deployment is live at `https://goalrail.dev` and uses `https://api.goalrail.dev` through `11me/infra` Flux GitOps
+- `apps/web/console` is the canonical multilingual EN/RU console source with real auth API login, optional first-login password change, `/v1/me`, logout, neutral internal role/status/surface IDs, runtime i18next language switching, no locale storage, an imported RU demo Contracts shell from `apps/web/demo-change-packet-ru` backed by authenticated read-only Contract discovery/detail/current-draft and repository-context data; the main deployment is live at `https://goalrail.dev` and uses `https://api.goalrail.dev` through `11me/infra` Flux GitOps
- `apps/workers/start-assistant` is the separate public-edge Worker package for live `POST /api/start-chat`; it is not a core `apps/server` route and does not own canonical Goalrail product state
- `apps/web/demo-change-packet` is the current React + Vite + Mantine EN change-packet demo prototype, deployed through standalone infra at `demo.goalrail.dev`
- `apps/web/demo-change-packet-ru` is the separate RU copy of the change-packet demo prototype, deployed through standalone infra at `demo.goalrail.ru` rather than in-app i18n
@@ -606,14 +606,14 @@ The project currently has:
shape while hiding `organization_id` and `project_id`. It does not create,
update, submit, approve, plan, execute, gate, prove, recompute readiness,
create clarification requests, or write events.
-- `apps/web/console` consumes `GET /v1/contracts?limit=50` on authenticated
- Contracts entry, renders compact rows with Contract id/state/Goal/RepoBinding
- and calm updated-time labels, supports an `all` / `draft` /
+- `apps/web/console` consumes `GET /v1/contracts?limit=50` on the authenticated
+ imported RU demo Contracts shell, renders backend-backed rows with Contract
+ id/state/Goal/RepoBinding and calm updated-time labels, supports an `all` / `draft` /
`ready_for_approval` / `approved` / `seeded` state filter plus a
repository-context-backed repo-binding filter and manual refresh, keeps
existing visible rows on transient discovery errors, keeps selected detail
- visible when active filters exclude it, and keeps manual Contract ID lookup as
- a secondary authenticated, organization-scoped read-only fallback through
+ visible when active filters exclude it, and loads selected detail through
+ authenticated, organization-scoped read-only
`GET /v1/contracts/{id}`. Selected Contract detail now renders the current draft body through read-only
`GET /v1/contracts/{id}/current-draft` when `current_draft_id` is present,
shows "No current draft is linked yet" without calling that endpoint when the
@@ -780,7 +780,7 @@ Current packaging target:
- repo overlay boundaries keep Goalrail and Punk working artifacts out of the root
- `GOALRAIL_OFFER.md` exists as the current sellable package source
- `apps/web/demo-change-packet` and `apps/web/demo-change-packet-ru` provide verified frontend change-packet walkthrough prototypes; EN and RU demo domains are wired independently through standalone infra without changing product phase order
-- `apps/web/console` provides the verified canonical multilingual EN/RU console source with existing server login / first-login password change / `/v1/me` / logout plus an ops-style Contracts surface that consumes read-only `GET /v1/contracts?limit=50` discovery by default, renders a compact contract rail/list with state and repo-binding filtering, manual refresh, selected aggregate detail through `GET /v1/contracts/{id}` plus current draft body through `GET /v1/contracts/{id}/current-draft` when linked, and secondary explicit `contract_id` lookup, plus a Delivery Readiness surface that polls read-only `GET /v1/qualification-feed?limit=50` into Qualification / Clarification / Contract / Blocked lanes while authenticated with one primary status per card, D-0091 display priority, calm browser-local timestamps, read-only clarification question text/context, and linked-contract `Open contract` navigation through `GET /v1/contracts/{id}`; selected Contract detail presents the public aggregate with one lifecycle status, linked ids, calm timestamps, and current draft fields when available, while task, execution, gate, runner, and proof data remain unavailable in that view; tokens remain in React memory only, locale is not persisted, `goalrail.console.theme` remains the only browser storage key, Users renders `/v1/me` only, the main `https://goalrail.dev` deployment is live with API base URL `https://api.goalrail.dev`, and legacy `https://console.goalrail.ru/` remains separate; the console does not claim automatic continuation/recheck polling, automatic clarification creation, automatic clarification answer submission, automatic contract draft creation, Delivery Readiness Goal/Contract workflow mutation controls, durable user settings API, analytics, runner, gate, proof, repo integration, or product-loop implementation
+- `apps/web/console` provides the verified canonical multilingual EN/RU console source with existing server login / first-login password change / `/v1/me` / logout plus an imported RU demo Contracts shell from `apps/web/demo-change-packet-ru` backed by authenticated read-only Contract discovery/detail/current-draft endpoints and metadata-only repository context. Delivery Readiness still polls read-only `GET /v1/qualification-feed?limit=50`, tokens remain in React memory only, locale is not persisted, `goalrail.console.theme` remains the only browser storage key, Users renders `/v1/me` only, the main `https://goalrail.dev` deployment is live with API base URL `https://api.goalrail.dev`, and legacy `https://console.goalrail.ru/` remains separate; the console does not claim automatic continuation/recheck polling, automatic clarification creation, automatic clarification answer submission, automatic contract draft creation from the browser, Delivery Readiness Goal/Contract workflow mutation controls, Contract lifecycle mutation controls in the browser, durable user settings API, analytics, runner, gate, proof, repo integration, or product-loop implementation
- `apps/cli` provides a verified Go CLI bootstrap plus first `goalrail login ` server auth path with browser loopback, random state, S256 verifier/challenge exchange, normal server-backed `goalrail init` repository-context bootstrap, optional `goalrail init --base ` workflow base override without Git mutation, low-level `goalrail init --project `, explicit auth-free `goalrail init --local-demo`, explicit provider-neutral `goalrail agent install`, local `goalrail project scan/status` freshness commands, marker-backed `goalrail work start` with `--body-file `, marker-backed `goalrail work continue --goal-id `, marker-backed `goalrail work answer --clarification-request-id --answers-file `, marker-backed `goalrail work plan --contract-id `, marker-backed `goalrail work execution prepare --task-id --checkout-receipt-id `, marker-backed `goalrail contract draft --goal-id `, marker-backed `goalrail contract update --contract-id --fields-file `, marker-backed `goalrail contract submit --contract-id `, and marker-backed `goalrail contract approve --contract-id --confirm-user-approval`; normal server-backed init records a bounded server-side metadata inventory snapshot after repository-context init, writes the non-secret Git-root `.goalrail/project.yml` repository marker after server success, ensures `.goalrail/.gitignore` for Goalrail-owned machine-local state, and runs a local Project Scan cache write, while `work start` reads that marker to create an IntakeRecord and Goal through existing server endpoints and returns a `goalrail.cli.v1` JSON envelope with `display.summary` plus an available continuation command. `work continue` reads the same marker plus stored login profile, validates `/v1/me` organization membership before mutation, calls authenticated `/v1/goals/{id}/continuation`, and returns available `draft_contract` for ready Goals, `ask_user` with one open clarification request for incomplete Goals, or `blocked` for rejected/blocked states; the server endpoint also rejects OrganizationMembership / Goal organization mismatches before readiness mutation. `work answer` reads structured `question_id`-bound answer JSON from file/stdin after marker/login/org validation, calls the authenticated clarification continuation endpoint, and returns the next `goalrail.cli.v1` action after server-owned answer recording, allowed Goal hint application, and explicit readiness re-check. `contract draft` reads the same marker plus stored login profile, validates `/v1/me` organization membership before mutation, refreshes local Project Scan baseline/overlay evidence without uploading raw source bodies, sends local marker `project_id` and `repo_binding_id` expectations for server-side Goal context validation, calls authenticated create-or-return `/v1/contracts`, and returns a `goalrail.cli.v1` envelope with `contract_id`, `contract_state`, `local_repo_receipt`, and available `update_contract` only while the returned Contract is still `draft`. `contract update` reads structured proposed fields JSON from file/stdin, validates marker/login/org before mutation, sends marker `project_id` and `repo_binding_id` expectations to authenticated `PATCH /v1/contracts/{id}`, updates only current ContractDraft proposed fields, returns `changed_fields`, and yields `review_contract`. `contract submit` validates marker/login/org before mutation, sends marker project/repo expectations to authenticated `POST /v1/contracts/{id}/submissions`, moves a complete draft to `ready_for_approval`, and yields available `approve_contract`. `contract approve` fails before HTTP without `--confirm-user-approval`, validates marker/login/org when present, sends marker project/repo expectations to authenticated `POST /v1/contracts/{id}/approvals`, creates an ApprovedContract snapshot, and yields available `plan_work`. `work plan` validates marker/login/org before mutation, sends marker project/repo expectations to authenticated `POST /v1/contracts/{id}/plans`, creates or returns one server WorkItemPlan with newly created plans starting queued, preserves returned `plan_state`, and maps queued, leased, proposal_submitted, accepted, and unknown states to honest unavailable follow-up actions. `work execution prepare` validates marker/login/org before mutation, sends marker project/repo expectations plus `checkout_receipt_id` to authenticated `POST /v1/tasks/{id}/execution-jobs`, creates or returns an `ExecutionJob(queued)`, and returns unavailable `runner_execution_required` without creating `Run` or executing commands. A CLI-level ADR-0026 pull-loop smoke fixture now covers `work start` -> `work continue` -> `work answer` -> `contract draft` -> `contract update` -> `contract submit` -> explicit `contract approve` -> `work plan` -> `work plan status` -> explicit `work proposal accept` through `WorkItem(planned)` -> `work checkout prepare` -> `work execution prepare` through `ExecutionJob(queued)` without assignment, claiming, runner lease, command execution, checkout receipt creation, `Run`, execution receipt, gate, or proof side effects. `agent install` writes `.goalrail/agent/GOALRAIL.md` and `.goalrail/agent/commands.json`, may create root `AGENTS.md` only when missing, and is not a provider-specific adapter. The CLI does not claim hosted execution, production repo auth, real gate decisions, Organization selection UX, public Organization creation, broad repo binding sync, context-pack generation, proof retrieval, proof generation, provider-specific shim, Jira/Linear sync, local LLM ownership, runner, or execution automation
- `apps/worker` provides the first minimal API-only `goalrail-worker` planning loop. It polls `POST /v1/plans/leases`, exits cleanly on no-work in `--once` mode, fetches the leased plan through `GET /v1/plans/{id}`, submits one deterministic development-mode proposal with `lease_id` plus `lease_token`, and keeps raw lease tokens out of logs and disk persistence. It uses local API DTOs only and does not import server internals, Postgres stores, or command execution packages. It is not a runner and does not checkout repositories, run commands, accept proposals, create WorkItems directly, assign or claim work, start `Run`, submit receipts, write `GateDecision`, create `Proof`, or add a queue/outbox/worker registry.
- `apps/runner` provides the first minimal API-only `goalrail-runner`