@mattstack/rt-client 0.4.1 → 0.5.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/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { RtResponse, RtClientOptions } from "./transport.ts";
2
- import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, ForgeSlug, ForgeTokenData, RunSummary, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary } from "./commands.ts";
2
+ import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, BranchEnrichment, ForgeSlug, ForgeTokenData, RunSummary, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary } from "./commands.ts";
3
3
  /**
4
4
  * One repo's project open-MR store. A cold repo forces a full paginated sync
5
5
  * on the daemon side when maxAgeMs demands it, which can run tens of seconds
@@ -13,6 +13,12 @@ import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, Forge
13
13
  export declare function readProjectMRs(repoName: string, maxAgeMs?: number, opts?: RtClientOptions, demand?: DemandDecl): Promise<RtResponse<ProjectMRsData>>;
14
14
  export declare function readDiscussions(repoName: string, iid: number, opts?: RtClientOptions): Promise<RtResponse<DiscussionsData>>;
15
15
  export declare function readMrsByBranch(repoName: string, branches: string[], opts?: RtClientOptions): Promise<RtResponse<MrByBranchData>>;
16
+ /**
17
+ * Cached ticket/MR enrichment for a set of branches, keyed by branch name
18
+ * (the cache's own primary key -- see lib/state/branch-cache.ts). Serves
19
+ * whatever the daemon already has; it does not trigger a fetch.
20
+ */
21
+ export declare function readBranchCache(branches: string[], opts?: RtClientOptions): Promise<RtResponse<Record<string, BranchEnrichment>>>;
16
22
  /**
17
23
  * The forge token for one tracked repo (MAT-33). Grant-gated on the daemon
18
24
  * side: an untracked repo comes back `ok: false` with the `rt daemon track`
@@ -10,17 +10,24 @@ export type Discussion = MRDetail["discussions"][number];
10
10
  export interface DemandDecl {
11
11
  client: string;
12
12
  authors: string[];
13
+ /** Codeowner sections this client needs covered (spec: second demand axis). */
14
+ codeownerSections?: string[];
13
15
  declaredAt: number;
14
16
  }
15
17
  export interface ProjectMRsScope {
16
18
  authors: string[];
17
19
  windowDays: number;
18
20
  uncovered: string[];
21
+ /** Effective synced section union; absent from a pre-sections daemon. */
22
+ sections?: string[];
23
+ /** Demanded sections not yet swept for this client. */
24
+ uncoveredSections?: string[];
19
25
  }
