@gallopsystems/agent-skills 1.20.1 → 1.21.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@gallopsystems/agent-skills",
3
- "version": "1.20.1",
3
+ "version": "1.21.0",
4
4
  "description": "Gallop Systems agent skills, symlinked into .claude/skills (Claude Code) and .agents/skills (Codex) on install.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -15,7 +15,9 @@ Invoke it as `node <skill>/bin/linear.mjs <command> [args] [--flags]`. Examples
15
15
 
16
16
  ### Check 1 — Workspace bootstrap config exists
17
17
 
18
- Before running any `linear.mjs` command, verify that the per-user workspace config exists at `~/.config/linctl/workspace.json` (override path with `$LINCTL_WORKSPACE_FILE`). This file holds the team UUID, the Linear member UUIDs that play the Frontend/PM and Backend roles, and the workflow-state and label UUIDs the CLI resolves symbolic names against. Without it, every command that needs the team, members, states, or labels will refuse to run.
18
+ Before running any `linear.mjs` command, verify that the per-user workspace config exists at `~/.config/linctl/workspace.json` (override path with `$LINCTL_WORKSPACE_FILE`). This file holds **every team** in the workspace (each with its own UUID plus its workflow-state and label UUIDs — states differ per team), an optional `defaultTeam`, and the Linear member UUIDs that play the Frontend/PM and Backend roles. Without it, every command that needs the team, members, states, or labels will refuse to run.
19
+
20
+ > **Multi-team workspaces:** `workspace.json` registers all teams, but `states`/`labels` are per-team (each team's `Todo` is a distinct UUID). Which team a command targets is resolved in this order: the **`--team <key|name|uuid>`** flag → the **`LINCTL_DEFAULT_TEAM`** env var (a per-repo default — set it via direnv/`.envrc` or your shell so every command in a repo targets that team) → the **`defaultTeam`** field in `workspace.json`. If none resolve, `--team` is **required** on team-scoped commands; workspace-wide commands (e.g. `list-initiatives`) work without a team. A legacy config (predating per-team support, i.e. with no `defaultTeam` key and top-level `states`/`labels`) still works — it falls back to the first registered team — but re-run `init` to migrate it to the per-team schema.
19
21
 
20
22
  ```bash
21
23
  [ -f "${LINCTL_WORKSPACE_FILE:-$HOME/.config/linctl/workspace.json}" ] && echo "ok" || echo "missing"
@@ -27,7 +29,7 @@ Before running any `linear.mjs` command, verify that the per-user workspace conf
27
29
  node linear.mjs init
28
30
  ```
29
31
 
30
- `init` calls Linear's GraphQL API, lists the workspace's members, and prompts the user to designate (1) the Frontend/PM lead and (2) the Backend lead by number. It then writes `~/.config/linctl/workspace.json`. The config is read fresh on every invocation — no re-sourcing needed.
32
+ `init` calls Linear's GraphQL API, lists the workspace's members, and prompts the user to designate (1) the Frontend/PM lead and (2) the Backend lead by number, then (3) an optional default team key (blank = no default, so `--team` is required on each team-scoped call). It registers **all** teams with their per-team states/labels and writes `~/.config/linctl/workspace.json`. The config is read fresh on every invocation — no re-sourcing needed.
31
33
 
32
34
  ### Check 2 — Linear MCP server installed and authorized
33
35
 
@@ -187,6 +189,7 @@ node linear.mjs help # full command list
187
189
  The CLI resolves friendly names against `workspace.json`, so you rarely need raw UUIDs:
188
190
 
189
191
  ```
192
+ --team ACME | "Acme Corp" (team key or name) (or a UUID)
190
193
  --state todo | backlog | "in progress" | "in review" | done | canceled (or a UUID)
191
194
  --assignee frontend | backend (or a UUID)
192
195
  --labels bug,frontend,feature (comma-separated label names) (or UUIDs)
@@ -194,7 +197,7 @@ The CLI resolves friendly names against `workspace.json`, so you rarely need raw
194
197
  --cycle current (the active cycle) (or a UUID)
195
198
  ```
196
199
 
197
- Any value that's already a UUID is passed through untouched. Project and milestone IDs are still UUIDs (pass them with `--project` / `--milestone`).
200
+ `--team` selects which team a team-scoped command runs against, and `--state`/`--labels` then resolve against **that team's** states and labels. When `--team` is omitted it falls back to `$LINCTL_DEFAULT_TEAM` (a per-repo default) and then to `workspace.json`'s `defaultTeam`; if none are set you'll get an error listing the registered team keys. Workspace-wide commands (initiatives) don't need a team. Any value that's already a UUID is passed through untouched. Project and milestone IDs are still UUIDs (pass them with `--project` / `--milestone`).
198
201
 
199
202
  ### Creating Issues
200
203
 
@@ -73,39 +73,111 @@ function readMaybeFile(v, key) {
73
73
  // --- Workspace config ------------------------------------------------------
74
74
 
75
75
  /**
76
+ * @typedef {{ id: string, key: string, name: string, states?: Record<string, string>, labels?: Record<string, string> }} TeamEntry
76
77
  * @typedef {{
77
78
  * teamId: string,
79
+ * teams: TeamEntry[],
78
80
  * members: { frontend?: string, backend?: string, frontendName?: string, backendName?: string },
79
81
  * states: Record<string, string>,
80
82
  * labels: Record<string, string>,
81
83
  * }} Config
82
84
  */
83
85
 
84
- /** @returns {Config | null} */
85
- function loadConfig() {
86
+ /**
87
+ * Resolve which team a command runs against. Precedence: the `--team` flag
88
+ * (key, name, or UUID), then `$LINCTL_DEFAULT_TEAM` (a per-repo default, set via
89
+ * direnv or the shell), then the config's `defaultTeam`. A config that predates
90
+ * per-team support (no `defaultTeam` key at all) and no env override falls back
91
+ * to the first team for compatibility. Returns null only when there is genuinely
92
+ * no default and no `--team` — the caller defers the error until `cfg.teamId` is
93
+ * actually read.
94
+ * @param {TeamEntry[]} teams
95
+ * @param {string|undefined} ref
96
+ * @param {string|null|undefined} fileDefault
97
+ * @returns {TeamEntry | null}
98
+ */
99
+ function selectTeam(teams, ref, fileDefault) {
100
+ /** @param {string} val */
101
+ const find = (val) => {
102
+ const v = String(val).toLowerCase();
103
+ return teams.find(
104
+ (t) => t.id === val || t.key?.toLowerCase() === v || t.name?.toLowerCase() === v,
105
+ );
106
+ };
107
+ if (ref) {
108
+ const t = find(ref);
109
+ if (!t) {
110
+ fail(`unknown team "${ref}". Registered: ${teams.map((x) => x.key).join(", ") || "(none)"}`);
111
+ }
112
+ return t ?? null;
113
+ }
114
+ const envDefault = process.env.LINCTL_DEFAULT_TEAM?.trim();
115
+ if (envDefault) {
116
+ const t = find(envDefault);
117
+ if (!t) {
118
+ fail(`LINCTL_DEFAULT_TEAM="${envDefault}" is not among the registered teams.`);
119
+ }
120
+ return t ?? null;
121
+ }
122
+ if (fileDefault) {
123
+ const t = find(fileDefault);
124
+ if (!t) fail(`configured defaultTeam "${fileDefault}" is not among the registered teams.`);
125
+ return t ?? null;
126
+ }
127
+ if (fileDefault === undefined) return teams[0] ?? null; // legacy config: no explicit default
128
+ return null; // defaultTeam === null and no env → no default; --team is required
129
+ }
130
+
131
+ /** @param {TeamEntry[]} teams */
132
+ function teamSelectionError(teams) {
133
+ const keys = teams.map((t) => t.key).join(", ") || "(none registered)";
134
+ return (
135
+ `no team selected for this command.\n` +
136
+ ` Pass --team <key> (one of: ${keys}),\n` +
137
+ ` set LINCTL_DEFAULT_TEAM in your shell/repo (per-repo default),\n` +
138
+ ` or set "defaultTeam" in ${WORKSPACE_FILE}.`
139
+ );
140
+ }
141
+
142
+ /** @param {string} [teamRef] @returns {Config | null} */
143
+ function loadConfig(teamRef) {
86
144
  if (!existsSync(WORKSPACE_FILE)) return null;
87
145
  const raw = JSON.parse(readFileSync(WORKSPACE_FILE, "utf8"));
146
+ /** @type {TeamEntry[]} */
88
147
  const teams = raw.teams ?? [];
89
148
  const roles = raw.roles ?? {};
90
- return {
91
- teamId: teams[0]?.id ?? "",
149
+ const sel = selectTeam(teams, teamRef, "defaultTeam" in raw ? raw.defaultTeam : undefined);
150
+ /** @type {Config} */
151
+ const cfg = {
152
+ // @ts-ignore — teamId is installed as a lazy getter below.
153
+ teams,
92
154
  members: {
93
155
  frontend: roles.frontend_lead?.id,
94
156
  backend: roles.backend_lead?.id,
95
157
  frontendName: roles.frontend_lead?.name,
96
158
  backendName: roles.backend_lead?.name,
97
159
  },
98
- states: raw.states ?? {},
99
- labels: raw.labels ?? {},
160
+ states: sel?.states ?? raw.states ?? {},
161
+ labels: sel?.labels ?? raw.labels ?? {},
100
162
  };
163
+ // Lazy: workspace-wide commands (e.g. list-initiatives) never read teamId, so
164
+ // they work without --team. Team-scoped commands trigger the error on access.
165
+ Object.defineProperty(cfg, "teamId", {
166
+ enumerable: true,
167
+ get() {
168
+ if (sel?.id) return sel.id;
169
+ return fail(teamSelectionError(teams));
170
+ },
171
+ });
172
+ return cfg;
101
173
  }
102
174
 
103
- /** @returns {Config} */
104
- function requireConfig() {
105
- const cfg = loadConfig();
106
- if (!cfg || !cfg.teamId) {
175
+ /** @param {string} [teamRef] @returns {Config} */
176
+ function requireConfig(teamRef) {
177
+ const cfg = loadConfig(teamRef);
178
+ if (!cfg) {
107
179
  fail(
108
- `workspace config not found or incomplete at ${WORKSPACE_FILE}\n` +
180
+ `workspace config not found at ${WORKSPACE_FILE}\n` +
109
181
  ` Run \`node linear.mjs init\` to generate it.`,
110
182
  );
111
183
  }
@@ -970,12 +1042,46 @@ async function cmdApi(args, v, cfg) {
970
1042
 
971
1043
  // --- init (interactive workspace bootstrap) --------------------------------
972
1044
 
1045
+ const WANTED_STATES = ["Backlog", "Todo", "In Progress", "In Review", "Done", "Canceled"];
1046
+ const WANTED_LABELS = ["discovery", "tech-debt", "backend", "frontend", "db", "bug", "feature", "improvement"];
1047
+
1048
+ /** Filter a team's raw state nodes down to the canonical names we track.
1049
+ * @param {Array<{id:string,name:string}>} nodes */
1050
+ function pickStates(nodes) {
1051
+ /** @type {Record<string, string>} */
1052
+ const states = {};
1053
+ const lookup = new Map(nodes.map((s) => [s.name.toLowerCase(), s]));
1054
+ for (const name of WANTED_STATES) {
1055
+ const s = lookup.get(name.toLowerCase());
1056
+ if (s) states[name] = s.id;
1057
+ }
1058
+ return states;
1059
+ }
1060
+
1061
+ /** Filter a team's raw label nodes down to the canonical names we track.
1062
+ * @param {Array<{id:string,name:string}>} nodes */
1063
+ function pickLabels(nodes) {
1064
+ const norm = (s) => s.toLowerCase().replace(/-/g, " ").trim();
1065
+ /** @type {Record<string, string>} */
1066
+ const labels = {};
1067
+ const lookup = new Map(nodes.map((l) => [norm(l.name), l]));
1068
+ for (const name of WANTED_LABELS) {
1069
+ const l = lookup.get(norm(name));
1070
+ if (l) labels[name] = l.id;
1071
+ }
1072
+ return labels;
1073
+ }
1074
+
973
1075
  async function cmdInit() {
974
1076
  if (!API_KEY) fail("LINEAR_API_KEY is not set. Set it first, then re-run `init`.");
975
1077
 
976
1078
  console.error("Fetching workspace data from Linear...");
1079
+ // Pull each team's own states + labels — they differ per team (e.g. each
1080
+ // team's "Todo" is a distinct workflow-state UUID).
977
1081
  const bootstrap = await gql(
978
- `{ teams(first: 50) { nodes { id key name parent { id } } }
1082
+ `{ teams(first: 50) { nodes { id key name parent { id }
1083
+ states { nodes { id name type } }
1084
+ labels { nodes { id name } } } }
979
1085
  users(first: 250) { nodes { id name email displayName } } }`,
980
1086
  );
981
1087
 
@@ -987,17 +1093,8 @@ async function cmdInit() {
987
1093
  process.exit(1);
988
1094
  }
989
1095
 
990
- // Prefer a parent team (no parent of its own) so states/labels come from the
991
- // umbrella team rather than a sub-team.
992
- const parents = teams.filter((t) => !t.parent);
993
- const chosenTeam = parents[0] ?? teams[0];
994
-
995
- const teamExtra = await gql(
996
- `query TeamExtra($id: String!) {
997
- team(id: $id) { states { nodes { id name type } } labels { nodes { id name } } }
998
- }`,
999
- { id: chosenTeam.id },
1000
- );
1096
+ // Parent (umbrella) teams first so the list reads top-down.
1097
+ const teamsSorted = [...teams].sort((a, b) => (a.parent ? 1 : 0) - (b.parent ? 1 : 0));
1001
1098
 
1002
1099
  // Filter out Linear's integration/bot users.
1003
1100
  const users = (bootstrap?.data?.users?.nodes ?? []).filter(
@@ -1014,6 +1111,10 @@ async function cmdInit() {
1014
1111
  const rl = createInterface({ input, output });
1015
1112
  const frontendIdx = await rl.question("Which member is the Frontend/PM lead? Enter number: ");
1016
1113
  const backendIdx = await rl.question("Which member is the Backend lead? Enter number: ");
1114
+ console.error("\nTeams: " + teamsSorted.map((t) => t.key).join(", "));
1115
+ const defaultTeamAns = (
1116
+ await rl.question("Default team key (blank = none; --team required each call): ")
1117
+ ).trim();
1017
1118
  rl.close();
1018
1119
 
1019
1120
  /** @param {string} idxStr */
@@ -1029,37 +1130,27 @@ async function cmdInit() {
1029
1130
  const frontend = pick(frontendIdx);
1030
1131
  const backend = pick(backendIdx);
1031
1132
 
1032
- const WANTED_STATES = ["Backlog", "Todo", "In Progress", "In Review", "Done", "Canceled"];
1033
- const WANTED_LABELS = ["discovery", "tech-debt", "backend", "frontend", "db", "bug", "feature", "improvement"];
1034
-
1035
- const stateNodes = teamExtra?.data?.team?.states?.nodes ?? [];
1036
- const labelNodes = teamExtra?.data?.team?.labels?.nodes ?? [];
1037
-
1038
- /** @type {Record<string, string>} */
1039
- const states = {};
1040
- const stateLookup = new Map(stateNodes.map((s) => [s.name.toLowerCase(), s]));
1041
- for (const name of WANTED_STATES) {
1042
- const s = stateLookup.get(name.toLowerCase());
1043
- if (s) states[name] = s.id;
1044
- }
1045
-
1046
- const norm = (s) => s.toLowerCase().replace(/-/g, " ").trim();
1047
- /** @type {Record<string, string>} */
1048
- const labels = {};
1049
- const labelLookup = new Map(labelNodes.map((l) => [norm(l.name), l]));
1050
- for (const name of WANTED_LABELS) {
1051
- const l = labelLookup.get(norm(name));
1052
- if (l) labels[name] = l.id;
1133
+ let defaultTeam = null;
1134
+ if (defaultTeamAns) {
1135
+ const match = teamsSorted.find(
1136
+ (t) =>
1137
+ t.key?.toLowerCase() === defaultTeamAns.toLowerCase() ||
1138
+ t.name?.toLowerCase() === defaultTeamAns.toLowerCase(),
1139
+ );
1140
+ if (!match) fail(`"${defaultTeamAns}" is not one of the workspace teams.`);
1141
+ defaultTeam = match.key;
1053
1142
  }
1054
1143
 
1055
- // Parent teams first so teams[0] is the umbrella team.
1056
- const teamsSorted = [...teams].sort((a, b) => (a.parent ? 1 : 0) - (b.parent ? 1 : 0));
1057
-
1058
1144
  const cfg = {
1059
- teams: teamsSorted.map((t) => ({ id: t.id, key: t.key, name: t.name })),
1145
+ teams: teamsSorted.map((t) => ({
1146
+ id: t.id,
1147
+ key: t.key,
1148
+ name: t.name,
1149
+ states: pickStates(t.states?.nodes ?? []),
1150
+ labels: pickLabels(t.labels?.nodes ?? []),
1151
+ })),
1152
+ defaultTeam,
1060
1153
  roles: { frontend_lead: frontend, backend_lead: backend },
1061
- states,
1062
- labels,
1063
1154
  };
1064
1155
 
1065
1156
  mkdirSync(dirname(WORKSPACE_FILE), { recursive: true });
@@ -1119,6 +1210,13 @@ const USAGE = `Linear CLI — node linear.mjs <command> [args] [--flags]
1119
1210
  Setup:
1120
1211
  init Interactive: fetch workspace, write ~/.config/linctl/workspace.json
1121
1212
 
1213
+ Team selection:
1214
+ --team <key|name|uuid> Run a team-scoped command against this team
1215
+ (e.g. --team ACME). Precedence: --team >
1216
+ $LINCTL_DEFAULT_TEAM (per-repo default) >
1217
+ workspace.json "defaultTeam". Workspace-wide
1218
+ commands (initiatives) don't need a team.
1219
+
1122
1220
  Issues:
1123
1221
  create-issue --title T [--description D|--description-file F] [--priority P]
1124
1222
  [--state S] [--assignee frontend|backend] [--labels a,b]
@@ -1187,6 +1285,7 @@ async function main() {
1187
1285
  priority: { type: "string" },
1188
1286
  state: { type: "string" },
1189
1287
  assignee: { type: "string" },
1288
+ team: { type: "string" },
1190
1289
  labels: { type: "string" },
1191
1290
  estimate: { type: "string" },
1192
1291
  project: { type: "string" },
@@ -1221,7 +1320,7 @@ async function main() {
1221
1320
  const handler = COMMANDS[command];
1222
1321
  if (!handler) fail(`unknown command "${command}". Run \`node linear.mjs help\`.`);
1223
1322
 
1224
- const cfg = NO_CONFIG.has(command) ? /** @type {Config} */ ({}) : requireConfig();
1323
+ const cfg = NO_CONFIG.has(command) ? /** @type {Config} */ ({}) : requireConfig(values.team);
1225
1324
  await handler(args, values, cfg);
1226
1325
  }
1227
1326