@f5-sales-demo/xcsh 19.92.2 → 19.94.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.92.2",
4
+ "version": "19.94.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "1.3.0",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.92.2",
60
- "@f5-sales-demo/pi-agent-core": "19.92.2",
61
- "@f5-sales-demo/pi-ai": "19.92.2",
62
- "@f5-sales-demo/pi-natives": "19.92.2",
63
- "@f5-sales-demo/pi-resource-management": "19.92.2",
64
- "@f5-sales-demo/pi-tui": "19.92.2",
65
- "@f5-sales-demo/pi-utils": "19.92.2",
59
+ "@f5-sales-demo/xcsh-stats": "19.94.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.94.0",
61
+ "@f5-sales-demo/pi-ai": "19.94.0",
62
+ "@f5-sales-demo/pi-natives": "19.94.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.94.0",
64
+ "@f5-sales-demo/pi-tui": "19.94.0",
65
+ "@f5-sales-demo/pi-utils": "19.94.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -10,6 +10,7 @@ import {
10
10
  normalizeHostToolDefinitions,
11
11
  RpcHostToolBridge,
12
12
  } from "../host-tools";
13
+ import { extractReferences } from "../references";
13
14
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
14
15
  import {
15
16
  type ChatDelta,
@@ -17,7 +18,6 @@ import {
17
18
  type ChatError,
18
19
  type ChatErrorReason,
19
20
  type ChatKeepalive,
20
- type ChatReference,
21
21
  type ChatRequest,
22
22
  type Configure,
23
23
  type ConfigureAck,
@@ -680,39 +680,6 @@ function composeBrowserPageContext(parts: string[], context: PageContextSnapshot
680
680
  parts.push("---");
681
681
  }
682
682
 
683
- export function classifyReferenceKind(url: string): "doc" | "console" {
684
- try {
685
- const parsed = new URL(url);
686
- if (/\.console\.ves\.volterra\.io$/.test(parsed.hostname)) return "console";
687
- if (parsed.hostname === "docs.cloud.f5.com" || parsed.pathname.startsWith("/docs")) return "doc";
688
- } catch {
689
- /* malformed URL — default to doc */
690
- }
691
- return "doc";
692
- }
693
-
694
- function titleFromUrl(url: string): string {
695
- try {
696
- const parsed = new URL(url);
697
- const segments = parsed.pathname.split("/").filter(Boolean);
698
- return segments.length > 0 ? segments[segments.length - 1] : parsed.hostname;
699
- } catch {
700
- return url;
701
- }
702
- }
703
-
704
- /**
705
- * Trailing characters a bare-URL match may greedily swallow at a markdown/prose
706
- * boundary — markdown emphasis + code (`*_~` and backtick) and sentence/wrap
707
- * punctuation. A real URL effectively never ends in these, so trimming them yields
708
- * the intended link (e.g. `**https://…/llms.txt**` or a code-wrapped
709
- * `` `https://…/llms.txt` `` → `https://…/llms.txt`). The markdown-link branch is
710
- * bounded by its closing `)` and needs no trimming.
711
- */
712
- function trimTrailingMarkup(url: string): string {
713
- return url.replace(/[*_~`,.;:!?'")\]}>]+$/, "");
714
- }
715
-
716
683
  /** Panel-facing labels for provider-side tools, used for the activity rows (#2340). */
717
684
  function serverToolLabels(toolName: string): { running: string; done: string } {
718
685
  switch (toolName) {
@@ -724,47 +691,3 @@ function serverToolLabels(toolName: string): { running: string; done: string } {
724
691
  return { running: `${toolName}: running`, done: toolName };
725
692
  }
726
693
  }
727
-
728
- export function extractReferences(msg: AssistantMessage): ChatReference[] {
729
- const refs: ChatReference[] = [];
730
- const seen = new Set<string>();
731
-
732
- // STRUCTURED CITATIONS FIRST (#2340): a provider-side search reports the real page title, so
733
- // it beats the regex scrape below — which can only guess a title from the URL path, and finds
734
- // nothing at all when the model cites a source without printing its URL in the prose. Done as
735
- // its own pass so a citation in a later block still wins over a scrape in an earlier one.
736
- for (const block of msg.content) {
737
- if (block.type !== "text" || !block.citations) continue;
738
- for (const citation of block.citations) {
739
- const url = citation.url;
740
- if (!url || seen.has(url)) continue;
741
- seen.add(url);
742
- refs.push({
743
- kind: classifyReferenceKind(url),
744
- title: citation.title?.trim() || titleFromUrl(url),
745
- url,
746
- });
747
- }
748
- }
749
-
750
- for (const block of msg.content) {
751
- if (block.type !== "text") continue;
752
-
753
- const mdLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
754
- for (let match = mdLinkRegex.exec(block.text); match !== null; match = mdLinkRegex.exec(block.text)) {
755
- const [, title, url] = match;
756
- if (seen.has(url)) continue;
757
- seen.add(url);
758
- refs.push({ kind: classifyReferenceKind(url), title, url });
759
- }
760
-
761
- const bareUrlRegex = /(?<!\()(https?:\/\/[^\s)>\]]+)/g;
762
- for (let match = bareUrlRegex.exec(block.text); match !== null; match = bareUrlRegex.exec(block.text)) {
763
- const url = trimTrailingMarkup(match[1]);
764
- if (seen.has(url)) continue;
765
- seen.add(url);
766
- refs.push({ kind: classifyReferenceKind(url), title: titleFromUrl(url), url });
767
- }
768
- }
769
- return refs;
770
- }
@@ -220,6 +220,7 @@ export function renderAboutDoc(info: RuntimeBuildInfo, context: ContextStatus |
220
220
  '2. For recent changes / "what\'s new", read `xcsh://changes` — it lists merged PRs live and flags',
221
221
  " what shipped after your build (it falls back to `gh pr list` / `git log` when gh is unavailable).",
222
222
  ' A fix may already be on `main`. For "where is X implemented?", read `xcsh://source`.',
223
+ " For the class of the repository you are working in and what you may author there, read `xcsh://fleet`.",
223
224
  "3. If behavior contradicts `xcsh://…` docs, read the actual source under the repo above to determine",
224
225
  " whether the binary is wrong or the doc is stale.",
225
226
  "4. Classify the report as one of: **bug**, **feature**, **docs-drift**, or **config/usage**.",
@@ -242,10 +243,13 @@ export function renderAboutDoc(info: RuntimeBuildInfo, context: ContextStatus |
242
243
  "",
243
244
  "The improvement workflow is always: open an issue on the repo, then a PR. The user receives changes only after a new release is built and they upgrade. Do not claim a change is live until the commit above reflects it.",
244
245
  "",
245
- "You are a GitHub / devops / security **operator**: your job is to author the issue, the",
246
- "reproduction, the Terraform/manifests/scripts/docs, and the PR description — but **implementing**",
247
- "feature code on xcsh, `f5-sales-demo/marketplace`, or `f5-sales-demo/api-specs-enriched` is",
248
- "delegated to a dedicated coding harness (Claude Code / Codex) with its own dev environment.",
246
+ "You are a network-engineer assistant operating through GitHub, not a coding assistant: your job is",
247
+ "to author the issue, the reproduction, the Terraform/manifests/scripts/docs, and the PR description.",
248
+ "Whether **implementing** the change is yours depends on the repository's class — read `xcsh://fleet`.",
249
+ "xcsh itself is classified `developer`, so feature code here is delegated to a dedicated coding",
250
+ "harness (Claude Code / Codex) with its own dev environment; the same holds for the other `developer`",
251
+ "repositories such as `f5-sales-demo/marketplace` and `f5-sales-demo/api-specs-enriched`. In a",
252
+ "`content` repository you author directly.",
249
253
  "To file well: clone the relevant repo, reproduce the behavior first, and follow `CONTRIBUTING.md` —",
250
254
  "TDD, evidence required, no unverified claims.",
251
255
  "",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.92.2",
21
- "commit": "5327ac71f0559634bb52145f5c4c61729b22f9ba",
22
- "shortCommit": "5327ac7",
20
+ "version": "19.94.0",
21
+ "commit": "cdc4c167af67436458be41ca65fbf643ecfcd6b9",
22
+ "shortCommit": "cdc4c16",
23
23
  "branch": "main",
24
- "tag": "v19.92.2",
25
- "commitDate": "2026-07-26T02:21:50Z",
26
- "buildDate": "2026-07-26T02:49:52.720Z",
24
+ "tag": "v19.94.0",
25
+ "commitDate": "2026-07-26T06:18:25Z",
26
+ "buildDate": "2026-07-26T06:39:31.633Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/5327ac71f0559634bb52145f5c4c61729b22f9ba",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.92.2"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/cdc4c167af67436458be41ca65fbf643ecfcd6b9",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.94.0"
33
33
  };
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Resolver for xcsh://fleet — "what kind of repository am I in, and what may I do here?"
3
+ *
4
+ * xcsh belongs to a fleet of repositories that are not interchangeable. Some hold
5
+ * demo and product content — documentation, Terraform plans, howtos — which xcsh
6
+ * authors directly. Some hold compiled code with their own build and test harness,
7
+ * where implementation belongs in a development environment and xcsh's deliverable
8
+ * is a verified issue. Some hold the fleet plumbing itself.
9
+ *
10
+ * That distinction is declared, not guessed: docs-control publishes `repo_classes`
11
+ * in `.claude/governance.json`, a managed file synced byte-identically into every
12
+ * governed repository. So the classification is readable offline from any checkout,
13
+ * and this resolver reads it from the working directory before reaching for the
14
+ * network.
15
+ *
16
+ * URL forms (host = "fleet"):
17
+ * - xcsh://fleet -> the current repository's class first, then the whole fleet
18
+ *
19
+ * Dynamic and uncached, mirroring xcsh://about. All shelling and file reading is
20
+ * injected so the resolver is unit-testable without a git repo, a `gh` binary, or
21
+ * a network.
22
+ */
23
+
24
+ import path from "node:path";
25
+ import { $ } from "bun";
26
+ import type { InternalResource, InternalUrl } from "./types";
27
+
28
+ export interface GhResult {
29
+ readonly ok: boolean;
30
+ readonly stdout: string;
31
+ readonly stderr: string;
32
+ }
33
+
34
+ export interface FleetDeps {
35
+ readonly cwd: () => string;
36
+ /** Absolute path to the enclosing git repository root, or null when outside one. */
37
+ readonly repoRoot: (cwd: string) => Promise<string | null>;
38
+ /** The `origin` remote URL, or null when there is none. */
39
+ readonly repoOrigin: (cwd: string) => Promise<string | null>;
40
+ /** Contents of `<repoRoot>/.claude/governance.json`, or null when absent/unreadable. */
41
+ readonly readGovernance: (repoRoot: string) => Promise<string | null>;
42
+ readonly runGh: (args: string[]) => Promise<GhResult>;
43
+ }
44
+
45
+ /** The repository the classification manifest is published from. */
46
+ export const GOVERNANCE_REPO = "f5-sales-demo/docs-control";
47
+ export const GOVERNANCE_RELPATH = ".claude/governance.json";
48
+
49
+ /**
50
+ * The organization's pre-rename name. Reads still redirect from it, but pushes are
51
+ * rejected, so a clone left on the old slug looks healthy until the first push
52
+ * fails. Classification keys are bare repository names, so the org does not affect
53
+ * the lookup — but it is worth warning about where we notice it.
54
+ */
55
+ export const LEGACY_ORG = "f5xc-salesdemos";
56
+ export const CURRENT_ORG = "f5-sales-demo";
57
+
58
+ /** Authority values the manifest may declare for a class. */
59
+ export const AUTHORITY_AUTHOR = "author";
60
+ export const AUTHORITY_DELEGATE = "delegate";
61
+ export const AUTHORITY_GOVERNED = "governed";
62
+
63
+ /** Rendered class name when no manifest could be read at all. */
64
+ export const CLASS_UNCLASSIFIED = "UNCLASSIFIED";
65
+
66
+ export interface ClassDefinition {
67
+ readonly authority: string;
68
+ readonly description?: string;
69
+ readonly surfaces?: readonly string[];
70
+ readonly delegateTo?: string;
71
+ }
72
+
73
+ export interface RepoClasses {
74
+ /** `source_repo` from the governance manifest — the repository that published it. */
75
+ readonly sourceRepo: string;
76
+ readonly defaultClass: string;
77
+ readonly classes: Readonly<Record<string, ClassDefinition>>;
78
+ readonly repos: Readonly<Record<string, string>>;
79
+ }
80
+
81
+ export interface RepoVerdict {
82
+ readonly className: string;
83
+ /** True when the manifest names this repository explicitly. */
84
+ readonly declared: boolean;
85
+ /** False when the remote is outside this organization, so no class may be applied. */
86
+ readonly trustedOrg: boolean;
87
+ readonly definition: ClassDefinition | null;
88
+ }
89
+
90
+ /** A repository's identity as read from its `origin` remote. */
91
+ export interface RepoIdentity {
92
+ readonly org: string;
93
+ readonly name: string;
94
+ }
95
+
96
+ /** Organizations whose repository names the manifest is allowed to speak for. */
97
+ export const TRUSTED_ORGS: readonly string[] = [CURRENT_ORG, LEGACY_ORG];
98
+
99
+ /**
100
+ * Read `repo_classes` out of a governance.json payload. Returns null — never throws —
101
+ * when the payload is malformed or predates the block, so a missing classification
102
+ * degrades into an actionable message rather than an error.
103
+ */
104
+ export function parseRepoClasses(json: string): RepoClasses | null {
105
+ let parsed: unknown;
106
+ try {
107
+ parsed = JSON.parse(json);
108
+ } catch {
109
+ return null;
110
+ }
111
+ if (typeof parsed !== "object" || parsed === null) return null;
112
+ const block = (parsed as Record<string, unknown>).repo_classes;
113
+ if (typeof block !== "object" || block === null) return null;
114
+
115
+ const raw = block as Record<string, unknown>;
116
+ const rawClasses = typeof raw.classes === "object" && raw.classes !== null ? raw.classes : {};
117
+ const rawRepos = typeof raw.repos === "object" && raw.repos !== null ? raw.repos : {};
118
+
119
+ const classes: Record<string, ClassDefinition> = {};
120
+ for (const [name, value] of Object.entries(rawClasses as Record<string, unknown>)) {
121
+ if (typeof value !== "object" || value === null) continue;
122
+ const def = value as Record<string, unknown>;
123
+ classes[name] = {
124
+ authority: String(def.authority ?? ""),
125
+ description: typeof def.description === "string" ? def.description : undefined,
126
+ surfaces: Array.isArray(def.surfaces) ? def.surfaces.map(String) : undefined,
127
+ delegateTo: typeof def.delegate_to === "string" ? def.delegate_to : undefined,
128
+ };
129
+ }
130
+
131
+ const repos: Record<string, string> = {};
132
+ for (const [name, value] of Object.entries(rawRepos as Record<string, unknown>)) {
133
+ if (typeof value === "string") repos[name] = value;
134
+ }
135
+
136
+ // A block with no classes is unusable; treat it as absent rather than render an
137
+ // empty fleet that looks authoritative.
138
+ if (Object.keys(classes).length === 0) return null;
139
+
140
+ const sourceRepo =
141
+ typeof (parsed as Record<string, unknown>).source_repo === "string"
142
+ ? ((parsed as Record<string, unknown>).source_repo as string)
143
+ : "";
144
+
145
+ return {
146
+ sourceRepo,
147
+ defaultClass: typeof raw._default === "string" ? raw._default : "",
148
+ classes,
149
+ repos,
150
+ };
151
+ }
152
+
153
+ /** Minimal shape of the session event bus, so this module does not depend on it. */
154
+ export interface CwdEventSource {
155
+ on(event: "cwd:changed", handler: (next: unknown) => void): unknown;
156
+ }
157
+
158
+ /**
159
+ * A getter for the working directory the *session* is in, not the one the process
160
+ * started in.
161
+ *
162
+ * The bash tool keeps its own cwd and updates it on `cd`, emitting `cwd:changed`
163
+ * (`tools/bash.ts`); `process.chdir` is never called for it. Classifying against
164
+ * `process.cwd()` would therefore keep reporting the startup repository, so a
165
+ * `content` grant could survive after moving into a `developer` repository.
166
+ */
167
+ export function createLiveCwdGetter(initialCwd: string, events?: CwdEventSource): () => string {
168
+ let current = initialCwd;
169
+ events?.on("cwd:changed", next => {
170
+ if (typeof next === "string" && next) current = next;
171
+ });
172
+ return () => current;
173
+ }
174
+
175
+ /** Split a GitHub remote URL into its org and bare repository name. */
176
+ export function repoNameFromOrigin(origin: string): { org: string; name: string } | null {
177
+ const cleaned = origin.trim().replace(/\.git$/, "");
178
+ const match = cleaned.match(/^(?:https?:\/\/|ssh:\/\/git@|git@)github\.com[:/]([^/]+)\/([^/]+)$/);
179
+ if (!match) return null;
180
+ const [, org, name] = match;
181
+ if (!org || !name) return null;
182
+ return { org, name };
183
+ }
184
+
185
+ /**
186
+ * Resolve a repository name to its class. An unlisted repository takes the
187
+ * manifest's `_default`, which docs-control pins to the most restrictive class, so
188
+ * forgetting an assignment withholds authority rather than granting it.
189
+ */
190
+ export function classifyRepo(classes: RepoClasses | null, repo: RepoIdentity | null): RepoVerdict {
191
+ const trustedOrg = repo !== null && TRUSTED_ORGS.includes(repo.org);
192
+ if (!classes || !repo || !trustedOrg) {
193
+ // The manifest keys repositories by bare name, so an identically-named repository
194
+ // in another organization would otherwise inherit its class. Names are only
195
+ // meaningful inside the organization that published the manifest.
196
+ return { className: CLASS_UNCLASSIFIED, declared: false, trustedOrg, definition: null };
197
+ }
198
+ const declared = Object.hasOwn(classes.repos, repo.name);
199
+ const className = declared ? (classes.repos[repo.name] as string) : classes.defaultClass;
200
+ const definition = classes.classes[className] ?? null;
201
+
202
+ // Fail closed on our own side. Both the prompt and this document promise that an
203
+ // unnamed repository is treated as the restrictive case, and that promise must not
204
+ // depend on the publisher having set `_default` correctly: a typo or drift making the
205
+ // default an authoring class would otherwise hand out write authority to every
206
+ // repository nobody has classified yet. A named assignment is honoured as written;
207
+ // an unnamed one can never resolve to `author`.
208
+ if (!declared && definition?.authority === AUTHORITY_AUTHOR) {
209
+ return {
210
+ className: className || CLASS_UNCLASSIFIED,
211
+ declared,
212
+ trustedOrg,
213
+ definition: { ...definition, authority: AUTHORITY_DELEGATE },
214
+ };
215
+ }
216
+
217
+ return { className: className || CLASS_UNCLASSIFIED, declared, trustedOrg, definition };
218
+ }
219
+
220
+ /** The behaviour each authority implies, stated so the agent does not have to infer it. */
221
+ function authorityGuidance(authority: string): string[] {
222
+ switch (authority) {
223
+ case AUTHORITY_AUTHOR:
224
+ return [
225
+ "**Authority: author.** Create, update and delete content here directly — documentation,",
226
+ "Terraform plans, howtos, diagrams, demo and traffic-generation scripts. You do not need to",
227
+ "ask permission to author; you do need to follow the governed path:",
228
+ "linked issue → branch → pull request → CI → auto-merge. Never commit to `main`.",
229
+ ];
230
+ case AUTHORITY_DELEGATE:
231
+ return [
232
+ "**Authority: delegate.** This repository holds compiled or tested code with its own build",
233
+ "and test harness. Do not implement feature code here. File a CONTRIBUTING-compliant issue —",
234
+ "reproduce first, no unverified claims — and delegate the implementation to a development",
235
+ "environment (Claude Code / Codex). Reviewing, specifying and documenting are still yours.",
236
+ ];
237
+ case AUTHORITY_GOVERNED:
238
+ return [
239
+ "**Authority: governed.** This is fleet plumbing: CI, packaging, container images, or",
240
+ "governance itself. Changes propagate to every repository, so they go through the governed",
241
+ "path only and are never made freehand.",
242
+ ];
243
+ default:
244
+ return [
245
+ "**Authority: unknown.** The manifest does not declare an authority for this class.",
246
+ "Treat it as `delegate` — the restrictive case — and ask docs-control to fix the manifest.",
247
+ ];
248
+ }
249
+ }
250
+
251
+ function renderCurrentRepo(
252
+ slug: string | null,
253
+ identity: RepoIdentity | null,
254
+ verdict: RepoVerdict,
255
+ classes: RepoClasses | null,
256
+ legacyOrg: boolean,
257
+ ): string[] {
258
+ const lines = ["## This repository", ""];
259
+
260
+ if (!slug) {
261
+ lines.push(
262
+ "Not inside a GitHub repository from this organization, so there is no class to apply.",
263
+ "Classify explicitly before authoring anything: read this document again from the repository",
264
+ "you intend to change.",
265
+ "",
266
+ );
267
+ return lines;
268
+ }
269
+
270
+ lines.push(`\`${slug}\` — class: **${verdict.className}**${verdict.declared ? " (declared)" : ""}`);
271
+ lines.push("");
272
+
273
+ if (!verdict.trustedOrg) {
274
+ lines.push(
275
+ `This remote is **outside \`${CURRENT_ORG}\`**, so the classification does not speak for it and`,
276
+ "**no authority is implied**. The manifest keys repositories by bare name, and a name only means",
277
+ "something inside the organization that published it — an identically-named repository elsewhere",
278
+ "is a different repository. Treat this as the restrictive case: do not author content here.",
279
+ "",
280
+ );
281
+ return lines;
282
+ }
283
+
284
+ if (verdict.className === CLASS_UNCLASSIFIED) {
285
+ lines.push(
286
+ "No classification manifest was available, so **no authority is implied**. Do not author",
287
+ "content here on the strength of a guess.",
288
+ "",
289
+ );
290
+ return lines;
291
+ }
292
+
293
+ if (!verdict.declared) {
294
+ lines.push(
295
+ `This repository is **UNCLASSIFIED** — it is not named in the manifest, so it falls back to`,
296
+ `the fail-safe default (\`${classes?.defaultClass || "developer"}\`) and is treated as the most`,
297
+ "restrictive case. Ask docs-control to classify it rather than assuming authoring rights.",
298
+ "",
299
+ );
300
+ }
301
+
302
+ lines.push(...authorityGuidance(verdict.definition?.authority ?? ""));
303
+ lines.push("");
304
+
305
+ if (verdict.definition?.surfaces?.length) {
306
+ lines.push(`Content surfaces: ${verdict.definition.surfaces.map(s => `\`${s}\``).join(", ")}`, "");
307
+ }
308
+
309
+ if (legacyOrg) {
310
+ lines.push(
311
+ `> **This clone points at the pre-rename organization \`${LEGACY_ORG}\`.** Reads redirect, but`,
312
+ "> **pushes are rejected**, so the branch will look fine until you try to publish it. Fix it first:",
313
+ ">",
314
+ "> ```",
315
+ `> git remote set-url origin https://github.com/${CURRENT_ORG}/${identity?.name ?? ""}.git`,
316
+ "> ```",
317
+ "",
318
+ );
319
+ }
320
+
321
+ return lines;
322
+ }
323
+
324
+ function renderFleet(classes: RepoClasses): string[] {
325
+ const byClass = new Map<string, string[]>();
326
+ for (const name of Object.keys(classes.classes)) byClass.set(name, []);
327
+ for (const [repo, className] of Object.entries(classes.repos)) {
328
+ if (!byClass.has(className)) byClass.set(className, []);
329
+ byClass.get(className)?.push(repo);
330
+ }
331
+
332
+ const lines = ["## Fleet", ""];
333
+ for (const [className, repos] of byClass) {
334
+ const def = classes.classes[className];
335
+ lines.push(`### ${className} (${repos.length}) — authority: ${def?.authority ?? "unknown"}`);
336
+ if (def?.description) lines.push("", def.description);
337
+ lines.push(
338
+ "",
339
+ repos.length > 0
340
+ ? repos
341
+ .sort()
342
+ .map(r => `\`${r}\``)
343
+ .join(" ")
344
+ : "_none_",
345
+ "",
346
+ );
347
+ }
348
+
349
+ lines.push(
350
+ "Any repository not listed above is **UNCLASSIFIED**. It is never granted authoring",
351
+ "authority no matter what the manifest's default says — an unnamed repository is always",
352
+ "treated as `delegate`, the restrictive case. Authority is never assumed from a",
353
+ "repository's contents; it is read from the manifest.",
354
+ "",
355
+ );
356
+ return lines;
357
+ }
358
+
359
+ /** Rendered when no manifest could be read — actionable, never a thrown error. */
360
+ function renderUnavailable(reason: string, slug: string | null): string {
361
+ return [
362
+ "# Fleet — classification unavailable",
363
+ "",
364
+ reason,
365
+ "",
366
+ "Until the classification is readable, **do not assume authority over any repository**: file an",
367
+ "issue and delegate rather than authoring content on a guess.",
368
+ "",
369
+ "Resolve it manually:",
370
+ "",
371
+ "```",
372
+ `gh api repos/${GOVERNANCE_REPO}/contents/${GOVERNANCE_RELPATH} --jq '.content' | base64 -d | jq .repo_classes`,
373
+ "```",
374
+ "",
375
+ `Or read \`${GOVERNANCE_RELPATH}\` in any governed checkout — the file is synced byte-identically`,
376
+ "across the fleet.",
377
+ "",
378
+ slug ? `Current repository: \`${slug}\`.` : "Not inside a GitHub repository from this organization.",
379
+ ].join("\n");
380
+ }
381
+
382
+ const FOOTER = [
383
+ "---",
384
+ "This classification decides *how* you contribute, not *whether* you do. In a `content`",
385
+ "repository, author directly through the governed path. In a `developer` repository, your",
386
+ "deliverable is a verified issue plus the specification, review and documentation around it —",
387
+ "the implementation belongs to a development environment. See `xcsh://source` for where xcsh's",
388
+ "own code lives and `xcsh://changes` for what shipped recently.",
389
+ ];
390
+
391
+ /** Where this verdict's manifest came from, so the reader can judge how fresh it is. */
392
+ export type ManifestOrigin = "local" | "canonical";
393
+
394
+ function renderProvenance(origin: ManifestOrigin, classes: RepoClasses): string[] {
395
+ if (origin === "canonical") {
396
+ return [`_Classification read live from \`${GOVERNANCE_REPO}\`._`, ""];
397
+ }
398
+ return [
399
+ `_Classification read from this checkout's \`${GOVERNANCE_RELPATH}\`, published by`,
400
+ `\`${classes.sourceRepo}\`. It is synced fleet-wide, but a checkout that has not pulled`,
401
+ "recently can lag the canonical copy. If a verdict looks wrong, confirm it before acting:_",
402
+ "",
403
+ "```",
404
+ `gh api repos/${GOVERNANCE_REPO}/contents/${GOVERNANCE_RELPATH} --jq '.content' | base64 -d | jq .repo_classes.repos`,
405
+ "```",
406
+ "",
407
+ ];
408
+ }
409
+
410
+ export function renderFleetDoc(
411
+ slug: string | null,
412
+ identity: RepoIdentity | null,
413
+ verdict: RepoVerdict,
414
+ classes: RepoClasses,
415
+ legacyOrg: boolean,
416
+ origin: ManifestOrigin = "local",
417
+ ): string {
418
+ return [
419
+ "# Fleet — repository classes and your authority here",
420
+ "",
421
+ ...renderCurrentRepo(slug, identity, verdict, classes, legacyOrg),
422
+ ...renderFleet(classes),
423
+ ...renderProvenance(origin, classes),
424
+ ...FOOTER,
425
+ ].join("\n");
426
+ }
427
+
428
+ async function defaultRunGh(args: string[]): Promise<GhResult> {
429
+ try {
430
+ const res = await $`gh ${args}`.quiet().nothrow();
431
+ return { ok: res.exitCode === 0, stdout: res.stdout.toString(), stderr: res.stderr.toString() };
432
+ } catch (err) {
433
+ return { ok: false, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
434
+ }
435
+ }
436
+
437
+ export class FleetResolver {
438
+ readonly #deps: FleetDeps;
439
+
440
+ constructor(deps: FleetDeps) {
441
+ this.#deps = deps;
442
+ }
443
+
444
+ async resolve(url: InternalUrl): Promise<InternalResource> {
445
+ const content = await this.#render();
446
+ return {
447
+ url: url.href,
448
+ content,
449
+ contentType: "text/markdown",
450
+ size: Buffer.byteLength(content, "utf-8"),
451
+ sourcePath: "xcsh://fleet",
452
+ };
453
+ }
454
+
455
+ async #render(): Promise<string> {
456
+ const cwd = this.#deps.cwd();
457
+ const root = await this.#deps.repoRoot(cwd);
458
+
459
+ let slug: string | null = null;
460
+ let identity: RepoIdentity | null = null;
461
+ let legacyOrg = false;
462
+ if (root) {
463
+ const origin = await this.#deps.repoOrigin(root);
464
+ const parsed = origin ? repoNameFromOrigin(origin) : null;
465
+ if (parsed) {
466
+ slug = `${parsed.org}/${parsed.name}`;
467
+ identity = parsed;
468
+ legacyOrg = parsed.org === LEGACY_ORG;
469
+ }
470
+ }
471
+
472
+ // Prefer the local, offline copy: it is byte-identical to the published one
473
+ // across the whole governed fleet, so there is no reason to spend a network
474
+ // round trip when we are standing in a governed repository.
475
+ let classes: RepoClasses | null = null;
476
+ let origin: ManifestOrigin = "local";
477
+ let staleLocal = false;
478
+ let untrustedLocal = false;
479
+ if (root) {
480
+ const local = await this.#deps.readGovernance(root);
481
+ if (local !== null) {
482
+ const parsedLocal = parseRepoClasses(local);
483
+ if (parsedLocal && parsedLocal.sourceRepo !== GOVERNANCE_REPO) {
484
+ // A governance.json that names a different publisher is not the fleet's
485
+ // manifest — it may belong to an unrelated project or a fork. Ignore it
486
+ // and ask the canonical repository instead of trusting what is on disk.
487
+ untrustedLocal = true;
488
+ } else {
489
+ classes = parsedLocal;
490
+ staleLocal = parsedLocal === null;
491
+ }
492
+ }
493
+ }
494
+
495
+ if (!classes) {
496
+ const remote = await this.#deps.runGh([
497
+ "api",
498
+ `repos/${GOVERNANCE_REPO}/contents/${GOVERNANCE_RELPATH}`,
499
+ "--jq",
500
+ ".content",
501
+ ]);
502
+ if (remote.ok && remote.stdout.trim()) {
503
+ const decoded = decodeMaybeBase64(remote.stdout.trim());
504
+ classes = parseRepoClasses(decoded);
505
+ if (classes) origin = "canonical";
506
+ }
507
+ }
508
+
509
+ if (!classes) {
510
+ let reason: string;
511
+ if (untrustedLocal) {
512
+ reason =
513
+ `This checkout's \`${GOVERNANCE_RELPATH}\` is published by a different repository, not ` +
514
+ `\`${GOVERNANCE_REPO}\`, so it was not trusted — and querying \`${GOVERNANCE_REPO}\` did not succeed.`;
515
+ } else if (staleLocal) {
516
+ reason =
517
+ `This checkout's \`${GOVERNANCE_RELPATH}\` has no \`repo_classes\` block — the classification ` +
518
+ `is not yet published to this repository, and querying \`${GOVERNANCE_REPO}\` did not succeed either.`;
519
+ } else {
520
+ reason = `No \`${GOVERNANCE_RELPATH}\` was readable here, and querying \`${GOVERNANCE_REPO}\` did not succeed.`;
521
+ }
522
+ return renderUnavailable(reason, slug);
523
+ }
524
+
525
+ return renderFleetDoc(slug, identity, classifyRepo(classes, identity), classes, legacyOrg, origin);
526
+ }
527
+ }
528
+
529
+ /**
530
+ * `gh api --jq .content` returns the base64 payload of a contents response. Accept
531
+ * either that or already-decoded JSON, so the caller does not have to care which
532
+ * form it got.
533
+ */
534
+ function decodeMaybeBase64(raw: string): string {
535
+ if (raw.startsWith("{")) return raw;
536
+ try {
537
+ return Buffer.from(raw.replace(/\s+/g, ""), "base64").toString("utf-8");
538
+ } catch {
539
+ return raw;
540
+ }
541
+ }
542
+
543
+ async function defaultRepoRoot(cwd: string): Promise<string | null> {
544
+ const git = await import("../utils/git");
545
+ return git.repo.root(cwd);
546
+ }
547
+
548
+ async function defaultRepoOrigin(cwd: string): Promise<string | null> {
549
+ const git = await import("../utils/git");
550
+ return (await git.remote.url(cwd, "origin")) ?? null;
551
+ }
552
+
553
+ async function defaultReadGovernance(repoRoot: string): Promise<string | null> {
554
+ try {
555
+ const file = Bun.file(path.join(repoRoot, GOVERNANCE_RELPATH));
556
+ if (!(await file.exists())) return null;
557
+ return await file.text();
558
+ } catch {
559
+ return null;
560
+ }
561
+ }
562
+
563
+ export function createFleetResolver(deps: Partial<FleetDeps> = {}): FleetResolver {
564
+ return new FleetResolver({
565
+ cwd: deps.cwd ?? (() => process.cwd()),
566
+ repoRoot: deps.repoRoot ?? defaultRepoRoot,
567
+ repoOrigin: deps.repoOrigin ?? defaultRepoOrigin,
568
+ readGovernance: deps.readGovernance ?? defaultReadGovernance,
569
+ runGh: deps.runGh ?? defaultRunGh,
570
+ });
571
+ }
@@ -148,6 +148,22 @@ export const TERRAFORM_INDEX: TerraformIndex = {
148
148
  "workload_flavor",
149
149
  ],
150
150
  },
