From 75fc8e1d1a27ca6091d55fde41278c7586d2724f Mon Sep 17 00:00:00 2001 From: Alice Norwood Date: Fri, 1 May 2026 14:20:09 -0400 Subject: [PATCH 01/16] Build Kanji a Day PWA learning loop --- apps/watch/KanjiADay/Preview/index.html | 843 +++++++++++++++++++----- 1 file changed, 689 insertions(+), 154 deletions(-) diff --git a/apps/watch/KanjiADay/Preview/index.html b/apps/watch/KanjiADay/Preview/index.html index 6bea830..294f181 100644 --- a/apps/watch/KanjiADay/Preview/index.html +++ b/apps/watch/KanjiADay/Preview/index.html @@ -7,206 +7,741 @@ -
-
-
-

Kanji a Day MVP

-
Today, stroke order, meaning. Three kanji. Watch-only.
+
+
+
+
+
+ 0 / 4 + Today +
+ Shape first +
+ ready + 0:00 +
+
+ +
+ +
+ +
+ +
+ + +
+
+
+ +
+

PWA MVP

+

Kanji a Day

+

+ A compact loop for one kanji: watch the stroke order, reveal the meaning, keep the streak, then preview what comes next. +

+
+
+ 0 + learned +
+
+ 0 + streak +
+
+ + next +
-
0 AI calls · no backend
-
-
-
-
-
10:09
-
Today
-
-
sun, day
-
Let's learn
-
-
Open once a day and get exactly one kanji.
-
-
-
-
10:09
-
3 / 4
- - - - - - -
Meaning
-
-
Follow the stroke order in a short animation.
-
-
-
-
10:09
-
Meaning
-
-
sun, day
-
Current streak 1 day
-
✓ Got it
-
-
Reveal meaning and preserve the daily streak.
-
+
+ + From f6de7e84b3fcb11779556724a5c1bb311e6bd566 Mon Sep 17 00:00:00 2001 From: Alice Norwood Date: Fri, 1 May 2026 14:40:43 -0400 Subject: [PATCH 02/16] Polish Kanji a Day product loop --- apps/watch/KanjiADay/Preview/index.html | 1251 +++++++++++++++++------ 1 file changed, 927 insertions(+), 324 deletions(-) diff --git a/apps/watch/KanjiADay/Preview/index.html b/apps/watch/KanjiADay/Preview/index.html index 294f181..d60b1cb 100644 --- a/apps/watch/KanjiADay/Preview/index.html +++ b/apps/watch/KanjiADay/Preview/index.html @@ -3,109 +3,129 @@ - Kanji a Day MVP Preview + Kanji a Day
-
+
- 0 / 4 - Today + 0 / 4 + Today
Shape first
@@ -340,81 +522,171 @@
- - + +
-
-

PWA MVP

+

Kanji a Day

-

- A compact loop for one kanji: watch the stroke order, reveal the meaning, keep the streak, then preview what comes next. -

-
-
- 0 - learned -
-
- 0 - streak -
-
- - next -
+

One kanji. Every day.

+

Learn stroke order, meaning, and reading in under a minute.

+ +
+ + +
+ +
+ + -
From f477e4b2202821596b11f61baf8780b6b60aa420 Mon Sep 17 00:00:00 2001 From: Alice Norwood Date: Fri, 1 May 2026 15:42:07 -0400 Subject: [PATCH 03/16] Add Kanji a Day docs page --- docs/cento-web-app.md | 2 +- docs/kanji-a-day.md | 74 +++++++++++++++++++++++ templates/agent-work-app/index.html | 64 ++++++++++++++++++++ templates/agent-work-app/styles.css | 93 +++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 docs/kanji-a-day.md diff --git a/docs/cento-web-app.md b/docs/cento-web-app.md index 23dd8a2..e51ab1e 100644 --- a/docs/cento-web-app.md +++ b/docs/cento-web-app.md @@ -22,7 +22,7 @@ The `/docs` route is a first-class documentation module with: - documentation sidebar with a Cento general group expanded by default - product-area groups folded by default for Taskstream, Cluster, Consulting, and References - hero copy for Cento Documentation -- six Explore by area cards +- Explore by area cards for Cento systems and app documentation such as Kanji a Day - Recent updates list - support callout - right-side On this page rail on desktop diff --git a/docs/kanji-a-day.md b/docs/kanji-a-day.md new file mode 100644 index 0000000..55f30a5 --- /dev/null +++ b/docs/kanji-a-day.md @@ -0,0 +1,74 @@ +# Kanji a Day + +Kanji a Day is a watch-friendly local learning app for one daily kanji. The product loop is intentionally narrow: + +```text +Today -> stroke practice -> meaning -> Got it -> history +``` + +The current preview focuses on the ritual that makes the app distinct: learn the shape first, reveal the meaning second, then save the kanji into memory. + +## User Flow + +- `Today` + Shows the current kanji, meaning, reading, and primary start action. +- `Stroke practice` + Animates each stroke in order. Future strokes stay faint, completed strokes stay visible, and the current stroke draws in. +- `Meaning` + Reveals the kanji, English meaning, reading, and one example word. +- `Got it` + Records the kanji as learned for the day. +- `History` + Shows learned kanji and gives the user a memory trail. + +## Product Rules + +- The learner should see the stroke order before the meaning is treated as complete. +- `Meaning` stays locked until all strokes finish. +- `Learned` increments only after the user confirms with `Got it`. +- Normal user mode must not show internal language such as MVP, PWA, AI calls, reset, localhost, or backend notes. +- Debug controls belong behind `?debug=1`. + +## Content + +The embedded starter set is: + +- `日` - sun, day +- `月` - moon, month +- `火` - fire +- `水` - water +- `木` - tree, wood +- `金` - gold, money +- `土` - earth, soil + +Each item should include meaning, reading, example vocabulary, stroke count, and SVG stroke path data. + +## Local Surfaces + +- Product preview source: `workspace/runs/agent-work/1000104/public/` +- WatchOS app source: `apps/watch/KanjiADay/` +- Static repo preview: `apps/watch/KanjiADay/Preview/index.html` + +The active local preview has been tested at: + +```text +http://127.0.0.1:47924/ +``` + +## Validation + +Use Firefox responsive design mode or Playwright to check: + +- 360px, 390px, 430px widths +- Today screen has no overlapping header text +- Stroke completion keeps Replay and Meaning fully inside the watch frame +- Meaning screen centers the kanji and keeps `Got it` visible inside the watch frame +- History has an empty state and learned cards +- Normal mode contains no debug or MVP copy +- `?debug=1` exposes debug-only controls when needed + +Recent visual evidence lives under: + +```text +workspace/runs/agent-work/1000104/screenshots/ +``` diff --git a/templates/agent-work-app/index.html b/templates/agent-work-app/index.html index 4a28bfa..cb6d9cb 100644 --- a/templates/agent-work-app/index.html +++ b/templates/agent-work-app/index.html @@ -256,6 +256,10 @@

Factory Runs

Engagements Reports +
+ Apps + Kanji a Day +
@@ -336,12 +340,71 @@

AI optimization

+ + +
+

Kanji a Day

+

Watch-friendly daily kanji app with stroke practice, meaning reveal, local progress, and history.

+
+ +
+ + + +
+ App documentation +

Kanji a Day

+

Kanji a Day is a compact learning app for one daily kanji. The experience is built around a product ritual: shape first, meaning second, memory third.

+ +
+
+

Core loop

+

Today opens the daily kanji, Stroke practice teaches order, Meaning reveals reading and example vocabulary, and Got it saves the kanji into history.

+
+
+

Current surfaces

+

The active local preview runs from workspace/runs/agent-work/1000104/public/. The native watch source lives under apps/watch/KanjiADay/, with a static repo preview in apps/watch/KanjiADay/Preview/index.html.

+
+
+ +
+ Today + + Stroke practice + + Meaning + + Got it + + History +
+ +
+
+

Product rules

+
    +
  • Meaning stays locked until all strokes complete.
  • +
  • Learned count increments only after the user taps Got it.
  • +
  • Normal mode must not show MVP, PWA, AI-call, reset, localhost, or backend copy.
  • +
  • Debug controls stay behind ?debug=1.
  • +
+
+
+

Starter content

+

The embedded beginner set is , , , , , , and . Each record should carry meaning, reading, example vocabulary, stroke count, and SVG stroke paths.

+
+
+ +
+ Validation focus +

Check Firefox responsive widths at 360px, 390px, and 430px. The watch header must not overlap, controls must stay inside the rounded frame, and the Meaning screen must center the kanji while keeping Got it visible.

Recent updates

+
Kanji a Day docs page added
Factory runtime adapters v1
Factory autopilot control loop v1
Docs and Research Center routes split
@@ -362,6 +425,7 @@

Can't find what you're looking for?

On this page

