From a36ebcc24af4559c3584a7ba57b08e6e5d65b7c6 Mon Sep 17 00:00:00 2001 From: mashkovd Date: Wed, 23 Sep 2026 11:16:10 +0200 Subject: [PATCH 1/4] feat(roadmap): read-only epic status and ready work items (#333) mctl-api reads the RoadmapPublication that mctlhq/.github publishes to its roadmap-state branch and serves it; it never evaluates readiness. - internal/roadmap: verifies the publication (apiVersion, sha256 of every derived file, one ready set and one health per named manifest, ready list agreeing with item states) and selects from it. Anything unverified is refused; a failed publication is never replaced by a cached one. - gitops.Reader: Revision and ReadFiles, one consistent checkout per read. - REST: GET /api/v1/roadmap/{epics,epic-status,ready}; 503 means cannot say. - MCP: mctl_get_epic_status, mctl_get_ready_work_items (portal: disabled, pending an owner decision). - Every answer carries provenance: state/evaluator/source revisions, the capture time and its age. - Tests read a real publication copied byte for byte (testdata/live). Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G --- README.md | 5 + cmd/api/kill_switch_test.go | 28 + cmd/api/main.go | 36 + docs/portal-allowlist.json | 10 + internal/api/handlers_roadmap.go | 113 + internal/api/handlers_roadmap_test.go | 121 + internal/api/router.go | 11 + internal/gitops/reader.go | 40 + internal/gitops/reader_test.go | 41 + internal/mcp/annotations_test.go | 6 +- internal/mcp/roadmap.go | 98 + internal/mcp/server.go | 4 + internal/openapi/openapi.yaml | 159 + internal/roadmap/roadmap.go | 501 ++ internal/roadmap/roadmap_test.go | 427 ++ internal/roadmap/testdata/live/SOURCE | 3 + internal/roadmap/testdata/live/health.json | 4036 +++++++++++++++++ .../roadmap/testdata/live/publication.json | 180 + internal/roadmap/testdata/live/ready-set.json | 2743 +++++++++++ internal/roadmap/testdata/live/snapshot.json | 2698 +++++++++++ 20 files changed, 11258 insertions(+), 2 deletions(-) create mode 100644 cmd/api/kill_switch_test.go create mode 100644 internal/api/handlers_roadmap.go create mode 100644 internal/api/handlers_roadmap_test.go create mode 100644 internal/mcp/roadmap.go create mode 100644 internal/roadmap/roadmap.go create mode 100644 internal/roadmap/roadmap_test.go create mode 100644 internal/roadmap/testdata/live/SOURCE create mode 100644 internal/roadmap/testdata/live/health.json create mode 100644 internal/roadmap/testdata/live/publication.json create mode 100644 internal/roadmap/testdata/live/ready-set.json create mode 100644 internal/roadmap/testdata/live/snapshot.json diff --git a/README.md b/README.md index 2c54eefe..89890823 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,11 @@ docker run -p 8080:8080 mctl-api | `OAUTH_PREREGISTERED_CLIENTS` | JSON array of static public OAuth clients for counterparts that cannot register dynamically (the Cloudflare MCP portal): `[{"client_id":"…","client_name":"…","redirect_uris":["https://…"]}]`. Exact redirect match, no secret field, never evicted; a malformed value or an unknown key refuses startup | — | No | | `AUDIT_DB_URL` | PostgreSQL connection string (falls back to in-memory). `sslmode=disable` is upgraded to `require`, or `verify-full` when a CNPG CA is mounted. | — | No | | `HUMAN_INPUT_DB_URL` | PostgreSQL for the human-input delivery ledger (`POST /api/v1/human-input/{request_id}/response`). Falls back to `AUDIT_DB_URL`; with neither, that endpoint answers 503 (there is no in-memory fallback). The raw answer is kept only until its delivery resolves, or until the request expires. | — | No | +| `ROADMAP_STATE_REPO_URL` | Repository holding the generated roadmap publication read by `/api/v1/roadmap/*` and the `mctl_get_epic_status` / `mctl_get_ready_work_items` tools. mctl-api only reads it; it never evaluates readiness. | `https://github.com/mctlhq/.github.git` | No | +| `ROADMAP_STATE_BRANCH` | Branch of the publication. | `roadmap-state` | No | +| `ROADMAP_STATE_LOCAL_PATH` | Local checkout, refreshed every 5 minutes. | `/tmp/roadmap-state` | No | +| `ROADMAP_STATE_TOKEN` | Credential for a private publication repository; the default repository is public. | — | No | +| `ROADMAP_STATE_DISABLED` | Kill switch: any value except `false`/`0`/`no`/`off` leaves the roadmap reader off (routes answer 503). | unset | No | | `TRUSTED_PROXY_CIDRS` | Comma-separated Traefik CIDRs/IPs trusted for `X-Forwarded-For` on audit events | — | No | | `ALLOW_INSECURE_DB` | Permit `sslmode=disable` (tests/local only) | — | No | | `BACKSTAGE_URL` | Backstage catalog URL | — | No | diff --git a/cmd/api/kill_switch_test.go b/cmd/api/kill_switch_test.go new file mode 100644 index 00000000..200ff265 --- /dev/null +++ b/cmd/api/kill_switch_test.go @@ -0,0 +1,28 @@ +// Copyright 2025 MCTL Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import "testing" + +func TestKillSwitchErrsTowardOff(t *testing.T) { + for v, want := range map[string]bool{ + "": false, "false": false, "0": false, "no": false, "off": false, " FALSE ": false, + "true": true, "1": true, "yes": true, "on": true, "disabled": true, + } { + if got := killSwitchOn(v); got != want { + t.Errorf("killSwitchOn(%q) = %v, want %v", v, got, want) + } + } +} diff --git a/cmd/api/main.go b/cmd/api/main.go index 21999c4e..a466a8a8 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -48,6 +48,7 @@ import ( "github.com/mctlhq/mctl-api/internal/loki" mctlmcp "github.com/mctlhq/mctl-api/internal/mcp" "github.com/mctlhq/mctl-api/internal/operations" + "github.com/mctlhq/mctl-api/internal/roadmap" "github.com/mctlhq/mctl-api/internal/temporalclient" "github.com/mctlhq/mctl-api/internal/usage" "github.com/mctlhq/mctl-api/internal/vault" @@ -240,6 +241,24 @@ func main() { } } + // Roadmap read model (mctl-api#333). A second gitops.Reader on the + // generated roadmap-state branch of mctlhq/.github: mctl-api only reads + // the published RoadmapPublication, it never runs the evaluator. The + // repository is public, so no credential is needed; ROADMAP_STATE_TOKEN + // exists for a private fork. ROADMAP_STATE_DISABLED (any value but false/0/no/off) turns it off and + // the roadmap endpoints answer 503. + var roadmapGit *gitops.Reader + var roadmapReader *roadmap.Reader + if !killSwitchOn(os.Getenv("ROADMAP_STATE_DISABLED")) { + roadmapGit = gitops.NewReader( + envOr("ROADMAP_STATE_REPO_URL", "https://github.com/mctlhq/.github.git"), + envOr("ROADMAP_STATE_BRANCH", "roadmap-state"), + envOr("ROADMAP_STATE_LOCAL_PATH", "/tmp/roadmap-state"), + ghtoken.Static(os.Getenv("ROADMAP_STATE_TOKEN")), "", "", + ) + roadmapReader = roadmap.NewReader(roadmapGit) + } + // Model usage / cost ledger (mctl-api#266, ADR-012). Optional — enabled // when USAGE_DB_URL or AUDIT_DB_URL is set. A nil store makes the usage // endpoints 503; see requireUsageAdmin for why that must not degrade to an @@ -565,6 +584,7 @@ func main() { AlertStore: alertStore, AgentRegistry: agentRegistryStore, Lifecycle: lifecycleStore, + Roadmap: roadmapReader, Usage: usageStore, DomainStore: domainStore, DomainVerifier: domainVerifier, @@ -598,6 +618,11 @@ func main() { ctx, cancel := context.WithCancel(rootCtx) defer cancel() go gitReader.RefreshLoop(ctx, 60*time.Second) + if roadmapGit != nil { + // The publication changes at most every couple of hours; freshness is + // the capture time it carries, not how often this pulls. + go roadmapGit.RefreshLoop(ctx, 5*time.Minute) + } // Close out audit rows the Argo completion webhook cannot reach. The hook's // HMAC secret exists only in the argo-workflows namespace, so operations @@ -1147,3 +1172,14 @@ func splitCSV(v string) []string { } return out } + +// killSwitchOn reads a kill-switch value. It errs toward off: any value +// except an explicit "false"/"0"/"no"/"off" (or empty) turns the feature off, +// so "yes" or "disabled" never silently leaves it on. +func killSwitchOn(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "", "false", "0", "no", "off": + return false + } + return true +} diff --git a/docs/portal-allowlist.json b/docs/portal-allowlist.json index ea4a3226..7afdfb78 100644 --- a/docs/portal-allowlist.json +++ b/docs/portal-allowlist.json @@ -92,6 +92,16 @@ "name": "mctl_get_agent", "enabled": false }, + { + "name": "mctl_get_epic_status", + "enabled": false, + "reason": "Read-only roadmap epic status from the published RoadmapPublication (public mctlhq/.github data, no credentials). Not yet enabled on the shared portal: that is an owner decision, not part of mctl-api#333." + }, + { + "name": "mctl_get_ready_work_items", + "enabled": false, + "reason": "Read-only list of ready roadmap work items from the published RoadmapPublication (public mctlhq/.github data, no credentials). Not yet enabled on the shared portal: that is an owner decision, not part of mctl-api#333." + }, { "name": "mctl_get_dev_loop", "enabled": true, diff --git a/internal/api/handlers_roadmap.go b/internal/api/handlers_roadmap.go new file mode 100644 index 00000000..4106e622 --- /dev/null +++ b/internal/api/handlers_roadmap.go @@ -0,0 +1,113 @@ +// Copyright 2025 MCTL Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/mctlhq/mctl-api/internal/roadmap" +) + +// Roadmap read model (mctl-api#333). Every answer here is selected from the +// RoadmapPublication that mctlhq/.github publishes to its roadmap-state +// branch; nothing is evaluated, inferred or written. A publication that is +// missing or fails verification answers 503, which means "cannot say", never +// "nothing is ready". + +const ( + roadmapCodeUnavailable = "roadmap_unavailable" + roadmapCodeEpicNotFound = "epic_not_found" + roadmapCodeInvalid = "invalid_request" +) + +func (h *Handlers) roadmapPublication(w http.ResponseWriter) (*roadmap.Publication, bool) { + pub, err := h.opts.Roadmap.Current() + if err != nil { + slog.Warn("roadmap publication unavailable", "error", err) + writeErrorCode(w, http.StatusServiceUnavailable, roadmapCodeUnavailable, err.Error(), nil) + return nil, false + } + return pub, true +} + +func writeRoadmapLookupError(w http.ResponseWriter, err error) { + if errors.Is(err, roadmap.ErrEpicNotFound) { + writeErrorCode(w, http.StatusNotFound, roadmapCodeEpicNotFound, err.Error(), nil) + return + } + writeError(w, http.StatusInternalServerError, "roadmap lookup failed") +} + +// ListRoadmapEpics handles GET /api/v1/roadmap/epics. +func (h *Handlers) ListRoadmapEpics(w http.ResponseWriter, r *http.Request) { + pub, ok := h.roadmapPublication(w) + if !ok { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "epics": pub.Epics(), + "provenance": pub.Provenance(time.Now()), + }) +} + +// GetRoadmapEpicStatus handles GET /api/v1/roadmap/epic-status?epic=. +func (h *Handlers) GetRoadmapEpicStatus(w http.ResponseWriter, r *http.Request) { + epic := strings.TrimSpace(r.URL.Query().Get("epic")) + if epic == "" { + writeErrorCode(w, http.StatusBadRequest, roadmapCodeInvalid, "epic is required: a name or a root issue such as mctlhq/.github#57", nil) + return + } + pub, ok := h.roadmapPublication(w) + if !ok { + return + } + status, err := pub.EpicStatus(epic, time.Now()) + if err != nil { + writeRoadmapLookupError(w, err) + return + } + writeJSON(w, http.StatusOK, status) +} + +// GetRoadmapReadyWorkItems handles GET /api/v1/roadmap/ready?epic=&required_only=. +// Without epic it answers for every active epic. required_only defaults to +// true: the safe wave-selection default. +func (h *Handlers) GetRoadmapReadyWorkItems(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + requiredOnly := true + if v := strings.TrimSpace(q.Get("required_only")); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, roadmapCodeInvalid, "required_only must be true or false", nil) + return + } + requiredOnly = b + } + pub, ok := h.roadmapPublication(w) + if !ok { + return + } + ready, err := pub.ReadyWorkItems(q.Get("epic"), requiredOnly, time.Now()) + if err != nil { + writeRoadmapLookupError(w, err) + return + } + writeJSON(w, http.StatusOK, ready) +} diff --git a/internal/api/handlers_roadmap_test.go b/internal/api/handlers_roadmap_test.go new file mode 100644 index 00000000..9bb39f58 --- /dev/null +++ b/internal/api/handlers_roadmap_test.go @@ -0,0 +1,121 @@ +// Copyright 2025 MCTL Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/mctlhq/mctl-api/internal/auth" + "github.com/mctlhq/mctl-api/internal/roadmap" +) + +type roadmapDirSource struct{ dir string } + +func (s roadmapDirSource) Revision() (string, error) { return "0123abc", nil } +func (s roadmapDirSource) ReadFiles(names ...string) (map[string][]byte, string, error) { + out := map[string][]byte{} + for _, n := range names { + data, err := os.ReadFile(filepath.Join(s.dir, n)) + if err != nil { + return nil, "", err + } + out[n] = data + } + return out, "0123abc", nil +} + +func roadmapRouter(h *Handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/v1/roadmap/epics", h.ListRoadmapEpics) + r.Get("/api/v1/roadmap/epic-status", h.GetRoadmapEpicStatus) + r.Get("/api/v1/roadmap/ready", h.GetRoadmapReadyWorkItems) + return r +} + +func roadmapGet(t *testing.T, h *Handlers, path string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req = req.WithContext(auth.WithUser(req.Context(), auth.NewGitHubUser("alice", []string{"acme"}))) + rec := httptest.NewRecorder() + roadmapRouter(h).ServeHTTP(rec, req) + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + return rec.Code, body +} + +func liveRoadmapHandlers() *Handlers { + src := roadmapDirSource{dir: filepath.Join("..", "roadmap", "testdata", "live")} + return &Handlers{opts: Options{Roadmap: roadmap.NewReader(src)}} +} + +func TestRoadmap_UnconfiguredOrBrokenAnswers503(t *testing.T) { + for name, h := range map[string]*Handlers{ + "unconfigured": {}, + "broken": {opts: Options{Roadmap: roadmap.NewReader(roadmapDirSource{dir: t.TempDir()})}}, + } { + for _, path := range []string{"/api/v1/roadmap/epics", "/api/v1/roadmap/ready", "/api/v1/roadmap/epic-status?epic=x"} { + if code, body := roadmapGet(t, h, path); code != http.StatusServiceUnavailable || body["code"] != "roadmap_unavailable" { + t.Errorf("%s %s = %d %v", name, path, code, body) + } + } + } +} + +func TestRoadmap_EpicStatusAndReady(t *testing.T) { + h := liveRoadmapHandlers() + code, body := roadmapGet(t, h, "/api/v1/roadmap/epic-status?epic=mctlhq%2F.github%2357") + if code != http.StatusOK { + t.Fatalf("epic-status = %d %v", code, body) + } + epic := body["epic"].(map[string]any) + if epic["name"] == "" || epic["lifecycle"] == "" || body["readiness"] == nil || body["health"] == nil { + t.Fatalf("epic-status body = %v", body) + } + prov := body["provenance"].(map[string]any) + if prov["state_revision"] != "0123abc" || prov["age_seconds"] == nil { + t.Fatalf("provenance = %v", prov) + } + + if code, _ := roadmapGet(t, h, "/api/v1/roadmap/epic-status"); code != http.StatusBadRequest { + t.Errorf("missing epic = %d", code) + } + if code, body := roadmapGet(t, h, "/api/v1/roadmap/epic-status?epic=nope"); code != http.StatusNotFound || body["code"] != "epic_not_found" { + t.Errorf("unknown epic = %d %v", code, body) + } + if code, _ := roadmapGet(t, h, "/api/v1/roadmap/ready?required_only=maybe"); code != http.StatusBadRequest { + t.Errorf("bad required_only = %d", code) + } + + count := func(body map[string]any) int { + n := 0 + for _, e := range body["epics"].([]any) { + n += len(e.(map[string]any)["items"].([]any)) + } + return n + } + _, def := roadmapGet(t, h, "/api/v1/roadmap/ready") + _, req := roadmapGet(t, h, "/api/v1/roadmap/ready?required_only=true") + _, all := roadmapGet(t, h, "/api/v1/roadmap/ready?required_only=false") + if def["required_only"] != true || count(def) != count(req) || count(all) <= count(req) { + t.Fatalf("required_only default/true/false = %d/%d/%d", count(def), count(req), count(all)) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 708e6120..3d1a4bb6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -34,6 +34,7 @@ import ( mctlmcp "github.com/mctlhq/mctl-api/internal/mcp" "github.com/mctlhq/mctl-api/internal/openapi" "github.com/mctlhq/mctl-api/internal/operations" + "github.com/mctlhq/mctl-api/internal/roadmap" "github.com/mctlhq/mctl-api/internal/usage" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -106,6 +107,10 @@ type Options struct { // which callers treat as "unknown", never as "unowned"). Lifecycle *lifecycle.Store + // Roadmap serves the RoadmapPublication read model (mctl-api#333). + // Optional: nil makes the roadmap endpoints 503. + Roadmap *roadmap.Reader + // Usage is the durable model-usage and cost ledger (mctl-api#266, // ADR-012). Optional — nil makes the usage endpoints 503, which a caller // must read as "the ledger is unavailable" and never as "nothing was @@ -428,6 +433,12 @@ func NewRouter(opts Options) http.Handler { r.Get("/human-input", h.ListHumanInputs) r.Get("/human-input/{request_id}", h.GetHumanInput) + // Roadmap read model (mctl-api#333): read-only, selected from the + // published RoadmapPublication, never evaluated here. + r.Get("/roadmap/epics", h.ListRoadmapEpics) + r.Get("/roadmap/epic-status", h.GetRoadmapEpicStatus) + r.Get("/roadmap/ready", h.GetRoadmapReadyWorkItems) + // Lifecycle ownership READS. Deliberately OUTSIDE the write // group above, for the same reason the dev-loop liveness read // is: the shepherd calls these once per sweep and an operator diff --git a/internal/gitops/reader.go b/internal/gitops/reader.go index c14dfd6f..801b4396 100644 --- a/internal/gitops/reader.go +++ b/internal/gitops/reader.go @@ -759,6 +759,46 @@ func (r *Reader) LastSync() time.Time { return r.lastSync } +// Revision returns the commit the checkout is on. +func (r *Reader) Revision() (string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + return r.revisionLocked() +} + +func (r *Reader) revisionLocked() (string, error) { + out, err := r.gitOutput(nil, "rev-parse", "HEAD") + if err != nil { + return "", fmt.Errorf("reading checkout revision: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// ReadFiles reads top-level files of the checkout together with the commit +// they came from. Holding the read lock across all of them means a refresh +// cannot land in between: the files and the revision are one checkout. +// Names are plain file names; anything with a path separator is refused. +func (r *Reader) ReadFiles(names ...string) (map[string][]byte, string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + rev, err := r.revisionLocked() + if err != nil { + return nil, "", err + } + out := make(map[string][]byte, len(names)) + for _, name := range names { + if name == "" || name != filepath.Base(name) || name == "." || name == ".." { + return nil, "", fmt.Errorf("invalid file name %q", name) + } + data, err := os.ReadFile(filepath.Join(r.localPath, name)) + if err != nil { + return nil, "", err + } + out[name] = data + } + return out, rev, nil +} + // ListPlatformSkills reads all platform-wide skills from // platform-gitops/platform-skills/catalog. func (r *Reader) ListPlatformSkills() ([]PlatformSkill, error) { diff --git a/internal/gitops/reader_test.go b/internal/gitops/reader_test.go index 96921ded..759f7e9d 100644 --- a/internal/gitops/reader_test.go +++ b/internal/gitops/reader_test.go @@ -1422,3 +1422,44 @@ func TestListHumanInputRequests(t *testing.T) { t.Fatalf("files = %v", seen) } } + +func TestReadFilesReturnsOneCheckoutAndRefusesPaths(t *testing.T) { + root := t.TempDir() + remote := filepath.Join(root, "remote.git") + work := filepath.Join(root, "work") + cache := filepath.Join(root, "cache") + runGit(t, root, "init", "--bare", remote) + runGit(t, root, "clone", remote, work) + runGit(t, work, "checkout", "-b", "roadmap-state") + runGit(t, work, "config", "user.email", "test@example.com") + runGit(t, work, "config", "user.name", "Test User") + if err := os.WriteFile(filepath.Join(work, "publication.json"), []byte(`{"v":1}`), 0o600); err != nil { + t.Fatal(err) + } + commitAll(t, work, "publish") + runGit(t, work, "push", "origin", "roadmap-state") + want := strings.TrimSpace(runGit(t, work, "rev-parse", "HEAD")) + + r := &Reader{repoURL: remote, branch: "roadmap-state", localPath: cache} + if err := r.refresh(); err != nil { + t.Fatal(err) + } + files, rev, err := r.ReadFiles("publication.json") + if err != nil { + t.Fatal(err) + } + if rev != want || string(files["publication.json"]) != `{"v":1}` { + t.Fatalf("rev=%s files=%q, want rev %s", rev, files, want) + } + if got, err := r.Revision(); err != nil || got != want { + t.Fatalf("Revision() = %s, %v", got, err) + } + for _, bad := range []string{"../remote.git/HEAD", ".git/config", "", ".", ".."} { + if _, _, err := r.ReadFiles(bad); err == nil { + t.Errorf("ReadFiles(%q) was allowed", bad) + } + } + if _, _, err := r.ReadFiles("missing.json"); err == nil { + t.Error("a missing file read as present") + } +} diff --git a/internal/mcp/annotations_test.go b/internal/mcp/annotations_test.go index b5fa3485..74d1e42b 100644 --- a/internal/mcp/annotations_test.go +++ b/internal/mcp/annotations_test.go @@ -45,6 +45,8 @@ var recordedHints = map[string]hints{ "mctl_enable_tenant_skill": {readOnly: false, destructive: false, idempotent: true}, "mctl_get_agent": {readOnly: true, destructive: false, idempotent: false}, "mctl_get_dev_loop": {readOnly: true, destructive: false, idempotent: false}, + "mctl_get_epic_status": {readOnly: true, destructive: false, idempotent: false}, + "mctl_get_ready_work_items": {readOnly: true, destructive: false, idempotent: false}, "mctl_get_incident": {readOnly: true, destructive: false, idempotent: false}, "mctl_get_lifecycle_ownership": {readOnly: true, destructive: false, idempotent: false}, "mctl_inspect_lifecycle_conflict": {readOnly: true, destructive: false, idempotent: false}, @@ -235,8 +237,8 @@ func TestEveryToolDeclaresBothHintsInSource(t *testing.T) { // spelled out so the allowlist can be copied rather than derived. func TestReadOnlyToolsAreTheRecordedSet(t *testing.T) { want := []string{ - "mctl_get_agent", "mctl_get_dev_loop", "mctl_get_incident", "mctl_get_lifecycle_ownership", - "mctl_get_openclaw_sizing_recommendation", "mctl_get_operation", + "mctl_get_agent", "mctl_get_dev_loop", "mctl_get_epic_status", "mctl_get_incident", "mctl_get_lifecycle_ownership", + "mctl_get_openclaw_sizing_recommendation", "mctl_get_operation", "mctl_get_ready_work_items", "mctl_get_resource_usage", "mctl_get_service_config", "mctl_get_service_logs", "mctl_get_service_status", "mctl_get_tenant", "mctl_get_workflow_logs", "mctl_get_workflow_status", "mctl_incident_summary", "mctl_inspect_lifecycle_conflict", diff --git a/internal/mcp/roadmap.go b/internal/mcp/roadmap.go new file mode 100644 index 00000000..75aa5e75 --- /dev/null +++ b/internal/mcp/roadmap.go @@ -0,0 +1,98 @@ +// Copyright 2025 MCTL Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + "fmt" + "net/url" + "strings" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// Roadmap read tools (mctl-api#333). Both wrap the REST read model, which +// selects from the RoadmapPublication mctlhq/.github publishes; neither +// evaluates readiness or touches GitHub. + +const roadmapProvenanceNote = `Every answer carries provenance: the roadmap-state commit, the evaluator and manifest revisions, the observation's capturedAt and its age in seconds. The answer is only as fresh as that capture — judge it before acting. An error saying the publication is unavailable means "cannot say", never "nothing is ready".` + +func (s *Server) toolGetEpicStatus() (mcplib.Tool, server.ToolHandlerFunc) { + tool := mcplib.NewTool("mctl_get_epic_status", + mcplib.WithTitleAnnotation("Roadmap epic status"), + mcplib.WithReadOnlyHintAnnotation(true), + mcplib.WithDestructiveHintAnnotation(false), + mcplib.WithDescription(`Status of one roadmap epic (an EpicDefinition in mctlhq/.github), from the published RoadmapPublication: the epic's lifecycle, title and goal, its manifest path and digest, the RoadmapReadySet (every work item's state — complete, ready, blocked or unknown — with typed blockers and the issue it is bound to) and the RoadmapHealth (allRequired completion and drift diagnostics), both exactly as published. + +Read-only: it never labels, approves, starts a DevLoop or writes to GitHub. Readiness is the Roadmap Control Plane's answer, not recomputed here; an unbound or unobserved item is "unknown", which is never ready. + +`+roadmapProvenanceNote), + mcplib.WithString("epic", + mcplib.Required(), + mcplib.Description(`Epic name (e.g. "lifecycle-ownership") or its root issue (e.g. "mctlhq/.github#57").`), + ), + ) + handler := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + epic := strings.TrimSpace(stringArg(req, "epic")) + if epic == "" { + return mcplib.NewToolResultError("missing required argument: epic"), nil + } + body, err := s.apiGet(ctx, "/api/v1/roadmap/epic-status?"+url.Values{"epic": {epic}}.Encode()) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("Failed to read epic status: %v", err)), nil + } + return mcplib.NewToolResultText(string(body)), nil + } + return tool, handler +} + +func (s *Server) toolGetReadyWorkItems() (mcplib.Tool, server.ToolHandlerFunc) { + tool := mcplib.NewTool("mctl_get_ready_work_items", + mcplib.WithTitleAnnotation("Ready roadmap work items"), + mcplib.WithReadOnlyHintAnnotation(true), + mcplib.WithDestructiveHintAnnotation(false), + mcplib.WithDescription(`Roadmap work items that are ready to start now, from the published RoadmapPublication: for one epic, or for every epic whose lifecycle is "active" when no epic is given. Each item is the published ready-set entry (id, required, repository/issue, phase, dependsOn), grouped under its epic with the manifest it came from. + +required_only defaults to true — the safe wave-selection default. Several items may be ready at once when the dependency graph allows it; choosing between them is a priority decision this tool does not make. Read-only: it never starts or approves anything. + +`+roadmapProvenanceNote), + mcplib.WithString("epic", + mcplib.Description(`Epic name or root issue ("mctlhq/.github#42"). Omit for every active epic.`), + ), + mcplib.WithString("required_only", + mcplib.Description(`"false" to include optional items. Defaults to true.`), + ), + ) + handler := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + q := url.Values{} + if epic := strings.TrimSpace(stringArg(req, "epic")); epic != "" { + q.Set("epic", epic) + } + if v := strings.TrimSpace(stringArg(req, "required_only")); v != "" { + q.Set("required_only", v) + } + path := "/api/v1/roadmap/ready" + if len(q) > 0 { + path += "?" + q.Encode() + } + body, err := s.apiGet(ctx, path) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("Failed to read ready work items: %v", err)), nil + } + return mcplib.NewToolResultText(string(body)), nil + } + return tool, handler +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index f85e8fad..88568c2f 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -256,6 +256,10 @@ func (s *Server) NewMCPServer() *server.MCPServer { srv.AddTool(s.toolRetryLifecycleHandoff()) srv.AddTool(s.toolListRecentAgentRuns()) + // Roadmap read model (mctl-api#333). + srv.AddTool(s.toolGetEpicStatus()) + srv.AddTool(s.toolGetReadyWorkItems()) + // Cloudflare MCP portal (dispatch only — no Cloudflare credential here). srv.AddTool(s.toolTriggerPortalServerAuthApply()) diff --git a/internal/openapi/openapi.yaml b/internal/openapi/openapi.yaml index 1dcd541e..e94336e8 100644 --- a/internal/openapi/openapi.yaml +++ b/internal/openapi/openapi.yaml @@ -529,6 +529,45 @@ components: received_at: type: string + RoadmapEpic: + type: object + properties: + name: {type: string} + lifecycle: {type: string, enum: [proposed, active, paused, completed, archived]} + title: {type: string} + goal: {type: string} + issue: + type: object + properties: + repository: {type: string} + number: {type: integer} + manifest: + type: object + properties: + path: {type: string} + sha256: {type: string} + RoadmapProvenance: + type: object + properties: + state_revision: + type: string + description: The roadmap-state commit this answer was read from. + evaluator_revision: {type: string} + source: + type: object + properties: + repository: {type: string} + ref: {type: string} + revision: {type: string} + observation: + type: object + properties: + mode: {type: string, enum: [live-capture, synthetic-fixture]} + capturedAt: {type: string, format: date-time} + apiBase: {type: string} + age_seconds: + type: integer + description: Seconds since observation.capturedAt; absent when the observation has no capture time. Error: type: object properties: @@ -2135,6 +2174,124 @@ paths: schema: $ref: '#/components/schemas/Error' + /api/v1/roadmap/epics: + get: + summary: List the epics in the roadmap publication + description: | + Read-only view of the RoadmapPublication mctlhq/.github publishes to + its generated `roadmap-state` branch (mctlhq/.github#119). mctl-api + verifies the publication's digests and serves it; it never evaluates + readiness. 503 means no publication could be verified: "cannot say", + never "nothing is ready". + tags: [Roadmap] + responses: + '200': + description: Every published epic with its lifecycle, and the publication's provenance + content: + application/json: + schema: + type: object + properties: + epics: + type: array + items: + $ref: '#/components/schemas/RoadmapEpic' + provenance: + $ref: '#/components/schemas/RoadmapProvenance' + '503': + description: No verified roadmap publication + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v1/roadmap/epic-status: + get: + summary: Status of one roadmap epic + description: | + The published RoadmapReadySet and RoadmapHealth for one epic, byte for + byte, with the epic's lifecycle, title, goal and manifest digest. + tags: [Roadmap] + parameters: + - name: epic + in: query + required: true + schema: {type: string} + description: 'Epic name, or its root issue as `owner/repo#N` (URL-encode the `#`).' + responses: + '200': + description: Epic status + content: + application/json: + schema: + type: object + properties: + epic: + $ref: '#/components/schemas/RoadmapEpic' + readiness: + type: object + description: The published RoadmapReadySet document for this epic. + health: + type: object + description: The published RoadmapHealth document for this epic. + provenance: + $ref: '#/components/schemas/RoadmapProvenance' + '400': + description: Missing epic + '404': + description: No epic by that name or root issue in the publication + '503': + description: No verified roadmap publication + /api/v1/roadmap/ready: + get: + summary: Roadmap work items ready now + description: | + Ready-set items the publication lists as ready, verbatim, grouped by + epic. Without `epic`, every epic whose lifecycle is `active`. + Choosing among several ready items is a priority decision this + endpoint does not make. + tags: [Roadmap] + parameters: + - name: epic + in: query + required: false + schema: {type: string} + description: Epic name or root issue; omit for every active epic. + - name: required_only + in: query + required: false + schema: {type: boolean, default: true} + responses: + '200': + description: Ready work items + content: + application/json: + schema: + type: object + properties: + required_only: {type: boolean} + epics: + type: array + items: + type: object + properties: + epic: + $ref: '#/components/schemas/RoadmapEpic' + source: + type: object + description: The epic's observation block from the ready set. + items: + type: array + items: + type: object + description: A published ready-set item. + provenance: + $ref: '#/components/schemas/RoadmapProvenance' + '400': + description: Invalid required_only + '404': + description: No epic by that name or root issue in the publication + '503': + description: No verified roadmap publication /api/v1/human-input: get: summary: List human-input requests the caller may answer @@ -2435,5 +2592,7 @@ tags: description: Platform operation registry and execution - name: Platform Skills description: Platform-wide skill catalog and tenant bindings + - name: Roadmap + description: Read-only roadmap publication (epic status and ready work items) - name: Agent Registry description: mctl-agents AgentManifest versions, releases, and promote/rollback diff --git a/internal/roadmap/roadmap.go b/internal/roadmap/roadmap.go new file mode 100644 index 00000000..7d32a479 --- /dev/null +++ b/internal/roadmap/roadmap.go @@ -0,0 +1,501 @@ +// Copyright 2025 MCTL Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package roadmap is a read-only view of the RoadmapPublication that +// mctlhq/.github publishes to its generated `roadmap-state` branch +// (mctlhq/.github#119). +// +// It never evaluates anything. Readiness, completion and health are the +// Roadmap Control Plane's answers, computed once from one observation; this +// package verifies that the published files are the ones the publication +// names, then selects from them. An item is ready here only because the +// published ready set says so. Anything it cannot verify, it refuses to serve. +package roadmap + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// APIVersion is the only publication version this reader understands. +const APIVersion = "roadmap.mctl.ai/v1alpha1" + +// Published file names, flat at the root of the roadmap-state branch. +const ( + PublicationFile = "publication.json" + SnapshotFile = "snapshot.json" + ReadySetFile = "ready-set.json" + HealthFile = "health.json" +) + +// Evaluator item states. The reader never assigns one; it only reads them. +const ( + StateComplete = "complete" + StateReady = "ready" + StateBlocked = "blocked" + StateUnknown = "unknown" +) + +// LifecycleActive is the EpicDefinition lifecycle selected by "all epics". +const LifecycleActive = "active" + +var ( + // ErrUnavailable: there is no publication this reader can trust. It means + // "cannot say", never "nothing is ready". + ErrUnavailable = errors.New("roadmap publication unavailable") + // ErrEpicNotFound: the publication has no epic by that name or root issue. + ErrEpicNotFound = errors.New("epic not found in the roadmap publication") +) + +// FileSource reads the published files. ReadFiles returns every name from one +// consistent checkout, together with that checkout's commit. +type FileSource interface { + Revision() (string, error) + ReadFiles(names ...string) (map[string][]byte, string, error) +} + +// IssueRef is a GitHub issue as the evaluator names it. +type IssueRef struct { + Repository string `json:"repository"` + Number int `json:"number"` +} + +func (r IssueRef) String() string { return r.Repository + "#" + strconv.Itoa(r.Number) } + +// ManifestRef is the EpicDefinition file an answer was derived from. +type ManifestRef struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +// Epic is an epic's identity and lifecycle as the publication records it. +type Epic struct { + Name string `json:"name"` + Lifecycle string `json:"lifecycle"` + Title string `json:"title"` + Goal string `json:"goal"` + Issue *IssueRef `json:"issue,omitempty"` + Manifest ManifestRef `json:"manifest"` +} + +// Observation is the single GitHub observation everything was derived from. +type Observation struct { + Mode string `json:"mode"` + CapturedAt *time.Time `json:"capturedAt,omitempty"` + APIBase string `json:"apiBase,omitempty"` +} + +// Provenance says where an answer came from and how old it is. Every +// response carries it, so a caller can judge freshness itself. +type Provenance struct { + StateRevision string `json:"state_revision"` + EvaluatorRevision string `json:"evaluator_revision"` + Source SourceRef `json:"source"` + Observation Observation `json:"observation"` + // AgeSeconds is now minus observation.capturedAt; absent when the + // observation carries no capture time (a synthetic fixture is never fresh). + AgeSeconds *int64 `json:"age_seconds,omitempty"` +} + +// SourceRef is where the manifests were read. +type SourceRef struct { + Repository string `json:"repository"` + Ref string `json:"ref"` + Revision string `json:"revision"` +} + +// EpicStatus is the composition of the published views for one epic. +type EpicStatus struct { + Epic Epic `json:"epic"` + // Readiness and Health are the published RoadmapReadySet and RoadmapHealth + // documents for this epic, byte for byte. + Readiness json.RawMessage `json:"readiness"` + Health json.RawMessage `json:"health"` + Provenance Provenance `json:"provenance"` +} + +// ReadyEpic is one epic's executable candidates. +type ReadyEpic struct { + Epic Epic `json:"epic"` + // Source is the epic's own observation block from the ready set. + Source json.RawMessage `json:"source"` + // Items are the published ready-set items, verbatim, whose id the ready + // set lists as ready. + Items []json.RawMessage `json:"items"` +} + +// ReadyWorkItems answers "what work is ready now". +type ReadyWorkItems struct { + RequiredOnly bool `json:"required_only"` + Epics []ReadyEpic `json:"epics"` + Provenance Provenance `json:"provenance"` +} + +// Publication is one verified, parsed publication. +type Publication struct { + revision string + prov Provenance + epics []*epicEntry // manifest path order, as published +} + +type epicEntry struct { + epic Epic + readiness json.RawMessage + health json.RawMessage + source json.RawMessage + items []item +} + +type item struct { + id string + required bool + state string + raw json.RawMessage +} + +// Wire shapes, only as much as verification and selection need. +type wirePublication struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Evaluator struct { + Revision string `json:"revision"` + } `json:"evaluator"` + Source SourceRef `json:"source"` + Manifests []struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Epic *struct { + Name string `json:"name"` + Lifecycle string `json:"lifecycle"` + Title string `json:"title"` + Goal string `json:"goal"` + } `json:"epic"` + } `json:"manifests"` + Observation Observation `json:"observation"` + Files map[string]struct { + SHA256 string `json:"sha256"` + } `json:"files"` +} + +type wireList struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Items []json.RawMessage `json:"items"` +} + +type wireEpicDoc struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Epic struct { + Name string `json:"name"` + Issue *IssueRef `json:"issue"` + Manifest ManifestRef `json:"manifest"` + } `json:"epic"` + Source json.RawMessage `json:"source"` + Items []json.RawMessage `json:"items"` + Ready []string `json:"ready"` +} + +type wireItem struct { + ID string `json:"id"` + Required *bool `json:"required"` + State string `json:"state"` +} + +func unavailable(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrUnavailable, fmt.Sprintf(format, args...)) +} + +// Parse verifies and parses one publication. files must hold all four +// published files; revision is the roadmap-state commit they came from. +func Parse(files map[string][]byte, revision string) (*Publication, error) { + for _, name := range []string{PublicationFile, SnapshotFile, ReadySetFile, HealthFile} { + if _, ok := files[name]; !ok { + return nil, unavailable("%s is missing", name) + } + } + var pub wirePublication + if err := json.Unmarshal(files[PublicationFile], &pub); err != nil { + return nil, unavailable("%s: %v", PublicationFile, err) + } + if pub.APIVersion != APIVersion || pub.Kind != "RoadmapPublication" { + return nil, unavailable("unsupported publication %s/%s", pub.APIVersion, pub.Kind) + } + if pub.Evaluator.Revision == "" || pub.Source.Revision == "" { + return nil, unavailable("publication carries no evaluator or source revision") + } + // The digests are the publication's claim about the other three files; + // a file that does not match it is not part of this publication. + for _, name := range []string{SnapshotFile, ReadySetFile, HealthFile} { + want := pub.Files[name].SHA256 + sum := sha256.Sum256(files[name]) + if want == "" || hex.EncodeToString(sum[:]) != want { + return nil, unavailable("%s does not match the digest in %s", name, PublicationFile) + } + } + if pub.Observation.Mode == "live-capture" && pub.Observation.CapturedAt == nil { + return nil, unavailable("a live capture without capturedAt") + } + if len(pub.Manifests) == 0 { + return nil, unavailable("publication covers no manifests") + } + + ready, err := parseList(files[ReadySetFile], "RoadmapReadySetList", "RoadmapReadySet") + if err != nil { + return nil, unavailable("%s: %v", ReadySetFile, err) + } + health, err := parseList(files[HealthFile], "RoadmapHealthList", "RoadmapHealth") + if err != nil { + return nil, unavailable("%s: %v", HealthFile, err) + } + + out := &Publication{ + revision: revision, + prov: Provenance{ + StateRevision: revision, + EvaluatorRevision: pub.Evaluator.Revision, + Source: pub.Source, + Observation: pub.Observation, + }, + } + names := map[string]bool{} + for _, m := range pub.Manifests { + if m.Epic == nil || m.Epic.Name == "" || m.Epic.Lifecycle == "" { + return nil, unavailable("manifest %s carries no epic identity", m.Path) + } + if names[m.Epic.Name] { + return nil, unavailable("epic %s is published twice", m.Epic.Name) + } + names[m.Epic.Name] = true + r, ok := ready[m.Path] + if !ok { + return nil, unavailable("%s has no ready set for %s", ReadySetFile, m.Path) + } + h, ok := health[m.Path] + if !ok { + return nil, unavailable("%s has no health for %s", HealthFile, m.Path) + } + delete(ready, m.Path) + delete(health, m.Path) + for _, doc := range []parsedDoc{r, h} { + if doc.wire.Epic.Manifest.SHA256 != m.SHA256 || doc.wire.Epic.Name != m.Epic.Name { + return nil, unavailable("%s was derived from a different manifest than the publication names", m.Path) + } + } + entry := &epicEntry{ + epic: Epic{ + Name: m.Epic.Name, Lifecycle: m.Epic.Lifecycle, Title: m.Epic.Title, Goal: m.Epic.Goal, + Issue: r.wire.Epic.Issue, + Manifest: ManifestRef{Path: m.Path, SHA256: m.SHA256}, + }, + readiness: r.raw, + health: h.raw, + source: r.wire.Source, + } + if entry.items, err = parseItems(r.wire); err != nil { + return nil, unavailable("%s: %v", m.Path, err) + } + out.epics = append(out.epics, entry) + } + if len(ready) != 0 || len(health) != 0 { + return nil, unavailable("derived files cover epics the publication does not name") + } + return out, nil +} + +type parsedDoc struct { + raw json.RawMessage + wire wireEpicDoc +} + +func parseList(data []byte, listKind, itemKind string) (map[string]parsedDoc, error) { + var list wireList + if err := json.Unmarshal(data, &list); err != nil { + return nil, err + } + if list.APIVersion != APIVersion || list.Kind != listKind { + return nil, fmt.Errorf("unsupported %s/%s", list.APIVersion, list.Kind) + } + out := map[string]parsedDoc{} + for _, raw := range list.Items { + var doc wireEpicDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, err + } + if doc.APIVersion != APIVersion || doc.Kind != itemKind { + return nil, fmt.Errorf("unsupported item %s/%s", doc.APIVersion, doc.Kind) + } + path := doc.Epic.Manifest.Path + if _, dup := out[path]; dup || path == "" { + return nil, fmt.Errorf("manifest %q is missing or repeated", path) + } + out[path] = parsedDoc{raw: raw, wire: doc} + } + return out, nil +} + +// parseItems reads the ready set's items and checks that its `ready` list +// and the items' own states say the same thing. The two are one answer from +// one evaluator; if they disagree the document is not one we can serve. +func parseItems(doc wireEpicDoc) ([]item, error) { + listed := map[string]bool{} + for _, id := range doc.Ready { + listed[id] = true + } + items := make([]item, 0, len(doc.Items)) + seen := map[string]bool{} + for _, raw := range doc.Items { + var w wireItem + if err := json.Unmarshal(raw, &w); err != nil { + return nil, err + } + if w.ID == "" || w.Required == nil || seen[w.ID] { + return nil, fmt.Errorf("item %q has no id or required flag, or is repeated", w.ID) + } + seen[w.ID] = true + switch w.State { + case StateComplete, StateReady, StateBlocked, StateUnknown: + default: + return nil, fmt.Errorf("item %s has unknown state %q", w.ID, w.State) + } + if (w.State == StateReady) != listed[w.ID] { + return nil, fmt.Errorf("item %s: state %q disagrees with the ready list", w.ID, w.State) + } + items = append(items, item{id: w.ID, required: *w.Required, state: w.State, raw: raw}) + } + for id := range listed { + if !seen[id] { + return nil, fmt.Errorf("ready list names %s, which is not an item", id) + } + } + return items, nil +} + +// Provenance is the publication's provenance, aged against now. +func (p *Publication) Provenance(now time.Time) Provenance { + prov := p.prov + if at := prov.Observation.CapturedAt; at != nil { + age := int64(now.Sub(*at) / time.Second) + prov.AgeSeconds = &age + } + return prov +} + +// find resolves an epic by name or by its root issue ("owner/repo#N"). +func (p *Publication) find(key string) (*epicEntry, error) { + key = strings.TrimSpace(key) + for _, e := range p.epics { + if e.epic.Name == key || (e.epic.Issue != nil && strings.EqualFold(e.epic.Issue.String(), key)) { + return e, nil + } + } + return nil, fmt.Errorf("%w: %q", ErrEpicNotFound, key) +} + +// EpicStatus answers "what is the status of epic X". +func (p *Publication) EpicStatus(key string, now time.Time) (*EpicStatus, error) { + e, err := p.find(key) + if err != nil { + return nil, err + } + return &EpicStatus{Epic: e.epic, Readiness: e.readiness, Health: e.health, Provenance: p.Provenance(now)}, nil +} + +// ReadyWorkItems answers "what work is ready now", for one epic (by name or +// root issue) or, with an empty key, for every active epic. +func (p *Publication) ReadyWorkItems(key string, requiredOnly bool, now time.Time) (*ReadyWorkItems, error) { + var epics []*epicEntry + if strings.TrimSpace(key) != "" { + e, err := p.find(key) + if err != nil { + return nil, err + } + epics = []*epicEntry{e} + } else { + for _, e := range p.epics { + if e.epic.Lifecycle == LifecycleActive { + epics = append(epics, e) + } + } + } + out := &ReadyWorkItems{RequiredOnly: requiredOnly, Epics: []ReadyEpic{}, Provenance: p.Provenance(now)} + for _, e := range epics { + re := ReadyEpic{Epic: e.epic, Source: e.source, Items: []json.RawMessage{}} + for _, it := range e.items { + if it.state == StateReady && (it.required || !requiredOnly) { + re.Items = append(re.Items, it.raw) + } + } + out.Epics = append(out.Epics, re) + } + return out, nil +} + +// Epics lists the published epics, sorted by name. +func (p *Publication) Epics() []Epic { + out := make([]Epic, 0, len(p.epics)) + for _, e := range p.epics { + out = append(out, e.epic) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Reader serves the current publication, re-verifying only when the +// roadmap-state checkout moves. +type Reader struct { + src FileSource + + mu sync.Mutex + cached *Publication +} + +// NewReader wraps a file source. +func NewReader(src FileSource) *Reader { return &Reader{src: src} } + +// Current returns the verified publication at the checkout's current commit. +// A publication that fails verification is never served, and neither is the +// previously cached one: the cache answers only for the commit it was read at. +func (r *Reader) Current() (*Publication, error) { + if r == nil || r.src == nil { + return nil, unavailable("no roadmap-state source is configured") + } + rev, err := r.src.Revision() + if err != nil { + return nil, unavailable("roadmap-state checkout: %v", err) + } + r.mu.Lock() + defer r.mu.Unlock() + if r.cached != nil && r.cached.revision == rev { + return r.cached, nil + } + files, readRev, err := r.src.ReadFiles(PublicationFile, SnapshotFile, ReadySetFile, HealthFile) + if err != nil { + return nil, unavailable("roadmap-state checkout: %v", err) + } + pub, err := Parse(files, readRev) + if err != nil { + return nil, err + } + r.cached = pub + return pub, nil +} diff --git a/internal/roadmap/roadmap_test.go b/internal/roadmap/roadmap_test.go new file mode 100644 index 00000000..c8e1df01 --- /dev/null +++ b/internal/roadmap/roadmap_test.go @@ -0,0 +1,427 @@ +// Copyright 2025 MCTL Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package roadmap + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "testing" + "time" +) + +// testdata/live is a real publication, copied byte for byte from the +// roadmap-state branch of mctlhq/.github (see testdata/live/SOURCE). The +// tests compare what this package serves against those bytes; they never +// restate an expected readiness of their own. +const liveDir = "testdata/live" + +var liveNow = time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) + +func liveFiles(t *testing.T) map[string][]byte { + t.Helper() + files := map[string][]byte{} + for _, name := range []string{PublicationFile, SnapshotFile, ReadySetFile, HealthFile} { + data, err := os.ReadFile(filepath.Join(liveDir, name)) + if err != nil { + t.Fatal(err) + } + files[name] = data + } + return files +} + +func livePublication(t *testing.T) *Publication { + t.Helper() + pub, err := Parse(liveFiles(t), "0000000000000000000000000000000000000001") + if err != nil { + t.Fatalf("the live publication does not verify: %v", err) + } + return pub +} + +type publishedDoc struct { + Epic struct { + Name string `json:"name"` + } `json:"epic"` + Items []struct { + ID string `json:"id"` + Required bool `json:"required"` + State string `json:"state"` + Issue *IssueRef `json:"issue"` + } `json:"items"` + Ready []string `json:"ready"` +} + +func publishedList(t *testing.T, file string) map[string]json.RawMessage { + t.Helper() + var list struct { + Items []json.RawMessage `json:"items"` + } + if err := json.Unmarshal(liveFiles(t)[file], &list); err != nil { + t.Fatal(err) + } + out := map[string]json.RawMessage{} + for _, raw := range list.Items { + var doc publishedDoc + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + out[doc.Epic.Name] = raw + } + return out +} + +func TestEpicStatusIsThePublishedDocumentsByteForByte(t *testing.T) { + pub := livePublication(t) + ready := publishedList(t, ReadySetFile) + health := publishedList(t, HealthFile) + if len(pub.Epics()) != len(ready) || len(ready) == 0 { + t.Fatalf("serves %d epics, the ready set has %d", len(pub.Epics()), len(ready)) + } + for _, e := range pub.Epics() { + status, err := pub.EpicStatus(e.Name, liveNow) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(status.Readiness, ready[e.Name]) || !bytes.Equal(status.Health, health[e.Name]) { + t.Errorf("%s: served documents differ from the published ones", e.Name) + } + } +} + +func TestReadyItemsAreExactlyThePublishedReadyList(t *testing.T) { + pub := livePublication(t) + for name, raw := range publishedList(t, ReadySetFile) { + var doc publishedDoc + _ = json.Unmarshal(raw, &doc) + required := map[string]bool{} + for _, it := range doc.Items { + required[it.ID] = it.Required + } + for _, requiredOnly := range []bool{false, true} { + got, err := pub.ReadyWorkItems(name, requiredOnly, liveNow) + if err != nil { + t.Fatal(err) + } + var want []string + for _, id := range doc.Ready { + if required[id] || !requiredOnly { + want = append(want, id) + } + } + if ids := itemIDs(t, got.Epics[0].Items); !equalSorted(ids, want) { + t.Errorf("%s required_only=%v: served %v, published %v", name, requiredOnly, ids, want) + } + } + } +} + +func TestAllEpicsMeansEveryActiveEpicAndNoOther(t *testing.T) { + pub := livePublication(t) + var publication struct { + Manifests []struct { + Epic struct { + Name string `json:"name"` + Lifecycle string `json:"lifecycle"` + } `json:"epic"` + } `json:"manifests"` + } + if err := json.Unmarshal(liveFiles(t)[PublicationFile], &publication); err != nil { + t.Fatal(err) + } + var active, inactive []string + for _, m := range publication.Manifests { + if m.Epic.Lifecycle == "active" { + active = append(active, m.Epic.Name) + } else { + inactive = append(inactive, m.Epic.Name) + } + } + if len(active) == 0 || len(inactive) == 0 { + t.Fatalf("fixture must hold active and inactive epics: %v / %v", active, inactive) + } + got, err := pub.ReadyWorkItems("", true, liveNow) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range got.Epics { + names = append(names, e.Epic.Name) + } + if !equalSorted(names, active) { + t.Fatalf("all-epics answer covers %v, the active epics are %v", names, active) + } +} + +// The #333 acceptance examples, read against the live publication: whatever +// the evaluator decided is what is served, and no unbound or unobserved item +// is ever offered as executable. +func TestAcceptanceExamplesFollowThePublication(t *testing.T) { + pub := livePublication(t) + + // Enterprise MCP (#35): several independent required items can be ready. + mcp, err := pub.ReadyWorkItems("mctlhq/.github#35", true, liveNow) + if err != nil { + t.Fatal(err) + } + if n := len(mcp.Epics[0].Items); n < 2 { + t.Errorf("enterprise MCP serves %d ready items; the publication lists at least two", n) + } + + // Nothing unbound or unknown is ever served as ready, in any epic. + all, err := pub.ReadyWorkItems("", false, liveNow) + if err != nil { + t.Fatal(err) + } + for _, e := range all.Epics { + for _, raw := range e.Items { + var it struct { + State string `json:"state"` + Issue *IssueRef `json:"issue"` + } + _ = json.Unmarshal(raw, &it) + if it.State != StateReady || it.Issue == nil { + t.Errorf("%s served a non-ready or unbound item: %s", e.Epic.Name, raw) + } + } + } + + // Lookup by root issue is the same epic as lookup by name. + byIssue, err := pub.EpicStatus("MCTLHQ/.github#57", liveNow) + if err != nil { + t.Fatal(err) + } + byName, err := pub.EpicStatus(byIssue.Epic.Name, liveNow) + if err != nil || !bytes.Equal(byName.Readiness, byIssue.Readiness) { + t.Fatalf("root issue and name disagree: %v", err) + } + if _, err := pub.EpicStatus("no-such-epic", liveNow); !errors.Is(err, ErrEpicNotFound) { + t.Fatalf("unknown epic: %v", err) + } +} + +func TestProvenanceCarriesTheCaptureAndItsAge(t *testing.T) { + pub := livePublication(t) + got, _ := pub.ReadyWorkItems("", true, liveNow) + p := got.Provenance + if p.Observation.Mode != "live-capture" || p.Observation.CapturedAt == nil || p.AgeSeconds == nil { + t.Fatalf("provenance = %+v", p) + } + if want := int64(liveNow.Sub(*p.Observation.CapturedAt) / time.Second); *p.AgeSeconds != want { + t.Fatalf("age = %d, want %d", *p.AgeSeconds, want) + } + if p.EvaluatorRevision == "" || p.Source.Revision == "" || p.StateRevision == "" { + t.Fatalf("revisions missing: %+v", p) + } +} + +// resign recomputes the digests in publication.json after a test edits a +// file, so a tamper test reaches the check it is aimed at instead of +// stopping at the digest. +func resign(t *testing.T, files map[string][]byte) { + t.Helper() + var pub map[string]any + if err := json.Unmarshal(files[PublicationFile], &pub); err != nil { + t.Fatal(err) + } + digests := pub["files"].(map[string]any) + for _, name := range []string{SnapshotFile, ReadySetFile, HealthFile} { + sum := sha256.Sum256(files[name]) + digests[name] = map[string]any{"sha256": hex.EncodeToString(sum[:])} + } + files[PublicationFile] = mustJSON(t, pub) +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return data +} + +func editJSON(t *testing.T, files map[string][]byte, name string, edit func(doc map[string]any)) { + t.Helper() + var doc map[string]any + if err := json.Unmarshal(files[name], &doc); err != nil { + t.Fatal(err) + } + edit(doc) + files[name] = mustJSON(t, doc) +} + +func firstReadySet(doc map[string]any) map[string]any { + return doc["items"].([]any)[0].(map[string]any) +} + +func TestAnythingUnverifiedIsRefused(t *testing.T) { + cases := map[string]func(t *testing.T, files map[string][]byte){ + "a file altered after publishing": func(t *testing.T, files map[string][]byte) { + files[ReadySetFile] = append(bytes.Clone(files[ReadySetFile]), ' ') + }, + "the snapshot altered after publishing": func(t *testing.T, files map[string][]byte) { + files[SnapshotFile] = append(bytes.Clone(files[SnapshotFile]), ' ') + }, + "a missing file": func(t *testing.T, files map[string][]byte) { + delete(files, HealthFile) + }, + "an unsupported apiVersion": func(t *testing.T, files map[string][]byte) { + editJSON(t, files, PublicationFile, func(doc map[string]any) { doc["apiVersion"] = "roadmap.mctl.ai/v2" }) + }, + "a manifest without epic identity": func(t *testing.T, files map[string][]byte) { + editJSON(t, files, PublicationFile, func(doc map[string]any) { + delete(doc["manifests"].([]any)[0].(map[string]any), "epic") + }) + }, + "a manifest whose epic has no lifecycle": func(t *testing.T, files map[string][]byte) { + editJSON(t, files, PublicationFile, func(doc map[string]any) { + delete(doc["manifests"].([]any)[0].(map[string]any)["epic"].(map[string]any), "lifecycle") + }) + }, + "a live capture without capturedAt": func(t *testing.T, files map[string][]byte) { + editJSON(t, files, PublicationFile, func(doc map[string]any) { + delete(doc["observation"].(map[string]any), "capturedAt") + }) + }, + "a ready list that disagrees with item states": func(t *testing.T, files map[string][]byte) { + editJSON(t, files, ReadySetFile, func(doc map[string]any) { + rs := firstReadySet(doc) + for _, raw := range rs["items"].([]any) { + it := raw.(map[string]any) + if it["state"] != StateReady { + rs["ready"] = append(rs["ready"].([]any), it["id"]) + return + } + } + t.Fatal("fixture epic has no non-ready item") + }) + resign(t, files) + }, + "an item in an unknown state": func(t *testing.T, files map[string][]byte) { + editJSON(t, files, ReadySetFile, func(doc map[string]any) { + // A non-ready item, so the ready list still agrees and only + // the state vocabulary check can refuse it. + for _, raw := range firstReadySet(doc)["items"].([]any) { + if it := raw.(map[string]any); it["state"] != StateReady { + it["state"] = "maybe" + return + } + } + t.Fatal("fixture epic has no non-ready item") + }) + resign(t, files) + }, + "a ready set derived from another manifest": func(t *testing.T, files map[string][]byte) { + editJSON(t, files, ReadySetFile, func(doc map[string]any) { + firstReadySet(doc)["epic"].(map[string]any)["manifest"].(map[string]any)["sha256"] = "00" + }) + resign(t, files) + }, + "an epic the publication does not name": func(t *testing.T, files map[string][]byte) { + editJSON(t, files, PublicationFile, func(doc map[string]any) { + doc["manifests"] = doc["manifests"].([]any)[1:] + }) + }, + } + for name, tamper := range cases { + t.Run(name, func(t *testing.T) { + files := liveFiles(t) + tamper(t, files) + if _, err := Parse(files, "rev"); !errors.Is(err, ErrUnavailable) { + t.Fatalf("err = %v, want ErrUnavailable", err) + } + }) + } +} + +type fakeSource struct { + rev string + files map[string][]byte + reads int +} + +func (f *fakeSource) Revision() (string, error) { return f.rev, nil } +func (f *fakeSource) ReadFiles(names ...string) (map[string][]byte, string, error) { + f.reads++ + out := map[string][]byte{} + for _, n := range names { + if data, ok := f.files[n]; ok { + out[n] = data + } + } + return out, f.rev, nil +} + +func TestReaderReverifiesOnlyWhenTheCheckoutMovesAndNeverServesStale(t *testing.T) { + src := &fakeSource{rev: "a", files: liveFiles(t)} + r := NewReader(src) + if _, err := r.Current(); err != nil { + t.Fatal(err) + } + if _, err := r.Current(); err != nil || src.reads != 1 { + t.Fatalf("same revision re-read the files: reads=%d err=%v", src.reads, err) + } + // The checkout moves to a broken publication: the previous good one must + // not be served in its place. + src.rev = "b" + src.files[ReadySetFile] = []byte("{}") + if _, err := r.Current(); !errors.Is(err, ErrUnavailable) { + t.Fatalf("broken publication: err = %v", err) + } + if _, err := r.Current(); !errors.Is(err, ErrUnavailable) { + t.Fatalf("broken publication, asked again: err = %v", err) + } + var nilReader *Reader + if _, err := nilReader.Current(); !errors.Is(err, ErrUnavailable) { + t.Fatalf("unconfigured reader: err = %v", err) + } +} + +func itemIDs(t *testing.T, items []json.RawMessage) []string { + t.Helper() + ids := []string{} + for _, raw := range items { + var it struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &it); err != nil { + t.Fatal(err) + } + ids = append(ids, it.ID) + } + return ids +} + +func equalSorted(a, b []string) bool { + a, b = append([]string{}, a...), append([]string{}, b...) + sort.Strings(a) + sort.Strings(b) + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/roadmap/testdata/live/SOURCE b/internal/roadmap/testdata/live/SOURCE new file mode 100644 index 00000000..daa09485 --- /dev/null +++ b/internal/roadmap/testdata/live/SOURCE @@ -0,0 +1,3 @@ +Copied byte for byte from mctlhq/.github branch roadmap-state at a36af84 +("publish: roadmap captured 2026-09-23T09:09:33Z from 657575c8a118"). +Do not edit: refresh by copying a newer publication. diff --git a/internal/roadmap/testdata/live/health.json b/internal/roadmap/testdata/live/health.json new file mode 100644 index 00000000..8cded2b7 --- /dev/null +++ b/internal/roadmap/testdata/live/health.json @@ -0,0 +1,4036 @@ +{ + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "items": [ + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "devloop-instrumentation" + ], + "items": [ + { + "id": "devloop-instrumentation", + "issue": { + "number": 50, + "repository": "mctlhq/.github" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "usage-contract", + "issue": { + "number": 49, + "repository": "mctlhq/.github" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "usage-ledger", + "issue": { + "number": 266, + "repository": "mctlhq/mctl-api" + }, + "reason": "closed", + "required": true, + "status": "complete" + } + ], + "mode": "allRequired", + "required": { + "complete": 2, + "incomplete": 1, + "total": 3, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner usage-ledger): blocked=mctlhq/mctl-api#266, blocker=mctlhq/.github#49", + "subject": { + "blocked": { + "number": 266, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 49, + "repository": "mctlhq/.github" + }, + "owner": "usage-ledger" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner devloop-instrumentation): blocked=mctlhq/.github#50, blocker=mctlhq/mctl-api#266", + "subject": { + "blocked": { + "number": 50, + "repository": "mctlhq/.github" + }, + "blocker": { + "number": 266, + "repository": "mctlhq/mctl-api" + }, + "owner": "devloop-instrumentation" + } + } + ], + "epic": { + "issue": { + "number": 48, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/agent-finops.yaml", + "sha256": "b733800ba387f07a9081bf009b1a6eed413ebecc7fe75b2dd967ccc0055bbbe2" + }, + "name": "agent-finops" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 2, + "error": 0, + "info": 0 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "capability-discovery", + "execution-evidence", + "governed-agent-authoring", + "human-approval", + "runtime-policy" + ], + "items": [ + { + "id": "agent-contract", + "issue": { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "capability-discovery", + "issue": { + "number": 242, + "repository": "mctlhq/mctl-agents" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "execution-admission", + "issue": { + "number": 395, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "execution-evidence", + "issue": { + "number": 199, + "repository": "mctlhq/mctl-agents" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "execution-identity", + "issue": { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "gitops-catalog", + "issue": { + "number": 950, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "governed-agent-authoring", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "human-approval", + "issue": { + "number": 198, + "repository": "mctlhq/mctl-agents" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "release-binding", + "issue": { + "number": 283, + "repository": "mctlhq/mctl-api" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "runtime-policy", + "issue": { + "number": 197, + "repository": "mctlhq/mctl-agents" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "runtime-resolver", + "issue": { + "number": 227, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "service-skills", + "issue": { + "number": 305, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + } + ], + "mode": "allRequired", + "required": { + "complete": 7, + "incomplete": 5, + "total": 12, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner execution-identity): blocked=mctlhq/mctl-agents#196, blocker=mctlhq/mctl-agents#226", + "subject": { + "blocked": { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + "owner": "execution-identity" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner runtime-policy): blocked=mctlhq/mctl-agents#197, blocker=mctlhq/mctl-agents#196", + "subject": { + "blocked": { + "number": 197, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + "owner": "runtime-policy" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner human-approval): blocked=mctlhq/mctl-agents#198, blocker=mctlhq/mctl-agents#196", + "subject": { + "blocked": { + "number": 198, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + "owner": "human-approval" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner human-approval): blocked=mctlhq/mctl-agents#198, blocker=mctlhq/mctl-agents#197", + "subject": { + "blocked": { + "number": 198, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 197, + "repository": "mctlhq/mctl-agents" + }, + "owner": "human-approval" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner execution-evidence): blocked=mctlhq/mctl-agents#199, blocker=mctlhq/mctl-agents#196", + "subject": { + "blocked": { + "number": 199, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + "owner": "execution-evidence" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner runtime-resolver): blocked=mctlhq/mctl-agents#227, blocker=mctlhq/mctl-agents#226", + "subject": { + "blocked": { + "number": 227, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + "owner": "runtime-resolver" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner capability-discovery): blocked=mctlhq/mctl-agents#242, blocker=mctlhq/mctl-agents#226", + "subject": { + "blocked": { + "number": 242, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + "owner": "capability-discovery" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner capability-discovery): blocked=mctlhq/mctl-agents#242, blocker=mctlhq/mctl-agents#227", + "subject": { + "blocked": { + "number": 242, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 227, + "repository": "mctlhq/mctl-agents" + }, + "owner": "capability-discovery" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner release-binding): blocked=mctlhq/mctl-api#283, blocker=mctlhq/mctl-agents#227", + "subject": { + "blocked": { + "number": 283, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 227, + "repository": "mctlhq/mctl-agents" + }, + "owner": "release-binding" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner release-binding): blocked=mctlhq/mctl-api#283, blocker=mctlhq/mctl-gitops#950", + "subject": { + "blocked": { + "number": 283, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 950, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "release-binding" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner service-skills): blocked=mctlhq/mctl-agents#305, blocker=mctlhq/mctl-agents#226", + "subject": { + "blocked": { + "number": 305, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + "owner": "service-skills" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner service-skills): blocked=mctlhq/mctl-agents#305, blocker=mctlhq/mctl-agents#227", + "subject": { + "blocked": { + "number": 305, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 227, + "repository": "mctlhq/mctl-agents" + }, + "owner": "service-skills" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner gitops-catalog): blocked=mctlhq/mctl-gitops#950, blocker=mctlhq/mctl-agents#226", + "subject": { + "blocked": { + "number": 950, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + "owner": "gitops-catalog" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner execution-admission): child=mctlhq/mctl-agents#395, expectedParent=mctlhq/.github#18", + "subject": { + "child": { + "number": 395, + "repository": "mctlhq/mctl-agents" + }, + "expectedParent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "owner": "execution-admission" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner governed-agent-authoring)", + "subject": { + "owner": "governed-agent-authoring" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-portal#112, observedParent=mctlhq/.github#18", + "subject": { + "child": { + "number": 112, + "repository": "mctlhq/mctl-portal" + }, + "observedParent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner execution-admission): child=mctlhq/mctl-gitops#1285, observedParent=mctlhq/mctl-agents#395", + "subject": { + "child": { + "number": 1285, + "repository": "mctlhq/mctl-gitops" + }, + "observedParent": { + "number": 395, + "repository": "mctlhq/mctl-agents" + }, + "owner": "execution-admission" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner execution-admission): child=mctlhq/mctl-agents#396, observedParent=mctlhq/mctl-agents#395", + "subject": { + "child": { + "number": 396, + "repository": "mctlhq/mctl-agents" + }, + "observedParent": { + "number": 395, + "repository": "mctlhq/mctl-agents" + }, + "owner": "execution-admission" + } + } + ], + "epic": { + "issue": { + "number": 18, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/agent-platform.yaml", + "sha256": "09efb27eab8afb1261210db66dd450e3923e598a3a54db84c81c78c9b807668d" + }, + "name": "agent-platform" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 14, + "error": 0, + "info": 4 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [], + "items": [ + { + "id": "channel-skeleton", + "issue": { + "number": 54, + "repository": "mctlhq/mctl-claude-remote" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "delivery-guarantees", + "issue": { + "number": 55, + "repository": "mctlhq/mctl-claude-remote" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "e2e-audit", + "issue": { + "number": 89, + "repository": "mctlhq/.github" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "envelope", + "issue": { + "number": 88, + "repository": "mctlhq/.github" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "github-producer", + "issue": { + "number": 323, + "repository": "mctlhq/mctl-api" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "telegram-producer", + "issue": { + "number": 646, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "valkey", + "issue": { + "number": 1260, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + } + ], + "mode": "allRequired", + "required": { + "complete": 7, + "incomplete": 0, + "total": 7, + "unknown": 0 + }, + "status": "complete" + }, + "diagnostics": [], + "epic": { + "issue": { + "number": 87, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/claude-remote-inbound-channels.yaml", + "sha256": "cdbc0b630b9d924d22ffb44f2917c8ba7507ff4b56dbfd10c8aae9c414a6c4d3" + }, + "name": "claude-remote-inbound-channels" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "healthy", + "summary": { + "drift": 0, + "error": 0, + "info": 0 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "lookup-deployment", + "onboarding-integration", + "operator-identity-lookup", + "product-update-feed" + ], + "items": [ + { + "id": "client-reachability-preferences", + "issue": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "login-bot-update-receiver", + "issue": { + "number": 619, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "lookup-deployment", + "issue": { + "number": 1182, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "onboarding-integration", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "operator-identity-lookup", + "issue": { + "number": 400, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "product-update-feed", + "issue": { + "number": 440, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "safe-broadcast", + "issue": { + "number": 439, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "closed", + "required": true, + "status": "complete" + } + ], + "mode": "allRequired", + "required": { + "complete": 3, + "incomplete": 4, + "total": 7, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner operator-identity-lookup): blocked=mctlhq/mctl-telegram#400, blocker=mctlhq/mctl-gitops#1182", + "subject": { + "blocked": { + "number": 400, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 1182, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "operator-identity-lookup" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner safe-broadcast): blocked=mctlhq/mctl-telegram#439, blocker=mctlhq/mctl-telegram#438", + "subject": { + "blocked": { + "number": 439, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "safe-broadcast" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner safe-broadcast): blocked=mctlhq/mctl-telegram#439, blocker=mctlhq/mctl-telegram#619", + "subject": { + "blocked": { + "number": 439, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 619, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "safe-broadcast" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner product-update-feed): blocked=mctlhq/mctl-telegram#440, blocker=mctlhq/mctl-telegram#439", + "subject": { + "blocked": { + "number": 440, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 439, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "product-update-feed" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner login-bot-update-receiver): blocked=mctlhq/mctl-telegram#619, blocker=mctlhq/mctl-telegram#438", + "subject": { + "blocked": { + "number": 619, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "login-bot-update-receiver" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner lookup-deployment): child=mctlhq/mctl-gitops#1182, expectedParent=mctlhq/.github#22, observedParent=mctlhq/mctl-telegram#515", + "subject": { + "child": { + "number": 1182, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 22, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 515, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "lookup-deployment" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner operator-identity-lookup): child=mctlhq/mctl-telegram#400, expectedParent=mctlhq/.github#22, observedParent=mctlhq/mctl-telegram#515", + "subject": { + "child": { + "number": 400, + "repository": "mctlhq/mctl-telegram" + }, + "expectedParent": { + "number": 22, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 515, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "operator-identity-lookup" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner client-reachability-preferences): child=mctlhq/mctl-telegram#438, expectedParent=mctlhq/.github#22, observedParent=mctlhq/mctl-telegram#515", + "subject": { + "child": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "expectedParent": { + "number": 22, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 515, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "client-reachability-preferences" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner safe-broadcast): child=mctlhq/mctl-telegram#439, expectedParent=mctlhq/.github#22, observedParent=mctlhq/mctl-telegram#618", + "subject": { + "child": { + "number": 439, + "repository": "mctlhq/mctl-telegram" + }, + "expectedParent": { + "number": 22, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 618, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "safe-broadcast" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner product-update-feed): child=mctlhq/mctl-telegram#440, expectedParent=mctlhq/.github#22, observedParent=mctlhq/mctl-telegram#618", + "subject": { + "child": { + "number": 440, + "repository": "mctlhq/mctl-telegram" + }, + "expectedParent": { + "number": 22, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 618, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "product-update-feed" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner login-bot-update-receiver): child=mctlhq/mctl-telegram#619, expectedParent=mctlhq/.github#22, observedParent=mctlhq/mctl-telegram#618", + "subject": { + "child": { + "number": 619, + "repository": "mctlhq/mctl-telegram" + }, + "expectedParent": { + "number": 22, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 618, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "login-bot-update-receiver" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner onboarding-integration)", + "subject": { + "owner": "onboarding-integration" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-telegram#515, observedParent=mctlhq/.github#22", + "subject": { + "child": { + "number": 515, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 22, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-telegram#618, observedParent=mctlhq/.github#22", + "subject": { + "child": { + "number": 618, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 22, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner client-reachability-preferences): child=mctlhq/mctl-telegram#620, observedParent=mctlhq/mctl-telegram#438", + "subject": { + "child": { + "number": 620, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "client-reachability-preferences" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner client-reachability-preferences): child=mctlhq/mctl-telegram#621, observedParent=mctlhq/mctl-telegram#438", + "subject": { + "child": { + "number": 621, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "client-reachability-preferences" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner client-reachability-preferences): child=mctlhq/mctl-telegram#622, observedParent=mctlhq/mctl-telegram#438", + "subject": { + "child": { + "number": 622, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "client-reachability-preferences" + } + } + ], + "epic": { + "issue": { + "number": 22, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/client-lifecycle.yaml", + "sha256": "85d6d932d69db2a4dd653c047410d73c47d5543c4f0b727e782c8934234ddf2d" + }, + "name": "client-lifecycle" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 11, + "error": 0, + "info": 6 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "git-only-operations-proof", + "mcp-portal-iac", + "operator-runbook", + "worker-routes", + "zone-apply-evidence" + ], + "items": [ + { + "id": "account-access", + "issue": { + "number": 1088, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": false, + "status": "incomplete" + }, + { + "id": "apply-identity", + "issue": { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "git-only-operations-proof", + "issue": { + "number": 1333, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "iac-bootstrap", + "issue": { + "number": 1093, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "inventory", + "issue": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "mcp-portal-iac", + "issue": { + "number": 1092, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "operator-runbook", + "issue": { + "number": 1180, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "state-credential-hardening", + "issue": { + "number": 1118, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": false, + "status": "incomplete" + }, + { + "id": "tunnel-handoff", + "issue": { + "number": 1090, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": false, + "status": "incomplete" + }, + { + "id": "worker-routes", + "issue": { + "number": 1179, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "zone-apply-evidence", + "issue": { + "number": 1281, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "zone-state-migration", + "issue": { + "number": 1178, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + } + ], + "mode": "allRequired", + "required": { + "complete": 4, + "incomplete": 5, + "total": 9, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner account-access): blocked=mctlhq/mctl-gitops#1088, blocker=mctlhq/mctl-gitops#1111", + "subject": { + "blocked": { + "number": 1088, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "account-access" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner tunnel-handoff): blocked=mctlhq/mctl-gitops#1090, blocker=mctlhq/mctl-gitops#1111", + "subject": { + "blocked": { + "number": 1090, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "tunnel-handoff" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner mcp-portal-iac): blocked=mctlhq/mctl-gitops#1092, blocker=mctlhq/mctl-gitops#1333", + "subject": { + "blocked": { + "number": 1092, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1333, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "mcp-portal-iac" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner iac-bootstrap): blocked=mctlhq/mctl-gitops#1093, blocker=mctlhq/mctl-gitops#1083", + "subject": { + "blocked": { + "number": 1093, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "iac-bootstrap" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner apply-identity): blocked=mctlhq/mctl-gitops#1111, blocker=mctlhq/mctl-gitops#1093", + "subject": { + "blocked": { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1093, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "apply-identity" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner state-credential-hardening): blocked=mctlhq/mctl-gitops#1118, blocker=mctlhq/mctl-gitops#1093", + "subject": { + "blocked": { + "number": 1118, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1093, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "state-credential-hardening" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner zone-state-migration): blocked=mctlhq/mctl-gitops#1178, blocker=mctlhq/mctl-gitops#1111", + "subject": { + "blocked": { + "number": 1178, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "zone-state-migration" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner worker-routes): blocked=mctlhq/mctl-gitops#1179, blocker=mctlhq/mctl-gitops#1111", + "subject": { + "blocked": { + "number": 1179, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "worker-routes" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner operator-runbook): blocked=mctlhq/mctl-gitops#1180, blocker=mctlhq/mctl-gitops#1178", + "subject": { + "blocked": { + "number": 1180, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1178, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "operator-runbook" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner zone-apply-evidence): blocked=mctlhq/mctl-gitops#1281, blocker=mctlhq/mctl-gitops#1178", + "subject": { + "blocked": { + "number": 1281, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1178, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "zone-apply-evidence" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner git-only-operations-proof): blocked=mctlhq/mctl-gitops#1333, blocker=mctlhq/mctl-gitops#1178", + "subject": { + "blocked": { + "number": 1333, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1178, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "git-only-operations-proof" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner git-only-operations-proof): blocked=mctlhq/mctl-gitops#1333, blocker=mctlhq/mctl-gitops#1179", + "subject": { + "blocked": { + "number": 1333, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1179, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "git-only-operations-proof" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner zone-apply-evidence): child=mctlhq/mctl-gitops#1281, expectedParent=mctlhq/.github#47", + "subject": { + "child": { + "number": 1281, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "owner": "zone-apply-evidence" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner git-only-operations-proof): child=mctlhq/mctl-gitops#1333, expectedParent=mctlhq/.github#47", + "subject": { + "child": { + "number": 1333, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "owner": "git-only-operations-proof" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner account-access): child=mctlhq/mctl-gitops#1088, expectedParent=mctlhq/.github#47, observedParent=mctlhq/mctl-gitops#1083", + "subject": { + "child": { + "number": 1088, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "account-access" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner tunnel-handoff): child=mctlhq/mctl-gitops#1090, expectedParent=mctlhq/.github#47, observedParent=mctlhq/mctl-gitops#1083", + "subject": { + "child": { + "number": 1090, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "tunnel-handoff" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner mcp-portal-iac): child=mctlhq/mctl-gitops#1092, expectedParent=mctlhq/.github#47, observedParent=mctlhq/mctl-gitops#1083", + "subject": { + "child": { + "number": 1092, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "mcp-portal-iac" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner iac-bootstrap): child=mctlhq/mctl-gitops#1093, expectedParent=mctlhq/.github#47, observedParent=mctlhq/mctl-gitops#1083", + "subject": { + "child": { + "number": 1093, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "iac-bootstrap" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner inventory): child=mctlhq/mctl-gitops#1087, observedParent=mctlhq/mctl-gitops#1083", + "subject": { + "child": { + "number": 1087, + "repository": "mctlhq/mctl-gitops" + }, + "observedParent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "inventory" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner inventory): child=mctlhq/mctl-gitops#1089, observedParent=mctlhq/mctl-gitops#1083", + "subject": { + "child": { + "number": 1089, + "repository": "mctlhq/mctl-gitops" + }, + "observedParent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "inventory" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner inventory): child=mctlhq/mctl-gitops#1091, observedParent=mctlhq/mctl-gitops#1083", + "subject": { + "child": { + "number": 1091, + "repository": "mctlhq/mctl-gitops" + }, + "observedParent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "inventory" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner inventory): child=mctlhq/mctl-gitops#1103, observedParent=mctlhq/mctl-gitops#1083", + "subject": { + "child": { + "number": 1103, + "repository": "mctlhq/mctl-gitops" + }, + "observedParent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "inventory" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-gitops#1109, observedParent=mctlhq/.github#47", + "subject": { + "child": { + "number": 1109, + "repository": "mctlhq/mctl-gitops" + }, + "observedParent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-gitops#1114, observedParent=mctlhq/.github#47", + "subject": { + "child": { + "number": 1114, + "repository": "mctlhq/mctl-gitops" + }, + "observedParent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-gitops#1115, observedParent=mctlhq/.github#47", + "subject": { + "child": { + "number": 1115, + "repository": "mctlhq/mctl-gitops" + }, + "observedParent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + } + ], + "epic": { + "issue": { + "number": 47, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/cloudflare-iac.yaml", + "sha256": "83ce4fe606b8908420ac580a9e055a77cb5104b6a26ab35d91b4b42f8f96d799" + }, + "name": "cloudflare-iac" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 18, + "error": 0, + "info": 7 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "context-development-lifecycle", + "context-evals", + "retrieval-ranking-trust" + ], + "items": [ + { + "id": "context-assembly", + "issue": { + "number": 265, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "context-contract", + "issue": { + "number": 264, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "context-development-lifecycle", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "context-evals", + "issue": { + "number": 266, + "repository": "mctlhq/mctl-agents" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "retrieval-ranking-trust", + "reason": "unbound", + "required": true, + "status": "incomplete" + } + ], + "mode": "allRequired", + "required": { + "complete": 2, + "incomplete": 3, + "total": 5, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner context-assembly): blocked=mctlhq/mctl-agents#265, blocker=mctlhq/mctl-agents#264", + "subject": { + "blocked": { + "number": 265, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 264, + "repository": "mctlhq/mctl-agents" + }, + "owner": "context-assembly" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner context-evals): blocked=mctlhq/mctl-agents#266, blocker=mctlhq/mctl-agents#265", + "subject": { + "blocked": { + "number": 266, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 265, + "repository": "mctlhq/mctl-agents" + }, + "owner": "context-evals" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner context-development-lifecycle)", + "subject": { + "owner": "context-development-lifecycle" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner retrieval-ranking-trust)", + "subject": { + "owner": "retrieval-ranking-trust" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-portal#111, observedParent=mctlhq/.github#20", + "subject": { + "child": { + "number": 111, + "repository": "mctlhq/mctl-portal" + }, + "observedParent": { + "number": 20, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-api#249, observedParent=mctlhq/.github#20", + "subject": { + "child": { + "number": 249, + "repository": "mctlhq/mctl-api" + }, + "observedParent": { + "number": 20, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-agents#310, observedParent=mctlhq/.github#20", + "subject": { + "child": { + "number": 310, + "repository": "mctlhq/mctl-agents" + }, + "observedParent": { + "number": 20, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-agents#60, observedParent=mctlhq/.github#20", + "subject": { + "child": { + "number": 60, + "repository": "mctlhq/mctl-agents" + }, + "observedParent": { + "number": 20, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + } + ], + "epic": { + "issue": { + "number": 20, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/context-platform.yaml", + "sha256": "5bbded1b31cec145e1eeb32d8c8bc3f60e08414c7347b6b1be402571fef3d4b9" + }, + "name": "context-platform" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 2, + "error": 0, + "info": 6 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [], + "items": [ + { + "id": "cwft-adopted-prs-persistence", + "issue": { + "number": 1278, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "direct-implementer-ownership", + "issue": { + "number": 239, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "proposal-less-pr-adoption", + "issue": { + "number": 334, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "steward-review-fix", + "issue": { + "number": 292, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + } + ], + "mode": "allRequired", + "required": { + "complete": 4, + "incomplete": 0, + "total": 4, + "unknown": 0 + }, + "status": "complete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner cwft-adopted-prs-persistence): blocked=mctlhq/mctl-gitops#1278, blocker=mctlhq/mctl-agents#334", + "subject": { + "blocked": { + "number": 1278, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 334, + "repository": "mctlhq/mctl-agents" + }, + "owner": "cwft-adopted-prs-persistence" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner proposal-less-pr-adoption): blocked=mctlhq/mctl-agents#334, blocker=mctlhq/mctl-agents#292", + "subject": { + "blocked": { + "number": 334, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 292, + "repository": "mctlhq/mctl-agents" + }, + "owner": "proposal-less-pr-adoption" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner cwft-adopted-prs-persistence): child=mctlhq/mctl-gitops#1278, expectedParent=mctlhq/.github#38", + "subject": { + "child": { + "number": 1278, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 38, + "repository": "mctlhq/.github" + }, + "owner": "cwft-adopted-prs-persistence" + } + } + ], + "epic": { + "issue": { + "number": 38, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/devloop-pr-adoption.yaml", + "sha256": "389dbcc450674de30970f0e8afcfb555e3947416c09fdc812b37e34bad2652ab" + }, + "name": "devloop-pr-adoption" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 3, + "error": 0, + "info": 0 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "feasibility-spike", + "local-execution-contract" + ], + "items": [ + { + "id": "feasibility-spike", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "local-execution-contract", + "reason": "unbound", + "required": true, + "status": "incomplete" + } + ], + "mode": "allRequired", + "required": { + "complete": 0, + "incomplete": 2, + "total": 2, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner feasibility-spike)", + "subject": { + "owner": "feasibility-spike" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner local-execution-contract)", + "subject": { + "owner": "local-execution-contract" + } + } + ], + "epic": { + "issue": { + "number": 34, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/edge-ai-android.yaml", + "sha256": "f29c77722186ed861ff6bbb767f03ce1b07440eb07f7d5af2f25572a83624f29" + }, + "name": "edge-ai-android" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "healthy", + "summary": { + "drift": 0, + "error": 0, + "info": 2 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "denied-user-and-bypass", + "enterprise-endpoint-decision", + "mcp-apps-validation", + "mcp-tasks" + ], + "items": [ + { + "id": "compatibility-spike", + "issue": { + "number": 44, + "repository": "mctlhq/.github" + }, + "reason": "closed", + "required": false, + "status": "complete" + }, + { + "id": "correlation-contract", + "issue": { + "number": 617, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "denied-user-and-bypass", + "issue": { + "number": 616, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "enterprise-endpoint-decision", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "gateway-dlp-result", + "issue": { + "number": 1181, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "gateway-hardening", + "issue": { + "number": 1193, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": false, + "status": "incomplete" + }, + { + "id": "local-bridge-proof", + "issue": { + "number": 615, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "open", + "required": false, + "status": "incomplete" + }, + { + "id": "mcp-apps", + "issue": { + "number": 569, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "mcp-apps-validation", + "issue": { + "number": 650, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "mcp-tasks", + "issue": { + "number": 284, + "repository": "mctlhq/mctl-api" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "telegram-compatibility", + "issue": { + "number": 585, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "closed", + "required": true, + "status": "complete" + } + ], + "mode": "allRequired", + "required": { + "complete": 4, + "incomplete": 4, + "total": 8, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner gateway-dlp-result): blocked=mctlhq/mctl-gitops#1181, blocker=mctlhq/mctl-telegram#585", + "subject": { + "blocked": { + "number": 1181, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 585, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "gateway-dlp-result" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner gateway-hardening): blocked=mctlhq/mctl-gitops#1193, blocker=mctlhq/mctl-gitops#1181", + "subject": { + "blocked": { + "number": 1193, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 1181, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "gateway-hardening" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner mcp-tasks): blocked=mctlhq/mctl-api#284, blocker=mctlhq/.github#41", + "subject": { + "blocked": { + "number": 284, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 41, + "repository": "mctlhq/.github" + }, + "owner": "mcp-tasks" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner mcp-tasks): blocked=mctlhq/mctl-api#284, blocker=mctlhq/.github#44", + "subject": { + "blocked": { + "number": 284, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 44, + "repository": "mctlhq/.github" + }, + "owner": "mcp-tasks" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner mcp-tasks): blocked=mctlhq/mctl-api#284, blocker=mctlhq/mctl-telegram#617", + "subject": { + "blocked": { + "number": 284, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 617, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "mcp-tasks" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner mcp-apps): blocked=mctlhq/mctl-telegram#569, blocker=mctlhq/mctl-telegram#617", + "subject": { + "blocked": { + "number": 569, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 617, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "mcp-apps" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner local-bridge-proof): blocked=mctlhq/mctl-telegram#615, blocker=mctlhq/mctl-telegram#585", + "subject": { + "blocked": { + "number": 615, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 585, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "local-bridge-proof" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner denied-user-and-bypass): blocked=mctlhq/mctl-telegram#616, blocker=mctlhq/.github#44", + "subject": { + "blocked": { + "number": 616, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 44, + "repository": "mctlhq/.github" + }, + "owner": "denied-user-and-bypass" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner denied-user-and-bypass): blocked=mctlhq/mctl-telegram#616, blocker=mctlhq/mctl-telegram#585", + "subject": { + "blocked": { + "number": 616, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 585, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "denied-user-and-bypass" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner denied-user-and-bypass): blocked=mctlhq/mctl-telegram#616, blocker=mctlhq/mctl-telegram#609", + "subject": { + "blocked": { + "number": 616, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 609, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "denied-user-and-bypass" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner correlation-contract): blocked=mctlhq/mctl-telegram#617, blocker=mctlhq/mctl-gitops#1181", + "subject": { + "blocked": { + "number": 617, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 1181, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "correlation-contract" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner mcp-apps-validation): blocked=mctlhq/mctl-telegram#650, blocker=mctlhq/mctl-telegram#569", + "subject": { + "blocked": { + "number": 650, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 569, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "mcp-apps-validation" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner gateway-hardening): child=mctlhq/mctl-gitops#1193, expectedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 1193, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "gateway-hardening" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner mcp-apps-validation): child=mctlhq/mctl-telegram#650, expectedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 650, + "repository": "mctlhq/mctl-telegram" + }, + "expectedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "mcp-apps-validation" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner telegram-compatibility): child=mctlhq/mctl-telegram#585, expectedParent=mctlhq/.github#35, observedParent=mctlhq/.github#44", + "subject": { + "child": { + "number": 585, + "repository": "mctlhq/mctl-telegram" + }, + "expectedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 44, + "repository": "mctlhq/.github" + }, + "owner": "telegram-compatibility" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner enterprise-endpoint-decision)", + "subject": { + "owner": "enterprise-endpoint-decision" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-api#276, observedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 276, + "repository": "mctlhq/mctl-api" + }, + "observedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/.github#41, observedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 41, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/.github#43, observedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 43, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/seerrsense#54, observedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 54, + "repository": "mctlhq/seerrsense" + }, + "observedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-telegram#568, observedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 568, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner compatibility-spike): child=mctlhq/mctl-telegram#605, observedParent=mctlhq/.github#44", + "subject": { + "child": { + "number": 605, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 44, + "repository": "mctlhq/.github" + }, + "owner": "compatibility-spike" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-telegram#607, observedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 607, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-telegram#614, observedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 614, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/.github#63, observedParent=mctlhq/.github#35", + "subject": { + "child": { + "number": 63, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + } + ], + "epic": { + "issue": { + "number": 35, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/enterprise-mcp.yaml", + "sha256": "15bf9f29085feff8e49d9f397ebea049862232b3029c52c069213808aaf7d974" + }, + "name": "enterprise-mcp" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 15, + "error": 0, + "info": 10 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "devloop-e2e", + "telegram-adapter" + ], + "items": [ + { + "id": "catalog-profile-rollout", + "issue": { + "number": 1277, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "devloop-e2e", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "docs", + "issue": { + "number": 106, + "repository": "mctlhq/mctl-docs" + }, + "reason": "open", + "required": false, + "status": "incomplete" + }, + { + "id": "human-input-api", + "issue": { + "number": 261, + "repository": "mctlhq/mctl-api" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "human-input-core", + "issue": { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "portal-card", + "issue": { + "number": 124, + "repository": "mctlhq/mctl-portal" + }, + "reason": "open", + "required": false, + "status": "incomplete" + }, + { + "id": "telegram-adapter", + "issue": { + "number": 571, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "open", + "required": true, + "status": "incomplete" + } + ], + "mode": "allRequired", + "required": { + "complete": 3, + "incomplete": 2, + "total": 5, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner docs): blocked=mctlhq/mctl-docs#106, blocker=mctlhq/mctl-api#261", + "subject": { + "blocked": { + "number": 106, + "repository": "mctlhq/mctl-docs" + }, + "blocker": { + "number": 261, + "repository": "mctlhq/mctl-api" + }, + "owner": "docs" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner docs): blocked=mctlhq/mctl-docs#106, blocker=mctlhq/mctl-agents#333", + "subject": { + "blocked": { + "number": 106, + "repository": "mctlhq/mctl-docs" + }, + "blocker": { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + "owner": "docs" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner portal-card): blocked=mctlhq/mctl-portal#124, blocker=mctlhq/mctl-api#261", + "subject": { + "blocked": { + "number": 124, + "repository": "mctlhq/mctl-portal" + }, + "blocker": { + "number": 261, + "repository": "mctlhq/mctl-api" + }, + "owner": "portal-card" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner catalog-profile-rollout): blocked=mctlhq/mctl-gitops#1277, blocker=mctlhq/mctl-agents#333", + "subject": { + "blocked": { + "number": 1277, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + "owner": "catalog-profile-rollout" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner telegram-adapter): blocked=mctlhq/mctl-telegram#571, blocker=mctlhq/mctl-api#261", + "subject": { + "blocked": { + "number": 571, + "repository": "mctlhq/mctl-telegram" + }, + "blocker": { + "number": 261, + "repository": "mctlhq/mctl-api" + }, + "owner": "telegram-adapter" + } + }, + { + "code": "DependencyUnexpected", + "level": "drift", + "message": "DependencyUnexpected (owner human-input-core): blocked=mctlhq/mctl-agents#333, blocker=mctlhq/mctl-api#227", + "subject": { + "blocked": { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 227, + "repository": "mctlhq/mctl-api" + }, + "owner": "human-input-core" + } + }, + { + "code": "DependencyUnexpected", + "level": "drift", + "message": "DependencyUnexpected (owner human-input-core): blocked=mctlhq/mctl-agents#333, blocker=mctlhq/mctl-agents#264", + "subject": { + "blocked": { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 264, + "repository": "mctlhq/mctl-agents" + }, + "owner": "human-input-core" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner catalog-profile-rollout): child=mctlhq/mctl-gitops#1277, expectedParent=mctlhq/.github#42", + "subject": { + "child": { + "number": 1277, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 42, + "repository": "mctlhq/.github" + }, + "owner": "catalog-profile-rollout" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner portal-card): child=mctlhq/mctl-portal#124, expectedParent=mctlhq/.github#42, observedParent=mctlhq/mctl-portal#111", + "subject": { + "child": { + "number": 124, + "repository": "mctlhq/mctl-portal" + }, + "expectedParent": { + "number": 42, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 111, + "repository": "mctlhq/mctl-portal" + }, + "owner": "portal-card" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner devloop-e2e)", + "subject": { + "owner": "devloop-e2e" + } + } + ], + "epic": { + "issue": { + "number": 42, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/human-input.yaml", + "sha256": "837fc21c9c61e868c44cf92fd6fada6a55f1e342f26f7b17dea0f59da729520e" + }, + "name": "human-input" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 9, + "error": 0, + "info": 1 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [], + "items": [ + { + "id": "devloop-ownership", + "issue": { + "number": 351, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "executor-fencing", + "issue": { + "number": 352, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "guarded-recovery", + "issue": { + "number": 294, + "repository": "mctlhq/mctl-api" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "ownership-contract", + "issue": { + "number": 350, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "ownership-inspection", + "issue": { + "number": 293, + "repository": "mctlhq/mctl-api" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "ownership-reconciler", + "issue": { + "number": 353, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + } + ], + "mode": "allRequired", + "required": { + "complete": 6, + "incomplete": 0, + "total": 6, + "unknown": 0 + }, + "status": "complete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner ownership-inspection): blocked=mctlhq/mctl-api#293, blocker=mctlhq/mctl-agents#350", + "subject": { + "blocked": { + "number": 293, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 350, + "repository": "mctlhq/mctl-agents" + }, + "owner": "ownership-inspection" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner ownership-inspection): blocked=mctlhq/mctl-api#293, blocker=mctlhq/mctl-agents#351", + "subject": { + "blocked": { + "number": 293, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 351, + "repository": "mctlhq/mctl-agents" + }, + "owner": "ownership-inspection" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner guarded-recovery): blocked=mctlhq/mctl-api#294, blocker=mctlhq/mctl-api#293", + "subject": { + "blocked": { + "number": 294, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 293, + "repository": "mctlhq/mctl-api" + }, + "owner": "guarded-recovery" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner guarded-recovery): blocked=mctlhq/mctl-api#294, blocker=mctlhq/mctl-agents#352", + "subject": { + "blocked": { + "number": 294, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 352, + "repository": "mctlhq/mctl-agents" + }, + "owner": "guarded-recovery" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner guarded-recovery): blocked=mctlhq/mctl-api#294, blocker=mctlhq/mctl-agents#353", + "subject": { + "blocked": { + "number": 294, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 353, + "repository": "mctlhq/mctl-agents" + }, + "owner": "guarded-recovery" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner devloop-ownership): blocked=mctlhq/mctl-agents#351, blocker=mctlhq/mctl-agents#350", + "subject": { + "blocked": { + "number": 351, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 350, + "repository": "mctlhq/mctl-agents" + }, + "owner": "devloop-ownership" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner executor-fencing): blocked=mctlhq/mctl-agents#352, blocker=mctlhq/mctl-agents#350", + "subject": { + "blocked": { + "number": 352, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 350, + "repository": "mctlhq/mctl-agents" + }, + "owner": "executor-fencing" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner executor-fencing): blocked=mctlhq/mctl-agents#352, blocker=mctlhq/mctl-agents#351", + "subject": { + "blocked": { + "number": 352, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 351, + "repository": "mctlhq/mctl-agents" + }, + "owner": "executor-fencing" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner ownership-reconciler): blocked=mctlhq/mctl-agents#353, blocker=mctlhq/mctl-agents#350", + "subject": { + "blocked": { + "number": 353, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 350, + "repository": "mctlhq/mctl-agents" + }, + "owner": "ownership-reconciler" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner ownership-reconciler): blocked=mctlhq/mctl-agents#353, blocker=mctlhq/mctl-agents#351", + "subject": { + "blocked": { + "number": 353, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 351, + "repository": "mctlhq/mctl-agents" + }, + "owner": "ownership-reconciler" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner ownership-reconciler): blocked=mctlhq/mctl-agents#353, blocker=mctlhq/mctl-agents#352", + "subject": { + "blocked": { + "number": 353, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 352, + "repository": "mctlhq/mctl-agents" + }, + "owner": "ownership-reconciler" + } + } + ], + "epic": { + "issue": { + "number": 57, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/lifecycle-ownership.yaml", + "sha256": "3937bc8a8c061efce42c61d4f29b12d09aca784b9b045ebdb5e8c7e7f51db779" + }, + "name": "lifecycle-ownership" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 11, + "error": 0, + "info": 0 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "cross-system-correlation", + "devloop-traces", + "live-bakeoff", + "mcp-span-context" + ], + "items": [ + { + "id": "agent-traces", + "issue": { + "number": 38, + "repository": "mctlhq/mctl-agent" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "attribute-catalog", + "issue": { + "number": 116, + "repository": "mctlhq/mctl-docs" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "backend-bakeoff", + "issue": { + "number": 903, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "cross-system-correlation", + "issue": { + "number": 97, + "repository": "mctlhq/mctl-agent" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "devloop-traces", + "issue": { + "number": 195, + "repository": "mctlhq/mctl-agents" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "live-bakeoff", + "issue": { + "number": 1280, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "mcp-span-context", + "issue": { + "number": 285, + "repository": "mctlhq/mctl-api" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "telemetry-gateway", + "issue": { + "number": 902, + "repository": "mctlhq/mctl-gitops" + }, + "reason": "closed", + "required": true, + "status": "complete" + } + ], + "mode": "allRequired", + "required": { + "complete": 4, + "incomplete": 4, + "total": 8, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner attribute-catalog): blocked=mctlhq/mctl-docs#116, blocker=mctlhq/mctl-gitops#902", + "subject": { + "blocked": { + "number": 116, + "repository": "mctlhq/mctl-docs" + }, + "blocker": { + "number": 902, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "attribute-catalog" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner live-bakeoff): blocked=mctlhq/mctl-gitops#1280, blocker=mctlhq/mctl-gitops#903", + "subject": { + "blocked": { + "number": 1280, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 903, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "live-bakeoff" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner devloop-traces): blocked=mctlhq/mctl-agents#195, blocker=mctlhq/mctl-gitops#902", + "subject": { + "blocked": { + "number": 195, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 902, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "devloop-traces" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner mcp-span-context): blocked=mctlhq/mctl-api#285, blocker=mctlhq/mctl-agents#195", + "subject": { + "blocked": { + "number": 285, + "repository": "mctlhq/mctl-api" + }, + "blocker": { + "number": 195, + "repository": "mctlhq/mctl-agents" + }, + "owner": "mcp-span-context" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner agent-traces): blocked=mctlhq/mctl-agent#38, blocker=mctlhq/mctl-agents#196", + "subject": { + "blocked": { + "number": 38, + "repository": "mctlhq/mctl-agent" + }, + "blocker": { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + "owner": "agent-traces" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner agent-traces): blocked=mctlhq/mctl-agent#38, blocker=mctlhq/mctl-gitops#902", + "subject": { + "blocked": { + "number": 38, + "repository": "mctlhq/mctl-agent" + }, + "blocker": { + "number": 902, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "agent-traces" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner backend-bakeoff): blocked=mctlhq/mctl-gitops#903, blocker=mctlhq/mctl-gitops#902", + "subject": { + "blocked": { + "number": 903, + "repository": "mctlhq/mctl-gitops" + }, + "blocker": { + "number": 902, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "backend-bakeoff" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner cross-system-correlation): blocked=mctlhq/mctl-agent#97, blocker=mctlhq/mctl-agents#195", + "subject": { + "blocked": { + "number": 97, + "repository": "mctlhq/mctl-agent" + }, + "blocker": { + "number": 195, + "repository": "mctlhq/mctl-agents" + }, + "owner": "cross-system-correlation" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner cross-system-correlation): blocked=mctlhq/mctl-agent#97, blocker=mctlhq/mctl-api#285", + "subject": { + "blocked": { + "number": 97, + "repository": "mctlhq/mctl-agent" + }, + "blocker": { + "number": 285, + "repository": "mctlhq/mctl-api" + }, + "owner": "cross-system-correlation" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner cross-system-correlation): blocked=mctlhq/mctl-agent#97, blocker=mctlhq/mctl-agent#38", + "subject": { + "blocked": { + "number": 97, + "repository": "mctlhq/mctl-agent" + }, + "blocker": { + "number": 38, + "repository": "mctlhq/mctl-agent" + }, + "owner": "cross-system-correlation" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner attribute-catalog): child=mctlhq/mctl-docs#116, expectedParent=mctlhq/.github#55", + "subject": { + "child": { + "number": 116, + "repository": "mctlhq/mctl-docs" + }, + "expectedParent": { + "number": 55, + "repository": "mctlhq/.github" + }, + "owner": "attribute-catalog" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner live-bakeoff): child=mctlhq/mctl-gitops#1280, expectedParent=mctlhq/.github#55", + "subject": { + "child": { + "number": 1280, + "repository": "mctlhq/mctl-gitops" + }, + "expectedParent": { + "number": 55, + "repository": "mctlhq/.github" + }, + "owner": "live-bakeoff" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-docs#107, observedParent=mctlhq/.github#55", + "subject": { + "child": { + "number": 107, + "repository": "mctlhq/mctl-docs" + }, + "observedParent": { + "number": 55, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + } + ], + "epic": { + "issue": { + "number": 55, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/observability.yaml", + "sha256": "95a4158421bd774a632fbb8c12de0d6062e740cd03e3a30dd01b63d0a5fc7a6a" + }, + "name": "observability" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 12, + "error": 0, + "info": 1 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "epic-status-api", + "governed-wave-start" + ], + "items": [ + { + "id": "epic-status-api", + "issue": { + "number": 333, + "repository": "mctlhq/mctl-api" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "governed-apply", + "issue": { + "number": 68, + "repository": "mctlhq/.github" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "governed-wave-start", + "issue": { + "number": 334, + "repository": "mctlhq/mctl-api" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "ready-work-items", + "issue": { + "number": 99, + "repository": "mctlhq/.github" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "reconciler", + "issue": { + "number": 67, + "repository": "mctlhq/.github" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "roadmap-health", + "issue": { + "number": 83, + "repository": "mctlhq/.github" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "roadmap-publication", + "issue": { + "number": 119, + "repository": "mctlhq/.github" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "temporal-health", + "issue": { + "number": 85, + "repository": "mctlhq/.github" + }, + "reason": "open", + "required": false, + "status": "incomplete" + } + ], + "mode": "allRequired", + "required": { + "complete": 5, + "incomplete": 2, + "total": 7, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [], + "epic": { + "issue": { + "number": 66, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/roadmap-control-plane.yaml", + "sha256": "d1f675d3915e874d4f74f01729b839a57c46db3a744d52575df4497a8549bee7" + }, + "name": "roadmap-control-plane" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "healthy", + "summary": { + "drift": 0, + "error": 0, + "info": 0 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "agentic-work-item-ux", + "second-surface-pilot", + "surface-identity-link", + "telegram-work-item-adapter", + "work-item-api", + "working-memory-continuity" + ], + "items": [ + { + "id": "agentic-work-item-ux", + "issue": { + "number": 126, + "repository": "mctlhq/mctl-portal" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "investigator-resume", + "issue": { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "second-surface-pilot", + "issue": { + "number": 341, + "repository": "mctlhq/mctl-api" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "surface-identity-link", + "issue": { + "number": 350, + "repository": "mctlhq/mctl-api" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "telegram-work-item-adapter", + "issue": { + "number": 443, + "repository": "mctlhq/mctl-telegram" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "work-item-api", + "issue": { + "number": 349, + "repository": "mctlhq/mctl-api" + }, + "reason": "open", + "required": true, + "status": "incomplete" + }, + { + "id": "work-item-contract", + "issue": { + "number": 227, + "repository": "mctlhq/mctl-api" + }, + "reason": "closed", + "required": true, + "status": "complete" + }, + { + "id": "working-memory-continuity", + "issue": { + "number": 431, + "repository": "mctlhq/mctl-agents" + }, + "reason": "open", + "required": true, + "status": "incomplete" + } + ], + "mode": "allRequired", + "required": { + "complete": 2, + "incomplete": 6, + "total": 8, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner agentic-work-item-ux): blocked=mctlhq/mctl-portal#126, blocker=mctlhq/mctl-api#341", + "subject": { + "blocked": { + "number": 126, + "repository": "mctlhq/mctl-portal" + }, + "blocker": { + "number": 341, + "repository": "mctlhq/mctl-api" + }, + "owner": "agentic-work-item-ux" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner agentic-work-item-ux): blocked=mctlhq/mctl-portal#126, blocker=mctlhq/mctl-agents#431", + "subject": { + "blocked": { + "number": 126, + "repository": "mctlhq/mctl-portal" + }, + "blocker": { + "number": 431, + "repository": "mctlhq/mctl-agents" + }, + "owner": "agentic-work-item-ux" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner investigator-resume): blocked=mctlhq/mctl-agents#267, blocker=mctlhq/mctl-gitops#1279", + "subject": { + "blocked": { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 1279, + "repository": "mctlhq/mctl-gitops" + }, + "owner": "investigator-resume" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner investigator-resume): blocked=mctlhq/mctl-agents#267, blocker=mctlhq/mctl-agents#264", + "subject": { + "blocked": { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 264, + "repository": "mctlhq/mctl-agents" + }, + "owner": "investigator-resume" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner investigator-resume): blocked=mctlhq/mctl-agents#267, blocker=mctlhq/mctl-api#335", + "subject": { + "blocked": { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 335, + "repository": "mctlhq/mctl-api" + }, + "owner": "investigator-resume" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner working-memory-continuity): blocked=mctlhq/mctl-agents#431, blocker=mctlhq/mctl-agents#265", + "subject": { + "blocked": { + "number": 431, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 265, + "repository": "mctlhq/mctl-agents" + }, + "owner": "working-memory-continuity" + } + }, + { + "code": "DependencyMissing", + "level": "drift", + "message": "DependencyMissing (owner working-memory-continuity): blocked=mctlhq/mctl-agents#431, blocker=mctlhq/mctl-agents#267", + "subject": { + "blocked": { + "number": 431, + "repository": "mctlhq/mctl-agents" + }, + "blocker": { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + "owner": "working-memory-continuity" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner agentic-work-item-ux): child=mctlhq/mctl-portal#126, expectedParent=mctlhq/.github#21", + "subject": { + "child": { + "number": 126, + "repository": "mctlhq/mctl-portal" + }, + "expectedParent": { + "number": 21, + "repository": "mctlhq/.github" + }, + "owner": "agentic-work-item-ux" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner second-surface-pilot): child=mctlhq/mctl-api#341, expectedParent=mctlhq/.github#21", + "subject": { + "child": { + "number": 341, + "repository": "mctlhq/mctl-api" + }, + "expectedParent": { + "number": 21, + "repository": "mctlhq/.github" + }, + "owner": "second-surface-pilot" + } + }, + { + "code": "HierarchyMissingParent", + "level": "drift", + "message": "HierarchyMissingParent (owner working-memory-continuity): child=mctlhq/mctl-agents#431, expectedParent=mctlhq/.github#21", + "subject": { + "child": { + "number": 431, + "repository": "mctlhq/mctl-agents" + }, + "expectedParent": { + "number": 21, + "repository": "mctlhq/.github" + }, + "owner": "working-memory-continuity" + } + }, + { + "code": "HierarchyWrongParent", + "level": "drift", + "message": "HierarchyWrongParent (owner telegram-work-item-adapter): child=mctlhq/mctl-telegram#443, expectedParent=mctlhq/.github#21, observedParent=mctlhq/mctl-telegram#517", + "subject": { + "child": { + "number": 443, + "repository": "mctlhq/mctl-telegram" + }, + "expectedParent": { + "number": 21, + "repository": "mctlhq/.github" + }, + "observedParent": { + "number": 517, + "repository": "mctlhq/mctl-telegram" + }, + "owner": "telegram-work-item-adapter" + } + }, + { + "code": "HierarchyUnexpectedChild", + "level": "info", + "message": "HierarchyUnexpectedChild (owner epic): child=mctlhq/mctl-telegram#517, observedParent=mctlhq/.github#21", + "subject": { + "child": { + "number": 517, + "repository": "mctlhq/mctl-telegram" + }, + "observedParent": { + "number": 21, + "repository": "mctlhq/.github" + }, + "owner": "epic" + } + } + ], + "epic": { + "issue": { + "number": 21, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/surfaces.yaml", + "sha256": "d2a2b3f134107c699dc2f7589f14d097a4915a9aa6978f904d732093c899a603" + }, + "name": "surfaces" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "drift", + "summary": { + "drift": 11, + "error": 0, + "info": 1 + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "completion": { + "blocking": [ + "delegated-agent-identity", + "github-decoupling", + "identity-federation", + "mcp-oauth-cimd", + "principal-model" + ], + "items": [ + { + "id": "delegated-agent-identity", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "github-decoupling", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "identity-federation", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "mcp-oauth-cimd", + "reason": "unbound", + "required": true, + "status": "incomplete" + }, + { + "id": "principal-model", + "reason": "unbound", + "required": true, + "status": "incomplete" + } + ], + "mode": "allRequired", + "required": { + "complete": 0, + "incomplete": 5, + "total": 5, + "unknown": 0 + }, + "status": "incomplete" + }, + "diagnostics": [ + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner delegated-agent-identity)", + "subject": { + "owner": "delegated-agent-identity" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner github-decoupling)", + "subject": { + "owner": "github-decoupling" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner identity-federation)", + "subject": { + "owner": "identity-federation" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner mcp-oauth-cimd)", + "subject": { + "owner": "mcp-oauth-cimd" + } + }, + { + "code": "BindingUnbound", + "level": "info", + "message": "BindingUnbound (owner principal-model)", + "subject": { + "owner": "principal-model" + } + } + ], + "epic": { + "issue": { + "number": 91, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/unified-identity.yaml", + "sha256": "27685889b990cc9ccf8ee8d232b93d8760f0ef176524826b169252c780eb1167" + }, + "name": "unified-identity" + }, + "kind": "RoadmapHealth", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "state": "healthy", + "summary": { + "drift": 0, + "error": 0, + "info": 5 + } + } + ], + "kind": "RoadmapHealthList" +} diff --git a/internal/roadmap/testdata/live/publication.json b/internal/roadmap/testdata/live/publication.json new file mode 100644 index 00000000..d712019c --- /dev/null +++ b/internal/roadmap/testdata/live/publication.json @@ -0,0 +1,180 @@ +{ + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "evaluator": { + "revision": "657575c8a118575eaf7bade0b8de447c31ec2dc9" + }, + "files": { + "health.json": { + "sha256": "5270f3c5cfe89aaf313ed6c2daa671fec58f4d9a792c751d765b04a234225452" + }, + "ready-set.json": { + "sha256": "d261f85717a4676c1fd49549385e55a4154c7ec50c3a79d0107a8da540c49fdd" + }, + "snapshot.json": { + "sha256": "fd76df5a0771b3091b7685ea178ab05d06109ff59490974bc2b18aa32fb10a73" + } + }, + "kind": "RoadmapPublication", + "manifests": [ + { + "epic": { + "goal": "Make model usage and cost attributable to exact mctl executions and DevLoop stages through a backend-neutral, durable and replay-safe usage ledger.", + "lifecycle": "active", + "name": "agent-finops", + "title": "Agent FinOps and model usage attribution" + }, + "path": "roadmap/epics/agent-finops.yaml", + "sha256": "b733800ba387f07a9081bf009b1a6eed413ebecc7fe75b2dd967ccc0055bbbe2" + }, + { + "epic": { + "goal": "Make agents first-class declarative platform resources whose immutable definitions and execution profiles determine model, skills, capabilities, policy, runtime, budget and evidence, with governed publication and creation.", + "lifecycle": "active", + "name": "agent-platform", + "title": "Agent definitions, execution profiles and governed agent creation" + }, + "path": "roadmap/epics/agent-platform.yaml", + "sha256": "09efb27eab8afb1261210db66dd450e3923e598a3a54db84c81c78c9b807668d" + }, + { + "epic": { + "goal": "A Telegram message or a GitHub pull request event wakes an already running Claude Code session in mctl-claude-remote through platform Valkey Streams and a Claude Code channel, with no polling and no Slack/Gmail bridge. Events carry references only; Claude hydrates canonical state through MCP and GitHub tools.", + "lifecycle": "completed", + "name": "claude-remote-inbound-channels", + "title": "Event-driven inbound Channels for Claude Remote" + }, + "path": "roadmap/epics/claude-remote-inbound-channels.yaml", + "sha256": "cdbc0b630b9d924d22ffb44f2917c8ba7507ff4b56dbfd10c8aae9c414a6c4d3" + }, + { + "epic": { + "goal": "Model client identity, reachability and notification preferences, then build a consent-aware, previewable and audited path for product communication without granting a model direct broadcast authority.", + "lifecycle": "active", + "name": "client-lifecycle", + "title": "Client onboarding and product communication" + }, + "path": "roadmap/epics/client-lifecycle.yaml", + "sha256": "85d6d932d69db2a4dd653c047410d73c47d5543c4f0b727e782c8934234ddf2d" + }, + { + "epic": { + "goal": "Move durable Cloudflare configuration to reviewed OpenTofu GitOps with mctl-owned remote state, controlled apply, drift detection and an explicit boundary around interactive and runtime state.", + "lifecycle": "active", + "name": "cloudflare-iac", + "title": "Declarative Cloudflare control plane with self-hosted state" + }, + "path": "roadmap/epics/cloudflare-iac.yaml", + "sha256": "83ce4fe606b8908420ac580a9e055a77cb5104b6a26ab35d91b4b42f8f96d799" + }, + { + "epic": { + "goal": "Make execution context a first-class, immutable and evaluable platform resource with provenance, deterministic assembly, retrieval quality signals and versioned context-development lifecycle.", + "lifecycle": "active", + "name": "context-platform", + "title": "Context engineering and retrieval control plane" + }, + "path": "roadmap/epics/context-platform.yaml", + "sha256": "5bbded1b31cec145e1eeb32d8c8bc3f60e08414c7347b6b1be402571fef3d4b9" + }, + { + "epic": { + "goal": "Ensure same-repository implementation PRs never become silently ownerless: direct implementer PRs retain lifecycle ownership, and proposal-less PRs can be safely adopted into the existing bounded review/remediation lifecycle.", + "lifecycle": "completed", + "name": "devloop-pr-adoption", + "title": "Adopt PRs that fall outside normal proposal ownership" + }, + "path": "roadmap/epics/devloop-pr-adoption.yaml", + "sha256": "389dbcc450674de30970f0e8afcfb555e3947416c09fdc812b37e34bad2652ab" + }, + { + "epic": { + "goal": "Evaluate whether Android devices can provide a governed local execution surface for mctl agents without weakening identity, policy, evidence or remote fallback semantics.", + "lifecycle": "paused", + "name": "edge-ai-android", + "title": "Edge AI on Android" + }, + "path": "roadmap/epics/edge-ai-android.yaml", + "sha256": "f29c77722186ed861ff6bbb767f03ce1b07440eb07f7d5af2f25572a83624f29" + }, + { + "epic": { + "goal": "Provide an enterprise MCP entry point where Cloudflare supplies the outer access and portal layer while mctl remains authoritative for identity, authorization, consent, durable execution and audit evidence.", + "lifecycle": "active", + "name": "enterprise-mcp", + "title": "Cloudflare MCP Portal and enterprise MCP boundary" + }, + "path": "roadmap/epics/enterprise-mcp.yaml", + "sha256": "15bf9f29085feff8e49d9f397ebea049862232b3029c52c069213808aaf7d974" + }, + { + "epic": { + "goal": "Allow an agent to suspend durable work, ask an authorized human for missing information, and resume the same WorkItem without treating clarification as approval.", + "lifecycle": "active", + "name": "human-input", + "title": "Durable agent clarification / feedback primitive" + }, + "path": "roadmap/epics/human-input.yaml", + "sha256": "837fc21c9c61e868c44cf92fd6fada6a55f1e342f26f7b17dea0f59da729520e" + }, + { + "epic": { + "goal": "Make lifecycle responsibility explicit, durable and queryable by separating entity ownership from executor claims, authorization and approval, then prove safe handoff, fencing and reconciliation on the DevLoop proposal/PR lifecycle.", + "lifecycle": "completed", + "name": "lifecycle-ownership", + "title": "First-class entity owner/executor contract and reconciliation" + }, + "path": "roadmap/epics/lifecycle-ownership.yaml", + "sha256": "3937bc8a8c061efce42c61d4f29b12d09aca784b9b045ebdb5e8c7e7f51db779" + }, + { + "epic": { + "goal": "Make mctl executions observable as correlated OpenTelemetry traces through a vendor-neutral OTLP boundary, with canonical execution identity, backend portability and no private payload recording by default.", + "lifecycle": "active", + "name": "observability", + "title": "Vendor-neutral execution tracing for agents and DevLoop" + }, + "path": "roadmap/epics/observability.yaml", + "sha256": "95a4158421bd774a632fbb8c12de0d6062e740cd03e3a30dd01b63d0a5fc7a6a" + }, + { + "epic": { + "goal": "Make a versioned EpicDefinition manifest the canonical desired graph for an epic, and derive drift, health and completion from the observed GitHub graph deterministically, with GitHub remaining the human planning surface.", + "lifecycle": "active", + "name": "roadmap-control-plane", + "title": "Declarative EpicDefinition and GitHub graph reconciliation" + }, + "path": "roadmap/epics/roadmap-control-plane.yaml", + "sha256": "d1f675d3915e874d4f74f01729b839a57c46db3a744d52575df4497a8549bee7" + }, + { + "epic": { + "goal": "Keep durable work state in the platform so a WorkItem can be inspected and resumed across Telegram, MCP, CLI and web surfaces without replaying raw conversation history or inheriting privileges across surfaces.", + "lifecycle": "active", + "name": "surfaces", + "title": "Cross-surface working context and agentic UX" + }, + "path": "roadmap/epics/surfaces.yaml", + "sha256": "d2a2b3f134107c699dc2f7589f14d097a4915a9aa6978f904d732093c899a603" + }, + { + "epic": { + "goal": "Decouple MCTL principals from GitHub identity by introducing stable canonical identities for humans, agents and services, with linked external identities, swappable federation, CIMD-aware MCP OAuth and delegated agent identity.", + "lifecycle": "active", + "name": "unified-identity", + "title": "MCTL Unified Identity and Agent Identity Broker" + }, + "path": "roadmap/epics/unified-identity.yaml", + "sha256": "27685889b990cc9ccf8ee8d232b93d8760f0ef176524826b169252c780eb1167" + } + ], + "observation": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "source": { + "ref": "main", + "repository": "mctlhq/.github", + "revision": "657575c8a118575eaf7bade0b8de447c31ec2dc9" + } +} diff --git a/internal/roadmap/testdata/live/ready-set.json b/internal/roadmap/testdata/live/ready-set.json new file mode 100644 index 00000000..7b2393d9 --- /dev/null +++ b/internal/roadmap/testdata/live/ready-set.json @@ -0,0 +1,2743 @@ +{ + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "items": [ + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 48, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/agent-finops.yaml", + "sha256": "b733800ba387f07a9081bf009b1a6eed413ebecc7fe75b2dd967ccc0055bbbe2" + }, + "name": "agent-finops" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "usage-ledger" + ], + "externalDependsOn": [], + "id": "devloop-instrumentation", + "issue": { + "number": 50, + "repository": "mctlhq/.github" + }, + "phase": "instrumentation", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "usage-contract", + "issue": { + "number": 49, + "repository": "mctlhq/.github" + }, + "phase": "contract", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "usage-contract" + ], + "externalDependsOn": [], + "id": "usage-ledger", + "issue": { + "number": 266, + "repository": "mctlhq/mctl-api" + }, + "phase": "ledger", + "required": true, + "state": "complete" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "devloop-instrumentation" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 0, + "complete": 2, + "ready": 1, + "unknown": 0 + }, + "required": { + "blocked": 0, + "complete": 2, + "ready": 1, + "unknown": 0 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 18, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/agent-platform.yaml", + "sha256": "09efb27eab8afb1261210db66dd450e3923e598a3a54db84c81c78c9b807668d" + }, + "name": "agent-platform" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "agent-contract", + "issue": { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + "phase": "contract", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "agent-contract", + "runtime-resolver" + ], + "externalDependsOn": [], + "id": "capability-discovery", + "issue": { + "number": 242, + "repository": "mctlhq/mctl-agents" + }, + "phase": "capabilities", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "execution-admission", + "issue": { + "number": 395, + "repository": "mctlhq/mctl-agents" + }, + "phase": "admission", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "execution-identity" + ], + "externalDependsOn": [], + "id": "execution-evidence", + "issue": { + "number": 199, + "repository": "mctlhq/mctl-agents" + }, + "phase": "governance", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "agent-contract" + ], + "externalDependsOn": [], + "id": "execution-identity", + "issue": { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + "phase": "governance", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "agent-contract" + ], + "externalDependsOn": [], + "id": "gitops-catalog", + "issue": { + "number": 950, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "catalog", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "capability-discovery", + "release-binding", + "runtime-policy", + "human-approval", + "execution-evidence" + ], + "externalDependsOn": [], + "id": "governed-agent-authoring", + "phase": "authoring", + "required": true, + "state": "unknown" + }, + { + "blockers": [ + { + "id": "runtime-policy", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "execution-identity", + "runtime-policy" + ], + "externalDependsOn": [], + "id": "human-approval", + "issue": { + "number": 198, + "repository": "mctlhq/mctl-agents" + }, + "phase": "governance", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "runtime-resolver", + "gitops-catalog" + ], + "externalDependsOn": [], + "id": "release-binding", + "issue": { + "number": 283, + "repository": "mctlhq/mctl-api" + }, + "phase": "catalog", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "execution-identity" + ], + "externalDependsOn": [], + "id": "runtime-policy", + "issue": { + "number": 197, + "repository": "mctlhq/mctl-agents" + }, + "phase": "governance", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "agent-contract" + ], + "externalDependsOn": [], + "id": "runtime-resolver", + "issue": { + "number": 227, + "repository": "mctlhq/mctl-agents" + }, + "phase": "runtime", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "agent-contract", + "runtime-resolver" + ], + "externalDependsOn": [], + "id": "service-skills", + "issue": { + "number": 305, + "repository": "mctlhq/mctl-agents" + }, + "phase": "capabilities", + "required": true, + "state": "complete" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "capability-discovery", + "execution-evidence", + "runtime-policy" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 1, + "complete": 7, + "ready": 3, + "unknown": 1 + }, + "required": { + "blocked": 1, + "complete": 7, + "ready": 3, + "unknown": 1 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 87, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/claude-remote-inbound-channels.yaml", + "sha256": "cdbc0b630b9d924d22ffb44f2917c8ba7507ff4b56dbfd10c8aae9c414a6c4d3" + }, + "name": "claude-remote-inbound-channels" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "valkey" + ], + "externalDependsOn": [], + "id": "channel-skeleton", + "issue": { + "number": 54, + "repository": "mctlhq/mctl-claude-remote" + }, + "phase": "skeleton", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "telegram-producer", + "github-producer" + ], + "externalDependsOn": [], + "id": "delivery-guarantees", + "issue": { + "number": 55, + "repository": "mctlhq/mctl-claude-remote" + }, + "phase": "hardening", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "telegram-producer", + "github-producer", + "delivery-guarantees" + ], + "externalDependsOn": [], + "id": "e2e-audit", + "issue": { + "number": 89, + "repository": "mctlhq/.github" + }, + "phase": "proof", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "envelope", + "issue": { + "number": 88, + "repository": "mctlhq/.github" + }, + "phase": "telegram", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "envelope", + "telegram-producer" + ], + "externalDependsOn": [], + "id": "github-producer", + "issue": { + "number": 323, + "repository": "mctlhq/mctl-api" + }, + "phase": "github", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "channel-skeleton", + "envelope" + ], + "externalDependsOn": [], + "id": "telegram-producer", + "issue": { + "number": 646, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "telegram", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "valkey", + "issue": { + "number": 1260, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "skeleton", + "required": true, + "state": "complete" + } + ], + "kind": "RoadmapReadySet", + "ready": [], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 0, + "complete": 7, + "ready": 0, + "unknown": 0 + }, + "required": { + "blocked": 0, + "complete": 7, + "ready": 0, + "unknown": 0 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 22, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/client-lifecycle.yaml", + "sha256": "85d6d932d69db2a4dd653c047410d73c47d5543c4f0b727e782c8934234ddf2d" + }, + "name": "client-lifecycle" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "client-reachability-preferences", + "issue": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "lifecycle", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "client-reachability-preferences" + ], + "externalDependsOn": [], + "id": "login-bot-update-receiver", + "issue": { + "number": 619, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "broadcast", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "lookup-deployment", + "issue": { + "number": 1182, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "identity", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "operator-identity-lookup", + "safe-broadcast", + "product-update-feed" + ], + "externalDependsOn": [], + "id": "onboarding-integration", + "phase": "onboarding", + "required": true, + "state": "unknown" + }, + { + "blockers": [ + { + "id": "lookup-deployment", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "lookup-deployment" + ], + "externalDependsOn": [], + "id": "operator-identity-lookup", + "issue": { + "number": 400, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "identity", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "safe-broadcast" + ], + "externalDependsOn": [], + "id": "product-update-feed", + "issue": { + "number": 440, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "updates", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "client-reachability-preferences", + "login-bot-update-receiver" + ], + "externalDependsOn": [], + "id": "safe-broadcast", + "issue": { + "number": 439, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "broadcast", + "required": true, + "state": "complete" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "lookup-deployment", + "product-update-feed" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 1, + "complete": 3, + "ready": 2, + "unknown": 1 + }, + "required": { + "blocked": 1, + "complete": 3, + "ready": 2, + "unknown": 1 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 47, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/cloudflare-iac.yaml", + "sha256": "83ce4fe606b8908420ac580a9e055a77cb5104b6a26ab35d91b4b42f8f96d799" + }, + "name": "cloudflare-iac" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "apply-identity" + ], + "externalDependsOn": [], + "id": "account-access", + "issue": { + "number": 1088, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "cutover", + "required": false, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "iac-bootstrap" + ], + "externalDependsOn": [], + "id": "apply-identity", + "issue": { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "bootstrap", + "required": true, + "state": "complete" + }, + { + "blockers": [ + { + "id": "worker-routes", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "zone-state-migration", + "worker-routes" + ], + "externalDependsOn": [], + "id": "git-only-operations-proof", + "issue": { + "number": 1333, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "operations", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "inventory" + ], + "externalDependsOn": [], + "id": "iac-bootstrap", + "issue": { + "number": 1093, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "bootstrap", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "inventory", + "issue": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "inventory", + "required": true, + "state": "complete" + }, + { + "blockers": [ + { + "id": "git-only-operations-proof", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "git-only-operations-proof" + ], + "externalDependsOn": [], + "id": "mcp-portal-iac", + "issue": { + "number": 1092, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "enterprise", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "zone-state-migration" + ], + "externalDependsOn": [], + "id": "operator-runbook", + "issue": { + "number": 1180, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "operations", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "iac-bootstrap" + ], + "externalDependsOn": [], + "id": "state-credential-hardening", + "issue": { + "number": 1118, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "bootstrap", + "required": false, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "apply-identity" + ], + "externalDependsOn": [], + "id": "tunnel-handoff", + "issue": { + "number": 1090, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "cutover", + "required": false, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "apply-identity" + ], + "externalDependsOn": [], + "id": "worker-routes", + "issue": { + "number": 1179, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "cutover", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "zone-state-migration" + ], + "externalDependsOn": [], + "id": "zone-apply-evidence", + "issue": { + "number": 1281, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "cutover", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "apply-identity" + ], + "externalDependsOn": [], + "id": "zone-state-migration", + "issue": { + "number": 1178, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "cutover", + "required": true, + "state": "complete" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "account-access", + "operator-runbook", + "state-credential-hardening", + "tunnel-handoff", + "worker-routes", + "zone-apply-evidence" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 2, + "complete": 4, + "ready": 6, + "unknown": 0 + }, + "required": { + "blocked": 2, + "complete": 4, + "ready": 3, + "unknown": 0 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 20, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/context-platform.yaml", + "sha256": "5bbded1b31cec145e1eeb32d8c8bc3f60e08414c7347b6b1be402571fef3d4b9" + }, + "name": "context-platform" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "context-contract" + ], + "externalDependsOn": [], + "id": "context-assembly", + "issue": { + "number": 265, + "repository": "mctlhq/mctl-agents" + }, + "phase": "assembly", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "context-contract", + "issue": { + "number": 264, + "repository": "mctlhq/mctl-agents" + }, + "phase": "contract", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "retrieval-ranking-trust", + "context-evals" + ], + "externalDependsOn": [], + "id": "context-development-lifecycle", + "phase": "lifecycle", + "required": true, + "state": "unknown" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "context-assembly" + ], + "externalDependsOn": [], + "id": "context-evals", + "issue": { + "number": 266, + "repository": "mctlhq/mctl-agents" + }, + "phase": "evaluation", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "context-assembly" + ], + "externalDependsOn": [], + "id": "retrieval-ranking-trust", + "phase": "ranking", + "required": true, + "state": "unknown" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "context-evals" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 0, + "complete": 2, + "ready": 1, + "unknown": 2 + }, + "required": { + "blocked": 0, + "complete": 2, + "ready": 1, + "unknown": 2 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 38, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/devloop-pr-adoption.yaml", + "sha256": "389dbcc450674de30970f0e8afcfb555e3947416c09fdc812b37e34bad2652ab" + }, + "name": "devloop-pr-adoption" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "proposal-less-pr-adoption" + ], + "externalDependsOn": [], + "id": "cwft-adopted-prs-persistence", + "issue": { + "number": 1278, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "adoption", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "direct-implementer-ownership", + "issue": { + "number": 239, + "repository": "mctlhq/mctl-agents" + }, + "phase": "direct", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "steward-review-fix" + ], + "externalDependsOn": [], + "id": "proposal-less-pr-adoption", + "issue": { + "number": 334, + "repository": "mctlhq/mctl-agents" + }, + "phase": "adoption", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "steward-review-fix", + "issue": { + "number": 292, + "repository": "mctlhq/mctl-agents" + }, + "phase": "steward", + "required": true, + "state": "complete" + } + ], + "kind": "RoadmapReadySet", + "ready": [], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 0, + "complete": 4, + "ready": 0, + "unknown": 0 + }, + "required": { + "blocked": 0, + "complete": 4, + "ready": 0, + "unknown": 0 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 34, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/edge-ai-android.yaml", + "sha256": "f29c77722186ed861ff6bbb767f03ce1b07440eb07f7d5af2f25572a83624f29" + }, + "name": "edge-ai-android" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "feasibility-spike", + "phase": "feasibility", + "required": true, + "state": "unknown" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "feasibility-spike" + ], + "externalDependsOn": [], + "id": "local-execution-contract", + "phase": "contract", + "required": true, + "state": "unknown" + } + ], + "kind": "RoadmapReadySet", + "ready": [], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 0, + "complete": 0, + "ready": 0, + "unknown": 2 + }, + "required": { + "blocked": 0, + "complete": 0, + "ready": 0, + "unknown": 2 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 35, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/enterprise-mcp.yaml", + "sha256": "15bf9f29085feff8e49d9f397ebea049862232b3029c52c069213808aaf7d974" + }, + "name": "enterprise-mcp" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "compatibility-spike", + "issue": { + "number": 44, + "repository": "mctlhq/.github" + }, + "phase": "compatibility", + "required": false, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "gateway-dlp-result" + ], + "externalDependsOn": [], + "id": "correlation-contract", + "issue": { + "number": 617, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "correlation", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "telegram-compatibility", + "compatibility-spike" + ], + "externalDependsOn": [ + { + "number": 609, + "repository": "mctlhq/mctl-telegram" + } + ], + "id": "denied-user-and-bypass", + "issue": { + "number": 616, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "portal", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "denied-user-and-bypass", + "correlation-contract", + "mcp-tasks", + "mcp-apps" + ], + "externalDependsOn": [ + { + "number": 91, + "repository": "mctlhq/.github" + } + ], + "id": "enterprise-endpoint-decision", + "phase": "enterprise-identity", + "required": true, + "state": "unknown" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "telegram-compatibility" + ], + "externalDependsOn": [], + "id": "gateway-dlp-result", + "issue": { + "number": 1181, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "gateway", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "gateway-dlp-result" + ], + "externalDependsOn": [], + "id": "gateway-hardening", + "issue": { + "number": 1193, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "gateway", + "required": false, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "telegram-compatibility" + ], + "externalDependsOn": [], + "id": "local-bridge-proof", + "issue": { + "number": 615, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "portal", + "required": false, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "correlation-contract" + ], + "externalDependsOn": [], + "id": "mcp-apps", + "issue": { + "number": 569, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "extensions", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "mcp-apps" + ], + "externalDependsOn": [], + "id": "mcp-apps-validation", + "issue": { + "number": 650, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "extensions", + "required": true, + "state": "ready" + }, + { + "blockers": [ + { + "issue": { + "number": 41, + "repository": "mctlhq/.github" + }, + "kind": "external", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "correlation-contract", + "compatibility-spike" + ], + "externalDependsOn": [ + { + "number": 41, + "repository": "mctlhq/.github" + } + ], + "id": "mcp-tasks", + "issue": { + "number": 284, + "repository": "mctlhq/mctl-api" + }, + "phase": "extensions", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "telegram-compatibility", + "issue": { + "number": 585, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "compatibility", + "required": true, + "state": "complete" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "denied-user-and-bypass", + "gateway-hardening", + "local-bridge-proof", + "mcp-apps-validation" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 1, + "complete": 5, + "ready": 4, + "unknown": 1 + }, + "required": { + "blocked": 1, + "complete": 4, + "ready": 2, + "unknown": 1 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 42, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/human-input.yaml", + "sha256": "837fc21c9c61e868c44cf92fd6fada6a55f1e342f26f7b17dea0f59da729520e" + }, + "name": "human-input" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "human-input-core" + ], + "externalDependsOn": [], + "id": "catalog-profile-rollout", + "issue": { + "number": 1277, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "core", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "telegram-adapter", + "catalog-profile-rollout" + ], + "externalDependsOn": [], + "id": "devloop-e2e", + "phase": "e2e", + "required": true, + "state": "unknown" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "human-input-core", + "human-input-api" + ], + "externalDependsOn": [], + "id": "docs", + "issue": { + "number": 106, + "repository": "mctlhq/mctl-docs" + }, + "phase": "surfaces", + "required": false, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "human-input-core" + ], + "externalDependsOn": [], + "id": "human-input-api", + "issue": { + "number": 261, + "repository": "mctlhq/mctl-api" + }, + "phase": "api", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "human-input-core", + "issue": { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + "phase": "core", + "required": true, + "state": "complete" + }, + { + "blockers": [ + { + "issue": { + "number": 350, + "repository": "mctlhq/mctl-api" + }, + "kind": "external", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "human-input-api" + ], + "externalDependsOn": [ + { + "number": 350, + "repository": "mctlhq/mctl-api" + } + ], + "id": "portal-card", + "issue": { + "number": 124, + "repository": "mctlhq/mctl-portal" + }, + "phase": "surfaces", + "required": false, + "state": "blocked" + }, + { + "blockers": [ + { + "issue": { + "number": 350, + "repository": "mctlhq/mctl-api" + }, + "kind": "external", + "reason": "open", + "status": "incomplete" + }, + { + "issue": { + "number": 443, + "repository": "mctlhq/mctl-telegram" + }, + "kind": "external", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "human-input-core", + "human-input-api" + ], + "externalDependsOn": [ + { + "number": 443, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 350, + "repository": "mctlhq/mctl-api" + } + ], + "id": "telegram-adapter", + "issue": { + "number": 571, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "surfaces", + "required": true, + "state": "blocked" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "docs" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 2, + "complete": 3, + "ready": 1, + "unknown": 1 + }, + "required": { + "blocked": 1, + "complete": 3, + "ready": 0, + "unknown": 1 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 57, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/lifecycle-ownership.yaml", + "sha256": "3937bc8a8c061efce42c61d4f29b12d09aca784b9b045ebdb5e8c7e7f51db779" + }, + "name": "lifecycle-ownership" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "ownership-contract" + ], + "externalDependsOn": [], + "id": "devloop-ownership", + "issue": { + "number": 351, + "repository": "mctlhq/mctl-agents" + }, + "phase": "pilot", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "ownership-contract", + "devloop-ownership" + ], + "externalDependsOn": [], + "id": "executor-fencing", + "issue": { + "number": 352, + "repository": "mctlhq/mctl-agents" + }, + "phase": "fencing", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "ownership-inspection", + "executor-fencing", + "ownership-reconciler" + ], + "externalDependsOn": [], + "id": "guarded-recovery", + "issue": { + "number": 294, + "repository": "mctlhq/mctl-api" + }, + "phase": "operations", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "ownership-contract", + "issue": { + "number": 350, + "repository": "mctlhq/mctl-agents" + }, + "phase": "contract", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "ownership-contract", + "devloop-ownership" + ], + "externalDependsOn": [], + "id": "ownership-inspection", + "issue": { + "number": 293, + "repository": "mctlhq/mctl-api" + }, + "phase": "operations", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "ownership-contract", + "devloop-ownership", + "executor-fencing" + ], + "externalDependsOn": [], + "id": "ownership-reconciler", + "issue": { + "number": 353, + "repository": "mctlhq/mctl-agents" + }, + "phase": "reconcile", + "required": true, + "state": "complete" + } + ], + "kind": "RoadmapReadySet", + "ready": [], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 0, + "complete": 6, + "ready": 0, + "unknown": 0 + }, + "required": { + "blocked": 0, + "complete": 6, + "ready": 0, + "unknown": 0 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 55, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/observability.yaml", + "sha256": "95a4158421bd774a632fbb8c12de0d6062e740cd03e3a30dd01b63d0a5fc7a6a" + }, + "name": "observability" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "telemetry-gateway" + ], + "externalDependsOn": [ + { + "number": 196, + "repository": "mctlhq/mctl-agents" + } + ], + "id": "agent-traces", + "issue": { + "number": 38, + "repository": "mctlhq/mctl-agent" + }, + "phase": "tracing", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "telemetry-gateway" + ], + "externalDependsOn": [], + "id": "attribute-catalog", + "issue": { + "number": 116, + "repository": "mctlhq/mctl-docs" + }, + "phase": "gateway", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "telemetry-gateway" + ], + "externalDependsOn": [], + "id": "backend-bakeoff", + "issue": { + "number": 903, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "backend", + "required": true, + "state": "complete" + }, + { + "blockers": [ + { + "id": "devloop-traces", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + }, + { + "id": "mcp-span-context", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "devloop-traces", + "agent-traces", + "mcp-span-context" + ], + "externalDependsOn": [], + "id": "cross-system-correlation", + "issue": { + "number": 97, + "repository": "mctlhq/mctl-agent" + }, + "phase": "correlation", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "telemetry-gateway" + ], + "externalDependsOn": [ + { + "number": 196, + "repository": "mctlhq/mctl-agents" + } + ], + "id": "devloop-traces", + "issue": { + "number": 195, + "repository": "mctlhq/mctl-agents" + }, + "phase": "tracing", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "backend-bakeoff" + ], + "externalDependsOn": [], + "id": "live-bakeoff", + "issue": { + "number": 1280, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "backend", + "required": true, + "state": "ready" + }, + { + "blockers": [ + { + "id": "devloop-traces", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "devloop-traces" + ], + "externalDependsOn": [], + "id": "mcp-span-context", + "issue": { + "number": 285, + "repository": "mctlhq/mctl-api" + }, + "phase": "correlation", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "telemetry-gateway", + "issue": { + "number": 902, + "repository": "mctlhq/mctl-gitops" + }, + "phase": "gateway", + "required": true, + "state": "complete" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "devloop-traces", + "live-bakeoff" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 2, + "complete": 4, + "ready": 2, + "unknown": 0 + }, + "required": { + "blocked": 2, + "complete": 4, + "ready": 2, + "unknown": 0 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 66, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/roadmap-control-plane.yaml", + "sha256": "d1f675d3915e874d4f74f01729b839a57c46db3a744d52575df4497a8549bee7" + }, + "name": "roadmap-control-plane" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "ready-work-items", + "roadmap-publication" + ], + "externalDependsOn": [], + "id": "epic-status-api", + "issue": { + "number": 333, + "repository": "mctlhq/mctl-api" + }, + "phase": "waves", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "reconciler" + ], + "externalDependsOn": [], + "id": "governed-apply", + "issue": { + "number": 68, + "repository": "mctlhq/.github" + }, + "phase": "apply", + "required": true, + "state": "complete" + }, + { + "blockers": [ + { + "id": "epic-status-api", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "ready-work-items", + "epic-status-api" + ], + "externalDependsOn": [], + "id": "governed-wave-start", + "issue": { + "number": 334, + "repository": "mctlhq/mctl-api" + }, + "phase": "waves", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "reconciler", + "roadmap-health" + ], + "externalDependsOn": [], + "id": "ready-work-items", + "issue": { + "number": 99, + "repository": "mctlhq/.github" + }, + "phase": "waves", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "reconciler", + "issue": { + "number": 67, + "repository": "mctlhq/.github" + }, + "phase": "detect", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "reconciler" + ], + "externalDependsOn": [], + "id": "roadmap-health", + "issue": { + "number": 83, + "repository": "mctlhq/.github" + }, + "phase": "health", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "ready-work-items", + "roadmap-health" + ], + "externalDependsOn": [], + "id": "roadmap-publication", + "issue": { + "number": 119, + "repository": "mctlhq/.github" + }, + "phase": "waves", + "required": true, + "state": "complete" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "roadmap-health" + ], + "externalDependsOn": [], + "id": "temporal-health", + "issue": { + "number": 85, + "repository": "mctlhq/.github" + }, + "phase": "temporal", + "required": false, + "state": "ready" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "epic-status-api", + "temporal-health" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 1, + "complete": 5, + "ready": 2, + "unknown": 0 + }, + "required": { + "blocked": 1, + "complete": 5, + "ready": 1, + "unknown": 0 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 21, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/surfaces.yaml", + "sha256": "d2a2b3f134107c699dc2f7589f14d097a4915a9aa6978f904d732093c899a603" + }, + "name": "surfaces" + }, + "items": [ + { + "blockers": [ + { + "id": "second-surface-pilot", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + }, + { + "id": "working-memory-continuity", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "second-surface-pilot", + "working-memory-continuity" + ], + "externalDependsOn": [], + "id": "agentic-work-item-ux", + "issue": { + "number": 126, + "repository": "mctlhq/mctl-portal" + }, + "phase": "ux", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [ + "work-item-contract" + ], + "externalDependsOn": [ + { + "number": 264, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 1279, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 335, + "repository": "mctlhq/mctl-api" + } + ], + "id": "investigator-resume", + "issue": { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + "phase": "resume", + "required": true, + "state": "complete" + }, + { + "blockers": [ + { + "id": "telegram-work-item-adapter", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + }, + { + "id": "work-item-api", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "work-item-api", + "investigator-resume", + "telegram-work-item-adapter" + ], + "externalDependsOn": [], + "id": "second-surface-pilot", + "issue": { + "number": 341, + "repository": "mctlhq/mctl-api" + }, + "phase": "resume", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "work-item-contract" + ], + "externalDependsOn": [], + "id": "surface-identity-link", + "issue": { + "number": 350, + "repository": "mctlhq/mctl-api" + }, + "phase": "contract", + "required": true, + "state": "ready" + }, + { + "blockers": [ + { + "id": "surface-identity-link", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + }, + { + "id": "work-item-api", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "work-item-contract", + "work-item-api", + "surface-identity-link", + "investigator-resume" + ], + "externalDependsOn": [], + "id": "telegram-work-item-adapter", + "issue": { + "number": 443, + "repository": "mctlhq/mctl-telegram" + }, + "phase": "resume", + "required": true, + "state": "blocked" + }, + { + "blockers": [], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "work-item-contract" + ], + "externalDependsOn": [], + "id": "work-item-api", + "issue": { + "number": 349, + "repository": "mctlhq/mctl-api" + }, + "phase": "contract", + "required": true, + "state": "ready" + }, + { + "blockers": [], + "completion": { + "reason": "closed", + "status": "complete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "work-item-contract", + "issue": { + "number": 227, + "repository": "mctlhq/mctl-api" + }, + "phase": "contract", + "required": true, + "state": "complete" + }, + { + "blockers": [ + { + "id": "work-item-api", + "kind": "workItem", + "reason": "open", + "status": "incomplete" + } + ], + "completion": { + "reason": "open", + "status": "incomplete" + }, + "dependsOn": [ + "investigator-resume", + "work-item-api" + ], + "externalDependsOn": [ + { + "number": 265, + "repository": "mctlhq/mctl-agents" + } + ], + "id": "working-memory-continuity", + "issue": { + "number": 431, + "repository": "mctlhq/mctl-agents" + }, + "phase": "continuity", + "required": true, + "state": "blocked" + } + ], + "kind": "RoadmapReadySet", + "ready": [ + "surface-identity-link", + "work-item-api" + ], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 4, + "complete": 2, + "ready": 2, + "unknown": 0 + }, + "required": { + "blocked": 4, + "complete": 2, + "ready": 2, + "unknown": 0 + } + } + }, + { + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "epic": { + "issue": { + "number": 91, + "repository": "mctlhq/.github" + }, + "manifest": { + "path": "roadmap/epics/unified-identity.yaml", + "sha256": "27685889b990cc9ccf8ee8d232b93d8760f0ef176524826b169252c780eb1167" + }, + "name": "unified-identity" + }, + "items": [ + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "principal-model" + ], + "externalDependsOn": [], + "id": "delegated-agent-identity", + "phase": "delegation", + "required": true, + "state": "unknown" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "identity-federation", + "mcp-oauth-cimd", + "delegated-agent-identity" + ], + "externalDependsOn": [], + "id": "github-decoupling", + "phase": "migration", + "required": true, + "state": "unknown" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "principal-model" + ], + "externalDependsOn": [], + "id": "identity-federation", + "phase": "federation", + "required": true, + "state": "unknown" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [ + "principal-model", + "identity-federation" + ], + "externalDependsOn": [], + "id": "mcp-oauth-cimd", + "phase": "mcp", + "required": true, + "state": "unknown" + }, + { + "blockers": [], + "completion": { + "reason": "unbound", + "status": "incomplete" + }, + "dependsOn": [], + "externalDependsOn": [], + "id": "principal-model", + "phase": "principal", + "required": true, + "state": "unknown" + } + ], + "kind": "RoadmapReadySet", + "ready": [], + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + }, + "summary": { + "items": { + "blocked": 0, + "complete": 0, + "ready": 0, + "unknown": 5 + }, + "required": { + "blocked": 0, + "complete": 0, + "ready": 0, + "unknown": 5 + } + } + } + ], + "kind": "RoadmapReadySetList" +} diff --git a/internal/roadmap/testdata/live/snapshot.json b/internal/roadmap/testdata/live/snapshot.json new file mode 100644 index 00000000..07c8437f --- /dev/null +++ b/internal/roadmap/testdata/live/snapshot.json @@ -0,0 +1,2698 @@ +{ + "apiVersion": "roadmap.mctl.ai/v1alpha1", + "issues": [ + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 18, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 18, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 199, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 227, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 950, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 242, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 197, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 198, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 305, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 112, + "repository": "mctlhq/mctl-portal" + }, + { + "number": 283, + "repository": "mctlhq/mctl-api" + } + ], + "updatedAt": "2026-09-20T15:03:52Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 20, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 20, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 310, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 111, + "repository": "mctlhq/mctl-portal" + }, + { + "number": 249, + "repository": "mctlhq/mctl-api" + }, + { + "number": 264, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 265, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 266, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 60, + "repository": "mctlhq/mctl-agents" + } + ], + "updatedAt": "2026-09-11T22:47:31Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 21, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 21, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 227, + "repository": "mctlhq/mctl-api" + }, + { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 517, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 349, + "repository": "mctlhq/mctl-api" + }, + { + "number": 350, + "repository": "mctlhq/mctl-api" + } + ], + "updatedAt": "2026-09-20T15:41:36Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 22, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 22, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 515, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 618, + "repository": "mctlhq/mctl-telegram" + } + ], + "updatedAt": "2026-09-11T22:48:40Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 34, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 34, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-11T22:48:48Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 35, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 35, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 44, + "repository": "mctlhq/.github" + }, + { + "number": 43, + "repository": "mctlhq/.github" + }, + { + "number": 41, + "repository": "mctlhq/.github" + }, + { + "number": 568, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 569, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 276, + "repository": "mctlhq/mctl-api" + }, + { + "number": 54, + "repository": "mctlhq/seerrsense" + }, + { + "number": 607, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 614, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 615, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 616, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 284, + "repository": "mctlhq/mctl-api" + }, + { + "number": 1181, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 617, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 63, + "repository": "mctlhq/.github" + } + ], + "updatedAt": "2026-09-11T23:28:41Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 38, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 38, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [ + { + "number": 239, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 292, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 334, + "repository": "mctlhq/mctl-agents" + } + ], + "updatedAt": "2026-09-22T19:13:21Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 41, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 41, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-08T07:31:03Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 42, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 42, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 261, + "repository": "mctlhq/mctl-api" + }, + { + "number": 571, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 106, + "repository": "mctlhq/mctl-docs" + } + ], + "updatedAt": "2026-09-11T22:48:20Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 44, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 44, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [ + { + "number": 585, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 605, + "repository": "mctlhq/mctl-telegram" + } + ], + "updatedAt": "2026-09-22T10:15:38Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 47, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 47, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1109, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1114, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1115, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1118, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1178, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1179, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1180, + "repository": "mctlhq/mctl-gitops" + } + ], + "updatedAt": "2026-09-11T22:47:45Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 48, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 48, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 49, + "repository": "mctlhq/.github" + }, + { + "number": 50, + "repository": "mctlhq/.github" + }, + { + "number": 266, + "repository": "mctlhq/mctl-api" + } + ], + "updatedAt": "2026-09-11T22:48:33Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 48, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 49, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 49, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T19:11:13Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 48, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 50, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 50, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-08T23:46:39Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 55, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 55, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 902, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 903, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 195, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 38, + "repository": "mctlhq/mctl-agent" + }, + { + "number": 97, + "repository": "mctlhq/mctl-agent" + }, + { + "number": 107, + "repository": "mctlhq/mctl-docs" + }, + { + "number": 285, + "repository": "mctlhq/mctl-api" + } + ], + "updatedAt": "2026-09-12T11:11:47Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 57, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 57, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [ + { + "number": 350, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 351, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 352, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 353, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 293, + "repository": "mctlhq/mctl-api" + }, + { + "number": 294, + "repository": "mctlhq/mctl-api" + } + ], + "updatedAt": "2026-09-22T07:07:36Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 66, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 66, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [ + { + "number": 83, + "repository": "mctlhq/.github" + }, + { + "number": 85, + "repository": "mctlhq/.github" + }, + { + "number": 67, + "repository": "mctlhq/.github" + }, + { + "number": 68, + "repository": "mctlhq/.github" + }, + { + "number": 333, + "repository": "mctlhq/mctl-api" + }, + { + "number": 334, + "repository": "mctlhq/mctl-api" + }, + { + "number": 99, + "repository": "mctlhq/.github" + }, + { + "number": 119, + "repository": "mctlhq/.github" + } + ], + "updatedAt": "2026-09-18T14:52:01Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 66, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 67, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 67, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-16T22:41:30Z" + }, + { + "blockedBy": [ + { + "number": 67, + "repository": "mctlhq/.github" + } + ], + "found": true, + "parent": { + "number": 66, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 68, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 68, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-19T00:00:13Z" + }, + { + "blockedBy": [ + { + "number": 67, + "repository": "mctlhq/.github" + } + ], + "found": true, + "parent": { + "number": 66, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 83, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 83, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-16T23:05:55Z" + }, + { + "blockedBy": [ + { + "number": 83, + "repository": "mctlhq/.github" + } + ], + "found": true, + "parent": { + "number": 66, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 85, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 85, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-18T14:52:08Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 87, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 87, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [ + { + "number": 88, + "repository": "mctlhq/.github" + }, + { + "number": 1260, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 646, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 323, + "repository": "mctlhq/mctl-api" + }, + { + "number": 54, + "repository": "mctlhq/mctl-claude-remote" + }, + { + "number": 55, + "repository": "mctlhq/mctl-claude-remote" + }, + { + "number": 89, + "repository": "mctlhq/.github" + } + ], + "updatedAt": "2026-09-18T06:33:13Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 87, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 88, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 88, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-17T12:13:29Z" + }, + { + "blockedBy": [ + { + "number": 646, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 323, + "repository": "mctlhq/mctl-api" + }, + { + "number": 55, + "repository": "mctlhq/mctl-claude-remote" + } + ], + "found": true, + "parent": { + "number": 87, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 89, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 89, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-18T06:32:20Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 91, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 91, + "repository": "mctlhq/.github" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-17T22:36:34Z" + }, + { + "blockedBy": [ + { + "number": 67, + "repository": "mctlhq/.github" + }, + { + "number": 83, + "repository": "mctlhq/.github" + } + ], + "found": true, + "parent": { + "number": 66, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 99, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 99, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-21T23:07:54Z" + }, + { + "blockedBy": [ + { + "number": 99, + "repository": "mctlhq/.github" + }, + { + "number": 83, + "repository": "mctlhq/.github" + } + ], + "found": true, + "parent": { + "number": 66, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 119, + "repository": "mctlhq/.github" + }, + "resolved": { + "number": 119, + "repository": "mctlhq/.github" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-23T08:57:24Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 55, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 38, + "repository": "mctlhq/mctl-agent" + }, + "resolved": { + "number": 38, + "repository": "mctlhq/mctl-agent" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-23T05:17:05Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 55, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 97, + "repository": "mctlhq/mctl-agent" + }, + "resolved": { + "number": 97, + "repository": "mctlhq/mctl-agent" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-11T00:23:30Z" + }, + { + "blockedBy": [ + { + "number": 196, + "repository": "mctlhq/mctl-agents" + } + ], + "found": true, + "parent": { + "number": 55, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 195, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 195, + "repository": "mctlhq/mctl-agents" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-08-29T06:28:10Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 196, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T20:49:51Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 197, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 197, + "repository": "mctlhq/mctl-agents" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-19T06:58:11Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 198, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 198, + "repository": "mctlhq/mctl-agents" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-01T16:35:45Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 199, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 199, + "repository": "mctlhq/mctl-agents" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-08-29T06:28:17Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 226, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-08-29T22:12:36Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 227, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 227, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-02T00:50:29Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 38, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 239, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 239, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T19:12:53Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 242, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 242, + "repository": "mctlhq/mctl-agents" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-19T00:13:46Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 20, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 264, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 264, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-11T21:09:06Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 20, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 265, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 265, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-19T09:30:42Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 20, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 266, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 266, + "repository": "mctlhq/mctl-agents" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-20T18:14:34Z" + }, + { + "blockedBy": [ + { + "number": 227, + "repository": "mctlhq/mctl-api" + } + ], + "found": true, + "parent": { + "number": 21, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T21:46:34Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 38, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 292, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 292, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-12T00:57:18Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 305, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 305, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T22:18:25Z" + }, + { + "blockedBy": [ + { + "number": 227, + "repository": "mctlhq/mctl-api" + }, + { + "number": 264, + "repository": "mctlhq/mctl-agents" + } + ], + "found": true, + "parent": { + "number": 42, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-23T00:17:38Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 38, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 334, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 334, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-19T11:16:47Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 57, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 350, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 350, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-12T09:32:56Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 57, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 351, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 351, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-13T23:23:25Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 57, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 352, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 352, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-19T07:07:19Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 57, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 353, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 353, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-18T23:17:04Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 395, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 395, + "repository": "mctlhq/mctl-agents" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [ + { + "number": 396, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 1285, + "repository": "mctlhq/mctl-gitops" + } + ], + "updatedAt": "2026-09-23T09:07:51Z" + }, + { + "blockedBy": [ + { + "number": 349, + "repository": "mctlhq/mctl-api" + } + ], + "found": true, + "parent": null, + "requested": { + "number": 431, + "repository": "mctlhq/mctl-agents" + }, + "resolved": { + "number": 431, + "repository": "mctlhq/mctl-agents" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-23T04:34:24Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 21, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 227, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 227, + "repository": "mctlhq/mctl-api" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-11T10:12:29Z" + }, + { + "blockedBy": [ + { + "number": 333, + "repository": "mctlhq/mctl-agents" + } + ], + "found": true, + "parent": { + "number": 42, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 261, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 261, + "repository": "mctlhq/mctl-api" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-23T04:17:59Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 48, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 266, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 266, + "repository": "mctlhq/mctl-api" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T21:07:14Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 283, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 283, + "repository": "mctlhq/mctl-api" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-19T01:52:27Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 284, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 284, + "repository": "mctlhq/mctl-api" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-11T00:23:48Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 55, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 285, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 285, + "repository": "mctlhq/mctl-api" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-11T00:24:17Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 57, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 293, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 293, + "repository": "mctlhq/mctl-api" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-14T13:24:28Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 57, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 294, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 294, + "repository": "mctlhq/mctl-api" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T06:51:35Z" + }, + { + "blockedBy": [ + { + "number": 88, + "repository": "mctlhq/.github" + }, + { + "number": 646, + "repository": "mctlhq/mctl-telegram" + } + ], + "found": true, + "parent": { + "number": 87, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 323, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 323, + "repository": "mctlhq/mctl-api" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-17T13:51:05Z" + }, + { + "blockedBy": [ + { + "number": 99, + "repository": "mctlhq/.github" + }, + { + "number": 119, + "repository": "mctlhq/.github" + } + ], + "found": true, + "parent": { + "number": 66, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 333, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 333, + "repository": "mctlhq/mctl-api" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-23T07:38:16Z" + }, + { + "blockedBy": [ + { + "number": 333, + "repository": "mctlhq/mctl-api" + }, + { + "number": 99, + "repository": "mctlhq/.github" + } + ], + "found": true, + "parent": { + "number": 66, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 334, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 334, + "repository": "mctlhq/mctl-api" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-23T07:38:27Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 335, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 335, + "repository": "mctlhq/mctl-api" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-19T18:24:30Z" + }, + { + "blockedBy": [ + { + "number": 349, + "repository": "mctlhq/mctl-api" + }, + { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 443, + "repository": "mctlhq/mctl-telegram" + } + ], + "found": true, + "parent": null, + "requested": { + "number": 341, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 341, + "repository": "mctlhq/mctl-api" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-20T15:40:34Z" + }, + { + "blockedBy": [ + { + "number": 227, + "repository": "mctlhq/mctl-api" + } + ], + "found": true, + "parent": { + "number": 21, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 349, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 349, + "repository": "mctlhq/mctl-api" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-23T06:49:26Z" + }, + { + "blockedBy": [ + { + "number": 227, + "repository": "mctlhq/mctl-api" + } + ], + "found": true, + "parent": { + "number": 21, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 350, + "repository": "mctlhq/mctl-api" + }, + "resolved": { + "number": 350, + "repository": "mctlhq/mctl-api" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-23T06:49:02Z" + }, + { + "blockedBy": [ + { + "number": 1260, + "repository": "mctlhq/mctl-gitops" + } + ], + "found": true, + "parent": { + "number": 87, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 54, + "repository": "mctlhq/mctl-claude-remote" + }, + "resolved": { + "number": 54, + "repository": "mctlhq/mctl-claude-remote" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-17T14:46:10Z" + }, + { + "blockedBy": [ + { + "number": 646, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 323, + "repository": "mctlhq/mctl-api" + } + ], + "found": true, + "parent": { + "number": 87, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 55, + "repository": "mctlhq/mctl-claude-remote" + }, + "resolved": { + "number": 55, + "repository": "mctlhq/mctl-claude-remote" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-17T20:45:44Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 42, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 106, + "repository": "mctlhq/mctl-docs" + }, + "resolved": { + "number": 106, + "repository": "mctlhq/mctl-docs" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-23T05:39:15Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 116, + "repository": "mctlhq/mctl-docs" + }, + "resolved": { + "number": 116, + "repository": "mctlhq/mctl-docs" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T11:48:02Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 55, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 902, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 902, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-11T16:36:45Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 55, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 903, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 903, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T07:41:54Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 18, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 950, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 950, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-08-30T06:09:46Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [ + { + "number": 1093, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1089, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1087, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1088, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1090, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1091, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1092, + "repository": "mctlhq/mctl-gitops" + }, + { + "number": 1103, + "repository": "mctlhq/mctl-gitops" + } + ], + "updatedAt": "2026-09-09T07:45:52Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "requested": { + "number": 1088, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1088, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-09T17:43:03Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "requested": { + "number": 1090, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1090, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-09T17:44:54Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "requested": { + "number": 1092, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1092, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-12T09:55:30Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 1083, + "repository": "mctlhq/mctl-gitops" + }, + "requested": { + "number": 1093, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1093, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-09T09:22:44Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1111, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-11T06:23:11Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 1118, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1118, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-09T16:27:21Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 1178, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1178, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-19T15:37:35Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 1179, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1179, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-11T00:23:09Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 47, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 1180, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1180, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-11T00:23:10Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 1181, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1181, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-11T22:07:34Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 515, + "repository": "mctlhq/mctl-telegram" + }, + "requested": { + "number": 1182, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1182, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-22T19:06:19Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 1193, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1193, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-11T22:07:10Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 87, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 1260, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1260, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-17T14:31:31Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 1277, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1277, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-23T01:25:25Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 1278, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1278, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-21T23:07:57Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 1279, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1279, + "repository": "mctlhq/mctl-gitops" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-21T16:06:18Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 1280, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1280, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-19T00:13:45Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 1281, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1281, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-22T19:06:31Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 1333, + "repository": "mctlhq/mctl-gitops" + }, + "resolved": { + "number": 1333, + "repository": "mctlhq/mctl-gitops" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-22T19:08:09Z" + }, + { + "blockedBy": [ + { + "number": 350, + "repository": "mctlhq/mctl-api" + } + ], + "found": true, + "parent": { + "number": 111, + "repository": "mctlhq/mctl-portal" + }, + "requested": { + "number": 124, + "repository": "mctlhq/mctl-portal" + }, + "resolved": { + "number": 124, + "repository": "mctlhq/mctl-portal" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-23T05:22:17Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 126, + "repository": "mctlhq/mctl-portal" + }, + "resolved": { + "number": 126, + "repository": "mctlhq/mctl-portal" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-20T15:40:46Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 515, + "repository": "mctlhq/mctl-telegram" + }, + "requested": { + "number": 400, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 400, + "repository": "mctlhq/mctl-telegram" + }, + "state": "open", + "stateReason": "reopened", + "subIssues": [], + "updatedAt": "2026-09-11T00:25:42Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 515, + "repository": "mctlhq/mctl-telegram" + }, + "requested": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 438, + "repository": "mctlhq/mctl-telegram" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [ + { + "number": 620, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 621, + "repository": "mctlhq/mctl-telegram" + }, + { + "number": 622, + "repository": "mctlhq/mctl-telegram" + } + ], + "updatedAt": "2026-09-22T16:04:37Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 618, + "repository": "mctlhq/mctl-telegram" + }, + "requested": { + "number": 439, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 439, + "repository": "mctlhq/mctl-telegram" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-23T02:13:31Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 618, + "repository": "mctlhq/mctl-telegram" + }, + "requested": { + "number": 440, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 440, + "repository": "mctlhq/mctl-telegram" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-01T10:30:29Z" + }, + { + "blockedBy": [ + { + "number": 267, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 349, + "repository": "mctlhq/mctl-api" + }, + { + "number": 350, + "repository": "mctlhq/mctl-api" + }, + { + "number": 227, + "repository": "mctlhq/mctl-api" + } + ], + "found": true, + "parent": { + "number": 517, + "repository": "mctlhq/mctl-telegram" + }, + "requested": { + "number": 443, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 443, + "repository": "mctlhq/mctl-telegram" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-23T07:39:59Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 569, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 569, + "repository": "mctlhq/mctl-telegram" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-19T17:36:20Z" + }, + { + "blockedBy": [ + { + "number": 333, + "repository": "mctlhq/mctl-agents" + }, + { + "number": 350, + "repository": "mctlhq/mctl-api" + }, + { + "number": 443, + "repository": "mctlhq/mctl-telegram" + } + ], + "found": true, + "parent": { + "number": 42, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 571, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 571, + "repository": "mctlhq/mctl-telegram" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-23T06:28:41Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 44, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 585, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 585, + "repository": "mctlhq/mctl-telegram" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-10T16:33:05Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 609, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 609, + "repository": "mctlhq/mctl-telegram" + }, + "state": "closed", + "subIssues": [], + "updatedAt": "2026-09-10T18:39:18Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 615, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 615, + "repository": "mctlhq/mctl-telegram" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-11T00:23:46Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 616, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 616, + "repository": "mctlhq/mctl-telegram" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-22T10:18:13Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 35, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 617, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 617, + "repository": "mctlhq/mctl-telegram" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-18T15:12:49Z" + }, + { + "blockedBy": [], + "found": true, + "parent": { + "number": 618, + "repository": "mctlhq/mctl-telegram" + }, + "requested": { + "number": 619, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 619, + "repository": "mctlhq/mctl-telegram" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-22T18:35:22Z" + }, + { + "blockedBy": [ + { + "number": 88, + "repository": "mctlhq/.github" + }, + { + "number": 54, + "repository": "mctlhq/mctl-claude-remote" + } + ], + "found": true, + "parent": { + "number": 87, + "repository": "mctlhq/.github" + }, + "requested": { + "number": 646, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 646, + "repository": "mctlhq/mctl-telegram" + }, + "state": "closed", + "stateReason": "completed", + "subIssues": [], + "updatedAt": "2026-09-17T13:51:11Z" + }, + { + "blockedBy": [], + "found": true, + "parent": null, + "requested": { + "number": 650, + "repository": "mctlhq/mctl-telegram" + }, + "resolved": { + "number": 650, + "repository": "mctlhq/mctl-telegram" + }, + "state": "open", + "subIssues": [], + "updatedAt": "2026-09-22T10:15:24Z" + } + ], + "kind": "GitHubGraphSnapshot", + "source": { + "apiBase": "https://api.github.com", + "capturedAt": "2026-09-23T09:09:33Z", + "mode": "live-capture" + } +} From 657e53fd672d9b21edc7840f68bf74e34f6f974d Mon Sep 17 00:00:00 2001 From: mashkovd Date: Wed, 23 Sep 2026 11:19:23 +0200 Subject: [PATCH 2/4] =?UTF-8?q?chore(roadmap):=20lint=20=E2=80=94=20case?= =?UTF-8?q?=20table=20for=20the=20kill=20switch,=20gosec=20notes,=20no=20r?= =?UTF-8?q?ange=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also accept "f" as false in the kill switch, matching #354. Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G --- cmd/api/kill_switch_test.go | 17 +++++++++++------ cmd/api/main.go | 4 ++-- internal/api/handlers_roadmap_test.go | 2 +- internal/gitops/reader.go | 2 +- internal/roadmap/roadmap.go | 2 +- internal/roadmap/roadmap_test.go | 2 +- 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/cmd/api/kill_switch_test.go b/cmd/api/kill_switch_test.go index 200ff265..56b1cc5a 100644 --- a/cmd/api/kill_switch_test.go +++ b/cmd/api/kill_switch_test.go @@ -17,12 +17,17 @@ package main import "testing" func TestKillSwitchErrsTowardOff(t *testing.T) { - for v, want := range map[string]bool{ - "": false, "false": false, "0": false, "no": false, "off": false, " FALSE ": false, - "true": true, "1": true, "yes": true, "on": true, "disabled": true, - } { - if got := killSwitchOn(v); got != want { - t.Errorf("killSwitchOn(%q) = %v, want %v", v, got, want) + cases := []struct { + value string + want bool + }{ + {"", false}, {"false", false}, {"f", false}, {"0", false}, {"no", false}, {"off", false}, + {" FALSE\t", false}, + {"true", true}, {"1", true}, {"yes", true}, {"on", true}, {"disabled", true}, + } + for _, c := range cases { + if got := killSwitchOn(c.value); got != c.want { + t.Errorf("killSwitchOn(%q) = %v, want %v", c.value, got, c.want) } } } diff --git a/cmd/api/main.go b/cmd/api/main.go index a466a8a8..dd6e97ce 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1174,11 +1174,11 @@ func splitCSV(v string) []string { } // killSwitchOn reads a kill-switch value. It errs toward off: any value -// except an explicit "false"/"0"/"no"/"off" (or empty) turns the feature off, +// except an explicit "false"/"f"/"0"/"no"/"off" (or empty) turns the feature off, // so "yes" or "disabled" never silently leaves it on. func killSwitchOn(v string) bool { switch strings.ToLower(strings.TrimSpace(v)) { - case "", "false", "0", "no", "off": + case "", "false", "f", "0", "no", "off": return false } return true diff --git a/internal/api/handlers_roadmap_test.go b/internal/api/handlers_roadmap_test.go index 9bb39f58..1e99389d 100644 --- a/internal/api/handlers_roadmap_test.go +++ b/internal/api/handlers_roadmap_test.go @@ -34,7 +34,7 @@ func (s roadmapDirSource) Revision() (string, error) { return "0123abc", nil } func (s roadmapDirSource) ReadFiles(names ...string) (map[string][]byte, string, error) { out := map[string][]byte{} for _, n := range names { - data, err := os.ReadFile(filepath.Join(s.dir, n)) + data, err := os.ReadFile(filepath.Join(s.dir, n)) //nolint:gosec // test fixture directory if err != nil { return nil, "", err } diff --git a/internal/gitops/reader.go b/internal/gitops/reader.go index 801b4396..57457b30 100644 --- a/internal/gitops/reader.go +++ b/internal/gitops/reader.go @@ -790,7 +790,7 @@ func (r *Reader) ReadFiles(names ...string) (map[string][]byte, string, error) { if name == "" || name != filepath.Base(name) || name == "." || name == ".." { return nil, "", fmt.Errorf("invalid file name %q", name) } - data, err := os.ReadFile(filepath.Join(r.localPath, name)) + data, err := os.ReadFile(filepath.Join(r.localPath, name)) //nolint:gosec // name is a plain file name, checked above if err != nil { return nil, "", err } diff --git a/internal/roadmap/roadmap.go b/internal/roadmap/roadmap.go index 7d32a479..d1e56574 100644 --- a/internal/roadmap/roadmap.go +++ b/internal/roadmap/roadmap.go @@ -296,7 +296,7 @@ func Parse(files map[string][]byte, revision string) (*Publication, error) { } delete(ready, m.Path) delete(health, m.Path) - for _, doc := range []parsedDoc{r, h} { + for _, doc := range []*parsedDoc{&r, &h} { if doc.wire.Epic.Manifest.SHA256 != m.SHA256 || doc.wire.Epic.Name != m.Epic.Name { return nil, unavailable("%s was derived from a different manifest than the publication names", m.Path) } diff --git a/internal/roadmap/roadmap_test.go b/internal/roadmap/roadmap_test.go index c8e1df01..631cc0f8 100644 --- a/internal/roadmap/roadmap_test.go +++ b/internal/roadmap/roadmap_test.go @@ -39,7 +39,7 @@ func liveFiles(t *testing.T) map[string][]byte { t.Helper() files := map[string][]byte{} for _, name := range []string{PublicationFile, SnapshotFile, ReadySetFile, HealthFile} { - data, err := os.ReadFile(filepath.Join(liveDir, name)) + data, err := os.ReadFile(filepath.Join(liveDir, name)) //nolint:gosec // fixed fixture names if err != nil { t.Fatal(err) } From fd9bbb15e4962e29f215a49ad941b28ab0055103 Mon Sep 17 00:00:00 2001 From: mashkovd Date: Wed, 23 Sep 2026 11:31:34 +0200 Subject: [PATCH 3/4] fix(roadmap): no fork per request, boolean required_only, typed MCP errors Review round on #356: - gitops.Reader records HEAD at the end of every refresh; Revision and ReadFiles read it instead of forking git per request. - The roadmap reader caches a failed verification per revision too, so a broken publication is re-read only when the checkout moves. - mctl_get_ready_work_items declares required_only as a boolean (a string is still accepted; anything else is refused, never dropped). - MCP tools use the status: 503 says readiness is UNKNOWN, 404 says epic not found. - A fixed 503 message; the detail stays in the log. - Refuse a manifest without a digest; fold case on epic names like issues; ReadFiles refuses symlinks. - Tests: MCP handler tests (query building, encoding, 503 vs 404), 200 on /epics, negative cache, symlink, revision after refresh. Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G --- internal/api/handlers_roadmap.go | 5 +- internal/api/handlers_roadmap_test.go | 11 ++- internal/gitops/reader.go | 31 ++++++-- internal/gitops/reader_test.go | 27 +++++++ internal/mcp/roadmap.go | 62 ++++++++++++---- internal/mcp/roadmap_test.go | 103 ++++++++++++++++++++++++++ internal/roadmap/roadmap.go | 25 ++++--- internal/roadmap/roadmap_test.go | 31 ++++++++ 8 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 internal/mcp/roadmap_test.go diff --git a/internal/api/handlers_roadmap.go b/internal/api/handlers_roadmap.go index 4106e622..316c9af4 100644 --- a/internal/api/handlers_roadmap.go +++ b/internal/api/handlers_roadmap.go @@ -40,8 +40,11 @@ const ( func (h *Handlers) roadmapPublication(w http.ResponseWriter) (*roadmap.Publication, bool) { pub, err := h.opts.Roadmap.Current() if err != nil { + // The detail (paths, git output) goes to the log; the caller needs + // only to know nothing verified, which means "cannot say". slog.Warn("roadmap publication unavailable", "error", err) - writeErrorCode(w, http.StatusServiceUnavailable, roadmapCodeUnavailable, err.Error(), nil) + writeErrorCode(w, http.StatusServiceUnavailable, roadmapCodeUnavailable, + "no verified roadmap publication: readiness is unknown, not empty", nil) return nil, false } return pub, true diff --git a/internal/api/handlers_roadmap_test.go b/internal/api/handlers_roadmap_test.go index 1e99389d..001fb7d6 100644 --- a/internal/api/handlers_roadmap_test.go +++ b/internal/api/handlers_roadmap_test.go @@ -20,6 +20,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/go-chi/chi/v5" @@ -73,9 +74,13 @@ func TestRoadmap_UnconfiguredOrBrokenAnswers503(t *testing.T) { "broken": {opts: Options{Roadmap: roadmap.NewReader(roadmapDirSource{dir: t.TempDir()})}}, } { for _, path := range []string{"/api/v1/roadmap/epics", "/api/v1/roadmap/ready", "/api/v1/roadmap/epic-status?epic=x"} { - if code, body := roadmapGet(t, h, path); code != http.StatusServiceUnavailable || body["code"] != "roadmap_unavailable" { + code, body := roadmapGet(t, h, path) + if code != http.StatusServiceUnavailable || body["code"] != "roadmap_unavailable" { t.Errorf("%s %s = %d %v", name, path, code, body) } + if msg, _ := body["error"].(string); strings.Contains(msg, "/") { + t.Errorf("%s %s leaks local detail: %q", name, path, msg) + } } } } @@ -95,6 +100,10 @@ func TestRoadmap_EpicStatusAndReady(t *testing.T) { t.Fatalf("provenance = %v", prov) } + code, list := roadmapGet(t, h, "/api/v1/roadmap/epics") + if epics, _ := list["epics"].([]any); code != http.StatusOK || len(epics) == 0 || list["provenance"] == nil { + t.Fatalf("epics = %d %v", code, list) + } if code, _ := roadmapGet(t, h, "/api/v1/roadmap/epic-status"); code != http.StatusBadRequest { t.Errorf("missing epic = %d", code) } diff --git a/internal/gitops/reader.go b/internal/gitops/reader.go index 57457b30..dd45a61e 100644 --- a/internal/gitops/reader.go +++ b/internal/gitops/reader.go @@ -46,6 +46,9 @@ type Reader struct { knownHostsPath string // Path to a known_hosts file for SSH host-key pinning (optional; empty means "use the shipped embedded default, materialized lazily") mu sync.RWMutex lastSync time.Time + // head is the commit the checkout is on, recorded at the end of every + // refresh under the write lock, so readers never fork git to learn it. + head string // resolvedKnownHostsPath caches the on-disk path of the materialized // embedded default known_hosts file, computed lazily on first SSH-mode @@ -231,6 +234,15 @@ func (r *Reader) RefreshLoop(ctx context.Context, interval time.Duration) { func (r *Reader) refresh() error { r.mu.Lock() defer r.mu.Unlock() + // Whatever happens below, head ends up naming what is on disk: a failed + // fetch leaves the old checkout (and its head) in place, a failed reset + // may not, and an absent checkout has none. + defer func() { + r.head = "" + if out, err := r.gitOutput(nil, "rev-parse", "HEAD"); err == nil { + r.head = strings.TrimSpace(string(out)) + } + }() // Resolve the credential once, here, and store it for the duration of this // refresh. Everything downstream — the clone URL below and redactTokenLocked, @@ -759,7 +771,8 @@ func (r *Reader) LastSync() time.Time { return r.lastSync } -// Revision returns the commit the checkout is on. +// Revision returns the commit the checkout is on, as the last refresh +// recorded it. func (r *Reader) Revision() (string, error) { r.mu.RLock() defer r.mu.RUnlock() @@ -767,11 +780,10 @@ func (r *Reader) Revision() (string, error) { } func (r *Reader) revisionLocked() (string, error) { - out, err := r.gitOutput(nil, "rev-parse", "HEAD") - if err != nil { - return "", fmt.Errorf("reading checkout revision: %w", err) + if r.head == "" { + return "", errors.New("no checkout revision: the repository has not been synced") } - return strings.TrimSpace(string(out)), nil + return r.head, nil } // ReadFiles reads top-level files of the checkout together with the commit @@ -790,7 +802,14 @@ func (r *Reader) ReadFiles(names ...string) (map[string][]byte, string, error) { if name == "" || name != filepath.Base(name) || name == "." || name == ".." { return nil, "", fmt.Errorf("invalid file name %q", name) } - data, err := os.ReadFile(filepath.Join(r.localPath, name)) //nolint:gosec // name is a plain file name, checked above + path := filepath.Join(r.localPath, name) + // A committed symlink would otherwise be followed out of the checkout. + if info, err := os.Lstat(path); err != nil { + return nil, "", err + } else if !info.Mode().IsRegular() { + return nil, "", fmt.Errorf("%s is not a regular file", name) + } + data, err := os.ReadFile(path) //nolint:gosec // plain file name, regular file inside the checkout if err != nil { return nil, "", err } diff --git a/internal/gitops/reader_test.go b/internal/gitops/reader_test.go index 759f7e9d..ba2ff815 100644 --- a/internal/gitops/reader_test.go +++ b/internal/gitops/reader_test.go @@ -1462,4 +1462,31 @@ func TestReadFilesReturnsOneCheckoutAndRefusesPaths(t *testing.T) { if _, _, err := r.ReadFiles("missing.json"); err == nil { t.Error("a missing file read as present") } + outside := filepath.Join(root, "outside.json") + if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(cache, "link.json")); err != nil { + t.Fatal(err) + } + if _, _, err := r.ReadFiles("link.json"); err == nil { + t.Error("a symlink out of the checkout was followed") + } + // Before any sync there is no revision to report. + if _, err := (&Reader{localPath: filepath.Join(root, "never")}).Revision(); err == nil { + t.Error("an unsynced reader reported a revision") + } + // The revision tracks what a refresh put on disk. + if err := os.WriteFile(filepath.Join(work, "publication.json"), []byte(`{"v":2}`), 0o600); err != nil { + t.Fatal(err) + } + commitAll(t, work, "publish again") + runGit(t, work, "push", "origin", "roadmap-state") + if err := r.refresh(); err != nil { + t.Fatal(err) + } + next := strings.TrimSpace(runGit(t, work, "rev-parse", "HEAD")) + if got, _ := r.Revision(); got != next || got == want { + t.Fatalf("after refresh Revision() = %s, want %s", got, next) + } } diff --git a/internal/mcp/roadmap.go b/internal/mcp/roadmap.go index 75aa5e75..bf2712c7 100644 --- a/internal/mcp/roadmap.go +++ b/internal/mcp/roadmap.go @@ -16,7 +16,9 @@ package mcp import ( "context" + "encoding/json" "fmt" + "net/http" "net/url" "strings" @@ -50,11 +52,7 @@ Read-only: it never labels, approves, starts a DevLoop or writes to GitHub. Read if epic == "" { return mcplib.NewToolResultError("missing required argument: epic"), nil } - body, err := s.apiGet(ctx, "/api/v1/roadmap/epic-status?"+url.Values{"epic": {epic}}.Encode()) - if err != nil { - return mcplib.NewToolResultError(fmt.Sprintf("Failed to read epic status: %v", err)), nil - } - return mcplib.NewToolResultText(string(body)), nil + return s.roadmapFetch(ctx, "/api/v1/roadmap/epic-status?"+url.Values{"epic": {epic}}.Encode(), "epic status") } return tool, handler } @@ -72,8 +70,8 @@ required_only defaults to true — the safe wave-selection default. Several item mcplib.WithString("epic", mcplib.Description(`Epic name or root issue ("mctlhq/.github#42"). Omit for every active epic.`), ), - mcplib.WithString("required_only", - mcplib.Description(`"false" to include optional items. Defaults to true.`), + mcplib.WithBoolean("required_only", + mcplib.Description(`false to include optional items. Defaults to true.`), ), ) handler := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { @@ -81,18 +79,54 @@ required_only defaults to true — the safe wave-selection default. Several item if epic := strings.TrimSpace(stringArg(req, "epic")); epic != "" { q.Set("epic", epic) } - if v := strings.TrimSpace(stringArg(req, "required_only")); v != "" { - q.Set("required_only", v) + // A boolean is the declared type; a string is still accepted so a + // client sending "false" is not silently answered as if it sent nothing. + switch v := req.GetArguments()["required_only"].(type) { + case bool: + q.Set("required_only", fmt.Sprint(v)) + case string: + if v = strings.TrimSpace(v); v != "" { + q.Set("required_only", v) + } + case nil: + default: + return mcplib.NewToolResultError("required_only must be a boolean"), nil } path := "/api/v1/roadmap/ready" if len(q) > 0 { path += "?" + q.Encode() } - body, err := s.apiGet(ctx, path) - if err != nil { - return mcplib.NewToolResultError(fmt.Sprintf("Failed to read ready work items: %v", err)), nil - } - return mcplib.NewToolResultText(string(body)), nil + return s.roadmapFetch(ctx, path, "ready work items") } return tool, handler } + +// roadmapFetch keeps the answers that mean different things apart: 503 is +// "cannot say", 404 is "no such epic", and neither is "nothing is ready". +func (s *Server) roadmapFetch(ctx context.Context, path, what string) (*mcplib.CallToolResult, error) { + body, status, err := s.apiGetStatus(ctx, path) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("Failed to read %s: %v", what, err)), nil + } + if status < 400 { + return mcplib.NewToolResultText(string(body)), nil + } + var apiErr struct { + Error string `json:"error"` + Code string `json:"code"` + } + _ = json.Unmarshal(body, &apiErr) + switch { + case status == http.StatusServiceUnavailable: + return mcplib.NewToolResultError(fmt.Sprintf( + "Roadmap publication unavailable (HTTP 503): readiness is UNKNOWN, not empty. Do not read this as \"nothing is ready\". %s", apiErr.Error)), nil + case status == http.StatusNotFound && apiErr.Code == "epic_not_found": + return mcplib.NewToolResultError(fmt.Sprintf("Epic not found in the roadmap publication: %s", apiErr.Error)), nil + default: + msg := apiErr.Error + if msg == "" { + msg = strings.TrimSpace(string(body)) + } + return mcplib.NewToolResultError(fmt.Sprintf("Failed to read %s (HTTP %d): %s", what, status, msg)), nil + } +} diff --git a/internal/mcp/roadmap_test.go b/internal/mcp/roadmap_test.go new file mode 100644 index 00000000..f5d12586 --- /dev/null +++ b/internal/mcp/roadmap_test.go @@ -0,0 +1,103 @@ +// Copyright 2025 MCTL Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// roadmapBackend records every request and answers with status/body. +func roadmapBackend(t *testing.T, status int, body string) (*httptest.Server, *[]string) { + t.Helper() + var seen []string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Method+" "+r.URL.RequestURI()) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(ts.Close) + return ts, &seen +} + +func callRoadmapTool(t *testing.T, build func(*Server) (mcplib.Tool, server.ToolHandlerFunc), apiURL string, args map[string]any) *mcplib.CallToolResult { + t.Helper() + tool, handler := build(NewServer(apiURL, "test-token")) + result, err := handler(context.Background(), mcplib.CallToolRequest{ + Params: mcplib.CallToolParams{Name: tool.Name, Arguments: args}, + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + return result +} + +func TestGetReadyWorkItemsToolBuildsTheQuery(t *testing.T) { + cases := []struct { + args map[string]any + want string + }{ + {map[string]any{}, "GET /api/v1/roadmap/ready"}, + {map[string]any{"required_only": false}, "GET /api/v1/roadmap/ready?required_only=false"}, + {map[string]any{"required_only": true}, "GET /api/v1/roadmap/ready?required_only=true"}, + {map[string]any{"required_only": "false"}, "GET /api/v1/roadmap/ready?required_only=false"}, + {map[string]any{"epic": "mctlhq/.github#42", "required_only": false}, "GET /api/v1/roadmap/ready?epic=mctlhq%2F.github%2342&required_only=false"}, + {map[string]any{"epic": " "}, "GET /api/v1/roadmap/ready"}, + } + for _, c := range cases { + ts, seen := roadmapBackend(t, http.StatusOK, `{"epics":[]}`) + result := callRoadmapTool(t, (*Server).toolGetReadyWorkItems, ts.URL, c.args) + if result.IsError || len(*seen) != 1 || (*seen)[0] != c.want { + t.Errorf("args %v: requests %v, want %q (error=%v)", c.args, *seen, c.want, result.IsError) + } + } + ts, seen := roadmapBackend(t, http.StatusOK, `{}`) + if result := callRoadmapTool(t, (*Server).toolGetReadyWorkItems, ts.URL, map[string]any{"required_only": 1}); !result.IsError || len(*seen) != 0 { + t.Errorf("a non-boolean required_only was sent on: %v", *seen) + } +} + +func TestGetEpicStatusToolEncodesTheRootIssue(t *testing.T) { + ts, seen := roadmapBackend(t, http.StatusOK, `{"epic":{}}`) + result := callRoadmapTool(t, (*Server).toolGetEpicStatus, ts.URL, map[string]any{"epic": "mctlhq/.github#57"}) + if result.IsError || len(*seen) != 1 || (*seen)[0] != "GET /api/v1/roadmap/epic-status?epic=mctlhq%2F.github%2357" { + t.Fatalf("requests %v", *seen) + } + if result := callRoadmapTool(t, (*Server).toolGetEpicStatus, ts.URL, map[string]any{}); !result.IsError { + t.Error("a missing epic was not refused") + } +} + +func TestRoadmapToolsKeepUnavailableApartFromNotFound(t *testing.T) { + unavailable, _ := roadmapBackend(t, http.StatusServiceUnavailable, `{"error":"no verified roadmap publication","code":"roadmap_unavailable"}`) + for _, build := range []func(*Server) (mcplib.Tool, server.ToolHandlerFunc){(*Server).toolGetEpicStatus, (*Server).toolGetReadyWorkItems} { + result := callRoadmapTool(t, build, unavailable.URL, map[string]any{"epic": "x"}) + if text := resultText(t, result); !result.IsError || !strings.Contains(text, "UNKNOWN") { + t.Errorf("503 answered %q", text) + } + } + notFound, _ := roadmapBackend(t, http.StatusNotFound, `{"error":"epic not found","code":"epic_not_found"}`) + result := callRoadmapTool(t, (*Server).toolGetEpicStatus, notFound.URL, map[string]any{"epic": "x"}) + if text := resultText(t, result); !result.IsError || !strings.Contains(text, "Epic not found") || strings.Contains(text, "UNKNOWN") { + t.Errorf("404 answered %q", text) + } +} diff --git a/internal/roadmap/roadmap.go b/internal/roadmap/roadmap.go index d1e56574..9d639d55 100644 --- a/internal/roadmap/roadmap.go +++ b/internal/roadmap/roadmap.go @@ -279,6 +279,9 @@ func Parse(files map[string][]byte, revision string) (*Publication, error) { } names := map[string]bool{} for _, m := range pub.Manifests { + if m.SHA256 == "" { + return nil, unavailable("manifest %s carries no digest", m.Path) + } if m.Epic == nil || m.Epic.Name == "" || m.Epic.Lifecycle == "" { return nil, unavailable("manifest %s carries no epic identity", m.Path) } @@ -404,7 +407,7 @@ func (p *Publication) Provenance(now time.Time) Provenance { func (p *Publication) find(key string) (*epicEntry, error) { key = strings.TrimSpace(key) for _, e := range p.epics { - if e.epic.Name == key || (e.epic.Issue != nil && strings.EqualFold(e.epic.Issue.String(), key)) { + if strings.EqualFold(e.epic.Name, key) || (e.epic.Issue != nil && strings.EqualFold(e.epic.Issue.String(), key)) { return e, nil } } @@ -465,8 +468,12 @@ func (p *Publication) Epics() []Epic { type Reader struct { src FileSource - mu sync.Mutex - cached *Publication + mu sync.Mutex + // The answer for one revision, good or bad: a publication that failed + // verification is re-read only when the checkout moves. + cachedRev string + cached *Publication + cachedErr error } // NewReader wraps a file source. @@ -485,17 +492,15 @@ func (r *Reader) Current() (*Publication, error) { } r.mu.Lock() defer r.mu.Unlock() - if r.cached != nil && r.cached.revision == rev { - return r.cached, nil + if r.cachedRev != "" && r.cachedRev == rev { + return r.cached, r.cachedErr } files, readRev, err := r.src.ReadFiles(PublicationFile, SnapshotFile, ReadySetFile, HealthFile) if err != nil { + // Not cached: a read error is about the checkout, not the revision. return nil, unavailable("roadmap-state checkout: %v", err) } pub, err := Parse(files, readRev) - if err != nil { - return nil, err - } - r.cached = pub - return pub, nil + r.cachedRev, r.cached, r.cachedErr = readRev, pub, err + return pub, err } diff --git a/internal/roadmap/roadmap_test.go b/internal/roadmap/roadmap_test.go index 631cc0f8..4f1a62f9 100644 --- a/internal/roadmap/roadmap_test.go +++ b/internal/roadmap/roadmap_test.go @@ -23,6 +23,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "time" ) @@ -213,6 +214,9 @@ func TestAcceptanceExamplesFollowThePublication(t *testing.T) { if err != nil || !bytes.Equal(byName.Readiness, byIssue.Readiness) { t.Fatalf("root issue and name disagree: %v", err) } + if folded, err := pub.EpicStatus(strings.ToUpper(byIssue.Epic.Name), liveNow); err != nil || folded.Epic.Name != byIssue.Epic.Name { + t.Fatalf("name lookup is not case-insensitive like the issue lookup: %v", err) + } if _, err := pub.EpicStatus("no-such-epic", liveNow); !errors.Is(err, ErrEpicNotFound) { t.Fatalf("unknown epic: %v", err) } @@ -297,6 +301,24 @@ func TestAnythingUnverifiedIsRefused(t *testing.T) { delete(doc["manifests"].([]any)[0].(map[string]any)["epic"].(map[string]any), "lifecycle") }) }, + "a manifest without a digest": func(t *testing.T, files map[string][]byte) { + editJSON(t, files, PublicationFile, func(doc map[string]any) { + for _, m := range doc["manifests"].([]any) { + m.(map[string]any)["sha256"] = "" + } + }) + editJSON(t, files, ReadySetFile, func(doc map[string]any) { + for _, d := range doc["items"].([]any) { + d.(map[string]any)["epic"].(map[string]any)["manifest"].(map[string]any)["sha256"] = "" + } + }) + editJSON(t, files, HealthFile, func(doc map[string]any) { + for _, d := range doc["items"].([]any) { + d.(map[string]any)["epic"].(map[string]any)["manifest"].(map[string]any)["sha256"] = "" + } + }) + resign(t, files) + }, "a live capture without capturedAt": func(t *testing.T, files map[string][]byte) { editJSON(t, files, PublicationFile, func(doc map[string]any) { delete(doc["observation"].(map[string]any), "capturedAt") @@ -390,6 +412,15 @@ func TestReaderReverifiesOnlyWhenTheCheckoutMovesAndNeverServesStale(t *testing. if _, err := r.Current(); !errors.Is(err, ErrUnavailable) { t.Fatalf("broken publication, asked again: err = %v", err) } + if src.reads != 2 { + t.Fatalf("a broken revision was re-verified on every call: reads=%d", src.reads) + } + // It moves again, to a good publication: served at once. + src.rev = "c" + src.files = liveFiles(t) + if _, err := r.Current(); err != nil { + t.Fatalf("repaired publication: %v", err) + } var nilReader *Reader if _, err := nilReader.Current(); !errors.Is(err, ErrUnavailable) { t.Fatalf("unconfigured reader: err = %v", err) From 639bdccd877970f4f063f3b172cd618fee5d3ee5 Mon Sep 17 00:00:00 2001 From: mashkovd Date: Wed, 23 Sep 2026 11:40:27 +0200 Subject: [PATCH 4/4] fix(roadmap): pin route registration; fold the duplicate guard; own HEAD only - TestRoadmapRoutesAreRegistered walks NewRouter for the three reads. - The duplicate-epic guard folds case, as lookup does. - refresh records HEAD only when localPath has its own .git, so a failed clone inside another repository never borrows that repository's HEAD. Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G --- internal/api/handlers_roadmap_test.go | 25 +++++++++++++++++++++++++ internal/gitops/reader.go | 5 +++++ internal/gitops/reader_test.go | 13 +++++++++++++ internal/roadmap/roadmap.go | 7 +++++-- internal/roadmap/roadmap_test.go | 23 +++++++++++++++++++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers_roadmap_test.go b/internal/api/handlers_roadmap_test.go index 001fb7d6..e2ffe0ee 100644 --- a/internal/api/handlers_roadmap_test.go +++ b/internal/api/handlers_roadmap_test.go @@ -128,3 +128,28 @@ func TestRoadmap_EpicStatusAndReady(t *testing.T) { t.Fatalf("required_only default/true/false = %d/%d/%d", count(def), count(req), count(all)) } } + +// The handler tests above mount their own router; this pins that the real +// one serves the roadmap reads at all. +func TestRoadmapRoutesAreRegistered(t *testing.T) { + router, ok := NewRouter(Options{}).(chi.Routes) + if !ok { + t.Fatal("the router does not expose its routes") + } + found := map[string]bool{} + if err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + found[method+" "+strings.TrimSuffix(route, "/")] = true + return nil + }); err != nil { + t.Fatalf("walk: %v", err) + } + for _, want := range []string{ + "GET /api/v1/roadmap/epics", + "GET /api/v1/roadmap/epic-status", + "GET /api/v1/roadmap/ready", + } { + if !found[want] { + t.Errorf("route not registered: %s", want) + } + } +} diff --git a/internal/gitops/reader.go b/internal/gitops/reader.go index dd45a61e..78ba556a 100644 --- a/internal/gitops/reader.go +++ b/internal/gitops/reader.go @@ -239,6 +239,11 @@ func (r *Reader) refresh() error { // may not, and an absent checkout has none. defer func() { r.head = "" + // Only this checkout's own HEAD: rev-parse from a path without its + // own .git would walk up and answer for an enclosing repository. + if _, err := os.Stat(filepath.Join(r.localPath, ".git")); err != nil { + return + } if out, err := r.gitOutput(nil, "rev-parse", "HEAD"); err == nil { r.head = strings.TrimSpace(string(out)) } diff --git a/internal/gitops/reader_test.go b/internal/gitops/reader_test.go index ba2ff815..500433e7 100644 --- a/internal/gitops/reader_test.go +++ b/internal/gitops/reader_test.go @@ -1476,6 +1476,19 @@ func TestReadFilesReturnsOneCheckoutAndRefusesPaths(t *testing.T) { if _, err := (&Reader{localPath: filepath.Join(root, "never")}).Revision(); err == nil { t.Error("an unsynced reader reported a revision") } + // A failed clone into a directory inside another repository must not + // borrow that repository's HEAD. + nestedPath := filepath.Join(work, "nested") + if err := os.MkdirAll(nestedPath, 0o750); err != nil { + t.Fatal(err) + } + nested := &Reader{repoURL: filepath.Join(root, "missing.git"), branch: "roadmap-state", localPath: nestedPath} + if err := nested.refresh(); err == nil { + t.Fatal("clone of a missing repository succeeded") + } + if got, err := nested.Revision(); err == nil { + t.Errorf("a failed clone reported revision %s", got) + } // The revision tracks what a refresh put on disk. if err := os.WriteFile(filepath.Join(work, "publication.json"), []byte(`{"v":2}`), 0o600); err != nil { t.Fatal(err) diff --git a/internal/roadmap/roadmap.go b/internal/roadmap/roadmap.go index 9d639d55..1b97af51 100644 --- a/internal/roadmap/roadmap.go +++ b/internal/roadmap/roadmap.go @@ -285,10 +285,13 @@ func Parse(files map[string][]byte, revision string) (*Publication, error) { if m.Epic == nil || m.Epic.Name == "" || m.Epic.Lifecycle == "" { return nil, unavailable("manifest %s carries no epic identity", m.Path) } - if names[m.Epic.Name] { + // Folded, because lookup folds: two names equal but for case would + // otherwise both verify and then resolve ambiguously. + folded := strings.ToLower(m.Epic.Name) + if names[folded] { return nil, unavailable("epic %s is published twice", m.Epic.Name) } - names[m.Epic.Name] = true + names[folded] = true r, ok := ready[m.Path] if !ok { return nil, unavailable("%s has no ready set for %s", ReadySetFile, m.Path) diff --git a/internal/roadmap/roadmap_test.go b/internal/roadmap/roadmap_test.go index 4f1a62f9..c1caab74 100644 --- a/internal/roadmap/roadmap_test.go +++ b/internal/roadmap/roadmap_test.go @@ -319,6 +319,29 @@ func TestAnythingUnverifiedIsRefused(t *testing.T) { }) resign(t, files) }, + "two epics equal but for case": func(t *testing.T, files map[string][]byte) { + var second string + editJSON(t, files, PublicationFile, func(doc map[string]any) { + ms := doc["manifests"].([]any) + first := ms[0].(map[string]any)["epic"].(map[string]any)["name"].(string) + e := ms[1].(map[string]any)["epic"].(map[string]any) + second = e["name"].(string) + e["name"] = strings.ToUpper(first) + }) + for _, f := range []string{ReadySetFile, HealthFile} { + editJSON(t, files, f, func(doc map[string]any) { + for _, d := range doc["items"].([]any) { + e := d.(map[string]any)["epic"].(map[string]any) + if e["name"] == second { + var pub map[string]any + _ = json.Unmarshal(files[PublicationFile], &pub) + e["name"] = pub["manifests"].([]any)[1].(map[string]any)["epic"].(map[string]any)["name"] + } + } + }) + } + resign(t, files) + }, "a live capture without capturedAt": func(t *testing.T, files map[string][]byte) { editJSON(t, files, PublicationFile, func(doc map[string]any) { delete(doc["observation"].(map[string]any), "capturedAt")