@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.3

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.
@@ -0,0 +1,484 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { readFileSync } from "node:fs";
3
+ import { runChild as defaultRunChild } from "../cli/primitives.mjs";
4
+ import { parseJsonText } from "./review-threads.mjs";
5
+ import { parseRepoSlug } from "./repo-slug.mjs";
6
+
7
+ /**
8
+ * Core `gh issue` operations, extracted from the thin CLI wrappers under
9
+ * `scripts/github/*.mjs` (view/create/edit/comment/list-issue,
10
+ * detect-linked-issue-pr) so both the CLI scripts and the GitHub tracker
11
+ * adapter (`../tracker/github-adapter.mjs`) call one implementation instead
12
+ * of duplicating gh-command construction. The CLI scripts keep their own
13
+ * arg parsing/USAGE/runCli; this module owns the actual `gh` calls and output
14
+ * shaping. Mirrors the existing `../projects/move-queue-item.mjs` /
15
+ * `../projects/list-queue-items.mjs` split (core logic + thin CLI wrapper).
16
+ */
17
+
18
+ const ISSUE_URL_NUMBER_PATTERN = /\/issues\/(\d+)(?:\D|$)/u;
19
+
20
+ // ── view-issue ──────────────────────────────────────────────────────────
21
+
22
+ export const VIEW_ISSUE_DEFAULT_FIELDS = "number,title,body,state,author,labels,url,createdAt,updatedAt";
23
+
24
+ export async function viewIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
25
+ const fields = options.fields ?? VIEW_ISSUE_DEFAULT_FIELDS;
26
+ const result = await run(
27
+ ghCommand,
28
+ ["issue", "view", String(options.issue), "--repo", options.repo, "--json", fields],
29
+ env,
30
+ );
31
+ if (result.code !== 0) {
32
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
33
+ throw new Error(`gh issue view failed: ${detail}`);
34
+ }
35
+ const issue = parseJsonText(result.stdout, { label: "gh issue view" });
36
+ if (issue === null || typeof issue !== "object" || Array.isArray(issue)) {
37
+ throw new Error("gh issue view did not return a JSON object");
38
+ }
39
+ return { ok: true, issue };
40
+ }
41
+
42
+ // ── create-issue ────────────────────────────────────────────────────────
43
+
44
+ // Build the `gh issue create` args. A --body-file path is forwarded straight
45
+ // to gh so large bodies avoid command-length limits.
46
+ export function buildCreateArgs(options) {
47
+ const args = ["issue", "create", "--repo", options.repo, "--title", options.title];
48
+ if (options.body !== undefined) {
49
+ args.push("--body", options.body);
50
+ } else {
51
+ args.push("--body-file", options.bodyFile);
52
+ }
53
+ if (options.milestone !== undefined) {
54
+ args.push("--milestone", options.milestone);
55
+ }
56
+ for (const l of options.labels ?? []) {
57
+ args.push("--label", l);
58
+ }
59
+ for (const u of options.assignees ?? []) {
60
+ args.push("--assignee", u);
61
+ }
62
+ return args;
63
+ }
64
+
65
+ export async function createIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
66
+ const args = buildCreateArgs(options);
67
+ const result = await run(ghCommand, args, env);
68
+ if (result.code !== 0) {
69
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
70
+ throw new Error(`gh issue create failed: ${detail}`);
71
+ }
72
+ // gh prints the created issue URL to stdout.
73
+ const url = (result.stdout ?? "").trim();
74
+ const match = ISSUE_URL_NUMBER_PATTERN.exec(url);
75
+ if (!match) {
76
+ throw new Error(`gh issue create returned no parseable issue URL: ${url || "<empty>"}`);
77
+ }
78
+ return { ok: true, issueNumber: Number(match[1]), url };
79
+ }
80
+
81
+ // ── edit-issue ──────────────────────────────────────────────────────────
82
+
83
+ export async function resolveEditBody(options) {
84
+ if (options.bodyFile === undefined) return options.body;
85
+ // Stdin (fd 0): fs/promises readFile does NOT accept an integer fd, so read
86
+ // it synchronously via the callback-style API (which does). A real path
87
+ // stays on the async promise read.
88
+ const body =
89
+ options.bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(options.bodyFile, "utf8");
90
+ if (body.trim().length === 0) {
91
+ throw new Error(`--body-file ${options.bodyFile} is empty`);
92
+ }
93
+ return body;
94
+ }
95
+
96
+ // Build the `gh issue edit` args and the parallel `edited` list (which fields
97
+ // were touched) so callers get a stable summary without re-reading the issue.
98
+ export async function buildEditArgs(options) {
99
+ const args = ["issue", "edit", String(options.issue), "--repo", options.repo];
100
+ const edited = [];
101
+ if (options.title !== undefined) {
102
+ args.push("--title", options.title);
103
+ edited.push("title");
104
+ }
105
+ const body = await resolveEditBody(options);
106
+ if (body !== undefined) {
107
+ if (options.bodyFile !== undefined && options.bodyFile !== "-") {
108
+ args.push("--body-file", options.bodyFile);
109
+ } else {
110
+ args.push("--body", body);
111
+ }
112
+ edited.push("body");
113
+ }
114
+ for (const u of options.addAssignees ?? []) {
115
+ args.push("--add-assignee", u);
116
+ }
117
+ if ((options.addAssignees ?? []).length > 0) edited.push("add-assignee");
118
+ for (const u of options.removeAssignees ?? []) {
119
+ args.push("--remove-assignee", u);
120
+ }
121
+ if ((options.removeAssignees ?? []).length > 0) edited.push("remove-assignee");
122
+ if (options.milestone !== undefined) {
123
+ args.push("--milestone", options.milestone);
124
+ edited.push("milestone");
125
+ }
126
+ return { args, edited };
127
+ }
128
+
129
+ export async function editIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
130
+ const { args, edited } = await buildEditArgs(options);
131
+ const result = await run(ghCommand, args, env);
132
+ if (result.code !== 0) {
133
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
134
+ throw new Error(`gh issue edit failed: ${detail}`);
135
+ }
136
+ return { ok: true, repo: options.repo, issue: options.issue, edited };
137
+ }
138
+
139
+ // ── comment-issue ───────────────────────────────────────────────────────
140
+
141
+ export async function resolveCommentBody(options) {
142
+ if (options.bodyFile === undefined) {
143
+ if (options.body.trim().length === 0) {
144
+ throw new Error("--body must not be empty");
145
+ }
146
+ return options.body;
147
+ }
148
+ const source = options.bodyFile === "-" ? 0 : options.bodyFile;
149
+ const body = await readFile(source, "utf8");
150
+ if (body.trim().length === 0) {
151
+ throw new Error(`--body-file ${options.bodyFile} is empty`);
152
+ }
153
+ return body;
154
+ }
155
+
156
+ export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
157
+ const body = await resolveCommentBody(options);
158
+ const result = await run(
159
+ ghCommand,
160
+ ["issue", "comment", String(options.issue), "--repo", options.repo, "--body", body],
161
+ env,
162
+ );
163
+ if (result.code !== 0) {
164
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
165
+ throw new Error(`gh issue comment failed: ${detail}`);
166
+ }
167
+ const commentUrl = result.stdout
168
+ .split(/\r?\n/u)
169
+ .map((line) => line.trim())
170
+ .filter((line) => line.length > 0)
171
+ .pop() ?? null;
172
+ if (commentUrl === null || !/^https?:\/\//u.test(commentUrl)) {
173
+ throw new Error(`gh issue comment did not return a comment URL (got: ${result.stdout.trim() || "<empty>"})`);
174
+ }
175
+ return { ok: true, repo: options.repo, issue: options.issue, commentUrl };
176
+ }
177
+
178
+ // ── list-issues ─────────────────────────────────────────────────────────
179
+
180
+ // Returns a well-typed issue, or null if the gh entry is missing/invalid in
181
+ // any required field.
182
+ export function normalizeIssue(raw) {
183
+ if (!Number.isInteger(raw?.number) || typeof raw?.title !== "string" || typeof raw?.state !== "string") {
184
+ return null;
185
+ }
186
+ return {
187
+ number: raw.number,
188
+ title: raw.title,
189
+ // gh reports issue state UPPERCASE (OPEN/CLOSED); normalize to lowercase.
190
+ state: raw.state.toLowerCase(),
191
+ labels: Array.isArray(raw?.labels)
192
+ ? raw.labels.map((l) => (typeof l?.name === "string" ? l.name : null)).filter((n) => n !== null)
193
+ : [],
194
+ };
195
+ }
196
+
197
+ export async function listIssues(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
198
+ const args = [
199
+ "issue",
200
+ "list",
201
+ "--repo",
202
+ options.repo,
203
+ "--state",
204
+ options.state ?? "open",
205
+ "--limit",
206
+ String(options.limit ?? 30),
207
+ "--json",
208
+ "number,title,state,labels",
209
+ ];
210
+ for (const label of options.labels ?? []) {
211
+ args.push("--label", label);
212
+ }
213
+ const result = await run(ghCommand, args, env);
214
+ if (result.code !== 0) {
215
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
216
+ throw new Error(`gh issue list failed: ${detail}`);
217
+ }
218
+ const payload = parseJsonText(result.stdout, { label: "gh issue list" });
219
+ if (!Array.isArray(payload)) {
220
+ throw new Error("gh issue list did not return a JSON array");
221
+ }
222
+ return { ok: true, issues: payload.map(normalizeIssue).filter((issue) => issue !== null) };
223
+ }
224
+
225
+ // ── detect-linked-issue-pr ──────────────────────────────────────────────
226
+
227
+ export const LINKED_ISSUE_PR_QUERY = [
228
+ "query($owner:String!, $name:String!, $issue:Int!, $after:String) {",
229
+ " repository(owner:$owner, name:$name) {",
230
+ " issue(number:$issue) {",
231
+ " timelineItems(first:100, after:$after, itemTypes:[CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) {",
232
+ " pageInfo {",
233
+ " hasNextPage",
234
+ " endCursor",
235
+ " }",
236
+ " nodes {",
237
+ " __typename",
238
+ " ... on ConnectedEvent {",
239
+ " createdAt",
240
+ " subject {",
241
+ " __typename",
242
+ " ... on PullRequest {",
243
+ " number",
244
+ " state",
245
+ " url",
246
+ " repository { nameWithOwner }",
247
+ " }",
248
+ " }",
249
+ " }",
250
+ " ... on CrossReferencedEvent {",
251
+ " createdAt",
252
+ " willCloseTarget",
253
+ " source {",
254
+ " __typename",
255
+ " ... on PullRequest {",
256
+ " number",
257
+ " state",
258
+ " url",
259
+ " repository { nameWithOwner }",
260
+ " }",
261
+ " }",
262
+ " }",
263
+ " }",
264
+ " }",
265
+ " }",
266
+ " }",
267
+ "}",
268
+ ].join("\n");
269
+
270
+ function buildLinkedPrQueryArgs({ owner, name, issue, after }) {
271
+ const args = [
272
+ "api",
273
+ "graphql",
274
+ "--field",
275
+ `owner=${owner}`,
276
+ "--field",
277
+ `name=${name}`,
278
+ "-F",
279
+ `issue=${issue}`,
280
+ "--field",
281
+ `query=${LINKED_ISSUE_PR_QUERY}`,
282
+ ];
283
+ if (typeof after === "string" && after.length > 0) {
284
+ args.push("--field", `after=${after}`);
285
+ }
286
+ return args;
287
+ }
288
+
289
+ function readLinkedPrTimelineConnection(payload) {
290
+ const connection = payload?.data?.repository?.issue?.timelineItems;
291
+ if (!connection || typeof connection !== "object") {
292
+ throw new Error("Invalid linked-PR GraphQL payload: missing data.repository.issue.timelineItems");
293
+ }
294
+ const nodes = Array.isArray(connection.nodes) ? connection.nodes : [];
295
+ const pageInfo = connection.pageInfo ?? {};
296
+ return {
297
+ nodes,
298
+ hasNextPage: Boolean(pageInfo.hasNextPage),
299
+ endCursor: typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null,
300
+ };
301
+ }
302
+
303
+ function normalizeLinkedPrNode(node) {
304
+ if (!node || typeof node !== "object") {
305
+ return null;
306
+ }
307
+ if (node.__typename === "ConnectedEvent") {
308
+ return {
309
+ eventType: "CONNECTED_EVENT",
310
+ eventCreatedAt: node.createdAt,
311
+ pr: node.subject,
312
+ };
313
+ }
314
+ if (node.__typename === "CrossReferencedEvent") {
315
+ // Only a cross-reference that will CLOSE this issue owns its board status.
316
+ // A bare body-mention (willCloseTarget:false, e.g. "part of #X") must not
317
+ // create board-ownership linkage (#1130).
318
+ if (node.willCloseTarget !== true) {
319
+ return null;
320
+ }
321
+ return {
322
+ eventType: "CROSS_REFERENCED_EVENT",
323
+ eventCreatedAt: node.createdAt,
324
+ pr: node.source,
325
+ };
326
+ }
327
+ return null;
328
+ }
329
+
330
+ function compareStableStrings(left, right) {
331
+ if (left === right) {
332
+ return 0;
333
+ }
334
+ return left < right ? -1 : 1;
335
+ }
336
+
337
+ function normalizeRepoSlugForComparison(repo) {
338
+ return typeof repo === "string" ? repo.trim().toLowerCase() : "";
339
+ }
340
+
341
+ function normalizeOpenSameRepoCandidate(candidate, repo) {
342
+ const pr = candidate?.pr;
343
+ const number = pr?.number;
344
+ const state = pr?.state;
345
+ const url = pr?.url;
346
+ const nameWithOwner = pr?.repository?.nameWithOwner;
347
+ if (!Number.isInteger(number) || number <= 0) {
348
+ return null;
349
+ }
350
+ if (
351
+ state !== "OPEN"
352
+ || normalizeRepoSlugForComparison(nameWithOwner) !== normalizeRepoSlugForComparison(repo)
353
+ ) {
354
+ return null;
355
+ }
356
+ const createdAtMs = Date.parse(candidate.eventCreatedAt);
357
+ if (!Number.isFinite(createdAtMs)) {
358
+ return null;
359
+ }
360
+ return {
361
+ prNumber: number,
362
+ prUrl: typeof url === "string" ? url : null,
363
+ eventType: candidate.eventType,
364
+ eventCreatedAt: typeof candidate.eventCreatedAt === "string" ? candidate.eventCreatedAt : null,
365
+ createdAtMs,
366
+ };
367
+ }
368
+
369
+ function normalizeClosedUnmergedSameRepoCandidate(candidate, repo) {
370
+ const pr = candidate?.pr;
371
+ const number = pr?.number;
372
+ const state = pr?.state;
373
+ const url = pr?.url;
374
+ const nameWithOwner = pr?.repository?.nameWithOwner;
375
+ if (!Number.isInteger(number) || number <= 0) {
376
+ return null;
377
+ }
378
+ if (
379
+ state !== "CLOSED"
380
+ || normalizeRepoSlugForComparison(nameWithOwner) !== normalizeRepoSlugForComparison(repo)
381
+ ) {
382
+ return null;
383
+ }
384
+ const createdAtMs = Date.parse(candidate.eventCreatedAt);
385
+ if (!Number.isFinite(createdAtMs)) {
386
+ return null;
387
+ }
388
+ return {
389
+ prNumber: number,
390
+ prUrl: typeof url === "string" ? url : null,
391
+ eventType: candidate.eventType,
392
+ eventCreatedAt: typeof candidate.eventCreatedAt === "string" ? candidate.eventCreatedAt : null,
393
+ createdAtMs,
394
+ };
395
+ }
396
+
397
+ export function selectLinkedIssuePr(candidates) {
398
+ if (!Array.isArray(candidates) || candidates.length === 0) {
399
+ return null;
400
+ }
401
+ const sorted = [...candidates].sort((left, right) => {
402
+ const leftPriority = left.eventType === "CONNECTED_EVENT" ? 0 : 1;
403
+ const rightPriority = right.eventType === "CONNECTED_EVENT" ? 0 : 1;
404
+ if (leftPriority !== rightPriority) {
405
+ return leftPriority - rightPriority;
406
+ }
407
+ if (left.createdAtMs !== right.createdAtMs) {
408
+ return right.createdAtMs - left.createdAtMs;
409
+ }
410
+ if (left.prNumber !== right.prNumber) {
411
+ return right.prNumber - left.prNumber;
412
+ }
413
+ return compareStableStrings(String(left.prUrl ?? ""), String(right.prUrl ?? ""));
414
+ });
415
+ return sorted[0] ?? null;
416
+ }
417
+
418
+ export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
419
+ const { owner, name } = parseRepoSlug(repo);
420
+ const candidates = [];
421
+ const closedUnmergedCandidates = [];
422
+ let after = null;
423
+ while (true) {
424
+ const result = await runChild(
425
+ ghCommand,
426
+ buildLinkedPrQueryArgs({ owner, name, issue, after }),
427
+ env,
428
+ );
429
+ if (result.code !== 0) {
430
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
431
+ throw new Error(`gh command failed: ${detail}`);
432
+ }
433
+ const payload = parseJsonText(result.stdout);
434
+ const { nodes, hasNextPage, endCursor } = readLinkedPrTimelineConnection(payload);
435
+ for (const node of nodes) {
436
+ const normalizedNode = normalizeLinkedPrNode(node);
437
+ if (!normalizedNode) {
438
+ continue;
439
+ }
440
+ const normalizedCandidate = normalizeOpenSameRepoCandidate(normalizedNode, repo);
441
+ if (normalizedCandidate) {
442
+ candidates.push(normalizedCandidate);
443
+ }
444
+ const closedUnmergedCandidate = normalizeClosedUnmergedSameRepoCandidate(normalizedNode, repo);
445
+ if (closedUnmergedCandidate) {
446
+ closedUnmergedCandidates.push(closedUnmergedCandidate);
447
+ }
448
+ }
449
+ if (!hasNextPage) {
450
+ break;
451
+ }
452
+ if (!endCursor) {
453
+ throw new Error("Invalid linked-PR GraphQL payload: pageInfo.hasNextPage is true but endCursor is missing");
454
+ }
455
+ after = endCursor;
456
+ }
457
+ const selected = selectLinkedIssuePr(candidates);
458
+ const selectedClosedUnmerged = selectLinkedIssuePr(closedUnmergedCandidates);
459
+ if (!selected) {
460
+ return {
461
+ ok: true,
462
+ repo,
463
+ issue,
464
+ hasOpenLinkedPr: false,
465
+ prNumber: null,
466
+ prUrl: null,
467
+ hasPriorClosedUnmergedPr: selectedClosedUnmerged !== null,
468
+ priorClosedUnmergedPrNumber: selectedClosedUnmerged?.prNumber ?? null,
469
+ priorClosedUnmergedPrUrl: selectedClosedUnmerged?.prUrl ?? null,
470
+ };
471
+ }
472
+ return {
473
+ ok: true,
474
+ repo,
475
+ issue,
476
+ hasOpenLinkedPr: true,
477
+ prNumber: selected.prNumber,
478
+ prUrl: selected.prUrl,
479
+ selection: {
480
+ eventType: selected.eventType,
481
+ eventCreatedAt: selected.eventCreatedAt,
482
+ },
483
+ };
484
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Shared deterministic single-contributor ownership classification for
3
+ * issue/PR assignees.
4
+ *
5
+ * Owner: packages/core — reusable pure logic consumed by the startup
6
+ * resolver (`resolve-dev-loop-startup.mjs`) and the Next Up pickup source
7
+ * (`resolve-active-board-item.mjs`). `classifyOwnership` never shells out —
8
+ * callers fetch assignees and the viewer's login via `gh`, then classify here.
9
+ */
10
+ import { isCopilotLogin } from "./copilot-helpers.mjs";
11
+
12
+ export const OWNERSHIP_STATE = Object.freeze({
13
+ ASSIGNED_TO_ME: "assigned_to_me",
14
+ ASSIGNED_TO_OTHER: "assigned_to_other",
15
+ ASSIGNED_TO_COPILOT: "assigned_to_copilot",
16
+ UNASSIGNED: "unassigned",
17
+ });
18
+
19
+ /**
20
+ * Classify assignee ownership of an issue/PR relative to the viewer.
21
+ *
22
+ * Copilot assignment is checked FIRST and short-circuits before any human
23
+ * comparison — the viewer login is never required to detect it, so a
24
+ * copilot-assigned artifact is unaffected by viewer-login resolution
25
+ * failures (matches the existing, unchanged Copilot-first flow).
26
+ *
27
+ * `assigned_to_me` requires the viewer to be the SOLE human assignee.
28
+ * `gh issue/pr edit --add-assignee` is not compare-and-swap, so two loopers
29
+ * racing to claim the same unassigned item can both end up co-assigned;
30
+ * membership-based classification would wave both through. A viewer
31
+ * co-assigned alongside another human is `assigned_to_other` (contested) —
32
+ * `foreignLogins` names the OTHER humans only (never the viewer), so error
33
+ * messages stay accurate. Login comparison is case-insensitive (GitHub
34
+ * logins are case-insensitive).
35
+ *
36
+ * @param {Array<{login?: string}>} assignees
37
+ * @param {string|null} [viewerLogin] - required only when a non-copilot
38
+ * assignee is present; pass null/undefined when the caller skipped
39
+ * resolving it (empty assignees, or a copilot assignee already found).
40
+ * @returns {{ state: string, foreignLogins: string[] }}
41
+ */
42
+ export function classifyOwnership(assignees, viewerLogin = null) {
43
+ const logins = (Array.isArray(assignees) ? assignees : [])
44
+ .map((a) => a?.login)
45
+ .filter((login) => typeof login === "string" && login.length > 0);
46
+ if (logins.some(isCopilotLogin)) {
47
+ return { state: OWNERSHIP_STATE.ASSIGNED_TO_COPILOT, foreignLogins: [] };
48
+ }
49
+ if (logins.length === 0) {
50
+ return { state: OWNERSHIP_STATE.UNASSIGNED, foreignLogins: [] };
51
+ }
52
+ const viewerLoginLower = typeof viewerLogin === "string" && viewerLogin.length > 0
53
+ ? viewerLogin.toLowerCase()
54
+ : null;
55
+ const otherLogins = viewerLoginLower === null
56
+ ? logins
57
+ : logins.filter((login) => login.toLowerCase() !== viewerLoginLower);
58
+ if (viewerLoginLower !== null && otherLogins.length === 0) {
59
+ return { state: OWNERSHIP_STATE.ASSIGNED_TO_ME, foreignLogins: [] };
60
+ }
61
+ return { state: OWNERSHIP_STATE.ASSIGNED_TO_OTHER, foreignLogins: otherLogins };
62
+ }
63
+
64
+ /**
65
+ * Whether classifying these assignees requires a resolved viewer login (i.e.
66
+ * there is at least one non-copilot assignee to compare against). Lets
67
+ * callers skip the extra `gh api user` call for the common empty/copilot
68
+ * cases, which also keeps those cases immune to viewer-login resolution
69
+ * failures.
70
+ *
71
+ * @param {Array<{login?: string}>} assignees
72
+ * @returns {boolean}
73
+ */
74
+ export function ownershipNeedsViewerLogin(assignees) {
75
+ const logins = (Array.isArray(assignees) ? assignees : [])
76
+ .map((a) => a?.login)
77
+ .filter((login) => typeof login === "string" && login.length > 0);
78
+ return logins.length > 0 && !logins.some(isCopilotLogin);
79
+ }
@@ -143,15 +143,16 @@ function shellSegments(command) {
143
143
  }