20
26
  export interface ProjectMRsData {
21
27
  mrs: Record<string, {
22
28
  pr: PullRequest;
23
29
  fetchedAt: number;
30
+ codeownerSections?: string[];
24
31
  }>;
25
32
  listSyncedAt: number;
26
33
  source: "poll" | "events" | "mutation";
@@ -40,6 +47,31 @@ export interface MrByBranchData {
40
47
  byBranch: Record<string, MrByBranchEntry | null>;
41
48
  syncedAt: number;
42
49
  }
50
+ /**
51
+ * Trimmed, structural view of the daemon's `CacheEntry` (lib/state/branch-cache.ts) --
52
+ * rt-client cannot import daemon/lib internals, so this names only the fields
53
+ * console's run-view rows read, spelled exactly as they land on the wire
54
+ * (`mr` is `toMRInfo(pr)`, i.e. `getMRDashboardProps` -- camelCase `webUrl`,
55
+ * nested `pipeline.status`, no `ciStatus`). Extra wire fields (including the
56
+ * rest of `pipeline`) are fine; anything this shape doesn't name is simply
57
+ * not surfaced.
58
+ */
59
+ export interface BranchEnrichment {
60
+ ticket: {
61
+ identifier: string;
62
+ title: string;
63
+ url: string;
64
+ } | null;
65
+ mr: {
66
+ iid: number;
67
+ webUrl: string | null;
68
+ state: string;
69
+ pipeline: {
70
+ status: string;
71
+ } | null;
72
+ } | null;
73
+ fetchedAt: number;
74
+ }
43
75
  /** Forges the daemon can hold a token for. */
44
76
  export type ForgeSlug = "gitlab" | "github";
45
77
  export interface ForgeTokenData {
@@ -90,7 +122,7 @@ export interface RoomSummary {
90
122
  }
91
123
  export type Attention = {
92
124
  needs: boolean;
93
- reason: "failed" | "stale" | "stranded" | null;
125
+ reason: "failed" | "stale" | "stranded" | "blocked" | null;
94
126
  evidence: string;
95
127
  };
96
128
  export interface RunSummary {
@@ -115,6 +147,20 @@ export interface RunSummary {
115
147
  the run has not produced that field yet. */
116
148
  ticket: string | null;
117
149
  branch: string | null;
150
+ /** The herdr agent attributed to this run (matched by recorded claude
151
+ session, else by worktree), mirrored live from `herdr agent list`.
152
+ Null when no agent matches or herdr is unavailable; absent on
153
+ pre-mirror daemons. */
154
+ agent?: RunAgent | null;
155
+ /** Executed stages only, in run order — the pipeline may define more that have not started. */
156
+ stages?: {
157
+ name: string;
158
+ status: string;
159
+ }[];
160
+ }
161
+ export interface RunAgent {
162
+ status: "working" | "idle" | "blocked" | "done" | "unknown";
163
+ pane: string;
118
164
  }
119
165
  export interface RunStageRow {
120
166
  name: string;
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
2
2
  export type { RtResponse, RtClientOptions } from "./transport.ts";
3
- export { readProjectMRs, readDiscussions, readMrsByBranch, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatArm, chatTouch, chatDisarm, chatUnreadWaking, eventsHead, } from "./client.ts";
3
+ export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatArm, chatTouch, chatDisarm, chatUnreadWaking, eventsHead, } from "./client.ts";
4
4
  export { COMMAND_NAMES } from "./commands.ts";
5
- export type { Discussion, DemandDecl, ProjectMRsScope, ProjectMRsData, DiscussionsData, MrByBranchEntry, MrByBranchData, Commands, CommandName, ForgeSlug, ForgeTokenData, Attention, RunSummary, RunStageRow, RunFieldRow, RunDecisionRow, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary, } from "./commands.ts";
5
+ export type { Discussion, DemandDecl, ProjectMRsScope, ProjectMRsData, DiscussionsData, MrByBranchEntry, MrByBranchData, BranchEnrichment, Commands, CommandName, ForgeSlug, ForgeTokenData, Attention, RunSummary, RunStageRow, RunFieldRow, RunDecisionRow, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary, } from "./commands.ts";
6
6
  export { subscribe, DEFAULT_WS_URL } from "./relay.ts";
7
7
  export type { RelayEventType } from "./relay.ts";
8
8
  export { repoNameForPath } from "./repos.ts";
package/dist/index.js CHANGED
@@ -39,6 +39,9 @@ function readDiscussions(repoName, iid, opts = {}) {
39
39
  function readMrsByBranch(repoName, branches, opts = {}) {
40
40
  return rtCommand("mr:by-branch", { repoName, branches }, { sockPath: opts.sockPath, timeoutMs: 60000 });
41
41
  }
42
+ function readBranchCache(branches, opts = {}) {
43
+ return rtCommand("cache:read", { branches }, { sockPath: opts.sockPath, timeoutMs: 1e4 });
44
+ }
42
45
  function resolveForgeToken(repoName, forge, opts = {}) {
43
46
  return rtCommand("secrets:forge-token", { repoName, forge }, { sockPath: opts.sockPath, timeoutMs: 1e4 });
44
47
  }
@@ -612,6 +615,13 @@ var REGISTRY = [
612
615
  merge: "replace",
613
616
  description: "Doctor skill the board's own API-tier triage sweep runs on your MRs; deliberately never resolved through a repo's skills.jsonc manifest. A sibling flat key of board.triage, not a field inside it — the board reader assembles the two independently."
614
617
  },
618
+ {
619
+ key: "board.tabs",
620
+ type: "array",
621
+ scopes: ["team"],
622
+ merge: "replace",
623
+ description: "Board tab definitions ({id, label, source, slackChannel?, reviewSkill?}); source.kind 'authors' is the classic roster board, 'codeowners' lists MRs blocked on an unapproved CODEOWNERS section. Absent = one implicit authors tab (fallback lives in the board reader, never here)."
624
+ },
615
625
  {
616
626
  key: "board.staleAfterDays",
617
627
  type: "number",
@@ -1395,7 +1405,7 @@ async function runCapture(argv, opts = {}) {
1395
1405
  }
1396
1406
  }
1397
1407
 
1398
- // src/settings/identity.ts
1408
+ // src/settings/identity-codec.ts
1399
1409
  var URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
1400
1410
  var SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
1401
1411
  function serializeIdentity(id) {
@@ -1443,6 +1453,8 @@ function normalizeRemote(remote) {
1443
1453
  return null;
1444
1454
  return `${host.toLowerCase()}/${normalizedPath}`;
1445
1455
  }
1456
+
1457
+ // src/settings/identity.ts
1446
1458
  function identityFromRemote(remote) {
1447
1459
  const store = readStore(machineSettingsPath());
1448
1460
  const overrides = store.global["rt.repoIdentityOverrides"];
@@ -1522,6 +1534,7 @@ export {
1522
1534
  readProjectMRs,
1523
1535
  readMrsByBranch,
1524
1536
  readDiscussions,
1537
+ readBranchCache,
1525
1538
  parseIdentity,
1526
1539
  normalizeRemote,
1527
1540
  listTeams,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The pure half of the repo-identity contract: the wire codec and the
3
+ * remote-URL normalizer. Split from identity.ts so browser bundles can key
4
+ * and label repos without dragging in fs/child_process — this module must
5
+ * never import node builtins or anything that does (the `./identity`
6
+ * subpath export points here, and a browser consumer evaluates it at module
7
+ * scope). Override-aware and derivation entry points stay in identity.ts.
8
+ */
9
+ export type RepoIdentity = {
10
+ kind: "remote";
11
+ id: string;
12
+ } | {
13
+ kind: "path";
14
+ id: string;
15
+ };
16
+ /**
17
+ * The wire form crosses the daemon socket, sits in board config, and lands in
18
+ * console's `/runs/:repo/...` URL — all of which need one slash-free segment.
19
+ * `encodeURIComponent` guarantees that and is exactly reversible.
20
+ */
21
+ export declare function serializeIdentity(id: RepoIdentity): string;
22
+ export declare function parseIdentity(wire: string): RepoIdentity | null;
23
+ /**
24
+ * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
25
+ * embedded credentials stripped) or null when the remote doesn't match a
26
+ * recognized host form (local paths, garbage input).
27
+ */
28
+ export declare function normalizeRemote(remote: string): string | null;
@@ -0,0 +1,53 @@
1
+ // src/settings/identity-codec.ts
2
+ var URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
3
+ var SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
4
+ function serializeIdentity(id) {
5
+ return `${id.kind}:${encodeURIComponent(id.id)}`;
6
+ }
7
+ function parseIdentity(wire) {
8
+ const colon = wire.indexOf(":");
9
+ if (colon === -1)
10
+ return null;
11
+ const kind = wire.slice(0, colon);
12
+ if (kind !== "remote" && kind !== "path")
13
+ return null;
14
+ const encoded = wire.slice(colon + 1);
15
+ let id;
16
+ try {
17
+ id = decodeURIComponent(encoded);
18
+ } catch {
19
+ return null;
20
+ }
21
+ if (encodeURIComponent(id) !== encoded)
22
+ return null;
23
+ return { kind, id };
24
+ }
25
+ function normalizeRemote(remote) {
26
+ const trimmed = remote.trim();
27
+ if (!trimmed)
28
+ return null;
29
+ let host;
30
+ let path;
31
+ const urlMatch = URL_RE.exec(trimmed);
32
+ if (urlMatch) {
33
+ host = urlMatch[1];
34
+ path = urlMatch[2];
35
+ } else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
36
+ const scpMatch = SCP_RE.exec(trimmed);
37
+ if (scpMatch) {
38
+ host = scpMatch[1];
39
+ path = scpMatch[2];
40
+ }
41
+ }
42
+ if (!host || !path)
43
+ return null;
44
+ const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
45
+ if (!normalizedPath)
46
+ return null;
47
+ return `${host.toLowerCase()}/${normalizedPath}`;
48
+ }
49
+ export {
50
+ serializeIdentity,
51
+ parseIdentity,
52
+ normalizeRemote
53
+ };
@@ -25,26 +25,9 @@
25
25
  * through `identityFromRemote`, memoized per path so repeated callers in
26
26
  * one process don't re-spawn git.
27
27
  */
28
- export type RepoIdentity = {
29
- kind: "remote";
30
- id: string;
31
- } | {
32
- kind: "path";
33
- id: string;
34
- };
35
- /**
36
- * The wire form crosses the daemon socket, sits in board config, and lands in
37
- * console's `/runs/:repo/...` URL — all of which need one slash-free segment.
38
- * `encodeURIComponent` guarantees that and is exactly reversible.
39
- */
40
- export declare function serializeIdentity(id: RepoIdentity): string;
41
- export declare function parseIdentity(wire: string): RepoIdentity | null;
42
- /**
43
- * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
44
- * embedded credentials stripped) or null when the remote doesn't match a
45
- * recognized host form (local paths, garbage input).
46
- */
47
- export declare function normalizeRemote(remote: string): string | null;
28
+ import { type RepoIdentity } from "./identity-codec.ts";
29
+ export { serializeIdentity, parseIdentity, normalizeRemote } from "./identity-codec.ts";
30
+ export type { RepoIdentity } from "./identity-codec.ts";
48
31
  /**
49
32
  * The sync helper every non-derivation call site uses: machine-store
50
33
  * fork/multi-remote overrides (exact remote-URL match) then normalizeRemote.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mattstack/rt-client",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -9,6 +9,12 @@
9
9
  "import": "./dist/index.js",
10
10
  "default": "./dist/index.js"
11
11
  },
12
+ "./identity": {
13
+ "types": "./dist/settings/identity-codec.d.ts",
14
+ "bun": "./src/settings/identity-codec.ts",
15
+ "import": "./dist/settings/identity-codec.js",
16
+ "default": "./dist/settings/identity-codec.js"
17
+ },
12
18
  "./test/fake-daemon.ts": "./test/fake-daemon.ts"
13
19
  },
14
20
  "peerDependencies": {
@@ -39,7 +45,7 @@
39
45
  "bun": ">=1.0.0"
40
46
  },
41
47
  "scripts": {
42
- "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.json",
48
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && bun build src/settings/identity-codec.ts --outfile dist/settings/identity-codec.js --target browser --format esm && tsc -p tsconfig.json",
43
49
  "check-types": "tsc --noEmit -p tsconfig.json",
44
50
  "prepack": "bun run build"
45
51
  },
package/src/client.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  ProjectMRsData,
11
11
  DiscussionsData,
12
12
  MrByBranchData,
13
+ BranchEnrichment,
13
14
  ForgeSlug,
14
15
  ForgeTokenData,
15
16
  RunSummary,
@@ -66,6 +67,22 @@ export function readMrsByBranch(
66
67
  );
67
68
  }
68
69
 
70
+ /**
71
+ * Cached ticket/MR enrichment for a set of branches, keyed by branch name
72
+ * (the cache's own primary key -- see lib/state/branch-cache.ts). Serves
73
+ * whatever the daemon already has; it does not trigger a fetch.
74
+ */
75
+ export function readBranchCache(
76
+ branches: string[],
77
+ opts: RtClientOptions = {},
78
+ ): Promise<RtResponse<Record<string, BranchEnrichment>>> {
79
+ return rtCommand<Record<string, BranchEnrichment>>(
80
+ "cache:read",
81
+ { branches },
82
+ { sockPath: opts.sockPath, timeoutMs: 10_000 },
83
+ );
84
+ }
85
+
69
86
  /**
70
87
  * The forge token for one tracked repo (MAT-33). Grant-gated on the daemon
71
88
  * side: an untracked repo comes back `ok: false` with the `rt daemon track`
package/src/commands.ts CHANGED
@@ -12,6 +12,8 @@ export type Discussion = MRDetail["discussions"][number];
12
12
  export interface DemandDecl {
13
13
  client: string;
14
14
  authors: string[];
15
+ /** Codeowner sections this client needs covered (spec: second demand axis). */
16
+ codeownerSections?: string[];
15
17
  declaredAt: number;
16
18
  }
17
19
 
@@ -19,10 +21,14 @@ export interface ProjectMRsScope {
19
21
  authors: string[];
20
22
  windowDays: number;
21
23
  uncovered: string[];
24
+ /** Effective synced section union; absent from a pre-sections daemon. */
25
+ sections?: string[];
26
+ /** Demanded sections not yet swept for this client. */
27
+ uncoveredSections?: string[];
22
28
  }
23
29
 
24
30
  export interface ProjectMRsData {
25
- mrs: Record<string, { pr: PullRequest; fetchedAt: number }>;
31
+ mrs: Record<string, { pr: PullRequest; fetchedAt: number; codeownerSections?: string[] }>;
26
32
  listSyncedAt: number;
27
33
  source: "poll" | "events" | "mutation";
28
34
  syncedAt: number;
@@ -45,6 +51,21 @@ export interface MrByBranchData {
45
51
  syncedAt: number;
46
52
  }
47
53
 
54
+ /**
55
+ * Trimmed, structural view of the daemon's `CacheEntry` (lib/state/branch-cache.ts) --
56
+ * rt-client cannot import daemon/lib internals, so this names only the fields
57
+ * console's run-view rows read, spelled exactly as they land on the wire
58
+ * (`mr` is `toMRInfo(pr)`, i.e. `getMRDashboardProps` -- camelCase `webUrl`,
59
+ * nested `pipeline.status`, no `ciStatus`). Extra wire fields (including the
60
+ * rest of `pipeline`) are fine; anything this shape doesn't name is simply
61
+ * not surfaced.
62
+ */
63
+ export interface BranchEnrichment {
64
+ ticket: { identifier: string; title: string; url: string } | null;
65
+ mr: { iid: number; webUrl: string | null; state: string; pipeline: { status: string } | null } | null;
66
+ fetchedAt: number;
67
+ }
68
+
48
69
  /** Forges the daemon can hold a token for. */
49
70
  export type ForgeSlug = "gitlab" | "github";
50
71
 
@@ -99,7 +120,7 @@ export interface RoomSummary {
99
120
  // never derive two verdicts that can disagree.
100
121
  export type Attention = {
101
122
  needs: boolean;
102
- reason: "failed" | "stale" | "stranded" | null;
123
+ reason: "failed" | "stale" | "stranded" | "blocked" | null;
103
124
  evidence: string;
104
125
  };
105
126
 
@@ -120,6 +141,17 @@ export interface RunSummary {
120
141
  the run has not produced that field yet. */
121
142
  ticket: string | null;
122
143
  branch: string | null;
144
+ /** The herdr agent attributed to this run (matched by recorded claude
145
+ session, else by worktree), mirrored live from `herdr agent list`.
146
+ Null when no agent matches or herdr is unavailable; absent on
147
+ pre-mirror daemons. */
148
+ agent?: RunAgent | null;
149
+ /** Executed stages only, in run order — the pipeline may define more that have not started. */
150
+ stages?: { name: string; status: string }[];
151
+ }
152
+ export interface RunAgent {
153
+ status: "working" | "idle" | "blocked" | "done" | "unknown";
154
+ pane: string;
123
155
  }
124
156
  export interface RunStageRow {
125
157
  name: string; status: string; attempt: number;
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  readProjectMRs,
6
6
  readDiscussions,
7
7
  readMrsByBranch,
8
+ readBranchCache,
8
9
  resolveForgeToken,
9
10
  listRuns,
10
11
  getRun,
@@ -33,6 +34,7 @@ export type {
33
34
  DiscussionsData,
34
35
  MrByBranchEntry,
35
36
  MrByBranchData,
37
+ BranchEnrichment,
36
38
  Commands,
37
39
  CommandName,
38
40
  ForgeSlug,
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The pure half of the repo-identity contract: the wire codec and the
3
+ * remote-URL normalizer. Split from identity.ts so browser bundles can key
4
+ * and label repos without dragging in fs/child_process — this module must
5
+ * never import node builtins or anything that does (the `./identity`
6
+ * subpath export points here, and a browser consumer evaluates it at module
7
+ * scope). Override-aware and derivation entry points stay in identity.ts.
8
+ */
9
+
10
+ // Full-URL forms: scheme://[user[:pass]@]host/path — https, ssh, git, http, ...
11
+ const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
12
+
13
+ // scp-like scp syntax: [user@]host:path (git@gitlab.com:group/repo.git).
14
+ // Deliberately excludes anything starting with "/" (absolute local paths)
15
+ // so a Windows-drive-letter-free local remote never falsely matches.
16
+ const SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
17
+
18
+ export type RepoIdentity =
19
+ | { kind: "remote"; id: string }
20
+ | { kind: "path"; id: string };
21
+
22
+ /**
23
+ * The wire form crosses the daemon socket, sits in board config, and lands in
24
+ * console's `/runs/:repo/...` URL — all of which need one slash-free segment.
25
+ * `encodeURIComponent` guarantees that and is exactly reversible.
26
+ */
27
+ export function serializeIdentity(id: RepoIdentity): string {
28
+ return `${id.kind}:${encodeURIComponent(id.id)}`;
29
+ }
30
+
31
+ export function parseIdentity(wire: string): RepoIdentity | null {
32
+ const colon = wire.indexOf(":");
33
+ if (colon === -1) return null;
34
+ const kind = wire.slice(0, colon);
35
+ if (kind !== "remote" && kind !== "path") return null;
36
+ const encoded = wire.slice(colon + 1);
37
+ let id: string;
38
+ try {
39
+ id = decodeURIComponent(encoded);
40
+ } catch {
41
+ return null;
42
+ }
43
+ // Canonical wires only: the id segment must be byte-for-byte what
44
+ // serializeIdentity emits. Guard sites validate with parseIdentity and then
45
+ // use the WIRE as a single path component (repoDataDir et al.) — a
46
+ // hand-built wire with a literal "/" ("path:../..") would otherwise parse
47
+ // and escape the state directory.
48
+ if (encodeURIComponent(id) !== encoded) return null;
49
+ return { kind, id };
50
+ }
51
+
52
+ /**
53
+ * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
54
+ * embedded credentials stripped) or null when the remote doesn't match a
55
+ * recognized host form (local paths, garbage input).
56
+ */
57
+ export function normalizeRemote(remote: string): string | null {
58
+ const trimmed = remote.trim();
59
+ if (!trimmed) return null;
60
+
61
+ let host: string | undefined;
62
+ let path: string | undefined;
63
+
64
+ const urlMatch = URL_RE.exec(trimmed);
65
+ if (urlMatch) {
66
+ host = urlMatch[1];
67
+ path = urlMatch[2];
68
+ } else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
69
+ const scpMatch = SCP_RE.exec(trimmed);
70
+ if (scpMatch) {
71
+ host = scpMatch[1];
72
+ path = scpMatch[2];
73
+ }
74
+ }
75
+
76
+ if (!host || !path) return null;
77
+
78
+ const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
79
+ if (!normalizedPath) return null;
80
+
81
+ return `${host.toLowerCase()}/${normalizedPath}`;
82
+ }
@@ -32,79 +32,10 @@ import { runCapture } from "./exec.ts";
32
32
  import { machineSettingsPath } from "./paths.ts";
33
33
  import { readStore } from "./stores.ts";
34
34
 
35
- // Full-URL forms: scheme://[user[:pass]@]host/path https, ssh, git, http, ...
36
- const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
35
+ import { normalizeRemote, type RepoIdentity } from "./identity-codec.ts";
37
36
 
38
- // scp-like scp syntax: [user@]host:path (git@gitlab.com:group/repo.git).
39
- // Deliberately excludes anything starting with "/" (absolute local paths)
40
- // so a Windows-drive-letter-free local remote never falsely matches.
41
- const SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
42
-
43
- export type RepoIdentity =
44
- | { kind: "remote"; id: string }
45
- | { kind: "path"; id: string };
46
-
47
- /**
48
- * The wire form crosses the daemon socket, sits in board config, and lands in
49
- * console's `/runs/:repo/...` URL — all of which need one slash-free segment.
50
- * `encodeURIComponent` guarantees that and is exactly reversible.
51
- */
52
- export function serializeIdentity(id: RepoIdentity): string {
53
- return `${id.kind}:${encodeURIComponent(id.id)}`;
54
- }
55
-
56
- export function parseIdentity(wire: string): RepoIdentity | null {
57
- const colon = wire.indexOf(":");
58
- if (colon === -1) return null;
59
- const kind = wire.slice(0, colon);
60
- if (kind !== "remote" && kind !== "path") return null;
61
- const encoded = wire.slice(colon + 1);
62
- let id: string;
63
- try {
64
- id = decodeURIComponent(encoded);
65
- } catch {
66
- return null;
67
- }
68
- // Canonical wires only: the id segment must be byte-for-byte what
69
- // serializeIdentity emits. Guard sites validate with parseIdentity and then
70
- // use the WIRE as a single path component (repoDataDir et al.) — a
71
- // hand-built wire with a literal "/" ("path:../..") would otherwise parse
72
- // and escape the state directory.
73
- if (encodeURIComponent(id) !== encoded) return null;
74
- return { kind, id };
75
- }
76
-
77
- /**
78
- * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
79
- * embedded credentials stripped) or null when the remote doesn't match a
80
- * recognized host form (local paths, garbage input).
81
- */
82
- export function normalizeRemote(remote: string): string | null {
83
- const trimmed = remote.trim();
84
- if (!trimmed) return null;
85
-
86
- let host: string | undefined;
87
- let path: string | undefined;
88
-
89
- const urlMatch = URL_RE.exec(trimmed);
90
- if (urlMatch) {
91
- host = urlMatch[1];
92
- path = urlMatch[2];
93
- } else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
94
- const scpMatch = SCP_RE.exec(trimmed);
95
- if (scpMatch) {
96
- host = scpMatch[1];
97
- path = scpMatch[2];
98
- }
99
- }
100
-
101
- if (!host || !path) return null;
102
-
103
- const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
104
- if (!normalizedPath) return null;
105
-
106
- return `${host.toLowerCase()}/${normalizedPath}`;
107
- }
37
+ export { serializeIdentity, parseIdentity, normalizeRemote } from "./identity-codec.ts";
38
+ export type { RepoIdentity } from "./identity-codec.ts";
108
39
 
109
40
  /**
110
41
  * The sync helper every non-derivation call site uses: machine-store
@@ -345,6 +345,13 @@ export const REGISTRY: readonly SettingDef[] = [
345
345
  merge: "replace",
346
346
  description: "Doctor skill the board's own API-tier triage sweep runs on your MRs; deliberately never resolved through a repo's skills.jsonc manifest. A sibling flat key of board.triage, not a field inside it — the board reader assembles the two independently.",
347
347
  },
348
+ {
349
+ key: "board.tabs",
350
+ type: "array",
351
+ scopes: ["team"],
352
+ merge: "replace",
353
+ description: "Board tab definitions ({id, label, source, slackChannel?, reviewSkill?}); source.kind 'authors' is the classic roster board, 'codeowners' lists MRs blocked on an unapproved CODEOWNERS section. Absent = one implicit authors tab (fallback lives in the board reader, never here).",
354
+ },
348
355
 
349
356
  // --- board (user) ----------------------------------------------------------
350
357
  {