151
+ {
152
+ name: "Uncategorized",
153
+ slug: "uncategorized",
154
+ description: "Resources pending categorization",
155
+ resource_count: 8,
156
+ resources: [
157
+ "application_profiles",
158
+ "authorization_server",
159
+ "bot_infrastructure",
160
+ "domain",
161
+ "mitigated_domain",
162
+ "protected_application",
163
+ "protected_domain",
164
+ "registration_approval",
165
+ ],
166
+ },
151
167
  {
152
168
  name: "DNS",
153
169
  slug: "dns",
@@ -163,21 +179,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
163
179
  "dns_zone",
164
180
  ],
165
181
  },
166
- {
167
- name: "Uncategorized",
168
- slug: "uncategorized",
169
- description: "Resources pending categorization",
170
- resource_count: 7,
171
- resources: [
172
- "application_profiles",
173
- "authorization_server",
174
- "bot_infrastructure",
175
- "mitigated_domain",
176
- "protected_application",
177
- "protected_domain",
178
- "registration_approval",
179
- ],
180
- },
181
182
  {
182
183
  name: "API Security",
183
184
  slug: "api-security",
@@ -185,13 +186,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
185
186
  resource_count: 5,
186
187
  resources: ["api_crawler", "api_definition", "api_discovery", "api_testing", "app_api_group"],
187
188
  },