144
144
 
145
145
  /**
146
- * Leading prefix a `gh pr <verb>` segment may carry before the `gh` executable:
147
- * a run of `NAME=value` env assignments, optional `command`/`env`/`exec` wrapper
148
- * words, and an absolute/relative path on the gh binary (`/usr/bin/gh`).
146
+ * Leading prefix a command segment may carry before its real executable: a run of `NAME=value`
147
+ * env assignments, optional `command`/`env`/`exec` wrapper words, and an absolute/relative path on
148
+ * the binary (`/usr/bin/gh`, `/usr/bin/git`). Shared by every classifier in this file that must
149
+ * catch its verb behind these forms (`gh pr <verb>`, `git stash`, ...).
149
150
  *
150
151
  * Note: this is a pragmatic normalizer, not a full shell tokenizer. Subshell
151
152
  * `(gh pr create)`, `{ …; }` group, `-R=value` short-flag, and backslash-escaped
152
153
  * `\gh` forms are deliberately out of scope.
153
154
  */
154
- const GH_PR_VERB_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
155
+ const SHELL_EXEC_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
155
156
 
156
157
  /**
157
158
  * Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
@@ -163,7 +164,36 @@ const GH_PR_VERB_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env
163
164
  * match — their first token is `node`, not `gh`.
164
165
  */
