@dadado/agent-kit-cli 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,8 @@ agent-kit dashboard
43
43
 
44
44
  The panel binds to loopback by default, serves its own static files, and snapshots the current workspace. L0 install does **not** copy `dashboard/` into your app; `agent-kit dashboard` resolves the panel from the installed package.
45
45
 
46
+ Browser-free: `agent-kit mission-control` is a third surface (ASCII Mission, Flight Log, Checklist, Crew Monitor) that reuses the same snapshot builders without starting the HTTP server. `agent-kit mission-control --once` prints one frame (Claude Code `/agent-kit`). The web dashboard stays shipped.
47
+
46
48
  Older tags before 4.8.2 do not include those assets. Prefer a current pin, or point `MISSION_CONTROL_KIT_ROOT` / `AGENT_KIT_HOME` at an agent-kit checkout that contains `dashboard/`.
47
49
 
48
50
  ## Bare invoke (welcome)
@@ -73,7 +75,8 @@ On an interactive TTY, long-running commands (`init`, `install`, `doctor`, `upda
73
75
  | `agent-kit doctor` | Diagnose repository readiness (`--json` includes an `env` pillar: bin-on-PATH, npm prefix writability, Node version, shell profile) |
74
76
  | `agent-kit setup-global` | Self-heal a root-owned npm global prefix (relocate to `~/.npm-global`, fix `PATH`, reinstall) |
75
77
  | `agent-kit update` | Re-apply L0/packs/skills from the registry |
76
- | `agent-kit dashboard` | Start Mission Control for this workspace |
78
+ | `agent-kit dashboard` | Start Mission Control for this workspace (browser panel) |
79
+ | `agent-kit mission-control` | ASCII Mission Control TUI (`--once` for one frame) |
77
80
  | `agent-kit add <id>` | Install a skill or L1 pack |
78
81
  | `agent-kit run-plan` | Headless continuous plan runner (never promotes to production) |
79
82
 
@@ -23,3 +23,20 @@ before it fans out to the workspace packages).
23
23
 
24
24
  `dashboard.html` is outside Biome's scope; CSS/HTML-only changes are covered by
25
25
  `packages/cli/src/dashboard/plugin-ux-validation.test.ts` instead.
26
+
27
+ ## Git tab and DevOps tab
28
+
29
+ The Git tab (`#git`) keeps the pre-rendered `git log --graph` markdown block and adds a
30
+ second, state-colored visual tree next to it, reusing the `.now-stepper`/`.now-step-marker`
31
+ timeline component built for the Current Mission panel (`renderGitVisualTree` in
32
+ `dashboard.html`, parsing `SNAPSHOT.git.graph`). It is single-lane by design — the markdown
33
+ block keeps the true branch-lane geometry; the stepper trades that for an at-a-glance
34
+ promotion read (HEAD, promoted to `origin/main`/`origin/staging`, or neither).
35
+
36
+ The DevOps tab (`#devops`) is scoped to CI/CD + deploy signal, separate from the
37
+ local-process-only Processes tab. `dashboard-data.mjs`'s `collectPipelineRuns()` shells to
38
+ `gh run list` (budget-guarded via `withinSnapshotBudget()`, fails soft to an honest
39
+ empty-state when `gh` is unavailable/unauthenticated); `collectDeploySignal()` reads `v*`
40
+ git tags plus the latest non-`Unreleased` `CHANGELOG.md` entry as a best-effort "what
41
+ shipped recently" proxy. Neither collector polls live infra/hosting — the DevOps tab never
42
+ implies monitoring it does not perform.
@@ -49,13 +49,33 @@ const MAX_TERMINALS = 20;
49
49
  const MAX_PROCESSES = 25;
50
50
  const MAX_GIT_GRAPH_LINES = 25;
51
51
  const MAX_GIT_GRAPH_LINE_CHARS = 160;
52
+ const MAX_PIPELINE_RUNS = 5;
53
+ const MAX_PIPELINE_NAME_CHARS = 80;
54
+ // Live-measured (2026-08-24, this repo, 8 fresh `gh run list` samples via the
55
+ // exact trimmed command): 3476-6584ms, one earlier ad-hoc sample 7674ms.
56
+ // Prior review sessions (.cursor/memory/plan-monitor-mc-git-tab-visual-tree-and-devops-panel.md)
57
+ // spanned 2.1-9.9s. 3500ms had no headroom at all over that range; 12000ms
58
+ // gives real margin over the observed worst case (~2.1s over the historical
59
+ // 9.9s max, ~4.3s over this session's tight-sample max) while staying well
60
+ // under the 60s outer child-process timeout (serve.mjs DATA_SCRIPT_TIMEOUT_MS).
61
+ const GH_RUN_LIST_TIMEOUT_MS = 12_000;
62
+ const MAX_DEPLOY_TAGS = 5;
63
+ const MAX_DEPLOY_TAG_NAME_CHARS = 64;
64
+ const MAX_CHANGELOG_ITEMS = 4;
65
+ const MAX_CHANGELOG_ITEM_CHARS = 140;
52
66
 
53
67
  /** Soft wall-clock budget for optional collectors (transcripts, reports, ps). */
54
68
  const SNAPSHOT_STARTED_MS = Date.now();
69
+ // Default raised from 12000ms by the same delta GH_RUN_LIST_TIMEOUT_MS grew
70
+ // (3500ms -> 12000ms, +8500ms): collectPipelineRuns() now runs last among the
71
+ // budget-guarded collectors and reserves GH_RUN_LIST_TIMEOUT_MS + 400ms of
72
+ // budget before attempting `gh run list` (see withinSnapshotBudget call
73
+ // there), so the total budget must grow by the same amount the reserve did
74
+ // or the larger timeout can never actually be used.
55
75
  const SNAPSHOT_BUDGET_MS = (() => {
56
76
  const raw = process.env.AGENT_KIT_DASHBOARD_DATA_BUDGET_MS;
57
- const n = raw != null && raw !== "" ? Number(raw) : 12_000;
58
- return Number.isFinite(n) && n > 0 ? n : 12_000;
77
+ const n = raw != null && raw !== "" ? Number(raw) : 20_500;
78
+ return Number.isFinite(n) && n > 0 ? n : 20_500;
59
79
  })();
60
80
  function withinSnapshotBudget(reserveMs = 400) {
61
81
  return Date.now() - SNAPSHOT_STARTED_MS + reserveMs < SNAPSHOT_BUDGET_MS;
@@ -104,7 +124,7 @@ function redactTerminalOutput(text) {
104
124
 
105
125
  const SNAPSHOT = {
106
126
  _schema: {
107
- version: "1.2.0",
127
+ version: "1.3.0",
108
128
  description: "Mission Control dashboard data model",
109
129
  fields: {
110
130
  generatedAt: "ISO-8601 timestamp of snapshot generation",
@@ -117,6 +137,8 @@ const SNAPSHOT = {
117
137
  memory:
118
138
  "Memory records: error count, decision count, recent decisions, recent parsed errors, error-o-meter stats",
119
139
  git: "Git repository state: branch, dirty status, commit, ahead/behind, bounded files[], promotion flow vs staging/main, graph lines, staging hygiene",
140
+ devops:
141
+ "Best-effort DevOps signal: pipeline ({available, runs[], reason?} recent gh run list rows; reason is 'budget' when the shared snapshot budget ran out before gh was attempted, or 'call-failed' when gh was attempted and missing/unauthenticated/timed out/errored; reason is only present when available is false) and deploy ({tags[], changelog} v* git tags + latest CHANGELOG release entry as a 'what shipped recently' proxy, not a live infra poll)",
120
142
  terminals:
121
143
  "Active Cursor terminal sessions with metadata, output line count, and capped lastOutput",
122
144
  processes:
@@ -127,7 +149,7 @@ const SNAPSHOT = {
127
149
  },
128
150
  },
129
151
  generatedAt: new Date().toISOString(),
130
- dashboardDataVersion: "1.3.0",
152
+ dashboardDataVersion: "1.4.0",
131
153
  plans: [],
132
154
  system: {
133
155
  repoRoot: ROOT,
@@ -246,6 +268,7 @@ const kitCommandPaths = new Set();
246
268
  const kitSkillDirs = new Set();
247
269
  const kitAgentPaths = new Set();
248
270
  const registryFile = join(ROOT, "registry", "registry.json");
271
+ let registryHadCommands = false;
249
272
  if (existsSync(registryFile)) {
250
273
  try {
251
274
  const registry = JSON.parse(readFileSync(registryFile, "utf8"));
@@ -265,10 +288,58 @@ if (existsSync(registryFile)) {
265
288
  kitAgentPaths.add(entry.path);
266
289
  }
267
290
  }
291
+ registryHadCommands = kitCommandPaths.size > 0;
268
292
  } catch {
269
293
  // Unreadable registry: degrade to all-editable rather than locking everything.
270
294
  }
271
295
  }
296
+ // `registry/registry.json` is a factory-only artifact index — consumer installs
297
+ // never ship it, so the block above always leaves kitCommandPaths empty there and
298
+ // every command reads kitManaged: false even though `update` owns it. Fall back to
299
+ // the locally-tracked kit markers: `.cursor/agent-kit.managed-hashes.json` (already
300
+ // keyed by the same repo-relative path as c.path / a.path) and, as a secondary
301
+ // source, `.cursor/agent-kit.json`'s `protected[]` list (exact paths only; glob
302
+ // entries like `.cursor/plans/**` don't identify individual kit-managed commands so
303
+ // they're skipped). Only engages when the registry is absent or yielded no commands,
304
+ // so factory behavior (registry present + populated) is unchanged.
305
+ if (!registryHadCommands) {
306
+ const managedHashesFile = join(ROOT, ".cursor", "agent-kit.managed-hashes.json");
307
+ const agentKitConfigFile = join(ROOT, ".cursor", "agent-kit.json");
308
+ const fallbackPaths = new Set();
309
+ if (existsSync(managedHashesFile)) {
310
+ try {
311
+ const managed = JSON.parse(readFileSync(managedHashesFile, "utf8"));
312
+ const hashes = managed?.hashes && typeof managed.hashes === "object" ? managed.hashes : {};
313
+ for (const path of Object.keys(hashes)) {
314
+ fallbackPaths.add(path);
315
+ }
316
+ } catch {
317
+ // Unreadable managed-hashes.json: fall through to protected[] (if any).
318
+ }
319
+ }
320
+ if (existsSync(agentKitConfigFile)) {
321
+ try {
322
+ const agentKitConfig = JSON.parse(readFileSync(agentKitConfigFile, "utf8"));
323
+ const protectedList = Array.isArray(agentKitConfig?.protected)
324
+ ? agentKitConfig.protected
325
+ : [];
326
+ for (const entry of protectedList) {
327
+ if (typeof entry === "string" && !entry.includes("*")) fallbackPaths.add(entry);
328
+ }
329
+ } catch {
330
+ // Unreadable agent-kit.json: managed-hashes.json (if any) still applies.
331
+ }
332
+ }
333
+ for (const path of fallbackPaths) {
334
+ if (path.startsWith(".cursor/commands/")) {
335
+ kitCommandPaths.add(path);
336
+ } else if (path.startsWith(".cursor/agents/")) {
337
+ kitAgentPaths.add(path);
338
+ } else if (path.startsWith(".cursor/skills/") && path.endsWith("/SKILL.md")) {
339
+ kitSkillDirs.add(path.slice(0, -"/SKILL.md".length));
340
+ }
341
+ }
342
+ }
272
343
  for (const c of SNAPSHOT.commands) {
273
344
  c.kitManaged = kitCommandPaths.has(c.path);
274
345
  }
@@ -402,7 +473,12 @@ if (existsSync(memoryDecisionsDir)) {
402
473
  try {
403
474
  const gitOpts = { cwd: ROOT, encoding: "utf-8", timeout: 5000 };
404
475
  const branch = execSync("git rev-parse --abbrev-ref HEAD", gitOpts).trim();
405
- const status = execSync("git status --short", gitOpts).trim();
476
+ // Only strip the trailing newline(s) here — `git status --short` uses
477
+ // positional XY status columns, so an unstaged-only row starts with a
478
+ // literal leading space (e.g. " M path"). A full-string .trim() eats that
479
+ // leading space on line 1 only, shifting parseGitStatusShort's staged /
480
+ // unstaged read for the first row.
481
+ const status = execSync("git status --short", gitOpts).replace(/\n+$/, "");
406
482
  const lastCommit = execSync("git log -1 --oneline", gitOpts).trim();
407
483
  let ahead = 0;
408
484
  let behind = 0;
@@ -487,6 +563,128 @@ try {
487
563
  SNAPSHOT._gitRecentLog = [];
488
564
  }
489
565
 
566
+ // 6b. DevOps: CI/CD pipeline status (best-effort, `gh` CLI) + a "what shipped
567
+ // recently" deploy-activity proxy from v* git tags and the latest CHANGELOG
568
+ // release entry. Both fail soft to an empty/unavailable shape; this snapshot
569
+ // never blocks or errors on either signal, and never claims live
570
+ // infra/hosting monitoring it does not perform.
571
+ function collectPipelineRuns() {
572
+ // gh run list is a network call, unlike the local-only git collectors
573
+ // above; the budget reserve must cover its own timeout, not just a nominal
574
+ // buffer (see .cursor/memory/errors/2026-07-26_mission-control-dashboard-data-timeout.md
575
+ // for why this file's soft-budget guards exist).
576
+ if (!withinSnapshotBudget(GH_RUN_LIST_TIMEOUT_MS + 400)) {
577
+ // `gh` was never even attempted this snapshot — the shared budget ran out
578
+ // first. Distinguished from a failed/timed-out call below so the UI does
579
+ // not misattribute this (the dominant real case; see live measurements
580
+ // in .cursor/memory/plan-monitor-mc-git-tab-visual-tree-and-devops-panel.md
581
+ // and the residual note this fix landed from) to an auth/install problem.
582
+ return { available: false, runs: [], reason: "budget" };
583
+ }
584
+ try {
585
+ // Trimmed to the fields renderDevopsPipelineCard() / navDevopsDot actually
586
+ // read (workflow, status, conclusion, branch, event, createdAt). databaseId
587
+ // and url are dropped: the pipeline card is display-only with no
588
+ // paste-destination for a run URL (ADR 2026-07-25 copy-only convention),
589
+ // so requesting them only adds payload cost without a consumer.
590
+ const out = execSync(
591
+ `gh run list --limit ${MAX_PIPELINE_RUNS} --json workflowName,status,conclusion,headBranch,event,createdAt`,
592
+ { cwd: ROOT, encoding: "utf-8", timeout: GH_RUN_LIST_TIMEOUT_MS },
593
+ );
594
+ const rows = JSON.parse(out);
595
+ if (!Array.isArray(rows)) return { available: false, runs: [], reason: "call-failed" };
596
+ const runs = rows.slice(0, MAX_PIPELINE_RUNS).map((r) => ({
597
+ workflow: truncateStr(String(r.workflowName || ""), MAX_PIPELINE_NAME_CHARS),
598
+ status: String(r.status || ""),
599
+ conclusion: r.conclusion ? String(r.conclusion) : null,
600
+ branch: truncateStr(String(r.headBranch || ""), MAX_STRING.branch),
601
+ event: String(r.event || ""),
602
+ createdAt: r.createdAt || null,
603
+ }));
604
+ return { available: true, runs };
605
+ } catch {
606
+ // gh missing, unauthenticated, its own execSync timeout, or non-repo cwd
607
+ // all read the same to the caller: no pipeline signal for this snapshot.
608
+ // Not further split (e.g. timeout vs auth) — that would need parsing the
609
+ // execSync error shape, which is more taxonomy than the empty-state copy
610
+ // needs; "call-failed" is enough to stop the UI from guessing "auth".
611
+ return { available: false, runs: [], reason: "call-failed" };
612
+ }
613
+ }
614
+
615
+ /** Markdown decoration removed, not converted (mirrors scripts/build-landing.mjs's
616
+ * stripMdInline): safe against unclosed tokens after the item-length cap below. */
617
+ function stripMdInlineForDeploySignal(s) {
618
+ return s
619
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
620
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
621
+ .replace(/`([^`]+)`/g, "$1");
622
+ }
623
+
624
+ /** First non-"Unreleased" `## [x.y.z] - date` entry and a capped bullet list. */
625
+ function parseLatestChangelogEntryForDeploySignal(changelog) {
626
+ const headerRe = /^## \[([^\]]+)\](?:\s*-\s*(.+))?$/gm;
627
+ const headers = [...changelog.matchAll(headerRe)];
628
+ const latest = headers.find((m) => m[1].toLowerCase() !== "unreleased");
629
+ if (!latest) return null;
630
+ const start = latest.index + latest[0].length;
631
+ const next = headers.find((m) => m.index > latest.index);
632
+ const body = changelog.slice(start, next ? next.index : undefined);
633
+ const items = [...body.matchAll(/^- (.+)$/gm)]
634
+ .map((m) => stripMdInlineForDeploySignal(m[1].trim()))
635
+ .filter(Boolean)
636
+ .slice(0, MAX_CHANGELOG_ITEMS)
637
+ .map((line) =>
638
+ line.length > MAX_CHANGELOG_ITEM_CHARS
639
+ ? `${line.slice(0, MAX_CHANGELOG_ITEM_CHARS).trimEnd()}…`
640
+ : line,
641
+ );
642
+ return { version: latest[1], date: (latest[2] || "").trim() || null, items };
643
+ }
644
+
645
+ function collectDeploySignal() {
646
+ if (!withinSnapshotBudget(400)) return { tags: [], changelog: null };
647
+ const gitOpts = { cwd: ROOT, encoding: "utf-8", timeout: 5000 };
648
+ let tags = [];
649
+ try {
650
+ // v*-only: an archive/* or other non-release tag under "what shipped
651
+ // recently" would be exactly the misleading signal this phase avoids.
652
+ const out = execSync(
653
+ `git for-each-ref --sort=-creatordate --format='%(refname:short)|%(creatordate:short)' --count=${MAX_DEPLOY_TAGS} 'refs/tags/v*'`,
654
+ gitOpts,
655
+ ).trim();
656
+ tags = out
657
+ ? out
658
+ .split("\n")
659
+ .filter(Boolean)
660
+ .map((line) => {
661
+ const [name, date] = line.split("|");
662
+ return { name: truncateStr(name || "", MAX_DEPLOY_TAG_NAME_CHARS), date: date || null };
663
+ })
664
+ : [];
665
+ } catch {
666
+ tags = [];
667
+ }
668
+
669
+ let changelog = null;
670
+ try {
671
+ const changelogPath = join(ROOT, "CHANGELOG.md");
672
+ if (existsSync(changelogPath)) {
673
+ changelog = parseLatestChangelogEntryForDeploySignal(readFileSync(changelogPath, "utf-8"));
674
+ }
675
+ } catch {
676
+ changelog = null;
677
+ }
678
+
679
+ return { tags, changelog };
680
+ }
681
+
682
+ // SNAPSHOT.devops is assigned near the end of the snapshot build (after the
683
+ // transcripts/reports/ps collectors below), not here — gh run list is a
684
+ // network call that must not run ahead of and starve the local-only
685
+ // budget-guarded collectors that share SNAPSHOT_BUDGET_MS. See the call site
686
+ // right before the missionControl assembly for the ordering rationale.
687
+
490
688
  // 7. Terminals (read from Cursor terminal files)
491
689
  const terminalsDir = resolve(process.env.HOME || "~", ".cursor", "projects");
492
690
  // Derive project path from ROOT rather than hardcoding a specific slug
@@ -1142,4 +1340,16 @@ function readPreviousInventory() {
1142
1340
  }
1143
1341
  SNAPSHOT._gitRecentLog = undefined;
1144
1342
 
1343
+ // DevOps: CI/CD pipeline status (best-effort, `gh` CLI) + a "what shipped
1344
+ // recently" deploy-activity proxy. Deliberately collected last, after every
1345
+ // other budget-guarded collector above (processes, detached-audit-sessions,
1346
+ // agentPrompts, externalReports, subagentRuns): collectPipelineRuns() makes
1347
+ // a network call (gh run list) unlike every other collector in this file, so
1348
+ // it must not run ahead of and eat into the shared SNAPSHOT_BUDGET_MS budget
1349
+ // that the local-only collectors also need.
1350
+ SNAPSHOT.devops = {
1351
+ pipeline: collectPipelineRuns(),
1352
+ deploy: collectDeploySignal(),
1353
+ };
1354
+
1145
1355
  process.stdout.write(JSON.stringify(SNAPSHOT, null, 2));
@@ -1173,6 +1173,45 @@ body.mc-fullscreen .top-tabs-row {
1173
1173
  color: var(--text-secondary);
1174
1174
  margin: 12px 0 6px;
1175
1175
  }
1176
+ .git-graph-title:first-child { margin-top: 0; }
1177
+
1178
+ /* ===== DevOps ===== */
1179
+ /* Row visual language mirrors .git-flow-row (state dot + lane + meta) so the
1180
+ * new tab reads as the same product, not a second component vocabulary. */
1181
+ .devops-row {
1182
+ display: flex;
1183
+ align-items: center;
1184
+ gap: 10px;
1185
+ font-size: 12px;
1186
+ padding: 8px 10px;
1187
+ border: 1px solid var(--border);
1188
+ border-radius: 8px;
1189
+ background: var(--bg-card);
1190
+ flex-wrap: wrap;
1191
+ margin-bottom: 6px;
1192
+ }
1193
+ .devops-row:last-child { margin-bottom: 0; }
1194
+ .devops-name {
1195
+ font-weight: 600;
1196
+ color: var(--text-primary);
1197
+ flex-shrink: 0;
1198
+ }
1199
+ .devops-meta {
1200
+ color: var(--text-secondary);
1201
+ flex: 1;
1202
+ min-width: 0;
1203
+ }
1204
+ .devops-time {
1205
+ color: var(--text-muted);
1206
+ font-family: var(--mc-font-mono);
1207
+ font-size: 11px;
1208
+ flex-shrink: 0;
1209
+ }
1210
+ .devops-note {
1211
+ font-size: 11px;
1212
+ color: var(--text-muted);
1213
+ margin: 0 0 8px;
1214
+ }
1176
1215
  .git-hygiene {
1177
1216
  display: flex;
1178
1217
  align-items: flex-start;
@@ -3644,6 +3683,10 @@ html[data-monitor-density="comfortable"] .live-activity-feed .monitor-row .monit
3644
3683
  Git
3645
3684
  <span class="dot dot-gray" id="navGitDot" aria-hidden="true"></span>
3646
3685
  </a>
3686
+ <a class="top-tab nav-more-item" role="menuitem" href="#" data-section="devops" tabindex="0" onclick="return showSection('devops')" aria-label="DevOps section">
3687
+ DevOps
3688
+ <span class="dot dot-gray" id="navDevopsDot" aria-hidden="true"></span>
3689
+ </a>
3647
3690
  <a class="top-tab nav-more-item" role="menuitem" href="#" data-section="memory" tabindex="0" onclick="return showSection('memory')" aria-label="Memory section">
3648
3691
  Memory
3649
3692
  </a>
@@ -3819,6 +3862,7 @@ const SECTION_IDS = [
3819
3862
  'commands',
3820
3863
  'health',
3821
3864
  'git',
3865
+ 'devops',
3822
3866
  'memory',
3823
3867
  'terminals',
3824
3868
  'processes',
@@ -5430,6 +5474,199 @@ function renderGitGraphCard(git) {
5430
5474
  `;
5431
5475
  }
5432
5476
 
5477
+ /** Cap for the visual tree row count (mirrors MAX_GIT_GRAPH_LINES server-side cap, further
5478
+ * bounded here so the stepper stays a glance, not a scroll well). */
5479
+ const MAX_GIT_VISUAL_TREE_ROWS = 12;
5480
+
5481
+ /**
5482
+ * Parse `git log --graph --oneline --decorate` lines into flattened commit
5483
+ * entries. Connector-only lines (`|`, `/`, `\`, whitespace between lanes)
5484
+ * carry no commit and are skipped — see the plan's "Phase 0 lane-flattening
5485
+ * interpretation" note: this trades true branch-lane geometry (still fully
5486
+ * present in the kept markdown block) for one row per commit.
5487
+ */
5488
+ function parseGitGraphCommits(lines) {
5489
+ const commits = [];
5490
+ for (const line of Array.isArray(lines) ? lines : []) {
5491
+ const m = /\*\s*([0-9a-f]{4,40})\s+(?:\(([^)]*)\)\s*)?(.*)$/.exec(line);
5492
+ if (!m) continue;
5493
+ const decoration = (m[2] || '').trim();
5494
+ commits.push({
5495
+ hash: m[1],
5496
+ decoration,
5497
+ subject: (m[3] || '').trim(),
5498
+ isHead: /\bHEAD\b/.test(decoration),
5499
+ isPromoted: /\borigin\/(main|staging)\b/.test(decoration),
5500
+ });
5501
+ }
5502
+ return commits;
5503
+ }
5504
+
5505
+ /**
5506
+ * Visual companion to the markdown git-graph block: reuses the exact
5507
+ * .now-stepper/.now-step/.now-step-marker timeline primitive (state-colored
5508
+ * markers + connecting line) with one row per parsed commit. Dot/marker
5509
+ * semantics stay locked to the existing Now-panel vocabulary: current/blue =
5510
+ * HEAD, done/green = already promoted to origin/main or origin/staging,
5511
+ * neutral = neither. Degrades to a compact empty-state when the graph has no
5512
+ * parseable commit line (e.g. a shallow clone) while a branch still exists.
5513
+ */
5514
+ function renderGitVisualTree(git) {
5515
+ const commits = parseGitGraphCommits(git?.graph);
5516
+ if (!commits.length) {
5517
+ if (!git?.branch) return '';
5518
+ return renderEmptyStateCta({
5519
+ headline: 'No visual tree yet',
5520
+ support: 'The commit graph had no parseable commit line to render.',
5521
+ compact: true,
5522
+ className: 'git-visual-tree-empty',
5523
+ });
5524
+ }
5525
+ const shown = commits.slice(0, MAX_GIT_VISUAL_TREE_ROWS);
5526
+ const rows = shown
5527
+ .map((c) => {
5528
+ const stateClass = c.isHead ? ' now-step-current' : c.isPromoted ? ' now-step-done' : '';
5529
+ const marker = c.isHead ? '●' : c.isPromoted ? '✓' : '○';
5530
+ const label = c.decoration ? escapeHtml(c.decoration) : 'Commit';
5531
+ const text = `<span class="now-todo-id">${escapeHtml(c.hash)}</span>${c.subject ? ` — ${escapeHtml(c.subject)}` : ''}`;
5532
+ return `
5533
+ <li class="now-step${stateClass}">
5534
+ <span class="now-step-marker" aria-hidden="true">${marker}</span>
5535
+ <div class="now-step-body">
5536
+ <span class="now-step-label">${label}</span>
5537
+ <div class="now-step-text">${text}</div>
5538
+ </div>
5539
+ </li>
5540
+ `;
5541
+ })
5542
+ .join('');
5543
+ return `
5544
+ <div class="git-graph-title">Visual tree (${shown.length} of ${commits.length} commit${commits.length !== 1 ? 's' : ''})</div>
5545
+ <ol class="now-stepper git-visual-tree" aria-label="Visual commit tree: state-colored markers by promotion status">
5546
+ ${rows}
5547
+ </ol>
5548
+ `;
5549
+ }
5550
+
5551
+ /** `in_progress` / `timed_out` -> `In progress` / `Timed out`. */
5552
+ function humanizeRunStatus(s) {
5553
+ const raw = String(s || '').trim();
5554
+ if (!raw) return 'Unknown';
5555
+ return raw
5556
+ .split('_')
5557
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
5558
+ .join(' ');
5559
+ }
5560
+
5561
+ /** Dot tone for one `gh run list` row: in-flight states read yellow; a
5562
+ * completed run reads by conclusion (green/red/yellow), never decorative. */
5563
+ function pipelineRunTone(run) {
5564
+ if (run.status !== 'completed') return 'yellow';
5565
+ if (run.conclusion === 'success') return 'green';
5566
+ if (['failure', 'cancelled', 'timed_out'].includes(run.conclusion)) return 'red';
5567
+ return 'yellow';
5568
+ }
5569
+
5570
+ /**
5571
+ * CI/CD pipeline card (Phase 2): recent `gh run list` rows, honest empty-state
5572
+ * when the collector didn't produce runs. Display only, no CTA — a
5573
+ * workflow-run URL has no named paste destination in the copy-only
5574
+ * convention (ADR 2026-07-25), so this card ships without one rather than
5575
+ * inventing a destination.
5576
+ *
5577
+ * Empty-state copy is keyed off `pipeline.reason` rather than one blanket
5578
+ * message: live measurement (2026-08-24, mc-git-tab-devops-pipeline-timeout
5579
+ * residuals) found the dominant real case is the shared snapshot budget
5580
+ * running out before `gh` is even attempted ('budget'), not gh being
5581
+ * missing/unauthenticated — the old unconditional "gh is unavailable or not
5582
+ * authenticated" copy misattributed that case and pointed the operator at
5583
+ * the wrong fix.
5584
+ */
5585
+ function renderDevopsPipelineCard(devops) {
5586
+ const pipeline = devops?.pipeline || null;
5587
+ if (!pipeline?.available) {
5588
+ const support = pipeline?.reason === 'budget'
5589
+ ? 'This snapshot ran out of time before checking recent workflow runs — refresh to retry.'
5590
+ : 'Recent workflow runs could not be read this snapshot (gh may be missing, unauthenticated, or the call timed out).';
5591
+ return renderEmptyStateCta({
5592
+ headline: 'No pipeline signal',
5593
+ support,
5594
+ compact: true,
5595
+ className: 'devops-pipeline-empty',
5596
+ });
5597
+ }
5598
+ const runs = pipeline.runs || [];
5599
+ if (!runs.length) {
5600
+ return renderEmptyStateCta({
5601
+ headline: 'No recent runs',
5602
+ support: 'No GitHub Actions workflow runs found for this repository.',
5603
+ compact: true,
5604
+ className: 'devops-pipeline-empty',
5605
+ });
5606
+ }
5607
+ const rows = runs
5608
+ .map((r) => {
5609
+ const tone = pipelineRunTone(r);
5610
+ const label = humanizeRunStatus(r.status === 'completed' ? r.conclusion || 'unknown' : r.status);
5611
+ return `
5612
+ <div class="devops-row">
5613
+ <span class="dot dot-${tone}" aria-hidden="true"></span>
5614
+ <span class="devops-name">${escapeHtml(r.workflow || 'Workflow')}</span>
5615
+ <span class="devops-meta">${escapeHtml(label)} · ${escapeHtml(r.branch || '—')} · ${escapeHtml(r.event || '—')}</span>
5616
+ <span class="devops-time">${escapeHtml(fmtDate(r.createdAt))}</span>
5617
+ </div>
5618
+ `;
5619
+ })
5620
+ .join('');
5621
+ return rows;
5622
+ }
5623
+
5624
+ /**
5625
+ * Deploy-activity card (Phase 3, best-effort, honest): a "what shipped
5626
+ * recently" proxy from `v*` git tags and the latest non-Unreleased CHANGELOG
5627
+ * entry. Deliberately not a live infra/hosting poll — no fabricated service
5628
+ * tiles for sources this dashboard does not actually reach.
5629
+ */
5630
+ function renderDevopsDeployCard(devops) {
5631
+ const deploy = devops?.deploy || null;
5632
+ const tags = deploy?.tags || [];
5633
+ const changelog = deploy?.changelog || null;
5634
+ if (!tags.length && !changelog) {
5635
+ return renderEmptyStateCta({
5636
+ headline: 'No deploy signal yet',
5637
+ support: 'No git release tags (v*) or a parseable CHANGELOG release entry were found. This section reflects what shipped, from tags and CHANGELOG only — it does not poll live infra or hosting.',
5638
+ compact: true,
5639
+ className: 'devops-deploy-empty',
5640
+ });
5641
+ }
5642
+ const changelogRow = changelog
5643
+ ? `
5644
+ <div class="devops-row">
5645
+ <span class="dot dot-green" aria-hidden="true"></span>
5646
+ <span class="devops-name">CHANGELOG ${escapeHtml(changelog.version)}</span>
5647
+ <span class="devops-meta">${escapeHtml((changelog.items || []).join(' · ') || 'Release notes')}</span>
5648
+ <span class="devops-time">${escapeHtml(changelog.date || '—')}</span>
5649
+ </div>
5650
+ `
5651
+ : '';
5652
+ const tagRows = tags
5653
+ .map(
5654
+ (t) => `
5655
+ <div class="devops-row">
5656
+ <span class="dot dot-gray" aria-hidden="true"></span>
5657
+ <span class="devops-name">${escapeHtml(t.name)}</span>
5658
+ <span class="devops-meta">git tag</span>
5659
+ <span class="devops-time">${escapeHtml(t.date || '—')}</span>
5660
+ </div>
5661
+ `,
5662
+ )
5663
+ .join('');
5664
+ return `
5665
+ <div class="devops-note">Best-effort "what shipped recently" from git tags + CHANGELOG — not a live infra/deploy poll.</div>
5666
+ ${changelogRow}${tagRows}
5667
+ `;
5668
+ }
5669
+
5433
5670
  /** Staging hygiene: untracked plan-monitor WIP (add-by-name only; ADR 2026-07-29 R14). */
5434
5671
  function renderGitHygieneHint(git) {
5435
5672
  const wip = git?.hygiene?.monitorWip || [];
@@ -6788,7 +7025,7 @@ function nowMetaIconSvg(kind, opts) {
6788
7025
  * features >= 1 unit. Detail that cannot meet the floor is drawn filled
6789
7026
  * (fill="currentColor" stroke="none"), never as a sub-stroke stroked shape.
6790
7027
  * Static path markup only; never concatenate untrusted text into the SVG.
6791
- * @param {'current-mission'|'monitor'|'field-report'|'checklist'|'more-sections'|'overview'|'plans'|'activity'|'agents'|'skills'|'skins'|'commands'|'health'|'git'|'memory'|'terminals'|'processes'|'config'} kind
7028
+ * @param {'current-mission'|'monitor'|'field-report'|'checklist'|'more-sections'|'overview'|'plans'|'activity'|'agents'|'skills'|'skins'|'commands'|'health'|'git'|'devops'|'memory'|'terminals'|'processes'|'config'} kind
6792
7029
  * @param {{ decorative?: boolean }} [opts] decorative true (default): aria-hidden next to a visible label.
6793
7030
  * decorative false: role=img + aria-label + title for icon-only controls (e.g. more-sections).
6794
7031
  */
@@ -6810,6 +7047,7 @@ function spaceIconSvg(kind, opts) {
6810
7047
  commands: 'Commands',
6811
7048
  health: 'Health',
6812
7049
  git: 'Git',
7050
+ devops: 'DevOps',
6813
7051
  memory: 'Memory',
6814
7052
  terminals: 'Terminals',
6815
7053
  processes: 'Processes',
@@ -6877,6 +7115,12 @@ function spaceIconSvg(kind, opts) {
6877
7115
  '<path d="M10.5 4.5v7a2 2 0 01-2 2h-2"/>' +
6878
7116
  '<path d="M6.5 11.5a2 2 0 100-4 2 2 0 000 4z"/>' +
6879
7117
  '<path d="M10.5 6.5v2"/>',
7118
+ // Linked nodes (devops) — two pipeline-stage circles joined by a short
7119
+ // link; distinct from the Git branch glyph and from the Skills gear.
7120
+ devops:
7121
+ '<circle cx="5" cy="8" r="2.25"/>' +
7122
+ '<circle cx="11" cy="8" r="2.25"/>' +
7123
+ '<path d="M7.25 8h1.5"/>',
6880
7124
  // Chip (memory) — two internal lines 3 units apart; a third line would drop clearance below the stroke floor
6881
7125
  memory:
6882
7126
  '<path d="M4.5 2.5h7a1 1 0 011 1v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-9a1 1 0 011-1z"/>' +
@@ -7779,6 +8023,24 @@ function renderUnsafe() {
7779
8023
  gitDot.className = 'dot dot-green';
7780
8024
  }
7781
8025
 
8026
+ // DevOps nav dot: state of the most recent pipeline run only (gray = no
8027
+ // signal / gh unavailable, green = latest run succeeded, yellow = latest
8028
+ // run in progress or non-success conclusion other than a hard failure,
8029
+ // red = latest run failed/cancelled/timed out).
8030
+ const devopsDot = document.getElementById('navDevopsDot');
8031
+ const latestRun = (d.devops?.pipeline?.runs || [])[0] || null;
8032
+ if (!latestRun) {
8033
+ devopsDot.className = 'dot dot-gray';
8034
+ } else if (latestRun.status !== 'completed') {
8035
+ devopsDot.className = 'dot dot-yellow';
8036
+ } else if (latestRun.conclusion === 'success') {
8037
+ devopsDot.className = 'dot dot-green';
8038
+ } else if (['failure', 'cancelled', 'timed_out'].includes(latestRun.conclusion)) {
8039
+ devopsDot.className = 'dot dot-red';
8040
+ } else {
8041
+ devopsDot.className = 'dot dot-yellow';
8042
+ }
8043
+
7782
8044
  // Terminals / processes nav items carry count badges only (no decorative dots).
7783
8045
 
7784
8046
  // Build full HTML string once
@@ -8169,11 +8431,33 @@ function renderUnsafe() {
8169
8431
  </div>
8170
8432
  </div>
8171
8433
  ${renderGitGraphCard(d.git)}
8434
+ ${renderGitVisualTree(d.git)}
8172
8435
  ${d.git?.dirty ? renderGitFileList(d.git) : ''}
8173
8436
  </div>
8174
8437
  </div>
8175
8438
  `);
8176
8439
 
8440
+ // ===== DevOps =====
8441
+ // Distinct scope from Processes (local-process-only): CI/CD pipeline status
8442
+ // + a best-effort, honestly-scoped deploy-activity proxy. No fabricated
8443
+ // "service health" widgets for infra this dashboard does not actually poll.
8444
+ const devopsPipelineRuns = d.devops?.pipeline?.runs || [];
8445
+ const devopsRunCount = devopsPipelineRuns.length;
8446
+ parts.push(`
8447
+ <div class="content-section" id="section-devops">
8448
+ <div class="section-title">
8449
+ DevOps
8450
+ <span class="section-subtitle">${devopsRunCount} pipeline run${devopsRunCount !== 1 ? 's' : ''}</span>
8451
+ </div>
8452
+ <div class="card">
8453
+ <div class="git-graph-title">CI/CD pipeline</div>
8454
+ ${renderDevopsPipelineCard(d.devops)}
8455
+ <div class="git-graph-title">Deploy activity</div>
8456
+ ${renderDevopsDeployCard(d.devops)}
8457
+ </div>
8458
+ </div>
8459
+ `);
8460
+
8177
8461
  // ===== Memory =====
8178
8462
  const recentDecisions = d.memory?.recentDecisions || [];
8179
8463
  const recentErrors = d.memory?.recentErrors || [];