Overview Explore by area + Kanji a Day Recent updates Support diff --git a/templates/agent-work-app/styles.css b/templates/agent-work-app/styles.css index b090273..8a1a7b6 100644 --- a/templates/agent-work-app/styles.css +++ b/templates/agent-work-app/styles.css @@ -1120,6 +1120,20 @@ body.researchMode .content { font-size: 1.16rem; } +.docsSection h3 { + margin: 0 0 0.55rem; + font-size: 0.98rem; +} + +.docsSection code { + color: var(--text); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--panel-line); + padding: 0.08rem 0.32rem; + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 0.92em; +} + .docsExploreGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1157,6 +1171,85 @@ body.researchMode .content { font-size: 1.15rem; } +.appDocSection { + max-width: 860px; +} + +.appDocSection > p { + max-width: 740px; + margin: 0 0 1.1rem; + color: var(--muted); + line-height: 1.58; +} + +.docsDetailGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.docsDetailGrid article, +.docsCallout { + border: 1px solid var(--panel-line); + background: linear-gradient(180deg, rgba(17, 17, 16, 0.96), rgba(11, 11, 10, 0.98)); + padding: 1.1rem; +} + +.docsDetailGrid p, +.docsDetailGrid li, +.docsCallout p { + color: var(--muted); + line-height: 1.52; +} + +.docsDetailGrid p, +.docsCallout p { + margin: 0; +} + +.docsDetailGrid ul { + margin: 0; + padding-left: 1.1rem; +} + +.docsDetailGrid li + li { + margin-top: 0.35rem; +} + +.docsFlow { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; + margin-top: 1rem; + padding: 0.9rem 1rem; + border: 1px solid var(--panel-line); + background: rgba(255, 90, 0, 0.05); +} + +.docsFlow span { + border: 1px solid var(--line-dim); + padding: 0.42rem 0.62rem; + color: var(--text); + font-weight: 900; +} + +.docsFlow b { + color: var(--orange); +} + +.docsCallout { + margin-top: 1rem; + border-color: rgba(255, 90, 0, 0.28); +} + +.docsCallout strong { + display: block; + margin-bottom: 0.45rem; + color: var(--orange); +} + .docGlyph { width: 2.25rem; height: 2.25rem; From 38fb3e045be2501dec619fad66bd0cf276f257c4 Mon Sep 17 00:00:00 2001 From: Alice Norwood Date: Fri, 1 May 2026 16:20:23 -0400 Subject: [PATCH 04/16] Add Kanji app overview control surface --- docs/kanji-a-day.md | 68 ++- skills/codex/app-overview-page-v1/SKILL.md | 127 +++++ templates/agent-work-app/app.js | 14 + templates/agent-work-app/index.html | 190 ++++++-- templates/agent-work-app/styles.css | 526 ++++++++++++++++++++- 5 files changed, 856 insertions(+), 69 deletions(-) create mode 100644 skills/codex/app-overview-page-v1/SKILL.md diff --git a/docs/kanji-a-day.md b/docs/kanji-a-day.md index 55f30a5..aa853bf 100644 --- a/docs/kanji-a-day.md +++ b/docs/kanji-a-day.md @@ -1,25 +1,35 @@ # Kanji a Day -Kanji a Day is a watch-friendly local learning app for one daily kanji. The product loop is intentionally narrow: +Kanji a Day is a watch-friendly local learning app for one daily kanji. ```text Today -> stroke practice -> meaning -> Got it -> history ``` -The current preview focuses on the ritual that makes the app distinct: learn the shape first, reveal the meaning second, then save the kanji into memory. +The Docs route now treats the app page as a Product Control Surface, not a static article. It follows `app_overview_page_v1` so an operator or agent can understand state, open entry points, validate behavior, and continue work from one page. -## User Flow +## Current State -- `Today` - Shows the current kanji, meaning, reading, and primary start action. -- `Stroke practice` - Animates each stroke in order. Future strokes stay faint, completed strokes stay visible, and the current stroke draws in. -- `Meaning` - Reveals the kanji, English meaning, reading, and one example word. -- `Got it` - Records the kanji as learned for the day. -- `History` - Shows learned kanji and gives the user a memory trail. +- Status: `development` +- Version: `0.3.0` +- Updated: `2026-05-01` +- Environment: local preview +- Live preview: `http://127.0.0.1:47924/` +- Taskstream epic: `1000110` +- Implementation branch: `codex/kanji-pwa-learning-loop` + +## Page Contract + +The `/docs#kanji-a-day` page must include: + +- Header: app identity, description, status, version, updated date +- Control Strip: live app, repository, and Taskstream links +- Project Dashboard: status, version, environment, validation date, daily lesson count, kanji set size, subscription state +- About: what the app does every day and the core mechanics +- Current Release: actual version, build, date, and shipped notes +- System Architecture: readable pipeline from PWA preview to local storage +- Operations: Taskstream, preview, validation, and architecture entry points +- Links + Entry Points: User Guide, Data Model, Changelog, and PR ## Product Rules @@ -43,31 +53,37 @@ The embedded starter set is: Each item should include meaning, reading, example vocabulary, stroke count, and SVG stroke path data. -## Local Surfaces - -- Product preview source: `workspace/runs/agent-work/1000104/public/` -- WatchOS app source: `apps/watch/KanjiADay/` -- Static repo preview: `apps/watch/KanjiADay/Preview/index.html` - -The active local preview has been tested at: +## Architecture ```text -http://127.0.0.1:47924/ +PWA Preview -> Stroke Player -> Kanji Dataset -> Local Storage ``` +- PWA Preview: watch-style compact UI under `workspace/runs/agent-work/1000104/public/` +- Stroke Player: sequential SVG stroke playback and replay +- Kanji Dataset: seven embedded beginner records +- Local Storage: learned history, streak state, and current kanji progress + ## Validation -Use Firefox responsive design mode or Playwright to check: +Use Firefox responsive design mode or screenshot automation to check: -- 360px, 390px, 430px widths +- `/docs#kanji-a-day` renders the Product Control Surface +- desktop layout has dense operational cards and a right action column +- 360px, 390px, and 430px widths have no horizontal clipping - Today screen has no overlapping header text - Stroke completion keeps Replay and Meaning fully inside the watch frame - Meaning screen centers the kanji and keeps `Got it` visible inside the watch frame -- History has an empty state and learned cards -- Normal mode contains no debug or MVP copy +- Normal app mode contains no debug or MVP copy - `?debug=1` exposes debug-only controls when needed -Recent visual evidence lives under: +Reference screenshot: + +```text +/home/alice/Downloads/kanji a day.png +``` + +Recent app visual evidence lives under: ```text workspace/runs/agent-work/1000104/screenshots/ diff --git a/skills/codex/app-overview-page-v1/SKILL.md b/skills/codex/app-overview-page-v1/SKILL.md new file mode 100644 index 0000000..3acf005 --- /dev/null +++ b/skills/codex/app-overview-page-v1/SKILL.md @@ -0,0 +1,127 @@ +--- +name: app_overview_page_v1 +description: Use when creating or refactoring a Cento app documentation page into a Product Control Surface with mandatory docs, dashboard, status, operations, architecture, and entry-point sections. +--- + +# App Overview Page v1 + +Create a single source-of-truth page for an app that combines docs, dashboard, status, operations, and entry points. + +## Required Page Contract + +Every app overview page must include these sections, in this order: + +1. Header +2. Control Strip +3. Project Dashboard +4. About +5. Current Release +6. System Architecture +7. Operations +8. Links + Entry Points + +Do not skip sections. If a system does not have a production value yet, state the real local/dev value and why. + +## Section Requirements + +### Header + +Include: + +- app name +- one-line functional description +- status badge: `development`, `staging`, or `production` +- version +- last updated date + +### Control Strip + +Include clickable, real links: + +- live app URL +- repository URL +- Cento Dashboard or Taskstream entry + +Optional links are allowed only when real: API endpoint, preview build, feature flags panel. + +### Project Dashboard + +Include: + +- status +- version +- environment +- last deploy, validation, or preview refresh time + +Prefer product-specific metrics over fake business metrics. Use realistic values and avoid placeholder rows. + +### About + +Use a short operational paragraph, then feature bullets. Answer what the system does every day. + +### Current Release + +Include: + +- version +- build number +- release date +- release notes + +Release notes must reflect actual capabilities. Do not add aspirational features. + +### System Architecture + +Use a simple readable pipeline, for example: + +```text +PWA Preview -> Stroke Player -> Kanji Dataset -> Local Storage +``` + +Include components, data flow, storage type, and analytics or validation path when applicable. + +### Operations + +Include agent-executable actions such as: + +- open in Taskstream +- open preview or build target +- view logs or evidence +- run validation + +Actions can link to the relevant route, issue, artifact, or docs anchor. Do not include nonfunctional buttons. + +### Links + Entry Points + +Include real anchors or links for: + +- User Guide +- Data Model +- Changelog +- API, when one exists + +## Design Rules + +- Use a two-column or three-column operational grid on desktop: core content left, actions and links right. +- Stack cleanly on mobile. +- Every section is a dense information card. +- Use dark surfaces, subtle borders, and orange only for primary accents/actions. +- Avoid marketing hero sections, filler copy, and empty space. + +## AI Generation Rules + +Must: + +- fill every section +- reflect actual system behavior +- use realistic values +- keep links clickable and real +- validate visually at desktop and mobile widths + +Must not: + +- say `TBD` +- say `coming soon` +- invent fake features not present in the release notes +- use placeholder metrics without explanation +- bury operations below marketing content diff --git a/templates/agent-work-app/app.js b/templates/agent-work-app/app.js index b163371..b1cc932 100644 --- a/templates/agent-work-app/app.js +++ b/templates/agent-work-app/app.js @@ -32,6 +32,7 @@ const perPageButtons = document.querySelectorAll(".perPageButton"); const countLinks = document.querySelectorAll("a[data-filter]"); const mainNavLinks = document.querySelectorAll("[data-main-route]"); const primaryNavLinks = document.querySelectorAll("[data-nav-route]"); +const docsHashLinks = document.querySelectorAll(".docsSidebar nav a[href^='#'], .docsToc a[href^='#']"); const agentSummary = document.querySelector("#agentSummary"); const agentCards = document.querySelector("#agentCards"); const taskstreamNav = document.querySelector(".taskstreamNav"); @@ -865,6 +866,16 @@ function setNavActive(route) { document.body.classList.toggle("researchMode", activeMain === "research"); } +function syncDocsHashNavigation() { + if (!docsHashLinks.length) return; + const activeHash = location.hash || "#overview"; + docsHashLinks.forEach((link) => { + const href = link.getAttribute("href") || ""; + const isKanjiChild = activeHash.startsWith("#kanji-") && href === "#kanji-a-day"; + link.classList.toggle("active", href === activeHash || isKanjiChild); + }); +} + function refreshSavedQueryOptions() { if (!savedQuerySelect) return; const options = ['']; @@ -1660,6 +1671,7 @@ function showCentoSection(route) { } const hash = route === "docs" ? location.hash : ""; history.replaceState(null, "", `/${route}${hash}`); + if (route === "docs") syncDocsHashNavigation(); } async function loadIssues() { @@ -2103,6 +2115,8 @@ window.addEventListener("popstate", () => { } }); +window.addEventListener("hashchange", syncDocsHashNavigation); + async function boot() { syncStateFromLocation(); setNavActive(); diff --git a/templates/agent-work-app/index.html b/templates/agent-work-app/index.html index cb6d9cb..ed56874 100644 --- a/templates/agent-work-app/index.html +++ b/templates/agent-work-app/index.html @@ -259,6 +259,12 @@

Factory Runs

Apps Kanji a Day +
@@ -351,54 +357,154 @@

Kanji a Day

-
- App documentation -

Kanji a Day

-

Kanji a Day is a compact learning app for one daily kanji. The experience is built around a product ritual: shape first, meaning second, memory third.

+
+
+
+ +
+ App control surface +

Kanji a Day

+

One kanji per day with stroke order, meaning, local progress, and history.

+
+ Development + v0.3.0 + +
+
+
-
-
-

Core loop

-

Today opens the daily kanji, Stroke practice teaches order, Meaning reveals reading and example vocabulary, and Got it saves the kanji into history.

-
-
-

Current surfaces

-

The active local preview runs from workspace/runs/agent-work/1000104/public/. The native watch source lives under apps/watch/KanjiADay/, with a static repo preview in apps/watch/KanjiADay/Preview/index.html.

-
-
+
+
+
+
4/410:09
+
+
ReplayMeaning
+
+
+
-
-
-
-

Product rules

-
    -
  • Meaning stays locked until all strokes complete.
  • -
  • Learned count increments only after the user taps Got it.
  • -
  • Normal mode must not show MVP, PWA, AI-call, reset, localhost, or backend copy.
  • -
  • Debug controls stay behind ?debug=1.
  • -
-
-
-

Starter content

-

The embedded beginner set is , , , , , , and . Each record should carry meaning, reading, example vocabulary, stroke count, and SVG stroke paths.

-
+
+

Project Dashboard

+
+
StatusDevelopment
+
Version0.3.0
+
Environmentlocal preview
+
Last Validation2026-05-01
+
Daily Lessons1
+
Kanji Set7
+
SubscriptionsNot enabled
+
+
+ +
+
+
+

About Kanji a Day

+

Kanji a Day delivers one beginner kanji each day through a compact watch-style learning loop. The system teaches shape first, then meaning, then stores the lesson in local history.

+
+
Stroke OrderSequential SVG playback
+
Daily LearningOne kanji per day
+
Meaning RevealMeaning, reading, example
+
Local-FirstProgress in localStorage
+
HistoryLearned cards and detail view
+
Debug ModeControls behind ?debug=1
+
+ User Guide +
+
+ +
+
+

Current Release

+
+
Version
0.3.0
+
Build
1000104
+
Release Date
2026-05-01
+
+ Release Notes +
    +
  • Watch-style daily kanji practice surface
  • +
  • Stroke playback with replay and completion state
  • +
  • Meaning reveal with reading and example word
  • +
  • Local progression, streak, and learned history
  • +
  • Normal mode hides internal debug controls
  • +
+ View Changelog +
+ +
+

Data Model

+

The embedded starter set is , , , , , , and . Each record carries meaning, reading, example vocabulary, stroke count, and SVG stroke paths.

+
+ not_started + + practicing + + stroke_complete + + meaning_viewed + + learned +
+
+
+ +
+

System Architecture

+
+
PWA Previewwatch-style UI
+ +
Stroke PlayerSVG paths
+ +
Kanji Dataset7 local records
+ +
Local Storagehistory + streak
+
+
+ +
-
- Validation focus +
+ Validation Focus

Check Firefox responsive widths at 360px, 390px, and 430px. The watch header must not overlap, controls must stay inside the rounded frame, and the Meaning screen must center the kanji while keeping Got it visible.

-
+
@@ -426,6 +532,8 @@

On this page

Overview Explore by area Kanji a Day + Architecture + Validation Recent updates Support diff --git a/templates/agent-work-app/styles.css b/templates/agent-work-app/styles.css index 8a1a7b6..da1df85 100644 --- a/templates/agent-work-app/styles.css +++ b/templates/agent-work-app/styles.css @@ -106,7 +106,7 @@ button:hover { top: 0; z-index: 20; padding: 0.75rem 1.1rem; - background: rgba(5, 4, 3, 0.94); + background: #050403; box-shadow: 0 0 24px rgba(255, 90, 0, 0.12); } @@ -808,6 +808,21 @@ body.researchMode .content { background: rgba(255, 90, 0, 0.16); } +.docsSubNav { + margin: 0.2rem 0 0.15rem 1.2rem; +} + +.docsSidebar nav .docsSubNav a { + min-height: 1.55rem; + padding-left: 0.45rem; + font-size: 0.84rem; +} + +.docsSidebar nav .docsSubNav .navIcon { + color: var(--muted); + font-size: 0.66rem; +} + .navIcon { width: 1rem; color: var(--orange); @@ -1172,7 +1187,7 @@ body.researchMode .content { } .appDocSection { - max-width: 860px; + max-width: none; } .appDocSection > p { @@ -1250,6 +1265,409 @@ body.researchMode .content { color: var(--orange); } +.appOverviewPage { + scroll-margin-top: 5.25rem; + color: var(--text); +} + +.appOverviewPage h2 { + margin: 0.15rem 0 0.5rem; + font-size: clamp(2.05rem, 3vw, 2.7rem); + line-height: 1.02; +} + +.appOverviewHero { + display: grid; + grid-template-columns: minmax(19rem, 1fr) minmax(13.5rem, 0.54fr) minmax(16rem, 0.64fr); + gap: 1rem; + align-items: stretch; +} + +.appIdentity { + display: grid; + grid-template-columns: 5rem minmax(0, 1fr); + gap: 1.25rem; + align-items: center; + min-width: 0; +} + +.appIcon { + width: 4.8rem; + height: 4.8rem; + display: grid; + place-items: center; + border: 1px solid rgba(255, 90, 0, 0.2); + border-radius: 18px; + background: + radial-gradient(circle, rgba(255, 90, 0, 0.18) 0 36%, transparent 37%), + #f2eee8; + color: #090807; + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 2.8rem; + font-weight: 900; + box-shadow: inset 0 0 0 0.48rem rgba(255, 90, 0, 0.08); +} + +.appIdentity p { + max-width: 46rem; + margin: 0; + color: var(--text); + line-height: 1.55; +} + +.appMetaRow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.65rem; + margin-top: 1.25rem; + color: var(--muted); +} + +.versionPill { + display: inline-flex; + align-items: center; + min-height: 1.55rem; + padding: 0 0.62rem; + border: 1px solid rgba(168, 156, 145, 0.22); + background: rgba(18, 18, 17, 0.9); + color: var(--text); + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 0.82rem; + font-weight: 900; +} + +.appPreviewCard, +.appControlCard, +.appDashboardCard, +.appInfoCard { + border: 1px solid rgba(168, 156, 145, 0.18); + border-radius: 6px; + background: rgba(17, 17, 16, 0.86); + box-shadow: 0 0 0 1px rgba(255, 90, 0, 0.02); +} + +.appPreviewCard { + display: grid; + place-items: center; + min-height: 12rem; + margin: 0; + padding: 0.95rem; + overflow: hidden; +} + +.miniWatch { + width: 8.8rem; + aspect-ratio: 0.78; + display: grid; + place-items: center; + border: 0.42rem solid #343b40; + border-radius: 2rem; + background: #010101; + box-shadow: 0 0 2rem rgba(255, 90, 0, 0.13); +} + +.miniWatchScreen { + width: 100%; + height: 100%; + display: grid; + grid-template-rows: 1.2rem minmax(0, 1fr) 1.8rem; + gap: 0.25rem; + padding: 0.6rem 0.55rem 0.5rem; + overflow: hidden; +} + +.miniWatchTop, +.miniWatchButtons { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--text); + font-size: 0.48rem; + font-weight: 900; +} + +.miniWatchTop span:first-child { + color: #ff4f3d; +} + +.miniKanji { + display: grid; + place-items: center; + color: #fff8f2; + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 4.4rem; + line-height: 1; +} + +.miniWatchButtons span, +.miniWatchButtons strong { + min-width: 2.9rem; + padding: 0.32rem 0.3rem; + border-radius: 1rem; + background: #10100f; + text-align: center; +} + +.miniWatchButtons strong { + background: #ff4937; + color: white; +} + +.appControlCard { + display: grid; + align-content: center; + padding: 0.75rem 1rem; +} + +.appControlCard a { + display: grid; + gap: 0.25rem; + padding: 0.8rem 0; + border-bottom: 1px solid rgba(168, 156, 145, 0.12); + color: var(--text); + text-decoration: none; +} + +.appControlCard a:last-child { + border-bottom: 0; +} + +.appControlCard a:hover span { + color: var(--orange); +} + +.appControlCard span { + color: #8fbfff; + overflow-wrap: anywhere; +} + +.appDashboardCard, +.appInfoCard { + margin-top: 1rem; + padding: 1.15rem 1.2rem; +} + +.appDashboardCard h3, +.appInfoCard h3 { + margin: 0 0 0.9rem; +} + +.appMetricGrid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0; +} + +.appMetricGrid div { + min-width: 0; + padding: 0.2rem 0.72rem 0.1rem; + border-left: 1px solid rgba(168, 156, 145, 0.14); +} + +.appMetricGrid div:first-child { + border-left: 0; + padding-left: 0; +} + +.appMetricGrid span, +.releaseFacts dt, +.appFeatureGrid small, +.appActionList small, +.appArchitecture small { + display: block; + color: var(--muted); + font-size: 0.84rem; +} + +.appMetricGrid strong { + display: flex; + align-items: center; + gap: 0.45rem; + margin-top: 0.55rem; + min-height: 1.7rem; + color: var(--text); + font-size: 0.96rem; + white-space: nowrap; + overflow-wrap: anywhere; +} + +.metricDot { + width: 0.48rem; + height: 0.48rem; + border-radius: 50%; + background: var(--green); +} + +.appOverviewGrid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(15rem, 0.88fr); + gap: 1rem; + align-items: start; +} + +.appMainColumn { + min-width: 0; +} + +.appInfoCard p { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.appFeatureGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.1rem 1rem; + margin-top: 1.15rem; +} + +.appFeatureGrid div { + display: grid; + grid-template-columns: 1.35rem minmax(0, 1fr); + gap: 0.25rem 0.55rem; + align-items: center; +} + +.appFeatureGrid small { + grid-column: 2; +} + +.featureIcon, +.archIcon { + color: var(--orange); + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-weight: 900; +} + +.appPrimaryLink { + display: inline-flex; + align-items: center; + gap: 0.65rem; + margin-top: 1.35rem; + min-height: 2.4rem; + padding: 0 0.95rem; + border: 1px solid var(--line-dim); + color: var(--orange); + font-weight: 900; + text-decoration: none; +} + +.appPrimaryLink:hover, +.appActionList a:hover { + border-color: var(--orange); + color: var(--orange); +} + +.releaseFacts { + display: grid; + gap: 0.5rem; + margin: 0 0 0.95rem; +} + +.releaseFacts div { + display: grid; + grid-template-columns: 9rem minmax(0, 1fr); + gap: 0.85rem; +} + +.releaseFacts dd { + margin: 0; + color: var(--text); +} + +.releaseLabel { + display: block; + margin: 0.25rem 0 0.55rem; +} + +.releaseList { + margin: 0; + padding-left: 1rem; + color: var(--muted); + line-height: 1.55; +} + +.releaseList li + li { + margin-top: 0.25rem; +} + +.releaseList li::marker { + color: var(--green); +} + +.appArchitecture { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); + gap: 0.85rem; + align-items: center; +} + +.appArchitecture > div { + min-height: 4.8rem; + display: grid; + grid-template-columns: 1.8rem minmax(0, 1fr); + gap: 0.25rem 0.65rem; + align-items: center; + padding: 0.9rem; + border: 1px solid rgba(168, 156, 145, 0.18); + background: rgba(8, 8, 7, 0.5); +} + +.appArchitecture small { + grid-column: 2; +} + +.appArchitecture > b { + justify-self: center; + margin: 0 -0.2rem; + color: var(--muted); + z-index: 1; +} + +.appActionColumn { + grid-column: 3; + grid-row: 1 / span 2; + min-width: 0; +} + +.appArchitectureCard { + grid-column: 1 / span 2; +} + +.appActionList { + display: grid; + gap: 0.2rem; +} + +.appActionList a { + display: grid; + grid-template-columns: 1.25rem minmax(0, 1fr) auto; + gap: 0.1rem 0.65rem; + align-items: center; + min-height: 3.25rem; + padding: 0.55rem 0; + border: 1px solid transparent; + color: var(--text); + text-decoration: none; +} + +.appActionList small { + grid-column: 2; +} + +.appActionList b { + grid-column: 3; + grid-row: 1 / span 2; + color: var(--muted); +} + +.compactFlow { + margin-top: 1rem; + font-size: 0.82rem; +} + .docGlyph { width: 2.25rem; height: 2.25rem; @@ -3158,6 +3576,36 @@ kbd { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .appOverviewHero, + .appOverviewGrid { + grid-template-columns: 1fr; + } + + .appActionColumn, + .appArchitectureCard { + grid-column: auto; + grid-row: auto; + } + + .appMetricGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem 0; + } + + .appMetricGrid div:nth-child(3n + 1) { + border-left: 0; + padding-left: 0; + } + + .appArchitecture { + grid-template-columns: 1fr; + } + + .appArchitecture > b { + margin: -0.55rem 0; + transform: rotate(90deg); + } + .filterPanel { grid-template-columns: repeat(2, minmax(150px, 1fr)); } @@ -3180,6 +3628,10 @@ kbd { overflow-x: hidden; } + .appOverviewPage { + scroll-margin-top: 12.5rem; + } + .topbar { padding: 0.75rem; } @@ -3350,6 +3802,76 @@ kbd { grid-template-columns: 1fr; } + .appOverviewPage h2 { + font-size: 2rem; + } + + .appIdentity { + grid-template-columns: 3.7rem minmax(0, 1fr); + gap: 0.9rem; + } + + .appIcon { + width: 3.7rem; + height: 3.7rem; + border-radius: 14px; + font-size: 2.15rem; + } + + .appMetaRow { + align-items: flex-start; + flex-direction: column; + gap: 0.45rem; + } + + .appPreviewCard { + min-height: 10rem; + } + + .miniWatch { + width: 7.8rem; + } + + .appMetricGrid, + .appFeatureGrid { + grid-template-columns: 1fr; + } + + .appMetricGrid strong { + white-space: normal; + } + + .appMetricGrid div, + .appMetricGrid div:nth-child(3n + 1) { + padding: 0.65rem 0 0.7rem; + border-left: 0; + border-top: 1px solid rgba(168, 156, 145, 0.14); + } + + .appMetricGrid div:first-child { + border-top: 0; + } + + .releaseFacts div { + grid-template-columns: 1fr; + gap: 0.18rem; + } + + .appInfoCard, + .appDashboardCard { + padding: 1rem; + } + + .compactFlow { + display: grid; + grid-template-columns: 1fr; + } + + .compactFlow b { + justify-self: start; + transform: rotate(90deg); + } + .docsAreaCard, .docsSupport { grid-template-columns: 2.7rem minmax(0, 1fr); From a9c5298e277082d603e7d22a2f70b40891c3d687 Mon Sep 17 00:00:00 2001 From: Alice Norwood Date: Fri, 1 May 2026 16:42:20 -0400 Subject: [PATCH 05/16] Fix Kanji docs route navigation --- templates/agent-work-app/app.js | 19 +++++++++---- templates/agent-work-app/index.html | 38 +++++++++++++++---------- templates/agent-work-app/styles.css | 43 +++++++++++++++++++++++++---- 3 files changed, 75 insertions(+), 25 deletions(-) diff --git a/templates/agent-work-app/app.js b/templates/agent-work-app/app.js index b1cc932..034313f 100644 --- a/templates/agent-work-app/app.js +++ b/templates/agent-work-app/app.js @@ -864,16 +864,25 @@ function setNavActive(route) { if (taskstreamNav) taskstreamNav.classList.toggle("hidden", activeMain !== "taskstream"); document.body.classList.toggle("docsMode", activeMain === "docs"); document.body.classList.toggle("researchMode", activeMain === "research"); + if (activeMain !== "docs") document.body.classList.remove("docsAppPage"); } -function syncDocsHashNavigation() { +function syncDocsHashNavigation(options = {}) { if (!docsHashLinks.length) return; const activeHash = location.hash || "#overview"; + const isKanjiAppPage = activeHash.startsWith("#kanji"); + document.body.classList.toggle("docsAppPage", isKanjiAppPage); docsHashLinks.forEach((link) => { const href = link.getAttribute("href") || ""; - const isKanjiChild = activeHash.startsWith("#kanji-") && href === "#kanji-a-day"; - link.classList.toggle("active", href === activeHash || isKanjiChild); + const isSidebarKanjiParent = href === "#kanji-a-day" && link.classList.contains("docsAppParentLink"); + link.classList.toggle("active", href === activeHash && !isSidebarKanjiParent); }); + if (options.scrollToHash && isKanjiAppPage) { + window.requestAnimationFrame(() => { + const target = document.querySelector(activeHash); + if (target) target.scrollIntoView({ block: "start" }); + }); + } } function refreshSavedQueryOptions() { @@ -1671,7 +1680,7 @@ function showCentoSection(route) { } const hash = route === "docs" ? location.hash : ""; history.replaceState(null, "", `/${route}${hash}`); - if (route === "docs") syncDocsHashNavigation(); + if (route === "docs") syncDocsHashNavigation({ scrollToHash: true }); } async function loadIssues() { @@ -2115,7 +2124,7 @@ window.addEventListener("popstate", () => { } }); -window.addEventListener("hashchange", syncDocsHashNavigation); +window.addEventListener("hashchange", () => syncDocsHashNavigation({ scrollToHash: true })); async function boot() { syncStateFromLocation(); diff --git a/templates/agent-work-app/index.html b/templates/agent-work-app/index.html index ed56874..3b6df9b 100644 --- a/templates/agent-work-app/index.html +++ b/templates/agent-work-app/index.html @@ -258,7 +258,7 @@

Factory Runs

Apps - Kanji a Day + Kanji a Day
Overview User Guide @@ -267,7 +267,7 @@

Factory Runs

- + Need help? Contact our support team @@ -276,18 +276,18 @@

Factory Runs

-
+
Docs Overview
-
+

Cento Documentation

Operator-facing reference for running Cento, validating work, and finding the right command surface without leaving the console.

-
+

Explore by area

-
+
+
+ Docs + + Apps + + Kanji a Day +
+
@@ -507,7 +515,7 @@
-
+

Recent updates

Kanji a Day docs page added
@@ -517,7 +525,7 @@

Recent updates

-
+
?
diff --git a/templates/agent-work-app/styles.css b/templates/agent-work-app/styles.css index da1df85..61c1582 100644 --- a/templates/agent-work-app/styles.css +++ b/templates/agent-work-app/styles.css @@ -717,6 +717,14 @@ body.researchMode .content { linear-gradient(180deg, rgba(7, 8, 8, 0.96), rgba(5, 5, 4, 0.98)); } +body.docsAppPage #docsView:not(.hidden) { + grid-template-columns: 246px minmax(0, 1fr); +} + +body.docsAppPage #docsView > .docsToc { + display: none; +} + #researchView:not(.hidden) { display: grid; grid-template-columns: 246px minmax(460px, 1fr) minmax(560px, 1.25fr); @@ -896,6 +904,27 @@ body.researchMode .content { min-width: 0; } +.docsAppOnly { + display: none !important; +} + +body.docsAppPage .docsOverviewOnly { + display: none !important; +} + +body.docsAppPage .docsAppOnly { + display: block !important; +} + +body.docsAppPage .docsBreadcrumb.docsAppOnly, +body.docsAppPage .appOverviewPage .docsBreadcrumb { + display: flex !important; +} + +body.docsAppPage .appOverviewPage { + margin-top: 0; +} + .researchHeader { display: grid; grid-template-columns: 2rem minmax(0, 1fr) auto; @@ -1278,7 +1307,7 @@ body.researchMode .content { .appOverviewHero { display: grid; - grid-template-columns: minmax(19rem, 1fr) minmax(13.5rem, 0.54fr) minmax(16rem, 0.64fr); + grid-template-columns: minmax(17rem, 1fr) minmax(12rem, 0.54fr) minmax(13rem, 0.64fr); gap: 1rem; align-items: stretch; } @@ -1455,8 +1484,8 @@ body.researchMode .content { .appMetricGrid { display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 0; + grid-template-columns: repeat(auto-fit, minmax(7.35rem, 1fr)); + gap: 0.75rem 0; } .appMetricGrid div { @@ -1482,13 +1511,13 @@ body.researchMode .content { .appMetricGrid strong { display: flex; - align-items: center; + align-items: flex-start; gap: 0.45rem; margin-top: 0.55rem; min-height: 1.7rem; color: var(--text); font-size: 0.96rem; - white-space: nowrap; + white-space: normal; overflow-wrap: anywhere; } @@ -3730,6 +3759,10 @@ kbd { grid-template-columns: 1fr; } + body.docsAppPage #docsView:not(.hidden) { + grid-template-columns: 1fr; + } + .docsSidebar { border-right: 0; border-bottom: 1px solid var(--line); From b66c1c5a56de9cd376659bcf169e3e8bbb6a5774 Mon Sep 17 00:00:00 2001 From: Alice Norwood Date: Fri, 1 May 2026 17:40:04 -0400 Subject: [PATCH 06/16] Add Cento execution mode contracts --- .cento/modes.yaml | 49 ++++++ data/cento-cli.json | 26 ++- scripts/cento.sh | 8 + scripts/cento_run_mode.py | 337 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 .cento/modes.yaml create mode 100644 scripts/cento_run_mode.py diff --git a/.cento/modes.yaml b/.cento/modes.yaml new file mode 100644 index 0000000..de92634 --- /dev/null +++ b/.cento/modes.yaml @@ -0,0 +1,49 @@ +modes: + fast: + time_budget_minutes: 5 + validation_tier: smoke + info_policy: infer + ask_policy: blockers_only + commit_policy: none + push_policy: none + max_workers: 0 + max_files_changed: 3 + repair_attempts: 0 + risk_acceptance: medium + behavior: + - Patch the visible issue only. + - Infer missing details when the choice is reversible. + - Skip broad cleanup, refactors, PRs, and full regression. + + standard: + time_budget_minutes: 15 + validation_tier: focused + info_policy: ask_if_blocked + ask_policy: one_batch_if_material + commit_policy: local_commit + push_policy: optional + max_workers: 2 + max_files_changed: 8 + repair_attempts: 1 + risk_acceptance: low_medium + behavior: + - Make a scoped product-quality patch. + - Ask only if the wrong choice would waste work. + - Run targeted validation and commit owned paths when clean. + + thorough: + time_budget_minutes: 30 + validation_tier: product + info_policy: ask_first + ask_policy: requirements_or_options_first + commit_policy: local_commit + push_policy: branch + pr_policy: draft + max_workers: 4 + max_files_changed: null + repair_attempts: 3 + risk_acceptance: low + behavior: + - Plan first with options and budget. + - Use explicit manifests, workers, and validation evidence. + - Push a branch and prepare PR/taskstream evidence when requested. diff --git a/data/cento-cli.json b/data/cento-cli.json index 579e9a7..25af9bc 100644 --- a/data/cento-cli.json +++ b/data/cento-cli.json @@ -150,13 +150,31 @@ }, { "name": "run", - "summary": "Run a registered tool by id.", - "usage": "cento run TOOL [args...]", - "flags": [], + "summary": "Run a registered tool by id, or create a fast/standard/thorough execution contract.", + "usage": "cento run TOOL [args...] | cento run fast|standard|thorough --task TEXT [--write PATH]", + "flags": [ + { + "name": "--mode fast|standard|thorough", + "summary": "Create an execution-mode contract without using the positional mode form.", + "usage": "cento run --mode fast --task \"Fix app docs page\" --write apps/foo/index.html" + }, + { + "name": "--task TEXT", + "summary": "Operator task statement for execution-mode contracts.", + "usage": "cento run fast --task \"Fix app docs page\"" + }, + { + "name": "--write PATH", + "summary": "Owned writable path for the generated contract. Repeatable.", + "usage": "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html" + } + ], "examples": [ "cento run scan --query \"mcp\"", "cento run crm docs", - "cento run factory status workspace/runs/factory/factory-planning-e2e" + "cento run factory status workspace/runs/factory/factory-planning-e2e", + "cento run fast --task \"Fix app docs page\" --write apps/foo/index.html --route /docs/foo", + "cento run --mode standard --task \"Polish Kanji docs\" --write templates/agent-work-app/index.html --validation focused" ] }, { diff --git a/scripts/cento.sh b/scripts/cento.sh index 1a0923d..d9a2e0f 100755 --- a/scripts/cento.sh +++ b/scripts/cento.sh @@ -45,6 +45,8 @@ Built-ins: install [all|zsh|tmux] Install cento shell and tmux integration run TOOL [args...] Run a registered tool by id + run fast|standard|thorough --task TEXT [--write PATH] + Create an execution-mode contract and Codex prompt Routing: cento TOOL [args...] Run a registered tool directly @@ -65,6 +67,7 @@ Examples: cento install all cento install zsh cento install tmux + cento run fast --task "Fix app docs page" --write apps/foo/index.html cento mcp doctor cento mcp docs cento scan --query "mcp" @@ -560,6 +563,11 @@ main() { ;; run) [[ $# -gt 0 ]] || cento_die "Usage: cento run TOOL [args...]" + case "${1:-}" in + --mode|fast|standard|thorough) + exec python3 "$ROOT_DIR/scripts/cento_run_mode.py" "$@" + ;; + esac local tool_id=$1 shift run_tool "$tool_id" "$@" diff --git a/scripts/cento_run_mode.py b/scripts/cento_run_mode.py new file mode 100644 index 0000000..d0ab234 --- /dev/null +++ b/scripts/cento_run_mode.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Create a Cento execution-mode contract for an operator task.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +MODES_PATH = ROOT / ".cento" / "modes.yaml" +RUN_ROOT = ROOT / "workspace" / "runs" / "cento-run" + +DEFAULT_MODES: dict[str, dict[str, Any]] = { + "fast": { + "time_budget_minutes": 5, + "validation_tier": "smoke", + "info_policy": "infer", + "ask_policy": "blockers_only", + "commit_policy": "none", + "push_policy": "none", + "max_workers": 0, + "max_files_changed": 3, + "repair_attempts": 0, + "risk_acceptance": "medium", + "behavior": [ + "Patch the visible issue only.", + "Infer missing details when the choice is reversible.", + "Skip broad cleanup, refactors, PRs, and full regression.", + ], + }, + "standard": { + "time_budget_minutes": 15, + "validation_tier": "focused", + "info_policy": "ask_if_blocked", + "ask_policy": "one_batch_if_material", + "commit_policy": "local_commit", + "push_policy": "optional", + "max_workers": 2, + "max_files_changed": 8, + "repair_attempts": 1, + "risk_acceptance": "low_medium", + "behavior": [ + "Make a scoped product-quality patch.", + "Ask only if the wrong choice would waste work.", + "Run targeted validation and commit owned paths when clean.", + ], + }, + "thorough": { + "time_budget_minutes": 30, + "validation_tier": "product", + "info_policy": "ask_first", + "ask_policy": "requirements_or_options_first", + "commit_policy": "local_commit", + "push_policy": "branch", + "pr_policy": "draft", + "max_workers": 4, + "max_files_changed": None, + "repair_attempts": 3, + "risk_acceptance": "low", + "behavior": [ + "Plan first with options and budget.", + "Use explicit manifests, workers, and validation evidence.", + "Push a branch and prepare PR/taskstream evidence when requested.", + ], + }, +} + + +def load_modes() -> dict[str, dict[str, Any]]: + if not MODES_PATH.exists(): + return DEFAULT_MODES + try: + import yaml # type: ignore + + data = yaml.safe_load(MODES_PATH.read_text(encoding="utf-8")) or {} + modes = data.get("modes") if isinstance(data, dict) else None + if isinstance(modes, dict): + return {**DEFAULT_MODES, **modes} + except Exception: + return DEFAULT_MODES + return DEFAULT_MODES + + +def parse_args(argv: list[str]) -> argparse.Namespace: + modes = load_modes() + parser = argparse.ArgumentParser( + prog="cento run", + description="Create a Cento fast/standard/thorough execution contract.", + ) + parser.add_argument("mode_pos", nargs="?", choices=sorted(modes), help="Execution mode.") + parser.add_argument("--mode", choices=sorted(modes), help="Execution mode.") + parser.add_argument("--task", required=True, help="Operator task statement.") + parser.add_argument("--write", action="append", default=[], help="Owned writable path. Repeatable.") + parser.add_argument("--read", action="append", default=[], help="Relevant read-only path. Repeatable.") + parser.add_argument("--route", action="append", default=[], help="Target route or URL. Repeatable.") + parser.add_argument("--validation", help="Override validation tier.") + parser.add_argument("--commit", help="Override commit policy.") + parser.add_argument("--time-budget", help="Override time budget, e.g. 5m.") + parser.add_argument("--allow-dirty-owned", action="store_true", help="Do not block if owned paths are dirty.") + parser.add_argument("--copy-prompt", action="store_true", help="Copy the generated prompt to the clipboard.") + parser.add_argument("--print-prompt", action="store_true", help="Print the generated prompt.") + parser.add_argument("--json", action="store_true", help="Print the contract JSON path/result only.") + args = parser.parse_args(argv) + args.mode_name = args.mode or args.mode_pos + if not args.mode_name: + parser.error("provide a mode, e.g. `cento run fast --task ...` or `cento run --mode fast --task ...`") + return args + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") + return slug[:48] or "task" + + +def run_git_status() -> list[str]: + result = subprocess.run( + ["git", "status", "--porcelain=v1", "--untracked-files=all"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + raise SystemExit(result.stderr.strip() or "git status failed") + return [line for line in result.stdout.splitlines() if line.strip()] + + +def normalize_path(value: str) -> str: + path = Path(value) + if path.is_absolute(): + try: + return str(path.resolve().relative_to(ROOT)) + except ValueError: + return str(path) + return str(path) + + +def status_path(line: str) -> str: + path = line[3:] + if " -> " in path: + path = path.split(" -> ", 1)[1] + return path.strip() + + +def path_overlaps(owner: str, changed: str) -> bool: + owner = owner.rstrip("/") + changed = changed.rstrip("/") + return changed == owner or changed.startswith(owner + "/") + + +def dirty_summary(write_paths: list[str]) -> tuple[list[str], list[str], list[str]]: + status_lines = run_git_status() + changed_paths = [status_path(line) for line in status_lines] + dirty_owned: list[str] = [] + for owner in write_paths: + dirty_owned.extend(path for path in changed_paths if path_overlaps(owner, path)) + dirty_owned = sorted(set(dirty_owned)) + dirty_unrelated = sorted(path for path in changed_paths if path not in dirty_owned) + return status_lines, dirty_owned, dirty_unrelated + + +def parse_minutes(value: str | None, fallback: Any) -> int: + if value is None: + try: + return int(fallback) + except Exception: + return 5 + match = re.fullmatch(r"\s*(\d+)\s*(m|min|minutes?)?\s*", value) + if not match: + raise SystemExit(f"Invalid --time-budget: {value}") + return int(match.group(1)) + + +def copy_to_clipboard(text: str) -> bool: + for command in (["pbcopy"], ["wl-copy"]): + if shutil.which(command[0]): + subprocess.run(command, input=text, text=True, check=False) + return True + for command in (["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]): + if shutil.which(command[0]): + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + start_new_session=True, + ) + assert process.stdin is not None + process.stdin.write(text) + process.stdin.close() + return True + return False + + +def prompt_text(contract: dict[str, Any]) -> str: + mode = contract["mode"] + policies = contract["policies"] + write_paths = contract["paths"]["write"] or [""] + read_paths = contract["paths"]["read"] or [""] + routes = contract["routes"] or [""] + dirty_note = ( + f"{len(contract['dirty']['unrelated_paths'])} unrelated dirty path(s) exist. Preserve them and stage only owned paths." + if contract["dirty"]["unrelated_paths"] + else "No unrelated dirty paths detected." + ) + sections = [ + "You are executing inside Cento.", + "", + "Task:", + contract["task"], + "", + "Execution contract:", + f"- Mode: {mode}", + f"- Time budget: {contract['time_budget_minutes']} minutes", + f"- Validation tier: {policies['validation_tier']}", + f"- Info policy: {policies['info_policy']}", + f"- Ask policy: {policies['ask_policy']}", + f"- Commit policy: {policies['commit_policy']}", + f"- Push policy: {policies['push_policy']}", + f"- Risk acceptance: {policies['risk_acceptance']}", + "", + "Mode behavior:", + *[f"- {item}" for item in contract["mode_behavior"]], + "", + "Owned write paths:", + *[f"- {path}" for path in write_paths], + "", + "Read context:", + *[f"- {path}" for path in read_paths], + "", + "Target routes / URLs:", + *[f"- {route}" for route in routes], + "", + "Dirty repo policy:", + "- Preserve unrelated work.", + "- Do not use git add -A.", + "- Stage only owned paths if commit policy permits.", + f"- {dirty_note}", + "", + "Required output:", + "- changed files", + f"- validation run according to {policies['validation_tier']}", + "- assumptions made", + "- known risks / skipped checks", + "- commit hash only if commit policy permits", + "", + ] + return "\n".join(sections) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + modes = load_modes() + mode_config = modes[args.mode_name] + write_paths = [normalize_path(path) for path in args.write] + read_paths = [normalize_path(path) for path in args.read] + status_lines, dirty_owned, dirty_unrelated = dirty_summary(write_paths) + if dirty_owned and not args.allow_dirty_owned: + print("Blocked: owned path is already dirty.", file=sys.stderr) + for path in dirty_owned: + print(f" {path}", file=sys.stderr) + print("Use --allow-dirty-owned only if you intentionally want to work with those changes.", file=sys.stderr) + return 2 + + now = datetime.now(timezone.utc) + run_dir = RUN_ROOT / f"{now.strftime('%Y%m%d-%H%M%S')}-{args.mode_name}-{slugify(args.task)}" + run_dir.mkdir(parents=True, exist_ok=True) + validation_tier = args.validation or str(mode_config.get("validation_tier") or "smoke") + commit_policy = args.commit or str(mode_config.get("commit_policy") or "none") + contract = { + "schema": "cento.execution-contract.v1", + "created_at": now.isoformat(), + "mode": args.mode_name, + "task": args.task, + "time_budget_minutes": parse_minutes(args.time_budget, mode_config.get("time_budget_minutes")), + "routes": args.route, + "paths": { + "write": write_paths, + "read": read_paths, + }, + "policies": { + "validation_tier": validation_tier, + "info_policy": str(mode_config.get("info_policy") or "infer"), + "ask_policy": str(mode_config.get("ask_policy") or "blockers_only"), + "commit_policy": commit_policy, + "push_policy": str(mode_config.get("push_policy") or "none"), + "risk_acceptance": str(mode_config.get("risk_acceptance") or "medium"), + "repair_attempts": mode_config.get("repair_attempts", 0), + "max_workers": mode_config.get("max_workers", 0), + "max_files_changed": mode_config.get("max_files_changed"), + }, + "mode_behavior": list(mode_config.get("behavior") or []), + "dirty": { + "status_lines": status_lines, + "owned_paths": dirty_owned, + "unrelated_paths": dirty_unrelated, + }, + "artifacts": { + "run_dir": str(run_dir.relative_to(ROOT)), + "contract": str((run_dir / "contract.json").relative_to(ROOT)), + "prompt": str((run_dir / "prompt.md").relative_to(ROOT)), + }, + } + prompt = prompt_text(contract) + (run_dir / "contract.json").write_text(json.dumps(contract, indent=2, sort_keys=True) + "\n", encoding="utf-8") + (run_dir / "prompt.md").write_text(prompt, encoding="utf-8") + (run_dir / "dirty-baseline.txt").write_text("\n".join(status_lines) + ("\n" if status_lines else ""), encoding="utf-8") + + if args.copy_prompt and not copy_to_clipboard(prompt): + print("Clipboard unavailable; prompt was written to disk.", file=sys.stderr) + if args.print_prompt: + print(prompt) + return 0 + if args.json: + print(json.dumps(contract["artifacts"], indent=2, sort_keys=True)) + return 0 + + print(f"Cento {args.mode_name} contract created") + print(f"Run dir: {contract['artifacts']['run_dir']}") + print(f"Prompt: {contract['artifacts']['prompt']}") + print(f"Mode: {args.mode_name} · {contract['time_budget_minutes']}m · {validation_tier} · commit {commit_policy}") + if dirty_unrelated: + print(f"Dirty unrelated paths preserved: {len(dirty_unrelated)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From c4d1fbd723a15a95b08c7c64b72a0654a9e4765e Mon Sep 17 00:00:00 2001 From: Alice Norwood Date: Sat, 2 May 2026 14:22:09 -0400 Subject: [PATCH 07/16] Add research and pipeline studio apps --- scripts/agent_work_app.py | 646 +- scripts/codebase_intelligence.py | 492 ++ templates/agent-work-app/app.js | 1094 +++- .../codebase-intelligence-graph.js | 557 ++ .../codebase-intelligence-panels.js | 869 +++ .../agent-work-app/codebase-intelligence.css | 1273 ++++ templates/agent-work-app/index.html | 1112 +++- templates/agent-work-app/styles.css | 5721 ++++++++++++----- templates/agent-work-app/sw.js | 49 +- tests/test_codebase_intelligence.py | 215 + 10 files changed, 10363 insertions(+), 1665 deletions(-) create mode 100644 scripts/codebase_intelligence.py create mode 100644 templates/agent-work-app/codebase-intelligence-graph.js create mode 100644 templates/agent-work-app/codebase-intelligence-panels.js create mode 100644 templates/agent-work-app/codebase-intelligence.css create mode 100644 tests/test_codebase_intelligence.py diff --git a/scripts/agent_work_app.py b/scripts/agent_work_app.py index 615edf0..4e3cacf 100644 --- a/scripts/agent_work_app.py +++ b/scripts/agent_work_app.py @@ -15,6 +15,7 @@ import webbrowser import shlex import shutil +from copy import deepcopy from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -76,6 +77,7 @@ LOG_FILE = STATE_DIR / "agent-work-app.log" SYNC_LOG_FILE = STATE_DIR / "agent-work-app-sync.log" SYNC_LOCK_FILE = STATE_DIR / "agent-work-app-sync.lock" +DEV_PIPELINE_STUDIO_ROOT = ROOT_DIR / "workspace" / "runs" / "dev-pipeline-studio" / "docs-pages" / "latest" HEALTH_PATH = "/health" SYNC_CRON_BEGIN = "# >>> cento agent-work-app sync >>>" SYNC_CRON_END = "# <<< cento agent-work-app sync <<<" @@ -1089,6 +1091,607 @@ def read_json_path(path: Path) -> dict[str, Any]: return payload if isinstance(payload, dict) else {} +def write_json_path(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(f".{path.name}.tmp") + tmp_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(path) + + +def title_status(value: str, fallback: str = "") -> str: + raw = str(value or fallback or "").strip() + if not raw: + return "" + return raw.replace("_", " ").replace("-", " ").title() + + +def event_count(path: Path) -> int: + try: + return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line.strip()) + except OSError: + return 0 + + +def dev_pipeline_relative(path: Path) -> str: + try: + return str(path.resolve().relative_to(ROOT_DIR.resolve())) + except ValueError: + return str(path) + + +def dev_pipeline_root_path(root: Path, relative: str) -> Path: + clean = str(relative or "").strip().lstrip("/") + if not clean: + raise AgentWorkAppError("pipeline artifact path is required") + path = (root / clean).resolve() + root_resolved = root.resolve() + if path != root_resolved and root_resolved not in path.parents: + raise AgentWorkAppError("pipeline artifact path is outside the studio root") + return path + + +def dev_pipeline_slug(value: str, fallback: str) -> str: + raw = str(value or fallback or "").strip().lower() + chars: list[str] = [] + previous_dash = False + for char in raw: + if char.isalnum(): + chars.append(char) + previous_dash = False + elif not previous_dash: + chars.append("-") + previous_dash = True + slug = "".join(chars).strip("-") + return slug or fallback + + +def dev_pipeline_unique_id(items: list[dict[str, Any]], base_id: str) -> str: + existing = {str(item.get("id") or "") for item in items} + candidate = base_id + index = 2 + while candidate in existing: + candidate = f"{base_id}-{index}" + index += 1 + return candidate + + +def dev_pipeline_text(value: Any, current: str = "") -> str: + if value is None: + return current + return str(value).strip() + + +def dev_pipeline_float(value: Any, current: float) -> float: + if value is None or value == "": + return current + try: + return float(value) + except (TypeError, ValueError): + return current + + +def dev_pipeline_text_list(value: Any, current: list[str]) -> list[str]: + if value is None: + return current + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [line.strip() for line in str(value).splitlines() if line.strip()] + + +def dev_pipeline_required_inputs(value: Any, current: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + raw_items = value if isinstance(value, list) else current if isinstance(current, list) else [] + inputs: list[dict[str, Any]] = [] + for index, item in enumerate(raw_items, start=1): + if not isinstance(item, dict): + continue + title = dev_pipeline_text(item.get("title"), "") + if not title: + continue + status = dev_pipeline_text(item.get("status"), "Missing").lower().replace("_", "-") + if status not in {"provided", "configured", "missing", "optional"}: + status = "missing" + inputs.append( + { + "id": dev_pipeline_slug(dev_pipeline_text(item.get("id"), title), f"input-{index}"), + "title": title, + "detail": dev_pipeline_text(item.get("detail"), ""), + "status": status, + "required": bool(item.get("required", status != "optional")), + } + ) + return inputs + + +def dev_pipeline_default_required_inputs(template_id: str) -> list[dict[str, Any]]: + if str(template_id or "") != "generic-task": + return [] + return [ + { + "id": "task-objective", + "title": "Task objective", + "detail": "User goal, target behavior, and project outcome", + "status": "provided", + "required": True, + }, + { + "id": "target-surface", + "title": "Target surface", + "detail": "App, route, command, or file area the AI should change", + "status": "configured", + "required": True, + }, + { + "id": "acceptance-criteria", + "title": "Acceptance criteria", + "detail": "Concrete conditions that prove the task is complete", + "status": "configured", + "required": True, + }, + { + "id": "constraints", + "title": "Constraints", + "detail": "Owned paths, exclusions, style rules, and risk limits", + "status": "configured", + "required": True, + }, + { + "id": "validation-evidence", + "title": "Validation evidence", + "detail": "Commands, screenshots, review gates, and handoff proof", + "status": "missing", + "required": True, + }, + ] + + +def dev_pipeline_append_event(root: Path, manifest: dict[str, Any], event: str, project_id: str, template_id: str, details: dict[str, Any] | None = None) -> None: + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} + events_rel = str(artifacts.get("events") or "events.ndjson") + event_path = dev_pipeline_root_path(root, events_rel) + event_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "event": event, + "project_id": project_id, + "template_id": template_id, + "details": details or {}, + } + with event_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True) + "\n") + + +def dev_pipeline_artifact(root: Path, relative: str) -> dict[str, Any]: + clean = str(relative or "").strip() + path = root / clean if clean else root + return artifact_payload(Path(clean).name or "artifact", dev_pipeline_relative(path), source="dev-pipeline-studio") + + +def dev_pipeline_artifact_json(root: Path, relative: str) -> dict[str, Any]: + clean = str(relative or "").strip() + if not clean: + return {} + return read_json_path(root / clean) + + +def dev_pipeline_find(items: list[dict[str, Any]], requested: str, default_id: str) -> dict[str, Any]: + wanted = str(requested or default_id or "").strip() + for item in items: + aliases = [str(value) for value in item.get("aliases", []) if isinstance(value, str)] + if str(item.get("id") or "") == wanted or wanted in aliases: + return item + for item in items: + if str(item.get("id") or "") == default_id: + return item + return items[0] if items else {} + + +def dev_pipeline_worker_manifest(project: dict[str, Any], template: dict[str, Any], worker: dict[str, Any], root: Path) -> tuple[dict[str, Any], str]: + manifest_rel = str(worker.get("manifest") or "").strip() + payload = dev_pipeline_artifact_json(root, manifest_rel) + project_id = str(project.get("id") or "") + template_id = str(template.get("id") or "") + worker_id = str(worker.get("id") or "") + if payload and payload.get("project") == project_id and payload.get("template_id") == template_id: + return payload, manifest_rel + + file_name = str(worker.get("file") or f"{worker_id}.json") + read_paths = [str(value) for value in project.get("read_paths", []) if isinstance(value, str)] + read_paths.append(f"templates/pipelines/{template_id}.json") + payload = { + "schema_version": "cento.worker_manifest.v1", + "id": f"{worker_id}_worker_01", + "project": project_id, + "template_id": template_id, + "type": str(template.get("worker_type") or "pipeline_worker"), + "task_id": worker_id, + "description": f"{worker.get('description') or worker.get('title') or worker_id} for {project.get('label') or project_id} using the {template.get('label') or template_id} template", + "owned_paths": [f"{project.get('owned_root') or 'workspace/generated'}/{file_name}"], + "read_paths": read_paths, + "dependencies": [], + "acceptance": [ + f"{template.get('label') or 'Template'} output is valid", + "Template parameters are preserved", + "Only owned paths changed", + ], + "validation": {"tier": str(template.get("validation_tier") or "smoke")}, + } + return payload, manifest_rel + + +def dev_pipeline_synthesized_worker_manifest(project: dict[str, Any], template: dict[str, Any], worker: dict[str, Any]) -> dict[str, Any]: + worker_id = str(worker.get("id") or "") + file_name = str(worker.get("file") or f"{worker_id}.json") + read_paths = [str(value) for value in project.get("read_paths", []) if isinstance(value, str)] + template_id = str(template.get("id") or "") + read_paths.append(f"templates/pipelines/{template_id}.json") + return { + "schema_version": "cento.worker_manifest.v1", + "id": f"{worker_id}_worker_01", + "project": str(project.get("id") or ""), + "template_id": template_id, + "type": str(template.get("worker_type") or "pipeline_worker"), + "task_id": worker_id, + "description": f"{worker.get('description') or worker.get('title') or worker_id} for {project.get('label') or project.get('id') or 'project'}", + "owned_paths": [f"{project.get('owned_root') or 'workspace/generated'}/{file_name}"], + "read_paths": read_paths, + "dependencies": [str(value) for value in worker.get("dependencies", []) if isinstance(value, str)], + "acceptance": [ + f"{template.get('label') or 'Template'} output is valid", + "Template parameters are preserved", + "Only owned paths changed", + ], + "validation": {"tier": str(template.get("validation_tier") or "smoke")}, + } + + +def dev_pipeline_duplicate_template(root: Path, manifest: dict[str, Any], project: dict[str, Any], source_template: dict[str, Any], label_override: str = "") -> dict[str, Any]: + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + source_label = str(source_template.get("label") or source_template.get("id") or "Pipeline template") + label = label_override.strip() if label_override.strip() else f"{source_label} copy" + base_id = dev_pipeline_slug(label, f"{source_template.get('id') or 'template'}-copy") + template_id = dev_pipeline_unique_id(templates, base_id) + copied = deepcopy(source_template) + copied["id"] = template_id + copied["label"] = label + copied["slug"] = template_id + copied["detail"] = "Editable draft template" + copied["selected_worker"] = str(copied.get("selected_worker") or "") + copied_workers = [item for item in copied.get("workers", []) if isinstance(item, dict)] + for worker in copied_workers: + worker_id = str(worker.get("id") or "") + if not worker_id: + continue + worker["manifest"] = f"workers/{template_id}_{worker_id}.json" + worker.setdefault("integration_receipt", f"integration_receipts/{template_id}_{worker_id}.json") + worker_manifest = dev_pipeline_synthesized_worker_manifest(project, copied, worker) + write_json_path(dev_pipeline_root_path(root, str(worker["manifest"])), worker_manifest) + copied["workers"] = copied_workers + if not copied.get("selected_worker") and copied_workers: + copied["selected_worker"] = str(copied_workers[0].get("id") or "") + if isinstance(manifest.get("templates"), list): + manifest["templates"].append(copied) + else: + manifest["templates"] = [copied] + return copied + + +def dev_pipeline_update(payload: dict[str, Any]) -> dict[str, Any]: + root = DEV_PIPELINE_STUDIO_ROOT + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + raise AgentWorkAppError(f"Dev Pipeline Studio manifest not found: {dev_pipeline_relative(manifest_path)}") + + action = str(payload.get("action") or "save").strip() or "save" + if action not in {"save", "select_worker", "duplicate", "new"}: + raise AgentWorkAppError(f"Unsupported Dev Pipeline Studio action: {action}") + + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + defaults = manifest.get("defaults") if isinstance(manifest.get("defaults"), dict) else {} + project_id = str(payload.get("project_id") or defaults.get("project_id") or "").strip() + template_id = str(payload.get("template_id") or defaults.get("template_id") or "").strip() + project = dev_pipeline_find(projects, project_id, str(defaults.get("project_id") or "")) + template = dev_pipeline_find(templates, template_id, str(defaults.get("template_id") or "")) + if not project or not template: + raise AgentWorkAppError("A valid project and template are required") + + project_payload = payload.get("project") if isinstance(payload.get("project"), dict) else {} + template_payload = payload.get("template") if isinstance(payload.get("template"), dict) else {} + + if action in {"duplicate", "new"}: + label_override = "" + if action == "new": + label_override = dev_pipeline_text(template_payload.get("label"), "New generic task template") + elif "label" in template_payload: + label_override = f"{dev_pipeline_text(template_payload.get('label'), str(template.get('label') or 'Template'))} copy" + template = dev_pipeline_duplicate_template(root, manifest, project, template, label_override=label_override) + template_id = str(template.get("id") or "") + + if "label" in project_payload: + project["label"] = dev_pipeline_text(project_payload.get("label"), str(project.get("label") or "")) + if "surface" in project_payload: + project["surface"] = dev_pipeline_text(project_payload.get("surface"), str(project.get("surface") or "")) + if "surface_value" in project_payload: + project["surface_value"] = dev_pipeline_text(project_payload.get("surface_value"), str(project.get("surface_value") or "")) + if "owned_root" in project_payload: + project["owned_root"] = dev_pipeline_text(project_payload.get("owned_root"), str(project.get("owned_root") or "")) + if "read_paths" in project_payload or "read_paths_text" in project_payload: + raw_read_paths = project_payload.get("read_paths", project_payload.get("read_paths_text")) + project["read_paths"] = dev_pipeline_text_list(raw_read_paths, [str(value) for value in project.get("read_paths", []) if isinstance(value, str)]) + + if "label" in template_payload: + template["label"] = dev_pipeline_text(template_payload.get("label"), str(template.get("label") or "")) + if "detail" in template_payload: + template["detail"] = dev_pipeline_text(template_payload.get("detail"), str(template.get("detail") or "")) + if "description" in template_payload: + template["description"] = dev_pipeline_text(template_payload.get("description"), str(template.get("description") or "")) + if "tagline" in template_payload: + template["tagline"] = dev_pipeline_text(template_payload.get("tagline"), str(template.get("tagline") or "")) + if "validation_tier" in template_payload: + template["validation_tier"] = dev_pipeline_text(template_payload.get("validation_tier"), str(template.get("validation_tier") or "")) + if "risk" in template_payload: + template["risk"] = dev_pipeline_text(template_payload.get("risk"), str(template.get("risk") or "")) + if "worker_stage_label" in template_payload: + template["worker_stage_label"] = dev_pipeline_text(template_payload.get("worker_stage_label"), str(template.get("worker_stage_label") or "")) + if "execution_model" in template_payload: + template["execution_model"] = dev_pipeline_text(template_payload.get("execution_model"), str(template.get("execution_model") or "")) + if "required_inputs" in template_payload: + template["required_inputs"] = dev_pipeline_required_inputs(template_payload.get("required_inputs"), template.get("required_inputs")) + template["budget_spent_usd"] = dev_pipeline_float(template_payload.get("budget_spent_usd"), float(template.get("budget_spent_usd", (manifest.get("budget") or {}).get("spent_usd", 0)) or 0)) + template["budget_cap_usd"] = dev_pipeline_float(template_payload.get("budget_cap_usd"), float(template.get("budget_cap_usd", (manifest.get("budget") or {}).get("cap_usd", 0)) or 0)) + + workers = [item for item in template.get("workers", []) if isinstance(item, dict)] + requested_worker = dev_pipeline_text(template_payload.get("selected_worker"), str(template.get("selected_worker") or "")) + if action == "select_worker": + requested_worker = dev_pipeline_text(payload.get("worker_id"), requested_worker) + if requested_worker and any(str(worker.get("id") or "") == requested_worker for worker in workers): + template["selected_worker"] = requested_worker + elif workers and not template.get("selected_worker"): + template["selected_worker"] = str(workers[0].get("id") or "") + + worker_manifest_payload = payload.get("worker_manifest") + if isinstance(worker_manifest_payload, dict): + selected_worker_id = str(template.get("selected_worker") or "") + selected_worker = next((item for item in workers if str(item.get("id") or "") == selected_worker_id), workers[0] if workers else {}) + selected_worker_id = str(selected_worker.get("id") or selected_worker_id) + if selected_worker_id: + template["selected_worker"] = selected_worker_id + manifest_rel = str(selected_worker.get("manifest") or f"workers/{template.get('id')}_{selected_worker_id}.json") + selected_worker["manifest"] = manifest_rel + worker_manifest_payload = deepcopy(worker_manifest_payload) + worker_manifest_payload["schema_version"] = str(worker_manifest_payload.get("schema_version") or "cento.worker_manifest.v1") + worker_manifest_payload["project"] = str(project.get("id") or "") + worker_manifest_payload["template_id"] = str(template.get("id") or "") + worker_manifest_payload["task_id"] = selected_worker_id + if "validation" not in worker_manifest_payload or not isinstance(worker_manifest_payload.get("validation"), dict): + worker_manifest_payload["validation"] = {"tier": str(template.get("validation_tier") or "smoke")} + write_json_path(dev_pipeline_root_path(root, manifest_rel), worker_manifest_payload) + + defaults = manifest.get("defaults") if isinstance(manifest.get("defaults"), dict) else {} + defaults["project_id"] = str(project.get("id") or "") + defaults["template_id"] = str(template.get("id") or "") + manifest["defaults"] = defaults + manifest["active_run_id"] = f"{template.get('id')}-{project.get('id')}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}" + manifest["status"] = "healthy" + manifest["status_detail"] = "Editable manifest saved; worker contract and pipeline execution metadata are in sync" + + write_json_path(manifest_path, manifest) + dev_pipeline_append_event( + root, + manifest, + f"pipeline_{action}", + str(project.get("id") or ""), + str(template.get("id") or ""), + {"selected_worker": str(template.get("selected_worker") or "")}, + ) + return dev_pipeline_studio_state(project_id=str(project.get("id") or ""), template_id=str(template.get("id") or "")) + + +def dev_pipeline_studio_state(project_id: str = "", template_id: str = "") -> dict[str, Any]: + root = DEV_PIPELINE_STUDIO_ROOT + manifest_path = root / "pipeline_manifest.json" + manifest = read_json_path(manifest_path) + if not manifest: + raise AgentWorkAppError(f"Dev Pipeline Studio manifest not found: {dev_pipeline_relative(manifest_path)}") + + projects = [item for item in manifest.get("projects", []) if isinstance(item, dict)] + templates = [item for item in manifest.get("templates", []) if isinstance(item, dict)] + defaults = manifest.get("defaults") if isinstance(manifest.get("defaults"), dict) else {} + project = dev_pipeline_find(projects, project_id, str(defaults.get("project_id") or "")) + template = dev_pipeline_find(templates, template_id, str(defaults.get("template_id") or "")) + workers = [item for item in template.get("workers", []) if isinstance(item, dict)] + selected_worker_id = str(template.get("selected_worker") or (workers[0].get("id") if workers else "")) + selected_worker = next((item for item in workers if str(item.get("id") or "") == selected_worker_id), workers[0] if workers else {}) + worker_manifest, worker_manifest_rel = dev_pipeline_worker_manifest(project, template, selected_worker, root) + + artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} + validation_receipt_rel = str(artifacts.get("validation_receipt") or "validation/validation_receipt.json") + validation_receipt = dev_pipeline_artifact_json(root, validation_receipt_rel) + evidence_bundle_rel = str(artifacts.get("evidence_bundle") or "evidence/evidence_bundle.json") + budget_receipt_rel = str(artifacts.get("budget_receipt") or "evidence/budget_receipt.json") + pipeline_receipt_rel = str(artifacts.get("pipeline_receipt") or "evidence/pipeline_receipt.json") + taskstream_evidence_rel = str(artifacts.get("taskstream_evidence") or "evidence/taskstream_evidence.json") + events_rel = str(artifacts.get("events") or "events.ndjson") + event_total = event_count(root / events_rel) + + budget_spent = float(template.get("budget_spent_usd", (manifest.get("budget") or {}).get("spent_usd", 0)) or 0) + budget_cap = float(template.get("budget_cap_usd", (manifest.get("budget") or {}).get("cap_usd", 0)) or 0) + tasks_completed = int(template.get("tasks_completed", len(workers)) or 0) + tasks_total = int(template.get("tasks_total", max(len(workers), tasks_completed)) or 0) + execution_model = str(template.get("execution_model") or ("ordered" if str(template.get("id") or "") == "generic-task" else "parallel")) + worker_stage_label = str(template.get("worker_stage_label") or ("2. Task Execution" if execution_model == "ordered" else "2. Workers (Parallel)")) + worker_count_label = f"{len(workers)} ordered steps" if execution_model == "ordered" else f"{len(workers)} workers" + required_inputs = dev_pipeline_required_inputs(template.get("required_inputs")) or dev_pipeline_default_required_inputs(str(template.get("id") or "")) + missing_required_inputs = [ + item for item in required_inputs + if item.get("required") and str(item.get("status") or "") == "missing" + ] + input_count_label = f"{len(missing_required_inputs)} missing / {len(required_inputs)} inputs" if missing_required_inputs else f"{len(required_inputs)} inputs ready" + validation_status = str(validation_receipt.get("status") or manifest.get("status") or "unknown") + status_label = "Healthy" if validation_status in {"passed", "healthy", "completed"} else title_status(validation_status, "Unknown") + + worker_cards: list[dict[str, Any]] = [] + integration_cards: list[dict[str, Any]] = [] + for index, worker in enumerate(workers, start=1): + worker_id = str(worker.get("id") or "") + file_name = str(worker.get("file") or f"{worker_id}.json") + receipt_rel = str(worker.get("integration_receipt") or f"integration_receipts/{template.get('id')}_{worker_id}.json") + receipt = dev_pipeline_artifact_json(root, receipt_rel) + receipt_status = str(receipt.get("status") or "accepted") + dependencies = [str(value) for value in worker.get("dependencies", []) if isinstance(value, str)] + if dependencies: + worker_detail = f"{file_name} after {', '.join(dependencies)}" + elif execution_model == "ordered": + worker_detail = f"{file_name} step {index}/{len(workers)}" + else: + worker_detail = f"{file_name} parallel" + worker_cards.append( + { + "id": worker_id, + "title": str(worker.get("title") or worker_id), + "file": file_name, + "detail": worker_detail, + "status": "Completed", + "selected": worker_id == selected_worker_id, + } + ) + integration_cards.append( + { + "id": worker_id, + "title": f"Integrate: {file_name}", + "file": "integration_receipt.json", + "status": title_status(receipt_status, "Accepted"), + "path": dev_pipeline_relative(root / receipt_rel) if (root / receipt_rel).exists() else "", + } + ) + + validator_cards: list[dict[str, Any]] = [] + for item in [entry for entry in template.get("validators", []) if isinstance(entry, dict)]: + receipt_rel = str(item.get("receipt") or "") + receipt = dev_pipeline_artifact_json(root, receipt_rel) + receipt_status = str(receipt.get("status") or "passed") + validator_cards.append( + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or item.get("id") or "Validator"), + "file": str(item.get("file") or Path(receipt_rel).name or "receipt.json"), + "status": title_status(receipt_status, "Passed"), + "path": dev_pipeline_relative(root / receipt_rel) if receipt_rel else "", + "summary": str(receipt.get("summary") or ""), + } + ) + + evidence_cards = [ + {"id": "pipeline_receipt", "title": "Pipeline Receipt", "file": "pipeline_receipt.json", "status": title_status(read_json_path(root / pipeline_receipt_rel).get("status") or "completed"), "path": dev_pipeline_relative(root / pipeline_receipt_rel)}, + {"id": "events", "title": "Events Log", "file": "events.ndjson", "status": f"{event_total} events", "path": dev_pipeline_relative(root / events_rel)}, + {"id": "evidence_bundle", "title": "Evidence Bundle", "file": "evidence_bundle.json", "status": "Attached", "path": dev_pipeline_relative(root / evidence_bundle_rel)}, + {"id": "budget", "title": "Budget Receipt", "file": "budget_receipt.json", "status": f"${budget_spent:.2f} of ${budget_cap:.2f}", "path": dev_pipeline_relative(root / budget_receipt_rel)}, + {"id": "taskstream", "title": "Taskstream Evidence", "file": "taskstream_evidence.json", "status": "Review", "path": dev_pipeline_relative(root / taskstream_evidence_rel)}, + ] + + return { + "schema_version": "cento.dev_pipeline_studio_state.v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "source_manifest": dev_pipeline_relative(manifest_path), + "root": dev_pipeline_relative(root), + "projects": [ + { + "id": str(item.get("id") or ""), + "label": str(item.get("label") or item.get("id") or ""), + "surface": str(item.get("surface") or ""), + "surface_value": str(item.get("surface_value") or ""), + "owned_root": str(item.get("owned_root") or ""), + "read_paths": [str(value) for value in item.get("read_paths", []) if isinstance(value, str)], + } + for item in projects + ], + "templates": [ + { + "id": str(item.get("id") or ""), + "label": str(item.get("label") or item.get("id") or ""), + "detail": str(item.get("detail") or ""), + "description": str(item.get("description") or ""), + "tagline": str(item.get("tagline") or ""), + "slug": str(item.get("slug") or item.get("id") or ""), + "worker_type": str(item.get("worker_type") or "pipeline_worker"), + "validation_tier": str(item.get("validation_tier") or ""), + "risk": str(item.get("risk") or ""), + "budget_spent_usd": float(item.get("budget_spent_usd", 0) or 0), + "budget_cap_usd": float(item.get("budget_cap_usd", 0) or 0), + "selected_worker": str(item.get("selected_worker") or ""), + "execution_model": str(item.get("execution_model") or ("ordered" if str(item.get("id") or "") == "generic-task" else "parallel")), + "worker_stage_label": str(item.get("worker_stage_label") or ""), + "required_inputs": dev_pipeline_required_inputs(item.get("required_inputs")) or dev_pipeline_default_required_inputs(str(item.get("id") or "")), + } + for item in templates + ], + "selected": {"project_id": str(project.get("id") or ""), "template_id": str(template.get("id") or "")}, + "pipeline": { + "id": str(manifest.get("id") or ""), + "run_name": f"{template.get('slug') or template.get('id')}-{project.get('id')}_{str(manifest.get('active_run_id') or '').rsplit('-', 1)[-1]}", + "status": status_label, + "status_detail": str(manifest.get("status_detail") or ""), + "project": str(project.get("label") or project.get("id") or ""), + "surface": str(project.get("surface") or ""), + "template": str(template.get("label") or template.get("id") or ""), + "template_detail": str(template.get("detail") or ""), + "tasks": f"{tasks_completed} / {tasks_total}", + "task_state": "Validated", + "budget": f"${budget_spent:.2f}", + "budget_detail": f"of ${budget_cap:.2f} budget", + "elapsed": str(manifest.get("elapsed") or ""), + "target": str(manifest.get("target") or ""), + "execution_model": execution_model, + "worker_stage_label": worker_stage_label, + "input_count": input_count_label, + "worker_count": worker_count_label, + "integration_count": f"{len(workers)} integration steps", + "input_cards": [ + { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or ""), + "file": str(item.get("detail") or ""), + "status": title_status(str(item.get("status") or "missing"), "Missing"), + "required": bool(item.get("required", True)), + } + for item in required_inputs + ], + "workers": worker_cards, + "integration": integration_cards, + "validators": validator_cards, + "evidence": evidence_cards, + "validation": { + "status": title_status(validation_status, "Passed"), + "tier": str(template.get("validation_tier") or validation_receipt.get("tier") or ""), + "receipt": dev_pipeline_relative(root / validation_receipt_rel), + "checks": len(validation_receipt.get("commands") or []), + }, + "inspector": { + "selected_worker": str(selected_worker.get("title") or selected_worker_id), + "badge": "W1", + "status": "Completed", + "manifest": worker_manifest, + "manifest_path": dev_pipeline_relative(root / worker_manifest_rel) if worker_manifest_rel else "", + "summary": { + "owned_paths": f"{len(worker_manifest.get('owned_paths') or [])} path", + "read_paths": f"{len(worker_manifest.get('read_paths') or [])} paths", + "dependencies": "None" if not worker_manifest.get("dependencies") else f"{len(worker_manifest.get('dependencies') or [])} dependencies", + "validation_tier": str((worker_manifest.get("validation") or {}).get("tier") or template.get("validation_tier") or ""), + "risk_level": str(template.get("risk") or ""), + }, + }, + }, + "artifacts": [ + dev_pipeline_artifact(root, "pipeline_manifest.json"), + dev_pipeline_artifact(root, str(artifacts.get("workset") or "workset.json")), + dev_pipeline_artifact(root, validation_receipt_rel), + dev_pipeline_artifact(root, evidence_bundle_rel), + dev_pipeline_artifact(root, events_rel), + ], + } + + def factory_run_list() -> dict[str, Any]: root = ROOT_DIR / "workspace" / "runs" / "factory" runs = [] @@ -2085,7 +2688,20 @@ def add_local_attachment(conn: sqlite3.Connection, issue_id: int, payload: dict[ def safe_static_path(raw_path: str) -> Path: route = raw_path.split("?", 1)[0].split("#", 1)[0] - if route in ("", "/") or route in {"/review", "/cluster", "/consulting", "/factory", "/docs", "/research-center"} or route.startswith("/issues/"): + app_routes = { + "/", + "/review", + "/cluster", + "/consulting", + "/factory", + "/docs", + "/research-center", + "/software-delivery-hub", + "/dev-pipeline-studio", + "/codebase-intelligence", + "/issues", + } + if route in ("",) or route in app_routes or route.startswith("/issues/"): route = "/index.html" path = (TEMPLATE_DIR / route.lstrip("/")).resolve() template_root = TEMPLATE_DIR.resolve() @@ -2225,6 +2841,12 @@ def do_GET(self) -> None: if parsed.path == "/api/factory": self.send_json(200, factory_run_list()) return + if parsed.path == "/api/dev-pipeline-studio": + query = parse_qs(parsed.query) + project_id = str((query.get("project") or [""])[0]) + template_id = str((query.get("template") or [""])[0]) + self.send_json(200, dev_pipeline_studio_state(project_id=project_id, template_id=template_id)) + return if parsed.path == "/api/review": with connect(db_path) as conn: init_db(conn) @@ -2274,6 +2896,25 @@ def do_GET(self) -> None: init_db(conn) self.send_json(200, sync_from_agent_work(conn)) return + if parsed.path == "/api/codebase-intelligence": + import codebase_intelligence as ci + self.send_json(200, ci.inventory()) + return + if parsed.path == "/api/codebase-intelligence/graph": + import codebase_intelligence as ci + self.send_json(200, ci.build_graph()) + return + if parsed.path == "/api/codebase-intelligence/inspect": + import codebase_intelligence as ci + query = parse_qs(parsed.query) + file_path = str((query.get("path") or [""])[0]) + if not file_path: + self.send_json(400, {"error": "path query parameter is required"}) + return + result = ci.inspect_file(file_path) + status = 404 if "error" in result and result.get("error") in ("file not found", "path is not a file") else (403 if "error" in result and "outside" in str(result.get("error")) else 200) + self.send_json(status, result) + return path = safe_static_path(self.path) if not path.exists() or not path.is_file(): self.send_json(404, {"error": "Not found"}) @@ -2292,6 +2933,9 @@ def do_POST(self) -> None: parsed = urlparse(self.path) try: payload = self.read_json() + if parsed.path == "/api/dev-pipeline-studio": + self.send_json(200, dev_pipeline_update(payload)) + return with connect(db_path) as conn: init_db(conn) if parsed.path == "/api/issues": diff --git a/scripts/codebase_intelligence.py b/scripts/codebase_intelligence.py new file mode 100644 index 0000000..45f0500 --- /dev/null +++ b/scripts/codebase_intelligence.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +"""Codebase Intelligence: local repository scanner for capability graph, inspector, and health data.""" +from __future__ import annotations + +import ast +import fnmatch +import json +import os +from pathlib import Path +from typing import Any + +ROOT_DIR = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# Capability definitions: each capability groups script patterns into a node +# --------------------------------------------------------------------------- +CAPABILITIES: list[dict[str, Any]] = [ + { + "id": "agent-work", + "label": "Agent Work", + "description": "Taskstream issue tracking, builder/validator workflow, local DB backend", + "color": "#4f8ef7", + "patterns": ["agent_work*.py"], + "kind": "core", + "route": "/", + }, + { + "id": "factory", + "label": "Factory Autopilot", + "description": "Automated factory pipeline, policy matrix, and run dispatch", + "color": "#f7a04f", + "patterns": ["factory_autopilot*.py", "factory_dispatch*.py", "factory_console*.py"], + "kind": "core", + "route": "/factory", + }, + { + "id": "cluster", + "label": "Cluster & Industrial", + "description": "Cluster job dispatch, industrial actions, focus, and panel", + "color": "#4ff7a0", + "patterns": ["cluster_*.py", "industrial_*.py"], + "kind": "infrastructure", + "route": "/cluster", + }, + { + "id": "cento-cli", + "label": "Cento CLI", + "description": "Unified CLI facade, interactive shell, run-mode, build, workset, runtime", + "color": "#a04ff7", + "patterns": ["cento_*.py"], + "kind": "tooling", + "route": None, + }, + { + "id": "storage", + "label": "Storage", + "description": "Storage policies and data persistence layer", + "color": "#f74f4f", + "patterns": ["storage*.py"], + "kind": "infrastructure", + "route": None, + }, + { + "id": "mcp", + "label": "MCP Server", + "description": "Model Context Protocol server and tooling integration", + "color": "#f7e04f", + "patterns": ["*mcp*.py"], + "kind": "tooling", + "route": None, + }, + { + "id": "docs", + "label": "Docs", + "description": "Documentation rendering, browsing, and delivery hub", + "color": "#4fd4f7", + "patterns": ["docs_*.py", "deliverables_hub.py"], + "kind": "ui", + "route": "/docs", + }, + { + "id": "research", + "label": "Research Center", + "description": "Research map and context gathering", + "color": "#f74fa0", + "patterns": ["research_*.py", "gather_*.py"], + "kind": "ui", + "route": "/research-center", + }, + { + "id": "consulting", + "label": "Consulting & Funnel", + "description": "Funnel checks, CRM module, and scan one-pager", + "color": "#a0f74f", + "patterns": ["funnel_*.py", "crm_*.py", "scan_*.py"], + "kind": "business", + "route": "/consulting", + }, + { + "id": "validation", + "label": "Validation", + "description": "Story manifest validation, contract checks, and validator tiers", + "color": "#4f4ff7", + "patterns": [ + "*validation*.py", + "*validate*.py", + "*contract_check*.py", + "story_*.py", + "validator_*.py", + "manifest_*.py", + "no_model_*.py", + ], + "kind": "quality", + "route": None, + }, + { + "id": "agent-manager", + "label": "Agent Manager", + "description": "Agent pool coordination, manager, and coordinator", + "color": "#e04ff7", + "patterns": ["agent_manager*.py", "agent_coordinator*.py", "agent_pool*.py"], + "kind": "core", + "route": None, + }, + { + "id": "platform", + "label": "Platform & Tools", + "description": "Platform reporting, tool index, network server, dashboard", + "color": "#f7c04f", + "patterns": [ + "platform_*.py", + "tool_index.py", + "network_*.py", + "dashboard_*.py", + "jobs_server.py", + "idea_board_server.py", + "bluetooth_*.py", + ], + "kind": "tooling", + "route": "/dev-pipeline-studio", + }, +] + +# Built-in cross-capability dependency hints (supplement import scanning) +CAPABILITY_DEPS: list[tuple[str, str]] = [ + ("factory", "agent-work"), + ("cluster", "agent-work"), + ("docs", "agent-work"), + ("validation", "agent-work"), + ("validation", "factory"), + ("agent-manager", "agent-work"), + ("cento-cli", "factory"), + ("cento-cli", "storage"), +] + +# Known HTTP route groups +ROUTE_GROUPS: list[dict[str, Any]] = [ + {"prefix": "/health", "methods": ["GET"], "module": "agent-work", "description": "App health check"}, + {"prefix": "/api/issues", "methods": ["GET", "POST", "PATCH"], "module": "agent-work", "description": "Issue CRUD"}, + {"prefix": "/api/runs", "methods": ["GET"], "module": "agent-work", "description": "Agent-work run list"}, + {"prefix": "/api/review", "methods": ["GET", "POST"], "module": "agent-work", "description": "Validator review queue and decisions"}, + {"prefix": "/api/factory", "methods": ["GET"], "module": "factory", "description": "Factory pipeline runs"}, + {"prefix": "/api/sync", "methods": ["GET"], "module": "agent-work", "description": "Sync from agent-work backend"}, + {"prefix": "/api/projects", "methods": ["GET"], "module": "agent-work", "description": "Project reference list"}, + {"prefix": "/api/trackers", "methods": ["GET"], "module": "agent-work", "description": "Tracker reference list"}, + {"prefix": "/api/statuses", "methods": ["GET"], "module": "agent-work", "description": "Status reference list"}, + {"prefix": "/api/artifacts", "methods": ["GET"], "module": "agent-work", "description": "Serve local artifact files"}, + {"prefix": "/api/queries", "methods": ["GET", "POST"], "module": "agent-work", "description": "Saved queries"}, + {"prefix": "/api/codebase-intelligence", "methods": ["GET"], "module": "codebase-intelligence", "description": "Codebase Intelligence inventory and graph"}, + {"prefix": "/api/codebase-intelligence/graph", "methods": ["GET"], "module": "codebase-intelligence", "description": "Capability graph nodes and edges"}, + {"prefix": "/api/codebase-intelligence/inspect", "methods": ["GET"], "module": "codebase-intelligence", "description": "File inspector details"}, +] + +# Known data stores +DATASTORES: list[dict[str, str]] = [ + {"id": "sqlite-agent-work", "label": "Agent Work SQLite DB", "kind": "sqlite", "path": "~/.local/state/cento/agent-work-app.sqlite3", "used_by": "agent-work"}, + {"id": "tools-registry", "label": "Tools Registry", "kind": "json-file", "path": "data/tools.json", "used_by": "platform"}, + {"id": "cento-cli-registry", "label": "CLI Commands Registry", "kind": "json-file", "path": "data/cento-cli.json", "used_by": "cento-cli"}, + {"id": "agent-runtimes", "label": "Agent Runtimes", "kind": "json-file", "path": "data/agent-runtimes.json", "used_by": "cluster"}, + {"id": "storage-policies", "label": "Storage Policies", "kind": "json-file", "path": "data/storage-policies.json", "used_by": "storage"}, + {"id": "industrial-actions", "label": "Industrial Actions", "kind": "json-file", "path": "data/industrial-actions.json", "used_by": "cluster"}, + {"id": "runtimes-yaml", "label": "Runtime Config", "kind": "yaml-file", "path": ".cento/runtimes.yaml", "used_by": "cento-cli"}, + {"id": "api-workers-yaml", "label": "API Workers", "kind": "yaml-file", "path": ".cento/api_workers.yaml", "used_by": "cento-cli"}, +] + + +# --------------------------------------------------------------------------- +# File scanning +# --------------------------------------------------------------------------- + +def _scripts_dir() -> Path: + return ROOT_DIR / "scripts" + + +def _file_matches(name: str, patterns: list[str]) -> bool: + return any(fnmatch.fnmatch(name, p) for p in patterns) + + +def _capability_for_file(name: str) -> str | None: + for cap in CAPABILITIES: + if _file_matches(name, cap["patterns"]): + return cap["id"] + return None + + +def _count_lines(path: Path) -> int: + try: + return sum(1 for _ in path.open("r", errors="replace")) + except OSError: + return 0 + + +def _parse_ast(path: Path) -> ast.Module | None: + try: + src = path.read_text(errors="replace") + return ast.parse(src, filename=str(path)) + except SyntaxError: + return None + + +def _extract_imports(tree: ast.Module) -> list[str]: + """Return local module names imported (stdlib/third-party filtered out).""" + stdlib_prefixes = { + "os", "sys", "re", "json", "ast", "abc", "io", "math", "time", + "datetime", "collections", "itertools", "functools", "typing", + "pathlib", "subprocess", "threading", "socket", "signal", + "sqlite3", "hashlib", "uuid", "base64", "shutil", "shlex", + "argparse", "textwrap", "fnmatch", "mimetypes", "tempfile", + "http", "urllib", "webbrowser", "logging", "copy", "enum", + "dataclasses", "contextlib", "traceback", "inspect", "platform", + "struct", "array", "queue", "weakref", "gc", "string", + "__future__", + } + local_imports: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + top = alias.name.split(".")[0] + if top not in stdlib_prefixes: + local_imports.append(alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module: + top = node.module.split(".")[0] + if top not in stdlib_prefixes: + local_imports.append(node.module) + return sorted(set(local_imports)) + + +def _extract_functions(tree: ast.Module) -> list[str]: + return [ + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and not node.name.startswith("_") + ] + + +def _extract_classes(tree: ast.Module) -> list[str]: + return [node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)] + + +def _module_docstring(tree: ast.Module) -> str: + val = ast.get_docstring(tree) or "" + return val.splitlines()[0] if val else "" + + +def scan_scripts() -> list[dict[str, Any]]: + """Return metadata for every .py file under scripts/.""" + scripts = _scripts_dir() + result: list[dict[str, Any]] = [] + for path in sorted(scripts.glob("*.py")): + name = path.name + rel = str(path.relative_to(ROOT_DIR)) + tree = _parse_ast(path) + imports = _extract_imports(tree) if tree else [] + functions = _extract_functions(tree) if tree else [] + classes = _extract_classes(tree) if tree else [] + docstring = _module_docstring(tree) if tree else "" + lines = _count_lines(path) + cap = _capability_for_file(name) + result.append({ + "name": name, + "path": rel, + "capability": cap, + "lines": lines, + "functions": len(functions), + "classes": len(classes), + "imports": imports, + "docstring": docstring, + }) + return result + + +# --------------------------------------------------------------------------- +# Capability graph +# --------------------------------------------------------------------------- + +def build_graph() -> dict[str, Any]: + """Build nodes and edges from live file scan + known hints.""" + files = scan_scripts() + + # Aggregate metrics per capability + cap_files: dict[str, list[dict[str, Any]]] = {c["id"]: [] for c in CAPABILITIES} + unassigned: list[dict[str, Any]] = [] + for f in files: + if f["capability"] and f["capability"] in cap_files: + cap_files[f["capability"]].append(f) + else: + unassigned.append(f) + + # Build nodes + nodes: list[dict[str, Any]] = [] + for cap in CAPABILITIES: + cid = cap["id"] + flist = cap_files[cid] + total_lines = sum(f["lines"] for f in flist) + total_fn = sum(f["functions"] for f in flist) + nodes.append({ + "id": cid, + "label": cap["label"], + "description": cap["description"], + "color": cap["color"], + "kind": cap["kind"], + "route": cap.get("route"), + "file_count": len(flist), + "total_lines": total_lines, + "total_functions": total_fn, + "files": [f["path"] for f in flist], + }) + + # Build edges from import scanning + # Map script module stem -> capability id + stem_to_cap: dict[str, str] = {} + for f in files: + if f["capability"]: + stem = Path(f["name"]).stem + stem_to_cap[stem] = f["capability"] + + edge_set: set[tuple[str, str]] = set() + for f in files: + if not f["capability"]: + continue + src_cap = f["capability"] + for imp in f["imports"]: + imp_stem = imp.split(".")[0] + dst_cap = stem_to_cap.get(imp_stem) + if dst_cap and dst_cap != src_cap: + edge_set.add((src_cap, dst_cap)) + + # Add built-in hints not already covered + for src, dst in CAPABILITY_DEPS: + edge_set.add((src, dst)) + + edges: list[dict[str, str]] = [{"source": s, "target": t} for s, t in sorted(edge_set)] + + return { + "nodes": nodes, + "edges": edges, + "unassigned_files": [f["path"] for f in unassigned], + "total_scripts": len(files), + } + + +# --------------------------------------------------------------------------- +# File inspector +# --------------------------------------------------------------------------- + +def inspect_file(rel_path: str) -> dict[str, Any]: + """Return inspector payload for a repository file.""" + # Sanitize path — must stay inside ROOT_DIR + candidate = (ROOT_DIR / rel_path).resolve() + if ROOT_DIR.resolve() not in candidate.parents and candidate != ROOT_DIR.resolve(): + return {"error": "path is outside repository", "path": rel_path} + if not candidate.exists(): + return {"error": "file not found", "path": rel_path} + if not candidate.is_file(): + return {"error": "path is not a file", "path": rel_path} + + lines = _count_lines(candidate) + result: dict[str, Any] = { + "path": rel_path, + "lines": lines, + "size_bytes": candidate.stat().st_size, + "extension": candidate.suffix, + } + + if candidate.suffix == ".py": + tree = _parse_ast(candidate) + if tree: + imports = _extract_imports(tree) + functions = _extract_functions(tree) + classes = _extract_classes(tree) + docstring = _module_docstring(tree) + cap = _capability_for_file(candidate.name) + result.update({ + "docstring": docstring, + "capability": cap, + "imports": imports, + "public_functions": functions, + "classes": classes, + "function_count": len(functions), + "class_count": len(classes), + "import_count": len(imports), + "health": _health_score(lines, functions, docstring), + }) + else: + result["parse_error"] = True + elif candidate.suffix == ".json": + try: + data = json.loads(candidate.read_text(errors="replace")) + result["json_keys"] = list(data.keys()) if isinstance(data, dict) else None + result["json_items"] = len(data) if isinstance(data, (dict, list)) else None + except (json.JSONDecodeError, OSError): + result["json_parse_error"] = True + + return result + + +def _health_score(lines: int, functions: list[str], docstring: str) -> dict[str, Any]: + """Simple heuristic health score for a Python file.""" + score = 100 + issues: list[str] = [] + if lines > 2000: + score -= 20 + issues.append("large file (>2000 lines)") + elif lines > 1000: + score -= 10 + issues.append("large file (>1000 lines)") + if not docstring: + score -= 10 + issues.append("no module docstring") + if not functions: + score -= 5 + issues.append("no public functions") + return {"score": max(0, score), "issues": issues} + + +# --------------------------------------------------------------------------- +# Health summary +# --------------------------------------------------------------------------- + +def health_summary() -> dict[str, Any]: + """Aggregate health metrics across all scripts.""" + files = scan_scripts() + total = len(files) + with_docstring = sum(1 for f in files if f.get("docstring")) + large_files = [f["path"] for f in files if f["lines"] > 1000] + total_lines = sum(f["lines"] for f in files) + total_functions = sum(f["functions"] for f in files) + uncategorized = [f["path"] for f in files if not f["capability"]] + + tests_dir = ROOT_DIR / "tests" + test_files = list(tests_dir.glob("test_*.py")) if tests_dir.exists() else [] + + return { + "script_count": total, + "total_lines": total_lines, + "total_functions": total_functions, + "test_file_count": len(test_files), + "with_docstring_pct": round(with_docstring / total * 100) if total else 0, + "large_files": large_files, + "uncategorized_files": uncategorized, + "data_file_count": len([p for p in (ROOT_DIR / "data").glob("*.json") if p.is_file()]) if (ROOT_DIR / "data").exists() else 0, + "doc_count": len(list((ROOT_DIR / "docs").glob("*.md"))) if (ROOT_DIR / "docs").exists() else 0, + } + + +# --------------------------------------------------------------------------- +# Full inventory (page payload) +# --------------------------------------------------------------------------- + +def inventory() -> dict[str, Any]: + """Full Codebase Intelligence page payload.""" + graph = build_graph() + health = health_summary() + return { + "graph": graph, + "health": health, + "routes": ROUTE_GROUPS, + "datastores": DATASTORES, + "capabilities": [ + { + "id": c["id"], + "label": c["label"], + "description": c["description"], + "kind": c["kind"], + "route": c.get("route"), + "color": c["color"], + } + for c in CAPABILITIES + ], + } diff --git a/templates/agent-work-app/app.js b/templates/agent-work-app/app.js index 034313f..29cabbd 100644 --- a/templates/agent-work-app/app.js +++ b/templates/agent-work-app/app.js @@ -36,11 +36,43 @@ const docsHashLinks = document.querySelectorAll(".docsSidebar nav a[href^='#'], const agentSummary = document.querySelector("#agentSummary"); const agentCards = document.querySelector("#agentCards"); const taskstreamNav = document.querySelector(".taskstreamNav"); +const homeView = document.querySelector("#homeView"); +const softwareDeliveryHubView = document.querySelector("#softwareDeliveryHubView"); +const devPipelineStudioView = document.querySelector("#devPipelineStudioView, .devPipelineStudioView"); +const sdHubRailLinks = document.querySelectorAll("[data-sd-hub-route]"); +const pipelineProjectSelect = document.querySelector("#pipelineProjectSelect"); +const pipelineTemplateSelect = document.querySelector("#pipelineTemplateSelect"); +const pipelineSurfaceSelect = document.querySelector("#pipelineSurfaceSelect"); +const pipelineTemplateLibrary = document.querySelector(".pipelineTemplateLibrary"); +let pipelineTemplateCards = document.querySelectorAll("[data-template-card]"); +const pipelineManifestCode = document.querySelector("#pipelineManifestCode"); +const pipelineManifestEditor = document.querySelector("#pipelineManifestEditor"); +const pipelineManifestStatus = document.querySelector("#pipelineManifestStatus"); +const pipelineFormatManifestButton = document.querySelector("#pipelineFormatManifestButton"); +const pipelineNewTemplateButton = document.querySelector("#pipelineNewTemplateButton"); +const pipelineDuplicateButton = document.querySelector("#pipelineDuplicateButton"); +const pipelineSaveDraftButton = document.querySelector("#pipelineSaveDraftButton"); +const pipelineSaveStatus = document.querySelector("#pipelineSaveStatus"); +const pipelineProjectLabelInput = document.querySelector("#pipelineProjectLabelInput"); +const pipelineTemplateLabelInput = document.querySelector("#pipelineTemplateLabelInput"); +const pipelineTemplateDetailInput = document.querySelector("#pipelineTemplateDetailInput"); +const pipelineExecutionModelSelect = document.querySelector("#pipelineExecutionModelSelect"); +const pipelineValidationTierInput = document.querySelector("#pipelineValidationTierInput"); +const pipelineRiskSelect = document.querySelector("#pipelineRiskSelect"); +const pipelineBudgetCapInput = document.querySelector("#pipelineBudgetCapInput"); +const pipelineReadPathsInput = document.querySelector("#pipelineReadPathsInput"); +const pipelineRequiredInputsEditor = document.querySelector("#pipelineRequiredInputsEditor"); +const pipelineAddInputButton = document.querySelector("#pipelineAddInputButton"); const clusterView = document.querySelector("#clusterView"); const consultingView = document.querySelector("#consultingView"); const factoryView = document.querySelector("#factoryView"); const docsView = document.querySelector("#docsView"); const researchView = document.querySelector("#researchView"); +const codebaseIntelligenceView = document.querySelector("#codebaseIntelligenceView"); +const researchRailLinks = document.querySelectorAll("[data-research-route]"); +const ciGraphMount = document.querySelector("#ciGraphMount"); +const ciInspectorMount = document.querySelector("#ciInspectorMount"); +const ciAskMount = document.querySelector("#ciAskMount"); const factoryRunList = document.querySelector("#factoryRunList"); const factoryRunCount = document.querySelector("#factoryRunCount"); const factoryDeliveredCount = document.querySelector("#factoryDeliveredCount"); @@ -136,6 +168,8 @@ let detailPayload = null; let detailIssueId = null; let detailLoadingId = null; let loadedIssueMode = "create"; +let codebaseIntelligenceInitialized = false; +let codebaseIntelligencePayload = null; function clampInt(value, fallback, minValue = 1) { const parsed = Number.parseInt(value, 10); @@ -430,7 +464,7 @@ function setLocationFromState() { if (perPage !== 25) params.set("per_page", String(perPage)); const suffix = params.toString() ? `?${params.toString()}` : ""; - history.replaceState(null, "", `${location.pathname.startsWith("/issues/") ? "/" : location.pathname}${suffix}`); + history.replaceState(null, "", `${location.pathname.startsWith("/issues/") ? "/issues" : location.pathname}${suffix}`); } function syncStateFromLocation() { @@ -852,9 +886,716 @@ function currentIssueIdFromDetail() { return match ? Number.parseInt(match[1], 10) : null; } +let pipelineStudioProjects = { + "generic-easy-medium-task": { + key: "generic-easy-medium-task", + name: "Generic Easy-Medium Task", + surface: "Cento code task", + surfaceValue: "generic-task", + ownedRoot: "workspace/runs/generic-task/outputs", + readPaths: ["AGENTS.md", "README.md", "scripts/**", "templates/agent-work-app/**", "tests/**"] + }, + "kanji-a-day": { + key: "kanji-a-day", + name: "Kanji a Day", + surface: "Docs app page", + surfaceValue: "docs-app-page", + ownedRoot: "docs/apps/kanji-a-day/sections", + readPaths: ["docs/templates/**", "docs/apps/kanji-a-day/page.config.json"] + }, + "cento-console-docs": { + key: "cento-console-docs", + name: "Cento Console Docs", + surface: "Console documentation page", + surfaceValue: "console-doc-page", + ownedRoot: "docs/console/sections", + readPaths: ["docs/templates/**", "templates/agent-work-app/index.html"] + }, + "consulting-crm": { + key: "consulting-crm", + name: "Consulting CRM", + surface: "CRM app page", + surfaceValue: "crm-page", + ownedRoot: "templates/crm/pages", + readPaths: ["templates/crm/**", "workspace/runs/crm-app/latest.json"] + } +}; + +let pipelineStudioTemplates = { + "generic-task": { + id: "generic-task", + label: "Generic easy-medium task", + detail: "Standard Cento task template", + slug: "generic-task", + workerType: "generic_task_worker", + validationTier: "smoke-plus", + risk: "medium", + tasks: "9 / 9", + budget: "$1.42", + budgetDetail: "of $3.00 budget", + selectedIndex: 0, + workers: [ + { id: "scope", title: "Scope & Acceptance Worker", file: "scope.json", description: "Convert request into acceptance criteria and owned paths" }, + { id: "context", title: "Context Discovery Worker", file: "context.json", description: "Inspect existing code, docs, tests, and conventions" }, + { id: "plan", title: "Change Plan Worker", file: "plan.json", description: "Create implementation and validation plan" }, + { id: "implementation", title: "Implementation Worker", file: "implementation.json", description: "Make the scoped change" }, + { id: "validation", title: "Focused Validation Worker", file: "validation.json", description: "Run checks, smoke tests, and focused tests" }, + { id: "handoff", title: "Handoff Evidence Worker", file: "handoff.json", description: "Prepare screenshots and final notes" } + ] + }, + "doc-page": { + id: "doc-page", + label: "Doc page creation", + detail: "Reusable web docs template", + slug: "doc-page", + workerType: "doc_page_worker", + validationTier: "smoke", + risk: "low", + tasks: "8 / 8", + budget: "$2.42", + budgetDetail: "of $5.00 budget", + selectedIndex: 0, + workers: [ + { id: "hero", title: "Hero Section Worker", file: "hero.json", description: "Generate hero section" }, + { id: "sections", title: "Body Sections Worker", file: "sections.json", description: "Generate body section structure" }, + { id: "metadata", title: "Metadata Worker", file: "metadata.json", description: "Generate metadata and navigation" }, + { id: "release", title: "Release Notes Worker", file: "release.json", description: "Generate release notes" }, + { id: "operations", title: "Operations Worker", file: "operations.json", description: "Generate operational details" }, + { id: "links", title: "Links Worker", file: "links.json", description: "Generate links and references" } + ] + }, + "dashboard-module": { + id: "dashboard-module", + label: "Dashboard module", + detail: "Operational console template", + slug: "dashboard-module", + workerType: "dashboard_module_worker", + validationTier: "screenshot", + risk: "medium", + tasks: "10 / 10", + budget: "$3.18", + budgetDetail: "of $6.50 budget", + selectedIndex: 1, + workers: [ + { id: "metrics", title: "Metric Model Worker", file: "metrics.json", description: "Define metric contracts" }, + { id: "panels", title: "Panel Layout Worker", file: "panels.json", description: "Generate dashboard panel layout" }, + { id: "actions", title: "Action Controls Worker", file: "actions.json", description: "Generate action controls" }, + { id: "adapter", title: "Data Adapter Worker", file: "adapter.json", description: "Define data adapter bindings" }, + { id: "empty-states", title: "Empty States Worker", file: "empty_states.json", description: "Generate loading and empty states" }, + { id: "screenshot", title: "Screenshot Worker", file: "screenshot.json", description: "Capture dashboard validation screenshot" } + ] + }, + "release-page": { + id: "release-page", + label: "Release evidence page", + detail: "Evidence and compliance template", + slug: "release-evidence", + workerType: "release_evidence_worker", + validationTier: "review", + risk: "medium", + tasks: "9 / 9", + budget: "$2.86", + budgetDetail: "of $5.50 budget", + selectedIndex: 2, + workers: [ + { id: "summary", title: "Change Summary Worker", file: "summary.json", description: "Generate release change summary" }, + { id: "artifacts", title: "Artifact Index Worker", file: "artifacts.json", description: "Generate artifact index" }, + { id: "approvals", title: "Approval Gate Worker", file: "approvals.json", description: "Generate approval gates" }, + { id: "cost", title: "Cost Receipt Worker", file: "cost.json", description: "Generate cost receipt" }, + { id: "risk", title: "Risk Notes Worker", file: "risk.json", description: "Generate risk notes" }, + { id: "audit", title: "Audit Trail Worker", file: "audit.json", description: "Generate audit trail" } + ] + } +}; + +let pipelineStudioControlsInitialized = false; +let pipelineStudioState = null; +let pipelineStudioOptionsReady = false; + +function setPipelineField(name, value) { + document.querySelectorAll(`[data-pipeline-field="${name}"]`).forEach((element) => { + element.textContent = value; + }); +} + +function updateIndexedPipelineText(attribute, values) { + document.querySelectorAll(`[${attribute}]`).forEach((element) => { + const index = Number.parseInt(element.getAttribute(attribute) || "0", 10); + element.textContent = values[index] || ""; + }); +} + +function selectedPipelineStudioProject() { + return pipelineStudioProjects[pipelineProjectSelect?.value || "generic-easy-medium-task"] || pipelineStudioProjects["generic-easy-medium-task"]; +} + +function selectedPipelineStudioTemplate() { + return pipelineStudioTemplates[pipelineTemplateSelect?.value || "generic-task"] || pipelineStudioTemplates["generic-task"]; +} + +function optionMarkup(items, labelKey = "label") { + return items + .map((item) => ``) + .join(""); +} + +function renderPipelineTemplateCards(templates, selectedTemplateId) { + if (!pipelineTemplateLibrary || !templates.length) return; + pipelineTemplateLibrary.innerHTML = templates + .map((template) => { + const isActive = template.id === selectedTemplateId; + return ` + + `; + }) + .join(""); + pipelineTemplateCards = document.querySelectorAll("[data-template-card]"); +} + +function normalizePipelineState(payload) { + if (!payload || !payload.pipeline) return; + const projects = Array.isArray(payload.projects) ? payload.projects : []; + const templates = Array.isArray(payload.templates) ? payload.templates : []; + if (projects.length) { + pipelineStudioProjects = Object.fromEntries(projects.map((project) => [ + project.id, + { + key: project.id, + name: project.label || project.id, + surface: project.surface || "", + surfaceValue: project.surface_value || "", + ownedRoot: project.owned_root || "", + readPaths: Array.isArray(project.read_paths) ? project.read_paths : [] + } + ])); + if (pipelineProjectSelect) { + pipelineProjectSelect.innerHTML = optionMarkup(projects); + } + } + if (templates.length) { + pipelineStudioTemplates = Object.fromEntries(templates.map((template) => [ + template.id, + { + id: template.id, + label: template.label || template.id, + detail: template.detail || "", + slug: template.id, + workerType: template.worker_type || "pipeline_worker", + validationTier: template.validation_tier || payload.pipeline.validation?.tier || "", + risk: template.risk || payload.pipeline.inspector?.summary?.risk_level || "", + tasks: payload.pipeline.tasks || "", + budget: payload.pipeline.budget || "", + budgetDetail: payload.pipeline.budget_detail || "", + budgetSpentUsd: Number(template.budget_spent_usd || 0), + budgetCapUsd: Number(template.budget_cap_usd || 0), + executionModel: template.execution_model || payload.pipeline.execution_model || "", + workerStageLabel: template.worker_stage_label || payload.pipeline.worker_stage_label || "", + selectedWorker: template.selected_worker || "", + requiredInputs: Array.isArray(template.required_inputs) ? template.required_inputs : [], + selectedIndex: 0, + workers: payload.pipeline.workers || [] + } + ])); + } + pipelineStudioState = payload; + pipelineStudioOptionsReady = true; + if (pipelineProjectSelect) pipelineProjectSelect.value = payload.selected?.project_id || pipelineProjectSelect.value; + if (pipelineTemplateSelect) { + if (templates.length) { + pipelineTemplateSelect.innerHTML = optionMarkup(templates); + } + pipelineTemplateSelect.value = payload.selected?.template_id || pipelineTemplateSelect.value; + } + if (pipelineSurfaceSelect) { + const selectedProject = projects.find((project) => project.id === (payload.selected?.project_id || "")); + if (selectedProject?.surface_value) { + if (!Array.from(pipelineSurfaceSelect.options).some((option) => option.value === selectedProject.surface_value)) { + pipelineSurfaceSelect.add(new Option(selectedProject.surface || selectedProject.surface_value, selectedProject.surface_value)); + } + pipelineSurfaceSelect.value = selectedProject.surface_value; + } + } + renderPipelineTemplateCards(templates, payload.selected?.template_id || ""); +} + +function renderPipelineCards(attributeBase, items, keys) { + keys.forEach(([suffix, key]) => { + updateIndexedPipelineText(`${attributeBase}-${suffix}`, items.map((item) => item[key] || "")); + }); +} + +function selectedPipelinePayloadProject() { + const projectId = pipelineStudioState?.selected?.project_id || pipelineProjectSelect?.value || ""; + return (pipelineStudioState?.projects || []).find((project) => project.id === projectId) || null; +} + +function selectedPipelinePayloadTemplate() { + const templateId = pipelineStudioState?.selected?.template_id || pipelineTemplateSelect?.value || ""; + return (pipelineStudioState?.templates || []).find((template) => template.id === templateId) || null; +} + +function setPipelineSaveStatus(message, isError = false) { + if (pipelineSaveStatus) { + pipelineSaveStatus.textContent = message; + pipelineSaveStatus.classList.toggle("error", isError); + } +} + +function setPipelineManifestStatus(message, isError = false) { + if (pipelineManifestStatus) { + pipelineManifestStatus.textContent = message; + pipelineManifestStatus.classList.toggle("error", isError); + } +} + +function pipelineInputStatusOptions(selected = "missing") { + return ["provided", "configured", "missing", "optional"] + .map((status) => ``) + .join(""); +} + +function renderPipelineRequiredInputsEditor(inputs) { + if (!pipelineRequiredInputsEditor) return; + const rows = Array.isArray(inputs) && inputs.length ? inputs : [ + { id: "task-request", title: "Task request", detail: "What should the AI change or build?", status: "missing", required: true } + ]; + pipelineRequiredInputsEditor.innerHTML = rows + .map((item) => ` +
+ + + + + +
+ `) + .join(""); +} + +function collectPipelineRequiredInputs() { + if (!pipelineRequiredInputsEditor) return []; + return Array.from(pipelineRequiredInputsEditor.querySelectorAll("[data-input-row]")) + .map((row, index) => { + const title = row.querySelector("[data-input-title]")?.value?.trim() || ""; + const detail = row.querySelector("[data-input-detail]")?.value?.trim() || ""; + const status = row.querySelector("[data-input-status]")?.value || "missing"; + const required = Boolean(row.querySelector("[data-input-required]")?.checked); + return { + id: title ? title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || `input-${index + 1}` : `input-${index + 1}`, + title, + detail, + status, + required + }; + }) + .filter((item) => item.title); +} + +function renderPipelineInputCards(items) { + const inputStage = document.querySelector(".stageInput"); + if (!inputStage) return; + inputStage.querySelectorAll(".pipelineCard").forEach((card) => card.remove()); + const button = inputStage.querySelector("button"); + (items || []).forEach((item) => { + const card = document.createElement("div"); + const status = String(item.status || "Missing").toLowerCase(); + card.className = `pipelineCard operatorInput ${status}`; + card.innerHTML = ` + + ${escapeHtml(item.title || "")} + ${escapeHtml(item.file || item.detail || "")} + ${escapeHtml(item.status || "Missing")} + `; + inputStage.insertBefore(card, button || null); + }); + if (button) button.textContent = `View all (${(items || []).length})`; +} + +function populatePipelineEditor() { + const project = selectedPipelinePayloadProject(); + const template = selectedPipelinePayloadTemplate(); + const inspector = pipelineStudioState?.pipeline?.inspector || {}; + if (pipelineProjectLabelInput) pipelineProjectLabelInput.value = project?.label || ""; + if (pipelineTemplateLabelInput) pipelineTemplateLabelInput.value = template?.label || ""; + if (pipelineTemplateDetailInput) pipelineTemplateDetailInput.value = template?.detail || ""; + if (pipelineExecutionModelSelect) pipelineExecutionModelSelect.value = template?.execution_model || pipelineStudioState?.pipeline?.execution_model || "ordered"; + if (pipelineValidationTierInput) pipelineValidationTierInput.value = template?.validation_tier || pipelineStudioState?.pipeline?.validation?.tier || ""; + if (pipelineRiskSelect) pipelineRiskSelect.value = template?.risk || inspector.summary?.risk_level || "medium"; + if (pipelineBudgetCapInput) pipelineBudgetCapInput.value = template?.budget_cap_usd ?? ""; + if (pipelineReadPathsInput) pipelineReadPathsInput.value = Array.isArray(project?.read_paths) ? project.read_paths.join("\n") : ""; + renderPipelineRequiredInputsEditor(template?.required_inputs || []); + const manifestText = JSON.stringify(inspector.manifest || {}, null, 2); + if (pipelineManifestEditor) pipelineManifestEditor.value = manifestText; + if (pipelineManifestCode) pipelineManifestCode.textContent = manifestText; + setPipelineSaveStatus("Loaded from pipeline manifest"); + setPipelineManifestStatus(inspector.manifest_path ? `Editing ${inspector.manifest_path}` : "Worker manifest synthesized from template"); +} + +function syncPipelineWorkerCards(workers) { + const cards = document.querySelectorAll(".pipelineCard.worker"); + cards.forEach((card, index) => { + const worker = workers[index]; + if (!worker) { + card.removeAttribute("data-worker-id"); + card.classList.remove("selected"); + return; + } + card.dataset.workerId = worker.id || ""; + card.classList.toggle("selected", Boolean(worker.selected)); + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-pressed", String(Boolean(worker.selected))); + }); +} + +function parsePipelineManifestEditor() { + if (!pipelineManifestEditor) return null; + const raw = pipelineManifestEditor.value.trim(); + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch (error) { + setPipelineManifestStatus(`Invalid worker manifest JSON: ${error.message}`, true); + throw error; + } +} + +function pipelineEditorPayload(action = "save", options = {}) { + const project = selectedPipelinePayloadProject(); + const template = selectedPipelinePayloadTemplate(); + const includeManifest = options.includeManifest !== false; + const workerManifest = includeManifest ? parsePipelineManifestEditor() : null; + const selectedWorker = options.workerId || workerManifest?.task_id || template?.selected_worker || pipelineStudioState?.pipeline?.workers?.find((worker) => worker.selected)?.id || ""; + return { + action, + project_id: pipelineStudioState?.selected?.project_id || pipelineProjectSelect?.value || "", + template_id: pipelineStudioState?.selected?.template_id || pipelineTemplateSelect?.value || "", + worker_id: options.workerId || "", + project: { + label: pipelineProjectLabelInput?.value || project?.label || "", + surface: pipelineSurfaceSelect?.selectedOptions?.[0]?.textContent || project?.surface || "", + surface_value: pipelineSurfaceSelect?.value || project?.surface_value || "", + owned_root: project?.owned_root || "", + read_paths_text: pipelineReadPathsInput?.value || "" + }, + template: { + label: pipelineTemplateLabelInput?.value || template?.label || "", + detail: pipelineTemplateDetailInput?.value || template?.detail || "", + description: template?.description || "", + tagline: template?.tagline || "", + validation_tier: pipelineValidationTierInput?.value || template?.validation_tier || "", + risk: pipelineRiskSelect?.value || template?.risk || "", + budget_spent_usd: template?.budget_spent_usd ?? 0, + budget_cap_usd: pipelineBudgetCapInput?.value || template?.budget_cap_usd || 0, + execution_model: pipelineExecutionModelSelect?.value || template?.execution_model || "ordered", + worker_stage_label: (pipelineExecutionModelSelect?.value || template?.execution_model) === "ordered" ? "2. Task Execution" : "2. Workers (Parallel)", + selected_worker: selectedWorker, + required_inputs: collectPipelineRequiredInputs() + }, + worker_manifest: workerManifest + }; +} + +async function savePipelineDraft(action = "save", options = {}) { + if (!pipelineProjectSelect || !pipelineTemplateSelect) return; + try { + setPipelineSaveStatus(action === "select_worker" ? "Selecting worker..." : "Saving pipeline draft..."); + const response = await fetch(`${API_BASE}/dev-pipeline-studio`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(pipelineEditorPayload(action, options)) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + normalizePipelineState(payload); + applyPipelineStudioContext(); + const verb = action === "duplicate" ? "Duplicated" : action === "new" ? "Created" : action === "select_worker" ? "Selected" : "Saved"; + setPipelineSaveStatus(`${verb} ${payload.selected?.template_id || "pipeline"} at ${new Date().toLocaleTimeString()}`); + } catch (error) { + setPipelineSaveStatus(`Save failed: ${error.message}`, true); + } +} + +function applyPipelineStudioContext() { + if (pipelineStudioState?.pipeline) { + const pipeline = pipelineStudioState.pipeline; + const inspector = pipeline.inspector || {}; + const summary = inspector.summary || {}; + setPipelineField("status", pipeline.status || "Unknown"); + setPipelineField("statusDetail", pipeline.status_detail || ""); + setPipelineField("project", pipeline.project || ""); + setPipelineField("surface", pipeline.surface || ""); + setPipelineField("template", pipeline.template || ""); + setPipelineField("templateDetail", pipeline.template_detail || ""); + setPipelineField("tasks", pipeline.tasks || ""); + setPipelineField("taskState", pipeline.task_state || ""); + setPipelineField("budget", pipeline.budget || ""); + setPipelineField("budgetDetail", pipeline.budget_detail || ""); + setPipelineField("runName", pipeline.run_name || ""); + setPipelineField("inputCount", pipeline.input_count || ""); + setPipelineField("workerStageLabel", pipeline.worker_stage_label || "2. Workers (Parallel)"); + setPipelineField("workerCount", pipeline.worker_count || ""); + setPipelineField("integrationCount", pipeline.integration_count || ""); + setPipelineField("selectedWorker", inspector.selected_worker || ""); + setPipelineField("ownedPathCount", summary.owned_paths || ""); + setPipelineField("readPathCount", summary.read_paths || ""); + setPipelineField("validationTier", summary.validation_tier || pipeline.validation?.tier || ""); + setPipelineField("riskLevel", summary.risk_level || ""); + renderPipelineInputCards(pipeline.input_cards || []); + renderPipelineCards("data-pipeline-input", pipeline.input_cards || [], [["title", "title"], ["file", "file"], ["status", "status"]]); + renderPipelineCards("data-pipeline-worker", pipeline.workers || [], [["title", "title"], ["file", "detail"]]); + renderPipelineCards("data-pipeline-integrate", pipeline.integration || [], [["title", "title"]]); + renderPipelineCards("data-pipeline-validator", pipeline.validators || [], [["title", "title"], ["file", "file"], ["status", "status"]]); + renderPipelineCards("data-pipeline-evidence", pipeline.evidence || [], [["title", "title"], ["file", "file"], ["status", "status"]]); + if (pipelineManifestCode) { + pipelineManifestCode.textContent = JSON.stringify(inspector.manifest || {}, null, 2); + } + populatePipelineEditor(); + syncPipelineWorkerCards(pipeline.workers || []); + pipelineTemplateCards.forEach((card) => { + const isActive = card.dataset.templateCard === (pipelineStudioState.selected?.template_id || ""); + card.classList.toggle("active", isActive); + card.setAttribute("aria-pressed", String(isActive)); + }); + return; + } + if (!pipelineProjectSelect || !pipelineTemplateSelect) return; + const project = selectedPipelineStudioProject(); + const template = selectedPipelineStudioTemplate(); + const selectedWorker = template.workers[template.selectedIndex] || template.workers[0]; + const workerFiles = template.workers.map((worker) => worker.file); + const runName = `${template.slug}-${project.key}_2026-05-02_120501`; + const readPaths = [...project.readPaths, `templates/pipelines/${template.id}.json`]; + const manifest = { + schema_version: "cento.worker_manifest.v1", + id: `${selectedWorker.id}_worker_01`, + project: project.key, + template_id: template.id, + type: template.workerType, + task_id: selectedWorker.id, + description: `${selectedWorker.description} for ${project.name} using the ${template.label} template`, + owned_paths: [`${project.ownedRoot}/${selectedWorker.file}`], + read_paths: readPaths, + dependencies: [], + acceptance: [ + `${template.label} output is valid`, + "Template parameters are preserved", + "Only owned paths changed" + ], + validation: { + tier: template.validationTier + } + }; + + if (pipelineSurfaceSelect) pipelineSurfaceSelect.value = project.surfaceValue; + setPipelineField("project", project.name); + setPipelineField("surface", project.surface); + setPipelineField("template", template.label); + setPipelineField("templateDetail", template.detail); + setPipelineField("tasks", template.tasks); + setPipelineField("taskState", "Template ready"); + setPipelineField("budget", template.budget); + setPipelineField("budgetDetail", template.budgetDetail); + setPipelineField("runName", runName); + setPipelineField("inputCount", `${(template.requiredInputs || []).length} inputs`); + setPipelineField("workerStageLabel", template.workerStageLabel || "2. Workers (Parallel)"); + setPipelineField("workerCount", `${template.workers.length} workers`); + setPipelineField("integrationCount", `${template.workers.length} integration steps`); + setPipelineField("selectedWorker", selectedWorker.title); + setPipelineField("ownedPathCount", "1 path"); + setPipelineField("readPathCount", `${readPaths.length} paths`); + setPipelineField("validationTier", template.validationTier); + setPipelineField("riskLevel", template.risk); + updateIndexedPipelineText("data-pipeline-worker-title", template.workers.map((worker) => worker.title)); + updateIndexedPipelineText("data-pipeline-worker-file", workerFiles); + updateIndexedPipelineText("data-pipeline-integrate-title", workerFiles.map((file) => `Integrate: ${file}`)); + renderPipelineInputCards(template.requiredInputs || []); + if (pipelineManifestCode) { + pipelineManifestCode.textContent = JSON.stringify(manifest, null, 2); + } + if (pipelineManifestEditor) pipelineManifestEditor.value = JSON.stringify(manifest, null, 2); + syncPipelineWorkerCards(template.workers || []); + pipelineTemplateCards.forEach((card) => { + const isActive = card.dataset.templateCard === template.id; + card.classList.toggle("active", isActive); + card.setAttribute("aria-pressed", String(isActive)); + }); +} + +async function loadPipelineStudioState() { + if (!pipelineProjectSelect || !pipelineTemplateSelect) { + applyPipelineStudioContext(); + return; + } + const project = pipelineProjectSelect.value || "generic-easy-medium-task"; + const template = pipelineTemplateSelect.value || "generic-task"; + try { + const payload = await apiGetJson(`${API_BASE}/dev-pipeline-studio?project=${encodeURIComponent(project)}&template=${encodeURIComponent(template)}`); + normalizePipelineState(payload); + applyPipelineStudioContext(); + } catch (error) { + console.warn("Dev Pipeline Studio backend unavailable; using local fallback.", error); + pipelineStudioState = null; + applyPipelineStudioContext(); + } +} + +function initPipelineStudioControls() { + if (pipelineStudioControlsInitialized || !pipelineProjectSelect || !pipelineTemplateSelect) return; + pipelineProjectSelect.addEventListener("change", () => { + void loadPipelineStudioState(); + }); + pipelineTemplateSelect.addEventListener("change", () => { + void loadPipelineStudioState(); + }); + if (pipelineTemplateLibrary) { + pipelineTemplateLibrary.addEventListener("click", (event) => { + const card = event.target.closest("[data-template-card]"); + if (!card || !pipelineTemplateSelect) return; + pipelineTemplateSelect.value = card.dataset.templateCard || "generic-task"; + void loadPipelineStudioState(); + }); + } + if (pipelineSaveDraftButton) { + pipelineSaveDraftButton.addEventListener("click", () => { + void savePipelineDraft("save"); + }); + } + if (pipelineDuplicateButton) { + pipelineDuplicateButton.addEventListener("click", () => { + void savePipelineDraft("duplicate"); + }); + } + if (pipelineNewTemplateButton) { + pipelineNewTemplateButton.addEventListener("click", () => { + void savePipelineDraft("new"); + }); + } + if (pipelineFormatManifestButton) { + pipelineFormatManifestButton.addEventListener("click", () => { + try { + const manifest = parsePipelineManifestEditor(); + if (pipelineManifestEditor) pipelineManifestEditor.value = JSON.stringify(manifest || {}, null, 2); + setPipelineManifestStatus("Worker manifest formatted"); + } catch (error) { + setPipelineManifestStatus(`Format failed: ${error.message}`, true); + } + }); + } + if (pipelineAddInputButton) { + pipelineAddInputButton.addEventListener("click", () => { + const current = collectPipelineRequiredInputs(); + current.push({ id: `input-${current.length + 1}`, title: "New input", detail: "", status: "missing", required: true }); + renderPipelineRequiredInputsEditor(current); + }); + } + if (pipelineRequiredInputsEditor) { + pipelineRequiredInputsEditor.addEventListener("click", (event) => { + const removeButton = event.target.closest("[data-remove-input]"); + if (!removeButton) return; + removeButton.closest("[data-input-row]")?.remove(); + }); + } + document.querySelector(".pipelineStageGrid")?.addEventListener("click", (event) => { + const card = event.target.closest(".pipelineCard.worker"); + const workerId = card?.dataset?.workerId || ""; + if (!workerId) return; + void savePipelineDraft("select_worker", { workerId, includeManifest: false }); + }); + document.querySelector(".pipelineStageGrid")?.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + const card = event.target.closest(".pipelineCard.worker"); + const workerId = card?.dataset?.workerId || ""; + if (!workerId) return; + event.preventDefault(); + void savePipelineDraft("select_worker", { workerId, includeManifest: false }); + }); + pipelineStudioControlsInitialized = true; + void loadPipelineStudioState(); +} + +function setOptionalHidden(element, isHidden) { + if (element) element.classList.toggle("hidden", isHidden); +} + +function hideSoftwareDeliveryViews() { + setOptionalHidden(homeView, true); + setOptionalHidden(softwareDeliveryHubView, true); + setOptionalHidden(devPipelineStudioView, true); +} + +function hideResearchViews() { + setOptionalHidden(researchView, true); + setOptionalHidden(codebaseIntelligenceView, true); +} + +function routeFromLocation() { + if (location.pathname === "/") return "home"; + if (location.pathname === "/software-delivery-hub") return "software-delivery"; + if (location.pathname === "/dev-pipeline-studio") return "dev-pipeline-studio"; + if (location.pathname === "/codebase-intelligence") return "codebase-intelligence"; + if (location.pathname === "/review") return "review"; + if (location.pathname === "/cluster") return "cluster"; + if (location.pathname === "/consulting") return "consulting"; + if (location.pathname === "/factory") return "factory"; + if (location.pathname === "/research-center") return "research"; + if (location.pathname === "/docs") return "docs"; + if (location.pathname === "/issues" || location.pathname.startsWith("/issues/")) return "issues"; + return "home"; +} + +function hasMainRoute(route) { + return Array.from(mainNavLinks).some((link) => link.dataset.mainRoute === route); +} + +function setSdHubRailActive(route) { + const activeRoute = route === "dev-pipeline-studio" || route === "factory" || route === "software-delivery" + ? route + : route === "issues" || route === "review" + ? "issues" + : ""; + sdHubRailLinks.forEach((link) => { + const isActive = link.dataset.sdHubRoute === activeRoute; + link.classList.toggle("active", isActive); + if (isActive) { + link.setAttribute("aria-current", "page"); + } else { + link.removeAttribute("aria-current"); + } + }); +} + +function setResearchRailActive(route) { + const activeRoute = route === "codebase-intelligence" ? "codebase-intelligence" : route === "research" ? "research" : ""; + researchRailLinks.forEach((link) => { + const isActive = link.dataset.researchRoute === activeRoute; + link.classList.toggle("active", isActive); + if (isActive) { + link.setAttribute("aria-current", "page"); + } else { + link.removeAttribute("aria-current"); + } + }); +} + function setNavActive(route) { - const activeRoute = route || (location.pathname.startsWith("/review") ? "review" : "issues"); - const activeMain = ["cluster", "consulting", "factory", "docs", "research"].includes(activeRoute) ? activeRoute : "taskstream"; + const activeRoute = route || routeFromLocation(); + let activeMain = "taskstream"; + if (activeRoute === "home") { + activeMain = homeView ? "home" : "taskstream"; + } else if (activeRoute === "software-delivery" || activeRoute === "factory" || activeRoute === "issues" || activeRoute === "dev-pipeline-studio") { + activeMain = softwareDeliveryHubView ? "software-delivery" : activeRoute === "factory" ? "factory" : "taskstream"; + } else if (activeRoute === "review") { + activeMain = hasMainRoute("review") ? "review" : "taskstream"; + } else if (activeRoute === "codebase-intelligence") { + activeMain = "research"; + } else if (["cluster", "consulting", "docs", "research"].includes(activeRoute)) { + activeMain = activeRoute; + } mainNavLinks.forEach((link) => { link.classList.toggle("active", link.dataset.mainRoute === activeMain); }); @@ -862,22 +1603,36 @@ function setNavActive(route) { link.classList.toggle("active", link.dataset.navRoute === activeRoute); }); if (taskstreamNav) taskstreamNav.classList.toggle("hidden", activeMain !== "taskstream"); + document.body.classList.toggle("homeMode", activeMain === "home"); + document.body.classList.toggle("softwareDeliveryMode", activeMain === "software-delivery"); document.body.classList.toggle("docsMode", activeMain === "docs"); document.body.classList.toggle("researchMode", activeMain === "research"); - if (activeMain !== "docs") document.body.classList.remove("docsAppPage"); + document.body.classList.toggle("codebaseMode", activeRoute === "codebase-intelligence"); + setSdHubRailActive(activeMain === "software-delivery" ? activeRoute : ""); + setResearchRailActive(activeMain === "research" ? activeRoute : ""); + if (activeMain !== "docs") { + document.body.classList.remove("docsAppPage"); + document.body.classList.remove("docsPipelinePage"); + document.body.classList.remove("docsParallelPage"); + } } function syncDocsHashNavigation(options = {}) { if (!docsHashLinks.length) return; const activeHash = location.hash || "#overview"; const isKanjiAppPage = activeHash.startsWith("#kanji"); + const isParallelPage = activeHash.startsWith("#parallel"); document.body.classList.toggle("docsAppPage", isKanjiAppPage); + document.body.classList.toggle("docsParallelPage", isParallelPage); + document.body.classList.remove("docsPipelinePage"); docsHashLinks.forEach((link) => { const href = link.getAttribute("href") || ""; const isSidebarKanjiParent = href === "#kanji-a-day" && link.classList.contains("docsAppParentLink"); - link.classList.toggle("active", href === activeHash && !isSidebarKanjiParent); + const isSidebarParallelParent = href === "#parallel-execution" && link.classList.contains("docsParallelParentLink"); + const isParallelParentActive = isSidebarParallelParent && isParallelPage; + link.classList.toggle("active", (href === activeHash && !isSidebarKanjiParent) || isParallelParentActive); }); - if (options.scrollToHash && isKanjiAppPage) { + if (options.scrollToHash && (isKanjiAppPage || isParallelPage)) { window.requestAnimationFrame(() => { const target = document.querySelector(activeHash); if (target) target.scrollIntoView({ block: "start" }); @@ -1562,13 +2317,14 @@ async function loadReview() { function showReview() { setNavActive("review"); document.body.classList.remove("reviewMode"); + hideSoftwareDeliveryViews(); listView.classList.add("hidden"); detailView.classList.add("hidden"); clusterView.classList.add("hidden"); consultingView.classList.add("hidden"); factoryView.classList.add("hidden"); docsView.classList.add("hidden"); - researchView.classList.add("hidden"); + hideResearchViews(); reviewView.classList.remove("hidden"); history.replaceState(null, "", "/review"); void loadReview().catch((error) => { @@ -1626,13 +2382,15 @@ async function showDetail(issueId) { detailContent.innerHTML = `
Loading issue…
`; historyList.innerHTML = `
Loading history…
`; document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); listView.classList.add("hidden"); reviewView.classList.add("hidden"); clusterView.classList.add("hidden"); consultingView.classList.add("hidden"); factoryView.classList.add("hidden"); docsView.classList.add("hidden"); - researchView.classList.add("hidden"); + hideResearchViews(); detailView.classList.remove("hidden"); history.replaceState(null, "", `/issues/${issueId}`); try { @@ -1646,21 +2404,292 @@ async function showDetail(issueId) { function showList() { setNavActive("issues"); document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); reviewView.classList.add("hidden"); detailView.classList.add("hidden"); clusterView.classList.add("hidden"); consultingView.classList.add("hidden"); factoryView.classList.add("hidden"); docsView.classList.add("hidden"); - researchView.classList.add("hidden"); + hideResearchViews(); listView.classList.remove("hidden"); setLocationFromState(); void withSpinner(loadIssues()); } +function showHome() { + if (!homeView) { + showList(); + return; + } + setNavActive("home"); + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + setOptionalHidden(homeView, false); + setOptionalHidden(softwareDeliveryHubView, true); + setOptionalHidden(devPipelineStudioView, true); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + history.replaceState(null, "", "/"); +} + +function showSoftwareDeliveryHub() { + if (!softwareDeliveryHubView) { + showCentoSection("factory"); + return; + } + if (location.hash === "#dev-pipeline-studio") { + history.replaceState(null, "", "/dev-pipeline-studio"); + showDevPipelineStudio(); + return; + } + setNavActive("software-delivery"); + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + softwareDeliveryHubView.querySelectorAll('.hubSidebar a').forEach((a) => { + a.classList.toggle('active', a.getAttribute('href') === '/software-delivery-hub'); + }); + setOptionalHidden(homeView, true); + setOptionalHidden(softwareDeliveryHubView, false); + setOptionalHidden(devPipelineStudioView, true); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + history.replaceState(null, "", "/software-delivery-hub"); +} + +function showDevPipelineStudio() { + if (!softwareDeliveryHubView || !devPipelineStudioView) { + showSoftwareDeliveryHub(); + return; + } + setNavActive("dev-pipeline-studio"); + document.body.classList.remove("reviewMode"); + document.body.classList.add("studioMode"); + softwareDeliveryHubView.querySelectorAll('.hubSidebar a').forEach((a) => { + a.classList.toggle('active', a.getAttribute('href') === '/dev-pipeline-studio'); + }); + setOptionalHidden(homeView, true); + setOptionalHidden(softwareDeliveryHubView, false); + setOptionalHidden(devPipelineStudioView, false); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + const pipelineControlsAlreadyReady = pipelineStudioControlsInitialized; + initPipelineStudioControls(); + if (pipelineControlsAlreadyReady) void loadPipelineStudioState(); + const hash = location.hash; + history.replaceState(null, "", `/dev-pipeline-studio${hash}`); + if (hash) { + window.requestAnimationFrame(() => { + const target = document.querySelector(hash); + if (target) target.scrollIntoView({ block: "start" }); + }); + } else { + window.scrollTo({ top: 0, left: 0 }); + } +} + +function ciCompactNumber(value) { + const number = Number(value || 0); + if (!Number.isFinite(number)) return "--"; + if (number >= 1000) return `${(number / 1000).toFixed(number >= 10000 ? 0 : 1)}k`; + return String(number); +} + +function setCiText(selector, value) { + document.querySelectorAll(selector).forEach((element) => { + element.textContent = value; + }); +} + +function setCiHealth(name, pct, label) { + const normalized = Math.max(0, Math.min(100, Number(pct || 0))); + document.querySelectorAll(`[data-ci-health="${name}"]`).forEach((element) => { + element.style.width = `${normalized}%`; + }); + setCiText(`[data-ci-health-label="${name}"]`, label || `${normalized}%`); +} + +function renderCodebaseInventory(payload) { + if (!payload) return; + const health = payload.health || {}; + const graph = payload.graph || {}; + const capabilities = Array.isArray(payload.capabilities) ? payload.capabilities : []; + const routes = Array.isArray(payload.routes) ? payload.routes : []; + const datastores = Array.isArray(payload.datastores) ? payload.datastores : []; + const uncategorized = Array.isArray(health.uncategorized_files) ? health.uncategorized_files.length : 0; + const routeHealth = Math.min(100, routes.length * 8); + const testHealth = Math.min(100, (Number(health.test_file_count || 0) / Math.max(1, Number(health.script_count || 1))) * 100); + + setCiText('[data-ci-metric="scriptCount"]', `${ciCompactNumber(health.script_count)} scripts indexed`); + setCiText('[data-ci-metric="capabilityCount"]', ciCompactNumber(capabilities.length || graph.nodes?.length)); + setCiText('[data-ci-metric="routeCount"]', ciCompactNumber(routes.length)); + setCiText('[data-ci-metric="dataFileCount"]', ciCompactNumber(health.data_file_count || datastores.length)); + setCiText('[data-ci-metric="testCount"]', ciCompactNumber(health.test_file_count)); + setCiText('[data-ci-metric="lineCount"]', `${ciCompactNumber(health.total_lines)} lines`); + setCiText("[data-ci-repo-state]", uncategorized ? "Needs map" : "Indexed"); + + const repoState = document.querySelector("[data-ci-repo-state]"); + if (repoState) repoState.classList.toggle("clean", uncategorized === 0); + setCiHealth("docstrings", health.with_docstring_pct || 0); + setCiHealth("tests", testHealth, `${Math.round(testHealth)}%`); + setCiHealth("routes", routeHealth, `${routes.length}`); +} + +function normalizeInspectorPayload(payload) { + if (!payload || payload.error) return null; + const extension = String(payload.extension || "").replace(/^\./, ""); + const language = extension ? extension.toUpperCase() : "File"; + const routes = Array.isArray(codebaseIntelligencePayload?.routes) ? codebaseIntelligencePayload.routes.slice(0, 5) : []; + const apiRoutes = routes.map((route) => ({ + method: Array.isArray(route.methods) ? route.methods[0] : "GET", + path: route.prefix || "/", + label: route.description || route.module || "Route", + })); + const debt = Array.isArray(payload.health?.issues) && payload.health.issues.length + ? payload.health.issues + : ["No high-risk inspector issues detected for this file."]; + return { + path: payload.path, + size_kb: ((Number(payload.size_bytes || 0)) / 1024).toFixed(1), + loc: payload.lines || 0, + modified: "Local workspace", + language, + purpose: payload.docstring || `Repository file inspected by Codebase Intelligence (${payload.path}).`, + api_routes: apiRoutes, + datastore: { + type: "repository", + path: payload.path, + }, + tech_debt: debt, + ai_assistant: { + prompt: "Explain this file and its connected routes", + answer: `This inspector entry summarizes ${payload.path}, including line count, parsed symbols, imports, and local capability mapping.`, + references: [{ path: payload.path, lines: payload.lines || 1 }], + }, + }; +} + +async function loadCodebaseIntelligencePayload() { + try { + codebaseIntelligencePayload = await apiGetJson(`${API_BASE}/codebase-intelligence`); + renderCodebaseInventory(codebaseIntelligencePayload); + } catch (error) { + setCiText('[data-ci-metric="scriptCount"]', "Inventory unavailable"); + setCiText('[data-ci-metric="lineCount"]', error.message); + } +} + +async function initCodebaseIntelligence() { + if (codebaseIntelligenceInitialized) return; + codebaseIntelligenceInitialized = true; + + if (ciGraphMount && window.CodebaseIntelligenceGraph?.init) { + ciGraphMount.innerHTML = ""; + window.CodebaseIntelligenceGraph.init(ciGraphMount); + } + + await loadCodebaseIntelligencePayload(); + + if (window.CIpanels) { + let inspectorPayload = null; + if (window.CIpanels.loadInspectorData) { + inspectorPayload = normalizeInspectorPayload(await window.CIpanels.loadInspectorData("scripts/agent_work_app.py")); + } + window.CIpanels.mountInspectorPanel?.(ciInspectorMount, inspectorPayload || undefined); + window.CIpanels.mountAskCentoPanel?.(ciAskMount, { + context: "Cento repository", + example_prompt: "Which console routes connect to Agent Work and Factory?", + answer: "Codebase Intelligence maps registered API routes, local script capabilities, and data stores so route ownership and dependencies stay visible while working in the Research Center.", + references: [ + { path: "scripts/agent_work_app.py", lines: 2542 }, + { path: "scripts/codebase_intelligence.py", lines: 472 }, + { path: "templates/agent-work-app/app.js", lines: 1 }, + ], + extra_refs: 2, + }); + } +} + +function showResearchCenter() { + setNavActive("research"); + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + setOptionalHidden(codebaseIntelligenceView, true); + setOptionalHidden(researchView, false); + history.replaceState(null, "", `/research-center${location.hash || ""}`); + if (location.hash) { + window.requestAnimationFrame(() => { + const target = document.querySelector(location.hash); + if (target) target.scrollIntoView({ block: "start" }); + }); + } +} + +function showCodebaseIntelligence() { + if (!codebaseIntelligenceView) { + showResearchCenter(); + return; + } + setNavActive("codebase-intelligence"); + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + setOptionalHidden(researchView, true); + setOptionalHidden(codebaseIntelligenceView, false); + history.replaceState(null, "", "/codebase-intelligence"); + window.scrollTo({ top: 0, left: 0 }); + void initCodebaseIntelligence().catch((error) => { + if (ciGraphMount) ciGraphMount.innerHTML = `
${escapeHtml(error.message)}
`; + }); +} + function showCentoSection(route) { + if (route === "research") { + showResearchCenter(); + return; + } + if (route === "codebase-intelligence") { + showCodebaseIntelligence(); + return; + } setNavActive(route); document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + hideSoftwareDeliveryViews(); reviewView.classList.add("hidden"); detailView.classList.add("hidden"); listView.classList.add("hidden"); @@ -1668,16 +2697,12 @@ function showCentoSection(route) { consultingView.classList.toggle("hidden", route !== "consulting"); factoryView.classList.toggle("hidden", route !== "factory"); docsView.classList.toggle("hidden", route !== "docs"); - researchView.classList.toggle("hidden", route !== "research"); + hideResearchViews(); if (route === "factory") { history.replaceState(null, "", "/factory"); void loadFactory(); return; } - if (route === "research") { - history.replaceState(null, "", "/research-center"); - return; - } const hash = route === "docs" ? location.hash : ""; history.replaceState(null, "", `/${route}${hash}`); if (route === "docs") syncDocsHashNavigation({ scrollToHash: true }); @@ -2105,7 +3130,13 @@ backButton.addEventListener("click", showList); window.addEventListener("popstate", () => { syncStateFromLocation(); const match = location.pathname.match(/^\/issues\/(\d+)/); - if (location.pathname === "/review") { + if (location.pathname === "/") { + showHome(); + } else if (location.pathname === "/software-delivery-hub") { + showSoftwareDeliveryHub(); + } else if (location.pathname === "/dev-pipeline-studio") { + showDevPipelineStudio(); + } else if (location.pathname === "/review") { showReview(); } else if (location.pathname === "/cluster") { showCentoSection("cluster"); @@ -2114,13 +3145,17 @@ window.addEventListener("popstate", () => { } else if (location.pathname === "/factory") { showCentoSection("factory"); } else if (location.pathname === "/research-center") { - showCentoSection("research"); + showResearchCenter(); + } else if (location.pathname === "/codebase-intelligence") { + showCodebaseIntelligence(); } else if (location.pathname === "/docs") { showCentoSection("docs"); } else if (match) { void showDetail(match[1]).catch(console.error); + } else if (location.pathname === "/issues") { + showList(); } else { - void withSpinner(loadIssues()); + showHome(); } }); @@ -2138,6 +3173,18 @@ async function boot() { showReview(); return; } + if (location.pathname === "/") { + showHome(); + return; + } + if (location.pathname === "/software-delivery-hub") { + showSoftwareDeliveryHub(); + return; + } + if (location.pathname === "/dev-pipeline-studio") { + showDevPipelineStudio(); + return; + } if (location.pathname === "/cluster") { showCentoSection("cluster"); return; @@ -2151,7 +3198,11 @@ async function boot() { return; } if (location.pathname === "/research-center") { - showCentoSection("research"); + showResearchCenter(); + return; + } + if (location.pathname === "/codebase-intelligence") { + showCodebaseIntelligence(); return; } if (location.pathname === "/docs") { @@ -2166,7 +3217,12 @@ async function boot() { }); return; } - await withSpinner(loadIssues()); + if (location.pathname === "/issues") { + showList(); + return; + } + showHome(); } +initPipelineStudioControls(); void boot(); diff --git a/templates/agent-work-app/codebase-intelligence-graph.js b/templates/agent-work-app/codebase-intelligence-graph.js new file mode 100644 index 0000000..36f2fe1 --- /dev/null +++ b/templates/agent-work-app/codebase-intelligence-graph.js @@ -0,0 +1,557 @@ +// Capability-map dependency graph for Cento Console. +// Exposes window.CodebaseIntelligenceGraph = { init(containerEl) }. +(function () { + 'use strict'; + + var NODE_W = 162; + var NODE_H = 44; + var GRAPH_W = 1020; + var GRAPH_H = 660; + var MINIMAP_W = 190; + var MINIMAP_H = 124; + + var TECH_COLORS = { + shell: '#ffb02e', + python: '#4dd7d1', + go: '#61afef', + html: '#ff5a00', + css: '#c084fc', + js: '#e5c07b', + json: '#a89c91', + sqlite: '#10b66a', + swift: '#ff6b9d', + }; + + var NODES = [ + { id: 'cli', label: 'Cento CLI', tech: 'shell', x: 100, y: 80 }, + { id: 'mobile', label: 'Mobile / Watch Apps', tech: 'swift', x: 510, y: 80 }, + { id: 'tui', label: 'Bubble Tea TUIs', tech: 'go', x: 870, y: 80 }, + { id: 'mcp', label: 'MCP Server', tech: 'python', x: 200, y: 210 }, + { id: 'bridge', label: 'Cluster Bridge', tech: 'go', x: 480, y: 210 }, + { id: 'console', label: 'Cento Console', tech: 'js', x: 780, y: 210 }, + { id: 'taskstream', label: 'Taskstream', tech: 'python', x: 130, y: 340 }, + { id: 'factory', label: 'Factory', tech: 'python', x: 400, y: 340 }, + { id: 'templates', label: 'Templates', tech: 'html', x: 660, y: 340 }, + { id: 'docs', label: 'Docs', tech: 'html', x: 910, y: 340 }, + { id: 'crm', label: 'CRM / Consulting', tech: 'python', x: 120, y: 470 }, + { id: 'storage', label: 'Storage Catalog', tech: 'sqlite', x: 370, y: 470 }, + { id: 'tests', label: 'Tests / E2E', tech: 'python', x: 630, y: 470 }, + { id: 'workspace', label: 'Workspace / Runs', tech: 'json', x: 880, y: 470 }, + { id: 'redmine', label: 'Redmine DB', tech: 'sqlite', x: 250, y: 590 }, + ]; + + var EDGES = [ + { from: 'cli', to: 'mcp', label: 'loads' }, + { from: 'cli', to: 'bridge', label: 'registers' }, + { from: 'cli', to: 'taskstream', label: 'dispatches' }, + { from: 'cli', to: 'factory', label: 'dispatches' }, + { from: 'mobile', to: 'mcp', label: 'registers' }, + { from: 'tui', to: 'taskstream', label: 'enables' }, + { from: 'mcp', to: 'taskstream', label: 'enables' }, + { from: 'mcp', to: 'factory', label: 'enables' }, + { from: 'bridge', to: 'factory', label: 'dispatches' }, + { from: 'console', to: 'taskstream', label: 'serves UI' }, + { from: 'console', to: 'templates', label: 'loads' }, + { from: 'console', to: 'docs', label: 'serves UI' }, + { from: 'taskstream', to: 'storage', label: 'persists' }, + { from: 'factory', to: 'workspace', label: 'writes' }, + { from: 'factory', to: 'tests', label: 'enables' }, + { from: 'crm', to: 'taskstream', label: 'creates' }, + { from: 'storage', to: 'redmine', label: 'indexes' }, + { from: 'tests', to: 'factory', label: 'enables' }, + { from: 'docs', to: 'templates', label: 'documents' }, + ]; + + // Build node lookup once. + var nodeMap = {}; + NODES.forEach(function (n) { nodeMap[n.id] = n; }); + + function boxExitPoint(cx, cy, hw, hh, tx, ty) { + var dx = tx - cx; + var dy = ty - cy; + if (dx === 0 && dy === 0) return { x: cx + hw, y: cy }; + var tx1 = dx !== 0 ? hw / Math.abs(dx) : Infinity; + var ty1 = dy !== 0 ? hh / Math.abs(dy) : Infinity; + var t = Math.min(tx1, ty1); + return { x: cx + t * dx, y: cy + t * dy }; + } + + function edgeEndpoints(fn, tn) { + var hw = NODE_W / 2; + var hh = NODE_H / 2; + var src = boxExitPoint(fn.x, fn.y, hw, hh, tn.x, tn.y); + var dst = boxExitPoint(tn.x, tn.y, hw, hh, fn.x, fn.y); + return { sx: src.x, sy: src.y, ex: dst.x, ey: dst.y }; + } + + function injectStyles() { + if (document.getElementById('cig-styles')) return; + var s = document.createElement('style'); + s.id = 'cig-styles'; + s.textContent = [ + '.cig-wrapper{display:flex;flex-direction:column;gap:.6rem;height:100%}', + '.cig-filters{display:flex;flex-wrap:wrap;gap:.35rem;padding:.4rem 0;border-bottom:1px solid #2d211a}', + '.cig-filter-btn{padding:.28rem .6rem;font-size:.77rem;font-weight:700;', + 'font-family:"IBM Plex Mono",ui-monospace,monospace;', + 'border:1px solid #2d211a;background:#0b0b0a;color:#a89c91;border-radius:2px;', + 'cursor:pointer;transition:border-color .12s,color .12s}', + '.cig-filter-btn:hover{border-color:var(--cig-tc,#c74700);color:var(--cig-tc,#ff5a00)}', + '.cig-filter-btn.active{border-color:var(--cig-tc,#ff5a00);color:var(--cig-tc,#ff5a00);', + 'background:rgba(255,90,0,.08)}', + '.cig-canvas-wrap{position:relative;flex:1;min-height:480px;height:520px;', + 'border:1px solid #2d211a;background:#050403;overflow:hidden}', + '.cig-canvas{display:block;cursor:grab;outline:none}', + '.cig-canvas:focus-visible{outline:1px solid #c74700;outline-offset:-1px}', + '.cig-minimap{position:absolute;bottom:.6rem;right:.6rem;border:1px solid #2d211a;', + 'pointer-events:none;opacity:.88;border-radius:2px}', + '.cig-zoom{position:absolute;top:.6rem;right:.6rem;display:flex;flex-direction:column;gap:2px}', + '.cig-zoom button{width:2rem;height:2rem;padding:0;font-size:1.05rem;line-height:1;', + 'display:flex;align-items:center;justify-content:center;', + 'border:1px solid #2d211a;background:rgba(11,11,10,.9);color:#a89c91;', + 'border-radius:2px;cursor:pointer}', + '.cig-zoom button:hover{border-color:#c74700;color:#ff5a00}', + ].join(''); + document.head.appendChild(s); + } + + function init(containerEl) { + injectStyles(); + + // --- DOM --- + var wrapper = document.createElement('div'); + wrapper.className = 'cig-wrapper'; + + var filterBar = document.createElement('div'); + filterBar.className = 'cig-filters'; + filterBar.setAttribute('role', 'toolbar'); + filterBar.setAttribute('aria-label', 'Filter by technology'); + + var TECH_FILTERS = ['All', 'Python', 'Shell', 'Go', 'HTML', 'CSS', 'JS', 'JSON', 'SQLite']; + var activeFilter = 'All'; + + TECH_FILTERS.forEach(function (f) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'cig-filter-btn' + (f === 'All' ? ' active' : ''); + btn.textContent = f; + btn.setAttribute('aria-pressed', f === 'All' ? 'true' : 'false'); + var tc = TECH_COLORS[f.toLowerCase()]; + if (tc) btn.style.setProperty('--cig-tc', tc); + btn.addEventListener('click', function () { + activeFilter = f; + filterBar.querySelectorAll('.cig-filter-btn').forEach(function (b) { + b.classList.remove('active'); + b.setAttribute('aria-pressed', 'false'); + }); + btn.classList.add('active'); + btn.setAttribute('aria-pressed', 'true'); + render(); + }); + filterBar.appendChild(btn); + }); + + var canvasWrap = document.createElement('div'); + canvasWrap.className = 'cig-canvas-wrap'; + + var canvas = document.createElement('canvas'); + canvas.className = 'cig-canvas'; + canvas.setAttribute('tabindex', '0'); + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', 'Codebase dependency graph. Drag to pan, scroll to zoom.'); + canvasWrap.appendChild(canvas); + + var minimap = document.createElement('canvas'); + minimap.className = 'cig-minimap'; + minimap.width = MINIMAP_W; + minimap.height = MINIMAP_H; + minimap.setAttribute('aria-hidden', 'true'); + canvasWrap.appendChild(minimap); + + var zoomCtrl = document.createElement('div'); + zoomCtrl.className = 'cig-zoom'; + zoomCtrl.setAttribute('role', 'group'); + zoomCtrl.setAttribute('aria-label', 'Zoom controls'); + var btnIn = mkBtn('+', 'Zoom in'); + var btnOut = mkBtn('−', 'Zoom out'); + var btnReset = mkBtn('⊙', 'Reset zoom'); + zoomCtrl.appendChild(btnIn); + zoomCtrl.appendChild(btnOut); + zoomCtrl.appendChild(btnReset); + canvasWrap.appendChild(zoomCtrl); + + wrapper.appendChild(filterBar); + wrapper.appendChild(canvasWrap); + containerEl.appendChild(wrapper); + + // --- State --- + var zoom = 1; + var panX = 0; + var panY = 0; + var dragging = false; + var dragOrigin = null; + var hoverId = null; + + // --- Helpers --- + function screenToWorld(sx, sy) { + return { x: (sx - panX) / zoom, y: (sy - panY) / zoom }; + } + + function nodeAt(sx, sy) { + var w = screenToWorld(sx, sy); + return NODES.find(function (n) { + return w.x >= n.x - NODE_W / 2 && w.x <= n.x + NODE_W / 2 && + w.y >= n.y - NODE_H / 2 && w.y <= n.y + NODE_H / 2; + }) || null; + } + + function visible(node) { + return activeFilter === 'All' || node.tech === activeFilter.toLowerCase(); + } + + // --- Render --- + function render() { + var ctx = canvas.getContext('2d'); + var W = canvas.width; + var H = canvas.height; + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = '#050403'; + ctx.fillRect(0, 0, W, H); + + ctx.save(); + ctx.translate(panX, panY); + ctx.scale(zoom, zoom); + + // Grid dots + ctx.fillStyle = 'rgba(255,90,0,0.05)'; + var gs = 50; + for (var gx = 0; gx <= GRAPH_W + gs; gx += gs) { + for (var gy = 0; gy <= GRAPH_H + gs; gy += gs) { + ctx.beginPath(); + ctx.arc(gx, gy, 1.2, 0, Math.PI * 2); + ctx.fill(); + } + } + + // Edges + EDGES.forEach(function (e) { + var fn = nodeMap[e.from]; + var tn = nodeMap[e.to]; + if (!fn || !tn) return; + var fv = visible(fn); + var tv = visible(tn); + var both = fv && tv; + var neither = !fv && !tv; + var alpha = neither ? 0.08 : both ? 0.55 : 0.22; + + var ep = edgeEndpoints(fn, tn); + var sx = ep.sx, sy = ep.sy, ex = ep.ex, ey = ep.ey; + + var angle = Math.atan2(ey - sy, ex - sx); + var hl = 9; + + ctx.globalAlpha = alpha; + ctx.strokeStyle = '#c74700'; + ctx.fillStyle = '#c74700'; + ctx.lineWidth = 1.4; + + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(ex, ey); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - hl * Math.cos(angle - Math.PI / 6), ey - hl * Math.sin(angle - Math.PI / 6)); + ctx.lineTo(ex - hl * Math.cos(angle + Math.PI / 6), ey - hl * Math.sin(angle + Math.PI / 6)); + ctx.closePath(); + ctx.fill(); + + // Label + var mx = (sx + ex) / 2; + var my = (sy + ey) / 2; + ctx.font = '9px "IBM Plex Mono",ui-monospace,monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + var tw = ctx.measureText(e.label).width; + ctx.fillStyle = 'rgba(5,4,3,0.9)'; + ctx.fillRect(mx - tw / 2 - 3, my - 7, tw + 6, 14); + ctx.fillStyle = neither ? '#612000' : '#a89c91'; + ctx.fillText(e.label, mx, my); + ctx.globalAlpha = 1; + }); + + // Nodes + NODES.forEach(function (n) { + var vis = visible(n); + var hov = hoverId === n.id; + var nx = n.x - NODE_W / 2; + var ny = n.y - NODE_H / 2; + var color = TECH_COLORS[n.tech] || '#a89c91'; + var r = 3; + + ctx.globalAlpha = vis ? 1 : 0.18; + + // Fill + ctx.fillStyle = hov ? 'rgba(255,90,0,0.16)' : 'rgba(11,11,10,0.95)'; + ctx.strokeStyle = hov ? '#ff5a00' : color; + ctx.lineWidth = hov ? 2 : 1.5; + roundRect(ctx, nx, ny, NODE_W, NODE_H, r); + ctx.fill(); + ctx.stroke(); + + // Left accent bar + ctx.fillStyle = color; + roundRect(ctx, nx, ny + 4, 3, NODE_H - 8, 1.5); + ctx.fill(); + + // Label + ctx.fillStyle = hov ? '#ff5a00' : (vis ? '#ece3d8' : '#4a3c30'); + ctx.font = '600 11.5px "Inter","IBM Plex Sans",system-ui,sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(n.label, nx + 11, ny + NODE_H / 2, NODE_W - 50); + + // Tech badge + var bx = nx + NODE_W - 37; + var by = ny + NODE_H / 2 - 7; + ctx.fillStyle = color + '20'; + ctx.strokeStyle = color + '50'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.rect(bx, by, 31, 14); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = color; + ctx.font = '700 8.5px "IBM Plex Mono",ui-monospace,monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(n.tech.toUpperCase().slice(0, 5), bx + 15.5, by + 7); + + ctx.globalAlpha = 1; + }); + + ctx.restore(); + renderMinimap(); + } + + function renderMinimap() { + var mc = minimap.getContext('2d'); + var mw = MINIMAP_W; + var mh = MINIMAP_H; + var pad = 14; + + mc.clearRect(0, 0, mw, mh); + mc.fillStyle = 'rgba(11,11,10,0.93)'; + mc.strokeStyle = '#2d211a'; + mc.lineWidth = 1; + mc.fillRect(0, 0, mw, mh); + mc.strokeRect(0, 0, mw, mh); + + var sx = (mw - pad) / GRAPH_W; + var sy = (mh - pad) / GRAPH_H; + var sc = Math.min(sx, sy); + var ox = pad / 2; + var oy = pad / 2; + + // Edges + mc.strokeStyle = 'rgba(199,71,0,0.35)'; + mc.lineWidth = 0.6; + EDGES.forEach(function (e) { + var fn = nodeMap[e.from]; + var tn = nodeMap[e.to]; + if (!fn || !tn) return; + mc.beginPath(); + mc.moveTo(fn.x * sc + ox, fn.y * sc + oy); + mc.lineTo(tn.x * sc + ox, tn.y * sc + oy); + mc.stroke(); + }); + + // Nodes + NODES.forEach(function (n) { + var color = TECH_COLORS[n.tech] || '#a89c91'; + mc.fillStyle = visible(n) ? color : 'rgba(45,33,26,0.55)'; + mc.beginPath(); + mc.arc(n.x * sc + ox, n.y * sc + oy, 3.2, 0, Math.PI * 2); + mc.fill(); + }); + + // Viewport rect + var vx = (-panX / zoom) * sc + ox; + var vy = (-panY / zoom) * sc + oy; + var vw = (canvas.width / zoom) * sc; + var vh = (canvas.height / zoom) * sc; + mc.strokeStyle = 'rgba(255,90,0,0.65)'; + mc.lineWidth = 1; + mc.strokeRect(vx, vy, vw, vh); + } + + // --- Fit --- + function fitToCanvas() { + var pad = 32; + zoom = Math.min((canvas.width - pad * 2) / GRAPH_W, (canvas.height - pad * 2) / GRAPH_H); + panX = pad; + panY = pad; + } + + function resizeCanvas() { + var w = canvasWrap.clientWidth || 800; + var h = canvasWrap.clientHeight || 520; + canvas.width = w; + canvas.height = h; + } + + // --- Zoom helpers --- + function zoomAt(cx, cy, factor) { + var oldZoom = zoom; + zoom = Math.min(Math.max(zoom * factor, 0.2), 5); + panX = cx - (cx - panX) * zoom / oldZoom; + panY = cy - (cy - panY) * zoom / oldZoom; + } + + // --- Event handlers --- + btnIn.addEventListener('click', function () { + zoomAt(canvas.width / 2, canvas.height / 2, 1.25); + render(); + }); + btnOut.addEventListener('click', function () { + zoomAt(canvas.width / 2, canvas.height / 2, 0.8); + render(); + }); + btnReset.addEventListener('click', function () { + fitToCanvas(); + render(); + }); + + canvas.addEventListener('wheel', function (e) { + e.preventDefault(); + var rect = canvas.getBoundingClientRect(); + zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY > 0 ? 0.9 : 1.1); + render(); + }, { passive: false }); + + canvas.addEventListener('mousedown', function (e) { + dragging = true; + dragOrigin = { x: e.clientX - panX, y: e.clientY - panY }; + canvas.style.cursor = 'grabbing'; + }); + + canvas.addEventListener('mousemove', function (e) { + if (dragging) { + panX = e.clientX - dragOrigin.x; + panY = e.clientY - dragOrigin.y; + render(); + } else { + var rect = canvas.getBoundingClientRect(); + var n = nodeAt(e.clientX - rect.left, e.clientY - rect.top); + var nid = n ? n.id : null; + if (nid !== hoverId) { + hoverId = nid; + canvas.style.cursor = hoverId ? 'pointer' : 'grab'; + render(); + } + } + }); + + canvas.addEventListener('mouseup', function () { + dragging = false; + canvas.style.cursor = hoverId ? 'pointer' : 'grab'; + }); + + canvas.addEventListener('mouseleave', function () { + dragging = false; + canvas.style.cursor = 'default'; + }); + + canvas.addEventListener('keydown', function (e) { + if (e.key === '+' || e.key === '=') { + zoomAt(canvas.width / 2, canvas.height / 2, 1.25); render(); + } else if (e.key === '-') { + zoomAt(canvas.width / 2, canvas.height / 2, 0.8); render(); + } else if (e.key === '0') { + fitToCanvas(); render(); + } + }); + + // Touch + var lastPinchDist = null; + canvas.addEventListener('touchstart', function (e) { + if (e.touches.length === 1) { + dragging = true; + dragOrigin = { x: e.touches[0].clientX - panX, y: e.touches[0].clientY - panY }; + } else if (e.touches.length === 2) { + var dx = e.touches[0].clientX - e.touches[1].clientX; + var dy = e.touches[0].clientY - e.touches[1].clientY; + lastPinchDist = Math.hypot(dx, dy); + } + }, { passive: true }); + + canvas.addEventListener('touchmove', function (e) { + e.preventDefault(); + if (e.touches.length === 1 && dragging) { + panX = e.touches[0].clientX - dragOrigin.x; + panY = e.touches[0].clientY - dragOrigin.y; + render(); + } else if (e.touches.length === 2) { + var dx = e.touches[0].clientX - e.touches[1].clientX; + var dy = e.touches[0].clientY - e.touches[1].clientY; + var dist = Math.hypot(dx, dy); + if (lastPinchDist) { + var rect = canvas.getBoundingClientRect(); + var cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left; + var cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top; + zoomAt(cx, cy, dist / lastPinchDist); + render(); + } + lastPinchDist = dist; + } + }, { passive: false }); + + canvas.addEventListener('touchend', function () { + dragging = false; + lastPinchDist = null; + }); + + var ro = new ResizeObserver(function () { + resizeCanvas(); + fitToCanvas(); + render(); + }); + ro.observe(canvasWrap); + + // Initial draw + resizeCanvas(); + fitToCanvas(); + render(); + + return { + refresh: render, + setFilter: function (tech) { activeFilter = tech; render(); }, + destroy: function () { ro.disconnect(); containerEl.removeChild(wrapper); }, + }; + } + + // --- Utility --- + function mkBtn(text, label) { + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = text; + b.setAttribute('aria-label', label); + return b; + } + + function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + } + + window.CodebaseIntelligenceGraph = { init: init }; +})(); diff --git a/templates/agent-work-app/codebase-intelligence-panels.js b/templates/agent-work-app/codebase-intelligence-panels.js new file mode 100644 index 0000000..9cc0088 --- /dev/null +++ b/templates/agent-work-app/codebase-intelligence-panels.js @@ -0,0 +1,869 @@ +// Inspector panel and Ask Cento panel module for the Codebase Intelligence route. +// Exposes window.CIpanels with render + init functions; no live model dependency. + +(function (global) { + "use strict"; + + // --- CSS injection --- + + function injectStyles() { + if (document.getElementById("ci-panels-styles")) return; + const style = document.createElement("style"); + style.id = "ci-panels-styles"; + style.textContent = ` +/* Inspector Panel */ +.ciInspectorPanel { + display: flex; + flex-direction: column; + background: var(--panel, #0b0b0a); + border-left: 1px solid var(--panel-line, #2d211a); + height: 100%; + overflow: hidden; + min-width: 0; + font-size: 0.82rem; + color: var(--text, #ece3d8); +} + +.ciInspectorHeader { + display: flex; + align-items: center; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; +} + +.ciInspectorTitle { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted, #a89c91); +} + +.ciInspectorMeta { + padding: 0.6rem 0.85rem; + border-bottom: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.ciMetaPath { + display: flex; + align-items: baseline; + gap: 0.4rem; + flex-wrap: wrap; + word-break: break-all; +} + +.ciMetaPath code { + font-family: "IBM Plex Mono", monospace; + font-size: 0.78rem; + color: var(--orange-soft, #ff8a2a); +} + +.ciMetaRow { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.ciMetaLabel { + color: var(--muted, #a89c91); + margin-right: 0.25rem; + font-size: 0.75rem; +} + +.ciLangBadge { + background: var(--panel-soft, #12110f); + border: 1px solid var(--line-dim, #612000); + color: var(--orange-soft, #ff8a2a); + padding: 0.1rem 0.45rem; + border-radius: 2px; + font-size: 0.72rem; + font-weight: 500; + margin-left: auto; +} + +.ciInspectorTabs { + display: flex; + border-bottom: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.ciInspectorTabs::-webkit-scrollbar { display: none; } + +.ciInspectorTab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--muted, #a89c91); + padding: 0.45rem 0.7rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + border-radius: 0; + flex-shrink: 0; +} + +.ciInspectorTab:hover { + color: var(--text, #ece3d8); + border-color: transparent; + background: transparent; +} + +.ciInspectorTab.active { + color: var(--orange, #ff5a00); + border-bottom-color: var(--orange, #ff5a00); +} + +.ciInspectorTabContent { + flex: 1; + overflow-y: auto; + padding: 0.75rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.ciTabPane.hidden { display: none; } + +.ciPurpose { + margin: 0; + color: var(--muted, #a89c91); + line-height: 1.55; + font-size: 0.8rem; +} + +.ciSection { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.ciSectionTitle { + margin: 0; + font-size: 0.73rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted, #a89c91); + padding-bottom: 0.25rem; + border-bottom: 1px solid var(--panel-line, #2d211a); +} + +.ciRouteList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.ciRouteRow { + display: flex; + align-items: baseline; + gap: 0.4rem; + flex-wrap: wrap; +} + +.ciMethodBadge { + font-family: "IBM Plex Mono", monospace; + font-size: 0.68rem; + font-weight: 600; + color: var(--green, #10b66a); + min-width: 2.2rem; +} + +.ciRoutePath { + font-family: "IBM Plex Mono", monospace; + font-size: 0.75rem; + color: var(--cyan, #4dd7d1); + word-break: break-all; +} + +.ciRouteLabel { + color: var(--muted, #a89c91); + font-size: 0.74rem; +} + +.ciDatastore { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; +} + +.ciDsBadge { + background: var(--panel-soft, #12110f); + border: 1px solid var(--panel-line, #2d211a); + color: var(--yellow, #ffb02e); + padding: 0.1rem 0.4rem; + border-radius: 2px; + font-size: 0.71rem; + font-weight: 600; +} + +.ciDsPath { + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + color: var(--muted, #a89c91); + word-break: break-all; +} + +.ciDebtList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.22rem; +} + +.ciDebtItem { + color: var(--muted, #a89c91); + font-size: 0.78rem; + padding-left: 0.9rem; + position: relative; +} + +.ciDebtItem::before { + content: "▸"; + position: absolute; + left: 0; + color: var(--line-dim, #612000); +} + +.ciAiAssistant { + border: 1px solid var(--panel-line, #2d211a); + border-radius: 3px; + padding: 0.65rem; + background: var(--panel-soft, #12110f); +} + +.ciAiPromptBox { + background: var(--bg, #050403); + border: 1px solid var(--line-dim, #612000); + border-radius: 2px; + padding: 0.4rem 0.55rem; + font-size: 0.78rem; + color: var(--text, #ece3d8); + margin-bottom: 0.5rem; +} + +.ciAiAnswer { + font-size: 0.76rem; + color: var(--muted, #a89c91); + line-height: 1.5; + white-space: pre-line; + margin-bottom: 0.45rem; +} + +.ciAiRefs { + list-style: none; + margin: 0 0 0.5rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.ciAiRefs li { + font-family: "IBM Plex Mono", monospace; + font-size: 0.71rem; + color: var(--cyan, #4dd7d1); +} + +.ciAiReportLink { + display: inline-block; + font-size: 0.75rem; + color: var(--orange, #ff5a00); + text-decoration: none; + border: 1px solid var(--line-dim, #612000); + padding: 0.2rem 0.55rem; + border-radius: 2px; + margin-top: 0.25rem; +} + +.ciAiReportLink:hover { + border-color: var(--orange, #ff5a00); + background: rgba(255,90,0,0.08); +} + +.ciPlaceholder { + margin: 0; + color: var(--muted, #a89c91); + font-size: 0.78rem; + font-style: italic; +} + +/* Ask Cento Panel */ +.ciAskPanel { + display: flex; + flex-direction: column; + background: var(--panel, #0b0b0a); + border-top: 1px solid var(--panel-line, #2d211a); + font-size: 0.82rem; + color: var(--text, #ece3d8); + min-width: 0; +} + +.ciAskHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.85rem; + border-bottom: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; + flex-wrap: wrap; + gap: 0.4rem; +} + +.ciAskTitle { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted, #a89c91); +} + +.ciAskContext { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.ciAskContextLabel { + font-size: 0.72rem; + color: var(--muted, #a89c91); +} + +.ciAskContextValue { + background: var(--panel-soft, #12110f); + border: 1px solid var(--panel-line, #2d211a); + padding: 0.1rem 0.45rem; + border-radius: 2px; + font-size: 0.72rem; + color: var(--text, #ece3d8); +} + +.ciAskThread { + flex: 1; + overflow-y: auto; + padding: 0.65rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.65rem; + max-height: 12rem; +} + +.ciAskBubble { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.ciAskAvatar { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.ciAskAvatarUser { color: var(--muted, #a89c91); } +.ciAskAvatarCento { color: var(--orange, #ff5a00); } + +.ciAskMessage { + margin: 0; + background: var(--panel-soft, #12110f); + border: 1px solid var(--panel-line, #2d211a); + border-radius: 2px; + padding: 0.4rem 0.6rem; + color: var(--text, #ece3d8); + font-size: 0.8rem; + line-height: 1.45; +} + +.ciAskAnswerWrap { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.ciAskAnswer { + margin: 0; + flex: 1; + min-width: 0; + color: var(--muted, #a89c91); + font-size: 0.78rem; + line-height: 1.5; + white-space: pre-line; + word-break: break-word; +} + +.ciAskRefBlock { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 9rem; + flex-shrink: 0; +} + +.ciAskRefTitle { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, #a89c91); +} + +.ciAskRefList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.ciAskRef { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.4rem; +} + +.ciAskRefLink { + font-family: "IBM Plex Mono", monospace; + font-size: 0.71rem; + color: var(--cyan, #4dd7d1); + text-decoration: none; + word-break: break-all; +} + +.ciAskRefLink:hover { text-decoration: underline; } + +.ciAskRefLines { + font-family: "IBM Plex Mono", monospace; + font-size: 0.7rem; + color: var(--muted, #a89c91); + white-space: nowrap; +} + +.ciAskRefExtra { + font-size: 0.72rem; + color: var(--muted, #a89c91); + font-style: italic; +} + +.ciAskActions { + display: flex; + align-items: center; + padding: 0.3rem 0.85rem; + gap: 0.75rem; + border-top: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; +} + +.ciAskFollowUpLink { + font-size: 0.75rem; + color: var(--orange-soft, #ff8a2a); + text-decoration: none; +} + +.ciAskFollowUpLink:hover { text-decoration: underline; } + +.ciAskInputRow { + display: flex; + align-items: center; + padding: 0.5rem 0.85rem; + gap: 0.5rem; + border-top: 1px solid var(--panel-line, #2d211a); + flex-shrink: 0; +} + +.ciAskInput { + flex: 1; + min-width: 0; + background: var(--panel-soft, #12110f); + border: 1px solid var(--panel-line, #2d211a); + color: var(--text, #ece3d8); + padding: 0.4rem 0.6rem; + border-radius: 2px; + font-size: 0.8rem; +} + +.ciAskInput:focus { + outline: none; + border-color: var(--line-dim, #612000); +} + +.ciAskInput::placeholder { color: var(--muted, #a89c91); } + +.ciAskSend { + background: var(--line-dim, #612000); + border: 1px solid var(--line-dim, #612000); + color: var(--text, #ece3d8); + width: 2rem; + height: 2rem; + padding: 0; + border-radius: 2px; + cursor: pointer; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ciAskSend:hover { + background: var(--orange, #ff5a00); + border-color: var(--orange, #ff5a00); +} + +@media (max-width: 768px) { + .ciAskAnswerWrap { flex-direction: column; } + .ciAskRefBlock { min-width: 0; } + .ciInspectorTabs { flex-wrap: wrap; } +} + `; + document.head.appendChild(style); + } + + // --- Fixture data --- + + const INSPECTOR_FIXTURE = { + path: "scripts/agent_work_app.py", + language: "Python", + size_kb: 31.4, + loc: 872, + modified: "Today, 10:42 AM", + purpose: + "ThrottlingHTTPServer local web app for Agent Work (Taskstream). Serves REST API and static UI. Processes issues, review, factory, artifacts, and health endpoints.", + api_routes: [ + { method: "GET", path: "/api/issues", label: "List issues" }, + { method: "GET", path: "/api/review", label: "Issue detail review" }, + { method: "GET", path: "/api/factory", label: "List factory runs" }, + { method: "GET", path: "/api/artifacts", label: "List catalog entries" }, + { method: "GET", path: "/api/health", label: "Health check" }, + ], + datastore: { + type: "SQLite", + path: "~/...state/cento/agent-work-app.sqlite3", + }, + tech_debt: [ + "Large single-file backend (872 LOC)", + "Missing DOM frontend (no framework)", + "Limited symbol search / no pagination", + "Workspace path needs pruning without pruning", + ], + ai_assistant: { + prompt: "Explain AgentWorkAppError and every route that can raise it", + answer: + "Here are all routes that can raise AgentWorkAppError, with any and\nwhere in the code.\n\nGET /api/issues\n- Raises when invalid query params or data load fails.\n\nPOST /api/review\n- Raised on invalid payload or persistence failure,\nscripts/agent_work_app.py:401-527\n\nGET /api/factory\n- Raises when accessing missing or unavailable\nfactory, scripts/agent_work_app.py:512-640\n\nGET /api/artifacts\n- Raises missing catalog entries.\n\nFull details with code snippets", + references: [ + { path: "scripts/agent_work_app.py", lines: 66 }, + { path: "scripts/agent_work_app.py", lines: 412 }, + { path: "scripts/agent_work_app.py", lines: 458 }, + { path: "scripts/agent_work_app.py", lines: 812 }, + ], + }, + }; + + const ASK_PANEL_FIXTURE = { + context: "Current Repository", + example_prompt: "Explain AgentWorkAppError and every route that can raise it", + answer: + "AgentWorkAppError is the base exception for the Agent Work web app. It's raised for domain and request errors and returned as JSON {\"error\": \"...\"} with appropriate HTTP status codes.\n\nAll routes that can raise it, with conditions and code references.", + references: [ + { path: "scripts/agent_work_app.py", lines: 412 }, + { path: "scripts/agent_work_app.py", lines: 458 }, + { path: "scripts/agent_work_app.py", lines: 612 }, + { path: "scripts/agent_work_app.py", lines: 736 }, + ], + extra_refs: 1, + }; + + // --- Helpers --- + + function esc(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + // --- Inspector Panel --- + + function renderInspectorPanel(data) { + data = data || INSPECTOR_FIXTURE; + + const tabs = ["Summary", "Call Graph", "Owners", "Debt", "Tests", "Evidence"]; + const tabsHtml = tabs + .map( + (t, i) => + `` + ) + .join(""); + + const routesHtml = (data.api_routes || []) + .map( + (r) => + `
  • ${esc(r.method)}${esc(r.path)}${esc(r.label)}
  • ` + ) + .join(""); + + const debtHtml = (data.tech_debt || []) + .map((d) => `
  • ${esc(d)}
  • `) + .join(""); + + const aiRefs = (data.ai_assistant.references || []) + .map((r) => `
  • ${esc(r.path)}:${esc(String(r.lines))}
  • `) + .join(""); + + return ` +
    +
    + Inspector +
    +
    +
    + File + ${esc(data.path)} +
    +
    + Size${esc(String(data.size_kb))} KB + Loc${esc(String(data.loc))} +
    +
    + Modified${esc(data.modified)} + ${esc(data.language)} +
    +
    + +
    +
    +

    ${esc(data.purpose)}

    + +
    +

    API Routes / Routes

    +
      ${routesHtml}
    +
    + +
    +

    Datastore

    +
    + ${esc(data.datastore.type)} + ${esc(data.datastore.path)} +
    +
    + +
    +

    Tech / Debt

    +
      ${debtHtml}
    +
    + +
    +

    AI Assistant (Cento)

    +
    ${esc(data.ai_assistant.prompt)}
    +
    ${esc(data.ai_assistant.answer)}
    +
      ${aiRefs}
    + Open in AI Report +
    +
    + + + + + + +
    +
    `.trim(); + } + + function initInspectorPanel(containerEl) { + if (!containerEl) return; + const tabs = containerEl.querySelectorAll(".ciInspectorTab"); + const panes = containerEl.querySelectorAll(".ciTabPane"); + tabs.forEach((tab) => { + tab.addEventListener("click", () => { + const target = tab.dataset.tab; + tabs.forEach((t) => { + t.classList.remove("active"); + t.setAttribute("aria-selected", "false"); + }); + tab.classList.add("active"); + tab.setAttribute("aria-selected", "true"); + panes.forEach((pane) => { + pane.classList.toggle("hidden", pane.dataset.pane !== target); + }); + }); + }); + } + + // --- Ask Cento Panel --- + + function renderAskCentoPanel(data) { + data = data || ASK_PANEL_FIXTURE; + + const refsHtml = (data.references || []) + .map( + (r) => + `
  • ${esc(r.path)}${esc(String(r.lines))}
  • ` + ) + .join(""); + const extraRef = + data.extra_refs > 0 + ? `
  • +${data.extra_refs} more reference${data.extra_refs > 1 ? "s" : ""}
  • ` + : ""; + + return ` +
    +
    + Ask Cento about code +
    + Context + ${esc(data.context)} +
    +
    +
    +
    + You +

    ${esc(data.example_prompt)}

    +
    +
    + Cento AI +
    +

    ${esc(data.answer)}

    +
    + Referenced in +
      ${refsHtml}${extraRef}
    +
    +
    +
    +
    + +
    + + +
    +
    `.trim(); + } + + function initAskCentoPanel(containerEl) { + if (!containerEl) return; + const input = containerEl.querySelector(".ciAskInput"); + const send = containerEl.querySelector(".ciAskSend"); + const thread = containerEl.querySelector(".ciAskThread"); + if (!input || !send) return; + + function appendUserBubble(text) { + if (!thread) return; + const div = document.createElement("div"); + div.className = "ciAskBubble ciAskBubbleUser"; + const avatar = document.createElement("span"); + avatar.className = "ciAskAvatar ciAskAvatarUser"; + avatar.textContent = "You"; + const msg = document.createElement("p"); + msg.className = "ciAskMessage"; + msg.textContent = text; + div.appendChild(avatar); + div.appendChild(msg); + thread.appendChild(div); + thread.scrollTop = thread.scrollHeight; + } + + function submitAsk() { + const val = input.value.trim(); + if (!val) return; + input.value = ""; + appendUserBubble(val); + } + + send.addEventListener("click", submitAsk); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submitAsk(); + } + }); + + const followUp = containerEl.querySelector(".ciAskFollowUpLink"); + if (followUp) { + followUp.addEventListener("click", (e) => { + e.preventDefault(); + input.focus(); + }); + } + } + + // --- API loader --- + + async function loadInspectorData(filePath) { + try { + const resp = await fetch( + `/api/codebase-intelligence/inspect?path=${encodeURIComponent(filePath)}` + ); + if (!resp.ok) return null; + return await resp.json(); + } catch { + return null; + } + } + + // --- Mount helpers --- + + function mountInspectorPanel(containerEl, data) { + if (!containerEl) return; + injectStyles(); + containerEl.innerHTML = renderInspectorPanel(data || INSPECTOR_FIXTURE); + initInspectorPanel(containerEl); + } + + function mountAskCentoPanel(containerEl, data) { + if (!containerEl) return; + injectStyles(); + containerEl.innerHTML = renderAskCentoPanel(data || ASK_PANEL_FIXTURE); + initAskCentoPanel(containerEl); + } + + // --- Public API --- + + global.CIpanels = { + INSPECTOR_FIXTURE, + ASK_PANEL_FIXTURE, + injectStyles, + renderInspectorPanel, + initInspectorPanel, + renderAskCentoPanel, + initAskCentoPanel, + loadInspectorData, + mountInspectorPanel, + mountAskCentoPanel, + }; +})(window); diff --git a/templates/agent-work-app/codebase-intelligence.css b/templates/agent-work-app/codebase-intelligence.css new file mode 100644 index 0000000..f95b4d2 --- /dev/null +++ b/templates/agent-work-app/codebase-intelligence.css @@ -0,0 +1,1273 @@ +/* Route-scoped styles for /codebase-intelligence + Scope: body.codebaseMode and #codebaseIntelligenceView + Do not use selectors from this file that would match the global shell. +*/ + +/* ─── Layout overrides ─────────────────────────────────────── */ + +body.codebaseMode .appShell { + grid-template-columns: 1fr; +} + +body.codebaseMode .sidebar, +body.codebaseMode .taskstreamNav { + display: none; +} + +body.codebaseMode .content { + padding: 0; + border: 0; + min-height: calc(100vh - 94px); + overflow: hidden; +} + +/* ─── Root view container ───────────────────────────────────── */ + +#codebaseIntelligenceView { + display: grid; + grid-template-columns: 170px minmax(0, 1fr) 360px; + grid-template-rows: auto minmax(0, 1fr); + min-height: calc(100vh - 94px); + background: #0b0b0a; + color: var(--text); + font-size: 0.82rem; +} + +#codebaseIntelligenceView.hidden { + display: none; +} + +#codebaseIntelligenceView:not(.hidden) { + display: grid; +} + +#codebaseIntelligenceView .sdHubChildHeader { + grid-column: 1 / -1; + grid-row: 1; +} + +/* ─── Left sidebar ──────────────────────────────────────────── */ + +.ciSidebar { + grid-column: 1; + grid-row: 2; + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); + background: linear-gradient(180deg, #0d0d0c, #090908); + overflow-y: auto; + padding-bottom: 1rem; +} + +.ciSidebarSection { + padding: 0.9rem 0.85rem 0.65rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.6); +} + +.ciSidebarSection:last-child { + border-bottom: 0; +} + +.ciSidebarLabel { + display: block; + margin: 0 0 0.45rem; + color: var(--muted); + font-size: 0.67rem; + font-weight: 900; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.ciSidebarNav { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.05rem; +} + +.ciSidebarNav a { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.32rem 0.45rem; + border-radius: 2px; + color: var(--muted); + text-decoration: none; + font-size: 0.8rem; + transition: color 0.12s, background 0.12s; + white-space: nowrap; +} + +.ciSidebarNav a .ciNavIcon { + width: 14px; + text-align: center; + opacity: 0.7; + flex-shrink: 0; +} + +.ciSidebarNav a:hover { + color: var(--text); + background: rgba(255, 90, 0, 0.06); +} + +.ciSidebarNav a.active { + color: var(--orange); + background: rgba(255, 90, 0, 0.1); + font-weight: 600; +} + +.ciSidebarNav a.active .ciNavIcon { + opacity: 1; +} + +/* Repository section */ + +.ciRepo { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.9rem 0.85rem 0.65rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.6); +} + +.ciRepoBranch { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.79rem; + color: var(--text); +} + +.ciRepoBranch .ciBranchIcon { + opacity: 0.55; + font-size: 0.75rem; +} + +.ciRepoStatus { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.38rem; + border: 1px solid var(--orange); + border-radius: 2px; + color: var(--orange); + font-size: 0.67rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + margin-left: auto; +} + +.ciRepoStatus.clean { + border-color: var(--green); + color: var(--green); +} + +.ciRepoMeta { + display: flex; + flex-direction: column; + gap: 0.18rem; + color: var(--muted); + font-size: 0.73rem; +} + +.ciRepoDirty { + display: flex; + align-items: flex-start; + gap: 0.38rem; + color: var(--yellow); + font-size: 0.75rem; + margin-top: 0.1rem; +} + +.ciRepoDirtyIcon { + flex-shrink: 0; + margin-top: 0.05rem; +} + +/* Code health */ + +.ciHealthList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.42rem; + padding: 0.9rem 0.85rem 0.65rem; +} + +.ciHealthRow { + display: grid; + grid-template-columns: 54px 1fr 30px; + align-items: center; + gap: 0.45rem; +} + +.ciHealthName { + font-size: 0.75rem; + color: var(--muted); + white-space: nowrap; +} + +.ciHealthBar { + height: 4px; + background: rgba(45, 33, 26, 0.7); + border-radius: 2px; + overflow: hidden; +} + +.ciHealthFill { + height: 100%; + border-radius: 2px; + background: var(--green); + transition: width 0.3s ease; +} + +.ciHealthFill.lang-python { background: #3b82f6; } +.ciHealthFill.lang-shell { background: var(--green); } +.ciHealthFill.lang-go { background: var(--yellow); } +.ciHealthFill.lang-js { background: #f59e0b; } +.ciHealthFill.lang-ts { background: var(--cyan); } + +.ciHealthPct { + font-size: 0.73rem; + color: var(--text); + text-align: right; +} + +.ciHealthReport { + display: inline-flex; + align-items: center; + gap: 0.3rem; + margin: 0.35rem 0.85rem 0; + padding: 0 0; + color: var(--orange); + font-size: 0.74rem; + text-decoration: none; +} + +.ciHealthReport:hover { + text-decoration: underline; +} + +/* ─── Main content area ─────────────────────────────────────── */ + +.ciMain { + grid-column: 2; + grid-row: 2; + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + border-right: 1px solid var(--line); +} + +/* ─── Capability cards bar ───────────────────────────────────── */ + +.ciCapabilityBar { + display: flex; + gap: 0; + border-bottom: 1px solid var(--line); + flex-shrink: 0; +} + +.ciCapCard { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-right: 1px solid rgba(45, 33, 26, 0.7); + flex: 1; + min-width: 0; + cursor: default; + transition: background 0.1s; +} + +.ciCapCard:last-child { + border-right: 0; +} + +.ciCapCard:hover { + background: rgba(255, 90, 0, 0.04); +} + +.ciCapIcon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 3px; + flex-shrink: 0; + font-size: 0.9rem; +} + +.ciCapIcon.blue { background: rgba(59, 130, 246, 0.2); color: #3b82f6; } +.ciCapIcon.purple { background: rgba(139, 92, 246, 0.2); color: #8b5cf6; } +.ciCapIcon.teal { background: rgba(77, 215, 209, 0.2); color: var(--cyan); } +.ciCapIcon.yellow { background: rgba(255, 176, 46, 0.2); color: var(--yellow); } +.ciCapIcon.green { background: rgba(16, 182, 106, 0.2); color: var(--green); } +.ciCapIcon.orange { background: rgba(255, 90, 0, 0.2); color: var(--orange); } + +.ciCapBody { + display: flex; + flex-direction: column; + min-width: 0; +} + +.ciCapCount { + font-size: 0.83rem; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ciCapDetail { + font-size: 0.68rem; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ─── Graph toolbar ──────────────────────────────────────────── */ + +.ciGraphToolbar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.85rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.6); + background: rgba(11, 11, 10, 0.96); + flex-shrink: 0; +} + +.ciGraphTitle { + font-size: 0.82rem; + font-weight: 700; + color: var(--text); + white-space: nowrap; +} + +.ciGraphTitle small { + font-weight: 400; + color: var(--orange); + font-size: 0.72rem; + margin-left: 0.35rem; + text-decoration: underline; + cursor: pointer; +} + +.ciGraphFilters { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; + margin-right: auto; +} + +.ciFilterLabel { + font-size: 0.72rem; + color: var(--muted); + margin-right: 0.1rem; +} + +.ciFilterBtn { + padding: 0.15rem 0.55rem; + border: 1px solid rgba(45, 33, 26, 0.8); + border-radius: 2px; + background: transparent; + color: var(--muted); + font-size: 0.71rem; + cursor: pointer; + transition: border-color 0.1s, color 0.1s, background 0.1s; +} + +.ciFilterBtn:hover { + border-color: var(--line-dim); + color: var(--text); +} + +.ciFilterBtn.active { + border-color: var(--orange); + color: var(--orange); + background: rgba(255, 90, 0, 0.08); +} + +.ciFilterBtn.clear { + border-color: transparent; + color: var(--muted); +} + +.ciFilterBtn.clear:hover { + color: var(--text); +} + +.ciGraphControls { + display: flex; + align-items: center; + gap: 0.35rem; + margin-left: auto; + flex-shrink: 0; +} + +.ciSelect { + padding: 0.22rem 0.55rem; + border: 1px solid var(--line-dim); + border-radius: 2px; + background: #10100f; + color: var(--text); + font-size: 0.72rem; + cursor: pointer; + appearance: none; + padding-right: 1.4rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23a89c91'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.45rem center; +} + +.ciSelect:focus { + outline: 1px solid var(--orange); +} + +.ciOptionsBtn { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.22rem 0.6rem; + border: 1px solid var(--line-dim); + border-radius: 2px; + background: #10100f; + color: var(--muted); + font-size: 0.72rem; + cursor: pointer; +} + +.ciOptionsBtn:hover { + border-color: var(--orange); + color: var(--orange); +} + +/* ─── Dependency graph canvas ────────────────────────────────── */ + +.ciGraphCanvas { + flex: 1; + position: relative; + background: + radial-gradient(circle at 50% 50%, rgba(255, 90, 0, 0.03), transparent 60%), + #0d0d0c; + overflow: hidden; + min-height: 320px; +} + +.ciLoading { + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + color: var(--muted); + font-size: 0.78rem; +} + +.ciGraphSvg { + width: 100%; + height: 100%; +} + +/* Dependency graph nodes */ + +.ciNode { + position: absolute; + border: 1px solid; + border-radius: 3px; + padding: 0.5rem 0.65rem; + min-width: 120px; + max-width: 180px; + cursor: pointer; + transition: box-shadow 0.15s, border-color 0.15s; + background: rgba(13, 13, 12, 0.92); + user-select: none; +} + +.ciNode:hover { + box-shadow: 0 0 0 2px currentColor; +} + +.ciNode.active { + box-shadow: 0 0 0 2px currentColor, 0 0 12px rgba(255, 90, 0, 0.25); +} + +.ciNode.blue { border-color: #3b82f6; color: #3b82f6; } +.ciNode.purple { border-color: #8b5cf6; color: #8b5cf6; } +.ciNode.yellow { border-color: var(--yellow); color: var(--yellow); } +.ciNode.teal { border-color: var(--cyan); color: var(--cyan); } +.ciNode.green { border-color: var(--green); color: var(--green); } +.ciNode.orange { border-color: var(--orange); color: var(--orange); } +.ciNode.muted { border-color: var(--line-dim); color: var(--muted); } + +.ciNodeHeader { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.25rem; +} + +.ciNodeDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.ciNodeTitle { + font-size: 0.74rem; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ciNodeSub { + font-size: 0.67rem; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ciNodeMeta { + display: flex; + gap: 0.45rem; + margin-top: 0.3rem; +} + +.ciNodeBadge { + font-size: 0.63rem; + padding: 0.08rem 0.35rem; + border: 1px solid rgba(168, 156, 145, 0.2); + border-radius: 2px; + color: var(--muted); +} + +/* Graph edges / SVG lines */ + +.ciEdge { + stroke: rgba(45, 33, 26, 0.8); + stroke-width: 1.5; + fill: none; +} + +.ciEdge.active { + stroke: var(--orange-soft); + stroke-width: 2; +} + +/* Graph minimap */ + +.ciMinimap { + position: absolute; + bottom: 0.75rem; + right: 0.75rem; + width: 120px; + height: 70px; + border: 1px solid var(--line-dim); + background: rgba(11, 11, 10, 0.88); + border-radius: 2px; + overflow: hidden; +} + +.ciMinimapViewport { + position: absolute; + border: 1px solid var(--orange); + background: rgba(255, 90, 0, 0.08); + border-radius: 1px; +} + +/* Zoom controls */ + +.ciZoomControls { + position: absolute; + bottom: 0.75rem; + left: 0.75rem; + display: flex; + gap: 0.25rem; +} + +.ciZoomBtn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line-dim); + background: rgba(11, 11, 10, 0.9); + color: var(--muted); + font-size: 0.9rem; + border-radius: 2px; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.ciZoomBtn:hover { + border-color: var(--orange); + color: var(--orange); +} + +/* ─── Codebase Data Flow strip ───────────────────────────────── */ + +.ciDataFlow { + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: rgba(11, 11, 10, 0.96); + padding: 0.65rem 0.85rem; + flex-shrink: 0; +} + +.ciDataFlowTitle { + font-size: 0.76rem; + font-weight: 700; + color: var(--text); + margin: 0 0 0.5rem; +} + +.ciDataFlowTitle small { + font-weight: 400; + color: var(--muted); + font-size: 0.7rem; + margin-left: 0.4rem; +} + +.ciDataFlowNodes { + display: flex; + align-items: center; + gap: 0; + overflow-x: auto; +} + +.ciDfNode { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.7rem; + border: 1px solid rgba(45, 33, 26, 0.8); + border-radius: 3px; + background: rgba(16, 16, 15, 0.9); + min-width: 110px; + flex-shrink: 0; + cursor: default; + transition: border-color 0.1s; +} + +.ciDfNode:hover { + border-color: var(--line); +} + +.ciDfArrow { + color: var(--muted); + font-size: 0.85rem; + flex-shrink: 0; + padding: 0 0.2rem; +} + +.ciDfIcon { + font-size: 1rem; + flex-shrink: 0; +} + +.ciDfBody { + display: flex; + flex-direction: column; +} + +.ciDfName { + font-size: 0.73rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.ciDfSub { + font-size: 0.66rem; + color: var(--muted); + white-space: nowrap; +} + +.ciDfCount { + font-size: 0.64rem; + color: var(--muted); + white-space: nowrap; +} + +/* ─── AI Ask panel ───────────────────────────────────────────── */ + +.ciAskPanel { + border-top: 1px solid rgba(45, 33, 26, 0.6); + flex-shrink: 0; + display: flex; + flex-direction: column; +} + +.ciAskHeader { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.5); +} + +.ciAskTitle { + font-size: 0.79rem; + font-weight: 700; + color: var(--text); +} + +.ciAskScope { + display: flex; + align-items: center; + gap: 0.3rem; + margin-left: auto; + color: var(--muted); + font-size: 0.71rem; +} + +.ciAskScopeBtn { + padding: 0.12rem 0.45rem; + border: 1px solid var(--line-dim); + border-radius: 2px; + background: transparent; + color: var(--muted); + font-size: 0.7rem; + cursor: pointer; +} + +.ciAskScopeBtn:hover, +.ciAskScopeBtn.active { + border-color: var(--orange); + color: var(--orange); +} + +.ciAskThread { + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; + flex: 1; + max-height: 220px; +} + +.ciAskMsg { + padding: 0.55rem 0.85rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.35); + display: flex; + gap: 0.6rem; +} + +.ciAskMsg.user { background: rgba(255, 90, 0, 0.03); } +.ciAskMsg.assistant { background: rgba(11, 11, 10, 0.9); } + +.ciAskAvatar { + font-size: 0.72rem; + font-weight: 700; + color: var(--muted); + white-space: nowrap; + padding-top: 0.05rem; + min-width: 36px; +} + +.ciAskMsg.user .ciAskAvatar { color: var(--orange); } + +.ciAskBody { + flex: 1; + min-width: 0; +} + +.ciAskText { + font-size: 0.77rem; + color: var(--text); + line-height: 1.55; +} + +.ciAskWarning { + display: flex; + align-items: flex-start; + gap: 0.4rem; + margin-top: 0.4rem; + padding: 0.4rem 0.55rem; + border: 1px solid rgba(255, 176, 46, 0.3); + border-radius: 2px; + background: rgba(255, 176, 46, 0.06); + font-size: 0.72rem; + color: var(--yellow); +} + +.ciAskWarningIcon { flex-shrink: 0; margin-top: 0.05rem; } + +.ciAskRefs { + display: flex; + flex-direction: column; + gap: 0.18rem; + margin-top: 0.45rem; +} + +.ciAskRef { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.69rem; + color: var(--orange); + text-decoration: none; +} + +.ciAskRef:hover { text-decoration: underline; } + +.ciAskRefLine { + color: var(--muted); + font-size: 0.67rem; +} + +.ciAskInput { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.85rem; + border-top: 1px solid rgba(45, 33, 26, 0.5); +} + +.ciAskInputField { + flex: 1; + background: rgba(18, 17, 15, 0.9); + border: 1px solid var(--line-dim); + border-radius: 2px; + color: var(--text); + font-size: 0.78rem; + padding: 0.38rem 0.6rem; +} + +.ciAskInputField::placeholder { + color: rgba(168, 156, 145, 0.45); +} + +.ciAskInputField:focus { + outline: 1px solid var(--orange); + border-color: var(--orange); +} + +.ciAskSend { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.38rem 0.7rem; + border: 1px solid var(--line-dim); + border-radius: 2px; + background: #10100f; + color: var(--muted); + font-size: 0.74rem; + cursor: pointer; + white-space: nowrap; +} + +.ciAskSend:hover { + border-color: var(--orange); + color: var(--orange); +} + +/* ─── Inspector panel ────────────────────────────────────────── */ + +.ciInspector { + grid-column: 3; + grid-row: 2; + display: flex; + flex-direction: column; + background: linear-gradient(180deg, #0d0d0c, #0a0a09); + border-left: 0; + overflow-y: auto; +} + +.ciInspectorHeader { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.6rem 0.9rem; + border-bottom: 1px solid var(--line); + flex-shrink: 0; +} + +.ciInspectorTitle { + font-size: 0.82rem; + font-weight: 700; + color: var(--text); +} + +.ciInspectorLang { + margin-left: auto; + padding: 0.1rem 0.45rem; + border: 1px solid rgba(59, 130, 246, 0.5); + border-radius: 2px; + color: #3b82f6; + font-size: 0.68rem; + font-weight: 600; +} + +.ciInspectorFile { + padding: 0.55rem 0.9rem; + border-bottom: 1px solid rgba(45, 33, 26, 0.5); +} + +.ciInspectorPath { + font-size: 0.74rem; + color: var(--orange); + word-break: break-all; +} + +.ciInspectorMeta { + display: flex; + gap: 0.9rem; + margin-top: 0.35rem; +} + +.ciInspectorMetaItem { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.ciInspectorMetaLabel { + font-size: 0.63rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ciInspectorMetaValue { + font-size: 0.76rem; + color: var(--text); + font-weight: 600; +} + +/* Inspector tabs */ + +.ciInspectorTabs { + display: flex; + border-bottom: 1px solid var(--line); + flex-shrink: 0; + overflow-x: auto; +} + +.ciTabBtn { + padding: 0.45rem 0.75rem; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted); + font-size: 0.73rem; + cursor: pointer; + white-space: nowrap; + transition: color 0.12s, border-color 0.12s; +} + +.ciTabBtn:hover { color: var(--text); } + +.ciTabBtn.active { + color: var(--orange); + border-bottom-color: var(--orange); + font-weight: 600; +} + +/* Inspector content sections */ + +.ciInspectorContent { + flex: 1; + overflow-y: auto; + padding: 0.75rem 0.9rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.ciSection { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.ciSectionTitle { + font-size: 0.71rem; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); + margin: 0; +} + +.ciSectionBody { + font-size: 0.77rem; + color: var(--text); + line-height: 1.55; +} + +/* API routes list */ + +.ciRouteList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.22rem; +} + +.ciRouteRow { + display: flex; + align-items: baseline; + gap: 0.5rem; + font-size: 0.74rem; + padding: 0.2rem 0; +} + +.ciRouteMethod { + font-family: "IBM Plex Mono", monospace; + font-size: 0.67rem; + font-weight: 700; + padding: 0.08rem 0.38rem; + border-radius: 2px; + min-width: 36px; + text-align: center; +} + +.ciRouteMethod.get { background: rgba(16, 182, 106, 0.15); color: var(--green); } +.ciRouteMethod.post { background: rgba(59, 130, 246, 0.15); color: #3b82f6; } +.ciRouteMethod.put { background: rgba(255, 176, 46, 0.15); color: var(--yellow); } +.ciRouteMethod.delete { background: rgba(255, 90, 0, 0.15); color: var(--orange); } +.ciRouteMethod.patch { background: rgba(77, 215, 209, 0.15); color: var(--cyan); } + +.ciRoutePath { + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + color: var(--text); +} + +.ciRouteDesc { + font-size: 0.69rem; + color: var(--muted); + margin-left: auto; +} + +/* Tech debt / risk items */ + +.ciRiskList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.ciRiskItem { + display: flex; + align-items: flex-start; + gap: 0.45rem; + font-size: 0.74rem; + color: var(--text); +} + +.ciRiskBullet { + flex-shrink: 0; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--line-dim); + margin-top: 0.38rem; +} + +.ciRiskBullet.high { background: var(--orange); } +.ciRiskBullet.medium { background: var(--yellow); } +.ciRiskBullet.low { background: var(--green); } + +/* AI assistant card in inspector */ + +.ciAiCard { + border: 1px solid rgba(45, 33, 26, 0.6); + border-radius: 3px; + overflow: hidden; +} + +.ciAiCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.7rem; + background: rgba(18, 17, 15, 0.8); + border-bottom: 1px solid rgba(45, 33, 26, 0.5); +} + +.ciAiCardTitle { + font-size: 0.74rem; + font-weight: 700; + color: var(--text); +} + +.ciAiCardPill { + font-size: 0.65rem; + padding: 0.08rem 0.4rem; + background: rgba(255, 90, 0, 0.1); + border: 1px solid rgba(255, 90, 0, 0.3); + border-radius: 10px; + color: var(--orange); +} + +.ciAiCardBody { + padding: 0.6rem 0.7rem; + font-size: 0.75rem; + color: var(--text); + line-height: 1.6; +} + +.ciAiCardBody code { + font-family: "IBM Plex Mono", monospace; + font-size: 0.7rem; + color: var(--orange-soft); + background: rgba(255, 90, 0, 0.07); + padding: 0.05rem 0.25rem; + border-radius: 2px; +} + +.ciAiCardFooter { + display: flex; + justify-content: flex-end; + padding: 0.4rem 0.7rem; + border-top: 1px solid rgba(45, 33, 26, 0.4); +} + +.ciOpenAiBtn { + font-size: 0.71rem; + color: var(--orange); + text-decoration: none; + padding: 0.15rem 0; +} + +.ciOpenAiBtn:hover { text-decoration: underline; } + +/* ─── Status / badge utilities ───────────────────────────────── */ + +.ciBadge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.4rem; + border-radius: 2px; + font-size: 0.67rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ciBadge.healthy { background: rgba(16, 182, 106, 0.12); color: var(--green); border: 1px solid rgba(16, 182, 106, 0.3); } +.ciBadge.warn { background: rgba(255, 176, 46, 0.12); color: var(--yellow); border: 1px solid rgba(255, 176, 46, 0.3); } +.ciBadge.error { background: rgba(255, 90, 0, 0.12); color: var(--orange); border: 1px solid rgba(255, 90, 0, 0.3); } +.ciBadge.info { background: rgba(59, 130, 246, 0.12); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.3); } +.ciBadge.neutral { background: rgba(168, 156, 145, 0.1); color: var(--muted); border: 1px solid rgba(168, 156, 145, 0.2); } + +/* ─── Responsive — tablet (< 1100px) ────────────────────────── */ + +@media (max-width: 1100px) { + #codebaseIntelligenceView { + grid-template-columns: 160px minmax(0, 1fr) 300px; + } + + .ciCapCard { + padding: 0.5rem 0.55rem; + } + + .ciCapDetail { + display: none; + } + + .ciInspectorContent { + padding: 0.6rem 0.7rem; + } +} + +/* ─── Responsive — hide inspector (< 900px) ─────────────────── */ + +@media (max-width: 900px) { + #codebaseIntelligenceView { + grid-template-columns: 150px minmax(0, 1fr); + } + + .ciInspector { + display: none; + } + + .ciInspector.visible { + display: flex; + position: fixed; + top: 64px; + right: 0; + bottom: 0; + width: 320px; + z-index: 200; + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.6); + border-left: 1px solid var(--line); + } +} + +/* ─── Responsive — collapse sidebar (< 680px) ───────────────── */ + +@media (max-width: 680px) { + #codebaseIntelligenceView { + grid-template-columns: 1fr; + } + + .ciMain, + .ciInspector { + grid-column: 1; + } + + .ciSidebar { + display: none; + } + + .ciSidebar.visible { + display: flex; + position: fixed; + top: 64px; + left: 0; + bottom: 0; + width: 220px; + z-index: 200; + border-right: 1px solid var(--line); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.6); + } + + .ciCapabilityBar { + flex-wrap: wrap; + } + + .ciCapCard { + flex: 1 1 45%; + border-right: 1px solid rgba(45, 33, 26, 0.7); + border-bottom: 1px solid rgba(45, 33, 26, 0.7); + } + + .ciGraphToolbar { + flex-wrap: wrap; + gap: 0.4rem; + } + + .ciGraphFilters { + order: 3; + width: 100%; + margin-right: 0; + } + + .ciDataFlowNodes { + padding-bottom: 0.3rem; + } +} diff --git a/templates/agent-work-app/index.html b/templates/agent-work-app/index.html index 3b6df9b..f7d5040 100644 --- a/templates/agent-work-app/index.html +++ b/templates/agent-work-app/index.html @@ -8,6 +8,7 @@ +
    @@ -19,8 +20,8 @@
    + + +
    +

    Parallel Execution

    +

    Local worksets, structured API artifacts, exclusive path leases, sequential integration, receipts, and current gaps.

    +
    + +
    @@ -357,6 +987,211 @@

    Kanji a Day

    +
    +
    + Docs + + Platform + + Parallel Execution +
    + +
    +
    + Platform / runner ecosystem +

    Parallel Execution Engine

    +

    Cento's current parallel runner is a contract-first local execution layer. It runs independent owned-path tasks concurrently, then returns every patch through one sequential integration and apply lane so evidence stays auditable.

    +
    + Local v1 shipped + API workers partial + Updated 2026-05-02 +
    +
    + +
    + +
    +
    StatusShipped local runnerWorkset v1 above Cento Build
    +
    Parallel boundaryWorker collectionIntegration and apply stay sequential
    +
    API cap6 requestsConfigured max parallel OpenAI requests
    +
    Budget guard$3 / $5Default target and hard max
    +
    Local profiles3 configuredcodex-fast, fixture-valid, python-fixture
    +
    + +
    +
    +
    + Execution flow +

    What actually runs in parallel

    +
    +

    Workers run together only while collecting proposed patches or structured artifacts. Repo mutation remains local and serialized.

    +
    +
    +
    + 1 + Plan and lease +

    workset.json declares tasks, explicit write_paths, simple dependencies, and a maximum parallelism cap.

    +
    +
    + 2 + Run workers +

    Fixture, local-command, or API workers run ready tasks concurrently when their dependencies have completed.

    +
    +
    + 3 + Collect artifacts +

    Local workers produce patch bundles. API workers produce structured JSON only; they never write repo files directly.

    +
    +
    + 4 + Integrate sequentially +

    cento build integrate checks ownership, protected paths, patch safety, and validation before acceptance.

    +
    +
    + 5 + Apply and record +

    Accepted patches apply one at a time, then Cento writes receipts, event logs, costs, and Taskstream evidence.

    +
    +
    +
    + +
    +
    +
    + Current capability +

    Done in the current ecosystem

    +
    +

    The shipped slice is deliberately narrow: it optimizes for bounded concurrency, explicit ownership, and receipts over broad autonomous fanout.

    +
    +
    +
    + Done +

    Single build package

    +

    cento build creates manifest-owned work packages, builder prompts, worker artifacts, patch bundles, integration receipts, apply receipts, validation receipts, and Taskstream evidence.

    +
    +
    + Done +

    Runtime profiles

    +

    cento runtime validates local profiles with argv arrays, scrubbed environment allowlists, timeouts, changed-file caps, and patch-line caps.

    +
    +
    + Done +

    Local workset runner

    +

    cento workset run executes exclusive-path tasks in isolated worktrees, then dry-runs and applies accepted patches through Cento Build.

    +
    +
    + Done +

    Structured execute path

    +

    cento workset execute supports fixture, local-command, and api-openai runtime families with dependency gates and sequential integration.

    +
    +
    + Done +

    API materialization guard

    +

    OpenAI workers return schema-checked JSON artifacts. A local materializer converts owned path contents into patch bundles and rejects unowned or protected paths.

    +
    +
    + Done +

    Budget and evidence receipts

    +

    API runs reserve cost before dispatch, enforce configured hard caps, and write cost receipts, worker receipts, workset receipts, evidence JSON, and events.ndjson.

    +
    +
    +
    + +
    +
    +
    + Boundaries +

    Partial or adjacent pieces

    +
    +

    These systems are related, but they should not be mistaken for a finished high-fanout execution platform.

    +
    +
    +
    + Factory runtime adapters +

    noop, local-shell-fixture, and codex-dry-run define prepare, launch, status, collect, and cancel contracts. Runtime v1 is contract-first and does not enable broad live execution.

    +
    +
    + Factory integration +

    Factory can plan, lease, collect, validate, dry-run integrate, prepare integration worktrees, apply one patch at a time, and render release evidence. It does not merge to main.

    +
    +
    + Agent pool kicker +

    cento agent-pool-kick can keep Taskstream builder, validator, small-task, and coordinator lanes moving with max-launch guardrails. It is not the same thing as a workset cloud scheduler.

    +
    +
    + Dev Pipeline Studio +

    The Studio visualizes worker contracts, claimed paths, validators, receipts, and evidence flow. It is a control surface, not the execution engine itself.

    +
    +
    +
    + +
    +
    +
    + Not done +

    What is intentionally not shipped yet

    +
    +

    These are still gaps in the Cento parallel runner ecosystem and should remain visible during planning.

    +
    +
    +
    No cloud worker pool in worksetWorkset execution is local. API workers call OpenAI for structured output, but repo mutation still happens locally.
    +
    No OpenAI Batch orchestrationThe API path uses Responses requests with retry limits; it does not submit or reconcile Batch API jobs.
    +
    No automatic task splitting from a screenshotManifests must be authored by the operator, Factory, or another planning layer before workset execution.
    +
    No shared-file parallel mergeOverlapping paths, glob write paths, and protected paths are rejected. Shared-file work must be a separate serialized integrator task.
    +
    No smart conflict resolverIf a patch fails policy or git apply --check, the affected task blocks while independent tasks can continue.
    +
    No automatic PR or main mergeBuild, Workset, and Factory write evidence and integration artifacts; they do not open PRs or merge releases automatically.
    +
    No automatic Taskstream DoneFactory sync previews Review or Blocked transitions. Human review and explicit status updates still close the loop.
    +
    +
    + +
    +
    +
    + Receipts +

    Evidence written per run

    +
    +

    The runner leaves an audit trail that explains the plan, each worker, every integration attempt, and the final status.

    +
    +
    + .cento/worksets/<run_id>/workset.json + .cento/worksets/<run_id>/leases.json + .cento/worksets/<run_id>/workset_receipt.json + .cento/worksets/<run_id>/workset_evidence.json + .cento/worksets/<run_id>/events.ndjson + .cento/worksets/<run_id>/workers/<worker_id>/artifact.json + .cento/worksets/<run_id>/workers/<worker_id>/cost_receipt.json + .cento/builds/workset_<run_id>_<task_id>/integration_receipt.json + .cento/builds/workset_<run_id>_<task_id>/apply_receipt.json + .cento/builds/workset_<run_id>_<task_id>/taskstream_evidence.json +
    +
    + +
    +
    +
    + CLI reference +

    Use this runner when paths are exclusive

    +
    +

    Start with validation. Use run for local profile workers and execute for the v1 runtime family shape.

    +
    +
    cento workset check tests/fixtures/cento_workset/workset.valid.json
    +cento workset run workset.json --max-workers 3 --runtime-profile codex-fast --apply sequential --validation smoke
    +cento workset execute workset.json --max-parallel 6 --runtime api-openai --budget-usd 3 --max-budget-usd 5 --integrate sequential --apply --validation smoke
    +cento workset materialize-artifact .cento/worksets/<run_id>/workers/<worker_id>/artifact.json
    +
    + +
    + Use It For +

    Docs pages, generated content slices, independent modules, validation experiments, and refactors where each worker owns explicit files. Do not use it for a single shared file, hidden global state, production deploys, or anything that needs cross-node execution without a higher-level Factory or Taskstream coordinator.

    +
    +
    +
    + +
    -
    +
    +
    + Software Delivery Hub +

    Taskstream

    +

    Agent work queue, review status, validation evidence, and handoffs.

    +
    + +
    +

    Agent Task

    @@ -971,8 +1938,20 @@

    Agent Task

    + Overview + Contracts Visualization + Execution Flow + Manifest Explorer + Evidence + Best Practices +
    @@ -487,26 +483,25 @@

    Dev Pipeline Studio Template Editor

    -
    -
    1
    1. Input2 artifacts
    +
    +
    1
    1. Input Contract2 artifacts
    Pipeline Manifestpipeline_manifest.jsonValid
    Workset Manifestworkset.jsonValid
    -
    -
    2
    2. Workers (Parallel)6 workers
    -
    Hero Section Workerhero.jsonCompleted
    -
    Body Sections Workersections.jsonCompleted
    -
    Metadata Workermetadata.jsonCompleted
    -
    Release Notes Workerrelease.jsonCompleted
    -
    Operations Workeroperations.jsonCompleted
    -
    Links Workerlinks.jsonCompleted
    - +
    +
    2
    2. Repo Discovery0 contracts
    + +
    + +
    +
    3
    3. Change Blueprint0 contracts
    +
    -
    -
    3
    3. Integration6 integration steps
    +
    +
    4
    4. Factory Execution6 execution steps
    Integrate: hero.jsonintegration_receipt.jsonAccepted
    Integrate: sections.jsonintegration_receipt.jsonAccepted
    Integrate: metadata.jsonintegration_receipt.jsonAccepted
    @@ -516,8 +511,8 @@

    Dev Pipeline Studio Template Editor

    -
    -
    4
    4. Validation3 validators
    +
    +
    5
    5. Deterministic Validation3 validators
    Smoke Validatorsmoke_receipt.jsonPassed
    Screenshot Validatorscreenshot_receipt.jsonPassed @@ -527,8 +522,8 @@

    Dev Pipeline Studio Template Editor

    -
    -
    5
    5. Evidence5 artifacts
    +
    +
    6
    6. Evidence / Handoff5 artifacts
    Pipeline Receiptpipeline_receipt.jsonCompleted
    Events Logevents.ndjson142 events
    Evidence Bundleevidence_bundle.jsonAttached
    @@ -541,29 +536,20 @@

    Dev Pipeline Studio Template Editor

    Pipeline Data Flow

    -
    Manifests

    Pipeline and workset manifests define tasks, ownership, and dependencies.

    pipeline_manifest.jsonworkset.json
    +
    Input Contract

    Task kind, surfaces, allowed changes, forbidden changes, and acceptance boundaries.

    input_manifest.jsonpipeline_manifest.json
    -
    Worker Artifacts

    Task workers produce scoped outputs with dependencies and owned paths.

    *.json artifactsworker_artifact.json
    +
    Repo Discovery

    Languages, test commands, ownership hints, and dependency graph sources are discovered deterministically.

    repo_context.jsonrepo_index
    -
    Integration Receipts

    Sequential integration ensures safe application.

    integration_receipt.json
    +
    Change Blueprint

    Structured change units and test units constrain AI use before Factory execution.

    change_plan.json
    -
    Validation Receipts

    Quality gates validate outputs.

    smoke_receipt.jsonscreenshot.png
    +
    Factory Execution

    Checkout, snapshot, apply, format, test, collect diff, and collect logs.

    execution_manifest.jsonintegration_receipt.json
    -
    Evidence

    Receipts, logs, and metrics for auditability.

    evidence.jsonpipeline_receipt.json
    +
    Deterministic Validation

    Cheap checks gate the result before optional AI review is allowed.

    validation_manifest.jsonvalidation_report.json
    + +
    Evidence / Handoff

    Diffs, logs, validation reports, acceptance maps, risk notes, and PR templates.

    evidence_manifest.jsonhandoff_packet.json
    -
    - Legend - Completed - In Progress - Queued - Blocked - Partial - ▧ Artifact - ▧ Receipt - ▧ Manifest -
    +
    +
    + Run Configuration + Provide the concrete answer this pipeline run should consume. Template presets can be layered on later. +
    + + + +
    No answer saved yet
    +
    Input manifest output pending
    @@ -707,11 +703,35 @@

    Pipeline Data Flow

    Config output pending Receipt output pending - +
    + + + No execution yet +
    +
    Select a validation card to configure it. +
    + Worker manifest loaded
    + + `; + } + return ` + + `; +} + +function renderRunPipelineInputControl(item = {}, index = 0, kind = pipelineInputType(item), source = pipelineInputSource(item)) { + const inputId = pipelineInputId(item, index); + const required = item.required !== false; + const requiredAttr = required ? " required" : ""; + if (source === "auto") { + const automation = pipelineInputAutomation(item); + return ` +
    + Automated${escapeHtml(automation ? `Generated by ${automation}` : "Resolved during pipeline execution")} +

    ${escapeHtml(item.evidence_policy || item.path_policy || item.detail || "Resolved during pipeline execution.")}

    +
    + `; + } + const initialValue = runPipelineInputInitialValue(item, index, kind); + const placeholder = runPipelineInputPlaceholder(kind, item); + if (inputId === "parallel-ui-config") { + return renderParallelConfigControl(item, index); + } + if (inputId === "multipipeline-schedule-config") { + return renderMultipipelineConfigControl(item, index); + } + if (inputId === "parallel-workstreams") { + return ` +
    + Auto-generated by defaultOpen only to override worker paths or provide JSON workstreams. + +
    + `; + } + if (kind === "questionnaire" && Array.isArray(item.questions) && item.questions.length) { + const isParallelObjective = inputId === "parallel-objective"; + const isMultipipelineObjective = inputId === "multipipeline-objective"; + return ` +
    + ${item.questions.map((question, questionIndex) => renderRunPipelineQuestionControl(question, index, questionIndex, required, isParallelObjective || isMultipipelineObjective ? { forceTextarea: true, rows: questionIndex === 2 ? 3 : 4 } : {})).join("")} +
    + `; + } + if (kind === "path" || kind === "evidence" || kind === "details" || kind === "questionnaire" || kind === "text") { + const rows = kind === "path" || kind === "evidence" ? 3 : 4; + return ` + + `; + } + if (kind === "image") { + return ` + + `; } + return ` + + `; } -const PIPELINE_INPUT_TYPES = { - text: { label: "Text", icon: "T", format: "plain text" }, - details: { label: "Details", icon: "D", format: "markdown" }, - image: { label: "Image", icon: "IMG", format: "image reference" }, - questionnaire: { label: "Questionnaire", icon: "Q", format: "structured answers" }, - path: { label: "Path target", icon: "P", format: "path list" }, - evidence: { label: "Evidence", icon: "E", format: "artifact list" } -}; - -function pipelineInputType(item) { - const raw = String(item?.kind || item?.input_type || item?.type || "text").trim().toLowerCase().replaceAll("_", "-"); - if (raw === "images" || raw === "screenshot" || raw === "mockup") return "image"; - if (raw === "question" || raw === "questions" || raw === "form") return "questionnaire"; - if (raw === "paths" || raw === "route" || raw === "routes" || raw === "command") return "path"; - if (raw === "artifact" || raw === "artifacts" || raw === "receipt") return "evidence"; - if (raw === "detail") return "details"; - return Object.prototype.hasOwnProperty.call(PIPELINE_INPUT_TYPES, raw) ? raw : "text"; +function renderRunPipelineInputCards() { + if (!runPipelineInputCards) return; + refreshRunPipelineTemplateSelect(); + const template = selectedRunPipelineTemplate(); + const inputs = currentRunPipelineInputs(); + const manualCount = inputs.filter((item) => pipelineInputSource(item) === "user" && item.required !== false).length; + const advancedCount = inputs.filter((item) => pipelineInputSource(item) === "user" && item.required === false).length; + const autoCount = inputs.filter((item) => pipelineInputSource(item) === "auto").length; + const project = pipelineProjectListForRun().find((item) => item.id === runPipelineProjectForTemplate(template.id || runPipelineSelectedTemplateId())); + if (runPipelineRouteTitle) { + runPipelineRouteTitle.textContent = template.label || template.id || "Selected route"; + } + if (runPipelineRouteDescription) { + runPipelineRouteDescription.textContent = `${manualCount} required manual input${manualCount === 1 ? "" : "s"}, ${advancedCount} advanced override${advancedCount === 1 ? "" : "s"}, ${autoCount} automated input${autoCount === 1 ? "" : "s"} resolved by the pipeline${project?.label ? ` · ${project.label}` : ""}.`; + } + if (!inputs.length) { + runPipelineInputCards.innerHTML = ` +
    + 0 +
    +
    + No manual inputs + This pipeline can run from its configured template contract. + Automated route +
    +
    +
    ReadyNo operator form fields required
    +
    +
    +
    + `; + return; + } + runPipelineInputCards.innerHTML = inputs.map((item, index) => { + const kind = pipelineInputType(item); + const source = pipelineInputSource(item); + const automation = pipelineInputAutomation(item); + const status = String(item.status || (source === "auto" ? "configured" : "missing")).toLowerCase(); + const inputId = pipelineInputId(item, index); + const requirement = item.required === false ? "Optional" : "Required"; + const variantClass = [ + inputId === "parallel-objective" ? "objective" : "", + inputId === "parallel-workstreams" ? "advanced" : "", + source === "auto" ? "collapsedAuto" : "", + ].filter(Boolean).join(" "); + return ` +
    + ${index + 1} +
    +
    + ${escapeHtml(item.title || inputId)} + ${escapeHtml(item.detail || item.evidence_policy || item.path_policy || "")} + ${escapeHtml(pipelineInputTypeLabel(kind))} · ${escapeHtml(source === "auto" ? `Auto${automation ? `: ${automation}` : ""}` : `${requirement} user input`)} +
    +
    + ${renderRunPipelineInputControl(item, index, kind, source)} +
    +
    +
    + `; + }).join(""); } -function pipelineInputTypeLabel(type) { - return PIPELINE_INPUT_TYPES[type]?.label || PIPELINE_INPUT_TYPES.text.label; +function runPipelineInputValue(index) { + const structured = runPipelineInputCards?.querySelector(`.runPipelineStructuredConfig[data-run-pipeline-input-index="${index}"]`); + if (structured) { + const values = {}; + structured.querySelectorAll("[data-run-pipeline-config]").forEach((control) => { + const key = control.getAttribute("data-run-pipeline-config") || ""; + if (key) values[key] = String(control.value || "").trim(); + }); + if (values.runtime !== "api-openai") { + values.budget_usd = "0.00"; + values.max_budget_usd = "0.00"; + } + if (structured.dataset.configProfile === "multipipeline") { + return [ + `passes: ${values.passes || MULTIPIPELINE_CONFIG_DEFAULTS.passes}`, + `child_pipeline: ${values.child_pipeline || MULTIPIPELINE_CONFIG_DEFAULTS.child_pipeline}`, + `execution_mode: ${values.execution_mode || MULTIPIPELINE_CONFIG_DEFAULTS.execution_mode}`, + `ui_screenshot: ${values.ui_screenshot || MULTIPIPELINE_CONFIG_DEFAULTS.ui_screenshot}`, + `pro_call: ${values.pro_call || MULTIPIPELINE_CONFIG_DEFAULTS.pro_call}`, + `handoff_policy: ${values.handoff_policy || MULTIPIPELINE_CONFIG_DEFAULTS.handoff_policy}`, + ].join("\n"); + } + return [ + `max_parallel: ${values.max_parallel || PARALLEL_CONFIG_DEFAULTS.max_parallel}`, + `runtime: ${values.runtime || PARALLEL_CONFIG_DEFAULTS.runtime}`, + `integrator: ${values.integrator || PARALLEL_CONFIG_DEFAULTS.integrator}`, + `validation: ${values.validation || PARALLEL_CONFIG_DEFAULTS.validation}`, + `apply_mode: ${values.apply_mode || PARALLEL_CONFIG_DEFAULTS.apply_mode}`, + `budget_usd: ${values.budget_usd || PARALLEL_CONFIG_DEFAULTS.budget_usd}`, + `max_budget_usd: ${values.max_budget_usd || PARALLEL_CONFIG_DEFAULTS.max_budget_usd}`, + ].join("\n"); + } + const control = runPipelineInputCards?.querySelector(`[data-run-pipeline-input-index="${index}"][data-run-pipeline-input-id]`); + return String(control?.value || "").trim(); } -function pipelineInputTypeIcon(type) { - return PIPELINE_INPUT_TYPES[type]?.icon || PIPELINE_INPUT_TYPES.text.icon; +function runPipelineQuestionAnswers(index) { + const answers = {}; + runPipelineInputCards?.querySelectorAll(`[data-run-pipeline-input-index="${index}"][data-run-pipeline-question-id]`).forEach((control) => { + const questionId = control.getAttribute("data-run-pipeline-question-id") || ""; + const value = String(control.value || "").trim(); + if (questionId && value) answers[questionId] = value; + }); + return answers; +} + +function runPipelineInputPayload(item, index) { + const inputId = pipelineInputId(item, index); + const kind = pipelineInputType(item); + const source = pipelineInputSource(item); + const base = { id: inputId, kind, source }; + const screenshotPath = runPipelineScreenshotInput?.value?.trim() || ""; + if (source === "auto") { + if (kind === "image" && inputId === "ui-screenshot-request" && screenshotPath) { + return { ...base, image_refs: [screenshotPath], image_notes: "Operator-provided optional screenshot context." }; + } + return base; + } + const manualValue = runPipelineInputValue(index); + const prompt = issueDescriptionInput.value.trim(); + const value = manualValue || (inputId === "operator-thoughts" ? prompt : ""); + if (kind === "questionnaire") { + const answers = runPipelineQuestionAnswers(index); + const answer = value || Object.entries(answers).map(([key, answerValue]) => `${key}: ${answerValue}`).join("\n"); + return { ...base, answer, answers }; + } + if (kind === "details" || kind === "text") return { ...base, answer: value }; + if (kind === "path") return { ...base, paths: pipelineTextToLines(value) }; + if (kind === "image") { + const imageRefs = pipelineTextToLines(value || screenshotPath); + return { ...base, image_refs: imageRefs, image_notes: value }; + } + if (kind === "evidence") return { ...base, artifacts: pipelineTextToLines(value), evidence_policy: value }; + return base; +} + +function syncRunPipelineStructuredConfig(control) { + const container = control?.closest?.(".runPipelineStructuredConfig"); + if (!container) return; + const runtime = container.querySelector('[data-run-pipeline-config="runtime"]')?.value || PARALLEL_CONFIG_DEFAULTS.runtime; + container.dataset.runtime = runtime; + if (runtime !== "api-openai") { + const budget = container.querySelector('[data-run-pipeline-config="budget_usd"]'); + const cap = container.querySelector('[data-run-pipeline-config="max_budget_usd"]'); + if (budget) budget.value = "0.00"; + if (cap) cap.value = "0.00"; + } +} + +function runPipelinePayload() { + const inputs = currentRunPipelineInputs(); + const templateId = runPipelineSelectedTemplateId(); + return { + schema_version: "cento.pipeline_run_request.v1", + project_id: runPipelineProjectForTemplate(templateId), + template_id: templateId, + inputs: inputs.map(runPipelineInputPayload), + }; } function pipelineElementIcon(name) { @@ -1334,7 +3334,9 @@ function renderPipelineInputCards(items) { if (!inputStage) return; inputStage.querySelectorAll(".pipelineCard").forEach((card) => card.remove()); const button = pipelineStageFooterButton(inputStage); - (items || []).forEach((item, index) => { + const inputItems = items || []; + inputStage.classList.toggle("inputSequenceList", inputItems.length > 1); + inputItems.forEach((item, index) => { const card = document.createElement("div"); const status = String(item.status || "Missing").toLowerCase(); const inputId = pipelineInputId(item, index); @@ -1342,20 +3344,29 @@ function renderPipelineInputCards(items) { const hasAnswer = pipelineInputAnswerPresent(item); card.className = `pipelineCard operatorInput ${status} inputType-${inputType} ${pipelineSelectedInputId === inputId ? "selected" : ""}`; card.dataset.inputId = inputId; + card.dataset.sequenceIndex = String(index + 1); + card.dataset.sequenceLast = String(index === inputItems.length - 1); card.setAttribute("role", "button"); card.setAttribute("tabindex", "0"); card.setAttribute("aria-pressed", String(pipelineSelectedInputId === inputId)); card.innerHTML = ` - ${escapeHtml(pipelineInputTypeIcon(inputType))} - ${escapeHtml(item.title || "")} - ${escapeHtml(item.detail || item.file || item.manifest || "")} - ${escapeHtml(pipelineInputTypeLabel(inputType))} - ${hasAnswer ? "Answer saved" : "Needs answer"} + ${index + 1} +
    +
    + ${escapeHtml(pipelineInputTypeIcon(inputType))} + ${escapeHtml(item.title || "")} +
    + ${escapeHtml(item.detail || item.file || item.manifest || "")} +
    + ${escapeHtml(pipelineInputTypeLabel(inputType))} + ${hasAnswer ? "Answer saved" : "Needs answer"} +
    +
    ${pipelineCardActionButtons("input", inputId, item.title || "input")} `; inputStage.insertBefore(card, button || null); }); - if (button) button.textContent = `View all (${(items || []).length})`; + if (button) button.textContent = `View all (${inputItems.length})`; } function pipelineValidatorId(item, index = 0) { @@ -1892,12 +3903,15 @@ function showPipelineInputInspector(inputId, message = "") { if (pipelineInspectorState) pipelineInspectorState.textContent = titleCasePipelineStatus(status); if (pipelineInputTitleInput) pipelineInputTitleInput.value = input.title || ""; if (pipelineInputTypeSelect) pipelineInputTypeSelect.value = pipelineInputType(input); + if (pipelineInputSourceSelect) pipelineInputSourceSelect.value = pipelineInputSource(input); if (pipelineInputDetailInput) pipelineInputDetailInput.value = input.detail || input.file || ""; if (pipelineInputStatusSelect) { pipelineInputStatusSelect.value = status; pipelineInputStatusSelect.dataset.initialStatus = status; } + if (pipelineInputAutomationInput) pipelineInputAutomationInput.value = pipelineInputAutomation(input); if (pipelineInputRequiredCheckbox) pipelineInputRequiredCheckbox.checked = input.required !== false; + if (pipelineInputMutedCheckbox) pipelineInputMutedCheckbox.checked = Boolean(input.muted || status === "muted" || input.blocking === false); if (pipelineInputFormatInput) pipelineInputFormatInput.value = input.format || PIPELINE_INPUT_TYPES[pipelineInputType(input)]?.format || ""; if (pipelineInputImageRefsInput) pipelineInputImageRefsInput.value = pipelineLinesToText(input.image_refs || input.images || input.references); if (pipelineInputImageNotesInput) pipelineInputImageNotesInput.value = input.image_notes || input.reference_notes || ""; @@ -1916,6 +3930,11 @@ function showPipelineInputInspector(inputId, message = "") { } if (pipelineInputManifestPath) pipelineInputManifestPath.textContent = input.manifest ? `Input manifest: ${input.manifest}` : "Input manifest output pending"; updatePipelineInputTypeEditors(pipelineInputType(input)); + renderPipelineImagePreviews(pipelineInputImagePreview, [ + ...pipelineValueList(input.image_refs || input.images || input.references), + ...pipelineValueList(input.artifacts || input.evidence_artifacts), + ...pipelineValueList(input.answer_values || input.provided_values || input.provided_paths), + ]); if (pipelineInputInspectorStatus) pipelineInputInspectorStatus.textContent = message || "Edit the input contract or provide run configuration, then save."; renderPipelineInputCards(pipelineStudioState?.pipeline?.input_cards || selectedPipelinePayloadTemplate()?.required_inputs || selectedPipelineStudioTemplate()?.requiredInputs || []); renderPipelineIntegrationCards(pipelineStudioState?.pipeline?.integration || []); @@ -1943,6 +3962,11 @@ function collectPipelineInputConfig() { detail: pipelineInputDetailInput?.value?.trim() || "", kind, input_type: kind, + source: pipelineInputSourceSelect?.value || selected.source || "user", + automation: pipelineInputAutomationInput?.value?.trim() || selected.automation || selected.automation_source || "", + automation_source: pipelineInputAutomationInput?.value?.trim() || selected.automation_source || selected.automation || "", + muted: Boolean(pipelineInputMutedCheckbox?.checked), + blocking: !Boolean(pipelineInputMutedCheckbox?.checked), status, required: Boolean(pipelineInputRequiredCheckbox?.checked), format: pipelineInputFormatInput?.value?.trim() || PIPELINE_INPUT_TYPES[kind]?.format || "", @@ -2240,6 +4264,10 @@ function showPipelineEvidenceInspector(evidenceId, message = "") { if (pipelineEvidenceNotesInput) pipelineEvidenceNotesInput.value = evidence.review_notes || ""; if (pipelineEvidenceConfigPath) pipelineEvidenceConfigPath.textContent = evidence.config ? `Config: ${evidence.config}` : "Config output pending"; if (pipelineEvidenceArtifactPath) pipelineEvidenceArtifactPath.textContent = evidence.path ? `Artifact: ${evidence.path}` : "Artifact output pending"; + renderPipelineImagePreviews(pipelineEvidenceArtifactPreview, [ + evidence.path || "", + ...pipelineValueList(evidence.required_sources), + ]); if (pipelineEvidenceInspectorStatus) pipelineEvidenceInspectorStatus.textContent = message || "Choose and configure this evidence artifact, then save outputs."; renderPipelineInputCards(pipelineStudioState?.pipeline?.input_cards || selectedPipelinePayloadTemplate()?.required_inputs || selectedPipelineStudioTemplate()?.requiredInputs || []); renderPipelineIntegrationCards(pipelineStudioState?.pipeline?.integration || []); @@ -2331,7 +4359,11 @@ function pipelineEditorPayload(action = "save", options = {}) { const selectedTemplateId = pipelineStudioState?.selected?.template_id || pipelineTemplateSelect?.value || ""; const workerStageLabel = selectedTemplateId === "generic-task" ? "2. Repo Discovery" - : (pipelineExecutionModelSelect?.value || template?.execution_model) === "ordered" + : selectedTemplateId === "hard-proreq-task" + ? "2. Cento Context" + : selectedTemplateId === "multipipeline-proreq-chain" + ? "2. Multipipeline Context" + : (pipelineExecutionModelSelect?.value || template?.execution_model) === "ordered" ? "2. Task Execution" : "2. Workers (Parallel)"; const payload = { @@ -2376,7 +4408,7 @@ function pipelineEditorPayload(action = "save", options = {}) { async function savePipelineDraft(action = "save", options = {}) { if (!pipelineProjectSelect || !pipelineTemplateSelect) return null; try { - setPipelineSaveStatus(action === "select_worker" ? "Selecting worker..." : action === "save_input" ? "Saving input contract..." : action === "save_integration" ? "Saving integration outputs..." : action === "save_validation" ? "Saving validation outputs..." : action === "run_validation" ? "Running validation..." : action === "save_evidence" ? "Saving evidence outputs..." : action === "add_element" ? "Adding pipeline element..." : action === "delete_element" ? "Removing pipeline element..." : "Saving pipeline draft..."); + setPipelineSaveStatus(action === "select_worker" ? "Selecting worker..." : action === "save_input" ? "Saving input contract..." : action === "save_integration" ? "Saving integration outputs..." : action === "save_validation" ? "Saving validation outputs..." : action === "run_validation" ? "Running validation..." : action === "run_delivery" || action === "run_execution_e2e" ? "Starting pipeline run..." : action === "save_evidence" ? "Saving evidence outputs..." : action === "add_element" ? "Adding pipeline element..." : action === "delete_element" ? "Removing pipeline element..." : "Saving pipeline draft..."); const response = await fetch(`${API_BASE}/dev-pipeline-studio`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -2386,7 +4418,7 @@ async function savePipelineDraft(action = "save", options = {}) { if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); normalizePipelineState(payload); applyPipelineStudioContext(); - const verb = action === "duplicate" ? "Duplicated" : action === "new" ? "Created" : action === "select_worker" ? "Selected" : action === "save_input" ? "Saved input" : action === "save_integration" ? "Saved integration" : action === "save_validation" ? "Saved validation" : action === "run_validation" ? "Ran validation" : action === "save_evidence" ? "Saved evidence" : action === "add_element" ? "Added element" : action === "delete_element" ? "Removed element" : "Saved"; + const verb = action === "duplicate" ? "Duplicated" : action === "new" ? "Created" : action === "select_worker" ? "Selected" : action === "save_input" ? "Saved input" : action === "save_integration" ? "Saved integration" : action === "save_validation" ? "Saved validation" : action === "run_validation" ? "Ran validation" : action === "run_delivery" || action === "run_execution_e2e" ? "Started pipeline run" : action === "save_evidence" ? "Saved evidence" : action === "add_element" ? "Added element" : action === "delete_element" ? "Removed element" : "Saved"; setPipelineSaveStatus(`${verb} ${payload.selected?.template_id || "pipeline"} at ${new Date().toLocaleTimeString()}`); return payload; } catch (error) { @@ -2514,6 +4546,7 @@ function applyPipelineStudioContext() { renderPipelineCards("data-pipeline-integrate", pipeline.integration || [], [["title", "title"]]); renderPipelineCards("data-pipeline-validator", pipeline.validators || [], [["title", "title"], ["file", "file"], ["status", "status"]]); renderPipelineCards("data-pipeline-evidence", pipeline.evidence || [], [["title", "title"], ["file", "file"], ["status", "status"]]); + renderPipelineExecutionFlow(); if (pipelineManifestCode) { pipelineManifestCode.textContent = JSON.stringify(inspector.manifest || {}, null, 2); } @@ -2622,20 +4655,38 @@ function applyPipelineStudioContext() { } async function loadPipelineStudioState() { + return loadPipelineStudioStateForRun(currentPipelineExecutionRunId); +} + +async function loadPipelineStudioStateForRun(runId = "") { if (!pipelineProjectSelect || !pipelineTemplateSelect) { applyPipelineStudioContext(); return; } - const project = pipelineProjectSelect.value || "generic-easy-medium-task"; - const template = pipelineTemplateSelect.value || "generic-task"; + const params = new URLSearchParams(location.search); + const routeProject = params.get("project") || ""; + const routeTemplate = params.get("template") || ""; + const routeRunId = params.get("run_id") || ""; + const project = routeProject || pipelineProjectSelect.value || "hard-proreq-project"; + const template = routeTemplate || pipelineTemplateSelect.value || "hard-proreq-task"; + if (routeProject && Array.from(pipelineProjectSelect.options).some((option) => option.value === routeProject)) { + pipelineProjectSelect.value = routeProject; + } + if (routeTemplate && Array.from(pipelineTemplateSelect.options).some((option) => option.value === routeTemplate)) { + pipelineTemplateSelect.value = routeTemplate; + } try { - const payload = await apiGetJson(`${API_BASE}/dev-pipeline-studio?project=${encodeURIComponent(project)}&template=${encodeURIComponent(template)}`); + const selectedRunId = runId || routeRunId; + const runQuery = selectedRunId ? `&run_id=${encodeURIComponent(selectedRunId)}` : ""; + const payload = await apiGetJson(`${API_BASE}/dev-pipeline-studio?project=${encodeURIComponent(project)}&template=${encodeURIComponent(template)}${runQuery}`); normalizePipelineState(payload); applyPipelineStudioContext(); + return payload; } catch (error) { console.warn("Dev Pipeline Studio backend unavailable; using local fallback.", error); pipelineStudioState = null; applyPipelineStudioContext(); + return null; } } @@ -2784,7 +4835,7 @@ function buildPipelineManifestExplorerData() { const root = pipelineStudioState?.root || "workspace/runs/dev-pipeline-studio/docs-pages/latest"; const generatedAt = pipelineStudioState?.generated_at || new Date().toISOString(); const pipeline = pipelineStudioState?.pipeline || {}; - const templateId = template.id || pipelineStudioState?.selected?.template_id || "generic-task"; + const templateId = template.id || pipelineStudioState?.selected?.template_id || "hard-proreq-task"; const inputCards = pipeline.input_cards || template.required_inputs || []; const workers = pipeline.workers || template.workers || []; const integrations = pipeline.integration || []; @@ -3326,6 +5377,10 @@ function setPipelineTab(tab, options = {}) { a.classList.toggle("active", a.dataset.pipelineTab === tab); }); const isExplorer = tab === "manifest-explorer"; + const isExecution = tab === "execution-flow"; + const studioRoot = document.querySelector("#dev-pipeline-studio"); + studioRoot?.classList.toggle("pipelineFlowMode", isExecution); + studioRoot?.classList.toggle("pipelineExplorerMode", isExplorer); document.querySelectorAll('.sdHubRailLinks a[href="/dev-pipeline-studio#manifest-explorer"]').forEach((link) => { link.classList.toggle("active", isExplorer); if (isExplorer) { @@ -3336,8 +5391,15 @@ function setPipelineTab(tab, options = {}) { }); document.querySelector("#dev-pipeline-studio > .pipelineHero")?.classList.toggle("hidden", isExplorer); document.querySelector("#dev-pipeline-studio > .pipelineContextPanel")?.classList.toggle("hidden", isExplorer); - document.querySelector("#dev-pipeline-studio .pipelineWorkbench")?.classList.toggle("hidden", isExplorer); + document.querySelector("#dev-pipeline-studio .pipelineWorkbench")?.classList.toggle("hidden", isExplorer || isExecution); + pipelineExecutionPage?.classList.toggle("hidden", !isExecution); manifestExplorerEl?.classList.toggle("hidden", !isExplorer); + if (isExecution) { + const runsDisclosure = document.querySelector(".pipelineExecutionRuns"); + const logsDisclosure = document.querySelector(".pipelineExecutionLogs"); + if (runsDisclosure) runsDisclosure.open = false; + if (logsDisclosure) logsDisclosure.open = false; + } if (options.updateHash) { const hash = PIPELINE_TAB_HASHES[tab] || PIPELINE_TAB_HASHES.contracts; history.replaceState(null, "", `/dev-pipeline-studio#${hash}`); @@ -3347,6 +5409,9 @@ function setPipelineTab(tab, options = {}) { initManifestExplorer(); } else if (isExplorer) { refreshManifestExplorer({ preserveSelection: true }); + } else if (isExecution) { + clearPipelineExecutionAnimation(); + renderPipelineExecutionFlow(); } } @@ -3368,7 +5433,7 @@ function initPipelineStudioControls() { pipelineTemplateLibrary.addEventListener("click", (event) => { const card = event.target.closest("[data-template-card]"); if (!card || !pipelineTemplateSelect) return; - pipelineTemplateSelect.value = card.dataset.templateCard || "generic-task"; + pipelineTemplateSelect.value = card.dataset.templateCard || "hard-proreq-task"; pipelineSelectedInputId = ""; pipelineSelectedIntegrationId = ""; pipelineSelectedValidationId = ""; @@ -3399,6 +5464,30 @@ function initPipelineStudioControls() { setPipelineTab(link.dataset.pipelineTab || "contracts", { updateHash: true }); }); } + if (pipelineExecutionPage) { + pipelineExecutionPage.addEventListener("click", (event) => { + const stageButton = event.target.closest("[data-execution-stage]"); + if (stageButton) { + selectPipelineExecutionStage(stageButton.dataset.executionStage || ""); + return; + } + const runButton = event.target.closest("[data-execution-run-id]"); + if (runButton) { + void loadPipelineExecutionRun(runButton.dataset.executionRunId || ""); + return; + } + const logButton = event.target.closest("[data-execution-log-filter]"); + if (logButton) setPipelineExecutionLogFilter(logButton.dataset.executionLogFilter || "all"); + }); + } + if (pipelineExecutionRunButton) { + pipelineExecutionRunButton.addEventListener("click", () => { + void runPipelineExecutionDelivery(); + }); + } + if (pipelineExecutionLogSearch) { + pipelineExecutionLogSearch.addEventListener("input", renderPipelineExecutionLogs); + } if (pipelineInspectorNav) { pipelineInspectorNav.addEventListener("click", (event) => { const link = event.target.closest("a[data-inspector-tab]"); @@ -3640,6 +5729,7 @@ function hideSoftwareDeliveryViews() { setOptionalHidden(homeView, true); setOptionalHidden(softwareDeliveryHubView, true); setOptionalHidden(devPipelineStudioView, true); + setOptionalHidden(patchSwarmView, true); } function hideResearchViews() { @@ -3650,6 +5740,7 @@ function hideResearchViews() { function routeFromLocation() { if (location.pathname === "/") return "home"; if (location.pathname === "/software-delivery-hub") return "software-delivery"; + if (location.pathname === "/patch-swarm" || location.pathname.startsWith("/patch-swarm/runs/")) return "patch-swarm"; if (location.pathname === "/dev-pipeline-studio") return "dev-pipeline-studio"; if (location.pathname === "/codebase-intelligence") return "codebase-intelligence"; if (location.pathname === "/review") return "review"; @@ -3667,7 +5758,7 @@ function hasMainRoute(route) { } function setSdHubRailActive(route) { - const activeRoute = route === "dev-pipeline-studio" || route === "factory" || route === "software-delivery" + const activeRoute = route === "dev-pipeline-studio" || route === "factory" || route === "software-delivery" || route === "patch-swarm" ? route : route === "issues" || route === "review" ? "issues" @@ -3701,7 +5792,7 @@ function setNavActive(route) { let activeMain = "taskstream"; if (activeRoute === "home") { activeMain = homeView ? "home" : "taskstream"; - } else if (activeRoute === "software-delivery" || activeRoute === "factory" || activeRoute === "issues" || activeRoute === "dev-pipeline-studio") { + } else if (activeRoute === "software-delivery" || activeRoute === "factory" || activeRoute === "issues" || activeRoute === "dev-pipeline-studio" || activeRoute === "patch-swarm") { activeMain = softwareDeliveryHubView ? "software-delivery" : activeRoute === "factory" ? "factory" : "taskstream"; } else if (activeRoute === "review") { activeMain = hasMainRoute("review") ? "review" : "taskstream"; @@ -3930,10 +6021,22 @@ function currentIssuePayloadFromDetail() { async function submitIssueForm(event) { event.preventDefault(); - const payload = issueFormPayload(); const issueId = issueIdInput.value ? Number.parseInt(issueIdInput.value, 10) : null; + if (!issueId) { + const response = await fetch(`${API_BASE}/pipeline-runs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(runPipelinePayload()), + }); + const result = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); + closeIssueModal(); + await openDefaultPipelineRouteFromIssue(result); + return; + } + const payload = issueFormPayload(); const response = await fetch(issueId ? `${API_BASE}/issues/${issueId}` : `${API_BASE}/issues`, { - method: issueId ? "PATCH" : "POST", + method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); @@ -3949,6 +6052,10 @@ async function submitIssueForm(event) { } } +function shouldAutoOpenPipelineRoute(payload) { + return Boolean(payload?.pipeline_route?.default && location.pathname !== "/dev-pipeline-studio"); +} + async function submitStatusTransition(event) { event.preventDefault(); const issueId = currentIssueIdFromDetail(); @@ -4514,6 +6621,10 @@ async function showDetail(issueId) { history.replaceState(null, "", `/issues/${issueId}`); try { const payload = await apiGetJson(`${API_BASE}/issues/${issueId}`); + if (shouldAutoOpenPipelineRoute(payload)) { + await openDefaultPipelineRouteFromIssue(payload); + return; + } renderDetail(payload); } catch (error) { showDetailError(error.message); @@ -4548,6 +6659,7 @@ function showHome() { setOptionalHidden(homeView, false); setOptionalHidden(softwareDeliveryHubView, true); setOptionalHidden(devPipelineStudioView, true); + setOptionalHidden(patchSwarmView, true); reviewView.classList.add("hidden"); detailView.classList.add("hidden"); listView.classList.add("hidden"); @@ -4578,6 +6690,7 @@ function showSoftwareDeliveryHub() { setOptionalHidden(homeView, true); setOptionalHidden(softwareDeliveryHubView, false); setOptionalHidden(devPipelineStudioView, true); + setOptionalHidden(patchSwarmView, true); reviewView.classList.add("hidden"); detailView.classList.add("hidden"); listView.classList.add("hidden"); @@ -4603,6 +6716,7 @@ function showDevPipelineStudio() { setOptionalHidden(homeView, true); setOptionalHidden(softwareDeliveryHubView, false); setOptionalHidden(devPipelineStudioView, false); + setOptionalHidden(patchSwarmView, true); reviewView.classList.add("hidden"); detailView.classList.add("hidden"); listView.classList.add("hidden"); @@ -4617,13 +6731,18 @@ function showDevPipelineStudio() { const hash = location.hash; setPipelineTab(pipelineTabFromHash(hash)); history.replaceState(null, "", `/dev-pipeline-studio${hash}`); - if (hash && pipelineTabFromHash(hash) !== "manifest-explorer") { + const cleanHash = String(hash || "").replace(/^#/, ""); + const isPipelineTabHash = Object.values(PIPELINE_TAB_HASHES).includes(cleanHash); + if (hash && !isPipelineTabHash) { window.requestAnimationFrame(() => { const target = document.querySelector(hash); if (target) target.scrollIntoView({ block: "start" }); }); } else { - window.scrollTo({ top: 0, left: 0 }); + window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, left: 0 }); + setTimeout(() => window.scrollTo({ top: 0, left: 0 }), 0); + }); } } @@ -5030,6 +7149,432 @@ async function loadFactory() { } } +function patchSwarmRunPathId() { + const match = location.pathname.match(/^\/patch-swarm\/runs\/([^/]+)/); + return match ? decodeURIComponent(match[1]) : ""; +} + +function patchSwarmStatusText(value, fallback = "-") { + const clean = String(value || fallback || "").trim(); + return clean ? clean.replaceAll("_", " ") : fallback; +} + +function patchSwarmRepoCanStart(repo) { + return Boolean(repo?.can_start) && Number(repo?.protected_dirty_count || 0) === 0; +} + +function patchSwarmRepoDirtyLabel(repo) { + if (!repo) return ""; + if (!repo.dirty) return "clean"; + const count = Number(repo.dirty_count || 0); + return `${count} dirty path${count === 1 ? "" : "s"}`; +} + +function patchSwarmRepoOptionLabel(repo) { + const state = patchSwarmRepoCanStart(repo) + ? `startable · ${patchSwarmRepoDirtyLabel(repo)}` + : `blocked${Number(repo.protected_dirty_count || 0) ? " · protected dirty" : ""}`; + return `${repo.name || repo.path} · ${state} · ${repo.path}`; +} + +function patchSwarmSortedRepos(repos) { + return [...(repos || [])].sort((left, right) => { + const leftStart = patchSwarmRepoCanStart(left) ? 0 : 1; + const rightStart = patchSwarmRepoCanStart(right) ? 0 : 1; + if (leftStart !== rightStart) return leftStart - rightStart; + return `${left.name || ""}\u0000${left.path || ""}`.localeCompare(`${right.name || ""}\u0000${right.path || ""}`); + }); +} + +function patchSwarmSelectedRepo() { + const selected = patchSwarmRepoSelect?.value || ""; + return patchSwarmRepos.find((repo) => repo.path === selected) || patchSwarmRepos[0] || null; +} + +function patchSwarmTaskReady() { + return Boolean(patchSwarmTask?.value.trim()); +} + +function patchSwarmCanSubmitStart() { + return patchSwarmRepoCanStart(patchSwarmSelectedRepo()) && patchSwarmTaskReady(); +} + +function patchSwarmSetStartStatus(state, message = "") { + if (!patchSwarmStartStatus) return; + const labels = { + ready: "Ready", + blocked: "Blocked", + task_required: "Task required", + starting: "Starting", + run_created: "Run created", + failed: "Failed", + }; + patchSwarmStartStatus.dataset.state = state; + patchSwarmStartStatus.innerHTML = `${escapeHtml(labels[state] || state)} ${escapeHtml(message)}`; +} + +function updatePatchSwarmStartControls({ preserveStatus = false } = {}) { + const repo = patchSwarmSelectedRepo(); + const mode = patchSwarmMode?.value || "fixture"; + const fixtureMessage = mode === "fixture" + ? "Fixture mode generates local candidate receipts with no API spend." + : "Live gated mode requires backend budget gates before real provider use."; + if (patchSwarmStartHint) { + patchSwarmStartHint.textContent = `${fixtureMessage} Generation does not mutate the selected repo.`; + } + if (!repo) { + if (patchSwarmStartButton) { + patchSwarmStartButton.disabled = true; + patchSwarmStartButton.title = "No local Git repos discovered."; + } + if (!preserveStatus) patchSwarmSetStartStatus("blocked", "No local Git repos were discovered."); + return false; + } + if (!patchSwarmRepoCanStart(repo)) { + const protectedPaths = Array.isArray(repo.protected_dirty) ? repo.protected_dirty.join(", ") : ""; + if (patchSwarmStartButton) { + patchSwarmStartButton.disabled = true; + patchSwarmStartButton.title = protectedPaths ? `Clear protected dirty paths first: ${protectedPaths}` : "Selected repo is blocked."; + } + if (!preserveStatus) { + patchSwarmSetStartStatus( + "blocked", + protectedPaths ? `Clear protected dirty paths first: ${protectedPaths}` : "The selected repo cannot start a run.", + ); + } + return false; + } + if (!patchSwarmTaskReady()) { + if (patchSwarmStartButton) { + patchSwarmStartButton.disabled = true; + patchSwarmStartButton.title = "Enter a task brief before starting."; + } + if (!preserveStatus) patchSwarmSetStartStatus("task_required", "Enter a task brief to start a fixture run."); + return false; + } + if (patchSwarmStartButton) { + patchSwarmStartButton.disabled = false; + patchSwarmStartButton.title = ""; + } + if (!preserveStatus) patchSwarmSetStartStatus("ready", "Start a safe fixture run for the selected repo."); + return true; +} + +function renderPatchSwarmRepoState() { + if (!patchSwarmRepoState) return; + const repo = patchSwarmSelectedRepo(); + if (!repo) { + patchSwarmRepoState.textContent = "No local Git repos discovered."; + patchSwarmRepoState.classList.add("blocked"); + patchSwarmRepoState.classList.remove("ready"); + updatePatchSwarmStartControls(); + return; + } + const canStart = patchSwarmRepoCanStart(repo); + const protectedPaths = Array.isArray(repo.protected_dirty) ? repo.protected_dirty : []; + const protectedText = protectedPaths.length ? `Protected dirty: ${protectedPaths.join(", ")}` : "No protected dirty paths."; + patchSwarmRepoState.innerHTML = ` + ${escapeHtml(repo.name || repo.path)} + ${escapeHtml(repo.branch || "unknown")} · ${escapeHtml(patchSwarmRepoDirtyLabel(repo))} · ${escapeHtml(canStart ? "startable" : "blocked")} + ${escapeHtml(protectedText)} + `; + patchSwarmRepoState.classList.toggle("blocked", !canStart); + patchSwarmRepoState.classList.toggle("ready", canStart); + updatePatchSwarmStartControls(); +} + +function renderPatchSwarmRepos(payload) { + const currentSelection = patchSwarmRepoSelect?.value || ""; + patchSwarmRepos = patchSwarmSortedRepos(Array.isArray(payload?.repos) ? payload.repos : []); + if (!patchSwarmRepoSelect) return; + patchSwarmRepoSelect.innerHTML = patchSwarmRepos.length + ? patchSwarmRepos.map((repo) => ``).join("") + : ``; + const previous = patchSwarmRepos.find((repo) => repo.path === currentSelection); + const preferred = previous && patchSwarmRepoCanStart(previous) + ? previous + : patchSwarmRepos.find(patchSwarmRepoCanStart) || patchSwarmRepos[0]; + if (preferred) patchSwarmRepoSelect.value = preferred.path; + renderPatchSwarmRepoState(); +} + +async function loadPatchSwarmRepos() { + if (!patchSwarmRepoSelect) return; + patchSwarmRepoState.textContent = "Loading repositories..."; + try { + renderPatchSwarmRepos(await apiGetJson(`${API_BASE}/patch-swarm/repos`)); + } catch (error) { + patchSwarmRepoState.textContent = error.message; + patchSwarmRepoState.classList.add("blocked"); + patchSwarmSetStartStatus("failed", error.message); + if (patchSwarmStartButton) patchSwarmStartButton.disabled = true; + } +} + +function renderPatchSwarmRunList(payload) { + patchSwarmRuns = Array.isArray(payload?.runs) ? payload.runs : []; + if (!patchSwarmRunList) return; + if (!patchSwarmRuns.length) { + patchSwarmRunList.innerHTML = `
    No Patch Swarm runs yet.
    `; + return; + } + patchSwarmRunList.innerHTML = patchSwarmRuns.slice(0, 12).map((run) => { + const active = patchSwarmDetail?.run?.run_id === run.run_id ? " active" : ""; + const repo = run.selected_repo || {}; + const isProductRun = Boolean(repo.path || repo.name); + const kind = isProductRun ? "product" : "legacy"; + const repoLabel = repo.name || repo.path || "legacy engine-only run"; + const status = patchSwarmStatusText(run.status, "unknown"); + const approval = patchSwarmStatusText(run.approval_status, "not approved"); + const apply = patchSwarmStatusText(run.apply_status, "not applied"); + return ` + + `; + }).join(""); +} + +async function loadPatchSwarmRuns() { + if (!patchSwarmRunList) return; + try { + const payload = await apiGetJson(`${API_BASE}/patch-swarm/runs`); + renderPatchSwarmRunList(payload); + } catch (error) { + patchSwarmRunList.innerHTML = `
    ${escapeHtml(error.message)}
    `; + } +} + +function patchSwarmSelectedCandidate() { + const candidates = Array.isArray(patchSwarmDetail?.candidates) ? patchSwarmDetail.candidates : []; + return candidates.find((candidate) => candidate.id === patchSwarmSelectedCandidateId) || null; +} + +function patchSwarmSelectedValidatedCandidates() { + const candidates = Array.isArray(patchSwarmDetail?.candidates) ? patchSwarmDetail.candidates : []; + const selectedIds = new Set((patchSwarmDetail?.integration?.selected_candidates || []).map(String)); + const selected = candidates.filter((candidate) => selectedIds.has(String(candidate.id))); + return selected.filter((candidate) => String(candidate.status || "") === "validated"); +} + +function patchSwarmCanApproveRun() { + const selectedIds = new Set((patchSwarmDetail?.integration?.selected_candidates || []).map(String)); + return selectedIds.size > 0 && patchSwarmSelectedValidatedCandidates().length === selectedIds.size; +} + +function updatePatchSwarmReviewActions() { + const canApprove = patchSwarmCanApproveRun(); + const approved = String(patchSwarmDetail?.run?.approval_status || patchSwarmDetail?.approval?.status || "") === "approved"; + const candidate = patchSwarmSelectedCandidate(); + if (patchSwarmApproveButton) { + patchSwarmApproveButton.disabled = !canApprove; + patchSwarmApproveButton.title = canApprove ? "" : "Approval requires selected candidates that passed validation."; + } + if (patchSwarmApplyButton) { + patchSwarmApplyButton.disabled = !approved; + patchSwarmApplyButton.title = approved ? "" : "Apply is available after supervised approval."; + } + if (patchSwarmRejectButton) { + patchSwarmRejectButton.disabled = !candidate; + patchSwarmRejectButton.title = candidate ? "" : "Select a candidate before rejecting."; + } +} + +function setPatchSwarmDetailPanelsVisible(visible) { + patchSwarmDetailEmpty?.classList.toggle("hidden", visible); + patchSwarmStatsPanel?.classList.toggle("hidden", !visible); + patchSwarmReviewGrid?.classList.toggle("hidden", !visible); + patchSwarmEvidence?.classList.toggle("hidden", !visible); +} + +function renderPatchSwarmEmptyDetail() { + patchSwarmDetail = null; + patchSwarmSelectedCandidateId = ""; + const title = document.querySelector("#patch-swarm-detail-title"); + if (title) title.textContent = "No run selected"; + if (patchSwarmRunSubtitle) { + patchSwarmRunSubtitle.textContent = "Start a fixture run with a startable repo, or select a recent product run."; + } + if (patchSwarmCandidateList) patchSwarmCandidateList.innerHTML = `
    No candidates loaded.
    `; + renderPatchSwarmDiff(null); + setPatchSwarmDetailPanelsVisible(false); + updatePatchSwarmReviewActions(); + renderPatchSwarmRunList({ runs: patchSwarmRuns }); +} + +function renderPatchSwarmDiff(candidate) { + if (!patchSwarmDiffPreview) return; + if (!candidate) { + patchSwarmSelectedCandidateId = ""; + patchSwarmDiffTitle.textContent = "Diff Preview"; + patchSwarmDiffMeta.textContent = "Select a candidate."; + patchSwarmDiffPreview.textContent = "No diff selected."; + updatePatchSwarmReviewActions(); + return; + } + patchSwarmSelectedCandidateId = candidate.id || ""; + patchSwarmDiffTitle.textContent = candidate.id || "Candidate"; + patchSwarmDiffMeta.textContent = `${candidate.provider || "provider"} · score ${candidate.score ?? "-"} · ${candidate.status || "unknown"}`; + patchSwarmDiffPreview.textContent = candidate.diff_preview || "Diff preview unavailable."; + document.querySelectorAll("[data-patch-swarm-candidate]").forEach((row) => { + row.classList.toggle("active", row.dataset.patchSwarmCandidate === patchSwarmSelectedCandidateId); + }); + updatePatchSwarmReviewActions(); +} + +function renderPatchSwarmCandidates(candidates) { + if (!patchSwarmCandidateList) return; + if (!candidates.length) { + patchSwarmCandidateList.innerHTML = `
    No candidates loaded.
    `; + renderPatchSwarmDiff(null); + return; + } + const selectedIds = new Set((patchSwarmDetail?.integration?.selected_candidates || []).map(String)); + patchSwarmCandidateList.innerHTML = candidates.slice(0, 80).map((candidate) => { + const selected = selectedIds.has(String(candidate.id)); + const rejected = candidate.decision === "rejected"; + return ` + + `; + }).join(""); + if (!patchSwarmSelectedCandidateId || !candidates.some((candidate) => candidate.id === patchSwarmSelectedCandidateId)) { + patchSwarmSelectedCandidateId = candidates[0]?.id || ""; + } + renderPatchSwarmDiff(patchSwarmSelectedCandidate()); +} + +function renderPatchSwarmDetail(payload) { + patchSwarmDetail = payload; + const run = payload?.run || {}; + const candidates = Array.isArray(payload?.candidates) ? payload.candidates : []; + document.querySelector("#patch-swarm-detail-title").textContent = run.run_id || "No run selected"; + patchSwarmRunSubtitle.textContent = run.task_brief || "Start or select a run to inspect ranked candidates."; + patchSwarmCandidateCount.textContent = String(run.candidate_count || 0); + patchSwarmSelectedCount.textContent = String(run.selected_count || 0); + patchSwarmValidationStatus.textContent = run.validation || "unknown"; + patchSwarmCost.textContent = `$${Number(run.estimated_cost_usd || 0).toFixed(6)}`; + patchSwarmApprovalStatus.textContent = run.approval_status || "not_approved"; + patchSwarmSelectedCandidateId = patchSwarmSelectedCandidateId || candidates[0]?.id || ""; + setPatchSwarmDetailPanelsVisible(true); + renderPatchSwarmCandidates(candidates); + const artifacts = run.artifacts || {}; + const repo = run.selected_repo || {}; + patchSwarmEvidence.innerHTML = ` + Decision report + Candidate index + ${escapeHtml(repo.path || run.run_dir || "")} + `; + updatePatchSwarmReviewActions(); + renderPatchSwarmRunList({ runs: patchSwarmRuns }); +} + +async function loadPatchSwarmDetail(runId) { + if (!runId) return; + patchSwarmSelectedCandidateId = ""; + const payload = await apiGetJson(`${API_BASE}/patch-swarm/runs/${encodeURIComponent(runId)}`); + renderPatchSwarmDetail(payload); + history.replaceState(null, "", `/patch-swarm/runs/${encodeURIComponent(runId)}`); +} + +async function submitPatchSwarmRun(event) { + event.preventDefault(); + const repo = patchSwarmSelectedRepo(); + if (!repo || !patchSwarmCanSubmitStart()) { + updatePatchSwarmStartControls(); + return; + } + patchSwarmSetStartStatus("starting", "Creating the fixture run and candidate receipts..."); + if (patchSwarmStartButton) patchSwarmStartButton.disabled = true; + const response = await fetch(`${API_BASE}/patch-swarm/runs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repo_path: repo.path, + task_brief: patchSwarmTask.value.trim(), + candidate_target: Number.parseInt(patchSwarmCandidateTarget.value, 10) || 30, + max_parallel_agents: Number.parseInt(patchSwarmMaxAgents.value, 10) || 3, + mode: patchSwarmMode.value || "fixture", + providers: patchSwarmProviders.value || "codex-exec,claude-code,api-openai", + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + patchSwarmSetStartStatus("failed", payload.error || `HTTP ${response.status}`); + updatePatchSwarmStartControls({ preserveStatus: true }); + return; + } + patchSwarmSetStartStatus("run_created", `Run ${payload.run?.run_id || ""} is ready for review.`); + updatePatchSwarmStartControls({ preserveStatus: true }); + await loadPatchSwarmRuns(); + renderPatchSwarmDetail(payload); + history.replaceState(null, "", `/patch-swarm/runs/${encodeURIComponent(payload.run?.run_id || "")}`); +} + +async function patchSwarmPostAction(action, body = {}) { + const runId = patchSwarmDetail?.run?.run_id; + if (!runId) return; + const response = await fetch(`${API_BASE}/patch-swarm/runs/${encodeURIComponent(runId)}/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + patchSwarmSetStartStatus("failed", payload.error || `HTTP ${response.status}`); + return; + } + renderPatchSwarmDetail(payload); + await loadPatchSwarmRuns(); +} + +async function showPatchSwarm(runId = patchSwarmRunPathId()) { + if (!patchSwarmView) { + showSoftwareDeliveryHub(); + return; + } + setNavActive("patch-swarm"); + document.body.classList.remove("reviewMode"); + document.body.classList.remove("studioMode"); + setOptionalHidden(homeView, true); + setOptionalHidden(softwareDeliveryHubView, true); + setOptionalHidden(devPipelineStudioView, true); + setOptionalHidden(patchSwarmView, false); + reviewView.classList.add("hidden"); + detailView.classList.add("hidden"); + listView.classList.add("hidden"); + clusterView.classList.add("hidden"); + consultingView.classList.add("hidden"); + factoryView.classList.add("hidden"); + docsView.classList.add("hidden"); + hideResearchViews(); + await loadPatchSwarmRepos(); + await loadPatchSwarmRuns(); + if (runId) { + await loadPatchSwarmDetail(runId).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); + renderPatchSwarmEmptyDetail(); + }); + } else { + renderPatchSwarmEmptyDetail(); + history.replaceState(null, "", "/patch-swarm"); + } +} + async function loadQueriesIntoSelect() { if (!savedQuerySelect) { savedQueries = []; @@ -5098,11 +7643,61 @@ document.querySelectorAll("[data-modal-close]").forEach((button) => { button.addEventListener("click", closeIssueModal); }); -newIssueButton.addEventListener("click", () => openIssueModal()); -headerNewIssueButton.addEventListener("click", () => openIssueModal()); +newIssueButton.addEventListener("click", () => void openRunPipelineModal()); +headerNewIssueButton.addEventListener("click", () => void openRunPipelineModal()); +quickRunPipelineButton?.addEventListener("click", () => void openRunPipelineModal()); +patchSwarmForm?.addEventListener("submit", (event) => void submitPatchSwarmRun(event).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); + updatePatchSwarmStartControls({ preserveStatus: true }); +})); +patchSwarmRefreshRepos?.addEventListener("click", () => void loadPatchSwarmRepos()); +patchSwarmRepoSelect?.addEventListener("change", renderPatchSwarmRepoState); +patchSwarmTask?.addEventListener("input", () => updatePatchSwarmStartControls()); +patchSwarmMode?.addEventListener("change", () => updatePatchSwarmStartControls()); +patchSwarmRunList?.addEventListener("click", (event) => { + const button = event.target.closest("[data-patch-swarm-run]"); + if (!button) return; + void loadPatchSwarmDetail(button.dataset.patchSwarmRun || "").catch((error) => { + patchSwarmSetStartStatus("failed", error.message); + }); +}); +patchSwarmCandidateList?.addEventListener("click", (event) => { + const button = event.target.closest("[data-patch-swarm-candidate]"); + if (!button) return; + const candidate = (patchSwarmDetail?.candidates || []).find((item) => item.id === button.dataset.patchSwarmCandidate); + renderPatchSwarmDiff(candidate); +}); +patchSwarmApproveButton?.addEventListener("click", () => void patchSwarmPostAction("approve", {}).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); +})); +patchSwarmApplyButton?.addEventListener("click", () => void patchSwarmPostAction("apply", { limit: 1, validate_each: true, use_factory: true }).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); +})); +patchSwarmRejectButton?.addEventListener("click", () => { + const candidate = patchSwarmSelectedCandidate(); + if (!candidate) return; + void patchSwarmPostAction("reject", { candidate_ids: [candidate.id], reason: "Rejected in Patch Swarm review." }).catch((error) => { + patchSwarmSetStartStatus("failed", error.message); + }); +}); detailEditButton.addEventListener("click", () => { if (detailPayload?.issue) openIssueModal(currentIssuePayloadFromDetail()); }); +runPipelineTemplateSelect?.addEventListener("change", () => { + currentRunPipelineTemplateId = runPipelineTemplateSelect.value || currentRunPipelineTemplateId; + if (pipelineTemplateSelect && Array.from(pipelineTemplateSelect.options).some((option) => option.value === currentRunPipelineTemplateId)) { + pipelineTemplateSelect.value = currentRunPipelineTemplateId; + } + const projectId = runPipelineProjectForTemplate(currentRunPipelineTemplateId); + if (pipelineProjectSelect && Array.from(pipelineProjectSelect.options).some((option) => option.value === projectId)) { + pipelineProjectSelect.value = projectId; + } + renderRunPipelineInputCards(); +}); +runPipelineInputCards?.addEventListener("change", (event) => { + const control = event.target.closest("[data-run-pipeline-config]"); + if (control) syncRunPipelineStructuredConfig(control); +}); issueForm.addEventListener("submit", (event) => void submitIssueForm(event).catch((error) => showDetailError(error.message))); statusForm.addEventListener("submit", (event) => void submitStatusTransition(event).catch((error) => showDetailError(error.message))); @@ -5254,6 +7849,8 @@ window.addEventListener("popstate", () => { showHome(); } else if (location.pathname === "/software-delivery-hub") { showSoftwareDeliveryHub(); + } else if (location.pathname === "/patch-swarm" || location.pathname.startsWith("/patch-swarm/runs/")) { + void showPatchSwarm(); } else if (location.pathname === "/dev-pipeline-studio") { showDevPipelineStudio(); } else if (location.pathname === "/review") { @@ -5291,6 +7888,8 @@ async function boot() { syncStateFromLocation(); setNavActive(); await loadQueriesIntoSelect(); + capturePrefilledIssuePromptFromUrl(); + window.setTimeout(openPrefilledIssueModalFromUrl, 0); if (agentSummary && agentCards) { await loadAgents(); window.setInterval(loadAgents, 30000); @@ -5307,6 +7906,10 @@ async function boot() { showSoftwareDeliveryHub(); return; } + if (location.pathname === "/patch-swarm" || location.pathname.startsWith("/patch-swarm/runs/")) { + await showPatchSwarm(); + return; + } if (location.pathname === "/dev-pipeline-studio") { showDevPipelineStudio(); return; @@ -5343,7 +7946,7 @@ async function boot() { }); return; } - if (location.pathname === "/issues") { + if (location.pathname === "/issues" || location.pathname === "/issues/new") { showList(); return; } diff --git a/templates/agent-work-app/index.html b/templates/agent-work-app/index.html index a60bb51..b2e6511 100644 --- a/templates/agent-work-app/index.html +++ b/templates/agent-work-app/index.html @@ -29,7 +29,7 @@
    Review queue - +
    @@ -78,14 +78,24 @@

    Software Delivery Hub

    Factory + + + Patch Swarm + Dev Pipeline Studio + + + Execution Flow +

    Cluster

    @@ -167,7 +177,7 @@

    Welcome to Cento Console

    System HealthHealthyAll systems nominal
    -
    Active Pipelines8 / 8Running
    +
    Active Workflows8Monitored
    Open Issues248Across all projects
    Cluster Nodes24Online
    Artifacts1,024Stored
    @@ -198,7 +208,7 @@

    Factory

    Taskstream

    -

    Track and manage work across agents, packages, and pipelines. Plan, prioritize, and ship with confidence.

    +

    Track and manage work across agents and packages. Plan, prioritize, and ship with confidence.

    Open
    248
    @@ -208,19 +218,19 @@

    Taskstream

    Open Taskstream - - + +
    -

    Dev Pipeline Studio

    -

    Design, visualize, and operate your development pipelines. From code to evidence.

    +

    Evidence Library

    +

    Review manifests, receipts, worker handoffs, release proof, and recorded delivery demos.

    -
    Pipelines
    8
    -
    Healthy
    8
    -
    Stages
    42
    -
    Success Rate
    98%
    +
    Bundles
    8
    +
    Demos
    5
    +
    Receipts
    61
    +
    Status
    Ready
    - Open Dev Pipeline Studio + Open Evidence
    @@ -243,10 +253,10 @@

    Platform Overview

    @@ -362,6 +375,7 @@

    Dev Pipeline Studio

    Taskstream Factory Dev Pipeline Studio + Execution Flow Manifest Explorer @@ -384,6 +398,8 @@

    Dev Pipeline Studio Template Editor