188
- {
189
- name: "Monitoring",
190
- slug: "monitoring",
191
- description: "Log receivers, alert policies, APM, and global logging configuration",
192
- resource_count: 5,
193
- resources: ["alert_receiver", "alert_template", "apm", "global_log_receiver", "log_receiver"],
194
- },
195
189
  {
196
190
  name: "Applications",
197
191
  slug: "applications",
@@ -213,6 +207,13 @@ export const TERRAFORM_INDEX: TerraformIndex = {
213
207
  resource_count: 4,
214
208
  resources: ["certificate", "certificate_chain", "crl", "trusted_ca_list"],
215
209
  },
210
+ {
211
+ name: "Monitoring",
212
+ slug: "monitoring",
213
+ description: "Log receivers, alert policies, APM, and global logging configuration",
214
+ resource_count: 4,
215
+ resources: ["alert_receiver", "alert_template", "global_log_receiver", "log_receiver"],
216
+ },
216
217
  {
217
218
  name: "VPN",
218
219
  slug: "vpn",
@@ -386,16 +387,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
386
387
  },
387
388
  import_syntax: "terraform import xcsh_api_testing.example namespace/name",
388
389
  },
389
- apm: {
390
- category: "monitoring",
391
- description: "New APM as a service with configured parameters",
392
- required: ["name", "namespace"],
393
- minimal_config: 'resource "xcsh_apm" "example" {\n name = "example-apm"\n namespace = "staging"\n}',
394
- dependencies: {
395
- requires: [],
396
- },
397
- import_syntax: "terraform import xcsh_apm.example namespace/name",
398
- },
399
390
  app_api_group: {
400
391
  category: "api-security",
401
392
  description: "App_api_group creates a new object in the storage backend for metadata.namespace",
@@ -531,7 +522,7 @@ export const TERRAFORM_INDEX: TerraformIndex = {
531
522
  bgp_routing_policy: {
532
523
  category: "security",
533
524
  description:
534
- "Bgp routing policy is a list of rules containing match criteria and action to be applied. these rules help contol routes which are imported or exported to bgp peers. configuration",
525
+ "Bgp routing policy is a list of rules containing match criteria and action to be applied. these rules help control routes which are imported or exported to bgp peers. configuration",
535
526
  required: ["name", "namespace"],
536
527
  minimal_config:
537
528
  'resource "xcsh_bgp_routing_policy" "example" {\n name = "example-bgp-routing-policy"\n namespace = "staging"\n}',
@@ -857,6 +848,17 @@ export const TERRAFORM_INDEX: TerraformIndex = {
857
848
  },
858
849
  import_syntax: "terraform import xcsh_dns_zone.example namespace/name",
859
850
  },
851
+ domain: {
852
+ category: "uncategorized",
853
+ description: "Allowed domain",
854
+ required: ["name", "namespace", "allowed_domain"],
855
+ minimal_config:
856
+ 'resource "xcsh_domain" "example" {\n name = "example-domain"\n namespace = "staging"\n\n allowed_domain = "example-value"\n}',
857
+ dependencies: {
858
+ requires: [],
859
+ },
860
+ import_syntax: "terraform import xcsh_domain.example namespace/name",
861
+ },
860
862
  endpoint: {
861
863
  category: "load-balancing",
862
864
  description: "Endpoint will create the object in the storage backend for namespace metadata.namespace",
@@ -1636,9 +1638,9 @@ export const TERRAFORM_INDEX: TerraformIndex = {
1636
1638
  udp_loadbalancer: {
1637
1639
  category: "load-balancing",
1638
1640
  description: "Load balancing UDP traffic across origin pools",
1639
- required: ["name", "namespace", "dns_volterra_managed", "enable_per_packet_load_balancing", "idle_timeout"],
1641
+ required: ["name", "namespace", "dns_volterra_managed", "idle_timeout"],
1640
1642
  minimal_config:
1641
- 'resource "xcsh_udp_loadbalancer" "example" {\n name = "example-udp-loadbalancer"\n namespace = "staging"\n\n domains = ["example-value"]\n dns_volterra_managed = true\n enable_per_packet_load_balancing = true\n idle_timeout = 1\n}',
1643
+ 'resource "xcsh_udp_loadbalancer" "example" {\n name = "example-udp-loadbalancer"\n namespace = "staging"\n\n domains = ["example-value"]\n dns_volterra_managed = true\n idle_timeout = 1\n}',
1642
1644
  dependencies: {
1643
1645
  requires: ["namespace", "origin_pool"],
1644
1646
  },
@@ -45,6 +45,7 @@ import { type ConsoleFieldMetadataData, EMPTY_CONSOLE_FIELD_METADATA } from "./c
45
45
  import { type ConsoleResolver, createConsoleResolver } from "./console-resolve";
46
46
  import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
47
47
  import extensionApiContent from "./extension-api.md" with { type: "text" };
48
+ import { createFleetResolver, type FleetDeps, type FleetResolver } from "./fleet-resolve";
48
49
  import { createPluginResolver, type GetPluginRoots, type PluginResolver } from "./plugin-resolve";
49
50
  import { createSourceResolver, type SourceResolver } from "./source-resolve";
50
51
  import { createTerraformResolver, type TerraformResolver } from "./terraform-resolve";
@@ -66,6 +67,7 @@ const EXTENSION_HOST = "extension";
66
67
  const PLUGIN_HOST = "plugin";
67
68
  const CHANGES_HOST = "changes";
68
69
  const SOURCE_HOST = "source";
70
+ const FLEET_HOST = "fleet";
69
71
  const EMPTY_INDEX: ApiSpecIndex = { version: "unavailable", timestamp: "", domains: [] };
70
72
  const EMPTY_CATALOG_INDEX: ApiCatalogIndex = {
71
73
  version: "unavailable",
@@ -272,6 +274,8 @@ export interface InternalDocsProtocolOptions {
272
274
  readonly apiSpecResolver?: ApiSpecResolver;
273
275
  readonly apiCatalogResolver?: ApiCatalogResolver;
274
276
  readonly getPluginRoots?: GetPluginRoots;
277
+ /** Injected so tests can classify without a git repo, a `gh` binary, or a network. */
278
+ readonly fleetDeps?: Partial<FleetDeps>;
275
279
  }
276
280
 
277
281
  export class InternalDocsProtocolHandler implements ProtocolHandler {
@@ -285,6 +289,8 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
285
289
  #pluginResolver: PluginResolver | null = null;
286
290
  #changesResolver: ChangesResolver | null = null;
287
291
  #sourceResolver: SourceResolver | null = null;
292
+ #fleetResolver: FleetResolver | null = null;
293
+ readonly #fleetDeps: Partial<FleetDeps> | undefined;
288
294
  readonly #getPluginRoots: GetPluginRoots | undefined;
289
295
 
290
296
  constructor(options: InternalDocsProtocolOptions = {}) {
@@ -294,6 +300,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
294
300
  this.#apiCatalogResolver = options.apiCatalogResolver ?? null;
295
301
  this.#terraformResolver = null;
296
302
  this.#getPluginRoots = options.getPluginRoots;
303
+ this.#fleetDeps = options.fleetDeps;
297
304
  }
298
305
 
299
306
  #getApiSpecResolver(): ApiSpecResolver {
@@ -352,6 +359,13 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
352
359
  return this.#changesResolver;
353
360
  }
354
361
 
362
+ #getFleetResolver(): FleetResolver {
363
+ if (!this.#fleetResolver) {
364
+ this.#fleetResolver = createFleetResolver(this.#fleetDeps ?? {});
365
+ }
366
+ return this.#fleetResolver;
367
+ }
368
+
355
369
  #getSourceResolver(): SourceResolver {
356
370
  if (!this.#sourceResolver) {
357
371
  this.#sourceResolver = createSourceResolver({ resolveBuildInfo: this.#resolveBuildInfo });
@@ -390,6 +404,10 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
390
404
  return this.#getSourceResolver().resolve(url);
391
405
  }
392
406
 
407
+ if (host === FLEET_HOST) {
408
+ return this.#getFleetResolver().resolve(url);
409
+ }
410
+
393
411
  if (host === BRANDING_HOST) {
394
412
  return this.#resolveBranding(url);
395
413
  }
@@ -494,6 +512,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
494
512
  const syntheticEntry = `- [${ABOUT_ROUTE}](${SCHEME_PREFIX}${ABOUT_ROUTE}) — identity and build fingerprint`;
495
513
  const changesEntry = `- [${CHANGES_HOST}](${SCHEME_PREFIX}${CHANGES_HOST}) — recent merged PRs (what's new since your build), live via gh`;
496
514
  const sourceEntry = `- [${SOURCE_HOST}](${SCHEME_PREFIX}${SOURCE_HOST}) — capability → source-path map and editable-surface rule`;
515
+ const fleetEntry = `- [${FLEET_HOST}](${SCHEME_PREFIX}${FLEET_HOST}) — this repository's class and what you may author here`;
497
516
  const apiSpecEntry = `- [${API_SPEC_HOST}/](${SCHEME_PREFIX}${API_SPEC_HOST}/) — F5 XC API specifications (${specs.index.domains.length} domains, v${specs.version})`;
498
517
  const apiCatalogEntry = `- [${API_CATALOG_HOST}/](${SCHEME_PREFIX}${API_CATALOG_HOST}/) — F5 XC API operation catalog (${catalog.summaries.length} categories, v${catalog.index.version})`;
499
518
  const brandingEntry = `- [${BRANDING_HOST}](${SCHEME_PREFIX}${BRANDING_HOST}) — F5 XC branding and legacy name mapping (v${branding.version})`;
@@ -501,10 +520,11 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
501
520
  const computerEntry = `- [${COMPUTER_ROUTE}](${SCHEME_PREFIX}${COMPUTER_ROUTE}) — machine hardware and environment profile`;
502
521
  const tf = loadTerraformIndex();
503
522
  const terraformEntry = `- [${TERRAFORM_HOST}/](${SCHEME_PREFIX}${TERRAFORM_HOST}/) — F5 XC Terraform provider (${Object.keys(tf.resources).length} resources, v${tf.version})`;
504
- const listing = [
523
+ const entries = [
505
524
  syntheticEntry,
506
525
  changesEntry,
507
526
  sourceEntry,
527
+ fleetEntry,
508
528
  apiSpecEntry,
509
529
  apiCatalogEntry,
510
530
  brandingEntry,
@@ -512,9 +532,12 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
512
532
  userEntry,
513
533
  computerEntry,
514
534
  ...EMBEDDED_DOC_FILENAMES.map(f => `- [${f}](${SCHEME_PREFIX}${f})`),
515
- ].join("\n");
516
- const totalCount = EMBEDDED_DOC_FILENAMES.length + 9;
517
- const content = `# Documentation\n\n${totalCount} files available:\n\n${listing}\n`;
535
+ ];
536
+ // Derived, not hand-maintained: the advertised count used to be a magic
537
+ // offset that no test pinned, so adding a route silently produced a wrong
538
+ // header. Counting the entries we actually list cannot drift.
539
+ const listing = entries.join("\n");
540
+ const content = `# Documentation\n\n${entries.length} files available:\n\n${listing}\n`;
518
541
 
519
542
  return {
520
543
  url: url.href,
@@ -24,6 +24,7 @@ import {
24
24
  RpcHostToolBridge,
25
25
  } from "../../host-tools";
26
26
  import { type Theme, theme } from "../../modes/theme/theme";
27
+ import { referencesEventFor } from "../../references";
27
28
  import type { AgentSession } from "../../session/agent-session";
28
29
  import { mapContextStatus, runWelcomeChecks } from "../components/welcome-checks";
29
30
  import type {
@@ -497,6 +498,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
497
498
  // Output all agent events as JSON
498
499
  session.subscribe(event => {
499
500
  output(event);
501
+ // Citations for the host's Sources chips, mirroring `chat_done.references` on
502
+ // the WS bridge. The turn-boundary rule (an intermediate tool-use step also
503
+ // emits message_end) lives in the shared, tested `referencesEventFor` (#2420).
504
+ const refs = referencesEventFor(event);
505
+ if (refs) output(refs);
500
506
  });
501
507
 
502
508
  // Handle a single command
@@ -28,14 +28,26 @@ technical depth exists to serve the SE work.
28
28
  Judgment: earned from production network incidents, security investigations, live
29
29
  infrastructure deployments, and customer-facing technical engagements.
30
30
 
31
- You operate as a **GitHub / devops / security operator**: you excel at comprehensive GitHub issues
32
- and PRs, Terraform plans, JSON manifests, architecture and how-to documentation, diagrams, and
33
- authorized attack-traffic simulation scripts and at organizing this netops/secops content into
34
- GitHub repositories. You author these artifacts and file rigorously-verified issues, but you are
35
- **not** the implementation coding harness: writing feature code for xcsh itself, the plugin
36
- marketplace, or the API specs is delegated to a dedicated development harness (Claude Code / Codex)
37
- with its own environment. You are also a **work in progress** under active development you improve
38
- by verified contribution, never by claiming. See `<self-awareness>`.
31
+ You are tuned as a **network-engineer assistant, not a coding assistant**. Your competence is
32
+ networking, security and cloud operations expressed through GitHub: comprehensive issues and PRs,
33
+ Terraform plans, JSON manifests, architecture and how-to documentation, diagrams, MEDDPICC and
34
+ account collateral, presentations, and authorized attack-traffic simulation scripts and organizing
35
+ that netops/secops content into GitHub repositories. Your operational reach spans cloud-provider
36
+ CLIs, document and spreadsheet authoring, browser-driven F5 XC console automation, GitHub, and
37
+ CRM/pipeline data; the specific capabilities available in this session are the tools and installed
38
+ plugins listed below, not an assumed set.
39
+
40
+ Writing code is something you can do, but shipping feature code is not your job. Which repository
41
+ you are standing in decides how you contribute, and that is declared rather than guessed: read
42
+ `xcsh://fleet`. In a repository classified **content** you author directly — documentation,
43
+ Terraform, howtos, demo and traffic-generation scripts — through the governed path. In one
44
+ classified **developer** your deliverable is a rigorously-verified issue plus the specification,
45
+ review and documentation around it, and the implementation is delegated to a development
46
+ environment (Claude Code / Codex). In one classified **scaffolding**, changes go through the
47
+ governed path only. An unclassified repository is treated as **developer**.
48
+
49
+ You are also a **work in progress** under active development — you improve by verified
50
+ contribution, never by claiming. See `<self-awareness>`.
39
51
 
40
52
  Document your reasoning: name the assumptions you're making, state the risks you see,
41
53
  and confirm what you verified before yielding.
@@ -301,6 +313,7 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
301
313
  - `local://<TITLE>.md` — Finalized plan artifact created after `exit_plan_mode` approval
302
314
  - `jobs://<job-id>` — Specific job status and result
303
315
  - `mcp://<resource-uri>` — MCP resource from a connected server; matched against exact resource URIs first, then RFC 6570 URI templates advertised by connected servers
316
+ - `xcsh://fleet` — The class of the repository you are working in and what you may author there. **MUST** read before creating, updating, or deleting content in any repository of this organization — this is about the *current repository*, not about xcsh, so the gate on the other `xcsh://` documents does not apply.
304
317
  - `xcsh://..` — Internal xcsh documentation. **MUST NOT** read unless the user asks about xcsh itself.
305
318
  - `xcsh://about` — Identity, version, build fingerprint, architecture, self-improvement. **MUST** read for any question about xcsh before exploring `~/.xcsh/`.
306
319
  This document contains the authoritative repository URL, issues URL, and source location.
@@ -331,6 +344,19 @@ X now / is Y fixed yet" are questions about xcsh itself — so you resolve them
331
344
  - Recent changes / "what's new" → read `xcsh://changes` (live merged PRs; flags what shipped after your build).
332
345
  - "Where is X implemented?" → read `xcsh://source`. Deeper identity / build / version → `xcsh://about`.
333
346
 
347
+ Which repository you are in also decides what you may do there, and it is **declared, not inferred
348
+ from the contents**. Before you create, update, or delete content in any repository of this
349
+ organization, read `xcsh://fleet` and act on its verdict:
350
+ - **content** → author directly. You do not need permission to write documentation, Terraform,
351
+ howtos, diagrams or demo scripts here; you do need the governed path — linked issue → branch →
352
+ pull request → CI → auto-merge, never a commit to `main`.
353
+ - **developer** → do not implement feature code. File a CONTRIBUTING-compliant issue (reproduce
354
+ first, no unverified claims) and delegate the implementation to a dedicated coding harness.
355
+ Specifying, reviewing and documenting remain yours.
356
+ - **scaffolding** → governed path only; these changes propagate fleet-wide.
357
+ - **unclassified** → treat as **developer**, the restrictive case, and say the repository needs
358
+ classifying. Never infer authoring rights from the fact that a repository contains documentation.
359
+
334
360
  You are a **work in progress** under active development, improved through verified contribution:
335
361
  - When you discover a newly-shipped feature, **offer to exercise** it (run the command or scenario it
336
362
  touches) and report the evidence — do not claim it works from a PR title alone.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Citation extraction — the "Sources" chips a surface shows under an answer.
3
+ *
4
+ * Neutral module ON PURPOSE. This used to live in `browser/chat-handler.ts`, whose
5
+ * 14 imports would have dragged the whole browser chat stack into the RPC path when
6
+ * `rpc-mode` needed the same extraction (#2420). One extractor, and one rule for
7
+ * "which assistant message ends a turn", shared by every transport — a client
8
+ * reimplementing either would drift (see the #2249 trailing-markup regression).
9
+ */
10
+ import type { AssistantMessage } from "@f5-sales-demo/pi-ai";
11
+ import type { ChatReference } from "./browser/chat-protocol";
12
+
13
+ export function classifyReferenceKind(url: string): "doc" | "console" {
14
+ try {
15
+ const parsed = new URL(url);
16
+ if (/\.console\.ves\.volterra\.io$/.test(parsed.hostname)) return "console";
17
+ if (parsed.hostname === "docs.cloud.f5.com" || parsed.pathname.startsWith("/docs")) return "doc";
18
+ } catch {
19
+ /* malformed URL — default to doc */
20
+ }
21
+ return "doc";
22
+ }
23
+ function titleFromUrl(url: string): string {
24
+ try {
25
+ const parsed = new URL(url);
26
+ const segments = parsed.pathname.split("/").filter(Boolean);
27
+ return segments.length > 0 ? segments[segments.length - 1] : parsed.hostname;
28
+ } catch {
29
+ return url;
30
+ }
31
+ }
32
+ /**
33
+ * Trailing characters a bare-URL match may greedily swallow at a markdown/prose
34
+ * boundary — markdown emphasis + code (`*_~` and backtick) and sentence/wrap
35
+ * punctuation. A real URL effectively never ends in these, so trimming them yields
36
+ * the intended link (e.g. `**https://…/llms.txt**` or a code-wrapped
37
+ * `` `https://…/llms.txt` `` → `https://…/llms.txt`). The markdown-link branch is
38
+ * bounded by its closing `)` and needs no trimming.
39
+ */
40
+ function trimTrailingMarkup(url: string): string {
41
+ return url.replace(/[*_~`,.;:!?'")\]}>]+$/, "");
42
+ }
43
+ export function extractReferences(msg: AssistantMessage): ChatReference[] {
44
+ const refs: ChatReference[] = [];
45
+ const seen = new Set<string>();
46
+
47
+ // STRUCTURED CITATIONS FIRST (#2340): a provider-side search reports the real page title, so
48
+ // it beats the regex scrape below — which can only guess a title from the URL path, and finds
49
+ // nothing at all when the model cites a source without printing its URL in the prose. Done as
50
+ // its own pass so a citation in a later block still wins over a scrape in an earlier one.
51
+ for (const block of msg.content) {
52
+ if (block.type !== "text" || !block.citations) continue;
53
+ for (const citation of block.citations) {
54
+ const url = citation.url;
55
+ if (!url || seen.has(url)) continue;
56
+ seen.add(url);
57
+ refs.push({
58
+ kind: classifyReferenceKind(url),
59
+ title: citation.title?.trim() || titleFromUrl(url),
60
+ url,
61
+ });
62
+ }
63
+ }
64
+
65
+ for (const block of msg.content) {
66
+ if (block.type !== "text") continue;
67
+
68
+ const mdLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
69
+ for (let match = mdLinkRegex.exec(block.text); match !== null; match = mdLinkRegex.exec(block.text)) {
70
+ const [, title, url] = match;
71
+ if (seen.has(url)) continue;
72
+ seen.add(url);
73
+ refs.push({ kind: classifyReferenceKind(url), title, url });
74
+ }
75
+
76
+ const bareUrlRegex = /(?<!\()(https?:\/\/[^\s)>\]]+)/g;
77
+ for (let match = bareUrlRegex.exec(block.text); match !== null; match = bareUrlRegex.exec(block.text)) {
78
+ const url = trimTrailingMarkup(match[1]);
79
+ if (seen.has(url)) continue;
80
+ seen.add(url);
81
+ refs.push({ kind: classifyReferenceKind(url), title: titleFromUrl(url), url });
82
+ }
83
+ }
84
+ return refs;
85
+ }
86
+
87
+ /** The RPC stream's citation event, mirroring `chat_done.references` on the bridge. */
88
+ export interface RpcReferencesEvent {
89
+ type: "references";
90
+ references: ChatReference[];
91
+ }
92
+
93
+ /**
94
+ * Decide whether an agent event should produce a citation event, and build it.
95
+ *
96
+ * Pure so the TURN-BOUNDARY rule is testable: an intermediate tool-use step also
97
+ * emits `message_end`, and treating that as terminal is the trap
98
+ * `chat-handler.ts` documents at length — it would publish the citations of a
99
+ * half-finished answer (and, on the bridge, end the turn early). Returns null for
100
+ * anything that is not a settled assistant message, and for a turn that cited
101
+ * nothing, so callers emit only real content.
102
+ *
103
+ * Typed by what it CONSUMES (a tagged event that may carry a message) rather than by
104
+ * one event union: the bridge and the RPC session stream different unions
105
+ * (`AgentEvent` vs `AgentSessionEvent`) and both must be able to call this.
106
+ */
107
+ export function referencesEventFor(event: { type: string; message?: unknown }): RpcReferencesEvent | null {
108
+ if (event.type !== "message_end") return null;
109
+ const message = (event as { message?: { role?: string } }).message;
110
+ if (message?.role !== "assistant") return null;
111
+ const assistant = message as AssistantMessage;
112
+ // Intermediate tool-use step — the turn continues after the tool round-trip.
113
+ if (assistant.stopReason === "toolUse" || assistant.content.some(part => part.type === "toolCall")) return null;
114
+ const references = extractReferences(assistant);
115
+ return references.length > 0 ? { type: "references", references } : null;
116
+ }
package/src/sdk.ts CHANGED
@@ -83,6 +83,7 @@ import {
83
83
  SkillProtocolHandler,
84
84
  } from "./internal-urls";
85
85
  import { buildComputerHint, loadComputerProfile } from "./internal-urls/computer-profile";
86
+ import { createLiveCwdGetter } from "./internal-urls/fleet-resolve";
86
87
  import { loadProfile, type UserProfile } from "./internal-urls/user-profile";
87
88
  import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./ipy/executor";
88
89
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-events";
@@ -1151,6 +1152,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1151
1152
  }
1152
1153
  },
1153
1154
  getPluginRoots: () => listXcshPluginRoots(os.homedir(), cwd).then(r => r.roots),
1155
+ // Classification must follow the session's `cd`, not the process cwd, which
1156
+ // never moves. The bash tool emits `cwd:changed` when it relocates.
1157
+ fleetDeps: { cwd: createLiveCwdGetter(cwd, eventBus) },
1154
1158
  }),
1155
1159
  );
1156
1160
  internalRouter.register(new JobsProtocolHandler({ getAsyncJobManager: () => asyncJobManager }));
@@ -15,7 +15,24 @@ export interface LlmsIndex {
15
15
  fetchedAt: string;
16
16
  }
17
17
 
18
- const INFRASTRUCTURE_SLUGS = new Set([
18
+ /**
19
+ * Federated sites that are not F5 XC *product* documentation, and so do not belong
20
+ * in the product list this module feeds to the system prompt.
21
+ *
22
+ * Deliberately NOT the `repo_classes` authority classification from
23
+ * `xcsh://fleet`. The two answer different questions and disagree: `docs`,
24
+ * `cdn-simulator` and `origin-server` are all authority `content` — xcsh does author
25
+ * their documentation and Terraform — while being the documentation portal and two
26
+ * lab-infrastructure repositories rather than products. Driving this filter off
27
+ * authority would advertise all three as F5 XC products.
28
+ *
29
+ * The concern this tracks is the `llms-federated-sites.json` category in the `docs`
30
+ * repository: everything here is `build-platform`, `developer-tools`, `portal` or
31
+ * `lab-infrastructure`, and everything kept is `product-features` or
32
+ * `api-specifications`. That file is not synced into clones, so the list is
33
+ * maintained here.
34
+ */
35
+ const NON_PRODUCT_SLUGS = new Set([
19
36
  "docs-builder",
20
37
  "docs-theme",
21
38
  "docs-icons",
@@ -70,7 +87,7 @@ export function parseLlmsTxt(content: string, now?: Date): LlmsIndex {
70
87
 
71
88
  const [, name, url, desc] = match;
72
89
  const slug = extractSlug(url);
73
- if (slug && INFRASTRUCTURE_SLUGS.has(slug)) continue;
90
+ if (slug && NON_PRODUCT_SLUGS.has(slug)) continue;
74
91
 
75
92
  products.push({ name, description: desc, url });
76
93
  }