diff --git a/docs/cockpit-concepts-and-rules.md b/docs/cockpit-concepts-and-rules.md new file mode 100644 index 0000000..2182c06 --- /dev/null +++ b/docs/cockpit-concepts-and-rules.md @@ -0,0 +1,261 @@ +# AgentFlow Cockpit concepts and rules + +This document defines the Cockpit product concepts and implemented rules. Use it as review input for SDLC roles, skills, agent prompts, and future issue standards. + +## Purpose + +AgentFlow Cockpit is the Goal Command Center for AgentFlow-managed delivery. It turns durable workflow records into a navigable operational view of goals, releases, role flow, readiness, approval gates, follow-ups, and safe workflow actions. + +Cockpit is not a GitHub dashboard. GitHub is the current storage and sync substrate. Product language should lead with AgentFlow concepts; source links remain secondary. + +## Primary navigation axes + +### Workspace + +A workspace is one configured repository from `AGENTFLOW_REPOSITORIES` or `COCKPIT_REPOSITORIES`. + +Rules: + +- If one repository is configured, show it as `Workspace: owner/repo`. +- If multiple repositories are configured, show a Workspace selector. +- Workspace switching uses `repo=owner/repo` in the URL. +- Workspace selector includes a visible `Switch` button and also submits on `onchange`. +- Issue, replay, dashboard, and guided-action links preserve `repo=`. +- Unknown repositories are rejected server-side. + +### View + +Dashboard views are URL-backed. They are navigation, not decorative tabs. + +Implemented values: + +- `view=goals` — goal command workspace. +- `view=releases` — release-first dashboard. +- `view=reviews` — review and human approval gate queue. +- `view=follow-ups` — follow-up queue. + +Rules: + +- Active view is styled. +- Browser back/forward should preserve view state. +- View links preserve selected workspace. + +### Release filter + +Release filtering applies in `view=releases`. + +Implemented values: + +- `release=unreleased` — default release queue; all release-impact goals not yet released. +- `release=released` — goals considered released/delivered. +- `release=all` — all release-impact goals. +- `release=needs-assignment` — release-impact goals missing explicit target release. +- Future: `release=vX.Y.Z` for a specific planned/released version. + +Rules: + +- Release filter links preserve selected workspace. +- Unreleased work is the default because it is most actionable. +- Release dashboard shows release candidate, target branch, unreleased count, released count, needs-assignment count, missing release notes, and blockers. + +## Release model + +Release is a first-class delivery lens. It answers: what ships, what is waiting, what lacks assignment, and what blocks release readiness. + +### Candidate release source + +Candidate version must come from explicit workflow/repo delivery data only. + +Accepted sources: + +1. Issue body fields such as `Release: vX.Y.Z`, `Target release: vX.Y.Z`, or `Version: vX.Y.Z`. +2. Milestone title exactly matching a release version like `vX.Y.Z`. + +Rules: + +- Incidental semver mentions in body text, logs, package versions, runtime versions, or test output must not become release candidates. +- If no explicit candidate exists, show `Next release`. + +### Release impact + +Implemented release impact states: + +- `major` +- `minor` +- `patch` +- `docs-only` +- `none` +- `unknown` + +Rules: + +- `no release impact` or `internal-only` means no release assignment required. +- Feature/minor labels imply release impact. +- Bug/fix labels imply patch impact. +- Documentation signals imply docs-only impact. + +### Release assignment state + +Implemented assignment states: + +- `assigned` — release-impact goal has explicit candidate version. +- `needs-assignment` — release-impact goal has no target release. +- `released` — closed/delivered state with no awaiting-release signal. +- `no-release-impact` — explicitly excluded from release assignment. + +Rules: + +- `needs-assignment` is a hygiene queue, not an error. +- UI copy should say: “This goal appears to affect delivery but has no target release. Assign it to a release or mark no release impact.” +- Missing release notes are tracked separately from release assignment. + +## Goal hierarchy and detail model + +Cockpit uses AgentFlow concepts first: + +- Goal Group — epic/parent issue. +- Goal — delivery objective. +- Delivery — implementation/PR/merge activity. +- Role Flow — role contributions and skipped-by-path roles. +- Readiness — applicable quality checks. +- Release — target/version/release state. +- Human approval gate — explicit human decision for high-assurance work. +- Follow-up — deferred tracked work. +- Source — external durable record link. + +Issue detail header grammar: + +1. Identity: `Goal #N` and title. +2. Concept badges: status, goal type, selected path, release state. +3. Actions: `↺ Goal Story`, `Open source ↗`. +4. Health orb as compact readiness summary. + +Rules: + +- Do not place source links between conceptual badges. +- Source is an external action, not product state. +- Goal Story is a primary navigational action. + +## Selected path and readiness rules + +Selected path defines which roles and checks apply. + +Implemented profiles: + +- `bounded` +- `standard` +- `high-assurance` +- `exploratory` + +Rules: + +- Skipped-by-path roles are excluded from readiness denominator. +- Quality score must not penalize intentionally skipped or not-applicable roles. +- Readiness score is `passed applicable checks / applicable checks`. +- Readiness details should use icons/color first; verbose states stay in tooltip/detail. + +Readiness icons: + +- `✓` complete/recorded/approved. +- `●` needs attention/not recorded/requested. +- `⊘` skipped/not applicable. +- `■` blocked. +- `!` error. + +Visible state words like `recorded` and `needs attention` should not overload primary rows when grade and icon already communicate state. + +## Human approval gate + +Human approval gate is the practical form of human review. + +Used for: + +- high-assurance work +- security-sensitive changes +- auth/permission changes +- data migration or data-loss risk +- production deployment risk +- explicit user/project policy + +Implemented statuses: + +- `not-required` +- `not-requested` +- `requested` +- `approved` + +Rules: + +- High-assurance path requires a human approval gate. +- Approval can be inferred from PR review approval or workflow text stating human security/acceptance/review approval. +- UI should say “Human approval gate,” not vague “Human review.” +- A complete human gate should record reviewer, scope, decision, and notes in durable workflow records. + +Recommended record shape: + +```md +## Human approval gate + +Reviewer: @login +Scope: security | acceptance | release | other +Decision: approved | changes-requested | blocked +Notes: ... +``` + +## Goal Story Replay + +Goal Story Replay explains how a goal moved from intent to current state. + +Rules: + +- Replay is chronological by default: oldest to newest. +- UI shows `Oldest → newest`. +- Events render as a vertical timeline with a flow line ending at `Current state`. +- Replay includes navigation back to Goal detail and Goal Command Center. +- Export markdown remains available. +- Replay should avoid internal-only copy such as “reruns agents.” + +## Guided workflow actions + +Guided workflow actions are safe workflow accelerators. They help users fix workflow state without leaving Cockpit. + +Implemented action cards: + +- Request human approval gate. +- Add missing clarification. +- Draft follow-up. +- Post handover. + +Rules: + +- Actions are preview-first. +- Writes require auth, CSRF, confirmation, and audit when enabled. +- Actions should be contextual and explain why they exist. +- Guardrails prevent destructive or remote execution actions. +- Product copy should not frame these as generic “guarded actions”; use “Guided workflow actions.” + +## Fail-safe and source rules + +Rules: + +- Empty sections should explain what is missing and what creates the missing data. +- Normal durable state is silent; only exceptional evidence/source states need badges. +- GitHub issue/PR/comment terminology is secondary to AgentFlow concepts. +- Source links use external-link styling (`↗`). +- No raw transcript, prompt, tool input, secret, or full log content belongs in UI, replay, export, or telemetry. + +## Implemented issue scope + +This document covers the non-Docker Cockpit work developed through the Goal Command Center sequence: + +- Goal Command Center concept and hierarchy. +- Path-aware readiness and version lens. +- Release dashboard and filters. +- Workspace switching. +- Goal detail header grammar. +- Readiness icon/detail model. +- Goal Story Replay timeline. +- Guided workflow actions. +- Human approval gate framing. + +Docker deployment remains intentionally excluded from this pass. diff --git a/docs/cockpit-qa.md b/docs/cockpit-qa.md index ce10b43..e3c292d 100644 --- a/docs/cockpit-qa.md +++ b/docs/cockpit-qa.md @@ -9,21 +9,24 @@ Docker deployment is intentionally a second pass. This checklist covers local an AGENTFLOW_REPOSITORIES=smota/agentflow-sdlc GITHUB_TOKEN= pnpm cockpit ``` 2. Open `/` and verify Goal Board loads. - - View tabs include Goals, Releases, Reviews, and Follow-ups. + - View tabs include Goals, Releases, Reviews, and Follow-ups and navigate with `view=` URLs. - Release dashboard is first-class, above the goal workspace. - - Release metrics include awaiting release, missing release notes, and release blockers. + - Release filter tabs support `unreleased`, `released`, `all`, and `needs-assignment`. + - Release metrics include unreleased, released, needs assignment, missing release notes, and blockers. + - Release candidate uses explicit release metadata/milestones or falls back to `Next release`; incidental package/runtime versions are not shown. + - Multiple configured repositories render a Workspace selector with visible Switch button and preserve `repo=` in links. 3. Open `/issues/119` and verify: - Goal detail has a back link to Goal Command Center. - Compact goal header uses stable grammar: identity, status/path/release badges, then actions. - Source is an external header action, not mixed into conceptual badges. - Selected Path and Version / Release Lens appear in the summary strip. - - Human review status is explicit when required by high-assurance path. + - Human approval gate status is explicit when required by high-assurance path. - Next-best-actions render as compact row cards. - Role Flow Contributions render icon/status first instead of generic timeline framing. - Readiness Health renders icon/color state markers, score, denominator, excluded skipped/not-applicable checks, and hover/focus detail. - Relationship Map renders only when relationships exist and explains its navigation purpose. - Activity & Decisions hides empty lanes or shows a compact empty state. - - Guarded actions panel says server-side auth/CSRF/audit required. + - Guided workflow actions explain preview-first writes and offer contextual action cards. 4. Open `/issues/127/replay` and verify Goal Story replay. - Navigation links back to Goal detail and Goal Command Center. - Timeline clearly says `Oldest → newest`. diff --git a/docs/cockpit.md b/docs/cockpit.md index d3c1d03..77dfe99 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -4,6 +4,8 @@ AgentFlow Cockpit is the optional Goal Command Center for goal-oriented SDLC del Cockpit is optional. The CLI/GitHub workflow remains authoritative and fully usable without Cockpit. +For the implemented product vocabulary and rules, see [`docs/cockpit-concepts-and-rules.md`](cockpit-concepts-and-rules.md). Use that document when reviewing SDLC roles, skills, and agents. + ## Product principles - GitHub is durable truth: issues, comments, PR bodies, commits, closure metadata. diff --git a/lib/__tests__/cockpit-goal-model.test.mjs b/lib/__tests__/cockpit-goal-model.test.mjs index 6a799f2..167e230 100644 --- a/lib/__tests__/cockpit-goal-model.test.mjs +++ b/lib/__tests__/cockpit-goal-model.test.mjs @@ -70,10 +70,21 @@ describe('AgentFlow goal model', () => { }) expect(model.releaseDashboard).toMatchObject({ targetBranch: 'development' }) expect(model.releaseDashboard.awaitingRelease).toHaveLength(1) + expect(model.releaseDashboard.needsAssignment).toHaveLength(1) expect(model.metrics.missingReleaseNotes).toBe(1) expect(buildReleaseDashboard(model.goals).includedGoals).toHaveLength(1) }) + it('does not use incidental semver mentions as release candidate', () => { + const version = deriveVersionLens({ + issue: issue({ body: 'Tests ran on package v2.1.9 but no release was planned.' }), + }) + expect(version.candidateVersion).toBeNull() + expect( + buildReleaseDashboard([buildAgentFlowGoalModel({ issue: issue() })]).releaseCandidate, + ).toBe('Next release') + }) + it('derives version lens for merged development work awaiting release', () => { const version = deriveVersionLens({ issue: issue({ labels: [{ name: 'feature' }] }), @@ -107,7 +118,7 @@ describe('AgentFlow goal model', () => { selectedPath, comments: [{ body: 'human security review approved and complete' }], }), - ).toMatchObject({ status: 'complete' }) + ).toMatchObject({ status: 'approved' }) }) }) diff --git a/lib/cockpit-goal-model.mjs b/lib/cockpit-goal-model.mjs index 1c163db..a9c9e5e 100644 --- a/lib/cockpit-goal-model.mjs +++ b/lib/cockpit-goal-model.mjs @@ -172,12 +172,19 @@ export function buildReleaseDashboard(goals = []) { const missingReleaseNotes = goals.filter( (goal) => goal.versionLens.releaseNoteState === 'needs-decision', ) + const needsAssignment = goals.filter( + (goal) => goal.versionLens.assignmentState === 'needs-assignment', + ) + const released = goals.filter((goal) => goal.versionLens.assignmentState === 'released') + const unreleased = goals.filter((goal) => + ['assigned', 'needs-assignment'].includes(goal.versionLens.assignmentState), + ) const blockers = goals.filter( (goal) => goal.status === 'blocked' || goal.humanGate.status === 'not-requested', ) - const releaseCandidate = - goals.find((goal) => goal.versionLens.candidateVersion)?.versionLens.candidateVersion || - `next ${goals[0]?.versionLens?.targetBranch || 'release'}` + const explicitCandidate = goals.find((goal) => goal.versionLens.candidateVersion)?.versionLens + .candidateVersion + const releaseCandidate = explicitCandidate || 'Next release' const targetBranch = awaitingRelease[0]?.versionLens.targetBranch || goals[0]?.versionLens?.targetBranch || @@ -187,6 +194,9 @@ export function buildReleaseDashboard(goals = []) { targetBranch, awaitingRelease, missingReleaseNotes, + needsAssignment, + released, + unreleased, blockers, includedGoals: goals.filter((goal) => goal.versionLens.releaseImpact !== 'none'), readyCount: awaitingRelease.filter((goal) => !missingReleaseNotes.includes(goal)).length, @@ -235,21 +245,21 @@ export function deriveHumanGate({ selectedPath, comments = [], pullRequests = [] const status = !required ? 'not-required' : complete - ? 'complete' + ? 'approved' : requested ? 'requested' : 'not-requested' return { required, reason: required - ? 'High-assurance path requires explicit human review.' - : 'Selected path does not require a human gate.', + ? 'High-assurance path requires a human approval gate.' + : 'Selected path does not require a human approval gate.', status, nextAction: required && !complete ? requested - ? 'Track human review to completion.' - : 'Request human review.' + ? 'Track human approval gate to decision.' + : 'Request human approval gate.' : 'No human gate action needed.', } } @@ -269,12 +279,20 @@ export function deriveVersionLens({ const releaseImpact = releaseImpactFromSignals({ body, labels }) const awaitingRelease = labels.includes('awaiting-release') || Boolean(mergedPr && targetBranch === 'development') - const candidateVersion = body.match(/v\d+\.\d+\.\d+/i)?.[0] || null + const candidateVersion = explicitReleaseVersion({ issue, body }) const releaseNoteState = /release note|changelog/i.test(body) ? 'recorded' : releaseImpact === 'none' ? 'not-needed' : 'needs-decision' + const assignmentState = + releaseImpact === 'none' + ? 'no-release-impact' + : issue.state === 'closed' && !awaitingRelease + ? 'released' + : candidateVersion + ? 'assigned' + : 'needs-assignment' return { state: issue.state === 'closed' && !awaitingRelease @@ -289,6 +307,7 @@ export function deriveVersionLens({ awaitingRelease, candidateVersion, releaseNoteState, + assignmentState, explanation: awaitingRelease ? `Merged to ${targetBranch}; release delivery still needs tracking.` : mergedPr @@ -600,8 +619,8 @@ export function deriveNextBestActions({ actions.push( action( 'request-human-gate', - 'Request human review', - 'Human review is required for this selected path and is not complete yet.', + 'Request human approval gate', + 'This selected path requires an explicit human decision before readiness.', 95, ), ) @@ -700,6 +719,16 @@ function skipReason({ phase, profile, risk }) { if (profile === 'exploratory') return `${phase.label} not required for exploratory path yet.` return `${phase.label} optional for ${profile} path.` } +function explicitReleaseVersion({ issue = {}, body = '' } = {}) { + const explicit = body.match( + /(?:target release|release|version)\s*:?\s*(v\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/i, + )?.[1] + if (explicit) return explicit + const milestone = typeof issue.milestone === 'string' ? issue.milestone : issue.milestone?.title + if (/^v\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/i.test(milestone || '')) return milestone + return null +} + function releaseImpactFromSignals({ body, labels }) { if (/no release impact|internal-only/i.test(body)) return 'none' if (/docs-only|documentation/i.test(body) || labels.includes('documentation')) return 'docs-only' diff --git a/lib/cockpit-ui.mjs b/lib/cockpit-ui.mjs index eab2229..26db029 100644 --- a/lib/cockpit-ui.mjs +++ b/lib/cockpit-ui.mjs @@ -3,6 +3,8 @@ export function renderCockpitPage({ body = '', user, repo, + repositories = [], + view = 'goals', csrfToken, } = {}) { return ` @@ -15,63 +17,110 @@ export function renderCockpitPage({ -
Goal Command Center
Move faster. Keep the work understandable.
+
Goal Command Center
Move faster. Keep the work understandable.
${csrfToken ? `` : ''}${body}
` } -export function renderGoalBoard(commandCenter = {}) { +export function renderGoalBoard( + commandCenter = {}, + { repo, view = 'goals', release = 'unreleased' } = {}, +) { const metrics = commandCenter.metrics || {} const goals = commandCenter.goals || [] const highlight = commandCenter.highlight - return ` + const repoQuery = repo ? `repo=${encodeURIComponent(repo)}` : '' + const query = (targetView) => + `/?${[repoQuery, `view=${targetView}`, targetView === 'releases' ? `release=${release}` : ''].filter(Boolean).join('&')}` + return `
${metric('Awaiting release', metrics.awaitingRelease, 'release')}${metric('Missing release notes', metrics.missingReleaseNotes, 'warn')}${metric('Release blockers', metrics.releaseBlockers, 'warn')}${metric('Active goals', metrics.activeGoals)}${metric('Need attention', metrics.needAttention, 'warn')}${metric('Readiness health', `${metrics.evidenceHealthAverage || 0}/100`)}
- ${renderReleaseDashboard(commandCenter.releaseDashboard || {})} + ${renderSelectedDashboardView(commandCenter, view, repo, release)}
- -
${highlight ? renderHighlight(highlight) : '

No active goals found

Connect a repository with AgentFlow-managed goals to begin.

'}${renderGoalList(goals)}
- + +
${highlight ? renderHighlight({ ...highlight, repo }) : '

No active goals found

Connect a repository with AgentFlow-managed goals to begin.

'}${renderGoalList(goals, repo)}
+
` } -function renderReleaseDashboard(release = {}) { - const awaiting = release.awaitingRelease || [] - const notes = release.missingReleaseNotes || [] - const blockers = release.blockers || [] - return `

Release dashboard

${escapeHtml(release.releaseCandidate || 'Next release')}

Target: ${escapeHtml(release.targetBranch || 'development')}

${awaiting.length}awaiting release${notes.length}missing notes${blockers.length}blockers
${ - awaiting.length - ? `
${awaiting - .slice(0, 4) +function renderSelectedDashboardView( + commandCenter = {}, + view = 'goals', + repo, + release = 'unreleased', +) { + if (view === 'releases') + return renderReleaseDashboard(commandCenter.releaseDashboard || {}, repo, release) + if (view === 'reviews') return renderReviewQueue(commandCenter.goals || [], repo) + if (view === 'follow-ups') return renderFollowUpQueue(commandCenter.goals || [], repo) + return renderReleaseDashboard(commandCenter.releaseDashboard || {}, repo, release) +} + +function renderReleaseDashboard(data = {}, repo, releaseFilter = 'unreleased') { + const awaiting = data.awaitingRelease || [] + const notes = data.missingReleaseNotes || [] + const blockers = data.blockers || [] + const needsAssignment = data.needsAssignment || [] + const released = data.released || [] + const unreleased = data.unreleased || [] + const all = data.includedGoals || [] + const releaseGoals = + releaseFilter === 'all' + ? all + : releaseFilter === 'released' + ? released + : releaseFilter === 'needs-assignment' + ? needsAssignment + : releaseFilter === 'unreleased' + ? unreleased + : all.filter((goal) => goal.versionLens.candidateVersion === releaseFilter) + return `

Release dashboard

${escapeHtml(data.releaseCandidate || 'Next release')}

Target: ${escapeHtml(data.targetBranch || 'development')}

${unreleased.length}unreleased${released.length}released${needsAssignment.length}needs assignment${notes.length}missing notes${blockers.length}blockers
${ + releaseGoals.length + ? `` - : '

No merged goals waiting on release.

' - }
` + : '

No goals in this release view.

' + }${needsAssignment.length ? '

Some goals appear to affect delivery but have no target release. Assign them to a release or mark no release impact.

' : ''}
` +} + +function renderReviewQueue(goals = [], repo) { + const reviewGoals = goals.filter((goal) => + goal.nextBestActions.some((action) => + ['request-review', 'request-human-gate'].includes(action.id), + ), + ) + return `

Review queue

${reviewGoals.length} goal(s) need review attention

${reviewGoals.length ? `` : '

No review gates pending.

'}
` +} + +function renderFollowUpQueue(goals = [], repo) { + const withFollowUps = goals.filter((goal) => goal.durableLinks.followUps.length) + return `

Follow-ups

${withFollowUps.length} goal(s) have follow-up links

${withFollowUps.length ? `` : '

No follow-ups recorded.

'}
` } function renderHighlight(goal) { - return `

Priority spotlight

${escapeHtml(goal.title)}

${escapeHtml(goal.nextBestActions[0]?.reason || 'No blocker detected.')}

Goal #${goal.number}${escapeHtml(goal.selectedPath.profile)}${escapeHtml(goal.versionLens.state)}

${renderHealthOrb(goal.evidenceHealth)}
` + return `

Priority spotlight

${escapeHtml(goal.title)}

${escapeHtml(goal.nextBestActions[0]?.reason || 'No blocker detected.')}

Goal #${goal.number}${escapeHtml(goal.selectedPath.profile)}${escapeHtml(goal.versionLens.state)}

${renderHealthOrb(goal.evidenceHealth)}
` } -function renderNextActions(actions = []) { +function renderNextActions(actions = [], repo) { if (!actions.length) return '

No action needed. Goals are flowing.

' return `
    ${actions .map( (action) => - `
  1. ${escapeHtml(action.label)}
    ${escapeHtml(action.reason)}
    Open goal #${action.goal.number}
  2. `, + `
  3. ${escapeHtml(action.label)}
    ${escapeHtml(action.reason)}
    Open goal #${action.goal.number}
  4. `, ) .join('')}
` } -function renderNextActionCards(actions = []) { +function renderNextActionCards(actions = [], repo) { if (!actions.length) return '

No action needed. Goals are flowing.

' return `
${actions .map( (action, index) => - `
${index + 1}

${escapeHtml(action.label)}

${escapeHtml(action.reason)}

Open goal
`, + `
${index + 1}

${escapeHtml(action.label)}

${escapeHtml(action.reason)}

Open goal
`, ) .join('')}
` } @@ -81,14 +130,14 @@ function summaryCard(label, value, detail) { } function renderGoalCard(goal) { - return `
${escapeHtml(displayState(goal.status))}Goal #${goal.number}

${escapeHtml(goal.title)}

${escapeHtml(goal.highlights?.[0]?.value || '')}

${escapeHtml(goal.selectedPath.profile)}${escapeHtml(goal.versionLens.state)} Goal Story

${goal.evidenceHealth.score}
` + return `
${escapeHtml(displayState(goal.status))}Goal #${goal.number}

${escapeHtml(goal.title)}

${escapeHtml(goal.highlights?.[0]?.value || '')}

${escapeHtml(goal.selectedPath.profile)}${escapeHtml(goal.versionLens.state)} Goal Story

${goal.evidenceHealth.score}
` } -function renderGoalList(goals = []) { - return `

Goal workspace

${goals.map(renderGoalCard).join('')}
` +function renderGoalList(goals = [], repo) { + return `

Goal workspace

${goals.map((goal) => renderGoalCard({ ...goal, repo })).join('')}
` } -function renderGoalHierarchy(goals = []) { +function renderGoalHierarchy(goals = [], repo) { if (!goals.length) return '

No goals found.

' const groups = goals.filter((goal) => goal.goalType === 'goal-group') const children = goals.filter((goal) => goal.goalType !== 'goal-group') @@ -100,7 +149,7 @@ function renderGoalHierarchy(goals = []) { .slice(0, 12) .map( (goal) => - `○ ${escapeHtml(goal.title)}${escapeHtml(displayState(goal.status))}`, + `○ ${escapeHtml(goal.title)}${escapeHtml(displayState(goal.status))}`, ) .join('')}`, ) @@ -108,23 +157,23 @@ function renderGoalHierarchy(goals = []) { children .map( (goal) => - `○ ${escapeHtml(goal.title)}${escapeHtml(displayState(goal.status))}`, + `○ ${escapeHtml(goal.title)}${escapeHtml(displayState(goal.status))}`, ) .join('') }` } -export function renderIssueView(view) { +export function renderIssueView(view, { repo } = {}) { const goal = view.goal - return ` -

Goal #${goal.number}

${escapeHtml(goal.title)}

${escapeHtml(displayState(goal.status))}${escapeHtml(goal.goalType)}${escapeHtml(goal.selectedPath.profile)} path${escapeHtml(displayState(goal.versionLens.state))}

↺ Goal StoryOpen source ↗${renderHealthOrb(goal.evidenceHealth)}
+ return ` +

Goal #${goal.number}

${escapeHtml(goal.title)}

${escapeHtml(displayState(goal.status))}${escapeHtml(goal.goalType)}${escapeHtml(goal.selectedPath.profile)} path${escapeHtml(displayState(goal.versionLens.state))}

↺ Goal StoryOpen source ↗${renderHealthOrb(goal.evidenceHealth)}
${summaryCard('Selected path', `${goal.selectedPath.profile} · ${goal.selectedPath.risk} risk`, goal.selectedPath.source)}${summaryCard('Version', `${goal.versionLens.releaseImpact} · ${displayState(goal.versionLens.state)}`, goal.versionLens.targetBranch)}${summaryCard('Human review', displayState(goal.humanGate.status), goal.humanGate.nextAction)}${summaryCard('Readiness', `${goal.evidenceHealth.score}/100`, `${goal.evidenceHealth.denominator} checks`)}
-

Next-best-actions

${renderNextActionCards(goal.nextBestActions.map((action) => ({ ...action, goal })).slice(0, 4))}
+

Next-best-actions

${renderNextActionCards(goal.nextBestActions.map((action) => ({ ...action, goal })).slice(0, 4), repo)}

Role Flow Contributions

${goal.roleContributions.map(renderRoleContribution).join('')}

Readiness Health

${renderEvidenceHealthCheck(goal.evidenceHealth)}

Relationship Map

${renderGraph(goal.graph)}
${renderWorkflowActivity(view.commentLanes)} - ${renderActionForms(view)}` + ${renderActionForms(view, repo)}` } export function renderEvidenceHealth(health) { @@ -157,11 +206,11 @@ function renderSelectedPath(path) { } function renderVersionLens(version) { - return `

Release state

${escapeHtml(displayState(version.state))}

${escapeHtml(version.explanation)}

Impact

${escapeHtml(version.releaseImpact)}

Target branch: ${escapeHtml(version.targetBranch)}

Release note: ${escapeHtml(displayState(version.releaseNoteState))}

${version.candidateVersion ? `

Candidate: ${escapeHtml(version.candidateVersion)}

` : ''}
` + return `

Release state

${escapeHtml(displayState(version.state))} ${escapeHtml(displayState(version.assignmentState))}

${escapeHtml(version.explanation)}

Impact

${escapeHtml(version.releaseImpact)}

Target branch: ${escapeHtml(version.targetBranch)}

Release note: ${escapeHtml(displayState(version.releaseNoteState))}

${version.candidateVersion ? `

Candidate: ${escapeHtml(version.candidateVersion)}

` : '

Needs release assignment or no-impact decision.

'}
` } -export function renderGoalStory(story) { - return ` +export function renderGoalStory(story, { repo } = {}) { + return `

Goal Story Replay

#${story.goal.number} ${escapeHtml(story.goal.title)}

${escapeHtml(story.status)}Oldest → newest

How this goal moved from intent to current state.

Chronological story

${renderReplayFilters()}${renderReplayEvents(story.compactEvents)}

Gaps

${story.missing.length ? `` : '

None detected.

'}
@@ -224,8 +273,28 @@ function renderWorkflowActivity(commentLanes = {}) { .join('')}` } -function renderActionForms(view) { - return `

Guided actions

Actions require server-side auth, CSRF, confirmation, and audit when enabled.

` +function renderActionForms(view, repo) { + const action = `/actions${repo ? `?repo=${encodeURIComponent(repo)}` : ''}` + const actions = [ + [ + 'request-human-review', + 'Request human approval gate', + 'Ask an accountable human to approve acceptance, security, or release readiness.', + ], + [ + 'add-clarification', + 'Add missing clarification', + 'Record scope, release, or acceptance criteria so the goal can move safely.', + ], + ['draft-follow-up', 'Draft follow-up', 'Track deferred work instead of hiding it in notes.'], + ['post-handover', 'Post handover', 'Leave next-step context for the next role or reviewer.'], + ] + return `

Guided workflow actions

Draft safe updates to the goal record. Cockpit previews every change before posting and protects writes with auth, CSRF, confirmation, and audit.

${actions + .map( + ([type, label, help]) => + `

${escapeHtml(label)}

${escapeHtml(help)}

`, + ) + .join('')}
` } function renderHealthOrb(health) { @@ -235,6 +304,33 @@ function renderHealthOrb(health) { function metric(label, value, klass = '') { return `
${escapeHtml(label)}${escapeHtml(value ?? 0)}
` } +function renderWorkspaceSelector({ repo, repositories = [] } = {}) { + if (!repo) return '' + if (repositories.length <= 1) + return `Workspace: ${escapeHtml(repo)}` + return `
` +} +function homeHref(repo, view, release) { + const params = new URLSearchParams() + if (repo) params.set('repo', repo) + if (view) params.set('view', view) + if (release) params.set('release', release) + const query = params.toString() + return `/${query ? `?${query}` : ''}` +} +function issueHref(number, repo) { + const query = repo ? `?repo=${encodeURIComponent(repo)}` : '' + return `/issues/${number}${query}` +} +function replayHref(number, repo, markdown = false) { + const query = repo ? `?repo=${encodeURIComponent(repo)}` : '' + return `/issues/${number}/replay${markdown ? '.md' : ''}${query}` +} function iconFor(type = '') { return type.includes('pr') ? '⑂' @@ -247,9 +343,19 @@ function iconFor(type = '') { : '●' } function stateClass(value = '') { - return ['recorded', 'complete', 'healthy', 'done', 'ready', 'moving', 'not-required'].includes( - value, - ) + return [ + 'recorded', + 'complete', + 'approved', + 'healthy', + 'done', + 'ready', + 'moving', + 'not-required', + 'assigned', + 'released', + 'no-release-impact', + ].includes(value) ? 'ok' : ['blocked', 'error'].includes(value) ? 'bad' @@ -290,7 +396,7 @@ h1,h2,h3 { color: var(--navy); line-height: 1.12; margin-top: 0; } h1 { font-siz .eyebrow { color: var(--teal); font-weight: 850; font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; } .panel { background: rgba(255,255,255,.92); border: 1px solid var(--line); border-radius: 1rem; padding: 1.25rem; box-shadow: 0 16px 42px rgba(23,35,63,.08); } .hero { background: linear-gradient(135deg, #fff, var(--mist)); border-color: var(--mint); } .highlight { display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: center; border-color: var(--coral); } -.view-tabs { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; } .view-tabs a { text-decoration:none; border:1px solid var(--line); background:#fff; border-radius:999px; padding:.35rem .8rem; font-weight:800; } +.view-tabs,.release-tabs { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; } .view-tabs a,.release-tabs a { text-decoration:none; border:1px solid var(--line); background:#fff; border-radius:999px; padding:.35rem .8rem; font-weight:800; } .view-tabs a.active,.release-tabs a.active { background:var(--navy); color:#fff; border-color:var(--navy); } .workspace-form { display:flex; gap:.45rem; align-items:center; } .workspace-form label { display:flex; gap:.45rem; align-items:center; margin:0; color:var(--muted); } .workspace-form select { padding:.25rem .5rem; border-radius:999px; } .workspace-form button { padding:.28rem .65rem; } .metrics { display: grid; grid-template-columns: repeat(6, 1fr); gap: .75rem; } .metric { background: var(--navy); color: #fff; padding: 1rem; border-radius: .8rem; } .metric span { color: #dce3ef; display:block; font-size:.82rem; } .metric strong { font-size: 1.7rem; } .metric.ok { background: var(--teal); } .metric.warn { background: var(--coral); } .metric.release { background: linear-gradient(135deg,var(--teal),var(--navy)); } .command-workspace { display:grid; grid-template-columns: 280px minmax(0, 1fr) 320px; gap:1rem; align-items:start; } .nav-pane,.action-pane { position: sticky; top: 96px; } .workspace-main { display:grid; gap:1rem; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; } .card,.goal-card,.summary-card,.next-action-card { background: #fff; border: 1px solid var(--line); border-radius: .85rem; padding: 1rem; } @@ -306,7 +412,7 @@ a { color: var(--navy); text-underline-offset: 4px; } .badge { background: var(- .dimension { border-bottom:1px solid var(--line); padding:.55rem 0; } .comment { border-left: 4px solid var(--teal); padding-left: .75rem; color: var(--muted); } .subtle { background:#fbfdfc; color:var(--muted); } .role-card { position:relative; padding-left:3.25rem; } .role-state,.state-dot { width:1.5rem; height:1.5rem; border-radius:50%; display:inline-grid; place-items:center; font-weight:900; border:1px solid var(--line); } .role-state { position:absolute; left:1rem; top:1rem; } .role-state.ok,.state-dot.ok { color:var(--teal); border-color:var(--teal); } .role-state.warn,.state-dot.warn { color:var(--coral); border-color:var(--coral); } .role-state.bad,.state-dot.bad { color:#b42318; border-color:#b42318; } .action-list li { margin: .65rem 0; } .flow-timeline { list-style:none; margin:0; padding:0 0 0 1.2rem; position:relative; } .flow-timeline:before { content:""; position:absolute; left:1.95rem; top:.4rem; bottom:2rem; width:2px; background:linear-gradient(var(--teal),var(--coral)); } .flow-timeline li { position:relative; display:grid; grid-template-columns:2rem 1fr; gap:.75rem; margin:.85rem 0; } .timeline-dot { z-index:1; width:1.65rem; height:1.65rem; border-radius:50%; display:grid; place-items:center; background:#fff; border:2px solid var(--teal); font-weight:900; } .timeline-end span { color:var(--coral); font-weight:900; z-index:1; } .graph-layout { display:grid; grid-template-columns: minmax(260px, 1fr) minmax(280px, .8fr); gap:1rem; } .graph-nodes { display:flex; flex-wrap:wrap; gap:.5rem; align-content:flex-start; } .graph-node { border:1px solid var(--line); border-radius:999px; padding:.45rem .7rem; background:var(--mist); text-decoration:none; } .detail-panel { border:1px solid var(--mint); background:#f8fcfb; border-radius:.9rem; padding:1rem; } -label { display: grid; gap: .35rem; margin: .75rem 0; } textarea { min-height: 6rem; } select, textarea, button { border-radius: .5rem; border: 1px solid var(--line); background: #fff; color: var(--ink); padding: .6rem; } button { background: var(--coral); color: #fff; border-color: var(--coral); font-weight: 800; } +label { display: grid; gap: .35rem; margin: .75rem 0; } textarea { min-height: 6rem; } select, textarea, button { border-radius: .5rem; border: 1px solid var(--line); background: #fff; color: var(--ink); padding: .6rem; } button { background: var(--coral); color: #fff; border-color: var(--coral); font-weight: 800; } .action-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:.8rem; } .action-card { border:1px solid var(--line); border-radius:.85rem; padding:1rem; background:#fff; } .warn-text { color:#966b1f; font-weight:700; } @media(max-width:1000px){ .command-workspace{grid-template-columns:1fr} .nav-pane,.action-pane{position:static} .summary-strip,.next-action-cards{grid-template-columns:1fr 1fr} } @media(max-width:820px){ .metrics{grid-template-columns:repeat(2,1fr)} .highlight,.health-summary,.graph-layout,.compact-hero,.release-dashboard{grid-template-columns:1fr} .summary-strip,.next-action-cards{grid-template-columns:1fr} .header-actions{justify-items:start} .release-stats{flex-wrap:wrap} } ` diff --git a/scripts/cockpit-server.mjs b/scripts/cockpit-server.mjs index 8f93444..2e29d4f 100644 --- a/scripts/cockpit-server.mjs +++ b/scripts/cockpit-server.mjs @@ -63,7 +63,17 @@ createServer(async (req, res) => { const session = sessions.get(readCookie(req, 'cockpit_session')) if (config.remote && !session) return redirect(res, '/login') - const repo = url.searchParams.get('repo') || config.repositories[0] + const repo = selectedRepository(url) + if (!repo) + return html( + res, + renderCockpitPage({ + title: 'Unknown workspace', + repositories: config.repositories, + body: '

Unknown workspace

Select a configured repository.

', + }), + 404, + ) const authz = await authorizeRequest({ session, repo }) if (!authz.ok) return html( @@ -79,7 +89,14 @@ createServer(async (req, res) => { return actionEndpoint(req, res, repo, session) if (url.pathname === '/telemetry' && req.method === 'POST') return telemetryEndpoint(req, res, repo, session) - if (url.pathname === '/') return home(res, repo, session) + if (url.pathname === '/') + return home( + res, + repo, + session, + url.searchParams.get('view') || 'goals', + url.searchParams.get('release') || 'unreleased', + ) const replayMatch = url.pathname.match(/^\/issues\/(\d+)\/replay(\.md)?$/) if (replayMatch) return replayPage(res, repo, Number(replayMatch[1]), session, Boolean(replayMatch[2])) @@ -104,7 +121,12 @@ createServer(async (req, res) => { console.log(`AgentFlow Cockpit listening on http://127.0.0.1:${config.port}`) }) -async function home(res, repo, session) { +function selectedRepository(url) { + const requested = url.searchParams.get('repo') || config.repositories[0] + return config.repositories.includes(requested) ? requested : null +} + +async function home(res, repo, session, view = 'goals', release = 'unreleased') { const issues = await github.issues(repo, { state: 'open', per_page: 50 }) const board = buildGoalBoard({ issues: issues.filter((issue) => !issue.pull_request) }) const sessionId = 'local-token' @@ -112,12 +134,14 @@ async function home(res, repo, session) { res, renderCockpitPage({ repo, + repositories: config.repositories, + view, user: session?.user?.login || 'token', csrfToken: createCsrfToken({ sessionId, secret: config.sessionSecret || 'local-development-session-secret-32', }), - body: renderGoalBoard(board), + body: renderGoalBoard(board, { repo, view, release }), }), ) } @@ -133,12 +157,13 @@ async function issuePage(req, res, repo, number, session) { res, renderCockpitPage({ repo, + repositories: config.repositories, user: session?.user?.login || 'token', csrfToken: createCsrfToken({ sessionId, secret: config.sessionSecret || 'local-development-session-secret-32', }), - body: renderIssueView(view), + body: renderIssueView(view, { repo }), }), ) } @@ -232,8 +257,9 @@ async function replayPage(res, repo, number, session, markdown = false) { res, renderCockpitPage({ repo, + repositories: config.repositories, user: session?.user?.login || 'token', - body: renderGoalStory(story), + body: renderGoalStory(story, { repo }), }), ) }