165
166
  function ghSubcmdVerbRegex(subcmd, verb) {
166
- return new RegExp(`^${GH_PR_VERB_PREFIX}gh\\s+${subcmd}\\s+${verb}(?:\\s|$)`, "i");
167
+ return new RegExp(`^${SHELL_EXEC_PREFIX}gh\\s+${subcmd}\\s+${verb}(?:\\s|$)`, "i");
168
+ }
169
+
170
+ /**
171
+ * A run of git global options that may appear between `git` and the subcommand: `-C <path>`,
172
+ * `-c <name>=<value>` (each consumes the following token as its value), `--git-dir=<path>`,
173
+ * `--work-tree=<path>`, or any other bare flag (`-x`, `--long-option`) that takes no value.
174
+ * Pragmatic normalizer, not a full git CLI parser.
175
+ */
176
+ const GIT_GLOBAL_OPTION_RUN =
177
+ "(?:(?:-C|-c)\\s+\\S+\\s+|--(?:git-dir|work-tree)=\\S+\\s+|--?[A-Za-z][\\w-]*\\s+)*";
178
+
179
+ /**
180
+ * Whether `command` contains a `git stash` invocation (any subcommand: bare, `push`, `pop`,
181
+ * `apply`, `save`, `list`, ...) in ANY shell segment — including behind the same env-assignment /
182
+ * `command`/`env`/`exec` wrapper / binary-path prefix (`GIT_DIR=.git git stash`, `command git
183
+ * stash`, `/usr/bin/git stash`) and git global options between `git` and `stash` (`git -C /tmp
184
+ * stash`, `git -c name=value stash pop`) that the sibling `gh` classifiers in this file already
185
+ * tolerate. Anchored per-segment, so `git stashed`, `git commit -m "git stash"`, or a path literal
186
+ * containing "git stash" never match. `refs/stash` is a single ref shared by every worktree over
187
+ * this repo's one `.git` directory, so a stash from one worktree can pop into another's — the
188
+ * PreToolUse gate blocks it outright on the target repo (see
189
+ * `skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout`).
190
+ * @param {string} command @returns {boolean}
191
+ */
192
+ export function commandContainsGitStash(command) {
193
+ const re = new RegExp(`^${SHELL_EXEC_PREFIX}git\\s+${GIT_GLOBAL_OPTION_RUN}stash(?:\\s|$)`, "i");
194
+ return command
195
+ .split(SHELL_SEGMENT_SEPARATOR)
196
+ .some((segment) => re.test(segment.trim()));
167
197
  }
168
198
 
169
199
  /** Build the `gh pr <verb>` prefix matcher — delegates to the generic subcmd matcher (DRY). */
@@ -17,7 +17,7 @@
17
17
  * - The evaluator is purely functional; no I/O or side effects
18
18
  * - Callers use evaluateConductorRouting as the single routing authority
19
19
  *
20
- * Integration boundary (see docs/conductor-routing-contract.md):
20
+ * Integration boundary (see skills/docs/conductor-routing-contract.md):
21
21
  * - This module starts after active-run identity and ownership are already resolved
22
22
  * - It consumes already-detected family-local lifecycle states as inputs
23
23
  * - It derives the routing outcome directly from states; it does not take a